import { mkdirSync, readFileSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import path from 'path' import { launchReapedChrome } from '../../../../scripts/lib/reap-browser' import { runFlowPlayback } from '../../cli/commands/flow' import { composeMarketingScreenshots, type MarketingBrowserLike, type ComposeResult, type ComposeSlideSource, } from './compose' import { createFramedScreenshotComposer } from './frame-compose' import { applyOrgDeckToPlan, type OrgDeckFrame } from './org-deck' import { loadScreenshotsPlan, type NormalizedScreenshotsPlan } from './schema' export interface RunScreenshotsOverrides { appTarget?: string | null deviceModel?: string | null simId?: string | null captureOnly?: boolean composeOnly?: boolean // copy/colors pulled from the team's org deck to overlay onto the plan's // compose slides (rnx screenshot appstore --org … --repo …). orgDeckFrames?: OrgDeckFrame[] | null } export interface CaptureResult { manifestPath: string rawDir: string framedDir: string rawFiles: string[] framedFiles: string[] } export interface ScreenshotsRunResult { plan: NormalizedScreenshotsPlan capture: CaptureResult compose: ComposeResult | null } function resolveCapturePathMode(plan: NormalizedScreenshotsPlan): 'plan' | 'flow' { if (plan.capture.pathMode === 'plan' || plan.capture.pathMode === 'flow') { return plan.capture.pathMode } return plan.capture.fromDir ? 'flow' : 'plan' } export function buildCaptureFlowArgs( plan: NormalizedScreenshotsPlan, overrides: RunScreenshotsOverrides, ): string[] { if (!plan.capture.flowPath) { throw new Error('capture flow path is required to build flow args') } const args = [plan.capture.flowPath] const pathMode = resolveCapturePathMode(plan) args.push('--screenshots', plan.capture.rawDir) if (pathMode === 'flow') { args.push('--screenshot-paths', 'flow') } const appTarget = overrides.appTarget ?? plan.appTarget const deviceModel = overrides.deviceModel ?? plan.deviceModel const simId = overrides.simId ?? plan.capture.simId if (appTarget) args.push('--url', appTarget) if (deviceModel) args.push('--device', deviceModel) if (simId) args.push('--sim', simId) if (plan.capture.openInNewSim) args.push('--new') return args } function resolveSlideRawPath( plan: NormalizedScreenshotsPlan, screenshot: string, ): string { return path.isAbsolute(screenshot) ? screenshot : path.join(plan.capture.rawDir, screenshot) } function resolveCaptureManifestPath(plan: NormalizedScreenshotsPlan): string { return path.join(plan.capture.outDir, 'manifest.json') } async function runCaptureStage( plan: NormalizedScreenshotsPlan, overrides: RunScreenshotsOverrides, ): Promise { if (!plan.capture.flowPath) return mkdirSync(plan.capture.rawDir, { recursive: true }) const args = buildCaptureFlowArgs(plan, overrides) const exitCode = await runFlowPlayback(args) if (exitCode !== 0) { throw new Error(`flow capture failed with exit code ${exitCode}`) } } async function buildFramedSources( plan: NormalizedScreenshotsPlan, browserOverride?: MarketingBrowserLike, ): Promise> { const sources = new Map() mkdirSync(plan.capture.framedDir, { recursive: true }) const browser = browserOverride ?? (await (async () => { const { chromium } = await import('playwright') return launchReapedChrome((options) => chromium.launch(options), { headless: true, }) })()) try { const composer = await createFramedScreenshotComposer(browser) try { for (const slide of plan.compose.slides) { const rawPath = resolveSlideRawPath(plan, slide.screenshot) const rawBuffer = readFileSync(rawPath) const framedBuffer = await composer.compose(rawBuffer, plan.compose.frame.style) const framedPath = path.join(plan.capture.framedDir, `${slide.assetKey}.png`) writeFileSync(framedPath, framedBuffer) sources.set(slide.id, framedPath) } } finally { await composer.close() } } finally { if (!browserOverride && typeof browser.close === 'function') { await browser.close() } } return sources } function writeCaptureManifest( plan: NormalizedScreenshotsPlan, framedSources: Map, effectiveDeviceModel: string | null, ): CaptureResult { const rawFiles: string[] = [] const framedFiles: string[] = [] mkdirSync(plan.capture.outDir, { recursive: true }) const manifestPath = resolveCaptureManifestPath(plan) const slides = plan.compose.slides.map((slide) => { const rawPath = resolveSlideRawPath(plan, slide.screenshot) const framedPath = framedSources.get(slide.id) ?? null rawFiles.push(rawPath) if (framedPath) framedFiles.push(framedPath) return { id: slide.id, screenshot: slide.screenshot, rawPath, framedPath, } }) writeFileSync( manifestPath, JSON.stringify( { generatedAt: new Date().toISOString(), deviceModel: effectiveDeviceModel, mode: plan.capture.mode, rawDir: plan.capture.rawDir, framedDir: plan.capture.framedDir, slides, }, null, 2, ), ) return { manifestPath, rawDir: plan.capture.rawDir, framedDir: plan.capture.framedDir, rawFiles, framedFiles, } } function resolveComposeSources( plan: NormalizedScreenshotsPlan, framedSources: Map, ): ComposeSlideSource[] { return plan.compose.slides.map((slide) => ({ slide, imagePath: plan.compose.frame.show === true ? (framedSources.get(slide.id) ?? resolveSlideRawPath(plan, slide.screenshot)) : resolveSlideRawPath(plan, slide.screenshot), })) } export async function runScreenshotsPlan( planPath: string, overrides: RunScreenshotsOverrides = {}, ): Promise { const plan = loadScreenshotsPlan(planPath) if (overrides.orgDeckFrames && overrides.orgDeckFrames.length > 0) { applyOrgDeckToPlan(plan, overrides.orgDeckFrames) } const effectiveDeviceModel = overrides.deviceModel ?? plan.deviceModel if (!overrides.composeOnly) { await runCaptureStage(plan, overrides) } const needsFramedSources = plan.capture.mode !== 'raw' || (!overrides.captureOnly && plan.compose.frame.show === true) const needsSharedBrowser = needsFramedSources || !overrides.captureOnly const sharedBrowser = needsSharedBrowser ? await (async () => { const { chromium } = await import('playwright') return launchReapedChrome((options) => chromium.launch(options), { headless: true, }) })() : null try { const framedSources = needsFramedSources ? await buildFramedSources(plan, sharedBrowser ?? undefined) : new Map() const capture = writeCaptureManifest(plan, framedSources, effectiveDeviceModel) if (overrides.captureOnly) { return { plan, capture, compose: null } } const composeSources = resolveComposeSources(plan, framedSources) const compose = await composeMarketingScreenshots( plan.compose, composeSources, sharedBrowser ?? undefined, ) return { plan, capture, compose } } finally { if (sharedBrowser && typeof sharedBrowser.close === 'function') { await sharedBrowser.close() } } } export async function runScreenshotsPlanFromExistingRaw( planPath: string, rawDir: string, ): Promise { const plan = loadScreenshotsPlan(planPath) plan.capture.rawDir = rawDir plan.capture.flowPath = null plan.capture.fromDir = rawDir const framedDir = path.join(tmpdir(), 'rnx-screenshots-framed') plan.capture.framedDir = framedDir const framedSources = await buildFramedSources(plan) const capture = writeCaptureManifest(plan, framedSources, plan.deviceModel) const compose = await composeMarketingScreenshots( plan.compose, resolveComposeSources(plan, framedSources), ) return { plan, capture, compose } }