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

+124
View File
@@ -5,11 +5,15 @@ import {
PhDownloadSimple,
PhEngine,
PhFileHtml,
PhFileImage,
PhFilmSlate,
PhGif,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadSceneAsGif } from '@/lib/gifExport'
import { downloadSceneAsApng, downloadSceneAsApngPreview } from '@/lib/apngExport'
import { downloadFromServer } from '@/lib/serverExport'
import type { ButtonAsset, Layer } from '@/types/scene'
import { Modal } from '@/components/ui'
@@ -136,6 +140,90 @@ async function exportWebm() {
}
}
async function exportGif() {
if (progress.value) return
const controller = new AbortController()
progress.value = {
progress: 0,
controller,
label: t('export.labelGif'),
error: null,
}
store.pause()
try {
await downloadSceneAsGif(activeSceneClone(), {
signal: controller.signal,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
progress.value = null
emit('update:modelValue', false)
} catch (err) {
if ((err as Error).name === 'AbortError') {
progress.value = null
return
}
if (progress.value) progress.value.error = (err as Error).message || String(err)
}
}
async function exportApng() {
if (progress.value) return
const controller = new AbortController()
progress.value = {
progress: 0,
controller,
label: t('export.labelApng'),
error: null,
}
store.pause()
try {
await downloadSceneAsApng(activeSceneClone(), {
signal: controller.signal,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
progress.value = null
emit('update:modelValue', false)
} catch (err) {
if ((err as Error).name === 'AbortError') {
progress.value = null
return
}
if (progress.value) progress.value.error = (err as Error).message || String(err)
}
}
async function exportApngPreview() {
if (progress.value) return
const controller = new AbortController()
progress.value = {
progress: 0,
controller,
label: t('export.labelApngPreview'),
error: null,
}
store.pause()
try {
await downloadSceneAsApngPreview(activeSceneClone(), {
signal: controller.signal,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
progress.value = null
emit('update:modelValue', false)
} catch (err) {
if ((err as Error).name === 'AbortError') {
progress.value = null
return
}
if (progress.value) progress.value.error = (err as Error).message || String(err)
}
}
async function exportMp4Server() {
if (progress.value) return
const controller = new AbortController()
@@ -267,6 +355,42 @@ function onUpdate(v: boolean) {
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportGif">
<div class="opt-icon"><PhGif :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
Animated GIF
<span class="badge">{{ t('export.gifBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.gifDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportApng">
<div class="opt-icon"><PhFileImage :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
Animated PNG
<span class="badge">{{ t('export.apngBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.apngDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportApngPreview">
<div class="opt-icon"><PhFileHtml :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
{{ t('export.apngPreviewTitle') }}
<span class="badge">{{ t('export.apngPreviewBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.apngPreviewDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
</div>
<div v-else-if="!progress.error" class="rendering">