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:
cheb0chik
2026-10-22 20:32:13 +03:00
commit ce1d870d54
121 changed files with 21570 additions and 0 deletions

193
scripts/deploy_site.py Normal file
View File

@@ -0,0 +1,193 @@
"""Deploy the redesigned site to the Helsinki server (ssh host "hels") and roll it back.
py scripts/deploy_site.py --dry-run show the plan, change nothing
py scripts/deploy_site.py back up the live files, upload, verify
py scripts/deploy_site.py --rollback restore the newest backup (or --stamp <timestamp> for another)
py scripts/deploy_site.py --portal swap the /lihont2 portal's server.py, restart it, roll back on failure
Only the files listed in stage_site.PAYLOAD are touched. Before overwriting, each live file is
copied to /var/backups/lihont-site/<timestamp>/ (outside the web root, so it is never served).
Files are uploaded under a temporary name and renamed into place, so a visitor never sees a
half-written page. Downloads, logos and the old md3-theme.css are left alone.
"""
from __future__ import annotations
import argparse
import hashlib
import shlex
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import stage_site # noqa: E402
HOST = "hels"
WEB_ROOT = "/var/www/lihont"
BACKUPS = "/var/backups/lihont-site"
SSH = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", HOST]
def remote(command: str, data: bytes | None = None) -> subprocess.CompletedProcess:
return subprocess.run(SSH + [command], input=data, capture_output=True, timeout=180)
def text(result: subprocess.CompletedProcess) -> str:
# ssh prints a post-quantum key exchange warning on stderr; it is not an error.
lines = [line for line in result.stderr.decode("utf-8", "replace").splitlines()
if line and not line.startswith("**")]
return "\n".join(lines)
def deploy(dry_run: bool) -> int:
stage_site.main()
files = [(stage_site.STAGE / target, target) for _source, target in stage_site.PAYLOAD]
stamp = time.strftime("%Y%m%d-%H%M%S")
backup = "%s/%s" % (BACKUPS, stamp)
print("Backup folder on the server: %s" % backup)
for local, target in files:
print(" %-26s %7d bytes" % (target, local.stat().st_size))
if dry_run:
print("Dry run: nothing was changed.")
return 0
# 1. Back up every live file that exists.
script = "set -e; mkdir -p %s; " % shlex.quote(backup)
for _local, target in files:
live = "%s/%s" % (WEB_ROOT, target)
script += "if [ -f %s ]; then mkdir -p %s; cp -p %s %s; fi; " % (
shlex.quote(live), shlex.quote("%s/%s" % (backup, Path(target).parent.as_posix())),
shlex.quote(live), shlex.quote("%s/%s" % (backup, target)))
result = remote(script)
if result.returncode:
print("Backup failed, nothing was uploaded:\n" + text(result))
return 1
# 2. Upload under a temporary name, then rename into place.
for local, target in files:
live = "%s/%s" % (WEB_ROOT, target)
command = ("set -e; mkdir -p %s; cat > %s.new && chown root:root %s.new && chmod 644 %s.new "
"&& mv -f %s.new %s") % tuple(
[shlex.quote(str(Path(live).parent.as_posix()))] + [shlex.quote(live)] * 5)
result = remote(command, local.read_bytes())
if result.returncode:
print("Upload of %s failed:\n%s\nRoll back with: py scripts/deploy_site.py --rollback" % (target, text(result)))
return 1
print("uploaded %s" % target)
# 3. Verify that the server holds exactly what was staged.
failed = 0
for local, target in files:
digest = hashlib.sha256(local.read_bytes()).hexdigest()
result = remote("sha256sum %s" % shlex.quote("%s/%s" % (WEB_ROOT, target)))
live = result.stdout.decode().split()[0] if result.returncode == 0 and result.stdout else "?"
status = "ok" if live == digest else "MISMATCH"
failed += status != "ok"
print(" %-26s %s" % (target, status))
if failed:
print("Some files differ. Roll back with: py scripts/deploy_site.py --rollback")
return 1
print("Deployed. Backup: %s" % backup)
return 0
PORTAL_SOURCE = Path(__file__).resolve().parents[1] / "src" / "web" / "server.py"
PORTAL_PATH = "/opt/lihont2/server.py"
PORTAL_BACKUPS = "/var/backups/lihont-portal"
PORTAL_SERVICE = "lihont2"
PORTAL_URL = "http://127.0.0.1:18750/lihont2/"
def portal_healthy() -> bool:
check = "sleep 2; systemctl is-active %s && curl -s -o /dev/null -w '%%{http_code}' %s" % (PORTAL_SERVICE, PORTAL_URL)
result = remote(check)
return result.returncode == 0 and result.stdout.decode().split()[-1:] == ["200"]
def restore_portal(stamp: str) -> int:
backup = "%s/%s/server.py" % (PORTAL_BACKUPS, stamp)
command = "set -e; cp -p %s %s.new && mv -f %s.new %s && systemctl restart %s" % (
shlex.quote(backup), PORTAL_PATH, PORTAL_PATH, PORTAL_PATH, PORTAL_SERVICE)
result = remote(command)
print(text(result) or "restored %s" % backup)
return result.returncode or (0 if portal_healthy() else 1)
def deploy_portal(dry_run: bool) -> int:
"""Swap /opt/lihont2/server.py and restart the portal; sessions live in sqlite, so logins survive."""
data = PORTAL_SOURCE.read_bytes()
stamp = time.strftime("%Y%m%d-%H%M%S")
print("server.py: %d bytes, backup %s/%s (root only)" % (len(data), PORTAL_BACKUPS, stamp))
if dry_run:
print("Dry run: nothing was changed.")
return 0
# Upload next to the target and parse it with the server's own Python before anything is replaced.
result = remote("cat > %s.new && python3 -c \"import ast; ast.parse(open('%s.new').read())\"" % (PORTAL_PATH, PORTAL_PATH), data)
if result.returncode:
print("The new server.py does not parse on the server; nothing was replaced:\n" + text(result))
remote("rm -f %s.new" % PORTAL_PATH)
return 1
backup = "%s/%s" % (PORTAL_BACKUPS, stamp)
command = ("set -e; umask 077; mkdir -p %s; cp -p %s %s/server.py; chown root:root %s.new; chmod 644 %s.new; "
"mv -f %s.new %s; systemctl restart %s") % (
shlex.quote(backup), PORTAL_PATH, shlex.quote(backup), PORTAL_PATH, PORTAL_PATH, PORTAL_PATH, PORTAL_PATH, PORTAL_SERVICE)
result = remote(command)
if result.returncode:
print("Swap failed:\n" + text(result))
return 1
if not portal_healthy():
print("The portal is not answering with the new server.py; restoring the old one.")
return restore_portal(stamp) or 1
print("Portal restarted and answering. Backup: %s" % backup)
return 0
def rollback_portal(stamp: str | None) -> int:
result = remote("ls -1 %s | sort" % shlex.quote(PORTAL_BACKUPS))
stamps = result.stdout.decode().split()
if result.returncode or not stamps:
print("No portal backup found on the server.")
return 1
print("Portal backups: %s" % ", ".join(stamps))
return restore_portal(stamp or stamps[-1])
def rollback(stamp: str | None) -> int:
result = remote("ls -1 %s | sort" % shlex.quote(BACKUPS))
stamps = result.stdout.decode().split()
if result.returncode or not stamps:
print("No backup found on the server.")
return 1
print("Backups: %s" % ", ".join(stamps))
stamp = stamp or stamps[-1]
if stamp not in stamps:
print("No such backup: %s" % stamp)
return 1
backup = "%s/%s" % (BACKUPS, stamp)
print("Restoring %s" % backup)
command = ("set -e; cd %s; find . -type f | while read f; do "
"mkdir -p \"$(dirname %s/$f)\"; cp -p \"$f\" \"%s/$f.new\" && mv -f \"%s/$f.new\" \"%s/$f\"; done; echo restored")
command = command % (shlex.quote(backup), WEB_ROOT, WEB_ROOT, WEB_ROOT, WEB_ROOT)
result = remote(command)
print(result.stdout.decode().strip() or text(result))
return result.returncode
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--rollback", action="store_true")
parser.add_argument("--stamp", help="backup to restore with --rollback (default: the newest)")
parser.add_argument("--portal", action="store_true", help="act on the /lihont2 portal (server.py) instead of the static site")
args = parser.parse_args()
if args.portal:
return rollback_portal(args.stamp) if args.rollback else deploy_portal(args.dry_run)
return rollback(args.stamp) if args.rollback else deploy(args.dry_run)
if __name__ == "__main__":
sys.exit(main())

