@hyperframes/studio 0.1.10 → 0.1.12

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 (38) hide show
  1. package/dist/assets/index-BEwJNmPo.js +92 -0
  2. package/dist/assets/index-BnvciBdD.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +10 -4
  5. package/src/App.tsx +744 -271
  6. package/src/components/editor/FileTree.tsx +186 -32
  7. package/src/components/editor/SourceEditor.tsx +3 -1
  8. package/src/components/nle/NLELayout.tsx +125 -23
  9. package/src/components/renders/RenderQueue.tsx +123 -0
  10. package/src/components/renders/RenderQueueItem.tsx +137 -0
  11. package/src/components/renders/useRenderQueue.ts +193 -0
  12. package/src/components/sidebar/AssetsTab.tsx +360 -0
  13. package/src/components/sidebar/CompositionsTab.tsx +227 -0
  14. package/src/components/sidebar/LeftSidebar.tsx +102 -0
  15. package/src/components/ui/ExpandOnHover.tsx +194 -0
  16. package/src/hooks/useCodeEditor.ts +1 -1
  17. package/src/hooks/useElementPicker.ts +5 -1
  18. package/src/index.ts +10 -2
  19. package/src/player/components/AudioWaveform.tsx +168 -0
  20. package/src/player/components/CompositionThumbnail.tsx +140 -0
  21. package/src/player/components/EditModal.tsx +165 -0
  22. package/src/player/components/Player.tsx +6 -5
  23. package/src/player/components/PlayerControls.tsx +78 -39
  24. package/src/player/components/Timeline.test.ts +110 -0
  25. package/src/player/components/Timeline.tsx +537 -260
  26. package/src/player/components/TimelineClip.tsx +80 -0
  27. package/src/player/components/VideoThumbnail.tsx +196 -0
  28. package/src/player/hooks/useTimelinePlayer.ts +404 -112
  29. package/src/player/index.ts +3 -3
  30. package/src/player/lib/time.test.ts +57 -0
  31. package/src/player/lib/time.ts +1 -0
  32. package/src/player/store/playerStore.test.ts +265 -0
  33. package/src/player/store/playerStore.ts +44 -16
  34. package/src/utils/htmlEditor.ts +164 -0
  35. package/dist/assets/index-Df6fO-S6.js +0 -78
  36. package/dist/assets/index-KoBceNoU.css +0 -1
  37. package/src/player/components/AgentActivityTrack.tsx +0 -93
  38. package/src/player/lib/useMountEffect.ts +0 -10
