@hyperframes/studio 0.8.56 → 0.8.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/assets/hyperframes-player-c23wnIfc.js +464 -0
  2. package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
  3. package/dist/assets/{index-DFlILHJe.js → index-DgkAzx3n.js} +1 -1
  4. package/dist/assets/{index-CxR7F2YQ.js → index-IDNr3X1H.js} +1 -1
  5. package/dist/assets/{index-BlkJ_Axe.js → index-gbOCDCuZ.js} +194 -194
  6. package/dist/index.d.ts +95 -9
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +15573 -14835
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +4 -0
  13. package/src/components/EditorShell.tsx +3 -1
  14. package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
  15. package/src/components/ProjectUnreachableBanner.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +0 -15
  17. package/src/components/editor/GridOverlay.tsx +12 -23
  18. package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +3 -1
  20. package/src/components/editor/PreviewGuides.test.tsx +6 -1
  21. package/src/components/editor/PreviewGuides.tsx +8 -10
  22. package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
  23. package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
  24. package/src/components/editor/SnapToolbar.test.tsx +25 -12
  25. package/src/components/editor/SnapToolbar.tsx +30 -59
  26. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
  27. package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
  28. package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
  29. package/src/components/editor/usePreviewCompositionRect.ts +12 -12
  30. package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
  31. package/src/components/editor/useVolumeAutomation.ts +57 -16
  32. package/src/components/nle/NLEContext.tsx +40 -4
  33. package/src/components/nle/NLEPreview.test.ts +66 -1
  34. package/src/components/nle/NLEPreview.tsx +88 -44
  35. package/src/components/nle/PreviewOverlays.tsx +4 -16
  36. package/src/components/nle/PreviewPane.tsx +17 -2
  37. package/src/components/nle/previewReadOnly.test.tsx +1 -0
  38. package/src/hooks/useCompositionDimensions.test.tsx +38 -0
  39. package/src/hooks/useCompositionDimensions.ts +17 -1
  40. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  41. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  42. package/src/hooks/useFileTree.test.tsx +56 -0
  43. package/src/hooks/useFileTree.ts +8 -1
  44. package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
  45. package/src/hooks/useSdkSession.ts +46 -10
  46. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  47. package/src/hooks/useServerConnection.ts +81 -8
  48. package/src/index.ts +12 -0
  49. package/src/player/components/Player.test.ts +141 -13
  50. package/src/player/components/Player.tsx +58 -2
  51. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  52. package/src/player/components/Timeline.tsx +23 -572
  53. package/src/player/components/TimelineCanvas.tsx +9 -30
  54. package/src/player/components/TimelineOverlays.test.ts +67 -32
  55. package/src/player/components/TimelineOverlays.tsx +46 -107
  56. package/src/player/components/TimelineProvider.tsx +191 -0
  57. package/src/player/components/testRootHarness.ts +18 -0
  58. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  59. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  60. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  61. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  62. package/src/player/components/useTimelineProviderState.tsx +583 -0
  63. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  64. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  65. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  66. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  67. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  68. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  69. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  70. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -88
  71. package/src/telemetry/events.test.ts +18 -0
  72. package/src/telemetry/events.ts +10 -0
  73. package/src/telemetry/system.test.ts +49 -0
  74. package/src/telemetry/system.ts +6 -0
  75. package/src/telemetry/tabId.test.ts +43 -0
  76. package/src/telemetry/tabId.ts +44 -0
  77. package/src/utils/previewCompositionSize.ts +26 -0
  78. package/src/utils/reloadDebug.ts +2 -4
  79. package/src/utils/sourcePatcher.ts +1 -1
  80. package/src/utils/studioTelemetry.test.ts +14 -0
  81. package/src/utils/studioTelemetry.ts +4 -0
  82. package/dist/assets/hyperframes-player-k8LzMtXX.js +0 -464
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.56",
3
+ "version": "0.8.57",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,11 +51,11 @@
51
51
  "dockview-react": "8.3.1",
52
52
  "gsap": "^3.13.0",
53
53
  "mediabunny": "^1.45.3",
