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.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 17:01:37 -03:00
1 parent f8545cd442
commit 6ac87824f3
68 files changed
+5735 -3300

No files matched your search

+24 -11
View File
@@ -1,13 +1,16 @@
/**
* Recodifica um vídeo para a vitrine animada, em que o tempo do vídeo muda a cada quadro da rolagem.
* Para a busca ser instantânea o vídeo precisa de um quadro-chave a cada 6 quadros e nenhum B-frame; o tamanho
* de cada arquivo fica em até 3 MB (sobe o CRF e, se preciso, reduz a imagem até caber).
* Para a busca ser instantânea o vídeo precisa de um quadro-chave a cada 6 quadros e nenhum B-frame. O vídeo roda
* a 12 quadros por segundo (--fps): o tempo dele anda devagar em relação à rolagem (cerca de 1 s de vídeo por 50 px),
* então 12 quadros por segundo de vídeo são dezenas por segundo de tela, e o arquivo cai a menos da metade. O tamanho
* de cada arquivo fica em até 1,2 MB (--max-mb): sobe o CRF (34, 37, 40, 43) e, se preciso, reduz a imagem até caber.
*
* ffmpeg-static não é dependência do site: instale-o numa pasta temporária e rode o script a partir dela
* (as dependências são resolvidas a partir da pasta onde o comando foi executado):
* (as dependências são resolvidas a partir da pasta onde o comando foi executado; vale também um `ffmpeg` no PATH
* ou --ffmpeg <exe>):
*
* mkdir rec && cd rec && npm init -y && npm i ffmpeg-static
* node <repo>/scripts/encode-scrub-video.mjs --in desktop-original.mp4 --out <repo>/public/videos/<id>/scrub-desktop.mp4 --size 1120x700
* node <repo>/scripts/encode-scrub-video.mjs --in desktop-original.mp4 --out <repo>/public/videos/<id>/scrub-desktop.mp4 --size 960x600
*
* Só existe o scrub do computador: o celular da moldura mostra o pôster, sem vídeo.
*
@@ -23,21 +26,31 @@ import { createRequire } from "node:module";
import { join } from "node:path";
const require = createRequire(join(process.cwd(), "noop.js"));
const ffmpegPath = require("ffmpeg-static");
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;
}, []),
);
function resolveFfmpeg() {
if (args.ffmpeg) return args.ffmpeg;
try {
return require("ffmpeg-static");
} catch {
return "ffmpeg";
}
}
const ffmpegPath = resolveFfmpeg();
const { in: input, out: output } = args;
const [width, height] = (args.size ?? "1120x700").split("x").map(Number);
const MAX_BYTES = Number(args["max-mb"] ?? 3) * 1024 * 1024;
const [width, height] = (args.size ?? "960x600").split("x").map(Number);
const FPS = Number(args.fps ?? 12);
const MAX_BYTES = Number(args["max-mb"] ?? 1.2) * 1024 * 1024;
const CRFS = (args.crfs ?? "34,37,40,43").split(",").map(Number);
if (!input || !output || !width || !height) {
console.error(
"uso: --in <video> --out <video.mp4> --size 1120x700 [--max-mb 3]",
"uso: --in <video> --out <video.mp4> [--size 960x600] [--fps 12] [--max-mb 1.2] [--crfs 34,37,40,43] [--ffmpeg <exe>]",
);
process.exit(1);
}
@@ -53,7 +66,7 @@ function encode(w, h, crf) {
"-i",
input,
"-vf",
`scale=${w}:${h}:flags=lanczos,fps=24`,
`scale=${w}:${h}:flags=lanczos,fps=${FPS}`,
"-an",
"-c:v",
"libx264",
@@ -88,7 +101,7 @@ function encode(w, h, crf) {
for (const scale of [1, 0.9, 0.8]) {
const w = Math.round((width * scale) / 2) * 2;
const h = Math.round((height * scale) / 2) * 2;
for (const crf of [28, 31, 34, 37]) {
for (const crf of CRFS) {
const bytes = encode(w, h, crf);
if (bytes <= MAX_BYTES) {
console.log(
+305 -32
View File
@@ -1,14 +1,28 @@
/**
* Grava um vídeo de rolagem em velocidade constante 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>/.
* 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:
* 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).
* --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.
@@ -20,8 +34,23 @@ 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 ffmpegPath = require("ffmpeg-static");
const { chromium } = require("playwright");
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) => {
@@ -38,6 +67,14 @@ 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 },
@@ -193,6 +230,141 @@ async function waitImages(page, timeout = 15000) {
.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);
@@ -238,27 +410,50 @@ async function recordOne(browser, vpName) {
await sleep(SETTLE_MS); // pré-carregadores e animações iniciais
const popups1 = await dismissPopups(page);
// passe de aquecimento: força imagens preguiçosas e conteúdo sob demanda; depois volta ao topo (essa parte é cortada do vídeo)
const warm = await page.evaluate(async () => {
const step = Math.round(window.innerHeight * 0.8);
let guard = 0;
while (
window.scrollY + window.innerHeight <
document.documentElement.scrollHeight - 2 &&
guard++ < 400
) {
window.scrollBy(0, step);
await new Promise((r) => setTimeout(r, 140));
}
return document.documentElement.scrollHeight;
});
await sleep(1200);
// 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(1800);
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,
@@ -268,22 +463,61 @@ async function recordOne(browser, vpName) {
hasHorizontalOverflow:
document.documentElement.scrollWidth > window.innerWidth + 1,
}));
const maxScroll = Math.max(0, metrics.height - metrics.view);
const speed = Math.max(SPEED, maxScroll / (MAX_SECONDS - 2));
const expectedScrollSeconds = maxScroll / speed;
// 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(1000);
await sleep(HOLD_MS); // a página parada no topo: de onde sai o poster
const result = await page.evaluate(
(speedPxS) =>
({ speedPxS, step, factors }) =>
new Promise((resolve) => {
const start = performance.now();
// 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;
const y = Math.min(((now - start) / 1000) * speedPxS, maxNow);
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 });
@@ -291,7 +525,7 @@ async function recordOne(browser, vpName) {
};
requestAnimationFrame(tick);
}),
speed,
{ speedPxS: speed, step: blank.step, factors: blank.factors },
);
await sleep(1000);
const t1 = Date.now() - tStart;
@@ -390,6 +624,8 @@ async function recordOne(browser, vpName) {
}
}
ff([
"-ss",
String(POSTER_AT),
"-i",
join(outDir, `${vpName}.mp4`),
"-frames:v",
@@ -401,6 +637,38 @@ async function recordOne(browser, vpName) {
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",
@@ -428,6 +696,10 @@ async function recordOne(browser, vpName) {
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,
@@ -442,7 +714,8 @@ async function recordOne(browser, vpName) {
const software = args.software === "1";
const browser = await chromium.launch({
headless: true,
channel: software ? undefined : "chromium",
executablePath: args.chrome,
channel: software || args.chrome ? undefined : "chromium",
args: software
? [
"--use-gl=angle",