/** * 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));