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({
|
export function AudioWaveform({
|
||||||
peaks: peaksProp,
|
peaks: peaksProp,
|
||||||
audioUrl,
|
audioUrl,
|
||||||
bars = 96,
|
bars: barsProp = 96,
|
||||||
progress = 0,
|
progress = 0,
|
||||||
onSeek,
|
onSeek,
|
||||||
width = 480,
|
width = 480,
|
||||||
@ -50,6 +50,9 @@ export function AudioWaveform({
|
|||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
}: AudioWaveformProps) {
|
}: 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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
const [decoded, setDecoded] = useState<number[] | null>(null);
|
const [decoded, setDecoded] = useState<number[] | null>(null);
|
||||||
@ -74,7 +77,13 @@ export function AudioWaveform({
|
|||||||
.webkitAudioContext;
|
.webkitAudioContext;
|
||||||
if (!Ctx) return;
|
if (!Ctx) return;
|
||||||
const ac = new Ctx();
|
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;
|
if (cancelled) return;
|
||||||
const channel = audio.getChannelData(0);
|
const channel = audio.getChannelData(0);
|
||||||
const block = Math.max(1, Math.floor(channel.length / bars));
|
const block = Math.max(1, Math.floor(channel.length / bars));
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
import {
|
import { type CSSProperties, type ReactNode } from 'react';
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
type CSSProperties,
|
|
||||||
type ReactNode,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
export type ImageGenStatus = 'idle' | 'streaming' | 'complete' | 'error';
|
export type ImageGenStatus = 'idle' | 'streaming' | 'complete' | 'error';
|
||||||
|
|
||||||
@ -60,13 +55,16 @@ export function ImageGenStream({
|
|||||||
style,
|
style,
|
||||||
}: ImageGenStreamProps) {
|
}: ImageGenStreamProps) {
|
||||||
const height = Math.round(width / Math.max(0.1, aspect));
|
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));
|
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 (
|
return (
|
||||||
<figure
|
<figure
|
||||||
@ -96,10 +94,10 @@ export function ImageGenStream({
|
|||||||
{status === 'idle' && (
|
{status === 'idle' && (
|
||||||
<Placeholder text="Ready to generate." />
|
<Placeholder text="Ready to generate." />
|
||||||
)}
|
)}
|
||||||
{(status === 'streaming' || status === 'complete') && (
|
{(status === 'streaming' || status === 'complete') &&
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
(visibleSrc ? (
|
||||||
<img
|
<img
|
||||||
src={status === 'complete' ? finalUrl ?? snapshot?.partialUrl : snapshot?.partialUrl}
|
src={visibleSrc}
|
||||||
alt={ariaLabel ?? 'Generated image'}
|
alt={ariaLabel ?? 'Generated image'}
|
||||||
data-testid="bl-image-gen-stream-img"
|
data-testid="bl-image-gen-stream-img"
|
||||||
style={{
|
style={{
|
||||||
@ -107,11 +105,12 @@ export function ImageGenStream({
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
objectFit: 'cover',
|
objectFit: 'cover',
|
||||||
filter: status === 'streaming' ? 'blur(4px)' : 'none',
|
filter: status === 'streaming' ? 'blur(4px)' : 'none',
|
||||||
opacity: revealedAt ? 1 : 0.95,
|
transition: 'filter 200ms ease',
|
||||||
transition: 'filter 200ms ease, opacity 200ms ease',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
) : (
|
||||||
|
<Placeholder text="Waiting for first frame…" />
|
||||||
|
))}
|
||||||
{status === 'streaming' && (
|
{status === 'streaming' && (
|
||||||
<div
|
<div
|
||||||
data-testid="bl-image-gen-stream-progress"
|
data-testid="bl-image-gen-stream-progress"
|
||||||
|
|||||||
@ -51,6 +51,13 @@ describe('AudioWaveform', () => {
|
|||||||
render(<AudioWaveform bars={16} width={120} height={40} />);
|
render(<AudioWaveform bars={16} width={120} height={40} />);
|
||||||
expect(screen.getByTestId('bl-audio-waveform')).toBeDefined();
|
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', () => {
|
describe('ImageGenStream', () => {
|
||||||
@ -82,6 +89,15 @@ describe('ImageGenStream', () => {
|
|||||||
const e = screen.getByTestId('bl-image-gen-stream-error');
|
const e = screen.getByTestId('bl-image-gen-stream-error');
|
||||||
expect(e.textContent).toMatch(/boom/);
|
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', () => {
|
describe('VideoPlayer', () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user