#!/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); });