/ scripts / generate-overlay.py
generate-overlay.py
  1  #!/usr/bin/env python3
  2  """Generate rough hand-drawn ellipse overlay for demo video.
  3  
  4  Creates pen-stroke style ellipses that:
  5  - Draw progressively (counter-clockwise from bottom)
  6  - Have varying stroke width (wider at start, thinner at end)
  7  - Overlap slightly (~375°) instead of closing perfectly
  8  - Stay visible during explanation, then fade out
  9  """
 10  
 11  import os
 12  import math
 13  import random
 14  import subprocess
 15  import json
 16  from PIL import Image, ImageDraw
 17  
 18  # Video dimensions
 19  WIDTH = 750
 20  HEIGHT = 500
 21  SCALE = 2  # Render at 2x for anti-aliasing
 22  
 23  # Get exact framerate and frame count from the video file
 24  VIDEO_FILE = os.environ.get("VIDEO_FILE") or os.path.join(
 25      os.path.dirname(__file__), "..", "docs", "assets", "demo.mp4"
 26  )
 27  
 28  def get_video_info(video_path):
 29      """Query video for exact framerate and frame count using ffprobe."""
 30      cmd = [
 31          "ffprobe", "-v", "error", "-select_streams", "v:0",
 32          "-show_entries", "stream=r_frame_rate,nb_frames,duration",
 33          "-of", "json", video_path
 34      ]
 35      result = subprocess.run(cmd, capture_output=True, text=True)
 36      data = json.loads(result.stdout)
 37      stream = data["streams"][0]
 38      
 39      # Parse framerate fraction (e.g., "58/3")
 40      fps_str = stream["r_frame_rate"]
 41      if "/" in fps_str:
 42          num, den = fps_str.split("/")
 43          fps = float(num) / float(den)
 44      else:
 45          fps = float(fps_str)
 46      
 47      nb_frames = int(stream["nb_frames"])
 48      duration = float(stream["duration"])
 49      return fps, nb_frames, duration
 50  
 51  if os.path.exists(VIDEO_FILE):
 52      FPS, NUM_FRAMES, DURATION = get_video_info(VIDEO_FILE)
 53      print(f"Video: {VIDEO_FILE}")
 54      print(f"  FPS: {FPS:.4f} ({NUM_FRAMES} frames / {DURATION:.2f}s)")
 55  else:
 56      # Fallback if video doesn't exist yet
 57      FPS = 58 / 3  # ~19.33 fps (typical x11grab rate)
 58      DURATION = 55
 59      NUM_FRAMES = int(FPS * DURATION)
 60      print(f"Warning: Video not found, using fallback FPS={FPS:.4f}")
 61  
 62  # Time offset to sync ellipses with video content
 63  # Negative = ellipses appear earlier, Positive = ellipses appear later
 64  # Analysis shows:
 65  #   - Code actions: +3ms lag (perfect)
 66  #   - Navigation: -120ms lag (2 frames early - ellipse moves before video)
 67  # Using +0.06s splits the difference: navigation ~1 frame early, code actions ~1 frame late
 68  SYNC_OFFSET = 0.06
 69  
 70  
 71  # Allow output dir override via env var (for local/CI flexibility)
 72  OUTPUT_DIR = os.environ.get("OVERLAY_FRAME_DIR") or os.path.join(os.path.dirname(__file__), "tmp")
 73  os.makedirs(OUTPUT_DIR, exist_ok=True)
 74  
 75  
 76  def generate_wobbly_path(cx, cy, rx, ry, seed, num_points=150):
 77      """Generate a smooth spiral-like ellipse path (not perfectly closed).
 78      
 79      Returns list of (x, y) points for ~375° of ellipse,
 80      starting at bottom, going counter-clockwise (on screen).
 81      Spiral grows outward so end is further from center than start.
 82      """
 83      random.seed(seed)
 84      
 85      points = []
 86      # ~375° = 1.04 * 2π
 87      start_angle = math.pi / 2  # bottom
 88      total_arc = 2.08 * math.pi  # ~375°
 89      
 90      for i in range(num_points + 1):
 91          progress = i / num_points
 92          # Subtract to go counter-clockwise on screen (Y-down coords)
 93          angle = start_angle - progress * total_arc
 94          
 95          # Spiral effect: radius increases as we draw (more open at end)
 96          spiral_growth = progress * rx * 0.15  # 15% growth for visible spiral
 97          
 98          # Subtle smooth variation for organic feel
 99          smooth_var = math.sin(angle * 2) * rx * 0.02 + math.sin(angle * 3.7) * rx * 0.015
