From 556672463131b1e2b0ee4488f6307c3eaeab4fc2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 26 Sep 2025 21:11:30 -0700 Subject: [PATCH 1/3] Simulcast support and refactoring --- app/src/components/profile.tsx | 21 +----- app/src/controls.tsx | 4 +- app/src/room/audio.ts | 8 +- app/src/room/broadcast.ts | 129 ++++++++++++++++++++------------- app/src/room/fake.ts | 20 +++-- app/src/room/index.ts | 29 ++------ app/src/room/local.ts | 51 ++++++++++--- app/src/room/space.ts | 37 ++++++---- app/src/room/video.ts | 115 +++++++++++++++-------------- moq | 2 +- 10 files changed, 227 insertions(+), 189 deletions(-) diff --git a/app/src/components/profile.tsx b/app/src/components/profile.tsx index 131cb388..eb762201 100644 --- a/app/src/components/profile.tsx +++ b/app/src/components/profile.tsx @@ -4,7 +4,6 @@ import solid from "@kixelated/signals/solid"; import { createSignal, JSX, onCleanup, Show } from "solid-js"; import * as Api from "../api"; import { Camera, Microphone } from "../controls"; -import { Broadcast } from "../room/broadcast"; import { Canvas } from "../room/canvas"; import { Local } from "../room/local"; import { Sound } from "../room/sound"; @@ -118,7 +117,6 @@ export default function Profile(props: { local: Local }): JSX.Element { */ class LocalPreview { canvas: Canvas; - broadcast: Broadcast; sound: Sound; space: Space; @@ -137,26 +135,16 @@ class LocalPreview { profile: true, }); - // Create a broadcast wrapper for rendering - this.broadcast = new Broadcast(camera, this.canvas, this.sound, { - visible: true, - position: { - x: 0, - y: 0, - z: 0, - s: 1, - }, - }); - - this.space.add("local", this.broadcast); + const broadcast = this.space.add("local", camera); + this.signals.cleanup(() => this.space.remove("local")); this.signals.effect((effect: Effect) => { - const position = effect.get(this.broadcast.position); + const position = effect.get(broadcast.position); if (position.x === 0 && position.y === 0 && position.s === 1) return; // Reset the position after 2 seconds. effect.timer(() => { - this.broadcast.position.set({ + broadcast.position.set({ x: 0, y: 0, z: 0, @@ -170,7 +158,6 @@ class LocalPreview { this.space.close(); this.canvas.close(); this.sound.close(); - this.broadcast.close(); // NOTE: Doesn't close the source broadcast. this.signals.close(); } } diff --git a/app/src/controls.tsx b/app/src/controls.tsx index 9081962f..1c5d6da3 100644 --- a/app/src/controls.tsx +++ b/app/src/controls.tsx @@ -266,7 +266,7 @@ export function Camera(props: { local: Local; room?: Room }): JSX.Element { const toggle = () => { props.local.webcam.enabled.update((prev: boolean) => !prev); }; - const media = solid(props.local.webcam.stream); + const media = solid(props.local.webcam.source); const [showMenu, setShowMenu] = createSignal(false); const [deviceChangeIndicator, setDeviceChangeIndicator] = createSignal(false); @@ -448,7 +448,7 @@ function Screen(props: { local: Local; room: Room }): JSX.Element { const toggle = () => { props.local.screen.enabled.update((prev: boolean) => !prev); }; - const media = solid(props.local.screen.stream); + const media = solid(props.local.screen.source); return ( diff --git a/app/src/room/audio.ts b/app/src/room/audio.ts index 491470a2..2daeab84 100644 --- a/app/src/room/audio.ts +++ b/app/src/room/audio.ts @@ -141,8 +141,8 @@ export class Audio { ctx.translate(bounds.position.x, bounds.position.y); - const RADIUS = 12 * this.broadcast.scale; - const PADDING = 12 * this.broadcast.scale; + const RADIUS = 12 * this.broadcast.zoom.peek(); + const PADDING = 12 * this.broadcast.zoom.peek(); // Background outline ctx.beginPath(); @@ -166,7 +166,7 @@ export class Audio { if (!analyserBuffer) return; // undefined in potato mode const bounds = this.broadcast.bounds.peek(); - const scale = this.broadcast.scale; + const scale = this.broadcast.zoom.peek(); ctx.save(); ctx.translate(bounds.position.x, bounds.position.y); @@ -217,7 +217,7 @@ export class Audio { // Add an additional border if we're speaking, ramping up/down the alpha if (this.#speakingAlpha > 0) { ctx.strokeStyle = `hsla(${hue}, 80%, 45%, ${this.#speakingAlpha})`; - ctx.lineWidth = 6 * this.broadcast.scale; + ctx.lineWidth = 6 * scale; ctx.stroke(); } diff --git a/app/src/room/broadcast.ts b/app/src/room/broadcast.ts index 78577c02..310948cf 100644 --- a/app/src/room/broadcast.ts +++ b/app/src/room/broadcast.ts @@ -1,6 +1,6 @@ -import { type Catalog, Publish, Watch } from "@kixelated/hang"; +import { Publish, Watch } from "@kixelated/hang"; import { Effect, Signal } from "@kixelated/signals"; -import { Audio, type AudioProps } from "./audio"; +import { Audio } from "./audio"; import { Canvas } from "./canvas"; import { Captions } from "./captions"; import { Chat } from "./chat"; @@ -19,12 +19,6 @@ export type ChatMessage = { expires: DOMHighResTimeStamp; }; -export type BroadcastProps = { - audio?: AudioProps; - position?: Catalog.Position; - visible?: boolean; -}; - // Catalog.Position but all fields are required. type Position = { x: number; @@ -33,6 +27,13 @@ type Position = { s: number; }; +export interface BroadcastProps { + source: T; + canvas: Canvas; + sound: Sound; + scale: Signal; +} + export class Broadcast { source: T; canvas: Canvas; @@ -46,7 +47,6 @@ export class Broadcast { message = new Signal(undefined); bounds: Signal; // 0 to canvas - scale = 1.0; // 1 is 100% velocity = Vector.create(0, 0); // in pixels per ? // Replaced by position @@ -64,31 +64,35 @@ export class Broadcast { meme = new Signal(undefined); memeName = new Signal(undefined); + scale: Signal; // room scale, 1 is 100% + zoom = new Signal(1.0); // local zoom, 1 is 100% + // Show a locator arrow for 8 seconds to show our position on join. #locatorStart?: DOMHighResTimeStamp; signals = new Effect(); - constructor(source: T, canvas: Canvas, sound: Sound, props?: BroadcastProps) { - this.source = source; - this.canvas = canvas; - this.visible = new Signal(props?.visible ?? true); + constructor(props: BroadcastProps) { + this.source = props.source; + this.canvas = props.canvas; + this.visible = new Signal(true); // TODO + this.scale = props.scale; // Unless provided, start them at the center of the screen with a tiiiiny bit of variance to break ties. const start = () => (Math.random() - 0.5) / 100; const position = { - x: props?.position?.x ?? start(), - y: props?.position?.y ?? start(), - z: props?.position?.z ?? 0, - s: props?.position?.s ?? 1, + x: start(), + y: start(), + z: 0, + s: 1, }; this.position = new Signal(position); this.video = new Video(this); - this.audio = new Audio(this, sound, props?.audio); - this.chat = new Chat(this, canvas); - this.captions = new Captions(this, canvas); + this.audio = new Audio(this, props.sound); + this.chat = new Chat(this, props.canvas); + this.captions = new Captions(this, props.canvas); const viewport = this.canvas.viewport.peek(); @@ -101,36 +105,38 @@ export class Broadcast { // Normalize to find the closest edge of the screen. startPosition = startPosition.normalize().mult(viewport.length()).add(viewport.div(2)); - this.bounds = new Signal(new Bounds(startPosition, this.video.targetSize)); + this.bounds = new Signal(new Bounds(startPosition, this.video.targetSize.peek())); - // Load the broadcaster's position from the network. - this.signals.effect((effect) => { - if (!effect.get(this.visible)) { - // Change the target position to somewhere outside the screen. - this.position.update((prev) => { - const offscreen = Vector.create(prev.x, prev.y).normalize().mult(2); - return { ...prev, x: offscreen.x, y: offscreen.y }; - }); - - return; - } - - // Update the target position from the network. - const location = effect.get(this.source.location.window.position); - if (!location) return; + this.signals.effect(this.#runLocation.bind(this)); + this.signals.effect(this.#runChat.bind(this)); + this.signals.effect(this.#runTargetPixels.bind(this)); + } + // Load the broadcaster's position from the network. + #runLocation(effect: Effect) { + if (!effect.get(this.visible)) { + // Change the target position to somewhere outside the screen. this.position.update((prev) => { - return { - ...prev, - x: location.x ?? prev.x, - y: location.y ?? prev.y, - z: location.z ?? prev.z, - s: location.s ?? prev.s, - }; + const offscreen = Vector.create(prev.x, prev.y).normalize().mult(2); + return { ...prev, x: offscreen.x, y: offscreen.y }; }); - }); - this.signals.effect(this.#runChat.bind(this)); + return; + } + + // Update the target position from the network. + const location = effect.get(this.source.location.window.position); + if (!location) return; + + this.position.update((prev) => { + return { + ...prev, + x: location.x ?? prev.x, + y: location.y ?? prev.y, + z: location.z ?? prev.z, + s: location.s ?? prev.s, + }; + }); } #runChat(effect: Effect) { @@ -165,8 +171,23 @@ export class Broadcast { }); } + // Decides the simulcast size to use based on the number of pixels. + #runTargetPixels(effect: Effect) { + if (!(this.source instanceof Watch.Broadcast)) return; + + const catalog = effect.get(this.source.video.catalog); + if (!catalog) return; + + const desired = catalog.display.height * catalog.display.width; + const scale = effect.get(this.scale); + const zoom = effect.get(this.zoom); + + const requested = desired * scale * zoom; + this.source.video.targetPixels.set(requested); + } + // TODO Also make scale a signal - tick(scale: number) { + tick() { this.video.tick(); const bounds = this.bounds.peek(); @@ -213,8 +234,12 @@ export class Broadcast { } // Apply everything now. - const targetSize = this.video.targetSize.mult(this.scale * scale); - this.scale += (targetPosition.s - this.scale) * 0.1; + const targetSize = this.video.targetSize.peek().mult(this.zoom.peek() * this.scale.peek()); + + const dz = (targetPosition.s - this.zoom.peek()) * 0.1; + if (Math.abs(dz) >= 0.002) { + this.zoom.update((prev) => prev + dz); + } // Apply the velocity and size. const dx = this.velocity.x / 50; @@ -275,9 +300,9 @@ export class Broadcast { ctx.globalAlpha *= alpha; // Calculate arrow position and animation - const arrowSize = 12 * this.scale; + const arrowSize = 12 * this.zoom.peek(); const pulseScale = 1 + Math.sin(now / 500) * 0.1; // Subtle pulsing effect - const offset = 10 * this.scale; + const offset = 10 * this.zoom.peek(); const gap = 2 * (arrowSize + offset); @@ -294,14 +319,14 @@ export class Broadcast { ctx.closePath(); // Style the arrow - ctx.lineWidth = 4 * this.scale; + ctx.lineWidth = 4 * this.zoom.peek(); ctx.strokeStyle = "#000"; // Gold color ctx.fillStyle = "#FFD700"; ctx.stroke(); ctx.fill(); // Draw "YOU" text - const fontSize = Math.round(32 * this.scale); // round to avoid busting font caches + const fontSize = Math.round(32 * this.zoom.peek()); // round to avoid busting font caches ctx.font = `bold ${fontSize}px Arial`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; diff --git a/app/src/room/fake.ts b/app/src/room/fake.ts index 5878d59a..3f85b458 100644 --- a/app/src/room/fake.ts +++ b/app/src/room/fake.ts @@ -1,6 +1,6 @@ import { Catalog } from "@kixelated/hang"; +import { u53 } from "@kixelated/hang/catalog"; import { Effect, Signal } from "@kixelated/signals"; -import { Broadcast } from "./broadcast"; import { Canvas } from "./canvas"; import { Sound } from "./sound"; import { Space } from "./space"; @@ -53,9 +53,8 @@ export class FakeBroadcast { }; video = { - media: new Signal(undefined), frame: new Signal(undefined), - flip: new Signal(undefined), + catalog: new Signal(undefined), detection: { enabled: new Signal(false), objects: new Signal(undefined), @@ -118,6 +117,16 @@ export class FakeBroadcast { this.#video = video; this.video.frame.set(video); + video.onloadedmetadata = () => { + this.video.catalog.set({ + tracks: {}, + display: { + width: u53(video.videoWidth), + height: u53(video.videoHeight), + }, + }); + }; + const source = new MediaElementAudioSourceNode(this.sound.context, { mediaElement: video }); this.audio.root.set(source); } @@ -126,8 +135,6 @@ export class FakeBroadcast { this.#video?.pause(); this.#video = undefined; - this.video.frame.set(undefined); - this.audio.root.update((prev) => { prev?.disconnect(); return undefined; @@ -153,13 +160,12 @@ export class FakeRoom { } add(path: string, broadcast: FakeBroadcast) { - this.space.add(path, new Broadcast(broadcast, this.space.canvas, this.sound)); + this.space.add(path, broadcast); } remove(path: string) { this.space.remove(path).then((broadcast) => { broadcast.close(); - broadcast.source.close(); }); } diff --git a/app/src/room/index.ts b/app/src/room/index.ts index b326d1c5..2ef37456 100644 --- a/app/src/room/index.ts +++ b/app/src/room/index.ts @@ -2,7 +2,6 @@ import { Publish, Watch } from "@kixelated/hang"; import * as Moq from "@kixelated/moq"; import { Effect } from "@kixelated/signals"; import Settings from "../settings"; -import { Broadcast } from "./broadcast"; import type { Canvas } from "./canvas"; import { Local } from "./local"; import { Space } from "./space"; @@ -71,17 +70,10 @@ export class Room { if (local) { if (update.active) { - const broadcast = new Broadcast(local, this.space.canvas, this.space.sound, { - // Wait until we get an announcement before rendering ourselves as online. - visible: false, - }); - - this.space.add(update.path, broadcast); + this.space.add(update.path, local); } else { - this.space.remove(update.path).then((broadcast) => { - broadcast.close(); - // We don't close local sources so we can toggle them. - }); + // NOTE: We don't close local sources so we can toggle them. + this.space.remove(update.path); } continue; } @@ -89,10 +81,7 @@ export class Room { if (update.active) { this.#addRemote(update.path); } else { - this.space.remove(update.path).then((broadcast) => { - broadcast.close(); - broadcast.source.close(); - }); + this.space.remove(update.path).then((broadcast) => broadcast.close()); } } } @@ -131,13 +120,9 @@ export class Room { }, }); - const broadcast = new Broadcast(watch, this.space.canvas, this.space.sound, { - visible: true, - }); - // Request the position we should use from this remote broadcast. - broadcast.signals.effect((effect) => { - const positions = effect.get(broadcast.source.location.peers.positions); + watch.signals.effect((effect) => { + const positions = effect.get(watch.location.peers.positions); if (!positions) return; // Check if our local handles are in the positions. @@ -157,7 +142,7 @@ export class Room { } }); - this.space.add(path, broadcast); + this.space.add(path, watch); } close() { diff --git a/app/src/room/local.ts b/app/src/room/local.ts index b02437e0..900948e5 100644 --- a/app/src/room/local.ts +++ b/app/src/room/local.ts @@ -49,12 +49,13 @@ export class Local { enabled: Settings.camera.enabled, device: { preferred: Settings.camera.device }, constraints: { - width: { ideal: 640 }, - height: { ideal: 640 }, + width: { ideal: 720 }, + height: { ideal: 720 }, frameRate: { ideal: 60 }, facingMode: { ideal: "user" }, resizeMode: "none", }, + flip: true, }); this.#signals.cleanup(() => this.webcam.close()); @@ -80,14 +81,26 @@ export class Local { avatar: this.avatar, }, video: { - enabled: Settings.camera.enabled, - source: this.webcam.stream, - flip: true, // TODO setting? + source: this.webcam.source, + hd: { + enabled: Settings.camera.enabled, + config: { + maxPixels: 720 * 720, + bitrateScale: 0.08, + }, + }, + sd: { + enabled: Settings.camera.enabled, + config: { + maxPixels: 360 * 360, + bitrateScale: 0.06, + }, + }, }, audio: { enabled: Settings.microphone.enabled, volume: Settings.microphone.gain, - source: this.microphone.stream, + source: this.microphone.source, speaking: { // TODO Figure out an efficient way to run models on mobile. enabled: !Tauri.MOBILE ? Settings.microphone.enabled : undefined, @@ -127,7 +140,7 @@ export class Local { frameRate: { ideal: 60 }, resizeMode: "none", width: { max: 1920 }, - height: { max: 1080 }, + height: { max: 1920 }, }, audio: { channelCount: { ideal: 2, max: 2 }, @@ -145,7 +158,21 @@ export class Local { enabled: this.screen.enabled, }, video: { - enabled: this.screen.enabled, + hd: { + enabled: this.screen.enabled, + config: { + maxPixels: 1920 * 1920, + bitrateScale: 0.08, + }, + }, + // TODO only enable for large enough screen + sd: { + enabled: this.screen.enabled, + config: { + maxPixels: 960 * 960, + bitrateScale: 0.06, + }, + }, }, location: { window: { @@ -160,11 +187,11 @@ export class Local { }); this.#signals.effect((effect) => { - const stream = effect.get(this.screen.stream); - if (!stream) return; + const source = effect.get(this.screen.source); + if (!source) return; - effect.set(this.share.audio.source, stream.audio); - effect.set(this.share.video.source, stream.video); + effect.set(this.share.audio.source, source.audio); + effect.set(this.share.video.source, source.video); effect.set(this.share.enabled, true, false); // only enable once there is a stream }); diff --git a/app/src/room/space.ts b/app/src/room/space.ts index 5f0d7325..33489ecd 100644 --- a/app/src/room/space.ts +++ b/app/src/room/space.ts @@ -1,6 +1,6 @@ import { Publish, Watch } from "@kixelated/hang"; import { Effect, Signal } from "@kixelated/signals"; -import { Broadcast } from "./broadcast"; +import { Broadcast, BroadcastSource } from "./broadcast"; import type { Canvas } from "./canvas"; import { Vector } from "./geometry"; import type { Sound } from "./sound"; @@ -25,7 +25,8 @@ export class Space { #hovering: Broadcast | undefined = undefined; #dragging?: Broadcast; - #scale = 1.0; + + #scale = new Signal(1.0); #maxZ = 0; @@ -53,6 +54,8 @@ export class Space { this.#signals.event(canvas.element, "touchend", this.#onTouchEnd.bind(this), { passive: false }); this.#signals.event(canvas.element, "touchcancel", this.#onTouchCancel.bind(this), { passive: false }); + this.#signals.effect(this.#runScale.bind(this)); + // This is a bit of a hack, but register our render method. this.canvas.onRender = this.#tick.bind(this); this.#signals.cleanup(() => { @@ -379,7 +382,9 @@ export class Space { return undefined; } - add(id: string, broadcast: Broadcast) { + add(id: string, source: BroadcastSource): Broadcast { + const broadcast = new Broadcast({ source, canvas: this.canvas, sound: this.sound, scale: this.#scale }); + // Put new broadcasts on top of the stack. // NOTE: This is not sent over the network. broadcast.position.update((prev) => ({ @@ -455,9 +460,11 @@ export class Space { this.sound.tts.left(name); }); + + return broadcast; } - async remove(path: string): Promise { + async remove(path: string): Promise { const broadcast = this.lookup.get(path); if (!broadcast) { throw new Error(`broadcast not found: ${path}`); @@ -476,7 +483,9 @@ export class Space { await new Promise((resolve) => setTimeout(resolve, 1000)); this.#rip.splice(this.#rip.indexOf(broadcast), 1); - return broadcast; + broadcast.close(); + + return broadcast.source; } clear(): Broadcast[] { @@ -487,16 +496,14 @@ export class Space { } #tick(ctx: CanvasRenderingContext2D, now: DOMHighResTimeStamp) { - this.#tickScale(); - for (const broadcast of this.#rip) { - broadcast.tick(this.#scale); + broadcast.tick(); } const broadcasts = this.ordered.peek(); for (const broadcast of broadcasts) { - broadcast.tick(this.#scale); + broadcast.tick(); } // Check for collisions. @@ -611,24 +618,26 @@ export class Space { ctx.restore(); } - #tickScale() { - const broadcasts = this.ordered.peek(); + #runScale(effect: Effect) { + const broadcasts = effect.get(this.ordered); if (broadcasts.length === 0) { // Avoid division by zero. return; } - const canvasArea = this.canvas.viewport.peek().area(); + const canvasArea = effect.get(this.canvas.viewport).area(); let broadcastArea = 0; for (const broadcast of broadcasts) { - broadcastArea += broadcast.video.targetSize.x * broadcast.video.targetSize.y; + const size = effect.get(broadcast.video.targetSize); + broadcastArea += size.x * size.y; } const fillRatio = broadcastArea / canvasArea; const targetFill = 0.25; - this.#scale = Math.min(Math.sqrt(targetFill / fillRatio), 1); + const scale = Math.min(Math.sqrt(targetFill / fillRatio), 1); + this.#scale.set(scale); } close() { diff --git a/app/src/room/video.ts b/app/src/room/video.ts index e2111d64..c23276b1 100644 --- a/app/src/room/video.ts +++ b/app/src/room/video.ts @@ -1,6 +1,8 @@ import { Publish, Watch } from "@kixelated/hang"; +import { Effect, Signal } from "@kixelated/signals"; import * as Api from "../api"; import type { Broadcast } from "./broadcast"; +import { FakeBroadcast } from "./fake"; import { Vector } from "./geometry"; import { MEME_AUDIO, MEME_AUDIO_LOOKUP, MEME_VIDEO, MEME_VIDEO_LOOKUP, type MemeVideoName } from "./meme"; @@ -17,11 +19,14 @@ export class Video { // 1 when a video frame is fully rendered, 0 when their avatar is fully rendered. avatarTransition = 0; - // The current video frame, for transitioning back to the avatar. - frame?: VideoFrame | HTMLVideoElement; + // The size of the avatar in pixels. + avatarSize = new Signal(undefined); + + // The current video frame. + frame?: CanvasImageSource; // The desired size of the video in pixels. - targetSize: Vector; // in pixels + targetSize = new Signal(Vector.create(128, 128)); // The opacity from 0 to 1, where 0 is offline and 1 is online. online = 0; @@ -31,67 +36,62 @@ export class Video { constructor(broadcast: Broadcast) { this.broadcast = broadcast; - this.targetSize = Vector.create(128, 128); + this.broadcast.signals.effect(this.#runAvatar.bind(this)); + this.broadcast.signals.effect(this.#runTargetSize.bind(this)); + this.broadcast.signals.effect(this.#runFrame.bind(this)); + } - // Set a random default avatar while the user details are loading. - // TODO Only start a broadcast after receiving the catalog to avoid this. - this.avatar.src = Api.randomAvatar(); + #runAvatar(effect: Effect) { + let avatar = effect.get(this.broadcast.source.user.avatar); + if (!avatar) { + // Don't unset the avatar if it's already set. + if (this.avatar) return; - // This doesn't use a memo because we intentionally prevent going back to the default avatar. - this.broadcast.signals.effect((effect) => { - const avatar = effect.get(this.broadcast.source.user.avatar); - if (!avatar) return; // don't unset + // Set a random default avatar while the user details are loading. + avatar = Api.randomAvatar(); + } - // TODO only set the avatar if it successfully loads - const newAvatar = new Image(); - newAvatar.src = avatar; + // TODO only set the avatar if it successfully loads + const newAvatar = new Image(); + newAvatar.src = avatar; - const load = () => { - this.avatar = newAvatar; - }; + const load = () => { + this.avatar = newAvatar; + this.avatarSize.set(Vector.create(newAvatar.width, newAvatar.height)); + }; - effect.event(newAvatar, "load", load); - }); + effect.event(newAvatar, "load", load); } - tick() { - const next = this.broadcast.source.video.frame.peek(); - if (next) { - this.avatarTransition = Math.min(this.avatarTransition + 0.05, 1); - - let width: number; - let height: number; + #runTargetSize(effect: Effect) { + const catalog = effect.get(this.broadcast.source.video.catalog); + if (catalog) { + this.targetSize.set(Vector.create(catalog.display.width, catalog.display.height)); + return; + } - if (next instanceof HTMLVideoElement) { - width = next.videoWidth; - height = next.videoHeight; - } else { - width = next.displayWidth; - height = next.displayHeight; - } + const avatar = effect.get(this.avatarSize); + if (avatar) { + // If the avatar is larger than 256x256, then shrink it to match the target area. + const ratio = Math.sqrt(avatar.x * avatar.y) / 256; + this.targetSize.set(avatar.div(ratio)); + return; + } + } - this.targetSize = Vector.create(width, height); + #runFrame(effect: Effect) { + // TODO FakeBroadcast should return a VideoFrame instead of a HTMLVideoElement. + this.frame = + this.broadcast.source instanceof FakeBroadcast + ? effect.get(this.broadcast.source.video.frame) + : effect.get(this.broadcast.source.video.frame); + } - if (this.frame instanceof VideoFrame) this.frame.close(); - this.frame = next instanceof HTMLVideoElement ? next : next.clone(); + tick() { + if (this.frame) { + this.avatarTransition = Math.min(this.avatarTransition + 0.05, 1); } else { this.avatarTransition = Math.max(this.avatarTransition - 0.05, 0); - // TODO do this once, not on every frame. - if (this.avatar.complete) { - this.targetSize = Vector.create(this.avatar.width, this.avatar.height); - - // If the avatar is larger than 256x256, then shrink it to match the target area. - const ratio = Math.sqrt(this.targetSize.x * this.targetSize.y) / 256; - if (ratio > 1) { - this.targetSize = this.targetSize.div(ratio); - } - } - - // Deallocate the frame once we're done with it. - if (this.avatarTransition === 0 && this.frame instanceof VideoFrame) { - this.frame.close(); - this.frame = undefined; - } } if (this.broadcast.visible.peek()) { @@ -118,7 +118,7 @@ export class Video { ctx.save(); const bounds = this.broadcast.bounds.peek(); - const scale = this.broadcast.scale; + const scale = this.broadcast.zoom.peek(); ctx.translate(bounds.position.x, bounds.position.y); ctx.globalAlpha *= this.online; @@ -166,12 +166,11 @@ export class Video { ctx.save(); ctx.globalAlpha *= this.avatarTransition; - // Apply horizontal flip only for Publish.Broadcast - // Watch.Broadcast already handles flipping internally with WebCodecs - const flip = this.broadcast.source.video.flip?.peek(); - const shouldFlip = flip && this.broadcast.source instanceof Publish.Broadcast; + // Apply horizontal flip when rendering the preview. + const flip = + this.broadcast.source instanceof Publish.Broadcast && this.broadcast.source.video.source.peek()?.flip; - if (shouldFlip) { + if (flip) { ctx.save(); ctx.scale(-1, 1); ctx.translate(-bounds.size.x, 0); diff --git a/moq b/moq index 154508a9..ffd0cde4 160000 --- a/moq +++ b/moq @@ -1 +1 @@ -Subproject commit 154508a96c1f2c3cbfbdec17c4a901ff496f34d1 +Subproject commit ffd0cde4c0171aa0629b2c93a66e508febce1f1c From 5fb8305d9e9bc43dc3bc190dca19095b260451e0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 28 Sep 2025 04:42:54 -0700 Subject: [PATCH 2/3] WIP --- app/src/components/profile.tsx | 2 +- app/src/room/video.ts | 6 +++++- moq | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/components/profile.tsx b/app/src/components/profile.tsx index eb762201..bbdae37b 100644 --- a/app/src/components/profile.tsx +++ b/app/src/components/profile.tsx @@ -155,9 +155,9 @@ class LocalPreview { } close() { + this.signals.close(); this.space.close(); this.canvas.close(); this.sound.close(); - this.signals.close(); } } diff --git a/app/src/room/video.ts b/app/src/room/video.ts index c23276b1..d7c92a60 100644 --- a/app/src/room/video.ts +++ b/app/src/room/video.ts @@ -77,14 +77,18 @@ export class Video { this.targetSize.set(avatar.div(ratio)); return; } + + this.targetSize.set(Vector.create(128, 128)); } #runFrame(effect: Effect) { + if (this.frame instanceof VideoFrame) this.frame.close(); + // TODO FakeBroadcast should return a VideoFrame instead of a HTMLVideoElement. this.frame = this.broadcast.source instanceof FakeBroadcast ? effect.get(this.broadcast.source.video.frame) - : effect.get(this.broadcast.source.video.frame); + : effect.get(this.broadcast.source.video.frame)?.clone(); } tick() { diff --git a/moq b/moq index ffd0cde4..7f5ce9b9 160000 --- a/moq +++ b/moq @@ -1 +1 @@ -Subproject commit ffd0cde4c0171aa0629b2c93a66e508febce1f1c +Subproject commit 7f5ce9b9fa2d8a67f2e06d9c99519f1c4db48cfa From b6983c219709062458dc3e9f33f2589f2ac80370 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 1 Oct 2025 10:47:31 -0700 Subject: [PATCH 3/3] Simulcast improvements. --- app/index.html | 12 +++++----- app/package.json | 1 + app/public/image/icon-default-128px.png | Bin 0 -> 9354 bytes app/public/image/icon-default-256px.png | Bin 19698 -> 0 bytes app/src/room/broadcast.ts | 20 ++++++++++------ app/src/room/fake.ts | 18 +++++++++------ app/src/room/index.ts | 5 +++- app/src/room/local.ts | 13 +++++------ app/src/room/tts/index.ts | 1 - app/src/room/video.ts | 29 ++++++++++++++++-------- bun.lock | 7 ++++-- moq | 2 +- 12 files changed, 66 insertions(+), 42 deletions(-) create mode 100644 app/public/image/icon-default-128px.png delete mode 100644 app/public/image/icon-default-256px.png diff --git a/app/index.html b/app/index.html index 087d6acf..32219720 100644 --- a/app/index.html +++ b/app/index.html @@ -12,19 +12,19 @@ - + - - + + - - - + + + diff --git a/app/package.json b/app/package.json index ad40dc81..bfeb31b6 100644 --- a/app/package.json +++ b/app/package.json @@ -27,6 +27,7 @@ "@tauri-apps/plugin-opener": "^2.5.0", "@tauri-apps/plugin-process": "^2.3.0", "@tauri-apps/plugin-updater": "^2.5.0", + "@types/semver": "^7.7.1", "comlink": "^4.4.2", "dompurify": "^3.2.6", "jszip": "^3.10.1", diff --git a/app/public/image/icon-default-128px.png b/app/public/image/icon-default-128px.png new file mode 100644 index 0000000000000000000000000000000000000000..62789db4558383886fabcf567cf91fcef4e1368f GIT binary patch literal 9354 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 Ef`VRnVE_OC literal 0 HcmV?d00001 diff --git a/app/public/image/icon-default-256px.png b/app/public/image/icon-default-256px.png deleted file mode 100644 index f601332da4f18104645ec530af5b12ad1f0d9b19..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19698 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 { this.signals.effect(this.#runLocation.bind(this)); this.signals.effect(this.#runChat.bind(this)); - this.signals.effect(this.#runTargetPixels.bind(this)); + this.signals.effect(this.#runTarget.bind(this)); } // Load the broadcaster's position from the network. @@ -172,18 +172,24 @@ export class Broadcast { } // Decides the simulcast size to use based on the number of pixels. - #runTargetPixels(effect: Effect) { + #runTarget(effect: Effect) { if (!(this.source instanceof Watch.Broadcast)) return; const catalog = effect.get(this.source.video.catalog); if (!catalog) return; - const desired = catalog.display.height * catalog.display.width; - const scale = effect.get(this.scale); - const zoom = effect.get(this.zoom); + for (const rendition of catalog) { + if (!rendition.config.displayAspectHeight || !rendition.config.displayAspectWidth) continue; - const requested = desired * scale * zoom; - this.source.video.targetPixels.set(requested); + const pixels = rendition.config.displayAspectHeight * rendition.config.displayAspectWidth; + const scale = effect.get(this.scale); + const zoom = effect.get(this.zoom); + + const scaled = pixels * scale * zoom; + effect.set(this.source.video.target, { pixels: scaled }); + + return; + } } // TODO Also make scale a signal diff --git a/app/src/room/fake.ts b/app/src/room/fake.ts index 3f85b458..582585f4 100644 --- a/app/src/room/fake.ts +++ b/app/src/room/fake.ts @@ -54,7 +54,7 @@ export class FakeBroadcast { video = { frame: new Signal(undefined), - catalog: new Signal(undefined), + catalog: new Signal(undefined), detection: { enabled: new Signal(false), objects: new Signal(undefined), @@ -118,13 +118,17 @@ export class FakeBroadcast { this.video.frame.set(video); video.onloadedmetadata = () => { - this.video.catalog.set({ - tracks: {}, - display: { - width: u53(video.videoWidth), - height: u53(video.videoHeight), + this.video.catalog.set([ + { + track: "video", + config: { + codec: "fake", + // Required for the correct display size. + displayAspectWidth: u53(video.videoWidth), + displayAspectHeight: u53(video.videoHeight), + }, }, - }); + ]); }; const source = new MediaElementAudioSourceNode(this.sound.context, { mediaElement: video }); diff --git a/app/src/room/index.ts b/app/src/room/index.ts index 2ef37456..7fc55066 100644 --- a/app/src/room/index.ts +++ b/app/src/room/index.ts @@ -106,7 +106,6 @@ export class Room { // Download the preview track to receive high-level information about the broadcaster. preview: { enabled: true }, audio: { - enabled: this.space.sound.suspended, // Download the speaking indicator. speaking: { enabled: true }, captions: { enabled: Settings.captions.render }, @@ -120,6 +119,10 @@ export class Room { }, }); + watch.signals.effect((effect) => { + watch.audio.enabled.set(!effect.get(this.space.sound.suspended)); + }); + // Request the position we should use from this remote broadcast. watch.signals.effect((effect) => { const positions = effect.get(watch.location.peers.positions); diff --git a/app/src/room/local.ts b/app/src/room/local.ts index 900948e5..96c44d61 100644 --- a/app/src/room/local.ts +++ b/app/src/room/local.ts @@ -49,13 +49,12 @@ export class Local { enabled: Settings.camera.enabled, device: { preferred: Settings.camera.device }, constraints: { - width: { ideal: 720 }, - height: { ideal: 720 }, + width: { ideal: 640 }, + height: { ideal: 640 }, frameRate: { ideal: 60 }, facingMode: { ideal: "user" }, resizeMode: "none", }, - flip: true, }); this.#signals.cleanup(() => this.webcam.close()); @@ -85,15 +84,15 @@ export class Local { hd: { enabled: Settings.camera.enabled, config: { - maxPixels: 720 * 720, - bitrateScale: 0.08, + maxPixels: 640 * 640, + flip: true, }, }, sd: { enabled: Settings.camera.enabled, config: { - maxPixels: 360 * 360, - bitrateScale: 0.06, + maxPixels: 320 * 320, + flip: true, }, }, }, diff --git a/app/src/room/tts/index.ts b/app/src/room/tts/index.ts index 4e091569..8bb6c1a7 100644 --- a/app/src/room/tts/index.ts +++ b/app/src/room/tts/index.ts @@ -242,7 +242,6 @@ export class TTS { } close() { - this.context.close(); this.#signals.close(); } } diff --git a/app/src/room/video.ts b/app/src/room/video.ts index d7c92a60..f4ea7271 100644 --- a/app/src/room/video.ts +++ b/app/src/room/video.ts @@ -65,9 +65,16 @@ export class Video { #runTargetSize(effect: Effect) { const catalog = effect.get(this.broadcast.source.video.catalog); + if (catalog) { - this.targetSize.set(Vector.create(catalog.display.width, catalog.display.height)); - return; + for (const rendition of catalog) { + if (rendition.config.displayAspectHeight && rendition.config.displayAspectWidth) { + this.targetSize.set( + Vector.create(rendition.config.displayAspectWidth, rendition.config.displayAspectHeight), + ); + return; + } + } } const avatar = effect.get(this.avatarSize); @@ -82,13 +89,14 @@ export class Video { } #runFrame(effect: Effect) { - if (this.frame instanceof VideoFrame) this.frame.close(); - - // TODO FakeBroadcast should return a VideoFrame instead of a HTMLVideoElement. - this.frame = - this.broadcast.source instanceof FakeBroadcast - ? effect.get(this.broadcast.source.video.frame) - : effect.get(this.broadcast.source.video.frame)?.clone(); + if (this.broadcast.source instanceof FakeBroadcast) { + // TODO FakeBroadcast should return a VideoFrame instead of a HTMLVideoElement. + this.frame = effect.get(this.broadcast.source.video.frame); + } else { + const frame = effect.get(this.broadcast.source.video.frame)?.clone(); + effect.cleanup(() => frame?.close()); + this.frame = frame; + } } tick() { @@ -172,7 +180,8 @@ export class Video { // Apply horizontal flip when rendering the preview. const flip = - this.broadcast.source instanceof Publish.Broadcast && this.broadcast.source.video.source.peek()?.flip; + this.broadcast.source instanceof Publish.Broadcast && + this.broadcast.source.video.hd.config.peek()?.flip; if (flip) { ctx.save(); diff --git a/bun.lock b/bun.lock index 425829d6..ae6afeeb 100644 --- a/bun.lock +++ b/bun.lock @@ -46,6 +46,7 @@ "@tauri-apps/plugin-opener": "^2.5.0", "@tauri-apps/plugin-process": "^2.3.0", "@tauri-apps/plugin-updater": "^2.5.0", + "@types/semver": "^7.7.1", "comlink": "^4.4.2", "dompurify": "^3.2.6", "jszip": "^3.10.1", @@ -101,8 +102,9 @@ }, "moq/js/moq": { "name": "@kixelated/moq", - "version": "0.9.0", + "version": "0.9.1", "dependencies": { + "@kixelated/signals": "workspace:*", "@kixelated/web-transport-ws": "^0.1.2", "async-mutex": "^0.5.0", }, @@ -118,7 +120,6 @@ "vite-plugin-html": "^3.2.2", }, "peerDependencies": { - "@kixelated/signals": "workspace:*", "zod": "^4.1.0", }, }, @@ -585,6 +586,8 @@ "@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="], + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@types/uuid": ["@types/uuid@10.0.0", "", {}, "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="], diff --git a/moq b/moq index 7f5ce9b9..0a6101da 160000 --- a/moq +++ b/moq @@ -1 +1 @@ -Subproject commit 7f5ce9b9fa2d8a67f2e06d9c99519f1c4db48cfa +Subproject commit 0a6101daae9e363a99f9507c683502aa684d811c