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",
|
||||
"version": "0.0.0",
|
||||
"name": "ruwio",
|
||||
"version": "0.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "animatr",
|
||||
"version": "0.0.0",
|
||||
"name": "ruwio",
|
||||
"version": "0.15.0",
|
||||
"dependencies": {
|
||||
"@phosphor-icons/vue": "^2.2.1",
|
||||
"gifenc": "^1.0.3",
|
||||
"konva": "^9.3.16",
|
||||
"pinia": "^2.2.6",
|
||||
"upng-js": "^2.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-konva": "^3.1.2"
|
||||
},
|
||||
@@ -1210,6 +1212,12 @@
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"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_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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
@@ -1458,6 +1472,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,8 +12,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@phosphor-icons/vue": "^2.2.1",
|
||||
"gifenc": "^1.0.3",
|
||||
"konva": "^9.3.16",
|
||||
"pinia": "^2.2.6",
|
||||
"upng-js": "^2.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-konva": "^3.1.2"
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -22,6 +22,9 @@ import {
|
||||
generateRuntimeScript,
|
||||
generateSceneScript,
|
||||
} 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 { createHistory } from '@/composables/useHistory'
|
||||
import { useKfSelection, kfSelectionKey } from '@/composables/useKfSelection'
|
||||
@@ -47,6 +50,7 @@ import ActionEditor from '@/components/ActionEditor.vue'
|
||||
import Timeline from '@/components/Timeline.vue'
|
||||
import AssetSidebar from '@/components/AssetSidebar.vue'
|
||||
import CanvasStage from '@/components/CanvasStage.vue'
|
||||
import ExportDialog from '@/components/ExportDialog.vue'
|
||||
import FullscreenPreview from '@/components/FullscreenPreview.vue'
|
||||
import HelpDialog from '@/components/HelpDialog.vue'
|
||||
import { createAudioRuntime } from '@/composables/useAudioRuntime'
|
||||
@@ -991,6 +995,146 @@ export function registerAllTests(): void {
|
||||
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', () => {
|
||||
// Regression: shadow properties (shadowBlur/Opacity/Angle/Distance/
|
||||
// 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', () => {
|
||||
test('togglePlay toggles playing state', async () => {
|
||||
const s = makeFreshStore()
|
||||
|
||||
@@ -395,6 +395,8 @@ export const en = {
|
||||
width: 'Width',
|
||||
height: 'Height',
|
||||
background: 'Background',
|
||||
backgroundTransparent: 'Transparent',
|
||||
backgroundTransparentTooltip: 'Transparent background (APNG exports keep alpha)',
|
||||
duration: 'Duration (s)',
|
||||
fps: 'FPS',
|
||||
timelineHint: 'Timeline length sets the ruler scale. Playback stops at the last keyframe.',
|
||||
@@ -417,11 +419,21 @@ export const en = {
|
||||
webmBadge: 'Browser · Realtime',
|
||||
webmDesc: 'No server — recorded in real time',
|
||||
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',
|
||||
failed: 'Export failed',
|
||||
back: 'Back',
|
||||
labelWebm: 'Rendering WebM (browser, realtime)…',
|
||||
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',
|
||||
skipHiddenDesc: 'Drop layers toggled off in the editor from every format below.',
|
||||
skipHiddenNone: 'No editor-hidden tracks in the scene.',
|
||||
@@ -1174,6 +1186,8 @@ export const de: DeepPartial<Messages> = {
|
||||
width: 'Breite',
|
||||
height: 'Höhe',
|
||||
background: 'Hintergrund',
|
||||
backgroundTransparent: 'Transparent',
|
||||
backgroundTransparentTooltip: 'Transparenter Hintergrund (beim APNG-Export bleibt Alpha erhalten)',
|
||||
duration: 'Dauer (s)',
|
||||
fps: 'FPS',
|
||||
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',
|
||||
webmDesc: 'Ohne Server: direkt im Browser aufgenommen.',
|
||||
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',
|
||||
failed: 'Export fehlgeschlagen',
|
||||
back: 'Zurück',
|
||||
labelWebm: 'WebM wird gerendert (Browser, Echtzeit)…',
|
||||
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',
|
||||
skipHiddenDesc: 'Lässt editor-versteckte Ebenen in allen Exportformaten weg.',
|
||||
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.clip()
|
||||
}
|
||||
// A background video takes over the backdrop — keep the stage area
|
||||
// transparent so the DOM video underneath is visible.
|
||||
if (!scene.backgroundVideo) {
|
||||
ctx.fillStyle = scene.canvas.background
|
||||
// Wipe the previous frame's pixels before drawing the new backdrop.
|
||||
// Mandatory for transparent-background scenes so subsequent frames
|
||||
// don't accumulate alpha; harmless for opaque ones since `fillRect`
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
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'
|
||||
Reference in new issue
Block a user