View 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()

80
scripts/quick_runtime.py Normal file
View File

@@ -0,0 +1,80 @@
"""Quick rebuild after a change in the runtime Java only (src/lihont/two).
Takes an APK that py -m lihont.upgrade built earlier, swaps the mod's runtime classes inside its
classes35.dex for a fresh compile of src/lihont/two, and signs the result. The smali patches of TikTok's
own classes are not touched, so this is only right while lihont/*_patch.py and lihont/upgrade.py are
unchanged since that APK was built; after any such change run the full build.
usage (from the repository root):
py -B scripts/quick_runtime.py ComplectAPK/lihont-2.0-luna-beta5.apk -o ComplectAPK/lihont-2.0-luna-beta6.apk
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.chdir(ROOT)
from lihont import dexpatch, manifest as manifest_module # noqa: E402
from lihont.apkzip import Apk # noqa: E402
from lihont.axml import Axml # noqa: E402
from lihont.toolchain import Toolchain # noqa: E402
RUNTIME = "Llihont/two/"
DEX = "classes35.dex"
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("built", help="an APK built by lihont.upgrade")
parser.add_argument("-o", "--out", required=True)
parser.add_argument("--work", default="work/quick")
args = parser.parse_args()
source, output, room = Path(args.built).resolve(), Path(args.out).resolve(), Path(args.work).resolve()
if source == output:
raise SystemExit("Write to a new file: the built APK stays as it is")
room.mkdir(parents=True, exist_ok=True)
tools = Toolchain("tools")
apk = Apk(str(source))
try:
api = manifest_module.min_sdk(Axml.parse(apk.read("AndroidManifest.xml")))
runtime = Path(tools.compile_dex("src/lihont/two", str(room / "runtime"), api)).read_bytes()
compiler = room / "compiler"
compiler.mkdir(parents=True, exist_ok=True)
subprocess.run(["javac", "--release", "17", "-cp", tools.smali, "-d", str(compiler),
"src/RefreshClasses.java"], check=True)
host_dex = apk.read(DEX)
host, fresh, merged = room / "host.dex", room / "fresh.dex", room / "merged.dex"
host.write_bytes(host_dex)
fresh.write_bytes(runtime)
subprocess.run(["java", "-Xmx1g", "-cp", os.pathsep.join([str(compiler), tools.smali]),
"RefreshClasses", str(host), str(fresh), str(merged), str(api)], check=True)
result = merged.read_bytes()
if dexpatch.dex_format(host_dex) != dexpatch.dex_format(result):
raise SystemExit("The refresh changed the DEX format")
before = {m for m in dexpatch.defined_methods(host_dex) if not m.startswith(RUNTIME)}
after = dexpatch.defined_methods(result)
if not before.issubset(after):
raise SystemExit("The refresh lost classes that are not part of the runtime")
if not any(m.startswith("Llihont/two/LihonTRegion;->") for m in after):
raise SystemExit("LihonTRegion is missing after the refresh")
apk.replace(DEX, result)
apk.drop_signature()
output.parent.mkdir(parents=True, exist_ok=True)
apk.write(str(output))
finally:
apk.close()
tools.sign(str(output))
print("%s %d bytes" % (output.name, output.stat().st_size))
if __name__ == "__main__":
main()

47
scripts/stage_site.py Normal file
View File

@@ -0,0 +1,47 @@
"""Assemble the website payload in work/site-stage, laid out as on the server.
py scripts/stage_site.py
The server serves /var/www/lihont. Pages that live in folders (/lihonb, /members, /pay) are
index.html files there. Only files that belong to the site redesign are staged: the pages and
src/web/assets/site/{md3.css,md3.js,icons.svg}. Existing downloads, logos and the old
md3-theme.css (used by the member portal) are not touched.
"""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
STAGE = ROOT / "work" / "site-stage"
# (source in the repository, path on the server relative to the web root)
PAYLOAD = [
("src/web/index.html", "index.html"),
("src/web/lihonb.html", "lihonb.html"),
("src/web/lihonb.html", "lihonb/index.html"),
("src/web/members_index.html", "members/index.html"),
("src/web/pay_index.html", "pay/index.html"),
("src/web/assets/site/md3.css", "assets/site/md3.css"),
("src/web/assets/site/md3.js", "assets/site/md3.js"),
("src/web/assets/site/icons.svg", "assets/site/icons.svg"),
]
def main() -> int:
missing = [source for source, _ in PAYLOAD if not (ROOT / source).is_file()]
if missing:
print("Missing sources:\n " + "\n ".join(missing))
return 1
for source, target in PAYLOAD:
destination = STAGE / target
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(ROOT / source, destination)
print("Staged %d files in %s" % (len(PAYLOAD), STAGE))
return 0
if __name__ == "__main__":
sys.exit(main())