Files
portifolio-evandrodevbr/scripts/record-project-videos.mjs
T
Evandro Fonseca JuniorandClaude Sonnet 5.5 b74044b529 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]>
2026-10-08 11:33:15 -03:00

471 lines
14 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 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));