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:
Executable
+100
@@ -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())
|
||||
Executable
+191
@@ -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())
|
||||
Executable
+234
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Executable
+196
@@ -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())
|
||||
Reference in New Issue
Block a user