#!/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'])/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'])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