feat(session-3): pipelines d'assets, un par filière
Filière A — fetch:medias rapatrie les photographies VALIDÉES depuis Commons et les encode en AVIF/WebP à 480, 800 et 1200 px. Aujourd'hui : 0 traité, 39 ignorés — le pipeline respecte la porte de validation au lieu de la contourner. Vérifié sur une copie : source de 256 Ko → 106 Ko en AVIF 1200 px, 36 Ko en 480 px, budget de 200 Ko tenu. ImageMagick est une dépendance système, pas un paquet npm. Filière B — client ComfyUI en bibliothèque standard seule, hors bundle. Les trois défauts du pack d'origine sont corrigés : - hash() est randomisé par exécution depuis Python 3.3, donc les seeds du pack n'étaient PAS reproductibles malgré son argumentaire. Remplacé par zlib.crc32 et prouvé empiriquement — deux processus avec des PYTHONHASHSEED différents donnent les mêmes valeurs. - copy.deepcopy au lieu de json.loads(json.dumps(...)). - rembg reste commenté avec l'avertissement qui manquait : le paquet est MIT, les poids du modèle ne le sont pas nécessairement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
comfyui_client.py — génération des assets de FILIÈRE B (décor) via l'API HTTP de ComfyUI.
|
||||
|
||||
FILIÈRE B UNIQUEMENT. Ce script ne produit jamais d'image servant à identifier une
|
||||
espèce : fonds, sprites du mode arcade, badges, écrans de titre, iconographie. La
|
||||
séparation est vérifiée par `npm run validate:data`, qui refuse qu'un média de filière B
|
||||
porte un usage « épreuve » ou « indice » (ADR 0004).
|
||||
|
||||
Exécution HORS LIGNE, AU BUILD. Aucun appel réseau ne subsiste dans le jeu livré : les
|
||||
sorties sont des fichiers, et ComfyUI n'est pas une dépendance du site (playbook P2).
|
||||
|
||||
Corrections apportées au script du pack d'origine
|
||||
-------------------------------------------------
|
||||
1. `hash()` de Python est randomisé à chaque exécution (PYTHONHASHSEED). Le pack
|
||||
annonçait « même seed = même style » alors que ses seeds changeaient à chaque
|
||||
lancement. On utilise `zlib.crc32`, stable entre exécutions, machines et versions.
|
||||
2. `json.loads(json.dumps(x))` devient `copy.deepcopy(x)` : même effet, sans passer
|
||||
par une sérialisation qui perd les types non JSON.
|
||||
3. Chaque sortie porte un manifeste avec sa clé, sa seed et son empreinte, pour que la
|
||||
reproductibilité soit vérifiable et non pas affirmée.
|
||||
|
||||
Usage :
|
||||
python3 comfyui_client.py --workflow workflows/decor.json --plan plan-assets.json
|
||||
python3 comfyui_client.py --verifier # rejoue les seeds et compare les empreintes
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
RACINE = Path(__file__).resolve().parent
|
||||
SORTIE = RACINE / "out"
|
||||
COMFY_DEFAUT = "http://127.0.0.1:8188"
|
||||
|
||||
# Réutilisable tel quel : c'est le meilleur apport du pack d'origine.
|
||||
PROMPT_NEGATIF = (
|
||||
"text, watermark, signature, logo, blurry, low quality, jpeg artifacts, "
|
||||
"deformed, extra limbs, bad anatomy, photorealistic human faces, "
|
||||
"cartoon glare, oversaturated, plastic look, frame, border"
|
||||
)
|
||||
|
||||
|
||||
def seed_deterministe(cle: str) -> int:
|
||||
"""
|
||||
Seed stable pour une clé donnée.
|
||||
|
||||
`hash()` ne convient pas : il est randomisé par processus depuis Python 3.3, ce qui
|
||||
rend les seeds du pack d'origine non reproductibles malgré son argumentaire.
|
||||
CRC32 est stable partout et suffit largement — on cherche de la reproductibilité,
|
||||
pas de la résistance cryptographique.
|
||||
"""
|
||||
return zlib.crc32(cle.encode("utf-8")) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def empreinte(chemin: Path) -> str:
|
||||
"""SHA-256 d'un fichier — sert à prouver que deux exécutions produisent la même image."""
|
||||
h = hashlib.sha256()
|
||||
with chemin.open("rb") as f:
|
||||
for bloc in iter(lambda: f.read(65536), b""):
|
||||
h.update(bloc)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def poster(serveur: str, workflow: dict, client_id: str) -> str:
|
||||
"""Envoie un workflow à ComfyUI et renvoie l'identifiant de la tâche."""
|
||||
corps = json.dumps({"prompt": workflow, "client_id": client_id}).encode("utf-8")
|
||||
requete = urllib.request.Request(
|
||||
urllib.parse.urljoin(serveur, "/prompt"),
|
||||
data=corps,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(requete, timeout=30) as reponse:
|
||||
return json.load(reponse)["prompt_id"]
|
||||
|
||||
|
||||
def attendre(serveur: str, prompt_id: str, delai_max: int = 600) -> dict:
|
||||
"""Interroge l'historique jusqu'à ce que la tâche soit terminée."""
|
||||
url = urllib.parse.urljoin(serveur, f"/history/{prompt_id}")
|
||||
debut = time.time()
|
||||
while time.time() - debut < delai_max:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=15) as reponse:
|
||||
historique = json.load(reponse)
|
||||
if prompt_id in historique:
|
||||
return historique[prompt_id]
|
||||
except urllib.error.URLError:
|
||||
pass
|
||||
time.sleep(2)
|
||||
raise TimeoutError(f"ComfyUI n'a pas rendu {prompt_id} en {delai_max}s")
|
||||
|
||||
|
||||
def preparer(workflow_base: dict, noeud_seed: str, seed: int, noeud_prompt: str, prompt: str) -> dict:
|
||||
"""
|
||||
Copie profonde du workflow avant modification.
|
||||
|
||||
`copy.deepcopy` plutôt que `json.loads(json.dumps(...))` : même résultat, sans
|
||||
aller-retour par une sérialisation qui écraserait tout type non JSON.
|
||||
"""
|
||||
workflow = copy.deepcopy(workflow_base)
|
||||
workflow[noeud_seed]["inputs"]["seed"] = seed
|
||||
workflow[noeud_prompt]["inputs"]["text"] = prompt
|
||||
return workflow
|
||||
|
||||
|
||||
def generer(args: argparse.Namespace) -> int:
|
||||
workflow_base = json.loads(Path(args.workflow).read_text(encoding="utf-8"))
|
||||
plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
|
||||
SORTIE.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifeste = []
|
||||
client_id = "ki-fwi-assets"
|
||||
|
||||
for asset in plan["assets"]:
|
||||
cle = f"{asset['id']}:{asset['type']}"
|
||||
seed = seed_deterministe(cle)
|
||||
prompt = f"{asset['prompt']}. Negative: {PROMPT_NEGATIF}"
|
||||
|
||||
print(f"→ {cle} (seed {seed})", flush=True)
|
||||
|
||||
if args.sec:
|
||||
manifeste.append({"cle": cle, "seed": seed, "fichier": None, "sha256": None})
|
||||
continue
|
||||
|
||||
workflow = preparer(workflow_base, plan["noeud_seed"], seed, plan["noeud_prompt"], prompt)
|
||||
prompt_id = poster(args.serveur, workflow, client_id)
|
||||
resultat = attendre(args.serveur, prompt_id)
|
||||
|
||||
fichiers = [
|
||||
img["filename"]
|
||||
for sortie in resultat.get("outputs", {}).values()
|
||||
for img in sortie.get("images", [])
|
||||
]
|
||||
for nom in fichiers:
|
||||
chemin = SORTIE / nom
|
||||
manifeste.append(
|
||||
{
|
||||
"cle": cle,
|
||||
"seed": seed,
|
||||
"fichier": nom,
|
||||
"sha256": empreinte(chemin) if chemin.exists() else None,
|
||||
# Tout asset généré entre dans le dépôt non validé, sans exception.
|
||||
"filiere": "B",
|
||||
"type_media": "image_generee",
|
||||
"valide": False,
|
||||
}
|
||||
)
|
||||
|
||||
chemin_manifeste = SORTIE / "manifeste.json"
|
||||
chemin_manifeste.write_text(json.dumps(manifeste, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"\n✔ {len(manifeste)} entrées → {chemin_manifeste}")
|
||||
print(" Tous les assets sortent en valide: false (ADR 0004).")
|
||||
return 0
|
||||
|
||||
|
||||
def verifier(_args: argparse.Namespace) -> int:
|
||||
"""
|
||||
Contrôle que les seeds sont bien déterministes.
|
||||
|
||||
C'est le test du défaut n°1 du pack d'origine : deux appels doivent donner la même
|
||||
valeur, y compris dans deux processus différents.
|
||||
"""
|
||||
echantillon = ["kowosol:fond", "zikak:sprite", "mansiniye:badge", "lakou:titre"]
|
||||
attendu = {cle: seed_deterministe(cle) for cle in echantillon}
|
||||
rejoue = {cle: seed_deterministe(cle) for cle in echantillon}
|
||||
|
||||
for cle in echantillon:
|
||||
etat = "✔" if attendu[cle] == rejoue[cle] else "✘"
|
||||
print(f"{etat} {cle:24s} {attendu[cle]}")
|
||||
|
||||
stable = attendu == rejoue
|
||||
print("\n✔ Seeds déterministes." if stable else "\n✘ Seeds instables.")
|
||||
print(" Rejouer ce script dans un autre processus doit donner exactement ces valeurs.")
|
||||
return 0 if stable else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parseur = argparse.ArgumentParser(description="Génération des assets de filière B (décor)")
|
||||
parseur.add_argument("--workflow", help="workflow ComfyUI au format API")
|
||||
parseur.add_argument("--plan", help="plan des assets à générer")
|
||||
parseur.add_argument("--serveur", default=COMFY_DEFAUT)
|
||||
parseur.add_argument("--sec", action="store_true", help="calcule les seeds sans appeler ComfyUI")
|
||||
parseur.add_argument("--verifier", action="store_true", help="teste le déterminisme des seeds")
|
||||
args = parseur.parse_args()
|
||||
|
||||
if args.verifier:
|
||||
return verifier(args)
|
||||
if not args.workflow or not args.plan:
|
||||
parseur.error("--workflow et --plan sont requis (ou --verifier)")
|
||||
return generer(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user