@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
@@ -6,11 +6,22 @@ import {
6
6
  StudioSaveNetworkError,
7
7
  } from "../utils/studioSaveDiagnostics";
8
8
  import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion";
9
+ import { studioApiFetch } from "../utils/studioApiFetch";
9
10
 
10
11
  export interface UseProjectFileWriterOptions {
11
12
  projectId: string | null;
12
13
  }
13
14
 
15
+ async function fetchProjectFile(projectId: string | null, path: string, query: string) {
16
+ if (!projectId) throw new Error("No active project");
17
+ const response = await studioApiFetch(
18
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}${query}`,
19
+ );
20
+ if (!response.ok) throw new Error(`Failed to read ${path}`);
21
+ const data = (await response.json()) as { content?: string; version?: string };
22
+ return { content: data.content, version: data.version ?? response.headers.get("etag") };
23
+ }
24
+
14
25
  // The etag-guarded project-file read/write pair every Studio save path
15
26
  // shares. A host mounting hand editing outside Studio needs only this, not
16
27
  // the full file manager (file tree, editor tabs, uploads, font assets).
@@ -30,29 +41,19 @@ export function useProjectFileWriter({ projectId }: UseProjectFileWriterOptions)
30
41
 
31
42
  const readProjectFile = useCallback(
32
43
  async (path: string): Promise<string> => {
33
- if (!projectId) throw new Error("No active project");
34
- const response = await fetch(
35
- `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
36
- );
37
- if (!response.ok) throw new Error(`Failed to read ${path}`);
38
- const data = (await response.json()) as { content?: string; version?: string };
39
- if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
40
- fileVersions.set(path, data.version ?? response.headers.get("etag"));
41
- return data.content;
44
+ const { content, version } = await fetchProjectFile(projectId, path, "");
45
+ if (typeof content !== "string") throw new Error(`Missing file contents for ${path}`);
46
+ fileVersions.set(path, version);
47
+ return content;
42
48
  },
43
49
  [fileVersions, projectId],
44
50
  );
45
51
 
