mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 00:00:21 +02:00
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:
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 |
@@ -75,6 +75,34 @@ All test media were generated by us. No paid content or DRM was involved.
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user