@hyperframes/studio 0.8.33 → 0.8.35

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 (90) hide show
  1. package/dist/assets/{hyperframes-player-CNhkujVy.js → hyperframes-player-lPOqAC3l.js} +1 -1
  2. package/dist/assets/{index-DOoo96Tb.js → index-BRr1JoHX.js} +182 -172
  3. package/dist/assets/{index-CslAQ3JV.js → index-RPEcu_bT.js} +1 -1
  4. package/dist/assets/{index-DiStlnx7.js → index-ywAULfGT.js} +1 -1
  5. package/dist/{chunk-A7S5SGNA.js → chunk-7BP32G62.js} +294 -53
  6. package/dist/chunk-7BP32G62.js.map +1 -0
  7. package/dist/{domEditingLayers-JMMVOGTO.js → domEditingLayers-UKMVYXT6.js} +2 -2
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +369 -220
  10. package/dist/index.js.map +1 -1
  11. package/package.json +8 -8
  12. package/src/App.tsx +6 -4
  13. package/src/captions/components/CaptionOverlay.sender.test.tsx +47 -0
  14. package/src/captions/components/CaptionOverlay.tsx +1 -0
  15. package/src/captions/generator.test.ts +30 -0
  16. package/src/captions/generator.ts +5 -3
  17. package/src/captions/hooks/useCaptionSync.ts +3 -2
  18. package/src/components/MediaPreview.tsx +2 -1
  19. package/src/components/editor/AnimationCard.test.tsx +3 -3
  20. package/src/components/editor/PropertyPanel.test.tsx +18 -14
  21. package/src/components/editor/SnapGuideOverlay.tsx +2 -6
  22. package/src/components/editor/domEditLayerWalkCache.test.ts +284 -0
  23. package/src/components/editor/domEditLayerWalkCache.ts +327 -0
  24. package/src/components/editor/domEditOverlayBasis.ts +72 -0
  25. package/src/components/editor/domEditOverlayGeometry.ts +78 -83
  26. package/src/components/editor/domEditOverlayMeasurePass.ts +60 -0
  27. package/src/components/editor/domEditOverlayTransform.ts +47 -9
  28. package/src/components/editor/domEditingDom.ts +63 -14
  29. package/src/components/editor/domEditingElement.ts +56 -14
  30. package/src/components/editor/domEditingLayers.test.ts +48 -0
  31. package/src/components/editor/domEditingLayers.ts +22 -35
  32. package/src/components/editor/offCanvasIndicatorGeometry.complexity.test.ts +373 -0
  33. package/src/components/editor/offCanvasIndicatorGeometry.ts +50 -11
  34. package/src/components/editor/offCanvasIndicatorRefresh.ts +43 -16
  35. package/src/components/editor/overlayFrameLoop.test.ts +246 -0
  36. package/src/components/editor/overlayFrameLoop.ts +204 -0
  37. package/src/components/editor/probeSourceElement.ts +29 -0
  38. package/src/components/editor/propertyPanelFill.tsx +2 -1
  39. package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +2 -2
  40. package/src/components/editor/useDomEditCompositionRect.ts +2 -4
  41. package/src/components/editor/useDomEditOverlayRects.ts +15 -9
  42. package/src/components/editor/useMotionPathData.ts +2 -4
  43. package/src/components/feedback/projectProvenance.ts +4 -1
  44. package/src/components/nle/NLEContext.tsx +2 -1
  45. package/src/components/nle/PreviewOverlays.tsx +1 -1
  46. package/src/components/nle/useCompositionStack.ts +11 -4
  47. package/src/components/renders/RenderQueue.test.tsx +2 -2
  48. package/src/components/renders/RenderQueueItem.tsx +2 -1
  49. package/src/components/renders/useRenderQueue.ts +3 -2
  50. package/src/components/sidebar/AssetsTab.tsx +2 -1
  51. package/src/components/sidebar/CompositionsTab.tsx +2 -1
  52. package/src/components/storyboard/FramePoster.tsx +2 -1
  53. package/src/hooks/useAskAgentModal.ts +2 -1
  54. package/src/hooks/useCaptionDetection.sender.test.tsx +54 -0
  55. package/src/hooks/useCaptionDetection.ts +3 -1
  56. package/src/hooks/useCompositionContentLoader.ts +3 -1
  57. package/src/hooks/useCompositionDimensions.test.tsx +47 -0
  58. package/src/hooks/useCompositionDimensions.ts +3 -2
  59. package/src/hooks/useDomEditCommits.ts +5 -4
  60. package/src/hooks/useElementLifecycleOps.ts +5 -1
  61. package/src/hooks/useFileTree.ts +5 -4
  62. package/src/hooks/useGroupCommits.ts +2 -1
  63. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -2
  64. package/src/hooks/useLintModal.ts +2 -1
  65. package/src/hooks/useRenderClipContent.ts +6 -5
  66. package/src/hooks/useTimelineDeleteOps.ts +5 -1
  67. package/src/player/components/Player.tsx +6 -4
  68. package/src/player/components/TimelineAutomationLane.test.tsx +9 -9
  69. package/src/player/components/thumbnailUtils.ts +6 -2
  70. package/src/player/components/timelineDragDrop.test.tsx +3 -3
  71. package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
  72. package/src/player/lib/timelineIframeHelpers.ts +17 -1
  73. package/src/test-setup.ts +8 -0
  74. package/src/utils/blockInstaller.ts +2 -1
  75. package/src/utils/projectRouting.test.ts +9 -0
  76. package/src/utils/projectRouting.ts +18 -1
  77. package/src/utils/razorSplitTransaction.test.ts +3 -3
  78. package/src/utils/sourceScopedSelectorIndex.test.ts +148 -0
  79. package/src/utils/sourceScopedSelectorIndex.ts +64 -6
  80. package/src/utils/studioFileHistory.ts +2 -1
  81. package/src/utils/studioHelpers.test.ts +5 -1
  82. package/src/utils/studioHelpers.ts +3 -2
  83. package/src/utils/timelineCompositionInsert.test.ts +3 -3
  84. package/src/webmcp/types.ts +5 -1
  85. package/src/webmcp/useStudioAgentTools.polyfill.test.tsx +219 -0
  86. package/src/webmcp/useStudioAgentTools.test.tsx +81 -67
  87. package/src/webmcp/useStudioAgentTools.ts +49 -28
  88. package/src/webmcp/webmcpTestUtils.ts +56 -0
  89. package/dist/chunk-A7S5SGNA.js.map +0 -1
  90. /package/dist/{domEditingLayers-JMMVOGTO.js.map → domEditingLayers-UKMVYXT6.js.map} +0 -0
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../../utils/projectRouting";
1
2
  // Composition drill-down stack management for NLEContext/EditorShell
