diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d8adc1..038916b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Bundle the OBS source helper in Linux and macOS installers and add a + **Capture & Live** Start/Stop control. The app selects the active Frame and + closes its SSH stream on a headset switch or app exit. OBS playback and audio + sync remain unverified. [Setup and limits](docs/obs-headset-source.md). + - Add a source-checkout helper that sends the Frame's SteamVR mirror and default-output audio to a local OBS Media Source. A real Frame supplied a 4.021-second H.264/AAC stream to a Linux receiver; OBS playback and sync diff --git a/app/build/check-resources.js b/app/build/check-resources.js index 49a7f9a..71b88d5 100644 --- a/app/build/check-resources.js +++ b/app/build/check-resources.js @@ -17,6 +17,7 @@ const REQUIRED = [ "ui/frame_status.py", "scripts/frame_power_sample.py", "scripts/frame_power_report.py", + "scripts/obs-headset-source.py", "ui/frame_android.py", "frame/android/lepton-app.sh", "frame/openxr-compat/XrApiLayer_FRAME_compat.json", diff --git a/app/main.js b/app/main.js index 25547b2..6a19e68 100644 --- a/app/main.js +++ b/app/main.js @@ -304,6 +304,63 @@ ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null); ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null); ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); }); +// The OBS source is local to this computer. The Python relay owns its SSH/ffmpeg +// process and cleans it up on SIGTERM. Keep one relay per app, and stop it when +// the selected headset changes or the app quits. +const OBS_SCRIPT = path.join(SCRIPTS, "obs-headset-source.py"); +let obsChild = null; +let obsState = { state: "idle", alias: "", error: "" }; +function publicObs() { return { ...obsState, supported: !IS_WIN }; } +function setObs(state, alias = "", error = "") { + obsState = { state, alias, error }; + if (win && !win.isDestroyed()) win.webContents.send("obs:state", publicObs()); + return publicObs(); +} +function stopObs() { + if (!obsChild) return publicObs(); + setObs("stopping", obsState.alias); + obsChild.kill("SIGTERM"); + return publicObs(); +} +ipcMain.handle("obs:status", (e) => fromUi(e) ? publicObs() : null); +ipcMain.handle("obs:start", async (e) => { + if (!fromUi(e)) return null; + if (IS_WIN) throw new Error("The OBS source currently supports Linux and macOS computers."); + if (obsChild) return publicObs(); + const alias = activeAlias(); + if (!ALIAS_RE.test(alias)) throw new Error("Select a valid headset first."); + if (!fs.existsSync(OBS_SCRIPT)) throw new Error("The OBS helper is missing; reinstall Frame Control."); + const env = { ...process.env, PATH: await loginPath() }; + const executable = python || await findPython(env); + if (!executable) throw new Error(PYTHON_HELP); + // A concurrent click or a headset switch may have happened while finding Python. + if (obsChild) return publicObs(); + if (alias !== activeAlias()) throw new Error("The selected headset changed; try again."); + const child = spawn(executable, [...PY_FLAGS, OBS_SCRIPT, "--frame", alias], + { env, stdio: ["ignore", "ignore", "pipe"], windowsHide: true }); + obsChild = child; + setObs("starting", alias); + let output = ""; + child.stderr.on("data", (chunk) => { + output = (output + chunk.toString("utf8")).slice(-2048); + if (obsChild === child && output.includes("Frame Control OBS stream is ready")) setObs("running", alias); + }); + child.once("error", (err) => { + if (obsChild !== child) return; + obsChild = null; + setObs("failed", alias, `Could not start OBS source: ${err.message}`); + }); + child.once("exit", (code, signal) => { + if (obsChild !== child) return; + const stopping = obsState.state === "stopping" || quitting; + obsChild = null; + setObs(stopping ? "idle" : "failed", alias, + stopping ? "" : `OBS source stopped (${signal || code}). ${output.trim().split("\n").pop() || "Check SteamVR and SSH."}`); + }); + return publicObs(); +}); +ipcMain.handle("obs:stop", (e) => fromUi(e) ? stopObs() : null); + // The page reports the headsets it knows (the server's Devices tab), so the Frame // menu can switch between them. Only plain names and ids go into the menu. const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; @@ -314,6 +371,7 @@ ipcMain.on("devices:changed", (e, list) => { .map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active })); if (JSON.stringify(next) === JSON.stringify(devices)) return; devices = next; + if (obsChild && obsState.alias !== activeAlias()) stopObs(); buildMenu(); }); const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME; @@ -641,6 +699,6 @@ if (!app.requestSingleInstanceLock()) { }); app.on("activate", () => { if (!win) createWindow(); }); app.on("window-all-closed", () => app.quit()); - app.on("before-quit", () => { quitting = true; stopServer(); }); - process.on("exit", stopServer); + app.on("before-quit", () => { quitting = true; stopObs(); stopServer(); }); + process.on("exit", () => { stopObs(); stopServer(); }); } diff --git a/app/package.json b/app/package.json index 383388e..70a6980 100644 --- a/app/package.json +++ b/app/package.json @@ -69,6 +69,7 @@ "to": "scripts", "filter": [ "*.sh", + "obs-headset-source.py", "frame_power_sample.py", "frame_power_report.py" ] diff --git a/app/preload.js b/app/preload.js index b6748a9..c6b1c7c 100644 --- a/app/preload.js +++ b/app/preload.js @@ -21,6 +21,15 @@ contextBridge.exposeInMainWorld("frameApp", { writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes), setUpConnection: () => ipcRenderer.invoke("connection:setup"), restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again + obs: { + status: () => ipcRenderer.invoke("obs:status"), + start: () => ipcRenderer.invoke("obs:start"), + stop: () => ipcRenderer.invoke("obs:stop"), + onState: (cb) => { + ipcRenderer.removeAllListeners("obs:state"); + ipcRenderer.on("obs:state", (_e, state) => cb(state)); + }, + }, // The Frame menu's headset switcher: the page tells it the headsets, and hears picks. devicesChanged: (list) => ipcRenderer.send("devices:changed", list), onUseDevice: (cb) => { diff --git a/docs/obs-headset-source.md b/docs/obs-headset-source.md index 4666989..3c5baef 100644 --- a/docs/obs-headset-source.md +++ b/docs/obs-headset-source.md @@ -5,19 +5,27 @@ 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: +1. Connect to the Frame and start SteamVR. OBS runs on the computer; the Frame + already has `ffmpeg`. +2. In the **Linux or macOS Frame Control app**, open **Capture & Live** and click + **Start OBS source**. Keep Frame Control open; **Stop OBS source**, switching + headsets, or quitting the app closes its SSH stream. It uses the selected + headset and the app's bundled helper. +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. + +From a source checkout, you can instead install `python3` on the computer and +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. +Ctrl+C stops the checkout helper. The app's Start/Stop control currently supports +Linux and macOS; Windows users can run the checkout helper with Python 3 and +OpenSSH from a terminal, but this path has not been verified on a Windows PC. `--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 diff --git a/scripts/obs-headset-source.py b/scripts/obs-headset-source.py index 1fe552f..4aac156 100644 --- a/scripts/obs-headset-source.py +++ b/scripts/obs-headset-source.py @@ -94,9 +94,13 @@ def main(argv=None): class _Sender: def __init__(self, sock, target): self.sock, self.target = sock, target + self.ready = False def send(self, data): self.sock.sendto(data, self.target) + if not self.ready: + self.ready = True + print('Frame Control OBS stream is ready', file=sys.stderr, flush=True) if __name__ == '__main__': diff --git a/tests/test_obs_headset_source.py b/tests/test_obs_headset_source.py index 9da0dc8..ea5446e 100644 --- a/tests/test_obs_headset_source.py +++ b/tests/test_obs_headset_source.py @@ -1,7 +1,15 @@ """The OBS relay must preserve transport packets and keep its output local.""" import importlib.util import io +import json +import os from pathlib import Path +import signal +import socket +import subprocess +import sys +import tempfile +import time import unittest @@ -20,6 +28,13 @@ class Receiver: class ObsSourceTests(unittest.TestCase): + def test_desktop_packages_the_helper_as_a_required_resource(self): + root = SCRIPT.parents[1] + build = json.loads((root / 'app' / 'package.json').read_text())['build'] + scripts = next(item for item in build['extraResources'] if item['to'] == 'scripts') + self.assertIn(SCRIPT.name, scripts['filter']) + self.assertIn('scripts/' + SCRIPT.name, (root / 'app' / 'build' / 'check-resources.js').read_text()) + def test_relay_keeps_whole_transport_packets(self): stream = bytes(range(188)) * 101 receiver = Receiver() @@ -40,6 +55,47 @@ class ObsSourceTests(unittest.TestCase): self.assertIn('-f mpegts', with_audio) self.assertNotIn('@DEFAULT_MONITOR@', obs.remote_command(audio=False)) + @unittest.skipIf(sys.platform == 'win32', 'the packaged Start/Stop control is Linux/macOS only') + def test_relay_stops_its_ssh_child_on_app_signal(self): + with tempfile.TemporaryDirectory() as folder, socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as receiver: + directory = Path(folder) + stopped = directory / 'stopped' + fake = directory / 'ssh' + fake.write_text("#!/usr/bin/env python3\n" + "import os, signal, sys, time\n" + "def stop(*_):\n" + " open(os.environ['FAKE_SSH_STOPPED'], 'w').close()\n" + " sys.exit(0)\n" + "signal.signal(signal.SIGTERM, stop)\n" + "sys.stdout.buffer.write(b'G' * 1316)\n" + "sys.stdout.buffer.flush()\n" + "while True: time.sleep(1)\n") + fake.chmod(0o755) + receiver.bind(('127.0.0.1', 0)) + receiver.settimeout(8) + env = {**os.environ, 'PATH': str(directory) + os.pathsep + os.environ.get('PATH', ''), + 'FAKE_SSH_STOPPED': str(stopped)} + process = subprocess.Popen([sys.executable, str(SCRIPT), '--frame', 'fake-frame', + '--port', str(receiver.getsockname()[1])], + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, env=env) + try: + packet, _ = receiver.recvfrom(1500) + self.assertEqual(packet, b'G' * 1316) + process.send_signal(signal.SIGTERM) + _, errors = process.communicate(timeout=8) + self.assertEqual(process.returncode, 0, errors.decode()) + self.assertIn(b'Frame Control OBS stream is ready', errors) + for _ in range(30): + if stopped.exists(): + break + time.sleep(0.1) + self.assertTrue(stopped.exists(), 'the SSH child survived relay shutdown') + finally: + if process.poll() is None: + process.kill() + process.communicate(timeout=5) + if __name__ == '__main__': unittest.main() diff --git a/ui/index.html b/ui/index.html index 3838348..fcdbd0b 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1240,7 +1240,11 @@
-Use the headset view and sound in OBS →
+