@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.
- package/dist/assets/index-CLmYRLY-.css +1 -0
- package/dist/assets/index-CRvFpc0E.js +84 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/App.tsx +139 -657
- package/src/components/LintModal.tsx +149 -0
- package/src/components/MediaPreview.tsx +79 -0
- package/src/components/editor/FileTree.tsx +50 -40
- package/src/components/editor/PropertyPanel.tsx +3 -3
- package/src/components/nle/NLELayout.tsx +59 -43
- package/src/components/renders/RenderQueue.tsx +19 -16
- package/src/components/renders/RenderQueueItem.tsx +77 -19
- package/src/components/renders/useRenderQueue.ts +1 -0
- package/src/components/sidebar/AssetsTab.tsx +37 -149
- package/src/components/sidebar/CompositionsTab.tsx +48 -162
- package/src/components/sidebar/LeftSidebar.tsx +79 -8
- package/src/components/ui/VideoFrameThumbnail.tsx +50 -0
- package/src/index.ts +0 -3
- package/src/player/components/CompositionThumbnail.tsx +21 -95
- package/src/player/components/EditModal.tsx +5 -5
- package/src/player/components/Player.tsx +0 -1
- package/src/player/components/PlayerControls.tsx +56 -3
- package/src/player/components/Timeline.tsx +14 -18
- package/src/player/components/TimelineClip.tsx +0 -1
- package/src/player/index.ts +0 -1
- package/src/player/store/playerStore.ts +3 -28
- package/src/utils/mediaTypes.ts +9 -0
- package/dist/assets/index-BEwJNmPo.js +0 -92
- package/dist/assets/index-BnvciBdD.css +0 -1
- package/src/components/ui/ExpandOnHover.tsx +0 -194
- package/src/hooks/useCodeEditor.ts +0 -88
- 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
|
-
|
|
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-
|
|
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-
|
|
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 —
|
|
2
|
+
* CompositionThumbnail — Single server-rendered JPEG stretched across the clip.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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 =
|
|
25
|
+
seekTime = 2,
|
|
32
26
|
duration = 5,
|
|
33
|
-
width = 1920,
|
|
34
|
-
height = 1080,
|
|
35
27
|
}: CompositionThumbnailProps) {
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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>
|
|
@@ -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={
|
|
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-
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
package/src/player/index.ts
CHANGED
|
@@ -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
|
|