fix(navegacao): foco em âncora de outra página, vitrine acessível e 3D mais leve

Foco cai no destino ao chegar de outra página; âncoras ignoram ?tipo=; movimento reduzido salta em todos os cliques; cronômetro da janela ampliada; degradê no topo de Projetos e legendas do mergulho com intervalos testados; vitrine acima da barra de cookies e com 2 paradas por projeto; reel-list.css no global; 3D montado em tarefas curtas, limitado a ~30 qps e estático com WebGL por software; pôsteres da vitrine de 330 para 158 KB; SC Plus regravado; scripts sem funções acima de complexidade 10.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 19:14:48 -03:00
1 parent 9e2d65a926
commit 204ebd5fa5
49 files changed
+2197 -606

No files matched your search

+278 -205
View File
@@ -18,6 +18,9 @@
* --ready-wait 1500 (ms de espera depois de o texto aparecer, para a animação de entrada do título acabar antes de o
* vídeo começar), --hide-text "Qual lingerie" (esconde na gravação a seção cujo título tem esse texto: para um bloco que o cliente ainda
* não preencheu, como uma grade de categorias com marcadores de imagem vazios, que não deve aparecer no vídeo),
* --color-scheme dark (esquema de cores que o site recebe, `light` ou `dark`: um site que muda de tema por
* prefers-color-scheme, mas escreve texto branco sobre um fundo que só escurece no tema escuro, sai com seções
* "vazias" no tema claro; sem a opção o navegador usa o padrão dele),
* --dry 1 (só mede os trechos em branco, sem gravar).
*
* Antes de gravar a página é percorrida devagar, com as imagens preguiçosas forçadas e decodificadas e a rede
@@ -75,6 +78,7 @@ const HOLD_MS = Number(args.hold ?? 1000);
const READY_TEXT = args["ready-text"];
const HIDE_TEXT = args["hide-text"];
const READY_WAIT_MS = Number(args["ready-wait"] ?? 0);
const COLOR_SCHEME = args["color-scheme"];
const VIEWPORTS = {
desktop: { width: 1440, height: 900, isMobile: false, hasTouch: false },
@@ -99,37 +103,39 @@ function ff(argv) {
async function dismissPopups(page) {
return page.evaluate(() => {
const done = [];
const roots = document.querySelectorAll(
'[id*="cookie" i],[class*="cookie" i],[id*="consent" i],[class*="consent" i],[role="dialog"],[role="alertdialog"],[aria-modal="true"],[class*="popup" i],[class*="newsletter" i]',
);
for (const root of roots) {
const SELECTOR =
'[id*="cookie" i],[class*="cookie" i],[id*="consent" i],[class*="consent" i],[role="dialog"],[role="alertdialog"],[aria-modal="true"],[class*="popup" i],[class*="newsletter" i]';
const text = (b) =>
(b.innerText || b.getAttribute("aria-label") || "").trim();
// visível, com tamanho de aviso e fixo na janela (cookies, newsletter, pop-ups)
const isPopup = (root) => {
const rect = root.getBoundingClientRect();
const style = getComputedStyle(root);
if (rect.width < 80 || rect.height < 24) continue;
if (
const hidden =
style.visibility === "hidden" ||
style.display === "none" ||
Number(style.opacity) === 0
)
continue;
if (!["fixed", "sticky"].includes(style.position)) continue;
Number(style.opacity) === 0;
const small = rect.width < 80 || rect.height < 24;
return !small && !hidden && ["fixed", "sticky"].includes(style.position);
};
// recusar primeiro; senão, qualquer botão que feche o aviso
const pickButton = (root) => {
const buttons = [
...root.querySelectorAll('button,a[role="button"],[role="button"]'),
];
const text = (b) =>
(b.innerText || b.getAttribute("aria-label") || "").trim();
const pick =
buttons.find((b) =>
/^(recusar|rejeitar|negar|apenas (os )?necess|somente (os )?necess)/i.test(
text(b),
),
) ||
buttons.find((b) =>
/(fechar|close|dispensar|entendi|^ok$|continuar|aceitar|accept|×|✕)/i.test(
text(b),
),
);
const refuse =
/^(recusar|rejeitar|negar|apenas (os )?necess|somente (os )?necess)/i;
const close =
/(fechar|close|dispensar|entendi|^ok$|continuar|aceitar|accept|×|✕)/i;
return (
buttons.find((b) => refuse.test(text(b))) ||
buttons.find((b) => close.test(text(b)))
);
};
const done = [];
for (const root of document.querySelectorAll(SELECTOR)) {
if (!isPopup(root)) continue;
const pick = pickButton(root);
if (pick) {
done.push(text(pick));
pick.click();
@@ -365,12 +371,8 @@ async function measureBlank(page) {
return { step, maxScroll, ratios, factors };
}
async function recordOne(browser, vpName) {
const vp = VIEWPORTS[vpName];
const rawDir = join(rawRoot, vpName);
rmSync(rawDir, { recursive: true, force: true });
mkdirSync(rawDir, { recursive: true });
/** O contexto do Chromium que grava o vídeo, com a página aberta e os erros do console recolhidos em `logs`. */
async function openRecording(browser, vp, rawDir) {
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
deviceScaleFactor: 1,
@@ -380,6 +382,7 @@ async function recordOne(browser, vpName) {
locale: "pt-BR",
timezoneId: "America/Sao_Paulo",
reducedMotion: "no-preference",
colorScheme: COLOR_SCHEME,
recordVideo: { dir: rawDir, size: { width: vp.width, height: vp.height } },
});
await context.addInitScript(() => {
@@ -400,40 +403,39 @@ async function recordOne(browser, vpName) {
m.type() === "error" &&
logs.push(`console.error: ${m.text().slice(0, 160)}`),
);
const tStart = Date.now();
return { context, page, logs };
}
/** Abre a página e espera a rede ociosa, as fontes e os pré-carregadores e animações iniciais. */
async function openPage(page) {
const response = await page.goto(url, { waitUntil: "load", timeout: 90000 });
await page
.waitForLoadState("networkidle", { timeout: 20000 })
.catch(() => {});
await page.evaluate(() => document.fonts.ready).catch(() => {});
await sleep(SETTLE_MS); // pré-carregadores e animações iniciais
const popups1 = await dismissPopups(page);
await sleep(SETTLE_MS);
return response;
}
// passe de aquecimento: percorre a página devagar e deixa todas as imagens carregadas e decodificadas, com a rede
// ociosa; depois volta ao topo (essa parte é cortada do vídeo)
await preloadPage(page);
await waitImages(page);
await page.evaluate(() => window.scrollTo(0, 0));
await sleep(600);
const popups2 = await dismissPopups(page);
const images = await imageStatus(page);
// imagem que não carrega de jeito nenhum (hospedagem de terceiros fora do ar, por exemplo) aparece como ícone de
// imagem quebrada com o texto alternativo: não vai para o vídeo
const hiddenSections = HIDE_TEXT
? await page.evaluate((text) => {
const hidden = [];
for (const heading of document.querySelectorAll("h1, h2, h3")) {
if (!heading.textContent.includes(text)) continue;
const section = heading.closest("section") ?? heading.parentElement;
if (!section) continue;
section.style.display = "none";
hidden.push(heading.textContent.trim());
}
return hidden;
}, HIDE_TEXT)
: [];
const hiddenBrokenImages = await page.evaluate(() => {
/** Esconde as seções (h1 a h3) cujo título tem `text`; devolve os títulos escondidos. */
const hideSections = (page, text) =>
page.evaluate((needle) => {
const hidden = [];
for (const heading of document.querySelectorAll("h1, h2, h3")) {
const section = heading.closest("section") ?? heading.parentElement;
if (!heading.textContent.includes(needle) || !section) continue;
section.style.display = "none";
hidden.push(heading.textContent.trim());
}
return hidden;
}, text);
/**
* Esconde as imagens que não carregam de jeito nenhum (hospedagem de terceiros fora do ar, por exemplo): aparecem como
* ícone de imagem quebrada com o texto alternativo e não vão para o vídeo. Devolve as URLs.
*/
const hideBrokenImages = (page) =>
page.evaluate(() => {
const broken = [...document.images].filter(
(img) => img.complete && img.naturalWidth === 0 && img.currentSrc,
);
@@ -441,21 +443,30 @@ async function recordOne(browser, vpName) {
return broken.map((img) => img.currentSrc);
});
const blank = await measureBlank(page);
/**
* Passe de aquecimento: percorre a página devagar e deixa todas as imagens carregadas e decodificadas, com a rede
* ociosa, fecha avisos e esconde o que não deve aparecer; depois volta ao topo (essa parte é cortada do vídeo).
*/
async function warmUp(page) {
const popups1 = await dismissPopups(page);
await preloadPage(page);
await waitImages(page);
await page.evaluate(() => window.scrollTo(0, 0));
await sleep(600);
if (args.dry === "1") {
await context.close();
return {
vp: vpName,
images,
hiddenBrokenImages,
step: blank.step,
inkRatios: blank.ratios.map((r) => +r.toFixed(3)),
factors: blank.factors.map((f) => +f.toFixed(1)),
};
}
const popups2 = await dismissPopups(page);
const images = await imageStatus(page);
const hiddenSections = HIDE_TEXT ? await hideSections(page, HIDE_TEXT) : [];
const hiddenBrokenImages = await hideBrokenImages(page);
return {
popupsClosed: [...popups1, ...popups2],
images,
hiddenSections,
hiddenBrokenImages,
};
}
const metrics = await page.evaluate(() => ({
const pageMetrics = (page) =>
page.evaluate(() => ({
scrollY: window.scrollY,
height: document.documentElement.scrollHeight,
view: window.innerHeight,
@@ -463,41 +474,43 @@ async function recordOne(browser, vpName) {
hasHorizontalOverflow:
document.documentElement.scrollWidth > window.innerWidth + 1,
}));
// tempo de rolagem a 1 px/s: cada trecho custa passo / fator; a velocidade base cabe o total em MAX_SECONDS - 2
/** Velocidade da rolagem (px/s): o tempo de cada trecho custa passo / fator, e o total cabe em MAX_SECONDS - 2. */
function scrollPlan(blank) {
const unitSeconds = blank.factors.reduce(
(sum, factor) => sum + blank.step / factor,
0,
);
const speed = Math.max(SPEED, unitSeconds / (MAX_SECONDS - 2));
const expectedScrollSeconds = unitSeconds / speed;
const warm = metrics.height;
return { speed, expectedScrollSeconds: unitSeconds / speed };
}
if (READY_TEXT) {
// o começo do slide, não um ponto qualquer dele: primeiro espera o texto sair (se já estiver lá), depois entrar
await page
.waitForFunction(
(text) =>
!(document.querySelector("h1")?.textContent ?? "").includes(text),
READY_TEXT,
{ timeout: 20000, polling: 100 },
)
.catch(() => {});
await page
.waitForFunction(
(text) =>
(document.querySelector("h1")?.textContent ?? "").includes(text),
READY_TEXT,
{ timeout: 40000, polling: 100 },
)
.catch(() => logs.push(`ready-text "${READY_TEXT}" não apareceu no h1`));
if (READY_WAIT_MS) await sleep(READY_WAIT_MS);
}
/** Com `--ready-text`: só segue quando o primeiro <h1> acaba de ganhar esse texto (o começo do slide). */
async function waitReadyText(page, logs) {
if (!READY_TEXT) return;
// primeiro espera o texto sair (se já estiver lá), depois entrar
await page
.waitForFunction(
(text) =>
!(document.querySelector("h1")?.textContent ?? "").includes(text),
READY_TEXT,
{ timeout: 20000, polling: 100 },
)
.catch(() => {});
await page
.waitForFunction(
(text) =>
(document.querySelector("h1")?.textContent ?? "").includes(text),
READY_TEXT,
{ timeout: 40000, polling: 100 },
)
.catch(() => logs.push(`ready-text "${READY_TEXT}" não apareceu no h1`));
if (READY_WAIT_MS) await sleep(READY_WAIT_MS);
}
// marcadores visuais (magenta no início, ciano no fim): o relógio do vídeo do Playwright não é confiável, então o corte é feito pelos quadros
await flash(page, "rgb(255,0,255)");
const t0 = Date.now() - tStart;
await sleep(HOLD_MS); // a página parada no topo: de onde sai o poster
const result = await page.evaluate(
/** Rola a página até o fim, guiada por tempo, na velocidade de cada trecho; devolve onde e em quanto tempo acabou. */
const scrollThrough = (page, plan) =>
page.evaluate(
({ speedPxS, step, factors }) =>
new Promise((resolve) => {
// fator de velocidade na posição y: interpolado entre as medidas de cada `step` px
@@ -525,27 +538,36 @@ async function recordOne(browser, vpName) {
};
requestAnimationFrame(tick);
}),
{ speedPxS: speed, step: blank.step, factors: blank.factors },
plan,
);
/** A rolagem gravada entre os marcadores magenta (início) e ciano (fim); devolve a rolagem e o tempo de relógio. */
async function recordScroll(page, blank, speed) {
await flash(page, "rgb(255,0,255)");
const startedAt = Date.now();
await sleep(HOLD_MS); // a página parada no topo: de onde sai o poster
const result = await scrollThrough(page, {
speedPxS: speed,
step: blank.step,
factors: blank.factors,
});
await sleep(1000);
const t1 = Date.now() - tStart;
const wallSeconds = (Date.now() - startedAt) / 1000;
await flash(page, "rgb(0,255,255)");
await context.close();
return { result, wallSeconds };
}
/** Corta o vídeo cru entre os marcadores (intermediário de alta qualidade, só para os cortes); devolve o arquivo e a duração. */
function trimRecording(rawDir) {
const rawFile = readdirSync(rawDir).find((f) => f.endsWith(".webm"));
const rawPath = join(rawDir, rawFile);
const marks = findMarkers(rawPath);
const startAt = marks.scrollStart.toFixed(3);
const marks = findMarkers(join(rawDir, rawFile));
const dur = (marks.scrollEnd - marks.scrollStart).toFixed(3);
const wallSeconds = (t1 - t0) / 1000;
const trimmed = join(rawDir, "trimmed.mp4");
// intermediário de alta qualidade, só para os cortes
ff([
"-ss",
startAt,
marks.scrollStart.toFixed(3),
"-i",
rawPath,
join(rawDir, rawFile),
"-t",
dur,
"-an",
@@ -561,68 +583,70 @@ async function recordOne(browser, vpName) {
"yuv420p",
trimmed,
]);
return { trimmed, dur };
}
const sizes = {};
const encode = (kind, scale) => {
const out = join(outDir, `${vpName}.${kind}`);
const vf = scale
? ["-vf", `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2`]
: [];
const crfs = kind === "mp4" ? [26, 30, 34, 38] : [33, 37, 41, 46];
for (const crf of crfs) {
if (kind === "mp4") {
ff([
"-i",
trimmed,
...vf,
"-an",
"-c:v",
"libx264",
"-preset",
"slow",
"-profile:v",
"main",
"-crf",
String(crf),
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
out,
]);
} else {
ff([
"-i",
trimmed,
...vf,
"-an",
"-c:v",
"libvpx-vp9",
"-b:v",
"0",
"-crf",
String(crf),
"-row-mt",
"1",
"-deadline",
"good",
"-cpu-used",
"2",
"-pix_fmt",
"yuv420p",
out,
]);
}
sizes[kind] = { crf, scale: scale ?? 1, bytes: statSync(out).size };
if (sizes[kind].bytes <= MAX_BYTES) return true;
}
return false;
};
for (const kind of ["mp4", "webm"]) {
if (!encode(kind, null)) {
for (const scale of [0.85, 0.7]) if (encode(kind, scale)) break;
}
const ENCODER_ARGS = {
mp4: (crf) => [
"-an",
"-c:v",
"libx264",
"-preset",
"slow",
"-profile:v",
"main",
"-crf",
String(crf),
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
],
webm: (crf) => [
"-an",
"-c:v",
"libvpx-vp9",
"-b:v",
"0",
"-crf",
String(crf),
"-row-mt",
"1",
"-deadline",
"good",
"-cpu-used",
"2",
"-pix_fmt",
"yuv420p",
],
};
const ENCODER_CRFS = { mp4: [26, 30, 34, 38], webm: [33, 37, 41, 46] };
/** Codifica `kind` com qualidade cada vez menor até caber em MAX_BYTES; devolve se coube e guarda o último em `sizes`. */
function encodeKind(trimmed, out, kind, scale, sizes) {
const vf = scale
? ["-vf", `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2`]
: [];
for (const crf of ENCODER_CRFS[kind]) {
ff(["-i", trimmed, ...vf, ...ENCODER_ARGS[kind](crf), out]);
sizes[kind] = { crf, scale: scale ?? 1, bytes: statSync(out).size };
if (sizes[kind].bytes <= MAX_BYTES) return true;
}
return false;
}
/** MP4 e WebM de até MAX_BYTES cada: se a qualidade mínima não basta, reduz a escala. */
function encodeVideos(trimmed, vpName) {
const sizes = {};
for (const kind of ["mp4", "webm"]) {
const out = join(outDir, `${vpName}.${kind}`);
if (!encodeKind(trimmed, out, kind, null, sizes))
[0.85, 0.7].some((scale) => encodeKind(trimmed, out, kind, scale, sizes));
}
return sizes;
}
function makePoster(vpName) {
ff([
"-ss",
String(POSTER_AT),
@@ -636,69 +660,118 @@ async function recordOne(browser, vpName) {
"80",
join(outDir, `${vpName}.webp`),
]);
}
// 6 quadros do vídeo pronto, igualmente espaçados: nenhum pode estar quase todo em branco
const frames = [];
for (let i = 0; i < 6; i++) {
/** O quadro do MP4 pronto no segundo `at`, como PNG. */
const frameAt = (vpName, at) =>
spawnSync(
ffmpegPath,
[
"-hide_banner",
"-loglevel",
"error",
"-ss",
at.toFixed(2),
"-i",
join(outDir, `${vpName}.mp4`),
"-frames:v",
"1",
"-f",
"image2pipe",
"-c:v",
"png",
"-",
],
{ maxBuffer: 1024 * 1024 * 16 },
).stdout;
/** 6 quadros do vídeo pronto, igualmente espaçados: nenhum pode estar quase todo em branco. */
function sampleFrames(vpName, dur) {
return Array.from({ length: 6 }, (_, i) => {
const at = ((i + 0.5) / 6) * Number(dur);
const png = spawnSync(
ffmpegPath,
[
"-hide_banner",
"-loglevel",
"error",
"-ss",
at.toFixed(2),
"-i",
join(outDir, `${vpName}.mp4`),
"-frames:v",
"1",
"-f",
"image2pipe",
"-c:v",
"png",
"-",
],
{ maxBuffer: 1024 * 1024 * 16 },
).stdout;
const { ratio } = inkRatio(png);
frames.push({
const { ratio } = inkRatio(frameAt(vpName, at));
return {
at: +at.toFixed(1),
ink: +ratio.toFixed(3),
blank: ratio < BLANK_LO,
});
}
};
});
}
// folha de contato para revisão visual (fora da pasta de saída)
/** Folha de contato para revisão visual (fora da pasta de saída). */
function contactSheet(trimmed, vpName, dur) {
const mobile = vpName === "mobile";
ff([
"-i",
trimmed,
"-vf",
`fps=1/${Math.max(3, Math.round(Number(dur) / 8))},scale=${vpName === "mobile" ? 195 : 480}:-1,tile=${vpName === "mobile" ? 8 : 4}x2`,
`fps=1/${Math.max(3, Math.round(Number(dur) / 8))},scale=${mobile ? 195 : 480}:-1,tile=${mobile ? 8 : 4}x2`,
"-frames:v",
"1",
join(rawRoot, `${vpName}-contact.png`),
]);
}
/** O que `--dry` devolve: só a medição dos trechos em branco. */
const dryReport = (vpName, warm, blank) => ({
vp: vpName,
images: warm.images,
hiddenBrokenImages: warm.hiddenBrokenImages,
step: blank.step,
inkRatios: blank.ratios.map((r) => +r.toFixed(3)),
factors: blank.factors.map((f) => +f.toFixed(1)),
});
async function recordOne(browser, vpName) {
const rawDir = join(rawRoot, vpName);
rmSync(rawDir, { recursive: true, force: true });
mkdirSync(rawDir, { recursive: true });
const { context, page, logs } = await openRecording(
browser,
VIEWPORTS[vpName],
rawDir,
);
const response = await openPage(page);
const warm = await warmUp(page);
const blank = await measureBlank(page);
await sleep(600);
if (args.dry === "1") {
await context.close();
return dryReport(vpName, warm, blank);
}
const metrics = await pageMetrics(page);
const plan = scrollPlan(blank);
await waitReadyText(page, logs);
const { result, wallSeconds } = await recordScroll(page, blank, plan.speed);
await context.close();
const { trimmed, dur } = trimRecording(rawDir);
const sizes = encodeVideos(trimmed, vpName);
makePoster(vpName);
const frames = sampleFrames(vpName, dur);
contactSheet(trimmed, vpName, dur);
return {
vp: vpName,
status: response?.status(),
title: metrics.title,
pageHeight: metrics.height,
warmHeight: warm,
speedPxS: Math.round(speed),
warmHeight: metrics.height,
speedPxS: Math.round(plan.speed),
scrollSeconds: +(result.ms / 1000).toFixed(1),
expectedScrollSeconds: +expectedScrollSeconds.toFixed(1),
expectedScrollSeconds: +plan.expectedScrollSeconds.toFixed(1),
endY: Math.round(result.endY),
maxNow: Math.round(result.maxNow),
durationS: +dur,
wallSeconds: +wallSeconds.toFixed(1),
videoSecondsPerWallSecond: +(Number(dur) / wallSeconds).toFixed(3),
popupsClosed: [...popups1, ...popups2],
popupsClosed: warm.popupsClosed,
horizontalOverflow: metrics.hasHorizontalOverflow,
images,
hiddenBrokenImages,
hiddenSections,
images: warm.images,
hiddenBrokenImages: warm.hiddenBrokenImages,
hiddenSections: warm.hiddenSections,
frames,
sizesKB: Object.fromEntries(
Object.entries(sizes).map(([k, v]) => [