0.16.0: animated GIF / APNG export + transparent canvas

- New frame-by-frame export pipeline in src/lib/frameExport.ts: loads
  scene images, walks rangeIn..rangeOut at scene.fps, drawSceneFrame
  to an offscreen canvas, awaits a per-frame callback, yields every
  4 frames to keep progress responsive, honours AbortSignal.
- Animated GIF via gifenc (per-frame quantize/palette, ~5 KB lib).
- Animated PNG via UPNG.js + pako (lossless RGBA, alpha-aware, ~30 KB).
- drawSceneFrame now clearRects first and skips the background fill
  when scene.canvas.background === 'transparent' — prerequisite for
  the alpha to reach the APNG output.
- DocumentDrawer: new checkerboard toggle next to the colour picker
  flips the stage between its last-picked colour and transparent;
  disabled swatch + 'Transparent' hex label make the state obvious.
- ExportDialog: three new option rows — Animated GIF, Animated PNG,
  and an APNG preview page that bundles the rendered APNG into a
  standalone HTML with a checkerboard backdrop + dark/light toggles
  for verifying transparency at a glance.
- Tests: renderFrames frame count + abort, GIF header magic, APNG
  acTL presence, transparent round-trip (decoded alpha == 0 on
  un-painted pixels), preview HTML structure, and the two UI tests
  covering ExportDialog's new rows and the progress-view transition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-24 10:21:30 +02:00
1 parent 827d80bfd0
commit fcfd21428d
12 files changed
+956 -14

No files matched your search

