@hyperframes/studio 0.8.75 → 0.8.77
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-BSDTJ9EF.js +464 -0
- package/dist/assets/{index-BlUYw1MB.js → index-2GgHjQq-.js} +163 -163
- package/dist/assets/{index-0w6iIlwZ.css → index-BWJqxeaH.css} +1 -1
- package/dist/assets/{index-BiFKcNjs.js → index-CsFCRdMo.js} +1 -1
- package/dist/assets/{index-HjexPsny.js → index-DcxWQ7Za.js} +1 -1
- package/dist/{chunk-7BP32G62.js → chunk-5VANA2TG.js} +12 -1
- package/dist/chunk-5VANA2TG.js.map +1 -0
- package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-IGPLTI5E.js} +4 -2
- package/dist/index.d.ts +34 -69
- package/dist/index.html +2 -2
- package/dist/index.js +5287 -5277
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanels.types.ts +0 -2
- package/src/components/editor/DomEditOverlay.test.ts +5 -0
- package/src/components/editor/LayersPanel.tsx +3 -14
- package/src/components/editor/PreviewOverlayProvider.test.tsx +6 -1
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingElement.ts +13 -0
- package/src/components/editor/domEditingLayers.test.ts +27 -0
- package/src/components/editor/domEditingLayers.ts +9 -0
- package/src/components/editor/gsapLivePreview.test.ts +63 -0
- package/src/components/editor/gsapLivePreview.ts +2 -14
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +57 -35
- package/src/components/editor/overlayFrameLoop.test.ts +16 -0
- package/src/components/editor/overlayFrameLoop.ts +11 -0
- package/src/components/editor/propertyPanelHelpers.ts +4 -6
- package/src/components/editor/usePreviewCompositionRect.test.tsx +6 -1
- package/src/components/nle/NLEPreview.test.ts +202 -22
- package/src/components/nle/NLEPreview.tsx +65 -46
- package/src/components/nle/PreviewPoster.tsx +99 -0
- package/src/components/nle/PreviewZoomOverlay.tsx +111 -0
- package/src/components/nle/previewZoom.test.ts +42 -0
- package/src/components/nle/previewZoom.ts +33 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +30 -49
- package/src/components/panels/VariablesPanel.tsx +0 -2
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +5 -1
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +14 -0
- package/src/components/sidebar/CompositionsTab.test.ts +4 -6
- package/src/components/sidebar/CompositionsTab.tsx +28 -21
- package/src/components/studioColorGradingScope.ts +0 -3
- package/src/hooks/domEditDeleteMembers.ts +0 -2
- package/src/hooks/gsapScriptCommitTypes.ts +0 -2
- package/src/hooks/keyframeCacheAstLoad.test.ts +31 -1
- package/src/hooks/keyframeCacheAstLoad.ts +2 -1
- package/src/hooks/timelineAudioGroupCreate.ts +0 -1
- package/src/hooks/timelineEditingHelpers.ts +0 -3
- package/src/hooks/timelineTimingSync.ts +0 -1
- package/src/hooks/timelineTrackVisibility.ts +0 -1
- package/src/hooks/useBlockHandlers.ts +0 -2
- package/src/hooks/useClipboard.test.ts +69 -10
- package/src/hooks/useClipboard.ts +28 -19
- package/src/hooks/useDomEditCommits.test.tsx +0 -1
- package/src/hooks/useDomEditCommits.ts +0 -4
- package/src/hooks/useDomEditWiring.ts +1 -3
- package/src/hooks/useEditHistoryActions.test.tsx +31 -14
- package/src/hooks/useEditHistoryActions.ts +12 -7
- package/src/hooks/useEditorSave.ts +0 -3
- package/src/hooks/useElementLifecycleOps.ts +0 -1
- package/src/hooks/useEnableKeyframes.test.ts +9 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useGestureRecording.test.tsx +1 -1
- package/src/hooks/useGestureRecording.ts +6 -10
- package/src/hooks/useGroupCommits.ts +0 -1
- package/src/hooks/useGsapScriptCommits.ts +0 -1
- package/src/hooks/useGsapTweenCache.element.test.tsx +39 -0
- package/src/hooks/useGsapTweenCache.ts +5 -13
- package/src/hooks/useLintModal.test.tsx +34 -1
- package/src/hooks/useLintModal.ts +32 -30
- package/src/hooks/usePersistentEditHistory.test.ts +221 -315
- package/src/hooks/usePersistentEditHistory.ts +155 -342
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +2 -0
- package/src/hooks/usePreviewPersistence.ts +0 -2
- package/src/hooks/useProjectCompositionVariables.ts +1 -3
- package/src/hooks/useRazorSplit.history.test.tsx +46 -67
- package/src/hooks/useRazorSplit.test.ts +17 -47
- package/src/hooks/useRenderClipContent.test.ts +29 -0
- package/src/hooks/useRenderClipContent.ts +6 -3
- package/src/hooks/useSdkSession.lifecycle.test.tsx +34 -4
- package/src/hooks/useSdkSession.ts +8 -2
- package/src/hooks/useSlideshowPersist.ts +0 -2
- package/src/hooks/useStudioContextValue.ts +6 -1
- package/src/hooks/useTimelineAssetDropOps.ts +0 -1
- package/src/hooks/useTimelineDeleteOps.ts +0 -1
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/hooks/useTimelineEditingTypes.ts +0 -2
- package/src/hostMount.test.tsx +53 -22
- package/src/main.tsx +3 -0
- package/src/player/components/CompositionThumbnail.tsx +14 -0
- package/src/player/components/Player.test.ts +49 -0
- package/src/player/components/Player.tsx +21 -7
- package/src/player/components/timelineClipDragCommit.test.ts +7 -39
- package/src/player/hooks/timelineDomBinding.test.ts +50 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +25 -1
- package/src/player/hooks/timelineSyncHydration.ts +3 -3
- package/src/player/hooks/useShadowPreviewReload.ts +1 -0
- package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
- package/src/player/hooks/useTimelinePlayer.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/automationStoreSync.test.ts +24 -0
- package/src/player/lib/automationStoreSync.ts +3 -9
- package/src/player/lib/playbackScrub.ts +1 -1
- package/src/player/lib/timelineDOM.test.ts +28 -0
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineElementHelpers.ts +78 -9
- package/src/player/lib/timelineIframeHelpers.test.ts +49 -5
- package/src/player/lib/timelineIframeHelpers.ts +13 -12
- package/src/player/store/playerStore.test.ts +35 -1
- package/src/player/store/playerStore.ts +29 -0
- package/src/player/store/readinessSlice.ts +34 -6
- package/src/utils/blockInstaller.ts +0 -3
- package/src/utils/previewPrefetch.test.ts +22 -0
- package/src/utils/previewPrefetch.ts +14 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/razorSplitTransaction.ts +1 -1
- package/src/utils/sdkEditTransaction.ts +0 -3
- package/src/utils/studioFileHistory.test.ts +0 -9
- package/src/utils/studioFileHistory.ts +1 -5
- package/src/utils/studioUiPreferences.test.ts +9 -3
- package/src/utils/studioUiPreferences.ts +0 -20
- package/src/utils/timelineCompositionInsert.ts +0 -1
- package/dist/assets/hyperframes-player-WbBVbWuO.js +0 -464
- package/dist/chunk-7BP32G62.js.map +0 -1
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +0 -60
- package/src/utils/editHistory.test.ts +0 -534
- package/src/utils/editHistory.ts +0 -236
- package/src/utils/editHistoryStorage.test.ts +0 -37
- package/src/utils/editHistoryStorage.ts +0 -99
- /package/dist/{domEditingLayers-UKMVYXT6.js.map → domEditingLayers-IGPLTI5E.js.map} +0 -0
|
@@ -10,6 +10,7 @@ import { readPreviewComplexity } from "../../player/hooks/usePreviewFirstFrameTe
|
|
|
10
10
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
11
|
|
|
12
12
|
const playerMounts: string[] = [];
|
|
13
|
+
let livePlayerProps: { onReadyToShowChange?: (ready: boolean) => void } = {};
|
|
13
14
|
|
|
14
15
|
vi.mock("../../player", async () => {
|
|
15
16
|
const React = await import("react");
|
|
@@ -18,11 +19,13 @@ vi.mock("../../player", async () => {
|
|
|
18
19
|
Player: React.forwardRef(function MockPlayer(
|
|
19
20
|
props: {
|
|
20
21
|
onLoad?: () => void;
|
|
22
|
+
onReadyToShowChange?: (ready: boolean) => void;
|
|
21
23
|
suppressLoadingOverlay?: boolean;
|
|
22
24
|
style?: React.CSSProperties;
|
|
23
25
|
},
|
|
24
26
|
ref: React.ForwardedRef<HTMLIFrameElement>,
|
|
25
27
|
) {
|
|
28
|
+
if (!props.suppressLoadingOverlay) livePlayerProps = props;
|
|
26
29
|
React.useEffect(() => {
|
|
27
30
|
props.onLoad?.();
|
|
28
31
|
}, [props]);
|
|
@@ -37,11 +40,6 @@ vi.mock("../../player", async () => {
|
|
|
37
40
|
};
|
|
38
41
|
});
|
|
39
42
|
|
|
40
|
-
vi.mock("../../utils/studioUiPreferences", () => ({
|
|
41
|
-
readStudioUiPreferences: () => ({}),
|
|
42
|
-
writeStudioUiPreferences: () => {},
|
|
43
|
-
}));
|
|
44
|
-
|
|
45
43
|
let resizeCallbacks: Array<() => void> = [];
|
|
46
44
|
|
|
47
45
|
class MockResizeObserver {
|
|
@@ -90,23 +88,25 @@ function renderPreview(
|
|
|
90
88
|
document.body.append(host);
|
|
91
89
|
const root = createRoot(host);
|
|
92
90
|
const iframeRef = createRef<HTMLIFrameElement>();
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
91
|
+
const render = (directUrl?: string, projectId = "timeline-edit-playground") =>
|
|
92
|
+
act(() => {
|
|
93
|
+
root.render(
|
|
94
|
+
React.createElement(NLEPreview, {
|
|
95
|
+
projectId,
|
|
96
|
+
directUrl,
|
|
97
|
+
iframeRef,
|
|
98
|
+
onIframeLoad: () => {},
|
|
99
|
+
previewSlots,
|
|
100
|
+
onShadowIframeLoad: () => {},
|
|
101
|
+
onShadowReadyChange: () => {},
|
|
102
|
+
onShadowError: () => {},
|
|
103
|
+
setShadowIframeNode: () => {},
|
|
104
|
+
resetPreviewSlots: () => {},
|
|
105
|
+
fillBox,
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
render();
|
|
110
110
|
|
|
111
111
|
const viewport = host.querySelector('[aria-label="Composition preview"]') as HTMLDivElement;
|
|
112
112
|
const stage = host.querySelector('[data-testid="preview-zoom-stage"]') as HTMLDivElement;
|
|
@@ -121,8 +121,12 @@ function renderPreview(
|
|
|
121
121
|
return {
|
|
122
122
|
host,
|
|
123
123
|
root,
|
|
124
|
+
render,
|
|
124
125
|
viewport,
|
|
125
126
|
stage,
|
|
127
|
+
openProject(projectId: string) {
|
|
128
|
+
render(undefined, projectId);
|
|
129
|
+
},
|
|
126
130
|
cleanup() {
|
|
127
131
|
act(() => {
|
|
128
132
|
root.unmount();
|
|
@@ -256,6 +260,111 @@ describe("NLEPreview", () => {
|
|
|
256
260
|
view.cleanup();
|
|
257
261
|
});
|
|
258
262
|
|
|
263
|
+
describe("zoom", () => {
|
|
264
|
+
beforeEach(() => {
|
|
265
|
+
vi.useFakeTimers();
|
|
266
|
+
localStorage.clear();
|
|
267
|
+
});
|
|
268
|
+
afterEach(() => {
|
|
269
|
+
vi.useRealTimers();
|
|
270
|
+
localStorage.clear();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
/** A pinch (ctrl + wheel) over the preview, then the settle that follows it. */
|
|
274
|
+
function pinchIn(view: ReturnType<typeof renderPreview>, steps: number) {
|
|
275
|
+
act(() => {
|
|
276
|
+
for (let step = 0; step < steps; step += 1) {
|
|
277
|
+
const pinch = new WheelEvent("wheel", {
|
|
278
|
+
bubbles: true,
|
|
279
|
+
cancelable: true,
|
|
280
|
+
clientX: 400,
|
|
281
|
+
clientY: 300,
|
|
282
|
+
deltaY: -10,
|
|
283
|
+
});
|
|
284
|
+
// happy-dom drops ctrlKey from the WheelEvent init; a trackpad pinch sets it.
|
|
285
|
+
Object.defineProperty(pinch, "ctrlKey", { value: true });
|
|
286
|
+
view.stage.dispatchEvent(pinch);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
act(() => vi.advanceTimersByTime(300));
|
|
290
|
+
}
|
|
291
|
+
const chip = (view: ReturnType<typeof renderPreview>) =>
|
|
292
|
+
view.host.querySelector('[data-testid="preview-zoom-chip"]');
|
|
293
|
+
const navigator = (view: ReturnType<typeof renderPreview>) =>
|
|
294
|
+
view.host.querySelector('[data-testid="preview-zoom-navigator"]');
|
|
295
|
+
|
|
296
|
+
it("labels a pan away from Fit without a zoom as panned", () => {
|
|
297
|
+
const view = renderPreview();
|
|
298
|
+
act(() => {
|
|
299
|
+
view.stage.dispatchEvent(
|
|
300
|
+
new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaX: -30, deltaY: 0 }),
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
act(() => vi.advanceTimersByTime(300));
|
|
304
|
+
expect(chip(view)?.textContent).toBe("Panned·Fit");
|
|
305
|
+
view.cleanup();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("keeps a click on Fit from reaching the pane behind it", () => {
|
|
309
|
+
const view = renderPreview();
|
|
310
|
+
// Above React's root, as the preview pane's handler is: React stops the event before either.
|
|
311
|
+
const pane = vi.fn();
|
|
312
|
+
document.body.addEventListener("pointerdown", pane);
|
|
313
|
+
pinchIn(view, 10);
|
|
314
|
+
act(() => {
|
|
315
|
+
view.host
|
|
316
|
+
.querySelector('[data-testid="preview-zoom-fit"]')!
|
|
317
|
+
.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
|
318
|
+
});
|
|
319
|
+
document.body.removeEventListener("pointerdown", pane);
|
|
320
|
+
expect(pane).not.toHaveBeenCalled();
|
|
321
|
+
view.cleanup();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("opens at Fit even when an older Studio saved a zoom", () => {
|
|
325
|
+
localStorage.setItem(
|
|
326
|
+
"hf-studio-ui-preferences",
|
|
327
|
+
JSON.stringify({ previewZoom: { zoomPercent: 245, panX: 0, panY: 0 } }),
|
|
328
|
+
);
|
|
329
|
+
const view = renderPreview();
|
|
330
|
+
expect(view.stage.style.transform).toContain("scale(1)");
|
|
331
|
+
expect(chip(view)).toBeNull();
|
|
332
|
+
view.cleanup();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("says how far it is zoomed, shows where in the frame, and Fit puts it back", () => {
|
|
336
|
+
const view = renderPreview();
|
|
337
|
+
expect([chip(view), navigator(view)]).toEqual([null, null]);
|
|
338
|
+
|
|
339
|
+
pinchIn(view, 10);
|
|
340
|
+
expect(chip(view)?.textContent).toMatch(/^Zoomed 2\d\d%·Fit$/);
|
|
341
|
+
const region = view.host.querySelector<HTMLElement>(
|
|
342
|
+
'[data-testid="preview-zoom-navigator-region"]',
|
|
343
|
+
);
|
|
344
|
+
expect(Number.parseFloat(region!.style.width)).toBeLessThan(100);
|
|
345
|
+
|
|
346
|
+
act(() => {
|
|
347
|
+
view.host.querySelector<HTMLButtonElement>('[data-testid="preview-zoom-fit"]')!.click();
|
|
348
|
+
});
|
|
349
|
+
act(() => vi.advanceTimersByTime(300));
|
|
350
|
+
expect(view.stage.style.transform).toContain("scale(1)");
|
|
351
|
+
expect([chip(view), navigator(view)]).toEqual([null, null]);
|
|
352
|
+
view.cleanup();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("keeps a zoom only while the project is open: nothing is saved, and another project opens at Fit", () => {
|
|
356
|
+
const view = renderPreview();
|
|
357
|
+
pinchIn(view, 10);
|
|
358
|
+
expect(chip(view)).not.toBeNull();
|
|
359
|
+
expect(localStorage.getItem("hf-studio-ui-preferences") ?? "").not.toContain("previewZoom");
|
|
360
|
+
|
|
361
|
+
view.openProject("another-project");
|
|
362
|
+
expect(view.stage.style.transform).toContain("scale(1)");
|
|
363
|
+
expect(chip(view)).toBeNull();
|
|
364
|
+
view.cleanup();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
259
368
|
it("insets the picture by default and fills a same-shape box when fillBox is on", () => {
|
|
260
369
|
const box = { width: 640, height: 360 };
|
|
261
370
|
const inset = renderPreview(undefined, { box });
|
|
@@ -283,6 +392,77 @@ describe("NLEPreview", () => {
|
|
|
283
392
|
view.cleanup();
|
|
284
393
|
});
|
|
285
394
|
|
|
395
|
+
it("covers the live preview with the cached frame-0 poster until it is ready to show", () => {
|
|
396
|
+
const view = renderPreview();
|
|
397
|
+
const poster = () =>
|
|
398
|
+
view.stage.querySelector<HTMLImageElement>('[data-testid="preview-poster"]');
|
|
399
|
+
expect(poster()?.getAttribute("src")).toBe(
|
|
400
|
+
"/api/projects/timeline-edit-playground/thumbnail/index.html?t=0&output=source&cached=1",
|
|
401
|
+
);
|
|
402
|
+
expect(poster()?.style.zIndex).toBe("2");
|
|
403
|
+
|
|
404
|
+
act(() => livePlayerProps.onReadyToShowChange?.(false));
|
|
405
|
+
expect(poster()?.hidden).toBe(false);
|
|
406
|
+
act(() => livePlayerProps.onReadyToShowChange?.(true));
|
|
407
|
+
expect(poster()?.hidden).toBe(true);
|
|
408
|
+
act(() => poster()?.dispatchEvent(new Event("load")));
|
|
409
|
+
expect(poster()).toBeNull();
|
|
410
|
+
view.cleanup();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe("a missing poster", () => {
|
|
414
|
+
const renderUrl =
|
|
415
|
+
"/api/projects/timeline-edit-playground/thumbnail/index.html?t=0&output=source";
|
|
416
|
+
const fetchSpy = vi.fn(() => Promise.resolve(new Response()));
|
|
417
|
+
beforeEach(() => {
|
|
418
|
+
fetchSpy.mockClear();
|
|
419
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
420
|
+
});
|
|
421
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
422
|
+
|
|
423
|
+
const settle = (
|
|
424
|
+
view: ReturnType<typeof renderPreview>,
|
|
425
|
+
steps: Array<"ready" | "missing" | "loaded">,
|
|
426
|
+
) => {
|
|
427
|
+
for (const step of steps) {
|
|
428
|
+
const poster = view.stage.querySelector('[data-testid="preview-poster"]');
|
|
429
|
+
act(() =>
|
|
430
|
+
step === "ready"
|
|
431
|
+
? livePlayerProps.onReadyToShowChange?.(true)
|
|
432
|
+
: poster?.dispatchEvent(new Event(step === "missing" ? "error" : "load")),
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
it("is rendered for the next open when the live frame is ready first", () => {
|
|
438
|
+
const view = renderPreview();
|
|
439
|
+
settle(view, ["ready", "missing"]);
|
|
440
|
+
expect(fetchSpy.mock.calls).toEqual([[renderUrl]]);
|
|
441
|
+
expect(view.stage.querySelector('[data-testid="preview-poster"]')).toBeNull();
|
|
442
|
+
view.cleanup();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("is rendered once the live frame is ready when it is missing first", () => {
|
|
446
|
+
const view = renderPreview();
|
|
447
|
+
settle(view, ["missing"]);
|
|
448
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
449
|
+
settle(view, ["ready", "ready"]);
|
|
450
|
+
expect(fetchSpy.mock.calls).toEqual([[renderUrl]]);
|
|
451
|
+
view.cleanup();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("is rendered on a return from a sub-composition when the live frame is ready first", () => {
|
|
455
|
+
const view = renderPreview();
|
|
456
|
+
settle(view, ["loaded", "ready"]);
|
|
457
|
+
view.render("/api/projects/timeline-edit-playground/preview/comp/compositions/intro.html");
|
|
458
|
+
settle(view, ["ready"]);
|
|
459
|
+
view.render();
|
|
460
|
+
settle(view, ["ready", "missing"]);
|
|
461
|
+
expect(fetchSpy.mock.calls).toEqual([[renderUrl]]);
|
|
462
|
+
view.cleanup();
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
286
466
|
it("mounts the live player once when the composition switches", () => {
|
|
287
467
|
playerMounts.length = 0;
|
|
288
468
|
const Harness = ({ projectId }: { projectId: string }) => {
|
|
@@ -2,7 +2,15 @@ import {
|
|
|
2
2
|
readPreviewCompositionSize,
|
|
3
3
|
type PreviewCompositionSize,
|
|
4
4
|
} from "../../utils/previewCompositionSize";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
memo,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useLayoutEffect,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
type RefObject,
|
|
13
|
+
} from "react";
|
|
6
14
|
import { Player } from "../../player";
|
|
7
15
|
import type { PreviewIframeSlot } from "../../player/hooks/useTimelineSyncCallbacks";
|
|
8
16
|
import {
|
|
@@ -10,6 +18,7 @@ import {
|
|
|
10
18
|
canStartPreviewPan,
|
|
11
19
|
clampPreviewPan,
|
|
12
20
|
clampPreviewZoomPercent,
|
|
21
|
+
isPreviewAtFit,
|
|
13
22
|
ownsPreviewPanTarget,
|
|
14
23
|
resolvePreviewWheelPan,
|
|
15
24
|
resolvePreviewWheelZoom,
|
|
@@ -17,8 +26,9 @@ import {
|
|
|
17
26
|
type PreviewZoomState,
|
|
18
27
|
} from "./previewZoom";
|
|
19
28
|
import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesStore";
|
|
20
|
-
import {
|
|
29
|
+
import { PreviewZoomOverlay, usePreviewNavigator } from "./PreviewZoomOverlay";
|
|
21
30
|
import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry";
|
|
31
|
+
import { PreviewPoster, usePreviewPoster } from "./PreviewPoster";
|
|
22
32
|
interface NLEPreviewProps {
|
|
23
33
|
projectId: string;
|
|
24
34
|
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
@@ -64,25 +74,6 @@ const SHADOW_IFRAME_STYLE: React.CSSProperties = {
|
|
|
64
74
|
pointerEvents: "none",
|
|
65
75
|
};
|
|
66
76
|
|
|
67
|
-
function isPreviewAtFit(state: PreviewZoomState): boolean {
|
|
68
|
-
return (
|
|
69
|
-
Math.abs(state.zoomPercent - 100) < 0.5 &&
|
|
70
|
-
Math.abs(state.panX) < 0.1 &&
|
|
71
|
-
Math.abs(state.panY) < 0.1
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function loadInitialZoom(): PreviewZoomState {
|
|
76
|
-
const stored = readStudioUiPreferences().previewZoom;
|
|
77
|
-
return stored
|
|
78
|
-
? {
|
|
79
|
-
zoomPercent: clampPreviewZoomPercent(stored.zoomPercent),
|
|
80
|
-
panX: stored.panX,
|
|
81
|
-
panY: stored.panY,
|
|
82
|
-
}
|
|
83
|
-
: DEFAULT_PREVIEW_ZOOM;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
77
|
export function resolvePreviewStageSize(
|
|
87
78
|
viewportWidth: number,
|
|
88
79
|
viewportHeight: number,
|
|
@@ -154,12 +145,13 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
154
145
|
const liveGenRef = useRef<number | null>(null);
|
|
155
146
|
const reportPreviewFirstFrame = usePreviewFirstFrameTelemetry(previewSlots);
|
|
156
147
|
const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(null);
|
|
148
|
+
const poster = usePreviewPoster(projectId, activeKey, directUrl);
|
|
157
149
|
const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
|
|
158
150
|
const insetPx = fillBox ? 0 : PREVIEW_STAGE_INSET_PX;
|
|
159
151
|
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
|
|
160
152
|
|
|
161
|
-
const zoomRef = useRef<PreviewZoomState>(
|
|
162
|
-
const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(
|
|
153
|
+
const zoomRef = useRef<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
|
|
154
|
+
const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
|
|
163
155
|
const hudRef = useRef<HTMLDivElement>(null);
|
|
164
156
|
const hudTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
165
157
|
const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -230,14 +222,33 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
230
222
|
const stageSizeRef = useRef(stageSize);
|
|
231
223
|
stageSizeRef.current = stageSize;
|
|
232
224
|
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
225
|
+
const { draw: drawNavigator, setRegion: setNavigatorRegion } = usePreviewNavigator(
|
|
226
|
+
viewportRef,
|
|
227
|
+
stageSize,
|
|
228
|
+
zoomRef,
|
|
229
|
+
);
|
|
230
|
+
const writeTransform = useCallback(
|
|
231
|
+
(state: PreviewZoomState) => {
|
|
232
|
+
const stage = stageRef.current;
|
|
233
|
+
if (!stage) return;
|
|
234
|
+
const s = toDomPrecision(state.zoomPercent / 100);
|
|
235
|
+
const px = toDomPrecision(state.panX);
|
|
236
|
+
const py = toDomPrecision(state.panY);
|
|
237
|
+
stage.style.transform = `translate3d(${px}px, ${py}px, 0) scale(${s})`;
|
|
238
|
+
drawNavigator(state);
|
|
239
|
+
},
|
|
240
|
+
[drawNavigator],
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const zoomProjectRef = useRef(projectId);
|
|
244
|
+
// Before paint, so the next project never shows a frame at the previous one's zoom.
|
|
245
|
+
useLayoutEffect(() => {
|
|
246
|
+
if (zoomProjectRef.current === projectId) return;
|
|
247
|
+
zoomProjectRef.current = projectId;
|
|
248
|
+
zoomRef.current = DEFAULT_PREVIEW_ZOOM;
|
|
249
|
+
writeTransform(DEFAULT_PREVIEW_ZOOM);
|
|
250
|
+
setSettledZoom(DEFAULT_PREVIEW_ZOOM);
|
|
251
|
+
}, [projectId, writeTransform]);
|
|
241
252
|
|
|
242
253
|
const applyTransform = useCallback(
|
|
243
254
|
(next: PreviewZoomState, showHud: boolean) => {
|
|
@@ -267,7 +278,6 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
267
278
|
settleTimerRef.current = setTimeout(() => {
|
|
268
279
|
zoomingRef.current = false;
|
|
269
280
|
const final = zoomRef.current;
|
|
270
|
-
writeStudioUiPreferences({ previewZoom: final });
|
|
271
281
|
setSettledZoom((prev) =>
|
|
272
282
|
prev.zoomPercent === final.zoomPercent &&
|
|
273
283
|
prev.panX === final.panX &&
|
|
@@ -302,9 +312,8 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
302
312
|
|
|
303
313
|
const applyInitialZoom = useCallback(() => {
|
|
304
314
|
const z = zoomRef.current;
|
|
305
|
-
if (
|
|
306
|
-
// A
|
|
307
|
-
// off-screen in a smaller one; clamp against the current viewport first.
|
|
315
|
+
if (!isPreviewAtFit(z)) {
|
|
316
|
+
// A composition reload can bring a different frame size than the pan was made on; clamp first.
|
|
308
317
|
const viewport = viewportRef.current;
|
|
309
318
|
const rect = viewport?.getBoundingClientRect();
|
|
310
319
|
const sz = stageSizeRef.current;
|
|
@@ -522,6 +531,8 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
522
531
|
applyInitialZoom();
|
|
523
532
|
}}
|
|
524
533
|
onCompositionLoadingChange={onCompositionLoadingChange}
|
|
534
|
+
onReadyToShowChange={poster.onLiveReadyToShowChange}
|
|
535
|
+
onPreviewError={poster.onPreviewError}
|
|
525
536
|
onPainted={(details) => reportPreviewFirstFrame(slot, details)}
|
|
526
537
|
portrait={portrait}
|
|
527
538
|
suppressLoadingOverlay={suppressLoadingOverlay}
|
|
@@ -547,6 +558,16 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
547
558
|
/>
|
|
548
559
|
),
|
|
549
560
|
)}
|
|
561
|
+
{poster.mountPoster && (
|
|
562
|
+
<PreviewPoster
|
|
563
|
+
key={activeKey}
|
|
564
|
+
projectId={projectId}
|
|
565
|
+
hidden={poster.hidePoster}
|
|
566
|
+
onSize={(size) => setCompositionSize((prev) => prev ?? size)}
|
|
567
|
+
onLoaded={poster.onPosterLoaded}
|
|
568
|
+
onMissing={poster.onPosterMissing}
|
|
569
|
+
/>
|
|
570
|
+
)}
|
|
550
571
|
</div>
|
|
551
572
|
</div>
|
|
552
573
|
<div
|
|
@@ -555,17 +576,15 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
555
576
|
style={{ opacity: 0, transition: "opacity 200ms ease-in" }}
|
|
556
577
|
aria-live="polite"
|
|
557
578
|
/>
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
</button>
|
|
568
|
-
)}
|
|
579
|
+
<PreviewZoomOverlay
|
|
580
|
+
zoom={settledZoom}
|
|
581
|
+
stageSize={stageSize}
|
|
582
|
+
onFit={() => {
|
|
583
|
+
applyZoom(DEFAULT_PREVIEW_ZOOM);
|
|
584
|
+
viewportRef.current?.focus();
|
|
585
|
+
}}
|
|
586
|
+
navigatorRegionRef={setNavigatorRegion}
|
|
587
|
+
/>
|
|
569
588
|
</div>
|
|
570
589
|
</div>
|
|
571
590
|
);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
3
|
+
import type { PreviewCompositionSize } from "../../utils/previewCompositionSize";
|
|
4
|
+
|
|
5
|
+
/** Frame 0 as the thumbnail route last rendered it; `cachedOnly` never starts a render. */
|
|
6
|
+
function previewPosterUrl(projectId: string, cachedOnly: boolean): string {
|
|
7
|
+
const query = `t=0&output=source${cachedOnly ? "&cached=1" : ""}`;
|
|
8
|
+
return buildProjectApiPath(projectId, `/thumbnail/index.html?${query}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const PREVIEW_POSTER_STYLE: React.CSSProperties = {
|
|
12
|
+
position: "absolute",
|
|
13
|
+
inset: 0,
|
|
14
|
+
width: "100%",
|
|
15
|
+
height: "100%",
|
|
16
|
+
objectFit: "contain",
|
|
17
|
+
zIndex: 2,
|
|
18
|
+
pointerEvents: "none",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Covers the live preview with the cached frame 0; reports its size so the stage fits it. */
|
|
22
|
+
export function PreviewPoster({
|
|
23
|
+
projectId,
|
|
24
|
+
hidden,
|
|
25
|
+
onSize,
|
|
26
|
+
onLoaded,
|
|
27
|
+
onMissing,
|
|
28
|
+
}: {
|
|
29
|
+
projectId: string;
|
|
30
|
+
hidden: boolean;
|
|
31
|
+
onSize: (size: PreviewCompositionSize) => void;
|
|
32
|
+
onLoaded: () => void;
|
|
33
|
+
onMissing: () => void;
|
|
34
|
+
}) {
|
|
35
|
+
return (
|
|
36
|
+
<img
|
|
37
|
+
src={previewPosterUrl(projectId, true)}
|
|
38
|
+
alt=""
|
|
39
|
+
aria-hidden
|
|
40
|
+
data-testid="preview-poster"
|
|
41
|
+
hidden={hidden}
|
|
42
|
+
style={PREVIEW_POSTER_STYLE}
|
|
43
|
+
onLoad={(event) => {
|
|
44
|
+
const { naturalWidth: width, naturalHeight: height } = event.currentTarget;
|
|
45
|
+
if (!hidden && width > 0 && height > 0) onSize({ width, height });
|
|
46
|
+
onLoaded();
|
|
47
|
+
}}
|
|
48
|
+
onError={onMissing}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The poster covers the live slot until its first frame is ready; a missing poster is rendered
|
|
54
|
+
* after both are known, in either order, off the open path, for the next open. */
|
|
55
|
+
export function usePreviewPoster(
|
|
56
|
+
projectId: string,
|
|
57
|
+
activeKey: string,
|
|
58
|
+
directUrl: string | undefined,
|
|
59
|
+
) {
|
|
60
|
+
const visit = useVisitNumber(activeKey);
|
|
61
|
+
const [coverDoneFor, setCoverDoneFor] = useState<number | null>(null);
|
|
62
|
+
const [posterSettledFor, setPosterSettledFor] = useState<number | null>(null);
|
|
63
|
+
const missingForRef = useRef<number | null>(null);
|
|
64
|
+
const liveReadyForRef = useRef<number | null>(null);
|
|
65
|
+
const renderMissingPoster = useCallback(() => {
|
|
66
|
+
if (missingForRef.current !== visit || liveReadyForRef.current !== visit) return;
|
|
67
|
+
missingForRef.current = null;
|
|
68
|
+
void fetch(previewPosterUrl(projectId, false)).catch(() => {});
|
|
69
|
+
}, [visit, projectId]);
|
|
70
|
+
const onLiveReadyToShowChange = useCallback(
|
|
71
|
+
(ready: boolean) => {
|
|
72
|
+
if (!ready) return;
|
|
73
|
+
setCoverDoneFor(visit);
|
|
74
|
+
liveReadyForRef.current = visit;
|
|
75
|
+
renderMissingPoster();
|
|
76
|
+
},
|
|
77
|
+
[visit, renderMissingPoster],
|
|
78
|
+
);
|
|
79
|
+
return {
|
|
80
|
+
mountPoster: !directUrl && (coverDoneFor !== visit || posterSettledFor !== visit),
|
|
81
|
+
hidePoster: coverDoneFor === visit,
|
|
82
|
+
onLiveReadyToShowChange,
|
|
83
|
+
onPreviewError: () => setCoverDoneFor(visit),
|
|
84
|
+
onPosterLoaded: () => setPosterSettledFor(visit),
|
|
85
|
+
onPosterMissing: () => {
|
|
86
|
+
setCoverDoneFor(visit);
|
|
87
|
+
setPosterSettledFor(visit);
|
|
88
|
+
missingForRef.current = visit;
|
|
89
|
+
renderMissingPoster();
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function useVisitNumber(key: string): number {
|
|
95
|
+
const [seen, setSeen] = useState({ key, visit: 0 });
|
|
96
|
+
if (seen.key === key) return seen.visit;
|
|
97
|
+
setSeen({ key, visit: seen.visit + 1 });
|
|
98
|
+
return seen.visit + 1;
|
|
99
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, type RefObject } from "react";
|
|
2
|
+
import {
|
|
3
|
+
isFitZoom,
|
|
4
|
+
isPreviewAtFit,
|
|
5
|
+
resolvePreviewVisibleRegion,
|
|
6
|
+
toDomPrecision,
|
|
7
|
+
type PreviewZoomState,
|
|
8
|
+
} from "./previewZoom";
|
|
9
|
+
|
|
10
|
+
const NAVIGATOR_PX = 112;
|
|
11
|
+
|
|
12
|
+
function zoomChipLabel(zoomPercent: number): string {
|
|
13
|
+
return isFitZoom(zoomPercent) ? "Panned" : `Zoomed ${Math.round(zoomPercent)}%`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function navigatorFrameSize(stage: { width: number; height: number }) {
|
|
17
|
+
const ratio = stage.width > 0 && stage.height > 0 ? stage.width / stage.height : 16 / 9;
|
|
18
|
+
return ratio >= 1
|
|
19
|
+
? { width: NAVIGATOR_PX, height: toDomPrecision(NAVIGATOR_PX / ratio) }
|
|
20
|
+
: { width: toDomPrecision(NAVIGATOR_PX * ratio), height: NAVIGATOR_PX };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `draw` moves the navigator's highlight without a render, so it can follow every transform write. */
|
|
24
|
+
export function usePreviewNavigator(
|
|
25
|
+
viewportRef: RefObject<HTMLDivElement | null>,
|
|
26
|
+
stageSize: { width: number; height: number },
|
|
27
|
+
zoomRef: RefObject<PreviewZoomState>,
|
|
28
|
+
) {
|
|
29
|
+
const regionRef = useRef<HTMLDivElement | null>(null);
|
|
30
|
+
const stageSizeRef = useRef(stageSize);
|
|
31
|
+
stageSizeRef.current = stageSize;
|
|
32
|
+
const draw = useCallback(
|
|
33
|
+
(state: PreviewZoomState) => {
|
|
34
|
+
const region = regionRef.current;
|
|
35
|
+
const rect = viewportRef.current?.getBoundingClientRect();
|
|
36
|
+
if (!region || !rect) return;
|
|
37
|
+
const visible = resolvePreviewVisibleRegion({
|
|
38
|
+
state,
|
|
39
|
+
viewportWidth: rect.width,
|
|
40
|
+
viewportHeight: rect.height,
|
|
41
|
+
contentWidth: stageSizeRef.current.width,
|
|
42
|
+
contentHeight: stageSizeRef.current.height,
|
|
43
|
+
});
|
|
44
|
+
region.style.left = `${visible.left * 100}%`;
|
|
45
|
+
region.style.top = `${visible.top * 100}%`;
|
|
46
|
+
region.style.width = `${visible.width * 100}%`;
|
|
47
|
+
region.style.height = `${visible.height * 100}%`;
|
|
48
|
+
},
|
|
49
|
+
[viewportRef],
|
|
50
|
+
);
|
|
51
|
+
const setRegion = useCallback(
|
|
52
|
+
(node: HTMLDivElement | null) => {
|
|
53
|
+
regionRef.current = node;
|
|
54
|
+
draw(zoomRef.current);
|
|
55
|
+
},
|
|
56
|
+
[draw, zoomRef],
|
|
57
|
+
);
|
|
58
|
+
useEffect(() => draw(zoomRef.current), [stageSize, draw, zoomRef]);
|
|
59
|
+
return { draw, setRegion };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function PreviewZoomOverlay({
|
|
63
|
+
zoom,
|
|
64
|
+
stageSize,
|
|
65
|
+
onFit,
|
|
66
|
+
navigatorRegionRef,
|
|
67
|
+
}: {
|
|
68
|
+
zoom: PreviewZoomState;
|
|
69
|
+
stageSize: { width: number; height: number };
|
|
70
|
+
onFit: () => void;
|
|
71
|
+
navigatorRegionRef: (node: HTMLDivElement | null) => void;
|
|
72
|
+
}) {
|
|
73
|
+
if (isPreviewAtFit(zoom)) return null;
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
<div
|
|
77
|
+
className="absolute top-3 left-1/2 -translate-x-1/2 z-50 flex items-center gap-1.5 rounded-md py-1 pl-2.5 pr-1 text-xs text-white/80 bg-black/60 backdrop-blur-xs"
|
|
78
|
+
data-testid="preview-zoom-chip"
|
|
79
|
+
// The pane clears the timeline selection on a pointerdown outside the frame.
|
|
80
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
81
|
+
>
|
|
82
|
+
<span className="tabular-nums">{zoomChipLabel(zoom.zoomPercent)}</span>
|
|
83
|
+
<span aria-hidden="true" className="text-white/30">
|
|
84
|
+
·
|
|
85
|
+
</span>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
className="rounded px-1.5 py-0.5 font-medium text-studio-accent hover:bg-white/10 transition-colors"
|
|
89
|
+
onClick={onFit}
|
|
90
|
+
aria-label="Fit the whole frame in view"
|
|
91
|
+
data-testid="preview-zoom-fit"
|
|
92
|
+
>
|
|
93
|
+
Fit
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
<div
|
|
97
|
+
aria-hidden="true"
|
|
98
|
+
className="pointer-events-none absolute bottom-3 right-3 z-50 rounded-md p-1.5 bg-black/60 backdrop-blur-xs"
|
|
99
|
+
data-testid="preview-zoom-navigator"
|
|
100
|
+
>
|
|
101
|
+
<div className="relative overflow-hidden bg-white/10" style={navigatorFrameSize(stageSize)}>
|
|
102
|
+
<div
|
|
103
|
+
ref={navigatorRegionRef}
|
|
104
|
+
className="absolute rounded-[1px] border border-studio-accent bg-studio-accent/15"
|
|
105
|
+
data-testid="preview-zoom-navigator-region"
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</>
|
|
110
|
+
);
|
|
111
|
+
}
|