#!/bin/bash # record_ui.sh DISPLAY PORT OUTDIR [STORY] [PLAYBACK_RATE]: record enclosure_ui_mcp.py --story STORY (v2 or v3, # default v3) driving the app on X display DISPLAY (1920x1080, started with --control PORT) to # OUTDIR/enclosure-demo-STORY.mp4 (H.264) and .gif, with captions and a drawn pointer (the app # gets synthetic pointer events, so the screen's own pointer never moves). Needs ffmpeg with # x11grab and libass. # PLAYBACK_RATE defaults to 1; label accelerated previews with their rate when publishing. # SOLVECRAFT_DEMO_NAME overrides the filename stem; PAUSE scales UI pauses, CAPTION is the # minimum real-time caption duration, and HOLD keeps the live completed model on screen. # For a readable 4x preview use SOLVECRAFT_DEMO_CAPTION=4 and SOLVECRAFT_DEMO_HOLD=8. set -e DISP=${1:-:0}; PORT=${2:-7878}; OUT=$(cd "${3:-.}" && pwd); STORY=${4:-v3}; RATE=${5:-1} HERE=$(cd "$(dirname "$0")" && pwd) CLI=${SOLVECRAFT_CLI:-solvecraft-cli} NAME=${SOLVECRAFT_DEMO_NAME:-enclosure-demo-$STORY} PAUSE=${SOLVECRAFT_DEMO_PAUSE:-0.55} CAPTION=${SOLVECRAFT_DEMO_CAPTION:-1.4} HOLD=${SOLVECRAFT_DEMO_HOLD:-1} python3 -c 'import math,sys; r=float(sys.argv[1]); assert math.isfinite(r) and 0=1910 and vp[1]+vp[3]>=1000: break time.sleep(.1) else: raise RuntimeError("the capture window did not resize") PY # x11grab stamps frames with the wall clock: its "start:" is when the first frame was taken. ffmpeg -loglevel info -nostats -y -f x11grab -draw_mouse 0 -video_size 1920x1080 -framerate 30 -i "$DISP" \ -c:v libx264 -preset veryfast -crf 16 -pix_fmt yuv420p "$OUT/raw.mp4" 2> "$OUT/ffmpeg.log" & FF=$! # Finalize the recording even when the modelling script refuses an unhealthy take. # Waiting lets ffmpeg write the MP4 index so the failed take remains inspectable. trap 'kill -INT $FF 2>/dev/null || true; wait $FF 2>/dev/null || true' EXIT sleep 1 python3 "$HERE/enclosure_ui_mcp.py" --port "$PORT" --cli "$CLI" --story "$STORY" --speed "$PAUSE" --minimum-caption "$CAPTION" --log "$OUT/steps.log" --save "$OUT/enclosure-$STORY.solvecraft" sleep "$HOLD" kill -INT $FF; wait $FF || true START=$(grep -o "start: [0-9.]*" "$OUT/ffmpeg.log" | head -1 | cut -d" " -f2) python3 "$HERE/overlay.py" "$OUT/steps.log" "$START" "$OUT" read CX CY < "$OUT/cursor.start" ffmpeg -loglevel error -y -i "$OUT/raw.mp4" -loop 1 -i "$OUT/cursor.png" -loop 1 -i "$OUT/ripple.png" -filter_complex \ "[0:v]ass=$OUT/captions.ass[v0];[v0][2:v]overlay@rip=x=-100:y=-100:enable='$(cat "$OUT/ripple.enable")':shortest=1[v1];[v1][1:v]overlay@cur=x=$CX:y=$CY:shortest=1,sendcmd=f=$OUT/cursor.cmd[v]" \ -map "[v]" -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p -movflags +faststart "$OUT/real-time.mp4" # Composite first, then omit the marked private proof interval. The burned-in cursor, # ripples and captions remain attached to their actual frames when playback time closes. python3 - "$OUT/steps.log" "$START" "$RATE" "$OUT/public.filter" <<'PY' import json,math,pathlib,sys log,start,rate,destination=sys.argv[1:] start,rate=float(start),float(rate) p=pathlib.Path(log) if p.stat().st_size>128*1024*1024: raise RuntimeError("pointer log exceeds capture budget") markers=[] with p.open() as stream: for i,line in enumerate(stream): if i>=500000: raise RuntimeError("pointer log exceeds event budget") event=json.loads(line) for kind in ("private_start","private_end"): if event.get(kind): markers.append((kind,float(event["t"])-start)) if markers: if len(markers)!=2 or [k for k,_ in markers]!=["private_start","private_end"]: raise RuntimeError("expected one ordered private capture interval") a,b=(t for _,t in markers) if not all(math.isfinite(t) for t in (a,b)) or not 0<=a