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
Generated
+27
-4
@@ -1,16 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "animatr",
|
"name": "ruwio",
|
||||||
"version": "0.0.0",
|
"version": "0.15.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "animatr",
|
"name": "ruwio",
|
||||||
"version": "0.0.0",
|
"version": "0.15.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@phosphor-icons/vue": "^2.2.1",
|
"@phosphor-icons/vue": "^2.2.1",
|
||||||
|
"gifenc": "^1.0.3",
|
||||||
"konva": "^9.3.16",
|
"konva": "^9.3.16",
|
||||||
"pinia": "^2.2.6",
|
"pinia": "^2.2.6",
|
||||||
|
"upng-js": "^2.1.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-konva": "^3.1.2"
|
"vue-konva": "^3.1.2"
|
||||||
},
|
},
|
||||||
@@ -1210,6 +1212,12 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gifenc": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/he": {
|
"node_modules/he": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||||
@@ -1290,6 +1298,12 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/path-browserify": {
|
"node_modules/path-browserify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
@@ -1458,6 +1472,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/upng-js": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/upng-js/-/upng-js-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-d3xzZzpMP64YkjP5pr8gNyvBt7dLk/uGI67EctzDuVp4lCZyVMo0aJO6l/VDlgbInJYDY6cnClLoBp29eKWI6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.2",
|
"version": "6.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ruwio",
|
"name": "ruwio",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.15.0",
|
"version": "0.16.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -12,8 +12,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@phosphor-icons/vue": "^2.2.1",
|
"@phosphor-icons/vue": "^2.2.1",
|
||||||
|
"gifenc": "^1.0.3",
|
||||||
"konva": "^9.3.16",
|
"konva": "^9.3.16",
|
||||||
"pinia": "^2.2.6",
|
"pinia": "^2.2.6",
|
||||||
|
"upng-js": "^2.1.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-konva": "^3.1.2"
|
"vue-konva": "^3.1.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
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 { useSceneStore } from '@/stores/scene'
|
||||||
import { confirmDialog, promptDialog, dialogState } from '@/components/ui'
|
import { confirmDialog, promptDialog, dialogState } from '@/components/ui'
|
||||||
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
|
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
|
||||||
@@ -240,6 +240,29 @@ const background = computed({
|
|||||||
set: (v: string) => store.setCanvasBackground(v),
|
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({
|
const duration = computed({
|
||||||
get: () => store.scene.duration,
|
get: () => store.scene.duration,
|
||||||
set: (v: number) => store.setDuration(v),
|
set: (v: number) => store.setDuration(v),
|
||||||
@@ -339,14 +362,26 @@ async function onRemovePreset(p: CanvasPreset) {
|
|||||||
<div class="fields-group">
|
<div class="fields-group">
|
||||||
<label class="field color-field">
|
<label class="field color-field">
|
||||||
<span>{{ t('settings.background') }}</span>
|
<span>{{ t('settings.background') }}</span>
|
||||||
<div class="color-row">
|
<div class="color-row" :class="{ transparent: bgTransparent }">
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
class="color-swatch"
|
class="color-swatch"
|
||||||
:value="background"
|
:value="bgTransparent ? lastNonTransparent : background"
|
||||||
|
:disabled="bgTransparent"
|
||||||
@input="background = ($event.target as HTMLInputElement).value"
|
@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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="field bg-video-field">
|
<label class="field bg-video-field">
|
||||||
@@ -590,6 +625,38 @@ async function onRemovePreset(p: CanvasPreset) {
|
|||||||
box-sizing: border-box;
|
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 {
|
.color-swatch::-webkit-color-swatch-wrapper {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import {
|
|||||||
PhDownloadSimple,
|
PhDownloadSimple,
|
||||||
PhEngine,
|
PhEngine,
|
||||||
PhFileHtml,
|
PhFileHtml,
|
||||||
|
PhFileImage,
|
||||||
PhFilmSlate,
|
PhFilmSlate,
|
||||||
|
PhGif,
|
||||||
} from '@phosphor-icons/vue'
|
} from '@phosphor-icons/vue'
|
||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
|
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
|
||||||
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
|
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
|
||||||
|
import { downloadSceneAsGif } from '@/lib/gifExport'
|
||||||
|
import { downloadSceneAsApng, downloadSceneAsApngPreview } from '@/lib/apngExport'
|
||||||
import { downloadFromServer } from '@/lib/serverExport'
|
import { downloadFromServer } from '@/lib/serverExport'
|
||||||
import type { ButtonAsset, Layer } from '@/types/scene'
|
import type { ButtonAsset, Layer } from '@/types/scene'
|
||||||
import { Modal } from '@/components/ui'
|
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() {
|
async function exportMp4Server() {
|
||||||
if (progress.value) return
|
if (progress.value) return
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -267,6 +355,42 @@ function onUpdate(v: boolean) {
|
|||||||
</div>
|
</div>
|
||||||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div v-else-if="!progress.error" class="rendering">
|
<div v-else-if="!progress.error" class="rendering">
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ import {
|
|||||||
generateRuntimeScript,
|
generateRuntimeScript,
|
||||||
generateSceneScript,
|
generateSceneScript,
|
||||||
} from '@/lib/exportPlayer'
|
} from '@/lib/exportPlayer'
|
||||||
|
import { renderFrames } from '@/lib/frameExport'
|
||||||
|
import { renderSceneToGif } from '@/lib/gifExport'
|
||||||
|
import { generateApngPreviewHtml, renderSceneToApng } from '@/lib/apngExport'
|
||||||
import { ProjectFileError, parseProject, serializeProject } from '@/lib/projectFile'
|
import { ProjectFileError, parseProject, serializeProject } from '@/lib/projectFile'
|
||||||
import { createHistory } from '@/composables/useHistory'
|
import { createHistory } from '@/composables/useHistory'
|
||||||
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
|
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
|
||||||
@@ -47,6 +50,7 @@ import ActionEditor from '@/components/ActionEditor.vue'
|
|||||||
import Timeline from '@/components/Timeline.vue'
|
import Timeline from '@/components/Timeline.vue'
|
||||||
import AssetSidebar from '@/components/AssetSidebar.vue'
|
import AssetSidebar from '@/components/AssetSidebar.vue'
|
||||||
import CanvasStage from '@/components/CanvasStage.vue'
|
import CanvasStage from '@/components/CanvasStage.vue'
|
||||||
|
import ExportDialog from '@/components/ExportDialog.vue'
|
||||||
import FullscreenPreview from '@/components/FullscreenPreview.vue'
|
import FullscreenPreview from '@/components/FullscreenPreview.vue'
|
||||||
import HelpDialog from '@/components/HelpDialog.vue'
|
import HelpDialog from '@/components/HelpDialog.vue'
|
||||||
import { createAudioRuntime } from '@/composables/useAudioRuntime'
|
import { createAudioRuntime } from '@/composables/useAudioRuntime'
|
||||||
@@ -991,6 +995,146 @@ export function registerAllTests(): void {
|
|||||||
expect(src).toContain('BundlR')
|
expect(src).toContain('BundlR')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('renderFrames yields fps*duration frames across the playback range', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.setDuration(1)
|
||||||
|
s.project.fps = 10
|
||||||
|
// Tiny 4×4 canvas keeps the loop fast — we only care about counts.
|
||||||
|
s.scene.canvas.width = 4
|
||||||
|
s.scene.canvas.height = 4
|
||||||
|
const a = s.addAsset({ kind: 'rect', name: 'R', width: 4, height: 4, fill: '#111' })
|
||||||
|
s.addLayer(a.id)!
|
||||||
|
const frames: number[] = []
|
||||||
|
await renderFrames(s.scene, ({ frameIndex, width, height }) => {
|
||||||
|
expect(width).toBe(4)
|
||||||
|
expect(height).toBe(4)
|
||||||
|
frames.push(frameIndex)
|
||||||
|
})
|
||||||
|
// 1 second at 10fps → 10 frames (indices 0..9).
|
||||||
|
expect(frames.length).toBe(10)
|
||||||
|
expect(frames[0]).toBe(0)
|
||||||
|
expect(frames[frames.length - 1]).toBe(9)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renderFrames aborts when the signal fires', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.setDuration(1)
|
||||||
|
s.project.fps = 10
|
||||||
|
s.scene.canvas.width = 4
|
||||||
|
s.scene.canvas.height = 4
|
||||||
|
const a = s.addAsset({ kind: 'rect', name: 'R', width: 4, height: 4, fill: '#111' })
|
||||||
|
s.addLayer(a.id)!
|
||||||
|
const controller = new AbortController()
|
||||||
|
let rendered = 0
|
||||||
|
const promise = renderFrames(
|
||||||
|
s.scene,
|
||||||
|
() => {
|
||||||
|
rendered += 1
|
||||||
|
if (rendered === 2) controller.abort()
|
||||||
|
},
|
||||||
|
{ signal: controller.signal },
|
||||||
|
)
|
||||||
|
let threw = false
|
||||||
|
try {
|
||||||
|
await promise
|
||||||
|
} catch (err) {
|
||||||
|
threw = (err as Error).name === 'AbortError'
|
||||||
|
}
|
||||||
|
expect(threw).toBe(true)
|
||||||
|
expect(rendered).toBeLessThan(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renderSceneToGif emits a GIF89a blob', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.setDuration(0.5)
|
||||||
|
s.project.fps = 4
|
||||||
|
s.scene.canvas.width = 8
|
||||||
|
s.scene.canvas.height = 8
|
||||||
|
const a = s.addAsset({ kind: 'rect', name: 'R', width: 8, height: 8, fill: '#22cc88' })
|
||||||
|
s.addLayer(a.id)!
|
||||||
|
const blob = await renderSceneToGif(s.scene)
|
||||||
|
expect(blob.type).toBe('image/gif')
|
||||||
|
expect(blob.size > 0).toBe(true)
|
||||||
|
const bytes = new Uint8Array(await blob.arrayBuffer())
|
||||||
|
// GIF magic: "GIF" + "87a" or "89a"
|
||||||
|
const header = String.fromCharCode(bytes[0], bytes[1], bytes[2])
|
||||||
|
expect(header).toBe('GIF')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generateApngPreviewHtml embeds the data URL + checkerboard CSS', () => {
|
||||||
|
const dataUrl = 'data:image/png;base64,AAAA'
|
||||||
|
const html = generateApngPreviewHtml(dataUrl, 320, 180)
|
||||||
|
// Payload is embedded so the exported HTML works as a single file.
|
||||||
|
expect(html).toContain(dataUrl)
|
||||||
|
// Aspect ratio drives the stage box.
|
||||||
|
expect(html).toContain('aspect-ratio: 320 / 180')
|
||||||
|
// Checkerboard + toggle buttons are present.
|
||||||
|
expect(html).toContain('.stage.checker')
|
||||||
|
expect(html).toContain('data-bg="checker"')
|
||||||
|
expect(html).toContain('data-bg="solid-dark"')
|
||||||
|
expect(html).toContain('data-bg="solid-light"')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('transparent background survives the APNG round-trip (alpha preserved)', async () => {
|
||||||
|
// A transparent canvas with no layers should encode a fully
|
||||||
|
// transparent animation. We decode the produced blob and verify
|
||||||
|
// at least one pixel's alpha channel is zero.
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.setDuration(0.5)
|
||||||
|
s.project.fps = 4
|
||||||
|
s.scene.canvas.width = 8
|
||||||
|
s.scene.canvas.height = 8
|
||||||
|
s.setCanvasBackground('transparent')
|
||||||
|
const blob = await renderSceneToApng(s.scene)
|
||||||
|
const buf = await blob.arrayBuffer()
|
||||||
|
const { default: UPNG } = await import('upng-js')
|
||||||
|
const decoded = UPNG.decode(buf)
|
||||||
|
const rgbaFrames = UPNG.toRGBA8(decoded)
|
||||||
|
expect(rgbaFrames.length > 0).toBe(true)
|
||||||
|
const first = new Uint8Array(rgbaFrames[0])
|
||||||
|
// Scan every pixel's alpha byte — at least one should be 0 since
|
||||||
|
// nothing painted on a transparent canvas.
|
||||||
|
let sawTransparent = false
|
||||||
|
for (let i = 3; i < first.length; i += 4) {
|
||||||
|
if (first[i] === 0) {
|
||||||
|
sawTransparent = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(sawTransparent).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renderSceneToApng emits a PNG blob with an acTL animation chunk', async () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.setDuration(0.5)
|
||||||
|
s.project.fps = 4
|
||||||
|
s.scene.canvas.width = 8
|
||||||
|
s.scene.canvas.height = 8
|
||||||
|
const a = s.addAsset({ kind: 'rect', name: 'R', width: 8, height: 8, fill: '#22cc88' })
|
||||||
|
s.addLayer(a.id)!
|
||||||
|
const blob = await renderSceneToApng(s.scene)
|
||||||
|
expect(blob.type).toBe('image/png')
|
||||||
|
expect(blob.size > 0).toBe(true)
|
||||||
|
const bytes = new Uint8Array(await blob.arrayBuffer())
|
||||||
|
// PNG magic: 0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A
|
||||||
|
expect(bytes[0]).toBe(0x89)
|
||||||
|
expect(String.fromCharCode(bytes[1], bytes[2], bytes[3])).toBe('PNG')
|
||||||
|
// Scan for the 'acTL' chunk type — present only in animated PNGs.
|
||||||
|
let hasActl = false
|
||||||
|
for (let i = 8; i < bytes.length - 4; i++) {
|
||||||
|
if (
|
||||||
|
bytes[i] === 0x61 &&
|
||||||
|
bytes[i + 1] === 0x63 &&
|
||||||
|
bytes[i + 2] === 0x54 &&
|
||||||
|
bytes[i + 3] === 0x4c
|
||||||
|
) {
|
||||||
|
hasActl = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(hasActl).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test('runtime lerpProps interpolates shadow + blur keyframes', () => {
|
test('runtime lerpProps interpolates shadow + blur keyframes', () => {
|
||||||
// Regression: shadow properties (shadowBlur/Opacity/Angle/Distance/
|
// Regression: shadow properties (shadowBlur/Opacity/Angle/Distance/
|
||||||
// Color) and blur used to be dropped by the runtime's lerpProps,
|
// Color) and blur used to be dropped by the runtime's lerpProps,
|
||||||
@@ -1160,6 +1304,91 @@ export function registerAllTests(): void {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
suite('ui — ExportDialog', () => {
|
||||||
|
// ExportDialog teleports through Modal so query through document
|
||||||
|
// rather than the handle's host root. Each test snapshots the
|
||||||
|
// live dialogs first and diffs after mount to isolate its own.
|
||||||
|
const snapshotDialogs = () =>
|
||||||
|
new Set(Array.from(document.querySelectorAll('[role="dialog"]')))
|
||||||
|
const dialogsAddedSince = (baseline: Set<Element>) =>
|
||||||
|
Array.from(document.querySelectorAll('[role="dialog"]')).filter(
|
||||||
|
(el) => !baseline.has(el),
|
||||||
|
) as HTMLElement[]
|
||||||
|
|
||||||
|
function findOptionByTitle(dialog: HTMLElement, title: string): HTMLButtonElement | null {
|
||||||
|
const items = dialog.querySelectorAll('button.opt')
|
||||||
|
for (const it of Array.from(items)) {
|
||||||
|
const t = it.querySelector('.opt-title')
|
||||||
|
if (t && t.textContent && t.textContent.includes(title)) {
|
||||||
|
return it as HTMLButtonElement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
test('renders a GIF, APNG, and APNG preview option row', async () => {
|
||||||
|
makeFreshStore()
|
||||||
|
const baseline = snapshotDialogs()
|
||||||
|
const handle = mountComponent(ExportDialog, { modelValue: true })
|
||||||
|
await nextTick()
|
||||||
|
const added = dialogsAddedSince(baseline)
|
||||||
|
expect(added.length).toBe(1)
|
||||||
|
const dialog = added[0]
|
||||||
|
// The three image-export rows we just added must all be present.
|
||||||
|
expect(findOptionByTitle(dialog, 'Animated GIF')).toBeTruthy()
|
||||||
|
expect(findOptionByTitle(dialog, 'Animated PNG')).toBeTruthy()
|
||||||
|
expect(findOptionByTitle(dialog, i18nT('export.apngPreviewTitle'))).toBeTruthy()
|
||||||
|
handle.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking APNG preview transitions the dialog to the progress view', async () => {
|
||||||
|
// Tiny scene so the async render stays short. We assert the
|
||||||
|
// synchronous state transition right after the click — the
|
||||||
|
// template flips to the progress bar before the render loop
|
||||||
|
// finishes. The outstanding work is cancelled via the controller
|
||||||
|
// afterwards so no file gets saved.
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.setDuration(0.25)
|
||||||
|
s.project.fps = 4
|
||||||
|
s.scene.canvas.width = 8
|
||||||
|
s.scene.canvas.height = 8
|
||||||
|
const a = s.addAsset({
|
||||||
|
kind: 'rect',
|
||||||
|
name: 'Preview',
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
fill: '#22cc88',
|
||||||
|
})
|
||||||
|
s.addLayer(a.id)!
|
||||||
|
|
||||||
|
// Silence actual file downloads during the test.
|
||||||
|
const originalClick = HTMLAnchorElement.prototype.click
|
||||||
|
HTMLAnchorElement.prototype.click = function () {}
|
||||||
|
|
||||||
|
const baseline = snapshotDialogs()
|
||||||
|
const handle = mountComponent(ExportDialog, { modelValue: true })
|
||||||
|
await nextTick()
|
||||||
|
const dialog = dialogsAddedSince(baseline)[0]
|
||||||
|
const btn = findOptionByTitle(dialog, i18nT('export.apngPreviewTitle'))
|
||||||
|
expect(btn).toBeTruthy()
|
||||||
|
btn!.click()
|
||||||
|
await nextTick()
|
||||||
|
// Progress view replaces the options list — `.rendering` appears,
|
||||||
|
// `.opt` buttons vanish.
|
||||||
|
const rendering = dialog.querySelector('.rendering') as HTMLElement | null
|
||||||
|
expect(rendering).toBeTruthy()
|
||||||
|
const label = dialog.querySelector('.r-text')?.textContent ?? ''
|
||||||
|
expect(label).toBe(i18nT('export.labelApngPreview'))
|
||||||
|
// Abort the pending render so the encoder doesn't burn CPU for
|
||||||
|
// the rest of the test run.
|
||||||
|
const cancelBtn = dialog.querySelector('.r-actions button') as HTMLButtonElement | null
|
||||||
|
cancelBtn?.click()
|
||||||
|
await nextTick()
|
||||||
|
HTMLAnchorElement.prototype.click = originalClick
|
||||||
|
handle.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
suite('playback — transport', () => {
|
suite('playback — transport', () => {
|
||||||
test('togglePlay toggles playing state', async () => {
|
test('togglePlay toggles playing state', async () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
|
|||||||
@@ -395,6 +395,8 @@ export const en = {
|
|||||||
width: 'Width',
|
width: 'Width',
|
||||||
height: 'Height',
|
height: 'Height',
|
||||||
background: 'Background',
|
background: 'Background',
|
||||||
|
backgroundTransparent: 'Transparent',
|
||||||
|
backgroundTransparentTooltip: 'Transparent background (APNG exports keep alpha)',
|
||||||
duration: 'Duration (s)',
|
duration: 'Duration (s)',
|
||||||
fps: 'FPS',
|
fps: 'FPS',
|
||||||
timelineHint: 'Timeline length sets the ruler scale. Playback stops at the last keyframe.',
|
timelineHint: 'Timeline length sets the ruler scale. Playback stops at the last keyframe.',
|
||||||
@@ -417,11 +419,21 @@ export const en = {
|
|||||||
webmBadge: 'Browser · Realtime',
|
webmBadge: 'Browser · Realtime',
|
||||||
webmDesc: 'No server — recorded in real time',
|
webmDesc: 'No server — recorded in real time',
|
||||||
webmNotSupported: 'Browser does not support MediaRecorder',
|
webmNotSupported: 'Browser does not support MediaRecorder',
|
||||||
|
gifBadge: 'Browser · Frame-by-frame',
|
||||||
|
gifDesc: '256-colour palette, broadest compatibility',
|
||||||
|
apngBadge: 'Browser · Lossless',
|
||||||
|
apngDesc: 'Full RGBA with transparency — .png file',
|
||||||
|
apngPreviewTitle: 'APNG preview page',
|
||||||
|
apngPreviewBadge: 'HTML · Checkerboard',
|
||||||
|
apngPreviewDesc: 'Standalone HTML with a checkerboard backdrop so you can verify transparency',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
failed: 'Export failed',
|
failed: 'Export failed',
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
labelWebm: 'Rendering WebM (browser, realtime)…',
|
labelWebm: 'Rendering WebM (browser, realtime)…',
|
||||||
labelMp4: 'Rendering MP4 (server)…',
|
labelMp4: 'Rendering MP4 (server)…',
|
||||||
|
labelGif: 'Rendering GIF (browser, frame-by-frame)…',
|
||||||
|
labelApng: 'Rendering APNG (browser, frame-by-frame)…',
|
||||||
|
labelApngPreview: 'Rendering APNG + preview page…',
|
||||||
skipHidden: 'Skip hidden tracks',
|
skipHidden: 'Skip hidden tracks',
|
||||||
skipHiddenDesc: 'Drop layers toggled off in the editor from every format below.',
|
skipHiddenDesc: 'Drop layers toggled off in the editor from every format below.',
|
||||||
skipHiddenNone: 'No editor-hidden tracks in the scene.',
|
skipHiddenNone: 'No editor-hidden tracks in the scene.',
|
||||||
@@ -1174,6 +1186,8 @@ export const de: DeepPartial<Messages> = {
|
|||||||
width: 'Breite',
|
width: 'Breite',
|
||||||
height: 'Höhe',
|
height: 'Höhe',
|
||||||
background: 'Hintergrund',
|
background: 'Hintergrund',
|
||||||
|
backgroundTransparent: 'Transparent',
|
||||||
|
backgroundTransparentTooltip: 'Transparenter Hintergrund (beim APNG-Export bleibt Alpha erhalten)',
|
||||||
duration: 'Dauer (s)',
|
duration: 'Dauer (s)',
|
||||||
fps: 'FPS',
|
fps: 'FPS',
|
||||||
timelineHint: 'Die Timeline-Länge bestimmt die Skala im Lineal. Die Wiedergabe stoppt am Ende.',
|
timelineHint: 'Die Timeline-Länge bestimmt die Skala im Lineal. Die Wiedergabe stoppt am Ende.',
|
||||||
@@ -1195,11 +1209,21 @@ export const de: DeepPartial<Messages> = {
|
|||||||
webmBadge: 'Browser · Echtzeit',
|
webmBadge: 'Browser · Echtzeit',
|
||||||
webmDesc: 'Ohne Server: direkt im Browser aufgenommen.',
|
webmDesc: 'Ohne Server: direkt im Browser aufgenommen.',
|
||||||
webmNotSupported: 'Dieser Browser unterstützt MediaRecorder nicht.',
|
webmNotSupported: 'Dieser Browser unterstützt MediaRecorder nicht.',
|
||||||
|
gifBadge: 'Browser · Frame-für-Frame',
|
||||||
|
gifDesc: '256-Farben-Palette, maximale Kompatibilität.',
|
||||||
|
apngBadge: 'Browser · Verlustfrei',
|
||||||
|
apngDesc: 'Volles RGBA mit Transparenz — .png-Datei.',
|
||||||
|
apngPreviewTitle: 'APNG-Vorschauseite',
|
||||||
|
apngPreviewBadge: 'HTML · Schachbrett',
|
||||||
|
apngPreviewDesc: 'Eigenständige HTML-Datei mit Schachbrett-Hintergrund zum Transparenz-Check.',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
failed: 'Export fehlgeschlagen',
|
failed: 'Export fehlgeschlagen',
|
||||||
back: 'Zurück',
|
back: 'Zurück',
|
||||||
labelWebm: 'WebM wird gerendert (Browser, Echtzeit)…',
|
labelWebm: 'WebM wird gerendert (Browser, Echtzeit)…',
|
||||||
labelMp4: 'MP4 wird gerendert (Server)…',
|
labelMp4: 'MP4 wird gerendert (Server)…',
|
||||||
|
labelGif: 'GIF wird gerendert (Browser, frame-für-frame)…',
|
||||||
|
labelApng: 'APNG wird gerendert (Browser, frame-für-frame)…',
|
||||||
|
labelApngPreview: 'APNG + Vorschau werden gerendert…',
|
||||||
skipHidden: 'Versteckte Tracks überspringen',
|
skipHidden: 'Versteckte Tracks überspringen',
|
||||||
skipHiddenDesc: 'Lässt editor-versteckte Ebenen in allen Exportformaten weg.',
|
skipHiddenDesc: 'Lässt editor-versteckte Ebenen in allen Exportformaten weg.',
|
||||||
skipHiddenNone: 'In dieser Szene gibt es keine editor-versteckten Tracks.',
|
skipHiddenNone: 'In dieser Szene gibt es keine editor-versteckten Tracks.',
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import UPNG from 'upng-js'
|
||||||
|
import type { Scene } from '@/types/scene'
|
||||||
|
import { renderFrames, type FrameExportOptions } from './frameExport'
|
||||||
|
|
||||||
|
export interface ApngExportOptions extends FrameExportOptions {
|
||||||
|
/** 0 = lossless; 256 = quantize to 256 colours for smaller files.
|
||||||
|
* UPNG accepts any value; defaults to lossless so transparency and
|
||||||
|
* gradients come out clean at the cost of size. */
|
||||||
|
colors?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the scene's playback range as an animated PNG (APNG).
|
||||||
|
* Collects each frame's RGBA buffer and hands the whole batch to
|
||||||
|
* UPNG.encode in one shot — the encoder picks inter-frame diff
|
||||||
|
* regions and writes the correct animation chunks (acTL / fcTL /
|
||||||
|
* fdAT). Slower than GIF but lossless and alpha-aware by default. */
|
||||||
|
export async function renderSceneToApng(
|
||||||
|
scene: Scene,
|
||||||
|
opts: ApngExportOptions = {},
|
||||||
|
): Promise<Blob> {
|
||||||
|
const frames: ArrayBuffer[] = []
|
||||||
|
const delays: number[] = []
|
||||||
|
let width = 0
|
||||||
|
let height = 0
|
||||||
|
await renderFrames(
|
||||||
|
scene,
|
||||||
|
({ ctx, width: w, height: h, delayMs }) => {
|
||||||
|
width = w
|
||||||
|
height = h
|
||||||
|
const buf = ctx.getImageData(0, 0, w, h).data.buffer
|
||||||
|
// Slice in case the returned ArrayBufferLike is shared; UPNG
|
||||||
|
// expects plain ArrayBuffer instances it can own.
|
||||||
|
frames.push(buf.slice(0))
|
||||||
|
delays.push(Math.round(delayMs))
|
||||||
|
},
|
||||||
|
opts,
|
||||||
|
)
|
||||||
|
if (frames.length === 0) {
|
||||||
|
throw new Error('No frames rendered — scene range is empty.')
|
||||||
|
}
|
||||||
|
const bytes = UPNG.encode(
|
||||||
|
frames,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
opts.colors ?? 0,
|
||||||
|
frames.length > 1 ? delays : undefined,
|
||||||
|
)
|
||||||
|
return new Blob([bytes], { type: 'image/png' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadSceneAsApng(
|
||||||
|
scene: Scene,
|
||||||
|
opts: ApngExportOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const blob = await renderSceneToApng(scene, opts)
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
// APNG files use the .png extension — modern browsers render the
|
||||||
|
// animation, older viewers fall back to the first frame.
|
||||||
|
a.download = 'animation.png'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turn a Blob into a base64 data URL. Uses FileReader so the encoder
|
||||||
|
* work happens off the main thread. */
|
||||||
|
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const r = new FileReader()
|
||||||
|
r.onload = () => resolve(String(r.result))
|
||||||
|
r.onerror = () => reject(r.error ?? new Error('Failed to read APNG blob'))
|
||||||
|
r.readAsDataURL(blob)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standalone preview page: checkerboard backdrop + the inlined APNG
|
||||||
|
* so the user can visually confirm alpha came through. The APNG is
|
||||||
|
* embedded as a data URL so the HTML works as a single dropped file
|
||||||
|
* (no sibling .png needed). Includes a "Solid background" toggle so
|
||||||
|
* the user can also check the animation against a neutral colour. */
|
||||||
|
export function generateApngPreviewHtml(
|
||||||
|
apngDataUrl: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): string {
|
||||||
|
const aspect = `${width} / ${height}`
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>RUWIO — APNG preview</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 32px 20px;
|
||||||
|
background: #f5f5f7;
|
||||||
|
color: #1d1d1f;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body { background: #1e1e22; color: #e5e5ea; }
|
||||||
|
.controls { background: #26262c; border-color: #3a3a42; }
|
||||||
|
.controls button { background: #2e2e35; color: #e5e5ea; border-color: #3a3a42; }
|
||||||
|
.controls button.active { background: #6ea8ff; border-color: #6ea8ff; color: #fff; }
|
||||||
|
}
|
||||||
|
h1 { font-size: 18px; font-weight: 600; margin: 0; }
|
||||||
|
p { margin: 0; color: #6e6e73; font-size: 12px; text-align: center; }
|
||||||
|
.stage {
|
||||||
|
width: min(100%, 960px);
|
||||||
|
aspect-ratio: ${aspect};
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0,0,0,0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
/* Checkerboard tiling. Two diagonal linear gradients stacked so
|
||||||
|
the tiles meet at their midpoints — the classic transparency
|
||||||
|
indicator from Photoshop / Figma. */
|
||||||
|
.stage.checker {
|
||||||
|
background-color: #fff;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, #d8d8dc 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #d8d8dc 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #d8d8dc 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #d8d8dc 75%);
|
||||||
|
background-size: 20px 20px;
|
||||||
|
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
|
||||||
|
}
|
||||||
|
.stage.solid-dark { background: #1e1e22; }
|
||||||
|
.stage.solid-light { background: #ffffff; }
|
||||||
|
.stage img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.controls button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: #1d1d1f;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.controls button.active {
|
||||||
|
background: #2b6af5;
|
||||||
|
border-color: #2b6af5;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8a8a8f;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>RUWIO — APNG preview</h1>
|
||||||
|
<p>Checkerboard indicates transparent pixels. Toggle solid backgrounds to double-check.</p>
|
||||||
|
|
||||||
|
<div class="stage checker" id="stage">
|
||||||
|
<img src="${apngDataUrl}" alt="animation preview" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button data-bg="checker" class="active">Checkerboard</button>
|
||||||
|
<button data-bg="solid-dark">Dark</button>
|
||||||
|
<button data-bg="solid-light">Light</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta">${width} × ${height} · animated PNG</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var stage = document.getElementById('stage')
|
||||||
|
var btns = document.querySelectorAll('.controls button')
|
||||||
|
btns.forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
stage.classList.remove('checker', 'solid-dark', 'solid-light')
|
||||||
|
stage.classList.add(b.dataset.bg)
|
||||||
|
btns.forEach(function (x) { x.classList.remove('active') })
|
||||||
|
b.classList.add('active')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}())
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the scene as APNG + wrap it in a checkerboard preview HTML
|
||||||
|
* file so the user can verify transparency. Downloads one
|
||||||
|
* self-contained HTML file (the APNG is inlined as a data URL). */
|
||||||
|
export async function downloadSceneAsApngPreview(
|
||||||
|
scene: Scene,
|
||||||
|
opts: ApngExportOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const blob = await renderSceneToApng(scene, opts)
|
||||||
|
const dataUrl = await blobToDataUrl(blob)
|
||||||
|
const html = generateApngPreviewHtml(
|
||||||
|
dataUrl,
|
||||||
|
scene.canvas.width,
|
||||||
|
scene.canvas.height,
|
||||||
|
)
|
||||||
|
const htmlBlob = new Blob([html], { type: 'text/html' })
|
||||||
|
const url = URL.createObjectURL(htmlBlob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'animation-preview.html'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type { Scene } from '@/types/scene'
|
||||||
|
import { drawSceneFrame, loadSceneImages } from './render'
|
||||||
|
|
||||||
|
/** End-of-playback time: clamp to the scene's last keyframe if the
|
||||||
|
* scene explicitly ends earlier than its nominal duration. Matches
|
||||||
|
* the WebM path so every export format sees the same range. */
|
||||||
|
function effectiveEnd(scene: Scene): number {
|
||||||
|
let max = 0
|
||||||
|
for (const layer of scene.layers) {
|
||||||
|
for (const kf of layer.keyframes) {
|
||||||
|
if (kf.time > max) max = kf.time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (max <= 0) return scene.duration
|
||||||
|
return Math.min(max, scene.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playbackRange(scene: Scene): { start: number; end: number } {
|
||||||
|
const autoEnd = effectiveEnd(scene)
|
||||||
|
const start = Math.max(0, Math.min(scene.duration, scene.inTime ?? 0))
|
||||||
|
const rawEnd = scene.outTime == null ? autoEnd : scene.outTime
|
||||||
|
const end = Math.max(start, Math.min(scene.duration, rawEnd))
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrameExportOptions {
|
||||||
|
/** 0..1, fraction of frames completed. Fired after each frame. */
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
|
signal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrameRenderContext {
|
||||||
|
scene: Scene
|
||||||
|
canvas: HTMLCanvasElement
|
||||||
|
ctx: CanvasRenderingContext2D
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
fps: number
|
||||||
|
frameCount: number
|
||||||
|
/** Index of the current frame (0..frameCount-1). */
|
||||||
|
frameIndex: number
|
||||||
|
/** Scene time that was just rendered. */
|
||||||
|
time: number
|
||||||
|
/** Per-frame delay in milliseconds, derived from `fps`. Useful for
|
||||||
|
* encoders that want to write timing metadata. */
|
||||||
|
delayMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Frame-by-frame render loop for formats that can't ride the
|
||||||
|
* MediaRecorder stream (GIF, APNG). Walks `rangeIn..rangeOut` at
|
||||||
|
* `scene.fps`, draws each frame to an offscreen canvas, and invokes
|
||||||
|
* `onFrame` before advancing. Awaits the callback so encoders can do
|
||||||
|
* async work (quantize, encode). Yields control every 4 frames so
|
||||||
|
* the UI stays responsive during long exports. */
|
||||||
|
export async function renderFrames(
|
||||||
|
scene: Scene,
|
||||||
|
onFrame: (ctx: FrameRenderContext) => void | Promise<void>,
|
||||||
|
opts: FrameExportOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const width = scene.canvas.width
|
||||||
|
const height = scene.canvas.height
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) throw new Error('2D context unavailable')
|
||||||
|
|
||||||
|
const images = await loadSceneImages(scene)
|
||||||
|
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
|
||||||
|
const rangeLen = Math.max(0, rangeOut - rangeIn)
|
||||||
|
const fps = Math.max(1, Math.round(scene.fps || 30))
|
||||||
|
const frameCount = Math.max(1, Math.round(rangeLen * fps))
|
||||||
|
const delayMs = 1000 / fps
|
||||||
|
|
||||||
|
for (let i = 0; i < frameCount; i++) {
|
||||||
|
if (opts.signal?.aborted) {
|
||||||
|
throw new DOMException('aborted', 'AbortError')
|
||||||
|
}
|
||||||
|
const t = rangeIn + i / fps
|
||||||
|
drawSceneFrame(ctx, scene, Math.min(t, rangeOut), images)
|
||||||
|
await onFrame({
|
||||||
|
scene,
|
||||||
|
canvas,
|
||||||
|
ctx,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fps,
|
||||||
|
frameCount,
|
||||||
|
frameIndex: i,
|
||||||
|
time: t,
|
||||||
|
delayMs,
|
||||||
|
})
|
||||||
|
opts.onProgress?.((i + 1) / frameCount)
|
||||||
|
// Let the browser paint progress + process abort signals without
|
||||||
|
// blocking the main thread for seconds on end.
|
||||||
|
if ((i & 3) === 0) {
|
||||||
|
await new Promise<void>((r) => setTimeout(r, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { GIFEncoder, applyPalette, quantize } from 'gifenc'
|
||||||
|
import type { Scene } from '@/types/scene'
|
||||||
|
import { renderFrames, type FrameExportOptions } from './frameExport'
|
||||||
|
|
||||||
|
/** Render the scene's playback range as an animated GIF. Uses gifenc's
|
||||||
|
* per-frame quantize + paletteMap path — no dithering, but fast and
|
||||||
|
* visually clean for the flat 2D content this editor produces. The
|
||||||
|
* palette is recomputed per frame so animations with big colour
|
||||||
|
* shifts still compress well (rather than pinning to the first
|
||||||
|
* frame's palette and muddying the rest). */
|
||||||
|
export async function renderSceneToGif(
|
||||||
|
scene: Scene,
|
||||||
|
opts: FrameExportOptions = {},
|
||||||
|
): Promise<Blob> {
|
||||||
|
const encoder = GIFEncoder()
|
||||||
|
let first = true
|
||||||
|
await renderFrames(
|
||||||
|
scene,
|
||||||
|
({ ctx, width, height, delayMs }) => {
|
||||||
|
const { data } = ctx.getImageData(0, 0, width, height)
|
||||||
|
const palette = quantize(data, 256, { format: 'rgb444' })
|
||||||
|
const indexed = applyPalette(data, palette, 'rgb444')
|
||||||
|
encoder.writeFrame(indexed, width, height, {
|
||||||
|
palette,
|
||||||
|
delay: Math.round(delayMs),
|
||||||
|
// GIF loops indefinitely by default (repeat: 0). Only the first
|
||||||
|
// frame needs the Netscape loop extension — gifenc wires this
|
||||||
|
// automatically when `first: true` is passed.
|
||||||
|
first,
|
||||||
|
repeat: 0,
|
||||||
|
})
|
||||||
|
first = false
|
||||||
|
},
|
||||||
|
opts,
|
||||||
|
)
|
||||||
|
encoder.finish()
|
||||||
|
const bytes = encoder.bytes()
|
||||||
|
// The encoder's bytes() Uint8Array may be backed by a shared buffer;
|
||||||
|
// copy into a fresh Uint8Array so the Blob owns its memory.
|
||||||
|
const copy = new Uint8Array(bytes.byteLength)
|
||||||
|
copy.set(bytes)
|
||||||
|
return new Blob([copy], { type: 'image/gif' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadSceneAsGif(
|
||||||
|
scene: Scene,
|
||||||
|
opts: FrameExportOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const blob = await renderSceneToGif(scene, opts)
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'animation.gif'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
}
|
||||||
+12
-4
@@ -373,10 +373,18 @@ export function drawSceneFrame(
|
|||||||
ctx.rect(0, 0, lw, lh)
|
ctx.rect(0, 0, lw, lh)
|
||||||
ctx.clip()
|
ctx.clip()
|
||||||
}
|
}
|
||||||
// A background video takes over the backdrop — keep the stage area
|
// Wipe the previous frame's pixels before drawing the new backdrop.
|
||||||
// transparent so the DOM video underneath is visible.
|
// Mandatory for transparent-background scenes so subsequent frames
|
||||||
if (!scene.backgroundVideo) {
|
// don't accumulate alpha; harmless for opaque ones since `fillRect`
|
||||||
ctx.fillStyle = scene.canvas.background
|
// below paints over everything.
|
||||||
|
ctx.clearRect(0, 0, lw, lh)
|
||||||
|
// A background video takes over the backdrop — leave the stage area
|
||||||
|
// transparent so the DOM video underneath is visible. Same treatment
|
||||||
|
// when the canvas background is explicitly 'transparent' so
|
||||||
|
// APNG/WebM exports carry the alpha channel out to the file.
|
||||||
|
const bg = scene.canvas.background
|
||||||
|
if (!scene.backgroundVideo && bg && bg !== 'transparent') {
|
||||||
|
ctx.fillStyle = bg
|
||||||
ctx.fillRect(0, 0, lw, lh)
|
ctx.fillRect(0, 0, lw, lh)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+71
@@ -0,0 +1,71 @@
|
|||||||
|
// Minimal type shims for the two export encoders. Only declare what
|
||||||
|
// the app actually uses — the full libraries are larger and untyped.
|
||||||
|
|
||||||
|
declare module 'gifenc' {
|
||||||
|
export interface GifFrameOptions {
|
||||||
|
palette?: number[][]
|
||||||
|
delay?: number
|
||||||
|
repeat?: number
|
||||||
|
transparent?: boolean
|
||||||
|
transparentIndex?: number
|
||||||
|
dispose?: number
|
||||||
|
first?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GifEncoder {
|
||||||
|
writeFrame(
|
||||||
|
index: Uint8Array | Uint8ClampedArray,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
opts?: GifFrameOptions,
|
||||||
|
): void
|
||||||
|
finish(): void
|
||||||
|
bytes(): Uint8Array
|
||||||
|
bytesView(): Uint8Array
|
||||||
|
reset(): void
|
||||||
|
buffer: ArrayBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GIFEncoder(opts?: { auto?: boolean; initialCapacity?: number }): GifEncoder
|
||||||
|
|
||||||
|
export function quantize(
|
||||||
|
rgba: Uint8Array | Uint8ClampedArray,
|
||||||
|
maxColors: number,
|
||||||
|
opts?: {
|
||||||
|
format?: 'rgb565' | 'rgb444' | 'rgba4444'
|
||||||
|
oneBitAlpha?: boolean | number
|
||||||
|
clearAlpha?: boolean
|
||||||
|
clearAlphaThreshold?: number
|
||||||
|
clearAlphaColor?: number
|
||||||
|
},
|
||||||
|
): number[][]
|
||||||
|
|
||||||
|
export function applyPalette(
|
||||||
|
rgba: Uint8Array | Uint8ClampedArray,
|
||||||
|
palette: number[][],
|
||||||
|
format?: 'rgb565' | 'rgb444' | 'rgba4444',
|
||||||
|
): Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'upng-js' {
|
||||||
|
const UPNG: {
|
||||||
|
encode(
|
||||||
|
imgs: ArrayBuffer[],
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
cnum: number,
|
||||||
|
dels?: number[],
|
||||||
|
): ArrayBuffer
|
||||||
|
decode(buffer: ArrayBuffer): {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
depth: number
|
||||||
|
ctype: number
|
||||||
|
frames: Array<{ delay?: number }>
|
||||||
|
tabs: Record<string, unknown>
|
||||||
|
data: Uint8Array
|
||||||
|
}
|
||||||
|
toRGBA8(img: ReturnType<typeof UPNG.decode>): ArrayBuffer[]
|
||||||
|
}
|
||||||
|
export default UPNG
|
||||||
|
}
|
||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '0.15.0'
|
export const APP_VERSION = '0.16.0'
|
||||||
export const APP_NAME = 'RUWIO'
|
export const APP_NAME = 'RUWIO'
|
||||||
Reference in new issue
Block a user