@hyperframes/studio 0.8.55 → 0.8.57

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 (145) hide show
  1. package/dist/assets/hyperframes-player-c23wnIfc.js +464 -0
  2. package/dist/assets/{index-DrHQAuGO.css → index-DKJpHYYd.css} +1 -1
  3. package/dist/assets/{index-BEIRytvV.js → index-DgkAzx3n.js} +1 -1
  4. package/dist/assets/{index-DaNpNFJC.js → index-IDNr3X1H.js} +1 -1
  5. package/dist/assets/{index-TcBZyzNA.js → index-gbOCDCuZ.js} +202 -202
  6. package/dist/index.d.ts +111 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +21534 -20683
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +18 -1
  13. package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
  14. package/src/components/EditorShell.selectionSync.test.tsx +57 -67
  15. package/src/components/EditorShell.tsx +62 -51
  16. package/src/components/EditorShellTestMocks.ts +46 -0
  17. package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
  18. package/src/components/ProjectUnreachableBanner.tsx +89 -0
  19. package/src/components/editor/DomEditOverlay.tsx +19 -55
  20. package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
  21. package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
  22. package/src/components/editor/GridOverlay.tsx +12 -23
  23. package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
  24. package/src/components/editor/MotionPathOverlay.tsx +3 -1
  25. package/src/components/editor/PreviewGuides.test.tsx +6 -1
  26. package/src/components/editor/PreviewGuides.tsx +8 -10
  27. package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
  28. package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
  29. package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
  30. package/src/components/editor/PropertyPanel.tsx +4 -3
  31. package/src/components/editor/SnapToolbar.test.tsx +26 -12
  32. package/src/components/editor/SnapToolbar.tsx +30 -59
  33. package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
  34. package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
  35. package/src/components/editor/previewReadOnlyContext.tsx +59 -0
  36. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
  37. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
  38. package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
  39. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
  40. package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
  41. package/src/components/editor/useDomEditNudge.ts +9 -0
  42. package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
  43. package/src/components/editor/useOffCanvasIndicators.ts +50 -0
  44. package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
  45. package/src/components/editor/usePreviewCompositionRect.ts +12 -12
  46. package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
  47. package/src/components/editor/useVolumeAutomation.ts +57 -16
  48. package/src/components/nle/NLEContext.tsx +40 -4
  49. package/src/components/nle/NLEPreview.test.ts +66 -1
  50. package/src/components/nle/NLEPreview.tsx +88 -44
  51. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
  52. package/src/components/nle/PreviewOverlays.tsx +19 -24
  53. package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
  54. package/src/components/nle/PreviewPane.tsx +17 -2
  55. package/src/components/nle/TimelinePane.tsx +5 -1
  56. package/src/components/nle/previewReadOnly.test.tsx +129 -0
  57. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  58. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
  59. package/src/components/nle/usePreviewBlockDrop.ts +3 -1
  60. package/src/hooks/appHotkeysDispatch.ts +16 -1
  61. package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
  62. package/src/hooks/timelineAudioGroupCreate.ts +3 -4
  63. package/src/hooks/timelineTrackVisibility.ts +7 -8
  64. package/src/hooks/useAppHotkeys.test.ts +71 -3
  65. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  66. package/src/hooks/useAppHotkeys.ts +4 -0
  67. package/src/hooks/useCompositionDimensions.test.tsx +38 -0
  68. package/src/hooks/useCompositionDimensions.ts +17 -1
  69. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  70. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  71. package/src/hooks/useDomEditCommits.test.tsx +1 -0
  72. package/src/hooks/useDomEditCommits.ts +3 -0
  73. package/src/hooks/useDomEditSession.test.tsx +1 -0
  74. package/src/hooks/useDomEditSession.ts +7 -4
  75. package/src/hooks/useDomEditWiring.ts +40 -40
  76. package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
  77. package/src/hooks/useDomGeometryCommits.ts +26 -20
  78. package/src/hooks/useFileTree.test.tsx +56 -0
  79. package/src/hooks/useFileTree.ts +8 -1
  80. package/src/hooks/useGestureCommit.test.tsx +29 -0
  81. package/src/hooks/useGestureCommit.ts +23 -0
  82. package/src/hooks/useInlineTextEdit.test.tsx +23 -2
  83. package/src/hooks/useInlineTextEdit.ts +11 -2
  84. package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
  85. package/src/hooks/useSdkSession.ts +46 -10
  86. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  87. package/src/hooks/useServerConnection.ts +81 -8
  88. package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
  89. package/src/hooks/useTimelineAssetDropOps.ts +1 -6
  90. package/src/hooks/useTimelineEditing.test.tsx +10 -7
  91. package/src/hooks/useTimelineEditing.ts +2 -7
  92. package/src/index.ts +12 -0
  93. package/src/player/components/Player.test.ts +141 -13
  94. package/src/player/components/Player.tsx +58 -2
  95. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  96. package/src/player/components/Timeline.tsx +23 -573
  97. package/src/player/components/TimelineCanvas.tsx +9 -30
  98. package/src/player/components/TimelineClip.test.tsx +52 -0
  99. package/src/player/components/TimelineClip.tsx +13 -2
  100. package/src/player/components/TimelineOverlays.test.ts +67 -32
  101. package/src/player/components/TimelineOverlays.tsx +46 -107
  102. package/src/player/components/TimelineProvider.tsx +191 -0
  103. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  104. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  105. package/src/player/components/testRootHarness.ts +18 -0
  106. package/src/player/components/timelineMotionStyles.test.ts +3 -3
  107. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  108. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  109. package/src/player/components/timelineTheme.test.ts +4 -4
  110. package/src/player/components/timelineTheme.ts +34 -30
  111. package/src/player/components/timelineTrackDisplay.ts +1 -4
  112. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  113. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  114. package/src/player/components/useTimelineProviderState.tsx +583 -0
  115. package/src/player/components/useTimelineTrackDerivations.ts +2 -2
  116. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  117. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  118. package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
  119. package/src/player/hooks/timelineSyncHydration.ts +0 -23
  120. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  121. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  122. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  123. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  124. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -91
  126. package/src/player/store/playerStore.ts +0 -4
  127. package/src/player/store/timelineElement.ts +2 -4
  128. package/src/player/store/timelineResetState.ts +0 -1
  129. package/src/styles/hex-baseline.json +3 -4
  130. package/src/styles/studio.css +7 -7
  131. package/src/styles/theme.css +29 -0
  132. package/src/telemetry/events.test.ts +18 -0
  133. package/src/telemetry/events.ts +10 -0
  134. package/src/telemetry/system.test.ts +49 -0
  135. package/src/telemetry/system.ts +6 -0
  136. package/src/telemetry/tabId.test.ts +43 -0
  137. package/src/telemetry/tabId.ts +44 -0
  138. package/src/utils/previewCompositionSize.ts +26 -0
  139. package/src/utils/reloadDebug.ts +2 -4
  140. package/src/utils/sourcePatcher.ts +1 -1
  141. package/src/utils/studioTelemetry.test.ts +14 -0
  142. package/src/utils/studioTelemetry.ts +4 -0
  143. package/dist/assets/hyperframes-player-BjAGc2ID.js +0 -464
  144. package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
  145. package/src/player/hooks/useTimelineRowElements.ts +0 -17
