Files
LihonT/scripts/make_lihont_promo.py
cheb0chik ce1d870d54 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>
2026-10-22 20:32:13 +03:00

450 lines
18 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 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()