#!/usr/bin/env python3 """Catalogue des animations d'un mod (lot AP6) : catalogue.json, catalogue.html, vignettes. Entrees : pack.json (--pack), livraison.json + fiches_calculees.json + rapport.json + csv de qualite (dossier --pipeline), vignettes .png (--vignettes, carres 512 px, defaut Saved/Measurement/vignettes), planches .jpg + .json (--planches, defaut Saved/Measurement/planches ; bande de N images carrees de 256 px, animee au survol), clips du mod ModSources//Content/Anims (ou --mod-dir). Collisions de cles : jeu de base, scenarios du jeu et catalogues de tous les autres mods de ModSources/*/Module/Animations/catalogue.json. Controles bloquants : code de sortie 1 et liste des causes ; rien n'est ecrit dans --out en cas d'echec. Python 3 standard, sans dependance. """ import argparse import csv import datetime import hashlib import html import json import os import re import shutil import sys BASE_KEYS = ["martialart", "sneak", "dance", "smoke", "write", "drinkbottle", "drinkglass", "drinktea", "camera", "call", "browse", "makeup", "eat", "sweep", "fightlost", "gun", "knockedout"] TRIAL_MODULE = "lpr_anim_essai" SENS_FR = {"sur place": "sur place", "avant": "avance", "arriere": "recule", "cote": "de côté"} def read_json(path): with open(path, encoding="utf-8-sig") as f: return json.load(f) def sha256_of(paths): h = hashlib.sha256() for p in paths: with open(p, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest().upper() def read_csv_rows(path): if not os.path.isfile(path): return [] with open(path, encoding="utf-8-sig", newline="") as f: return list(csv.DictReader(f)) def num(v): try: return float(v) except (TypeError, ValueError): return 0.0 def game_keys(root): """Cles deja utilisees par le jeu (hors module d'essai) : ANIMATION: et animate().""" keys = set() mods = os.path.join(root, "Content", "Modules") pat = re.compile(r"ANIMATION:\s*([A-Za-z0-9_]+)|animate\(\s*([A-Za-z0-9_]+)\s*\)") for dp, dn, fn in os.walk(mods): rel = os.path.relpath(dp, mods).replace("\\", "/") if rel.split("/")[0] == TRIAL_MODULE: dn[:] = [] continue for name in fn: if name.lower().endswith((".lpaction", ".lpscene")): try: with open(os.path.join(dp, name), encoding="utf-8-sig", errors="replace") as f: text = f.read() except OSError: continue for m in pat.finditer(text): keys.add((m.group(1) or m.group(2)).lower()) return keys def other_catalogue_keys(root, mod, out_dir): keys = {} base = os.path.join(root, "ModSources") if not os.path.isdir(base): return keys own = os.path.normcase(os.path.abspath(os.path.join(out_dir, "catalogue.json"))) for d in sorted(os.listdir(base)): p = os.path.join(base, d, "Module", "Animations", "catalogue.json") if d == mod or not os.path.isfile(p) or os.path.normcase(os.path.abspath(p)) == own: continue try: for a in read_json(p).get("animations", []): keys[str(a.get("cle", "")).lower()] = d except (ValueError, OSError): pass return keys def build(args, errors): root = os.path.abspath(args.root) pipe = os.path.abspath(args.pipeline) pack_path = os.path.abspath(args.pack) out = os.path.abspath(args.out) mod_dir = os.path.abspath(args.mod_dir) if args.mod_dir else os.path.join(root, "ModSources", args.mod) vign = os.path.abspath(args.vignettes) if args.vignettes else os.path.join(root, "Saved", "Measurement", "vignettes") plan = os.path.abspath(args.planches) if args.planches else os.path.join(root, "Saved", "Measurement", "planches") def load(path, what): if not os.path.isfile(path): errors.append("%s introuvable : %s" % (what, path)) return None try: return read_json(path) except ValueError as e: errors.append("%s illisible : %s (%s)" % (what, path, e)) return None pack = load(pack_path, "pack.json") liv_path = os.path.join(pipe, "livraison.json") fic_path = os.path.join(pipe, "fiches_calculees.json") liv = load(liv_path, "livraison.json") fic = load(fic_path, "fiches_calculees.json") rap = load(os.path.join(pipe, "rapport.json"), "rapport.json") if None in (pack, liv, fic, rap): return None # Rapport et fiches calculees. if rap.get("valide") is not True: errors.append("rapport.json non valide : %s" % "; ".join(map(str, rap.get("echecs") or []))) if fic.get("echecs"): errors.append("fiches_calculees.json contient des echecs : %s" % "; ".join(map(str, fic["echecs"]))) if os.path.getmtime(fic_path) <= os.path.getmtime(liv_path): errors.append("fiches_calculees.json (%s) n'est pas plus recent que livraison.json (%s) : " "le catalogue ne decrit pas les clips livres" % (datetime.datetime.fromtimestamp(os.path.getmtime(fic_path)).isoformat(" ", "seconds"), datetime.datetime.fromtimestamp(os.path.getmtime(liv_path)).isoformat(" ", "seconds"))) fiches = fic.get("fiches", {}) declared = pack.get("animations", {}) # Cles : uniques, sans collision. keys = [str(e.get("cle", "")) for e in liv] seen = set() for k in keys: if k in seen: errors.append("cle en double dans livraison.json : %s" % k) seen.add(k) taken = {k: "jeu de base" for k in BASE_KEYS} for k in game_keys(root): taken.setdefault(k, "scenario du jeu") for k, d in other_catalogue_keys(root, args.mod, out).items(): taken.setdefault(k, "catalogue de %s" % d) for k in keys: if k.lower() in taken: errors.append("collision de cle : %s deja utilisee (%s)" % (k, taken[k.lower()])) # Qualite. a_revoir = {} for line in rap.get("a_revoir") or []: parts = [p.strip() for p in str(line).split(" : ", 2)] if len(parts) == 3: a_revoir.setdefault(parts[1], []).append("%s : %s" % (parts[0], parts[2])) qual = {} for corps in ("Bridget", "Michael"): for r in read_csv_rows(os.path.join(pipe, "eval_%s.csv" % corps)): q = qual.setdefault(r["clip"], {"brut": 0.0, "torsion": 0.0}) q["brut"] = max(q["brut"], num(r.get("brut_pire"))) q["torsion"] = max(q["torsion"], num(r.get("torsion_deg"))) animations = [] thumbs = [] for e in liv: k = str(e.get("cle", "")) d = declared.get(k) f = fiches.get(k) if d is None: errors.append("%s : absente de pack.json" % k) if f is None: errors.append("%s : fiche calculee absente" % k) if d is None or f is None: continue clip = d.get("clip", "") miss = [] for name, val in (("clip", clip), ("nom.fr", (d.get("nom") or {}).get("fr")), ("nom.en", (d.get("nom") or {}).get("en")), ("categorie", d.get("categorie")), ("style", d.get("style")), ("usage.fr", (d.get("usage") or {}).get("fr")), ("usage.en", (d.get("usage") or {}).get("en"))): if not val: miss.append(name) for name in ("images", "cadence", "duree_s", "boucle", "deplacement_cm", "vitesse_cm_s", "deplacement_sens", "posture", "pieds_au_sol_pct", "mains"): if f.get(name) in (None, "", {}): miss.append("fiche." + name) for name in ("source", "licence", "credit"): if not pack.get({"source": "source", "licence": "licence", "credit": "credit"}[name]): miss.append("pack." + name) for name in ("asset_F", "asset_M"): if not e.get(name): miss.append("livraison." + name) if miss: errors.append("%s : champs obligatoires vides : %s" % (k, ", ".join(miss))) thumb = os.path.join(vign, "%s.png" % clip) if not clip or not os.path.isfile(thumb): errors.append("%s : vignette absente (%s)" % (k, thumb)) pj = os.path.join(plan, "%s.jpg" % clip) pm = os.path.join(plan, "%s.json" % clip) pinfo = None if not clip or not os.path.isfile(pj): errors.append("%s : planche absente (%s)" % (k, pj)) elif not os.path.isfile(pm): errors.append("%s : description de planche absente (%s)" % (k, pm)) else: try: pinfo = read_json(pm) n_img = pinfo["images"] if isinstance(n_img, bool) or not isinstance(n_img, int) or n_img < 2: errors.append("%s : planche invalide, images = %r (au moins 2 attendues) : %s" % (k, n_img, pm)) pinfo = None elif pinfo.get("taille") != 256: errors.append("%s : planche invalide, taille = %r (256 attendu) : %s" % (k, pinfo.get("taille"), pm)) pinfo = None except (ValueError, KeyError, TypeError, AttributeError) as e: errors.append("%s : description de planche illisible : %s (%r)" % (k, pm, e)) pinfo = None files = [os.path.join(mod_dir, "Content", "Anims", "Female", k + "_F.uasset"), os.path.join(mod_dir, "Content", "Anims", "Male", k + "_M.uasset")] for p in files: if not os.path.isfile(p): errors.append("%s : clip du mod absent : %s" % (k, p)) if miss or not os.path.isfile(files[0]) or not os.path.isfile(files[1]) or not os.path.isfile(thumb) or pinfo is None: continue # causes deja listees ci-dessus tf, tm = os.path.getsize(files[0]), os.path.getsize(files[1]) motifs = a_revoir.get(clip, []) q = qual.get(clip, {"brut": 0.0, "torsion": 0.0}) decl = d.get("posture") if decl is not None and (not isinstance(decl, str) or not decl.strip()): errors.append("%s : posture declaree invalide dans pack.json" % k) continue posture = {"principale": decl if decl else f["posture"], "posture_origine": "declaree" if decl else "calculee"} if decl: posture["posture_calculee"] = f["posture"] if f.get("posture_debut") != f.get("posture_fin"): posture["debut"], posture["fin"] = f.get("posture_debut"), f.get("posture_fin") # Hauteur d'assise seulement pour une posture assise (une chute traverse des images "assis"). if f.get("hauteur_assise_cm") is not None and posture["principale"] == "assis": posture["hauteur_assise_cm"] = f["hauteur_assise_cm"] dur_p = float(f["duree_s"]) or num(pinfo.get("duree_s")) if dur_p <= 0: errors.append("%s : duree nulle, cadence de planche incalculable" % k) continue animations.append({ "cle": k, "appel": {"script": "Actor.animate(%s)" % k, "action": "ANIMATION: %s" % k}, "nom": {"fr": d["nom"]["fr"], "en": d["nom"]["en"]}, "assets": {"feminin": e["asset_F"], "masculin": e["asset_M"]}, "duree_s": round(float(f["duree_s"]), 2), "images": f["images"], "planche": {"fichier": "planches/%s.jpg" % k, "images": pinfo["images"], "taille": 256, "ips": round(pinfo["images"] / dur_p, 1)}, "cadence": f["cadence"], "taille_ko": {"feminin": round(tf / 1024), "masculin": round(tm / 1024), "total": round((tf + tm) / 1024)}, "taille_cuite": False, "boucle": {"oui": bool(f["boucle"]), "ecart_pct": round(f.get("boucle_ecart_pct") or 0.0, 2)}, "deplacement": {"cm": f["deplacement_cm"], "cm_s": f["vitesse_cm_s"], "sens": f["deplacement_sens"]}, "posture": posture, "contacts": {"pieds_au_sol_pct": f["pieds_au_sol_pct"], "mains": f["mains"]}, "accessoire": d.get("accessoire", ""), "categorie": d["categorie"], "style": d["style"], "usage": {"fr": d["usage"]["fr"], "en": d["usage"]["en"]}, "corps": ["feminin", "masculin"], "qualite": {"verdict": "à revoir" if motifs else "accepté", "motifs": motifs, "pire_brut_pct": round(q["brut"], 2), "pire_torsion_deg": round(q["torsion"], 2)}, "source": pack.get("source", ""), "licence": pack.get("licence", ""), "credit": pack.get("credit", ""), "version": args.version, "empreinte": sha256_of(files), }) thumbs.append((k, thumb, pj)) if errors: return None header = { "pack": pack.get("pack", ""), "mod": args.mod, "version": args.version, "genere": datetime.datetime.now().isoformat(timespec="seconds"), "nombre_animations": len(animations), "credit": pack.get("credit", ""), "animations": animations, } return header, thumbs, out # ---------------------------------------------------------------------------- HTML CSS = """ :root{--bg:#f6f7f9;--fg:#1d2330;--mut:#5d6677;--card:#fff;--bd:#d9dde5;--acc:#2057c9;--ok:#1d7a3f;--warn:#a85b00;--chip:#eef1f6} @media (prefers-color-scheme:dark){:root{--bg:#14171d;--fg:#e6e9ef;--mut:#9aa3b4;--card:#1d222b;--bd:#323a48;--acc:#7aa7ff;--ok:#5fcf8a;--warn:#f0a850;--chip:#262c37}} *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.45 system-ui,Segoe UI,sans-serif;overflow-wrap:anywhere} header{padding:16px;max-width:1200px;margin:0 auto} h1{margin:0 0 4px;font-size:1.4rem} .sub{color:var(--mut);font-size:.9rem} .bar{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px} .bar input,.bar select{font:inherit;padding:6px 8px;border:1px solid var(--bd);border-radius:6px;background:var(--card);color:var(--fg);min-width:0;max-width:100%} .bar input{flex:1 1 220px} main{max-width:1200px;margin:0 auto;padding:0 16px 24px;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr));gap:12px} .card{background:var(--card);border:1px solid var(--bd);border-radius:10px;overflow:hidden;display:flex;flex-direction:column} .card[hidden],.card img[hidden],.card .anim[hidden]{display:none} .card img{width:100%;aspect-ratio:1/1;object-fit:contain;background:var(--chip);display:block} .card .anim{width:100%;aspect-ratio:1/1;background-color:var(--chip);background-repeat:no-repeat} .in{padding:10px 12px;display:flex;flex-direction:column;gap:6px} .in h2{margin:0;font-size:1.05rem} .key{font-family:Consolas,monospace;background:var(--chip);border:1px solid var(--bd);border-radius:6px;padding:3px 6px;cursor:pointer;text-align:left;color:var(--fg);font-size:.85rem} .call{font-family:Consolas,monospace;font-size:.8rem;color:var(--mut)} .tags{display:flex;flex-wrap:wrap;gap:4px} .tags span{background:var(--chip);border-radius:10px;padding:1px 8px;font-size:.78rem} .use{font-size:.88rem} .q-ok{color:var(--ok)}.q-rev{color:var(--warn);font-weight:600} footer{max-width:1200px;margin:0 auto;padding:0 16px 24px;color:var(--mut);font-size:.8rem} #none{display:none;padding:16px;max-width:1200px;margin:0 auto} """ JS = """ const A=JSON.parse(document.getElementById('data').textContent).animations; const $=id=>document.getElementById(id); const cap=s=>s.charAt(0).toUpperCase()+s.slice(1); function dur(a){return a.duree_s<2?'courte':(a.duree_s>5?'longue':'moyenne')} function fill(sel,vals){[...new Set(vals)].sort().forEach(v=>{const o=document.createElement('option');o.value=v;o.textContent=v;sel.appendChild(o)})} fill($('fcat'),A.map(a=>a.categorie));fill($('fpos'),A.map(a=>a.posture.principale)); const SENS={'sur place':'sur place','avant':'avance','arriere':'recule','cote':'de côté'}; function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]))} function card(a){ const dp=a.deplacement,p=a.posture; let pos=p.principale;if(p.debut)pos+=' ('+p.debut+' → '+p.fin+')';if(p.hauteur_assise_cm!=null)pos+=', assise '+p.hauteur_assise_cm+' cm'; const mv=dp.sens==='sur place'?'sur place':(SENS[dp.sens]||dp.sens)+' '+Math.round(dp.cm)+' cm ('+Math.round(dp.cm_s)+' cm/s)'; const q=a.qualite,qc=q.verdict==='accepté'?'q-ok':'q-rev'; const el=document.createElement('article');el.className='card'; el.innerHTML=''+esc(a.nom.fr)+'' +'

