@hyperframes/studio 0.6.0-alpha.9 → 0.6.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/assets/hyperframes-player-CzwFysqv.js +418 -0
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-hYc4aP7M.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +421 -4303
- package/src/captions/components/CaptionOverlay.tsx +13 -246
- package/src/captions/components/CaptionOverlayUtils.ts +221 -0
- package/src/components/AskAgentModal.tsx +120 -0
- package/src/components/StudioHeader.tsx +133 -0
- package/src/components/StudioLeftSidebar.tsx +125 -0
- package/src/components/StudioPreviewArea.tsx +167 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +88 -993
- package/src/components/editor/EaseCurveEditor.tsx +221 -0
- package/src/components/editor/FileTree.tsx +13 -621
- package/src/components/editor/FileTreeIcons.tsx +128 -0
- package/src/components/editor/FileTreeNodes.tsx +496 -0
- package/src/components/editor/MotionPanel.tsx +16 -390
- package/src/components/editor/MotionPanelFields.tsx +185 -0
- package/src/components/editor/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- package/src/components/editor/domEditOverlayGeometry.ts +211 -0
- package/src/components/editor/domEditOverlayGestures.ts +138 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
- package/src/components/editor/domEditing.ts +44 -1117
- package/src/components/editor/domEditingAgentPrompt.ts +97 -0
- package/src/components/editor/domEditingDom.ts +266 -0
- package/src/components/editor/domEditingElement.ts +329 -0
- package/src/components/editor/domEditingLayers.ts +460 -0
- package/src/components/editor/domEditingTypes.ts +125 -0
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +84 -1049
- package/src/components/editor/manualEditsDom.ts +436 -0
- package/src/components/editor/manualEditsParsing.ts +280 -0
- package/src/components/editor/manualEditsSnapshot.ts +333 -0
- package/src/components/editor/manualEditsTypes.ts +141 -0
- package/src/components/editor/propertyPanelColor.tsx +371 -0
- package/src/components/editor/propertyPanelFill.tsx +421 -0
- package/src/components/editor/propertyPanelFont.tsx +455 -0
- package/src/components/editor/propertyPanelHelpers.ts +401 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
- package/src/components/editor/propertyPanelSections.tsx +453 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
- package/src/components/editor/studioMotion.ts +47 -434
- package/src/components/editor/studioMotionOps.ts +299 -0
- package/src/components/editor/studioMotionTypes.ts +168 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
- package/src/components/editor/useDomEditOverlayRects.ts +207 -0
- package/src/components/nle/NLELayout.tsx +68 -155
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/nle/useCompositionStack.ts +126 -0
- package/src/components/renders/RenderQueue.tsx +102 -31
- package/src/components/renders/useRenderQueue.ts +8 -2
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- package/src/contexts/DomEditContext.tsx +137 -0
- package/src/contexts/FileManagerContext.tsx +110 -0
- package/src/contexts/PanelLayoutContext.tsx +68 -0
- package/src/contexts/StudioContext.tsx +135 -0
- package/src/hooks/useAppHotkeys.ts +326 -0
- package/src/hooks/useAskAgentModal.ts +162 -0
- package/src/hooks/useCaptionDetection.ts +132 -0
- package/src/hooks/useCompositionDimensions.ts +25 -0
- package/src/hooks/useConsoleErrorCapture.ts +60 -0
- package/src/hooks/useDomEditCommits.ts +437 -0
- package/src/hooks/useDomEditSession.ts +342 -0
- package/src/hooks/useDomEditTextCommits.ts +330 -0
- package/src/hooks/useDomSelection.ts +398 -0
- package/src/hooks/useFileManager.ts +431 -0
- package/src/hooks/useFrameCapture.ts +77 -0
- package/src/hooks/useLintModal.ts +35 -0
- package/src/hooks/useManifestPersistence.ts +492 -0
- package/src/hooks/usePanelLayout.ts +68 -0
- package/src/hooks/usePreviewInteraction.ts +153 -0
- package/src/hooks/useRenderClipContent.ts +124 -0
- package/src/hooks/useTimelineEditing.ts +472 -0
- package/src/hooks/useToast.ts +20 -0
- package/src/player/components/Player.tsx +33 -2
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +196 -1518
- package/src/player/components/TimelineCanvas.tsx +434 -0
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/components/TimelineEmptyState.tsx +102 -0
- package/src/player/components/TimelineRuler.tsx +90 -0
- package/src/player/components/timelineIcons.tsx +49 -0
- package/src/player/components/timelineLayout.ts +215 -0
- package/src/player/components/timelineUtils.ts +211 -0
- package/src/player/components/useTimelineClipDrag.ts +388 -0
- package/src/player/components/useTimelinePlayhead.ts +200 -0
- package/src/player/components/useTimelineRangeSelection.ts +135 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
- package/src/player/hooks/useTimelinePlayer.ts +105 -1371
- package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
- package/src/player/lib/playbackAdapter.ts +145 -0
- package/src/player/lib/playbackShortcuts.ts +68 -0
- package/src/player/lib/playbackTypes.ts +60 -0
- package/src/player/lib/timelineDOM.ts +373 -0
- package/src/player/lib/timelineElementHelpers.ts +303 -0
- package/src/player/lib/timelineIframeHelpers.ts +269 -0
- package/src/utils/domEditHelpers.ts +50 -0
- package/src/utils/studioFontHelpers.ts +83 -0
- package/src/utils/studioHelpers.ts +214 -0
- package/src/utils/studioPreviewHelpers.ts +185 -0
- package/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
- package/dist/assets/index-14zH9lqh.css +0 -1
- package/dist/assets/index-DYCiFGWQ.js +0 -108
- package/src/player/components/TimelineClip.test.ts +0 -92
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useMountEffect } from "./useMountEffect";
|
|
3
|
+
import {
|
|
4
|
+
STUDIO_MANUAL_EDITS_PATH,
|
|
5
|
+
applyStudioManualEditManifest,
|
|
6
|
+
emptyStudioManualEditManifest,
|
|
7
|
+
installStudioManualEditSeekReapply,
|
|
8
|
+
isStudioManualEditManifestPath,
|
|
9
|
+
parseStudioManualEditManifest,
|
|
10
|
+
readStudioFileChangePath,
|
|
11
|
+
serializeStudioManualEditManifest,
|
|
12
|
+
type StudioManualEditManifest,
|
|
13
|
+
} from "../components/editor/manualEdits";
|
|
14
|
+
import {
|
|
15
|
+
STUDIO_MOTION_PATH,
|
|
16
|
+
applyStudioMotionManifest,
|
|
17
|
+
emptyStudioMotionManifest,
|
|
18
|
+
installStudioMotionSeekReapply,
|
|
19
|
+
isStudioMotionManifestPath,
|
|
20
|
+
parseStudioMotionManifest,
|
|
21
|
+
serializeStudioMotionManifest,
|
|
22
|
+
type StudioMotionManifest,
|
|
23
|
+
} from "../components/editor/studioMotion";
|
|
24
|
+
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
25
|
+
import type { EditHistoryKind } from "../utils/editHistory";
|
|
26
|
+
|
|
27
|
+
// ── Types ──
|
|
28
|
+
|
|
29
|
+
interface RecordEditInput {
|
|
30
|
+
label: string;
|
|
31
|
+
kind: EditHistoryKind;
|
|
32
|
+
coalesceKey?: string;
|
|
33
|
+
files: Record<string, { before: string; after: string }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface UseManifestPersistenceParams {
|
|
37
|
+
projectId: string | null;
|
|
38
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
39
|
+
readOptionalProjectFile: (path: string) => Promise<string>;
|
|
40
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
41
|
+
recordEdit: (entry: RecordEditInput) => Promise<void>;
|
|
42
|
+
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
43
|
+
activeCompPathRef: React.MutableRefObject<string | null>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Hook ──
|
|
47
|
+
|
|
48
|
+
export function useManifestPersistence({
|
|
49
|
+
projectId,
|
|
50
|
+
showToast,
|
|
51
|
+
readOptionalProjectFile,
|
|
52
|
+
writeProjectFile,
|
|
53
|
+
recordEdit,
|
|
54
|
+
previewIframeRef,
|
|
55
|
+
activeCompPathRef,
|
|
56
|
+
}: UseManifestPersistenceParams) {
|
|
57
|
+
const [, setStudioMotionRevision] = useState(0);
|
|
58
|
+
|
|
59
|
+
const domEditSaveTimestampRef = useRef(0);
|
|
60
|
+
const domTextCommitVersionRef = useRef(0);
|
|
61
|
+
const domEditSaveQueueRef = useRef(Promise.resolve());
|
|
62
|
+
const studioManualEditManifestRef = useRef<StudioManualEditManifest>(
|
|
63
|
+
emptyStudioManualEditManifest(),
|
|
64
|
+
);
|
|
65
|
+
const studioManualEditRevisionRef = useRef(0);
|
|
66
|
+
const studioMotionManifestRef = useRef<StudioMotionManifest>(emptyStudioMotionManifest());
|
|
67
|
+
const studioMotionRevisionRef = useRef(0);
|
|
68
|
+
const applyStudioManualEditsToPreviewRef = useRef<
|
|
69
|
+
(
|
|
70
|
+
iframe?: HTMLIFrameElement | null,
|
|
71
|
+
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
72
|
+
) => Promise<void>
|
|
73
|
+
>(async () => {});
|
|
74
|
+
const applyStudioMotionToPreviewRef = useRef<
|
|
75
|
+
(
|
|
76
|
+
iframe?: HTMLIFrameElement | null,
|
|
77
|
+
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
78
|
+
) => Promise<void>
|
|
79
|
+
>(async () => {});
|
|
80
|
+
const studioManualEditProjectRef = useRef<string | null>(projectId);
|
|
81
|
+
|
|
82
|
+
// Keep a ref to the latest projectId so async save callbacks always read the
|
|
83
|
+
// current value, even when the callback was captured in a stale closure.
|
|
84
|
+
const projectIdRef = useRef(projectId);
|
|
85
|
+
projectIdRef.current = projectId;
|
|
86
|
+
|
|
87
|
+
// ── Queue / drain helpers ──
|
|
88
|
+
|
|
89
|
+
const queueDomEditSave = useCallback((save: () => Promise<void>) => {
|
|
90
|
+
const queuedSave = domEditSaveQueueRef.current.catch(() => undefined).then(save);
|
|
91
|
+
domEditSaveQueueRef.current = queuedSave.then(
|
|
92
|
+
() => undefined,
|
|
93
|
+
() => undefined,
|
|
94
|
+
);
|
|
95
|
+
return queuedSave;
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
const waitForPendingDomEditSaves = useCallback(async () => {
|
|
99
|
+
await domEditSaveQueueRef.current.catch(() => undefined);
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
// ── Apply manual edits ──
|
|
103
|
+
|
|
104
|
+
const applyCurrentStudioManualEditsToPreview = useCallback(
|
|
105
|
+
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
|
106
|
+
if (!iframe) return;
|
|
107
|
+
let doc: Document | null = null;
|
|
108
|
+
try {
|
|
109
|
+
doc = iframe.contentDocument;
|
|
110
|
+
} catch {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (!doc) return;
|
|
114
|
+
const previewDoc = doc;
|
|
115
|
+
|
|
116
|
+
const applyManifest = () => {
|
|
117
|
+
applyStudioManualEditManifest(
|
|
118
|
+
previewDoc,
|
|
119
|
+
studioManualEditManifestRef.current,
|
|
120
|
+
activeCompPathRef.current,
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
const applyAndInstallSeekHooks = () => {
|
|
124
|
+
applyManifest();
|
|
125
|
+
if (iframe.contentWindow) {
|
|
126
|
+
installStudioManualEditSeekReapply(iframe.contentWindow, applyManifest);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const win = iframe.contentWindow;
|
|
131
|
+
applyAndInstallSeekHooks();
|
|
132
|
+
win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
|
|
133
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 80);
|
|
134
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 250);
|
|
135
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 500);
|
|
136
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 1000);
|
|
137
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 2000);
|
|
138
|
+
},
|
|
139
|
+
[activeCompPathRef, previewIframeRef],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const applyStudioManualEditsToPreview = useCallback(
|
|
143
|
+
async (
|
|
144
|
+
iframe: HTMLIFrameElement | null = previewIframeRef.current,
|
|
145
|
+
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
146
|
+
) => {
|
|
147
|
+
const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
|
|
148
|
+
if (!readFromDiskFirst) {
|
|
149
|
+
applyCurrentStudioManualEditsToPreview(iframe);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const readRevision = studioManualEditRevisionRef.current;
|
|
153
|
+
let content: string;
|
|
154
|
+
try {
|
|
155
|
+
content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
const message =
|
|
158
|
+
error instanceof Error ? error.message : "Failed to read manual edit manifest";
|
|
159
|
+
showToast(message);
|
|
160
|
+
applyCurrentStudioManualEditsToPreview(iframe);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) {
|
|
164
|
+
studioManualEditManifestRef.current = parseStudioManualEditManifest(content);
|
|
165
|
+
if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1;
|
|
166
|
+
}
|
|
167
|
+
applyCurrentStudioManualEditsToPreview(iframe);
|
|
168
|
+
},
|
|
169
|
+
[applyCurrentStudioManualEditsToPreview, previewIframeRef, readOptionalProjectFile, showToast],
|
|
170
|
+
);
|
|
171
|
+
applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
|
|
172
|
+
|
|
173
|
+
// ── Apply motion ──
|
|
174
|
+
|
|
175
|
+
const applyCurrentStudioMotionToPreview = useCallback(
|
|
176
|
+
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
|
177
|
+
if (!iframe) return;
|
|
178
|
+
let doc: Document | null = null;
|
|
179
|
+
try {
|
|
180
|
+
doc = iframe.contentDocument;
|
|
181
|
+
} catch {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!doc) return;
|
|
185
|
+
const previewDoc = doc;
|
|
186
|
+
|
|
187
|
+
const applyManifest = () => {
|
|
188
|
+
applyStudioMotionManifest(
|
|
189
|
+
previewDoc,
|
|
190
|
+
studioMotionManifestRef.current,
|
|
191
|
+
activeCompPathRef.current,
|
|
192
|
+
);
|
|
193
|
+
};
|
|
194
|
+
const applyAndInstallSeekHooks = () => {
|
|
195
|
+
applyManifest();
|
|
196
|
+
if (iframe.contentWindow) {
|
|
197
|
+
installStudioMotionSeekReapply(iframe.contentWindow, applyManifest);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const win = iframe.contentWindow;
|
|
202
|
+
win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
|
|
203
|
+
win?.setTimeout?.(applyAndInstallSeekHooks, 120);
|
|
204
|
+
},
|
|
205
|
+
[activeCompPathRef, previewIframeRef],
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const applyStudioMotionToPreview = useCallback(
|
|
209
|
+
async (
|
|
210
|
+
iframe: HTMLIFrameElement | null = previewIframeRef.current,
|
|
211
|
+
options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
|
|
212
|
+
) => {
|
|
213
|
+
const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
|
|
214
|
+
if (!readFromDiskFirst) {
|
|
215
|
+
applyCurrentStudioMotionToPreview(iframe);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const readRevision = studioMotionRevisionRef.current;
|
|
219
|
+
let content: string;
|
|
220
|
+
try {
|
|
221
|
+
content = await readOptionalProjectFile(STUDIO_MOTION_PATH);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
const message = error instanceof Error ? error.message : "Failed to read motion manifest";
|
|
224
|
+
showToast(message);
|
|
225
|
+
applyCurrentStudioMotionToPreview(iframe);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (options?.forceFromDisk || readRevision === studioMotionRevisionRef.current) {
|
|
229
|
+
studioMotionManifestRef.current = parseStudioMotionManifest(content);
|
|
230
|
+
if (options?.forceFromDisk) studioMotionRevisionRef.current += 1;
|
|
231
|
+
setStudioMotionRevision((revision) => revision + 1);
|
|
232
|
+
}
|
|
233
|
+
applyCurrentStudioMotionToPreview(iframe);
|
|
234
|
+
},
|
|
235
|
+
[applyCurrentStudioMotionToPreview, previewIframeRef, readOptionalProjectFile, showToast],
|
|
236
|
+
);
|
|
237
|
+
applyStudioMotionToPreviewRef.current = applyStudioMotionToPreview;
|
|
238
|
+
|
|
239
|
+
// ── Optimistic commits ──
|
|
240
|
+
|
|
241
|
+
const commitStudioManualEditManifestOptimistically = useCallback(
|
|
242
|
+
(
|
|
243
|
+
updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
|
|
244
|
+
options: { label: string; coalesceKey: string },
|
|
245
|
+
) => {
|
|
246
|
+
const previousManifest = studioManualEditManifestRef.current;
|
|
247
|
+
const nextManifest = updateManifest(previousManifest);
|
|
248
|
+
const previousContent = serializeStudioManualEditManifest(previousManifest);
|
|
249
|
+
const nextContent = serializeStudioManualEditManifest(nextManifest);
|
|
250
|
+
if (nextContent === previousContent) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const revision = studioManualEditRevisionRef.current + 1;
|
|
255
|
+
studioManualEditRevisionRef.current = revision;
|
|
256
|
+
studioManualEditManifestRef.current = nextManifest;
|
|
257
|
+
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
258
|
+
|
|
259
|
+
const save = async () => {
|
|
260
|
+
const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
|
|
261
|
+
const diskManifest = parseStudioManualEditManifest(originalContent);
|
|
262
|
+
const nextDiskManifest = updateManifest(diskManifest);
|
|
263
|
+
const nextDiskContent = serializeStudioManualEditManifest(nextDiskManifest);
|
|
264
|
+
if (nextDiskContent === originalContent) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const pid = projectIdRef.current;
|
|
269
|
+
if (!pid) throw new Error("No active project");
|
|
270
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
271
|
+
await saveProjectFilesWithHistory({
|
|
272
|
+
projectId: pid,
|
|
273
|
+
label: options.label,
|
|
274
|
+
kind: "manual",
|
|
275
|
+
coalesceKey: options.coalesceKey,
|
|
276
|
+
files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent },
|
|
277
|
+
readFile: async () => originalContent,
|
|
278
|
+
writeFile: writeProjectFile,
|
|
279
|
+
recordEdit,
|
|
280
|
+
});
|
|
281
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
282
|
+
|
|
283
|
+
if (studioManualEditRevisionRef.current === revision) {
|
|
284
|
+
studioManualEditManifestRef.current = nextDiskManifest;
|
|
285
|
+
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
void queueDomEditSave(save).catch((error) => {
|
|
290
|
+
if (studioManualEditRevisionRef.current === revision) {
|
|
291
|
+
studioManualEditRevisionRef.current += 1;
|
|
292
|
+
studioManualEditManifestRef.current = previousManifest;
|
|
293
|
+
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
|
294
|
+
}
|
|
295
|
+
const message = error instanceof Error ? error.message : "Failed to save manual edit";
|
|
296
|
+
showToast(message);
|
|
297
|
+
});
|
|
298
|
+
},
|
|
299
|
+
[
|
|
300
|
+
applyCurrentStudioManualEditsToPreview,
|
|
301
|
+
recordEdit,
|
|
302
|
+
queueDomEditSave,
|
|
303
|
+
readOptionalProjectFile,
|
|
304
|
+
showToast,
|
|
305
|
+
writeProjectFile,
|
|
306
|
+
previewIframeRef,
|
|
307
|
+
],
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const commitStudioMotionManifestOptimistically = useCallback(
|
|
311
|
+
(
|
|
312
|
+
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
|
|
313
|
+
options: { label: string; coalesceKey: string },
|
|
314
|
+
) => {
|
|
315
|
+
const previousManifest = studioMotionManifestRef.current;
|
|
316
|
+
const nextManifest = updateManifest(previousManifest);
|
|
317
|
+
const previousContent = serializeStudioMotionManifest(previousManifest);
|
|
318
|
+
const nextContent = serializeStudioMotionManifest(nextManifest);
|
|
319
|
+
if (nextContent === previousContent) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const revision = studioMotionRevisionRef.current + 1;
|
|
324
|
+
studioMotionRevisionRef.current = revision;
|
|
325
|
+
studioMotionManifestRef.current = nextManifest;
|
|
326
|
+
setStudioMotionRevision((current) => current + 1);
|
|
327
|
+
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
|
328
|
+
|
|
329
|
+
const save = async () => {
|
|
330
|
+
const originalContent = await readOptionalProjectFile(STUDIO_MOTION_PATH);
|
|
331
|
+
const diskManifest = parseStudioMotionManifest(originalContent);
|
|
332
|
+
const nextDiskManifest = updateManifest(diskManifest);
|
|
333
|
+
const nextDiskContent = serializeStudioMotionManifest(nextDiskManifest);
|
|
334
|
+
if (nextDiskContent === originalContent) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const pid = projectIdRef.current;
|
|
339
|
+
if (!pid) throw new Error("No active project");
|
|
340
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
341
|
+
await saveProjectFilesWithHistory({
|
|
342
|
+
projectId: pid,
|
|
343
|
+
label: options.label,
|
|
344
|
+
kind: "motion",
|
|
345
|
+
coalesceKey: options.coalesceKey,
|
|
346
|
+
files: { [STUDIO_MOTION_PATH]: nextDiskContent },
|
|
347
|
+
readFile: async () => originalContent,
|
|
348
|
+
writeFile: writeProjectFile,
|
|
349
|
+
recordEdit,
|
|
350
|
+
});
|
|
351
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
352
|
+
|
|
353
|
+
if (studioMotionRevisionRef.current === revision) {
|
|
354
|
+
studioMotionManifestRef.current = nextDiskManifest;
|
|
355
|
+
setStudioMotionRevision((current) => current + 1);
|
|
356
|
+
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
void queueDomEditSave(save).catch((error) => {
|
|
361
|
+
if (studioMotionRevisionRef.current === revision) {
|
|
362
|
+
studioMotionRevisionRef.current += 1;
|
|
363
|
+
studioMotionManifestRef.current = previousManifest;
|
|
364
|
+
setStudioMotionRevision((current) => current + 1);
|
|
365
|
+
applyCurrentStudioMotionToPreview(previewIframeRef.current);
|
|
366
|
+
}
|
|
367
|
+
const message = error instanceof Error ? error.message : "Failed to save motion edit";
|
|
368
|
+
showToast(message);
|
|
369
|
+
});
|
|
370
|
+
},
|
|
371
|
+
[
|
|
372
|
+
applyCurrentStudioMotionToPreview,
|
|
373
|
+
recordEdit,
|
|
374
|
+
queueDomEditSave,
|
|
375
|
+
readOptionalProjectFile,
|
|
376
|
+
showToast,
|
|
377
|
+
writeProjectFile,
|
|
378
|
+
previewIframeRef,
|
|
379
|
+
],
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// ── Sync preview after undo/redo ──
|
|
383
|
+
|
|
384
|
+
const syncHistoryPreviewAfterApply = useCallback(
|
|
385
|
+
async (paths: string[] | undefined) => {
|
|
386
|
+
const changedPaths = paths ?? [];
|
|
387
|
+
const manualManifestOnly =
|
|
388
|
+
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MANUAL_EDITS_PATH);
|
|
389
|
+
const motionManifestOnly =
|
|
390
|
+
changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MOTION_PATH);
|
|
391
|
+
|
|
392
|
+
if (manualManifestOnly) {
|
|
393
|
+
await applyStudioManualEditsToPreview(previewIframeRef.current, { forceFromDisk: true });
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (motionManifestOnly) {
|
|
397
|
+
await applyStudioMotionToPreview(previewIframeRef.current, { forceFromDisk: true });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Reload the iframe in-place rather than recreating the Player component.
|
|
402
|
+
// This preserves the <hyperframes-player> web component and its shader
|
|
403
|
+
// transition cache — only the iframe document reloads, so transitions that
|
|
404
|
+
// weren't touched by the undo/redo don't need to rebuild from scratch.
|
|
405
|
+
const iframe = previewIframeRef.current;
|
|
406
|
+
if (iframe?.contentWindow) {
|
|
407
|
+
try {
|
|
408
|
+
iframe.contentWindow.location.reload();
|
|
409
|
+
return;
|
|
410
|
+
} catch {
|
|
411
|
+
// Cross-origin or detached — fall through to full refresh
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
[applyStudioManualEditsToPreview, applyStudioMotionToPreview, previewIframeRef],
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
// ── Reset manifests when project changes ──
|
|
419
|
+
|
|
420
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
421
|
+
useEffect(() => {
|
|
422
|
+
const previousProjectId = studioManualEditProjectRef.current;
|
|
423
|
+
studioManualEditProjectRef.current = projectId;
|
|
424
|
+
if (!previousProjectId || previousProjectId === projectId) return;
|
|
425
|
+
studioManualEditManifestRef.current = emptyStudioManualEditManifest();
|
|
426
|
+
studioManualEditRevisionRef.current += 1;
|
|
427
|
+
studioMotionManifestRef.current = emptyStudioMotionManifest();
|
|
428
|
+
studioMotionRevisionRef.current += 1;
|
|
429
|
+
setStudioMotionRevision((revision) => revision + 1);
|
|
430
|
+
}, [projectId]);
|
|
431
|
+
|
|
432
|
+
// ── Listen for external file changes (HMR / SSE) ──
|
|
433
|
+
// In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
|
|
434
|
+
// Suppress file-change events that echo back from a recent DOM edit save —
|
|
435
|
+
// those changes are already applied to the iframe DOM and a full reload
|
|
436
|
+
// would flash the preview.
|
|
437
|
+
useMountEffect(() => {
|
|
438
|
+
const handler = (payload?: unknown) => {
|
|
439
|
+
const changedPath = readStudioFileChangePath(payload);
|
|
440
|
+
const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
|
|
441
|
+
if (isStudioManualEditManifestPath(changedPath)) {
|
|
442
|
+
if (!recentDomEditSave) {
|
|
443
|
+
void applyStudioManualEditsToPreviewRef.current(previewIframeRef.current, {
|
|
444
|
+
forceFromDisk: true,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (isStudioMotionManifestPath(changedPath)) {
|
|
450
|
+
if (!recentDomEditSave) {
|
|
451
|
+
void applyStudioMotionToPreviewRef.current(previewIframeRef.current, {
|
|
452
|
+
forceFromDisk: true,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
// Non-manifest file changes are not handled here — the caller is
|
|
458
|
+
// responsible for triggering a preview refresh via onExternalFileChange
|
|
459
|
+
// if needed. This hook only suppresses echoes and handles manifest reloads.
|
|
460
|
+
};
|
|
461
|
+
if (import.meta.hot) {
|
|
462
|
+
import.meta.hot.on("hf:file-change", handler);
|
|
463
|
+
return () => import.meta.hot?.off?.("hf:file-change", handler);
|
|
464
|
+
}
|
|
465
|
+
// SSE fallback for embedded studio server
|
|
466
|
+
const es = new EventSource("/api/events");
|
|
467
|
+
es.addEventListener("file-change", handler);
|
|
468
|
+
return () => es.close();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
domEditSaveTimestampRef,
|
|
473
|
+
domTextCommitVersionRef,
|
|
474
|
+
domEditSaveQueueRef,
|
|
475
|
+
studioManualEditManifestRef,
|
|
476
|
+
studioManualEditRevisionRef,
|
|
477
|
+
studioMotionManifestRef,
|
|
478
|
+
studioMotionRevisionRef,
|
|
479
|
+
applyStudioManualEditsToPreviewRef,
|
|
480
|
+
applyStudioMotionToPreviewRef,
|
|
481
|
+
studioManualEditProjectRef,
|
|
482
|
+
queueDomEditSave,
|
|
483
|
+
waitForPendingDomEditSaves,
|
|
484
|
+
applyCurrentStudioManualEditsToPreview,
|
|
485
|
+
applyStudioManualEditsToPreview,
|
|
486
|
+
applyCurrentStudioMotionToPreview,
|
|
487
|
+
applyStudioMotionToPreview,
|
|
488
|
+
commitStudioManualEditManifestOptimistically,
|
|
489
|
+
commitStudioMotionManifestOptimistically,
|
|
490
|
+
syncHistoryPreviewAfterApply,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from "react";
|
|
2
|
+
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
3
|
+
|
|
4
|
+
export function usePanelLayout() {
|
|
5
|
+
const [leftWidth, setLeftWidth] = useState(240);
|
|
6
|
+
const [rightWidth, setRightWidth] = useState(400);
|
|
7
|
+
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
8
|
+
const [rightCollapsed, setRightCollapsed] = useState(true);
|
|
9
|
+
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
|
|
10
|
+
const panelDragRef = useRef<{
|
|
11
|
+
side: "left" | "right";
|
|
12
|
+
startX: number;
|
|
13
|
+
startW: number;
|
|
14
|
+
} | null>(null);
|
|
15
|
+
|
|
16
|
+
const toggleLeftSidebar = useCallback(() => {
|
|
17
|
+
setLeftCollapsed((collapsed) => !collapsed);
|
|
18
|
+
}, []);
|
|
19
|
+
|
|
20
|
+
const handlePanelResizeStart = useCallback(
|
|
21
|
+
(side: "left" | "right", e: React.PointerEvent) => {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
24
|
+
panelDragRef.current = {
|
|
25
|
+
side,
|
|
26
|
+
startX: e.clientX,
|
|
27
|
+
startW: side === "left" ? leftWidth : rightWidth,
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
[leftWidth, rightWidth],
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const handlePanelResizeMove = useCallback((e: React.PointerEvent) => {
|
|
34
|
+
const drag = panelDragRef.current;
|
|
35
|
+
if (!drag) return;
|
|
36
|
+
const delta = e.clientX - drag.startX;
|
|
37
|
+
const maxLeft = Math.floor(window.innerWidth * 0.5);
|
|
38
|
+
const newW = Math.max(
|
|
39
|
+
160,
|
|
40
|
+
Math.min(
|
|
41
|
+
drag.side === "left" ? maxLeft : 600,
|
|
42
|
+
drag.startW + (drag.side === "left" ? delta : -delta),
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
if (drag.side === "left") setLeftWidth(newW);
|
|
46
|
+
else setRightWidth(newW);
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const handlePanelResizeEnd = useCallback(() => {
|
|
50
|
+
panelDragRef.current = null;
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
leftWidth,
|
|
55
|
+
setLeftWidth,
|
|
56
|
+
rightWidth,
|
|
57
|
+
leftCollapsed,
|
|
58
|
+
setLeftCollapsed,
|
|
59
|
+
rightCollapsed,
|
|
60
|
+
setRightCollapsed,
|
|
61
|
+
rightPanelTab,
|
|
62
|
+
setRightPanelTab,
|
|
63
|
+
toggleLeftSidebar,
|
|
64
|
+
handlePanelResizeStart,
|
|
65
|
+
handlePanelResizeMove,
|
|
66
|
+
handlePanelResizeEnd,
|
|
67
|
+
};
|
|
68
|
+
}
|