#!/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____.{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 [--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 // .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'); // ── acha os arquivos já baixados pela Trilha B em ~/Downloads ──────────────── // nomes: BRAS__20.500.12156.1___THUMB__thumb_2.jpg (preferido, leve) // ou BRAS__20.500.12156.1___.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(); } // ── 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 ".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____..." 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)`); } main().catch(e => { console.error(e); process.exit(1); });