@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.
- package/dist/assets/{hyperframes-player-C-9N8nk0.js → hyperframes-player-mGYvPVpw.js} +1 -1
- package/dist/assets/{index-BoUM6Xqb.js → index-BWHt0-ir.js} +1 -1
- package/dist/assets/{index-CAvZ0AA-.js → index-DYNaXPVf.js} +201 -201
- package/dist/assets/{index-CVuorIMv.js → index-Dr3hNpvS.js} +1 -1
- package/dist/{chunk-TXZMMOMA.js → chunk-3F47EFCT.js} +100 -2
- package/dist/chunk-3F47EFCT.js.map +1 -0
- package/dist/{domEditingLayers-D7VYFJJD.js → domEditingLayers-OL7GLSS4.js} +2 -2
- package/dist/index.d.ts +104 -22
- package/dist/index.html +1 -1
- package/dist/index.js +1131 -1044
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/captions/hooks/useCaptionSync.ts +10 -6
- package/src/components/ProjectUnreachableBanner.tsx +3 -3
- package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
- package/src/components/editor/domGroupMoveBy.ts +95 -0
- package/src/components/editor/dragUndoPaint.test.ts +5 -5
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
- package/src/components/editor/manualEditingAvailability.ts +8 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -6
- package/src/components/editor/motionPathSelection.test.ts +6 -31
- package/src/components/editor/probeSourceElement.ts +2 -1
- package/src/components/editor/propertyPanelFont.tsx +3 -2
- package/src/components/editor/useColorGradingController.ts +2 -1
- package/src/components/editor/useFxCarveNodes.ts +2 -1
- package/src/components/editor/useFxLevelling.ts +2 -1
- package/src/components/feedback/projectProvenance.ts +2 -1
- package/src/components/nle/NLEContext.test.ts +1 -0
- package/src/components/nle/NLEContext.tsx +2 -1
- package/src/components/nle/NLEPreview.tsx +12 -10
- package/src/components/nle/PreviewPoster.tsx +2 -1
- package/src/components/renders/useFfmpegStatus.ts +2 -1
- package/src/components/renders/useRenderQueue.ts +5 -4
- package/src/components/sidebar/AssetsTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/domSelectionTestHarness.ts +23 -0
- package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
- package/src/hooks/domStyleCommit.ts +8 -1
- package/src/hooks/draggedGsapPosition.ts +10 -0
- package/src/hooks/gsapDragCommit.test.ts +75 -164
- package/src/hooks/gsapDragCommit.ts +2 -105
- package/src/hooks/gsapDragPositionCommit.ts +30 -393
- package/src/hooks/gsapEditOutcome.ts +22 -2
- package/src/hooks/gsapGroupPreflight.ts +34 -0
- package/src/hooks/gsapMutationClient.ts +4 -3
- package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
- package/src/hooks/gsapParsedTween.test.ts +62 -0
- package/src/hooks/gsapParsedTween.ts +243 -0
- package/src/hooks/gsapPositionDetection.test.ts +78 -0
- package/src/hooks/gsapPositionDetection.ts +52 -33
- package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
- package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
- package/src/hooks/gsapResizeIntercept.test.ts +63 -11
- package/src/hooks/gsapResizeIntercept.ts +79 -169
- package/src/hooks/gsapResizeSweep.test.ts +16 -33
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
- package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
- package/src/hooks/gsapRuntimeBridge.ts +105 -131
- package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
- package/src/hooks/gsapScriptCommitTypes.ts +8 -0
- package/src/hooks/gsapShared.ts +7 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
- package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
- package/src/hooks/gsapValueAtPlayhead.ts +292 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
- package/src/hooks/keyframeCacheAstLoad.ts +2 -1
- package/src/hooks/newTweenTarget.test.ts +41 -76
- package/src/hooks/timelineTimingSync.test.ts +1 -0
- package/src/hooks/timelineTimingSync.ts +2 -1
- package/src/hooks/useAskAgentModal.ts +2 -1
- package/src/hooks/useBlockCatalog.ts +2 -1
- package/src/hooks/useCaptionDetection.ts +2 -1
- package/src/hooks/useCompositionContentLoader.ts +2 -1
- package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
- package/src/hooks/useDomEditSession.test.tsx +30 -1
- package/src/hooks/useDomEditSession.ts +10 -0
- package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -2
- package/src/hooks/useDomGeometryCommit.ts +1 -0
- package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
- package/src/hooks/useEditHistoryActions.test.tsx +15 -0
- package/src/hooks/useEditHistoryActions.ts +16 -5
- package/src/hooks/useElementLifecycleOps.ts +2 -1
- package/src/hooks/useEnableKeyframes.test.ts +32 -1
- package/src/hooks/useEnableKeyframes.ts +2 -1
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
- package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
- package/src/hooks/useFileManager.ts +14 -10
- package/src/hooks/useFileTree.ts +3 -2
- package/src/hooks/useFrameCapture.ts +5 -1
- package/src/hooks/useFreezeFrame.ts +3 -2
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +17 -29
- package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
- package/src/hooks/useGsapAwareEditing.ts +5 -0
- package/src/hooks/useGsapAwareGroupMove.ts +16 -16
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
- package/src/hooks/useGsapScriptCommits.test.tsx +3 -1
- package/src/hooks/useGsapScriptCommits.ts +12 -10
- package/src/hooks/useLintModal.ts +2 -1
- package/src/hooks/usePersistentEditHistory.test.ts +277 -1
- package/src/hooks/usePersistentEditHistory.ts +47 -8
- package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
- package/src/hooks/usePreviewPersistence.ts +13 -11
- package/src/hooks/useProjectFileWriter.ts +20 -19
- package/src/hooks/useRemoveBackground.ts +2 -1
- package/src/hooks/useSdkSession.ts +2 -1
- package/src/hooks/useServerConnection.ts +4 -3
- package/src/hooks/useStudioSelectionPublisher.ts +2 -1
- package/src/hooks/useThumbnailStill.test.ts +100 -0
- package/src/hooks/useThumbnailStill.ts +37 -0
- package/src/hooks/useTimelineDeleteOps.ts +2 -1
- package/src/index.ts +4 -0
- package/src/player/components/AudioWaveform.tsx +3 -2
- package/src/player/components/CompositionThumbnail.tsx +2 -1
- package/src/player/components/clipMenuNormalize.ts +2 -1
- package/src/player/components/clipPeakMap.ts +2 -1
- package/src/player/hooks/useTimelinePlayer.ts +5 -1
- package/src/player/lib/thumbnailScheduler.test.ts +16 -0
- package/src/player/lib/thumbnailScheduler.ts +10 -2
- package/src/player/sceneSwap.ts +2 -1
- package/src/telemetry/client.ts +2 -1
- package/src/utils/blockInstaller.ts +2 -1
- package/src/utils/elementGsap.test.ts +9 -0
- package/src/utils/elementGsap.ts +7 -0
- package/src/utils/gsapSoftReload.ts +2 -3
- package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
- package/src/utils/gsapUndoRestore.ts +3 -6
- package/src/utils/previewPrefetch.ts +2 -1
- package/src/utils/razorSplitTransaction.ts +12 -8
- package/src/utils/softReloadTargets.ts +1 -3
- package/src/utils/studioApiFetch.test.ts +75 -0
- package/src/utils/studioApiFetch.ts +13 -0
- package/src/utils/studioFileHistory.ts +4 -1
- package/src/utils/studioHelpers.ts +7 -1
- package/src/utils/studioPendingEdits.test.ts +23 -6
- package/src/utils/studioPendingEdits.ts +32 -8
- package/src/utils/studioTelemetry.ts +2 -1
- package/src/utils/timelineCompositionInsert.ts +3 -2
- package/src/webmcp/StudioAgentTools.tsx +2 -1
- package/dist/chunk-TXZMMOMA.js.map +0 -1
- /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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
this.activeByKind[entry.request.kind]--;
|
|
343
|
+
freeSlot();
|
|
336
344
|
this.pump();
|
|
337
345
|
});
|
|
338
346
|
}
|
package/src/player/sceneSwap.ts
CHANGED
|
@@ -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
|
|
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");
|
package/src/telemetry/client.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
});
|
package/src/utils/elementGsap.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|