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,729 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Generate frames for Lacy Shell UGC demo video — V2 Enhanced.
4
+
5
+ Improvements over v1:
6
+ - 60fps for smoother motion
7
+ - Gradient background (deep dark purple-black)
8
+ - Glow/bloom effect on colored indicators using GaussianBlur compositing
9
+ - Drop shadow on terminal windows
10
+ - CRT scan-line overlay (subtle, every 3px)
11
+ - Variable speed typing with natural micro-pauses
12
+ - Scene fade-in/fade-out transitions
13
+ - Progress bar at bottom
14
+ - Accessibility captions overlay
15
+ - Floating code particle background
16
+ - Elastic spring animation on key text reveals
17
+ - Thumbnail export at CTA peak
18
+ """
19
+
20
+ import os
21
+ import math
22
+ import random
23
+ from PIL import Image, ImageDraw, ImageFont, ImageFilter
24
+
25
+ # === Config ===
26
+ W, H = 1080, 1920
27
+ FPS = 60
28
+ OUT_DIR = "/tmp/ugc-video/frames-v2"
29
+ THUMBNAIL_PATH = "/tmp/ugc-video/lacy-shell-thumbnail.png"
30
+ FADE_FRAMES = 18 # 0.3s fade envelope at scene edges
31
+
32
+ # Colors (RGB)
33
+ BG = (10, 8, 20) # Deep dark purple-black (top)
34
+ BG2 = (16, 12, 30) # Gradient end (bottom)
35
+ TERM_BG = (20, 20, 30) # Terminal background
36
+ WHITE = (235, 235, 240)
37
+ GRAY = (130, 130, 145)
38
+ DIM = (60, 60, 80)
39
+ GREEN = (52, 211, 153) # Indicator green
40
+ MAGENTA = (216, 100, 240) # Indicator magenta
41
+ BLUE = (96, 165, 250) # Auto mode blue
42
+ HOOK_YELLOW = (250, 204, 21)
43
+ CURSOR_COLOR = (235, 235, 240)
44
+
45
+ # === Pre-computed assets ===
46
+
47
+ _GRADIENT_BG = None
48
+
49
+ def get_gradient_bg():
50
+ global _GRADIENT_BG
51
+ if _GRADIENT_BG is None:
52
+ strip = Image.new("RGB", (1, H))
53
+ for y in range(H):
54
+ t = y / H
55
+ r = int(BG[0] + (BG2[0] - BG[0]) * t)
56
+ g = int(BG[1] + (BG2[1] - BG[1]) * t)
57
+ b = int(BG[2] + (BG2[2] - BG[2]) * t)
58
+ strip.putpixel((0, y), (r, g, b))
59
+ _GRADIENT_BG = strip.resize((W, H), Image.NEAREST)
60
+ return _GRADIENT_BG.copy()
61
+
62
+
63
+ _SCANLINE_OVERLAY = None
64
+
65
+ def get_scanline_overlay():
66
+ global _SCANLINE_OVERLAY
67
+ if _SCANLINE_OVERLAY is None:
68
+ overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
69
+ d = ImageDraw.Draw(overlay)
70
+ for y in range(0, H, 3):
71
+ d.line([(0, y), (W, y)], fill=(0, 0, 0, 15))
72
+ _SCANLINE_OVERLAY = overlay
73
+ return _SCANLINE_OVERLAY
74
+
75
+
76
+ def apply_scanlines(img):
77
+ rgba = img.convert("RGBA")
78
+ result = Image.alpha_composite(rgba, get_scanline_overlay())
79
+ return result.convert("RGB")
80
+
81
+
82
+ def new_frame():
83
+ return get_gradient_bg()
84
+
85
+
86
+ # === Math helpers ===
87
+
88
+ def lerp_color(c1, c2, t):
89
+ t = max(0.0, min(1.0, t))
90
+ return tuple(int(c1[i] + (c2[i] - c1[i]) * t) for i in range(3))
91
+
92
+ def ease_out(t):
93
+ t = max(0.0, min(1.0, t))
94
+ return 1.0 - (1.0 - t) ** 3
95
+
96
+ def ease_in_out(t):
97
+ t = max(0.0, min(1.0, t))
98
+ return 3 * t * t - 2 * t * t * t
99
+
100
+ def ease_out_elastic(t):
101
+ """Springy overshoot — useful for title pop-in."""
102
+ if t <= 0: return 0.0
103
+ if t >= 1: return 1.0
104
+ c4 = (2 * math.pi) / 3
105
+ return pow(2, -10 * t) * math.sin((t * 10 - 0.75) * c4) + 1.0
106
+
107
+
108
+ # === Fonts ===
109
+
110
+ def load_font(size, bold=False):
111
+ paths = [
112
+ "/opt/X11/share/system_fonts/Menlo.ttc",
113
+ "/System/Library/Fonts/Menlo.ttc",
114
+ "/System/Library/Fonts/SFNSMono.ttf",
115
+ ]
116
+ for p in paths:
117
+ try:
118
+ return ImageFont.truetype(p, size, index=1 if bold else 0)
119
+ except:
120
+ try:
121
+ return ImageFont.truetype(p, size)
122
+ except:
123
+ continue
124
+ return ImageFont.load_default()
125
+
126
+ def load_sans_font(size, bold=False):
127
+ paths = [
128
+ "/System/Library/Fonts/SFNS.ttf",
129
+ "/System/Library/Fonts/Helvetica.ttc",
130
+ ]
131
+ for p in paths:
132
+ try:
133
+ return ImageFont.truetype(p, size, index=2 if bold else 0)
134
+ except:
135
+ try:
136
+ return ImageFont.truetype(p, size)
137
+ except:
138
+ continue
139
+ return load_font(size, bold)
140
+
141
+ FONT_MONO = load_font(32)
142
+ FONT_MONO_SM = load_font(26)
143
+ FONT_MONO_BOLD = load_font(34, bold=True)
144
+ FONT_HOOK = load_sans_font(64, bold=True)
145
+ FONT_HOOK_SM = load_sans_font(48, bold=True)
146
+ FONT_CTA = load_sans_font(56, bold=True)
147
+ FONT_CTA_SM = load_sans_font(36)
148
+ FONT_LABEL = load_sans_font(28)
149
+ FONT_CAPTION = load_sans_font(30)
150
+
151
+
152
+ # === Drawing helpers ===
153
+
154
+ def text_width(draw, text, font):
155
+ return draw.textlength(text, font=font)
156
+
157
+ def draw_rounded_rect(draw, xy, radius, fill=None):
158
+ x0, y0, x1, y1 = xy
159
+ r = radius
160
+ draw.rectangle([x0 + r, y0, x1 - r, y1], fill=fill)
161
+ draw.rectangle([x0, y0 + r, x1, y1 - r], fill=fill)
162
+ draw.pieslice([x0, y0, x0 + 2*r, y0 + 2*r], 180, 270, fill=fill)
163
+ draw.pieslice([x1 - 2*r, y0, x1, y0 + 2*r], 270, 360, fill=fill)
164
+ draw.pieslice([x0, y1 - 2*r, x0 + 2*r, y1 ], 90, 180, fill=fill)
165
+ draw.pieslice([x1 - 2*r, y1 - 2*r, x1, y1 ], 0, 90, fill=fill)
166
+
167
+ def draw_glow_indicator(img, x, y, color, inner_size=10, glow_size=40):
168
+ """Composite a glowing indicator dot onto img. Returns new RGB image."""
169
+ r, g, b = color
170
+ glow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
171
+ gd = ImageDraw.Draw(glow)
172
+ for radius, alpha in [(glow_size, 25), (int(glow_size * 0.65), 50), (int(glow_size * 0.35), 80)]:
173
+ gd.ellipse([x - radius, y - radius, x + radius, y + radius], fill=(r, g, b, alpha))
174
+ glow = glow.filter(ImageFilter.GaussianBlur(radius=12))
175
+ # Solid centre over the blurred halo
176
+ gd2 = ImageDraw.Draw(glow)
177
+ gd2.ellipse([x - inner_size, y - inner_size, x + inner_size, y + inner_size],
178
+ fill=(r, g, b, 255))
179
+ rgba = img.convert("RGBA")
180
+ result = Image.alpha_composite(rgba, glow)
181
+ return result.convert("RGB")
182
+
183
+ def draw_drop_shadow(img, rect, radius=16, offset=(10, 14), blur_r=20):
184
+ """Composite a blurred drop-shadow behind rect. Returns new RGB image."""
185
+ x0, y0, x1, y1 = rect
186
+ ox, oy = offset
187
+ shadow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
188
+ sd = ImageDraw.Draw(shadow)
189
+ draw_rounded_rect(sd, (x0 + ox, y0 + oy, x1 + ox, y1 + oy), radius, fill=(0, 0, 0, 120))
190
+ shadow = shadow.filter(ImageFilter.GaussianBlur(radius=blur_r))
191
+ rgba = img.convert("RGBA")
192
+ return Image.alpha_composite(rgba, shadow).convert("RGB")
193
+
194
+ def draw_particles(img, t, opacity_mult=1.0):
195
+ """Floating code-snippet particles drifting upward."""
196
+ snippets = ["if [", "&&", "| grep", "$(", "zsh", "bash", "$PATH",
197
+ "~/.zshrc", "export", "alias", "git", "| head", ">>",
198
+ "2>&1", "eval", "source", "lash", "claude"]
199
+ rng = random.Random(42)
200
+ draw = ImageDraw.Draw(img)
201
+ for _ in range(18):
202
+ px = rng.randint(20, W - 80)
203
+ base_py = rng.randint(0, H)
204
+ speed = rng.uniform(15, 45)
205
+ py = int((base_py - t * speed) % H)
206
+ snippet = rng.choice(snippets)
207
+ alpha = rng.randint(8, 22)
208
+ alpha_sc = int(alpha * opacity_mult)
209
+ c = lerp_color(BG, GRAY, alpha_sc / 100)
210
+ try:
211
+ draw.text((px, py), snippet, fill=c, font=FONT_MONO_SM)
212
+ except Exception:
213
+ pass
214
+ return img
215
+
216
+ def draw_progress_bar(draw, progress, color=MAGENTA):
217
+ """Thin 6px progress bar at the very bottom of the frame."""
218
+ y = H - 12
219
+ bar_w = int(W * max(0.0, min(1.0, progress)))
220
+ draw.rectangle([0, y, W, y + 6], fill=(30, 30, 45))
221
+ if bar_w > 0:
222
+ draw.rectangle([0, y, bar_w, y + 6], fill=color)
223
+ if bar_w > 10:
224
+ draw.ellipse([bar_w - 6, y - 3, bar_w + 6, y + 9], fill=color)
225
+
226
+ def draw_caption(draw, text, t_in=1.0, y_base=None):
227
+ """Semi-transparent caption pill near bottom of frame."""
228
+ if t_in <= 0:
229
+ return
230
+ alpha = min(1.0, t_in)
231
+ if y_base is None:
232
+ y_base = H - 220
233
+ tw = text_width(draw, text, FONT_CAPTION)
234
+ pad_x, pad_y = 24, 12
235
+ bx = (W - tw - pad_x * 2) / 2
236
+ bg_c = lerp_color(BG, (25, 25, 40), alpha)
237
+ bord_c = lerp_color(BG, (60, 60, 80), alpha * 0.5)
238
+ draw_rounded_rect(draw, (bx - 2, y_base - 2, bx + tw + pad_x * 2 + 2, y_base + 46), 10, fill=bord_c)
239
+ draw_rounded_rect(draw, (bx, y_base, bx + tw + pad_x * 2, y_base + 44), 8, fill=bg_c)
240
+ tc = lerp_color(BG, WHITE, ease_out(alpha))
241
+ draw.text((bx + pad_x, y_base + 7), text, fill=tc, font=FONT_CAPTION)
242
+
243
+ def draw_terminal_window(img, y_top, height, title="lacy ~"):
244
+ """Draw macOS-style terminal with drop shadow. Returns (new_img, cx, cy)."""
245
+ margin = 50
246
+ x0, y0 = margin, y_top
247
+ x1, y1 = W - margin, y_top + height
248
+ # Drop shadow first (operates on img)
249
+ img = draw_drop_shadow(img, (x0, y0, x1, y1))
250
+ draw = ImageDraw.Draw(img)
251
+ # Window body
252
+ draw_rounded_rect(draw, (x0, y0, x1, y1), 16, fill=TERM_BG)
253
+ # Subtle top edge highlight
254
+ draw.line([(x0 + 16, y0), (x1 - 16, y0)], fill=(50, 50, 65), width=1)
255
+ # Title bar
256
+ draw_rounded_rect(draw, (x0, y0, x1, y0 + 44), 16, fill=(32, 32, 44))
257
+ draw.rectangle([x0, y0 + 28, x1, y0 + 44], fill=(32, 32, 44))
258
+ # Traffic lights
259
+ draw.ellipse([x0 + 18, y0 + 14, x0 + 32, y0 + 28], fill=(255, 95, 87))
260
+ draw.ellipse([x0 + 42, y0 + 14, x0 + 56, y0 + 28], fill=(255, 189, 46))
261
+ draw.ellipse([x0 + 66, y0 + 14, x0 + 80, y0 + 28], fill=(39, 201, 63))
262
+ # Title text
263
+ tw = draw.textlength(title, font=FONT_MONO_SM)
264
+ draw.text(((W - tw) / 2, y0 + 11), title, fill=GRAY, font=FONT_MONO_SM)
265
+ return img, x0 + 24, y0 + 60
266
+
267
+ def draw_cursor(draw, x, y, font, f, always_on=False):
268
+ visible = always_on or (f % (FPS // 2)) < (FPS // 4)
269
+ if visible:
270
+ bbox = font.getbbox("M")
271
+ ch_w = bbox[2] - bbox[0]
272
+ ch_h = bbox[3] - bbox[1]
273
+ draw.rectangle([x, y - 2, x + ch_w, y + ch_h + 2], fill=CURSOR_COLOR)
274
+
275
+ def variable_typing_chars(t, command, start_t=0.08, duration=0.42):
276
+ """Natural typing — eased progress with a brief settle at the end."""
277
+ if t < start_t:
278
+ return 0
279
+ t_adj = max(0.0, min(1.0, (t - start_t) / duration))
280
+ return min(int(len(command) * ease_in_out(t_adj)), len(command))
281
+
282
+
283
+ # === Scene base ===
284
+
285
+ class Scene:
286
+ def __init__(self, duration_sec, caption=""):
287
+ self.duration = int(duration_sec * FPS)
288
+ self.caption = caption
289
+
290
+ def render(self, f, global_progress=0.0):
291
+ raise NotImplementedError
292
+
293
+ def fade_alpha(self, f):
294
+ if f < FADE_FRAMES:
295
+ return ease_out(f / FADE_FRAMES)
296
+ frames_from_end = self.duration - f
297
+ if frames_from_end < FADE_FRAMES:
298
+ return ease_out(frames_from_end / FADE_FRAMES)
299
+ return 1.0
300
+
301
+ def apply_fade(self, img, f):
302
+ alpha = self.fade_alpha(f)
303
+ if alpha >= 0.99:
304
+ return img
305
+ overlay = Image.new("RGB", (W, H), BG)
306
+ return Image.blend(img, overlay, 1.0 - alpha)
307
+
308
+
309
+ # === Scenes ===
310
+
311
+ class HookScene(Scene):
312
+ def __init__(self):
313
+ super().__init__(3.5, caption="What if your shell understood you?")
314
+
315
+ def render(self, f, global_progress=0.0):
316
+ img = new_frame()
317
+ img = draw_particles(img, f / FPS, opacity_mult=0.6)
318
+ draw = ImageDraw.Draw(img)
319
+ t = f / self.duration
320
+
321
+ words = ["What if", "your shell", "understood", "you?"]
322
+ y_start = H // 2 - 160
323
+ for i, word in enumerate(words):
324
+ word_t = max(0.0, min(1.0, (t - i * 0.12) / 0.18))
325
+ if word_t <= 0:
326
+ continue
327
+ alpha_t = ease_out(word_t)
328
+ # Elastic spring on the last word
329
+ spring = (1.0 - ease_out_elastic(word_t)) * 25 if word == "you?" else (1.0 - alpha_t) * 18
330
+ color = HOOK_YELLOW if word == "understood" else WHITE
331
+ c = lerp_color(BG, color, alpha_t)
332
+ font = FONT_HOOK
333
+ tw = draw.textlength(word, font=font)
334
+ draw.text(((W - tw) / 2, y_start + i * 90 + spring), word, fill=c, font=font)
335
+
336
+ # Watermark fade-in
337
+ if t > 0.5:
338
+ wm_t = min(1.0, (t - 0.5) / 0.3)
339
+ c = lerp_color(BG, DIM, ease_out(wm_t))
340
+ tw = draw.textlength("lacy.sh", font=FONT_LABEL)
341
+ draw.text(((W - tw) / 2, H - 140), "lacy.sh", fill=c, font=FONT_LABEL)
342
+
343
+ draw_progress_bar(draw, global_progress, color=GREEN)
344
+ draw_caption(draw, self.caption, t_in=max(0.0, (t - 0.6) / 0.3))
345
+ img = apply_scanlines(img)
346
+ return self.apply_fade(img, f)
347
+
348
+
349
+ class TerminalTypingScene(Scene):
350
+ def __init__(self, prompt_user, command, indicator_color, label,
351
+ response_lines=None, duration_sec=5, caption=""):
352
+ super().__init__(duration_sec, caption=caption)
353
+ self.prompt_user = prompt_user
354
+ self.command = command
355
+ self.indicator_color = indicator_color
356
+ self.label = label
357
+ self.response_lines = response_lines or []
358
+
359
+ def render(self, f, global_progress=0.0):
360
+ t = f / self.duration
361
+ img = new_frame()
362
+ img = draw_particles(img, f / FPS, opacity_mult=0.4)
363
+
364
+ # Label banner
365
+ draw = ImageDraw.Draw(img)
366
+ label_t = min(1.0, t / 0.08)
367
+ c = lerp_color(BG, self.indicator_color, ease_out(label_t))
368
+ tw = draw.textlength(self.label, font=FONT_LABEL)
369
+ draw.text(((W - tw) / 2, 80), self.label, fill=c, font=FONT_LABEL)
370
+
371
+ # Terminal window (modifies img)
372
+ term_top = 160
373
+ term_height = min(900, 300 + len(self.response_lines) * 50)
374
+ img, cx, cy = draw_terminal_window(img, term_top, term_height)
375
+ draw = ImageDraw.Draw(img)
376
+
377
+ # Prompt
378
+ prompt = f"{self.prompt_user} "
379
+ draw.text((cx, cy), prompt, fill=GREEN, font=FONT_MONO)
380
+ prompt_w = text_width(draw, prompt, FONT_MONO)
381
+
382
+ # Typing
383
+ chars_shown = variable_typing_chars(t, self.command)
384
+ typed = self.command[:chars_shown]
385
+
386
+ # Indicator
387
+ ind_x, ind_y = cx - 4, cy + 18
388
+ if chars_shown > 0:
389
+ ind_t = min(1.0, chars_shown / max(3, len(self.command) * 0.3))
390
+ ind_color = lerp_color(GRAY, self.indicator_color, ease_out(ind_t))
391
+ else:
392
+ ind_color = GRAY
393
+
394
+ if chars_shown > 3:
395
+ glow_prog = min(1.0, (chars_shown - 3) / 10)
396
+ img = draw_glow_indicator(img, ind_x, ind_y, ind_color,
397
+ inner_size=8,
398
+ glow_size=int(20 + glow_prog * 15))
399
+ draw = ImageDraw.Draw(img)
400
+ else:
401
+ draw.ellipse([ind_x - 8, ind_y - 8, ind_x + 8, ind_y + 8], fill=ind_color)
402
+
403
+ draw.text((cx + prompt_w, cy), typed, fill=WHITE, font=FONT_MONO)
404
+ typed_w = text_width(draw, typed, FONT_MONO)
405
+ draw_cursor(draw, cx + prompt_w + typed_w, cy, FONT_MONO, f,
406
+ always_on=(chars_shown < len(self.command)))
407
+
408
+ # Response lines — slide in from right
409
+ if t > 0.55 and self.response_lines:
410
+ resp_t = (t - 0.55) / 0.35
411
+ lines_show = int(len(self.response_lines) * min(1.0, resp_t * 1.5))
412
+ for i in range(lines_show):
413
+ line = self.response_lines[i]
414
+ line_y = cy + 50 + i * 42
415
+ slide_t = min(1.0, (resp_t * 1.5 - i * 0.15))
416
+ x_off = int((1.0 - ease_out(slide_t)) * 20)
417
+ if line.startswith("$"):
418
+ lc = GREEN
419
+ elif line.startswith("#"):
420
+ lc = GRAY
421
+ line = line[1:].strip()
422
+ else:
423
+ lc = WHITE
424
+ draw.text((cx + x_off, line_y), line, fill=lc, font=FONT_MONO_SM)
425
+
426
+ # AUTO mode badge
427
+ mode_text = "AUTO"
428
+ badge_w = text_width(draw, mode_text, FONT_MONO_SM) + 20
429
+ badge_x = W - 50 - badge_w - 10
430
+ badge_y = term_top + term_height - 45
431
+ draw_rounded_rect(draw, (badge_x, badge_y, badge_x + badge_w, badge_y + 30), 6, fill=(28, 28, 42))
432
+ draw.text((badge_x + 10, badge_y + 2), mode_text, fill=BLUE, font=FONT_MONO_SM)
433
+
434
+ draw_progress_bar(draw, global_progress, color=self.indicator_color)
435
+ draw_caption(draw, self.caption, t_in=min(1.0, t / 0.2))
436
+ img = apply_scanlines(img)
437
+ return self.apply_fade(img, f)
438
+
439
+
440
+ class SplitComparisonScene(Scene):
441
+ def __init__(self):
442
+ super().__init__(5, caption="Same word. Different intent.")
443
+
444
+ def render(self, f, global_progress=0.0):
445
+ t = f / self.duration
446
+ img = new_frame()
447
+ img = draw_particles(img, f / FPS, opacity_mult=0.4)
448
+ draw = ImageDraw.Draw(img)
449
+
450
+ # Title with elastic pop
451
+ title = "Same word. Different intent."
452
+ title_t = min(1.0, t / 0.12)
453
+ c = lerp_color(BG, WHITE, ease_out_elastic(title_t))
454
+ tw = draw.textlength(title, font=FONT_HOOK_SM)
455
+ spring = (1.0 - ease_out_elastic(title_t)) * 15
456
+ draw.text(((W - tw) / 2, 100 + spring), title, fill=c, font=FONT_HOOK_SM)
457
+
458
+ # ── Top terminal: "install nodejs" → SHELL (green) ──
459
+ if t > 0.12:
460
+ t1 = (t - 0.12) / 0.35
461
+ t1_top = 240
462
+ img = draw_drop_shadow(img, (50, t1_top, W - 50, t1_top + 200))
463
+ draw = ImageDraw.Draw(img)
464
+ draw_rounded_rect(draw, (50, t1_top, W-50, t1_top+200), 16, fill=TERM_BG)
465
+ draw_rounded_rect(draw, (50, t1_top, W-50, t1_top+44 ), 16, fill=(32,32,44))
466
+ draw.rectangle([50, t1_top+28, W-50, t1_top+44], fill=(32,32,44))
467
+ draw.ellipse([68, t1_top+14, 82, t1_top+28], fill=(255, 95, 87))
468
+ draw.ellipse([92, t1_top+14,106, t1_top+28], fill=(255,189, 46))
469
+ draw.ellipse([116,t1_top+14,130, t1_top+28], fill=( 39,201, 63))
470
+ ttl = "shell command"
471
+ tw2 = draw.textlength(ttl, font=FONT_MONO_SM)
472
+ draw.text(((W - tw2) / 2, t1_top + 11), ttl, fill=GRAY, font=FONT_MONO_SM)
473
+
474
+ cx, cy = 74, t1_top + 65
475
+ cmd1 = "install nodejs"
476
+ chars1 = int(len(cmd1) * min(1.0, ease_in_out(max(0.0, t1))))
477
+ draw.text((cx, cy), "~ ", fill=GREEN, font=FONT_MONO)
478
+ pw = text_width(draw, "~ ", FONT_MONO)
479
+ draw.text((cx + pw, cy), cmd1[:chars1], fill=WHITE, font=FONT_MONO)
480
+ if chars1 > 0:
481
+ img = draw_glow_indicator(img, cx - 4, cy + 18, GREEN, 8, 22)
482
+ draw = ImageDraw.Draw(img)
483
+ else:
484
+ draw.ellipse([cx-12, cy+10, cx+4, cy+26], fill=GRAY)
485
+
486
+ if t1 > 0.7:
487
+ draw.text((cx, cy + 45), "→ brew install node", fill=GRAY, font=FONT_MONO_SM)
488
+ lbl = "SHELL"
489
+ lw = text_width(draw, lbl, FONT_LABEL)
490
+ lc = lerp_color(BG, GREEN, min(1.0, (t1 - 0.7) / 0.2))
491
+ draw.text((W - 74 - lw, t1_top + 170), lbl, fill=lc, font=FONT_LABEL)
492
+
493
+ # Divider
494
+ if t > 0.4:
495
+ div_t = min(1.0, (t - 0.4) / 0.1)
496
+ div_w = int(400 * ease_out(div_t))
497
+ div_x = (W - div_w) // 2
498
+ draw.line([(div_x, 490), (div_x + div_w, 490)], fill=DIM, width=2)
499
+
500
+ # ── Bottom terminal: "install a way to..." → AGENT (magenta) ──
501
+ if t > 0.4:
502
+ t2 = (t - 0.4) / 0.45
503
+ t2_top = 520
504
+ img = draw_drop_shadow(img, (50, t2_top, W - 50, t2_top + 260))
505
+ draw = ImageDraw.Draw(img)
506
+ draw_rounded_rect(draw, (50, t2_top, W-50, t2_top+260), 16, fill=TERM_BG)
507
+ draw_rounded_rect(draw, (50, t2_top, W-50, t2_top+44 ), 16, fill=(32,32,44))
508
+ draw.rectangle([50, t2_top+28, W-50, t2_top+44], fill=(32,32,44))
509
+ draw.ellipse([68, t2_top+14, 82, t2_top+28], fill=(255, 95, 87))
510
+ draw.ellipse([92, t2_top+14,106, t2_top+28], fill=(255,189, 46))
511
+ draw.ellipse([116,t2_top+14,130, t2_top+28], fill=( 39,201, 63))
512
+ ttl2 = "natural language"
513
+ tw3 = draw.textlength(ttl2, font=FONT_MONO_SM)
514
+ draw.text(((W - tw3) / 2, t2_top + 11), ttl2, fill=GRAY, font=FONT_MONO_SM)
515
+
516
+ cx2, cy2 = 74, t2_top + 65
517
+ cmd2 = "install a way to monitor logs"
518
+ chars2 = int(len(cmd2) * min(1.0, ease_in_out(max(0.0, t2))))
519
+ draw.text((cx2, cy2), "~ ", fill=GREEN, font=FONT_MONO)
520
+ pw2 = text_width(draw, "~ ", FONT_MONO)
521
+ draw.text((cx2 + pw2, cy2), cmd2[:chars2], fill=WHITE, font=FONT_MONO)
522
+
523
+ if chars2 > 10:
524
+ img = draw_glow_indicator(img, cx2 - 4, cy2 + 18, MAGENTA, 8, 24)
525
+ draw = ImageDraw.Draw(img)
526
+ elif chars2 > 0:
527
+ blend_c = lerp_color(GREEN, MAGENTA, chars2 / 10)
528
+ draw.ellipse([cx2-12, cy2+10, cx2+4, cy2+26], fill=blend_c)
529
+ else:
530
+ draw.ellipse([cx2-12, cy2+10, cx2+4, cy2+26], fill=GRAY)
531
+
532
+ if t2 > 0.7:
533
+ ai_lines = [
534
+ "→ AI: I'd recommend using",
535
+ " 'pm2' or 'lnav'. Want me",
536
+ " to set one up for you?",
537
+ ]
538
+ for i, line in enumerate(ai_lines):
539
+ slide_t = min(1.0, (t2 - 0.7 - i * 0.08) / 0.2)
540
+ if slide_t <= 0:
541
+ continue
542
+ lc = lerp_color(BG, MAGENTA, ease_out(slide_t))
543
+ draw.text((cx2, cy2 + 45 + i * 38), line, fill=lc, font=FONT_MONO_SM)
544
+
545
+ lbl = "AGENT"
546
+ lw = text_width(draw, lbl, FONT_LABEL)
547
+ lc2 = lerp_color(BG, MAGENTA, min(1.0, (t2 - 0.7) / 0.2))
548
+ draw.text((W - 74 - lw, t2_top + 228), lbl, fill=lc2, font=FONT_LABEL)
549
+
550
+ draw_progress_bar(draw, global_progress)
551
+ draw_caption(draw, self.caption, t_in=min(1.0, t / 0.2))
552
+ img = apply_scanlines(img)
553
+ return self.apply_fade(img, f)
554
+
555
+
556
+ class CTAScene(Scene):
557
+ def __init__(self):
558
+ super().__init__(4.0, caption="curl -fsSL lacy.sh/install | bash")
559
+
560
+ def render(self, f, global_progress=0.0):
561
+ t = f / self.duration
562
+ img = new_frame()
563
+ img = draw_particles(img, f / FPS, opacity_mult=1.2)
564
+ draw = ImageDraw.Draw(img)
565
+
566
+ # Brand — elastic pop-in
567
+ brand_t = min(1.0, t / 0.2)
568
+ c = lerp_color(BG, WHITE, ease_out_elastic(brand_t))
569
+ brand = "lacy.sh"
570
+ tw = draw.textlength(brand, font=FONT_HOOK)
571
+ spring = (1.0 - ease_out_elastic(brand_t)) * 30
572
+ draw.text(((W - tw) / 2, H // 2 - 200 + spring), brand, fill=c, font=FONT_HOOK)
573
+
574
+ # Tagline
575
+ if t > 0.15:
576
+ tag_t = min(1.0, (t - 0.15) / 0.2)
577
+ c = lerp_color(BG, GRAY, ease_out(tag_t))
578
+ tag = "Talk to your shell."
579
+ tw = draw.textlength(tag, font=FONT_CTA_SM)
580
+ draw.text(((W - tw) / 2, H // 2 - 110), tag, fill=c, font=FONT_CTA_SM)
581
+
582
+ # Install command box with magenta glow border
583
+ if t > 0.3:
584
+ box_t = min(1.0, (t - 0.3) / 0.2)
585
+ install = "curl -fsSL lacy.sh/install | bash"
586
+ iw = text_width(draw, install, FONT_MONO_SM) + 50
587
+ box_x = (W - iw) / 2
588
+ box_y = H // 2 - 20
589
+ ba = ease_out(box_t)
590
+ glow_c = lerp_color(BG, MAGENTA, ba * 0.5)
591
+ draw_rounded_rect(draw, (box_x - 3, box_y - 3, box_x + iw + 3, box_y + 53), 13, fill=glow_c)
592
+ draw_rounded_rect(draw, (box_x, box_y, box_x + iw, box_y + 50), 10,
593
+ fill=lerp_color(BG, (28, 28, 42), ba))
594
+ draw.text((box_x + 25, box_y + 10), install,
595
+ fill=lerp_color(BG, WHITE, ba), font=FONT_MONO_SM)
596
+
597
+ # Feature bullets
598
+ if t > 0.5:
599
+ feats = ["ZSH & Bash", "macOS / Linux / WSL", "Works with any AI CLI"]
600
+ for i, feat in enumerate(feats):
601
+ feat_t = min(1.0, (t - 0.5 - i * 0.08) / 0.2)
602
+ if feat_t <= 0:
603
+ continue
604
+ fc = lerp_color(BG, DIM, ease_out(feat_t))
605
+ fw = draw.textlength(feat, font=FONT_LABEL)
606
+ draw.text(((W - fw) / 2, H // 2 + 100 + i * 50), feat, fill=fc, font=FONT_LABEL)
607
+
608
+ # Three indicator dots with glow
609
+ if t > 0.6:
610
+ dot_t = min(1.0, (t - 0.6) / 0.25)
611
+ da = ease_out_elastic(dot_t)
612
+ for color, offset in [(GREEN, -70), (MAGENTA, 0), (BLUE, 70)]:
613
+ cx_d = W // 2 + offset
614
+ cy_d = H // 2 + 295
615
+ if da > 0.5:
616
+ img = draw_glow_indicator(img, cx_d, cy_d, color, inner_size=8, glow_size=28)
617
+ draw = ImageDraw.Draw(img)
618
+ else:
619
+ dc = lerp_color(BG, color, da)
620
+ draw.ellipse([cx_d - 8, cy_d - 8, cx_d + 8, cy_d + 8], fill=dc)
621
+
622
+ draw_progress_bar(draw, global_progress, color=MAGENTA)
623
+ draw_caption(draw, self.caption, t_in=max(0.0, (t - 0.4) / 0.3))
624
+ img = apply_scanlines(img)
625
+ return self.apply_fade(img, f)
626
+
627
+
628
+ # === Main generation ===
629
+
630
+ def generate_all():
631
+ os.makedirs(OUT_DIR, exist_ok=True)
632
+ os.makedirs(os.path.dirname(THUMBNAIL_PATH), exist_ok=True)
633
+
634
+ scenes = [
635
+ HookScene(),
636
+ TerminalTypingScene(
637
+ prompt_user="~",
638
+ command="ls -la",
639
+ indicator_color=GREEN,
640
+ label="SHELL COMMAND → EXECUTES NORMALLY",
641
+ response_lines=[
642
+ "total 48",
643
+ "drwxr-xr-x 12 user staff 384 Mar 13",
644
+ "-rw-r--r-- 1 user staff 1024 README.md",
645
+ "-rw-r--r-- 1 user staff 512 package.json",
646
+ ],
647
+ duration_sec=4.5,
648
+ caption="Shell commands run as normal",
649
+ ),
650
+ TerminalTypingScene(
651
+ prompt_user="~",
652
+ command="what files are in this project",
653
+ indicator_color=MAGENTA,
654
+ label="NATURAL LANGUAGE → ROUTES TO AI",
655
+ response_lines=[
656
+ "#AI analyzing your project...",
657
+ "",
658
+ "This is a Node.js project with:",
659
+ " - 12 source files in src/",
660
+ " - Jest test suite (8 specs)",
661
+ " - TypeScript configuration",
662
+ ],
663
+ duration_sec=5.5,
664
+ caption="Natural language routes to AI",
665
+ ),
666
+ SplitComparisonScene(),
667
+ TerminalTypingScene(
668
+ prompt_user="~",
669
+ command="fix the failing tests",
670
+ indicator_color=MAGENTA,
671
+ label="AUTO-REROUTE: FAILS → SENDS TO AI",
672
+ response_lines=[
673
+ "#bash: fix: command not found",
674
+ "#→ Detected NL, routing to AI...",
675
+ "",
676
+ "Found 2 failing tests. Fixing...",
677
+ " ✓ auth.test.ts — fixed import",
678
+ " ✓ api.test.ts — updated mock",
679
+ ],
680
+ duration_sec=5.5,
681
+ caption="Failed command? Auto-routes to AI",
682
+ ),
683
+ CTAScene(),
684
+ ]
685
+
686
+ total_frames = sum(s.duration for s in scenes)
687
+ thumbnail_saved = False
688
+ print(f"Generating {total_frames} frames ({total_frames / FPS:.1f}s at {FPS}fps)")
689
+
690
+ frame_num = 0
691
+ for scene_idx, scene in enumerate(scenes):
692
+ print(f" Scene {scene_idx + 1}/{len(scenes)}: {scene.__class__.__name__} "
693
+ f"({scene.duration} frames, {scene.duration / FPS:.1f}s)")
694
+ for f in range(scene.duration):
695
+ global_progress = (frame_num + 1) / total_frames
696
+ img = scene.render(f, global_progress)
697
+ img.save(os.path.join(OUT_DIR, f"frame_{frame_num:05d}.png"))
698
+
699
+ # Thumbnail: CTA scene at ~40% in
700
+ if not thumbnail_saved and isinstance(scene, CTAScene) and f == int(scene.duration * 0.4):
701
+ img.save(THUMBNAIL_PATH)
702
+ print(f" → Thumbnail saved: {THUMBNAIL_PATH}")
703
+ thumbnail_saved = True
704
+
705
+ frame_num += 1
706
+
707
+ print(f"\nDone! {frame_num} frames saved to {OUT_DIR}")
708
+ print(f"\n── Encode commands ──")
709
+ print(f"# Full demo (60fps):")
710
+ print(f"ffmpeg -framerate {FPS} -i {OUT_DIR}/frame_%05d.png \\")
711
+ print(f" -c:v libx264 -pix_fmt yuv420p -crf 18 \\")
712
+ print(f" -y docs/videos/lacy-shell-demo-v2.mp4")
713
+ print(f"\n# TikTok/Reels/Shorts variant (keep 9:16, embed safe-zone):")
714
+ print(f"ffmpeg -i docs/videos/lacy-shell-demo-v2.mp4 \\")
715
+ print(f" -vf 'scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2' \\")
716
+ print(f" -c:v libx264 -pix_fmt yuv420p -crf 20 \\")
717
+ print(f" -y docs/videos/lacy-shell-demo-v2-tiktok.mp4")
718
+ print(f"\n# YouTube Shorts (same file works — 9:16 ✓)")
719
+ print(f"\n# Twitter/X square crop:")
720
+ print(f"ffmpeg -i docs/videos/lacy-shell-demo-v2.mp4 \\")
721
+ print(f" -vf 'crop=1080:1080:0:420' \\")
722
+ print(f" -c:v libx264 -pix_fmt yuv420p -crf 20 \\")
723
+ print(f" -y docs/videos/lacy-shell-demo-v2-square.mp4")
724
+
725
+ return frame_num, total_frames / FPS
726
+
727
+
728
+ if __name__ == "__main__":
729
+ n_frames, duration = generate_all()