@hyperframes/studio 0.8.32 → 0.8.34

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 (85) hide show
  1. package/dist/assets/{hyperframes-player-CroojEG1.js → hyperframes-player-D4aSRr_M.js} +1 -1
  2. package/dist/assets/{index-PVOehm7h.js → index-CkxPQxLJ.js} +182 -172
  3. package/dist/assets/{index-CSyaU1m2.js → index-aNyQBoJv.js} +1 -1
  4. package/dist/assets/{index-D0AjUHNT.js → index-hch4ktcC.js} +1 -1
  5. package/dist/{chunk-A7S5SGNA.js → chunk-7BP32G62.js} +294 -53
  6. package/dist/chunk-7BP32G62.js.map +1 -0
  7. package/dist/{domEditingLayers-JMMVOGTO.js → domEditingLayers-UKMVYXT6.js} +2 -2
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +354 -206
  10. package/dist/index.js.map +1 -1
  11. package/package.json +8 -8
  12. package/src/App.tsx +6 -4
  13. package/src/captions/components/CaptionOverlay.sender.test.tsx +47 -0
  14. package/src/captions/components/CaptionOverlay.tsx +1 -0
  15. package/src/captions/generator.test.ts +30 -0
  16. package/src/captions/generator.ts +5 -3
  17. package/src/captions/hooks/useCaptionSync.ts +3 -2
  18. package/src/components/MediaPreview.tsx +2 -1
  19. package/src/components/editor/AnimationCard.test.tsx +3 -3
  20. package/src/components/editor/PropertyPanel.test.tsx +18 -14
  21. package/src/components/editor/SnapGuideOverlay.tsx +2 -6
  22. package/src/components/editor/domEditLayerWalkCache.test.ts +284 -0
  23. package/src/components/editor/domEditLayerWalkCache.ts +327 -0
  24. package/src/components/editor/domEditOverlayBasis.ts +72 -0
  25. package/src/components/editor/domEditOverlayGeometry.ts +78 -83
  26. package/src/components/editor/domEditOverlayMeasurePass.ts +60 -0
  27. package/src/components/editor/domEditOverlayTransform.ts +47 -9
  28. package/src/components/editor/domEditingDom.ts +63 -14
  29. package/src/components/editor/domEditingElement.ts +56 -14
  30. package/src/components/editor/domEditingLayers.test.ts +48 -0
  31. package/src/components/editor/domEditingLayers.ts +22 -35
  32. package/src/components/editor/offCanvasIndicatorGeometry.complexity.test.ts +373 -0
  33. package/src/components/editor/offCanvasIndicatorGeometry.ts +50 -11
  34. package/src/components/editor/offCanvasIndicatorRefresh.ts +43 -16
  35. package/src/components/editor/overlayFrameLoop.test.ts +246 -0
  36. package/src/components/editor/overlayFrameLoop.ts +204 -0
  37. package/src/components/editor/probeSourceElement.ts +29 -0
  38. package/src/components/editor/propertyPanelFill.tsx +2 -1
  39. package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +2 -2
  40. package/src/components/editor/useDomEditCompositionRect.ts +2 -4
  41. package/src/components/editor/useDomEditOverlayRects.ts +15 -9
  42. package/src/components/editor/useMotionPathData.ts +2 -4
  43. package/src/components/feedback/projectProvenance.ts +4 -1
  44. package/src/components/nle/NLEContext.tsx +2 -1
  45. package/src/components/nle/PreviewOverlays.tsx +1 -1
  46. package/src/components/nle/useCompositionStack.ts +11 -4
  47. package/src/components/renders/RenderQueue.test.tsx +2 -2
  48. package/src/components/renders/RenderQueueItem.tsx +2 -1
  49. package/src/components/renders/useRenderQueue.ts +3 -2
  50. package/src/components/sidebar/AssetsTab.tsx +2 -1
  51. package/src/components/sidebar/CompositionsTab.tsx +2 -1
  52. package/src/components/storyboard/FramePoster.tsx +2 -1
  53. package/src/hooks/useAskAgentModal.ts +2 -1
  54. package/src/hooks/useCaptionDetection.sender.test.tsx +54 -0
  55. package/src/hooks/useCaptionDetection.ts +3 -1
  56. package/src/hooks/useCompositionContentLoader.ts +3 -1
  57. package/src/hooks/useCompositionDimensions.test.tsx +47 -0
  58. package/src/hooks/useCompositionDimensions.ts +3 -2
  59. package/src/hooks/useDomEditCommits.ts +5 -4
  60. package/src/hooks/useElementLifecycleOps.ts +5 -1
  61. package/src/hooks/useFileTree.ts +5 -4
  62. package/src/hooks/useGroupCommits.ts +2 -1
  63. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -2
  64. package/src/hooks/useLintModal.ts +2 -1
  65. package/src/hooks/useRenderClipContent.ts +6 -5
  66. package/src/hooks/useTimelineDeleteOps.ts +5 -1
  67. package/src/player/components/Player.tsx +6 -4
  68. package/src/player/components/TimelineAutomationLane.test.tsx +9 -9
  69. package/src/player/components/thumbnailUtils.ts +6 -2
  70. package/src/player/components/timelineDragDrop.test.tsx +3 -3
  71. package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
  72. package/src/player/lib/timelineIframeHelpers.ts +17 -1
  73. package/src/test-setup.ts +8 -0
  74. package/src/utils/blockInstaller.ts +2 -1
  75. package/src/utils/projectRouting.test.ts +9 -0
  76. package/src/utils/projectRouting.ts +18 -1
  77. package/src/utils/razorSplitTransaction.test.ts +3 -3
  78. package/src/utils/sourceScopedSelectorIndex.test.ts +148 -0
  79. package/src/utils/sourceScopedSelectorIndex.ts +64 -6
  80. package/src/utils/studioFileHistory.ts +2 -1
  81. package/src/utils/studioHelpers.test.ts +5 -1
  82. package/src/utils/studioHelpers.ts +3 -2
  83. package/src/utils/timelineCompositionInsert.test.ts +3 -3
  84. package/dist/chunk-A7S5SGNA.js.map +0 -1
  85. /package/dist/{domEditingLayers-JMMVOGTO.js.map → domEditingLayers-UKMVYXT6.js.map} +0 -0
