Media surface primitives — ImageGenStream, AudioWaveform, VideoPlayer.
Zero runtime deps (canvas + native <video> + native <img>); PdfPreview
deferred to 0.2.x where it'll lazy-load pdf.js.
──────────────────────────────────────────────────────────────────
<ImageGenStream> · Wave 13.G.1
──────────────────────────────────────────────────────────────────
- Driven entirely by props (status + snapshot + finalUrl)
- 4 statuses: idle / streaming / complete / error
- Streaming: blurred partial image + overlay progress bar +
step label (e.g. 'denoising 24/50')
- Complete: drops blur with fade-in
- Error: muted error placeholder (token-tinted danger)
- Host pipes any transport (SSE / WebSocket / polling) into
the snapshot prop — pure presentation
<AudioWaveform> · Wave 13.G.2
- Canvas render with DPR-aware paint
- Two sources: pre-computed peaks (cheapest) OR lazy WebAudio
decode from audioUrl (falls back gracefully w/o AudioContext)
- Click-to-seek (returns 0..1 position)
- Progress overlay tints played bars with progressColor
- Resampling helper handles arbitrary peak-count inputs
<VideoPlayer> · Wave 13.G.4
- Native <video controls> wrapper — accessible by default
- Chapter buttons that seek + auto-play (silently swallows
autoplay rejections)
- Optional in-memory caption track (aria-live polite); hosts
that prefer real WebVTT pass <track> via the slot prop
- Token-tinted; rounded; muted poster background
──────────────────────────────────────────────────────────────────
Quality gates
──────────────────────────────────────────────────────────────────
✓ 10/10 tests passing
- AudioWaveform: 3 cases (canvas size · click-seek math ·
placeholder when no peaks)
- ImageGenStream: 4 cases (idle / streaming / complete /
error)
- VideoPlayer: 3 cases (src · chapter button list · caption
rail)
✓ tsc build clean
✓ Zero new runtime deps; all WebAudio access guarded for SSR
Showcase /futurism/multimodal (MAG.7) lands in the paired showcase
commit.
181 lines
5.5 KiB
TypeScript
181 lines
5.5 KiB
TypeScript
import {
|
|
useEffect,
|
|
useId,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
type MouseEvent as ReactMouseEvent,
|
|
} from 'react';
|
|
|
|
export interface AudioWaveformProps {
|
|
/** Pre-computed peak samples (0..1). Mutually exclusive with `audioUrl`. */
|
|
peaks?: number[];
|
|
/** Audio file URL — when set + peaks absent, peaks are derived via WebAudio. */
|
|
audioUrl?: string;
|
|
/** Number of bars to render. Default 96. */
|
|
bars?: number;
|
|
/** Current playback position (0..1) — drives the progress overlay. */
|
|
progress?: number;
|
|
/** Called when the user clicks a bar (returns 0..1 position). */
|
|
onSeek?: (position: number) => void;
|
|
width?: number;
|
|
height?: number;
|
|
color?: string;
|
|
progressColor?: string;
|
|
ariaLabel?: string;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/**
|
|
* `<AudioWaveform>` — canvas-rendered waveform with click-to-seek.
|
|
*
|
|
* Wave 13.G.2. Accepts a pre-computed `peaks` array (cheapest) OR
|
|
* lazy-decodes peaks via WebAudio for an `audioUrl`. While the decode
|
|
* is in flight (or in environments without `AudioContext`) a flat
|
|
* placeholder is rendered.
|
|
*/
|
|
export function AudioWaveform({
|
|
peaks: peaksProp,
|
|
audioUrl,
|
|
bars = 96,
|
|
progress = 0,
|
|
onSeek,
|
|
width = 480,
|
|
height = 80,
|
|
color = 'var(--bl-accent, #6366f1)',
|
|
progressColor = 'var(--bl-text-primary, #111)',
|
|
ariaLabel,
|
|
className,
|
|
style,
|
|
}: AudioWaveformProps) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const titleId = useId();
|
|
const [decoded, setDecoded] = useState<number[] | null>(null);
|
|
|
|
// Lazy WebAudio decode when only audioUrl is supplied.
|
|
useEffect(() => {
|
|
if (peaksProp || !audioUrl) return;
|
|
if (typeof window === 'undefined') return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const res = await fetch(audioUrl);
|
|
const buf = await res.arrayBuffer();
|
|
const Ctx =
|
|
(
|
|
window as unknown as {
|
|
AudioContext?: typeof AudioContext;
|
|
webkitAudioContext?: typeof AudioContext;
|
|
}
|
|
).AudioContext ??
|
|
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
|
.webkitAudioContext;
|
|
if (!Ctx) return;
|
|
const ac = new Ctx();
|
|
const audio = await ac.decodeAudioData(buf);
|
|
if (cancelled) return;
|
|
const channel = audio.getChannelData(0);
|
|
const block = Math.max(1, Math.floor(channel.length / bars));
|
|
const out = new Array<number>(bars).fill(0);
|
|
for (let i = 0; i < bars; i++) {
|
|
const start = i * block;
|
|
let max = 0;
|
|
for (let j = 0; j < block && start + j < channel.length; j++) {
|
|
const v = Math.abs(channel[start + j] ?? 0);
|
|
if (v > max) max = v;
|
|
}
|
|
out[i] = Math.min(1, max);
|
|
}
|
|
if (!cancelled) setDecoded(out);
|
|
} catch {
|
|
/* swallow — placeholder remains */
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [peaksProp, audioUrl, bars]);
|
|
|
|
const finalPeaks = useMemo(() => {
|
|
if (peaksProp && peaksProp.length > 0) return resampleTo(peaksProp, bars);
|
|
if (decoded && decoded.length > 0) return decoded;
|
|
return new Array<number>(bars).fill(0.04);
|
|
}, [peaksProp, decoded, bars]);
|
|
|
|
// Paint.
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const dpr =
|
|
typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
|
|
canvas.width = Math.floor(width * dpr);
|
|
canvas.height = Math.floor(height * dpr);
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
ctx.scale(dpr, dpr);
|
|
ctx.clearRect(0, 0, width, height);
|
|
const slot = width / finalPeaks.length;
|
|
const barW = Math.max(1, slot * 0.65);
|
|
const mid = height / 2;
|
|
const progressX = Math.max(0, Math.min(1, progress)) * width;
|
|
finalPeaks.forEach((p, i) => {
|
|
const x = slot * i + (slot - barW) / 2;
|
|
const h = Math.max(2, p * height * 0.9);
|
|
ctx.fillStyle = x + barW <= progressX ? progressColor : color;
|
|
ctx.fillRect(x, mid - h / 2, barW, h);
|
|
});
|
|
}, [finalPeaks, width, height, color, progressColor, progress]);
|
|
|
|
const onClick = (e: ReactMouseEvent<HTMLCanvasElement>) => {
|
|
if (!onSeek) return;
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
onSeek(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
|
};
|
|
|
|
return (
|
|
<div
|
|
role="img"
|
|
aria-labelledby={titleId}
|
|
data-testid="bl-audio-waveform"
|
|
className={className}
|
|
style={{ display: 'inline-block', ...style }}
|
|
>
|
|
<span id={titleId} hidden>
|
|
{ariaLabel ?? 'Audio waveform'}
|
|
</span>
|
|
<canvas
|
|
ref={canvasRef}
|
|
onClick={onClick}
|
|
style={{
|
|
width,
|
|
height,
|
|
cursor: onSeek ? 'pointer' : 'default',
|
|
display: 'block',
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Resample any peaks array up/down to `bars` length. */
|
|
function resampleTo(src: number[], bars: number): number[] {
|
|
if (src.length === bars) return src;
|
|
if (src.length === 0) return new Array<number>(bars).fill(0);
|
|
const out = new Array<number>(bars);
|
|
const ratio = src.length / bars;
|
|
for (let i = 0; i < bars; i++) {
|
|
const start = Math.floor(i * ratio);
|
|
const end = Math.max(start + 1, Math.floor((i + 1) * ratio));
|
|
let max = 0;
|
|
for (let j = start; j < end && j < src.length; j++) {
|
|
const v = Math.abs(src[j] ?? 0);
|
|
if (v > max) max = v;
|
|
}
|
|
out[i] = Math.min(1, max);
|
|
}
|
|
return out;
|
|
}
|