@@ -0,0 +1,260 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { makeSelection } from "../../hooks/domSelectionTestHarness";
7
+ import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS } from "./domEditNudge";
8
+ import { __resetForTests } from "../../utils/canvasNudgeGate";
9
+ import { PreviewReadOnlyProvider } from "./previewReadOnlyContext";
10
+ import "./domEditOverlayTestMocks";
11
+ import { DomEditOverlay } from "./DomEditOverlay";
12
+
13
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
+
15
+ const RECT = { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 };
16
+ const layout = vi.hoisted(() => ({ group: [] as unknown[] }));
17
+
18
+ vi.mock("./useDomEditOverlayRects", () => ({
19
+ useDomEditOverlayRects: () => ({
20
+ overlayRect: { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
21
+ overlayRectRef: {
22
+ current: { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
23
+ },
24
+ setOverlayRect: () => undefined,
25
+ hoverRect: null,
26
+ groupOverlayItems: layout.group,
27
+ groupOverlayItemsRef: { current: layout.group },
28
+ setGroupOverlayItems: () => undefined,
29
+ childRects: [],
30
+ }),
31
+ }));
32
+ const BOX = '[data-dom-edit-selection-box="true"]';
33
+ let root: Root;
34
+ let host: HTMLElement;
35
+
36
+ function textElement(id: string): HTMLElement {
37
+ const element = document.createElement("h1");
38
+ element.id = id;
39
+ element.textContent = "Title";
40
+ document.body.append(element);
41
+ return element;
42
+ }
43
+
44
+ function fixture(
45
+ overrides: Partial<React.ComponentProps<typeof DomEditOverlay>> = {},
46
+ readOnly = false,
47
+ ) {
48
+ const spies = {
49
+ onCanvasMouseDown: vi.fn(),
50
+ onSelectionChange: vi.fn(),
51
+ onManualDragStart: vi.fn(),
52
+ onBlockedMove: vi.fn(),
53
+ onPathOffsetCommit: vi.fn(),
54
+ onGroupPathOffsetCommit: vi.fn(),
55
+ onBoxSizeCommit: vi.fn(),
56
+ onRotationCommit: vi.fn(),
57
+ onStyleCommit: vi.fn(),
58
+ onDeleteSelection: vi.fn(),
59
+ onApplyZIndex: vi.fn(),
60
+ };
61
+ const selection = makeSelection("Title", textElement("title"));
62
+ selection.capabilities.canApplyManualRotation = true;
63
+ selection.textFields = [{ key: "text", label: "Text", value: "Title" }] as never;
64
+ const props = {
65
+ iframeRef: { current: document.createElement("iframe") },
66
+ activeCompositionPath: null,
67
+ selection,
68
+ hoverSelection: null,
69
+ onCanvasPointerMove: () => Promise.resolve(selection),
70
+ onCanvasPointerLeave: () => undefined,
71
+ ...spies,
72
+ ...overrides,
73
+ };
74
+ host = document.createElement("div");
75
+ document.body.append(host);
76
+ root = createRoot(host);
77
+ act(() =>
78
+ root.render(
79
+ <PreviewReadOnlyProvider readOnly={readOnly}>
80
+ <DomEditOverlay {...props} />
81
+ </PreviewReadOnlyProvider>,
82
+ ),
83
+ );
84
+ return { spies, selection, overlay: host.firstElementChild as HTMLElement };
85
+ }
86
+
87
+ const fire = (target: Element, type: string, init: MouseEventInit = {}) => {
88
+ const event = new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, ...init });
89
+ act(() => {
90
+ target.dispatchEvent(event);
91
+ });
92
+ return event;
93
+ };
94
+
95
+ beforeEach(() => {
96
+ vi.useFakeTimers();
97
+ __resetForTests();
98
+ HTMLElement.prototype.setPointerCapture = () => undefined;
99
+ });
100
+
101
+ afterEach(() => {
102
+ act(() => root.unmount());
103
+ document.body.innerHTML = "";
104
+ layout.group = [];
105
+ vi.useRealTimers();
106
+ });
107
+
108
+ describe("DomEditOverlay with the preview read-only", () => {
109
+ it("control: with the flag off a press on the box starts a move", () => {
110
+ const { spies, overlay } = fixture();
111
+ fire(overlay.querySelector(BOX)!, "pointerdown");
112
+ expect(spies.onManualDragStart).toHaveBeenCalledTimes(1);
113
+ });
114
+
115
+ it("refuses to start a move from the selection box", () => {
116
+ const { spies, overlay } = fixture({}, true);
117
+ const box = overlay.querySelector(BOX)!;
118
+ fire(box, "pointerdown");
119
+ fire(box, "pointermove", { clientX: 40 });
120
+ fire(box, "pointerup");
121
+ expect(spies.onManualDragStart).not.toHaveBeenCalled();
122
+ expect(spies.onBlockedMove).not.toHaveBeenCalled();
123
+ expect(spies.onPathOffsetCommit).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it("offers no resize dots, and none can start a resize", () => {
127
+ const off = fixture();
128
+ expect(off.overlay.querySelectorAll("div.h-4.w-4")).toHaveLength(4);
129
+ act(() => root.unmount());
130
+ document.body.innerHTML = "";
131
+ const { spies, overlay } = fixture({}, true);
132
+ expect(overlay.querySelectorAll("div.h-4.w-4")).toHaveLength(0);
133
+ expect(spies.onBoxSizeCommit).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it("offers no rotate handle", () => {
137
+ const off = fixture();
138
+ expect(off.overlay.querySelector('[aria-label="Rotate selection"]')).not.toBeNull();
139
+ act(() => root.unmount());
140
+ document.body.innerHTML = "";
141
+ const { spies, overlay } = fixture({}, true);
142
+ expect(overlay.querySelector('[aria-label="Rotate selection"]')).toBeNull();
143
+ expect(spies.onRotationCommit).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it("offers no crop handles, so clip-path is never committed", () => {
147
+ const off = fixture();
148
+ expect(off.overlay.querySelector("[data-dom-edit-crop-frame]")).not.toBeNull();
149
+ act(() => root.unmount());
150
+ document.body.innerHTML = "";
151
+ const { spies, overlay } = fixture({}, true);
152
+ expect(overlay.querySelector("[data-dom-edit-crop-frame]")).toBeNull();
153
+ expect(spies.onStyleCommit).not.toHaveBeenCalled();
154
+ });
155
+
156
+ it("does not drag a multi-selection", () => {
157
+ const members = ["a", "b"].map((id) => {
158
+ const selection = makeSelection(id, textElement(id));
159
+ return { key: id, selection, element: selection.element, rect: RECT };
160
+ });
161
+ layout.group = members;
162
+ const { spies, overlay } = fixture(
163
+ {
164
+ selection: null,
165
+ groupSelections: members.map((m) => m.selection),
166
+ },
167
+ true,
168
+ );
169
+ fire(overlay.querySelector(BOX)!, "pointerdown");
170
+ expect(spies.onManualDragStart).not.toHaveBeenCalled();
171
+ expect(spies.onGroupPathOffsetCommit).not.toHaveBeenCalled();
172
+ });
173
+
174
+ const nudge = () => {
175
+ const event = new KeyboardEvent("keydown", {
176
+ key: "ArrowRight",
177
+ bubbles: true,
178
+ cancelable: true,
179
+ });
180
+ act(() => {
181
+ window.dispatchEvent(event);
182
+ vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
183
+ });
184
+ return event;
185
+ };
186
+
187
+ it("control: with the flag off an arrow key nudges", () => {
188
+ const { spies } = fixture();
189
+ expect(nudge().defaultPrevented).toBe(true);
190
+ expect(spies.onPathOffsetCommit).toHaveBeenCalledTimes(1);
191
+ });
192
+
193
+ it("neither nudges on an arrow key nor swallows it", () => {
194
+ const { spies } = fixture({}, true);
195
+ expect(nudge().defaultPrevented).toBe(false);
196
+ expect(spies.onPathOffsetCommit).not.toHaveBeenCalled();
197
+ });
198
+
199
+ const pressEnter = (overlay: HTMLElement) =>
200
+ act(() => {
201
+ overlay.dispatchEvent(
202
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
203
+ );
204
+ });
205
+
206
+ it("control: with the flag off Enter opens the text for editing", () => {
207
+ const { selection, overlay } = fixture();
208
+ pressEnter(overlay);
209
+ expect(selection.element.hasAttribute("contenteditable")).toBe(true);
210
+ });
211
+
212
+ it("does not open text for editing on Enter", () => {
213
+ const { selection, overlay } = fixture({}, true);
214
+ pressEnter(overlay);
215
+ expect(selection.element.hasAttribute("contenteditable")).toBe(false);
216
+ });
217
+
218
+ const rightClick = async (overlay: HTMLElement) => {
219
+ await act(async () => {
220
+ overlay.dispatchEvent(
221
+ new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 5, clientY: 5 }),
222
+ );
223
+ });
224
+ };
225
+
226
+ it("control: with the flag off right-click offers delete and z-order", async () => {
227
+ const { overlay } = fixture();
228
+ await rightClick(overlay);
229
+ expect(document.body.textContent).toContain("Delete");
230
+ });
231
+
232
+ it("offers no delete or z-order on right-click when read-only", async () => {
233
+ // Same fixture as the control test above (a real, already-selected element) so the
234
+ // context menu actually opens; `selection: null` here would close it via
235
+ // useCanvasContextMenuState's own deselect effect, before read-only ever mattered.
236
+ const { spies, overlay } = fixture({}, true);
237
+ await rightClick(overlay);
238
+ expect(document.body.textContent).not.toContain("Delete");
239
+ expect(spies.onDeleteSelection).not.toHaveBeenCalled();
240
+ expect(spies.onApplyZIndex).not.toHaveBeenCalled();
241
+ });
242
+
243
+ it("still selects and reports to the host on right-click of an unselected element", async () => {
244
+ const { spies, overlay } = fixture({ selection: null }, true);
245
+ await rightClick(overlay);
246
+ expect(spies.onSelectionChange).toHaveBeenCalledTimes(1);
247
+ });
248
+
249
+ it("still selects and reports to the host on a click", () => {
250
+ const { spies, overlay } = fixture({ selection: null }, true);
251
+ fire(overlay, "mousedown");
252
+ expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
253
+ });
254
+
255
+ it("still selects on a click of the selection box", () => {
256
+ const { spies, overlay } = fixture({}, true);
257
+ fire(overlay.querySelector(BOX)!, "click");
258
+ expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
259
+ });
260
+ });
@@ -0,0 +1,59 @@
1
+ import { useContext, useMemo, type ReactNode } from "react";
2
+ import { createStableContext } from "../../utils/hmrStableContext";
3
+
4
+ interface PreviewReadOnlyValue {
5
+ readOnly: boolean;
6
+ reason: string;
7
+ }
8
+
9
+ const DEFAULT_REASON = "Preview is read-only.";
10
+
11
+ // Real default (not a throwing required-provider context): most existing
12
+ // mounts render with no wrapper at all and must stay editable, exactly as today.
13
+ const PreviewReadOnlyContext = createStableContext<PreviewReadOnlyValue>("PreviewReadOnlyContext", {
14
+ readOnly: false,
15
+ reason: DEFAULT_REASON,
16
+ });
17
+
18
+ export function PreviewReadOnlyProvider({
19
+ readOnly,
20
+ reason,
21
+ children,
22
+ }: {
23
+ readOnly: boolean;
24
+ reason?: string;
25
+ children: ReactNode;
26
+ }) {
27
+ const value = useMemo(() => ({ readOnly, reason: reason ?? DEFAULT_REASON }), [readOnly, reason]);
28
+ return <PreviewReadOnlyContext value={value}>{children}</PreviewReadOnlyContext>;
29
+ }
30
+
31
+ export function usePreviewReadOnly(): boolean {
32
+ return useContext(PreviewReadOnlyContext).readOnly;
33
+ }
34
+
35
+ /** Short host-supplied text for why a hand-edit control is disabled. */
36
+ export function usePreviewReadOnlyReason(): string {
37
+ return useContext(PreviewReadOnlyContext).reason;
38
+ }
39
+
40
+ interface ManualEditCapabilities {
41
+ canApplyManualOffset: boolean;
42
+ canApplyManualSize: boolean;
43
+ canApplyManualRotation: boolean;
44
+ }
45
+
46
+ /**
47
+ * The manual X/Y/W/H/rotation fields' disabled state: capability, or preview read-only.
48
+ * Call unconditionally, even with no selection — `capabilities` may be null/undefined.
49
+ */
50
+ export function useManualEditDisabledFlags(
51
+ capabilities: ManualEditCapabilities | null | undefined,
52
+ ) {
53
+ const { readOnly } = useContext(PreviewReadOnlyContext);
54
+ return {
55
+ manualOffsetEditingDisabled: !capabilities?.canApplyManualOffset || readOnly,
56
+ manualSizeEditingDisabled: !capabilities?.canApplyManualSize || readOnly,
57
+ manualRotationEditingDisabled: !capabilities?.canApplyManualRotation || readOnly,
58
+ };
59
+ }
@@ -5,6 +5,7 @@ import { formatPxMetricValue } from "./propertyPanelHelpers";
5
5
  import { resolveValueTier } from "./propertyPanelValueTier";
6
6
  import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
7
7
  import type { DomEditSelection } from "./domEditingTypes";
8
+ import { usePreviewReadOnly, usePreviewReadOnlyReason } from "./previewReadOnlyContext";
8
9
 
9
10
  type KeyframeEntry = Array<{
10
11
  percentage: number;
@@ -119,6 +120,9 @@ export function LayoutGeometryRows({
119
120
  onRemoveKeyframe,
120
121
  onConvertToKeyframes,
121
122
  }: GeometryRowsProps) {
123
+ const readOnlyPreview = usePreviewReadOnly();
124
+ const readOnlyPreviewReason = usePreviewReadOnlyReason();
125
+ const readOnlyTooltip = readOnlyPreview ? readOnlyPreviewReason : undefined;
122
126
  const gutterProps = {
123
127
  element,
124
128
  gsapAnimId,
@@ -137,6 +141,7 @@ export function LayoutGeometryRows({
137
141
  value={formatPxMetricValue(displayX)}
138
142
  tier={displayX === 0 ? "default" : "explicitCustom"}
139
143
  disabled={manualOffsetEditingDisabled}
144
+ tooltip={readOnlyTooltip}
140
145
  onCommit={(next) => commitManualOffset("x", next)}
141
146
  suffix={<KeyframeGutter property="x" displayValue={displayX} {...gutterProps} />}
142
147
  />
@@ -145,6 +150,7 @@ export function LayoutGeometryRows({
145
150
  value={formatPxMetricValue(displayY)}
146
151
  tier={displayY === 0 ? "default" : "explicitCustom"}
147
152
  disabled={manualOffsetEditingDisabled}
153
+ tooltip={readOnlyTooltip}
148
154
  onCommit={(next) => commitManualOffset("y", next)}
149
155
  suffix={<KeyframeGutter property="y" displayValue={displayY} {...gutterProps} />}
150
156
  />
@@ -153,6 +159,7 @@ export function LayoutGeometryRows({
153
159
  value={formatPxMetricValue(displayW)}
154
160
  tier="default"
155
161
  disabled={manualSizeEditingDisabled}
162
+ tooltip={readOnlyTooltip}
156
163
  onCommit={(next) => commitManualSize("width", next)}
157
164
  suffix={<KeyframeGutter property="width" displayValue={displayW} {...gutterProps} />}
158
165
  />
@@ -161,6 +168,7 @@ export function LayoutGeometryRows({
161
168
  value={formatPxMetricValue(displayH)}
162
169
  tier="default"
163
170
  disabled={manualSizeEditingDisabled}
171
+ tooltip={readOnlyTooltip}
164
172
  onCommit={(next) => commitManualSize("height", next)}
165
173
  suffix={<KeyframeGutter property="height" displayValue={displayH} {...gutterProps} />}
166
174
  />
@@ -169,6 +177,7 @@ export function LayoutGeometryRows({
169
177
  value={`${displayR}°`}
170
178
  tier="default"
171
179
  disabled={manualRotationEditingDisabled}
180
+ tooltip={readOnlyTooltip}
172
181
  onCommit={(next) => commitManualRotation(next.replace("°", ""))}
173
182
  suffix={<KeyframeGutter property="rotation" displayValue={displayR} {...gutterProps} />}
174
183
  />
@@ -250,8 +250,8 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
250
250
  rateTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
251
251
  rateTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
252
252
  });
253
- // min=25, max=300, ratio=1.0 -> raw=300 -> commit(300) -> 300/100=3 -> "3"
254
- expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "3");
253
+ // the speed slider is log-scaled 0.1x..10x, so the far end of the track is 10x
254
+ expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "10");
255
255
  act(() => root.unmount());
256
256
  });
257
257
 
@@ -13,6 +13,10 @@ import {
13
13
  import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
14
14
  import { FlatToggle } from "./propertyPanelFlatToggle";
15
15
  import { AutomationToggle } from "./propertyPanelFxControls";
16
+ import { RATE_RANGE } from "@hyperframes/core/audio-automation";
17
+ import { type SpeedPresetId } from "@hyperframes/core/speed-ramp";
18
+ import { SPEED_PRESET_OPTIONS, type RateBinding } from "./useVolumeAutomation";
19
+ import { fromUnit, toUnit } from "../../player/components/automationLaneGeometry";
16
20
  import {
17
21
  AUDIO_GAIN_FADER_MAX,
18
22
  AUDIO_GAIN_FADER_MIN,
@@ -36,6 +40,7 @@ export function FlatMediaSection({
36
40
  onRemoveVolumeAutomation,
37
41
  onCommitVolumeAt,
38
42
  automatedVolumeValue,
43
+ rate,
39
44
  }: {
40
45
  projectDir: string | null;
41
46
  element: DomEditSelection;
@@ -49,6 +54,8 @@ export function FlatMediaSection({
49
54
  onRemoveVolumeAutomation?: () => void;
50
55
  onCommitVolumeAt?: (v: number) => void;
51
56
  automatedVolumeValue?: number;
57
+ /** Speed lane binding and presets; absent outside the Studio panel. */
58
+ rate?: RateBinding;
52
59
  onRemoveBackground?: (
53
60
  inputPath: string,
54
61
  options: {
@@ -77,7 +84,9 @@ export function FlatMediaSection({
77
84
  Number.parseFloat(
78
85
  element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
79
86
  ) || 0;
80
- const playbackRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
87
+ const constantRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
88
+ const playbackRate =
89
+ rate?.automated && rate.automatedValue !== undefined ? rate.automatedValue : constantRate;
81
90
  const sourceDuration =
82
91
  Number.parseFloat(element.dataAttributes["source-duration"] ?? "") ||
83
92
  (el as HTMLMediaElement).duration ||
@@ -259,17 +268,42 @@ export function FlatMediaSection({
259
268
  }
260
269
  />
261
270
  </div>
262
- <FlatSlider
263
- label="Rate"
264
- value={playbackRate * 100}
265
- min={25}
266
- max={300}
267
- tier={playbackRate === 1 ? "default" : "explicitCustom"}
268
- displayValue={`${formatNumericValue(playbackRate)}x`}
269
- onCommit={(next) =>
270
- void onSetAttribute("playback-rate", formatNumericValue(next / 100))
271
- }
272
- />
271
+ <div className="flex items-center gap-1">
272
+ <div className="min-w-0 flex-1">
273
+ <FlatSlider
274
+ label="Speed"
275
+ value={Math.round(toUnit(RATE_RANGE, playbackRate) * 1000)}
276
+ min={0}
277
+ max={1000}
278
+ tier={playbackRate === 1 ? "default" : "explicitCustom"}
279
+ displayValue={`${formatNumericValue(playbackRate)}x`}
280
+ onCommit={(next) => {
281
+ const speed = fromUnit(RATE_RANGE, next / 1000);
282
+ if (rate?.automated) {
283
+ rate.onCommitAt(speed);
284
+ } else {
285
+ void onSetAttribute("playback-rate", formatNumericValue(speed));
286
+ }
287
+ }}
288
+ />
289
+ </div>
290
+ <AutomationToggle
291
+ paramKey="rate"
292
+ label="Speed"
293
+ automated={Boolean(rate?.automated)}
294
+ onAutomate={rate ? () => rate.onAutomate() : undefined}
295
+ onRemoveAutomation={rate ? () => rate.onRemoveAutomation() : undefined}
296
+ />
297
+ </div>
298
+ {rate?.canApplyPreset && (
299
+ <FlatSelectRow
300
+ label="Speed preset"
301
+ value=""
302
+ options={[{ value: "", label: "Choose…" }, ...SPEED_PRESET_OPTIONS]}
303
+ tier="default"
304
+ onChange={(id) => id && rate.onApplyPreset(id as SpeedPresetId)}
305
+ />
306
+ )}
273
307
  <FlatSlider
274
308
  label="Media start"
275
309
  value={Math.round(mediaStart * 100)}
@@ -19,6 +19,7 @@ export function FlatRow({
19
19
  value,
20
20
  tier,
21
21
  disabled,
22
+ tooltip,
22
23
  liveCommit,
23
24
  suffix,
24
25
  dropdown,
@@ -30,6 +31,8 @@ export function FlatRow({
30
31
  value: string;
31
32
  tier: PropertyValueTier;
32
33
  disabled?: boolean;
34
+ /** Shown as a title attribute, e.g. why the row is disabled. */
35
+ tooltip?: string;
33
36
  liveCommit?: boolean;
34
37
  suffix?: ReactNode;
35
38
  /** Renders a trailing 10px caret-down, for select-backed rows. */
@@ -40,7 +43,7 @@ export function FlatRow({
40
43
  }) {
41
44
  const track = useTrackDesignInput();
42
45
  return (
43
- <div className="group flex min-h-[30px] items-center justify-between gap-3">
46
+ <div className="group flex min-h-[30px] items-center justify-between gap-3" title={tooltip}>
44
47
  <span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
45
48
  <span className="flex min-w-0 shrink-0 items-center gap-1.5">
46
49
  <span
@@ -459,12 +459,7 @@ function representativeElement() {
459
459
  describe("classic PropertyPanel input coverage", () => {
460
460
  it("emits only named, known-section events across body inputs and header/footer chrome", async () => {
461
461
  vi.resetModules();
462
- vi.doMock("./manualEditingAvailability", async () => {
463
- const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
464
- "./manualEditingAvailability",
465
- );
466
- return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: false };
467
- });
462
+ vi.doMock("./manualEditingAvailability", () => ({ STUDIO_FLAT_INSPECTOR_ENABLED: false }));
468
463
  const { PropertyPanel } = await import("./PropertyPanel");
469
464
  const host = render(
470
465
  <PropertyPanel
@@ -524,18 +519,13 @@ describe("classic PropertyPanel input coverage", () => {
524
519
  expect(sections.has("header")).toBe(true);
525
520
  expect(sections.has("footer")).toBe(true);
526
521
  expect(sections.size).toBeGreaterThan(2);
527
- });
522
+ }, 15000);
528
523
  });
529
524
 
530
525
  describe("flat PropertyPanel input coverage", () => {
531
526
  it("emits only named flat events from known sections for every visible layout input", async () => {
532
527
  vi.resetModules();
533
- vi.doMock("./manualEditingAvailability", async () => {
534
- const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
535
- "./manualEditingAvailability",
536
- );
537
- return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: true };
538
- });
528
+ vi.doMock("./manualEditingAvailability", () => ({ STUDIO_FLAT_INSPECTOR_ENABLED: true }));
539
529
  const { PropertyPanel } = await import("./PropertyPanel");
540
530
  const host = render(
541
531
  <PropertyPanel
@@ -167,6 +167,15 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
167
167
  const commitSessionRef = useRef(commitSession);
168
168
  commitSessionRef.current = commitSession;
169
169
 
170
+ useEffect(() => {
171
+ if (params.allowCanvasMovement || !sessionRef.current) return;
172
+ const session = sessionRef.current;
173
+ sessionRef.current = null;
174
+ if (session.timer) clearTimeout(session.timer);
175
+ restoreManualOffsetDragMembers(session.members);
176
+ endManualOffsetDragMembers(session.members);
177
+ }, [params.allowCanvasMovement]);
178
+
170
179
  // Build drag members for the current target set — the same member snapshot a
171
180
  // pointer drag starts from (startGesture / startGroupDrag), so the nudge
172
181
  // commit converts offsets → GSAP x/y with identical math.
@@ -532,6 +532,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
532
532
  restoreGestureOverlayRect(g);
533
533
  }
534
534
  if (g?.mode === "rotation" && sel) {
535
+ applyRotationDraftViaGsap(
536
+ sel.element,
537
+ g.actualRotation - (Number.parseFloat(g.initialRotation.studioRotation) || 0),
538
+ );
535
539
  restoreStudioRotation(sel.element, g.initialRotation);
536
540
  endStudioManualEditGesture(sel.element, g.manualEditDragToken);
537
541
  }
@@ -0,0 +1,50 @@
1
+ import { useEffect, useRef, useState, type RefObject } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
3
+ import type { DomEditCompositionRect } from "./useDomEditCompositionRect";
4
+ import { type OffCanvasRect } from "./OffCanvasIndicators";
5
+ import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
6
+
7
+ export function useOffCanvasIndicators({
8
+ iframeRef,
9
+ overlayRef,
10
+ compRectRef,
11
+ activeCompositionPathRef,
12
+ activeCompositionPath,
13
+ }: {
14
+ iframeRef: RefObject<HTMLIFrameElement | null>;
15
+ overlayRef: RefObject<HTMLDivElement | null>;
16
+ compRectRef: React.MutableRefObject<DomEditCompositionRect>;
17
+ activeCompositionPathRef: React.MutableRefObject<string | null>;
18
+ activeCompositionPath: string | null;
19
+ }): {
20
+ offCanvasRects: OffCanvasRect[];
21
+ offCanvasElementsRef: React.MutableRefObject<Map<string, HTMLElement>>;
22
+ } {
23
+ const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
24
+ const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
25
+ const offCanvasDirtyRef = useRef(true);
26
+ const offCanvasSigRef = useRef("");
27
+ const offCanvasObserverRef = useRef<MutationObserver | null>(null);
28
+ const offCanvasObservedDocRef = useRef<Document | null>(null);
29
+
30
+ useMountEffect(() =>
31
+ startOffCanvasIndicatorRefresh({
32
+ iframeRef,
33
+ overlayRef,
34
+ compRectRef,
35
+ activeCompositionPathRef,
36
+ dirtyRef: offCanvasDirtyRef,
37
+ sigRef: offCanvasSigRef,
38
+ observerRef: offCanvasObserverRef,
39
+ observedDocRef: offCanvasObservedDocRef,
40
+ elementsRef: offCanvasElementsRef,
41
+ setRects: setOffCanvasRects,
42
+ }),
43
+ );
44
+
45
+ useEffect(() => {
46
+ offCanvasDirtyRef.current = true;
47
+ }, [activeCompositionPath]);
48
+
49
+ return { offCanvasRects, offCanvasElementsRef };
50
+ }