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.
- package/.claude/settings.local.json +26 -0
- package/.github/FUNDING.yml +3 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +49 -0
- package/.github/ISSUE_TEMPLATE/config.yml +5 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +28 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +17 -0
- package/.github/SECURITY.md +32 -0
- package/.github/assets/logo-horizontal-dark.png +0 -0
- package/.github/assets/logo-horizontal-dark.svg +17 -0
- package/.github/assets/logo-horizontal.png +0 -0
- package/.github/assets/logo-horizontal.svg +17 -0
- package/.github/assets/logo.png +0 -0
- package/.github/assets/logo.svg +12 -0
- package/.github/assets/social-preview.png +0 -0
- package/.github/assets/social-preview.svg +50 -0
- package/.github/dependabot.yml +21 -0
- package/.github/workflows/ci.yml +80 -0
- package/.github/workflows/dependabot-auto-merge.yml +32 -0
- package/CHANGELOG.md +366 -0
- package/CLAUDE.md +340 -0
- package/CONTRIBUTING.md +141 -0
- package/LICENSE +110 -0
- package/README.md +201 -31
- package/RELEASING.md +148 -0
- package/STYLE.md +202 -0
- package/assets/hero.jpeg +0 -0
- package/assets/mode-indicators.jpeg +0 -0
- package/assets/real-time-indicator.jpeg +0 -0
- package/assets/supported-tools.jpeg +0 -0
- package/bin/lacy +1028 -0
- package/docs/ADDING-BACKENDS.md +124 -0
- package/docs/DEVTO-ARTICLE.md +94 -0
- package/docs/DOCS.md +68 -0
- package/docs/GROWTH-STRATEGY.md +119 -0
- package/docs/HN-RESPONSES.md +122 -0
- package/docs/LAUNCH-COPY-FINAL.md +105 -0
- package/docs/MARKETING.md +411 -0
- package/docs/NATURAL_LANGUAGE_DETECTION.md +204 -0
- package/docs/UGC_VIDEO_SCRIPT.md +114 -0
- package/docs/articles/devto-how-i-made-my-terminal-understand-english.md +117 -0
- package/docs/demo-color-transition.gif +0 -0
- package/docs/demo-full.gif +0 -0
- package/docs/demo-indicator.gif +0 -0
- package/docs/launch-thread-may6.sh +158 -0
- package/docs/videos/README.md +189 -0
- package/docs/videos/generate_frames.py +510 -0
- package/docs/videos/generate_frames_v2.py +729 -0
- package/docs/videos/generate_short.py +328 -0
- package/docs/videos/generate_short_v2.py +526 -0
- package/docs/videos/lacy-shell-demo-v2.mp4 +0 -0
- package/docs/videos/lacy-shell-demo.mp4 +0 -0
- package/docs/videos/lacy-shell-short-v2.mp4 +0 -0
- package/docs/videos/lacy-shell-short.mp4 +0 -0
- package/install.sh +1009 -0
- package/lacy.plugin.bash +75 -0
- package/lacy.plugin.fish +43 -0
- package/lacy.plugin.zsh +65 -0
- package/lib/animations.zsh +3 -0
- package/lib/bash/completions.bash +40 -0
- package/lib/bash/execute.bash +233 -0
- package/lib/bash/init.bash +40 -0
- package/lib/bash/keybindings.bash +134 -0
- package/lib/bash/prompt.bash +85 -0
- package/lib/commands/info.sh +25 -0
- package/lib/config.zsh +3 -0
- package/lib/constants.zsh +3 -0
- package/lib/core/animations.sh +271 -0
- package/lib/core/commands.sh +297 -0
- package/lib/core/config.sh +340 -0
- package/lib/core/constants.sh +366 -0
- package/lib/core/context.sh +260 -0
- package/lib/core/detection.sh +417 -0
- package/lib/core/mcp.sh +741 -0
- package/lib/core/modes.sh +123 -0
- package/lib/core/preheat.sh +496 -0
- package/lib/core/spinner.sh +174 -0
- package/lib/core/telemetry.sh +99 -0
- package/lib/detection.zsh +3 -0
- package/lib/execute.zsh +3 -0
- package/lib/fish/config.fish +66 -0
- package/lib/fish/detection.fish +90 -0
- package/lib/fish/execute.fish +105 -0
- package/lib/fish/keybindings.fish +42 -0
- package/lib/fish/prompt.fish +30 -0
- package/lib/keybindings.zsh +3 -0
- package/lib/mcp.zsh +3 -0
- package/lib/modes.zsh +3 -0
- package/lib/preheat.zsh +3 -0
- package/lib/prompt.zsh +3 -0
- package/lib/spinner.zsh +3 -0
- package/lib/zsh/completions.zsh +60 -0
- package/lib/zsh/execute.zsh +294 -0
- package/lib/zsh/init.zsh +26 -0
- package/lib/zsh/keybindings.zsh +551 -0
- package/lib/zsh/prompt.zsh +90 -0
- package/package.json +42 -27
- package/packages/lacy/README.md +61 -0
- package/packages/lacy/commands/info.sh +25 -0
- package/{index.mjs → packages/lacy/index.mjs} +247 -20
- package/packages/lacy/package-lock.json +71 -0
- package/packages/lacy/package.json +42 -0
- package/script/release.ts +487 -0
- package/squirrel.toml +36 -0
- package/tests/test_bash.bash +163 -0
- package/tests/test_core.sh +607 -0
- package/tests/test_gemini.sh +119 -0
- package/tests/test_gemini_mcp.sh +126 -0
- package/tests/test_preheat_server.zsh +446 -0
- 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
|
|
Binary file
|
|
Binary file
|