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;
}