diff --git a/packages/media-ui/src/AudioWaveform.tsx b/packages/media-ui/src/AudioWaveform.tsx index 08ee65f3..75266314 100644 --- a/packages/media-ui/src/AudioWaveform.tsx +++ b/packages/media-ui/src/AudioWaveform.tsx @@ -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(null); const titleId = useId(); const [decoded, setDecoded] = useState(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)); diff --git a/packages/media-ui/src/ImageGenStream.tsx b/packages/media-ui/src/ImageGenStream.tsx index 4e6131a9..76682eba 100644 --- a/packages/media-ui/src/ImageGenStream.tsx +++ b/packages/media-ui/src/ImageGenStream.tsx @@ -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(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 with `src={undefined}` (browser broken-image). + const visibleSrc = + status === 'complete' + ? finalUrl ?? snapshot?.partialUrl + : status === 'streaming' + ? snapshot?.partialUrl + : undefined; return (
)} - {(status === 'streaming' || status === 'complete') && ( - // eslint-disable-next-line @next/next/no-img-element - {ariaLabel - )} + {(status === 'streaming' || status === 'complete') && + (visibleSrc ? ( + {ariaLabel + ) : ( + + ))} {status === 'streaming' && (
{ render(); 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()).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 ', () => { + render(); + 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', () => {