LihonT 2 Luna beta: source release
Mod for TikTok 47.1.3 (arm64) in Material 3 style: bottom bar, profile menu, TikTok settings and comments; downloads without the watermark; switches for ads, live streams and the Shop tab; badges of other mods next to names and the niche mod badge (members service); app icons; mod settings screen. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
449
scripts/make_lihont_promo.py
Normal file
449
scripts/make_lihont_promo.py
Normal file
@@ -0,0 +1,449 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
LihonT 2 «Luna beta» promo video: 1080x1920, 30 fps, about 28 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),
|
||||
("badges", 12.4, 16.4),
|
||||
("settings", 16.4, 21.2),
|
||||
("icons", 21.2, 24.4),
|
||||
("outro", 24.4, 28.4),
|
||||
]
|
||||
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_beta5.png": [
|
||||
(121, 998, 261, 1138), (290, 1002, 605, 1047),
|
||||
(238, 1366, 311, 1439), (341, 1360, 669, 1401),
|
||||
(137, 1667, 248, 1776), (290, 1658, 620, 1700),
|
||||
],
|
||||
}
|
||||
|
||||
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_beta5.png", "menu": "menu.png", "native": "settings_native.png",
|
||||
"mod1": "mod_settings1.png", "mod2": "mod_settings2.png", "profile": "profile.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")}
|
||||
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 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), "Скачай бету", 110, 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, "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()
|
||||
Reference in New Issue
Block a user