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.
This commit is contained in:
1 parent
b0813a1d50
commit
b5c5270256
6 files changed
+299
-2
No files matched your search
@@ -0,0 +1,90 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user