@hyperframes/studio 0.2.0 → 0.2.2-alpha.1

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/index.html CHANGED
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>HyperFrames Studio</title>
7
- <script type="module" crossorigin src="/assets/index-DfhSlTti.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-Bkp9HQbo.css">
7
+ <script type="module" crossorigin src="/assets/index-DA_l-VKo.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BT9D8I7B.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.2.0",
3
+ "version": "0.2.2-alpha.1",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,7 +32,7 @@
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "codemirror": "^6.0.1",
34
34
  "motion": "^12.38.0",
35
- "@hyperframes/core": "0.2.0"
35
+ "@hyperframes/core": "0.2.2-alpha.1"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/react": "^19.0.0",
package/src/App.tsx CHANGED
@@ -12,6 +12,12 @@ import { LintModal } from "./components/LintModal";
12
12
  import type { LintFinding } from "./components/LintModal";
13
13
  import { MediaPreview } from "./components/MediaPreview";
14
14
  import { isMediaFile } from "./utils/mediaTypes";
15
+ import { CaptionOverlay } from "./captions/components/CaptionOverlay";
16
+ import { CaptionPropertyPanel } from "./captions/components/CaptionPropertyPanel";
17
+ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
18
+ import { useCaptionStore } from "./captions/store";
19
+ import { useCaptionSync } from "./captions/hooks/useCaptionSync";
20
+ import { parseCaptionComposition } from "./captions/parser";
15
21
 
