lacy 1.8.11 → 1.8.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/.claude/settings.local.json +26 -0
  2. package/.github/FUNDING.yml +3 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.yml +49 -0
  4. package/.github/ISSUE_TEMPLATE/config.yml +5 -0
  5. package/.github/ISSUE_TEMPLATE/feature_request.yml +28 -0
  6. package/.github/PULL_REQUEST_TEMPLATE.md +17 -0
  7. package/.github/SECURITY.md +32 -0
  8. package/.github/assets/logo-horizontal-dark.png +0 -0
  9. package/.github/assets/logo-horizontal-dark.svg +17 -0
  10. package/.github/assets/logo-horizontal.png +0 -0
  11. package/.github/assets/logo-horizontal.svg +17 -0
  12. package/.github/assets/logo.png +0 -0
  13. package/.github/assets/logo.svg +12 -0
  14. package/.github/assets/social-preview.png +0 -0
  15. package/.github/assets/social-preview.svg +50 -0
  16. package/.github/dependabot.yml +21 -0
  17. package/.github/workflows/ci.yml +80 -0
  18. package/.github/workflows/dependabot-auto-merge.yml +32 -0
  19. package/CHANGELOG.md +366 -0
  20. package/CLAUDE.md +340 -0
  21. package/CONTRIBUTING.md +141 -0
  22. package/LICENSE +110 -0
  23. package/README.md +201 -31
  24. package/RELEASING.md +148 -0
  25. package/STYLE.md +202 -0
  26. package/assets/hero.jpeg +0 -0
  27. package/assets/mode-indicators.jpeg +0 -0
  28. package/assets/real-time-indicator.jpeg +0 -0
  29. package/assets/supported-tools.jpeg +0 -0
  30. package/bin/lacy +1028 -0
  31. package/docs/ADDING-BACKENDS.md +124 -0
  32. package/docs/DEVTO-ARTICLE.md +94 -0
  33. package/docs/DOCS.md +68 -0
  34. package/docs/GROWTH-STRATEGY.md +119 -0
  35. package/docs/HN-RESPONSES.md +122 -0
  36. package/docs/LAUNCH-COPY-FINAL.md +105 -0
  37. package/docs/MARKETING.md +411 -0
  38. package/docs/NATURAL_LANGUAGE_DETECTION.md +204 -0
  39. package/docs/UGC_VIDEO_SCRIPT.md +114 -0
  40. package/docs/articles/devto-how-i-made-my-terminal-understand-english.md +117 -0
  41. package/docs/demo-color-transition.gif +0 -0
  42. package/docs/demo-full.gif +0 -0
  43. package/docs/demo-indicator.gif +0 -0
  44. package/docs/launch-thread-may6.sh +158 -0
  45. package/docs/videos/README.md +189 -0
  46. package/docs/videos/generate_frames.py +510 -0
  47. package/docs/videos/generate_frames_v2.py +729 -0
  48. package/docs/videos/generate_short.py +328 -0
  49. package/docs/videos/generate_short_v2.py +526 -0
  50. package/docs/videos/lacy-shell-demo-v2.mp4 +0 -0
  51. package/docs/videos/lacy-shell-demo.mp4 +0 -0
  52. package/docs/videos/lacy-shell-short-v2.mp4 +0 -0
  53. package/docs/videos/lacy-shell-short.mp4 +0 -0
  54. package/install.sh +1009 -0
  55. package/lacy.plugin.bash +75 -0
  56. package/lacy.plugin.fish +43 -0
  57. package/lacy.plugin.zsh +65 -0
  58. package/lib/animations.zsh +3 -0
  59. package/lib/bash/completions.bash +40 -0
  60. package/lib/bash/execute.bash +233 -0
  61. package/lib/bash/init.bash +40 -0
  62. package/lib/bash/keybindings.bash +134 -0
  63. package/lib/bash/prompt.bash +85 -0
  64. package/lib/commands/info.sh +25 -0
  65. package/lib/config.zsh +3 -0
  66. package/lib/constants.zsh +3 -0
  67. package/lib/core/animations.sh +271 -0
  68. package/lib/core/commands.sh +297 -0
  69. package/lib/core/config.sh +340 -0
  70. package/lib/core/constants.sh +366 -0
  71. package/lib/core/context.sh +260 -0
  72. package/lib/core/detection.sh +417 -0
  73. package/lib/core/mcp.sh +741 -0
  74. package/lib/core/modes.sh +123 -0
  75. package/lib/core/preheat.sh +496 -0
  76. package/lib/core/spinner.sh +174 -0
  77. package/lib/core/telemetry.sh +99 -0
  78. package/lib/detection.zsh +3 -0
  79. package/lib/execute.zsh +3 -0
  80. package/lib/fish/config.fish +66 -0
  81. package/lib/fish/detection.fish +90 -0
  82. package/lib/fish/execute.fish +105 -0
  83. package/lib/fish/keybindings.fish +42 -0
  84. package/lib/fish/prompt.fish +30 -0
  85. package/lib/keybindings.zsh +3 -0
  86. package/lib/mcp.zsh +3 -0
  87. package/lib/modes.zsh +3 -0
  88. package/lib/preheat.zsh +3 -0
  89. package/lib/prompt.zsh +3 -0
  90. package/lib/spinner.zsh +3 -0
  91. package/lib/zsh/completions.zsh +60 -0
  92. package/lib/zsh/execute.zsh +294 -0
  93. package/lib/zsh/init.zsh +26 -0
  94. package/lib/zsh/keybindings.zsh +551 -0
  95. package/lib/zsh/prompt.zsh +90 -0
  96. package/package.json +42 -27
  97. package/packages/lacy/README.md +61 -0
  98. package/packages/lacy/commands/info.sh +25 -0
  99. package/{index.mjs → packages/lacy/index.mjs} +247 -20
  100. package/packages/lacy/package-lock.json +71 -0
  101. package/packages/lacy/package.json +42 -0
  102. package/script/release.ts +487 -0
  103. package/squirrel.toml +36 -0
  104. package/tests/test_bash.bash +163 -0
  105. package/tests/test_core.sh +607 -0
  106. package/tests/test_gemini.sh +119 -0
  107. package/tests/test_gemini_mcp.sh +126 -0
  108. package/tests/test_preheat_server.zsh +446 -0
  109. package/uninstall.sh +52 -0
