fix: carregador do mural à prova de loop no celular

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.
This commit is contained in:
rodado
2026-09-14 12:37:59 +02:00
parent 10b7cb0cb9
commit fc45ccd9d0
4 changed files with 193 additions and 7 deletions
+50
View File
@@ -266,6 +266,56 @@ antes de tudo isso o mapa era 5,52 MB / 0,73 MB.
debaixo do `#card-wrap` (20). Agora 25 (scrim 24), e no celular a ficha debaixo do `#card-wrap` (20). Agora 25 (scrim 24), e no celular a ficha
estaciona acima do peek da sheet para não cobrir ‹ anterior / próxima ›. estaciona acima do peek da sheet para não cobrir ‹ anterior / próxima ›.
### Achado 3.5 — o mural entrava em loop de carregamento no celular
Relato do usuário (14/09): no celular a tela inicial ficou em loop. Causa, lida
no carregador: o celular **decodificava cada página em 8192×8192** (~268 MB) e
só então reduzia para 4096 num canvas, retendo os canvases além das texturas —
pico de ~800 MB. O iOS não lança erro por falta de memória: mata a aba e a
**recarrega sozinho**, e a página recomeçava o mesmo carregamento. Não havia
tratamento de erro nenhum: fetch falho ou decodificação recusada deixavam a
barra parada para sempre.
Correção (commit `xyz` e `brasiliano` de 14/09):
- **Páginas leves** `atlas-m-0..3.webp` (3072², peça de 48 px), geradas pelo
`build-atlas.js` (`--so-mobile` regera só elas) e registradas em
`atlas-map.json` no campo `mobile`. Mesma posição de cada peça, então a conta de
uv não muda. 6,8 MB no total contra 42 MB; 36 MB decodificados por página.
- **Modo leve** em toque, `maxTextureSize` < 8192 ou `deviceMemory` ≤ 4, ou
forçado com `localStorage.mural_leve = '1'`.
- **Guarda contra loop**: marca `mural_carregando` em `sessionStorage` antes da
parte pesada, apagada só depois de dois quadros renderizados. Se a página abre
com a marca, a tentativa anterior morreu → vai direto para o modo leve e
mostra "modo leve — a tentativa anterior não terminou".
- **Nunca recarrega sozinho**: HTTP de erro, decodificação recusada e
`webglcontextlost` mostram aviso com botão "tentar de novo" (que recarrega uma
vez, já no modo leve). Progresso parado por 30 s mostra "o carregamento parou
de avançar" sem derrubar o que ainda pode estar vivo.
Verificado com Chrome real via `puppeteer-core` (perfil próprio, WebGL
swiftshader), emulando celular com toque — roteiros em
`scratchpad/teste-mural/testa.mjs` e `testa2.mjs`:
| cenário | resultado |
|---|---|
| A — celular carrega | só as 4 páginas leves, capa some em 8,3 s, marca apagada |
| C — marca de crash no desktop | aviso de modo leve, nenhuma página pesada, sem recarga |
| D — página falha no download | aviso com botão, **nenhuma recarga em 10 s** |
| E — botão tentar de novo | recarrega uma vez, termina no modo leve, sem 2ª recarga |
| F — perda de contexto WebGL | aviso com botão, sem recarga |
| G — download travado | aviso "parou de avançar" em 33 s, sem recarga |
O card no modo leve recorta a peça certa: a mesma peça tirada do atlas normal e
do leve difere 4/255 em média; a vizinha, 41,5/255.
**Limite do teste**: não há iPhone de verdade aqui — o limite de memória do iOS
não se emula no Chrome. O que está provado é que o celular não decodifica mais
8192² e que nenhum caminho de falha recarrega sozinho.
**Armadilha de teste**: `framenavigated` do Puppeteer conta a `about:blank`
inicial e mudanças de histórico do mural, então contar navegações dá falso
positivo de recarga. Usar uma marca em `window`, que só some numa recarga real.
## Miniaturas derivadas + reconciliação do ledger — 2026-09-12 ## Miniaturas derivadas + reconciliação do ledger — 2026-09-12
Mural no ar com **13.059 peças** (antes 12.552): commits `bba32f6`, Mural no ar com **13.059 peças** (antes 12.552): commits `bba32f6`,
File diff suppressed because one or more lines are too long
+31 -1
View File
@@ -19,6 +19,7 @@
* *
* Uso: * Uso:
* node web/build-atlas.js [--n=99999] [--out=atlas] [--thumbs=DIR] * 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] * node web/build-atlas.js [--n=99999] [--out=atlas] [--downloads=~/Downloads]
*/ */
@@ -48,6 +49,12 @@ const ATLAS_SIZE = 8192; // dentro do limite de textura de qualquer GPU moderna
const TILES_PER_ROW = ATLAS_SIZE / TILE_SIZE; // 64 -> capacidade 4096 peças por atlas 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 TILES_PER_ATLAS = TILES_PER_ROW * TILES_PER_ROW;
const CATALOGO = path.resolve(__dirname, '..', 'pipeline', 'catalogo.jsonl'); 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 ──────────────── // ── acha os arquivos já baixados pela Trilha B em ~/Downloads ────────────────
// nomes: BRAS__20.500.12156.1_<id>__THUMB__thumb_2.jpg (preferido, leve) // nomes: BRAS__20.500.12156.1_<id>__THUMB__thumb_2.jpg (preferido, leve)
@@ -201,6 +208,27 @@ async function realTile(filePath) {
.toBuffer(); .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 ──────────────────────────────────────────────────────────────────── // ── main ────────────────────────────────────────────────────────────────────
async function main() { async function main() {
@@ -284,6 +312,8 @@ async function main() {
const mapPath = path.join(OUT_DIR, 'atlas-map.json'); const mapPath = path.join(OUT_DIR, 'atlas-map.json');
fs.writeFileSync(mapPath, JSON.stringify(mapData)); fs.writeFileSync(mapPath, JSON.stringify(mapData));
console.log(`Salvo atlas-map.json (${(fs.statSync(mapPath).size / 1024).toFixed(0)} KB)`); console.log(`Salvo atlas-map.json (${(fs.statSync(mapPath).size / 1024).toFixed(0)} KB)`);
await buildMobile(OUT_DIR);
} }
main().catch(e => { console.error(e); process.exit(1); }); // --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); });
+111 -5
View File
@@ -94,6 +94,24 @@
[hidden] { display: none !important; } [hidden] { display: none !important; }
/* shown instead of a frozen bar: a load that dies or stalls says so, and the
retry is the reader's call — never an automatic reload, which on a phone
that just ran out of memory is exactly how the loop starts */
#falha {
position: fixed; inset: 0; z-index: 200; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 14px; padding: 0 24px;
background: rgba(11,11,12,.94); color: #ddd; text-align: center;
font-family: system-ui, sans-serif; font-size: .9rem; line-height: 1.5;
}
#falha small { color: #888; font-size: .72rem; max-width: 34em; }
#falha button, #lento button {
font: inherit; font-size: .72rem; letter-spacing: .12em; text-transform: uppercase;
color: #0b0b0c; background: #7fa8c9; border: 0; border-radius: 3px; padding: 10px 16px;
}
#lento { margin-top: 18px; font-family: system-ui, sans-serif; font-size: .7rem; color: #999; }
#lento button { margin-top: 8px; font-size: .64rem; padding: 7px 12px; }
#aviso-leve { margin-top: 10px; font-family: system-ui, sans-serif; font-size: .62rem; color: #777; }
/* ── compact filter panel, top-left — dark chrome, so it reads as part of /* ── compact filter panel, top-left — dark chrome, so it reads as part of
the wall's own darkness instead of a lit slab thrown on top of it. the wall's own darkness instead of a lit slab thrown on top of it.
Every colour below comes from these tokens: retheme by editing them. ─── */ Every colour below comes from these tokens: retheme by editing them. ─── */
@@ -513,6 +531,7 @@
</div> </div>
<div class="enter" id="enter-label">carregando imagens… 0%</div> <div class="enter" id="enter-label">carregando imagens… 0%</div>
<div id="progress-track"><div id="progress-bar"></div></div> <div id="progress-track"><div id="progress-bar"></div></div>
<div id="aviso-leve" hidden>modo leve — a tentativa anterior não terminou de carregar</div>
</div> </div>
</div> </div>
@@ -604,6 +623,44 @@ renderer.setPixelRatio(Math.min(window.devicePixelRatio, isTouch ? 1.75 : 2));
renderer.setSize(window.innerWidth, window.innerHeight); renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.outputColorSpace = THREE.SRGBColorSpace;
// ─── light mode and the crash-loop guard ──────────────────────────────────
// On an iPhone, running out of memory throws nothing: the system kills the tab
// and reloads it by itself, the page starts the very same load, dies again — a
// loop. A mark in sessionStorage survives that automatic reload, so the next
// attempt knows the last one never finished and goes straight to the light
// atlas instead of repeating the crash.
const LOAD_MARK = 'mural_carregando';
const store = (kind, fn) => { try { return fn(window[kind]); } catch (e) { return null; } };
const previousLoadDied = !!store('sessionStorage', s => s.getItem(LOAD_MARK));
const lightMode = isTouch || previousLoadDied
|| store('localStorage', s => s.getItem('mural_leve')) === '1'
|| renderer.capabilities.maxTextureSize < 8192
|| (navigator.deviceMemory !== undefined && navigator.deviceMemory <= 4);
function showFailure(title, detail) {
if (document.getElementById('falha')) return;
const box = document.createElement('div');
box.id = 'falha';
const h = document.createElement('div'); h.textContent = title;
const small = document.createElement('small');
small.textContent = detail || 'Tentar de novo carrega a versão leve do mural, feita para celular.';
const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = 'tentar de novo';
btn.addEventListener('click', () => {
store('sessionStorage', s => s.setItem(LOAD_MARK, String(Date.now()))); // retry in light mode
location.reload();
});
box.append(h, small, btn);
document.body.appendChild(box);
}
// the GPU dropping the wall's textures is a memory signal too — say so, and let
// the reader decide; preventDefault keeps the browser from giving up silently
canvas.addEventListener('webglcontextlost', e => {
e.preventDefault();
store('sessionStorage', s => s.setItem(LOAD_MARK, String(Date.now())));
showFailure('O navegador liberou a memória do mural.');
}, false);
const scene = new THREE.Scene(); const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b0b0c); scene.background = new THREE.Color(0x0b0b0c);
@@ -1563,6 +1620,7 @@ const asMB = bytes => `${(bytes / 1048576).toFixed(1)} MB`;
// read a body in chunks so the wait is legible instead of a frozen line // read a body in chunks so the wait is legible instead of a frozen line
async function fetchWithProgress(url, onProgress) { async function fetchWithProgress(url, onProgress) {
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
const total = parseInt(res.headers.get('content-length') || '0', 10); const total = parseInt(res.headers.get('content-length') || '0', 10);
const reader = res.body.getReader(); const reader = res.body.getReader();
const chunks = []; const chunks = [];
@@ -1577,10 +1635,38 @@ async function fetchWithProgress(url, onProgress) {
return { blob: new Blob(chunks), total }; return { blob: new Blob(chunks), total };
} }
let lastActivity = performance.now();
let loadFinished = false;
const touchActivity = () => { lastActivity = performance.now(); };
// a bar that stops moving for half a minute is not "almost there": offer a way
// out without tearing down the load that may still be alive
const stallWatch = setInterval(() => {
if (loadFinished) { clearInterval(stallWatch); return; }
if (performance.now() - lastActivity < 30000 || document.getElementById('lento')) return;
const box = document.createElement('div');
box.id = 'lento';
box.textContent = 'o carregamento parou de avançar';
const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = 'tentar de novo';
btn.addEventListener('click', () => {
store('sessionStorage', s => s.setItem(LOAD_MARK, String(Date.now())));
location.reload();
});
box.append(document.createElement('br'), btn);
(document.getElementById('title-screen')?.classList.contains('show')
? document.getElementById('title-screen') : document.getElementById('loading')).appendChild(box);
}, 3000);
async function main() { async function main() {
// set before anything heavy: if this tab is killed mid-load, the automatic
// reload finds the mark and takes the light path
store('sessionStorage', s => s.setItem(LOAD_MARK, String(Date.now())));
if (previousLoadDied) document.getElementById('aviso-leve').hidden = false;
// the catalogue is megabytes of its own, and until now nothing said so // the catalogue is megabytes of its own, and until now nothing said so
const loadingMsg = document.getElementById('loading-msg'); const loadingMsg = document.getElementById('loading-msg');
const catalogue = await fetchWithProgress('atlas/atlas-map.json', (loaded, total) => { const catalogue = await fetchWithProgress('atlas/atlas-map.json', (loaded, total) => {
touchActivity();
// a gzipped response often carries no content-length: count bytes instead // a gzipped response often carries no content-length: count bytes instead
loadingMsg.textContent = total loadingMsg.textContent = total
? `carregando catálogo… ${pctOf(loaded, total)}%` ? `carregando catálogo… ${pctOf(loaded, total)}%`
@@ -1629,7 +1715,10 @@ async function main() {
// and the plain <img> (for the card crop) from the same downloaded blob ── // and the plain <img> (for the card crop) from the same downloaded blob ──
// Decoding four 8192² pages takes about as long as fetching them does, so the // Decoding four 8192² pages takes about as long as fetching them does, so the
// bar covers both: parked at 100% for the whole decode, it read as stuck. // bar covers both: parked at 100% for the whole decode, it read as stuck.
const nAtlases = mapData.atlases.length; // phones get the light pages: same layout at 3072², so tiles keep their
// atlas/col/row and the uv math is unchanged
const pageSet = (lightMode && mapData.mobile) ? mapData.mobile : { size: mapData.size, atlases: mapData.atlases };
const nAtlases = pageSet.atlases.length;
const fileBytes = new Array(nAtlases).fill(0); const fileBytes = new Array(nAtlases).fill(0);
const fileTotals = new Array(nAtlases).fill(0); const fileTotals = new Array(nAtlases).fill(0);
let pagesReady = 0; let pagesReady = 0;
@@ -1652,11 +1741,16 @@ async function main() {
? `${down < 1 ? 'carregando' : 'preparando'} imagens… ${pct}%` ? `${down < 1 ? 'carregando' : 'preparando'} imagens… ${pct}%`
: `carregando imagens… ${asMB(loaded)}`; : `carregando imagens… ${asMB(loaded)}`;
progressBar.style.width = `${pct}%`; progressBar.style.width = `${pct}%`;
touchActivity();
} }
// every response's headers first, bodies second: reading as they arrived meant // every response's headers first, bodies second: reading as they arrived meant
// the last file's size landed mid-download and the percentage fell backwards // the last file's size landed mid-download and the percentage fell backwards
const responses = await Promise.all(mapData.atlases.map(name => fetch(`atlas/${name}`))); const responses = await Promise.all(pageSet.atlases.map(async name => {
const res = await fetch(`atlas/${name}`);
if (!res.ok) throw new Error(`atlas/${name}: HTTP ${res.status}`);
return res;
}));
responses.forEach((res, idx) => { responses.forEach((res, idx) => {
fileTotals[idx] = parseInt(res.headers.get('content-length') || '0', 10); fileTotals[idx] = parseInt(res.headers.get('content-length') || '0', 10);
}); });
@@ -1685,13 +1779,18 @@ async function main() {
const atlasMats = []; const atlasMats = [];
atlasSources = []; atlasSources = [];
for (const blob of blobs) { for (let b = 0; b < blobs.length; b++) {
const blob = blobs[b];
blobs[b] = null; // the compressed page is not needed once it is a bitmap
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const img = new Image(); const img = new Image();
img.src = url; img.src = url;
await img.decode().catch(() => {}); await img.decode().catch(() => {});
// a refused decode used to fall through as a blank page and a bar that
// never finished; say which page failed instead
if (!img.naturalWidth) throw new Error(`não foi possível decodificar ${pageSet.atlases[b]}`);
let source = img, size = img.naturalWidth || mapData.size; let source = img, size = img.naturalWidth;
if (size > maxTex) { if (size > maxTex) {
const c = document.createElement('canvas'); const c = document.createElement('canvas');
c.width = c.height = maxTex; c.width = c.height = maxTex;
@@ -1733,11 +1832,17 @@ async function main() {
// let the browser actually paint that frame before the freeze begins. // let the browser actually paint that frame before the freeze begins.
enterLabel.textContent = 'montando o mural… 90%'; enterLabel.textContent = 'montando o mural… 90%';
progressBar.style.width = '90%'; progressBar.style.width = '90%';
touchActivity();
await new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res))); await new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res)));
// ── ready — start rendering the wall right away, behind a translucent // ── ready — start rendering the wall right away, behind a translucent
// title card, so the wall is already alive when the cover lifts ── // title card, so the wall is already alive when the cover lifts ──
tick(); tick();
// two painted frames with every texture on the GPU: the heavy part is over,
// so the next visit — or a reload — takes the normal path again
await new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res)));
loadFinished = true;
store('sessionStorage', s => s.removeItem(LOAD_MARK));
progressBar.style.width = '100%'; progressBar.style.width = '100%';
enterLabel.textContent = 'entrando…'; enterLabel.textContent = 'entrando…';
enterLabel.classList.add('ready'); enterLabel.classList.add('ready');
@@ -1774,8 +1879,9 @@ async function main() {
} }
main().catch(err => { main().catch(err => {
document.getElementById('loading').innerHTML = `<p style="color:#e88">erro: ${err.message}</p>`;
console.error(err); console.error(err);
loadFinished = true;
showFailure('O mural não terminou de carregar.', `${err.message} — tentar de novo carrega a versão leve.`);
}); });
</script> </script>
</body> </body>