import { mkdirSync, readFileSync, writeFileSync } from 'fs' import path from 'path' import { launchReapedChrome } from '../../../../scripts/lib/reap-browser' import { DEFAULT_FRAME_SHADOW, toAssetKey, type BackgroundSpec, type CanvasPreset, type FrameComposeSpec, type PosePresetName, type TextPresetName, } from './registry' import type { NormalizedComposePlan, NormalizedScreenshotSlide } from './schema' export interface ComposeSlideSource { slide: NormalizedScreenshotSlide imagePath: string } export interface ComposeManifestEntry { slideId: string canvas: string locale: string outputPath: string sourcePath: string } export interface ComposeResult { manifestPath: string outputs: string[] entries: ComposeManifestEntry[] } export type MarketingBrowserLike = { newContext: (opts: { viewport: { width: number; height: number } }) => Promise<{ newPage: () => Promise<{ setViewportSize: (size: { width: number; height: number }) => Promise setContent: (html: string, opts: { waitUntil: 'load' }) => Promise waitForTimeout: (ms: number) => Promise screenshot: (opts: { type: 'png' clip: { x: number; y: number; width: number; height: number } }) => Promise close: () => Promise }> close: () => Promise }> close?: () => Promise } function escapeHtml(value: string): string { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') } function normalizeHexColor(color: string): string { const value = color.trim() if (/^#[0-9a-f]{3}$/i.test(value)) { return `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}` } return value } function toRgba(color: string, alpha: number): string { const value = normalizeHexColor(color) const clampedAlpha = Math.max(0, Math.min(1, alpha)) const hexMatch = value.match(/^#([0-9a-f]{6})$/i) if (hexMatch) { const hex = hexMatch[1] const r = Number.parseInt(hex.slice(0, 2), 16) const g = Number.parseInt(hex.slice(2, 4), 16) const b = Number.parseInt(hex.slice(4, 6), 16) return `rgba(${r}, ${g}, ${b}, ${clampedAlpha})` } if (value.startsWith('rgb(')) { return value.replace(/^rgb\((.+)\)$/i, `rgba($1, ${clampedAlpha})`) } return value } function backgroundCss(background: BackgroundSpec): string { if (background.type === 'solid') { return background.color || '#000000' } const direction = background.direction ?? 180 const stops = background.stops?.map((stop) => `${stop.color} ${stop.offset}%`).join(', ') || '#000000 0%, #111111 100%' return `linear-gradient(${direction}deg, ${stops})` } function glowCss(background: BackgroundSpec): string { const glow = background.glow if (!glow) return 'none' return `radial-gradient(circle at 50% 24%, ${toRgba(glow, 0.46)} 0%, ${toRgba(glow, 0.2)} 24%, rgba(0,0,0,0) 62%)` } function poseTransform(pose: PosePresetName): string { switch (pose) { case 'tilted-left': return 'perspective(2400px) rotateY(10deg) rotateZ(-2deg)' case 'tilted-right': return 'perspective(2400px) rotateY(-10deg) rotateZ(2deg)' case 'cut-bottom': return 'translateY(10%) scale(1.08)' case 'cut-top': return 'translateY(-16%) scale(1.08)' case 'straight': default: return 'none' } } function combineTransforms(...parts: string[]): string { const active = parts .map((part) => part.trim()) .filter((part) => part.length > 0 && part !== 'none') return active.length > 0 ? active.join(' ') : 'none' } function shadowFilter(shadow: FrameComposeSpec['shadow']): string { const glowColor = toRgba(shadow.color, shadow.opacity) const lowerGlow = toRgba(shadow.color, shadow.opacity * 0.52) return `drop-shadow(0 34px ${shadow.spread}px ${lowerGlow}) drop-shadow(0 0 ${shadow.blur}px ${glowColor})` } function resolveSlideShadow( shadow: FrameComposeSpec['shadow'], background: BackgroundSpec, ): FrameComposeSpec['shadow'] { if (!background.glow) return shadow if (shadow.color && shadow.color !== DEFAULT_FRAME_SHADOW.color) return shadow return { ...shadow, color: background.glow, } } function textLayout( preset: TextPresetName, canvas: CanvasPreset, ): { copyStyle: string titleStyle: string subStyle: string eyebrowStyle: string deviceStyle: ( frame: FrameComposeSpec, slide: NormalizedScreenshotSlide, background: BackgroundSpec, ) => string } { if (preset === 'editorial-left') { const headline = Math.round(canvas.height * 0.056) const subhead = Math.round(canvas.height * 0.022) return { copyStyle: [ 'position:absolute', `top:${Math.round(canvas.height * 0.12)}px`, `left:${Math.round(canvas.width * 0.085)}px`, `width:${Math.round(canvas.width * 0.42)}px`, 'text-align:left', 'z-index:3', ].join(';'), titleStyle: `font-size:${headline}px;line-height:0.92;`, subStyle: `font-size:${subhead}px;line-height:1.36;max-width:${Math.round(canvas.width * 0.36)}px;`, eyebrowStyle: 'align-items:flex-start;', deviceStyle: (frame, slide, background) => { const width = Math.round(canvas.width * 0.68 * (slide.scale ?? frame.scale)) const offsetY = Math.round( canvas.height * (0.03 + frame.offsetY + (slide.offsetY ?? 0)), ) const pose = slide.pose || frame.pose || 'tilted-right' const shadow = resolveSlideShadow(frame.shadow, background) return [ 'position:absolute', `top:${Math.round(canvas.height * 0.18) + offsetY}px`, `left:${Math.round(canvas.width * 0.56)}px`, `width:${width}px`, 'transform-origin:center center', `transform:${poseTransform(pose)}`, `filter:${shadowFilter(shadow)}`, 'z-index:2', ].join(';') }, } } if (preset === 'minimal-bottom') { const headline = Math.round(canvas.height * 0.041) const subhead = Math.round(canvas.height * 0.019) return { copyStyle: [ 'position:absolute', `left:${Math.round(canvas.width * 0.12)}px`, `right:${Math.round(canvas.width * 0.12)}px`, `bottom:${Math.round(canvas.height * 0.085)}px`, 'text-align:center', 'z-index:3', ].join(';'), titleStyle: `font-size:${headline}px;line-height:0.96;`, subStyle: `font-size:${subhead}px;line-height:1.34;max-width:${Math.round(canvas.width * 0.56)}px;margin:0 auto;`, eyebrowStyle: 'align-items:center;', deviceStyle: (frame, slide, background) => { const width = Math.round(canvas.width * 0.84 * (slide.scale ?? frame.scale)) const offsetY = Math.round(canvas.height * (frame.offsetY + (slide.offsetY ?? 0))) const pose = slide.pose || frame.pose const shadow = resolveSlideShadow(frame.shadow, background) return [ 'position:absolute', `top:${Math.round(canvas.height * 0.075) + offsetY}px`, 'left:50%', `width:${width}px`, 'transform-origin:center center', `transform:${combineTransforms('translateX(-50%)', poseTransform(pose))}`, `filter:${shadowFilter(shadow)}`, 'z-index:2', ].join(';') }, } } const headline = Math.round(canvas.height * 0.04) const subhead = Math.round(canvas.height * 0.019) return { copyStyle: [ 'position:absolute', `top:${Math.round(canvas.height * 0.084)}px`, 'left:50%', `width:${Math.round(canvas.width * 0.88)}px`, 'transform:translateX(-50%)', 'text-align:center', 'z-index:3', ].join(';'), titleStyle: `font-size:${headline}px;line-height:0.92;`, subStyle: `font-size:${subhead}px;line-height:1.34;max-width:${Math.round(canvas.width * 0.6)}px;margin:0 auto;`, eyebrowStyle: 'align-items:center;', deviceStyle: (frame, slide, background) => { const width = Math.round(canvas.width * 0.64 * (slide.scale ?? frame.scale)) const offsetY = Math.round(canvas.height * (frame.offsetY + (slide.offsetY ?? 0))) const pose = slide.pose || frame.pose const shadow = resolveSlideShadow(frame.shadow, background) return [ 'position:absolute', `top:${Math.round(canvas.height * 0.28) + offsetY}px`, 'left:50%', `width:${width}px`, 'transform-origin:center top', `transform:${combineTransforms('translateX(-50%)', poseTransform(pose))}`, `filter:${shadowFilter(shadow)}`, 'z-index:2', ].join(';') }, } } function renderSlideHtml({ canvas, compose, slide, imageDataUrl, }: { canvas: CanvasPreset compose: NormalizedComposePlan slide: NormalizedScreenshotSlide imageDataUrl: string }): string { const background = slide.background ?? compose.background const layout = textLayout(compose.text.preset, canvas) const copyPresent = compose.text.preset !== 'none' && Boolean(slide.eyebrow || slide.headline || slide.subheadline) const imageStyle = layout.deviceStyle(compose.frame, slide, background) const titleHtml = slide.headline ? `
${escapeHtml(slide.headline)}
` : '' const subHtml = slide.subheadline ? `
${escapeHtml(slide.subheadline)}
` : '' const eyebrowHtml = slide.eyebrow ? `
${escapeHtml(slide.eyebrow)}
` : '' return `
${copyPresent ? `
${eyebrowHtml}${titleHtml}${subHtml}
` : ''}
` } export async function composeMarketingScreenshots( compose: NormalizedComposePlan, slides: ComposeSlideSource[], browserOverride?: MarketingBrowserLike, ): Promise { const entries: ComposeManifestEntry[] = [] const browser = browserOverride ?? (await (async () => { const { chromium } = await import('playwright') return launchReapedChrome((options) => chromium.launch(options), { headless: true, }) })()) try { const context = await browser.newContext({ viewport: { width: 1280, height: 720 } }) try { for (const slideSource of slides) { const buffer = readFileSync(slideSource.imagePath) const imageDataUrl = `data:image/png;base64,${buffer.toString('base64')}` for (const canvas of compose.canvases) { const page = await context.newPage() try { await page.setViewportSize({ width: canvas.width, height: canvas.height }) await page.setContent( renderSlideHtml({ canvas, compose, slide: slideSource.slide, imageDataUrl, }), { waitUntil: 'load' }, ) await page.waitForTimeout(20) const outputDir = path.join(compose.outDir, canvas.name, compose.locale) mkdirSync(outputDir, { recursive: true }) const outputPath = path.join(outputDir, `${slideSource.slide.assetKey}.png`) const png = (await page.screenshot({ type: 'png', clip: { x: 0, y: 0, width: canvas.width, height: canvas.height }, })) as Buffer writeFileSync(outputPath, png) entries.push({ slideId: slideSource.slide.id, canvas: canvas.name, locale: compose.locale, outputPath, sourcePath: slideSource.imagePath, }) } finally { await page.close() } } } } finally { await context.close() } } finally { if (!browserOverride && typeof browser.close === 'function') { await browser.close() } } mkdirSync(compose.outDir, { recursive: true }) const manifestPath = path.join(compose.outDir, 'manifest.json') writeFileSync( manifestPath, JSON.stringify( { generatedAt: new Date().toISOString(), locale: compose.locale, outputs: entries, }, null, 2, ), ) return { manifestPath, outputs: entries.map((entry) => entry.outputPath), entries, } }