@hyperframes/studio 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/assets/index-CLmYRLY-.css +1 -0
  2. package/dist/assets/index-CRvFpc0E.js +84 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +2 -2
  5. package/src/App.tsx +139 -657
  6. package/src/components/LintModal.tsx +149 -0
  7. package/src/components/MediaPreview.tsx +79 -0
  8. package/src/components/editor/FileTree.tsx +50 -40
  9. package/src/components/editor/PropertyPanel.tsx +3 -3
  10. package/src/components/nle/NLELayout.tsx +59 -43
  11. package/src/components/renders/RenderQueue.tsx +19 -16
  12. package/src/components/renders/RenderQueueItem.tsx +77 -19
  13. package/src/components/renders/useRenderQueue.ts +1 -0
  14. package/src/components/sidebar/AssetsTab.tsx +37 -149
  15. package/src/components/sidebar/CompositionsTab.tsx +48 -162
  16. package/src/components/sidebar/LeftSidebar.tsx +79 -8
  17. package/src/components/ui/VideoFrameThumbnail.tsx +50 -0
  18. package/src/index.ts +0 -3
  19. package/src/player/components/CompositionThumbnail.tsx +21 -95
  20. package/src/player/components/EditModal.tsx +5 -5
  21. package/src/player/components/Player.tsx +0 -1
  22. package/src/player/components/PlayerControls.tsx +56 -3
  23. package/src/player/components/Timeline.tsx +14 -18
  24. package/src/player/components/TimelineClip.tsx +0 -1
  25. package/src/player/index.ts +0 -1
  26. package/src/player/store/playerStore.ts +3 -28
  27. package/src/utils/mediaTypes.ts +9 -0
  28. package/dist/assets/index-BEwJNmPo.js +0 -92
  29. package/dist/assets/index-BnvciBdD.css +0 -1
  30. package/src/components/ui/ExpandOnHover.tsx +0 -194
  31. package/src/hooks/useCodeEditor.ts +0 -88
  32. package/src/player/components/PreviewPanel.tsx +0 -181
@@ -1,15 +1,18 @@
1
- import { memo, useState, useCallback } from "react";
1
+ import { memo, useState, useCallback, type ReactNode } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { CompositionsTab } from "./CompositionsTab";
4
4
  import { AssetsTab } from "./AssetsTab";
5
+ import { FileTree } from "../editor/FileTree";
5
6
 
6
- type SidebarTab = "compositions" | "assets";
7
+ type SidebarTab = "compositions" | "assets" | "code";
7
8
 
8
9
  const STORAGE_KEY = "hf-studio-sidebar-tab";
9
10
 
10
11
  function getPersistedTab(): SidebarTab {
11
12
  const stored = localStorage.getItem(STORAGE_KEY);
12
- return stored === "assets" ? "assets" : "compositions";
13
+ if (stored === "assets") return "assets";
14
+ if (stored === "code") return "code";
15
+ return "compositions";
13
16
  }
14
17
 
