Files
Sanctification/tools/card-production/card_workspace.py

276 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Scaffold, validate and promote versioned Sanctification card work."""
from pathlib import Path
import argparse, hashlib, json, os, re, shutil, sys, unicodedata
import xml.etree.ElementTree as ET
from PIL import Image
import numpy as np
REPO=next(p for p in Path(__file__).resolve().parents if (p/'docs/card-layer-pipeline.md').is_file())
RESOLUTIONS={'low':(500,700),'med':(1000,1400),'high':(2000,2800)}
PRINTINGS=('normal','boundless','borderless','textless')
STAGES=('art-review','assembly-review','approved')
def digest(path):return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def read(path):return json.loads(Path(path).read_text())
def write(path,value):Path(path).write_text(json.dumps(value,indent=2)+'\n')
def safe_name(value):
if not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*',value):raise ValueError(f'Use a lowercase stable slug: {value}')
return value
def safe_card_id(value):
if not re.fullmatch(r'[A-Z]+-[0-9]{3,}',value):raise ValueError(f'Use the exact catalogue ID, e.g. BP-045: {value}')
return value
def title_slug(title):
ascii_title=unicodedata.normalize('NFKD',title).encode('ascii','ignore').decode().lower()
slug=re.sub(r'[^a-z0-9]+','-',ascii_title).strip('-')
if not slug:raise ValueError('Title needs a readable ASCII folder slug')
return safe_name(slug)
def valid_folder_name(card_id,name):
prefix=safe_card_id(card_id)+'-'
if not name.startswith(prefix):raise ValueError(f'Folder must begin with {prefix}')
safe_name(name[len(prefix):]);return name
def profile_paths(root):
return [path for path in root.glob('*/card.json') if not path.parent.is_symlink()]
def resolve_folder_name(card_id):
safe_card_id(card_id)
roots=[REPO/'in-progress/cards',REPO/'artifacts/cards']
index=REPO/'in-progress/index.json'
if index.is_file():
entries=[entry for entry in read(index)['cards'] if entry['cardId']==card_id]
if len(entries)>1:raise ValueError(f'Duplicate catalogue ID in index: {card_id}')
if entries and entries[0].get('folderName'):
name=valid_folder_name(card_id,entries[0]['folderName'])
for root in roots:
path=root/name/'card.json'
if path.is_file() and read(path)['cardId']==card_id:return name
matches=[path for root in roots for path in profile_paths(root) if read(path)['cardId']==card_id]
names={valid_folder_name(card_id,read(path)['folderName']) for path in matches}
if len(names)!=1:raise ValueError(f'Expected one folder identity for {card_id}; found {len(names)}')
return names.pop()
def revision_candidates(name,revision):
candidates=[REPO/'in-progress/cards'/name/'revisions'/revision,
REPO/'artifacts/cards'/name,
REPO/'in-progress/cards'/name/'history'/f'accepted-{revision}']
return [path for path in candidates if (path/'manifest.json').is_file() and read(path/'manifest.json')['revision']==revision]
def remap_content(directory,source,destination):
path=directory/'card.json'
content=read(path)
# These are live build inputs; provenance and historical review reports stay intact.
for key in ['art','previousNormal']:
value=content.get(key)
if value:
target=REPO/value
if target.is_relative_to(source):content[key]=str((destination/target.relative_to(source)).relative_to(REPO))
write(path,content)
def rebase_linked_inputs(directory,source,destination):
for path in directory.rglob('*'):
if path.is_symlink():
target=path.resolve()
if target.is_relative_to(source):
mapped=destination/target.relative_to(source);path.unlink();path.symlink_to(os.path.relpath(mapped,path.parent))
elif path.suffix=='.svg' and path.is_file():
tree=ET.parse(path);changed=False
for e in tree.getroot().iter():
for attr in ['href','{http://www.w3.org/1999/xlink}href']:
value=e.get(attr)
if value and not value.startswith(('data:','#')):
target=(path.parent/value).resolve()
if target.is_relative_to(source):
e.set(attr,os.path.relpath(destination/target.relative_to(source),path.parent));changed=True
if changed:tree.write(path,encoding='unicode')
remap_content(directory,source,destination)
def resolve_revision(card_id,revision):
safe_card_id(card_id);safe_name(revision);name=resolve_folder_name(card_id)
found=revision_candidates(name,revision)
if len(found)!=1:raise ValueError(f'Expected one canonical revision for {card_id}/{revision}; found {len(found)}')
directory=found[0];manifest=read(directory/'manifest.json')
if manifest['cardId']!=card_id or manifest['revision']!=revision:raise ValueError('Revision identity does not match the request')
return directory
def scaffold(card_id,title,rarity,revision):
safe_card_id(card_id);safe_name(revision)
roots=[REPO/'in-progress/cards',REPO/'artifacts/cards']
existing=any(read(path)['cardId']==card_id for root in roots for path in profile_paths(root))
name=resolve_folder_name(card_id) if existing else f'{card_id}-{title_slug(title)}'
base=roots[0]/name;target=base/'revisions'/revision
if target.exists() or revision_candidates(name,revision):raise ValueError('Revision already exists; choose a new revision.')
for folder in ['source','review']:(target/folder).mkdir(parents=True,exist_ok=True)
for resolution in RESOLUTIONS:
for printing in PRINTINGS:(target/resolution/printing).mkdir(parents=True)
previous=next((read(root/name/'card.json') for root in roots if (root/name/'card.json').is_file()),None)
profile=previous or {'schemaVersion':1,'cardId':card_id,'title':title,'rarity':rarity,'folderName':name,'history':[]}
profile.update({'selectedRevision':revision,'stage':'art-review','approval':None});write(base/'card.json',profile)
write(target/'card.json',{'cardId':card_id,'title':title,'name':title,'rarity':rarity,'folderName':name,'artApproval':None})
write(target/'manifest.json',{'schemaVersion':1,'cardId':card_id,'folderName':name,'revision':revision,'stage':'art-review','approval':None,'content':'card.json','files':{},'compatibilityAliases':[]})
refresh(target);update_index();return target
def check_exports(directory,require_complete=True):
exports={}
for resolution,size in RESOLUTIONS.items():
files={}
for printing in PRINTINGS:
expected=['card.png','finish-mask.png']+(['text-mask.png'] if printing in ['normal','borderless'] else [])
for filename in expected:
if require_complete and not (directory/resolution/printing/filename).is_file():raise ValueError(f'Missing {resolution}/{printing}/{filename}')
for path in sorted((directory/resolution/printing).glob('*')):
if not path.is_file():continue
record={'sha256':digest(path)}
if path.suffix=='.png':
image=Image.open(path).convert('RGBA')
if image.size!=size:raise ValueError(f'Wrong dimensions: {path}')
a=np.asarray(image)
if np.any(a[:,:,3]!=255):raise ValueError(f'Non-opaque export: {path}')
if path.name.endswith('mask.png') and (not np.array_equal(a[:,:,0],a[:,:,1]) or not np.array_equal(a[:,:,1],a[:,:,2])):raise ValueError(f'Non-grayscale data map: {path}')
record['dimensions']=list(size)
elif path.suffix=='.svg':
for e in ET.parse(path).getroot().iter():
for attr in ['href','{http://www.w3.org/1999/xlink}href']:
value=e.get(attr)
if value and not value.startswith(('data:','#')) and not (path.parent/value).is_file():raise ValueError(f'Broken SVG image link: {path}: {value}')
files[str(path.relative_to(directory/resolution))]=record
exports[resolution]={'schemaVersion':1,'dimensions':list(size),'files':files}
return exports
def refresh(directory,allow_approved=False):
manifest=read(directory/'manifest.json')
if manifest['stage']=='approved' and not allow_approved:raise ValueError('Approved revision is frozen; create a new working revision.')
export_records=check_exports(directory,manifest['stage']!='art-review')
for label,record in export_records.items():write(directory/label/'manifest.json',record)
# Imported legacy validation is a per-revision snapshot, not a mutable shared sidecar.
if manifest.get('importedFrom')=='spikes/tier-layer-library-proof':
proof=REPO/manifest['importedFrom'];source=proof/'output/validation.json';validation=read(source)
entry=next(c for c in validation['cards'] if c['tier']==read(directory/'card.json')['tier'])
write(directory/'review/build-validation.json',{'status':validation['status'],'importedFrom':str(source.relative_to(REPO)),'sourceReportSHA256':digest(source),'card':entry,'finishRecipe':validation['finishRecipe'],'movingLight':validation['visualReview']['movingLight']})
files={}
for path in sorted(directory.rglob('*')):
if path.is_file() and path.name!='manifest.json' and not path.is_relative_to(directory/'review') and '__pycache__' not in path.parts:
files[str(path.relative_to(directory))]={'sha256':digest(path)}
for label in RESOLUTIONS:files[f'{label}/manifest.json']={'sha256':digest(directory/label/'manifest.json')}
manifest['files']=files
card=read(directory/'card.json')
if card.get('layerVersion'):
sys.path.insert(0,str(REPO/'artifacts/layers'));from library import load_tier
shared=load_tier(card['tier'],card['layerVersion'])
manifest['sharedLayers']={'tier':card['tier'],'version':card['layerVersion'],'manifestSHA256':shared['manifestSHA256']}
manifest['sharedFontManifestSHA256']=digest(REPO/'fonts/manifest.json')
write(directory/'manifest.json',manifest)
return manifest
def validate(directory):
manifest=read(directory/'manifest.json')
if manifest['stage'] not in STAGES:raise ValueError('Unknown revision stage')
for relative,record in manifest['files'].items():
if digest(directory/relative)!=record['sha256']:raise ValueError(f'Changed revision file: {relative}')
if manifest.get('sharedLayers'):
sys.path.insert(0,str(REPO/'artifacts/layers'));from library import load_tier
shared=manifest['sharedLayers'];loaded=load_tier(shared['tier'],shared['version'])
if loaded['manifestSHA256']!=shared['manifestSHA256']:raise ValueError('Pinned shared layer manifest changed')
if digest(REPO/'fonts/manifest.json')!=manifest['sharedFontManifestSHA256']:raise ValueError('Pinned shared font manifest changed')
exports=check_exports(directory,manifest['stage']!='art-review')
for label,expected in exports.items():
if read(directory/label/'manifest.json')!=expected:raise ValueError(f'Stale resolution manifest: {label}')
return manifest
def relocate_revision(source,destination,aliases):
"""Retain linked inputs and editable SVG registration when changing directory depth."""
if destination.exists():raise ValueError('Approval destination already exists')
links=[];svgs=[]
for path in source.rglob('*'):
if path.is_symlink():links.append((path.relative_to(source),path.resolve()))
elif path.suffix=='.svg' and path.is_file():
tree=ET.parse(path);refs=[]
for e in tree.getroot().iter():
for attr in ['href','{http://www.w3.org/1999/xlink}href']:
value=e.get(attr)
if value and not value.startswith(('data:','#')):refs.append((e,attr,(path.parent/value).resolve()))
if refs:svgs.append((path.relative_to(source),tree,refs))
destination.parent.mkdir(parents=True,exist_ok=True);shutil.move(str(source),str(destination))
def moved(path):return destination/path.relative_to(source) if path.is_relative_to(source) else path
for relative,target in links:
slot=destination/relative;slot.unlink();slot.symlink_to(os.path.relpath(moved(target),slot.parent),target_is_directory=moved(target).is_dir())
for relative,tree,refs in svgs:
path=destination/relative
for e,attr,target in refs:e.set(attr,os.path.relpath(moved(target),path.parent))
ET.register_namespace('','http://www.w3.org/2000/svg');ET.register_namespace('xlink','http://www.w3.org/1999/xlink');tree.write(path,encoding='unicode')
remap_content(destination,source,destination)
for alias in aliases:
slot=REPO/alias['alias'];target=destination/alias['target']
if not slot.is_symlink():raise ValueError(f'Expected compatibility symlink: {slot}')
slot.unlink();slot.symlink_to(os.path.relpath(target,slot.parent))
return destination
def approve(card_id,revision,note):
if not note.strip():raise ValueError('Record the user’s explicit approval wording')
name=resolve_folder_name(card_id)
profile_path=REPO/'in-progress/cards'/name/'card.json'
if read(profile_path)['selectedRevision']!=revision:raise ValueError('Select this exact revision in the card profile before approving it')
source=resolve_revision(card_id,revision);manifest=validate(source)
if manifest['stage']!='assembly-review':raise ValueError('Only an assembly-review revision can be approved')
aliases=manifest.get('compatibilityAliases',[])
for alias in aliases:
if not (REPO/alias['alias']).is_symlink():raise ValueError('Compatibility alias no longer exists')
target=REPO/'artifacts/cards'/name
if target.exists():
previous=validate(target)
if previous['stage']!='approved':raise ValueError('Existing artifact is not an approved card')
archive=REPO/'in-progress/cards'/name/'history'/f"accepted-{previous['revision']}"
if archive.exists():raise ValueError('Previous acceptance archive already exists')
# Pin incoming references to the old accepted artwork before replacing its location.
rebase_linked_inputs(source,target,archive)
relocate_revision(target,archive,previous.get('compatibilityAliases',[]))
refresh(archive,allow_approved=True)
target=relocate_revision(source,target,aliases)
manifest['stage']='approved';manifest['approval']={'by':'user','note':note};write(target/'manifest.json',manifest)
base=REPO/'in-progress/cards'/name;profile=read(base/'card.json');profile.update({'selectedRevision':revision,'stage':'approved','approval':manifest['approval']});write(base/'card.json',profile)
content=read(target/'card.json')
for key in ['schemaVersion','selectedRevision','stage','approval','rarity']:
if key in profile:content[key]=profile[key]
write(target/'card.json',content)
refresh(target,allow_approved=True);update_index();return target
def update_index():
profiles={}
# Working selection takes precedence when an older approved release also exists.
for root in [REPO/'artifacts/cards',REPO/'in-progress/cards']:
seen=set()
for path in profile_paths(root):
profile=read(path);card_id=safe_card_id(profile['cardId']);name=valid_folder_name(card_id,profile['folderName'])
if card_id in seen:raise ValueError(f'Duplicate catalogue ID: {card_id}')
if path.parent.name!=name:raise ValueError('Profile folderName differs from its directory')
seen.add(card_id)
if card_id in profiles and profiles[card_id]['folderName']!=name:raise ValueError('Working and approved folder identities disagree')
profiles[card_id]=profile
cards=[]
for card_id,profile in sorted(profiles.items()):
name=profile['folderName'];revision=safe_name(profile['selectedRevision'])
found=revision_candidates(name,revision)
if len(found)!=1:raise ValueError(f'Expected one selected revision for {card_id}')
directory=found[0];manifest=read(directory/'manifest.json')
if manifest['cardId']!=card_id:raise ValueError('Selected revision identity mismatch')
cards.append({'cardId':card_id,'title':profile['title'],'folderName':name,'rarity':profile['rarity'],'selectedRevision':revision,'stage':manifest['stage'],'path':str(directory.relative_to(REPO))})
write(REPO/'in-progress/index.json',{'schemaVersion':2,'cards':cards});return cards
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__);sub=parser.add_subparsers(dest='command',required=True)
for command in ['new','refresh','validate','approve']:
p=sub.add_parser(command);p.add_argument('--card',required=True);p.add_argument('--revision',required=True)
if command=='new':p.add_argument('--title',required=True);p.add_argument('--rarity',choices=['Common','Uncommon','Rare','Extraordinary','Legendary'],required=True)
if command=='approve':p.add_argument('--approval-note',required=True)
sub.add_parser('status');args=parser.parse_args()
try:
if args.command=='status':
for card in update_index():print(f'{card["cardId"]} · {card["title"]}: {card["stage"]} · {card["selectedRevision"]} · {card["path"]}')
elif args.command=='new':print(scaffold(args.card,args.title,args.rarity,args.revision))
elif args.command=='approve':print(approve(args.card,args.revision,args.approval_note))
elif args.command=='refresh':print(refresh(resolve_revision(args.card,args.revision))['stage']);update_index()
else:print(validate(resolve_revision(args.card,args.revision))['stage'])
except (ValueError,FileNotFoundError,KeyError) as error:parser.exit(1,f'{error}\n')