From c56e43ef2f585988abff0591e8c04066d884e492 Mon Sep 17 00:00:00 2001 From: flagadajones Date: Wed, 1 Apr 2026 21:06:40 +0200 Subject: [PATCH 1/2] feat: add support for animated images (GIF/WebP) and enhance rendering logic --- src-tauri/Cargo.lock | 41 ++++ src-tauri/Cargo.toml | 2 +- src/components/InstanceEditor.svelte | 16 +- src/components/Key.svelte | 38 ++-- src/lib/rendererHelper.ts | 297 +++++++++++++++++++++------ 5 files changed, 304 insertions(+), 90 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b27bedaf..f45b12e1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -711,6 +711,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -1786,6 +1792,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -2320,12 +2336,25 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", + "image-webp", "moxcms", "num-traits", "zune-core", "zune-jpeg", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -3803,6 +3832,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.30.0" @@ -6366,6 +6401,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "which" version = "4.4.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index faab8732..a2d8a401 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,7 +22,7 @@ tokio-tungstenite = "0.28" tiny_http = "0.12" elgato-streamdeck = { version = "0.12", default-features = false, features = ["async"] } hidapi = "2.6" -image = { version = "0.25", default-features = false, features = ["bmp", "jpeg"] } +image = { version = "0.25", default-features = false, features = ["bmp", "jpeg", "gif", "webp"] } # Smaller utility libraries dashmap = { version = "6.1", features = ["serde"] } futures = "0.3" diff --git a/src/components/InstanceEditor.svelte b/src/components/InstanceEditor.svelte index 494bf5ab..99779c3e 100644 --- a/src/components/InstanceEditor.svelte +++ b/src/components/InstanceEditor.svelte @@ -89,14 +89,14 @@ }} title="Click to select an image, or right-click to reset to the default image." > - {#await renderImage(null, null, instance.states[state], instance.action.states[state]?.image ?? instance.action.icon, false, false, true, false, false, 0, true)} -
- {:then resolvedSrc} - State {state} + {#await renderImage(null, null, instance.states[state], instance.action.states[state]?.image ?? instance.action.icon, false, false, true, false, false, 0, true) then resolvedSrc} + {#if typeof resolvedSrc === "string"} + State {state} + {/if} {/await}
diff --git a/src/components/Key.svelte b/src/components/Key.svelte index bc33d9ba..df3b2bd1 100644 --- a/src/components/Key.svelte +++ b/src/components/Key.svelte @@ -16,7 +16,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; - import { tick } from "svelte"; + import { onDestroy, tick } from "svelte"; export let context: Context | null; export let label: string = ""; @@ -144,26 +144,38 @@ let canvas: HTMLCanvasElement; let lock = new CanvasLock(); + let animationCleanup: (() => void) | undefined; export let size = 144; + + function stopAnimation() { + if (animationCleanup) { + animationCleanup(); + animationCleanup = undefined; + } + } + + onDestroy(stopAnimation); + $: (async () => { const sl = structuredClone(slot); - if (!sl) { - const unlock = await lock.lock(); - try { + const unlock = await lock.lock(); + try { + stopAnimation(); + if (!sl) { const ctx = canvas?.getContext("2d"); if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); if (active) await invoke("update_image", { context, image: null }); - } finally { - unlock(); - } - } else { - const unlock = await lock.lock(); - try { + } else { let fallback = sl.action.states[sl.current_state]?.image ?? sl.action.icon; - if (state) await renderImage(canvas, context, state, fallback, showOk, showAlert, true, active, pressed, $settings?.rotation); - } finally { - unlock(); + if (state) { + const result = await renderImage(canvas, context, state, fallback, showOk, showAlert, true, active, pressed, $settings?.rotation); + if (typeof result === "function") { + animationCleanup = result; + } + } } + } finally { + unlock(); } })(); diff --git a/src/lib/rendererHelper.ts b/src/lib/rendererHelper.ts index 9689b3e9..38ae7068 100644 --- a/src/lib/rendererHelper.ts +++ b/src/lib/rendererHelper.ts @@ -5,6 +5,17 @@ import { getWebserverUrl } from "./ports.ts"; import { invoke } from "@tauri-apps/api/core"; +/** Check if an image source is potentially animated (GIF or WebP). */ +function isAnimatedSource(src: string): boolean { + if (src.startsWith("data:image/gif")) return true; + if (src.startsWith("data:image/webp")) return true; + if (!src.startsWith("data:")) { + const lower = src.toLowerCase(); + if (lower.endsWith(".gif") || lower.endsWith(".webp")) return true; + } + return false; +} + export function getImage(image: string | undefined, fallback: string | undefined): string { if (!image) return fallback ? getImage(fallback, undefined) : "/alert.png"; if (image.startsWith("opendeck/")) return image.replace("opendeck", ""); @@ -27,6 +38,26 @@ export function getImage(image: string | undefined, fallback: string | undefined return image; } +/** Shared global origin for all animation clocks, so all animated keys on the same page stay in sync. */ +const ANIMATION_ORIGIN = performance.now(); + +/** Compute which frame index to display given a shared time origin and per-frame durations. */ +function computeFrameIndex(durations: number[], now: number): number { + // Total loop duration + let totalDuration = 0; + for (const d of durations) totalDuration += d; + if (totalDuration <= 0) return 0; + + // Time elapsed within the current loop iteration + let elapsed = (now - ANIMATION_ORIGIN) % totalDuration; + + for (let i = 0; i < durations.length; i++) { + if (elapsed < durations[i]) return i; + elapsed -= durations[i]; + } + return 0; +} + export class CanvasLock { currentLock = Promise.resolve(); async lock() { @@ -39,32 +70,20 @@ export class CanvasLock { } } -export async function renderImage( - canvas: HTMLCanvasElement | null, - slotContext: Context | null, +/** Draw one composited frame onto the canvas (background + image + text + overlays + press). */ +function drawFrame( + canvas: HTMLCanvasElement, + context: CanvasRenderingContext2D, + image: CanvasImageSource, state: ActionState, - fallback: string | undefined, showOk: boolean, showAlert: boolean, - processImage: boolean, - active: boolean, pressed: boolean, - rotation?: number, - preview?: boolean, + rotation: number | undefined, + scale: number, + okImage: HTMLImageElement | null, + alertImage: HTMLImageElement | null, ) { - // Create canvas - let scale = 1; - if (!canvas) { - canvas = document.createElement("canvas"); - canvas.width = 144; - canvas.height = 144; - } else { - scale = canvas.width / 144; - } - - const context = canvas.getContext("2d"); - if (!context) return; - context.save(); if (rotation) { context.translate(canvas.width / 2, canvas.height / 2); @@ -72,39 +91,23 @@ export async function renderImage( context.translate(-canvas.width / 2, -canvas.height / 2); } - try { - // Load image - const image = document.createElement("img"); - image.crossOrigin = "anonymous"; - image.src = processImage ? getImage(state.image, fallback) : state.image; - if (image.src == undefined) return; - await new Promise((resolve, reject) => { - image.onload = resolve; - image.onerror = reject; - }); - - context.clearRect(0, 0, canvas.width, canvas.height); - - // Draw background color - if (!state.background_colour.startsWith("#000000")) { - context.fillStyle = state.background_colour; - context.fillRect(0, 0, canvas.width, canvas.height); - } + context.clearRect(0, 0, canvas.width, canvas.height); - // Draw image - context.imageSmoothingQuality = "high"; - const imageScale = Math.max(10, state.image_scale || 100) / 100; - const xScaled = canvas.width * imageScale; - const yScaled = canvas.height * imageScale; - const xOffset = (canvas.width - xScaled) / 2; - const yOffset = (canvas.height - yScaled) / 2; - context.drawImage(image, xOffset, yOffset, xScaled, yScaled); - } catch (error: any) { - if (!(error instanceof Event)) console.error(error); - context.clearRect(0, 0, canvas.width, canvas.height); - showAlert = true; + // Draw background color + if (!state.background_colour.startsWith("#000000")) { + context.fillStyle = state.background_colour; + context.fillRect(0, 0, canvas.width, canvas.height); } + // Draw image + context.imageSmoothingQuality = "high"; + const imageScale = Math.max(10, state.image_scale || 100) / 100; + const xScaled = canvas.width * imageScale; + const yScaled = canvas.height * imageScale; + const xOffset = (canvas.width - xScaled) / 2; + const yOffset = (canvas.height - yScaled) / 2; + context.drawImage(image, xOffset, yOffset, xScaled, yScaled); + // Draw text if (state.show) { const size = state.size * 2 * scale; @@ -130,33 +133,19 @@ export async function renderImage( context.fillText(line, x, y + (size * parseInt(index))); if (state.underline) { const width = context.measureText(line).width; - // Set to black for the outline, since it uses the same fill style info as the text colour. context.fillStyle = "black"; context.fillRect(x - (width / 2) - 3, y + (size * parseInt(index)) + size, width + 6, 9); - // Reset to the user's choice of text colour. context.fillStyle = state.colour; context.fillRect(x - (width / 2), y + (size * parseInt(index)) + size + 4, width, 3); } } } - if (showOk) { - const okImage = document.createElement("img"); - okImage.crossOrigin = "anonymous"; - okImage.src = "/ok.png"; - await new Promise((resolve) => { - okImage.onload = resolve; - }); + if (showOk && okImage) { context.drawImage(okImage, 0, 0, canvas.width, canvas.height); } - if (showAlert) { - const alertImage = document.createElement("img"); - alertImage.crossOrigin = "anonymous"; - alertImage.src = "/alert.png"; - await new Promise((resolve) => { - alertImage.onload = resolve; - }); + if (showAlert && alertImage) { context.drawImage(alertImage, 0, 0, canvas.width, canvas.height); } @@ -181,12 +170,184 @@ export async function renderImage( } context.restore(); +} + +/** Pre-load a static overlay image (ok/alert). Returns null if it fails to load. */ +function loadOverlay(src: string): Promise { + const img = document.createElement("img"); + img.crossOrigin = "anonymous"; + img.src = src; + return new Promise((resolve) => { + img.onload = () => resolve(img); + img.onerror = () => resolve(null); + }); +} + +interface AnimatedFrames { + frames: ImageBitmap[]; + durations: number[]; // in milliseconds +} + +/** Decode individual frames from an animated GIF/WebP using the ImageDecoder WebCodecs API. */ +async function decodeAnimatedFrames(src: string): Promise { + // deno-lint-ignore no-explicit-any + if (!("ImageDecoder" in globalThis)) return null; + + try { + const response = await fetch(src); + const blob = await response.blob(); + const type = blob.type || (src.startsWith("data:") ? src.substring(5, src.indexOf(";")) : ""); + if (!type) return null; + + // deno-lint-ignore no-explicit-any + const decoder = new (globalThis as any).ImageDecoder({ type, data: blob.stream() }); + await decoder.completed; + + const track = decoder.tracks.selectedTrack; + if (!track || track.frameCount <= 1) { + decoder.close(); + return null; + } - if (active && slotContext) setTimeout(async () => await invoke("update_image", { context: slotContext, image: canvas.toDataURL("image/jpeg") }), 10); + const frames: ImageBitmap[] = []; + const durations: number[] = []; + + for (let i = 0; i < track.frameCount; i++) { + const result = await decoder.decode({ frameIndex: i }); + const bitmap = await createImageBitmap(result.image); + frames.push(bitmap); + // VideoFrame.duration is in microseconds; default to 100ms if missing + durations.push((result.image.duration || 100000) / 1000); + result.image.close(); + } + + decoder.close(); + return { frames, durations }; + } catch { + return null; + } +} + +export async function renderImage( + canvas: HTMLCanvasElement | null, + slotContext: Context | null, + state: ActionState, + fallback: string | undefined, + showOk: boolean, + showAlert: boolean, + processImage: boolean, + active: boolean, + pressed: boolean, + rotation?: number, + preview?: boolean, +): Promise<(() => void) | string | undefined> { + // Create canvas + let scale = 1; + if (!canvas) { + canvas = document.createElement("canvas"); + canvas.width = 144; + canvas.height = 144; + } else { + scale = canvas.width / 144; + } + + const context = canvas.getContext("2d"); + if (!context) return; + + const imageSrc = processImage ? getImage(state.image, fallback) : state.image; + const animated = isAnimatedSource(imageSrc); + + // Pre-load overlay images if needed + const okImage = showOk ? await loadOverlay("/ok.png") : null; + const alertImage = showAlert ? await loadOverlay("/alert.png") : null; + + try { + // Load image + const image = document.createElement("img"); + image.crossOrigin = "anonymous"; + image.src = imageSrc; + if (image.src == undefined) return; + await new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = reject; + }); + + if (animated && !preview) { + // Try to decode individual frames using the ImageDecoder WebCodecs API. + const animData = await decodeAnimatedFrames(imageSrc); + if (animData) { + let stopped = false; + let deviceUpdateInFlight = false; + const DEVICE_MIN_INTERVAL = 150; // minimum ms between device updates + + // Separate canvas for device frames to avoid interfering with UI. + const deviceCanvas = document.createElement("canvas"); + deviceCanvas.width = canvas!.width; + deviceCanvas.height = canvas!.height; + const deviceCtx = deviceCanvas.getContext("2d")!; + + const tick = () => { + if (stopped) return; + const now = performance.now(); + + // All animations use the shared global clock so they stay in sync. + const frameIndex = computeFrameIndex(animData.durations, now); + + // Draw current frame to the visible canvas (UI) + drawFrame(canvas!, context, animData.frames[frameIndex], state, showOk, showAlert, pressed, rotation, scale, okImage, alertImage); + + // Send the same frame to the device at a throttled rate + if (active && slotContext && !deviceUpdateInFlight) { + deviceUpdateInFlight = true; + drawFrame(deviceCanvas, deviceCtx, animData.frames[frameIndex], state, showOk, showAlert, false, rotation, scale, okImage, alertImage); + invoke("update_image", { context: slotContext, image: deviceCanvas.toDataURL("image/jpeg") }) + .finally(() => { + setTimeout(() => { + deviceUpdateInFlight = false; + }, DEVICE_MIN_INTERVAL); + }); + } + + requestAnimationFrame(tick); + }; + + requestAnimationFrame(tick); + + return () => { + stopped = true; + for (const frame of animData.frames) frame.close(); + }; + } + // If ImageDecoder is unavailable or image is not truly animated, fall through to static draw. + } + + // Static image: single draw + drawFrame(canvas, context, image, state, showOk, showAlert, pressed, rotation, scale, okImage, alertImage); + } catch (error: any) { + if (!(error instanceof Event)) console.error(error); + context.save(); + if (rotation) { + context.translate(canvas.width / 2, canvas.height / 2); + context.rotate(rotation * Math.PI / 180); + context.translate(-canvas.width / 2, -canvas.height / 2); + } + context.clearRect(0, 0, canvas.width, canvas.height); + context.restore(); + showAlert = true; + const alertFallback = await loadOverlay("/alert.png"); + if (alertFallback) { + context.drawImage(alertFallback, 0, 0, canvas.width, canvas.height); + } + } + + if (active && slotContext) setTimeout(async () => await invoke("update_image", { context: slotContext, image: canvas!.toDataURL("image/jpeg") }), 10); else if (preview) return canvas.toDataURL(); } export async function resizeImage(source: string): Promise { + // Preserve animated formats (GIF, WebP) as-is to avoid flattening to a single frame. + if (isAnimatedSource(source)) return source; + const canvas = document.createElement("canvas"); canvas.width = 288; canvas.height = 288; From 233a5f4a960ae0c3f504abfdc921d962aad7e1eb Mon Sep 17 00:00:00 2001 From: flagadajones Date: Thu, 2 Apr 2026 01:47:35 +0200 Subject: [PATCH 2/2] fix: enhance animated image rendering with sequence management and context invalidation --- src-tauri/src/events/frontend/instances.rs | 35 +++++- src/components/Key.svelte | 21 ++-- src/lib/rendererHelper.ts | 124 ++++++++++++++++----- 3 files changed, 144 insertions(+), 36 deletions(-) diff --git a/src-tauri/src/events/frontend/instances.rs b/src-tauri/src/events/frontend/instances.rs index bc224c94..3de8dfa4 100644 --- a/src-tauri/src/events/frontend/instances.rs +++ b/src-tauri/src/events/frontend/instances.rs @@ -3,8 +3,18 @@ use super::Error; use crate::shared::{Action, ActionContext, ActionInstance, ActionState, Context, config_dir}; use crate::store::profiles::{LocksMut, acquire_locks, acquire_locks_mut, get_instance_mut, get_slot, get_slot_mut, save_profile}; +use std::collections::HashMap; +use std::sync::LazyLock; + use tauri::{AppHandle, Emitter, Manager, command}; use tokio::fs::remove_dir_all; +use tokio::sync::RwLock; + +static LAST_RENDER_SEQUENCES: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); + +fn context_sequence_key(context: &Context) -> String { + format!("{}.{}.{}.{}", context.device, context.profile, context.controller, context.position) +} #[command] pub async fn create_instance(app: AppHandle, action: Action, context: Context) -> Result, Error> { @@ -221,11 +231,34 @@ pub async fn set_state(context: ActionContext, index: u16, state: ActionState) - } #[command] -pub async fn update_image(context: Context, image: Option) { +pub async fn update_image(context: Context, image: Option, render_sequence: Option) { if Some(&context.profile) != crate::store::profiles::DEVICE_STORES.write().await.get_selected_profile(&context.device).ok().as_ref() { return; } + // Ignore image writes for slots that no longer have an instance (e.g. after move/remove). + if image.is_some() { + let locks = acquire_locks().await; + let Ok(slot) = get_slot(&context, &locks).await else { return; }; + if slot.is_none() { + return; + } + } + + if let Some(sequence) = render_sequence { + let mut sequences = LAST_RENDER_SEQUENCES.write().await; + let key = context_sequence_key(&context); + if let Some(previous) = sequences.get(&key) + && sequence < *previous { + return; + } + sequences.insert(key, sequence); + } else if image.is_none() { + // Clearing a slot invalidates any prior sequencing state for this context. + let mut sequences = LAST_RENDER_SEQUENCES.write().await; + sequences.remove(&context_sequence_key(&context)); + } + if let Err(error) = crate::events::outbound::devices::update_image(context, image).await { log::warn!("Failed to update device image: {}", error); } diff --git a/src/components/Key.svelte b/src/components/Key.svelte index 4a505ea2..b2f1502d 100644 --- a/src/components/Key.svelte +++ b/src/components/Key.svelte @@ -11,7 +11,7 @@ import InstanceEditor from "./InstanceEditor.svelte"; import { copiedItem, inspectedInstance, inspectedParentAction, openContextMenu } from "$lib/propertyInspector"; - import { CanvasLock, renderImage } from "$lib/rendererHelper"; + import { CanvasLock, invalidateRenderContext, renderImage } from "$lib/rendererHelper"; import { settings } from "$lib/settings"; import { invoke } from "@tauri-apps/api/core"; @@ -146,6 +146,7 @@ let canvas: HTMLCanvasElement; let lock = new CanvasLock(); let animationCleanup: (() => void) | undefined; + let renderVersion = 0; export let size = 144; function stopAnimation() { @@ -158,11 +159,15 @@ onDestroy(stopAnimation); $: (async () => { + const version = ++renderVersion; const sl = structuredClone(slot); + // Stop previous loop immediately so moved animated keys cannot keep pushing old frames. + stopAnimation(); const unlock = await lock.lock(); try { - stopAnimation(); + if (version !== renderVersion) return; if (!sl) { + invalidateRenderContext(context); const ctx = canvas?.getContext("2d"); if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); if (active) await invoke("update_image", { context, image: null }); @@ -170,6 +175,10 @@ let fallback = sl.action.states[sl.current_state]?.image ?? sl.action.icon; if (state) { const result = await renderImage(canvas, context, state, fallback, showOk, showAlert, true, active, pressed, $settings?.rotation); + if (version !== renderVersion) { + if (typeof result === "function") result(); + return; + } if (typeof result === "function") { animationCleanup = result; } @@ -180,14 +189,6 @@ } })(); - function clearAndRedraw() { - canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height); - slot = slot; - } - $: if ($settings?.rotation != undefined) { - clearAndRedraw(); - } - async function triggerVirtualPress() { if (!active || !context || !slot) return; await invoke("trigger_virtual_press", { context }); diff --git a/src/lib/rendererHelper.ts b/src/lib/rendererHelper.ts index 38ae7068..1a32c780 100644 --- a/src/lib/rendererHelper.ts +++ b/src/lib/rendererHelper.ts @@ -40,6 +40,27 @@ export function getImage(image: string | undefined, fallback: string | undefined /** Shared global origin for all animation clocks, so all animated keys on the same page stay in sync. */ const ANIMATION_ORIGIN = performance.now(); +const ACTIVE_RENDER_TOKENS = new Map(); +const UPDATE_SEQUENCES = new Map(); + +function contextKey(slotContext: Context | null): string | null { + if (!slotContext) return null; + return `${slotContext.device}.${slotContext.profile}.${slotContext.controller}.${slotContext.position}`; +} + +export function invalidateRenderContext(slotContext: Context | null) { + const key = contextKey(slotContext); + if (!key) return; + ACTIVE_RENDER_TOKENS.delete(key); +} + +function nextUpdateSequence(slotContext: Context | null): number | null { + const key = contextKey(slotContext); + if (!key) return null; + const next = (UPDATE_SEQUENCES.get(key) ?? 0) + 1; + UPDATE_SEQUENCES.set(key, next); + return next; +} /** Compute which frame index to display given a shared time origin and per-frame durations. */ function computeFrameIndex(durations: number[], now: number): number { @@ -173,14 +194,21 @@ function drawFrame( } /** Pre-load a static overlay image (ok/alert). Returns null if it fails to load. */ +const OVERLAY_CACHE = new Map>(); + function loadOverlay(src: string): Promise { + const cached = OVERLAY_CACHE.get(src); + if (cached) return cached; + const img = document.createElement("img"); img.crossOrigin = "anonymous"; img.src = src; - return new Promise((resolve) => { + const promise = new Promise((resolve) => { img.onload = () => resolve(img); img.onerror = () => resolve(null); }); + OVERLAY_CACHE.set(src, promise); + return promise; } interface AnimatedFrames { @@ -213,7 +241,8 @@ async function decodeAnimatedFrames(src: string): Promise const durations: number[] = []; for (let i = 0; i < track.frameCount; i++) { - const result = await decoder.decode({ frameIndex: i }); + // Request fully composited frames to avoid disposal-mode flicker on device updates. + const result = await decoder.decode({ frameIndex: i, completeFrames: true }); const bitmap = await createImageBitmap(result.image); frames.push(bitmap); // VideoFrame.duration is in microseconds; default to 100ms if missing @@ -241,6 +270,17 @@ export async function renderImage( rotation?: number, preview?: boolean, ): Promise<(() => void) | string | undefined> { + const key = contextKey(slotContext); + const renderToken = key ? Symbol(key) : null; + if (key && renderToken) { + ACTIVE_RENDER_TOKENS.set(key, renderToken); + } + + const isCurrentRender = () => { + if (!key || !renderToken) return true; + return ACTIVE_RENDER_TOKENS.get(key) === renderToken; + }; + // Create canvas let scale = 1; if (!canvas) { @@ -272,13 +312,33 @@ export async function renderImage( image.onerror = reject; }); + // Always render a first static frame immediately so all keys appear fast on page load. + drawFrame(canvas, context, image, state, showOk, showAlert, pressed, rotation, scale, okImage, alertImage); + + if (active && slotContext && isCurrentRender()) { + void invoke("update_image", { + context: slotContext, + image: canvas.toDataURL("image/jpeg"), + render_sequence: nextUpdateSequence(slotContext), + }); + } + if (animated && !preview) { - // Try to decode individual frames using the ImageDecoder WebCodecs API. - const animData = await decodeAnimatedFrames(imageSrc); - if (animData) { - let stopped = false; + let stopped = false; + let cleanupFrames: (() => void) | undefined; + + // Decode and start animation in background without blocking first paint. + void (async () => { + const animData = await decodeAnimatedFrames(imageSrc); + if (!animData || stopped || !isCurrentRender()) { + if (animData) { + for (const frame of animData.frames) frame.close(); + } + return; + } + let deviceUpdateInFlight = false; - const DEVICE_MIN_INTERVAL = 150; // minimum ms between device updates + let lastSentFrameIndex = -1; // Separate canvas for device frames to avoid interfering with UI. const deviceCanvas = document.createElement("canvas"); @@ -287,7 +347,7 @@ export async function renderImage( const deviceCtx = deviceCanvas.getContext("2d")!; const tick = () => { - if (stopped) return; + if (stopped || !isCurrentRender()) return; const now = performance.now(); // All animations use the shared global clock so they stay in sync. @@ -296,33 +356,39 @@ export async function renderImage( // Draw current frame to the visible canvas (UI) drawFrame(canvas!, context, animData.frames[frameIndex], state, showOk, showAlert, pressed, rotation, scale, okImage, alertImage); - // Send the same frame to the device at a throttled rate - if (active && slotContext && !deviceUpdateInFlight) { + // Send to hardware only when frame changes to preserve ordering and avoid aliasing artifacts. + if (active && slotContext && !deviceUpdateInFlight && isCurrentRender() && frameIndex !== lastSentFrameIndex) { deviceUpdateInFlight = true; + lastSentFrameIndex = frameIndex; drawFrame(deviceCanvas, deviceCtx, animData.frames[frameIndex], state, showOk, showAlert, false, rotation, scale, okImage, alertImage); - invoke("update_image", { context: slotContext, image: deviceCanvas.toDataURL("image/jpeg") }) + invoke("update_image", { + context: slotContext, + image: deviceCanvas.toDataURL("image/jpeg"), + render_sequence: nextUpdateSequence(slotContext), + }) .finally(() => { - setTimeout(() => { - deviceUpdateInFlight = false; - }, DEVICE_MIN_INTERVAL); + deviceUpdateInFlight = false; }); } requestAnimationFrame(tick); }; - requestAnimationFrame(tick); - - return () => { - stopped = true; + cleanupFrames = () => { for (const frame of animData.frames) frame.close(); }; - } - // If ImageDecoder is unavailable or image is not truly animated, fall through to static draw. - } - // Static image: single draw - drawFrame(canvas, context, image, state, showOk, showAlert, pressed, rotation, scale, okImage, alertImage); + requestAnimationFrame(tick); + })(); + + return () => { + stopped = true; + cleanupFrames?.(); + if (key && ACTIVE_RENDER_TOKENS.get(key) === renderToken) { + ACTIVE_RENDER_TOKENS.delete(key); + } + }; + } } catch (error: any) { if (!(error instanceof Event)) console.error(error); context.save(); @@ -340,8 +406,16 @@ export async function renderImage( } } - if (active && slotContext) setTimeout(async () => await invoke("update_image", { context: slotContext, image: canvas!.toDataURL("image/jpeg") }), 10); - else if (preview) return canvas.toDataURL(); + if (active && slotContext) { + setTimeout(async () => { + if (!isCurrentRender()) return; + await invoke("update_image", { + context: slotContext, + image: canvas!.toDataURL("image/jpeg"), + render_sequence: nextUpdateSequence(slotContext), + }); + }, 10); + } else if (preview) return canvas.toDataURL(); } export async function resizeImage(source: string): Promise {