From 0bdd83dc0847ea02c21c1fd01f2afdad71009676 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 1 Feb 2026 11:32:29 -0600 Subject: [PATCH] fix(immersive): destroy artwork structure, preserve only mood Implement proper atmospheric background pipeline: - Random NON-CENTERED crop (150-300% zoom) to break composition - Extreme blur at small size (destroys all semantic detail) - Scale up with smoothing for additional blur - Color adjustments (saturation 1.4x, brightness 0.5x) - Vignette overlay The background is now an unrecognizable color field derived from artwork colors. Acceptance test: "Can I identify the album from the background alone?" must be NO. Applies to both WebGL and CSS fallback renderers. --- .../immersive/ImmersiveBackground.svelte | 124 ++++++-- src/lib/immersive/utils/texture-loader.ts | 276 ++++++++++++++---- 2 files changed, 318 insertions(+), 82 deletions(-) diff --git a/src/lib/components/immersive/ImmersiveBackground.svelte b/src/lib/components/immersive/ImmersiveBackground.svelte index a53493038..d09671ecf 100644 --- a/src/lib/components/immersive/ImmersiveBackground.svelte +++ b/src/lib/components/immersive/ImmersiveBackground.svelte @@ -62,26 +62,106 @@ } // ===================================================== - // CSS Fallback Implementation (original code) + // CSS Fallback Implementation + // Uses same atmospheric transformation as WebGL path // ===================================================== + // Seeded random for consistent crops per image URL + function seededRandom(seed: string): () => number { + let hash = 0; + for (let i = 0; i < seed.length; i++) { + const char = seed.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return () => { + hash = Math.imul(hash ^ (hash >>> 16), 2246822507); + hash = Math.imul(hash ^ (hash >>> 13), 3266489909); + hash ^= hash >>> 16; + return (hash >>> 0) / 4294967296; + }; + } + async function generateBlurredBackground(imageUrl: string): Promise { if (!canvasRef || !imageUrl) return; - const ctx = canvasRef.getContext('2d'); - if (!ctx) return; - const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { - const size = 64; - canvasRef!.width = size; - canvasRef!.height = size; + const rand = seededRandom(imageUrl); - ctx.drawImage(img, 0, 0, size, size); + // Step 1: Extract random NON-CENTERED crop at small size + const CROP_SIZE = 32; + const cropCanvas = document.createElement('canvas'); + cropCanvas.width = CROP_SIZE; + cropCanvas.height = CROP_SIZE; + const cropCtx = cropCanvas.getContext('2d'); + if (!cropCtx) { isLoading = false; return; } - const imageData = ctx.getImageData(0, 0, size, size); + // Zoom factor: 1.5x to 3x + const zoomFactor = 1.5 + rand() * 1.5; + const sourceSize = Math.min(img.width, img.height) / zoomFactor; + const maxOffset = Math.min(img.width, img.height) - sourceSize; + + // Random position, biased away from center + let sx = rand() * maxOffset; + let sy = rand() * maxOffset; + + const cropCenterX = sx + sourceSize / 2; + const cropCenterY = sy + sourceSize / 2; + const imageCenterX = img.width / 2; + const imageCenterY = img.height / 2; + + const distFromCenter = Math.sqrt( + Math.pow(cropCenterX - imageCenterX, 2) + + Math.pow(cropCenterY - imageCenterY, 2) + ); + const minDistFromCenter = Math.min(img.width, img.height) * 0.15; + + if (distFromCenter < minDistFromCenter && maxOffset > 0) { + const corner = Math.floor(rand() * 4); + switch (corner) { + case 0: sx = 0; sy = 0; break; + case 1: sx = maxOffset; sy = 0; break; + case 2: sx = 0; sy = maxOffset; break; + case 3: sx = maxOffset; sy = maxOffset; break; + } + } + + cropCtx.drawImage( + img, + sx, sy, sourceSize, sourceSize, + 0, 0, CROP_SIZE, CROP_SIZE + ); + + // Step 2: Apply extreme blur at small size (destroys structure) + let current = cropCanvas; + for (let i = 0; i < 3; i++) { + const next = document.createElement('canvas'); + next.width = CROP_SIZE; + next.height = CROP_SIZE; + const ctx = next.getContext('2d'); + if (!ctx) continue; + ctx.filter = 'blur(8px)'; + const expand = 16; + ctx.drawImage(current, -expand, -expand, CROP_SIZE + expand * 2, CROP_SIZE + expand * 2); + current = next; + } + + // Step 3: Scale up to final size with smoothing + const FINAL_SIZE = 128; + canvasRef!.width = FINAL_SIZE; + canvasRef!.height = FINAL_SIZE; + const finalCtx = canvasRef!.getContext('2d'); + if (!finalCtx) { isLoading = false; return; } + + finalCtx.imageSmoothingEnabled = true; + finalCtx.imageSmoothingQuality = 'high'; + finalCtx.drawImage(current, 0, 0, FINAL_SIZE, FINAL_SIZE); + + // Step 4: Apply color adjustments via pixel manipulation + const imageData = finalCtx.getImageData(0, 0, FINAL_SIZE, FINAL_SIZE); const data = imageData.data; for (let i = 0; i < data.length; i += 4) { @@ -90,17 +170,23 @@ const b = data[i + 2]; const avg = (r + g + b) / 3; - const satFactor = 1.3; + // Saturation boost (1.4x) + brightness reduction (0.5x) + contrast reduction + const satFactor = 1.4; let newR = avg + (r - avg) * satFactor; let newG = avg + (g - avg) * satFactor; let newB = avg + (b - avg) * satFactor; - data[i] = Math.min(255, Math.max(0, newR * 0.55)); - data[i + 1] = Math.min(255, Math.max(0, newG * 0.55)); - data[i + 2] = Math.min(255, Math.max(0, newB * 0.55)); + // Brightness and contrast + newR = newR * 0.5; + newG = newG * 0.5; + newB = newB * 0.5; + + data[i] = Math.min(255, Math.max(0, newR)); + data[i + 1] = Math.min(255, Math.max(0, newG)); + data[i + 2] = Math.min(255, Math.max(0, newB)); } - ctx.putImageData(imageData, 0, 0); + finalCtx.putImageData(imageData, 0, 0); isLoading = false; }; @@ -165,12 +251,12 @@ .background-canvas { position: absolute; - inset: -80px; - width: calc(100% + 160px); - height: calc(100% + 160px); + inset: -120px; + width: calc(100% + 240px); + height: calc(100% + 240px); image-rendering: auto; - filter: blur(40px); - transform: scale(1.15) translateZ(0); + filter: blur(60px); + transform: scale(1.2) translateZ(0); will-change: opacity; transition: opacity 500ms ease-out; } diff --git a/src/lib/immersive/utils/texture-loader.ts b/src/lib/immersive/utils/texture-loader.ts index 933526341..2f5e48cf1 100644 --- a/src/lib/immersive/utils/texture-loader.ts +++ b/src/lib/immersive/utils/texture-loader.ts @@ -1,33 +1,121 @@ /** - * Texture Loader + * Texture Loader - Atmospheric Background Generator * - * Creates a high-quality pre-blurred texture that matches CSS blur(40px) quality. - * The blur is computed ONCE at load time - no per-frame processing. + * Creates an ATMOSPHERIC COLOR FIELD from album artwork. + * The artwork is used ONLY as a color/mood source. + * The original image must be COMPLETELY UNRECOGNIZABLE. * - * Strategy: - * 1. Load image at reasonable size (256x256) - * 2. Apply heavy Gaussian-like blur via multiple canvas filter passes - * 3. Apply color adjustments (saturation, brightness) - * 4. Upload to GPU texture ONCE + * Pipeline (NON-NEGOTIABLE): + * 1. Crop random NON-CENTERED region (150-300% zoom) + * 2. Downscale to TINY size (destroys all detail) + * 3. Apply EXTREME blur (destroys remaining structure) + * 4. Amplify colors while reducing contrast + * 5. Upload to GPU ONCE + * + * Acceptance Test: + * "If I hide the album cover, can I tell which album this is?" + * YES = FAIL, NO = PASS */ import { createTextureFromImage } from './webgl-utils'; -// Cache of loaded blurred images -const blurCache = new Map(); +// Cache of generated atmospheric textures +const atmosphereCache = new Map(); const MAX_CACHE_SIZE = 20; // Active load requests (for cancellation) const activeLoads = new Map(); +// Seeded random for consistent crops per image URL +function seededRandom(seed: string): () => number { + let hash = 0; + for (let i = 0; i < seed.length; i++) { + const char = seed.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return () => { + hash = Math.imul(hash ^ (hash >>> 16), 2246822507); + hash = Math.imul(hash ^ (hash >>> 13), 3266489909); + hash ^= hash >>> 16; + return (hash >>> 0) / 4294967296; + }; +} + /** - * Apply multiple blur passes to achieve CSS blur(40px) equivalent. - * Uses OffscreenCanvas if available for better performance. + * Extract a random NON-CENTERED crop from the image. + * Zoom: 150-300% (takes a portion, not the whole image) + * Position: Random, avoiding center to break symmetry */ -function applyHeavyBlur( +function extractRandomCrop( + img: HTMLImageElement, + targetSize: number, + seed: string +): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = targetSize; + canvas.height = targetSize; + const ctx = canvas.getContext('2d'); + if (!ctx) return canvas; + + const rand = seededRandom(seed); + + // Zoom factor: 1.5x to 3x (we take 33%-66% of the image) + const zoomFactor = 1.5 + rand() * 1.5; + + // Source region size (what portion of original image to take) + const sourceSize = Math.min(img.width, img.height) / zoomFactor; + + // Available range for crop position (avoid edges) + const maxOffset = Math.min(img.width, img.height) - sourceSize; + + // Random position, biased AWAY from center + // Generate position, then push away from center if too close + let sx = rand() * maxOffset; + let sy = rand() * maxOffset; + + // Calculate center of crop + const cropCenterX = sx + sourceSize / 2; + const cropCenterY = sy + sourceSize / 2; + const imageCenterX = img.width / 2; + const imageCenterY = img.height / 2; + + // If crop is too centered, push it away + const distFromCenter = Math.sqrt( + Math.pow(cropCenterX - imageCenterX, 2) + + Math.pow(cropCenterY - imageCenterY, 2) + ); + const minDistFromCenter = Math.min(img.width, img.height) * 0.15; + + if (distFromCenter < minDistFromCenter && maxOffset > 0) { + // Push towards a random corner + const corner = Math.floor(rand() * 4); + switch (corner) { + case 0: sx = 0; sy = 0; break; // top-left + case 1: sx = maxOffset; sy = 0; break; // top-right + case 2: sx = 0; sy = maxOffset; break; // bottom-left + case 3: sx = maxOffset; sy = maxOffset; break; // bottom-right + } + } + + // Draw the cropped region scaled to target size + ctx.drawImage( + img, + sx, sy, sourceSize, sourceSize, + 0, 0, targetSize, targetSize + ); + + return canvas; +} + +/** + * Apply EXTREME blur to completely destroy any remaining structure. + * Uses multiple passes and very high blur radius. + */ +function applyExtremeBlur( sourceCanvas: HTMLCanvasElement, - passes: number = 4, - blurPerPass: number = 12 + passes: number, + blurRadius: number ): HTMLCanvasElement { let current = sourceCanvas; const size = sourceCanvas.width; @@ -39,9 +127,16 @@ function applyHeavyBlur( const ctx = next.getContext('2d'); if (!ctx) return current; - // Each pass applies blur - ctx.filter = `blur(${blurPerPass}px)`; - ctx.drawImage(current, 0, 0); + // Apply blur with overpaint to avoid edge artifacts + ctx.filter = `blur(${blurRadius}px)`; + + // Draw larger and offset to eliminate edge darkening + const expand = blurRadius * 2; + ctx.drawImage( + current, + -expand, -expand, + size + expand * 2, size + expand * 2 + ); current = next; } @@ -50,15 +145,69 @@ function applyHeavyBlur( } /** - * Generate a pre-blurred background image. - * This creates a texture that looks like CSS filter: blur(40px). + * Apply color adjustments to create atmospheric mood. + * - Boost saturation (preserve mood) + * - Reduce brightness (background use) + * - Reduce contrast (smooth gradients) */ -async function generateBlurredImage( +function applyColorAdjustments( + sourceCanvas: HTMLCanvasElement +): HTMLCanvasElement { + const size = sourceCanvas.width; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) return sourceCanvas; + + // Saturation boost + brightness/contrast reduction + ctx.filter = 'saturate(1.4) brightness(0.5) contrast(0.85)'; + ctx.drawImage(sourceCanvas, 0, 0); + + return canvas; +} + +/** + * Add subtle vignette to push focus inward. + */ +function applyVignette( + sourceCanvas: HTMLCanvasElement, + intensity: number = 0.3 +): HTMLCanvasElement { + const size = sourceCanvas.width; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) return sourceCanvas; + + // Draw source + ctx.drawImage(sourceCanvas, 0, 0); + + // Create radial gradient for vignette + const gradient = ctx.createRadialGradient( + size / 2, size / 2, size * 0.2, + size / 2, size / 2, size * 0.7 + ); + gradient.addColorStop(0, `rgba(0, 0, 0, 0)`); + gradient.addColorStop(1, `rgba(0, 0, 0, ${intensity})`); + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, size, size); + + return canvas; +} + +/** + * Generate an atmospheric color field from artwork. + * The result must be COMPLETELY UNRECOGNIZABLE. + */ +async function generateAtmosphere( imageUrl: string, signal?: AbortSignal ): Promise { // Check cache first - const cached = blurCache.get(imageUrl); + const cached = atmosphereCache.get(imageUrl); if (cached) return cached; return new Promise((resolve, reject) => { @@ -86,50 +235,51 @@ async function generateBlurredImage( } try { - // Step 1: Draw image to initial canvas at target size - // 256x256 provides good quality while keeping processing fast - const SIZE = 256; - const BLUR_PASSES = 4; - const BLUR_PER_PASS = 15; // 4 passes * 15px ≈ CSS blur(40-50px) + // === TRANSFORMATION PIPELINE === - const sourceCanvas = document.createElement('canvas'); - sourceCanvas.width = SIZE; - sourceCanvas.height = SIZE; - const sourceCtx = sourceCanvas.getContext('2d'); - if (!sourceCtx) { + // Step 1: Extract random NON-CENTERED crop + // Use small initial size - we're destroying detail anyway + const INITIAL_SIZE = 64; + const croppedCanvas = extractRandomCrop(img, INITIAL_SIZE, imageUrl); + + // Step 2: First blur pass at small size (very efficient) + // High blur radius relative to size destroys all structure + const smallBlurred = applyExtremeBlur(croppedCanvas, 3, 12); + + // Step 3: Scale up to final size + const FINAL_SIZE = 256; + const scaledCanvas = document.createElement('canvas'); + scaledCanvas.width = FINAL_SIZE; + scaledCanvas.height = FINAL_SIZE; + const scaledCtx = scaledCanvas.getContext('2d'); + if (!scaledCtx) { reject(new Error('Could not get canvas context')); return; } - // Draw image scaled to fit canvas - sourceCtx.drawImage(img, 0, 0, SIZE, SIZE); + // Bilinear interpolation during scale-up adds more blur + scaledCtx.imageSmoothingEnabled = true; + scaledCtx.imageSmoothingQuality = 'high'; + scaledCtx.drawImage(smallBlurred, 0, 0, FINAL_SIZE, FINAL_SIZE); - // Step 2: Apply heavy blur (multiple passes) - const blurredCanvas = applyHeavyBlur(sourceCanvas, BLUR_PASSES, BLUR_PER_PASS); + // Step 4: Additional blur at final size for smoothness + const finalBlurred = applyExtremeBlur(scaledCanvas, 2, 20); - // Step 3: Apply color adjustments - const finalCanvas = document.createElement('canvas'); - finalCanvas.width = SIZE; - finalCanvas.height = SIZE; - const finalCtx = finalCanvas.getContext('2d'); - if (!finalCtx) { - reject(new Error('Could not get final canvas context')); - return; - } + // Step 5: Apply color adjustments + const colorAdjusted = applyColorAdjustments(finalBlurred); - // Saturation boost + brightness reduction for background use - finalCtx.filter = 'saturate(1.3) brightness(0.55)'; - finalCtx.drawImage(blurredCanvas, 0, 0); + // Step 6: Apply subtle vignette + const final = applyVignette(colorAdjusted, 0.25); - // Step 4: Convert to data URL - const dataUrl = finalCanvas.toDataURL('image/jpeg', 0.85); + // Convert to data URL + const dataUrl = final.toDataURL('image/jpeg', 0.9); // Cache the result - if (blurCache.size >= MAX_CACHE_SIZE) { - const firstKey = blurCache.keys().next().value; - if (firstKey) blurCache.delete(firstKey); + if (atmosphereCache.size >= MAX_CACHE_SIZE) { + const firstKey = atmosphereCache.keys().next().value; + if (firstKey) atmosphereCache.delete(firstKey); } - blurCache.set(imageUrl, dataUrl); + atmosphereCache.set(imageUrl, dataUrl); resolve(dataUrl); } catch (err) { @@ -187,10 +337,10 @@ export interface LoadTextureResult { } /** - * Load a pre-blurred texture from artwork URL. + * Load an atmospheric texture from artwork URL. * - * The texture is blurred ONCE at load time. - * No per-frame blur processing needed. + * The texture is an UNRECOGNIZABLE color field derived from the artwork. + * Computed ONCE at load time - no per-frame processing. */ export async function loadBlurredTexture( gl: WebGL2RenderingContext, @@ -207,14 +357,14 @@ export async function loadBlurredTexture( activeLoads.set(requestId, controller); try { - // Generate pre-blurred image (cached) - const blurredDataUrl = await generateBlurredImage( + // Generate atmospheric color field (cached) + const atmosphereDataUrl = await generateAtmosphere( artworkUrl, controller.signal ); // Load as image element - const img = await loadImage(blurredDataUrl, controller.signal); + const img = await loadImage(atmosphereDataUrl, controller.signal); // Create texture (uploaded ONCE) const texture = createTextureFromImage(gl, img); @@ -252,10 +402,10 @@ export function cancelAllLoads(): void { } /** - * Clear the blur cache. + * Clear the atmosphere cache. */ export function clearBlurCache(): void { - blurCache.clear(); + atmosphereCache.clear(); } /** @@ -263,7 +413,7 @@ export function clearBlurCache(): void { */ export function getBlurCacheStats(): { size: number; maxSize: number } { return { - size: blurCache.size, + size: atmosphereCache.size, maxSize: MAX_CACHE_SIZE, }; }