@@ -0,0 +1,227 @@
1
+ import { memo, useRef, useState, useCallback, useEffect } from "react";
2
+ import { ExpandOnHover } from "../ui/ExpandOnHover";
3
+
4
+ interface CompositionsTabProps {
5
+ projectId: string;
6
+ compositions: string[];
7
+ activeComposition: string | null;
8
+ onSelect: (comp: string) => void;
9
+ }
10
+
11
+ function ExpandedCompPreview({
12
+ previewUrl,
13
+ name,
14
+ comp,
15
+ onSelect,
16
+ }: {
17
+ previewUrl: string;
18
+ name: string;
19
+ comp: string;
20
+ onSelect: () => void;
21
+ }) {
22
+ const containerRef = useRef<HTMLDivElement>(null);
23
+ const iframeRef = useRef<HTMLIFrameElement>(null);
24
+ const [dims, setDims] = useState({ w: 1920, h: 1080 });
25
+ const [scale, setScale] = useState(1);
26
+
27
+ const updateScale = useCallback(() => {
28
+ const el = containerRef.current;
29
+ if (!el) return;
30
+ const s = Math.min(el.clientWidth / dims.w, el.clientHeight / dims.h);
31
+ setScale(s);
32
+ }, [dims]);
33
+
34
+ // eslint-disable-next-line no-restricted-syntax
35
+ useEffect(() => {
36
+ updateScale();
37
+ const el = containerRef.current;
38
+ if (!el) return;
39
+ const ro = new ResizeObserver(updateScale);
40
+ ro.observe(el);
41
+ return () => ro.disconnect();
42
+ }, [updateScale]);
43
+
44
+ const handleLoad = useCallback(() => {
45
+ const iframe = iframeRef.current;
46
+ if (!iframe) return;
47
+ // Detect dimensions from composition
48
+ try {
49
+ const doc = iframe.contentDocument;
50
+ if (doc) {
51
+ const root = doc.querySelector("[data-composition-id]");
52
+ if (root) {
53
+ const w = parseInt(root.getAttribute("data-width") ?? "0", 10);
54
+ const h = parseInt(root.getAttribute("data-height") ?? "0", 10);
55
+ if (w > 0 && h > 0) setDims({ w, h });
56
+ }
57
+ }
58
+ } catch {
59
+ /* cross-origin */
60
+ }
61
+
62
+ let attempts = 0;
63
+ const interval = setInterval(() => {
64
+ try {
65
+ const win = iframe.contentWindow as Window & {
66
+ __player?: { play: () => void; seek: (t: number) => void };
67
+ __timelines?: Record<string, { play: () => void; seek: (t: number) => void }>;
68
+ };
69
+ if (win?.__player) {
70
+ win.__player.seek(0.5);
71
+ win.__player.play();
72
+ clearInterval(interval);
73
+ return;
74
+ }
75
+ if (win?.__timelines) {
76
+ const keys = Object.keys(win.__timelines);
77
+ const tl = keys.length > 0 ? win.__timelines[keys[keys.length - 1]] : null;
78
+ if (tl) {
79
+ tl.seek(0.5);
80
+ tl.play();
81
+ clearInterval(interval);
82
+ }
83
+ }
84
+ } catch {
85
+ /* cross-origin */
86
+ }
87
+ if (++attempts > 15) clearInterval(interval);
88
+ }, 200);
89
+ }, []);
90
+
91
+ const offsetX = containerRef.current
92
+ ? (containerRef.current.clientWidth - dims.w * scale) / 2
93
+ : 0;
94
+ const offsetY = containerRef.current
95
+ ? (containerRef.current.clientHeight - dims.h * scale) / 2
96
+ : 0;
97
+
98
+ return (
99
+ <div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
100
+ <div ref={containerRef} className="flex-1 min-h-0 relative overflow-hidden bg-black">
101
+ <iframe
102
+ ref={iframeRef}
103
+ src={previewUrl}
104
+ sandbox="allow-scripts allow-same-origin"
105
+ onLoad={handleLoad}
106
+ className="absolute border-none"
107
+ style={{
108
+ left: Math.max(0, offsetX),
109
+ top: Math.max(0, offsetY),
110
+ width: dims.w,
111
+ height: dims.h,
112
+ transformOrigin: "0 0",
113
+ transform: `scale(${scale})`,
114
+ }}
115
+ tabIndex={-1}
116
+ />
117
+ </div>
118
+ <div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
119
+ <div>
120
+ <div className="text-sm font-medium text-neutral-200">{name}</div>
121
+ <div className="text-[10px] text-neutral-600 font-mono mt-0.5">{comp}</div>
122
+ </div>
123
+ <button
124
+ onClick={(e) => {
125
+ e.stopPropagation();
126
+ onSelect();
127
+ }}
128
+ className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors"
129
+ >
130
+ Open
131
+ </button>
132
+ </div>
133
+ </div>
134
+ );
135
+ }
136
+
137
+ function CompCard({
138
+ projectId,
139
+ comp,
140
+ isActive,
141
+ onSelect,
142
+ }: {
143
+ projectId: string;
144
+ comp: string;
145
+ isActive: boolean;
146
+ onSelect: () => void;
147
+ }) {
148
+ const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
149
+ const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=0.5`;
150
+ const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
151
+
152
+ const card = (
153
+ <div
154
+ className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
155
+ isActive
156
+ ? "bg-[#3CE6AC]/10 border-l-2 border-[#3CE6AC]"
157
+ : "border-l-2 border-transparent hover:bg-neutral-800/50"
158
+ }`}
159
+ >
160
+ <div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0">
161
+ <img
162
+ src={thumbnailUrl}
163
+ alt={name}
164
+ loading="lazy"
165
+ className="w-full h-full object-cover"
166
+ onError={(e) => {
167
+ (e.target as HTMLImageElement).style.display = "none";
168
+ }}
169
+ />
170
+ </div>
171
+ <div className="min-w-0 flex-1">
172
+ <span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
173
+ <span className="text-[9px] text-neutral-600 truncate block">{comp}</span>
174
+ </div>
175
+ </div>
176
+ );
177
+
178
+ return (
179
+ <ExpandOnHover
180
+ expandedContent={(closeExpand) => (
181
+ <ExpandedCompPreview
182
+ previewUrl={previewUrl}
183
+ name={name}
184
+ comp={comp}
185
+ onSelect={() => {
186
+ closeExpand();
187
+ onSelect();
188
+ }}
189
+ />
190
+ )}
191
+ onClick={onSelect}
192
+ expandScale={0.5}
193
+ delay={500}
194
+ >
195
+ {card}
196
+ </ExpandOnHover>
197
+ );
198
+ }
199
+
200
+ export const CompositionsTab = memo(function CompositionsTab({
201
+ projectId,
202
+ compositions,
203
+ activeComposition,
204
+ onSelect,
205
+ }: CompositionsTabProps) {
206
+ if (compositions.length === 0) {
207
+ return (
208
+ <div className="flex-1 flex items-center justify-center px-4">
209
+ <p className="text-xs text-neutral-600 text-center">No compositions found</p>
210
+ </div>
211
+ );
212
+ }
213
+
214
+ return (
215
+ <div className="flex-1 overflow-y-auto">
216
+ {compositions.map((comp) => (
217
+ <CompCard
218
+ key={comp}
219
+ projectId={projectId}
220
+ comp={comp}
221
+ isActive={activeComposition === comp}
222
+ onSelect={() => onSelect(comp)}
223
+ />
224
+ ))}
225
+ </div>
226
+ );
227
+ });
@@ -0,0 +1,102 @@
1
+ import { memo, useState, useCallback } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
3
+ import { CompositionsTab } from "./CompositionsTab";
4
+ import { AssetsTab } from "./AssetsTab";
5
+
6
+ type SidebarTab = "compositions" | "assets";
7
+
8
+ const STORAGE_KEY = "hf-studio-sidebar-tab";
9
+
10
+ function getPersistedTab(): SidebarTab {
11
+ const stored = localStorage.getItem(STORAGE_KEY);
12
+ return stored === "assets" ? "assets" : "compositions";
13
+ }
14
+
15
+ interface LeftSidebarProps {
16
+ width?: number;
17
+ projectId: string;
18
+ compositions: string[];
19
+ assets: string[];
20
+ activeComposition: string | null;
21
+ onSelectComposition: (comp: string) => void;
22
+ onImportFiles?: (files: FileList) => void;
23
+ }
24
+
25
+ export const LeftSidebar = memo(function LeftSidebar({
26
+ width = 240,
27
+ projectId,
28
+ compositions,
29
+ assets,
30
+ activeComposition,
31
+ onSelectComposition,
32
+ onImportFiles,
33
+ }: LeftSidebarProps) {
34
+ const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
35
+
36
+ const selectTab = useCallback((t: SidebarTab) => {
37
+ setTab(t);
38
+ localStorage.setItem(STORAGE_KEY, t);
39
+ }, []);
40
+
41
+ // Keyboard shortcuts: Cmd+1 for Compositions, Cmd+2 for Assets
42
+ useMountEffect(() => {
43
+ const handler = (e: KeyboardEvent) => {
44
+ if (!e.metaKey && !e.ctrlKey) return;
45
+ if (e.key === "1") {
46
+ e.preventDefault();
47
+ selectTab("compositions");
48
+ }
49
+ if (e.key === "2") {
50
+ e.preventDefault();
51
+ selectTab("assets");
52
+ }
53
+ };
54
+ window.addEventListener("keydown", handler);
55
+ return () => window.removeEventListener("keydown", handler);
56
+ });
57
+
58
+ return (
59
+ <div
60
+ className="flex flex-col h-full bg-neutral-950 border-r border-neutral-800/50"
61
+ style={{ width }}
62
+ >
63
+ {/* Tabs */}
64
+ <div className="flex border-b border-neutral-800/50 flex-shrink-0">
65
+ <button
66
+ type="button"
67
+ onClick={() => selectTab("compositions")}
68
+ className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
69
+ tab === "compositions"
70
+ ? "text-neutral-200 border-b-2 border-blue-500"
71
+ : "text-neutral-500 hover:text-neutral-400"
72
+ }`}
73
+ >
74
+ Compositions
75
+ </button>
76
+ <button
77
+ type="button"
78
+ onClick={() => selectTab("assets")}
79
+ className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
80
+ tab === "assets"
81
+ ? "text-neutral-200 border-b-2 border-blue-500"
82
+ : "text-neutral-500 hover:text-neutral-400"
83
+ }`}
84
+ >
85
+ Assets
86
+ </button>
87
+ </div>
88
+
89
+ {/* Tab content */}
90
+ {tab === "compositions" ? (
91
+ <CompositionsTab
92
+ projectId={projectId}
93
+ compositions={compositions}
94
+ activeComposition={activeComposition}
95
+ onSelect={onSelectComposition}
96
+ />
97
+ ) : (
98
+ <AssetsTab projectId={projectId} assets={assets} onImport={onImportFiles} />
99
+ )}
100
+ </div>
101
+ );
102
+ });
@@ -0,0 +1,194 @@
1
+ import React, { useState, useRef, useCallback, useEffect, type ReactNode } from "react";
2
+ import { motion, AnimatePresence } from "motion/react";
3
+
4
+ interface ExpandOnHoverProps {
5
+ children: ReactNode;
6
+ expandedContent?: ReactNode | ((close: () => void) => ReactNode);
7
+ expandScale?: number;
8
+ delay?: number;
9
+ className?: string;
10
+ onClick?: () => void;
11
+ }
12
+
13
+ export function ExpandOnHover({
14
+ children,
15
+ expandedContent,
16
+ expandScale = 0.75,
17
+ delay = 300,
18
+ className = "",
19
+ onClick,
20
+ }: ExpandOnHoverProps) {
21
+ const [isExpanded, setIsExpanded] = useState(false);
22
+ const [origin, setOrigin] = useState({ x: 0, y: 0, w: 0, h: 0 });
23
+ const containerRef = useRef<HTMLDivElement>(null);
24
+ const expandedRef = useRef<HTMLDivElement>(null);
25
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
26
+ const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
27
+
28
+ const close = useCallback(() => {
29
+ if (timerRef.current) {
30
+ clearTimeout(timerRef.current);
31
+ timerRef.current = null;
32
+ }
33
+ if (closeTimerRef.current) {
34
+ clearTimeout(closeTimerRef.current);
35
+ closeTimerRef.current = null;
36
+ }
37
+ setIsExpanded(false);
38
+ }, []);
39
+
40
+ const open = useCallback(() => {
41
+ if (!containerRef.current) return;
42
+ const rect = containerRef.current.getBoundingClientRect();
43
+ setOrigin({ x: rect.left, y: rect.top, w: rect.width, h: rect.height });
44
+ setIsExpanded(true);
45
+ }, []);
46
+
47
+ const handleCardEnter = useCallback(() => {
48
+ if (isExpanded) return;
49
+ if (timerRef.current) clearTimeout(timerRef.current);
50
+ timerRef.current = setTimeout(open, delay);
51
+ }, [delay, open, isExpanded]);
52
+
53
+ const handleCardLeave = useCallback(() => {
54
+ if (isExpanded) return;
55
+ if (timerRef.current) {
56
+ clearTimeout(timerRef.current);
57
+ timerRef.current = null;
58
+ }
59
+ }, [isExpanded]);
60
+
61
+ // When expanded: track mouse position. If mouse stays outside the expanded
62
+ // card for 600ms continuously, close. Any re-entry resets the timer.
63
+ // Note: useEffect with [isExpanded] is acceptable — subscribes to window mousemove
64
+ // only while expanded, with cleanup on collapse. Can't be a mount effect.
65
+ // eslint-disable-next-line no-restricted-syntax
66
+ useEffect(() => {
67
+ if (!isExpanded) return;
68
+
69
+ const CLOSE_DELAY = 600; // ms mouse must be outside to close
70
+ const START_DELAY = 400; // ms before we start checking (let animation settle)
71
+ let tracking = false;
72
+
73
+ const startTracking = setTimeout(() => {
74
+ tracking = true;
75
+ }, START_DELAY);
76
+
77
+ const handleMouseMove = (e: MouseEvent) => {
78
+ if (!tracking) return;
79
+ const el = expandedRef.current;
80
+ if (!el) return;
81
+
82
+ const rect = el.getBoundingClientRect();
83
+ // Add generous padding so edge movements don't trigger close
84
+ const pad = 20;
85
+ const inside =
86
+ e.clientX >= rect.left - pad &&
87
+ e.clientX <= rect.right + pad &&
88
+ e.clientY >= rect.top - pad &&
89
+ e.clientY <= rect.bottom + pad;
90
+
91
+ if (inside) {
92
+ // Mouse is inside — cancel any pending close
93
+ if (closeTimerRef.current) {
94
+ clearTimeout(closeTimerRef.current);
95
+ closeTimerRef.current = null;
96
+ }
97
+ } else {
98
+ // Mouse is outside — start close countdown if not already started
99
+ if (!closeTimerRef.current) {
100
+ closeTimerRef.current = setTimeout(() => {
101
+ setIsExpanded(false);
102
+ }, CLOSE_DELAY);
103
+ }
104
+ }
105
+ };
106
+
107
+ window.addEventListener("mousemove", handleMouseMove);
108
+
109
+ return () => {
110
+ clearTimeout(startTracking);
111
+ if (closeTimerRef.current) {
112
+ clearTimeout(closeTimerRef.current);
113
+ closeTimerRef.current = null;
114
+ }
115
+ window.removeEventListener("mousemove", handleMouseMove);
116
+ };
117
+ }, [isExpanded]);
118
+
119
+ const vw = typeof window !== "undefined" ? window.innerWidth : 1440;
120
+ const vh = typeof window !== "undefined" ? window.innerHeight : 900;
121
+ const targetW = vw * expandScale;
122
+ const targetH = vh * expandScale;
123
+ const targetX = (vw - targetW) / 2;
124
+ const targetY = (vh - targetH) / 2;
125
+
126
+ return (
127
+ <>
128
+ <div
129
+ ref={containerRef}
130
+ className={className}
131
+ onMouseEnter={handleCardEnter}
132
+ onMouseLeave={handleCardLeave}
133
+ onClick={onClick}
134
+ style={{ opacity: isExpanded ? 0 : 1, transition: "opacity 100ms ease-out" }}
135
+ >
136
+ {children}
137
+ </div>
138
+
139
+ <AnimatePresence>
140
+ {isExpanded && (
141
+ <>
142
+ {/* Backdrop */}
143
+ <motion.div
144
+ initial={{ opacity: 0 }}
145
+ animate={{ opacity: 1 }}
146
+ exit={{ opacity: 0 }}
147
+ transition={{ duration: 0.15 }}
148
+ className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
149
+ onClick={close}
150
+ />
151
+ {/* Expanded card */}
152
+ <motion.div
153
+ ref={expandedRef}
154
+ initial={{
155
+ left: origin.x,
156
+ top: origin.y,
157
+ width: origin.w,
158
+ height: origin.h,
159
+ }}
160
+ animate={{
161
+ left: targetX,
162
+ top: targetY,
163
+ width: targetW,
164
+ height: targetH,
165
+ }}
166
+ exit={{
167
+ left: origin.x,
168
+ top: origin.y,
169
+ width: origin.w,
170
+ height: origin.h,
171
+ }}
172
+ transition={{
173
+ type: "spring",
174
+ stiffness: 280,
175
+ damping: 28,
176
+ mass: 0.8,
177
+ }}
178
+ className="fixed z-50 overflow-hidden rounded-[16px] shadow-dialog"
179
+ onClick={(e: React.MouseEvent) => {
180
+ e.stopPropagation();
181
+ close();
182
+ onClick?.();
183
+ }}
184
+ >
185
+ {typeof expandedContent === "function"
186
+ ? expandedContent(close)
187
+ : (expandedContent ?? children)}
188
+ </motion.div>
189
+ </>
190
+ )}
191
+ </AnimatePresence>
192
+ </>
193
+ );
194
+ }
@@ -16,7 +16,7 @@ export interface UseCodeEditorReturn {
16
16
  setActiveFile: (path: string) => void;
17
17
  updateContent: (content: string) => void;
18
18
  markSaved: (path: string) => void;
19
- /** External update from agent — updates saved content, shows reload indicator */
19
+ /** External update — updates saved content, shows reload indicator */
20
20
  externalUpdate: (path: string, content: string) => void;
21
21
  }
