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:
parent
da8d4ecb19
commit
b2e45380ec
@ -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));
|
||||
|
||||
@ -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,10 +94,10 @@ export function ImageGenStream({
|
||||
{status === 'idle' && (
|
||||
<Placeholder text="Ready to generate." />
|
||||
)}
|
||||
{(status === 'streaming' || status === 'complete') && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
{(status === 'streaming' || status === 'complete') &&
|
||||
(visibleSrc ? (
|
||||
<img
|
||||
src={status === 'complete' ? finalUrl ?? snapshot?.partialUrl : snapshot?.partialUrl}
|
||||
src={visibleSrc}
|
||||
alt={ariaLabel ?? 'Generated image'}
|
||||
data-testid="bl-image-gen-stream-img"
|
||||
style={{
|
||||
@ -107,11 +105,12 @@ export function ImageGenStream({
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
filter: status === 'streaming' ? 'blur(4px)' : 'none',
|
||||
opacity: revealedAt ? 1 : 0.95,
|
||||
transition: 'filter 200ms ease, opacity 200ms ease',
|
||||
transition: 'filter 200ms ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
) : (
|
||||
<Placeholder text="Waiting for first frame…" />
|
||||
))}
|
||||
{status === 'streaming' && (
|
||||
<div
|
||||
data-testid="bl-image-gen-stream-progress"
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user