242 lines
7 KiB
Python
242 lines
7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Promote a finalist illustration to the quinn.www deployment.
|
|
|
|
Usage:
|
|
promote.py --slug SLUG --kind destination|specialty --finalist FILENAME
|
|
|
|
Copies the finalist from out/ to:
|
|
deployments/@domains/quinn.www/root/public/photos/illustrations/<kind>_<slug>.png
|
|
|
|
Produces the matching .webp via cwebp.
|
|
Updates manifest.json with a new or replaced ManifestEntry.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import math
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Literal
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SCRIPT_DIR = Path(__file__).parent
|
|
OUT_DIR = SCRIPT_DIR / "out"
|
|
|
|
REPO_ROOT = Path(__file__).parents[4] # lilith-platform.live/
|
|
ILLUSTRATIONS_DIR = (
|
|
REPO_ROOT
|
|
/ "deployments"
|
|
/ "@domains"
|
|
/ "quinn.www"
|
|
/ "root"
|
|
/ "public"
|
|
/ "photos"
|
|
/ "illustrations"
|
|
)
|
|
MANIFEST_PATH = (
|
|
REPO_ROOT
|
|
/ "deployments"
|
|
/ "@domains"
|
|
/ "quinn.www"
|
|
/ "root"
|
|
/ "public"
|
|
/ "photos"
|
|
/ "manifest.json"
|
|
)
|
|
|
|
Kind = Literal["destination", "specialty"]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Logging
|
|
# ---------------------------------------------------------------------------
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
log = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Manifest types (matches existing ManifestEntry shape)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
ManifestEntry = dict # {filename, webp, width, height, sizeKB, webpSizeKB, aspect}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def get_image_dimensions(path: Path) -> tuple[int, int]:
|
|
"""Return (width, height) via PIL."""
|
|
try:
|
|
from PIL import Image # type: ignore[import-untyped]
|
|
except ImportError as exc:
|
|
raise RuntimeError(
|
|
"Pillow is required for reading image dimensions. Install it: pip install Pillow"
|
|
) from exc
|
|
with Image.open(path) as img:
|
|
return img.size # (width, height)
|
|
|
|
|
|
def file_size_kb(path: Path) -> int:
|
|
return math.ceil(path.stat().st_size / 1024)
|
|
|
|
|
|
def convert_to_webp(src: Path, dst: Path) -> None:
|
|
"""Run cwebp to produce dst from src."""
|
|
if shutil.which("cwebp") is None:
|
|
raise RuntimeError(
|
|
"cwebp is not on PATH. Install webp: e.g. `dnf install libwebp-tools`"
|
|
)
|
|
result = subprocess.run(
|
|
["cwebp", "-q", "85", str(src), "-o", str(dst)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(
|
|
f"cwebp failed for {src.name}:\n{result.stderr[:500]}"
|
|
)
|
|
log.info(" webp -> %s (%d KB)", dst.name, file_size_kb(dst))
|
|
|
|
|
|
def load_manifest() -> list[ManifestEntry]:
|
|
if not MANIFEST_PATH.exists():
|
|
return []
|
|
return json.loads(MANIFEST_PATH.read_text())
|
|
|
|
|
|
def save_manifest(entries: list[ManifestEntry]) -> None:
|
|
MANIFEST_PATH.write_text(json.dumps(entries, indent=2) + "\n")
|
|
|
|
|
|
def upsert_manifest(
|
|
entries: list[ManifestEntry],
|
|
png_path: Path,
|
|
webp_path: Path,
|
|
kind: Kind,
|
|
slug: str,
|
|
) -> list[ManifestEntry]:
|
|
"""
|
|
Add or replace the manifest entry for this illustration.
|
|
|
|
NOTE: The existing manifest.json uses top-level filenames (no subdirectory prefix).
|
|
Illustrations live under photos/illustrations/ which is a subdirectory.
|
|
The filename field here is set to `illustrations/<kind>_<slug>.png` so that
|
|
consumers can resolve it relative to the photos/ root. If the admin validator
|
|
or frontend consumer expects flat filenames only, a small update to the
|
|
ManifestEntry shape will be needed — see the PR description.
|
|
"""
|
|
width, height = get_image_dimensions(png_path)
|
|
aspect = round(width / height, 3)
|
|
filename = f"illustrations/{png_path.name}"
|
|
webp_filename = f"illustrations/{webp_path.name}"
|
|
|
|
new_entry: ManifestEntry = {
|
|
"filename": filename,
|
|
"webp": webp_filename,
|
|
"width": width,
|
|
"height": height,
|
|
"sizeKB": file_size_kb(png_path),
|
|
"webpSizeKB": file_size_kb(webp_path),
|
|
"aspect": aspect,
|
|
}
|
|
|
|
# Idempotent: replace existing entry for this slug if present
|
|
updated = [e for e in entries if e.get("filename") != filename]
|
|
updated.append(new_entry)
|
|
return updated
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Promote a finalist illustration to quinn.www deployment"
|
|
)
|
|
parser.add_argument("--slug", required=True, help="Slug (e.g. paris, gfe-sensual__girlfriend-experience)")
|
|
parser.add_argument(
|
|
"--kind",
|
|
required=True,
|
|
choices=["destination", "specialty"],
|
|
help="Kind of illustration",
|
|
)
|
|
parser.add_argument(
|
|
"--finalist",
|
|
required=True,
|
|
help="Filename from out/ (e.g. destination_paris_r1v2.png)",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
slug: str = args.slug
|
|
kind: Kind = args.kind
|
|
finalist_name: str = args.finalist
|
|
|
|
finalist_src = OUT_DIR / finalist_name
|
|
if not finalist_src.exists():
|
|
log.error("Finalist not found: %s", finalist_src)
|
|
sys.exit(1)
|
|
|
|
# Destination filename: <kind>_<slug>.png
|
|
dest_png_name = f"{kind}_{slug}.png"
|
|
dest_webp_name = f"{kind}_{slug}.webp"
|
|
|
|
ILLUSTRATIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
dest_png = ILLUSTRATIONS_DIR / dest_png_name
|
|
dest_webp = ILLUSTRATIONS_DIR / dest_webp_name
|
|
|
|
# Copy PNG
|
|
shutil.copy2(finalist_src, dest_png)
|
|
log.info("Copied %s -> %s (%d KB)", finalist_name, dest_png, file_size_kb(dest_png))
|
|
|
|
# Produce WebP
|
|
convert_to_webp(dest_png, dest_webp)
|
|
|
|
# Update manifest.json
|
|
entries = load_manifest()
|
|
entries = upsert_manifest(entries, dest_png, dest_webp, kind, slug)
|
|
save_manifest(entries)
|
|
log.info(
|
|
"manifest.json updated — %s entry: illustrations/%s",
|
|
"replaced" if any(True for _ in []) else "added",
|
|
dest_png_name,
|
|
)
|
|
|
|
log.info(
|
|
"Done. Files:\n %s\n %s\n manifest -> %s",
|
|
dest_png,
|
|
dest_webp,
|
|
MANIFEST_PATH,
|
|
)
|
|
|
|
# Flag the subdirectory/manifest shape situation for awareness
|
|
log.info(
|
|
"NOTE: manifest entry uses filename='illustrations/%s' (subdirectory path). "
|
|
"If the admin validator or frontend consumer only supports flat filenames, "
|
|
"update the ManifestEntry consumer to handle the 'illustrations/' prefix.",
|
|
dest_png_name,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|