mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 01:01:42 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06f79b6649 | ||
|
|
ec2617fd9d | ||
|
|
3e488ae880 |
No files matched your search
@@ -76,7 +76,7 @@ Drag files onto the window to send them. Drop a game's .zip, folder or .exe to a
|
||||
<td valign="top">
|
||||
|
||||
**📸 Screenshots and recordings**<br>
|
||||
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record SteamVR's mirrored headset view, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder. [Recording details and passthrough limits](docs/recording.md#does-record-include-passthrough).
|
||||
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record what the headset shows, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder ([not tested on a headset yet](docs/recording.md)).
|
||||
|
||||
**⌨️ Keyboard and trackpad**<br>
|
||||
Type and point in the headset from your computer or phone, through Valve's own input path, with nothing to install. Accents and emoji use KDE Connect, which Frame Control brings along and sets up the first time you type one. Copy text to the headset's clipboard and back again. Press T to open it.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.20",
|
||||
"version": "0.4.19",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.20",
|
||||
"version": "0.4.19",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.20",
|
||||
"version": "0.4.19",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# SteamOS 0.5.6 compatibility check, 25 October 2026
|
||||
|
||||
The real Frame was accessed **after** the 00:00 AEDT hold, under a separately
|
||||
acquired `/tmp/frame-test.lock`. The coordinator had finished its v0.4.19 smoke
|
||||
test and released its lock first. This check took place approximately 00:04–
|
||||
00:08 AEDT (13:04–13:08 UTC). A device clock reading in the first snapshot was
|
||||
**00:04:58 AEDT**. The one temporary capture was removed and our lock was
|
||||
released. Steam and SteamVR were left running.
|
||||
|
||||
| Check | Observation |
|
||||
|---|---|
|
||||
| OS | `VERSION_ID=0.5.6`, `VARIANT_ID=vr`, `BUILD_ID=20261022.6153909` |
|
||||
| Update channel | `atomupd-manager tracked-branch` returned `beta`; tracked variant `vr`; update status `idle`. `atomupd-manager status` and `check` returned D-Bus `AccessDenied` to the normal user. No root, update, or channel change was attempted. |
|
||||
| Discovery | `frame.local` and a sample `frame-0000.local` name were unresolved from the Linux host. Avahi was active on the Frame, but `/etc/avahi/avahi-daemon.conf` has `publish-addresses=no`. The configured `ssh frame` alias worked. The host reached the Frame over Tailscale, so a local-subnet scan and same-LAN cached-IP reconnection were not exercised. |
|
||||
| Script fallback | `steamos-session-select` was present and `holo-session-select` absent. The installed devkit utilities contained `steamos-prepare-upload` and `steamos-delete`; their `holo-*` replacements were absent. No session switch, install, or delete command was run. |
|
||||
| Battery and memory | Frame Control's `ui/frame_status.py` returned a system battery at 98%, Charging, with its source under `/sys/class/power_supply/max1720x_bat_7-36`; memory was 16,422,952,960 bytes total and 11,256,848,384 bytes available. The battery was 97% at the initial raw sysfs check and 98% at the final check while charging. |
|
||||
| Recording source | The user `steamvr-v4l2cam.service` and `/dev/video99` were present. A single frame from that device was black while the unworn headset was in standby. Frame Control's MP4 path reads that same SteamVR `system.HeadsetView` mirror and never opens a passthrough camera. Active passthrough inclusion in the mirror remains unverified. The sample image was not retained or published. |
|
||||
| Dominant eye | Neither standard `steamvr.vrsettings` path checked had a readable dominant-eye setting. No source or effect on the mirror was verified; Frame Control does not attach dominant-eye metadata. |
|
||||
|
||||
This was a bounded compatibility check, not a rerun of the coordinator's
|
||||
13-step smoke test. The coordinator reported that smoke passed on this same
|
||||
build, with battery 95% to 96% while charging and cleanup complete.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Headset view in OBS
|
||||
|
||||
Frame Control can send SteamVR's headset mirror and the Frame's output audio to
|
||||
OBS on a Linux or macOS computer. The helper uses the same `/dev/video99` video
|
||||
source and default-output monitor as **Capture & Live → Record**. It sends a
|
||||
local MPEG-TS stream; no port is opened to the LAN or internet.
|
||||
|
||||
1. Connect to the Frame with `ssh frame` and start SteamVR. Install `python3`
|
||||
on the computer; the Frame already has `ffmpeg`. OBS runs on the computer.
|
||||
2. From this repository, run:
|
||||
|
||||
```sh
|
||||
python3 scripts/obs-headset-source.py
|
||||
```
|
||||
|
||||
3. In OBS, add a **Media Source**, clear **Local File**, set **Input** to
|
||||
`udp://127.0.0.1:15099`, and set **Input Format** to `mpegts`. The source
|
||||
carries video and AAC audio. Use OBS's audio mixer to choose monitoring and
|
||||
recording levels. Leave the helper running while the source is in use;
|
||||
Ctrl+C stops it.
|
||||
|
||||
`--height 1080 --fps 60` increases quality and Frame CPU use. The default is
|
||||
720p30. `--port 15100` selects another loopback port; use the same port in
|
||||
OBS. If the Frame's PulseAudio monitor cannot be read, the helper exits with
|
||||
an error. Use `--no-audio` only when a silent view is useful. To inspect the
|
||||
stream without OBS, run `ffprobe -v error -show_streams
|
||||
udp://127.0.0.1:15099` in a second terminal while the helper is running.
|
||||
|
||||
This is the composited, undistorted one-eye **SteamVR mirror**, not a raw
|
||||
passthrough camera feed. The mirror may be blank when the headset is idle or
|
||||
unworn. Whether active passthrough pixels appear in it is still unverified.
|
||||
The helper does not send a microphone. It is designed for one local OBS
|
||||
receiver. **Verified 2026-10-24:** against a real Frame running SteamOS 0.5.6,
|
||||
build 20261022.6153909, the receiver saved a 4.021-second MPEG-TS sample on
|
||||
the Linux PC; `ffprobe` found H.264 video and AAC audio. The temporary sample
|
||||
was removed. The headset was unworn, so visible content and audible sound were
|
||||
not checked. OBS itself was not installed on that PC; its Media Source setup,
|
||||
monitoring and sync are still unverified.
|
||||
+1
-19
@@ -1,9 +1,6 @@
|
||||
# Recording and Steam clips
|
||||
|
||||
For a live OBS Media Source with headset output audio, see
|
||||
[Headset view in OBS](obs-headset-source.md).
|
||||
|
||||
Frame Control can record SteamVR's mirrored headset view, with its sound, and save it as
|
||||
Frame Control can record what the headset shows, with its sound, and save it as
|
||||
an `.mp4` on your computer. It can also export the clips and background
|
||||
recordings from Steam's own game recording, at any length, as one `.mp4` each.
|
||||
Both are on **Tools → Capture & Live**. Everything is saved to
|
||||
@@ -50,21 +47,6 @@ the Frame, converted to `.mp4` there (without re-encoding) and copied to
|
||||
|
||||
Recording and **Live** can run together; both read the same device.
|
||||
|
||||
### Does Record include passthrough?
|
||||
|
||||
**Record captures SteamVR's `system.HeadsetView` mirror from `/dev/video99`.**
|
||||
Frame Control does not open the headset's passthrough cameras or add a separate
|
||||
camera layer to the MP4. If SteamVR puts passthrough into that mirror, it will
|
||||
appear in the recording; Frame Control cannot force or promise that result.
|
||||
Steam's own **Steam clips** use a separate game recording path and may differ.
|
||||
|
||||
On 25 October 2026, the Frame was in standby. A frame captured from
|
||||
`/dev/video99` was black, as expected in standby, so this check **did not
|
||||
establish whether active passthrough appears in the mirror**. A wearer-visible
|
||||
passthrough session and its simultaneous mirror frame are still needed for a
|
||||
yes-or-no answer about passthrough pixels. See the
|
||||
[live compatibility evidence](evidence/steamos-compat-live-2026-10-25.md).
|
||||
|
||||
## Steam clips
|
||||
|
||||
**Steam clips** lists what Steam's game recording has kept on the Frame: saved
|
||||
|
||||
@@ -53,13 +53,8 @@ The battery reader selects a system battery by power-supply `type` and `scope`
|
||||
and reads standard sysfs fields. The memory reader uses `/proc/meminfo`.
|
||||
Neither relies on the performance overlay's labels or hard-coded SteamOS
|
||||
paths. SteamOS 0.5.6's improved overlay reporting does not establish a sysfs
|
||||
path change. A live check on build `20261022.6153909` confirmed that Frame
|
||||
Control's existing readers return battery and memory values. The dominant-eye
|
||||
setting is not read yet; recording metadata must not claim an eye until its
|
||||
source and effect on `/dev/video99` are verified. The same check found the
|
||||
`beta` update branch, but the normal user could not query update availability
|
||||
through Atomupd; no update was applied. See the
|
||||
[live evidence](evidence/steamos-compat-live-2026-10-25.md).
|
||||
path change. The dominant-eye setting is not read yet; recording metadata
|
||||
must not claim an eye until its source and effect on `/dev/video99` are verified.
|
||||
|
||||
Sources: [SteamOS 3.9 news](https://store.steampowered.com/news/app/1675200),
|
||||
[Frame OS news](https://steamcommunity.com/app/4165890/allnews/), and the
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Send the Frame's SteamVR mirror and output audio to a local OBS Media Source.
|
||||
|
||||
OBS opens udp://127.0.0.1:15099 with input format mpegts. Nothing is bound to
|
||||
the network: this process sends MPEG-TS packets to the computer's loopback.
|
||||
"""
|
||||
import argparse
|
||||
import shlex
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
PACKET = 188
|
||||
DATAGRAM = PACKET * 7
|
||||
|
||||
|
||||
def remote_command(height=720, fps=30, audio=True):
|
||||
if height not in (720, 1080) or fps not in (30, 60):
|
||||
raise ValueError('height must be 720 or 1080; fps must be 30 or 60')
|
||||
rate = 3 if height == 720 else 6
|
||||
clock = '-thread_queue_size 1024 -use_wallclock_as_timestamps 1'
|
||||
video = f'{clock} -f v4l2 -video_size 1920x1080 -i /dev/video99'
|
||||
sound = f' {clock} -f pulse -i @DEFAULT_MONITOR@' if audio else ''
|
||||
maps = '-map 0:v:0 -map 1:a:0' if audio else '-map 0:v:0'
|
||||
encode = (f'-vf fps={fps},scale=-2:{height},format=yuv420p '
|
||||
f'-c:v libx264 -preset ultrafast -tune zerolatency -g {fps * 2} -bf 0 '
|
||||
f'-b:v {rate}M -maxrate {rate}M -bufsize {rate}M '
|
||||
'-x264-params repeat-headers=1')
|
||||
if audio:
|
||||
encode += ' -af aresample=async=1 -c:a aac -b:a 160k'
|
||||
return ("export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}; "
|
||||
"test -e /dev/video99 || { echo 'SteamVR mirror /dev/video99 is missing' >&2; exit 3; }; "
|
||||
f'ffmpeg -hide_banner -loglevel warning -nostdin {video}{sound} '
|
||||
f'{maps} {encode} -f mpegts -flush_packets 1 -')
|
||||
|
||||
|
||||
def relay(source, destination):
|
||||
"""Preserve 188-byte MPEG-TS packet boundaries in loopback datagrams."""
|
||||
pending = bytearray()
|
||||
total = 0
|
||||
while data := source.read(DATAGRAM):
|
||||
pending.extend(data)
|
||||
size = min(len(pending) // PACKET * PACKET, DATAGRAM)
|
||||
if size:
|
||||
destination.send(bytes(pending[:size]))
|
||||
del pending[:size]
|
||||
total += size
|
||||
if pending:
|
||||
raise RuntimeError('Frame stream ended with an incomplete MPEG-TS packet')
|
||||
return total
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--frame', default='frame', help='SSH host or alias (default: frame)')
|
||||
parser.add_argument('--port', type=int, default=15099, help='local OBS UDP port (default: 15099)')
|
||||
parser.add_argument('--height', type=int, choices=(720, 1080), default=720)
|
||||
parser.add_argument('--fps', type=int, choices=(30, 60), default=30)
|
||||
parser.add_argument('--no-audio', action='store_true', help='video only when the Frame audio monitor is unavailable')
|
||||
args = parser.parse_args(argv)
|
||||
if not 1024 <= args.port <= 65535:
|
||||
parser.error('port must be between 1024 and 65535')
|
||||
if not args.frame or args.frame.startswith('-') or any(c.isspace() for c in args.frame):
|
||||
parser.error('frame must be one SSH host or alias')
|
||||
command = remote_command(args.height, args.fps, not args.no_audio)
|
||||
ssh = ['ssh', '-T', '-o', 'BatchMode=yes', args.frame, 'bash', '-lc', shlex.quote(command)]
|
||||
target = ('127.0.0.1', args.port)
|
||||
print(f'Sending Frame view to udp://127.0.0.1:{args.port} for OBS; press Ctrl+C to stop.', file=sys.stderr)
|
||||
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp:
|
||||
proc = subprocess.Popen(ssh, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE)
|
||||
try:
|
||||
amount = relay(proc.stdout, _Sender(udp, target))
|
||||
code = proc.wait(timeout=10)
|
||||
if code or not amount:
|
||||
raise RuntimeError(f'Frame stream stopped (SSH exit {code}; {amount} bytes). '
|
||||
'Check SteamVR and the audio monitor; try --no-audio if needed.')
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
finally:
|
||||
proc.stdout.close()
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
|
||||
class _Sender:
|
||||
def __init__(self, sock, target):
|
||||
self.sock, self.target = sock, target
|
||||
|
||||
def send(self, data):
|
||||
self.sock.sendto(data, self.target)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
sys.exit(main())
|
||||
except (OSError, RuntimeError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -1,45 +0,0 @@
|
||||
"""The OBS relay must preserve transport packets and keep its output local."""
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / 'scripts' / 'obs-headset-source.py'
|
||||
spec = importlib.util.spec_from_file_location('obs_headset_source', SCRIPT)
|
||||
obs = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(obs)
|
||||
|
||||
|
||||
class Receiver:
|
||||
def __init__(self):
|
||||
self.chunks = []
|
||||
|
||||
def send(self, data):
|
||||
self.chunks.append(data)
|
||||
|
||||
|
||||
class ObsSourceTests(unittest.TestCase):
|
||||
def test_relay_keeps_whole_transport_packets(self):
|
||||
stream = bytes(range(188)) * 101
|
||||
receiver = Receiver()
|
||||
self.assertEqual(obs.relay(io.BytesIO(stream), receiver), len(stream))
|
||||
self.assertEqual(b''.join(receiver.chunks), stream)
|
||||
self.assertTrue(all(len(chunk) % 188 == 0 and len(chunk) <= 1316
|
||||
for chunk in receiver.chunks))
|
||||
|
||||
def test_incomplete_packet_fails(self):
|
||||
with self.assertRaisesRegex(RuntimeError, 'incomplete'):
|
||||
obs.relay(io.BytesIO(b'x' * 189), Receiver())
|
||||
|
||||
def test_remote_command_uses_recording_sources(self):
|
||||
with_audio = obs.remote_command()
|
||||
self.assertIn('/dev/video99', with_audio)
|
||||
self.assertIn('@DEFAULT_MONITOR@', with_audio)
|
||||
self.assertIn('-map 1:a:0', with_audio)
|
||||
self.assertIn('-f mpegts', with_audio)
|
||||
self.assertNotIn('@DEFAULT_MONITOR@', obs.remote_command(audio=False))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1240,7 +1240,6 @@
|
||||
</div>
|
||||
<div class="hint" id="recStatus" role="status" hidden></div>
|
||||
<div class="hint" id="viewHint"></div>
|
||||
<p class="sub"><a href="https://github.com/saphid/frame-control/blob/main/docs/obs-headset-source.md" target="_blank" rel="noopener noreferrer">Use the headset view and sound in OBS →</a></p>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user