mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
Live H.264 video of the headset view in Frame Control
Live in the headset view now streams video instead of polling stereo screenshots (~2 fps). SteamVR's steamvr-v4l2cam.service mirrors the headset view into /dev/video99; ffmpeg on the Frame encodes it with x264 (720p30 by default, AUD + repeated SPS/PPS), /api/stream relays the raw H.264 over SSH, and the page splits it on access unit delimiters and decodes it with WebCodecs into the existing viewer. Capture still takes a stereo still; the desktop panel keeps capture polling, and the page falls back to it if the video can't start. The remote ffmpeg runs under a shell that kills it when the SSH channel closes, stderr goes to a temp file, and a 10 s stall ends the stream. One stream at a time; a new one supersedes the last. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8b7c46a63a
commit
f324aac690
5 files changed
+226
-10
No files matched your search
@@ -105,10 +105,12 @@ the same UI in a browser without packaging:
|
|||||||
|
|
||||||
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
||||||
floating panels, dashboard and controllers). Shows the left eye, like pointing
|
floating panels, dashboard and controllers). Shows the left eye, like pointing
|
||||||
a camera into one lens, or both eyes; single shot or about 2 fps live; saves
|
a camera into one lens, or both eyes, as a single shot; saves as PNG. **Live**
|
||||||
as PNG. The viewer fits the whole frame; zoom with − / + (or scroll, or
|
is 720p video at about 30 fps: `ffmpeg` on the Frame encodes SteamVR's
|
||||||
double-click), drag to pan, `0` to fit, `F` for full screen. It uses OpenVR's `IVRScreenshots` API through Python `ctypes`
|
headset-view device (`/dev/video99`) to H.264 over SSH, and the page decodes
|
||||||
(`ui/frame_vrshot.py`), so nothing is installed on the Frame. **Desktop panel**
|
it with WebCodecs. Live video is one eye; Capture still gets both. The viewer fits the whole frame; zoom with − / + (or scroll, or
|
||||||
|
double-click), drag to pan, `0` to fit, `F` for full screen. Capture uses OpenVR's `IVRScreenshots` API through Python `ctypes`
|
||||||
|
(`ui/frame_vrshot.py`). Nothing extra is installed on the Frame (SteamOS ships `ffmpeg`). **Desktop panel**
|
||||||
captures gamescope's flat layer instead.
|
captures gamescope's flat layer instead.
|
||||||
- Battery with charging state: charge rate in watts, time to full or empty,
|
- Battery with charging state: charge rate in watts, time to full or empty,
|
||||||
charger type and wattage (for example USB-C PD 20 W), and battery temperature
|
charger type and wattage (for example USB-C PD 20 W), and battery temperature
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
|||||||
| Handy gamescope root properties on `:0`: `GAMESCOPE_FOCUSABLE_APPS`, `GAMESCOPE_FOCUSABLE_WINDOWS` (triples: window, app id, pid), `GAMESCOPE_FOCUSED_APP`. Read them with `DISPLAY=:0 xprop -root`. | Debugging panels |
|
| Handy gamescope root properties on `:0`: `GAMESCOPE_FOCUSABLE_APPS`, `GAMESCOPE_FOCUSABLE_WINDOWS` (triples: window, app id, pid), `GAMESCOPE_FOCUSED_APP`. Read them with `DISPLAY=:0 xprop -root`. | Debugging panels |
|
||||||
| `gamescopectl screenshot <file>` (with `WAYLAND_DISPLAY=gamescope-0`) captures gamescope's flat layer. | Frame Control's capture |
|
| `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` |
|
| 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`) |
|
||||||
| 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 |
|
| 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 |
|
| `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn |
|
||||||
| The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) |
|
| The SteamVR dashboard has docking: Float in World, Move, Size, Curvature, controller docking, Theater, Multitasking View. **Inferred** from `/opt/steamvr/resources/webinterface/dashboard/` and not yet driven by hand. | [panels.md](panels.md) |
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class ServerGuards(unittest.TestCase):
|
|||||||
self.assertEqual(self.request("GET", "/api/status")[0], 403)
|
self.assertEqual(self.request("GET", "/api/status")[0], 403)
|
||||||
self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403)
|
self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403)
|
||||||
self.assertEqual(self.request("GET", "/api/shots")[0], 403)
|
self.assertEqual(self.request("GET", "/api/shots")[0], 403)
|
||||||
|
self.assertEqual(self.request("GET", "/api/stream")[0], 403)
|
||||||
self.assertEqual(self.request("GET", "/api/shots/image?id=1/250820/20260925225208_1.jpg")[0], 403)
|
self.assertEqual(self.request("GET", "/api/shots/image?id=1/250820/20260925225208_1.jpg")[0], 403)
|
||||||
self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403)
|
self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403)
|
||||||
|
|
||||||
@@ -116,6 +117,11 @@ class ServerGuards(unittest.TestCase):
|
|||||||
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
|
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
|
||||||
self.assertEqual(status, 400, shot)
|
self.assertEqual(status, 400, shot)
|
||||||
|
|
||||||
|
def test_stream_settings_checked_before_ssh(self):
|
||||||
|
for query in ("h=480", "fps=24", "h=abc", "h=1080&fps=120"):
|
||||||
|
status, _, _ = self.request("GET", f"/api/stream?{query}", headers={"X-Frame-UI": "1"})
|
||||||
|
self.assertEqual(status, 400, query)
|
||||||
|
|
||||||
def test_bad_bodies(self):
|
def test_bad_bodies(self):
|
||||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||||
conn.request("POST", "/api/launch", body=b"{not json", headers={"X-Frame-UI": "1"})
|
conn.request("POST", "/api/launch", body=b"{not json", headers={"X-Frame-UI": "1"})
|
||||||
|
|||||||
+115
-4
@@ -321,7 +321,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<button class="action" id="shotBtn">Capture</button>
|
<button class="action" id="shotBtn">Capture</button>
|
||||||
<button id="liveBtn" title="Keep capturing">Live</button>
|
<button id="liveBtn" title="Keep updating: video of the headset view, or repeated captures of the desktop panel">Live</button>
|
||||||
<button id="saveBtn" disabled>Save</button>
|
<button id="saveBtn" disabled>Save</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="viewer" id="viewer">
|
<div class="viewer" id="viewer">
|
||||||
@@ -575,7 +575,7 @@ const QUICK = [["Remmina", "org.remmina.Remmina"], ["Moonlight", "com.moonlight_
|
|||||||
["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]];
|
["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]];
|
||||||
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
|
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
|
||||||
const HINTS = {
|
const HINTS = {
|
||||||
headset: "What the lenses show, composited by SteamVR: the room, floating panels, dashboard and controllers. Live refreshes about twice a second. Captures show everything on screen, including anything private.",
|
headset: "What the lenses show, composited by SteamVR: the room, floating panels, dashboard and controllers. Live streams it as video (one eye, about 30 fps); Capture takes a still of both eyes. Captures show everything on screen, including anything private.",
|
||||||
flat: "gamescope's 2D layer: the desktop panel and Steam's flat UI, without the room or VR scene.",
|
flat: "gamescope's 2D layer: the desktop panel and Steam's flat UI, without the room or VR scene.",
|
||||||
};
|
};
|
||||||
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", shot: "Screenshot" };
|
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", shot: "Screenshot" };
|
||||||
@@ -751,10 +751,12 @@ function render(s) {
|
|||||||
|
|
||||||
// ---- view ----
|
// ---- view ----
|
||||||
function setView(v) {
|
function setView(v) {
|
||||||
|
const changed = v !== view;
|
||||||
view = v;
|
view = v;
|
||||||
document.querySelectorAll("[data-view]").forEach(b => b.classList.toggle("on", b.dataset.view === v));
|
document.querySelectorAll("[data-view]").forEach(b => b.classList.toggle("on", b.dataset.view === v));
|
||||||
$("eyeSeg").style.visibility = v === "headset" ? "visible" : "hidden";
|
$("eyeSeg").style.visibility = v === "headset" && !video.ctl ? "visible" : "hidden";
|
||||||
$("viewHint").textContent = HINTS[v];
|
$("viewHint").textContent = HINTS[v];
|
||||||
|
if (live && changed) { toggleLive(false); toggleLive(true); } // video for the headset, captures for the panel
|
||||||
}
|
}
|
||||||
function setEye(e) {
|
function setEye(e) {
|
||||||
eye = e;
|
eye = e;
|
||||||
@@ -841,6 +843,7 @@ function isBlank(ctx, w, h) {
|
|||||||
return max - min < 6;
|
return max - min < 6;
|
||||||
}
|
}
|
||||||
async function capture() {
|
async function capture() {
|
||||||
|
if (video.ctl) toggleLive(false); // Capture during live video takes a still of both eyes instead
|
||||||
const gen = viewGen; // a screenshot opened meanwhile wins over this capture
|
const gen = viewGen; // a screenshot opened meanwhile wins over this capture
|
||||||
if (!live) $("viewer").classList.add("busy"); // no spinner flashing over a live stream
|
if (!live) $("viewer").classList.add("busy"); // no spinner flashing over a live stream
|
||||||
let url = null;
|
let url = null;
|
||||||
@@ -883,7 +886,115 @@ function toggleLive(on) {
|
|||||||
liveFailures = 0;
|
liveFailures = 0;
|
||||||
$("liveBtn").classList.toggle("on", live);
|
$("liveBtn").classList.toggle("on", live);
|
||||||
$("liveBadge").hidden = !live;
|
$("liveBadge").hidden = !live;
|
||||||
if (live) liveLoop(); else clearTimeout(liveTimer);
|
clearTimeout(liveTimer);
|
||||||
|
stopVideo();
|
||||||
|
if (!live) return;
|
||||||
|
if (view === "headset" && "VideoDecoder" in window) {
|
||||||
|
const started = startVideo();
|
||||||
|
const ctl = video.ctl;
|
||||||
|
started.catch(e => {
|
||||||
|
if (!live || ctl !== video.ctl || e.name === "AbortError") return;
|
||||||
|
log("Live video failed: " + e.message, "e");
|
||||||
|
toast("Live video failed, using captures instead: " + e.message, true);
|
||||||
|
stopVideo();
|
||||||
|
liveLoop();
|
||||||
|
});
|
||||||
|
} else liveLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- live video of the headset view ----
|
||||||
|
// The server relays raw H.264 (Annex B) from SteamVR's headset-view device.
|
||||||
|
// Every frame starts with an access unit delimiter (NAL type 9), which is how
|
||||||
|
// the stream is cut into frames for WebCodecs.
|
||||||
|
const STREAM_QUERY = "h=720&fps=30", STREAM_FPS = 30;
|
||||||
|
const video = { ctl: null, dec: null, gen: 0 };
|
||||||
|
function startCode(b, i) { return b[i] === 0 && b[i + 1] === 0 && b[i + 2] === 1; }
|
||||||
|
function nalTypes(au) {
|
||||||
|
const types = [];
|
||||||
|
for (let i = 0; i + 3 < au.length; i++) if (startCode(au, i)) { types.push(au[i + 3] & 0x1f); i += 3; }
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
async function startVideo() {
|
||||||
|
const gen = ++video.gen, ctl = new AbortController();
|
||||||
|
video.ctl = ctl;
|
||||||
|
$("eyeSeg").style.visibility = "hidden";
|
||||||
|
if (!lastImg) $("viewer").classList.add("busy");
|
||||||
|
let frames = 0, shown = 0, second = performance.now(), needKey = true, ts = 0;
|
||||||
|
const c = $("canvas"), ctx = c.getContext("2d");
|
||||||
|
const dec = video.dec = new VideoDecoder({
|
||||||
|
output: frame => {
|
||||||
|
if (gen !== video.gen) return frame.close();
|
||||||
|
if (c.width !== frame.displayWidth || c.height !== frame.displayHeight) {
|
||||||
|
c.width = frame.displayWidth; c.height = frame.displayHeight;
|
||||||
|
$("viewer").style.aspectRatio = `${c.width} / ${c.height}`;
|
||||||
|
setZoom(1);
|
||||||
|
}
|
||||||
|
ctx.drawImage(frame, 0, 0);
|
||||||
|
frame.close();
|
||||||
|
if (!shown++) {
|
||||||
|
viewGen++; // a capture still in flight mustn't replace the video
|
||||||
|
lastImg = null; lastSource = "video"; lastShot = null;
|
||||||
|
$("viewer").classList.remove("busy");
|
||||||
|
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false; $("asleep").hidden = true;
|
||||||
|
$("srcBadge").hidden = false; $("srcBadge").textContent = "Headset view · video";
|
||||||
|
$("stamp").hidden = false; $("saveBtn").disabled = false;
|
||||||
|
}
|
||||||
|
frames++;
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - second >= 1000) {
|
||||||
|
$("stamp").textContent = `${Math.round(frames * 1000 / (now - second))} fps`;
|
||||||
|
frames = 0; second = now;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: e => log("Video decoder: " + e.message, "e"),
|
||||||
|
});
|
||||||
|
const r = await fetch(`/api/stream?${STREAM_QUERY}`, { headers: {"X-Frame-UI": "1"}, signal: ctl.signal });
|
||||||
|
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||||
|
const reader = r.body.getReader();
|
||||||
|
let buf = new Uint8Array(0), scan = 0, auStart = -1;
|
||||||
|
const onAU = au => {
|
||||||
|
const types = nalTypes(au), key = types.includes(5);
|
||||||
|
if (dec.state === "unconfigured") {
|
||||||
|
const sps = types.indexOf(7);
|
||||||
|
if (sps < 0) return;
|
||||||
|
let i = 0, n = -1; // find the SPS bytes: profile, constraints, level follow its header
|
||||||
|
for (; i + 3 < au.length; i++) if (startCode(au, i) && ++n === sps) break;
|
||||||
|
if (i + 6 >= au.length) return;
|
||||||
|
const hex = [au[i + 4], au[i + 5], au[i + 6]].map(b => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
dec.configure({ codec: `avc1.${hex}`, optimizeForLatency: true });
|
||||||
|
}
|
||||||
|
// If decoding falls behind, drop frames until the next keyframe rather than lag.
|
||||||
|
if (dec.decodeQueueSize > 3) needKey = true;
|
||||||
|
if (needKey && !key) return;
|
||||||
|
needKey = false;
|
||||||
|
dec.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp: ts, data: au }));
|
||||||
|
ts += 1e6 / STREAM_FPS;
|
||||||
|
};
|
||||||
|
for (;;) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done || gen !== video.gen) break;
|
||||||
|
const next = new Uint8Array(buf.length + value.length);
|
||||||
|
next.set(buf); next.set(value, buf.length);
|
||||||
|
buf = next;
|
||||||
|
if (buf.length > 8 << 20) throw new Error("the stream isn't split into frames");
|
||||||
|
for (; scan + 3 < buf.length; scan++) {
|
||||||
|
if (!startCode(buf, scan) || (buf[scan + 3] & 0x1f) !== 9) continue;
|
||||||
|
if (auStart >= 0) onAU(buf.subarray(auStart, scan));
|
||||||
|
auStart = scan;
|
||||||
|
scan += 3;
|
||||||
|
}
|
||||||
|
if (auStart > 0) { buf = buf.slice(auStart); scan -= auStart; auStart = 0; }
|
||||||
|
}
|
||||||
|
if (gen === video.gen && live) throw new Error(shown ? "the stream ended" : "no video arrived");
|
||||||
|
}
|
||||||
|
function stopVideo() {
|
||||||
|
video.gen++;
|
||||||
|
if (video.ctl) video.ctl.abort();
|
||||||
|
if (video.dec && video.dec.state !== "closed") video.dec.close();
|
||||||
|
video.ctl = video.dec = null;
|
||||||
|
$("viewer").classList.remove("busy");
|
||||||
|
if (lastSource === "video") { $("srcBadge").textContent = "Headset view · video (stopped)"; $("stamp").hidden = true; }
|
||||||
|
$("eyeSeg").style.visibility = view === "headset" ? "visible" : "hidden";
|
||||||
}
|
}
|
||||||
$("shotBtn").onclick = () => capture();
|
$("shotBtn").onclick = () => capture();
|
||||||
$("liveBtn").onclick = () => toggleLive(!live);
|
$("liveBtn").onclick = () => toggleLive(!live);
|
||||||
|
|||||||
+98
-2
@@ -12,6 +12,7 @@ import http.client
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import select
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
@@ -272,6 +273,41 @@ def save_shots(body):
|
|||||||
return {"message": msg + (f" ({skipped} already there)" if skipped else ""), "saved": n}
|
return {"message": msg + (f" ({skipped} already there)" if skipped else ""), "saved": n}
|
||||||
|
|
||||||
|
|
||||||
|
# Live video of the headset view. SteamVR's steamvr-v4l2cam.service copies the
|
||||||
|
# headset view (one undistorted 1920x1080 image) into the v4l2loopback device
|
||||||
|
# /dev/video99. ffmpeg encodes it with x264 (the hardware encoder crashes
|
||||||
|
# ffmpeg) and the raw H.264 comes back over SSH for the page to decode with
|
||||||
|
# WebCodecs. An access unit delimiter starts every frame so the page can split
|
||||||
|
# the stream, and repeated SPS/PPS let it start at any keyframe. ffmpeg runs in
|
||||||
|
# the background while the shell waits for our stdin to close: when the local
|
||||||
|
# ssh goes, the channel closes and the shell kills ffmpeg, even one that has
|
||||||
|
# stopped writing (and so would never get SIGPIPE).
|
||||||
|
STREAM_DEVICE = "/dev/video99"
|
||||||
|
STREAM_HEIGHTS = (720, 1080)
|
||||||
|
STREAM_FPS = (30, 60)
|
||||||
|
STREAM_STALL = 10 # seconds without video before the stream is dropped
|
||||||
|
_stream_lock = threading.Lock()
|
||||||
|
_stream_proc = None
|
||||||
|
|
||||||
|
|
||||||
|
def stream_command(query):
|
||||||
|
q = parse_qs(query)
|
||||||
|
try:
|
||||||
|
height = int((q.get("h") or ["720"])[0])
|
||||||
|
fps = int((q.get("fps") or ["30"])[0])
|
||||||
|
except ValueError:
|
||||||
|
raise Failure("h and fps must be integers", 400)
|
||||||
|
if height not in STREAM_HEIGHTS or fps not in STREAM_FPS:
|
||||||
|
raise Failure(f"h must be one of {STREAM_HEIGHTS} and fps one of {STREAM_FPS}", 400)
|
||||||
|
rate = 3 if height == 720 else 6 # Mbit/s
|
||||||
|
return (f"[ -e {STREAM_DEVICE} ] || {{ echo 'No headset view device ({STREAM_DEVICE}). Is SteamVR running?' >&2; exit 3; }}; "
|
||||||
|
f"ffmpeg -hide_banner -loglevel error -nostdin -f v4l2 -video_size 1920x1080 -i {STREAM_DEVICE} "
|
||||||
|
f"-vf fps={fps},scale=-2:{height},format=yuv420p -c:v libx264 -preset ultrafast -tune zerolatency "
|
||||||
|
f"-g {fps * 2} -bf 0 -b:v {rate}M -maxrate {rate}M -bufsize {rate // 2 or 1}M "
|
||||||
|
f"-x264-params aud=1:repeat-headers=1 -f h264 - & p=$!; "
|
||||||
|
f"exec >&-; cat >/dev/null; kill $p 2>/dev/null; wait $p")
|
||||||
|
|
||||||
|
|
||||||
def launch(body):
|
def launch(body):
|
||||||
appid = str(body.get("appid", ""))
|
appid = str(body.get("appid", ""))
|
||||||
if not APPID.match(appid):
|
if not APPID.match(appid):
|
||||||
@@ -423,7 +459,7 @@ FONT_RANGE = (0.5, 2.0)
|
|||||||
KNOWN_LABELS = {"com.t3tools.t3code": "T3 Code", "org.fdroid.fdroid": "F-Droid"}
|
KNOWN_LABELS = {"com.t3tools.t3code": "T3 Code", "org.fdroid.fdroid": "F-Droid"}
|
||||||
# One ADB session at a time: requests are rare, and it keeps adb's state simple.
|
# One ADB session at a time: requests are rare, and it keeps adb's state simple.
|
||||||
_adb_lock = threading.Lock()
|
_adb_lock = threading.Lock()
|
||||||
_live_tunnels = set() # ssh processes to kill if the server stops mid-request
|
_live_tunnels = set() # ssh processes (ADB forwards, live video) to kill if the server stops mid-request
|
||||||
|
|
||||||
|
|
||||||
def adb_path():
|
def adb_path():
|
||||||
@@ -770,6 +806,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json(list_shots())
|
self.send_json(list_shots())
|
||||||
elif path == "/api/shots/image":
|
elif path == "/api/shots/image":
|
||||||
self.send_bytes(*shot_image(url.query))
|
self.send_bytes(*shot_image(url.query))
|
||||||
|
elif path == "/api/stream":
|
||||||
|
self.stream_video(url.query)
|
||||||
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["headset"]:
|
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["headset"]:
|
||||||
self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")])
|
self.send_bytes(headset_view(), "image/png", headers=[("X-Capture-Source", "steamvr")])
|
||||||
elif path == "/api/screenshot":
|
elif path == "/api/screenshot":
|
||||||
@@ -808,6 +846,64 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||||
|
|
||||||
|
def stream_video(self, query):
|
||||||
|
"""Raw H.264 of the headset view until the page disconnects (see stream_command)."""
|
||||||
|
global _stream_proc
|
||||||
|
remote = stream_command(query)
|
||||||
|
ensure_master()
|
||||||
|
# stderr goes to a file: nothing reads it while streaming, and a full
|
||||||
|
# pipe would stall ffmpeg. It's only read if the stream fails to start.
|
||||||
|
errors = tempfile.TemporaryFile()
|
||||||
|
proc = subprocess.Popen([*SSH, FRAME, remote], stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE, stderr=errors)
|
||||||
|
try:
|
||||||
|
_live_tunnels.add(proc)
|
||||||
|
# One viewer at a time: a new stream (another tab, a reload) ends the last one.
|
||||||
|
with _stream_lock:
|
||||||
|
old, _stream_proc = _stream_proc, proc
|
||||||
|
if old and old.poll() is None:
|
||||||
|
old.terminate()
|
||||||
|
fd = proc.stdout.fileno()
|
||||||
|
# Nothing is sent until the first bytes arrive, so a failure to
|
||||||
|
# start still comes back as a JSON error.
|
||||||
|
ready, _, _ = select.select([fd], [], [], 20)
|
||||||
|
first = os.read(fd, 1 << 16) if ready else b""
|
||||||
|
if not first:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait()
|
||||||
|
errors.seek(0)
|
||||||
|
err = strip_ansi(errors.read().decode(errors="replace")).strip()
|
||||||
|
raise Failure(err or "The headset view sent no video for 20 s")
|
||||||
|
chunk = first
|
||||||
|
try:
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "video/h264")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Frame-Options", "DENY")
|
||||||
|
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
|
||||||
|
self.end_headers()
|
||||||
|
self.close_connection = True # the body ends when the connection does
|
||||||
|
while chunk:
|
||||||
|
self.wfile.write(chunk)
|
||||||
|
self.wfile.flush()
|
||||||
|
# A stalled headset view ends the stream rather than
|
||||||
|
# holding this thread (and the page) forever.
|
||||||
|
ready, _, _ = select.select([fd], [], [], STREAM_STALL)
|
||||||
|
chunk = os.read(fd, 1 << 16) if ready else b""
|
||||||
|
except OSError:
|
||||||
|
pass # the page stopped watching (or stopped reading); the body has started, so no JSON
|
||||||
|
finally:
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait()
|
||||||
|
for f in (proc.stdin, proc.stdout, errors):
|
||||||
|
f.close()
|
||||||
|
_live_tunnels.discard(proc)
|
||||||
|
|
||||||
def upload(self):
|
def upload(self):
|
||||||
"""Raw file body. X-Filename names it; X-Mode is 'push', 'apk' (install) or 'apkinfo' (read only)."""
|
"""Raw file body. X-Filename names it; X-Mode is 'push', 'apk' (install) or 'apkinfo' (read only)."""
|
||||||
name = os.path.basename(unquote(self.headers.get("X-Filename", "")))
|
name = os.path.basename(unquote(self.headers.get("X-Filename", "")))
|
||||||
@@ -871,7 +967,7 @@ def main():
|
|||||||
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True)
|
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True)
|
||||||
if _master and _master.poll() is None:
|
if _master and _master.poll() is None:
|
||||||
_master.terminate()
|
_master.terminate()
|
||||||
for proc in list(_live_tunnels): # ADB forwards of requests cut off mid-way
|
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
|
||||||
if proc.poll() is None:
|
if proc.poll() is None:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user