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:
th committed 2026-04-25 09:49:32 +02:00
1 parent b0813a1d50
commit b5c5270256
6 files changed
+299 -2

No files matched your search

+139
View File
@@ -1439,6 +1439,84 @@ export function registerAllTests(): void {
})
})
suite('export — still image', () => {
// The still-image export reuses the animated render path so the
// saved PNG/JPG matches the corresponding frame in a GIF/APNG
// exactly. These checks pin the public surface (blob mime,
// dimensions, transparency handling) rather than re-testing the
// render itself.
function tinyScene() {
const s = makeFreshStore()
s.setDuration(0.25)
s.project.fps = 4
s.scene.canvas.width = 16
s.scene.canvas.height = 16
const a = s.addAsset({
kind: 'rect', name: 'P', width: 16, height: 16, fill: '#22cc88',
})
s.addLayer(a.id)!
return s
}
test('renderSceneToStillImage returns a PNG blob with the requested size', async () => {
const { renderSceneToStillImage } = await import('@/lib/stillImageExport')
const s = tinyScene()
const blob = await renderSceneToStillImage(s.scene, 0, 'png', {
targetWidth: 64, targetHeight: 64,
})
expect(blob.type).toBe('image/png')
expect(blob.size > 0).toBe(true)
})
test('renderSceneToStillImage returns a JPEG blob for format=jpg', async () => {
const { renderSceneToStillImage } = await import('@/lib/stillImageExport')
const s = tinyScene()
const blob = await renderSceneToStillImage(s.scene, 0, 'jpg', {
targetWidth: 32, targetHeight: 32,
})
expect(blob.type).toBe('image/jpeg')
expect(blob.size > 0).toBe(true)
})
test('JPG of a transparent scene gets a solid white backdrop', async () => {
// Sanity: JPG can't carry alpha; a transparent scene must be
// composited onto an opaque background or it'd come out black.
const { renderSceneToStillImage } = await import('@/lib/stillImageExport')
const s = tinyScene()
s.scene.canvas.background = 'transparent'
const blob = await renderSceneToStillImage(s.scene, 0, 'jpg', {
targetWidth: 8, targetHeight: 8,
})
// Decode the blob back into pixel data and check at least one
// pixel that should NOT have been touched by the green rect.
// Easiest: render at 1×1 covering the whole canvas, sample
// origin. Better: re-render to a small canvas so we can read
// pixels deterministically without a network round-trip.
const url = URL.createObjectURL(blob)
try {
const img = new Image()
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = (e) => reject(e)
img.src = url
})
const c = document.createElement('canvas')
c.width = 8; c.height = 8
const cx = c.getContext('2d')!
cx.drawImage(img, 0, 0)
// The whole 16×16 scene was a green rect, so every pixel of
// the JPEG is green — what we want to confirm is that it's
// NOT black. Read center pixel and assert at least one of
// R/G/B is non-zero, which rules out a pure-black backdrop.
const px = cx.getImageData(4, 4, 1, 1).data
expect(px[0] + px[1] + px[2] > 0).toBe(true)
} finally {
URL.revokeObjectURL(url)
}
})
})
suite('ui — ExportDialog', () => {
// ExportDialog teleports through Modal so query through document
// rather than the handle's host root. Each test snapshots the
@@ -1614,6 +1692,67 @@ export function registerAllTests(): void {
}
})
test('renders the Still image subgroup with PNG + JPG options', async () => {
// The third subgroup lives under the project column and is
// driven by the playhead, not the playback range. The heading
// exists and both options surface.
makeFreshStore()
const baseline = snapshotDialogs()
const handle = mountComponent(ExportDialog, { modelValue: true })
try {
await nextTick()
const dialog = dialogsAddedSince(baseline)[0]
const headings = Array.from(dialog.querySelectorAll('.col-heading')).map(
(el) => el.textContent?.trim() ?? '',
)
expect(headings).toContain(i18nT('export.colStill'))
expect(findOptionByTitle(dialog, i18nT('export.stillPngTitle'))).toBeTruthy()
expect(findOptionByTitle(dialog, i18nT('export.stillJpgTitle'))).toBeTruthy()
} finally {
handle.unmount()
}
})
test('clicking the PNG still triggers a download with .png extension', async () => {
// Stub anchor.click so the test sees the URL/download attrs
// without actually navigating. Use a minimal scene so render
// is fast. After click we assert the dialog closed.
const s = makeFreshStore()
s.setDuration(0.25)
s.project.fps = 4
s.scene.canvas.width = 8
s.scene.canvas.height = 8
const a = s.addAsset({
kind: 'rect', name: 'P', width: 8, height: 8, fill: '#22cc88',
})
s.addLayer(a.id)!
const downloads: { name: string; href: string }[] = []
const originalClick = HTMLAnchorElement.prototype.click
HTMLAnchorElement.prototype.click = function () {
const a = this as HTMLAnchorElement
downloads.push({ name: a.download, href: a.href })
}
const baseline = snapshotDialogs()
const handle = mountComponent(ExportDialog, { modelValue: true })
try {
await nextTick()
const dialog = dialogsAddedSince(baseline)[0]
const btn = findOptionByTitle(dialog, i18nT('export.stillPngTitle'))
expect(btn).toBeTruthy()
btn!.click()
// Render is async — wait for the toBlob callback chain.
for (let i = 0; i < 20 && downloads.length === 0; i++) {
await new Promise<void>((r) => setTimeout(r, 5))
}
expect(downloads.length).toBe(1)
expect(downloads[0].name.endsWith('.png')).toBe(true)
} finally {
HTMLAnchorElement.prototype.click = originalClick
handle.unmount()
}
})
test('clicking APNG preview transitions the dialog to the progress view', async () => {
// Tiny scene so the async render stays short. We assert the
// synchronous state transition right after the click — the