46
52
  const readOptionalProjectFile = useCallback(
47
53
  async (path: string): Promise<string> => {
48
- if (!projectId) throw new Error("No active project");
49
- const response = await fetch(
50
- `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
51
- );
52
- if (!response.ok) throw new Error(`Failed to read ${path}`);
53
- const data = (await response.json()) as { content?: string; version?: string };
54
- fileVersions.set(path, data.version ?? response.headers.get("etag"));
55
- return typeof data.content === "string" ? data.content : "";
54
+ const { content, version } = await fetchProjectFile(projectId, path, "?optional=1");
55
+ fileVersions.set(path, version);
56
+ return typeof content === "string" ? content : "";
56
57
  },
57
58
  [fileVersions, projectId],
58
59
  );
@@ -63,7 +64,7 @@ export function useProjectFileWriter({ projectId }: UseProjectFileWriterOptions)
63
64
  const writeProjectId = projectId;
64
65
  let expectedVersion = await studioExpectedFileVersion(fileVersions, path, expectedContent);
65
66
  if (expectedVersion === undefined) {
66
- const preflight = await fetch(
67
+ const preflight = await studioApiFetch(
67
68
  `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
68
69
  );
69
70
  if (preflight.ok) {
@@ -86,7 +87,7 @@ export function useProjectFileWriter({ projectId }: UseProjectFileWriterOptions)
86
87
  // the retry can produce a second filesystem receipt that must be suppressed independently.
87
88
  let response: Response;
88
89
  try {
89
- response = await fetch(
90
+ response = await studioApiFetch(
90
91
  `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
91
92
  {
92
93
  method: "PUT",
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
2
  import { waitForMediaJob } from "../components/studioMediaJobs";
3
3
  import type { BackgroundRemovalProgress } from "../components/editor/propertyPanelTypes";
4
+ import { studioApiFetch } from "../utils/studioApiFetch";
4
5
 
5
6
  interface RemoveBackgroundOptions {
6
7
  createBackgroundPlate?: boolean;
@@ -31,7 +32,7 @@ export function useRemoveBackground(
31
32
  return useCallback(
32
33
  // fallow-ignore-next-line complexity
33
34
  async (inputPath: string, options: RemoveBackgroundOptions) => {
34
- const response = await fetch(
35
+ const response = await studioApiFetch(
35
36
  `/api/projects/${encodeURIComponent(projectId)}/media/remove-background`,
36
37
  {
37
38
  method: "POST",
@@ -6,6 +6,7 @@ import { trackStudioEvent } from "../utils/studioTelemetry";
6
6
  import type { PublishSdkSession } from "../utils/sdkCutover";
7
7
  import { addExternalFileReloadListener } from "./externalFileReloadBus";
8
8
  import { whenPreviewBooted } from "../player/store/playerStore";
9
+ import { studioApiFetch } from "../utils/studioApiFetch";
9
10
 
10
11
  /**
11
12
  * Why an optional project-file read produced no usable content. `stage: "read"`
@@ -194,7 +195,7 @@ async function readProjectFileOptional(
194
195
  let res: Response;
195
196
  const fetchStarted = performance.now();
196
197
  try {
197
- res = await fetch(
198
+ res = await studioApiFetch(
198
199
  `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
199
200
  );
200
201
  } catch {
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
2
2
  import { buildProjectHash, parseProjectIdFromHash } from "../utils/projectRouting";
3
3
  import { useMountEffect } from "./useMountEffect";
4
4
  import { trackStudioEvent } from "../utils/studioTelemetry";
5
+ import { studioApiFetch } from "../utils/studioApiFetch";
5
6
 
6
7
  interface ServerConnectionState {
7
8
  projectId: string | null;
@@ -37,7 +38,7 @@ type ProjectHashVerdict = {
37
38
 
38
39
  async function resolveHashProject(id: string): Promise<ProjectHashVerdict> {
39
40
  try {
40
- const res = await fetch(`/api/projects/${encodeURIComponent(id)}`);
41
+ const res = await studioApiFetch(`/api/projects/${encodeURIComponent(id)}`);
41
42
  if (res.ok) return { outcome: "ok", status: res.status };
42
43
  return { outcome: res.status === 404 ? "missing" : "unknown", status: res.status };
43
44
  } catch {
@@ -72,7 +73,7 @@ function replaceProjectHash(projectId: string): void {
72
73
  */
73
74
  async function firstProjectId(): Promise<string | null> {
74
75
  try {
75
- const res = await fetch("/api/projects");
76
+ const res = await studioApiFetch("/api/projects");
76
77
  const data = (await res.json()) as { projects?: Array<{ id?: string }> };
77
78
  return data.projects?.[0]?.id ?? null;
78
79
  } catch {
@@ -107,7 +108,7 @@ export function useServerConnection(): ServerConnectionState {
107
108
  }
108
109
 
109
110
  function tryConnect() {
110
- fetch("/api/projects")
111
+ studioApiFetch("/api/projects")
111
112
  .then((r) => r.json())
112
113
  .then(async (data) => {
113
114
  if (cancelled) return;
@@ -3,6 +3,7 @@ import type { DomEditSelection } from "../components/editor/domEditing";
3
3
  import { usePlayerStore } from "../player";
4
4
  import { buildStudioSelectionSnapshot } from "../utils/studioSelectionSnapshot";
5
5
  import { trackStudioEvent } from "../utils/studioTelemetry";
6
+ import { studioApiFetch } from "../utils/studioApiFetch";
6
7
 
7
8
  interface UseStudioSelectionPublisherParams {
8
9
  projectId: string | null;
@@ -26,7 +27,7 @@ function reportSelectionPublishError(error: unknown): void {
26
27
  }
27
28
 
28
29
  function putSelection(projectId: string, selection: unknown, signal?: AbortSignal): Promise<void> {
29
- return fetch(`/api/projects/${encodeURIComponent(projectId)}/selection`, {
30
+ return studioApiFetch(`/api/projects/${encodeURIComponent(projectId)}/selection`, {
30
31
  method: "PUT",
31
32
  headers: { "Content-Type": "application/json" },
32
33
  body: JSON.stringify({ selection }),
@@ -0,0 +1,100 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { thumbnailScheduler } from "../player/lib/thumbnailScheduler";
7
+ // By package name, as a host imports it: the test fails if the export goes.
8
+ import { useThumbnailStill, type ThumbnailStill } from "@hyperframes/studio";
9
+
10
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
11
+
12
+ class MockImage {
13
+ onload: (() => void) | null = null;
14
+ onerror: (() => void) | null = null;
15
+ naturalWidth = 320;
16
+ naturalHeight = 180;
17
+ set src(_value: string) {
18
+ queueMicrotask(() => this.onload?.());
19
+ }
20
+ }
21
+
22
+ const URL_ONE = "http://localhost:3000/api/projects/demo/thumbnail/index.html?t=1.00&revision=1";
23
+ const originalImage = globalThis.Image;
24
+ const originalFetch = globalThis.fetch;
25
+ const originalCreate = URL.createObjectURL;
26
+ const originalRevoke = URL.revokeObjectURL;
27
+ let host: HTMLDivElement;
28
+ let root: Root | null = null;
29
+ let shown: ThumbnailStill | null = null;
30
+
31
+ function Still({ url }: { url: string | null }) {
32
+ shown = useThumbnailStill(url, "demo");
33
+ return null;
34
+ }
35
+
36
+ const mount = (url: string | null) =>
37
+ act(async () => {
38
+ root ??= createRoot(host);
39
+ root.render(React.createElement(Still, { url }));
40
+ });
41
+
42
+ beforeEach(() => {
43
+ globalThis.Image = MockImage as unknown as typeof Image;
44
+ globalThis.fetch = vi.fn(async () => new Response(new Blob(["still"]), { status: 200 }));
45
+ URL.createObjectURL = vi.fn(() => "blob:still");
46
+ URL.revokeObjectURL = vi.fn();
47
+ shown = null;
48
+ host = document.createElement("div");
49
+ document.body.append(host);
50
+ });
51
+
52
+ afterEach(() => {
53
+ act(() => root?.unmount());
54
+ root = null;
55
+ thumbnailScheduler.setPreviewReloading(false);
56
+ thumbnailScheduler.invalidateProject("demo");
57
+ globalThis.Image = originalImage;
58
+ globalThis.fetch = originalFetch;
59
+ URL.createObjectURL = originalCreate;
60
+ URL.revokeObjectURL = originalRevoke;
61
+ document.body.replaceChildren();
62
+ });
63
+
64
+ describe("useThumbnailStill", () => {
65
+ it("hands back the still's object URL once the scheduler has loaded it", async () => {
66
+ await mount(URL_ONE);
67
+ expect(globalThis.fetch).toHaveBeenCalledWith(URL_ONE, expect.anything());
68
+ await vi.waitFor(() => expect(shown).toEqual({ status: "ready", url: "blob:still" }));
69
+ });
70
+
71
+ it("hands back the same object while nothing changes", async () => {
72
+ await mount(URL_ONE);
73
+ await vi.waitFor(() => expect(shown).toEqual({ status: "ready", url: "blob:still" }));
74
+ const first = shown;
75
+ await mount(URL_ONE);
76
+ expect(shown).toBe(first);
77
+ });
78
+
79
+ it("asks for nothing without a url", async () => {
80
+ await mount(null);
81
+ expect(globalThis.fetch).not.toHaveBeenCalled();
82
+ expect(shown).toEqual({ status: "loading" });
83
+ });
84
+
85
+ it("says failed when the still cannot load, so a host can fall back", async () => {
86
+ globalThis.fetch = vi.fn(async () => new Response("", { status: 500 }));
87
+ await mount(URL_ONE);
88
+ await vi.waitFor(() => expect(shown).toEqual({ status: "failed" }));
89
+ });
90
+
91
+ it("holds the fetch while a preview reloads, and runs it once the reload is over", async () => {
92
+ thumbnailScheduler.setPreviewReloading(true);
93
+ await mount(URL_ONE);
94
+ expect(globalThis.fetch).not.toHaveBeenCalled();
95
+ expect(shown).toEqual({ status: "loading" });
96
+ await act(async () => thumbnailScheduler.setPreviewReloading(false));
97
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
98
+ await vi.waitFor(() => expect(shown).toEqual({ status: "ready", url: "blob:still" }));
99
+ });
100
+ });
@@ -0,0 +1,37 @@
1
+ import { useMemo } from "react";
2
+ import { compositionThumbnailRequest } from "../player/components/CompositionThumbnail";
3
+ import type { ThumbnailPriority } from "../player/lib/thumbnailScheduler";
4
+ import { useThumbnailLease } from "./useThumbnailLease";
5
+
6
+ export interface UseThumbnailStillOptions {
7
+ sessionEpoch?: number;
8
+ priority?: ThumbnailPriority;
9
+ }
10
+
11
+ export type ThumbnailStill =
12
+ | { status: "loading" }
13
+ | { status: "ready"; url: string }
14
+ | { status: "failed" };
15
+
16
+ const LOADING: ThumbnailStill = Object.freeze({ status: "loading" });
17
+ const FAILED: ThumbnailStill = Object.freeze({ status: "failed" });
18
+
19
+ /** A host picture's server still through the thumbnail scheduler, so it yields to a preview reload. */
20
+ export function useThumbnailStill(
21
+ url: string | null,
22
+ projectId: string,
23
+ { sessionEpoch = 0, priority = "visible" }: UseThumbnailStillOptions = {},
24
+ ): ThumbnailStill {
25
+ const request = useMemo(
26
+ () => (url ? compositionThumbnailRequest(url, projectId, { sessionEpoch, priority }) : null),
27
+ [priority, projectId, sessionEpoch, url],
28
+ );
29
+ const snapshot = useThumbnailLease(request);
30
+ const ready =
31
+ snapshot.status === "ready" && snapshot.value.kind === "image" ? snapshot.value.url : null;
32
+ return useMemo(
33
+ () =>
34
+ ready ? { status: "ready", url: ready } : snapshot.status === "error" ? FAILED : LOADING,
35
+ [ready, snapshot.status],
36
+ );
37
+ }
@@ -21,6 +21,7 @@ import type {
21
21
  TimelineGroupCommitOptions,
22
22
  TimelineGroupMoveChange,
23
23
  } from "./useTimelineGroupEditing";
24
+ import { studioApiFetch } from "../utils/studioApiFetch";
24
25
 
25
26
  /** Apply already-resolved ripple changes to the surviving elements for the
26
27
  * optimistic store update after a delete. Pure — no IO. */
@@ -130,7 +131,7 @@ export function useTimelineDeleteOps({
130
131
  }
131
132
  return patchTarget;
132
133
  });
133
- const removeResponse = await fetch(
134
+ const removeResponse = await studioApiFetch(
134
135
  buildProjectApiPath(
135
136
  pid,
136
137
  `/file-mutations/remove-elements/${encodeURIComponent(targetPath)}`,
package/src/index.ts CHANGED
@@ -76,6 +76,8 @@ export type { AudioWaveformProps } from "./player/components/AudioWaveform";
76
76
  export { ImageThumbnail } from "./player/components/ImageThumbnail";
77
77
  export type { ImageThumbnailProps } from "./player/components/ImageThumbnail";
78
78
  export { useRenderClipContent } from "./hooks/useRenderClipContent";
79
+ export { useThumbnailStill } from "./hooks/useThumbnailStill";
80
+ export type { ThumbnailStill, UseThumbnailStillOptions } from "./hooks/useThumbnailStill";
79
81
  export type { UseRenderClipContentOptions } from "./hooks/useRenderClipContent";
80
82
  export type { ThumbnailPriority } from "./player/lib/thumbnailScheduler";
81
83
  export type {
@@ -208,6 +210,8 @@ export type { DomEditZOrder } from "./components/editor/useDomEditZOrder";
208
210
  export type { ZOrderAction } from "./components/editor/canvasContextMenuZOrder";
209
211
  export type { DomEditCapabilities, DomEditSelection } from "./components/editor/domEditingTypes";
210
212
  export { useDomStyleCommit } from "./hooks/useDomStyleCommit";
213
+ export { ColorField } from "./components/editor/propertyPanelColor";
214
+ export { GradientField } from "./components/editor/propertyPanelFill";
211
215
  export type { UseDomStyleCommitOptions } from "./hooks/useDomStyleCommit";
212
216
  export type { DomEditCommitDeclineReason, DomEditCommitOutcome } from "./hooks/domEditCommitRunner";
213
217
  export { resolveDomEditSelection } from "./components/editor/domEditingLayers";
@@ -5,6 +5,7 @@ import { useThumbnailLease } from "../../hooks/useThumbnailLease";
5
5
  import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
6
6
  import { decimatePeaks, loudnessToOpacity } from "./audioWaveformPeaks";
7
7
  import { ClipFadesContext, type ClipFadeShape } from "./TimelineClipFades";
8
+ import { studioApiFetch } from "../../utils/studioApiFetch";
8
9
 
9
10
  export interface AudioWaveformProps {
10
11
  audioUrl: string;
@@ -124,7 +125,7 @@ async function loadWaveform(
124
125
  }
125
126
 
126
127
  async function fetchWaveformPeaks(url: string, signal: AbortSignal): Promise<number[]> {
127
- const response = await fetch(url, { signal });
128
+ const response = await studioApiFetch(url, { signal });
128
129
  if (!response.ok) throw new Error(`Waveform request failed (${response.status})`);
129
130
  const data: unknown = await response.json();
130
131
  if (
@@ -140,7 +141,7 @@ async function fetchWaveformPeaks(url: string, signal: AbortSignal): Promise<num
140
141
  }
141
142
 
142
143
  async function decodeWaveformPeaks(url: string, signal: AbortSignal): Promise<number[]> {
143
- const response = await fetch(url, { signal });
144
+ const response = await studioApiFetch(url, { signal });
144
145
  if (!response.ok) throw new Error(`Audio request failed (${response.status})`);
145
146
  const buffer = await response.arrayBuffer();
146
147
  if (signal.aborted) throw new DOMException("Aborted", "AbortError");
@@ -8,6 +8,7 @@ import {
8
8
  } from "../lib/thumbnailScheduler";
9
9
  import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
10
10
  import { computeThumbnailStrip, probeImageAspect } from "./thumbnailUtils";
11
+ import { studioApiFetch } from "../../utils/studioApiFetch";
11
12
 
12
13
  interface CompositionThumbnailProps {
13
14
  previewUrl: string;
@@ -106,7 +107,7 @@ export function compositionThumbnailRequest(
106
107
  }
107
108
 
108
109
  async function loadCompositionImage(url: string, signal: AbortSignal) {
109
- const response = await fetch(url, { signal });
110
+ const response = await studioApiFetch(url, { signal });
110
111
  if (!response.ok) throw new Error(`Composition thumbnail failed (${response.status})`);
111
112
  const blob = await response.blob();
112
113
  if (signal.aborted) throw new DOMException("Aborted", "AbortError");
@@ -4,6 +4,7 @@ import { resolveMediaPreviewUrl } from "./thumbnailUtils";
4
4
  import type { TimelineElement } from "../store/timelineElement";
5
5
  import { parseAutomation, VOLUME_TARGET } from "@hyperframes/core/audio-automation";
6
6
  import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
7
+ import { studioApiFetch } from "../../utils/studioApiFetch";
7
8
 
8
9
  export interface NormalizePlan {
9
10
  targetLufs: number;
@@ -74,7 +75,7 @@ function isNormalizePlan(value: unknown): value is NormalizePlan {
74
75
  export async function requestNormalizePlan(
75
76
  projectId: string,
76
77
  el: TimelineElement,
77
- fetchImpl: typeof fetch = fetch,
78
+ fetchImpl: typeof studioApiFetch = studioApiFetch,
78
79
  ): Promise<NormalizePlan> {
79
80
  const origin = window.location.origin;
80
81
  const relative = resolvePreviewRelative(
@@ -1,4 +1,5 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
+ import { studioApiFetch } from "../../utils/studioApiFetch";
2
3
  import { resolvePreviewRelative } from "../../utils/previewRelativePath";
3
4
  import type { TimelineElement } from "../store/timelineElement";
4
5
  import { clipSourcePeak, isPeakMap, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns";
@@ -10,7 +11,7 @@ const peakMapRequests = new Map<string, Promise<PeakMap | null>>();
10
11
  export function loadPeakMap(url: string): Promise<PeakMap | null> {
11
12
  const pending = peakMapRequests.get(url);
12
13
  if (pending) return pending;
13
- const request = fetch(url)
14
+ const request = studioApiFetch(url)
14
15
  .then((res) => (res.ok ? res.json() : null))
15
16
  .then((body: unknown) => (isPeakMap(body) ? body : null))
16
17
  .catch(() => null);
@@ -36,6 +36,7 @@ import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFi
36
36
  import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
37
37
  import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
38
38
  import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
39
+ import { isStudioManualEditGestureLiveIn } from "../../components/editor/manualEditsDom";
39
40
  import { createPreviewMessageHandler } from "./previewMessageRouter";
40
41
  import { timelineElementsChanged } from "./timelinePlayerSync";
41
42
  import { safeContentDocument } from "./timelineSyncHydration";
@@ -486,7 +487,10 @@ export function useTimelinePlayer({
486
487
  // A newer edit, or anything replacing the live preview (a reload, a composition switch), wins.
487
488
  const isCurrent = () => gen === refreshGenRef.current && slot === previewGeneration();
488
489
  const swap = sceneSwapFor(iframe);
489
- if (!swap || isRefreshingRef.current) return reloadWholeFilm(url.toString());
490
+ const swapWouldReplaceGestureNode =
491
+ !!iframe.contentDocument && isStudioManualEditGestureLiveIn(iframe.contentDocument);
492
+ if (!swap || isRefreshingRef.current || swapWouldReplaceGestureNode)
493
+ return reloadWholeFilm(url.toString());
490
494
  swap(url.toString(), isCurrent, cancel.signal).catch((error: unknown) => {
491
495
  if (!isCurrent()) return;
492
496
  logReload("scene-swap-refused", { reason: String(error) });
@@ -219,6 +219,22 @@ describe("ThumbnailScheduler", () => {
219
219
  expect(scheduler.getDiagnostics()).toMatchObject({ queued: 0, active: 0, leases: 0 });
220
220
  });
221
221
 
222
+ it("frees an aborted job's slot at once, even when its loader ignores the abort", async () => {
223
+ const scheduler = new ThumbnailScheduler(
224
+ resolveTimelineViewportBudgets({ concurrentVideoDecodes: 1 }),
225
+ );
226
+ const stuck = deferred<ThumbnailLoadedResult>();
227
+ const lease = scheduler.acquire(
228
+ request("stuck", () => stuck.promise, "visible", { kind: "video" }),
229
+ vi.fn(),
230
+ );
231
+ lease.release();
232
+ const nextLoad = vi.fn(async () => result("next"));
233
+ scheduler.acquire(request("next", nextLoad, "visible", { kind: "video" }), vi.fn());
234
+ await flush();
235
+ expect(nextLoad).toHaveBeenCalledTimes(1);
236
+ });
237
+
222
238
  it("disposes a late result exactly once after its final lease releases", async () => {
223
239
  const scheduler = new ThumbnailScheduler();
224
240
  const pending = deferred<ThumbnailLoadedResult>();
@@ -310,6 +310,15 @@ export class ThumbnailScheduler {
310
310
  entry.controller = controller;
311
311
  this.activeByBucket[bucket]++;
312
312
  this.activeByKind[entry.request.kind]++;
313
+ let slotFree = false;
314
+ const freeSlot = () => {
315
+ if (slotFree) return;
316
+ slotFree = true;
317
+ this.activeByBucket[bucket]--;
318
+ this.activeByKind[entry.request.kind]--;
319
+ this.pump();
320
+ };
321
+ controller.signal.addEventListener("abort", freeSlot, { once: true });
313
322
  this.notify(entry);
314
323
 
315
324
  const pending = this.loadWithTimeout(entry, controller);
@@ -331,8 +340,7 @@ export class ThumbnailScheduler {
331
340
  })
332
341
  .finally(() => {
333
342
  if (entry.controller === controller) entry.controller = null;
334
- this.activeByBucket[bucket]--;
335
- this.activeByKind[entry.request.kind]--;
343
+ freeSlot();
336
344
  this.pump();
337
345
  });
338
346
  }
@@ -1,3 +1,4 @@
1
+ import { studioApiFetch } from "../utils/studioApiFetch";
1
2
  type SwapWindow = Window & {
2
3
  __hfSwapScenes?: (html: string, signal?: AbortSignal) => Promise<void>;
3
4
  };
@@ -72,7 +73,7 @@ export function sceneSwapFor(
72
73
  );
73
74
  cancel?.addEventListener("abort", () => deadline.abort(cancel.reason), { once: true });
74
75
  const swapping = (async () => {
75
- const response = await fetch(url, { signal: deadline.signal });
76
+ const response = await studioApiFetch(url, { signal: deadline.signal });
76
77
  if (!response.ok) throw new Error(`preview request failed with ${response.status}`);
77
78
  const html = await response.text();
78
79
  if (!isCurrent()) throw new Error("superseded by a newer reload");
@@ -9,6 +9,7 @@ import { browserTelemetryAllowed } from "./policy";
9
9
  import { getBrowserSystemMeta } from "./system";
10
10
  import { canaryEventProperties } from "./canary";
11
11
  import { recordBreadcrumb } from "./breadcrumbs";
12
+ import { studioApiFetch } from "../utils/studioApiFetch";
12
13
 
13
14
  // Write-only PostHog project key, safe to embed in client code.
14
15
  const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
@@ -83,7 +84,7 @@ function send(url: string, payload: string): void {
83
84
  // Prefer fetch with keepalive (survives page navigation). sendBeacon is a
84
85
  // fallback for older runtimes where fetch isn't available.
85
86
  try {
86
- void fetch(url, {
87
+ void studioApiFetch(url, {
87
88
  method: "POST",
88
89
  headers: { "Content-Type": "application/json" },
89
90
  body: payload,
@@ -12,6 +12,7 @@ import { saveProjectFilesWithHistory } from "./studioFileHistory";
12
12
  import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
13
13
  import { extendRootDurationInSource } from "./rootDuration";
14
14
  import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
15
+ import { studioApiFetch } from "./studioApiFetch";
15
16
 
16
17
  function getMaxZIndexFromIframe(iframe: HTMLIFrameElement | null): number {
17
18
  try {
@@ -65,7 +66,7 @@ async function installRegistryItem({
65
66
  block: RegistryItem;
66
67
  compositionFile: string;
67
68
  } | null> {
68
- const response = await fetch(buildProjectApiPath(projectId, `/registry/install`), {
69
+ const response = await studioApiFetch(buildProjectApiPath(projectId, `/registry/install`), {
69
70
  method: "POST",
70
71
  headers: { "Content-Type": "application/json" },
71
72
  body: JSON.stringify({ blockName }),
@@ -0,0 +1,9 @@
1
+ // @vitest-environment happy-dom
2
+ import { expect, it } from "vitest";
3
+ import { elementTargets } from "./elementGsap";
4
+
5
+ it("keeps only the targets GSAP styles: not a plain object, not an XML-namespace element", () => {
6
+ const box = document.createElement("div");
7
+ const item = document.createElementNS("urn:x", "item");
8
+ expect(elementTargets({ targets: () => [{}, item, box, null] })).toEqual([box]);
9
+ });
@@ -43,3 +43,10 @@ export function readElementGsapNumber(element: HTMLElement, prop: string): numbe
43
43
  const value = Number(gsapOf(element)?.getProperty?.(element, prop));
44
44
  return Number.isFinite(value) ? value : null;
45
45
  }
46
+
47
+ /** The targets CSSPlugin styles: not plain objects (the runtime's filler) or XML-namespace elements. */
48
+ export function elementTargets(tween: { targets?: () => unknown[] }): Element[] {
49
+ return (tween.targets?.() ?? []).filter((t): t is Element =>
50
+ Boolean((t as HTMLElement | null)?.style && (t as Node).nodeType),
51
+ );
52
+ }
@@ -209,7 +209,6 @@ export interface SoftReloadOptions {
209
209
  currentTimeOverride?: number;
210
210
  /** After-write file HTML — the primary source for the authored opacity and transform restore. */
211
211
  authoredHtml?: string;
212
- reparse?: Element[];
213
212
  /** Other composition files a reset element is written in, by path; null when one could not be read. */
214
213
  nestedFiles?: Map<string, string> | null;
215
214
  }
@@ -274,7 +273,7 @@ export function applySoftReload(
274
273
  scriptText: string,
275
274
  options: SoftReloadOptions = {},
276
275
  ): SoftReloadResult {
277
- const { onAsyncFailure, currentTimeOverride, authoredHtml, reparse = [] } = options;
276
+ const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
278
277
  if (!iframe || !scriptText) return "cannot-soft-reload";
279
278
 
280
279
  const win = iframe.contentWindow as IframeWindow | null;
@@ -347,7 +346,7 @@ export function applySoftReload(
347
346
  // fallow-ignore-next-line complexity
348
347
  const doReload = () => {
349
348
  const timelines = win.__timelines;
350
- const targets = collectResetTargets(win, doc, targetKeys, reparse);
349
+ const targets = collectResetTargets(win, doc, targetKeys);
351
350
 
352
351
  // Kill ONLY the target composition's timeline(s) — leaving every other
353
352
  // composition's timeline (and its children on the global timeline) intact.