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,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()
|