@hyperframes/studio 0.7.90 → 0.7.93
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-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
- package/dist/assets/index-D78KEjgB.css +1 -0
- package/dist/assets/index-Dp1zVb9X.js +428 -0
- package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +40 -36
- package/dist/index.html +2 -2
- package/dist/index.js +9598 -6798
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +14 -14
- package/src/components/DesignPanelPromoteProvider.tsx +1 -1
- package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
- package/src/components/ExternalFileConflictBanner.tsx +255 -0
- package/src/components/StudioRightPanel.tsx +1 -1
- package/src/components/editor/GsapAnimationList.tsx +84 -0
- package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
- package/src/components/editor/GsapAnimationSection.tsx +11 -41
- package/src/components/editor/PropertyPanel.tsx +1 -1
- package/src/components/editor/PropertyPanelFlat.tsx +29 -26
- package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
- package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
- package/src/components/editor/propertyPanelTypes.ts +10 -4
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
- package/src/components/renders/useRenderQueue.ts +16 -3
- package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/contexts/FileManagerContext.tsx +12 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
- package/src/hooks/externalFileReloadBus.test.ts +34 -0
- package/src/hooks/externalFileReloadBus.ts +18 -0
- package/src/hooks/gsapDragCommit.test.ts +39 -1
- package/src/hooks/gsapDragCommit.ts +12 -40
- package/src/hooks/gsapEditOutcome.ts +72 -0
- package/src/hooks/gsapResizeIntercept.test.ts +90 -2
- package/src/hooks/gsapResizeIntercept.ts +44 -16
- package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
- package/src/hooks/gsapRuntimeBridge.ts +93 -28
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
- package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
- package/src/hooks/useDomGeometryCommits.ts +20 -0
- package/src/hooks/useEditorSave.test.tsx +131 -0
- package/src/hooks/useEditorSave.ts +113 -39
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
- package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
- package/src/hooks/useFileManager.ts +32 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
- package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
- package/src/hooks/useGsapAwareEditing.ts +88 -27
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
- package/src/hooks/useGsapKeyframeOps.ts +20 -9
- package/src/hooks/usePreviewPersistence.test.ts +23 -0
- package/src/hooks/usePreviewPersistence.ts +22 -58
- package/src/hooks/useSdkSession.ts +8 -52
- package/src/hooks/useStudioExternalFileChanges.ts +81 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -7
- package/src/hooks/useStudioSessionStart.ts +15 -0
- package/src/hooks/useStudioTestHooks.test.tsx +11 -7
- package/src/player/components/BeatStrip.test.tsx +374 -0
- package/src/player/components/BeatStrip.tsx +295 -37
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
- package/src/player/components/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +26 -8
- package/src/player/components/Timeline.tsx +87 -88
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineCanvas.tsx +14 -71
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +20 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
- package/src/player/components/TimelineClipDiamonds.tsx +118 -223
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
- package/src/player/components/TimelineGestureOverlay.tsx +96 -0
- package/src/player/components/TimelineLanes.test.tsx +64 -7
- package/src/player/components/TimelineLanes.tsx +84 -66
- package/src/player/components/TimelineOverlays.test.ts +161 -0
- package/src/player/components/TimelineOverlays.tsx +104 -11
- package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
- package/src/player/components/TimelinePropertyLanes.tsx +9 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.tsx +16 -0
- package/src/player/components/TimelineTrackRow.tsx +58 -7
- package/src/player/components/timelineCallbacks.ts +1 -1
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
- package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
- package/src/player/components/timelineClipDragPreview.test.ts +33 -0
- package/src/player/components/timelineClipDragPreview.ts +18 -12
- package/src/player/components/timelineClipDragTypes.ts +12 -0
- package/src/player/components/timelineDiamondTypes.ts +18 -21
- package/src/player/components/timelineDragDrop.test.tsx +206 -0
- package/src/player/components/timelineDragDrop.ts +152 -30
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -1
- package/src/player/components/timelineMarquee.test.ts +73 -25
- package/src/player/components/timelineMarquee.ts +44 -16
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/timelineTestViewport.ts +13 -0
- package/src/player/components/timelineViewModel.ts +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
- package/src/player/components/useTimelineClipDrag.ts +112 -70
- package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
- package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
- package/src/player/components/useTimelineKeyboardActor.ts +147 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
- package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
- package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
- package/src/player/components/useTimelineLogicalFocus.ts +80 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
- package/src/player/components/useTimelineLogicalRows.ts +47 -0
- package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
- package/src/player/components/useTimelineRangeSelection.ts +202 -69
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRowVirtualization.ts +26 -29
- package/src/player/components/useTrackGapMenu.test.tsx +64 -0
- package/src/player/components/useTrackGapMenu.ts +32 -9
- package/src/player/store/keyframeSlice.ts +59 -18
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +106 -23
- package/src/player/store/playerStore.ts +43 -40
- package/src/player/store/timelineFocusState.ts +15 -0
- package/src/telemetry/canary.test.ts +325 -0
- package/src/telemetry/canary.ts +268 -0
- package/src/telemetry/client.test.ts +8 -3
- package/src/telemetry/client.ts +13 -43
- package/src/telemetry/config.ts +16 -2
- package/src/telemetry/distinctId.test.ts +35 -0
- package/src/telemetry/distinctId.ts +11 -2
- package/src/telemetry/policy.test.ts +81 -0
- package/src/telemetry/policy.ts +104 -0
- package/src/utils/domEditSaveQueue.test.ts +37 -1
- package/src/utils/domEditSaveQueue.ts +22 -4
- package/src/utils/externalConflictStorage.test.ts +95 -0
- package/src/utils/externalConflictStorage.ts +172 -0
- package/src/utils/studioFileVersion.test.ts +28 -1
- package/src/utils/studioFileVersion.ts +30 -0
- package/src/utils/studioPendingEdits.test.ts +108 -1
- package/src/utils/studioPendingEdits.ts +39 -2
- package/src/utils/studioSaveDiagnostics.ts +5 -0
- package/src/utils/studioTelemetry.test.ts +76 -0
- package/src/utils/studioTelemetry.ts +14 -6
- package/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-Cql15Yur.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- package/src/player/components/useTimelineRevealClip.ts +0 -180
|
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
|
2
2
|
import type { CanvasResolution } from "@hyperframes/parsers";
|
|
3
3
|
import { trackStudioRenderStart } from "../../telemetry/events";
|
|
4
4
|
import { getAnonymousId } from "../../telemetry/config";
|
|
5
|
+
import { browserTelemetryAllowed } from "../../telemetry/policy";
|
|
5
6
|
import { generateId } from "../../utils/generateId";
|
|
6
7
|
|
|
7
8
|
export interface RenderJob {
|
|
@@ -157,16 +158,28 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
157
158
|
resolution?: string;
|
|
158
159
|
composition?: string;
|
|
159
160
|
variables?: Record<string, unknown>;
|
|
160
|
-
telemetryDistinctId
|
|
161
|
+
telemetryDistinctId?: string;
|
|
162
|
+
telemetryOptOut?: boolean;
|
|
161
163
|
} = {
|
|
162
164
|
fps,
|
|
163
165
|
quality,
|
|
164
166
|
format,
|
|
167
|
+
};
|
|
168
|
+
// The id is MINTED by getAnonymousId(), so calling it unconditionally
|
|
169
|
+
// created a telemetry identity for a profile that had opted out — and
|
|
170
|
+
// then shipped it to the server. The server's own policy cannot see this
|
|
171
|
+
// browser's localStorage or DoNotTrack, so it has to be told: an
|
|
172
|
+
// explicit `telemetryOptOut` suppresses the render outcome, which
|
|
173
|
+
// omitting the id alone does NOT (an old client omits it too, and that
|
|
174
|
+
// falls back to the install id).
|
|
175
|
+
if (browserTelemetryAllowed()) {
|
|
165
176
|
// So the server-emitted render_complete/render_error is attributed to
|
|
166
177
|
// this browser user (same id studio_* events use), making the render
|
|
167
178
|
// funnel joinable. Matches studio_render_start fired just above.
|
|
168
|
-
telemetryDistinctId
|
|
169
|
-
}
|
|
179
|
+
body.telemetryDistinctId = getAnonymousId();
|
|
180
|
+
} else {
|
|
181
|
+
body.telemetryOptOut = true;
|
|
182
|
+
}
|
|
170
183
|
if (resolution && resolution !== "auto") body.resolution = resolution;
|
|
171
184
|
if (composition) body.composition = composition;
|
|
172
185
|
if (opts.variables && Object.keys(opts.variables).length > 0) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
// The render request body is the only place the browser can tell the CLI that
|
|
4
|
+
// this profile opted out. The CLI's own policy cannot see localStorage or
|
|
5
|
+
// DoNotTrack in someone else's browser, so if the body says nothing, the
|
|
6
|
+
// server emits render_complete / render_error anyway — attributed to the
|
|
7
|
+
// install id rather than the user's, which is worse than attributing it
|
|
8
|
+
// correctly.
|
|
9
|
+
|
|
10
|
+
import { act } from "react";
|
|
11
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
13
|
+
|
|
14
|
+
const policyState = { allowed: true };
|
|
15
|
+
const mintCalls = vi.fn(() => "browser-user-123");
|
|
16
|
+
|
|
17
|
+
vi.mock("../../telemetry/policy", () => ({
|
|
18
|
+
browserTelemetryAllowed: () => policyState.allowed,
|
|
19
|
+
}));
|
|
20
|
+
vi.mock("../../telemetry/config", () => ({
|
|
21
|
+
getAnonymousId: () => mintCalls(),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("../../telemetry/events", () => ({
|
|
24
|
+
trackStudioRenderStart: vi.fn(),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const { useRenderQueue } = await import("./useRenderQueue");
|
|
28
|
+
|
|
29
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
30
|
+
|
|
31
|
+
let root: Root | null = null;
|
|
32
|
+
|
|
33
|
+
/** Body of the POST the hook makes when a render is started. */
|
|
34
|
+
async function startRenderBody(): Promise<Record<string, unknown>> {
|
|
35
|
+
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
|
|
36
|
+
Promise.resolve(
|
|
37
|
+
new Response(JSON.stringify({ jobId: "j1", status: "rendering" }), {
|
|
38
|
+
status: 200,
|
|
39
|
+
headers: { "content-type": "application/json" },
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
44
|
+
vi.stubGlobal(
|
|
45
|
+
"EventSource",
|
|
46
|
+
class {
|
|
47
|
+
close(): void {}
|
|
48
|
+
addEventListener(): void {}
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
let api: ReturnType<typeof useRenderQueue> | null = null;
|
|
53
|
+
function Harness(): null {
|
|
54
|
+
api = useRenderQueue("demo");
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const host = document.createElement("div");
|
|
58
|
+
document.body.append(host);
|
|
59
|
+
root = createRoot(host);
|
|
60
|
+
act(() => {
|
|
61
|
+
root?.render(<Harness />);
|
|
62
|
+
});
|
|
63
|
+
await act(async () => {
|
|
64
|
+
await api?.startRender({ fps: 30, quality: "standard", format: "mp4" });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const post = fetchMock.mock.calls.find(([, init]) => init?.method === "POST");
|
|
68
|
+
const body = post?.[1]?.body;
|
|
69
|
+
if (body === undefined || body === null) throw new Error("hook made no POST with a body");
|
|
70
|
+
return JSON.parse(String(body)) as Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
policyState.allowed = true;
|
|
75
|
+
mintCalls.mockClear();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
if (root) act(() => root?.unmount());
|
|
80
|
+
root = null;
|
|
81
|
+
document.body.innerHTML = "";
|
|
82
|
+
vi.unstubAllGlobals();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("render request telemetry fields", () => {
|
|
86
|
+
it("sends the browser id when this profile allows telemetry", async () => {
|
|
87
|
+
const body = await startRenderBody();
|
|
88
|
+
expect(body["telemetryDistinctId"]).toBe("browser-user-123");
|
|
89
|
+
expect(body["telemetryOptOut"]).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("mints no id and says so explicitly when the profile opted out", async () => {
|
|
93
|
+
policyState.allowed = false;
|
|
94
|
+
const body = await startRenderBody();
|
|
95
|
+
// Minting is itself the leak: getAnonymousId() CREATES and persists an
|
|
96
|
+
// identity, so calling it for an opted-out profile is wrong even if the
|
|
97
|
+
// value were never sent.
|
|
98
|
+
expect(mintCalls).not.toHaveBeenCalled();
|
|
99
|
+
expect(body["telemetryDistinctId"]).toBeUndefined();
|
|
100
|
+
// Omission alone would be read as an old client and fall back to the
|
|
101
|
+
// install id — the flag is what actually suppresses the server event.
|
|
102
|
+
expect(body["telemetryOptOut"]).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -8,6 +8,7 @@ import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
|
|
|
8
8
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
9
9
|
import { ContextMenu } from "./AssetContextMenu";
|
|
10
10
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
11
|
+
import { timelineClipFocusId } from "../../player/components/timelineNavigationIdentity";
|
|
11
12
|
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
|
12
13
|
import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
|
|
13
14
|
import { basename, ext, truncateMiddle, formatDuration } from "./assetHelpers";
|
|
@@ -133,7 +134,7 @@ export function AssetCard({
|
|
|
133
134
|
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
|
|
134
135
|
|
|
135
136
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
136
|
-
const
|
|
137
|
+
const requestTimelineFocus = usePlayerStore((s) => s.requestTimelineFocus);
|
|
137
138
|
const elements = usePlayerStore((s) => s.elements);
|
|
138
139
|
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
|
|
139
140
|
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
|
|
@@ -158,7 +159,7 @@ export function AssetCard({
|
|
|
158
159
|
const clipKey = clip.key ?? clip.id;
|
|
159
160
|
setSelectedElementId(clipKey);
|
|
160
161
|
// Scroll the timeline so the selected clip is actually visible.
|
|
161
|
-
|
|
162
|
+
requestTimelineFocus(timelineClipFocusId(clipKey));
|
|
162
163
|
return;
|
|
163
164
|
}
|
|
164
165
|
}
|
|
@@ -171,7 +172,7 @@ export function AssetCard({
|
|
|
171
172
|
asset,
|
|
172
173
|
projectId,
|
|
173
174
|
setSelectedElementId,
|
|
174
|
-
|
|
175
|
+
requestTimelineFocus,
|
|
175
176
|
setPreviewAsset,
|
|
176
177
|
clearPreviewAsset,
|
|
177
178
|
],
|
|
@@ -6,6 +6,7 @@ import { usePlayerStore } from "../../player/store/playerStore";
|
|
|
6
6
|
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
|
7
7
|
import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
|
|
8
8
|
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
|
|
9
|
+
import { timelineClipFocusId } from "../../player/components/timelineNavigationIdentity";
|
|
9
10
|
|
|
10
11
|
export function AudioRow({
|
|
11
12
|
projectId,
|
|
@@ -43,7 +44,7 @@ export function AudioRow({
|
|
|
43
44
|
// CapCut-style click behavior: drag-threshold gate.
|
|
44
45
|
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
|
|
45
46
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
46
|
-
const
|
|
47
|
+
const requestTimelineFocus = usePlayerStore((s) => s.requestTimelineFocus);
|
|
47
48
|
const elements = usePlayerStore((s) => s.elements);
|
|
48
49
|
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
|
|
49
50
|
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
|
|
@@ -67,7 +68,7 @@ export function AudioRow({
|
|
|
67
68
|
const clipKey = clip.key ?? clip.id;
|
|
68
69
|
setSelectedElementId(clipKey);
|
|
69
70
|
// Scroll the timeline so the selected clip is actually visible.
|
|
70
|
-
|
|
71
|
+
requestTimelineFocus(timelineClipFocusId(clipKey));
|
|
71
72
|
return;
|
|
72
73
|
}
|
|
73
74
|
}
|
|
@@ -80,7 +81,7 @@ export function AudioRow({
|
|
|
80
81
|
asset,
|
|
81
82
|
projectId,
|
|
82
83
|
setSelectedElementId,
|
|
83
|
-
|
|
84
|
+
requestTimelineFocus,
|
|
84
85
|
setPreviewAsset,
|
|
85
86
|
clearPreviewAsset,
|
|
86
87
|
],
|
|
@@ -27,9 +27,13 @@ export function FileManagerProvider({
|
|
|
27
27
|
editingPathRef,
|
|
28
28
|
projectIdRef,
|
|
29
29
|
saveRafRef,
|
|
30
|
+
flushPendingSourceSave,
|
|
31
|
+
discardPendingSourceSave,
|
|
32
|
+
getPendingSourceCandidate,
|
|
30
33
|
importedFontAssetsRef,
|
|
31
34
|
readProjectFile,
|
|
32
35
|
writeProjectFile,
|
|
36
|
+
overwriteExternalConflict,
|
|
33
37
|
readOptionalProjectFile,
|
|
34
38
|
observeProjectFileVersion,
|
|
35
39
|
updateEditingFileContent,
|
|
@@ -67,9 +71,13 @@ export function FileManagerProvider({
|
|
|
67
71
|
editingPathRef,
|
|
68
72
|
projectIdRef,
|
|
69
73
|
saveRafRef,
|
|
74
|
+
flushPendingSourceSave,
|
|
75
|
+
discardPendingSourceSave,
|
|
76
|
+
getPendingSourceCandidate,
|
|
70
77
|
importedFontAssetsRef,
|
|
71
78
|
readProjectFile,
|
|
72
79
|
writeProjectFile,
|
|
80
|
+
overwriteExternalConflict,
|
|
73
81
|
readOptionalProjectFile,
|
|
74
82
|
observeProjectFileVersion,
|
|
75
83
|
updateEditingFileContent,
|
|
@@ -101,9 +109,13 @@ export function FileManagerProvider({
|
|
|
101
109
|
editingPathRef,
|
|
102
110
|
projectIdRef,
|
|
103
111
|
saveRafRef,
|
|
112
|
+
flushPendingSourceSave,
|
|
113
|
+
discardPendingSourceSave,
|
|
114
|
+
getPendingSourceCandidate,
|
|
104
115
|
importedFontAssetsRef,
|
|
105
116
|
readProjectFile,
|
|
106
117
|
writeProjectFile,
|
|
118
|
+
overwriteExternalConflict,
|
|
107
119
|
readOptionalProjectFile,
|
|
108
120
|
observeProjectFileVersion,
|
|
109
121
|
updateEditingFileContent,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
describe("external file-change subscription ownership", () => {
|
|
5
|
+
it("has one subscriber instead of independent Preview and SDK listeners", () => {
|
|
6
|
+
const preview = readFileSync(new URL("./usePreviewPersistence.ts", import.meta.url), "utf8");
|
|
7
|
+
const sdk = readFileSync(new URL("./useSdkSession.ts", import.meta.url), "utf8");
|
|
8
|
+
const coordinator = readFileSync(
|
|
9
|
+
new URL("./useExternalFileChangeCoordinator.ts", import.meta.url),
|
|
10
|
+
"utf8",
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
expect(preview).not.toContain('hot.on("hf:file-change"');
|
|
14
|
+
expect(sdk).not.toContain('hot.on("hf:file-change"');
|
|
15
|
+
expect(coordinator.match(/hot\.on\("hf:file-change"/g)).toHaveLength(1);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { addExternalFileReloadListener, notifyExternalFileReload } from "./externalFileReloadBus";
|
|
3
|
+
|
|
4
|
+
describe("external file reload bus", () => {
|
|
5
|
+
it("lets the sole watcher reload every SDK owner of the changed path", () => {
|
|
6
|
+
const first = vi.fn();
|
|
7
|
+
const second = vi.fn();
|
|
8
|
+
const removeFirst = addExternalFileReloadListener(first);
|
|
9
|
+
const removeSecond = addExternalFileReloadListener(second);
|
|
10
|
+
|
|
11
|
+
notifyExternalFileReload("scenes/card.html");
|
|
12
|
+
expect(first).toHaveBeenCalledWith("scenes/card.html");
|
|
13
|
+
expect(second).toHaveBeenCalledWith("scenes/card.html");
|
|
14
|
+
|
|
15
|
+
removeFirst();
|
|
16
|
+
removeSecond();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("isolates a broken listener so later SDK owners still reload", () => {
|
|
20
|
+
const broken = vi.fn(() => {
|
|
21
|
+
throw new Error("stale owner");
|
|
22
|
+
});
|
|
23
|
+
const healthy = vi.fn();
|
|
24
|
+
const removeBroken = addExternalFileReloadListener(broken);
|
|
25
|
+
const removeHealthy = addExternalFileReloadListener(healthy);
|
|
26
|
+
|
|
27
|
+
expect(() => notifyExternalFileReload("scenes/card.html")).not.toThrow();
|
|
28
|
+
expect(broken).toHaveBeenCalledWith("scenes/card.html");
|
|
29
|
+
expect(healthy).toHaveBeenCalledWith("scenes/card.html");
|
|
30
|
+
|
|
31
|
+
removeBroken();
|
|
32
|
+
removeHealthy();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
type ExternalFileReloadListener = (path: string) => void;
|
|
2
|
+
|
|
3
|
+
const listeners = new Set<ExternalFileReloadListener>();
|
|
4
|
+
|
|
5
|
+
export function addExternalFileReloadListener(listener: ExternalFileReloadListener): () => void {
|
|
6
|
+
listeners.add(listener);
|
|
7
|
+
return () => listeners.delete(listener);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function notifyExternalFileReload(path: string): void {
|
|
11
|
+
for (const listener of listeners) {
|
|
12
|
+
try {
|
|
13
|
+
listener(path);
|
|
14
|
+
} catch {
|
|
15
|
+
// A stale SDK owner must not prevent sibling owners from reloading.
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import { describe, expect, it, beforeEach } from "vitest";
|
|
3
|
+
import { describe, expect, it, beforeEach, vi } from "vitest";
|
|
4
4
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
5
5
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
6
6
|
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
commitStaticGsapRotation,
|
|
10
10
|
commitStaticGsapSize,
|
|
11
11
|
findExistingPositionWrite,
|
|
12
|
+
materializeIfDynamic,
|
|
12
13
|
parkPlayheadOnKeyframe,
|
|
13
14
|
type GsapDragCommitCallbacks,
|
|
14
15
|
} from "./gsapDragCommit";
|
|
@@ -27,6 +28,43 @@ const selection = (): DomEditSelection =>
|
|
|
27
28
|
},
|
|
28
29
|
}) as unknown as DomEditSelection;
|
|
29
30
|
|
|
31
|
+
function selectorlessSelection(): DomEditSelection {
|
|
32
|
+
return {
|
|
33
|
+
selector: ".shared",
|
|
34
|
+
element: document.createElement("div"),
|
|
35
|
+
} as unknown as DomEditSelection;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("lower GSAP commit helpers fail closed", () => {
|
|
39
|
+
it("rejects instead of silently dropping a static position write without a stable selector", async () => {
|
|
40
|
+
const commitMutation = vi.fn();
|
|
41
|
+
await expect(
|
|
42
|
+
commitStaticGsapPosition(
|
|
43
|
+
selectorlessSelection(),
|
|
44
|
+
{ x: 10, y: 20 },
|
|
45
|
+
{ x: 0, y: 0 },
|
|
46
|
+
".shared",
|
|
47
|
+
null,
|
|
48
|
+
{ commitMutation },
|
|
49
|
+
),
|
|
50
|
+
).rejects.toMatchObject({ name: "GsapEditBlockedError", reason: "no-selector" });
|
|
51
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rejects runtime-dynamic materialization instead of rewriting source during a gesture", async () => {
|
|
55
|
+
const commitMutation = vi.fn();
|
|
56
|
+
await expect(
|
|
57
|
+
materializeIfDynamic(
|
|
58
|
+
{ ...flatTween(), hasUnresolvedKeyframes: true },
|
|
59
|
+
null,
|
|
60
|
+
commitMutation,
|
|
61
|
+
selection(),
|
|
62
|
+
),
|
|
63
|
+
).rejects.toMatchObject({ name: "GsapEditBlockedError", reason: "source-uneditable" });
|
|
64
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
30
68
|
const flatTween = (): GsapAnimation =>
|
|
31
69
|
({
|
|
32
70
|
id: "#puck-a-to",
|
|
@@ -9,14 +9,14 @@ import {
|
|
|
9
9
|
STUDIO_ORIGINAL_HEIGHT_ATTR,
|
|
10
10
|
} from "../components/editor/manualEditsTypes";
|
|
11
11
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
12
|
-
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
|
13
12
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
14
13
|
import { roundTo3 } from "../utils/rounding";
|
|
15
|
-
import { computeElementPercentage,
|
|
14
|
+
import { computeElementPercentage, writeTargetSelector } from "./gsapShared";
|
|
16
15
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
17
16
|
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
|
18
17
|
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
|
|
19
18
|
import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
|
|
19
|
+
import { GsapEditBlockedError } from "./gsapEditOutcome";
|
|
20
20
|
export {
|
|
21
21
|
findExistingPositionWrite,
|
|
22
22
|
findRotationSetAnimation,
|
|
@@ -87,7 +87,7 @@ async function replaceKeyframedPositionHold(
|
|
|
87
87
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
88
88
|
): Promise<void> {
|
|
89
89
|
const target = newTweenTarget(selection);
|
|
90
|
-
if (!target)
|
|
90
|
+
if (!target) throw new GsapEditBlockedError("no-selector");
|
|
91
91
|
const persist = async (commit: GsapDragCommitCallbacks["commitMutation"]) => {
|
|
92
92
|
await commit(
|
|
93
93
|
selection,
|
|
@@ -130,40 +130,12 @@ export async function materializeIfDynamic(
|
|
|
130
130
|
selection: DomEditSelection,
|
|
131
131
|
): Promise<string | void> {
|
|
132
132
|
if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
keyframes: data.keyframes,
|
|
140
|
-
easeEach: data.easeEach,
|
|
141
|
-
}));
|
|
142
|
-
await commitMutation(
|
|
143
|
-
selection,
|
|
144
|
-
{
|
|
145
|
-
type: "materialize-keyframes",
|
|
146
|
-
animationId: anim.id,
|
|
147
|
-
keyframes: allScanned.get(selection.id ?? "")?.keyframes ?? [],
|
|
148
|
-
allElements,
|
|
149
|
-
},
|
|
150
|
-
{ label: "Unroll dynamic animations", skipReload: true },
|
|
151
|
-
);
|
|
152
|
-
return `${anim.targetSelector}-to-0`;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
|
|
156
|
-
if (!runtime || runtime.keyframes.length === 0) return;
|
|
157
|
-
await commitMutation(
|
|
158
|
-
selection,
|
|
159
|
-
{
|
|
160
|
-
type: "materialize-keyframes",
|
|
161
|
-
animationId: anim.id,
|
|
162
|
-
keyframes: runtime.keyframes,
|
|
163
|
-
easeEach: runtime.easeEach,
|
|
164
|
-
},
|
|
165
|
-
{ label: "Materialize dynamic keyframes", skipReload: true },
|
|
166
|
-
);
|
|
133
|
+
// Geometry commits must never rewrite runtime/computed source implicitly.
|
|
134
|
+
// The explicit Unroll action owns that source-destructive transition.
|
|
135
|
+
void iframe;
|
|
136
|
+
void commitMutation;
|
|
137
|
+
void selection;
|
|
138
|
+
throw new GsapEditBlockedError("source-uneditable");
|
|
167
139
|
}
|
|
168
140
|
|
|
169
141
|
// ── Drag → GSAP position math ──────────────────────────────────────────────
|
|
@@ -219,7 +191,7 @@ export async function commitStaticGsapPosition(
|
|
|
219
191
|
// The patch reuses the WRITTEN target so the runtime moves exactly the element
|
|
220
192
|
// the source write names.
|
|
221
193
|
const target = newTweenTarget(selection);
|
|
222
|
-
if (!target)
|
|
194
|
+
if (!target) throw new GsapEditBlockedError("no-selector");
|
|
223
195
|
await callbacks.commitMutation(
|
|
224
196
|
selection,
|
|
225
197
|
{
|
|
@@ -277,7 +249,7 @@ export async function commitStaticGsapRotation(
|
|
|
277
249
|
}
|
|
278
250
|
// New static hold → off-timeline `gsap.set` (no 0% keyframe marker) + instant patch.
|
|
279
251
|
const target = newTweenTarget(selection);
|
|
280
|
-
if (!target)
|
|
252
|
+
if (!target) throw new GsapEditBlockedError("no-selector");
|
|
281
253
|
await callbacks.commitMutation(
|
|
282
254
|
selection,
|
|
283
255
|
{
|
|
@@ -331,7 +303,7 @@ export async function commitStaticGsapSize(
|
|
|
331
303
|
return;
|
|
332
304
|
}
|
|
333
305
|
const target = newTweenTarget(selection);
|
|
334
|
-
if (!target)
|
|
306
|
+
if (!target) throw new GsapEditBlockedError("no-selector");
|
|
335
307
|
await callbacks.commitMutation(
|
|
336
308
|
selection,
|
|
337
309
|
{
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
|
|
3
|
+
export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable";
|
|
4
|
+
|
|
5
|
+
export type GsapEditOutcome =
|
|
6
|
+
| { status: "persisted" }
|
|
7
|
+
| { status: "blocked"; reason: GsapEditBlockReason };
|
|
8
|
+
|
|
9
|
+
const COPY: Record<GsapEditBlockReason, string> = {
|
|
10
|
+
"no-selector": "This layer needs a stable selector before Studio can save the edit.",
|
|
11
|
+
"unroll-required":
|
|
12
|
+
"This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
|
|
13
|
+
"source-uneditable": "This animation is computed at runtime. Edit the animation in the Code tab.",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export class GsapEditBlockedError extends Error {
|
|
17
|
+
constructor(readonly reason: GsapEditBlockReason) {
|
|
18
|
+
super(COPY[reason]);
|
|
19
|
+
this.name = "GsapEditBlockedError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function assertGsapEditPersisted(outcome: GsapEditOutcome): void {
|
|
24
|
+
if (outcome.status === "blocked") throw new GsapEditBlockedError(outcome.reason);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assertGsapAnimationDirectlyEditable(animation: GsapAnimation): void {
|
|
28
|
+
const editability = editabilityForProvenance(animation.provenance);
|
|
29
|
+
if (editability === "unroll") throw new GsapEditBlockedError("unroll-required");
|
|
30
|
+
if (
|
|
31
|
+
editability === "source" ||
|
|
32
|
+
animation.hasUnresolvedKeyframes ||
|
|
33
|
+
animation.hasUnresolvedSelector
|
|
34
|
+
) {
|
|
35
|
+
throw new GsapEditBlockedError("source-uneditable");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isGsapEditBlockedError(error: unknown): error is GsapEditBlockedError {
|
|
40
|
+
return error instanceof GsapEditBlockedError;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function animationWritesAnyProperty(
|
|
44
|
+
animation: GsapAnimation,
|
|
45
|
+
properties: ReadonlySet<string>,
|
|
46
|
+
): boolean {
|
|
47
|
+
return (
|
|
48
|
+
Object.keys(animation.properties ?? {}).some((property) => properties.has(property)) ||
|
|
49
|
+
Object.keys(animation.fromProperties ?? {}).some((property) => properties.has(property)) ||
|
|
50
|
+
!!animation.keyframes?.keyframes.some((keyframe) =>
|
|
51
|
+
Object.keys(keyframe.properties).some((property) => properties.has(property)),
|
|
52
|
+
)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Fail-closed ownership check shared by drag, resize, rotate, and inspector edits. */
|
|
57
|
+
export function directEditOutcomeForProperties(
|
|
58
|
+
animations: GsapAnimation[],
|
|
59
|
+
properties: ReadonlySet<string>,
|
|
60
|
+
): GsapEditOutcome {
|
|
61
|
+
try {
|
|
62
|
+
for (const animation of animations) {
|
|
63
|
+
if (animationWritesAnyProperty(animation, properties)) {
|
|
64
|
+
assertGsapAnimationDirectlyEditable(animation);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { status: "persisted" };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (isGsapEditBlockedError(error)) return { status: "blocked", reason: error.reason };
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|