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:
OKI
2026-07-27 11:56:25 -04:00
co-authored by Claude Opus 5
parent dadcbdbcbb
commit ebb05796c9
6 changed files with 509 additions and 1 deletions
+54
View File
@@ -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.
+3 -1
View File
@@ -21,7 +21,9 @@
"build": "vite build && node tools/build/sitemap.mjs", "build": "vite build && node tools/build/sitemap.mjs",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "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": { "devDependencies": {
"@fontsource/archivo": "^5.3.0", "@fontsource/archivo": "^5.3.0",
+82
View File
@@ -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 `<id>:<type>`. 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.
+203
View File
@@ -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())
+10
View File
@@ -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).
+157
View File
@@ -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/<entree>/<media>.{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. Cest l’état attendu tant quun humain\n' +
'na 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);
});