54
- "@hyperframes/player": "0.8.56",
55
- "@hyperframes/sdk": "0.8.56",
56
- "@hyperframes/core": "0.8.56",
57
- "@hyperframes/studio-server": "0.8.56",
58
- "@hyperframes/parsers": "0.8.56"
54
+ "@hyperframes/core": "0.8.57",
55
+ "@hyperframes/player": "0.8.57",
56
+ "@hyperframes/parsers": "0.8.57",
57
+ "@hyperframes/sdk": "0.8.57",
58
+ "@hyperframes/studio-server": "0.8.57"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@tailwindcss/vite": "^4.3.3",
@@ -71,7 +71,7 @@
71
71
  "vite": "^6.4.2",
72
72
  "vitest": "^4.1.11",
73
73
  "zustand": "^5.0.0",
74
- "@hyperframes/producer": "0.8.56"
74
+ "@hyperframes/producer": "0.8.57"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "react": "19",
package/src/App.tsx CHANGED
@@ -7,6 +7,7 @@ import { usePlayerStore } from "./player";
7
7
  import { StudioOverlays } from "./components/StudioOverlays";
8
8
  import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
9
9
  import { ExternalFileConflictBanner } from "./components/ExternalFileConflictBanner";
10
+ import { ProjectUnreachableBanner } from "./components/ProjectUnreachableBanner";
10
11
  import { useCaptionStore } from "./captions/store";
11
12
  import { useCaptionSync } from "./captions/hooks/useCaptionSync";
12
13
  import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
@@ -475,6 +476,9 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
475
476
  />
476
477
  )}
477
478
  <ExternalFileConflictBanner coordinator={externalFileChanges} />
479
+ {sdkHandle.unreachableProject && (
480
+ <ProjectUnreachableBanner projectId={sdkHandle.unreachableProject} />
481
+ )}
478
482
  <EditorShell
479
483
  readOnlyPreview={readOnlyPreview}
480
484
  readOnlyPreviewReason={readOnlyPreviewReason}
