mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
- 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
177 lines
5.8 KiB
JavaScript
177 lines
5.8 KiB
JavaScript
/**
|
||
* 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()
|
||
}
|
||
}
|