mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
✨ feat: Add streaming support (#125)
This adds: - [x] Keyboard and mouse handling on the frontend - [x] Video and audio streaming from the backend to the frontend - [x] Input server that works with Websockets Update - 17/11 - [ ] Master docker container to run this - [ ] Steam runtime - [ ] Entrypoint.sh --------- Co-authored-by: Kristian Ollikainen <14197772+DatCaptainHorse@users.noreply.github.com> Co-authored-by: Kristian Ollikainen <DatCaptainHorse@users.noreply.github.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,6 +12,8 @@ node_modules
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
|
||||||
|
.idea/
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
diff --git a/src/utils.c b/src/utils.c
|
|
||||||
index e00f3c5..4f1f0bf 100644
|
|
||||||
--- a/src/utils.c
|
|
||||||
+++ b/src/utils.c
|
|
||||||
@@ -71,7 +71,7 @@ void for_each_active_monitor_output_x11(Display *display, active_monitor_callbac
|
|
||||||
char display_name[256];
|
|
||||||
for(int i = 0; i < screen_res->noutput; ++i) {
|
|
||||||
XRROutputInfo *out_info = XRRGetOutputInfo(display, screen_res, screen_res->outputs[i]);
|
|
||||||
- if(out_info && out_info->crtc && out_info->connection == RR_Connected) {
|
|
||||||
+ if(out_info && out_info->crtc) {
|
|
||||||
XRRCrtcInfo *crt_info = XRRGetCrtcInfo(display, screen_res, out_info->crtc);
|
|
||||||
if(crt_info && crt_info->mode) {
|
|
||||||
const XRRModeInfo *mode_info = get_mode_info(screen_res, crt_info->mode);
|
|
||||||
@@ -218,10 +218,10 @@ static void for_each_active_monitor_output_drm(const gsr_egl *egl, active_monito
|
|
||||||
if(connector_type)
|
|
||||||
++connector_type->count;
|
|
||||||
|
|
||||||
- if(connector->connection != DRM_MODE_CONNECTED) {
|
|
||||||
- drmModeFreeConnector(connector);
|
|
||||||
- continue;
|
|
||||||
- }
|
|
||||||
+ //if(connector->connection != DRM_MODE_CONNECTED) {
|
|
||||||
+ // drmModeFreeConnector(connector);
|
|
||||||
+ // continue;
|
|
||||||
+ //}
|
|
||||||
|
|
||||||
if(connector_type)
|
|
||||||
++connector_type->count_active;
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
diff --git a/src/main.cpp b/src/main.cpp
|
|
||||||
index 112a6ac..57bd9bf 100644
|
|
||||||
--- a/src/main.cpp
|
|
||||||
+++ b/src/main.cpp
|
|
||||||
@@ -1906,6 +1906,7 @@ int main(int argc, char **argv) {
|
|
||||||
{ "-gopm", Arg { {}, true, false } }, // deprecated, used keyint instead
|
|
||||||
{ "-keyint", Arg { {}, true, false } },
|
|
||||||
{ "-encoder", Arg { {}, true, false } },
|
|
||||||
+ { "-device", Arg { {}, true, false } },
|
|
||||||
};
|
|
||||||
|
|
||||||
for(int i = 1; i < argc; i += 2) {
|
|
||||||
@@ -2226,6 +2227,10 @@ int main(int argc, char **argv) {
|
|
||||||
overclock = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
+ const char *dri_device = args["-device"].value();
|
|
||||||
+ if (dri_device)
|
|
||||||
+ egl.dri_card_path = dri_device;
|
|
||||||
+
|
|
||||||
egl.card_path[0] = '\0';
|
|
||||||
if(wayland || egl.gpu_info.vendor != GSR_GPU_VENDOR_NVIDIA) {
|
|
||||||
// TODO: Allow specifying another card, and in other places
|
|
||||||
3750
Cargo.lock
generated
Normal file
3750
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
Normal file
16
Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"packages/server",
|
||||||
|
"packages/relay/dev"
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0-alpha.1"
|
||||||
|
repository = "https://github.com/nestriness/nestri"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.80"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
gst = { package = "gstreamer", git = "https://gitlab.freedesktop.org/gstreamer/gstreamer-rs", version = "0.24.0" }
|
||||||
|
gst-app = { package = "gstreamer-app", git = "https://gitlab.freedesktop.org/gstreamer/gstreamer-rs", version = "0.24.0" }
|
||||||
206
Containerfile.master
Normal file
206
Containerfile.master
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
#! Runs the docker server that handles everything else
|
||||||
|
#******************************************************************************
|
||||||
|
# base
|
||||||
|
#******************************************************************************
|
||||||
|
FROM archlinux:base-20241027.0.273886 AS base
|
||||||
|
# How to run - docker run -it --rm --device /dev/dri nestri /bin/bash - DO NOT forget the ports
|
||||||
|
# TODO: Migrate XDG_RUNTIME_DIR to /run/user/1000
|
||||||
|
# TODO: Add nestri-server to pulseaudio.conf
|
||||||
|
# TODO: Add our own entrypoint, with our very own zombie ripper 🧟🏾♀️
|
||||||
|
# FIXME: Add user root to `pulse-access` group as well :D
|
||||||
|
# TODO: Test the whole damn thing
|
||||||
|
|
||||||
|
# Update the pacman repo
|
||||||
|
RUN \
|
||||||
|
pacman -Syu --noconfirm
|
||||||
|
|
||||||
|
#******************************************************************************
|
||||||
|
# builder
|
||||||
|
#******************************************************************************
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
pacman -Su --noconfirm \
|
||||||
|
base-devel \
|
||||||
|
git \
|
||||||
|
sudo \
|
||||||
|
vim
|
||||||
|
|
||||||
|
WORKDIR /scratch
|
||||||
|
|
||||||
|
# Allow nobody user to invoke pacman to install packages (as part of makepkg) and modify the system.
|
||||||
|
# This should never exist in a running image, just used by *-build Docker stages.
|
||||||
|
RUN \
|
||||||
|
echo "nobody ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers;
|
||||||
|
|
||||||
|
ENV ARTIFACTS=/artifacts \
|
||||||
|
CARGO_TARGET_DIR=/build
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
mkdir -p /artifacts \
|
||||||
|
&& mkdir -p /build
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
chgrp nobody /scratch /artifacts /build \
|
||||||
|
&& chmod g+ws /scratch /artifacts /build
|
||||||
|
|
||||||
|
#******************************************************************************
|
||||||
|
# rust-builder
|
||||||
|
#******************************************************************************
|
||||||
|
|
||||||
|
FROM builder AS rust-builder
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
pacman -Su --noconfirm \
|
||||||
|
rustup
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
rustup default stable
|
||||||
|
|
||||||
|
#******************************************************************************
|
||||||
|
# nestri-server-builder
|
||||||
|
#******************************************************************************
|
||||||
|
# Builds nestri server binary
|
||||||
|
FROM rust-builder AS nestri-server-builder
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
pacman -Su --noconfirm \
|
||||||
|
wayland \
|
||||||
|
vpl-gpu-rt \
|
||||||
|
gstreamer \
|
||||||
|
gst-plugin-va \
|
||||||
|
gst-plugins-base \
|
||||||
|
gst-plugins-good \
|
||||||
|
mesa-utils \
|
||||||
|
weston \
|
||||||
|
xorg-xwayland
|
||||||
|
|
||||||
|
|
||||||
|
#******************************************************************************
|
||||||
|
# nestri-server-build
|
||||||
|
#******************************************************************************
|
||||||
|
|
||||||
|
FROM nestri-server-builder AS nestri-server-build
|
||||||
|
|
||||||
|
#Allow makepkg to be run as nobody.
|
||||||
|
RUN chgrp -R nobody /scratch && chmod -R g+ws /scratch
|
||||||
|
|
||||||
|
# USER nobody
|
||||||
|
|
||||||
|
# Perform the server build.
|
||||||
|
WORKDIR /scratch/server
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
git clone https://github.com/nestriness/nestri
|
||||||
|
|
||||||
|
WORKDIR /scratch/server/nestri
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
git checkout feat/stream \
|
||||||
|
&& cargo build -j$(nproc) --release
|
||||||
|
|
||||||
|
# COPY packages/server/build/ /scratch/server/
|
||||||
|
|
||||||
|
# RUN makepkg && cp *.zst "$ARTIFACTS"
|
||||||
|
#******************************************************************************
|
||||||
|
# runtime_base_pkgs
|
||||||
|
#******************************************************************************
|
||||||
|
|
||||||
|
FROM base AS runtime_base_pkgs
|
||||||
|
|
||||||
|
COPY --from=nestri-server-build /build/release/nestri-server /usr/bin/
|
||||||
|
|
||||||
|
#******************************************************************************
|
||||||
|
# runtime_base
|
||||||
|
#******************************************************************************
|
||||||
|
|
||||||
|
FROM runtime_base_pkgs AS runtime_base
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
pacman -Su --noconfirm \
|
||||||
|
weston \
|
||||||
|
sudo \
|
||||||
|
xorg-xwayland \
|
||||||
|
gstreamer \
|
||||||
|
gst-plugins-base \
|
||||||
|
gst-plugins-good \
|
||||||
|
gst-plugin-qsv \
|
||||||
|
gst-plugin-va \
|
||||||
|
gst-plugin-fmp4 \
|
||||||
|
mesa \
|
||||||
|
# Grab GPU encoding packages
|
||||||
|
# Intel (modern VPL + VA-API)
|
||||||
|
vpl-gpu-rt \
|
||||||
|
intel-media-driver \
|
||||||
|
# AMD/ATI (VA-API)
|
||||||
|
libva-mesa-driver \
|
||||||
|
# NVIDIA (proprietary)
|
||||||
|
nvidia-utils \
|
||||||
|
# Audio
|
||||||
|
pulseaudio \
|
||||||
|
# Supervisor
|
||||||
|
supervisor
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
# Set up our non-root user $(nestri)
|
||||||
|
groupadd -g 1000 nestri \
|
||||||
|
&& useradd -ms /bin/bash nestri -u 1000 -g 1000 \
|
||||||
|
&& passwd -d nestri \
|
||||||
|
# Setup Pulseaudio
|
||||||
|
&& useradd -d /var/run/pulse -s /usr/bin/nologin -G audio pulse \
|
||||||
|
&& groupadd pulse-access \
|
||||||
|
&& usermod -aG audio,input,render,video,pulse-access nestri \
|
||||||
|
&& echo "nestri ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers \
|
||||||
|
&& echo "Users created" \
|
||||||
|
# Create an empty machine-id file
|
||||||
|
&& touch /etc/machine-id
|
||||||
|
|
||||||
|
ENV \
|
||||||
|
XDG_RUNTIME_DIR=/tmp
|
||||||
|
|
||||||
|
#******************************************************************************
|
||||||
|
# runtime
|
||||||
|
#******************************************************************************
|
||||||
|
|
||||||
|
FROM runtime_base AS runtime
|
||||||
|
# Setup supervisor #
|
||||||
|
RUN <<-EOF
|
||||||
|
echo -e "
|
||||||
|
[supervisord]
|
||||||
|
user=root
|
||||||
|
nodaemon=true
|
||||||
|
loglevel=info
|
||||||
|
logfile=/tmp/supervisord.log
|
||||||
|
pidfile=/tmp/supervisord.pid
|
||||||
|
|
||||||
|
[program:dbus]
|
||||||
|
user=root
|
||||||
|
command=dbus-daemon --system --nofork
|
||||||
|
logfile=/tmp/dbus.log
|
||||||
|
pidfile=/tmp/dbus.pid
|
||||||
|
stopsignal=INT
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=1
|
||||||
|
|
||||||
|
[program:pulseaudio]
|
||||||
|
user=root
|
||||||
|
command=pulseaudio --daemonize=no --system --disallow-module-loading --disallow-exit --exit-idle-time=-1
|
||||||
|
logfile=/tmp/pulseaudio.log
|
||||||
|
pidfile=/tmp/pulseaudio.pid
|
||||||
|
stopsignal=INT
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=10
|
||||||
|
" | tee /etc/supervisord.conf
|
||||||
|
EOF
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
chown -R nestri:nestri /tmp /etc/supervisord.conf
|
||||||
|
|
||||||
|
ENV USER=nestri
|
||||||
|
USER 1000
|
||||||
|
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
|
||||||
|
# Debug - pactl list
|
||||||
20
Containerfile.relay
Normal file
20
Containerfile.relay
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM docker.io/golang:1.23-alpine AS go-build
|
||||||
|
WORKDIR /builder
|
||||||
|
COPY packages/relay/ /builder/
|
||||||
|
RUN go build
|
||||||
|
|
||||||
|
FROM docker.io/golang:1.23-alpine
|
||||||
|
COPY --from=go-build /builder/relay /relay/relay
|
||||||
|
WORKDIR /relay
|
||||||
|
|
||||||
|
# ENV flags
|
||||||
|
ENV VERBOSE=false
|
||||||
|
ENV ENDPOINT_PORT=8088
|
||||||
|
ENV WEBRTC_UDP_START=10000
|
||||||
|
ENV WEBRTC_UDP_END=20000
|
||||||
|
ENV STUN_SERVER="stun.l.google.com:19302"
|
||||||
|
|
||||||
|
EXPOSE $ENDPOINT_PORT
|
||||||
|
EXPOSE $WEBRTC_UDP_START-$WEBRTC_UDP_END/udp
|
||||||
|
|
||||||
|
ENTRYPOINT ["/relay/relay"]
|
||||||
219
Containerfile.runner
Normal file
219
Containerfile.runner
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# Container build arguments #
|
||||||
|
ARG BASE_IMAGE=docker.io/cachyos/cachyos-v3:latest
|
||||||
|
|
||||||
|
#******************************************************************************
|
||||||
|
# gst-builder
|
||||||
|
#******************************************************************************
|
||||||
|
FROM ${BASE_IMAGE} AS gst-builder
|
||||||
|
WORKDIR /builder/
|
||||||
|
|
||||||
|
# Grab build and rust packages #
|
||||||
|
RUN pacman -Syu --noconfirm meson pkgconf cmake git gcc make rustup \
|
||||||
|
gstreamer gst-plugins-base gst-plugins-good
|
||||||
|
|
||||||
|
# Setup stable rust toolchain #
|
||||||
|
RUN rustup default stable
|
||||||
|
# Clone nestri source #
|
||||||
|
RUN git clone -b feat/stream https://github.com/nestriness/nestri.git
|
||||||
|
|
||||||
|
# Build nestri #
|
||||||
|
RUN cd nestri/packages/server/ && \
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
#******************************************************************************
|
||||||
|
# gstwayland-builder
|
||||||
|
#******************************************************************************
|
||||||
|
FROM ${BASE_IMAGE} AS gstwayland-builder
|
||||||
|
WORKDIR /builder/
|
||||||
|
|
||||||
|
# Grab build and rust packages #
|
||||||
|
RUN pacman -Syu --noconfirm meson pkgconf cmake git gcc make rustup \
|
||||||
|
libxkbcommon wayland gstreamer gst-plugins-base gst-plugins-good libinput
|
||||||
|
|
||||||
|
# Setup stable rust toolchain #
|
||||||
|
RUN rustup default stable
|
||||||
|
# Build required cargo-c package #
|
||||||
|
RUN cargo install cargo-c
|
||||||
|
# Clone gst plugin source #
|
||||||
|
RUN git clone https://github.com/games-on-whales/gst-wayland-display.git
|
||||||
|
|
||||||
|
# Build gst plugin #
|
||||||
|
RUN mkdir plugin && \
|
||||||
|
cd gst-wayland-display && \
|
||||||
|
cargo cinstall --prefix=/builder/plugin/
|
||||||
|
|
||||||
|
|
||||||
|
#******************************************************************************
|
||||||
|
# runtime
|
||||||
|
#******************************************************************************
|
||||||
|
FROM ${BASE_IMAGE} AS runtime
|
||||||
|
|
||||||
|
## Nestri Env Variables ##
|
||||||
|
ENV NESTRI_PARAMS=""
|
||||||
|
ENV RESOLUTION="1280x720"
|
||||||
|
|
||||||
|
## Install Graphics, Media, and Audio packages ##
|
||||||
|
RUN pacman -Syu --noconfirm --needed \
|
||||||
|
# Graphics packages
|
||||||
|
sudo mesa mesa-utils xorg-xwayland labwc wlr-randr mangohud \
|
||||||
|
# Vulkan drivers
|
||||||
|
vulkan-intel vulkan-radeon nvidia-utils \
|
||||||
|
# Media encoding packages
|
||||||
|
vpl-gpu-rt intel-media-driver libva-utils \
|
||||||
|
# GStreamer plugins
|
||||||
|
gstreamer gst-plugins-base gst-plugins-good \
|
||||||
|
gst-plugin-va gst-plugins-bad gst-plugin-fmp4 \
|
||||||
|
gst-plugin-qsv gst-plugin-pipewire gst-plugin-rswebrtc \
|
||||||
|
gst-plugins-ugly gst-plugin-rsrtp \
|
||||||
|
# Audio packages
|
||||||
|
pipewire pipewire-pulse pipewire-alsa wireplumber \
|
||||||
|
# Other requirements
|
||||||
|
supervisor \
|
||||||
|
# Custom
|
||||||
|
umu-launcher && \
|
||||||
|
# Clean up pacman cache and unnecessary files
|
||||||
|
pacman -Scc --noconfirm && \
|
||||||
|
rm -rf /var/cache/pacman/pkg/* /tmp/* /var/tmp/* && \
|
||||||
|
# Optionally clean documentation, man pages, and locales
|
||||||
|
find /usr/share/locale -mindepth 1 -maxdepth 1 ! -name "en*" -exec rm -rf {} + && \
|
||||||
|
rm -rf /usr/share/doc /usr/share/man /usr/share/info
|
||||||
|
|
||||||
|
|
||||||
|
## User ##
|
||||||
|
# Create and setup user #
|
||||||
|
ENV USER="nestri" \
|
||||||
|
UID=99 \
|
||||||
|
GID=100 \
|
||||||
|
USER_PASSWORD="nestri1234" \
|
||||||
|
USER_HOME="/home/nestri"
|
||||||
|
|
||||||
|
RUN mkdir -p ${USER_HOME} && \
|
||||||
|
useradd -d ${USER_HOME} -u ${UID} -s /bin/bash ${USER} && \
|
||||||
|
chown -R ${USER} ${USER_HOME} && \
|
||||||
|
echo "${USER} ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
|
||||||
|
echo "${USER}:${USER_PASSWORD}" | chpasswd
|
||||||
|
|
||||||
|
# Run directory #
|
||||||
|
RUN mkdir -p /run/user/${UID} && \
|
||||||
|
chown ${USER}:${USER} /run/user/${UID}
|
||||||
|
|
||||||
|
# Home config directory #
|
||||||
|
RUN mkdir -p ${USER_HOME}/.config && \
|
||||||
|
chown ${USER}:${USER} ${USER_HOME}/.config
|
||||||
|
|
||||||
|
# Groups #
|
||||||
|
RUN usermod -aG input root && usermod -aG input ${USER} && \
|
||||||
|
usermod -aG video root && usermod -aG video ${USER} && \
|
||||||
|
usermod -aG render root && usermod -aG render ${USER}
|
||||||
|
|
||||||
|
## Copy files from builders ##
|
||||||
|
# this is done here at end to not trigger full rebuild on changes to builder
|
||||||
|
# nestri
|
||||||
|
COPY --from=gst-builder /builder/nestri/target/release/nestri-server /usr/bin/nestri-server
|
||||||
|
# gstwayland
|
||||||
|
COPY --from=gstwayland-builder /builder/plugin/include/libgstwaylanddisplay /usr/include/
|
||||||
|
COPY --from=gstwayland-builder /builder/plugin/lib/*libgstwayland* /usr/lib/
|
||||||
|
COPY --from=gstwayland-builder /builder/plugin/lib/gstreamer-1.0/libgstwayland* /usr/lib/gstreamer-1.0/
|
||||||
|
COPY --from=gstwayland-builder /builder/plugin/lib/pkgconfig/gstwayland* /usr/lib/pkgconfig/
|
||||||
|
COPY --from=gstwayland-builder /builder/plugin/lib/pkgconfig/libgstwayland* /usr/lib/pkgconfig/
|
||||||
|
|
||||||
|
## Copy scripts ##
|
||||||
|
COPY packages/scripts/ /etc/nestri/
|
||||||
|
|
||||||
|
## Startup ##
|
||||||
|
# Setup supervisor #
|
||||||
|
RUN <<-EOF
|
||||||
|
echo -e "
|
||||||
|
[supervisord]
|
||||||
|
user=root
|
||||||
|
nodaemon=true
|
||||||
|
loglevel=info
|
||||||
|
logfile=/tmp/supervisord.log
|
||||||
|
|
||||||
|
[program:dbus]
|
||||||
|
user=root
|
||||||
|
command=dbus-daemon --system --nofork --nopidfile
|
||||||
|
logfile=/tmp/dbus.log
|
||||||
|
autoerestart=true
|
||||||
|
autostart=true
|
||||||
|
startretries=3
|
||||||
|
priority=1
|
||||||
|
|
||||||
|
[program:seatd]
|
||||||
|
user=root
|
||||||
|
command=seatd
|
||||||
|
logfile=/tmp/seatd.log
|
||||||
|
autoerestart=true
|
||||||
|
autostart=true
|
||||||
|
startretries=3
|
||||||
|
priority=2
|
||||||
|
|
||||||
|
[program:pipewire]
|
||||||
|
user=nestri
|
||||||
|
command=dbus-launch pipewire
|
||||||
|
environment=XDG_RUNTIME_DIR=\"/run/user/${UID}\",HOME=\"${USER_HOME}\"
|
||||||
|
logfile=/tmp/pipewire.log
|
||||||
|
autoerestart=true
|
||||||
|
autostart=true
|
||||||
|
startretries=3
|
||||||
|
priority=10
|
||||||
|
|
||||||
|
[program:pipewire-pulse]
|
||||||
|
user=nestri
|
||||||
|
command=dbus-launch pipewire-pulse
|
||||||
|
environment=XDG_RUNTIME_DIR=\"/run/user/${UID}\",HOME=\"${USER_HOME}\"
|
||||||
|
logfile=/tmp/pipewire-pulse.log
|
||||||
|
autoerestart=true
|
||||||
|
autostart=true
|
||||||
|
startretries=3
|
||||||
|
priority=20
|
||||||
|
|
||||||
|
[program:wireplumber]
|
||||||
|
user=nestri
|
||||||
|
command=dbus-launch wireplumber
|
||||||
|
environment=XDG_RUNTIME_DIR=\"/run/user/${UID}\",HOME=\"${USER_HOME}\"
|
||||||
|
logfile=/tmp/wireplumber.log
|
||||||
|
autoerestart=true
|
||||||
|
autostart=true
|
||||||
|
startretries=3
|
||||||
|
priority=30
|
||||||
|
|
||||||
|
[program:nestri-server]
|
||||||
|
user=nestri
|
||||||
|
command=sh -c 'nestri-server \$NESTRI_PARAMS'
|
||||||
|
environment=XDG_RUNTIME_DIR=\"/run/user/${UID}\",HOME=\"${USER_HOME}\"
|
||||||
|
logfile=/tmp/nestri-server.log
|
||||||
|
autoerestart=true
|
||||||
|
autostart=true
|
||||||
|
startretries=3
|
||||||
|
priority=50
|
||||||
|
|
||||||
|
[program:labwc]
|
||||||
|
user=nestri
|
||||||
|
command=sh -c 'sleep 4 && rm -rf /tmp/.X11-unix && mkdir -p /tmp/.X11-unix && chown nestri:nestri /tmp/.X11-unix && labwc'
|
||||||
|
environment=XDG_RUNTIME_DIR=\"/run/user/${UID}\",HOME=\"${USER_HOME}\",WAYLAND_DISPLAY=\"wayland-1\",WLR_BACKENDS=\"wayland\",WLR_RENDERER=\"vulkan\"
|
||||||
|
logfile=/tmp/labwc.log
|
||||||
|
autoerestart=true
|
||||||
|
autostart=true
|
||||||
|
startretries=5
|
||||||
|
priority=60
|
||||||
|
|
||||||
|
[program:wlrrandr]
|
||||||
|
user=nestri
|
||||||
|
command=sh -c 'sleep 6 && wlr-randr --output WL-1 --custom-mode \$RESOLUTION && read -n 1'
|
||||||
|
environment=XDG_RUNTIME_DIR=\"/run/user/${UID}\",HOME=\"${USER_HOME}\",WAYLAND_DISPLAY=\"wayland-0\"
|
||||||
|
logfile=/tmp/wlrrandr.log
|
||||||
|
autoerestart=true
|
||||||
|
autostart=true
|
||||||
|
startretries=10
|
||||||
|
priority=70
|
||||||
|
" | tee /etc/supervisord.conf
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Wireplumber disable suspend #
|
||||||
|
# Remove suspend node
|
||||||
|
RUN sed -z -i 's/{[[:space:]]*name = node\/suspend-node\.lua,[[:space:]]*type = script\/lua[[:space:]]*provides = hooks\.node\.suspend[[:space:]]*}[[:space:]]*//g' /usr/share/wireplumber/wireplumber.conf
|
||||||
|
# Remove "hooks.node.suspend" want
|
||||||
|
RUN sed -i '/wants = \[/{s/hooks\.node\.suspend\s*//; s/,\s*\]/]/}' /usr/share/wireplumber/wireplumber.conf
|
||||||
|
|
||||||
|
ENTRYPOINT ["supervisord", "-c", "/etc/supervisord.conf"]
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Docus
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
Install dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Edge Side Rendering
|
|
||||||
|
|
||||||
Can be deployed to Vercel Functions, Netlify Functions, AWS, and most Node-compatible environments.
|
|
||||||
|
|
||||||
Look at all the available presets [here](https://v3.nuxtjs.org/guide/deploy/presets).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Static Generation
|
|
||||||
|
|
||||||
Use the `generate` command to build your application.
|
|
||||||
|
|
||||||
The HTML files will be generated in the .output/public directory and ready to be deployed to any static compatible hosting.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run generate
|
|
||||||
```
|
|
||||||
|
|
||||||
## Preview build
|
|
||||||
|
|
||||||
You might want to preview the result of your build locally, to do so, run the following command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn preview
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
For a detailed explanation of how things work, check out [Docus](https://docus.dev).
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxi dev",
|
"nestri.dev": "nuxi dev",
|
||||||
"build": "nuxi build",
|
"build": "nuxi build",
|
||||||
"generate": "nuxi generate",
|
"generate": "nuxi generate",
|
||||||
"preview": "nuxi preview",
|
"preview": "nuxi preview",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "./.nuxt/tsconfig.json",
|
// "extends": "./.nuxt/tsconfig.json",
|
||||||
"ignoreConfigErrors": true
|
"ignoreConfigErrors": true
|
||||||
}
|
}
|
||||||
|
|||||||
23
apps/www/adapters/deno/vite.config.ts
Normal file
23
apps/www/adapters/deno/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { denoServerAdapter } from "@builder.io/qwik-city/adapters/deno-server/vite";
|
||||||
|
import { extendConfig } from "@builder.io/qwik-city/vite";
|
||||||
|
import baseConfig from "../../vite.config";
|
||||||
|
|
||||||
|
export default extendConfig(baseConfig, () => {
|
||||||
|
return {
|
||||||
|
build: {
|
||||||
|
ssr: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: ["src/entry.deno.ts", "@qwik-city-plan"],
|
||||||
|
},
|
||||||
|
minify: false,
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
denoServerAdapter({
|
||||||
|
ssg: {
|
||||||
|
include: ["/*"],
|
||||||
|
origin: "https://yoursite.dev",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"build.client": "vite build",
|
"build.client": "vite build",
|
||||||
"build.preview": "vite build --ssr src/entry.preview.tsx",
|
"build.preview": "vite build --ssr src/entry.preview.tsx",
|
||||||
"build.server": "vite build -c adapters/cloudflare-pages/vite.config.ts",
|
"build.server": "vite build -c adapters/cloudflare-pages/vite.config.ts",
|
||||||
|
"deno:build.server": "vite build -c adapters/deno/vite.config.ts",
|
||||||
"build.types": "tsc --incremental --noEmit",
|
"build.types": "tsc --incremental --noEmit",
|
||||||
"deploy": "wrangler pages deploy ./dist",
|
"deploy": "wrangler pages deploy ./dist",
|
||||||
"dev": "vite --mode ssr",
|
"dev": "vite --mode ssr",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"lint": "eslint \"src/**/*.ts*\"",
|
"lint": "eslint \"src/**/*.ts*\"",
|
||||||
"preview": "qwik build preview && vite preview --open",
|
"preview": "qwik build preview && vite preview --open",
|
||||||
"serve": "wrangler pages dev ./dist --compatibility-flags=nodejs_als",
|
"serve": "wrangler pages dev ./dist --compatibility-flags=nodejs_als",
|
||||||
|
"deno:serve": "deno run --allow-net --allow-read --allow-env server/entry.deno.js",
|
||||||
"start": "vite --open --mode ssr",
|
"start": "vite --open --mode ssr",
|
||||||
"qwik": "qwik"
|
"qwik": "qwik"
|
||||||
},
|
},
|
||||||
@@ -34,7 +36,8 @@
|
|||||||
"@builder.io/qwik-react": "0.5.0",
|
"@builder.io/qwik-react": "0.5.0",
|
||||||
"@modular-forms/qwik": "^0.27.0",
|
"@modular-forms/qwik": "^0.27.0",
|
||||||
"@nestri/eslint-config": "*",
|
"@nestri/eslint-config": "*",
|
||||||
"@nestri/moq": "*",
|
"@nestri/input": "*",
|
||||||
|
"@nestri/libmoq": "*",
|
||||||
"@nestri/typescript-config": "*",
|
"@nestri/typescript-config": "*",
|
||||||
"@nestri/ui": "*",
|
"@nestri/ui": "*",
|
||||||
"@types/eslint": "8.56.10",
|
"@types/eslint": "8.56.10",
|
||||||
@@ -54,5 +57,9 @@
|
|||||||
"vite": "5.3.5",
|
"vite": "5.3.5",
|
||||||
"vite-tsconfig-paths": "^4.2.1",
|
"vite-tsconfig-paths": "^4.2.1",
|
||||||
"wrangler": "^3.0.0"
|
"wrangler": "^3.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/pako": "^2.0.3",
|
||||||
|
"pako": "^2.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
apps/www/src/entry.deno.ts
Normal file
45
apps/www/src/entry.deno.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* WHAT IS THIS FILE?
|
||||||
|
*
|
||||||
|
* It's the entry point for the Deno HTTP server when building for production.
|
||||||
|
*
|
||||||
|
* Learn more about the Deno integration here:
|
||||||
|
* - https://qwik.dev/docs/deployments/deno/
|
||||||
|
* - https://docs.deno.com/runtime/tutorials/http_server
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
import { createQwikCity } from "@builder.io/qwik-city/middleware/deno";
|
||||||
|
import qwikCityPlan from "@qwik-city-plan";
|
||||||
|
import { manifest } from "@qwik-client-manifest";
|
||||||
|
import render from "./entry.ssr";
|
||||||
|
|
||||||
|
// Create the Qwik City Deno middleware
|
||||||
|
const { router, notFound, staticFile } = createQwikCity({
|
||||||
|
render,
|
||||||
|
qwikCityPlan,
|
||||||
|
manifest,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow for dynamic port
|
||||||
|
const port = Number(Deno.env.get("PORT") ?? 3009);
|
||||||
|
|
||||||
|
/* eslint-disable */
|
||||||
|
console.log(`Server starter: http://localhost:${port}/app/`);
|
||||||
|
|
||||||
|
Deno.serve({ port }, async (request: Request, info: any) => {
|
||||||
|
const staticResponse = await staticFile(request);
|
||||||
|
if (staticResponse) {
|
||||||
|
return staticResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side render this request with Qwik City
|
||||||
|
const qwikCityResponse = await router(request, info);
|
||||||
|
if (qwikCityResponse) {
|
||||||
|
return qwikCityResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path not found
|
||||||
|
return notFound(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
declare const Deno: any;
|
||||||
@@ -34,7 +34,7 @@ export default component$(() => {
|
|||||||
<RouterHead />
|
<RouterHead />
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
class="bg-gray-100 text-gray-950 dark:bg-gray-900 dark:text-gray-50 font-body flex min-h-[100dvh] flex-col overflow-x-hidden antialiased"
|
class="bg-gray-100 text-gray-950 dark:bg-gray-900 dark:text-gray-50 font-body flex flex-col items-center justify-center overflow-x-hidden antialiased"
|
||||||
lang="en">
|
lang="en">
|
||||||
<RouterOutlet />
|
<RouterOutlet />
|
||||||
{/* {!isDev && <ServiceWorkerRegister />} */}
|
{/* {!isDev && <ServiceWorkerRegister />} */}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as v from "valibot"
|
import * as v from "valibot"
|
||||||
import { Broadcast } from "./tester";
|
//FIXME: Make sure this works
|
||||||
|
// import { Broadcast } from "./tester";
|
||||||
import { cn } from "@nestri/ui/design";
|
import { cn } from "@nestri/ui/design";
|
||||||
import { routeLoader$ } from "@builder.io/qwik-city";
|
import { routeLoader$ } from "@builder.io/qwik-city";
|
||||||
import { component$, $, useSignal } from "@builder.io/qwik";
|
import { component$, $, useSignal } from "@builder.io/qwik";
|
||||||
@@ -36,11 +37,11 @@ export default component$(() => {
|
|||||||
|
|
||||||
const handleSubmit = $<SubmitHandler<Form>>(async (values) => {
|
const handleSubmit = $<SubmitHandler<Form>>(async (values) => {
|
||||||
const randomNamespace = generateRandomWord(6);
|
const randomNamespace = generateRandomWord(6);
|
||||||
const sub = await Broadcast.init({ url: values.url, fingerprint: undefined, namespace: randomNamespace })
|
// const sub = await Broadcast.init({ url: values.url, fingerprint: undefined, namespace: randomNamespace })
|
||||||
|
|
||||||
setTimeout(() => {
|
// setTimeout(() => {
|
||||||
broadcasterOk.value = sub.isSubscribed()
|
// broadcasterOk.value = sub.isSubscribed()
|
||||||
}, 1000);
|
// }, 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,208 +1,208 @@
|
|||||||
import type { Connection, SubscribeRecv } from "@nestri/moq/transport"
|
// import type { Connection, SubscribeRecv } from "@nestri/libmoq/transport"
|
||||||
import { asError } from "@nestri/moq/common/error"
|
// import { asError } from "@nestri/moq/common/error"
|
||||||
import { Client } from "@nestri/moq/transport/client"
|
// import { Client } from "@nestri/moq/transport/client"
|
||||||
import * as Catalog from "@nestri/moq/media/catalog"
|
// import * as Catalog from "@nestri/moq/media/catalog"
|
||||||
import { type GroupWriter } from "@nestri/moq/transport/objects"
|
// import { type GroupWriter } from "@nestri/moq/transport/objects"
|
||||||
|
|
||||||
export interface BroadcastConfig {
|
// export interface BroadcastConfig {
|
||||||
namespace: string
|
// namespace: string
|
||||||
connection: Connection
|
// connection: Connection
|
||||||
}
|
// }
|
||||||
export interface BroadcasterConfig {
|
// export interface BroadcasterConfig {
|
||||||
url: string
|
// url: string
|
||||||
namespace: string
|
// namespace: string
|
||||||
fingerprint?: string // URL to fetch TLS certificate fingerprint
|
// fingerprint?: string // URL to fetch TLS certificate fingerprint
|
||||||
}
|
// }
|
||||||
|
|
||||||
export interface BroadcastConfigTrack {
|
// export interface BroadcastConfigTrack {
|
||||||
input: string
|
// input: string
|
||||||
bitrate: number
|
// bitrate: number
|
||||||
}
|
// }
|
||||||
|
|
||||||
export class Broadcast {
|
// export class Broadcast {
|
||||||
stream: GroupWriter | null
|
// stream: GroupWriter | null
|
||||||
subscriber: SubscribeRecv | null
|
// subscriber: SubscribeRecv | null
|
||||||
subscribed: boolean;
|
// subscribed: boolean;
|
||||||
|
|
||||||
|
|
||||||
readonly config: BroadcastConfig
|
// readonly config: BroadcastConfig
|
||||||
readonly catalog: Catalog.Root
|
// readonly catalog: Catalog.Root
|
||||||
readonly connection: Connection
|
// readonly connection: Connection
|
||||||
readonly namespace: string
|
// readonly namespace: string
|
||||||
|
|
||||||
#running: Promise<void>
|
// #running: Promise<void>
|
||||||
|
|
||||||
constructor(config: BroadcastConfig) {
|
// constructor(config: BroadcastConfig) {
|
||||||
this.subscribed = false
|
// this.subscribed = false
|
||||||
this.namespace = config.namespace
|
// this.namespace = config.namespace
|
||||||
this.connection = config.connection
|
// this.connection = config.connection
|
||||||
this.config = config
|
// this.config = config
|
||||||
//Arbitrary values, just to keep TypeScript happy :)
|
// //Arbitrary values, just to keep TypeScript happy :)
|
||||||
this.catalog = {
|
// this.catalog = {
|
||||||
version: 1,
|
// version: 1,
|
||||||
streamingFormat: 1,
|
// streamingFormat: 1,
|
||||||
streamingFormatVersion: "0.2",
|
// streamingFormatVersion: "0.2",
|
||||||
supportsDeltaUpdates: false,
|
// supportsDeltaUpdates: false,
|
||||||
commonTrackFields: {
|
// commonTrackFields: {
|
||||||
packaging: "loc",
|
// packaging: "loc",
|
||||||
renderGroup: 1,
|
// renderGroup: 1,
|
||||||
},
|
// },
|
||||||
tracks: [{
|
// tracks: [{
|
||||||
name: "tester",
|
// name: "tester",
|
||||||
namespace: "tester",
|
// namespace: "tester",
|
||||||
selectionParams: {}
|
// selectionParams: {}
|
||||||
}],
|
// }],
|
||||||
}
|
// }
|
||||||
this.stream = null
|
// this.stream = null
|
||||||
this.subscriber = null
|
// this.subscriber = null
|
||||||
|
|
||||||
this.#running = this.#run()
|
// this.#running = this.#run()
|
||||||
}
|
// }
|
||||||
|
|
||||||
static async init(config: BroadcasterConfig): Promise<Broadcast> {
|
// static async init(config: BroadcasterConfig): Promise<Broadcast> {
|
||||||
const client = new Client({ url: config.url, fingerprint: config.fingerprint, role: "publisher" })
|
// const client = new Client({ url: config.url, fingerprint: config.fingerprint, role: "publisher" })
|
||||||
const connection = await client.connect();
|
// const connection = await client.connect();
|
||||||
|
|
||||||
return new Broadcast({ connection, namespace: config.namespace })
|
// return new Broadcast({ connection, namespace: config.namespace })
|
||||||
}
|
// }
|
||||||
|
|
||||||
async #run() {
|
// async #run() {
|
||||||
try {
|
// try {
|
||||||
await this.connection.announce(this.namespace)
|
// await this.connection.announce(this.namespace)
|
||||||
this.subscribed = true
|
// this.subscribed = true
|
||||||
} catch (error) {
|
// } catch (error) {
|
||||||
|
|
||||||
this.subscribed = false
|
// this.subscribed = false
|
||||||
}
|
// }
|
||||||
|
|
||||||
for (; ;) {
|
// for (; ;) {
|
||||||
const subscriber = await this.connection.subscribed()
|
// const subscriber = await this.connection.subscribed()
|
||||||
|
|
||||||
if (!subscriber) {
|
// if (!subscriber) {
|
||||||
this.subscribed = false
|
// this.subscribed = false
|
||||||
|
|
||||||
break
|
// break
|
||||||
}
|
// }
|
||||||
|
|
||||||
await subscriber.ack()
|
// await subscriber.ack()
|
||||||
|
|
||||||
this.subscriber = subscriber
|
// this.subscriber = subscriber
|
||||||
|
|
||||||
this.subscribed = true
|
// this.subscribed = true
|
||||||
|
|
||||||
const bytes = Catalog.encode(this.catalog);
|
// const bytes = Catalog.encode(this.catalog);
|
||||||
|
|
||||||
const stream = await subscriber.group({ group: 0 });
|
// const stream = await subscriber.group({ group: 0 });
|
||||||
|
|
||||||
await stream.write({ object: 0, payload: bytes })
|
// await stream.write({ object: 0, payload: bytes })
|
||||||
|
|
||||||
this.stream = stream
|
// this.stream = stream
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
isSubscribed(): boolean {
|
// isSubscribed(): boolean {
|
||||||
return this.subscribed;
|
// return this.subscribed;
|
||||||
}
|
// }
|
||||||
|
|
||||||
// async #serveSubscribe(subscriber: SubscribeRecv) {
|
// // async #serveSubscribe(subscriber: SubscribeRecv) {
|
||||||
// try {
|
// // try {
|
||||||
|
|
||||||
// // Send a SUBSCRIBE_OK
|
// // // Send a SUBSCRIBE_OK
|
||||||
// await subscriber.ack()
|
// // await subscriber.ack()
|
||||||
|
|
||||||
// console.log("catalog track name:", subscriber.track)
|
// // console.log("catalog track name:", subscriber.track)
|
||||||
|
|
||||||
// const stream = await subscriber.group({ group: 0 });
|
// // const stream = await subscriber.group({ group: 0 });
|
||||||
|
|
||||||
// // const bytes = this.catalog.encode("Hello World")
|
// // // const bytes = this.catalog.encode("Hello World")
|
||||||
|
|
||||||
// await stream.write({ object: 0, payload: bytes })
|
// // await stream.write({ object: 0, payload: bytes })
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// } catch (e) {
|
// // } catch (e) {
|
||||||
// const err = asError(e)
|
// // const err = asError(e)
|
||||||
// await subscriber.close(1n, `failed to process publish: ${err.message}`)
|
// // await subscriber.close(1n, `failed to process publish: ${err.message}`)
|
||||||
// } finally {
|
// // } finally {
|
||||||
// // TODO we can't close subscribers because there's no support for clean termination
|
// // // TODO we can't close subscribers because there's no support for clean termination
|
||||||
// // await subscriber.close()
|
// // // await subscriber.close()
|
||||||
// }
|
// // }
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// async mouseUpdatePosition({ x, y }: { x: number, y: number }, stream: GroupWriter) {
|
// // async mouseUpdatePosition({ x, y }: { x: number, y: number }, stream: GroupWriter) {
|
||||||
|
|
||||||
// const mouse_move = {
|
// // const mouse_move = {
|
||||||
// input_type: "mouse_move",
|
// // input_type: "mouse_move",
|
||||||
// delta_y: y,
|
// // delta_y: y,
|
||||||
// delta_x: x,
|
// // delta_x: x,
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// const bytes = Catalog.encode(this.catalog)
|
// // const bytes = Catalog.encode(this.catalog)
|
||||||
|
|
||||||
// await stream.write({ object: 0, payload: bytes });
|
// // await stream.write({ object: 0, payload: bytes });
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// async mouseUpdateButtons(e: MouseEvent, stream: GroupWriter) {
|
// // async mouseUpdateButtons(e: MouseEvent, stream: GroupWriter) {
|
||||||
// const data: { input_type?: "mouse_key_down" | "mouse_key_up"; button: number; } = { button: e.button };
|
// // const data: { input_type?: "mouse_key_down" | "mouse_key_up"; button: number; } = { button: e.button };
|
||||||
|
|
||||||
// if (e.type === "mousedown") {
|
// // if (e.type === "mousedown") {
|
||||||
// data["input_type"] = "mouse_key_down"
|
// // data["input_type"] = "mouse_key_down"
|
||||||
// } else if (e.type === "mouseup") {
|
// // } else if (e.type === "mouseup") {
|
||||||
// data["input_type"] = "mouse_key_up"
|
// // data["input_type"] = "mouse_key_up"
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// const bytes = Catalog.encode(this.catalog)
|
// // const bytes = Catalog.encode(this.catalog)
|
||||||
|
|
||||||
// await stream.write({ object: 0, payload: bytes });
|
// // await stream.write({ object: 0, payload: bytes });
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// async mouseUpdateWheel(e: WheelEvent, stream: GroupWriter) {
|
// // async mouseUpdateWheel(e: WheelEvent, stream: GroupWriter) {
|
||||||
// const data: { input_type?: "mouse_wheel_up" | "mouse_wheel_down" } = {}
|
// // const data: { input_type?: "mouse_wheel_up" | "mouse_wheel_down" } = {}
|
||||||
|
|
||||||
// if (e.deltaY < 0.0) {
|
// // if (e.deltaY < 0.0) {
|
||||||
// data["input_type"] = "mouse_wheel_up"
|
// // data["input_type"] = "mouse_wheel_up"
|
||||||
// } else {
|
// // } else {
|
||||||
// data["input_type"] = "mouse_wheel_down"
|
// // data["input_type"] = "mouse_wheel_down"
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// const bytes = Catalog.encode(this.catalog)
|
// // const bytes = Catalog.encode(this.catalog)
|
||||||
|
|
||||||
// await stream.write({ object: 0, payload: bytes });
|
// // await stream.write({ object: 0, payload: bytes });
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// async updateKeyUp(e: KeyboardEvent, stream: GroupWriter) {
|
// // async updateKeyUp(e: KeyboardEvent, stream: GroupWriter) {
|
||||||
// const data = {
|
// // const data = {
|
||||||
// input_type: "key_up",
|
// // input_type: "key_up",
|
||||||
// key_code: e.keyCode
|
// // key_code: e.keyCode
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// const bytes = Catalog.encode(this.catalog)
|
// // const bytes = Catalog.encode(this.catalog)
|
||||||
|
|
||||||
// await stream.write({ object: 0, payload: bytes });
|
// // await stream.write({ object: 0, payload: bytes });
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// async updateKeyDown(e: KeyboardEvent, stream: GroupWriter) {
|
// // async updateKeyDown(e: KeyboardEvent, stream: GroupWriter) {
|
||||||
// const data = {
|
// // const data = {
|
||||||
// input_type: "key_down",
|
// // input_type: "key_down",
|
||||||
// key_code: e.keyCode
|
// // key_code: e.keyCode
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// const bytes = Catalog.encode(this.catalog)
|
// // const bytes = Catalog.encode(this.catalog)
|
||||||
|
|
||||||
// await stream.write({ object: 0, payload: bytes });
|
// // await stream.write({ object: 0, payload: bytes });
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
close() {
|
// close() {
|
||||||
// TODO implement publish close
|
// // TODO implement publish close
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Returns the error message when the connection is closed
|
// // Returns the error message when the connection is closed
|
||||||
async closed(): Promise<Error> {
|
// async closed(): Promise<Error> {
|
||||||
try {
|
// try {
|
||||||
await this.#running
|
// await this.#running
|
||||||
return new Error("closed") // clean termination
|
// return new Error("closed") // clean termination
|
||||||
} catch (e) {
|
// } catch (e) {
|
||||||
return asError(e)
|
// return asError(e)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
@@ -12,13 +12,39 @@ export default component$(() => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HomeNavBar />
|
<HomeNavBar />
|
||||||
<section class="flex flex-col gap-4 justify-center pt-20 items-center w-full text-left pb-4">
|
{/* <div class="bg-red-500 h-[66px] w-screen"></div> */}
|
||||||
|
{/* <section class="absolute flex mx-auto my-0 inset-[0_0_20%] overflow-hidden -z-[1] before:inset-0 before:absolute before:z-[1] after:absolute after:inset-0 after:[background:linear-gradient(180deg,transparent_60%,#000)] before:[background:linear-gradient(90deg,transparent_85%,#000),linear-gradient(-90deg,transparent_85%,#000)]" >
|
||||||
|
<img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/1172470/library_hero_2x.jpg" height={200} width={300} class="max-w-full min-w-full max-h-full min-h-full object-cover absolute top-0 bottom-0 left-0 right-0 w-0 h-0"/>
|
||||||
|
</section> */}
|
||||||
|
|
||||||
|
<section class="w-full top-[70px] pb-5 ring-gray-300 ring-2 max-w-3xl rounded-xl overflow-hidden relative h-auto shadow-xl">
|
||||||
|
<div class="w-full h-auto relative">
|
||||||
|
<img src="https://media.rawg.io/media/games/511/5118aff5091cb3efec399c808f8c598f.jpg" height={200} width={300} class="w-full aspect-[16/9] object-cover" />
|
||||||
|
<img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/1174180/logo_2x.png?t=1671484934" height={200} width={300} class="w-[40%] aspect-[16/9] absolute bottom-4 left-1/2 -translate-x-1/2 object-cover" />
|
||||||
|
</div>
|
||||||
|
<div class="px-6 pt-2">
|
||||||
|
<div class="flex gap-2 items-center h-max">
|
||||||
|
{/* <img src="https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/1174180/5bf6edd7efb1110b457da905e7ac696c6c619ed1.ico" height={20} width={20} class="size-10 bg-black aspect-square rounded-xl ring-2 ring-gray-700" /> */}
|
||||||
|
<p class="text-2xl font-title font-bold">Red Dead Redemption 2</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/* <section class="w-full before:inset-0 before:absolute before:z-[1] relative after:bg-gradient-to-b after:from-transparent after:from-60% after:to-black before:[background:linear-gradient(90deg,transparent_70%,#000),linear-gradient(-90deg,transparent_70%,#000)]" >
|
||||||
|
<img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/359550/library_hero_2x.jpg" height={200} width={300} class="w-full aspect-[96/31]"/>
|
||||||
|
</section><section class="w-full before:inset-0 before:absolute before:z-[1] relative after:bg-gradient-to-b after:from-transparent after:from-60% after:to-black before:[background:linear-gradient(90deg,transparent_70%,#000),linear-gradient(-90deg,transparent_70%,#000)]" >
|
||||||
|
<img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/359550/library_hero_2x.jpg" height={200} width={300} class="w-full aspect-[96/31]"/>
|
||||||
|
</section><section class="w-full before:inset-0 before:absolute before:z-[1] relative after:bg-gradient-to-b after:from-transparent after:from-60% after:to-black before:[background:linear-gradient(90deg,transparent_70%,#000),linear-gradient(-90deg,transparent_70%,#000)]" >
|
||||||
|
<img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/359550/library_hero_2x.jpg" height={200} width={300} class="w-full aspect-[96/31]"/>
|
||||||
|
</section><section class="w-full before:inset-0 before:absolute before:z-[1] relative after:bg-gradient-to-b after:from-transparent after:from-60% after:to-black before:[background:linear-gradient(90deg,transparent_70%,#000),linear-gradient(-90deg,transparent_70%,#000)]" >
|
||||||
|
<img src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/359550/library_hero_2x.jpg" height={200} width={300} class="w-full aspect-[96/31]"/>
|
||||||
|
</section> */}
|
||||||
|
{/* <section class="flex flex-col gap-4 justify-center pt-20 items-center w-full text-left pb-4">
|
||||||
<div class="flex flex-col gap-4 mx-auto max-w-2xl w-full">
|
<div class="flex flex-col gap-4 mx-auto max-w-2xl w-full">
|
||||||
<h1 class="text-5xl font-bold font-title">{getGreeting()}, <span>Wanjohi</span></h1>
|
<h1 class="text-5xl font-bold font-title">{getGreeting()}, <span>Wanjohi</span></h1>
|
||||||
<p class="dark:text-gray-50/70 text-gray-950/70 text-xl">What will you play today?</p>
|
<p class="dark:text-gray-50/70 text-gray-950/70 text-xl">What will you play today?</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section> */}
|
||||||
<section class="flex flex-col gap-4 justify-center pt-10 items-center w-full text-left pb-4">
|
{/* <section class="flex flex-col gap-4 justify-center pt-10 items-center w-full text-left pb-4">
|
||||||
<ul class="gap-4 relative list-none w-full max-w-xl lg:max-w-4xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 after:pointer-events-none after:select-none after:bg-gradient-to-b after:from-transparent after:dark:to-gray-900 after:to-gray-100 after:fixed after:left-0 after:-bottom-[1px] after:z-10 after:backdrop-blur-sm after:h-[100px] after:w-full after:[-webkit-mask-image:linear-gradient(to_top,theme(colors.primary.100)_50%,transparent)] after:dark:[-webkit-mask-image:linear-gradient(to_top,theme(colors.primary.900)_50%,transparent)]">
|
<ul class="gap-4 relative list-none w-full max-w-xl lg:max-w-4xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 after:pointer-events-none after:select-none after:bg-gradient-to-b after:from-transparent after:dark:to-gray-900 after:to-gray-100 after:fixed after:left-0 after:-bottom-[1px] after:z-10 after:backdrop-blur-sm after:h-[100px] after:w-full after:[-webkit-mask-image:linear-gradient(to_top,theme(colors.primary.100)_50%,transparent)] after:dark:[-webkit-mask-image:linear-gradient(to_top,theme(colors.primary.900)_50%,transparent)]">
|
||||||
<li class="col-span-full">
|
<li class="col-span-full">
|
||||||
<Card
|
<Card
|
||||||
@@ -87,21 +113,7 @@ export default component$(() => {
|
|||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section> */}
|
||||||
<nav class="w-full flex justify-center h-[100px] z-50 items-center gap-4 bg-transparent fixed -bottom-[1px] left-0 right-0">
|
|
||||||
{/* <nav class="flex gap-4 w-max px-4 py-2 rounded-full shadow-2xl shadow-gray-950 bg-neutral-200 text-gray-900 dark:text-gray-100 dark:bg-neutral-800 ring-gray-300 dark:ring-gray-700 ring-1">
|
|
||||||
<button class="text-xl font-title">
|
|
||||||
<span class="material-symbols-outlined">
|
|
||||||
home
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button class="text-xl font-title">
|
|
||||||
<span class="material-symbols-outlined">
|
|
||||||
home
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</nav> */}
|
|
||||||
</nav>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
287
apps/www/src/routes/play/[id]/index.tsx
Normal file
287
apps/www/src/routes/play/[id]/index.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import {useLocation} from "@builder.io/qwik-city";
|
||||||
|
import {Keyboard, Mouse, WebRTCStream} from "@nestri/input"
|
||||||
|
import {component$, useSignal, useVisibleTask$} from "@builder.io/qwik";
|
||||||
|
|
||||||
|
export default component$(() => {
|
||||||
|
const id = useLocation().params.id;
|
||||||
|
const canvas = useSignal<HTMLCanvasElement>();
|
||||||
|
|
||||||
|
useVisibleTask$(({track}) => {
|
||||||
|
track(() => canvas.value);
|
||||||
|
|
||||||
|
if (!canvas.value) return; // Ensure canvas is available
|
||||||
|
|
||||||
|
// Create video element and make it output to canvas (TODO: improve this)
|
||||||
|
let video = document.getElementById("webrtc-video-player");
|
||||||
|
if (!video) {
|
||||||
|
video = document.createElement("video");
|
||||||
|
video.id = "stream-video-player";
|
||||||
|
video.style.visibility = "hidden";
|
||||||
|
const webrtc = new WebRTCStream("https://relay.dathorse.com", id, (mediaStream) => {
|
||||||
|
if (video && mediaStream && (video as HTMLVideoElement).srcObject === null) {
|
||||||
|
console.log("Setting mediastream");
|
||||||
|
(video as HTMLVideoElement).srcObject = mediaStream;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
window.hasstream = true;
|
||||||
|
// @ts-ignore
|
||||||
|
window.roomOfflineElement?.remove();
|
||||||
|
|
||||||
|
const playbtn = document.createElement("button");
|
||||||
|
playbtn.style.position = "absolute";
|
||||||
|
playbtn.style.left = "50%";
|
||||||
|
playbtn.style.top = "50%";
|
||||||
|
playbtn.style.transform = "translateX(-50%) translateY(-50%)";
|
||||||
|
playbtn.style.width = "12rem";
|
||||||
|
playbtn.style.height = "6rem";
|
||||||
|
playbtn.style.borderRadius = "1rem";
|
||||||
|
playbtn.style.backgroundColor = "rgb(175, 50, 50)";
|
||||||
|
playbtn.style.color = "black";
|
||||||
|
playbtn.style.fontSize = "1.5em";
|
||||||
|
playbtn.textContent = "< Start >";
|
||||||
|
|
||||||
|
playbtn.onclick = () => {
|
||||||
|
playbtn.remove();
|
||||||
|
(video as HTMLVideoElement).play().then(() => {
|
||||||
|
if (canvas.value) {
|
||||||
|
canvas.value.width = (video as HTMLVideoElement).videoWidth;
|
||||||
|
canvas.value.height = (video as HTMLVideoElement).videoHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.value.getContext("2d");
|
||||||
|
const renderer = () => {
|
||||||
|
// @ts-ignore
|
||||||
|
if (ctx && window.hasstream) {
|
||||||
|
ctx.drawImage((video as HTMLVideoElement), 0, 0);
|
||||||
|
(video as HTMLVideoElement).requestVideoFrameCallback(renderer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(video as HTMLVideoElement).requestVideoFrameCallback(renderer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("pointerlockchange", () => {
|
||||||
|
if (!canvas.value) return; // Ensure canvas is available
|
||||||
|
// @ts-ignore
|
||||||
|
if (document.pointerLockElement && !window.nestrimouse && !window.nestrikeyboard) {
|
||||||
|
// @ts-ignore
|
||||||
|
window.nestrimouse = new Mouse({canvas: canvas.value, webrtc});
|
||||||
|
// @ts-ignore
|
||||||
|
window.nestrikeyboard = new Keyboard({canvas: canvas.value, webrtc});
|
||||||
|
// @ts-ignore
|
||||||
|
} else if (!document.pointerLockElement && window.nestrimouse && window.nestrikeyboard) {
|
||||||
|
// @ts-ignore
|
||||||
|
window.nestrimouse.dispose();
|
||||||
|
// @ts-ignore
|
||||||
|
window.nestrimouse = undefined;
|
||||||
|
// @ts-ignore
|
||||||
|
window.nestrikeyboard.dispose();
|
||||||
|
// @ts-ignore
|
||||||
|
window.nestrikeyboard = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.body.append(playbtn);
|
||||||
|
} else if (mediaStream === null) {
|
||||||
|
console.log("MediaStream is null, Room is offline");
|
||||||
|
// Add a message to the screen
|
||||||
|
const offline = document.createElement("div");
|
||||||
|
offline.style.position = "absolute";
|
||||||
|
offline.style.left = "50%";
|
||||||
|
offline.style.top = "50%";
|
||||||
|
offline.style.transform = "translateX(-50%) translateY(-50%)";
|
||||||
|
offline.style.width = "auto";
|
||||||
|
offline.style.height = "auto";
|
||||||
|
offline.style.color = "lightgray";
|
||||||
|
offline.style.fontSize = "2em";
|
||||||
|
offline.textContent = "Offline";
|
||||||
|
document.body.append(offline);
|
||||||
|
// @ts-ignore
|
||||||
|
window.roomOfflineElement = offline;
|
||||||
|
// @ts-ignore
|
||||||
|
window.hasstream = false;
|
||||||
|
// Clear canvas if it has been set
|
||||||
|
if (canvas.value) {
|
||||||
|
const ctx = canvas.value.getContext("2d");
|
||||||
|
if (ctx) ctx.clearRect(0, 0, canvas.value.width, canvas.value.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvas}
|
||||||
|
onClick$={async () => {
|
||||||
|
// @ts-ignore
|
||||||
|
if (canvas.value && window.hasstream) {
|
||||||
|
// Do not use - unadjustedMovement: true - breaks input on linux
|
||||||
|
await canvas.value.requestPointerLock();
|
||||||
|
await canvas.value.requestFullscreen()
|
||||||
|
if (document.fullscreenElement !== null) {
|
||||||
|
// @ts-ignore
|
||||||
|
if ('keyboard' in window.navigator && 'lock' in window.navigator.keyboard) {
|
||||||
|
const keys = [
|
||||||
|
"AltLeft",
|
||||||
|
"AltRight",
|
||||||
|
"Tab",
|
||||||
|
"Escape",
|
||||||
|
"ContextMenu",
|
||||||
|
"MetaLeft",
|
||||||
|
"MetaRight"
|
||||||
|
];
|
||||||
|
console.log("requesting keyboard lock");
|
||||||
|
// @ts-ignore
|
||||||
|
window.navigator.keyboard.lock(keys).then(
|
||||||
|
() => {
|
||||||
|
console.log("keyboard lock success");
|
||||||
|
}
|
||||||
|
).catch(
|
||||||
|
(e: any) => {
|
||||||
|
console.log("keyboard lock failed: ", e);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
console.log("keyboard lock not supported, navigator is: ", window.navigator, navigator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
//TODO: go full screen, then lock on "landscape" screen-orientation on mobile
|
||||||
|
class="aspect-video h-full w-full object-contain max-h-screen"/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
{/**
|
||||||
|
.spinningCircleInner_b6db20 {
|
||||||
|
transform: rotate(280deg);
|
||||||
|
}
|
||||||
|
.inner_b6db20 {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
contain: paint;
|
||||||
|
} */
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* <div class="loadingPopout_a8c724" role="dialog" tabindex="-1" aria-modal="true"><div class="spinner_b6db20 spinningCircle_b6db20" role="img" aria-label="Loading"><div class="spinningCircleInner_b6db20 inner_b6db20"><svg class="circular_b6db20" viewBox="25 25 50 50"><circle class="path_b6db20 path3_b6db20" cx="50" cy="50" r="20"></circle><circle class="path_b6db20 path2_b6db20" cx="50" cy="50" r="20"></circle><circle class="path_b6db20" cx="50" cy="50" r="20"></circle></svg></div></div></div> */
|
||||||
|
}
|
||||||
|
// .loadingPopout_a8c724 {
|
||||||
|
// background-color: var(--background-secondary);
|
||||||
|
// display: flex;
|
||||||
|
// justify-content: center;
|
||||||
|
// padding: 8px;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .circular_b6db20 {
|
||||||
|
// animation: spinner-spinning-circle-rotate_b6db20 2s linear infinite;
|
||||||
|
// height: 100%;
|
||||||
|
// width: 100%;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 100% {
|
||||||
|
// transform: rotate(360deg);
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
{/* .path3_b6db20 {
|
||||||
|
animation-delay: .23s;
|
||||||
|
stroke: var(--text-brand);
|
||||||
|
}
|
||||||
|
.path_b6db20 {
|
||||||
|
animation: spinner-spinning-circle-dash_b6db20 2s ease-in-out infinite;
|
||||||
|
stroke-dasharray: 1, 200;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 6;
|
||||||
|
stroke-miterlimit: 10;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke: var(--brand-500);
|
||||||
|
}
|
||||||
|
circle[Attributes Style] {
|
||||||
|
cx: 50;
|
||||||
|
cy: 50;
|
||||||
|
r: 20;
|
||||||
|
}
|
||||||
|
user agent stylesheet
|
||||||
|
:not(svg) {
|
||||||
|
transform-origin: 0px 0px;
|
||||||
|
} */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// .path2_b6db20 {
|
||||||
|
// animation-delay: .15s;
|
||||||
|
// stroke: var(--text-brand);
|
||||||
|
// opacity: .6;
|
||||||
|
// }
|
||||||
|
// .path_b6db20 {
|
||||||
|
// animation: spinner-spinning-circle-dash_b6db20 2s ease-in-out infinite;
|
||||||
|
// stroke-dasharray: 1, 200;
|
||||||
|
// stroke-dashoffset: 0;
|
||||||
|
// fill: none;
|
||||||
|
// stroke-width: 6;
|
||||||
|
// stroke-miterlimit: 10;
|
||||||
|
// stroke-linecap: round;
|
||||||
|
// stroke: var(--brand-500);
|
||||||
|
// }
|
||||||
|
// circle[Attributes Style] {
|
||||||
|
// cx: 50;
|
||||||
|
// cy: 50;
|
||||||
|
// r: 20;
|
||||||
|
|
||||||
|
|
||||||
|
// function throttle(func, limit) {
|
||||||
|
// let inThrottle;
|
||||||
|
// return function(...args) {
|
||||||
|
// if (!inThrottle) {
|
||||||
|
// func.apply(this, args);
|
||||||
|
// inThrottle = true;
|
||||||
|
// setTimeout(() => inThrottle = false, limit);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Use it like this:
|
||||||
|
// const throttledMouseMove = throttle((x, y) => {
|
||||||
|
// websocket.send(JSON.stringify({
|
||||||
|
// type: 'mousemove',
|
||||||
|
// x: x,
|
||||||
|
// y: y
|
||||||
|
// }));
|
||||||
|
// }, 16); // ~60fps
|
||||||
|
|
||||||
|
// use std::time::Instant;
|
||||||
|
|
||||||
|
// // Add these to your AppState
|
||||||
|
// struct AppState {
|
||||||
|
// pipeline: Arc<Mutex<gst::Pipeline>>,
|
||||||
|
// last_mouse_move: Arc<Mutex<(i32, i32, Instant)>>, // Add this
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Then in your MouseMove handler:
|
||||||
|
// InputMessage::MouseMove { x, y } => {
|
||||||
|
// let mut last_move = state.last_mouse_move.lock().unwrap();
|
||||||
|
// let now = Instant::now();
|
||||||
|
|
||||||
|
// // Only process if coordinates are different or enough time has passed
|
||||||
|
// if (last_move.0 != x || last_move.1 != y) &&
|
||||||
|
// (now.duration_since(last_move.2).as_millis() > 16) { // ~60fps
|
||||||
|
|
||||||
|
// println!("Mouse moved to x: {}, y: {}", x, y);
|
||||||
|
|
||||||
|
// let structure = gst::Structure::builder("MouseMoveRelative")
|
||||||
|
// .field("pointer_x", x as f64)
|
||||||
|
// .field("pointer_y", y as f64)
|
||||||
|
// .build();
|
||||||
|
|
||||||
|
// let event = gst::event::CustomUpstream::new(structure);
|
||||||
|
// pipeline.send_event(event);
|
||||||
|
|
||||||
|
// // Update last position and time
|
||||||
|
// *last_move = (x, y, now);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -41,5 +41,5 @@
|
|||||||
"./*.config.ts",
|
"./*.config.ts",
|
||||||
"./*.config.js",
|
"./*.config.js",
|
||||||
"content-collections.ts"
|
"content-collections.ts"
|
||||||
]
|
, "../../packages/input/src/webrtc-stream.ts" ]
|
||||||
}
|
}
|
||||||
1
docker-compose.yml
Normal file
1
docker-compose.yml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#FIXME: A simple docker-compose file for running the MoQ relay and the cachyos server
|
||||||
@@ -43,6 +43,7 @@ module.exports = {
|
|||||||
"prefer-spread": "off",
|
"prefer-spread": "off",
|
||||||
"no-case-declarations": "off",
|
"no-case-declarations": "off",
|
||||||
"no-console": "off",
|
"no-console": "off",
|
||||||
|
"qwik/no-use-visible-task": "off",
|
||||||
"@typescript-eslint/consistent-type-imports": "warn",
|
"@typescript-eslint/consistent-type-imports": "warn",
|
||||||
"@typescript-eslint/no-unnecessary-condition": "warn",
|
"@typescript-eslint/no-unnecessary-condition": "warn",
|
||||||
},
|
},
|
||||||
|
|||||||
9
packages/input/package.json
Normal file
9
packages/input/package.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "@nestri/input",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"sideEffects": false,
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
113
packages/input/src/codes.ts
Normal file
113
packages/input/src/codes.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
export const keyCodeToLinuxEventCode: { [key: string]: number } = {
|
||||||
|
'KeyA': 30,
|
||||||
|
'KeyB': 48,
|
||||||
|
'KeyC': 46,
|
||||||
|
'KeyD': 32,
|
||||||
|
'KeyE': 18,
|
||||||
|
'KeyF': 33,
|
||||||
|
'KeyG': 34,
|
||||||
|
'KeyH': 35,
|
||||||
|
'KeyI': 23,
|
||||||
|
'KeyJ': 36,
|
||||||
|
'KeyK': 37,
|
||||||
|
'KeyL': 38,
|
||||||
|
'KeyM': 50,
|
||||||
|
'KeyN': 49,
|
||||||
|
'KeyO': 24,
|
||||||
|
'KeyP': 25,
|
||||||
|
'KeyQ': 16,
|
||||||
|
'KeyR': 19,
|
||||||
|
'KeyS': 31,
|
||||||
|
'KeyT': 20,
|
||||||
|
'KeyU': 22,
|
||||||
|
'KeyV': 47,
|
||||||
|
'KeyW': 17,
|
||||||
|
'KeyX': 45,
|
||||||
|
'KeyY': 21,
|
||||||
|
'KeyZ': 44,
|
||||||
|
'Digit1': 2,
|
||||||
|
'Digit2': 3,
|
||||||
|
'Digit3': 4,
|
||||||
|
'Digit4': 5,
|
||||||
|
'Digit5': 6,
|
||||||
|
'Digit6': 7,
|
||||||
|
'Digit7': 8,
|
||||||
|
'Digit8': 9,
|
||||||
|
'Digit9': 10,
|
||||||
|
'Digit0': 11,
|
||||||
|
'Enter': 28,
|
||||||
|
'Escape': 1,
|
||||||
|
'Backspace': 14,
|
||||||
|
'Tab': 15,
|
||||||
|
'Space': 57,
|
||||||
|
'Minus': 12,
|
||||||
|
'Equal': 13,
|
||||||
|
'BracketLeft': 26,
|
||||||
|
'BracketRight': 27,
|
||||||
|
'Backslash': 43,
|
||||||
|
'Semicolon': 39,
|
||||||
|
'Quote': 40,
|
||||||
|
'Backquote': 41,
|
||||||
|
'Comma': 51,
|
||||||
|
'Period': 52,
|
||||||
|
'Slash': 53,
|
||||||
|
'CapsLock': 58,
|
||||||
|
'F1': 59,
|
||||||
|
'F2': 60,
|
||||||
|
'F3': 61,
|
||||||
|
'F4': 62,
|
||||||
|
'F5': 63,
|
||||||
|
'F6': 64,
|
||||||
|
'F7': 65,
|
||||||
|
'F8': 66,
|
||||||
|
'F9': 67,
|
||||||
|
'F10': 68,
|
||||||
|
'F11': 87,
|
||||||
|
'F12': 88,
|
||||||
|
'Insert': 110,
|
||||||
|
'Delete': 111,
|
||||||
|
'ArrowUp': 103,
|
||||||
|
'ArrowDown': 108,
|
||||||
|
'ArrowLeft': 105,
|
||||||
|
'ArrowRight': 106,
|
||||||
|
'Home': 102,
|
||||||
|
'End': 107,
|
||||||
|
'PageUp': 104,
|
||||||
|
'PageDown': 109,
|
||||||
|
'NumLock': 69,
|
||||||
|
'ScrollLock': 70,
|
||||||
|
'Pause': 119,
|
||||||
|
'Numpad0': 82,
|
||||||
|
'Numpad1': 79,
|
||||||
|
'Numpad2': 80,
|
||||||
|
'Numpad3': 81,
|
||||||
|
'Numpad4': 75,
|
||||||
|
'Numpad5': 76,
|
||||||
|
'Numpad6': 77,
|
||||||
|
'Numpad7': 71,
|
||||||
|
'Numpad8': 72,
|
||||||
|
'Numpad9': 73,
|
||||||
|
'NumpadDivide': 98,
|
||||||
|
'NumpadMultiply': 55,
|
||||||
|
'NumpadSubtract': 74,
|
||||||
|
'NumpadAdd': 78,
|
||||||
|
'NumpadEnter': 96,
|
||||||
|
'NumpadDecimal': 83,
|
||||||
|
'ControlLeft': 29,
|
||||||
|
'ControlRight': 97,
|
||||||
|
'ShiftLeft': 42,
|
||||||
|
'ShiftRight': 54,
|
||||||
|
'AltLeft': 56,
|
||||||
|
'AltRight': 100,
|
||||||
|
//'MetaLeft': 125, // Disabled as will break input
|
||||||
|
//'MetaRight': 126, // Disabled as will break input
|
||||||
|
'ContextMenu': 127,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mouseButtonToLinuxEventCode: { [button: number]: number } = {
|
||||||
|
0: 272,
|
||||||
|
2: 273,
|
||||||
|
1: 274,
|
||||||
|
3: 275,
|
||||||
|
4: 276
|
||||||
|
};
|
||||||
3
packages/input/src/index.ts
Normal file
3
packages/input/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./keyboard"
|
||||||
|
export * from "./mouse"
|
||||||
|
export * from "./webrtc-stream"
|
||||||
96
packages/input/src/keyboard.ts
Normal file
96
packages/input/src/keyboard.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import {type Input} from "./types"
|
||||||
|
import {keyCodeToLinuxEventCode} from "./codes"
|
||||||
|
import {MessageInput, encodeMessage} from "./messages";
|
||||||
|
import {WebRTCStream} from "./webrtc-stream";
|
||||||
|
import {LatencyTracker} from "./latency";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
webrtc: WebRTCStream;
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Keyboard {
|
||||||
|
protected wrtc: WebRTCStream;
|
||||||
|
protected canvas: HTMLCanvasElement;
|
||||||
|
protected connected!: boolean;
|
||||||
|
|
||||||
|
// Store references to event listeners
|
||||||
|
private keydownListener: (e: KeyboardEvent) => void;
|
||||||
|
private keyupListener: (e: KeyboardEvent) => void;
|
||||||
|
|
||||||
|
constructor({webrtc, canvas}: Props) {
|
||||||
|
this.wrtc = webrtc;
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.keydownListener = this.createKeyboardListener("keydown", (e: any) => ({
|
||||||
|
type: "KeyDown",
|
||||||
|
key: this.keyToVirtualKeyCode(e.code)
|
||||||
|
}));
|
||||||
|
this.keyupListener = this.createKeyboardListener("keyup", (e: any) => ({
|
||||||
|
type: "KeyUp",
|
||||||
|
key: this.keyToVirtualKeyCode(e.code)
|
||||||
|
}));
|
||||||
|
this.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
private run() {
|
||||||
|
//calls all the other functions
|
||||||
|
if (!document.pointerLockElement) {
|
||||||
|
if (this.connected) {
|
||||||
|
this.stop()
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.pointerLockElement == this.canvas) {
|
||||||
|
this.connected = true
|
||||||
|
document.addEventListener("keydown", this.keydownListener, {passive: false});
|
||||||
|
document.addEventListener("keyup", this.keyupListener, {passive: false});
|
||||||
|
} else {
|
||||||
|
if (this.connected) {
|
||||||
|
this.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stop() {
|
||||||
|
document.removeEventListener("keydown", this.keydownListener);
|
||||||
|
document.removeEventListener("keyup", this.keyupListener);
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create and return mouse listeners
|
||||||
|
private createKeyboardListener(type: string, dataCreator: (e: Event) => Partial<Input>): (e: Event) => void {
|
||||||
|
return (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
// Prevent repeated key events from being sent (important for games)
|
||||||
|
if ((e as any).repeat)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const data = dataCreator(e as any); // type assertion because of the way dataCreator is used
|
||||||
|
const dataString = JSON.stringify({...data, type} as Input);
|
||||||
|
|
||||||
|
// Latency tracking
|
||||||
|
const tracker = new LatencyTracker("input-keyboard");
|
||||||
|
tracker.addTimestamp("client_send");
|
||||||
|
const message: MessageInput = {
|
||||||
|
payload_type: "input",
|
||||||
|
data: dataString,
|
||||||
|
latency: tracker,
|
||||||
|
};
|
||||||
|
this.wrtc.sendBinary(encodeMessage(message));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose() {
|
||||||
|
document.exitPointerLock();
|
||||||
|
this.stop();
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private keyToVirtualKeyCode(code: string) {
|
||||||
|
// Treat Home key as Escape - TODO: Make user-configurable
|
||||||
|
if (code === "Home") return 1;
|
||||||
|
return keyCodeToLinuxEventCode[code] || undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
packages/input/src/latency.ts
Normal file
54
packages/input/src/latency.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
type TimestampEntry = {
|
||||||
|
stage: string;
|
||||||
|
time: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class LatencyTracker {
|
||||||
|
sequence_id: string;
|
||||||
|
timestamps: TimestampEntry[];
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
|
||||||
|
constructor(sequence_id: string, timestamps: TimestampEntry[] = [], metadata: Record<string, any> = {}) {
|
||||||
|
this.sequence_id = sequence_id;
|
||||||
|
this.timestamps = timestamps;
|
||||||
|
this.metadata = metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
addTimestamp(stage: string): void {
|
||||||
|
const timestamp: TimestampEntry = {
|
||||||
|
stage,
|
||||||
|
time: new Date(),
|
||||||
|
};
|
||||||
|
this.timestamps.push(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculates the total time between the first and last recorded timestamps.
|
||||||
|
getTotalLatency(): number {
|
||||||
|
if (this.timestamps.length < 2) return 0;
|
||||||
|
|
||||||
|
const times = this.timestamps.map((entry) => entry.time.getTime());
|
||||||
|
const minTime = Math.min(...times);
|
||||||
|
const maxTime = Math.max(...times);
|
||||||
|
return maxTime - minTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON(): Record<string, any> {
|
||||||
|
return {
|
||||||
|
sequence_id: this.sequence_id,
|
||||||
|
timestamps: this.timestamps.map((entry) => ({
|
||||||
|
stage: entry.stage,
|
||||||
|
// Fill nanoseconds with zeros to match the expected format
|
||||||
|
time: entry.time.toISOString().replace(/\.(\d+)Z$/, ".$1000000Z"),
|
||||||
|
})),
|
||||||
|
metadata: this.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: any): LatencyTracker {
|
||||||
|
const timestamps: TimestampEntry[] = json.timestamps.map((ts: any) => ({
|
||||||
|
stage: ts.stage,
|
||||||
|
time: new Date(ts.time),
|
||||||
|
}));
|
||||||
|
return new LatencyTracker(json.sequence_id, timestamps, json.metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
packages/input/src/messages.ts
Normal file
73
packages/input/src/messages.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import {gzip, ungzip} from "pako";
|
||||||
|
import {LatencyTracker} from "./latency";
|
||||||
|
|
||||||
|
export interface MessageBase {
|
||||||
|
payload_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageInput extends MessageBase {
|
||||||
|
payload_type: "input";
|
||||||
|
data: string;
|
||||||
|
latency?: LatencyTracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageICE extends MessageBase {
|
||||||
|
payload_type: "ice";
|
||||||
|
candidate: RTCIceCandidateInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageSDP extends MessageBase {
|
||||||
|
payload_type: "sdp";
|
||||||
|
sdp: RTCSessionDescriptionInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum JoinerType {
|
||||||
|
JoinerNode = 0,
|
||||||
|
JoinerClient = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageJoin extends MessageBase {
|
||||||
|
payload_type: "join";
|
||||||
|
joiner_type: JoinerType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AnswerType {
|
||||||
|
AnswerOffline = 0,
|
||||||
|
AnswerInUse,
|
||||||
|
AnswerOK
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageAnswer extends MessageBase {
|
||||||
|
payload_type: "answer";
|
||||||
|
answer_type: AnswerType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blobToUint8Array(blob: Blob): Promise<Uint8Array> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
const arrayBuffer = reader.result as ArrayBuffer;
|
||||||
|
resolve(new Uint8Array(arrayBuffer));
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsArrayBuffer(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeMessage<T>(message: T): Uint8Array {
|
||||||
|
// Convert the message to JSON string
|
||||||
|
const json = JSON.stringify(message);
|
||||||
|
// Compress the JSON string using gzip
|
||||||
|
return gzip(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decodeMessage<T>(data: Blob): Promise<T> {
|
||||||
|
// Convert the Blob to Uint8Array
|
||||||
|
const array = await blobToUint8Array(data);
|
||||||
|
// Decompress the gzip data
|
||||||
|
const decompressed = ungzip(array);
|
||||||
|
// Convert the Uint8Array to JSON string
|
||||||
|
const json = new TextDecoder().decode(decompressed);
|
||||||
|
// Parse the JSON string
|
||||||
|
return JSON.parse(json);
|
||||||
|
}
|
||||||
112
packages/input/src/mouse.ts
Normal file
112
packages/input/src/mouse.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import {type Input} from "./types"
|
||||||
|
import {mouseButtonToLinuxEventCode} from "./codes"
|
||||||
|
import {MessageInput, encodeMessage} from "./messages";
|
||||||
|
import {WebRTCStream} from "./webrtc-stream";
|
||||||
|
import {LatencyTracker} from "./latency";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
webrtc: WebRTCStream;
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Mouse {
|
||||||
|
protected wrtc: WebRTCStream;
|
||||||
|
protected canvas: HTMLCanvasElement;
|
||||||
|
protected connected!: boolean;
|
||||||
|
|
||||||
|
// Store references to event listeners
|
||||||
|
private mousemoveListener: (e: MouseEvent) => void;
|
||||||
|
private mousedownListener: (e: MouseEvent) => void;
|
||||||
|
private mouseupListener: (e: MouseEvent) => void;
|
||||||
|
private mousewheelListener: (e: WheelEvent) => void;
|
||||||
|
|
||||||
|
constructor({webrtc, canvas}: Props) {
|
||||||
|
this.wrtc = webrtc;
|
||||||
|
this.canvas = canvas;
|
||||||
|
|
||||||
|
this.mousemoveListener = this.createMouseListener("mousemove", (e: any) => ({
|
||||||
|
type: "MouseMove",
|
||||||
|
x: e.movementX,
|
||||||
|
y: e.movementY
|
||||||
|
}));
|
||||||
|
this.mousedownListener = this.createMouseListener("mousedown", (e: any) => ({
|
||||||
|
type: "MouseKeyDown",
|
||||||
|
key: this.keyToVirtualKeyCode(e.button)
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.mouseupListener = this.createMouseListener("mouseup", (e: any) => ({
|
||||||
|
type: "MouseKeyUp",
|
||||||
|
key: this.keyToVirtualKeyCode(e.button)
|
||||||
|
}));
|
||||||
|
this.mousewheelListener = this.createMouseListener("wheel", (e: any) => ({
|
||||||
|
type: "MouseWheel",
|
||||||
|
x: e.deltaX,
|
||||||
|
y: e.deltaY
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
private run() {
|
||||||
|
//calls all the other functions
|
||||||
|
if (!document.pointerLockElement) {
|
||||||
|
console.log("no pointerlock")
|
||||||
|
if (this.connected) {
|
||||||
|
this.stop()
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.pointerLockElement == this.canvas) {
|
||||||
|
this.connected = true
|
||||||
|
this.canvas.addEventListener("mousemove", this.mousemoveListener, { passive: false });
|
||||||
|
this.canvas.addEventListener("mousedown", this.mousedownListener, { passive: false });
|
||||||
|
this.canvas.addEventListener("mouseup", this.mouseupListener, { passive: false });
|
||||||
|
this.canvas.addEventListener("wheel", this.mousewheelListener, { passive: false });
|
||||||
|
|
||||||
|
} else {
|
||||||
|
if (this.connected) {
|
||||||
|
this.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private stop() {
|
||||||
|
this.canvas.removeEventListener("mousemove", this.mousemoveListener);
|
||||||
|
this.canvas.removeEventListener("mousedown", this.mousedownListener);
|
||||||
|
this.canvas.removeEventListener("mouseup", this.mouseupListener);
|
||||||
|
this.canvas.removeEventListener("wheel", this.mousewheelListener);
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create and return mouse listeners
|
||||||
|
private createMouseListener(type: string, dataCreator: (e: Event) => Partial<Input>): (e: Event) => void {
|
||||||
|
return (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const data = dataCreator(e as any); // type assertion because of the way dataCreator is used
|
||||||
|
const dataString = JSON.stringify({...data, type} as Input);
|
||||||
|
|
||||||
|
// Latency tracking
|
||||||
|
const tracker = new LatencyTracker("input-mouse");
|
||||||
|
tracker.addTimestamp("client_send");
|
||||||
|
const message: MessageInput = {
|
||||||
|
payload_type: "input",
|
||||||
|
data: dataString,
|
||||||
|
latency: tracker,
|
||||||
|
};
|
||||||
|
this.wrtc.sendBinary(encodeMessage(message));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose() {
|
||||||
|
document.exitPointerLock();
|
||||||
|
this.stop();
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private keyToVirtualKeyCode(code: number) {
|
||||||
|
return mouseButtonToLinuxEventCode[code] || undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
packages/input/src/types.ts
Normal file
52
packages/input/src/types.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
interface BaseInput {
|
||||||
|
timestamp?: number; // Add a timestamp for better context (optional)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MouseMove extends BaseInput {
|
||||||
|
type: "MouseMove";
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MouseMoveAbs extends BaseInput {
|
||||||
|
type: "MouseMoveAbs";
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MouseWheel extends BaseInput {
|
||||||
|
type: "MouseWheel";
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MouseKeyDown extends BaseInput {
|
||||||
|
type: "MouseKeyDown";
|
||||||
|
key: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MouseKeyUp extends BaseInput {
|
||||||
|
type: "MouseKeyUp";
|
||||||
|
key: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeyDown extends BaseInput {
|
||||||
|
type: "KeyDown";
|
||||||
|
key: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeyUp extends BaseInput {
|
||||||
|
type: "KeyUp";
|
||||||
|
key: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type Input =
|
||||||
|
| MouseMove
|
||||||
|
| MouseMoveAbs
|
||||||
|
| MouseWheel
|
||||||
|
| MouseKeyDown
|
||||||
|
| MouseKeyUp
|
||||||
|
| KeyDown
|
||||||
|
| KeyUp;
|
||||||
|
|
||||||
166
packages/input/src/webrtc-stream.ts
Normal file
166
packages/input/src/webrtc-stream.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import {
|
||||||
|
MessageBase,
|
||||||
|
MessageICE,
|
||||||
|
MessageJoin,
|
||||||
|
MessageSDP,
|
||||||
|
MessageAnswer,
|
||||||
|
JoinerType,
|
||||||
|
AnswerType,
|
||||||
|
decodeMessage,
|
||||||
|
encodeMessage
|
||||||
|
} from "./messages";
|
||||||
|
|
||||||
|
export class WebRTCStream {
|
||||||
|
private _ws: WebSocket | undefined = undefined;
|
||||||
|
private _pc: RTCPeerConnection | undefined = undefined;
|
||||||
|
private _mediaStream: MediaStream | undefined = undefined;
|
||||||
|
private _dataChannel: RTCDataChannel | undefined = undefined;
|
||||||
|
private _onConnected: ((stream: MediaStream | null) => void) | undefined = undefined;
|
||||||
|
|
||||||
|
constructor(serverURL: string, roomName: string, connectedCallback: (stream: MediaStream | null) => void) {
|
||||||
|
// If roomName is not provided, return
|
||||||
|
if (roomName.length <= 0) {
|
||||||
|
console.error("Room name not provided");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._onConnected = connectedCallback;
|
||||||
|
|
||||||
|
console.log("Setting up WebSocket");
|
||||||
|
// Replace http/https with ws/wss
|
||||||
|
const wsURL = serverURL.replace(/^http/, "ws");
|
||||||
|
this._ws = new WebSocket(`${wsURL}/api/ws/${roomName}`);
|
||||||
|
this._ws.onopen = async () => {
|
||||||
|
console.log("WebSocket opened");
|
||||||
|
|
||||||
|
console.log("Setting up PeerConnection");
|
||||||
|
this._pc = new RTCPeerConnection({
|
||||||
|
iceServers: [
|
||||||
|
{
|
||||||
|
urls: "stun:stun.l.google.com:19302"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
this._pc.ontrack = (e) => {
|
||||||
|
console.log("Track received: ", e.track);
|
||||||
|
this._mediaStream = e.streams[e.streams.length - 1];
|
||||||
|
};
|
||||||
|
|
||||||
|
this._pc.onconnectionstatechange = () => {
|
||||||
|
console.log("Connection state: ", this._pc!.connectionState);
|
||||||
|
if (this._pc!.connectionState === "connected") {
|
||||||
|
if (this._onConnected && this._mediaStream)
|
||||||
|
this._onConnected(this._mediaStream);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._pc.onicecandidate = (e) => {
|
||||||
|
if (e.candidate) {
|
||||||
|
const message: MessageICE = {
|
||||||
|
payload_type: "ice",
|
||||||
|
candidate: e.candidate
|
||||||
|
};
|
||||||
|
this._ws!.send(encodeMessage(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._pc.ondatachannel = (e) => {
|
||||||
|
this._dataChannel = e.channel;
|
||||||
|
this._setupDataChannelEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send join message
|
||||||
|
const joinMessage: MessageJoin = {
|
||||||
|
payload_type: "join",
|
||||||
|
joiner_type: JoinerType.JoinerClient
|
||||||
|
};
|
||||||
|
this._ws!.send(encodeMessage(joinMessage));
|
||||||
|
}
|
||||||
|
|
||||||
|
let iceHolder: RTCIceCandidateInit[] = [];
|
||||||
|
|
||||||
|
this._ws.onmessage = async (e) => {
|
||||||
|
// allow only binary
|
||||||
|
if (typeof e.data !== "object") return;
|
||||||
|
if (!e.data) return;
|
||||||
|
const message = await decodeMessage<MessageBase>(e.data);
|
||||||
|
switch (message.payload_type) {
|
||||||
|
case "sdp":
|
||||||
|
await this._pc!.setRemoteDescription((message as MessageSDP).sdp);
|
||||||
|
// Create our answer
|
||||||
|
const answer = await this._pc!.createAnswer();
|
||||||
|
// Force stereo in Chromium browsers
|
||||||
|
answer.sdp = this.forceOpusStereo(answer.sdp!);
|
||||||
|
await this._pc!.setLocalDescription(answer);
|
||||||
|
this._ws!.send(encodeMessage({
|
||||||
|
payload_type: "sdp",
|
||||||
|
sdp: answer
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
case "ice":
|
||||||
|
// If remote description is not set yet, hold the ICE candidates
|
||||||
|
if (this._pc!.remoteDescription) {
|
||||||
|
await this._pc!.addIceCandidate((message as MessageICE).candidate);
|
||||||
|
// Add held ICE candidates
|
||||||
|
for (const ice of iceHolder) {
|
||||||
|
await this._pc!.addIceCandidate(ice);
|
||||||
|
}
|
||||||
|
iceHolder = [];
|
||||||
|
} else {
|
||||||
|
iceHolder.push((message as MessageICE).candidate);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "answer":
|
||||||
|
switch ((message as MessageAnswer).answer_type) {
|
||||||
|
case AnswerType.AnswerOffline:
|
||||||
|
console.log("Room is offline");
|
||||||
|
// Call callback with null stream
|
||||||
|
if (this._onConnected)
|
||||||
|
this._onConnected(null);
|
||||||
|
|
||||||
|
break;
|
||||||
|
case AnswerType.AnswerInUse:
|
||||||
|
console.warn("Room is in use, we shouldn't even be getting this message");
|
||||||
|
break;
|
||||||
|
case AnswerType.AnswerOK:
|
||||||
|
console.log("Joining Room was successful");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error("Unknown message type: ", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._ws.onclose = () => {
|
||||||
|
console.log("WebSocket closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
this._ws.onerror = (e) => {
|
||||||
|
console.error("WebSocket error: ", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forces opus to stereo in Chromium browsers, because of course
|
||||||
|
private forceOpusStereo(SDP: string): string {
|
||||||
|
// Look for "minptime=10;useinbandfec=1" and replace with "minptime=10;useinbandfec=1;stereo=1;sprop-stereo=1;"
|
||||||
|
return SDP.replace(/(minptime=10;useinbandfec=1)/, "$1;stereo=1;sprop-stereo=1;");
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setupDataChannelEvents() {
|
||||||
|
if (!this._dataChannel) return;
|
||||||
|
|
||||||
|
this._dataChannel.onclose = () => console.log('sendChannel has closed')
|
||||||
|
this._dataChannel.onopen = () => console.log('sendChannel has opened')
|
||||||
|
this._dataChannel.onmessage = e => console.log(`Message from DataChannel '${this._dataChannel?.label}' payload '${e.data}'`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send binary message through the data channel
|
||||||
|
public sendBinary(data: Uint8Array) {
|
||||||
|
if (this._dataChannel && this._dataChannel.readyState === "open")
|
||||||
|
this._dataChannel.send(data);
|
||||||
|
else
|
||||||
|
console.log("Data channel not open or not established.");
|
||||||
|
}
|
||||||
|
}
|
||||||
32
packages/master/go.mod
Normal file
32
packages/master/go.mod
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
module master
|
||||||
|
|
||||||
|
go 1.23.3
|
||||||
|
|
||||||
|
require github.com/docker/docker v27.3.1+incompatible
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/Microsoft/go-winio v0.4.14 // indirect
|
||||||
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
|
github.com/docker/go-connections v0.5.0 // indirect
|
||||||
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
|
github.com/moby/term v0.5.0 // indirect
|
||||||
|
github.com/morikuni/aec v1.0.0 // indirect
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.32.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.32.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk v1.32.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.32.0 // indirect
|
||||||
|
golang.org/x/sys v0.27.0 // indirect
|
||||||
|
golang.org/x/time v0.8.0 // indirect
|
||||||
|
gotest.tools/v3 v3.5.1 // indirect
|
||||||
|
)
|
||||||
123
packages/master/go.sum
Normal file
123
packages/master/go.sum
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
|
||||||
|
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||||
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||||
|
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN9coASF1GusYX6AlU=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94=
|
||||||
|
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
|
||||||
|
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 h1:IJFEoHiytixx8cMiVAO+GmHR6Frwu+u5Ur8njpFO6Ac=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0/go.mod h1:3rHrKNtLIoS0oZwkY2vxi+oJcwFRWdtUyRII+so45p8=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI=
|
||||||
|
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
|
||||||
|
go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
|
||||||
|
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
|
||||||
|
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||||
|
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||||
|
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||||
|
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||||
|
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||||
|
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:dguCy7UOdZhTvLzDyt15+rOrawrpM4q7DD9dQ1P11P4=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 h1:XVhgTWWV3kGQlwJHR3upFWZeTsei6Oks1apkZSeonIE=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
|
||||||
|
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
||||||
|
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
||||||
|
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
||||||
|
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
||||||
|
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
||||||
80
packages/master/main.go
Normal file
80
packages/master/main.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/docker/docker/pkg/stdcopy"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
// Try to get the Docker version
|
||||||
|
_, err = cli.ServerVersion(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// If an error occurs (e.g., Docker is not running), return false
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the image
|
||||||
|
containerName := "hello-world"
|
||||||
|
|
||||||
|
reader, err := cli.ImagePull(ctx, containerName, image.PullOptions{})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// cli.ImagePull is asynchronous.
|
||||||
|
// The reader needs to be read completely for the pull operation to complete.
|
||||||
|
// If stdout is not required, consider using io.Discard instead of os.Stdout.
|
||||||
|
io.Copy(os.Stdout, reader)
|
||||||
|
|
||||||
|
resp, err := cli.ContainerCreate(ctx, &container.Config{
|
||||||
|
Image: "hello-world",
|
||||||
|
},
|
||||||
|
nil, nil, nil, containerName)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the container
|
||||||
|
if err := cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the container to finish and get its logs
|
||||||
|
statusCh, errCh := cli.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning)
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
case <-statusCh:
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := cli.ContainerLogs(ctx, resp.ID, container.LogsOptions{ShowStdout: true})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdcopy.StdCopy(os.Stdout, os.Stderr, out)
|
||||||
|
|
||||||
|
// Remove the container
|
||||||
|
if err := cli.ContainerRemove(ctx, resp.ID, container.RemoveOptions{}); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ module.exports = {
|
|||||||
"prettier",
|
"prettier",
|
||||||
],
|
],
|
||||||
parser: "@typescript-eslint/parser",
|
parser: "@typescript-eslint/parser",
|
||||||
plugins: ["@typescript-eslint", "prettier"],
|
plugins: ["@typescript-eslint", "prettier", "solid"],
|
||||||
root: true,
|
root: true,
|
||||||
env: {
|
env: {
|
||||||
browser: true,
|
browser: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export class Deferred<T> {
|
export class Deferred<T> {
|
||||||
promise: Promise<T>
|
promise: Promise<T>
|
||||||
resolve!: (value: T | PromiseLike<T>) => void
|
resolve!: (value: T | PromiseLike<T>) => void
|
||||||
reject!: (reason: any) => void
|
reject!: (reason: unknown) => void
|
||||||
pending = true
|
pending = true
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -35,16 +35,19 @@ export class Watch<T> {
|
|||||||
|
|
||||||
update(v: T | ((v: T) => T)) {
|
update(v: T | ((v: T) => T)) {
|
||||||
if (!this.#next.pending) {
|
if (!this.#next.pending) {
|
||||||
throw new Error("already closed")
|
throw new Error("closed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're given a function, call it with the current value
|
// If we're given a function, call it with the current value
|
||||||
|
let value: T
|
||||||
if (v instanceof Function) {
|
if (v instanceof Function) {
|
||||||
v = v(this.#current[0])
|
value = v(this.#current[0])
|
||||||
|
} else {
|
||||||
|
value = v
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = new Deferred<WatchNext<T>>()
|
const next = new Deferred<WatchNext<T>>()
|
||||||
this.#current = [v, next.promise]
|
this.#current = [value, next.promise]
|
||||||
this.#next.resolve(this.#current)
|
this.#next.resolve(this.#current)
|
||||||
this.#next = next
|
this.#next = next
|
||||||
}
|
}
|
||||||
@@ -53,6 +56,10 @@ export class Watch<T> {
|
|||||||
this.#current[1] = undefined
|
this.#current[1] = undefined
|
||||||
this.#next.resolve(this.#current)
|
this.#next.resolve(this.#current)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
closed() {
|
||||||
|
return !this.#next.pending
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wakes up a multiple consumers.
|
// Wakes up a multiple consumers.
|
||||||
@@ -88,6 +95,7 @@ export class Queue<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async push(v: T) {
|
async push(v: T) {
|
||||||
|
if (this.#closed) throw new Error("closed")
|
||||||
const w = this.#stream.writable.getWriter()
|
const w = this.#stream.writable.getWriter()
|
||||||
await w.write(v)
|
await w.write(v)
|
||||||
w.releaseLock()
|
w.releaseLock()
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
// I hate javascript
|
// I hate javascript
|
||||||
export function asError(e: any): Error {
|
export function asError(e: unknown): Error {
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
return e
|
return e
|
||||||
} else if (typeof e === "string") {
|
|
||||||
return new Error(e)
|
|
||||||
} else {
|
|
||||||
return new Error(String(e))
|
|
||||||
}
|
}
|
||||||
|
if (typeof e === "string") {
|
||||||
|
return new Error(e)
|
||||||
|
}
|
||||||
|
return new Error(String(e))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isError(e: any): e is Error {
|
export function isError(e: unknown): e is Error {
|
||||||
return e instanceof Error
|
return e instanceof Error
|
||||||
}
|
}
|
||||||
|
|||||||
11
packages/moq/common/hex.ts
Normal file
11
packages/moq/common/hex.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export function decode(str: string): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(str.length / 2)
|
||||||
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
|
bytes[i] = Number.parseInt(str.slice(2 * i, 2 * i + 2), 16)
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encode(_bytes: Uint8Array): string {
|
||||||
|
throw "todo"
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
enum STATE {
|
enum STATE {
|
||||||
READ_POS = 0, // The current read position
|
READ_POS = 0, // The current read position
|
||||||
WRITE_POS, // The current write position
|
WRITE_POS = 1, // The current write position
|
||||||
LENGTH, // Clever way of saving the total number of enums values.
|
LENGTH = 2, // Clever way of saving the total number of enums values.
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FrameCopyToOptions {
|
interface FrameCopyToOptions {
|
||||||
@@ -62,16 +62,12 @@ export class Ring {
|
|||||||
const readPos = Atomics.load(this.state, STATE.READ_POS)
|
const readPos = Atomics.load(this.state, STATE.READ_POS)
|
||||||
const writePos = Atomics.load(this.state, STATE.WRITE_POS)
|
const writePos = Atomics.load(this.state, STATE.WRITE_POS)
|
||||||
|
|
||||||
const startPos = writePos
|
const available = this.capacity - (writePos - readPos)
|
||||||
let endPos = writePos + frame.numberOfFrames
|
if (available <= 0) return 0
|
||||||
|
|
||||||
if (endPos > readPos + this.capacity) {
|
const toWrite = Math.min(frame.numberOfFrames, available)
|
||||||
endPos = readPos + this.capacity
|
const startPos = writePos
|
||||||
if (endPos <= startPos) {
|
const endPos = writePos + toWrite
|
||||||
// No space to write
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const startIndex = startPos % this.capacity
|
const startIndex = startPos % this.capacity
|
||||||
const endIndex = endPos % this.capacity
|
const endIndex = endPos % this.capacity
|
||||||
@@ -114,7 +110,7 @@ export class Ring {
|
|||||||
|
|
||||||
Atomics.store(this.state, STATE.WRITE_POS, endPos)
|
Atomics.store(this.state, STATE.WRITE_POS, endPos)
|
||||||
|
|
||||||
return endPos - startPos
|
return toWrite
|
||||||
}
|
}
|
||||||
|
|
||||||
read(dst: Float32Array[]): number {
|
read(dst: Float32Array[]): number {
|
||||||
|
|||||||
@@ -1,15 +1,67 @@
|
|||||||
|
import { Deferred } from "../common/async"
|
||||||
|
import type { Frame } from "../karp/frame"
|
||||||
|
import type { Group, Track } from "../transfork"
|
||||||
|
import { Closed } from "../transfork/error"
|
||||||
|
|
||||||
const SUPPORTED = [
|
const SUPPORTED = [
|
||||||
// TODO support AAC
|
// TODO support AAC
|
||||||
// "mp4a"
|
// "mp4a"
|
||||||
"Opus",
|
"Opus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export class Packer {
|
||||||
|
#source: MediaStreamTrackProcessor<AudioData>
|
||||||
|
#encoder: Encoder
|
||||||
|
|
||||||
|
#data: Track
|
||||||
|
#current?: Group
|
||||||
|
|
||||||
|
constructor(track: MediaStreamAudioTrack, encoder: Encoder, data: Track) {
|
||||||
|
this.#source = new MediaStreamTrackProcessor({ track })
|
||||||
|
this.#encoder = encoder
|
||||||
|
this.#data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
const output = new WritableStream({
|
||||||
|
write: (chunk) => this.#write(chunk),
|
||||||
|
close: () => this.#close(),
|
||||||
|
abort: (e) => this.#close(e),
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.#source.readable.pipeThrough(this.#encoder.frames).pipeTo(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
#write(frame: Frame) {
|
||||||
|
// TODO use a fixed interval instead of keyframes (audio)
|
||||||
|
// TODO actually just align with video
|
||||||
|
if (!this.#current || frame.type === "key") {
|
||||||
|
if (this.#current) {
|
||||||
|
this.#current.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#current = this.#data.appendGroup()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#current.writeFrame(frame.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
#close(err?: unknown) {
|
||||||
|
const closed = Closed.from(err)
|
||||||
|
if (this.#current) {
|
||||||
|
this.#current.close(closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#data.close(closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Encoder {
|
export class Encoder {
|
||||||
#encoder!: AudioEncoder
|
#encoder!: AudioEncoder
|
||||||
#encoderConfig: AudioEncoderConfig
|
#encoderConfig: AudioEncoderConfig
|
||||||
#decoderConfig?: AudioDecoderConfig
|
#decoderConfig = new Deferred<AudioDecoderConfig>()
|
||||||
|
|
||||||
frames: TransformStream<AudioData, AudioDecoderConfig | EncodedAudioChunk>
|
frames: TransformStream<AudioData, EncodedAudioChunk>
|
||||||
|
|
||||||
constructor(config: AudioEncoderConfig) {
|
constructor(config: AudioEncoderConfig) {
|
||||||
this.#encoderConfig = config
|
this.#encoderConfig = config
|
||||||
@@ -21,7 +73,7 @@ export class Encoder {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#start(controller: TransformStreamDefaultController<AudioDecoderConfig | EncodedAudioChunk>) {
|
#start(controller: TransformStreamDefaultController<EncodedAudioChunk>) {
|
||||||
this.#encoder = new AudioEncoder({
|
this.#encoder = new AudioEncoder({
|
||||||
output: (frame, metadata) => {
|
output: (frame, metadata) => {
|
||||||
this.#enqueue(controller, frame, metadata)
|
this.#enqueue(controller, frame, metadata)
|
||||||
@@ -40,17 +92,16 @@ export class Encoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#enqueue(
|
#enqueue(
|
||||||
controller: TransformStreamDefaultController<AudioDecoderConfig | EncodedAudioChunk>,
|
controller: TransformStreamDefaultController<EncodedAudioChunk>,
|
||||||
frame: EncodedAudioChunk,
|
frame: EncodedAudioChunk,
|
||||||
metadata?: EncodedAudioChunkMetadata,
|
metadata?: EncodedAudioChunkMetadata,
|
||||||
) {
|
) {
|
||||||
const config = metadata?.decoderConfig
|
const config = metadata?.decoderConfig
|
||||||
if (config && !this.#decoderConfig) {
|
if (config && !this.#decoderConfig.pending) {
|
||||||
const config = metadata.decoderConfig
|
const config = metadata.decoderConfig
|
||||||
if (!config) throw new Error("missing decoder config")
|
if (!config) throw new Error("missing decoder config")
|
||||||
|
|
||||||
controller.enqueue(config)
|
this.#decoderConfig.resolve(config)
|
||||||
this.#decoderConfig = config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
controller.enqueue(frame)
|
controller.enqueue(frame)
|
||||||
@@ -72,4 +123,8 @@ export class Encoder {
|
|||||||
get config() {
|
get config() {
|
||||||
return this.#encoderConfig
|
return this.#encoderConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async decoderConfig(): Promise<AudioDecoderConfig> {
|
||||||
|
return await this.#decoderConfig.promise
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { Connection, SubscribeRecv } from "../transport"
|
import * as Catalog from "../karp/catalog"
|
||||||
import { asError } from "../common/error"
|
import * as Transfork from "../transfork"
|
||||||
import { Segment } from "./segment"
|
import * as Audio from "./audio"
|
||||||
import { Track } from "./track"
|
import * as Video from "./video"
|
||||||
import * as Catalog from "../media/catalog"
|
|
||||||
|
|
||||||
import { isAudioTrackSettings, isVideoTrackSettings } from "../common/settings"
|
import { isAudioTrackSettings, isVideoTrackSettings } from "../common/settings"
|
||||||
|
|
||||||
export interface BroadcastConfig {
|
export interface BroadcastConfig {
|
||||||
namespace: string
|
path: string[]
|
||||||
connection: Connection
|
|
||||||
media: MediaStream
|
media: MediaStream
|
||||||
|
id?: number
|
||||||
|
|
||||||
audio?: AudioEncoderConfig
|
audio?: AudioEncoderConfig
|
||||||
video?: VideoEncoderConfig
|
video?: VideoEncoderConfig
|
||||||
@@ -21,221 +20,89 @@ export interface BroadcastConfigTrack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class Broadcast {
|
export class Broadcast {
|
||||||
#tracks = new Map<string, Track>()
|
#config: BroadcastConfig
|
||||||
|
#path: string[]
|
||||||
readonly config: BroadcastConfig
|
|
||||||
readonly catalog: Catalog.Root
|
|
||||||
readonly connection: Connection
|
|
||||||
readonly namespace: string
|
|
||||||
|
|
||||||
#running: Promise<void>
|
|
||||||
|
|
||||||
constructor(config: BroadcastConfig) {
|
constructor(config: BroadcastConfig) {
|
||||||
this.connection = config.connection
|
const id = config.id || new Date().getTime() / 1000
|
||||||
this.config = config
|
|
||||||
this.namespace = config.namespace
|
|
||||||
|
|
||||||
const tracks: Catalog.Track[] = []
|
this.#config = config
|
||||||
|
this.#path = config.path.concat(id.toString())
|
||||||
|
}
|
||||||
|
|
||||||
for (const media of this.config.media.getTracks()) {
|
async publish(connection: Transfork.Connection) {
|
||||||
const track = new Track(media, config)
|
const broadcast: Catalog.Broadcast = {
|
||||||
this.#tracks.set(track.name, track)
|
path: this.#config.path,
|
||||||
|
audio: [],
|
||||||
|
video: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const media of this.#config.media.getTracks()) {
|
||||||
const settings = media.getSettings()
|
const settings = media.getSettings()
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
name: media.id, // TODO way too verbose
|
||||||
|
priority: media.kind === "video" ? 1 : 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = new Transfork.Track(this.#config.path.concat(info.name), info.priority)
|
||||||
|
|
||||||
if (isVideoTrackSettings(settings)) {
|
if (isVideoTrackSettings(settings)) {
|
||||||
if (!config.video) {
|
if (!this.#config.video) {
|
||||||
throw new Error("no video configuration provided")
|
throw new Error("no video configuration provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
const video: Catalog.VideoTrack = {
|
const encoder = new Video.Encoder(this.#config.video)
|
||||||
namespace: this.namespace,
|
const packer = new Video.Packer(media as MediaStreamVideoTrack, encoder, track)
|
||||||
name: `${track.name}.m4s`,
|
|
||||||
initTrack: `${track.name}.mp4`,
|
// TODO handle error
|
||||||
selectionParams: {
|
packer.run().catch((err) => console.error("failed to run video packer: ", err))
|
||||||
mimeType: "video/mp4",
|
|
||||||
codec: config.video.codec,
|
const decoder = await encoder.decoderConfig()
|
||||||
width: settings.width,
|
const description = decoder.description ? new Uint8Array(decoder.description as ArrayBuffer) : undefined
|
||||||
height: settings.height,
|
|
||||||
framerate: settings.frameRate,
|
const video: Catalog.Video = {
|
||||||
bitrate: config.video.bitrate,
|
track: info,
|
||||||
},
|
codec: decoder.codec,
|
||||||
|
description: description,
|
||||||
|
resolution: { width: settings.width, height: settings.height },
|
||||||
|
frame_rate: settings.frameRate,
|
||||||
|
bitrate: this.#config.video.bitrate,
|
||||||
}
|
}
|
||||||
|
|
||||||
tracks.push(video)
|
broadcast.video.push(video)
|
||||||
} else if (isAudioTrackSettings(settings)) {
|
} else if (isAudioTrackSettings(settings)) {
|
||||||
if (!config.audio) {
|
if (!this.#config.audio) {
|
||||||
throw new Error("no audio configuration provided")
|
throw new Error("no audio configuration provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
const audio: Catalog.AudioTrack = {
|
const encoder = new Audio.Encoder(this.#config.audio)
|
||||||
namespace: this.namespace,
|
const packer = new Audio.Packer(media as MediaStreamAudioTrack, encoder, track)
|
||||||
name: `${track.name}.m4s`,
|
packer.run().catch((err) => console.error("failed to run audio packer: ", err)) // TODO handle error
|
||||||
initTrack: `${track.name}.mp4`,
|
|
||||||
selectionParams: {
|
const decoder = await encoder.decoderConfig()
|
||||||
mimeType: "audio/ogg",
|
|
||||||
codec: config.audio.codec,
|
const audio: Catalog.Audio = {
|
||||||
samplerate: settings.sampleRate,
|
track: info,
|
||||||
//sampleSize: settings.sampleSize,
|
codec: decoder.codec,
|
||||||
channelConfig: `${settings.channelCount}`,
|
sample_rate: settings.sampleRate,
|
||||||
bitrate: config.audio.bitrate,
|
channel_count: settings.channelCount,
|
||||||
},
|
bitrate: this.#config.audio.bitrate,
|
||||||
}
|
}
|
||||||
|
|
||||||
tracks.push(audio)
|
broadcast.audio.push(audio)
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`unknown track type: ${media.kind}`)
|
throw new Error(`unknown track type: ${media.kind}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
connection.publish(track.reader())
|
||||||
}
|
}
|
||||||
|
|
||||||
this.catalog = {
|
const track = new Transfork.Track(this.#config.path.concat("catalog.json"), 0)
|
||||||
version: 1,
|
track.appendGroup().writeFrames(Catalog.encode(broadcast))
|
||||||
streamingFormat: 1,
|
|
||||||
streamingFormatVersion: "0.2",
|
connection.publish(track.reader())
|
||||||
supportsDeltaUpdates: false,
|
|
||||||
commonTrackFields: {
|
|
||||||
packaging: "cmaf",
|
|
||||||
renderGroup: 1,
|
|
||||||
},
|
|
||||||
tracks,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#running = this.#run()
|
close() {}
|
||||||
}
|
|
||||||
|
|
||||||
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)]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
// Extends EncodedVideoChunk, allowing a new "init" type
|
|
||||||
export interface Chunk {
|
|
||||||
type: "init" | "key" | "delta"
|
|
||||||
timestamp: number // microseconds
|
|
||||||
duration: number // microseconds
|
|
||||||
data: Uint8Array
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Chunk } from "./chunk"
|
import type { Frame } from "../karp/frame"
|
||||||
|
|
||||||
export class Segment {
|
export class Segment {
|
||||||
id: number
|
id: number
|
||||||
|
|
||||||
// Take in a stream of chunks
|
// Take in a stream of frames
|
||||||
input: WritableStream<Chunk>
|
input: WritableStream<Frame>
|
||||||
|
|
||||||
// Output a stream of bytes, which we fork for each new subscriber.
|
// Output a stream of bytes, which we fork for each new subscriber.
|
||||||
#cache: ReadableStream<Uint8Array>
|
#cache: ReadableStream<Uint8Array>
|
||||||
@@ -16,16 +16,18 @@ export class Segment {
|
|||||||
|
|
||||||
// Set a max size for each segment, dropping the tail if it gets too long.
|
// 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.
|
// We tee the reader, so this limit applies to the FASTEST reader.
|
||||||
const backpressure = new ByteLengthQueuingStrategy({ highWaterMark: 8_000_000 })
|
const backpressure = new ByteLengthQueuingStrategy({
|
||||||
|
highWaterMark: 8_000_000,
|
||||||
|
})
|
||||||
|
|
||||||
const transport = new TransformStream<Chunk, Uint8Array>(
|
const transport = new TransformStream<Frame, Uint8Array>(
|
||||||
{
|
{
|
||||||
transform: (chunk: Chunk, controller) => {
|
transform: (frame: Frame, controller) => {
|
||||||
// Compute the max timestamp of the segment
|
// Compute the max timestamp of the segment
|
||||||
this.timestamp = Math.max(chunk.timestamp + chunk.duration)
|
this.timestamp = Math.max(this.timestamp, frame.timestamp)
|
||||||
|
|
||||||
// Push the chunk to any listeners.
|
// Push the chunk to any listeners.
|
||||||
controller.enqueue(chunk.data)
|
controller.enqueue(frame.data)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Segment } from "./segment"
|
|
||||||
import { Notify } from "../common/async"
|
import { Notify } from "../common/async"
|
||||||
import { Chunk } from "./chunk"
|
import type { BroadcastConfig } from "./broadcast"
|
||||||
import { Container } from "./container"
|
import { Segment } from "./segment"
|
||||||
import { BroadcastConfig } from "./broadcast"
|
|
||||||
|
|
||||||
|
import type { Frame } from "../karp/frame"
|
||||||
import * as Audio from "./audio"
|
import * as Audio from "./audio"
|
||||||
import * as Video from "./video"
|
import * as Video from "./video"
|
||||||
|
|
||||||
@@ -36,7 +35,6 @@ export class Track {
|
|||||||
async #runAudio(track: MediaStreamAudioTrack, config: AudioEncoderConfig) {
|
async #runAudio(track: MediaStreamAudioTrack, config: AudioEncoderConfig) {
|
||||||
const source = new MediaStreamTrackProcessor({ track })
|
const source = new MediaStreamTrackProcessor({ track })
|
||||||
const encoder = new Audio.Encoder(config)
|
const encoder = new Audio.Encoder(config)
|
||||||
const container = new Container()
|
|
||||||
|
|
||||||
// Split the container at keyframe boundaries
|
// Split the container at keyframe boundaries
|
||||||
const segments = new WritableStream({
|
const segments = new WritableStream({
|
||||||
@@ -45,13 +43,12 @@ export class Track {
|
|||||||
abort: (e) => this.#close(e),
|
abort: (e) => this.#close(e),
|
||||||
})
|
})
|
||||||
|
|
||||||
return source.readable.pipeThrough(encoder.frames).pipeThrough(container.encode).pipeTo(segments)
|
return source.readable.pipeThrough(encoder.frames).pipeTo(segments)
|
||||||
}
|
}
|
||||||
|
|
||||||
async #runVideo(track: MediaStreamVideoTrack, config: VideoEncoderConfig) {
|
async #runVideo(track: MediaStreamVideoTrack, config: VideoEncoderConfig) {
|
||||||
const source = new MediaStreamTrackProcessor({ track })
|
const source = new MediaStreamTrackProcessor({ track })
|
||||||
const encoder = new Video.Encoder(config)
|
const encoder = new Video.Encoder(config)
|
||||||
const container = new Container()
|
|
||||||
|
|
||||||
// Split the container at keyframe boundaries
|
// Split the container at keyframe boundaries
|
||||||
const segments = new WritableStream({
|
const segments = new WritableStream({
|
||||||
@@ -60,18 +57,12 @@ export class Track {
|
|||||||
abort: (e) => this.#close(e),
|
abort: (e) => this.#close(e),
|
||||||
})
|
})
|
||||||
|
|
||||||
return source.readable.pipeThrough(encoder.frames).pipeThrough(container.encode).pipeTo(segments)
|
return source.readable.pipeThrough(encoder.frames).pipeTo(segments)
|
||||||
}
|
|
||||||
|
|
||||||
async #write(chunk: Chunk) {
|
|
||||||
if (chunk.type === "init") {
|
|
||||||
this.#init = chunk.data
|
|
||||||
this.#notify.wake()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #write(frame: Frame) {
|
||||||
let current = this.#segments.at(-1)
|
let current = this.#segments.at(-1)
|
||||||
if (!current || chunk.type === "key") {
|
if (!current || frame.type === "key") {
|
||||||
if (current) {
|
if (current) {
|
||||||
await current.input.close()
|
await current.input.close()
|
||||||
}
|
}
|
||||||
@@ -88,7 +79,7 @@ export class Track {
|
|||||||
const first = this.#segments[0]
|
const first = this.#segments[0]
|
||||||
|
|
||||||
// Expire after 10s
|
// Expire after 10s
|
||||||
if (chunk.timestamp - first.timestamp < 10_000_000) break
|
if (frame.timestamp - first.timestamp < 10_000_000) break
|
||||||
this.#segments.shift()
|
this.#segments.shift()
|
||||||
this.#offset += 1
|
this.#offset += 1
|
||||||
|
|
||||||
@@ -99,7 +90,7 @@ export class Track {
|
|||||||
const writer = current.input.getWriter()
|
const writer = current.input.getWriter()
|
||||||
|
|
||||||
if ((writer.desiredSize || 0) > 0) {
|
if ((writer.desiredSize || 0) > 0) {
|
||||||
await writer.write(chunk)
|
await writer.write(frame)
|
||||||
} else {
|
} else {
|
||||||
console.warn("dropping chunk", writer.desiredSize)
|
console.warn("dropping chunk", writer.desiredSize)
|
||||||
}
|
}
|
||||||
@@ -147,7 +138,8 @@ export class Track {
|
|||||||
if (this.#error) {
|
if (this.#error) {
|
||||||
controller.error(this.#error)
|
controller.error(this.#error)
|
||||||
return
|
return
|
||||||
} else if (this.#closed) {
|
}
|
||||||
|
if (this.#closed) {
|
||||||
controller.close()
|
controller.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
"path": "../common"
|
"path": "../common"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../transport"
|
"path": "../transfork"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../media"
|
"path": "../karp"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import { Deferred } from "../common/async"
|
||||||
|
import type { Frame } from "../karp/frame"
|
||||||
|
import type { Group, Track } from "../transfork"
|
||||||
|
import { Closed } from "../transfork/error"
|
||||||
|
|
||||||
const SUPPORTED = [
|
const SUPPORTED = [
|
||||||
"avc1", // H.264
|
"avc1", // H.264
|
||||||
"hev1", // HEVC (aka h.265)
|
"hev1", // HEVC (aka h.265)
|
||||||
@@ -8,10 +13,55 @@ export interface EncoderSupported {
|
|||||||
codecs: string[]
|
codecs: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class Packer {
|
||||||
|
#source: MediaStreamTrackProcessor<VideoFrame>
|
||||||
|
#encoder: Encoder
|
||||||
|
|
||||||
|
#data: Track
|
||||||
|
#current?: Group
|
||||||
|
|
||||||
|
constructor(track: MediaStreamVideoTrack, encoder: Encoder, data: Track) {
|
||||||
|
this.#source = new MediaStreamTrackProcessor({ track })
|
||||||
|
this.#encoder = encoder
|
||||||
|
this.#data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
const output = new WritableStream({
|
||||||
|
write: (chunk) => this.#write(chunk),
|
||||||
|
close: () => this.#close(),
|
||||||
|
abort: (e) => this.#close(e),
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.#source.readable.pipeThrough(this.#encoder.frames).pipeTo(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
#write(frame: Frame) {
|
||||||
|
if (!this.#current || frame.type === "key") {
|
||||||
|
if (this.#current) {
|
||||||
|
this.#current.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#current = this.#data.appendGroup()
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.encode(this.#current)
|
||||||
|
}
|
||||||
|
|
||||||
|
#close(err?: unknown) {
|
||||||
|
const closed = Closed.from(err)
|
||||||
|
if (this.#current) {
|
||||||
|
this.#current.close(closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#data.close(closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Encoder {
|
export class Encoder {
|
||||||
#encoder!: VideoEncoder
|
#encoder!: VideoEncoder
|
||||||
#encoderConfig: VideoEncoderConfig
|
#encoderConfig: VideoEncoderConfig
|
||||||
#decoderConfig?: VideoDecoderConfig
|
#decoderConfig = new Deferred<VideoDecoderConfig>()
|
||||||
|
|
||||||
// true if we should insert a keyframe, undefined when the encoder should decide
|
// true if we should insert a keyframe, undefined when the encoder should decide
|
||||||
#keyframeNext: true | undefined = true
|
#keyframeNext: true | undefined = true
|
||||||
@@ -20,7 +70,7 @@ export class Encoder {
|
|||||||
#keyframeCounter = 0
|
#keyframeCounter = 0
|
||||||
|
|
||||||
// Converts raw rames to encoded frames.
|
// Converts raw rames to encoded frames.
|
||||||
frames: TransformStream<VideoFrame, VideoDecoderConfig | EncodedVideoChunk>
|
frames: TransformStream<VideoFrame, EncodedVideoChunk>
|
||||||
|
|
||||||
constructor(config: VideoEncoderConfig) {
|
constructor(config: VideoEncoderConfig) {
|
||||||
config.bitrateMode ??= "constant"
|
config.bitrateMode ??= "constant"
|
||||||
@@ -53,12 +103,17 @@ export class Encoder {
|
|||||||
return !!res.supported
|
return !!res.supported
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async decoderConfig(): Promise<VideoDecoderConfig> {
|
||||||
|
return await this.#decoderConfig.promise
|
||||||
|
}
|
||||||
|
|
||||||
#start(controller: TransformStreamDefaultController<EncodedVideoChunk>) {
|
#start(controller: TransformStreamDefaultController<EncodedVideoChunk>) {
|
||||||
this.#encoder = new VideoEncoder({
|
this.#encoder = new VideoEncoder({
|
||||||
output: (frame, metadata) => {
|
output: (frame, metadata) => {
|
||||||
this.#enqueue(controller, frame, metadata)
|
this.#enqueue(controller, frame, metadata)
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
|
this.#decoderConfig.reject(err)
|
||||||
throw err
|
throw err
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -77,23 +132,22 @@ export class Encoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#enqueue(
|
#enqueue(
|
||||||
controller: TransformStreamDefaultController<VideoDecoderConfig | EncodedVideoChunk>,
|
controller: TransformStreamDefaultController<EncodedVideoChunk>,
|
||||||
frame: EncodedVideoChunk,
|
frame: EncodedVideoChunk,
|
||||||
metadata?: EncodedVideoChunkMetadata,
|
metadata?: EncodedVideoChunkMetadata,
|
||||||
) {
|
) {
|
||||||
if (!this.#decoderConfig) {
|
if (this.#decoderConfig.pending) {
|
||||||
const config = metadata?.decoderConfig
|
const config = metadata?.decoderConfig
|
||||||
if (!config) throw new Error("missing decoder config")
|
if (!config) throw new Error("missing decoder config")
|
||||||
|
this.#decoderConfig.resolve(config)
|
||||||
controller.enqueue(config)
|
|
||||||
this.#decoderConfig = config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frame.type === "key") {
|
if (frame.type === "key") {
|
||||||
this.#keyframeCounter = 0
|
this.#keyframeCounter = 0
|
||||||
} else {
|
} else {
|
||||||
this.#keyframeCounter += 1
|
this.#keyframeCounter += 1
|
||||||
if (this.#keyframeCounter + this.#encoder.encodeQueueSize >= 2 * this.#encoderConfig.framerate!) {
|
const framesPerGop = this.#encoderConfig.framerate ? 2 * this.#encoderConfig.framerate : 60
|
||||||
|
if (this.#keyframeCounter + this.#encoder.encodeQueueSize >= framesPerGop) {
|
||||||
this.#keyframeNext = true
|
this.#keyframeNext = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
packages/moq/karp/catalog/audio.ts
Normal file
20
packages/moq/karp/catalog/audio.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { type Track, decodeTrack } from "./track"
|
||||||
|
|
||||||
|
export interface Audio {
|
||||||
|
track: Track
|
||||||
|
codec: string
|
||||||
|
sample_rate: number
|
||||||
|
channel_count: number
|
||||||
|
bitrate?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeAudio(o: unknown): o is Audio {
|
||||||
|
if (typeof o !== "object" || o === null) return false
|
||||||
|
|
||||||
|
const obj = o as Partial<Audio>
|
||||||
|
if (!decodeTrack(obj.track)) return false
|
||||||
|
if (typeof obj.codec !== "string") return false
|
||||||
|
if (typeof obj.sample_rate !== "number") return false
|
||||||
|
if (typeof obj.channel_count !== "number") return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
62
packages/moq/karp/catalog/broadcast.ts
Normal file
62
packages/moq/karp/catalog/broadcast.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import * as Transfork from "../../transfork"
|
||||||
|
import { type Audio, decodeAudio } from "./audio"
|
||||||
|
import { type Video, decodeVideo } from "./video"
|
||||||
|
|
||||||
|
export interface Broadcast {
|
||||||
|
path: string[]
|
||||||
|
video: Video[]
|
||||||
|
audio: Audio[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encode(catalog: Broadcast): Uint8Array {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
console.debug("encoding catalog", catalog)
|
||||||
|
const str = JSON.stringify(catalog)
|
||||||
|
return encoder.encode(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decode(path: string[], raw: Uint8Array): Broadcast {
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
const str = decoder.decode(raw)
|
||||||
|
|
||||||
|
const catalog = JSON.parse(str)
|
||||||
|
if (!decodeBroadcast(catalog)) {
|
||||||
|
console.error("invalid catalog", catalog)
|
||||||
|
throw new Error("invalid catalog")
|
||||||
|
}
|
||||||
|
|
||||||
|
catalog.path = path
|
||||||
|
return catalog
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetch(connection: Transfork.Connection, path: string[]): Promise<Broadcast> {
|
||||||
|
const track = new Transfork.Track(path.concat("catalog.json"), 0)
|
||||||
|
const sub = await connection.subscribe(track)
|
||||||
|
try {
|
||||||
|
const segment = await sub.nextGroup()
|
||||||
|
if (!segment) throw new Error("no catalog data")
|
||||||
|
|
||||||
|
const frame = await segment.readFrame()
|
||||||
|
if (!frame) throw new Error("no catalog frame")
|
||||||
|
|
||||||
|
segment.close()
|
||||||
|
return decode(path, frame)
|
||||||
|
} finally {
|
||||||
|
sub.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeBroadcast(o: unknown): o is Broadcast {
|
||||||
|
if (typeof o !== "object" || o === null) return false
|
||||||
|
|
||||||
|
const catalog = o as Partial<Broadcast>
|
||||||
|
if (catalog.audio === undefined) catalog.audio = []
|
||||||
|
if (!Array.isArray(catalog.audio)) return false
|
||||||
|
if (!catalog.audio.every((track: unknown) => decodeAudio(track))) return false
|
||||||
|
|
||||||
|
if (catalog.video === undefined) catalog.video = []
|
||||||
|
if (!Array.isArray(catalog.video)) return false
|
||||||
|
if (!catalog.video.every((track: unknown) => decodeVideo(track))) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
7
packages/moq/karp/catalog/index.ts
Normal file
7
packages/moq/karp/catalog/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Audio } from "./audio"
|
||||||
|
import { type Broadcast, decode, encode, fetch } from "./broadcast"
|
||||||
|
import type { Track } from "./track"
|
||||||
|
import type { Video } from "./video"
|
||||||
|
|
||||||
|
export type { Audio, Video, Track, Broadcast }
|
||||||
|
export { encode, decode, fetch }
|
||||||
15
packages/moq/karp/catalog/track.ts
Normal file
15
packages/moq/karp/catalog/track.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export type GroupOrder = "desc" | "asc"
|
||||||
|
|
||||||
|
export interface Track {
|
||||||
|
name: string
|
||||||
|
priority: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeTrack(o: unknown): o is Track {
|
||||||
|
if (typeof o !== "object" || o === null) return false
|
||||||
|
|
||||||
|
const obj = o as Partial<Track>
|
||||||
|
if (typeof obj.name !== "string") return false
|
||||||
|
if (typeof obj.priority !== "number") return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
29
packages/moq/karp/catalog/video.ts
Normal file
29
packages/moq/karp/catalog/video.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as Hex from "../../common/hex"
|
||||||
|
import { type Track, decodeTrack } from "./track"
|
||||||
|
|
||||||
|
export interface Video {
|
||||||
|
track: Track
|
||||||
|
codec: string
|
||||||
|
description?: Uint8Array
|
||||||
|
bitrate?: number
|
||||||
|
frame_rate?: number
|
||||||
|
resolution: Dimensions
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Dimensions {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeVideo(o: unknown): o is Video {
|
||||||
|
if (typeof o !== "object" || o === null) return false
|
||||||
|
|
||||||
|
const obj = o as Partial<Video>
|
||||||
|
if (!decodeTrack(obj.track)) return false
|
||||||
|
if (typeof obj.codec !== "string") return false
|
||||||
|
if (typeof obj.description !== "string") return false
|
||||||
|
|
||||||
|
obj.description = Hex.decode(obj.description)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
64
packages/moq/karp/frame.ts
Normal file
64
packages/moq/karp/frame.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { Group, GroupReader } from "../transfork/model"
|
||||||
|
import { setVint62 } from "../transfork/stream"
|
||||||
|
|
||||||
|
export type FrameType = "key" | "delta"
|
||||||
|
|
||||||
|
export class Frame {
|
||||||
|
type: FrameType
|
||||||
|
timestamp: number
|
||||||
|
data: Uint8Array
|
||||||
|
|
||||||
|
constructor(type: FrameType, timestamp: number, data: Uint8Array) {
|
||||||
|
this.type = type
|
||||||
|
this.timestamp = timestamp
|
||||||
|
this.data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode(group: GroupReader): Promise<Frame | undefined> {
|
||||||
|
const kind = group.index === 0 ? "key" : "delta"
|
||||||
|
const payload = await group.readFrame()
|
||||||
|
if (!payload) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const [timestamp, data] = decode_timestamp(payload)
|
||||||
|
return new Frame(kind, timestamp, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
encode(group: Group) {
|
||||||
|
if ((group.length === 0) !== (this.type === "key")) {
|
||||||
|
throw new Error(`invalid ${this.type} position`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame = new Uint8Array(8 + this.data.byteLength)
|
||||||
|
const size = setVint62(frame, BigInt(this.timestamp)).byteLength
|
||||||
|
frame.set(this.data, size)
|
||||||
|
frame = new Uint8Array(frame.buffer, 0, this.data.byteLength + size)
|
||||||
|
|
||||||
|
group.writeFrame(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QUIC VarInt
|
||||||
|
function decode_timestamp(buf: Uint8Array): [number, Uint8Array] {
|
||||||
|
const size = 1 << ((buf[0] & 0xc0) >> 6)
|
||||||
|
|
||||||
|
const view = new DataView(buf.buffer, buf.byteOffset, size)
|
||||||
|
const remain = new Uint8Array(buf.buffer, buf.byteOffset + size, buf.byteLength - size)
|
||||||
|
let v: number
|
||||||
|
|
||||||
|
if (size === 1) {
|
||||||
|
v = buf[0] & 0x3f
|
||||||
|
} else if (size === 2) {
|
||||||
|
v = view.getInt16(0) & 0x3fff
|
||||||
|
} else if (size === 4) {
|
||||||
|
v = view.getUint32(0) & 0x3fffffff
|
||||||
|
} else if (size === 8) {
|
||||||
|
// NOTE: Precision loss above 2^52
|
||||||
|
v = Number(view.getBigUint64(0) & 0x3fffffffffffffffn)
|
||||||
|
} else {
|
||||||
|
throw new Error("impossible")
|
||||||
|
}
|
||||||
|
|
||||||
|
return [v, remain]
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
{
|
{
|
||||||
"extends": "../tsconfig.json",
|
"extends": "../tsconfig.json",
|
||||||
"include": ["."],
|
"include": ["."],
|
||||||
"compilerOptions": {
|
"compilerOptions": {},
|
||||||
"types": ["mp4box"]
|
|
||||||
},
|
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
"path": "../transport"
|
"path": "../transfork"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../common"
|
"path": "../common"
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
// 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"
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@nestri/moq",
|
"name": "@nestri/libmoq",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"description": "Media over QUIC library",
|
"description": "Media over QUIC library",
|
||||||
@@ -7,23 +7,14 @@
|
|||||||
"repository": "github:kixelated/moq-js",
|
"repository": "github:kixelated/moq-js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -b && cp ../LICENSE* ./dist && cp ./README.md ./dist && cp ./package.json ./dist",
|
"build": "tsc -b && cp ../LICENSE* ./dist && cp ./README.md ./dist && cp ./package.json ./dist",
|
||||||
"lint": "eslint .",
|
"check": "biome check",
|
||||||
"fmt": "prettier --write ."
|
"fix": "biome check --write"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/audioworklet": "^0.0.50",
|
"@types/audioworklet": "^0.0.50",
|
||||||
"@types/dom-mediacapture-transform": "^0.1.6",
|
"@types/dom-mediacapture-transform": "^0.1.6",
|
||||||
"@types/dom-webcodecs": "^0.1.8",
|
"@types/dom-webcodecs": "^0.1.8",
|
||||||
"@typescript/lib-dom": "npm:@types/web@^0.0.115",
|
"@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"
|
"typescript": "^5.1.6"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"mp4box": "^0.5.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,57 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
import * as Message from "./worker/message"
|
import { Ring, RingShared } from "../common/ring"
|
||||||
|
import type * as Catalog from "../karp/catalog"
|
||||||
|
import type { Frame } from "../karp/frame"
|
||||||
|
import type { Component } from "./timeline"
|
||||||
|
|
||||||
// This is a non-standard way of importing worklet/workers.
|
// 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
|
// 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"
|
import workletURL from "./worklet/index.ts?worker&url"
|
||||||
|
|
||||||
// NOTE: This must be on the main thread
|
export class Renderer {
|
||||||
export class Audio {
|
#context: AudioContext
|
||||||
context: AudioContext
|
#worklet: Promise<AudioWorkletNode>
|
||||||
worklet: Promise<AudioWorkletNode>
|
|
||||||
|
|
||||||
constructor(config: Message.ConfigAudio) {
|
#ring: Ring
|
||||||
this.context = new AudioContext({
|
#ringShared: RingShared
|
||||||
|
|
||||||
|
#timeline: Component
|
||||||
|
#track: Catalog.Audio
|
||||||
|
|
||||||
|
#decoder!: AudioDecoder
|
||||||
|
#stream: TransformStream<Frame, AudioData>
|
||||||
|
|
||||||
|
constructor(track: Catalog.Audio, timeline: Component) {
|
||||||
|
this.#track = track
|
||||||
|
this.#context = new AudioContext({
|
||||||
latencyHint: "interactive",
|
latencyHint: "interactive",
|
||||||
sampleRate: config.sampleRate,
|
sampleRate: track.sample_rate,
|
||||||
})
|
})
|
||||||
|
|
||||||
this.worklet = this.load(config)
|
this.#worklet = this.load(track)
|
||||||
|
|
||||||
|
this.#timeline = timeline
|
||||||
|
this.#ringShared = new RingShared(2, track.sample_rate / 10) // 100ms
|
||||||
|
this.#ring = new Ring(this.#ringShared)
|
||||||
|
|
||||||
|
this.#stream = new TransformStream({
|
||||||
|
start: this.#start.bind(this),
|
||||||
|
transform: this.#transform.bind(this),
|
||||||
|
})
|
||||||
|
|
||||||
|
this.#run().catch((err) => console.error("failed to run audio renderer: ", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
private async load(config: Message.ConfigAudio): Promise<AudioWorkletNode> {
|
private async load(catalog: Catalog.Audio): Promise<AudioWorkletNode> {
|
||||||
// Load the worklet source code.
|
// Load the worklet source code.
|
||||||
await this.context.audioWorklet.addModule(workletURL)
|
await this.#context.audioWorklet.addModule(workletURL)
|
||||||
|
|
||||||
const volume = this.context.createGain()
|
const volume = this.#context.createGain()
|
||||||
volume.gain.value = 2.0
|
volume.gain.value = 2.0
|
||||||
|
|
||||||
// Create the worklet
|
// Create the worklet
|
||||||
const worklet = new AudioWorkletNode(this.context, "renderer")
|
const worklet = new AudioWorkletNode(this.#context, "renderer")
|
||||||
|
|
||||||
worklet.port.addEventListener("message", this.on.bind(this))
|
worklet.port.addEventListener("message", this.on.bind(this))
|
||||||
worklet.onprocessorerror = (e: Event) => {
|
worklet.onprocessorerror = (e: Event) => {
|
||||||
@@ -37,7 +60,13 @@ export class Audio {
|
|||||||
|
|
||||||
// Connect the worklet to the volume node and then to the speakers
|
// Connect the worklet to the volume node and then to the speakers
|
||||||
worklet.connect(volume)
|
worklet.connect(volume)
|
||||||
volume.connect(this.context.destination)
|
volume.connect(this.#context.destination)
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
sampleRate: catalog.sample_rate,
|
||||||
|
channelCount: catalog.channel_count,
|
||||||
|
ring: this.#ringShared,
|
||||||
|
}
|
||||||
|
|
||||||
worklet.port.postMessage({ config })
|
worklet.port.postMessage({ config })
|
||||||
|
|
||||||
@@ -47,4 +76,58 @@ export class Audio {
|
|||||||
private on(_event: MessageEvent) {
|
private on(_event: MessageEvent) {
|
||||||
// TODO
|
// TODO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
play() {
|
||||||
|
this.#context.resume().catch((err) => console.warn("failed to resume audio context: ", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.#context.close().catch((err) => console.warn("failed to close audio context: ", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
#start(controller: TransformStreamDefaultController) {
|
||||||
|
this.#decoder = new AudioDecoder({
|
||||||
|
output: (frame: AudioData) => {
|
||||||
|
controller.enqueue(frame)
|
||||||
|
},
|
||||||
|
error: console.warn,
|
||||||
|
})
|
||||||
|
|
||||||
|
// We only support OPUS right now which doesn't need a description.
|
||||||
|
this.#decoder.configure({
|
||||||
|
codec: this.#track.codec,
|
||||||
|
sampleRate: this.#track.sample_rate,
|
||||||
|
numberOfChannels: this.#track.channel_count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#transform(frame: Frame) {
|
||||||
|
const chunk = new EncodedAudioChunk({
|
||||||
|
type: frame.type,
|
||||||
|
timestamp: frame.timestamp,
|
||||||
|
data: frame.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`,
|
||||||
|
);
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
/// <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>
|
|
||||||
}
|
|
||||||
148
packages/moq/playback/broadcast.ts
Normal file
148
packages/moq/playback/broadcast.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import type * as Catalog from "../karp/catalog"
|
||||||
|
import type { Connection } from "../transfork/connection"
|
||||||
|
|
||||||
|
import { Track } from "../transfork"
|
||||||
|
|
||||||
|
import { Frame } from "../karp/frame"
|
||||||
|
import type { GroupReader } from "../transfork/model"
|
||||||
|
import * as Audio from "./audio"
|
||||||
|
import { Timeline } from "./timeline"
|
||||||
|
import * as Video from "./video"
|
||||||
|
|
||||||
|
// This class must be created on the main thread due to AudioContext.
|
||||||
|
export class Broadcast {
|
||||||
|
#connection: Connection
|
||||||
|
#catalog: Catalog.Broadcast
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
|
||||||
|
// Timeline receives samples, buffering them and choosing the timestamp to render.
|
||||||
|
#timeline = new Timeline()
|
||||||
|
|
||||||
|
#audio?: Audio.Renderer
|
||||||
|
#video?: Video.Renderer
|
||||||
|
|
||||||
|
constructor(connection: Connection, catalog: Catalog.Broadcast, canvas: HTMLCanvasElement) {
|
||||||
|
this.#connection = connection
|
||||||
|
this.#catalog = catalog
|
||||||
|
|
||||||
|
const running = []
|
||||||
|
|
||||||
|
// Only configure audio is we have an audio track
|
||||||
|
const audio = (catalog.audio || []).at(0)
|
||||||
|
if (audio) {
|
||||||
|
this.#audio = new Audio.Renderer(audio, this.#timeline.audio)
|
||||||
|
running.push(this.#runAudio(audio))
|
||||||
|
}
|
||||||
|
|
||||||
|
const video = (catalog.video || []).at(0)
|
||||||
|
if (video) {
|
||||||
|
this.#video = new Video.Renderer(video, canvas, this.#timeline.video)
|
||||||
|
running.push(this.#runVideo(video))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async work
|
||||||
|
this.#running = Promise.race([...running])
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runAudio(audio: Catalog.Audio) {
|
||||||
|
const track = new Track(this.#catalog.path.concat(audio.track.name), audio.track.priority)
|
||||||
|
const sub = await this.#connection.subscribe(track)
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const group = await Promise.race([sub.nextGroup(), this.#running])
|
||||||
|
if (!group) break
|
||||||
|
|
||||||
|
this.#runAudioGroup(audio, group)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => group.close())
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
sub.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runVideo(video: Catalog.Video) {
|
||||||
|
const track = new Track(this.#catalog.path.concat(video.track.name), video.track.priority)
|
||||||
|
const sub = await this.#connection.subscribe(track)
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const group = await Promise.race([sub.nextGroup(), this.#running])
|
||||||
|
if (!group) break
|
||||||
|
|
||||||
|
this.#runVideoGroup(video, group)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => group.close())
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
sub.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runAudioGroup(audio: Catalog.Audio, group: GroupReader) {
|
||||||
|
const timeline = this.#timeline.audio
|
||||||
|
|
||||||
|
// Create a queue that will contain each frame
|
||||||
|
const queue = new TransformStream<Frame>({})
|
||||||
|
const segment = queue.writable.getWriter()
|
||||||
|
|
||||||
|
// Add the segment to the timeline
|
||||||
|
const segments = timeline.segments.getWriter()
|
||||||
|
await segments.write({
|
||||||
|
sequence: group.id,
|
||||||
|
frames: queue.readable,
|
||||||
|
})
|
||||||
|
segments.releaseLock()
|
||||||
|
|
||||||
|
// Read each chunk, decoding the MP4 frames and adding them to the queue.
|
||||||
|
for (;;) {
|
||||||
|
const frame = await Frame.decode(group)
|
||||||
|
if (!frame) break
|
||||||
|
|
||||||
|
await segment.write(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We done.
|
||||||
|
await segment.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runVideoGroup(video: Catalog.Video, group: GroupReader) {
|
||||||
|
const timeline = this.#timeline.video
|
||||||
|
|
||||||
|
// Create a queue that will contain each MP4 frame.
|
||||||
|
const queue = new TransformStream<Frame>({})
|
||||||
|
const segment = queue.writable.getWriter()
|
||||||
|
|
||||||
|
// Add the segment to the timeline
|
||||||
|
const segments = timeline.segments.getWriter()
|
||||||
|
await segments.write({
|
||||||
|
sequence: group.id,
|
||||||
|
frames: queue.readable,
|
||||||
|
})
|
||||||
|
segments.releaseLock()
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const frame = await Frame.decode(group)
|
||||||
|
if (!frame) break
|
||||||
|
|
||||||
|
await segment.write(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We done.
|
||||||
|
await segment.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
unmute() {
|
||||||
|
console.debug("unmuting audio")
|
||||||
|
this.#audio?.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.#audio?.close()
|
||||||
|
this.#video?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,190 +1,2 @@
|
|||||||
import * as Message from "./worker/message"
|
export { Player } from "./player"
|
||||||
|
export type { PlayerConfig } from "./player"
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|||||||
63
packages/moq/playback/player.ts
Normal file
63
packages/moq/playback/player.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import * as Catalog from "../karp/catalog"
|
||||||
|
import type { Connection } from "../transfork/connection"
|
||||||
|
import { Broadcast } from "./broadcast"
|
||||||
|
|
||||||
|
export interface PlayerConfig {
|
||||||
|
connection: Connection
|
||||||
|
path: string[]
|
||||||
|
canvas: HTMLCanvasElement
|
||||||
|
}
|
||||||
|
|
||||||
|
// This class must be created on the main thread due to AudioContext.
|
||||||
|
export class Player {
|
||||||
|
#config: PlayerConfig
|
||||||
|
#running: Promise<void>
|
||||||
|
#active?: Broadcast
|
||||||
|
|
||||||
|
constructor(config: PlayerConfig) {
|
||||||
|
this.#config = config
|
||||||
|
this.#running = this.#run()
|
||||||
|
}
|
||||||
|
|
||||||
|
async #run() {
|
||||||
|
const announced = await this.#config.connection.announced(this.#config.path)
|
||||||
|
|
||||||
|
let activeId = -1
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const announce = await announced.next()
|
||||||
|
if (!announce) break
|
||||||
|
|
||||||
|
if (announce.path.length === this.#config.path.length) {
|
||||||
|
throw new Error("expected resumable broadcast")
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = announce.path.slice(0, this.#config.path.length + 1)
|
||||||
|
|
||||||
|
const id = Number.parseInt(path[path.length - 1])
|
||||||
|
if (id <= activeId) continue
|
||||||
|
|
||||||
|
const catalog = await Catalog.fetch(this.#config.connection, path)
|
||||||
|
|
||||||
|
this.#active?.close()
|
||||||
|
this.#active = new Broadcast(this.#config.connection, catalog, this.#config.canvas)
|
||||||
|
activeId = id
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#active?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.#config.connection.close()
|
||||||
|
this.#active?.close()
|
||||||
|
this.#active = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async closed() {
|
||||||
|
await Promise.any([this.#running, this.#config.connection.closed()])
|
||||||
|
}
|
||||||
|
|
||||||
|
unmute() {
|
||||||
|
this.#active?.unmute()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { Frame } from "../../media/mp4"
|
import type { Frame } from "../karp/frame"
|
||||||
export type { Frame }
|
|
||||||
|
|
||||||
export interface Range {
|
export interface Range {
|
||||||
start: number
|
start: number
|
||||||
@@ -48,7 +47,7 @@ export class Component {
|
|||||||
// Get the next segment to render.
|
// Get the next segment to render.
|
||||||
const segments = this.#segments.readable.getReader()
|
const segments = this.#segments.readable.getReader()
|
||||||
|
|
||||||
let res
|
let res: ReadableStreamReadResult<Segment> | ReadableStreamReadResult<Frame>
|
||||||
if (this.#current) {
|
if (this.#current) {
|
||||||
// Get the next frame to render.
|
// Get the next frame to render.
|
||||||
const frames = this.#current.frames.getReader()
|
const frames = this.#current.frames.getReader()
|
||||||
@@ -85,17 +84,17 @@ export class Component {
|
|||||||
// Our segment is older than the current, abandon it.
|
// Our segment is older than the current, abandon it.
|
||||||
await value.frames.cancel("skipping segment; too old")
|
await value.frames.cancel("skipping segment; too old")
|
||||||
continue
|
continue
|
||||||
} else {
|
}
|
||||||
|
|
||||||
// Our segment is newer than the current, cancel the old one.
|
// Our segment is newer than the current, cancel the old one.
|
||||||
await this.#current.frames.cancel("skipping segment; too slow")
|
await this.#current.frames.cancel("skipping segment; too slow")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
this.#current = value
|
this.#current = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async #cancel(reason: any) {
|
async #cancel(reason: Error) {
|
||||||
if (this.#current) {
|
if (this.#current) {
|
||||||
await this.#current.frames.cancel(reason)
|
await this.#current.frames.cancel(reason)
|
||||||
}
|
}
|
||||||
@@ -111,8 +110,6 @@ export class Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Return if a type is a segment or frame
|
// 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 {
|
function isSegment(value: Segment | Frame): value is Segment {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
||||||
return (value as Segment).frames !== undefined
|
return (value as Segment).frames !== undefined
|
||||||
}
|
}
|
||||||
@@ -10,10 +10,10 @@
|
|||||||
"path": "../common"
|
"path": "../common"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../transport"
|
"path": "../transfork"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../media"
|
"path": "../karp"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { Frame, Component } from "./timeline"
|
import type * as Catalog from "../karp/catalog"
|
||||||
import * as MP4 from "../../media/mp4"
|
import type { Frame } from "../karp/frame"
|
||||||
import * as Message from "./message"
|
import type { Component } from "./timeline"
|
||||||
|
|
||||||
export class Renderer {
|
export class Renderer {
|
||||||
#canvas: OffscreenCanvas
|
#track: Catalog.Video
|
||||||
|
#canvas: HTMLCanvasElement
|
||||||
#timeline: Component
|
#timeline: Component
|
||||||
|
|
||||||
#decoder!: VideoDecoder
|
#decoder!: VideoDecoder
|
||||||
#queue: TransformStream<Frame, VideoFrame>
|
#queue: TransformStream<Frame, VideoFrame>
|
||||||
|
|
||||||
constructor(config: Message.ConfigVideo, timeline: Component) {
|
constructor(track: Catalog.Video, canvas: HTMLCanvasElement, timeline: Component) {
|
||||||
this.#canvas = config.canvas
|
this.#track = track
|
||||||
|
this.#canvas = canvas
|
||||||
this.#timeline = timeline
|
this.#timeline = timeline
|
||||||
|
|
||||||
this.#queue = new TransformStream({
|
this.#queue = new TransformStream({
|
||||||
@@ -18,7 +20,11 @@ export class Renderer {
|
|||||||
transform: this.#transform.bind(this),
|
transform: this.#transform.bind(this),
|
||||||
})
|
})
|
||||||
|
|
||||||
this.#run().catch(console.error)
|
this.#run().catch((err) => console.error("failed to run video renderer: ", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
// TODO
|
||||||
}
|
}
|
||||||
|
|
||||||
async #run() {
|
async #run() {
|
||||||
@@ -47,36 +53,21 @@ export class Renderer {
|
|||||||
},
|
},
|
||||||
error: console.error,
|
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({
|
this.#decoder.configure({
|
||||||
codec: track.codec,
|
codec: this.#track.codec,
|
||||||
codedHeight: track.video.height,
|
codedHeight: this.#track.resolution.height,
|
||||||
codedWidth: track.video.width,
|
codedWidth: this.#track.resolution.width,
|
||||||
description,
|
description: this.#track.description,
|
||||||
// optimizeForLatency: true
|
optimizeForLatency: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#transform(frame: Frame) {
|
||||||
const chunk = new EncodedVideoChunk({
|
const chunk = new EncodedVideoChunk({
|
||||||
type: frame.sample.is_sync ? "key" : "delta",
|
type: frame.type,
|
||||||
data: frame.sample.data,
|
data: frame.data,
|
||||||
timestamp: frame.sample.dts / frame.track.timescale,
|
timestamp: frame.timestamp,
|
||||||
})
|
})
|
||||||
|
|
||||||
this.#decoder.decode(chunk)
|
this.#decoder.decode(chunk)
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
*/
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// TODO add support for @/ to avoid relative imports
|
// TODO add support for @/ to avoid relative imports
|
||||||
import { Ring } from "../../common/ring"
|
import { Ring } from "../../common/ring"
|
||||||
import * as Message from "./message"
|
import type * as Message from "./message"
|
||||||
|
|
||||||
class Renderer extends AudioWorkletProcessor {
|
class Renderer extends AudioWorkletProcessor {
|
||||||
ring?: Ring
|
ring?: Ring
|
||||||
@@ -26,17 +26,17 @@ class Renderer extends AudioWorkletProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inputs and outputs in groups of 128 samples.
|
// Inputs and outputs in groups of 128 samples.
|
||||||
process(inputs: Float32Array[][], outputs: Float32Array[][], _parameters: Record<string, Float32Array>): boolean {
|
process(_inputs: Float32Array[][], outputs: Float32Array[][], _parameters: Record<string, Float32Array>): boolean {
|
||||||
if (!this.ring) {
|
if (!this.ring) {
|
||||||
// Paused
|
// Paused
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inputs.length != 1 && outputs.length != 1) {
|
if (outputs.length !== 1) {
|
||||||
throw new Error("only a single track is supported")
|
throw new Error("only a single track is supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.ring.size() == this.ring.capacity) {
|
if (this.ring.size() === this.ring.capacity) {
|
||||||
// This is a hack to clear any latency in the ring buffer.
|
// This is a hack to clear any latency in the ring buffer.
|
||||||
// The proper solution is to play back slightly faster?
|
// The proper solution is to play back slightly faster?
|
||||||
console.warn("resyncing ring buffer")
|
console.warn("resyncing ring buffer")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RingShared } from "../../common/ring"
|
import type { RingShared } from "../../common/ring"
|
||||||
|
|
||||||
export interface From {
|
export interface From {
|
||||||
config?: Config
|
config?: Config
|
||||||
@@ -7,6 +7,5 @@ export interface From {
|
|||||||
export interface Config {
|
export interface Config {
|
||||||
channels: number
|
channels: number
|
||||||
sampleRate: number
|
sampleRate: number
|
||||||
|
|
||||||
ring: RingShared
|
ring: RingShared
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import * as Stream from "./stream"
|
import * as Hex from "../common/hex"
|
||||||
import * as Setup from "./setup"
|
|
||||||
import * as Control from "./control"
|
|
||||||
import { Objects } from "./objects"
|
|
||||||
import { Connection } from "./connection"
|
import { Connection } from "./connection"
|
||||||
|
import * as Message from "./message"
|
||||||
|
import { Stream } from "./stream"
|
||||||
|
|
||||||
export interface ClientConfig {
|
export interface ClientConfig {
|
||||||
url: string
|
url: string
|
||||||
|
|
||||||
// Parameters used to create the MoQ session
|
|
||||||
role: Setup.Role
|
|
||||||
|
|
||||||
// If set, the server fingerprint will be fetched from this URL.
|
// If set, the server fingerprint will be fetched from this URL.
|
||||||
// This is required to use self-signed certificates with Chrome (May 2023)
|
// This is required to use self-signed certificates with Chrome (May 2023)
|
||||||
fingerprint?: string
|
fingerprint?: string
|
||||||
@@ -39,28 +35,17 @@ export class Client {
|
|||||||
const quic = new WebTransport(this.config.url, options)
|
const quic = new WebTransport(this.config.url, options)
|
||||||
await quic.ready
|
await quic.ready
|
||||||
|
|
||||||
const stream = await quic.createBidirectionalStream()
|
const client = new Message.SessionClient([Message.Version.FORK_02])
|
||||||
|
const stream = await Stream.open(quic, client)
|
||||||
|
|
||||||
const writer = new Stream.Writer(stream.writable)
|
const server = await Message.SessionServer.decode(stream.reader)
|
||||||
const reader = new Stream.Reader(new Uint8Array(), stream.readable)
|
if (server.version !== Message.Version.FORK_02) {
|
||||||
|
|
||||||
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}`)
|
throw new Error(`unsupported server version: ${server.version}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const control = new Control.Stream(reader, writer)
|
console.log(`established connection: version=${server.version}`)
|
||||||
const objects = new Objects(quic)
|
|
||||||
|
|
||||||
return new Connection(quic, control, objects)
|
return new Connection(quic, stream)
|
||||||
}
|
}
|
||||||
|
|
||||||
async #fetchFingerprint(url?: string): Promise<WebTransportHash | undefined> {
|
async #fetchFingerprint(url?: string): Promise<WebTransportHash | undefined> {
|
||||||
@@ -68,16 +53,11 @@ export class Client {
|
|||||||
|
|
||||||
// TODO remove this fingerprint when Chrome WebTransport accepts the system CA
|
// TODO remove this fingerprint when Chrome WebTransport accepts the system CA
|
||||||
const response = await fetch(url)
|
const response = await fetch(url)
|
||||||
const hexString = await response.text()
|
const bytes = Hex.decode(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 {
|
return {
|
||||||
algorithm: "sha-256",
|
algorithm: "sha-256",
|
||||||
value: hexBytes,
|
value: bytes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
158
packages/moq/transfork/connection.ts
Normal file
158
packages/moq/transfork/connection.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { asError } from "../common/error"
|
||||||
|
import * as Message from "./message"
|
||||||
|
import { Reader, Stream } from "./stream"
|
||||||
|
|
||||||
|
import type { Queue } from "../common/async"
|
||||||
|
import { Closed } from "./error"
|
||||||
|
import type { Track, TrackReader } from "./model"
|
||||||
|
import { Publisher } from "./publisher"
|
||||||
|
import { type Announced, Subscriber } from "./subscriber"
|
||||||
|
|
||||||
|
export class Connection {
|
||||||
|
// The established WebTransport session.
|
||||||
|
#quic: WebTransport
|
||||||
|
|
||||||
|
// Use to receive/send session messages.
|
||||||
|
#session: Stream
|
||||||
|
|
||||||
|
// Module for contributing tracks.
|
||||||
|
#publisher: Publisher
|
||||||
|
|
||||||
|
// Module for distributing tracks.
|
||||||
|
#subscriber: Subscriber
|
||||||
|
|
||||||
|
// Async work running in the background
|
||||||
|
#running: Promise<void>
|
||||||
|
|
||||||
|
constructor(quic: WebTransport, session: Stream) {
|
||||||
|
this.#quic = quic
|
||||||
|
this.#session = session
|
||||||
|
|
||||||
|
this.#publisher = new Publisher(this.#quic)
|
||||||
|
this.#subscriber = new Subscriber(this.#quic)
|
||||||
|
|
||||||
|
this.#running = this.#run()
|
||||||
|
}
|
||||||
|
|
||||||
|
close(code = 0, reason = "") {
|
||||||
|
this.#quic.close({ closeCode: code, reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
async #run(): Promise<void> {
|
||||||
|
const session = this.#runSession().catch((err) => new Error("failed to run session: ", err))
|
||||||
|
const bidis = this.#runBidis().catch((err) => new Error("failed to run bidis: ", err))
|
||||||
|
const unis = this.#runUnis().catch((err) => new Error("failed to run unis: ", err))
|
||||||
|
|
||||||
|
await Promise.all([session, bidis, unis])
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(track: TrackReader) {
|
||||||
|
this.#publisher.publish(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
async announced(prefix: string[] = []): Promise<Queue<Announced>> {
|
||||||
|
return this.#subscriber.announced(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribe(track: Track): Promise<TrackReader> {
|
||||||
|
return await this.#subscriber.subscribe(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runSession() {
|
||||||
|
// Receive messages until the connection is closed.
|
||||||
|
for (;;) {
|
||||||
|
const msg = await Message.SessionInfo.decode_maybe(this.#session.reader)
|
||||||
|
if (!msg) break
|
||||||
|
// TODO use the session info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runBidis() {
|
||||||
|
for (;;) {
|
||||||
|
const next = await Stream.accept(this.#quic)
|
||||||
|
if (!next) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const [msg, stream] = next
|
||||||
|
this.#runBidi(msg, stream).catch((err) => stream.writer.reset(Closed.extract(err)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runBidi(msg: Message.Bi, stream: Stream) {
|
||||||
|
console.debug("received bi stream: ", msg)
|
||||||
|
|
||||||
|
if (msg instanceof Message.SessionClient) {
|
||||||
|
throw new Error("duplicate session stream")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg instanceof Message.AnnounceInterest) {
|
||||||
|
if (!this.#subscriber) {
|
||||||
|
throw new Error("not a subscriber")
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.#publisher.runAnnounce(msg, stream)
|
||||||
|
}
|
||||||
|
if (msg instanceof Message.Subscribe) {
|
||||||
|
if (!this.#publisher) {
|
||||||
|
throw new Error("not a publisher")
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.#publisher.runSubscribe(msg, stream)
|
||||||
|
}
|
||||||
|
if (msg instanceof Message.Datagrams) {
|
||||||
|
if (!this.#publisher) {
|
||||||
|
throw new Error("not a publisher")
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.#publisher.runDatagrams(msg, stream)
|
||||||
|
}
|
||||||
|
if (msg instanceof Message.Fetch) {
|
||||||
|
if (!this.#publisher) {
|
||||||
|
throw new Error("not a publisher")
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.#publisher.runFetch(msg, stream)
|
||||||
|
}
|
||||||
|
if (msg instanceof Message.InfoRequest) {
|
||||||
|
if (!this.#publisher) {
|
||||||
|
throw new Error("not a publisher")
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.#publisher.runInfo(msg, stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runUnis() {
|
||||||
|
for (;;) {
|
||||||
|
const next = await Reader.accept(this.#quic)
|
||||||
|
if (!next) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const [msg, stream] = next
|
||||||
|
this.#runUni(msg, stream).catch((err) => stream.stop(Closed.extract(err)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runUni(msg: Message.Uni, stream: Reader) {
|
||||||
|
console.debug("received uni stream: ", msg)
|
||||||
|
|
||||||
|
if (msg instanceof Message.Group) {
|
||||||
|
if (!this.#subscriber) {
|
||||||
|
throw new Error("not a subscriber")
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.#subscriber.runGroup(msg, stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async closed(): Promise<Error> {
|
||||||
|
try {
|
||||||
|
await this.#running
|
||||||
|
return new Error("closed")
|
||||||
|
} catch (e) {
|
||||||
|
return asError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
packages/moq/transfork/error.ts
Normal file
20
packages/moq/transfork/error.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export class Closed extends Error {
|
||||||
|
readonly code?: number
|
||||||
|
|
||||||
|
constructor(code?: number) {
|
||||||
|
super(`closed code=${code}`)
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
|
||||||
|
static from(err: unknown): Closed {
|
||||||
|
return new Closed(Closed.extract(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
static extract(err: unknown): number {
|
||||||
|
if (err instanceof WebTransportError && err.streamErrorCode !== null) {
|
||||||
|
return err.streamErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
45
packages/moq/transfork/frame.ts
Normal file
45
packages/moq/transfork/frame.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { Reader, Writer } from "./stream"
|
||||||
|
|
||||||
|
export class FrameReader {
|
||||||
|
#stream: Reader
|
||||||
|
|
||||||
|
constructor(stream: Reader) {
|
||||||
|
this.#stream = stream
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the next frame
|
||||||
|
async read(): Promise<Uint8Array | undefined> {
|
||||||
|
if (await this.#stream.done()) return
|
||||||
|
|
||||||
|
const size = await this.#stream.u53()
|
||||||
|
const payload = await this.#stream.read(size)
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(code: number) {
|
||||||
|
await this.#stream.stop(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FrameWriter {
|
||||||
|
#stream: Writer
|
||||||
|
|
||||||
|
constructor(stream: Writer) {
|
||||||
|
this.#stream = stream
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes the next frame
|
||||||
|
async write(payload: Uint8Array) {
|
||||||
|
await this.#stream.u53(payload.byteLength)
|
||||||
|
await this.#stream.write(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
await this.#stream.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async reset(code: number) {
|
||||||
|
await this.#stream.reset(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,5 +3,5 @@ export type { ClientConfig } from "./client"
|
|||||||
|
|
||||||
export { Connection } from "./connection"
|
export { Connection } from "./connection"
|
||||||
|
|
||||||
export { SubscribeRecv, AnnounceSend } from "./publisher"
|
export { Track, Group } from "./model"
|
||||||
export { AnnounceRecv, SubscribeSend } from "./subscriber"
|
export { Announced } from "./subscriber"
|
||||||
428
packages/moq/transfork/message.ts
Normal file
428
packages/moq/transfork/message.ts
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
import type { Reader, Writer } from "./stream"
|
||||||
|
|
||||||
|
export enum Version {
|
||||||
|
DRAFT_00 = 0xff000000,
|
||||||
|
DRAFT_01 = 0xff000001,
|
||||||
|
DRAFT_02 = 0xff000002,
|
||||||
|
DRAFT_03 = 0xff000003,
|
||||||
|
FORK_00 = 0xff0bad00,
|
||||||
|
FORK_01 = 0xff0bad01,
|
||||||
|
FORK_02 = 0xff0bad02,
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Extensions {
|
||||||
|
entries: Map<bigint, Uint8Array>
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.entries = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
set(id: bigint, value: Uint8Array) {
|
||||||
|
this.entries.set(id, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: bigint): Uint8Array | undefined {
|
||||||
|
return this.entries.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: bigint): Uint8Array | undefined {
|
||||||
|
const value = this.entries.get(id)
|
||||||
|
this.entries.delete(id)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
async encode(w: Writer) {
|
||||||
|
await w.u53(this.entries.size)
|
||||||
|
for (const [id, value] of this.entries) {
|
||||||
|
await w.u62(id)
|
||||||
|
await w.u53(value.length)
|
||||||
|
await w.write(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode(r: Reader): Promise<Extensions> {
|
||||||
|
const count = await r.u53()
|
||||||
|
const params = new Extensions()
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const id = await r.u62()
|
||||||
|
const size = await r.u53()
|
||||||
|
const value = await r.read(size)
|
||||||
|
|
||||||
|
if (params.entries.has(id)) {
|
||||||
|
throw new Error(`duplicate parameter id: ${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
params.entries.set(id, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Order {
|
||||||
|
Any = 0,
|
||||||
|
Ascending = 1,
|
||||||
|
Descending = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionClient {
|
||||||
|
versions: Version[]
|
||||||
|
extensions: Extensions
|
||||||
|
|
||||||
|
static StreamID = 0x0
|
||||||
|
|
||||||
|
constructor(versions: Version[], extensions = new Extensions()) {
|
||||||
|
this.versions = versions
|
||||||
|
this.extensions = extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
async encode(w: Writer) {
|
||||||
|
await w.u53(this.versions.length)
|
||||||
|
for (const v of this.versions) {
|
||||||
|
await w.u53(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.extensions.encode(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode(r: Reader): Promise<SessionClient> {
|
||||||
|
const versions = []
|
||||||
|
const count = await r.u53()
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
versions.push(await r.u53())
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensions = await Extensions.decode(r)
|
||||||
|
return new SessionClient(versions, extensions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionServer {
|
||||||
|
version: Version
|
||||||
|
extensions: Extensions
|
||||||
|
|
||||||
|
constructor(version: Version, extensions = new Extensions()) {
|
||||||
|
this.version = version
|
||||||
|
this.extensions = extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
async encode(w: Writer) {
|
||||||
|
await w.u53(this.version)
|
||||||
|
await this.extensions.encode(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode(r: Reader): Promise<SessionServer> {
|
||||||
|
const version = await r.u53()
|
||||||
|
const extensions = await Extensions.decode(r)
|
||||||
|
return new SessionServer(version, extensions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionInfo {
|
||||||
|
bitrate: number
|
||||||
|
|
||||||
|
constructor(bitrate: number) {
|
||||||
|
this.bitrate = bitrate
|
||||||
|
}
|
||||||
|
|
||||||
|
async encode(w: Writer) {
|
||||||
|
await w.u53(this.bitrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode(r: Reader): Promise<SessionInfo> {
|
||||||
|
const bitrate = await r.u53()
|
||||||
|
return new SessionInfo(bitrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode_maybe(r: Reader): Promise<SessionInfo | undefined> {
|
||||||
|
if (await r.done()) return
|
||||||
|
return await SessionInfo.decode(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnnounceStatus = "active" | "closed"
|
||||||
|
|
||||||
|
export class Announce {
|
||||||
|
suffix: string[]
|
||||||
|
status: AnnounceStatus
|
||||||
|
|
||||||
|
constructor(suffix: string[], status: AnnounceStatus) {
|
||||||
|
this.suffix = suffix
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
async encode(w: Writer) {
|
||||||
|
await w.u53(this.status === "active" ? 1 : 0)
|
||||||
|
await w.path(this.suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode(r: Reader): Promise<Announce> {
|
||||||
|
const status = (await r.u53()) === 1 ? "active" : "closed"
|
||||||
|
const suffix = await r.path()
|
||||||
|
return new Announce(suffix, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode_maybe(r: Reader): Promise<Announce | undefined> {
|
||||||
|
if (await r.done()) return
|
||||||
|
return await Announce.decode(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnnounceInterest {
|
||||||
|
static StreamID = 0x1
|
||||||
|
|
||||||
|
constructor(public prefix: string[]) {}
|
||||||
|
|
||||||
|
async encode(w: Writer) {
|
||||||
|
await w.path(this.prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode(r: Reader): Promise<AnnounceInterest> {
|
||||||
|
const prefix = await r.path()
|
||||||
|
return new AnnounceInterest(prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubscribeUpdate {
|
||||||
|
priority: number
|
||||||
|
order = Order.Any
|
||||||
|
expires = 0 // ms
|
||||||
|
|
||||||
|
start?: bigint
|
||||||
|
end?: bigint
|
||||||
|
|
||||||
|
constructor(priority: number) {
|
||||||
|
this.priority = priority
|
||||||
|
}
|
||||||
|
|
||||||
|
async encode(w: Writer) {
|
||||||
|
await w.u53(this.priority)
|
||||||
|
await w.u53(this.order)
|
||||||
|
await w.u53(this.expires)
|
||||||
|
await w.u62(this.start ? this.start + 1n : 0n)
|
||||||
|
await w.u62(this.end ? this.end + 1n : 0n)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode(r: Reader): Promise<SubscribeUpdate> {
|
||||||
|
const priority = await r.u53()
|
||||||
|
const order = await r.u53()
|
||||||
|
if (order > 2) {
|
||||||
|
throw new Error(`invalid order: ${order}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const expires = await r.u53()
|
||||||
|
const start = await r.u62()
|
||||||
|
const end = await r.u62()
|
||||||
|
|
||||||
|
const update = new SubscribeUpdate(priority)
|
||||||
|
update.order = order
|
||||||
|
update.expires = expires
|
||||||
|
update.start = start === 0n ? undefined : start - 1n
|
||||||
|
update.end = end === 0n ? undefined : end - 1n
|
||||||
|
|
||||||
|
return update
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode_maybe(r: Reader): Promise<SubscribeUpdate | undefined> {
|
||||||
|
if (await r.done()) return
|
||||||
|
return await SubscribeUpdate.decode(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Subscribe extends SubscribeUpdate {
|
||||||
|
id: bigint
|
||||||
|
path: string[]
|
||||||
|
|
||||||
|
static StreamID = 0x2
|
||||||
|
|
||||||
|
constructor(id: bigint, path: string[], priority: number) {
|
||||||
|
super(priority)
|
||||||
|
|
||||||
|
this.id = id
|
||||||
|
this.path = path
|
||||||
|
}
|
||||||
|
|
||||||
|
async encode(w: Writer) {
|
||||||
|
await w.u62(this.id)
|
||||||
|
await w.path(this.path)
|
||||||
|
await super.encode(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode(r: Reader): Promise<Subscribe> {
|
||||||
|
const id = await r.u62()
|
||||||
|
const path = await r.path()
|
||||||
|
const update = await SubscribeUpdate.decode(r)
|
||||||
|
|
||||||
|
const subscribe = new Subscribe(id, path, update.priority)
|
||||||
|
subscribe.order = update.order
|
||||||
|
subscribe.expires = update.expires
|
||||||
|
subscribe.start = update.start
|
||||||
|
subscribe.end = update.end
|
||||||
|
|
||||||
|
return subscribe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Datagrams extends Subscribe {
|
||||||
|
static StreamID = 0x3
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Info {
|
||||||
|
priority: number
|
||||||
|
order = Order.Descending
|
||||||
|
expires = 0
|
||||||
|
latest?: number
|
||||||
|
|
||||||
|
constructor(priority: number) {
|
||||||
|
this.priority = priority
|
||||||
|
}
|
||||||
|
|
||||||
|
async encode(w: Writer) {
|
||||||
|
await w.u53(this.priority)
|
||||||
|
await w.u53(this.order)
|
||||||
|
await w.u53(this.expires)
|
||||||
|
await w.u53(this.latest ? this.latest + 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode(r: Reader): Promise<Info> {
|
||||||
|
const priority = await r.u53()
|
||||||
|
const order = await r.u53()
|
||||||
|
const latest = await r.u53()
|
||||||
|
|
||||||
|
const info = new Info(priority)
|
||||||
|
info.latest = latest === 0 ? undefined : latest - 1
|
||||||
|
info.order = order
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InfoRequest {
|
||||||
|
path: string[]
|
||||||
|
|
||||||
|
static StreamID = 0x5
|
||||||
|
|
||||||
|
constructor(path: string[]) {
|
||||||
|
this.path = path
|
||||||
|
}
|
||||||
|
|
||||||
|
async encode(w: Writer) {
|
||||||
|
await w.path(this.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode(r: Reader): Promise<InfoRequest> {
|
||||||
|
const path = await r.path()
|
||||||
|
return new InfoRequest(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FetchUpdate {
|
||||||
|
priority: number
|
||||||
|
|
||||||
|
constructor(priority: number) {
|
||||||
|
this.priority = priority
|
||||||
|
}
|
||||||
|
|
||||||
|
async encode(w: Writer) {
|
||||||
|
await w.u53(this.priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode(r: Reader): Promise<FetchUpdate> {
|
||||||
|
return new FetchUpdate(await r.u53())
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode_maybe(r: Reader): Promise<FetchUpdate | undefined> {
|
||||||
|
if (await r.done()) return
|
||||||
|
return await FetchUpdate.decode(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Fetch extends FetchUpdate {
|
||||||
|
path: string[]
|
||||||
|
|
||||||
|
static StreamID = 0x4
|
||||||
|
|
||||||
|
constructor(path: string[], priority: number) {
|
||||||
|
super(priority)
|
||||||
|
this.path = path
|
||||||
|
}
|
||||||
|
|
||||||
|
async encode(w: Writer) {
|
||||||
|
await w.path(this.path)
|
||||||
|
await super.encode(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode(r: Reader): Promise<Fetch> {
|
||||||
|
const path = await r.path()
|
||||||
|
const update = await FetchUpdate.decode(r)
|
||||||
|
|
||||||
|
const fetch = new Fetch(path, update.priority)
|
||||||
|
return fetch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Group {
|
||||||
|
subscribe: bigint
|
||||||
|
sequence: number
|
||||||
|
|
||||||
|
static StreamID = 0x0
|
||||||
|
|
||||||
|
constructor(subscribe: bigint, sequence: number) {
|
||||||
|
this.subscribe = subscribe
|
||||||
|
this.sequence = sequence
|
||||||
|
}
|
||||||
|
|
||||||
|
async encode(w: Writer) {
|
||||||
|
await w.u62(this.subscribe)
|
||||||
|
await w.u53(this.sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode(r: Reader): Promise<Group> {
|
||||||
|
return new Group(await r.u62(), await r.u53())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GroupDrop {
|
||||||
|
sequence: number
|
||||||
|
count: number
|
||||||
|
error: number
|
||||||
|
|
||||||
|
constructor(sequence: number, count: number, error: number) {
|
||||||
|
this.sequence = sequence
|
||||||
|
this.count = count
|
||||||
|
this.error = error
|
||||||
|
}
|
||||||
|
|
||||||
|
async encode(w: Writer) {
|
||||||
|
await w.u53(this.sequence)
|
||||||
|
await w.u53(this.count)
|
||||||
|
await w.u53(this.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode(r: Reader): Promise<GroupDrop> {
|
||||||
|
return new GroupDrop(await r.u53(), await r.u53(), await r.u53())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Frame {
|
||||||
|
payload: Uint8Array
|
||||||
|
|
||||||
|
constructor(payload: Uint8Array) {
|
||||||
|
this.payload = payload
|
||||||
|
}
|
||||||
|
|
||||||
|
async encode(w: Writer) {
|
||||||
|
await w.u53(this.payload.byteLength)
|
||||||
|
await w.write(this.payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async decode(r: Reader): Promise<Frame> {
|
||||||
|
const size = await r.u53()
|
||||||
|
const payload = await r.read(size)
|
||||||
|
return new Frame(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Bi = SessionClient | AnnounceInterest | Subscribe | Datagrams | Fetch | InfoRequest
|
||||||
|
export type Uni = Group
|
||||||
170
packages/moq/transfork/model.ts
Normal file
170
packages/moq/transfork/model.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { Watch } from "../common/async"
|
||||||
|
import { Closed } from "./error"
|
||||||
|
import { Order } from "./message"
|
||||||
|
|
||||||
|
export class Track {
|
||||||
|
readonly path: string[]
|
||||||
|
readonly priority: number
|
||||||
|
order = Order.Any
|
||||||
|
|
||||||
|
// TODO use an array
|
||||||
|
latest = new Watch<GroupReader | undefined>(undefined)
|
||||||
|
|
||||||
|
readers = 0
|
||||||
|
closed?: Closed
|
||||||
|
|
||||||
|
constructor(path: string[], priority: number) {
|
||||||
|
this.path = path
|
||||||
|
this.priority = priority
|
||||||
|
}
|
||||||
|
|
||||||
|
appendGroup(): Group {
|
||||||
|
const next = this.latest.value()[0]?.id ?? 0
|
||||||
|
return this.createGroup(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
createGroup(sequence: number): Group {
|
||||||
|
if (this.closed) throw this.closed
|
||||||
|
|
||||||
|
const group = new Group(sequence)
|
||||||
|
const [current, _] = this.latest.value()
|
||||||
|
|
||||||
|
// TODO use an array
|
||||||
|
if (!current || current.id < sequence) {
|
||||||
|
const reader = new GroupReader(group)
|
||||||
|
this.latest.update(reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
close(closed = new Closed()) {
|
||||||
|
if (this.closed) return
|
||||||
|
this.closed = closed
|
||||||
|
this.latest.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
reader(): TrackReader {
|
||||||
|
// VERY important that readers are closed to decrement the count
|
||||||
|
this.readers += 1
|
||||||
|
return new TrackReader(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TrackReader {
|
||||||
|
latest?: number
|
||||||
|
#track: Track
|
||||||
|
|
||||||
|
constructor(track: Track) {
|
||||||
|
this.#track = track
|
||||||
|
}
|
||||||
|
|
||||||
|
async nextGroup(): Promise<GroupReader | undefined> {
|
||||||
|
let [current, next] = this.#track.latest.value()
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
if (current && this.latest !== current.id) {
|
||||||
|
this.latest = current.id
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#track.closed) throw this.#track.closed
|
||||||
|
|
||||||
|
if (!next) return
|
||||||
|
;[current, next] = await next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get path() {
|
||||||
|
return this.#track.path
|
||||||
|
}
|
||||||
|
|
||||||
|
get order() {
|
||||||
|
return this.#track.order
|
||||||
|
}
|
||||||
|
|
||||||
|
get priority() {
|
||||||
|
return this.#track.priority
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.#track.readers -= 1
|
||||||
|
if (this.#track.readers <= 0) this.#track.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Group {
|
||||||
|
readonly id: number
|
||||||
|
|
||||||
|
chunks = new Watch<Uint8Array[]>([])
|
||||||
|
readers = 0
|
||||||
|
closed?: Closed
|
||||||
|
|
||||||
|
constructor(id: number) {
|
||||||
|
this.id = id
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFrame(frame: Uint8Array) {
|
||||||
|
if (this.closed) throw this.closed
|
||||||
|
this.chunks.update((chunks) => [...chunks, frame])
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFrames(...frames: Uint8Array[]) {
|
||||||
|
if (this.closed) throw this.closed
|
||||||
|
this.chunks.update((chunks) => [...chunks, ...frames])
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
reader(): GroupReader {
|
||||||
|
this.readers += 1
|
||||||
|
return new GroupReader(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
get length(): number {
|
||||||
|
return this.chunks.value()[0].length
|
||||||
|
}
|
||||||
|
|
||||||
|
close(closed = new Closed()) {
|
||||||
|
if (this.closed) return
|
||||||
|
this.closed = closed
|
||||||
|
this.chunks.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GroupReader {
|
||||||
|
#group: Group
|
||||||
|
#index = 0
|
||||||
|
|
||||||
|
constructor(group: Group) {
|
||||||
|
this.#group = group
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFrame(): Promise<Uint8Array | undefined> {
|
||||||
|
let [chunks, next] = this.#group.chunks.value()
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
if (this.#index < chunks.length) {
|
||||||
|
this.#index += 1
|
||||||
|
return chunks[this.#index - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#group.closed) throw this.#group.closed
|
||||||
|
|
||||||
|
if (!next) return
|
||||||
|
;[chunks, next] = await next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get index(): number {
|
||||||
|
return this.#index
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): number {
|
||||||
|
return this.#group.id
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.#group.readers -= 1
|
||||||
|
if (this.#group.readers <= 0) this.#group.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
173
packages/moq/transfork/publisher.ts
Normal file
173
packages/moq/transfork/publisher.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { Watch } from "../common/async"
|
||||||
|
import { Closed } from "./error"
|
||||||
|
import * as Message from "./message"
|
||||||
|
import type { GroupReader, TrackReader } from "./model"
|
||||||
|
import { type Stream, Writer } from "./stream"
|
||||||
|
|
||||||
|
export class Publisher {
|
||||||
|
#quic: WebTransport
|
||||||
|
|
||||||
|
// Our announced broadcasts.
|
||||||
|
#announce = new Map<string[], TrackReader>()
|
||||||
|
|
||||||
|
// Their subscribed tracks.
|
||||||
|
#subscribe = new Map<bigint, Subscribed>()
|
||||||
|
|
||||||
|
constructor(quic: WebTransport) {
|
||||||
|
this.#quic = quic
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish a track
|
||||||
|
publish(track: TrackReader) {
|
||||||
|
if (this.#announce.has(track.path)) {
|
||||||
|
throw new Error(`already announced: ${track.path.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#announce.set(track.path, track)
|
||||||
|
|
||||||
|
// TODO: clean up announcements
|
||||||
|
// track.closed().then(() => this.#announce.delete(track.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#get(path: string[]): TrackReader | undefined {
|
||||||
|
return this.#announce.get(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async runAnnounce(msg: Message.AnnounceInterest, stream: Stream) {
|
||||||
|
for (const announce of this.#announce.values()) {
|
||||||
|
if (announce.path.length < msg.prefix.length) continue
|
||||||
|
|
||||||
|
const prefix = announce.path.slice(0, msg.prefix.length)
|
||||||
|
if (prefix !== msg.prefix) continue
|
||||||
|
|
||||||
|
const suffix = announce.path.slice(msg.prefix.length)
|
||||||
|
|
||||||
|
const active = new Message.Announce(suffix, "active")
|
||||||
|
await active.encode(stream.writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO support updates.
|
||||||
|
// Until then, just keep the stream open.
|
||||||
|
await stream.reader.closed()
|
||||||
|
}
|
||||||
|
|
||||||
|
async runSubscribe(msg: Message.Subscribe, stream: Stream) {
|
||||||
|
if (this.#subscribe.has(msg.id)) {
|
||||||
|
throw new Error(`duplicate subscribe for id: ${msg.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = this.#get(msg.path)
|
||||||
|
if (!track) {
|
||||||
|
await stream.writer.reset(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribe = new Subscribed(msg, track, this.#quic)
|
||||||
|
|
||||||
|
// TODO close the stream when done
|
||||||
|
subscribe.run().catch((err) => console.warn("failed to run subscribe: ", err))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = new Message.Info(track.priority)
|
||||||
|
info.order = track.order
|
||||||
|
info.latest = track.latest
|
||||||
|
await info.encode(stream.writer)
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
// TODO try_decode
|
||||||
|
const update = await Message.SubscribeUpdate.decode_maybe(stream.reader)
|
||||||
|
if (!update) {
|
||||||
|
subscribe.close()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO use the update
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
subscribe.close(Closed.from(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runDatagrams(msg: Message.Datagrams, stream: Stream) {
|
||||||
|
await stream.writer.reset(501)
|
||||||
|
throw new Error("datagrams not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
async runFetch(msg: Message.Fetch, stream: Stream) {
|
||||||
|
await stream.writer.reset(501)
|
||||||
|
throw new Error("fetch not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
async runInfo(msg: Message.InfoRequest, stream: Stream) {
|
||||||
|
const track = this.#get(msg.path)
|
||||||
|
if (!track) {
|
||||||
|
await stream.writer.reset(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = new Message.Info(track.priority)
|
||||||
|
info.order = track.order
|
||||||
|
info.latest = track.latest
|
||||||
|
|
||||||
|
await info.encode(stream.writer)
|
||||||
|
|
||||||
|
throw new Error("info not implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Subscribed {
|
||||||
|
#id: bigint
|
||||||
|
#track: TrackReader
|
||||||
|
#quic: WebTransport
|
||||||
|
|
||||||
|
#closed = new Watch<Closed | undefined>(undefined)
|
||||||
|
|
||||||
|
constructor(msg: Message.Subscribe, track: TrackReader, quic: WebTransport) {
|
||||||
|
this.#id = msg.id
|
||||||
|
this.#track = track
|
||||||
|
this.#quic = quic
|
||||||
|
}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
const closed = this.closed()
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const [group, done] = await Promise.all([this.#track.nextGroup(), closed])
|
||||||
|
if (done) return
|
||||||
|
if (!group) break
|
||||||
|
|
||||||
|
this.#runGroup(group).catch((err) => console.warn("failed to run group: ", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO wait until all groups are done
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async #runGroup(group: GroupReader) {
|
||||||
|
const msg = new Message.Group(this.#id, group.id)
|
||||||
|
const stream = await Writer.open(this.#quic, msg)
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const frame = await group.readFrame()
|
||||||
|
if (!frame) break
|
||||||
|
|
||||||
|
await stream.u53(frame.byteLength)
|
||||||
|
await stream.write(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(err = new Closed()) {
|
||||||
|
this.#closed.update(err)
|
||||||
|
this.#track.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async closed(): Promise<Closed> {
|
||||||
|
let [closed, next] = this.#closed.value()
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
if (closed !== undefined) return closed
|
||||||
|
if (!next) return new Closed()
|
||||||
|
;[closed, next] = await next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,93 @@
|
|||||||
const MAX_U6 = Math.pow(2, 6) - 1
|
import * as Message from "./message"
|
||||||
const MAX_U14 = Math.pow(2, 14) - 1
|
|
||||||
const MAX_U30 = Math.pow(2, 30) - 1
|
const MAX_U6 = 2 ** 6 - 1
|
||||||
const MAX_U31 = Math.pow(2, 31) - 1
|
const MAX_U14 = 2 ** 14 - 1
|
||||||
|
const MAX_U30 = 2 ** 30 - 1
|
||||||
|
const MAX_U31 = 2 ** 31 - 1
|
||||||
const MAX_U53 = Number.MAX_SAFE_INTEGER
|
const MAX_U53 = Number.MAX_SAFE_INTEGER
|
||||||
const MAX_U62: bigint = 2n ** 62n - 1n
|
const MAX_U62: bigint = 2n ** 62n - 1n
|
||||||
|
|
||||||
|
export class Stream {
|
||||||
|
reader: Reader
|
||||||
|
writer: Writer
|
||||||
|
|
||||||
|
constructor(props: {
|
||||||
|
writable: WritableStream<Uint8Array>
|
||||||
|
readable: ReadableStream<Uint8Array>
|
||||||
|
}) {
|
||||||
|
this.writer = new Writer(props.writable)
|
||||||
|
this.reader = new Reader(props.readable)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async accept(quic: WebTransport): Promise<[Message.Bi, Stream] | undefined> {
|
||||||
|
const reader = quic.incomingBidirectionalStreams.getReader()
|
||||||
|
const next = await reader.read()
|
||||||
|
reader.releaseLock()
|
||||||
|
|
||||||
|
if (next.done) return
|
||||||
|
const stream = new Stream(next.value)
|
||||||
|
let msg: Message.Bi
|
||||||
|
|
||||||
|
const typ = await stream.reader.u8()
|
||||||
|
if (typ === Message.SessionClient.StreamID) {
|
||||||
|
msg = await Message.SessionClient.decode(stream.reader)
|
||||||
|
} else if (typ === Message.AnnounceInterest.StreamID) {
|
||||||
|
msg = await Message.AnnounceInterest.decode(stream.reader)
|
||||||
|
} else if (typ === Message.Subscribe.StreamID) {
|
||||||
|
msg = await Message.Subscribe.decode(stream.reader)
|
||||||
|
} else if (typ === Message.Datagrams.StreamID) {
|
||||||
|
msg = await Message.Datagrams.decode(stream.reader)
|
||||||
|
} else if (typ === Message.Fetch.StreamID) {
|
||||||
|
msg = await Message.Fetch.decode(stream.reader)
|
||||||
|
} else if (typ === Message.InfoRequest.StreamID) {
|
||||||
|
msg = await Message.InfoRequest.decode(stream.reader)
|
||||||
|
} else {
|
||||||
|
throw new Error(`unknown stream type: ${typ}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug("accepted stream", msg)
|
||||||
|
|
||||||
|
return [msg, stream]
|
||||||
|
}
|
||||||
|
|
||||||
|
static async open(quic: WebTransport, msg: Message.Bi): Promise<Stream> {
|
||||||
|
const stream = new Stream(await quic.createBidirectionalStream())
|
||||||
|
|
||||||
|
if (msg instanceof Message.SessionClient) {
|
||||||
|
await stream.writer.u8(Message.SessionClient.StreamID)
|
||||||
|
} else if (msg instanceof Message.AnnounceInterest) {
|
||||||
|
await stream.writer.u8(Message.AnnounceInterest.StreamID)
|
||||||
|
} else if (msg instanceof Message.Subscribe) {
|
||||||
|
await stream.writer.u8(Message.Subscribe.StreamID)
|
||||||
|
} else if (msg instanceof Message.Datagrams) {
|
||||||
|
await stream.writer.u8(Message.Datagrams.StreamID)
|
||||||
|
} else if (msg instanceof Message.Fetch) {
|
||||||
|
await stream.writer.u8(Message.Fetch.StreamID)
|
||||||
|
} else if (msg instanceof Message.InfoRequest) {
|
||||||
|
await stream.writer.u8(Message.InfoRequest.StreamID)
|
||||||
|
} else {
|
||||||
|
// Make sure we're not missing any types.
|
||||||
|
const _: never = msg
|
||||||
|
throw new Error("invalid message type")
|
||||||
|
}
|
||||||
|
|
||||||
|
await msg.encode(stream.writer)
|
||||||
|
|
||||||
|
console.debug("opened stream", msg)
|
||||||
|
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(code?: number) {
|
||||||
|
if (code === undefined) {
|
||||||
|
await this.writer.close()
|
||||||
|
} else {
|
||||||
|
await this.writer.reset(code)
|
||||||
|
await this.reader.stop(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reader wraps a stream and provides convience methods for reading pieces from a stream
|
// 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.
|
// Unfortunately we can't use a BYOB reader because it's not supported with WebTransport+WebWorkers yet.
|
||||||
export class Reader {
|
export class Reader {
|
||||||
@@ -12,7 +95,7 @@ export class Reader {
|
|||||||
#stream: ReadableStream<Uint8Array>
|
#stream: ReadableStream<Uint8Array>
|
||||||
#reader: ReadableStreamDefaultReader<Uint8Array>
|
#reader: ReadableStreamDefaultReader<Uint8Array>
|
||||||
|
|
||||||
constructor(buffer: Uint8Array, stream: ReadableStream<Uint8Array>) {
|
constructor(stream: ReadableStream<Uint8Array>, buffer = new Uint8Array()) {
|
||||||
this.#buffer = buffer
|
this.#buffer = buffer
|
||||||
this.#stream = stream
|
this.#stream = stream
|
||||||
this.#reader = this.#stream.getReader()
|
this.#reader = this.#stream.getReader()
|
||||||
@@ -27,7 +110,7 @@ export class Reader {
|
|||||||
|
|
||||||
const buffer = new Uint8Array(result.value)
|
const buffer = new Uint8Array(result.value)
|
||||||
|
|
||||||
if (this.#buffer.byteLength == 0) {
|
if (this.#buffer.byteLength === 0) {
|
||||||
this.#buffer = buffer
|
this.#buffer = buffer
|
||||||
} else {
|
} else {
|
||||||
const temp = new Uint8Array(this.#buffer.byteLength + buffer.byteLength)
|
const temp = new Uint8Array(this.#buffer.byteLength + buffer.byteLength)
|
||||||
@@ -57,14 +140,13 @@ export class Reader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async read(size: number): Promise<Uint8Array> {
|
async read(size: number): Promise<Uint8Array> {
|
||||||
if (size == 0) return new Uint8Array()
|
if (size === 0) return new Uint8Array()
|
||||||
|
|
||||||
await this.#fillTo(size)
|
await this.#fillTo(size)
|
||||||
return this.#slice(size)
|
return this.#slice(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
async readAll(): Promise<Uint8Array> {
|
async readAll(): Promise<Uint8Array> {
|
||||||
// eslint-disable-next-line no-empty
|
|
||||||
while (await this.#fill()) {}
|
while (await this.#fill()) {}
|
||||||
return this.#slice(this.#buffer.byteLength)
|
return this.#slice(this.#buffer.byteLength)
|
||||||
}
|
}
|
||||||
@@ -79,6 +161,17 @@ export class Reader {
|
|||||||
return new TextDecoder().decode(buffer)
|
return new TextDecoder().decode(buffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async path(): Promise<string[]> {
|
||||||
|
const parts = await this.u53()
|
||||||
|
const path = []
|
||||||
|
|
||||||
|
for (let i = 0; i < parts; i++) {
|
||||||
|
path.push(await this.string())
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
async u8(): Promise<number> {
|
async u8(): Promise<number> {
|
||||||
await this.#fillTo(1)
|
await this.#fillTo(1)
|
||||||
return this.#slice(1)[0]
|
return this.#slice(1)[0]
|
||||||
@@ -99,30 +192,29 @@ export class Reader {
|
|||||||
await this.#fillTo(1)
|
await this.#fillTo(1)
|
||||||
const size = (this.#buffer[0] & 0xc0) >> 6
|
const size = (this.#buffer[0] & 0xc0) >> 6
|
||||||
|
|
||||||
if (size == 0) {
|
if (size === 0) {
|
||||||
const first = this.#slice(1)[0]
|
const first = this.#slice(1)[0]
|
||||||
return BigInt(first) & 0x3fn
|
return BigInt(first) & 0x3fn
|
||||||
} else if (size == 1) {
|
}
|
||||||
|
if (size === 1) {
|
||||||
await this.#fillTo(2)
|
await this.#fillTo(2)
|
||||||
const slice = this.#slice(2)
|
const slice = this.#slice(2)
|
||||||
const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength)
|
const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength)
|
||||||
|
|
||||||
return BigInt(view.getInt16(0)) & 0x3fffn
|
return BigInt(view.getInt16(0)) & 0x3fffn
|
||||||
} else if (size == 2) {
|
}
|
||||||
|
if (size === 2) {
|
||||||
await this.#fillTo(4)
|
await this.#fillTo(4)
|
||||||
const slice = this.#slice(4)
|
const slice = this.#slice(4)
|
||||||
const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength)
|
const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength)
|
||||||
|
|
||||||
return BigInt(view.getUint32(0)) & 0x3fffffffn
|
return BigInt(view.getUint32(0)) & 0x3fffffffn
|
||||||
} else if (size == 3) {
|
}
|
||||||
await this.#fillTo(8)
|
await this.#fillTo(8)
|
||||||
const slice = this.#slice(8)
|
const slice = this.#slice(8)
|
||||||
const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength)
|
const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength)
|
||||||
|
|
||||||
return view.getBigUint64(0) & 0x3fffffffffffffffn
|
return view.getBigUint64(0) & 0x3fffffffffffffffn
|
||||||
} else {
|
|
||||||
throw new Error("impossible")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async done(): Promise<boolean> {
|
async done(): Promise<boolean> {
|
||||||
@@ -130,15 +222,38 @@ export class Reader {
|
|||||||
return !(await this.#fill())
|
return !(await this.#fill())
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async stop(code: number) {
|
||||||
this.#reader.releaseLock()
|
this.#reader.releaseLock()
|
||||||
await this.#stream.cancel()
|
await this.#stream.cancel(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
async closed() {
|
||||||
|
return this.#reader.closed
|
||||||
}
|
}
|
||||||
|
|
||||||
release(): [Uint8Array, ReadableStream<Uint8Array>] {
|
release(): [Uint8Array, ReadableStream<Uint8Array>] {
|
||||||
this.#reader.releaseLock()
|
this.#reader.releaseLock()
|
||||||
return [this.#buffer, this.#stream]
|
return [this.#buffer, this.#stream]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async accept(quic: WebTransport): Promise<[Message.Group, Reader] | undefined> {
|
||||||
|
const reader = quic.incomingUnidirectionalStreams.getReader()
|
||||||
|
const next = await reader.read()
|
||||||
|
reader.releaseLock()
|
||||||
|
|
||||||
|
if (next.done) return
|
||||||
|
const stream = new Reader(next.value)
|
||||||
|
let msg: Message.Uni
|
||||||
|
|
||||||
|
const typ = await stream.u8()
|
||||||
|
if (typ === Message.Group.StreamID) {
|
||||||
|
msg = await Message.Group.decode(stream)
|
||||||
|
} else {
|
||||||
|
throw new Error(`unknown stream type: ${typ}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [msg, stream]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Writer wraps a stream and writes chunks of data
|
// Writer wraps a stream and writes chunks of data
|
||||||
@@ -170,7 +285,8 @@ export class Writer {
|
|||||||
async u53(v: number) {
|
async u53(v: number) {
|
||||||
if (v < 0) {
|
if (v < 0) {
|
||||||
throw new Error(`underflow, value is negative: ${v}`)
|
throw new Error(`underflow, value is negative: ${v}`)
|
||||||
} else if (v > MAX_U53) {
|
}
|
||||||
|
if (v > MAX_U53) {
|
||||||
throw new Error(`overflow, value larger than 53-bits: ${v}`)
|
throw new Error(`overflow, value larger than 53-bits: ${v}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +296,8 @@ export class Writer {
|
|||||||
async u62(v: bigint) {
|
async u62(v: bigint) {
|
||||||
if (v < 0) {
|
if (v < 0) {
|
||||||
throw new Error(`underflow, value is negative: ${v}`)
|
throw new Error(`underflow, value is negative: ${v}`)
|
||||||
} else if (v >= MAX_U62) {
|
}
|
||||||
|
if (v >= MAX_U62) {
|
||||||
throw new Error(`overflow, value larger than 62-bits: ${v}`)
|
throw new Error(`overflow, value larger than 62-bits: ${v}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,72 +314,102 @@ export class Writer {
|
|||||||
await this.write(data)
|
await this.write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async path(path: string[]) {
|
||||||
|
await this.u53(path.length)
|
||||||
|
for (const part of path) {
|
||||||
|
await this.string(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
this.#writer.releaseLock()
|
this.#writer.releaseLock()
|
||||||
await this.#stream.close()
|
await this.#stream.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reset(code: number) {
|
||||||
|
this.#writer.releaseLock()
|
||||||
|
await this.#stream.abort(code)
|
||||||
|
}
|
||||||
|
|
||||||
release(): WritableStream<Uint8Array> {
|
release(): WritableStream<Uint8Array> {
|
||||||
this.#writer.releaseLock()
|
this.#writer.releaseLock()
|
||||||
return this.#stream
|
return this.#stream
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async open(quic: WebTransport, msg: Message.Uni): Promise<Writer> {
|
||||||
|
const stream = new Writer(await quic.createUnidirectionalStream())
|
||||||
|
|
||||||
|
if (msg instanceof Message.Group) {
|
||||||
|
await stream.u8(Message.Group.StreamID)
|
||||||
|
} else {
|
||||||
|
// Make sure we're not missing any types.
|
||||||
|
const _: never = msg
|
||||||
|
throw new Error("invalid message type")
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUint8(dst: Uint8Array, v: number): Uint8Array {
|
export function setUint8(dst: Uint8Array, v: number): Uint8Array {
|
||||||
dst[0] = v
|
dst[0] = v
|
||||||
return dst.slice(0, 1)
|
return dst.slice(0, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUint16(dst: Uint8Array, v: number): Uint8Array {
|
export function setUint16(dst: Uint8Array, v: number): Uint8Array {
|
||||||
const view = new DataView(dst.buffer, dst.byteOffset, 2)
|
const view = new DataView(dst.buffer, dst.byteOffset, 2)
|
||||||
view.setUint16(0, v)
|
view.setUint16(0, v)
|
||||||
|
|
||||||
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setInt32(dst: Uint8Array, v: number): Uint8Array {
|
export function setInt32(dst: Uint8Array, v: number): Uint8Array {
|
||||||
const view = new DataView(dst.buffer, dst.byteOffset, 4)
|
const view = new DataView(dst.buffer, dst.byteOffset, 4)
|
||||||
view.setInt32(0, v)
|
view.setInt32(0, v)
|
||||||
|
|
||||||
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUint32(dst: Uint8Array, v: number): Uint8Array {
|
export function setUint32(dst: Uint8Array, v: number): Uint8Array {
|
||||||
const view = new DataView(dst.buffer, dst.byteOffset, 4)
|
const view = new DataView(dst.buffer, dst.byteOffset, 4)
|
||||||
view.setUint32(0, v)
|
view.setUint32(0, v)
|
||||||
|
|
||||||
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setVint53(dst: Uint8Array, v: number): Uint8Array {
|
export function setVint53(dst: Uint8Array, v: number): Uint8Array {
|
||||||
if (v <= MAX_U6) {
|
if (v <= MAX_U6) {
|
||||||
return setUint8(dst, v)
|
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}`)
|
|
||||||
}
|
}
|
||||||
|
if (v <= MAX_U14) {
|
||||||
|
return setUint16(dst, v | 0x4000)
|
||||||
|
}
|
||||||
|
if (v <= MAX_U30) {
|
||||||
|
return setUint32(dst, v | 0x80000000)
|
||||||
|
}
|
||||||
|
if (v <= MAX_U53) {
|
||||||
|
return setUint64(dst, BigInt(v) | 0xc000000000000000n)
|
||||||
|
}
|
||||||
|
throw new Error(`overflow, value larger than 53-bits: ${v}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setVint62(dst: Uint8Array, v: bigint): Uint8Array {
|
export function setVint62(dst: Uint8Array, v: bigint): Uint8Array {
|
||||||
if (v < MAX_U6) {
|
if (v < MAX_U6) {
|
||||||
return setUint8(dst, Number(v))
|
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}`)
|
|
||||||
}
|
}
|
||||||
|
if (v < MAX_U14) {
|
||||||
|
return setUint16(dst, Number(v) | 0x4000)
|
||||||
|
}
|
||||||
|
if (v <= MAX_U30) {
|
||||||
|
return setUint32(dst, Number(v) | 0x80000000)
|
||||||
|
}
|
||||||
|
if (v <= MAX_U62) {
|
||||||
|
return setUint64(dst, BigInt(v) | 0xc000000000000000n)
|
||||||
|
}
|
||||||
|
throw new Error(`overflow, value larger than 62-bits: ${v}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUint64(dst: Uint8Array, v: bigint): Uint8Array {
|
export function setUint64(dst: Uint8Array, v: bigint): Uint8Array {
|
||||||
const view = new DataView(dst.buffer, dst.byteOffset, 8)
|
const view = new DataView(dst.buffer, dst.byteOffset, 8)
|
||||||
view.setBigUint64(0, v)
|
view.setBigUint64(0, v)
|
||||||
|
|
||||||
169
packages/moq/transfork/subscriber.ts
Normal file
169
packages/moq/transfork/subscriber.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { Queue, Watch } from "../common/async"
|
||||||
|
import { Closed } from "./error"
|
||||||
|
import { FrameReader } from "./frame"
|
||||||
|
import * as Message from "./message"
|
||||||
|
import type { Track, TrackReader } from "./model"
|
||||||
|
import { type Reader, Stream } from "./stream"
|
||||||
|
|
||||||
|
export class Subscriber {
|
||||||
|
#quic: WebTransport
|
||||||
|
|
||||||
|
// Our subscribed tracks.
|
||||||
|
#subscribe = new Map<bigint, Subscribe>()
|
||||||
|
#subscribeNext = 0n
|
||||||
|
|
||||||
|
constructor(quic: WebTransport) {
|
||||||
|
this.#quic = quic
|
||||||
|
}
|
||||||
|
|
||||||
|
async announced(prefix: string[]): Promise<Queue<Announced>> {
|
||||||
|
const announced = new Queue<Announced>()
|
||||||
|
|
||||||
|
const msg = new Message.AnnounceInterest(prefix)
|
||||||
|
const stream = await Stream.open(this.#quic, msg)
|
||||||
|
|
||||||
|
this.runAnnounced(stream, announced, prefix)
|
||||||
|
.then(() => announced.close())
|
||||||
|
.catch((err) => announced.abort(err))
|
||||||
|
|
||||||
|
return announced
|
||||||
|
}
|
||||||
|
|
||||||
|
async runAnnounced(stream: Stream, announced: Queue<Announced>, prefix: string[]) {
|
||||||
|
const toggle: Map<string[], Announced> = new Map()
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const announce = await Message.Announce.decode_maybe(stream.reader)
|
||||||
|
if (!announce) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = toggle.get(announce.suffix)
|
||||||
|
if (existing) {
|
||||||
|
if (announce.status === "active") {
|
||||||
|
throw new Error("duplicate announce")
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.close()
|
||||||
|
toggle.delete(announce.suffix)
|
||||||
|
} else {
|
||||||
|
if (announce.status === "closed") {
|
||||||
|
throw new Error("unknown announce")
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = prefix.concat(announce.suffix)
|
||||||
|
const item = new Announced(path)
|
||||||
|
await announced.push(item)
|
||||||
|
toggle.set(announce.suffix, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for (const item of toggle.values()) {
|
||||||
|
item.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Deduplicate identical subscribes
|
||||||
|
async subscribe(track: Track): Promise<TrackReader> {
|
||||||
|
const id = this.#subscribeNext++
|
||||||
|
const msg = new Message.Subscribe(id, track.path, track.priority)
|
||||||
|
|
||||||
|
const stream = await Stream.open(this.#quic, msg)
|
||||||
|
const subscribe = new Subscribe(id, stream, track)
|
||||||
|
|
||||||
|
this.#subscribe.set(subscribe.id, subscribe)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ok = await Message.Info.decode(stream.reader)
|
||||||
|
|
||||||
|
/*
|
||||||
|
for (;;) {
|
||||||
|
const dropped = await Message.GroupDrop.decode(stream.reader)
|
||||||
|
console.debug("dropped", dropped)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
return subscribe.track.reader()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
this.#subscribe.delete(subscribe.id)
|
||||||
|
await subscribe.close(Closed.from(err))
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runGroup(group: Message.Group, stream: Reader) {
|
||||||
|
const subscribe = this.#subscribe.get(group.subscribe)
|
||||||
|
if (!subscribe) return
|
||||||
|
|
||||||
|
const writer = subscribe.track.createGroup(group.sequence)
|
||||||
|
|
||||||
|
const reader = new FrameReader(stream)
|
||||||
|
for (;;) {
|
||||||
|
const frame = await reader.read()
|
||||||
|
if (!frame) break
|
||||||
|
|
||||||
|
writer.writeFrame(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Announced {
|
||||||
|
readonly path: string[]
|
||||||
|
|
||||||
|
#closed = new Watch<Closed | undefined>(undefined)
|
||||||
|
|
||||||
|
constructor(path: string[]) {
|
||||||
|
this.path = path
|
||||||
|
}
|
||||||
|
|
||||||
|
close(err = new Closed()) {
|
||||||
|
this.#closed.update(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
async closed(): Promise<Closed> {
|
||||||
|
let [closed, next] = this.#closed.value()
|
||||||
|
for (;;) {
|
||||||
|
if (closed !== undefined) return closed
|
||||||
|
if (!next) return new Closed()
|
||||||
|
;[closed, next] = await next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Subscribe {
|
||||||
|
readonly id: bigint
|
||||||
|
readonly track: Track
|
||||||
|
readonly stream: Stream
|
||||||
|
|
||||||
|
// A queue of received streams for this subscription.
|
||||||
|
#closed = new Watch<Closed | undefined>(undefined)
|
||||||
|
|
||||||
|
constructor(id: bigint, stream: Stream, track: Track) {
|
||||||
|
this.id = id
|
||||||
|
this.track = track
|
||||||
|
this.stream = stream
|
||||||
|
}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
try {
|
||||||
|
await this.closed()
|
||||||
|
await this.close()
|
||||||
|
} catch (err) {
|
||||||
|
await this.close(Closed.from(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(closed?: Closed) {
|
||||||
|
this.track.close(closed)
|
||||||
|
await this.stream.close(closed?.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
async closed() {
|
||||||
|
await this.stream.reader.closed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,550 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"types": [], // Don't automatically import any @types modules.
|
"types": [], // Don't automatically import any @types modules.
|
||||||
"lib": ["es2022", "dom"],
|
"lib": ["es2022", "dom"],
|
||||||
"typeRoots": ["./types", "../node_modules/@types"]
|
"typeRoots": ["../node_modules/@types"]
|
||||||
},
|
},
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
@@ -30,10 +30,10 @@
|
|||||||
"path": "./contribute"
|
"path": "./contribute"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./transport"
|
"path": "./transfork"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./media"
|
"path": "./karp"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
1848
packages/moq/types/mp4box.d.ts
vendored
1848
packages/moq/types/mp4box.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../tsconfig.json",
|
|
||||||
"include": ["."]
|
|
||||||
}
|
|
||||||
16
packages/relay/dev/Cargo.toml
Normal file
16
packages/relay/dev/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "dev"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "nestri-test-server"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
webrtc = "0.11.0"
|
||||||
|
tokio = { version = "1.41.1", features = ["full"] }
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
serde = { version = "1.0.215", features = ["derive"]}
|
||||||
|
serde_json = "1.0.133"
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user