diff --git a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts index 636528e..3da360d 100644 --- a/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts +++ b/DeepDrftPublic/Interop/visualizer/MixVisualizer.ts @@ -67,6 +67,21 @@ const RIBBON_HALF_WIDTH_FRAC = 0.92; */ const MAX_DPR = 2; +// ── Diagnostics ────────────────────────────────────────────────────────────────── +// +// Set true to surface the init/draw/datum/playback seams to the browser console +// (all prefixed `[MixVisualizer]`). The error/warn paths fire regardless of this +// flag — they only trigger on the abnormal path. The verbose `log` paths (datum +// received/uploaded, first-draw dimensions, GL error after first draw) are gated +// here so they can be silenced once the renderer is confirmed healthy. Leave it on +// while the runtime fix is being verified through the browser. +const DEBUG = true; + +const TAG = '[MixVisualizer]'; +function debugLog(...args: unknown[]): void { + if (DEBUG) console.log(TAG, ...args); +} + // ── Theme: the gradient stop colours, read live from the active MudBlazor palette. ─ // // The shader cannot resolve `var(--mud-palette-*)` directly — uniforms are plain @@ -90,10 +105,19 @@ interface ResolvedTheme { edge: [number, number, number]; } -/** Read a CSS custom property off an element, falling back if it is empty/undefined. */ +/** + * Read a CSS custom property off an element, falling back if it is empty/undefined. + * An empty value means the var did not inherit onto the canvas (e.g. the palette is + * scoped to a wrapper the canvas isn't under), which would silently swap the ribbon + * colour for the hardcoded default — so warn on it when diagnosing. + */ function readVar(el: Element, name: string, fallback: string): string { const v = getComputedStyle(el).getPropertyValue(name).trim(); - return v.length > 0 ? v : fallback; + if (v.length === 0) { + if (DEBUG) console.warn(`${TAG} CSS var '${name}' did not resolve off the canvas — using fallback '${fallback}'; ribbon colour may be wrong.`); + return fallback; + } + return v; } /** @@ -278,6 +302,12 @@ void main() { // per-frame CPU blur. (No CSS backdrop-filter, no shadowBlur — spec §5.2.) float feather = 1.5; float inside = 1.0 - smoothstep(halfWidth - feather, halfWidth + feather, distFromCentre); + // With no datum, amp is 0 everywhere and halfWidth collapses to 0 — but the + // feathered smoothstep still lights the ~feather-wide column at the exact centre, + // drawing a thin vertical line down the middle of an otherwise-empty backdrop. + // That is the at-rest "vertical bar" artifact. The predecessor drew nothing at + // rest; match it by forcing the silhouette empty whenever there is no datum. + if (uHasDatum < 0.5) inside = 0.0; // Vertical gradient: brightest at the now line, dimming toward both edges. Same // luminosity cue the predecessor drew (edge -> accent -> edge by screen Y). @@ -349,6 +379,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { // No WebGL2 (old engine / disabled): hand back a no-op handle so the // component still functions as a plain backdrop (mirrors the predecessor's // no-2d-context fallback, now guarding against no-WebGL2). + console.error(`${TAG} getContext('webgl2') returned null — WebGL2 unavailable; rendering a plain backdrop.`); return noopHandle(); } // Non-null binding so the closures below keep the narrowing (TS does not carry @@ -361,7 +392,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { } catch (err) { // A compile/link failure on an exotic driver should degrade to the plain // backdrop, not crash the page. Log for diagnosis; return the no-op handle. - console.warn(err); + console.error(`${TAG} shader compile/link failed; rendering a plain backdrop.`, err); return noopHandle(); } @@ -369,7 +400,12 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { // shader sources its positions from gl_VertexID, so no attribute buffers. const vao = gl.createVertexArray(); - // Cache uniform locations once. + // Cache uniform locations once. A null here for a uniform we actually upload + // means either the name is misspelled or the GLSL compiler dead-stripped it + // (it isn't reachable in the shader) — both of which silently break a uniform's + // effect, so surface them. `uTimeSeconds` is reserved for Wave 3 and currently + // unused by the fragment shader, so the compiler is free to strip it; we exempt + // it from the warning to avoid a false alarm. const u = { resolution: gl.getUniformLocation(program, 'uResolution'), playheadSeconds: gl.getUniformLocation(program, 'uPlayheadSeconds'), @@ -381,6 +417,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { hasDatum: gl.getUniformLocation(program, 'uHasDatum'), datum: gl.getUniformLocation(program, 'uDatum'), }; + for (const [name, loc] of Object.entries(u)) { + if (loc === null && name !== 'timeSeconds') { + console.warn(`${TAG} uniform '${name}' resolved to null — it will have no effect (misspelled or dead-stripped from the shader).`); + } + } // ── Mutable state, fed by the component through the handle. ────────────────── let datum: Datum | null = null; @@ -389,12 +430,14 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { /** Resolve the gradient stops from the live palette vars on the canvas. */ function readTheme(): ResolvedTheme { - return { + const resolved: ResolvedTheme = { // Brightest stop at the "now" line — the bespoke themes' primary accent. accent: parseColor(readVar(canvas, '--mud-palette-primary', '#b08d57')), // Dim stop at the edges — the surface colour so the ribbon fades into the page. edge: parseColor(readVar(canvas, '--mud-palette-surface', '#1a1a1a')), }; + debugLog(`theme resolved — accent=[${resolved.accent.map((c) => c.toFixed(2)).join(', ')}], edge=[${resolved.edge.map((c) => c.toFixed(2)).join(', ')}].`); + return resolved; } let theme: ResolvedTheme = readTheme(); @@ -403,6 +446,12 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { let disposed = false; const startTimeMs = performance.now(); + // One-shot diagnostics: log the canvas dimensions + a post-draw gl.getError() the + // first time we actually draw at a non-degenerate size. A 1×1 (or 300×150 default) + // backing store here means the canvas had no layout box when the first draw ran — + // the ResizeObserver will correct it, but the first paint would be degenerate. + let firstRealDrawLogged = false; + // Backing-store size in device pixels, tracked so we only resize the canvas // (which clears it) when the CSS box actually changed. let cssWidth = 0; @@ -495,6 +544,21 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { // One full-screen triangle (3 vertices), positions from gl_VertexID. gl.drawArrays(gl.TRIANGLES, 0, 3); gl.bindVertexArray(null); + + // First draw at a real (laid-out) size: report dimensions and any accumulated + // GL error. We hold this log until cssWidth/cssHeight are populated so the + // dimensions Daniel sees are the meaningful ones, not a pre-layout 1×1. + // gl.getError() is a pipeline stall, so we only call it once, never per frame. + if (!firstRealDrawLogged && cssWidth > 0 && cssHeight > 0) { + firstRealDrawLogged = true; + debugLog( + `first draw — backing store ${canvas.width}x${canvas.height} px (css ${cssWidth}x${cssHeight} @ dpr ${dpr}), hasDatum=${datum ? 1 : 0}`, + ); + const glErr = gl.getError(); + if (glErr !== gl.NO_ERROR) { + console.error(`${TAG} gl.getError() after first draw: 0x${glErr.toString(16)} — the draw did not complete cleanly.`); + } + } } // ── rAF loop lifecycle (spec §E: cool when paused/backgrounded). ───────────── @@ -564,9 +628,18 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { * CLAMP_TO_EDGE. Returns the Datum, or null on an empty/invalid input. */ function uploadDatum(samplesBase64: string, durationSeconds: number): Datum | null { - if (durationSeconds <= 0 || !samplesBase64) return null; + if (durationSeconds <= 0 || !samplesBase64) { + // Expected before the player reports a duration: the bridge pushes an empty + // datum until then. Not an error, but worth seeing while diagnosing. + debugLog(`uploadDatum skipped — durationSeconds=${durationSeconds}, base64 length=${samplesBase64?.length ?? 0}.`); + return null; + } const samples = decodeSamples(samplesBase64); - if (samples.length === 0) return null; + if (samples.length === 0) { + console.warn(`${TAG} uploadDatum: decoded 0 samples from a non-empty base64 string — datum will not render.`); + return null; + } + debugLog(`uploadDatum — ${samples.length} samples for ${durationSeconds.toFixed(2)}s mix (${(samples.length / durationSeconds).toFixed(1)} samples/s).`); const texture = gl.createTexture(); if (!texture) return null; @@ -594,6 +667,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { return { setDatum(samplesBase64: string, durationSeconds: number): void { + debugLog(`setDatum received — base64 length ${samplesBase64?.length ?? 0}, durationSeconds ${durationSeconds}.`); // Free the previous datum's GPU texture before replacing it (no leak // across re-pushes / mix changes — spec §5.11). if (datum) { @@ -612,6 +686,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle { if (isPlaying && !wasPlaying) { // Transition paused/stopped → playing: start the rAF loop. + debugLog(`playback started — position ${positionSeconds.toFixed(2)}s, datum ${datum ? 'present' : 'ABSENT'}; starting rAF loop.`); startLoop(); } else if (!isPlaying && wasPlaying) { // Transition playing → paused/stopped: the in-flight frame draws the