feat: export clips, protect bookmarks and clean session storage

This commit is contained in:
2026-10-08 18:56:51 +02:00
parent f6cdef5dd2
commit 7ac7e66ff1
28 changed files with 2286 additions and 35 deletions
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Validate a persisted clip before any source cleanup (ffmpeg/ffprobe required)."""
import json
import pathlib
import subprocess
import sys
saved_path = pathlib.Path(sys.argv[1])
saved = json.loads(saved_path.read_text())
clip = saved_path.with_suffix('.mp4')
session_path = saved_path.parent.parent / 'sessions' / str(saved['source_session']) / 'session.json'
session = json.loads(session_path.read_text())
seconds = lambda d: d['secs'] + d['nanos'] / 1e9
start, end = seconds(saved['start']), seconds(saved['end'])
assert end > start
pieces = [s for s in session['segments'] if s['available'] and seconds(s['start']) < end and seconds(s['end']) > start]
assert len(pieces) >= 2, 'Export must cross a segment boundary'
def probe(path):
return json.loads(subprocess.check_output(['ffprobe', '-v', 'error', '-show_streams', '-show_packets', '-of', 'json', str(path)]))
source = probe(session_path.parent / pieces[0]['path'])
output = probe(clip)
video = next(s for s in output['streams'] if s['codec_type'] == 'video')
source_video = next(s for s in source['streams'] if s['codec_type'] == 'video')
assert video['codec_name'] == source_video['codec_name']
pts = sorted(float(p['pts_time']) for p in output['packets'] if p['stream_index'] == video['index'])
fps = pieces[0]['frames_per_second']
for a, b in zip(pts, pts[1:]):
assert abs(b-a-1/fps) < 2e-6, ('Video gap/duplicate', a, b, fps)
assert abs(pts[0]) < 1/fps
assert abs(pts[-1]+1/fps-(end-start)) < 1/fps+0.002, (pts[-1], start, end)
# Metadata records independent AAC epochs, even if earlier files are cleaned up later.
runs={}
for segment in session['segments']:
if not segment['complete'] or not segment['available']:
continue
run=str(pathlib.Path(segment['path']).parent)
facts=probe(session_path.parent/segment['path'])
audios=[s for s in facts['streams'] if s['codec_type']=='audio']
totals=runs.setdefault(run,[0]*len(audios))
assert segment['audio_start_samples']==totals,(segment['path'],segment['audio_start_samples'],totals)
for i,stream in enumerate(audios):
totals[i]+=1024*len([p for p in facts['packets'] if p['stream_index']==stream['index']])
for ordinal,stream in enumerate([s for s in output['streams'] if s['codec_type']=='audio']):
if stream['codec_type'] == 'audio':
timestamps = [float(p['pts_time']) for p in output['packets'] if p['stream_index'] == stream['index']]
step = 1024 / int(stream['sample_rate'])
assert timestamps
import math
source_ordinal=ordinal if saved['separate_audio'] or len([s for s in source['streams'] if s['codec_type']=='audio'])!=3 else 2
first=pieces[0]
run_base=seconds(first['start'])-first['frame_start']/fps
phase=run_base+first['audio_start_samples'][source_ordinal]/int(stream['sample_rate'])-start
expected_first=phase+max(0,math.ceil(-phase/step))*step
assert abs(timestamps[0]-expected_first)<1/int(stream['sample_rate'])+2e-6,('A/V phase',timestamps[0],expected_first)
for a, b in zip(timestamps, timestamps[1:]):
assert abs(b-a-step) < 2e-6, ('Audio gap/duplicate', a, b)
source_audio = [s for s in source['streams'] if s['codec_type'] == 'audio']
expected = 2 if saved['separate_audio'] and len(source_audio) == 3 else 1
assert len([s for s in output['streams'] if s['codec_type'] == 'audio']) == expected
result = subprocess.run(['ffmpeg', '-v', 'error', '-xerror', '-i', str(clip), '-f', 'null', '-'], capture_output=True)
assert result.returncode == 0 and not result.stderr, result.stderr.decode()
assert saved['clip']['size_bytes'] == clip.stat().st_size
print(f'PASS clip {clip.name}: {video["codec_name"]}, {len(pts)} CFR frames, continuous audio, zero decode errors, {end-start:.3f}s')
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# Real desktop buttons, background export, persisted bookmark and forced cleanup.
set -euo pipefail
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
out="${1:-/tmp/clipbin-clips-validation-$(date +%s)}"
mkdir -p "${out}"
cargo build --locked -p clipbin-desktop -p rig-pointer --manifest-path "${repo}/Cargo.toml"
cargo build --locked -p clipbin-media --example export --manifest-path "${repo}/Cargo.toml"
export CLIPBIN_CLIPS_REPO="${repo}" CLIPBIN_CLIPS_OUT="${out}"
"${repo}/scripts/rig/headless.sh" bash <<'INNER'
set -euo pipefail
repo="${CLIPBIN_CLIPS_REPO}"; out="${CLIPBIN_CLIPS_OUT}"
export XDG_DATA_HOME="${out}/data" XDG_CONFIG_HOME="${out}/config"
mkdir -p "${XDG_CONFIG_HOME}/clipbin"
cat > "${XDG_CONFIG_HOME}/clipbin/settings.json" <<'JSON'
{"clip_duration":{"secs":5,"nanos":0},"bookmark_before":{"secs":2,"nanos":0},"bookmark_after":{"secs":2,"nanos":0}}
JSON
python3 - "${XDG_CONFIG_HOME}/clipbin/settings.json" <<'PYSETTINGS'
import json,os,pathlib,sys
p=pathlib.Path(sys.argv[1]);s=json.loads(p.read_text())
s['clip_duration']['secs']=int(os.environ.get('CLIPBIN_VALIDATE_CLIP_SECONDS','5'))
s['codec']=os.environ.get('CLIPBIN_VALIDATE_CODEC','H264')
assert s['codec'] in ('H264','Hevc','Av1')
s['microphone_enabled']=os.environ.get('CLIPBIN_VALIDATE_MIC','0')=='1'
s['export_separate_audio']=os.environ.get('CLIPBIN_VALIDATE_SEPARATE_AUDIO','0')=='1'
p.write_text(json.dumps(s))
PYSETTINGS
"${repo}/target/debug/clipbin-desktop" > "${out}/desktop.log" 2>&1 & app=$!
trap 'kill "${app}" "${game:-}" 2>/dev/null || true' EXIT
quit_app() {
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' >/dev/null
# A fresh GPUI window may not have received the rig's keyboard focus yet.
# Move/click inside it before injecting the shortcut, then retry boundedly.
read -r qx qy qw qh < <(geometry)
click "$((qx+qw-30))" "$((qy+100))"
sleep 0.5
wtype -M ctrl -k q -m ctrl
for attempt in $(seq 20); do
if ! kill -0 "${app}" 2>/dev/null; then wait "${app}"; return; fi
if ((attempt % 4 == 0)); then
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' >/dev/null
wtype -M ctrl -k q -m ctrl
fi
sleep 1
done
grim "${out}/quit-failed.png"
swaymsg -t get_tree > "${out}/quit-failed-tree.json"
echo 'FAIL desktop did not honour Quit' >&2
exit 1
}
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=$!
for _ in $(seq 30); do
if grep -q 'recorder_status=Recording' "${out}/desktop.log"; then break; fi
kill -0 "${app}"; sleep 1
done
grep -q 'recorder_status=Recording' "${out}/desktop.log"
sleep 5
# Read window geometry rather than depending on output placement/titlebar height.
geometry() { swaymsg -t get_tree | python3 -c 'import json,sys
s=[json.load(sys.stdin)]
while s:
n=s.pop()
if n.get("app_id")=="dev.clipbin.Clipbin":
r=n["rect"];print(r["x"],r["y"],r["width"],r["height"]);break
s.extend(n.get("nodes",[])+n.get("floating_nodes",[]))'; }
read -r x y width height < <(geometry)
click() { printf 'move %s %s\nclick\nsleep 0.3\n' "$1" "$2" | "${repo}/target/debug/rig-pointer" 1920 1080; }
grim "${out}/before-buttons.png"
# Bottom sidebar: storage 88px, recorder 70px, gap24px, bookmark32px.
click "$((x+110))" "$((y+height-248))"
sleep 5
press_ns="$(python3 -c 'import time;print(time.time_ns())')"
click "$((x+110))" "$((y+height-304))"
for _ in $(seq 30); do
if grep -q 'export_status=Saved clip' "${out}/desktop.log"; then break; fi
kill -0 "${app}"; sleep 1
done
grep -q 'export_status=Saved clip' "${out}/desktop.log"
python3 - "${XDG_DATA_HOME}" "${repo}" "${press_ns}" <<'PY'
import pathlib,subprocess,sys,json,os
clips=[p for p in pathlib.Path(sys.argv[1]).glob('clipbin/clips/*.json') if p.stem.isdigit()]
assert len(clips)==1,clips
subprocess.run(['python3',sys.argv[2]+'/scripts/validate/check-clip.py',str(clips[0])],check=True)
m=json.loads(next(pathlib.Path(sys.argv[1]).glob('clipbin/sessions/*/session.json')).read_text())
assert len(m['session']['bookmarks'])==1 and m['session']['bookmarks'][0]['protected']
assert m['session']['active'],'Export must leave capture active'
saved=json.loads(clips[0].read_text())
configured=int(os.environ.get('CLIPBIN_VALIDATE_CLIP_SECONDS','5'))
seconds=lambda d:d['secs']+d['nanos']/1e9
pressed=int(sys.argv[3])/1e9-m['session']['started']['secs_since_epoch']-m['session']['started']['nanos_since_epoch']/1e9
assert pressed-.2<=seconds(saved['end'])<=pressed+1.2,(pressed,saved['end'])
if pressed>=configured:
assert pressed-configured-1.2<=seconds(saved['start'])<=pressed-configured+.2,(pressed,saved['start'])
assert configured<=seconds(saved['clip']['length'])<=configured+1.1
else:
assert abs(seconds(saved['start'])-min(seconds(s['start']) for s in m['segments'] if s['available']))<.001
assert seconds(saved['clip']['length'])<configured
print('PASS shorter session used its actual available footage')
print('PASS export kept the original button press range while the segment finalised')
PY
click "$((x+100))" "$((y+128))"
sleep 2; grim "${out}/clips.png"
kill "${game}"; wait "${game}" 2>/dev/null || true
sleep 3
quit_app
python3 - "${XDG_CONFIG_HOME}/clipbin/settings.json" <<'PY'
import json,pathlib,sys
p=pathlib.Path(sys.argv[1]);s=json.loads(p.read_text());s['unprotected_session_limit_bytes']=1;p.write_text(json.dumps(s))
PY
"${repo}/target/debug/clipbin-desktop" > "${out}/cleanup.log" 2>&1 & app=$!
sleep 6
python3 - "${XDG_DATA_HOME}" "${repo}" "${out}" <<'PY'
import json,pathlib,sys,subprocess
root=pathlib.Path(sys.argv[1]);p=next(root.glob('clipbin/sessions/*/session.json'));m=json.loads(p.read_text())
seconds=lambda d:d['secs']+d['nanos']/1e9
b=m['session']['bookmarks'][0];start=max(0,seconds(b['at'])-seconds(b['before']));end=min(seconds(m['session']['length']),seconds(b['at'])+seconds(b['after']))
assert b['protected'];removed=kept=0
for s in m['segments']:
overlap=seconds(s['start'])<end and seconds(s['end'])>start
if overlap:
assert s['available'] and (p.parent/s['path']).is_file();kept+=1
elif seconds(s['end'])>seconds(s['start']):
assert not s['available'] and not (p.parent/s['path']).exists();removed+=1
assert kept and removed,(kept,removed)
assert any(s['kind']=='Removed' for s in m['session']['spans'])
assert len(list(root.glob('clipbin/clips/*.mp4')))==1
print('PASS forced tiny-budget cleanup preserved bookmark footage and clip, removed neighbours and persisted Removed spans')
# Earlier unprotected files are gone: the retained bookmark must still remux
# using its persisted per-stream clock, without scanning those deleted files.
export=pathlib.Path(sys.argv[3])/'bookmark-after-cleanup.mp4'
subprocess.run([sys.argv[2]+'/target/debug/examples/export',str(p.parent.parent),str(m['session']['id']),str(int(start*1000)),str(int(end*1000)),str(export)],check=True)
subprocess.run(['ffmpeg','-v','error','-xerror','-i',str(export),'-f','null','-'],check=True)
packets=json.loads(subprocess.check_output(['ffprobe','-v','error','-select_streams','v:0','-show_packets','-of','json',str(export)]))['packets']
pts=sorted(float(p['pts_time']) for p in packets);fps=next(s['frames_per_second'] for s in m['segments'] if s['available'])
assert all(abs(b-a-1/fps)<2e-6 for a,b in zip(pts,pts[1:]))
print('PASS protected bookmark exported after preceding source segments were deleted')
PY
quit_app
echo 'PASS in-app clip and bookmark paths; portal GlobalShortcuts unavailable in wlr rig (real Hyprland remains unvalidated)'
INNER
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# X11 native grabs under the rig's Xwayland; portal actions need real Hyprland.
set -euo pipefail
command -v xdotool >/dev/null || { echo "Install xdotool to inject X11 hotkeys" >&2; exit 127; }
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cargo build --locked -p clipbin-core --example hotkeys --manifest-path "${repo}/Cargo.toml"
export CLIPBIN_HOTKEY_REPO="${repo}"
"${repo}/scripts/rig/headless.sh" --x11 bash <<'INNER'
set -euo pipefail
log="${CLIPBIN_RIG_DIR}/hotkeys.log"
env -u WAYLAND_DISPLAY "${CLIPBIN_HOTKEY_REPO}/target/debug/examples/hotkeys" > "${log}" 2>&1 & helper=$!
trap 'kill "${helper}" 2>/dev/null || true' EXIT
sleep 2
swaymsg '[title="clipbin-test-game"] focus' > /dev/null
xdotool key ctrl+shift+F9
sleep 0.3
xdotool key ctrl+shift+F10
if ! wait "${helper}"; then cat "${log}"; exit 1; fi
cat "${log}"
grep -q 'PASS native clip and bookmark hotkeys' "${log}"
INNER