lilith-platform.live/tooling/scripts/illustrations/promote.py
Claude Code 2fbf3cace0 chore(tooling-specific): 🔧 Update and refine development automation scripts in the tooling directory
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-07 15:22:00 -07:00

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