Add issue No. 02 "Obsession" + reusable image pipeline

Content
- 100 new plates under content/posts/02/ — one recurring red-haired model
  across 10 editorial registers (Haute Couture, Film Noir, Boudoir,
  Avant-Garde, Minimalism, Baroque, Street Style, Surrealism, Monochrome,
  Nocturne), generated with flux-1.1-pro, face-swapped to a consistent
  identity (FaceFusion), and upscaled (Upscayl + Remacri, 3328x4992).
- content/issues/02/_index.md; issue 01 -> status archived; hugo.toml params
  (issueIds newest-first, issueNumber/Name/Season/Blurb).

Structure
- Plate bundles grouped by issue: content/posts/<issue>/<slug>/, with
  build.render:never stubs so /posts/<issue>/ isn't emitted. nginx 301s the
  legacy flat /posts/<slug>/ URLs to /posts/01/<slug>/. The /posts/ archive
  uses .RegularPagesRecursive.

Image pipeline (scripts/, data/ gitignored)
- generate-images.py  — Replicate flux-1.1-pro, 960x1440, idempotent.
- faceswap-images.py  — FaceFusion batch-run, one consistent face.
- upscale-images.py   — Upscayl + remacri-4x, long edge clamped to 4992.
- build-issue.py      — prompts JSON -> content/posts/<issue>/*/index.md.

Build
- CSS now compiled by Hugo's css.TailwindCSS (partials/css.html +
  templates.Defer); dropped the standalone Tailwind CLI step, concurrently,
  and the gitignored static/css/main.css. Requires Hugo >= 0.161; Dockerfile
  collapsed to a single hugomods/hugo:debian-node build stage.
  pnpm-workspace.yaml: preferSymlinkedExecutables + allowBuilds.

Front end
- Pagination 8 per page.
- Lightbox: brand mark and category link out; robust SPA back/forward
  (fetch before startViewTransition, single-flight guard, swallow the
  transition abort rejection, popstate re-renders / reopens the viewer).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZPmxGywFnAhmYJB1eh9fm
This commit is contained in:
2026-08-29 21:11:24 +02:00
co-authored by Claude Sonnet 5
parent 1480f73a8f
commit 2dc7a0063f
432 changed files with 3195 additions and 830 deletions
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""
Assemble a Roux issue's Hugo page bundles from a prompts JSON file.
python3 scripts/build-issue.py --issue 02 --prompts data/prompts/issue-02.json
Reads an array of objects with keys:
slug, title, description, category, tags[], prompt, featured (optional bool)
Writes content/posts/<issue>/<slug>/index.md for each, with plate numbers assigned
by stable sort on slug (matches scripts/import-posts.py). Frontmatter shape mirrors
the existing issue-01 bundles. `featured` comes from the JSON, so re-runs never lose
the homepage flags (unlike issue 01's hand-added ones).
Images are expected at content/posts/<issue>/<slug>/<slug>.png (written there by
scripts/generate-images.py). Pass --images DIR to copy them in from a staging dir
that holds either <slug>.png or <slug>/<slug>.png.
"""
import argparse
import json
import shutil
from pathlib import Path
SITE = Path(__file__).resolve().parent.parent
def norm_desc(desc: str) -> str:
desc = desc.strip()
if desc:
desc = desc[0].upper() + desc[1:]
if not desc.endswith((".", "!", "?")):
desc += "."
return desc.replace('"', '\\"')
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--issue", required=True)
ap.add_argument("--prompts", required=True, type=Path)
ap.add_argument("--images", type=Path, help="staging dir to copy <slug>.png from")
args = ap.parse_args()
content = SITE / "content" / "posts" / args.issue
content.mkdir(parents=True, exist_ok=True)
entries = json.loads(args.prompts.read_text(encoding="utf-8"))
entries.sort(key=lambda e: e["slug"])
missing_img = []
for idx, entry in enumerate(entries, start=1):
slug = entry["slug"]
plate = f"{idx:03d}"
title = entry["title"].strip().replace('"', '\\"')
desc = norm_desc(entry["description"])
cat = (entry.get("category") or "Uncategorised").strip()
tags = [t.strip() for t in entry.get("tags", []) if t.strip()]
bundle = content / slug
bundle.mkdir(exist_ok=True)
img_name = f"{slug}.png"
dst_img = bundle / img_name
if args.images and not dst_img.exists():
for cand in (args.images / img_name, args.images / slug / img_name):
if cand.is_file():
shutil.copy2(cand, dst_img)
break
if not dst_img.exists():
missing_img.append(slug)
cats_yaml = f' - "{cat}"'
tags_yaml = "\n".join(f' - "{t}"' for t in tags) if tags else " []"
featured_yaml = "\nfeatured: true" if entry.get("featured") else ""
md = f"""---
title: "{title}"
description: "{desc}"
plate: "{plate}"
slug: "{slug}"
issues:
- "{args.issue}"
image: "{img_name}"
weight: {idx}{featured_yaml}
categories:
{cats_yaml}
tags:
{tags_yaml}
---
"""
(bundle / "index.md").write_text(md, encoding="utf-8")
print(f"Wrote {len(entries)} bundles to {content}")
if missing_img:
print(f"WARNING: {len(missing_img)} bundles have no image yet: "
+ ", ".join(missing_img[:10]) + ("" if len(missing_img) > 10 else ""))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""
Face-swap issue plate images with FaceFusion (~/Projekte/facefusion).
python3 scripts/faceswap-images.py --issue 02 --source ~/Bilder/palina.webp
Runs FaceFusion `batch-run` once (models load a single time) over every
content/posts/<issue>/<slug>/<slug>.png, swapping in the face from --source, and
writes the results back in place (the .png sources are tracked in git, so that is
the backup). Pass --out DIR to write to a staging tree instead.
Idempotent: a slug already recorded in data/prompts/issue-<issue>.faceswap.json
for the same source image is skipped unless --force.
FaceFusion is invoked through its own virtualenv
(~/Projekte/facefusion/.venv/bin/python); this script itself needs only stdlib.
"""
import argparse
import hashlib
import json
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
SITE = Path(__file__).resolve().parent.parent
FF_DIR = Path.home() / "Projekte" / "facefusion"
FF_PY = FF_DIR / ".venv" / "bin" / "python"
FF_ENTRY = FF_DIR / "facefusion.py"
def sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def load_manifest(path: Path) -> dict:
if path.is_file():
try:
return {r["slug"]: r for r in json.loads(path.read_text(encoding="utf-8"))}
except (json.JSONDecodeError, KeyError, TypeError):
pass
return {}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--issue", required=True)
ap.add_argument("--source", type=Path, default=Path.home() / "Bilder" / "palina.webp",
help="face to swap in (default: ~/Bilder/palina.webp)")
ap.add_argument("--out", type=Path, help="output root; default is in-place")
# Everything below is passed to FaceFusion only when explicitly set;
# otherwise FaceFusion uses its own defaults.
ap.add_argument("--model",
help="face-swapper model (blendswap_256, hyperswap_1a_256, simswap_unofficial_512, …)")
ap.add_argument("--pixel-boost",
choices=["256x256", "512x512", "768x768", "1024x1024"])
ap.add_argument("--swapper-weight")
ap.add_argument("--enhance", action="store_true",
help="also run the face_enhancer processor (sharper, slower)")
ap.add_argument("--enhancer-model")
ap.add_argument("--enhancer-blend", type=int)
ap.add_argument("--output-image-quality", type=int)
ap.add_argument("--execution-providers", nargs="+",
help="cuda / cpu / tensorrt …")
ap.add_argument("--execution-thread-count", type=int)
ap.add_argument("--only", help="comma-separated slugs")
ap.add_argument("--limit", type=int)
ap.add_argument("--force", action="store_true")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
if not FF_PY.exists():
sys.exit(f"FaceFusion venv python not found: {FF_PY}")
source = args.source.expanduser()
if not source.is_file():
sys.exit(f"source face not found: {source}")
content = SITE / "content" / "posts" / args.issue
if not content.is_dir():
sys.exit(f"no such issue dir: {content}")
bundles = sorted(p for p in content.iterdir()
if p.is_dir() and (p / f"{p.name}.png").is_file())
if args.only:
want = {s.strip() for s in args.only.split(",")}
bundles = [b for b in bundles if b.name in want]
if args.limit:
bundles = bundles[: args.limit]
if not bundles:
sys.exit("no matching plate images")
src_hash = sha256(source)
manifest_path = SITE / "data" / "prompts" / f"issue-{args.issue}.faceswap.json"
done = load_manifest(manifest_path)
todo = [b for b in bundles
if args.force or done.get(b.name, {}).get("source_sha256") != src_hash]
skipped = len(bundles) - len(todo)
print(f"{len(bundles)} plates · {len(todo)} to swap · {skipped} already done "
f"(source {source.name} {src_hash[:12]})")
if not todo:
return 0
processors = ["face_swapper"] + (["face_enhancer"] if args.enhance else [])
with tempfile.TemporaryDirectory(prefix="roux-faceswap-") as td:
tin, tout, tjobs = Path(td) / "in", Path(td) / "out", Path(td) / "jobs"
tin.mkdir()
tout.mkdir()
tjobs.mkdir()
for b in todo:
shutil.copy2(b / f"{b.name}.png", tin / f"{b.name}.png")
cmd = [
str(FF_PY), str(FF_ENTRY), "batch-run",
"--jobs-path", str(tjobs),
"-s", str(source),
"-t", str(tin / "*.png"),
"-o", str(tout / "{target_name}.png"),
"--processors", *processors,
"--log-level", "info",
]
if args.model:
cmd += ["--face-swapper-model", args.model]
if args.pixel_boost:
cmd += ["--face-swapper-pixel-boost", args.pixel_boost]
if args.swapper_weight:
cmd += ["--face-swapper-weight", args.swapper_weight]
if args.enhancer_model:
cmd += ["--face-enhancer-model", args.enhancer_model]
if args.enhancer_blend is not None:
cmd += ["--face-enhancer-blend", str(args.enhancer_blend)]
if args.output_image_quality is not None:
cmd += ["--output-image-quality", str(args.output_image_quality)]
if args.execution_providers:
cmd += ["--execution-providers", *args.execution_providers]
if args.execution_thread_count is not None:
cmd += ["--execution-thread-count", str(args.execution_thread_count)]
if args.dry_run:
print(" ".join(cmd))
return 0
print(f"→ facefusion batch-run ({len(todo)} steps, processors: {'+'.join(processors)})")
rc = subprocess.run(cmd, cwd=str(FF_DIR)).returncode
if rc != 0:
sys.exit(f"facefusion exited {rc}")
out_root = args.out.expanduser() if args.out else content
ok, failed = [], []
for b in todo:
produced = tout / f"{b.name}.png"
if not produced.is_file():
failed.append(b.name)
continue
dest = out_root / b.name / f"{b.name}.png"
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(produced), str(dest))
ok.append(b.name)
done[b.name] = {
"slug": b.name,
"source": str(source),
"source_sha256": src_hash,
"model": args.model or "facefusion-default",
"pixel_boost": args.pixel_boost or "facefusion-default",
"enhanced": args.enhance,
"done_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
# Only the in-place pipeline is tracked; --out is for experimentation.
if not args.out:
manifest_path.parent.mkdir(parents=True, exist_ok=True)
manifest_path.write_text(
json.dumps([done[k] for k in sorted(done)], indent=2, ensure_ascii=False) + "\n",
encoding="utf-8")
print(f"\n{len(ok)} swapped / {len(failed)} failed / {skipped} skipped")
if failed:
print("failed (no face detected?):", ", ".join(sorted(failed)))
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""
Generate plate images with Replicate (black-forest-labs/flux-1.1-pro by default).
Reusable across issues. Reads a prompts JSON file — an array of objects with at
least `slug` and `prompt` — and writes one `<slug>.png` per entry.
python3 scripts/generate-images.py --prompts data/prompts/issue-02.json --issue 02
Output layout: <out>/<slug>/<slug>.png (a Hugo page-bundle dir per plate).
With --issue NN and no --out, <out> defaults to content/posts/NN.
The Replicate token is read from $REPLICATE_API_TOKEN, or from ~/.env if unset.
Only stdlib is used (no `requests`, no `replicate`).
"""
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
API_ROOT = "https://api.replicate.com/v1"
SITE = Path(__file__).resolve().parent.parent
TERMINAL = {"succeeded", "failed", "canceled"}
def load_token() -> str:
tok = os.environ.get("REPLICATE_API_TOKEN", "").strip()
if tok:
return tok
env = Path.home() / ".env"
if env.is_file():
for line in env.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
if key.strip() == "REPLICATE_API_TOKEN":
return val.strip().strip("'").strip('"')
sys.exit("REPLICATE_API_TOKEN not found in environment or ~/.env")
def _req(url: str, token: str, method: str = "GET", body: dict | None = None,
extra_headers: dict | None = None):
data = json.dumps(body).encode() if body is not None else None
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
if extra_headers:
headers.update(extra_headers)
r = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(r, timeout=120) as resp:
return json.loads(resp.read().decode())
def create_prediction(model: str, inp: dict, token: str, retries: int = 3) -> dict:
url = f"{API_ROOT}/models/{model}/predictions"
for attempt in range(retries):
try:
return _req(url, token, "POST", {"input": inp}, {"Prefer": "wait"})
except urllib.error.HTTPError as e:
if e.code in (429, 500, 502, 503, 504) and attempt < retries - 1:
time.sleep(2 ** attempt * 3)
continue
detail = e.read().decode(errors="replace")
raise RuntimeError(f"HTTP {e.code} creating prediction: {detail}") from e
except urllib.error.URLError as e:
if attempt < retries - 1:
time.sleep(2 ** attempt * 3)
continue
raise RuntimeError(f"network error creating prediction: {e}") from e
raise RuntimeError("exhausted retries creating prediction")
def wait_for(pred: dict, token: str, timeout_s: int = 300) -> dict:
deadline = time.time() + timeout_s
while pred.get("status") not in TERMINAL:
if time.time() > deadline:
raise RuntimeError("timed out waiting for prediction")
time.sleep(2)
pred = _req(pred["urls"]["get"], token)
return pred
def download(url: str, dest: Path, retries: int = 3) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
for attempt in range(retries):
try:
with urllib.request.urlopen(url, timeout=120) as resp:
dest.write_bytes(resp.read())
return
except (urllib.error.URLError, urllib.error.HTTPError) as e:
if attempt < retries - 1:
time.sleep(2 ** attempt * 2)
continue
raise RuntimeError(f"failed to download {url}: {e}") from e
def build_input(entry: dict, args, seed: int) -> dict:
inp = {
"prompt": entry["prompt"],
"output_format": args.output_format,
"safety_tolerance": args.safety_tolerance,
"prompt_upsampling": args.prompt_upsampling,
}
if args.width and args.height:
inp["aspect_ratio"] = "custom"
inp["width"] = args.width
inp["height"] = args.height
else:
inp["aspect_ratio"] = args.aspect
if seed is not None:
inp["seed"] = seed
return inp
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--prompts", required=True, type=Path)
ap.add_argument("--issue", help="issue id; default --out is content/posts/<issue>")
ap.add_argument("--out", type=Path, help="output root (overrides --issue)")
ap.add_argument("--model", default="black-forest-labs/flux-1.1-pro")
ap.add_argument("--aspect", default="2:3",
help="aspect_ratio; ignored when --width and --height are both set")
ap.add_argument("--width", type=int, default=960,
help="custom width (multiple of 32, <=1440); 0 to use --aspect instead")
ap.add_argument("--height", type=int, default=1440,
help="custom height (multiple of 32, <=1440); 0 to use --aspect instead")
ap.add_argument("--output-format", default="png")
ap.add_argument("--safety-tolerance", type=int, default=6)
ap.add_argument("--prompt-upsampling", default="true",
type=lambda s: s.lower() not in ("false", "0", "no"))
ap.add_argument("--seed-base", type=int, default=20260829,
help="per-image seed = seed_base + index; pass -1 to disable")
ap.add_argument("--concurrency", type=int, default=3)
ap.add_argument("--limit", type=int)
ap.add_argument("--only", help="comma-separated slugs to (re)generate")
ap.add_argument("--force", action="store_true")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
if args.out:
out_root = args.out
elif args.issue:
out_root = SITE / "content" / "posts" / args.issue
else:
ap.error("pass --out or --issue")
entries = json.loads(args.prompts.read_text(encoding="utf-8"))
entries.sort(key=lambda e: e["slug"])
if args.only:
want = {s.strip() for s in args.only.split(",")}
entries = [e for e in entries if e["slug"] in want]
if args.limit:
entries = entries[: args.limit]
token = None if args.dry_run else load_token()
manifest_path = args.prompts.with_suffix(".generated.json")
manifest = []
if manifest_path.is_file():
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
manifest = []
tasks = []
for i, entry in enumerate(entries):
slug = entry["slug"]
dest = out_root / slug / f"{slug}.png"
seed = None if args.seed_base < 0 else args.seed_base + i
if dest.exists() and not args.force:
tasks.append((entry, dest, seed, "skip"))
else:
tasks.append((entry, dest, seed, "gen"))
to_gen = [t for t in tasks if t[3] == "gen"]
skipped = len(tasks) - len(to_gen)
print(f"{len(entries)} entries · {len(to_gen)} to generate · {skipped} already present")
if args.dry_run:
for entry, dest, seed, _ in to_gen:
print(f"\n--- {entry['slug']} -> {dest}")
print(json.dumps(build_input(entry, args, seed), indent=2, ensure_ascii=False))
return 0
ok, failed = [], []
def run(entry, dest, seed):
inp = build_input(entry, args, seed)
pred = create_prediction(args.model, inp, token)
pred = wait_for(pred, token)
if pred["status"] != "succeeded":
raise RuntimeError(pred.get("error") or pred["status"])
output = pred["output"]
if isinstance(output, list):
output = output[0]
download(output, dest)
return {
"slug": entry["slug"],
"prediction_id": pred.get("id"),
"version": pred.get("version"),
"seed": seed,
"model": args.model,
"output_url": output,
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
futs = {pool.submit(run, e, d, s): e["slug"] for e, d, s, _ in to_gen}
for fut in as_completed(futs):
slug = futs[fut]
try:
rec = fut.result()
ok.append(slug)
manifest = [m for m in manifest if m.get("slug") != slug] + [rec]
print(f" ok {slug}")
except Exception as e: # noqa: BLE001 — report and continue
failed.append(slug)
print(f" FAIL {slug}: {e}")
manifest.sort(key=lambda m: m["slug"])
manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
print(f"\n{len(ok)} ok / {len(failed)} failed / {skipped} skipped")
if failed:
print("failed:", ", ".join(sorted(failed)))
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
Regular → Executable
+1 -1
View File
@@ -13,7 +13,7 @@ GINGER = Path.home() / "projects" / "ginger"
CSV_PATH = GINGER / "posts.csv"
IMG_DIR = GINGER / "images" / "final" / "selected"
SITE = Path(__file__).parent.parent
CONTENT = SITE / "content" / "posts"
CONTENT = SITE / "content" / "posts" / "01"
CONTENT.mkdir(parents=True, exist_ok=True)
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""
Upscale issue plate images with Upscayl's CLI and the Remacri model.
python3 scripts/upscale-images.py --issue 02
Copies every content/posts/<issue>/<slug>/<slug>.png into a flat staging dir,
runs `upscayl-bin` once over the whole dir (the model loads a single time), then
writes the results back in place. Optionally clamps the long edge and re-strips
metadata with ImageMagick so the committed PNGs stay a sane size.
Idempotent: a slug already recorded in data/prompts/issue-<issue>.upscale.json
for the same settings is skipped unless --force.
Needs only stdlib. `upscayl-bin` and (for --max-edge) `magick` must be on PATH or
at their default locations.
"""
import argparse
import json
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
SITE = Path(__file__).resolve().parent.parent
UPSCAYL_CANDIDATES = [
Path("/usr/share/upscayl/bin/upscayl-bin"),
Path("/opt/Upscayl/resources/bin/upscayl-bin"),
]
MODELS_CANDIDATES = [
Path("/usr/share/upscayl/models"),
Path("/opt/Upscayl/resources/models"),
Path.home() / ".config" / "upscayl" / "models",
]
def which(name: str) -> str | None:
return shutil.which(name)
def find_upscayl() -> Path:
for p in UPSCAYL_CANDIDATES:
if p.is_file():
return p
found = which("upscayl-bin")
if found:
return Path(found)
sys.exit("upscayl-bin not found (looked in /usr/share/upscayl/bin and PATH)")
def find_models(explicit: Path | None) -> Path:
if explicit:
if explicit.is_dir():
return explicit
sys.exit(f"--models-dir not a directory: {explicit}")
for p in MODELS_CANDIDATES:
if p.is_dir():
return p
sys.exit("upscayl models dir not found")
def load_manifest(path: Path) -> dict:
if path.is_file():
try:
return {r["slug"]: r for r in json.loads(path.read_text(encoding="utf-8"))}
except (json.JSONDecodeError, KeyError, TypeError):
pass
return {}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--issue", required=True)
ap.add_argument("--out", type=Path, help="output root; default is in-place")
ap.add_argument("--model", default="remacri-4x", help="upscayl model name")
ap.add_argument("--models-dir", type=Path, help="override models folder")
ap.add_argument("--scale", type=int, default=4, choices=[2, 3, 4],
help="output scale (default 4)")
ap.add_argument("--max-edge", type=int, default=4992,
help="clamp the long edge with ImageMagick after upscaling "
"(0 = keep native scale; default 4992 ≈ issue-01 sources)")
ap.add_argument("--quality", type=int, default=95,
help="ImageMagick PNG quality when --max-edge applies")
ap.add_argument("--compress", type=int, default=0,
help="upscayl -c compression (0-100)")
ap.add_argument("--gpu-id", help="upscayl -g gpu id (default auto)")
ap.add_argument("--only", help="comma-separated slugs")
ap.add_argument("--limit", type=int)
ap.add_argument("--force", action="store_true")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
upscayl = find_upscayl()
models_dir = find_models(args.models_dir)
if not (models_dir / f"{args.model}.bin").is_file():
sys.exit(f"model {args.model!r} not in {models_dir}")
magick = which("magick") or which("convert")
if args.max_edge and not magick:
sys.exit("--max-edge needs ImageMagick (magick/convert) on PATH; pass --max-edge 0 to skip")
content = SITE / "content" / "posts" / args.issue
if not content.is_dir():
sys.exit(f"no such issue dir: {content}")
bundles = sorted(p for p in content.iterdir()
if p.is_dir() and (p / f"{p.name}.png").is_file())
if args.only:
want = {s.strip() for s in args.only.split(",")}
bundles = [b for b in bundles if b.name in want]
if args.limit:
bundles = bundles[: args.limit]
if not bundles:
sys.exit("no matching plate images")
settings = f"{args.model}|s{args.scale}|e{args.max_edge}|c{args.compress}"
manifest_path = SITE / "data" / "prompts" / f"issue-{args.issue}.upscale.json"
done = load_manifest(manifest_path)
todo = [b for b in bundles
if args.force or done.get(b.name, {}).get("settings") != settings]
skipped = len(bundles) - len(todo)
print(f"{len(bundles)} plates · {len(todo)} to upscale · {skipped} already done ({settings})")
if not todo:
return 0
with tempfile.TemporaryDirectory(prefix="roux-upscale-") as td:
tin, tout = Path(td) / "in", Path(td) / "out"
tin.mkdir()
tout.mkdir()
for b in todo:
shutil.copy2(b / f"{b.name}.png", tin / f"{b.name}.png")
cmd = [str(upscayl), "-i", str(tin), "-o", str(tout),
"-m", str(models_dir), "-n", args.model,
"-s", str(args.scale), "-f", "png"]
if args.compress:
cmd += ["-c", str(args.compress)]
if args.gpu_id:
cmd += ["-g", args.gpu_id]
if args.dry_run:
print(" ".join(cmd))
if args.max_edge:
print(f"# then: {magick} <img> -resize {args.max_edge}x{args.max_edge}\\> "
f"-strip -quality {args.quality} <img>")
return 0
print(f"→ upscayl {args.model} x{args.scale} ({len(todo)} images)")
rc = subprocess.run(cmd).returncode
if rc != 0:
sys.exit(f"upscayl-bin exited {rc}")
out_root = args.out.expanduser() if args.out else content
ok, failed = [], []
for b in todo:
produced = tout / f"{b.name}.png"
if not produced.is_file():
failed.append(b.name)
continue
if args.max_edge:
subprocess.run([magick, str(produced),
"-resize", f"{args.max_edge}x{args.max_edge}>",
"-strip", "-quality", str(args.quality),
str(produced)], check=True)
dest = out_root / b.name / f"{b.name}.png"
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(produced), str(dest))
ok.append(b.name)
done[b.name] = {
"slug": b.name,
"settings": settings,
"model": args.model,
"scale": args.scale,
"max_edge": args.max_edge,
"done_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
# Only the in-place pipeline is tracked; --out is for experimentation.
if not args.out:
manifest_path.parent.mkdir(parents=True, exist_ok=True)
manifest_path.write_text(
json.dumps([done[k] for k in sorted(done)], indent=2, ensure_ascii=False) + "\n",
encoding="utf-8")
print(f"\n{len(ok)} upscaled / {len(failed)} failed / {skipped} skipped")
if failed:
print("failed:", ", ".join(sorted(failed)))
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())