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,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
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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())
|
||||||
@@ -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).
|
||||||
@@ -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. 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user