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

410 lines
26 KiB
Python
Raw Permalink 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]
return [path for path in candidates if (path/'manifest.json').is_file() and read(path/'manifest.json')['revision']==revision]
def historical_revision_paths(name,revision):
base=REPO/'in-progress/cards'/name/'history'
return [base/'revisions'/revision,base/f'accepted-{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 internalize_linked_inputs(directory,source):
"""Copy live inputs out of a package that is about to become compact history."""
source=source.resolve();replacements={};changed=False
def is_source_file(path):
path=path.resolve()
return path.is_file() and path.is_relative_to(source)
for path in sorted(directory.rglob('*')):
if not path.is_symlink():continue
target=path.resolve()
if not is_source_file(target):continue
path.unlink();shutil.copy2(target,path);replacements[target]=path;changed=True
def local_copy(target):
nonlocal changed
target=target.resolve()
if target in replacements:return replacements[target]
preferred=directory/'source'/target.name
if preferred.exists() and preferred.is_file() and digest(preferred)==digest(target):
replacements[target]=preferred;return preferred
if preferred.exists():
source_revision=read(source/'manifest.json')['revision']
preferred=directory/'source'/'retained'/source_revision/target.relative_to(source)
preferred.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(target,preferred)
replacements[target]=preferred;changed=True;return preferred
content_path=directory/'card.json';content=read(content_path);content_changed=False
for key in ['art','previousNormal']:
value=content.get(key)
if not value:continue
target=(REPO/value).resolve()
if is_source_file(target):
content[key]=str(local_copy(target).relative_to(REPO));content_changed=True
if content_changed:write(content_path,content)
for path in sorted(directory.rglob('*.svg')):
tree=ET.parse(path);svg_changed=False
for e in tree.getroot().iter():
for attr in ['href','{http://www.w3.org/1999/xlink}href']:
value=e.get(attr)
if not value or value.startswith(('data:','#')):continue
target=(path.parent/value).resolve()
if is_source_file(target):
e.set(attr,os.path.relpath(local_copy(target),path.parent));svg_changed=True
if svg_changed:
ET.register_namespace('','http://www.w3.org/2000/svg');ET.register_namespace('xlink','http://www.w3.org/1999/xlink')
tree.write(path,encoding='unicode');changed=True
return changed
def compact_revision(directory,reason):
"""Retain text provenance and one low reference face per printing."""
manifest_path=directory/'manifest.json'
if not manifest_path.is_file():raise ValueError(f'Cannot compact a revision without a manifest: {directory}')
manifest=read(manifest_path);full_manifest=directory/'full-manifest.json'
if full_manifest.exists():raise ValueError(f'Revision is already compact: {directory}')
references=sorted((directory/'low').glob('*/card.png'))
if not references:
art=directory/'source/art-master.png'
if art.is_file():
preview=directory/'review/art-reference.png';preview.parent.mkdir(parents=True,exist_ok=True)
with Image.open(art) as image:
image=image.convert('RGBA');image.thumbnail(RESOLUTIONS['low'],Image.Resampling.LANCZOS)
image.save(preview,optimize=True)
references=[preview]
manifest_path.replace(full_manifest)
for resolution in ['med','high']:
path=directory/resolution
if path.exists():shutil.rmtree(path)
low=directory/'low'
if low.exists():
for path in sorted(low.rglob('*'),reverse=True):
if path.is_file() or path.is_symlink():
if not (path.name=='card.png' and path.parent.name in PRINTINGS):path.unlink()
elif path.is_dir() and not any(path.iterdir()):path.rmdir()
visual_suffixes={'.png','.jpg','.jpeg','.webp','.tif','.tiff','.gif','.bmp','.svg','.psd','.xcf'}
for area in ['source','review']:
root=directory/area
if not root.exists():continue
for path in sorted(root.rglob('*'),reverse=True):
if (path.is_file() or path.is_symlink()) and path.suffix.lower() in visual_suffixes and path not in references:path.unlink()
elif path.is_dir() and not any(path.iterdir()):path.rmdir()
retained_references=[str(path.relative_to(directory)) for path in references if path.is_file()]
record={'schemaVersion':1,'kind':'compact-card-revision','cardId':manifest['cardId'],
'folderName':manifest.get('folderName'),'revision':manifest['revision'],'originalStage':manifest['stage'],
'approval':manifest.get('approval'),'reason':reason,'originalManifest':'full-manifest.json',
'originalManifestSHA256':digest(full_manifest),'referenceResolution':'low','referenceImages':retained_references,
'retention':'Text provenance plus one 500 x 700 card face per available printing; full current acceptance lives in artifacts/cards.'}
write(directory/'record.json',record);return record
def add_history_entry(profile,revision,path,status):
base=REPO/'in-progress/cards'/profile['folderName'];relative=str(path.relative_to(base))
if not any(entry.get('revision')==revision and entry.get('path')==relative for entry in profile.setdefault('history',[])):
profile['history'].append({'revision':revision,'path':relative,'status':status})
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) or any(path.exists() for path in historical_revision_paths(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);base=REPO/'in-progress/cards'/name
profile_path=base/'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')
siblings=sorted(path for path in (base/'revisions').glob('*') if path.is_dir() and path!=source)
sibling_archives=[base/'history/revisions'/path.name for path in siblings]
if any(path.exists() for path in sibling_archives):raise ValueError('Superseded revision history destination already exists')
target=REPO/'artifacts/cards'/name;previous=None;archive=None
if target.exists():
previous=validate(target)
if previous['stage']!='approved':raise ValueError('Existing artifact is not an approved card')
archive=base/'history'/f"accepted-{previous['revision']}"
if archive.exists():raise ValueError('Previous acceptance archive already exists')
changed=False
for dependency in siblings+([target] if target.exists() else []):
changed=internalize_linked_inputs(source,dependency) or changed
if changed:
manifest=refresh(source);validate(source)
if target.exists():
relocate_revision(target,archive,previous.get('compatibilityAliases',[]))
refresh(archive,allow_approved=True);compact_revision(archive,'replaced by a newer accepted revision')
target=relocate_revision(source,target,aliases)
manifest['stage']='approved';manifest['approval']={'by':'user','note':note};write(target/'manifest.json',manifest)
profile=read(profile_path);profile.update({'selectedRevision':revision,'stage':'approved','approval':manifest['approval']})
if archive is not None:add_history_entry(profile,previous['revision'],archive,'superseded-acceptance-record')
for sibling,destination in zip(siblings,sibling_archives):
destination.parent.mkdir(parents=True,exist_ok=True);shutil.move(str(sibling),str(destination))
compact_revision(destination,f'superseded when {revision} was accepted')
add_history_entry(profile,read(destination/'record.json')['revision'],destination,'superseded-revision-record')
write(profile_path,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 compact_card_history(card_id):
"""Apply compact retention to history for a card that is already approved."""
name=resolve_folder_name(card_id);base=REPO/'in-progress/cards'/name;profile_path=base/'card.json'
profile=read(profile_path);target=REPO/'artifacts/cards'/name
manifest=validate(target)
if manifest['stage']!='approved' or profile.get('stage')!='approved':raise ValueError('History cleanup requires an approved card')
accepted=sorted(path for path in (base/'history').glob('accepted-*') if (path/'manifest.json').is_file())
working=sorted(path for path in (base/'revisions').glob('*') if (path/'manifest.json').is_file())
destinations=[base/'history/revisions'/path.name for path in working]
if any(path.exists() for path in destinations):raise ValueError('Superseded revision history destination already exists')
changed=False
for dependency in accepted+working:changed=internalize_linked_inputs(target,dependency) or changed
if changed:refresh(target,allow_approved=True)
validate(target)
records=[]
for archive in accepted:
record=compact_revision(archive,'replaced by the current accepted revision');records.append(record)
add_history_entry(profile,record['revision'],archive,'superseded-acceptance-record')
for source,destination in zip(working,destinations):
destination.parent.mkdir(parents=True,exist_ok=True);shutil.move(str(source),str(destination))
record=compact_revision(destination,f"superseded when {manifest['revision']} was accepted");records.append(record)
add_history_entry(profile,record['revision'],destination,'superseded-revision-record')
write(profile_path,profile);update_index()
return {'cardId':card_id,'acceptedRevision':manifest['revision'],'compacted':[record['revision'] for record in records]}
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)
compact_parser=sub.add_parser('compact');compact_parser.add_argument('--card',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=='compact':print(json.dumps(compact_card_history(args.card),indent=2))
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')