teqo-backup/scripts/lib/reelRender.mjs
Francisco Solla 258fd22379 feat(C203): gráficos do Instagram na marca e paleta oficiais do kit 1313
- rodapé com jorge-solla-positivo.png (data URI, frame 286×90) no lugar do lockup tipográfico; renderer fail-closed sem a marca
- paleta oficial: destaque/valência boa #e4102f, sinal de marca #184e92; amarelo/verde só nos ativos
- dono único da leitura do kit (scripts/lib/campaignKitAssets.mjs; root por import.meta.url) reusado pelo reel
- lineChart default alinhado ao vermelho oficial; CHART_COLORS/dossiê intactos
- skill/command e pinos de prosa/teste atualizados; SCRIPTS_SPEC_PINNED + impl plan + changelog
2026-09-19 11:24:06 -03:00

177 lines
5.8 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.

/**
* C196 — Playwright rendering of the reel assets: downloads the site's own
* Inter/Brexter `@font-face` rules (extracted during the capture) and embeds
* them as data URIs, renders the transparent caption/chrome overlays and the
* cover PNG, and records the graphic scenes (hook/CTA) with the same
* timestamped screencast recorder used for the site capture.
*
* No database, no app: the site is visited as an anonymous reader.
*/
import { existsSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { REEL_DEVICE_SCALE, REEL_VIEWPORT, sleep, startFrameRecorder } from './reelCapture.mjs'
import { encodeFrames, PREVIEW_CRF, PREVIEW_PRESET } from './reelFfmpeg.mjs'
import { REEL } from './reelTimeline.mjs'
export const openRenderContext = (browser) =>
browser.newContext({ viewport: REEL_VIEWPORT, deviceScaleFactor: REEL_DEVICE_SCALE })
/** `@font-face` rules of the loaded site page (URL sources only, no local()). */
export const extractFontFaces = (page) =>
page.evaluate(() => {
const faces = []
for (const sheet of document.styleSheets) {
let rules
try {
rules = [...sheet.cssRules]
} catch {
continue
}
for (const rule of rules) {
if (!(rule instanceof CSSFontFaceRule)) continue
const src = rule.style.getPropertyValue('src')
if (!src.includes('url(')) continue
faces.push({
family: rule.style.getPropertyValue('font-family').replace(/^["']|["']$/g, ''),
weight: rule.style.getPropertyValue('font-weight') || '400',
style: rule.style.getPropertyValue('font-style') || 'normal',
unicodeRange: rule.style.getPropertyValue('unicode-range'),
src,
})
}
}
return faces
})
const FONT_FORMATS = [
['.woff2', 'woff2'],
['.woff', 'woff'],
['.ttf', 'truetype'],
]
const formatOf = (url) =>
FONT_FORMATS.find(([extension]) => url.includes(extension))?.[1] ?? 'woff2'
const remapFamily = (family) => {
if (/brexter/i.test(family)) return 'ReelBrexter'
if (/inter/i.test(family)) return 'ReelInter'
return null
}
const downloadFont = async ({ url, cacheDir, fetchImpl }) => {
const name = url.split('/').pop().split('?')[0]
const path = join(cacheDir, name)
if (existsSync(path)) return readFile(path)
const response = await fetchImpl(url)
if (!response.ok) throw new Error(`fonte ${url} respondeu ${response.status}`)
const bytes = Buffer.from(await response.arrayBuffer())
await mkdir(dirname(path), { recursive: true })
await writeFile(path, bytes)
return bytes
}
/**
* Builds the `@font-face` CSS with the site's own font files embedded as data
* URIs (relative font URLs resolve against the captured page URL). The brand
* faces are part of the product surface: if the site serves no Brexter or no
* Inter face, the render fails closed instead of silently changing the type.
*/
export const loadFontCss = async ({
fontFaces,
cacheDir,
pageUrl,
fetchImpl = fetch,
warn = console.warn,
}) => {
const css = []
const embedded = new Set()
for (const face of fontFaces) {
const family = remapFamily(face.family)
if (!family) continue
const src = face.src.match(/url\((?:"|')?([^"')]+)(?:"|')?\)/)?.[1]
if (!src) continue
const url = pageUrl ? new URL(src, pageUrl).toString() : src
try {
const bytes = await downloadFont({ url, cacheDir, fetchImpl })
embedded.add(family)
css.push(
`@font-face{font-family:${family};font-style:${face.style};font-weight:${face.weight};` +
`${face.unicodeRange ? `unicode-range:${face.unicodeRange};` : ''}` +
`src:url(data:font/${formatOf(url)};base64,${bytes.toString('base64')}) format('${formatOf(url)}')}`,
)
} catch (error) {
warn(
`Reel: falha ao embutir a fonte ${face.family} (${url.split('/').pop()}): ${error.message}`,
)
}
}
const missing = ['ReelBrexter', 'ReelInter'].filter((family) => !embedded.has(family))
if (missing.length > 0) {
throw new Error(
`O site não serviu as fontes de marca do reel (${missing.join(', ')}). ` +
'A superfície gráfica depende da Brexter/Inter oficiais; verifique o site e regenere.',
)
}
return css.join('\n')
}
const settle = async (page) => {
await page.evaluate(() => document.fonts.ready)
await sleep(80)
}
const renderPng = async ({ page, html, output, transparent = true }) => {
await page.setContent(html, { waitUntil: 'load' })
await settle(page)
await page.screenshot({ path: output, omitBackground: transparent })
return output
}
/** Transparent PNG asset (captions, chrome) at the physical 1080×1920. */
export const renderOverlay = (options) => renderPng(options)
/** Opaque PNG asset (cover) at the physical 1080×1920. */
export const renderCover = (options) => renderPng({ ...options, transparent: false })
/**
* Records a graphic scene (hook/CTA): sets the content, waits for the fonts,
* starts the CSS animation by adding `.play` and records `durationMs`.
*/
export const recordGraphicScene = async ({
context,
template,
html,
durationMs,
output,
framesDir,
ffmpegBin,
fps = REEL.fps,
preset = PREVIEW_PRESET,
crf = PREVIEW_CRF,
}) => {
const page = await context.newPage()
try {
const recorder = await startFrameRecorder({ context, page, framesDir })
await page.setContent(html, { waitUntil: 'load' })
await settle(page)
await page.evaluate(() => document.body.classList.add('play'))
const readyAtMs = Date.now()
await sleep(durationMs)
const stats = await recorder.stop()
await encodeFrames({
ffmpegBin,
frames: stats.frames,
listPath: join(framesDir, 'frames.txt'),
output,
fps,
preset,
crf,
})
return { template, output, readyAtMs, stats }
} finally {
await page.close()
}
}