@hyperframes/studio 0.6.99 → 0.6.101
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-BITwbxi-.css +1 -0
- package/dist/assets/{index-C52IT_lp.js → index-CQ3n6Y9q.js} +1 -1
- package/dist/assets/index-CTiqZ7XQ.js +296 -0
- package/dist/assets/{index-DOh7E1uj.js → index-DvttAtOD.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +5 -4
- package/src/App.tsx +13 -13
- package/src/components/editor/PropertyPanel.tsx +24 -16
- package/src/components/editor/manualEditingAvailability.ts +12 -1
- package/src/components/nle/NLELayout.tsx +89 -1
- package/src/components/renders/useRenderQueue.ts +12 -8
- package/src/hooks/gsapScriptCommitHelpers.ts +8 -5
- package/src/hooks/gsapScriptCommitTypes.ts +3 -0
- package/src/hooks/gsapTargetCache.ts +65 -0
- package/src/hooks/useAppHotkeys.ts +10 -0
- package/src/hooks/useDomEditCommits.ts +12 -14
- package/src/hooks/useDomEditSession.ts +13 -0
- package/src/hooks/useDomGeometryCommits.ts +1 -36
- package/src/hooks/useElementLifecycleOps.ts +5 -0
- package/src/hooks/useGsapAnimationOps.ts +26 -1
- package/src/hooks/useGsapScriptCommits.ts +5 -2
- package/src/hooks/useRazorSplit.ts +3 -0
- package/src/hooks/useSdkSelectionSync.ts +25 -0
- package/src/hooks/useSdkSession.test.ts +20 -0
- package/src/hooks/useSdkSession.ts +101 -0
- package/src/hooks/useTimelineEditing.ts +23 -3
- package/src/player/components/Timeline.tsx +31 -18
- package/src/player/components/TimelineClip.tsx +3 -3
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +30 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +91 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +153 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +22 -0
- package/src/player/store/playerStore.ts +22 -8
- package/src/telemetry/events.test.ts +16 -1
- package/src/telemetry/events.ts +15 -0
- package/src/utils/blockCategories.ts +2 -2
- package/src/utils/sdkShadow.test.ts +246 -0
- package/src/utils/sdkShadow.ts +404 -0
- package/src/utils/studioHelpers.test.ts +25 -1
- package/src/utils/studioHelpers.ts +54 -28
- package/dist/assets/index-B62bDCQv.css +0 -1
- package/dist/assets/index-DrwSRbsl.js +0 -252
|
@@ -1 +1 @@
|
|
|
1
|
-
import{g as P}from"./index-
|
|
1
|
+
import{g as P}from"./index-CTiqZ7XQ.js";function j(c,d){for(var s=0;s<d.length;s++){const a=d[s];if(typeof a!="string"&&!Array.isArray(a)){for(const i in a)if(i!=="default"&&!(i in c)){const l=Object.getOwnPropertyDescriptor(a,i);l&&Object.defineProperty(c,i,l.get?l:{enumerable:!0,get:()=>a[i]})}}}return Object.freeze(Object.defineProperty(c,Symbol.toStringTag,{value:"Module"}))}var v={},w;function k(){if(w)return v;w=1,Object.defineProperty(v,"__esModule",{value:!0}),v.default=d;var c=window.OfflineAudioContext||window.webkitOfflineAudioContext;function d(e){var r=a(e);return r.start(0),[i,y,O(e.sampleRate),s].reduce(function(t,o){return o(t)},r.buffer.getChannelData(0))}function s(e){return e.sort(function(r,t){return t.count-r.count}).splice(0,5)[0].tempo}function a(e){var r=e.length,t=e.numberOfChannels,o=e.sampleRate,n=new c(t,r,o),u=n.createBufferSource();u.buffer=e;var f=n.createBiquadFilter();return f.type="lowpass",u.connect(f),f.connect(n.destination),u}function i(e){for(var r=[],t=.9,o=.3,n=15;r.length<n&&t>=o;)r=l(e,t),t-=.05;if(r.length<n)throw new Error("Could not find enough samples for a reliable detection.");return r}function l(e,r){for(var t=[],o=0,n=e.length;o<n;o+=1)e[o]>r&&(t.push(o),o+=1e4);return t}function y(e){var r=[];return e.forEach(function(t,o){for(var n=function(x){var g=e[o+x]-t,_=r.some(function(h){if(h.interval===g)return h.count+=1});_||r.push({interval:g,count:1})},u=0;u<10;u+=1)n(u)}),r}function O(e){return function(r){var t=[];return r.forEach(function(o){if(o.interval!==0){for(var n=60/(o.interval/e);n<90;)n*=2;for(;n>180;)n/=2;n=Math.round(n);var u=t.some(function(f){if(f.tempo===n)return f.count+=o.count});u||t.push({tempo:n,count:o.count})}}),t}}return v}var p,b;function q(){return b||(b=1,p=k().default),p}var m=q();const A=P(m),D=j({__proto__:null,default:A},[m]);export{D as i};
|
package/dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>HyperFrames Studio</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-CTiqZ7XQ.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BITwbxi-.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.101",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -33,8 +33,9 @@
|
|
|
33
33
|
"@phosphor-icons/react": "^2.1.10",
|
|
34
34
|
"bpm-detective": "^2.0.5",
|
|
35
35
|
"mediabunny": "^1.45.3",
|
|
36
|
-
"@hyperframes/core": "0.6.
|
|
37
|
-
"@hyperframes/player": "0.6.
|
|
36
|
+
"@hyperframes/core": "0.6.101",
|
|
37
|
+
"@hyperframes/player": "0.6.101",
|
|
38
|
+
"@hyperframes/sdk": "0.6.101"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
41
|
"@types/react": "19",
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
"vite": "^6.4.2",
|
|
49
50
|
"vitest": "^3.2.4",
|
|
50
51
|
"zustand": "^5.0.0",
|
|
51
|
-
"@hyperframes/producer": "0.6.
|
|
52
|
+
"@hyperframes/producer": "0.6.101"
|
|
52
53
|
},
|
|
53
54
|
"peerDependencies": {
|
|
54
55
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -13,6 +13,8 @@ import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
|
|
|
13
13
|
import { useTimelineEditing } from "./hooks/useTimelineEditing";
|
|
14
14
|
import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
|
|
15
15
|
import { useDomEditSession } from "./hooks/useDomEditSession";
|
|
16
|
+
import { useSdkSession } from "./hooks/useSdkSession";
|
|
17
|
+
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
|
|
16
18
|
import { useBlockHandlers } from "./hooks/useBlockHandlers";
|
|
17
19
|
import { useAppHotkeys } from "./hooks/useAppHotkeys";
|
|
18
20
|
import { useClipboard } from "./hooks/useClipboard";
|
|
@@ -173,6 +175,7 @@ export function StudioApp() {
|
|
|
173
175
|
reloadPreview: () => setRefreshKey((k) => k + 1),
|
|
174
176
|
pendingTimelineEditPathRef,
|
|
175
177
|
});
|
|
178
|
+
const sdkSession = useSdkSession(projectId, activeCompPath);
|
|
176
179
|
const timelineEditing = useTimelineEditing({
|
|
177
180
|
projectId,
|
|
178
181
|
activeCompPath,
|
|
@@ -186,6 +189,7 @@ export function StudioApp() {
|
|
|
186
189
|
pendingTimelineEditPathRef,
|
|
187
190
|
uploadProjectFiles: fileManager.uploadProjectFiles,
|
|
188
191
|
isRecordingRef: isGestureRecordingRef,
|
|
192
|
+
sdkSession,
|
|
189
193
|
});
|
|
190
194
|
const {
|
|
191
195
|
activeBlockParams,
|
|
@@ -299,6 +303,7 @@ export function StudioApp() {
|
|
|
299
303
|
openSourceForSelection: fileManager.openSourceForSelection,
|
|
300
304
|
selectSidebarTab: selectSidebarTabStable,
|
|
301
305
|
getSidebarTab: getSidebarTabStable,
|
|
306
|
+
sdkSession,
|
|
302
307
|
});
|
|
303
308
|
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
|
|
304
309
|
clearDomSelectionRef.current = domEditSession.clearDomSelection;
|
|
@@ -314,6 +319,12 @@ export function StudioApp() {
|
|
|
314
319
|
domEditSession.handleGsapRemoveKeyframe(a.id, p);
|
|
315
320
|
}
|
|
316
321
|
};
|
|
322
|
+
useSdkSelectionSync(
|
|
323
|
+
sdkSession,
|
|
324
|
+
domEditSession.domEditSelection,
|
|
325
|
+
domEditSession.domEditGroupSelections,
|
|
326
|
+
);
|
|
327
|
+
|
|
317
328
|
useCaptionDetection({
|
|
318
329
|
projectId,
|
|
319
330
|
activeCompPath,
|
|
@@ -419,17 +430,6 @@ export function StudioApp() {
|
|
|
419
430
|
applyDomSelection: domEditSession.applyDomSelection,
|
|
420
431
|
initialState: initialUrlStateRef.current,
|
|
421
432
|
});
|
|
422
|
-
const { jobs, isRendering, deleteRender, clearCompleted, startRender } = renderQueue;
|
|
423
|
-
const stableRenderQueue = useMemo(
|
|
424
|
-
() => ({
|
|
425
|
-
jobs,
|
|
426
|
-
isRendering,
|
|
427
|
-
deleteRender,
|
|
428
|
-
clearCompleted,
|
|
429
|
-
startRender: startRender as (options: unknown) => Promise<void>,
|
|
430
|
-
}),
|
|
431
|
-
[jobs, isRendering, deleteRender, clearCompleted, startRender],
|
|
432
|
-
);
|
|
433
433
|
const studioCtxValue = buildStudioContextValue({
|
|
434
434
|
projectId: projectId!,
|
|
435
435
|
activeCompPath,
|
|
@@ -445,7 +445,7 @@ export function StudioApp() {
|
|
|
445
445
|
editHistory,
|
|
446
446
|
handleUndo: appHotkeys.handleUndo,
|
|
447
447
|
handleRedo: appHotkeys.handleRedo,
|
|
448
|
-
renderQueue
|
|
448
|
+
renderQueue,
|
|
449
449
|
compositionDimensions,
|
|
450
450
|
waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
|
|
451
451
|
handlePreviewIframeRef,
|
|
@@ -485,7 +485,7 @@ export function StudioApp() {
|
|
|
485
485
|
refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime}
|
|
486
486
|
inspectorButtonActive={inspectorButtonActive}
|
|
487
487
|
inspectorPanelActive={inspectorPanelActive}
|
|
488
|
-
onExport={() => void renderQueue.startRender()}
|
|
488
|
+
onExport={() => void renderQueue.startRender(undefined)}
|
|
489
489
|
/>
|
|
490
490
|
{previewPersistence.domEditSaveQueuePaused && (
|
|
491
491
|
<SaveQueuePausedBanner
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo, useEffect, useRef, useState } from "react";
|
|
1
|
+
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { Eye, Layers, Move, X } from "../../icons/SystemIcons";
|
|
3
3
|
import { useStudioShellContext } from "../../contexts/StudioContext";
|
|
4
4
|
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
|
@@ -111,6 +111,29 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
111
111
|
const cacheElementKey = element?.id ?? element?.selector ?? "";
|
|
112
112
|
const cacheEntry = usePlayerStore((s) => s.keyframeCache.get(cacheElementKey));
|
|
113
113
|
|
|
114
|
+
const iframeRef = previewIframeRef ?? { current: null };
|
|
115
|
+
const gsapAnimIdForMemo = element
|
|
116
|
+
? (gsapAnimations?.find((a: { keyframes?: unknown }) => a.keyframes)?.id ??
|
|
117
|
+
gsapAnimations?.[0]?.id ??
|
|
118
|
+
null)
|
|
119
|
+
: null;
|
|
120
|
+
const gsapRuntimeValues = useMemo(
|
|
121
|
+
() =>
|
|
122
|
+
element
|
|
123
|
+
? readGsapRuntimeValuesForPanel(gsapAnimIdForMemo, gsapAnimations, element, iframeRef)
|
|
124
|
+
: null,
|
|
125
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- iframeRef is stable; currentTime drives re-reads during playback
|
|
126
|
+
[gsapAnimIdForMemo, gsapAnimations, element, currentTime],
|
|
127
|
+
);
|
|
128
|
+
const gsapBorderRadius = useMemo(
|
|
129
|
+
() =>
|
|
130
|
+
element
|
|
131
|
+
? readGsapBorderRadiusForPanel(gsapRuntimeValues, gsapAnimations, element, iframeRef)
|
|
132
|
+
: null,
|
|
133
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
134
|
+
[gsapRuntimeValues, gsapAnimations, element, currentTime],
|
|
135
|
+
);
|
|
136
|
+
|
|
114
137
|
if (!element) {
|
|
115
138
|
return (
|
|
116
139
|
<div className="flex h-full flex-col bg-neutral-900">
|
|
@@ -194,21 +217,6 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
194
217
|
return gsapAnimId ?? "";
|
|
195
218
|
};
|
|
196
219
|
|
|
197
|
-
// Read ALL GSAP-interpolated values at the current seek time.
|
|
198
|
-
const gsapRuntimeValues = readGsapRuntimeValuesForPanel(
|
|
199
|
-
gsapAnimId,
|
|
200
|
-
gsapAnimations,
|
|
201
|
-
element,
|
|
202
|
-
previewIframeRef ?? { current: null },
|
|
203
|
-
);
|
|
204
|
-
|
|
205
|
-
const gsapBorderRadius = readGsapBorderRadiusForPanel(
|
|
206
|
-
gsapRuntimeValues,
|
|
207
|
-
gsapAnimations,
|
|
208
|
-
element,
|
|
209
|
-
previewIframeRef ?? { current: null },
|
|
210
|
-
);
|
|
211
|
-
|
|
212
220
|
const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
|
|
213
221
|
const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
|
|
214
222
|
const displayW = gsapRuntimeValues?.width ?? resolvedWidth;
|
|
@@ -73,7 +73,7 @@ export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
|
|
|
73
73
|
export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
|
|
74
74
|
env,
|
|
75
75
|
["VITE_STUDIO_ENABLE_RAZOR_TOOL", "VITE_STUDIO_RAZOR_TOOL_ENABLED"],
|
|
76
|
-
|
|
76
|
+
true,
|
|
77
77
|
);
|
|
78
78
|
|
|
79
79
|
// When disabled (the default), drag/resize/rotate commits always take the CSS
|
|
@@ -88,4 +88,15 @@ export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
|
|
|
88
88
|
|
|
89
89
|
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
|
|
90
90
|
|
|
91
|
+
// Stage 7 Step 3b: shadow dispatch parity mode — dispatches ops to the SDK
|
|
92
|
+
// session alongside the server patch path and logs mismatches via telemetry.
|
|
93
|
+
// Default on: server stays authoritative (no user-visible change), so we want
|
|
94
|
+
// the sdk_shadow_dispatch parity signal from all traffic. Disable via
|
|
95
|
+
// VITE_STUDIO_SDK_SHADOW_ENABLED=false.
|
|
96
|
+
export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
|
|
97
|
+
env,
|
|
98
|
+
["VITE_STUDIO_SDK_SHADOW_ENABLED"],
|
|
99
|
+
true,
|
|
100
|
+
);
|
|
101
|
+
|
|
91
102
|
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
|
|
@@ -10,10 +10,13 @@ import {
|
|
|
10
10
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
11
11
|
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
|
|
12
12
|
import type { TimelineElement } from "../../player";
|
|
13
|
+
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
13
14
|
import { NLEPreview } from "./NLEPreview";
|
|
14
15
|
import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
|
|
15
16
|
import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
|
|
16
17
|
import { useCompositionStack } from "./useCompositionStack";
|
|
18
|
+
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
|
19
|
+
import { trackStudioExpandedClipEdit } from "../../telemetry/events";
|
|
17
20
|
import {
|
|
18
21
|
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
|
19
22
|
getTimelineToggleTitle,
|
|
@@ -58,6 +61,7 @@ interface NLELayoutProps {
|
|
|
58
61
|
blockName: string,
|
|
59
62
|
position: { left: number; top: number },
|
|
60
63
|
) => Promise<void> | void;
|
|
64
|
+
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
61
65
|
onSelectTimelineElement?: (element: TimelineElement | null) => void;
|
|
62
66
|
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
|
|
63
67
|
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
|
@@ -103,6 +107,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
103
107
|
onAssetDrop,
|
|
104
108
|
onBlockDrop,
|
|
105
109
|
onPreviewBlockDrop,
|
|
110
|
+
onBlockedEditAttempt,
|
|
106
111
|
onSelectTimelineElement,
|
|
107
112
|
onCompIdToSrcChange,
|
|
108
113
|
timelineVisible,
|
|
@@ -175,6 +180,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
175
180
|
const handleDrillDown = useCallback(
|
|
176
181
|
(element: TimelineElement) => {
|
|
177
182
|
if (!element.compositionSrc) return;
|
|
183
|
+
usePlayerStore.getState().setSelectedElementId(null);
|
|
178
184
|
// Check compIdToSrc map first; then scan iframe DOM; then fall through to drillDown
|
|
179
185
|
const compId = element.id;
|
|
180
186
|
let resolvedPath = compIdToSrc.get(compId);
|
|
@@ -202,6 +208,73 @@ export const NLELayout = memo(function NLELayout({
|
|
|
202
208
|
[compIdToSrc, drillDown, iframeRef_],
|
|
203
209
|
);
|
|
204
210
|
|
|
211
|
+
// Move/resize/split come from the timeline edit context, not props — the
|
|
212
|
+
// wrappers below intercept expanded clips and must call the *real* handlers.
|
|
213
|
+
// (Delete is a direct prop; it stays that way.)
|
|
214
|
+
const { onMoveElement, onResizeElement, onSplitElement } = useTimelineEditContext();
|
|
215
|
+
|
|
216
|
+
// An expanded sub-comp child reaches the normal edit handlers in its own
|
|
217
|
+
// local coordinates: addressed by its real DOM id, with timeline time rebased
|
|
218
|
+
// onto the sub-comp it lives in. The handlers then save + reloadPreview exactly
|
|
219
|
+
// as they do for top-level clips — no separate live-DOM path.
|
|
220
|
+
const toLocalElement = useCallback(
|
|
221
|
+
(element: TimelineElement, basis: number): TimelineElement => ({
|
|
222
|
+
...element,
|
|
223
|
+
id: element.domId ?? element.id,
|
|
224
|
+
start: element.start - basis,
|
|
225
|
+
}),
|
|
226
|
+
[],
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const handleMoveElement = useCallback(
|
|
230
|
+
(element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
|
231
|
+
const basis = element.expandedParentStart;
|
|
232
|
+
if (basis === undefined) return onMoveElement?.(element, updates);
|
|
233
|
+
trackStudioExpandedClipEdit({ action: "move" });
|
|
234
|
+
onMoveElement?.(toLocalElement(element, basis), {
|
|
235
|
+
...updates,
|
|
236
|
+
start: Math.max(0, updates.start - basis),
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
[onMoveElement, toLocalElement],
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const handleResizeElement = useCallback(
|
|
243
|
+
(
|
|
244
|
+
element: TimelineElement,
|
|
245
|
+
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
246
|
+
) => {
|
|
247
|
+
const basis = element.expandedParentStart;
|
|
248
|
+
if (basis === undefined) return onResizeElement?.(element, updates);
|
|
249
|
+
trackStudioExpandedClipEdit({ action: "resize" });
|
|
250
|
+
onResizeElement?.(toLocalElement(element, basis), {
|
|
251
|
+
...updates,
|
|
252
|
+
start: Math.max(0, updates.start - basis),
|
|
253
|
+
});
|
|
254
|
+
},
|
|
255
|
+
[onResizeElement, toLocalElement],
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const handleDeleteElement = useCallback(
|
|
259
|
+
(element: TimelineElement) => {
|
|
260
|
+
const basis = element.expandedParentStart;
|
|
261
|
+
if (basis === undefined) return onDeleteElement?.(element);
|
|
262
|
+
trackStudioExpandedClipEdit({ action: "delete" });
|
|
263
|
+
return onDeleteElement?.(toLocalElement(element, basis));
|
|
264
|
+
},
|
|
265
|
+
[onDeleteElement, toLocalElement],
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const handleSplitElement = useCallback(
|
|
269
|
+
(element: TimelineElement, splitTime: number) => {
|
|
270
|
+
const basis = element.expandedParentStart;
|
|
271
|
+
if (basis === undefined) return onSplitElement?.(element, splitTime);
|
|
272
|
+
trackStudioExpandedClipEdit({ action: "split" });
|
|
273
|
+
return onSplitElement?.(toLocalElement(element, basis), Math.max(0, splitTime - basis));
|
|
274
|
+
},
|
|
275
|
+
[onSplitElement, toLocalElement],
|
|
276
|
+
);
|
|
277
|
+
|
|
205
278
|
// Composition ID → file path map from raw index.html
|
|
206
279
|
const compIdToSrcRef = useRef(compIdToSrc);
|
|
207
280
|
compIdToSrcRef.current = compIdToSrc;
|
|
@@ -356,6 +429,17 @@ export const NLELayout = memo(function NLELayout({
|
|
|
356
429
|
<div
|
|
357
430
|
className="flex-1 min-h-0 relative"
|
|
358
431
|
data-preview-pan-surface="true"
|
|
432
|
+
onPointerDown={(e) => {
|
|
433
|
+
const el = iframeRef.current?.parentElement ?? iframeRef.current;
|
|
434
|
+
if (!el) return;
|
|
435
|
+
const rect = el.getBoundingClientRect();
|
|
436
|
+
const inside =
|
|
437
|
+
e.clientX >= rect.left &&
|
|
438
|
+
e.clientX <= rect.right &&
|
|
439
|
+
e.clientY >= rect.top &&
|
|
440
|
+
e.clientY <= rect.bottom;
|
|
441
|
+
if (!inside) onSelectTimelineElement?.(null);
|
|
442
|
+
}}
|
|
359
443
|
onDragOver={handlePreviewDragOver}
|
|
360
444
|
onDragLeave={handlePreviewDragLeave}
|
|
361
445
|
onDrop={handlePreviewDrop}
|
|
@@ -429,9 +513,13 @@ export const NLELayout = memo(function NLELayout({
|
|
|
429
513
|
onDrillDown={handleDrillDown}
|
|
430
514
|
renderClipContent={renderClipContent}
|
|
431
515
|
onFileDrop={onFileDrop}
|
|
432
|
-
onDeleteElement={
|
|
516
|
+
onDeleteElement={handleDeleteElement}
|
|
433
517
|
onAssetDrop={onAssetDrop}
|
|
434
518
|
onBlockDrop={onBlockDrop}
|
|
519
|
+
onMoveElement={handleMoveElement}
|
|
520
|
+
onResizeElement={handleResizeElement}
|
|
521
|
+
onBlockedEditAttempt={onBlockedEditAttempt}
|
|
522
|
+
onSplitElement={handleSplitElement}
|
|
435
523
|
onSelectElement={onSelectTimelineElement}
|
|
436
524
|
/>
|
|
437
525
|
</div>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback, useRef } from "react";
|
|
1
|
+
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
2
2
|
import { trackStudioRenderStart } from "../../telemetry/events";
|
|
3
3
|
|
|
4
4
|
export interface RenderJob {
|
|
@@ -238,11 +238,15 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
238
238
|
};
|
|
239
239
|
}, [projectId]);
|
|
240
240
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
241
|
+
const isRendering = jobs.some((j) => j.status === "rendering");
|
|
242
|
+
return useMemo(
|
|
243
|
+
() => ({
|
|
244
|
+
jobs,
|
|
245
|
+
isRendering,
|
|
246
|
+
deleteRender,
|
|
247
|
+
clearCompleted,
|
|
248
|
+
startRender: startRender as (options: unknown) => Promise<void>,
|
|
249
|
+
}),
|
|
250
|
+
[jobs, isRendering, deleteRender, clearCompleted, startRender],
|
|
251
|
+
);
|
|
248
252
|
}
|
|
@@ -37,6 +37,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
37
37
|
return typeof value === "object" && value !== null;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
export function formatFieldsSuffix(rawFields: unknown): string {
|
|
41
|
+
const fields = Array.isArray(rawFields)
|
|
42
|
+
? rawFields.filter((f): f is string => typeof f === "string")
|
|
43
|
+
: [];
|
|
44
|
+
return fields.length > 0 ? ` (${fields.join(", ")})` : "";
|
|
45
|
+
}
|
|
46
|
+
|
|
40
47
|
export async function readJsonResponseBody(res: Response): Promise<unknown> {
|
|
41
48
|
const contentType = res.headers.get("content-type") ?? "";
|
|
42
49
|
if (!contentType.includes("application/json")) {
|
|
@@ -55,14 +62,10 @@ function formatGsapMutationHttpErrorMessage(statusCode: number, body: unknown):
|
|
|
55
62
|
export function formatGsapMutationRejectionToast(error: GsapMutationHttpError): string {
|
|
56
63
|
const body = error.responseBody;
|
|
57
64
|
if (isRecord(body)) {
|
|
58
|
-
const fields = Array.isArray(body.fields)
|
|
59
|
-
? body.fields.filter((field): field is string => typeof field === "string")
|
|
60
|
-
: [];
|
|
61
|
-
const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : "";
|
|
62
65
|
return `Couldn't save animation: ${formatGsapMutationHttpErrorMessage(
|
|
63
66
|
error.statusCode,
|
|
64
67
|
body,
|
|
65
|
-
)}${
|
|
68
|
+
)}${formatFieldsSuffix(body.fields)}`;
|
|
66
69
|
}
|
|
67
70
|
return `Couldn't save animation: ${error.message}`;
|
|
68
71
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
2
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
4
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
4
5
|
|
|
@@ -55,4 +56,6 @@ export interface GsapScriptCommitsParams {
|
|
|
55
56
|
onCacheInvalidate: () => void;
|
|
56
57
|
onFileContentChanged?: (path: string, content: string) => void;
|
|
57
58
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
59
|
+
/** Stage 7 Step 3b: SDK session for shadow GSAP dispatch (server stays authoritative). */
|
|
60
|
+
sdkSession?: Composition | null;
|
|
58
61
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
2
|
+
|
|
3
|
+
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
|
|
4
|
+
|
|
5
|
+
let _gsapCachedTimelines: Record<string, TimelineLike> | undefined;
|
|
6
|
+
let _gsapTargetIds: Set<string> | undefined;
|
|
7
|
+
let _gsapTargetNodes: WeakSet<Element> | undefined;
|
|
8
|
+
|
|
9
|
+
function addTargetsFromTimeline(tl: TimelineLike, ids: Set<string>, nodes: WeakSet<Element>): void {
|
|
10
|
+
const children = tl.getChildren?.(true);
|
|
11
|
+
if (!children) return;
|
|
12
|
+
for (const child of children) {
|
|
13
|
+
const targets = child.targets?.();
|
|
14
|
+
if (!targets) continue;
|
|
15
|
+
for (const t of targets) {
|
|
16
|
+
nodes.add(t);
|
|
17
|
+
if (t.id) ids.add(t.id);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function collectGsapTargets(timelines: Record<string, TimelineLike>): {
|
|
23
|
+
ids: Set<string>;
|
|
24
|
+
nodes: WeakSet<Element>;
|
|
25
|
+
} {
|
|
26
|
+
const ids = new Set<string>();
|
|
27
|
+
const nodes = new WeakSet<Element>();
|
|
28
|
+
for (const tl of Object.values(timelines)) {
|
|
29
|
+
if (!tl) continue;
|
|
30
|
+
try {
|
|
31
|
+
addTargetsFromTimeline(tl, ids, nodes);
|
|
32
|
+
} catch {
|
|
33
|
+
/* teardown race */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { ids, nodes };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readTimelines(iframe: HTMLIFrameElement | null): Record<string, TimelineLike> | undefined {
|
|
40
|
+
if (!iframe?.contentWindow) return undefined;
|
|
41
|
+
try {
|
|
42
|
+
return (iframe.contentWindow as Window & { __timelines?: Record<string, TimelineLike> })
|
|
43
|
+
.__timelines;
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isElementGsapTargeted(
|
|
50
|
+
iframe: HTMLIFrameElement | null,
|
|
51
|
+
element: HTMLElement,
|
|
52
|
+
): boolean {
|
|
53
|
+
if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false;
|
|
54
|
+
const timelines = readTimelines(iframe);
|
|
55
|
+
if (!timelines) return false;
|
|
56
|
+
|
|
57
|
+
if (timelines !== _gsapCachedTimelines) {
|
|
58
|
+
const cache = collectGsapTargets(timelines);
|
|
59
|
+
_gsapTargetIds = cache.ids;
|
|
60
|
+
_gsapTargetNodes = cache.nodes;
|
|
61
|
+
_gsapCachedTimelines = timelines;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return _gsapTargetNodes!.has(element) || !!(element.id && _gsapTargetIds!.has(element.id));
|
|
65
|
+
}
|
|
@@ -136,6 +136,7 @@ interface HotkeyCallbacks {
|
|
|
136
136
|
onToggleRecording?: () => void;
|
|
137
137
|
leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
|
|
138
138
|
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
|
139
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): boolean {
|
|
@@ -205,6 +206,14 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
|
|
|
205
206
|
void cb.handleTimelineElementSplit(el, currentTime);
|
|
206
207
|
return;
|
|
207
208
|
}
|
|
209
|
+
// Expanded sub-comp children carry a qualified `sourceFile#id` selection
|
|
210
|
+
// that isn't in the raw `elements` list, so the s-key can't resolve them.
|
|
211
|
+
// Nudge toward the razor tool instead of failing silently.
|
|
212
|
+
if (!el && selectedElementId.includes("#")) {
|
|
213
|
+
event.preventDefault();
|
|
214
|
+
cb.showToast("Use the razor tool (B) to split clips inside a sub-composition", "info");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
208
217
|
}
|
|
209
218
|
}
|
|
210
219
|
|
|
@@ -376,6 +385,7 @@ export function useAppHotkeys({
|
|
|
376
385
|
onToggleRecording,
|
|
377
386
|
leftSidebarRef,
|
|
378
387
|
domEditSelectionRef,
|
|
388
|
+
showToast,
|
|
379
389
|
};
|
|
380
390
|
|
|
381
391
|
// ── Keydown dispatch ──
|
|
@@ -9,13 +9,12 @@ import { buildDomEditPatchTarget, type DomEditSelection } from "../components/ed
|
|
|
9
9
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
10
10
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
11
11
|
import type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
12
|
+
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
12
13
|
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
|
|
13
14
|
import { useDomEditTextCommits } from "./useDomEditTextCommits";
|
|
14
15
|
import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
|
15
16
|
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
|
16
|
-
|
|
17
|
-
// Re-export so existing consumers keep their import path
|
|
18
|
-
export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits";
|
|
17
|
+
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
|
|
19
18
|
|
|
20
19
|
// ── Helpers ──
|
|
21
20
|
|
|
@@ -33,15 +32,9 @@ async function readErrorResponseBody(
|
|
|
33
32
|
|
|
34
33
|
function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string {
|
|
35
34
|
if (!body?.error) return "Couldn't save edit";
|
|
36
|
-
|
|
37
|
-
? body.fields.filter((field): field is string => typeof field === "string")
|
|
38
|
-
: [];
|
|
39
|
-
const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : "";
|
|
40
|
-
return `Couldn't save edit: ${body.error}${suffix}`;
|
|
35
|
+
return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`;
|
|
41
36
|
}
|
|
42
37
|
|
|
43
|
-
// ── Types ──
|
|
44
|
-
|
|
45
38
|
interface RecordEditInput {
|
|
46
39
|
label: string;
|
|
47
40
|
kind: EditHistoryKind;
|
|
@@ -49,8 +42,6 @@ interface RecordEditInput {
|
|
|
49
42
|
files: Record<string, { before: string; after: string }>;
|
|
50
43
|
}
|
|
51
44
|
|
|
52
|
-
export type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
53
|
-
|
|
54
45
|
export interface UseDomEditCommitsParams {
|
|
55
46
|
activeCompPath: string | null;
|
|
56
47
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
@@ -77,10 +68,12 @@ export interface UseDomEditCommitsParams {
|
|
|
77
68
|
target: HTMLElement,
|
|
78
69
|
options?: { preferClipAncestor?: boolean },
|
|
79
70
|
) => Promise<DomEditSelection | null>;
|
|
71
|
+
/** Stage 7 Step 3b: called after a successful server-side element patch. */
|
|
72
|
+
onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void;
|
|
73
|
+
/** Stage 7 Step 3b: called after a successful server-side element delete. */
|
|
74
|
+
onElementDeleted?: (selection: DomEditSelection) => void;
|
|
80
75
|
}
|
|
81
76
|
|
|
82
|
-
// ── Hook ──
|
|
83
|
-
|
|
84
77
|
export function useDomEditCommits({
|
|
85
78
|
activeCompPath,
|
|
86
79
|
previewIframeRef,
|
|
@@ -99,6 +92,8 @@ export function useDomEditCommits({
|
|
|
99
92
|
clearDomSelection,
|
|
100
93
|
refreshDomEditSelectionFromPreview,
|
|
101
94
|
buildDomSelectionFromTarget,
|
|
95
|
+
onDomEditPersisted,
|
|
96
|
+
onElementDeleted,
|
|
102
97
|
}: UseDomEditCommitsParams) {
|
|
103
98
|
const resolveImportedFontAsset = useCallback(
|
|
104
99
|
(fontFamilyValue: string): ImportedFontAsset | null => {
|
|
@@ -220,6 +215,7 @@ export function useDomEditCommits({
|
|
|
220
215
|
coalesceKey: options?.coalesceKey,
|
|
221
216
|
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
|
222
217
|
});
|
|
218
|
+
onDomEditPersisted?.(selection, operations);
|
|
223
219
|
|
|
224
220
|
if (!options?.skipRefresh) {
|
|
225
221
|
reloadPreview();
|
|
@@ -233,6 +229,7 @@ export function useDomEditCommits({
|
|
|
233
229
|
domEditSaveTimestampRef,
|
|
234
230
|
reloadPreview,
|
|
235
231
|
showToast,
|
|
232
|
+
onDomEditPersisted,
|
|
236
233
|
],
|
|
237
234
|
);
|
|
238
235
|
|
|
@@ -293,6 +290,7 @@ export function useDomEditCommits({
|
|
|
293
290
|
reloadPreview,
|
|
294
291
|
clearDomSelection,
|
|
295
292
|
commitPositionPatchToHtml,
|
|
293
|
+
onElementDeleted,
|
|
296
294
|
});
|
|
297
295
|
|
|
298
296
|
return {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
1
2
|
import type { TimelineElement } from "../player";
|
|
2
3
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
3
4
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
@@ -8,6 +9,7 @@ import { useAskAgentModal } from "./useAskAgentModal";
|
|
|
8
9
|
import { useDomSelection } from "./useDomSelection";
|
|
9
10
|
import { usePreviewInteraction } from "./usePreviewInteraction";
|
|
10
11
|
import { useDomEditCommits } from "./useDomEditCommits";
|
|
12
|
+
import { runShadowDispatch, runShadowDelete } from "../utils/sdkShadow";
|
|
11
13
|
import { useGsapScriptCommits } from "./useGsapScriptCommits";
|
|
12
14
|
import { useGsapCacheVersion } from "./useGsapTweenCache";
|
|
13
15
|
import { useDomEditWiring } from "./useDomEditWiring";
|
|
@@ -58,6 +60,8 @@ export interface UseDomEditSessionParams {
|
|
|
58
60
|
openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
|
|
59
61
|
selectSidebarTab?: (tab: SidebarTab) => void;
|
|
60
62
|
getSidebarTab?: () => SidebarTab;
|
|
63
|
+
/** Stage 7 Step 3b: SDK session for shadow dispatch parity tracking. */
|
|
64
|
+
sdkSession?: Composition | null;
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
// ── Hook ──
|
|
@@ -96,6 +100,7 @@ export function useDomEditSession({
|
|
|
96
100
|
openSourceForSelection,
|
|
97
101
|
selectSidebarTab,
|
|
98
102
|
getSidebarTab,
|
|
103
|
+
sdkSession,
|
|
99
104
|
}: UseDomEditSessionParams) {
|
|
100
105
|
void _setRefreshKey;
|
|
101
106
|
void _readProjectFile;
|
|
@@ -189,6 +194,7 @@ export function useDomEditSession({
|
|
|
189
194
|
onCacheInvalidate: bumpGsapCache,
|
|
190
195
|
onFileContentChanged: updateEditingFileContent,
|
|
191
196
|
showToast,
|
|
197
|
+
sdkSession,
|
|
192
198
|
});
|
|
193
199
|
|
|
194
200
|
// ── DOM commit handlers ──
|
|
@@ -227,6 +233,10 @@ export function useDomEditSession({
|
|
|
227
233
|
clearDomSelection,
|
|
228
234
|
refreshDomEditSelectionFromPreview,
|
|
229
235
|
buildDomSelectionFromTarget,
|
|
236
|
+
onDomEditPersisted: sdkSession
|
|
237
|
+
? (sel, ops) => runShadowDispatch(sdkSession, sel, ops)
|
|
238
|
+
: undefined,
|
|
239
|
+
onElementDeleted: sdkSession ? (sel) => runShadowDelete(sdkSession, sel.hfId) : undefined,
|
|
230
240
|
});
|
|
231
241
|
|
|
232
242
|
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
|
|
@@ -255,6 +265,9 @@ export function useDomEditSession({
|
|
|
255
265
|
handleGsapRemoveAllKeyframes,
|
|
256
266
|
handleResetSelectedElementKeyframes,
|
|
257
267
|
} = useDomEditWiring({
|
|
268
|
+
// Pre-existing prop-drilling clone (same param set forwarded to
|
|
269
|
+
// useDomEditWiring); surfaced by this PR's adjacent edits, not introduced.
|
|
270
|
+
// fallow-ignore-next-line code-duplication
|
|
258
271
|
projectId,
|
|
259
272
|
activeCompPath,
|
|
260
273
|
domEditSelection,
|