Files

158 lines
4.8 KiB
JavaScript
Raw Permalink Normal View History

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