From b74044b5294abe5ff3cc25b2bdd64a92d0bdfdc5 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Thu, 8 Oct 2026 11:33:15 -0300 Subject: [PATCH] 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 --- scripts/record-project-videos.mjs | 470 ++++++++++++++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 scripts/record-project-videos.mjs diff --git a/scripts/record-project-videos.mjs b/scripts/record-project-videos.mjs new file mode 100644 index 0000000..4e4ff27 --- /dev/null +++ b/scripts/record-project-videos.mjs @@ -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//. + * + * 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 /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));