Compare commits

...
Author SHA1 Message Date
SAPHID 9542ac1bac Bump Frame Control to 0.4.16 2026-10-24 07:35:07 +00:00
Alex SouthwellandSAPHID 17f6d52717 Document real Steam clip export verification (#147)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 07:34:34 +00:00
Alex SouthwellandSAPHID b4e80cab0a Escape power report CSV metadata for spreadsheets (#146)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 07:28:26 +00:00
7 changed files with 61 additions and 26 deletions

No files matched your search

+3
View File
@@ -2,6 +2,9 @@
## Unreleased
- Treat headset aliases and other CSV metadata as plain text in power and heat
exports, so spreadsheet formula prefixes cannot run when a report is opened.
- Add desktop Tools → Power & heat report: bounded read-only one/five-minute
observations with progress, stop and HTML/JSON/CSV downloads. Received samples
survive stop, disconnect and headset switch, labeled incomplete and tied to the
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.15",
"version": "0.4.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.15",
"version": "0.4.16",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.15",
"version": "0.4.16",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+3 -1
View File
@@ -94,7 +94,9 @@ in your browser to view the offline table and its embedded JSON/CSV downloads.
Snapshots taken before completion explicitly say incomplete. Desktop CSV includes
`collection.state`, `collection.requestedSamples` and `collection.receivedSamples`
metadata rows with the original headset alias in the source column, even when no
sensor sample arrived. JSON records the alias and device ID; HTML names the alias.
sensor sample arrived. CSV text fields, including the alias, are escaped if they
begin with a spreadsheet formula prefix. JSON records the alias and device ID;
HTML names the alias.
Only the latest observation is retained in server memory, including partial
samples after stop or disconnect. **Download before starting another observation,
+28 -16
View File
@@ -8,13 +8,13 @@ Both are on **Tools → Capture & Live**. Everything is saved to
Nothing needs installing on the computer or on the Frame: the work is done by
the `ffmpeg` that SteamOS ships.
**Recording is tested on a headset; Steam clip export isn't yet.** On a Frame
**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. That Frame had no
Steam recordings, so the clip list came back empty and export has only been
tested with files in Steam's format made by `ffmpeg`. The parts we're least sure
of are listed under [What hasn't been checked](#what-hasnt-been-checked).
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
@@ -147,19 +147,31 @@ 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
## Real Steam clip verification and remaining checks
The 2026-10-24 read-only clip list on build `20261015.6141207` again returned
zero clips: its `gamerecordings` directory held only `gamerecording.pb`. No
Steam recording was created during that check, so a real Steam clip export is
still unverified. The recording and Stop path were verified earlier on that
build, as described above. 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.
- 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.
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**. The literal desktop **Export** button was not clicked. 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.
- Long saved clips, trims starting partway through a Steam recording, and the
desktop **Export** button. One real saved clip's
directory layout was 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.
+10 -6
View File
@@ -141,6 +141,13 @@ def collect(status, count, interval, emit=None):
def csv_samples(report):
# CSV quoting alone does not prevent spreadsheet formulas in text fields.
def cell(value):
if isinstance(value, str) and (value.startswith(('\t', '\r', '\n')) or
value.lstrip(' \t\r\n').startswith(('=', '+', '-', '@'))):
return "'" + value
return value
output = io.StringIO(newline='')
writer = csv.writer(output)
writer.writerow(('time', 'elapsedSeconds', 'metric', 'value', 'unit', 'source', 'state'))
@@ -148,17 +155,14 @@ def csv_samples(report):
for key, value in (('collection.state', report['stopped'] or 'Completed'),
('collection.requestedSamples', report['requestedSamples']),
('collection.receivedSamples', len(report['samples']))):
writer.writerow(('', '', key, value, '', report['headset']['alias'],
'incomplete' if report['stopped'] else 'completed'))
writer.writerow([cell(v) for v in ('', '', key, value, '', report['headset']['alias'],
'incomplete' if report['stopped'] else 'completed')])
for sample in report['samples']:
for key, row in sample['readings'].items():
# csv quoting does not prevent spreadsheet formulas in labels.
def cell(value):
return "'" + value if isinstance(value, str) and value.startswith(('=', '+', '-', '@')) else value
writer.writerow([cell(v) for v in (sample['time'], sample['elapsedSeconds'], key,
row['value'], row['unit'], row['source'], row['state'])])
for group in sample['unavailableGroups']:
writer.writerow((sample['time'], sample['elapsedSeconds'], group, '', '', '', UNAVAILABLE))
writer.writerow([cell(v) for v in (sample['time'], sample['elapsedSeconds'], group, '', '', '', UNAVAILABLE)])
return output.getvalue()
+14
View File
@@ -2,6 +2,7 @@
import sandbox # noqa: F401
import base64
import contextlib
import csv
from html.parser import HTMLParser
import io
import json
@@ -43,6 +44,19 @@ class Report(unittest.TestCase):
with contextlib.redirect_stderr(io.StringIO()):
return power.stream_report([sys.executable, '-c', program], '', count, 1)
def test_csv_metadata_headset_alias_is_plain_text_in_spreadsheets(self):
for prefix in ('=', '+', '-', '@', ' =', '\t='):
with self.subTest(prefix=prefix):
alias = prefix + 'HYPERLINK("https://example.invalid")'
report = {'headset': {'alias': alias}, 'requestedSamples': 12,
'stopped': 'Stopped', 'samples': []}
rows = list(csv.reader(io.StringIO(power.csv_samples(report))))
self.assertEqual(len(rows), 4)
self.assertEqual([row[5] for row in rows[1:]], ["'" + alias] * 3)
report['headset']['alias'] = 'living-room-frame'
rows = list(csv.reader(io.StringIO(power.csv_samples(report))))
self.assertEqual([row[5] for row in rows[1:]], ['living-room-frame'] * 3)
def test_completed_stream_preserves_zero_unknown_units_and_downloads(self):
event = json.dumps({'sample': SAMPLE})
program = f'import sys; sys.stdin.read(); print({event!r}); print(\'{{"finished": {{"stopped": null}}}}\')'