15
18
  interface LeftSidebarProps {
@@ -20,6 +23,12 @@ interface LeftSidebarProps {
20
23
  activeComposition: string | null;
21
24
  onSelectComposition: (comp: string) => void;
22
25
  onImportFiles?: (files: FileList) => void;
26
+ fileTree?: string[];
27
+ editingFile?: { path: string; content: string | null } | null;
28
+ onSelectFile?: (path: string) => void;
29
+ codeChildren?: ReactNode;
30
+ onLint?: () => void;
31
+ linting?: boolean;
23
32
  }
24
33
 
25
34
  export const LeftSidebar = memo(function LeftSidebar({
@@ -30,6 +39,12 @@ export const LeftSidebar = memo(function LeftSidebar({
30
39
  activeComposition,
31
40
  onSelectComposition,
32
41
  onImportFiles,
42
+ fileTree: fileProp,
43
+ editingFile,
44
+ onSelectFile,
45
+ codeChildren,
46
+ onLint,
47
+ linting,
33
48
  }: LeftSidebarProps) {
34
49
  const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
35
50
 
@@ -60,14 +75,25 @@ export const LeftSidebar = memo(function LeftSidebar({
60
75
  className="flex flex-col h-full bg-neutral-950 border-r border-neutral-800/50"
61
76
  style={{ width }}
62
77
  >
63
- {/* Tabs */}
78
+ {/* Tabs — Code first */}
64
79
  <div className="flex border-b border-neutral-800/50 flex-shrink-0">
80
+ <button
81
+ type="button"
82
+ onClick={() => selectTab("code")}
83
+ className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
84
+ tab === "code"
85
+ ? "text-neutral-200 border-b-2 border-studio-accent"
86
+ : "text-neutral-500 hover:text-neutral-400"
87
+ }`}
88
+ >
89
+ Code
90
+ </button>
65
91
  <button
66
92
  type="button"
67
93
  onClick={() => selectTab("compositions")}
68
94
  className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
69
95
  tab === "compositions"
70
- ? "text-neutral-200 border-b-2 border-blue-500"
96
+ ? "text-neutral-200 border-b-2 border-studio-accent"
71
97
  : "text-neutral-500 hover:text-neutral-400"
72
98
  }`}
73
99
  >
@@ -78,7 +104,7 @@ export const LeftSidebar = memo(function LeftSidebar({
78
104
  onClick={() => selectTab("assets")}
79
105
  className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
80
106
  tab === "assets"
81
- ? "text-neutral-200 border-b-2 border-blue-500"
107
+ ? "text-neutral-200 border-b-2 border-studio-accent"
82
108
  : "text-neutral-500 hover:text-neutral-400"
83
109
  }`}
84
110
  >
@@ -87,16 +113,61 @@ export const LeftSidebar = memo(function LeftSidebar({
87
113
  </div>
88
114
 
89
115
  {/* Tab content */}
90
- {tab === "compositions" ? (
116
+ {tab === "compositions" && (
91
117
  <CompositionsTab
92
118
  projectId={projectId}
93
119
  compositions={compositions}
94
120
  activeComposition={activeComposition}
95
121
  onSelect={onSelectComposition}
96
122
  />
97
- ) : (
123
+ )}
124
+ {tab === "assets" && (
98
125
  <AssetsTab projectId={projectId} assets={assets} onImport={onImportFiles} />
99
126
  )}
127
+ {tab === "code" && (
128
+ <div className="flex flex-1 min-h-0">
129
+ {(fileProp?.length ?? 0) > 0 && (
130
+ <div className="w-[140px] flex-shrink-0 border-r border-neutral-800 overflow-y-auto">
131
+ <FileTree
132
+ files={fileProp ?? []}
133
+ activeFile={editingFile?.path ?? null}
134
+ onSelectFile={onSelectFile ?? (() => {})}
135
+ />
136
+ </div>
137
+ )}
138
+ <div className="flex-1 overflow-hidden min-w-0">
139
+ {codeChildren ?? (
140
+ <div className="flex items-center justify-center h-full text-neutral-600 text-sm">
141
+ Select a file to edit
142
+ </div>
143
+ )}
144
+ </div>
145
+ </div>
146
+ )}
147
+
148
+ {/* Lint button pinned at the bottom */}
149
+ {onLint && (
150
+ <div className="border-t border-neutral-800 p-2 flex-shrink-0">
151
+ <button
152
+ onClick={onLint}
153
+ disabled={linting}
154
+ className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-medium text-neutral-500 hover:text-amber-300 hover:bg-neutral-800 transition-colors disabled:opacity-40"
155
+ >
156
+ <svg
157
+ width="12"
158
+ height="12"
159
+ viewBox="0 0 24 24"
160
+ fill="none"
161
+ stroke="currentColor"
162
+ strokeWidth="2"
163
+ >
164
+ <path d="M9 11l3 3L22 4" />
165
+ <path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
166
+ </svg>
167
+ {linting ? "Linting…" : "Lint"}
168
+ </button>
169
+ </div>
170
+ )}
100
171
  </div>
101
172
  );
102
173
  });
@@ -0,0 +1,50 @@
1
+ import { useState, useEffect } from "react";
2
+
3
+ /**
4
+ * Extracts a representative JPEG frame from a video URL using a hidden
5
+ * video + canvas. Seeks to ~10% of duration to avoid black opening frames.
6
+ * Used by AssetThumbnail (assets tab) and RenderQueueItem (renders tab).
7
+ */
8
+ export function VideoFrameThumbnail({ src }: { src: string }) {
9
+ const [frame, setFrame] = useState<string | null>(null);
10
+
11
+ useEffect(() => {
12
+ const video = document.createElement("video");
13
+ video.crossOrigin = "anonymous";
14
+ video.muted = true;
15
+ video.preload = "metadata";
16
+
17
+ const canvas = document.createElement("canvas");
18
+ const ctx = canvas.getContext("2d");
19
+
20
+ const cleanup = () => {
21
+ video.src = "";
22
+ video.load();
23
+ };
24
+
25
+ video.addEventListener("loadedmetadata", () => {
26
+ video.currentTime = Math.min(2, video.duration * 0.1 || 2);
27
+ });
28
+
29
+ video.addEventListener("seeked", () => {
30
+ if (!ctx) return;
31
+ canvas.width = video.videoWidth;
32
+ canvas.height = video.videoHeight;
33
+ ctx.drawImage(video, 0, 0);
34
+ setFrame(canvas.toDataURL("image/jpeg", 0.7));
35
+ cleanup();
36
+ });
37
+
38
+ video.addEventListener("error", cleanup);
39
+ video.src = src;
40
+ video.load();
41
+
42
+ return cleanup;
43
+ }, [src]);
44
+
45
+ if (!frame) {
46
+ return <div className="w-full h-full bg-neutral-800 animate-pulse" />;
47
+ }
48
+
49
+ return <img src={frame} alt="" draggable={false} className="w-full h-full object-contain" />;
50
+ }
package/src/index.ts CHANGED
@@ -9,7 +9,6 @@ export {
9
9
  Player,
10
10
  PlayerControls,
11
11
  Timeline,
12
- PreviewPanel,
13
12
  VideoThumbnail,
14
13
  CompositionThumbnail,
15
14
  useTimelinePlayer,
@@ -28,8 +27,6 @@ export { FileTree } from "./components/editor/FileTree";
28
27
  export { StudioApp } from "./App";
29
28
 
30
29
  // Hooks
31
- export { useCodeEditor } from "./hooks/useCodeEditor";
32
- export type { OpenFile, UseCodeEditorReturn } from "./hooks/useCodeEditor";
33
30
  export { useElementPicker } from "./hooks/useElementPicker";
34
31
  export type { PickedElement } from "./hooks/useElementPicker";
35
32
 
@@ -1,18 +1,12 @@
1
1
  /**
2
- * CompositionThumbnail — Film-strip of server-rendered JPEG thumbnails.
2
+ * CompositionThumbnail — Single server-rendered JPEG stretched across the clip.
3
3
  *
4
- * Requests multiple thumbnails at different timestamps across the clip duration
5
- * and tiles them horizontally like VideoThumbnail does for video clips.
6
- * Each frame is a separate <img> from /api/projects/:id/thumbnail/:path?t=X.
7
- *
8
- * Uses ResizeObserver to adapt frame count when the clip width changes (zoom).
4
+ * Takes one screenshot at the midpoint of the clip and covers the full width —
5
+ * same approach as After Effects for precomps. This avoids the 1-2s per-frame
6
+ * Puppeteer cost of rendering multiple filmstrip frames.
9
7
  */
10
8
 
11
- import { memo, useRef, useState, useCallback } from "react";
12
- import { useMountEffect } from "../../hooks/useMountEffect";
13
-
14
- const CLIP_HEIGHT = 66;
15
- const MAX_UNIQUE_FRAMES = 6;
9
+ import { memo } from "react";
16
10
 
17
11
  interface CompositionThumbnailProps {
18
12
  previewUrl: string;
@@ -28,97 +22,29 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
28
22
  previewUrl,
29
23
  label,
30
24
  labelColor,
31
- seekTime = 0.4,
25
+ seekTime = 2,
32
26
  duration = 5,
33
- width = 1920,
34
- height = 1080,
35
27
  }: CompositionThumbnailProps) {
36
- const [containerWidth, setContainerWidth] = useState(0);
37
- const roRef = useRef<ResizeObserver | null>(null);
38
-
39
- const setRef = useCallback((el: HTMLDivElement | null) => {
40
- roRef.current?.disconnect();
41
- if (!el) return;
42
-
43
- // Walk up to data-clip parent for accurate width
44
- let target: HTMLElement = el;
45
- let parent = el.parentElement;
46
- let depth = 0;
47
- while (parent && !parent.hasAttribute("data-clip") && depth < 5) {
48
- parent = parent.parentElement;
49
- depth++;
50
- }
51
- if (parent?.hasAttribute("data-clip")) target = parent;
52
-
53
- requestAnimationFrame(() => {
54
- const w = target.clientWidth || target.getBoundingClientRect().width;
55
- if (w > 0) setContainerWidth(w);
56
- });
57
-
58
- roRef.current = new ResizeObserver(([entry]) => setContainerWidth(entry.contentRect.width));
59
- roRef.current.observe(target);
60
- }, []);
61
-
62
- useMountEffect(() => () => {
63
- roRef.current?.disconnect();
64
- });
65
-
66
- // Convert preview URL to thumbnail base URL
28
+ // Single screenshot at the midpoint of the clip
67
29
  const thumbnailBase = previewUrl
68
30
  .replace("/preview/comp/", "/thumbnail/")
69
31
  .replace(/\/preview$/, "/thumbnail/index.html");
70
-
71
- // Calculate frame layout
72
- const aspect = width / height;
73
- const frameW = Math.round(CLIP_HEIGHT * aspect);
74
- const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
75
- const uniqueFrames = Math.min(frameCount, MAX_UNIQUE_FRAMES);
76
-
77
- // Each frame tile represents a real position in the clip.
78
- // Offset slightly (0.5s) into each segment to avoid landing on transition
79
- // points where content is invisible due to fade-in/fade-out animations.
80
- const timestamps: number[] = [];
81
- const pad = Math.min(0.5, duration * 0.05);
82
- for (let i = 0; i < uniqueFrames; i++) {
83
- const frac = uniqueFrames === 1 ? 0.5 : i / (uniqueFrames - 1);
84
- const raw = seekTime + frac * duration;
85
- // Clamp to [pad, duration - pad] to stay inside visible content
86
- timestamps.push(seekTime + Math.max(pad, Math.min(duration - pad, raw - seekTime)));
87
- }
32
+ const midTime = seekTime + duration / 2;
33
+ const url = `${thumbnailBase}?t=${midTime.toFixed(2)}`;
88
34
 
89
35
  return (
90
- <div ref={setRef} className="absolute inset-0 overflow-hidden bg-neutral-950">
91
- {/* Film strip — each tile maps to its real timeline position */}
92
- <div className="absolute inset-0 flex">
93
- {Array.from({ length: frameCount }).map((_, i) => {
94
- // Map this tile's visual position to a timestamp
95
- const tileFrac = frameCount === 1 ? 0.5 : i / (frameCount - 1);
96
- const t = seekTime + tileFrac * duration;
97
- // Use the nearest cached unique frame
98
- const uniqueIdx = Math.min(Math.round(tileFrac * (uniqueFrames - 1)), uniqueFrames - 1);
99
- const cachedT = timestamps[uniqueIdx];
100
- const url = `${thumbnailBase}?t=${(cachedT ?? t).toFixed(2)}`;
101
- return (
102
- <div
103
- key={i}
104
- className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
105
- style={{ width: frameW }}
106
- >
107
- <img
108
- src={url}
109
- alt=""
110
- draggable={false}
111
- loading="lazy"
112
- onLoad={(e) => {
113
- (e.target as HTMLImageElement).style.opacity = "1";
114
- }}
115
- className="absolute inset-0 w-full h-full object-cover"
116
- style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
117
- />
118
- </div>
119
- );
120
- })}
121
- </div>
36
+ <div className="absolute inset-0 overflow-hidden bg-neutral-950">
37
+ <img
38
+ src={url}
39
+ alt=""
40
+ draggable={false}
41
+ loading="lazy"
42
+ onLoad={(e) => {
43
+ (e.target as HTMLImageElement).style.opacity = "1";
44
+ }}
45
+ className="absolute inset-0 w-full h-full object-cover"
46
+ style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
47
+ />
122
48
 
123
49
  {/* Label */}
124
50
  <div
@@ -105,7 +105,7 @@ Preserve all other elements and timing outside this range.`;
105
105
  {/* Header */}
106
106
  <div className="flex items-center justify-between px-4 py-2.5 border-b border-neutral-800/60">
107
107
  <div className="flex items-center gap-2">
108
- <div className="w-1.5 h-1.5 rounded-full bg-blue-400" />
108
+ <div className="w-1.5 h-1.5 rounded-full bg-studio-accent" />
109
109
  <span className="text-[11px] font-medium text-neutral-300">
110
110
  {formatTime(start)} — {formatTime(end)}
111
111
  </span>
@@ -120,7 +120,7 @@ Preserve all other elements and timing outside this range.`;
120
120
  <div className="px-4 py-2 border-b border-neutral-800/40 max-h-24 overflow-y-auto">
121
121
  {elementsInRange.map((el) => (
122
122
  <div key={el.id} className="flex items-center justify-between py-0.5">
123
- <span className="text-[10px] font-mono text-blue-400/80">#{el.id}</span>
123
+ <span className="text-[10px] font-mono text-studio-accent/80">#{el.id}</span>
124
124
  <span className="text-[10px] text-neutral-600">{el.tag}</span>
125
125
  </div>
126
126
  ))}
@@ -141,7 +141,7 @@ Preserve all other elements and timing outside this range.`;
141
141
  }}
142
142
  placeholder="What should change?"
143
143
  rows={2}
144
- className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-blue-500/40 transition-colors"
144
+ className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-studio-accent/40 transition-colors"
145
145
  />
146
146
  </div>
147
147
 
@@ -152,11 +152,11 @@ Preserve all other elements and timing outside this range.`;
152
152
  className={`w-full py-1.5 text-[11px] font-medium rounded-lg transition-all ${
153
153
  copied
154
154
  ? "bg-green-500/20 text-green-400 border border-green-500/30"
155
- : "bg-blue-500/15 text-blue-400 border border-blue-500/25 hover:bg-blue-500/25"
155
+ : "bg-studio-accent/15 text-studio-accent border border-studio-accent/25 hover:bg-studio-accent/25"
156
156
  }`}
157
157
  >
158
158
  {copied ? "Copied!" : "Copy to Agent"}
159
- {!copied && <span className="text-[9px] text-blue-400/50 ml-1.5">Cmd+Enter</span>}
159
+ {!copied && <span className="text-[9px] text-studio-accent/50 ml-1.5">Cmd+Enter</span>}
160
160
  </button>
161
161
  </div>
162
162
  </div>
@@ -117,7 +117,6 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
117
117
  width: dims.w,
118
118
  height: dims.h,
119
119
  border: "none",
120
- outline: "1px solid black",
121
120
  transform: `scale(${scale})`,
122
121
  transformOrigin: "center center",
123
122
  flexShrink: 0,
@@ -1,4 +1,4 @@
1
- import { useRef, useState, useCallback, memo } from "react";
1
+ import { useRef, useState, useCallback, useEffect, memo } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { formatTime } from "../lib/time";
4
4
  import { usePlayerStore, liveTime } from "../store/playerStore";
@@ -8,11 +8,15 @@ const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
8
8
  interface PlayerControlsProps {
9
9
  onTogglePlay: () => void;
10
10
  onSeek: (time: number) => void;
11
+ timelineVisible?: boolean;
12
+ onToggleTimeline?: () => void;
11
13
  }
12
14
 
13
15
  export const PlayerControls = memo(function PlayerControls({
14
16
  onTogglePlay,
15
17
  onSeek,
18
+ timelineVisible,
19
+ onToggleTimeline,
16
20
  }: PlayerControlsProps) {
17
21
  // Subscribe to only the fields we render — each selector prevents cascading re-renders
18
22
  const isPlaying = usePlayerStore((s) => s.isPlaying);
@@ -26,6 +30,8 @@ export const PlayerControls = memo(function PlayerControls({
26
30
  const progressThumbRef = useRef<HTMLDivElement>(null);
27
31
  const timeDisplayRef = useRef<HTMLSpanElement>(null);
28
32
  const seekBarRef = useRef<HTMLDivElement>(null);
33
+ const sliderRef = useRef<HTMLDivElement>(null);
34
+ const speedMenuContainerRef = useRef<HTMLDivElement>(null);
29
35
  const isDraggingRef = useRef(false);
30
36
  const currentTimeRef = useRef(0);
31
37
 
@@ -39,6 +45,7 @@ export const PlayerControls = memo(function PlayerControls({
39
45
  if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
40
46
  if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
41
47
  if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t);
48
+ if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
42
49
  };
43
50
  const unsub = liveTime.subscribe(updateProgress);
44
51
  updateProgress(usePlayerStore.getState().currentTime);
@@ -60,6 +67,22 @@ export const PlayerControls = memo(function PlayerControls({
60
67
  };
61
68
  });
62
69
 
70
+ useEffect(() => {
71
+ if (!showSpeedMenu) return;
72
+ const handleMouseDown = (e: MouseEvent) => {
73
+ if (
74
+ speedMenuContainerRef.current &&
75
+ !speedMenuContainerRef.current.contains(e.target as Node)
76
+ ) {
77
+ setShowSpeedMenu(false);
78
+ }
79
+ };
80
+ document.addEventListener("mousedown", handleMouseDown);
81
+ return () => {
82
+ document.removeEventListener("mousedown", handleMouseDown);
83
+ };
84
+ }, [showSpeedMenu]);
85
+
63
86
  const seekFromClientX = useCallback(
64
87
  (clientX: number) => {
65
88
  const bar = seekBarRef.current;
@@ -149,7 +172,10 @@ export const PlayerControls = memo(function PlayerControls({
149
172
 
150
173
  {/* Seek bar — teal progress fill */}
151
174
  <div
152
- ref={seekBarRef}
175
+ ref={(el) => {
176
+ (seekBarRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
177
+ (sliderRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
178
+ }}
153
179
  role="slider"
154
180
  tabIndex={0}
155
181
  aria-label="Seek"
@@ -184,7 +210,7 @@ export const PlayerControls = memo(function PlayerControls({
184
210
  </div>
185
211
 
186
212
  {/* Speed control */}
187
- <div className="relative flex-shrink-0">
213
+ <div ref={speedMenuContainerRef} className="relative flex-shrink-0">
188
214
  <button
189
215
  type="button"
190
216
  onClick={() => setShowSpeedMenu((v) => !v)}
@@ -224,6 +250,33 @@ export const PlayerControls = memo(function PlayerControls({
224
250
  </div>
225
251
  )}
226
252
  </div>
253
+
254
+ {/* Timeline toggle */}
255
+ {onToggleTimeline !== undefined && (
256
+ <button
257
+ onClick={onToggleTimeline}
258
+ className={`w-7 h-7 flex items-center justify-center rounded-md border transition-colors ${
259
+ timelineVisible
260
+ ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
261
+ : "border-neutral-700 text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
262
+ }`}
263
+ title={timelineVisible ? "Hide timeline" : "Show timeline"}
264
+ >
265
+ <svg
266
+ width="13"
267
+ height="13"
268
+ viewBox="0 0 24 24"
269
+ fill="none"
270
+ stroke="currentColor"
271
+ strokeWidth="2"
272
+ strokeLinecap="round"
273
+ >
274
+ <rect x="3" y="13" width="18" height="8" rx="1" />
275
+ <line x1="3" y1="9" x2="21" y2="9" />
276
+ <line x1="3" y1="5" x2="21" y2="5" />
277
+ </svg>
278
+ </button>
279
+ )}
227
280
  </div>
228
281
  );
229
282
  });
@@ -152,9 +152,6 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
152
152
  return { major, minor };
153
153
  }
154
154
 
155
- /** @deprecated Use formatTime from '../lib/time' instead */
156
- export const formatTick = formatTime;
157
-
158
155
  /* ── Component ──────────────────────────────────────────────────── */
159
156
  interface TimelineProps {
160
157
  /** Called when user seeks via ruler/track click or playhead drag */
@@ -170,11 +167,6 @@ interface TimelineProps {
170
167
  renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
171
168
  /** Called when files are dropped onto the empty timeline */
172
169
  onFileDrop?: (files: File[]) => void;
173
- /** Called when a clip is moved, resized, or changes track via drag */
174
- onClipChange?: (
175
- elementId: string,
176
- updates: { start?: number; duration?: number; track?: number },
177
- ) => void;
178
170
  }
179
171
 
180
172
  export const Timeline = memo(function Timeline({
@@ -346,12 +338,11 @@ export const Timeline = memo(function Timeline({
346
338
 
347
339
  const handlePointerDown = useCallback(
348
340
  (e: React.PointerEvent) => {
349
- if ((e.target as HTMLElement).closest("[data-clip]")) return;
350
341
  if (e.button !== 0) return;
351
- (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
352
342
 
353
- // Shift+click starts range selection
343
+ // Shift+click starts range selection — even on clips
354
344
  if (e.shiftKey) {
345
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
355
346
  isRangeSelecting.current = true;
356
347
  setShowPopover(false);
357
348
  const rect = scrollRef.current?.getBoundingClientRect();
@@ -364,6 +355,10 @@ export const Timeline = memo(function Timeline({
364
355
  return;
365
356
  }
366
357
 
358
+ // Normal click on a clip — let the clip handle it
359
+ if ((e.target as HTMLElement).closest("[data-clip]")) return;
360
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
361
+
367
362
  isDragging.current = true;
368
363
  setRangeSelection(null);
369
364
  setShowPopover(false);
@@ -434,7 +429,7 @@ export const Timeline = memo(function Timeline({
434
429
  return (
435
430
  <div
436
431
  className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
437
- isDragOver ? "border-blue-500/50 bg-blue-500/[0.03]" : "border-neutral-800/50"
432
+ isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50"
438
433
  }`}
439
434
  onDragOver={(e) => {
440
435
  e.preventDefault();
@@ -471,7 +466,9 @@ export const Timeline = memo(function Timeline({
471
466
  <div className="flex-1 flex items-center justify-center">
472
467
  <div
473
468
  className={`flex items-center gap-3 px-6 py-3 border border-dashed rounded-lg transition-colors duration-150 ${
474
- isDragOver ? "border-blue-400/60 bg-blue-500/[0.06]" : "border-neutral-700/50"
469
+ isDragOver
470
+ ? "border-studio-accent/60 bg-studio-accent/[0.06]"
471
+ : "border-neutral-700/50"
475
472
  }`}
476
473
  >
477
474
  {isDragOver ? (
@@ -485,13 +482,13 @@ export const Timeline = memo(function Timeline({
485
482
  strokeWidth="1.5"
486
483
  strokeLinecap="round"
487
484
  strokeLinejoin="round"
488
- className="text-blue-400 flex-shrink-0"
485
+ className="text-studio-accent flex-shrink-0"
489
486
  >
490
487
  <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
491
488
  <polyline points="7 10 12 15 17 10" />
492
489
  <line x1="12" y1="15" x2="12" y2="3" />
493
490
  </svg>
494
- <span className="text-[13px] text-blue-400">Drop media files to import</span>
491
+ <span className="text-[13px] text-studio-accent">Drop media files to import</span>
495
492
  </>
496
493
  ) : (
497
494
  <>
@@ -573,7 +570,7 @@ export const Timeline = memo(function Timeline({
573
570
  {/* Shift hint */}
574
571
  {shiftHeld && !rangeSelection && (
575
572
  <div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
576
- <span className="text-[9px] text-blue-400/60 font-medium">
573
+ <span className="text-[9px] text-studio-accent/60 font-medium">
577
574
  Drag to select range
578
575
  </span>
579
576
  </div>
@@ -642,7 +639,6 @@ export const Timeline = memo(function Timeline({
642
639
  key={clipKey}
643
640
  el={el}
644
641
  pps={pps}
645
- trackH={TRACK_H}
646
642
  clipY={CLIP_Y}
647
643
  isSelected={isSelected}
648
644
  isHovered={isHovered}
@@ -749,7 +745,7 @@ export const Timeline = memo(function Timeline({
749
745
 
750
746
  {/* Keyboard shortcut hint — always visible */}
751
747
  {!showPopover && !rangeSelection && (
752
- <div className="absolute bottom-2 right-3 pointer-events-none">
748
+ <div className="absolute bottom-2 right-3 pointer-events-none z-20">
753
749
  <div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-neutral-800/50 border border-neutral-700/20">
754
750
  <kbd className="text-[9px] font-mono text-neutral-500 bg-neutral-700/40 px-1 py-0.5 rounded">
755
751
  Shift
@@ -6,7 +6,6 @@ import type { TimelineElement } from "../store/playerStore";
6
6
  interface TimelineClipProps {
7
7
  el: TimelineElement;
8
8
  pps: number;
9
- trackH: number;
10
9
  clipY: number;
11
10
  isSelected: boolean;
12
11
  isHovered: boolean;
@@ -2,7 +2,6 @@
2
2
  export { Player } from "./components/Player";
3
3
  export { PlayerControls } from "./components/PlayerControls";
4
4
  export { Timeline } from "./components/Timeline";
5
- export { PreviewPanel } from "./components/PreviewPanel";
6
5
  export { VideoThumbnail } from "./components/VideoThumbnail";
7
6
  export { CompositionThumbnail } from "./components/CompositionThumbnail";
8
7