From 4675c27179428a737b73ede8b44fb57a876ab0da Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 16 May 2023 10:23:50 -0700 Subject: [PATCH] Make a docker-compose ez mode. --- README.md | 69 ++++++++----------------- cert/.dockerignore | 3 ++ cert/.gitignore | 1 + cert/Dockerfile | 20 ++++++++ cert/generate | 2 +- docker-compose.yml | 45 ++++++++++++++++ media/.dockerignore | 1 + media/Dockerfile | 20 ++++++++ media/{generate => fragment} | 0 server/.dockerignore | 1 + server/Cargo.lock | 6 ++- server/Cargo.toml | 6 +-- server/Dockerfile | 42 +++++++++++++++ server/src/main.rs | 2 +- server/src/session/mod.rs | 9 ++-- server/src/transport/server.rs | 4 ++ server/src/transport/streams.rs | 66 +++++++++++++----------- web/.dockerignore | 4 ++ web/.eslintrc.cjs | 13 +++++ web/Dockerfile | 26 ++++++++++ web/package.json | 5 +- web/src/index.ts | 6 +-- web/src/player/audio.ts | 75 +++++++++++++++++++++++++++ web/src/player/video.ts | 91 +++++++++++++++++++++++++++++++++ web/src/transport/index.ts | 4 +- 25 files changed, 425 insertions(+), 96 deletions(-) create mode 100644 cert/.dockerignore create mode 100644 cert/Dockerfile create mode 100644 docker-compose.yml create mode 100644 media/.dockerignore create mode 100644 media/Dockerfile rename media/{generate => fragment} (100%) create mode 100644 server/.dockerignore create mode 100644 server/Dockerfile create mode 100644 web/.dockerignore create mode 100644 web/.eslintrc.cjs create mode 100644 web/Dockerfile create mode 100644 web/src/player/audio.ts create mode 100644 web/src/player/video.ts diff --git a/README.md b/README.md index 7af173b..43e6dc6 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,39 @@ # Warp -Segmented live media delivery protocol utilizing QUIC streams. See the [Warp draft](https://datatracker.ietf.org/doc/draft-lcurley-warp/). +Live media delivery protocol utilizing QUIC streams. See the [Warp draft](https://datatracker.ietf.org/doc/draft-lcurley-warp/). -Warp works by delivering each audio and video segment as a separate QUIC stream. These streams are assigned a priority such that old video will arrive last and can be dropped. This avoids buffering in many cases, offering the viewer a potentially better experience. +Warp works by delivering media over independent QUIC stream. These streams are assigned a priority such that old video will arrive last and can be dropped. This avoids buffering in many cases, offering the viewer a potentially better experience. -# Limitations -## Browser Support -This demo currently only works on Chrome for two reasons: +This demo requires WebTransport and WebCodecs, which currently (May 2023) only works on Chrome. -1. WebTransport support. -2. [Media underflow behavior](https://github.com/whatwg/html/issues/6359). +# Development +## Easy Mode +Requires Docker *only*. -The ability to skip video abuses the fact that Chrome can play audio without video for up to 3 seconds (hardcoded!) when using MSE. It is possible to use something like WebCodecs instead... but that's still Chrome only at the moment. +``` +docker-compose up --build +``` -## Streaming -This demo works by reading pre-encoded media and sleeping based on media timestamps. Obviously this is not a live stream; you should plug in your own encoder or source. +Then open [https://localhost:4444/](https://localhost:4444) in a browser. You'll have to click past the TLS error, but that's the price you pay for being lazy. Follow the more in-depth instructions if you want a better development experience. -The media is encoded on disk as a LL-DASH playlist. There's a crude parser and I haven't used DASH before so don't expect it to work with arbitrary inputs. - -## QUIC Implementation -This demo uses a fork of [quic-go](https://github.com/lucas-clemente/quic-go). There are two critical features missing upstream: - -1. ~~[WebTransport](https://github.com/lucas-clemente/quic-go/issues/3191)~~ -2. [Prioritization](https://github.com/lucas-clemente/quic-go/pull/3442) - -## Congestion Control -This demo uses a single rendition. A production implementation will want to: - -1. Change the rendition bitrate to match the estimated bitrate. -2. Switch renditions at segment boundaries based on the estimated bitrate. -3. or both! - -Also, quic-go ships with the default New Reno congestion control. Something like [BBRv2](https://github.com/lucas-clemente/quic-go/issues/341) will work much better for live video as it limits RTT growth. - - -# Setup ## Requirements * Go +* Rust * ffmpeg * openssl -* Chrome Canary +* Chrome ## Media This demo simulates a live stream by reading a file from disk and sleeping based on media timestamps. Obviously you should hook this up to a real live stream to do anything useful. -Download your favorite media file: +Download your favorite media file and convert it to fragmented MP4: ``` wget http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 -O media/source.mp4 +./media/fragment ``` -Use ffmpeg to create a LL-DASH playlist. This creates a segment every 2s and MP4 fragment every 10ms. -``` -./media/generate -``` - -You can increase the `frag_duration` (microseconds) to slightly reduce the file size in exchange for higher latency. - -## TLS +## Certificates Unfortunately, QUIC mandates TLS and makes local development difficult. - -If you have a valid certificate you can use it instead of self-signing. The go binaries take a `-tls-cert` and `-tls-key` argument. Skip the remaining steps in this section and use your hostname instead. +If you have a valid certificate you can use it instead of self-signing. Otherwise, we use [mkcert](https://github.com/FiloSottile/mkcert) to install a self-signed CA: ``` @@ -72,13 +47,13 @@ The Warp server supports WebTransport, pushing media over streams once a connect ``` cd server -go run main.go +cargo run ``` -This can be accessed via WebTransport on `https://localhost:4443` by default. +This listens for WebTransport connections (not HTTP) on `https://localhost:4443` by default. -## Web Player -The web assets need to be hosted with a HTTPS server. If you're using a self-signed certificate, you may need to ignore the security warning in Chrome (Advanced -> proceed to localhost). +## Web +The web assets need to be hosted with a HTTPS server. ``` cd web @@ -86,6 +61,4 @@ yarn install yarn serve ``` -These can be accessed on `https://localhost:4444` by default. - -If you use a custom domain for the Warp server, make sure to override the server URL with the `url` query string parameter, e.g. `https://localhost:4444/?url=https://warp.demo`. +These can be accessed on `https://localhost:4444` by default. \ No newline at end of file diff --git a/cert/.dockerignore b/cert/.dockerignore new file mode 100644 index 0000000..9879661 --- /dev/null +++ b/cert/.dockerignore @@ -0,0 +1,3 @@ +*.crt +*.key +*.hex \ No newline at end of file diff --git a/cert/.gitignore b/cert/.gitignore index be870b4..9879661 100644 --- a/cert/.gitignore +++ b/cert/.gitignore @@ -1,2 +1,3 @@ *.crt *.key +*.hex \ No newline at end of file diff --git a/cert/Dockerfile b/cert/Dockerfile new file mode 100644 index 0000000..84cb44c --- /dev/null +++ b/cert/Dockerfile @@ -0,0 +1,20 @@ +# Use ubuntu because it's ez +FROM ubuntu:latest + +# Use openssl and golang to generate certificates +RUN apt-get update && \ + apt-get install -y ca-certificates openssl golang + +# Save the certificates to a volume +VOLUME /cert +WORKDIR /cert + +# Download the go modules +COPY go.mod go.sum ./ +RUN go mod download + +# Copy over the remaining files. +COPY . . + +# TODO support an output directory +CMD ./generate \ No newline at end of file diff --git a/cert/generate b/cert/generate index bcbf9c9..c86b309 100755 --- a/cert/generate +++ b/cert/generate @@ -17,4 +17,4 @@ go run filippo.io/mkcert -ecdsa -install go run filippo.io/mkcert -ecdsa -days 10 -cert-file "$CRT" -key-file "$KEY" localhost 127.0.0.1 ::1 # Compute the sha256 fingerprint of the certificate for WebTransport -openssl x509 -in "$CRT" -outform der | openssl dgst -sha256 > ../web/fingerprint.hex +openssl x509 -in "$CRT" -outform der | openssl dgst -sha256 > localhost.hex \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c2ad05d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +version: '3' + +services: + # Generate certificates only valid for 14 days. + cert: + build: ./cert + volumes: + - cert:/cert + + # Generate a fragmented MP4 file for testing. + media: + build: ./media + volumes: + - media:/media + + # Serve the web code once we have certificates. + web: + build: ./web + ports: + - "4444:4444" + volumes: + - cert:/cert + depends_on: + cert: + condition: service_completed_successfully + + # Run the server once we have certificates and media. + server: + build: ./server + environment: + - RUST_LOG=debug + ports: + - "4443:4443/udp" + volumes: + - cert:/cert + - media:/media + depends_on: + cert: + condition: service_completed_successfully + media: + condition: service_completed_successfully + +volumes: + cert: + media: diff --git a/media/.dockerignore b/media/.dockerignore new file mode 100644 index 0000000..fa48555 --- /dev/null +++ b/media/.dockerignore @@ -0,0 +1 @@ +fragmented.mp4 diff --git a/media/Dockerfile b/media/Dockerfile new file mode 100644 index 0000000..f8f36e4 --- /dev/null +++ b/media/Dockerfile @@ -0,0 +1,20 @@ +# Create a build image +FROM ubuntu:latest + +# Install necessary packages +RUN apt-get update && \ + apt-get install -y \ + ca-certificates \ + wget \ + ffmpeg + +# Create a media volume +VOLUME /media +WORKDIR /media + +# Download a file from the internet, in this case my boy big buck bunny +RUN wget http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 -O source.mp4 + +# Copy an run a script to create a fragmented mp4 (more overhead, easier to split) +COPY fragment . +CMD ./fragment diff --git a/media/generate b/media/fragment similarity index 100% rename from media/generate rename to media/fragment diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1 @@ +target diff --git a/server/Cargo.lock b/server/Cargo.lock index 0ae81ac..88d44d5 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -329,6 +329,8 @@ dependencies = [ [[package]] name = "mp4" version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509348cba250e7b852a875100a2ddce7a36ee3abf881a681c756670c1774264d" dependencies = [ "byteorder", "bytes", @@ -384,7 +386,7 @@ dependencies = [ [[package]] name = "octets" version = "0.2.0" -source = "git+https://github.com/n8o/quiche.git?branch=master#0137dc3ca6f4f31e3175d0a0868acb9c64b46cc7" +source = "git+https://github.com/kixelated/quiche.git?branch=master#007a25b35b9509d673466fed8ddc73fd8d9b4184" [[package]] name = "once_cell" @@ -404,7 +406,7 @@ dependencies = [ [[package]] name = "quiche" version = "0.17.1" -source = "git+https://github.com/n8o/quiche.git?branch=master#0137dc3ca6f4f31e3175d0a0868acb9c64b46cc7" +source = "git+https://github.com/kixelated/quiche.git?branch=master#007a25b35b9509d673466fed8ddc73fd8d9b4184" dependencies = [ "cmake", "lazy_static", diff --git a/server/Cargo.toml b/server/Cargo.toml index af201c6..1d04ec0 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -6,13 +6,13 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -quiche = { git = "https://github.com/n8o/quiche.git", branch = "master" } # WebTransport fork +quiche = { git = "https://github.com/kixelated/quiche.git", branch = "master" } # WebTransport fork clap = { version = "4.0", features = [ "derive" ] } log = { version = "0.4", features = ["std"] } mio = { version = "0.8", features = ["net", "os-poll"] } env_logger = "0.9.3" ring = "0.16" anyhow = "1.0.70" -mp4 = { path = "../../mp4-rust" } # { git = "https://github.com/kixelated/mp4-rust.git", branch = "trexs" } +mp4 = "0.13.0" serde = "1.0.160" -serde_json = "1.0" \ No newline at end of file +serde_json = "1.0" diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..b903dc9 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,42 @@ +# Use the official Rust image as the base image +FROM rust:latest as build + +# Quiche requires docker +RUN apt-get update && \ + apt-get install -y cmake + +# Set the build directory +WORKDIR /warp + +# Create an empty project +RUN cargo init --bin + +# Copy the Cargo.toml and Cargo.lock files to the container +COPY Cargo.toml Cargo.lock ./ + +# Build the empty project so we download/cache dependencies +RUN cargo build --release + +# Copy the entire project to the container +COPY . . + +# Build the project +RUN cargo build --release + +# Make a new image to run the binary +FROM ubuntu:latest + +# Use a volume to access certificates +VOLUME /cert + +# Use another volume to access the media +VOLUME /media + +# Expose port 4443 for the server +EXPOSE 4443/udp + +# Copy the built binary +COPY --from=build /warp/target/release/warp /bin + +# Set the startup command to run the binary +CMD warp --cert /cert/localhost.crt --key /cert/localhost.key --media /media/fragmented.mp4 \ No newline at end of file diff --git a/server/src/main.rs b/server/src/main.rs index 3d4d422..56441d7 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -6,7 +6,7 @@ use clap::Parser; #[derive(Parser)] struct Cli { /// Listen on this address - #[arg(short, long, default_value = "127.0.0.1:4443")] + #[arg(short, long, default_value = "0.0.0.0:4443")] addr: String, /// Use the certificate file at this path diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index b3b38e7..0b4af65 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -29,13 +29,15 @@ impl transport::App for Session { Ok(e) => e, }; + log::debug!("webtransport event {:?}", event); + match event { webtransport::ServerEvent::ConnectRequest(_req) => { // you can handle request with // req.authority() // req.path() // and you can validate this request with req.origin() - session.accept_connect_request(conn, None).unwrap(); + session.accept_connect_request(conn, None)?; // TODO let media = media::Source::new("../media/fragmented.mp4")?; @@ -65,10 +67,11 @@ impl transport::App for Session { } // Send any pending stream data. - self.streams.poll(conn)?; + // NOTE: This doesn't return an error because it's async, and would be confusing. + self.streams.poll(conn); // Fetch the next media fragment, possibly queuing up stream data. - self.poll_source(conn, session)?; + self.poll_source(conn, session).expect("poll_source"); Ok(()) } diff --git a/server/src/transport/server.rs b/server/src/transport/server.rs index 202d526..d0ab1f7 100644 --- a/server/src/transport/server.rs +++ b/server/src/transport/server.rs @@ -78,6 +78,8 @@ impl Server { } pub fn run(&mut self) -> anyhow::Result<()> { + log::info!("listening on {}", self.socket.local_addr()?); + loop { self.wait()?; self.receive()?; @@ -253,6 +255,8 @@ impl Server { for conn in self.conns.values_mut() { if let Some(session) = &mut conn.session { if let Err(e) = conn.app.poll(&mut conn.quiche, session) { + log::debug!("app error: {:?}", e); + // Close the connection on any application error let reason = format!("app error: {:?}", e); conn.quiche.close(true, 0xff, reason.as_bytes()).ok(); diff --git a/server/src/transport/streams.rs b/server/src/transport/streams.rs index 149e5de..de9bdd9 100644 --- a/server/src/transport/streams.rs +++ b/server/src/transport/streams.rs @@ -55,7 +55,11 @@ impl Streams { // If there's no data buffered, try to write it immediately. let size = if stream.buffer.is_empty() { - conn.stream_send(id, buf, fin)? + match conn.stream_send(id, buf, fin) { + Ok(size) => size, + Err(quiche::Error::Done) => 0, + Err(e) => anyhow::bail!(e), + } } else { 0 }; @@ -71,36 +75,8 @@ impl Streams { } // Flush any pending stream data. - pub fn poll(&mut self, conn: &mut quiche::Connection) -> anyhow::Result<()> { - // Loop over stream in order order. - 'outer: for stream in self.ordered.iter_mut() { - // Keep reading from the buffer until it's empty. - while !stream.buffer.is_empty() { - // VecDeque is a ring buffer, so we can't write the whole thing at once. - let parts = stream.buffer.as_slices(); - - let size = conn.stream_send(stream.id, parts.0, false)?; - if size == 0 { - // No more space available for this stream. - continue 'outer; - } - - // Remove the bytes that were written. - stream.buffer.drain(..size); - } - - if stream.fin { - // Write the stream done signal. - conn.stream_send(stream.id, &[], true)?; - } - } - - // Remove streams that are done. - // No need to reprioritize, since the streams are still in order order. - self.ordered - .retain(|stream| !stream.buffer.is_empty() || !stream.fin); - - Ok(()) + pub fn poll(&mut self, conn: &mut quiche::Connection) { + self.ordered.retain_mut(|s| s.poll(conn).is_ok()); } // Set the send order of the stream. @@ -143,3 +119,31 @@ impl Streams { pos } } + +impl Stream { + fn poll(&mut self, conn: &mut quiche::Connection) -> quiche::Result<()> { + // Keep reading from the buffer until it's empty. + while !self.buffer.is_empty() { + // VecDeque is a ring buffer, so we can't write the whole thing at once. + let parts = self.buffer.as_slices(); + + let size = conn.stream_send(self.id, parts.0, false)?; + if size == 0 { + // No more space available for this stream. + return Ok(()); + } + + // Remove the bytes that were written. + self.buffer.drain(..size); + } + + if self.fin { + // Write the stream done signal. + conn.stream_send(self.id, &[], true)?; + + Err(quiche::Error::Done) + } else { + Ok(()) + } + } +} diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..93e58d7 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,4 @@ +dist +.parcel-cache +node_modules +fingerprint.hex \ No newline at end of file diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs new file mode 100644 index 0000000..278f910 --- /dev/null +++ b/web/.eslintrc.cjs @@ -0,0 +1,13 @@ +/* eslint-env node */ +module.exports = { + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + root: true, + ignorePatterns: [ 'dist', 'node_modules' ], + rules: { + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-explicit-any": "off", + } +}; diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..25c9320 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,26 @@ +# Use the official Node.js image as the build image +FROM node:latest + +# Set the build directory +WORKDIR /build + +# Copy the package.json and yarn.lock files to the container +COPY package*.json yarn.lock ./ + +# Install dependencies +RUN yarn install + +# Copy the entire project to the container +COPY . . + +# Expose port 4444 for serving the project +EXPOSE 4444 + +# Copy the certificate hash before running +VOLUME /cert + +# Make a symlink to the certificate fingerprint +RUN ln -s /cert/localhost.hex fingerprint.hex + +# Copy the certificate fingerprint and start the web server +CMD yarn parcel serve --https --cert /cert/localhost.crt --key /cert/localhost.key --port 4444 \ No newline at end of file diff --git a/web/package.json b/web/package.json index 0fe1770..72148b6 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,8 @@ { + "license": "Apache-2.0", "source": "src/index.html", "scripts": { - "serve": "parcel serve --https --cert ../cert/localhost.crt --key ../cert/localhost.key --host localhost --port 4444 --open", + "serve": "parcel serve --https --cert ../cert/localhost.crt --key ../cert/localhost.key --port 4444 --open", "build": "parcel build", "check": "tsc --noEmit" }, @@ -16,4 +17,4 @@ "dependencies": { "mp4box": "^0.5.2" } -} +} \ No newline at end of file diff --git a/web/src/index.ts b/web/src/index.ts index 5882480..2f83061 100644 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -12,7 +12,7 @@ for (let c = 0; c < fingerprintHex.length-1; c += 2) { const params = new URLSearchParams(window.location.search) -const url = params.get("url") || "https://127.0.0.1:4443/watch" +const url = params.get("url") || "https://localhost:4443/watch" const canvas = document.querySelector("canvas#video")! const transport = new Transport({ @@ -30,7 +30,7 @@ const player = new Player({ const play = document.querySelector("#screen #play")! -let playFunc = (e: Event) => { +const playFunc = (e: Event) => { player.play({}) e.preventDefault() @@ -38,4 +38,4 @@ let playFunc = (e: Event) => { play.style.display = "none" } -play.addEventListener('click', playFunc) \ No newline at end of file +play.addEventListener('click', playFunc) diff --git a/web/src/player/audio.ts b/web/src/player/audio.ts new file mode 100644 index 0000000..361036d --- /dev/null +++ b/web/src/player/audio.ts @@ -0,0 +1,75 @@ +import * as Message from "./message"; +import { Ring } from "./ring" + +export default class Audio { + ring: Ring; + queue: Array; + + sync?: DOMHighResTimeStamp; // the wall clock value for timestamp 0, in microseconds + last?: number; // the timestamp of the last rendered frame, in microseconds + + constructor(config: Message.AudioConfig) { + this.ring = new Ring(config.ring); + this.queue = []; + } + + push(frame: AudioData) { + if (!this.sync) { + // Save the frame as the sync point + // TODO sync with video + this.sync = 1000 * performance.now() - frame.timestamp + } + + // Drop any old frames + if (this.last && frame.timestamp <= this.last) { + frame.close() + return + } + + // Insert the frame into the queue sorted by timestamp. + if (this.queue.length > 0 && this.queue[this.queue.length-1].timestamp <= frame.timestamp) { + // Fast path because we normally append to the end. + this.queue.push(frame) + } else { + // Do a full binary search + let low = 0 + let high = this.queue.length; + + while (low < high) { + const mid = (low + high) >>> 1; + if (this.queue[mid].timestamp < frame.timestamp) low = mid + 1; + else high = mid; + } + + this.queue.splice(low, 0, frame) + } + } + + + draw() { + // Convert to microseconds + const now = 1000 * performance.now(); + + // Determine the target timestamp. + const target = now - this.sync! + + // Check if we should skip some frames + while (this.queue.length) { + const next = this.queue[0] + + if (next.timestamp > target) { + const ok = this.ring.write(next) + if (!ok) { + console.warn("ring buffer is full") + // No more space in the ring + break + } + } else { + console.warn("dropping audio") + } + + next.close() + this.queue.shift() + } + } +} \ No newline at end of file diff --git a/web/src/player/video.ts b/web/src/player/video.ts new file mode 100644 index 0000000..d112150 --- /dev/null +++ b/web/src/player/video.ts @@ -0,0 +1,91 @@ +import * as Message from "./message"; + +export default class Video { + canvas: OffscreenCanvas; + queue: Array; + + render: number; // non-zero if requestAnimationFrame has been called + sync?: DOMHighResTimeStamp; // the wall clock value for timestamp 0, in microseconds + last?: number; // the timestamp of the last rendered frame, in microseconds + + constructor(config: Message.VideoConfig) { + this.canvas = config.canvas; + this.queue = []; + + this.render = 0; + } + + push(frame: VideoFrame) { + if (!this.sync) { + // Save the frame as the sync point + this.sync = 1000 * performance.now() - frame.timestamp + } + + // Drop any old frames + if (this.last && frame.timestamp <= this.last) { + frame.close() + return + } + + // Insert the frame into the queue sorted by timestamp. + if (this.queue.length > 0 && this.queue[this.queue.length-1].timestamp <= frame.timestamp) { + // Fast path because we normally append to the end. + this.queue.push(frame) + } else { + // Do a full binary search + let low = 0 + let high = this.queue.length; + + while (low < high) { + const mid = (low + high) >>> 1; + if (this.queue[mid].timestamp < frame.timestamp) low = mid + 1; + else high = mid; + } + + this.queue.splice(low, 0, frame) + } + + // Queue up to render the next frame. + if (!this.render) { + this.render = self.requestAnimationFrame(this.draw.bind(this)) + } + } + + draw(now: DOMHighResTimeStamp) { + // Convert to microseconds + now *= 1000; + + // Determine the target timestamp. + const target = now - this.sync! + + let frame = this.queue[0]; + if (frame.timestamp >= target) { + // nothing to render yet, wait for the next animation frame + this.render = self.requestAnimationFrame(this.draw.bind(this)) + return + } + + this.queue.shift(); + + // Check if we should skip some frames + while (this.queue.length) { + const next = this.queue[0] + if (next.timestamp > target) break + + frame.close() + frame = this.queue.shift()!; + } + + const ctx = this.canvas.getContext("2d"); + ctx!.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height) // TODO aspect ratio + + this.last = frame.timestamp; + frame.close() + + if (this.queue.length) { + this.render = self.requestAnimationFrame(this.draw.bind(this)) + } else { + this.render = 0 + } + } +} \ No newline at end of file diff --git a/web/src/transport/index.ts b/web/src/transport/index.ts index 72eb4fd..0532bc3 100644 --- a/web/src/transport/index.ts +++ b/web/src/transport/index.ts @@ -56,7 +56,7 @@ export default class Transport { const q = await this.quic const streams = q.incomingUnidirectionalStreams.getReader() - while (true) { + for (;;) { const result = await streams.read() if (result.done) break @@ -66,7 +66,7 @@ export default class Transport { } async handleStream(stream: ReadableStream) { - let r = new Stream.Reader(stream) + const r = new Stream.Reader(stream) while (!await r.done()) { const size = await r.uint32();