0.16.0: animated GIF / APNG export + transparent canvas

- New frame-by-frame export pipeline in src/lib/frameExport.ts: loads
  scene images, walks rangeIn..rangeOut at scene.fps, drawSceneFrame
  to an offscreen canvas, awaits a per-frame callback, yields every
  4 frames to keep progress responsive, honours AbortSignal.
- Animated GIF via gifenc (per-frame quantize/palette, ~5 KB lib).
- Animated PNG via UPNG.js + pako (lossless RGBA, alpha-aware, ~30 KB).
- drawSceneFrame now clearRects first and skips the background fill
  when scene.canvas.background === 'transparent' — prerequisite for
  the alpha to reach the APNG output.
- DocumentDrawer: new checkerboard toggle next to the colour picker
  flips the stage between its last-picked colour and transparent;
  disabled swatch + 'Transparent' hex label make the state obvious.
- ExportDialog: three new option rows — Animated GIF, Animated PNG,
  and an APNG preview page that bundles the rendered APNG into a
  standalone HTML with a checkerboard backdrop + dark/light toggles
  for verifying transparency at a glance.
- Tests: renderFrames frame count + abort, GIF header magic, APNG
  acTL presence, transparent round-trip (decoded alpha == 0 on
  un-painted pixels), preview HTML structure, and the two UI tests
  covering ExportDialog's new rows and the progress-view transition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-24 10:21:30 +02:00
1 parent 827d80bfd0
commit fcfd21428d
12 files changed
+956 -14

No files matched your search

+27 -4
View File
@@ -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
View File
@@ -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"
},
+71 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { PhFilmReel, PhPlus, PhX } from '@phosphor-icons/vue'
import { PhCheckerboard, PhFilmReel, PhPlus, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { confirmDialog, promptDialog, dialogState } from '@/components/ui'
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
@@ -240,6 +240,29 @@ const background = computed({
set: (v: string) => store.setCanvasBackground(v),
})
// Transparent toggle: stores 'transparent' sentinel when on, falls back
// to the most recent colour the user picked when turned off. The fallback
// sticks in a local ref so flipping the toggle doesn't lose their custom
// colour each time.
const lastNonTransparent = ref<string>(
store.scene.canvas.background && store.scene.canvas.background !== 'transparent'
? store.scene.canvas.background
: '#ffffff',
)
const bgTransparent = computed({
get: () => store.scene.canvas.background === 'transparent',
set: (on: boolean) => {
if (on) {
if (store.scene.canvas.background !== 'transparent') {
lastNonTransparent.value = store.scene.canvas.background || '#ffffff'
}
store.setCanvasBackground('transparent')
} else {
store.setCanvasBackground(lastNonTransparent.value || '#ffffff')
}
},
})
const duration = computed({
get: () => store.scene.duration,
set: (v: number) => store.setDuration(v),
@@ -339,14 +362,26 @@ async function onRemovePreset(p: CanvasPreset) {
<div class="fields-group">
<label class="field color-field">
<span>{{ t('settings.background') }}</span>
<div class="color-row">
<div class="color-row" :class="{ transparent: bgTransparent }">
<input
type="color"
class="color-swatch"
:value="background"
:value="bgTransparent ? lastNonTransparent : background"
:disabled="bgTransparent"
@input="background = ($event.target as HTMLInputElement).value"
/>
<span class="hex">{{ background.toUpperCase() }}</span>
<span class="hex">
{{ bgTransparent ? t('settings.backgroundTransparent') : background.toUpperCase() }}
</span>
<button
type="button"
class="bg-transparent-btn"
:class="{ active: bgTransparent }"
v-tooltip="t('settings.backgroundTransparentTooltip')"
@click="bgTransparent = !bgTransparent"
>
<PhCheckerboard :size="12" />
</button>
</div>
</label>
<label class="field bg-video-field">
@@ -590,6 +625,38 @@ async function onRemovePreset(p: CanvasPreset) {
box-sizing: border-box;
}
.color-swatch:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.bg-transparent-btn {
width: 18px;
height: 18px;
padding: 0;
margin-left: auto;
border: 1px solid var(--border);
background: transparent;
color: var(--text-dim);
border-radius: 3px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-sizing: border-box;
}
.bg-transparent-btn:hover {
color: var(--text);
background: var(--panel-alt);
}
.bg-transparent-btn.active {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-soft);
}
.color-swatch::-webkit-color-swatch-wrapper {
padding: 0;
}
+124
View File
@@ -5,11 +5,15 @@ import {
PhDownloadSimple,
PhEngine,
PhFileHtml,
PhFileImage,
PhFilmSlate,
PhGif,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadSceneAsGif } from '@/lib/gifExport'
import { downloadSceneAsApng, downloadSceneAsApngPreview } from '@/lib/apngExport'
import { downloadFromServer } from '@/lib/serverExport'
import type { ButtonAsset, Layer } from '@/types/scene'
import { Modal } from '@/components/ui'
@@ -136,6 +140,90 @@ async function exportWebm() {
}
}
async function exportGif() {
if (progress.value) return
const controller = new AbortController()
progress.value = {
progress: 0,
controller,
label: t('export.labelGif'),
error: null,
}
store.pause()
try {
await downloadSceneAsGif(activeSceneClone(), {
signal: controller.signal,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
progress.value = null
emit('update:modelValue', false)
} catch (err) {
if ((err as Error).name === 'AbortError') {
progress.value = null
return
}
if (progress.value) progress.value.error = (err as Error).message || String(err)
}
}
async function exportApng() {
if (progress.value) return
const controller = new AbortController()
progress.value = {
progress: 0,
controller,
label: t('export.labelApng'),
error: null,
}
store.pause()
try {
await downloadSceneAsApng(activeSceneClone(), {
signal: controller.signal,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
progress.value = null
emit('update:modelValue', false)
} catch (err) {
if ((err as Error).name === 'AbortError') {
progress.value = null
return
}
if (progress.value) progress.value.error = (err as Error).message || String(err)
}
}
async function exportApngPreview() {
if (progress.value) return
const controller = new AbortController()
progress.value = {
progress: 0,
controller,
label: t('export.labelApngPreview'),
error: null,
}
store.pause()
try {
await downloadSceneAsApngPreview(activeSceneClone(), {
signal: controller.signal,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
progress.value = null
emit('update:modelValue', false)
} catch (err) {
if ((err as Error).name === 'AbortError') {
progress.value = null
return
}
if (progress.value) progress.value.error = (err as Error).message || String(err)
}
}
async function exportMp4Server() {
if (progress.value) return
const controller = new AbortController()
@@ -267,6 +355,42 @@ function onUpdate(v: boolean) {
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportGif">
<div class="opt-icon"><PhGif :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
Animated GIF
<span class="badge">{{ t('export.gifBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.gifDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportApng">
<div class="opt-icon"><PhFileImage :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
Animated PNG
<span class="badge">{{ t('export.apngBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.apngDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportApngPreview">
<div class="opt-icon"><PhFileHtml :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
{{ t('export.apngPreviewTitle') }}
<span class="badge">{{ t('export.apngPreviewBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.apngPreviewDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
</div>
<div v-else-if="!progress.error" class="rendering">
+229
View File
@@ -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()
+24
View File
@@ -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.',
+236
View File
@@ -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)
}
+100
View File
@@ -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))
}
}
}
+58
View File
@@ -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
View File
@@ -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)
}
+71
View File
@@ -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
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.15.0'
export const APP_VERSION = '0.16.0'
export const APP_NAME = 'RUWIO'