← Files Creative ProductionARCHIVED FILE

scripts/review_renderer.py

18.5 KB · Sep 30, 2026 · 23:20 UTC

↓ Download file

#!/usr/bin/env python3
"""Shared review renderers for Creative Production explorers.

Explorers should emit manifests and choose a preset here instead of writing
bespoke review HTML. That keeps the inspection surfaces consistent while still
allowing product-specific layouts such as image walls or larger text boards.
"""

from __future__ import annotations

import argparse
import json
from html import escape
from pathlib import Path
from typing import Any

PRESET_ALIASES = {
    "wall": "image-wall",
    "review-wall": "image-wall",
    "image_wall": "image-wall",
    "shot-wall": "image-wall",
    "selector": "selector-board",
    "positioning": "positioning-board",
}

PRESETS: dict[str, dict[str, Any]] = {
    "image-wall": {
        "background": "#fff",
        "text": "#181818",
        "minTileWidth": 220,
        "gapWithCaptions": 18,
        "gapWithoutCaptions": 10,
        "showCaptions": True,
        "chrome": False,
    },
    "selector-board": {
        "background": "#fff",
        "text": "#181818",
        "minTileWidth": 260,
        "gapWithCaptions": 16,
        "gapWithoutCaptions": 12,
        "showCaptions": True,
        "chrome": False,
    },
    "positioning-board": {
        "background": "#fff",
        "text": "#181818",
        "minTileWidth": 320,
        "gapWithCaptions": 18,
        "gapWithoutCaptions": 16,
        "showCaptions": True,
        "chrome": False,
    },
    "moodboard": {
        "background": "#fff",
        "text": "#181818",
        "minTileWidth": 280,
        "gapWithCaptions": 18,
        "gapWithoutCaptions": 14,
        "showCaptions": True,
        "chrome": False,
    },
    "detail-review": {
        "background": "#fff",
        "text": "#181818",
        "minTileWidth": 360,
        "gapWithCaptions": 18,
        "gapWithoutCaptions": 16,
        "showCaptions": True,
        "chrome": False,
    },
}


def normalize_preset(value: str | None) -> str:
    preset = (value or "image-wall").strip()
    preset = PRESET_ALIASES.get(preset, preset)
    if preset not in PRESETS:
        available = ", ".join(sorted(PRESETS))
        raise ValueError(f"Unknown review preset '{value}'. Available presets: {available}")
    return preset


def image_files(out_dir: Path) -> list[Path]:
    web = sorted(out_dir.glob("[0-9]*-*-web.png"))
    if web:
        return web
    return sorted(
        path for path in out_dir.glob("[0-9]*-*.png") if not path.name.endswith("-web.png")
    )


def _index_width(manifest: list[dict[str, Any]]) -> int:
    return 3 if len(manifest) >= 100 else 2


