Files
jpmschweitzerandClaude Opus 5 a384ec0c7c feat(config): T-1283 — the godot and visual domains
reach godot parse-sweep / cold-parse, reach visual diff / blank-check /
thumbnail. Five scripts retired, and the callers rewired — tests/run-visual
invoked three of them by path at four sites, which is a wider blast radius than
the make targets were.

The godot pair were grep pipelines encoding five hard-won lessons as comments
nobody could test. They are Python filters now, with the reasons attached, and
the engine invocation is a guarded exec. Verified on the real client: 229
scripts, clean.

Their three not-ok states stay distinct, because only one is a verdict about
the code. An engine that crashed or is missing is not a parse failure —
reporting it as one blames the tree for a broken toolchain. A sweep that
emitted no completion marker checked nothing, and zero errors from a check that
never ran reads as clean, which is the false-green the sweep exists to close.
The deliberate asymmetry between the two checks is preserved and documented:
cold-parse filters "Cannot infer the type", the sweep does not, because that
suppression is why cold-parse stayed silent about a helper that genuinely does
not parse.

All three visual scripts carried the same root bug as validate-checklist:
Path(__file__).parent.parent, correct at tooling/ and two levels too deep at
tooling/domains/visual. Fixed during the move rather than after, having learned
that it fails silently — paths resolve to nothing, the work appears to have
nothing to do, and the tool reports success. Three domains now where that would
have shipped a false pass.

Two bugs my own transformation introduced, both found by running rather than
reading. Multi-line print(..., file=sys.stderr) became console.event(...,
file=sys.stderr), and console puts unknown kwargs into the payload — a file
object would have reached json.dumps at the exact moment something was already
being reported as an error. And the replacement script wrote escaped quotes
into three files. Mechanical transformations need mechanical verification.

sys.exit removed from four sites: a service must not end the process.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 13:59:29 +02:00

