@hyperframes/studio 0.8.115 → 0.8.116

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 (149) hide show
  1. package/dist/assets/{hyperframes-player-C-9N8nk0.js → hyperframes-player-mGYvPVpw.js} +1 -1
  2. package/dist/assets/{index-BoUM6Xqb.js → index-BWHt0-ir.js} +1 -1
  3. package/dist/assets/{index-CAvZ0AA-.js → index-DYNaXPVf.js} +201 -201
  4. package/dist/assets/{index-CVuorIMv.js → index-Dr3hNpvS.js} +1 -1
  5. package/dist/{chunk-TXZMMOMA.js → chunk-3F47EFCT.js} +100 -2
  6. package/dist/chunk-3F47EFCT.js.map +1 -0
  7. package/dist/{domEditingLayers-D7VYFJJD.js → domEditingLayers-OL7GLSS4.js} +2 -2
  8. package/dist/index.d.ts +104 -22
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +1131 -1044
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/captions/hooks/useCaptionSync.ts +10 -6
  14. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  15. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  16. package/src/components/editor/domGroupMoveBy.ts +95 -0
  17. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  18. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  19. package/src/components/editor/manualEditingAvailability.ts +8 -0
  20. package/src/components/editor/manualOffsetDrag.ts +5 -6
  21. package/src/components/editor/motionPathSelection.test.ts +6 -31
  22. package/src/components/editor/probeSourceElement.ts +2 -1
  23. package/src/components/editor/propertyPanelFont.tsx +3 -2
  24. package/src/components/editor/useColorGradingController.ts +2 -1
  25. package/src/components/editor/useFxCarveNodes.ts +2 -1
  26. package/src/components/editor/useFxLevelling.ts +2 -1
  27. package/src/components/feedback/projectProvenance.ts +2 -1
  28. package/src/components/nle/NLEContext.test.ts +1 -0
  29. package/src/components/nle/NLEContext.tsx +2 -1
  30. package/src/components/nle/NLEPreview.tsx +12 -10
  31. package/src/components/nle/PreviewPoster.tsx +2 -1
  32. package/src/components/renders/useFfmpegStatus.ts +2 -1
  33. package/src/components/renders/useRenderQueue.ts +5 -4
  34. package/src/components/sidebar/AssetsTab.tsx +2 -1
  35. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  36. package/src/contexts/DomEditContext.tsx +4 -0
  37. package/src/hooks/domSelectionTestHarness.ts +23 -0
  38. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  39. package/src/hooks/domStyleCommit.ts +8 -1
  40. package/src/hooks/draggedGsapPosition.ts +10 -0
  41. package/src/hooks/gsapDragCommit.test.ts +75 -164
  42. package/src/hooks/gsapDragCommit.ts +2 -105
  43. package/src/hooks/gsapDragPositionCommit.ts +30 -393
  44. package/src/hooks/gsapEditOutcome.ts +22 -2
  45. package/src/hooks/gsapGroupPreflight.ts +34 -0
  46. package/src/hooks/gsapMutationClient.ts +4 -3
  47. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  48. package/src/hooks/gsapParsedTween.test.ts +62 -0
  49. package/src/hooks/gsapParsedTween.ts +243 -0
  50. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  51. package/src/hooks/gsapPositionDetection.ts +52 -33
  52. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  53. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  54. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  55. package/src/hooks/gsapResizeIntercept.test.ts +63 -11
  56. package/src/hooks/gsapResizeIntercept.ts +79 -169
  57. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  58. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  59. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  60. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  61. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  62. package/src/hooks/gsapScriptCommitTypes.ts +8 -0
  63. package/src/hooks/gsapShared.ts +7 -0
  64. package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
  65. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  66. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  67. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  68. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  69. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  70. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  71. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  72. package/src/hooks/newTweenTarget.test.ts +41 -76
  73. package/src/hooks/timelineTimingSync.test.ts +1 -0
  74. package/src/hooks/timelineTimingSync.ts +2 -1
  75. package/src/hooks/useAskAgentModal.ts +2 -1
  76. package/src/hooks/useBlockCatalog.ts +2 -1
  77. package/src/hooks/useCaptionDetection.ts +2 -1
  78. package/src/hooks/useCompositionContentLoader.ts +2 -1
  79. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  80. package/src/hooks/useDomEditSession.test.tsx +30 -1
  81. package/src/hooks/useDomEditSession.ts +10 -0
  82. package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
  83. package/src/hooks/useDomEditTextCommits.ts +8 -2
  84. package/src/hooks/useDomGeometryCommit.ts +1 -0
  85. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  86. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  87. package/src/hooks/useEditHistoryActions.ts +16 -5
  88. package/src/hooks/useElementLifecycleOps.ts +2 -1
  89. package/src/hooks/useEnableKeyframes.test.ts +32 -1
  90. package/src/hooks/useEnableKeyframes.ts +2 -1
  91. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
  92. package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
  93. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  94. package/src/hooks/useFileManager.ts +14 -10
  95. package/src/hooks/useFileTree.ts +3 -2
  96. package/src/hooks/useFrameCapture.ts +5 -1
  97. package/src/hooks/useFreezeFrame.ts +3 -2
  98. package/src/hooks/useGroupCommits.ts +2 -1
  99. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  100. package/src/hooks/useGsapAwareEditing.test.tsx +17 -29
  101. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  102. package/src/hooks/useGsapAwareEditing.ts +5 -0
  103. package/src/hooks/useGsapAwareGroupMove.ts +16 -16
  104. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  105. package/src/hooks/useGsapScriptCommits.test.tsx +3 -1
  106. package/src/hooks/useGsapScriptCommits.ts +12 -10
  107. package/src/hooks/useLintModal.ts +2 -1
  108. package/src/hooks/usePersistentEditHistory.test.ts +277 -1
  109. package/src/hooks/usePersistentEditHistory.ts +47 -8
  110. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  111. package/src/hooks/usePreviewPersistence.ts +13 -11
  112. package/src/hooks/useProjectFileWriter.ts +20 -19
  113. package/src/hooks/useRemoveBackground.ts +2 -1
  114. package/src/hooks/useSdkSession.ts +2 -1
  115. package/src/hooks/useServerConnection.ts +4 -3
  116. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  117. package/src/hooks/useThumbnailStill.test.ts +100 -0
  118. package/src/hooks/useThumbnailStill.ts +37 -0
  119. package/src/hooks/useTimelineDeleteOps.ts +2 -1
  120. package/src/index.ts +4 -0
  121. package/src/player/components/AudioWaveform.tsx +3 -2
  122. package/src/player/components/CompositionThumbnail.tsx +2 -1
  123. package/src/player/components/clipMenuNormalize.ts +2 -1
  124. package/src/player/components/clipPeakMap.ts +2 -1
  125. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  126. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  127. package/src/player/lib/thumbnailScheduler.ts +10 -2
  128. package/src/player/sceneSwap.ts +2 -1
  129. package/src/telemetry/client.ts +2 -1
  130. package/src/utils/blockInstaller.ts +2 -1
  131. package/src/utils/elementGsap.test.ts +9 -0
  132. package/src/utils/elementGsap.ts +7 -0
  133. package/src/utils/gsapSoftReload.ts +2 -3
  134. package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
  135. package/src/utils/gsapUndoRestore.ts +3 -6
  136. package/src/utils/previewPrefetch.ts +2 -1
  137. package/src/utils/razorSplitTransaction.ts +12 -8
  138. package/src/utils/softReloadTargets.ts +1 -3
  139. package/src/utils/studioApiFetch.test.ts +75 -0
  140. package/src/utils/studioApiFetch.ts +13 -0
  141. package/src/utils/studioFileHistory.ts +4 -1
  142. package/src/utils/studioHelpers.ts +7 -1
  143. package/src/utils/studioPendingEdits.test.ts +23 -6
  144. package/src/utils/studioPendingEdits.ts +32 -8
  145. package/src/utils/studioTelemetry.ts +2 -1
  146. package/src/utils/timelineCompositionInsert.ts +3 -2
  147. package/src/webmcp/StudioAgentTools.tsx +2 -1
  148. package/dist/chunk-TXZMMOMA.js.map +0 -1
  149. /package/dist/{domEditingLayers-D7VYFJJD.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
@@ -43,54 +43,60 @@ beforeAll(async () => {
43
43
 
44
44
  afterAll(() => browser?.close());
45
45
 
46
+ // The first case opens the cold browser's first page, which overran 5 s on a loaded CI runner.
46
47
  it.each([
47
48
  { gsap: "hold", placement: "px", rotation: 0 },
48
49
  { gsap: "tween", placement: "px", rotation: 0 },
49
50
  { gsap: "tween", placement: "xpercent", rotation: 30 },
50
- ])("an undone crop leaves a $gsap $placement r$rotation layer where it was", async (spec) => {
51
- const dir = join(mkdtempSync(join(tmpdir(), "undo-gsap-")), "case");
52
- writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
53
- const restored = readFileSync(join(dir, "index.html"), "utf8");
54
- const previous = restored.replace(
55
- `<div id="target"`,
56
- `<div id="target" style="clip-path: ${CROP}"`,
57
- );
58
- const page = await browser.newPage();
59
- await page.setViewport({ width: 1920, height: 1080 });
60
- await page.setRequestInterception(true);
61
- page.on("request", (request) =>
62
- request.url().endsWith("/gsap.min.js")
63
- ? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
64
- : request.continue(),
65
- );
66
- await page.setContent(restored, { waitUntil: "load" });
67
- await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
68
- await page.waitForFunction(() => "__player" in window);
69
- await page.evaluate(undoBundle);
51
+ ])(
52
+ "an undone crop leaves a $gsap $placement r$rotation layer where it was",
53
+ async (spec) => {
54
+ const dir = join(mkdtempSync(join(tmpdir(), "undo-gsap-")), "case");
55
+ writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
56
+ const restored = readFileSync(join(dir, "index.html"), "utf8");
57
+ const previous = restored.replace(
58
+ `<div id="target"`,
59
+ `<div id="target" style="clip-path: ${CROP}"`,
60
+ );
61
+ const page = await browser.newPage();
62
+ await page.setViewport({ width: 1920, height: 1080 });
63
+ await page.setRequestInterception(true);
64
+ // Only the fixture's GSAP CDN script is served; anything else is refused, never fetched.
65
+ page.on("request", (request) =>
66
+ request.url().endsWith("/gsap.min.js")
67
+ ? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
68
+ : request.abort("blockedbyclient"),
69
+ );
70
+ await page.setContent(restored, { waitUntil: "load" });
71
+ await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
72
+ await page.waitForFunction(() => "__player" in window);
73
+ await page.evaluate(undoBundle);
70
74
 
71
- const box = () =>
72
- page.evaluate(() => {
73
- const { left, top, width, height } = document
74
- .getElementById("target")!
75
- .getBoundingClientRect();
76
- return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
77
- });
78
- await page.evaluate(() =>
79
- (window as unknown as { __player: { seek(t: number): void } }).__player.seek(1),
80
- );
81
- const before = await box();
82
- const outcome = await page.evaluate(
83
- (files, crop) => {
84
- document.getElementById("target")!.style.setProperty("clip-path", crop);
85
- const undo = (window as unknown as { hfUndo: typeof import("./gsapUndoRestore") }).hfUndo;
86
- const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
87
- return undo.applyUndoRestoreToPreview(iframe, "index.html", files, 1, () => {});
88
- },
89
- { "index.html": { previous, restored } },
90
- CROP,
91
- );
75
+ const box = () =>
76
+ page.evaluate(() => {
77
+ const { left, top, width, height } = document
78
+ .getElementById("target")!
79
+ .getBoundingClientRect();
80
+ return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
81
+ });
82
+ await page.evaluate(() =>
83
+ (window as unknown as { __player: { seek(t: number): void } }).__player.seek(1),
84
+ );
85
+ const before = await box();
86
+ const outcome = await page.evaluate(
87
+ (files, crop) => {
88
+ document.getElementById("target")!.style.setProperty("clip-path", crop);
89
+ const undo = (window as unknown as { hfUndo: typeof import("./gsapUndoRestore") }).hfUndo;
90
+ const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
91
+ return undo.applyUndoRestoreToPreview(iframe, "index.html", files, 1, () => {});
92
+ },
93
+ { "index.html": { previous, restored } },
94
+ CROP,
95
+ );
92
96
 
93
- expect(outcome).toBe("soft");
94
- expect(await box()).toEqual(before);
95
- await page.close();
96
- });
97
+ expect(outcome).toBe("soft");
98
+ expect(await box()).toEqual(before);
99
+ await page.close();
100
+ },
101
+ 30_000,
102
+ );
@@ -398,20 +398,17 @@ export function applyUndoRestoreToPreview(
398
398
  const active = files[activeDocPath];
399
399
  const restoredScript = active ? extractGsapScriptText(active.restored) : null;
400
400
  const previousScript = active ? extractGsapScriptText(active.previous) : null;
401
- const reparse = plan.scripted
402
- ? plan.targets.map(({ live }) => live).filter((el) => "_gsap" in el)
403
- : [];
404
- if (reparse.length && !restoredScript) {
401
+ const reparse = plan.scripted && plan.targets.some(({ live }) => "_gsap" in live);
402
+ if (reparse && !restoredScript) {
405
403
  reloadPreview();
406
404
  return "full";
407
405
  }
408
- if (restoredScript && (restoredScript !== previousScript || reparse.length)) {
406
+ if (restoredScript && (restoredScript !== previousScript || reparse)) {
409
407
  syncStaleEditMarks(doc, active);
410
408
  const result = applySoftReload(iframe, restoredScript, {
411
409
  onAsyncFailure: reloadPreview,
412
410
  currentTimeOverride: currentTime,
413
411
  authoredHtml: active.restored,
414
- reparse,
415
412
  nestedFiles,
416
413
  });
417
414
  if (result === "cannot-soft-reload") {
@@ -1,5 +1,6 @@
1
1
  import { applyPreviewVariablesToUrl } from "../hooks/previewVariablesStore";
2
2
  import { buildProjectApiPath, parseProjectIdFromHash } from "./projectRouting";
3
+ import { studioApiFetch } from "./studioApiFetch";
3
4
 
4
5
  let prefetched: string | null = null;
5
6
 
@@ -10,5 +11,5 @@ export function prefetchPreviewForHash(hash: string): void {
10
11
  prefetched = projectId;
11
12
  const url = new URL(buildProjectApiPath(projectId, "/preview"), window.location.origin);
12
13
  applyPreviewVariablesToUrl(url);
13
- fetch(url).catch(() => undefined);
14
+ studioApiFetch(url).catch(() => undefined);
14
15
  }
@@ -6,6 +6,7 @@ import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
6
6
  import { buildProjectApiPath } from "./projectRouting";
7
7
  import { markStudioWriteToken } from "./studioFileVersion";
8
8
  import { resolveElementTrack } from "./studioHelpers";
9
+ import { studioApiFetch } from "./studioApiFetch";
9
10
 
10
11
  type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
11
12
 
@@ -101,7 +102,7 @@ export function buildAtomicCutIntents(
101
102
  }
102
103
 
103
104
  async function readFileVersion(projectId: string, path: string): Promise<string> {
104
- const response = await fetch(
105
+ const response = await studioApiFetch(
105
106
  buildProjectApiPath(projectId, `/files/${encodeURIComponent(path)}`),
106
107
  );
107
108
  if (!response.ok) throw new Error(`Failed to read ${path} before cut (${response.status})`);
@@ -124,14 +125,17 @@ async function requestAtomicCut(
124
125
  }
125
126
  const transactionToken = `cut:${crypto.randomUUID()}`;
126
127
  markStudioWriteToken(transactionToken);
127
- const response = await fetch(buildProjectApiPath(projectId, "/file-mutations/split-batch"), {
128
- method: "POST",
129
- headers: {
130
- "Content-Type": "application/json",
131
- "X-Hyperframes-Write-Token": transactionToken,
128
+ const response = await studioApiFetch(
129
+ buildProjectApiPath(projectId, "/file-mutations/split-batch"),
130
+ {
131
+ method: "POST",
132
+ headers: {
133
+ "Content-Type": "application/json",
134
+ "X-Hyperframes-Write-Token": transactionToken,
135
+ },
136
+ body: JSON.stringify({ files, transactionToken }),
132
137
  },
133
- body: JSON.stringify({ files, transactionToken }),
134
- });
138
+ );
135
139
  const body = (await response.json().catch(() => null)) as
136
140
  | (Partial<CutBatchResponse> & { error?: string; outcome?: string })
137
141
  | null;
@@ -48,10 +48,8 @@ export function collectResetTargets(
48
48
  win: { __timelines?: Record<string, unknown> },
49
49
  doc: Document,
50
50
  targetKeys: string[],
51
- reparse: Element[] = [],
52
51
  ): Map<Element, Set<string>> {
53
- // Elements the caller needs GSAP to parse again, with nothing tweened yet.
54
- const targets = new Map<Element, Set<string>>(reparse.map((el) => [el, new Set<string>()]));
52
+ const targets = new Map<Element, Set<string>>();
55
53
  const timelines = (win.__timelines ?? {}) as Record<string, TweenLike | undefined>;
56
54
  const others = new Set<unknown>(
57
55
  Object.entries(timelines).flatMap(([key, tl]) => (targetKeys.includes(key) ? [] : [tl])),
@@ -0,0 +1,75 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ async function loadWithEnv(env: Record<string, string>) {
4
+ vi.resetModules();
5
+ for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value);
6
+ return import("./studioApiFetch");
7
+ }
8
+
9
+ describe("studioApiFetch", () => {
10
+ afterEach(() => {
11
+ vi.unstubAllGlobals();
12
+ vi.unstubAllEnvs();
13
+ vi.resetModules();
14
+ });
15
+
16
+ it("sends Studio's requests without credentials, keeping the caller's options", async () => {
17
+ const fetchMock = vi.fn(async () => new Response("ok"));
18
+ vi.stubGlobal("fetch", fetchMock);
19
+ const { studioApiFetch } = await loadWithEnv({});
20
+ const signal = new AbortController().signal;
21
+
22
+ await studioApiFetch("/api/projects/demo/history/step", { method: "POST", signal });
23
+
24
+ expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/history/step", {
25
+ method: "POST",
26
+ signal,
27
+ credentials: "omit",
28
+ });
29
+ });
30
+
31
+ it.each([
32
+ ["the CLI or Studio dev server", "http://localhost:5190/"],
33
+ ["Desktop's own server", "http://127.0.0.1:43117/"],
34
+ ["IPv6 loopback", "http://[::1]:8080/"],
35
+ ])("leaves cookies out when Studio is served from %s", async (_, page) => {
36
+ const fetchMock = vi.fn(async () => new Response("ok"));
37
+ vi.stubGlobal("fetch", fetchMock);
38
+ vi.stubGlobal("location", new URL(page));
39
+ const { studioApiFetch } = await loadWithEnv({});
40
+
41
+ await studioApiFetch("/api/projects/demo/files/index.html");
42
+
43
+ expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/files/index.html", {
44
+ credentials: "omit",
45
+ });
46
+ });
47
+
48
+ it("keeps same-origin cookies off loopback, where a proxy or tunnel may authenticate with them", async () => {
49
+ const fetchMock = vi.fn(async () => new Response("ok"));
50
+ vi.stubGlobal("fetch", fetchMock);
51
+ vi.stubGlobal("location", new URL("https://studio.example.com/editor"));
52
+ const { studioApiFetch } = await loadWithEnv({});
53
+
54
+ await studioApiFetch("/api/projects/demo/files/index.html");
55
+
56
+ expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/files/index.html", {
57
+ credentials: "same-origin",
58
+ });
59
+ });
60
+
61
+ it("sends same-origin credentials on loopback for a host that opted out", async () => {
62
+ vi.stubGlobal("location", new URL("http://localhost:5190/"));
63
+ const fetchMock = vi.fn(async () => new Response("ok"));
64
+ vi.stubGlobal("fetch", fetchMock);
65
+ const { studioApiFetch } = await loadWithEnv({
66
+ VITE_STUDIO_API_SAME_ORIGIN_CREDENTIALS: "true",
67
+ });
68
+
69
+ await studioApiFetch("/api/projects/demo/files/index.html");
70
+
71
+ expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/files/index.html", {
72
+ credentials: "same-origin",
73
+ });
74
+ });
75
+ });
@@ -0,0 +1,13 @@
1
+ import { STUDIO_API_SAME_ORIGIN_CREDENTIALS } from "../components/editor/manualEditingAvailability";
2
+
3
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
4
+
5
+ function studioApiCredentials(): RequestCredentials {
6
+ if (STUDIO_API_SAME_ORIGIN_CREDENTIALS) return "same-origin";
7
+ const host = globalThis.location?.hostname;
8
+ return !host || LOOPBACK_HOSTS.has(host) ? "omit" : "same-origin";
9
+ }
10
+
11
+ export function studioApiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
12
+ return globalThis.fetch(input, { ...init, credentials: studioApiCredentials() });
13
+ }
@@ -2,6 +2,7 @@ import { buildProjectApiPath } from "./projectRouting";
2
2
  import type { MutableRefObject } from "react";
3
3
  import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
4
4
  import { createStudioSaveHttpError } from "./studioSaveDiagnostics";
5
+ import { studioApiFetch } from "./studioApiFetch";
5
6
 
6
7
  export interface RecordEditInput {
7
8
  label: string;
@@ -49,7 +50,9 @@ interface SaveProjectFilesWithHistoryInput {
49
50
  }
50
51
 
51
52
  export async function readProjectFileContent(pid: string, path: string): Promise<string> {
52
- const response = await fetch(buildProjectApiPath(pid, `/files/${encodeURIComponent(path)}`));
53
+ const response = await studioApiFetch(
54
+ buildProjectApiPath(pid, `/files/${encodeURIComponent(path)}`),
55
+ );
53
56
  if (!response.ok) {
54
57
  throw await createStudioSaveHttpError(response, `Failed to read ${path}`);
55
58
  }
@@ -4,6 +4,7 @@ import type { TimelineElement } from "../player/store/playerStore";
4
4
  import type { DomEditSelection } from "../components/editor/domEditing";
5
5
  import type { TimelineAssetKind } from "./timelineAssetDrop";
6
6
  import { roundToCenti } from "./rounding";
7
+ import { studioApiFetch } from "./studioApiFetch";
7
8
 
8
9
  export interface EditingFile {
9
10
  path: string;
@@ -91,6 +92,11 @@ export function isImageBackgroundValue(value: string): boolean {
91
92
  return /^url\(/i.test(value.trim());
92
93
  }
93
94
 
95
+ export function cssPropertyName(property: string): string {
96
+ if (property.startsWith("--")) return property;
97
+ return property.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`).replace(/^ms-/, "-ms-");
98
+ }
99
+
94
100
  export function isManualGeometryStyleProperty(property: string): boolean {
95
101
  return property === "left" || property === "top" || property === "width" || property === "height";
96
102
  }
@@ -401,7 +407,7 @@ export async function resolveAssetHasAudio(
401
407
  assetPath: string,
402
408
  ): Promise<boolean | null> {
403
409
  try {
404
- const response = await fetch(mediaMetadataUrl(projectId, assetPath));
410
+ const response = await studioApiFetch(mediaMetadataUrl(projectId, assetPath));
405
411
  if (!response.ok) return null;
406
412
  const data: unknown = await response.json();
407
413
  const metadata = isPlainRecord(data) ? data.metadata : undefined;
@@ -5,7 +5,7 @@ import {
5
5
  beginStudioPendingEdit,
6
6
  flushStudioPendingEdits,
7
7
  hasStudioPendingEdits,
8
- revertNewestStudioPendingEdit,
8
+ paintBackNewestStudioPendingEdit,
9
9
  trackStudioPendingEdit,
10
10
  trackedStudioEdit,
11
11
  } from "./studioPendingEdits";
@@ -227,12 +227,12 @@ describe("a pending edit undo can paint back", () => {
227
227
  shown.length = 0;
228
228
  const first = edit("first");
229
229
  const second = edit("second");
230
- const again = revertNewestStudioPendingEdit();
230
+ const again = paintBackNewestStudioPendingEdit();
231
231
  expect(shown).toEqual(["second undone"]);
232
232
  expect(second.reverted()).toBe(true);
233
233
  expect(first.reverted()).toBe(false);
234
- expect(revertNewestStudioPendingEdit()).toBeNull();
235
- again!();
234
+ expect(paintBackNewestStudioPendingEdit()).toBeNull();
235
+ again!.showAgain();
236
236
  expect(shown).toEqual(["second undone", "second again"]);
237
237
  first.settle();
238
238
  second.settle();
@@ -243,7 +243,7 @@ describe("a pending edit undo can paint back", () => {
243
243
  const move = edit("move");
244
244
  let saved!: () => void;
245
245
  trackStudioPendingEdit(new Promise<void>((resolve) => (saved = resolve)));
246
- expect(revertNewestStudioPendingEdit()).toBeNull();
246
+ expect(paintBackNewestStudioPendingEdit()).toBeNull();
247
247
  expect(shown).toEqual([]);
248
248
  saved();
249
249
  move.settle();
@@ -258,7 +258,7 @@ describe("a pending edit undo can paint back", () => {
258
258
  trackStudioPendingEdit(new Promise<void>((resolve) => (saved = resolve))),
259
259
  );
260
260
  move.settle(save);
261
- revertNewestStudioPendingEdit();
261
+ paintBackNewestStudioPendingEdit();
262
262
  expect(shown).toEqual(["move undone"]);
263
263
  saved();
264
264
  await expect(flushStudioPendingEdits()).resolves.toEqual({ status: "clean" });
@@ -295,3 +295,20 @@ describe("the package's public revert", () => {
295
295
  }
296
296
  });
297
297
  });
298
+
299
+ describe("a drain of only the current edits", () => {
300
+ it("ends once the edits pending at its start land, without waiting for one started after", async () => {
301
+ let landFirst!: () => void;
302
+ let landLater!: () => void;
303
+ trackStudioPendingEdit(new Promise<void>((resolve) => (landFirst = resolve)));
304
+ let drained = false;
305
+ const drain = flushStudioPendingEdits({ onlyCurrent: true }).then(() => (drained = true));
306
+ await Promise.resolve();
307
+ trackStudioPendingEdit(new Promise<void>((resolve) => (landLater = resolve)));
308
+ landFirst();
309
+ await drain;
310
+ expect(drained).toBe(true);
311
+ expect(hasStudioPendingEdits()).toBe(true);
312
+ landLater();
313
+ });
314
+ });
@@ -11,7 +11,13 @@ export type StudioPendingEditsDrainResult = StudioSaveDrainResult;
11
11
 
12
12
  export type StudioEditRevert = () => () => void;
13
13
 
14
- const pendingEdits = new Map<Promise<unknown>, { revert: StudioEditRevert | null }>();
14
+ interface PendingEdit {
15
+ revert: StudioEditRevert | null;
16
+ landed: () => Promise<boolean>;
17
+ }
18
+
19
+ const pendingEdits = new Map<Promise<unknown>, PendingEdit>();
20
+ const NOT_SAVED = () => Promise.resolve(false);
15
21
  let adopting = false;
16
22
 
17
23
  function waitForPostBlurEffects(): Promise<void> {
@@ -67,7 +73,7 @@ export function trackStudioPendingEdit(
67
73
  if (!result) return undefined;
68
74
  const promise = Promise.resolve(result);
69
75
  if (adopting) return promise;
70
- pendingEdits.set(promise, { revert: null });
76
+ pendingEdits.set(promise, { revert: null, landed: NOT_SAVED });
71
77
  promise.then(
72
78
  () => pendingEdits.delete(promise),
73
79
  () => pendingEdits.delete(promise),
@@ -90,6 +96,8 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
90
96
  const promise = trackStudioPendingEdit(new Promise<unknown>((resolve) => (settle = resolve)))!;
91
97
  const entry = pendingEdits.get(promise)!;
92
98
  entry.revert = revert;
99
+ let landed = Promise.resolve(false);
100
+ entry.landed = () => landed;
93
101
  return {
94
102
  settle,
95
103
  reverted: () => entry.revert === null && revert !== null,
@@ -97,7 +105,12 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
97
105
  adopt<T>(start: () => T): T {
98
106
  adopting = true;
99
107
  try {
100
- return start();
108
+ const committed = start();
109
+ landed = Promise.resolve(committed).then(
110
+ () => true,
111
+ () => false,
112
+ );
113
+ return committed;
101
114
  } catch (error) {
102
115
  settle();
103
116
  throw error;
@@ -108,15 +121,24 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
108
121
  };
109
122
  }
110
123
 
111
- export function revertNewestStudioPendingEdit(): (() => void) | null {
124
+ export function paintBackNewestStudioPendingEdit(): {
125
+ showAgain: () => void;
126
+ landed: () => Promise<boolean>;
127
+ } | null {
112
128
  const newest = [...pendingEdits.values()].at(-1);
113
129
  const revert = newest?.revert;
114
130
  if (!newest || !revert) return null;
115
131
  newest.revert = null;
116
- return revert();
132
+ return { showAgain: revert(), landed: newest.landed };
133
+ }
134
+
135
+ export function revertNewestStudioPendingEdit(): (() => void) | null {
136
+ return paintBackNewestStudioPendingEdit()?.showAgain ?? null;
117
137
  }
118
138
 
119
- export async function flushStudioPendingEdits(): Promise<StudioPendingEditsDrainResult> {
139
+ export async function flushStudioPendingEdits({
140
+ onlyCurrent = false,
141
+ } = {}): Promise<StudioPendingEditsDrainResult> {
120
142
  const active = focusedField();
121
143
  if (active) {
122
144
  active.blur();
@@ -128,10 +150,12 @@ export async function flushStudioPendingEdits(): Promise<StudioPendingEditsDrain
128
150
  window.dispatchEvent(
129
151
  new CustomEvent<StudioFlushPendingEditsDetail>(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }),
130
152
  );
153
+ const current = onlyCurrent ? new Set(pendingEdits.keys()) : null;
154
+ const waiting = () => [...pendingEdits.keys()].filter((edit) => !current || current.has(edit));
131
155
  let conflict: StudioFileConflictError | undefined;
132
156
  let firstFailure: PromiseRejectedResult | undefined;
133
- while (detail.promises.length > 0 || pendingEdits.size > 0) {
134
- const promises = [...detail.promises, ...pendingEdits.keys()];
157
+ while (detail.promises.length > 0 || waiting().length > 0) {
158
+ const promises = [...detail.promises, ...waiting()];
135
159
  detail.promises = [];
136
160
  const batchFailures = inspectDrainFailures(await Promise.allSettled(promises));
137
161
  conflict ??= batchFailures.conflict;
@@ -4,6 +4,7 @@ import { browserTelemetryAllowed } from "../telemetry/policy";
4
4
  import { canaryEventProperties } from "../telemetry/canary";
5
5
  import { agentRuntimeProperty } from "../telemetry/agentRuntime";
6
6
  import { tabIdProperty } from "../telemetry/tabId";
7
+ import { studioApiFetch } from "./studioApiFetch";
7
8
 
8
9
  // PostHog public ingest key — write-only, safe to ship in the client bundle
9
10
  const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
@@ -149,7 +150,7 @@ async function flushEvents(): Promise<void> {
149
150
  const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
150
151
 
151
152
  try {
152
- await fetch(`${POSTHOG_HOST}/batch/`, {
153
+ await studioApiFetch(`${POSTHOG_HOST}/batch/`, {
153
154
  method: "POST",
154
155
  headers: { "Content-Type": "application/json" },
155
156
  body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
@@ -4,6 +4,7 @@ import type { RecordEditInput } from "./studioFileHistory";
4
4
  import { buildProjectApiPath } from "./projectRouting";
5
5
  import { studioWriteHeaders } from "./studioFileVersion";
6
6
  import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
7
+ import { studioApiFetch } from "./studioApiFetch";
7
8
 
8
9
  interface TimelineCompositionInsertionResult {
9
10
  path: string;
@@ -20,7 +21,7 @@ async function insertTimelineComposition(input: {
20
21
  start: number;
21
22
  track: number;
22
23
  }): Promise<TimelineCompositionInsertionResult> {
23
- const current = await fetch(
24
+ const current = await studioApiFetch(
24
25
  buildProjectApiPath(input.projectId, `/files/${encodeURIComponent(input.targetPath)}`),
25
26
  );
26
27
  if (!current.ok) {
@@ -29,7 +30,7 @@ async function insertTimelineComposition(input: {
29
30
  const snapshot = (await current.json()) as { version?: string };
30
31
  if (typeof snapshot.version !== "string") throw new Error("Missing composition file version");
31
32
 
32
- const response = await fetch(
33
+ const response = await studioApiFetch(
33
34
  buildProjectApiPath(
34
35
  input.projectId,
35
36
  `/file-mutations/insert-composition/${encodeURIComponent(input.targetPath)}`,
@@ -12,6 +12,7 @@ import {
12
12
  captureStudioBoxSize,
13
13
  restoreStudioBoxSize,
14
14
  } from "../components/editor/manualEdits";
15
+ import { studioApiFetch } from "../utils/studioApiFetch";
15
16
 
16
17
  export function readLiveSelectionBox(
17
18
  doc: Document | null | undefined,
@@ -141,7 +142,7 @@ export function StudioAgentTools() {
141
142
  // fetches the URL itself.
142
143
  probeFrame: async (url) => {
143
144
  try {
144
- const response = await fetch(url, { method: "HEAD" });
145
+ const response = await studioApiFetch(url, { method: "HEAD" });
145
146
  return { ok: response.ok, status: response.status };
146
147
  } catch {
147
148
  return { ok: false, status: 0 };