@@ -0,0 +1,246 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import {
5
+ IDLE_POLL_MS,
6
+ requestOverlayFrames,
7
+ resetOverlayFrameLoopForTests,
8
+ subscribeOverlayFrame,
9
+ } from "./overlayFrameLoop";
10
+
11
+ /**
12
+ * The editor's overlay polls run on one shared, parkable frame loop. The two
13
+ * things that must hold: it stops asking for frames when nothing is happening,
14
+ * and every route by which something CAN happen starts it again.
15
+ */
16
+ describe("overlay frame loop", () => {
17
+ const originalRaf = window.requestAnimationFrame;
18
+ const originalCancelRaf = window.cancelAnimationFrame;
19
+ let queued: Array<() => void>;
20
+
21
+ /** Run whatever frames are queued, once. */
22
+ const step = (): number => {
23
+ const batch = queued;
24
+ queued = [];
25
+ for (const callback of batch) callback();
26
+ return batch.length;
27
+ };
28
+
29
+ /** Frames requested over `ms` of wall time, stepping each one. */
30
+ const framesOver = (ms: number): number => {
31
+ let frames = 0;
32
+ for (let elapsed = 0; elapsed < ms; elapsed += 16) {
33
+ vi.advanceTimersByTime(16);
34
+ frames += step();
35
+ }
36
+ return frames;
37
+ };
38
+
39
+ beforeEach(() => {
40
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance", "Date"] });
41
+ queued = [];
42
+ window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
43
+ queued.push(() => callback(performance.now()));
44
+ return queued.length;
45
+ }) as typeof window.requestAnimationFrame;
46
+ window.cancelAnimationFrame = (() => {
47
+ queued = [];
48
+ }) as typeof window.cancelAnimationFrame;
49
+ });
50
+
51
+ afterEach(() => {
52
+ resetOverlayFrameLoopForTests();
53
+ vi.useRealTimers();
54
+ window.requestAnimationFrame = originalRaf;
55
+ window.cancelAnimationFrame = originalCancelRaf;
56
+ });
57
+
58
+ it("runs every subscriber on one frame, not one frame each", () => {
59
+ const calls: string[] = [];
60
+ subscribeOverlayFrame(() => calls.push("a"));
61
+ subscribeOverlayFrame(() => calls.push("b"));
62
+ expect(step()).toBe(1);
63
+ expect(calls).toEqual(["a", "b"]);
64
+ });
65
+
66
+ it("drops to the idle poll rate once nothing has happened", () => {
67
+ let runs = 0;
68
+ subscribeOverlayFrame(() => {
69
+ runs += 1;
70
+ });
71
+ // Awake first: the subscriber has never read anything.
72
+ framesOver(500);
73
+ const runsWhileAwake = runs;
74
+ runs = 0;
75
+ framesOver(1000);
76
+ // A second of silence is four idle polls, not sixty frames.
77
+ expect(runsWhileAwake).toBeGreaterThan(10);
78
+ expect(runs).toBeLessThanOrEqual(Math.ceil(1000 / IDLE_POLL_MS) + 1);
79
+ });
80
+
81
+ it("wakes to full rate on a pointer event", () => {
82
+ let runs = 0;
83
+ subscribeOverlayFrame(() => {
84
+ runs += 1;
85
+ });
86
+ framesOver(1000);
87
+ runs = 0;
88
+ window.dispatchEvent(new Event("pointermove"));
89
+ expect(framesOver(200)).toBeGreaterThan(5);
90
+ expect(runs).toBeGreaterThan(5);
91
+ });
92
+
93
+ const previewState = (frame: number, isPlaying = false): void => {
94
+ window.dispatchEvent(
95
+ new MessageEvent("message", {
96
+ data: { source: "hf-preview", type: "state", frame, isPlaying },
97
+ origin: window.location.origin,
98
+ }),
99
+ );
100
+ };
101
+
102
+ it("wakes to full rate when the preview reports a new frame", () => {
103
+ let runs = 0;
104
+ subscribeOverlayFrame(() => {
105
+ runs += 1;
106
+ });
107
+ previewState(0);
108
+ framesOver(1000);
109
+ runs = 0;
110
+ previewState(1);
111
+ framesOver(200);
112
+ expect(runs).toBeGreaterThan(5);
113
+ });
114
+
115
+ it("ignores the paused heartbeat, which repeats a frame the overlays already drew", () => {
116
+ let runs = 0;
117
+ subscribeOverlayFrame(() => {
118
+ runs += 1;
119
+ });
120
+ previewState(7);
121
+ framesOver(1000);
122
+ runs = 0;
123
+ // The bridge posts an unchanged state every 80ms while paused. Treating
124
+ // that as news would hold the overlays at 60fps for the life of the tab.
125
+ for (let beat = 0; beat < 12; beat += 1) {
126
+ previewState(7);
127
+ framesOver(80);
128
+ }
129
+ expect(runs).toBeLessThanOrEqual(Math.ceil(960 / IDLE_POLL_MS) + 1);
130
+ });
131
+
132
+ it("wakes on an explicit request, which is how preview-document mutations arrive", () => {
133
+ let runs = 0;
134
+ subscribeOverlayFrame(() => {
135
+ runs += 1;
136
+ });
137
+ framesOver(1000);
138
+ runs = 0;
139
+ requestOverlayFrames();
140
+ framesOver(200);
141
+ expect(runs).toBeGreaterThan(5);
142
+ });
143
+
144
+ it("keeps every other subscriber running, and the loop alive, when one throws", () => {
145
+ const runs = { a: 0, c: 0 };
146
+ // The loop rethrows out of band so the error still reaches the page's
147
+ // error reporting. Capture those instead of letting them escape the test,
148
+ // and assert every one of them was surfaced rather than swallowed.
149
+ const rethrown: Array<() => void> = [];
150
+ vi.spyOn(globalThis, "queueMicrotask").mockImplementation((fn: () => void) => {
151
+ rethrown.push(fn);
152
+ });
153
+
154
+ subscribeOverlayFrame(() => {
155
+ runs.a += 1;
156
+ });
157
+ subscribeOverlayFrame(() => {
158
+ throw new Error("subscriber blew up");
159
+ });
160
+ subscribeOverlayFrame(() => {
161
+ runs.c += 1;
162
+ });
163
+
164
+ for (let i = 0; i < 5; i += 1) step();
165
+
166
+ // The survivors ran on every frame, every failure was reported, and the
167
+ // loop is still asking for more frames.
168
+ expect(runs.a).toBe(5);
169
+ expect(runs.c).toBe(5);
170
+ expect(rethrown).toHaveLength(5);
171
+ expect(() => rethrown[0]()).toThrow("subscriber blew up");
172
+ expect(queued.length).toBeGreaterThan(0);
173
+ });
174
+
175
+ it("does not wake for a message that is not the preview's", () => {
176
+ let runs = 0;
177
+ subscribeOverlayFrame(() => {
178
+ runs += 1;
179
+ });
180
+ framesOver(1000);
181
+ runs = 0;
182
+ window.dispatchEvent(
183
+ new MessageEvent("message", {
184
+ data: { source: "some-extension" },
185
+ origin: window.location.origin,
186
+ }),
187
+ );
188
+ window.dispatchEvent(
189
+ new MessageEvent("message", { data: "a string", origin: window.location.origin }),
190
+ );
191
+ framesOver(200);
192
+ expect(runs).toBeLessThanOrEqual(2);
193
+ });
194
+
195
+ it("ignores a preview-shaped message from another origin", () => {
196
+ let runs = 0;
197
+ subscribeOverlayFrame(() => {
198
+ runs += 1;
199
+ });
200
+ framesOver(1000);
201
+ runs = 0;
202
+ // Same payload the preview sends, from somewhere that is not the preview.
203
+ // Anything embedded on the page can post this; only the origin tells them
204
+ // apart, and waking on it would hold the overlays at full rate for free.
205
+ window.dispatchEvent(
206
+ new MessageEvent("message", {
207
+ data: { source: "hf-preview", type: "state", frame: 42, isPlaying: false },
208
+ origin: "https://not-the-preview.example",
209
+ }),
210
+ );
211
+ framesOver(200);
212
+ expect(runs).toBeLessThanOrEqual(2);
213
+ });
214
+
215
+ it("wakes for a preview message that is not a state post", () => {
216
+ let runs = 0;
217
+ subscribeOverlayFrame(() => {
218
+ runs += 1;
219
+ });
220
+ framesOver(1000);
221
+ runs = 0;
222
+ // A new clip manifest is news whatever the playhead is doing.
223
+ window.dispatchEvent(
224
+ new MessageEvent("message", {
225
+ data: { source: "hf-preview", type: "timeline", clips: [] },
226
+ origin: window.location.origin,
227
+ }),
228
+ );
229
+ framesOver(200);
230
+ expect(runs).toBeGreaterThan(5);
231
+ });
232
+
233
+ it("stops entirely, and stops listening, once the last subscriber leaves", () => {
234
+ let runs = 0;
235
+ const unsubscribe = subscribeOverlayFrame(() => {
236
+ runs += 1;
237
+ });
238
+ framesOver(100);
239
+ unsubscribe();
240
+ runs = 0;
241
+ window.dispatchEvent(new Event("pointermove"));
242
+ framesOver(1000);
243
+ expect(runs).toBe(0);
244
+ expect(queued).toHaveLength(0);
245
+ });
246
+ });
@@ -0,0 +1,204 @@
1
+ /**
2
+ * One animation frame for every editor overlay that tracks preview geometry by
3
+ * polling — and no frame at all while the editor is sitting still.
4
+ *
5
+ * Each overlay used to own an unconditional `requestAnimationFrame(update)`.
6
+ * Four of them never stopped, so a paused, untouched Studio ran ~240 callbacks
7
+ * a second, every one of them reading layout and one of them writing style,
8
+ * which kept the compositor committing 55 frames a second with nothing moving.
9
+ *
10
+ * Parking is safe because none of these overlays has a clock of its own. They
11
+ * change only in response to something observable: a pointer, a key, a scroll
12
+ * or resize, a message from the preview (the runtime posts one on every frame
13
+ * the playhead moves), or a mutation inside the preview document. Those are the
14
+ * wake sources below, and `requestOverlayFrames` is the door for anything else.
15
+ *
16
+ * `IDLE_POLL_MS` is the safety net, and the reason a missed wake source is a
17
+ * quarter second of staleness rather than a permanently frozen overlay.
18
+ */
19
+
20
+ /**
21
+ * How long the loop keeps running frames after the last wake.
22
+ *
23
+ * Long enough to outlast the consequences of the event that woke it, not just
24
+ * the event: a click opens a panel whose CSS transition moves the preview for
25
+ * the next couple of hundred milliseconds, and no further event is dispatched
26
+ * while it does.
27
+ */
28
+ const AWAKE_MS = 400;
29
+ /** How often a parked loop runs one frame anyway, in case a wake was missed. */
30
+ export const IDLE_POLL_MS = 250;
31
+
32
+ const subscribers = new Set<() => void>();
33
+
34
+ let frameId: number | null = null;
35
+ let idleTimerId: ReturnType<typeof setTimeout> | null = null;
36
+ let awakeUntil = 0;
37
+ let listenersAttached = false;
38
+
39
+ /** Wake sources. All passive reads; none of them can be cancelled by us. */
40
+ const WINDOW_EVENTS = [
41
+ "pointerdown",
42
+ "pointermove",
43
+ "pointerup",
44
+ "wheel",
45
+ "keydown",
46
+ "keyup",
47
+ "scroll",
48
+ "resize",
49
+ "visibilitychange",
50
+ ] as const;
51
+
52
+ // The preview posts a `state` message on a fixed interval even when the
53
+ // playhead has not moved — the control bridge's paused heartbeat, which exists
54
+ // so a listener can confirm a paused position. Waking on every message would
55
+ // therefore hold the overlays at 60 fps for as long as the editor is open,
56
+ // which is exactly the cost this loop exists to remove. Wake on the messages
57
+ // that carry news instead.
58
+ let lastPreviewFrame: number | null = null;
59
+ let lastPreviewPlaying: boolean | null = null;
60
+
61
+ /**
62
+ * The preview iframe is served by the editor's own origin (`useTimelinePlayer`
63
+ * builds its src against `window.location.origin`), so a message from anywhere
64
+ * else is not the preview whatever its payload claims. Answered before the
65
+ * payload is read, because a sender we do not trust controls every field in it.
66
+ */
67
+ function isFromPreviewOrigin(event: MessageEvent): boolean {
68
+ if (typeof window === "undefined") return true;
69
+ return event.origin === window.location.origin;
70
+ }
71
+
72
+ /**
73
+ * `window` also receives postMessage traffic from extensions, devtools and any
74
+ * other embed on the page. Waking on those would hold the overlays awake for
75
+ * reasons that have nothing to do with the preview.
76
+ */
77
+ function isPreviewPayload(data: unknown): data is { type?: unknown } {
78
+ if (data == null || typeof data !== "object") return false;
79
+ return (data as { source?: unknown }).source === "hf-preview";
80
+ }
81
+
82
+ /** The playhead position a `state` post carries, or null for any other post. */
83
+ function previewStateOf(data: {
84
+ type?: unknown;
85
+ }): { frame: number | null; playing: boolean } | null {
86
+ if (data.type !== "state") return null;
87
+ const frame = (data as { frame?: unknown }).frame;
88
+ return {
89
+ frame: typeof frame === "number" ? frame : null,
90
+ playing: (data as { isPlaying?: unknown }).isPlaying === true,
91
+ };
92
+ }
93
+
94
+ function onPreviewMessage(event: MessageEvent): void {
95
+ if (!isFromPreviewOrigin(event)) return;
96
+ const data: unknown = event.data;
97
+ if (!isPreviewPayload(data)) return;
98
+ const state = previewStateOf(data);
99
+ if (state) {
100
+ if (state.frame === lastPreviewFrame && state.playing === lastPreviewPlaying) return;
101
+ lastPreviewFrame = state.frame;
102
+ lastPreviewPlaying = state.playing;
103
+ }
104
+ requestOverlayFrames();
105
+ }
106
+
107
+ function runFrame(): void {
108
+ frameId = null;
109
+ // Re-arm BEFORE running anything, exactly as the four separate loops this
110
+ // replaces did. A subscriber that throws must not take the loop down with
111
+ // it — and a shared loop makes that failure four overlays wide plus the
112
+ // idle-poll safety net, permanently, rather than one overlay's own problem.
113
+ schedule();
114
+ for (const subscriber of subscribers) {
115
+ try {
116
+ subscriber();
117
+ } catch (error) {
118
+ // Rethrown out of band so it still reaches window.onerror and whatever
119
+ // reports errors, without any subscriber becoming the others' fate.
120
+ queueMicrotask(() => {
121
+ throw error;
122
+ });
123
+ }
124
+ }
125
+ }
126
+
127
+ function schedule(): void {
128
+ if (subscribers.size === 0) return;
129
+ if (frameId != null || idleTimerId != null) return;
130
+ if (performance.now() < awakeUntil) {
131
+ frameId = requestAnimationFrame(runFrame);
132
+ return;
133
+ }
134
+ idleTimerId = setTimeout(() => {
135
+ idleTimerId = null;
136
+ frameId = requestAnimationFrame(runFrame);
137
+ }, IDLE_POLL_MS);
138
+ }
139
+
140
+ /** Something moved, or might have. Run frames at full rate for a moment. */
141
+ export function requestOverlayFrames(): void {
142
+ awakeUntil = performance.now() + AWAKE_MS;
143
+ if (idleTimerId != null) {
144
+ clearTimeout(idleTimerId);
145
+ idleTimerId = null;
146
+ }
147
+ schedule();
148
+ }
149
+
150
+ function attachListeners(): void {
151
+ if (listenersAttached || typeof window === "undefined") return;
152
+ listenersAttached = true;
153
+ for (const type of WINDOW_EVENTS) {
154
+ window.addEventListener(type, requestOverlayFrames, { capture: true, passive: true });
155
+ }
156
+ window.addEventListener("message", onPreviewMessage, { capture: true, passive: true });
157
+ }
158
+
159
+ function detachListeners(): void {
160
+ if (!listenersAttached || typeof window === "undefined") return;
161
+ listenersAttached = false;
162
+ for (const type of WINDOW_EVENTS) {
163
+ window.removeEventListener(type, requestOverlayFrames, { capture: true });
164
+ }
165
+ window.removeEventListener("message", onPreviewMessage, { capture: true });
166
+ }
167
+
168
+ /**
169
+ * Run `update` on the shared loop. The subscriber must be a pure poll: it is
170
+ * called on an ordinary animation frame while the editor is active and roughly
171
+ * four times a second while it is not.
172
+ */
173
+ export function subscribeOverlayFrame(update: () => void): () => void {
174
+ subscribers.add(update);
175
+ attachListeners();
176
+ // A fresh subscriber has never read anything, so it starts awake.
177
+ requestOverlayFrames();
178
+ return () => {
179
+ subscribers.delete(update);
180
+ if (subscribers.size > 0) return;
181
+ if (frameId != null) {
182
+ cancelAnimationFrame(frameId);
183
+ frameId = null;
184
+ }
185
+ if (idleTimerId != null) {
186
+ clearTimeout(idleTimerId);
187
+ idleTimerId = null;
188
+ }
189
+ detachListeners();
190
+ };
191
+ }
192
+
193
+ /** Test seam: the loop is module state, and tests need it back at zero. */
194
+ export function resetOverlayFrameLoopForTests(): void {
195
+ subscribers.clear();
196
+ if (frameId != null) cancelAnimationFrame(frameId);
197
+ if (idleTimerId != null) clearTimeout(idleTimerId);
198
+ frameId = null;
199
+ idleTimerId = null;
200
+ awakeUntil = 0;
201
+ lastPreviewFrame = null;
202
+ lastPreviewPlaying = null;
203
+ detachListeners();
204
+ }
@@ -0,0 +1,29 @@
1
+ import { buildProjectApiPath } from "../../utils/projectRouting";
2
+
3
+ export async function probeSourceElement(
4
+ projectId: string,
5
+ sourceFile: string,
6
+ target: { id?: string; hfId?: string; selector?: string; selectorIndex?: number },
7
+ ): Promise<boolean> {
8
+ try {
9
+ const response = await fetch(
10
+ buildProjectApiPath(
11
+ projectId,
12
+ `/file-mutations/probe-element/${encodeURIComponent(sourceFile)}`,
13
+ ),
14
+ {
15
+ method: "POST",
16
+ headers: { "Content-Type": "application/json" },
17
+ body: JSON.stringify({ target }),
18
+ },
19
+ );
20
+ if (!response.ok) return true;
21
+ const data = await response.json();
22
+ if (data && typeof data === "object" && "exists" in data && data.exists === false) {
23
+ return false;
24
+ }
25
+ return true;
26
+ } catch {
27
+ return true;
28
+ }
29
+ }
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../../utils/projectRouting";
1
2
  import { useMemo, useRef, useState } from "react";
