diff --git a/docs/img/media-ui-panel.png b/docs/img/media-ui-panel.png new file mode 100644 index 0000000..2e2a41b Binary files /dev/null and b/docs/img/media-ui-panel.png differ diff --git a/docs/vr-video.md b/docs/vr-video.md index 1e242a6..2232915 100644 --- a/docs/vr-video.md +++ b/docs/vr-video.md @@ -75,6 +75,34 @@ All test media were generated by us. No paid content or DRM was involved. ![Our Gaussian-splat stereo preview on the Frame](img/media-splat-proof.png) +**Verified end to end, 2026-09-29** (same build; headset unworn): uploads +through the HTTP API, the web UI and `scripts/push-vr-video.sh`, all played by +the owned player. Our generated test files: + +| Case | Result | +|---|---| +| H.264 half-SBS 1920×1080 with AAC, theatre | 240/240 frames in 8.09 s; audio stream "Frame Control Media" in PulseAudio | +| H.265 half-OU 1920×1080 | 180/180 frames in 6.03 s; red left eye, cyan right | +| H.264 full-SBS 3840×1080, `stereo_mode=left_right` only | Detected from metadata; 150/150 frames in 5.03 s | +| H.264 1280×720, explicit 2D | 150/150 frames in 5.02 s | +| SBS PNG, OU JPEG (theatre) | Correct eye in each capture | +| 3,000-Gaussian `.splat` | Rendered in about 5 s, then held until Stop | +| 2D file on Auto, `_SBS_OU` file, HEIC, VP9 | Refused with the documented message | +| Second Play while one runs | Refused: "Stop the current media…" | + +Stop always left the unit inactive, and no player process remained. + +**Standby (verified):** an unworn Frame turns its displays off a few seconds +after it wakes. `SetOverlayRaw` then returns `RequestFailed` (23). The +first run's movie died there. The player now drops frames while the headset +is in standby, keeps the audio and its clock going, and resumes the picture +when the headset wakes. The 8 s movie above dropped 40 frames and finished. +Stills and the theatre surround are re-sent after waking. Five minutes +without an accepted frame is reported as an error. Headset-view captures +taken during standby show a flat dark frame, not our screen. + +![Media panel in Frame Control while a photo plays on the Frame](img/media-ui-panel.png) + **Verified failed route:** GStreamer 1.24.2's `playbin` selected `v4l2h264dec`, delivered the first RGBA sample and then segfaulted (exit 139) in the basic appsink probe and the OpenVR probe. We do not ship that route. diff --git a/tests/test_media.py b/tests/test_media.py index 9665c27..eb3079f 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -1,6 +1,9 @@ """Owned media planning, eye isolation, decoder choice and fake-Frame ownership.""" +import argparse +import io import json import os +import signal from pathlib import Path import struct import sys @@ -56,6 +59,58 @@ class Media(unittest.TestCase): self.assertNotIn('-re', cmd) self.assertIn('-frames:v', cmd) + def test_video_survives_standby_and_stop_after_end_stays_ended(self): + # Verified 2026-09-29: an unworn Frame enters standby within seconds and + # SetOverlayRaw then returns RequestFailed (23) until it wakes. + calls = [] + + class FakeOverlay: + def __init__(self): + self.closed = False + + def create(self, *a, **k): + return len(calls) + + def call(self, *a): + pass + + def pixels(self, handle, data, w, h): + calls.append((handle, w, h)) + if len(calls) <= 3: + raise player.OverlayBusy('standby') + + def close(self): + # A Stop arriving during cleanup must be ignored, not become an error. + # Call the installed handler directly: a real SIGTERM kills Windows. + handler = signal.getsignal(signal.SIGTERM) + if callable(handler): + handler(signal.SIGTERM, None) + + frame = bytes(4*2*4) + proc = unittest.mock.MagicMock() + proc.stdout = io.BytesIO(frame*4) + proc.wait.return_value = 0 + proc.poll.return_value = 0 + with tempfile.TemporaryDirectory() as d, \ + patch.object(player, 'Overlay', FakeOverlay), \ + patch.object(player, 'probe', return_value=({'codec_name': 'h264', 'width': 4, 'height': 2}, False)), \ + patch.object(player.subprocess, 'Popen', return_value=proc), \ + patch.object(player.time, 'sleep'): + path = Path(d)/'clip_SBS.mp4' + path.write_bytes(b'x') + status = Path(d)/'status.json' + old = signal.getsignal(signal.SIGTERM), signal.getsignal(signal.SIGINT) + try: + player.play(argparse.Namespace(file=str(path), layout='auto', theatre=True, status=str(status))) + finally: + signal.signal(signal.SIGTERM, old[0]) + signal.signal(signal.SIGINT, old[1]) + result = json.loads(status.read_text()) + self.assertEqual((result['state'], result['frames']), ('ended', 4)) + # Surround + first two video frames were dropped; the surround was re-sent after wake. + self.assertEqual(result['dropped'], 3) + self.assertIn((0, 1, 1), calls[3:]) + def test_fake_frame_library_and_traversal(self): with tempfile.TemporaryDirectory() as d, patch.object(remote, 'ROOT', Path(d)): identity = 'a'*32+'/space and quote\'.png' diff --git a/ui/frame_media_player.py b/ui/frame_media_player.py index a6d2bdc..eb9750b 100644 --- a/ui/frame_media_player.py +++ b/ui/frame_media_player.py @@ -33,6 +33,15 @@ SLOTS = { } +class OverlayBusy(RuntimeError): + """SetOverlayRaw's RequestFailed (23): SteamVR isn't taking frames, e.g. the + unworn headset is in standby (verified 2026-09-29). Transient, not fatal.""" + + +# A screen that can't take a frame for this long is broken, not asleep. +BUSY_LIMIT = 300 + + class Overlay: def __init__(self): self.handles = [] @@ -53,6 +62,8 @@ class Overlay: def call(self, name, *values): slot, args = SLOTS[name] rc = C.CFUNCTYPE(C.c_int, *args)(self.table[slot])(*values) + if rc == 23 and name == 'SetOverlayRaw': + raise OverlayBusy('SteamVR is not accepting frames (standby?)') if rc: raise RuntimeError('OpenVR %s failed: %s' % (name, rc)) @@ -135,6 +146,34 @@ def play(args): photo = plan['kind'] == 'photo' command = decoder_command(path, info, width, height, audio, photo) vr, proc, frames, started = None, None, 0, time.monotonic() + dropped, busy_since, pending = 0, None, [] + + def show(handle, data, w, h): + """Submit a frame; during standby drop it (video) or report False (stills retry).""" + nonlocal dropped, busy_since + try: + vr.pixels(handle, data, w, h) + except OverlayBusy: + dropped += 1 + busy_since = busy_since or time.monotonic() + if time.monotonic() - busy_since > BUSY_LIMIT: + raise RuntimeError('SteamVR stopped accepting frames for %d s' % BUSY_LIMIT) + return False + busy_since = None + while pending: # e.g. the theatre surround, if it was sent during standby + item = pending.pop(0) + if not show(*item): + pending.insert(0, item) + break + return True + + def hold(handle, data, w, h): + """Keep a still (photo or splat) up until Stop, retrying through standby.""" + shown = False + while True: + shown = shown or show(handle, data, w, h) + time.sleep(1) + # systemd sends SIGTERM to the whole unit, including ffmpeg. Python unwinds # ownership; no unrelated Steam/SteamVR process or setting is touched. def stop(signum, frame): @@ -146,14 +185,13 @@ def play(args): if args.theatre: surround = vr.create('framecontrol.media.surround', 40, 4, order=0) vr.call('SetOverlayAlpha', surround, .85) - vr.pixels(surround, b'\x00\x00\x00\xff', 1, 1) + if not show(surround, b'\x00\x00\x00\xff', 1, 1): + pending.append((surround, b'\x00\x00\x00\xff', 1, 1)) screen = vr.create('framecontrol.media.screen', 3 if args.theatre else 1.6, 2, plan['layout'] != 'mono', aspect) if splat: - vr.pixels(screen, data, width, height) write_status(status, state='playing', file=path.name, frames=1, **plan) - while True: - time.sleep(1) + hold(screen, data, width, height) proc = subprocess.Popen(command, stdout=subprocess.PIPE) video_start = time.monotonic() while True: @@ -163,11 +201,14 @@ def play(args): data, outw, outh = frame_media.stereo_pixels(data, width, height, plan['layout']) if not photo: time.sleep(max(0, video_start + frames/30 - time.monotonic())) - vr.pixels(screen, data, outw, outh) + if photo: + still = data, outw, outh + else: + show(screen, data, outw, outh) frames += 1 if frames == 1 or frames % 30 == 0: write_status(status, state='playing', file=path.name, frames=frames, - seconds=time.monotonic()-started, **plan) + dropped=dropped, seconds=time.monotonic()-started, **plan) if not photo: time.sleep(max(0, video_start + frames/30 - time.monotonic())) rc = proc.wait(timeout=10) @@ -176,12 +217,15 @@ def play(args): if not frames: raise RuntimeError('Decoder produced no frames') if photo: - while True: - time.sleep(1) - write_status(status, state='ended', frames=frames, seconds=time.monotonic()-started) + hold(screen, *still) + write_status(status, state='ended', frames=frames, dropped=dropped, + seconds=time.monotonic()-started) except InterruptedError: - write_status(status, state='stopped', frames=frames) + write_status(status, state='stopped', frames=frames, dropped=dropped) finally: + # A Stop that lands during cleanup must not turn 'ended' into an error. + signal.signal(signal.SIGTERM, signal.SIG_IGN) + signal.signal(signal.SIGINT, signal.SIG_IGN) if proc: if proc.poll() is None: proc.terminate() diff --git a/ui/frame_media_remote.py b/ui/frame_media_remote.py index 3b70bfe..fc0b2ae 100644 --- a/ui/frame_media_remote.py +++ b/ui/frame_media_remote.py @@ -88,7 +88,7 @@ def run(body): '--property=StandardOutput=append:'+str(RUNTIME/'player.log'), '--property=StandardError=append:'+str(RUNTIME/'player.log'), 'python3', str(RUNTIME/'frame_media_player.py'), str(path), - '--layout', plan['layout'], '--status', str(STATUS)] + '--layout', body.get('layout', 'auto'), '--status', str(STATUS)] if body.get('theatre'): command.append('--theatre') started = subprocess.run(command, capture_output=True, text=True, timeout=15)