Compare commits

..
Author SHA1 Message Date
Alex SouthwellandSAPHID f0f39ac823 Prepare Frame Control 0.4.31 and record round-two validation (#221)
* Prepare Frame Control 0.4.31 with round-two validation

* State the x86 Linux runtime limit in smoke evidence

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:54:03 +00:00
Alex SouthwellandSAPHID 1252ecc7af Add read-only large status screen to headset panel (#220)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:46:01 +00:00
Alex SouthwellandSAPHID 7b53af1c18 Fix OBS lifecycle cleanup and validate real headset capture (#219)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:42:11 +00:00
Alex SouthwellandSAPHID 1255ef2bbf Fix Steam clip export collisions across headsets, accounts and sessions (#217)
* Fix Steam clip export collisions across headset and session identities

* Group clip export changelog with screenshot copying

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:37:51 +00:00
Alex SouthwellandSAPHID fdd60d3ccd Fix auto-sync retry recovery and headset-bound listings (#218)
* Fix auto-sync retry recovery and headset-bound listings

* Read auto-sync Settings fixture as UTF-8 on Windows

* Keep auto-sync changelog separate from parallel fixes

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:35:47 +00:00
45 changed files with 2303 additions and 151 deletions

No files matched your search

+6 -1
View File
@@ -46,7 +46,7 @@ jobs:
python ui/frame_wireless_doctor.py --json > "$RUNNER_TEMP/linux-wireless-doctor.json"
python -c 'import json, os; from pathlib import Path; d=json.loads((Path(os.environ["RUNNER_TEMP"]) / "linux-wireless-doctor.json").read_text()); assert d["supported"] and d["selected"] is None and d["schema"] == 1'
- name: App syntax
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
run: node --check app/main.js && node --check app/obs-source.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
- name: App tests
run: node --test app/test/*.test.js
- name: Website
@@ -71,6 +71,11 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: OBS lifecycle tests
run: node app/test/obs-source.test.js
- name: Server tests
run: python -m unittest discover -s tests -v
+10
View File
@@ -24,6 +24,11 @@
existing files in the old flat folder are left in place.
[Copying guide](docs/recording.md).
- Keep Steam clip exports separate for each headset, Steam account and nested
recording session. Manual export and automatic copying use the same folders;
existing flat exports stay untouched and do not mark unrelated clips saved.
[Recording guide](docs/recording.md#steam-clips).
- Compare two explicit Windows Wireless Adapter doctor reads in local memory.
Highlight exact PnP instances and known state changes after a dropout;
incomplete inventories stay unknown. A Forget button clears the comparison.
@@ -225,3 +230,8 @@
shared Wine services are left alone. Verified with local Linux process
regressions and a 13/13 real Frame smoke run that stopped five test-owned
Proton process PIDs before removing the Windows test title.
- Keep failed automatic screenshot and clip copies visible after three attempts.
**Resume** and **Copy everything now** allow another try without restarting;
headset switches cannot use another headset's list or saved-file history.
[Copying guide](docs/recording.md).
+24 -53
View File
@@ -311,57 +311,28 @@ ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
// pipe lets it stop SSH on every platform, including Windows where SIGTERM
// terminates Python without running its cleanup block.
const OBS_SCRIPT = path.join(SCRIPTS, "obs-headset-source.py");
let obsChild = null;
let obsState = { state: "idle", alias: "", error: "" };
function publicObs() { return { ...obsState, supported: true }; }
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);
if (obsChild.stdin && !obsChild.stdin.writableEnded) obsChild.stdin.end();
return publicObs();
}
ipcMain.handle("obs:status", (e) => fromUi(e) ? publicObs() : null);
ipcMain.handle("obs:start", async (e) => {
if (!fromUi(e)) return null;
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, "--control-stdin"],
{ env, stdio: ["pipe", "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();
const { createObsSource } = require("./obs-source");
const obsSource = createObsSource({
spawn,
// Keep the button hidden until native Windows/Frame streaming is verified.
supported: !IS_WIN,
prepare: async alias => {
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);
if (quitting || alias !== activeAlias()) throw new Error("The selected headset changed; try again.");
return { executable, args: [...PY_FLAGS, OBS_SCRIPT], env };
},
onState: state => {
if (win && !win.isDestroyed()) win.webContents.send("obs:state", state);
},
});
ipcMain.handle("obs:stop", (e) => fromUi(e) ? stopObs() : null);
const publicObs = () => obsSource.status();
const stopObs = () => obsSource.stop();
ipcMain.handle("obs:status", e => fromUi(e) ? publicObs() : null);
ipcMain.handle("obs:start", (e, port) => fromUi(e) ? obsSource.start(activeAlias(), port) : null);
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.
@@ -373,7 +344,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();
if (obsSource.status().alias && obsSource.status().alias !== activeAlias()) stopObs();
buildMenu();
});
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
@@ -765,11 +736,11 @@ if (!app.requestSingleInstanceLock()) {
app.on("before-quit", (event) => {
quitting = true;
stopServer();
if (!obsChild || obsQuitReady) return;
if (!obsSource.child || obsQuitReady) { stopObs(); return; }
event.preventDefault();
if (obsQuitWaiting) return;
obsQuitWaiting = true;
const child = obsChild;
const child = obsSource.child;
let timer;
child.once("close", () => {
clearTimeout(timer);
+59
View File
@@ -0,0 +1,59 @@
// Own one Python relay. Async preparation can be cancelled before a process exists.
const ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
function createObsSource({ prepare, spawn, onState = () => {}, supported = true }) {
let child = null, attempt = 0;
let state = { state: 'idle', alias: '', port: 15099, error: '', supported };
const status = () => ({ ...state });
function set(next, alias = state.alias, port = state.port, error = '') {
state = { state: next, alias, port, error, supported };
onState(status()); return status();
}
function stop() {
++attempt;
if (!child) return set('idle');
set('stopping');
if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
return status();
}
async function start(alias, port = 15099) {
if (!supported) return status();
if (!ALIAS.test(alias || '')) throw new Error('Select a valid headset first.');
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('OBS port must be a whole number from 1024 to 65535.');
if (child || state.state === 'starting') return status();
const id = ++attempt;
set('starting', alias, port);
try {
const { executable, args = [], env } = await prepare(alias);
if (id !== attempt) return status();
const proc = spawn(executable, [...args, '--frame', alias, '--port', String(port), '--control-stdin'],
{ env, stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
child = proc;
// The helper may exit between a click and closing its control pipe.
proc.stdin.on('error', () => {});
let output = '';
proc.stderr.on('data', chunk => {
output = (output + chunk.toString('utf8')).slice(-2048);
if (child === proc && state.state === 'starting' && output.includes('Frame Control OBS stream is ready'))
set('running', alias, port);
});
proc.once('error', error => {
if (child !== proc) return;
child = null; set('failed', alias, port, `Could not start OBS source: ${error.message}`);
});
// close follows stderr EOF, so the final failure message is available.
proc.once('close', (code, signal) => {
if (child !== proc) return;
const stopping = state.state === 'stopping';
child = null;
set(stopping ? 'idle' : 'failed', alias, port,
stopping ? '' : `OBS source stopped (${signal || code}). ${output.trim().split('\n').pop() || 'Check SteamVR and SSH.'}`);
});
return status();
} catch (error) {
if (id !== attempt) return status();
return set('failed', alias, port, error.message);
}
}
return { start, stop, status, get child() { return child; } };
}
module.exports = { createObsSource };
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.30",
"version": "0.4.31",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.30",
"version": "0.4.31",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.30",
"version": "0.4.31",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -36,6 +36,7 @@
},
"files": [
"main.js",
"obs-source.js",
"preload.js",
"install-link.js",
"updater.js",
+1 -1
View File
@@ -23,7 +23,7 @@ contextBridge.exposeInMainWorld("frameApp", {
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
obs: {
status: () => ipcRenderer.invoke("obs:status"),
start: () => ipcRenderer.invoke("obs:start"),
start: (port) => ipcRenderer.invoke("obs:start", port),
stop: () => ipcRenderer.invoke("obs:stop"),
onState: (cb) => {
ipcRenderer.removeAllListeners("obs:state");
+86
View File
@@ -0,0 +1,86 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { createObsSource } = require('../obs-source');
function fixture(prepare) {
const states = [], children = [];
const source = createObsSource({
prepare: prepare || (async () => ({ executable: 'python', args: ['helper.py'], env: {} })),
spawn(executable, args, options) {
const child = new EventEmitter();
child.stderr = new EventEmitter(); child.stdin = new EventEmitter();
child.stdin.end = () => { child.stdin.writableEnded = true; };
children.push({ child, executable, args, options }); return child;
},
onState: s => states.push(s),
});
return { source, states, children };
}
test('Stop during Python discovery cancels the pending start', async () => {
let resolve;
const f = fixture(() => new Promise(r => { resolve = r; }));
const pending = f.source.start('frame', 15100);
assert.equal(f.source.status().state, 'starting');
f.source.stop();
resolve({ executable: 'python', args: ['helper.py'], env: {} });
await pending;
assert.equal(f.children.length, 0);
assert.equal(f.source.status().state, 'idle');
});
test('late stderr cannot undo Stop; closing allows a fresh start on another port', async () => {
const f = fixture(); await f.source.start('frame', 15100);
const { child, args } = f.children[0];
assert.deepEqual(args.slice(-5), ['--frame', 'frame', '--port', '15100', '--control-stdin']);
child.stderr.emit('data', Buffer.from('Frame Control OBS stream is '));
child.stderr.emit('data', Buffer.from('ready\n'));
assert.equal(f.source.status().state, 'running');
f.source.stop();
child.stderr.emit('data', Buffer.from('last ffmpeg warning\n'));
assert.equal(f.source.status().state, 'stopping');
child.emit('close', 0, null);
assert.equal(f.source.status().state, 'idle');
await f.source.start('other-frame', 15101);
assert.equal(f.source.status().port, 15101);
// An old child cannot overwrite the replacement's state.
child.emit('close', 255, null);
assert.equal(f.source.status().state, 'starting');
});
test('concurrent starts spawn once and preparation failures are retryable', async () => {
let attempts = 0;
const f = fixture(async () => {
if (++attempts === 1) throw new Error('Python missing');
return { executable: 'python', args: [], env: {} };
});
assert.equal((await f.source.start('frame')).state, 'failed');
assert.match(f.source.status().error, /Python missing/);
await Promise.all([f.source.start('frame'), f.source.start('frame')]);
assert.equal(f.children.length, 1);
});
test('invalid ports and aliases cannot spawn; errors and closed pipes remain visible', async () => {
const f = fixture();
for (const port of [0, 1023, 65536, 15099.5, '15100']) {
await assert.rejects(f.source.start('frame', port), /port/i);
}
await assert.rejects(f.source.start('-bad'), /headset/i);
assert.equal(f.children.length, 0);
await f.source.start('frame');
const { child } = f.children[0];
child.stdin.emit('error', Object.assign(new Error('closed pipe'), { code: 'EPIPE' }));
child.stderr.emit('data', Buffer.from('network lost\n'));
child.emit('close', 255, null);
assert.equal(f.source.status().state, 'failed');
assert.match(f.source.status().error, /network lost/);
});
test('an unverified host hides the controls and cannot spawn a relay', async () => {
let spawned = false;
const source = createObsSource({ supported: false, prepare: async () => ({}), spawn: () => { spawned = true; } });
assert.equal(source.status().supported, false);
await source.start('frame');
assert.equal(spawned, false);
});
+3 -2
View File
@@ -1,9 +1,10 @@
# What we have tested
Current for Frame Control **v0.4.30**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
Current for Frame Control **v0.4.31**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|---|---|---|---|---|
| Core title smoke test | Linux lxso2 installed, launched and removed reserved native/Proton titles on the real Frame; all 13 steps passed | No | Unworn headset; visible rendering and controller input not checked. Other feature paths retain their separate limits below | 25 Oct, v0.4.31 candidate source `1255ef2`, Frame OS 0.5.6 build `20261022.6153909`: [sanitized results](evidence/round2-smoke-2026-10-25.json) |
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
@@ -18,7 +19,7 @@ Current for Frame Control **v0.4.30**, 25 October 2026. “Real Frame + physical
| Keyboard and trackpad from Frame Control | A Linux PC sent a click and ASCII keys to a temporary focused window on the real Frame | No | Worn-headset interaction, Unicode delivery and pixel-accurate pointing | 24 Oct, v0.4.19: [received-event log](evidence/pad-gamescope-2026-10-24.md), [PR #66](https://github.com/saphid/frame-control/pull/66) |
| Backup and restore after reset | Same-headset backup, no-op save restore and Return to Game Mode checks | No | Restore **after a factory reset**, replacement restore and Android data restore have not been tested on a headset. No headset reset was done | 24 Oct, v0.4.17: [backup guide](backup-and-recovery.md), [Return to Game Mode log](evidence/return-game-mode-packaged-2026-10-24.json) |
| Android game backup import | F-Droid Basic APK with synthetic main and patch OBBs installed, transferred and read back on a real Frame | No | v0.4.30 restricted split-APK backup route has generated-fixture and existing merger tests only; no real split game or RE4 gameplay test | 20 Oct OBB headset check; 25 Oct, v0.4.30 split fixtures: [OBB device evidence](evidence/android-import/device.json), [split PR #206](https://github.com/saphid/frame-control/pull/206), [guide](android-backups.md) |
| Passthrough in recordings | No active-passthrough mirror check | No | Standby frame was uniform; whether visible room pixels enter `/dev/video99` is **unknown**. Raw cameras are never captured by Frame Control | 25 Oct, v0.4.21: [headset log](evidence/round3-headset-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
| Passthrough in recordings | Real room pixels in OBS and a production MP4 after safe SteamVR wake | No | **Yes** for the checked 0.5.6 mirror. Raw cameras are not opened. Other games/modes and Steam clips may differ | 25 Oct: [validation](evidence/obs-validation-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
| Experimental Frame Control SteamVR panel | Real Frame OS 0.5.6 installed its user service, registered an overlay, served authenticated live status and a read-only extension, saved a screenshot and short MP4, and removed owned resources | No | Headset was unworn in standby: overlay reported `not_visible`, stereo capture was dark; wearer-visible pixels and controller input are unverified | 25 Oct, v0.4.29: [PR #198](https://github.com/saphid/frame-control/pull/198), [PR #201](https://github.com/saphid/frame-control/pull/201), [panel guide](headset-app.md) |
@@ -0,0 +1,79 @@
# Auto-sync retry and headset-selection reliability
Date: 25 October 2026. Base: `d79efb4`, Frame Control v0.4.30.
Issue: [#214](https://github.com/saphid/frame-control/issues/214).
Environment: Linux x86_64, Python 3.12.3, Node 24.15.0.
## Reproduced problems
The existing 19 auto-sync tests passed on the base revision. Added regressions
failed before the implementation: an exhausted clip lost its error, explicit
retry did not recover failed screenshots or clips, and switches around listing
or copying continued the old pass. The original callback interface also had no
way to carry the tick's captured device into the server list reservation.
The final fake-source tests exercise these boundaries:
- Fail screenshots or clips three times, keep checking without further copy
attempts, and retain the source error plus an explicit retry instruction.
- Recover the source and use either Resume or Copy everything now without
restarting. Successful copies stay deduplicated; Resume retains the ledger
even after a local file is deleted. Copy everything now retains its documented
ability to restore deleted files and include older media.
- Reset an exhausted budget while the source still fails: another three
attempts are allowed, rather than unlimited background attempts.
- Save an exhausted item by hand and clear its stale failure on the next list.
- Switch after tick captures A, during a list, after screenshot bookkeeping,
between media kinds, before a transfer, or before transfer bookkeeping.
Interrupted passes do not mark the other headset's IDs or continue copying.
- Switch to B exactly at callback entry through the production `_sync_list`
adapter. `working(A)` refuses before calling the source list, leaving both
ledgers and copy callbacks untouched. The same production reservation blocks
a device switch during a list and rejects stale screenshot/clip transfers.
- Execute the production Settings renderer in Node: Resume is available for
copy errors and disk-full pauses, and hidden for a healthy state.
## Implementation boundary
Each list callback now receives the captured device, like the copy callbacks.
The existing server `working(meant)` mechanism checks that intended headset
under `_work_lock` while reserving work; `devices_post` uses the same lock to
refuse switches during that work. The worker also checks for interruption
around lists and transfers. No new device lock or clip destination logic is
introduced. The server diff is confined to `_sync_list`.
The failed-attempt and error maps are in-memory and reset on explicit retry or
restart. The successful-copy ledger and settings persistence retain their
existing format. Disk-full pauses, offline backoff, clip settling and the
three-attempt limit remain covered by the original tests.
## Checks
- `python3 -m unittest discover -s tests -p test_autosync.py -v`: 33 passed.
- `python3 -m unittest discover -s tests -p test_shot_copy.py -v`: 2 passed.
- `python3 -m unittest discover -s tests -p test_clips.py -v`: 34 passed,
including local ffmpeg coverage.
- `python3 -m unittest discover -s tests -p test_service_regressions.py -v`:
36 passed.
- `python3 -m unittest discover -s tests -p test_page_scripts.py -v`: 5 passed.
- `node --test site/test/*.test.mjs`: 2 test files passed.
- `python3 -m py_compile ui/frame_autosync.py ui/server.py tests/test_autosync.py`
and `git diff --check`: passed.
- `python3 -m unittest discover -s tests -v`: 1,525 tests run in 184.876
seconds, passed with 16 skips. Ran with sandbox escalation for loopback
sockets; test isolation still used throwaway app/SSH data and mocked headset
transport. Final output:
```text
Ran 1525 tests in 184.876s
OK (skipped=16)
```
## Limits and handoff
All new regressions use fake sources and mocked transport; they do not prove
real-headset transfer or UI rendering. No headset was accessed: its shared test
lock was held by another task. The Docker fake-Frame end-to-end suite and
Windows/macOS runs were not performed locally. The coordinator owns review and
merge; this task does not release, change PR #113, or publish announcements.
+125
View File
@@ -0,0 +1,125 @@
# OBS end-to-end validation — 25 October 2026
**Passthrough: yes.** Real room pixels behind SteamVR panels appeared in the
live `/dev/video99` mirror, the saved OBS recording, and a decoded frame at
four seconds in a production MP4. The Frame was safely woken with
`vrcmd --handlewakeup`; no camera, power or compositor settings were changed.
This establishes the checked SteamVR mirror, not every game/mode or Steam's
own clips. Treat recordings as potentially showing the room.
## Environment and provenance
- Host: lxso2, Linux x86_64. Real OBS 30.0.2 in disposable Ubuntu 24.04/Xvfb,
using software llvmpipe rendering and the Media Source `ffmpeg_source`.
- Frame: SteamOS 0.5.6, build `20261022.6153909`, SteamVR 2.18.2, reachable
with the existing `ssh frame` connection. Hardware use was serialized with
coordinator mppkd9qczt through `/tmp/frame-test.lock` and a unique owner.
- Released v0.4.30 AppImage SHA-256:
`b1335bb06130f2cce64a0dd40a8cb60a52c68effc258dc4bc35f8c40c822d5bb`.
The long recording started through this app's actual Electron IPC, Python
helper and SSH stream. Robustness checks then used that released runtime
with the final changed resources and repacked `app.asar`; this was not a
newly built AppImage. No OBS plugin or host FFmpeg is required by the app.
- Windows release ZIP SHA-256:
`a393cc1653f14f162b4a3e56803f7fe9bea7b04904d18577b0b2d7d7c2d95b03`.
Checked against release SHA256SUMS; bundled Python 3.12.14.
Machine-readable measurements and the manual stimulus/analysis programs are
in [obs-validation-2026-10-25/](obs-validation-2026-10-25/).
Room images and videos remain private under `/tmp/obs-aacapv3evh` on lxso2;
only their hashes are committed in `private-artifacts.json`.
## Recording, sound, sync and latency
| Check | Measured result | What it establishes |
| --- | --- | --- |
| Real OBS recording | 187.367 s; 15,784,412 bytes; H.264 1280×720 at 30 fps; 5,621 decoded video frames; AAC 48 kHz stereo | More than three minutes through the packaged Linux path |
| OBS dropped frames | 0 encoder skips; 90 render lag/stall frames, approximately 1.6% of recording frames | The software-rendered host was not lossless; this is not a hardware-rendered performance benchmark |
| Audio | 1 kHz stimulus; FFT peak 996.094 Hz at 11.719 Hz bin resolution; peak −13.207 dBFS; 0 clipped samples; measured harmonic energy −81.32 dB relative to fundamental | Non-silent, no numerical clipping or large harmonic distortion in the checked tone; no listening or real-game quality verdict |
| Baseline A/V | Audio leads video by median 426.67 ms; range 360–426.67 ms across 36 pulses | Default OBS setup needs sync checking; not suitable for a claim of automatic accurate sync |
| Live image latency | 1,431.4 ms, n=1; clock exchange RTT 3.409 ms; screenshot polling about 20–35 ms | Overlay submission to OBS source screenshot, not physical headset motion-to-photon latency |
| Production MP4 | 5.121 s; 1920×1080, 30 fps, 153 video frames; AAC stereo; request to saved result 7.435 s | A separate production recorder saved room passthrough pixels too |
The manual stimulus places an owned black/white OpenVR marker over the mirror
and plays a 200 ms, 1 kHz tone every five seconds through the Frame's default
output. `pa_simple_get_latency` estimates when the submitted samples play;
marker submission is aligned to that estimate. This measures software output,
not an externally measured loudspeaker/physical-display event. Most submission
jitter was below 0.2 ms; compositor timing and the 30 fps/10 ms analysis grids
still limit precision. FFmpeg decodes both tracks; the analysis detects marker
edges and groups AAC pre-echo onsets within 100 ms.
A separate initial +400 ms OBS Sync Offset trial left 83.33 ms of audio lead.
Changing the offset during an already active source from 0 to +367 ms did not
improve that run (366.67 ms before, 370 ms after). Do not assume a universal
offset or that a live setting change has taken effect: reopen the source and
verify another recording. Three subsequent fresh-source trials, setting the offset **before activating**
the Media Source, measured 480 ms audio lead at 0 ms, 70 ms lead at +400 ms,
and **0 ms at +480 ms** across three pulses (30 fps video and 10 ms audio
analysis resolution). This proves a setup-specific workaround, not a universal
default. See `calibration-initial.json`. Full trial measurements are retained. Zero OBS
buffering caused missing/distorted sound and was rejected. Zero mux delay
and alternate timestamp experiments did not improve latency and were not
merged. The default capture/encoding settings remain unchanged.
## Robustness and fixes
| Failure or scenario | Fix / result | Proof |
| --- | --- | --- |
| Stop during asynchronous Python discovery could still launch later | Cancel the pending generation; expose Stop while starting | Controller regression test |
| Late stderr containing an earlier ready message changed stopping back to running | Only accept ready while starting; ignore stale child events | Original released block reproduced in `race-before.json`; controller regression |
| A non-responsive SSH process defeated timeout termination | Bounded terminate/wait then kill; SSH connection/keepalive bounds | Real SSH child deliberately stopped with SIGSTOP; clear UI failure in 15.009 s including 5 s forced cleanup; both processes gone |
| Timeout with the app's stdin still open aborted Python at shutdown | Read the control descriptor directly, avoiding a blocked buffered-stdin daemon lock | Previously reproduced fatal shutdown; final real stall shows `The Frame OBS video stopped for 10 seconds`; subprocess regression holds stdin open |
| Three Start/Stop cycles | First packets 0.890 / 0.701 / 0.709 s; Stop to idle 61.7 / 52.7 / 53.0 ms | Final patched packaged runtime, ports 15101–15103, `robustness.json` |
| Restart after stall | First packet 0.691 s | Same runtime and real Frame |
| Quit while streaming | No surviving helper at 2.021 s | Same runtime; final audit found no test FFmpeg/stimulus process |
| Port 15099 occupied | App now offers a validated alternate port and matching copyable URL | OBS occupied 15099 while real packets arrived on alternate ports; UDP cannot identify or guarantee the receiver |
| OBS before/after Start; receiver restarts | Live decoding verified, including recreated sources while relay stays active; an already-open stalled receiver may need hide/show or properties OK | Documented recovery, not automatic OBS control |
| Standby/wake | Safe wake changed a blank mirror to visible room/panels | Private mirror and saved recordings |
A forced SSH stall tests the transport failure and error/cleanup behavior.
It does **not** prove real Wi-Fi loss or full headset suspend/resume. No physical
network interface was disabled and no headset reset was performed. There is
no automatic reconnect: restore connectivity and press Start. Valid blank
standby frames do not trigger a packet timeout.
## Windows and macOS
**Windows controls remain hidden.** A disposable Windows 11 VM ran the final
helper using the released app's bundled Python and a native executable SSH
fixture. It stayed alive for three seconds without a UDP receiver, then sent
1,316-byte packets when a receiver opened. Closing its control pipe returned
exit 0 with no surviving SSH child (`windows-pipe.json`; stop measured below
the VM timer's resolution in the final run). The helper hides the SSH console
and tolerates Winsock's delayed UDP receiver-unreachable error.
The real-headset Windows attempt delivered zero packets through a temporary
host SSH bridge. Native Windows OBS, direct Windows-to-Frame SSH, firewall
behavior and packaged app Start/Stop have therefore not passed end to end.
No local FFmpeg was installed; encoding belongs on the Frame. No firewall
exception, headset authorized key, or Windows scheduled task was installed.
**macOS:** no physical Mac or macOS VM was available. Python tests and the
five controller lifecycle tests run in the macOS CI matrix; these cannot
establish capture performance, audio quality, packaged launch or OBS behavior
on a physical Mac.
## Validation and remaining limits
Local suite: **1,514 Python tests passed, 16 skipped**, 46 app tests and 18
site tests passed. The latest helper additionally passed all 11 focused OBS
tests; the controller passed all five focused lifecycle tests. Cross-platform
CI results belong to the linked PR, not these host measurements.
Still unverified: perceptual listening, real-game audio routing, accurate
default A/V sync, broad latency/performance across hosts, hours-long sessions,
physical Wi-Fi drop/full suspend recovery, physical macOS and native Windows
OBS. The app/docs now explain copyable URL/format, receiver recovery, port
selection, passthrough privacy and OBS Virtual Camera's video-only limit.
The Discord guide provides the corresponding audio-sharing choices.
Cleanup completed: Steam and vrcompositor remained running, battery 98%,
no test FFmpeg/stimulus process remained, UDP ports 15099/15101/15102/15103/15105
were bindable, task guest SSH config and the temporary bridge were removed,
and the owned headset lock was released. The disposable Windows VM was
stopped with its disk retained. See `cleanup.json`.
@@ -0,0 +1,23 @@
import json,subprocess,sys,statistics
from pathlib import Path
import numpy as np
from scipy import signal
video=Path(sys.argv[1]); root=video.parent
v=np.frombuffer(subprocess.check_output(['ffmpeg','-v','error','-i',str(video),'-map','0:v','-vf','scale=64:36','-pix_fmt','rgb24','-f','rawvideo','-']),dtype=np.uint8).reshape(-1,36,64,3)
a=np.frombuffer(subprocess.check_output(['ffmpeg','-v','error','-i',str(video),'-map','0:a','-ac','1','-ar','48000','-f','f32le','-']),dtype='<f4')
# Locate the high contrast square marker, excluding the background's low variations.
gray=v.astype(float).mean(axis=3); spread=np.percentile(gray,98,axis=0)-np.percentile(gray,2,axis=0)
y,x=np.unravel_index(spread.argmax(),spread.shape); track=gray[:,y,x]; lo,hi=np.percentile(track,[2,98]); white=track>(lo+hi)/2;onsets=np.where(np.diff(white.astype(int))==1)[0]+1
rms=np.sqrt(np.mean(a[:len(a)//480*480].reshape(-1,480)**2,axis=1)); audible=rms>.003;beeps=(np.where(np.diff(audible.astype(int))==1)[0]+1)/100
beeps=beeps[np.r_[True,np.diff(beeps)>.1]]
visual=onsets/30; offsets=[]
for t in visual:
if len(beeps):
b=beeps[np.argmin(abs(beeps-t))]
if abs(b-t)<2:offsets.append(float((b-t)*1000))
active=a[abs(a)>.003]; peaks,props=signal.find_peaks(rms,height=.003,distance=100)
# Spectrum of one well inside a tone, with Hann window; harmonic distortion estimate.
start=int((beeps[2]+.05)*48000) if len(beeps)>2 else 0;sample=a[start:start+4096];freq=np.fft.rfftfreq(len(sample),1/48000);power=abs(np.fft.rfft(sample*np.hanning(len(sample))))**2
base=power[(freq>960)&(freq<1040)].sum();harm=sum(power[(freq>h*1000-40)&(freq<h*1000+40)].sum() for h in range(2,10))
result={'duration_s':len(v)/30,'video_frames':len(v),'marker_pixel_xy':[int(x),int(y)],'marker_levels':[float(lo),float(hi)],'visual_markers_s':visual.tolist(),'audio_onsets_s':beeps.tolist(),'audio_minus_video_ms':offsets,'offset_median_ms':statistics.median(offsets) if offsets else None,'offset_min_max_ms':[min(offsets),max(offsets)] if offsets else None,'peak_dbfs':float(20*np.log10(abs(a).max())),'clipped_samples':int((abs(a)>=.999).sum()),'tone_frequency_hz':float(freq[power.argmax()]),'harmonics_to_fundamental_db':float(10*np.log10(harm/base)),'rms_peak':float(rms.max())}
(root/(video.stem+'-analysis.json')).write_text(json.dumps(result,indent=2));np.save(root/(video.stem+'-marker-spread.npy'),spread);print(json.dumps(result,indent=2))
@@ -0,0 +1,136 @@
{
"duration_s": 187.36666666666667,
"video_frames": 5621,
"marker_pixel_xy": [
50,
0
],
"marker_levels": [
0.0,
255.0
],
"visual_markers_s": [
9.766666666666667,
14.766666666666667,
19.766666666666666,
24.766666666666666,
29.766666666666666,
34.766666666666666,
39.766666666666666,
44.766666666666666,
49.766666666666666,
54.766666666666666,
59.766666666666666,
64.76666666666667,
69.76666666666667,
74.76666666666667,
79.76666666666667,
84.76666666666667,
89.76666666666667,
94.76666666666667,
99.76666666666667,
104.76666666666667,
109.76666666666667,
114.76666666666667,
119.76666666666667,
124.76666666666667,
129.76666666666668,
134.76666666666668,
139.76666666666668,
144.7,
149.7,
154.7,
159.7,
164.7,
169.7,
174.7,
179.7,
184.7
],
"audio_onsets_s": [
9.34,
14.34,
19.34,
24.34,
29.34,
34.34,
39.34,
44.34,
49.34,
54.34,
59.34,
64.34,
69.34,
74.34,
79.34,
84.34,
89.34,
94.34,
99.34,
104.34,
109.34,
114.34,
119.34,
124.34,
129.34,
134.34,
139.34,
144.34,
149.34,
154.34,
159.34,
164.34,
169.34,
174.34,
179.34,
184.34
],
"audio_minus_video_ms": [
-426.66666666666765,
-426.66666666666765,
-426.66666666666583,
-426.66666666666583,
-426.66666666666583,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666765,
-426.6666666666765,
-426.6666666666765,
-359.9999999999852,
-359.9999999999852,
-359.9999999999852,
-359.9999999999852,
-359.9999999999852,
-359.9999999999852,
-359.9999999999852,
-359.9999999999852,
-359.9999999999852
],
"offset_median_ms": -426.6666666666623,
"offset_min_max_ms": [
-426.6666666666765,
-359.9999999999852
],
"peak_dbfs": -13.2072114944458,
"clipped_samples": 0,
"tone_frequency_hz": 996.09375,
"harmonics_to_fundamental_db": -81.32048537168609,
"rms_peak": 0.15280573070049286
}
@@ -0,0 +1,123 @@
[
{
"duration_s": 21.033333333333335,
"video_frames": 631,
"marker_pixel_xy": [
50,
0
],
"marker_levels": [
0.0,
255.0
],
"visual_markers_s": [
4.9,
9.9,
14.9,
19.9
],
"audio_onsets_s": [
4.42,
9.42,
14.42,
19.42
],
"audio_minus_video_ms": [
-480.00000000000045,
-480.00000000000045,
-480.00000000000045,
-479.9999999999969
],
"offset_median_ms": -480.00000000000045,
"offset_min_max_ms": [
-480.00000000000045,
-479.9999999999969
],
"peak_dbfs": -13.202757835388184,
"clipped_samples": 0,
"tone_frequency_hz": 996.09375,
"harmonics_to_fundamental_db": -83.94567523863627,
"rms_peak": 0.15256504714488983,
"offset_ms": 0,
"recording": "2026-10-25 06-31-15.mkv"
},
{
"duration_s": 21.066666666666666,
"video_frames": 632,
"marker_pixel_xy": [
50,
0
],
"marker_levels": [
0.0,
255.0
],
"visual_markers_s": [
10.6,
15.6,
20.6
],
"audio_onsets_s": [
5.62,
10.53,
15.53,
20.53
],
"audio_minus_video_ms": [
-70.00000000000028,
-70.00000000000028,
-70.00000000000028
],
"offset_median_ms": -70.00000000000028,
"offset_min_max_ms": [
-70.00000000000028,
-70.00000000000028
],
"peak_dbfs": -13.215741157531738,
"clipped_samples": 0,
"tone_frequency_hz": 996.09375,
"harmonics_to_fundamental_db": -83.99685616364809,
"rms_peak": 0.15322808921337128,
"offset_ms": 400,
"recording": "2026-10-25 06-31-46.mkv"
},
{
"duration_s": 21.0,
"video_frames": 630,
"marker_pixel_xy": [
50,
0
],
"marker_levels": [
0.0,
255.0
],
"visual_markers_s": [
7.6,
12.6,
17.6
],
"audio_onsets_s": [
7.6,
12.6,
17.6
],
"audio_minus_video_ms": [
0.0,
0.0,
0.0
],
"offset_median_ms": 0.0,
"offset_min_max_ms": [
0.0,
0.0
],
"peak_dbfs": -13.219660758972168,
"clipped_samples": 0,
"tone_frequency_hz": 996.09375,
"harmonics_to_fundamental_db": -83.85259564406981,
"rms_peak": 0.15266509354114532,
"offset_ms": 480,
"recording": "2026-10-25 06-32-18.mkv"
}
]
@@ -0,0 +1,22 @@
import json,sys,time,subprocess,os
from pathlib import Path
sys.path.insert(0,'/tmp/obs-aacapv3evh');from control import OBS
r=Path('/tmp/obs-aacapv3evh');o=OBS();
try:o.request('RemoveInput',{'inputName':'Frame mirror'})
except RuntimeError:pass
time.sleep(1)
h=subprocess.Popen([sys.executable,'scripts/obs-headset-source.py','--control-stdin'],stdin=subprocess.PIPE,stderr=(r/'calibration-initial-helper.log').open('w'))
p=subprocess.Popen(['ssh','frame','XDG_RUNTIME_DIR=/run/user/1000 timeout 100 python3 /tmp/obs-aacapv3evh/stimulus.py'],stdout=(r/'calibration-initial-stimulus.jsonl').open('w'),stderr=(r/'calibration-initial-stimulus.log').open('w'))
results=[]
try:
for offset in (0,400,480):
name='Frame calibration '+str(offset)
data=o.request('CreateInput',{'sceneName':'Scene','inputName':name,'inputKind':'ffmpeg_source','inputSettings':{'is_local_file':False,'input':'udp://127.0.0.1:15099','input_format':'mpegts','buffering_mb':2,'restart_on_activate':True},'sceneItemEnabled':False})
o.request('SetInputAudioSyncOffset',{'inputName':name,'inputAudioSyncOffset':offset})
print('setting',o.request('GetInputAudioSyncOffset',{'inputName':name}),flush=True)
o.request('SetSceneItemEnabled',{'sceneName':'Scene','sceneItemId':data['sceneItemId'],'sceneItemEnabled':True})
time.sleep(7);o.request('StartRecord');time.sleep(21);rec=o.request('StopRecord');video=r/Path(rec['outputPath']).name
env=dict(os.environ,PYTHONPATH=str(r/'deps'));a=json.loads(subprocess.check_output([sys.executable,str(r/'analyse.py'),str(video)],env=env));a.update(offset_ms=offset,recording=video.name);results.append(a);print(json.dumps(a),flush=True)
o.request('RemoveInput',{'inputName':name})
finally:
h.stdin.close();h.wait(timeout=10);p.terminate();p.wait(timeout=10);(r/'calibration-initial-result.json').write_text(json.dumps(results,indent=2))
@@ -0,0 +1,88 @@
{
"offset_ms": 367,
"records": [
{
"duration_s": 19.966666666666665,
"video_frames": 599,
"marker_pixel_xy": [
50,
0
],
"marker_levels": [
0.0,
255.0
],
"visual_markers_s": [
6.133333333333334,
7.266666666666667,
12.266666666666667,
17.266666666666666
],
"audio_onsets_s": [
6.9,
11.9,
16.9
],
"audio_minus_video_ms": [
766.6666666666666,
-366.66666666666623,
-366.66666666666714,
-366.66666666666714
],
"offset_median_ms": -366.6666666666667,
"offset_min_max_ms": [
-366.66666666666714,
766.6666666666666
],
"peak_dbfs": -13.215461730957031,
"clipped_samples": 0,
"tone_frequency_hz": 996.09375,
"harmonics_to_fundamental_db": -84.12980132719696,
"rms_peak": 0.15284518897533417,
"phase": "before",
"recording": "2026-10-25 06-28-11.mkv"
},
{
"duration_s": 20.066666666666666,
"video_frames": 602,
"marker_pixel_xy": [
50,
0
],
"marker_levels": [
0.0,
255.0
],
"visual_markers_s": [
4.4,
9.4,
14.4,
19.4
],
"audio_onsets_s": [
4.03,
9.03,
14.03,
19.03
],
"audio_minus_video_ms": [
-370.0000000000001,
-370.000000000001,
-370.000000000001,
-369.99999999999744
],
"offset_median_ms": -370.00000000000057,
"offset_min_max_ms": [
-370.000000000001,
-369.99999999999744
],
"peak_dbfs": -13.167834281921387,
"clipped_samples": 0,
"tone_frequency_hz": 996.09375,
"harmonics_to_fundamental_db": -82.16381051643725,
"rms_peak": 0.15364883840084076,
"phase": "after",
"recording": "2026-10-25 06-28-34.mkv"
}
]
}
@@ -0,0 +1,16 @@
{
"bindable_udp_ports": [
15099,
15101,
15102,
15103,
15105
],
"remote_task_ffmpeg_and_stimulus_processes": 0,
"steam_and_vrcompositor_running": true,
"battery_percent": 98,
"headset_lock_released": true,
"guest_task_ssh_host_block_removed": true,
"temporary_ssh_bridge_stopped": true,
"persistent_headset_authentication_changes": false
}
@@ -0,0 +1,46 @@
{
"duration_s": 36.06666666666667,
"video_frames": 1082,
"marker_pixel_xy": [
50,
0
],
"marker_levels": [
0.0,
255.0
],
"visual_markers_s": [
9.433333333333334,
14.433333333333334,
19.433333333333334,
24.433333333333334,
29.433333333333334,
34.43333333333333
],
"audio_onsets_s": [
9.35,
14.35,
19.35,
24.35,
29.35,
34.35
],
"audio_minus_video_ms": [
-83.33333333333393,
-83.33333333333393,
-83.33333333333215,
-83.33333333333215,
-83.33333333333215,
-83.3333333333286
],
"offset_median_ms": -83.33333333333215,
"offset_min_max_ms": [
-83.33333333333393,
-83.3333333333286
],
"peak_dbfs": -13.15299129486084,
"clipped_samples": 0,
"tone_frequency_hz": 996.09375,
"harmonics_to_fundamental_db": -82.4335935832891,
"rms_peak": 0.15312190353870392
}
@@ -0,0 +1,13 @@
{
"clock": {
"rtt": 0.0034094941802322865,
"offset_remote_minus_host": -2894789.9195547644
},
"hits": [
{
"source_index": 1000,
"delay_ms": 1431.418131571263,
"query_ms": 11.743285227566957
}
]
}
@@ -0,0 +1,25 @@
{
"programs": [
],
"streams": [
{
"codec_name": "h264",
"width": 1920,
"height": 1080,
"r_frame_rate": "30/1",
"nb_read_frames": "153"
},
{
"codec_name": "aac",
"sample_rate": "48000",
"channels": 2,
"r_frame_rate": "0/0",
"nb_read_frames": "233"
}
],
"format": {
"duration": "5.121000",
"size": "3932019"
}
}
@@ -0,0 +1,10 @@
{
"elapsed_s": 7.434504003729671,
"result": {
"state": "saved",
"file": "/tmp/obs-aacapv3evh/mp4/rec-20261025-062251.mp4",
"audio": true,
"seconds": 5,
"message": "Saved rec-20261025-062251.mp4 to /tmp OBS evidence"
}
}
@@ -0,0 +1,210 @@
{
"result": {
"outputPath": "/evidence/2026-10-25 06-08-22.mkv"
},
"samples": [
{
"elapsed": 30.000104404054582,
"obs": {
"activeFps": 30.000000300000007,
"availableDiskSpace": 9068.64453125,
"averageFrameRenderTime": 18.458413,
"cpuUsage": 20.24916849878798,
"memoryUsage": 543.7109375,
"outputSkippedFrames": 0,
"outputTotalFrames": 959,
"renderSkippedFrames": 4,
"renderTotalFrames": 1783,
"webSocketSessionIncomingMessages": 14,
"webSocketSessionOutgoingMessages": 16
},
"record": {
"outputActive": true,
"outputBytes": 2243010,
"outputDuration": 30866,
"outputPaused": false,
"outputTimecode": "00:00:30.866"
},
"app": {
"state": "running",
"alias": "frame",
"error": "",
"supported": true
}
},
{
"elapsed": 60.002103955019265,
"obs": {
"activeFps": 30.000000300000007,
"availableDiskSpace": 8942.09375,
"averageFrameRenderTime": 13.660508,
"cpuUsage": 23.863888888888887,
"memoryUsage": 544.56640625,
"outputSkippedFrames": 0,
"outputTotalFrames": 1859,
"renderSkippedFrames": 5,
"renderTotalFrames": 2683,
"webSocketSessionIncomingMessages": 16,
"webSocketSessionOutgoingMessages": 18
},
"record": {
"outputActive": true,
"outputBytes": 4951606,
"outputDuration": 60866,
"outputPaused": false,
"outputTimecode": "00:01:00.866"
},
"app": {
"state": "running",
"alias": "frame",
"error": "",
"supported": true
}
},
{
"elapsed": 90.0040782331489,
"obs": {
"activeFps": 30.000000300000007,
"availableDiskSpace": 8900.6015625,
"averageFrameRenderTime": 12.110281,
"cpuUsage": 23.680555555555557,
"memoryUsage": 545.74609375,
"outputSkippedFrames": 0,
"outputTotalFrames": 2759,
"renderSkippedFrames": 13,
"renderTotalFrames": 3583,
"webSocketSessionIncomingMessages": 18,
"webSocketSessionOutgoingMessages": 20
},
"record": {
"outputActive": true,
"outputBytes": 7552329,
"outputDuration": 90866,
"outputPaused": false,
"outputTimecode": "00:01:30.866"
},
"app": {
"state": "running",
"alias": "frame",
"error": "",
"supported": true
}
},
{
"elapsed": 120.0062853959389,
"obs": {
"activeFps": 30.000000300000007,
"availableDiskSpace": 8603.08984375,
"averageFrameRenderTime": 14.892247,
"cpuUsage": 24.66111111111111,
"memoryUsage": 546.52734375,
"outputSkippedFrames": 0,
"outputTotalFrames": 3659,
"renderSkippedFrames": 13,
"renderTotalFrames": 4483,
"webSocketSessionIncomingMessages": 20,
"webSocketSessionOutgoingMessages": 22
},
"record": {
"outputActive": true,
"outputBytes": 10124530,
"outputDuration": 120866,
"outputPaused": false,
"outputTimecode": "00:02:00.866"
},
"app": {
"state": "running",
"alias": "frame",
"error": "",
"supported": true
}
},
{
"elapsed": 150.00849257130176,
"obs": {
"activeFps": 30.000000300000007,
"availableDiskSpace": 8484.58203125,
"averageFrameRenderTime": 15.3434,
"cpuUsage": 23.169444444444444,
"memoryUsage": 548.5546875,
"outputSkippedFrames": 0,
"outputTotalFrames": 4559,
"renderSkippedFrames": 78,
"renderTotalFrames": 5383,
"webSocketSessionIncomingMessages": 22,
"webSocketSessionOutgoingMessages": 24
},
"record": {
"outputActive": true,
"outputBytes": 12643085,
"outputDuration": 150833,
"outputPaused": false,
"outputTimecode": "00:02:30.833"
},
"app": {
"state": "running",
"alias": "frame",
"error": "",
"supported": true
}
},
{
"elapsed": 180.01099411491305,
"obs": {
"activeFps": 30.000000300000007,
"availableDiskSpace": 8382.75,
"averageFrameRenderTime": 31.402812,
"cpuUsage": 23.717094301899365,
"memoryUsage": 548.51953125,
"outputSkippedFrames": 0,
"outputTotalFrames": 5459,
"renderSkippedFrames": 90,
"renderTotalFrames": 6283,
"webSocketSessionIncomingMessages": 24,
"webSocketSessionOutgoingMessages": 26
},
"record": {
"outputActive": true,
"outputBytes": 15130232,
"outputDuration": 180833,
"outputPaused": false,
"outputTimecode": "00:03:00.833"
},
"app": {
"state": "running",
"alias": "frame",
"error": "",
"supported": true
}
},
{
"elapsed": 185.01674677524716,
"obs": {
"activeFps": 30.000000300000007,
"availableDiskSpace": 8366.79296875,
"averageFrameRenderTime": 22.859219,
"cpuUsage": 23.766666666666666,
"memoryUsage": 510.28515625,
"outputSkippedFrames": 0,
"outputTotalFrames": 5609,
"renderSkippedFrames": 90,
"renderTotalFrames": 6433,
"webSocketSessionIncomingMessages": 26,
"webSocketSessionOutgoingMessages": 28
},
"record": {
"outputActive": true,
"outputBytes": 15580161,
"outputDuration": 185833,
"outputPaused": false,
"outputTimecode": "00:03:05.833"
},
"app": {
"state": "running",
"alias": "frame",
"error": "",
"supported": true
}
}
]
}
@@ -0,0 +1,26 @@
[
{
"name": "2026-10-25 06-08-22.mkv",
"bytes": 15784412,
"sha256": "f2760ed28bf48274cc8c936428edbc389cd3a9f4b9cd2a61e699a04bf4487b50",
"published": false
},
{
"name": "rec-20261025-062251.mp4",
"bytes": 3932019,
"sha256": "c71de1a49b75159c587fddd012361b9e6626a4d5cc41b79696343a17b73c151a",
"published": false
},
{
"name": "mirror-wake.png",
"bytes": 1741004,
"sha256": "0754466948d5b9e8448f9ed67c8582e19802367df6ed4002afc64f42e1763f53",
"published": false
},
{
"name": "private-mp4-frame4.png",
"bytes": 1002068,
"sha256": "08d87bc9ab0e62115b42cca7dfcf7e4db11848a0ede273034a92a69297c6775b",
"published": false
}
]
@@ -0,0 +1,15 @@
{
"baseline": "d79efb4",
"after_stop": {
"state": "stopping",
"alias": "frame",
"error": "",
"supported": true
},
"after_late_stderr": {
"state": "running",
"alias": "frame",
"error": "",
"supported": true
}
}
@@ -0,0 +1,70 @@
[
{
"test": "start-stop",
"cycle": 1,
"port": 15101,
"first_packet_s": 0.890392117202282,
"packet_bytes": 1316,
"stop_s": 0.06173023069277406,
"state": {
"state": "idle",
"alias": "frame",
"port": 15101,
"error": "",
"supported": true
}
},
{
"test": "start-stop",
"cycle": 2,
"port": 15102,
"first_packet_s": 0.7008698149584234,
"packet_bytes": 1316,
"stop_s": 0.0526565620675683,
"state": {
"state": "idle",
"alias": "frame",
"port": 15102,
"error": "",
"supported": true
}
},
{
"test": "start-stop",
"cycle": 3,
"port": 15103,
"first_packet_s": 0.7092029112391174,
"packet_bytes": 1316,
"stop_s": 0.052990387193858624,
"state": {
"state": "idle",
"alias": "frame",
"port": 15103,
"error": "",
"supported": true
}
},
{
"test": "stalled-ssh",
"ssh_pid": 3025339,
"elapsed_s": 15.008651106152683,
"state": {
"state": "failed",
"alias": "frame",
"port": 15105,
"error": "OBS source stopped (1). The Frame OBS video stopped for 10 seconds",
"supported": true
},
"helper_gone": true,
"ssh_gone": true
},
{
"test": "restart-after-stall",
"first_packet_s": 0.6910299891605973
},
{
"test": "quit-streaming",
"checked_after_s": 2.020789209753275,
"surviving_helpers": []
}
]
@@ -0,0 +1,46 @@
import ctypes as C, json, math, os, signal, struct, subprocess, sys, threading, time
# Put frame_media_player.py and its two imports beside this helper on the Frame.
sys.path.insert(0, os.path.dirname(__file__))
from frame_media_player import Overlay, OverlayBusy
os.environ['XDG_RUNTIME_DIR']='/run/user/1000'
class Spec(C.Structure):
_fields_=[('format',C.c_int),('rate',C.c_uint32),('channels',C.c_uint8)]
class Attr(C.Structure):
_fields_=[(k,C.c_uint32) for k in ('maxlength','tlength','prebuf','minreq','fragsize')]
p=C.CDLL('libpulse-simple.so.0'); p.pa_simple_new.restype=C.c_void_p
p.pa_simple_new.argtypes=[C.c_char_p,C.c_char_p,C.c_int,C.c_char_p,C.c_char_p,C.POINTER(Spec),C.c_void_p,C.POINTER(Attr),C.POINTER(C.c_int)]
p.pa_simple_write.argtypes=[C.c_void_p,C.c_void_p,C.c_size_t,C.POINTER(C.c_int)]
p.pa_simple_get_latency.argtypes=[C.c_void_p,C.POINTER(C.c_int)];p.pa_simple_get_latency.restype=C.c_uint64
p.pa_simple_free.argtypes=[C.c_void_p]
err=C.c_int(); spec=Spec(3,48000,2); attr=Attr(0xffffffff,9600,0,1920,0xffffffff)
stream=p.pa_simple_new(None,b'OBS validation',1,None,b'aligned marker',C.byref(spec),None,C.byref(attr),C.byref(err))
if not stream: raise RuntimeError('pulse '+str(err.value))
vr=Overlay(); handle=vr.create('framecontrol.obs.validation.aacapv3evh',2,1.5,order=100)
quit=threading.Event(); events=[]
signal.signal(signal.SIGTERM,lambda *_:quit.set())
def wake():
while not quit.is_set():
subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--handlewakeup'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
quit.wait(1)
threading.Thread(target=wake,daemon=True).start()
try:
previous=False
for i in range(19000):
if quit.is_set():break
tone= i>=500 and (i-500)%500<20
block=b''.join(struct.pack('<hh',int(5000*math.sin(2*math.pi*1000*(i*480+j)/48000)) if tone else 0,int(5000*math.sin(2*math.pi*1000*(i*480+j)/48000)) if tone else 0) for j in range(480))
b=C.create_string_buffer(block)
if p.pa_simple_write(stream,b,len(block),C.byref(err)):raise RuntimeError('write '+str(err.value))
if tone != previous or i==0:
latency=p.pa_simple_get_latency(stream,C.byref(err))/1e6
expected=time.monotonic()+latency-.01
time.sleep(max(0,expected-time.monotonic()))
before=time.monotonic()
try:vr.pixels(handle,(b'\xff\xff\xff\xff' if tone else b'\x00\x00\x00\xff')*64*64,64,64)
except OverlayBusy:continue
after=time.monotonic()
event={'tone':tone,'index':i,'expected_audio':expected,'visual_submit':before,'visual_return':after,'submit_minus_audio_ms':(before-expected)*1000,'pulse_latency_ms':latency*1000}
print(json.dumps(event),flush=True); events.append(event)
previous=tone
finally:
quit.set();vr.close();p.pa_simple_free(stream)
@@ -0,0 +1,10 @@
{
"alive_without_receiver": true,
"platform": "win32",
"has_udp_connreset": false,
"packet_bytes": 1316,
"ssh_pid": 2668,
"stop_seconds": 0.0,
"helper_exit": 0,
"ssh_child_alive": false
}
+91
View File
@@ -0,0 +1,91 @@
# Round 2 retry, 25 October 2026
The provider retry completed four further fixes, each in an isolated child
worktree with its own issue, branch, PR, tests, docs and Announcement draft.
The coordinator reviewed each patch, independently ran focused tests, verified
its exact head and complete CI, merged it, then read back GitHub's merged state.
PR #113 was untouched. No social announcements were posted.
## Merged fixes
| Change | PR | Merge commit | Coordinator focused tests | Final CI |
| --- | --- | --- | --- | --- |
| Skip linked backup ancestors and recheck before packing | [#215](https://github.com/saphid/frame-control/pull/215) | `da34d94` | 34 passed | 11 checks passed at `8cc03c8` |
| Clean orphan smoke metadata/shortcuts; POSIX runner without zsh | [#216](https://github.com/saphid/frame-control/pull/216) | `d5fa8fe` | 17 passed | 10 checks passed at `a5daa4a` |
| Separate Steam clips by headset/account/full session ID | [#217](https://github.com/saphid/frame-control/pull/217) | `1255ef2` | 62 passed | 10 checks passed at `7dfe777` |
| Retry failed automatic copies and bind listings to the captured headset | [#218](https://github.com/saphid/frame-control/pull/218) | `fdd60d3` | 33 passed | 10 checks passed at `af2bb17` |
Two initial CI fixture failures were fixed before merging: macOS temporary-path
aliases needed a resolved working directory in the wrapper test; Windows
needed explicit UTF-8 when reading the Settings test's HTML fixture.
Concurrent main changes #208/#209/#210 were preserved. Changelog conflicts
retained both entries. No history was rewritten.
## Integrated validation
At main `1255ef2`, after all four merges:
- `python3 -m unittest discover -s tests`: 1,559 tests, 16 skipped, passed in
157.335 seconds. Loopback networking was enabled for local HTTP fixtures.
- `node --test app/test/*.test.js site/test/*.test.mjs`: 59 tests passed.
- Worker backup validation also passed both Docker recovery-kit E2E tests in
an isolated Compose project, then removed its containers, volumes and network.
- Local proof logs: `/tmp/frame-round2-integrated-tests.log` and
`/tmp/frame-round2-integrated-node.log`. Individual focused logs use
`/tmp/frame-round2-{backup,smoke,clips,autosync}-review.log`.
## Real Frame smoke
The current host is lxso2; its existing direct `ssh frame` alias works.
Another OBS task initially held `/tmp/frame-test.lock`, so only read-only
checks were done until it released the lock. This coordinator then acquired it
atomically and labeled it `frame-round2-vdw4yh1xv8-20261025`.
The merged `scripts/frame-smoke.sh` ran directly without zsh at 06:38:37 UTC.
All 13 steps passed on SteamOS 0.5.6, BUILD_ID `20261022.6153909`. The ARM64
and Proton titles ran. The x86-64 Linux title reported its runtime missing,
an explicitly accepted smoke outcome rather than a successful execution.
Installs/removals succeeded; the Windows title's remove
step stopped five test-owned PIDs. Both final process sweeps were empty.
Independent readback found no reserved title folders or metadata, SteamVR's
PID was unchanged, and the three observed gamescope focus properties matched
before/after. Battery went from 99% Charging to 100% Full. Only this task's
lock was removed, and its absence was verified.
[Sanitized structured proof](round2-smoke-2026-10-25.json). Raw local results:
`/tmp/frame-round2-smoke-results/2026-10-25T063837Z-20261022.6153909.json`;
console log `/tmp/frame-round2-headset-smoke.log`; state readbacks
`/tmp/frame-round2-smoke-before.txt` and `/tmp/frame-round2-smoke-after.txt`.
The headset was unworn. Visible rendering/controller input was not verified.
No pairing, password/security change, reboot or factory reset was performed.
The new backup/clip/auto-sync paths have local fixtures and cross-platform CI;
this smoke does not establish a new real-data backup/clip/UI test. Synthetic
orphan metadata was not seeded on the real device. Backup path checks retain
a documented race with concurrent filesystem changes.
## Prior round-two work reconciled
The failed coordinator checkout was still at v0.4.7. It was first updated to
main `d79efb4`, already v0.4.30. Earlier PRs had completed clipboard testing
(#124), original smoke process cleanup (#126), Windows VM capture (#129),
battery health (#125), setup doctor (#127), and read-only storage (#128).
Later evidence already covered real clip export (#147/#149/#154/#156) and
Return to Game Mode (#143/#157). These remain earlier evidence, not tests
performed by this retry. Physical Windows GPU/input and current physical
macOS checks remain incomplete; see the current support matrix.
Decision: fix reproduced data/cleanup/retry defects rather than automate game
moves without a verified transaction API or promise unverified phone-background
sync. Prepare v0.4.31; publication still requires its Linux AppImage install test.
## Delegation provenance
All implementations completed. Task IDs use prefix
`node:delegated-task:command%3Amcp%3Ad9bc35d8-0e9c-42c2-b798-25bd8af94d81%3Adelegate-task%3A`
followed by one of:
- `frame-r2-retry-smoke-orphans-20261025-r1`
- `frame-r2-backup-symlink-20261025-r1`
- `frame-r2-clip-collision-20261025-r1`
- `frame-r2-autosync-retry-20261025-r1`
+123
View File
@@ -0,0 +1,123 @@
{
"source_commit": "1255ef2bbf2e2e5fd86f8b984b592bed56f20780",
"started": "2026-10-25T06:38:37Z",
"os_release": {
"VERSION_ID": "0.5.6",
"VARIANT_ID": "vr",
"BUILD_ID": "20261022.6153909"
},
"passed": 13,
"failed": 0,
"paired": false,
"devkit_utils_there_before": true,
"battery_before": {
"capacity": 99,
"statuses": [
"Charging",
"Charging"
]
},
"battery_after": {
"capacity": 100,
"statuses": [
"Full",
"Full"
]
},
"steamvr_pid_before": 124586,
"steamvr_pid_after": 124586,
"observed_focus_properties_unchanged": true,
"reserved_files_and_processes_absent_after": true,
"own_shared_lock_released": true,
"headset_worn": false,
"steps": [
{
"step": "cleanup: leftovers from earlier runs",
"ok": true,
"seconds": 7.8,
"stopped_process_counts": [
0,
0
]
},
{
"step": "devkit service: properties.json",
"ok": true,
"seconds": 0.0
},
{
"step": "status",
"ok": true,
"seconds": 0.6
},
{
"step": "arm64: install",
"ok": true,
"seconds": 7.7
},
{
"step": "arm64: launch",
"ok": true,
"seconds": 3.3,
"outcome": "running"
},
{
"step": "arm64: remove",
"ok": true,
"seconds": 6.4,
"stopped_process_counts": [
2,
0
]
},
{
"step": "x86_64: install",
"ok": true,
"seconds": 7.6
},
{
"step": "x86_64: launch",
"ok": true,
"seconds": 3.2,
"outcome": "runtime missing"
},
{
"step": "x86_64: remove",
"ok": true,
"seconds": 6.2,
"stopped_process_counts": [
0,
0
]
},
{
"step": "exe: install",
"ok": true,
"seconds": 7.5
},
{
"step": "exe: launch",
"ok": true,
"seconds": 3.3,
"outcome": "running"
},
{
"step": "exe: remove",
"ok": true,
"seconds": 6.5,
"stopped_process_counts": [
5,
0
]
},
{
"step": "cleanup: everything this run installed",
"ok": true,
"seconds": 7.4,
"stopped_process_counts": [
0,
0
]
}
]
}
@@ -0,0 +1,70 @@
# Steam clip export collision fixtures, 25 October 2026
Base: `d79efb4` (Frame Control v0.4.30). Issue:
[#212](https://github.com/saphid/frame-control/issues/212).
Host: Linux 6.8.0-139-generic, Python 3.12.3, ffmpeg 6.1.1.
## Baseline reproduction
Loaded the unmodified `ui/frame_clips.py` from `d79efb4` into a temporary
Python module and exported three fake sources into a temporary directory:
```
1/clips/clip_620/video/bg
2/clips/clip_620/video/bg
1/clips/clip_620/video/other
```
Each source's payload was its own ID. All three exports returned the same
destination, `clip_620.mp4`; listing marked all three saved. The final file
contained only the last source ID. No SSH or headset was used.
## Regression coverage
`tests/test_clips.py` exports five IDs on each of two fake headsets, including
equal clip names across accounts, nested sessions with equal leaf names,
different leaf names under one clip and the background/clip distinction.
All ten payloads survive at distinct destinations. Listing marks only the
identities already exported. A pre-existing flat MP4 retains its original
bytes and does not mark any identity saved. Normal export still returns
`message` and `file`, removes its remote temporary export and clears local
staging; a failed re-export preserves the completed file.
`tests/test_clip_copy.py` exercises the server's list and manual export
functions and its actual auto-sync callbacks with fake transport. A manual
copy is recognised by auto-sync, all other account/session identities copy,
and switching to a second headset copies its own sources without overwriting
the first. A delayed manual job refuses a changed headset with status 409.
Both local phone mode and local headset runtime reject Steam clip list,
export and auto-sync requests with status 400. The separate `Recorder` path
is unchanged.
## Validation
- `PYTHONPATH=tests python3 -m unittest test_clips test_clip_copy test_autosync test_shot_copy test_headset_actions -v`
— 66 tests passed in 17.288 seconds, no skips. Includes real ffmpeg/ffprobe
export of a generated 65-second DASH fixture and recording stop/cleanup.
- `PYTHONPATH=tests python3 -m unittest test_server -v`
— 44 tests passed in 12.315 seconds, no skips.
- `node --test site/test/*.test.mjs` — 2 tests passed.
- `python3 -m unittest discover -s tests -v` — 1,518 tests run in
181.025 seconds; OK, 16 skipped for unavailable platform/tool prerequisites.
- `python3 -m py_compile ui/frame_clips.py ui/server.py tests/test_clips.py tests/test_clip_copy.py`
and `git diff --check` — passed.
The server and full Python suites require localhost sockets and were run
outside the network sandbox. Tests import `tests/sandbox.py` to isolate app
data, SSH settings, home directories and service URLs.
## Limits and upgrade behaviour
No real headset was accessed: the shared device lock is held by other work.
This is Linux fixture and server coverage, not a fresh packaged-app or
Windows/macOS verification. Existing real-headset export evidence in the
recording guide predates this destination change.
Old flat exports cannot prove headset/account/session identity and are left
untouched. Previously remembered auto-sync IDs retain their existing
watermark/history behaviour; **Copy everything now** or manual **Export**
creates an identified copy. No release, version bump, merge or social post
is included. PR #113 is outside this task.
+1
View File
@@ -31,6 +31,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
| `gamescopectl screenshot <file>` (with `WAYLAND_DISPLAY=gamescope-0`) captures gamescope's flat layer. | Frame Control's capture |
| The **headset view** (both eyes, fully composited: room, panels, dashboard, controllers) comes from OpenVR `IVRScreenshots::RequestScreenshot(VRScreenshotType_Stereo)`. It's callable from `python3` with `ctypes` against `/opt/steamvr/bin/linuxarm64/libopenvr_api.so` as an overlay app. The compositor appends `.png`, writing a 1920×1080 side-by-side image (960×1080 per eye) plus a left-eye preview, in about 0.3s. In standby the frame is blank. `vrcmd --screenshot` and `vrcmd --compositorcmd screenshot_request` wrote nothing, even with `steamvr/rawCapturePath` set. | `ui/frame_vrshot.py` |
| SteamVR's `steamvr-v4l2cam.service` (`/opt/steamvr/bin/linuxarm64/v4l2cam --output=99`) copies the headset view (the `system.HeadsetView` mirror, one undistorted image) into the v4l2loopback device `/dev/video99` ("SteamVR"), 1920×1080 RGB24. `ffmpeg -f v4l2 -i /dev/video99` reads it at about 70 new frames/s; the first frame read can be black. The Frame's hardware encoder (`iris_encoder`, `/dev/video-enc0`) crashes ffmpeg's `h264_v4l2m2m`, so encode with `libx264 -preset ultrafast -tune zerolatency`: 720p30 takes about 0.7 of a core and 1080p60 about 1.7 (of 8). gamescope also publishes a PipeWire `gamescope` video source, but the Frame's GStreamer has no `pipewiresrc`. **Verified 2026-09-26.** | Frame Control's live video (`/api/stream`) |
| **Passthrough in the mirror: yes on SteamOS 0.5.6**, build `20261022.6153909`, SteamVR 2.18.2. After `vrcmd --handlewakeup`, `/dev/video99` showed real room pixels behind panels. They also appeared in OBS's saved recording and a five-second production MP4. No camera setting was changed, and the private room images were not published. Other games/modes and Steam clips were not established. **Verified 2026-10-25.** | [OBS validation](evidence/obs-validation-2026-10-25.md) |
| Battery: `/sys/class/power_supply/max1720x_bat_7-36` gives µV/µA (current is positive while charging), `time_to_full_now`/`time_to_empty_now` in seconds, and `temp` in tenths of °C. The charger shows up as `tcpm-source-psy-…` (`type=USB`, `usb_type=C PD [PD_PPS]`), for example 12 V × 1.67 A. | Frame Control's battery card |
| `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn |
| **Testing VR apps without wearing the headset.** In standby SteamVR keeps OpenXR sessions hidden, so they render one frame and stop. `vrcmd` (in `/opt/steamvr/bin/linuxarm64`) settings use `section.key`: `vrcmd --set-settings-bool power.pauseCompositorOnStandby 0` and `vrcmd --set-settings-float power.turnOffScreensTimeout 3600`, then `vrcmd --handlewakeup`, keep the compositor running, and the scene app becomes visible. If it stays `visible-blurred`, the Steam dashboard is open: `SteamClient.OpenVR.VROverlay.HideDashboard()` in Steam's `SharedJSContext` (CDP on 8080) closes it. The headset view then captures with `ui/frame_vrshot.py`. Restore afterwards with `--set-settings-bool power.pauseCompositorOnStandby 1` and `--set-settings-float power.turnOffScreensTimeout 5`. The bool setter reads `true` as false, so use 1/0. A Steam launch that stalls in standby at `ShowInterstitials` or `CreatingProcess` (see `console_log.txt`) continues with `SteamClient.Apps.ContinueGameAction(<action id>, "<appid>", "<task>")`. **Verified 2026-09-27.** | Proving VR output remotely, [webxr-chromium.md](webxr-chromium.md) |
+43 -9
View File
@@ -1,7 +1,7 @@
# Headset view in OBS
Frame Control can send SteamVR's headset mirror and the Frame's output audio to
OBS on a Windows, Linux or macOS computer. The helper uses the same `/dev/video99` video
OBS on a Linux or macOS computer. A checkout helper is also available for Windows tests. 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.
@@ -11,7 +11,10 @@ local MPEG-TS stream; no port is opened to the LAN or internet.
**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
3. Expand **OBS setup** in the app to copy the Input URL and format. The port
can be changed before Start if another program uses 15099. Use that same
port in OBS. The sender cannot identify which program is listening.
4. 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.
@@ -24,9 +27,12 @@ run:
```
Ctrl+C stops the checkout helper. The app closes a control pipe when you click
Stop or quit; the helper then closes its SSH child, including on Windows. On
Windows, install Python 3 and OpenSSH Client if Frame Control cannot find them.
The Windows app Start/Stop path still needs a physical Windows/Frame test.
Stop or quit; the helper then closes its SSH child, including on Windows. Encoding runs on the Frame; the computer does not need FFmpeg. The desktop
app bundles Python. A source checkout needs Python 3.8+ and OpenSSH Client.
The Windows button is hidden: native Windows/Frame streaming and OBS have
not passed an end-to-end test. A Windows 11 VM passed the helper control-pipe,
late UDP receiver and child-cleanup fixture; its real-headset bridge delivered
zero packets. These are separate results, not native Windows support.
If no first packet arrives within 20 seconds, or an active stream sends no
packet for 10 seconds, the helper closes SSH and shows an error instead of
leaving **Start OBS source** waiting or a frozen source running indefinitely.
@@ -43,18 +49,22 @@ 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.
unworn. **Room passthrough is verified** in this mirror and the saved OBS/MP4 outputs
on SteamOS 0.5.6 after a safe SteamVR wake. Check the picture before sharing;
other games and modes may differ.
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
the video showed the standby mirror. That initial check did not assess listening quality, A/V sync or active
passthrough. The full validation below supersedes those gaps with measurements
and a positive passthrough result, while listening remains unverified. See the
[test evidence](evidence/obs-media-source-real-2026-10-25.md).
The [follow-up Linux/Frame transport check](evidence/obs-followup-2026-10-25.md)
measured packet continuity and a short MP4 recording on the current SteamOS
build. The headset was still in standby.
build. The newer [full OBS validation](evidence/obs-validation-2026-10-25.md)
records more than three minutes and measures audio, sync, latency and cleanup.
## SteamOS / Steam Machine PC host
@@ -108,3 +118,27 @@ The real Frame/OBS check above proved that a synthetic tone from the Frame's
output reached the Media Source recording. It did **not** use a running game,
listen to the audio, reproduce the reported Game Capture failure, or measure
audio/video sync. Real game audio while the headset is awake remains unverified.
## Check sync and recover a blank source
Make a short recording of a visible click, clap or other simultaneous marker
and sound before streaming. Open **Advanced Audio Properties** in OBS. If audio
arrives early, hide/deactivate this Media Source, add a positive **Sync Offset
(ms)** in Advanced Audio Properties, then show/reactivate it, record again,
and check the result. Changing the offset during playback did not correct
the tested OBS 30 source. Setting it before activation did: a +480 ms offset
reduced a 480 ms lead to 0 ms within the test resolution in three pulses. The 2026-10-25 Linux software-rendering test measured
about 0.4 seconds of audio lead with default OBS settings. This is a measured
setup result, not a universal offset. Do not apply it blindly to another host.
Start Frame Control's source before adding or activating the OBS Media Source.
After Stop/Start or a connection failure, hide and show the Media Source, or
reopen its properties and click OK, to reopen its UDP receiver. Keep only one
receiver on a port. If another application owns 15099, stop the relay, choose
another port in **OBS setup**, and change the OBS Input to match. Sending UDP
packets does not confirm that OBS has received or decoded them.
A stalled stream ends with an error. Wake or reconnect the Frame and press
Start again; automatic reconnection is not implemented. Standby can produce
valid but blank frames, so it does not necessarily trigger a packet timeout.
A fully suspended Frame cannot be woken over an unavailable SSH connection.
+4 -4
View File
@@ -20,10 +20,10 @@ compatibility](steamos-2026-10-compat.md). For the charge-limit result, see
No charging control was written. An 80% limit is **not supported** by Frame
Control; see [charge-limit.md](charge-limit.md) and the
[headset check](evidence/round3-headset-2026-10-25.md).
- Active passthrough in the recording mirror remains **unknown**: the only
captured mirror frame was uniform while the headset was unworn in standby.
A worn, active passthrough check is still needed; see the
[headset check](evidence/round3-headset-2026-10-25.md).
- Passthrough in the MP4 and OBS mirror is **verified on 0.5.6**: after a safe
SteamVR wake, room pixels appeared behind panels in the actual mirror and
saved outputs. Per-game behavior and Steam's own clips remain unverified;
see the [OBS validation](evidence/obs-validation-2026-10-25.md).
## Verified on device (2026-09-25)
+50 -17
View File
@@ -52,28 +52,49 @@ 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.
**Yes, the MP4 recording and OBS mirror can include room passthrough.** On
25 October 2026, SteamOS 0.5.6 build `20261022.6153909`, a safe SteamVR wake
produced real room pixels behind panels in `/dev/video99`. Room pixels also
appeared in decoded frames of both the OBS recording and a five-second MP4
made with the packaged production recorder. The private room images are not
published. See the [test record](evidence/obs-validation-2026-10-25.md).
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).
Both paths read SteamVR's `system.HeadsetView` mirror. Frame Control does not
open raw passthrough cameras or add a camera layer. The observed mirror includes
SteamVR's composited room and panels; it is not limited to a game's image.
This does not promise that every game or passthrough mode appears the same way.
The mirror can be blank in standby. Steam's own **Steam clips** use a separate
game recording path and were not checked for passthrough in this test.
## Steam clips
**Steam clips** lists what Steam's game recording has kept on the Frame: saved
clips and background recordings, newest first, with the game, length and size.
**Export** joins one into a single `.mp4` in `~/Videos/SteamFrame`, named after
its folder (for example `clip_620_20261020_202134.mp4`), however long it is.
**Export** joins one into a single `.mp4` under `~/Videos/SteamFrame`, named after
its clip folder (for example `clip_620_20261020_202134.mp4`), however long it is.
Steam's own export is reported to stop at about 60 seconds. Exports run in the
background; ones already on your computer are marked.
Each export keeps its headset, Steam account and complete recording session ID:
```
~/Videos/SteamFrame/headset-<id>/<account>/clips/<clip>/video/<session>/<clip>.mp4
~/Videos/SteamFrame/headset-<id>/<account>/video/<session>/<session>.mp4
```
The headset folder uses the same short device-identity hash as screenshots.
All folders in the Steam clip ID are kept, including nested sessions, so equal
clip names across accounts, headsets or sessions keep separate files. Manual
**Export** and automatic copying use the same path and saved marker. Exporting
the same identified session again replaces only that session's export.
Existing exports in the old flat `~/Videos/SteamFrame/<clip>.mp4` layout stay
untouched. Their source identity cannot be proved, so they do not mark a clip
as saved. Export again or use **Copy everything now** to make an identified
copy. Automatic copying still remembers IDs copied before the upgrade; use
**Copy everything now** to copy those again. This destination change has local
fixture coverage; it has not been tested on a real headset.
How it works: Steam keeps each recording as MPEG-DASH segments, one folder per
recording session.
@@ -137,18 +158,29 @@ case was tested with separate fake sources, not two real same-name screenshots.
- **When this computer is full** (under 512 MB free, or a copy runs out of
space) it pauses and says so in Settings and above the lists. Free some space
and press **Resume**.
- **With several headsets** it copies from the one you're connected to.
- **When one file keeps failing** it tries at most three times, then leaves
the problem visible in Settings. Fix the source problem and press **Resume**
to allow another three attempts without restarting Frame Control. **Copy
everything now** also resets failed attempts and includes older files.
Resume keeps the history of successful copies, so it does not recopy them.
- **With several headsets** each check and copy belongs to the headset selected
when the check began. Switching between lists or copies stops that pass;
another headset's saved files cannot become part of its copy history.
Settings shows when it last checked, how many files it last copied and when,
and the last problem it had. The screenshot and clip lists say when it's on.
Retry and switch handling have offline regression coverage with fake sources;
see the [test record](evidence/autosync-reliability-2026-10-25.md).
## For developers
`ui/frame_clips.py` holds the logic, so other features can reuse it (automatic
copying, in `ui/frame_autosync.py`, does):
- `list_clips(run_frame)` and `export_clip(id, run_frame, run, fetch)` are the
computer's side; the server passes how to reach the Frame.
- `list_clips(run_frame, device_id=...)` and
`export_clip(id, run_frame, run, fetch, device_id=...)` are the computer's
side; the server passes how to reach the Frame and its pinned device identity.
Both use `clip_local_path(id, device_id, folder=None)` for the destination.
- `list_recordings(userdata)` and `export(userdata, id)` are the Frame's side,
run as `python3 - list` / `python3 - export ID` with the file on stdin.
- `Recorder` runs one recording from Start to the copy.
@@ -171,7 +203,8 @@ The API, with the same guards as every other request:
They aren't available when the server runs on the Frame itself (the iPhone
app), since they save to a computer.
Tests: `tests/test_clips.py`, and `tests/test_autosync.py` for automatic
Tests: `tests/test_clips.py`, `tests/test_clip_copy.py` for server destinations
and collision coverage, and `tests/test_autosync.py` for automatic
copying (with a fake headset). They run the recording's shell under bash with a
stand-in `ffmpeg`, so Stop, the time limit and the clean-up are tested as they
run on the Frame. Where `ffmpeg` is installed, they also make a 65-second DASH
+1 -1
View File
@@ -17,4 +17,4 @@ Frame Control sends the Frame's **output audio**, not its microphone. Discord
audio routing differs by computer and requires a separate virtual audio device
if you want game sound in the call. This guide has not been verified in a real
Discord call. The SteamVR mirror can be blank while the headset is idle, and
active passthrough in that mirror remains unverified.
the mirror can include real room passthrough behind panels. Check the scene before sharing.
+23 -1
View File
@@ -12,6 +12,21 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
## Unit tests
Automatic screenshot and clip copying has offline coverage in
`tests/test_autosync.py`:
```sh
python3 -m unittest discover -s tests -p test_autosync.py -v
```
Fake sources exercise the three-attempt limit, visible exhausted errors,
Resume/Copy everything now recovery, successful-copy deduplication, disk-full
pauses and headset changes around lists and transfers. Server tests use the
production `working(meant)` reservation and listing/copy adapters; a Node test
executes the Settings renderer to check that Resume is available after an error.
These tests use no headset. See the
[auto-sync reliability test record](evidence/autosync-reliability-2026-10-25.md).
The gamescope keyboard/trackpad route has focused suites in `test_input.py`,
`test_touch.py` and `test_service_regressions.py`. They cover pad translation,
ASCII/modifier keys, Unicode queuing/retries, stale panel targets, release
@@ -246,7 +261,14 @@ title; the following process sweep found none. The final cleanup found no
test titles or processes, the test desktop was closed, and the battery
remained at 99%. That historical run used the Python entry point directly;
the current POSIX wrapper has local offline coverage on a PATH without zsh.
The orphan-metadata and shortcut follow-up has not been rerun on a headset.
The current runner and all-six-ID cleanup sweeps passed another 13-step real
Frame run on 25 October, build `20261022.6153909`, at source commit `1255ef2`.
The Windows title's remove step stopped five test-owned Proton/wrapper PIDs;
the final sweep and independent readback found no reserved files or processes.
SteamVR stayed running, observed focus properties matched before/after, and
this task's shared lock was released. The headset was unworn. This run did
not seed synthetic orphan metadata or shortcuts; those cases use local
fixtures. [Sanitized results](evidence/round2-smoke-2026-10-25.json).
The local regression suite runs real Linux processes in temporary fake title
directories (including deleted folders and a TERM-resistant wrapper); it checks
exit, escalation and survival of unrelated processes. Local filesystem commands
+37 -17
View File
@@ -5,6 +5,7 @@ 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 os
import shlex
import signal
import socket
@@ -18,6 +19,7 @@ PACKET = 188
DATAGRAM = PACKET * 7
STARTUP_TIMEOUT = 20
STREAM_STALL = 10
CHILD_STOP_TIMEOUT = 5
def remote_command(height=720, fps=30, audio=True):
@@ -58,19 +60,34 @@ def relay(source, destination, on_packet=None):
return total
def watch_control(stream, proc, stopped):
def stop_child(proc):
"""Bound cleanup even if a stalled SSH process does not handle termination."""
if proc.poll() is not None:
return
try:
proc.terminate()
proc.wait(timeout=CHILD_STOP_TIMEOUT)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
except ProcessLookupError:
pass # another shutdown path already reaped it
def watch_control(fd, proc, stopped):
"""A desktop app closes its pipe to stop SSH, including on Windows.
Windows TerminateProcess does not run Python's finally block. The pipe also
closes if the app crashes, so the SSH connection is closed in either case.
Read the descriptor directly: a blocked daemon must not hold sys.stdin's
buffered-I/O lock when a timeout makes the main thread exit.
"""
try:
stream.read(1)
os.read(fd, 1)
except OSError:
pass
stopped.set()
if proc.poll() is None:
proc.terminate()
stop_child(proc)
class StreamWatchdog:
@@ -111,8 +128,7 @@ class StreamWatchdog:
self.condition.wait(left)
else:
return
if self.proc.poll() is None:
self.proc.terminate()
stop_child(self.proc)
def main(argv=None):
@@ -129,16 +145,19 @@ def main(argv=None):
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)]
ssh = ['ssh', '-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10',
'-o', 'ServerAliveInterval=5', '-o', 'ServerAliveCountMax=2',
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)
options = {'creationflags': subprocess.CREATE_NO_WINDOW} if sys.platform == 'win32' else {}
proc = subprocess.Popen(ssh, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, **options)
stopped = threading.Event()
watchdog = StreamWatchdog(proc)
if args.control_stdin:
threading.Thread(target=watch_control, args=(sys.stdin.buffer, proc, stopped), daemon=True).start()
threading.Thread(target=watch_control, args=(sys.stdin.fileno(), proc, stopped), daemon=True).start()
try:
amount = relay(proc.stdout, _Sender(udp, target), watchdog.packet)
code = proc.wait(timeout=10)
@@ -155,13 +174,7 @@ def main(argv=None):
finally:
watchdog.close()
proc.stdout.close()
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
stop_child(proc)
class _Sender:
@@ -170,7 +183,14 @@ class _Sender:
self.ready = False
def send(self, data):
self.sock.sendto(data, self.target)
try:
self.sock.sendto(data, self.target)
except ConnectionResetError:
# Winsock can report a previous ICMP "port unreachable" here while
# OBS is closed. Keep sending so opening the receiver can recover.
if sys.platform != 'win32':
raise
return
if not self.ready:
self.ready = True
print('Frame Control OBS stream is ready', file=sys.stderr, flush=True)
+227 -2
View File
@@ -7,11 +7,13 @@ import sandbox # noqa: F401 (first: keeps tests off real data and services)
import errno
import json
import shutil
import subprocess
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
import frame_autosync as fa # noqa: E402
@@ -35,13 +37,15 @@ class FakeFrame:
self.device, self.busy, self.down = "frame-a", False, False
self.save_error = self.export_error = None
def list_shots(self):
def list_shots(self, device):
assert device == self.device
self.calls.append("list_shots")
if self.down:
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
return {"shots": list(self.shots)}
def list_clips(self):
def list_clips(self, device):
assert device == self.device
self.calls.append("list_clips")
if self.down:
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
@@ -263,6 +267,134 @@ class AutoSyncTest(unittest.TestCase):
for _ in range(fa.TRIES + 2):
self.sync.tick()
self.assertEqual(self.frame.calls.count("export_clip"), fa.TRIES)
self.assertIn("ffmpeg couldn't join", self.sync.state()["error"] or "")
self.assertIn("Resume", self.sync.state()["error"] or "")
def test_explicit_retry_recovers_without_recopying_successful_items(self):
for kind in ("shots", "clips"):
for action in ("resume", "copy_all"):
with self.subTest(kind=kind, action=action):
self.frame = FakeFrame()
f = self.frame
sync = self.make()
sync.update({"screenshots": True, "clips": True})
f.shots, f.clips = [shot(1, NOW + 5)], [clip(1, NOW + 5)]
error_attr = "save_error" if kind == "shots" else "export_error"
copy_call = "save_shots" if kind == "shots" else "export_clip"
setattr(f, error_attr, RuntimeError("source not ready"))
for _ in range(fa.TRIES + 2):
sync.tick()
self.assertEqual(f.calls.count(copy_call), fa.TRIES)
self.assertIn("source not ready", sync.state()["error"] or "")
setattr(f, error_attr, None)
sync.tick() # recovery alone must not create unlimited automatic retries
self.assertEqual(f.calls.count(copy_call), fa.TRIES)
getattr(sync, action)()
sync.tick()
self.assertEqual(f.saved, [("frame-a", [shot(1, 0)["id"]])])
self.assertEqual(f.exported, [("frame-a", clip(1, 0)["id"])])
self.assertIsNone(sync.state()["error"])
# Resume preserves the per-headset ledger even if a local file was deleted.
for item in f.shots + f.clips:
item["saved"] = False
sync.resume()
sync.tick()
self.assertEqual((len(f.saved), len(f.exported)), (1, 1))
self.sync.path.unlink(missing_ok=True)
def test_explicit_retry_is_still_bounded(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5)]
self.frame.export_error = RuntimeError("bad source")
for action in ("resume", "copy_all"):
for _ in range(fa.TRIES + 2):
self.sync.tick()
before = self.frame.calls.count("export_clip")
getattr(self.sync, action)()
for _ in range(fa.TRIES + 2):
self.sync.tick()
self.assertEqual(self.frame.calls.count("export_clip") - before, fa.TRIES)
self.assertIn("bad source", self.sync.state()["error"] or "")
def test_failed_item_saved_by_hand_clears_error(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5)]
self.frame.export_error = RuntimeError("bad source")
for _ in range(fa.TRIES + 1):
self.sync.tick()
self.frame.clips[0]["saved"] = True
self.sync.tick()
self.assertIsNone(self.sync.state()["error"])
self.assertIn(clip(1, 0)["id"], self.sync._synced("frame-a", "clips"))
def test_switch_after_capture_does_not_list_the_new_headset(self):
self.sync.update({"screenshots": True, "clips": True})
def switched():
self.frame.device = "frame-b"
return False
self.sync.busy = switched # tick has already captured frame-a
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertEqual(self.frame.calls, [])
def test_list_callbacks_receive_the_captured_device(self):
self.sync.update({"screenshots": True, "clips": True})
self.sync.list_shots = mock.Mock(return_value={"shots": []})
self.sync.list_clips = mock.Mock(return_value={"clips": []})
self.sync.tick()
self.sync.list_shots.assert_called_once_with("frame-a")
self.sync.list_clips.assert_called_once_with("frame-a")
def test_switch_during_listing_does_not_mark_or_copy_its_results(self):
for kind, item in (("shots", shot(1, NOW + 5)), ("clips", clip(1, NOW + 5))):
with self.subTest(kind=kind):
self.frame.device = "frame-a"
self.sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
def listing(*_args):
self.frame.device = "frame-b"
return {kind: [dict(item, saved=True), dict(item, id="uncopied", saved=False)]}
setattr(self.sync, "list_" + kind, listing)
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertEqual(self.sync._synced("frame-a", kind), set())
self.assertEqual(self.sync._synced("frame-b", kind), set())
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
def test_switch_between_kinds_does_not_read_clips_or_mark_transfer(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.shots = [shot(1, NOW + 5)]
self.frame.clips = [clip(1, NOW + 5, saved=True)]
save = self.sync.save_shots
def then_switch(device, ids):
save(device, ids)
self.frame.device = "frame-b"
self.sync.save_shots = then_switch
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertNotIn("list_clips", self.frame.calls)
self.assertEqual(self.sync._synced("frame-a", "clips"), set())
self.assertEqual(self.sync._synced("frame-a", "shots"), set())
def test_switch_after_bookkeeping_stops_before_next_kind(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.shots = [shot(1, NOW + 5, saved=True)]
mark = self.sync._mark
def then_switch(device, kind, ids):
mark(device, kind, ids)
self.frame.device = "frame-b"
self.sync._mark = then_switch
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertNotIn("list_clips", self.frame.calls)
def test_switch_before_transfer_does_not_copy(self):
for kind, item in (("shots", shot(1, NOW + 5)), ("clips", clip(1, NOW + 5))):
with self.subTest(kind=kind):
self.frame.device = "frame-a"
self.sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
setattr(self.frame, kind, [item])
def switched_space(_folder):
self.frame.device = "frame-b"
return 100 * GB
self.sync.space = switched_space
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
def test_worker_stops(self):
stop = threading.Event()
@@ -275,6 +407,75 @@ class AutoSyncTest(unittest.TestCase):
class Server(unittest.TestCase):
def test_switch_at_list_callback_entry_does_not_read_or_mark_the_new_headset(self):
import server
with tempfile.TemporaryDirectory() as folder:
for kind in ("shots", "clips"):
with self.subTest(kind=kind):
active = {"id": "frame-a"}
link = mock.Mock()
link.active_device.side_effect = lambda: dict(active)
listing = mock.Mock(return_value={kind: [
shot(1, NOW + 5, saved=True) if kind == "shots" else clip(1, NOW + 5, saved=True)]})
def switch_then_list(device):
active["id"] = "frame-b"
return server._sync_list(listing)(device)
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]):
sync = fa.AutoSync(switch_then_list, mock.Mock(), switch_then_list, mock.Mock(),
lambda: active["id"], lambda: False,
path=Path(folder) / (kind + ".json"), clock=lambda: NOW)
sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
sync.tick()
listing.assert_not_called()
sync.save_shots.assert_not_called()
sync.export_clip.assert_not_called()
self.assertEqual(sync._synced("frame-a", kind), set())
self.assertEqual(sync._synced("frame-b", kind), set())
self.assertIn("switched headsets", sync.state()["error"])
self.assertEqual(server._work[0], 0)
def test_listing_rejects_switch_before_reserving_work(self):
import server
link = mock.Mock()
link.active_device.return_value = {"id": "frame-b"}
listing = mock.Mock(return_value={"shots": [shot(1, NOW, saved=True)]})
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]):
with self.assertRaisesRegex(server.Failure, "switched headsets"):
server._sync_list(listing)("frame-a")
listing.assert_not_called()
self.assertEqual(server._work[0], 0)
def test_listing_reserves_work_on_the_captured_device(self):
import server
link = mock.Mock()
link.active_device.return_value = {"id": "frame-a", "name": "Frame A"}
def listing():
self.assertGreater(server._work[0], 0)
with self.assertRaises(server.Failure):
server.devices_post({"action": "use", "id": "frame-b"})
return {"shots": []}
with mock.patch.object(server, "LINK", link), \
mock.patch.object(server, "PRIVATE", False), mock.patch.object(server, "_work", [0]):
self.assertEqual(server._sync_list(listing)("frame-a"), {"shots": []})
self.assertEqual(server._work[0], 0)
def test_copy_rejects_switch_after_listing(self):
import server
link = mock.Mock()
link.active_device.return_value = {"id": "frame-a"}
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]), \
mock.patch.object(server, "save_shots") as save, \
mock.patch.object(server.frame_clips, "export_clip") as export:
server._sync_list(lambda: {"shots": []})("frame-a")
link.active_device.return_value = {"id": "frame-b"}
for copy in (lambda: server._sync_shots("frame-a", [shot(1, NOW)["id"]]),
lambda: server._sync_clip("frame-a", clip(1, NOW)["id"])):
with self.assertRaisesRegex(server.Failure, "switched headsets"):
copy()
save.assert_not_called()
export.assert_not_called()
self.assertEqual(server._work[0], 0)
def test_route_and_settings(self):
import server
self.assertIn("/api/autosync", server.POST)
@@ -287,5 +488,29 @@ class Server(unittest.TestCase):
server.auto_sync({"action": "now"}) # nothing connected
@unittest.skipUnless(shutil.which("node"), "Node exercises the Settings renderer")
class Settings(unittest.TestCase):
def test_resume_is_available_after_a_copy_error(self):
page = (Path(__file__).resolve().parents[1] / "ui/index.html").read_text(encoding="utf-8")
script = page.split("const autoSync =", 1)[1].split("async function loadAutoSync", 1)[0]
harness = r'''
const assert = require('node:assert/strict');
const nodes = {};
const $ = id => nodes[id] ||= {};
const HOST = {mobile: false};
const esc = value => value;
'''
checks = r'''
for (const state of [{error: 'Could not copy clip'}, {paused: 'Disk full'}, {}]) {
renderAutoSync(state);
assert.equal($('syncResume').hidden, !(state.error || state.paused));
if (state.error) assert.match($('syncStatus').textContent, /Could not copy clip/);
}
'''
result = subprocess.run(["node", "-e", harness + "const autoSync =" + script + checks],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()
+115
View File
@@ -0,0 +1,115 @@
"""Clip destinations through the server and automatic copying, without a headset."""
import sandbox # noqa: F401
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
import frame_autosync
import frame_clips
import server
IDS = ["1/clips/clip_620/video/bg", "2/clips/clip_620/video/bg",
"1/clips/clip_620/video/other", "1/clips/other/video/bg"]
NOW = 1800000000
class ClipCopyTest(unittest.TestCase):
def setUp(self):
self.folder = Path(tempfile.mkdtemp(prefix="frame-clip-copy-"))
self.addCleanup(shutil.rmtree, self.folder, ignore_errors=True)
self.device = "frame-a"
self.link = mock.Mock()
self.link.active_device.side_effect = lambda: {"id": self.device, "name": self.device}
self.link.snapshot.return_value = {"phase": "connected"}
self.exports = []
self.payload = None
for name, value in (("LINK", self.link), ("LOCAL", False)):
patch = mock.patch.object(server, name, value)
patch.start()
self.addCleanup(patch.stop)
patch = mock.patch.object(frame_clips, "VIDEOS_DIR", self.folder)
patch.start()
self.addCleanup(patch.stop)
for name, fn in (("_clips_frame", self.frame), ("fetch_file", self.fetch),
("ssh", lambda *a, **k: "")):
patch = mock.patch.object(server, name, side_effect=fn)
patch.start()
self.addCleanup(patch.stop)
(self.folder / "clip_620.mp4").write_bytes(b"legacy export")
def frame(self, args, timeout):
if args == ["list"]:
return {"clips": [{"id": cid, "kind": "clip", "time": NOW + 5, "idle": 60} for cid in IDS]}
self.exports.append((self.device, args[1]))
self.payload = f"{self.device}: {args[1]}".encode()
return {"path": f"{frame_clips.WORK}/export-0123456789abcdef/clip_620.mp4",
"size": len(self.payload)}
def fetch(self, remote, target):
target.write_bytes(self.payload)
def test_manual_export_lists_only_matching_identity_and_keeps_job_shape(self):
with mock.patch.object(server, "start_job", side_effect=lambda label, work: work()) as job:
for device in ("frame-a", "frame-b"):
self.device = device
self.assertFalse(any(c["saved"] for c in server.list_clips()["clips"]))
result = server.export_clip({"id": IDS[0]})
self.assertEqual(set(result), {"message", "file"})
self.assertEqual(Path(result["file"]).read_bytes(), f"{device}: {IDS[0]}".encode())
self.assertEqual([c["saved"] for c in server.list_clips()["clips"]], [True, False, False, False])
self.assertEqual(job.call_args.args[0], "Export clip_620.mp4")
first = frame_clips.clip_local_path(IDS[0], "frame-a", self.folder)
self.assertEqual(first.read_bytes(), f"frame-a: {IDS[0]}".encode())
self.assertEqual((self.folder / "clip_620.mp4").read_bytes(), b"legacy export")
def test_auto_sync_uses_same_identity_as_manual_exports(self):
with mock.patch.object(server, "start_job", side_effect=lambda label, work: work()):
server.export_clip({"id": IDS[0]})
sync = frame_autosync.AutoSync(
server._sync_list(server.list_shots), server._sync_shots,
server._sync_list(server.list_clips), server._sync_clip,
server._sync_device, lambda: False, shots_folder=self.folder / "shots",
clips_folder=self.folder, path=self.folder / "auto-sync.json", clock=lambda: NOW,
space=lambda folder: 100 * 1024**3)
sync.update({"clips": True})
sync.tick()
self.assertEqual(self.exports, [("frame-a", cid) for cid in IDS])
self.device = "frame-b"
sync.tick()
self.assertEqual(self.exports, [(device, cid) for device in ("frame-a", "frame-b") for cid in IDS])
sync.tick()
self.assertEqual(len(self.exports), 8)
for device in ("frame-a", "frame-b"):
for cid in IDS:
target = frame_clips.clip_local_path(cid, device, self.folder)
self.assertEqual(target.read_bytes(), f"{device}: {cid}".encode())
self.assertEqual((self.folder / "clip_620.mp4").read_bytes(), b"legacy export")
def test_delayed_job_refuses_a_different_headset(self):
with mock.patch.object(server, "start_job", return_value={"job": "queued"}) as job:
self.assertEqual(server.export_clip({"id": IDS[0]}), {"job": "queued"})
self.device = "frame-b"
with self.assertRaises(server.Failure) as error:
job.call_args.args[1]()
self.assertEqual(error.exception.status, 409)
self.assertEqual(self.exports, [])
def test_local_and_headset_modes_reject_steam_clip_exports(self):
for headset in (False, True):
with self.subTest(headset=headset), mock.patch.object(server, "LOCAL", True), \
mock.patch.object(server, "HEADSET_RUNTIME", headset):
for action in (server.list_clips, lambda: server.export_clip({"id": IDS[0]}),
lambda: server.auto_sync({"action": "now"})):
with self.assertRaises(server.Failure) as error:
action()
self.assertEqual(error.exception.status, 400)
self.assertEqual(self.exports, [])
if __name__ == "__main__":
unittest.main()
+65 -8
View File
@@ -71,6 +71,17 @@ class Ids(unittest.TestCase):
with self.subTest(bad=bad), self.assertRaises(ValueError):
clips.remove_command(bad)
def test_local_path_keeps_validated_identity_and_hashes_device_names(self):
folder = Path("exports")
target = clips.clip_local_path("123/clips/clip/video/session", "../../device:name", folder)
self.assertRegex(target.parts[1], r"^headset-[0-9a-f]{12}$")
self.assertEqual(target.parts[2:], ("123", "clips", "clip", "video", "session", "clip.mp4"))
for bad in (None, "", 42):
with self.subTest(device=bad), self.assertRaises(ValueError):
clips.clip_local_path("123/video/bg", bad, folder)
with self.assertRaises(ValueError):
clips.clip_local_path("../clips/clip", "frame-a", folder)
def test_recording_names_checked(self):
for fn in (clips.finish_command, clips.cleanup_command, lambda n: clips.record_command(n, True)):
for bad in ("rec-1", "rec-20261020-201950; rm -rf ~", "../rec-20261020-201950", "*"):
@@ -156,10 +167,10 @@ class Sessions(unittest.TestCase):
def test_listing_marks_what_is_already_here(self):
here = self.dir / "here"
write(here / "clip_620_20261020_202134.mp4")
write(clips.clip_local_path("1/clips/clip_620_20261020_202134/video/bg", "frame-a", here))
frame = lambda args, timeout: {"clips": [{"id": "1/clips/clip_620_20261020_202134/video/bg"},
{"id": "1/video/bg_620_20261020_201950"}]}
got = clips.list_clips(frame, folder=here)
got = clips.list_clips(frame, folder=here, device_id="frame-a")
self.assertEqual([c["saved"] for c in got["clips"]], [True, False])
self.assertEqual(got["folder"], str(here))
@@ -261,12 +272,16 @@ class ComputerSide(unittest.TestCase):
frame = lambda args, timeout: calls.append(("frame", args)) or {"path": path, "size": 3}
run = lambda cmd, timeout: calls.append(("run", cmd))
fetch = lambda remote, local: calls.append(("fetch", remote)) or Path(local).write_bytes(b"mp4")
got = clips.export_clip("1/clips/clip_620/video/bg", frame, run, fetch, folder=self.dir)
got = clips.export_clip("1/clips/clip_620/video/bg", frame, run, fetch, folder=self.dir, device_id="frame-a")
self.assertEqual(got["message"], "Saved clip_620.mp4 to ~/Videos/SteamFrame")
self.assertEqual((self.dir / "clip_620.mp4").read_bytes(), b"mp4")
target = clips.clip_local_path("1/clips/clip_620/video/bg", "frame-a", self.dir)
self.assertEqual(got["file"], str(target))
self.assertEqual(set(got), {"message", "file"})
self.assertEqual(target.read_bytes(), b"mp4")
self.assertEqual([c[0] for c in calls], ["frame", "fetch", "run"])
self.assertEqual(calls[2][1], clips.remove_command(path))
self.assertEqual([p.name for p in self.dir.iterdir()], ["clip_620.mp4"])
self.assertEqual(list(self.dir.rglob("*.mp4")), [target])
self.assertFalse(list(self.dir.glob(".incoming-*")))
def test_failed_copy_leaves_no_partial_file_and_still_cleans_up(self):
path = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
@@ -277,20 +292,62 @@ class ComputerSide(unittest.TestCase):
raise RuntimeError("connection lost")
with self.assertRaisesRegex(RuntimeError, "connection lost"):
clips.export_clip("1/clips/clip_620/video/bg", lambda a, t: {"path": path, "size": 4},
lambda cmd, timeout: ran.append(cmd), fetch, folder=self.dir)
lambda cmd, timeout: ran.append(cmd), fetch, folder=self.dir, device_id="frame-a")
self.assertEqual(list(self.dir.iterdir()), [])
self.assertEqual(ran, [clips.remove_command(path)])
def test_same_names_keep_each_account_headset_kind_and_nested_session(self):
first = "1/clips/clip_620/video/bg"
ids = [first, "2/clips/clip_620/video/bg", "1/clips/clip_620/video/other",
"1/clips/clip_620/other/bg", "1/video/clip_620"]
legacy = write(self.dir / "clip_620.mp4", b"legacy export")
remote = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
frame = lambda args, timeout: {"clips": [{"id": cid} for cid in ids]}
stored = {}
for device in ("frame-a", "frame-b"):
self.assertFalse(any(c["saved"] for c in clips.list_clips(
frame, folder=self.dir, device_id=device)["clips"]))
for index, cid in enumerate(ids):
payload = f"{device} session {index}".encode()
result = clips.export_clip(cid, lambda a, t: {"path": remote, "size": len(payload)},
lambda c, t: None, lambda r, p: p.write_bytes(payload),
folder=self.dir, device_id=device)
stored[Path(result["file"])] = payload
listing = clips.list_clips(frame, folder=self.dir, device_id=device)
self.assertEqual([c["saved"] for c in listing["clips"]],
[n <= index for n in range(len(ids))])
self.assertEqual(listing["folder"], str(self.dir))
self.assertEqual(len(stored), len(ids) * 2)
for path, payload in stored.items():
self.assertEqual(path.read_bytes(), payload)
self.assertEqual(legacy.read_bytes(), b"legacy export")
self.assertFalse(list(self.dir.glob(".incoming-*")))
def test_failed_reexport_preserves_completed_export(self):
cid = "1/clips/clip_620/video/bg"
target = write(clips.clip_local_path(cid, "frame-a", self.dir), b"completed")
remote = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
def fetch(remote, local):
local.write_bytes(b"partial")
raise RuntimeError("connection lost")
with self.assertRaisesRegex(RuntimeError, "connection lost"):
clips.export_clip(cid, lambda a, t: {"path": remote}, lambda c, t: None, fetch,
folder=self.dir, device_id="frame-a")
self.assertEqual(target.read_bytes(), b"completed")
self.assertFalse(list(self.dir.glob(".incoming-*")))
def test_bad_id_never_reaches_the_frame(self):
never = mock.Mock(side_effect=AssertionError("reached the Frame"))
with self.assertRaises(ValueError):
clips.export_clip("1/video/../../.ssh", never, never, never, folder=self.dir)
clips.export_clip("1/video/../../.ssh", never, never, never, folder=self.dir, device_id="frame-a")
def test_unexpected_path_from_the_frame_refused(self):
never = mock.Mock(side_effect=AssertionError("deleted or copied"))
with self.assertRaises(ValueError):
clips.export_clip("1/video/bg", lambda a, t: {"path": ".ssh/id_ed25519", "size": 1}, never, never,
folder=self.dir)
folder=self.dir, device_id="frame-a")
class Commands(unittest.TestCase):
+68 -1
View File
@@ -68,6 +68,9 @@ class ObsSourceTests(unittest.TestCase):
def terminate(self):
self.stopped.set()
def wait(self, timeout=None):
return 255
for ready in (False, True):
with self.subTest(ready=ready):
child = Child()
@@ -80,7 +83,71 @@ class ObsSourceTests(unittest.TestCase):
finally:
watchdog.close()
@unittest.skipIf(sys.platform == 'win32', 'the packaged Start/Stop control is Linux/macOS only')
def test_shutdown_kills_an_unresponsive_ssh_child(self):
proc = mock.Mock()
proc.poll.return_value = None
proc.wait.side_effect = [subprocess.TimeoutExpired('ssh', 5), 137]
obs.stop_child(proc)
proc.terminate.assert_called_once()
proc.kill.assert_called_once()
self.assertEqual(proc.wait.call_count, 2)
def test_windows_sender_ignores_absent_receiver_and_hides_ssh_console(self):
proc = mock.Mock()
proc.stdout = io.BytesIO(bytes(range(188)) * 7)
proc.poll.return_value = proc.wait.return_value = 0
# These are the actual Python Winsock control and subprocess constants.
with mock.patch.object(obs.sys, 'platform', 'win32'), \
mock.patch.object(obs.subprocess, 'CREATE_NO_WINDOW', 0x08000000, create=True), \
mock.patch.object(obs.socket, 'socket') as socket_factory, \
mock.patch.object(obs.subprocess, 'Popen', return_value=proc) as popen:
self.assertEqual(obs.main(['--frame', 'test-frame']), 0)
udp = socket_factory.return_value.__enter__.return_value
self.assertEqual(popen.call_args.kwargs['creationflags'], 0x08000000)
udp.sendto.assert_called_once()
def test_windows_recovers_from_an_unreachable_udp_receiver(self):
udp = mock.Mock()
udp.sendto.side_effect = [ConnectionResetError(10054, 'receiver closed'), 188]
sender = obs._Sender(udp, ('127.0.0.1', 15099))
with mock.patch.object(obs.sys, 'platform', 'win32'):
sender.send(b'G' * 188)
self.assertFalse(sender.ready)
sender.send(b'G' * 188)
self.assertTrue(sender.ready)
self.assertEqual(udp.sendto.call_count, 2)
@unittest.skipIf(sys.platform == 'win32', 'the fake SSH executable uses a POSIX shebang')
def test_stall_exits_cleanly_even_while_app_control_pipe_stays_open(self):
with tempfile.TemporaryDirectory() as folder:
directory = Path(folder)
fake = directory / 'ssh'
fake.write_text("#!/usr/bin/env python3\n"
"import signal,sys,time\n"
"signal.signal(signal.SIGTERM, signal.SIG_IGN)\n"
"sys.stdout.buffer.write(b'G'*1316);sys.stdout.buffer.flush()\n"
"while True: time.sleep(1)\n")
fake.chmod(0o755)
wrapper = ("import importlib.util; "
f"s=importlib.util.spec_from_file_location('obs', {str(SCRIPT)!r}); "
"o=importlib.util.module_from_spec(s);s.loader.exec_module(o); "
"o.STARTUP_TIMEOUT=o.STREAM_STALL=o.CHILD_STOP_TIMEOUT=0.15; "
"o.main(['--frame','fake-frame','--control-stdin'])")
env = {**os.environ, 'PATH': str(directory) + os.pathsep + os.environ.get('PATH', '')}
process = subprocess.Popen([sys.executable, '-c', wrapper], stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, env=env)
try:
# Keep stdin OPEN: real Electron does this until the helper exits.
self.assertEqual(process.wait(timeout=4), 1)
error = process.stderr.read().decode(errors='replace')
self.assertIn('video stopped', error)
self.assertNotIn('Fatal Python error', error)
finally:
if process.poll() is None:
process.kill(); process.wait(timeout=5)
process.stdin.close(); process.stderr.close()
@unittest.skipIf(sys.platform == 'win32', 'the fake SSH executable uses a POSIX shebang')
def test_relay_stops_its_ssh_child_on_app_signal_or_control_pipe(self):
for mode in ('signal', 'control-pipe'):
with self.subTest(mode=mode):
+56 -12
View File
@@ -16,9 +16,9 @@ saved Steam clips to ~/Videos/SteamFrame.
- When the headset can't be reached it waits longer each time, up to MAX_BACKOFF.
The server supplies how to reach the headset, so this can be tested without one:
list_shots() -> {"shots": [{"id", "time", "saved"}...]}
list_shots(device) -> {"shots": [{"id", "time", "saved"}...]}
save_shots(device, ids) copies those screenshots here
list_clips() -> {"clips": [{"id", "kind", "time", "idle", "saved"}...]}
list_clips(device) -> {"clips": [{"id", "kind", "time", "idle", "saved"}...]}
export_clip(device, id) exports one clip here
device() -> the connected headset's id, or None
busy() -> True while other work runs on the headset (installs, jobs, a recording)
@@ -41,7 +41,7 @@ SETTLE = 30 # a clip written to within this many seconds is still being recorde
MIN_FREE = 512 * 1024**2 # pause below this much free space on this computer
KEEP = 5000 # copied ids remembered per headset and kind
BATCH = 100 # screenshots per copy
TRIES = 3 # an item that fails this often is left until the app restarts
TRIES = 3 # failed attempts per item until Resume, Copy everything now, or restart
KINDS = ("shots", "clips")
@@ -86,6 +86,7 @@ class AutoSync:
self._running = False
self._backoff = 0
self._tries = {} # (device, id) -> failed attempts this run
self._failures = {} # last error for each failed item, including exhausted retries
self._errors = [] # this pass's, for the page
self._s = self._load()
@@ -143,13 +144,19 @@ class AutoSync:
"""Copy everything on the headset that isn't on this computer, once (also resumes)."""
with self._lock:
self._all, self._s["paused"] = True, None
self._tries.clear()
self._failures.clear()
self._s["error"] = None
self._save()
self._backoff = 0
self._wake.set()
return self.state()
def resume(self):
with self._lock:
self._s["paused"], self._s["error"] = None, None
self._tries.clear()
self._failures.clear()
self._save()
self._backoff = 0
self._wake.set()
@@ -215,6 +222,9 @@ class AutoSync:
def _mark(self, device, kind, ids):
with self._lock:
for item in ids:
self._tries.pop((device, item), None)
self._failures.pop((device, item), None)
per = self._s["synced"].setdefault(device, {})
have = per.get(kind, [])
new = [i for i in ids if i not in set(have)]
@@ -233,24 +243,46 @@ class AutoSync:
"then press Resume.")
if self.offline(error):
raise error
key = (device, item)
self._tries[key] = self._tries.get(key, 0) + 1
self._errors.append(f"Couldn't copy {item.rsplit('/', 1)[-1]}: {error}")
with self._lock:
key = (device, item)
self._tries[key] = self._tries.get(key, 0) + 1
message = f"Couldn't copy {item.rsplit('/', 1)[-1]}: {error}"
if self._tries[key] >= TRIES:
message += ". Automatic retries stopped. Press Resume to try again."
self._failures[key] = message
self._errors.append(message)
def _retry_allowed(self, device, item):
with self._lock:
key = (device, item)
if self._tries.get(key, 0) < TRIES:
return True
self._errors.append(self._failures[key])
return False
def _interrupted(self, device):
return self.busy() or self.device() != device
def _pass(self, device, shots, clips, since):
"""-> (copied, clips still being written, finished without stopping for other work)."""
copied = waiting = 0
if shots:
listing = self.list_shots()["shots"]
if self._interrupted(device):
return copied, waiting, False
listing = self.list_shots(device)["shots"]
if self._interrupted(device):
return copied, waiting, False
done = self._synced(device, "shots")
self._mark(device, "shots", [s["id"] for s in listing if s.get("saved")])
todo = [s["id"] for s in sorted(listing, key=lambda s: s["time"])
if not s.get("saved") and (since is None or (s["time"] >= since and s["id"] not in done))
and self._tries.get((device, s["id"]), 0) < TRIES]
and self._retry_allowed(device, s["id"])]
for i in range(0, len(todo), BATCH):
if self.busy():
if self._interrupted(device):
return copied, waiting, False
self._room(self.shots_folder)
if self._interrupted(device):
return copied, waiting, False
batch = todo[i:i + BATCH]
try:
self.save_shots(device, batch)
@@ -258,28 +290,40 @@ class AutoSync:
for item in batch:
self._failed(device, item, e)
continue
if self.device() != device:
return copied, waiting, False
self._mark(device, "shots", batch)
copied += len(batch)
if clips:
listing = [c for c in self.list_clips()["clips"] if c.get("kind") == "clip"]
if self._interrupted(device):
return copied, waiting, False
listing = [c for c in self.list_clips(device)["clips"] if c.get("kind") == "clip"]
if self._interrupted(device):
return copied, waiting, False
done = self._synced(device, "clips")
self._mark(device, "clips", [c["id"] for c in listing if c.get("saved")])
for c in sorted(listing, key=lambda c: c["time"]):
if c.get("saved") or self._tries.get((device, c["id"]), 0) >= TRIES:
if c.get("saved"):
continue
if since is not None and (c["time"] < since or c["id"] in done):
continue
if not self._retry_allowed(device, c["id"]):
continue
if c.get("idle") is None or c["idle"] < SETTLE:
waiting += 1 # still being written: copied on a later look
continue
if self.busy():
if self._interrupted(device):
return copied, waiting, False
self._room(self.clips_folder)
if self._interrupted(device):
return copied, waiting, False
try:
self.export_clip(device, c["id"])
except Exception as e:
self._failed(device, c["id"], e)
continue
if self.device() != device:
return copied, waiting, False
self._mark(device, "clips", [c["id"]])
copied += 1
return copied, waiting, True
+20 -7
View File
@@ -26,6 +26,7 @@ that stream. The folder names are as users and Steam's own files describe them;
listing doesn't depend on them: any folder under clips/ or video/ (up to four levels)
holding init-stream*.m4s counts as a session.
"""
import hashlib
import json
import os
import re
@@ -363,10 +364,20 @@ def parse_id(clip_id):
def export_name(clip_id):
"""The .mp4's name on the computer: the clip's (or background session's) own folder name."""
"""The .mp4's basename; it alone does not identify a saved export."""
return parse_id(clip_id)[2] + ".mp4"
def clip_local_path(clip_id, device_id, folder=None):
"""Keep the headset, account and full Steam session identity in the destination."""
parts = parse_id(clip_id)
if not isinstance(device_id, str) or not device_id:
raise ValueError("missing clip headset identity")
headset = hashlib.sha256(device_id.encode("utf-8")).hexdigest()[:12]
folder = Path(folder) if folder else VIDEOS_DIR
return folder / f"headset-{headset}" / Path(*parts) / export_name(clip_id)
def read_session(folder):
"""{stream number: [init, chunk, chunk...]} for one session folder, chunks in number
order. A stream without its init segment or without chunks can't be played, so it's left out."""
@@ -552,19 +563,20 @@ def remove_command(path):
# run_frame(args, timeout) runs this file on the Frame with `args` and returns its JSON;
# run(command, timeout) and fetch(remote, local) as for Recorder.
def list_clips(run_frame, folder=None):
def list_clips(run_frame, folder=None, *, device_id):
"""Steam's recordings on the Frame, each marked saved if its .mp4 is already here."""
folder = Path(folder) if folder else VIDEOS_DIR
clips = run_frame(["list"], 60)["clips"]
for c in clips:
c["saved"] = (folder / export_name(c["id"])).exists()
c["saved"] = clip_local_path(c["id"], device_id, folder).is_file()
return {"clips": clips, "folder": str(folder)}
def export_clip(clip_id, run_frame, run, fetch, folder=None):
def export_clip(clip_id, run_frame, run, fetch, folder=None, *, device_id):
"""Stitch a Steam recording on the Frame and copy the .mp4 here. -> {"message", "file"}."""
folder = Path(folder) if folder else VIDEOS_DIR
name = export_name(clip_id) # checks the id before anything reaches the Frame
target = clip_local_path(clip_id, device_id, folder) # validate before reaching the Frame
name = target.name
made = run_frame(["export", clip_id], 1900)
path = made.get("path")
remove = remove_command(path) # refuses a path that isn't one of our exports
@@ -573,7 +585,8 @@ def export_clip(clip_id, run_frame, run, fetch, folder=None):
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=folder))
try:
fetch(path, incoming / name)
os.replace(incoming / name, folder / name)
target.parent.mkdir(parents=True, exist_ok=True)
os.replace(incoming / name, target)
finally:
shutil.rmtree(incoming, ignore_errors=True)
finally:
@@ -581,7 +594,7 @@ def export_clip(clip_id, run_frame, run, fetch, folder=None):
run(remove, 30)
except Exception:
pass # the next export sweeps it
return {"message": f"Saved {name} to ~/Videos/SteamFrame", "file": str(folder / name)}
return {"message": f"Saved {name} to ~/Videos/SteamFrame", "file": str(target)}
# ---- on the Frame -----------------------------------------------------------------
+23 -6
View File
@@ -1243,6 +1243,15 @@
<div id="obsControl" class="hint" hidden>
<button class="small" id="obsBtn">Start OBS source</button>
<span id="obsStatus" role="status"></span>
<details style="margin-top:8px">
<summary>OBS setup</summary>
<p>Start the source here, then add a Media Source in OBS and clear <strong>Local File</strong>.</p>
<label>Local UDP port <input id="obsPort" type="number" min="1024" max="65535" step="1" value="15099" style="width:100px"></label>
<p>Input: <input id="obsUrl" readonly value="udp://127.0.0.1:15099" aria-label="OBS input URL" style="width:260px"> <button class="small" id="obsCopyUrl">Copy URL</button></p>
<p>Input Format: <code>mpegts</code> <button class="small" id="obsCopyFormat">Copy format</button></p>
<p>Use one receiver. If another program uses this port, choose a different port here and in OBS, then restart the source. Sending does not confirm OBS is receiving.</p>
<p>If OBS stays blank after Start or a restart, hide and show its Media Source, or reopen its properties and click OK. The stream includes output sound and may show your room through passthrough. If sound arrives before the picture, hide the Media Source, add a positive Sync Offset in OBS Advanced Audio Properties, then show it and check a test recording. OBS Virtual Camera carries video only.</p>
</details>
</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>
@@ -5610,7 +5619,7 @@ const syncWhen = t => new Date(t * 1000).toLocaleString([], { dateStyle: "medium
function renderAutoSync(s) {
autoSync.s = s;
$("syncShots").checked = s.screenshots; $("syncClips").checked = s.clips;
$("syncResume").hidden = !s.paused;
$("syncResume").hidden = !(s.paused || s.error);
const on = s.screenshots || s.clips, lines = [];
if (s.paused) lines.push(s.paused);
else if (s.error) lines.push(`Last problem: ${s.error}`);
@@ -5691,23 +5700,31 @@ if (window.frameApp?.obs) {
const obs = window.frameApp.obs;
const wrap = $("obsControl"), button = $("obsBtn"), status = $("obsStatus");
let state = { state: "idle" };
const port = $("obsPort"), input = $("obsUrl");
port.oninput = () => { input.value = `udp://127.0.0.1:${port.value}`; };
$("obsCopyUrl").onclick = e => act("Copy OBS URL", () => copyText(input.value), e.currentTarget);
$("obsCopyFormat").onclick = e => act("Copy OBS format", () => copyText("mpegts"), e.currentTarget);
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";
const active = next.state === "starting" || next.state === "running";
button.textContent = active ? "Stop OBS source" : "Start OBS source";
button.disabled = next.state === "stopping";
port.disabled = active || next.state === "stopping";
if (port.disabled && next.port) { port.value = next.port; port.oninput(); }
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 === "running" ? `Sending ${next.alias} view and sound to udp://127.0.0.1:${next.port || 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 stopping = state.state === "starting" || state.state === "running";
if (!stopping && !port.reportValidity()) return;
const result = await act(stopping ? "Stop OBS source" : "Start OBS source",
() => stopping ? obs.stop() : obs.start(), e.currentTarget);
() => stopping ? obs.stop() : obs.start(Number(port.value)), e.currentTarget);
if (result) showObs(result);
};
}
+10 -6
View File
@@ -928,7 +928,9 @@ def _clips_frame(args, timeout):
def list_clips():
_clips_here()
try:
return frame_clips.list_clips(_clips_frame)
device = agent_target()["id"]
with working(device):
return frame_clips.list_clips(_clips_frame, device_id=device)
except frame_clips.ClipError as e:
raise Failure(str(e), e.status)
@@ -938,11 +940,13 @@ def export_clip(body):
_clips_here()
clip_id = body.get("id")
name = frame_clips.export_name(clip_id) # ValueError, so 400, for a bad id
device = agent_target()["id"]
def work():
try:
return frame_clips.export_clip(clip_id, _clips_frame, lambda cmd, timeout: ssh(cmd, timeout=timeout),
fetch_file)
with working(device):
return frame_clips.export_clip(clip_id, _clips_frame, lambda cmd, timeout: ssh(cmd, timeout=timeout),
fetch_file, device_id=device)
except frame_clips.ClipError as e:
raise Failure(str(e), e.status)
return start_job(f"Export {name}", work)
@@ -965,12 +969,12 @@ def _sync_shots(device, ids):
def _sync_clip(device, clip_id):
with working(device):
return frame_clips.export_clip(clip_id, _clips_frame, lambda cmd, timeout: ssh(cmd, timeout=timeout),
fetch_file)
fetch_file, device_id=device)
def _sync_list(listing):
def run():
with working(_sync_device()):
def run(device):
with working(device):
return listing()
return run