A script scanned every tracked doc, rule, skill, agent, hook and source file for tooling/ paths that no longer exist, skipping historical records (sprints, discussions, workshops, governance, generated wiki pages). It found 62. The ones that tell a reader what to RUN now name the reach verb: - The atlas skill still sent agents to tooling/atlas, atlas-verify, atlas-update-field and atlas-commit-and-sync — about forty lines, all retired in T-1285. They now name the `reach atlas` verbs, and the skill records that commit-and-sync STAGES by default (--commit to commit) and takes --corridor as an option. - The clerk agent named tooling/clerk-review (now `reach dev clerk`). The Si and clerk briefings sent those agents to the retired tooling/db/decision and sqlite-query CLIs and to decisions/*.md paths that moved to governance/ in the pql migration. They now name pql. - The ticket-cli rule documented `pql decisions read`, which does not exist; `show` already includes the body. - The culture authoring guide and the RON sources name `reach validate ron`, with the same arguments as before. - The 41 Blender payloads' usage lines ran the retired tooling/blender wrapper, and the docstrings still cited pre-carve-out paths. They now read `reach blender run <payload>`. - Doc comments in server/, client/, wiki TOMLs and the domain modules. What is left is deliberate: "Formerly …" provenance, dated plans and findings docs, the retired-pipeline doc, and a build-artefact path. project.yaml 0.4.14 (mirrored to the client). Comment-only, but four touched files are in the canvas-version registry (trait_catalog_reader.rs, since T-1289, canvas_sources.py itself, and two client files). The gate is path-based and has no override. The previous push was rejected on exactly this. Three of the edits are stamped ledger sources, so systems.db is regenerated and the stamp is fresh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
202 lines
6.6 KiB
Python
Executable File
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):
|
|
reach 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:
|
|
reach 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)
|