@hyperframes/studio 0.8.81 → 0.8.83

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.
Files changed (161) hide show
  1. package/dist/assets/{hyperframes-player-C68UawR3.js → hyperframes-player-BAerMCex.js} +1 -1
  2. package/dist/assets/{index-CMzPGv9P.js → index-BUhcVhNy.js} +1 -1
  3. package/dist/assets/{index-hNVM9Pd2.js → index-BYhK_KMc.js} +187 -187
  4. package/dist/assets/{index-CS5SImRT.js → index-Iv0HI7eM.js} +1 -1
  5. package/dist/{chunk-I7XNAIOF.js → chunk-3IOCVWB6.js} +6 -97
  6. package/dist/chunk-3IOCVWB6.js.map +1 -0
  7. package/dist/chunk-62DDWOSH.js +189 -0
  8. package/dist/chunk-62DDWOSH.js.map +1 -0
  9. package/dist/chunk-LPZMAGS7.js +1871 -0
  10. package/dist/chunk-LPZMAGS7.js.map +1 -0
  11. package/dist/chunk-QZ6HNNFG.js +934 -0
  12. package/dist/chunk-QZ6HNNFG.js.map +1 -0
  13. package/dist/chunk-S65AQH6M.js +101 -0
  14. package/dist/chunk-S65AQH6M.js.map +1 -0
  15. package/dist/{domEditingLayers-MHFDKAQ7.js → domEditingLayers-ZOOMN7CQ.js} +3 -2
  16. package/dist/index.d.ts +834 -330
  17. package/dist/index.html +1 -1
  18. package/dist/index.js +26378 -28802
  19. package/dist/index.js.map +1 -1
  20. package/dist/player.d.ts +30 -0
  21. package/dist/player.js +10 -0
  22. package/dist/player.js.map +1 -0
  23. package/dist/ui.d.ts +294 -0
  24. package/dist/ui.js +66 -0
  25. package/dist/ui.js.map +1 -0
  26. package/package.json +16 -8
  27. package/src/components/StudioHeader.tsx +1 -121
  28. package/src/components/TimelineHistoryButtons.dom.test.tsx +38 -1
  29. package/src/components/TimelineHistoryButtons.tsx +26 -14
  30. package/src/components/TimelineToolbar.test.tsx +9 -1
  31. package/src/components/TimelineToolbar.tsx +13 -5
  32. package/src/components/editor/BlockParamsPanel.tsx +14 -10
  33. package/src/components/editor/CanvasContextMenu.test.tsx +3 -3
  34. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  35. package/src/components/editor/ConnectedDomEditOverlay.test.tsx +163 -0
  36. package/src/components/editor/ConnectedDomEditOverlay.tsx +206 -0
  37. package/src/components/editor/DomEditOverlay.tsx +36 -21
  38. package/src/components/editor/LayersPanel.tsx +1 -1
  39. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  40. package/src/components/editor/SnapGuideOverlay.tsx +1 -1
  41. package/src/components/editor/anchoredResizeReleaseShift.test.ts +2 -6
  42. package/src/components/editor/clipAudioClock.test.ts +61 -0
  43. package/src/components/editor/clipAudioClock.ts +78 -0
  44. package/src/components/editor/manualEdits.test.ts +24 -0
  45. package/src/components/editor/manualEdits.ts +6 -2
  46. package/src/components/editor/manualEditsDom.ts +0 -65
  47. package/src/components/editor/manualEditsSeekReapply.ts +136 -0
  48. package/src/components/editor/marqueeCommit.ts +100 -37
  49. package/src/components/editor/previewReadOnly.overlay.test.tsx +220 -17
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +206 -32
  51. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +62 -0
  52. package/src/components/editor/propertyPanelFlatMediaSection.tsx +4 -6
  53. package/src/components/editor/propertyPanelHelpers.ts +15 -0
  54. package/src/components/editor/propertyPanelMediaSection.tsx +3 -5
  55. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +1 -1
  56. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +117 -0
  57. package/src/components/editor/useFxCarve.ts +11 -15
  58. package/src/components/editor/useFxCarveNodes.ts +27 -15
  59. package/src/components/editor/useFxLevelling.ts +20 -31
  60. package/src/components/editor/useInlineTextEditing.tsx +35 -19
  61. package/src/components/feedback/projectProvenance.ts +2 -3
  62. package/src/components/nle/AudioMeterStrip.test.tsx +26 -3
  63. package/src/components/nle/AudioMeterStrip.tsx +23 -12
  64. package/src/components/nle/PreviewOverlays.tsx +7 -188
  65. package/src/components/nle/PreviewOverlaysTestMocks.ts +2 -2
  66. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +1 -1
  67. package/src/components/pointerTargetSize.test.tsx +1 -1
  68. package/src/components/studioColorGradingScope.ts +12 -36
  69. package/src/components/ui/HyperframesLogo.tsx +120 -0
  70. package/src/contexts/TimelineEditContext.test.tsx +49 -0
  71. package/src/contexts/TimelineEditContext.tsx +27 -23
  72. package/src/domEditExports.test.tsx +69 -0
  73. package/src/editorGesturesExports.test.tsx +236 -0
  74. package/src/hooks/canvasQuickSaves.test.tsx +195 -0
  75. package/src/hooks/gsapMutationClient.ts +116 -0
  76. package/src/hooks/resizeObserverTestUtils.ts +14 -0
  77. package/src/hooks/timelineAudioGroupCreate.ts +18 -40
  78. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  79. package/src/hooks/timelineEditingHelpers.ts +66 -61
  80. package/src/hooks/timelineQuickSaves.test.ts +195 -0
  81. package/src/hooks/timelineTimingSync.test.ts +12 -0
  82. package/src/hooks/timelineTimingSync.ts +31 -123
  83. package/src/hooks/timelineTrackVisibility.ts +15 -29
  84. package/src/hooks/useAskAgentModal.test.tsx +78 -0
  85. package/src/hooks/useAskAgentModal.ts +3 -1
  86. package/src/hooks/useClipboard.source.test.ts +32 -0
  87. package/src/hooks/useClipboard.ts +21 -20
  88. package/src/hooks/useDomEditCommits.test.tsx +13 -9
  89. package/src/hooks/useDomEditCommits.ts +88 -97
  90. package/src/hooks/useDomEditCommitsHelpers.ts +69 -6
  91. package/src/hooks/useEditorSave.ts +1 -1
  92. package/src/hooks/useElementLifecycleOps.ts +37 -38
  93. package/src/hooks/useFileManager.projectOwnership.test.tsx +51 -4
  94. package/src/hooks/useFileManager.ts +21 -12
  95. package/src/hooks/useGroupCommits.ts +23 -24
  96. package/src/hooks/useGsapAnimationOps.ts +26 -8
  97. package/src/hooks/useGsapScriptCommits.ts +1 -0
  98. package/src/hooks/useKeyframeKeyboard.test.tsx +99 -0
  99. package/src/hooks/useKeyframeKeyboard.ts +9 -71
  100. package/src/hooks/usePreviewPersistence.ts +1 -1
  101. package/src/hooks/useThumbnailStripSize.test.tsx +47 -0
  102. package/src/hooks/useThumbnailStripSize.ts +31 -0
  103. package/src/hooks/useTimelineAssetDropOps.ts +38 -35
  104. package/src/hooks/useTimelineDeleteOps.ts +53 -56
  105. package/src/hooks/useTimelineEditing.ts +3 -1
  106. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  107. package/src/index.ts +53 -32
  108. package/src/lightEntries.test.ts +32 -0
  109. package/src/player/components/CompositionThumbnail.test.ts +40 -6
  110. package/src/player/components/CompositionThumbnail.tsx +6 -21
  111. package/src/player/components/ImageThumbnail.test.tsx +27 -6
  112. package/src/player/components/ImageThumbnail.tsx +4 -18
  113. package/src/player/components/PlayerControls.test.tsx +52 -0
  114. package/src/player/components/PlayerControls.tsx +2 -1
  115. package/src/player/components/Timeline.test.ts +28 -3
  116. package/src/player/components/TimelineCanvas.tsx +5 -3
  117. package/src/player/components/TimelineClipFades.test.tsx +31 -10
  118. package/src/player/components/TimelineClipFades.tsx +2 -1
  119. package/src/player/components/TimelineTypes.ts +4 -2
  120. package/src/player/components/VideoThumbnail.test.tsx +33 -11
  121. package/src/player/components/VideoThumbnail.tsx +7 -29
  122. package/src/player/components/studioShortcuts.ts +8 -5
  123. package/src/player/components/thumbnailUtils.test.ts +16 -20
  124. package/src/player/components/thumbnailUtils.ts +6 -7
  125. package/src/player/components/timelineClipChildren.test.ts +34 -1
  126. package/src/player/components/timelineClipChildren.tsx +7 -4
  127. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +141 -85
  128. package/src/player/components/timelineClipDragGestureLifecycle.ts +2 -0
  129. package/src/player/components/timelineEditing.test.ts +55 -0
  130. package/src/player/components/timelineGroupEditing.ts +13 -6
  131. package/src/player/components/timelinePlayheadTransform.test.ts +33 -0
  132. package/src/player/components/timelinePlayheadTransform.ts +17 -0
  133. package/src/player/components/timelinePointerRelease.ts +4 -0
  134. package/src/player/components/useTimelinePlayhead.ts +26 -8
  135. package/src/player/components/useTimelineProviderState.tsx +4 -0
  136. package/src/player/components/useTimelineStackingSync.test.tsx +46 -0
  137. package/src/player/components/useTimelineStackingSync.ts +18 -11
  138. package/src/player/hooks/timelinePlayerSync.test.ts +20 -0
  139. package/src/player/hooks/timelinePlayerSync.ts +18 -8
  140. package/src/player/hooks/useShadowPreviewReload.ts +15 -4
  141. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +19 -0
  142. package/src/player/hooks/useTimelinePlayer.test.ts +93 -1
  143. package/src/player/lib/playbackShortcuts.ts +18 -0
  144. package/src/player/lib/playbackTypes.ts +1 -0
  145. package/src/player/lib/timelineDOM.test.ts +92 -0
  146. package/src/player/lib/timelineDOM.ts +0 -2
  147. package/src/player/lib/timelineElementHelpers.ts +47 -10
  148. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  149. package/src/player/store/timelineElement.ts +2 -1
  150. package/src/playerEntry.ts +1 -0
  151. package/src/timelineChromeExports.test.tsx +79 -0
  152. package/src/timelineStackingSyncExport.test.tsx +67 -0
  153. package/src/ui.ts +31 -0
  154. package/src/utils/authoredSource.ts +15 -0
  155. package/src/utils/blockInstaller.ts +32 -27
  156. package/src/utils/mediaTypes.test.ts +33 -0
  157. package/src/utils/mediaTypes.ts +12 -5
  158. package/src/utils/studioFileHistory.test.ts +14 -14
  159. package/src/utils/studioFileHistory.ts +31 -2
  160. package/dist/chunk-I7XNAIOF.js.map +0 -1
  161. /package/dist/{domEditingLayers-MHFDKAQ7.js.map → domEditingLayers-ZOOMN7CQ.js.map} +0 -0
