Files
qbz/src/lib/components/immersive/ImmersiveBackground.svelte
T
vicrodh 0bdd83dc08 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.
2026-02-01 11:32:29 -06:00

283 lines
8.1 KiB
Svelte

<script lang="ts">
/**
* Immersive Background
*
* Renders the blurred album artwork background for the immersive player.
* Uses WebGL2 when available (with ambient motion), falls back to CSS blur.
*/
import { onMount } from 'svelte';
import {
BUILD_IMMERSIVE_ENABLED,
isWebGL2Available,
isRuntimeEnabled,
} from '$lib/immersive';
interface Props {
artwork: string;
/** Enable ambient motion effect (WebGL2 only) */
enableAmbient?: boolean;
/** Ambient motion intensity 0-1 (WebGL2 only) */
ambientIntensity?: number;
}
let { artwork, enableAmbient = true, ambientIntensity }: Props = $props();
// Determine which renderer to use
let useWebGL = $state(false);
let useFallback = $state(false);
let WebGLCanvas: typeof import('$lib/immersive/ImmersiveAmbientCanvas.svelte').default | null = $state(null);
// Fallback state (CSS-based blur)
let canvasRef: HTMLCanvasElement | undefined = $state();
let isLoading = $state(true);
let currentArtwork = $state('');
// Check capabilities and load WebGL component if available
onMount(async () => {
if (BUILD_IMMERSIVE_ENABLED && isRuntimeEnabled() && isWebGL2Available()) {
try {
// Dynamic import - only loads if WebGL2 is available
const module = await import('$lib/immersive/ImmersiveAmbientCanvas.svelte');
WebGLCanvas = module.default;
useWebGL = true;
console.log('[ImmersiveBackground] Using WebGL2 renderer');
} catch (e) {
console.warn('[ImmersiveBackground] Failed to load WebGL canvas:', e);
useFallback = true;
}
} else {
useFallback = true;
console.log('[ImmersiveBackground] Using CSS fallback');
}
});
/**
* Handle WebGL fallback (if WebGL init fails)
*/
function handleWebGLFallback(): void {
console.warn('[ImmersiveBackground] WebGL failed, switching to CSS fallback');
useWebGL = false;
useFallback = true;
}
// =====================================================
// 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<void> {
if (!canvasRef || !imageUrl) return;
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const rand = seededRandom(imageUrl);
// 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; }
// 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) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const avg = (r + g + b) / 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;
// 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));
}
finalCtx.putImageData(imageData, 0, 0);
isLoading = false;
};
img.onerror = () => {
isLoading = false;
};
img.src = imageUrl;
}
// Track artwork changes for fallback renderer
$effect(() => {
if (useFallback && artwork && artwork !== currentArtwork) {
currentArtwork = artwork;
isLoading = true;
generateBlurredBackground(artwork);
}
});
</script>
<div class="immersive-background">
{#if useWebGL && WebGLCanvas}
<!-- WebGL2 Renderer with ambient motion -->
<WebGLCanvas
artworkUrl={artwork}
enableMotion={enableAmbient}
intensity={ambientIntensity}
onFallback={handleWebGLFallback}
/>
{:else if useFallback}
<!-- CSS Fallback Renderer -->
<div class="fallback-container" class:loading={isLoading}>
<canvas
bind:this={canvasRef}
class="background-canvas"
aria-hidden="true"
></canvas>
</div>
{:else}
<!-- Loading state while determining renderer -->
<div class="loading-placeholder"></div>
{/if}
<!-- Dark overlay for better contrast -->
<div class="dark-overlay"></div>
</div>
<style>
.immersive-background {
position: absolute;
inset: 0;
overflow: hidden;
z-index: 0;
background-color: #0a0a0b;
}
/* CSS Fallback styles */
.fallback-container {
position: absolute;
inset: 0;
}
.background-canvas {
position: absolute;
inset: -120px;
width: calc(100% + 240px);
height: calc(100% + 240px);
image-rendering: auto;
filter: blur(60px);
transform: scale(1.2) translateZ(0);
will-change: opacity;
transition: opacity 500ms ease-out;
}
.loading .background-canvas {
opacity: 0;
}
.loading-placeholder {
position: absolute;
inset: 0;
background-color: #0a0a0b;
}
/* Dark overlay */
.dark-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.15);
pointer-events: none;
z-index: 1;
}
</style>