Files
portifolio-evandrodevbr/scripts/record-project-videos.mjs
T
Evandro Fonseca Junior 6ac87824f3 perf(movimento): sem tempo morto, vídeos regravados e 3D sem React Three Fiber
Legenda do mergulho até Projetos cobrir a cena; transição de página sem blur e com fundo escuro; foco no conteúdo ao trocar de página; listas com marcador e dois pesos de título; vídeos da SEV e da SC Plus regravados sem quadros brancos; scrub a 12-15 fps (-30%); vitrine com uma parada de Tab e setas, contador no celular e CSS sob demanda; âncoras num módulo só (fragment-nav); FramePulse com setInterval; CSS morto removido.
2026-10-14 17:01:37 -03:00

744 lines
24 KiB
JavaScript
Raw 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),
* --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 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 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 rect = root.getBoundingClientRect();
const style = getComputedStyle(root);
if (rect.width < 80 || rect.height < 24) continue;
if (
style.visibility === "hidden" ||
style.display === "none" ||
Number(style.opacity) === 0
)
continue;
if (!["fixed", "sticky"].includes(style.position)) continue;
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),
),
);
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 };
}
async function recordOne(browser, vpName) {
const vp = VIEWPORTS[vpName];
const rawDir = join(rawRoot, vpName);
rmSync(rawDir, { recursive: true, force: true });
mkdirSync(rawDir, { recursive: true });
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",
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)}`),
);
const tStart = Date.now();
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);
// 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(() => {
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);
});
const blank = await measureBlank(page);
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 metrics = await page.evaluate(() => ({
scrollY: window.scrollY,
height: document.documentElement.scrollHeight,
view: window.innerHeight,
title: document.title,
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
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;
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);
}
// 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(
({ 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);
}),
{ speedPxS: speed, step: blank.step, factors: blank.factors },
);
await sleep(1000);
const t1 = Date.now() - tStart;
await flash(page, "rgb(0,255,255)");
await context.close();
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 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,
"-i",
rawPath,
"-t",
dur,
"-an",
"-r",
String(FPS),
"-c:v",
"libx264",
"-crf",
"14",
"-preset",
"veryfast",
"-pix_fmt",
"yuv420p",
trimmed,
]);
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;
}
}
ff([
"-ss",
String(POSTER_AT),
"-i",
join(outDir, `${vpName}.mp4`),
"-frames:v",
"1",
"-c:v",
"libwebp",
"-quality",
"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++) {
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({
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)
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`,
"-frames:v",
"1",
join(rawRoot, `${vpName}-contact.png`),
]);
return {
vp: vpName,
status: response?.status(),
title: metrics.title,
pageHeight: metrics.height,
warmHeight: warm,
speedPxS: Math.round(speed),
scrollSeconds: +(result.ms / 1000).toFixed(1),
expectedScrollSeconds: +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],
horizontalOverflow: metrics.hasHorizontalOverflow,
images,
hiddenBrokenImages,
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));