@@ -0,0 +1,236 @@
1
+ // @vitest-environment happy-dom
2
+ // Drives Studio's snapping and marquee by package name with a host's own iframe and overlay.
3
+ import { act, type PointerEvent } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ collectSnapContext,
7
+ resolveSnapAdjustment,
8
+ SNAP_THRESHOLD_PX,
9
+ SnapGuideOverlay,
10
+ useMarqueeGestures,
11
+ usePlayerStore,
12
+ type MarqueeGestures,
13
+ type MarqueeGesturesDeps,
14
+ type MarqueeRect,
15
+ } from "@hyperframes/studio";
16
+ import { installReactActEnvironment, mountReactHarness } from "./hooks/domSelectionTestHarness";
17
+ import {
18
+ collectDomEditLayerItems,
19
+ resolveDomEditSelection,
20
+ } from "./components/editor/domEditingLayers";
21
+
22
+ const rects = new Map<string, MarqueeRect>([
23
+ ["a", { left: 0, top: 0, width: 10, height: 10 }],
24
+ ["b", { left: 100, top: 100, width: 10, height: 10 }],
25
+ ]);
26
+
27
+ vi.mock("./components/editor/domEditOverlayGeometry", async (importOriginal) => ({
28
+ ...(await importOriginal<object>()),
29
+ isElementVisibleForOverlay: () => true,
30
+ toVisibleOverlayRect: (_overlay: unknown, _iframe: unknown, el: HTMLElement) => {
31
+ const rect = rects.get(el.id);
32
+ return rect ? { ...rect, editScaleX: 1, editScaleY: 1 } : null;
33
+ },
34
+ }));
35
+ vi.mock("./components/editor/domEditingElement", async (importOriginal) => ({
36
+ ...(await importOriginal<object>()),
37
+ isElementComputedVisible: () => true,
38
+ }));
39
+ vi.mock("./components/editor/domEditingLayers", async (importOriginal) => ({
40
+ ...(await importOriginal<object>()),
41
+ collectDomEditLayerItems: vi.fn((root: HTMLElement) =>
42
+ Array.from(root.children).map((element) => ({ element })),
43
+ ),
44
+ resolveDomEditSelection: vi.fn(async (element: HTMLElement) => ({ id: element.id })),
45
+ }));
46
+
47
+ installReactActEnvironment();
48
+
49
+ afterEach(() => {
50
+ document.body.innerHTML = "";
51
+ vi.clearAllMocks();
52
+ usePlayerStore.setState({ previewBooted: false });
53
+ });
54
+
55
+ function MarqueeHost<T>(props: {
56
+ deps: MarqueeGesturesDeps<T>;
57
+ onState: (state: MarqueeGestures) => void;
58
+ }) {
59
+ props.onState(useMarqueeGestures(props.deps as unknown as MarqueeGesturesDeps));
60
+ return null;
61
+ }
62
+
63
+ function mountMarquee<T>(extra: Partial<MarqueeGesturesDeps<T>> = {}) {
64
+ document.body.innerHTML = `<div data-composition-id="main" data-width="1920" data-height="1080">
65
+ <div id="a"></div><div id="b"></div></div>`;
66
+ const overlay = document.createElement("div");
67
+ overlay.setPointerCapture = vi.fn();
68
+ overlay.releasePointerCapture = vi.fn();
69
+ document.body.append(overlay);
70
+ const onSelect = vi.fn();
71
+ const deps: MarqueeGesturesDeps<T> = {
72
+ iframeRef: { current: { contentDocument: document } as unknown as HTMLIFrameElement },
73
+ overlayRef: { current: overlay },
74
+ activeCompositionPathRef: { current: "index.html" },
75
+ onMarqueeSelectRef: { current: onSelect },
76
+ ...extra,
77
+ };
78
+ let state: MarqueeGestures | undefined;
79
+ const root = mountReactHarness(<MarqueeHost deps={deps} onState={(next) => (state = next)} />);
80
+ const at = (x: number, y: number, shiftKey = false) =>
81
+ ({
82
+ clientX: x,
83
+ clientY: y,
84
+ pointerId: 7,
85
+ shiftKey,
86
+ currentTarget: overlay,
87
+ }) as unknown as PointerEvent<HTMLDivElement>;
88
+ const marquee = () => state as MarqueeGestures;
89
+ return { overlay, onSelect, root, at, marquee };
90
+ }
91
+
92
+ describe("marquee package export, driven by a host", () => {
93
+ const byId = { resolveHits: (elements: HTMLElement[]) => elements.map((el) => el.id) };
94
+
95
+ it("hands the elements a drag touched to the host's resolveHits and selects what it returns", async () => {
96
+ const { overlay, onSelect, at, marquee } = mountMarquee<string>(byId);
97
+ act(() => marquee().begin(at(1, 1)));
98
+ expect(overlay.setPointerCapture).toHaveBeenCalledWith(7);
99
+ act(() => marquee().onPointerMove(at(20, 20)));
100
+ expect(marquee().marqueeRect).toEqual({ left: 1, top: 1, width: 19, height: 19 });
101
+ expect(marquee().candidateRects).toEqual([rects.get("a")]);
102
+ await act(async () => marquee().onPointerUp(at(20, 20, true)));
103
+ expect(onSelect).toHaveBeenCalledWith(["a"], true);
104
+ expect(overlay.releasePointerCapture).toHaveBeenCalledWith(7);
105
+ expect(marquee().marqueeRect).toBeNull();
106
+ });
107
+
108
+ it("measures the candidates once per drag", async () => {
109
+ const { at, marquee } = mountMarquee<string>(byId);
110
+ act(() => marquee().begin(at(1, 1)));
111
+ act(() => marquee().onPointerMove(at(20, 20)));
112
+ act(() => marquee().onPointerMove(at(120, 120)));
113
+ await act(async () => marquee().onPointerUp(at(120, 120)));
114
+ expect(collectDomEditLayerItems).toHaveBeenCalledTimes(1);
115
+ });
116
+
117
+ it("selects nothing on a release under the threshold, as a click", async () => {
118
+ const resolveHits = vi.fn(byId.resolveHits);
119
+ const { onSelect, at, marquee } = mountMarquee<string>({ resolveHits });
120
+ act(() => marquee().begin(at(1, 1)));
121
+ await act(async () => marquee().onPointerUp(at(2, 2)));
122
+ expect(onSelect).toHaveBeenCalledWith([], false);
123
+ expect(resolveHits).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it("cancel() drops an active marquee and the release that follows selects nothing", async () => {
127
+ const { overlay, onSelect, at, marquee } = mountMarquee<string>(byId);
128
+ act(() => marquee().begin(at(1, 1)));
129
+ act(() => marquee().onPointerMove(at(20, 20)));
130
+ act(() => marquee().cancel());
131
+ expect(marquee().marqueeRect).toBeNull();
132
+ expect(marquee().candidateRects).toEqual([]);
133
+ expect(overlay.releasePointerCapture).toHaveBeenCalledWith(7);
134
+ await act(async () => marquee().onPointerUp(at(20, 20)));
135
+ expect(onSelect).not.toHaveBeenCalled();
136
+ });
137
+
138
+ it("a pointercancel mid-drag drops the marquee without selecting", async () => {
139
+ const { onSelect, at, marquee } = mountMarquee<string>(byId);
140
+ act(() => marquee().begin(at(1, 1)));
141
+ act(() => marquee().onPointerMove(at(20, 20)));
142
+ act(() => marquee().onPointerCancel());
143
+ expect(marquee().marqueeRect).toBeNull();
144
+ await act(async () => marquee().onPointerUp(at(20, 20)));
145
+ expect(onSelect).not.toHaveBeenCalled();
146
+ });
147
+
148
+ it("will not hand a host Studio's selections under the host's own pick type", () => {
149
+ const hostDeps: MarqueeGesturesDeps<string> = {
150
+ iframeRef: { current: null },
151
+ overlayRef: { current: null },
152
+ activeCompositionPathRef: { current: null },
153
+ onMarqueeSelectRef: { current: undefined },
154
+ };
155
+ // @ts-expect-error a host pick type needs its own resolveHits
156
+ const typed = () => useMarqueeGestures<string>(hostDeps);
157
+ expect(typed).toBeTypeOf("function");
158
+ });
159
+
160
+ it("resolves Studio edit selections when the host passes no resolveHits", async () => {
161
+ const { onSelect, at, marquee } = mountMarquee();
162
+ act(() => marquee().begin(at(1, 1)));
163
+ act(() => marquee().onPointerMove(at(20, 20)));
164
+ await act(async () => marquee().onPointerUp(at(20, 20)));
165
+ expect(resolveDomEditSelection).toHaveBeenCalledWith(
166
+ document.getElementById("a"),
167
+ expect.objectContaining({ skipSourceProbe: true }),
168
+ );
169
+ expect(onSelect).toHaveBeenCalledWith([{ id: "a" }], false);
170
+ });
171
+
172
+ it("passes pointer events that are not a marquee to Studio's gestures, and needs none", () => {
173
+ const gestures = { onPointerMove: vi.fn(), onPointerUp: vi.fn(), clearPointerState: vi.fn() };
174
+ const selectionRef = { current: null };
175
+ const withGestures = mountMarquee<string>({ ...byId, gestures, selectionRef });
176
+ act(() => withGestures.marquee().onPointerMove(withGestures.at(5, 5)));
177
+ act(() => withGestures.marquee().onPointerUp(withGestures.at(5, 5)));
178
+ act(() => withGestures.marquee().onPointerCancel());
179
+ expect(gestures.onPointerMove).toHaveBeenCalledTimes(1);
180
+ expect(gestures.onPointerUp).toHaveBeenCalledTimes(1);
181
+ expect(gestures.clearPointerState).toHaveBeenCalledWith(selectionRef);
182
+
183
+ const bare = mountMarquee<string>(byId);
184
+ expect(() => {
185
+ bare.marquee().onPointerMove(bare.at(5, 5));
186
+ bare.marquee().onPointerUp(bare.at(5, 5));
187
+ bare.marquee().onPointerCancel();
188
+ }).not.toThrow();
189
+ });
190
+ });
191
+
192
+ describe("snapping package export, driven by a host", () => {
193
+ it("snaps a host's drag to another element and draws the guide", async () => {
194
+ document.body.innerHTML = `<div data-composition-id="main" data-width="1920" data-height="1080">
195
+ <div id="a"></div><div id="b"></div></div>`;
196
+ const overlay = document.createElement("div");
197
+ const iframe = {
198
+ contentDocument: document,
199
+ getBoundingClientRect: () => ({ left: 0, top: 0, width: 1920, height: 1080 }),
200
+ } as unknown as HTMLIFrameElement;
201
+ const moving = document.getElementById("a") as HTMLElement;
202
+ const context = collectSnapContext({
203
+ overlayEl: overlay,
204
+ iframe,
205
+ excludeElements: new Set([moving]),
206
+ });
207
+ const snap = resolveSnapAdjustment({
208
+ movingRect: rects.get("a")!,
209
+ proposedDx: 98,
210
+ proposedDy: 0,
211
+ targets: context.targets,
212
+ threshold: SNAP_THRESHOLD_PX,
213
+ disabled: !context.snapEnabled,
214
+ });
215
+ expect(snap.dx).toBe(100);
216
+ expect(snap.guides.length).toBeGreaterThan(0);
217
+
218
+ // The guides draw once the host's preview has booted, as NLEPreview's player marks it.
219
+ usePlayerStore.setState({ previewBooted: true });
220
+ const snapGuidesRef = { current: { guides: snap.guides, spacingGuides: snap.spacingGuides } };
221
+ const root = mountReactHarness(
222
+ <SnapGuideOverlay
223
+ snapGuidesRef={snapGuidesRef}
224
+ compositionLeft={0}
225
+ compositionTop={0}
226
+ compositionWidth={1920}
227
+ compositionHeight={1080}
228
+ />,
229
+ );
230
+ await vi.waitFor(() => {
231
+ const guide = document.querySelector<HTMLElement>('[aria-hidden="true"] > div');
232
+ expect(guide?.style.display).toBe("");
233
+ });
234
+ await act(async () => root.unmount());
235
+ });
236
+ });
@@ -0,0 +1,195 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { DomEditSelection, DomEditTextField } from "../components/editor/domEditing";
6
+ import type { TimelineElement } from "../player/store/playerStore";
7
+ import { buildTimelineMoveTimingPatch, persistTimelineEdit } from "./timelineEditingHelpers";
8
+ import { useDomEditCommits } from "./useDomEditCommits";
9
+
10
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
11
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
12
+
13
+ const SOURCE =
14
+ '<!doctype html><html><head></head><body><div data-hf-id="hf-card" style="color: red">Card</div>' +
15
+ '<div id="clip" class="clip" data-start="1" data-duration="5" data-track-index="0"></div></body></html>';
16
+
17
+ // The server patches whatever is on disk; the writer refuses a stale base like If-Match does.
18
+ // The first write can be held mid-flight.
19
+ function fakeProject() {
20
+ let disk = SOURCE;
21
+ const refused: string[] = [];
22
+ let writes = 0;
23
+ let release = () => {};
24
+ const firstWrite = new Promise<void>((resolve) => (release = resolve));
25
+ vi.stubGlobal(
26
+ "fetch",
27
+ vi.fn(async (input: string, init?: RequestInit) => {
28
+ if (input.includes("/file-mutations/patch-element/")) {
29
+ const { operations } = JSON.parse(String(init?.body)) as {
30
+ operations: Array<{ property?: string; value?: string }>;
31
+ };
32
+ for (const op of operations) {
33
+ if (op.property === "color") disk = disk.replace(/color: \w+/, `color: ${op.value}`);
34
+ }
35
+ return Response.json({ ok: true, changed: true, matched: true, content: disk });
36
+ }
37
+ return Response.json({ content: disk });
38
+ }),
39
+ );
40
+ const writeProjectFile = async (path: string, content: string, expected?: string) => {
41
+ if (++writes === 1) await firstWrite;
42
+ if (expected !== undefined && expected !== disk && content !== disk) {
43
+ refused.push(path);
44
+ throw new Error(`409 conflict on ${path}`);
45
+ }
46
+ disk = content;
47
+ };
48
+ return { writeProjectFile, refused, release: () => release(), read: () => disk };
49
+ }
50
+
51
+ type Project = ReturnType<typeof fakeProject>;
52
+
53
+ function mountCanvasCommits(project: Project) {
54
+ const iframe = document.createElement("iframe");
55
+ document.body.append(iframe);
56
+ iframe.contentDocument!.body.innerHTML =
57
+ '<div data-hf-id="hf-card" style="color: red">Card</div>';
58
+ const element = iframe.contentDocument!.querySelector<HTMLElement>('[data-hf-id="hf-card"]')!;
59
+ const selection = {
60
+ element,
61
+ label: "Card",
62
+ tagName: "div",
63
+ sourceFile: "index.html",
64
+ compositionPath: "index.html",
65
+ isCompositionHost: false,
66
+ isInsideLockedComposition: false,
67
+ boundingBox: { x: 0, y: 0, width: 120, height: 40 },
68
+ textContent: "Card",
69
+ dataAttributes: {},
70
+ inlineStyles: { color: "red" },
71
+ computedStyles: {},
72
+ textFields: [],
73
+ capabilities: { canSelect: true, canEditStyles: true, canMove: true, canResize: true },
74
+ hfId: "hf-card",
75
+ selector: '[data-hf-id="hf-card"]',
76
+ selectorIndex: 0,
77
+ } as unknown as DomEditSelection;
78
+ const captured: { current: ReturnType<typeof useDomEditCommits> | null } = { current: null };
79
+ const toasts: string[] = [];
80
+ function Probe() {
81
+ captured.current = useDomEditCommits({
82
+ activeCompPath: "index.html",
83
+ previewIframeRef: { current: iframe },
84
+ showToast: (message) => toasts.push(message),
85
+ queueDomEditSave: (save) => save(),
86
+ writeProjectFile: project.writeProjectFile,
87
+ editHistory: { recordEdit: async () => {} },
88
+ fileTree: [],
89
+ importedFontAssetsRef: { current: [] },
90
+ projectId: "p1",
91
+ projectIdRef: { current: "p1" },
92
+ reloadPreview: () => {},
93
+ domEditSelection: selection,
94
+ applyDomSelection: () => {},
95
+ clearDomSelection: () => {},
96
+ refreshDomEditSelectionFromPreview: () => {},
97
+ buildDomSelectionFromTarget: async () => null,
98
+ readOnlyPreview: false,
99
+ });
100
+ return null;
101
+ }
102
+ const root = createRoot(document.createElement("div"));
103
+ act(() => root.render(createElement(Probe)));
104
+ return { hook: captured.current!, selection, toasts };
105
+ }
106
+
107
+ const selfText: DomEditTextField = {
108
+ key: "self",
109
+ label: "self",
110
+ value: "Card",
111
+ tagName: "div",
112
+ attributes: [],
113
+ inlineStyles: {},
114
+ computedStyles: {},
115
+ source: "self",
116
+ };
117
+
118
+ const moveClip = (project: Project) =>
119
+ persistTimelineEdit({
120
+ projectId: "p1",
121
+ activeCompPath: "index.html",
122
+ element: {
123
+ id: "clip",
124
+ key: "clip",
125
+ domId: "clip",
126
+ tag: "div",
127
+ start: 1,
128
+ duration: 5,
129
+ track: 0,
130
+ } as TimelineElement,
131
+ label: "Move clip",
132
+ buildPatches: (original, target) => buildTimelineMoveTimingPatch(original, target, 3, 5),
133
+ writeProjectFile: project.writeProjectFile,
134
+ recordEdit: async () => {},
135
+ pendingTimelineEditPathRef: { current: new Set<string>() },
136
+ });
137
+
138
+ // Starts the second save while the first one's write is in flight; both must land unrefused.
139
+ async function expectBothLand(
140
+ project: Project,
141
+ first: () => Promise<unknown>,
142
+ second: () => Promise<unknown>,
143
+ ) {
144
+ await act(async () => {
145
+ const saves = [first()];
146
+ await new Promise((resolve) => setTimeout(resolve, 0));
147
+ saves.push(second());
148
+ await new Promise((resolve) => setTimeout(resolve, 0));
149
+ project.release();
150
+ await Promise.allSettled(saves);
151
+ });
152
+ expect(project.refused).toEqual([]);
153
+ }
154
+
155
+ afterEach(() => {
156
+ vi.unstubAllGlobals();
157
+ document.body.innerHTML = "";
158
+ });
159
+
160
+ describe("canvas edit commits share the file queue", () => {
161
+ it("lands a second canvas commit started while the first one's font write is held", async () => {
162
+ const project = fakeProject();
163
+ const { hook, selection, toasts } = mountCanvasCommits(project);
164
+ const font = {
165
+ family: "Imported",
166
+ path: "fonts/Imported.woff2",
167
+ url: "/api/projects/p1/preview/fonts/Imported.woff2",
168
+ };
169
+
170
+ await expectBothLand(
171
+ project,
172
+ () => hook.commitDomTextFields(selection, [selfText], { importedFont: font }),
173
+ () => hook.handleDomStyleCommit("color", "green"),
174
+ );
175
+
176
+ expect(toasts).toEqual([]);
177
+ expect(project.read()).toContain("@font-face");
178
+ expect(project.read()).toContain("color: green");
179
+ });
180
+
181
+ it("lands a canvas commit started while a timeline save is writing", async () => {
182
+ const project = fakeProject();
183
+ const { hook, toasts } = mountCanvasCommits(project);
184
+
185
+ await expectBothLand(
186
+ project,
187
+ () => moveClip(project),
188
+ () => hook.handleDomStyleCommit("color", "green"),
189
+ );
190
+
191
+ expect(toasts).toEqual([]);
192
+ expect(project.read()).toContain('id="clip" class="clip" data-start="3"');
193
+ expect(project.read()).toContain("color: green");
194
+ });
195
+ });
@@ -0,0 +1,116 @@
1
+ import { studioWriteHeaders } from "../utils/studioFileVersion";
2
+
3
+ export class GsapPreviewConvergenceError extends Error {}
4
+ export class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {}
5
+
6
+ /** Verify rollback ownership support before any GSAP mutation can land. */
7
+ export async function requireGsapOwnershipProtocol(projectId: string): Promise<void> {
8
+ const response = await fetch(
9
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-capabilities`,
10
+ );
11
+ if (!response.ok) {
12
+ throw new GsapOwnershipProtocolError("Server does not support owned GSAP mutations");
13
+ }
14
+ const body = await response.json().catch(() => null);
15
+ if (!isRecord(body) || body.atomicOwnershipPairs !== true) {
16
+ throw new GsapOwnershipProtocolError("Invalid GSAP mutation capability response");
17
+ }
18
+ }
19
+
20
+ /** Atomically restore one GSAP mutation only while its exact output still owns
21
+ * the file. The server performs compare + write synchronously, eliminating the
22
+ * client GET→PUT window that could overwrite a successor edit. */
23
+ export async function rollbackOwnedMutation(
24
+ projectId: string,
25
+ targetPath: string,
26
+ expected: string,
27
+ restore: string,
28
+ ): Promise<"restored" | "conflict"> {
29
+ if (targetPath.includes("\0") || targetPath.includes("..")) {
30
+ throw new Error(`Unsafe path: ${targetPath}`);
31
+ }
32
+ const response = await fetch(
33
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`,
34
+ {
35
+ method: "POST",
36
+ // Deliberately unclaimed: a rollback runs because a mutation did not
37
+ // converge, so let the restored file reload the preview.
38
+ headers: { "Content-Type": "application/json" },
39
+ body: JSON.stringify({ expected, restore }),
40
+ },
41
+ );
42
+ if (!response.ok) {
43
+ throw new Error(`Failed to restore ${targetPath}`);
44
+ }
45
+ const result = (await response.json()) as { restored?: unknown; conflict?: unknown };
46
+ if (result.restored === true && result.conflict === false) return "restored";
47
+ if (result.restored === false && result.conflict === true) return "conflict";
48
+ throw new Error(`Invalid restore response for ${targetPath}`);
49
+ }
50
+
51
+ /** The server's GSAP-mutation response. `scriptText` is the rewritten root script for a soft
52
+ * reload, null when none came back: the caller then full-reloads when `mutated`, or rebinds
53
+ * the runtime timing in place when nothing was rewritten (see syncTimingEditPreview). */
54
+ export type GsapMutationStatus = {
55
+ mutated: boolean;
56
+ scriptText: string | null;
57
+ /** Atomic whole-file ownership pair returned by the mutation endpoint. */
58
+ before?: string;
59
+ after?: string;
60
+ };
61
+
62
+ function isRecord(value: unknown): value is Record<string, unknown> {
63
+ return typeof value === "object" && value !== null;
64
+ }
65
+
66
+ function readMutationStatus(value: unknown): GsapMutationStatus {
67
+ if (
68
+ !isRecord(value) ||
69
+ typeof value.mutated !== "boolean" ||
70
+ typeof value.before !== "string" ||
71
+ typeof value.after !== "string" ||
72
+ value.mutated !== (value.before !== value.after) ||
73
+ ("changed" in value && value.changed !== value.mutated)
74
+ ) {
75
+ throw new GsapOwnershipProtocolError("Invalid owned GSAP mutation response");
76
+ }
77
+ return {
78
+ mutated: value.mutated,
79
+ scriptText: typeof value.scriptText === "string" ? value.scriptText : null,
80
+ before: value.before,
81
+ after: value.after,
82
+ };
83
+ }
84
+
85
+ function readMutationError(value: unknown, fallback: string): string {
86
+ if (isRecord(value) && typeof value.error === "string") return value.error;
87
+ return fallback;
88
+ }
89
+
90
+ export async function postGsapMutation(
91
+ projectId: string,
92
+ filePath: string,
93
+ mutation: Record<string, unknown>,
94
+ fallback: string,
95
+ ): Promise<GsapMutationStatus> {
96
+ let response: Response;
97
+ try {
98
+ response = await fetch(
99
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
100
+ {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
103
+ body: JSON.stringify(mutation),
104
+ },
105
+ );
106
+ } catch (error) {
107
+ throw new GsapPreviewConvergenceError(`${fallback}: mutation outcome unknown`, {
108
+ cause: error,
109
+ });
110
+ }
111
+ const body: unknown = await response.json().catch(() => null);
112
+ if (!response.ok) {
113
+ throw new GsapPreviewConvergenceError(readMutationError(body, fallback));
114
+ }
115
+ return readMutationStatus(body);
116
+ }
@@ -0,0 +1,14 @@
1
+ // Test-only ResizeObserver stand-in: a test delivers a size by hand with `reportResize`.
2
+ let resizeCallback: ResizeObserverCallback | null = null;
3
+
4
+ export function MockResizeObserver(callback: ResizeObserverCallback) {
5
+ resizeCallback = callback;
6
+ return { observe() {}, disconnect() {}, unobserve() {} };
7
+ }
8
+
9
+ export function reportResize(width: number, height: number): void {
10
+ resizeCallback!(
11
+ [{ contentRect: { width, height } } as ResizeObserverEntry],
12
+ {} as ResizeObserver,
13
+ );
14
+ }
@@ -14,9 +14,9 @@ import { runtimeAudioId } from "../player/lib/timelineElementHelpers";
14
14
  import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
15
15
  import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
16
16
  import {
17
- applyPatchByTarget,
18
- buildPatchTarget,
19
17
  findTimelineElementInIframe,
18
+ operationChanges,
19
+ patchTimelineChangesInSource,
20
20
  readFileContent,
21
21
  type RecordEditInput,
22
22
  } from "./timelineEditingHelpers";
@@ -197,52 +197,30 @@ export async function createAudioGroupAndAssignMembers({
197
197
  property: HF_AUDIO_GROUP_ATTR,
198
198
  value: groupId,
199
199
  };
200
- const originalByPath = new Map<string, string>();
201
- const files: Record<string, string> = {};
200
+ const groupPath = activeCompPath || "index.html";
201
+ const byPath = groupElementsByTargetPath(elements, activeCompPath);
202
+ const files: Record<string, (current: string) => string> = {};
203
+ for (const targetPath of new Set([...byPath.keys(), groupPath])) {
204
+ files[targetPath] = (current) => {
205
+ let patched = patchTimelineChangesInSource(
206
+ current,
207
+ targetPath,
208
+ operationChanges(byPath.get(targetPath) ?? [], groupOperation),
209
+ );
210
+ if (targetPath === groupPath) patched = insertGroupElement(patched, groupId, groupLabel);
211
+ if (patched !== current) pendingTimelineEditPathRef.current.add(targetPath);
212
+ return patched;
213
+ };
214
+ }
202
215
 
203
216
  try {
204
- for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
205
- let patchedContent = await readFileContent(projectId, targetPath);
206
- originalByPath.set(targetPath, patchedContent);
207
-
208
- for (const element of fileElements) {
209
- const patchTarget = buildPatchTarget(element);
210
- if (!patchTarget) {
211
- throw new Error(`Timeline element ${element.id} is missing a patchable target`);
212
- }
213
- if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
214
- throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
215
- }
216
- patchedContent = applyPatchByTarget(patchedContent, patchTarget, groupOperation);
217
- }
218
-
219
- files[targetPath] = patchedContent;
220
- pendingTimelineEditPathRef.current.add(targetPath);
221
- }
222
-
223
- const groupPath = activeCompPath || "index.html";
224
- let groupContent = files[groupPath];
225
- if (groupContent === undefined) {
226
- groupContent = await readFileContent(projectId, groupPath);
227
- originalByPath.set(groupPath, groupContent);
228
- }
229
- const withGroupElement = insertGroupElement(groupContent, groupId, groupLabel);
230
- if (withGroupElement !== groupContent) {
231
- files[groupPath] = withGroupElement;
232
- pendingTimelineEditPathRef.current.add(groupPath);
233
- }
234
-
235
217
  const changedPaths = await saveProjectFilesWithHistory({
236
218
  projectId,
237
219
  label: groupLabel
238
220
  ? `Group ${elements.length} clips as ${groupLabel}`
239
221
  : `Group ${elements.length} voice clips`,
240
222
  files,
241
- readFile: async (path) => {
242
- const original = originalByPath.get(path);
243
- if (original !== undefined) return original;
244
- return readFileContent(projectId, path);
245
- },
223
+ readFile: (path) => readFileContent(projectId, path),
246
224
  writeFile: writeProjectFile,
247
225
  recordEdit,
248
226
  });
@@ -12,6 +12,7 @@ import {
12
12
  type PersistTimelineBatchChange,
13
13
  } from "./timelineEditingHelpers";
14
14
  import type { TimelineElement } from "../player/store/playerStore";
15
+ import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
15
16
  import { usePlayerStore } from "../player/store/playerStore";
16
17
  import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
17
18
  import { timelineKeyframeSelectionKey } from "../player/components/timelineKeyframeIdentity";
@@ -244,6 +245,26 @@ describe("buildTimelineResizeTimingPatch", () => {
244
245
  });
245
246
  });
