spriteforge/src/spriteforge/introspection.py

183 lines
12 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
import hashlib
import json
import math
from pathlib import Path
from typing import Any, Iterable
import numpy as np
from PIL import Image,ImageDraw
from .manifest import AssetEntry
from .palette import PalettePack,decode_sfp
from .reader import SfaFile,decode_sfa
@dataclass(frozen=True)
class ValidationIssue:
severity:str
asset_id:str
message:str
def __str__(self)->str:
target=f" {self.asset_id}" if self.asset_id else ""
return f"{self.severity.upper()}{target}: {self.message}"
class Catalog:
def __init__(self,build_dir:Path):
self.build_dir=build_dir.resolve();index_path=self.build_dir/"index.json"
try:self.index=json.loads(index_path.read_text(encoding="utf-8"))
except (OSError,json.JSONDecodeError) as error:raise ValueError(f"cannot read catalog {index_path}: {error}") from error
if self.index.get("version")!=1 or not isinstance(self.index.get("assets"),list):raise ValueError("unsupported or malformed index.json")
self.assets={item["id"]:item for item in self.index["assets"] if isinstance(item,dict) and isinstance(item.get("id"),str)}
if len(self.assets)!=len(self.index["assets"]):raise ValueError("duplicate or malformed assets in index.json")
def asset(self,asset_id:str)->dict[str,Any]:
try:return self.assets[asset_id]
except KeyError as error:raise ValueError(f"asset not found: {asset_id}") from error
def _file(self,name:str)->Path:
path=(self.build_dir/name).resolve()
if path.parent!=self.build_dir:raise ValueError("catalog contains unsafe file path")
return path
def sfa(self,asset_id:str)->SfaFile:
item=self.asset(asset_id);path=self._file(item["file"])
try:return decode_sfa(path.read_bytes())
except (OSError,ValueError) as error:raise ValueError(f"cannot read {asset_id}: {error}") from error
def palettes(self)->PalettePack:
palette=self.index.get("palette",{});name=palette.get("file") if isinstance(palette,dict) else palette
if not isinstance(name,str):raise ValueError("catalog has no palette file")
try:return decode_sfp(self._file(name).read_bytes())
except (OSError,ValueError) as error:raise ValueError(f"cannot read palettes: {error}") from error
def list_rows(catalog:Catalog,tag:str|None=None,backend:str|None=None)->list[dict[str,Any]]:
rows=[]
for item in catalog.assets.values():
if tag and tag not in item.get("tags",[]):continue
if backend and item.get("backend")!=backend:continue
sizes=item.get("sizes",[]);unique={tuple(size) for size in sizes}
size="mixed" if len(unique)>1 else (f"{sizes[0][0]}x{sizes[0][1]}" if sizes else "-")
rows.append({"id":item["id"],"backend":item.get("backend","?"),"frames":item.get("frames",0),
"dirs":item.get("directions",0),"size":size,"hash":item.get("hash","-")[:12],"status":item.get("status","?")})
return sorted(rows,key=lambda row:row["id"])
def table(rows:list[dict[str,Any]],columns:tuple[str,...])->str:
if not rows:return "(no assets)"
widths={column:max(len(column),*(len(str(row.get(column,""))) for row in rows)) for column in columns}
header=" ".join(column.upper().ljust(widths[column]) for column in columns)
divider=" ".join("-"*widths[column] for column in columns)
body=[" ".join(str(row.get(column,"")).ljust(widths[column]) for column in columns) for row in rows]
return "\n".join([header,divider,*body])
def describe_text(catalog:Catalog,asset_id:str)->str:
item=catalog.asset(asset_id);lines=[f"id: {asset_id}"]
for key in ("backend","backend_version","postprocess_version","status","hash","content_hash","file","bytes","frames","directions","animation","animations","layer_count","layer_orders","warnings","sizes","pivots","durations_ms","tags","source_manifest"):
if key in item:lines.append(f"{key}: {json.dumps(item[key],ensure_ascii=False) if isinstance(item[key],(list,dict)) else item[key]}")
lines.append("spec:")
for key,value in sorted(item.get("spec",{}).items()):lines.append(f" {key}: {json.dumps(value,ensure_ascii=False,sort_keys=True)}")
return "\n".join(lines)
def ascii_preview(catalog:Catalog,asset_id:str,frame_index:int=0,columns:int=40)->str:
asset=catalog.sfa(asset_id)
if not 0<=frame_index<len(asset.frames):raise ValueError(f"frame index out of range: 0..{len(asset.frames)-1}")
frame=asset.frames[frame_index];mask=np.frombuffer(frame.indices,dtype=np.uint8).reshape(frame.height,frame.width)!=0
scale=max(1,math.ceil(frame.width/columns));out_w=math.ceil(frame.width/scale);out_h=math.ceil(frame.height/scale)
lines=[f"{asset_id} frame={frame_index} size={frame.width}x{frame.height} pivot={frame.pivot_x},{frame.pivot_y}"]
for by in range(out_h):
chars=[]
for bx in range(out_w):
block=mask[by*scale:min((by+1)*scale,frame.height),bx*scale:min((bx+1)*scale,frame.width)]
ratio=float(block.mean()) if block.size else 0
chars.append("#" if ratio>.5 else "+" if ratio else " ")
lines.append("".join(chars).rstrip())
return "\n".join(lines)
def palette_text(catalog:Catalog,name:str)->str:
pack=catalog.palettes()
try:rgb=pack.palettes[name]
except KeyError as error:raise ValueError(f"palette not found: {name}; available: {', '.join(sorted(pack.palettes))}") from error
lines=["INDEX HEX NAME","----- ------- --------"]
for index in range(256):
r,g,b=rgb[index*3:index*3+3];label="transparent" if index==0 else "-"
lines.append(f"{index:5d} #{r:02X}{g:02X}{b:02X} {label}")
return "\n".join(lines)
def stats_text(catalog:Catalog)->str:
assets=list(catalog.assets.values());total=sum(int(item.get("bytes",0)) for item in assets)
backends:dict[str,int]={}
for item in assets:backends[item.get("backend","?")]=backends.get(item.get("backend","?"),0)+1
lines=[f"assets: {len(assets)}",f"frames: {sum(int(x.get('frames',0)) for x in assets)}",f"asset_bytes: {total}",
f"normal_coverage: {sum(bool(x.get('streams',{}).get('normals')) for x in assets)}/{len(assets)}",
f"depth_coverage: {sum(bool(x.get('streams',{}).get('depth')) for x in assets)}/{len(assets)}",
f"warnings: {sum(len(x.get('warnings',[])) for x in assets)}","backends:"]
lines.extend(f" {name}: {count}" for name,count in sorted(backends.items()))
return "\n".join(lines)
def validate_catalog(catalog:Catalog,manifest_assets:Iterable[AssetEntry]=())->list[ValidationIssue]:
issues=[]
expected={entry.id for entry in manifest_assets}
for missing in sorted(expected-set(catalog.assets)):issues.append(ValidationIssue("error",missing,"declared in manifest but missing from catalog"))
for asset_id,item in sorted(catalog.assets.items()):
for warning in item.get("warnings",[]):issues.append(ValidationIssue("warning",asset_id,warning))
path=catalog._file(item.get("file",""))
if not path.is_file():issues.append(ValidationIssue("error",asset_id,"SFA file is missing"));continue
blob=path.read_bytes();actual=hashlib.sha256(blob).hexdigest()
if actual!=item.get("content_hash"):issues.append(ValidationIssue("error",asset_id,"file hash differs from index.json"))
try:sfa=decode_sfa(blob)
except ValueError as error:issues.append(ValidationIssue("error",asset_id,f"invalid SFA: {error}"));continue
if len(sfa.frames)!=item.get("frames"):issues.append(ValidationIssue("error",asset_id,"frame count differs from index.json"))
if item.get("layer_orders") is not None and [list(x) for x in sfa.layer_orders]!=item.get("layer_orders"):
issues.append(ValidationIssue("error",asset_id,"layer order differs from index.json"))
names=[entry[0] for entry in sfa.animations]
indexed_names=[entry.get("name") for entry in item.get("animations",[])]
if indexed_names and names!=indexed_names:issues.append(ValidationIssue("error",asset_id,"animation table differs from index.json"))
# Paper-doll modules deliberately share the body's ground pivot, so a
# small helmet/weapon/vest bbox may be far away from its own pivot.
# That is alignment data, not corruption. Standalone sprites retain
# the heuristic, summarized once instead of flooding the report.
external_pivot="paperdoll_layer" in item.get("tags",[])
far_pivots=[]
for number,frame in enumerate(sfa.frames):
if not any(frame.indices):issues.append(ValidationIssue("error",asset_id,f"frame {number} is empty"))
if frame.duration_ms<=0:issues.append(ValidationIssue("error",asset_id,f"frame {number} has zero duration"))
if not external_pivot and (abs(frame.pivot_x)>frame.width*4 or abs(frame.pivot_y)>frame.height*4):
far_pivots.append(number)
if len(frame.indices)!=frame.width*frame.height:issues.append(ValidationIssue("error",asset_id,f"frame {number} pixel count mismatch"))
if far_pivots:
sample=", ".join(str(number) for number in far_pivots[:8])
suffix="..." if len(far_pivots)>8 else ""
issues.append(ValidationIssue("warning",asset_id,
f"{len(far_pivots)} frame(s) have pivot far outside bbox: {sample}{suffix}"))
try:catalog.palettes()
except ValueError as error:issues.append(ValidationIssue("error","",str(error)))
return issues
def diff_text(catalog:Catalog,asset_id:str,against:str)->str:
current=catalog.asset(asset_id);history=catalog.build_dir/".sfmeta"/asset_id
matches=sorted(history.glob(f"{against}*.json")) if history.is_dir() else []
if len(matches)!=1:raise ValueError("comparison hash not found or ambiguous")
old=json.loads(matches[0].read_text(encoding="utf-8"))
keys=("hash","content_hash","backend_version","postprocess_version","frames","directions","sizes","pivots","durations_ms","spec","bytes")
changed=[key for key in keys if old.get(key)!=current.get(key)]
if not changed:return f"{asset_id}: no changes against {against}"
lines=[f"{asset_id}: {len(changed)} field(s) changed against {against}"]
for key in changed:lines.append(f"{key}: {json.dumps(old.get(key),ensure_ascii=False,sort_keys=True)} -> {json.dumps(current.get(key),ensure_ascii=False,sort_keys=True)}")
return "\n".join(lines)
def contact_sheet(catalog:Catalog,output:Path,asset_ids:list[str]|None=None)->None:
ids=asset_ids or sorted(catalog.assets);pack=catalog.palettes();rgb=np.frombuffer(pack.palettes[sorted(pack.palettes)[0]],dtype=np.uint8).reshape(256,3)
rendered=[]
for asset_id in ids:
frame=catalog.sfa(asset_id).frames[0];indices=np.frombuffer(frame.indices,dtype=np.uint8).reshape(frame.height,frame.width)
rgba=np.zeros((frame.height,frame.width,4),dtype=np.uint8);rgba[...,:3]=rgb[indices];rgba[...,3]=np.where(indices!=0,255,0)
rendered.append((asset_id,Image.fromarray(rgba,"RGBA")))
if not rendered:raise ValueError("no assets for contact sheet")
cell_w=max(image.width for _,image in rendered)+16;cell_h=max(image.height for _,image in rendered)+32
cols=math.ceil(math.sqrt(len(rendered)));rows=math.ceil(len(rendered)/cols)
sheet=Image.new("RGBA",(cell_w*cols,cell_h*rows),(24,24,24,255));draw=ImageDraw.Draw(sheet)
for index,(name,image) in enumerate(rendered):
x=(index%cols)*cell_w+8;y=(index//cols)*cell_h+20;sheet.alpha_composite(image,(x,y));draw.text((x,4+(index//cols)*cell_h),name,fill=(240,240,240,255))
output.parent.mkdir(parents=True,exist_ok=True);sheet.save(output,"PNG")