feat: export clips, protect bookmarks and clean session storage
This commit is contained in:
Executable
+66
@@ -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')
|
||||
Executable
+143
@@ -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
|
||||
Executable
+21
@@ -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
|
||||
Reference in New Issue
Block a user