refactor(studio): improve Timeline, PlayerControls, and player hook (#63)

## Summary
- **Timeline**: Refactored track rendering with zoom support, drag/resize interactions, and playhead scrubbing
- **PlayerControls**: Redesigned with seek bar, time display, playback rate selector
- **useTimelinePlayer**: Enhanced with iframe bridge communication, timeline message parsing, and deterministic seek
- Add Timeline unit tests (109 lines)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Miguel Ángel
2026-03-28 20:39:35 +01:00
committed by GitHub
parent 625b5131bd
commit 0769678f46
16 changed files with 1790 additions and 343 deletions
@@ -0,0 +1,175 @@
/**
* CompositionThumbnail — Film-strip of server-rendered JPEG thumbnails.
*
* Requests multiple thumbnails at different timestamps across the clip duration
* and tiles them horizontally — like VideoThumbnail does for video clips.
* Each frame is a separate <img> from /api/projects/:id/thumbnail/:path?t=X.
*
* Lazy-loaded via IntersectionObserver. Uses ResizeObserver to adapt frame count
* when the clip width changes (zoom).
*/
import { memo, useRef, useState, useCallback, useEffect } from "react";
const CLIP_HEIGHT = 66;
const MAX_UNIQUE_FRAMES = 6;
interface CompositionThumbnailProps {
previewUrl: string;
label: string;
labelColor: string;
seekTime?: number;
duration?: number;
width?: number;
height?: number;
}
export const CompositionThumbnail = memo(function CompositionThumbnail({
previewUrl,
label,
labelColor,
seekTime = 0.4,
duration = 5,
width = 1920,
height = 1080,
}: CompositionThumbnailProps) {
const [visible, setVisible] = useState(false);
const [containerWidth, setContainerWidth] = useState(0);
const [loadedFrames, setLoadedFrames] = useState<Set<number>>(new Set());
const ioRef = useRef<IntersectionObserver | null>(null);
const roRef = useRef<ResizeObserver | null>(null);
const setRef = useCallback((el: HTMLDivElement | null) => {
ioRef.current?.disconnect();
roRef.current?.disconnect();
if (!el) return;
// Walk up to data-clip parent for accurate width (max 5 levels to avoid overshoot)
let target: HTMLElement = el;
let parent = el.parentElement;
let depth = 0;
while (parent && !parent.hasAttribute("data-clip") && depth < 5) {
parent = parent.parentElement;
depth++;
}
if (parent?.hasAttribute("data-clip")) target = parent;
requestAnimationFrame(() => {
const w = target.clientWidth || target.getBoundingClientRect().width;
if (w > 0) setContainerWidth(w);
});
ioRef.current = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
ioRef.current?.disconnect();
requestAnimationFrame(() => {
const w = target.clientWidth || target.getBoundingClientRect().width;
if (w > 0) setContainerWidth(w);
});
}
},
{ rootMargin: "300px" },
);
ioRef.current.observe(el);
roRef.current = new ResizeObserver(([entry]) => setContainerWidth(entry.contentRect.width));
roRef.current.observe(target);
}, []);
useEffect(
() => () => {
ioRef.current?.disconnect();
roRef.current?.disconnect();
},
[],
);
// Convert preview URL to thumbnail base URL
const thumbnailBase = previewUrl
.replace("/preview/comp/", "/thumbnail/")
.replace(/\/preview$/, "/thumbnail/index.html");
// Calculate frame layout
const aspect = width / height;
const frameW = Math.round(CLIP_HEIGHT * aspect);
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
const uniqueFrames = Math.min(frameCount, MAX_UNIQUE_FRAMES);
// Generate timestamps spread across the clip duration.
// Start at 30% into the scene to skip entrance animations (opacity:0 → 1).
// End at 90% to avoid catching exit animations.
const timestamps: number[] = [];
const startOffset = duration * 0.3;
const endOffset = duration * 0.9;
const range = endOffset - startOffset;
for (let i = 0; i < uniqueFrames; i++) {
const frac = uniqueFrames === 1 ? 0 : i / (uniqueFrames - 1);
timestamps.push(seekTime + startOffset + frac * range);
}
const hasAnyFrame = loadedFrames.size > 0;
return (
<div ref={setRef} className="absolute inset-0 overflow-hidden bg-neutral-950">
{/* Film strip */}
{visible && (
<div className="absolute inset-0 flex">
{Array.from({ length: frameCount }).map((_, i) => {
const uniqueIdx = i % uniqueFrames;
const t = timestamps[uniqueIdx];
const url = `${thumbnailBase}?t=${t.toFixed(2)}`;
return (
<div
key={i}
className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
style={{ width: frameW }}
>
<img
src={url}
alt=""
draggable={false}
loading="lazy"
onLoad={() => setLoadedFrames((prev) => new Set(prev).add(uniqueIdx))}
className="absolute inset-0 w-full h-full object-cover"
style={{
opacity: loadedFrames.has(uniqueIdx) ? 1 : 0,
transition: "opacity 200ms ease-out",
}}
/>
</div>
);
})}
</div>
)}
{/* Shimmer while loading */}
{(!visible || !hasAnyFrame) && (
<div
className="absolute inset-0 animate-pulse"
style={{
background:
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
}}
/>
)}
{/* Label */}
<div
className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
style={{
background:
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
}}
>
<span
className="text-[9px] font-semibold truncate block leading-tight"
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
>
{label}
</span>
</div>
</div>
);
});
@@ -39,7 +39,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
const handleMessage = (e: MessageEvent) => {
const data = e.data;
if (
(data?.source === "hf-preview" || data?.source === "hf-preview") &&
data?.source === "hf-preview" &&
data?.type === "stage-size" &&
data.width > 0 &&
data.height > 0
@@ -83,8 +83,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
}
}
}
} catch {
// Cross-origin
} catch (err) {
console.warn("[Player] Could not read iframe dimensions (cross-origin)", err);
}
if (loadCountRef.current > 1) {
@@ -103,7 +103,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
return (
<div
ref={containerRef}
className="w-full h-full max-w-full max-h-full overflow-hidden shadow-float border border-neutral-800 bg-black flex items-center justify-center rounded-card-inner"
className="w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center"
>
<iframe
ref={ref}
@@ -117,6 +117,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
width: dims.w,
height: dims.h,
border: "none",
outline: "1px solid black",
transform: `scale(${scale})`,
transformOrigin: "center center",
flexShrink: 0,
@@ -1,17 +1,10 @@
import { useRef, useState, useCallback, memo } from "react";
import { useRef, useState, useCallback, useEffect, memo } from "react";
import { formatTime } from "../lib/time";
import { usePlayerStore, liveTime } from "../store/playerStore";
import { useMountEffect } from "../lib/useMountEffect";
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
interface PlayerControlsProps {
/** @deprecated Pass via store — kept for backwards compat */
isPlaying?: boolean;
/** @deprecated Pass via store — kept for backwards compat */
duration?: number;
/** @deprecated Pass via store — kept for backwards compat */
timelineReady?: boolean;
onTogglePlay: () => void;
onSeek: (time: number) => void;
}
@@ -19,20 +12,15 @@ interface PlayerControlsProps {
export const PlayerControls = memo(function PlayerControls({
onTogglePlay,
onSeek,
...overrides
}: PlayerControlsProps) {
// Subscribe to only the fields we render — each selector prevents cascading re-renders
const storeIsPlaying = usePlayerStore((s) => s.isPlaying);
const storeDuration = usePlayerStore((s) => s.duration);
const storeTimelineReady = usePlayerStore((s) => s.timelineReady);
const isPlaying = usePlayerStore((s) => s.isPlaying);
const duration = usePlayerStore((s) => s.duration);
const timelineReady = usePlayerStore((s) => s.timelineReady);
const playbackRate = usePlayerStore((s) => s.playbackRate);
const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
const isPlaying = overrides.isPlaying ?? storeIsPlaying;
const duration = overrides.duration ?? storeDuration;
const timelineReady = overrides.timelineReady ?? storeTimelineReady;
const progressFillRef = useRef<HTMLDivElement>(null);
const progressThumbRef = useRef<HTMLDivElement>(null);
const timeDisplayRef = useRef<HTMLSpanElement>(null);
@@ -42,17 +30,34 @@ export const PlayerControls = memo(function PlayerControls({
const durationRef = useRef(duration);
durationRef.current = duration;
useMountEffect(() => {
const unsub = liveTime.subscribe((t) => {
useEffect(() => {
const updateProgress = (t: number) => {
currentTimeRef.current = t;
const dur = durationRef.current;
const pct = dur > 0 ? (t / dur) * 100 : 0;
const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0;
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t);
});
return unsub;
});
};
const unsub = liveTime.subscribe(updateProgress);
updateProgress(usePlayerStore.getState().currentTime);
// Also poll every 500ms as a fallback in case liveTime doesn't fire
const interval = setInterval(() => {
const t = usePlayerStore.getState().currentTime;
const dur = usePlayerStore.getState().duration;
if (dur > 0 && t > 0) {
const pct = Math.min(100, (t / dur) * 100);
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
}
}, 500);
return () => {
unsub();
clearInterval(interval);
};
}, []);
const seekFromClientX = useCallback(
(clientX: number) => {
@@ -60,6 +65,10 @@ export const PlayerControls = memo(function PlayerControls({
if (!bar || duration <= 0) return;
const rect = bar.getBoundingClientRect();
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
// Immediately update progress bar visuals (don't wait for liveTime round-trip)
const pct = percent * 100;
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
onSeek(percent * duration);
},
[duration, onSeek],
@@ -102,32 +111,42 @@ export const PlayerControls = memo(function PlayerControls({
);
return (
<div className="px-3 py-2 flex items-center gap-3">
<div
className="px-4 py-2 flex items-center gap-3"
style={{ borderTop: "1px solid rgba(255,255,255,0.04)" }}
>
{/* Play/Pause button */}
<button
type="button"
aria-label={isPlaying ? "Pause" : "Play"}
onClick={onTogglePlay}
disabled={!timelineReady}
className="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-md text-neutral-300 hover:text-white hover:bg-neutral-800 disabled:opacity-40 disabled:pointer-events-none transition-colors"
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
style={{ background: "rgba(255,255,255,0.06)" }}
>
{isPlaying ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M8 5v14l11-7z" />
<svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
<polygon points="6,3 20,12 6,21" />
</svg>
)}
</button>
<span className="text-neutral-500 font-mono text-xs tabular-nums flex-shrink-0 min-w-[80px]">
{/* Time display */}
<span
className="font-mono text-[11px] tabular-nums flex-shrink-0 min-w-[72px]"
style={{ color: "#A1A1AA" }}
>
<span ref={timeDisplayRef}>{formatTime(0)}</span>
<span className="text-neutral-700 mx-0.5">/</span>
<span className="text-neutral-600">{formatTime(duration)}</span>
<span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
</span>
{/* Seek bar — teal progress fill */}
<div
ref={seekBarRef}
role="slider"
@@ -141,16 +160,24 @@ export const PlayerControls = memo(function PlayerControls({
onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown}
>
<div className="w-full h-[3px] bg-neutral-800 rounded-full relative">
<div
className="w-full rounded-full relative"
style={{ background: "rgba(255,255,255,0.15)", height: "3px" }}
>
{/* Progress fill — width is controlled imperatively via ref to avoid React re-render resets */}
<div
ref={progressFillRef}
className="absolute inset-y-0 left-0 bg-white/80 rounded-full"
style={{ width: 0 }}
className="absolute top-0 bottom-0 left-0 z-[1] rounded-full"
style={{ background: "linear-gradient(90deg, var(--hf-accent, #3CE6AC), #2BBFA0)" }}
/>
{/* Playhead thumb — left is controlled imperatively via ref */}
<div
ref={progressThumbRef}
className="absolute top-1/2 w-2 h-2 bg-white rounded-full -translate-y-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity"
style={{ left: 0 }}
className="absolute top-1/2 z-[2] w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 transition-transform group-hover:scale-125"
style={{
background: "var(--hf-accent, #3CE6AC)",
boxShadow: "0 0 6px rgba(60,230,172,0.4), 0 1px 4px rgba(0,0,0,0.4)",
}}
/>
</div>
</div>
@@ -160,12 +187,16 @@ export const PlayerControls = memo(function PlayerControls({
<button
type="button"
onClick={() => setShowSpeedMenu((v) => !v)}
className="px-1.5 py-0.5 rounded text-[11px] font-mono tabular-nums text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
className="px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
>
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
</button>
{showSpeedMenu && (
<div className="absolute bottom-full right-0 mb-1 py-1 bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl z-50 min-w-[60px]">
<div
className="absolute bottom-full right-0 mb-1.5 rounded-lg shadow-xl z-50 min-w-[56px] overflow-hidden"
style={{ background: "#161618", border: "1px solid rgba(255,255,255,0.08)" }}
>
{SPEED_OPTIONS.map((rate) => (
<button
key={rate}
@@ -173,11 +204,18 @@ export const PlayerControls = memo(function PlayerControls({
setPlaybackRate(rate);
setShowSpeedMenu(false);
}}
className={`block w-full px-3 py-1 text-xs text-left font-mono tabular-nums transition-colors ${
rate === playbackRate
? "text-white bg-neutral-800"
: "text-neutral-400 hover:text-white hover:bg-neutral-800"
}`}
className="block w-full px-3 py-1.5 text-[11px] text-left font-mono tabular-nums transition-colors"
style={{
color: rate === playbackRate ? "#FAFAFA" : "#71717A",
background: rate === playbackRate ? "rgba(255,255,255,0.06)" : "transparent",
}}
onMouseEnter={(e) => {
if (rate !== playbackRate)
e.currentTarget.style.background = "rgba(255,255,255,0.04)";
}}
onMouseLeave={(e) => {
if (rate !== playbackRate) e.currentTarget.style.background = "transparent";
}}
>
{rate}x
</button>
@@ -0,0 +1,109 @@
import { describe, it, expect } from "vitest";
import { generateTicks, formatTick } from "./Timeline";
describe("generateTicks", () => {
it("returns empty arrays for duration <= 0", () => {
expect(generateTicks(0)).toEqual({ major: [], minor: [] });
expect(generateTicks(-5)).toEqual({ major: [], minor: [] });
});
it("generates ticks for a short duration (3 seconds)", () => {
const { major } = generateTicks(3);
expect(major.length).toBeGreaterThan(0);
expect(major[0]).toBe(0);
expect(major).toContain(0);
expect(major).toContain(1);
expect(major).toContain(2);
expect(major).toContain(3);
});
it("generates ticks for a medium duration (10 seconds)", () => {
const { major, minor } = generateTicks(10);
expect(major).toContain(0);
expect(major).toContain(2);
expect(major).toContain(4);
expect(major).toContain(6);
expect(major).toContain(8);
expect(major).toContain(10);
expect(minor).toContain(1);
expect(minor).toContain(3);
expect(minor).toContain(5);
});
it("generates ticks for a long duration (120 seconds)", () => {
const { major, minor } = generateTicks(120);
expect(major).toContain(0);
expect(major).toContain(30);
expect(major).toContain(60);
expect(major).toContain(90);
expect(major).toContain(120);
expect(minor).toContain(15);
expect(minor).toContain(45);
});
it("generates ticks for a very long duration (500 seconds)", () => {
const { major } = generateTicks(500);
expect(major).toContain(0);
expect(major).toContain(60);
expect(major).toContain(120);
});
it("major and minor ticks do not overlap", () => {
const { major, minor } = generateTicks(30);
for (const t of minor) {
expect(major).not.toContain(t);
}
});
it("all tick values are non-negative", () => {
const { major, minor } = generateTicks(60);
for (const t of [...major, ...minor]) {
expect(t).toBeGreaterThanOrEqual(0);
}
});
it("major ticks always start at 0", () => {
for (const d of [1, 5, 10, 30, 60, 120, 300]) {
const { major } = generateTicks(d);
expect(major[0]).toBe(0);
}
});
});
describe("formatTick", () => {
it("formats 0 seconds as 0:00", () => {
expect(formatTick(0)).toBe("0:00");
});
it("formats seconds below a minute", () => {
expect(formatTick(5)).toBe("0:05");
expect(formatTick(30)).toBe("0:30");
expect(formatTick(59)).toBe("0:59");
});
it("formats exactly one minute", () => {
expect(formatTick(60)).toBe("1:00");
});
it("formats minutes and seconds", () => {
expect(formatTick(90)).toBe("1:30");
expect(formatTick(125)).toBe("2:05");
});
it("floors fractional seconds", () => {
expect(formatTick(5.7)).toBe("0:05");
expect(formatTick(59.9)).toBe("0:59");
expect(formatTick(90.5)).toBe("1:30");
});
it("handles large values", () => {
expect(formatTick(600)).toBe("10:00");
expect(formatTick(3661)).toBe("61:01");
});
it("zero-pads seconds to two digits", () => {
expect(formatTick(1)).toBe("0:01");
expect(formatTick(9)).toBe("0:09");
expect(formatTick(61)).toBe("1:01");
});
});
+424 -200
View File
@@ -1,12 +1,13 @@
import { useRef, useMemo, useCallback, useState, memo, type ReactNode } from "react";
import { useRef, useMemo, useCallback, useState, memo, type ReactNode, useEffect } from "react";
import { usePlayerStore, liveTime } from "../store/playerStore";
import { useMountEffect } from "../lib/useMountEffect";
import { TimelineClip } from "./TimelineClip";
/* ── Layout ─────────────────────────────────────────────────────── */
const GUTTER = 32;
const TRACK_H = 28;
const TRACK_H = 72;
const RULER_H = 24;
const CLIP_Y = 2; // vertical inset inside track
const CLIP_Y = 3; // vertical inset inside track
/* ── Vibrant Color System (Figma-inspired, dark-mode adapted) ──── */
interface TrackStyle {
@@ -22,7 +23,7 @@ interface TrackStyle {
icon: ReactNode;
}
/* ── Icons from Figma HyperFrames design system ── */
/* ── Icons from Figma Motion Cut design system ── */
const ICON_BASE = "/icons/timeline";
function TimelineIcon({ src }: { src: string }) {
return (
@@ -124,15 +125,21 @@ function getStyle(tag: string): TrackStyle {
}
/* ── Tick Generation ────────────────────────────────────────────── */
function generateTicks(duration: number): { major: number[]; minor: number[] } {
if (duration <= 0) return { major: [], minor: [] };
export function generateTicks(duration: number): { major: number[]; minor: number[] } {
if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
return { major: [], minor: [] };
const intervals = [0.5, 1, 2, 5, 10, 15, 30, 60];
const target = duration / 6;
const majorInterval = intervals.find((i) => i >= target) ?? 60;
const minorInterval = majorInterval / 2;
const minorInterval = Math.max(0.25, majorInterval / 2);
const major: number[] = [];
const minor: number[] = [];
for (let t = 0; t <= duration + 0.001; t += minorInterval) {
const maxTicks = 500; // Safety cap to prevent infinite loop
for (
let t = 0;
t <= duration + 0.001 && major.length + minor.length < maxTicks;
t += minorInterval
) {
const rounded = Math.round(t * 100) / 100;
const isMajor =
Math.abs(rounded % majorInterval) < 0.01 ||
@@ -143,7 +150,7 @@ function generateTicks(duration: number): { major: number[]; minor: number[] } {
return { major, minor };
}
function formatTick(s: number): string {
export function formatTick(s: number): string {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, "0")}`;
@@ -155,52 +162,172 @@ interface TimelineProps {
onSeek?: (time: number) => void;
/** Called when user double-clicks a composition clip to drill into it */
onDrillDown?: (element: import("../store/playerStore").TimelineElement) => void;
/** Optional custom content renderer for clips (thumbnails, waveforms, etc.) */
renderClipContent?: (
element: import("../store/playerStore").TimelineElement,
style: { clip: string; label: string },
) => ReactNode;
/** Optional overlay renderer for clips (e.g. badges, cursors) */
renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
/** Called when files are dropped onto the empty timeline */
onFileDrop?: (files: File[]) => void;
/** Called when a clip is moved, resized, or changes track via drag */
onClipChange?: (
elementId: string,
updates: { start?: number; duration?: number; track?: number },
) => void;
}
export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: TimelineProps = {}) {
export const Timeline = memo(function Timeline({
onSeek,
onDrillDown,
renderClipContent,
renderClipOverlay,
onFileDrop,
}: TimelineProps = {}) {
const elements = usePlayerStore((s) => s.elements);
const duration = usePlayerStore((s) => s.duration);
const timelineReady = usePlayerStore((s) => s.timelineReady);
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
const zoomMode = usePlayerStore((s) => s.zoomMode);
const manualPps = usePlayerStore((s) => s.pixelsPerSecond);
const playheadRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
const isDragging = useRef(false);
const [viewportWidth, setViewportWidth] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);
const durationRef = useRef(duration);
durationRef.current = duration;
// Callback ref: sets up ResizeObserver when the DOM element actually mounts.
// useMountEffect can't work here because the component returns null on first
// render (timelineReady=false), so containerRef.current is null when the
// effect fires and the ResizeObserver is never created.
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
containerRef.current = el;
if (!el) return;
setViewportWidth(el.clientWidth);
roRef.current = new ResizeObserver(([entry]) => {
setViewportWidth(entry.contentRect.width);
});
roRef.current.observe(el);
}, []);
// Clean up ResizeObserver on unmount
useEffect(
() => () => {
roRef.current?.disconnect();
},
[],
);
// Effective duration: max of store duration and the furthest element end.
// processTimelineMessage updates elements but not duration, so elements can
// extend beyond the store's duration — this ensures fit mode shows everything.
const effectiveDuration = useMemo(() => {
const safeDur = Number.isFinite(duration) ? duration : 0;
if (elements.length === 0) return safeDur;
const maxEnd = Math.max(...elements.map((el) => el.start + el.duration));
const result = Math.max(safeDur, maxEnd);
return Number.isFinite(result) ? result : safeDur;
}, [elements, duration]);
// Calculate effective pixels per second
// In fit mode, use clientWidth (excludes scrollbar) with a small padding
const fitPps =
viewportWidth > GUTTER && effectiveDuration > 0
? (viewportWidth - GUTTER - 2) / effectiveDuration
: 100;
const pps = zoomMode === "fit" ? fitPps : manualPps;
const trackContentWidth = Math.max(0, effectiveDuration * pps);
const durationRef = useRef(effectiveDuration);
durationRef.current = effectiveDuration;
const ppsRef = useRef(pps);
ppsRef.current = pps;
useMountEffect(() => {
const unsub = liveTime.subscribe((t) => {
const dur = durationRef.current;
if (!playheadRef.current || dur <= 0) return;
const pct = (t / dur) * 100;
playheadRef.current.style.left = `calc(${GUTTER}px + (100% - ${GUTTER}px) * ${pct / 100})`;
const px = t * ppsRef.current;
playheadRef.current.style.left = `${GUTTER + px}px`;
// Auto-scroll to follow playhead during playback or seeking
const scroll = scrollRef.current;
if (scroll && !isDragging.current) {
const playheadX = GUTTER + px;
const visibleRight = scroll.scrollLeft + scroll.clientWidth;
const visibleLeft = scroll.scrollLeft;
const edgeMargin = scroll.clientWidth * 0.12;
if (playheadX > visibleRight - edgeMargin) {
// Playhead near right edge — page forward
scroll.scrollLeft = playheadX - scroll.clientWidth * 0.15;
} else if (playheadX < visibleLeft + GUTTER) {
// Playhead before visible area (e.g. loop) — jump back
scroll.scrollLeft = Math.max(0, playheadX - GUTTER);
}
}
});
return unsub;
});
const dragScrollRaf = useRef(0);
const seekFromX = useCallback(
(clientX: number) => {
const el = containerRef.current;
if (!el || duration <= 0) return;
const el = scrollRef.current;
if (!el || effectiveDuration <= 0) return;
const rect = el.getBoundingClientRect();
const start = rect.left + GUTTER;
const w = rect.width - GUTTER;
if (w <= 0) return;
const pct = Math.max(0, Math.min(1, (clientX - start) / w));
const time = pct * duration;
// Notify liveTime for instant visual update (direct DOM, no re-render)
const scrollLeft = el.scrollLeft;
const x = clientX - rect.left + scrollLeft - GUTTER;
if (x < 0) return;
const time = Math.max(0, Math.min(effectiveDuration, x / pps));
liveTime.notify(time);
// Call parent's onSeek to actually seek the iframe/player
onSeek?.(time);
},
[duration, onSeek],
[effectiveDuration, onSeek, pps],
);
// Auto-scroll the timeline when dragging the playhead near edges
const autoScrollDuringDrag = useCallback(
(clientX: number) => {
cancelAnimationFrame(dragScrollRaf.current);
const el = scrollRef.current;
if (!el || !isDragging.current) return;
const rect = el.getBoundingClientRect();
const edgeZone = 40;
const maxSpeed = 12;
let scrollDelta = 0;
if (clientX < rect.left + edgeZone) {
// Near left edge — scroll left
const proximity = Math.max(0, 1 - (clientX - rect.left) / edgeZone);
scrollDelta = -maxSpeed * proximity;
} else if (clientX > rect.right - edgeZone) {
// Near right edge — scroll right
const proximity = Math.max(0, 1 - (rect.right - clientX) / edgeZone);
scrollDelta = maxSpeed * proximity;
}
if (scrollDelta !== 0) {
el.scrollLeft += scrollDelta;
seekFromX(clientX);
dragScrollRaf.current = requestAnimationFrame(() => autoScrollDuringDrag(clientX));
}
},
[seekFromX],
);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
if ((e.target as HTMLElement).closest("[data-clip]")) return;
if (e.button !== 0) return;
isDragging.current = true;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
seekFromX(e.clientX);
@@ -209,12 +336,15 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (isDragging.current) seekFromX(e.clientX);
if (!isDragging.current) return;
seekFromX(e.clientX);
autoScrollDuringDrag(e.clientX);
},
[seekFromX],
[seekFromX, autoScrollDuringDrag],
);
const handlePointerUp = useCallback(() => {
isDragging.current = false;
cancelAnimationFrame(dragScrollRaf.current);
}, []);
const tracks = useMemo(() => {
@@ -236,13 +366,101 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
return map;
}, [tracks]);
const { major, minor } = useMemo(() => generateTicks(duration), [duration]);
const { major, minor } = useMemo(() => generateTicks(effectiveDuration), [effectiveDuration]);
if (!timelineReady) return null;
if (elements.length === 0) {
const [isDragOver, setIsDragOver] = useState(false);
if (!timelineReady || elements.length === 0) {
return (
<div className="px-3 py-3 text-2xs text-neutral-600 border-t border-neutral-800/50">
No timeline elements
<div
className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
isDragOver ? "border-blue-500/50 bg-blue-500/[0.03]" : "border-neutral-800/50"
}`}
onDragOver={(e) => {
e.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragOver(false);
if (onFileDrop && e.dataTransfer.files.length > 0) {
onFileDrop(Array.from(e.dataTransfer.files));
}
}}
>
{/* Ruler */}
<div
className="flex-shrink-0 border-b border-neutral-800/40 flex items-end relative"
style={{ height: RULER_H, paddingLeft: GUTTER }}
>
{[0, 10, 20, 30, 40, 50].map((s) => (
<div
key={s}
className="flex flex-col items-center"
style={{ position: "absolute", left: GUTTER + s * 14 }}
>
<span className="text-[9px] text-neutral-600 font-mono tabular-nums leading-none mb-0.5">
{`${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`}
</span>
<div className="w-px h-[5px] bg-neutral-700/40" />
</div>
))}
</div>
{/* Empty drop zone */}
<div className="flex-1 flex items-center justify-center">
<div
className={`flex items-center gap-3 px-6 py-3 border border-dashed rounded-lg transition-colors duration-150 ${
isDragOver ? "border-blue-400/60 bg-blue-500/[0.06]" : "border-neutral-700/50"
}`}
>
{isDragOver ? (
<>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-blue-400 flex-shrink-0"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span className="text-[13px] text-blue-400">Drop media files to import</span>
</>
) : (
<>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-neutral-600 flex-shrink-0"
>
<rect x="2" y="2" width="20" height="20" rx="2" />
<path d="M7 2v20" />
<path d="M17 2v20" />
<path d="M2 7h20" />
<path d="M2 17h20" />
</svg>
<span className="text-[13px] text-neutral-500">
{onFileDrop
? "Drop media here or describe your video to start"
: "Describe your video to start creating"}
</span>
</>
)}
</div>
</div>
</div>
);
}
@@ -251,189 +469,195 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
return (
<div
ref={containerRef}
ref={setContainerRef}
aria-label="Timeline"
className="border-t border-neutral-800/50 bg-[#0a0a0b] select-none overflow-x-hidden cursor-crosshair"
style={{ touchAction: "none" }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
className="border-t border-neutral-800/50 bg-[#0a0a0b] select-none cursor-crosshair h-full overflow-hidden"
style={{ touchAction: "pan-x pan-y" }}
>
<div className="relative" style={{ height: totalH }}>
{/* Grid lines */}
<svg
className="absolute pointer-events-none"
style={{ left: GUTTER }}
width={`calc(100% - ${GUTTER}px)`}
height={totalH}
>
{major.map((t) => (
<line
key={`g-${t}`}
x1={`${(t / duration) * 100}%`}
y1={RULER_H}
x2={`${(t / duration) * 100}%`}
y2={totalH}
stroke="rgba(255,255,255,0.035)"
strokeWidth="1"
/>
))}
</svg>
<div
ref={scrollRef}
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onLostPointerCapture={handlePointerUp}
>
<div className="relative" style={{ height: totalH, width: GUTTER + trackContentWidth }}>
{/* Grid lines */}
<svg
className="absolute pointer-events-none"
style={{ left: GUTTER, width: trackContentWidth }}
height={totalH}
>
{major.map((t) => {
const x = t * pps;
return (
<line
key={`g-${t}`}
x1={x}
y1={RULER_H}
x2={x}
y2={totalH}
stroke="rgba(255,255,255,0.035)"
strokeWidth="1"
/>
);
})}
</svg>
{/* Ruler */}
<div
className="relative border-b border-neutral-800/40"
style={{ height: RULER_H, marginLeft: GUTTER }}
>
{minor.map((t) => (
<div
key={`m-${t}`}
className="absolute bottom-0"
style={{ left: `${(t / duration) * 100}%` }}
>
<div className="w-px h-[3px] bg-neutral-700/40" />
</div>
))}
{major.map((t) => (
<div
key={`M-${t}`}
className="absolute bottom-0 flex flex-col items-center"
style={{ left: `${(t / duration) * 100}%` }}
>
<span className="text-[9px] text-neutral-500 font-mono tabular-nums leading-none mb-0.5">
{formatTick(t)}
</span>
<div className="w-px h-[5px] bg-neutral-600/60" />
</div>
))}
</div>
{/* Tracks */}
{tracks.map(([trackNum, els]) => {
const ts = trackStyles.get(trackNum) ?? DEFAULT;
return (
<div
key={trackNum}
className="relative flex"
style={{ height: TRACK_H, backgroundColor: ts.row }}
>
{/* Gutter: colored icon badge (Figma HyperFrames style) */}
{/* Ruler */}
<div
className="relative border-b border-neutral-800/40 overflow-hidden"
style={{ height: RULER_H, marginLeft: GUTTER, width: trackContentWidth }}
>
{minor.map((t) => (
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
<div className="w-px h-[3px] bg-neutral-700/40" />
</div>
))}
{major.map((t) => (
<div
className="flex-shrink-0 flex items-center justify-center"
style={{ width: GUTTER }}
key={`M-${t}`}
className="absolute bottom-0 flex flex-col items-center"
style={{ left: t * pps }}
>
<span className="text-[9px] text-neutral-500 font-mono tabular-nums leading-none mb-0.5">
{formatTick(t)}
</span>
<div className="w-px h-[5px] bg-neutral-600/60" />
</div>
))}
</div>
{/* Tracks */}
{tracks.map(([trackNum, els]) => {
const ts = trackStyles.get(trackNum) ?? DEFAULT;
return (
<div
key={trackNum}
className="relative flex"
style={{ height: TRACK_H, backgroundColor: ts.row }}
>
{/* Gutter: colored icon badge (Figma Motion Cut style) */}
<div
className="flex items-center justify-center"
style={{
width: 20,
height: 20,
borderRadius: 6,
backgroundColor: ts.gutter,
border: "1px solid rgba(255,255,255,0.35)",
color: "#fff",
}}
className="flex-shrink-0 flex items-center justify-center"
style={{ width: GUTTER }}
>
{ts.icon}
<div
className="flex items-center justify-center"
style={{
width: 20,
height: 20,
borderRadius: 6,
backgroundColor: ts.gutter,
border: "1px solid rgba(255,255,255,0.35)",
color: "#fff",
}}
>
{ts.icon}
</div>
</div>
{/* Clips */}
<div style={{ width: trackContentWidth }} className="relative">
{els.map((el, i) => {
const clipStyle = getStyle(el.tag);
const isSelected = selectedElementId === el.id;
const isComposition = !!el.compositionSrc;
const clipKey = `${el.id}-${i}`;
const isHovered = hoveredClip === clipKey;
const hasCustomContent = !!renderClipContent;
const clipWidthPx = Math.max(el.duration * pps, 4);
return (
<TimelineClip
key={clipKey}
el={el}
pps={pps}
trackH={TRACK_H}
clipY={CLIP_Y}
isSelected={isSelected}
isHovered={isHovered}
hasCustomContent={hasCustomContent}
style={clipStyle}
isComposition={isComposition}
onHoverStart={() => setHoveredClip(clipKey)}
onHoverEnd={() => setHoveredClip(null)}
onClick={(e) => {
e.stopPropagation();
setSelectedElementId(isSelected ? null : el.id);
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (isComposition && onDrillDown) onDrillDown(el);
}}
>
{renderClipOverlay?.(el)}
<div
className={
renderClipContent
? "absolute inset-0 overflow-hidden rounded-[4px]"
: "flex items-center overflow-hidden flex-1 min-w-0"
}
>
{renderClipContent?.(el, clipStyle) ?? (
<>
<span
className="text-[10px] font-semibold truncate px-1.5 leading-none"
style={{ color: clipStyle.label }}
>
{el.id || el.tag}
</span>
{clipWidthPx > 60 && (
<span
className="text-[9px] font-mono tabular-nums pr-1.5 ml-auto flex-shrink-0 leading-none opacity-70"
style={{ color: clipStyle.label }}
>
{el.duration.toFixed(1)}s
</span>
)}
</>
)}
</div>
</TimelineClip>
);
})}
</div>
</div>
);
})}
{/* Clips */}
<div className="flex-1 relative">
{els.map((el, i) => {
const leftPct = (el.start / duration) * 100;
const widthPct = (el.duration / duration) * 100;
const style = getStyle(el.tag);
const isSelected = selectedElementId === el.id;
const isComposition = !!el.compositionSrc;
const clipKey = `${el.id}-${i}`;
const isHovered = hoveredClip === clipKey;
return (
<div
key={clipKey}
data-clip="true"
className="absolute flex items-center overflow-hidden"
style={{
left: `${leftPct}%`,
width: `${Math.max(widthPct, 1)}%`,
top: CLIP_Y,
bottom: CLIP_Y,
borderRadius: 5,
backgroundColor: style.clip,
backgroundImage: isComposition
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.08) 3px, rgba(255,255,255,0.08) 6px)`
: undefined,
border: isSelected
? `2px solid rgba(255,255,255,0.9)`
: `1px solid rgba(255,255,255,${isHovered ? 0.3 : 0.15})`,
boxShadow: isSelected
? `0 0 0 1px ${style.clip}, 0 2px 8px rgba(0,0,0,0.4)`
: isHovered
? "0 1px 4px rgba(0,0,0,0.3)"
: "none",
cursor: "pointer",
transition: "border-color 120ms, box-shadow 120ms, transform 80ms",
transform: isHovered && !isSelected ? "scaleY(1.04)" : "scaleY(1)",
zIndex: isSelected ? 10 : isHovered ? 5 : 1,
}}
title={
isComposition
? `${el.compositionSrc} \u2022 Double-click to open`
: `${el.id || el.tag} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
}
onPointerEnter={() => setHoveredClip(clipKey)}
onPointerLeave={() => setHoveredClip(null)}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setSelectedElementId(isSelected ? null : el.id);
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (isComposition && onDrillDown) {
onDrillDown(el);
}
}}
>
<span
className="text-[10px] font-semibold truncate px-1.5 leading-none"
style={{ color: style.label }}
>
{el.id || el.tag}
</span>
{widthPct > 10 && (
<span
className="text-[9px] font-mono tabular-nums pr-1.5 ml-auto flex-shrink-0 leading-none opacity-70"
style={{ color: style.label }}
>
{el.duration.toFixed(1)}s
</span>
)}
</div>
);
})}
</div>
</div>
);
})}
{/* Playhead */}
<div
ref={playheadRef}
className="absolute top-0 bottom-0 z-20 pointer-events-none"
style={{ left: `${GUTTER}px` }}
>
<div className="absolute top-0 bottom-0 left-1/2 -translate-x-1/2 w-px bg-white/90" />
<div className="absolute left-1/2 -translate-x-1/2" style={{ top: 0 }}>
{/* Playhead — z-[100] to stay above all clips (which use z-1 to z-10) */}
<div
ref={playheadRef}
className="absolute top-0 bottom-0 pointer-events-none"
style={{ left: `${GUTTER}px`, zIndex: 100 }}
>
<div
className="absolute top-0 bottom-0"
style={{
width: 0,
height: 0,
borderLeft: "5px solid transparent",
borderRight: "5px solid transparent",
borderTop: "7px solid rgba(255,255,255,0.95)",
left: "50%",
width: 2,
marginLeft: -1,
background: "var(--hf-accent, #3CE6AC)",
boxShadow: "0 0 8px rgba(60,230,172,0.5)",
}}
/>
<div
className="absolute"
style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}
>
<div
style={{
width: 0,
height: 0,
borderLeft: "6px solid transparent",
borderRight: "6px solid transparent",
borderTop: "8px solid var(--hf-accent, #3CE6AC)",
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
}}
/>
</div>
</div>
</div>
</div>
@@ -0,0 +1,81 @@
// TimelineClip — Visual clip component for the NLE timeline.
import { memo, type ReactNode } from "react";
import type { TimelineElement } from "../store/playerStore";
interface TimelineClipProps {
el: TimelineElement;
pps: number;
trackH: number;
clipY: number;
isSelected: boolean;
isHovered: boolean;
hasCustomContent: boolean;
style: { clip: string; label: string };
isComposition: boolean;
onHoverStart: () => void;
onHoverEnd: () => void;
onClick: (e: React.MouseEvent) => void;
onDoubleClick: (e: React.MouseEvent) => void;
children?: ReactNode;
}
export const TimelineClip = memo(function TimelineClip({
el,
pps,
clipY,
isSelected,
isHovered,
hasCustomContent,
style,
isComposition,
onHoverStart,
onHoverEnd,
onClick,
onDoubleClick,
children,
}: TimelineClipProps) {
const leftPx = el.start * pps;
const widthPx = Math.max(el.duration * pps, 4);
return (
<div
data-clip="true"
className={hasCustomContent ? "absolute" : "absolute flex items-center"}
style={{
left: leftPx,
width: widthPx,
top: clipY,
bottom: clipY,
borderRadius: 5,
backgroundColor: hasCustomContent ? (isComposition ? "#111" : style.clip) : style.clip,
backgroundImage:
isComposition && !hasCustomContent
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.08) 3px, rgba(255,255,255,0.08) 6px)`
: undefined,
border: isSelected
? "2px solid rgba(255,255,255,0.9)"
: `1px solid rgba(255,255,255,${isHovered ? 0.3 : 0.15})`,
boxShadow: isSelected
? `0 0 0 1px ${style.clip}, 0 2px 8px rgba(0,0,0,0.4)`
: isHovered
? "0 1px 4px rgba(0,0,0,0.3)"
: "none",
transition: "border-color 120ms, box-shadow 120ms",
zIndex: isSelected ? 10 : isHovered ? 5 : 1,
cursor: "pointer",
}}
title={
isComposition
? `${el.compositionSrc} \u2022 Double-click to open`
: `${el.id || el.tag} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
}
onPointerEnter={onHoverStart}
onPointerLeave={onHoverEnd}
onClick={onClick}
onDoubleClick={onDoubleClick}
>
{children}
</div>
);
});
@@ -0,0 +1,195 @@
import { memo, useRef, useState, useCallback, useEffect } from "react";
interface VideoThumbnailProps {
videoSrc: string;
label: string;
labelColor: string;
duration?: number;
}
const CLIP_HEIGHT = 66;
const MAX_UNIQUE_FRAMES: number = 6;
/**
* Renders a film-strip of video frames extracted client-side via a hidden
* <video> + <canvas>. Each frame is a fixed-width tile; frames repeat to
* fill the clip width — matching ClipThumbnail's visual pattern.
*/
export const VideoThumbnail = memo(function VideoThumbnail({
videoSrc,
label,
labelColor,
duration = 5,
}: VideoThumbnailProps) {
const [containerWidth, setContainerWidth] = useState(0);
const [visible, setVisible] = useState(false);
const [frames, setFrames] = useState<string[]>([]);
const [aspect, setAspect] = useState(16 / 9);
const ioRef = useRef<IntersectionObserver | null>(null);
const roRef = useRef<ResizeObserver | null>(null);
const extractingRef = useRef(false);
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
ioRef.current?.disconnect();
roRef.current?.disconnect();
if (!el) return;
const measured = el.parentElement?.clientWidth || el.clientWidth;
setContainerWidth(measured);
ioRef.current = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
ioRef.current?.disconnect();
}
},
{ rootMargin: "200px" },
);
ioRef.current.observe(el);
const target = el.parentElement || el;
roRef.current = new ResizeObserver(([entry]) => {
setContainerWidth(entry.contentRect.width);
});
roRef.current.observe(target);
}, []);
useEffect(
() => () => {
ioRef.current?.disconnect();
roRef.current?.disconnect();
},
[],
);
// Extract frames progressively — each frame appears as soon as it's ready
useEffect(() => {
if (!visible || extractingRef.current) return;
extractingRef.current = true;
const video = document.createElement("video");
video.crossOrigin = "anonymous";
video.muted = true;
video.preload = "auto";
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
extractingRef.current = false;
return;
}
const timestamps: number[] = [];
const minSeek = Math.min(0.4, duration * 0.05);
for (let i = 0; i < MAX_UNIQUE_FRAMES; i++) {
const raw =
MAX_UNIQUE_FRAMES === 1 ? duration * 0.15 : (i / (MAX_UNIQUE_FRAMES - 1)) * duration;
timestamps.push(Math.max(raw, minSeek));
}
let idx = 0;
let cancelled = false;
const extractNext = () => {
if (cancelled || idx >= timestamps.length) {
if (!cancelled) {
video.src = "";
video.load();
}
return;
}
video.currentTime = timestamps[idx];
};
video.addEventListener("loadedmetadata", () => {
if (video.videoWidth > 0 && video.videoHeight > 0) {
setAspect(video.videoWidth / video.videoHeight);
const h = CLIP_HEIGHT * 2;
const w = Math.round(h * (video.videoWidth / video.videoHeight));
canvas.width = w;
canvas.height = h;
}
extractNext();
});
video.addEventListener("seeked", () => {
if (cancelled) return;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL("image/jpeg", 0.6);
// Stream each frame immediately
setFrames((prev) => [...prev, dataUrl]);
idx++;
extractNext();
});
video.addEventListener("error", () => {
/* keep whatever frames we have */
});
video.src = videoSrc;
video.load();
return () => {
cancelled = true;
extractingRef.current = false;
setFrames([]);
video.src = "";
video.load();
};
}, [visible, videoSrc, duration]);
const frameW = Math.round(CLIP_HEIGHT * aspect);
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
return (
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
{visible && frames.length > 0 && (
<div className="absolute inset-0 flex">
{Array.from({ length: frameCount }).map((_, i) => {
const src = frames[i % frames.length];
return (
<div
key={i}
className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
style={{ width: frameW }}
>
<img
src={src}
alt=""
draggable={false}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
);
})}
</div>
)}
{visible && frames.length === 0 && (
<div
className="absolute inset-0 animate-pulse"
style={{
background:
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
}}
/>
)}
<div
className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
style={{
background:
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
}}
>
<span
className="text-[9px] font-semibold truncate block leading-tight"
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
>
{label}
</span>
</div>
</div>
);
});