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:
@@ -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
|
||||
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
|
||||
|
||||
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
@@ -19,6 +19,7 @@
|
||||
*
|
||||
* 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]
|
||||
*/
|
||||
|
||||
@@ -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_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)
|
||||
@@ -201,6 +208,27 @@ async function realTile(filePath) {
|
||||
.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() {
|
||||
@@ -284,6 +312,8 @@ async function main() {
|
||||
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);
|
||||
}
|
||||
|
||||
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
@@ -94,6 +94,24 @@
|
||||
|
||||
[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
|
||||
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. ─── */
|
||||
@@ -513,6 +531,7 @@
|
||||
</div>
|
||||
<div class="enter" id="enter-label">carregando imagens… 0%</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>
|
||||
|
||||
@@ -604,6 +623,44 @@ renderer.setPixelRatio(Math.min(window.devicePixelRatio, isTouch ? 1.75 : 2));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
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();
|
||||
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
|
||||
async function fetchWithProgress(url, onProgress) {
|
||||
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 reader = res.body.getReader();
|
||||
const chunks = [];
|
||||
@@ -1577,10 +1635,38 @@ async function fetchWithProgress(url, onProgress) {
|
||||
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() {
|
||||
// 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
|
||||
const loadingMsg = document.getElementById('loading-msg');
|
||||
const catalogue = await fetchWithProgress('atlas/atlas-map.json', (loaded, total) => {
|
||||
touchActivity();
|
||||
// a gzipped response often carries no content-length: count bytes instead
|
||||
loadingMsg.textContent = 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 ──
|
||||
// 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.
|
||||
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 fileTotals = new Array(nAtlases).fill(0);
|
||||
let pagesReady = 0;
|
||||
@@ -1652,11 +1741,16 @@ async function main() {
|
||||
? `${down < 1 ? 'carregando' : 'preparando'} imagens… ${pct}%`
|
||||
: `carregando imagens… ${asMB(loaded)}`;
|
||||
progressBar.style.width = `${pct}%`;
|
||||
touchActivity();
|
||||
}
|
||||
|
||||
// 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
|
||||
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) => {
|
||||
fileTotals[idx] = parseInt(res.headers.get('content-length') || '0', 10);
|
||||
});
|
||||
@@ -1685,13 +1779,18 @@ async function main() {
|
||||
|
||||
const atlasMats = [];
|
||||
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 img = new Image();
|
||||
img.src = url;
|
||||
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) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = maxTex;
|
||||
@@ -1733,11 +1832,17 @@ async function main() {
|
||||
// let the browser actually paint that frame before the freeze begins.
|
||||
enterLabel.textContent = 'montando o mural… 90%';
|
||||
progressBar.style.width = '90%';
|
||||
touchActivity();
|
||||
await new Promise(res => requestAnimationFrame(() => requestAnimationFrame(res)));
|
||||
|
||||
// ── ready — start rendering the wall right away, behind a translucent
|
||||
// title card, so the wall is already alive when the cover lifts ──
|
||||
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%';
|
||||
enterLabel.textContent = 'entrando…';
|
||||
enterLabel.classList.add('ready');
|
||||
@@ -1774,8 +1879,9 @@ async function main() {
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
document.getElementById('loading').innerHTML = `<p style="color:#e88">erro: ${err.message}</p>`;
|
||||
console.error(err);
|
||||
loadFinished = true;
|
||||
showFailure('O mural não terminou de carregar.', `${err.message} — tentar de novo carrega a versão leve.`);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user