/** * Grava um vídeo de rolagem 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 * (vale também `playwright-core` e um `ffmpeg` no PATH, com --chrome e --ffmpeg apontando para os executáveis): * * 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), --chrome , --ffmpeg , * --poster-at 0 (segundo do vídeo de onde sai o poster: use um instante em que o cabeçalho já esteja parado), * --fast 5 (quantas vezes mais rápido a rolagem passa por trechos quase em branco; 1 desliga), --lo 0.02 e --hi 0.06 * (fração de pixels diferentes do fundo abaixo da qual um trecho é "em branco" e acima da qual é "cheio"), * --hold 1000 (ms de página parada no topo antes de rolar), --ready-text "Nova Coleção" (só começa a gravar quando o * primeiro

da página tem esse texto: para um herói que troca de slide, o vídeo e o poster nascem sempre do mesmo), * --ready-wait 1500 (ms de espera depois de o texto aparecer, para a animação de entrada do título acabar antes de o * vídeo começar), --hide-text "Qual lingerie" (esconde na gravação a seção cujo título tem esse texto: para um bloco que o cliente ainda * não preencheu, como uma grade de categorias com marcadores de imagem vazios, que não deve aparecer no vídeo), * --color-scheme dark (esquema de cores que o site recebe, `light` ou `dark`: um site que muda de tema por * prefers-color-scheme, mas escreve texto branco sobre um fundo que só escurece no tema escuro, sai com seções * "vazias" no tema claro; sem a opção o navegador usa o padrão dele), * --dry 1 (só mede os trechos em branco, sem gravar). * * Antes de gravar a página é percorrida devagar, com as imagens preguiçosas forçadas e decodificadas e a rede * ociosa: o vídeo não pode mostrar quadros brancos de imagem que ainda não chegou. Depois, 6 quadros do vídeo * pronto são medidos (campo `frames` do relatório; `blank: true` marca um quadro quase todo em branco). * * 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 tryRequire = (...names) => { for (const name of names) { try { return require(name); } catch {} } return null; }; const cliArgs = process.argv.slice(2); const argOf = (name) => { const at = cliArgs.indexOf(`--${name}`); return at < 0 ? undefined : cliArgs[at + 1]; }; const ffmpegPath = argOf("ffmpeg") ?? tryRequire("ffmpeg-static") ?? "ffmpeg"; const playwright = tryRequire("playwright", "playwright-core"); if (!playwright) throw new Error("instale playwright (ou playwright-core)"); const { chromium } = 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 FAST = Number(args.fast ?? 5); const BLANK_LO = Number(args.lo ?? 0.02); const BLANK_HI = Number(args.hi ?? 0.06); const POSTER_AT = Number(args["poster-at"] ?? 0); const HOLD_MS = Number(args.hold ?? 1000); const READY_TEXT = args["ready-text"]; const HIDE_TEXT = args["hide-text"]; const READY_WAIT_MS = Number(args["ready-wait"] ?? 0); const COLOR_SCHEME = args["color-scheme"]; 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 SELECTOR = '[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]'; const text = (b) => (b.innerText || b.getAttribute("aria-label") || "").trim(); // visível, com tamanho de aviso e fixo na janela (cookies, newsletter, pop-ups) const isPopup = (root) => { const rect = root.getBoundingClientRect(); const style = getComputedStyle(root); const hidden = style.visibility === "hidden" || style.display === "none" || Number(style.opacity) === 0; const small = rect.width < 80 || rect.height < 24; return !small && !hidden && ["fixed", "sticky"].includes(style.position); }; // recusar primeiro; senão, qualquer botão que feche o aviso const pickButton = (root) => { const buttons = [ ...root.querySelectorAll('button,a[role="button"],[role="button"]'), ]; const refuse = /^(recusar|rejeitar|negar|apenas (os )?necess|somente (os )?necess)/i; const close = /(fechar|close|dispensar|entendi|^ok$|continuar|aceitar|accept|×|✕)/i; return ( buttons.find((b) => refuse.test(text(b))) || buttons.find((b) => close.test(text(b))) ); }; const done = []; for (const root of document.querySelectorAll(SELECTOR)) { if (!isPopup(root)) continue; const pick = pickButton(root); 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(() => {}); } /** Imagens que ainda não chegaram (ou chegaram quebradas) e quantas já estão decodificadas. */ const imageStatus = (page) => page.evaluate(() => { const images = [...document.images]; const loaded = images.filter((img) => img.complete && img.naturalWidth > 0); return { total: images.length, pending: images .filter((img) => !img.complete || img.naturalWidth === 0) .map((img) => img.currentSrc || img.src) .slice(0, 5), loaded: loaded.length, }; }); /** * Percorre a página devagar, meia tela por vez, com as imagens preguiçosas forçadas a carregar, esperando as da * janela chegarem e a rede ficar ociosa; no fim decodifica todas. Volta ao topo. Nada do que a gravação vai mostrar * pode ainda estar a caminho. */ async function preloadPage(page) { for (let round = 0; round < 2; round++) { await page.evaluate(() => { for (const img of document.images) { if (img.loading === "lazy") img.loading = "eager"; img.decoding = "sync"; } }); const height = await page.evaluate( () => document.documentElement.scrollHeight, ); const view = await page.evaluate(() => window.innerHeight); for (let y = 0; y < height; y += Math.round(view * 0.5)) { await page.evaluate((top) => window.scrollTo(0, top), y); await sleep(350); await page .waitForFunction( () => [...document.images] .filter((img) => { const r = img.getBoundingClientRect(); return r.bottom > -innerHeight && r.top < innerHeight * 2; }) .every((img) => img.complete), null, { timeout: 6000 }, ) .catch(() => {}); } await page .waitForLoadState("networkidle", { timeout: 15000 }) .catch(() => {}); } await page .evaluate(() => Promise.all( [...document.images].map((img) => img.decode().catch(() => {})), ), ) .catch(() => {}); await page.evaluate(() => window.scrollTo(0, 0)); await sleep(1500); } /** Fração de pixels de um PNG que diferem do fundo (o nível de cinza mais comum), em 64x40. */ function inkRatio(png, background) { const r = spawnSync( ffmpegPath, [ "-hide_banner", "-loglevel", "error", "-i", "pipe:0", "-vf", "scale=64:40:flags=area,format=gray", "-frames:v", "1", "-f", "rawvideo", "-", ], { input: png, maxBuffer: 1024 * 1024 }, ); const px = r.stdout; if (!px?.length) return { ratio: 1, mode: 255 }; const hist = new Array(256).fill(0); for (const v of px) hist[v] += 1; let mode = 0; for (let i = 1; i < 256; i++) if (hist[i] > hist[mode]) mode = i; const bg = background ?? mode; let far = 0; for (const v of px) if (Math.abs(v - bg) > 14) far += 1; return { ratio: far / px.length, mode }; } /** * Mede, a cada 1/8 de tela de rolagem, quanto da janela tem conteúdo (pixels que diferem do fundo da página). * Devolve o passo, as frações e o fator de velocidade de cada posição: 1 onde há conteúdo e até `FAST` onde a janela * está quase toda em branco (a rolagem atravessa o vazio depressa em vez de gravar um vídeo branco). */ async function measureBlank(page) { const height = await page.evaluate( () => document.documentElement.scrollHeight, ); const view = await page.evaluate(() => window.innerHeight); const maxScroll = Math.max(0, height - view); const step = Math.max(40, Math.round(view / 8)); const ratios = []; const modes = []; for (let y = 0; y <= maxScroll + step; y += step) { await page.evaluate( ([top, max]) => window.scrollTo(0, Math.min(top, max)), [y, maxScroll], ); await sleep(160); const { ratio, mode } = inkRatio(await page.screenshot({ type: "png" })); ratios.push(ratio); modes.push(mode); } await page.evaluate(() => window.scrollTo(0, 0)); // média móvel curta: a velocidade muda aos poucos, sem arranque nem freada bruscos const smooth = ratios.map((_, i) => { const part = ratios.slice(Math.max(0, i - 2), i + 3); return Math.max(ratios[i], part.reduce((a, b) => a + b, 0) / part.length); }); const factors = smooth.map((ratio) => { if (FAST <= 1 || ratio >= BLANK_HI) return 1; if (ratio <= BLANK_LO) return FAST; const t = (ratio - BLANK_LO) / (BLANK_HI - BLANK_LO); return FAST + (1 - FAST) * t; }); return { step, maxScroll, ratios, factors }; } /** O contexto do Chromium que grava o vídeo, com a página aberta e os erros do console recolhidos em `logs`. */ async function openRecording(browser, vp, rawDir) { 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", colorScheme: COLOR_SCHEME, 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)}`), ); return { context, page, logs }; } /** Abre a página e espera a rede ociosa, as fontes e os pré-carregadores e animações iniciais. */ async function openPage(page) { 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); return response; } /** Esconde as seções (h1 a h3) cujo título tem `text`; devolve os títulos escondidos. */ const hideSections = (page, text) => page.evaluate((needle) => { const hidden = []; for (const heading of document.querySelectorAll("h1, h2, h3")) { const section = heading.closest("section") ?? heading.parentElement; if (!heading.textContent.includes(needle) || !section) continue; section.style.display = "none"; hidden.push(heading.textContent.trim()); } return hidden; }, text); /** * Esconde as imagens que não carregam de jeito nenhum (hospedagem de terceiros fora do ar, por exemplo): aparecem como * ícone de imagem quebrada com o texto alternativo e não vão para o vídeo. Devolve as URLs. */ const hideBrokenImages = (page) => page.evaluate(() => { const broken = [...document.images].filter( (img) => img.complete && img.naturalWidth === 0 && img.currentSrc, ); for (const img of broken) img.style.visibility = "hidden"; return broken.map((img) => img.currentSrc); }); /** * Passe de aquecimento: percorre a página devagar e deixa todas as imagens carregadas e decodificadas, com a rede * ociosa, fecha avisos e esconde o que não deve aparecer; depois volta ao topo (essa parte é cortada do vídeo). */ async function warmUp(page) { const popups1 = await dismissPopups(page); await preloadPage(page); await waitImages(page); await page.evaluate(() => window.scrollTo(0, 0)); await sleep(600); const popups2 = await dismissPopups(page); const images = await imageStatus(page); const hiddenSections = HIDE_TEXT ? await hideSections(page, HIDE_TEXT) : []; const hiddenBrokenImages = await hideBrokenImages(page); return { popupsClosed: [...popups1, ...popups2], images, hiddenSections, hiddenBrokenImages, }; } const pageMetrics = (page) => page.evaluate(() => ({ scrollY: window.scrollY, height: document.documentElement.scrollHeight, view: window.innerHeight, title: document.title, hasHorizontalOverflow: document.documentElement.scrollWidth > window.innerWidth + 1, })); /** Velocidade da rolagem (px/s): o tempo de cada trecho custa passo / fator, e o total cabe em MAX_SECONDS - 2. */ function scrollPlan(blank) { const unitSeconds = blank.factors.reduce( (sum, factor) => sum + blank.step / factor, 0, ); const speed = Math.max(SPEED, unitSeconds / (MAX_SECONDS - 2)); return { speed, expectedScrollSeconds: unitSeconds / speed }; } /** Com `--ready-text`: só segue quando o primeiro

acaba de ganhar esse texto (o começo do slide). */ async function waitReadyText(page, logs) { if (!READY_TEXT) return; // primeiro espera o texto sair (se já estiver lá), depois entrar await page .waitForFunction( (text) => !(document.querySelector("h1")?.textContent ?? "").includes(text), READY_TEXT, { timeout: 20000, polling: 100 }, ) .catch(() => {}); await page .waitForFunction( (text) => (document.querySelector("h1")?.textContent ?? "").includes(text), READY_TEXT, { timeout: 40000, polling: 100 }, ) .catch(() => logs.push(`ready-text "${READY_TEXT}" não apareceu no h1`)); if (READY_WAIT_MS) await sleep(READY_WAIT_MS); } /** Rola a página até o fim, guiada por tempo, na velocidade de cada trecho; devolve onde e em quanto tempo acabou. */ const scrollThrough = (page, plan) => page.evaluate( ({ speedPxS, step, factors }) => new Promise((resolve) => { // fator de velocidade na posição y: interpolado entre as medidas de cada `step` px const factorAt = (y) => { const at = Math.min(factors.length - 1, Math.max(0, y / step)); const i = Math.floor(at); const next = Math.min(factors.length - 1, i + 1); return factors[i] + (factors[next] - factors[i]) * (at - i); }; let y = 0; let last = performance.now(); const start = last; const tick = (now) => { const maxNow = document.documentElement.scrollHeight - window.innerHeight; y = Math.min( y + ((now - last) / 1000) * speedPxS * factorAt(y), maxNow, ); last = now; window.scrollTo(0, y); if (y >= maxNow - 0.5) resolve({ endY: window.scrollY, maxNow, ms: now - start }); else requestAnimationFrame(tick); }; requestAnimationFrame(tick); }), plan, ); /** A rolagem gravada entre os marcadores magenta (início) e ciano (fim); devolve a rolagem e o tempo de relógio. */ async function recordScroll(page, blank, speed) { await flash(page, "rgb(255,0,255)"); const startedAt = Date.now(); await sleep(HOLD_MS); // a página parada no topo: de onde sai o poster const result = await scrollThrough(page, { speedPxS: speed, step: blank.step, factors: blank.factors, }); await sleep(1000); const wallSeconds = (Date.now() - startedAt) / 1000; await flash(page, "rgb(0,255,255)"); return { result, wallSeconds }; } /** Corta o vídeo cru entre os marcadores (intermediário de alta qualidade, só para os cortes); devolve o arquivo e a duração. */ function trimRecording(rawDir) { const rawFile = readdirSync(rawDir).find((f) => f.endsWith(".webm")); const marks = findMarkers(join(rawDir, rawFile)); const dur = (marks.scrollEnd - marks.scrollStart).toFixed(3); const trimmed = join(rawDir, "trimmed.mp4"); ff([ "-ss", marks.scrollStart.toFixed(3), "-i", join(rawDir, rawFile), "-t", dur, "-an", "-r", String(FPS), "-c:v", "libx264", "-crf", "14", "-preset", "veryfast", "-pix_fmt", "yuv420p", trimmed, ]); return { trimmed, dur }; } const ENCODER_ARGS = { mp4: (crf) => [ "-an", "-c:v", "libx264", "-preset", "slow", "-profile:v", "main", "-crf", String(crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart", ], webm: (crf) => [ "-an", "-c:v", "libvpx-vp9", "-b:v", "0", "-crf", String(crf), "-row-mt", "1", "-deadline", "good", "-cpu-used", "2", "-pix_fmt", "yuv420p", ], }; const ENCODER_CRFS = { mp4: [26, 30, 34, 38], webm: [33, 37, 41, 46] }; /** Codifica `kind` com qualidade cada vez menor até caber em MAX_BYTES; devolve se coube e guarda o último em `sizes`. */ function encodeKind(trimmed, out, kind, scale, sizes) { const vf = scale ? ["-vf", `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2`] : []; for (const crf of ENCODER_CRFS[kind]) { ff(["-i", trimmed, ...vf, ...ENCODER_ARGS[kind](crf), out]); sizes[kind] = { crf, scale: scale ?? 1, bytes: statSync(out).size }; if (sizes[kind].bytes <= MAX_BYTES) return true; } return false; } /** MP4 e WebM de até MAX_BYTES cada: se a qualidade mínima não basta, reduz a escala. */ function encodeVideos(trimmed, vpName) { const sizes = {}; for (const kind of ["mp4", "webm"]) { const out = join(outDir, `${vpName}.${kind}`); if (!encodeKind(trimmed, out, kind, null, sizes)) [0.85, 0.7].some((scale) => encodeKind(trimmed, out, kind, scale, sizes)); } return sizes; } function makePoster(vpName) { ff([ "-ss", String(POSTER_AT), "-i", join(outDir, `${vpName}.mp4`), "-frames:v", "1", "-c:v", "libwebp", "-quality", "80", join(outDir, `${vpName}.webp`), ]); } /** O quadro do MP4 pronto no segundo `at`, como PNG. */ const frameAt = (vpName, at) => spawnSync( ffmpegPath, [ "-hide_banner", "-loglevel", "error", "-ss", at.toFixed(2), "-i", join(outDir, `${vpName}.mp4`), "-frames:v", "1", "-f", "image2pipe", "-c:v", "png", "-", ], { maxBuffer: 1024 * 1024 * 16 }, ).stdout; /** 6 quadros do vídeo pronto, igualmente espaçados: nenhum pode estar quase todo em branco. */ function sampleFrames(vpName, dur) { return Array.from({ length: 6 }, (_, i) => { const at = ((i + 0.5) / 6) * Number(dur); const { ratio } = inkRatio(frameAt(vpName, at)); return { at: +at.toFixed(1), ink: +ratio.toFixed(3), blank: ratio < BLANK_LO, }; }); } /** Folha de contato para revisão visual (fora da pasta de saída). */ function contactSheet(trimmed, vpName, dur) { const mobile = vpName === "mobile"; ff([ "-i", trimmed, "-vf", `fps=1/${Math.max(3, Math.round(Number(dur) / 8))},scale=${mobile ? 195 : 480}:-1,tile=${mobile ? 8 : 4}x2`, "-frames:v", "1", join(rawRoot, `${vpName}-contact.png`), ]); } /** O que `--dry` devolve: só a medição dos trechos em branco. */ const dryReport = (vpName, warm, blank) => ({ vp: vpName, images: warm.images, hiddenBrokenImages: warm.hiddenBrokenImages, step: blank.step, inkRatios: blank.ratios.map((r) => +r.toFixed(3)), factors: blank.factors.map((f) => +f.toFixed(1)), }); async function recordOne(browser, vpName) { const rawDir = join(rawRoot, vpName); rmSync(rawDir, { recursive: true, force: true }); mkdirSync(rawDir, { recursive: true }); const { context, page, logs } = await openRecording( browser, VIEWPORTS[vpName], rawDir, ); const response = await openPage(page); const warm = await warmUp(page); const blank = await measureBlank(page); await sleep(600); if (args.dry === "1") { await context.close(); return dryReport(vpName, warm, blank); } const metrics = await pageMetrics(page); const plan = scrollPlan(blank); await waitReadyText(page, logs); const { result, wallSeconds } = await recordScroll(page, blank, plan.speed); await context.close(); const { trimmed, dur } = trimRecording(rawDir); const sizes = encodeVideos(trimmed, vpName); makePoster(vpName); const frames = sampleFrames(vpName, dur); contactSheet(trimmed, vpName, dur); return { vp: vpName, status: response?.status(), title: metrics.title, pageHeight: metrics.height, warmHeight: metrics.height, speedPxS: Math.round(plan.speed), scrollSeconds: +(result.ms / 1000).toFixed(1), expectedScrollSeconds: +plan.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: warm.popupsClosed, horizontalOverflow: metrics.hasHorizontalOverflow, images: warm.images, hiddenBrokenImages: warm.hiddenBrokenImages, hiddenSections: warm.hiddenSections, frames, 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, executablePath: args.chrome, channel: software || args.chrome ? 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));