def _manifest_by_output(manifest: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
    indexed = {}
    for item in manifest:
        output = item.get("output") or item.get("href") or item.get("src")
        if output:
            indexed[str(output)] = item
            indexed[Path(str(output)).name] = item
    return indexed


def _caption_for(
    item: dict[str, Any] | None,
    fallback_label: str,
    fallback_index: int | str,
    width: int,
) -> str:
    if item:
        label = str(item.get("title") or item.get("label") or fallback_label)
        index = item.get("index", fallback_index)
    else:
        label = fallback_label
        index = fallback_index
    try:
        prefix = f"{int(index):0{width}d}."
    except (TypeError, ValueError):
        prefix = f"{index}."
    return f"<figcaption>{escape(prefix)} {escape(label)}</figcaption>"


def _controls_for(src: str, label: str) -> str:
    return (
        '<div class="tile-controls">'
        '<button class="select-card" type="button" aria-pressed="false">Select</button>'
        f'<a class="download-card" href="{escape(src, quote=True)}" download="{escape(Path(src).name, quote=True)}">Download</a>'
        f'<button class="remove-card" type="button" aria-label="Remove {escape(label, quote=True)}">Remove</button>'
        "</div>"
    )


def _image_cards_from_files(
    files: list[Path],
    manifest: list[dict[str, Any]],
    *,
    show_captions: bool,
    show_controls: bool = False,
) -> list[str]:
    indexed = _manifest_by_output(manifest)
    width = _index_width(manifest)
    cards = []
    for idx, path in enumerate(files, start=1):
        original = path.name.replace("-web.png", ".png")
        item = indexed.get(original) or indexed.get(path.name)
        label = (
            str(item.get("title") or item.get("label"))
            if item
            else path.stem.replace("-web", "").replace("-", " ").title()
        )
        index = item.get("index") if item else path.name.split("-", 1)[0]
        href = escape(str(item.get("href") if item and item.get("href") else original), quote=True)
        src = escape(str(item.get("src") if item and item.get("src") else path.name), quote=True)
        caption = _caption_for(item, label, index or idx, width) if show_captions else ""
        controls = _controls_for(src, label) if show_controls else ""
        if show_controls:
            cards.append(
                f'<figure data-card-id="{escape(str(item.get("id") if item else path.stem), quote=True)}">'
                f'<a class="image-link" href="{href}"><img src="{src}" alt="{escape(label, quote=True)}"></a>'
                f"{controls}{caption}</figure>"
            )
        else:
            cards.append(
                f'<figure><a href="{href}"><img src="{src}" alt="{escape(label, quote=True)}"></a>{caption}</figure>'
            )
    return cards


def _image_cards_from_manifest(
    manifest: list[dict[str, Any]],
    *,
    show_captions: bool,
    show_controls: bool = False,
) -> list[str]:
    width = _index_width(manifest)
    cards = []
    for idx, item in enumerate(manifest, start=1):
        src = item.get("src") or item.get("image") or item.get("imageUrl")
        if not src:
            continue
        href = item.get("href") or src
        label = str(
            item.get("title") or item.get("label") or Path(str(src)).stem.replace("-", " ").title()
        )
        caption = _caption_for(item, label, item.get("index", idx), width) if show_captions else ""
        controls = _controls_for(str(src), label) if show_controls else ""
        if show_controls:
            cards.append(
                f'<figure data-card-id="{escape(str(item.get("id", idx)), quote=True)}">'
                f'<a class="image-link" href="{escape(str(href), quote=True)}">'
                f'<img src="{escape(str(src), quote=True)}" alt="{escape(label, quote=True)}"></a>'
                f"{controls}{caption}</figure>"
            )
        else:
            cards.append(
                f'<figure><a href="{escape(str(href), quote=True)}">'
                f'<img src="{escape(str(src), quote=True)}" alt="{escape(label, quote=True)}"></a>{caption}</figure>'
            )
    return cards


def _prompt_cards(manifest: list[dict[str, Any]]) -> list[str]:
    width = _index_width(manifest)
    cards = []
    for idx, item in enumerate(manifest, start=1):
        route = f"<span>{escape(str(item['routeName']))}</span>" if item.get("routeName") else ""
        label = escape(str(item.get("title") or item.get("label") or f"Review item {idx}"))
        prompt = escape(str(item.get("prompt") or ""))
        index = item.get("index", idx)
        try:
            prefix = f"{int(index):0{width}d}."
        except (TypeError, ValueError):
            prefix = f"{index}."
        cards.append(
            '<figure class="prompt-card">'
            f"<figcaption>{escape(prefix)} {label} {route}</figcaption>"
            f"<p>{prompt}</p>"
            "</figure>"
        )
    return cards


def build_review_html(
    out_dir: Path,
    manifest: list[dict[str, Any]],
    review_options: dict[str, Any] | None = None,
) -> Path:
    review_options = review_options or {}
    preset_name = normalize_preset(review_options.get("preset") or review_options.get("layout"))
    preset = PRESETS[preset_name]
    show_captions = bool(review_options.get("showCaptions", preset["showCaptions"]))
    show_prompts = bool(review_options.get("showPrompts", preset.get("showPrompts", True)))
    show_controls = bool(review_options.get("showControls", preset.get("showControls", False)))
    title = str(review_options.get("title") or "Creative Production Review")
    output_name = str(review_options.get("output") or "review-board.html")
    gap = preset["gapWithCaptions"] if show_captions else preset["gapWithoutCaptions"]
    min_tile_width = int(review_options.get("minTileWidth", preset["minTileWidth"]))

    files = image_files(out_dir)
    if files:
        cards = _image_cards_from_files(
            files, manifest, show_captions=show_captions, show_controls=show_controls
        )
    else:
        cards = _image_cards_from_manifest(
            manifest, show_captions=show_captions, show_controls=show_controls
        )
        if not cards and show_prompts:
            cards = _prompt_cards(manifest)
        elif not cards:
            message = str(
                review_options.get("emptyMessage")
                or "Images pending. Generate the selected shots to populate this review wall."
            )
            cards = [f'<section class="empty-state">{escape(message)}</section>']

    interactive_css = (
        """
.tile-controls { display: flex; gap: 6px; margin-top: 7px; opacity: .86; }
.tile-controls button, .tile-controls a { border: 1px solid #dfdfdf; border-radius: 7px; background: #fff; color: #333; cursor: pointer; font: inherit; font-size: 11px; font-weight: 650; line-height: 1; padding: 6px 8px; text-decoration: none; }
.tile-controls button:hover, .tile-controls a:hover { background: #f6f6f6; }
figure.selected img { outline: 3px solid #181818; outline-offset: 2px; }
figure.hidden { display: none; }
.image-viewer { border: 0; border-radius: 10px; padding: 0; max-width: min(94vw, 1400px); background: transparent; }
.image-viewer::backdrop { background: rgba(0, 0, 0, .72); }
.image-viewer-inner { display: grid; gap: 8px; padding: 10px; background: #fff; border-radius: 10px; }
.image-viewer img { max-width: 90vw; max-height: 84vh; width: auto; border: 0; border-radius: 8px; }
.viewer-close { justify-self: end; border: 1px solid #ddd; background: #fff; border-radius: 999px; cursor: pointer; padding: 6px 10px; }
"""
        if show_controls
        else ""
    )

    interactive_html = (
        """
<dialog class="image-viewer" id="imageViewer">
  <div class="image-viewer-inner">
    <button class="viewer-close" type="button">Close</button>
    <img id="viewerImage" alt="">
  </div>
</dialog>
<script>
(function () {
  var key = "creative-production-review:" + location.pathname;
  var stored = {};
  try { stored = JSON.parse(localStorage.getItem(key) || "{}"); } catch (error) { stored = {}; }
  var selected = new Set(stored.selected || []);
  var hidden = new Set(stored.hidden || []);
  function persist() {
    localStorage.setItem(key, JSON.stringify({ selected: Array.from(selected), hidden: Array.from(hidden) }));
  }
  document.querySelectorAll("figure[data-card-id]").forEach(function (card) {
    var id = card.getAttribute("data-card-id");
    if (selected.has(id)) card.classList.add("selected");
    if (hidden.has(id)) card.classList.add("hidden");
    var select = card.querySelector(".select-card");
    if (select) {
      select.setAttribute("aria-pressed", selected.has(id) ? "true" : "false");
      select.textContent = selected.has(id) ? "Selected" : "Select";
      select.addEventListener("click", function () {
        if (selected.has(id)) selected.delete(id);
        else selected.add(id);
        card.classList.toggle("selected", selected.has(id));
        select.setAttribute("aria-pressed", selected.has(id) ? "true" : "false");
        select.textContent = selected.has(id) ? "Selected" : "Select";
        persist();
      });
    }
    var remove = card.querySelector(".remove-card");
    if (remove) {
      remove.addEventListener("click", function () {
        hidden.add(id);
        card.classList.add("hidden");
        persist();
      });
    }
  });
  var viewer = document.getElementById("imageViewer");
  var viewerImage = document.getElementById("viewerImage");
  if (viewer && viewerImage) {
    document.querySelectorAll(".image-link").forEach(function (link) {
      link.addEventListener("click", function (event) {
        event.preventDefault();
        viewerImage.src = link.getAttribute("href");
        if (typeof viewer.showModal === "function") viewer.showModal();
        else location.href = link.getAttribute("href");
      });
    });
    viewer.querySelector(".viewer-close").addEventListener("click", function () { viewer.close(); });
    viewer.addEventListener("click", function (event) { if (event.target === viewer) viewer.close(); });
  }
}());
</script>
"""
        if show_controls
        else ""
    )

    html = f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(title)}</title>
