Files
ki-fwi/tools/data/fetch-medias.mjs
T
OKIandClaude Opus 5 ebb05796c9 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>
2026-07-27 11:56:25 -04:00

158 lines
4.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});