mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
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:
@@ -48,3 +48,9 @@ packages/producer/src/services/fontData.generated.ts
|
||||
# Test artifacts
|
||||
my-video/
|
||||
packages/studio/data/
|
||||
|
||||
# QA artifacts
|
||||
qa-*.webm
|
||||
scorecard.png
|
||||
.worktrees/
|
||||
.desloppify/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
docs/
|
||||
DOCS_GUIDELINES.md
|
||||
packages/producer/tests/
|
||||
*.generated.ts
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@hyperframes/cli",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.10",
|
||||
"bin": {
|
||||
"hyperframes": "./dist/cli.js",
|
||||
},
|
||||
@@ -57,7 +57,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@hyperframes/core",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.10",
|
||||
"devDependencies": {
|
||||
"@types/jsdom": "^28.0.0",
|
||||
"@types/node": "^24.10.13",
|
||||
@@ -74,7 +74,7 @@
|
||||
},
|
||||
"packages/engine": {
|
||||
"name": "@hyperframes/engine",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.6",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hyperframes/core": "workspace:^",
|
||||
@@ -91,7 +91,7 @@
|
||||
},
|
||||
"packages/producer": {
|
||||
"name": "@hyperframes/producer",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.6",
|
||||
"dependencies": {
|
||||
"@fontsource/archivo-black": "^5.2.8",
|
||||
"@fontsource/eb-garamond": "^5.2.7",
|
||||
@@ -121,7 +121,7 @@
|
||||
},
|
||||
"packages/studio": {
|
||||
"name": "@hyperframes/studio",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.10",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
@@ -143,9 +143,12 @@
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"puppeteer-core": "^24.40.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^3.2.4",
|
||||
"zustand": "^5.0.0",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
@@ -1361,8 +1364,6 @@
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "3.2.0", "picocolors": "1.1.1" }, "peerDependencies": { "browserslist": "4.28.1" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "18.3.1" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "0.21.5", "postcss": "8.5.8", "rollup": "4.59.0" }, "optionalDependencies": { "@types/node": "22.19.15", "fsevents": "2.3.3" }, "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
@@ -1419,7 +1420,7 @@
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "1.6.0" }, "optionalDependencies": { "@types/react": "19.2.14", "react": "18.3.1" } }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
||||
"zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
|
||||
|
||||
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
@@ -44,9 +46,12 @@
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"puppeteer-core": "^24.40.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^3.2.4",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -81,6 +81,71 @@ function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse [data-start] elements from a Document into TimelineElement[].
|
||||
* Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
|
||||
*/
|
||||
function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
|
||||
const rootComp = doc.querySelector("[data-composition-id]");
|
||||
const nodes = doc.querySelectorAll("[data-start]");
|
||||
const els: TimelineElement[] = [];
|
||||
let trackCounter = 0;
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (node === rootComp) return;
|
||||
const el = node as HTMLElement;
|
||||
const startStr = el.getAttribute("data-start");
|
||||
if (startStr == null) return;
|
||||
const start = parseFloat(startStr);
|
||||
if (isNaN(start)) return;
|
||||
|
||||
const tagLower = el.tagName.toLowerCase();
|
||||
let dur = 0;
|
||||
const durStr = el.getAttribute("data-duration");
|
||||
if (durStr != null) dur = parseFloat(durStr);
|
||||
if (isNaN(dur) || dur <= 0) dur = Math.max(0, rootDuration - start);
|
||||
|
||||
const trackStr = el.getAttribute("data-track-index");
|
||||
const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
|
||||
const entry: TimelineElement = {
|
||||
id: el.id || el.className?.split(" ")[0] || tagLower,
|
||||
tag: tagLower,
|
||||
start,
|
||||
duration: dur,
|
||||
track: isNaN(track) ? 0 : track,
|
||||
};
|
||||
|
||||
// Media elements
|
||||
if (tagLower === "video" || tagLower === "audio" || tagLower === "img") {
|
||||
const src = el.getAttribute("src");
|
||||
if (src) entry.src = src;
|
||||
const ms = el.getAttribute("data-media-start");
|
||||
if (ms) entry.playbackStart = parseFloat(ms);
|
||||
const vol = el.getAttribute("data-volume");
|
||||
if (vol) entry.volume = parseFloat(vol);
|
||||
}
|
||||
|
||||
// Sub-compositions
|
||||
const compSrc =
|
||||
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|
||||
const compId = el.getAttribute("data-composition-id");
|
||||
if (compSrc) {
|
||||
entry.compositionSrc = compSrc;
|
||||
} else if (compId && compId !== rootComp?.getAttribute("data-composition-id")) {
|
||||
// Inline composition — expose inner video for thumbnails
|
||||
const innerVideo = el.querySelector("video[src]");
|
||||
if (innerVideo) {
|
||||
entry.src = innerVideo.getAttribute("src") || undefined;
|
||||
entry.tag = "video";
|
||||
}
|
||||
}
|
||||
|
||||
els.push(entry);
|
||||
});
|
||||
|
||||
return els;
|
||||
}
|
||||
|
||||
function normalizePreviewViewport(doc: Document, win: Window): void {
|
||||
if (doc.documentElement) {
|
||||
doc.documentElement.style.overflow = "hidden";
|
||||
@@ -136,13 +201,8 @@ function unmutePreviewMedia(iframe: HTMLIFrameElement | null): void {
|
||||
{ source: "hf-parent", type: "control", action: "set-muted", muted: false },
|
||||
"*",
|
||||
);
|
||||
// Fallback for CDN runtime that still uses the old source name
|
||||
iframe.contentWindow?.postMessage(
|
||||
{ source: "hf-parent", type: "control", action: "set-muted", muted: false },
|
||||
"*",
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
} catch (err) {
|
||||
console.warn("[useTimelinePlayer] Failed to unmute preview media", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +215,7 @@ export function useTimelinePlayer() {
|
||||
|
||||
// ZERO store subscriptions — this hook never causes re-renders.
|
||||
// All reads use getState() (point-in-time), all writes use the stable setters.
|
||||
const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements, reset } =
|
||||
const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } =
|
||||
usePlayerStore.getState();
|
||||
|
||||
const getAdapter = useCallback((): PlaybackAdapter | null => {
|
||||
@@ -175,7 +235,8 @@ export function useTimelinePlayer() {
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.warn("[useTimelinePlayer] Could not get playback adapter (cross-origin)", err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
@@ -211,10 +272,6 @@ export function useTimelinePlayer() {
|
||||
{ source: "hf-parent", type: "control", action: "set-playback-rate", playbackRate: rate },
|
||||
"*",
|
||||
);
|
||||
iframe.contentWindow?.postMessage(
|
||||
{ source: "hf-parent", type: "control", action: "set-playback-rate", playbackRate: rate },
|
||||
"*",
|
||||
);
|
||||
// Also set directly on GSAP timeline if accessible
|
||||
try {
|
||||
const win = iframe.contentWindow as IframeWindow | null;
|
||||
@@ -228,8 +285,8 @@ export function useTimelinePlayer() {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
} catch (err) {
|
||||
console.warn("[useTimelinePlayer] Could not set playback rate (cross-origin)", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -250,9 +307,10 @@ export function useTimelinePlayer() {
|
||||
const adapter = getAdapter();
|
||||
if (!adapter) return;
|
||||
adapter.pause();
|
||||
setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time
|
||||
setIsPlaying(false);
|
||||
stopRAFLoop();
|
||||
}, [getAdapter, setIsPlaying, stopRAFLoop]);
|
||||
}, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (usePlayerStore.getState().isPlaying) {
|
||||
@@ -268,41 +326,198 @@ export function useTimelinePlayer() {
|
||||
if (!adapter) return;
|
||||
adapter.seek(time);
|
||||
liveTime.notify(time); // Direct DOM updates (playhead, timecode, progress) — no re-render
|
||||
setCurrentTime(time); // sync store so Split/Delete have accurate time
|
||||
stopRAFLoop();
|
||||
// Only update store if state actually changes (avoids unnecessary re-renders)
|
||||
if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
|
||||
},
|
||||
[getAdapter, setIsPlaying, stopRAFLoop],
|
||||
[getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop],
|
||||
);
|
||||
|
||||
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
|
||||
const processTimelineMessage = useCallback(
|
||||
(data: { clips: ClipManifestClip[]; durationInFrames: number }) => {
|
||||
if (!data.clips || data.clips.length === 0) return;
|
||||
if (!data.clips || data.clips.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show only root-level clips: those with no parentCompositionId (direct children of root).
|
||||
// Sub-composition children (parentCompositionId !== null) belong to the drill-down view.
|
||||
const els: TimelineElement[] = data.clips
|
||||
.filter((clip) => !clip.parentCompositionId)
|
||||
.map((clip) => {
|
||||
const entry: TimelineElement = {
|
||||
id: clip.id || clip.label || clip.tagName || "element",
|
||||
tag: clip.tagName || clip.kind,
|
||||
start: clip.start,
|
||||
duration: clip.duration,
|
||||
track: clip.track,
|
||||
};
|
||||
if (clip.assetUrl) entry.src = clip.assetUrl;
|
||||
if (clip.kind === "composition" && clip.compositionId) {
|
||||
entry.compositionSrc = clip.compositionSrc || `compositions/${clip.compositionId}.html`;
|
||||
// Show root-level clips: no parentCompositionId, OR parent is a "phantom wrapper"
|
||||
const clipCompositionIds = new Set(data.clips.map((c) => c.compositionId).filter(Boolean));
|
||||
const filtered = data.clips.filter(
|
||||
(clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
|
||||
);
|
||||
const els: TimelineElement[] = filtered.map((clip) => {
|
||||
const entry: TimelineElement = {
|
||||
id: clip.id || clip.label || clip.tagName || "element",
|
||||
tag: clip.tagName || clip.kind,
|
||||
start: clip.start,
|
||||
duration: clip.duration,
|
||||
track: clip.track,
|
||||
};
|
||||
if (clip.assetUrl) entry.src = clip.assetUrl;
|
||||
if (clip.kind === "composition" && clip.compositionId) {
|
||||
// The bundler renames data-composition-src to data-composition-file
|
||||
// after inlining, so the clip manifest may not have compositionSrc.
|
||||
// Fall back to reading data-composition-file from the DOM.
|
||||
let resolvedSrc = clip.compositionSrc;
|
||||
let hostEl: Element | null = null;
|
||||
if (!resolvedSrc) {
|
||||
try {
|
||||
const iframeDoc = iframeRef.current?.contentDocument;
|
||||
hostEl =
|
||||
iframeDoc?.querySelector(`[data-composition-id="${clip.compositionId}"]`) ?? null;
|
||||
resolvedSrc = hostEl?.getAttribute("data-composition-file") ?? null;
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
if (resolvedSrc) {
|
||||
entry.compositionSrc = resolvedSrc;
|
||||
} else if (hostEl) {
|
||||
// Inline composition (no external file) — expose inner video for thumbnails
|
||||
const innerVideo = hostEl.querySelector("video[src]");
|
||||
if (innerVideo) {
|
||||
entry.src = innerVideo.getAttribute("src") || undefined;
|
||||
entry.tag = "video";
|
||||
}
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
// Don't downgrade: if we already have more elements with a longer duration,
|
||||
// skip updates that would show fewer clips (transient runtime state).
|
||||
const currentElements = usePlayerStore.getState().elements;
|
||||
const currentDuration = usePlayerStore.getState().duration;
|
||||
const rawDuration = data.durationInFrames / 30;
|
||||
// Clamp non-finite or absurdly large durations — the runtime can emit
|
||||
// Infinity when it detects a loop-inflated GSAP timeline without an
|
||||
// explicit data-duration on the root composition.
|
||||
const newDuration = Number.isFinite(rawDuration) ? rawDuration : 0;
|
||||
if (currentElements.length > els.length && newDuration <= currentDuration) {
|
||||
return; // skip transient downgrade
|
||||
}
|
||||
setElements(els);
|
||||
// Ensure duration covers the furthest clip end so fit-zoom shows everything
|
||||
if (els.length > 0) {
|
||||
const maxEnd = Math.max(...els.map((e) => e.start + e.duration));
|
||||
const effectiveDur = Math.max(newDuration, maxEnd);
|
||||
if (Number.isFinite(effectiveDur) && effectiveDur > currentDuration)
|
||||
setDuration(effectiveDur);
|
||||
}
|
||||
if (els.length > 0) setTimelineReady(true);
|
||||
},
|
||||
[setElements],
|
||||
[setElements, setTimelineReady, setDuration],
|
||||
);
|
||||
|
||||
/**
|
||||
* Scan the iframe DOM for composition hosts missing from the current
|
||||
* timeline elements and add them. The CDN runtime often fails to resolve
|
||||
* element-reference starts (`data-start="intro"`) so composition hosts
|
||||
* are silently dropped from `__clipManifest`. This pass reads the DOM +
|
||||
* GSAP timeline registry directly to fill the gaps.
|
||||
*/
|
||||
const enrichMissingCompositions = useCallback(() => {
|
||||
try {
|
||||
const iframe = iframeRef.current;
|
||||
const doc = iframe?.contentDocument;
|
||||
const iframeWin = iframe?.contentWindow as IframeWindow | null;
|
||||
if (!doc || !iframeWin) return;
|
||||
|
||||
const currentEls = usePlayerStore.getState().elements;
|
||||
const existingIds = new Set(currentEls.map((e) => e.id));
|
||||
const rootComp = doc.querySelector("[data-composition-id]");
|
||||
const rootCompId = rootComp?.getAttribute("data-composition-id");
|
||||
// Use [data-composition-id][data-start] — the composition loader strips
|
||||
// data-composition-src after loading, so we can't rely on it.
|
||||
const hosts = doc.querySelectorAll("[data-composition-id][data-start]");
|
||||
const missing: TimelineElement[] = [];
|
||||
|
||||
hosts.forEach((host) => {
|
||||
const el = host as HTMLElement;
|
||||
const compId = el.getAttribute("data-composition-id");
|
||||
if (!compId || compId === rootCompId) return;
|
||||
if (existingIds.has(el.id) || existingIds.has(compId)) return;
|
||||
|
||||
// Resolve start: numeric or element-reference
|
||||
const startAttr = el.getAttribute("data-start") ?? "0";
|
||||
let start = parseFloat(startAttr);
|
||||
if (isNaN(start)) {
|
||||
const ref =
|
||||
doc.getElementById(startAttr) ||
|
||||
doc.querySelector(`[data-composition-id="${startAttr}"]`);
|
||||
if (ref) {
|
||||
const refStartAttr = ref.getAttribute("data-start") ?? "0";
|
||||
let refStart = parseFloat(refStartAttr);
|
||||
// Recursively resolve one level of reference for the ref's own start
|
||||
if (isNaN(refStart)) {
|
||||
const refRef =
|
||||
doc.getElementById(refStartAttr) ||
|
||||
doc.querySelector(`[data-composition-id="${refStartAttr}"]`);
|
||||
const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0;
|
||||
const rrCompId = refRef?.getAttribute("data-composition-id");
|
||||
const rrDur =
|
||||
parseFloat(refRef?.getAttribute("data-duration") ?? "") ||
|
||||
(rrCompId
|
||||
? ((
|
||||
iframeWin.__timelines?.[rrCompId] as TimelineLike | undefined
|
||||
)?.duration?.() ?? 0)
|
||||
: 0);
|
||||
refStart = rrStart + rrDur;
|
||||
}
|
||||
const refCompId = ref.getAttribute("data-composition-id");
|
||||
const refDur =
|
||||
parseFloat(ref.getAttribute("data-duration") ?? "") ||
|
||||
(refCompId
|
||||
? ((iframeWin.__timelines?.[refCompId] as TimelineLike | undefined)?.duration?.() ??
|
||||
0)
|
||||
: 0);
|
||||
start = refStart + refDur;
|
||||
} else {
|
||||
start = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve duration from data-duration or GSAP timeline
|
||||
let dur = parseFloat(el.getAttribute("data-duration") ?? "");
|
||||
if (isNaN(dur) || dur <= 0) {
|
||||
dur = (iframeWin.__timelines?.[compId] as TimelineLike | undefined)?.duration?.() ?? 0;
|
||||
}
|
||||
if (!Number.isFinite(dur) || dur <= 0) return;
|
||||
if (!Number.isFinite(start)) start = 0;
|
||||
|
||||
const trackStr = el.getAttribute("data-track-index");
|
||||
const track = trackStr != null ? parseInt(trackStr, 10) : 0;
|
||||
const compSrc =
|
||||
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|
||||
const entry: TimelineElement = {
|
||||
id: el.id || compId,
|
||||
tag: el.tagName.toLowerCase(),
|
||||
start,
|
||||
duration: dur,
|
||||
track: isNaN(track) ? 0 : track,
|
||||
};
|
||||
if (compSrc) {
|
||||
entry.compositionSrc = compSrc;
|
||||
} else {
|
||||
// Inline composition — expose inner video for thumbnails
|
||||
const innerVideo = el.querySelector("video[src]");
|
||||
if (innerVideo) {
|
||||
entry.src = innerVideo.getAttribute("src") || undefined;
|
||||
entry.tag = "video";
|
||||
}
|
||||
}
|
||||
missing.push(entry);
|
||||
});
|
||||
|
||||
if (missing.length > 0) {
|
||||
setElements([...currentEls, ...missing]);
|
||||
setTimelineReady(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[useTimelinePlayer] enrichMissingCompositions failed", err);
|
||||
}
|
||||
}, [setElements, setTimelineReady]);
|
||||
|
||||
const onIframeLoad = useCallback(() => {
|
||||
unmutePreviewMedia(iframeRef.current);
|
||||
|
||||
@@ -323,7 +538,8 @@ export function useTimelinePlayer() {
|
||||
const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
|
||||
|
||||
adapter.seek(startTime);
|
||||
setDuration(adapter.getDuration());
|
||||
const adapterDur = adapter.getDuration();
|
||||
if (Number.isFinite(adapterDur) && adapterDur > 0) setDuration(adapterDur);
|
||||
setCurrentTime(startTime);
|
||||
if (!isRefreshingRef.current) {
|
||||
setTimelineReady(true);
|
||||
@@ -343,55 +559,57 @@ export function useTimelinePlayer() {
|
||||
const manifest = iframeWin?.__clipManifest;
|
||||
if (manifest && manifest.clips.length > 0) {
|
||||
processTimelineMessage(manifest);
|
||||
} else if (doc) {
|
||||
}
|
||||
// Enrich: fill in composition hosts the manifest missed
|
||||
enrichMissingCompositions();
|
||||
|
||||
// Run DOM fallback if still no elements were populated
|
||||
// (manifest may exist but all clips filtered out by parentCompositionId logic)
|
||||
if (usePlayerStore.getState().elements.length === 0 && doc) {
|
||||
// Fallback: parse data-start elements directly from DOM (raw HTML without runtime)
|
||||
const els = parseTimelineFromDOM(doc, adapter.getDuration());
|
||||
if (els.length > 0) {
|
||||
setElements(els);
|
||||
setTimelineReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback for standalone composition previews: if still no
|
||||
// elements, build timeline entries from the DOM inside the root
|
||||
// composition. This ensures the timeline always shows content when
|
||||
// viewing a single composition (where elements lack data-start).
|
||||
if (usePlayerStore.getState().elements.length === 0 && doc) {
|
||||
const rootComp = doc.querySelector("[data-composition-id]");
|
||||
const nodes = doc.querySelectorAll("[data-start]");
|
||||
const els: TimelineElement[] = [];
|
||||
let trackCounter = 0;
|
||||
const rootDuration = adapter.getDuration();
|
||||
nodes.forEach((node) => {
|
||||
if (node === rootComp) return;
|
||||
const el = node as HTMLElement;
|
||||
const startStr = el.getAttribute("data-start");
|
||||
if (startStr == null) return;
|
||||
const start = parseFloat(startStr);
|
||||
if (isNaN(start)) return;
|
||||
|
||||
const tagLower = el.tagName.toLowerCase();
|
||||
let dur = 0;
|
||||
const durStr = el.getAttribute("data-duration");
|
||||
if (durStr != null) dur = parseFloat(durStr);
|
||||
if (isNaN(dur) || dur <= 0) dur = Math.max(0, rootDuration - start);
|
||||
|
||||
const trackStr = el.getAttribute("data-track-index");
|
||||
const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
|
||||
const entry: TimelineElement = {
|
||||
id: el.id || el.className?.split(" ")[0] || tagLower,
|
||||
tag: tagLower,
|
||||
start,
|
||||
duration: dur,
|
||||
track: isNaN(track) ? 0 : track,
|
||||
};
|
||||
if (tagLower === "video" || tagLower === "audio" || tagLower === "img") {
|
||||
const src = el.getAttribute("src");
|
||||
if (src) entry.src = src;
|
||||
}
|
||||
// Detect sub-compositions
|
||||
const compSrc = el.getAttribute("data-composition-src");
|
||||
const compId = el.getAttribute("data-composition-id");
|
||||
if (compSrc || (compId && compId !== rootComp?.getAttribute("data-composition-id"))) {
|
||||
entry.compositionSrc = compSrc || `compositions/${compId}.html`;
|
||||
}
|
||||
els.push(entry);
|
||||
});
|
||||
if (els.length > 0) setElements(els);
|
||||
if (rootComp && rootDuration > 0) {
|
||||
const rootId = rootComp.getAttribute("data-composition-id") || "composition";
|
||||
// Derive compositionSrc from the iframe URL for thumbnail rendering.
|
||||
// URL pattern: /api/projects/{id}/preview/comp/{path}
|
||||
const iframeSrc = iframeRef.current?.src || "";
|
||||
const compPathMatch = iframeSrc.match(/\/preview\/comp\/(.+?)(?:\?|$)/);
|
||||
const compositionSrc = compPathMatch
|
||||
? decodeURIComponent(compPathMatch[1])
|
||||
: undefined;
|
||||
// Always show the root composition as a single clip — guarantees
|
||||
// the timeline is never empty when a valid composition is loaded.
|
||||
setElements([
|
||||
{
|
||||
id: rootId,
|
||||
tag: (rootComp as HTMLElement).tagName?.toLowerCase() || "div",
|
||||
start: 0,
|
||||
duration: rootDuration,
|
||||
track: 0,
|
||||
compositionSrc,
|
||||
},
|
||||
]);
|
||||
setTimelineReady(true);
|
||||
}
|
||||
}
|
||||
// The runtime will also postMessage the full timeline after all compositions load.
|
||||
// That message is handled by the window listener below, which will update elements
|
||||
// with the complete data (including async-loaded compositions).
|
||||
} catch {
|
||||
// Cross-origin or DOM access error
|
||||
} catch (err) {
|
||||
console.warn("[useTimelinePlayer] Could not read timeline elements from iframe", err);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -401,7 +619,7 @@ export function useTimelinePlayer() {
|
||||
console.warn("Could not find __player, __timeline, or __timelines on iframe after 5s");
|
||||
}
|
||||
}, 200);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- setElements is a stable zustand setter
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
getAdapter,
|
||||
setDuration,
|
||||
@@ -409,6 +627,7 @@ export function useTimelinePlayer() {
|
||||
setTimelineReady,
|
||||
setIsPlaying,
|
||||
processTimelineMessage,
|
||||
enrichMissingCompositions,
|
||||
]);
|
||||
|
||||
/** Save the current playback time so the next onIframeLoad restores it. */
|
||||
@@ -436,8 +655,12 @@ export function useTimelinePlayer() {
|
||||
|
||||
const togglePlayRef = useRef(togglePlay);
|
||||
togglePlayRef.current = togglePlay;
|
||||
const getAdapterRef = useRef(getAdapter);
|
||||
getAdapterRef.current = getAdapter;
|
||||
const processTimelineMessageRef = useRef(processTimelineMessage);
|
||||
processTimelineMessageRef.current = processTimelineMessage;
|
||||
const enrichMissingCompositionsRef = useRef(enrichMissingCompositions);
|
||||
enrichMissingCompositionsRef.current = enrichMissingCompositions;
|
||||
|
||||
useMountEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -452,33 +675,95 @@ export function useTimelinePlayer() {
|
||||
// so we get the complete clip list (not just the first few).
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
const data = e.data;
|
||||
if (
|
||||
(data?.source === "hf-preview" || data?.source === "hf-preview") &&
|
||||
data?.type === "timeline" &&
|
||||
Array.isArray(data.clips)
|
||||
) {
|
||||
// Only process messages from the main preview iframe — ignore MediaPanel/ClipThumbnail iframes
|
||||
if (e.source && iframeRef.current && e.source !== iframeRef.current.contentWindow) {
|
||||
return;
|
||||
}
|
||||
// Also handle the runtime's state message which includes timeline data
|
||||
if (data?.source === "hf-preview" && data?.type === "state") {
|
||||
// State message means the runtime is alive — check for elements
|
||||
try {
|
||||
if (usePlayerStore.getState().elements.length === 0) {
|
||||
const iframe = iframeRef.current;
|
||||
const iframeWin = iframe?.contentWindow as IframeWindow | null;
|
||||
const manifest = iframeWin?.__clipManifest;
|
||||
if (manifest && manifest.clips.length > 0) {
|
||||
processTimelineMessageRef.current(manifest);
|
||||
}
|
||||
}
|
||||
// Always try to enrich — timelines may have registered since the last check
|
||||
enrichMissingCompositionsRef.current();
|
||||
} catch (err) {
|
||||
console.warn("[useTimelinePlayer] Could not read clip manifest from iframe", err);
|
||||
}
|
||||
}
|
||||
if (data?.source === "hf-preview" && data?.type === "timeline" && Array.isArray(data.clips)) {
|
||||
processTimelineMessageRef.current(data);
|
||||
// Fill in composition hosts the manifest missed (element-reference starts)
|
||||
enrichMissingCompositionsRef.current();
|
||||
// Update duration only if the new value is longer (don't downgrade during generation)
|
||||
if (data.durationInFrames > 0) {
|
||||
if (data.durationInFrames > 0 && Number.isFinite(data.durationInFrames)) {
|
||||
const fps = 30;
|
||||
const dur = data.durationInFrames / fps;
|
||||
const currentDur = usePlayerStore.getState().duration;
|
||||
if (dur > currentDur) usePlayerStore.getState().setDuration(dur);
|
||||
}
|
||||
// If manifest produced 0 elements after filtering, try DOM fallback
|
||||
if (usePlayerStore.getState().elements.length === 0) {
|
||||
try {
|
||||
const iframe = iframeRef.current;
|
||||
const doc = iframe?.contentDocument;
|
||||
const adapter = getAdapter();
|
||||
if (doc && adapter) {
|
||||
const els = parseTimelineFromDOM(doc, adapter.getDuration());
|
||||
if (els.length > 0) {
|
||||
setElements(els);
|
||||
setTimelineReady(true);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[useTimelinePlayer] Could not read timeline elements on navigate (cross-origin)",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Pause video when tab loses focus (user switches away)
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden && usePlayerStore.getState().isPlaying) {
|
||||
const adapter = getAdapterRef.current?.();
|
||||
if (adapter) {
|
||||
adapter.pause();
|
||||
setIsPlaying(false);
|
||||
stopRAFLoop();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("message", handleMessage);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("message", handleMessage);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
stopRAFLoop();
|
||||
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
||||
reset();
|
||||
// Don't reset() on cleanup — preserve timeline elements across iframe refreshes
|
||||
// to prevent blink. New data will replace old when the iframe reloads.
|
||||
};
|
||||
});
|
||||
|
||||
/** Reset the player store (elements, duration, etc.) — call when switching sessions. */
|
||||
const resetPlayer = useCallback(() => {
|
||||
stopRAFLoop();
|
||||
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
||||
usePlayerStore.getState().reset();
|
||||
}, [stopRAFLoop]);
|
||||
|
||||
return {
|
||||
iframeRef,
|
||||
play,
|
||||
@@ -488,5 +773,6 @@ export function useTimelinePlayer() {
|
||||
onIframeLoad,
|
||||
refreshPlayer,
|
||||
saveSeekPosition,
|
||||
resetPlayer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ export { Player } from "./components/Player";
|
||||
export { PlayerControls } from "./components/PlayerControls";
|
||||
export { Timeline } from "./components/Timeline";
|
||||
export { PreviewPanel } from "./components/PreviewPanel";
|
||||
export { VideoThumbnail } from "./components/VideoThumbnail";
|
||||
export { CompositionThumbnail } from "./components/CompositionThumbnail";
|
||||
|
||||
// Hooks
|
||||
export { useTimelinePlayer } from "./hooks/useTimelinePlayer";
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatTime } from "./time";
|
||||
|
||||
describe("formatTime", () => {
|
||||
it("formats zero seconds", () => {
|
||||
expect(formatTime(0)).toBe("0:00");
|
||||
});
|
||||
|
||||
it("formats seconds less than a minute", () => {
|
||||
expect(formatTime(5)).toBe("0:05");
|
||||
expect(formatTime(30)).toBe("0:30");
|
||||
expect(formatTime(59)).toBe("0:59");
|
||||
});
|
||||
|
||||
it("formats exact minutes", () => {
|
||||
expect(formatTime(60)).toBe("1:00");
|
||||
expect(formatTime(120)).toBe("2:00");
|
||||
expect(formatTime(600)).toBe("10:00");
|
||||
});
|
||||
|
||||
it("formats minutes and seconds", () => {
|
||||
expect(formatTime(65)).toBe("1:05");
|
||||
expect(formatTime(90)).toBe("1:30");
|
||||
expect(formatTime(125)).toBe("2:05");
|
||||
});
|
||||
|
||||
it("formats large values (over an hour)", () => {
|
||||
expect(formatTime(3600)).toBe("60:00");
|
||||
expect(formatTime(3661)).toBe("61:01");
|
||||
expect(formatTime(7200)).toBe("120:00");
|
||||
});
|
||||
|
||||
it("floors fractional seconds", () => {
|
||||
expect(formatTime(0.9)).toBe("0:00");
|
||||
expect(formatTime(1.5)).toBe("0:01");
|
||||
expect(formatTime(59.99)).toBe("0:59");
|
||||
expect(formatTime(60.5)).toBe("1:00");
|
||||
});
|
||||
|
||||
it("pads single-digit seconds with leading zero", () => {
|
||||
expect(formatTime(1)).toBe("0:01");
|
||||
expect(formatTime(61)).toBe("1:01");
|
||||
expect(formatTime(609)).toBe("10:09");
|
||||
});
|
||||
|
||||
it("guards against negative values", () => {
|
||||
expect(formatTime(-1)).toBe("0:00");
|
||||
});
|
||||
|
||||
it("guards against NaN", () => {
|
||||
expect(formatTime(NaN)).toBe("0:00");
|
||||
});
|
||||
|
||||
it("guards against Infinity", () => {
|
||||
expect(formatTime(Infinity)).toBe("0:00");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
export function formatTime(time: number): string {
|
||||
if (!Number.isFinite(time) || time < 0) return "0:00";
|
||||
const mins = Math.floor(time / 60);
|
||||
const secs = Math.floor(time % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { usePlayerStore, liveTime, type TimelineElement } from "./playerStore";
|
||||
|
||||
describe("usePlayerStore", () => {
|
||||
beforeEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("has correct defaults", () => {
|
||||
const state = usePlayerStore.getState();
|
||||
expect(state.isPlaying).toBe(false);
|
||||
expect(state.currentTime).toBe(0);
|
||||
expect(state.duration).toBe(0);
|
||||
expect(state.timelineReady).toBe(false);
|
||||
expect(state.elements).toEqual([]);
|
||||
expect(state.selectedElementId).toBeNull();
|
||||
expect(state.playbackRate).toBe(1);
|
||||
expect(state.zoomMode).toBe("fit");
|
||||
expect(state.pixelsPerSecond).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setIsPlaying", () => {
|
||||
it("sets isPlaying to true", () => {
|
||||
usePlayerStore.getState().setIsPlaying(true);
|
||||
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
||||
});
|
||||
|
||||
it("sets isPlaying to false", () => {
|
||||
usePlayerStore.getState().setIsPlaying(true);
|
||||
usePlayerStore.getState().setIsPlaying(false);
|
||||
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCurrentTime", () => {
|
||||
it("updates currentTime", () => {
|
||||
usePlayerStore.getState().setCurrentTime(12.5);
|
||||
expect(usePlayerStore.getState().currentTime).toBe(12.5);
|
||||
});
|
||||
|
||||
it("accepts zero", () => {
|
||||
usePlayerStore.getState().setCurrentTime(42);
|
||||
usePlayerStore.getState().setCurrentTime(0);
|
||||
expect(usePlayerStore.getState().currentTime).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setDuration", () => {
|
||||
it("updates duration", () => {
|
||||
usePlayerStore.getState().setDuration(120);
|
||||
expect(usePlayerStore.getState().duration).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPlaybackRate", () => {
|
||||
it("updates playbackRate", () => {
|
||||
usePlayerStore.getState().setPlaybackRate(2);
|
||||
expect(usePlayerStore.getState().playbackRate).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTimelineReady", () => {
|
||||
it("updates timelineReady", () => {
|
||||
usePlayerStore.getState().setTimelineReady(true);
|
||||
expect(usePlayerStore.getState().timelineReady).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setElements", () => {
|
||||
it("sets the elements array", () => {
|
||||
const elements: TimelineElement[] = [
|
||||
{ id: "el-1", tag: "div", start: 0, duration: 5, track: 0 },
|
||||
{
|
||||
id: "el-2",
|
||||
tag: "video",
|
||||
start: 2,
|
||||
duration: 10,
|
||||
track: 1,
|
||||
src: "test.mp4",
|
||||
},
|
||||
];
|
||||
usePlayerStore.getState().setElements(elements);
|
||||
expect(usePlayerStore.getState().elements).toEqual(elements);
|
||||
expect(usePlayerStore.getState().elements).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("replaces existing elements", () => {
|
||||
usePlayerStore
|
||||
.getState()
|
||||
.setElements([{ id: "el-1", tag: "div", start: 0, duration: 5, track: 0 }]);
|
||||
usePlayerStore
|
||||
.getState()
|
||||
.setElements([{ id: "el-3", tag: "span", start: 1, duration: 3, track: 0 }]);
|
||||
const elements = usePlayerStore.getState().elements;
|
||||
expect(elements).toHaveLength(1);
|
||||
expect(elements[0].id).toBe("el-3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSelectedElementId", () => {
|
||||
it("selects an element", () => {
|
||||
usePlayerStore.getState().setSelectedElementId("el-1");
|
||||
expect(usePlayerStore.getState().selectedElementId).toBe("el-1");
|
||||
});
|
||||
|
||||
it("clears selection with null", () => {
|
||||
usePlayerStore.getState().setSelectedElementId("el-1");
|
||||
usePlayerStore.getState().setSelectedElementId(null);
|
||||
expect(usePlayerStore.getState().selectedElementId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateElementStart", () => {
|
||||
it("updates the start time of a specific element", () => {
|
||||
usePlayerStore.getState().setElements([
|
||||
{ id: "el-1", tag: "div", start: 0, duration: 5, track: 0 },
|
||||
{ id: "el-2", tag: "div", start: 5, duration: 5, track: 1 },
|
||||
]);
|
||||
usePlayerStore.getState().updateElementStart("el-1", 3);
|
||||
const elements = usePlayerStore.getState().elements;
|
||||
expect(elements[0].start).toBe(3);
|
||||
expect(elements[1].start).toBe(5); // unchanged
|
||||
});
|
||||
|
||||
it("does not modify elements when id is not found", () => {
|
||||
const original: TimelineElement[] = [
|
||||
{ id: "el-1", tag: "div", start: 0, duration: 5, track: 0 },
|
||||
];
|
||||
usePlayerStore.getState().setElements(original);
|
||||
usePlayerStore.getState().updateElementStart("nonexistent", 10);
|
||||
expect(usePlayerStore.getState().elements[0].start).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setZoomMode", () => {
|
||||
it("changes zoom mode to manual", () => {
|
||||
usePlayerStore.getState().setZoomMode("manual");
|
||||
expect(usePlayerStore.getState().zoomMode).toBe("manual");
|
||||
});
|
||||
|
||||
it("changes zoom mode back to fit", () => {
|
||||
usePlayerStore.getState().setZoomMode("manual");
|
||||
usePlayerStore.getState().setZoomMode("fit");
|
||||
expect(usePlayerStore.getState().zoomMode).toBe("fit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPixelsPerSecond", () => {
|
||||
it("updates pixelsPerSecond", () => {
|
||||
usePlayerStore.getState().setPixelsPerSecond(200);
|
||||
expect(usePlayerStore.getState().pixelsPerSecond).toBe(200);
|
||||
});
|
||||
|
||||
it("clamps to minimum of 10", () => {
|
||||
usePlayerStore.getState().setPixelsPerSecond(5);
|
||||
expect(usePlayerStore.getState().pixelsPerSecond).toBe(10);
|
||||
});
|
||||
|
||||
it("clamps negative values to 10", () => {
|
||||
usePlayerStore.getState().setPixelsPerSecond(-50);
|
||||
expect(usePlayerStore.getState().pixelsPerSecond).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reset", () => {
|
||||
it("resets all state to defaults", () => {
|
||||
// Mutate everything
|
||||
const store = usePlayerStore.getState();
|
||||
store.setIsPlaying(true);
|
||||
store.setCurrentTime(42);
|
||||
store.setDuration(120);
|
||||
store.setTimelineReady(true);
|
||||
store.setElements([{ id: "el-1", tag: "div", start: 0, duration: 5, track: 0 }]);
|
||||
store.setSelectedElementId("el-1");
|
||||
|
||||
// Reset
|
||||
usePlayerStore.getState().reset();
|
||||
|
||||
const state = usePlayerStore.getState();
|
||||
expect(state.isPlaying).toBe(false);
|
||||
expect(state.currentTime).toBe(0);
|
||||
expect(state.duration).toBe(0);
|
||||
expect(state.timelineReady).toBe(false);
|
||||
expect(state.elements).toEqual([]);
|
||||
expect(state.selectedElementId).toBeNull();
|
||||
});
|
||||
|
||||
it("does not reset playbackRate, zoomMode, or pixelsPerSecond", () => {
|
||||
const store = usePlayerStore.getState();
|
||||
store.setPlaybackRate(2);
|
||||
store.setZoomMode("manual");
|
||||
store.setPixelsPerSecond(200);
|
||||
|
||||
usePlayerStore.getState().reset();
|
||||
|
||||
const state = usePlayerStore.getState();
|
||||
// reset() only resets the fields explicitly listed in the reset function
|
||||
expect(state.playbackRate).toBe(2);
|
||||
expect(state.zoomMode).toBe("manual");
|
||||
expect(state.pixelsPerSecond).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveTime", () => {
|
||||
it("notifies subscribers with the current time", () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = liveTime.subscribe(listener);
|
||||
|
||||
liveTime.notify(5.5);
|
||||
expect(listener).toHaveBeenCalledWith(5.5);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
liveTime.notify(10);
|
||||
expect(listener).toHaveBeenCalledWith(10);
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("supports multiple subscribers", () => {
|
||||
const listener1 = vi.fn();
|
||||
const listener2 = vi.fn();
|
||||
const unsub1 = liveTime.subscribe(listener1);
|
||||
const unsub2 = liveTime.subscribe(listener2);
|
||||
|
||||
liveTime.notify(3);
|
||||
expect(listener1).toHaveBeenCalledWith(3);
|
||||
expect(listener2).toHaveBeenCalledWith(3);
|
||||
|
||||
unsub1();
|
||||
unsub2();
|
||||
});
|
||||
|
||||
it("unsubscribe stops notifications", () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = liveTime.subscribe(listener);
|
||||
|
||||
liveTime.notify(1);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
liveTime.notify(2);
|
||||
expect(listener).toHaveBeenCalledTimes(1); // not called again
|
||||
});
|
||||
|
||||
it("unsubscribe returns true when listener existed", () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = liveTime.subscribe(listener);
|
||||
// Set.delete returns boolean, our unsubscribe wraps it
|
||||
const result = unsubscribe();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("double unsubscribe returns false", () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = liveTime.subscribe(listener);
|
||||
unsubscribe();
|
||||
const result = unsubscribe();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user