Skip to content

feat(meta_quest_camera): streaming MJPEG → MP4 (no on-device disk) - #18

Merged
styu12 merged 2 commits into
mainfrom
feat-quest-streaming-recording
Apr 15, 2026
Merged

feat(meta_quest_camera): streaming MJPEG → MP4 (no on-device disk)#18
styu12 merged 2 commits into
mainfrom
feat-quest-streaming-recording

Conversation

@styu12

@styu12 styu12 commented Apr 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Refactor the Quest passthrough camera adapter to stream-record rather than pull files off device after stop. The same /preview/{eye} MJPEG channel that powers the viewer panel now also feeds a passthrough MP4 writer when recording is active. Stop is instant; files land on disk in real time.

Pairs with opengraph-studio#74 (TBD) which delivers the Quest-side companion app changes.

Why

The old design recorded MJPEG-AVI on the Quest itself and pulled ~90 MB-per-eye files over HTTP after /recording/stop. On a real WiFi link that meant 30 s+ blocking after stop, fragile resume logic, and four serialised file downloads. Streaming eliminates all of that.

Bandwidth measurement on the live AP showed:

  • Sustained TCP throughput Quest → Mac: 51 Mbps
  • Required for 720p stereo @ 30 fps Q=75: ~30-40 Mbps
  • Margin: 28%, comfortable for the recording cycle

Changes

New: mp4_writer.pyStreamingVideoRecorder

  • Mux raw JPEG bytes into MP4 (mjpeg codec, PyAV) without re-encoding
  • Sidecar *.timestamps.jsonl with both host-projected and Quest-native ns per frame (post-hoc clock-drift correction)
  • Idempotent start / stop, thread-safe write_frame

Modified: preview.pyMjpegPreviewConsumer

  • New set_frame_sink(sink) hook delivers raw MjpegFrame to a per-recording callback
  • Sink exceptions caught — misbehaving recorder cannot kill the viewer feed
  • MjpegFrame.quest_native_ns parsed from new X-Quest-Native-Ns header (None on older Quest builds)

Refactored: stream.pyMetaQuestCameraStream

  • start_recording attaches sinks to existing consumers (no Quest-side /recording/start call)
  • stop_recording detaches sinks, closes recorders, returns report from in-memory state — no file pull
  • Outcome classification: completed / partial (one eye missing or mux errors) / failed (no frames at all)

Removed

  • file_puller.py + test_file_puller.py
  • timestamps.py + test_timestamps.py

Test Plan

  • All 26 tests/unit/adapters/meta_quest_camera/ tests pass (5 new tests added for streaming flow)
  • PyAV smoke test: 30 synthesised JPEGs → valid MP4, 0 mux errors, ffprobe confirms mjpeg/720x720/30fps
  • Bandwidth measurement on live Quest: 51 Mbps sustained TCP throughput → 30 Mbps requirement met with margin
  • End-to-end recording with live Quest 3 — pending headset-active session (frames don't flow when app loses focus)

🤖 Generated with Claude Code

styu12 and others added 2 commits April 14, 2026 20:17
…e disk)

The previous design recorded MJPEG-AVI on the Quest itself and only
pulled the resulting ~90 MB-per-eye file *after* /recording/stop
returned. On a real WiFi link that meant a 30 s+ blocking wait that
looked like a hang, fragile resume logic, and four serialised file
downloads. Stop becomes instant with this change and the file lands
on disk in real time.

The same /preview/{eye} MJPEG channel that powers the viewer panel
now also feeds a passthrough MP4 writer when recording is active.
One TCP connection per eye carries both responsibilities; recording
is a cheap sink-attach toggle, so the viewer feed never blanks across
a record/stop cycle.

What's new on the Python side
-----------------------------
* mp4_writer.py — StreamingVideoRecorder muxes raw JPEG bytes into
  MP4 (mjpeg codec, PyAV) without re-encoding, plus a sidecar
  timestamps JSONL that now includes BOTH the host-projected and
  Quest-native nanosecond timestamps for post-hoc clock-drift
  correction.
* preview.py — MjpegPreviewConsumer gains a set_frame_sink() hook
  that delivers the raw MjpegFrame to a per-recording callback. Sink
  exceptions are caught so a misbehaving recorder cannot kill the
  viewer feed. MjpegFrame.quest_native_ns is parsed from the new
  X-Quest-Native-Ns header (None on older Quest builds).
* stream.py — MetaQuestCameraStream's start_recording attaches sinks
  to the existing consumers and stop_recording detaches and closes.
  No more Quest-side /recording/start, file pull, or DELETE.
* file_puller.py + timestamps.py removed (and their tests).

Quest-side changes ship in the paired opengraph-studio commit:
PreviewFrameSlot now carries both timestamps, /preview/{eye} emits
X-Quest-Native-Ns, defaults bump to 1280x720 / decimate=1 / Q=75.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@styu12
styu12 merged commit ec58c93 into main Apr 15, 2026
0 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant