chore(videos): script que grava a rolagem de uma pagina e gera MP4, WebM e poster

Playwright headless com GPU, rolagem em velocidade constante (ate 30 s), corte
por quadros-marcador e saida com ate 3 MB por video.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Evandro Fonseca JuniorandClaude Sonnet 5.5 committed 2026-10-08 11:33:15 -03:00
1 parent 168ee9a180
commit b74044b529
1 file changed
+470
+470
View File
@@ -0,0 +1,470 @@
/**
* 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>/.
*
* Playwright e ffmpeg não são dependências do site. Instale-os numa pasta temporária e rode o script a partir dela:
*
* 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).
*
* 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 ffmpegPath = require("ffmpeg-static");
const { chromium } = require("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 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(() => {});
}
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: 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);
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 metrics = await page.evaluate(() => ({
scrollY: window.scrollY,
height: document.documentElement.scrollHeight,
view: window.innerHeight,
title: document.title,
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;
// 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);
const result = await page.evaluate(
(speedPxS) =>
new Promise((resolve) => {
const start = performance.now();
const tick = (now) => {
const maxNow =
document.documentElement.scrollHeight - window.innerHeight;
const y = Math.min(((now - start) / 1000) * speedPxS, maxNow);
window.scrollTo(0, y);
if (y >= maxNow - 0.5)
resolve({ endY: window.scrollY, maxNow, ms: now - start });
else requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}),
speed,
);
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([
"-i",
join(outDir, `${vpName}.mp4`),
"-frames:v",
"1",
"-c:v",
"libwebp",
"-quality",
"80",
join(outDir, `${vpName}.webp`),
]);
// 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,
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,
channel: software ? 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));