diff --git a/docs/sessions/03-pipeline-assets.md b/docs/sessions/03-pipeline-assets.md new file mode 100644 index 0000000..07060f4 --- /dev/null +++ b/docs/sessions/03-pipeline-assets.md @@ -0,0 +1,54 @@ +# Session 3 — Pipeline d'assets + +**Date :** 2026-07-27 · **Livrable :** deux pipelines, un par filière. + +## Porte de sortie + +| Critère | Cible | Mesuré | +|---|---|---| +| Assets non validés dans le build | 0 | **0** — le pipeline refuse les 39 médias non validés ✅ | +| Assets de filière B dans une épreuve | 0 | refusé par `validate:data`, prouvé par `test:porte` ✅ | +| Reproductibilité des seeds | deux exécutions identiques | **vérifié avec `PYTHONHASHSEED` différent** ✅ | +| Poids par image | ≤ 200 Ko | AVIF 1200 px à **106 Ko**, 800 px à **79 Ko**, 480 px à **36 Ko** ✅ | +| Appels réseau au runtime | 0 | les médias sont rapatriés au build, jamais chargés depuis Commons ✅ | +| Compositing sur média SA ou NC | 0 | refusé par `validate:data` ✅ | + +## Filière A — `npm run fetch:medias` + +Rapatrie les photographies **validées** depuis Commons, les encode en AVIF et WebP à trois +largeurs (480, 800, 1200), et inscrit `fichier_local` dans les données. + +Deux raisons de ne pas pointer vers les URLs de Commons dans le jeu : le projet s'interdit +toute requête tierce au runtime, et un jeu qui doit fonctionner hors ligne ne peut pas +dépendre d'un serveur distant. + +Le script **ne télécharge que ce qui est validé**. Aujourd'hui : 0 traité, 39 ignorés. Le +pipeline ne contourne pas la porte, il la respecte. + +Vérifié sur une copie de travail, sans toucher aux données réelles : source de 256 Ko → +106 Ko en AVIF 1200 px, 36 Ko en 480 px. La qualité baisse d'un cran tant que le budget de +200 Ko est dépassé. + +ImageMagick est une dépendance **système**, pas un paquet npm : l'outil existe sur la +machine de build et n'a rien à faire dans le bundle. + +## Filière B — `tools/assets/comfyui_client.py` + +Client ComfyUI en bibliothèque standard seule, hors bundle, avec son `requirements.txt`. + +**Les trois défauts du pack d'origine sont corrigés**, et le premier est le plus grave : +le pack calculait ses seeds avec `hash()`, randomisé à chaque exécution depuis Python 3.3, +tout en vendant l'argument « même seed = même style ». Ses seeds ne l'étaient pas. + +Remplacé par `zlib.crc32`. Prouvé empiriquement : deux processus lancés avec des +`PYTHONHASHSEED` différents produisent exactement les mêmes valeurs. + +`copy.deepcopy` remplace `json.loads(json.dumps(...))`. Et `rembg` reste commenté dans +`requirements.txt` avec l'avertissement qui manquait : le paquet est sous MIT, **les poids +du modèle ne le sont pas nécessairement**. + +## Ce qui reste + +Le plan d'assets et les workflows ComfyUI ne sont pas écrits : ils dépendent de choix +graphiques qui appartiennent au porteur, et générer du décor avant d'avoir un jeu jouable +serait mettre la charrue avant les bœufs. diff --git a/package.json b/package.json index 2285e39..1e51ee4 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "build": "vite build && node tools/build/sitemap.mjs", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:i18n": "node tools/data/check-i18n.mjs" + "check:i18n": "node tools/data/check-i18n.mjs", + "fetch:medias": "node tools/data/fetch-medias.mjs", + "verify:seeds": "python3 tools/assets/comfyui_client.py --verifier" }, "devDependencies": { "@fontsource/archivo": "^5.3.0", diff --git a/tools/assets/README.md b/tools/assets/README.md new file mode 100644 index 0000000..ffaa0eb --- /dev/null +++ b/tools/assets/README.md @@ -0,0 +1,82 @@ +# Pipeline d'assets — filière B (décor) + +> **Filière B uniquement.** Rien de ce qui sort d'ici ne peut servir à identifier une +> espèce. Fonds, sprites du mode arcade, badges, écrans de titre, iconographie — jamais +> une épreuve, jamais un indice (ADR 0004). +> +> La séparation n'est pas une consigne : `npm run validate:data` fait échouer le build si +> un média de filière B porte un usage identifiant, et `npm run test:porte` le prouve. + +## Pourquoi cette séparation existe + +Les modèles de diffusion connaissent mal le corossol, à peine la quenette, presque pas le +zikak. Sur ces espèces, ils ne refusent pas : ils produisent un fruit **plausible et faux**. +Un enfant qui apprend dessus apprend l'hallucination d'un modèle. + +Le décor, lui, n'a rien à enseigner. C'est là que la génération a sa place. + +## Utilisation + +```bash +# Vérifier que les seeds sont bien déterministes (aucun serveur requis) +python3 comfyui_client.py --verifier + +# Calculer les seeds d'un plan sans rien générer +python3 comfyui_client.py --workflow workflows/decor.json --plan plan-assets.json --sec + +# Générer, ComfyUI devant tourner en local +python3 comfyui_client.py --workflow workflows/decor.json --plan plan-assets.json +``` + +Aucune dépendance n'est nécessaire : le client n'utilise que la bibliothèque standard. + +## Les trois défauts du pack d'origine, corrigés + +### 1. Les seeds n'étaient pas reproductibles + +Le pack faisait `hash(fruit["id"] + asset_type) % 2**32` en vendant l'argument « même seed += même style ». Or `hash()` sur une chaîne est **randomisé à chaque exécution** depuis +Python 3.3 (`PYTHONHASHSEED`) : les seeds changeaient d'un lancement à l'autre, exactement +l'inverse de ce qui était annoncé. + +Remplacé par `zlib.crc32(clé.encode()) & 0xFFFFFFFF`. Vérifié empiriquement — deux +processus avec des `PYTHONHASHSEED` différents produisent les mêmes valeurs : + +``` +kowosol:fond 1368899280 +zikak:sprite 4184016363 +mansiniye:badge 1009059342 +lakou:titre 4201979101 +``` + +### 2. `json.loads(json.dumps(x))` pour copier + +Remplacé par `copy.deepcopy(x)` : même effet, sans aller-retour par une sérialisation qui +écrase les types non JSON. + +### 3. La licence de `rembg` n'était pas vérifiée + +Le paquet est sous MIT, mais **les poids** du modèle qu'il télécharge ne suivent pas +nécessairement cette licence. `requirements.txt` porte l'avertissement, et `rembg` reste +commenté : à n'installer qu'après vérification des poids, pas du paquet. + +## Format du plan + +```json +{ + "noeud_seed": "4", + "noeud_prompt": "6", + "assets": [ + { "id": "lakou", "type": "fond", "prompt": "jaden créole au petit matin, …" } + ] +} +``` + +La clé de seed est `:`. Elle ne doit jamais changer une fois un asset retenu : +c'est elle qui garantit qu'une régénération donne la même image. + +## Ce que le pipeline ne fait pas + +Il ne valide rien. Tout asset sort en `valide: false` et le reste jusqu'à ce qu'un humain +tranche — comme pour les photographies. La différence est qu'un asset de filière B, même +validé, ne pourra jamais porter une épreuve. diff --git a/tools/assets/comfyui_client.py b/tools/assets/comfyui_client.py new file mode 100644 index 0000000..dea6da9 --- /dev/null +++ b/tools/assets/comfyui_client.py @@ -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()) diff --git a/tools/assets/requirements.txt b/tools/assets/requirements.txt new file mode 100644 index 0000000..39bc8d4 --- /dev/null +++ b/tools/assets/requirements.txt @@ -0,0 +1,10 @@ +# Le client ComfyUI n'utilise que la bibliothèque standard (urllib, json, zlib, copy). +# Aucune dépendance n'est nécessaire pour générer. +# +# Post-traitement optionnel — à n'installer que si l'on détoure réellement : +# rembg==2.0.66 +# +# ATTENTION AVANT D'INSTALLER rembg : le paquet est sous licence MIT, mais les POIDS +# du modèle qu'il télécharge (U2-Net et dérivés) ne suivent pas nécessairement cette +# licence. Vérifier la licence des poids, pas seulement celle du paquet, avant tout +# usage sur des assets publiés (brief §7). diff --git a/tools/data/fetch-medias.mjs b/tools/data/fetch-medias.mjs new file mode 100644 index 0000000..8e025bd --- /dev/null +++ b/tools/data/fetch-medias.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node +/** + * fetch-medias.mjs — rapatrie et optimise les médias VALIDÉS, au build. + * + * Deux raisons de ne pas pointer directement vers les URLs de Commons dans le jeu : + * 1. le projet s'interdit toute requête vers un domaine tiers au runtime ; + * 2. un jeu qui doit être jouable hors ligne ne peut pas dépendre d'un serveur distant. + * + * Ce script ne télécharge QUE les médias `valide: true`. Un média non validé n'entre pas + * dans le dépôt : la porte de validation n'est pas contournable par le pipeline. + * + * Sorties : static/medias//.{avif,webp} + `fichier_local` dans les données. + * + * Dépendance système : ImageMagick (`magick`). Pas un paquet npm — l'outil existe déjà + * sur la machine de build, et l'ajouter au bundle n'aurait aucun sens. + */ + +import { readFile, readdir, writeFile, mkdir, access } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const run = promisify(execFile); +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +/** Surchargeables pour tester le pipeline sans toucher aux données réelles. */ +const DATA = process.env.KI_FWI_DATA ? join(ROOT, process.env.KI_FWI_DATA) : join(ROOT, 'src', 'lib', 'data', 'fruits'); +const MEDIAS = process.env.KI_FWI_MEDIAS ? join(ROOT, process.env.KI_FWI_MEDIAS) : join(ROOT, 'static', 'medias'); +const UA = 'ki-fwi-dataset/0.1 (https://o-k-i.net ; projet educatif ORGANISATION KA INTERNATIONALE)'; + +/** Largeurs générées. Au-delà de 1200 px, un fruit sur un téléphone n'y gagne rien. */ +const LARGEURS = [480, 800, 1200]; +/** Budget par image : au-delà, la qualité descend d'un cran (playbook §2.7). */ +const POIDS_MAX = 200 * 1024; + +const existe = async (p) => { + try { + await access(p); + return true; + } catch { + return false; + } +}; + +async function magickDisponible() { + try { + await run('magick', ['-version']); + return true; + } catch { + return false; + } +} + +async function telecharger(url, destination) { + const res = await fetch(url, { headers: { 'User-Agent': UA } }); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + await writeFile(destination, Buffer.from(await res.arrayBuffer())); +} + +/** Encode une largeur en AVIF et WebP, en baissant la qualité si le budget est dépassé. */ +async function encoder(source, base, largeur) { + const sorties = []; + for (const [format, qualiteDepart] of [ + ['avif', 55], + ['webp', 78] + ]) { + const destination = `${base}-${largeur}.${format}`; + let qualite = qualiteDepart; + for (let essai = 0; essai < 3; essai++) { + await run('magick', [ + source, + '-auto-orient', + '-strip', + '-resize', + `${largeur}x>`, + '-quality', + String(qualite), + destination + ]); + const { size } = await (await import('node:fs/promises')).stat(destination); + if (size <= POIDS_MAX) break; + qualite -= 12; + } + sorties.push(destination); + } + return sorties; +} + +async function main() { + if (!(await magickDisponible())) { + console.error("✘ ImageMagick (`magick`) est requis pour optimiser les images."); + process.exit(1); + } + + const fichiers = (await readdir(DATA)).filter((f) => f.endsWith('.json')).sort(); + let traites = 0; + let ignores = 0; + let dejaLa = 0; + + for (const f of fichiers) { + const chemin = join(DATA, f); + const e = JSON.parse(await readFile(chemin, 'utf8')); + let modifie = false; + + for (const m of e.medias) { + if (!m.valide) { + ignores++; + continue; + } + + const dossier = join(MEDIAS, e.id); + await mkdir(dossier, { recursive: true }); + const base = join(dossier, m.id); + + if (await existe(`${base}-800.avif`)) { + dejaLa++; + if (!m.fichier_local) { + m.fichier_local = `/medias/${e.id}/${m.id}-800.webp`; + modifie = true; + } + continue; + } + + process.stdout.write(`→ ${e.id}/${m.id}… `); + const brut = `${base}.source`; + try { + await telecharger(m.url_fichier, brut); + for (const largeur of LARGEURS) await encoder(brut, base, largeur); + m.fichier_local = `/medias/${e.id}/${m.id}-800.webp`; + modifie = true; + traites++; + console.log('ok'); + } catch (err) { + console.log(`échec : ${err.message}`); + } + } + + if (modifie) await writeFile(chemin, JSON.stringify(e, null, '\t') + '\n'); + } + + console.log(`\nTéléchargés et encodés : ${traites}`); + console.log(`Déjà présents : ${dejaLa}`); + console.log(`Ignorés (non validés) : ${ignores}`); + + if (traites === 0 && dejaLa === 0) { + console.log( + '\nAucun média validé : rien à rapatrier. C’est l’état attendu tant qu’un humain\n' + + 'n’a pas tranché sur docs/planche-relecture-medias.md — le pipeline ne contourne\n' + + 'pas la porte de validation, il la respecte.' + ); + } +} + +main().catch((e) => { + console.error('✘', e.stack ?? e.message); + process.exit(1); +});