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]>
471 lines
14 KiB
JavaScript
471 lines
14 KiB
JavaScript
/**
|
||
* 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));
|