@@ -172,6 +172,7 @@ export function EditorShell({
172
172
  activeCompositionPath={activeCompPath}
173
173
  onIframeRef={handlePreviewIframeRef}
174
174
  onCompIdToSrcChange={setCompIdToSrc}
175
+ onPreviewReloadFailed={(message) => showToast(message, "error")}
175
176
  onCompositionLoadingChange={setCompositionLoading}
176
177
  onCompositionChange={(compPath) => {
177
178
  // Sync activeCompPath when the user drills down via the timeline or
@@ -264,7 +265,7 @@ function EditorShellBody({
264
265
  onDuplicateClip,
265
266
  canPasteClip,
266
267
  }: EditorShellBodyProps) {
267
- const { compositionStack, updateCompositionStack } = useNLEContext();
268
+ const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
268
269
 
269
270
  // The caption track's blocks are seek targets; CaptionTimeline took an onSeek
270
271
  // prop that nothing ever passed, so clicking a block did nothing.
@@ -284,6 +285,7 @@ function EditorShellBody({
284
285
 
285
286
  return (
286
287
  <div
288
+ ref={containerRef}
287
289
  // Shell canvas is a step LIGHTER than the near-black panel cards so the
288
290
  // gaps between panels read as visible seams (CapCut-style).
289
291
  className="flex flex-col flex-1 min-h-0 bg-panel-surface"
@@ -0,0 +1,113 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { ProjectUnreachableBanner } from "./ProjectUnreachableBanner";
6
+
7
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8
+
9
+ let root: Root | null = null;
10
+
11
+ afterEach(() => {
12
+ if (root) act(() => root?.unmount());
13
+ root = null;
14
+ document.body.replaceChildren();
15
+ vi.restoreAllMocks();
16
+ vi.unstubAllGlobals();
17
+ });
18
+
19
+ function stubProjects(projects: unknown): void {
20
+ vi.stubGlobal(
21
+ "fetch",
22
+ vi.fn(async () => new Response(JSON.stringify({ projects }), { status: 200 })),
23
+ );
24
+ }
25
+
26
+ async function renderBanner(projectId: string): Promise<string> {
27
+ const host = document.createElement("div");
28
+ document.body.append(host);
29
+ root = createRoot(host);
30
+ await act(async () => {
31
+ root?.render(<ProjectUnreachableBanner projectId={projectId} />);
32
+ });
33
+ await act(async () => {
34
+ await new Promise((resolve) => setTimeout(resolve, 0));
35
+ });
36
+ return host.textContent ?? "";
37
+ }
38
+
39
+ describe("ProjectUnreachableBanner", () => {
40
+ // The production case: the CLI host serves exactly one project, and it is not
41
+ // this tab's. Naming both sides is the whole point — the user needs to know
42
+ // their project still exists and which one this Studio actually opened.
43
+ it("names both projects when this Studio serves a different one", async () => {
44
+ stubProjects([{ id: "out-video1", title: "out-video1" }]);
45
+ const text = await renderBanner("out-fb1");
46
+
47
+ expect(text).toContain("out-video1");
48
+ expect(text).toContain("out-fb1");
49
+ expect(text).toContain("This Studio is serving");
50
+ expect(text).not.toContain("renamed");
51
+ });
52
+
53
+ // With several projects a 404 cannot be told apart from a rename or a
54
+ // deletion, so the banner must not claim a cause. "Could be gone" is the
55
+ // honest ceiling, and it is a weaker claim than "is gone".
56
+ it("does not claim a cause when several projects are served", async () => {
57
+ stubProjects([
58
+ { id: "a", title: "a" },
59
+ { id: "b", title: "b" },
60
+ ]);
61
+ const text = await renderBanner("out-fb1");
62
+
63
+ expect(text).toContain("renamed, moved, or deleted");
64
+ expect(text).not.toContain("This Studio is serving");
65
+ });
66
+
67
+ // A server that answers with this tab's own project while the read 404s is
68
+ // not the stale-tab case, so the single-project wording would be false.
69
+ it("falls back to the general wording when the served project is this one", async () => {
70
+ stubProjects([{ id: "out-fb1", title: "out-fb1" }]);
71
+ const text = await renderBanner("out-fb1");
72
+
73
+ expect(text).toContain("renamed, moved, or deleted");
74
+ expect(text).not.toContain("This Studio is serving");
75
+ });
76
+
77
+ it("says nothing it cannot back up when the project list cannot be read", async () => {
78
+ vi.stubGlobal(
79
+ "fetch",
80
+ vi.fn(async () => new Response("boom", { status: 500 })),
81
+ );
82
+ const text = await renderBanner("out-fb1");
83
+
84
+ expect(text).toContain("renamed, moved, or deleted");
85
+ expect(text).not.toContain("This Studio is serving");
86
+ });
87
+
88
+ it("renders nothing until the project list resolves", async () => {
89
+ let resolveList: ((value: Response) => void) | undefined;
90
+ vi.stubGlobal(
91
+ "fetch",
92
+ vi.fn(
93
+ () =>
94
+ new Promise<Response>((resolve) => {
95
+ resolveList = resolve;
96
+ }),
97
+ ),
98
+ );
99
+ const host = document.createElement("div");
100
+ document.body.append(host);
101
+ root = createRoot(host);
102
+ await act(async () => {
103
+ root?.render(<ProjectUnreachableBanner projectId="out-fb1" />);
104
+ });
105
+ expect(host.textContent).toBe("");
106
+
107
+ await act(async () => {
108
+ resolveList?.(new Response(JSON.stringify({ projects: [{ id: "other" }] }), { status: 200 }));
109
+ await new Promise((resolve) => setTimeout(resolve, 0));
110
+ });
111
+ expect(host.textContent).toContain("This Studio is serving");
112
+ });
113
+ });
@@ -0,0 +1,89 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ interface ServedProject {
4
+ id: string;
5
+ title?: string;
6
+ }
7
+
8
+ interface ProjectUnreachableBannerProps {
9
+ /** The project this tab was opened for and the server cannot resolve. */
10
+ projectId: string;
11
+ }
12
+
13
+ /**
14
+ * Shown when the running Studio cannot resolve this tab's project id.
15
+ *
16
+ * Why this exists: a tab whose project the server does not serve fails every
17
+ * read with a 404, so every edit silently takes the server path and nothing
18
+ * lands. The user sees a normal-looking editor that does not save. In the
19
+ * CLI-embedded host — the only host that reports telemetry, since
20
+ * `telemetry/policy.ts` suppresses Vite dev — the cause is almost always that
21
+ * this Studio is serving a *different* project: `hyperframes preview` reuses
22
+ * port 3002, so starting it on another folder takes the port from under an
23
+ * open tab, and the project it was pointed at is untouched on disk.
24
+ *
25
+ * The wording is deliberately narrower than "this project is gone". We can see
26
+ * which project this server serves; we cannot see why it does not serve this
27
+ * one, and nothing here is lost. See
28
+ * `docs/hyperframes/plans/sdk/2026-09-20-stale-project-id-remaining-doors-plan.md` §C.
29
+ */
30
+ export function ProjectUnreachableBanner({ projectId }: ProjectUnreachableBannerProps) {
31
+ const [served, setServed] = useState<ServedProject[] | null>(null);
32
+
33
+ useEffect(() => {
34
+ let cancelled = false;
35
+ // A list we could not read is not evidence of anything, so a failed fetch
36
+ // leaves `served` empty and the banner falls back to the vague wording
37
+ // rather than claiming this Studio serves nothing.
38
+ fetch("/api/projects")
39
+ .then((res) => (res.ok ? res.json() : { projects: [] }))
40
+ .then((data: { projects?: ServedProject[] }) => {
41
+ if (!cancelled) setServed(data.projects ?? []);
42
+ })
43
+ .catch(() => {
44
+ if (!cancelled) setServed([]);
45
+ });
46
+ return () => {
47
+ cancelled = true;
48
+ };
49
+ }, [projectId]);
50
+
51
+ // Withheld until the list arrives: which wording applies is a claim about
52
+ // what this server serves, and guessing it would flash the wrong one.
53
+ if (served === null) return null;
54
+
55
+ // Exactly one project served, and it is not this tab's. Under the CLI host
56
+ // that is the whole truth, and it names both sides. With several projects we
57
+ // cannot tell a rename from a deletion, so we do not pretend to.
58
+ const soleProject = served.length === 1 && served[0].id !== projectId ? served[0] : null;
59
+ const servedLabel = soleProject?.title ?? soleProject?.id;
60
+
61
+ return (
62
+ <div
63
+ role="alert"
64
+ className="hf-backdrop-in absolute left-1/2 top-14 z-92 flex max-w-[calc(100vw-32px)] -translate-x-1/2 items-center gap-3 rounded-md border border-amber-500/30 bg-amber-950/85 px-4 py-2 text-[12px] font-medium text-amber-100 shadow-lg shadow-black/30"
65
+ >
66
+ {soleProject ? (
67
+ <>
68
+ <span>
69
+ This Studio is serving <strong>{servedLabel}</strong>. This tab was opened for{" "}
70
+ <strong>{projectId}</strong>.
71
+ </span>
72
+ {/* Reloading re-runs the mount-time hash check, which rewrites the hash
73
+ to the project this server actually serves. That is the same
74
+ fallback a fresh load performs, so the button promises nothing
75
+ beyond what a reload already does. */}
76
+ <button
77
+ type="button"
78
+ onClick={() => window.location.reload()}
79
+ className="shrink-0 rounded-sm border border-amber-300/20 px-2 py-1 text-[11px] text-amber-100 transition-colors hover:bg-amber-400/10 active:scale-[0.98]"
80
+ >
81
+ Open {servedLabel}
82
+ </button>
83
+ </>
84
+ ) : (
85
+ <span>Couldn&apos;t open this project — it may have been renamed, moved, or deleted.</span>
86
+ )}
87
+ </div>
88
+ );
89
+ }
@@ -20,7 +20,6 @@ import { ChildRectOutlines, OffCanvasIndicators } from "./OffCanvasIndicators";
20
20
  import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
21
21
  import { useDomEditNudge } from "./useDomEditNudge";
22
22
  import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
23
- import { GridOverlay } from "./GridOverlay";
24
23
  import type { GestureRecordingState } from "./GestureRecordControl";
25
24
  import { DomEditGroupChrome, DomEditSelectionChrome } from "./DomEditSelectionChrome";
26
25
  import { hugRectForElement } from "./domEditOverlayCrop";
@@ -84,8 +83,6 @@ interface DomEditOverlayProps {
84
83
  ) => Promise<void> | void;
85
84
  onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
86
85
  onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
87
- gridVisible?: boolean;
88
- gridSpacing?: number;
89
86
  recordingState?: GestureRecordingState;
90
87
  onToggleRecording?: () => void;
91
88
  onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void;
@@ -125,8 +122,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
125
122
  onCanvasPointerLeave,
126
123
  onSelectionChange,
127
124
  onBlockedMove,
128
- gridVisible = false,
129
- gridSpacing = 50,
130
125
  onManualDragStart,
131
126
  onPathOffsetCommit,
132
127
  onGroupPathOffsetCommit,
@@ -555,16 +550,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
555
550
  />
556
551
  )}
557
552
  <ZOrderCrossedFlash rect={zOrderFlashRect} />
558
- <GridOverlay
559
- visible={gridVisible}
560
- spacing={gridSpacing}
561
- scaleX={compRect.scaleX}
562
- scaleY={compRect.scaleY}
563
- compositionLeft={compRect.left}
564
- compositionTop={compRect.top}
565
- compositionWidth={compRect.width}
566
- compositionHeight={compRect.height}
567
- />
568
553
  <SnapGuideOverlay
569
554
  snapGuidesRef={snapGuidesRef}
570
555
  compositionLeft={compRect.left}
@@ -1,28 +1,17 @@
1
- // fallow-ignore-file unused-file
2
1
  import { memo } from "react";
2
+ import { usePreviewOverlayContext } from "./PreviewOverlayProvider";
3
3
 
4
- interface GridOverlayProps {
5
- visible: boolean;
6
- spacing: number;
7
- scaleX: number;
8
- scaleY: number;
9
- compositionLeft: number;
10
- compositionTop: number;
11
- compositionWidth: number;
12
- compositionHeight: number;
13
- }
14
-
15
- // fallow-ignore-next-line complexity
16
- export const GridOverlay = memo(function GridOverlay({
17
- visible,
18
- spacing,
19
- scaleX,
20
- scaleY,
21
- compositionLeft,
22
- compositionTop,
23
- compositionWidth,
24
- compositionHeight,
25
- }: GridOverlayProps) {
4
+ export const GridOverlay = memo(function GridOverlay() {
5
+ const { state } = usePreviewOverlayContext();
6
+ const { gridVisible: visible, gridSpacing: spacing } = state.snapPrefs;
7
+ const {
8
+ scaleX,
9
+ scaleY,
10
+ left: compositionLeft,
11
+ top: compositionTop,
12
+ width: compositionWidth,
13
+ height: compositionHeight,
14
+ } = state.compositionRect;
26
15
  if (!visible || spacing <= 0) return null;
27
16
 
28
17
  const overlaySpacingX = spacing * scaleX;
@@ -0,0 +1,66 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { expect, it, vi } from "vitest";
5
+ import { MotionPathOverlay } from "./MotionPathOverlay";
6
+ import { usePlayerStore } from "../../player/store/playerStore";
7
+ import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
8
+ import type { DomEditSelection } from "./domEditing";
9
+
10
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
11
+
12
+ vi.mock("../../contexts/DomEditContext", () => ({
13
+ useDomEditContext: () => ({ selectedGsapAnimations: [] }),
14
+ }));
15
+ vi.mock("./motionPathSelection", () => ({
16
+ selectorFor: () => "#box",
17
+ editableAnimationId: () => null,
18
+ }));
19
+ vi.mock("./useMotionPathData", async (importOriginal) => ({
20
+ ...(await importOriginal<typeof import("./useMotionPathData")>()),
21
+ useMotionPathData: () => ({
22
+ rect: null,
23
+ geometry: null,
24
+ geometryResolved: true,
25
+ visibleInPreview: true,
26
+ home: null,
27
+ pScale: 1,
28
+ }),
29
+ }));
30
+
31
+ it("re-evaluates set-destination availability when a reload swaps the live iframe", () => {
32
+ const host = document.createElement("div");
33
+ const before = document.createElement("iframe");
34
+ const after = document.createElement("iframe");
35
+ document.body.append(host, before, after);
36
+ Reflect.set(after.contentWindow as object, "MotionPathPlugin", {});
37
+ const ref = { current: before };
38
+ const root = createRoot(host);
39
+ const selection = { element: document.createElement("div") } as unknown as DomEditSelection;
40
+ try {
41
+ act(() => usePreviewIframeStore.getState().setIframe(before));
42
+ act(() =>
43
+ root.render(
44
+ <MotionPathOverlay
45
+ iframeRef={ref}
46
+ selection={selection}
47
+ compositionSize={{ width: 1920, height: 1080 }}
48
+ isPlaying={false}
49
+ />,
50
+ ),
51
+ );
52
+ expect(usePlayerStore.getState().motionPathCreateAvailable).toBe(false);
53
+
54
+ act(() => {
55
+ ref.current = after;
56
+ usePreviewIframeStore.getState().setIframe(after);
57
+ });
58
+ expect(usePlayerStore.getState().motionPathCreateAvailable).toBe(true);
59
+ } finally {
60
+ act(() => root.unmount());
61
+ usePreviewIframeStore.setState({ iframe: null });
62
+ host.remove();
63
+ before.remove();
64
+ after.remove();
65
+ }
66
+ });
@@ -3,6 +3,7 @@ import { memo, useEffect, useRef, useState, type RefObject } from "react";
3
3
  import type { DomEditSelection } from "./domEditing";
4
4
  import { useDomEditContext } from "../../contexts/DomEditContext";
5
5
  import { usePlayerStore } from "../../player/store/playerStore";
6
+ import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
6
7
  import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
7
8
  import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
8
9
  import { nearestPointOnPath, type MotionNodeRef } from "./motionPathGeometry";
@@ -141,7 +142,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
141
142
  // No one-element selector means the path could only be authored onto the
142
143
  // element's class siblings, so the toolbar toggle stays hidden instead of
143
144
  // arming a press that the effect below would silently drop.
144
- const canCreate = createMode && !!createSelector && hasMotionPathPlugin(iframeRef.current);
145
+ const livePreviewIframe = useLivePreviewIframe();
146
+ const canCreate = createMode && !!createSelector && hasMotionPathPlugin(livePreviewIframe);
145
147
 
146
148
  // Publish whether the selected element can take a path so the preview toolbar
147
149
  // shows its "set destination" toggle. Drops to false when this overlay unmounts
@@ -4,6 +4,7 @@ import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { PreviewGuides } from "./PreviewGuides";
7
+ import { PreviewOverlayProvider } from "./PreviewOverlayProvider";
7
8
  import { usePreviewGuidesStore } from "./previewGuidesStore";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -26,7 +27,11 @@ function render(rulerVisible: boolean, safeMarginsVisible: boolean) {
26
27
  const host = document.createElement("div");
27
28
  document.body.append(host);
28
29
  act(() => {
29
- createRoot(host).render(<PreviewGuides iframeRef={{ current: null }} />);
30
+ createRoot(host).render(
31
+ <PreviewOverlayProvider>
32
+ <PreviewGuides />
33
+ </PreviewOverlayProvider>,
34
+ );
30
35
  });
31
36
  return host;
32
37
  }
@@ -1,30 +1,28 @@
1
1
  import { memo, useRef, type RefObject } from "react";
2
2
  import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
3
- import { RULER_GUTTER_PX, usePreviewGuidesStore } from "./previewGuidesStore";
3
+ import { RULER_GUTTER_PX } from "./previewGuidesStore";
4
4
  import { SAFE_BOX_PERCENTS, safeBoxInsetPercent } from "../../utils/previewSafeMargins";
5
-
6
- interface PreviewGuidesProps {
7
- iframeRef: RefObject<HTMLIFrameElement | null>;
8
- }
5
+ import { usePreviewOverlayContext } from "./PreviewOverlayProvider";
9
6
 
10
7
  const TICKS = Array.from({ length: 11 }, (_, i) => i * 10);
11
8
  const INK = "color-mix(in srgb, white 70%, transparent)";
12
9
 
13
10
  /** Ruler and safe-margin boxes drawn over the preview pane, never inside the composition. */
14
- export const PreviewGuides = memo(function PreviewGuides({ iframeRef }: PreviewGuidesProps) {
15
- const rulerVisible = usePreviewGuidesStore((s) => s.rulerVisible);
16
- const safeMarginsVisible = usePreviewGuidesStore((s) => s.safeMarginsVisible);
11
+ export const PreviewGuides = memo(function PreviewGuides() {
12
+ const { state } = usePreviewOverlayContext();
13
+ const { rulerVisible, safeMarginsVisible } = state;
17
14
  if (!rulerVisible && !safeMarginsVisible) return null;
18
15
  return (
19
16
  <ActiveGuides
20
- iframeRef={iframeRef}
17
+ iframeRef={state.iframeRef}
21
18
  rulerVisible={rulerVisible}
22
19
  safeMarginsVisible={safeMarginsVisible}
23
20
  />
24
21
  );
25
22
  });
26
23
 
27
- interface ActiveGuidesProps extends PreviewGuidesProps {
24
+ interface ActiveGuidesProps {
25
+ iframeRef: RefObject<HTMLIFrameElement | null>;
28
26
  rulerVisible: boolean;
29
27
  safeMarginsVisible: boolean;
30
28
  }
@@ -0,0 +1,90 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { GridOverlay } from "./GridOverlay";
7
+ import { PreviewOverlayProvider } from "./PreviewOverlayProvider";
8
+ import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
9
+
10
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ afterEach(() => {
13
+ document.body.innerHTML = "";
14
+ window.localStorage.clear();
15
+ usePreviewIframeStore.getState().setIframe(null);
16
+ });
17
+
18
+ function compositionFrame() {
19
+ const iframe = document.createElement("iframe");
20
+ iframe.getBoundingClientRect = () => new DOMRect(12, 18, 640, 360);
21
+ const root = iframe.contentDocument?.createElement("div");
22
+ root?.setAttribute("data-composition-id", "main");
23
+ root?.setAttribute("data-width", "1280");
24
+ root?.setAttribute("data-height", "720");
25
+ if (root) iframe.contentDocument?.body.append(root);
26
+ document.body.append(iframe);
27
+ return iframe;
28
+ }
29
+
30
+ describe("PreviewOverlayProvider", () => {
31
+ it("derives grid geometry after the iframe prop becomes available", async () => {
32
+ window.localStorage.setItem(
33
+ "hf-studio-ui-preferences",
34
+ JSON.stringify({ gridVisible: true, gridSpacing: 20 }),
35
+ );
36
+ const host = document.createElement("div");
37
+ document.body.append(host);
38
+ const root = createRoot(host);
39
+
40
+ act(() => {
41
+ root.render(
42
+ <PreviewOverlayProvider iframe={null}>
43
+ <GridOverlay />
44
+ </PreviewOverlayProvider>,
45
+ );
46
+ });
47
+ expect(host.querySelector('[aria-hidden="true"]')).not.toBeNull();
48
+
49
+ const iframe = compositionFrame();
50
+ act(() => {
51
+ root.render(
52
+ <PreviewOverlayProvider iframe={iframe}>
53
+ <GridOverlay />
54
+ </PreviewOverlayProvider>,
55
+ );
56
+ });
57
+ await act(async () => {
58
+ await new Promise((resolve) => setTimeout(resolve, 400));
59
+ });
60
+
61
+ const grid = host.querySelector<HTMLElement>('[aria-hidden="true"]');
62
+ expect([grid?.style.left, grid?.style.top, grid?.style.width, grid?.style.height]).toEqual([
63
+ "12px",
64
+ "18px",
65
+ "640px",
66
+ "360px",
67
+ ]);
68
+ expect(grid?.style.backgroundSize).toBe("20px 20px");
69
+ act(() => root.unmount());
70
+ });
71
+
72
+ it("does not clear an iframe store entry owned elsewhere on unmount", () => {
73
+ const ownerIframe = compositionFrame();
74
+ usePreviewIframeStore.getState().setIframe(ownerIframe);
75
+ const host = document.createElement("div");
76
+ document.body.append(host);
77
+ const root = createRoot(host);
78
+
79
+ act(() => {
80
+ root.render(
81
+ <PreviewOverlayProvider iframe={null}>
82
+ <GridOverlay />
83
+ </PreviewOverlayProvider>,
84
+ );
85
+ });
86
+ act(() => root.unmount());
87
+
88
+ expect(usePreviewIframeStore.getState().iframe).toBe(ownerIframe);
89
+ });
90
+ });