build-atlas.js gera páginas leves de 3072 px para celular (--so-mobile regera só elas) e registra o campo mobile no atlas-map.json. wall.html usa essas páginas em toque ou GPU/aparelho fraco, detecta pela marca em sessionStorage que a tentativa anterior morreu e força o modo leve, e trata falha de download, de decodificação e perda de contexto WebGL com aviso e botão, sem recarregar sozinho. Detalhes e testes no achado 3.5.
320 lines
13 KiB
JavaScript
320 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/*
|
|
* build-atlas.js — mural 3D de miniaturas do acervo.
|
|
*
|
|
* Baseado em ../tocador/script/build-3d-atlas.js (mesma ideia: empacotar
|
|
* miniaturas num atlas WebP único + um atlas-map.json com metadados por
|
|
* peça, pra wall.html carregar 2 arquivos em vez de milhares).
|
|
*
|
|
* FONTE DAS IMAGENS: o site de origem (brasilianafotografica.bn.gov.br)
|
|
* bloqueia requisições automatizadas (Cloudflare — ver README.md,
|
|
* achados 3-5), então este script não busca nada pela rede. Em vez
|
|
* disso ele lê o que a Trilha B (baixador-navegador.js) já trouxe pra
|
|
* ~/Downloads — arquivos "BRAS__<handle>__<nome-original>.{jpg,JPG,png}",
|
|
* usando o THUMB__thumb_2 quando existe (mais leve) e a imagem de
|
|
* preservação/extra como alternativa. Itens sem nenhum arquivo baixado
|
|
* ainda ficam de fora do mural — rode de novo depois de baixar mais.
|
|
*
|
|
* Uso:
|
|
* node web/build-atlas.js [--n=99999] [--out=atlas] [--thumbs=DIR]
|
|
* node web/build-atlas.js --so-mobile (só as páginas leves, a partir do atlas pronto)
|
|
* node web/build-atlas.js [--n=99999] [--out=atlas] [--downloads=~/Downloads]
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const sharp = require('sharp');
|
|
const readline = require('readline');
|
|
|
|
const args = Object.fromEntries(
|
|
process.argv.slice(2).map(a => {
|
|
const m = a.match(/^--([\w-]+)=(.*)$/);
|
|
return m ? [m[1], m[2]] : [a.replace(/^--/, ''), true];
|
|
})
|
|
);
|
|
|
|
const N_ITEMS = parseInt(args.n || '99999', 10); // effectively "all we have" unless capped
|
|
const OUT_DIR = path.resolve(__dirname, args.out || 'atlas'); // web/atlas, sibling of this script
|
|
const DOWNLOADS_DIR = path.resolve(args.downloads || path.join(os.homedir(), 'Downloads'));
|
|
// --thumbs=DIR: pasta de miniaturas ja derivadas, uma por item, nomeadas
|
|
// <handle-com-underscore>.webp (geradas por vips/magick a partir da imagem
|
|
// de preservacao). E a fonte preferida: o subidor apaga de ~/Downloads assim
|
|
// que sobe pro S3, entao Downloads nunca tem o acervo inteiro ao mesmo tempo.
|
|
const THUMBS_DIR = args.thumbs ? path.resolve(args.thumbs) : null;
|
|
const TILE_SIZE = 128;
|
|
const ATLAS_SIZE = 8192; // dentro do limite de textura de qualquer GPU moderna (max comum 8192-16384)
|
|
const TILES_PER_ROW = ATLAS_SIZE / TILE_SIZE; // 64 -> capacidade 4096 peças por atlas
|
|
const TILES_PER_ATLAS = TILES_PER_ROW * TILES_PER_ROW;
|
|
const CATALOGO = path.resolve(__dirname, '..', 'pipeline', 'catalogo.jsonl');
|
|
// light pages for phones: the same layout scaled down, so every tile keeps its
|
|
// atlas/col/row and only the page size changes. 3072² stays under maxTextureSize
|
|
// everywhere and under the ~16.7 M-pixel image decode ceiling of iOS Safari — a
|
|
// phone never has to decode an 8192² page, which is what killed the tab.
|
|
const MOBILE_TILE = 48;
|
|
const MOBILE_SIZE = TILES_PER_ROW * MOBILE_TILE; // 3072
|
|
|
|
// ── acha os arquivos já baixados pela Trilha B em ~/Downloads ────────────────
|
|
// nomes: BRAS__20.500.12156.1_<id>__THUMB__thumb_2.jpg (preferido, leve)
|
|
// ou BRAS__20.500.12156.1_<id>__<nome-original>.jpg (imagem cheia, alternativa)
|
|
|
|
function scanThumbs(dir) {
|
|
const byHandle = new Map();
|
|
let files;
|
|
try {
|
|
files = fs.readdirSync(dir);
|
|
} catch {
|
|
return byHandle;
|
|
}
|
|
for (const name of files) {
|
|
const m = name.match(/^(20\.500\.12156\.1_\d+)\.(webp|jpg|png)$/i);
|
|
if (!m) continue;
|
|
const handle = m[1].replace('_', '/');
|
|
byHandle.set(handle, { path: path.join(dir, name), isThumb: true });
|
|
}
|
|
return byHandle;
|
|
}
|
|
|
|
function scanDownloads() {
|
|
const re = /^BRAS__(20\.500\.12156\.1_\d+)__(.*)$/;
|
|
const byHandle = new Map(); // handle (com "/") -> { path, isThumb }
|
|
let files;
|
|
try {
|
|
files = fs.readdirSync(DOWNLOADS_DIR);
|
|
} catch {
|
|
return byHandle;
|
|
}
|
|
for (const name of files) {
|
|
const m = name.match(re);
|
|
if (!m) continue;
|
|
const handle = m[1].replace('_', '/'); // "20.500.12156.1_10518" -> "20.500.12156.1/10518"
|
|
const isThumb = /__THUMB__/.test(name);
|
|
const existing = byHandle.get(handle);
|
|
if (!existing || (isThumb && !existing.isThumb)) {
|
|
byHandle.set(handle, { path: path.join(DOWNLOADS_DIR, name), isThumb });
|
|
}
|
|
}
|
|
return byHandle;
|
|
}
|
|
|
|
// ── util: cor média de uma faixa de pixels (mesma lógica do tocador) ──────────
|
|
|
|
function stripAvg(d, S, x0, y0, x1, y1) {
|
|
let r = 0, g = 0, b = 0, n = 0;
|
|
for (let y = y0; y <= y1; y++) {
|
|
for (let x = x0; x <= x1; x++) {
|
|
const i = (y * S + x) * 3;
|
|
r += d[i]; g += d[i + 1]; b += d[i + 2]; n++;
|
|
}
|
|
}
|
|
return (Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n);
|
|
}
|
|
|
|
function computeEdgeColors(rgb, S) {
|
|
const t = 2;
|
|
const right = stripAvg(rgb, S, S - t, 0, S - 1, S - 1);
|
|
const left = stripAvg(rgb, S, 0, 0, t - 1, S - 1);
|
|
const top = stripAvg(rgb, S, 0, 0, S - 1, t - 1);
|
|
const bottom = stripAvg(rgb, S, 0, S - t, S - 1, S - 1);
|
|
const avg = stripAvg(rgb, S, 0, 0, S - 1, S - 1);
|
|
const dk = h => ((((h >> 16) & 0xff) >> 1) << 16) | ((((h >> 8) & 0xff) >> 1) << 8) | ((h & 0xff) >> 1);
|
|
return [right, left, top, bottom, dk(avg)];
|
|
}
|
|
|
|
// ── metadados: pega campos úteis do dublin_core pro card ──────────────────────
|
|
|
|
function pickDC(dublinCore, name) {
|
|
const hit = (dublinCore || []).find(e => e.name === name);
|
|
return hit ? hit.value : null;
|
|
}
|
|
|
|
// A record carries its subjects (and descriptions, and places) as *repeated*
|
|
// dc.subject rows in full_table — six of them on average, 78.835 in all — and
|
|
// pickDC would take only the first. The descriptive half of the catalogue lives
|
|
// here: without it "indigenista" finds nothing even though five items are
|
|
// classified "Política indigenista". Undefined keys vanish from the JSON, so an
|
|
// item only pays for the fields it actually has.
|
|
function pickAll(item, field, { noUrls = false } = {}) {
|
|
const out = [];
|
|
for (const row of item.full_table || []) {
|
|
if (row.field !== field) continue;
|
|
const v = String(row.value ?? '').trim();
|
|
if (!v || out.includes(v)) continue;
|
|
// dc.identifier and dc.relation are mostly bare URLs — a megabyte of text
|
|
// nobody will ever search for. What is worth keeping is hiding underneath:
|
|
// the archival codes (013RJ012036) and the collection names (Gilberto Ferrez).
|
|
if (noUrls && /^https?:\/\//i.test(v)) continue;
|
|
out.push(v);
|
|
}
|
|
return out;
|
|
}
|
|
const some = (item, f, o) => { const v = pickAll(item, f, o); return v.length ? v : undefined; };
|
|
const one = (item, f) => pickAll(item, f)[0] || undefined;
|
|
|
|
function summarize(item) {
|
|
const dc = item.dublin_core || [];
|
|
return {
|
|
handle: item.handle,
|
|
title: item.title,
|
|
community: item.community,
|
|
rights: item.rights,
|
|
date: pickDC(dc, 'DC.date'),
|
|
contributor: pickDC(dc, 'DC.contributor'),
|
|
type: pickDC(dc, 'DC.type'),
|
|
// sem source_url: é o mesmo endereço para todos os itens, com o handle no
|
|
// meio — 1,2 MB de prefixo repetido 12.552 vezes que o mural remonta numa
|
|
// linha. O handle acima é a chave do mapa e sai de meta lá embaixo.
|
|
// what free-text search is actually for. dc.language ("por") and dc.format
|
|
// ("JPG") are left out on purpose: technical, never searched, and "por"
|
|
// alone would match a couple of thousand items by accident.
|
|
subject: some(item, 'dc.subject'),
|
|
description: some(item, 'dc.description'),
|
|
coverage: some(item, 'dc.coverage'),
|
|
source: one(item, 'dc.source'),
|
|
publisher: one(item, 'dc.publisher'),
|
|
relation: some(item, 'dc.relation', { noUrls: true }),
|
|
identifier: some(item, 'dc.identifier', { noUrls: true }),
|
|
};
|
|
}
|
|
|
|
// ── carrega do catalogo.jsonl só os itens que têm arquivo baixado (streaming) ──
|
|
|
|
async function loadItems(n, byHandle) {
|
|
const items = [];
|
|
const rl = readline.createInterface({ input: fs.createReadStream(CATALOGO), crlfDelay: Infinity });
|
|
for await (const line of rl) {
|
|
if (!line.trim()) continue;
|
|
const o = JSON.parse(line);
|
|
// um item sem título ainda é uma peça: o card já cai pra "(sem título)"
|
|
// sozinho, e descartá-lo aqui escondia do mural itens que têm imagem
|
|
// (11749 do Museu Aeroespacial e 6241 do Instituto Moreira Salles)
|
|
const file = byHandle.get(o.handle);
|
|
if (!file) continue; // sem imagem baixada ainda — fora do mural por enquanto
|
|
items.push({ ...summarize(o), _file: file.path });
|
|
if (items.length >= n) { rl.close(); break; }
|
|
}
|
|
return items;
|
|
}
|
|
|
|
// ── lê o arquivo real e encaixa no tile ────────────────────────────────────────
|
|
|
|
async function realTile(filePath) {
|
|
return sharp(filePath)
|
|
.resize(TILE_SIZE, TILE_SIZE, { fit: 'cover' })
|
|
.flatten({ background: '#000' })
|
|
.raw()
|
|
.toBuffer();
|
|
}
|
|
|
|
// ── light pages for phones, from the desktop pages already on disk ────────────
|
|
|
|
async function buildMobile(outDir) {
|
|
const mapPath = path.join(outDir, 'atlas-map.json');
|
|
const mapData = JSON.parse(fs.readFileSync(mapPath, 'utf8'));
|
|
const atlases = [];
|
|
for (let a = 0; a < mapData.atlases.length; a++) {
|
|
const name = `atlas-m-${a}.webp`;
|
|
await sharp(path.join(outDir, mapData.atlases[a]))
|
|
.resize(MOBILE_SIZE, MOBILE_SIZE, { kernel: 'lanczos3' })
|
|
.webp({ quality: 82, effort: 4 })
|
|
.toFile(path.join(outDir, name));
|
|
const mb = (fs.statSync(path.join(outDir, name)).size / 1024 / 1024).toFixed(2);
|
|
console.log(`Salvo ${name} (${mb} MB, ${MOBILE_SIZE}px)`);
|
|
atlases.push(name);
|
|
}
|
|
mapData.mobile = { size: MOBILE_SIZE, tile: MOBILE_TILE, atlases };
|
|
fs.writeFileSync(mapPath, JSON.stringify(mapData));
|
|
console.log(`atlas-map.json: variante leve registrada (${atlases.length} páginas de ${MOBILE_SIZE}px)`);
|
|
}
|
|
|
|
// ── main ────────────────────────────────────────────────────────────────────
|
|
|
|
async function main() {
|
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
|
|
|
let byHandle;
|
|
if (THUMBS_DIR) {
|
|
console.log(`Procurando miniaturas em ${THUMBS_DIR}...`);
|
|
byHandle = scanThumbs(THUMBS_DIR);
|
|
console.log(`${byHandle.size} peças com miniatura derivada.`);
|
|
if (byHandle.size === 0) {
|
|
console.error('Nenhum arquivo "<handle>.webp" encontrado em ' + THUMBS_DIR + '.');
|
|
process.exit(1);
|
|
}
|
|
} else {
|
|
console.log(`Procurando downloads em ${DOWNLOADS_DIR}...`);
|
|
byHandle = scanDownloads();
|
|
console.log(`${byHandle.size} peças com arquivo já baixado (Trilha B).`);
|
|
if (byHandle.size === 0) {
|
|
console.error('Nenhum arquivo "BRAS__<handle>__..." encontrado em ' + DOWNLOADS_DIR + '. Rode baixador-navegador.js primeiro.');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
console.log(`Lendo ${CATALOGO}...`);
|
|
const items = await loadItems(N_ITEMS, byHandle);
|
|
console.log(`${items.length} itens combinados (catálogo ∩ downloads).`);
|
|
|
|
const nAtlases = Math.max(1, Math.ceil(items.length / TILES_PER_ATLAS));
|
|
console.log(`${nAtlases} atlas(es) de ${ATLAS_SIZE}px (até ${TILES_PER_ATLAS} peças cada, tile=${TILE_SIZE}px).`);
|
|
|
|
const map = {};
|
|
const atlasFiles = [];
|
|
let failed = 0;
|
|
|
|
for (let a = 0; a < nAtlases; a++) {
|
|
const pageItems = items.slice(a * TILES_PER_ATLAS, (a + 1) * TILES_PER_ATLAS);
|
|
const buf = Buffer.alloc(ATLAS_SIZE * ATLAS_SIZE * 3, 0);
|
|
|
|
for (let i = 0; i < pageItems.length; i++) {
|
|
const col = i % TILES_PER_ROW;
|
|
const row = Math.floor(i / TILES_PER_ROW);
|
|
let rgb;
|
|
try {
|
|
rgb = await realTile(pageItems[i]._file);
|
|
} catch (e) {
|
|
failed++;
|
|
continue;
|
|
}
|
|
for (let ty = 0; ty < TILE_SIZE; ty++) {
|
|
const src = ty * TILE_SIZE * 3;
|
|
const dst = ((row * TILE_SIZE + ty) * ATLAS_SIZE + col * TILE_SIZE) * 3;
|
|
rgb.copy(buf, dst, src, src + TILE_SIZE * 3);
|
|
}
|
|
const [cr, cl, ct, cb, ck] = computeEdgeColors(rgb, TILE_SIZE);
|
|
// handle vira a chave da entrada; repeti-lo dentro de meta custava 0,4 MB
|
|
const { _file, handle, ...meta } = pageItems[i];
|
|
map[handle] = { atlas: a, col, row, cr, cl, ct, cb, ck, meta };
|
|
const n = a * TILES_PER_ATLAS + i + 1;
|
|
if (n % 500 === 0) console.log(` ${n}/${items.length}`);
|
|
}
|
|
|
|
const name = `atlas-${a}.webp`;
|
|
const atlasPath = path.join(OUT_DIR, name);
|
|
await sharp(buf, { raw: { width: ATLAS_SIZE, height: ATLAS_SIZE, channels: 3 } })
|
|
.webp({ quality: 85, effort: 4 })
|
|
.toFile(atlasPath);
|
|
const mb = (fs.statSync(atlasPath).size / 1024 / 1024).toFixed(2);
|
|
console.log(`Salvo ${name} (${mb} MB, ${pageItems.length} peças)`);
|
|
atlasFiles.push(name);
|
|
}
|
|
if (failed) console.log(`${failed} arquivo(s) falharam ao decodificar e ficaram de fora.`);
|
|
|
|
const mapData = {
|
|
tile: TILE_SIZE,
|
|
size: ATLAS_SIZE,
|
|
atlases: atlasFiles,
|
|
placeholder: false, // fotos reais baixadas pela Trilha B, não mais o placeholder repetido
|
|
map,
|
|
};
|
|
const mapPath = path.join(OUT_DIR, 'atlas-map.json');
|
|
fs.writeFileSync(mapPath, JSON.stringify(mapData));
|
|
console.log(`Salvo atlas-map.json (${(fs.statSync(mapPath).size / 1024).toFixed(0)} KB)`);
|
|
await buildMobile(OUT_DIR);
|
|
}
|
|
|
|
// --so-mobile: only regenerate the light pages from the desktop atlas on disk
|
|
(args['so-mobile'] ? buildMobile(OUT_DIR) : main()).catch(e => { console.error(e); process.exit(1); });
|