mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 01:01:42 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e9140f49f |
No files matched your search
@@ -0,0 +1,38 @@
|
||||
# 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,5 +1,8 @@
|
||||
# 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
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/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)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""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,6 +1240,7 @@
|
||||
</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