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.
91 lines
3.2 KiB
TypeScript
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)
|
|
}
|