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,328 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Generate frames for Lacy Shell UGC SHORT video (15s).
|
|
4
|
+
Quick hook → single demo → CTA. Maximum shareability.
|
|
5
|
+
Vertical 1080x1920 for TikTok/Reels/Shorts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import math
|
|
10
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
11
|
+
|
|
12
|
+
W, H = 1080, 1920
|
|
13
|
+
FPS = 30
|
|
14
|
+
OUT_DIR = "/tmp/ugc-video/frames-short"
|
|
15
|
+
os.makedirs(OUT_DIR, exist_ok=True)
|
|
16
|
+
|
|
17
|
+
# Colors
|
|
18
|
+
BG = (13, 13, 15)
|
|
19
|
+
TERM_BG = (22, 22, 28)
|
|
20
|
+
WHITE = (235, 235, 240)
|
|
21
|
+
GRAY = (130, 130, 145)
|
|
22
|
+
DIM = (80, 80, 95)
|
|
23
|
+
GREEN = (52, 211, 153)
|
|
24
|
+
MAGENTA = (216, 100, 240)
|
|
25
|
+
BLUE = (96, 165, 250)
|
|
26
|
+
YELLOW = (250, 204, 21)
|
|
27
|
+
|
|
28
|
+
def load_font(size, bold=False):
|
|
29
|
+
paths = ["/opt/X11/share/system_fonts/Menlo.ttc", "/System/Library/Fonts/Menlo.ttc"]
|
|
30
|
+
for p in paths:
|
|
31
|
+
try:
|
|
32
|
+
return ImageFont.truetype(p, size, index=1 if bold else 0)
|
|
33
|
+
except:
|
|
34
|
+
try:
|
|
35
|
+
return ImageFont.truetype(p, size)
|
|
36
|
+
except:
|
|
37
|
+
continue
|
|
38
|
+
return ImageFont.load_default()
|
|
39
|
+
|
|
40
|
+
def load_sans(size, bold=False):
|
|
41
|
+
for p in ["/System/Library/Fonts/SFNS.ttf"]:
|
|
42
|
+
try:
|
|
43
|
+
return ImageFont.truetype(p, size, index=2 if bold else 0)
|
|
44
|
+
except:
|
|
45
|
+
try:
|
|
46
|
+
return ImageFont.truetype(p, size)
|
|
47
|
+
except:
|
|
48
|
+
continue
|
|
49
|
+
return load_font(size, bold)
|
|
50
|
+
|
|
51
|
+
FONT_BIG = load_sans(72, True)
|
|
52
|
+
FONT_MED = load_sans(48, True)
|
|
53
|
+
FONT_SM = load_sans(32)
|
|
54
|
+
FONT_MONO = load_font(30)
|
|
55
|
+
FONT_MONO_SM = load_font(24)
|
|
56
|
+
FONT_MONO_LG = load_font(36, True)
|
|
57
|
+
|
|
58
|
+
def ease_out(t): return 1 - (1 - min(1, max(0, t))) ** 3
|
|
59
|
+
def ease_in_out(t): return 3 * t * t - 2 * t * t * t if t < 1 else 1
|
|
60
|
+
|
|
61
|
+
def lerp(c1, c2, t):
|
|
62
|
+
t = max(0, min(1, t))
|
|
63
|
+
return tuple(int(c1[i] + (c2[i] - c1[i]) * t) for i in range(3))
|
|
64
|
+
|
|
65
|
+
def rounded_rect(draw, xy, r, fill):
|
|
66
|
+
x0, y0, x1, y1 = xy
|
|
67
|
+
draw.rectangle([x0+r, y0, x1-r, y1], fill=fill)
|
|
68
|
+
draw.rectangle([x0, y0+r, x1, y1-r], fill=fill)
|
|
69
|
+
draw.pieslice([x0, y0, x0+2*r, y0+2*r], 180, 270, fill=fill)
|
|
70
|
+
draw.pieslice([x1-2*r, y0, x1, y0+2*r], 270, 360, fill=fill)
|
|
71
|
+
draw.pieslice([x0, y1-2*r, x0+2*r, y1], 90, 180, fill=fill)
|
|
72
|
+
draw.pieslice([x1-2*r, y1-2*r, x1, y1], 0, 90, fill=fill)
|
|
73
|
+
|
|
74
|
+
def tw(draw, text, font):
|
|
75
|
+
return draw.textlength(text, font=font)
|
|
76
|
+
|
|
77
|
+
def center_text(draw, y, text, font, color):
|
|
78
|
+
w = tw(draw, text, font)
|
|
79
|
+
draw.text(((W - w) / 2, y), text, fill=color, font=font)
|
|
80
|
+
|
|
81
|
+
# === Scenes ===
|
|
82
|
+
|
|
83
|
+
def scene_hook(f, total):
|
|
84
|
+
"""0-2s: Bold hook text"""
|
|
85
|
+
img = Image.new("RGB", (W, H), BG)
|
|
86
|
+
draw = ImageDraw.Draw(img)
|
|
87
|
+
t = f / total
|
|
88
|
+
|
|
89
|
+
# "Stop" - pops in
|
|
90
|
+
if t < 0.5:
|
|
91
|
+
s_t = ease_out(t / 0.15)
|
|
92
|
+
c = lerp(BG, WHITE, s_t)
|
|
93
|
+
center_text(draw, H//2 - 180, "Stop", FONT_BIG, c)
|
|
94
|
+
|
|
95
|
+
# "copy-pasting" - slides in
|
|
96
|
+
if t > 0.12:
|
|
97
|
+
c_t = ease_out((t - 0.12) / 0.15)
|
|
98
|
+
c = lerp(BG, WHITE, c_t)
|
|
99
|
+
center_text(draw, H//2 - 80, "copy-pasting", FONT_BIG, c)
|
|
100
|
+
|
|
101
|
+
# "into ChatGPT" — in yellow
|
|
102
|
+
if t > 0.25:
|
|
103
|
+
g_t = ease_out((t - 0.25) / 0.15)
|
|
104
|
+
c = lerp(BG, YELLOW, g_t)
|
|
105
|
+
center_text(draw, H//2 + 20, "into ChatGPT.", FONT_BIG, c)
|
|
106
|
+
|
|
107
|
+
# Subtext
|
|
108
|
+
if t > 0.55:
|
|
109
|
+
sub_t = ease_out((t - 0.55) / 0.2)
|
|
110
|
+
c = lerp(BG, GRAY, sub_t)
|
|
111
|
+
center_text(draw, H//2 + 160, "Just talk to your shell.", FONT_MED, c)
|
|
112
|
+
|
|
113
|
+
return img
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def scene_demo(f, total):
|
|
117
|
+
"""2-10s: Terminal demo — type command, then type NL query"""
|
|
118
|
+
img = Image.new("RGB", (W, H), BG)
|
|
119
|
+
draw = ImageDraw.Draw(img)
|
|
120
|
+
t = f / total
|
|
121
|
+
|
|
122
|
+
margin = 50
|
|
123
|
+
term_top = 250
|
|
124
|
+
term_h = 1100
|
|
125
|
+
|
|
126
|
+
# Terminal window
|
|
127
|
+
rounded_rect(draw, (margin, term_top, W-margin, term_top+term_h), 16, TERM_BG)
|
|
128
|
+
# Title bar
|
|
129
|
+
rounded_rect(draw, (margin, term_top, W-margin, term_top+44), 16, (35, 35, 45))
|
|
130
|
+
draw.rectangle([margin, term_top+30, W-margin, term_top+44], fill=(35, 35, 45))
|
|
131
|
+
draw.ellipse([margin+18, term_top+14, margin+32, term_top+28], fill=(255, 95, 87))
|
|
132
|
+
draw.ellipse([margin+42, term_top+14, margin+56, term_top+28], fill=(255, 189, 46))
|
|
133
|
+
draw.ellipse([margin+66, term_top+14, margin+80, term_top+28], fill=(39, 201, 63))
|
|
134
|
+
|
|
135
|
+
title = "lacy ~"
|
|
136
|
+
ttw = tw(draw, title, FONT_MONO_SM)
|
|
137
|
+
draw.text(((W-ttw)/2, term_top+12), title, fill=GRAY, font=FONT_MONO_SM)
|
|
138
|
+
|
|
139
|
+
cx, cy = margin + 24, term_top + 65
|
|
140
|
+
|
|
141
|
+
# Phase 1 (0-0.35): Type "git status" → green indicator → shell output
|
|
142
|
+
cmd1 = "git status"
|
|
143
|
+
type1_t = min(1, t / 0.2)
|
|
144
|
+
chars1 = int(len(cmd1) * ease_in_out(type1_t))
|
|
145
|
+
|
|
146
|
+
# Indicator
|
|
147
|
+
if chars1 > 0:
|
|
148
|
+
ind_c = GREEN
|
|
149
|
+
else:
|
|
150
|
+
ind_c = GRAY
|
|
151
|
+
draw.ellipse([cx-2, cy+8, cx+12, cy+22], fill=ind_c)
|
|
152
|
+
|
|
153
|
+
prompt_text = "~ "
|
|
154
|
+
draw.text((cx + 20, cy), prompt_text, fill=GREEN, font=FONT_MONO)
|
|
155
|
+
pw = tw(draw, prompt_text, FONT_MONO)
|
|
156
|
+
draw.text((cx + 20 + pw, cy), cmd1[:chars1], fill=WHITE, font=FONT_MONO)
|
|
157
|
+
|
|
158
|
+
# Cursor for cmd1
|
|
159
|
+
if t < 0.25:
|
|
160
|
+
cw = tw(draw, cmd1[:chars1], FONT_MONO)
|
|
161
|
+
if (f % 15) < 10 or type1_t < 1:
|
|
162
|
+
bbox = FONT_MONO.getbbox("M")
|
|
163
|
+
draw.rectangle([cx+20+pw+cw, cy-1, cx+20+pw+cw+(bbox[2]-bbox[0]), cy+bbox[3]-bbox[1]+1], fill=WHITE)
|
|
164
|
+
|
|
165
|
+
# Shell output for git status
|
|
166
|
+
if t > 0.25:
|
|
167
|
+
out_t = min(1, (t - 0.25) / 0.08)
|
|
168
|
+
lines = [
|
|
169
|
+
("On branch main", WHITE),
|
|
170
|
+
("Changes not staged:", YELLOW),
|
|
171
|
+
(" modified: src/app.ts", (255, 130, 100)),
|
|
172
|
+
(" modified: src/utils.ts", (255, 130, 100)),
|
|
173
|
+
]
|
|
174
|
+
n_show = int(len(lines) * ease_out(out_t))
|
|
175
|
+
for i in range(n_show):
|
|
176
|
+
draw.text((cx + 20, cy + 44 + i * 36), lines[i][0], fill=lines[i][1], font=FONT_MONO_SM)
|
|
177
|
+
|
|
178
|
+
# Green "SHELL" label
|
|
179
|
+
if out_t > 0.5:
|
|
180
|
+
lb_t = ease_out((out_t - 0.5) / 0.5)
|
|
181
|
+
lc = lerp(BG, GREEN, lb_t)
|
|
182
|
+
lbl = "→ Shell"
|
|
183
|
+
lw = tw(draw, lbl, FONT_SM)
|
|
184
|
+
draw.text((W - margin - lw - 20, cy + 2), lbl, fill=lc, font=FONT_SM)
|
|
185
|
+
|
|
186
|
+
# Phase 2 (0.4-0.75): Type NL query → magenta indicator → AI response
|
|
187
|
+
line2_y = cy + 230
|
|
188
|
+
if t > 0.38:
|
|
189
|
+
t2 = (t - 0.38) / 0.35
|
|
190
|
+
cmd2 = "explain what changed and why"
|
|
191
|
+
chars2 = int(len(cmd2) * min(1, ease_in_out(t2)))
|
|
192
|
+
|
|
193
|
+
# Magenta indicator (transitions from green)
|
|
194
|
+
if chars2 > 5:
|
|
195
|
+
ind2_c = MAGENTA
|
|
196
|
+
elif chars2 > 0:
|
|
197
|
+
blend = chars2 / 5
|
|
198
|
+
ind2_c = lerp(GREEN, MAGENTA, blend)
|
|
199
|
+
else:
|
|
200
|
+
ind2_c = GRAY
|
|
201
|
+
draw.ellipse([cx-2, line2_y+8, cx+12, line2_y+22], fill=ind2_c)
|
|
202
|
+
|
|
203
|
+
draw.text((cx+20, line2_y), "~ ", fill=GREEN, font=FONT_MONO)
|
|
204
|
+
draw.text((cx+20+pw, line2_y), cmd2[:chars2], fill=WHITE, font=FONT_MONO)
|
|
205
|
+
|
|
206
|
+
# Cursor
|
|
207
|
+
if t2 < 1:
|
|
208
|
+
cw2 = tw(draw, cmd2[:chars2], FONT_MONO)
|
|
209
|
+
if (f % 15) < 10:
|
|
210
|
+
bbox = FONT_MONO.getbbox("M")
|
|
211
|
+
draw.rectangle([cx+20+pw+cw2, line2_y-1, cx+20+pw+cw2+(bbox[2]-bbox[0]), line2_y+bbox[3]-bbox[1]+1], fill=WHITE)
|
|
212
|
+
|
|
213
|
+
# Magenta "AGENT" label
|
|
214
|
+
if t2 > 0.5:
|
|
215
|
+
lb2_t = ease_out((t2 - 0.5) / 0.5)
|
|
216
|
+
lc2 = lerp(BG, MAGENTA, lb2_t)
|
|
217
|
+
draw.text((W - margin - tw(draw, "→ Agent", FONT_SM) - 20, line2_y + 2), "→ Agent", fill=lc2, font=FONT_SM)
|
|
218
|
+
|
|
219
|
+
# AI response
|
|
220
|
+
if t > 0.78:
|
|
221
|
+
ai_t = (t - 0.78) / 0.2
|
|
222
|
+
ai_lines = [
|
|
223
|
+
"The changes in src/app.ts add a",
|
|
224
|
+
"new authentication middleware that",
|
|
225
|
+
"validates JWT tokens before routing.",
|
|
226
|
+
"",
|
|
227
|
+
"src/utils.ts was updated to export",
|
|
228
|
+
"a new `verifyToken()` helper that",
|
|
229
|
+
"the middleware depends on.",
|
|
230
|
+
]
|
|
231
|
+
n_ai = int(len(ai_lines) * min(1, ease_out(ai_t * 1.3)))
|
|
232
|
+
for i in range(n_ai):
|
|
233
|
+
lc_ai = lerp(TERM_BG, MAGENTA, ease_out(ai_t))
|
|
234
|
+
draw.text((cx + 20, line2_y + 44 + i * 36), ai_lines[i], fill=lc_ai, font=FONT_MONO_SM)
|
|
235
|
+
|
|
236
|
+
# Mode badge
|
|
237
|
+
rounded_rect(draw, (W-margin-90, term_top+term_h-42, W-margin-10, term_top+term_h-14), 6, (30, 30, 42))
|
|
238
|
+
draw.text((W-margin-82, term_top+term_h-40), "AUTO", fill=BLUE, font=FONT_MONO_SM)
|
|
239
|
+
|
|
240
|
+
# Top label
|
|
241
|
+
if t < 0.38:
|
|
242
|
+
lbl_text = "Commands run in your shell"
|
|
243
|
+
lbl_c = GREEN
|
|
244
|
+
else:
|
|
245
|
+
lbl_text = "Questions go to AI"
|
|
246
|
+
lbl_c = MAGENTA
|
|
247
|
+
ltw = tw(draw, lbl_text, FONT_SM)
|
|
248
|
+
center_text(draw, 170, lbl_text, FONT_SM, lbl_c)
|
|
249
|
+
|
|
250
|
+
return img
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def scene_cta(f, total):
|
|
254
|
+
"""10-15s: CTA"""
|
|
255
|
+
img = Image.new("RGB", (W, H), BG)
|
|
256
|
+
draw = ImageDraw.Draw(img)
|
|
257
|
+
t = f / total
|
|
258
|
+
|
|
259
|
+
# Brand
|
|
260
|
+
b_t = ease_out(t / 0.2)
|
|
261
|
+
center_text(draw, H//2 - 220, "lacy.sh", FONT_BIG, lerp(BG, WHITE, b_t))
|
|
262
|
+
|
|
263
|
+
# Tagline
|
|
264
|
+
if t > 0.1:
|
|
265
|
+
tg_t = ease_out((t - 0.1) / 0.2)
|
|
266
|
+
center_text(draw, H//2 - 120, "Talk to your shell.", FONT_MED, lerp(BG, GRAY, tg_t))
|
|
267
|
+
|
|
268
|
+
# Install box
|
|
269
|
+
if t > 0.25:
|
|
270
|
+
bx_t = ease_out((t - 0.25) / 0.2)
|
|
271
|
+
install = "curl -fsSL lacy.sh/install | bash"
|
|
272
|
+
iw = tw(draw, install, FONT_MONO_SM) + 50
|
|
273
|
+
bx = (W - iw) / 2
|
|
274
|
+
by = H//2 - 20
|
|
275
|
+
rounded_rect(draw, (bx-2, by-2, bx+iw+2, by+52), 12, lerp(BG, MAGENTA, bx_t * 0.4))
|
|
276
|
+
rounded_rect(draw, (bx, by, bx+iw, by+50), 10, lerp(BG, (30, 30, 42), bx_t))
|
|
277
|
+
draw.text((bx+25, by+12), install, fill=lerp(BG, WHITE, bx_t), font=FONT_MONO_SM)
|
|
278
|
+
|
|
279
|
+
# "or" + npx
|
|
280
|
+
if t > 0.4:
|
|
281
|
+
or_t = ease_out((t - 0.4) / 0.15)
|
|
282
|
+
center_text(draw, H//2 + 60, "or", FONT_SM, lerp(BG, DIM, or_t))
|
|
283
|
+
center_text(draw, H//2 + 105, "npx lacy", FONT_MONO, lerp(BG, GRAY, or_t))
|
|
284
|
+
|
|
285
|
+
# Features
|
|
286
|
+
if t > 0.55:
|
|
287
|
+
feats = ["ZSH & Bash", "macOS · Linux · WSL", "Works with Claude, Gemini, Codex..."]
|
|
288
|
+
for i, feat in enumerate(feats):
|
|
289
|
+
ft = ease_out((t - 0.55 - i * 0.06) / 0.15)
|
|
290
|
+
if ft > 0:
|
|
291
|
+
center_text(draw, H//2 + 200 + i * 50, feat, FONT_SM, lerp(BG, DIM, ft))
|
|
292
|
+
|
|
293
|
+
# Colored dots
|
|
294
|
+
if t > 0.7:
|
|
295
|
+
dt = ease_out((t - 0.7) / 0.15)
|
|
296
|
+
for color, ox in [(GREEN, -60), (MAGENTA, 0), (BLUE, 60)]:
|
|
297
|
+
dc = lerp(BG, color, dt)
|
|
298
|
+
draw.ellipse([W//2+ox-6, H//2+380, W//2+ox+6, H//2+392], fill=dc)
|
|
299
|
+
|
|
300
|
+
return img
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# === Generate ===
|
|
304
|
+
|
|
305
|
+
def generate():
|
|
306
|
+
scenes = [
|
|
307
|
+
(scene_hook, 2.0), # 0-2s
|
|
308
|
+
(scene_demo, 8.0), # 2-10s
|
|
309
|
+
(scene_cta, 5.0), # 10-15s
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
frame_num = 0
|
|
313
|
+
total_frames = int(sum(d for _, d in scenes) * FPS)
|
|
314
|
+
print(f"Generating {total_frames} frames ({total_frames/FPS:.1f}s at {FPS}fps)")
|
|
315
|
+
|
|
316
|
+
for i, (fn, dur) in enumerate(scenes):
|
|
317
|
+
n = int(dur * FPS)
|
|
318
|
+
print(f" Scene {i+1}/{len(scenes)}: {fn.__name__} ({n} frames)")
|
|
319
|
+
for f in range(n):
|
|
320
|
+
img = fn(f, n)
|
|
321
|
+
img.save(os.path.join(OUT_DIR, f"frame_{frame_num:05d}.png"))
|
|
322
|
+
frame_num += 1
|
|
323
|
+
|
|
324
|
+
print(f"Done! {frame_num} frames")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
generate()
|