#!/usr/bin/env python3 """Bounded, non-overwriting realistic quadruped conversion. No live app access.""" import argparse,base64,fcntl,hashlib,json,math,os,re,shutil,subprocess,sys,time from pathlib import Path from datetime import datetime from zoneinfo import ZoneInfo import requests HOME=Path(__file__).resolve().parent BLENDER=os.environ.get('FORGE_ANIMAL_BLENDER','/root/blender-spike/blender-5.2.0-linux-x64/blender') KINDS={'wolf':['wolf','wolves'],'fox':['fox','foxes'],'horse':['horse','horses'],'dog':['hound','hounds'],'bull':['bull','bulls'],'cow':['cattle'],'deer':['deer'],'stag':['stag','stags']} def stamp():return datetime.now(ZoneInfo('Europe/Paris')).strftime('%Y-%m-%d %H:%M:%S CET') def write(p,v):p.write_text(json.dumps(v,indent=2)) def sha(p):return hashlib.sha256(p.read_bytes()).hexdigest() def command(argv,log,timeout=600): with log.open('w') as f:r=subprocess.run(list(map(str,argv)),stdout=f,stderr=subprocess.STDOUT,timeout=timeout) if r.returncode:raise RuntimeError(f'{Path(str(argv[0])).name} failed ({r.returncode}); see {log.name}') def blender(script,*args,log):command([BLENDER,'-b','--python-exit-code','1','--python',HOME/script,'--',*args],log) def copy(src,dst): with Path(src).open('rb') as a,dst.open('xb') as b:shutil.copyfileobj(a,b) def catalog_metadata(source,kind,animal,height): # Trust a source catalog only when its named GLB is the exact input bytes. source=Path(source).resolve();metadata=None for parent in (source.parent,source.parent.parent): catalog=parent/'catalog_entries.json' if not catalog.exists():continue for e in json.loads(catalog.read_text()): if Path(e.get('file','')).name==source.name: asset=parent/e['file'] if not asset.exists():asset=parent/'glb'/e['file'] if asset.exists() and sha(asset)==sha(source):metadata=e;break if not metadata or not metadata.get('licence') or not metadata.get('credit'):raise ValueError('Input rig needs a matching source catalog with licence and credit') attack='headbutts' if kind in ['bull','cow','stag','deer'] else 'kicks' if kind=='horse' else 'bites' look={'wolf':'grey fur and a bushy tail','bull':'dark fur and curved horns','fox':'red fur and a bushy tail','horse':'a natural coat and flowing tail'}.get(kind,'a natural coat') return {'licence':metadata['licence'],'credit':'Forge generated mesh; input rig and animations: '+metadata['credit'],'about':f'A realistic {animal} with {look}, {height:.2f} metres tall, that walks, gallops, {attack} and falls when it dies.','good_for':{'wolf':['forests','mountains','snowfields'],'bull':['farms','pastures','rural villages'],'fox':['woodlands','meadows','farms'],'horse':['farms','grasslands','rural roads']}.get(kind,['forests','grasslands','wilderness']),'tint':[]} def reference(a,d): from reference_gate import check prompt=f"Photorealistic full-body {a.animal}, head on LEFT and tail on RIGHT, white background without shadows. Use the supplied animal for proportions only. CHANGE THE POSE: tail raised and held straight out horizontally behind the rump, NEVER hanging down, NEVER touching a leg. Large clear white-background gap between the entire free tail and BOTH hind legs. Four legs visible separately, stagger all four feet horizontally with clear white gaps between every pair, slight three-quarter side view. Natural anatomy, fur, eyes, ears, muzzle, paws or hooves. No extra limbs, no props, no text. Tail pose and leg positions must differ from the input when needed to expose these gaps." for attempt in range(1,4): trial=d/f'reference-attempt-{attempt}';trial.mkdir() if a.ref: copy(a.ref,trial/'reference.png');write(trial/'request.json',{'mode':'operator reference','source_sha256':sha(Path(a.ref))}) else: data={'prompt':prompt,'size':'1024x1024','response_format':'b64_json','seed':a.seed+attempt-1,'subject_description':a.animal,'identity_lock':'false'} write(trial/'request.json',{'mode':'pose-separated image edit',**data,'input_sha256':sha(d/'neutral.png')}) with (d/'neutral.png').open('rb') as f:r=requests.post('http://127.0.0.1:8093/v1/images/edits',files={'image':('neutral.png',f,'image/png')},data=data,timeout=240) r.raise_for_status();response=r.json();item=response['data'][0] if 'b64_json' in item:raw=base64.b64decode(item['b64_json']) else: u=item['url'].replace('http://forge-vertex:8080','http://127.0.0.1:8093');rr=requests.get(u,timeout=60);rr.raise_for_status();raw=rr.content (trial/'reference.png').write_bytes(raw) result=check(trial/'reference.png',trial) print('REFERENCE',attempt,json.dumps(result),flush=True) if result['pass']: copy(trial/'reference.png',d/'reference.png');write(d/'reference-gate.json',result);write(d/'reference-provenance.json',{'attempt':attempt,'sha256':sha(d/'reference.png'),'created':stamp()});return if a.ref:break prompt+=' IMPORTANT: previous silhouette FAILED: '+', '.join(k for k,v in result['checks'].items() if not v)+'. Increase these gaps substantially.' raise ValueError('Reference rejected before any 3D spend: silhouette gaps failed; see reference-attempt directories') def generate(a,d): if not json.loads((d/'reference-gate.json').read_text()).get('pass'):raise ValueError('Reference gate must pass before 3D') if a.mesh:copy(a.mesh,d/'raw.glb');write(d/'job-final.json',{'status':'replayed','source':str(a.mesh),'sha256':sha(d/'raw.glb')});return # The lock serializes this CLI; the service queue also accounts for other clients. with (HOME/'gpu.lock').open('a') as lock: fcntl.flock(lock,fcntl.LOCK_EX);deadline=time.monotonic()+1800 while True: q=requests.get('http://127.0.0.1:8087/v1/3d/queue',timeout=20);q.raise_for_status();queue=q.json();write(d/'queue.json',queue) if not queue['depth']:break if time.monotonic()>deadline:raise TimeoutError('GPU queue did not clear within 30 minutes') time.sleep(8) with (d/'reference.png').open('rb') as f:r=requests.post('http://127.0.0.1:8087/v1/3d/jobs',files={'image':('reference.png',f,'image/png')},data={'seed':a.seed,'max_faces':14000,'bake':'false'},timeout=45) r.raise_for_status();sub=r.json();write(d/'submission.json',sub);job=sub.get('job_id',sub.get('id'));print('3D job',job,flush=True) while True: r=requests.get('http://127.0.0.1:8087/v1/3d/jobs/'+job,timeout=30);r.raise_for_status();result=r.json();write(d/'job-final.json',result) if result['status'] in ('done','failed','cancelled'):break if time.monotonic()>deadline:raise TimeoutError('3D job timed out; job ID retained, service job was not cancelled') time.sleep(8) if result['status']!='done':raise RuntimeError('3D job failed: '+str(result.get('error','unknown'))) result=result['result'];item=result['data'][0] if 'data' in result else result rr=requests.get('http://127.0.0.1:8087/cache/'+item['url'].split('/')[-1],timeout=120);rr.raise_for_status();(d/'raw.glb').write_bytes(rr.content) def qa(d,entry,tag):command(['node',HOME/'qa.mjs',d,d/entry,tag],d/(tag+'-qa.log'),timeout=420) def gates(d): r=json.loads((d/'final-runtime.json').read_text());src=json.loads((d/'input.json').read_text());tail=json.loads((d/'tail.json').read_text());audit=json.loads((d/'audit.json').read_text()) fragments=json.loads((d/'fragments.json').read_text());post=json.loads((d/'tail.json').read_text()).get('fragments_after',{}) evidence={name:sha(d/name) for name in ['final-rear.png','final-belly.png','final-side.png','final-tail-clips.png','final-tail-close.png','final-bellyClay.png','final-rearClay.png'] if (d/name).exists()} metrics={'evidence':evidence,'fragments':fragments,'fragments_after':post,'candidate_sha256':sha(d/'candidate.glb'),'bytes':(d/'candidate.glb').stat().st_size,'triangles':r['triangles'],'browser_errors':r['browser_errors'],'skin_errors':r['errors'],'tail':r['tail'],'contact':r['contact'],'foot_contact':r.get('foot_contact',{}),'speeds_m_s':{k:v['speed'] for k,v in r['speeds'].items()},'bones':r['bones'],'source_height_m':src['height_m'],'output_height_m':r['height_m'],'clips':[{'name':c['name'],'duration':c['duration'],'poses':c['poses_played']} for c in r['clips']],'topology':tail,'geometry_audit':audit} finite=lambda x:isinstance(x,(int,float)) and math.isfinite(x) checks={'evidence':len(evidence)==7,'fragments':fragments.get('pass') is True and post.get('pass') is True,'scale':abs(r['height_m']/src['height_m']-1)<=.05,'skeleton':all(n.replace('.','') in r['bones'] for n in src['bones']),'size':metrics['bytes']<=1500000,'triangles':6000<=r['triangles']<=20000,'browser':not r['browser_errors'] and not r['errors'],'clips':len(r['clips'])==len(src['clip_durations']) and all(abs(src['clip_durations'].get(c['name'],-100)-c['duration'])<.002 for c in r['clips']),'tail_region_present':r['tail']['region_method'].startswith('exported geometric') and r['tail']['tail_vertices']>20,'tail_weights':r['tail']['max_tail_on_non_tail']==0 and r['tail']['max_leg_on_tail']==0,'closed_cuts':all(m['boundary_edges_after']<=m['boundary_edges_before'] for m in tail['meshes']),'ground':set(r['contact'])=={'walk','run','attack','die'} and all(finite(v['lowest_surface_m']) and v['lowest_surface_m']>=-.02 for v in r['contact'].values()),'foot_contact':set(r.get('foot_contact',{}))=={'walk','run'} and all(len(v['feet'])==4 and all(f['samples']>=3 for f in v['feet']) and finite(v['max_abs_sole_height_m']) and v['max_abs_sole_height_m']<=.025 and finite(v['max_stance_slip_rms_m']) and v['max_stance_slip_rms_m']<=.04 for v in r.get('foot_contact',{}).values()),'speeds':set(metrics['speeds_m_s'])=={'walk','run'} and all(finite(v) and v>0 for v in metrics['speeds_m_s'].values()),'geometry':audit.get('findings')==[] and audit.get('zero_area_triangles')==0 and audit.get('nonfinite_attribute_components')==0} return {'created':stamp(),'numeric_pass':all(checks.values()),'checks':checks,'metrics':metrics,'visual_verdict':'UNREVIEWED','shipped':False} def report(d,v,steps): lines=['# Realistic animal pipeline', '', 'Written '+stamp(),'', 'Visual verdict: '+v.get('visual_verdict','UNREVIEWED'), 'Released: '+str(v.get('shipped',False)), '', '## Gates'] for k,val in v.get('checks',{}).items():lines.append(f'- {k}: {"PASS" if val else "FAIL"}') if v.get('visual_note'):lines+=['','Review: '+v['visual_note']] if v.get('failure'):lines+=['','Failure: '+v['failure']] lines+=['','## Stages']+[f'- {s["name"]}: {s["seconds"]:.1f} seconds' for s in steps] lines+=['','## Limits','- Only the documented Quaternius quadruped bone names and +Z-facing GLBs are supported.','- Reference gate measures projected lower-leg and distal-tail gaps, not hidden 3D surfaces.','- Raised reference tails can be mapped onto a downward donor rest chain; inspect the resulting bends.','- Tail cutting is disabled; fused source anatomy must be regenerated. Existing UVs are preserved.','- Eye placement and horn shape require visual inspection, not a numerical proof.','- Flat-floor corrections change source keyframes but preserve all clip names and durations.','- Browser checks use software WebGL; no game, combat, slope or performance testing.','- No asset is released until numeric gates pass and a visual review is recorded.'] (d/'report.md').write_text('\n'.join(lines)+'\n') def main(): p=argparse.ArgumentParser(description=__doc__);p.add_argument('--in',dest='input',type=Path,required=True);p.add_argument('--animal',required=True);p.add_argument('--id',required=True);p.add_argument('--out',type=Path,required=True);p.add_argument('--ref',type=Path);p.add_argument('--seed',type=int,default=47017);p.add_argument('--mesh',type=Path,help='Replay a previously generated static mesh, explicitly skipping the paid 3D job');p.add_argument('--repair',type=Path,help='Repair an existing realistic rigged GLB, explicitly skipping image and 3D generation');p.add_argument('--review',choices=['GOOD','OK','BAD'],help='Review an existing completed run; never regenerates');p.add_argument('--recheck',action='store_true',help='Re-run final audit and browser gates without changing GLBs');p.add_argument('--review-note',default='');a=p.parse_args() if not re.fullmatch(r'[a-z][a-z0-9-]{2,79}',a.id):p.error('id must be a lowercase file-safe slug') matches=[k for k in KINDS if k in a.animal.lower().split()] if len(matches)!=1:p.error('Supported animal nouns: '+', '.join(KINDS)) kind=matches[0];d=a.out.resolve();steps=[];start=time.monotonic();v={} if a.recheck: cfg=json.loads((d/'run.json').read_text()) if cfg['id']!=a.id or cfg['animal']!=a.animal or cfg['source_sha256']!=sha(a.input):raise ValueError('Recheck identity does not match run') old=json.loads((d/'verdict.json').read_text()) if old.get('shipped'):raise ValueError('Released runs are immutable') steps=json.loads((d/'stages.json').read_text());qa(d,'candidate-entry.json','final');command(['python3',HOME/'audit_glb.py',d/'candidate.glb','--strict'],d/'audit.json');v=gates(d);v['elapsed_seconds']=old.get('elapsed_seconds');write(d/'verdict.json',v);report(d,v,steps);print(json.dumps({'numeric_pass':v['numeric_pass'],'failed':[k for k,x in v['checks'].items() if not x]}));return 0 if v['numeric_pass'] else 2 if a.review: v=json.loads((d/'verdict.json').read_text());steps=json.loads((d/'stages.json').read_text());cfg=json.loads((d/'run.json').read_text()) if cfg['id']!=a.id or cfg['animal']!=a.animal or cfg['source_sha256']!=sha(a.input):raise ValueError('Review identity does not match run') if not a.review_note:raise ValueError('--review-note must describe the inspected renders') if a.review!='BAD' and (not (d/'candidate.glb').exists() or sha(d/'candidate.glb')!=v.get('metrics',{}).get('candidate_sha256')):raise ValueError('Candidate changed after gate checks; use --recheck') if v.get('shipped'):raise FileExistsError('Run already released; immutable output') if a.review!='BAD' and (len(v.get('metrics',{}).get('evidence',{}))!=7 or any(not (d/n).exists() or sha(d/n)!=h for n,h in v['metrics']['evidence'].items())):raise ValueError('Missing or changed visual evidence; recheck required') v.update(visual_verdict=a.review,visual_note=a.review_note,reviewed=stamp()) if a.review!='BAD' and v['numeric_pass']: e=json.loads((d/'candidate-entry.json').read_text());e['file']=a.id+'.glb';e['speeds']=v['metrics']['speeds_m_s'];e['names']=KINDS[kind];e['style']='realistic';copy(d/'candidate.glb',d/e['file']);write(d/'catalog_entry.json',e);v.update(shipped=True,sha256=sha(d/e['file'])) write(d/'verdict.json',v);report(d,v,steps);print(json.dumps({'verdict':a.review,'shipped':v['shipped'],'out':str(d)}));return 0 if v['shipped'] or a.review=='BAD' else 2 d.mkdir(parents=True,exist_ok=False) def step(name,fn): t=time.monotonic();print(stamp(),name,flush=True) try:fn() except Exception: steps.append({'name':name,'seconds':round(time.monotonic()-t,2),'failed':True});write(d/'stages.json',steps);raise steps.append({'name':name,'seconds':round(time.monotonic()-t,2),'tools_sha256':{p.name:sha(p) for p in HOME.iterdir() if p.is_file() and (p.suffix in ['.py','.js','.mjs'] or p.name=='forge-realistic-animal')}});write(d/'stages.json',steps) try: copy(a.input,d/'source.glb');write(d/'run.json',{'id':a.id,'animal':a.animal,'seed':a.seed,'started':stamp(),'source_sha256':sha(a.input),'mode':'repair' if a.repair else 'mesh-replay' if a.mesh else 'full-generation'}) step('inspect',lambda:blender('inspect_animal.py',d/'source.glb',d,log=d/'inspect.log')) info=json.loads((d/'input.json').read_text());entry={'id':a.id,'role':'enemy','about':'A realistic '+a.animal+' with six source animation clips.','good_for':KINDS[kind],'names':KINDS[kind],'file':'source.glb','height_m':info['height_m'],'clips':info['clips'],'tint':[],'licence':'Generated mesh; input rig licence retained','credit':'Forge generated mesh; input skeleton and animation clips retained. Verify source rig and reference rights before redistribution.','speeds':{},'style':'realistic','mood':['natural','wilderness'],'small':False};entry.update(catalog_metadata(a.input,kind,a.animal,info['height_m']));write(d/'entry.json',entry) if a.repair:step('reuse-existing-rigged-mesh',lambda:copy(a.repair,d/'bound.glb'));entry['file']='bound.glb';write(d/'bound-entry.json',entry) else: step('reference',lambda:reference(a,d));step('image-to-3D',lambda:generate(a,d));step('fit-and-bind',lambda:blender('bind.py',d,kind,log=d/'bind.log')) step('fragment-cleanup',lambda:blender('fragments.py',d/'bound.glb',d/'cleaned.glb',d/'fragments.json',log=d/'fragments.log')) step('before-renders',lambda:qa(d,'bound-entry.json','before')) step('tail-separation',lambda:blender('tail.py',d/'cleaned.glb',d/'separated.glb',d/'tail.json',log=d/'tail.log')) entry['file']='separated.glb';write(d/'separated-entry.json',entry);step('separated-playback',lambda:qa(d,'separated-entry.json','separated')) step('foot-contact',lambda:blender('plant.py',d,log=d/'plant.log')) step('floor-clearance',lambda:blender('clearance.py',d,kind,log=d/'clearance.log')) step('final-playback',lambda:qa(d,'candidate-entry.json','final')) step('geometry-audit',lambda:command(['python3',HOME/'audit_glb.py',d/'candidate.glb','--strict'],d/'audit.json')) v=gates(d);v['elapsed_seconds']=round(time.monotonic()-start,2);write(d/'verdict.json',v);report(d,v,steps) print(json.dumps({'numeric_pass':v['numeric_pass'],'visual_verdict':'UNREVIEWED','out':str(d),'failed':[k for k,x in v['checks'].items() if not x]}));return 0 if v['numeric_pass'] else 2 except Exception as e: v={'numeric_pass':False,'visual_verdict':'UNREVIEWED','shipped':False,'failure':str(e),'elapsed_seconds':round(time.monotonic()-start,2)};write(d/'verdict.json',v);write(d/'stages.json',steps);report(d,v,steps);print('FAILED:',e,file=sys.stderr);return 1 if __name__=='__main__':sys.exit(main())