@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.
Files changed (130) hide show
  1. package/dist/assets/hyperframes-player-BSDTJ9EF.js +464 -0
  2. package/dist/assets/{index-BlUYw1MB.js → index-2GgHjQq-.js} +163 -163
  3. package/dist/assets/{index-0w6iIlwZ.css → index-BWJqxeaH.css} +1 -1
  4. package/dist/assets/{index-BiFKcNjs.js → index-CsFCRdMo.js} +1 -1
  5. package/dist/assets/{index-HjexPsny.js → index-DcxWQ7Za.js} +1 -1
  6. package/dist/{chunk-7BP32G62.js → chunk-5VANA2TG.js} +12 -1
  7. package/dist/chunk-5VANA2TG.js.map +1 -0
  8. package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-IGPLTI5E.js} +4 -2
  9. package/dist/index.d.ts +34 -69
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +5287 -5277
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +7 -7
  15. package/src/components/StudioRightPanels.types.ts +0 -2
  16. package/src/components/editor/DomEditOverlay.test.ts +5 -0
  17. package/src/components/editor/LayersPanel.tsx +3 -14
  18. package/src/components/editor/PreviewOverlayProvider.test.tsx +6 -1
  19. package/src/components/editor/domEditing.ts +1 -0
  20. package/src/components/editor/domEditingElement.ts +13 -0
  21. package/src/components/editor/domEditingLayers.test.ts +27 -0
  22. package/src/components/editor/domEditingLayers.ts +9 -0
  23. package/src/components/editor/gsapLivePreview.test.ts +63 -0
  24. package/src/components/editor/gsapLivePreview.ts +2 -14
  25. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +57 -35
  26. package/src/components/editor/overlayFrameLoop.test.ts +16 -0
  27. package/src/components/editor/overlayFrameLoop.ts +11 -0
  28. package/src/components/editor/propertyPanelHelpers.ts +4 -6
  29. package/src/components/editor/usePreviewCompositionRect.test.tsx +6 -1
  30. package/src/components/nle/NLEPreview.test.ts +202 -22
  31. package/src/components/nle/NLEPreview.tsx +65 -46
  32. package/src/components/nle/PreviewPoster.tsx +99 -0
  33. package/src/components/nle/PreviewZoomOverlay.tsx +111 -0
  34. package/src/components/nle/previewZoom.test.ts +42 -0
  35. package/src/components/nle/previewZoom.ts +33 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +30 -49
  37. package/src/components/panels/VariablesPanel.tsx +0 -2
  38. package/src/components/sidebar/CompositionsTab.drag.test.tsx +5 -1
  39. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +14 -0
  40. package/src/components/sidebar/CompositionsTab.test.ts +4 -6
  41. package/src/components/sidebar/CompositionsTab.tsx +28 -21
  42. package/src/components/studioColorGradingScope.ts +0 -3
  43. package/src/hooks/domEditDeleteMembers.ts +0 -2
  44. package/src/hooks/gsapScriptCommitTypes.ts +0 -2
  45. package/src/hooks/keyframeCacheAstLoad.test.ts +31 -1
  46. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  47. package/src/hooks/timelineAudioGroupCreate.ts +0 -1
  48. package/src/hooks/timelineEditingHelpers.ts +0 -3
  49. package/src/hooks/timelineTimingSync.ts +0 -1
  50. package/src/hooks/timelineTrackVisibility.ts +0 -1
  51. package/src/hooks/useBlockHandlers.ts +0 -2
  52. package/src/hooks/useClipboard.test.ts +69 -10
  53. package/src/hooks/useClipboard.ts +28 -19
  54. package/src/hooks/useDomEditCommits.test.tsx +0 -1
  55. package/src/hooks/useDomEditCommits.ts +0 -4
  56. package/src/hooks/useDomEditWiring.ts +1 -3
  57. package/src/hooks/useEditHistoryActions.test.tsx +31 -14
  58. package/src/hooks/useEditHistoryActions.ts +12 -7
  59. package/src/hooks/useEditorSave.ts +0 -3
  60. package/src/hooks/useElementLifecycleOps.ts +0 -1
  61. package/src/hooks/useEnableKeyframes.test.ts +9 -1
  62. package/src/hooks/useFileManager.ts +0 -2
  63. package/src/hooks/useGestureRecording.test.tsx +1 -1
  64. package/src/hooks/useGestureRecording.ts +6 -10
  65. package/src/hooks/useGroupCommits.ts +0 -1
  66. package/src/hooks/useGsapScriptCommits.ts +0 -1
  67. package/src/hooks/useGsapTweenCache.element.test.tsx +39 -0
  68. package/src/hooks/useGsapTweenCache.ts +5 -13
  69. package/src/hooks/useLintModal.test.tsx +34 -1
  70. package/src/hooks/useLintModal.ts +32 -30
  71. package/src/hooks/usePersistentEditHistory.test.ts +221 -315
  72. package/src/hooks/usePersistentEditHistory.ts +155 -342
  73. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +2 -0
  74. package/src/hooks/usePreviewPersistence.ts +0 -2
  75. package/src/hooks/useProjectCompositionVariables.ts +1 -3
  76. package/src/hooks/useRazorSplit.history.test.tsx +46 -67
  77. package/src/hooks/useRazorSplit.test.ts +17 -47
  78. package/src/hooks/useRenderClipContent.test.ts +29 -0
  79. package/src/hooks/useRenderClipContent.ts +6 -3
  80. package/src/hooks/useSdkSession.lifecycle.test.tsx +34 -4
  81. package/src/hooks/useSdkSession.ts +8 -2
  82. package/src/hooks/useSlideshowPersist.ts +0 -2
  83. package/src/hooks/useStudioContextValue.ts +6 -1
  84. package/src/hooks/useTimelineAssetDropOps.ts +0 -1
  85. package/src/hooks/useTimelineDeleteOps.ts +0 -1
  86. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  87. package/src/hooks/useTimelineEditingTypes.ts +0 -2
  88. package/src/hostMount.test.tsx +53 -22
  89. package/src/main.tsx +3 -0
  90. package/src/player/components/CompositionThumbnail.tsx +14 -0
  91. package/src/player/components/Player.test.ts +49 -0
  92. package/src/player/components/Player.tsx +21 -7
  93. package/src/player/components/timelineClipDragCommit.test.ts +7 -39
  94. package/src/player/hooks/timelineDomBinding.test.ts +50 -0
  95. package/src/player/hooks/timelineSyncHydration.test.ts +25 -1
  96. package/src/player/hooks/timelineSyncHydration.ts +3 -3
  97. package/src/player/hooks/useShadowPreviewReload.ts +1 -0
  98. package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
  99. package/src/player/hooks/useTimelinePlayer.ts +1 -0
  100. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  101. package/src/player/lib/automationStoreSync.test.ts +24 -0
  102. package/src/player/lib/automationStoreSync.ts +3 -9
  103. package/src/player/lib/playbackScrub.ts +1 -1
  104. package/src/player/lib/timelineDOM.test.ts +28 -0
  105. package/src/player/lib/timelineDOM.ts +4 -2
  106. package/src/player/lib/timelineElementHelpers.ts +78 -9
  107. package/src/player/lib/timelineIframeHelpers.test.ts +49 -5
  108. package/src/player/lib/timelineIframeHelpers.ts +13 -12
  109. package/src/player/store/playerStore.test.ts +35 -1
  110. package/src/player/store/playerStore.ts +29 -0
  111. package/src/player/store/readinessSlice.ts +34 -6
  112. package/src/utils/blockInstaller.ts +0 -3
  113. package/src/utils/previewPrefetch.test.ts +22 -0
  114. package/src/utils/previewPrefetch.ts +14 -0
  115. package/src/utils/razorSplitTransaction.test.ts +0 -1
  116. package/src/utils/razorSplitTransaction.ts +1 -1
  117. package/src/utils/sdkEditTransaction.ts +0 -3
  118. package/src/utils/studioFileHistory.test.ts +0 -9
  119. package/src/utils/studioFileHistory.ts +1 -5
  120. package/src/utils/studioUiPreferences.test.ts +9 -3
  121. package/src/utils/studioUiPreferences.ts +0 -20
  122. package/src/utils/timelineCompositionInsert.ts +0 -1
  123. package/dist/assets/hyperframes-player-WbBVbWuO.js +0 -464
  124. package/dist/chunk-7BP32G62.js.map +0 -1
  125. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +0 -60
  126. package/src/utils/editHistory.test.ts +0 -534
  127. package/src/utils/editHistory.ts +0 -236
  128. package/src/utils/editHistoryStorage.test.ts +0 -37
  129. package/src/utils/editHistoryStorage.ts +0 -99
  130. /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
