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}")