@hyperframes/studio 0.6.122-alpha.0 → 0.7.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/{index-i-MLqhmm.js → index-CnYOrmoA.js} +1 -1
- package/dist/assets/index-DzWIinxk.css +1 -0
- package/dist/assets/index-Z4bA3Fi0.js +374 -0
- package/dist/assets/{index-DSLrl2tB.js → index-wWkKTkIq.js} +24 -24
- package/dist/index.d.ts +8 -0
- package/dist/index.html +3 -3
- package/dist/index.js +3768 -2245
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/App.tsx +1 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioPreviewArea.tsx +12 -0
- package/src/components/TimelineToolbar.tsx +29 -16
- package/src/components/editor/DomEditOverlay.test.ts +0 -13
- package/src/components/editor/DomEditOverlay.tsx +3 -10
- package/src/components/editor/KeyframeNavigation.test.ts +36 -0
- package/src/components/editor/KeyframeNavigation.tsx +34 -4
- package/src/components/editor/MotionPathNode.tsx +98 -0
- package/src/components/editor/MotionPathOverlay.tsx +481 -0
- package/src/components/editor/SnapToolbar.tsx +28 -3
- package/src/components/editor/SourceEditor.tsx +21 -9
- package/src/components/editor/domEditOverlayGeometry.ts +12 -0
- package/src/components/editor/domEditOverlayGestures.ts +5 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
- package/src/components/editor/domEditingDom.ts +24 -9
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +1 -11
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +68 -41
- package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
- package/src/components/editor/manualOffsetDrag.test.ts +12 -0
- package/src/components/editor/manualOffsetDrag.ts +79 -4
- package/src/components/editor/motionPathCommit.test.ts +130 -0
- package/src/components/editor/motionPathCommit.ts +83 -0
- package/src/components/editor/motionPathGeometry.test.ts +127 -0
- package/src/components/editor/motionPathGeometry.ts +116 -0
- package/src/components/editor/motionPathSelection.ts +33 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
- package/src/components/editor/useDomEditOverlayRects.ts +6 -0
- package/src/components/editor/useMotionPathData.ts +135 -0
- package/src/components/nle/NLELayout.tsx +17 -6
- package/src/components/panels/SlideshowPanel.tsx +2 -1
- package/src/components/panels/SlideshowSubPanels.tsx +2 -1
- package/src/components/renders/RenderQueue.tsx +1 -1
- package/src/components/renders/useRenderQueue.ts +3 -2
- package/src/hooks/draggedGsapPosition.ts +47 -0
- package/src/hooks/gsapDragCommit.test.ts +435 -0
- package/src/hooks/gsapDragCommit.ts +278 -290
- package/src/hooks/gsapDragPositionCommit.ts +410 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
- package/src/hooks/gsapRuntimeBridge.ts +86 -69
- package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
- package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
- package/src/hooks/gsapRuntimePatch.test.ts +462 -0
- package/src/hooks/gsapRuntimePatch.ts +159 -0
- package/src/hooks/gsapScriptCommitTypes.ts +11 -1
- package/src/hooks/gsapShared.test.ts +58 -0
- package/src/hooks/gsapShared.ts +28 -10
- package/src/hooks/gsapTargetCache.ts +0 -3
- package/src/hooks/useDomEditPreviewSync.ts +3 -0
- package/src/hooks/useDomEditSession.ts +0 -4
- package/src/hooks/useDomEditWiring.ts +1 -0
- package/src/hooks/useDomSelection.ts +12 -5
- package/src/hooks/useEnableKeyframes.test.ts +130 -0
- package/src/hooks/useEnableKeyframes.ts +298 -45
- package/src/hooks/useGestureCommit.ts +121 -53
- package/src/hooks/useGestureRecording.ts +61 -42
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
- package/src/hooks/useGsapAnimationOps.ts +1 -1
- package/src/hooks/useGsapAwareEditing.ts +38 -40
- package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
- package/src/hooks/useGsapScriptCommits.ts +102 -6
- package/src/hooks/useGsapTweenCache.ts +12 -11
- package/src/hooks/useRazorSplit.ts +21 -2
- package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
- package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
- package/src/hooks/useServerConnection.ts +4 -1
- package/src/hooks/useStudioContextValue.ts +3 -2
- package/src/hooks/useStudioUrlState.ts +97 -37
- package/src/player/components/ShortcutsPanel.tsx +8 -0
- package/src/player/components/TimelineClipDiamonds.tsx +10 -12
- package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/lib/mediaProbe.ts +7 -1
- package/src/player/store/playerStore.ts +38 -1
- package/src/telemetry/config.ts +3 -2
- package/src/utils/editDebugLog.ts +16 -0
- package/src/utils/generateId.ts +7 -0
- package/src/utils/gsapSoftReload.test.ts +217 -13
- package/src/utils/gsapSoftReload.ts +228 -50
- package/src/utils/studioHelpers.test.ts +31 -1
- package/src/utils/studioHelpers.ts +24 -0
- package/src/utils/studioPreviewHelpers.test.ts +26 -1
- package/src/utils/studioPreviewHelpers.ts +30 -6
- package/src/utils/studioTelemetry.ts +3 -1
- package/dist/assets/index-Baz2T-9U.js +0 -269
- package/dist/assets/index-BvEhf7SS.css +0 -1
- package/src/player/components/TimelinePropertyRows.tsx +0 -126
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
import { act, createElement } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
|
|
6
|
+
// React's act() emits a warning unless the runtime is flagged as a test env.
|
|
7
|
+
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
8
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
9
|
+
import type { CommitMutation } from "./gsapScriptCommitTypes";
|
|
10
|
+
import { useSafeGsapCommitMutation } from "./useSafeGsapCommitMutation";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Regression: the safe wrapper used to `void` the commit and return undefined, so
|
|
14
|
+
* the facade resolved IMMEDIATELY — consumers that `await session.commitMutation`
|
|
15
|
+
* (gesture recording, enable-keyframes) fired their post-actions (toast, reseek,
|
|
16
|
+
* "idle") BEFORE the server save landed. The wrapper must now RETURN the chain so
|
|
17
|
+
* awaiters resolve only after the save (and its error handling) settles.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const selection = { id: "box", selector: "#box" } as unknown as DomEditSelection;
|
|
21
|
+
|
|
22
|
+
// Mount a hook and capture its return value via a ref.
|
|
23
|
+
function renderSafeCommit(
|
|
24
|
+
commitMutation: CommitMutation,
|
|
25
|
+
showToast?: (message: string, tone?: "error" | "info") => void,
|
|
26
|
+
) {
|
|
27
|
+
const captured: { fn: ReturnType<typeof useSafeGsapCommitMutation> | null } = { fn: null };
|
|
28
|
+
function Probe() {
|
|
29
|
+
captured.fn = useSafeGsapCommitMutation(commitMutation, vi.fn(), showToast);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const container = document.createElement("div");
|
|
33
|
+
const root = createRoot(container);
|
|
34
|
+
act(() => {
|
|
35
|
+
root.render(createElement(Probe));
|
|
36
|
+
});
|
|
37
|
+
return captured.fn!;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("useSafeGsapCommitMutation — returned promise settles after the commit", () => {
|
|
41
|
+
it("resolves only after the underlying commit resolves", async () => {
|
|
42
|
+
let resolveCommit!: () => void;
|
|
43
|
+
const order: string[] = [];
|
|
44
|
+
const commitMutation: CommitMutation = vi.fn(
|
|
45
|
+
() =>
|
|
46
|
+
new Promise<void>((resolve) => {
|
|
47
|
+
resolveCommit = () => {
|
|
48
|
+
order.push("commit-settled");
|
|
49
|
+
resolve();
|
|
50
|
+
};
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const safe = renderSafeCommit(commitMutation);
|
|
55
|
+
const awaited = safe(selection, { type: "noop" }, { label: "test" }).then(() => {
|
|
56
|
+
order.push("awaiter-resumed");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Give the microtask queue a chance — the awaiter must NOT have resumed yet
|
|
60
|
+
// because the commit is still pending.
|
|
61
|
+
await Promise.resolve();
|
|
62
|
+
expect(order).toEqual([]);
|
|
63
|
+
|
|
64
|
+
resolveCommit();
|
|
65
|
+
await awaited;
|
|
66
|
+
expect(order).toEqual(["commit-settled", "awaiter-resumed"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("resolves (success-after-handled) after a failed commit, surfacing a toast", async () => {
|
|
70
|
+
const showToast = vi.fn();
|
|
71
|
+
const order: string[] = [];
|
|
72
|
+
const commitMutation: CommitMutation = vi.fn(async () => {
|
|
73
|
+
order.push("commit-rejected");
|
|
74
|
+
throw new Error("save failed");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const safe = renderSafeCommit(commitMutation, showToast);
|
|
78
|
+
await safe(selection, { type: "noop" }, { label: "test" });
|
|
79
|
+
order.push("awaiter-resumed");
|
|
80
|
+
|
|
81
|
+
// The awaiter resumes AFTER the rejection was handled, and the promise does
|
|
82
|
+
// not reject (the .catch swallows it for the toast).
|
|
83
|
+
expect(order).toEqual(["commit-rejected", "awaiter-resumed"]);
|
|
84
|
+
expect(showToast).toHaveBeenCalledWith(expect.stringContaining("Couldn't save"), "error");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -42,12 +42,16 @@ export function useSafeGsapCommitMutation(
|
|
|
42
42
|
selection: DomEditSelection,
|
|
43
43
|
mutation: Record<string, unknown>,
|
|
44
44
|
options: CommitMutationOptions,
|
|
45
|
-
) =>
|
|
46
|
-
|
|
45
|
+
): Promise<void> =>
|
|
46
|
+
// Return the chain so awaiting consumers (gesture commit, enable-keyframes)
|
|
47
|
+
// run their post-actions AFTER the server save settles, not immediately.
|
|
48
|
+
// The `.catch` handles the failure (toast + telemetry) and resolves the
|
|
49
|
+
// chain, so awaiters see a settled (success-after-handled) promise rather
|
|
50
|
+
// than an unhandled rejection.
|
|
51
|
+
commitMutation(selection, mutation, options).catch((error) => {
|
|
47
52
|
trackGsapSaveFailure(error, selection, mutation, options.label);
|
|
48
53
|
showToast?.(`Couldn't save animation: ${getStudioSaveErrorMessage(error)}`, "error");
|
|
49
|
-
})
|
|
50
|
-
},
|
|
54
|
+
}),
|
|
51
55
|
[commitMutation, trackGsapSaveFailure, showToast],
|
|
52
56
|
);
|
|
53
57
|
}
|
|
@@ -26,7 +26,10 @@ export function useServerConnection(): ServerConnectionState {
|
|
|
26
26
|
useMountEffect(() => {
|
|
27
27
|
const hashProjectId = parseProjectIdFromHash(window.location.hash);
|
|
28
28
|
let cancelled = false;
|
|
29
|
-
|
|
29
|
+
// Explicitly `number` (the DOM return of window.setTimeout) rather than
|
|
30
|
+
// ReturnType<typeof window.setTimeout> — with @types/node present, that infers
|
|
31
|
+
// NodeJS.Timeout and clashes with the DOM number the call actually returns.
|
|
32
|
+
let retryTimer: number | null = null;
|
|
30
33
|
|
|
31
34
|
function scheduleRetry() {
|
|
32
35
|
setWaitingForServer(true);
|
|
@@ -90,8 +90,9 @@ export function useInspectorState(
|
|
|
90
90
|
inspectorPanelActive,
|
|
91
91
|
inspectorButtonActive:
|
|
92
92
|
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
// Keep the selection box + motion path drawn even when the Inspector is
|
|
94
|
+
// collapsed — closing the panel shouldn't visually deselect the element.
|
|
95
|
+
shouldShowSelectedDomBounds: inspectorPanelActive && !isPlaying && !isGestureRecording,
|
|
95
96
|
};
|
|
96
97
|
}, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]);
|
|
97
98
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { usePlayerStore } from "../player";
|
|
3
3
|
import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
|
|
4
4
|
import { clampNumber, type RightPanelTab } from "../utils/studioHelpers";
|
|
5
|
+
import { parseProjectIdFromHash } from "../utils/projectRouting";
|
|
5
6
|
import {
|
|
6
7
|
buildStudioHash,
|
|
8
|
+
parseStudioUrlStateFromHash,
|
|
7
9
|
type StudioUrlSelectionState,
|
|
8
10
|
type StudioUrlState,
|
|
9
11
|
} from "../utils/studioUrlState";
|
|
@@ -33,6 +35,7 @@ interface UseStudioUrlStateParams {
|
|
|
33
35
|
preserveGroup?: boolean;
|
|
34
36
|
},
|
|
35
37
|
) => void;
|
|
38
|
+
setRightPanelTab: (tab: RightPanelTab) => void;
|
|
36
39
|
initialState: StudioUrlState;
|
|
37
40
|
}
|
|
38
41
|
|
|
@@ -68,12 +71,17 @@ export function useStudioUrlState({
|
|
|
68
71
|
domEditSelection,
|
|
69
72
|
buildDomSelectionFromTarget,
|
|
70
73
|
applyDomSelection,
|
|
74
|
+
setRightPanelTab,
|
|
71
75
|
initialState,
|
|
72
76
|
}: UseStudioUrlStateParams) {
|
|
73
77
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
74
78
|
const hydratedSeekRef = useRef(initialState.currentTime == null);
|
|
75
79
|
const hydratedInitialTimeRef = useRef(initialState.currentTime == null);
|
|
76
80
|
const hydratedSelectionRef = useRef(initialState.selection == null);
|
|
81
|
+
// Mirrors hydratedSelectionRef as state so the selection-hydration effect can
|
|
82
|
+
// drop its currentTime subscription once hydration completes — otherwise it
|
|
83
|
+
// re-runs on every playhead tick for the lifetime of the session.
|
|
84
|
+
const [selectionHydrated, setSelectionHydrated] = useState(initialState.selection == null);
|
|
77
85
|
const pendingSelectionRef = useRef(initialState.selection);
|
|
78
86
|
const stableTimeRef = useRef<number | null>(initialState.currentTime);
|
|
79
87
|
|
|
@@ -91,67 +99,91 @@ export function useStudioUrlState({
|
|
|
91
99
|
[activeCompPath, domEditSelection, rightCollapsed, rightPanelTab, timelineVisible],
|
|
92
100
|
);
|
|
93
101
|
|
|
102
|
+
// Resolve a URL selection to a live element and apply it. Shared by the initial
|
|
103
|
+
// hydration effect and the external-navigation (hashchange) handler. Returns
|
|
104
|
+
// false ONLY when the iframe document isn't ready yet (caller should retry);
|
|
105
|
+
// a missing element or null selection clears the selection and returns true.
|
|
106
|
+
const applyUrlSelection = useCallback(
|
|
107
|
+
(selection: StudioUrlSelectionState | null): boolean => {
|
|
108
|
+
if (!selection) {
|
|
109
|
+
applyDomSelection(null, { revealPanel: false });
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
let doc: Document | null = null;
|
|
113
|
+
try {
|
|
114
|
+
doc = previewIframeRef.current?.contentDocument ?? null;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
if (!doc) return false;
|
|
119
|
+
const element = findElementForSelection(
|
|
120
|
+
doc,
|
|
121
|
+
{
|
|
122
|
+
sourceFile: selection.sourceFile ?? "",
|
|
123
|
+
id: selection.id,
|
|
124
|
+
selector: selection.selector,
|
|
125
|
+
selectorIndex: selection.selectorIndex,
|
|
126
|
+
},
|
|
127
|
+
activeCompPath,
|
|
128
|
+
);
|
|
129
|
+
if (!element) {
|
|
130
|
+
applyDomSelection(null, { revealPanel: false });
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
void buildDomSelectionFromTarget(element, { preferClipAncestor: false }).then((resolved) => {
|
|
134
|
+
applyDomSelection(resolved, { revealPanel: false });
|
|
135
|
+
});
|
|
136
|
+
return true;
|
|
137
|
+
},
|
|
138
|
+
[activeCompPath, applyDomSelection, buildDomSelectionFromTarget, previewIframeRef],
|
|
139
|
+
);
|
|
140
|
+
|
|
94
141
|
useEffect(() => {
|
|
95
142
|
if (!projectId || hydratedSeekRef.current || compositionLoading) return;
|
|
96
143
|
const nextTime =
|
|
97
144
|
duration > 0
|
|
98
145
|
? clampNumber(initialState.currentTime ?? 0, 0, duration)
|
|
99
146
|
: Math.max(0, initialState.currentTime ?? 0);
|
|
147
|
+
// The request is honored even if it fires before the player runtime mounts:
|
|
148
|
+
// initializeAdapter reconciles the store's requestedSeekTime when the adapter
|
|
149
|
+
// becomes ready. currentTime then settles to nextTime, releasing the selection
|
|
150
|
+
// hydration below.
|
|
100
151
|
usePlayerStore.getState().requestSeek(nextTime);
|
|
101
152
|
stableTimeRef.current = nextTime;
|
|
102
153
|
hydratedSeekRef.current = true;
|
|
103
154
|
}, [projectId, compositionLoading, duration, initialState.currentTime]);
|
|
104
155
|
|
|
156
|
+
// Once hydration completes the selection effect no longer needs the playhead,
|
|
157
|
+
// so freeze its time dependency. This stops the effect re-running on every tick
|
|
158
|
+
// for the rest of the session (cosmetic perf) while still retrying as the seek
|
|
159
|
+
// settles before hydration.
|
|
160
|
+
const selectionHydrationTime = selectionHydrated ? 0 : currentTime;
|
|
105
161
|
useEffect(() => {
|
|
106
162
|
if (!projectId || hydratedSelectionRef.current || compositionLoading) return;
|
|
107
163
|
if (!hydratedSeekRef.current) return;
|
|
108
164
|
const targetTime = initialState.currentTime;
|
|
109
|
-
if (targetTime != null && Math.abs(
|
|
110
|
-
|
|
111
|
-
const pendingSelection = pendingSelectionRef.current;
|
|
112
|
-
if (!pendingSelection) {
|
|
113
|
-
hydratedSelectionRef.current = true;
|
|
165
|
+
if (targetTime != null && Math.abs(selectionHydrationTime - stableTimeRef.current!) > 0.05) {
|
|
114
166
|
return;
|
|
115
167
|
}
|
|
116
168
|
|
|
117
|
-
|
|
118
|
-
try {
|
|
119
|
-
doc = previewIframeRef.current?.contentDocument ?? null;
|
|
120
|
-
} catch {
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
if (!doc) return;
|
|
124
|
-
|
|
125
|
-
const element = findElementForSelection(
|
|
126
|
-
doc,
|
|
127
|
-
{
|
|
128
|
-
sourceFile: pendingSelection.sourceFile ?? "",
|
|
129
|
-
id: pendingSelection.id,
|
|
130
|
-
selector: pendingSelection.selector,
|
|
131
|
-
selectorIndex: pendingSelection.selectorIndex,
|
|
132
|
-
},
|
|
133
|
-
activeCompPath,
|
|
134
|
-
);
|
|
135
|
-
if (!element) {
|
|
136
|
-
applyDomSelection(null, { revealPanel: false });
|
|
169
|
+
const markHydrated = () => {
|
|
137
170
|
hydratedSelectionRef.current = true;
|
|
138
|
-
|
|
171
|
+
setSelectionHydrated(true);
|
|
172
|
+
};
|
|
173
|
+
const pendingSelection = pendingSelectionRef.current;
|
|
174
|
+
if (!pendingSelection) {
|
|
175
|
+
markHydrated();
|
|
139
176
|
return;
|
|
140
177
|
}
|
|
141
|
-
|
|
142
|
-
|
|
178
|
+
// Doc not ready yet → leave hydration pending so a later tick retries.
|
|
179
|
+
if (!applyUrlSelection(pendingSelection)) return;
|
|
180
|
+
markHydrated();
|
|
143
181
|
pendingSelectionRef.current = null;
|
|
144
|
-
void buildDomSelectionFromTarget(element, { preferClipAncestor: false }).then((selection) => {
|
|
145
|
-
applyDomSelection(selection, { revealPanel: false });
|
|
146
|
-
});
|
|
147
182
|
}, [
|
|
148
|
-
|
|
149
|
-
applyDomSelection,
|
|
150
|
-
buildDomSelectionFromTarget,
|
|
183
|
+
applyUrlSelection,
|
|
151
184
|
compositionLoading,
|
|
152
|
-
|
|
185
|
+
selectionHydrationTime,
|
|
153
186
|
initialState.currentTime,
|
|
154
|
-
previewIframeRef,
|
|
155
187
|
projectId,
|
|
156
188
|
refreshKey,
|
|
157
189
|
]);
|
|
@@ -185,4 +217,32 @@ export function useStudioUrlState({
|
|
|
185
217
|
if (!projectId) return;
|
|
186
218
|
replaceHash(buildStudioHash(projectId, buildUrlState()));
|
|
187
219
|
}, [activeCompPathHydrated, buildUrlState, projectId]);
|
|
220
|
+
|
|
221
|
+
// Re-apply URL state when the hash changes externally (pasting a new link,
|
|
222
|
+
// back/forward) AFTER initial load. The app only reads the URL once on mount
|
|
223
|
+
// and otherwise WRITES the hash via replaceState (which never fires
|
|
224
|
+
// `hashchange`), so this listener sees only genuine external navigations —
|
|
225
|
+
// without it, opening a same-project deep link (different `t`, element, or
|
|
226
|
+
// tab) is silently ignored and then overwritten by the next hash-sync.
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
if (!projectId) return;
|
|
229
|
+
const onHashChange = () => {
|
|
230
|
+
if (parseProjectIdFromHash(window.location.hash) !== projectId) return; // different project → remount handles it
|
|
231
|
+
const parsed = parseStudioUrlStateFromHash(window.location.hash);
|
|
232
|
+
if (parsed.currentTime != null) {
|
|
233
|
+
const clamped =
|
|
234
|
+
duration > 0
|
|
235
|
+
? clampNumber(parsed.currentTime, 0, duration)
|
|
236
|
+
: Math.max(0, parsed.currentTime);
|
|
237
|
+
if (Math.abs(usePlayerStore.getState().currentTime - clamped) > 0.05) {
|
|
238
|
+
usePlayerStore.getState().requestSeek(clamped);
|
|
239
|
+
stableTimeRef.current = clamped;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
applyUrlSelection(parsed.selection);
|
|
243
|
+
if (parsed.rightPanelTab) setRightPanelTab(parsed.rightPanelTab);
|
|
244
|
+
};
|
|
245
|
+
window.addEventListener("hashchange", onHashChange);
|
|
246
|
+
return () => window.removeEventListener("hashchange", onHashChange);
|
|
247
|
+
}, [projectId, duration, applyUrlSelection, setRightPanelTab]);
|
|
188
248
|
}
|
|
@@ -50,6 +50,14 @@ const SHORTCUT_SECTIONS = [
|
|
|
50
50
|
{ key: "⌘ Scroll", label: "Record scale" },
|
|
51
51
|
],
|
|
52
52
|
},
|
|
53
|
+
{
|
|
54
|
+
title: "Canvas",
|
|
55
|
+
hints: [
|
|
56
|
+
{ key: "Drag", label: "Move element / add keyframe" },
|
|
57
|
+
{ key: "⌥ Drag", label: "Move entire animation path" },
|
|
58
|
+
{ key: "⇧ Drag", label: "Uniform resize" },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
53
61
|
{
|
|
54
62
|
title: "Panels",
|
|
55
63
|
hints: [
|
|
@@ -42,13 +42,10 @@ interface TimelineClipDiamondsProps {
|
|
|
42
42
|
const DIAMOND_RATIO = 0.8;
|
|
43
43
|
// Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
|
|
44
44
|
// slightly outside [0, 100] (from rounding or stale cache during the async
|
|
45
|
-
// persist → reload cycle) are
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
function clampDiamondLeft(rawLeft: number, diamondSize: number, clipWidth: number): number {
|
|
50
|
-
return Math.max(0, Math.min(clipWidth - diamondSize, rawLeft));
|
|
51
|
-
}
|
|
45
|
+
// persist → reload cycle) are still rendered (the clip is overflow-visible) at
|
|
46
|
+
// their true position rather than hidden.
|
|
47
|
+
const KF_MIN_PCT = -5;
|
|
48
|
+
const KF_MAX_PCT = 105;
|
|
52
49
|
|
|
53
50
|
export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
54
51
|
keyframesData,
|
|
@@ -225,11 +222,12 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
225
222
|
})}
|
|
226
223
|
|
|
227
224
|
{sorted.map((kf, i) => {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
)
|
|
225
|
+
// Center the diamond ON its keyframe %: left = (% · width) − half so the
|
|
226
|
+
// diamond's midpoint sits exactly at the percentage. At 0% the midpoint
|
|
227
|
+
// is the clip's left edge (the diamond's left half overflows, which the
|
|
228
|
+
// overflow-visible clip shows) — NOT shifted fully inside. No clamp, or
|
|
229
|
+
// boundary keyframes (0% / 100%) would render off-center.
|
|
230
|
+
const leftPx = (effPct(kf.percentage) / 100) * clipWidthPx - half;
|
|
233
231
|
const kfKey = `${elementId}:${kf.percentage}`;
|
|
234
232
|
const isKfSelected = selectedKeyframes.has(kfKey);
|
|
235
233
|
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { buildExpandedElements } from "./useExpandedTimelineElements";
|
|
3
|
+
import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
|
|
3
4
|
import type { TimelineElement } from "../store/playerStore";
|
|
4
5
|
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
5
6
|
|
|
@@ -88,4 +89,37 @@ describe("buildExpandedElements", () => {
|
|
|
88
89
|
expect(child.expandedParentStart).toBe(13); // C's start, not B's 12 or A's 10
|
|
89
90
|
expect(child.sourceFile).toBe("c.html"); // C's file, not b.html or a.html
|
|
90
91
|
});
|
|
92
|
+
|
|
93
|
+
// Regression: an expanded child must share one identity (`key`) with the flat
|
|
94
|
+
// store element for the same DOM id. Before the fix the child key fell back to
|
|
95
|
+
// the colon form (`index.html:eyebrow:N`) while the store/selection used the
|
|
96
|
+
// hash form (`index.html#eyebrow`), so clicking an expanded child never
|
|
97
|
+
// highlighted it (isSelected compares the two keys).
|
|
98
|
+
it("keys expanded children in hash form, matching the flat store element", () => {
|
|
99
|
+
// Single composition (no sub-comps): scene `s1` with same-file children.
|
|
100
|
+
const elements = [el({ id: "s1", domId: "s1", start: 0, duration: 14 })];
|
|
101
|
+
const manifest = [
|
|
102
|
+
clip({ id: "s1", start: 0, duration: 14 }),
|
|
103
|
+
clip({ id: "eyebrow", start: 0, duration: 14 }),
|
|
104
|
+
clip({ id: "title", start: 0, duration: 14 }),
|
|
105
|
+
];
|
|
106
|
+
const parentMap = new Map([
|
|
107
|
+
["eyebrow", "s1"],
|
|
108
|
+
["title", "s1"],
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
const out = buildExpandedElements(elements, manifest, parentMap, "s1", "s1");
|
|
112
|
+
const child = out.find((e) => e.domId === "eyebrow")!;
|
|
113
|
+
|
|
114
|
+
const expectedStoreKey = buildTimelineElementKey({
|
|
115
|
+
id: "eyebrow",
|
|
116
|
+
fallbackIndex: 0,
|
|
117
|
+
domId: "eyebrow",
|
|
118
|
+
selector: "#eyebrow",
|
|
119
|
+
sourceFile: undefined,
|
|
120
|
+
});
|
|
121
|
+
expect(expectedStoreKey).toBe("index.html#eyebrow");
|
|
122
|
+
expect(child.key).toBe("index.html#eyebrow");
|
|
123
|
+
expect(child.key).toBe(expectedStoreKey);
|
|
124
|
+
});
|
|
91
125
|
});
|
|
@@ -2,6 +2,7 @@ import { useMemo } from "react";
|
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
4
4
|
import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
|
|
5
|
+
import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
|
|
5
6
|
|
|
6
7
|
function findTopLevelAncestor(id: string, parentMap: Map<string, string>): string | null {
|
|
7
8
|
let current = parentMap.get(id);
|
|
@@ -78,14 +79,31 @@ function buildChildElements(
|
|
|
78
79
|
clip: child,
|
|
79
80
|
fallbackIndex: result.length,
|
|
80
81
|
});
|
|
82
|
+
const domId = child.id ?? undefined;
|
|
83
|
+
const selector = child.id ? `#${child.id}` : undefined;
|
|
84
|
+
// `base.key` was built without a hostEl, so it fell back to the colon form
|
|
85
|
+
// (`index.html:<id>:<idx>`) even though we set domId below. Recompute it from
|
|
86
|
+
// the same inputs the store uses (`<sourceFile>#<domId>`) so an expanded
|
|
87
|
+
// child shares one identity with its flat store element — otherwise selecting
|
|
88
|
+
// it sets `selectedElementId` to the store's hash key while the rendered row
|
|
89
|
+
// is keyed by the colon form, and `isSelected` never matches (no highlight).
|
|
90
|
+
const key = buildTimelineElementKey({
|
|
91
|
+
id: base.id,
|
|
92
|
+
fallbackIndex: result.length,
|
|
93
|
+
domId,
|
|
94
|
+
selector,
|
|
95
|
+
selectorIndex: base.selectorIndex,
|
|
96
|
+
sourceFile: editBasis.sourceFile,
|
|
97
|
+
});
|
|
81
98
|
result.push({
|
|
82
99
|
...base,
|
|
100
|
+
key,
|
|
83
101
|
start: clamped.start,
|
|
84
102
|
duration: clamped.duration,
|
|
85
103
|
track: display.track + result.length,
|
|
86
104
|
expandedParentStart: editBasis.start,
|
|
87
|
-
domId
|
|
88
|
-
selector
|
|
105
|
+
domId,
|
|
106
|
+
selector,
|
|
89
107
|
sourceFile: editBasis.sourceFile,
|
|
90
108
|
timingSource: "authored" as const,
|
|
91
109
|
});
|
|
@@ -174,8 +174,16 @@ export function useTimelineSyncCallbacks({
|
|
|
174
174
|
if (!adapter || adapter.getDuration() <= 0) return false;
|
|
175
175
|
|
|
176
176
|
adapter.pause();
|
|
177
|
-
|
|
177
|
+
// Honor a seek requested before the adapter was ready. It may sit in either
|
|
178
|
+
// place: `pendingSeekRef` if the store subscription was mounted when requestSeek
|
|
179
|
+
// fired, or only in the store's `requestedSeekTime` if it fired earlier still
|
|
180
|
+
// (deep-link hydration runs before the player subscription mounts, so the request
|
|
181
|
+
// never reaches pendingSeekRef). Reconciling with the store here is what makes a
|
|
182
|
+
// deep-linked `?t=` land instead of starting at 0.
|
|
183
|
+
const storeSeek = usePlayerStore.getState().requestedSeekTime;
|
|
184
|
+
const seekTo = pendingSeekRef.current ?? storeSeek;
|
|
178
185
|
pendingSeekRef.current = null;
|
|
186
|
+
if (storeSeek != null) usePlayerStore.getState().clearSeekRequest();
|
|
179
187
|
const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
|
|
180
188
|
|
|
181
189
|
adapter.seek(startTime);
|
|
@@ -8,6 +8,9 @@ interface MediaProbeResult {
|
|
|
8
8
|
|
|
9
9
|
const cache = new Map<string, MediaProbeResult>();
|
|
10
10
|
const inflight = new Map<string, Promise<MediaProbeResult | null>>();
|
|
11
|
+
// URLs whose probe failed (CORS, 404, non-media). Remembered so the rAF-driven
|
|
12
|
+
// timeline re-derive doesn't re-fetch them every frame and flood the console.
|
|
13
|
+
const failed = new Set<string>();
|
|
11
14
|
|
|
12
15
|
let mediabunnyModule: typeof import("mediabunny") | null | false = null;
|
|
13
16
|
|
|
@@ -97,7 +100,8 @@ export async function probeMissingSourceDurations<
|
|
|
97
100
|
el.src &&
|
|
98
101
|
el.sourceDuration == null &&
|
|
99
102
|
["video", "audio"].includes(el.tag.toLowerCase()) &&
|
|
100
|
-
!getCachedProbe(el.src)
|
|
103
|
+
!getCachedProbe(el.src) &&
|
|
104
|
+
!failed.has(normalizeUrl(el.src)),
|
|
101
105
|
);
|
|
102
106
|
if (needs.length === 0) return;
|
|
103
107
|
await Promise.allSettled(
|
|
@@ -112,6 +116,7 @@ async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
|
|
|
112
116
|
const key = normalizeUrl(url);
|
|
113
117
|
const cached = cache.get(key);
|
|
114
118
|
if (cached) return cached;
|
|
119
|
+
if (failed.has(key)) return null;
|
|
115
120
|
|
|
116
121
|
let pending = inflight.get(key);
|
|
117
122
|
if (pending) return pending;
|
|
@@ -119,6 +124,7 @@ async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
|
|
|
119
124
|
pending = probeOne(key).then((result) => {
|
|
120
125
|
inflight.delete(key);
|
|
121
126
|
if (result) cache.set(key, result);
|
|
127
|
+
else failed.add(key);
|
|
122
128
|
return result;
|
|
123
129
|
});
|
|
124
130
|
inflight.set(key, pending);
|
|
@@ -96,6 +96,14 @@ interface PlayerState {
|
|
|
96
96
|
* (drag, resize, rotate) target this instead of recomputing from playhead. */
|
|
97
97
|
activeKeyframePct: number | null;
|
|
98
98
|
setActiveKeyframePct: (pct: number | null) => void;
|
|
99
|
+
/** Motion-path "set destination" mode. Armed from the preview toolbar (replaces
|
|
100
|
+
* the old double-click-on-canvas UX); while armed, one canvas click places the
|
|
101
|
+
* new path's destination. `available` is published by MotionPathOverlay so the
|
|
102
|
+
* toolbar shows the button only when the selected element can take a path. */
|
|
103
|
+
motionPathArmed: boolean;
|
|
104
|
+
setMotionPathArmed: (armed: boolean) => void;
|
|
105
|
+
motionPathCreateAvailable: boolean;
|
|
106
|
+
setMotionPathCreateAvailable: (available: boolean) => void;
|
|
99
107
|
|
|
100
108
|
/** Multi-select: additional selected elements beyond selectedElementId. */
|
|
101
109
|
selectedElementIds: Set<string>;
|
|
@@ -209,6 +217,10 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
209
217
|
|
|
210
218
|
activeKeyframePct: null,
|
|
211
219
|
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
|
|
220
|
+
motionPathArmed: false,
|
|
221
|
+
setMotionPathArmed: (armed) => set({ motionPathArmed: armed }),
|
|
222
|
+
motionPathCreateAvailable: false,
|
|
223
|
+
setMotionPathCreateAvailable: (available) => set({ motionPathCreateAvailable: available }),
|
|
212
224
|
|
|
213
225
|
selectedElementIds: new Set<string>(),
|
|
214
226
|
toggleSelectedElementId: (id: string) =>
|
|
@@ -327,7 +339,16 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
327
339
|
setTimelineReady: (ready) => set({ timelineReady: ready }),
|
|
328
340
|
setBeatDragging: (dragging) => set({ beatDragging: dragging }),
|
|
329
341
|
setElements: (elements) => set({ elements }),
|
|
330
|
-
setSelectedElementId: (id) =>
|
|
342
|
+
setSelectedElementId: (id) =>
|
|
343
|
+
set((s) =>
|
|
344
|
+
// Selecting a different element drops any active keyframe selection — otherwise
|
|
345
|
+
// a stale activeKeyframePct from a prior diamond click would force the next drag
|
|
346
|
+
// to "modify" a keyframe on the new element. A diamond click sets the pct AFTER
|
|
347
|
+
// calling setSelectedElementId, so this never clobbers a genuine keyframe select.
|
|
348
|
+
id !== s.selectedElementId
|
|
349
|
+
? { selectedElementId: id, activeKeyframePct: null, motionPathArmed: false }
|
|
350
|
+
: { selectedElementId: id },
|
|
351
|
+
),
|
|
331
352
|
updateElement: (elementId, updates) =>
|
|
332
353
|
set((state) => ({
|
|
333
354
|
elements: state.elements.map((el) =>
|
|
@@ -361,3 +382,19 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
361
382
|
clipParentMap: new Map(),
|
|
362
383
|
}),
|
|
363
384
|
}));
|
|
385
|
+
|
|
386
|
+
// Bug-bash aid: expose the store so a reproduction can dump live state from the
|
|
387
|
+
// console, e.g. `__playerStore.getState().selectedElementId`. Harmless read
|
|
388
|
+
// handle; no behavioural effect.
|
|
389
|
+
// Only in dev. `import.meta.env` may be undefined in non-Vite bundlers (Next.js
|
|
390
|
+
// Turbopack), so guard the access like the telemetry client does.
|
|
391
|
+
function isDevBuild(): boolean {
|
|
392
|
+
try {
|
|
393
|
+
return import.meta.env.DEV === true;
|
|
394
|
+
} catch {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (isDevBuild() && typeof window !== "undefined") {
|
|
399
|
+
(window as unknown as { __playerStore?: typeof usePlayerStore }).__playerStore = usePlayerStore;
|
|
400
|
+
}
|
package/src/telemetry/config.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// localStorage.setItem('hyperframes-studio:telemetryDisabled','1')
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
|
|
8
|
+
import { generateId } from "../utils/generateId";
|
|
9
|
+
|
|
8
10
|
const ANON_ID_KEY = "hyperframes-studio:anonymousId";
|
|
9
11
|
const OPT_OUT_KEY = "hyperframes-studio:telemetryDisabled";
|
|
10
12
|
const NOTICE_KEY = "hyperframes-studio:telemetryNoticeShown";
|
|
@@ -18,8 +20,7 @@ function safeLocalStorage(): Storage | null {
|
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
function newAnonymousId(): string {
|
|
21
|
-
|
|
22
|
-
return `anon-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
23
|
+
return generateId();
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function getAnonymousId(): string {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Gated strategic logging for the GSAP keyframe / manual-drag / gesture / razor
|
|
2
|
+
// edit flows. Silent in production; on in dev builds, or anywhere once you set
|
|
3
|
+
// `window.__hfDebug = true` in the console. Single `[hf-edit:<scope>]` prefix so
|
|
4
|
+
// the whole edit pipeline is greppable. Fires only at commit boundaries (user
|
|
5
|
+
// actions), never in render/raf loops, so it doesn't spam.
|
|
6
|
+
export function editLog(scope: string, ...args: unknown[]): void {
|
|
7
|
+
if (typeof window === "undefined") return;
|
|
8
|
+
const w = window as unknown as { __hfDebug?: boolean };
|
|
9
|
+
if (!import.meta.env.DEV && !w.__hfDebug) return;
|
|
10
|
+
// Stringify object args so the console prints their contents inline (`{x:1}`)
|
|
11
|
+
// instead of a collapsed `Object` — keeps the edit trail greppable/copyable.
|
|
12
|
+
const parts = args.map((a) =>
|
|
13
|
+
typeof a === "object" && a !== null ? JSON.stringify(a) : String(a),
|
|
14
|
+
);
|
|
15
|
+
console.debug(`[hf-edit:${scope}]`, ...parts);
|
|
16
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// ponytail: crypto.randomUUID is undefined on plain HTTP non-localhost origins
|
|
2
|
+
export function generateId(): string {
|
|
3
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
4
|
+
return crypto.randomUUID();
|
|
5
|
+
}
|
|
6
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
7
|
+
}
|