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,510 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Generate frames for Lacy Shell UGC demo video.
4
+ Produces a vertical (1080x1920) terminal demo video showing:
5
+ 1. Hook text
6
+ 2. Shell command (green indicator)
7
+ 3. Natural language query (magenta indicator)
8
+ 4. AI response
9
+ 5. CTA
10
+ """
11
+
12
+ import os
13
+ import math
14
+ from PIL import Image, ImageDraw, ImageFont
15
+
16
+ # === Config ===
17
+ W, H = 1080, 1920
18
+ FPS = 30
19
+ OUT_DIR = "/tmp/ugc-video/frames"
20
+
21
+ # Colors (RGB)
22
+ BG = (13, 13, 15) # Near-black background
23
+ TERM_BG = (22, 22, 28) # Terminal background
24
+ TERM_BORDER = (45, 45, 55) # Terminal window border
25
+ WHITE = (235, 235, 240)
26
+ GRAY = (130, 130, 145)
27
+ DIM = (80, 80, 95)
28
+ GREEN = (52, 211, 153) # Indicator green
29
+ MAGENTA = (216, 100, 240) # Indicator magenta
30
+ BLUE = (96, 165, 250) # Auto mode blue
31
+ HOOK_YELLOW = (250, 204, 21)
32
+ CTA_GRADIENT_1 = (139, 92, 246) # Purple
33
+ CTA_GRADIENT_2 = (236, 72, 153) # Pink
34
+ CURSOR_COLOR = (235, 235, 240)
35
+
36
+ # Fonts
37
+ def load_font(size, bold=False):
38
+ paths = [
39
+ "/opt/X11/share/system_fonts/Menlo.ttc",
40
+ "/System/Library/Fonts/Menlo.ttc",
41
+ "/System/Library/Fonts/SFNSMono.ttf",
42
+ ]
43
+ for p in paths:
44
+ try:
45
+ idx = 1 if bold else 0
46
+ return ImageFont.truetype(p, size, index=idx)
47
+ except:
48
+ try:
49
+ return ImageFont.truetype(p, size)
50
+ except:
51
+ continue
52
+ return ImageFont.load_default()
53
+
54
+ def load_sans_font(size, bold=False):
55
+ paths = [
56
+ "/System/Library/Fonts/SFNS.ttf",
57
+ "/System/Library/Fonts/Helvetica.ttc",
58
+ ]
59
+ for p in paths:
60
+ try:
61
+ return ImageFont.truetype(p, size, index=2 if bold else 0)
62
+ except:
63
+ try:
64
+ return ImageFont.truetype(p, size)
65
+ except:
66
+ continue
67
+ return load_font(size, bold)
68
+
69
+ FONT_MONO = load_font(32)
70
+ FONT_MONO_SM = load_font(26)
71
+ FONT_MONO_BOLD = load_font(34, bold=True)
72
+ FONT_HOOK = load_sans_font(64, bold=True)
73
+ FONT_HOOK_SM = load_sans_font(48, bold=True)
74
+ FONT_CTA = load_sans_font(56, bold=True)
75
+ FONT_CTA_SM = load_sans_font(36)
76
+ FONT_LABEL = load_sans_font(28)
77
+
78
+ # === Drawing Helpers ===
79
+
80
+ def draw_rounded_rect(draw, xy, radius, fill=None, outline=None, width=1):
81
+ x0, y0, x1, y1 = xy
82
+ r = radius
83
+ # Use pieslice for corners
84
+ draw.rectangle([x0 + r, y0, x1 - r, y1], fill=fill)
85
+ draw.rectangle([x0, y0 + r, x1, y1 - r], fill=fill)
86
+ draw.pieslice([x0, y0, x0 + 2*r, y0 + 2*r], 180, 270, fill=fill)
87
+ draw.pieslice([x1 - 2*r, y0, x1, y0 + 2*r], 270, 360, fill=fill)
88
+ draw.pieslice([x0, y1 - 2*r, x0 + 2*r, y1], 90, 180, fill=fill)
89
+ draw.pieslice([x1 - 2*r, y1 - 2*r, x1, y1], 0, 90, fill=fill)
90
+
91
+ def draw_indicator(draw, x, y, color, size=14):
92
+ """Draw the colored dot indicator."""
93
+ draw.ellipse([x - size, y - size, x + size, y + size], fill=color)
94
+
95
+ def draw_cursor(draw, x, y, font, visible=True):
96
+ """Draw blinking cursor block."""
97
+ if visible:
98
+ bbox = font.getbbox("M")
99
+ ch_w = bbox[2] - bbox[0]
100
+ ch_h = bbox[3] - bbox[1]
101
+ draw.rectangle([x, y - 2, x + ch_w, y + ch_h + 2], fill=CURSOR_COLOR)
102
+
103
+ def draw_terminal_window(draw, y_top, height, title="lacy ~"):
104
+ """Draw a macOS-style terminal window."""
105
+ margin = 50
106
+ x0, y0 = margin, y_top
107
+ x1, y1 = W - margin, y_top + height
108
+
109
+ # Window background
110
+ draw_rounded_rect(draw, (x0, y0, x1, y1), 16, fill=TERM_BG)
111
+
112
+ # Title bar
113
+ draw_rounded_rect(draw, (x0, y0, x1, y0 + 44), 16, fill=(35, 35, 45))
114
+ draw.rectangle([x0, y0 + 30, x1, y0 + 44], fill=(35, 35, 45))
115
+
116
+ # Traffic lights
117
+ draw.ellipse([x0 + 18, y0 + 14, x0 + 32, y0 + 28], fill=(255, 95, 87))
118
+ draw.ellipse([x0 + 42, y0 + 14, x0 + 56, y0 + 28], fill=(255, 189, 46))
119
+ draw.ellipse([x0 + 66, y0 + 14, x0 + 80, y0 + 28], fill=(39, 201, 63))
120
+
121
+ # Title text
122
+ tw = draw.textlength(title, font=FONT_MONO_SM)
123
+ draw.text(((W - tw) / 2, y0 + 11), title, fill=GRAY, font=FONT_MONO_SM)
124
+
125
+ return x0 + 24, y0 + 60 # Return content start position
126
+
127
+
128
+ def lerp_color(c1, c2, t):
129
+ return tuple(int(c1[i] + (c2[i] - c1[i]) * t) for i in range(3))
130
+
131
+ def ease_out(t):
132
+ return 1 - (1 - t) ** 3
133
+
134
+ def ease_in_out(t):
135
+ return 3 * t * t - 2 * t * t * t
136
+
137
+ def text_width(draw, text, font):
138
+ return draw.textlength(text, font=font)
139
+
140
+
141
+ # === Scene Definitions ===
142
+
143
+ class Scene:
144
+ def __init__(self, duration_sec):
145
+ self.duration = int(duration_sec * FPS)
146
+ self.frames = []
147
+
148
+ def render(self, frame_num):
149
+ raise NotImplementedError
150
+
151
+
152
+ class HookScene(Scene):
153
+ """Opening hook: 'What if your shell understood you?'"""
154
+ def __init__(self):
155
+ super().__init__(3.5)
156
+
157
+ def render(self, f):
158
+ img = Image.new("RGB", (W, H), BG)
159
+ draw = ImageDraw.Draw(img)
160
+ t = f / self.duration
161
+
162
+ # Fade in text word by word
163
+ words = ["What if", "your shell", "understood", "you?"]
164
+ y_start = H // 2 - 140
165
+
166
+ for i, word in enumerate(words):
167
+ word_t = max(0, min(1, (t - i * 0.15) / 0.2))
168
+ if word_t <= 0:
169
+ continue
170
+ alpha_t = ease_out(word_t)
171
+
172
+ color = HOOK_YELLOW if word == "understood" else WHITE
173
+ c = lerp_color(BG, color, alpha_t)
174
+
175
+ font = FONT_HOOK
176
+ tw = draw.textlength(word, font=font)
177
+ x = (W - tw) / 2
178
+ y = y_start + i * 80
179
+
180
+ # Slight upward drift
181
+ y_offset = (1 - alpha_t) * 20
182
+ draw.text((x, y + y_offset), word, fill=c, font=font)
183
+
184
+ # Subtle "lacy.sh" watermark at bottom
185
+ if t > 0.5:
186
+ wm_t = min(1, (t - 0.5) / 0.3)
187
+ c = lerp_color(BG, DIM, ease_out(wm_t))
188
+ tw = draw.textlength("lacy.sh", font=FONT_LABEL)
189
+ draw.text(((W - tw) / 2, H - 120), "lacy.sh", fill=c, font=FONT_LABEL)
190
+
191
+ return img
192
+
193
+
194
+ class TerminalTypingScene(Scene):
195
+ """Shows typing in terminal with indicator color changing."""
196
+ def __init__(self, prompt_user, command, indicator_color, label, response_lines=None, duration_sec=5):
197
+ super().__init__(duration_sec)
198
+ self.prompt_user = prompt_user
199
+ self.command = command
200
+ self.indicator_color = indicator_color
201
+ self.label = label
202
+ self.response_lines = response_lines or []
203
+
204
+ def render(self, f):
205
+ img = Image.new("RGB", (W, H), BG)
206
+ draw = ImageDraw.Draw(img)
207
+ t = f / self.duration
208
+
209
+ # Label at top
210
+ label_t = min(1, t / 0.1)
211
+ c = lerp_color(BG, self.indicator_color, ease_out(label_t))
212
+ tw = draw.textlength(self.label, font=FONT_LABEL)
213
+ draw.text(((W - tw) / 2, 80), self.label, fill=c, font=FONT_LABEL)
214
+
215
+ # Draw terminal window
216
+ term_top = 160
217
+ term_height = min(900, 300 + len(self.response_lines) * 50)
218
+ cx, cy = draw_terminal_window(draw, term_top, term_height)
219
+
220
+ # Prompt line
221
+ prompt = f"{self.prompt_user} "
222
+ draw.text((cx, cy), prompt, fill=GREEN, font=FONT_MONO)
223
+ prompt_w = text_width(draw, prompt, FONT_MONO)
224
+
225
+ # Indicator dot (left of prompt area, in terminal margin)
226
+ indicator_x = cx - 4
227
+ indicator_y = cy + 18
228
+
229
+ # Typing animation (starts at t=0.1, ends at t=0.5)
230
+ type_t = max(0, min(1, (t - 0.1) / 0.4))
231
+ chars_shown = int(len(self.command) * ease_in_out(type_t))
232
+ typed = self.command[:chars_shown]
233
+
234
+ # Draw indicator — animate from gray to color as typing progresses
235
+ if chars_shown > 0:
236
+ ind_t = min(1, chars_shown / max(3, len(self.command) * 0.3))
237
+ ind_color = lerp_color(GRAY, self.indicator_color, ease_out(ind_t))
238
+ else:
239
+ ind_color = GRAY
240
+ draw_indicator(draw, indicator_x, indicator_y, ind_color, size=8)
241
+
242
+ # Draw typed text
243
+ draw.text((cx + prompt_w, cy), typed, fill=WHITE, font=FONT_MONO)
244
+
245
+ # Cursor
246
+ typed_w = text_width(draw, typed, FONT_MONO)
247
+ cursor_visible = (f % (FPS // 2)) < (FPS // 4) or type_t < 1
248
+ if type_t < 1:
249
+ cursor_visible = True
250
+ draw_cursor(draw, cx + prompt_w + typed_w, cy, FONT_MONO, cursor_visible)
251
+
252
+ # Response lines (appear after typing done)
253
+ if t > 0.55 and self.response_lines:
254
+ resp_t = (t - 0.55) / 0.35
255
+ lines_to_show = int(len(self.response_lines) * min(1, resp_t * 1.5))
256
+ for i in range(lines_to_show):
257
+ line = self.response_lines[i]
258
+ line_y = cy + 50 + i * 42
259
+ # Determine line color
260
+ if line.startswith("$"):
261
+ lc = GREEN
262
+ elif line.startswith("#"):
263
+ lc = GRAY
264
+ line = line[1:].strip()
265
+ else:
266
+ lc = WHITE
267
+ draw.text((cx, line_y), line, fill=lc, font=FONT_MONO_SM)
268
+
269
+ # Mode badge in lower right of terminal
270
+ mode_text = "AUTO"
271
+ badge_w = text_width(draw, mode_text, FONT_MONO_SM) + 20
272
+ badge_x = W - 50 - badge_w - 10
273
+ badge_y = term_top + term_height - 45
274
+ draw_rounded_rect(draw, (badge_x, badge_y, badge_x + badge_w, badge_y + 30), 6, fill=(30, 30, 42))
275
+ draw.text((badge_x + 10, badge_y + 2), mode_text, fill=BLUE, font=FONT_MONO_SM)
276
+
277
+ return img
278
+
279
+
280
+ class SplitComparisonScene(Scene):
281
+ """Side-by-side showing same word routing differently."""
282
+ def __init__(self):
283
+ super().__init__(5)
284
+
285
+ def render(self, f):
286
+ img = Image.new("RGB", (W, H), BG)
287
+ draw = ImageDraw.Draw(img)
288
+ t = f / self.duration
289
+
290
+ # Title
291
+ title = "Same word. Different intent."
292
+ title_t = min(1, t / 0.15)
293
+ c = lerp_color(BG, WHITE, ease_out(title_t))
294
+ tw = draw.textlength(title, font=FONT_HOOK_SM)
295
+ draw.text(((W - tw) / 2, 100), title, fill=c, font=FONT_HOOK_SM)
296
+
297
+ # Top terminal: "install nodejs" → shell (green)
298
+ if t > 0.15:
299
+ t1 = (t - 0.15) / 0.35
300
+ term1_top = 240
301
+ cx, cy = draw_terminal_window(draw, term1_top, 200, "shell command")
302
+
303
+ cmd1 = "install nodejs"
304
+ chars1 = int(len(cmd1) * min(1, ease_in_out(max(0, t1))))
305
+ typed1 = cmd1[:chars1]
306
+
307
+ draw.text((cx, cy), "~ ", fill=GREEN, font=FONT_MONO)
308
+ pw = text_width(draw, "~ ", FONT_MONO)
309
+ draw.text((cx + pw, cy), typed1, fill=WHITE, font=FONT_MONO)
310
+
311
+ # Green indicator
312
+ if chars1 > 0:
313
+ draw_indicator(draw, cx - 4, cy + 18, GREEN, 8)
314
+ else:
315
+ draw_indicator(draw, cx - 4, cy + 18, GRAY, 8)
316
+
317
+ # Response
318
+ if t1 > 0.7:
319
+ draw.text((cx, cy + 45), "→ brew install node", fill=GRAY, font=FONT_MONO_SM)
320
+ # Green label
321
+ lbl = "SHELL"
322
+ lw = text_width(draw, lbl, FONT_LABEL)
323
+ draw.text((W - 74 - lw, term1_top + 170), lbl, fill=GREEN, font=FONT_LABEL)
324
+
325
+ # Bottom terminal: "install a way to..." → agent (magenta)
326
+ if t > 0.45:
327
+ t2 = (t - 0.45) / 0.4
328
+ term2_top = 520
329
+ cx2, cy2 = draw_terminal_window(draw, term2_top, 250, "natural language")
330
+
331
+ cmd2 = "install a way to monitor logs"
332
+ chars2 = int(len(cmd2) * min(1, ease_in_out(max(0, t2))))
333
+ typed2 = cmd2[:chars2]
334
+
335
+ draw.text((cx2, cy2), "~ ", fill=GREEN, font=FONT_MONO)
336
+ pw2 = text_width(draw, "~ ", FONT_MONO)
337
+ draw.text((cx2 + pw2, cy2), typed2, fill=WHITE, font=FONT_MONO)
338
+
339
+ # Magenta indicator transitions as NL detected
340
+ if chars2 > 10:
341
+ draw_indicator(draw, cx2 - 4, cy2 + 18, MAGENTA, 8)
342
+ elif chars2 > 0:
343
+ draw_indicator(draw, cx2 - 4, cy2 + 18, GREEN, 8)
344
+ else:
345
+ draw_indicator(draw, cx2 - 4, cy2 + 18, GRAY, 8)
346
+
347
+ # AI response
348
+ if t2 > 0.7:
349
+ ai_lines = [
350
+ "→ AI: I'd recommend using",
351
+ " 'pm2' or 'lnav'. Want me",
352
+ " to set one up for you?",
353
+ ]
354
+ for i, line in enumerate(ai_lines):
355
+ draw.text((cx2, cy2 + 45 + i * 38), line, fill=MAGENTA, font=FONT_MONO_SM)
356
+
357
+ lbl = "AGENT"
358
+ lw = text_width(draw, lbl, FONT_LABEL)
359
+ draw.text((W - 74 - lw, term2_top + 218), lbl, fill=MAGENTA, font=FONT_LABEL)
360
+
361
+ # Divider line
362
+ if t > 0.4:
363
+ div_t = min(1, (t - 0.4) / 0.1)
364
+ div_w = int(400 * ease_out(div_t))
365
+ div_x = (W - div_w) // 2
366
+ draw.line([(div_x, 490), (div_x + div_w, 490)], fill=DIM, width=2)
367
+
368
+ return img
369
+
370
+
371
+ class CTAScene(Scene):
372
+ """Call to action with install command."""
373
+ def __init__(self):
374
+ super().__init__(3.5)
375
+
376
+ def render(self, f):
377
+ img = Image.new("RGB", (W, H), BG)
378
+ draw = ImageDraw.Draw(img)
379
+ t = f / self.duration
380
+
381
+ # Gradient text effect for "lacy.sh"
382
+ brand_t = min(1, t / 0.25)
383
+ c = lerp_color(BG, WHITE, ease_out(brand_t))
384
+ brand = "lacy.sh"
385
+ tw = draw.textlength(brand, font=FONT_HOOK)
386
+ draw.text(((W - tw) / 2, H // 2 - 200), brand, fill=c, font=FONT_HOOK)
387
+
388
+ # Tagline
389
+ if t > 0.15:
390
+ tag_t = min(1, (t - 0.15) / 0.2)
391
+ c = lerp_color(BG, GRAY, ease_out(tag_t))
392
+ tag = "Talk to your shell."
393
+ tw = draw.textlength(tag, font=FONT_CTA_SM)
394
+ draw.text(((W - tw) / 2, H // 2 - 110), tag, fill=c, font=FONT_CTA_SM)
395
+
396
+ # Install command box
397
+ if t > 0.3:
398
+ box_t = min(1, (t - 0.3) / 0.2)
399
+ install = "curl -fsSL lacy.sh/install | bash"
400
+ iw = text_width(draw, install, FONT_MONO_SM) + 50
401
+ box_x = (W - iw) / 2
402
+ box_y = H // 2 - 20
403
+
404
+ # Box background with border
405
+ box_alpha = ease_out(box_t)
406
+ box_fill = lerp_color(BG, (30, 30, 42), box_alpha)
407
+ border_fill = lerp_color(BG, MAGENTA, box_alpha * 0.5)
408
+ draw_rounded_rect(draw, (box_x - 2, box_y - 2, box_x + iw + 2, box_y + 52), 12, fill=border_fill)
409
+ draw_rounded_rect(draw, (box_x, box_y, box_x + iw, box_y + 50), 10, fill=box_fill)
410
+
411
+ ic = lerp_color(BG, WHITE, box_alpha)
412
+ draw.text((box_x + 25, box_y + 10), install, fill=ic, font=FONT_MONO_SM)
413
+
414
+ # Bottom features
415
+ if t > 0.5:
416
+ feats = ["ZSH & Bash", "macOS / Linux / WSL", "Works with any AI CLI"]
417
+ for i, feat in enumerate(feats):
418
+ feat_t = min(1, (t - 0.5 - i * 0.08) / 0.2)
419
+ if feat_t <= 0:
420
+ continue
421
+ fc = lerp_color(BG, DIM, ease_out(feat_t))
422
+ fw = draw.textlength(feat, font=FONT_LABEL)
423
+ fy = H // 2 + 100 + i * 45
424
+ draw.text(((W - fw) / 2, fy), feat, fill=fc, font=FONT_LABEL)
425
+
426
+ # Colored dots decorative
427
+ if t > 0.6:
428
+ dot_t = min(1, (t - 0.6) / 0.2)
429
+ dot_alpha = ease_out(dot_t)
430
+ dots = [(GREEN, -60), (MAGENTA, 0), (BLUE, 60)]
431
+ for color, offset in dots:
432
+ dc = lerp_color(BG, color, dot_alpha)
433
+ cx = W // 2 + offset
434
+ cy_pos = H // 2 + 280
435
+ draw.ellipse([cx - 6, cy_pos - 6, cx + 6, cy_pos + 6], fill=dc)
436
+
437
+ return img
438
+
439
+
440
+ # === Main Generation ===
441
+
442
+ def generate_all():
443
+ scenes = [
444
+ HookScene(), # 3.5s - Hook
445
+ TerminalTypingScene( # 5s - Shell command
446
+ prompt_user="~",
447
+ command="ls -la",
448
+ indicator_color=GREEN,
449
+ label="SHELL COMMAND → EXECUTES NORMALLY",
450
+ response_lines=[
451
+ "total 48",
452
+ "drwxr-xr-x 12 user staff 384 Mar 13",
453
+ "-rw-r--r-- 1 user staff 1024 README.md",
454
+ "-rw-r--r-- 1 user staff 512 package.json",
455
+ ],
456
+ duration_sec=4.5,
457
+ ),
458
+ TerminalTypingScene( # 5s - NL query
459
+ prompt_user="~",
460
+ command="what files are in this project",
461
+ indicator_color=MAGENTA,
462
+ label="NATURAL LANGUAGE → ROUTES TO AI",
463
+ response_lines=[
464
+ "#AI analyzing your project...",
465
+ "",
466
+ "This is a Node.js project with:",
467
+ " - 12 source files in src/",
468
+ " - Jest test suite (8 specs)",
469
+ " - TypeScript configuration",
470
+ ],
471
+ duration_sec=5.5,
472
+ ),
473
+ SplitComparisonScene(), # 5s - Comparison
474
+ TerminalTypingScene( # 4s - Auto-reroute demo
475
+ prompt_user="~",
476
+ command="fix the failing tests",
477
+ indicator_color=MAGENTA,
478
+ label="AUTO-REROUTE: FAILS → SENDS TO AI",
479
+ response_lines=[
480
+ "#bash: fix: command not found",
481
+ "#→ Detected NL, routing to AI...",
482
+ "",
483
+ "Found 2 failing tests. Fixing...",
484
+ " ✓ auth.test.ts — fixed import",
485
+ " ✓ api.test.ts — updated mock",
486
+ ],
487
+ duration_sec=5.5,
488
+ ),
489
+ CTAScene(), # 3.5s - CTA
490
+ ]
491
+
492
+ frame_num = 0
493
+ total_frames = sum(s.duration for s in scenes)
494
+ print(f"Generating {total_frames} frames ({total_frames / FPS:.1f}s at {FPS}fps)")
495
+
496
+ for scene_idx, scene in enumerate(scenes):
497
+ print(f" Scene {scene_idx + 1}/{len(scenes)}: {scene.__class__.__name__} ({scene.duration} frames)")
498
+ for f in range(scene.duration):
499
+ img = scene.render(f)
500
+ img.save(os.path.join(OUT_DIR, f"frame_{frame_num:05d}.png"))
501
+ frame_num += 1
502
+
503
+ print(f"Done! {frame_num} frames saved to {OUT_DIR}")
504
+ return frame_num, total_frames / FPS
505
+
506
+
507
+ if __name__ == "__main__":
508
+ n_frames, duration = generate_all()
509
+ print(f"\nTo create video:")
510
+ print(f" ffmpeg -framerate {FPS} -i {OUT_DIR}/frame_%05d.png -c:v libx264 -pix_fmt yuv420p -crf 18 /tmp/ugc-video/lacy-shell-demo.mp4")