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>
450 lines
18 KiB
Python
450 lines
18 KiB
Python
#!/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()
|