/ scripts / record-demo-director.sh
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"