Ghostlight/tools/validate_visual_blockouts.py
2026-05-03 14:34:50 +01:00

176 lines
8.6 KiB
Python

"""Validate Ghostlight visual blockout plan examples."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_EXAMPLES = sorted((ROOT / "examples" / "visual-blockouts").glob("*.json"))
class ValidationError(Exception):
pass
def load_json(path: Path) -> Any:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def require(condition: bool, message: str) -> None:
if not condition:
raise ValidationError(message)
def require_keys(obj: dict[str, Any], keys: list[str], path: str) -> None:
missing = [key for key in keys if key not in obj]
require(not missing, f"{path} missing required keys: {', '.join(missing)}")
def require_nonempty_string(value: Any, path: str) -> None:
require(isinstance(value, str) and value.strip(), f"{path} must be a non-empty string")
def require_string_list(value: Any, path: str) -> None:
require(isinstance(value, list), f"{path} must be an array")
for index, item in enumerate(value):
require_nonempty_string(item, f"{path}[{index}]")
def require_vector3(value: Any, path: str) -> None:
require(isinstance(value, list) and len(value) == 3, f"{path} must be a 3-number array")
for index, item in enumerate(value):
require(isinstance(item, (int, float)), f"{path}[{index}] must be a number")
def require_color(value: Any, path: str) -> None:
require(isinstance(value, list) and len(value) == 4, f"{path} must be a 4-number RGBA array")
for index, item in enumerate(value):
require(isinstance(item, (int, float)) and 0 <= item <= 1, f"{path}[{index}] must be 0..1")
def validate_blockout_plan(document: dict[str, Any], source: Path) -> None:
require(document.get("schema_version") == "ghostlight.visual_blockout_plan.v0", f"{source}.schema_version is wrong")
require_keys(
document,
[
"blockout_plan_id",
"fixture_id",
"visual_plan_ref",
"purpose",
"layout",
"semantic_materials",
"geometry_modules",
"proxy_actors",
"cameras",
"guide_renders",
"prompt_use_contract",
],
str(source),
)
for key in ["blockout_plan_id", "fixture_id", "visual_plan_ref", "purpose"]:
require_nonempty_string(document[key], f"{source}.{key}")
visual_plan = ROOT / document["visual_plan_ref"]
require(visual_plan.exists(), f"{source}.visual_plan_ref does not exist: {visual_plan}")
visual_data = load_json(visual_plan)
visual_scene_ids = {
scene.get("visual_scene_id")
for scene in visual_data.get("visual_scene_plan", {}).get("scenes", [])
if isinstance(scene, dict)
}
require(visual_scene_ids, f"{source}.visual_plan_ref has no visual_scene_plan.scenes")
layout = document["layout"]
require_keys(layout, ["layout_id", "layout_asset", "coordinate_system", "scale_notes", "source_refs"], f"{source}.layout")
for key in ["layout_id", "layout_asset", "coordinate_system", "scale_notes"]:
require_nonempty_string(layout[key], f"{source}.layout.{key}")
require_string_list(layout["source_refs"], f"{source}.layout.source_refs")
material_ids: set[str] = set()
require(isinstance(document["semantic_materials"], list) and document["semantic_materials"], f"{source}.semantic_materials must be non-empty")
for index, material in enumerate(document["semantic_materials"]):
path = f"{source}.semantic_materials[{index}]"
require_keys(material, ["material_id", "display_name", "base_color", "intended_read", "use_in_prompt"], path)
require_nonempty_string(material["material_id"], f"{path}.material_id")
require(material["material_id"] not in material_ids, f"{path}.material_id duplicates {material['material_id']}")
material_ids.add(material["material_id"])
require_color(material["base_color"], f"{path}.base_color")
for key in ["display_name", "intended_read", "use_in_prompt"]:
require_nonempty_string(material[key], f"{path}.{key}")
module_ids: set[str] = set()
for index, module in enumerate(document["geometry_modules"]):
path = f"{source}.geometry_modules[{index}]"
require_keys(module, ["module_id", "object_class", "script_ref", "geometry_script_tree", "parameters", "material_id", "notes"], path)
require_nonempty_string(module["module_id"], f"{path}.module_id")
require(module["module_id"] not in module_ids, f"{path}.module_id duplicates {module['module_id']}")
module_ids.add(module["module_id"])
require(module["material_id"] in material_ids, f"{path}.material_id references unknown material {module['material_id']}")
require(isinstance(module["parameters"], dict), f"{path}.parameters must be an object")
for key in ["object_class", "script_ref", "geometry_script_tree", "notes"]:
require_nonempty_string(module[key], f"{path}.{key}")
for index, actor in enumerate(document["proxy_actors"]):
path = f"{source}.proxy_actors[{index}]"
require_keys(actor, ["actor_id", "visual_ref", "proxy_shape", "material_id", "default_position", "default_rotation", "scale", "notes"], path)
require(actor["material_id"] in material_ids, f"{path}.material_id references unknown material {actor['material_id']}")
for key in ["actor_id", "visual_ref", "proxy_shape", "notes"]:
require_nonempty_string(actor[key], f"{path}.{key}")
require_vector3(actor["default_position"], f"{path}.default_position")
require_vector3(actor["default_rotation"], f"{path}.default_rotation")
require_vector3(actor["scale"], f"{path}.scale")
camera_ids: set[str] = set()
for index, camera in enumerate(document["cameras"]):
path = f"{source}.cameras[{index}]"
require_keys(camera, ["camera_id", "visual_scene_ids", "position", "rotation", "lens_mm", "shot_role", "framing_notes"], path)
require_nonempty_string(camera["camera_id"], f"{path}.camera_id")
require(camera["camera_id"] not in camera_ids, f"{path}.camera_id duplicates {camera['camera_id']}")
camera_ids.add(camera["camera_id"])
require_string_list(camera["visual_scene_ids"], f"{path}.visual_scene_ids")
for scene_id in camera["visual_scene_ids"]:
require(scene_id in visual_scene_ids, f"{path}.visual_scene_ids references unknown visual scene {scene_id}")
require_vector3(camera["position"], f"{path}.position")
require_vector3(camera["rotation"], f"{path}.rotation")
if "target" in camera:
require_vector3(camera["target"], f"{path}.target")
require(isinstance(camera["lens_mm"], (int, float)) and camera["lens_mm"] > 0, f"{path}.lens_mm must be positive")
for key in ["shot_role", "framing_notes"]:
require_nonempty_string(camera[key], f"{path}.{key}")
for index, render in enumerate(document["guide_renders"]):
path = f"{source}.guide_renders[{index}]"
require_keys(render, ["render_id", "camera_id", "visual_scene_id", "render_modes", "output_refs", "imagegen_reference_use"], path)
require(render["camera_id"] in camera_ids, f"{path}.camera_id references unknown camera {render['camera_id']}")
require(render["visual_scene_id"] in visual_scene_ids, f"{path}.visual_scene_id references unknown visual scene {render['visual_scene_id']}")
for key in ["render_id", "imagegen_reference_use"]:
require_nonempty_string(render[key], f"{path}.{key}")
require_string_list(render["render_modes"], f"{path}.render_modes")
require_string_list(render["output_refs"], f"{path}.output_refs")
contract = document["prompt_use_contract"]
require_keys(contract, ["reference_image_role", "must_preserve", "may_reinterpret", "must_not_assume"], f"{source}.prompt_use_contract")
require_nonempty_string(contract["reference_image_role"], f"{source}.prompt_use_contract.reference_image_role")
for key in ["must_preserve", "may_reinterpret", "must_not_assume"]:
require_string_list(contract[key], f"{source}.prompt_use_contract.{key}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("paths", nargs="*", type=Path, help="Visual blockout plan JSON files to validate.")
args = parser.parse_args()
paths = args.paths or DEFAULT_EXAMPLES
require(bool(paths), "No visual blockout plans found.")
for path in paths:
validate_blockout_plan(load_json(path), path)
print(f"ok: {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())