diff --git a/package-lock.json b/package-lock.json index 408a060..a40bdc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 47cfa39..1a56bc3 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/components/DocumentDrawer.vue b/src/components/DocumentDrawer.vue index 52f2553..67b0f4b 100644 --- a/src/components/DocumentDrawer.vue +++ b/src/components/DocumentDrawer.vue @@ -1,6 +1,6 @@ + + +` +} + +/** 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 { + 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) +} diff --git a/src/lib/frameExport.ts b/src/lib/frameExport.ts new file mode 100644 index 0000000..12e8608 --- /dev/null +++ b/src/lib/frameExport.ts @@ -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, + opts: FrameExportOptions = {}, +): Promise { + 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((r) => setTimeout(r, 0)) + } + } +} diff --git a/src/lib/gifExport.ts b/src/lib/gifExport.ts new file mode 100644 index 0000000..2014c80 --- /dev/null +++ b/src/lib/gifExport.ts @@ -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 { + 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 { + 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) +} diff --git a/src/lib/render.ts b/src/lib/render.ts index 415e655..c2b25ac 100644 --- a/src/lib/render.ts +++ b/src/lib/render.ts @@ -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) } diff --git a/src/types/encoders.d.ts b/src/types/encoders.d.ts new file mode 100644 index 0000000..f42cfb0 --- /dev/null +++ b/src/types/encoders.d.ts @@ -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 + data: Uint8Array + } + toRGBA8(img: ReturnType): ArrayBuffer[] + } + export default UPNG +} diff --git a/src/version.ts b/src/version.ts index 1bf2134..d2ffe8c 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.15.0' +export const APP_VERSION = '0.16.0' export const APP_NAME = 'RUWIO'