- act(() => {
95
- root.render(
96
- React.createElement(NLEPreview, {
97
- projectId: "timeline-edit-playground",
98
- iframeRef,
99
- onIframeLoad: () => {},
100
- previewSlots,
101
- onShadowIframeLoad: () => {},
102
- onShadowReadyChange: () => {},
103
- onShadowError: () => {},
104
- setShadowIframeNode: () => {},
105
- resetPreviewSlots: () => {},
106
- fillBox,
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 { memo, useCallback, useEffect, useRef, useState, type RefObject } from "react";
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 { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
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>(loadInitialZoom());
162
- const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(() => zoomRef.current);
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 writeTransform = useCallback((state: PreviewZoomState) => {
234
- const stage = stageRef.current;
235
- if (!stage) return;
236
- const s = toDomPrecision(state.zoomPercent / 100);
237
- const px = toDomPrecision(state.panX);
238
- const py = toDomPrecision(state.panY);
239
- stage.style.transform = `translate3d(${px}px, ${py}px, 0) scale(${s})`;
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 (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
306
- // A pan persisted on a large window can restore the composition mostly
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
- {!isPreviewAtFit(settledZoom) && (
559
- <button
560
- type="button"
561
- className="absolute bottom-3 right-3 z-50 rounded-md px-2.5 py-1 text-xs font-medium text-white/80 bg-black/50 backdrop-blur-xs hover:bg-black/70 hover:text-white transition-colors"
562
- onClick={() => applyZoom(DEFAULT_PREVIEW_ZOOM)}
563
- aria-label="Reset zoom to fit"
564
- data-testid="preview-reset-zoom"
565
- >
566
- {Math.round(settledZoom.zoomPercent)}% — Reset
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
+ }