16
22
  interface EditingFile {
17
23
  path: string;
@@ -50,12 +56,134 @@ export function StudioApp() {
50
56
  const [fileTree, setFileTree] = useState<string[]>([]);
51
57
  const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
52
58
  const renderQueue = useRenderQueue(projectId);
59
+ const captionEditMode = useCaptionStore((s) => s.isEditMode);
60
+ const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
61
+ const captionSync = useCaptionSync(projectId);
53
62
 
54
63
  // Resizable and collapsible panel widths
55
64
  const [leftWidth, setLeftWidth] = useState(240);
56
65
  const [rightWidth, setRightWidth] = useState(400);
57
66
  const [leftCollapsed, setLeftCollapsed] = useState(false);
58
67
  const [rightCollapsed, setRightCollapsed] = useState(true);
68
+ // Auto-enter caption edit mode when the iframe contains .caption-group elements.
69
+ // This is a subscription to external events (postMessage from runtime) — useEffect
70
+ // is appropriate here. The runtime fires "state"/"timeline" messages after all
71
+ // compositions load, which triggers caption detection.
72
+ // eslint-disable-next-line no-restricted-syntax
73
+ useEffect(() => {
74
+ if (!projectId) return;
75
+
76
+ let activating = false;
77
+
78
+ const tryActivateCaptions = () => {
79
+ if (useCaptionStore.getState().isEditMode || activating) {
80
+ return;
81
+ }
82
+
83
+ const iframe = previewIframeRef.current;
84
+ let doc: Document | null = null;
85
+ let win: Window | null = null;
86
+ try {
87
+ doc = iframe?.contentDocument ?? null;
88
+ win = iframe?.contentWindow ?? null;
89
+ } catch {
90
+ return;
91
+ }
92
+ if (!doc || !win) return;
93
+
94
+ const groups = doc.querySelectorAll(".caption-group");
95
+ if (groups.length === 0) return;
96
+
97
+ // Find the captions composition source path.
98
+ // The runtime strips data-composition-src after loading, so also check
99
+ // data-composition-file (set by the bundler) and the compIdToSrc map.
100
+ let captionSrcPath: string | null = null;
101
+
102
+ // Strategy 1: data-composition-src or data-composition-file attributes
103
+ const compHosts = doc.querySelectorAll("[data-composition-src], [data-composition-file]");
104
+ for (const host of compHosts) {
105
+ const src =
106
+ host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
107
+ if (src && src.includes("captions")) {
108
+ captionSrcPath = src;
109
+ break;
110
+ }
111
+ }
112
+
113
+ // Strategy 2: compIdToSrc map (built from raw index.html before runtime strips attrs)
114
+ if (!captionSrcPath) {
115
+ for (const [id, src] of compIdToSrc) {
116
+ if (id.includes("caption") || src.includes("caption")) {
117
+ captionSrcPath = src;
118
+ break;
119
+ }
120
+ }
121
+ }
122
+
123
+ // Strategy 3: activeCompPath if viewing captions directly
124
+ if (!captionSrcPath && activeCompPath?.includes("captions")) {
125
+ captionSrcPath = activeCompPath;
126
+ }
127
+
128
+ // Strategy 4: find composition element with "caption" in its ID
129
+ if (!captionSrcPath) {
130
+ const captionComp = doc.querySelector('[data-composition-id*="caption"]');
131
+ if (captionComp) {
132
+ const compId = captionComp.getAttribute("data-composition-id") || "";
133
+ captionSrcPath = compIdToSrc.get(compId) || null;
134
+ }
135
+ }
136
+
137
+ if (!captionSrcPath) return;
138
+
139
+ activating = true;
140
+ const srcPath = captionSrcPath;
141
+ fetch(`/api/projects/${projectId}/files/${encodeURIComponent(srcPath)}`)
142
+ .then((r) => r.json())
143
+ .then((data: { content?: string }) => {
144
+ if (!data.content || !doc || !win || useCaptionStore.getState().isEditMode) return;
145
+ const root = doc.querySelector("[data-composition-id]");
146
+ const w = parseInt(root?.getAttribute("data-width") ?? "1920", 10);
147
+ const h = parseInt(root?.getAttribute("data-height") ?? "1080", 10);
148
+ const dur = parseFloat(root?.getAttribute("data-duration") ?? "0");
149
+ const model = parseCaptionComposition(doc, win, data.content, w, h, dur);
150
+ if (!model) return;
151
+ const store = useCaptionStore.getState();
152
+ store.setModel(model);
153
+ store.setSourceFilePath(srcPath);
154
+ store.setEditMode(true);
155
+ captionSync.loadOverrides();
156
+ })
157
+ .catch(() => {})
158
+ .finally(() => {
159
+ activating = false;
160
+ });
161
+ };
162
+
163
+ // Listen for runtime messages that signal composition loading is complete
164
+ const handleMessage = (e: MessageEvent) => {
165
+ const data = e.data;
166
+ if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
167
+ tryActivateCaptions();
168
+ }
169
+ };
170
+
171
+ window.addEventListener("message", handleMessage);
172
+ // Try immediately in case compositions are already loaded
173
+ tryActivateCaptions();
174
+
175
+ return () => {
176
+ window.removeEventListener("message", handleMessage);
177
+ };
178
+ }, [activeCompPath, projectId, compIdToSrc, captionSync]);
179
+
180
+ // Auto-expand right panel when a caption word is selected
181
+ // eslint-disable-next-line no-restricted-syntax
182
+ useEffect(() => {
183
+ if (captionEditMode) {
184
+ setRightCollapsed(!captionHasSelection);
185
+ }
186
+ }, [captionHasSelection, captionEditMode]);
59
187
  const [globalDragOver, setGlobalDragOver] = useState(false);
60
188
  const [uploadToast, setUploadToast] = useState<string | null>(null);
61
189
  const [timelineVisible, setTimelineVisible] = useState(false);
@@ -159,12 +287,14 @@ export function StudioApp() {
159
287
  [compIdToSrc, activePreviewUrl],
160
288
  );
161
289
  const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
290
+ const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
162
291
  const [linting, setLinting] = useState(false);
163
292
  const [refreshKey, setRefreshKey] = useState(0);
164
293
  const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
165
294
  const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
166
295
  const projectIdRef = useRef(projectId);
167
296
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
297
+ const consoleErrorsRef = useRef<LintFinding[]>([]);
168
298
 
169
299
  // Listen for external file changes (user editing HTML outside the editor).
170
300
  // In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
@@ -673,7 +803,69 @@ export function StudioApp() {
673
803
  }}
