Add bounded session playback, synced audio and frame thumbnails
This commit is contained in:
Executable
+159
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env bash
|
||||
# Playback, active browsing, real thumbnails and audio in the private desktop rig.
|
||||
set -euo pipefail
|
||||
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
out="${1:-${repo}/target/playback-validation}"
|
||||
mkdir -p "${out}"
|
||||
out="$(cd "${out}" && pwd)"
|
||||
cargo build --locked --workspace --manifest-path "${repo}/Cargo.toml"
|
||||
cargo build --locked -p clipbin-media --example playback-check --manifest-path "${repo}/Cargo.toml"
|
||||
export CLIPBIN_PLAYBACK_REPO="${repo}" CLIPBIN_PLAYBACK_OUT="${out}"
|
||||
"${repo}/scripts/rig/headless.sh" bash <<'INNER'
|
||||
set -euo pipefail
|
||||
repo="${CLIPBIN_PLAYBACK_REPO}" out="${CLIPBIN_PLAYBACK_OUT}"
|
||||
export XDG_DATA_HOME="${out}/data" XDG_CONFIG_HOME="${out}/config" CLIPBIN_TRACE_FRAMES=1
|
||||
mkdir -p "${XDG_DATA_HOME}" "${XDG_CONFIG_HOME}"
|
||||
# ALSA's PipeWire plugin is cpal's fallback on Linux; there is no hardware card.
|
||||
cat > "${out}/alsa.conf" <<'ALSA'
|
||||
</usr/share/alsa/alsa.conf>
|
||||
pcm.!default { type pipewire }
|
||||
ctl.!default { type pipewire }
|
||||
ALSA
|
||||
export ALSA_CONFIG_PATH="${out}/alsa.conf"
|
||||
swaymsg '[title="clipbin-test-game"] fullscreen disable' > /dev/null
|
||||
send_key() {
|
||||
# Destroying rig-pointer's virtual seat can clear keyboard focus in Sway.
|
||||
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null
|
||||
sleep .2
|
||||
timeout 10 wtype -s 200 -P "$1" -s 100 -p "$1"
|
||||
}
|
||||
# Timeline bounds come from GPUI's trace rather than guessed screen positions.
|
||||
pointer_time() {
|
||||
python3 - "$1" "$2" "$3" "${XDG_DATA_HOME}" <<'PYPOINTER'
|
||||
import pathlib,re,json,sys
|
||||
log=pathlib.Path(sys.argv[1]).read_text()
|
||||
x,y,w,h=map(float,re.findall(r'playback_timeline x=([\d.]+) y=([\d.]+) width=([\d.]+) height=([\d.]+)',log)[-1])
|
||||
p=max(pathlib.Path(sys.argv[4]).glob('clipbin/sessions/*/session.json'),key=lambda p:int(p.parent.name))
|
||||
m=json.loads(p.read_text());length=m['session']['length'];length=length['secs']+length['nanos']/1e9
|
||||
if sys.argv[2] in ('gap','after-gap'):
|
||||
segment=next(s for s in m['segments'] if s['complete'] and not s['available'])
|
||||
def seconds(d):return d['secs']+d['nanos']/1e9
|
||||
at=(seconds(segment['start'])+seconds(segment['end']))/2 if sys.argv[2]=='gap' else seconds(segment['end'])+.5
|
||||
else:at=float(sys.argv[2])
|
||||
print(f"move {round(280+x+w*at/length)} {round(110+y+h/2)}\nsleep .15\n{sys.argv[3]}\nsleep .15")
|
||||
PYPOINTER
|
||||
}
|
||||
swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] floating enable' > /dev/null
|
||||
swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] resize set width 1360 px height 860 px' > /dev/null
|
||||
swaymsg 'for_window [app_id="dev.clipbin.Clipbin"] move position 280 110' > /dev/null
|
||||
"${repo}/target/debug/clipbin-desktop" > "${out}/active.log" 2>&1 & app=$!
|
||||
trap 'kill "${app:-}" "${game:-}" "${audio_capture:-}" 2>/dev/null || true' EXIT
|
||||
sleep 3
|
||||
swaymsg '[title="clipbin-test-game"] fullscreen disable' > /dev/null
|
||||
swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set width 1360 px height 860 px, move position 280 110, 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}/active.log"; then break; fi; sleep 1; done
|
||||
grep -q recorder_status=Recording "${out}/active.log"
|
||||
sleep 8
|
||||
grim "${out}/sessions-thumbnails.png"
|
||||
# Open the first session card, seek near the start, and play while recording continues.
|
||||
printf 'move 670 330\nsleep .2\ndown\nsleep .1\nup\nsleep 1\n' | "${repo}/target/debug/rig-pointer" 1920 1080
|
||||
grim "${out}/editor-open.png"
|
||||
pointer_time "${out}/active.log" 1 click | "${repo}/target/debug/rig-pointer" 1920 1080
|
||||
# GPUI/Wayland needs the virtual keyboard keymap to settle and a held key;
|
||||
# instantaneous wtype press/release occasionally disappears before dispatch.
|
||||
send_key space
|
||||
sleep 1
|
||||
grim "${out}/editor-playing-1.png"
|
||||
sleep 2
|
||||
grim "${out}/editor-playing-2.png"
|
||||
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 m['session']['active'];assert len([s for s in m['segments'] if s['complete'] and s['available']])>=4
|
||||
assert list(p.parent.rglob('*.thumbnail.png')), 'thumbnail worker produced no cached frame'
|
||||
print('PASS playback left recording active and generated real thumbnails')
|
||||
PY
|
||||
kill "${game}"; wait "${game}" 2>/dev/null || true
|
||||
sleep 3
|
||||
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null
|
||||
timeout 10 wtype -s 150 -M ctrl -s 100 -P q -s 100 -p 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 Ctrl+Q did not exit' >&2; exit 1; fi
|
||||
wait "${app}"
|
||||
kill "$(cat "${CLIPBIN_RIG_DIR}/game.pid")"
|
||||
# Simulate a cleaned-up middle segment in this isolated test library. Keep its
|
||||
# timeline placement: the player must show the gap rather than concatenate it away.
|
||||
python3 - "${XDG_DATA_HOME}" "${out}" <<'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());segments=[s for s in m['segments'] if s['complete'] and s['available']];assert len(segments)>=4
|
||||
s=segments[2];s['available']=False
|
||||
m['session']['spans']=[dict(start=m['session']['spans'][0]['start'],end=s['start'],kind='Footage'),dict(start=s['start'],end=s['end'],kind='Removed'),dict(start=s['end'],end=m['session']['length'],kind='Footage')]
|
||||
p.write_text(json.dumps(m));pathlib.Path(sys.argv[2]+'/session-id').write_text(str(m['session']['id']))
|
||||
PY
|
||||
id="$(cat "${out}/session-id")"
|
||||
# The CLI exercises the same player with audio only; recording tone has stopped.
|
||||
ffmpeg -v error -y -f pulse -i clipbin-rig-sink.monitor -t 10 "${out}/audio-output.wav" & audio_capture=$!
|
||||
python3 "${repo}/scripts/validate/measure-cpu.py" "${out}/player-cpu.txt" "${repo}/target/debug/examples/playback-check" "${XDG_DATA_HOME}/clipbin/sessions" "${id}" > "${out}/player.log" 2>&1
|
||||
wait "${audio_capture}"
|
||||
CLIPBIN_ROUTE="session:${id}" "${repo}/target/debug/clipbin-desktop" > "${out}/playback.log" 2>&1 & app=$!
|
||||
sleep 3
|
||||
swaymsg '[app_id="dev.clipbin.Clipbin"] floating enable, resize set width 1360 px height 860 px, move position 280 110, focus' > /dev/null
|
||||
# Drag through several files, then seek into the removed interval.
|
||||
{ pointer_time "${out}/playback.log" 1 down; pointer_time "${out}/playback.log" 3 "sleep 0.5"; pointer_time "${out}/playback.log" after-gap up; } | "${repo}/target/debug/rig-pointer" 1920 1080
|
||||
sleep 1
|
||||
grim "${out}/editor-seek.png"
|
||||
pointer_time "${out}/playback.log" gap click | "${repo}/target/debug/rig-pointer" 1920 1080
|
||||
sleep 1
|
||||
grim "${out}/editor-gap.png"
|
||||
pointer_time "${out}/playback.log" 1 $'scroll -1\nsleep 0.5\nscroll 1' | "${repo}/target/debug/rig-pointer" 1920 1080
|
||||
pointer_time "${out}/playback.log" 2.5 click | "${repo}/target/debug/rig-pointer" 1920 1080
|
||||
sleep .3
|
||||
send_key Right
|
||||
sleep .3
|
||||
send_key Left
|
||||
sleep .3
|
||||
swaymsg '[app_id="dev.clipbin.Clipbin"] focus' > /dev/null
|
||||
timeout 10 wtype -s 150 -M ctrl -s 100 -P q -s 100 -p 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 Ctrl+Q did not exit' >&2; exit 1; fi
|
||||
wait "${app}"
|
||||
python3 - "${out}" <<'PY'
|
||||
import pathlib,subprocess,re,sys,math,array,statistics,json
|
||||
out=pathlib.Path(sys.argv[1])
|
||||
def rgb(path):return subprocess.check_output(['ffmpeg','-v','error','-i',str(path),'-vf','crop=700:300:555:260','-f','rawvideo','-pix_fmt','rgb24','-'])
|
||||
a,b=map(rgb,[out/'editor-playing-1.png',out/'editor-playing-2.png'])
|
||||
changed=sum(x!=y for x,y in zip(a,b))/len(a);assert changed>.02, f'preview frozen: {changed}'
|
||||
raw=subprocess.check_output(['ffmpeg','-v','error','-i',str(out/'audio-output.wav'),'-ac','1','-ar','48000','-f','f32le','-'])
|
||||
samples=array.array('f',raw);rms=math.sqrt(sum(x*x for x in samples)/len(samples));assert rms>.001, f'no device audio: {rms}'
|
||||
def window_rms(start,end):
|
||||
block=samples[int(start*48000):int(end*48000)]
|
||||
return math.sqrt(sum(x*x for x in block)/max(1,len(block)))
|
||||
full=window_rms(2,3);half=window_rms(7.9,8.4);muted=window_rms(9.5,9.9)
|
||||
assert full>.001 and .35<half/full<.65 and muted<full*.02, f'volume/mute wrong: {full}, {half}, {muted}'
|
||||
log=(out/'player.log').read_text();assert 'state=gap' in log and 'state=frame' in log
|
||||
assert 'Cannot play audio' not in log and 'Audio output failed' not in log
|
||||
rows=re.findall(r'playback time=([\d.]+) decode_ms=([\d.]+).*presented=true',log)
|
||||
times=[float(row[1]) for row in rows];assert times
|
||||
print(f'PASS moving preview ({changed:.1%} pixels changed), device audio RMS={rms:.5f}, gaps and boundary playback')
|
||||
print(f'decode_ms median={statistics.median(times):.3f} p95={sorted(times)[int(len(times)*.95)]:.3f} max={max(times):.3f}')
|
||||
ui_log=(out/'playback.log').read_text()
|
||||
metadata=json.loads(next((out/'data/clipbin/sessions').glob('*/session.json')).read_text())
|
||||
def seconds(d):return d['secs']+d['nanos']/1e9
|
||||
removed=next(s for s in metadata['segments'] if s['complete'] and not s['available'])
|
||||
gap=(seconds(removed['start'])+seconds(removed['end']))/2
|
||||
gap_times=[float(t) for t in re.findall(r'playback time=([\d.]+).*state=gap',ui_log)]
|
||||
assert any(abs(t-gap)<.1 for t in gap_times), f'UI seek never reached gap at {gap}'
|
||||
frame_times=[(float(t),float(v)) for t,v in re.findall(r'playback time=([\d.]+).*state=frame.*video_time=([\d.]+)',ui_log)]
|
||||
assert any(abs(t-(seconds(removed['end'])+.5))<.1 and abs(t-v)<.020 for t,v in frame_times), 'UI boundary seek showed the wrong frame/time'
|
||||
# Right then Left must actually move the selected frame, while remaining paused.
|
||||
steps=[v for t,v in frame_times if abs(t-2.5)<.15]
|
||||
assert len(set(steps))>=2, f'frame stepping did not change the video frame: {steps}'
|
||||
uploads=[float(x) for x in re.findall(r'playback_ui upload_ms=([\d.]+)',(out/'active.log').read_text())]
|
||||
assert len(uploads)>30, 'too few moving frames reached GPUI'
|
||||
print(f'GPUI frame upload median={statistics.median(uploads):.3f}ms p95={sorted(uploads)[int(len(uploads)*.95)]:.3f}ms')
|
||||
(out/'report.txt').write_text(f'changed_preview_pixels={changed:.4f}\naudio_rms={rms:.5f}\ndecode_median_ms={statistics.median(times):.3f}\ndecode_p95_ms={sorted(times)[int(len(times)*.95)]:.3f}\n')
|
||||
PY
|
||||
echo "PASS playback validation; screenshots and timing in ${out}"
|
||||
INNER
|
||||
Reference in New Issue
Block a user