Files
LihonT/scripts/make_lihont_promo.py
cheb0chik e63ca75eac LihonT 2 Luna pre-release
Avatar shapes (circle, flower, rounded square, hexagon, heart, triangle): the own shape everywhere and, through
the members service, the shapes other members published after proving the account with the code in the bio.
Own accent colour and AMOLED, compact bottom bar, Material 3 profile and plus button, save menu (quality, audio
only, photo posts without the watermark), fixed comment cards, "Luna (pre-release)" in the mod screen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 19:03:48 +03:00

517 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
LihonT 2 «Luna pre-release» promo video: 1080x1920, 30 fps, about 37 seconds, with a synthesised soundtrack.
The screens are real screenshots of the mod on a phone, taken to work/promo/shots/ (the status bar and the
gesture strip are cut off). Frames are drawn with Pillow in worker processes and piped to ffmpeg.
usage (from the repository root):
py -B scripts/make_lihont_promo.py # renders work/promo/lihont_promo.mp4
py -B scripts/make_lihont_promo.py --still 9.5 # one frame at 9.5 s as work/promo/still_9.50.png
"""
from __future__ import annotations
import argparse
import math
import random
import shutil
import struct
import subprocess
import wave
from multiprocessing import Pool
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter, ImageFont
W, H, FPS = 1080, 1920, 30
ROOT = Path(__file__).resolve().parents[1]
SHOTS = ROOT / "work" / "promo" / "shots"
OUT = ROOT / "work" / "promo" / "lihont_promo.mp4"
AUDIO = ROOT / "work" / "promo" / "promo_audio.wav"
LOGO = ROOT / "src" / "brand" / "logo_note_flower_1024.png"
ICONS = [("tolstyj", "Эй толстый"), ("elmacho", "Эль мачо"), ("pushistik", "Пушистик"), ("original", "Оригинал")]
# Scenes: name, start, end (seconds). Neighbouring scenes cross-fade over 0.4 s.
SCENES = [
("intro", 0.0, 3.6),
("system", 3.6, 8.0),
("comments", 8.0, 12.4),
("shapes", 12.4, 17.2),
("save", 17.2, 21.8),
("badges", 21.8, 25.8),
("settings", 25.8, 30.6),
("icons", 30.6, 33.8),
("outro", 33.8, 37.8),
]
DURATION = SCENES[-1][2]
FRAMES = int(round(DURATION * FPS))
FADE = 0.2
BG = (20, 18, 24)
PRIMARY = (208, 188, 255)
ON = (230, 224, 233)
MUTED = (202, 196, 208)
CHIP = (74, 68, 88)
PINK = (239, 184, 200)
CORAL = (226, 108, 108)
STATUS_CUT = 118 # status bar with the clock
NAV_CUT = 66 # gesture strip
# Faces of people other than the author are blurred in the comment shots: (left, top, right, bottom) in shot pixels.
BLUR = {
"comments.png": [
(56, 1312, 184, 1436), (206, 1304, 580, 1360),
(206, 1628, 300, 1718), (306, 1618, 654, 1676),
(54, 1892, 184, 2016), (206, 1884, 554, 1940),
],
}
FONT_BOLD = "C:/Windows/Fonts/segoeuib.ttf"
FONT_REG = "C:/Windows/Fonts/segoeui.ttf"
_fonts: dict = {}
def font(size: int, bold: bool = True) -> ImageFont.FreeTypeFont:
key = (int(size), bold)
if key not in _fonts:
_fonts[key] = ImageFont.truetype(FONT_BOLD if bold else FONT_REG, int(size))
return _fonts[key]
# ---------------------------------------------------------------- easing
def clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
return max(lo, min(hi, x))
def ease_out_cubic(x: float) -> float:
x = clamp(x)
return 1 - (1 - x) ** 3
def ease_out_back(x: float) -> float:
x = clamp(x)
c1 = 1.70158
c3 = c1 + 1
return 1 + c3 * ((x - 1) ** 3) + c1 * ((x - 1) ** 2)
def ease_in_out(x: float) -> float:
x = clamp(x)
return -(math.cos(math.pi * x) - 1) / 2
def pop(lt: float, delay: float, duration: float = 0.55) -> float:
return ease_out_back((lt - delay) / duration)
# ---------------------------------------------------------------- audio
def generate_audio(path: Path, duration: float) -> None:
rate = 44100
n = int(rate * duration)
bpm = 120
beat = 60.0 / bpm
random.seed(7)
arp = [220.0, 261.63, 293.66, 329.63, 392.0, 440.0, 523.25, 587.33]
risers = [s[1] - 0.8 for s in SCENES[1:]]
out = []
for i in range(n):
t = i / rate
idx = int(t / beat)
pos = (t % beat) / beat
kick = 0.0
if pos < 0.3:
freq = 160.0 * math.exp(-pos * 20) + 45.0
kick = math.sin(2 * math.pi * freq * pos) * math.exp(-pos * 12) * 0.85
clap = 0.0
if idx % 2 == 1 and pos < 0.22:
clap = ((random.random() * 2 - 1) * 0.7 + math.sin(2 * math.pi * 210 * pos) * 0.3) * math.exp(-pos * 16) * 0.55
hat = 0.0
sp = ((t * 4) % beat) / beat
if sp < 0.06:
hat = (random.random() * 2 - 1) * math.exp(-sp * 48) * (0.30 if int(t * 4 / beat) % 2 else 0.15)
root = [55.0, 55.0, 65.4, 73.4][(idx // 4) % 4]
side = min(1.0, pos * 3.5) if pos < 0.3 else 1.0
bass = (math.sin(2 * math.pi * root * t) + 0.3 * math.sin(4 * math.pi * root * t)) * side * 0.38
note = arp[int(t * 8) % len(arp)]
pluck = math.sin(2 * math.pi * note * t) * math.exp(-(t % 0.125) * 22) * 0.2
rise = 0.0
for cut in risers:
if 0 <= cut + 0.8 - t < 0.8 and t >= cut:
p = (t - cut) / 0.8
rise += (math.sin(2 * math.pi * (250 + p * p * 3000) * t) + random.random() * 0.3) * p * 0.28
fade = 1.0
if t < 0.4:
fade = t / 0.4
if t > duration - 1.4:
fade = max(0.0, (duration - t) / 1.4)
mix = max(-0.95, min(0.95, (kick + clap + hat + bass + pluck + rise) * fade))
out.append(int(mix * 32767))
with wave.open(str(path), "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(rate)
wf.writeframes(struct.pack("<%dh" % len(out), *out))
# ---------------------------------------------------------------- assets
ASSETS: dict = {}
def rounded(img: Image.Image, radius: int) -> Image.Image:
img = img.convert("RGBA")
mask = Image.new("L", img.size, 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, img.width - 1, img.height - 1], radius=radius, fill=255)
img.putalpha(mask)
return img
def load_shot(name: str) -> Image.Image:
img = Image.open(SHOTS / name).convert("RGB")
for box in BLUR.get(name, []):
region = img.crop(box).filter(ImageFilter.GaussianBlur(22))
img.paste(region, box[:2])
return img.crop((0, STATUS_CUT, img.width, img.height - NAV_CUT))
BEZEL, PAD = 14, 40
def phone_shell(screen_w: int, screen_h: int) -> Image.Image:
body_w, body_h = screen_w + 2 * BEZEL, screen_h + 2 * BEZEL
canvas = Image.new("RGBA", (body_w + 2 * PAD, body_h + 2 * PAD), (0, 0, 0, 0))
shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
ImageDraw.Draw(shadow).rounded_rectangle([PAD, PAD + 18, PAD + body_w, PAD + 18 + body_h], radius=56, fill=(0, 0, 0, 190))
canvas.alpha_composite(shadow.filter(ImageFilter.GaussianBlur(20)))
ImageDraw.Draw(canvas).rounded_rectangle([PAD, PAD, PAD + body_w, PAD + body_h], radius=56,
fill=(30, 29, 34, 255), outline=(82, 78, 90, 255), width=3)
return canvas
def mount(shell: Image.Image, screen: Image.Image) -> Image.Image:
phone = shell.copy()
phone.alpha_composite(rounded(screen, 40), (PAD + BEZEL, PAD + BEZEL))
return phone
def screen_for(shot: Image.Image, screen_w: int = 560) -> Image.Image:
return shot.resize((screen_w, int(screen_w * shot.height / shot.width)), Image.Resampling.LANCZOS)
def build_phone(shot: Image.Image, screen_w: int = 560) -> Image.Image:
screen = screen_for(shot, screen_w)
return mount(phone_shell(screen.width, screen.height), screen)
def blob(radius: int, color: tuple) -> Image.Image:
layer = Image.new("RGBA", (radius * 4, radius * 4), (0, 0, 0, 0))
ImageDraw.Draw(layer).ellipse([radius, radius, radius * 3, radius * 3], fill=color)
return layer.filter(ImageFilter.GaussianBlur(radius * 0.55))
def flower(size: int, color: tuple) -> Image.Image:
layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(layer)
c, r = size / 2, size * 0.30
pr = size * 0.17
d.ellipse([c - r, c - r, c + r, c + r], fill=color)
for k in range(8):
a = k * math.pi / 4
px, py = c + math.cos(a) * size * 0.31, c + math.sin(a) * size * 0.31
d.ellipse([px - pr, py - pr, px + pr, py + pr], fill=color)
return layer
def init_assets() -> None:
if ASSETS:
return
ASSETS["bg"] = Image.new("RGBA", (W, H), BG + (255,))
ASSETS["blobs"] = [blob(360, (208, 188, 255, 62)), blob(300, (239, 184, 200, 52)), blob(260, (120, 200, 190, 36))]
ASSETS["flower"] = flower(70, (255, 255, 255, 26))
rnd = random.Random(3)
ASSETS["motes"] = [(rnd.random() * W, rnd.random() * H, 0.4 + rnd.random() * 0.9, rnd.random() * 6.28) for _ in range(16)]
ASSETS["logo"] = Image.open(LOGO).convert("RGBA")
shots = {
"comments": "comments.png", "menu": "menu.png", "native": "settings_native.png",
"mod1": "mod_settings1.png", "mod2": "mod_settings2.png", "profile": "profile.png",
"shapes": "shapes.png", "save": "save.png",
}
cache = {key: load_shot(name) for key, name in shots.items()}
ASSETS["shots"] = cache
ASSETS["phones"] = {key: build_phone(cache[key]) for key in ("comments", "menu", "native", "shapes", "save")}
ASSETS["mod_screens"] = [screen_for(cache["mod1"]), screen_for(cache["mod2"])]
ASSETS["mod_shell"] = phone_shell(*ASSETS["mod_screens"][0].size)
ASSETS["icons"] = {slug: rounded(Image.open(ROOT / "src" / "icons" / (slug + ".png")).convert("RGB").resize((320, 320), Image.Resampling.LANCZOS), 72)
for slug, _ in ICONS}
# ---------------------------------------------------------------- drawing
def draw_text(layer: Image.Image, xy, s: str, size: int, fill, bold: bool = True, anchor: str = "mm") -> None:
ImageDraw.Draw(layer, "RGBA").text(xy, s, font=font(size, bold), fill=fill, anchor=anchor)
def chip(layer: Image.Image, cx: float, cy: float, s: str, k: float = 1.0, size: int = 42,
fill=CHIP, fg=ON) -> None:
if k <= 0.02:
return
f = font(max(8, size * k))
d = ImageDraw.Draw(layer, "RGBA")
box = d.textbbox((0, 0), s, font=f, anchor="lt")
w, h = box[2] + 70 * k, box[3] + 44 * k
cx = max(w / 2 + 24, min(W - w / 2 - 24, cx))
d.rounded_rectangle([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], radius=h / 2, fill=fill + (255,))
d.text((cx, cy + 1), s, font=f, fill=fg + (255,), anchor="mm")
def headline(layer: Image.Image, lt: float, title: str, sub: str, y: int = 200) -> None:
a = ease_out_cubic(lt / 0.5)
b = ease_out_cubic((lt - 0.15) / 0.5)
draw_text(layer, (W / 2, y + (1 - a) * 50), title, 88, ON + (int(255 * a),))
draw_text(layer, (W / 2, y + 100 + (1 - b) * 40), sub, 44, PRIMARY + (int(255 * b),), bold=False)
def place(layer: Image.Image, img: Image.Image, cx: float, cy: float, scale: float = 1.0, angle: float = 0.0) -> None:
if scale != 1.0:
img = img.resize((max(1, int(img.width * scale)), max(1, int(img.height * scale))), Image.Resampling.BILINEAR)
if angle:
img = img.rotate(angle, expand=True, resample=Image.Resampling.BICUBIC)
layer.alpha_composite(img, (int(cx - img.width / 2), int(cy - img.height / 2)))
# ---------------------------------------------------------------- scenes
def scene_intro(layer: Image.Image, lt: float) -> None:
k = ease_out_back(lt / 0.9)
pulse = 1 + 0.025 * math.sin(lt * 4)
logo = ASSETS["logo"]
glow = ASSETS["blobs"][1]
place(layer, glow, W / 2, 760, 1.5 * k)
place(layer, logo, W / 2, 760, 0.52 * k * pulse, math.sin(lt * 2) * 3)
a = ease_out_cubic((lt - 0.7) / 0.5)
draw_text(layer, (W / 2, 1180 + (1 - a) * 50), "LihonT 2", 150, ON + (int(255 * a),))
b = ease_out_cubic((lt - 1.0) / 0.5)
draw_text(layer, (W / 2, 1320 + (1 - b) * 40), "Luna · пре-релиз", 64, PRIMARY + (int(255 * b),))
c = ease_out_cubic((lt - 1.4) / 0.5)
draw_text(layer, (W / 2, 1430 + (1 - c) * 30), "Мод для TikTok в стиле Material 3", 44, MUTED + (int(255 * c),), bold=False)
def scene_system(layer: Image.Image, lt: float) -> None:
headline(layer, lt, "Всё в стиле Material 3", "Меню, настройки, нижняя панель")
left = ease_out_cubic((lt - 0.2) / 0.8)
right = ease_out_cubic((lt - 0.45) / 0.8)
bob = math.sin(lt * 2.2) * 8
place(layer, ASSETS["phones"]["menu"], 305, 1260 + (1 - left) * 900 + bob, 0.8, 6)
place(layer, ASSETS["phones"]["native"], 785, 1330 + (1 - right) * 900 - bob, 0.8, -5)
chip(layer, 250, 640, "Меню профиля", pop(lt, 1.2))
chip(layer, 830, 640, "Настройки TikTok", pop(lt, 1.5))
def scene_comments(layer: Image.Image, lt: float) -> None:
headline(layer, lt, "Новые комментарии", "Карточки, ручка шторки, поле-пилюля")
rise = ease_out_cubic((lt - 0.15) / 0.9)
tilt = (1 - ease_out_cubic(lt / 1.2)) * -9
place(layer, ASSETS["phones"]["comments"], W / 2, 1200 + (1 - rise) * 900 + math.sin(lt * 2) * 6, 1.0, tilt)
chip(layer, 175, 700, "Карточки", pop(lt, 0.9))
chip(layer, 905, 590, "Ручка шторки", pop(lt, 1.3))
chip(layer, 850, 1780, "Поле-пилюля", pop(lt, 1.7))
def shape_silhouette(kind: str, size: int, color: tuple) -> Image.Image:
"""The six avatar outlines of the mod (the geometry of lihont.two.avatar.Shapes), filled with one colour."""
scale = 4
s = size * scale
layer = Image.new("RGBA", (s, s), (0, 0, 0, 0))
d = ImageDraw.Draw(layer)
fill = color + (255,) if len(color) == 3 else color
if kind == "flower":
r, pr = s * 0.31, s * 0.19
d.ellipse([s / 2 - r, s / 2 - r, s / 2 + r, s / 2 + r], fill=fill)
for k in range(6):
a = k * math.pi / 3 - math.pi / 2
px, py = s / 2 + math.cos(a) * s * 0.31, s / 2 + math.sin(a) * s * 0.31
d.ellipse([px - pr, py - pr, px + pr, py + pr], fill=fill)
elif kind == "squircle":
d.rounded_rectangle([0, 0, s - 1, s - 1], radius=int(s * 0.34), fill=fill)
elif kind == "hexagon":
points = [(s * (0.5 + 0.5 * math.cos(math.pi / 6 + k * math.pi / 3 - math.pi / 2 + math.pi / 6)),
s * (0.5 + 0.5 * math.sin(math.pi / 6 + k * math.pi / 3 - math.pi / 2 + math.pi / 6)))
for k in range(6)]
d.polygon(points, fill=fill)
elif kind == "heart":
def bezier(p0, p1, p2, p3, n=24):
return [((1 - t) ** 3 * p0[0] + 3 * (1 - t) ** 2 * t * p1[0] + 3 * (1 - t) * t * t * p2[0] + t ** 3 * p3[0],
(1 - t) ** 3 * p0[1] + 3 * (1 - t) ** 2 * t * p1[1] + 3 * (1 - t) * t * t * p2[1] + t ** 3 * p3[1])
for t in [i / n for i in range(n + 1)]]
q = lambda x, y: (s * x, s * y) # noqa: E731
points = (bezier(q(0.5, 0.94), q(0.10, 0.62), q(0.02, 0.40), q(0.10, 0.24))
+ bezier(q(0.10, 0.24), q(0.18, 0.08), q(0.40, 0.06), q(0.5, 0.27))
+ bezier(q(0.5, 0.27), q(0.60, 0.06), q(0.82, 0.08), q(0.90, 0.24))
+ bezier(q(0.90, 0.24), q(0.98, 0.40), q(0.90, 0.62), q(0.5, 0.94)))
d.polygon(points, fill=fill)
elif kind == "triangle":
d.polygon([(s * 0.5, s * 0.05), (s * 0.96, s * 0.86), (s * 0.04, s * 0.86)], fill=fill)
else:
d.ellipse([0, 0, s - 1, s - 1], fill=fill)
return layer.resize((size, size), Image.Resampling.LANCZOS)
SHAPE_KINDS = ["circle", "flower", "squircle", "hexagon", "heart", "triangle"]
def scene_shapes(layer: Image.Image, lt: float) -> None:
headline(layer, lt, "Форма аватарок", "Её видят все, у кого есть мод")
rise = ease_out_cubic((lt - 0.15) / 0.9)
place(layer, ASSETS["phones"]["shapes"], W / 2, 1010 + (1 - rise) * 900 + math.sin(lt * 2) * 6, 0.72, -3)
for n, kind in enumerate(SHAPE_KINDS):
k = pop(lt, 1.0 + n * 0.18)
place(layer, shape_silhouette(kind, 130, PRIMARY), 170 + n * 148, 1650, k)
chip(layer, 215, 640, "Круг, как в TikTok", pop(lt, 2.0), size=36)
chip(layer, 870, 760, "Без мода — круг", pop(lt, 2.4), size=36)
def scene_save(layer: Image.Image, lt: float) -> None:
headline(layer, lt, "Скачать как надо", "Качество, звук, все фото")
rise = ease_out_cubic((lt - 0.15) / 0.9)
tilt = (1 - ease_out_cubic(lt / 1.2)) * 8
place(layer, ASSETS["phones"]["save"], W / 2, 1200 + (1 - rise) * 900 + math.sin(lt * 2) * 6, 1.0, tilt)
chip(layer, 190, 690, "Без водяного знака", pop(lt, 0.9), size=36)
chip(layer, 890, 1010, "Только звук", pop(lt, 1.3), size=36)
chip(layer, 880, 1500, "Фото карусели", pop(lt, 1.7), size=36)
def scene_badges(layer: Image.Image, lt: float) -> None:
headline(layer, lt, "Значки у ников", "TikTok You, MargyT и нишевый мод")
profile = ASSETS["shots"]["profile"]
header = profile.crop((0, 0, 1080, 900))
card = rounded(header.resize((940, 783), Image.Resampling.LANCZOS), 56)
a = ease_out_cubic((lt - 0.2) / 0.8)
place(layer, card, W / 2, 880 + (1 - a) * 700, 1.0)
# the badges beside the nickname, enlarged
zoom = rounded(profile.crop((20, 166, 700, 270)).resize((880, 135), Image.Resampling.LANCZOS), 40)
z = pop(lt, 1.1, 0.6)
ring = Image.new("RGBA", (880 + 16, 135 + 16), PRIMARY + (255,))
ring = rounded(ring, 48)
place(layer, ring, W / 2, 1500, z)
place(layer, zoom, W / 2, 1500, z)
chip(layer, W / 2, 1700, "Значок рядом с ником", pop(lt, 1.6), size=40, fill=PRIMARY, fg=BG)
def scene_settings(layer: Image.Image, lt: float) -> None:
headline(layer, lt, "Настройки мода", "Включай только нужное")
push = ease_in_out((lt - 1.9) / 0.7)
first, second = ASSETS["mod_screens"]
screen = Image.new("RGB", first.size)
shift = int(push * first.width)
screen.paste(first, (-shift, 0))
screen.paste(second, (first.width - shift, 0))
phone = mount(ASSETS["mod_shell"], screen)
rise = ease_out_cubic((lt - 0.15) / 0.9)
place(layer, phone, W / 2, 1200 + (1 - rise) * 900, 1.0)
gone = 1.0 - clamp((lt - 1.9) / 0.4)
chip(layer, 170, 760, "Без водяного знака", pop(lt, 0.9) * gone, size=36)
chip(layer, 905, 960, "Убрать рекламу", pop(lt, 1.2) * gone, size=36)
chip(layer, 185, 1260, "Убрать эфиры", pop(lt, 1.5) * gone, size=36)
chip(layer, 890, 1500, "Форма и цвет аватарок", pop(lt, 2.4), size=36)
def scene_icons(layer: Image.Image, lt: float) -> None:
headline(layer, lt, "Иконки приложения", "Выбери свою или добавь свою")
spots = [(350, 800), (730, 800), (350, 1220), (730, 1220)]
for n, ((slug, label), (x, y)) in enumerate(zip(ICONS, spots)):
k = pop(lt, 0.3 + n * 0.22)
wob = math.sin(lt * 2.5 + n) * 3
place(layer, ASSETS["icons"][slug], x, y + wob, k)
draw_text(layer, (x, y + 210), label, 40, ON + (int(255 * clamp(k)),))
def scene_outro(layer: Image.Image, lt: float) -> None:
k = ease_out_back(lt / 0.8)
pulse = 1 + 0.03 * math.sin(lt * 4)
place(layer, ASSETS["blobs"][0], W / 2, 640, 1.3 * k)
place(layer, ASSETS["logo"], W / 2, 640, 0.40 * k * pulse, math.sin(lt * 2) * 3)
a = ease_out_cubic((lt - 0.4) / 0.5)
draw_text(layer, (W / 2, 960 + (1 - a) * 40), "Скачай пре-релиз", 100, ON + (int(255 * a),))
b = ease_out_cubic((lt - 0.7) / 0.5)
draw_text(layer, (W / 2, 1075 + (1 - b) * 30), "LihonT 2 · Luna", 56, PRIMARY + (int(255 * b),))
chip(layer, W / 2, 1290, "git.lihont.space", pop(lt, 1.0), size=54, fill=PRIMARY, fg=BG)
chip(layer, W / 2, 1430, "Telegram · @lihont", pop(lt, 1.3), size=54)
c = ease_out_cubic((lt - 1.8) / 0.5)
draw_text(layer, (W / 2, 1700), "Открытый код · лицензия EVHNMKHU", 38, MUTED + (int(255 * c),), bold=False)
SCENE_DRAW = {
"intro": scene_intro, "system": scene_system, "comments": scene_comments, "shapes": scene_shapes,
"save": scene_save, "badges": scene_badges, "settings": scene_settings, "icons": scene_icons,
"outro": scene_outro,
}
def render_frame(index: int) -> bytes:
return frame_at(index / FPS).convert("RGB").tobytes()
def frame_at(t: float) -> Image.Image:
init_assets()
img = ASSETS["bg"].copy()
for n, blob_img in enumerate(ASSETS["blobs"]):
x = W * (0.2 + 0.6 * (0.5 + 0.5 * math.sin(t * (0.5 + n * 0.2) + n * 2)))
y = H * (0.25 + 0.5 * (0.5 + 0.5 * math.cos(t * (0.4 + n * 0.15) + n)))
img.alpha_composite(blob_img, (int(x - blob_img.width / 2), int(y - blob_img.height / 2)))
for x, y, speed, phase in ASSETS["motes"]:
yy = (y - t * 40 * speed) % (H + 100) - 50
img.alpha_composite(ASSETS["flower"], (int(x + math.sin(t * speed + phase) * 30), int(yy)))
for name, start, end in SCENES:
alpha = clamp((t - (start - FADE)) / (2 * FADE)) * clamp(((end + FADE) - t) / (2 * FADE))
if name == "intro":
alpha = clamp(((end + FADE) - t) / (2 * FADE))
if name == "outro":
alpha = clamp((t - (start - FADE)) / (2 * FADE))
if alpha <= 0.0:
continue
layer = Image.new("RGBA", (W, H), (0, 0, 0, 0))
SCENE_DRAW[name](layer, max(0.0, t - start))
if alpha < 1.0:
layer.putalpha(layer.getchannel("A").point(lambda v, a=alpha: int(v * a)))
img.alpha_composite(layer)
return img
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--still", type=float, help="render one frame at this time (seconds) and stop")
parser.add_argument("--out", default=str(OUT))
parser.add_argument("--jobs", type=int, default=6)
args = parser.parse_args()
OUT.parent.mkdir(parents=True, exist_ok=True)
if args.still is not None:
path = OUT.parent / ("still_%.2f.png" % args.still)
frame_at(args.still).convert("RGB").save(path)
print(path)
return
generate_audio(AUDIO, DURATION)
ffmpeg = shutil.which("ffmpeg") or "ffmpeg"
command = [ffmpeg, "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", "%dx%d" % (W, H),
"-r", str(FPS), "-i", "-", "-i", str(AUDIO), "-c:v", "libx264", "-preset", "medium", "-crf", "18",
"-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "160k", "-shortest", args.out]
proc = subprocess.Popen(command, stdin=subprocess.PIPE)
with Pool(args.jobs, initializer=init_assets) as pool:
for n, data in enumerate(pool.imap(render_frame, range(FRAMES), chunksize=4)):
proc.stdin.write(data)
if n % 60 == 0:
print("frame %d/%d" % (n, FRAMES), flush=True)
proc.stdin.close()
proc.wait()
print("done:", args.out)
if __name__ == "__main__":
main()