100          
101          current_rx = rx + spiral_growth + smooth_var
102          current_ry = ry + spiral_growth * 0.4 + smooth_var * 0.5
103          
104          x = cx + current_rx * math.cos(angle)
105          y = cy + current_ry * math.sin(angle)
106          points.append((x, y))
107      
108      return points
109  
110  
111  def draw_stroke_segment(draw, points, start_idx, end_idx, base_color, base_alpha, 
112                          start_width, end_width):
113      """Draw a segment of the stroke with varying width."""
114      if end_idx <= start_idx:
115          return
116      
117      total_points = len(points) - 1
118      
119      for i in range(start_idx, min(end_idx, len(points) - 1)):
120          # Progress within this segment for width interpolation
121          segment_progress = (i - start_idx) / max(1, end_idx - start_idx)
122          width = start_width + (end_width - start_width) * segment_progress
123          width = max(1, int(width))
124          
125          # Slight alpha variation along stroke
126          alpha_var = 0.9 + 0.1 * math.sin(i * 0.3)
127          alpha = int(base_alpha * alpha_var)
128          
129          color = (base_color[0], base_color[1], base_color[2], alpha)
130          
131          # Draw line segment
132          draw.line([points[i], points[i + 1]], fill=color, width=width)
133          
134          # Draw small circle at joints for smoother look
135          if width > 2:
136              x, y = points[i]
137              r = width / 2 - 0.5
138              draw.ellipse([x - r, y - r, x + r, y + r], fill=color)
139  
140  
141  def ease_out_cubic(t):
142      """Ease out - starts fast, slows down."""
143      return 1 - pow(1 - t, 3)
144  
145  
146  def ease_in_quad(t):
147      """Ease in - starts slow, speeds up."""
148      return t * t
149  
150  
151  
152  # === New: Read shapes from JSONL and scroll from CSV ===
153  import json
154  
155  ELLIPSES_FILE = os.path.join(os.path.dirname(__file__), "ellipses.jsonl")
156  
157  def load_ellipses(jsonl_path):
158      """Load shapes and their position timelines from JSONL.
159      
160      Handles:
161      - Shape definitions with shape field ("ellipse", "none" for fade markers)
162      - Legacy format without shape field (treated as ellipse)
163      - Move events that update shape positions over time
164      - Groups shapes and determines fade times based on group transitions
165      """
166      shapes = {}
167      move_events = []  # Collect move events separately
168      all_groups = set()  # Track all groups including fade markers
169      
170      with open(jsonl_path, "r") as f:
171          for line in f:
172              if line.strip():
173                  evt = json.loads(line)
174                  if evt.get("type") == "scroll":
175                      continue  # skip scroll events
176                  elif evt.get("type") in ("move", "after", "after-move"):
177                      # Position update for an existing shape
178                      move_events.append(evt)
179                  elif evt.get("type") in ("before", "before-move"):
180                      continue  # skip 'before' events
181                  elif "t_draw" in evt:
182                      shape_type = evt.get("shape", "ellipse")  # Default to ellipse for legacy
183                      group = evt.get("group", evt.get("id", str(len(shapes))))
184                      all_groups.add(group)
185                      
186                      if shape_type == "none":
187                          # Fade marker - just records group for fade timing
188                          continue
189                      elif shape_type == "ellipse" and "cx" in evt and "cy" in evt:
190                          # Ellipse shape
191                          ell_id = evt.get("id", str(len(shapes)))
192                          ell = evt.copy()
193                          # Defaults
194                          ell.setdefault("draw_duration", 1.0)
195                          ell.setdefault("fade_duration", 0.5)
196                          ell.setdefault("start_width", 8)
197                          ell.setdefault("end_width", 4)
198                          ell.setdefault("color", [25, 50, 120])
199                          ell.setdefault("spiral", 0.25)
200                          ell.setdefault("seed", ell_id)
201                          ell.setdefault("group", ell_id)
202                          ell["color"] = tuple(ell["color"])
203                          # Position timeline: list of (t, cx, cy) for interpolation
204                          ell["position_timeline"] = [(ell["t_draw"], ell["cx"], ell["cy"])]
205                          shapes[ell_id] = ell
206      
207      # Apply move events to build position timelines
208      # Track all movements accurately (renderer will clip if needed)
209      for move in move_events:
210          ell_id = move.get("id")
211          if ell_id in shapes:
212              t = move["t"]
213              cx = move["cx"]
214              cy = move["cy"]
215              shapes[ell_id]["position_timeline"].append((t, cx, cy))
216      
217      # Sort position timelines by time
218      for ell in shapes.values():
219          ell["position_timeline"].sort(key=lambda x: x[0])
220      
221      # Convert to list
222      ellipse_list = list(shapes.values())
223      
224      # Re-read file to get group timing info (including fade markers)
225      group_draw_times = {}  # group -> earliest t_draw
226      with open(jsonl_path, "r") as f:
227          for line in f:
228              if line.strip():
229                  evt = json.loads(line)
230                  if "t_draw" in evt and "group" in evt:
231                      g = evt["group"]
232                      t = evt["t_draw"]
233                      if g not in group_draw_times or t < group_draw_times[g]:
234                          group_draw_times[g] = t
235      
236      # Sort groups by their draw time
237      sorted_groups = sorted(group_draw_times.keys(), key=lambda g: group_draw_times[g])
238      
239      # For each group, find when the next group starts (that's when this group fades)
240      group_fade_times = {}
241      for i, g in enumerate(sorted_groups):
242          if i + 1 < len(sorted_groups):
243              next_g = sorted_groups[i + 1]
244              group_fade_times[g] = group_draw_times[next_g]
245          else:
246              # Last group: default fade after 10s
247              group_fade_times[g] = group_draw_times[g] + 10.0
248      
249      # Post-process: set t_fade based on group fade time
250      for ell in ellipse_list:
251          group = ell.get("group", ell.get("id", 0))
252          t_draw = ell["t_draw"]
253          t_fade = group_fade_times.get(group, t_draw + 10.0)
254          ell["t_fade"] = t_fade
255          # Compute durations for animation
256          ell["draw_duration"] = ell.get("draw_duration", 1.0)
257          ell["fade_duration"] = ell.get("fade_duration", 0.5)
258          ell["hold_duration"] = max(0.1, ell["t_fade"] - ell["t_draw"] - ell["draw_duration"])
259          ell["start"] = ell["t_draw"]
260          ell["end"] = ell["t_fade"] + ell["fade_duration"]
261      
262      return ellipse_list
263  
264  
265  def get_ellipse_position(ell, t):
266      """Get (cx, cy) for ellipse at time t - uses most recent position, no interpolation.
267      
268      ############################################################################
269      # IMPORTANT: X-AXIS CHANGES ARE INTENTIONALLY SUPPRESSED IN RENDERING!
270      # 
271      # The position timeline tracks both X and Y changes from Emacs, but we only
272      # use Y-axis changes for rendering. This is because:
273      # 1. X changes are typically tiny cursor position adjustments
274      # 2. X movement looks jittery and distracting in the video
275      # 3. The ellipse should stay horizontally stable around its target
276      #
277      # The X position is fixed to the INITIAL cx value from when the ellipse
278      # was first drawn. Only cy (vertical position) updates over time.
279      #
280      # If you need X tracking in the future, the data is still logged in
281      # ellipses.jsonl - just change this function to use best_pos[1] for cx.
282      ############################################################################
283      """
284      timeline = ell.get("position_timeline", [])
285      if not timeline:
286          return ell["cx"], ell["cy"]
287      
288      # Use INITIAL X position (never changes) - see docstring above
289      initial_cx = ell["cx"]
290      
291      # Find the most recent Y position at or before time t
292      best_pos = timeline[0]  # Start with first position
293      for pos in timeline:
294          if pos[0] <= t:
295              best_pos = pos
296          else:
297              break  # Timeline is sorted, no need to continue
298      
299      # Return fixed X, dynamic Y
300      return initial_cx, best_pos[2]
301  
302  
303  # Extract scroll events from ellipses.jsonl and build scroll timeline
304  def load_scroll_from_jsonl(jsonl_path):
305      scroll_points = []
306      with open(jsonl_path, "r") as f:
307          for line in f:
308              if line.strip():
309                  evt = json.loads(line)
310                  if evt.get("type") == "scroll":
311                      t = float(evt["t"])
312                      y = float(evt["offset"])
313                      scroll_points.append((t, y))
314      scroll_points.sort()
315      return scroll_points
316  
317  def get_scroll_y(scroll_points, t):
318      if not scroll_points:
319          return 0
320      for i in range(len(scroll_points) - 1):
321          t0, y0 = scroll_points[i]
322          t1, y1 = scroll_points[i + 1]
323          if t0 <= t <= t1:
324              f = (t - t0) / (t1 - t0)
325              return y0 + (y1 - y0) * f
326      return scroll_points[-1][1]
327  
328  # Patch: allow spiral as parameter
329  def generate_wobbly_path(cx, cy, rx, ry, seed, num_points=150, spiral=0.25):
330      random.seed(seed)
331      points = []
332      start_angle = math.pi / 2
333      total_arc = 2.08 * math.pi
334      for i in range(num_points + 1):
335          progress = i / num_points
336          angle = start_angle - progress * total_arc
337          spiral_growth = progress * rx * spiral
338          smooth_var = math.sin(angle * 2) * rx * 0.02 + math.sin(angle * 3.7) * rx * 0.015
339          current_rx = rx + spiral_growth + smooth_var
340          current_ry = ry + spiral_growth * 0.4 + smooth_var * 0.5
341          x = cx + current_rx * math.cos(angle)
342          y = cy + current_ry * math.sin(angle)
343          points.append((x, y))
344      return points
345  
346  
347  
348  ellipses = load_ellipses(ELLIPSES_FILE)
349  scroll_points = load_scroll_from_jsonl(ELLIPSES_FILE)
350  
351  # Count move events for reporting
352  move_count = sum(len(ell.get("position_timeline", [])) - 1 for ell in ellipses)
353  
354  # All times in JSONL are now relative to video start (t0), no adjustment needed
355  print(f"Loaded {len(ellipses)} ellipses, {move_count} move events, {len(scroll_points)} scroll events")
356  for ell in ellipses:
357      moves = len(ell.get("position_timeline", [])) - 1
358      print(f"  {ell['id']}: group={ell.get('group')}, t_draw={ell['t_draw']:.1f}, t_fade={ell['t_fade']:.1f}, moves={moves}")
359  print(f"Generating {NUM_FRAMES} frames at {FPS:.4f} fps...")
360  
361  for frame_num in range(NUM_FRAMES):
362      t = frame_num / FPS  # Current time in video (seconds)
363      # For position lookup, apply sync offset (negative = look ahead in timeline)
364      t_pos = t - SYNC_OFFSET  # Subtract negative offset = add to look ahead
365      current_scroll = get_scroll_y(scroll_points, t_pos)
366      img = Image.new('RGBA', (WIDTH * SCALE, HEIGHT * SCALE), (0, 0, 0, 0))
367      draw = ImageDraw.Draw(img)
368      for ell in ellipses:
369          # Times are relative to video start
370          if t < ell["start"] or t > ell["end"]:
371              continue
372          local_t = t - ell["start"]
373          
374          # Get interpolated position for this ellipse at time t_pos (with sync offset)
375          cx, cy = get_ellipse_position(ell, t_pos)
376          
377          # Generate path centered at current position
378          path = generate_wobbly_path(cx, cy, ell["rx"], ell["ry"], ell["seed"], 
379                                      num_points=150, spiral=ell["spiral"])
380          path = [(x * SCALE, y * SCALE) for x, y in path]
381          
382          num_points = len(path) - 1
383          if local_t < ell["draw_duration"]:
384              draw_progress = ease_out_cubic(local_t / ell["draw_duration"])
385              draw_end_idx = int(draw_progress * num_points)
386              alpha = 160
387          elif local_t < ell["draw_duration"] + ell["hold_duration"]:
388              draw_end_idx = num_points
389              alpha = 160
390          else:
391              fade_t = local_t - ell["draw_duration"] - ell["hold_duration"]
392              fade_progress = ease_in_quad(fade_t / ell["fade_duration"])
393              draw_end_idx = num_points
394              alpha = int(160 * (1 - fade_progress))
395          if draw_end_idx > 0:
396              draw_stroke_segment(
397                  draw, path, 0, draw_end_idx,
398                  ell["color"], alpha,
399                  ell["start_width"] * SCALE, ell["end_width"] * SCALE
400              )
401      img = img.resize((WIDTH, HEIGHT), Image.LANCZOS)
402      img.save(f"{OUTPUT_DIR}/frame_{frame_num:05d}.png")
403      if frame_num % 100 == 0:
404          print(f"  Frame {frame_num}/{NUM_FRAMES}")
405  print(f"Done! Frames in {OUTPUT_DIR}")