Files
saphid--frame-control/docs/recording.md
T

275 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Recording and Steam clips
For a live OBS Media Source with headset output audio, see
[Headset view in OBS](obs-headset-source.md).
Frame Control can record SteamVR's mirrored headset view, with its sound, and save it as
an `.mp4` on your computer. It can also export the clips and background
recordings from Steam's own game recording, at any length, as one `.mp4` each.
Both are on **Tools → Capture & Live**. Everything is saved to
`~/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.
**Recording and Steam clip export have each been tested on a headset.** On a Frame
running SteamOS 0.5.6 (build 20261015.6141207), Record and Stop saved an
8-second 1080p H.264 recording with AAC sound to the computer, Stop took about a
second, and nothing was left running or stored on the Frame. A later test on
that build created a saved clip with Steam's own recorder and exported it with
Frame Control's production export function. The parts we're least sure of are
listed under [What hasn't been checked](#what-hasnt-been-checked).
## 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: it types `q` into
`ffmpeg`'s input, as at its keyboard, and kills it if it's still there 10
seconds later. Not a signal: SteamOS's `ffmpeg` 7.0 catches SIGTERM and
SIGINT and carries on recording. A recording that was never copied is
deleted when the next one starts.
Recording and **Live** can run together; both read the same device.
### Does Record include passthrough?
**Yes, the MP4 recording and OBS mirror can include room passthrough.** On
25 October 2026, SteamOS 0.5.6 build `20261022.6153909`, a safe SteamVR wake
produced real room pixels behind panels in `/dev/video99`. Room pixels also
appeared in decoded frames of both the OBS recording and a five-second MP4
made with the packaged production recorder. The private room images are not
published. See the [test record](evidence/obs-validation-2026-10-25.md).
Both paths read SteamVR's `system.HeadsetView` mirror. Frame Control does not
open raw passthrough cameras or add a camera layer. The observed mirror includes
SteamVR's composited room and panels; it is not limited to a game's image.
This does not promise that every game or passthrough mode appears the same way.
The mirror can be blank in standby. Steam's own **Steam clips** use a separate
game recording path and were not checked for passthrough in this test.
## Steam clips
**Steam clips** lists what Steam's game recording has kept on the Frame: saved
clips and background recordings, newest first, with the game, length and size.
**Export** joins one into a single `.mp4` under `~/Videos/SteamFrame`, named after
its clip folder (for example `clip_620_20261020_202134.mp4`), however long it is.
Steam's own export is reported to stop at about 60 seconds. Exports run in the
background; ones already on your computer are marked.
Each export keeps its headset, Steam account and complete recording session ID:
```
~/Videos/SteamFrame/headset-<id>/<account>/clips/<clip>/video/<session>/<clip>.mp4
~/Videos/SteamFrame/headset-<id>/<account>/video/<session>/<session>.mp4
```
The headset folder uses the same short device-identity hash as screenshots.
All folders in the Steam clip ID are kept, including nested sessions, so equal
clip names across accounts, headsets or sessions keep separate files. Manual
**Export** and automatic copying use the same path and saved marker. Exporting
the same identified session again replaces only that session's export.
Existing exports in the old flat `~/Videos/SteamFrame/<clip>.mp4` layout stay
untouched. Their source identity cannot be proved, so they do not mark a clip
as saved. Export again or use **Copy everything now** to make an identified
copy. Automatic copying still remembers IDs copied before the upgrade; use
**Copy everything now** to copy those again. This destination change has local
fixture coverage; it has not been tested on a real headset.
How it works: Steam keeps each recording as MPEG-DASH segments, one folder per
recording session.
```
~/.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`.
Each newly saved screenshot lives under
`~/Pictures/SteamFrame/headset-<id>/<Steam account>/<game ID>/<filename>`.
The headset folder uses a short hash of Frame Control's device identity.
Steam can use the same screenshot filename for two games or accounts; these
folders keep both originals. Existing screenshots in the old flat
`~/Pictures/SteamFrame/<filename>` layout stay where they are. They cannot be
matched reliably to one headset and game, so **Copy everything now** can create
a new, correctly identified copy. It never overwrites the old file.
One real Frame screenshot was copied through this path on 25 October 2026;
see the [test record](evidence/screenshot-copy-2026-10-25.md). The collision
case was tested with separate fake sources, not two real same-name screenshots.
- **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**.
- **When one file keeps failing** it tries at most three times, then leaves
the problem visible in Settings. Fix the source problem and press **Resume**
to allow another three attempts without restarting Frame Control. **Copy
everything now** also resets failed attempts and includes older files.
Resume keeps the history of successful copies, so it does not recopy them.
- **With several headsets** each check and copy belongs to the headset selected
when the check began. Switching between lists or copies stops that pass;
another headset's saved files cannot become part of its copy history.
Settings shows when it last checked, how many files it last copied and when,
and the last problem it had. The screenshot and clip lists say when it's on.
Retry and switch handling have offline regression coverage with fake sources;
see the [test record](evidence/autosync-reliability-2026-10-25.md).
## For developers
`ui/frame_clips.py` holds the logic, so other features can reuse it (automatic
copying, in `ui/frame_autosync.py`, does):
- `list_clips(run_frame, device_id=...)` and
`export_clip(id, run_frame, run, fetch, device_id=...)` are the computer's
side; the server passes how to reach the Frame and its pinned device identity.
Both use `clip_local_path(id, device_id, folder=None)` for the destination.
- `list_recordings(userdata)` and `export(userdata, id)` are the Frame's side,
run as `python3 - list` / `python3 - export ID` with the file on stdin.
- `Recorder` runs one recording from Start to the copy.
- `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`, `tests/test_clip_copy.py` for server destinations
and collision coverage, and `tests/test_autosync.py` for automatic
copying (with a fake headset). They run the recording's shell under bash with a
stand-in `ffmpeg`, so Stop, the time limit and the clean-up are tested as they
run on the Frame. Where `ffmpeg` is installed, they also make a 65-second DASH
recording with Steam's file names and export it with the real `ffmpeg`.
## Real Steam clip verification and remaining checks
On 2026-10-24, v0.4.15 on build `20261015.6141207` recorded a ten-second
reserved test session through Steam's own game recorder, saved it as a clip,
then exported it with `frame_clips.export_clip`, the same implementation used
by the desktop API. The 159,829-byte MP4 decoded completely: 10.29 seconds,
1920×1080 H.264 video and stereo AAC audio (SHA-256
`f8a476eefb2bde7ba5cb464632ec9b952eea3ab1610b5bed2098bf1f11d62cf1`).
All 616 decoded video frames and the decoded audio matched Steam's source
segments. Recording mode was restored
to off; the reserved title and its processes were removed, the shared test lock
was released, and the battery remained at 99%. The Steam source clip was kept.
The inspected frame is nearly black in both Steam's source and the export, so
this establishes file creation and faithful export, **not visible gameplay
capture**. On v0.4.16, the literal desktop **Export** button was also clicked
against that retained real Steam clip in the source UI on lxso2. The job
completed and saved `~/Videos/SteamFrame/clip_13009719723623972864_20261024_072115.mp4`;
its 159,829 bytes and SHA-256 matched the previously decoded export exactly.
The temporary server stopped, the shared device lock was released, and the
headset remained in Game Mode at 99% battery. See
[`docs/evidence/clip-gui-export-2026-10-24.json`](evidence/clip-gui-export-2026-10-24.json)
for the narrow proof. This did not exercise the packaged desktop installer.
On 2026-10-24, the exact v0.4.17 tag exported a second real Steam-saved clip
through `frame_clips.export_clip`, the same production path used by **Export**.
Steam listed the clip at **100.801 seconds**, with 68 chunks and no gaps. The
copied MP4 was **100.833 seconds** by `ffprobe` and decoded completely with
`ffmpeg`; it was not cut at 60 seconds. The test restored Steam's recording
mode, removed its reserved title and processes, and left Game Mode active at
100% battery. See [the long-clip evidence](evidence/clip-long-2026-10-24.json).
That code-path test did not click **Export** in the packaged desktop app or
verify visible gameplay quality or perceptual audio/video sync.
Later that day, the published v0.4.17 x86_64 Linux AppImage also passed the
packaged desktop **Export** check against the retained 100.801-second clip.
The asset SHA-256 was verified before launching its unchanged extracted payload
in an isolated Electron profile on a virtual X desktop. An automated pointer
click on the visible **Tools → Capture & Live → Steam clips → Export** button
saved the MP4 and changed the row to **ON PC / Export again**. The saved file
was 100.833335 seconds, decoded completely, and matched the prior production
export by size and SHA-256. The app and test display were closed,
the Frame export cache was empty, the source clip was retained, and the shared
lock was released at 99% battery with Game Mode and SteamVR still running.
See [the packaged export evidence](evidence/clip-packaged-export-2026-10-24.json)
and its cropped before/after screenshots. This verifies the packaged Electron
button and bundled export path; AppImage FUSE mounting, desktop integration,
physical display/GPU rendering, gameplay quality and perceptual sync remain
unverified. The virtual-display launch used `--no-sandbox --disable-gpu`.
Remaining checks include:
- Perceptual audio/video sync and audible content. Frame Control's separate
**Record** path stamps camera and audio inputs with the wall clock because
they otherwise count from different clocks.
- Clips longer than the 100-second test and trims starting partway through a
Steam recording. Two real saved clips' directory layouts were checked; the
parser remains deliberately loose
for other layouts.
- 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.