mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 11:01:54 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26206e85b4 | ||
|
|
f4efefa958 | ||
|
|
e179616ad0 | ||
|
|
8f245cede7 | ||
|
|
359407bd06 | ||
|
|
3dd8e982a3 |
No files matched your search
@@ -69,8 +69,8 @@ Drag files onto the window to send them. Drop a game's .zip, folder or .exe to a
|
||||
</td>
|
||||
<td valign="top">
|
||||
|
||||
**📸 Screenshots**<br>
|
||||
Browse the shots you take in the headset and save them to your Pictures folder.
|
||||
**📸 Screenshots and recordings**<br>
|
||||
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record what the headset shows, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder ([not tested on a headset yet](docs/recording.md)).
|
||||
|
||||
**⌨️ Type on the headset**<br>
|
||||
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Copy text to the headset's clipboard and back again. Nothing else to install, anywhere. Press T to open it.
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Battery charge limit (80%): research
|
||||
|
||||
**Question:** can Frame Control set an 80% battery charge limit on the Frame
|
||||
over SSH or adb, safely?
|
||||
|
||||
**Verdict: don't build it yet; it needs a headset check.** Valve's supported
|
||||
mechanism (the one Steam's own charge-limit setting uses on the Deck) is not
|
||||
configured for the Frame. The only control the Frame seems to have is a
|
||||
vendor charger attribute that Valve's own charging daemon also manages.
|
||||
Research as of 2026-10-23. **Not tested on a headset.**
|
||||
|
||||
## How Steam does it on other devices
|
||||
|
||||
- Steam's **Settings → Power → Battery Charge Limit** shipped to the Deck in
|
||||
SteamOS 3.7.8 (beta client 2025-05-08)
|
||||
([GamingOnLinux](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)).
|
||||
- Steam calls SteamOS Manager on the session bus:
|
||||
`com.steampowered.SteamOSManager1.BatteryChargeLimit1`, with properties
|
||||
`MaxChargeLevel` (`i`, read/write; `-1` resets to the default) and
|
||||
`SuggestedMinimumLimit` (`i`, read-only). The CLI is
|
||||
`steamosctl get-max-charge-level` / `set-max-charge-level` /
|
||||
`suggested-minimum-charge-limit`. Source: `data/interfaces/com.steampowered.SteamOSManager1.xml`
|
||||
and `steamos-manager/src/bin/steamosctl.rs` in
|
||||
[holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager),
|
||||
read at commit `83981ab` (2026-10-21).
|
||||
- The root daemon writes the value to sysfs only if the device's TOML in
|
||||
`data/devices/` has a `[battery_charge_limit]` section. Two methods exist
|
||||
(`steamos-manager/src/power.rs`, `BatteryChargeLimitMethod`):
|
||||
- `hwmon_attribute`: the Deck writes `max_battery_charge_level` on
|
||||
`steamdeck_hwmon` (`data/devices/steam-deck.toml`).
|
||||
- `acpi_sb`: ROG Ally, MSI Claw and others write the standard
|
||||
`charge_control_end_threshold` on the ACPI battery
|
||||
(`/sys/bus/{platform,acpi}/drivers/…battery…/PNP0C0A:00/…/power_supply/*/`).
|
||||
On ARM there's no ACPI, so this path can't match there either.
|
||||
- With no `[battery_charge_limit]` section, the interface fails with
|
||||
`UnknownInterface` (Steam can still show its toggle on unsupported hardware:
|
||||
[steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044)).
|
||||
|
||||
## What the Frame has
|
||||
|
||||
- **Upstream `data/devices/steam-frame.toml`** (matched by
|
||||
`dt.compatible = "qcom,sm8650"`, variant `Deckard`) configures GPU clocks
|
||||
and performance profiles only. It has **no `[battery_charge_limit]`**, so
|
||||
SteamOS Manager on the Frame has no charge-limit backend. Its git history
|
||||
(2026-09-10 to 2026-10-12) never added one.
|
||||
- **Battery driver:** the Frame's battery is
|
||||
`/sys/class/power_supply/max1720x_bat_7-36`, and the charger is a
|
||||
`tcpm-source-psy-…` USB-PD supply ([how-the-frame-works.md](how-the-frame-works.md)).
|
||||
Both were verified 2026-09-25, and `ui/frame_status.py` `battery()` and
|
||||
`power_source()` read them. The Maxim MAX1720x is a **fuel gauge** and
|
||||
can't control charging. Mainline `drivers/power/supply/max1720x_battery.c`
|
||||
exposes only read-only properties (capacity, voltage, current, temp,
|
||||
charge_full…), with no `charge_control_end_threshold`. Valve's build is
|
||||
patched: it reports `time_to_full_now`, which mainline doesn't have. Even
|
||||
so, a charge stop would have to come from the separate charger IC, not this
|
||||
node.
|
||||
- **Not the qcom_battmgr path.** Some other SM8650/SM8750 handhelds get
|
||||
`charge_control_end_threshold` from Qualcomm's `qcom_battmgr`
|
||||
(pmic_glink) driver, clamped at 55% or above. A third-party distro enabled
|
||||
Steam's limit on those by patching steamos-manager
|
||||
([armada-os/armada#758](https://github.com/armada-os/armada/pull/758)). The
|
||||
Frame's battery isn't exposed by `qcom_battmgr`, so that doesn't carry over.
|
||||
(Unverified: whether a `qcom_battmgr` node also exists on the Frame. The
|
||||
read-only checks below answer this.)
|
||||
- **What third parties found:**
|
||||
[y0usaf/frame-charge](https://github.com/y0usaf/frame-charge) reports that
|
||||
the Frame has no `charge_control_end_threshold` and falls back to a vendor
|
||||
`charging_pause` attribute. It also reports that `constant_charge_current`
|
||||
changes on its own, which it attributes to Valve's `deckard_charge_daemon`
|
||||
(`deckard-charger.service`). By its own README, that tool has only run its
|
||||
read-only `status` on a Frame and has **not** been tested against real
|
||||
charging. Two questions are still open: whether pausing stops the current,
|
||||
and whether Valve's daemon undoes it.
|
||||
|
||||
## Why not build it now
|
||||
|
||||
- **No supported endpoint.** The only Valve-supported interface,
|
||||
`BatteryChargeLimit1`, isn't backed on the Frame. Writing sysfs ourselves
|
||||
would bypass Steam and SteamOS Manager, and it needs root (`sudo -S` with
|
||||
the user's password).
|
||||
- **A rival owner.** Valve's charge daemon already adjusts charging
|
||||
parameters. Toggling `charging_pause` underneath it could be undone without
|
||||
warning, fight it in a loop, or leave charging paused.
|
||||
- **Stuck at a low charge.** If the app or the Frame dies while charging is
|
||||
paused, the Frame could sit at a low charge on the charger. Whether a
|
||||
reboot clears `charging_pause` is unknown.
|
||||
- **The UI would mislead.** Steam's setting (if it shows at all) wouldn't
|
||||
reflect our limit.
|
||||
- **adb doesn't help.** Wi-Fi adb (`frame:5555`) reaches the Lepton Android
|
||||
container only. USB-C adb reaches the Linux side as `steamos` (non-root, the
|
||||
same as SSH, [ssh.md](ssh.md)). Neither adds a charging-control route.
|
||||
Android's `dumpsys battery` in Lepton is a container view, not the hardware.
|
||||
|
||||
**Revisit when** a SteamOS update adds `[battery_charge_limit]` to
|
||||
`steam-frame.toml` (or `steamosctl get-max-charge-level` stops returning
|
||||
`UnknownInterface` on the Frame). Then the feature is just a SteamOS Manager
|
||||
call:
|
||||
|
||||
- Read: `steamosctl get-max-charge-level`
|
||||
- Set: `steamosctl set-max-charge-level 80`
|
||||
- Undo: `steamosctl set-max-charge-level -- -1`
|
||||
|
||||
That runs as `steamos` with no sudo. It's the same value Steam's own
|
||||
Settings → Power shows, so both stay in sync.
|
||||
|
||||
## Headset checks (read-only first)
|
||||
|
||||
Run these over SSH as `steamos`. Steps 1–4 only read.
|
||||
|
||||
```sh
|
||||
# 1. Does SteamOS Manager offer the interface on this build?
|
||||
steamosctl get-max-charge-level; steamosctl suggested-minimum-charge-limit
|
||||
busctl --user introspect com.steampowered.SteamOSManager1 \
|
||||
/com/steampowered/SteamOSManager1 | grep -i -A3 ChargeLimit
|
||||
grep -n battery_charge_limit /usr/share/steamos-manager/devices/*frame* 2>/dev/null
|
||||
|
||||
# 2. Which power supplies exist, and which drivers back them?
|
||||
for d in /sys/class/power_supply/*; do
|
||||
echo "== $d ($(cat $d/type)) driver=$(basename "$(readlink -f $d/device/driver)" 2>/dev/null)"
|
||||
ls $d
|
||||
done
|
||||
|
||||
# 3. Any charge-control attributes, with their values and permissions?
|
||||
ls -l /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
|
||||
grep -H . /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
|
||||
|
||||
# 4. What is Valve's charger daemon, and what does it touch?
|
||||
systemctl status deckard-charger.service --no-pager
|
||||
systemctl cat deckard-charger.service
|
||||
journalctl -b -u deckard-charger.service --no-pager | tail -50
|
||||
```
|
||||
|
||||
If step 1 succeeds on some build, test `steamosctl set-max-charge-level 80`
|
||||
on a charger at a charge above 80%. Watch `current_now` on the
|
||||
`max1720x_bat_*` node fall to about 0, then `steamosctl set-max-charge-level -- -1` and
|
||||
confirm charging resumes. Do this on a supervised Frame only. If only step 3
|
||||
finds a vendor attribute, **stop**: don't write it, and leave the question
|
||||
with Valve.
|
||||
|
||||
## Sources
|
||||
|
||||
- steamos-manager source, commit `83981ab` (2026-10-21):
|
||||
`data/devices/steam-frame.toml`, `data/devices/steam-deck.toml`,
|
||||
`data/interfaces/com.steampowered.SteamOSManager1.xml`,
|
||||
`steamos-manager/src/power.rs`, `steamos-manager/src/manager/user.rs`.
|
||||
[gitlab.steamos.cloud/holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager)
|
||||
- Mainline [`max1720x_battery.c`](https://github.com/torvalds/linux/blob/master/drivers/power/supply/max1720x_battery.c)
|
||||
- [ValveSoftware/steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044):
|
||||
the Steam toggle shows on unsupported hardware
|
||||
- [armada-os/armada#758](https://github.com/armada-os/armada/pull/758):
|
||||
`qcom_battmgr` threshold on other SM8650/SM8750 devices
|
||||
- [y0usaf/frame-charge](https://github.com/y0usaf/frame-charge): third-party,
|
||||
untested on real charging
|
||||
- [GamingOnLinux: SteamOS 3.7.7 beta charge limit](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)
|
||||
- Repo: [how-the-frame-works.md](how-the-frame-works.md) (battery and
|
||||
charger nodes), [ssh.md](ssh.md) (adb scope), `ui/frame_status.py`
|
||||
@@ -15,6 +15,10 @@ depends on SSH working through the `frame` alias from `scripts/connect.sh`.
|
||||
| KDE Connect | KDE Connect on both | Guess | There's a macOS build of KDE Connect, but whether it's present or installable on the Frame wasn't confirmed. It would give you clipboard sync, file send, and remote input. Worth checking on-device. |
|
||||
| microSD | Physical card | Confirmed that the slot exists ([Wikipedia](https://en.wikipedia.org/wiki/Steam_Frame)) | Offline fallback. |
|
||||
|
||||
Screenshots and Steam clips have their own buttons on **Tools → Capture &
|
||||
Live**, and **Settings → Copy to this computer** copies new ones here
|
||||
automatically: see [recording.md](recording.md#copying-new-ones-automatically).
|
||||
|
||||
## Clipboard
|
||||
|
||||
`scripts/paste-to-frame.sh` sends the Mac clipboard (or stdin) to the
|
||||
|
||||
@@ -49,6 +49,10 @@ counts them while they run.
|
||||
gamescope's flat layer instead.
|
||||
- **Screenshots** you take in the headset with Steam's shortcut: browse them and
|
||||
save them to `~/Pictures/SteamFrame`.
|
||||
- **Record** what the headset shows, with its sound, to an `.mp4` in
|
||||
`~/Videos/SteamFrame`, and **Steam clips**: export clips and background
|
||||
recordings from Steam's game recording at any length, without re-encoding.
|
||||
See [recording.md](recording.md). Not tested on a headset yet.
|
||||
- **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.
|
||||
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
|
||||
|
||||
@@ -143,6 +143,15 @@ check in the headset:
|
||||
- Keys from SteamVR's on-screen keyboard.
|
||||
- Whether Flathub Chromium decodes H.264 (otherwise use Compatible).
|
||||
|
||||
## Battery charge limit (2026-10-23)
|
||||
|
||||
Can an 80% charge limit be set over SSH or adb? **Not safely yet.** SteamOS
|
||||
Manager's `BatteryChargeLimit1` (the interface Steam's own setting uses) has
|
||||
no backend in the Frame's device config, and the battery node is a
|
||||
`max1720x` fuel gauge with no `charge_control_end_threshold`. Research and
|
||||
read-only headset checks are in [charge-limit.md](charge-limit.md). Not tested
|
||||
on a headset.
|
||||
|
||||
## Unconfirmed claims made in these docs
|
||||
|
||||
- `/home` and `/etc` persist across Frame OS updates. This is inferred from
|
||||
|
||||
@@ -287,3 +287,12 @@ then every 6 hours: the latest release's `update.json` on GitHub, or
|
||||
`api.github.com/repos/saphid/frame-control/releases/latest` if that fails.
|
||||
Those requests carry no id. To stop it, set
|
||||
`FRAME_CONTROL_NO_UPDATE_CHECK=1`. See [releasing.md](releasing.md).
|
||||
|
||||
## Screenshots and clips
|
||||
|
||||
Copying screenshots and Steam clips to your computer, by hand or with
|
||||
**Settings → Copy to this computer**, goes straight from the headset to your
|
||||
computer over the same SSH connection as everything else. Nothing is uploaded
|
||||
anywhere. What automatic copying has copied is listed, by file id, in
|
||||
`auto-sync.json` in Frame Control's data folder; see
|
||||
[recording.md](recording.md#copying-new-ones-automatically).
|
||||
@@ -0,0 +1,158 @@
|
||||
# Recording and Steam clips
|
||||
|
||||
Frame Control can record what the headset shows, with its sound, and save it as
|
||||
an `.mp4` on your computer. It can also export the clips and background
|
||||
recordings from Steam's own game recording, at any length, as one `.mp4` each.
|
||||
Both are on **Tools → Capture & Live**. Everything is saved to
|
||||
`~/Videos/SteamFrame`, next to the screenshots in `~/Pictures/SteamFrame`.
|
||||
Nothing needs installing on the computer or on the Frame: the work is done by
|
||||
the `ffmpeg` that SteamOS ships.
|
||||
|
||||
**Not tested on a headset yet.** This is built from what the code already does
|
||||
on the Frame (the live view, the media player) and from how Steam is reported
|
||||
to store its recordings. The parts we're least sure of are listed under
|
||||
[What hasn't been checked](#what-hasnt-been-checked). Please report what you see.
|
||||
|
||||
## Record
|
||||
|
||||
Press **Record** next to **Live**. The button turns into **Stop**, and a line
|
||||
under the viewer counts the time. Press **Stop**: the recording is finished on
|
||||
the Frame, converted to `.mp4` there (without re-encoding) and copied to
|
||||
`~/Videos/SteamFrame/rec-<date>-<time>.mp4`. The line says when it's saved.
|
||||
|
||||
- **What's in it**: the same picture as **Live**, SteamVR's copy of the headset
|
||||
view (one undistorted 1920×1080 eye, from `/dev/video99`), at 30 fps and
|
||||
8 Mbit/s, encoded with x264 (the Frame's hardware encoder crashes `ffmpeg`).
|
||||
The sound is what the headset plays: the monitor of PipeWire's default
|
||||
output, read through its PulseAudio server (`-f pulse -i @DEFAULT_MONITOR@`).
|
||||
The microphone isn't recorded.
|
||||
- **Without sound**: before each recording Frame Control checks for a fraction
|
||||
of a second that `ffmpeg` can read that audio. If it can't, the recording goes
|
||||
ahead as video only, and the page and the saved message say so.
|
||||
- **Limits**: a recording stops by itself after 20 minutes and is saved as if
|
||||
you'd pressed Stop. It needs about 2.9 GB free on the Frame to start (room for
|
||||
a full-length recording twice, while it's converted), and checks the space on
|
||||
your computer before copying. One recording runs at a time.
|
||||
- **Where it waits on the Frame**: `~/.cache/frame-control/video/`, not `/tmp`,
|
||||
which is a RAM disk on SteamOS. It's deleted once copied.
|
||||
- **If the connection drops or the app quits** while recording, the Frame
|
||||
stops `ffmpeg` cleanly when the SSH channel closes, as the live view does. A
|
||||
recording that was never copied is deleted when the next one starts.
|
||||
|
||||
Recording and **Live** can run together; both read the same device.
|
||||
|
||||
## 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.
|
||||
Steam's own export is reported to stop at about 60 seconds. Exports run in the
|
||||
background; ones already on your computer are marked.
|
||||
|
||||
How it works: Steam keeps each recording as MPEG-DASH segments, one folder per
|
||||
recording session.
|
||||
|
||||
```
|
||||
~/.local/share/Steam/userdata/<account>/gamerecordings/
|
||||
video/bg_<appid>_<YYYYMMDD>_<HHMMSS>/ background recording
|
||||
clips/clip_<appid>_<YYYYMMDD>_<HHMMSS>/video/bg_<...>/ a saved clip
|
||||
session.mpd
|
||||
init-stream0.m4s chunk-stream0-00001.m4s chunk-stream0-00002.m4s ...
|
||||
init-stream1.m4s chunk-stream1-00001.m4s ...
|
||||
```
|
||||
|
||||
Each stream's `init-stream` segment followed by its chunks, in number order, is
|
||||
a playable fragmented MP4 of that stream. On the Frame, `ui/frame_clips.py`
|
||||
builds those files and has `ffmpeg` put the video and audio into one `.mp4`
|
||||
with `-c copy`, so nothing is re-encoded and it takes seconds. The result is
|
||||
copied to the computer and deleted from the Frame. The export needs free space
|
||||
on the Frame of about twice the recording's size while it runs.
|
||||
|
||||
The listing doesn't rely on the folder names: any folder under `clips/` or
|
||||
`video/` (up to four levels down) holding `init-stream*.m4s` counts. A stream
|
||||
missing its init segment or its chunks is left out. Missing chunk numbers are
|
||||
shown as "pieces missing"; the rest still exports.
|
||||
|
||||
## Copying new ones automatically
|
||||
|
||||
**Settings → Copy to this computer** copies new screenshots and Steam clips
|
||||
here on their own. It's off until you turn it on, with a switch each for
|
||||
screenshots and Steam clips. While a headset is connected, Frame Control looks
|
||||
about once a minute and copies what's new with the same code as **Save** and
|
||||
**Export**: screenshots to `~/Pictures/SteamFrame`, clips to
|
||||
`~/Videos/SteamFrame`.
|
||||
|
||||
- **Only what's new.** It copies what you take after turning it on, so the
|
||||
shots and clips already on the headset don't all arrive at once. **Copy
|
||||
everything now** copies everything that isn't on this computer yet, once.
|
||||
- **Deleted stays deleted.** What it has copied is remembered, per headset, in
|
||||
`auto-sync.json` in Frame Control's data folder, so a file you delete here
|
||||
isn't copied again (**Copy everything now** would bring it back).
|
||||
- **Saved clips only.** Steam's background recording is a rolling buffer, not
|
||||
something you chose to keep, so it isn't copied; export it by hand. A clip
|
||||
Steam wrote to in the last 30 seconds is still being saved and waits for the
|
||||
next look.
|
||||
- **It gives way.** It waits while an install, an export, a recording or
|
||||
anything else is running on the headset, and stops between files if one
|
||||
starts. While a copy is running, switching headsets waits for it, as it does
|
||||
for an export. When the headset can't be reached it waits longer each time,
|
||||
up to 15 minutes, and starts over once it answers.
|
||||
- **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.
|
||||
|
||||
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.
|
||||
|
||||
## 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_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.
|
||||
- `parse_id` checks a clip id (`<account>/<clips|video>/<folder>...`, plain
|
||||
names only, no `.` or `..`) before it reaches a path or a shell.
|
||||
|
||||
The API, with the same guards as every other request:
|
||||
|
||||
| Request | Does |
|
||||
|---|---|
|
||||
| `GET /api/record` | The recording's state: `idle`, `starting`, `recording` (with `elapsed`, `audio`, `limit`), `stopping`, `saving`, `saved` (with `file`) or `failed` (with `error`) |
|
||||
| `POST /api/record {"action": "start"\|"stop"}` | Start or stop |
|
||||
| `GET /api/clips` | Steam's recordings on the Frame, each with `saved` |
|
||||
| `POST /api/clips/export {"id"}` | Starts an export; poll `/api/job` |
|
||||
| `POST /api/open {"what": "videos"}` | Opens `~/Videos/SteamFrame` |
|
||||
| `GET /api/autosync` | Automatic copying: `screenshots`, `clips`, `since` (the watermark), `checked`, `copied`, `copied_time`, `error`, `paused`, `waiting` (clips still being written) |
|
||||
| `POST /api/autosync {"action": "settings", "screenshots", "clips"}` | Turn either on or off; turning it on sets `since` to now |
|
||||
| `POST /api/autosync {"action": "now"\|"resume"}` | Copy everything not here yet, once; or resume after running out of space |
|
||||
|
||||
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
|
||||
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
|
||||
recording with Steam's file names and export it with the real `ffmpeg`.
|
||||
|
||||
## What hasn't been checked
|
||||
|
||||
None of this has run on a Steam Frame yet. In particular:
|
||||
|
||||
- That SteamOS's `ffmpeg` can read `@DEFAULT_MONITOR@`. It has the PulseAudio
|
||||
output device (the media player plays through it), so the input is expected,
|
||||
but if it fails the recording is video only, as above.
|
||||
- Audio and video sync. Both inputs are stamped with the wall clock, because
|
||||
the camera device and the audio server otherwise count from different clocks.
|
||||
- The exact layout of `gamerecordings` on the Frame's SteamOS build, and
|
||||
whether a clip's segments start mid-recording. The parser is deliberately
|
||||
loose about the layout.
|
||||
- How much x264 at 1080p30 costs the Frame while a game is running.
|
||||
- Automatic copying: that 30 seconds without a write is a safe sign Steam has
|
||||
finished saving a clip, and how a once-a-minute listing feels on the Frame.
|
||||
@@ -64,7 +64,7 @@ function sharedPage({productionApi=false} = {}) {
|
||||
const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
|
||||
};
|
||||
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
|
||||
padReset(){},ctrlReset(){},resetShots(){},closeDevPop(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
|
||||
padReset(){},ctrlReset(){},resetShots(){},resetClips(){},closeDevPop(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
|
||||
vm.runInContext(`const UI_KEY = 'test-only';
|
||||
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
|
||||
const disp = {}, gm = {seq:0}, cat = {}, wi = {}, sourceState = {jobs:new Map(),detail:null,detailRequest:0};
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Copying new screenshots and clips to the computer on their own (ui/frame_autosync.py).
|
||||
|
||||
The headset is a fake: lists of shots and clips, and save/export functions that
|
||||
record what they were asked to copy. No SSH, no headset.
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import errno
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
|
||||
import frame_autosync as fa # noqa: E402
|
||||
|
||||
NOW = 1_800_000_000.0
|
||||
GB = 1024**3
|
||||
|
||||
|
||||
def shot(n, t, saved=False):
|
||||
return {"id": f"1/620/20261020{n:06d}_1.jpg", "time": t, "saved": saved}
|
||||
|
||||
|
||||
def clip(n, t, idle=600, kind="clip", saved=False):
|
||||
return {"id": f"1/clips/clip_620_{n}/video/bg", "kind": kind, "time": t, "idle": idle, "saved": saved}
|
||||
|
||||
|
||||
class FakeFrame:
|
||||
def __init__(self):
|
||||
self.shots, self.clips = [], []
|
||||
self.saved, self.exported, self.calls = [], [], []
|
||||
self.device, self.busy, self.down = "frame-a", False, False
|
||||
self.save_error = self.export_error = None
|
||||
|
||||
def list_shots(self):
|
||||
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):
|
||||
self.calls.append("list_clips")
|
||||
if self.down:
|
||||
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
|
||||
return {"clips": list(self.clips)}
|
||||
|
||||
def save_shots(self, device, ids):
|
||||
self.calls.append("save_shots")
|
||||
if self.save_error:
|
||||
raise self.save_error
|
||||
self.saved.append((device, list(ids)))
|
||||
for s in self.shots:
|
||||
if s["id"] in ids:
|
||||
s["saved"] = True
|
||||
|
||||
def export_clip(self, device, clip_id):
|
||||
self.calls.append("export_clip")
|
||||
if self.export_error:
|
||||
raise self.export_error
|
||||
self.exported.append((device, clip_id))
|
||||
for c in self.clips:
|
||||
if c["id"] == clip_id:
|
||||
c["saved"] = True
|
||||
|
||||
|
||||
class AutoSyncTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
self.frame = FakeFrame()
|
||||
self.now = [NOW]
|
||||
self.free = [100 * GB]
|
||||
self.sync = self.make()
|
||||
|
||||
def make(self):
|
||||
f = self.frame
|
||||
return fa.AutoSync(f.list_shots, f.save_shots, f.list_clips, f.export_clip,
|
||||
lambda: f.device, lambda: f.busy,
|
||||
offline=lambda e: "The Frame isn't answering." if "timed out" in str(e) else None,
|
||||
shots_folder=self.dir / "Pictures", clips_folder=self.dir / "Videos",
|
||||
path=self.dir / "auto-sync.json", clock=lambda: self.now[0],
|
||||
space=lambda folder: self.free[0])
|
||||
|
||||
def test_off_by_default_and_does_nothing(self):
|
||||
self.frame.shots = [shot(1, NOW + 10)]
|
||||
self.frame.clips = [clip(1, NOW + 10)]
|
||||
s = self.sync.state()
|
||||
self.assertFalse(s["screenshots"] or s["clips"])
|
||||
self.assertEqual(self.sync.tick(), fa.INTERVAL)
|
||||
self.assertEqual(self.frame.calls, []) # not even a listing
|
||||
self.assertFalse((self.dir / "auto-sync.json").exists())
|
||||
|
||||
def test_settings_persist(self):
|
||||
self.sync.update({"screenshots": True})
|
||||
again = self.make().state()
|
||||
self.assertTrue(again["screenshots"])
|
||||
self.assertFalse(again["clips"])
|
||||
self.assertEqual(again["since"], NOW)
|
||||
saved = json.loads((self.dir / "auto-sync.json").read_text())
|
||||
self.assertEqual((saved["screenshots"], saved["clips"]), (True, False))
|
||||
|
||||
def test_only_what_is_newer_than_turning_it_on(self):
|
||||
self.frame.shots = [shot(1, NOW - 3600), shot(2, NOW + 5), shot(3, NOW + 50, saved=True)]
|
||||
self.frame.clips = [clip(1, NOW - 3600), clip(2, NOW + 5)]
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.assertEqual(self.sync.tick(), fa.INTERVAL)
|
||||
self.assertEqual(self.frame.saved, [("frame-a", [shot(2, 0)["id"]])])
|
||||
self.assertEqual(self.frame.exported, [("frame-a", clip(2, 0)["id"])])
|
||||
s = self.sync.state()
|
||||
self.assertEqual((s["copied"], s["copied_time"], s["checked"], s["error"]), (2, NOW, NOW, None))
|
||||
|
||||
def test_watermark_moves_when_turned_off_and_on_again(self):
|
||||
self.sync.update({"screenshots": True})
|
||||
self.now[0] += 1000
|
||||
self.sync.update({"clips": True}) # already on: the watermark stays
|
||||
self.assertEqual(self.sync.state()["since"], NOW)
|
||||
self.sync.update({"screenshots": False, "clips": False})
|
||||
self.sync.update({"screenshots": True})
|
||||
self.assertEqual(self.sync.state()["since"], NOW + 1000)
|
||||
|
||||
def test_copy_everything_now_once(self):
|
||||
self.frame.shots = [shot(1, NOW - 3600), shot(2, NOW - 60)]
|
||||
self.frame.clips = [clip(1, NOW - 3600), clip(2, NOW - 60, kind="background")]
|
||||
self.sync.copy_all() # works with the toggles off too
|
||||
self.assertTrue(self.sync.state()["pending"])
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.saved, [("frame-a", [shot(1, 0)["id"], shot(2, 0)["id"]])])
|
||||
self.assertEqual(self.frame.exported, [("frame-a", clip(1, 0)["id"])]) # never the background recording
|
||||
self.assertFalse(self.sync.state()["pending"])
|
||||
self.frame.calls.clear()
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.calls, []) # off again afterwards
|
||||
|
||||
def test_not_copied_again_after_deleting_it_here(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.frame.shots = [shot(1, NOW + 5)]
|
||||
self.frame.clips = [clip(1, NOW + 5)]
|
||||
self.sync.tick()
|
||||
for s in self.frame.shots + self.frame.clips:
|
||||
s["saved"] = False # the person deleted the files
|
||||
self.frame.saved.clear()
|
||||
self.frame.exported.clear()
|
||||
self.make().tick() # remembered across restarts too
|
||||
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
|
||||
|
||||
def test_saved_by_hand_counts_as_copied(self):
|
||||
self.sync.update({"screenshots": True})
|
||||
self.frame.shots = [shot(1, NOW + 5, saved=True)]
|
||||
self.sync.tick()
|
||||
self.frame.shots[0]["saved"] = False
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.saved, [])
|
||||
|
||||
def test_remembered_per_headset(self):
|
||||
self.sync.update({"screenshots": True})
|
||||
self.frame.shots = [shot(1, NOW + 5)]
|
||||
self.sync.tick()
|
||||
self.frame.device = "frame-b"
|
||||
self.frame.shots = [shot(1, NOW + 5)] # the same name on another headset
|
||||
self.sync.tick()
|
||||
self.assertEqual([d for d, _ in self.frame.saved], ["frame-a", "frame-b"])
|
||||
|
||||
def test_clip_still_being_recorded_waits(self):
|
||||
self.sync.update({"clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5, idle=4), clip(2, NOW + 6, idle=None)]
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.exported, [])
|
||||
self.assertEqual(self.sync.state()["waiting"], 2)
|
||||
self.frame.clips[0]["idle"] = fa.SETTLE + 1
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.exported, [("frame-a", clip(1, 0)["id"])])
|
||||
self.assertEqual(self.sync.state()["waiting"], 1)
|
||||
|
||||
def test_no_headset_no_work(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.frame.device = None
|
||||
self.assertEqual(self.sync.tick(), fa.INTERVAL)
|
||||
self.assertEqual(self.frame.calls, [])
|
||||
|
||||
def test_waits_for_other_work(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.frame.shots = [shot(1, NOW + 5)]
|
||||
self.frame.busy = True
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertEqual(self.frame.calls, [])
|
||||
self.frame.busy = False
|
||||
self.sync.tick()
|
||||
self.assertEqual(len(self.frame.saved), 1)
|
||||
|
||||
def test_stops_between_items_when_other_work_starts(self):
|
||||
self.sync.update({"clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5), clip(2, NOW + 6)]
|
||||
export = self.frame.export_clip
|
||||
|
||||
def then_busy(device, clip_id):
|
||||
export(device, clip_id)
|
||||
self.frame.busy = True # an install started meanwhile
|
||||
self.frame.export_clip = then_busy
|
||||
sync = self.make()
|
||||
self.assertEqual(sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertEqual(len(self.frame.exported), 1)
|
||||
|
||||
def test_backs_off_while_unreachable(self):
|
||||
self.sync.update({"screenshots": True})
|
||||
self.frame.down = True
|
||||
waits = [self.sync.tick() for _ in range(6)]
|
||||
self.assertEqual(waits[:4], [120, 240, 480, 900])
|
||||
self.assertEqual(waits[-1], fa.MAX_BACKOFF)
|
||||
self.assertEqual(self.sync.state()["error"], "The Frame isn't answering.")
|
||||
self.frame.down = False
|
||||
self.assertEqual(self.sync.tick(), fa.INTERVAL)
|
||||
self.assertIsNone(self.sync.state()["error"])
|
||||
self.frame.down = True
|
||||
self.assertEqual(self.sync.tick(), 120) # starts over after a good look
|
||||
|
||||
def test_pauses_when_this_computer_is_nearly_full(self):
|
||||
self.sync.update({"screenshots": True})
|
||||
self.frame.shots = [shot(1, NOW + 5)]
|
||||
self.free[0] = 10 * 1024**2
|
||||
self.sync.tick()
|
||||
s = self.sync.state()
|
||||
self.assertIn("out of space", s["paused"])
|
||||
self.assertEqual(self.frame.saved, [])
|
||||
self.free[0] = 100 * GB
|
||||
self.frame.calls.clear()
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.calls, []) # stays paused, also after a restart
|
||||
self.assertTrue(self.make().state()["paused"])
|
||||
self.sync.resume()
|
||||
self.sync.tick()
|
||||
self.assertEqual(len(self.frame.saved), 1)
|
||||
self.assertIsNone(self.sync.state()["paused"])
|
||||
|
||||
def test_pauses_when_a_copy_runs_out_of_space(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5)]
|
||||
self.frame.export_error = RuntimeError("Not enough free space on this computer: it needs 900 MB")
|
||||
self.sync.tick()
|
||||
self.assertIn("ran out of space", self.sync.state()["paused"])
|
||||
self.sync.resume()
|
||||
self.frame.export_error = None
|
||||
self.frame.shots = [shot(1, NOW + 5)]
|
||||
self.frame.save_error = OSError(errno.ENOSPC, "No space left on device")
|
||||
self.sync.tick()
|
||||
self.assertTrue(self.sync.state()["paused"])
|
||||
|
||||
def test_space_on_the_frame_is_not_a_pause(self):
|
||||
self.sync.update({"clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5)]
|
||||
self.frame.export_error = RuntimeError("Not enough free space on the Frame to export it: it needs 900 MB")
|
||||
self.sync.tick()
|
||||
s = self.sync.state()
|
||||
self.assertIsNone(s["paused"])
|
||||
self.assertIn("Not enough free space on the Frame", s["error"])
|
||||
|
||||
def test_an_item_that_keeps_failing_is_left_alone(self):
|
||||
self.sync.update({"clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5)]
|
||||
self.frame.export_error = RuntimeError("ffmpeg couldn't join the recording")
|
||||
for _ in range(fa.TRIES + 2):
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.calls.count("export_clip"), fa.TRIES)
|
||||
|
||||
def test_worker_stops(self):
|
||||
stop = threading.Event()
|
||||
t = threading.Thread(target=self.sync.run, args=(stop,), daemon=True)
|
||||
t.start()
|
||||
stop.set()
|
||||
self.sync.update({}) # wakes it
|
||||
t.join(5)
|
||||
self.assertFalse(t.is_alive())
|
||||
|
||||
|
||||
class Server(unittest.TestCase):
|
||||
def test_route_and_settings(self):
|
||||
import server
|
||||
self.assertIn("/api/autosync", server.POST)
|
||||
with self.assertRaises(server.Failure):
|
||||
server.auto_sync({"action": "explode"})
|
||||
state = server.auto_sync({"action": "settings", "screenshots": "yes"}) # not a bool: ignored
|
||||
self.assertFalse(state["screenshots"])
|
||||
if server.LINK is None:
|
||||
with self.assertRaises(server.Failure):
|
||||
server.auto_sync({"action": "now"}) # nothing connected
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,562 @@
|
||||
"""Recordings of the headset view and Steam clip exports (ui/frame_clips.py), without a headset.
|
||||
|
||||
Steam's recordings are fixture trees in the layout frame_clips.py describes. The
|
||||
recording's shell runs for real under bash with a stand-in ffmpeg, so Stop, the time
|
||||
limit and the clean-up are the real script's behaviour. With ffmpeg installed here, a
|
||||
65-second DASH recording is made in Steam's file naming and exported for real.
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
|
||||
import frame_clips as clips # noqa: E402
|
||||
|
||||
POSIX = os.name != "nt" and shutil.which("bash")
|
||||
|
||||
|
||||
def write(path, data=b"x"):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
|
||||
def session(folder, streams=(0, 1), chunks=3, mpd=None, first=1, pad=5):
|
||||
"""A session folder as Steam writes it: init + numbered chunks per stream."""
|
||||
for n in streams:
|
||||
write(folder / f"init-stream{n}.m4s", f"init{n};".encode())
|
||||
for c in range(first, first + chunks):
|
||||
write(folder / f"chunk-stream{n}-{c:0{pad}d}.m4s", f"s{n}c{c};".encode())
|
||||
if mpd is not None:
|
||||
write(folder / "session.mpd", mpd.encode())
|
||||
return folder
|
||||
|
||||
|
||||
class Ids(unittest.TestCase):
|
||||
def test_good_ids(self):
|
||||
for cid in ("123/video/bg_620_20261020_201950",
|
||||
"123/clips/clip_620_20261020_202134/video/bg_620_20261020_201950",
|
||||
"9/clips/a/b/c/d"):
|
||||
self.assertEqual(clips.parse_id(cid)[0], cid.split("/")[0])
|
||||
self.assertEqual(clips.export_name("123/clips/clip_620_20261020_202134/video/bg_620_1"),
|
||||
"clip_620_20261020_202134.mp4")
|
||||
|
||||
def test_no_traversal_or_shell_from_ids(self):
|
||||
for cid in ("", None, 5, "123", "123/video", "123/other/bg", "abc/video/bg", "/123/video/bg",
|
||||
"123/video/..", "123/video/.", "123/video/bg/../../../../.ssh", "123/video/.hidden",
|
||||
"123/video/bg;rm -rf ~", "123/video/bg$(id)", "123/video/bg name", "123/video/bg\n",
|
||||
"123/video/%2e%2e", "123\\video\\bg", "123/video//bg", "123/clips/a/b/c/d/e",
|
||||
"123/video/" + "a" * 101, "1234567890123/video/bg"):
|
||||
with self.subTest(cid=cid), self.assertRaises(ValueError):
|
||||
clips.parse_id(cid)
|
||||
with self.subTest(cid=cid), self.assertRaises(ValueError):
|
||||
clips.export_name(cid)
|
||||
|
||||
def test_export_paths_checked_before_delete(self):
|
||||
good = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
|
||||
self.assertEqual(clips.remove_command(good), f'rm -rf "$HOME"/{clips.WORK}/export-0123456789abcdef')
|
||||
for bad in (None, "", "/etc/passwd", f"{clips.WORK}/export-0123456789abcdef/../../x.mp4",
|
||||
f"{clips.WORK}/export-xyz/clip.mp4", f"{clips.WORK}/../export-0123456789abcdef/a.mp4",
|
||||
f"{clips.WORK}/export-0123456789abcdef/a.mp4; rm -rf ~", ".ssh/export-0123456789abcdef/a.mp4"):
|
||||
with self.subTest(bad=bad), self.assertRaises(ValueError):
|
||||
clips.remove_command(bad)
|
||||
|
||||
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", "*"):
|
||||
with self.subTest(bad=bad), self.assertRaises(ValueError):
|
||||
fn(bad)
|
||||
|
||||
|
||||
class Sessions(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
|
||||
def test_chunks_in_number_order_whatever_the_padding(self):
|
||||
s = self.dir / "s"
|
||||
write(s / "init-stream0.m4s")
|
||||
for c in (10, 2, 1, 100, 9):
|
||||
write(s / f"chunk-stream0-{c}.m4s")
|
||||
write(s / "chunk-stream0-00003.m4s")
|
||||
got = clips.read_session(s)
|
||||
self.assertEqual([os.path.basename(p) for p in got[0]],
|
||||
["init-stream0.m4s", "chunk-stream0-1.m4s", "chunk-stream0-2.m4s", "chunk-stream0-00003.m4s",
|
||||
"chunk-stream0-9.m4s", "chunk-stream0-10.m4s", "chunk-stream0-100.m4s"])
|
||||
self.assertEqual(clips.chunk_gaps(got[0]), 100 - 6)
|
||||
|
||||
def test_unplayable_streams_and_strangers_left_out(self):
|
||||
s = self.dir / "s"
|
||||
session(s, streams=(0,), chunks=2)
|
||||
write(s / "init-stream1.m4s") # no chunks
|
||||
write(s / "chunk-stream2-00001.m4s") # no init
|
||||
write(s / "chunk-stream0-00001.m4s.part") # not a chunk
|
||||
write(s / "thumbnail.jpg")
|
||||
(s / "chunk-stream0-00009.m4s").mkdir() # a folder, not a file
|
||||
if POSIX:
|
||||
os.symlink("/etc/passwd", s / "chunk-stream0-00005.m4s") # links aren't followed
|
||||
got = clips.read_session(s)
|
||||
self.assertEqual(list(got), [0])
|
||||
self.assertEqual(len(got[0]), 3)
|
||||
|
||||
def test_manifest_duration(self):
|
||||
for text, want in (('mediaPresentationDuration="PT1M23.4S"', 83.4), ('mediaPresentationDuration="PT2H"', 7200),
|
||||
('mediaPresentationDuration="PT0H3M0.000S"', 180), ('mediaPresentationDuration="P1DT1S"', 86401),
|
||||
('mediaPresentationDuration="PT"', None), ('nothing here', None), (None, None)):
|
||||
self.assertEqual(clips.mpd_duration(text), want, text)
|
||||
|
||||
def test_listing_a_fixture_tree(self):
|
||||
ud = self.dir / "userdata"
|
||||
gr = ud / "123" / "gamerecordings"
|
||||
session(gr / "clips" / "clip_620_20261020_202134" / "video" / "bg_620_20261020_201950", chunks=40,
|
||||
mpd='<MPD mediaPresentationDuration="PT1M20.0S">')
|
||||
write(gr / "clips" / "clip_620_20261020_202134" / "thumbnail.jpg")
|
||||
session(gr / "video" / "bg_1172470_20261021_090000", streams=(0,), chunks=2)
|
||||
session(gr / "video" / "odd-name", chunks=1) # no stamp: time from the files
|
||||
(gr / "video" / "empty").mkdir()
|
||||
session(gr / "video" / ".hidden", chunks=1) # not a name an id can carry
|
||||
session(gr / "timelines" / "x", chunks=1) # not clips/ or video/
|
||||
session(ud / "notanaccount" / "gamerecordings" / "video" / "bg_1_20260101_000000", chunks=1)
|
||||
session(gr / "video" / "a" / "b" / "c" / "d" / "e", chunks=1) # deeper than ids go
|
||||
if POSIX:
|
||||
os.symlink(gr, gr / "video" / "loop") # links aren't followed
|
||||
for f in (gr / "video" / "odd-name").iterdir():
|
||||
os.utime(f, (1e9, 1e9)) # 2001: older than the others
|
||||
got = clips.list_recordings(ud)
|
||||
self.assertEqual([c["id"] for c in got], ["123/video/bg_1172470_20261021_090000",
|
||||
"123/clips/clip_620_20261020_202134/video/bg_620_20261020_201950",
|
||||
"123/video/odd-name"]) # newest first
|
||||
self.assertEqual(got[-1]["time"], 1e9)
|
||||
by = {c["id"]: c for c in got}
|
||||
clip = by["123/clips/clip_620_20261020_202134/video/bg_620_20261020_201950"]
|
||||
self.assertEqual((clip["kind"], clip["appid"], clip["streams"], clip["chunks"], clip["gaps"], clip["duration"]),
|
||||
("clip", "620", 2, 80, 0, 80.0))
|
||||
self.assertEqual(clip["file"], "clip_620_20261020_202134.mp4")
|
||||
self.assertEqual(clip["time"], time.mktime((2026, 10, 20, 20, 21, 34, 0, 0, -1)))
|
||||
self.assertEqual(clip["size"], sum(f.stat().st_size for f in
|
||||
(gr / "clips" / "clip_620_20261020_202134" / "video" / "bg_620_20261020_201950").iterdir()
|
||||
if f.suffix == ".m4s"))
|
||||
bg = by["123/video/bg_1172470_20261021_090000"]
|
||||
self.assertEqual((bg["kind"], bg["appid"], bg["streams"], bg["duration"]), ("background", "1172470", 1, None))
|
||||
self.assertIsNone(by["123/video/odd-name"]["appid"])
|
||||
# How long since Steam last wrote to it: auto-sync waits for a clip still being recorded.
|
||||
self.assertLess(clip["idle"], 60)
|
||||
self.assertGreater(by["123/video/odd-name"]["idle"], 10**8)
|
||||
self.assertEqual(clips.list_recordings(self.dir / "missing"), [])
|
||||
|
||||
def test_listing_marks_what_is_already_here(self):
|
||||
here = self.dir / "here"
|
||||
write(here / "clip_620_20261020_202134.mp4")
|
||||
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)
|
||||
self.assertEqual([c["saved"] for c in got["clips"]], [True, False])
|
||||
self.assertEqual(got["folder"], str(here))
|
||||
|
||||
|
||||
class Export(unittest.TestCase):
|
||||
"""The Frame's half of an export, against a fixture tree, with ffmpeg stood in."""
|
||||
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
self.ud = self.dir / "home" / clips.STEAM_USERDATA
|
||||
self.home = self.dir / "home"
|
||||
self.cid = "123/clips/clip_620_20261020_202134/video/bg_620_20261020_201950"
|
||||
session(self.ud / "123" / "gamerecordings" / "clips" / "clip_620_20261020_202134" / "video" /
|
||||
"bg_620_20261020_201950", chunks=12)
|
||||
|
||||
def fake_ffmpeg(self, seen, rc=0):
|
||||
def run(cmd, **kw):
|
||||
ins = [cmd[i + 1] for i, a in enumerate(cmd) if a == "-i"]
|
||||
seen.append({"cmd": cmd, "inputs": {Path(i).name: Path(i).read_bytes() for i in ins}})
|
||||
if rc == 0:
|
||||
Path(cmd[-1]).write_bytes(b"mp4")
|
||||
return subprocess.CompletedProcess(cmd, rc, "", "Invalid data found" if rc else "")
|
||||
return run
|
||||
|
||||
def test_streams_stitched_in_order_and_copied_not_reencoded(self):
|
||||
seen = []
|
||||
with mock.patch.object(clips.subprocess, "run", self.fake_ffmpeg(seen)):
|
||||
got = clips.export(self.ud, self.cid, home=self.home)
|
||||
self.assertRegex(got["path"], clips.EXPORT_PATH)
|
||||
self.assertTrue(got["path"].endswith("/clip_620_20261020_202134.mp4"))
|
||||
self.assertEqual(got["size"], 3)
|
||||
cmd = seen[0]["cmd"]
|
||||
self.assertEqual(cmd[cmd.index("-c") + 1], "copy")
|
||||
self.assertEqual([cmd[i + 1] for i, a in enumerate(cmd) if a == "-map"], ["0", "1"])
|
||||
for n in (0, 1):
|
||||
self.assertEqual(seen[0]["inputs"][f"stream{n}.mp4"],
|
||||
f"init{n};".encode() + b"".join(f"s{n}c{c};".encode() for c in range(1, 13)))
|
||||
out = self.home / got["path"]
|
||||
self.assertEqual(sorted(p.name for p in out.parent.iterdir()), [out.name]) # stream files gone
|
||||
|
||||
def test_failed_join_leaves_nothing_behind(self):
|
||||
with mock.patch.object(clips.subprocess, "run", self.fake_ffmpeg([], rc=1)):
|
||||
with self.assertRaisesRegex(clips.ClipError, "Invalid data"):
|
||||
clips.export(self.ud, self.cid, home=self.home)
|
||||
self.assertEqual(list((self.home / clips.WORK).glob("export-*")), [])
|
||||
|
||||
def test_bad_or_missing_ids(self):
|
||||
with self.assertRaises(ValueError):
|
||||
clips.export(self.ud, "123/video/../../../../etc", home=self.home)
|
||||
with self.assertRaises(ClipErrorOrOSError):
|
||||
clips.export(self.ud, "123/video/nothing_here", home=self.home)
|
||||
|
||||
@unittest.skipUnless(POSIX, "symlinks")
|
||||
def test_a_linked_account_cannot_reach_outside_userdata(self):
|
||||
outside = session(self.dir / "elsewhere" / "gamerecordings" / "video" / "bg_1_20260101_000000", chunks=1)
|
||||
os.symlink(self.dir / "elsewhere", self.ud / "456")
|
||||
self.assertIsNotNone(outside)
|
||||
with self.assertRaises(ValueError):
|
||||
clips.export(self.ud, "456/video/bg_1_20260101_000000", home=self.home)
|
||||
|
||||
def test_no_space_on_the_frame(self):
|
||||
full = mock.Mock(free=10)
|
||||
with mock.patch.object(clips.shutil, "disk_usage", return_value=full):
|
||||
with self.assertRaisesRegex(clips.ClipError, "Not enough free space on the Frame"):
|
||||
clips.export(self.ud, self.cid, home=self.home)
|
||||
|
||||
def test_old_exports_swept(self):
|
||||
work = self.home / clips.WORK
|
||||
old, new = work / "export-aaaaaaaaaaaaaaaa", work / "export-bbbbbbbbbbbbbbbb"
|
||||
write(old / "x.mp4"), write(new / "y.mp4")
|
||||
os.utime(old, (time.time() - 7 * 3600,) * 2)
|
||||
with mock.patch.object(clips.subprocess, "run", self.fake_ffmpeg([])):
|
||||
clips.export(self.ud, self.cid, home=self.home)
|
||||
self.assertFalse(old.exists())
|
||||
self.assertTrue(new.exists())
|
||||
|
||||
def test_main_prints_json(self):
|
||||
with mock.patch.object(clips.Path, "home", return_value=self.home), \
|
||||
mock.patch("sys.stdout", new_callable=lambda: __import__("io").StringIO()) as out:
|
||||
self.assertEqual(clips.main(["list"]), 0)
|
||||
self.assertEqual(json.loads(out.getvalue())["clips"][0]["id"], self.cid)
|
||||
with mock.patch("sys.stdout", new_callable=lambda: __import__("io").StringIO()) as out:
|
||||
self.assertEqual(clips.main(["export", "../../x"]), 1)
|
||||
self.assertEqual(json.loads(out.getvalue()), {"error": "bad clip id"})
|
||||
|
||||
|
||||
ClipErrorOrOSError = (clips.ClipError, OSError)
|
||||
|
||||
|
||||
class ComputerSide(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
|
||||
def test_export_copies_then_deletes_on_the_frame(self):
|
||||
path = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
|
||||
calls = []
|
||||
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)
|
||||
self.assertEqual(got["message"], "Saved clip_620.mp4 to ~/Videos/SteamFrame")
|
||||
self.assertEqual((self.dir / "clip_620.mp4").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"])
|
||||
|
||||
def test_failed_copy_leaves_no_partial_file_and_still_cleans_up(self):
|
||||
path = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
|
||||
ran = []
|
||||
|
||||
def fetch(remote, local):
|
||||
Path(local).write_bytes(b"half")
|
||||
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)
|
||||
self.assertEqual(list(self.dir.iterdir()), [])
|
||||
self.assertEqual(ran, [clips.remove_command(path)])
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class Commands(unittest.TestCase):
|
||||
def test_record_command_with_and_without_sound(self):
|
||||
with_sound = shlex.split(clips.record_command("rec-20261020-201950", True))
|
||||
self.assertEqual(with_sound[:2], ["bash", "-c"])
|
||||
script = with_sound[2]
|
||||
self.assertIn(f"-f v4l2 -video_size 1920x1080 -i {clips.STREAM_DEVICE}", script)
|
||||
self.assertIn("-c:v libx264", script)
|
||||
self.assertNotIn("v4l2m2m", script) # the hardware encoder crashes ffmpeg
|
||||
self.assertIn(f"-f pulse -i {clips.AUDIO_SOURCE}", script)
|
||||
self.assertIn("-map 1:a:0", script)
|
||||
self.assertIn("-c:a aac", script)
|
||||
self.assertIn(f"-t {clips.MAX_SECONDS} ", script)
|
||||
self.assertIn('"$d/rec-20261020-201950.mkv"', script)
|
||||
self.assertEqual(script.count("-use_wallclock_as_timestamps 1"), 2)
|
||||
quiet = shlex.split(clips.record_command("rec-20261020-201950", False))[2]
|
||||
self.assertNotIn("pulse", quiet)
|
||||
self.assertNotIn("-c:a", quiet)
|
||||
self.assertEqual(quiet.count("-use_wallclock_as_timestamps 1"), 1)
|
||||
with self.assertRaises(ValueError):
|
||||
clips.record_command("rec-20261020-201950", True, max_seconds=clips.MAX_SECONDS + 1)
|
||||
|
||||
def test_probe_checks_device_space_and_sound(self):
|
||||
probe = clips.probe_command()
|
||||
self.assertIn(f"[ -e {clips.STREAM_DEVICE} ]", probe)
|
||||
self.assertIn(f"-ge {clips.NEED_KB} ]", probe)
|
||||
self.assertIn(f"timeout 5 ffmpeg -hide_banner -loglevel error -nostdin -f pulse -i {clips.AUDIO_SOURCE}", probe)
|
||||
self.assertIn("echo audio=1; else echo audio=0", probe)
|
||||
self.assertIn(f'd="$HOME/{clips.WORK}"', probe)
|
||||
|
||||
def test_finish_remuxes_without_reencoding(self):
|
||||
cmd = clips.finish_command("rec-20261020-201950")
|
||||
self.assertIn("-i rec-20261020-201950.mkv -map 0 -c copy -movflags +faststart rec-20261020-201950.mp4", cmd)
|
||||
self.assertNotIn("libx264", cmd)
|
||||
self.assertEqual(clips.cleanup_command("rec-20261020-201950"), f'rm -f "$HOME/{clips.WORK}/rec-20261020-201950".*')
|
||||
|
||||
def test_stitch_command(self):
|
||||
self.assertEqual(clips.stitch_command(["a.mp4", "b.mp4"], "out.mp4"),
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", "a.mp4", "-i", "b.mp4",
|
||||
"-map", "0", "-map", "1", "-c", "copy", "-movflags", "+faststart", "out.mp4"])
|
||||
|
||||
|
||||
# A stand-in for ffmpeg on the Frame: records how it was run, writes its output file,
|
||||
# and finishes the file when asked to stop (SIGTERM), as ffmpeg does. FAKE_FFMPEG_RUN
|
||||
# makes the recording end by itself after that many seconds (the time limit).
|
||||
FAKE_FFMPEG = r'''#!/usr/bin/env python3
|
||||
import json, os, signal, sys, time
|
||||
home = os.environ["HOME"]
|
||||
with open(os.path.join(home, "ffmpeg-calls.jsonl"), "a") as f:
|
||||
f.write(json.dumps(sys.argv[1:]) + "\n")
|
||||
out = sys.argv[-1]
|
||||
if "-c" in sys.argv and sys.argv[sys.argv.index("-c") + 1] == "copy": # the remux after a recording
|
||||
src = sys.argv[sys.argv.index("-i") + 1]
|
||||
open(out, "wb").write(open(src, "rb").read() + b"+mp4")
|
||||
sys.exit(0)
|
||||
stop = []
|
||||
signal.signal(signal.SIGTERM, lambda *a: stop.append("TERM"))
|
||||
f = open(out, "wb")
|
||||
f.write(b"v"); f.flush()
|
||||
with open(os.path.join(home, "ffmpeg.pid.tmp"), "w") as p:
|
||||
p.write(str(os.getpid()))
|
||||
os.replace(os.path.join(home, "ffmpeg.pid.tmp"), os.path.join(home, "ffmpeg.pid"))
|
||||
end = time.time() + float(os.environ.get("FAKE_FFMPEG_RUN", "60"))
|
||||
while not stop and time.time() < end:
|
||||
f.write(b"v"); f.flush(); time.sleep(0.05)
|
||||
f.close()
|
||||
open(os.path.join(home, "ffmpeg-ended"), "w").write(stop[0] if stop else "limit")
|
||||
sys.exit(255 if stop else 0)
|
||||
'''
|
||||
|
||||
|
||||
@unittest.skipUnless(POSIX, "the recording's shell is bash on the Frame")
|
||||
class Recording(unittest.TestCase):
|
||||
"""The Recorder with the real record/finish/cleanup shell, run locally under bash."""
|
||||
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
self.frame = self.dir / "frame" # the Frame's home
|
||||
(self.frame / clips.WORK).mkdir(parents=True)
|
||||
bin_dir = self.dir / "bin"
|
||||
write(bin_dir / "ffmpeg", FAKE_FFMPEG.encode()).chmod(0o755)
|
||||
self.env = {**os.environ, "HOME": str(self.frame), "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}"}
|
||||
self.here = self.dir / "Videos"
|
||||
self.channels, self.released, self.ran = [], [], []
|
||||
self.audio = "audio=1"
|
||||
|
||||
def open_channel(self, command, stderr):
|
||||
proc = subprocess.Popen(shlex.split(command), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr,
|
||||
env=self.env)
|
||||
self.channels.append(proc)
|
||||
self.addCleanup(lambda: proc.poll() is None and proc.kill())
|
||||
return proc
|
||||
|
||||
def run_cmd(self, command, timeout):
|
||||
self.ran.append(command)
|
||||
if command == clips.probe_command():
|
||||
return self.audio + "\n" # needs /dev/video99, so answered here
|
||||
r = subprocess.run(["bash", "-c", command], capture_output=True, text=True, env=self.env, timeout=timeout)
|
||||
if r.returncode:
|
||||
raise RuntimeError(r.stderr.strip() or f"exit {r.returncode}")
|
||||
return r.stdout
|
||||
|
||||
def fetch(self, remote, local):
|
||||
shutil.copyfile(self.frame / remote, local)
|
||||
|
||||
def recorder(self):
|
||||
rec = clips.Recorder(self.open_channel, self.run_cmd, self.fetch,
|
||||
lambda fn: threading.Thread(target=fn, daemon=True).start(),
|
||||
release=self.released.append, folder=self.here)
|
||||
start = rec.start
|
||||
|
||||
def started():
|
||||
# "started" means ffmpeg was launched; wait until it's running, as it would be by a person's Stop.
|
||||
pid = self.frame / "ffmpeg.pid"
|
||||
for f in (pid, self.frame / "ffmpeg-ended"):
|
||||
f.unlink(missing_ok=True)
|
||||
got = start()
|
||||
for _ in range(200):
|
||||
if pid.exists() or (self.frame / "ffmpeg-ended").exists():
|
||||
break
|
||||
time.sleep(0.02)
|
||||
return got
|
||||
rec.start = started
|
||||
return rec
|
||||
|
||||
def wait_for(self, rec, states, timeout=20):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
s = rec.status()
|
||||
if s["state"] in states:
|
||||
return s
|
||||
time.sleep(0.05)
|
||||
self.fail(f"still {rec.status()}")
|
||||
|
||||
def ffmpeg_calls(self):
|
||||
return [json.loads(line) for line in (self.frame / "ffmpeg-calls.jsonl").read_text().splitlines()]
|
||||
|
||||
def test_stop_finishes_the_file_and_copies_it_here(self):
|
||||
rec = self.recorder()
|
||||
started = rec.start()
|
||||
self.assertEqual((started["state"], started["audio"], started["message"]), ("recording", True, "Recording"))
|
||||
self.assertIn("pulse", self.ffmpeg_calls()[0])
|
||||
time.sleep(0.3)
|
||||
self.assertEqual(rec.stop()["state"], "stopping")
|
||||
done = self.wait_for(rec, ("saved", "failed"))
|
||||
self.assertEqual(done["state"], "saved", done)
|
||||
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "TERM") # asked to finish, not killed
|
||||
saved = Path(done["file"])
|
||||
self.assertEqual(saved.parent, self.here)
|
||||
self.assertRegex(saved.name, r"^rec-\d{8}-\d{6}\.mp4$")
|
||||
self.assertTrue(saved.read_bytes().endswith(b"+mp4"))
|
||||
self.assertEqual([p.name for p in self.here.iterdir()], [saved.name]) # no .incoming- left
|
||||
self.assertEqual(list((self.frame / clips.WORK).iterdir()), []) # cleaned on the Frame
|
||||
self.assertIsNotNone(self.channels[0].poll()) # the channel has gone
|
||||
self.assertEqual(self.released, self.channels)
|
||||
self.assertEqual(self.ran[-1], clips.cleanup_command(saved.stem))
|
||||
|
||||
def test_channel_closing_stops_ffmpeg_on_the_frame(self):
|
||||
# The computer's ssh going away closes the channel just as Stop does.
|
||||
rec = self.recorder()
|
||||
rec.start()
|
||||
pid = int((self.frame / "ffmpeg.pid").read_text())
|
||||
self.channels[0].stdin.close()
|
||||
self.wait_for(rec, ("saved", "failed"))
|
||||
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "TERM")
|
||||
with self.assertRaises(ProcessLookupError):
|
||||
for _ in range(100):
|
||||
os.kill(pid, 0)
|
||||
time.sleep(0.05)
|
||||
|
||||
def test_time_limit_saves_on_its_own(self):
|
||||
self.env["FAKE_FFMPEG_RUN"] = "0.3"
|
||||
rec = self.recorder()
|
||||
rec.start()
|
||||
done = self.wait_for(rec, ("saved", "failed"))
|
||||
self.assertEqual(done["state"], "saved", done)
|
||||
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "limit")
|
||||
with self.assertRaisesRegex(clips.ClipError, "Not recording"):
|
||||
rec.stop()
|
||||
|
||||
def test_no_sound_records_video_and_says_so(self):
|
||||
self.audio = "audio=0"
|
||||
rec = self.recorder()
|
||||
started = rec.start()
|
||||
self.assertFalse(started["audio"])
|
||||
self.assertIn("without sound", started["message"])
|
||||
self.assertNotIn("pulse", self.ffmpeg_calls()[0])
|
||||
rec.stop()
|
||||
done = self.wait_for(rec, ("saved", "failed"))
|
||||
self.assertIn("(no sound)", done["message"])
|
||||
|
||||
def test_one_at_a_time(self):
|
||||
rec = self.recorder()
|
||||
with self.assertRaisesRegex(clips.ClipError, "Not recording"):
|
||||
rec.stop()
|
||||
rec.start()
|
||||
with self.assertRaises(clips.ClipError) as e:
|
||||
rec.start()
|
||||
self.assertEqual(e.exception.status, 409)
|
||||
rec.stop()
|
||||
self.wait_for(rec, ("saved",))
|
||||
rec.start() # a new one once the last is saved
|
||||
rec.stop()
|
||||
self.wait_for(rec, ("saved",))
|
||||
|
||||
def test_failed_start_reports_why(self):
|
||||
rec = clips.Recorder(lambda cmd, err: subprocess.Popen(["bash", "-c", "echo 'no v4l2 here' >&2; exit 1"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=err),
|
||||
lambda cmd, t: "audio=1", self.fetch, lambda fn: fn(), release=self.released.append,
|
||||
folder=self.here)
|
||||
with self.assertRaisesRegex(clips.ClipError, "no v4l2 here"):
|
||||
rec.start()
|
||||
self.assertEqual(rec.status()["state"], "failed")
|
||||
self.assertEqual(len(self.released), 1)
|
||||
|
||||
def test_failed_probe_reports_why(self):
|
||||
def run(cmd, timeout):
|
||||
raise clips.ClipError("No headset view device (/dev/video99). Is SteamVR running?")
|
||||
rec = clips.Recorder(mock.Mock(side_effect=AssertionError("recorded anyway")), run, self.fetch,
|
||||
lambda fn: fn(), folder=self.here)
|
||||
with self.assertRaisesRegex(clips.ClipError, "Is SteamVR running"):
|
||||
rec.start()
|
||||
self.assertEqual(rec.status()["state"], "failed")
|
||||
|
||||
def test_empty_recording_is_an_error_not_a_file(self):
|
||||
self.env["FAKE_FFMPEG_RUN"] = "0"
|
||||
rec = self.recorder()
|
||||
with mock.patch.object(clips, "finish_command", lambda name: f"echo 'The recording is empty' >&2; exit 1"):
|
||||
rec.start()
|
||||
done = self.wait_for(rec, ("saved", "failed"))
|
||||
self.assertEqual(done["state"], "failed")
|
||||
self.assertIn("The recording is empty", done["error"])
|
||||
self.assertFalse(self.here.exists() and any(self.here.iterdir()))
|
||||
self.assertEqual(list((self.frame / clips.WORK).iterdir()), [])
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "needs ffmpeg and ffprobe")
|
||||
class RealFfmpeg(unittest.TestCase):
|
||||
"""A 65 s recording in Steam's DASH naming (ffmpeg's own dash muxer names files the same
|
||||
way), stitched by the real ffmpeg: longer than the 60 s Steam's own export manages."""
|
||||
|
||||
def test_export_of_a_long_recording(self):
|
||||
d = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, d, ignore_errors=True)
|
||||
ud = d / "home" / clips.STEAM_USERDATA
|
||||
folder = ud / "123" / "gamerecordings" / "video" / "bg_620_20261020_201950"
|
||||
folder.mkdir(parents=True)
|
||||
r = subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=64x64:rate=10",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=8000", "-t", "65",
|
||||
"-c:v", "libx264", "-preset", "ultrafast", "-g", "20", "-c:a", "aac",
|
||||
"-seg_duration", "2", "-use_template", "1", "-use_timeline", "0",
|
||||
"-f", "dash", str(folder / "session.mpd")], capture_output=True, text=True)
|
||||
if r.returncode != 0 or not (folder / "chunk-stream0-00001.m4s").exists():
|
||||
self.skipTest("this ffmpeg can't write DASH with libx264: " + r.stderr[-300:])
|
||||
listed = clips.list_recordings(ud)
|
||||
self.assertEqual(len(listed), 1)
|
||||
self.assertGreater(listed[0]["chunks"], 30)
|
||||
got = clips.export(ud, listed[0]["id"], home=d / "home")
|
||||
out = d / "home" / got["path"]
|
||||
probe = json.loads(subprocess.run(["ffprobe", "-v", "error", "-show_streams", "-show_format", "-of", "json", str(out)],
|
||||
capture_output=True, text=True, check=True).stdout)
|
||||
self.assertEqual(sorted(s["codec_type"] for s in probe["streams"]), ["audio", "video"])
|
||||
self.assertGreater(float(probe["format"]["duration"]), 64)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -109,6 +109,10 @@ class ServerGuards(unittest.TestCase):
|
||||
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("POST", "/api/launch", {"appid": "620"})[0], 403)
|
||||
self.assertEqual(self.request("GET", "/api/record")[0], 403)
|
||||
self.assertEqual(self.request("GET", "/api/clips")[0], 403)
|
||||
self.assertEqual(self.request("POST", "/api/record", {"action": "start"})[0], 403)
|
||||
self.assertEqual(self.request("POST", "/api/clips/export", {"id": "1/video/bg_620_20261020_201950"})[0], 403)
|
||||
|
||||
def test_captures_are_not_cacheable(self):
|
||||
# Headset captures show everything on screen; nothing may cache them.
|
||||
@@ -134,11 +138,24 @@ class ServerGuards(unittest.TestCase):
|
||||
("/api/shots/save", {"ids": [1]}),
|
||||
("/api/shots/save", {"ids": ["1/250820/../../.ssh/id_ed25519"]}),
|
||||
("/api/shots/save", {"ids": ["1/250820/20260925225208_1.jpg; rm -rf ~"]}),
|
||||
("/api/record", {}),
|
||||
("/api/record", {"action": "explode"}),
|
||||
("/api/clips/export", {}),
|
||||
("/api/clips/export", {"id": 5}),
|
||||
("/api/clips/export", {"id": "1/video/../../../../.ssh"}),
|
||||
("/api/clips/export", {"id": "1/video/bg;rm -rf ~"}),
|
||||
("/api/clips/export", {"id": "../1/video/bg"}),
|
||||
]
|
||||
for path, body in cases:
|
||||
status, payload = self.post(path, body)
|
||||
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
|
||||
|
||||
def test_recorder_idle_until_started(self):
|
||||
status, _, payload = self.request("GET", "/api/record", headers={"X-Frame-UI": "1"})
|
||||
self.assertEqual((status, json.loads(payload)), (200, {"state": "idle"}))
|
||||
status, payload = self.post("/api/record", {"action": "stop"})
|
||||
self.assertEqual(status, 409, payload)
|
||||
|
||||
def test_showing_a_shot_needs_it_saved_here(self):
|
||||
status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"})
|
||||
self.assertEqual(status, 404, payload)
|
||||
@@ -530,6 +547,14 @@ class LocalMode(unittest.TestCase):
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertIsInstance(body["titles"], list)
|
||||
|
||||
def test_recordings_are_for_a_computer(self):
|
||||
for method, path, body in (("GET", "/api/record", None), ("GET", "/api/clips", None),
|
||||
("POST", "/api/record", {"action": "start"}),
|
||||
("POST", "/api/clips/export", {"id": "1/video/bg_620_20261020_201950"})):
|
||||
status, payload = self.request(method, path, body)
|
||||
self.assertEqual(status, 400, (path, payload))
|
||||
self.assertIn("computer", payload["error"])
|
||||
|
||||
def test_open_is_for_the_app_and_power_needs_a_password(self):
|
||||
self.assertEqual(self.request("POST", "/api/open", {"what": "terminal"})[0], 400)
|
||||
status, body = self.request("POST", "/api/open", {"what": "reboot"})
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ function connectionFixture() {
|
||||
const f=workspaceFixture(), c=f.context;
|
||||
Object.assign(c,{URLSearchParams,Date,clearTimeout,link:{s:{phase:'connected'},device:'a',phase:'connected',rev:0},disp:{list:[],seq:0},cat:{apps:null},wi:{},devGen:1,androidSeq:0,androidPending:new Set(),titlesSeq:0,volTimer:null,refreshing:null,lastImg:null,lastShot:null,
|
||||
sourceState:{jobs:new Map(),detail:null,detailRequest:0},touches:new Map(),gesture:null,padType:{blur(){},value:''},
|
||||
log(){},renderPill(){},renderConnDlg(){},renderHome(){},resetShots(){},renderBanner(){},loadDevices(){},renderDevices(){},renderTests(){},viaText(){return 'test';},netLabel(){return 'test';},refresh:()=>Promise.resolve(),reloadAll(){},checkContactPrompt(){},ctrlReset(){},padShow:s=>{c.pad.state=s.state;}});
|
||||
log(){},renderPill(){},renderConnDlg(){},renderHome(){},resetShots(){},resetClips(){},renderBanner(){},loadDevices(){},renderDevices(){},renderTests(){},viaText(){return 'test';},netLabel(){return 'test';},refresh:()=>Promise.resolve(),reloadAll(){},checkContactPrompt(){},ctrlReset(){},padShow:s=>{c.pad.state=s.state;}});
|
||||
c.window.connDevice='a';
|
||||
f.run('const UI_KEY="test";'+pick(html,'const HEADSET_FREE =','// What a panel shows'));
|
||||
f.run(pick(html,'function onConnection(s) {','// Read the event stream'));
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Copy new screenshots and Steam clips from the headset to this computer on their own.
|
||||
|
||||
Off until it's turned on in Settings. Then a worker in the server looks about once a
|
||||
minute, while a headset is connected and nothing else is running on it, and copies
|
||||
what's new with the same code as Save and Export: screenshots to ~/Pictures/SteamFrame,
|
||||
saved Steam clips to ~/Videos/SteamFrame.
|
||||
|
||||
- Only what's newer than the moment it was turned on, so turning it on doesn't copy
|
||||
the whole back-catalogue; Copy everything now does that, once.
|
||||
- What it has copied is remembered per headset (auto-sync.json in the app's data
|
||||
folder), so a file deleted from this computer isn't copied again.
|
||||
- A clip Steam is still writing (a chunk changed in the last SETTLE seconds) waits
|
||||
for the next look. Steam's background recording is never copied: it's a rolling
|
||||
buffer, not something anyone chose to keep.
|
||||
- When this computer runs out of space it pauses and says so, until Resume.
|
||||
- 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"}...]}
|
||||
save_shots(device, ids) copies those screenshots here
|
||||
list_clips() -> {"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)
|
||||
offline(error) -> a plain message if the error means the headset couldn't be reached
|
||||
"""
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import frame_host
|
||||
|
||||
INTERVAL = 60 # seconds between looks
|
||||
BUSY_WAIT = 15 # something else is running: look again soon
|
||||
MAX_BACKOFF = 15 * 60 # the longest wait while the headset can't be reached
|
||||
SETTLE = 30 # a clip written to within this many seconds is still being recorded
|
||||
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
|
||||
KINDS = ("shots", "clips")
|
||||
|
||||
|
||||
class DiskFull(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def free_space(folder):
|
||||
"""Free bytes on the disk `folder` is (or will be) on."""
|
||||
p = Path(folder)
|
||||
while not p.exists() and p != p.parent:
|
||||
p = p.parent
|
||||
return shutil.disk_usage(p).free
|
||||
|
||||
|
||||
def disk_full(error):
|
||||
"""Whether an error means this computer ran out of space (not the headset)."""
|
||||
if isinstance(error, OSError) and error.errno == errno.ENOSPC:
|
||||
return True
|
||||
text = str(error)
|
||||
return "No space left on device" in text or "free space on this computer" in text
|
||||
|
||||
|
||||
def _defaults():
|
||||
return {"screenshots": False, "clips": False, "since": None, "synced": {}, "paused": None,
|
||||
"checked": None, "copied": 0, "copied_time": None, "error": None, "waiting": 0}
|
||||
|
||||
|
||||
class AutoSync:
|
||||
def __init__(self, list_shots, save_shots, list_clips, export_clip, device, busy, offline=lambda e: None,
|
||||
shots_folder=None, clips_folder=None, path=None, clock=time.time, space=free_space):
|
||||
self.list_shots, self.save_shots = list_shots, save_shots
|
||||
self.list_clips, self.export_clip = list_clips, export_clip
|
||||
self.device, self.busy, self.offline = device, busy, offline
|
||||
self.shots_folder = Path(shots_folder or Path.home() / "Pictures" / "SteamFrame")
|
||||
self.clips_folder = Path(clips_folder or Path.home() / "Videos" / "SteamFrame")
|
||||
self.path = Path(path or frame_host.data_dir("auto-sync.json"))
|
||||
self.clock, self.space = clock, space
|
||||
self._lock = threading.RLock()
|
||||
self._wake = threading.Event()
|
||||
self._all = False # Copy everything now, waiting for the next pass
|
||||
self._running = False
|
||||
self._backoff = 0
|
||||
self._tries = {} # (device, id) -> failed attempts this run
|
||||
self._errors = [] # this pass's, for the page
|
||||
self._s = self._load()
|
||||
|
||||
# ---- settings and what the page shows ----
|
||||
def _load(self):
|
||||
s = _defaults()
|
||||
try:
|
||||
saved = json.loads(self.path.read_text())
|
||||
if isinstance(saved, dict):
|
||||
s.update({k: v for k, v in saved.items() if k in s})
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
s["screenshots"], s["clips"] = bool(s["screenshots"]), bool(s["clips"])
|
||||
if not isinstance(s["synced"], dict):
|
||||
s["synced"] = {}
|
||||
return s
|
||||
|
||||
def _save(self):
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(self._s, indent=1))
|
||||
os.replace(tmp, self.path)
|
||||
except OSError:
|
||||
pass # the settings still apply until the app restarts
|
||||
|
||||
def enabled(self):
|
||||
with self._lock:
|
||||
return self._s["screenshots"] or self._s["clips"]
|
||||
|
||||
def state(self):
|
||||
with self._lock:
|
||||
s = self._s
|
||||
return {k: s[k] for k in ("screenshots", "clips", "since", "paused", "checked", "copied",
|
||||
"copied_time", "error", "waiting")} | {
|
||||
"pending": self._all, "running": self._running,
|
||||
"folders": {"shots": str(self.shots_folder), "clips": str(self.clips_folder)}}
|
||||
|
||||
def update(self, changes):
|
||||
"""{"screenshots": bool, "clips": bool}: turning it on starts the watermark now."""
|
||||
with self._lock:
|
||||
was = self.enabled()
|
||||
for key in ("screenshots", "clips"):
|
||||
if key in changes:
|
||||
self._s[key] = bool(changes[key])
|
||||
if self.enabled() and (not was or self._s["since"] is None):
|
||||
self._s["since"] = self.clock()
|
||||
if not self.enabled():
|
||||
self._s["error"], self._s["waiting"] = None, 0
|
||||
self._save()
|
||||
self._wake.set()
|
||||
return self.state()
|
||||
|
||||
def copy_all(self):
|
||||
"""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._save()
|
||||
self._wake.set()
|
||||
return self.state()
|
||||
|
||||
def resume(self):
|
||||
with self._lock:
|
||||
self._s["paused"], self._s["error"] = None, None
|
||||
self._save()
|
||||
self._backoff = 0
|
||||
self._wake.set()
|
||||
return self.state()
|
||||
|
||||
# ---- the worker ----
|
||||
def run(self, stop):
|
||||
"""Until `stop` (a threading.Event) is set."""
|
||||
while not stop.is_set():
|
||||
delay = self.tick()
|
||||
self._wake.wait(delay)
|
||||
self._wake.clear()
|
||||
|
||||
def tick(self):
|
||||
"""One look. -> seconds until the next."""
|
||||
with self._lock:
|
||||
want_all = self._all
|
||||
shots = self._s["screenshots"] or want_all
|
||||
clips = self._s["clips"] or want_all
|
||||
if not (shots or clips) or self._s["paused"]:
|
||||
return INTERVAL
|
||||
since = None if want_all else self._s["since"] or 0
|
||||
device = self.device()
|
||||
if not device:
|
||||
return INTERVAL # nothing connected: nothing to ask
|
||||
if self.busy():
|
||||
return BUSY_WAIT
|
||||
with self._lock:
|
||||
self._running = True
|
||||
self._errors = []
|
||||
try:
|
||||
copied, waiting, done = self._pass(device, shots, clips, since)
|
||||
except DiskFull as e:
|
||||
with self._lock:
|
||||
self._s.update(paused=str(e), error=str(e))
|
||||
self._save()
|
||||
return INTERVAL
|
||||
except Exception as e:
|
||||
friendly = self.offline(e)
|
||||
self._backoff = min(max(self._backoff * 2, INTERVAL * 2), MAX_BACKOFF)
|
||||
with self._lock:
|
||||
self._s["error"] = friendly or str(e)
|
||||
self._save()
|
||||
return self._backoff
|
||||
finally:
|
||||
with self._lock:
|
||||
self._running = False
|
||||
self._backoff = 0
|
||||
with self._lock:
|
||||
if copied:
|
||||
self._s.update(copied=copied, copied_time=self.clock())
|
||||
self._s.update(checked=self.clock(), waiting=waiting)
|
||||
if done:
|
||||
self._s["error"] = self._errors[0] if self._errors else None
|
||||
if want_all:
|
||||
self._all = False
|
||||
self._save()
|
||||
return INTERVAL if done else BUSY_WAIT
|
||||
|
||||
def _synced(self, device, kind):
|
||||
with self._lock:
|
||||
return set(self._s["synced"].get(device, {}).get(kind, []))
|
||||
|
||||
def _mark(self, device, kind, ids):
|
||||
with self._lock:
|
||||
per = self._s["synced"].setdefault(device, {})
|
||||
have = per.get(kind, [])
|
||||
new = [i for i in ids if i not in set(have)]
|
||||
if new:
|
||||
per[kind] = (have + new)[-KEEP:]
|
||||
self._save()
|
||||
|
||||
def _room(self, folder):
|
||||
if self.space(folder) < MIN_FREE:
|
||||
raise DiskFull(f"Paused: this computer is nearly out of space ({folder} has under "
|
||||
f"{MIN_FREE // 1024**2} MB free). Free some space, then press Resume.")
|
||||
|
||||
def _failed(self, device, item, error):
|
||||
if disk_full(error):
|
||||
raise DiskFull("Paused: this computer ran out of space while copying. Free some space, "
|
||||
"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}")
|
||||
|
||||
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"]
|
||||
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]
|
||||
for i in range(0, len(todo), BATCH):
|
||||
if self.busy():
|
||||
return copied, waiting, False
|
||||
self._room(self.shots_folder)
|
||||
batch = todo[i:i + BATCH]
|
||||
try:
|
||||
self.save_shots(device, batch)
|
||||
except Exception as e:
|
||||
for item in batch:
|
||||
self._failed(device, item, e)
|
||||
continue
|
||||
self._mark(device, "shots", batch)
|
||||
copied += len(batch)
|
||||
if clips:
|
||||
listing = [c for c in self.list_clips()["clips"] if c.get("kind") == "clip"]
|
||||
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:
|
||||
continue
|
||||
if since is not None and (c["time"] < since or c["id"] in done):
|
||||
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():
|
||||
return copied, waiting, False
|
||||
self._room(self.clips_folder)
|
||||
try:
|
||||
self.export_clip(device, c["id"])
|
||||
except Exception as e:
|
||||
self._failed(device, c["id"], e)
|
||||
continue
|
||||
self._mark(device, "clips", [c["id"]])
|
||||
copied += 1
|
||||
return copied, waiting, True
|
||||
@@ -0,0 +1,583 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Video from the headset: recordings of what it shows, with its sound, and Steam's own clips.
|
||||
|
||||
Two halves share this file.
|
||||
|
||||
On the computer, server.py builds the shell commands below, runs them over SSH and
|
||||
copies the finished .mp4 to ~/Videos/SteamFrame (Recorder does a recording from Start
|
||||
to the copy; export_clip does a Steam clip). Nothing here needs ffmpeg on the computer.
|
||||
|
||||
On the Frame, this file runs as `python3 - list` or `python3 - export ID` (sent on
|
||||
stdin, like frame_steam.py, so stdlib only): it lists Steam's game recordings and
|
||||
stitches one into a single .mp4 with SteamOS's ffmpeg, copying the streams as they are.
|
||||
It prints one JSON object, or {"error": ...}.
|
||||
|
||||
Steam's game recording keeps DASH segments, one folder per recording session:
|
||||
|
||||
~/.local/share/Steam/userdata/<account>/gamerecordings/
|
||||
video/bg_<appid>_<YYYYMMDD>_<HHMMSS>/ background recording
|
||||
clips/clip_<appid>_<YYYYMMDD>_<HHMMSS>/video/bg_<appid>_<...>/ a saved clip
|
||||
session.mpd
|
||||
init-stream0.m4s chunk-stream0-00001.m4s chunk-stream0-00002.m4s ...
|
||||
init-stream1.m4s chunk-stream1-00001.m4s ...
|
||||
|
||||
Each stream's init segment followed by its chunks, in order, is a fragmented MP4 of
|
||||
that stream. The folder names are as users and Steam's own files describe them; the
|
||||
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 json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# ---- where things go ---------------------------------------------------------
|
||||
|
||||
VIDEOS_DIR = Path.home() / "Videos" / "SteamFrame" # on the computer, beside ~/Pictures/SteamFrame
|
||||
# On the Frame, relative to its home: recordings in progress and stitched exports.
|
||||
# Not /tmp: it's a RAM disk there, and a recording can be over a gigabyte.
|
||||
WORK = ".cache/frame-control/video"
|
||||
|
||||
# ---- recording what the headset shows ------------------------------------------
|
||||
#
|
||||
# The same picture the live view streams: SteamVR's copy of the headset view in the
|
||||
# v4l2loopback device, encoded with x264 (the hardware encoder crashes ffmpeg). The
|
||||
# sound is the monitor of PipeWire's default output, read through its PulseAudio
|
||||
# server: SteamOS's ffmpeg has the pulse device (the media player writes to it, see
|
||||
# docs/vr-video.md). If it can't be read, the recording goes ahead without sound and
|
||||
# says so. Both inputs are stamped with the wall clock: the camera device and the
|
||||
# audio server would otherwise count from different clocks.
|
||||
|
||||
STREAM_DEVICE = "/dev/video99"
|
||||
AUDIO_SOURCE = "@DEFAULT_MONITOR@" # PulseAudio's name for the default output's monitor
|
||||
MAX_SECONDS = 20 * 60
|
||||
FPS = 30
|
||||
VIDEO_RATE = 8 # Mbit/s at 1080p
|
||||
AUDIO_RATE = 160 # kbit/s AAC
|
||||
# Room for a full-length recording twice over (the .mkv, then the .mp4 made from it).
|
||||
NEED_KB = (VIDEO_RATE * 1000 + AUDIO_RATE) * MAX_SECONDS // 8 * 2 + 512 * 1024
|
||||
REC_NAME = re.compile(r"rec-\d{8}-\d{6}")
|
||||
|
||||
|
||||
class ClipError(Exception):
|
||||
def __init__(self, message, status=502):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
def recording_name(now=None):
|
||||
return time.strftime("rec-%Y%m%d-%H%M%S", time.localtime(now))
|
||||
|
||||
|
||||
def probe_command():
|
||||
"""Checks before a recording, run on the Frame: prints audio=1 or audio=0.
|
||||
|
||||
One recording runs at a time, so anything left in the folder is from one that was
|
||||
cut off (the app quit, or the copy failed); it's removed to free the space."""
|
||||
return (f"set -u; export XDG_RUNTIME_DIR=${{XDG_RUNTIME_DIR:-/run/user/$(id -u)}}; "
|
||||
f"[ -e {STREAM_DEVICE} ] || {{ echo 'No headset view device ({STREAM_DEVICE}). Is SteamVR running?' >&2; exit 3; }}; "
|
||||
f"command -v ffmpeg >/dev/null || {{ echo 'ffmpeg is missing on the Frame' >&2; exit 3; }}; "
|
||||
f"d=\"$HOME/{WORK}\"; mkdir -p \"$d\" && rm -f \"$d\"/rec-*; "
|
||||
f"free=$(df -Pk \"$d\" | awk 'NR==2 {{print $4}}'); "
|
||||
f"[ \"${{free:-0}}\" -ge {NEED_KB} ] || {{ echo 'Not enough free space on the Frame to record: "
|
||||
f"it needs {NEED_KB // 1024 // 1024 + 1} GB' >&2; exit 4; }}; "
|
||||
f"if timeout 5 ffmpeg -hide_banner -loglevel error -nostdin -f pulse -i {AUDIO_SOURCE} -t 0.2 -f null - "
|
||||
f">/dev/null 2>&1; then echo audio=1; else echo audio=0; fi")
|
||||
|
||||
|
||||
def record_command(name, audio, max_seconds=MAX_SECONDS):
|
||||
"""The recording, run on the Frame through `bash -c`.
|
||||
|
||||
ffmpeg runs in the background while the shell watches its stdin, which is the SSH
|
||||
channel: when that closes (Stop closed it, or the computer's ssh went away) the
|
||||
shell sends ffmpeg SIGTERM, which makes it finish the file properly (as q or
|
||||
Ctrl-C would; SIGINT itself is ignored by a background job of a script). A recording
|
||||
that ends by itself (the time limit, an error) ends the wait too. The shell prints
|
||||
"started" once ffmpeg is running and "exit N" when it has gone."""
|
||||
if not REC_NAME.fullmatch(name):
|
||||
raise ValueError(f"bad recording name {name!r}")
|
||||
if not 1 <= max_seconds <= MAX_SECONDS:
|
||||
raise ValueError("max_seconds out of range")
|
||||
clock = "-thread_queue_size 1024 -use_wallclock_as_timestamps 1"
|
||||
inputs = f"{clock} -f v4l2 -video_size 1920x1080 -i {STREAM_DEVICE}"
|
||||
maps = "-map 0:v:0"
|
||||
codecs = (f"-vf fps={FPS},scale=-2:1080,format=yuv420p -c:v libx264 -preset ultrafast "
|
||||
f"-b:v {VIDEO_RATE}M -maxrate {VIDEO_RATE}M -bufsize {VIDEO_RATE * 2}M -g {FPS * 2}")
|
||||
if audio:
|
||||
inputs += f" {clock} -f pulse -i {AUDIO_SOURCE}"
|
||||
maps += " -map 1:a:0"
|
||||
codecs += f" -af aresample=async=1 -c:a aac -b:a {AUDIO_RATE}k"
|
||||
script = (f"export XDG_RUNTIME_DIR=${{XDG_RUNTIME_DIR:-/run/user/$(id -u)}}; d=\"$HOME/{WORK}\"; "
|
||||
f"ffmpeg -hide_banner -loglevel error -nostdin -y {inputs} {maps} {codecs} -t {max_seconds} "
|
||||
f"\"$d/{name}.mkv\" </dev/null >/dev/null 2>\"$d/{name}.log\" & p=$!; "
|
||||
f"echo started; "
|
||||
f"while kill -0 $p 2>/dev/null; do read -r -t 1 _; r=$?; [ $r -eq 0 ] || [ $r -gt 128 ] || break; done; "
|
||||
f"kill $p 2>/dev/null; wait $p; echo \"exit $?\"")
|
||||
return "bash -c " + shlex.quote(script)
|
||||
|
||||
|
||||
def finish_command(name):
|
||||
"""After the recording: make the .mp4 the computer gets (no re-encode), on the Frame."""
|
||||
if not REC_NAME.fullmatch(name):
|
||||
raise ValueError(f"bad recording name {name!r}")
|
||||
return (f"cd \"$HOME/{WORK}\" || exit 1; "
|
||||
f"[ -s {name}.mkv ] || {{ tail -c 2000 {name}.log >&2 2>/dev/null; echo 'The recording is empty' >&2; exit 1; }}; "
|
||||
f"ffmpeg -hide_banner -loglevel error -nostdin -y -i {name}.mkv -map 0 -c copy -movflags +faststart {name}.mp4 "
|
||||
f"&& rm -f {name}.mkv && wc -c < {name}.mp4")
|
||||
|
||||
|
||||
def cleanup_command(name):
|
||||
if not REC_NAME.fullmatch(name):
|
||||
raise ValueError(f"bad recording name {name!r}")
|
||||
return f"rm -f \"$HOME/{WORK}/{name}\".*"
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""One recording at a time, from Start to the .mp4 on this computer.
|
||||
|
||||
The server supplies how to reach the Frame, so this can be tested without one:
|
||||
open_channel(command, stderr) -> Popen of `command` on the Frame, stdin and stdout pipes
|
||||
run(command, timeout) -> its stdout; raises on failure
|
||||
fetch(remote, local) copies a file from the Frame (a path relative to its home)
|
||||
start_thread(fn) starts fn in a thread that counts as headset work
|
||||
release(proc) forgets a channel once it has closed
|
||||
"""
|
||||
|
||||
def __init__(self, open_channel, run, fetch, start_thread, release=lambda proc: None, folder=None):
|
||||
self.open_channel, self.run, self.fetch = open_channel, run, fetch
|
||||
self.start_thread, self.release = start_thread, release
|
||||
self.folder = Path(folder) if folder else VIDEOS_DIR
|
||||
self._lock = threading.Lock()
|
||||
self._state = {"state": "idle"}
|
||||
self._proc = None
|
||||
|
||||
def status(self):
|
||||
with self._lock:
|
||||
s = dict(self._state)
|
||||
if s["state"] == "recording":
|
||||
s["elapsed"] = round(time.time() - s["started"], 1)
|
||||
return s
|
||||
|
||||
def start(self):
|
||||
with self._lock:
|
||||
if self._state["state"] in ("starting", "recording", "stopping", "saving"):
|
||||
raise ClipError("Already recording, or still saving the last recording", 409)
|
||||
self._state = {"state": "starting"}
|
||||
try:
|
||||
out = self.run(probe_command(), 30)
|
||||
audio = "audio=1" in out
|
||||
name = recording_name()
|
||||
errors = tempfile.TemporaryFile()
|
||||
proc = self.open_channel(record_command(name, audio), errors)
|
||||
lines = _line_reader(proc.stdout)
|
||||
first = _next_line(lines, 20)
|
||||
if first != "started":
|
||||
_end(proc)
|
||||
errors.seek(0)
|
||||
err = errors.read().decode(errors="replace").strip()
|
||||
errors.close()
|
||||
self.release(proc)
|
||||
raise ClipError(err or "The Frame didn't start recording")
|
||||
except BaseException as e:
|
||||
with self._lock:
|
||||
self._state = {"state": "failed", "error": str(e)}
|
||||
raise
|
||||
with self._lock:
|
||||
self._proc = proc
|
||||
self._state = {"state": "recording", "name": name, "audio": audio, "started": time.time(),
|
||||
"limit": MAX_SECONDS}
|
||||
try:
|
||||
self.start_thread(lambda: self._watch(proc, name, audio, lines, errors))
|
||||
except BaseException:
|
||||
_end(proc)
|
||||
self.release(proc)
|
||||
with self._lock:
|
||||
self._state, self._proc = {"state": "failed", "error": "Couldn't follow the recording"}, None
|
||||
raise
|
||||
return dict(self.status(), message="Recording" + ("" if audio else
|
||||
" without sound: the Frame's audio couldn't be read"))
|
||||
|
||||
def stop(self):
|
||||
"""Ends the recording; the copy to this computer carries on in the background."""
|
||||
with self._lock:
|
||||
if self._state["state"] != "recording":
|
||||
raise ClipError("Not recording", 409)
|
||||
self._state = dict(self._state, state="stopping")
|
||||
proc = self._proc
|
||||
_close(proc.stdin) # the shell on the Frame sees EOF and stops ffmpeg
|
||||
return {"state": "stopping", "message": "Stopping the recording…"}
|
||||
|
||||
def _watch(self, proc, name, audio, lines, errors):
|
||||
# Waits for ffmpeg to finish: after Stop, or at the time limit (a margin
|
||||
# past it, so a stuck channel can't hold the recording open forever).
|
||||
deadline = time.time() + MAX_SECONDS + 60
|
||||
while True:
|
||||
stopping = self.status()["state"] == "stopping"
|
||||
line = _next_line(lines, 30 if stopping else 5)
|
||||
if line is None or line.startswith("exit "):
|
||||
break # ffmpeg has gone and the file is finished
|
||||
if line == "" and (stopping or time.time() > deadline):
|
||||
break # no answer within 30 s of Stop, or well past the limit: close the channel
|
||||
_end(proc)
|
||||
errors.close()
|
||||
self.release(proc)
|
||||
with self._lock:
|
||||
self._proc = None
|
||||
seconds = round(time.time() - self._state["started"])
|
||||
self._state = dict(self._state, state="saving")
|
||||
try:
|
||||
self.save(name)
|
||||
msg = f"Saved {name}.mp4 to ~/Videos/SteamFrame" + ("" if audio else " (no sound)")
|
||||
state = {"state": "saved", "file": str(self.folder / f"{name}.mp4"), "audio": audio,
|
||||
"seconds": seconds, "message": msg}
|
||||
except Exception as e:
|
||||
state = {"state": "failed", "error": f"Couldn't save the recording: {e}"}
|
||||
finally:
|
||||
try:
|
||||
self.run(cleanup_command(name), 20)
|
||||
except Exception:
|
||||
pass # the next recording's checks remove it
|
||||
with self._lock:
|
||||
self._state = state
|
||||
|
||||
def save(self, name):
|
||||
"""Make the .mp4 on the Frame and copy it here, never leaving a partial file in the folder."""
|
||||
out = self.run(finish_command(name), 600)
|
||||
size = int(out.strip().splitlines()[-1]) if out.strip() else 0
|
||||
_make_room(self.folder, size)
|
||||
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=self.folder))
|
||||
try:
|
||||
self.fetch(f"{WORK}/{name}.mp4", incoming / f"{name}.mp4")
|
||||
os.replace(incoming / f"{name}.mp4", self.folder / f"{name}.mp4")
|
||||
finally:
|
||||
shutil.rmtree(incoming, ignore_errors=True)
|
||||
|
||||
|
||||
def _make_room(folder, size):
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
free = shutil.disk_usage(folder).free
|
||||
if size and free < size + 256 * 1024**2:
|
||||
raise ClipError(f"Not enough free space on this computer: it needs {size // 1024**2 + 256} MB", 507)
|
||||
|
||||
|
||||
def _close(f):
|
||||
try:
|
||||
f.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _end(proc):
|
||||
"""Close our end of the channel and make sure the local ssh has gone."""
|
||||
_close(proc.stdin)
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
_close(proc.stdout)
|
||||
|
||||
|
||||
def _line_reader(pipe):
|
||||
"""Lines from a pipe via a thread (select() can't wait on pipes on Windows); None at EOF."""
|
||||
import queue
|
||||
lines = queue.Queue()
|
||||
|
||||
def pump():
|
||||
try:
|
||||
for raw in iter(pipe.readline, b""):
|
||||
lines.put(raw.decode(errors="replace").strip())
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
lines.put(None)
|
||||
|
||||
threading.Thread(target=pump, daemon=True).start()
|
||||
return lines
|
||||
|
||||
|
||||
def _next_line(lines, timeout):
|
||||
"""The next line, None at EOF, or "" after `timeout` seconds of silence."""
|
||||
import queue
|
||||
try:
|
||||
return lines.get(timeout=timeout)
|
||||
except queue.Empty:
|
||||
return ""
|
||||
|
||||
|
||||
# ---- Steam's game recordings ---------------------------------------------------
|
||||
|
||||
STEAM_USERDATA = ".local/share/Steam/userdata"
|
||||
KINDS = {"clips": "clip", "video": "background"}
|
||||
MAX_DEPTH = 4 # folders below clips/ or video/
|
||||
PART = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}") # no leading dot, so never "." or ".."
|
||||
INIT = re.compile(r"init-stream(\d{1,3})\.m4s")
|
||||
CHUNK = re.compile(r"chunk-stream(\d{1,3})-(\d{1,9})\.m4s")
|
||||
STAMP = re.compile(r"(?:^|_)(\d{1,10})_(\d{8})_(\d{6})$")
|
||||
EXPORT_PATH = re.compile(re.escape(WORK) + r"/export-[0-9a-f]{16}/[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}\.mp4")
|
||||
|
||||
|
||||
def parse_id(clip_id):
|
||||
"""A recording's id, "<account>/<clips|video>/<folder>[/<folder>...]", as path parts.
|
||||
|
||||
Checked before it goes near a path or a shell: plain names only, no "." or "..",
|
||||
at most MAX_DEPTH folders. Raises ValueError."""
|
||||
parts = clip_id.split("/") if isinstance(clip_id, str) and len(clip_id) <= 600 else []
|
||||
if (len(parts) < 3 or len(parts) > 2 + MAX_DEPTH or not re.fullmatch(r"\d{1,12}", parts[0])
|
||||
or parts[1] not in KINDS or not all(PART.fullmatch(p) for p in parts[2:])):
|
||||
raise ValueError("bad clip id")
|
||||
return parts
|
||||
|
||||
|
||||
def export_name(clip_id):
|
||||
"""The .mp4's name on the computer: the clip's (or background session's) own folder name."""
|
||||
return parse_id(clip_id)[2] + ".mp4"
|
||||
|
||||
|
||||
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."""
|
||||
inits, chunks = {}, {}
|
||||
for entry in os.scandir(folder):
|
||||
if not entry.is_file(follow_symlinks=False):
|
||||
continue
|
||||
m = INIT.fullmatch(entry.name)
|
||||
if m:
|
||||
inits[int(m[1])] = entry.path
|
||||
continue
|
||||
m = CHUNK.fullmatch(entry.name)
|
||||
if m:
|
||||
chunks.setdefault(int(m[1]), []).append((int(m[2]), entry.path))
|
||||
return {n: [inits[n], *(p for _, p in sorted(chunks[n]))] for n in sorted(inits) if chunks.get(n)}
|
||||
|
||||
|
||||
def chunk_gaps(files):
|
||||
"""How many chunk numbers are missing between the first and last (Steam deleted or lost them)."""
|
||||
nums = sorted(int(CHUNK.fullmatch(os.path.basename(f))[2]) for f in files[1:])
|
||||
return nums[-1] - nums[0] + 1 - len(nums) if nums else 0
|
||||
|
||||
|
||||
def mpd_duration(text):
|
||||
"""Seconds from the manifest's mediaPresentationDuration ("PT1M23.4S"), or None."""
|
||||
m = re.search(r'mediaPresentationDuration="P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?"', text or "")
|
||||
if not m or not any(m.groups()):
|
||||
return None
|
||||
d, h, mi, s = m.groups()
|
||||
try:
|
||||
return int(d or 0) * 86400 + int(h or 0) * 3600 + int(mi or 0) * 60 + float(s or 0)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _sessions(base, depth=0):
|
||||
"""Folders under base (clips/ or video/) that hold init segments, without following links."""
|
||||
try:
|
||||
entries = list(os.scandir(base))
|
||||
except OSError:
|
||||
return
|
||||
if any(e.is_file(follow_symlinks=False) and INIT.fullmatch(e.name) for e in entries):
|
||||
yield base
|
||||
return
|
||||
if depth < MAX_DEPTH:
|
||||
for e in sorted(entries, key=lambda e: e.name):
|
||||
if e.is_dir(follow_symlinks=False) and PART.fullmatch(e.name):
|
||||
yield from _sessions(e.path, depth + 1)
|
||||
|
||||
|
||||
def describe(userdata, clip_id):
|
||||
"""What the list shows about one recording, or None if it holds nothing playable."""
|
||||
parts = parse_id(clip_id)
|
||||
folder = os.path.join(userdata, parts[0], "gamerecordings", *parts[1:])
|
||||
streams = read_session(folder)
|
||||
if not streams:
|
||||
return None
|
||||
files = [f for s in streams.values() for f in s]
|
||||
stats = [os.stat(f) for f in files]
|
||||
duration = None
|
||||
for mpd in sorted(p for p in os.listdir(folder) if p.endswith(".mpd")):
|
||||
try:
|
||||
duration = mpd_duration(Path(folder, mpd).read_text(errors="replace")[:20000])
|
||||
except OSError:
|
||||
pass
|
||||
if duration is not None:
|
||||
break
|
||||
newest = max(s.st_mtime for s in stats)
|
||||
appid, when = None, newest
|
||||
for p in parts[2:]:
|
||||
m = STAMP.search(p)
|
||||
if m:
|
||||
appid = m[1]
|
||||
try:
|
||||
when = time.mktime(time.strptime(m[2] + m[3], "%Y%m%d%H%M%S"))
|
||||
except ValueError:
|
||||
pass
|
||||
break
|
||||
return {"id": clip_id, "kind": KINDS[parts[1]], "appid": appid, "time": when,
|
||||
"size": sum(s.st_size for s in stats), "streams": len(streams),
|
||||
"chunks": sum(len(s) - 1 for s in streams.values()),
|
||||
"gaps": sum(chunk_gaps(s) for s in streams.values()), "duration": duration,
|
||||
"file": export_name(clip_id),
|
||||
# Seconds since Steam last wrote to it, by the Frame's clock: still recording if small.
|
||||
"idle": round(max(0.0, time.time() - newest), 1)}
|
||||
|
||||
|
||||
def list_recordings(userdata):
|
||||
"""Every Steam recording session on this Frame, newest first (runs on the Frame)."""
|
||||
found = []
|
||||
try:
|
||||
accounts = sorted(e.name for e in os.scandir(userdata) if e.is_dir(follow_symlinks=False)
|
||||
and re.fullmatch(r"\d{1,12}", e.name))
|
||||
except OSError:
|
||||
return []
|
||||
for account in accounts:
|
||||
root = os.path.join(userdata, account, "gamerecordings")
|
||||
for kind in KINDS:
|
||||
for folder in _sessions(os.path.join(root, kind)):
|
||||
clip_id = "/".join([account, *os.path.relpath(folder, root).split(os.sep)])
|
||||
try:
|
||||
info = describe(userdata, clip_id)
|
||||
except (OSError, ValueError):
|
||||
continue # changed while we looked, or a name we don't take
|
||||
if info:
|
||||
found.append(info)
|
||||
found.sort(key=lambda c: c["time"], reverse=True)
|
||||
return found
|
||||
|
||||
|
||||
def stitch_command(inputs, out):
|
||||
"""ffmpeg that puts each stream file in one .mp4, copying the streams as they are."""
|
||||
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-nostdin", "-y"]
|
||||
for f in inputs:
|
||||
cmd += ["-i", str(f)]
|
||||
for i in range(len(inputs)):
|
||||
cmd += ["-map", str(i)]
|
||||
return cmd + ["-c", "copy", "-movflags", "+faststart", str(out)]
|
||||
|
||||
|
||||
def export(userdata, clip_id, home=None):
|
||||
"""Stitch one recording into an .mp4 under WORK (runs on the Frame). -> its path
|
||||
relative to home and its size. The computer copies it, then deletes it with
|
||||
remove_command."""
|
||||
home = Path(home or Path.home())
|
||||
parts = parse_id(clip_id)
|
||||
folder = Path(userdata, parts[0], "gamerecordings", *parts[1:])
|
||||
real = os.path.realpath(folder)
|
||||
if os.path.commonpath([real, os.path.realpath(userdata)]) != os.path.realpath(userdata):
|
||||
raise ValueError("bad clip id")
|
||||
streams = read_session(real)
|
||||
if not streams:
|
||||
raise ClipError("That recording has no complete streams on the Frame", 404)
|
||||
size = sum(os.path.getsize(f) for s in streams.values() for f in s)
|
||||
work = home / WORK
|
||||
work.mkdir(parents=True, exist_ok=True)
|
||||
_sweep(work)
|
||||
# The stream files and the .mp4 made from them are each about the recording's size.
|
||||
if shutil.disk_usage(work).free < size * 2 + 64 * 1024**2:
|
||||
raise ClipError(f"Not enough free space on the Frame to export it: it needs {(size * 2) // 1024**2 + 64} MB", 507)
|
||||
out_dir = work / f"export-{secrets.token_hex(8)}"
|
||||
out_dir.mkdir()
|
||||
try:
|
||||
inputs = []
|
||||
for n, files in streams.items():
|
||||
path = out_dir / f"stream{n}.mp4"
|
||||
with open(path, "wb") as dst:
|
||||
for f in files:
|
||||
with open(f, "rb") as src:
|
||||
shutil.copyfileobj(src, dst, 1 << 20)
|
||||
inputs.append(path)
|
||||
out = out_dir / export_name(clip_id)
|
||||
r = subprocess.run(stitch_command(inputs, out), capture_output=True, text=True, errors="replace", timeout=1800)
|
||||
if r.returncode != 0 or not out.exists():
|
||||
raise ClipError("ffmpeg couldn't join the recording: " + (r.stderr.strip()[-1500:] or f"exit {r.returncode}"))
|
||||
for f in inputs:
|
||||
f.unlink()
|
||||
except BaseException:
|
||||
shutil.rmtree(out_dir, ignore_errors=True)
|
||||
raise
|
||||
return {"path": out.relative_to(home).as_posix(), "size": out.stat().st_size}
|
||||
|
||||
|
||||
def _sweep(work, older=6 * 3600):
|
||||
"""Exports left behind by a copy that never finished."""
|
||||
for d in work.glob("export-*"):
|
||||
try:
|
||||
if time.time() - d.stat().st_mtime > older:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def remove_command(path):
|
||||
"""Delete an export on the Frame once it's been copied (path as export returned it)."""
|
||||
if not EXPORT_PATH.fullmatch(path or ""):
|
||||
raise ValueError(f"unexpected export path {path!r}")
|
||||
return f"rm -rf \"$HOME\"/{shlex.quote(path.rsplit('/', 1)[0])}"
|
||||
|
||||
|
||||
# ---- the computer's side of Steam clips -------------------------------------------
|
||||
#
|
||||
# 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):
|
||||
"""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()
|
||||
return {"clips": clips, "folder": str(folder)}
|
||||
|
||||
|
||||
def export_clip(clip_id, run_frame, run, fetch, folder=None):
|
||||
"""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
|
||||
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
|
||||
try:
|
||||
_make_room(folder, int(made.get("size") or 0))
|
||||
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=folder))
|
||||
try:
|
||||
fetch(path, incoming / name)
|
||||
os.replace(incoming / name, folder / name)
|
||||
finally:
|
||||
shutil.rmtree(incoming, ignore_errors=True)
|
||||
finally:
|
||||
try:
|
||||
run(remove, 30)
|
||||
except Exception:
|
||||
pass # the next export sweeps it
|
||||
return {"message": f"Saved {name} to ~/Videos/SteamFrame", "file": str(folder / name)}
|
||||
|
||||
|
||||
# ---- on the Frame -----------------------------------------------------------------
|
||||
|
||||
def main(argv):
|
||||
userdata = Path.home() / STEAM_USERDATA
|
||||
try:
|
||||
if argv[:1] == ["list"]:
|
||||
result = {"clips": list_recordings(userdata)}
|
||||
elif len(argv) == 2 and argv[0] == "export":
|
||||
result = export(userdata, argv[1])
|
||||
else:
|
||||
result = {"error": "usage: frame_clips.py list | export ID"}
|
||||
except (ClipError, ValueError, OSError, subprocess.TimeoutExpired) as e:
|
||||
result = {"error": str(e)}
|
||||
print(json.dumps(result))
|
||||
return 1 if "error" in result else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
+177
-7
@@ -388,6 +388,7 @@
|
||||
.popt { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 0 0 14px; cursor: pointer; }
|
||||
.popt input { margin: 3px 0 0; width: 16px; height: 16px; accent-color: var(--blue); }
|
||||
.popt b { font-weight: 600; color: var(--text); }
|
||||
.sync-note { margin: 10px 0 0; }
|
||||
.popt .sub { grid-column: 2; line-height: 1.45; }
|
||||
.sentlog { max-height: 260px; overflow: auto; background: rgba(0,0,0,.3); border-radius: 3px; padding: 10px;
|
||||
font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-all; margin: 8px 0 0; }
|
||||
@@ -1182,6 +1183,7 @@
|
||||
<span class="view-actions">
|
||||
<button class="action" id="shotBtn">Capture</button>
|
||||
<button id="liveBtn" title="Keep updating, as video">Live</button>
|
||||
<button id="recBtn" title="Record what the headset shows, with its sound, to ~/Videos/SteamFrame on this computer">Record</button>
|
||||
<button id="ctrlBtn" title="Control the Frame by tapping or clicking on the view (C)">Control</button>
|
||||
<button id="copyBtn" disabled title="Copy the image to the clipboard (or right-click it)">Copy</button>
|
||||
<button id="saveBtn" disabled>Save</button>
|
||||
@@ -1226,6 +1228,7 @@
|
||||
<button data-ctrl-key="28" title="Enter">Enter</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hint" id="recStatus" role="status" hidden></div>
|
||||
<div class="hint" id="viewHint"></div>
|
||||
</section>
|
||||
|
||||
@@ -1245,8 +1248,18 @@
|
||||
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
|
||||
</div>
|
||||
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
|
||||
<div class="sub sync-note" id="shotsSyncNote" hidden></div>
|
||||
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. New ones appear on their own. Click one to open it in the viewer, Copy puts it on the clipboard, and Save copies it to <code>~/Pictures/SteamFrame</code>. Right-click for more.</div>
|
||||
</section>
|
||||
<section id="clips">
|
||||
<div class="shelf-head"><h2>Steam clips</h2><span class="count" id="clipCount"></span><span class="spacer"></span>
|
||||
<button class="small" id="clipsRefresh">Refresh</button>
|
||||
<button class="small" id="videosFolder" title="Open the SteamFrame folder in your Videos">Show folder</button>
|
||||
</div>
|
||||
<div class="list" id="clipList"><div class="sub">Loading…</div></div>
|
||||
<div class="sub sync-note" id="clipsSyncNote" hidden></div>
|
||||
<div class="hint">Clips and background recordings from Steam's game recording in the headset. Export joins one into a single <code>.mp4</code> in <code>~/Videos/SteamFrame</code>, however long it is, without re-encoding it. Recordings you make with <b>Record</b> go there too.</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="page" data-page="games">
|
||||
@@ -1602,6 +1615,21 @@
|
||||
<div id="steamGridStatus" class="hint" role="status"></div>
|
||||
<button id="refreshAndroidArt" class="small" style="margin-top:12px">Refresh artwork for installed apps and titles</button>
|
||||
</section>
|
||||
<section class="panel" id="autoSync">
|
||||
<div class="shelf-head"><h2>Copy to this computer</h2></div>
|
||||
<p class="sub">Copy new screenshots and clips to this computer automatically, about once a minute while a headset is connected.</p>
|
||||
<label class="popt"><input type="checkbox" id="syncShots"><b>Screenshots</b>
|
||||
<span class="sub">Into <code>~/Pictures/SteamFrame</code>, as Save does.</span></label>
|
||||
<label class="popt"><input type="checkbox" id="syncClips"><b>Steam clips</b>
|
||||
<span class="sub">Clips you save in Steam, as <code>.mp4</code> files in <code>~/Videos/SteamFrame</code>, once Steam has finished
|
||||
writing them. Steam's background recording isn't copied.</span></label>
|
||||
<div class="row"><button class="small" id="syncNow">Copy everything now</button>
|
||||
<button class="small action" id="syncResume" hidden>Resume</button></div>
|
||||
<div class="hint" id="syncStatus" role="status"></div>
|
||||
<div class="hint">Only what you take after turning it on is copied; Copy everything now copies the rest, once. A file you
|
||||
delete from this computer isn't copied again. It copies from the headset you're connected to, and waits while an
|
||||
install, an export or a recording is running.</div>
|
||||
</section>
|
||||
<section class="panel" id="power">
|
||||
<div class="shelf-head"><h2>Remote access</h2></div>
|
||||
<div class="actions">
|
||||
@@ -1689,7 +1717,7 @@
|
||||
</div>
|
||||
<div class="page" data-page="settings">
|
||||
<div class="workspace-heading"><div><a href="#settings" id="settingsBack" class="workspace-link" hidden>← All settings</a><h1 id="settingsTitle">Settings</h1><p id="settingsSubtitle">Connection, headset controls and Frame Control preferences.</p></div></div>
|
||||
<div id="settingsDirectory" class="quick-routes"><a href="#devices"><span>Devices & connection</span><small>Connect a Frame or switch headsets</small></a><a href="#control"><span>Control Center</span><small>Live metrics and searchable controls</small></a><a href="#home"><span>Frame activity</span><small>Readiness, running apps and downloads</small></a><a href="#display"><span>App display & reports</span><small>Resolution, scale and compatibility</small></a><a href="#artworkSettings"><span>Library artwork</span><small>Images and artwork sources</small></a><a href="#privacy"><span>Privacy & updates</span><small>Sharing preferences and app updates</small></a><a href="#power"><span>Remote & power</span><small>Remote access, sleep and restart</small></a></div>
|
||||
<div id="settingsDirectory" class="quick-routes"><a href="#devices"><span>Devices & connection</span><small>Connect a Frame or switch headsets</small></a><a href="#control"><span>Control Center</span><small>Live metrics and searchable controls</small></a><a href="#home"><span>Frame activity</span><small>Readiness, running apps and downloads</small></a><a href="#display"><span>App display & reports</span><small>Resolution, scale and compatibility</small></a><a href="#artworkSettings"><span>Library artwork</span><small>Images and artwork sources</small></a><a href="#autoSync" id="syncLink"><span>Copy to this computer</span><small>New screenshots and clips, automatically</small></a><a href="#privacy"><span>Privacy & updates</span><small>Sharing preferences and app updates</small></a><a href="#power"><span>Remote & power</span><small>Remote access, sleep and restart</small></a></div>
|
||||
<div id="settingsWorkspace" hidden></div>
|
||||
</div>
|
||||
|
||||
@@ -2016,9 +2044,11 @@ function applyHostWording(h) {
|
||||
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
|
||||
document.body.classList.toggle("mobile", !!h.mobile);
|
||||
$("shotsFolder").hidden = !!h.mobile;
|
||||
$("recBtn").hidden = $("clips").hidden = !!h.mobile; // recordings are saved to a computer
|
||||
$("syncLink").hidden = !!h.mobile; // and so are automatic copies
|
||||
$("quest").hidden = !!h.mobile;
|
||||
$("metaLib").hidden = !!h.mobile; // the Quest plugs into a computer; the phone app's server runs on the Frame
|
||||
if (!h.mobile) $("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
|
||||
if (!h.mobile) $("shotsFolder").textContent = $("videosFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
|
||||
$("shotsSaveNew").textContent = h.mobile ? `Save all to ${h.computer}` : `Save new to ${h.computer}`;
|
||||
$("clipMac").textContent = `Send ${h.computer} clipboard`;
|
||||
$("clipFrom").textContent = `Copy the headset's clipboard to this ${h.computer}`;
|
||||
@@ -2132,7 +2162,7 @@ function setOnline(ok, why) {
|
||||
reloadAll();
|
||||
}
|
||||
}
|
||||
function reloadAll() { loadTitles(); loadOwned(); loadAndroid(); loadDisplays(); loadShots(); }
|
||||
function reloadAll() { loadTitles(); loadOwned(); loadAndroid(); loadDisplays(); loadShots(); loadClips(); }
|
||||
$("offRetry").onclick = () => refresh();
|
||||
if (window.frameApp && window.frameApp.setUpConnection) {
|
||||
$("offSetup").hidden = false;
|
||||
@@ -5133,6 +5163,146 @@ $("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/ap
|
||||
// Watch for new shots: a cheap listing over the shared SSH connection while the
|
||||
// window is visible and the Frame is reachable, and again on coming back to it.
|
||||
setInterval(() => { if (!document.hidden && online) loadShots(true); }, SHOTS_POLL_MS);
|
||||
|
||||
// ---- Copying new screenshots and clips here on their own (ui/frame_autosync.py) ----
|
||||
const autoSync = { s: null, copied: null };
|
||||
const syncWhen = t => new Date(t * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
|
||||
function renderAutoSync(s) {
|
||||
autoSync.s = s;
|
||||
$("syncShots").checked = s.screenshots; $("syncClips").checked = s.clips;
|
||||
$("syncResume").hidden = !s.paused;
|
||||
const on = s.screenshots || s.clips, lines = [];
|
||||
if (s.paused) lines.push(s.paused);
|
||||
else if (s.error) lines.push(`Last problem: ${s.error}`);
|
||||
if (s.running || s.pending) lines.push("Copying…");
|
||||
if (s.copied_time) lines.push(`Last copied ${s.copied} file${s.copied === 1 ? "" : "s"} on ${syncWhen(s.copied_time)}.`);
|
||||
if (on && s.checked) lines.push(`Last checked ${syncWhen(s.checked)}.`);
|
||||
if (on && s.waiting) lines.push(`${s.waiting} clip${s.waiting === 1 ? " is" : "s are"} still being written; copied once Steam finishes.`);
|
||||
if (on && s.since) lines.push(`Copying what's new since ${syncWhen(s.since)}.`);
|
||||
$("syncStatus").textContent = lines.length ? lines.join(" ") : "Off.";
|
||||
for (const [id, key, what] of [["shotsSyncNote", "screenshots", "New screenshots"], ["clipsSyncNote", "clips", "New Steam clips"]]) {
|
||||
$(id).hidden = !s[key] || !!HOST.mobile;
|
||||
$(id).innerHTML = s.paused ? `${esc(s.paused)} <a href="#autoSync">Settings</a>`
|
||||
: `${what} are copied to this ${esc(HOST.computer)} automatically${s.copied_time ? `; last copy ${esc(syncWhen(s.copied_time))}` : ""}. <a href="#autoSync">Change</a>`;
|
||||
}
|
||||
// Something new arrived here: show it as saved.
|
||||
if (autoSync.copied !== null && s.copied_time !== autoSync.copied) { loadShots(true); if (!HOST.mobile) loadClips(); }
|
||||
autoSync.copied = s.copied_time;
|
||||
}
|
||||
async function loadAutoSync() {
|
||||
try { renderAutoSync(await api("/api/autosync")); } catch {} // not on the iPhone app's server
|
||||
}
|
||||
async function setAutoSync(body, label) {
|
||||
try { renderAutoSync(await api("/api/autosync", body)); }
|
||||
catch (e) { toast(`${label} failed: ${e.message}`, true); loadAutoSync(); }
|
||||
}
|
||||
$("syncShots").onchange = e => setAutoSync({ action: "settings", screenshots: e.target.checked }, "Saving");
|
||||
$("syncClips").onchange = e => setAutoSync({ action: "settings", clips: e.target.checked }, "Saving");
|
||||
$("syncNow").onclick = async e => { const r = await act("Copy everything now", () => api("/api/autosync", { action: "now" }), e.currentTarget); if (r) renderAutoSync(r); };
|
||||
$("syncResume").onclick = async e => { const r = await act("Resume copying", () => api("/api/autosync", { action: "resume" }), e.currentTarget); if (r) renderAutoSync(r); };
|
||||
loadAutoSync();
|
||||
setInterval(() => { const s = autoSync.s; if (!document.hidden && s && (s.screenshots || s.clips || s.pending || s.running)) loadAutoSync(); }, 20000);
|
||||
|
||||
// ---- Recording the headset view, and Steam's own clips (server.py, frame_clips.py) ----
|
||||
// A recording runs on the Frame until Stop; the server then copies the .mp4 here
|
||||
// and the page follows it by polling /api/record.
|
||||
const rec = { state: "idle", timer: null };
|
||||
const clock = t => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`;
|
||||
function showRec(s) {
|
||||
const was = rec.state;
|
||||
rec.state = s.state;
|
||||
const busy = ["starting", "recording", "stopping", "saving"].includes(s.state);
|
||||
const b = $("recBtn");
|
||||
b.textContent = s.state === "recording" ? "Stop" : "Record";
|
||||
b.classList.toggle("on", s.state === "recording");
|
||||
b.disabled = busy && s.state !== "recording";
|
||||
const line = $("recStatus");
|
||||
line.textContent = s.state === "recording" ? `Recording ${clock(s.elapsed || 0)} (stops by itself at ${clock(s.limit)})` +
|
||||
(s.audio ? "" : ". No sound: the Frame's audio couldn't be read, so this is video only.")
|
||||
: s.state === "starting" ? "Starting the recording…"
|
||||
: s.state === "stopping" ? "Stopping the recording…"
|
||||
: s.state === "saving" ? `Copying the recording to this ${HOST.computer}…`
|
||||
: s.state === "saved" ? s.message
|
||||
: s.state === "failed" ? `Recording failed: ${s.error}` : "";
|
||||
line.hidden = !line.textContent;
|
||||
if (was !== s.state && was !== "idle" && s.state === "saved") { log(s.message, "ok"); toast(s.message); }
|
||||
if (was !== s.state && was !== "idle" && was !== "starting" && s.state === "failed") { log(line.textContent, "e"); toast(line.textContent, true); }
|
||||
clearTimeout(rec.timer);
|
||||
if (busy) rec.timer = setTimeout(pollRec, 1000);
|
||||
}
|
||||
async function pollRec() {
|
||||
if (HOST.mobile) return;
|
||||
clearTimeout(rec.timer);
|
||||
let s;
|
||||
try { s = await api("/api/record"); }
|
||||
catch { if (rec.state !== "idle") rec.timer = setTimeout(pollRec, 3000); return; }
|
||||
showRec(s);
|
||||
}
|
||||
$("recBtn").onclick = async e => {
|
||||
const start = rec.state !== "recording";
|
||||
const res = await act(start ? "Start recording" : "Stop recording",
|
||||
() => api("/api/record", { action: start ? "start" : "stop" }), e.currentTarget);
|
||||
if (res) showRec(res); else pollRec();
|
||||
};
|
||||
|
||||
const clipsState = { list: [], gen: 0, exporting: new Set() };
|
||||
async function loadClips() {
|
||||
if (HOST.mobile) return;
|
||||
const gen = clipsState.gen;
|
||||
$("clipsRefresh").disabled = true;
|
||||
let got;
|
||||
try { got = await api("/api/clips"); }
|
||||
catch (e) { if (gen === clipsState.gen) failed($("clipList"), e); return; }
|
||||
finally { $("clipsRefresh").disabled = false; }
|
||||
if (gen !== clipsState.gen) return;
|
||||
clipsState.list = got.clips;
|
||||
drawClips();
|
||||
}
|
||||
// Forget the clips of a headset we've switched away from.
|
||||
function resetClips() {
|
||||
clipsState.gen++; clipsState.list = [];
|
||||
$("clipCount").textContent = "";
|
||||
$("clipList").innerHTML = `<div class="sub">Loading…</div>`;
|
||||
}
|
||||
const megabytes = n => n >= 1e9 ? `${(n / 1e9).toFixed(1)} GB` : `${Math.max(1, Math.round(n / 1e6))} MB`;
|
||||
function drawClips() {
|
||||
const list = clipsState.list;
|
||||
$("clipCount").textContent = list.length ? `${list.length} on the Frame` : "";
|
||||
$("clipList").innerHTML = list.length ? list.map((c, i) => `<div class="item"><div class="grow">
|
||||
<div class="t">${esc(c.appid ? shotApp(c.appid) : "Steam recording")} · ${c.kind === "clip" ? "Clip" : "Background recording"}</div>
|
||||
<div class="s" title="${esc(new Date(c.time * 1000).toLocaleString())}">${esc([shotTime(c.time), c.duration ? clock(c.duration) : "",
|
||||
megabytes(c.size), c.gaps ? `${c.gaps} pieces missing` : ""].filter(Boolean).join(" · "))}</div></div>
|
||||
${c.saved ? `<span class="tag">On ${HOST.computer}</span>` : ""}
|
||||
<button class="small" data-clip-export="${i}"${clipsState.exporting.has(c.id) ? " disabled" : ""}>${c.saved ? "Export again" : "Export"}</button>
|
||||
</div>`).join("")
|
||||
: `<div class="sub">No Steam clips or background recordings on the Frame.</div>`;
|
||||
}
|
||||
async function exportClip(c, btn) {
|
||||
const label = `Export ${c.file}`;
|
||||
clipsState.exporting.add(c.id); btn.disabled = true;
|
||||
log(label + "…"); toast(label + "…");
|
||||
try {
|
||||
const { job } = await api("/api/clips/export", { id: c.id });
|
||||
for (let misses = 0; ;) {
|
||||
await new Promise(ok => setTimeout(ok, 1500));
|
||||
let j;
|
||||
try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; }
|
||||
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
|
||||
if (!j.done) continue;
|
||||
if (j.error) throw new Error(j.error);
|
||||
log(j.message, "ok"); toast(j.message);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
|
||||
} finally { clipsState.exporting.delete(c.id); loadClips(); }
|
||||
}
|
||||
$("clipList").onclick = e => {
|
||||
const b = e.target.closest("[data-clip-export]");
|
||||
if (b) exportClip(clipsState.list[+b.dataset.clipExport], b);
|
||||
};
|
||||
$("clipsRefresh").onclick = () => loadClips();
|
||||
$("videosFolder").onclick = e => act($("videosFolder").textContent, () => api("/api/open", { what: "videos" }), e.currentTarget);
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online) loadShots(true); });
|
||||
|
||||
// ---- Panel switcher: our UI over SteamVR's panel API ----
|
||||
@@ -5269,8 +5439,8 @@ const PAGES = ["apps", "games", "tools", "settings"];
|
||||
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
|
||||
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
|
||||
vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools"};
|
||||
const TOOL_TITLES = {transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork",
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools"};
|
||||
const TOOL_TITLES = {transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
|
||||
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates"};
|
||||
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
|
||||
@@ -6062,7 +6232,7 @@ if (window.frameApp && window.frameApp.onInstallLink) {
|
||||
}
|
||||
|
||||
setView("headset");
|
||||
refresh().then(() => { loadShots(); pollComfort(); }); // after status, so app names resolve and comfort knows if the Frame answers
|
||||
refresh().then(() => { loadShots(); loadClips(); pollRec(); pollComfort(); }); // after status, so app names resolve and comfort knows if the Frame answers
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
||||
</script>
|
||||
<script>
|
||||
@@ -6353,7 +6523,7 @@ function onConnection(s) {
|
||||
disp.list = []; disp.port = null;
|
||||
// The lists behind those panels too, so filters can't bring the old ones back.
|
||||
gm.storeDraft = ""; gm.owned = null; gm.byId = new Map(); gm.results = []; gm.store = []; gm.storeQ = null; gm.shown = 0; gm.shownBySource = {owned:0, store:0}; gm.seq++;
|
||||
androidApps = []; androidSeq++; androidPending.clear(); $('installedDetail').close(); resetShots();
|
||||
androidApps = []; androidSeq++; androidPending.clear(); $('installedDetail').close(); resetShots(); resetClips();
|
||||
renderHome();
|
||||
titlesSeq++; disp.seq++; // answers to loads already on their way are ignored
|
||||
if (cat.apps) filterCatalog(); // "Installed" tags were the other headset's
|
||||
|
||||
+137
@@ -49,6 +49,8 @@ from apk_sources import search as apk_search, SourceError # noqa: E402
|
||||
from apk_sources import fdroid as apk_fdroid # noqa: E402
|
||||
import frame_apk_versions # noqa: E402
|
||||
import frame_catalog # noqa: E402
|
||||
import frame_clips # noqa: E402
|
||||
import frame_autosync # noqa: E402
|
||||
import frame_devices # noqa: E402
|
||||
import frame_steamgriddb
|
||||
import frame_comfort # noqa: E402
|
||||
@@ -746,6 +748,125 @@ def stream_command(query):
|
||||
f"exec >&-; cat >/dev/null; kill $p 2>/dev/null; wait $p")
|
||||
|
||||
|
||||
# Recordings of the headset view with its sound, and Steam's own clips, saved as .mp4
|
||||
# in ~/Videos/SteamFrame (frame_clips.py has the commands and the Frame-side half).
|
||||
|
||||
def fetch_file(remote, local):
|
||||
"""Copy one file from the Frame (a path relative to its home) to `local`."""
|
||||
ensure_master()
|
||||
try:
|
||||
# Modern scp uses SFTP, so the remote path isn't parsed by a shell. -p keeps its time.
|
||||
r = frame_host.run_ssh(["scp", "-p", *SSH[1:], f"{FRAME}:{remote}", str(local)], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"Copying {Path(remote).name} timed out")
|
||||
if r.returncode != 0:
|
||||
raise Failure(strip_ansi(r.stderr or r.stdout).strip() or f"scp exited {r.returncode}")
|
||||
|
||||
|
||||
def _record_channel(command, stderr):
|
||||
ensure_master()
|
||||
proc = subprocess.Popen([*SSH, FRAME, command], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr)
|
||||
_live_tunnels.add(proc) # killed if the server stops: the Frame then finishes the file
|
||||
return proc
|
||||
|
||||
|
||||
recorder = frame_clips.Recorder(_record_channel, lambda cmd, timeout: ssh(cmd, timeout=timeout), fetch_file,
|
||||
lambda fn: busy_thread(fn).start(), release=lambda proc: _live_tunnels.discard(proc))
|
||||
|
||||
|
||||
def _clips_here():
|
||||
if LOCAL:
|
||||
raise Failure("Recordings are saved to a computer: use Frame Control on your computer", 400)
|
||||
|
||||
|
||||
def record(body):
|
||||
"""{"action": "start"|"stop"}: record what the headset shows, with its sound."""
|
||||
_clips_here()
|
||||
action = body.get("action")
|
||||
if action not in ("start", "stop"):
|
||||
raise Failure("action must be start or stop", 400)
|
||||
try:
|
||||
return recorder.start() if action == "start" else recorder.stop()
|
||||
except frame_clips.ClipError as e:
|
||||
raise Failure(str(e), e.status)
|
||||
|
||||
|
||||
def _clips_frame(args, timeout):
|
||||
return steam_frame(*args, timeout=timeout, script="frame_clips.py")
|
||||
|
||||
|
||||
def list_clips():
|
||||
_clips_here()
|
||||
try:
|
||||
return frame_clips.list_clips(_clips_frame)
|
||||
except frame_clips.ClipError as e:
|
||||
raise Failure(str(e), e.status)
|
||||
|
||||
|
||||
def export_clip(body):
|
||||
"""{"id"}: stitch one of Steam's recordings on the Frame and copy the .mp4 here (a job)."""
|
||||
_clips_here()
|
||||
clip_id = body.get("id")
|
||||
name = frame_clips.export_name(clip_id) # ValueError, so 400, for a bad id
|
||||
|
||||
def work():
|
||||
try:
|
||||
return frame_clips.export_clip(clip_id, _clips_frame, lambda cmd, timeout: ssh(cmd, timeout=timeout),
|
||||
fetch_file)
|
||||
except frame_clips.ClipError as e:
|
||||
raise Failure(str(e), e.status)
|
||||
return start_job(f"Export {name}", work)
|
||||
|
||||
|
||||
# Copying new screenshots and clips here on their own (frame_autosync.py). Each copy
|
||||
# counts as work on the headset it was listed on, so a switch can't send it elsewhere.
|
||||
|
||||
def _sync_device():
|
||||
if not LINK or LINK.snapshot().get("phase") != "connected":
|
||||
return None
|
||||
return LINK.active_device().get("id")
|
||||
|
||||
|
||||
def _sync_shots(device, ids):
|
||||
with working(device):
|
||||
return save_shots({"ids": 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)
|
||||
|
||||
|
||||
def _sync_list(listing):
|
||||
def run():
|
||||
with working(_sync_device()):
|
||||
return listing()
|
||||
return run
|
||||
|
||||
|
||||
_stop_sync = threading.Event()
|
||||
autosync = frame_autosync.AutoSync(_sync_list(list_shots), _sync_shots, _sync_list(list_clips), _sync_clip,
|
||||
_sync_device, lambda: _work[0] > 0, offline=lambda e: unreachable(str(e)),
|
||||
shots_folder=SHOTS_DIR, clips_folder=frame_clips.VIDEOS_DIR)
|
||||
|
||||
|
||||
def auto_sync(body):
|
||||
"""{"action": "settings", "screenshots", "clips"} | {"action": "now"} | {"action": "resume"}."""
|
||||
_clips_here()
|
||||
action = body.get("action")
|
||||
if action == "settings":
|
||||
return autosync.update({k: body[k] for k in ("screenshots", "clips") if isinstance(body.get(k), bool)})
|
||||
if action == "now":
|
||||
if not _sync_device():
|
||||
raise Failure("Connect a headset first", 409)
|
||||
return dict(autosync.copy_all(), message="Copying everything that isn't on this computer yet…")
|
||||
if action == "resume":
|
||||
return dict(autosync.resume(), message="Copying again")
|
||||
raise Failure("action must be settings, now or resume", 400)
|
||||
|
||||
|
||||
def launch(body):
|
||||
appid = str(body.get("appid", ""))
|
||||
if not APPID.match(appid):
|
||||
@@ -1532,6 +1653,10 @@ def open_thing(body):
|
||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
frame_host.open_path(SHOTS_DIR)
|
||||
return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
|
||||
if what == "videos":
|
||||
frame_clips.VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
frame_host.open_path(frame_clips.VIDEOS_DIR)
|
||||
return {"message": f"Opened {frame_clips.VIDEOS_DIR} in {frame_host.FILE_MANAGER}"}
|
||||
if what == "shot":
|
||||
saved = SHOTS_DIR / shot_path(body.get("id")).rsplit("/", 1)[-1]
|
||||
if not saved.exists():
|
||||
@@ -2773,6 +2898,7 @@ POST = {
|
||||
"/api/settings/artwork": frame_steamgriddb.save_settings,
|
||||
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/queue": queue_action, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/mods": mods, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||
"/api/record": record, "/api/clips/export": export_clip, "/api/autosync": auto_sync,
|
||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||
"/api/webinstall/cancel": webinstall_cancel,
|
||||
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
|
||||
@@ -3065,6 +3191,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(list_shots())
|
||||
elif path == "/api/shots/image":
|
||||
self.send_bytes(*shot_image(url.query))
|
||||
elif path == "/api/record":
|
||||
_clips_here()
|
||||
self.send_json(recorder.status())
|
||||
elif path == "/api/clips":
|
||||
self.send_json(list_clips())
|
||||
elif path == "/api/autosync":
|
||||
_clips_here()
|
||||
self.send_json(autosync.state())
|
||||
elif path == "/api/stream":
|
||||
self.stream_video(url.query)
|
||||
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["panel"]:
|
||||
@@ -3355,6 +3489,8 @@ def main():
|
||||
frame_report.link = LINK # its connection summary goes in every report's diagnostics
|
||||
frame_report.start_ssh_check() # ...with which ssh this is, found once in the background
|
||||
LINK.start()
|
||||
if not PRIVATE: # one worker per user, in the app's server
|
||||
threading.Thread(target=autosync.run, args=(_stop_sync,), daemon=True).start()
|
||||
if not frame_host.WINDOWS:
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||
if args.exit_on_eof:
|
||||
@@ -3377,6 +3513,7 @@ def main():
|
||||
# mid-cleanup would abort it and leave the SSH master running.
|
||||
if not frame_host.WINDOWS:
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
_stop_sync.set()
|
||||
webinstall_shutdown()
|
||||
macview.shutdown() # close the headset's viewers before the agent goes
|
||||
# The master was started with -N, so it stays up until told to exit.
|
||||
|
||||
Reference in new issue
Block a user