feat: automatically record Steam game sessions
Detect games with native process exit watchers, own OBS on a background worker, and persist settings and recoverable session timelines. Place segments by split frame counts, recover capture with honest gaps, and finalise on game exit or explicit Quit. Keep the current recording alive after GPUI closes its last window, then exit when the game ends. Add an end-to-end rig covering recording, recovery, exact packet-count offsets, disk library reload and clean Quit.
This commit is contained in:
@@ -87,7 +87,7 @@ name=write-failure
|
||||
> "${out}/${name}.log" 2>&1
|
||||
probe_exit=$?
|
||||
chmod 700 "${out}/${name}/session" 2>/dev/null
|
||||
expect_failure "${name}" "(no footage was written|Error)"
|
||||
expect_failure "${name}" "(no footage was written|Error|EncodeError)"
|
||||
|
||||
# 7. Free space falls below the safety margin.
|
||||
name=low-disk-space
|
||||
|
||||
Executable
+152
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env bash
|
||||
# End-to-end desktop/Steam-process/session test in the private Wayland rig.
|
||||
set -euo pipefail
|
||||
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
out="${1:-${TMPDIR:-/tmp}/clipbin-session-validation}"
|
||||
mkdir -p "${out}"
|
||||
cargo build --locked -p clipbin-desktop --manifest-path "${repo}/Cargo.toml"
|
||||
export CLIPBIN_SESSION_REPO="${repo}" CLIPBIN_SESSION_OUT="${out}"
|
||||
"${repo}/scripts/rig/headless.sh" bash <<'INNER'
|
||||
set -euo pipefail
|
||||
out="${CLIPBIN_SESSION_OUT}"
|
||||
portal_config="${XDG_CONFIG_HOME}"
|
||||
export XDG_DATA_HOME="${out}/data" XDG_CONFIG_HOME="${out}/config"
|
||||
mkdir -p "${XDG_DATA_HOME}" "${XDG_CONFIG_HOME}"
|
||||
"${CLIPBIN_SESSION_REPO}/target/debug/clipbin-desktop" > "${out}/desktop.log" 2>&1 &
|
||||
app=$!
|
||||
trap 'kill "${app}" "${game:-}" "${portal:-}" 2>/dev/null || true' EXIT
|
||||
sleep 3
|
||||
swaymsg '[title="clipbin-test-game"] fullscreen disable' > /dev/null || true
|
||||
swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set 1360 860, move position center, focus' > /dev/null
|
||||
bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' &
|
||||
game=$!
|
||||
python3 - "${game}" <<'PY'
|
||||
import sys,time
|
||||
for _ in range(20):
|
||||
argv = open(f'/proc/{sys.argv[1]}/cmdline', 'rb').read().split(b'\0')
|
||||
if argv[0] == b'reaper': break
|
||||
time.sleep(.1)
|
||||
argv = open(f'/proc/{sys.argv[1]}/cmdline', 'rb').read().split(b'\0')
|
||||
assert argv[0] == b'reaper' and b'SteamLaunch' in argv and b'AppId=480' in argv, argv
|
||||
PY
|
||||
for _ in $(seq 30); do
|
||||
if rg -q 'recorder_status=Recording' "${out}/desktop.log"; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
rg -q 'recorder_status=Recording' "${out}/desktop.log"
|
||||
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null
|
||||
sleep 0.5
|
||||
grim "${out}/recording.png"
|
||||
if [[ "${CLIPBIN_VALIDATE_RECOVERY:-0}" == 1 ]]; then
|
||||
kill "$(cat "${CLIPBIN_RIG_DIR}/xdpw.pid")"
|
||||
for _ in $(seq 15); do
|
||||
if rg -q 'recorder_status=Failed' "${out}/desktop.log"; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
rg -q 'recorder_status=Failed' "${out}/desktop.log"
|
||||
grim "${out}/capture-failed.png"
|
||||
XDG_CONFIG_HOME="${portal_config}" /usr/lib/xdg-desktop-portal-wlr > "${out}/recovered-portal.log" 2>&1 &
|
||||
portal=$!
|
||||
for _ in $(seq 30); do
|
||||
if [[ $(rg -c 'recorder_status=Recording' "${out}/desktop.log") -ge 2 ]]; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
[[ $(rg -c 'recorder_status=Recording' "${out}/desktop.log") -ge 2 ]]
|
||||
python3 - "${XDG_DATA_HOME}" <<'PY'
|
||||
import pathlib,json,sys
|
||||
p=max(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'), key=lambda p:int(p.parent.name))
|
||||
m=json.loads(p.read_text())
|
||||
assert any(s['path'].startswith('run-002/') for s in m['segments'])
|
||||
assert len([s for s in m['session']['spans'] if s['kind']=='Missing']) >= 2
|
||||
print('PASS capture recovery resumed in the same session with a Missing gap')
|
||||
PY
|
||||
fi
|
||||
# Closing the sole window must leave the process and footage running.
|
||||
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' > /dev/null
|
||||
kill -0 "${app}"
|
||||
sleep 3
|
||||
kill -0 "${app}"
|
||||
kill "${game}"
|
||||
wait "${game}" 2>/dev/null || true
|
||||
finalised=0
|
||||
for _ in $(seq 20); do
|
||||
if python3 - "${XDG_DATA_HOME}" 2>/dev/null <<'PY'
|
||||
import pathlib, json, sys
|
||||
files = list(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'))
|
||||
assert files
|
||||
path = max(files, key=lambda p: int(p.parent.name))
|
||||
m = json.loads(path.read_text())
|
||||
assert m['app_id'] == 480 and not m['session']['active']
|
||||
assert m['segments'] and all(s['complete'] for s in m['segments'])
|
||||
assert all((path.parent / s['path']).stat().st_size > 0 for s in m['segments'])
|
||||
assert any(s['kind'] == 'Footage' for s in m['session']['spans'])
|
||||
print('PASS session finalised with footage')
|
||||
PY
|
||||
then finalised=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
[[ ${finalised} == 1 ]]
|
||||
python3 - "${XDG_DATA_HOME}" <<'PY'
|
||||
import pathlib,json,subprocess,sys
|
||||
path=max(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'), key=lambda p:int(p.parent.name))
|
||||
m=json.loads(path.read_text())
|
||||
ns=lambda d:d['secs']*1_000_000_000+d['nanos']
|
||||
runs={}
|
||||
for segment in m['segments']:
|
||||
run=segment['path'].split('/')[0]
|
||||
base,packets=runs.get(run,(ns(segment['start']),0))
|
||||
assert segment['dropped_frames']==0, 'Dropped frames make count-derived offsets uncertain'
|
||||
expected=base+packets*1_000_000_000//segment['frames_per_second']
|
||||
assert ns(segment['start'])==expected, (segment['path'],ns(segment['start']),expected)
|
||||
if not segment['available']:
|
||||
assert ns(segment['end'])==ns(segment['start'])
|
||||
runs[run]=(base,packets)
|
||||
continue
|
||||
result=json.loads(subprocess.check_output(['ffprobe','-v','error','-select_streams','v:0','-count_packets','-show_entries','stream=nb_read_packets','-of','json',str(path.parent/segment['path'])]))
|
||||
count=int(result['streams'][0]['nb_read_packets'])
|
||||
assert count>0
|
||||
runs[run]=(base,packets+count)
|
||||
print('PASS segment offsets exactly match cumulative earlier video packet counts')
|
||||
PY
|
||||
python3 - "${XDG_DATA_HOME}" <<'PY'
|
||||
import pathlib,json,sys
|
||||
files=list(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'))
|
||||
assert files and all(not json.loads(p.read_text())['session']['active'] for p in files)
|
||||
PY
|
||||
for _ in $(seq 10); do
|
||||
if ! kill -0 "${app}" 2>/dev/null; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
if kill -0 "${app}" 2>/dev/null; then echo 'FAIL closed app kept discovering after game exit' >&2; exit 1; fi
|
||||
wait "${app}"
|
||||
kill "$(cat "${CLIPBIN_RIG_DIR}/game.pid")"
|
||||
# Reopen to prove the library loads from disk, then Quit cleanly with Ctrl+Q.
|
||||
"${CLIPBIN_SESSION_REPO}/target/debug/clipbin-desktop" > "${out}/reopened.log" 2>&1 &
|
||||
app=$!
|
||||
sleep 4
|
||||
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null
|
||||
sleep 0.5
|
||||
grim "${out}/library.png"
|
||||
bash -c 'exec -a reaper python3 -c "import time; time.sleep(120)" SteamLaunch AppId=480 --' &
|
||||
game=$!
|
||||
for _ in $(seq 30); do
|
||||
if rg -q 'recorder_status=Recording' "${out}/reopened.log"; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
rg -q 'recorder_status=Recording' "${out}/reopened.log"
|
||||
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null
|
||||
wtype -M ctrl -k q -m ctrl
|
||||
for _ in $(seq 20); do
|
||||
if ! kill -0 "${app}" 2>/dev/null; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
if kill -0 "${app}" 2>/dev/null; then echo 'FAIL explicit Quit did not exit' >&2; exit 1; fi
|
||||
wait "${app}"
|
||||
python3 - "${XDG_DATA_HOME}" <<'PY'
|
||||
import pathlib,json,sys
|
||||
files=list(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json'))
|
||||
assert len(files) >= 2
|
||||
assert all(not json.loads(p.read_text())['session']['active'] for p in files)
|
||||
PY
|
||||
echo "PASS automatic recording, window close, game exit, library reload and Quit"
|
||||
INNER
|
||||
Reference in New Issue
Block a user