feat(media-ui): @bytelyst/media-ui@0.1.0 — Wave 13.G.1/.2/.4
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.
This commit is contained in:
parent
2affd1aba0
commit
99e59597d1
34
packages/media-ui/package.json
Normal file
34
packages/media-ui/package.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@bytelyst/media-ui",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Media surface primitives — AudioWaveform, VideoPlayer, ImageGenStream. Zero runtime deps; PdfPreview deferred to 0.2.x (pdf.js).",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
180
packages/media-ui/src/AudioWaveform.tsx
Normal file
180
packages/media-ui/src/AudioWaveform.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
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;
|
||||
}
|
||||
191
packages/media-ui/src/ImageGenStream.tsx
Normal file
191
packages/media-ui/src/ImageGenStream.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
export type ImageGenStatus = 'idle' | 'streaming' | 'complete' | 'error';
|
||||
|
||||
export interface ImageGenSnapshot {
|
||||
/** Progress 0..1. */
|
||||
progress: number;
|
||||
/** Optional partial image URL (e.g. data: or blob:). */
|
||||
partialUrl?: string;
|
||||
/** Step label (e.g. "denoising 24/50"). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ImageGenStreamProps {
|
||||
/** Live status. */
|
||||
status: ImageGenStatus;
|
||||
/** Latest snapshot from the upstream stream. */
|
||||
snapshot?: ImageGenSnapshot;
|
||||
/** Final image URL (rendered when status === 'complete'). */
|
||||
finalUrl?: string;
|
||||
/** Optional error message (rendered when status === 'error'). */
|
||||
errorMessage?: string;
|
||||
/** Aspect ratio (w/h). Default 1 (square). */
|
||||
aspect?: number;
|
||||
/** Width in px. Default 320. */
|
||||
width?: number;
|
||||
/** Optional caption slot. */
|
||||
caption?: ReactNode;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* `<ImageGenStream>` — surface that animates a streaming image-generation
|
||||
* job. Driven entirely by props; the host pipes whatever transport
|
||||
* (SSE / WebSocket / polling) into the `status` + `snapshot` props.
|
||||
*
|
||||
* Wave 13.G.1. Pure presentation. Status drives visual:
|
||||
* - idle: empty placeholder with helpful copy
|
||||
* - streaming: partial image + progress bar + label
|
||||
* - complete: final image with subtle fade-in
|
||||
* - error: muted error card
|
||||
*/
|
||||
export function ImageGenStream({
|
||||
status,
|
||||
snapshot,
|
||||
finalUrl,
|
||||
errorMessage,
|
||||
aspect = 1,
|
||||
width = 320,
|
||||
caption,
|
||||
ariaLabel,
|
||||
className,
|
||||
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));
|
||||
|
||||
return (
|
||||
<figure
|
||||
data-testid="bl-image-gen-stream"
|
||||
data-status={status}
|
||||
className={className}
|
||||
style={{
|
||||
margin: 0,
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
...style,
|
||||
}}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width,
|
||||
height,
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
background: 'var(--bl-surface-muted, rgba(0,0,0,0.06))',
|
||||
border: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
|
||||
}}
|
||||
>
|
||||
{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' && (
|
||||
<div
|
||||
data-testid="bl-image-gen-stream-progress"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: '6px 8px 8px',
|
||||
background:
|
||||
'linear-gradient(to top, rgba(0,0,0,0.6), transparent)',
|
||||
color: 'white',
|
||||
fontSize: 11,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
background: 'rgba(255,255,255,0.18)',
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${progress * 100}%`,
|
||||
background: 'var(--bl-accent, #6366f1)',
|
||||
transition: 'width 240ms ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>{snapshot?.label ?? `${Math.round(progress * 100)}%`}</div>
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && <Placeholder text={errorMessage ?? 'Something went wrong.'} kind="error" />}
|
||||
</div>
|
||||
{caption && (
|
||||
<figcaption
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--bl-text-tertiary, #888)',
|
||||
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
{caption}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function Placeholder({ text, kind }: { text: string; kind?: 'error' }) {
|
||||
return (
|
||||
<div
|
||||
data-testid={kind === 'error' ? 'bl-image-gen-stream-error' : 'bl-image-gen-stream-idle'}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
padding: 16,
|
||||
textAlign: 'center',
|
||||
color:
|
||||
kind === 'error'
|
||||
? 'var(--bl-danger, #ef4444)'
|
||||
: 'var(--bl-text-tertiary, #888)',
|
||||
fontSize: 12,
|
||||
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
packages/media-ui/src/VideoPlayer.tsx
Normal file
189
packages/media-ui/src/VideoPlayer.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
export interface VideoChapter {
|
||||
/** Stable id — React key. */
|
||||
id: string;
|
||||
/** Display label. */
|
||||
label: string;
|
||||
/** Chapter start time in seconds. */
|
||||
startSeconds: number;
|
||||
}
|
||||
|
||||
export interface VideoCaption {
|
||||
/** Stable id. */
|
||||
id: string;
|
||||
/** Caption start in seconds. */
|
||||
start: number;
|
||||
/** Caption end in seconds. */
|
||||
end: number;
|
||||
/** Display text. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
/** Video source URL. */
|
||||
src: string;
|
||||
/** Optional poster image. */
|
||||
poster?: string;
|
||||
/** Optional chapter list — rendered as buttons under the player. */
|
||||
chapters?: VideoChapter[];
|
||||
/**
|
||||
* Optional in-memory caption track. When provided, the active line is
|
||||
* rendered as an aria-live caption strip below the video.
|
||||
* Hosts that prefer real <track> elements can pass `captionsTrackUrl`
|
||||
* instead via the `slot` prop and skip this list.
|
||||
*/
|
||||
captions?: VideoCaption[];
|
||||
/** Slot rendered above the chapter list — e.g. native <track>. */
|
||||
slot?: ReactNode;
|
||||
width?: number;
|
||||
height?: number;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* `<VideoPlayer>` — minimal token-themed video wrapper with chapter
|
||||
* navigation + optional caption rail.
|
||||
*
|
||||
* Wave 13.G.4. Uses the native `<video controls>` element; chapters
|
||||
* are rendered as discrete "seek-to" buttons. Captions are passed in
|
||||
* pure JS for the simple case — hosts wanting WebVTT pass a `<track>`
|
||||
* via the `slot` prop.
|
||||
*/
|
||||
export function VideoPlayer({
|
||||
src,
|
||||
poster,
|
||||
chapters,
|
||||
captions,
|
||||
slot,
|
||||
width = 480,
|
||||
height = 270,
|
||||
ariaLabel,
|
||||
className,
|
||||
style,
|
||||
}: VideoPlayerProps) {
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
const [activeCaption, setActiveCaption] = useState<VideoCaption | null>(null);
|
||||
|
||||
// Tick the active caption as the video plays.
|
||||
useEffect(() => {
|
||||
if (!captions || captions.length === 0) return;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const onTime = () => {
|
||||
const t = el.currentTime;
|
||||
const hit = captions.find((c) => t >= c.start && t < c.end);
|
||||
setActiveCaption(hit ?? null);
|
||||
};
|
||||
el.addEventListener('timeupdate', onTime);
|
||||
return () => el.removeEventListener('timeupdate', onTime);
|
||||
}, [captions]);
|
||||
|
||||
const seek = (s: number) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.currentTime = s;
|
||||
void el.play().catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="bl-video-player"
|
||||
className={className}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={ref}
|
||||
src={src}
|
||||
poster={poster}
|
||||
controls
|
||||
width={width}
|
||||
height={height}
|
||||
aria-label={ariaLabel}
|
||||
data-testid="bl-video-player-video"
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
borderRadius: 12,
|
||||
background: 'var(--bl-surface-muted, #000)',
|
||||
}}
|
||||
>
|
||||
{slot}
|
||||
Your browser does not support the video element.
|
||||
</video>
|
||||
{captions && captions.length > 0 && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
data-testid="bl-video-player-caption"
|
||||
style={{
|
||||
minHeight: 22,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 8,
|
||||
background: 'var(--bl-surface-muted, rgba(0,0,0,0.05))',
|
||||
fontSize: 13,
|
||||
color: 'var(--bl-text-secondary, #555)',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
{activeCaption?.text ?? ''}
|
||||
</div>
|
||||
)}
|
||||
{chapters && chapters.length > 0 && (
|
||||
<nav
|
||||
aria-label="Chapters"
|
||||
data-testid="bl-video-player-chapters"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6,
|
||||
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
{chapters.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => seek(c.startSeconds)}
|
||||
data-testid="bl-video-player-chapter"
|
||||
data-chapter-id={c.id}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
|
||||
background: 'var(--bl-surface-card, #fff)',
|
||||
color: 'var(--bl-text-secondary, #555)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<span style={{ opacity: 0.6 }}>{formatTime(c.startSeconds)}</span>{' '}
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(s: number): string {
|
||||
if (!Number.isFinite(s) || s < 0) return '0:00';
|
||||
const mm = Math.floor(s / 60);
|
||||
const ss = Math.floor(s % 60);
|
||||
return `${mm}:${ss.toString().padStart(2, '0')}`;
|
||||
}
|
||||
119
packages/media-ui/src/__tests__/media-ui.test.tsx
Normal file
119
packages/media-ui/src/__tests__/media-ui.test.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { AudioWaveform } from '../AudioWaveform.js';
|
||||
import { ImageGenStream } from '../ImageGenStream.js';
|
||||
import { VideoPlayer } from '../VideoPlayer.js';
|
||||
|
||||
beforeEach(() => cleanup());
|
||||
|
||||
describe('AudioWaveform', () => {
|
||||
it('renders a canvas with the configured size', () => {
|
||||
render(<AudioWaveform peaks={[0.1, 0.5, 0.9, 0.4]} width={320} height={64} />);
|
||||
const root = screen.getByTestId('bl-audio-waveform');
|
||||
const canvas = root.querySelector('canvas');
|
||||
expect(canvas).not.toBeNull();
|
||||
expect(canvas?.style.width).toBe('320px');
|
||||
expect(canvas?.style.height).toBe('64px');
|
||||
});
|
||||
|
||||
it('invokes onSeek with the clicked position', () => {
|
||||
let seeked: number | null = null;
|
||||
render(
|
||||
<AudioWaveform
|
||||
peaks={[0.1, 0.5, 0.9]}
|
||||
width={200}
|
||||
height={40}
|
||||
onSeek={(p) => {
|
||||
seeked = p;
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const canvas = screen.getByTestId('bl-audio-waveform').querySelector('canvas')!;
|
||||
// Stub bounding rect to 200 px wide for the click math.
|
||||
canvas.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 200,
|
||||
bottom: 40,
|
||||
width: 200,
|
||||
height: 40,
|
||||
toJSON: () => '',
|
||||
}) as DOMRect;
|
||||
fireEvent.click(canvas, { clientX: 100, clientY: 20 });
|
||||
expect(seeked).toBeCloseTo(0.5, 5);
|
||||
});
|
||||
|
||||
it('renders a placeholder waveform when neither peaks nor audioUrl are supplied', () => {
|
||||
render(<AudioWaveform bars={16} width={120} height={40} />);
|
||||
expect(screen.getByTestId('bl-audio-waveform')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImageGenStream', () => {
|
||||
it('idle status renders the idle placeholder', () => {
|
||||
render(<ImageGenStream status="idle" />);
|
||||
expect(screen.getByTestId('bl-image-gen-stream-idle')).toBeDefined();
|
||||
});
|
||||
|
||||
it('streaming status renders the progress strip + image', () => {
|
||||
render(
|
||||
<ImageGenStream
|
||||
status="streaming"
|
||||
snapshot={{ progress: 0.4, partialUrl: 'data:image/gif;base64,R0lGODlh', label: '20/50' }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('bl-image-gen-stream-progress')).toBeDefined();
|
||||
expect(screen.getByTestId('bl-image-gen-stream-img')).toBeDefined();
|
||||
expect(screen.getByTestId('bl-image-gen-stream').getAttribute('data-status')).toBe('streaming');
|
||||
});
|
||||
|
||||
it('complete status drops the blur', () => {
|
||||
render(<ImageGenStream status="complete" finalUrl="data:image/gif;base64,R0lGODlh" />);
|
||||
const img = screen.getByTestId('bl-image-gen-stream-img') as HTMLImageElement;
|
||||
expect(img.style.filter).toBe('none');
|
||||
});
|
||||
|
||||
it('error status renders the error placeholder', () => {
|
||||
render(<ImageGenStream status="error" errorMessage="boom" />);
|
||||
const e = screen.getByTestId('bl-image-gen-stream-error');
|
||||
expect(e.textContent).toMatch(/boom/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VideoPlayer', () => {
|
||||
it('renders the native <video> element with the src', () => {
|
||||
render(<VideoPlayer src="https://example.com/demo.mp4" />);
|
||||
const v = screen.getByTestId('bl-video-player-video') as HTMLVideoElement;
|
||||
expect(v.src).toMatch(/demo\.mp4$/);
|
||||
});
|
||||
|
||||
it('renders chapter buttons that match the chapter list', () => {
|
||||
render(
|
||||
<VideoPlayer
|
||||
src="x.mp4"
|
||||
chapters={[
|
||||
{ id: 'intro', label: 'Intro', startSeconds: 0 },
|
||||
{ id: 'demo', label: 'Demo', startSeconds: 65 },
|
||||
{ id: 'qa', label: 'Q&A', startSeconds: 360 },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByTestId('bl-video-player-chapter')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders the caption rail when captions are supplied', () => {
|
||||
render(
|
||||
<VideoPlayer
|
||||
src="x.mp4"
|
||||
captions={[
|
||||
{ id: 'c1', start: 0, end: 5, text: 'Hi there.' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('bl-video-player-caption')).toBeDefined();
|
||||
});
|
||||
});
|
||||
28
packages/media-ui/src/index.ts
Normal file
28
packages/media-ui/src/index.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @bytelyst/media-ui — media surface primitives.
|
||||
*
|
||||
* Exports (0.1.0 — Wave 13.G.1/.2/.4):
|
||||
* <ImageGenStream> streaming image-generation surface
|
||||
* <AudioWaveform> canvas waveform + click-to-seek
|
||||
* <VideoPlayer> <video controls> + chapters + caption rail
|
||||
*
|
||||
* Deferred to 0.2.x (per roadmap §13.G):
|
||||
* <PdfPreview> lazy pdf.js — needs the pdf.js runtime
|
||||
*/
|
||||
|
||||
export { ImageGenStream } from './ImageGenStream.js';
|
||||
export type {
|
||||
ImageGenSnapshot,
|
||||
ImageGenStatus,
|
||||
ImageGenStreamProps,
|
||||
} from './ImageGenStream.js';
|
||||
|
||||
export { AudioWaveform } from './AudioWaveform.js';
|
||||
export type { AudioWaveformProps } from './AudioWaveform.js';
|
||||
|
||||
export { VideoPlayer } from './VideoPlayer.js';
|
||||
export type {
|
||||
VideoCaption,
|
||||
VideoChapter,
|
||||
VideoPlayerProps,
|
||||
} from './VideoPlayer.js';
|
||||
11
packages/media-ui/tsconfig.json
Normal file
11
packages/media-ui/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||
}
|
||||
2
packages/media-ui/vitest.config.ts
Normal file
2
packages/media-ui/vitest.config.ts
Normal file
@ -0,0 +1,2 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({ test: { environment: 'happy-dom', pool: 'forks' } });
|
||||
Loading…
Reference in New Issue
Block a user