mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 07:00:37 +02:00
Fix media stop, upload cleanup and review findings
- Stop is a no-op when the collected player unit is already gone (raw systemctl stop exits 5 on the Frame; verified 2026-09-29). - Surface systemd-run stderr when the player can't start. - Keep the copy error if the cleanup ssh also fails; reject upload names that the play path can never accept. - Allow 60 s for play (ffprobe 30 s + systemd-run 15 s remote). - Docs: four-hour cap is unconditional; no delete action yet; fix a garbled timing sentence. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4d4e45f622
commit
f81f70ed5d
4 files changed
+40
-12
No files matched your search
+6
-5
@@ -53,9 +53,9 @@ All test media were generated by us. No paid content or DRM was involved.
|
|||||||
test in 0.128 s (decode only); converting all frames to RGBA took 0.242 s.
|
test in 0.128 s (decode only); converting all frames to RGBA took 0.242 s.
|
||||||
- Our ffmpeg → Python → OpenVR path submitted all 150 frames and exited 0.
|
- Our ffmpeg → Python → OpenVR path submitted all 150 frames and exited 0.
|
||||||
The 1280×720 prototype took 4.775 s; the full 1920×1080 player took
|
The 1280×720 prototype took 4.775 s; the full 1920×1080 player took
|
||||||
4.618 s. These are wall times, not in-headset frame-rate measurements;
|
4.618 s. These are wall times, not in-headset frame-rate measurements.
|
||||||
These initial probes exposed an early finish. The final player explicitly
|
Finishing a 5 s clip early showed those probes weren't paced, so the final
|
||||||
paces 30 fps output: all 150 frames then completed in **5.025 s**.
|
player paces output at 30 fps: all 150 frames then completed in **5.025 s**.
|
||||||
- The H.265 hardware decoder also completed the generated 1080p clip
|
- The H.265 hardware decoder also completed the generated 1080p clip
|
||||||
(60 frames, exit 0); this is a short compatibility check, not a 4K/8K benchmark.
|
(60 frames, exit 0); this is a short compatibility check, not a 4K/8K benchmark.
|
||||||
- Our actual player, launched through Frame Control's media API, displayed
|
- Our actual player, launched through Frame Control's media API, displayed
|
||||||
@@ -101,8 +101,9 @@ The ffmpeg path above completed instead.
|
|||||||
version rejects files above 20,000 records. It is a stereo preview, not
|
version rejects files above 20,000 records. It is a stereo preview, not
|
||||||
an immersive walk-through.
|
an immersive walk-through.
|
||||||
- A single player can run at a time. It stops at EOF, on **Stop**, or after
|
- A single player can run at a time. It stops at EOF, on **Stop**, or after
|
||||||
four hours if the computer disconnects. Still images remain until Stop
|
four hours in any case, so longer movies are cut off. Still images remain
|
||||||
or that timeout. The log is
|
until Stop or that timeout. There is no delete action yet: remove old
|
||||||
|
uploads from `~/Videos/FrameControl/` over SSH. The log is
|
||||||
`~/.local/share/frame-control/media/player.log` on the Frame.
|
`~/.local/share/frame-control/media/player.log` on the Frame.
|
||||||
- **Panel/stream theatre remains outside this media slice:** SteamVR's
|
- **Panel/stream theatre remains outside this media slice:** SteamVR's
|
||||||
`vrcmd --dock-overlay` accepts `theater`, but docking an existing panel
|
`vrcmd --dock-overlay` accepts `theater`, but docking an existing panel
|
||||||
|
|||||||
+18
-1
@@ -83,9 +83,26 @@ class Media(unittest.TestCase):
|
|||||||
ssh.assert_not_called()
|
ssh.assert_not_called()
|
||||||
|
|
||||||
def test_fake_frame_stop_only_owns_our_unit(self):
|
def test_fake_frame_stop_only_owns_our_unit(self):
|
||||||
with patch.object(remote.subprocess, 'run') as run, patch.object(remote, 'status', return_value={}):
|
with patch.object(remote.subprocess, 'run') as run, patch.object(remote, 'status', return_value={}), \
|
||||||
|
patch.object(remote, 'active', return_value=True):
|
||||||
remote.run({'action': 'stop'})
|
remote.run({'action': 'stop'})
|
||||||
self.assertEqual(run.call_args.args[0], ['systemctl', '--user', 'stop', 'frame-control-media.service'])
|
self.assertEqual(run.call_args.args[0], ['systemctl', '--user', 'stop', 'frame-control-media.service'])
|
||||||
|
# A finished player is already collected; Stop is then a no-op, not an error.
|
||||||
|
with patch.object(remote.subprocess, 'run') as run, patch.object(remote, 'status', return_value={}), \
|
||||||
|
patch.object(remote, 'active', return_value=False):
|
||||||
|
remote.run({'action': 'stop'})
|
||||||
|
run.assert_not_called()
|
||||||
|
|
||||||
|
def test_upload_rejects_unplayable_names_and_keeps_copy_error(self):
|
||||||
|
with patch.object(server, 'ssh') as ssh, patch.object(server, 'push_file') as push:
|
||||||
|
for name in ('.hidden.mp4', 'a\\b_SBS.mp4'):
|
||||||
|
with self.assertRaises(server.Failure):
|
||||||
|
server.push_media(Path('/tmp')/name)
|
||||||
|
ssh.assert_not_called()
|
||||||
|
push.side_effect = server.Failure('copy failed')
|
||||||
|
ssh.side_effect = [None, server.Failure('link down')]
|
||||||
|
with self.assertRaisesRegex(server.Failure, 'copy failed'):
|
||||||
|
server.push_media(Path('/tmp/film_SBS.mp4'))
|
||||||
|
|
||||||
def test_splat_invalid_records_and_stereo_parallax(self):
|
def test_splat_invalid_records_and_stereo_parallax(self):
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ def run(body):
|
|||||||
if action == 'status':
|
if action == 'status':
|
||||||
return status()
|
return status()
|
||||||
if action == 'stop':
|
if action == 'stop':
|
||||||
subprocess.run(['systemctl', '--user', 'stop', UNIT], check=True, timeout=15)
|
# --collect unloads the unit after it exits; stopping it then is a no-op, not an error.
|
||||||
|
if active():
|
||||||
|
subprocess.run(['systemctl', '--user', 'stop', UNIT], check=True, timeout=15)
|
||||||
return {'message': 'Media player stopped', **status()}
|
return {'message': 'Media player stopped', **status()}
|
||||||
if action != 'play':
|
if action != 'play':
|
||||||
raise ValueError('Media action must be list, status, play or stop')
|
raise ValueError('Media action must be list, status, play or stop')
|
||||||
@@ -87,7 +89,9 @@ def run(body):
|
|||||||
'--layout', plan['layout'], '--status', str(STATUS)]
|
'--layout', plan['layout'], '--status', str(STATUS)]
|
||||||
if body.get('theatre'):
|
if body.get('theatre'):
|
||||||
command.append('--theatre')
|
command.append('--theatre')
|
||||||
subprocess.run(command, check=True, capture_output=True, text=True, timeout=15)
|
started = subprocess.run(command, capture_output=True, text=True, timeout=15)
|
||||||
|
if started.returncode:
|
||||||
|
raise RuntimeError('Could not start the media player: ' + (started.stderr.strip() or 'systemd-run exited %s' % started.returncode))
|
||||||
return {'message': 'Starting Frame Control media', 'plan': plan}
|
return {'message': 'Starting Frame Control media', 'plan': plan}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -1262,7 +1262,7 @@ for name, source in json.load(sys.stdin).items():
|
|||||||
ssh("python3 -c " + shlex.quote(installer), stdin=json.dumps(sources))
|
ssh("python3 -c " + shlex.quote(installer), stdin=json.dumps(sources))
|
||||||
try:
|
try:
|
||||||
out = ssh("python3 ~/.local/share/frame-control/media/frame_media_remote.py",
|
out = ssh("python3 ~/.local/share/frame-control/media/frame_media_remote.py",
|
||||||
stdin=json.dumps(body), timeout=45)
|
stdin=json.dumps(body), timeout=60)
|
||||||
except Failure as e:
|
except Failure as e:
|
||||||
for line in reversed(getattr(e, "stdout", "").splitlines()):
|
for line in reversed(getattr(e, "stdout", "").splitlines()):
|
||||||
try:
|
try:
|
||||||
@@ -1278,17 +1278,23 @@ for name, source in json.load(sys.stdin).items():
|
|||||||
def push_media(path):
|
def push_media(path):
|
||||||
# Validate the format, but leave layout selection until playback (ffprobe
|
# Validate the format, but leave layout selection until playback (ffprobe
|
||||||
# can then read metadata on the Frame, where it is installed).
|
# can then read metadata on the Frame, where it is installed).
|
||||||
frame_media.plan(Path(path).name, "mono")
|
name = Path(path).name
|
||||||
|
frame_media.plan(name, "mono")
|
||||||
|
if name.startswith(".") or "\\" in name:
|
||||||
|
raise Failure("Rename the file: media names can't start with a dot or contain a backslash", 400)
|
||||||
token = secrets.token_hex(16)
|
token = secrets.token_hex(16)
|
||||||
dest = "Videos/FrameControl/" + token + "/"
|
dest = "Videos/FrameControl/" + token + "/"
|
||||||
ssh("mkdir -p ~/" + dest)
|
ssh("mkdir -p ~/" + dest)
|
||||||
try:
|
try:
|
||||||
push_file(path, dest)
|
push_file(path, dest)
|
||||||
except Exception:
|
except Exception:
|
||||||
ssh("rm -rf ~/" + dest)
|
try:
|
||||||
|
ssh("rm -rf ~/" + dest)
|
||||||
|
except Failure:
|
||||||
|
pass # keep the copy error; an empty folder isn't listed as media
|
||||||
raise
|
raise
|
||||||
return {"message": "Media sent. Choose its layout and press Play.",
|
return {"message": "Media sent. Choose its layout and press Play.",
|
||||||
"id": token + "/" + Path(path).name}
|
"id": token + "/" + name}
|
||||||
|
|
||||||
|
|
||||||
POST = {"/api/media": media, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
POST = {"/api/media": media, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||||
|
|||||||
Reference in new issue
Block a user