674
804
  onIframeRef={(iframe) => {
675
805
  previewIframeRef.current = iframe;
806
+ consoleErrorsRef.current = [];
807
+ setConsoleErrors(null);
808
+ if (!iframe) return;
809
+
810
+ // Attach error capture after each iframe load (content resets on navigation)
811
+ const attachErrorCapture = () => {
812
+ try {
813
+ const win = iframe.contentWindow as (Window & typeof globalThis) | null;
814
+ if (!win) return;
815
+ // Guard against double-patching
816
+ if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
817
+ (win as unknown as Record<string, unknown>).__hfErrorCapture = true;
818
+ const origError = win.console.error.bind(win.console);
819
+ win.console.error = function (...args: unknown[]) {
820
+ origError(...args);
821
+ const text = args
822
+ .map((a) => (a instanceof Error ? a.message : String(a)))
823
+ .join(" ");
824
+ if (text.includes("favicon")) return;
825
+ consoleErrorsRef.current = [
826
+ ...consoleErrorsRef.current,
827
+ { severity: "error", message: text },
828
+ ];
829
+ setConsoleErrors([...consoleErrorsRef.current]);
830
+ };
831
+ win.addEventListener("error", (e: ErrorEvent) => {
832
+ const text = e.message || String(e);
833
+ consoleErrorsRef.current = [
834
+ ...consoleErrorsRef.current,
835
+ { severity: "error", message: text },
836
+ ];
837
+ setConsoleErrors([...consoleErrorsRef.current]);
838
+ });
839
+ } catch {
840
+ // cross-origin — can't attach
841
+ }
842
+ };
843
+ // Attach now (iframe may already be loaded) and on future loads
844
+ attachErrorCapture();
845
+ iframe.addEventListener("load", () => {
846
+ consoleErrorsRef.current = [];
847
+ setConsoleErrors(null);
848
+ attachErrorCapture();
849
+ });
676
850
  }}
851
+ previewOverlay={
852
+ captionEditMode ? <CaptionOverlay iframeRef={previewIframeRef} /> : undefined
853
+ }
854
+ timelineFooter={
855
+ captionEditMode ? (
856
+ <div
857
+ className="border-t border-neutral-800/30 flex-shrink-0"
858
+ style={{ height: 60 }}
859
+ >
860
+ <div className="flex items-center gap-1.5 px-2 py-0.5">
861
+ <span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
862
+ Captions
863
+ </span>
864
+ </div>
865
+ <CaptionTimeline pixelsPerSecond={100} />
866
+ </div>
867
+ ) : undefined
868
+ }
677
869
  timelineVisible={timelineVisible}
678
870
  onToggleTimeline={() => setTimelineVisible((v) => !v)}
679
871
  />
@@ -693,14 +885,18 @@ export function StudioApp() {
693
885
  className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
694
886
  style={{ width: rightWidth }}
695
887
  >
696
- <RenderQueue
697
- jobs={renderQueue.jobs}
698
- projectId={projectId}
699
- onDelete={renderQueue.deleteRender}
700
- onClearCompleted={renderQueue.clearCompleted}
701
- onStartRender={(format) => renderQueue.startRender(30, "standard", format)}
702
- isRendering={renderQueue.isRendering}
703
- />
888
+ {captionEditMode ? (
889
+ <CaptionPropertyPanel iframeRef={previewIframeRef} />
890
+ ) : (
891
+ <RenderQueue
892
+ jobs={renderQueue.jobs}
893
+ projectId={projectId}
894
+ onDelete={renderQueue.deleteRender}
895
+ onClearCompleted={renderQueue.clearCompleted}
896
+ onStartRender={(format) => renderQueue.startRender(30, "standard", format)}
897
+ isRendering={renderQueue.isRendering}
898
+ />
899
+ )}
704
900
  </div>
705
901
  </>
706
902
  )}
@@ -711,6 +907,15 @@ export function StudioApp() {
711
907
  <LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
712
908
  )}
713
909
 
910
+ {/* Console errors modal — auto-shows when composition has runtime errors */}
911
+ {consoleErrors !== null && consoleErrors.length > 0 && projectId && (
912
+ <LintModal
913
+ findings={consoleErrors}
914
+ projectId={projectId}
915
+ onClose={() => setConsoleErrors(null)}
916
+ />
917
+ )}
918
+
714
919
  {/* Global drag-drop overlay */}
