@hyperframes/studio 0.1.0
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-B1830ANq.js +78 -0
- package/dist/assets/index-KoBceNoU.css +1 -0
- package/dist/icons/timeline/audio.svg +7 -0
- package/dist/icons/timeline/captions.svg +5 -0
- package/dist/icons/timeline/composition.svg +12 -0
- package/dist/icons/timeline/image.svg +18 -0
- package/dist/icons/timeline/music.svg +10 -0
- package/dist/icons/timeline/text.svg +3 -0
- package/dist/index.html +13 -0
- package/package.json +50 -0
- package/src/App.tsx +557 -0
- package/src/components/editor/FileTree.tsx +70 -0
- package/src/components/editor/PropertyPanel.tsx +209 -0
- package/src/components/editor/SourceEditor.tsx +116 -0
- package/src/components/nle/CompositionBreadcrumb.tsx +57 -0
- package/src/components/nle/NLELayout.tsx +252 -0
- package/src/components/nle/NLEPreview.tsx +37 -0
- package/src/components/ui/Button.tsx +123 -0
- package/src/components/ui/index.ts +2 -0
- package/src/hooks/useCodeEditor.ts +82 -0
- package/src/hooks/useElementPicker.ts +338 -0
- package/src/hooks/useMountEffect.ts +18 -0
- package/src/icons/SystemIcons.tsx +130 -0
- package/src/index.ts +31 -0
- package/src/main.tsx +10 -0
- package/src/player/components/AgentActivityTrack.tsx +98 -0
- package/src/player/components/Player.tsx +120 -0
- package/src/player/components/PlayerControls.tsx +181 -0
- package/src/player/components/PreviewPanel.tsx +149 -0
- package/src/player/components/Timeline.tsx +431 -0
- package/src/player/hooks/useTimelinePlayer.ts +465 -0
- package/src/player/index.ts +17 -0
- package/src/player/lib/time.ts +5 -0
- package/src/player/lib/useMountEffect.ts +10 -0
- package/src/player/store/playerStore.ts +93 -0
- package/src/styles/studio.css +31 -0
- package/src/utils/sourcePatcher.ts +149 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// NLE Layout
|
|
2
|
+
export { NLELayout } from "./components/nle/NLELayout";
|
|
3
|
+
export { NLEPreview } from "./components/nle/NLEPreview";
|
|
4
|
+
export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
|
|
5
|
+
export type { CompositionLevel } from "./components/nle/CompositionBreadcrumb";
|
|
6
|
+
|
|
7
|
+
// Player (preview, timeline, playback controls)
|
|
8
|
+
export {
|
|
9
|
+
Player,
|
|
10
|
+
PlayerControls,
|
|
11
|
+
Timeline,
|
|
12
|
+
PreviewPanel,
|
|
13
|
+
AgentActivityTrack,
|
|
14
|
+
useTimelinePlayer,
|
|
15
|
+
usePlayerStore,
|
|
16
|
+
liveTime,
|
|
17
|
+
formatTime,
|
|
18
|
+
} from "./player";
|
|
19
|
+
export type { AgentActivity, TimelineElement, ActiveEdits } from "./player";
|
|
20
|
+
|
|
21
|
+
// Editor
|
|
22
|
+
export { SourceEditor } from "./components/editor/SourceEditor";
|
|
23
|
+
export { PropertyPanel } from "./components/editor/PropertyPanel";
|
|
24
|
+
export { FileTree } from "./components/editor/FileTree";
|
|
25
|
+
|
|
26
|
+
// App
|
|
27
|
+
export { StudioApp } from "./App";
|
|
28
|
+
|
|
29
|
+
// Hooks
|
|
30
|
+
export { useCodeEditor } from "./hooks/useCodeEditor";
|
|
31
|
+
export { useElementPicker } from "./hooks/useElementPicker";
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { StrictMode } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { StudioApp } from "./App";
|
|
4
|
+
import "./styles/studio.css";
|
|
5
|
+
|
|
6
|
+
createRoot(document.getElementById("root")!).render(
|
|
7
|
+
<StrictMode>
|
|
8
|
+
<StudioApp />
|
|
9
|
+
</StrictMode>,
|
|
10
|
+
);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
|
|
3
|
+
const TRACK_H = 20;
|
|
4
|
+
const GUTTER = 32;
|
|
5
|
+
|
|
6
|
+
export interface AgentActivity {
|
|
7
|
+
agentId: string;
|
|
8
|
+
name: string;
|
|
9
|
+
color: string;
|
|
10
|
+
/** Active work periods mapped to VIDEO time (not wall clock) */
|
|
11
|
+
periods: Array<{ start: number; end: number }>;
|
|
12
|
+
/** Element creation events at specific video times */
|
|
13
|
+
events: Array<{ time: number; type: "create" | "modify" }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface AgentActivityTrackProps {
|
|
17
|
+
agents: AgentActivity[];
|
|
18
|
+
duration: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const AgentActivityTrack = memo(function AgentActivityTrack({ agents, duration }: AgentActivityTrackProps) {
|
|
22
|
+
if (agents.length === 0 || duration <= 0) return null;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="border-t border-neutral-800/30">
|
|
26
|
+
{/* Section header */}
|
|
27
|
+
<div className="flex items-center gap-1.5 px-2 py-1 text-[9px] text-neutral-600 font-medium uppercase tracking-wider">
|
|
28
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="4" /></svg>
|
|
29
|
+
Agent Activity
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
{agents.map((agent) => (
|
|
33
|
+
<div
|
|
34
|
+
key={agent.agentId}
|
|
35
|
+
className="relative flex"
|
|
36
|
+
style={{ height: TRACK_H }}
|
|
37
|
+
>
|
|
38
|
+
{/* Gutter: agent name */}
|
|
39
|
+
<div
|
|
40
|
+
className="flex-shrink-0 flex items-center justify-center"
|
|
41
|
+
style={{ width: GUTTER }}
|
|
42
|
+
title={agent.name}
|
|
43
|
+
>
|
|
44
|
+
<div
|
|
45
|
+
className="w-2 h-2 rounded-full"
|
|
46
|
+
style={{ backgroundColor: agent.color }}
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
{/* Lane */}
|
|
51
|
+
<div className="flex-1 relative" style={{ backgroundColor: `${agent.color}06` }}>
|
|
52
|
+
{/* Active work periods */}
|
|
53
|
+
{agent.periods.map((period, i) => {
|
|
54
|
+
const leftPct = (period.start / duration) * 100;
|
|
55
|
+
const widthPct = ((period.end - period.start) / duration) * 100;
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
key={`period-${i}`}
|
|
59
|
+
className="absolute top-1 bottom-1 rounded-sm"
|
|
60
|
+
style={{
|
|
61
|
+
left: `${leftPct}%`,
|
|
62
|
+
width: `${Math.max(widthPct, 0.5)}%`,
|
|
63
|
+
backgroundColor: `${agent.color}30`,
|
|
64
|
+
border: `1px solid ${agent.color}20`,
|
|
65
|
+
}}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
|
|
70
|
+
{/* Events: diamonds for create, circles for modify */}
|
|
71
|
+
{agent.events.map((event, i) => {
|
|
72
|
+
const leftPct = (event.time / duration) * 100;
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
key={`event-${i}`}
|
|
76
|
+
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2"
|
|
77
|
+
style={{ left: `${leftPct}%` }}
|
|
78
|
+
>
|
|
79
|
+
{event.type === "create" ? (
|
|
80
|
+
<div
|
|
81
|
+
className="w-2 h-2 rotate-45"
|
|
82
|
+
style={{ backgroundColor: agent.color }}
|
|
83
|
+
/>
|
|
84
|
+
) : (
|
|
85
|
+
<div
|
|
86
|
+
className="w-1.5 h-1.5 rounded-full"
|
|
87
|
+
style={{ backgroundColor: agent.color }}
|
|
88
|
+
/>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
})}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { forwardRef, useRef, useState, useCallback } from "react";
|
|
2
|
+
import { useMountEffect } from "../lib/useMountEffect";
|
|
3
|
+
|
|
4
|
+
const NATIVE_W = 1920;
|
|
5
|
+
const NATIVE_H = 1080;
|
|
6
|
+
|
|
7
|
+
interface PlayerProps {
|
|
8
|
+
projectId?: string;
|
|
9
|
+
directUrl?: string;
|
|
10
|
+
onLoad: () => void;
|
|
11
|
+
portrait?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(({ projectId, directUrl, onLoad, portrait }, ref) => {
|
|
15
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
16
|
+
const [scale, setScale] = useState(1);
|
|
17
|
+
const dimsRef = useRef({ w: portrait ? NATIVE_H : NATIVE_W, h: portrait ? NATIVE_W : NATIVE_H });
|
|
18
|
+
const [dims, setDims] = useState(dimsRef.current);
|
|
19
|
+
const loadCountRef = useRef(0);
|
|
20
|
+
|
|
21
|
+
const updateScale = useCallback(() => {
|
|
22
|
+
const el = containerRef.current;
|
|
23
|
+
if (!el) return;
|
|
24
|
+
const rect = el.getBoundingClientRect();
|
|
25
|
+
const d = dimsRef.current;
|
|
26
|
+
setScale(Math.min(rect.width / d.w, rect.height / d.h));
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
useMountEffect(() => {
|
|
30
|
+
updateScale();
|
|
31
|
+
const ro = new ResizeObserver(updateScale);
|
|
32
|
+
if (containerRef.current) ro.observe(containerRef.current);
|
|
33
|
+
|
|
34
|
+
// Listen for stage-size messages from the runtime
|
|
35
|
+
const handleMessage = (e: MessageEvent) => {
|
|
36
|
+
const data = e.data;
|
|
37
|
+
if ((data?.source === "hf-preview" || data?.source === "hf-preview") && data?.type === "stage-size" && data.width > 0 && data.height > 0) {
|
|
38
|
+
if (dimsRef.current.w !== data.width || dimsRef.current.h !== data.height) {
|
|
39
|
+
dimsRef.current = { w: data.width, h: data.height };
|
|
40
|
+
setDims(dimsRef.current);
|
|
41
|
+
updateScale();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
window.addEventListener("message", handleMessage);
|
|
46
|
+
|
|
47
|
+
return () => {
|
|
48
|
+
ro.disconnect();
|
|
49
|
+
window.removeEventListener("message", handleMessage);
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const handleLoad = useCallback(() => {
|
|
54
|
+
loadCountRef.current++;
|
|
55
|
+
|
|
56
|
+
// Auto-detect dimensions from the composition's data-width/data-height
|
|
57
|
+
try {
|
|
58
|
+
const iframeEl = typeof ref === "function" ? null : ref?.current;
|
|
59
|
+
const doc = iframeEl?.contentDocument;
|
|
60
|
+
if (doc) {
|
|
61
|
+
const root = doc.querySelector("[data-composition-id]");
|
|
62
|
+
if (root) {
|
|
63
|
+
const dw = parseInt(root.getAttribute("data-width") || "0", 10);
|
|
64
|
+
const dh = parseInt(root.getAttribute("data-height") || "0", 10);
|
|
65
|
+
if (dw > 0 && dh > 0 && (dw !== dimsRef.current.w || dh !== dimsRef.current.h)) {
|
|
66
|
+
dimsRef.current = { w: dw, h: dh };
|
|
67
|
+
setDims(dimsRef.current);
|
|
68
|
+
// Recalc scale with new dims
|
|
69
|
+
const el = containerRef.current;
|
|
70
|
+
if (el) {
|
|
71
|
+
const rect = el.getBoundingClientRect();
|
|
72
|
+
setScale(Math.min(rect.width / dw, rect.height / dh));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Cross-origin
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (loadCountRef.current > 1) {
|
|
82
|
+
const el = containerRef.current;
|
|
83
|
+
if (el) {
|
|
84
|
+
el.classList.remove("preview-revealing");
|
|
85
|
+
void el.offsetWidth;
|
|
86
|
+
el.classList.add("preview-revealing");
|
|
87
|
+
const onEnd = () => el.classList.remove("preview-revealing");
|
|
88
|
+
el.addEventListener("animationend", onEnd, { once: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
onLoad();
|
|
92
|
+
}, [onLoad, ref]);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div
|
|
96
|
+
ref={containerRef}
|
|
97
|
+
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"
|
|
98
|
+
>
|
|
99
|
+
<iframe
|
|
100
|
+
ref={ref}
|
|
101
|
+
src={directUrl || `/api/projects/${projectId}/preview`}
|
|
102
|
+
onLoad={handleLoad}
|
|
103
|
+
sandbox="allow-scripts allow-same-origin"
|
|
104
|
+
allow="autoplay; fullscreen"
|
|
105
|
+
referrerPolicy="no-referrer"
|
|
106
|
+
title="Project Preview"
|
|
107
|
+
style={{
|
|
108
|
+
width: dims.w,
|
|
109
|
+
height: dims.h,
|
|
110
|
+
border: "none",
|
|
111
|
+
transform: `scale(${scale})`,
|
|
112
|
+
transformOrigin: "center center",
|
|
113
|
+
flexShrink: 0,
|
|
114
|
+
}}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
Player.displayName = "Player";
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { useRef, useState, useCallback, memo } from "react";
|
|
2
|
+
import { formatTime } from "../lib/time";
|
|
3
|
+
import { usePlayerStore, liveTime } from "../store/playerStore";
|
|
4
|
+
import { useMountEffect } from "../lib/useMountEffect";
|
|
5
|
+
|
|
6
|
+
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
|
|
7
|
+
|
|
8
|
+
interface PlayerControlsProps {
|
|
9
|
+
/** @deprecated Pass via store — kept for backwards compat */
|
|
10
|
+
isPlaying?: boolean;
|
|
11
|
+
/** @deprecated Pass via store — kept for backwards compat */
|
|
12
|
+
duration?: number;
|
|
13
|
+
/** @deprecated Pass via store — kept for backwards compat */
|
|
14
|
+
timelineReady?: boolean;
|
|
15
|
+
onTogglePlay: () => void;
|
|
16
|
+
onSeek: (time: number) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const PlayerControls = memo(function PlayerControls({ onTogglePlay, onSeek, ...overrides }: PlayerControlsProps) {
|
|
20
|
+
// Subscribe to only the fields we render — each selector prevents cascading re-renders
|
|
21
|
+
const storeIsPlaying = usePlayerStore((s) => s.isPlaying);
|
|
22
|
+
const storeDuration = usePlayerStore((s) => s.duration);
|
|
23
|
+
const storeTimelineReady = usePlayerStore((s) => s.timelineReady);
|
|
24
|
+
const playbackRate = usePlayerStore((s) => s.playbackRate);
|
|
25
|
+
const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
|
|
26
|
+
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
|
27
|
+
|
|
28
|
+
const isPlaying = overrides.isPlaying ?? storeIsPlaying;
|
|
29
|
+
const duration = overrides.duration ?? storeDuration;
|
|
30
|
+
const timelineReady = overrides.timelineReady ?? storeTimelineReady;
|
|
31
|
+
|
|
32
|
+
const progressFillRef = useRef<HTMLDivElement>(null);
|
|
33
|
+
const progressThumbRef = useRef<HTMLDivElement>(null);
|
|
34
|
+
const timeDisplayRef = useRef<HTMLSpanElement>(null);
|
|
35
|
+
const seekBarRef = useRef<HTMLDivElement>(null);
|
|
36
|
+
const isDraggingRef = useRef(false);
|
|
37
|
+
const currentTimeRef = useRef(0);
|
|
38
|
+
|
|
39
|
+
const durationRef = useRef(duration);
|
|
40
|
+
durationRef.current = duration;
|
|
41
|
+
useMountEffect(() => {
|
|
42
|
+
const unsub = liveTime.subscribe((t) => {
|
|
43
|
+
currentTimeRef.current = t;
|
|
44
|
+
const dur = durationRef.current;
|
|
45
|
+
const pct = dur > 0 ? (t / dur) * 100 : 0;
|
|
46
|
+
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
|
|
47
|
+
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
|
|
48
|
+
if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t);
|
|
49
|
+
});
|
|
50
|
+
return unsub;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const seekFromClientX = useCallback(
|
|
54
|
+
(clientX: number) => {
|
|
55
|
+
const bar = seekBarRef.current;
|
|
56
|
+
if (!bar || duration <= 0) return;
|
|
57
|
+
const rect = bar.getBoundingClientRect();
|
|
58
|
+
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
59
|
+
onSeek(percent * duration);
|
|
60
|
+
},
|
|
61
|
+
[duration, onSeek],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const handleMouseDown = useCallback(
|
|
65
|
+
(e: React.MouseEvent) => {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
isDraggingRef.current = true;
|
|
68
|
+
seekFromClientX(e.clientX);
|
|
69
|
+
|
|
70
|
+
const onMouseMove = (me: MouseEvent) => {
|
|
71
|
+
if (isDraggingRef.current) seekFromClientX(me.clientX);
|
|
72
|
+
};
|
|
73
|
+
const onMouseUp = () => {
|
|
74
|
+
isDraggingRef.current = false;
|
|
75
|
+
window.removeEventListener("mousemove", onMouseMove);
|
|
76
|
+
window.removeEventListener("mouseup", onMouseUp);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
window.addEventListener("mousemove", onMouseMove);
|
|
80
|
+
window.addEventListener("mouseup", onMouseUp);
|
|
81
|
+
},
|
|
82
|
+
[seekFromClientX],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const handleKeyDown = useCallback(
|
|
86
|
+
(e: React.KeyboardEvent) => {
|
|
87
|
+
if (!timelineReady || duration <= 0) return;
|
|
88
|
+
const step = e.shiftKey ? 5 : 1;
|
|
89
|
+
if (e.key === "ArrowLeft") {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
onSeek(Math.max(0, currentTimeRef.current - step));
|
|
92
|
+
} else if (e.key === "ArrowRight") {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
onSeek(Math.min(duration, currentTimeRef.current + step));
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
[timelineReady, duration, onSeek],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="px-3 py-2 flex items-center gap-3">
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
aria-label={isPlaying ? "Pause" : "Play"}
|
|
105
|
+
onClick={onTogglePlay}
|
|
106
|
+
disabled={!timelineReady}
|
|
107
|
+
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"
|
|
108
|
+
>
|
|
109
|
+
{isPlaying ? (
|
|
110
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
111
|
+
<rect x="6" y="4" width="4" height="16" rx="1" />
|
|
112
|
+
<rect x="14" y="4" width="4" height="16" rx="1" />
|
|
113
|
+
</svg>
|
|
114
|
+
) : (
|
|
115
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
116
|
+
<path d="M8 5v14l11-7z" />
|
|
117
|
+
</svg>
|
|
118
|
+
)}
|
|
119
|
+
</button>
|
|
120
|
+
|
|
121
|
+
<span className="text-neutral-500 font-mono text-xs tabular-nums flex-shrink-0 min-w-[80px]">
|
|
122
|
+
<span ref={timeDisplayRef}>{formatTime(0)}</span>
|
|
123
|
+
<span className="text-neutral-700 mx-0.5">/</span>
|
|
124
|
+
<span className="text-neutral-600">{formatTime(duration)}</span>
|
|
125
|
+
</span>
|
|
126
|
+
|
|
127
|
+
<div
|
|
128
|
+
ref={seekBarRef}
|
|
129
|
+
role="slider"
|
|
130
|
+
tabIndex={0}
|
|
131
|
+
aria-label="Seek"
|
|
132
|
+
aria-valuemin={0}
|
|
133
|
+
aria-valuemax={Math.round(duration)}
|
|
134
|
+
aria-valuenow={0}
|
|
135
|
+
className="flex-1 h-6 flex items-center cursor-pointer group"
|
|
136
|
+
style={{ touchAction: "manipulation" }}
|
|
137
|
+
onMouseDown={handleMouseDown}
|
|
138
|
+
onKeyDown={handleKeyDown}
|
|
139
|
+
>
|
|
140
|
+
<div className="w-full h-[3px] bg-neutral-800 rounded-full relative">
|
|
141
|
+
<div
|
|
142
|
+
ref={progressFillRef}
|
|
143
|
+
className="absolute inset-y-0 left-0 bg-white/80 rounded-full"
|
|
144
|
+
style={{ width: 0 }}
|
|
145
|
+
/>
|
|
146
|
+
<div
|
|
147
|
+
ref={progressThumbRef}
|
|
148
|
+
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"
|
|
149
|
+
style={{ left: 0 }}
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{/* Speed control */}
|
|
155
|
+
<div className="relative flex-shrink-0">
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
onClick={() => setShowSpeedMenu((v) => !v)}
|
|
159
|
+
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"
|
|
160
|
+
>
|
|
161
|
+
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
|
|
162
|
+
</button>
|
|
163
|
+
{showSpeedMenu && (
|
|
164
|
+
<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]">
|
|
165
|
+
{SPEED_OPTIONS.map((rate) => (
|
|
166
|
+
<button
|
|
167
|
+
key={rate}
|
|
168
|
+
onClick={() => { setPlaybackRate(rate); setShowSpeedMenu(false); }}
|
|
169
|
+
className={`block w-full px-3 py-1 text-xs text-left font-mono tabular-nums transition-colors ${
|
|
170
|
+
rate === playbackRate ? "text-white bg-neutral-800" : "text-neutral-400 hover:text-white hover:bg-neutral-800"
|
|
171
|
+
}`}
|
|
172
|
+
>
|
|
173
|
+
{rate}x
|
|
174
|
+
</button>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { ReactNode, Ref } from "react";
|
|
2
|
+
import { Player } from "./Player";
|
|
3
|
+
import { PlayerControls } from "./PlayerControls";
|
|
4
|
+
import { Timeline } from "./Timeline";
|
|
5
|
+
|
|
6
|
+
interface RenderStatus {
|
|
7
|
+
state: "idle" | "rendering" | "complete" | "error";
|
|
8
|
+
stage?: string;
|
|
9
|
+
progress?: number;
|
|
10
|
+
error?: string;
|
|
11
|
+
onRender?: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PreviewPanelProps {
|
|
15
|
+
projectId: string | null;
|
|
16
|
+
hasProject: boolean;
|
|
17
|
+
portrait: boolean;
|
|
18
|
+
iframeRef: Ref<HTMLIFrameElement>;
|
|
19
|
+
onIframeLoad: () => void;
|
|
20
|
+
onTogglePlay: () => void;
|
|
21
|
+
onSeek: (t: number) => void;
|
|
22
|
+
/** Optional render status — pass to show rendering progress/state */
|
|
23
|
+
renderStatus?: RenderStatus;
|
|
24
|
+
/** Optional slot for custom content below the timeline */
|
|
25
|
+
children?: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function PreviewPanel({
|
|
29
|
+
projectId,
|
|
30
|
+
hasProject,
|
|
31
|
+
portrait,
|
|
32
|
+
iframeRef,
|
|
33
|
+
onIframeLoad,
|
|
34
|
+
onTogglePlay,
|
|
35
|
+
onSeek,
|
|
36
|
+
renderStatus,
|
|
37
|
+
children,
|
|
38
|
+
}: PreviewPanelProps) {
|
|
39
|
+
|
|
40
|
+
const renderState = renderStatus?.state ?? "idle";
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
className="min-w-0 overflow-hidden"
|
|
45
|
+
style={{
|
|
46
|
+
display: "grid",
|
|
47
|
+
gridTemplateRows: hasProject && projectId ? "1fr auto auto auto" : "1fr",
|
|
48
|
+
height: "100%",
|
|
49
|
+
minHeight: 0,
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
{hasProject && projectId ? (
|
|
53
|
+
<>
|
|
54
|
+
{/* Player — takes all remaining space, constrained for portrait */}
|
|
55
|
+
<div className="flex items-center justify-center p-2 overflow-hidden" style={{ minHeight: 0, minWidth: 0 }}>
|
|
56
|
+
<Player ref={iframeRef} projectId={projectId} onLoad={onIframeLoad} portrait={portrait} />
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
{/* Controls — fixed height */}
|
|
60
|
+
<div className="bg-neutral-950 border-t border-neutral-800 flex-shrink-0">
|
|
61
|
+
<PlayerControls
|
|
62
|
+
onTogglePlay={onTogglePlay}
|
|
63
|
+
onSeek={onSeek}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
{/* Timeline — capped height, internal scroll */}
|
|
68
|
+
<div className="bg-neutral-950 flex-shrink-0 overflow-y-auto" style={{ maxHeight: "100px" }}>
|
|
69
|
+
<Timeline onSeek={onSeek} />
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
{/* Render status — only shown when actively rendering, complete, or error */}
|
|
73
|
+
{renderStatus && (renderState === "rendering" || renderState === "complete" || renderState === "error") && (
|
|
74
|
+
<div className="bg-neutral-950 border-t border-neutral-800 px-4 py-2 flex items-center justify-end gap-2 flex-shrink-0">
|
|
75
|
+
{renderState === "rendering" && (
|
|
76
|
+
<div className="flex-1">
|
|
77
|
+
<div className="flex items-center gap-2">
|
|
78
|
+
<div className="flex-1 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
|
|
79
|
+
<div
|
|
80
|
+
className="h-full bg-blue-500 rounded-full transition-[width] duration-200"
|
|
81
|
+
style={{ width: `${renderStatus.progress ?? 0}%` }}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
<span className="text-xs text-neutral-400 flex-shrink-0">
|
|
85
|
+
{renderStatus.stage || "Rendering..."}
|
|
86
|
+
</span>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
{renderState === "complete" && (
|
|
91
|
+
<div className="flex items-center gap-1.5 text-xs text-green-400">
|
|
92
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
93
|
+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
|
94
|
+
<polyline points="22 4 12 14.01 9 11.01" />
|
|
95
|
+
</svg>
|
|
96
|
+
<span>Complete</span>
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
{renderState === "error" && (
|
|
100
|
+
<div className="flex items-center gap-2 text-xs text-red-400">
|
|
101
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
102
|
+
<circle cx="12" cy="12" r="10" />
|
|
103
|
+
<line x1="12" y1="8" x2="12" y2="12" />
|
|
104
|
+
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
105
|
+
</svg>
|
|
106
|
+
<span className="truncate">{renderStatus.error}</span>
|
|
107
|
+
{renderStatus.onRender && (
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
onClick={renderStatus.onRender}
|
|
111
|
+
className="flex-shrink-0 px-2 py-0.5 text-xs text-neutral-300 hover:text-white hover:bg-neutral-800 rounded transition-colors"
|
|
112
|
+
>
|
|
113
|
+
Retry
|
|
114
|
+
</button>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
{/* Optional custom slot */}
|
|
122
|
+
{children}
|
|
123
|
+
</>
|
|
124
|
+
) : (
|
|
125
|
+
<div className="flex items-center justify-center w-full min-w-0">
|
|
126
|
+
<div className="text-center w-full">
|
|
127
|
+
<div className="w-16 h-16 mx-auto mb-4 rounded-card bg-neutral-900 flex items-center justify-center">
|
|
128
|
+
<svg
|
|
129
|
+
width="24"
|
|
130
|
+
height="24"
|
|
131
|
+
viewBox="0 0 24 24"
|
|
132
|
+
fill="none"
|
|
133
|
+
stroke="currentColor"
|
|
134
|
+
strokeWidth="1.5"
|
|
135
|
+
strokeLinecap="round"
|
|
136
|
+
strokeLinejoin="round"
|
|
137
|
+
className="text-neutral-600"
|
|
138
|
+
>
|
|
139
|
+
<polygon points="5 3 19 12 5 21 5 3" />
|
|
140
|
+
</svg>
|
|
141
|
+
</div>
|
|
142
|
+
<p className="text-sm text-neutral-600">Preview will appear here</p>
|
|
143
|
+
<p className="text-xs text-neutral-700 mt-1">Send a message to generate a video composition</p>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|