145 lines
7.6 KiB
Bash
Executable File
145 lines
7.6 KiB
Bash
Executable File
#!/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}"
|
|
source "${repo}/scripts/rig/keys.sh"
|
|
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
|
|
rig_key q 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
|
|
rig_key q 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
|