+229
View File
@@ -22,6 +22,9 @@ import {
generateRuntimeScript,
generateSceneScript,
} from '@/lib/exportPlayer'
import { renderFrames } from '@/lib/frameExport'
import { renderSceneToGif } from '@/lib/gifExport'
import { generateApngPreviewHtml, renderSceneToApng } from '@/lib/apngExport'
import { ProjectFileError, parseProject, serializeProject } from '@/lib/projectFile'
import { createHistory } from '@/composables/useHistory'
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
@@ -47,6 +50,7 @@ import ActionEditor from '@/components/ActionEditor.vue'
import Timeline from '@/components/Timeline.vue'
import AssetSidebar from '@/components/AssetSidebar.vue'
import CanvasStage from '@/components/CanvasStage.vue'
import ExportDialog from '@/components/ExportDialog.vue'
import FullscreenPreview from '@/components/FullscreenPreview.vue'
import HelpDialog from '@/components/HelpDialog.vue'
import { createAudioRuntime } from '@/composables/useAudioRuntime'
@@ -991,6 +995,146 @@ export function registerAllTests(): void {
expect(src).toContain('BundlR')
})
test('renderFrames yields fps*duration frames across the playback range', async () => {
const s = makeFreshStore()
s.setDuration(1)
s.project.fps = 10
// Tiny 4×4 canvas keeps the loop fast — we only care about counts.
s.scene.canvas.width = 4
s.scene.canvas.height = 4
const a = s.addAsset({ kind: 'rect', name: 'R', width: 4, height: 4, fill: '#111' })
s.addLayer(a.id)!
const frames: number[] = []
await renderFrames(s.scene, ({ frameIndex, width, height }) => {
expect(width).toBe(4)
expect(height).toBe(4)
frames.push(frameIndex)
})
// 1 second at 10fps → 10 frames (indices 0..9).
expect(frames.length).toBe(10)
expect(frames[0]).toBe(0)
expect(frames[frames.length - 1]).toBe(9)
})
test('renderFrames aborts when the signal fires', async () => {
const s = makeFreshStore()
s.setDuration(1)
s.project.fps = 10
s.scene.canvas.width = 4
s.scene.canvas.height = 4
const a = s.addAsset({ kind: 'rect', name: 'R', width: 4, height: 4, fill: '#111' })
s.addLayer(a.id)!
const controller = new AbortController()
let rendered = 0
const promise = renderFrames(
s.scene,
() => {
rendered += 1
if (rendered === 2) controller.abort()
},
{ signal: controller.signal },
)
let threw = false
try {
await promise
} catch (err) {
threw = (err as Error).name === 'AbortError'
}
expect(threw).toBe(true)
expect(rendered).toBeLessThan(10)
})
test('renderSceneToGif emits a GIF89a blob', async () => {
const s = makeFreshStore()
s.setDuration(0.5)
s.project.fps = 4
s.scene.canvas.width = 8
s.scene.canvas.height = 8
const a = s.addAsset({ kind: 'rect', name: 'R', width: 8, height: 8, fill: '#22cc88' })
s.addLayer(a.id)!
const blob = await renderSceneToGif(s.scene)
expect(blob.type).toBe('image/gif')
expect(blob.size > 0).toBe(true)
const bytes = new Uint8Array(await blob.arrayBuffer())
// GIF magic: "GIF" + "87a" or "89a"
const header = String.fromCharCode(bytes[0], bytes[1], bytes[2])
expect(header).toBe('GIF')
})
test('generateApngPreviewHtml embeds the data URL + checkerboard CSS', () => {
const dataUrl = 'data:image/png;base64,AAAA'
const html = generateApngPreviewHtml(dataUrl, 320, 180)
// Payload is embedded so the exported HTML works as a single file.
expect(html).toContain(dataUrl)
// Aspect ratio drives the stage box.
expect(html).toContain('aspect-ratio: 320 / 180')
// Checkerboard + toggle buttons are present.
expect(html).toContain('.stage.checker')
expect(html).toContain('data-bg="checker"')
expect(html).toContain('data-bg="solid-dark"')
expect(html).toContain('data-bg="solid-light"')
})
test('transparent background survives the APNG round-trip (alpha preserved)', async () => {
// A transparent canvas with no layers should encode a fully
// transparent animation. We decode the produced blob and verify
// at least one pixel's alpha channel is zero.
const s = makeFreshStore()
s.setDuration(0.5)
s.project.fps = 4
s.scene.canvas.width = 8
s.scene.canvas.height = 8
s.setCanvasBackground('transparent')
const blob = await renderSceneToApng(s.scene)
const buf = await blob.arrayBuffer()
const { default: UPNG } = await import('upng-js')
const decoded = UPNG.decode(buf)
const rgbaFrames = UPNG.toRGBA8(decoded)
expect(rgbaFrames.length > 0).toBe(true)
const first = new Uint8Array(rgbaFrames[0])
// Scan every pixel's alpha byte — at least one should be 0 since
// nothing painted on a transparent canvas.
let sawTransparent = false
for (let i = 3; i < first.length; i += 4) {
if (first[i] === 0) {
sawTransparent = true
break
}
}
expect(sawTransparent).toBe(true)
})
test('renderSceneToApng emits a PNG blob with an acTL animation chunk', async () => {
const s = makeFreshStore()
s.setDuration(0.5)
s.project.fps = 4
s.scene.canvas.width = 8
s.scene.canvas.height = 8
const a = s.addAsset({ kind: 'rect', name: 'R', width: 8, height: 8, fill: '#22cc88' })
s.addLayer(a.id)!
const blob = await renderSceneToApng(s.scene)
expect(blob.type).toBe('image/png')
expect(blob.size > 0).toBe(true)
const bytes = new Uint8Array(await blob.arrayBuffer())
// PNG magic: 0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A
expect(bytes[0]).toBe(0x89)
expect(String.fromCharCode(bytes[1], bytes[2], bytes[3])).toBe('PNG')
// Scan for the 'acTL' chunk type — present only in animated PNGs.
let hasActl = false
for (let i = 8; i < bytes.length - 4; i++) {
if (
bytes[i] === 0x61 &&
bytes[i + 1] === 0x63 &&
bytes[i + 2] === 0x54 &&
bytes[i + 3] === 0x4c
) {
hasActl = true
break
}
}
expect(hasActl).toBe(true)
})
test('runtime lerpProps interpolates shadow + blur keyframes', () => {
// Regression: shadow properties (shadowBlur/Opacity/Angle/Distance/
// Color) and blur used to be dropped by the runtime's lerpProps,
@@ -1160,6 +1304,91 @@ export function registerAllTests(): void {
})
})
suite('ui — ExportDialog', () => {
// ExportDialog teleports through Modal so query through document
// rather than the handle's host root. Each test snapshots the
// live dialogs first and diffs after mount to isolate its own.
const snapshotDialogs = () =>
new Set(Array.from(document.querySelectorAll('[role="dialog"]')))
const dialogsAddedSince = (baseline: Set<Element>) =>
Array.from(document.querySelectorAll('[role="dialog"]')).filter(
(el) => !baseline.has(el),
) as HTMLElement[]
function findOptionByTitle(dialog: HTMLElement, title: string): HTMLButtonElement | null {
const items = dialog.querySelectorAll('button.opt')
for (const it of Array.from(items)) {
const t = it.querySelector('.opt-title')
if (t && t.textContent && t.textContent.includes(title)) {
return it as HTMLButtonElement
}
}
return null
}
test('renders a GIF, APNG, and APNG preview option row', async () => {
makeFreshStore()
const baseline = snapshotDialogs()
const handle = mountComponent(ExportDialog, { modelValue: true })
await nextTick()
const added = dialogsAddedSince(baseline)
expect(added.length).toBe(1)
const dialog = added[0]
// The three image-export rows we just added must all be present.
expect(findOptionByTitle(dialog, 'Animated GIF')).toBeTruthy()
expect(findOptionByTitle(dialog, 'Animated PNG')).toBeTruthy()
expect(findOptionByTitle(dialog, i18nT('export.apngPreviewTitle'))).toBeTruthy()
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
// template flips to the progress bar before the render loop
// finishes. The outstanding work is cancelled via the controller
// afterwards so no file gets saved.
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: 'Preview',
width: 8,
height: 8,
fill: '#22cc88',
})
s.addLayer(a.id)!
// Silence actual file downloads during the test.
const originalClick = HTMLAnchorElement.prototype.click
HTMLAnchorElement.prototype.click = function () {}
const baseline = snapshotDialogs()
const handle = mountComponent(ExportDialog, { modelValue: true })
await nextTick()
const dialog = dialogsAddedSince(baseline)[0]
const btn = findOptionByTitle(dialog, i18nT('export.apngPreviewTitle'))
expect(btn).toBeTruthy()
btn!.click()
await nextTick()
// Progress view replaces the options list — `.rendering` appears,
// `.opt` buttons vanish.
const rendering = dialog.querySelector('.rendering') as HTMLElement | null
expect(rendering).toBeTruthy()
const label = dialog.querySelector('.r-text')?.textContent ?? ''
expect(label).toBe(i18nT('export.labelApngPreview'))
// Abort the pending render so the encoder doesn't burn CPU for
// the rest of the test run.
const cancelBtn = dialog.querySelector('.r-actions button') as HTMLButtonElement | null
cancelBtn?.click()
await nextTick()
HTMLAnchorElement.prototype.click = originalClick
handle.unmount()
})
})
suite('playback — transport', () => {
test('togglePlay toggles playing state', async () => {
const s = makeFreshStore()