mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 01:01:42 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcdd69f6d8 | ||
|
|
f5becdd2c0 | ||
|
|
e185cb3b29 | ||
|
|
2320dc4d0e | ||
|
|
affe3eda65 | ||
|
|
9e9b25e9f4 | ||
|
|
f7153b3ab1 | ||
|
|
5c987ae8b8 | ||
|
|
42b0d729cb | ||
|
|
ae63fa584c |
No files matched your search
@@ -2,6 +2,32 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Verify the packaged OBS Start and Stop control with a real Frame, and record
|
||||
37.600 seconds of headset mirror video and non-silent output audio in OBS.
|
||||
Listening, sync and active passthrough remain unverified.
|
||||
[Test record](docs/evidence/obs-media-source-real-2026-10-25.md).
|
||||
|
||||
- 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. A real Frame stream
|
||||
reached OBS with video and non-silent audio; listening and 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
|
||||
remain unverified. [Setup and limits](docs/obs-headset-source.md).
|
||||
|
||||
- Link emulator setup guidance from Store → Emulators. Existing Steam library
|
||||
shortcuts and artwork cover Android apps and sideloaded titles; Android ROM
|
||||
transfer still needs a verified per-instance workflow.
|
||||
[Guide](docs/emulators.md).
|
||||
|
||||
- Record the current Frame OS mirror and charge-limit checks. Active
|
||||
passthrough in the mirror is still unknown; this build has no supported
|
||||
user-writable 80% charge-limit control.
|
||||
[Headset evidence](docs/evidence/round3-headset-2026-10-25.md).
|
||||
|
||||
- Add Windows Tools → Wireless Adapter doctor: an explicit read-only PC snapshot
|
||||
of distinct Wi-Fi Direct PnP instances, presence/link state, PC Wi-Fi and
|
||||
reported channels/bands, with possible 2.4 GHz overlap guidance. Unknowns stay
|
||||
|
||||
@@ -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 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)).
|
||||
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).
|
||||
|
||||
**⌨️ 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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
+60
-2
@@ -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(); });
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.19",
|
||||
"version": "0.4.22",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.19",
|
||||
"version": "0.4.22",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.19",
|
||||
"version": "0.4.22",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -69,6 +69,7 @@
|
||||
"to": "scripts",
|
||||
"filter": [
|
||||
"*.sh",
|
||||
"obs-headset-source.py",
|
||||
"frame_power_sample.py",
|
||||
"frame_power_report.py"
|
||||
]
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -9,6 +9,11 @@ only control the Frame has is a vendor charger attribute that Valve's own
|
||||
charging daemon also manages. Research as of 2026-10-23, confirmed with the
|
||||
read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207):
|
||||
|
||||
Rechecked read-only on build `20261022.6153909` on 2026-10-25: the
|
||||
`BatteryChargeLimit1` interface still returned `UnknownInterface`, no
|
||||
`charge_control_*` sysfs attribute existed, and the vendor charging switches
|
||||
were root-owned mode `644`. See [Round 3 headset evidence](evidence/round3-headset-2026-10-25.md).
|
||||
|
||||
- `steamosctl get-max-charge-level` fails with `UnknownInterface: ...BatteryChargeLimit1`.
|
||||
- `/usr/share/steamos-manager/devices/` has `[battery_charge_limit]` only for
|
||||
the Steam Deck, ROG Ally, ROG Xbox Ally and MSI Claw, not the Frame.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Emulators and your own games
|
||||
|
||||
Frame Control's **Store → Emulators** lists Android emulators from its open
|
||||
app sources. Pick an app, read its compatibility result, and install it. An
|
||||
installed Android app gets its own Steam library shortcut and artwork; **Add
|
||||
artwork** in its library menu can fill missing images later. The artwork
|
||||
workflow also covers native Linux and Windows titles added through **Send to
|
||||
Frame**. An optional SteamGridDB key under **Settings → Library artwork**
|
||||
can supply exact-title community images; source images and generated art are
|
||||
fallbacks. See [VR APKs → In the Steam library](vr-apks.md#in-the-steam-library)
|
||||
for the limits and tested behavior.
|
||||
|
||||
## Set up an emulator
|
||||
|
||||
1. In **Store → Emulators**, choose an emulator that supports the console and
|
||||
file format you want. Check its compatibility result before installing;
|
||||
an app listed in the store is not automatically verified on the Frame.
|
||||
2. Launch the new shortcut from Steam. Open the emulator's own settings to
|
||||
choose a gamepad layout, core or system, and game folder. These controls
|
||||
differ by emulator; Frame Control does not change them.
|
||||
3. Use game files you own and any firmware that emulator's instructions
|
||||
require. Frame Control does not provide ROMs or firmware.
|
||||
|
||||
**Android apps run in separate Lepton instances.** Copying a ROM to the
|
||||
Frame's Linux `~/Downloads` through **Send to Frame** does not automatically
|
||||
put it in an Android app's `/sdcard/Download`. A one-click ROM transfer is
|
||||
deferred until an emulator and its file picker can be tested end to end on a
|
||||
real Frame. An app's own import or sync feature may be enough. Advanced users
|
||||
can use [Lepton's per-instance ADB port](apks.md)
|
||||
to copy into that instance, after confirming the correct container and port.
|
||||
Do not assume that port 5555 is the emulator: it is Lepton Development, while
|
||||
installed apps receive their own ports.
|
||||
|
||||
For a native Linux emulator that can read the Frame's home directory, use
|
||||
**Send to Frame → Copy to ~/Downloads instead** for your files, then open them
|
||||
from the emulator. Whether a particular emulator has a Frame-compatible ARM64
|
||||
build, correct controller mapping and readable storage must be checked for
|
||||
that app. **Linux apps** in Frame Control lists Flatpak options; a Windows or
|
||||
native game archive can instead be added through
|
||||
[Send to Frame](sideloading.md#using-it), which registers a Steam title and
|
||||
artwork. Each ROM is usually opened *inside* its emulator; Frame Control does
|
||||
not create one Steam shortcut per ROM.
|
||||
|
||||
## If something does not appear
|
||||
|
||||
- Check that the emulator is running and is the same app instance to which
|
||||
you sent the file. Android instances do not share `/sdcard` storage.
|
||||
- Check the emulator's supported extension and whether it expects an archive
|
||||
or an extracted file. Some systems need firmware before games appear.
|
||||
- For an empty Steam tile, use **Add artwork** in the installed app or
|
||||
sideloaded title menu. This changes artwork, not emulator data or ROMs.
|
||||
|
||||
The Frame's microSD/USB path into Android apps remains unverified without an
|
||||
inserted medium and an app readback test; see [issue #169](https://github.com/saphid/frame-control/issues/169).
|
||||
@@ -0,0 +1,48 @@
|
||||
# OBS Media Source on a real Frame, 2026-10-25 AEDT
|
||||
|
||||
The Linux x86_64 AppImage from PR #174's exact head `69fff152` had SHA-256
|
||||
`4902ac80b77fbf98ec918db29df9cf9260b8abd6763a5b5055572335869f038a`.
|
||||
Its extracted resources passed `app/build/check-resources.js`, including the
|
||||
OBS helper, and the app launched under Xvfb with an isolated profile and a
|
||||
dummy SSH alias. Its bundled server served the page with HTTP 200.
|
||||
|
||||
For the device test, the PC was lxso2. The real Frame was reached as `ssh frame`
|
||||
under an atomically acquired `/tmp/frame-test.lock`. Battery read 100% Full
|
||||
before and after. SteamVR 2.18.2 reported `activity_level: 3` (standby). The
|
||||
mirror service and `/dev/video99` were active. The test cleaned its temporary
|
||||
remote WAV and released only its own lock.
|
||||
|
||||
OBS 30.0.2 ran in a disposable Ubuntu 24.04 container on lxso2, with Xvfb and
|
||||
host loopback networking. A Media Source opened `udp://127.0.0.1:15099` with
|
||||
input format `mpegts`. A synthetic pattern and tone first verified the OBS
|
||||
scene: its saved 43.733-second MKV had H.264 video and AAC audio with a
|
||||
`-17.9 dB` audio peak. The real Frame test then ran the helper from the
|
||||
extracted AppImage resources **before** OBS opened the source, so OBS joined
|
||||
an ongoing stream. OBS's saved 37.600-second MKV had H.264 video and AAC audio.
|
||||
The standby mirror appeared in decoded frames by five seconds. A 440 Hz tone
|
||||
played briefly to the Frame's default PulseAudio output appeared in the OBS
|
||||
audio recording at seconds 20–21; decoded audio peaked at `-34.4 dB`. A
|
||||
separate direct recording of `@DEFAULT_MONITOR@` during the same type of tone
|
||||
peaked at `-34.5 dB`. These measurements verify non-silent headset output
|
||||
audio passed through the helper and OBS into the saved recording.
|
||||
|
||||
The first OBS attempt opened its Media Source well before the Frame sent
|
||||
packets. Its output had a silent AAC track until the mirror appeared late.
|
||||
Starting the helper before OBS fixed this in the second run. The setup guide
|
||||
now recommends starting the source in Frame Control before adding or activating
|
||||
the OBS Media Source.
|
||||
|
||||
The exact-head AppImage was then launched on an isolated Xvfb display and
|
||||
profile with the real `frame` alias. In **Tools → Capture & Live**, clicking
|
||||
**Start OBS source** changed the UI to **Stop OBS source** and showed
|
||||
`udp://127.0.0.1:15099` after the first stream packet. The packaged Python
|
||||
helper was running with `--frame frame`. Clicking **Stop OBS source** returned
|
||||
the UI to **Start OBS source**; the helper and its remote `/dev/video99` ffmpeg
|
||||
process were gone. The app exited through **File → Quit**. Battery again read
|
||||
100% Full, and the owned lock was released.
|
||||
|
||||
**Limits:** Nobody listened to OBS audio monitoring or the Frame speakers.
|
||||
Audio/video sync was not measured. The headset was unworn and in standby, so
|
||||
this test does not answer whether active passthrough pixels appear in the
|
||||
mirror. The OBS MKVs were temporary local test artifacts and are not included
|
||||
in the repository.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Round 3 headset checks, 25 October 2026
|
||||
|
||||
These checks used the real Frame after the device hold ended. SteamOS was
|
||||
0.5.6, build `20261022.6153909`. Each device session acquired
|
||||
`/tmp/frame-test.lock` atomically and released only its own lock. The battery
|
||||
read 100% and Full in the first check and after the OBS session; Steam and
|
||||
SteamVR were left running.
|
||||
|
||||
| Question | Observation | Conclusion |
|
||||
|---|---|---|
|
||||
| Does active passthrough appear in SteamVR's `/dev/video99` mirror? | The mirror service was active. One 1920×1080 RGB frame read from `/dev/video99` had a single pixel value, `(24, 28, 53)`, throughout. The headset was unworn; no active passthrough session or wearer-visible room view was established. Frame Control has no verified remote passthrough toggle. The temporary frame was removed. | **Still unknown.** A uniform standby frame cannot answer whether SteamVR composites active passthrough into its mirror. No headset setting was changed. |
|
||||
| Can Frame Control prompt when a headset text box opens? | No desktop `plasmashell` process was running during this check. There is no verified cross-app focus event from SteamVR, Lepton and the nested desktop; KDE Connect's `keyboardstate` packet describes keyboard support, not a focused text field. | **Not built.** A prompt triggered by an unverified signal would be misleading. The existing **Type on the headset** control remains available. |
|
||||
| Does reverse clipboard exist? | Frame Control already has **Copy the headset's clipboard here**. Its earlier real Frame test used a temporary nested desktop and confirmed that text copied through Klipper reached a Linux PC clipboard. No desktop panel was open during this session, so it was not rerun here. | **Shipped and previously verified**, text only. Windows/macOS backends and arbitrary headset apps remain unverified on a real Frame. |
|
||||
| Is an 80% charge limit available and writable? | `steamosctl get-max-charge-level` returned `UnknownInterface: ...BatteryChargeLimit1`. No `charge_control_*` attribute was found under `/sys/class/power_supply`. Only `pm8550b-charger/charging_pause` and `charging_enable` were present, each mode `644`, owner `root:root`. | **No supported user-writable control.** No battery attribute was written. |
|
||||
|
||||
The separate OBS transport check on the same build sent the mirror and the
|
||||
default-output monitor to a Linux PC. A 4.021-second temporary capture had
|
||||
H.264 video and AAC audio according to `ffprobe`. It was removed; OBS itself
|
||||
and audible playback were not tested. See the OBS source PR and guide.
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,47 @@
|
||||
# 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 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
|
||||
```
|
||||
|
||||
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
|
||||
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-25:** the helper bundled in the Linux AppImage
|
||||
sent a real Frame stream to OBS 30's Media Source. OBS recorded 37.6 seconds
|
||||
with H.264 video and AAC audio; a brief 440 Hz tone played to the Frame's
|
||||
default output was present in the saved audio. The headset was in standby, so
|
||||
the video showed the standby mirror. Nobody listened to OBS's monitoring output,
|
||||
and audio/video sync and active passthrough remain unverified. See the
|
||||
[test evidence](evidence/obs-media-source-real-2026-10-25.md).
|
||||
+19
-1
@@ -1,6 +1,9 @@
|
||||
# Recording and Steam clips
|
||||
|
||||
Frame Control can record what the headset shows, with its sound, and save it as
|
||||
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.
|
||||
Both are on **Tools → Capture & Live**. Everything is saved to
|
||||
@@ -47,6 +50,21 @@ 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,8 +53,13 @@ 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. 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.
|
||||
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).
|
||||
|
||||
Sources: [SteamOS 3.9 news](https://store.steampowered.com/news/app/1675200),
|
||||
[Frame OS news](https://steamcommunity.com/app/4165890/allnews/), and the
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/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
|
||||
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__':
|
||||
try:
|
||||
sys.exit(main())
|
||||
except (OSError, RuntimeError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""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
|
||||
|
||||
|
||||
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_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()
|
||||
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))
|
||||
|
||||
@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()
|
||||
+33
-1
@@ -1240,6 +1240,11 @@
|
||||
</div>
|
||||
<div class="hint" id="recStatus" role="status" hidden></div>
|
||||
<div class="hint" id="viewHint"></div>
|
||||
<div id="obsControl" class="hint" hidden>
|
||||
<button class="small" id="obsBtn">Start OBS source</button>
|
||||
<span id="obsStatus" role="status"></span>
|
||||
</div>
|
||||
<p class="sub"><a href="https://github.com/saphid/frame-control/blob/main/docs/obs-headset-source.md" target="_blank" rel="noopener noreferrer">Set up the headset view and sound in OBS →</a></p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -4388,7 +4393,7 @@ function renderCats() {
|
||||
$("storeCats").querySelectorAll("[data-cat]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.cat === sourceState.category)));
|
||||
const [, label, eyebrow, blurb] = storeCat(sourceState.category);
|
||||
$("storeHeading").hidden = !sourceState.category;
|
||||
$("storeHeading").innerHTML = `<div class="store-eyebrow">${esc(sourceState.category ? eyebrow : "Android apps for your Frame")}</div><h1>${esc(sourceState.category ? label : "Apps & VR")}</h1><p>${esc(blurb)}</p>`;
|
||||
$("storeHeading").innerHTML = `<div class="store-eyebrow">${esc(sourceState.category ? eyebrow : "Android apps for your Frame")}</div><h1>${esc(sourceState.category ? label : "Apps & VR")}</h1><p>${esc(blurb)}</p>${sourceState.category === "emulators" ? '<p><a href="https://github.com/saphid/frame-control/blob/main/docs/emulators.md" target="_blank" rel="noopener noreferrer">Set up an emulator and your own games →</a></p>' : ""}`;
|
||||
}
|
||||
function renderStore() {
|
||||
const apps=sourceState.apps, q=$("sourceQ").value.trim(), browsing=!q && !sourceState.category && !sourceState.installable && !sourceState.source;
|
||||
@@ -5489,6 +5494,33 @@ $("recBtn").onclick = async e => {
|
||||
if (res) showRec(res); else pollRec();
|
||||
};
|
||||
|
||||
// The desktop app owns the local OBS relay, so it can close SSH when the app
|
||||
// exits. The browser and mobile views keep the command-line guide instead.
|
||||
if (window.frameApp?.obs) {
|
||||
const obs = window.frameApp.obs;
|
||||
const wrap = $("obsControl"), button = $("obsBtn"), status = $("obsStatus");
|
||||
let state = { state: "idle" };
|
||||
function showObs(next) {
|
||||
if (!next) return;
|
||||
state = next;
|
||||
wrap.hidden = !next.supported;
|
||||
button.textContent = next.state === "running" ? "Stop OBS source" : "Start OBS source";
|
||||
button.disabled = next.state === "starting" || next.state === "stopping";
|
||||
status.textContent = next.state === "starting" ? "Connecting to the Frame and waiting for video…"
|
||||
: next.state === "running" ? `Sending ${next.alias} view and sound to udp://127.0.0.1:15099`
|
||||
: next.state === "stopping" ? "Stopping the OBS source…"
|
||||
: next.state === "failed" ? next.error : "";
|
||||
}
|
||||
obs.onState(showObs);
|
||||
obs.status().then(showObs).catch(() => {});
|
||||
button.onclick = async e => {
|
||||
const stopping = state.state === "running";
|
||||
const result = await act(stopping ? "Stop OBS source" : "Start OBS source",
|
||||
() => stopping ? obs.stop() : obs.start(), e.currentTarget);
|
||||
if (result) showObs(result);
|
||||
};
|
||||
}
|
||||
|
||||
const clipsState = { list: [], gen: 0, exporting: new Set() };
|
||||
async function loadClips() {
|
||||
if (HOST.mobile) return;
|
||||
|
||||
Reference in new issue
Block a user