246
247
 
248
+ describe("buildTimelineResizeTimingPatch — source in-point read as playback reads it", () => {
249
+ it.each([
250
+ ['data-playback-start="-1" data-media-start="2"', 4.5, 1.5],
251
+ ['data-playback-start="2"', 4.5, 1.5],
252
+ ['data-media-start="junk"', 5.5, 0.5],
253
+ ['data-media-start="1.5s"', 5.5, 0.5],
254
+ ])("%s, head to %s", (inPoint, start, expected) => {
255
+ const source = `<div id="root"><video id="a" class="clip" data-start="5" data-duration="3" ${inPoint}></video></div>`;
256
+ const element = el({ id: "a", tag: "video", domId: "a", start: 5, duration: 3 });
257
+ const host = document.createElement("div");
258
+ host.innerHTML = buildTimelineResizeTimingPatch(source, { id: "a" }, element, {
259
+ start,
260
+ duration: 8 - start,
261
+ playbackStart: undefined,
262
+ });
263
+ const video = host.querySelector("#a")!;
264
+ expect(readMediaOffsetSeconds((name) => video.getAttribute(name))).toBe(expected);
265
+ });
266
+ });
267
+
247
268
  describe("persistTimelineBatchEdit", () => {
248
269
  const SOURCE = `<div id="root"><video id="a" class="clip" data-start="1" data-track-index="0"></video><video id="b" class="clip" data-start="2" data-track-index="1"></video></div>`;
249
270