22
22
 
@@ -156,7 +156,11 @@ export function useElementPicker(
156
156
  (
157
157
  elementId: string,
158
158
  selector: string,
159
- op: { type: "inline-style" | "attribute" | "text-content"; property: string; value: string },
159
+ op: {
160
+ type: "inline-style" | "attribute" | "text-content";
161
+ property: string;
162
+ value: string;
163
+ },
160
164
  ) => {
161
165
  const opts = optionsRef.current;
162
166
  if (!opts?.workspaceFiles || !opts.onSyncFiles || !elementId) return;
package/src/index.ts CHANGED
@@ -10,13 +10,14 @@ export {
10
10
  PlayerControls,
11
11
  Timeline,
12
12
  PreviewPanel,
13
- AgentActivityTrack,
13
+ VideoThumbnail,
14
+ CompositionThumbnail,
14
15
  useTimelinePlayer,
15
16
  usePlayerStore,
16
17
  liveTime,
17
18
  formatTime,
18
19
  } from "./player";
19
- export type { AgentActivity, TimelineElement, ActiveEdits } from "./player";
20
+ export type { TimelineElement } from "./player";
20
21
 
21
22
  // Editor
22
23
  export { SourceEditor } from "./components/editor/SourceEditor";
@@ -28,4 +29,11 @@ export { StudioApp } from "./App";
28
29
 
29
30
  // Hooks
30
31
  export { useCodeEditor } from "./hooks/useCodeEditor";
32
+ export type { OpenFile, UseCodeEditorReturn } from "./hooks/useCodeEditor";
31
33
  export { useElementPicker } from "./hooks/useElementPicker";
34
+ export type { PickedElement } from "./hooks/useElementPicker";
35
+
36
+ // Utilities
37
+ export { resolveSourceFile, applyPatch } from "./utils/sourcePatcher";
38
+ export type { PatchOperation } from "./utils/sourcePatcher";
39
+ export { parseStyleString, mergeStyleIntoTag, findElementBlock } from "./utils/htmlEditor";