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