2
3
  import { Plus, RotateCcw, X } from "../../icons/SystemIcons";
3
4
  import {
@@ -158,7 +159,7 @@ export function ImageFillField({
158
159
  {selectedAsset && (
159
160
  <div className="overflow-hidden rounded-xl border border-neutral-800 bg-neutral-900/80">
160
161
  <img
161
- src={`/api/projects/${projectId}/preview/${selectedAsset}`}
162
+ src={buildProjectApiPath(projectId, `/preview/${selectedAsset}`)}
162
163
  alt={selectedAsset.split("/").pop() ?? selectedAsset}
163
164
  className="h-28 w-full object-contain bg-neutral-950/80"
164
165
  />
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { afterEach, describe, expect, it, vi, type Mock } from "vitest";
6
6
  import {
7
7
  HF_COLOR_GRADING_ACTIVE_EFFECT_KEYS,
8
8
  HF_COLOR_GRADING_EFFECT_APPLY_DEFAULTS,
@@ -38,7 +38,7 @@ function renderInto(node: React.ReactElement) {
38
38
 
39
39
  function sectionProps(
40
40
  grading: ReturnType<typeof neutralGrading>,
41
- onCommitColorGrading: ReturnType<typeof vi.fn> = vi.fn(),
41
+ onCommitColorGrading: Mock = vi.fn(),
42
42
  ) {
43
43
  return {
44
44
  grading,
@@ -1,5 +1,6 @@
1
1
  import { useState, type RefObject } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
+ import { subscribeOverlayFrame } from "./overlayFrameLoop";
3
4
 
4
5
  export interface DomEditCompositionRect {
5
6
  left: number;
@@ -39,10 +40,8 @@ export function useDomEditCompositionRect({
39
40
  });
40
41
 
41
42
  useMountEffect(() => {
42
- let frame = 0;
43
43
  // fallow-ignore-next-line complexity
44
44
  const update = () => {
45
- frame = requestAnimationFrame(update);
46
45
  const iframe = iframeRef.current;
47
46
  const overlayEl = overlayRef.current;
48
47
  if (!iframe || !overlayEl) return;
@@ -60,8 +59,7 @@ export function useDomEditCompositionRect({
60
59
  const next = { left, top, width: iRect.width, height: iRect.height, scaleX, scaleY };
61
60
  setCompRect((prev) => (sameRect(prev, next) ? prev : next));
62
61
  };
63
- frame = requestAnimationFrame(update);
64
- return () => cancelAnimationFrame(frame);
62
+ return subscribeOverlayFrame(update);
65
63
  });
66
64
 
67
65
  return compRect;
@@ -19,6 +19,8 @@ import {
19
19
  selectionCacheKey,
20
20
  orientedVisibleOverlayRect,
21
21
  } from "./domEditOverlayGeometry";
22
+ import { computeOverlayRootScale } from "./domEditOverlayBasis";
23
+ import { subscribeOverlayFrame } from "./overlayFrameLoop";
22
24
 
23
25
  function childRectsEqual(a: OverlayRect[], b: OverlayRect[]): boolean {
24
26
  if (a.length !== b.length) return false;
@@ -106,8 +108,6 @@ export function useDomEditOverlayRects({
106
108
  };
107
109
 
108
110
  useMountEffect(() => {
109
- let frame = 0;
110
-
111
111
  const clearAll = () => {
112
112
  setOverlayRect(null);
113
113
  setHoverRect(null);
@@ -115,7 +115,6 @@ export function useDomEditOverlayRects({
115
115
  };
116
116
 
117
117
  const update = () => {
118
- frame = requestAnimationFrame(update);
119
118
  if (rafPausedRef.current) {
120
119
  if (childRectsRef.current.length > 0) {
121
120
  childRectsRef.current = [];
@@ -144,6 +143,14 @@ export function useDomEditOverlayRects({
144
143
  return;
145
144
  }
146
145
 
146
+ // One basis for the whole frame: the selection box, up to 60 child
147
+ // outlines, every group box and the hover box all map through the same
148
+ // iframe→overlay transform, and nothing below writes to the preview DOM
149
+ // or moves the canvas. Resolved per call it was one
150
+ // `querySelector("[data-composition-id]")` and three layout reads EACH,
151
+ // every frame.
152
+ const scale = computeOverlayRootScale(overlayEl, iframe, doc);
153
+
147
154
  if (sel) {
148
155
  const el = resolveElementForOverlay(
149
156
  doc,
@@ -164,7 +171,7 @@ export function useDomEditOverlayRects({
164
171
  // (a cheap per-call check) and only pays for the full corner-transform
165
172
  // measurement when the element is actually rotated — this RAF loop runs
166
173
  // every frame for any single selection, so that gate matters here most.
167
- const nextRect = orientedGroupAwareOverlayRect(overlayEl, iframe, el);
174
+ const nextRect = orientedGroupAwareOverlayRect(overlayEl, iframe, el, scale);
168
175
  setOverlayRect(nextRect);
169
176
  const descendants = el.querySelectorAll("*");
170
177
  if (descendants.length > 0 && descendants.length <= 60) {
@@ -174,7 +181,7 @@ export function useDomEditOverlayRects({
174
181
  if (!child.getBoundingClientRect) continue;
175
182
  // Oriented, not axis-aligned: a child of a rotated element drew its
176
183
  // outline square around the rotated glyphs instead of on them.
177
- const r = orientedVisibleOverlayRect(overlayEl, iframe, child);
184
+ const r = orientedVisibleOverlayRect(overlayEl, iframe, child, scale);
178
185
  if (r && r.width > 2 && r.height > 2) nextChildRects.push(r);
179
186
  }
180
187
  if (!childRectsEqual(childRectsRef.current, nextChildRects)) {
@@ -213,7 +220,7 @@ export function useDomEditOverlayRects({
213
220
  if (liveGroupKeys.has(key)) continue;
214
221
  liveGroupKeys.add(key);
215
222
  const el = resolveGroupElement(doc, groupSelection);
216
- const base = el ? groupAwareOverlayRect(overlayEl, iframe, el) : null;
223
+ const base = el ? groupAwareOverlayRect(overlayEl, iframe, el, scale) : null;
217
224
  const rect = base && el ? { ...base, ...hugRectForElement(base, el) } : base;
218
225
  if (el && rect)
219
226
  nextGroupItems.push({ key, selection: groupSelection, element: el, rect });
@@ -251,11 +258,10 @@ export function useDomEditOverlayRects({
251
258
  return;
252
259
  }
253
260
 
254
- setHoverRect(orientedGroupAwareOverlayRect(overlayEl, iframe, hoverEl));
261
+ setHoverRect(orientedGroupAwareOverlayRect(overlayEl, iframe, hoverEl, scale));
255
262
  };
256
263
 
257
- frame = requestAnimationFrame(update);
258
- return () => cancelAnimationFrame(frame);
264
+ return subscribeOverlayFrame(update);
259
265
  });
260
266
 
261
267
  return {
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type RefObject } from "react";
2
2
  import { readRuntimeKeyframes } from "../../hooks/gsapRuntimeKeyframes";
3
3
  import { isElementVisibleForOverlay } from "./domEditOverlayGeometry";
4
4
  import { buildMotionPathGeometry, type MotionPathGeometry } from "./motionPathGeometry";
5
+ import { subscribeOverlayFrame } from "./overlayFrameLoop";
5
6
 
6
7
  type Rect = { left: number; top: number; width: number; height: number };
7
8
 
@@ -121,7 +122,6 @@ export function useMotionPathData(
121
122
  return;
122
123
  }
123
124
  setHome(null);
124
- let raf = 0;
125
125
  const tick = () => {
126
126
  const el = iframeRef.current;
127
127
  if (el) {
@@ -153,10 +153,8 @@ export function useMotionPathData(
153
153
  setPScale((p) => (Math.abs(p - ps) < 0.001 ? p : ps));
154
154
  }
155
155
  }
156
- raf = requestAnimationFrame(tick);
157
156
  };
158
- raf = requestAnimationFrame(tick);
159
- return () => cancelAnimationFrame(raf);
157
+ return subscribeOverlayFrame(tick);
160
158
  }, [selector, iframeRef]);
161
159
 
162
160
  useEffect(() => {
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../../utils/projectRouting";
1
2
  // ---------------------------------------------------------------------------
2
3
  // How this project came to exist, and roughly what shape it is.
3
4
  //
@@ -53,7 +54,9 @@ export async function captureProjectProvenance(
53
54
  if (!scaffolded) return;
54
55
 
55
56
  try {
56
- const res = await fetch(`/api/projects/${projectId}/files/${encodeURIComponent(CONFIG_FILE)}`);
57
+ const res = await fetch(
58
+ buildProjectApiPath(projectId, `/files/${encodeURIComponent(CONFIG_FILE)}`),
59
+ );
57
60
  if (!res.ok) return;
58
61
  // The route answers with an envelope, not the file: {filename, content,
59
62
  // version}. The config is the `content` string inside it.
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../../utils/projectRouting";
1
2
  import { useContext, useState, useCallback, useRef, useEffect, type ReactNode } from "react";
2
3
  import { useTimelinePlayer, usePlayerStore } from "../../player";
3
4
  import type { TimelineElement } from "../../player";
@@ -185,7 +186,7 @@ export function NLEProvider({
185
186
  setCompositionSourceMap(emptyMap);
186
187
  onCompIdToSrcChangeRef.current?.(emptyMap);
187
188
 
188
- fetch(`/api/projects/${projectId}/files/index.html`, {
189
+ fetch(buildProjectApiPath(projectId, `/files/index.html`), {
189
190
  signal: controller.signal,
190
191
  })
191
192
  .then((r) => {
@@ -139,7 +139,7 @@ export function PreviewOverlays({
139
139
  }: PreviewOverlaysProps) {
140
140
  const { activeCompPath, previewIframeRef } = useStudioShellContext();
141
141
  const { captionEditMode, compositionLoading, isPlaying } = useStudioPlaybackContext();
142
- const compositionDimensions = useCompositionDimensions();
142
+ const compositionDimensions = useCompositionDimensions(previewIframeRef);
143
143
 
144
144
  // Caption edit mode is entered automatically when captions are detected;
145
145
  // these give the author an explicit way OUT (and back in). Without them the