← Files Comic SolARCHIVED FILE
skills/comic-sol/scripts/page_quality.py
20.7 KB · Sep 30, 2026 · 23:14 UTC
#!/usr/bin/env python3
"""Schema-2.0 page QA records derived from composition and lettering evidence."""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping, Sequence
from PIL import Image
from comic_sol import atomic_write_json, read_json, sha256_file
from layouts import LAYOUT_VERSION, match_layout, validate_custom_layout
from project_io import contained_project_path, open_path_nofollow
from quality_records import PAGE_CHECK_IDS, validate_quality_checks
DETERMINISTIC_PAGE_CHECK_IDS = frozenset({
"clipped-text",
"text-overlap",
"reading-order",
"layout-border-integrity",
})
SUBJECTIVE_PAGE_CHECK_IDS = frozenset(PAGE_CHECK_IDS) - DETERMINISTIC_PAGE_CHECK_IDS
@dataclass(frozen=True)
class PageQualityIssue:
path: str
field: str
message: str
def _page_id(page_number: int) -> str:
if not isinstance(page_number, int) or isinstance(page_number, bool) or page_number < 1:
raise ValueError("page number must be a positive integer")
return f"page-{page_number:03d}"
def _storyboard_page(storyboard: Mapping[str, object], page_number: int) -> dict[str, object]:
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 _rect_tuple(panel: Mapping[str, object]) -> tuple[int, int, int, int]:
rect = panel.get("rect")
if not isinstance(rect, dict):
raise ValueError("storyboard panel rectangle is missing")
values = tuple(rect.get(key) for key in ("x", "y", "width", "height"))
if len(values) != 4 or any(not isinstance(value, int) or isinstance(value, bool) for value in values):
raise ValueError("storyboard panel rectangle must contain integers")
return values # type: ignore[return-value]
def _overlap(first: Mapping[str, object], second: Mapping[str, object]) -> bool:
ax, ay, aw, ah = (first.get(key) for key in ("x", "y", "width", "height"))
bx, by, bw, bh = (second.get(key) for key in ("x", "y", "width", "height"))
if not all(isinstance(value, int) and not isinstance(value, bool)
for value in (ax, ay, aw, ah, bx, by, bw, bh)):
return True
return not (ax + aw <= bx or bx + bw <= ax or ay + ah <= by or by + bh <= ay) # type: ignore[operator]
def _page_context(project_dir: Path, page_number: int) -> dict[str, object]:
storyboard_path = contained_project_path(project_dir, "plan/storyboard.json", must_exist=True)
storyboard = read_json(storyboard_path)
page = _storyboard_page(storyboard, page_number)
panels = page.get("panels")
if not isinstance(panels, list) or not panels:
raise ValueError("storyboard page panels must be a non-empty array")
rectangles = tuple(_rect_tuple(panel) for panel in panels if isinstance(panel, dict))
if len(rectangles) != len(panels):
raise ValueError("storyboard page contains an invalid panel")
validate_custom_layout(rectangles, tuple(range(1, len(rectangles) + 1)))
matched_layout = match_layout(rectangles)
declared_layout = page.get("layout")
if not isinstance(declared_layout, str) or not declared_layout:
declared_layout = matched_layout
page_id = _page_id(page_number)
page_relative = f"pages/{page_id}.png"
page_path = contained_project_path(project_dir, page_relative, must_exist=True)
with open_path_nofollow(page_path) as stream, Image.open(stream) as image:
image.load()
page_width, page_height = image.size
cache_relative = "cache/composition.json"
cache_path = contained_project_path(project_dir, cache_relative, must_exist=True)
lettering: list[tuple[str, str, dict[str, object]]] = []
for panel in panels:
panel_id = panel.get("id") if isinstance(panel, dict) else None
if not isinstance(panel_id, str):
raise ValueError("storyboard panel ID is invalid")
relative = f"panels/{panel_id}/lettering.json"
path = contained_project_path(project_dir, relative, must_exist=True)
geometry = read_json(path)
lettering.append((panel_id, sha256_file(path), geometry))
return {
"storyboard": storyboard,
"storyboard_path": storyboard_path,
"page": page,
"panels": panels,
"rectangles": rectangles,
"declared_layout": declared_layout,
"matched_layout": matched_layout,
"page_relative": page_relative,
"page_path": page_path,
"page_width": page_width,
"page_height": page_height,
"cache_relative": cache_relative,
"cache_path": cache_path,
"lettering": lettering,
}
def _deterministic_checks(context: Mapping[str, object]) -> list[dict[str, object]]:
panels = context["panels"]
lettering = context["lettering"]
assert isinstance(panels, list) and isinstance(lettering, list)
clipped_regions: list[dict[str, object]] = []
overlap_regions: list[dict[str, object]] = []
order_regions: list[dict[str, object]] = []
for panel_index, ((panel_id, _, geometry), panel) in enumerate(zip(lettering, panels), 1):
assert isinstance(panel_id, str) and isinstance(geometry, dict) and isinstance(panel, dict)
items = geometry.get("items")
if not isinstance(items, list):
items = []
boxes: list[tuple[str, dict[str, object]]] = []
orders: list[int] = []
clean = context["rectangles"][panel_index - 1] # type: ignore[index]
_, _, panel_width, panel_height = clean
for item in items:
if not isinstance(item, dict):
continue
item_id = item.get("id") if isinstance(item.get("id"), str) else "unknown"
box = item.get("box")
order = item.get("reading_order")
if isinstance(order, int) and not isinstance(order, bool):
orders.append(order)
if not isinstance(box, dict):
clipped_regions.append({"panel_id": panel_id, "item_id": item_id, "reason": "missing-box"})
continue
values = tuple(box.get(key) for key in ("x", "y", "width", "height"))
if not all(isinstance(value, int) and not isinstance(value, bool) for value in values):
clipped_regions.append({"panel_id": panel_id, "item_id": item_id, "reason": "invalid-box"})
continue
x, y, width, height = values
if x < 0 or y < 0 or width <= 0 or height <= 0 or x + width > panel_width or y + height > panel_height:
clipped_regions.append({"panel_id": panel_id, "item_id": item_id, "box": box})
for prior_id, prior_box in boxes:
if _overlap(box, prior_box):
overlap_regions.append({"panel_id": panel_id, "items": [prior_id, item_id]})
boxes.append((item_id, box))
if orders != list(range(1, len(items) + 1)):
order_regions.append({"panel_id": panel_id, "observed": orders})
layout_regions = [{
"layout": context["declared_layout"],
"matched_layout": context["matched_layout"],
"rectangles": [list(rectangle) for rectangle in context["rectangles"]],
}]
definitions = (
("clipped-text", clipped_regions, "All lettering boxes remain inside their source panel bounds."),
("text-overlap", overlap_regions, "No lettering boxes overlap within any source panel."),
("reading-order", order_regions, "Every panel uses a contiguous one-based lettering reading order."),
("layout-border-integrity", [] if context["declared_layout"] == context["matched_layout"] else layout_regions,
"Storyboard rectangles are contained, non-overlapping, and match the declared layout."),
)
checks = []
for check_id, failures, evidence in definitions:
checks.append({
"id": check_id,
"result": "fail" if failures else "pass",
"severity": "error",
"evidence": evidence if not failures else f"Deterministic geometry found {len(failures)} failure region(s) for {check_id}.",
"method": "deterministic-geometry-v1",
"reviewer": "comic-sol",
"regions": failures or layout_regions,
})
return checks
def _reviewer_checks(values: Sequence[Mapping[str, object]]) -> list[dict[str, object]]:
checks = [dict(value) for value in values]
ids = [value.get("id") for value in checks]
if len(checks) != len(SUBJECTIVE_PAGE_CHECK_IDS) or set(ids) != SUBJECTIVE_PAGE_CHECK_IDS:
raise ValueError("subjective page checks require exact bounded reviewer evidence")
categories = validate_quality_checks(
checks + [
{
"id": check_id,
"result": "pass",
"severity": "error",
"evidence": f"Deterministic evidence for {check_id} is independently derived.",
"method": "deterministic-geometry-v1",
"reviewer": "comic-sol",
"regions": [{"scope": "page"}],
}
for check_id in DETERMINISTIC_PAGE_CHECK_IDS
],
PAGE_CHECK_IDS,
)
if categories:
raise ValueError(", ".join(categories))
return checks
def _validate_tail_evidence(
context: Mapping[str, object], checks: Sequence[Mapping[str, object]]
) -> None:
"""Require one current bounded-review region for every authored dialogue."""
tail_check = next(
(check for check in checks if check.get("id") == "bubble-tail-direction"),
None,
)
if tail_check is None:
raise ValueError("bubble-tail-evidence-mismatch: check is missing")
regions = tail_check.get("regions")
if not isinstance(regions, list):
raise ValueError("bubble-tail-evidence-mismatch: regions must be an array")
panels = context.get("panels")
lettering = context.get("lettering")
if not isinstance(panels, list) or not isinstance(lettering, list):
raise ValueError("bubble-tail-evidence-mismatch: page context is invalid")
geometry_by_panel = {
panel_id: geometry
for panel_id, _, geometry in lettering
if isinstance(panel_id, str) and isinstance(geometry, dict)
}
expected: dict[tuple[str, str], dict[str, object]] = {}
for panel in panels:
if not isinstance(panel, dict) or not isinstance(panel.get("id"), str):
raise ValueError("bubble-tail-evidence-mismatch: panel is invalid")
panel_id = panel["id"]
geometry = geometry_by_panel.get(panel_id)
items = geometry.get("items") if isinstance(geometry, dict) else None
if not isinstance(items, list):
raise ValueError("bubble-tail-evidence-mismatch: lettering geometry is missing")
placed = {
item.get("id"): item
for item in items
if isinstance(item, dict) and isinstance(item.get("id"), str)
}
text_items = panel.get("text")
if not isinstance(text_items, list):
raise ValueError("bubble-tail-evidence-mismatch: panel text is invalid")
for item in text_items:
if not isinstance(item, dict) or item.get("kind") != "dialogue":
continue
text_id = item.get("id")
geometry_item = placed.get(text_id)
tail = geometry_item.get("tail") if isinstance(geometry_item, dict) else None
if not isinstance(text_id, str) or not isinstance(tail, dict):
raise ValueError("bubble-tail-evidence-mismatch: dialogue tail is missing")
expected[(panel_id, text_id)] = {
"panel_id": panel_id,
"text_id": text_id,
"speaker": item.get("speaker"),
"voice_source": item.get("voice_source"),
"speaker_anchor": item.get("speaker_anchor"),
"tip": tail.get("tip"),
}
observed: dict[tuple[str, str], Mapping[str, object]] = {}
required_fields = {
"panel_id", "text_id", "speaker", "voice_source",
"speaker_anchor", "tip", "result",
}
for region in regions:
if not isinstance(region, dict) or set(region) != required_fields:
raise ValueError("bubble-tail-evidence-mismatch: region fields are invalid")
panel_id = region.get("panel_id")
text_id = region.get("text_id")
if not isinstance(panel_id, str) or not isinstance(text_id, str):
raise ValueError("bubble-tail-evidence-mismatch: region identity is invalid")
key = (panel_id, text_id)
if key in observed or region.get("result") not in {"pass", "fail"}:
raise ValueError("bubble-tail-evidence-mismatch: region identity is invalid")
observed[key] = region
if set(observed) != set(expected):
raise ValueError("bubble-tail-evidence-mismatch: dialogue coverage is incomplete")
for key, expected_region in expected.items():
region = observed[key]
if any(region.get(field) != value for field, value in expected_region.items()):
raise ValueError("bubble-tail-evidence-mismatch: region is stale")
results = [region.get("result") for region in observed.values()]
check_result = tail_check.get("result")
if (check_result == "pass" and any(result != "pass" for result in results)) or (
check_result == "fail" and results and all(result == "pass" for result in results)
):
raise ValueError("bubble-tail-evidence-mismatch: check result is inconsistent")
def build_page_quality_record(
project_dir: Path,
page_number: int,
visual_checks: Sequence[Mapping[str, object]],
) -> dict[str, object]:
"""Build a current schema-2.0 page QA record without inventing visual evidence."""
project_dir = Path(project_dir)
context = _page_context(project_dir, page_number)
deterministic = _deterministic_checks(context)
subjective = _reviewer_checks(visual_checks)
_validate_tail_evidence(context, subjective)
checks_by_id = {check["id"]: check for check in deterministic + subjective}
checks = [checks_by_id[check_id] for check_id in PAGE_CHECK_IDS]
failures = [check for check in checks if check.get("result") == "fail"]
lettering = context["lettering"]
assert isinstance(lettering, list)
record: dict[str, object] = {
"bindings": {
"composition_cache_path": context["cache_relative"],
"composition_cache_sha256": sha256_file(context["cache_path"]), # type: ignore[arg-type]
"layout_name": context["declared_layout"],
"layout_version": LAYOUT_VERSION,
"lettering_sha256s": [f"{panel_id}:{digest}" for panel_id, digest, _ in lettering],
"page_height": context["page_height"],
"page_path": context["page_relative"],
"page_sha256": sha256_file(context["page_path"]), # type: ignore[arg-type]
"page_width": context["page_width"],
"storyboard_path": "plan/storyboard.json",
"storyboard_sha256": sha256_file(context["storyboard_path"]), # type: ignore[arg-type]
},
"checks": checks,
"decision": "regenerate" if failures else "accept",
"kind": "page-qa",
"review": {
"method": "deterministic-plus-bounded-visual-review",
"reviewed_at": "fixture-deterministic",
"reviewer": "fixture-reviewer",
},
"schema_version": "2.0",
"subject_id": _page_id(page_number),
"unresolved_warnings": [],
}
categories = validate_quality_checks(checks, PAGE_CHECK_IDS)
if categories:
raise ValueError(", ".join(categories))
return record
def write_page_quality_record(
project_dir: Path, page_number: int, record: Mapping[str, object]
) -> Path:
destination = contained_project_path(
Path(project_dir), f"qa/pages/{_page_id(page_number)}.json"
)
atomic_write_json(destination, dict(record))
return destination
def validate_page_quality(project_dir: Path, page_number: int) -> tuple[PageQualityIssue, ...]:
"""Fail closed when page QA or any of its provenance bindings is stale."""
project_dir = Path(project_dir)
relative = f"qa/pages/{_page_id(page_number)}.json"
issues: list[PageQualityIssue] = []
def stale(field: str, detail: str) -> None:
issues.append(PageQualityIssue(relative, field, f"page-quality-stale: {detail}"))
try:
path = contained_project_path(project_dir, relative, must_exist=True)
record = read_json(path)
except (OSError, ValueError, json.JSONDecodeError):
stale("record.path", "page quality record is missing or unreadable")
return tuple(issues)
if record.get("schema_version") != "2.0" or record.get("kind") != "page-qa":
stale("schema_version", "page quality record is not schema 2.0")
return tuple(issues)
if record.get("subject_id") != _page_id(page_number):
stale("subject_id", "page subject does not match its path")
categories = validate_quality_checks(record.get("checks"), PAGE_CHECK_IDS)
for category in categories:
stale("checks", category)
bindings = record.get("bindings")
if not isinstance(bindings, dict):
stale("bindings", "page quality bindings are missing")
return tuple(sorted(issues, key=lambda issue: (issue.path, issue.field, issue.message)))
# Verify byte bindings before parsing their semantic contents. Corrupt JSON
# must still identify the exact stale artifact rather than collapse into one
# generic provenance error.
direct_artifacts = (
("page_sha256", bindings.get("page_path")),
("composition_cache_sha256", bindings.get("composition_cache_path")),
("storyboard_sha256", bindings.get("storyboard_path")),
)
for digest_field, relative_path in direct_artifacts:
try:
artifact = (
contained_project_path(project_dir, relative_path, must_exist=True)
if isinstance(relative_path, str)
else None
)
except (OSError, ValueError):
artifact = None
if artifact is None or not artifact.is_file():
stale(f"bindings.{digest_field}", "bound artifact is missing")
elif bindings.get(digest_field) != sha256_file(artifact):
stale(f"bindings.{digest_field}", "bound artifact hash does not match")
lettering_bindings = bindings.get("lettering_sha256s")
if not isinstance(lettering_bindings, list):
stale("bindings.lettering_sha256s", "ordered lettering bindings are missing")
else:
current_lettering: list[str] = []
lettering_readable = True
for binding in lettering_bindings:
if not isinstance(binding, str) or ":" not in binding:
lettering_readable = False
break
panel_id, _ = binding.split(":", 1)
try:
geometry_path = contained_project_path(
project_dir, f"panels/{panel_id}/lettering.json", must_exist=True
)
except (OSError, ValueError):
lettering_readable = False
break
current_lettering.append(f"{panel_id}:{sha256_file(geometry_path)}")
if not lettering_readable or current_lettering != lettering_bindings:
stale(
"bindings.lettering_sha256s",
"ordered lettering artifact hashes do not match",
)
try:
context = _page_context(project_dir, page_number)
except (OSError, ValueError, json.JSONDecodeError):
if not issues:
stale("bindings", "current page provenance is missing or unreadable")
return tuple(sorted(issues, key=lambda issue: (issue.path, issue.field, issue.message)))
expected = {
"page_sha256": sha256_file(context["page_path"]), # type: ignore[arg-type]
"page_width": context["page_width"],
"page_height": context["page_height"],
"composition_cache_sha256": sha256_file(context["cache_path"]), # type: ignore[arg-type]
"storyboard_sha256": sha256_file(context["storyboard_path"]), # type: ignore[arg-type]
"layout_name": context["declared_layout"],
"layout_version": LAYOUT_VERSION,
"lettering_sha256s": [
f"{panel_id}:{digest}" for panel_id, digest, _ in context["lettering"] # type: ignore[union-attr]
],
}
for field, current in expected.items():
if bindings.get(field) != current:
stale(f"bindings.{field}", "bound value does not match current artifacts")
return tuple(sorted(issues, key=lambda issue: (issue.path, issue.field, issue.message)))
SHA-256: 2ececc179dae38a6b4d0d87fdb211a3e0b88a75ec78d441430af937e85bc1704