fix(visualizer): blank Mix ribbon at rest + init/draw diagnostics (P10 W1)

This commit is contained in:
daniel-c-harvey
2026-06-15 17:45:21 -04:00
parent b3283d0bd2
commit 06b58304c5
@@ -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