<style>
body {{ margin: 0; font-family: Arial, sans-serif; background: {preset["background"]}; color: {preset["text"]}; }}
main {{ padding: 24px; }}
.grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax({min_tile_width}px, 1fr)); gap: {gap}px; align-items: start; }}
figure {{ margin: 0; }}
img {{ width: 100%; height: auto; display: block; border: 1px solid #e5e5e5; border-radius: 8px; background: #fafafa; }}
figcaption {{ margin-top: 8px; font-size: 13px; line-height: 1.3; color: #333; }}
.prompt-card {{ border: 1px solid #e5e5e5; border-radius: 8px; padding: 14px; background: #fafafa; }}
.prompt-card figcaption {{ margin-top: 0; font-weight: 700; }}
.prompt-card figcaption span {{ display: inline-block; margin-left: 6px; color: #7a4b16; font-weight: 600; }}
.prompt-card p {{ margin: 10px 0 0; font-size: 12px; line-height: 1.45; color: #555; }}
.empty-state {{ border: 1px solid #e5e5e5; border-radius: 8px; padding: 18px; color: #555; background: #fafafa; }}
{interactive_css}
</style>
</head>
<body><main><div class="grid">{"".join(cards)}</div></main>{interactive_html}</body>
</html>
"""
    review_file = out_dir / output_name
    review_file.write_text(html, encoding="utf-8")
    return review_file


def _resolve_image_path(out_dir: Path, src: str) -> Path:
    path = Path(src)
    if path.is_absolute():
        return path
    return out_dir / path


def build_contact_sheet(
    out_dir: Path,
    manifest: list[dict[str, Any]],
    review_options: dict[str, Any] | None = None,
) -> Path | None:
    review_options = review_options or {}
    show_captions = bool(review_options.get("showCaptions", PRESETS["image-wall"]["showCaptions"]))
    try:
        from PIL import Image, ImageDraw, ImageFont
    except Exception:
        return None

    files = image_files(out_dir)
    if files:
        sources = [(path, None) for path in files]
    else:
        sources = []
        for item in manifest:
            src = item.get("src") or item.get("image") or item.get("imageUrl")
            if src:
                sources.append((_resolve_image_path(out_dir, str(src)), item))
    if not sources:
        return None

    indexed = _manifest_by_output(manifest)
    width = _index_width(manifest)
    thumb = int(review_options.get("contactSheetThumb", 260))
    label_h = 54 if show_captions else 0
    gap = 18 if show_captions else 12
    pad = 24
    cols = int(review_options.get("contactSheetColumns", 5))
    rows = (len(sources) + cols - 1) // cols
    sheet_w = pad * 2 + cols * thumb + (cols - 1) * gap
    sheet_h = pad * 2 + rows * (thumb + label_h) + (rows - 1) * gap
    sheet = Image.new("RGB", (sheet_w, sheet_h), "white")
    draw = ImageDraw.Draw(sheet)
    try:
        font = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial.ttf", 14)
    except Exception:
        font = ImageFont.load_default()

    for idx, (path, explicit_item) in enumerate(sources):
        row, col = divmod(idx, cols)
        x = pad + col * (thumb + gap)
        y = pad + row * (thumb + label_h + gap)
        image = Image.open(path).convert("RGB")
        image.thumbnail((thumb, thumb), Image.LANCZOS)
        bx = x + (thumb - image.width) // 2
        by = y + (thumb - image.height) // 2
        draw.rounded_rectangle(
            [x, y, x + thumb, y + thumb], radius=8, outline=(224, 224, 224), width=1
        )
        sheet.paste(image, (bx, by))
        if not show_captions:
            continue
        original = path.name.replace("-web.png", ".png")
        item = explicit_item or indexed.get(original) or indexed.get(path.name)
        label = (
            str(item.get("title") or item.get("label"))
            if item
            else path.stem.replace("-web", "").replace("-", " ").title()
        )
        index = item.get("index") if item else idx + 1
        try:
            prefix = f"{int(index):0{width}d}."
        except (TypeError, ValueError):
            prefix = f"{index}."
        draw.text((x, y + thumb + 8), f"{prefix} {label}", fill=(30, 30, 30), font=font)

    sheet_file = out_dir / str(
        review_options.get("contactSheetOutput") or "offer-contact-sheet.png"
    )
    sheet.save(sheet_file, quality=95)
    return sheet_file


def _read_json_arg(value: str | None) -> dict[str, Any]:
    if not value:
        return {}
    path = Path(value)
    if path.exists():
        return json.loads(path.read_text(encoding="utf-8"))
    return json.loads(value)


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Render a Creative Production review page from a manifest."
    )
    parser.add_argument("--out-dir", required=True, type=Path)
    parser.add_argument("--manifest", required=True, type=Path)
    parser.add_argument("--review-options", help="JSON string or path to a JSON file.")
    parser.add_argument("--preset")
    parser.add_argument("--show-captions", action="store_true")
    parser.add_argument("--hide-captions", action="store_true")
    parser.add_argument("--contact-sheet", action="store_true")
    args = parser.parse_args()

    manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
    review_options = _read_json_arg(args.review_options)
    if args.preset:
        review_options["preset"] = args.preset
    if args.show_captions:
        review_options["showCaptions"] = True
    if args.hide_captions:
        review_options["showCaptions"] = False
    review_file = build_review_html(args.out_dir, manifest, review_options)
    contact_sheet = (
        build_contact_sheet(args.out_dir, manifest, review_options) if args.contact_sheet else None
    )
    print(
        json.dumps(
            {
                "review_html": str(review_file.resolve()),
                "contact_sheet": str(contact_sheet.resolve()) if contact_sheet else None,
            },
            indent=2,
        )
    )


if __name__ == "__main__":
    main()

SHA-256: f2708fc7eecfa41e9c28f7d0739ace8db6c1326ba0f1233565e194711bca9940