Files
MultiRoombaRover/perf/spectate-issues/003-room-camera-snapshot-object-url-churn.md
T
2026-06-12 01:01:53 -04:00

203 lines
5.7 KiB
Markdown

# Issue 003: Room Camera And Snapshot Object URL Churn
## Summary
Room camera and rover snapshot frames are delivered as binary payloads. The client creates
a new `Blob`, creates a new object URL, revokes the previous URL, and updates React state
for every frame. In the 30s `/spectate` audit, this caused 117 `img.src` mutations and
11MB of binary socket payloads.
## Severity
Medium-high.
This is not the biggest measured CPU source, but it is steady work and very visible on
low-end devices because JPEG decoding, Blob allocation, object URL churn, image source
updates, and React state updates all happen together.
## Upstream Likelihood
Low.
This is mostly `/spectate` and camera-panel specific. It will not be fixed by the `/`
input, control context, or log work. It may benefit indirectly if broad rerenders are
reduced, but the frame handling itself needs targeted changes.
## Affected Files
- `webui/src/hooks/useRoomCameraSnapshots.js`
- `webui/src/hooks/useRoverSnapshots.js`
- `webui/src/components/RoomCameraPanel/index.jsx`
- `webui/src/components/RoomCameraFeed/index.jsx`
- `server/src/services/roomCameraService/socketGateway.js`
- `server/src/services/roverSnapshotService/socketGateway.js`
## Evidence
Artifact:
```sh
perf/results/2026-06-12T04-55-16-888Z-root-runtime/root-runtime-report.json
```
30s sample:
```txt
unlabeled binary socket events: 126
unlabeled binary bytes: 11,048,477
roomCamera:frame events: 117
roverSnapshot:frame events: 7
img src mutations: 117
blink class mutations: 117
```
The 8x throttled profile also had these CPU entries:
```txt
Blob
decodeString
setAttribute
```
Those line up with binary socket parsing, Blob/object URL creation, and `img.src` updates.
## Current Code Path
`useRoomCameraSnapshots` handles each frame:
```txt
webui/src/hooks/useRoomCameraSnapshots.js:78
const blob = new Blob([buffer], { type: 'image/jpeg' });
webui/src/hooks/useRoomCameraSnapshots.js:79
const url = URL.createObjectURL(blob);
webui/src/hooks/useRoomCameraSnapshots.js:82
URL.revokeObjectURL(prevUrl);
webui/src/hooks/useRoomCameraSnapshots.js:85
setFeeds(...)
```
`useRoverSnapshots` has the same pattern.
`RoomCameraFeed` writes that object URL into an image:
```txt
webui/src/components/RoomCameraFeed/index.jsx:24
<img src={feed.objectUrl} ... />
```
It also toggles blink state for each frame:
```txt
webui/src/components/RoomCameraFeed/index.jsx:12
setBlink((prev) => !prev);
```
Server-side frame rates:
```txt
server/src/services/roomCameraService/socketGateway.js:11
STREAM_INTERVAL_MS = 1000
server/src/services/roverSnapshotService/socketGateway.js:11
STREAM_INTERVAL_MS = 333
```
## Why This Matters
The page is doing memory allocation and DOM/image decode work on every camera frame. At
one room camera per second this can be acceptable, but `/spectate` is already busy. When
combined with live WebRTC and telemetry maps, this becomes another steady CPU drain.
The blink dot is also doing one React state update and class mutation per frame. It is
small, but it is pure extra work.
## Fix Strategies
### Option A: Drop Frames When The Client Is Behind
Keep only the latest pending frame per camera. If the previous object URL has not been
painted or decoded yet, replace the pending buffer instead of forcing every frame through
React.
### Option B: Throttle Display Updates On Mobile
Render room camera frames at a lower client-side display rate:
```txt
desktop: 1fps as today, or configured
mobile: 0.2-0.5fps for secondary room cameras
```
The server can still emit at its existing rate for other clients, but this client can
choose not to render every frame.
### Option C: Lazy Mount Or Collapse Room Cameras On Mobile
On portrait mobile, `SecondaryRow` can defer `RoomCameraPanel` until:
- the user scrolls near it
- the user opens the panel
- the page is idle after initial rover feeds have settled
### Option D: Remove Per-Frame React Blink State
Replace the blink toggle with CSS animation or a timestamp updated less often.
For example:
- keep status text
- use a CSS pulse class while status is `playing`
- avoid toggling React state on every frame
### Option E: Stabilize Room Camera Source Lists
`RoomCameraPanel` currently passes a newly created array of objects into the snapshot hook:
```txt
webui/src/components/RoomCameraPanel/index.jsx:37
useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })))
```
The hook ultimately keys by IDs, so this is not the main issue, but it still creates extra
memo/effect churn on rerender. Prefer a memoized array of IDs:
```jsx
const cameraIds = useMemo(() => cameras.map((camera) => camera.id), [cameras]);
const feedMap = useRoomCameraSnapshots(cameraIds);
```
## Recommended Path
Start with the cheap fixes:
1. Remove the per-frame blink state.
2. Memoize camera IDs in `RoomCameraPanel`.
3. Add client-side render throttling/drop-latest behavior in `useRoomCameraSnapshots`.
4. Consider lazy mounting/collapsing room cameras in mobile spectator layout.
## Validation
Run:
```sh
CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 \
node perf/live-root-runtime-audit.mjs https://rover.otter.land/spectate perf/results
```
Expected improvements:
- `img.h-full.w-full:attributes:src` mutations should drop if display is throttled.
- `span.h-2.w-2:attributes:class` mutations should disappear if blink state is removed.
- `ScriptDuration` and long tasks should improve modestly.
- Binary socket bytes may stay the same unless server subscription/rate is changed.
## Risks
- Reducing room camera update rate can make the room view feel stale.
- Object URL lifecycle must stay correct. Do not leak old URLs.
- If lazy mounting unsubscribes too aggressively, first frame display may feel delayed.