- FastAPI backend: admin bootstrap with forced credential change, admin-created users with one-time generated passwords, roles as permission sets - Games and versions with content-addressed blob storage; forks copy only the manifest, so mods share assets with the original - Worker: archive/RPA extraction, unrpyc for .rpyc, own .rpy parser, Python subset translated via ast, media converted to WebP/Opus/WebM - React panel (library search, game page, upload, users, roles) and a framework-free web player with saves, history, skip/auto, transitions - docker compose: postgres, backend, worker, nginx on a single HTTP port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
87 lines
3.2 KiB
Python
87 lines
3.2 KiB
Python
"""Media conversion into web-friendly formats."""
|
|
|
|
import shutil
|
|
import subprocess
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageSequence
|
|
|
|
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tga", ".avif"}
|
|
AUDIO_EXTS = {".ogg", ".mp3", ".wav", ".opus", ".flac", ".m4a", ".oga"}
|
|
VIDEO_EXTS = {".webm", ".ogv", ".mp4", ".mkv", ".avi", ".mpg", ".mpeg", ".m4v"}
|
|
FONT_EXTS = {".ttf", ".otf", ".woff", ".woff2", ".ttc"}
|
|
|
|
WEBP_MAX = 16383
|
|
|
|
|
|
def kind_of(path: Path) -> str | None:
|
|
ext = path.suffix.lower()
|
|
if ext in IMAGE_EXTS:
|
|
return "image"
|
|
if ext in AUDIO_EXTS:
|
|
return "audio"
|
|
if ext in VIDEO_EXTS:
|
|
return "video"
|
|
if ext in FONT_EXTS:
|
|
return "font"
|
|
return None
|
|
|
|
|
|
def target_ext(path: Path) -> str:
|
|
kind = kind_of(path)
|
|
return {"image": ".webp", "audio": ".ogg", "video": ".webm"}.get(kind, path.suffix.lower())
|
|
|
|
|
|
def convert(src: Path, out_dir: Path) -> tuple[Path, str]:
|
|
"""Convert one file into out_dir. Returns (output file, extension used for the blob)."""
|
|
kind = kind_of(src)
|
|
tmp = str(out_dir / uuid.uuid4().hex)
|
|
if kind == "image":
|
|
return _image(src, Path(tmp))
|
|
if kind == "audio":
|
|
return _ffmpeg(src, Path(tmp + ".ogg"),
|
|
["-vn", "-c:a", "libopus", "-b:a", "112k", "-vbr", "on"]), ".ogg"
|
|
if kind == "video":
|
|
if src.suffix.lower() == ".webm":
|
|
out = Path(tmp + ".webm")
|
|
shutil.copyfile(src, out)
|
|
return out, ".webm"
|
|
return _ffmpeg(src, Path(tmp + ".webm"),
|
|
["-c:v", "libvpx-vp9", "-crf", "34", "-b:v", "0", "-deadline", "realtime",
|
|
"-cpu-used", "8", "-row-mt", "1", "-c:a", "libopus", "-b:a", "112k"]
|
|
), ".webm"
|
|
out = Path(tmp + src.suffix.lower())
|
|
shutil.copyfile(src, out)
|
|
return out, src.suffix.lower()
|
|
|
|
|
|
def _image(src: Path, tmp: Path) -> tuple[Path, str]:
|
|
with Image.open(src) as img:
|
|
animated = getattr(img, "is_animated", False)
|
|
if max(img.size) > WEBP_MAX:
|
|
out = tmp.with_suffix(".png")
|
|
img.save(out, "PNG", optimize=False)
|
|
return out, ".png"
|
|
out = tmp.with_suffix(".webp")
|
|
if animated:
|
|
frames = [f.convert("RGBA") for f in ImageSequence.Iterator(img)]
|
|
frames[0].save(out, "WEBP", save_all=True, append_images=frames[1:], quality=85,
|
|
duration=img.info.get("duration", 100), loop=0)
|
|
return out, ".webp"
|
|
has_alpha = img.mode in ("RGBA", "LA", "PA") or (img.mode == "P" and "transparency" in img.info)
|
|
img = img.convert("RGBA" if has_alpha else "RGB")
|
|
img.save(out, "WEBP", quality=88, method=4, alpha_quality=95)
|
|
return out, ".webp"
|
|
|
|
|
|
def _ffmpeg(src: Path, out: Path, args: list[str]) -> Path:
|
|
proc = subprocess.run(
|
|
["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", str(src),
|
|
*args, str(out)],
|
|
capture_output=True, text=True, timeout=3600,
|
|
)
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"ffmpeg: {proc.stderr.strip()[-300:]}")
|
|
return out
|