715
920
  {globalDragOver && (
716
921
  <div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
@@ -0,0 +1,269 @@
1
+ import { memo, useCallback } from "react";
2
+ import { useCaptionStore } from "../store";
3
+ import type { CaptionAnimation } from "../types";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Constants
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const ENTRANCE_PRESETS = [
10
+ "none",
11
+ "fade",
12
+ "slide-up",
13
+ "slide-down",
14
+ "slide-left",
15
+ "slide-right",
16
+ "pop",
17
+ "slam",
18
+ "bounce",
19
+ "typewriter",
20
+ "blur-in",
21
+ "flip",
22
+ "drop",
23
+ ];
24
+
25
+ const HIGHLIGHT_PRESETS = [
26
+ "none",
27
+ "color-change",
28
+ "scale-pop",
29
+ "glow-pulse",
30
+ "underline-sweep",
31
+ "background-fill",
32
+ "bounce",
33
+ ];
34
+
35
+ const EXIT_PRESETS = [
36
+ "none",
37
+ "fade",
38
+ "slide-up",
39
+ "slide-down",
40
+ "slide-left",
41
+ "slide-right",
42
+ "scatter",
43
+ "drop",
44
+ "collapse",
45
+ "blur-out",
46
+ "shrink",
47
+ ];
48
+
49
+ const EASE_PRESETS = [
50
+ "power1.out",
51
+ "power2.out",
52
+ "power3.out",
53
+ "power4.out",
54
+ "power1.in",
55
+ "power2.in",
56
+ "power3.in",
57
+ "power1.inOut",
58
+ "power2.inOut",
59
+ "back.out(1.7)",
60
+ "elastic.out(1,0.3)",
61
+ "bounce.out",
62
+ ];
63
+
64
+ import { Section, Row, inputCls } from "./shared";
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Animation phase controls
68
+ // ---------------------------------------------------------------------------
69
+
70
+ interface AnimationPhaseProps {
71
+ label: string;
72
+ presets: string[];
73
+ animation: CaptionAnimation | null;
74
+ showIntensity?: boolean;
75
+ onChange: (update: Partial<CaptionAnimation>) => void;
76
+ }
77
+
78
+ function AnimationPhase({
79
+ label,
80
+ presets,
81
+ animation,
82
+ showIntensity,
83
+ onChange,
84
+ }: AnimationPhaseProps) {
85
+ const preset = animation?.preset ?? "none";
86
+ const duration = animation?.duration ?? 0.2;
87
+ const ease = animation?.ease ?? "power2.out";
88
+ const stagger = animation?.stagger ?? 0;
89
+ const intensity = animation?.intensity ?? 1;
90
+
91
+ return (
92
+ <Section label={label}>
93
+ <Row label="Preset">
94
+ <select
95
+ value={preset}
96
+ onChange={(e) => onChange({ preset: e.target.value })}
97
+ className={inputCls}
98
+ >
99
+ {presets.map((p) => (
100
+ <option key={p} value={p}>
101
+ {p}
102
+ </option>
103
+ ))}
104
+ </select>
105
+ </Row>
106
+
107
+ <Row label="Duration">
108
+ <input
109
+ type="number"
110
+ value={duration}
111
+ step={0.05}
112
+ min={0}
113
+ max={2}
114
+ onChange={(e) => onChange({ duration: Number(e.target.value) })}
115
+ className={inputCls}
116
+ />
117
+ </Row>
118
+
119
+ <Row label="Ease">
120
+ <select
121
+ value={ease}
122
+ onChange={(e) => onChange({ ease: e.target.value })}
123
+ className={inputCls}
124
+ >
125
+ {EASE_PRESETS.map((e) => (
126
+ <option key={e} value={e}>
127
+ {e}
128
+ </option>
129
+ ))}
130
+ </select>
131
+ </Row>
132
+
133
+ <Row label="Stagger">
134
+ <input
135
+ type="number"
136
+ value={stagger}
137
+ step={0.02}
138
+ min={0}
139
+ max={0.5}
140
+ onChange={(e) => onChange({ stagger: Number(e.target.value) })}
141
+ className={inputCls}
142
+ />
143
+ </Row>
144
+
145
+ {showIntensity && (
146
+ <Row label="Intensity">
147
+ <div className="flex items-center gap-2">
148
+ <input
149
+ type="range"
150
+ min={0}
151
+ max={1}
152
+ step={0.01}
153
+ value={intensity}
154
+ onChange={(e) => onChange({ intensity: Number(e.target.value) })}
155
+ className="flex-1 accent-studio-accent"
156
+ />
157
+ <span className="text-2xs text-neutral-400 font-mono w-8 text-right flex-shrink-0">
158
+ {intensity.toFixed(2)}
159
+ </span>
160
+ </div>
161
+ </Row>
162
+ )}
163
+ </Section>
164
+ );
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Main component
169
+ // ---------------------------------------------------------------------------
170
+
171
+ export const CaptionAnimationPanel = memo(function CaptionAnimationPanel() {
172
+ const model = useCaptionStore((s) => s.model);
173
+ const selectedGroupId = useCaptionStore((s) => s.selectedGroupId);
174
+ const selectedSegmentIds = useCaptionStore((s) => s.selectedSegmentIds);
175
+ const updateGroupAnimation = useCaptionStore((s) => s.updateGroupAnimation);
176
+ const applyAnimationToAll = useCaptionStore((s) => s.applyAnimationToAll);
177
+
178
+ // Resolve which group to edit
179
+ let resolvedGroupId: string | null = selectedGroupId;
180
+ if (!resolvedGroupId && model && selectedSegmentIds.size > 0) {
181
+ const firstSegmentId = [...selectedSegmentIds][0];
182
+ if (firstSegmentId) {
183
+ for (const [gid, group] of model.groups) {
184
+ if (group.segmentIds.includes(firstSegmentId)) {
185
+ resolvedGroupId = gid;
186
+ break;
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ const group = resolvedGroupId ? model?.groups.get(resolvedGroupId) : undefined;
193
+ const animation = group?.animation;
194
+
195
+ // All hooks must be called before any early return
196
+ const handleEntranceChange = useCallback(
197
+ (update: Partial<CaptionAnimation>) => {
198
+ if (resolvedGroupId) updateGroupAnimation(resolvedGroupId, "entrance", update);
199
+ },
200
+ [resolvedGroupId, updateGroupAnimation],
201
+ );
202
+
203
+ const handleHighlightChange = useCallback(
204
+ (update: Partial<CaptionAnimation>) => {
205
+ if (resolvedGroupId) updateGroupAnimation(resolvedGroupId, "highlight", update);
206
+ },
207
+ [resolvedGroupId, updateGroupAnimation],
208
+ );
209
+
210
+ const handleExitChange = useCallback(
211
+ (update: Partial<CaptionAnimation>) => {
212
+ if (resolvedGroupId) updateGroupAnimation(resolvedGroupId, "exit", update);
213
+ },
214
+ [resolvedGroupId, updateGroupAnimation],
215
+ );
216
+
217
+ const handleApplyToAll = useCallback(() => {
218
+ if (animation) applyAnimationToAll(animation);
219
+ }, [animation, applyAnimationToAll]);
220
+
221
+ // Empty state — after all hooks
222
+ if (!group || !resolvedGroupId || !animation) {
223
+ return (
224
+ <div className="flex items-center justify-center h-full px-4 text-center">
225
+ <p className="text-xs text-neutral-500">Select a caption group to edit animations</p>
226
+ </div>
227
+ );
228
+ }
229
+
230
+ return (
231
+ <div className="flex flex-col h-full min-h-0">
232
+ {/* Scrollable content */}
233
+ <div className="flex-1 overflow-y-auto px-3 py-2">
234
+ <AnimationPhase
235
+ label="Entrance"
236
+ presets={ENTRANCE_PRESETS}
237
+ animation={animation.entrance}
238
+ onChange={handleEntranceChange}
239
+ />
240
+
241
+ <AnimationPhase
242
+ label="Highlight"
243
+ presets={HIGHLIGHT_PRESETS}
244
+ animation={animation.highlight}
245
+ showIntensity
246
+ onChange={handleHighlightChange}
247
+ />
248
+
249
+ <AnimationPhase
250
+ label="Exit"
251
+ presets={EXIT_PRESETS}
252
+ animation={animation.exit}
253
+ onChange={handleExitChange}
254
+ />
255
+ </div>
256
+
257
+ {/* Footer */}
258
+ <div className="flex-shrink-0 px-3 py-2 border-t border-neutral-800">
259
+ <button
260
+ type="button"
261
+ onClick={handleApplyToAll}
262
+ className="w-full py-1.5 rounded border border-neutral-700 text-2xs text-neutral-300 hover:border-studio-accent/50 hover:text-studio-accent transition-colors"
263
+ >
264
+ Apply to all groups
265
+ </button>
266
+ </div>
267
+ </div>
268
+ );
269
+ });