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.
817 lines
27 KiB
JavaScript
817 lines
27 KiB
JavaScript
/**
|
||
* 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));
|