Files
Evandro Fonseca Junior 204ebd5fa5 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.
2026-10-14 19:14:48 -03:00

817 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Grava um vídeo de rolagem de uma página (Playwright, headless com GPU) e codifica MP4 (H.264), WebM (VP9) e um
* poster WebP, cada vídeo com até 3 MB. É assim que nascem os arquivos de public/videos/<id>/.
*
* Playwright e ffmpeg não são dependências do site. Instale-os numa pasta temporária e rode o script a partir dela
* (vale também `playwright-core` e um `ffmpeg` no PATH, com --chrome e --ffmpeg apontando para os executáveis):
*
* mkdir rec && cd rec && npm init -y && npm i playwright ffmpeg-static && npx playwright install chromium
* node <repo>/scripts/record-project-videos.mjs --slug lavacao-047 --url https://exemplo.com --out ./out
*
* Opções: --viewports desktop,mobile (1440x900 e 390x844), --speed 300 (px/s), --max 30 (segundos), --settle 3000 (ms),
* --software 1 (rasterização por software; perde quadros em páginas pesadas), --chrome <exe>, --ffmpeg <exe>,
* --poster-at 0 (segundo do vídeo de onde sai o poster: use um instante em que o cabeçalho já esteja parado),
* --fast 5 (quantas vezes mais rápido a rolagem passa por trechos quase em branco; 1 desliga), --lo 0.02 e --hi 0.06
* (fração de pixels diferentes do fundo abaixo da qual um trecho é "em branco" e acima da qual é "cheio"),
* --hold 1000 (ms de página parada no topo antes de rolar), --ready-text "Nova Coleção" (só começa a gravar quando o
* primeiro <h1> da página tem esse texto: para um herói que troca de slide, o vídeo e o poster nascem sempre do mesmo),
* --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
* ociosa: o vídeo não pode mostrar quadros brancos de imagem que ainda não chegou. Depois, 6 quadros do vídeo
* pronto são medidos (campo `frames` do relatório; `blank: true` marca um quadro quase todo em branco).
*
* O relógio do vídeo do Playwright não acompanha o relógio real, então o corte entre o aquecimento da página e a
* rolagem usa quadros magenta (início) e ciano (fim) injetados na própria página e procurados no vídeo.
*/
import { spawnSync } from "node:child_process";
import { mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
import { createRequire } from "node:module";
import { join } from "node:path";
// resolve playwright e ffmpeg-static a partir da pasta onde o comando foi executado, não da pasta do script
const require = createRequire(join(process.cwd(), "noop.js"));
const tryRequire = (...names) => {
for (const name of names) {
try {
return require(name);
} catch {}
}
return null;
};
const cliArgs = process.argv.slice(2);
const argOf = (name) => {
const at = cliArgs.indexOf(`--${name}`);
return at < 0 ? undefined : cliArgs[at + 1];
};
const ffmpegPath = argOf("ffmpeg") ?? tryRequire("ffmpeg-static") ?? "ffmpeg";
const playwright = tryRequire("playwright", "playwright-core");
if (!playwright) throw new Error("instale playwright (ou playwright-core)");
const { chromium } = playwright;
const args = Object.fromEntries(
process.argv.slice(2).reduce((acc, cur, i, all) => {
if (cur.startsWith("--")) acc.push([cur.slice(2), all[i + 1]]);
return acc;
}, []),
);
const slug = args.slug;
const url = args.url;
const outRoot = args.out;
const viewportNames = (args.viewports ?? "desktop,mobile").split(",");
const SPEED = Number(args.speed ?? 300); // px/s
const MAX_SECONDS = Number(args.max ?? 30);
const SETTLE_MS = Number(args.settle ?? 3000);
const MAX_BYTES = 3 * 1024 * 1024;
const FPS = 30;
const FAST = Number(args.fast ?? 5);
const BLANK_LO = Number(args.lo ?? 0.02);
const BLANK_HI = Number(args.hi ?? 0.06);
const POSTER_AT = Number(args["poster-at"] ?? 0);
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 },
mobile: { width: 390, height: 844, isMobile: true, hasTouch: true },
};
const MOBILE_UA =
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const outDir = join(outRoot, slug);
const rawRoot = join(outRoot, ".raw", slug);
mkdirSync(outDir, { recursive: true });
function ff(argv) {
const r = spawnSync(
ffmpegPath,
["-hide_banner", "-loglevel", "error", "-y", ...argv],
{ encoding: "utf8" },
);
if (r.status !== 0) throw new Error(`ffmpeg falhou: ${r.stderr}`);
}
async function dismissPopups(page) {
return page.evaluate(() => {
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);
const hidden =
style.visibility === "hidden" ||
style.display === "none" ||
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 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();
}
}
return done;
});
}
async function flash(page, color) {
await page.evaluate(
(c) =>
new Promise((resolve) => {
const el = document.createElement("div");
el.style.cssText = `position:fixed;inset:0;z-index:2147483647;pointer-events:none;background:${c}`;
document.documentElement.appendChild(el);
setTimeout(() => {
el.remove();
requestAnimationFrame(() => requestAnimationFrame(resolve));
}, 320);
}),
color,
);
await sleep(150);
}
// decodifica o vídeo cru em miniaturas 2x2 e procura as sequências magenta (início) e ciano (fim)
function findMarkers(rawPath) {
const r = spawnSync(
ffmpegPath,
[
"-hide_banner",
"-loglevel",
"error",
"-i",
rawPath,
"-vf",
`fps=${FPS},scale=2:2:flags=area,format=rgb24`,
"-f",
"rawvideo",
"-",
],
{ maxBuffer: 1024 * 1024 * 64 },
);
const buf = r.stdout;
const frames = Math.floor(buf.length / 12);
const kind = (i) => {
let R = 0,
G = 0,
B = 0;
for (let p = 0; p < 4; p++) {
R += buf[i * 12 + p * 3];
G += buf[i * 12 + p * 3 + 1];
B += buf[i * 12 + p * 3 + 2];
}
R /= 4;
G /= 4;
B /= 4;
if (R > 190 && G < 80 && B > 190) return "m";
if (R < 80 && G > 190 && B > 190) return "c";
return "-";
};
const seq = Array.from({ length: frames }, (_, i) => kind(i)).join("");
const mStart = seq.indexOf("mmm");
if (mStart < 0) throw new Error("marcador magenta não encontrado no vídeo");
let mEnd = mStart;
while (seq[mEnd + 1] === "m") mEnd += 1;
const cStart = seq.indexOf("ccc", mEnd);
if (cStart < 0) throw new Error("marcador ciano não encontrado no vídeo");
return {
scrollStart: (mEnd + 2) / FPS,
scrollEnd: (cStart - 1) / FPS,
frames,
mStart,
mEnd,
cStart,
};
}
async function waitImages(page, timeout = 15000) {
await page
.evaluate(
(ms) =>
Promise.race([
Promise.all(
[...document.images].map((img) =>
img.complete
? null
: new Promise((res) => {
img.onload = img.onerror = res;
}),
),
),
new Promise((res) => setTimeout(res, ms)),
]),
timeout,
)
.catch(() => {});
}
/** Imagens que ainda não chegaram (ou chegaram quebradas) e quantas já estão decodificadas. */
const imageStatus = (page) =>
page.evaluate(() => {
const images = [...document.images];
const loaded = images.filter((img) => img.complete && img.naturalWidth > 0);
return {
total: images.length,
pending: images
.filter((img) => !img.complete || img.naturalWidth === 0)
.map((img) => img.currentSrc || img.src)
.slice(0, 5),
loaded: loaded.length,
};
});
/**
* Percorre a página devagar, meia tela por vez, com as imagens preguiçosas forçadas a carregar, esperando as da
* janela chegarem e a rede ficar ociosa; no fim decodifica todas. Volta ao topo. Nada do que a gravação vai mostrar
* pode ainda estar a caminho.
*/
async function preloadPage(page) {
for (let round = 0; round < 2; round++) {
await page.evaluate(() => {
for (const img of document.images) {
if (img.loading === "lazy") img.loading = "eager";
img.decoding = "sync";
}
});
const height = await page.evaluate(
() => document.documentElement.scrollHeight,
);
const view = await page.evaluate(() => window.innerHeight);
for (let y = 0; y < height; y += Math.round(view * 0.5)) {
await page.evaluate((top) => window.scrollTo(0, top), y);
await sleep(350);
await page
.waitForFunction(
() =>
[...document.images]
.filter((img) => {
const r = img.getBoundingClientRect();
return r.bottom > -innerHeight && r.top < innerHeight * 2;
})
.every((img) => img.complete),
null,
{ timeout: 6000 },
)
.catch(() => {});
}
await page
.waitForLoadState("networkidle", { timeout: 15000 })
.catch(() => {});
}
await page
.evaluate(() =>
Promise.all(
[...document.images].map((img) => img.decode().catch(() => {})),
),
)
.catch(() => {});
await page.evaluate(() => window.scrollTo(0, 0));
await sleep(1500);
}
/** Fração de pixels de um PNG que diferem do fundo (o nível de cinza mais comum), em 64x40. */
function inkRatio(png, background) {
const r = spawnSync(
ffmpegPath,
[
"-hide_banner",
"-loglevel",
"error",
"-i",
"pipe:0",
"-vf",
"scale=64:40:flags=area,format=gray",
"-frames:v",
"1",
"-f",
"rawvideo",
"-",
],
{ input: png, maxBuffer: 1024 * 1024 },
);
const px = r.stdout;
if (!px?.length) return { ratio: 1, mode: 255 };
const hist = new Array(256).fill(0);
for (const v of px) hist[v] += 1;
let mode = 0;
for (let i = 1; i < 256; i++) if (hist[i] > hist[mode]) mode = i;
const bg = background ?? mode;
let far = 0;
for (const v of px) if (Math.abs(v - bg) > 14) far += 1;
return { ratio: far / px.length, mode };
}
/**
* Mede, a cada 1/8 de tela de rolagem, quanto da janela tem conteúdo (pixels que diferem do fundo da página).
* Devolve o passo, as frações e o fator de velocidade de cada posição: 1 onde há conteúdo e até `FAST` onde a janela
* está quase toda em branco (a rolagem atravessa o vazio depressa em vez de gravar um vídeo branco).
*/
async function measureBlank(page) {
const height = await page.evaluate(
() => document.documentElement.scrollHeight,
);
const view = await page.evaluate(() => window.innerHeight);
const maxScroll = Math.max(0, height - view);
const step = Math.max(40, Math.round(view / 8));
const ratios = [];
const modes = [];
for (let y = 0; y <= maxScroll + step; y += step) {
await page.evaluate(
([top, max]) => window.scrollTo(0, Math.min(top, max)),
[y, maxScroll],
);
await sleep(160);
const { ratio, mode } = inkRatio(await page.screenshot({ type: "png" }));
ratios.push(ratio);
modes.push(mode);
}
await page.evaluate(() => window.scrollTo(0, 0));
// média móvel curta: a velocidade muda aos poucos, sem arranque nem freada bruscos
const smooth = ratios.map((_, i) => {
const part = ratios.slice(Math.max(0, i - 2), i + 3);
return Math.max(ratios[i], part.reduce((a, b) => a + b, 0) / part.length);
});
const factors = smooth.map((ratio) => {
if (FAST <= 1 || ratio >= BLANK_HI) return 1;
if (ratio <= BLANK_LO) return FAST;
const t = (ratio - BLANK_LO) / (BLANK_HI - BLANK_LO);
return FAST + (1 - FAST) * t;
});
return { step, maxScroll, ratios, factors };
}
/** 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,
isMobile: vp.isMobile,
hasTouch: vp.hasTouch,
userAgent: vp.isMobile ? MOBILE_UA : undefined,
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(() => {
// sem rolagem suave do CSS (o scroll é guiado por tempo) e sem o indicador do Next em dev
const style = document.createElement("style");
style.textContent =
"html,body{scroll-behavior:auto!important}nextjs-portal,#__next-build-watcher,[data-nextjs-toast]{display:none!important}";
document.addEventListener("DOMContentLoaded", () =>
document.head.appendChild(style),
);
});
const page = await context.newPage();
const logs = [];
page.on("pageerror", (e) => logs.push(`pageerror: ${e.message}`));
page.on(
"console",
(m) =>
m.type() === "error" &&
logs.push(`console.error: ${m.text().slice(0, 160)}`),
);
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);
return response;
}
/** 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,
);
for (const img of broken) img.style.visibility = "hidden";
return broken.map((img) => img.currentSrc);
});
/**
* 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);
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 pageMetrics = (page) =>
page.evaluate(() => ({
scrollY: window.scrollY,
height: document.documentElement.scrollHeight,
view: window.innerHeight,
title: document.title,
hasHorizontalOverflow:
document.documentElement.scrollWidth > window.innerWidth + 1,
}));
/** 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));
return { speed, expectedScrollSeconds: unitSeconds / speed };
}
/** 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);
}
/** 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
const factorAt = (y) => {
const at = Math.min(factors.length - 1, Math.max(0, y / step));
const i = Math.floor(at);
const next = Math.min(factors.length - 1, i + 1);
return factors[i] + (factors[next] - factors[i]) * (at - i);
};
let y = 0;
let last = performance.now();
const start = last;
const tick = (now) => {
const maxNow =
document.documentElement.scrollHeight - window.innerHeight;
y = Math.min(
y + ((now - last) / 1000) * speedPxS * factorAt(y),
maxNow,
);
last = now;
window.scrollTo(0, y);
if (y >= maxNow - 0.5)
resolve({ endY: window.scrollY, maxNow, ms: now - start });
else requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}),
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 wallSeconds = (Date.now() - startedAt) / 1000;
await flash(page, "rgb(0,255,255)");
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 marks = findMarkers(join(rawDir, rawFile));
const dur = (marks.scrollEnd - marks.scrollStart).toFixed(3);
const trimmed = join(rawDir, "trimmed.mp4");
ff([
"-ss",
marks.scrollStart.toFixed(3),
"-i",
join(rawDir, rawFile),
"-t",
dur,
"-an",
"-r",
String(FPS),
"-c:v",
"libx264",
"-crf",
"14",
"-preset",
"veryfast",
"-pix_fmt",
"yuv420p",
trimmed,
]);
return { trimmed, dur };
}
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),
"-i",
join(outDir, `${vpName}.mp4`),
"-frames:v",
"1",
"-c:v",
"libwebp",
"-quality",
"80",
join(outDir, `${vpName}.webp`),
]);
}
/** 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 { 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). */
function contactSheet(trimmed, vpName, dur) {
const mobile = vpName === "mobile";
ff([
"-i",
trimmed,
"-vf",
`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: metrics.height,
speedPxS: Math.round(plan.speed),
scrollSeconds: +(result.ms / 1000).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: warm.popupsClosed,
horizontalOverflow: metrics.hasHorizontalOverflow,
images: warm.images,
hiddenBrokenImages: warm.hiddenBrokenImages,
hiddenSections: warm.hiddenSections,
frames,
sizesKB: Object.fromEntries(
Object.entries(sizes).map(([k, v]) => [
k,
{ kb: Math.round(v.bytes / 1024), crf: v.crf, scale: v.scale },
]),
),
errors: logs.slice(0, 5),
};
}
// "novo headless" do Chromium completo (headless=new) usa a GPU real; o headless-shell cai em rasterização por software e perde quadros
const software = args.software === "1";
const browser = await chromium.launch({
headless: true,
executablePath: args.chrome,
channel: software || args.chrome ? undefined : "chromium",
args: software
? [
"--use-gl=angle",
"--use-angle=swiftshader",
"--enable-unsafe-swiftshader",
"--ignore-gpu-blocklist",
"--autoplay-policy=no-user-gesture-required",
]
: [
"--use-angle=d3d11",
"--enable-gpu-rasterization",
"--ignore-gpu-blocklist",
"--autoplay-policy=no-user-gesture-required",
],
});
const report = [];
for (const vpName of viewportNames) {
try {
report.push(await recordOne(browser, vpName));
} catch (e) {
report.push({ vp: vpName, error: String(e).slice(0, 400) });
}
}
await browser.close();
console.log(JSON.stringify({ slug, url, report }, null, 2));