← Files Comic SolARCHIVED FILE
skills/comic-sol/scripts/compose_pages.py
13.1 KB · Sep 30, 2026 · 23:14 UTC
#!/usr/bin/env python3
"""Compose deterministic Comic Sol page PNGs from lettered panel images."""
from __future__ import annotations
import argparse
import hashlib
import io
import json
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageOps
from comic_sol import (
PAGE_HEIGHT,
PAGE_WIDTH,
atomic_write_bytes,
canonical_artifact_bytes,
read_json,
sha256_file,
)
from layouts import LAYOUT_VERSION, get_layout, match_layout, validate_custom_layout
from project_io import ProjectTransaction, contained_project_path, open_path_nofollow
COMPOSITION_CACHE_PATH = "cache/composition.json"
def _storyboard_page(storyboard: dict, page_number: int) -> dict:
pages = storyboard.get("pages")
if not isinstance(pages, list):
raise ValueError("storyboard pages must be an array")
matches = [
page for page in pages
if isinstance(page, dict) and page.get("number") == page_number
]
if len(matches) != 1:
raise ValueError(f"storyboard page {page_number} was not found exactly once")
return matches[0]
def _artifact_path(project_dir: Path, panel_id: str, source_artifacts: dict) -> str | Path:
configured = source_artifacts.get(panel_id)
if isinstance(configured, dict):
configured = configured.get("path")
candidates: list[str | Path] = []
if isinstance(configured, (str, Path)):
candidates.append(configured)
candidates.extend((
f"panels/{panel_id}/lettered.png",
f"pages/{panel_id}.png",
f"panels/lettered/{panel_id}.png",
))
for relative in candidates:
candidate = contained_project_path(project_dir, relative)
if candidate.is_file():
contained_project_path(project_dir, relative, must_exist=True)
return relative
raise FileNotFoundError(f"missing required lettered panel image: {panel_id}")
def _page_sources(
project_dir: Path, page: dict, source_artifacts: dict
) -> list[tuple[dict, str | Path]]:
panels = page.get("panels")
if not isinstance(panels, list):
raise ValueError(f"page {page.get('number')} panels must be an array")
sources: list[tuple[dict, str | Path]] = []
missing: list[str] = []
for panel in panels:
if not isinstance(panel, dict) or not isinstance(panel.get("id"), str):
raise ValueError(f"page {page.get('number')} contains an invalid panel")
panel_id = panel["id"]
try:
source = _artifact_path(project_dir, panel_id, source_artifacts)
except FileNotFoundError:
missing.append(panel_id)
else:
sources.append((panel, source))
if missing:
raise FileNotFoundError(
"missing required lettered panel image(s): " + ", ".join(missing)
)
return sources
def _background_color(manifest_settings: dict) -> tuple[int, int, int]:
configured = manifest_settings.get(
"page_background", manifest_settings.get("background", "white")
)
if configured == "black":
return (0, 0, 0)
if configured == "white" or configured is None:
return (255, 255, 255)
raise ValueError("manifest page background must be black or white")
def _rect(panel: dict) -> tuple[int, int, int, int]:
rect = panel.get("rect")
if not isinstance(rect, dict):
raise ValueError(f"panel {panel.get('id')} has no rectangle")
values = tuple(rect.get(key) for key in ("x", "y", "width", "height"))
if any(isinstance(value, bool) or not isinstance(value, int) for value in values):
raise ValueError(f"panel {panel.get('id')} rectangle must contain integers")
x, y, width, height = values
if (
x < 0 or y < 0 or width <= 0 or height <= 0
or x + width > PAGE_WIDTH or y + height > PAGE_HEIGHT
):
raise ValueError(f"panel {panel.get('id')} rectangle exceeds the page")
return x, y, width, height
def _page_layout(page: dict) -> tuple[str, tuple[tuple[int, int, int, int], ...]]:
"""Validate page geometry and return its canonical layout identity."""
panels = page.get("panels")
if not isinstance(panels, list) or not panels:
raise ValueError(f"page {page.get('number')} panels must be a non-empty array")
rectangles = tuple(_rect(panel) for panel in panels if isinstance(panel, dict))
if len(rectangles) != len(panels):
raise ValueError(f"page {page.get('number')} contains an invalid panel")
validate_custom_layout(rectangles, tuple(range(1, len(rectangles) + 1)))
matched = match_layout(rectangles)
declared = page.get("layout")
if not isinstance(declared, str) or not declared:
return matched, rectangles
if declared == "custom":
if matched != "custom":
raise ValueError("declared layout custom does not match storyboard rectangles")
return declared, rectangles
definition = get_layout(declared)
if definition.rectangles != rectangles:
raise ValueError(
f"declared layout {declared} does not match storyboard rectangles"
)
return declared, rectangles
def _compose_to_bytes(
project_dir: Path,
page: dict,
sources: list[tuple[dict, str | Path]],
manifest_settings: dict,
) -> bytes:
canvas = Image.new("RGB", (PAGE_WIDTH, PAGE_HEIGHT), _background_color(manifest_settings))
draw = ImageDraw.Draw(canvas)
for panel, source_relative in sources:
x, y, width, height = _rect(panel)
try:
source_path = contained_project_path(
project_dir, source_relative, must_exist=True
)
with open_path_nofollow(source_path) as stream, Image.open(stream) as source:
source.load()
fitted = ImageOps.fit(
source.convert("RGB"),
(width, height),
method=Image.Resampling.LANCZOS,
centering=(0.5, 0.5),
)
except (OSError, SyntaxError) as error:
raise ValueError(
f"panel {panel.get('id')} is not a readable image"
) from error
canvas.paste(fitted, (x, y))
draw.rectangle(
(x, y, x + width - 1, y + height - 1),
outline=(0, 0, 0),
width=6,
)
encoded = io.BytesIO()
canvas.save(encoded, format="PNG", optimize=False, compress_level=9)
return encoded.getvalue()
def compose_page(
project_dir: Path,
page_number: int,
storyboard: dict,
manifest_settings: dict,
source_artifacts: dict,
) -> Path:
"""Compose one numbered page and atomically publish its deterministic PNG."""
if isinstance(page_number, bool) or not isinstance(page_number, int) or page_number < 1:
raise ValueError("page number must be a positive integer")
if not isinstance(storyboard, dict) or not isinstance(manifest_settings, dict):
raise TypeError("storyboard and manifest_settings must be objects")
if not isinstance(source_artifacts, dict):
raise TypeError("source_artifacts must be an object")
project_dir = Path(project_dir)
page = _storyboard_page(storyboard, page_number)
_page_layout(page)
sources = _page_sources(project_dir, page, source_artifacts)
payload = _compose_to_bytes(project_dir, page, sources, manifest_settings)
output_path = project_dir / f"pages/page-{page_number:03d}.png"
atomic_write_bytes(output_path, payload)
return output_path
def compose_all_pages(project_dir: Path) -> list[Path]:
"""Compose every storyboard page in numeric order after a complete preflight."""
project_dir = Path(project_dir)
storyboard = read_json(contained_project_path(project_dir, "plan/storyboard.json", must_exist=True))
manifest = read_json(contained_project_path(project_dir, "project.json", must_exist=True))
settings = manifest.get("settings")
artifacts = manifest.get("artifacts", {})
if not isinstance(settings, dict) or not isinstance(artifacts, dict):
raise ValueError("manifest settings and artifacts must be objects")
pages = storyboard.get("pages")
if not isinstance(pages, list):
raise ValueError("storyboard pages must be an array")
page_numbers = sorted(
page.get("number") for page in pages
if isinstance(page, dict)
and isinstance(page.get("number"), int)
and not isinstance(page.get("number"), bool)
)
if len(page_numbers) != len(pages) or page_numbers != list(range(1, len(pages) + 1)):
raise ValueError("storyboard pages must be numbered contiguously from 1")
prepared_pages = []
for number in page_numbers:
page = _storyboard_page(storyboard, number)
layout_name, rectangles = _page_layout(page)
sources = _page_sources(project_dir, page, artifacts)
prepared_pages.append((number, page, sources, layout_name, rectangles))
payloads = [
(f"pages/page-{number:03d}.png", _compose_to_bytes(project_dir, page, sources, settings))
for number, page, sources, _, _ in prepared_pages
]
payload_by_number = {
number: (relative, payload)
for (number, _, _, _, _), (relative, payload)
in zip(prepared_pages, payloads)
}
cache_pages = []
for number, page, sources, layout_name, rectangles in prepared_pages:
relative, payload = payload_by_number[number]
panel_ids = [panel["id"] for panel, _ in sources]
ordered_hashes = []
for panel, source_relative in sources:
source_path = contained_project_path(
project_dir, source_relative, must_exist=True
)
ordered_hashes.append(f"{panel['id']}:{sha256_file(source_path)}")
cache_pages.append({
"layout": {
"name": layout_name,
"rectangles": [list(rectangle) for rectangle in rectangles],
"version": LAYOUT_VERSION,
},
"ordered_lettered_sha256s": ordered_hashes,
"output": {
"dimensions": [PAGE_WIDTH, PAGE_HEIGHT],
"path": relative,
"sha256": hashlib.sha256(payload).hexdigest(),
},
"page_id": f"page-{number:03d}",
"panel_ids": panel_ids,
"settings_sha256": hashlib.sha256(
canonical_artifact_bytes(settings)
).hexdigest(),
"storyboard_page_sha256": hashlib.sha256(
canonical_artifact_bytes(page)
).hexdigest(),
})
cache_payload = canonical_artifact_bytes({
"kind": "composition-cache",
"pages": cache_pages,
"schema_version": "2.0",
})
output_paths = []
with ProjectTransaction(project_dir, "composition") as transaction:
for relative, payload in payloads:
transaction.stage_bytes(relative, payload)
output_paths.append(project_dir / relative)
transaction.stage_bytes(COMPOSITION_CACHE_PATH, cache_payload)
# Re-read under the lock so a concurrent writer's manifest is not lost.
locked_manifest = read_json(project_dir / "project.json")
descriptors = locked_manifest.get("artifacts")
if not isinstance(descriptors, dict):
descriptors = {}
descriptors["composition_cache"] = {
"path": COMPOSITION_CACHE_PATH,
"sha256": hashlib.sha256(cache_payload).hexdigest(),
}
locked_manifest["artifacts"] = descriptors
transaction.stage_bytes(
"project.json", canonical_artifact_bytes(locked_manifest)
)
transaction.commit()
return output_paths
def compose_project(project_dir: Path) -> list[Path]:
"""Compose all pages for a generated project."""
return compose_all_pages(Path(project_dir))
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="compose_pages.py")
parser.add_argument("project_dir", type=Path)
selection = parser.add_mutually_exclusive_group()
selection.add_argument("--page", type=int)
selection.add_argument("--all", action="store_true", dest="all_pages")
return parser
def main(argv: list[str] | None = None) -> int:
arguments = _build_parser().parse_args(argv)
try:
if arguments.page is None:
paths = compose_all_pages(arguments.project_dir)
else:
storyboard = read_json(contained_project_path(arguments.project_dir, "plan/storyboard.json", must_exist=True))
manifest = read_json(contained_project_path(arguments.project_dir, "project.json", must_exist=True))
settings = manifest.get("settings")
artifacts = manifest.get("artifacts", {})
if not isinstance(settings, dict) or not isinstance(artifacts, dict):
raise ValueError("manifest settings and artifacts must be objects")
paths = [compose_page(
arguments.project_dir, arguments.page, storyboard, settings, artifacts
)]
print("\n".join(path.as_posix() for path in paths))
return 0
except (OSError, TypeError, ValueError, json.JSONDecodeError) as error:
print(f"ERROR {type(error).__name__}: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: 1dac010506359faf55fca28bb09459c08e84ab3951b2f95cbfb913ad96680f7b