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

+71 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { PhFilmReel, PhPlus, PhX } from '@phosphor-icons/vue'
import { PhCheckerboard, PhFilmReel, PhPlus, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { confirmDialog, promptDialog, dialogState } from '@/components/ui'
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
@@ -240,6 +240,29 @@ const background = computed({
set: (v: string) => store.setCanvasBackground(v),
})
// Transparent toggle: stores 'transparent' sentinel when on, falls back
// to the most recent colour the user picked when turned off. The fallback
// sticks in a local ref so flipping the toggle doesn't lose their custom
// colour each time.
const lastNonTransparent = ref<string>(
store.scene.canvas.background && store.scene.canvas.background !== 'transparent'
? store.scene.canvas.background
: '#ffffff',
)
const bgTransparent = computed({
get: () => store.scene.canvas.background === 'transparent',
set: (on: boolean) => {
if (on) {
if (store.scene.canvas.background !== 'transparent') {
lastNonTransparent.value = store.scene.canvas.background || '#ffffff'
}
store.setCanvasBackground('transparent')
} else {
store.setCanvasBackground(lastNonTransparent.value || '#ffffff')
}
},
})
const duration = computed({
get: () => store.scene.duration,
set: (v: number) => store.setDuration(v),
@@ -339,14 +362,26 @@ async function onRemovePreset(p: CanvasPreset) {
<div class="fields-group">
<label class="field color-field">
<span>{{ t('settings.background') }}</span>
<div class="color-row">
<div class="color-row" :class="{ transparent: bgTransparent }">
<input
type="color"
class="color-swatch"
:value="background"
:value="bgTransparent ? lastNonTransparent : background"
:disabled="bgTransparent"
@input="background = ($event.target as HTMLInputElement).value"
/>
<span class="hex">{{ background.toUpperCase() }}</span>
<span class="hex">
{{ bgTransparent ? t('settings.backgroundTransparent') : background.toUpperCase() }}
</span>
<button
type="button"
class="bg-transparent-btn"
:class="{ active: bgTransparent }"
v-tooltip="t('settings.backgroundTransparentTooltip')"
@click="bgTransparent = !bgTransparent"
>
<PhCheckerboard :size="12" />
</button>
</div>
</label>
<label class="field bg-video-field">
@@ -590,6 +625,38 @@ async function onRemovePreset(p: CanvasPreset) {
box-sizing: border-box;
}
.color-swatch:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.bg-transparent-btn {
width: 18px;
height: 18px;
padding: 0;
margin-left: auto;
border: 1px solid var(--border);
background: transparent;
color: var(--text-dim);
border-radius: 3px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-sizing: border-box;
}
.bg-transparent-btn:hover {
color: var(--text);
background: var(--panel-alt);
}
.bg-transparent-btn.active {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-soft);
}
.color-swatch::-webkit-color-swatch-wrapper {
padding: 0;
}
+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">