139 lines
6.2 KiB
Python
139 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate FG-2 media provenance and deterministic runtime outputs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from PIL import Image
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
MEDIA_ROOT = ROOT / "docs/runtime/fg2/media"
|
|
MANIFEST_PATH = ROOT / "runtime/assets/fg2/asset-manifest.json"
|
|
EXPECTED_SIZES = {(380, 180), (422, 195), (320, 180)}
|
|
EXPECTED_ACTORS = {"PF-B01": 6, "EN-R1-09": 4, "EN-R1-08": 4}
|
|
EXPECTED_TASKS = {
|
|
"task_01M04FDED6E16D7SBPJ5C03TAS",
|
|
"task_01M04FDDTANDVT4FC1RG9BG76F",
|
|
"task_01M04FDED19RK2PKRZ4NEP7R2Z",
|
|
"task_01M04FHH85F3MFKC5NWWSZXDV8",
|
|
}
|
|
|
|
|
|
def sha256(path: Path) -> str:
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def require(condition: bool, message: str) -> None:
|
|
if not condition:
|
|
raise AssertionError(message)
|
|
|
|
|
|
def runtime_path(relative: str) -> Path:
|
|
return ROOT / "runtime" / relative
|
|
|
|
|
|
def verify_provider_reports() -> None:
|
|
tasks = set()
|
|
total_cost = 0.0
|
|
for report_path in sorted((MEDIA_ROOT / "reports").glob("*.json")):
|
|
report = json.loads(report_path.read_text(encoding="utf-8"))
|
|
require(report.get("status") == "completed", f"incomplete report: {report_path}")
|
|
tasks.add(report["task_id"])
|
|
total_cost += float(report["cost"])
|
|
output = ROOT / report["output"]
|
|
require(output.is_file(), f"missing provider output: {output}")
|
|
require(output.stat().st_size == int(report["bytes"]), f"provider byte mismatch: {output}")
|
|
require(float(report["credits_cost"]) == 0.14, f"credit mismatch: {report_path}")
|
|
require(tasks == EXPECTED_TASKS, "provider task inventory mismatch")
|
|
require(abs(total_cost - 0.056) < 0.000001, "provider cost must be exactly USD 0.056")
|
|
|
|
|
|
def verify_cluster_pitch(image: Image.Image) -> None:
|
|
pixels = image.load()
|
|
for top in range(0, image.height - 1, 2):
|
|
for left in range(0, image.width - 1, 2):
|
|
block = {pixels[left, top], pixels[left + 1, top], pixels[left, top + 1], pixels[left + 1, top + 1]}
|
|
require(len(block) == 1, f"non-integer environment cluster at {left},{top}")
|
|
|
|
|
|
def verify_environment(manifest: dict) -> int:
|
|
environment = manifest["environment"]
|
|
require(environment["source_band_cuts"] == [0, 682, 1365, 2048], "source thirds changed")
|
|
require(environment["palette_size"] == 32, "environment palette contract changed")
|
|
seen_sizes = set()
|
|
checked = 0
|
|
for layer_name in ("background", "midground", "foreground"):
|
|
records = environment["layers"][layer_name]
|
|
for size_key, record in records.items():
|
|
width, height = (int(value) for value in size_key.split("x"))
|
|
seen_sizes.add((width, height))
|
|
path = runtime_path(record["path"])
|
|
require(path.is_file(), f"missing environment layer: {path}")
|
|
require(sha256(path) == record["sha256"], f"environment hash mismatch: {path}")
|
|
with Image.open(path) as image:
|
|
rgba = image.convert("RGBA")
|
|
require(rgba.size == (width, height), f"environment size mismatch: {path}")
|
|
require(record["cluster_pitch"] == 2, f"cluster pitch mismatch: {path}")
|
|
require(int(record["colors"]) <= 32, f"palette exceeds 32 colors: {path}")
|
|
alphas = {pixel[3] for pixel in rgba.getdata()}
|
|
require(alphas <= {0, 255}, f"soft alpha found: {path}")
|
|
if layer_name == "background":
|
|
require(alphas == {255}, f"background must be opaque: {path}")
|
|
else:
|
|
require(alphas == {0, 255}, f"overlay alpha missing: {path}")
|
|
key_pixels = sum(1 for r, g, b, a in rgba.getdata() if a and r >= 240 and b >= 240 and g <= 24)
|
|
require(key_pixels == 0, f"magenta residue found: {path}")
|
|
verify_cluster_pitch(rgba)
|
|
checked += 1
|
|
require(seen_sizes == EXPECTED_SIZES, "environment target-size inventory mismatch")
|
|
return checked
|
|
|
|
|
|
def verify_actors(manifest: dict) -> int:
|
|
require(set(manifest["actors"]) == set(EXPECTED_ACTORS), "actor identity inventory mismatch")
|
|
checked = 0
|
|
for identity_id, expected_frames in EXPECTED_ACTORS.items():
|
|
actor = manifest["actors"][identity_id]
|
|
require(actor["play_size"] == [36, 36], f"play size mismatch: {identity_id}")
|
|
require(actor["pivot"] == [18, 34], f"pivot mismatch: {identity_id}")
|
|
require(len(actor["frames"]) == expected_frames, f"frame count mismatch: {identity_id}")
|
|
atlas = runtime_path(actor["atlas"])
|
|
require(atlas.is_file() and sha256(atlas) == actor["atlas_sha256"], f"atlas mismatch: {identity_id}")
|
|
for frame in actor["frames"]:
|
|
path = runtime_path(frame["path"])
|
|
require(path.is_file() and sha256(path) == frame["sha256"], f"frame hash mismatch: {path}")
|
|
with Image.open(path) as image:
|
|
rgba = image.convert("RGBA")
|
|
require(rgba.size == (72, 72), f"actor cell mismatch: {path}")
|
|
alpha = rgba.getchannel("A")
|
|
require(alpha.getbbox() is not None, f"empty actor frame: {path}")
|
|
left, top, right, bottom = alpha.getbbox()
|
|
require(left > 0 and top > 0 and right < 72 and bottom < 72, f"actor touches edge: {path}")
|
|
require(set(alpha.getdata()) <= {0, 255}, f"soft actor alpha found: {path}")
|
|
checked += 1
|
|
return checked
|
|
|
|
|
|
def main() -> int:
|
|
verify_provider_reports()
|
|
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
|
require(manifest.get("kind") == "abg-fg2-runtime-media", "runtime manifest kind mismatch")
|
|
require(manifest.get("filter") == "nearest", "runtime filter must be nearest")
|
|
require(manifest.get("integer_placement") is True, "integer placement must be true")
|
|
environment_count = verify_environment(manifest)
|
|
frame_count = verify_actors(manifest)
|
|
print(
|
|
"ABG_FG2_MEDIA_OK "
|
|
f"tasks=4 cost_usd=0.056 environment={environment_count} actor_frames={frame_count} "
|
|
"palette_max=32 cluster_pitch=2 edge=0 chroma=0 alpha=binary"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|