'+esc(a.nom.fr)+' / '+esc(a.nom.en)+'

' +'' +'
'+esc(a.appel.script)+'
'+esc(a.appel.action)+'
' +'
'+esc(a.categorie)+''+esc(pos)+''+a.duree_s.toString().replace('.',',')+' s ('+dur(a)+')' +''+(a.boucle.oui?'boucle':'une fois')+''+esc(mv)+''+(a.accessoire?''+esc(a.accessoire)+'':'')+'
' +'
'+esc(a.usage.fr)+'
' +'
Qualité : '+esc(q.verdict)+(q.motifs.length?' ('+esc(q.motifs.join(' ; '))+')':'')+'
'; hover(el,a); const b=el.querySelector('.key'); b.onclick=()=>{const done=()=>{b.textContent='copié';setTimeout(()=>b.textContent=a.cle,900)}; if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(a.cle).then(done,()=>sel(b))}else sel(b)}; return el} const reduce=matchMedia('(prefers-reduced-motion: reduce)'); function hover(el,a){ const pl=a.planche,img=el.querySelector('img');let div=null,timer=null,ready=false,want=false,last='mouse'; function show(){if(!want||!ready)return;img.hidden=true;div.hidden=false;let i=0;div.style.backgroundPosition='0% 0%'; clearInterval(timer);timer=setInterval(()=>{i=(i+1)%pl.images;div.style.backgroundPosition=(i*100/(pl.images-1))+'% 0%'},1000/pl.ips)} function start(){want=true; if(!div){div=document.createElement('div');div.className='anim';div.hidden=true;div.setAttribute('role','img');div.setAttribute('aria-label',a.nom.fr); div.style.backgroundSize=(pl.images*100)+'% 100%';img.parentNode.insertBefore(div,img); const im=new Image();im.onload=()=>{div.style.backgroundImage='url("'+pl.fichier+'")';ready=true;show()};im.src=pl.fichier} else show()} function stop(){want=false;clearInterval(timer);timer=null;if(div)div.hidden=true;img.hidden=false} el.addEventListener('pointerenter',e=>{if(e.pointerType!=='touch'&&!reduce.matches)start()}); el.addEventListener('pointerleave',e=>{if(e.pointerType!=='touch')stop()}); el.addEventListener('pointerdown',e=>{last=e.pointerType}); el.addEventListener('click',e=>{if(!e.target.closest('img,.anim'))return;if(last==='touch'||reduce.matches){want?stop():start()}})} function sel(b){const r=document.createRange();r.selectNodeContents(b);const s=getSelection();s.removeAllRanges();s.addRange(r)} const main=$('list'),cards=A.map(a=>({a,el:card(a)}));cards.forEach(c=>main.appendChild(c.el)); function apply(){ const t=$('q').value.trim().toLowerCase(),c=$('fcat').value,p=$('fpos').value,d=$('fdur').value;let n=0; cards.forEach(x=>{const a=x.a; const hay=(a.cle+' '+a.nom.fr+' '+a.nom.en+' '+a.usage.fr+' '+a.usage.en+' '+a.accessoire+' '+a.style).toLowerCase(); const ok=(!t||hay.includes(t))&&(!c||a.categorie===c)&&(!p||a.posture.principale===p)&&(!d||dur(a)===d); x.el.hidden=!ok;if(ok)n++}); $('count').textContent=n+' / '+A.length+' animation(s)';$('none').style.display=n?'none':'block'} ['q','fcat','fpos','fdur'].forEach(i=>$(i).addEventListener('input',apply));apply(); """ def render_html(cat): data = json.dumps(cat, ensure_ascii=False).replace(" %(title)s

