183 lines
12 KiB
Python
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")
|