202 lines
6.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Contact sheet and crop tool for visual QA flow captures.
Two modes:
Grid mode (default):
tooling/visual-thumbnail DIR [--config PATH]
Reads {flow}_{NNN}.png frames + {flow}_manifest.txt sidecar from DIR,
generates a contact sheet grid with timecodes and labels.
Output: DIR/{flow}_sheet.png
Crop mode:
tooling/visual-thumbnail --crop REGION IMAGE [--config PATH]
Extracts a named region from IMAGE at 1:1 scale.
Output: IMAGE_crop_{REGION}.png
Config: reads thumbnail dimensions, columns, and crop regions from
tests/visual.json (auto-detected from script location, or --config).
"""
import json
from pathlib import Path
from tooling.core import config, console
from tooling.core.errors import ReachError
try:
from PIL import Image, ImageDraw
except ImportError:
console.event("visual-thumbnail requires Pillow: pip install Pillow", level="error")
raise ReachError(
"visual-thumbnail: config not found",
fix="pass --config, or restore tests/visual.json",
)
ROOT = config.repo_root()
DEFAULT_CONFIG = ROOT / "tests" / "visual.json"
# Fallbacks when config keys are missing
DEFAULT_THUMB_WIDTH = 320
DEFAULT_THUMB_HEIGHT = 180
DEFAULT_COLUMNS = 4
LABEL_HEIGHT = 24 # pixels reserved below each thumbnail for text
def load_config(config_path: Path) -> dict:
"""Load configuration from JSON file."""
if not config_path.exists():
console.event(f"Config not found: {config_path}", level="error")
console.event("Continuing with built-in defaults.", level="error")
return {}
with open(config_path) as f:
return json.load(f)
def parse_manifest(manifest_path: Path) -> list[dict]:
"""Parse a flow manifest file.
Each line: NNN TIMECODE LABEL
Example: 001 0:03 dialogue opens
"""
entries = []
with open(manifest_path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(None, 2)
if len(parts) < 2:
continue
entry = {
"frame": parts[0],
"timecode": parts[1],
"label": parts[2] if len(parts) > 2 else "",
}
entries.append(entry)
return entries
def detect_flow(directory: Path) -> str | None:
"""Detect flow name from manifest sidecar in directory."""
manifests = list(directory.glob("*_manifest.txt"))
if len(manifests) == 1:
# {flow}_manifest.txt -> flow
stem = manifests[0].stem
return stem.removesuffix("_manifest")
if len(manifests) > 1:
console.event(f"Multiple manifests found in {directory}:", level="error")
for m in manifests:
console.event(f" {m.name}", level="error")
return None
return None
def grid_mode(directory: Path, config: dict) -> int:
"""Generate a contact sheet from flow captures."""
directory = directory.resolve()
if not directory.is_dir():
console.event(f"Not a directory: {directory}", level="error")
return 1
flow = detect_flow(directory)
if flow is None:
console.event(f"No manifest found in {directory}. Expected {{flow}}_manifest.txt", level="error")
return 1
manifest_path = directory / f"{flow}_manifest.txt"
entries = parse_manifest(manifest_path)
if not entries:
console.event(f"Empty manifest: {manifest_path}", level="error")
return 1
# Read thumbnail config
thumb_cfg = config.get("thumbnail", {})
tw = thumb_cfg.get("width", DEFAULT_THUMB_WIDTH)
th = thumb_cfg.get("height", DEFAULT_THUMB_HEIGHT)
cols = thumb_cfg.get("columns", DEFAULT_COLUMNS)
rows = (len(entries) + cols - 1) // cols
cell_h = th + LABEL_HEIGHT
sheet_w = tw * cols
sheet_h = cell_h * rows
sheet = Image.new("RGB", (sheet_w, sheet_h), color=(30, 30, 30))
draw = ImageDraw.Draw(sheet)
for idx, entry in enumerate(entries):
frame_file = directory / f"{flow}_{entry['frame']}.png"
if not frame_file.exists():
console.event(f" Missing frame: {frame_file.name}", level="error")
continue
img = Image.open(frame_file)
img.thumbnail((tw, th), Image.LANCZOS)
col = idx % cols
row = idx // cols
x = col * tw
y = row * cell_h
# Center thumbnail within its cell if it's smaller than tw x th
offset_x = x + (tw - img.width) // 2
offset_y = y + (th - img.height) // 2
sheet.paste(img, (offset_x, offset_y))
# Draw timecode + label below thumbnail
text = entry["timecode"]
if entry["label"]:
text += f" {entry['label']}"
text_y = y + th + 2
draw.text((x + 4, text_y), text, fill=(200, 200, 200))
output_path = directory / f"{flow}_sheet.png"
sheet.save(output_path)
console.event(f"Sheet: {output_path}")
return 0
def crop_mode(region_name: str, image_path: Path, config: dict) -> int:
"""Extract a named crop region from an image at 1:1 scale."""
image_path = image_path.resolve()
if not image_path.exists():
console.event(f"Image not found: {image_path}", level="error")
return 1
crops = config.get("crops", {})
if region_name not in crops:
available = ", ".join(sorted(crops.keys())) if crops else "(none)"
console.event(f"Unknown crop region: {region_name}", level="error")
console.event(f"Available regions: {available}", level="error")
return 1
coords = crops[region_name]
if not isinstance(coords, list) or len(coords) != 4:
console.event(f"Invalid crop coords for '{region_name}': expected [x, y, w, h]", level="error")
return 1
x, y, w, h = coords
img = Image.open(image_path)
cropped = img.crop((x, y, x + w, y + h))
stem = image_path.stem
suffix = image_path.suffix
output_path = image_path.parent / f"{stem}_crop_{region_name}{suffix}"
cropped.save(output_path)
console.event(output_path)
return 0
def run(target, crop: str | None = None, config_path=None) -> int:
"""Contact sheet (grid mode) or named-region crop, depending on `crop`.
Two modes behind one entry, as the original had them. The router exposes
them as one verb with a flag rather than two, because the second argument
changes what `target` MEANS — a directory in grid mode, a file in crop
mode — and two verbs would each have to re-explain that.
"""
config = load_config(config_path or DEFAULT_CONFIG)
if crop:
return crop_mode(crop, target, config)
return grid_mode(target, config)