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