#!/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()