@@ -0,0 +1,526 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Generate frames for Lacy Shell UGC SHORT video — V2 Enhanced (15s).
4
+
5
+ Quick hook → single demo → CTA. Maximum shareability.
6
+ Vertical 1080x1920 for TikTok / Instagram Reels / YouTube Shorts.
7
+
8
+ V2 improvements over v1:
9
+ - 60fps
10
+ - Gradient dark-purple background
11
+ - Glow/bloom on colored indicators
12
+ - Drop shadow on terminal window
13
+ - CRT scan-line overlay
14
+ - Variable typing speed
15
+ - Scene fade-in/out transitions
16
+ - Progress bar
17
+ - Accessibility captions
18
+ - Floating code particles
19
+ - Elastic spring animation on hook text
20
+ - Thumbnail export
21
+ """
22
+
23
+ import os
24
+ import math
25
+ import random
26
+ from PIL import Image, ImageDraw, ImageFont, ImageFilter
27
+
28
+ W, H = 1080, 1920
29
+ FPS = 60
30
+ OUT_DIR = "/tmp/ugc-video/frames-short-v2"
31
+ THUMBNAIL_PATH = "/tmp/ugc-video/lacy-shell-short-thumbnail.png"
32
+ FADE_FRAMES = 15 # 0.25s
33
+
34
+ # Colors
35
+ BG = (10, 8, 20)
36
+ BG2 = (16, 12, 30)
37
+ TERM_BG = (20, 20, 30)
38
+ WHITE = (235, 235, 240)
39
+ GRAY = (130, 130, 145)
40
+ DIM = ( 60, 60, 80)
41
+ GREEN = ( 52, 211, 153)
42
+ MAGENTA = (216, 100, 240)
43
+ BLUE = ( 96, 165, 250)
44
+ YELLOW = (250, 204, 21)
45
+
46
+
47
+ # ── Pre-computed assets ──────────────────────────────────────────────────────
48
+
49
+ _GRADIENT_BG = None
50
+ _SCANLINE_OVERLAY = None
51
+
52
+ def get_gradient_bg():
53
+ global _GRADIENT_BG
54
+ if _GRADIENT_BG is None:
55
+ strip = Image.new("RGB", (1, H))
56
+ for y in range(H):
57
+ t = y / H
58
+ r = int(BG[0] + (BG2[0] - BG[0]) * t)
59
+ g = int(BG[1] + (BG2[1] - BG[1]) * t)
60
+ b = int(BG[2] + (BG2[2] - BG[2]) * t)
61
+ strip.putpixel((0, y), (r, g, b))
62
+ _GRADIENT_BG = strip.resize((W, H), Image.NEAREST)
63
+ return _GRADIENT_BG.copy()
64
+
65
+ def get_scanline_overlay():
66
+ global _SCANLINE_OVERLAY
67
+ if _SCANLINE_OVERLAY is None:
68
+ ol = Image.new("RGBA", (W, H), (0, 0, 0, 0))
69
+ d = ImageDraw.Draw(ol)
70
+ for y in range(0, H, 3):
71
+ d.line([(0, y), (W, y)], fill=(0, 0, 0, 15))
72
+ _SCANLINE_OVERLAY = ol
73
+ return _SCANLINE_OVERLAY
74
+
75
+ def apply_scanlines(img):
76
+ rgba = img.convert("RGBA")
77
+ result = Image.alpha_composite(rgba, get_scanline_overlay())
78
+ return result.convert("RGB")
79
+
80
+ def new_frame():
81
+ return get_gradient_bg()
82
+
83
+
84
+ # ── Math ─────────────────────────────────────────────────────────────────────
85
+
86
+ def lerp(c1, c2, t):
87
+ t = max(0.0, min(1.0, t))
88
+ return tuple(int(c1[i] + (c2[i] - c1[i]) * t) for i in range(3))
89
+
90
+ def ease_out(t):
91
+ t = max(0.0, min(1.0, t))
92
+ return 1.0 - (1.0 - t) ** 3
93
+
94
+ def ease_in_out(t):
95
+ t = max(0.0, min(1.0, t))
96
+ return 3 * t * t - 2 * t * t * t
97
+
98
+ def ease_out_elastic(t):
99
+ if t <= 0: return 0.0
100
+ if t >= 1: return 1.0
101
+ c4 = (2 * math.pi) / 3
102
+ return pow(2, -10 * t) * math.sin((t * 10 - 0.75) * c4) + 1.0
103
+
104
+
105
+ # ── Fonts ────────────────────────────────────────────────────────────────────
106
+
107
+ def load_font(size, bold=False):
108
+ for p in ["/opt/X11/share/system_fonts/Menlo.ttc", "/System/Library/Fonts/Menlo.ttc"]:
109
+ try:
110
+ return ImageFont.truetype(p, size, index=1 if bold else 0)
111
+ except:
112
+ try:
113
+ return ImageFont.truetype(p, size)
114
+ except:
115
+ continue
116
+ return ImageFont.load_default()
117
+
118
+ def load_sans(size, bold=False):
119
+ for p in ["/System/Library/Fonts/SFNS.ttf", "/System/Library/Fonts/Helvetica.ttc"]:
120
+ try:
121
+ return ImageFont.truetype(p, size, index=2 if bold else 0)
122
+ except:
123
+ try:
124
+ return ImageFont.truetype(p, size)
125
+ except:
126
+ continue
127
+ return load_font(size, bold)
128
+
129
+ FONT_BIG = load_sans(72, True)
130
+ FONT_MED = load_sans(48, True)
131
+ FONT_SM = load_sans(32)
132
+ FONT_CAPTION = load_sans(30)
133
+ FONT_MONO = load_font(30)
134
+ FONT_MONO_SM = load_font(24)
135
+ FONT_MONO_LG = load_font(36, True)
136
+
137
+
138
+ # ── Drawing helpers ───────────────────────────────────────────────────────────
139
+
140
+ def tw(draw, text, font):
141
+ return draw.textlength(text, font=font)
142
+
143
+ def center_text(draw, y, text, font, color):
144
+ w = tw(draw, text, font)
145
+ draw.text(((W - w) / 2, y), text, fill=color, font=font)
146
+
147
+ def rounded_rect(draw, xy, r, fill):
148
+ x0, y0, x1, y1 = xy
149
+ draw.rectangle([x0+r, y0, x1-r, y1], fill=fill)
150
+ draw.rectangle([x0, y0+r, x1, y1-r], fill=fill)
151
+ draw.pieslice([x0, y0, x0+2*r, y0+2*r], 180, 270, fill=fill)
152
+ draw.pieslice([x1-2*r, y0, x1, y0+2*r], 270, 360, fill=fill)
153
+ draw.pieslice([x0, y1-2*r, x0+2*r, y1 ], 90, 180, fill=fill)
154
+ draw.pieslice([x1-2*r, y1-2*r, x1, y1 ], 0, 90, fill=fill)
155
+
156
+ def draw_glow_indicator(img, x, y, color, inner_size=10, glow_size=36):
157
+ r, g, b = color
158
+ glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
159
+ gd = ImageDraw.Draw(glow)
160
+ for radius, alpha in [(glow_size, 25), (int(glow_size * 0.65), 50), (int(glow_size * 0.35), 80)]:
161
+ gd.ellipse([x - radius, y - radius, x + radius, y + radius], fill=(r, g, b, alpha))
162
+ glow = glow.filter(ImageFilter.GaussianBlur(radius=10))
163
+ gd2 = ImageDraw.Draw(glow)
164
+ gd2.ellipse([x - inner_size, y - inner_size, x + inner_size, y + inner_size], fill=(r, g, b, 255))
165
+ rgba = img.convert("RGBA")
166
+ result = Image.alpha_composite(rgba, glow)
167
+ return result.convert("RGB")
168
+
169
+ def draw_drop_shadow(img, rect, blur_r=18):
170
+ x0, y0, x1, y1 = rect
171
+ ox, oy = 10, 14
172
+ shadow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
173
+ sd = ImageDraw.Draw(shadow)
174
+ rounded_rect(sd, (x0+ox, y0+oy, x1+ox, y1+oy), 16, (0, 0, 0, 110))
175
+ shadow = shadow.filter(ImageFilter.GaussianBlur(radius=blur_r))
176
+ rgba = img.convert("RGBA")
177
+ return Image.alpha_composite(rgba, shadow).convert("RGB")
178
+
179
+ def draw_particles(img, t, opacity_mult=1.0):
180
+ snippets = ["if [", "&&", "| grep", "$(", "zsh", "bash", "$PATH",
181
+ "~/.zshrc", "export", "alias", "git", "| head", "2>&1",
182
+ "eval", "source", "lash", "claude"]
183
+ rng = random.Random(99)
184
+ draw = ImageDraw.Draw(img)
185
+ for _ in range(15):
186
+ px = rng.randint(20, W - 80)
187
+ base_py = rng.randint(0, H)
188
+ speed = rng.uniform(15, 40)
189
+ py = int((base_py - t * speed) % H)
190
+ snippet = rng.choice(snippets)
191
+ alpha = rng.randint(8, 20)
192
+ c = lerp(BG, GRAY, int(alpha * opacity_mult) / 100)
193
+ try:
194
+ draw.text((px, py), snippet, fill=c, font=FONT_MONO_SM)
195
+ except Exception:
196
+ pass
197
+ return img
198
+
199
+ def draw_progress_bar(draw, progress, color=MAGENTA):
200
+ y = H - 12
201
+ bar_w = int(W * max(0.0, min(1.0, progress)))
202
+ draw.rectangle([0, y, W, y + 6], fill=(30, 30, 45))
203
+ if bar_w > 0:
204
+ draw.rectangle([0, y, bar_w, y + 6], fill=color)
205
+ if bar_w > 10:
206
+ draw.ellipse([bar_w - 6, y - 3, bar_w + 6, y + 9], fill=color)
207
+
208
+ def draw_caption(draw, text, t_in=1.0):
209
+ if t_in <= 0:
210
+ return
211
+ alpha = min(1.0, t_in)
212
+ y_base = H - 220
213
+ text_w = tw(draw, text, FONT_CAPTION)
214
+ pad = 24
215
+ bx = (W - text_w - pad * 2) / 2
216
+ bg_c = lerp(BG, (25, 25, 40), alpha)
217
+ brd_c = lerp(BG, (60, 60, 80), alpha * 0.5)
218
+ rounded_rect(draw, (bx-2, y_base-2, bx+text_w+pad*2+2, y_base+46), 10, brd_c)
219
+ rounded_rect(draw, (bx, y_base, bx+text_w+pad*2, y_base+44), 8, bg_c)
220
+ tc = lerp(BG, WHITE, ease_out(alpha))
221
+ draw.text((bx + pad, y_base + 7), text, fill=tc, font=FONT_CAPTION)
222
+
223
+ def fade_envelope(f, total, fade=FADE_FRAMES):
224
+ if f < fade:
225
+ return ease_out(f / fade)
226
+ remaining = total - f
227
+ if remaining < fade:
228
+ return ease_out(remaining / fade)
229
+ return 1.0
230
+
231
+ def apply_fade(img, f, total):
232
+ alpha = fade_envelope(f, total)
233
+ if alpha >= 0.99:
234
+ return img
235
+ overlay = Image.new("RGB", (W, H), BG)
236
+ return Image.blend(img, overlay, 1.0 - alpha)
237
+
238
+
239
+ # ── Scenes ────────────────────────────────────────────────────────────────────
240
+
241
+ def scene_hook(f, total, global_progress):
242
+ """0-2s: Bold hook text with elastic pop-in."""
243
+ img = new_frame()
244
+ img = draw_particles(img, f / FPS, opacity_mult=0.6)
245
+ draw = ImageDraw.Draw(img)
246
+ t = f / total
247
+
248
+ lines = [
249
+ ("Stop", WHITE, 0.00, FONT_BIG, H//2 - 180),
250
+ ("copy-pasting", WHITE, 0.12, FONT_BIG, H//2 - 80),
251
+ ("into ChatGPT.", YELLOW, 0.25, FONT_BIG, H//2 + 20),
252
+ ]
253
+ for text, color, delay, font, y in lines:
254
+ lt = max(0.0, min(1.0, (t - delay) / 0.14))
255
+ if lt <= 0:
256
+ continue
257
+ spring = (1.0 - ease_out_elastic(lt)) * 22
258
+ c = lerp(BG, color, ease_out(lt))
259
+ center_text(draw, y + spring, text, font, c)
260
+
261
+ if t > 0.55:
262
+ sub_t = ease_out((t - 0.55) / 0.2)
263
+ c = lerp(BG, GRAY, sub_t)
264
+ center_text(draw, H//2 + 165, "Just talk to your shell.", FONT_MED, c)
265
+
266
+ draw_progress_bar(draw, global_progress, color=GREEN)
267
+ draw_caption(draw, "Stop copy-pasting into ChatGPT.", t_in=max(0.0, (t - 0.5) / 0.25))
268
+ img = apply_scanlines(img)
269
+ return apply_fade(img, f, total)
270
+
271
+
272
+ def scene_demo(f, total, global_progress):
273
+ """2-10s: Terminal demo — git status (green) → explain… (magenta)."""
274
+ img = new_frame()
275
+ img = draw_particles(img, f / FPS, opacity_mult=0.4)
276
+ t = f / total
277
+
278
+ margin = 50
279
+ term_top = 250
280
+ term_h = 1100
281
+
282
+ # Drop shadow
283
+ img = draw_drop_shadow(img, (margin, term_top, W - margin, term_top + term_h))
284
+ draw = ImageDraw.Draw(img)
285
+
286
+ # Terminal window
287
+ rounded_rect(draw, (margin, term_top, W-margin, term_top+term_h), 16, TERM_BG)
288
+ rounded_rect(draw, (margin, term_top, W-margin, term_top+44), 16, (32, 32, 44))
289
+ draw.rectangle([margin, term_top+28, W-margin, term_top+44], fill=(32, 32, 44))
290
+ draw.ellipse([margin+18, term_top+14, margin+32, term_top+28], fill=(255, 95, 87))
291
+ draw.ellipse([margin+42, term_top+14, margin+56, term_top+28], fill=(255, 189, 46))
292
+ draw.ellipse([margin+66, term_top+14, margin+80, term_top+28], fill=(39, 201, 63))
293
+ title_t = "lacy ~"
294
+ ttw = tw(draw, title_t, FONT_MONO_SM)
295
+ draw.text(((W - ttw) / 2, term_top + 12), title_t, fill=GRAY, font=FONT_MONO_SM)
296
+
297
+ cx, cy = margin + 24, term_top + 65
298
+
299
+ # ── Phase 1 (0–0.35): "git status" → green ──
300
+ cmd1 = "git status"
301
+ type1_t = min(1.0, t / 0.2)
302
+ chars1 = int(len(cmd1) * ease_in_out(type1_t))
303
+ ind1_c = GREEN if chars1 > 0 else GRAY
304
+ prompt_t = "~ "
305
+ pw = tw(draw, prompt_t, FONT_MONO)
306
+
307
+ if chars1 > 0:
308
+ img = draw_glow_indicator(img, cx - 2, cy + 14, ind1_c, inner_size=7, glow_size=22)
309
+ draw = ImageDraw.Draw(img)
310
+ else:
311
+ draw.ellipse([cx - 9, cy + 7, cx + 5, cy + 21], fill=ind1_c)
312
+
313
+ draw.text((cx + 18, cy), prompt_t, fill=GREEN, font=FONT_MONO)
314
+ draw.text((cx + 18 + pw, cy), cmd1[:chars1], fill=WHITE, font=FONT_MONO)
315
+
316
+ # Cursor phase 1
317
+ if t < 0.25:
318
+ cw = tw(draw, cmd1[:chars1], FONT_MONO)
319
+ if (f % 15) < 10 or type1_t < 1:
320
+ bbox = FONT_MONO.getbbox("M")
321
+ draw.rectangle([cx+18+pw+cw, cy-1,
322
+ cx+18+pw+cw+(bbox[2]-bbox[0]), cy+bbox[3]-bbox[1]+1], fill=WHITE)
323
+
324
+ # Shell output
325
+ if t > 0.25:
326
+ out_t = min(1.0, (t - 0.25) / 0.08)
327
+ lines = [
328
+ ("On branch main", WHITE),
329
+ ("Changes not staged:", YELLOW),
330
+ (" modified: src/app.ts", (255, 130, 100)),
331
+ (" modified: src/utils.ts",(255, 130, 100)),
332
+ ]
333
+ n_show = int(len(lines) * ease_out(out_t))
334
+ for i in range(n_show):
335
+ x_off = int((1.0 - ease_out(min(1.0, out_t * 2 - i * 0.2))) * 18)
336
+ draw.text((cx + 18 + x_off, cy + 44 + i * 36), lines[i][0], fill=lines[i][1], font=FONT_MONO_SM)
337
+ if out_t > 0.5:
338
+ lb_t = ease_out((out_t - 0.5) / 0.5)
339
+ lc = lerp(BG, GREEN, lb_t)
340
+ lbl = "→ Shell"
341
+ lw = tw(draw, lbl, FONT_SM)
342
+ draw.text((W - margin - lw - 20, cy + 2), lbl, fill=lc, font=FONT_SM)
343
+
344
+ # ── Phase 2 (0.38–0.75): "explain what changed and why" → magenta ──
345
+ line2_y = cy + 230
346
+ if t > 0.38:
347
+ t2 = (t - 0.38) / 0.35
348
+ cmd2 = "explain what changed and why"
349
+ chars2 = int(len(cmd2) * min(1.0, ease_in_out(t2)))
350
+
351
+ if chars2 > 5:
352
+ img = draw_glow_indicator(img, cx - 2, line2_y + 14, MAGENTA, inner_size=7, glow_size=24)
353
+ draw = ImageDraw.Draw(img)
354
+ elif chars2 > 0:
355
+ blend_c = lerp(GREEN, MAGENTA, chars2 / 5)
356
+ draw.ellipse([cx-9, line2_y+7, cx+5, line2_y+21], fill=blend_c)
357
+ else:
358
+ draw.ellipse([cx-9, line2_y+7, cx+5, line2_y+21], fill=GRAY)
359
+
360
+ draw.text((cx + 18, line2_y), "~ ", fill=GREEN, font=FONT_MONO)
361
+ draw.text((cx + 18 + pw, line2_y), cmd2[:chars2], fill=WHITE, font=FONT_MONO)
362
+
363
+ if t2 < 1:
364
+ cw2 = tw(draw, cmd2[:chars2], FONT_MONO)
365
+ if (f % 15) < 10:
366
+ bbox = FONT_MONO.getbbox("M")
367
+ draw.rectangle([cx+18+pw+cw2, line2_y-1,
368
+ cx+18+pw+cw2+(bbox[2]-bbox[0]), line2_y+bbox[3]-bbox[1]+1], fill=WHITE)
369
+ if t2 > 0.5:
370
+ lb2_t = ease_out((t2 - 0.5) / 0.5)
371
+ lc2 = lerp(BG, MAGENTA, lb2_t)
372
+ lbl2 = "→ Agent"
373
+ lw2 = tw(draw, lbl2, FONT_SM)
374
+ draw.text((W - margin - lw2 - 20, line2_y + 2), lbl2, fill=lc2, font=FONT_SM)
375
+
376
+ # AI response
377
+ if t > 0.78:
378
+ ai_t = (t - 0.78) / 0.2
379
+ ai_lines = [
380
+ "The changes in src/app.ts add a",
381
+ "new authentication middleware that",
382
+ "validates JWT tokens before routing.",
383
+ "",
384
+ "src/utils.ts was updated to export",
385
+ "a new `verifyToken()` helper.",
386
+ ]
387
+ n_ai = int(len(ai_lines) * min(1.0, ease_out(ai_t * 1.3)))
388
+ for i in range(n_ai):
389
+ x_off = int((1.0 - ease_out(min(1.0, ai_t * 2 - i * 0.15))) * 20)
390
+ lc_ai = lerp(TERM_BG, MAGENTA, ease_out(ai_t))
391
+ draw.text((cx + 18 + x_off, line2_y + 44 + i * 36), ai_lines[i],
392
+ fill=lc_ai, font=FONT_MONO_SM)
393
+
394
+ # AUTO badge
395
+ rounded_rect(draw, (W - margin - 90, term_top + term_h - 42,
396
+ W - margin - 10, term_top + term_h - 14), 6, (30, 30, 42))
397
+ draw.text((W - margin - 82, term_top + term_h - 40), "AUTO", fill=BLUE, font=FONT_MONO_SM)
398
+
399
+ # Top label
400
+ if t < 0.38:
401
+ lbl_text = "Commands run in your shell"
402
+ lbl_c = GREEN
403
+ else:
404
+ lbl_text = "Questions go to AI"
405
+ lbl_c = MAGENTA
406
+ center_text(draw, 170, lbl_text, FONT_SM, lbl_c)
407
+
408
+ draw_progress_bar(draw, global_progress, color=lbl_c)
409
+ draw_caption(draw, lbl_text, t_in=min(1.0, t / 0.2))
410
+ img = apply_scanlines(img)
411
+ return apply_fade(img, f, total)
412
+
413
+
414
+ def scene_cta(f, total, global_progress):
415
+ """10-15s: CTA — brand, tagline, install command, feature bullets."""
416
+ img = new_frame()
417
+ img = draw_particles(img, f / FPS, opacity_mult=1.1)
418
+ draw = ImageDraw.Draw(img)
419
+ t = f / total
420
+
421
+ # Brand — elastic pop-in
422
+ b_t = min(1.0, t / 0.2)
423
+ spring = (1.0 - ease_out_elastic(b_t)) * 28
424
+ center_text(draw, H//2 - 220 + spring, "lacy.sh", FONT_BIG, lerp(BG, WHITE, ease_out(b_t)))
425
+
426
+ # Tagline
427
+ if t > 0.1:
428
+ tg_t = ease_out((t - 0.1) / 0.2)
429
+ center_text(draw, H//2 - 120, "Talk to your shell.", FONT_MED, lerp(BG, GRAY, tg_t))
430
+
431
+ # Install box with glow border
432
+ if t > 0.25:
433
+ bx_t = ease_out((t - 0.25) / 0.2)
434
+ install = "curl -fsSL lacy.sh/install | bash"
435
+ iw = tw(draw, install, FONT_MONO_SM) + 50
436
+ bx = (W - iw) / 2
437
+ by = H // 2 - 20
438
+ glow_c = lerp(BG, MAGENTA, bx_t * 0.45)
439
+ rounded_rect(draw, (bx-3, by-3, bx+iw+3, by+53), 13, glow_c)
440
+ rounded_rect(draw, (bx, by, bx+iw, by+50), 10, lerp(BG, (28, 28, 42), bx_t))
441
+ draw.text((bx + 25, by + 12), install, fill=lerp(BG, WHITE, bx_t), font=FONT_MONO_SM)
442
+
443
+ # "or" + npx
444
+ if t > 0.4:
445
+ or_t = ease_out((t - 0.4) / 0.15)
446
+ center_text(draw, H//2 + 60, "or", FONT_SM, lerp(BG, DIM, or_t))
447
+ center_text(draw, H//2 +105, "npx lacy", FONT_MONO, lerp(BG, GRAY, or_t))
448
+
449
+ # Feature bullets
450
+ if t > 0.55:
451
+ feats = ["ZSH & Bash", "macOS · Linux · WSL", "Works with Claude, Gemini, Codex..."]
452
+ for i, feat in enumerate(feats):
453
+ ft = ease_out(max(0.0, (t - 0.55 - i * 0.06) / 0.15))
454
+ if ft > 0:
455
+ center_text(draw, H//2 + 200 + i * 52, feat, FONT_SM, lerp(BG, DIM, ft))
456
+
457
+ # Three colored dots with glow
458
+ if t > 0.7:
459
+ dt = min(1.0, (t - 0.7) / 0.15)
460
+ da = ease_out_elastic(dt)
461
+ for color, ox in [(GREEN, -65), (MAGENTA, 0), (BLUE, 65)]:
462
+ cx_d = W // 2 + ox
463
+ cy_d = H // 2 + 390
464
+ if da > 0.5:
465
+ img = draw_glow_indicator(img, cx_d, cy_d, color, inner_size=7, glow_size=24)
466
+ draw = ImageDraw.Draw(img)
467
+ else:
468
+ dc = lerp(BG, color, da)
469
+ draw.ellipse([cx_d - 7, cy_d - 7, cx_d + 7, cy_d + 7], fill=dc)
470
+
471
+ draw_progress_bar(draw, global_progress, color=MAGENTA)
472
+ draw_caption(draw, "curl -fsSL lacy.sh/install | bash", t_in=max(0.0, (t - 0.4) / 0.3))
473
+ img = apply_scanlines(img)
474
+ return apply_fade(img, f, total)
475
+
476
+
477
+ # ── Main generation ───────────────────────────────────────────────────────────
478
+
479
+ def generate():
480
+ os.makedirs(OUT_DIR, exist_ok=True)
481
+ os.makedirs(os.path.dirname(THUMBNAIL_PATH), exist_ok=True)
482
+
483
+ scenes = [
484
+ (scene_hook, 2.0), # 0-2s
485
+ (scene_demo, 8.0), # 2-10s
486
+ (scene_cta, 5.0), # 10-15s
487
+ ]
488
+
489
+ total_frames = int(sum(d for _, d in scenes) * FPS)
490
+ thumbnail_saved = False
491
+ print(f"Generating {total_frames} frames ({total_frames / FPS:.1f}s at {FPS}fps)")
492
+
493
+ frame_num = 0
494
+ for i, (fn, dur) in enumerate(scenes):
495
+ n = int(dur * FPS)
496
+ print(f" Scene {i+1}/{len(scenes)}: {fn.__name__} ({n} frames, {dur:.1f}s)")
497
+ for f in range(n):
498
+ global_progress = (frame_num + 1) / total_frames
499
+ img = fn(f, n, global_progress)
500
+ img.save(os.path.join(OUT_DIR, f"frame_{frame_num:05d}.png"))
501
+
502
+ # Thumbnail at CTA ~40%
503
+ if not thumbnail_saved and fn == scene_cta and f == int(n * 0.4):
504
+ img.save(THUMBNAIL_PATH)
505
+ print(f" → Thumbnail saved: {THUMBNAIL_PATH}")
506
+ thumbnail_saved = True
507
+
508
+ frame_num += 1
509
+
510
+ print(f"\nDone! {frame_num} frames saved to {OUT_DIR}")
511
+ print(f"\n── Encode commands ──")
512
+ print(f"# Short (60fps):")
513
+ print(f"ffmpeg -framerate {FPS} -i {OUT_DIR}/frame_%05d.png \\")
514
+ print(f" -c:v libx264 -pix_fmt yuv420p -crf 18 \\")
515
+ print(f" -y docs/videos/lacy-shell-short-v2.mp4")
516
+ print(f"\n# TikTok/Reels/Shorts (1080x1920 9:16 ✓ — same file works):")
517
+ print(f"# Just rename or copy: lacy-shell-short-v2.mp4")
518
+ print(f"\n# Instagram square variant:")
519
+ print(f"ffmpeg -i docs/videos/lacy-shell-short-v2.mp4 \\")
520
+ print(f" -vf 'crop=1080:1080:0:420' \\")
521
+ print(f" -c:v libx264 -pix_fmt yuv420p -crf 20 \\")
522
+ print(f" -y docs/videos/lacy-shell-short-v2-square.mp4")
523
+
524
+
525
+ if __name__ == "__main__":
526
+ generate()
Binary file
Binary file