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
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user