record-demo-director.sh
1 #!/bin/bash 2 # Automated demo recording for code-action-quick using emacs-director 3 # Records specific window by ID, merges with pre-generated TTS narration 4 5 set -e 6 7 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 8 PROJECT_DIR="$(dirname "$SCRIPT_DIR")" 9 DEMO_FILE="$PROJECT_DIR/test/fixtures/demo/src/main.rs" 10 OUTPUT_DIR="$PROJECT_DIR/docs/assets" 11 12 # Configuration 13 FRAME_PIXEL_WIDTH=750 14 FRAME_PIXEL_HEIGHT=500 15 RECORD_DURATION=55 # Will be adjusted based on narration length 16 LSP_WAIT=12 17 18 mkdir -p "$OUTPUT_DIR" 19 20 # Output files 21 VIDEO_FILE="$OUTPUT_DIR/demo.mp4" 22 VIDEO_RAW="$OUTPUT_DIR/demo-raw.mp4" 23 GIF_FILE="$OUTPUT_DIR/demo.gif" 24 25 # Signal files for coordination with Emacs 26 READY_FILE="/tmp/demo-director-ready" 27 DONE_FILE="/tmp/demo-director-done" 28 T0_FILE="/tmp/demo-t0" 29 30 # Clean up signal files 31 rm -f "$READY_FILE" "$DONE_FILE" "$T0_FILE" 32 33 # Reset demo file to initial state 34 echo "[0/8] Resetting demo file to initial state..." 35 cd "$PROJECT_DIR" 36 if git diff --quiet -- "$DEMO_FILE" 2>/dev/null; then 37 echo " Demo file already clean" 38 else 39 git checkout -- "$DEMO_FILE" 40 echo " Demo file reset" 41 fi 42 43 echo "=== Automated Demo Recording (director) ===" 44 echo "Output: $VIDEO_FILE" 45 echo "" 46 47 # Clean up old ellipse log and overlay frames 48 ELLIPSES_FILE="$SCRIPT_DIR/ellipses.jsonl" 49 TMP_OVERLAY_DIR="$SCRIPT_DIR/tmp" 50 rm -f "$ELLIPSES_FILE" 51 rm -rf "$TMP_OVERLAY_DIR" 52 mkdir -p "$TMP_OVERLAY_DIR" 53 54 cleanup() { 55 echo "Cleaning up..." 56 # Kill any remaining Emacs processes from this script 57 pkill -f "emacs.*demo-director" 2>/dev/null || true 58 [[ -n "$FFMPEG_PID" ]] && kill "$FFMPEG_PID" 2>/dev/null || true 59 [[ -n "$EMACS_PID" ]] && kill "$EMACS_PID" 2>/dev/null || true 60 # Restore demo file 61 cd "$PROJECT_DIR" 62 git checkout -- "$DEMO_FILE" 2>/dev/null || true 63 # Clean up intermediate files 64 [[ -f "$VIDEO_RAW" ]] && rm -f "$VIDEO_RAW" 65 rm -f "$READY_FILE" "$DONE_FILE" "$T0_FILE" 66 } 67 trap cleanup EXIT 68 69 # Step 1: Check for pre-generated narration 70 echo "[1/8] Checking TTS narration..." 71 NARRATION_FILE="$OUTPUT_DIR/demo-narration.mp3" 72 if [[ -s "$NARRATION_FILE" ]]; then 73 AUDIO_DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$NARRATION_FILE" 2>/dev/null | cut -d. -f1) 74 echo " Using existing narration: $(du -h "$NARRATION_FILE" | cut -f1), ${AUDIO_DURATION}s" 75 AUDIO_FILE="$NARRATION_FILE" 76 if [[ -n "$AUDIO_DURATION" && "$AUDIO_DURATION" -gt 0 ]]; then 77 RECORD_DURATION=$((AUDIO_DURATION + 2)) 78 echo " Adjusted video duration to ${RECORD_DURATION}s" 79 fi 80 elif [[ -x "$SCRIPT_DIR/generate-narration.sh" ]]; then 81 echo " Generating narration..." 82 "$SCRIPT_DIR/generate-narration.sh" 83 if [[ -s "$NARRATION_FILE" ]]; then 84 AUDIO_DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$NARRATION_FILE" 2>/dev/null | cut -d. -f1) 85 AUDIO_FILE="$NARRATION_FILE" 86 RECORD_DURATION=$((AUDIO_DURATION + 2)) 87 echo " Adjusted video duration to ${RECORD_DURATION}s" 88 else 89 echo " WARNING: Narration generation failed, continuing without audio" 90 AUDIO_FILE="" 91 fi 92 else 93 echo " WARNING: No narration found, continuing without audio" 94 AUDIO_FILE="" 95 fi 96 97 # Step 2: Prepare environment 98 echo "[2/8] Preparing environment..." 99 pkill -f "emacs.*demo-director" 2>/dev/null || true 100 sleep 1 101 cd "$PROJECT_DIR" 102 git checkout -- "$DEMO_FILE" 2>/dev/null || true 103 104 # Step 3: Start Emacs with director script 105 echo "[3/8] Starting Emacs with director..." 106 export DEMO_PROJECT_DIR="$PROJECT_DIR" 107 export DEMO_FILE="$DEMO_FILE" 108 export DEMO_FRAME_WIDTH="$FRAME_PIXEL_WIDTH" 109 export DEMO_FRAME_HEIGHT="$FRAME_PIXEL_HEIGHT" 110 export DEMO_LSP_WAIT="$LSP_WAIT" 111 export DEMO_SYNC_OFFSET="${SYNC_OFFSET:-0.0}" 112 113 # Start Emacs in background with user's init.el (for theming/customization) 114 emacs -l "$SCRIPT_DIR/demo-director.el" & 115 EMACS_PID=$! 116 117 # Step 4: Wait for Emacs to be ready (it writes window ID to ready file) 118 echo "[4/8] Waiting for Emacs setup (LSP init ~${LSP_WAIT}s)..." 119 WAIT_COUNT=0 120 MAX_WAIT=$((LSP_WAIT + 30)) 121 while [[ ! -f "$READY_FILE" ]]; do 122 sleep 1 123 WAIT_COUNT=$((WAIT_COUNT + 1)) 124 if [[ $WAIT_COUNT -ge $MAX_WAIT ]]; then 125 echo "ERROR: Emacs setup timed out after ${MAX_WAIT}s" 126 exit 1 127 fi 128 if ! kill -0 "$EMACS_PID" 2>/dev/null; then 129 echo "ERROR: Emacs process died during setup" 130 exit 1 131 fi 132 done 133 134 # Read window ID from ready file 135 WINDOW_ID=$(cat "$READY_FILE" | tr -d '[:space:]') 136 echo " Emacs ready after ${WAIT_COUNT}s, Window ID: $WINDOW_ID" 137 138 if [[ -z "$WINDOW_ID" || "$WINDOW_ID" == "nil" ]]; then 139 echo "ERROR: Invalid window ID" 140 exit 1 141 fi 142 143 # Step 5: Start recording 144 echo "[5/8] Starting recording (${RECORD_DURATION}s)..." 145 146 # Determine output target 147 if [[ -n "$AUDIO_FILE" && -s "$AUDIO_FILE" ]]; then 148 RECORD_TARGET="$VIDEO_RAW" 149 else 150 RECORD_TARGET="$VIDEO_FILE" 151 fi 152 153 # Make dimensions even for libx264 154 REC_W=$((FRAME_PIXEL_WIDTH / 2 * 2)) 155 REC_H=$((FRAME_PIXEL_HEIGHT / 2 * 2)) 156 157 # Start ffmpeg recording 158 nohup ffmpeg -y -f x11grab -framerate 15 -window_id "$WINDOW_ID" -i :0.0 \ 159 -t "$RECORD_DURATION" -c:v libx264 -preset ultrafast -crf 18 \ 160 -vf "scale=${REC_W}:${REC_H}" \ 161 "$RECORD_TARGET" </dev/null >/tmp/ffmpeg.log 2>&1 & 162 FFMPEG_PID=$! 163 164 # Wait for ffmpeg to actually start capturing 165 sleep 1.0 166 167 # Verify ffmpeg is running before signaling 168 if ! kill -0 "$FFMPEG_PID" 2>/dev/null; then 169 echo "ERROR: ffmpeg failed to start!" 170 cat /tmp/ffmpeg.log 171 exit 1 172 fi 173 174 # Capture T0 and write to file for Emacs to read 175 T0=$(python3 -c 'import time; print(time.time())') 176 echo "$T0" > "$T0_FILE" 177 echo " Recording started, T0=$T0" 178 179 # Step 6: Wait for demo to complete or recording to finish 180 echo "[6/8] Running demo sequence..." 181 DEMO_TIMEOUT=$((RECORD_DURATION + 10)) 182 WAIT_COUNT=0 183 while [[ ! -f "$DONE_FILE" ]]; do 184 sleep 1 185 WAIT_COUNT=$((WAIT_COUNT + 1)) 186 187 # Check if Emacs is still running 188 if ! kill -0 "$EMACS_PID" 2>/dev/null; then 189 echo " Emacs finished after ${WAIT_COUNT}s" 190 break 191 fi 192 193 # Check if ffmpeg is still running 194 if ! kill -0 "$FFMPEG_PID" 2>/dev/null; then 195 echo " Recording finished after ${WAIT_COUNT}s" 196 break 197 fi 198 199 if [[ $WAIT_COUNT -ge $DEMO_TIMEOUT ]]; then 200 echo " Demo timeout after ${WAIT_COUNT}s" 201 break 202 fi 203 done 204 205 # Wait for ffmpeg to finish 206 wait $FFMPEG_PID 2>/dev/null || true 207 unset FFMPEG_PID 208 209 # Wait for Emacs to write ellipses file (DONE_FILE signals completion) 210 echo " Waiting for ellipses file..." 211 ELLIPSE_WAIT=0 212 while [[ ! -f "$DONE_FILE" && $ELLIPSE_WAIT -lt 5 ]]; do 213 sleep 0.5 214 ELLIPSE_WAIT=$((ELLIPSE_WAIT + 1)) 215 done 216 217 # Give Emacs a moment to finish writing 218 sleep 0.5 219 220 # Kill Emacs if still running 221 kill "$EMACS_PID" 2>/dev/null || true 222 wait "$EMACS_PID" 2>/dev/null || true 223 unset EMACS_PID 224 225 # Step 7: Post-process video 226 echo "[7/8] Post-processing video..." 227 if [[ ! -s "$RECORD_TARGET" ]]; then 228 echo "ERROR: Video file empty or missing!" 229 cat /tmp/ffmpeg.log 230 exit 1 231 fi 232 233 # Merge audio if we have it 234 if [[ -n "$AUDIO_FILE" && -s "$AUDIO_FILE" && "$RECORD_TARGET" == "$VIDEO_RAW" ]]; then 235 echo " Merging audio with video..." 236 ffmpeg -y -i "$VIDEO_RAW" -i "$AUDIO_FILE" \ 237 -c:v copy -c:a aac -b:a 128k \ 238 -shortest \ 239 "$VIDEO_FILE" 2>/dev/null 240 if [[ -s "$VIDEO_FILE" ]]; then 241 echo " Audio merged successfully" 242 rm -f "$VIDEO_RAW" 243 else 244 echo " WARNING: Audio merge failed, using video without audio" 245 mv "$VIDEO_RAW" "$VIDEO_FILE" 246 fi 247 fi 248 249 # Generate overlay frames and composite 250 echo " Generating overlay frames..." 251 export OVERLAY_FRAME_DIR="$TMP_OVERLAY_DIR" 252 export VIDEO_FILE="$VIDEO_FILE" 253 python3 "$SCRIPT_DIR/generate-overlay.py" 254 255 OVERLAY_FRAMES="$TMP_OVERLAY_DIR/frame_%05d.png" 256 OVERLAYED_VIDEO="$OUTPUT_DIR/demo-overlay.mp4" 257 258 # Query framerate and frame count from captured video 259 VIDEO_FPS=$(ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of default=noprint_wrappers=1:nokey=1 "$VIDEO_FILE") 260 VIDEO_FRAME_COUNT=$(ffprobe -v error -select_streams v:0 -show_entries stream=nb_frames -of default=noprint_wrappers=1:nokey=1 "$VIDEO_FILE") 261 echo " Video: $VIDEO_FPS fps, $VIDEO_FRAME_COUNT frames" 262 263 # Composite overlay if frames exist 264 if ls "$TMP_OVERLAY_DIR"/*.png 1> /dev/null 2>&1; then 265 OVERLAY_FRAME_COUNT=$(ls -1 "$TMP_OVERLAY_DIR"/*.png | wc -l) 266 echo " Overlay: $OVERLAY_FRAME_COUNT frames" 267 echo " Compositing overlay (1:1 frame lockstep)..." 268 # Use image2 demuxer with exact framerate to read overlay frames 269 # eof_action=pass makes overlay continue with last frame if video is longer 270 ffmpeg -y -i "$VIDEO_FILE" -framerate "$VIDEO_FPS" -i "$OVERLAY_FRAMES" \ 271 -filter_complex "[0:v][1:v]overlay=eof_action=pass:format=auto" \ 272 -c:v libx264 -preset ultrafast -crf 18 -c:a copy "$OVERLAYED_VIDEO" 2>/dev/null 273 if [[ -s "$OVERLAYED_VIDEO" ]]; then 274 mv "$OVERLAYED_VIDEO" "$VIDEO_FILE" 275 echo " Overlay composited successfully" 276 else 277 echo " WARNING: Overlay compositing failed" 278 fi 279 else 280 echo " No overlay frames, skipping compositing" 281 fi 282 283 # Step 8: Create GIF 284 echo "[8/8] Creating GIF..." 285 ffmpeg -y -i "$VIDEO_FILE" \ 286 -vf "fps=12,scale=750:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" \ 287 "$GIF_FILE" 2>/dev/null 288 289 echo "" 290 echo "=== Done! ===" 291 [[ -s "$VIDEO_FILE" ]] && echo "Video: $VIDEO_FILE ($(du -h "$VIDEO_FILE" | cut -f1))" 292 [[ -s "$AUDIO_FILE" ]] && echo "Audio: $AUDIO_FILE ($(du -h "$AUDIO_FILE" | cut -f1))" 293 [[ -s "$GIF_FILE" ]] && echo "GIF: $GIF_FILE ($(du -h "$GIF_FILE" | cut -f1))" 294 echo "" 295 echo "Play: mpv $VIDEO_FILE"