diff --git a/src/lib/immersive/ImmersiveAmbientCanvas.svelte b/src/lib/immersive/ImmersiveAmbientCanvas.svelte index 7d370d469..ee9e7becf 100644 --- a/src/lib/immersive/ImmersiveAmbientCanvas.svelte +++ b/src/lib/immersive/ImmersiveAmbientCanvas.svelte @@ -3,13 +3,15 @@ * Immersive Ambient Canvas * * WebGL2-based canvas for rendering the immersive background. - * Phase 1: Static texture rendering only. + * Phase 2: Pre-blurred artwork with async loading. * * Features: * - WebGL2 context with graceful fallback * - Context loss/restore handling * - Automatic resize handling * - Performance metrics reporting + * - Async texture loading with cancellation + * - Smooth crossfade between textures */ import { onMount, onDestroy } from 'svelte'; @@ -18,13 +20,14 @@ createShaderProgram, createFullscreenQuad, createPlaceholderTexture, - createTextureFromImage, resizeCanvasToDisplaySize, cleanupWebGL, } from './utils/webgl-utils'; + import { loadBlurredTexture, cancelAllLoads } from './utils/texture-loader'; import { SHADERS } from './shaders'; import { updateMetrics, + updateTextureCount, handleContextLost, handleContextRestored, } from './ImmersiveRenderer'; @@ -48,6 +51,7 @@ let vao: WebGLVertexArrayObject | null = null; let vertexBuffer: WebGLBuffer | null = null; let currentTexture: WebGLTexture | null = null; + let nextTexture: WebGLTexture | null = null; let placeholderTexture: WebGLTexture | null = null; // Uniform locations @@ -57,6 +61,7 @@ let animationFrameId: number | null = null; let isInitialized = false; let lastArtworkUrl = ''; + let textureLoadId = 0; // For cancellation /** * Initialize WebGL resources. @@ -113,6 +118,9 @@ * Clean up WebGL resources. */ function destroyWebGL(): void { + // Cancel any pending texture loads + cancelAllLoads(); + if (animationFrameId !== null) { cancelAnimationFrame(animationFrameId); animationFrameId = null; @@ -128,7 +136,7 @@ program, vao, vertexBuffer, - textures: [currentTexture, placeholderTexture], + textures: [currentTexture, nextTexture, placeholderTexture], }); } @@ -137,6 +145,7 @@ vao = null; vertexBuffer = null; currentTexture = null; + nextTexture = null; placeholderTexture = null; u_texture = null; isInitialized = false; @@ -175,33 +184,44 @@ } /** - * Load a texture from URL. + * Load a pre-blurred texture from artwork URL. + * Handles async loading with cancellation support. */ async function loadTexture(url: string): Promise { if (!gl || !isInitialized) return; - const img = new Image(); - img.crossOrigin = 'anonymous'; + // Increment load ID for cancellation + const loadId = ++textureLoadId; + const requestId = `artwork-${loadId}`; try { - await new Promise((resolve, reject) => { - img.onload = () => resolve(); - img.onerror = () => reject(new Error(`Failed to load: ${url}`)); - img.src = url; - }); + // Load pre-blurred texture + const result = await loadBlurredTexture(gl, url, requestId); - // Delete old texture + // Check if this load was cancelled (newer load started) + if (loadId !== textureLoadId || !result) { + return; + } + + // Swap textures if (currentTexture) { gl.deleteTexture(currentTexture); } - - // Create new texture - currentTexture = createTextureFromImage(gl, img); + currentTexture = result.texture; lastArtworkUrl = url; + // Update metrics + updateTextureCount(1, result.width * result.height * 4); + // Render with new texture render(); + + console.log(`[ImmersiveCanvas] Texture loaded: ${result.width}x${result.height}`); } catch (e) { + // Ignore abort errors + if (e instanceof DOMException && e.name === 'AbortError') { + return; + } console.warn('[ImmersiveCanvas] Failed to load texture:', e); } } diff --git a/src/lib/immersive/utils/texture-loader.ts b/src/lib/immersive/utils/texture-loader.ts new file mode 100644 index 000000000..7184c5eca --- /dev/null +++ b/src/lib/immersive/utils/texture-loader.ts @@ -0,0 +1,238 @@ +/** + * Texture Loader + * + * Handles async loading of artwork textures with pre-blur processing. + * Supports request cancellation for rapid track changes. + */ + +import { createTextureFromImage } from './webgl-utils'; + +// Cache of loaded blurred images (data URLs) +const blurCache = new Map(); +const MAX_CACHE_SIZE = 20; + +// Active load requests (for cancellation) +const activeLoads = new Map(); + +/** + * Generate a pre-blurred version of an image. + * Uses canvas 2D for blur (one-time CPU cost, then GPU texture). + * + * @param imageUrl - Source image URL + * @param size - Output size (smaller = more blur when scaled) + * @param blurRadius - Canvas filter blur radius + * @param signal - AbortSignal for cancellation + */ +async function generateBlurredImage( + imageUrl: string, + size: number = 64, + blurRadius: number = 20, + signal?: AbortSignal +): Promise { + // Check cache first + const cacheKey = `${imageUrl}-${size}-${blurRadius}`; + const cached = blurCache.get(cacheKey); + if (cached) return cached; + + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + + const img = new Image(); + img.crossOrigin = 'anonymous'; + + const abortHandler = () => { + img.src = ''; + reject(new DOMException('Aborted', 'AbortError')); + }; + + signal?.addEventListener('abort', abortHandler); + + img.onload = () => { + signal?.removeEventListener('abort', abortHandler); + + if (signal?.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + + try { + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + reject(new Error('Could not get canvas context')); + return; + } + + // Apply blur and color adjustments + ctx.filter = `blur(${blurRadius}px) saturate(1.2) brightness(0.5)`; + + // Draw with offset to avoid edge artifacts + const offset = blurRadius; + const drawSize = size + offset * 2; + ctx.drawImage(img, -offset, -offset, drawSize, drawSize); + + // Convert to data URL + const dataUrl = canvas.toDataURL('image/jpeg', 0.8); + + // Cache the result + if (blurCache.size >= MAX_CACHE_SIZE) { + const firstKey = blurCache.keys().next().value; + if (firstKey) blurCache.delete(firstKey); + } + blurCache.set(cacheKey, dataUrl); + + resolve(dataUrl); + } catch (err) { + reject(err); + } + }; + + img.onerror = () => { + signal?.removeEventListener('abort', abortHandler); + reject(new Error(`Failed to load image: ${imageUrl}`)); + }; + + img.src = imageUrl; + }); +} + +/** + * Load an image from a URL or data URL. + */ +function loadImage(src: string, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + + const img = new Image(); + img.crossOrigin = 'anonymous'; + + const abortHandler = () => { + img.src = ''; + reject(new DOMException('Aborted', 'AbortError')); + }; + + signal?.addEventListener('abort', abortHandler); + + img.onload = () => { + signal?.removeEventListener('abort', abortHandler); + resolve(img); + }; + + img.onerror = () => { + signal?.removeEventListener('abort', abortHandler); + reject(new Error(`Failed to load image: ${src}`)); + }; + + img.src = src; + }); +} + +export interface LoadTextureResult { + texture: WebGLTexture; + width: number; + height: number; +} + +/** + * Load a blurred texture from artwork URL. + * + * Process: + * 1. Generate pre-blurred image (cached) + * 2. Load as HTMLImageElement + * 3. Upload to WebGL texture + * + * Cancels any previous load for the same request ID. + * + * @param gl - WebGL2 context + * @param artworkUrl - URL of the artwork + * @param requestId - Unique ID for this load request (for cancellation) + */ +export async function loadBlurredTexture( + gl: WebGL2RenderingContext, + artworkUrl: string, + requestId: string = 'default' +): Promise { + // Cancel any previous load with the same request ID + const existingController = activeLoads.get(requestId); + if (existingController) { + existingController.abort(); + } + + // Create new abort controller + const controller = new AbortController(); + activeLoads.set(requestId, controller); + + try { + // Generate blurred image + const blurredDataUrl = await generateBlurredImage( + artworkUrl, + 128, // Size (larger = less blur needed) + 30, // Blur radius + controller.signal + ); + + // Load as image element + const img = await loadImage(blurredDataUrl, controller.signal); + + // Create texture + const texture = createTextureFromImage(gl, img); + if (!texture) { + throw new Error('Failed to create texture'); + } + + // Clean up active load + activeLoads.delete(requestId); + + return { + texture, + width: img.width, + height: img.height, + }; + } catch (err) { + activeLoads.delete(requestId); + + // Don't log abort errors - they're expected during rapid track changes + if (err instanceof DOMException && err.name === 'AbortError') { + return null; + } + + console.warn('[TextureLoader] Failed to load texture:', err); + return null; + } +} + +/** + * Cancel all active texture loads. + */ +export function cancelAllLoads(): void { + for (const controller of activeLoads.values()) { + controller.abort(); + } + activeLoads.clear(); +} + +/** + * Clear the blur cache. + */ +export function clearBlurCache(): void { + blurCache.clear(); +} + +/** + * Get cache statistics for debugging. + */ +export function getBlurCacheStats(): { size: number; maxSize: number } { + return { + size: blurCache.size, + maxSize: MAX_CACHE_SIZE, + }; +}