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:
193
scripts/deploy_site.py
Normal file
193
scripts/deploy_site.py
Normal 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())
|
||||
449
scripts/make_lihont_promo.py
Normal file
449
scripts/make_lihont_promo.py
Normal 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
80
scripts/quick_runtime.py
Normal 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
47
scripts/stage_site.py
Normal 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())
|
||||
Reference in New Issue
Block a user