From 094c12c6d84f6b74bd1bad323fcc17d0b6aeee68 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:54:05 +1000 Subject: [PATCH 1/2] 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) --- docs/img/media-ui-panel.png | Bin 0 -> 59098 bytes docs/vr-video.md | 28 ++++++++++++++++ tests/test_media.py | 55 +++++++++++++++++++++++++++++++ ui/frame_media_player.py | 64 ++++++++++++++++++++++++++++++------ ui/frame_media_remote.py | 2 +- 5 files changed, 138 insertions(+), 11 deletions(-) create mode 100644 docs/img/media-ui-panel.png diff --git a/docs/img/media-ui-panel.png b/docs/img/media-ui-panel.png new file mode 100644 index 0000000000000000000000000000000000000000..2e2a41b85bf752db12d33127020ba5e85ba72ae9 GIT binary patch literal 59098 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 UtqAl3aOa7VoVskev{~SP0f#$^_y7O^ literal 0 HcmV?d00001 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) From 1cd56740a6325146c159591ced1a96a0ae3b9467 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:32:12 +1000 Subject: [PATCH 2/2] Media player: keep retrying the theatre surround after a still is shown Review (GPT-6 Astra, P2): the still-image loop stopped calling show() once the screen took its first frame, so a surround refused during standby was never retried and stayed missing for PNG and splat playback until restart. hold() now keeps draining pending uploads after the screen is shown, until both are up. Also: stills and the surround wait out standby without counting as dropped video frames or tripping the five-minute limit (video only); teardown errors no longer overwrite a finished status; Stop is ignored once the outcome is decided. Tests: PNG and splat where the screen is accepted before the surround recovers (fail on the old loop); fake-clock coverage of the five-minute limit and its reset; status keeps filename/metadata layout sources and explicit layouts stay explicit. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_media.py | 143 +++++++++++++++++++++++++++++++++------ ui/frame_media_player.py | 56 ++++++++++----- 2 files changed, 163 insertions(+), 36 deletions(-) diff --git a/tests/test_media.py b/tests/test_media.py index eb3079f..f277341 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -19,6 +19,13 @@ import frame_splat as splat import server +def stop_now(): + """What systemd's SIGTERM does to the player, without signalling the test process.""" + handler = signal.getsignal(signal.SIGTERM) + if callable(handler): + handler(signal.SIGTERM, None) + + class Media(unittest.TestCase): def test_layout_evidence_and_override(self): for name, layout in [('film_SBS.mp4', 'sbs'), ('film.OU.mkv', 'ou'), @@ -59,15 +66,21 @@ 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. + def play_with(self, name, busy=0, on_pixels=None, sleeps=None, info=None, + fail=None, layout='auto'): + """Run the player against a fake OpenVR; returns (status, pixels calls). + + Handle 0 is the theatre surround and 1 the screen. `fail(handle, n)` makes + the n-th upload busy; every status written is kept in self.writes.""" calls = [] + self.writes = [] + write_status = player.write_status + + def record(path, **values): + self.writes.append(values) + write_status(path, **values) class FakeOverlay: - def __init__(self): - self.closed = False - def create(self, *a, **k): return len(calls) @@ -76,41 +89,133 @@ class Media(unittest.TestCase): def pixels(self, handle, data, w, h): calls.append((handle, w, h)) - if len(calls) <= 3: + if on_pixels: + on_pixels(len(calls)) + if len(calls) <= busy or (fail and fail(handle, len(calls))): raise player.OverlayBusy('standby') def close(self): - # A Stop arriving during cleanup must be ignored, not become an error. + # A Stop landing 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) + stop_now() 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 + old = signal.getsignal(signal.SIGTERM), signal.getsignal(signal.SIGINT) 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, 'probe', return_value=(info or {'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' + patch.object(player, 'write_status', side_effect=record), \ + patch.object(player.frame_splat, 'render', return_value=(bytes(4*4*2), 4, 2)), \ + patch.object(player.time, 'sleep', side_effect=sleeps): + path = Path(d)/name 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))) + player.play(argparse.Namespace(file=str(path), layout=layout, theatre=True, status=str(status))) finally: signal.signal(signal.SIGTERM, old[0]) signal.signal(signal.SIGINT, old[1]) - result = json.loads(status.read_text()) + return json.loads(status.read_text()), calls + + 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. + result, calls = self.play_with('clip_SBS.mp4', busy=3) 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) + # Two video frames were dropped; the surround (handle 0) waited and was re-sent. + self.assertEqual(result['dropped'], 2) self.assertIn((0, 1, 1), calls[3:]) + def test_stop_mid_video_reports_stopped(self): + result, _ = self.play_with('clip_SBS.mp4', on_pixels=lambda n: n == 3 and stop_now()) + self.assertEqual(result['state'], 'stopped') + + def test_video_errors_when_steamvr_never_takes_frames(self): + with patch.object(player, 'BUSY_LIMIT', -1), \ + self.assertRaisesRegex(RuntimeError, 'stopped accepting frames'): + self.play_with('clip_SBS.mp4', busy=99) + + def test_still_waits_out_standby_without_a_limit(self): + # Stills have no timeline: keep retrying (here past BUSY_LIMIT) until shown. + ticks = iter(range(10)) + def sleep(_): + if next(ticks) == 8: + stop_now() + with patch.object(player, 'BUSY_LIMIT', -1): + result, calls = self.play_with('photo_SBS.png', busy=5, sleeps=sleep, + info={'codec_name': 'png', 'width': 4, 'height': 2}) + self.assertEqual(result['state'], 'stopped') + screen = [c for c in calls if c[0] == 1] + self.assertGreater(len(screen), 1) # retried through standby + self.assertEqual(calls[-1], (0, 1, 1)) # surround drained once the screen took a frame + + def still_with_late_surround(self, name, **kw): + # The screen takes its first frame while the surround is still refused + # (its first upload, the drain right after the screen, and one retry). + ticks = iter(range(10)) + def sleep(_): + if next(ticks) == 5: + stop_now() + surround_tries = [] + def fail(handle, n): + if handle == 0: + surround_tries.append(n) + return len(surround_tries) <= 3 + return False + result, calls = self.play_with(name, sleeps=sleep, fail=fail, **kw) + self.assertEqual(result['state'], 'stopped') + screen = [c for c in calls if c[0] == 1] + surround = [c for c in calls if c[0] == 0] + self.assertEqual(len(screen), 1) # shown once, not re-sent every second + self.assertEqual(len(surround), 4) # kept retrying after the screen, until it took + self.assertEqual(calls[-1][0], 0) + return calls + + def test_photo_surround_recovers_after_screen_is_shown(self): + self.still_with_late_surround('photo_SBS.png', + info={'codec_name': 'png', 'width': 4, 'height': 2}) + + def test_splat_surround_recovers_after_screen_is_shown(self): + self.still_with_late_surround('scene.splat') + + def test_video_standby_limit_is_five_minutes_without_an_accepted_frame(self): + self.assertEqual(player.BUSY_LIMIT, 300) + def run(times, busy): + # Upload n happens at times[n] seconds on a fake clock; 1 is the surround. + clock = [1000.0] + def on_pixels(n): + clock[0] = 1000.0 + times.get(n, times[max(times)]) + with patch.object(player.time, 'monotonic', side_effect=lambda: clock[0]): + return self.play_with('clip_SBS.mp4', on_pixels=on_pixels, + fail=lambda h, n: h == 1 and n in busy) + # Busy for 299 s, then a frame lands: no error. + result, _ = run({1: 0, 2: 0, 3: 299, 4: 299, 5: 299}, busy={2, 3}) + self.assertEqual((result['state'], result['dropped']), ('ended', 2)) + # An accepted frame resets the timer: 600 s busy in total, never 300 s in a row. + result, _ = run({1: 0, 2: 0, 3: 200, 4: 250, 5: 450}, busy={2, 3, 5}) + self.assertEqual((result['state'], result['dropped']), ('ended', 3)) + # 301 s in a row without an accepted frame is an error. + with self.assertRaisesRegex(RuntimeError, 'stopped accepting frames for 300 s'): + run({1: 0, 2: 0, 3: 301}, busy={2, 3, 4, 5}) + + def test_status_reports_where_the_layout_came_from(self): + video = {'codec_name': 'h264', 'width': 4, 'height': 2} + for name, layout, tags, expect in [ + ('clip_SBS.mp4', 'auto', None, ('sbs', 'filename')), + ('clip.mkv', 'auto', {'stereo_mode': 'left_right'}, ('full-sbs', 'metadata')), + ('clip_OU.mp4', 'sbs', None, ('sbs', 'explicit')), + ('clip.mkv', 'mono', {'stereo_mode': 'left_right'}, ('mono', 'explicit'))]: + with self.subTest(name=name, layout=layout): + self.play_with(name, layout=layout, info=dict(video, tags=tags) if tags else video) + playing = self.writes[0] + self.assertEqual(playing['state'], 'playing') + self.assertEqual((playing['layout'], playing['source']), expect) + 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 eb9750b..5227da2 100644 --- a/ui/frame_media_player.py +++ b/ui/frame_media_player.py @@ -86,11 +86,14 @@ class Overlay: self.call('ShowOverlay', handle) def close(self): - try: - for h in reversed(self.handles): + # Best effort: SteamVR removes a disconnected client's overlays anyway, + # and a teardown error must not overwrite a finished playback's status. + for h in reversed(self.handles): + try: self.call('DestroyOverlay', h) - finally: - self.vr.VR_ShutdownInternal() + except RuntimeError: + pass + self.vr.VR_ShutdownInternal() def probe(path): @@ -125,6 +128,11 @@ def decoder_command(path, info, width, height, audio, photo=False): return cmd +def ignore_signals(): + signal.signal(signal.SIGTERM, signal.SIG_IGN) + signal.signal(signal.SIGINT, signal.SIG_IGN) + + def write_status(path, **values): tmp = path.with_suffix('.tmp') tmp.write_text(json.dumps(values)) @@ -148,30 +156,42 @@ def play(args): 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).""" + def show(handle, data, w, h, video=False): + """Submit a frame. During standby return False; a video frame is dropped.""" nonlocal dropped, busy_since try: vr.pixels(handle, data, w, h) except OverlayBusy: + if not video: + return False # stills and the surround just wait; nothing is lost 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 + if video: + busy_since = None + drain() return True + def drain(): + """Re-send anything that arrived during standby (e.g. the theatre surround).""" + while pending: + item = pending.pop(0) + try: + vr.pixels(*item) + except OverlayBusy: + pending.insert(0, item) + return + 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) + if shown: + drain() + else: + shown = show(handle, data, w, h) time.sleep(1) # systemd sends SIGTERM to the whole unit, including ffmpeg. Python unwinds @@ -204,7 +224,7 @@ def play(args): if photo: still = data, outw, outh else: - show(screen, data, outw, outh) + show(screen, data, outw, outh, video=True) frames += 1 if frames == 1 or frames % 30 == 0: write_status(status, state='playing', file=path.name, frames=frames, @@ -218,14 +238,16 @@ def play(args): raise RuntimeError('Decoder produced no frames') if photo: hold(screen, *still) + # From here on a Stop can't change the outcome; don't let it turn + # 'ended' into an error while we write status and clean up. + ignore_signals() write_status(status, state='ended', frames=frames, dropped=dropped, seconds=time.monotonic()-started) except InterruptedError: + ignore_signals() 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) + ignore_signals() if proc: if proc.poll() is None: proc.terminate()