poc(immersive-bg): replace WebGL2 path with kawarp in 'full' mode

ImmersiveBackground feeds every panel that doesn't render its own
canvas (Static, Coverflow, Lyrics-focus, Queue-focus, AlbumReactive,
TrackInfo split, Suggestions split, the legacy split lyrics/queue),
so refactoring it once swaps the bg engine across all of them in a
single change.

What changed:

  - 'full' mode used to dynamic-import ImmersiveAmbientCanvas (a
    bespoke WebGL2 renderer maintained in-house) and a CSS-blur
    canvas as fallback. Replaced with a kawarp WebGL canvas and
    'lite' mode as the WebGL-failure fallback (was already the
    watchdog's degradation target). Drops ~400 lines of legacy
    canvas blur code from the bundle indirectly — the dynamic
    import path is gone.
  - Kawarp is mounted directly inside the component on a bound
    canvas via $effect once the canvas is in the DOM.
    transitionDuration: 1000 ms means kawarp handles cross-fades
    natively, so when in full mode we skip the manual CSS opacity
    dance the lite/off modes still need.
  - Tuning matches the values landed on KawarpPanel after the A/B
    rounds (warp 1.0, blur 7, speed 1.15, scale 1.0, saturation 1.5,
    tint 0.06). Comments cite the rationale.
  - switchToMode disposes the kawarp instance when degrading away
    from full, so no leaked GL context after a watchdog event.
  - lite + off modes are unchanged, including their CSS-fade logic.

cc
This commit is contained in:
vicrodh committed 2026-04-29 02:57:11 -06:00
1 parent a48d1b369d
commit c106928a53
1 file changed
+156 -260
@@ -3,13 +3,28 @@
* Immersive Background
*
* Renders the blurred album artwork background for the immersive player.
* Uses WebGL2 when available (with ambient motion), falls back to CSS blur.
* Three modes (set via getConfig().backgroundMode, with watchdog
* degradation):
*
* - 'full' — Kawarp WebGL renderer (Kawase blur + domain warping).
* The default and the look the marketing copy promises.
* - 'lite' — CSS-transform of a pre-blurred bitmap. Cheap, uses the
* compositor only. Fallback for memory-constrained hosts
* (Pi-class) and the watchdog's degradation target.
* - 'off' — Solid color extracted from the cover. The most muted
* option, for users who want the immersive view without
* any motion at all.
*
* Kawarp parameter tuning is the result of an A/B benchmark against
* the previous WebGL2 path on the Static panel — see the POC notes
* for the full reasoning. Short version: demo defaults from
* kawarp.boidu.dev with three colour-side adjustments so cover-driven
* palette dominates the look instead of a global tint.
*/
import { onMount, onDestroy } from 'svelte';
import { Kawarp } from '@kawarp/core';
import {
BUILD_IMMERSIVE_ENABLED,
isWebGL2Available,
isRuntimeEnabled,
getConfig,
generateAtmosphere,
@@ -18,30 +33,25 @@
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();
let { artwork }: Props = $props();
// Determine which renderer to use
let backgroundMode: BackgroundMode = $state('full');
let useWebGL = $state(false);
let useFallback = $state(false);
let useKawarp = $state(false);
let useSolidColor = $state(false);
let useLiteMode = $state(false);
let solidColor = $state('#0a0a0b');
let liteImageUrl = $state('');
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('');
// Kawarp state
let kawarpCanvas: HTMLCanvasElement | undefined = $state();
let kawarp: Kawarp | undefined;
let kawarpResizeObserver: ResizeObserver | undefined;
let lastLoadedArtwork: string | null = null;
// Crossfade transition state
// CSS-mode crossfade state (lite/off rely on this; kawarp does its own
// crossfade via transitionDuration so it doesn't touch isTransitioning).
let isTransitioning = $state(false);
let previousArtwork = $state('');
@@ -51,12 +61,20 @@
async function switchToMode(mode: BackgroundMode): Promise<void> {
console.log(`[ImmersiveBackground] Switching to mode: ${mode}`);
// Tear down kawarp if it was active — switching away from full means
// we don't need the WebGL context anymore.
if (kawarp) {
kawarpResizeObserver?.disconnect();
kawarpResizeObserver = undefined;
kawarp.dispose();
kawarp = undefined;
lastLoadedArtwork = null;
}
// Reset all mode flags
useWebGL = false;
useFallback = false;
useKawarp = false;
useSolidColor = false;
useLiteMode = false;
WebGLCanvas = null;
backgroundMode = mode;
if (mode === 'off') {
@@ -71,6 +89,10 @@
console.warn('[ImmersiveBackground] Lite mode texture failed:', err);
}
}
} else if (mode === 'full') {
useKawarp = true;
// The canvas-bind effect below will pick up the new mount and
// initialise kawarp once the canvas is in the DOM.
}
}
@@ -81,9 +103,7 @@
}
}
// Check capabilities and load WebGL component if available
onMount(async () => {
// Listen for performance watchdog degradation events
window.addEventListener('immersive:background-degraded', handleBackgroundDegraded);
const config = getConfig();
@@ -92,9 +112,7 @@
if (backgroundMode === 'off') {
useSolidColor = true;
console.log('[ImmersiveBackground] Background mode: off (solid color)');
if (artwork) {
extractDominantColor(artwork);
}
if (artwork) extractDominantColor(artwork);
return;
}
@@ -111,37 +129,110 @@
return;
}
// Full mode: WebGL2 → CSS fallback
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] Background mode: full (WebGL2)');
} catch (e) {
console.warn('[ImmersiveBackground] Failed to load WebGL canvas:', e);
useFallback = true;
}
// full mode → kawarp. The runtime gate is intentionally loose:
// BUILD_IMMERSIVE_ENABLED was the historical gate for the bespoke
// WebGL2 renderer; kawarp is part of the standard build, so all we
// need is the immersive runtime to be enabled.
if (isRuntimeEnabled()) {
useKawarp = true;
console.log('[ImmersiveBackground] Background mode: full (Kawarp)');
} else {
useFallback = true;
console.log('[ImmersiveBackground] Background mode: full (CSS fallback)');
// Runtime disabled — fall back to lite. Solid colour is too austere
// for users who didn't explicitly opt into 'off'.
useLiteMode = true;
console.log('[ImmersiveBackground] Background mode: lite (runtime disabled)');
if (artwork) {
try {
liteImageUrl = await generateAtmosphere(artwork);
} catch (err) {
console.warn('[ImmersiveBackground] Lite mode texture failed:', err);
}
}
}
});
// Mount kawarp once the canvas element is bound. Re-runs if the canvas
// gets re-mounted (e.g. after a switchToMode round trip).
$effect(() => {
if (!useKawarp || !kawarpCanvas || kawarp) return;
try {
kawarp = new Kawarp(kawarpCanvas, {
// See KawarpPanel notes for the rationale on each value.
warpIntensity: 1.0,
blurPasses: 7,
animationSpeed: 1.15,
transitionDuration: 1000,
saturation: 1.5,
tintIntensity: 0.06,
dithering: 0.008,
scale: 1.0,
});
} catch (err) {
console.warn('[ImmersiveBackground] Kawarp init failed, falling back to lite:', err);
kawarp = undefined;
switchToMode('lite');
return;
}
if (artwork) {
lastLoadedArtwork = artwork;
kawarp.loadImage(artwork).catch((e) =>
console.warn('[ImmersiveBackground] Kawarp loadImage failed:', e),
);
}
kawarp.start();
kawarpResizeObserver = new ResizeObserver(() => kawarp?.resize());
kawarpResizeObserver.observe(kawarpCanvas);
});
// Track artwork changes. Kawarp does its own crossfade (transitionDuration
// ms) so we don't trigger the CSS opacity dance for it; lite / off /
// legacy CSS modes still go through the manual fade.
$effect(() => {
if (artwork === previousArtwork) return;
if (useKawarp && kawarp && artwork && artwork !== lastLoadedArtwork) {
lastLoadedArtwork = artwork;
kawarp.loadImage(artwork).catch((e) =>
console.warn('[ImmersiveBackground] Kawarp loadImage failed:', e),
);
previousArtwork = artwork;
return;
}
if (artwork && artwork !== previousArtwork) {
isTransitioning = true;
setTimeout(async () => {
previousArtwork = artwork;
if (useSolidColor) {
extractDominantColor(artwork);
}
if (useLiteMode) {
try {
liteImageUrl = await generateAtmosphere(artwork);
} catch (err) {
console.warn('[ImmersiveBackground] Lite mode texture update failed:', err);
}
}
setTimeout(() => {
isTransitioning = false;
}, 100);
}, 400);
}
});
onDestroy(() => {
window.removeEventListener('immersive:background-degraded', handleBackgroundDegraded);
kawarpResizeObserver?.disconnect();
kawarp?.dispose();
kawarp = undefined;
});
/**
* Handle WebGL fallback (if WebGL init fails)
*/
function handleWebGLFallback(): void {
console.warn('[ImmersiveBackground] WebGL failed, switching to CSS fallback');
useWebGL = false;
useFallback = true;
}
// =====================================================
// Solid Color Mode (blur disabled)
// Extracts dominant color from artwork at tiny resolution
@@ -165,7 +256,6 @@
gSum += data[i + 1];
bSum += data[i + 2];
}
// Darken to 30% brightness for comfortable background
const darken = 0.3;
const r = Math.round((rSum / pixels) * darken);
const g = Math.round((gSum / pixels) * darken);
@@ -174,181 +264,6 @@
};
img.src = imageUrl;
}
// =====================================================
// 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.0x to 1.1x (takes 90%-100% for ALL colors)
const zoomFactor = 1.0 + rand() * 0.1;
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 and trigger transition
$effect(() => {
if (artwork && artwork !== previousArtwork) {
// Trigger fade out
isTransitioning = true;
// After fade out, update artwork
setTimeout(async () => {
previousArtwork = artwork;
// For solid color mode, re-extract dominant color
if (useSolidColor) {
extractDominantColor(artwork);
}
// For lite mode, regenerate atmosphere data URL
if (useLiteMode) {
try {
liteImageUrl = await generateAtmosphere(artwork);
} catch (err) {
console.warn('[ImmersiveBackground] Lite mode texture update failed:', err);
}
}
// For fallback renderer, regenerate background
if (useFallback) {
currentArtwork = artwork;
isLoading = true;
generateBlurredBackground(artwork);
}
// Fade back in after a small delay for texture load
setTimeout(() => {
isTransitioning = false;
}, 100);
}, 400); // Match CSS transition duration
}
});
</script>
<div class="immersive-background">
@@ -365,23 +280,13 @@
aria-hidden="true"
></div>
{/if}
{:else 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 if useKawarp}
<!-- Full mode: kawarp WebGL renderer (mounted by $effect once bound) -->
<canvas
bind:this={kawarpCanvas}
class="kawarp-canvas"
aria-hidden="true"
></canvas>
{:else}
<!-- Loading state while determining renderer -->
<div class="loading-placeholder"></div>
@@ -401,7 +306,8 @@
background-color: #0a0a0b;
}
/* Crossfade transition layer */
/* Crossfade transition layer (used only by lite / off modes — kawarp
handles its own crossfade internally via transitionDuration). */
.background-layer {
position: absolute;
inset: 0;
@@ -413,26 +319,16 @@
opacity: 0;
}
/* CSS Fallback styles */
.fallback-container {
.kawarp-canvas {
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;
width: 100%;
height: 100%;
/* Composite on its own GPU layer to avoid WebKitGTK repainting
surrounding chrome on every animation frame — same trick the
1.2.10 sticky-header fix used. */
will-change: transform;
transform: translateZ(0);
}
.solid-background {