@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.
- package/dist/assets/{hyperframes-player-CroojEG1.js → hyperframes-player-D4aSRr_M.js} +1 -1
- package/dist/assets/{index-PVOehm7h.js → index-CkxPQxLJ.js} +182 -172
- package/dist/assets/{index-CSyaU1m2.js → index-aNyQBoJv.js} +1 -1
- package/dist/assets/{index-D0AjUHNT.js → index-hch4ktcC.js} +1 -1
- package/dist/{chunk-A7S5SGNA.js → chunk-7BP32G62.js} +294 -53
- package/dist/chunk-7BP32G62.js.map +1 -0
- package/dist/{domEditingLayers-JMMVOGTO.js → domEditingLayers-UKMVYXT6.js} +2 -2
- package/dist/index.html +1 -1
- package/dist/index.js +354 -206
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -4
- package/src/captions/components/CaptionOverlay.sender.test.tsx +47 -0
- package/src/captions/components/CaptionOverlay.tsx +1 -0
- package/src/captions/generator.test.ts +30 -0
- package/src/captions/generator.ts +5 -3
- package/src/captions/hooks/useCaptionSync.ts +3 -2
- package/src/components/MediaPreview.tsx +2 -1
- package/src/components/editor/AnimationCard.test.tsx +3 -3
- package/src/components/editor/PropertyPanel.test.tsx +18 -14
- package/src/components/editor/SnapGuideOverlay.tsx +2 -6
- package/src/components/editor/domEditLayerWalkCache.test.ts +284 -0
- package/src/components/editor/domEditLayerWalkCache.ts +327 -0
- package/src/components/editor/domEditOverlayBasis.ts +72 -0
- package/src/components/editor/domEditOverlayGeometry.ts +78 -83
- package/src/components/editor/domEditOverlayMeasurePass.ts +60 -0
- package/src/components/editor/domEditOverlayTransform.ts +47 -9
- package/src/components/editor/domEditingDom.ts +63 -14
- package/src/components/editor/domEditingElement.ts +56 -14
- package/src/components/editor/domEditingLayers.test.ts +48 -0
- package/src/components/editor/domEditingLayers.ts +22 -35
- package/src/components/editor/offCanvasIndicatorGeometry.complexity.test.ts +373 -0
- package/src/components/editor/offCanvasIndicatorGeometry.ts +50 -11
- package/src/components/editor/offCanvasIndicatorRefresh.ts +43 -16
- package/src/components/editor/overlayFrameLoop.test.ts +246 -0
- package/src/components/editor/overlayFrameLoop.ts +204 -0
- package/src/components/editor/probeSourceElement.ts +29 -0
- package/src/components/editor/propertyPanelFill.tsx +2 -1
- package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +2 -2
- package/src/components/editor/useDomEditCompositionRect.ts +2 -4
- package/src/components/editor/useDomEditOverlayRects.ts +15 -9
- package/src/components/editor/useMotionPathData.ts +2 -4
- package/src/components/feedback/projectProvenance.ts +4 -1
- package/src/components/nle/NLEContext.tsx +2 -1
- package/src/components/nle/PreviewOverlays.tsx +1 -1
- package/src/components/nle/useCompositionStack.ts +11 -4
- package/src/components/renders/RenderQueue.test.tsx +2 -2
- package/src/components/renders/RenderQueueItem.tsx +2 -1
- package/src/components/renders/useRenderQueue.ts +3 -2
- package/src/components/sidebar/AssetsTab.tsx +2 -1
- package/src/components/sidebar/CompositionsTab.tsx +2 -1
- package/src/components/storyboard/FramePoster.tsx +2 -1
- package/src/hooks/useAskAgentModal.ts +2 -1
- package/src/hooks/useCaptionDetection.sender.test.tsx +54 -0
- package/src/hooks/useCaptionDetection.ts +3 -1
- package/src/hooks/useCompositionContentLoader.ts +3 -1
- package/src/hooks/useCompositionDimensions.test.tsx +47 -0
- package/src/hooks/useCompositionDimensions.ts +3 -2
- package/src/hooks/useDomEditCommits.ts +5 -4
- package/src/hooks/useElementLifecycleOps.ts +5 -1
- package/src/hooks/useFileTree.ts +5 -4
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -2
- package/src/hooks/useLintModal.ts +2 -1
- package/src/hooks/useRenderClipContent.ts +6 -5
- package/src/hooks/useTimelineDeleteOps.ts +5 -1
- package/src/player/components/Player.tsx +6 -4
- package/src/player/components/TimelineAutomationLane.test.tsx +9 -9
- package/src/player/components/thumbnailUtils.ts +6 -2
- package/src/player/components/timelineDragDrop.test.tsx +3 -3
- package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
- package/src/player/lib/timelineIframeHelpers.ts +17 -1
- package/src/test-setup.ts +8 -0
- package/src/utils/blockInstaller.ts +2 -1
- package/src/utils/projectRouting.test.ts +9 -0
- package/src/utils/projectRouting.ts +18 -1
- package/src/utils/razorSplitTransaction.test.ts +3 -3
- package/src/utils/sourceScopedSelectorIndex.test.ts +148 -0
- package/src/utils/sourceScopedSelectorIndex.ts +64 -6
- package/src/utils/studioFileHistory.ts +2 -1
- package/src/utils/studioHelpers.test.ts +5 -1
- package/src/utils/studioHelpers.ts +3 -2
- package/src/utils/timelineCompositionInsert.test.ts +3 -3
- package/dist/chunk-A7S5SGNA.js.map +0 -1
- /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={`/
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(`/
|
|
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
|