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>
158 lines
4.8 KiB
JavaScript
158 lines
4.8 KiB
JavaScript
#!/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);
|
||
});
|