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:
1 parent
827d80bfd0
commit
fcfd21428d
12 files changed
+956
-14
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user