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:
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 {
|
||||
|
||||
Reference in new issue
Block a user