%(title)s

Version %(version)s — %(n)d animations — généré le %(date)s. Copiez la clé puis écrivez Actor.animate(clé) dans une scène ou ANIMATION: clé dans une action. Survolez une vignette pour voir le mouvement.
Aucune animation ne correspond aux filtres.
%(credit)s
""" % {"title": html.escape(title), "css": CSS, "js": JS, "data": data, "version": html.escape(cat["version"]), "n": cat["nombre_animations"], "date": html.escape(cat["genere"][:10]), "credit": html.escape(cat["credit"])} def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--pack", required=True) ap.add_argument("--pipeline", required=True) ap.add_argument("--mod", required=True) ap.add_argument("--version", required=True) ap.add_argument("--out", required=True) here = os.path.dirname(os.path.abspath(__file__)) ap.add_argument("--root", default=os.path.dirname(os.path.dirname(here)), help="racine du projet") ap.add_argument("--mod-dir", default="", help="dossier du mod (defaut : /ModSources/)") ap.add_argument("--planches", default="", help="dossier des planches .jpg/.json (defaut : /Saved/Measurement/planches)") ap.add_argument("--vignettes", default="", help="dossier des vignettes .png (defaut : /Saved/Measurement/vignettes)") args = ap.parse_args() errors = [] result = None try: result = build(args, errors) except Exception as e: # noqa: BLE001 errors.append("erreur inattendue : %r" % (e,)) rapport = os.path.join(os.path.abspath(args.pipeline), "catalogue_rapport.json") info = {"date": datetime.datetime.now().isoformat(timespec="seconds"), "mod": args.mod, "version": args.version, "ok": result is not None and not errors, "fiches": len(result[0]["animations"]) if result else 0, "echecs": errors} if result is None or errors: try: os.makedirs(os.path.dirname(rapport), exist_ok=True) with open(rapport, "w", encoding="utf-8", newline="\n") as f: json.dump(info, f, ensure_ascii=False, indent=2) except OSError: pass print("CATALOGUE ECHEC : %d cause(s)" % len(errors), file=sys.stderr) for e in errors: print(" - " + e, file=sys.stderr) return 1 cat, thumbs, out = result os.makedirs(os.path.join(out, "vignettes"), exist_ok=True) os.makedirs(os.path.join(out, "planches"), exist_ok=True) for k, src, psrc in thumbs: shutil.copyfile(src, os.path.join(out, "vignettes", k + ".png")) shutil.copyfile(psrc, os.path.join(out, "planches", k + ".jpg")) with open(os.path.join(out, "catalogue.json"), "w", encoding="utf-8", newline="\n") as f: json.dump(cat, f, ensure_ascii=False, indent=2) f.write("\n") with open(os.path.join(out, "catalogue.html"), "w", encoding="utf-8", newline="\n") as f: f.write(render_html(cat)) with open(rapport, "w", encoding="utf-8", newline="\n") as f: json.dump(info, f, ensure_ascii=False, indent=2) print("CATALOGUE OK : %d fiche(s) -> %s" % (len(cat["animations"]), out)) return 0 if __name__ == "__main__": sys.exit(main())