From bae089e22339d8c14392ff12a6bf3c434837e571 Mon Sep 17 00:00:00 2001 From: Wanjohi <71614375+wanjohiryan@users.noreply.github.com> Date: Thu, 26 Sep 2024 21:34:42 +0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Host=20a=20relay=20on=20Het?= =?UTF-8?q?zner=20(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We are hosting a [MoQ](https://quic.video) relay on a remote (bare metal) server on Hetzner With a lot of help from @victorpahuus --- .certs/.gitignore | 3 + .certs/.terraform.lock.hcl | 61 + .certs/README.md | 24 + .certs/input.tf | 7 + .certs/main.tf | 65 + .certs/terraform.tfvars | 2 + .gitignore | 5 +- apps/www/package.json | 3 + .../src/routes/(moq)/moq/checker/index.tsx | 118 ++ .../src/routes/(moq)/moq/checker/tester.ts | 208 ++ apps/www/tsconfig.json | 2 +- apps/www/vite.config.ts | 13 + bun.lockb | Bin 372120 -> 459752 bytes infra/RELAY.md | 87 + infra/domain.ts | 16 +- infra/github.ts | 38 - infra/relay.ts | 22 + package.json | 1 + packages/eslint-config/qwik.js | 85 +- packages/moq/.eslintrc.cjs | 56 + packages/moq/.prettierrc.yaml | 4 + packages/moq/README.md | 20 + packages/moq/common/async.ts | 120 ++ packages/moq/common/download.ts | 18 + packages/moq/common/error.ts | 14 + packages/moq/common/index.ts | 1 + packages/moq/common/ring.ts | 176 ++ packages/moq/common/settings.ts | 33 + packages/moq/common/tsconfig.json | 4 + packages/moq/contribute/audio.ts | 75 + packages/moq/contribute/broadcast.ts | 241 +++ packages/moq/contribute/chunk.ts | 7 + packages/moq/contribute/container.ts | 165 ++ packages/moq/contribute/index.ts | 5 + packages/moq/contribute/segment.ts | 45 + packages/moq/contribute/track.ts | 170 ++ packages/moq/contribute/tsconfig.json | 18 + packages/moq/contribute/video.ts | 111 + packages/moq/media/catalog/index.ts | 218 ++ packages/moq/media/mp4/index.ts | 37 + packages/moq/media/mp4/parser.ts | 71 + packages/moq/media/mp4/rename.ts | 13 + packages/moq/media/tsconfig.json | 15 + packages/moq/package.json | 29 + packages/moq/playback/audio.ts | 50 + packages/moq/playback/backend.ts | 114 + packages/moq/playback/index.ts | 190 ++ packages/moq/playback/tsconfig.json | 22 + packages/moq/playback/worker/audio.ts | 73 + packages/moq/playback/worker/index.ts | 119 ++ packages/moq/playback/worker/message.ts | 98 + packages/moq/playback/worker/timeline.ts | 118 ++ packages/moq/playback/worker/video.ts | 84 + packages/moq/playback/worklet/index.ts | 58 + packages/moq/playback/worklet/message.ts | 12 + packages/moq/playback/worklet/tsconfig.json | 14 + packages/moq/transport/client.ts | 83 + packages/moq/transport/connection.ts | 95 + packages/moq/transport/control.ts | 550 +++++ packages/moq/transport/index.ts | 7 + packages/moq/transport/objects.ts | 307 +++ packages/moq/transport/publisher.ts | 230 ++ packages/moq/transport/setup.ts | 163 ++ packages/moq/transport/stream.ts | 270 +++ packages/moq/transport/subscriber.ts | 197 ++ packages/moq/transport/tsconfig.json | 9 + packages/moq/tsconfig.json | 42 + packages/moq/types/mp4box.d.ts | 1848 +++++++++++++++++ packages/moq/types/tsconfig.json | 4 + packages/typescript-config/base.json | 2 +- packages/ui/package.json | 4 +- packages/ui/src/router-head.tsx | 4 +- sst-env.d.ts | 4 - sst.config.ts | 6 +- 74 files changed, 7107 insertions(+), 96 deletions(-) create mode 100644 .certs/.gitignore create mode 100644 .certs/.terraform.lock.hcl create mode 100644 .certs/README.md create mode 100644 .certs/input.tf create mode 100644 .certs/main.tf create mode 100644 .certs/terraform.tfvars create mode 100644 apps/www/src/routes/(moq)/moq/checker/index.tsx create mode 100644 apps/www/src/routes/(moq)/moq/checker/tester.ts create mode 100644 infra/RELAY.md delete mode 100644 infra/github.ts create mode 100644 infra/relay.ts create mode 100644 packages/moq/.eslintrc.cjs create mode 100644 packages/moq/.prettierrc.yaml create mode 100644 packages/moq/README.md create mode 100644 packages/moq/common/async.ts create mode 100644 packages/moq/common/download.ts create mode 100644 packages/moq/common/error.ts create mode 100644 packages/moq/common/index.ts create mode 100644 packages/moq/common/ring.ts create mode 100644 packages/moq/common/settings.ts create mode 100644 packages/moq/common/tsconfig.json create mode 100644 packages/moq/contribute/audio.ts create mode 100644 packages/moq/contribute/broadcast.ts create mode 100644 packages/moq/contribute/chunk.ts create mode 100644 packages/moq/contribute/container.ts create mode 100644 packages/moq/contribute/index.ts create mode 100644 packages/moq/contribute/segment.ts create mode 100644 packages/moq/contribute/track.ts create mode 100644 packages/moq/contribute/tsconfig.json create mode 100644 packages/moq/contribute/video.ts create mode 100644 packages/moq/media/catalog/index.ts create mode 100644 packages/moq/media/mp4/index.ts create mode 100644 packages/moq/media/mp4/parser.ts create mode 100644 packages/moq/media/mp4/rename.ts create mode 100644 packages/moq/media/tsconfig.json create mode 100644 packages/moq/package.json create mode 100644 packages/moq/playback/audio.ts create mode 100644 packages/moq/playback/backend.ts create mode 100644 packages/moq/playback/index.ts create mode 100644 packages/moq/playback/tsconfig.json create mode 100644 packages/moq/playback/worker/audio.ts create mode 100644 packages/moq/playback/worker/index.ts create mode 100644 packages/moq/playback/worker/message.ts create mode 100644 packages/moq/playback/worker/timeline.ts create mode 100644 packages/moq/playback/worker/video.ts create mode 100644 packages/moq/playback/worklet/index.ts create mode 100644 packages/moq/playback/worklet/message.ts create mode 100644 packages/moq/playback/worklet/tsconfig.json create mode 100644 packages/moq/transport/client.ts create mode 100644 packages/moq/transport/connection.ts create mode 100644 packages/moq/transport/control.ts create mode 100644 packages/moq/transport/index.ts create mode 100644 packages/moq/transport/objects.ts create mode 100644 packages/moq/transport/publisher.ts create mode 100644 packages/moq/transport/setup.ts create mode 100644 packages/moq/transport/stream.ts create mode 100644 packages/moq/transport/subscriber.ts create mode 100644 packages/moq/transport/tsconfig.json create mode 100644 packages/moq/tsconfig.json create mode 100644 packages/moq/types/mp4box.d.ts create mode 100644 packages/moq/types/tsconfig.json diff --git a/.certs/.gitignore b/.certs/.gitignore new file mode 100644 index 00000000..21924bc5 --- /dev/null +++ b/.certs/.gitignore @@ -0,0 +1,3 @@ +.terraform +relay_* +terraform.tfstate \ No newline at end of file diff --git a/.certs/.terraform.lock.hcl b/.certs/.terraform.lock.hcl new file mode 100644 index 00000000..65fa884f --- /dev/null +++ b/.certs/.terraform.lock.hcl @@ -0,0 +1,61 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/local" { + version = "2.5.2" + hashes = [ + "h1:JlMZD6nYqJ8sSrFfEAH0Vk/SL8WLZRmFaMUF9PJK5wM=", + "zh:136299545178ce281c56f36965bf91c35407c11897f7082b3b983d86cb79b511", + "zh:3b4486858aa9cb8163378722b642c57c529b6c64bfbfc9461d940a84cd66ebea", + "zh:4855ee628ead847741aa4f4fc9bed50cfdbf197f2912775dd9fe7bc43fa077c0", + "zh:4b8cd2583d1edcac4011caafe8afb7a95e8110a607a1d5fb87d921178074a69b", + "zh:52084ddaff8c8cd3f9e7bcb7ce4dc1eab00602912c96da43c29b4762dc376038", + "zh:71562d330d3f92d79b2952ffdda0dad167e952e46200c767dd30c6af8d7c0ed3", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:805f81ade06ff68fa8b908d31892eaed5c180ae031c77ad35f82cb7a74b97cf4", + "zh:8b6b3ebeaaa8e38dd04e56996abe80db9be6f4c1df75ac3cccc77642899bd464", + "zh:ad07750576b99248037b897de71113cc19b1a8d0bc235eb99173cc83d0de3b1b", + "zh:b9f1c3bfadb74068f5c205292badb0661e17ac05eb23bfe8bd809691e4583d0e", + "zh:cc4cbcd67414fefb111c1bf7ab0bc4beb8c0b553d01719ad17de9a047adff4d1", + ] +} + +provider "registry.terraform.io/hashicorp/tls" { + version = "4.0.6" + hashes = [ + "h1:dYSb3V94K5dDMtrBRLPzBpkMTPn+3cXZ/kIJdtFL+2M=", + "zh:10de0d8af02f2e578101688fd334da3849f56ea91b0d9bd5b1f7a243417fdda8", + "zh:37fc01f8b2bc9d5b055dc3e78bfd1beb7c42cfb776a4c81106e19c8911366297", + "zh:4578ca03d1dd0b7f572d96bd03f744be24c726bfd282173d54b100fd221608bb", + "zh:6c475491d1250050765a91a493ef330adc24689e8837a0f07da5a0e1269e11c1", + "zh:81bde94d53cdababa5b376bbc6947668be4c45ab655de7aa2e8e4736dfd52509", + "zh:abdce260840b7b050c4e401d4f75c7a199fafe58a8b213947a258f75ac18b3e8", + "zh:b754cebfc5184873840f16a642a7c9ef78c34dc246a8ae29e056c79939963c7a", + "zh:c928b66086078f9917aef0eec15982f2e337914c5c4dbc31dd4741403db7eb18", + "zh:cded27bee5f24de6f2ee0cfd1df46a7f88e84aaffc2ecbf3ff7094160f193d50", + "zh:d65eb3867e8f69aaf1b8bb53bd637c99c6b649ba3db16ded50fa9a01076d1a27", + "zh:ecb0c8b528c7a619fa71852bb3fb5c151d47576c5aab2bf3af4db52588722eeb", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} + +provider "registry.terraform.io/vancluever/acme" { + version = "2.26.0" + constraints = "~> 2.0" + hashes = [ + "h1:4Lk5cb2Fg1q1JEQf1jkrShjPC3ayukp4eFcdL4e+y0w=", + "zh:11f554916ee99d8930de6d7bb5a014ec636b53ef9ba35eea84b0d2522c78230f", + "zh:231c31271c25477c95e0a4972857b6d5e9d7c3a300cbc4b0948566d87bc46e04", + "zh:2ae165ca7a994a4c77801a82ebd9f2f6de33a4c8882381bea575b3385cc251d8", + "zh:2cf01e4694d81b24972f5dab8e5f374aa59100082ff6e2435615d9c0f24cc00e", + "zh:3de6f6f9d052dfaa5d5f366d7ca26bdebb42fc74b6e19325e67420c37ff630d3", + "zh:3fd2b4b680b970394e4d0d49c2a8e5365297e79cea418ce87197cc8bb456d8c7", + "zh:46ea249cc01dce23ff6c8f02106e693be3b046059834b60b670c45a8f4093980", + "zh:57cb181c73b6e7397744d885c788d8815ad6a43f07769e98c6327bbc37272896", + "zh:761f2adf3e63559bd279763eb91247cdebf31401d79853755453274f143cbb36", + "zh:c4a9905bf81d38201c080cb91ea85002194c47ca26619644628184a56c394b7d", + "zh:d6e3a757c357239edefb640807778fb69805b9ae5df84a811a2d505c51089367", + "zh:d713856e4a459e1091cbb19ffb830d25cd88953d3e54acd46db0729c77a531d8", + "zh:f7cb8dec263d0ee223737dad3b6fa8071258f41cfa9e0b8cf7f337f9f501fc3b", + ] +} diff --git a/.certs/README.md b/.certs/README.md new file mode 100644 index 00000000..071f07dd --- /dev/null +++ b/.certs/README.md @@ -0,0 +1,24 @@ +## Usage +1. Update the terraform.tfvars file with your domain and email. + +2. Run `terraform init` to initialize the Terraform working directory. + +3. Run `terraform plan` to see the planned changes. + +4. Run `terraform apply` to create the resources and obtain the certificate. +Outputs + +The configuration provides two sensitive outputs: +```bash +certificate_pem: The full certificate chain +private_key_pem: The private key for the certificate +``` + +These can be then be used in your `moq-relay` as it requires SSL/TLS certificates. + +## Note +The generated certificate and key files are saved locally and ignored by git: +```git +.terraform +relay_* +``` \ No newline at end of file diff --git a/.certs/input.tf b/.certs/input.tf new file mode 100644 index 00000000..4d543861 --- /dev/null +++ b/.certs/input.tf @@ -0,0 +1,7 @@ +variable "email" { + description = "Your email address, used for LetsEncrypt" +} + +variable "domain" { + description = "domain name" +} \ No newline at end of file diff --git a/.certs/main.tf b/.certs/main.tf new file mode 100644 index 00000000..a4915613 --- /dev/null +++ b/.certs/main.tf @@ -0,0 +1,65 @@ +terraform { + required_providers { + acme = { + source = "vancluever/acme" + version = "~> 2.0" + } + } +} + +provider "acme" { + server_url = "https://acme-v02.api.letsencrypt.org/directory" +} + +resource "acme_registration" "reg" { + email_address = "wanjohiryan33@gmail.com" +} + +resource "tls_private_key" "relay" { + algorithm = "ECDSA" + ecdsa_curve = "P256" +} + +resource "acme_registration" "relay" { + account_key_pem = tls_private_key.relay.private_key_pem + email_address = var.email +} + +resource "acme_certificate" "relay" { + account_key_pem = acme_registration.relay.account_key_pem + common_name = "relay.${var.domain}" + subject_alternative_names = ["*.relay.${var.domain}"] + key_type = tls_private_key.relay.ecdsa_curve + + recursive_nameservers = ["8.8.8.8:53"] + + dns_challenge { + provider = "route53" + } +} + +# New resources to save certificate and private key +resource "local_file" "cert_file" { + content = "${acme_certificate.relay.certificate_pem}${acme_certificate.relay.issuer_pem}" + filename = "${path.module}/relay_cert.crt" + file_permission = "0644" + directory_permission = "0755" +} + +resource "local_file" "key_file" { + content = acme_certificate.relay.private_key_pem + filename = "${path.module}/relay_key.key" + file_permission = "0600" + directory_permission = "0755" +} + +# Outputs for certificate and private key +output "certificate_pem" { + value = "${acme_certificate.relay.certificate_pem}${acme_certificate.relay.issuer_pem}" + sensitive = true +} + +output "private_key_pem" { + value = acme_certificate.relay.private_key_pem + sensitive = true +} diff --git a/.certs/terraform.tfvars b/.certs/terraform.tfvars new file mode 100644 index 00000000..c63348f1 --- /dev/null +++ b/.certs/terraform.tfvars @@ -0,0 +1,2 @@ +domain = "fst.so" +email = "wanjohiryan33@gmail.com" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 28dc834e..97223e6b 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,7 @@ yarn-error.log* .sst #Bun merging errors, EVERY time :( -bun.lockb \ No newline at end of file +bun.lockb + +#tests +id_* \ No newline at end of file diff --git a/apps/www/package.json b/apps/www/package.json index b39d3648..7e56b847 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -32,7 +32,9 @@ "@builder.io/qwik": "^1.8.0", "@builder.io/qwik-city": "^1.8.0", "@builder.io/qwik-react": "0.5.0", + "@modular-forms/qwik": "^0.27.0", "@nestri/eslint-config": "*", + "@nestri/moq": "*", "@nestri/typescript-config": "*", "@nestri/ui": "*", "@types/eslint": "8.56.10", @@ -48,6 +50,7 @@ "react-dom": "18.2.0", "typescript": "5.4.5", "undici": "*", + "valibot": "^0.42.1", "vite": "5.3.5", "vite-tsconfig-paths": "^4.2.1", "wrangler": "^3.0.0" diff --git a/apps/www/src/routes/(moq)/moq/checker/index.tsx b/apps/www/src/routes/(moq)/moq/checker/index.tsx new file mode 100644 index 00000000..aee03dc5 --- /dev/null +++ b/apps/www/src/routes/(moq)/moq/checker/index.tsx @@ -0,0 +1,118 @@ +import * as v from "valibot" +import { Broadcast } from "./tester"; +import { cn } from "@nestri/ui/design"; +import { routeLoader$ } from "@builder.io/qwik-city"; +import { component$, $, useSignal } from "@builder.io/qwik"; +import { MotionComponent, transition, TitleSection, Button } from "@nestri/ui/react"; +import { type InitialValues, type SubmitHandler, useForm, valiForm$ } from "@modular-forms/qwik" + +const Schema = v.object({ + url: v.pipe( + v.string(), + v.minLength(10, "Please input a valid url"), + v.url("Please input a valid url"), + ) +}, "Please fill in all the fields correctly.") + +type Form = v.InferInput; + +export const useFormLoader = routeLoader$>(async () => { + return { + url: "" + } +}) + +const generateRandomWord = (length: number) => { + const characters = 'abcdefghijklmnopqrstuvwxyz'; + return Array.from({ length }, () => characters[Math.floor(Math.random() * characters.length)]).join(''); +}; + +export default component$(() => { + const broadcasterOk = useSignal(); + const [state, { Form, Field }] = useForm
({ + loader: useFormLoader(), + validate: valiForm$(Schema) + }); + + const handleSubmit = $>(async (values) => { + const randomNamespace = generateRandomWord(6); + const sub = await Broadcast.init({ url: values.url, fingerprint: undefined, namespace: randomNamespace }) + + setTimeout(() => { + broadcasterOk.value = sub.isSubscribed() + }, 1000); + }); + + return ( + <> + + +
+ + + {(field, props) => { + return ( +
+
+ +
+ {field.error && (

{field.error}

)} +
+ ) + }} +
+ + {/* */} + + {/* + + + + + + */} + + Check + +
+ + + {typeof broadcasterOk.value !== "undefined" && broadcasterOk.value == true ? ( + + Your relay is doing okay + + ) : typeof broadcasterOk.value !== "undefined" && ( + + Your relay has an issue + + )} +
+
+ + ) +}) \ No newline at end of file diff --git a/apps/www/src/routes/(moq)/moq/checker/tester.ts b/apps/www/src/routes/(moq)/moq/checker/tester.ts new file mode 100644 index 00000000..11446ea8 --- /dev/null +++ b/apps/www/src/routes/(moq)/moq/checker/tester.ts @@ -0,0 +1,208 @@ +import type { Connection, SubscribeRecv } from "@nestri/moq/transport" +import { asError } from "@nestri/moq/common/error" +import { Client } from "@nestri/moq/transport/client" +import * as Catalog from "@nestri/moq/media/catalog" +import { type GroupWriter } from "@nestri/moq/transport/objects" + +export interface BroadcastConfig { + namespace: string + connection: Connection +} +export interface BroadcasterConfig { + url: string + namespace: string + fingerprint?: string // URL to fetch TLS certificate fingerprint +} + +export interface BroadcastConfigTrack { + input: string + bitrate: number +} + +export class Broadcast { + stream: GroupWriter | null + subscriber: SubscribeRecv | null + subscribed: boolean; + + + readonly config: BroadcastConfig + readonly catalog: Catalog.Root + readonly connection: Connection + readonly namespace: string + + #running: Promise + + constructor(config: BroadcastConfig) { + this.subscribed = false + this.namespace = config.namespace + this.connection = config.connection + this.config = config + //Arbitrary values, just to keep TypeScript happy :) + this.catalog = { + version: 1, + streamingFormat: 1, + streamingFormatVersion: "0.2", + supportsDeltaUpdates: false, + commonTrackFields: { + packaging: "loc", + renderGroup: 1, + }, + tracks: [{ + name: "tester", + namespace: "tester", + selectionParams: {} + }], + } + this.stream = null + this.subscriber = null + + this.#running = this.#run() + } + + static async init(config: BroadcasterConfig): Promise { + const client = new Client({ url: config.url, fingerprint: config.fingerprint, role: "publisher" }) + const connection = await client.connect(); + + return new Broadcast({ connection, namespace: config.namespace }) + } + + async #run() { + try { + await this.connection.announce(this.namespace) + this.subscribed = true + } catch (error) { + + this.subscribed = false + } + + for (; ;) { + const subscriber = await this.connection.subscribed() + + if (!subscriber) { + this.subscribed = false + + break + } + + await subscriber.ack() + + this.subscriber = subscriber + + this.subscribed = true + + const bytes = Catalog.encode(this.catalog); + + const stream = await subscriber.group({ group: 0 }); + + await stream.write({ object: 0, payload: bytes }) + + this.stream = stream + } + } + + isSubscribed(): boolean { + return this.subscribed; + } + + // async #serveSubscribe(subscriber: SubscribeRecv) { + // try { + + // // Send a SUBSCRIBE_OK + // await subscriber.ack() + + // console.log("catalog track name:", subscriber.track) + + // const stream = await subscriber.group({ group: 0 }); + + // // const bytes = this.catalog.encode("Hello World") + + // await stream.write({ object: 0, payload: bytes }) + + + + // } catch (e) { + // const err = asError(e) + // await subscriber.close(1n, `failed to process publish: ${err.message}`) + // } finally { + // // TODO we can't close subscribers because there's no support for clean termination + // // await subscriber.close() + // } + // } + + // async mouseUpdatePosition({ x, y }: { x: number, y: number }, stream: GroupWriter) { + + // const mouse_move = { + // input_type: "mouse_move", + // delta_y: y, + // delta_x: x, + // } + + // const bytes = Catalog.encode(this.catalog) + + // await stream.write({ object: 0, payload: bytes }); + // } + + // async mouseUpdateButtons(e: MouseEvent, stream: GroupWriter) { + // const data: { input_type?: "mouse_key_down" | "mouse_key_up"; button: number; } = { button: e.button }; + + // if (e.type === "mousedown") { + // data["input_type"] = "mouse_key_down" + // } else if (e.type === "mouseup") { + // data["input_type"] = "mouse_key_up" + // } + + // const bytes = Catalog.encode(this.catalog) + + // await stream.write({ object: 0, payload: bytes }); + // } + + // async mouseUpdateWheel(e: WheelEvent, stream: GroupWriter) { + // const data: { input_type?: "mouse_wheel_up" | "mouse_wheel_down" } = {} + + // if (e.deltaY < 0.0) { + // data["input_type"] = "mouse_wheel_up" + // } else { + // data["input_type"] = "mouse_wheel_down" + // } + + // const bytes = Catalog.encode(this.catalog) + + // await stream.write({ object: 0, payload: bytes }); + // } + + // async updateKeyUp(e: KeyboardEvent, stream: GroupWriter) { + // const data = { + // input_type: "key_up", + // key_code: e.keyCode + // } + + // const bytes = Catalog.encode(this.catalog) + + // await stream.write({ object: 0, payload: bytes }); + // } + + // async updateKeyDown(e: KeyboardEvent, stream: GroupWriter) { + // const data = { + // input_type: "key_down", + // key_code: e.keyCode + // } + + // const bytes = Catalog.encode(this.catalog) + + // await stream.write({ object: 0, payload: bytes }); + // } + + close() { + // TODO implement publish close + } + + // Returns the error message when the connection is closed + async closed(): Promise { + try { + await this.#running + return new Error("closed") // clean termination + } catch (e) { + return asError(e) + } + } +} \ No newline at end of file diff --git a/apps/www/tsconfig.json b/apps/www/tsconfig.json index 25cda1a5..ab18d603 100644 --- a/apps/www/tsconfig.json +++ b/apps/www/tsconfig.json @@ -2,7 +2,7 @@ "extends": "@nestri/typescript-config/base.json", "compilerOptions": { "allowJs": true, - "target": "ES2017", + "target": "ES2022", "module": "ES2022", "lib": [ "es2022", diff --git a/apps/www/vite.config.ts b/apps/www/vite.config.ts index 3dde541e..67713ab0 100644 --- a/apps/www/vite.config.ts +++ b/apps/www/vite.config.ts @@ -27,6 +27,17 @@ export default defineConfig((): UserConfig => { qwikVite(), tsconfigPaths(), qwikReact(), + //For Moq-js (SharedArrayBuffer) + { + name: "configure-response-headers", + configureServer: (server) => { + server.middlewares.use((_req, res, next) => { + res.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); + res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + next(); + }); + }, + }, ], // This tells Vite which dependencies to pre-build in dev mode. optimizeDeps: { @@ -51,6 +62,8 @@ export default defineConfig((): UserConfig => { // } // : undefined, server: { + // https: true, + // proxy:{}, headers: { // Don't cache the server response in dev mode "Cache-Control": "public, max-age=0", diff --git a/bun.lockb b/bun.lockb index 50b51333963567c3fa22311b7c9ee8b0e574ad28..203fb559a10266fcdf215106143ed7d0a4c0d977 100755 GIT binary patch delta 128966 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ʜ%t%Jn}D4I*e^eLZ)BxQvuc1= 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 ({ - [`${url}:sub`]: "repo:nestriness/nestri:*", - })), - }, - }, - ], - }, - }); - - new aws.iam.RolePolicyAttachment("GithubRolePolicy", { - policyArn: "arn:aws:iam::aws:policy/AdministratorAccess", - role: githubRole.name, - }); -} \ No newline at end of file diff --git a/infra/relay.ts b/infra/relay.ts new file mode 100644 index 00000000..5da8dc5b --- /dev/null +++ b/infra/relay.ts @@ -0,0 +1,22 @@ +import { resolve as pathResolve } from "node:path"; +import { readFileSync as readFile } from "node:fs"; +//Copy your (known) ssh public key to the remote machine +//ssh-copy-id "-p $port" user@host + +const domain = "fst.so" +const ips = ["95.216.29.238"] + +// Get the hosted zone +const zone = aws.route53.getZone({ name: domain }); + +// Create an A record +const record = new aws.route53.Record("Relay DNS Records", { + zoneId: zone.then(zone => zone.zoneId), + type: "A", + name: `relay.${domain}`, + ttl: 300, + records: ips, +}); + +// Export the URL +export const url = $interpolate`https://${record.name}`; \ No newline at end of file diff --git a/package.json b/package.json index eb26f395..2581272a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "4.20240821.1", + "@pulumi/pulumi": "^3.134.0", "@types/aws-lambda": "8.10.145", "prettier": "^3.2.5", "turbo": "^2.0.12", diff --git a/packages/eslint-config/qwik.js b/packages/eslint-config/qwik.js index 8aac57a1..3ee14854 100644 --- a/packages/eslint-config/qwik.js +++ b/packages/eslint-config/qwik.js @@ -1,42 +1,49 @@ module.exports = { - env: { - browser: true, - es2021: true, - node: true, + env: { + browser: true, + es2021: true, + node: true, + }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:qwik/recommended", + ], + parser: "@typescript-eslint/parser", + parserOptions: { + tsconfigRootDir: __dirname, + project: ["./tsconfig.json"], + ecmaVersion: 2021, + sourceType: "module", + ecmaFeatures: { + jsx: true, }, - extends: [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:qwik/recommended", - ], - parser: "@typescript-eslint/parser", - parserOptions: { - tsconfigRootDir: __dirname, - project: ["./tsconfig.json"], - ecmaVersion: 2021, - sourceType: "module", - ecmaFeatures: { - jsx: true, + }, + plugins: ["@typescript-eslint"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-inferrable-types": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-empty-interface": "off", + "@typescript-eslint/no-namespace": "off", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-this-alias": "off", + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/ban-ts-comment": "off", + // Warn when an unused variable doesn't start with an underscore + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", }, - }, - plugins: ["@typescript-eslint"], - rules: { - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/no-inferrable-types": "off", - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-empty-interface": "off", - "@typescript-eslint/no-namespace": "off", - "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/no-this-alias": "off", - "@typescript-eslint/ban-types": "off", - "@typescript-eslint/ban-ts-comment": "off", - "prefer-spread": "off", - "no-case-declarations": "off", - "no-console": "off", - "@typescript-eslint/no-unused-vars": ["error"], - "@typescript-eslint/consistent-type-imports": "warn", - "@typescript-eslint/no-unnecessary-condition": "warn", - }, - }; - \ No newline at end of file + ], + "prefer-spread": "off", + "no-case-declarations": "off", + "no-console": "off", + "@typescript-eslint/consistent-type-imports": "warn", + "@typescript-eslint/no-unnecessary-condition": "warn", + }, +}; diff --git a/packages/moq/.eslintrc.cjs b/packages/moq/.eslintrc.cjs new file mode 100644 index 00000000..89e59ace --- /dev/null +++ b/packages/moq/.eslintrc.cjs @@ -0,0 +1,56 @@ +/* eslint-env node */ +module.exports = { + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking", + "plugin:@typescript-eslint/strict", + "prettier", + ], + parser: "@typescript-eslint/parser", + plugins: ["@typescript-eslint", "prettier"], + root: true, + env: { + browser: true, + es2022: true, + worker: true, + }, + ignorePatterns: ["dist", "node_modules", ".eslintrc.cjs"], + rules: { + // Allow the ! operator because typescript can't always figure out when something is not undefined + "@typescript-eslint/no-non-null-assertion": "off", + + // Allow `any` because Javascript was not designed to be type safe. + "@typescript-eslint/no-explicit-any": "off", + + // Requring a comment in empty function is silly + "@typescript-eslint/no-empty-function": "off", + + // Warn when an unused variable doesn't start with an underscore + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + + // The no-unsafe-* rules are a pain an introduce a lot of false-positives. + // Typescript will make sure things are properly typed. + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-return": "off", + + // Make formatting errors into warnings + "prettier/prettier": 1, + }, + + parserOptions: { + project: true, + tsconfigRootDir: __dirname, + }, +} diff --git a/packages/moq/.prettierrc.yaml b/packages/moq/.prettierrc.yaml new file mode 100644 index 00000000..708c01ae --- /dev/null +++ b/packages/moq/.prettierrc.yaml @@ -0,0 +1,4 @@ +# note: root .editorconfig is used + +# Don't insert semi-colons unless needed +semi: false diff --git a/packages/moq/README.md b/packages/moq/README.md new file mode 100644 index 00000000..4aa2e40e --- /dev/null +++ b/packages/moq/README.md @@ -0,0 +1,20 @@ +# Media over QUIC + +Media over QUIC (MoQ) is a live media delivery protocol utilizing QUIC streams. +See the [Warp draft](https://datatracker.ietf.org/doc/draft-lcurley-warp/). + +This is a Typescript library that supports both contribution (ingest) and distribution (playback). +It requires a server, such as [moq-rs](https://github.com/kixelated/moq-rs). + +## Usage + +``` +npm install @kixelated/moq +``` + +## License + +Licensed under either: + +- Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) diff --git a/packages/moq/common/async.ts b/packages/moq/common/async.ts new file mode 100644 index 00000000..a0fa0b4e --- /dev/null +++ b/packages/moq/common/async.ts @@ -0,0 +1,120 @@ +export class Deferred { + promise: Promise + resolve!: (value: T | PromiseLike) => void + reject!: (reason: any) => void + pending = true + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = (value) => { + this.pending = false + resolve(value) + } + this.reject = (reason) => { + this.pending = false + reject(reason) + } + }) + } +} + +export type WatchNext = [T, Promise> | undefined] + +export class Watch { + #current: WatchNext + #next = new Deferred>() + + constructor(init: T) { + this.#next = new Deferred>() + this.#current = [init, this.#next.promise] + } + + value(): WatchNext { + return this.#current + } + + update(v: T | ((v: T) => T)) { + if (!this.#next.pending) { + throw new Error("already closed") + } + + // If we're given a function, call it with the current value + if (v instanceof Function) { + v = v(this.#current[0]) + } + + const next = new Deferred>() + this.#current = [v, next.promise] + this.#next.resolve(this.#current) + this.#next = next + } + + close() { + this.#current[1] = undefined + this.#next.resolve(this.#current) + } +} + +// Wakes up a multiple consumers. +export class Notify { + #next = new Deferred() + + async wait() { + return this.#next.promise + } + + wake() { + if (!this.#next.pending) { + throw new Error("closed") + } + + this.#next.resolve() + this.#next = new Deferred() + } + + close() { + this.#next.resolve() + } +} + +// Allows queuing N values, like a Channel. +export class Queue { + #stream: TransformStream + #closed = false + + constructor(capacity = 1) { + const queue = new CountQueuingStrategy({ highWaterMark: capacity }) + this.#stream = new TransformStream({}, undefined, queue) + } + + async push(v: T) { + const w = this.#stream.writable.getWriter() + await w.write(v) + w.releaseLock() + } + + async next(): Promise { + const r = this.#stream.readable.getReader() + const { value, done } = await r.read() + r.releaseLock() + + if (done) return + return value + } + + async abort(err: Error) { + if (this.#closed) return + await this.#stream.writable.abort(err) + this.#closed = true + } + + async close() { + if (this.#closed) return + await this.#stream.writable.close() + this.#closed = true + } + + closed() { + return this.#closed + } +} diff --git a/packages/moq/common/download.ts b/packages/moq/common/download.ts new file mode 100644 index 00000000..4a2a69b5 --- /dev/null +++ b/packages/moq/common/download.ts @@ -0,0 +1,18 @@ +// Utility function to download a Uint8Array for debugging. +export function download(data: Uint8Array, name: string) { + const blob = new Blob([data], { + type: "application/octet-stream", + }) + + const url = window.URL.createObjectURL(blob) + + const a = document.createElement("a") + a.href = url + a.download = name + document.body.appendChild(a) + a.style.display = "none" + a.click() + a.remove() + + setTimeout(() => window.URL.revokeObjectURL(url), 1000) +} diff --git a/packages/moq/common/error.ts b/packages/moq/common/error.ts new file mode 100644 index 00000000..c627a0b9 --- /dev/null +++ b/packages/moq/common/error.ts @@ -0,0 +1,14 @@ +// I hate javascript +export function asError(e: any): Error { + if (e instanceof Error) { + return e + } else if (typeof e === "string") { + return new Error(e) + } else { + return new Error(String(e)) + } +} + +export function isError(e: any): e is Error { + return e instanceof Error +} diff --git a/packages/moq/common/index.ts b/packages/moq/common/index.ts new file mode 100644 index 00000000..6d519d33 --- /dev/null +++ b/packages/moq/common/index.ts @@ -0,0 +1 @@ +export { asError } from "./error" diff --git a/packages/moq/common/ring.ts b/packages/moq/common/ring.ts new file mode 100644 index 00000000..97a73e65 --- /dev/null +++ b/packages/moq/common/ring.ts @@ -0,0 +1,176 @@ +// Ring buffer with audio samples. + +enum STATE { + READ_POS = 0, // The current read position + WRITE_POS, // The current write position + LENGTH, // Clever way of saving the total number of enums values. +} + +interface FrameCopyToOptions { + frameCount?: number + frameOffset?: number + planeIndex: number +} + +// This is implemented by AudioData in WebCodecs, but we don't import it because it's a DOM type. +interface Frame { + numberOfFrames: number + numberOfChannels: number + copyTo(dst: Float32Array, options: FrameCopyToOptions): void +} + +// No prototype to make this easier to send via postMessage +export class RingShared { + state: SharedArrayBuffer + + channels: SharedArrayBuffer[] + capacity: number + + constructor(channels: number, capacity: number) { + // Store the current state in a separate ring buffer. + this.state = new SharedArrayBuffer(STATE.LENGTH * Int32Array.BYTES_PER_ELEMENT) + + // Create a buffer for each audio channel + this.channels = [] + for (let i = 0; i < channels; i += 1) { + const buffer = new SharedArrayBuffer(capacity * Float32Array.BYTES_PER_ELEMENT) + this.channels.push(buffer) + } + + this.capacity = capacity + } +} + +export class Ring { + state: Int32Array + channels: Float32Array[] + capacity: number + + constructor(shared: RingShared) { + this.state = new Int32Array(shared.state) + + this.channels = [] + for (const channel of shared.channels) { + this.channels.push(new Float32Array(channel)) + } + + this.capacity = shared.capacity + } + + // Write samples for single audio frame, returning the total number written. + write(frame: Frame): number { + const readPos = Atomics.load(this.state, STATE.READ_POS) + const writePos = Atomics.load(this.state, STATE.WRITE_POS) + + const startPos = writePos + let endPos = writePos + frame.numberOfFrames + + if (endPos > readPos + this.capacity) { + endPos = readPos + this.capacity + if (endPos <= startPos) { + // No space to write + return 0 + } + } + + const startIndex = startPos % this.capacity + const endIndex = endPos % this.capacity + + // Loop over each channel + for (let i = 0; i < this.channels.length; i += 1) { + const channel = this.channels[i] + + // If the AudioData doesn't have enough channels, duplicate it. + const planeIndex = Math.min(i, frame.numberOfChannels - 1) + + if (startIndex < endIndex) { + // One continuous range to copy. + const full = channel.subarray(startIndex, endIndex) + + frame.copyTo(full, { + planeIndex, + frameCount: endIndex - startIndex, + }) + } else { + const first = channel.subarray(startIndex) + const second = channel.subarray(0, endIndex) + + frame.copyTo(first, { + planeIndex, + frameCount: first.length, + }) + + // We need this conditional when startIndex == 0 and endIndex == 0 + // When capacity=4410 and frameCount=1024, this was happening 52s into the audio. + if (second.length) { + frame.copyTo(second, { + planeIndex, + frameOffset: first.length, + frameCount: second.length, + }) + } + } + } + + Atomics.store(this.state, STATE.WRITE_POS, endPos) + + return endPos - startPos + } + + read(dst: Float32Array[]): number { + const readPos = Atomics.load(this.state, STATE.READ_POS) + const writePos = Atomics.load(this.state, STATE.WRITE_POS) + + const startPos = readPos + let endPos = startPos + dst[0].length + + if (endPos > writePos) { + endPos = writePos + if (endPos <= startPos) { + // Nothing to read + return 0 + } + } + + const startIndex = startPos % this.capacity + const endIndex = endPos % this.capacity + + // Loop over each channel + for (let i = 0; i < dst.length; i += 1) { + if (i >= this.channels.length) { + // ignore excess channels + } + + const input = this.channels[i] + const output = dst[i] + + if (startIndex < endIndex) { + const full = input.subarray(startIndex, endIndex) + output.set(full) + } else { + const first = input.subarray(startIndex) + const second = input.subarray(0, endIndex) + + output.set(first) + output.set(second, first.length) + } + } + + Atomics.store(this.state, STATE.READ_POS, endPos) + + return endPos - startPos + } + + clear() { + const pos = Atomics.load(this.state, STATE.WRITE_POS) + Atomics.store(this.state, STATE.READ_POS, pos) + } + + size() { + // TODO is this thread safe? + const readPos = Atomics.load(this.state, STATE.READ_POS) + const writePos = Atomics.load(this.state, STATE.WRITE_POS) + + return writePos - readPos + } +} diff --git a/packages/moq/common/settings.ts b/packages/moq/common/settings.ts new file mode 100644 index 00000000..807fc393 --- /dev/null +++ b/packages/moq/common/settings.ts @@ -0,0 +1,33 @@ +// MediaTrackSettings can represent both audio and video, which means a LOT of possibly undefined properties. +// This is a fork of the MediaTrackSettings interface with properties required for audio or vidfeo. +export interface AudioTrackSettings { + deviceId: string + groupId: string + + autoGainControl: boolean + channelCount: number + echoCancellation: boolean + noiseSuppression: boolean + sampleRate: number + sampleSize: number +} + +export interface VideoTrackSettings { + deviceId: string + groupId: string + + aspectRatio: number + facingMode: "user" | "environment" | "left" | "right" + frameRate: number + height: number + resizeMode: "none" | "crop-and-scale" + width: number +} + +export function isAudioTrackSettings(settings: MediaTrackSettings): settings is AudioTrackSettings { + return "sampleRate" in settings +} + +export function isVideoTrackSettings(settings: MediaTrackSettings): settings is VideoTrackSettings { + return "width" in settings +} diff --git a/packages/moq/common/tsconfig.json b/packages/moq/common/tsconfig.json new file mode 100644 index 00000000..784e2190 --- /dev/null +++ b/packages/moq/common/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["."] +} diff --git a/packages/moq/contribute/audio.ts b/packages/moq/contribute/audio.ts new file mode 100644 index 00000000..d42fe288 --- /dev/null +++ b/packages/moq/contribute/audio.ts @@ -0,0 +1,75 @@ +const SUPPORTED = [ + // TODO support AAC + // "mp4a" + "Opus", +] + +export class Encoder { + #encoder!: AudioEncoder + #encoderConfig: AudioEncoderConfig + #decoderConfig?: AudioDecoderConfig + + frames: TransformStream + + constructor(config: AudioEncoderConfig) { + this.#encoderConfig = config + + this.frames = new TransformStream({ + start: this.#start.bind(this), + transform: this.#transform.bind(this), + flush: this.#flush.bind(this), + }) + } + + #start(controller: TransformStreamDefaultController) { + this.#encoder = new AudioEncoder({ + output: (frame, metadata) => { + this.#enqueue(controller, frame, metadata) + }, + error: (err) => { + throw err + }, + }) + + this.#encoder.configure(this.#encoderConfig) + } + + #transform(frame: AudioData) { + this.#encoder.encode(frame) + frame.close() + } + + #enqueue( + controller: TransformStreamDefaultController, + frame: EncodedAudioChunk, + metadata?: EncodedAudioChunkMetadata, + ) { + const config = metadata?.decoderConfig + if (config && !this.#decoderConfig) { + const config = metadata.decoderConfig + if (!config) throw new Error("missing decoder config") + + controller.enqueue(config) + this.#decoderConfig = config + } + + controller.enqueue(frame) + } + + #flush() { + this.#encoder.close() + } + + static async isSupported(config: AudioEncoderConfig) { + // Check if we support a specific codec family + const short = config.codec.substring(0, 4) + if (!SUPPORTED.includes(short)) return false + + const res = await AudioEncoder.isConfigSupported(config) + return !!res.supported + } + + get config() { + return this.#encoderConfig + } +} diff --git a/packages/moq/contribute/broadcast.ts b/packages/moq/contribute/broadcast.ts new file mode 100644 index 00000000..2f8224fb --- /dev/null +++ b/packages/moq/contribute/broadcast.ts @@ -0,0 +1,241 @@ +import { Connection, SubscribeRecv } from "../transport" +import { asError } from "../common/error" +import { Segment } from "./segment" +import { Track } from "./track" +import * as Catalog from "../media/catalog" + +import { isAudioTrackSettings, isVideoTrackSettings } from "../common/settings" + +export interface BroadcastConfig { + namespace: string + connection: Connection + media: MediaStream + + audio?: AudioEncoderConfig + video?: VideoEncoderConfig +} + +export interface BroadcastConfigTrack { + codec: string + bitrate: number +} + +export class Broadcast { + #tracks = new Map() + + readonly config: BroadcastConfig + readonly catalog: Catalog.Root + readonly connection: Connection + readonly namespace: string + + #running: Promise + + constructor(config: BroadcastConfig) { + this.connection = config.connection + this.config = config + this.namespace = config.namespace + + const tracks: Catalog.Track[] = [] + + for (const media of this.config.media.getTracks()) { + const track = new Track(media, config) + this.#tracks.set(track.name, track) + + const settings = media.getSettings() + + if (isVideoTrackSettings(settings)) { + if (!config.video) { + throw new Error("no video configuration provided") + } + + const video: Catalog.VideoTrack = { + namespace: this.namespace, + name: `${track.name}.m4s`, + initTrack: `${track.name}.mp4`, + selectionParams: { + mimeType: "video/mp4", + codec: config.video.codec, + width: settings.width, + height: settings.height, + framerate: settings.frameRate, + bitrate: config.video.bitrate, + }, + } + + tracks.push(video) + } else if (isAudioTrackSettings(settings)) { + if (!config.audio) { + throw new Error("no audio configuration provided") + } + + const audio: Catalog.AudioTrack = { + namespace: this.namespace, + name: `${track.name}.m4s`, + initTrack: `${track.name}.mp4`, + selectionParams: { + mimeType: "audio/ogg", + codec: config.audio.codec, + samplerate: settings.sampleRate, + //sampleSize: settings.sampleSize, + channelConfig: `${settings.channelCount}`, + bitrate: config.audio.bitrate, + }, + } + + tracks.push(audio) + } else { + throw new Error(`unknown track type: ${media.kind}`) + } + } + + this.catalog = { + version: 1, + streamingFormat: 1, + streamingFormatVersion: "0.2", + supportsDeltaUpdates: false, + commonTrackFields: { + packaging: "cmaf", + renderGroup: 1, + }, + tracks, + } + + this.#running = this.#run() + } + + async #run() { + await this.connection.announce(this.namespace) + + for (;;) { + const subscriber = await this.connection.subscribed() + if (!subscriber) break + + // Run an async task to serve each subscription. + this.#serveSubscribe(subscriber).catch((e) => { + const err = asError(e) + console.warn("failed to serve subscribe", err) + }) + } + } + + async #serveSubscribe(subscriber: SubscribeRecv) { + try { + const [base, ext] = splitExt(subscriber.track) + if (ext === "catalog") { + await this.#serveCatalog(subscriber, base) + } else if (ext === "mp4") { + await this.#serveInit(subscriber, base) + } else if (ext === "m4s") { + await this.#serveTrack(subscriber, base) + } else { + throw new Error(`unknown subscription: ${subscriber.track}`) + } + } catch (e) { + const err = asError(e) + await subscriber.close(1n, `failed to process subscribe: ${err.message}`) + } finally { + // TODO we can't close subscribers because there's no support for clean termination + // await subscriber.close() + } + } + + async #serveCatalog(subscriber: SubscribeRecv, name: string) { + // We only support ".catalog" + if (name !== "") throw new Error(`unknown catalog: ${name}`) + + const bytes = Catalog.encode(this.catalog) + + // Send a SUBSCRIBE_OK + await subscriber.ack() + + const stream = await subscriber.group({ group: 0 }) + await stream.write({ object: 0, payload: bytes }) + await stream.close() + } + + async #serveInit(subscriber: SubscribeRecv, name: string) { + const track = this.#tracks.get(name) + if (!track) throw new Error(`no track with name ${subscriber.track}`) + + // Send a SUBSCRIBE_OK + await subscriber.ack() + + const init = await track.init() + + const stream = await subscriber.group({ group: 0 }) + await stream.write({ object: 0, payload: init }) + await stream.close() + } + + async #serveTrack(subscriber: SubscribeRecv, name: string) { + const track = this.#tracks.get(name) + if (!track) throw new Error(`no track with name ${subscriber.track}`) + + // Send a SUBSCRIBE_OK + await subscriber.ack() + + const segments = track.segments().getReader() + + for (;;) { + const { value: segment, done } = await segments.read() + if (done) break + + // Serve the segment and log any errors that occur. + this.#serveSegment(subscriber, segment).catch((e) => { + const err = asError(e) + console.warn("failed to serve segment", err) + }) + } + } + + async #serveSegment(subscriber: SubscribeRecv, segment: Segment) { + // Create a new stream for each segment. + const stream = await subscriber.group({ + group: segment.id, + priority: 0, // TODO + }) + + let object = 0 + + // Pipe the segment to the stream. + const chunks = segment.chunks().getReader() + for (;;) { + const { value, done } = await chunks.read() + if (done) break + + await stream.write({ + object, + payload: value, + }) + + object += 1 + } + + await stream.close() + } + + // Attach the captured video stream to the given video element. + attach(video: HTMLVideoElement) { + video.srcObject = this.config.media + } + + close() { + // TODO implement publish close + } + + // Returns the error message when the connection is closed + async closed(): Promise { + try { + await this.#running + return new Error("closed") // clean termination + } catch (e) { + return asError(e) + } + } +} + +function splitExt(s: string): [string, string] { + const i = s.lastIndexOf(".") + if (i < 0) throw new Error(`no extension found`) + return [s.substring(0, i), s.substring(i + 1)] +} diff --git a/packages/moq/contribute/chunk.ts b/packages/moq/contribute/chunk.ts new file mode 100644 index 00000000..2fcfe334 --- /dev/null +++ b/packages/moq/contribute/chunk.ts @@ -0,0 +1,7 @@ +// Extends EncodedVideoChunk, allowing a new "init" type +export interface Chunk { + type: "init" | "key" | "delta" + timestamp: number // microseconds + duration: number // microseconds + data: Uint8Array +} diff --git a/packages/moq/contribute/container.ts b/packages/moq/contribute/container.ts new file mode 100644 index 00000000..ab1b4f1d --- /dev/null +++ b/packages/moq/contribute/container.ts @@ -0,0 +1,165 @@ +import * as MP4 from "../media/mp4" +import { Chunk } from "./chunk" + +type DecoderConfig = AudioDecoderConfig | VideoDecoderConfig +type EncodedChunk = EncodedAudioChunk | EncodedVideoChunk + +export class Container { + #mp4: MP4.ISOFile + #frame?: EncodedAudioChunk | EncodedVideoChunk // 1 frame buffer + #track?: number + #segment = 0 + + encode: TransformStream + + constructor() { + this.#mp4 = new MP4.ISOFile() + this.#mp4.init() + + this.encode = new TransformStream({ + transform: (frame, controller) => { + if (isDecoderConfig(frame)) { + return this.#init(frame, controller) + } else { + return this.#enqueue(frame, controller) + } + }, + }) + } + + #init(frame: DecoderConfig, controller: TransformStreamDefaultController) { + if (this.#track) throw new Error("duplicate decoder config") + + let codec = frame.codec.substring(0, 4) + if (codec == "opus") { + codec = "Opus" + } + + const options: MP4.TrackOptions = { + type: codec, + timescale: 1_000_000, + } + + if (isVideoConfig(frame)) { + options.width = frame.codedWidth + options.height = frame.codedHeight + } else { + options.channel_count = frame.numberOfChannels + options.samplerate = frame.sampleRate + } + + if (!frame.description) throw new Error("missing frame description") + const desc = frame.description as ArrayBufferLike + + if (codec === "avc1") { + options.avcDecoderConfigRecord = desc + } else if (codec === "hev1") { + options.hevcDecoderConfigRecord = desc + } else if (codec === "Opus") { + // description is an identification header: https://datatracker.ietf.org/doc/html/rfc7845#section-5.1 + // The first 8 bytes are the magic string "OpusHead", followed by what we actually want. + const dops = new MP4.BoxParser.dOpsBox(undefined) + + // Annoyingly, the header is little endian while MP4 is big endian, so we have to parse. + const data = new MP4.Stream(desc, 8, MP4.Stream.LITTLE_ENDIAN) + dops.parse(data) + + dops.Version = 0 + options.description = dops + options.hdlr = "soun" + } else { + throw new Error(`unsupported codec: ${codec}`) + } + + this.#track = this.#mp4.addTrack(options) + if (!this.#track) throw new Error("failed to initialize MP4 track") + + const buffer = MP4.ISOFile.writeInitializationSegment(this.#mp4.ftyp!, this.#mp4.moov!, 0, 0) + const data = new Uint8Array(buffer) + + controller.enqueue({ + type: "init", + timestamp: 0, + duration: 0, + data, + }) + } + + #enqueue(frame: EncodedChunk, controller: TransformStreamDefaultController) { + // Check if we should create a new segment + if (frame.type == "key") { + this.#segment += 1 + } else if (this.#segment == 0) { + throw new Error("must start with keyframe") + } + + // We need a one frame buffer to compute the duration + if (!this.#frame) { + this.#frame = frame + return + } + + const duration = frame.timestamp - this.#frame.timestamp + + // TODO avoid this extra copy by writing to the mdat directly + // ...which means changing mp4box.js to take an offset instead of ArrayBuffer + const buffer = new Uint8Array(this.#frame.byteLength) + this.#frame.copyTo(buffer) + + if (!this.#track) throw new Error("missing decoder config") + + // Add the sample to the container + this.#mp4.addSample(this.#track, buffer, { + duration, + dts: this.#frame.timestamp, + cts: this.#frame.timestamp, + is_sync: this.#frame.type == "key", + }) + + const stream = new MP4.Stream(undefined, 0, MP4.Stream.BIG_ENDIAN) + + // Moof and mdat atoms are written in pairs. + // TODO remove the moof/mdat from the Box to reclaim memory once everything works + for (;;) { + const moof = this.#mp4.moofs.shift() + const mdat = this.#mp4.mdats.shift() + + if (!moof && !mdat) break + if (!moof) throw new Error("moof missing") + if (!mdat) throw new Error("mdat missing") + + moof.write(stream) + mdat.write(stream) + } + + // TODO avoid this extra copy by writing to the buffer provided in copyTo + const data = new Uint8Array(stream.buffer) + + controller.enqueue({ + type: this.#frame.type, + timestamp: this.#frame.timestamp, + duration: this.#frame.duration ?? 0, + data, + }) + + this.#frame = frame + } + + /* TODO flush the last frame + #flush(controller: TransformStreamDefaultController) { + if (this.#frame) { + // TODO guess the duration + this.#enqueue(this.#frame, 0, controller) + } + } + */ +} + +function isDecoderConfig(frame: DecoderConfig | EncodedChunk): frame is DecoderConfig { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return (frame as DecoderConfig).codec !== undefined +} + +function isVideoConfig(frame: DecoderConfig): frame is VideoDecoderConfig { + return (frame as VideoDecoderConfig).codedWidth !== undefined +} diff --git a/packages/moq/contribute/index.ts b/packages/moq/contribute/index.ts new file mode 100644 index 00000000..704f6769 --- /dev/null +++ b/packages/moq/contribute/index.ts @@ -0,0 +1,5 @@ +export { Broadcast } from "./broadcast" +export type { BroadcastConfig, BroadcastConfigTrack } from "./broadcast" + +export { Encoder as VideoEncoder } from "./video" +export { Encoder as AudioEncoder } from "./audio" diff --git a/packages/moq/contribute/segment.ts b/packages/moq/contribute/segment.ts new file mode 100644 index 00000000..f0aa8195 --- /dev/null +++ b/packages/moq/contribute/segment.ts @@ -0,0 +1,45 @@ +import { Chunk } from "./chunk" + +export class Segment { + id: number + + // Take in a stream of chunks + input: WritableStream + + // Output a stream of bytes, which we fork for each new subscriber. + #cache: ReadableStream + + timestamp = 0 + + constructor(id: number) { + this.id = id + + // Set a max size for each segment, dropping the tail if it gets too long. + // We tee the reader, so this limit applies to the FASTEST reader. + const backpressure = new ByteLengthQueuingStrategy({ highWaterMark: 8_000_000 }) + + const transport = new TransformStream( + { + transform: (chunk: Chunk, controller) => { + // Compute the max timestamp of the segment + this.timestamp = Math.max(chunk.timestamp + chunk.duration) + + // Push the chunk to any listeners. + controller.enqueue(chunk.data) + }, + }, + undefined, + backpressure, + ) + + this.input = transport.writable + this.#cache = transport.readable + } + + // Split the output reader into two parts. + chunks(): ReadableStream { + const [tee, cache] = this.#cache.tee() + this.#cache = cache + return tee + } +} diff --git a/packages/moq/contribute/track.ts b/packages/moq/contribute/track.ts new file mode 100644 index 00000000..cec70d81 --- /dev/null +++ b/packages/moq/contribute/track.ts @@ -0,0 +1,170 @@ +import { Segment } from "./segment" +import { Notify } from "../common/async" +import { Chunk } from "./chunk" +import { Container } from "./container" +import { BroadcastConfig } from "./broadcast" + +import * as Audio from "./audio" +import * as Video from "./video" + +export class Track { + name: string + + #init?: Uint8Array + #segments: Segment[] = [] + + #offset = 0 // number of segments removed from the front of the queue + #closed = false + #error?: Error + #notify = new Notify() + + constructor(media: MediaStreamTrack, config: BroadcastConfig) { + this.name = media.kind + + // We need to split based on type because Typescript is hard + if (isAudioTrack(media)) { + if (!config.audio) throw new Error("no audio config") + this.#runAudio(media, config.audio).catch((err) => this.#close(err)) + } else if (isVideoTrack(media)) { + if (!config.video) throw new Error("no video config") + this.#runVideo(media, config.video).catch((err) => this.#close(err)) + } else { + throw new Error(`unknown track type: ${media.kind}`) + } + } + + async #runAudio(track: MediaStreamAudioTrack, config: AudioEncoderConfig) { + const source = new MediaStreamTrackProcessor({ track }) + const encoder = new Audio.Encoder(config) + const container = new Container() + + // Split the container at keyframe boundaries + const segments = new WritableStream({ + write: (chunk) => this.#write(chunk), + close: () => this.#close(), + abort: (e) => this.#close(e), + }) + + return source.readable.pipeThrough(encoder.frames).pipeThrough(container.encode).pipeTo(segments) + } + + async #runVideo(track: MediaStreamVideoTrack, config: VideoEncoderConfig) { + const source = new MediaStreamTrackProcessor({ track }) + const encoder = new Video.Encoder(config) + const container = new Container() + + // Split the container at keyframe boundaries + const segments = new WritableStream({ + write: (chunk) => this.#write(chunk), + close: () => this.#close(), + abort: (e) => this.#close(e), + }) + + return source.readable.pipeThrough(encoder.frames).pipeThrough(container.encode).pipeTo(segments) + } + + async #write(chunk: Chunk) { + if (chunk.type === "init") { + this.#init = chunk.data + this.#notify.wake() + return + } + + let current = this.#segments.at(-1) + if (!current || chunk.type === "key") { + if (current) { + await current.input.close() + } + + const segment = new Segment(this.#offset + this.#segments.length) + this.#segments.push(segment) + + this.#notify.wake() + + current = segment + + // Clear old segments + while (this.#segments.length > 1) { + const first = this.#segments[0] + + // Expire after 10s + if (chunk.timestamp - first.timestamp < 10_000_000) break + this.#segments.shift() + this.#offset += 1 + + await first.input.abort("expired") + } + } + + const writer = current.input.getWriter() + + if ((writer.desiredSize || 0) > 0) { + await writer.write(chunk) + } else { + console.warn("dropping chunk", writer.desiredSize) + } + + writer.releaseLock() + } + + async #close(e?: Error) { + this.#error = e + + const current = this.#segments.at(-1) + if (current) { + await current.input.close() + } + + this.#closed = true + this.#notify.wake() + } + + async init(): Promise { + while (!this.#init) { + if (this.#closed) throw new Error("track closed") + await this.#notify.wait() + } + + return this.#init + } + + // TODO generize this + segments(): ReadableStream { + let pos = this.#offset + + return new ReadableStream({ + pull: async (controller) => { + for (;;) { + let index = pos - this.#offset + if (index < 0) index = 0 + + if (index < this.#segments.length) { + controller.enqueue(this.#segments[index]) + pos += 1 + return // Called again when more data is requested + } + + if (this.#error) { + controller.error(this.#error) + return + } else if (this.#closed) { + controller.close() + return + } + + // Pull again on wakeup + // NOTE: We can't return until we enqueue at least one segment. + await this.#notify.wait() + } + }, + }) + } +} + +function isAudioTrack(track: MediaStreamTrack): track is MediaStreamAudioTrack { + return track.kind === "audio" +} + +function isVideoTrack(track: MediaStreamTrack): track is MediaStreamVideoTrack { + return track.kind === "video" +} diff --git a/packages/moq/contribute/tsconfig.json b/packages/moq/contribute/tsconfig.json new file mode 100644 index 00000000..70899a7f --- /dev/null +++ b/packages/moq/contribute/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../tsconfig.json", + "include": ["."], + "compilerOptions": { + "types": ["dom-mediacapture-transform", "dom-webcodecs"] + }, + "references": [ + { + "path": "../common" + }, + { + "path": "../transport" + }, + { + "path": "../media" + } + ] +} diff --git a/packages/moq/contribute/video.ts b/packages/moq/contribute/video.ts new file mode 100644 index 00000000..747c7659 --- /dev/null +++ b/packages/moq/contribute/video.ts @@ -0,0 +1,111 @@ +const SUPPORTED = [ + "avc1", // H.264 + "hev1", // HEVC (aka h.265) + // "av01", // TDOO support AV1 +] + +export interface EncoderSupported { + codecs: string[] +} + +export class Encoder { + #encoder!: VideoEncoder + #encoderConfig: VideoEncoderConfig + #decoderConfig?: VideoDecoderConfig + + // true if we should insert a keyframe, undefined when the encoder should decide + #keyframeNext: true | undefined = true + + // Count the number of frames without a keyframe. + #keyframeCounter = 0 + + // Converts raw rames to encoded frames. + frames: TransformStream + + constructor(config: VideoEncoderConfig) { + config.bitrateMode ??= "constant" + config.latencyMode ??= "realtime" + + this.#encoderConfig = config + + this.frames = new TransformStream({ + start: this.#start.bind(this), + transform: this.#transform.bind(this), + flush: this.#flush.bind(this), + }) + } + + static async isSupported(config: VideoEncoderConfig) { + // Check if we support a specific codec family + const short = config.codec.substring(0, 4) + if (!SUPPORTED.includes(short)) return false + + // Default to hardware encoding + config.hardwareAcceleration ??= "prefer-hardware" + + // Default to CBR + config.bitrateMode ??= "constant" + + // Default to realtime encoding + config.latencyMode ??= "realtime" + + const res = await VideoEncoder.isConfigSupported(config) + return !!res.supported + } + + #start(controller: TransformStreamDefaultController) { + this.#encoder = new VideoEncoder({ + output: (frame, metadata) => { + this.#enqueue(controller, frame, metadata) + }, + error: (err) => { + throw err + }, + }) + + this.#encoder.configure(this.#encoderConfig) + } + + #transform(frame: VideoFrame) { + const encoder = this.#encoder + + // Set keyFrame to undefined when we're not sure so the encoder can decide. + encoder.encode(frame, { keyFrame: this.#keyframeNext }) + this.#keyframeNext = undefined + + frame.close() + } + + #enqueue( + controller: TransformStreamDefaultController, + frame: EncodedVideoChunk, + metadata?: EncodedVideoChunkMetadata, + ) { + if (!this.#decoderConfig) { + const config = metadata?.decoderConfig + if (!config) throw new Error("missing decoder config") + + controller.enqueue(config) + this.#decoderConfig = config + } + + if (frame.type === "key") { + this.#keyframeCounter = 0 + } else { + this.#keyframeCounter += 1 + if (this.#keyframeCounter + this.#encoder.encodeQueueSize >= 2 * this.#encoderConfig.framerate!) { + this.#keyframeNext = true + } + } + + controller.enqueue(frame) + } + + #flush() { + this.#encoder.close() + } + + get config() { + return this.#encoderConfig + } +} diff --git a/packages/moq/media/catalog/index.ts b/packages/moq/media/catalog/index.ts new file mode 100644 index 00000000..41170a52 --- /dev/null +++ b/packages/moq/media/catalog/index.ts @@ -0,0 +1,218 @@ +import { Connection } from "../../transport" +import { asError } from "../../common/error" + +export interface CommonTrackFields { + namespace?: string + packaging?: string + renderGroup?: number + altGroup?: number +} + +export interface Root { + version: number + streamingFormat: number + streamingFormatVersion: string + supportsDeltaUpdates: boolean + commonTrackFields: CommonTrackFields + tracks: Track[] +} + +export function encode(catalog: Root): Uint8Array { + const encoder = new TextEncoder() + const str = JSON.stringify(catalog) + return encoder.encode(str) +} + +export function decode(raw: Uint8Array): Root { + const decoder = new TextDecoder() + const str = decoder.decode(raw) + + const catalog = JSON.parse(str) + if (!isRoot(catalog)) { + throw new Error("invalid catalog") + } + + // Merge common track fields into each track. + for (const track of catalog.tracks) { + track.altGroup ??= catalog.commonTrackFields.altGroup + track.namespace ??= catalog.commonTrackFields.namespace + track.packaging ??= catalog.commonTrackFields.packaging + track.renderGroup ??= catalog.commonTrackFields.renderGroup + } + + return catalog +} + +export async function fetch(connection: Connection, namespace: string): Promise { + const subscribe = await connection.subscribe(namespace, ".catalog") + try { + const segment = await subscribe.data() + if (!segment) throw new Error("no catalog data") + + const chunk = await segment.read() + if (!chunk) throw new Error("no catalog chunk") + + await segment.close() + await subscribe.close() // we done + + if (chunk.payload instanceof Uint8Array) { + return decode(chunk.payload) + } else { + throw new Error("invalid catalog chunk") + } + } catch (e) { + const err = asError(e) + + // Close the subscription after we're done. + await subscribe.close(1n, err.message) + + throw err + } +} + +export function isRoot(catalog: any): catalog is Root { + if (!isCatalogFieldValid(catalog, "packaging")) return false + if (!isCatalogFieldValid(catalog, "namespace")) return false + if (!Array.isArray(catalog.tracks)) return false + return catalog.tracks.every((track: any) => isTrack(track)) +} + +export interface Track { + namespace?: string + name: string + depends?: any[] + packaging?: string + renderGroup?: number + selectionParams: SelectionParams // technically optional but not really + altGroup?: number + initTrack?: string + initData?: string +} + +export interface Mp4Track extends Track { + initTrack?: string + initData?: string + selectionParams: Mp4SelectionParams +} + +export interface SelectionParams { + codec?: string + mimeType?: string + bitrate?: number + lang?: string +} + +export interface Mp4SelectionParams extends SelectionParams { + mimeType: "video/mp4" +} + +export interface AudioTrack extends Track { + name: string + selectionParams: AudioSelectionParams +} + +export interface AudioSelectionParams extends SelectionParams { + samplerate: number + channelConfig: string +} + +export interface VideoTrack extends Track { + name: string + selectionParams: VideoSelectionParams + temporalId?: number + spatialId?: number +} + +export interface VideoSelectionParams extends SelectionParams { + width: number + height: number + displayWidth?: number + displayHeight?: number + framerate?: number +} + +export function isTrack(track: any): track is Track { + if (typeof track.name !== "string") return false + return true +} + +export function isMp4Track(track: any): track is Mp4Track { + if (!isTrack(track)) return false + if (typeof track.initTrack !== "string" && typeof track.initData !== "string") return false + if (typeof track.selectionParams.mimeType !== "string") return false + return true +} + +export function isVideoTrack(track: any): track is VideoTrack { + if (!isTrack(track)) return false + return isVideoSelectionParams(track.selectionParams) +} + +export function isVideoSelectionParams(params: any): params is VideoSelectionParams { + if (typeof params.width !== "number") return false + if (typeof params.height !== "number") return false + return true +} + +export function isAudioTrack(track: any): track is AudioTrack { + if (!isTrack(track)) return false + return isAudioSelectionParams(track.selectionParams) +} + +export function isAudioSelectionParams(params: any): params is AudioSelectionParams { + if (typeof params.channelConfig !== "string") return false + if (typeof params.samplerate !== "number") return false + return true +} + +function isCatalogFieldValid(catalog: any, field: string): boolean { + //packaging,namespace if common would be listed in commonTrackFields but if fields + //in commonTrackFields are mentiond in Tracks , the fields in Tracks precedes + + function isValidPackaging(packaging: any): boolean { + return packaging === "cmaf" || packaging === "loc" + } + + function isValidNamespace(namespace: any): boolean { + return typeof namespace === "string" + } + + let isValidField: (value: any) => boolean + if (field === "packaging") { + isValidField = isValidPackaging + } else if (field === "namespace") { + isValidField = isValidNamespace + } else { + throw new Error(`Invalid field: ${field}`) + } + + if (catalog.commonTrackFields[field] !== undefined && !isValidField(catalog.commonTrackFields[field])) { + return false + } + + for (const track of catalog.tracks) { + if (track[field] !== undefined && !isValidField(track[field])) { + return false + } + } + + return true +} + +export function isMediaTrack(track: any): track is Track { + if (track.name.toLowerCase().includes("audio") || track.name.toLowerCase().includes("video")) { + return true + } + + if (track.selectionParams && track.selectionParams.codec) { + const codec = track.selectionParams.codec.toLowerCase() + const acceptedCodecs = ["mp4a", "avc1"] + + for (const acceptedCodec of acceptedCodecs) { + if (codec.includes(acceptedCodec)) { + return true + } + } + } + return false +} diff --git a/packages/moq/media/mp4/index.ts b/packages/moq/media/mp4/index.ts new file mode 100644 index 00000000..5301db33 --- /dev/null +++ b/packages/moq/media/mp4/index.ts @@ -0,0 +1,37 @@ +// Rename some stuff so it's on brand. +// We need a separate file so this file can use the rename too. +import * as MP4 from "./rename" +export * from "./rename" + +export * from "./parser" + +export function isAudioTrack(track: MP4.Track): track is MP4.AudioTrack { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return (track as MP4.AudioTrack).audio !== undefined +} + +export function isVideoTrack(track: MP4.Track): track is MP4.VideoTrack { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return (track as MP4.VideoTrack).video !== undefined +} + +// TODO contribute to mp4box +MP4.BoxParser.dOpsBox.prototype.write = function (stream: MP4.Stream) { + this.size = this.ChannelMappingFamily === 0 ? 11 : 13 + this.ChannelMapping!.length + this.writeHeader(stream) + + stream.writeUint8(this.Version) + stream.writeUint8(this.OutputChannelCount) + stream.writeUint16(this.PreSkip) + stream.writeUint32(this.InputSampleRate) + stream.writeInt16(this.OutputGain) + stream.writeUint8(this.ChannelMappingFamily) + + if (this.ChannelMappingFamily !== 0) { + stream.writeUint8(this.StreamCount!) + stream.writeUint8(this.CoupledCount!) + for (const mapping of this.ChannelMapping!) { + stream.writeUint8(mapping) + } + } +} diff --git a/packages/moq/media/mp4/parser.ts b/packages/moq/media/mp4/parser.ts new file mode 100644 index 00000000..7cc78d08 --- /dev/null +++ b/packages/moq/media/mp4/parser.ts @@ -0,0 +1,71 @@ +import * as MP4 from "./index" + +export interface Frame { + track: MP4.Track // The track this frame belongs to + sample: MP4.Sample // The actual sample contain the frame data +} + +// Decode a MP4 container into individual samples. +export class Parser { + info!: MP4.Info + + #mp4 = MP4.New() + #offset = 0 + + #samples: Array = [] + + constructor(init: Uint8Array) { + this.#mp4.onError = (err) => { + console.error("MP4 error", err) + } + + this.#mp4.onReady = (info: MP4.Info) => { + this.info = info + + // Extract all of the tracks, because we don't know if it's audio or video. + for (const track of info.tracks) { + this.#mp4.setExtractionOptions(track.id, track, { nbSamples: 1 }) + } + } + + this.#mp4.onSamples = (_track_id: number, track: MP4.Track, samples: MP4.Sample[]) => { + for (const sample of samples) { + this.#samples.push({ track, sample }) + } + } + + this.#mp4.start() + + // For some reason we need to modify the underlying ArrayBuffer with offset + const copy = new Uint8Array(init) + const buffer = copy.buffer as MP4.ArrayBuffer + buffer.fileStart = this.#offset + + this.#mp4.appendBuffer(buffer) + this.#offset += buffer.byteLength + this.#mp4.flush() + + if (!this.info) { + throw new Error("could not parse MP4 info") + } + } + + decode(chunk: Uint8Array): Array { + const copy = new Uint8Array(chunk) + + // For some reason we need to modify the underlying ArrayBuffer with offset + const buffer = copy.buffer as MP4.ArrayBuffer + buffer.fileStart = this.#offset + + // Parse the data + this.#mp4.appendBuffer(buffer) + this.#mp4.flush() + + this.#offset += buffer.byteLength + + const samples = [...this.#samples] + this.#samples.length = 0 + + return samples + } +} diff --git a/packages/moq/media/mp4/rename.ts b/packages/moq/media/mp4/rename.ts new file mode 100644 index 00000000..36f8fc88 --- /dev/null +++ b/packages/moq/media/mp4/rename.ts @@ -0,0 +1,13 @@ +// Rename some stuff so it's on brand. +export { createFile as New, DataStream as Stream, ISOFile, BoxParser, Log } from "mp4box" + +export type { + MP4ArrayBuffer as ArrayBuffer, + MP4Info as Info, + MP4Track as Track, + MP4AudioTrack as AudioTrack, + MP4VideoTrack as VideoTrack, + Sample, + TrackOptions, + SampleOptions, +} from "mp4box" diff --git a/packages/moq/media/tsconfig.json b/packages/moq/media/tsconfig.json new file mode 100644 index 00000000..2e4ee287 --- /dev/null +++ b/packages/moq/media/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfig.json", + "include": ["."], + "compilerOptions": { + "types": ["mp4box"] + }, + "references": [ + { + "path": "../transport" + }, + { + "path": "../common" + } + ] +} diff --git a/packages/moq/package.json b/packages/moq/package.json new file mode 100644 index 00000000..30535dd2 --- /dev/null +++ b/packages/moq/package.json @@ -0,0 +1,29 @@ +{ + "name": "@nestri/moq", + "type": "module", + "version": "0.1.4", + "description": "Media over QUIC library", + "license": "(MIT OR Apache-2.0)", + "repository": "github:kixelated/moq-js", + "scripts": { + "build": "tsc -b && cp ../LICENSE* ./dist && cp ./README.md ./dist && cp ./package.json ./dist", + "lint": "eslint .", + "fmt": "prettier --write ." + }, + "devDependencies": { + "@types/audioworklet": "^0.0.50", + "@types/dom-mediacapture-transform": "^0.1.6", + "@types/dom-webcodecs": "^0.1.8", + "@typescript/lib-dom": "npm:@types/web@^0.0.115", + "@typescript-eslint/eslint-plugin": "^6.4.0", + "@typescript-eslint/parser": "^6.4.0", + "eslint": "^8.47.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "prettier": "^3.0.1", + "typescript": "^5.1.6" + }, + "dependencies": { + "mp4box": "^0.5.2" + } +} diff --git a/packages/moq/playback/audio.ts b/packages/moq/playback/audio.ts new file mode 100644 index 00000000..687885a7 --- /dev/null +++ b/packages/moq/playback/audio.ts @@ -0,0 +1,50 @@ +/// + +import * as Message from "./worker/message" + +// This is a non-standard way of importing worklet/workers. +// Unfortunately, it's the only option because of a Vite bug: https://github.com/vitejs/vite/issues/11823 +import workletURL from "./worklet/index.ts?worker&url" + +// NOTE: This must be on the main thread +export class Audio { + context: AudioContext + worklet: Promise + + constructor(config: Message.ConfigAudio) { + this.context = new AudioContext({ + latencyHint: "interactive", + sampleRate: config.sampleRate, + }) + + this.worklet = this.load(config) + } + + private async load(config: Message.ConfigAudio): Promise { + // Load the worklet source code. + await this.context.audioWorklet.addModule(workletURL) + + const volume = this.context.createGain() + volume.gain.value = 2.0 + + // Create the worklet + const worklet = new AudioWorkletNode(this.context, "renderer") + + worklet.port.addEventListener("message", this.on.bind(this)) + worklet.onprocessorerror = (e: Event) => { + console.error("Audio worklet error:", e) + } + + // Connect the worklet to the volume node and then to the speakers + worklet.connect(volume) + volume.connect(this.context.destination) + + worklet.port.postMessage({ config }) + + return worklet + } + + private on(_event: MessageEvent) { + // TODO + } +} diff --git a/packages/moq/playback/backend.ts b/packages/moq/playback/backend.ts new file mode 100644 index 00000000..2b336ce1 --- /dev/null +++ b/packages/moq/playback/backend.ts @@ -0,0 +1,114 @@ +/// + +import * as Message from "./worker/message" +import { Audio } from "./audio" + +import MediaWorker from "./worker?worker" +import { RingShared } from "../common/ring" +import { Root, isAudioTrack } from "../media/catalog" +import { GroupHeader } from "../transport/objects" + +export interface PlayerConfig { + canvas: OffscreenCanvas + catalog: Root +} + +// This is a non-standard way of importing worklet/workers. +// Unfortunately, it's the only option because of a Vite bug: https://github.com/vitejs/vite/issues/11823 + +// Responsible for sending messages to the worker and worklet. +export default class Backend { + // General worker + #worker: Worker + + // The audio context, which must be created on the main thread. + #audio?: Audio + + constructor(config: PlayerConfig) { + // TODO does this block the main thread? If so, make this async + // @ts-expect-error: The Vite typing is wrong https://github.com/vitejs/vite/blob/22bd67d70a1390daae19ca33d7de162140d533d6/packages/vite/client.d.ts#L182 + this.#worker = new MediaWorker({ format: "es" }) + this.#worker.addEventListener("message", this.on.bind(this)) + + let sampleRate: number | undefined + let channels: number | undefined + + for (const track of config.catalog.tracks) { + if (isAudioTrack(track)) { + if (sampleRate && track.selectionParams.samplerate !== sampleRate) { + throw new Error(`TODO multiple audio tracks with different sample rates`) + } + + sampleRate = track.selectionParams.samplerate + + // TODO properly handle weird channel configs + channels = Math.max(+track.selectionParams.channelConfig, channels ?? 0) + } + } + + const msg: Message.Config = {} + + // Only configure audio is we have an audio track + if (sampleRate && channels) { + msg.audio = { + channels: channels, + sampleRate: sampleRate, + ring: new RingShared(2, sampleRate / 10), // 100ms + } + + this.#audio = new Audio(msg.audio) + } + + // TODO only send the canvas if we have a video track + msg.video = { + canvas: config.canvas, + } + + this.send({ config: msg }, msg.video.canvas) + } + + async play() { + await this.#audio?.context.resume() + } + + init(init: Init) { + this.send({ init }) + } + + segment(segment: Segment) { + this.send({ segment }, segment.stream) + } + + async close() { + this.#worker.terminate() + await this.#audio?.context.close() + } + + // Enforce we're sending valid types to the worker + private send(msg: Message.ToWorker, ...transfer: Transferable[]) { + //console.log("sent message from main to worker", msg) + this.#worker.postMessage(msg, transfer) + } + + private on(e: MessageEvent) { + const msg = e.data as Message.FromWorker + + // Don't print the verbose timeline message. + if (!msg.timeline) { + //console.log("received message from worker to main", msg) + } + } +} + +export interface Init { + name: string // name of the init track + data: Uint8Array +} + +export interface Segment { + init: string // name of the init track + kind: "audio" | "video" + header: GroupHeader + buffer: Uint8Array + stream: ReadableStream +} diff --git a/packages/moq/playback/index.ts b/packages/moq/playback/index.ts new file mode 100644 index 00000000..706b2f2c --- /dev/null +++ b/packages/moq/playback/index.ts @@ -0,0 +1,190 @@ +import * as Message from "./worker/message" + +import { Connection } from "../transport/connection" +import * as Catalog from "../media/catalog" +import { asError } from "../common/error" + +import Backend from "./backend" + +import { Client } from "../transport/client" +import { GroupReader } from "../transport/objects" + +export type Range = Message.Range +export type Timeline = Message.Timeline + +export interface PlayerConfig { + url: string + namespace: string + fingerprint?: string // URL to fetch TLS certificate fingerprint + canvas: HTMLCanvasElement +} + +// This class must be created on the main thread due to AudioContext. +export class Player { + #backend: Backend + + // A periodically updated timeline + //#timeline = new Watch(undefined) + + #connection: Connection + #catalog: Catalog.Root + + // Running is a promise that resolves when the player is closed. + // #close is called with no error, while #abort is called with an error. + #running: Promise + #close!: () => void + #abort!: (err: Error) => void + + private constructor(connection: Connection, catalog: Catalog.Root, backend: Backend) { + this.#connection = connection + this.#catalog = catalog + this.#backend = backend + + const abort = new Promise((resolve, reject) => { + this.#close = resolve + this.#abort = reject + }) + + // Async work + this.#running = Promise.race([this.#run(), abort]).catch(this.#close) + } + + static async create(config: PlayerConfig): Promise { + const client = new Client({ url: config.url, fingerprint: config.fingerprint, role: "subscriber" }) + const connection = await client.connect() + + const catalog = await Catalog.fetch(connection, config.namespace) + console.log("catalog", catalog) + + const canvas = config.canvas.transferControlToOffscreen() + const backend = new Backend({ canvas, catalog }) + + return new Player(connection, catalog, backend) + } + + async #run() { + const inits = new Set<[string, string]>() + const tracks = new Array() + + for (const track of this.#catalog.tracks) { + if (!track.namespace) throw new Error("track has no namespace") + if (track.initTrack) inits.add([track.namespace, track.initTrack]) + tracks.push(track) + } + + // Call #runInit on each unique init track + // TODO do this in parallel with #runTrack to remove a round trip + await Promise.all(Array.from(inits).map((init) => this.#runInit(...init))) + + // Call #runTrack on each track + await Promise.all(tracks.map((track) => this.#runTrack(track))) + } + + async #runInit(namespace: string, name: string) { + const sub = await this.#connection.subscribe(namespace, name) + try { + const init = await Promise.race([sub.data(), this.#running]) + if (!init) throw new Error("no init data") + + // We don't care what type of reader we get, we just want the payload. + const chunk = await init.read() + if (!chunk) throw new Error("no init chunk") + if (!(chunk.payload instanceof Uint8Array)) throw new Error("invalid init chunk") + + this.#backend.init({ data: chunk.payload, name }) + } finally { + await sub.close() + } + } + + async #runTrack(track: Catalog.Track) { + if (!track.namespace) throw new Error("track has no namespace") + const sub = await this.#connection.subscribe(track.namespace, track.name) + + try { + for (;;) { + const segment = await Promise.race([sub.data(), this.#running]) + if (!segment) break + + if (!(segment instanceof GroupReader)) { + throw new Error(`expected group reader for segment: ${track.name}`) + } + + const kind = Catalog.isVideoTrack(track) ? "video" : Catalog.isAudioTrack(track) ? "audio" : "unknown" + if (kind == "unknown") { + throw new Error(`unknown track kind: ${track.name}`) + } + + if (!track.initTrack) { + throw new Error(`no init track for segment: ${track.name}`) + } + + const [buffer, stream] = segment.stream.release() + + this.#backend.segment({ + init: track.initTrack, + kind, + header: segment.header, + buffer, + stream, + }) + } + } catch (error) { + console.error("Error in #runTrack:", error) + } finally { + await sub.close() + } + } + + getCatalog() { + return this.#catalog + } + + #onMessage(msg: Message.FromWorker) { + if (msg.timeline) { + //this.#timeline.update(msg.timeline) + } + } + + async close(err?: Error) { + if (err) this.#abort(err) + else this.#close() + + if (this.#connection) this.#connection.close() + if (this.#backend) await this.#backend.close() + } + + async closed(): Promise { + try { + await this.#running + } catch (e) { + return asError(e) + } + } + + /* + play() { + this.#backend.play({ minBuffer: 0.5 }) // TODO configurable + } + + seek(timestamp: number) { + this.#backend.seek({ timestamp }) + } + */ + + async play() { + await this.#backend.play() + } + + /* + async *timeline() { + for (;;) { + const [timeline, next] = this.#timeline.value() + if (timeline) yield timeline + if (!next) break + + await next + } + } + */ +} diff --git a/packages/moq/playback/tsconfig.json b/packages/moq/playback/tsconfig.json new file mode 100644 index 00000000..77318e89 --- /dev/null +++ b/packages/moq/playback/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../tsconfig.json", + "include": ["."], + "exclude": ["./worklet"], + "compilerOptions": { + "types": ["dom-mediacapture-transform", "dom-webcodecs"] + }, + "references": [ + { + "path": "../common" + }, + { + "path": "../transport" + }, + { + "path": "../media" + } + ], + "paths": { + "@/*": ["*"] + } +} diff --git a/packages/moq/playback/worker/audio.ts b/packages/moq/playback/worker/audio.ts new file mode 100644 index 00000000..de57e364 --- /dev/null +++ b/packages/moq/playback/worker/audio.ts @@ -0,0 +1,73 @@ +import * as Message from "./message" +import { Ring } from "../../common/ring" +import { Component, Frame } from "./timeline" +import * as MP4 from "../../media/mp4" + +// This is run in a worker. +export class Renderer { + #ring: Ring + #timeline: Component + + #decoder!: AudioDecoder + #stream: TransformStream + + constructor(config: Message.ConfigAudio, timeline: Component) { + this.#timeline = timeline + this.#ring = new Ring(config.ring) + + this.#stream = new TransformStream({ + start: this.#start.bind(this), + transform: this.#transform.bind(this), + }) + + this.#run().catch(console.error) + } + + #start(controller: TransformStreamDefaultController) { + this.#decoder = new AudioDecoder({ + output: (frame: AudioData) => { + controller.enqueue(frame) + }, + error: console.warn, + }) + } + + #transform(frame: Frame) { + if (this.#decoder.state !== "configured") { + const track = frame.track + if (!MP4.isAudioTrack(track)) throw new Error("expected audio track") + + // We only support OPUS right now which doesn't need a description. + this.#decoder.configure({ + codec: track.codec, + sampleRate: track.audio.sample_rate, + numberOfChannels: track.audio.channel_count, + }) + } + + const chunk = new EncodedAudioChunk({ + type: frame.sample.is_sync ? "key" : "delta", + timestamp: frame.sample.dts / frame.track.timescale, + duration: frame.sample.duration, + data: frame.sample.data, + }) + + this.#decoder.decode(chunk) + } + + async #run() { + const reader = this.#timeline.frames.pipeThrough(this.#stream).getReader() + + for (;;) { + const { value: frame, done } = await reader.read() + if (done) break + + // Write audio samples to the ring buffer, dropping when there's no space. + const written = this.#ring.write(frame) + + if (written < frame.numberOfFrames) { + console.warn(`droppped ${frame.numberOfFrames - written} audio samples`) + } + } + } +} diff --git a/packages/moq/playback/worker/index.ts b/packages/moq/playback/worker/index.ts new file mode 100644 index 00000000..1275b3f5 --- /dev/null +++ b/packages/moq/playback/worker/index.ts @@ -0,0 +1,119 @@ +import { Timeline } from "./timeline" + +import * as Audio from "./audio" +import * as Video from "./video" + +import * as MP4 from "../../media/mp4" +import * as Message from "./message" +import { asError } from "../../common/error" +import { Deferred } from "../../common/async" +import { GroupReader, Reader } from "../../transport/objects" + +class Worker { + // Timeline receives samples, buffering them and choosing the timestamp to render. + #timeline = new Timeline() + + // A map of init tracks. + #inits = new Map>() + + // Renderer requests samples, rendering video frames and emitting audio frames. + #audio?: Audio.Renderer + #video?: Video.Renderer + + on(e: MessageEvent) { + const msg = e.data as Message.ToWorker + + if (msg.config) { + this.#onConfig(msg.config) + } else if (msg.init) { + // TODO buffer the init segmnet so we don't hold the stream open. + this.#onInit(msg.init) + } else if (msg.segment) { + this.#onSegment(msg.segment).catch(console.warn) + } else { + throw new Error(`unknown message: + ${JSON.stringify(msg)}`) + } + } + + #onConfig(msg: Message.Config) { + if (msg.audio) { + this.#audio = new Audio.Renderer(msg.audio, this.#timeline.audio) + } + + if (msg.video) { + this.#video = new Video.Renderer(msg.video, this.#timeline.video) + } + } + + #onInit(msg: Message.Init) { + let init = this.#inits.get(msg.name) + if (!init) { + init = new Deferred() + this.#inits.set(msg.name, init) + } + + init.resolve(msg.data) + } + + async #onSegment(msg: Message.Segment) { + let init = this.#inits.get(msg.init) + if (!init) { + init = new Deferred() + this.#inits.set(msg.init, init) + } + + // Create a new stream that we will use to decode. + const container = new MP4.Parser(await init.promise) + + const timeline = msg.kind === "audio" ? this.#timeline.audio : this.#timeline.video + const reader = new GroupReader(msg.header, new Reader(msg.buffer, msg.stream)) + + // Create a queue that will contain each MP4 frame. + const queue = new TransformStream({}) + const segment = queue.writable.getWriter() + + // Add the segment to the timeline + const segments = timeline.segments.getWriter() + await segments.write({ + sequence: msg.header.group, + frames: queue.readable, + }) + segments.releaseLock() + + // Read each chunk, decoding the MP4 frames and adding them to the queue. + for (;;) { + const chunk = await reader.read() + if (!chunk) { + break + } + + if (!(chunk.payload instanceof Uint8Array)) { + throw new Error(`invalid payload: ${chunk.payload}`) + } + + const frames = container.decode(chunk.payload) + for (const frame of frames) { + await segment.write(frame) + } + } + + // We done. + await segment.close() + } +} + +// Pass all events to the worker +const worker = new Worker() +self.addEventListener("message", (msg) => { + try { + worker.on(msg) + } catch (e) { + const err = asError(e) + console.warn("worker error:", err) + } +}) + +// Validates this is an expected message +function _send(msg: Message.FromWorker) { + postMessage(msg) +} diff --git a/packages/moq/playback/worker/message.ts b/packages/moq/playback/worker/message.ts new file mode 100644 index 00000000..e6a19181 --- /dev/null +++ b/packages/moq/playback/worker/message.ts @@ -0,0 +1,98 @@ +import { GroupHeader } from "../../transport/objects" +import { RingShared } from "../../common/ring" + +export interface Config { + audio?: ConfigAudio + video?: ConfigVideo +} + +export interface ConfigAudio { + channels: number + sampleRate: number + + ring: RingShared +} + +export interface ConfigVideo { + canvas: OffscreenCanvas +} + +export interface Init { + name: string // name of the init object + data: Uint8Array +} + +export interface Segment { + init: string // name of the init object + kind: "audio" | "video" + header: GroupHeader + buffer: Uint8Array + stream: ReadableStream +} + +/* +export interface Play { + // Start playback once the minimum buffer size has been reached. + minBuffer: number +} + +export interface Seek { + timestamp: number +} +*/ + +// Sent periodically with the current timeline info. +export interface Timeline { + // The current playback position + timestamp?: number + + // Audio specific information + audio: TimelineAudio + + // Video specific information + video: TimelineVideo +} + +export interface TimelineAudio { + buffer: Range[] +} + +export interface TimelineVideo { + buffer: Range[] +} + +export interface Range { + start: number + end: number +} + +// Used to validate that only the correct messages can be sent. + +// Any top level messages that can be sent to the worker. +export interface ToWorker { + // Sent to configure on startup. + config?: Config + + // Sent on each init/data stream + init?: Init + segment?: Segment + + /* + // Sent to control playback + play?: Play + seek?: Seek + */ +} + +// Any top-level messages that can be sent from the worker. +export interface FromWorker { + // Sent back to the main thread regularly to update the UI + timeline?: Timeline +} + +/* +interface ToWorklet { + config?: Audio.Config +} + +*/ diff --git a/packages/moq/playback/worker/timeline.ts b/packages/moq/playback/worker/timeline.ts new file mode 100644 index 00000000..2eaac02f --- /dev/null +++ b/packages/moq/playback/worker/timeline.ts @@ -0,0 +1,118 @@ +import type { Frame } from "../../media/mp4" +export type { Frame } + +export interface Range { + start: number + end: number +} + +export class Timeline { + // Maintain audio and video seprarately + audio: Component + video: Component + + // Construct a timeline + constructor() { + this.audio = new Component() + this.video = new Component() + } +} + +interface Segment { + sequence: number + frames: ReadableStream +} + +export class Component { + #current?: Segment + + frames: ReadableStream + #segments: TransformStream + + constructor() { + this.frames = new ReadableStream({ + pull: this.#pull.bind(this), + cancel: this.#cancel.bind(this), + }) + + // This is a hack to have an async channel with 100 items. + this.#segments = new TransformStream({}, { highWaterMark: 100 }) + } + + get segments() { + return this.#segments.writable + } + + async #pull(controller: ReadableStreamDefaultController) { + for (;;) { + // Get the next segment to render. + const segments = this.#segments.readable.getReader() + + let res + if (this.#current) { + // Get the next frame to render. + const frames = this.#current.frames.getReader() + + // Wait for either the frames or segments to be ready. + // NOTE: This assume that the first promise gets priority. + res = await Promise.race([frames.read(), segments.read()]) + + frames.releaseLock() + } else { + res = await segments.read() + } + + segments.releaseLock() + + const { value, done } = res + + if (done) { + // We assume the current segment has been closed + // TODO support the segments stream closing + this.#current = undefined + continue + } + + if (!isSegment(value)) { + // Return so the reader can decide when to get the next frame. + controller.enqueue(value) + return + } + + // We didn't get any frames, and instead got a new segment. + if (this.#current) { + if (value.sequence < this.#current.sequence) { + // Our segment is older than the current, abandon it. + await value.frames.cancel("skipping segment; too old") + continue + } else { + // Our segment is newer than the current, cancel the old one. + await this.#current.frames.cancel("skipping segment; too slow") + } + } + + this.#current = value + } + } + + async #cancel(reason: any) { + if (this.#current) { + await this.#current.frames.cancel(reason) + } + + const segments = this.#segments.readable.getReader() + for (;;) { + const { value: segment, done } = await segments.read() + if (done) break + + await segment.frames.cancel(reason) + } + } +} + +// Return if a type is a segment or frame +// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents +function isSegment(value: Segment | Frame): value is Segment { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return (value as Segment).frames !== undefined +} diff --git a/packages/moq/playback/worker/video.ts b/packages/moq/playback/worker/video.ts new file mode 100644 index 00000000..3c0fd11d --- /dev/null +++ b/packages/moq/playback/worker/video.ts @@ -0,0 +1,84 @@ +import { Frame, Component } from "./timeline" +import * as MP4 from "../../media/mp4" +import * as Message from "./message" + +export class Renderer { + #canvas: OffscreenCanvas + #timeline: Component + + #decoder!: VideoDecoder + #queue: TransformStream + + constructor(config: Message.ConfigVideo, timeline: Component) { + this.#canvas = config.canvas + this.#timeline = timeline + + this.#queue = new TransformStream({ + start: this.#start.bind(this), + transform: this.#transform.bind(this), + }) + + this.#run().catch(console.error) + } + + async #run() { + const reader = this.#timeline.frames.pipeThrough(this.#queue).getReader() + for (;;) { + const { value: frame, done } = await reader.read() + if (done) break + + self.requestAnimationFrame(() => { + this.#canvas.width = frame.displayWidth + this.#canvas.height = frame.displayHeight + + const ctx = this.#canvas.getContext("2d") + if (!ctx) throw new Error("failed to get canvas context") + + ctx.drawImage(frame, 0, 0, frame.displayWidth, frame.displayHeight) // TODO respect aspect ratio + frame.close() + }) + } + } + + #start(controller: TransformStreamDefaultController) { + this.#decoder = new VideoDecoder({ + output: (frame: VideoFrame) => { + controller.enqueue(frame) + }, + error: console.error, + }) + } + + #transform(frame: Frame) { + // Configure the decoder with the first frame + if (this.#decoder.state !== "configured") { + const { sample, track } = frame + + const desc = sample.description + const box = desc.avcC ?? desc.hvcC ?? desc.vpcC ?? desc.av1C + if (!box) throw new Error(`unsupported codec: ${track.codec}`) + + const buffer = new MP4.Stream(undefined, 0, MP4.Stream.BIG_ENDIAN) + box.write(buffer) + const description = new Uint8Array(buffer.buffer, 8) // Remove the box header. + + if (!MP4.isVideoTrack(track)) throw new Error("expected video track") + + this.#decoder.configure({ + codec: track.codec, + codedHeight: track.video.height, + codedWidth: track.video.width, + description, + // optimizeForLatency: true + }) + } + + const chunk = new EncodedVideoChunk({ + type: frame.sample.is_sync ? "key" : "delta", + data: frame.sample.data, + timestamp: frame.sample.dts / frame.track.timescale, + }) + + this.#decoder.decode(chunk) + } +} diff --git a/packages/moq/playback/worklet/index.ts b/packages/moq/playback/worklet/index.ts new file mode 100644 index 00000000..afe485df --- /dev/null +++ b/packages/moq/playback/worklet/index.ts @@ -0,0 +1,58 @@ +// TODO add support for @/ to avoid relative imports +import { Ring } from "../../common/ring" +import * as Message from "./message" + +class Renderer extends AudioWorkletProcessor { + ring?: Ring + base: number + + constructor() { + // The super constructor call is required. + super() + + this.base = 0 + this.port.onmessage = this.onMessage.bind(this) + } + + onMessage(e: MessageEvent) { + const msg = e.data as Message.From + if (msg.config) { + this.onConfig(msg.config) + } + } + + onConfig(config: Message.Config) { + this.ring = new Ring(config.ring) + } + + // Inputs and outputs in groups of 128 samples. + process(inputs: Float32Array[][], outputs: Float32Array[][], _parameters: Record): boolean { + if (!this.ring) { + // Paused + return true + } + + if (inputs.length != 1 && outputs.length != 1) { + throw new Error("only a single track is supported") + } + + if (this.ring.size() == this.ring.capacity) { + // This is a hack to clear any latency in the ring buffer. + // The proper solution is to play back slightly faster? + console.warn("resyncing ring buffer") + this.ring.clear() + return true + } + + const output = outputs[0] + + const size = this.ring.read(output) + if (size < output.length) { + // TODO trigger rebuffering event + } + + return true + } +} + +registerProcessor("renderer", Renderer) diff --git a/packages/moq/playback/worklet/message.ts b/packages/moq/playback/worklet/message.ts new file mode 100644 index 00000000..e55ebdd8 --- /dev/null +++ b/packages/moq/playback/worklet/message.ts @@ -0,0 +1,12 @@ +import { RingShared } from "../../common/ring" + +export interface From { + config?: Config +} + +export interface Config { + channels: number + sampleRate: number + + ring: RingShared +} diff --git a/packages/moq/playback/worklet/tsconfig.json b/packages/moq/playback/worklet/tsconfig.json new file mode 100644 index 00000000..77c5a718 --- /dev/null +++ b/packages/moq/playback/worklet/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "include": ["."], + "exclude": ["./index"], + "compilerOptions": { + "lib": ["es2022"], + "types": ["audioworklet"] + }, + "references": [ + { + "path": "../../common" + } + ] +} diff --git a/packages/moq/transport/client.ts b/packages/moq/transport/client.ts new file mode 100644 index 00000000..1ae51021 --- /dev/null +++ b/packages/moq/transport/client.ts @@ -0,0 +1,83 @@ +import * as Stream from "./stream" +import * as Setup from "./setup" +import * as Control from "./control" +import { Objects } from "./objects" +import { Connection } from "./connection" + +export interface ClientConfig { + url: string + + // Parameters used to create the MoQ session + role: Setup.Role + + // If set, the server fingerprint will be fetched from this URL. + // This is required to use self-signed certificates with Chrome (May 2023) + fingerprint?: string +} + +export class Client { + #fingerprint: Promise + + readonly config: ClientConfig + + constructor(config: ClientConfig) { + this.config = config + + this.#fingerprint = this.#fetchFingerprint(config.fingerprint).catch((e) => { + console.warn("failed to fetch fingerprint: ", e) + return undefined + }) + } + + async connect(): Promise { + // Helper function to make creating a promise easier + const options: WebTransportOptions = {} + + const fingerprint = await this.#fingerprint + if (fingerprint) options.serverCertificateHashes = [fingerprint] + + const quic = new WebTransport(this.config.url, options) + await quic.ready + + const stream = await quic.createBidirectionalStream() + + const writer = new Stream.Writer(stream.writable) + const reader = new Stream.Reader(new Uint8Array(), stream.readable) + + const setup = new Setup.Stream(reader, writer) + + // Send the setup message. + await setup.send.client({ versions: [Setup.Version.DRAFT_04], role: this.config.role }) + + // Receive the setup message. + // TODO verify the SETUP response. + const server = await setup.recv.server() + + if (server.version != Setup.Version.DRAFT_04) { + throw new Error(`unsupported server version: ${server.version}`) + } + + const control = new Control.Stream(reader, writer) + const objects = new Objects(quic) + + return new Connection(quic, control, objects) + } + + async #fetchFingerprint(url?: string): Promise { + if (!url) return + + // TODO remove this fingerprint when Chrome WebTransport accepts the system CA + const response = await fetch(url) + const hexString = await response.text() + + const hexBytes = new Uint8Array(hexString.length / 2) + for (let i = 0; i < hexBytes.length; i += 1) { + hexBytes[i] = parseInt(hexString.slice(2 * i, 2 * i + 2), 16) + } + + return { + algorithm: "sha-256", + value: hexBytes, + } + } +} diff --git a/packages/moq/transport/connection.ts b/packages/moq/transport/connection.ts new file mode 100644 index 00000000..55e5c77b --- /dev/null +++ b/packages/moq/transport/connection.ts @@ -0,0 +1,95 @@ +import * as Control from "./control" +import { Objects } from "./objects" +import { asError } from "../common/error" + +import { Publisher } from "./publisher" +import { Subscriber } from "./subscriber" + +export class Connection { + // The established WebTransport session. + #quic: WebTransport + + // Use to receive/send control messages. + #control: Control.Stream + + // Use to receive/send objects. + #objects: Objects + + // Module for contributing tracks. + #publisher: Publisher + + // Module for distributing tracks. + #subscriber: Subscriber + + // Async work running in the background + #running: Promise + + constructor(quic: WebTransport, control: Control.Stream, objects: Objects) { + this.#quic = quic + this.#control = control + this.#objects = objects + + this.#publisher = new Publisher(this.#control, this.#objects) + this.#subscriber = new Subscriber(this.#control, this.#objects) + + this.#running = this.#run() + } + + close(code = 0, reason = "") { + this.#quic.close({ closeCode: code, reason }) + } + + async #run(): Promise { + await Promise.all([this.#runControl(), this.#runObjects()]) + } + + announce(namespace: string) { + return this.#publisher.announce(namespace) + } + + announced() { + return this.#subscriber.announced() + } + + subscribe(namespace: string, track: string) { + return this.#subscriber.subscribe(namespace, track) + } + + subscribed() { + return this.#publisher.subscribed() + } + + async #runControl() { + // Receive messages until the connection is closed. + for (;;) { + const msg = await this.#control.recv() + await this.#recv(msg) + } + } + + async #runObjects() { + for (;;) { + const obj = await this.#objects.recv() + if (!obj) break + + await this.#subscriber.recvObject(obj) + } + } + + async #recv(msg: Control.Message) { + if (Control.isPublisher(msg)) { + await this.#subscriber.recv(msg) + } else { + await this.#publisher.recv(msg) + } + } + + async closed(): Promise { + try { + await this.#running + return new Error("closed") + } catch (e) { + return asError(e) + } + } +} diff --git a/packages/moq/transport/control.ts b/packages/moq/transport/control.ts new file mode 100644 index 00000000..e4cd93dc --- /dev/null +++ b/packages/moq/transport/control.ts @@ -0,0 +1,550 @@ +import { Reader, Writer } from "./stream" + +export type Message = Subscriber | Publisher + +// Sent by subscriber +export type Subscriber = Subscribe | Unsubscribe | AnnounceOk | AnnounceError + +export function isSubscriber(m: Message): m is Subscriber { + return ( + m.kind == Msg.Subscribe || m.kind == Msg.Unsubscribe || m.kind == Msg.AnnounceOk || m.kind == Msg.AnnounceError + ) +} + +// Sent by publisher +export type Publisher = SubscribeOk | SubscribeError | SubscribeDone | Announce | Unannounce + +export function isPublisher(m: Message): m is Publisher { + return ( + m.kind == Msg.SubscribeOk || + m.kind == Msg.SubscribeError || + m.kind == Msg.SubscribeDone || + m.kind == Msg.Announce || + m.kind == Msg.Unannounce + ) +} + +// I wish we didn't have to split Msg and Id into separate enums. +// However using the string in the message makes it easier to debug. +// We'll take the tiny performance hit until I'm better at Typescript. +export enum Msg { + // NOTE: object and setup are in other modules + Subscribe = "subscribe", + SubscribeOk = "subscribe_ok", + SubscribeError = "subscribe_error", + SubscribeDone = "subscribe_done", + Unsubscribe = "unsubscribe", + Announce = "announce", + AnnounceOk = "announce_ok", + AnnounceError = "announce_error", + Unannounce = "unannounce", + GoAway = "go_away", +} + +enum Id { + // NOTE: object and setup are in other modules + // Object = 0, + // Setup = 1, + + Subscribe = 0x3, + SubscribeOk = 0x4, + SubscribeError = 0x5, + SubscribeDone = 0xb, + Unsubscribe = 0xa, + Announce = 0x6, + AnnounceOk = 0x7, + AnnounceError = 0x8, + Unannounce = 0x9, + GoAway = 0x10, +} + +export interface Subscribe { + kind: Msg.Subscribe + + id: bigint + trackId: bigint + namespace: string + name: string + + location: Location + + params?: Parameters +} + +export type Location = LatestGroup | LatestObject | AbsoluteStart | AbsoluteRange + +export interface LatestGroup { + mode: "latest_group" +} + +export interface LatestObject { + mode: "latest_object" +} + +export interface AbsoluteStart { + mode: "absolute_start" + start_group: number + start_object: number +} + +export interface AbsoluteRange { + mode: "absolute_range" + start_group: number + start_object: number + end_group: number + end_object: number +} + +export type Parameters = Map + +export interface SubscribeOk { + kind: Msg.SubscribeOk + id: bigint + expires: bigint + latest?: [number, number] +} + +export interface SubscribeDone { + kind: Msg.SubscribeDone + id: bigint + code: bigint + reason: string + final?: [number, number] +} + +export interface SubscribeError { + kind: Msg.SubscribeError + id: bigint + code: bigint + reason: string +} + +export interface Unsubscribe { + kind: Msg.Unsubscribe + id: bigint +} + +export interface Announce { + kind: Msg.Announce + namespace: string + params?: Parameters +} + +export interface AnnounceOk { + kind: Msg.AnnounceOk + namespace: string +} + +export interface AnnounceError { + kind: Msg.AnnounceError + namespace: string + code: bigint + reason: string +} + +export interface Unannounce { + kind: Msg.Unannounce + namespace: string +} + +export class Stream { + private decoder: Decoder + private encoder: Encoder + + #mutex = Promise.resolve() + + constructor(r: Reader, w: Writer) { + this.decoder = new Decoder(r) + this.encoder = new Encoder(w) + } + + // Will error if two messages are read at once. + async recv(): Promise { + const msg = await this.decoder.message() + console.log("received message", msg) + return msg + } + + async send(msg: Message) { + const unlock = await this.#lock() + try { + console.log("sending message", msg) + await this.encoder.message(msg) + } finally { + unlock() + } + } + + async #lock() { + // Make a new promise that we can resolve later. + let done: () => void + const p = new Promise((resolve) => { + done = () => resolve() + }) + + // Wait until the previous lock is done, then resolve our our lock. + const lock = this.#mutex.then(() => done) + + // Save our lock as the next lock. + this.#mutex = p + + // Return the lock. + return lock + } +} + +export class Decoder { + r: Reader + + constructor(r: Reader) { + this.r = r + } + + private async msg(): Promise { + const t = await this.r.u53() + switch (t) { + case Id.Subscribe: + return Msg.Subscribe + case Id.SubscribeOk: + return Msg.SubscribeOk + case Id.SubscribeDone: + return Msg.SubscribeDone + case Id.SubscribeError: + return Msg.SubscribeError + case Id.Unsubscribe: + return Msg.Unsubscribe + case Id.Announce: + return Msg.Announce + case Id.AnnounceOk: + return Msg.AnnounceOk + case Id.AnnounceError: + return Msg.AnnounceError + case Id.Unannounce: + return Msg.Unannounce + case Id.GoAway: + return Msg.GoAway + } + + throw new Error(`unknown control message type: ${t}`) + } + + async message(): Promise { + const t = await this.msg() + switch (t) { + case Msg.Subscribe: + return this.subscribe() + case Msg.SubscribeOk: + return this.subscribe_ok() + case Msg.SubscribeError: + return this.subscribe_error() + case Msg.SubscribeDone: + return this.subscribe_done() + case Msg.Unsubscribe: + return this.unsubscribe() + case Msg.Announce: + return this.announce() + case Msg.AnnounceOk: + return this.announce_ok() + case Msg.Unannounce: + return this.unannounce() + case Msg.AnnounceError: + return this.announce_error() + case Msg.GoAway: + throw new Error("TODO: implement go away") + } + } + + private async subscribe(): Promise { + return { + kind: Msg.Subscribe, + id: await this.r.u62(), + trackId: await this.r.u62(), + namespace: await this.r.string(), + name: await this.r.string(), + location: await this.location(), + params: await this.parameters(), + } + } + + private async location(): Promise { + const mode = await this.r.u62() + if (mode == 1n) { + return { + mode: "latest_group", + } + } else if (mode == 2n) { + return { + mode: "latest_object", + } + } else if (mode == 3n) { + return { + mode: "absolute_start", + start_group: await this.r.u53(), + start_object: await this.r.u53(), + } + } else if (mode == 4n) { + return { + mode: "absolute_range", + start_group: await this.r.u53(), + start_object: await this.r.u53(), + end_group: await this.r.u53(), + end_object: await this.r.u53(), + } + } else { + throw new Error(`invalid filter type: ${mode}`) + } + } + + private async parameters(): Promise { + const count = await this.r.u53() + if (count == 0) return undefined + + const params = new Map() + + for (let i = 0; i < count; i++) { + const id = await this.r.u62() + const size = await this.r.u53() + const value = await this.r.read(size) + + if (params.has(id)) { + throw new Error(`duplicate parameter id: ${id}`) + } + + params.set(id, value) + } + + return params + } + + private async subscribe_ok(): Promise { + const id = await this.r.u62() + const expires = await this.r.u62() + + let latest: [number, number] | undefined + + const flag = await this.r.u8() + if (flag === 1) { + latest = [await this.r.u53(), await this.r.u53()] + } else if (flag !== 0) { + throw new Error(`invalid final flag: ${flag}`) + } + + return { + kind: Msg.SubscribeOk, + id, + expires, + latest, + } + } + + private async subscribe_done(): Promise { + const id = await this.r.u62() + const code = await this.r.u62() + const reason = await this.r.string() + + let final: [number, number] | undefined + + const flag = await this.r.u8() + if (flag === 1) { + final = [await this.r.u53(), await this.r.u53()] + } else if (flag !== 0) { + throw new Error(`invalid final flag: ${flag}`) + } + + return { + kind: Msg.SubscribeDone, + id, + code, + reason, + final, + } + } + + private async subscribe_error(): Promise { + return { + kind: Msg.SubscribeError, + id: await this.r.u62(), + code: await this.r.u62(), + reason: await this.r.string(), + } + } + + private async unsubscribe(): Promise { + return { + kind: Msg.Unsubscribe, + id: await this.r.u62(), + } + } + + private async announce(): Promise { + const namespace = await this.r.string() + + return { + kind: Msg.Announce, + namespace, + params: await this.parameters(), + } + } + + private async announce_ok(): Promise { + return { + kind: Msg.AnnounceOk, + namespace: await this.r.string(), + } + } + + private async announce_error(): Promise { + return { + kind: Msg.AnnounceError, + namespace: await this.r.string(), + code: await this.r.u62(), + reason: await this.r.string(), + } + } + + private async unannounce(): Promise { + return { + kind: Msg.Unannounce, + namespace: await this.r.string(), + } + } +} + +export class Encoder { + w: Writer + + constructor(w: Writer) { + this.w = w + } + + async message(m: Message) { + switch (m.kind) { + case Msg.Subscribe: + return this.subscribe(m) + case Msg.SubscribeOk: + return this.subscribe_ok(m) + case Msg.SubscribeError: + return this.subscribe_error(m) + case Msg.SubscribeDone: + return this.subscribe_done(m) + case Msg.Unsubscribe: + return this.unsubscribe(m) + case Msg.Announce: + return this.announce(m) + case Msg.AnnounceOk: + return this.announce_ok(m) + case Msg.AnnounceError: + return this.announce_error(m) + case Msg.Unannounce: + return this.unannounce(m) + } + } + + async subscribe(s: Subscribe) { + await this.w.u53(Id.Subscribe) + await this.w.u62(s.id) + await this.w.u62(s.trackId) + await this.w.string(s.namespace) + await this.w.string(s.name) + await this.location(s.location) + await this.parameters(s.params) + } + + private async location(l: Location) { + switch (l.mode) { + case "latest_group": + await this.w.u62(1n) + break + case "latest_object": + await this.w.u62(2n) + break + case "absolute_start": + await this.w.u62(3n) + await this.w.u53(l.start_group) + await this.w.u53(l.start_object) + break + case "absolute_range": + await this.w.u62(3n) + await this.w.u53(l.start_group) + await this.w.u53(l.start_object) + await this.w.u53(l.end_group) + await this.w.u53(l.end_object) + } + } + + private async parameters(p: Parameters | undefined) { + if (!p) { + await this.w.u8(0) + return + } + + await this.w.u53(p.size) + for (const [id, value] of p) { + await this.w.u62(id) + await this.w.u53(value.length) + await this.w.write(value) + } + } + + async subscribe_ok(s: SubscribeOk) { + await this.w.u53(Id.SubscribeOk) + await this.w.u62(s.id) + await this.w.u62(s.expires) + + if (s.latest !== undefined) { + await this.w.u8(1) + await this.w.u53(s.latest[0]) + await this.w.u53(s.latest[1]) + } else { + await this.w.u8(0) + } + } + + async subscribe_done(s: SubscribeDone) { + await this.w.u53(Id.SubscribeDone) + await this.w.u62(s.id) + await this.w.u62(s.code) + await this.w.string(s.reason) + + if (s.final !== undefined) { + await this.w.u8(1) + await this.w.u53(s.final[0]) + await this.w.u53(s.final[1]) + } else { + await this.w.u8(0) + } + } + + async subscribe_error(s: SubscribeError) { + await this.w.u53(Id.SubscribeError) + await this.w.u62(s.id) + } + + async unsubscribe(s: Unsubscribe) { + await this.w.u53(Id.Unsubscribe) + await this.w.u62(s.id) + } + + async announce(a: Announce) { + await this.w.u53(Id.Announce) + await this.w.string(a.namespace) + await this.w.u53(0) // parameters + } + + async announce_ok(a: AnnounceOk) { + await this.w.u53(Id.AnnounceOk) + await this.w.string(a.namespace) + } + + async announce_error(a: AnnounceError) { + await this.w.u53(Id.AnnounceError) + await this.w.string(a.namespace) + await this.w.u62(a.code) + await this.w.string(a.reason) + } + + async unannounce(a: Unannounce) { + await this.w.u53(Id.Unannounce) + await this.w.string(a.namespace) + } +} diff --git a/packages/moq/transport/index.ts b/packages/moq/transport/index.ts new file mode 100644 index 00000000..ca86a6c5 --- /dev/null +++ b/packages/moq/transport/index.ts @@ -0,0 +1,7 @@ +export { Client } from "./client" +export type { ClientConfig } from "./client" + +export { Connection } from "./connection" + +export { SubscribeRecv, AnnounceSend } from "./publisher" +export { AnnounceRecv, SubscribeSend } from "./subscriber" diff --git a/packages/moq/transport/objects.ts b/packages/moq/transport/objects.ts new file mode 100644 index 00000000..05c9ed4d --- /dev/null +++ b/packages/moq/transport/objects.ts @@ -0,0 +1,307 @@ +import { Reader, Writer } from "./stream" +export { Reader, Writer } + +export enum StreamType { + Object = 0x0, + Track = 0x50, + Group = 0x51, +} + +export enum Status { + OBJECT_NULL = 1, + GROUP_NULL = 2, + GROUP_END = 3, + TRACK_END = 4, +} + +export interface TrackHeader { + type: StreamType.Track + sub: bigint + track: bigint + priority: number // VarInt with a u32 maximum value +} + +export interface TrackChunk { + group: number // The group sequence, as a number because 2^53 is enough. + object: number + payload: Uint8Array | Status +} + +export interface GroupHeader { + type: StreamType.Group + sub: bigint + track: bigint + group: number // The group sequence, as a number because 2^53 is enough. + priority: number // VarInt with a u32 maximum value +} + +export interface GroupChunk { + object: number + payload: Uint8Array | Status +} + +export interface ObjectHeader { + type: StreamType.Object + sub: bigint + track: bigint + group: number + object: number + priority: number + status: number +} + +export interface ObjectChunk { + payload: Uint8Array +} + +type WriterType = T extends TrackHeader + ? TrackWriter + : T extends GroupHeader + ? GroupWriter + : T extends ObjectHeader + ? ObjectWriter + : never + +export class Objects { + private quic: WebTransport + + constructor(quic: WebTransport) { + this.quic = quic + } + + async send(h: T): Promise> { + const stream = await this.quic.createUnidirectionalStream() + const w = new Writer(stream) + + await w.u53(h.type) + await w.u62(h.sub) + await w.u62(h.track) + + let res: WriterType + + if (h.type == StreamType.Object) { + await w.u53(h.group) + await w.u53(h.object) + await w.u53(h.priority) + await w.u53(h.status) + + res = new ObjectWriter(h, w) as WriterType + } else if (h.type === StreamType.Group) { + await w.u53(h.group) + await w.u53(h.priority) + + res = new GroupWriter(h, w) as WriterType + } else if (h.type === StreamType.Track) { + await w.u53(h.priority) + + res = new TrackWriter(h, w) as WriterType + } else { + throw new Error("unknown header type") + } + + // console.trace("send object", res.header) + + return res + } + + async recv(): Promise { + const streams = this.quic.incomingUnidirectionalStreams.getReader() + + const { value, done } = await streams.read() + streams.releaseLock() + + if (done) return + + const r = new Reader(new Uint8Array(), value) + const type = (await r.u53()) as StreamType + let res: TrackReader | GroupReader | ObjectReader + + if (type == StreamType.Track) { + const h: TrackHeader = { + type, + sub: await r.u62(), + track: await r.u62(), + priority: await r.u53(), + } + + res = new TrackReader(h, r) + } else if (type == StreamType.Group) { + const h: GroupHeader = { + type, + sub: await r.u62(), + track: await r.u62(), + group: await r.u53(), + priority: await r.u53(), + } + res = new GroupReader(h, r) + } else if (type == StreamType.Object) { + const h = { + type, + sub: await r.u62(), + track: await r.u62(), + group: await r.u53(), + object: await r.u53(), + status: await r.u53(), + priority: await r.u53(), + } + + res = new ObjectReader(h, r) + } else { + throw new Error("unknown stream type") + } + + // console.trace("receive object", res.header) + + return res + } +} + +export class TrackWriter { + constructor( + public header: TrackHeader, + public stream: Writer, + ) {} + + async write(c: TrackChunk) { + await this.stream.u53(c.group) + await this.stream.u53(c.object) + + if (c.payload instanceof Uint8Array) { + await this.stream.u53(c.payload.byteLength) + await this.stream.write(c.payload) + } else { + // empty payload with status + await this.stream.u53(0) + await this.stream.u53(c.payload as number) + } + } + + async close() { + await this.stream.close() + } +} + +export class GroupWriter { + constructor( + public header: GroupHeader, + public stream: Writer, + ) {} + + async write(c: GroupChunk) { + await this.stream.u53(c.object) + if (c.payload instanceof Uint8Array) { + await this.stream.u53(c.payload.byteLength) + await this.stream.write(c.payload) + } else { + await this.stream.u53(0) + await this.stream.u53(c.payload as number) + } + } + + async close() { + await this.stream.close() + } +} + +export class ObjectWriter { + constructor( + public header: ObjectHeader, + public stream: Writer, + ) {} + + async write(c: ObjectChunk) { + await this.stream.write(c.payload) + } + + async close() { + await this.stream.close() + } +} + +export class TrackReader { + constructor( + public header: TrackHeader, + public stream: Reader, + ) {} + + async read(): Promise { + if (await this.stream.done()) { + return + } + + const group = await this.stream.u53() + const object = await this.stream.u53() + const size = await this.stream.u53() + + let payload + if (size == 0) { + payload = (await this.stream.u53()) as Status + } else { + payload = await this.stream.read(size) + } + + return { + group, + object, + payload, + } + } + + async close() { + await this.stream.close() + } +} + +export class GroupReader { + constructor( + public header: GroupHeader, + public stream: Reader, + ) {} + + async read(): Promise { + if (await this.stream.done()) { + return + } + + const object = await this.stream.u53() + const size = await this.stream.u53() + + let payload + if (size == 0) { + payload = (await this.stream.u53()) as Status + } else { + payload = await this.stream.read(size) + } + + return { + object, + payload, + } + } + + async close() { + await this.stream.close() + } +} + +export class ObjectReader { + constructor( + public header: ObjectHeader, + public stream: Reader, + ) {} + + // NOTE: Can only be called once. + async read(): Promise { + if (await this.stream.done()) { + return + } + + return { + payload: await this.stream.readAll(), + } + } + + async close() { + await this.stream.close() + } +} diff --git a/packages/moq/transport/publisher.ts b/packages/moq/transport/publisher.ts new file mode 100644 index 00000000..547344d7 --- /dev/null +++ b/packages/moq/transport/publisher.ts @@ -0,0 +1,230 @@ +import * as Control from "./control" +import { Queue, Watch } from "../common/async" +import { Objects, GroupWriter, ObjectWriter, StreamType, TrackWriter } from "./objects" + +export class Publisher { + // Used to send control messages + #control: Control.Stream + + // Use to send objects. + #objects: Objects + + // Our announced tracks. + #announce = new Map() + + // Their subscribed tracks. + #subscribe = new Map() + #subscribeQueue = new Queue(Number.MAX_SAFE_INTEGER) // Unbounded queue in case there's no receiver + + constructor(control: Control.Stream, objects: Objects) { + this.#control = control + this.#objects = objects + } + + // Announce a track namespace. + async announce(namespace: string): Promise { + if (this.#announce.has(namespace)) { + throw new Error(`already announce: ${namespace}`) + } + + const announce = new AnnounceSend(this.#control, namespace) + this.#announce.set(namespace, announce) + + await this.#control.send({ + kind: Control.Msg.Announce, + namespace, + }) + + return announce + } + + // Receive the next new subscription + async subscribed() { + return await this.#subscribeQueue.next() + } + + async recv(msg: Control.Subscriber) { + if (msg.kind == Control.Msg.Subscribe) { + await this.recvSubscribe(msg) + } else if (msg.kind == Control.Msg.Unsubscribe) { + this.recvUnsubscribe(msg) + } else if (msg.kind == Control.Msg.AnnounceOk) { + this.recvAnnounceOk(msg) + } else if (msg.kind == Control.Msg.AnnounceError) { + this.recvAnnounceError(msg) + } else { + throw new Error(`unknown control message`) // impossible + } + } + + recvAnnounceOk(msg: Control.AnnounceOk) { + const announce = this.#announce.get(msg.namespace) + if (!announce) { + throw new Error(`announce OK for unknown announce: ${msg.namespace}`) + } + + announce.onOk() + } + + recvAnnounceError(msg: Control.AnnounceError) { + const announce = this.#announce.get(msg.namespace) + if (!announce) { + // TODO debug this + console.warn(`announce error for unknown announce: ${msg.namespace}`) + return + } + + announce.onError(msg.code, msg.reason) + } + + async recvSubscribe(msg: Control.Subscribe) { + if (this.#subscribe.has(msg.id)) { + throw new Error(`duplicate subscribe for id: ${msg.id}`) + } + + const subscribe = new SubscribeRecv(this.#control, this.#objects, msg) + this.#subscribe.set(msg.id, subscribe) + await this.#subscribeQueue.push(subscribe) + + await this.#control.send({ kind: Control.Msg.SubscribeOk, id: msg.id, expires: 0n }) + } + + recvUnsubscribe(_msg: Control.Unsubscribe) { + throw new Error("TODO unsubscribe") + } +} + +export class AnnounceSend { + #control: Control.Stream + + readonly namespace: string + + // The current state, updated by control messages. + #state = new Watch<"init" | "ack" | Error>("init") + + constructor(control: Control.Stream, namespace: string) { + this.#control = control + this.namespace = namespace + } + + async ok() { + for (;;) { + const [state, next] = this.#state.value() + if (state === "ack") return + if (state instanceof Error) throw state + if (!next) throw new Error("closed") + + await next + } + } + + async active() { + for (;;) { + const [state, next] = this.#state.value() + if (state instanceof Error) throw state + if (!next) return + + await next + } + } + + async close() { + // TODO implement unsubscribe + // await this.#inner.sendUnsubscribe() + } + + closed() { + const [state, next] = this.#state.value() + return state instanceof Error || next == undefined + } + + onOk() { + if (this.closed()) return + this.#state.update("ack") + } + + onError(code: bigint, reason: string) { + if (this.closed()) return + + const err = new Error(`ANNOUNCE_ERROR (${code})` + reason ? `: ${reason}` : "") + this.#state.update(err) + } +} + +export class SubscribeRecv { + #control: Control.Stream + #objects: Objects + #id: bigint + #trackId: bigint + + readonly namespace: string + readonly track: string + + // The current state of the subscription. + #state: "init" | "ack" | "closed" = "init" + + constructor(control: Control.Stream, objects: Objects, msg: Control.Subscribe) { + this.#control = control // so we can send messages + this.#objects = objects // so we can send objects + this.#id = msg.id + this.#trackId = msg.trackId + this.namespace = msg.namespace + this.track = msg.name + } + + // Acknowledge the subscription as valid. + async ack() { + if (this.#state !== "init") return + this.#state = "ack" + + // Send the control message. + return this.#control.send({ kind: Control.Msg.SubscribeOk, id: this.#id, expires: 0n }) + } + + // Close the subscription with an error. + async close(code = 0n, reason = "") { + if (this.#state === "closed") return + this.#state = "closed" + + return this.#control.send({ + kind: Control.Msg.SubscribeDone, + id: this.#id, + code, + reason, + }) + } + + // Create a writable data stream for the entire track + async serve(props?: { priority: number }): Promise { + return this.#objects.send({ + type: StreamType.Track, + sub: this.#id, + track: this.#trackId, + priority: props?.priority ?? 0, + }) + } + + // Create a writable data stream for a group within the track + async group(props: { group: number; priority?: number }): Promise { + return this.#objects.send({ + type: StreamType.Group, + sub: this.#id, + track: this.#trackId, + group: props.group, + priority: props.priority ?? 0, + }) + } + + // Create a writable data stream for a single object within the track + async object(props: { group: number; object: number; priority?: number }): Promise { + return this.#objects.send({ + type: StreamType.Object, + sub: this.#id, + track: this.#trackId, + group: props.group, + object: props.object, + priority: props.priority ?? 0, + status: 0, + }) + } +} diff --git a/packages/moq/transport/setup.ts b/packages/moq/transport/setup.ts new file mode 100644 index 00000000..ac813925 --- /dev/null +++ b/packages/moq/transport/setup.ts @@ -0,0 +1,163 @@ +import { Reader, Writer } from "./stream" + +export type Message = Client | Server +export type Role = "publisher" | "subscriber" | "both" + +export enum Version { + DRAFT_00 = 0xff000000, + DRAFT_01 = 0xff000001, + DRAFT_02 = 0xff000002, + DRAFT_03 = 0xff000003, + DRAFT_04 = 0xff000004, + KIXEL_00 = 0xbad00, + KIXEL_01 = 0xbad01, +} + +// NOTE: These are forked from moq-transport-00. +// 1. messages lack a sized length +// 2. parameters are not optional and written in order (role + path) +// 3. role indicates local support only, not remote support + +export interface Client { + versions: Version[] + role: Role + params?: Parameters +} + +export interface Server { + version: Version + params?: Parameters +} + +export class Stream { + recv: Decoder + send: Encoder + + constructor(r: Reader, w: Writer) { + this.recv = new Decoder(r) + this.send = new Encoder(w) + } +} + +export type Parameters = Map + +export class Decoder { + r: Reader + + constructor(r: Reader) { + this.r = r + } + + async client(): Promise { + const type = await this.r.u53() + if (type !== 0x40) throw new Error(`client SETUP type must be 0x40, got ${type}`) + + const count = await this.r.u53() + + const versions = [] + for (let i = 0; i < count; i++) { + const version = await this.r.u53() + versions.push(version) + } + + const params = await this.parameters() + const role = this.role(params?.get(0n)) + + return { + versions, + role, + params, + } + } + + async server(): Promise { + const type = await this.r.u53() + if (type !== 0x41) throw new Error(`server SETUP type must be 0x41, got ${type}`) + + const version = await this.r.u53() + const params = await this.parameters() + + return { + version, + params, + } + } + + private async parameters(): Promise { + const count = await this.r.u53() + if (count == 0) return undefined + + const params = new Map() + + for (let i = 0; i < count; i++) { + const id = await this.r.u62() + const size = await this.r.u53() + const value = await this.r.read(size) + + if (params.has(id)) { + throw new Error(`duplicate parameter id: ${id}`) + } + + params.set(id, value) + } + + return params + } + + role(raw: Uint8Array | undefined): Role { + if (!raw) throw new Error("missing role parameter") + if (raw.length != 1) throw new Error("multi-byte varint not supported") + + switch (raw[0]) { + case 1: + return "publisher" + case 2: + return "subscriber" + case 3: + return "both" + default: + throw new Error(`invalid role: ${raw[0]}`) + } + } +} + +export class Encoder { + w: Writer + + constructor(w: Writer) { + this.w = w + } + + async client(c: Client) { + await this.w.u53(0x40) + await this.w.u53(c.versions.length) + for (const v of c.versions) { + await this.w.u53(v) + } + + // I hate it + const params = c.params ?? new Map() + params.set(0n, new Uint8Array([c.role == "publisher" ? 1 : c.role == "subscriber" ? 2 : 3])) + await this.parameters(params) + } + + async server(s: Server) { + await this.w.u53(0x41) + await this.w.u53(s.version) + await this.parameters(s.params) + } + + private async parameters(p: Parameters | undefined) { + if (!p) { + await this.w.u8(0) + return + } + + await this.w.u53(p.size) + for (const [id, value] of p) { + await this.w.u62(id) + await this.w.u53(value.length) + await this.w.write(value) + } + } +} diff --git a/packages/moq/transport/stream.ts b/packages/moq/transport/stream.ts new file mode 100644 index 00000000..e8ad59c8 --- /dev/null +++ b/packages/moq/transport/stream.ts @@ -0,0 +1,270 @@ +const MAX_U6 = Math.pow(2, 6) - 1 +const MAX_U14 = Math.pow(2, 14) - 1 +const MAX_U30 = Math.pow(2, 30) - 1 +const MAX_U31 = Math.pow(2, 31) - 1 +const MAX_U53 = Number.MAX_SAFE_INTEGER +const MAX_U62: bigint = 2n ** 62n - 1n + +// Reader wraps a stream and provides convience methods for reading pieces from a stream +// Unfortunately we can't use a BYOB reader because it's not supported with WebTransport+WebWorkers yet. +export class Reader { + #buffer: Uint8Array + #stream: ReadableStream + #reader: ReadableStreamDefaultReader + + constructor(buffer: Uint8Array, stream: ReadableStream) { + this.#buffer = buffer + this.#stream = stream + this.#reader = this.#stream.getReader() + } + + // Adds more data to the buffer, returning true if more data was added. + async #fill(): Promise { + const result = await this.#reader.read() + if (result.done) { + return false + } + + const buffer = new Uint8Array(result.value) + + if (this.#buffer.byteLength == 0) { + this.#buffer = buffer + } else { + const temp = new Uint8Array(this.#buffer.byteLength + buffer.byteLength) + temp.set(this.#buffer) + temp.set(buffer, this.#buffer.byteLength) + this.#buffer = temp + } + + return true + } + + // Add more data to the buffer until it's at least size bytes. + async #fillTo(size: number) { + while (this.#buffer.byteLength < size) { + if (!(await this.#fill())) { + throw new Error("unexpected end of stream") + } + } + } + + // Consumes the first size bytes of the buffer. + #slice(size: number): Uint8Array { + const result = new Uint8Array(this.#buffer.buffer, this.#buffer.byteOffset, size) + this.#buffer = new Uint8Array(this.#buffer.buffer, this.#buffer.byteOffset + size) + + return result + } + + async read(size: number): Promise { + if (size == 0) return new Uint8Array() + + await this.#fillTo(size) + return this.#slice(size) + } + + async readAll(): Promise { + // eslint-disable-next-line no-empty + while (await this.#fill()) {} + return this.#slice(this.#buffer.byteLength) + } + + async string(maxLength?: number): Promise { + const length = await this.u53() + if (maxLength !== undefined && length > maxLength) { + throw new Error(`string length ${length} exceeds max length ${maxLength}`) + } + + const buffer = await this.read(length) + return new TextDecoder().decode(buffer) + } + + async u8(): Promise { + await this.#fillTo(1) + return this.#slice(1)[0] + } + + // Returns a Number using 53-bits, the max Javascript can use for integer math + async u53(): Promise { + const v = await this.u62() + if (v > MAX_U53) { + throw new Error("value larger than 53-bits; use v62 instead") + } + + return Number(v) + } + + // NOTE: Returns a bigint instead of a number since it may be larger than 53-bits + async u62(): Promise { + await this.#fillTo(1) + const size = (this.#buffer[0] & 0xc0) >> 6 + + if (size == 0) { + const first = this.#slice(1)[0] + return BigInt(first) & 0x3fn + } else if (size == 1) { + await this.#fillTo(2) + const slice = this.#slice(2) + const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength) + + return BigInt(view.getInt16(0)) & 0x3fffn + } else if (size == 2) { + await this.#fillTo(4) + const slice = this.#slice(4) + const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength) + + return BigInt(view.getUint32(0)) & 0x3fffffffn + } else if (size == 3) { + await this.#fillTo(8) + const slice = this.#slice(8) + const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength) + + return view.getBigUint64(0) & 0x3fffffffffffffffn + } else { + throw new Error("impossible") + } + } + + async done(): Promise { + if (this.#buffer.byteLength > 0) return false + return !(await this.#fill()) + } + + async close() { + this.#reader.releaseLock() + await this.#stream.cancel() + } + + release(): [Uint8Array, ReadableStream] { + this.#reader.releaseLock() + return [this.#buffer, this.#stream] + } +} + +// Writer wraps a stream and writes chunks of data +export class Writer { + #scratch: Uint8Array + #writer: WritableStreamDefaultWriter + #stream: WritableStream + + constructor(stream: WritableStream) { + this.#stream = stream + this.#scratch = new Uint8Array(8) + this.#writer = this.#stream.getWriter() + } + + async u8(v: number) { + await this.write(setUint8(this.#scratch, v)) + } + + async i32(v: number) { + if (Math.abs(v) > MAX_U31) { + throw new Error(`overflow, value larger than 32-bits: ${v}`) + } + + // We don't use a VarInt, so it always takes 4 bytes. + // This could be improved but nothing is standardized yet. + await this.write(setInt32(this.#scratch, v)) + } + + async u53(v: number) { + if (v < 0) { + throw new Error(`underflow, value is negative: ${v}`) + } else if (v > MAX_U53) { + throw new Error(`overflow, value larger than 53-bits: ${v}`) + } + + await this.write(setVint53(this.#scratch, v)) + } + + async u62(v: bigint) { + if (v < 0) { + throw new Error(`underflow, value is negative: ${v}`) + } else if (v >= MAX_U62) { + throw new Error(`overflow, value larger than 62-bits: ${v}`) + } + + await this.write(setVint62(this.#scratch, v)) + } + + async write(v: Uint8Array) { + await this.#writer.write(v) + } + + async string(str: string) { + const data = new TextEncoder().encode(str) + await this.u53(data.byteLength) + await this.write(data) + } + + async close() { + this.#writer.releaseLock() + await this.#stream.close() + } + + release(): WritableStream { + this.#writer.releaseLock() + return this.#stream + } +} + +function setUint8(dst: Uint8Array, v: number): Uint8Array { + dst[0] = v + return dst.slice(0, 1) +} + +function setUint16(dst: Uint8Array, v: number): Uint8Array { + const view = new DataView(dst.buffer, dst.byteOffset, 2) + view.setUint16(0, v) + + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength) +} + +function setInt32(dst: Uint8Array, v: number): Uint8Array { + const view = new DataView(dst.buffer, dst.byteOffset, 4) + view.setInt32(0, v) + + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength) +} + +function setUint32(dst: Uint8Array, v: number): Uint8Array { + const view = new DataView(dst.buffer, dst.byteOffset, 4) + view.setUint32(0, v) + + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength) +} + +function setVint53(dst: Uint8Array, v: number): Uint8Array { + if (v <= MAX_U6) { + return setUint8(dst, v) + } else if (v <= MAX_U14) { + return setUint16(dst, v | 0x4000) + } else if (v <= MAX_U30) { + return setUint32(dst, v | 0x80000000) + } else if (v <= MAX_U53) { + return setUint64(dst, BigInt(v) | 0xc000000000000000n) + } else { + throw new Error(`overflow, value larger than 53-bits: ${v}`) + } +} + +function setVint62(dst: Uint8Array, v: bigint): Uint8Array { + if (v < MAX_U6) { + return setUint8(dst, Number(v)) + } else if (v < MAX_U14) { + return setUint16(dst, Number(v) | 0x4000) + } else if (v <= MAX_U30) { + return setUint32(dst, Number(v) | 0x80000000) + } else if (v <= MAX_U62) { + return setUint64(dst, BigInt(v) | 0xc000000000000000n) + } else { + throw new Error(`overflow, value larger than 62-bits: ${v}`) + } +} + +function setUint64(dst: Uint8Array, v: bigint): Uint8Array { + const view = new DataView(dst.buffer, dst.byteOffset, 8) + view.setBigUint64(0, v) + + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength) +} diff --git a/packages/moq/transport/subscriber.ts b/packages/moq/transport/subscriber.ts new file mode 100644 index 00000000..bd1db717 --- /dev/null +++ b/packages/moq/transport/subscriber.ts @@ -0,0 +1,197 @@ +import * as Control from "./control" +import { Queue, Watch } from "../common/async" +import { Objects } from "./objects" +import type { TrackReader, GroupReader, ObjectReader } from "./objects" + +export class Subscriber { + // Use to send control messages. + #control: Control.Stream + + // Use to send objects. + #objects: Objects + + // Announced broadcasts. + #announce = new Map() + #announceQueue = new Watch([]) + + // Our subscribed tracks. + #subscribe = new Map() + #subscribeNext = 0n + + constructor(control: Control.Stream, objects: Objects) { + this.#control = control + this.#objects = objects + } + + announced(): Watch { + return this.#announceQueue + } + + async recv(msg: Control.Publisher) { + if (msg.kind == Control.Msg.Announce) { + await this.recvAnnounce(msg) + } else if (msg.kind == Control.Msg.Unannounce) { + this.recvUnannounce(msg) + } else if (msg.kind == Control.Msg.SubscribeOk) { + this.recvSubscribeOk(msg) + } else if (msg.kind == Control.Msg.SubscribeError) { + await this.recvSubscribeError(msg) + } else if (msg.kind == Control.Msg.SubscribeDone) { + await this.recvSubscribeDone(msg) + } else { + throw new Error(`unknown control message`) // impossible + } + } + + async recvAnnounce(msg: Control.Announce) { + if (this.#announce.has(msg.namespace)) { + throw new Error(`duplicate announce for namespace: ${msg.namespace}`) + } + + await this.#control.send({ kind: Control.Msg.AnnounceOk, namespace: msg.namespace }) + + const announce = new AnnounceRecv(this.#control, msg.namespace) + this.#announce.set(msg.namespace, announce) + + this.#announceQueue.update((queue) => [...queue, announce]) + } + + recvUnannounce(_msg: Control.Unannounce) { + throw new Error(`TODO Unannounce`) + } + + async subscribe(namespace: string, track: string) { + const id = this.#subscribeNext++ + + const subscribe = new SubscribeSend(this.#control, id, namespace, track) + this.#subscribe.set(id, subscribe) + + await this.#control.send({ + kind: Control.Msg.Subscribe, + id, + trackId: id, + namespace, + name: track, + location: { + mode: "latest_group", + }, + }) + + return subscribe + } + + recvSubscribeOk(msg: Control.SubscribeOk) { + const subscribe = this.#subscribe.get(msg.id) + if (!subscribe) { + throw new Error(`subscribe ok for unknown id: ${msg.id}`) + } + + subscribe.onOk() + } + + async recvSubscribeError(msg: Control.SubscribeError) { + const subscribe = this.#subscribe.get(msg.id) + if (!subscribe) { + throw new Error(`subscribe error for unknown id: ${msg.id}`) + } + + await subscribe.onError(msg.code, msg.reason) + } + + async recvSubscribeDone(msg: Control.SubscribeDone) { + const subscribe = this.#subscribe.get(msg.id) + if (!subscribe) { + throw new Error(`subscribe error for unknown id: ${msg.id}`) + } + + await subscribe.onError(msg.code, msg.reason) + } + + async recvObject(reader: TrackReader | GroupReader | ObjectReader) { + const subscribe = this.#subscribe.get(reader.header.track) + if (!subscribe) { + throw new Error(`data for for unknown track: ${reader.header.track}`) + } + + await subscribe.onData(reader) + } +} + +export class AnnounceRecv { + #control: Control.Stream + + readonly namespace: string + + // The current state of the announce + #state: "init" | "ack" | "closed" = "init" + + constructor(control: Control.Stream, namespace: string) { + this.#control = control // so we can send messages + this.namespace = namespace + } + + // Acknowledge the subscription as valid. + async ok() { + if (this.#state !== "init") return + this.#state = "ack" + + // Send the control message. + return this.#control.send({ kind: Control.Msg.AnnounceOk, namespace: this.namespace }) + } + + async close(code = 0n, reason = "") { + if (this.#state === "closed") return + this.#state = "closed" + + return this.#control.send({ kind: Control.Msg.AnnounceError, namespace: this.namespace, code, reason }) + } +} + +export class SubscribeSend { + #control: Control.Stream + #id: bigint + + readonly namespace: string + readonly track: string + + // A queue of received streams for this subscription. + #data = new Queue() + + constructor(control: Control.Stream, id: bigint, namespace: string, track: string) { + this.#control = control // so we can send messages + this.#id = id + this.namespace = namespace + this.track = track + } + + async close(_code = 0n, _reason = "") { + // TODO implement unsubscribe + // await this.#inner.sendReset(code, reason) + } + + onOk() { + // noop + } + + async onError(code: bigint, reason: string) { + if (code == 0n) { + return await this.#data.close() + } + + if (reason !== "") { + reason = `: ${reason}` + } + + const err = new Error(`SUBSCRIBE_ERROR (${code})${reason}`) + return await this.#data.abort(err) + } + + async onData(reader: TrackReader | GroupReader | ObjectReader) { + if (!this.#data.closed()) await this.#data.push(reader) + } + + // Receive the next a readable data stream + async data() { + return await this.#data.next() + } +} diff --git a/packages/moq/transport/tsconfig.json b/packages/moq/transport/tsconfig.json new file mode 100644 index 00000000..88da9069 --- /dev/null +++ b/packages/moq/transport/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "include": ["."], + "references": [ + { + "path": "../common" + } + ] +} diff --git a/packages/moq/tsconfig.json b/packages/moq/tsconfig.json new file mode 100644 index 00000000..2ab7124f --- /dev/null +++ b/packages/moq/tsconfig.json @@ -0,0 +1,42 @@ +{ + "files": [], // don't build anything with these settings. + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "moduleResolution": "node", + "rootDir": ".", + "outDir": "./dist", + "declaration": true, + "strict": true, + "composite": true, + "declarationMap": true, + "sourceMap": true, + "isolatedModules": true, + "types": [], // Don't automatically import any @types modules. + "lib": ["es2022", "dom"], + "typeRoots": ["./types", "../node_modules/@types"] + }, + "references": [ + { + "path": "./common" + }, + { + "path": "./playback" + }, + { + "path": "./playback/worklet" + }, + { + "path": "./contribute" + }, + { + "path": "./transport" + }, + { + "path": "./media" + } + ], + "paths": { + "@/*": ["*"] + } +} diff --git a/packages/moq/types/mp4box.d.ts b/packages/moq/types/mp4box.d.ts new file mode 100644 index 00000000..923887ad --- /dev/null +++ b/packages/moq/types/mp4box.d.ts @@ -0,0 +1,1848 @@ +// https://github.com/gpac/mp4box.js/issues/233 + +declare module "mp4box" { + export interface MP4MediaTrack { + id: number + created: Date + modified: Date + movie_duration: number + layer: number + alternate_group: number + volume: number + track_width: number + track_height: number + timescale: number + duration: number + bitrate: number + codec: string + language: string + nb_samples: number + } + + export interface MP4VideoData { + width: number + height: number + } + + export interface MP4VideoTrack extends MP4MediaTrack { + video: MP4VideoData + } + + export interface MP4AudioData { + sample_rate: number + channel_count: number + sample_size: number + } + + export interface MP4AudioTrack extends MP4MediaTrack { + audio: MP4AudioData + } + + export type MP4Track = MP4VideoTrack | MP4AudioTrack + + export interface MP4Info { + duration: number + timescale: number + fragment_duration: number + isFragmented: boolean + isProgressive: boolean + hasIOD: boolean + brands: string[] + created: Date + modified: Date + tracks: MP4Track[] + mime: string + audioTracks: MP4AudioTrack[] + videoTracks: MP4VideoTrack[] + } + + export type MP4ArrayBuffer = ArrayBuffer & { fileStart: number } + + export function createFile(): ISOFile + + export interface Sample { + number: number + track_id: number + timescale: number + description_index: number + description: { + avcC?: BoxParser.avcCBox // h.264 + hvcC?: BoxParser.hvcCBox // hevc + vpcC?: BoxParser.vpcCBox // vp9 + av1C?: BoxParser.av1CBox // av1 + } + data: Uint8Array + size: number + alreadyRead?: number + duration: number + cts: number + dts: number + is_sync: boolean + is_leading?: number + depends_on?: number + is_depended_on?: number + has_redundancy?: number + degradation_priority?: number + offset?: number + subsamples?: any + } + + export interface ExtractionOptions { + nbSamples: number + } + + export class DataStream { + // WARNING, the default is little endian, which is not what MP4 uses. + constructor(buffer?: ArrayBuffer, byteOffset?: number, endianness?: boolean) + getPosition(): number + + get byteLength(): number + get buffer(): ArrayBuffer + set buffer(v: ArrayBuffer) + get byteOffset(): number + set byteOffset(v: number) + get dataView(): DataView + set dataView(v: DataView) + + seek(pos: number): void + isEof(): boolean + + mapFloat32Array(length: number, e?: boolean): any + mapFloat64Array(length: number, e?: boolean): any + mapInt16Array(length: number, e?: boolean): any + mapInt32Array(length: number, e?: boolean): any + mapInt8Array(length: number): any + mapUint16Array(length: number, e?: boolean): any + mapUint32Array(length: number, e?: boolean): any + mapUint8Array(length: number): any + + readInt32Array(length: number, endianness?: boolean): Int32Array + readInt16Array(length: number, endianness?: boolean): Int16Array + readInt8Array(length: number): Int8Array + readUint32Array(length: number, endianness?: boolean): Uint32Array + readUint16Array(length: number, endianness?: boolean): Uint16Array + readUint8Array(length: number): Uint8Array + readFloat64Array(length: number, endianness?: boolean): Float64Array + readFloat32Array(length: number, endianness?: boolean): Float32Array + + readInt32(endianness?: boolean): number + readInt16(endianness?: boolean): number + readInt8(): number + readUint32(endianness?: boolean): number + //readUint32Array(length: any, e: any): any + readUint24(): number + readUint16(endianness?: boolean): number + readUint8(): number + //readUint64(): any + readFloat32(endianness?: boolean): number + readFloat64(endianness?: boolean): number + //readCString(length: number): any + //readString(length: number, encoding: any): any + + static endianness: boolean + + memcpy( + dst: ArrayBufferLike, + dstOffset: number, + src: ArrayBufferLike, + srcOffset: number, + byteLength: number, + ): void + + // TODO I got bored porting all functions + + save(filename: string): void + shift(offset: number): void + + writeInt32Array(arr: Int32Array, endianness?: boolean): void + writeInt16Array(arr: Int16Array, endianness?: boolean): void + writeInt8Array(arr: Int8Array): void + writeUint32Array(arr: Uint32Array, endianness?: boolean): void + writeUint16Array(arr: Uint16Array, endianness?: boolean): void + writeUint8Array(arr: Uint8Array): void + writeFloat64Array(arr: Float64Array, endianness?: boolean): void + writeFloat32Array(arr: Float32Array, endianness?: boolean): void + writeInt32(v: number, endianness?: boolean): void + writeInt16(v: number, endianness?: boolean): void + writeInt8(v: number): void + writeUint32(v: number, endianness?: boolean): void + writeUint16(v: number, endianness?: boolean): void + writeUint8(v: number): void + writeFloat32(v: number, endianness?: boolean): void + writeFloat64(v: number, endianness?: boolean): void + writeUCS2String(s: string, endianness?: boolean, length?: number): void + writeString(s: string, encoding?: string, length?: number): void + writeCString(s: string, length?: number): void + writeUint64(v: number): void + writeUint24(v: number): void + adjustUint32(pos: number, v: number): void + + static LITTLE_ENDIAN: boolean + static BIG_ENDIAN: boolean + + // TODO add correct types; these are exported by dts-gen + readCString(length: any): any + readInt64(): any + readString(length: any, encoding: any): any + readUint64(): any + writeStruct(structDefinition: any, struct: any): void + writeType(t: any, v: any, struct: any): any + + static arrayToNative(array: any, arrayIsLittleEndian: any): any + static flipArrayEndianness(array: any): any + static memcpy(dst: any, dstOffset: any, src: any, srcOffset: any, byteLength: any): void + static nativeToEndian(array: any, littleEndian: any): any + } + + export interface TrackOptions { + id?: number + type?: string + width?: number + height?: number + duration?: number + layer?: number + timescale?: number + media_duration?: number + language?: string + hdlr?: string + + // video + avcDecoderConfigRecord?: any + hevcDecoderConfigRecord?: any + + // audio + balance?: number + channel_count?: number + samplesize?: number + samplerate?: number + + //captions + namespace?: string + schema_location?: string + auxiliary_mime_types?: string + + description?: BoxParser.Box + description_boxes?: BoxParser.Box[] + + default_sample_description_index_id?: number + default_sample_duration?: number + default_sample_size?: number + default_sample_flags?: number + } + + export interface FileOptions { + brands?: string[] + timescale?: number + rate?: number + duration?: number + width?: number + } + + export interface SampleOptions { + sample_description_index?: number + duration?: number + cts?: number + dts?: number + is_sync?: boolean + is_leading?: number + depends_on?: number + is_depended_on?: number + has_redundancy?: number + degradation_priority?: number + subsamples?: any + } + + // TODO add the remaining functions + // TODO move to another module + export class ISOFile { + constructor(stream?: DataStream) + + init(options?: FileOptions): ISOFile + addTrack(options?: TrackOptions): number + addSample(track: number, data: Uint8Array, options?: SampleOptions): Sample + + createSingleSampleMoof(sample: Sample): BoxParser.moofBox + + // helpers + getTrackById(id: number): BoxParser.trakBox | undefined + getTrexById(id: number): BoxParser.trexBox | undefined + + // boxes that are added to the root + boxes: BoxParser.Box[] + mdats: BoxParser.mdatBox[] + moofs: BoxParser.moofBox[] + + ftyp?: BoxParser.ftypBox + moov?: BoxParser.moovBox + + static writeInitializationSegment( + ftyp: BoxParser.ftypBox, + moov: BoxParser.moovBox, + total_duration: number, + sample_duration: number, + ): ArrayBuffer + + // TODO add correct types; these are exported by dts-gen + add(name: any): any + addBox(box: any): any + appendBuffer(ab: any, last: any): any + buildSampleLists(): void + buildTrakSampleLists(trak: any): void + checkBuffer(ab: any): any + createFragment(track_id: any, sampleNumber: any, stream_: any): any + equal(b: any): any + flattenItemInfo(): void + flush(): void + getAllocatedSampleDataSize(): any + getBox(type: any): any + getBoxes(type: any, returnEarly: any): any + getBuffer(): any + getCodecs(): any + getInfo(): any + getItem(item_id: any): any + getMetaHandler(): any + getPrimaryItem(): any + getSample(trak: any, sampleNum: any): any + getTrackSample(track_id: any, number: any): any + getTrackSamplesInfo(track_id: any): any + hasIncompleteMdat(): any + hasItem(name: any): any + initializeSegmentation(): any + itemToFragmentedTrackFile(_options: any): any + parse(): void + print(output: any): void + processIncompleteBox(ret: any): any + processIncompleteMdat(): any + processItems(callback: any): void + processSamples(last: any): void + releaseItem(item_id: any): any + releaseSample(trak: any, sampleNum: any): any + releaseUsedSamples(id: any, sampleNum: any): void + resetTables(): void + restoreParsePosition(): any + save(name: any): void + saveParsePosition(): void + seek(time: any, useRap: any): any + seekTrack(time: any, useRap: any, trak: any): any + setExtractionOptions(id: any, user: any, options: any): void + setSegmentOptions(id: any, user: any, options: any): void + start(): void + stop(): void + unsetExtractionOptions(id: any): void + unsetSegmentOptions(id: any): void + updateSampleLists(): void + updateUsedBytes(box: any, ret: any): void + write(outstream: any): void + + static initSampleGroups(trak: any, traf: any, sbgps: any, trak_sgpds: any, traf_sgpds: any): void + static process_sdtp(sdtp: any, sample: any, number: any): void + static setSampleGroupProperties(trak: any, sample: any, sample_number: any, sample_groups_info: any): void + + // TODO Expand public API; it's difficult to tell what should be public + onMoovStart?: () => void + onReady?: (info: MP4Info) => void + onError?: (e: string) => void + onSamples?: (id: number, user: any, samples: Sample[]) => void + + appendBuffer(data: MP4ArrayBuffer): number + start(): void + stop(): void + flush(): void + + setExtractionOptions(id: number, user: any, options: ExtractionOptions): void + } + + export namespace BoxParser { + export class Box { + size?: number + flags?: number // Do these go here? + data?: Uint8Array + + constructor(type?: string, size?: number) + + add(name: string): Box + addBox(box: Box): Box + set(name: string, value: any): void + addEntry(value: string, prop?: string): void + printHeader(output: any): void + write(stream: DataStream): void + writeHeader(stream: DataStream, msg?: string): void + computeSize(): void + + // TODO add types for these + parse(stream: any): void + parseDataAndRewind(stream: any): void + parseLanguage(stream: any): void + print(output: any): void + } + + // TODO finish add types for these classes + export class AudioSampleEntry extends SampleEntry { + constructor(type: any, size?: number) + + getChannelCount(): any + getSampleRate(): any + getSampleSize(): any + isAudio(): any + parse(stream: any): void + write(stream: any): void + } + + export class CoLLBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class ContainerBox extends Box { + constructor(type: any, size?: number, uuid?: any) + + parse(stream: any): void + print(output: any): void + write(stream: any): void + } + + export class FullBox extends Box { + constructor(type: any, size?: number, uuid?: any) + + parse(stream: any): void + parseDataAndRewind(stream: any): void + parseFullHeader(stream: any): void + printHeader(output: any): void + writeHeader(stream: any): void + } + + export class HintSampleEntry extends SampleEntry { + constructor(type: any, size?: number) + } + + export class MetadataSampleEntry extends SampleEntry { + constructor(type: any, size?: number) + + isMetadata(): any + } + + export class OpusSampleEntry extends SampleEntry { + constructor(size?: number) + } + + export class SampleEntry extends Box { + constructor(type: any, size?: number, hdr_size?: number, start?: number) + + getChannelCount(): any + getCodec(): any + getHeight(): any + getSampleRate(): any + getSampleSize(): any + getWidth(): any + isAudio(): any + isHint(): any + isMetadata(): any + isSubtitle(): any + isVideo(): any + parse(stream: any): void + parseDataAndRewind(stream: any): void + parseFooter(stream: any): void + parseHeader(stream: any): void + write(stream: any): void + writeFooter(stream: any): void + writeHeader(stream: any): void + } + + export class SampleGroupEntry { + constructor(type: any) + + parse(stream: any): void + write(stream: any): void + } + + export class SingleItemTypeReferenceBox extends ContainerBox { + constructor(type: any, size?: number, hdr_size?: number, start?: number) + + parse(stream: any): void + } + + export class SingleItemTypeReferenceBoxLarge { + constructor(type: any, size?: number, hdr_size?: number, start?: number) + + parse(stream: any): void + } + + export class SmDmBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class SubtitleSampleEntry extends SampleEntry { + constructor(type: any, size?: number) + + isSubtitle(): any + } + + export class SystemSampleEntry extends SampleEntry { + constructor(type: any, size?: number) + } + + export class TextSampleEntry extends SampleEntry { + constructor(type: any, size?: number) + } + + export class TrackGroupTypeBox extends FullBox { + constructor(type: any, size?: number) + + parse(stream: any): void + } + + export class TrackReferenceTypeBox extends ContainerBox { + constructor(type: any, size?: number, hdr_size?: number, start?: number) + + parse(stream: any): void + + write(stream: any): void + } + + export class VisualSampleEntry extends SampleEntry { + constructor(type: any, size?: number) + + getHeight(): any + getWidth(): any + isVideo(): any + parse(stream: any): void + write(stream: any): void + } + + export class a1lxBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class a1opBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class alstSampleGroupEntry extends SampleGroupEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class auxCBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class av01SampleEntry extends SampleEntry { + constructor(size?: number) + + getCodec(): any + } + + export class av1CBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class avc1SampleEntry extends SampleEntry { + constructor(size?: number) + + getCodec(): any + } + + export class avc2SampleEntry extends SampleEntry { + constructor(size?: number) + + getCodec(): any + } + + export class avc3SampleEntry extends SampleEntry { + constructor(size?: number) + + getCodec(): any + } + + export class avc4SampleEntry extends SampleEntry { + constructor(size?: number) + + getCodec(): any + } + + export class avcCBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class avllSampleGroupEntry extends SampleGroupEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class avssSampleGroupEntry extends SampleGroupEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class btrtBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class bxmlBox extends FullBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class clapBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class clefBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class clliBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class co64Box extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class colrBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class cprtBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class cslgBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class cttsBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + unpack(samples: any): void + write(stream: any): void + } + + export class dOpsBox extends ContainerBox { + constructor(size?: number) + + parse(stream: DataStream): void + + Version: number + OutputChannelCount: number + PreSkip: number + InputSampleRate: number + OutputGain: number + ChannelMappingFamily: number + + // When channelMappingFamily != 0 + StreamCount?: number + CoupledCount?: number + ChannelMapping?: number[] + } + + export class dac3Box extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class dec3Box extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class dfLaBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class dimmBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class dinfBox extends ContainerBox { + constructor(size?: number) + } + + export class dmaxBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class dmedBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class drefBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class drepBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class dtrtSampleGroupEntry extends SampleGroupEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class edtsBox extends ContainerBox { + constructor(size?: number) + } + + export class elngBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class elstBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class emsgBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class encaSampleEntry extends SampleEntry { + constructor(size?: number) + } + + export class encmSampleEntry extends SampleEntry { + constructor(size?: number) + } + + export class encsSampleEntry extends SampleEntry { + constructor(size?: number) + } + + export class enctSampleEntry extends SampleEntry { + constructor(size?: number) + } + + export class encuSampleEntry extends SampleEntry { + constructor(size?: number) + } + + export class encvSampleEntry extends SampleEntry { + constructor(size?: number) + } + + export class enofBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class esdsBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class fielBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class freeBox extends Box { + constructor(size?: number) + } + + export class frmaBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class ftypBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class hdlrBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class hev1SampleEntry extends SampleEntry { + constructor(size?: number) + + getCodec(): any + } + + export class hinfBox extends ContainerBox { + constructor(size?: number) + } + + export class hmhdBox extends FullBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class hntiBox extends ContainerBox { + constructor(size?: number) + } + + export class hvc1SampleEntry extends SampleEntry { + constructor(size?: number) + + getCodec(): any + } + + export class hvcCBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class idatBox extends Box { + constructor(size?: number) + } + + export class iinfBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class ilocBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class imirBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class infeBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class iodsBox extends FullBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class ipcoBox extends ContainerBox { + constructor(size?: number) + } + + export class ipmaBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class iproBox extends FullBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class iprpBox extends ContainerBox { + constructor(size?: number) + ipmas: ipmaBox[] + } + + export class irefBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class irotBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class ispeBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class kindBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + + write(stream: any): void + } + + export class levaBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class lselBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class maxrBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class mdatBox extends Box { + constructor(size?: number) + } + + export class mdcvBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class mdhdBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + + write(stream: any): void + } + + export class mdiaBox extends ContainerBox { + constructor(size?: number) + } + + export class mecoBox extends Box { + constructor(size?: number) + } + + export class mehdBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + + write(stream: any): void + } + + export class mereBox extends FullBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class metaBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class mettSampleEntry extends SampleEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class metxSampleEntry extends SampleEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class mfhdBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + + write(stream: any): void + } + + export class mfraBox extends ContainerBox { + constructor(size?: number) + tfras: tfraBox[] + } + + export class mfroBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class minfBox extends ContainerBox { + constructor(size?: number) + } + + export class moofBox extends ContainerBox { + constructor(size?: number) + trafs: trafBox[] + } + + export class moovBox extends ContainerBox { + constructor(size?: number) + traks: trakBox[] + psshs: psshBox[] + } + + export class mp4aSampleEntry extends SampleEntry { + constructor(size?: number) + + getCodec(): any + } + + export class msrcTrackGroupTypeBox extends ContainerBox { + constructor(size?: number) + } + + export class mvexBox extends ContainerBox { + constructor(size?: number) + + trexs: trexBox[] + } + + export class mvhdBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + print(output: any): void + write(stream: any): void + } + + export class mvifSampleGroupEntry extends SampleGroupEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class nmhdBox extends FullBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class npckBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class numpBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class padbBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class paspBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class paylBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class paytBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class pdinBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class pitmBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class pixiBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class pmaxBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class prftBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class profBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class prolSampleGroupEntry extends SampleGroupEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class psshBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class rashSampleGroupEntry extends SampleGroupEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class rinfBox extends ContainerBox { + constructor(size?: number) + } + + export class rollSampleGroupEntry extends SampleGroupEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class saioBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class saizBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class sbgpBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + + write(stream: any): void + } + + export class sbttSampleEntry extends SampleEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class schiBox extends ContainerBox { + constructor(size?: number) + } + + export class schmBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class scifSampleGroupEntry extends SampleGroupEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class scnmSampleGroupEntry extends SampleGroupEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class sdtpBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class seigSampleGroupEntry extends SampleGroupEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class sencBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class sgpdBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class sidxBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class sinfBox extends ContainerBox { + constructor(size?: number) + } + + export class skipBox extends Box { + constructor(size?: number) + } + + export class smhdBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class ssixBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class stblBox extends ContainerBox { + constructor(size?: number) + + sgpds: sgpdBox[] + sbgps: sbgpBox[] + } + + export class stcoBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + unpack(samples: any): void + write(stream: any): void + } + + export class stdpBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class sthdBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class stppSampleEntry extends SampleEntry { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class strdBox extends ContainerBox { + constructor(size?: number) + } + + export class striBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class strkBox extends Box { + constructor(size?: number) + } + + export class stsaSampleGroupEntry extends SampleGroupEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class stscBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + unpack(samples: any): void + write(stream: any): void + } + + export class stsdBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class stsgBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class stshBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class stssBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class stszBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + unpack(samples: any): void + write(stream: any): void + } + + export class sttsBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + unpack(samples: any): void + write(stream: any): void + } + + export class stviBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class stxtSampleEntry extends SampleEntry { + constructor(size?: number) + + getCodec(): any + parse(stream: any): void + } + + export class stypBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class stz2Box extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class subsBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class syncSampleGroupEntry extends SampleGroupEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class taptBox extends ContainerBox { + constructor(size?: number) + } + + export class teleSampleGroupEntry extends SampleGroupEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class tencBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class tfdtBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class tfhdBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class tfraBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class tkhdBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + print(output: any): void + write(stream: any): void + } + + export class tmaxBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class tminBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class totlBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class tpayBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class tpylBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class trafBox extends ContainerBox { + constructor(size?: number) + truns: trunBox[] + sgpd: sgpdBox[] + sbgp: sbgpBox[] + } + + export class trakBox extends ContainerBox { + constructor(size?: number) + } + + export class trefBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class trepBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class trexBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class trgrBox extends ContainerBox { + constructor(size?: number) + } + + export class trpyBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class trunBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + + sample_count: number + + sample_duration?: number[] + sample_size?: number[] + sample_flags?: number[] + sample_composition_time_offset?: number[] + + data_offset?: number + data_offset_position?: number + } + + export class tsasSampleGroupEntry extends SampleGroupEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class tsclSampleGroupEntry extends SampleGroupEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class tselBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class tx3gSampleEntry extends SampleEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class txtCBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class udtaBox extends ContainerBox { + constructor(size?: number) + kinds: kindBox[] + } + + export class viprSampleGroupEntry extends SampleGroupEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export class vmhdBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + write(stream: any): void + } + + export class vp08SampleEntry extends SampleEntry { + constructor(size?: number) + + getCodec(): any + } + + export class vp09SampleEntry extends SampleEntry { + constructor(size?: number) + + getCodec(): any + } + + export class vpcCBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class vttCBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class vttcBox extends ContainerBox { + constructor(size?: number) + } + + export class vvc1SampleEntry extends SampleEntry { + constructor(size?: number) + + getCodec(): any + } + + export class vvcCBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class vvcNSampleEntry extends SampleEntry { + constructor(size?: number) + } + + export class vvi1SampleEntry extends SampleEntry { + constructor(size?: number) + + getCodec(): any + } + + export class vvnCBox extends ContainerBox { + constructor(size?: number) + + parse(stream: any): void + } + + export class vvs1SampleEntry extends SampleEntry { + constructor(size?: number) + } + + export class wvttSampleEntry extends SampleEntry { + constructor(size?: number) + + parse(stream: any): void + } + + export const BASIC_BOXES: string[] + export const CONTAINER_BOXES: string[][] + export const DIFF_BOXES_PROP_NAMES: string[] + export const DIFF_PRIMITIVE_ARRAY_PROP_NAMES: string[] + export const ERR_INVALID_DATA: number + export const ERR_NOT_ENOUGH_DATA: number + export const FULL_BOXES: string[] + export const OK: number + export const SAMPLE_ENTRY_TYPE_AUDIO: string + export const SAMPLE_ENTRY_TYPE_HINT: string + export const SAMPLE_ENTRY_TYPE_METADATA: string + export const SAMPLE_ENTRY_TYPE_SUBTITLE: string + export const SAMPLE_ENTRY_TYPE_SYSTEM: string + export const SAMPLE_ENTRY_TYPE_TEXT: string + export const SAMPLE_ENTRY_TYPE_VISUAL: string + export const TFHD_FLAG_BASE_DATA_OFFSET: number + export const TFHD_FLAG_DEFAULT_BASE_IS_MOOF: number + export const TFHD_FLAG_DUR_EMPTY: number + export const TFHD_FLAG_SAMPLE_DESC: number + export const TFHD_FLAG_SAMPLE_DUR: number + export const TFHD_FLAG_SAMPLE_FLAGS: number + export const TFHD_FLAG_SAMPLE_SIZE: number + export const TKHD_FLAG_ENABLED: number + export const TKHD_FLAG_IN_MOVIE: number + export const TKHD_FLAG_IN_PREVIEW: number + export const TRUN_FLAGS_CTS_OFFSET: number + export const TRUN_FLAGS_DATA_OFFSET: number + export const TRUN_FLAGS_DURATION: number + export const TRUN_FLAGS_FIRST_FLAG: number + export const TRUN_FLAGS_FLAGS: number + export const TRUN_FLAGS_SIZE: number + export const UUIDs: string[] + export const boxCodes: string[] + export const containerBoxCodes: any[] + export const fullBoxCodes: any[] + + export const sampleEntryCodes: { + Audio: string[] + Hint: any[] + Metadata: string[] + Subtitle: string[] + System: string[] + Text: string[] + Visual: string[] + } + + export const sampleGroupEntryCodes: any[] + + export const trackGroupTypes: any[] + + export function addSubBoxArrays(subBoxNames: any): void + export function boxEqual(box_a: any, box_b: any): any + export function boxEqualFields(box_a: any, box_b: any): any + export function createBoxCtor(type: any, parseMethod: any): void + export function createContainerBoxCtor(type: any, parseMethod: any, subBoxNames: any): void + export function createEncryptedSampleEntryCtor(mediaType: any, type: any, parseMethod: any): void + export function createFullBoxCtor(type: any, parseMethod: any): void + export function createMediaSampleEntryCtor(mediaType: any, parseMethod: any, subBoxNames: any): void + export function createSampleEntryCtor(mediaType: any, type: any, parseMethod: any, subBoxNames: any): void + export function createSampleGroupCtor(type: any, parseMethod: any): void + export function createTrackGroupCtor(type: any, parseMethod: any): void + export function createUUIDBox(uuid: any, isFullBox: any, isContainerBox: any, parseMethod: any): void + export function decimalToHex(d: any, padding: any): any + export function initialize(): void + export function parseHex16(stream: any): any + export function parseOneBox(stream: any, headerOnly: any, parentsize?: number): any + export function parseUUID(stream: any): any + + /* ??? + namespace UUIDBoxes { + export class a2394f525a9b4f14a2446c427c648df4 { + constructor(size?: number) + } + + export class a5d40b30e81411ddba2f0800200c9a66 { + constructor(size?: number) + + parse(stream: any): void + } + + export class d08a4f1810f34a82b6c832d8aba183d3 { + constructor(size?: number) + + parse(stream: any): void + } + + export class d4807ef2ca3946958e5426cb9e46a79f { + constructor(size?: number) + + parse(stream: any): void + } + } + */ + } + + // TODO Add types for the remaining classes found via dts-gen + export class MP4BoxStream { + constructor(arrayBuffer: any) + + getEndPosition(): any + getLength(): any + getPosition(): any + isEos(): any + readAnyInt(size?: number, signed?: boolean): any + readCString(): any + readInt16(): any + readInt16Array(length: any): any + readInt32(): any + readInt32Array(length: any): any + readInt64(): any + readInt8(): any + readString(length: any): any + readUint16(): any + readUint16Array(length: any): any + readUint24(): any + readUint32(): any + readUint32Array(length: any): any + readUint64(): any + readUint8(): any + readUint8Array(length: any): any + seek(pos: any): any + } + + export class MultiBufferStream { + constructor(buffer: any) + + addUsedBytes(nbBytes: any): void + cleanBuffers(): void + findEndContiguousBuf(inputindex: any): any + findPosition(fromStart: any, filePosition: any, markAsUsed: any): any + getEndFilePositionAfter(pos: any): any + getEndPosition(): any + getLength(): any + getPosition(): any + initialized(): any + insertBuffer(ab: any): void + logBufferLevel(info: any): void + mergeNextBuffer(): any + reduceBuffer(buffer: any, offset: any, newLength: any): any + seek(filePosition: any, fromStart: any, markAsUsed: any): any + setAllUsedBytes(): void + } + + export class Textin4Parser { + constructor() + + parseConfig(data: any): any + parseSample(sample: any): any + } + + export class XMLSubtitlein4Parser { + constructor() + + parseSample(sample: any): any + } + + export function MPEG4DescriptorParser(): any + + export namespace BoxParser {} + + export namespace Log { + export const LOG_LEVEL_ERROR = 4 + export const LOG_LEVEL_WARNING = 3 + export const LOG_LEVEL_INFO = 2 + export const LOG_LEVEL_DEBUG = 1 + + export function debug(module: any, msg: any): void + export function error(module: any, msg: any): void + export function getDurationString(duration: any, _timescale: any): any + export function info(module: any, msg: any): void + export function log(module: any, msg: any): void + export function printRanges(ranges: any): any + export function setLogLevel(level: any): void + export function warn(module: any, msg: any): void + } +} diff --git a/packages/moq/types/tsconfig.json b/packages/moq/types/tsconfig.json new file mode 100644 index 00000000..784e2190 --- /dev/null +++ b/packages/moq/types/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["."] +} diff --git a/packages/typescript-config/base.json b/packages/typescript-config/base.json index 359bdb6e..5064c1e8 100644 --- a/packages/typescript-config/base.json +++ b/packages/typescript-config/base.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "allowJs": true, - "target": "ES2017", + "target": "ES2022", "module": "ES2022", "lib": ["es2022", "DOM", "WebWorker", "DOM.Iterable"], "jsx": "react-jsx", diff --git a/packages/ui/package.json b/packages/ui/package.json index 8cc01b9c..17a3156e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -26,6 +26,7 @@ "@builder.io/qwik-react": "0.5.0", "@fontsource/bricolage-grotesque": "^5.0.7", "@fontsource/geist-sans": "^5.0.3", + "@modular-forms/qwik": "0.26.1", "@nestri/core": "*", "@nestri/eslint-config": "*", "@nestri/typescript-config": "*", @@ -48,6 +49,7 @@ "tailwind-merge": "^2.4.0", "tailwind-variants": "^0.2.1", "tailwindcss": "^3.4.9", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "valibot": "^0.42.1" } } \ No newline at end of file diff --git a/packages/ui/src/router-head.tsx b/packages/ui/src/router-head.tsx index 0363fe02..d21828bb 100644 --- a/packages/ui/src/router-head.tsx +++ b/packages/ui/src/router-head.tsx @@ -15,9 +15,9 @@ export const RouterHead = component$(() => { {/* {head.title} */} {loc.url.pathname === "/" ? "Nestri – Your games. Your rules.": - loc.url.pathname.startsWith("/blog/") + loc.url.pathname.startsWith("/moq/") ? - head.title + `MoQ – Nestri` : `${loc.url.pathname.split("/")[1].charAt(0).toUpperCase() + loc.url.pathname.split("/")[1].slice(1)} – Nestri` } diff --git a/sst-env.d.ts b/sst-env.d.ts index be8895f6..9ee5cc1b 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -7,10 +7,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "Relay": { - "type": "sst.aws.Service" - "url": string - } } } export {} diff --git a/sst.config.ts b/sst.config.ts index 8f3f882a..ba0495de 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -6,7 +6,11 @@ export default $config({ name: "nestri", removal: input?.stage === "production" ? "retain" : "remove", home: "aws", - providers: { cloudflare: "5.37.1" }, + providers: { + cloudflare: "5.37.1", + docker: "4.5.5", + "@pulumi/command": "1.0.1", + }, }; }, async run() {