Keep media playing through headset standby

Real-Frame testing (2026-09-29) found an unworn headset enters standby
within seconds; SetOverlayRaw then returns RequestFailed (23) and the
movie died. The player now drops frames during standby, keeps audio
and pacing, re-sends stills and the theatre surround after waking, and
only errors after five minutes without an accepted frame.

A Stop arriving while the player is already shutting down is ignored,
so a finished video stays 'ended' instead of 'error: Stopped'. The
status now reports the layout's real source (filename/metadata).

Docs record the end-to-end device matrix (API, web UI, CLI).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-29 11:54:05 +10:00
1 parent e702adbd77
commit 094c12c6d8
5 files changed
+138 -11

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+28
View File
@@ -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.
+55
View File
@@ -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'
+54 -10
View File
@@ -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()
+1 -1
View File
@@ -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)