mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-11 00:05:36 +02:00
✨ feat: Host a relay on Hetzner (#114)
We are hosting a [MoQ](https://quic.video) relay on a remote (bare metal) server on Hetzner With a lot of help from @victorpahuus
This commit is contained in:
3
.certs/.gitignore
vendored
Normal file
3
.certs/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.terraform
|
||||
relay_*
|
||||
terraform.tfstate
|
||||
61
.certs/.terraform.lock.hcl
generated
Normal file
61
.certs/.terraform.lock.hcl
generated
Normal file
@@ -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",
|
||||
]
|
||||
}
|
||||
24
.certs/README.md
Normal file
24
.certs/README.md
Normal file
@@ -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_*
|
||||
```
|
||||
7
.certs/input.tf
Normal file
7
.certs/input.tf
Normal file
@@ -0,0 +1,7 @@
|
||||
variable "email" {
|
||||
description = "Your email address, used for LetsEncrypt"
|
||||
}
|
||||
|
||||
variable "domain" {
|
||||
description = "domain name"
|
||||
}
|
||||
65
.certs/main.tf
Normal file
65
.certs/main.tf
Normal file
@@ -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
|
||||
}
|
||||
2
.certs/terraform.tfvars
Normal file
2
.certs/terraform.tfvars
Normal file
@@ -0,0 +1,2 @@
|
||||
domain = "fst.so"
|
||||
email = "wanjohiryan33@gmail.com"
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -41,4 +41,7 @@ yarn-error.log*
|
||||
.sst
|
||||
|
||||
#Bun merging errors, EVERY time :(
|
||||
bun.lockb
|
||||
bun.lockb
|
||||
|
||||
#tests
|
||||
id_*
|
||||
@@ -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"
|
||||
|
||||
118
apps/www/src/routes/(moq)/moq/checker/index.tsx
Normal file
118
apps/www/src/routes/(moq)/moq/checker/index.tsx
Normal file
@@ -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<typeof Schema>;
|
||||
|
||||
export const useFormLoader = routeLoader$<InitialValues<Form>>(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<boolean | undefined>();
|
||||
const [state, { Form, Field }] = useForm<Form>({
|
||||
loader: useFormLoader(),
|
||||
validate: valiForm$(Schema)
|
||||
});
|
||||
|
||||
const handleSubmit = $<SubmitHandler<Form>>(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 (
|
||||
<>
|
||||
<TitleSection client:load title="MoQ Checker" description="Test the connection to your Media-Over-Quic relay!" />
|
||||
<MotionComponent
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={transition}
|
||||
client:load
|
||||
class="flex items-center justify-center w-full"
|
||||
as="div"
|
||||
>
|
||||
<section class="w-full flex flex-col gap-4 justify-center items-center">
|
||||
<Form onSubmit$={handleSubmit} class="w-full max-w-xl flex px-3 gap-2">
|
||||
<Field name="url">
|
||||
{(field, props) => {
|
||||
return (
|
||||
<div class="w-full flex flex-col gap-2">
|
||||
<div class="bg-gray-200 dark:bg-gray-800 flex rounded-lg w-full relative h-10 flex-none border focus-within:bg-gray-300/70 dark:focus-within:bg-gray-700/70 border-gray-300 dark:border-gray-700 ">
|
||||
<input type="url" class={cn("w-full relative h-full bg-transparent rounded-lg p-3 focus-within:outline-none focus-within:ring-2 focus-within:ring-gray-400 dark:focus-within:ring-gray-600 focus-within:ring-offset-2 focus-visible:outline-none focus-within:ring-offset-gray-100 dark:focus-within:ring-offset-gray-900 placeholder:text-gray-500/70", typeof broadcasterOk.value !== "undefined" && broadcasterOk.value == true && "ring-2 ring-offset-2 ring-offset-gray-100 dark:ring-offset-gray-900 ring-green-500", typeof broadcasterOk.value !== "undefined" && (broadcasterOk.value == false) && "ring-2 ring-offset-2 ring-offset-gray-100 dark:ring-offset-gray-900 ring-red-500")} placeholder="https://relay.domain.com" {...props} />
|
||||
</div>
|
||||
{field.error && (<p class='text-[0.8rem] font-medium text-danger-600 dark:text-danger-500' >{field.error}</p>)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Field>
|
||||
|
||||
{/* <button class={cn(buttonVariants.solid({ size: "md", intent: "neutral" }), "w-max space-y-0 relative")} style={{ height: 40, marginTop: 0 }} type="submit" >
|
||||
Check
|
||||
</button> */}
|
||||
<Button.Root
|
||||
disabled={state.submitting}
|
||||
isLoading={state.submitting}
|
||||
// setIsLoading={setIsLoading}
|
||||
client:load
|
||||
//@ts-ignore
|
||||
type="submit"
|
||||
style={{ height: 40, marginTop: 0 }}
|
||||
intent="neutral"
|
||||
size="md"
|
||||
class="w-max space-y-0 relative">
|
||||
{/* <Button.Icon
|
||||
isLoading={isLoading.value}
|
||||
client:load>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 16 16">
|
||||
<g fill="currentColor">
|
||||
<path d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006l4.304 1.769A2.2 2.2 0 0 1 5.62 8.88l1.96-2.844l-.001-.04a3.046 3.046 0 0 1 3.042-3.043a3.046 3.046 0 0 1 3.042 3.043a3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11a2.22 2.22 0 0 1-1.312-1.568L.33 10.333Z" /><path d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165a1.7 1.7 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029a2.03 2.03 0 0 0 2.027-2.029a2.03 2.03 0 0 0-2.027-2.027a2.03 2.03 0 0 0-2.027 2.027m2.03-1.527a1.524 1.524 0 1 1-.002 3.048a1.524 1.524 0 0 1 .002-3.048" />
|
||||
</g>
|
||||
</svg>
|
||||
</Button.Icon> */}
|
||||
<Button.Label
|
||||
loadingText="Checking..."
|
||||
class="text-ellipsis whitespace-nowrap"
|
||||
isLoading={state.submitting}>
|
||||
Check
|
||||
</Button.Label>
|
||||
<div class="w-[8%]" />
|
||||
</Button.Root>
|
||||
</Form>
|
||||
{typeof broadcasterOk.value !== "undefined" && broadcasterOk.value == true ? (
|
||||
<span class="w-full text-green-500 max-w-xl flex space-y-6 px-3 gap-2">
|
||||
Your relay is doing okay
|
||||
</span>
|
||||
) : typeof broadcasterOk.value !== "undefined" && (
|
||||
<span class="w-full text-red-500 max-w-xl flex space-y-6 px-3 gap-2">
|
||||
Your relay has an issue
|
||||
</span>
|
||||
)}
|
||||
</section>
|
||||
</MotionComponent>
|
||||
</>
|
||||
)
|
||||
})
|
||||
208
apps/www/src/routes/(moq)/moq/checker/tester.ts
Normal file
208
apps/www/src/routes/(moq)/moq/checker/tester.ts
Normal file
@@ -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<void>
|
||||
|
||||
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<Broadcast> {
|
||||
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<Error> {
|
||||
try {
|
||||
await this.#running
|
||||
return new Error("closed") // clean termination
|
||||
} catch (e) {
|
||||
return asError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "@nestri/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"target": "ES2017",
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": [
|
||||
"es2022",
|
||||
|
||||
@@ -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",
|
||||
|
||||
87
infra/RELAY.md
Normal file
87
infra/RELAY.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# How to Deploy Your Own MoQ Relay on a Server
|
||||
|
||||
This guide will walk you through the steps to deploy your own MoQ relay on a server.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Server Requirements:**
|
||||
- Ensure port 443 is open for both TCP and UDP (`:443/udp & :443/tcp`).
|
||||
- The server should have a minimum of **4GB RAM** and **2 vCPUs**.
|
||||
- Supports ARM or AMD64 architecture.
|
||||
|
||||
2. **Software Requirements:**
|
||||
- Docker and `docker-compose` must be installed on the server. You can use [this installation script](https://github.com/docker/docker-install) for Docker.
|
||||
- Git must be installed to clone the necessary repository.
|
||||
|
||||
3. **Certificates:**
|
||||
- You will need private and public certificates. It is recommended to use certificates from a trusted CA rather than self-signed certificates.
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### Step 1: Clone the Repository
|
||||
|
||||
Clone the `kixelated/moq-rs` repository to your local machine:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kixelated/moq-rs moq
|
||||
```
|
||||
|
||||
### Step 2: Verify Port Availability
|
||||
|
||||
Check if port 443 is already in use on your server:
|
||||
|
||||
```bash
|
||||
sudo netstat -tulpn | grep ':443' | grep LISTEN
|
||||
```
|
||||
or
|
||||
```bash
|
||||
sudo lsof -i -P -n | grep LISTEN | grep 443
|
||||
```
|
||||
|
||||
If you find any processes using port 443, consider terminating them.
|
||||
|
||||
### Step 3: Configure Ports
|
||||
|
||||
Navigate to the cloned directory and edit the Docker compose file to use port 443:
|
||||
|
||||
```bash
|
||||
cd moq
|
||||
vim docker-compose.yml
|
||||
```
|
||||
|
||||
Change the ports section from lines 34 to 35 to:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
```
|
||||
|
||||
### Step 4: Prepare Certificates
|
||||
|
||||
Copy your generated certificates into the `moq/dev` directory and rename them:
|
||||
|
||||
```bash
|
||||
cp cert.pem moq/dev/localhost.crt
|
||||
cp key.pem moq/dev/localhost.key
|
||||
```
|
||||
|
||||
### Step 5: Start Docker Instances
|
||||
|
||||
Ensure you are in the root directory of the `moq` project, then start the Docker containers:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Step 6: Link Domain to Server IP
|
||||
|
||||
Configure your DNS settings to connect your server's IP address to your domain:
|
||||
|
||||
```
|
||||
Record Type: A
|
||||
Subdomain: relay.fst.so
|
||||
IP Address: xx.xxx.xx.xxx
|
||||
```
|
||||
|
||||
Congratulations, your MoQ server is now set up! You can verify its functionality by using the [MoQ Checker](https://nestri.pages.dev/moq/checker).
|
||||
@@ -1,9 +1,9 @@
|
||||
export const domain =
|
||||
{
|
||||
production: "fst.so",
|
||||
dev: "dev.fst.so",
|
||||
}[$app.stage] || $app.stage + ".dev.fst.so";
|
||||
// export const domain =
|
||||
// {
|
||||
// production: "fst.so",
|
||||
// dev: "dev.fst.so",
|
||||
// }[$app.stage] || $app.stage + ".dev.fst.so";
|
||||
|
||||
export const zone = cloudflare.getZoneOutput({
|
||||
name: "fst.so",
|
||||
});
|
||||
// export const zone = cloudflare.getZoneOutput({
|
||||
// name: "fst.so",
|
||||
// });
|
||||
@@ -1,38 +0,0 @@
|
||||
import { isPermanentStage } from "./stage";
|
||||
|
||||
if (isPermanentStage) {
|
||||
const github = new aws.iam.OpenIdConnectProvider("GithubProvider", {
|
||||
url: "https://token.actions.githubusercontent.com",
|
||||
clientIdLists: ["sts.amazonaws.com"],
|
||||
thumbprintLists: [
|
||||
"6938fd4d98bab03faadb97b34396831e3780aea1",
|
||||
"1c58a3a8518e8759bf075b76b750d4f2df264fcd",
|
||||
],
|
||||
});
|
||||
|
||||
const githubRole = new aws.iam.Role("GithubRole", {
|
||||
name: [$app.name, $app.stage, "github"].join("-"),
|
||||
assumeRolePolicy: {
|
||||
Version: "2012-10-17",
|
||||
Statement: [
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: {
|
||||
Federated: github.arn,
|
||||
},
|
||||
Action: "sts:AssumeRoleWithWebIdentity",
|
||||
Condition: {
|
||||
StringLike: github.url.apply((url) => ({
|
||||
[`${url}:sub`]: "repo:nestriness/nestri:*",
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
new aws.iam.RolePolicyAttachment("GithubRolePolicy", {
|
||||
policyArn: "arn:aws:iam::aws:policy/AdministratorAccess",
|
||||
role: githubRole.name,
|
||||
});
|
||||
}
|
||||
22
infra/relay.ts
Normal file
22
infra/relay.ts
Normal file
@@ -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}`;
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
],
|
||||
"prefer-spread": "off",
|
||||
"no-case-declarations": "off",
|
||||
"no-console": "off",
|
||||
"@typescript-eslint/consistent-type-imports": "warn",
|
||||
"@typescript-eslint/no-unnecessary-condition": "warn",
|
||||
},
|
||||
};
|
||||
|
||||
56
packages/moq/.eslintrc.cjs
Normal file
56
packages/moq/.eslintrc.cjs
Normal file
@@ -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,
|
||||
},
|
||||
}
|
||||
4
packages/moq/.prettierrc.yaml
Normal file
4
packages/moq/.prettierrc.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
# note: root .editorconfig is used
|
||||
|
||||
# Don't insert semi-colons unless needed
|
||||
semi: false
|
||||
20
packages/moq/README.md
Normal file
20
packages/moq/README.md
Normal file
@@ -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)
|
||||
120
packages/moq/common/async.ts
Normal file
120
packages/moq/common/async.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
export class Deferred<T> {
|
||||
promise: Promise<T>
|
||||
resolve!: (value: T | PromiseLike<T>) => 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> = [T, Promise<WatchNext<T>> | undefined]
|
||||
|
||||
export class Watch<T> {
|
||||
#current: WatchNext<T>
|
||||
#next = new Deferred<WatchNext<T>>()
|
||||
|
||||
constructor(init: T) {
|
||||
this.#next = new Deferred<WatchNext<T>>()
|
||||
this.#current = [init, this.#next.promise]
|
||||
}
|
||||
|
||||
value(): WatchNext<T> {
|
||||
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<WatchNext<T>>()
|
||||
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<void>()
|
||||
|
||||
async wait() {
|
||||
return this.#next.promise
|
||||
}
|
||||
|
||||
wake() {
|
||||
if (!this.#next.pending) {
|
||||
throw new Error("closed")
|
||||
}
|
||||
|
||||
this.#next.resolve()
|
||||
this.#next = new Deferred<void>()
|
||||
}
|
||||
|
||||
close() {
|
||||
this.#next.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// Allows queuing N values, like a Channel.
|
||||
export class Queue<T> {
|
||||
#stream: TransformStream<T, T>
|
||||
#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<T | undefined> {
|
||||
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
|
||||
}
|
||||
}
|
||||
18
packages/moq/common/download.ts
Normal file
18
packages/moq/common/download.ts
Normal file
@@ -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)
|
||||
}
|
||||
14
packages/moq/common/error.ts
Normal file
14
packages/moq/common/error.ts
Normal file
@@ -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
|
||||
}
|
||||
1
packages/moq/common/index.ts
Normal file
1
packages/moq/common/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { asError } from "./error"
|
||||
176
packages/moq/common/ring.ts
Normal file
176
packages/moq/common/ring.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
33
packages/moq/common/settings.ts
Normal file
33
packages/moq/common/settings.ts
Normal file
@@ -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
|
||||
}
|
||||
4
packages/moq/common/tsconfig.json
Normal file
4
packages/moq/common/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["."]
|
||||
}
|
||||
75
packages/moq/contribute/audio.ts
Normal file
75
packages/moq/contribute/audio.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
const SUPPORTED = [
|
||||
// TODO support AAC
|
||||
// "mp4a"
|
||||
"Opus",
|
||||
]
|
||||
|
||||
export class Encoder {
|
||||
#encoder!: AudioEncoder
|
||||
#encoderConfig: AudioEncoderConfig
|
||||
#decoderConfig?: AudioDecoderConfig
|
||||
|
||||
frames: TransformStream<AudioData, AudioDecoderConfig | EncodedAudioChunk>
|
||||
|
||||
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<AudioDecoderConfig | EncodedAudioChunk>) {
|
||||
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<AudioDecoderConfig | EncodedAudioChunk>,
|
||||
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
|
||||
}
|
||||
}
|
||||
241
packages/moq/contribute/broadcast.ts
Normal file
241
packages/moq/contribute/broadcast.ts
Normal file
@@ -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<string, Track>()
|
||||
|
||||
readonly config: BroadcastConfig
|
||||
readonly catalog: Catalog.Root
|
||||
readonly connection: Connection
|
||||
readonly namespace: string
|
||||
|
||||
#running: Promise<void>
|
||||
|
||||
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<Error> {
|
||||
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)]
|
||||
}
|
||||
7
packages/moq/contribute/chunk.ts
Normal file
7
packages/moq/contribute/chunk.ts
Normal file
@@ -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
|
||||
}
|
||||
165
packages/moq/contribute/container.ts
Normal file
165
packages/moq/contribute/container.ts
Normal file
@@ -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<DecoderConfig | EncodedChunk, Chunk>
|
||||
|
||||
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<Chunk>) {
|
||||
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<Chunk>) {
|
||||
// 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<Chunk>) {
|
||||
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
|
||||
}
|
||||
5
packages/moq/contribute/index.ts
Normal file
5
packages/moq/contribute/index.ts
Normal file
@@ -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"
|
||||
45
packages/moq/contribute/segment.ts
Normal file
45
packages/moq/contribute/segment.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Chunk } from "./chunk"
|
||||
|
||||
export class Segment {
|
||||
id: number
|
||||
|
||||
// Take in a stream of chunks
|
||||
input: WritableStream<Chunk>
|
||||
|
||||
// Output a stream of bytes, which we fork for each new subscriber.
|
||||
#cache: ReadableStream<Uint8Array>
|
||||
|
||||
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<Chunk, Uint8Array>(
|
||||
{
|
||||
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<Uint8Array> {
|
||||
const [tee, cache] = this.#cache.tee()
|
||||
this.#cache = cache
|
||||
return tee
|
||||
}
|
||||
}
|
||||
170
packages/moq/contribute/track.ts
Normal file
170
packages/moq/contribute/track.ts
Normal file
@@ -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<Uint8Array> {
|
||||
while (!this.#init) {
|
||||
if (this.#closed) throw new Error("track closed")
|
||||
await this.#notify.wait()
|
||||
}
|
||||
|
||||
return this.#init
|
||||
}
|
||||
|
||||
// TODO generize this
|
||||
segments(): ReadableStream<Segment> {
|
||||
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"
|
||||
}
|
||||
18
packages/moq/contribute/tsconfig.json
Normal file
18
packages/moq/contribute/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["."],
|
||||
"compilerOptions": {
|
||||
"types": ["dom-mediacapture-transform", "dom-webcodecs"]
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../common"
|
||||
},
|
||||
{
|
||||
"path": "../transport"
|
||||
},
|
||||
{
|
||||
"path": "../media"
|
||||
}
|
||||
]
|
||||
}
|
||||
111
packages/moq/contribute/video.ts
Normal file
111
packages/moq/contribute/video.ts
Normal file
@@ -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<VideoFrame, VideoDecoderConfig | EncodedVideoChunk>
|
||||
|
||||
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<EncodedVideoChunk>) {
|
||||
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<VideoDecoderConfig | EncodedVideoChunk>,
|
||||
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
|
||||
}
|
||||
}
|
||||
218
packages/moq/media/catalog/index.ts
Normal file
218
packages/moq/media/catalog/index.ts
Normal file
@@ -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<Root> {
|
||||
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
|
||||
}
|
||||
37
packages/moq/media/mp4/index.ts
Normal file
37
packages/moq/media/mp4/index.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
71
packages/moq/media/mp4/parser.ts
Normal file
71
packages/moq/media/mp4/parser.ts
Normal file
@@ -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<Frame> = []
|
||||
|
||||
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<Frame> {
|
||||
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
|
||||
}
|
||||
}
|
||||
13
packages/moq/media/mp4/rename.ts
Normal file
13
packages/moq/media/mp4/rename.ts
Normal file
@@ -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"
|
||||
15
packages/moq/media/tsconfig.json
Normal file
15
packages/moq/media/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["."],
|
||||
"compilerOptions": {
|
||||
"types": ["mp4box"]
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../transport"
|
||||
},
|
||||
{
|
||||
"path": "../common"
|
||||
}
|
||||
]
|
||||
}
|
||||
29
packages/moq/package.json
Normal file
29
packages/moq/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
50
packages/moq/playback/audio.ts
Normal file
50
packages/moq/playback/audio.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
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<AudioWorkletNode>
|
||||
|
||||
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<AudioWorkletNode> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
114
packages/moq/playback/backend.ts
Normal file
114
packages/moq/playback/backend.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
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<Uint8Array>
|
||||
}
|
||||
190
packages/moq/playback/index.ts
Normal file
190
packages/moq/playback/index.ts
Normal file
@@ -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<Timeline | undefined>(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<void>
|
||||
#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<void>((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<Player> {
|
||||
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<Catalog.Track>()
|
||||
|
||||
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<Error | undefined> {
|
||||
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
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
22
packages/moq/playback/tsconfig.json
Normal file
22
packages/moq/playback/tsconfig.json
Normal file
@@ -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": {
|
||||
"@/*": ["*"]
|
||||
}
|
||||
}
|
||||
73
packages/moq/playback/worker/audio.ts
Normal file
73
packages/moq/playback/worker/audio.ts
Normal file
@@ -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<Frame, AudioData>
|
||||
|
||||
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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
119
packages/moq/playback/worker/index.ts
Normal file
119
packages/moq/playback/worker/index.ts
Normal file
@@ -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<string, Deferred<Uint8Array>>()
|
||||
|
||||
// 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<MP4.Frame>({})
|
||||
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)
|
||||
}
|
||||
98
packages/moq/playback/worker/message.ts
Normal file
98
packages/moq/playback/worker/message.ts
Normal file
@@ -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<Uint8Array>
|
||||
}
|
||||
|
||||
/*
|
||||
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
|
||||
}
|
||||
|
||||
*/
|
||||
118
packages/moq/playback/worker/timeline.ts
Normal file
118
packages/moq/playback/worker/timeline.ts
Normal file
@@ -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<Frame>
|
||||
}
|
||||
|
||||
export class Component {
|
||||
#current?: Segment
|
||||
|
||||
frames: ReadableStream<Frame>
|
||||
#segments: TransformStream<Segment, Segment>
|
||||
|
||||
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<Frame>) {
|
||||
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
|
||||
}
|
||||
84
packages/moq/playback/worker/video.ts
Normal file
84
packages/moq/playback/worker/video.ts
Normal file
@@ -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<Frame, VideoFrame>
|
||||
|
||||
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<VideoFrame>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
58
packages/moq/playback/worklet/index.ts
Normal file
58
packages/moq/playback/worklet/index.ts
Normal file
@@ -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<string, Float32Array>): 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)
|
||||
12
packages/moq/playback/worklet/message.ts
Normal file
12
packages/moq/playback/worklet/message.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { RingShared } from "../../common/ring"
|
||||
|
||||
export interface From {
|
||||
config?: Config
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
channels: number
|
||||
sampleRate: number
|
||||
|
||||
ring: RingShared
|
||||
}
|
||||
14
packages/moq/playback/worklet/tsconfig.json
Normal file
14
packages/moq/playback/worklet/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["."],
|
||||
"exclude": ["./index"],
|
||||
"compilerOptions": {
|
||||
"lib": ["es2022"],
|
||||
"types": ["audioworklet"]
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../../common"
|
||||
}
|
||||
]
|
||||
}
|
||||
83
packages/moq/transport/client.ts
Normal file
83
packages/moq/transport/client.ts
Normal file
@@ -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<WebTransportHash | undefined>
|
||||
|
||||
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<Connection> {
|
||||
// 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<WebTransportHash | undefined> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
95
packages/moq/transport/connection.ts
Normal file
95
packages/moq/transport/connection.ts
Normal file
@@ -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<void>
|
||||
|
||||
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<void> {
|
||||
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<Error> {
|
||||
try {
|
||||
await this.#running
|
||||
return new Error("closed")
|
||||
} catch (e) {
|
||||
return asError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
550
packages/moq/transport/control.ts
Normal file
550
packages/moq/transport/control.ts
Normal file
@@ -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<bigint, Uint8Array>
|
||||
|
||||
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<Message> {
|
||||
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<void>((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<Msg> {
|
||||
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<Message> {
|
||||
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<Subscribe> {
|
||||
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<Location> {
|
||||
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<Parameters | undefined> {
|
||||
const count = await this.r.u53()
|
||||
if (count == 0) return undefined
|
||||
|
||||
const params = new Map<bigint, Uint8Array>()
|
||||
|
||||
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<SubscribeOk> {
|
||||
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<SubscribeDone> {
|
||||
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<SubscribeError> {
|
||||
return {
|
||||
kind: Msg.SubscribeError,
|
||||
id: await this.r.u62(),
|
||||
code: await this.r.u62(),
|
||||
reason: await this.r.string(),
|
||||
}
|
||||
}
|
||||
|
||||
private async unsubscribe(): Promise<Unsubscribe> {
|
||||
return {
|
||||
kind: Msg.Unsubscribe,
|
||||
id: await this.r.u62(),
|
||||
}
|
||||
}
|
||||
|
||||
private async announce(): Promise<Announce> {
|
||||
const namespace = await this.r.string()
|
||||
|
||||
return {
|
||||
kind: Msg.Announce,
|
||||
namespace,
|
||||
params: await this.parameters(),
|
||||
}
|
||||
}
|
||||
|
||||
private async announce_ok(): Promise<AnnounceOk> {
|
||||
return {
|
||||
kind: Msg.AnnounceOk,
|
||||
namespace: await this.r.string(),
|
||||
}
|
||||
}
|
||||
|
||||
private async announce_error(): Promise<AnnounceError> {
|
||||
return {
|
||||
kind: Msg.AnnounceError,
|
||||
namespace: await this.r.string(),
|
||||
code: await this.r.u62(),
|
||||
reason: await this.r.string(),
|
||||
}
|
||||
}
|
||||
|
||||
private async unannounce(): Promise<Unannounce> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
7
packages/moq/transport/index.ts
Normal file
7
packages/moq/transport/index.ts
Normal file
@@ -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"
|
||||
307
packages/moq/transport/objects.ts
Normal file
307
packages/moq/transport/objects.ts
Normal file
@@ -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> = 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<T extends TrackHeader | GroupHeader | ObjectHeader>(h: T): Promise<WriterType<T>> {
|
||||
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<T>
|
||||
|
||||
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<T>
|
||||
} else if (h.type === StreamType.Group) {
|
||||
await w.u53(h.group)
|
||||
await w.u53(h.priority)
|
||||
|
||||
res = new GroupWriter(h, w) as WriterType<T>
|
||||
} else if (h.type === StreamType.Track) {
|
||||
await w.u53(h.priority)
|
||||
|
||||
res = new TrackWriter(h, w) as WriterType<T>
|
||||
} else {
|
||||
throw new Error("unknown header type")
|
||||
}
|
||||
|
||||
// console.trace("send object", res.header)
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
async recv(): Promise<TrackReader | GroupReader | ObjectReader | undefined> {
|
||||
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<TrackChunk | undefined> {
|
||||
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<GroupChunk | undefined> {
|
||||
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<ObjectChunk | undefined> {
|
||||
if (await this.stream.done()) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
payload: await this.stream.readAll(),
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.stream.close()
|
||||
}
|
||||
}
|
||||
230
packages/moq/transport/publisher.ts
Normal file
230
packages/moq/transport/publisher.ts
Normal file
@@ -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<string, AnnounceSend>()
|
||||
|
||||
// Their subscribed tracks.
|
||||
#subscribe = new Map<bigint, SubscribeRecv>()
|
||||
#subscribeQueue = new Queue<SubscribeRecv>(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<AnnounceSend> {
|
||||
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<TrackWriter> {
|
||||
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<GroupWriter> {
|
||||
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<ObjectWriter> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
163
packages/moq/transport/setup.ts
Normal file
163
packages/moq/transport/setup.ts
Normal file
@@ -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<bigint, Uint8Array>
|
||||
|
||||
export class Decoder {
|
||||
r: Reader
|
||||
|
||||
constructor(r: Reader) {
|
||||
this.r = r
|
||||
}
|
||||
|
||||
async client(): Promise<Client> {
|
||||
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<Server> {
|
||||
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<Parameters | undefined> {
|
||||
const count = await this.r.u53()
|
||||
if (count == 0) return undefined
|
||||
|
||||
const params = new Map<bigint, Uint8Array>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
270
packages/moq/transport/stream.ts
Normal file
270
packages/moq/transport/stream.ts
Normal file
@@ -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<Uint8Array>
|
||||
#reader: ReadableStreamDefaultReader<Uint8Array>
|
||||
|
||||
constructor(buffer: Uint8Array, stream: ReadableStream<Uint8Array>) {
|
||||
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<boolean> {
|
||||
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<Uint8Array> {
|
||||
if (size == 0) return new Uint8Array()
|
||||
|
||||
await this.#fillTo(size)
|
||||
return this.#slice(size)
|
||||
}
|
||||
|
||||
async readAll(): Promise<Uint8Array> {
|
||||
// eslint-disable-next-line no-empty
|
||||
while (await this.#fill()) {}
|
||||
return this.#slice(this.#buffer.byteLength)
|
||||
}
|
||||
|
||||
async string(maxLength?: number): Promise<string> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<bigint> {
|
||||
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<boolean> {
|
||||
if (this.#buffer.byteLength > 0) return false
|
||||
return !(await this.#fill())
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.#reader.releaseLock()
|
||||
await this.#stream.cancel()
|
||||
}
|
||||
|
||||
release(): [Uint8Array, ReadableStream<Uint8Array>] {
|
||||
this.#reader.releaseLock()
|
||||
return [this.#buffer, this.#stream]
|
||||
}
|
||||
}
|
||||
|
||||
// Writer wraps a stream and writes chunks of data
|
||||
export class Writer {
|
||||
#scratch: Uint8Array
|
||||
#writer: WritableStreamDefaultWriter<Uint8Array>
|
||||
#stream: WritableStream<Uint8Array>
|
||||
|
||||
constructor(stream: WritableStream<Uint8Array>) {
|
||||
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<Uint8Array> {
|
||||
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)
|
||||
}
|
||||
197
packages/moq/transport/subscriber.ts
Normal file
197
packages/moq/transport/subscriber.ts
Normal file
@@ -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<string, AnnounceRecv>()
|
||||
#announceQueue = new Watch<AnnounceRecv[]>([])
|
||||
|
||||
// Our subscribed tracks.
|
||||
#subscribe = new Map<bigint, SubscribeSend>()
|
||||
#subscribeNext = 0n
|
||||
|
||||
constructor(control: Control.Stream, objects: Objects) {
|
||||
this.#control = control
|
||||
this.#objects = objects
|
||||
}
|
||||
|
||||
announced(): Watch<AnnounceRecv[]> {
|
||||
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<TrackReader | GroupReader | ObjectReader>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
9
packages/moq/transport/tsconfig.json
Normal file
9
packages/moq/transport/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["."],
|
||||
"references": [
|
||||
{
|
||||
"path": "../common"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
packages/moq/tsconfig.json
Normal file
42
packages/moq/tsconfig.json
Normal file
@@ -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": {
|
||||
"@/*": ["*"]
|
||||
}
|
||||
}
|
||||
1848
packages/moq/types/mp4box.d.ts
vendored
Normal file
1848
packages/moq/types/mp4box.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
4
packages/moq/types/tsconfig.json
Normal file
4
packages/moq/types/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["."]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
</title>
|
||||
|
||||
4
sst-env.d.ts
vendored
4
sst-env.d.ts
vendored
@@ -7,10 +7,6 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Relay": {
|
||||
"type": "sst.aws.Service"
|
||||
"url": string
|
||||
}
|
||||
}
|
||||
}
|
||||
export {}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user