2
3
  import { useState, useCallback, useRef, useEffect } from "react";
3
4
  import { usePlayerStore } from "../../player";
@@ -29,7 +30,7 @@ export function useCompositionStack({
29
30
  {
30
31
  id: "master",
31
32
  label: "Master",
32
- previewUrl: `/api/projects/${projectId}/preview`,
33
+ previewUrl: buildProjectApiPath(projectId, `/preview`),
33
34
  },
34
35
  ]);
35
36
 
@@ -89,7 +90,10 @@ export function useCompositionStack({
89
90
  .split("/")
90
91
  .pop()
91
92
  ?.replace(/\.html$/, "") || resolvedPath;
92
- const previewUrl = `/api/projects/${projectId}/preview/comp/${encodePreviewPath(resolvedPath)}`;
93
+ const previewUrl = buildProjectApiPath(
94
+ projectId,
95
+ `/preview/comp/${encodePreviewPath(resolvedPath)}`,
96
+ );
93
97
  return [...prev, { id: resolvedPath, label, previewUrl }];
94
98
  });
95
99
  },
@@ -103,7 +107,7 @@ export function useCompositionStack({
103
107
  const master: CompositionLevel = {
104
108
  id: "master",
105
109
  label: "Master",
106
- previewUrl: `/api/projects/${projectId}/preview`,
110
+ previewUrl: buildProjectApiPath(projectId, `/preview`),
107
111
  };
108
112
  if (activeCompositionPath === "index.html") {
109
113
  usePlayerStore.getState().setElements([]);
@@ -116,7 +120,10 @@ export function useCompositionStack({
116
120
  // panel highlighted the row, so the canvas and timeline stayed on
117
121
  // index.html and edits landed in the root file.
118
122
  const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
119
- const previewUrl = `/api/projects/${projectId}/preview/comp/${encodePreviewPath(activeCompositionPath)}`;
123
+ const previewUrl = buildProjectApiPath(
124
+ projectId,
125
+ `/preview/comp/${encodePreviewPath(activeCompositionPath)}`,
126
+ );
120
127
  usePlayerStore.getState().setElements([]);
121
128
  updateCompositionStack((prev) => {
122
129
  if (prev[prev.length - 1]?.id === activeCompositionPath) return prev;
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
6
6
  import { RenderQueue } from "./RenderQueue";
7
7
  import type { FfmpegStatus } from "./useFfmpegStatus";
8
8
 
@@ -26,7 +26,7 @@ afterEach(() => {
26
26
  document.body.innerHTML = "";
27
27
  });
28
28
 
29
- function mountRenderQueue(onStartRender: ReturnType<typeof vi.fn>) {
29
+ function mountRenderQueue(onStartRender: Mock) {
30
30
  const host = document.createElement("div");
31
31
  document.body.append(host);
32
32
  root = createRoot(host);
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../../utils/projectRouting";
1
2
  import { memo, useCallback, useState } from "react";
2
3
  import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
3
4
  import { Button } from "../ui/Button";
@@ -36,7 +37,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
36
37
  const [confirmingDelete, setConfirmingDelete] = useState(false);
37
38
 
38
39
  // Direct file URL — serves from disk, survives server restarts
39
- const fileSrc = `/api/projects/${projectId}/renders/file/${job.filename}`;
40
+ const fileSrc = buildProjectApiPath(projectId, `/renders/file/${job.filename}`);
40
41
 
41
42
  const handleOpen = useCallback(() => {
42
43
  window.open(fileSrc, "_blank");
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../../utils/projectRouting";
1
2
  import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2
3
  import type { CanvasResolution } from "@hyperframes/parsers";
3
4
  import { trackStudioRenderStart } from "../../telemetry/events";
@@ -125,7 +126,7 @@ export function useRenderQueue(
125
126
  const loadRenders = useCallback(async () => {
126
127
  if (!projectId) return;
127
128
  try {
128
- const res = await fetch(`/api/projects/${projectId}/renders`);
129
+ const res = await fetch(buildProjectApiPath(projectId, `/renders`));
129
130
  if (!res.ok) {
130
131
  setLoadError(`Couldn't load render history (server error ${res.status}).`);
131
132
  return;
@@ -261,7 +262,7 @@ export function useRenderQueue(
261
262
  }
262
263
  let res: Response;
263
264
  try {
264
- res = await fetch(`/api/projects/${projectId}/render`, {
265
+ res = await fetch(buildProjectApiPath(projectId, `/render`), {
265
266
  method: "POST",
266
267
  headers: { "Content-Type": "application/json" },
267
268
  body: JSON.stringify(body),
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../../utils/projectRouting";
1
2
  // fallow-ignore-file code-duplication
2
3
  import { memo, useState, useCallback, useRef, useMemo, useEffect } from "react";
3
4
  import { SearchInput } from "../ui/SearchInput";
@@ -216,7 +217,7 @@ export const AssetsTab = memo(function AssetsTab({
216
217
  useEffect(() => {
217
218
  if (manifest404Ref.current.has(projectId)) return;
218
219
  let cancelled = false;
219
- fetch(`/api/projects/${projectId}/preview/.media/manifest.jsonl`)
220
+ fetch(buildProjectApiPath(projectId, `/preview/.media/manifest.jsonl`))
220
221
  .then((r) => {
221
222
  if (!r.ok) {
222
223
  manifest404Ref.current.add(projectId);
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../../utils/projectRouting";
1
2
  import { memo, useCallback, useEffect, useRef, useState } from "react";
2
3
  import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
3
4
  import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
@@ -173,7 +174,7 @@ function CompCard({
173
174
  setLivePreviewLoaded(false);
174
175
  };
175
176
  const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
176
- const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
177
+ const previewUrl = buildProjectApiPath(projectId, `/preview/comp/${comp}`);
177
178
  const thumbnailUrl = buildCompositionThumbnailUrl({
178
179
  previewUrl,
179
180
  seekTime: THUMBNAIL_SEEK_TIME_SECONDS,
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../../utils/projectRouting";
1
2
  import { useEffect, useState } from "react";
2
3
  import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
3
4
 
@@ -51,7 +52,7 @@ export function FramePoster({
51
52
  );
52
53
  }
53
54
  let url = buildCompositionThumbnailUrl({
54
- previewUrl: `/api/projects/${projectId}/preview/comp/${src}`,
55
+ previewUrl: buildProjectApiPath(projectId, `/preview/comp/${src}`),
55
56
  seekTime: seconds,
56
57
  duration: 0,
57
58
  origin: window.location.origin,
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../utils/projectRouting";
1
2
  import { useState, useCallback, useRef, useEffect } from "react";
2
3
  import { copyTextToClipboard } from "../utils/clipboard";
3
4
  import { readTagSnippetByTarget } from "../utils/sourcePatcher";
@@ -53,7 +54,7 @@ export function useAskAgentModal({
53
54
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
54
55
  try {
55
56
  const response = await fetch(
56
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
57
+ buildProjectApiPath(pid, `/files/${encodeURIComponent(targetPath)}`),
57
58
  );
58
59
  if (!response.ok) return;
59
60
 
@@ -0,0 +1,54 @@
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 { useCaptionDetection } from "./useCaptionDetection";
6
+
7
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
8
+
9
+ it("does not let a foreign window trigger caption activation", () => {
10
+ const host = document.createElement("div");
11
+ const preview = document.createElement("iframe");
12
+ const foreign = document.createElement("iframe");
13
+ document.body.append(host, preview, foreign);
14
+ const root = createRoot(host);
15
+ const fetchMock = vi.fn(() => new Promise<Response>(() => {}));
16
+ vi.stubGlobal("fetch", fetchMock);
17
+ const params = {
18
+ projectId: "demo",
19
+ activeCompPath: "captions.html",
20
+ compIdToSrc: new Map<string, string>(),
21
+ captionEditMode: false,
22
+ captionHasSelection: false,
23
+ previewIframeRef: { current: preview },
24
+ captionSync: { save: vi.fn(), loadOverrides: vi.fn(async () => {}) },
25
+ setRightCollapsed: vi.fn(),
26
+ };
27
+ function Harness() {
28
+ useCaptionDetection(params);
29
+ return null;
30
+ }
31
+ const send = (source: MessageEventSource | null) =>
32
+ act(() => {
33
+ window.dispatchEvent(
34
+ new MessageEvent("message", { source, data: { source: "hf-preview", type: "state" } }),
35
+ );
36
+ });
37
+ try {
38
+ act(() => root.render(<Harness />));
39
+ const group = preview.contentDocument!.createElement("div");
40
+ group.className = "caption-group";
41
+ preview.contentDocument!.body.append(group);
42
+ send(foreign.contentWindow);
43
+ send(null);
44
+ expect(fetchMock).not.toHaveBeenCalled();
45
+ send(preview.contentWindow);
46
+ expect(fetchMock).toHaveBeenCalledOnce();
47
+ } finally {
48
+ act(() => root.unmount());
49
+ host.remove();
50
+ preview.remove();
51
+ foreign.remove();
52
+ vi.unstubAllGlobals();
53
+ }
54
+ });
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../utils/projectRouting";
1
2
  import { useEffect, useRef } from "react";
2
3
  import { useCaptionStore } from "../captions/store";
3
4
  import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
@@ -107,7 +108,7 @@ export function useCaptionDetection({
107
108
 
108
109
  activating = true;
109
110
  const srcPath = captionSrcPath;
110
- fetch(`/api/projects/${projectId}/files/${encodeURIComponent(srcPath)}`)
111
+ fetch(buildProjectApiPath(projectId, `/files/${encodeURIComponent(srcPath)}`))
111
112
  .then((r) => r.json())
112
113
  .then((data: { content?: string }) => {
113
114
  if (!data.content || !doc || !win || useCaptionStore.getState().isEditMode) return;
@@ -130,6 +131,7 @@ export function useCaptionDetection({
130
131
  };
131
132
 
132
133
  const handleMessage = (e: MessageEvent) => {
134
+ if (!e.source || e.source !== previewIframeRef.current?.contentWindow) return;
133
135
  const data = e.data;
134
136
  if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
135
137
  if (!acceptStudioRuntimeMessage(data)) return;
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../utils/projectRouting";
1
2
  import { useCallback } from "react";
2
3
 
3
4
  /**
@@ -20,9 +21,10 @@ export function useCompositionContentLoader({
20
21
  }) {
21
22
  return useCallback(
22
23
  (comp: string) => {
24
+ if (!projectId) return;
23
25
  setActiveCompPath(comp.endsWith(".html") ? comp : null);
24
26
  setEditingFile({ path: comp, content: null });
25
- fetch(`/api/projects/${projectId}/files/${comp}`)
27
+ fetch(buildProjectApiPath(projectId, `/files/${encodeURIComponent(comp)}`))
26
28
  .then(async (r) => {
27
29
  if (!r.ok) throw new Error(`Failed to load ${comp} (${r.status})`);
28
30
  return r.json();
@@ -0,0 +1,47 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { expect, it } from "vitest";
5
+ import { useCompositionDimensions } from "./useCompositionDimensions";
6
+
7
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
8
+
9
+ it("accepts only the current preview frame and rejects foreign or missing senders", () => {
10
+ const host = document.createElement("div");
11
+ const preview = document.createElement("iframe");
12
+ const foreign = document.createElement("iframe");
13
+ document.body.append(host, preview, foreign);
14
+ const ref = { current: preview };
15
+ const root = createRoot(host);
16
+ function Harness() {
17
+ return JSON.stringify(useCompositionDimensions(ref));
18
+ }
19
+ const send = (source: MessageEventSource | null, width: number) =>
20
+ act(() => {
21
+ window.dispatchEvent(
22
+ new MessageEvent("message", {
23
+ source,
24
+ origin: window.location.origin,
25
+ data: { source: "hf-preview", type: "stage-size", width, height: 100 },
26
+ }),
27
+ );
28
+ });
29
+ try {
30
+ act(() => root.render(<Harness />));
31
+ send(foreign.contentWindow, 300);
32
+ send(null, 400);
33
+ expect(host.textContent).toBe("null");
34
+ send(preview.contentWindow, 200);
35
+ expect(host.textContent).toBe('{"width":200,"height":100}');
36
+ ref.current = foreign;
37
+ send(preview.contentWindow, 500);
38
+ expect(host.textContent).toBe('{"width":200,"height":100}');
39
+ send(foreign.contentWindow, 600);
40
+ expect(host.textContent).toBe('{"width":600,"height":100}');
41
+ } finally {
42
+ act(() => root.unmount());
43
+ host.remove();
44
+ preview.remove();
45
+ foreign.remove();
46
+ }
47
+ });
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
1
+ import { useState, type RefObject } from "react";
2
2
  import { useMountEffect } from "./useMountEffect";
3
3
  import type { CompositionDimensions } from "../components/renders/RenderQueue";
4
4
  import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
@@ -29,13 +29,14 @@ function readPositiveDimensions(width: unknown, height: unknown): CompositionDim
29
29
  return { width: parsedWidth, height: parsedHeight };
30
30
  }
31
31
 
32
- export function useCompositionDimensions() {
32
+ export function useCompositionDimensions(iframeRef: RefObject<HTMLIFrameElement | null>) {
33
33
  const [compositionDimensions, setCompositionDimensions] = useState<CompositionDimensions | null>(
34
34
  null,
35
35
  );
36
36
 
37
37
  useMountEffect(() => {
38
38
  const handleMessage = (e: MessageEvent) => {
39
+ if (!e.source || e.source !== iframeRef.current?.contentWindow) return;
39
40
  const dimensions = readCompositionSizeMessage(e.data);
40
41
  if (!dimensions) return;
41
42
  setCompositionDimensions((prev) =>
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../utils/projectRouting";
1
2
  import { useCallback, useRef } from "react";
2
3
  import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
3
4
  import { FONT_EXT } from "../utils/mediaTypes";
@@ -127,11 +128,11 @@ export function useDomEditCommits({
127
128
  FONT_EXT.test(path) &&
128
129
  fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
129
130
  );
130
- if (!asset) return null;
131
+ if (!asset || !projectId) return null;
131
132
  return {
132
133
  family: fontFamilyFromAssetPath(asset),
133
134
  path: asset,
134
- url: `/api/projects/${projectId}/preview/${asset}`,
135
+ url: buildProjectApiPath(projectId, `/preview/${asset}`),
135
136
  };
136
137
  },
137
138
  [fileTree, projectId, importedFontAssetsRef],
@@ -161,7 +162,7 @@ export function useDomEditCommits({
161
162
  };
162
163
 
163
164
  const readResponse = await fetch(
164
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
165
+ buildProjectApiPath(pid, `/files/${encodeURIComponent(targetPath)}`),
165
166
  );
166
167
  if (!readResponse.ok) {
167
168
  throw await createStudioSaveHttpError(readResponse, `Failed to read ${targetPath}`);
@@ -219,7 +220,7 @@ export function useDomEditCommits({
219
220
  domEditSaveTimestampRef.current = Date.now();
220
221
 
221
222
  const patchResponse = await fetch(
222
- `/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
223
+ buildProjectApiPath(pid, `/file-mutations/patch-element/${encodeURIComponent(targetPath)}`),
223
224
  {
224
225
  method: "POST",
225
226
  headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../utils/projectRouting";
1
2
  import { useCallback } from "react";
2
3
  import { usePlayerStore } from "../player";
3
4
  import {
@@ -152,7 +153,10 @@ export function useElementLifecycleOps({
152
153
  // selection runs to hundreds of members — the file ended up correct, but
153
154
  // only after long enough that Delete looked like it had done nothing.
154
155
  const removeResponse = await fetch(
155
- `/api/projects/${pid}/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`,
156
+ buildProjectApiPath(
157
+ pid,
158
+ `/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`,
159
+ ),
156
160
  {
157
161
  method: "POST",
158
162
  headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../utils/projectRouting";
1
2
  import { useState, useCallback, useEffect, useMemo } from "react";
2
3
  import { FONT_EXT } from "../utils/mediaTypes";
3
4
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
@@ -22,7 +23,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
22
23
  }
23
24
  let cancelled = false;
24
25
  setFileTreeLoaded(false);
25
- fetch(`/api/projects/${projectId}`)
26
+ fetch(buildProjectApiPath(projectId))
26
27
  .then((r) => r.json())
27
28
  .then((data: { files?: string[]; dir?: string; compositions?: string[] }) => {
28
29
  if (cancelled) return;
@@ -47,7 +48,7 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
47
48
  const refreshFileTree = useCallback(async () => {
48
49
  const pid = projectIdRef.current;
49
50
  if (!pid) return;
50
- const res = await fetch(`/api/projects/${pid}`);
51
+ const res = await fetch(buildProjectApiPath(pid));
51
52
  const data = await res.json();
52
53
  if (data.files) setFileTree(data.files);
53
54
  }, [projectIdRef]);
@@ -62,12 +63,12 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
62
63
 
63
64
  const fontAssets = useMemo<ImportedFontAsset[]>(
64
65
  () =>
65
- assets
66
+ (projectId ? assets : [])
66
67
  .filter((asset) => FONT_EXT.test(asset))
67
68
  .map((asset) => ({
68
69
  family: fontFamilyFromAssetPath(asset),
69
70
  path: asset,
70
- url: `/api/projects/${projectId}/preview/${asset}`,
71
+ url: projectId ? buildProjectApiPath(projectId, `/preview/${asset}`) : "",
71
72
  })),
72
73
  [assets, projectId],
73
74
  );
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../utils/projectRouting";
1
2
  import { useCallback } from "react";
2
3
  import {
3
4
  readProjectFileContent,
@@ -73,7 +74,7 @@ async function commitStructuralMutation(
73
74
 
74
75
  deps.domEditSaveTimestampRef.current = Date.now();
75
76
  const mutateResponse = await fetch(
76
- `/api/projects/${pid}/file-mutations/${route}/${encodeURIComponent(targetPath)}`,
77
+ buildProjectApiPath(pid, `/file-mutations/${route}/${encodeURIComponent(targetPath)}`),
77
78
  {
78
79
  method: "POST",
79
80
  headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import React, { act } from "react";
4
- import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
5
5
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
6
6
  import { mountReactHarness } from "./domSelectionTestHarness";
7
7
  import { GsapEditBlockedError } from "./gsapEditOutcome";
@@ -25,7 +25,7 @@ const selection = {
25
25
  element: document.createElement("div"),
26
26
  } as unknown as DomEditSelection;
27
27
 
28
- function mountFailureTelemetry(showToast: ReturnType<typeof vi.fn>) {
28
+ function mountFailureTelemetry(showToast: Mock) {
29
29
  let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
30
30
  function Harness() {
31
31
  report = useGsapInteractionFailureTelemetry("index.html", showToast);
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../utils/projectRouting";
1
2
  import { useState, useCallback, useEffect, useRef, useMemo } from "react";
2
3
  import type { LintFinding } from "../components/LintModal";
3
4
  import { usePlayerStore } from "../player";
@@ -35,7 +36,7 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
35
36
  if (!projectId) return;
36
37
  if (!opts?.background) setLinting(true);
37
38
  try {
38
- const res = await fetch(`/api/projects/${projectId}/lint`);
39
+ const res = await fetch(buildProjectApiPath(projectId, `/lint`));
39
40
  const data = await res.json();
40
41
  const parsed = ((data.findings ?? []) as RawFinding[]).map(parseFinding);
41
42
  if (opts?.background) {
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../utils/projectRouting";
1
2
  import { useCallback, type ReactNode } from "react";
2
3
  import { createElement } from "react";
3
4
  import { CompositionThumbnail, VideoThumbnail } from "../player";
@@ -16,7 +17,7 @@ export function normalizeCompositionSrc(
16
17
  ): string {
17
18
  try {
18
19
  const parsed = new URL(compSrc, origin);
19
- const previewPrefix = `/api/projects/${projectId}/preview/`;
20
+ const previewPrefix = buildProjectApiPath(projectId, `/preview/`);
20
21
  if (parsed.pathname.startsWith(previewPrefix)) {
21
22
  return parsed.pathname.slice(previewPrefix.length);
22
23
  }
@@ -35,7 +36,7 @@ function resolvePreviewRelative(
35
36
  if (!src) return null;
36
37
  try {
37
38
  const parsed = new URL(src, origin);
38
- const base = new URL(`/api/projects/${pid}/preview/`, origin).pathname;
39
+ const base = new URL(buildProjectApiPath(pid, `/preview/`), origin).pathname;
39
40
  return parsed.pathname.startsWith(base)
40
41
  ? decodeURIComponent(parsed.pathname.slice(base.length))
41
42
  : null;
@@ -77,7 +78,7 @@ function renderAudioClip(
77
78
  // returns the DECODED path, so it must be re-encoded here.
78
79
  const encodedRelative = srcRelative ? encodePreviewPath(srcRelative) : null;
79
80
  const waveformUrl = encodedRelative
80
- ? `/api/projects/${pid}/waveform/${encodedRelative}`
81
+ ? buildProjectApiPath(pid, `/waveform/${encodedRelative}`)
81
82
  : undefined;
82
83
  const { start, end } = trimFractions(el);
83
84
  return createElement(AudioWaveform, {
@@ -146,7 +147,7 @@ export function useRenderClipContent({
146
147
  // instead of capturing the master at a time when the comp is fading in.
147
148
  if (compSrc) {
148
149
  return createElement(CompositionThumbnail, {
149
- previewUrl: `/api/projects/${pid}/preview/comp/${encodePreviewPath(compSrc)}`,
150
+ previewUrl: buildProjectApiPath(pid, `/preview/comp/${encodePreviewPath(compSrc)}`),
150
151
  label: "",
151
152
  labelColor: style.label,
152
153
 
@@ -225,7 +226,7 @@ export function useRenderClipContent({
225
226
 
226
227
  if (htmlPreviewEligible) {
227
228
  return createElement(CompositionThumbnail, {
228
- previewUrl: `/api/projects/${pid}/preview`,
229
+ previewUrl: buildProjectApiPath(pid, `/preview`),
229
230
  label: "",
230
231
  labelColor: style.label,
231
232
 
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../utils/projectRouting";
1
2
  // Timeline clip deletion: the marquee/multi path and the single-clip wrapper
2
3
  // the context menu uses. Extracted verbatim from useTimelineEditing.ts to keep
3
4
  // it under the studio 600-line cap, following useTimelineAssetDropOps.
@@ -76,7 +77,10 @@ export function useTimelineDeleteOps({
76
77
  }
77
78
 
78
79
  const removeResponse = await fetch(
79
- `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
80
+ buildProjectApiPath(
81
+ pid,
82
+ `/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
83
+ ),
80
84
  {
81
85
  method: "POST",
82
86
  headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../../utils/projectRouting";
1
2
  import { forwardRef, useEffect, useRef, useState } from "react";
2
3
  import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness";
3
4
  import { useMountEffect } from "../../hooks/useMountEffect";
@@ -158,6 +159,10 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
158
159
  const container = containerRef.current;
159
160
  if (!container) return;
160
161
 
162
+ const previewSource =
163
+ directUrl || (projectId ? buildProjectApiPath(projectId, "/preview") : null);
164
+ if (!previewSource) return;
165
+
161
166
  let canceled = false;
162
167
  let cleanup: (() => void) | undefined;
163
168
 
@@ -167,10 +172,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
167
172
 
168
173
  // Create the web component imperatively to avoid JSX custom-element typing.
169
174
  const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
170
- const srcUrl = new URL(
171
- directUrl || `/api/projects/${projectId}/preview`,
172
- window.location.origin,
173
- );
175
+ const srcUrl = new URL(previewSource, window.location.origin);
174
176
  applyPreviewVariablesToUrl(srcUrl);
175
177
  const src = srcUrl.pathname + srcUrl.search;
176
178
  const retryPreview = () => {
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { act } from "react";
3
- import { describe, expect, it, vi } from "vitest";
3
+ import { describe, expect, it, vi, type Mock } from "vitest";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { TimelineAutomationLane } from "./TimelineAutomationLane";
6
6
  import { PAD_X } from "./automationLaneGeometry";
@@ -602,8 +602,8 @@ const mount = (automation: HfAutomation, over: Record<string, unknown> = {}) =>
602
602
  // assertion below reads the calls the lane made.
603
603
  const props = {
604
604
  ...base,
605
- onPreview: base.onPreview as ReturnType<typeof vi.fn>,
606
- onCommit: base.onCommit as ReturnType<typeof vi.fn>,
605
+ onPreview: base.onPreview as Mock,
606
+ onCommit: base.onCommit as Mock,
607
607
  };
608
608
  const { container } = render(<TimelineAutomationLane {...props} />);
609
609
  const svg = container.querySelector("svg")!;
@@ -685,7 +685,7 @@ describe("TimelineAutomationLane segment drag", () => {
685
685
  ],
686
686
  };
687
687
 
688
- const previewedPoints = (props: { onPreview: ReturnType<typeof vi.fn> }) =>
688
+ const previewedPoints = (props: { onPreview: Mock }) =>
689
689
  props.onPreview.mock.calls.at(-1)?.[0].lanes[0].points as {
690
690
  t: number;
691
691
  v: number;
@@ -787,8 +787,8 @@ const mountRerenderable = (automation: HfAutomation, over: Record<string, unknow
787
787
  const base = laneProps({ automation, ...over });
788
788
  const props = {
789
789
  ...base,
790
- onPreview: base.onPreview as ReturnType<typeof vi.fn>,
791
- onCommit: base.onCommit as ReturnType<typeof vi.fn>,
790
+ onPreview: base.onPreview as Mock,
791
+ onCommit: base.onCommit as Mock,
792
792
  };
793
793
  const { container, rerender } = renderRerenderable(<TimelineAutomationLane {...props} />);
794
794
  const svg = container.querySelector("svg")!;
@@ -944,7 +944,7 @@ describe("TimelineAutomationLane modifiers", () => {
944
944
  },
945
945
  ],
946
946
  };
947
- const draggedTimes = (props: { onPreview: ReturnType<typeof vi.fn> }) => {
947
+ const draggedTimes = (props: { onPreview: Mock }) => {
948
948
  const points = props.onPreview.mock.calls.at(-1)?.[0].lanes[0].points as
949
949
  | { t: number }[]
950
950
  | undefined;
@@ -1204,7 +1204,7 @@ describe("TimelineAutomationLane group drag", () => {
1204
1204
  },
1205
1205
  ],
1206
1206
  };
1207
- const pointsOf = (props: { onPreview: ReturnType<typeof vi.fn> }) =>
1207
+ const pointsOf = (props: { onPreview: Mock }) =>
1208
1208
  props.onPreview.mock.calls.at(-1)?.[0].lanes[0].points as { t: number; v: number }[];
1209
1209
 
1210
1210
  it("moves every selected point by the same distance", () => {
@@ -1346,7 +1346,7 @@ describe("TimelineAutomationLane shift axis lock", () => {
1346
1346
  },
1347
1347
  ],
1348
1348
  };
1349
- const lastPoints = (props: { onPreview: ReturnType<typeof vi.fn> }) =>
1349
+ const lastPoints = (props: { onPreview: Mock }) =>
1350
1350
  props.onPreview.mock.calls.at(-1)?.[0].lanes[0].points as { t: number; v: number }[];
1351
1351
 
1352
1352
  it("holds the value when the pointer travels mostly sideways", () => {
@@ -1,3 +1,4 @@
1
+ import { buildProjectApiPath } from "../../utils/projectRouting";
1
2
  /** Rendered height of a timeline-clip thumbnail strip, in CSS px. */
2
3
  export const THUMBNAIL_CLIP_HEIGHT = 66;
3
4
 
@@ -113,7 +114,7 @@ export function resolveMediaPreviewUrl(
113
114
  return src;
114
115
  }
115
116
  if (!studioOrigin || parsed.origin !== studioOrigin) return src;
116
- const previewPath = new URL(`/api/projects/${projectId}/preview/`, studioOrigin).pathname;
117
+ const previewPath = new URL(buildProjectApiPath(projectId, `/preview/`), studioOrigin).pathname;
117
118
  if (parsed.pathname.startsWith(previewPath)) return src;
118
119
  if (parsed.pathname.startsWith("/api/")) return src;
119
120
  try {
@@ -128,5 +129,8 @@ export function resolveMediaPreviewUrl(
128
129
  suffix = `${parsed.search}${parsed.hash}`;
129
130
  }
130
131
 
131
- return `/api/projects/${projectId}/preview/${encodePreviewPath(relativePath.replace(/^\/+/, ""))}${suffix}`;
132
+ return buildProjectApiPath(
133
+ projectId,
134
+ `/preview/${encodePreviewPath(relativePath.replace(/^\/+/, ""))}${suffix}`,
135
+ );
132
136
  }