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