From 7d792c33bf5c0a65056e1e4ab17a328d358663cc Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Mon, 29 Jun 2026 23:38:45 -0400 Subject: [PATCH] lotus(#7): fix screenshare cap per review - Apply the encoding patch to ALL simulcast layers, not just encodings[0]: screenshare publishes simulcast (VP8), so the full-res layer (the real bandwidth hog) was left uncapped (CRITICAL). - Re-apply on TrackUnmuted/restart + a 500ms settle, since LiveKit's refreshSenderEncodings() overwrites our caps on replaceTrack (device/ source switch, processor toggle) without firing LocalTrackPublished (HIGH). - Clamp values to sane ranges so a typo can't brick the encoder (MED). Co-Authored-By: Claude Opus 4.8 --- src/lotus/lotusQuality.ts | 49 +++++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/src/lotus/lotusQuality.ts b/src/lotus/lotusQuality.ts index d35d604c..5fcea4ce 100644 --- a/src/lotus/lotusQuality.ts +++ b/src/lotus/lotusQuality.ts @@ -89,17 +89,24 @@ export function startLotusQuality(vm: CallViewModel): () => void { // Add listeners for new rooms + apply current settings to them. for (const room of next) { if (!roomListeners.has(room)) { - const onPublished = (): void => applyToRoom(room); - room.localParticipant.on( + // Re-apply on (re)publish AND unmute/track-restart: LiveKit's + // refreshSenderEncodings() overwrites maxBitrate/maxFramerate from the + // publish presets on replaceTrack (device/source switch, processor + // toggle, restart-on-unmute), and those paths don't emit + // LocalTrackPublished. The settle re-apply lands after LiveKit's async + // recompute so our cap wins. + const reapply = (): void => { + applyToRoom(room); + setTimeout(() => applyToRoom(room), 500); + }; + const events = [ ParticipantEvent.LocalTrackPublished, - onPublished, - ); - roomListeners.set(room, () => - room.localParticipant.off( - ParticipantEvent.LocalTrackPublished, - onPublished, - ), - ); + ParticipantEvent.TrackUnmuted, + ] as const; + for (const e of events) room.localParticipant.on(e, reapply); + roomListeners.set(room, () => { + for (const e of events) room.localParticipant.off(e, reapply); + }); applyToRoom(room); } } @@ -109,14 +116,19 @@ export function startLotusQuality(vm: CallViewModel): () => void { void w.api.transport.reply(ev.detail, {}); const data = ev.detail.data as Record | undefined; if (!data) return; - for (const key of [ - "audioMaxBitrate", - "screenshareMaxBitrate", - "screenshareMaxFramerate", - ] as const) { + // Clamp to sane ranges so a typo can't brick the encoder (e.g. a 1 bps mic). + const ranges: Record = { + audioMaxBitrate: [6_000, 510_000], + screenshareMaxBitrate: [50_000, 20_000_000], + screenshareMaxFramerate: [1, 60], + }; + for (const key of Object.keys(ranges) as (keyof QualitySettings)[]) { const v = data[key]; if (v === null) settings[key] = undefined; - else if (typeof v === "number" && v > 0) settings[key] = v; + else if (typeof v === "number" && Number.isFinite(v)) { + const [lo, hi] = ranges[key]; + settings[key] = Math.min(hi, Math.max(lo, v)); + } } applyToAll(); }; @@ -139,7 +151,10 @@ async function patchSender( const params = sender.getParameters(); if (!params.encodings || params.encodings.length === 0) params.encodings = [{}]; - Object.assign(params.encodings[0], patch); + // Apply to EVERY encoding, not just encodings[0]: screenshare publishes + // with simulcast (VP8), so encodings[0] is the small layer and the + // full-resolution layer — the real bandwidth hog — is a later encoding. + for (const enc of params.encodings) Object.assign(enc, patch); await sender.setParameters(params); } catch (e) { logger.warn("[lotus] set_quality: setParameters failed", e);