{SPEED_OPTIONS.map((rate) => (
diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts
new file mode 100644
index 000000000..aa81613f2
--- /dev/null
+++ b/packages/studio/src/player/components/Timeline.test.ts
@@ -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");
+ });
+});
diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx
index 8f79f4145..a4af3160e 100644
--- a/packages/studio/src/player/components/Timeline.tsx
+++ b/packages/studio/src/player/components/Timeline.tsx
@@ -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
(null);
const containerRef = useRef(null);
+ const scrollRef = useRef(null);
const [hoveredClip, setHoveredClip] = useState(null);
const isDragging = useRef(false);
+ const [viewportWidth, setViewportWidth] = useState(0);
+ const roRef = useRef(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]);
+
+ const [isDragOver, setIsDragOver] = useState(false);
- if (!timelineReady) return null;
- if (elements.length === 0) {
+ if (!timelineReady || elements.length === 0) {
return (
-
- No timeline elements
+
{
+ 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 */}
+
+ {[0, 10, 20, 30, 40, 50].map((s) => (
+
+
+ {`${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`}
+
+
+
+ ))}
+
+ {/* Empty drop zone */}
+
+
+ {isDragOver ? (
+ <>
+
+
Drop media files to import
+ >
+ ) : (
+ <>
+
+
+ {onFileDrop
+ ? "Drop media here or describe your video to start"
+ : "Describe your video to start creating"}
+
+ >
+ )}
+
+
);
}
@@ -251,189 +469,195 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
return (
-
- {/* Grid lines */}
-
+
+
+ {/* Grid lines */}
+
- {/* Ruler */}
-
- {minor.map((t) => (
-
- ))}
- {major.map((t) => (
-
-
- {formatTick(t)}
-
-
-
- ))}
-
+ {/* Ruler */}
+
+ {minor.map((t) => (
+
+ ))}
+ {major.map((t) => (
+
+
+ {formatTick(t)}
+
+
+
+ ))}
+
- {/* Tracks */}
- {tracks.map(([trackNum, els]) => {
- const ts = trackStyles.get(trackNum) ?? DEFAULT;
- return (
-
- {/* Gutter: colored icon badge (Figma HyperFrames style) */}
+ {/* Tracks */}
+ {tracks.map(([trackNum, els]) => {
+ const ts = trackStyles.get(trackNum) ?? DEFAULT;
+ return (
+ {/* Gutter: colored icon badge (Figma Motion Cut style) */}
- {ts.icon}
+
+ {ts.icon}
+
-
- {/* Clips */}
-
- {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 (
-
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);
- }
- }}
- >
-
+ {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 (
+ setHoveredClip(clipKey)}
+ onHoverEnd={() => setHoveredClip(null)}
+ onClick={(e) => {
+ e.stopPropagation();
+ setSelectedElementId(isSelected ? null : el.id);
+ }}
+ onDoubleClick={(e) => {
+ e.stopPropagation();
+ if (isComposition && onDrillDown) onDrillDown(el);
+ }}
>
- {el.id || el.tag}
-
- {widthPct > 10 && (
-
- {el.duration.toFixed(1)}s
-
- )}
-
- );
- })}
+ {renderClipContent?.(el, clipStyle) ?? (
+ <>
+
+ {el.id || el.tag}
+
+ {clipWidthPx > 60 && (
+
+ {el.duration.toFixed(1)}s
+
+ )}
+ >
+ )}
+
+
+ );
+ })}
+
-
- );
- })}
+ );
+ })}
- {/* Playhead */}
-
-
-
+ {/* Playhead — z-[100] to stay above all clips (which use z-1 to z-10) */}
+
diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx
new file mode 100644
index 000000000..edf298157
--- /dev/null
+++ b/packages/studio/src/player/components/TimelineClip.tsx
@@ -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 (
+
+ {children}
+
+ );
+});
diff --git a/packages/studio/src/player/components/VideoThumbnail.tsx b/packages/studio/src/player/components/VideoThumbnail.tsx
new file mode 100644
index 000000000..5cc325a65
--- /dev/null
+++ b/packages/studio/src/player/components/VideoThumbnail.tsx
@@ -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
+ *