import type { Scene } from '@/types/scene' import { drawSceneFrame, loadSceneImages } from './render' export type StillImageFormat = 'png' | 'jpg' export interface StillImageExportOptions { /** Output width in pixels. Defaults to the scene canvas width. */ targetWidth?: number /** Output height. Caller derives from targetWidth + scene aspect to * avoid squashing. */ targetHeight?: number /** JPEG quality 0..1 (PNG ignores this). Defaults to 0.92 — visually * indistinguishable from the source for most flat motion graphics. */ quality?: number /** Background fill applied under the frame. Useful for JPG (no * alpha) so transparent scenes don't come out as black blocks. * Defaults to white when format is 'jpg' and the scene background * is transparent. PNG passes alpha through unchanged. */ background?: string } /** Render a single scene frame at `time` to a Blob. The caller decides * what `time` means (usually the editor playhead). Reuses the same * `drawSceneFrame` path as the animated formats, so the still image * is pixel-identical to the matching frame in the GIF/APNG export. */ export async function renderSceneToStillImage( scene: Scene, time: number, format: StillImageFormat, opts: StillImageExportOptions = {}, ): Promise { const srcWidth = scene.canvas.width const srcHeight = scene.canvas.height const width = Math.max(1, Math.round(opts.targetWidth ?? srcWidth)) const height = Math.max(1, Math.round(opts.targetHeight ?? srcHeight)) const canvas = document.createElement('canvas') canvas.width = width canvas.height = height const ctx = canvas.getContext('2d') if (!ctx) throw new Error('2D context unavailable') // JPG can't carry alpha — paint a solid backdrop first so a // transparent scene doesn't fall back to black. PNG keeps alpha. const sceneBg = scene.canvas.background const needsOpaqueBackdrop = format === 'jpg' && (!sceneBg || sceneBg === 'transparent') const backdrop = opts.background ?? (needsOpaqueBackdrop ? '#ffffff' : null) if (backdrop) { ctx.fillStyle = backdrop ctx.fillRect(0, 0, width, height) } const scaleX = width / srcWidth const scaleY = height / srcHeight if (scaleX !== 1 || scaleY !== 1) ctx.scale(scaleX, scaleY) const images = await loadSceneImages(scene) drawSceneFrame(ctx, scene, Math.max(0, time), images) const mime = format === 'jpg' ? 'image/jpeg' : 'image/png' const quality = format === 'jpg' ? (opts.quality ?? 0.92) : undefined return new Promise((resolve, reject) => { canvas.toBlob( (blob) => { if (blob) resolve(blob) else reject(new Error('Failed to encode still image')) }, mime, quality, ) }) } export async function downloadSceneAsStillImage( scene: Scene, time: number, format: StillImageFormat, opts: StillImageExportOptions = {}, filenameBase = 'frame', ): Promise { const blob = await renderSceneToStillImage(scene, time, format, opts) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `${filenameBase}.${format === 'jpg' ? 'jpg' : 'png'}` document.body.appendChild(a) a.click() document.body.removeChild(a) setTimeout(() => URL.revokeObjectURL(url), 1000) }