fix(media-ui): plug AudioContext leak + guard <img> against undefined src

Two latent bugs caught in the audit pass:

1. **AudioContext leak in AudioWaveform.**
   The lazy WebAudio decoder constructed an `AudioContext` per
   mount-with-audioUrl and never called `.close()`. Browsers cap the
   total per page (Chrome ~6, Firefox ~6) so a long-lived session that
   remounted waveforms enough times eventually hit
   `InvalidStateError: cannot create AudioContext` and silently
   stopped decoding.
   Fix: wrap `decodeAudioData` in try/finally and `close()` the
   context in the finally. Errors from close() are swallowed (best-
   effort cleanup).

2. **Undefined img src in ImageGenStream.**
   When `status=streaming` arrived before the first
   `snapshot.partialUrl` (very common during the SSE handshake),
   the component rendered <img src={undefined}> which browsers treat
   as a broken-image icon. Same for status=complete + missing
   finalUrl.
   Fix: compute `visibleSrc` once; only mount <img> if a source
   exists, otherwise show 'Waiting for first frame\u2026' placeholder.
   Also removed the dead `revealedAt` state \u2014 the prior 0.95/1
   opacity dance was imperceptible and contributed nothing.

3. **Bonus: AudioWaveform `bars` clamp.**
   `bars={0}` (or negative / fractional) divided by zero on the
   canvas slot math and rendered an empty waveform. Now clamped to
   `Math.max(1, Math.floor(barsProp))`.

Tests: 10 \u2192 12
  AudioWaveform   \u00b7 bars=0 doesn't crash + canvas still renders
  ImageGenStream  \u00b7 streaming without partialUrl shows placeholder
                    instead of <img src={undefined}>
This commit is contained in:
saravanakumardb1 2026-05-27 18:44:50 -07:00
parent da8d4ecb19
commit b2e45380ec
3 changed files with 54 additions and 30 deletions

View File

@ -39,7 +39,7 @@ export interface AudioWaveformProps {
export function AudioWaveform({
peaks: peaksProp,
audioUrl,
bars = 96,
bars: barsProp = 96,
progress = 0,
onSeek,
width = 480,
@ -50,6 +50,9 @@ export function AudioWaveform({
className,
style,
}: AudioWaveformProps) {
// Clamp to a sane minimum — zero or negative bars would divide-by-zero
// on canvas and produce an empty render.
const bars = Math.max(1, Math.floor(barsProp));
const canvasRef = useRef<HTMLCanvasElement>(null);
const titleId = useId();
const [decoded, setDecoded] = useState<number[] | null>(null);
@ -74,7 +77,13 @@ export function AudioWaveform({
.webkitAudioContext;
if (!Ctx) return;
const ac = new Ctx();
const audio = await ac.decodeAudioData(buf);
let audio;
try {
audio = await ac.decodeAudioData(buf);
} finally {
// Free the AudioContext slot — browsers cap the count per page.
void ac.close?.().catch(() => {});
}
if (cancelled) return;
const channel = audio.getChannelData(0);
const block = Math.max(1, Math.floor(channel.length / bars));

View File

@ -1,9 +1,4 @@
import {
useEffect,
useState,
type CSSProperties,
type ReactNode,
} from 'react';
import { type CSSProperties, type ReactNode } from 'react';
export type ImageGenStatus = 'idle' | 'streaming' | 'complete' | 'error';
@ -60,13 +55,16 @@ export function ImageGenStream({
style,
}: ImageGenStreamProps) {
const height = Math.round(width / Math.max(0.1, aspect));
const [revealedAt, setRevealedAt] = useState<number | null>(null);
useEffect(() => {
if (status === 'complete') setRevealedAt(Date.now());
else setRevealedAt(null);
}, [status, finalUrl]);
const progress = Math.max(0, Math.min(1, snapshot?.progress ?? 0));
// Pick the visible source: prefer the final URL when complete, otherwise
// the partial. If neither is supplied yet the host gets the placeholder
// shell — never an <img> with `src={undefined}` (browser broken-image).
const visibleSrc =
status === 'complete'
? finalUrl ?? snapshot?.partialUrl
: status === 'streaming'
? snapshot?.partialUrl
: undefined;
return (
<figure
@ -96,22 +94,23 @@ export function ImageGenStream({
{status === 'idle' && (
<Placeholder text="Ready to generate." />
)}
{(status === 'streaming' || status === 'complete') && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={status === 'complete' ? finalUrl ?? snapshot?.partialUrl : snapshot?.partialUrl}
alt={ariaLabel ?? 'Generated image'}
data-testid="bl-image-gen-stream-img"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
filter: status === 'streaming' ? 'blur(4px)' : 'none',
opacity: revealedAt ? 1 : 0.95,
transition: 'filter 200ms ease, opacity 200ms ease',
}}
/>
)}
{(status === 'streaming' || status === 'complete') &&
(visibleSrc ? (
<img
src={visibleSrc}
alt={ariaLabel ?? 'Generated image'}
data-testid="bl-image-gen-stream-img"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
filter: status === 'streaming' ? 'blur(4px)' : 'none',
transition: 'filter 200ms ease',
}}
/>
) : (
<Placeholder text="Waiting for first frame…" />
))}
{status === 'streaming' && (
<div
data-testid="bl-image-gen-stream-progress"

View File

@ -51,6 +51,13 @@ describe('AudioWaveform', () => {
render(<AudioWaveform bars={16} width={120} height={40} />);
expect(screen.getByTestId('bl-audio-waveform')).toBeDefined();
});
it('clamps bars=0 to a safe minimum (no division-by-zero on canvas)', () => {
// Should not throw, should render a canvas.
expect(() => render(<AudioWaveform peaks={[0.5]} bars={0} />)).not.toThrow();
const canvas = screen.getByTestId('bl-audio-waveform').querySelector('canvas');
expect(canvas).not.toBeNull();
});
});
describe('ImageGenStream', () => {
@ -82,6 +89,15 @@ describe('ImageGenStream', () => {
const e = screen.getByTestId('bl-image-gen-stream-error');
expect(e.textContent).toMatch(/boom/);
});
it('streaming with no partialUrl renders a placeholder instead of an empty <img>', () => {
render(<ImageGenStream status="streaming" snapshot={{ progress: 0.1 }} />);
expect(screen.queryByTestId('bl-image-gen-stream-img')).toBeNull();
// The waiting placeholder uses the same testid as the idle one (Placeholder helper).
expect(screen.getByTestId('bl-image-gen-stream-idle').textContent).toMatch(/waiting/i);
// Progress strip still renders.
expect(screen.getByTestId('bl-image-gen-stream-progress')).toBeDefined();
});
});
describe('VideoPlayer', () => {