-
Notifications
You must be signed in to change notification settings - Fork 275
✨(frontend) introduce performance mode with auto-detection and telemetry #1593
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| export interface HardwareSnapshot { | ||
| /** navigator.hardwareConcurrency — logical CPU cores. */ | ||
| cpu_cores: number | null | ||
| /** navigator.deviceMemory — RAM in GiB, bucketed by the browser (Chromium only). */ | ||
| device_memory_gb: number | null | ||
| /** performance.memory.jsHeapSizeLimit in MB (Chromium only, non-standard). */ | ||
| js_heap_limit_mb: number | null | ||
| /** performance.memory.usedJSHeapSize in MB (Chromium only, non-standard). */ | ||
| js_heap_used_mb: number | null | ||
| /** Battery level 0..1 via navigator.getBattery() (Chromium only). */ | ||
| battery_level: number | null | ||
| /** Whether the device is plugged in, via navigator.getBattery(). */ | ||
| battery_charging: boolean | null | ||
| } | ||
|
|
||
| const BATTERY_TIMEOUT_MS = 1_000 | ||
|
|
||
| const toMb = (bytes: unknown): number | null => | ||
| typeof bytes === 'number' ? Math.round(bytes / (1024 * 1024)) : null | ||
|
|
||
| export const collectHardwareSnapshot = async (): Promise<HardwareSnapshot> => { | ||
| const snapshot: HardwareSnapshot = { | ||
| cpu_cores: null, | ||
| device_memory_gb: null, | ||
| js_heap_limit_mb: null, | ||
| js_heap_used_mb: null, | ||
| battery_level: null, | ||
| battery_charging: null, | ||
| } | ||
|
|
||
| try { | ||
| snapshot.cpu_cores = navigator.hardwareConcurrency ?? null | ||
|
|
||
| const nav = navigator as Navigator & { | ||
| deviceMemory?: number | ||
| userAgentData?: { mobile?: boolean; platform?: string } | ||
| getBattery?: () => Promise<{ level: number; charging: boolean }> | ||
| } | ||
|
|
||
| snapshot.device_memory_gb = nav.deviceMemory ?? null | ||
|
|
||
| const memory = ( | ||
| performance as Performance & { | ||
| memory?: { jsHeapSizeLimit?: number; usedJSHeapSize?: number } | ||
| } | ||
| ).memory | ||
| snapshot.js_heap_limit_mb = toMb(memory?.jsHeapSizeLimit) | ||
| snapshot.js_heap_used_mb = toMb(memory?.usedJSHeapSize) | ||
|
|
||
| if (typeof nav.getBattery === 'function') { | ||
| // getBattery can hang on some platforms — don't let it delay the event. | ||
| let timeoutId: ReturnType<typeof setTimeout> | undefined | ||
| const battery = await Promise.race([ | ||
| nav.getBattery(), | ||
| new Promise<null>((resolve) => { | ||
| timeoutId = setTimeout(() => resolve(null), BATTERY_TIMEOUT_MS) | ||
| }), | ||
| ]) | ||
| .catch(() => null) | ||
| .finally(() => clearTimeout(timeoutId)) | ||
| if (battery) { | ||
| snapshot.battery_level = battery.level | ||
| snapshot.battery_charging = battery.charging | ||
| } | ||
| } | ||
| } catch { | ||
| // telemetry must never break the app | ||
| } | ||
|
|
||
| return snapshot | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { useToast } from 'react-aria' | ||
| import { useRef } from 'react' | ||
| import { RiCloseLine } from '@remixicon/react' | ||
|
|
||
| import { type ToastProps } from './Toast' | ||
| import { HStack, VStack } from '@/styled-system/jsx' | ||
| import { useTranslation } from 'react-i18next' | ||
| import { Button, Text } from '@/primitives' | ||
| import { css } from '@/styled-system/css' | ||
| import { StyledToastContainer } from './StyledToastContainer' | ||
| import { disablePerformanceMode } from '@/stores/performanceMode' | ||
| import { captureEvent } from '@/features/analytics/telemetry' | ||
|
|
||
| export function ToastCpuConstrained({ state, ...props }: Readonly<ToastProps>) { | ||
| const { t } = useTranslation('notifications', { | ||
| keyPrefix: 'cpuConstrained', | ||
| }) | ||
| const ref = useRef(null) | ||
| const { toastProps, contentProps, closeButtonProps } = useToast( | ||
| props, | ||
| state, | ||
| ref | ||
| ) | ||
| const toast = props.toast | ||
|
|
||
| const handleKeepQuality = () => { | ||
| captureEvent('cpu-constrained-degradation-cancelled') | ||
| disablePerformanceMode({ declinedAuto: true }) | ||
| state.close(toast.key) | ||
| } | ||
|
|
||
| return ( | ||
| <StyledToastContainer {...toastProps} ref={ref}> | ||
| <HStack alignItems="start" gap="0.5rem" padding={14}> | ||
| <VStack | ||
| justify="start" | ||
| alignItems="self-start" | ||
| {...contentProps} | ||
| maxWidth="370px" | ||
| gap="0.75rem" | ||
| > | ||
| <Text | ||
| margin={false} | ||
| className={css({ | ||
| wordBreak: 'break-word', | ||
| overflowWrap: 'break-word', | ||
| whiteSpace: 'normal', | ||
| })} | ||
| > | ||
| {t('message')} | ||
| </Text> | ||
| <Button | ||
| size="sm" | ||
| variant="text" | ||
| className={css({ | ||
| color: 'primary.300', | ||
| })} | ||
| onPress={() => handleKeepQuality()} | ||
| > | ||
| {t('keepQuality')} | ||
| </Button> | ||
| </VStack> | ||
| <Button square size="sm" invisible {...closeButtonProps}> | ||
| <RiCloseLine size={18} color="white" /> | ||
| </Button> | ||
| </HStack> | ||
| </StyledToastContainer> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import { useEffect, useRef } from 'react' | ||
| import { useRoomContext } from '@livekit/components-react' | ||
| import { | ||
| LocalTrackPublication, | ||
| LocalVideoTrack, | ||
| ParticipantEvent, | ||
| Track, | ||
| } from 'livekit-client' | ||
|
|
||
| import { captureEvent } from '@/features/analytics/telemetry' | ||
| import { collectHardwareSnapshot } from '@/features/analytics/hardware' | ||
| import { notifyCpuConstrained } from '@/features/notifications/utils' | ||
| import { isFireFox } from '@/utils/livekit' | ||
| import { | ||
| enablePerformanceMode, | ||
| performanceModeStore, | ||
| } from '@/stores/performanceMode' | ||
|
|
||
| export const CpuConstrainedObserver = () => { | ||
| const room = useRoomContext() | ||
| const degradedTracksRef = useRef(new WeakSet<LocalVideoTrack>()) | ||
|
|
||
| useEffect(() => { | ||
| const localParticipant = room.localParticipant | ||
|
|
||
| const handleCpuConstrained = ( | ||
| track: LocalVideoTrack, | ||
| publication: LocalTrackPublication | ||
| ) => { | ||
| const { enabled, userDeclinedAuto } = performanceModeStore | ||
|
|
||
| const shouldDegrade = | ||
| publication.source === Track.Source.Camera && | ||
| !enabled && | ||
| !userDeclinedAuto && | ||
| !degradedTracksRef.current.has(track) | ||
|
|
||
| void collectHardwareSnapshot().then((hardware) => { | ||
| captureEvent('cpu-constrained', { | ||
| firefox: isFireFox(), | ||
| source: publication.source, | ||
| degraded: shouldDegrade, | ||
| trackOptions: publication.options, | ||
| performance_mode_enabled: enabled, | ||
| user_declined_auto: userDeclinedAuto, | ||
| ...hardware, | ||
| }) | ||
| }) | ||
|
|
||
| if (!shouldDegrade) return | ||
| degradedTracksRef.current.add(track) | ||
|
|
||
| enablePerformanceMode('cpu') | ||
| notifyCpuConstrained() | ||
| } | ||
|
|
||
| localParticipant.on( | ||
| ParticipantEvent.LocalTrackCpuConstrained, | ||
| handleCpuConstrained | ||
| ) | ||
|
|
||
| return () => { | ||
| localParticipant.off( | ||
| ParticipantEvent.LocalTrackCpuConstrained, | ||
| handleCpuConstrained | ||
| ) | ||
| } | ||
| }, [room]) | ||
|
|
||
| return null | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| import { useEffect } from 'react' | ||
| import { useRoomContext } from '@livekit/components-react' | ||
| import { | ||
| LocalTrackPublication, | ||
| LocalVideoTrack, | ||
| ParticipantEvent, | ||
| Track, | ||
| TrackEvent, | ||
| } from 'livekit-client' | ||
| import { useSnapshot } from 'valtio' | ||
| import { reportError } from '@/features/analytics/telemetry' | ||
| import { | ||
| disablePerformanceMode, | ||
| performanceModeStore, | ||
| } from '@/stores/performanceMode' | ||
| import { degradeVideoTrack, restoreVideoTrack } from '../degradation' | ||
|
|
||
| /** Delay to re-apply degradation after restart to avoid racing LiveKit's encoding recompute. */ | ||
| const REAPPLY_AFTER_RESTART_MS = 1_000 | ||
|
|
||
| /** Syncs performance mode store state to outbound camera track encoding settings. */ | ||
| export const PerformanceModeController = () => { | ||
| const room = useRoomContext() | ||
| const { enabled } = useSnapshot(performanceModeStore) | ||
|
|
||
| // Manage degradation application and track lifecycle events | ||
| useEffect(() => { | ||
| const localParticipant = room.localParticipant | ||
|
|
||
| const getCameraTrack = () => { | ||
| const pub = localParticipant.getTrackPublication(Track.Source.Camera) | ||
| return pub?.track instanceof LocalVideoTrack ? pub.track : null | ||
| } | ||
|
|
||
| const track = getCameraTrack() | ||
|
|
||
| // Restore track quality if performance mode is disabled | ||
| if (!enabled) { | ||
| if (track) { | ||
| restoreVideoTrack(track).catch((err) => | ||
| reportError('performance_mode_failure', err, { action: 'restore' }) | ||
| ) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| const applyDegradation = (t: LocalVideoTrack, action = 'degrade') => { | ||
| degradeVideoTrack(t).catch((err) => | ||
| reportError('performance_mode_failure', err, { action }) | ||
| ) | ||
|
Comment on lines
+38
to
+50
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
ast-grep outline src/frontend/src/features/performance --items all
rg -n -C 6 '\b(getParameters|setParameters|degradeVideoTrack|restoreVideoTrack)\b' \
src/frontend/src/features/performance src/frontend/src/features/roomsRepository: suitenumerique/meet Length of output: 9423 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- controller ---'
cat -n src/frontend/src/features/performance/components/PerformanceModeController.tsx | sed -n '1,180p'
printf '%s\n' '--- degradation ---'
cat -n src/frontend/src/features/performance/degradation.ts | sed -n '1,180p'
printf '%s\n' '--- relevant call sites and track lifecycle ---'
rg -n -C 8 'PerformanceModeController|disablePerformanceMode|performanceModeStore|getCameraTrack|addListener|restart|LocalVideoTrack' \
src/frontend/src/features/performance src/frontend/src/features/rooms src/frontend/src/storesRepository: suitenumerique/meet Length of output: 50375 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- CPU observer ---'
cat -n src/frontend/src/features/performance/components/CpuConstrainedObserver.tsx | sed -n '20,130p'
printf '%s\n' '--- all degradation call sites ---'
rg -n -C 5 '\b(degradeVideoTrack|restoreVideoTrack|isTrackDegraded)\b' \
src/frontend/src --glob '*.ts' --glob '*.tsx'
printf '%s\n' '--- tests and package versions ---'
rg -n -i 'performance.?mode|degrad|setParameters|getParameters' \
src/frontend --glob '*test*' --glob '*spec*' --glob 'package.json' --glob 'pnpm-lock.yaml' --glob 'yarn.lock' --glob 'package-lock.json' || trueRepository: suitenumerique/meet Length of output: 8263 🏁 Script executed: #!/bin/bash
set -euo pipefail
node - <<'JS'
const pristine = [
{ rid: 'low', active: true, maxBitrate: 900000 },
{ rid: 'high', active: true, maxBitrate: 2500000 },
]
let current = structuredClone(pristine)
const pending = []
const saved = new Map()
const track = {}
const sender = {
getParameters() {
return { encodings: structuredClone(current) }
},
setParameters(params) {
return new Promise((resolve) => pending.push({ params, resolve }))
},
}
track.sender = sender
async function degradeVideoTrack(t) {
const params = t.sender.getParameters()
if (!saved.has(t)) saved.set(t, structuredClone(params.encodings))
params.encodings = params.encodings.map((encoding, index) =>
index === 0
? { ...encoding, maxBitrate: 300000 }
: { ...encoding, active: false }
)
await t.sender.setParameters(params)
}
async function restoreVideoTrack(t) {
const snapshot = saved.get(t)
saved.delete(t)
if (!snapshot) return
const params = t.sender.getParameters()
params.encodings = params.encodings.map((encoding, index) => ({
...encoding,
...snapshot[index],
}))
await t.sender.setParameters(params)
}
const degradation = degradeVideoTrack(track)
const restoration = restoreVideoTrack(track)
if (pending.length !== 2) throw new Error(`expected 2 pending writes, got ${pending.length}`)
// Complete restoration first, then the already-pending degradation.
current = structuredClone(pending[1].params.encodings)
pending[1].resolve()
await restoration
current = structuredClone(pending[0].params.encodings)
pending[0].resolve()
await degradation
console.log(JSON.stringify({
finalEncodings: current,
restored: JSON.stringify(current) === JSON.stringify(pristine),
pendingWrites: 2,
}))
JSRepository: suitenumerique/meet Length of output: 312 Serialize sender updates per
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // Re-apply degradation after track restarts (e.g. device/resolution changes) | ||
| const watchTrackRestart = (t: LocalVideoTrack) => { | ||
| let timeoutId: ReturnType<typeof setTimeout> | ||
| const onRestarted = () => { | ||
| clearTimeout(timeoutId) | ||
| timeoutId = setTimeout(() => { | ||
| if (performanceModeStore.enabled) applyDegradation(t, 'reapply') | ||
| }, REAPPLY_AFTER_RESTART_MS) | ||
| } | ||
| t.on(TrackEvent.Restarted, onRestarted) | ||
| return () => { | ||
| clearTimeout(timeoutId) | ||
| t.off(TrackEvent.Restarted, onRestarted) | ||
| } | ||
| } | ||
|
|
||
| let unwatchRestart: (() => void) | undefined | ||
|
|
||
| if (track) { | ||
| applyDegradation(track) | ||
| unwatchRestart = watchTrackRestart(track) | ||
| } | ||
|
|
||
| // Apply degradation to newly published camera tracks | ||
| const handlePublished = (pub: LocalTrackPublication) => { | ||
| if ( | ||
| pub.source === Track.Source.Camera && | ||
| pub.track instanceof LocalVideoTrack | ||
| ) { | ||
| unwatchRestart?.() | ||
| applyDegradation(pub.track) | ||
| unwatchRestart = watchTrackRestart(pub.track) | ||
| } | ||
| } | ||
| localParticipant.on(ParticipantEvent.LocalTrackPublished, handlePublished) | ||
|
|
||
| return () => { | ||
| localParticipant.off( | ||
| ParticipantEvent.LocalTrackPublished, | ||
| handlePublished | ||
| ) | ||
| unwatchRestart?.() | ||
| } | ||
| }, [room, enabled]) | ||
|
|
||
| // Reset auto (CPU-triggered) performance mode on unmount/leave room | ||
| useEffect(() => { | ||
| return () => { | ||
| if (performanceModeStore.trigger === 'cpu') { | ||
| disablePerformanceMode() | ||
| } | ||
|
lebaudantoine marked this conversation as resolved.
lebaudantoine marked this conversation as resolved.
|
||
| } | ||
| }, []) | ||
|
|
||
| return null | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.