Files
RUWIO/src/lib/stillImageExport.ts
T
th b5c5270256 1.1.6: still image export — PNG / JPG snapshot of the current frame
Adds a "Still image" subgroup under the project column in the export
dialog. Both options render the current playhead frame via the same
drawSceneFrame path used by GIF/APNG, so the still is pixel-identical
to the matching animated frame. JPG fills transparent scenes white;
PNG keeps alpha. Honours the per-export target size for higher-res
poster frames.
2026-04-25 09:49:32 +02:00

91 lines
3.2 KiB
TypeScript

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<Blob> {
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<Blob>((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<void> {
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)
}