@hyperframes/studio 0.8.74 → 0.8.76
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-BkDxgSKG.js +464 -0
- package/dist/assets/{index-vccPjvCl.js → index-COX0scwJ.js} +1 -1
- package/dist/assets/{index-DhTyI9zH.js → index-DeHir1up.js} +178 -178
- package/dist/assets/{index-B86QTmD2.js → index-FmKUmYhC.js} +1 -1
- package/dist/{chunk-7BP32G62.js → chunk-IYDE66BY.js} +6 -1
- package/dist/chunk-IYDE66BY.js.map +1 -0
- package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-2XBFXXSW.js} +4 -2
- package/dist/index.d.ts +35 -70
- package/dist/index.html +1 -1
- package/dist/index.js +4518 -4568
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanels.types.ts +0 -2
- package/src/components/editor/DomEditOverlay.test.ts +5 -0
- package/src/components/editor/LayersPanel.tsx +3 -14
- package/src/components/editor/PreviewOverlayProvider.test.tsx +6 -1
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingLayers.test.ts +27 -0
- package/src/components/editor/domEditingLayers.ts +9 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +57 -35
- package/src/components/editor/overlayFrameLoop.test.ts +16 -0
- package/src/components/editor/overlayFrameLoop.ts +11 -0
- package/src/components/editor/usePreviewCompositionRect.test.tsx +6 -1
- package/src/components/nle/NLEPreview.test.ts +94 -17
- package/src/components/nle/NLEPreview.tsx +14 -0
- package/src/components/nle/PreviewPoster.tsx +99 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +30 -49
- package/src/components/panels/VariablesPanel.tsx +0 -2
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +5 -1
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +14 -0
- package/src/components/sidebar/CompositionsTab.test.ts +4 -6
- package/src/components/sidebar/CompositionsTab.tsx +28 -21
- package/src/components/studioColorGradingScope.ts +0 -3
- package/src/hooks/domEditDeleteMembers.ts +0 -2
- package/src/hooks/gsapScriptCommitTypes.ts +0 -2
- package/src/hooks/keyframeCacheAstLoad.test.ts +31 -1
- package/src/hooks/keyframeCacheAstLoad.ts +2 -1
- package/src/hooks/timelineAudioGroupCreate.ts +0 -1
- package/src/hooks/timelineEditingHelpers.ts +11 -3
- package/src/hooks/timelineTimingSync.ts +0 -1
- package/src/hooks/timelineTrackVisibility.ts +0 -1
- package/src/hooks/useBlockHandlers.ts +0 -2
- package/src/hooks/useClipboard.test.ts +69 -10
- package/src/hooks/useClipboard.ts +28 -19
- package/src/hooks/useDomEditCommits.test.tsx +0 -1
- package/src/hooks/useDomEditCommits.ts +0 -4
- package/src/hooks/useEditHistoryActions.test.tsx +31 -14
- package/src/hooks/useEditHistoryActions.ts +12 -7
- package/src/hooks/useEditorSave.ts +0 -3
- package/src/hooks/useElementLifecycleOps.ts +0 -1
- package/src/hooks/useEnableKeyframes.test.ts +9 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useGroupCommits.ts +0 -1
- package/src/hooks/useGsapScriptCommits.ts +0 -1
- package/src/hooks/useLintModal.test.tsx +34 -1
- package/src/hooks/useLintModal.ts +32 -30
- package/src/hooks/usePersistentEditHistory.test.ts +221 -315
- package/src/hooks/usePersistentEditHistory.ts +155 -342
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +2 -0
- package/src/hooks/usePreviewPersistence.ts +0 -2
- package/src/hooks/useProjectCompositionVariables.ts +1 -3
- package/src/hooks/useRazorSplit.history.test.tsx +46 -67
- package/src/hooks/useRazorSplit.test.ts +17 -47
- package/src/hooks/useRenderClipContent.test.ts +29 -0
- package/src/hooks/useRenderClipContent.ts +6 -3
- package/src/hooks/useSdkSession.lifecycle.test.tsx +34 -4
- package/src/hooks/useSdkSession.ts +8 -2
- package/src/hooks/useSlideshowPersist.ts +0 -2
- package/src/hooks/useStudioContextValue.ts +6 -1
- package/src/hooks/useTimelineAssetDropOps.ts +0 -1
- package/src/hooks/useTimelineDeleteOps.test.tsx +85 -2
- package/src/hooks/useTimelineDeleteOps.ts +8 -8
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/hooks/useTimelineEditingTypes.ts +0 -2
- package/src/hostMount.test.tsx +53 -22
- package/src/main.tsx +3 -0
- package/src/player/components/CompositionThumbnail.tsx +14 -0
- package/src/player/components/Player.test.ts +49 -0
- package/src/player/components/Player.tsx +21 -7
- package/src/player/components/PlayheadIndicator.tsx +4 -1
- package/src/player/components/Timeline.test.ts +1 -3
- package/src/player/components/Timeline.virtualization.test.tsx +21 -0
- package/src/player/components/TimelineCanvas.tsx +14 -12
- package/src/player/components/TimelinePlayheadLayer.tsx +33 -0
- package/src/player/components/TimelineRuler.tsx +7 -40
- package/src/player/components/timelineClipDragCommit.test.ts +7 -39
- package/src/player/components/useSettledScrollLeft.ts +38 -0
- package/src/player/hooks/timelineDomBinding.test.ts +50 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +25 -1
- package/src/player/hooks/timelineSyncHydration.ts +3 -3
- package/src/player/hooks/useShadowPreviewReload.ts +20 -1
- package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +85 -0
- package/src/player/hooks/useTimelinePlayer.ts +15 -3
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/timelineElementHelpers.ts +45 -9
- package/src/player/store/playerStore.test.ts +35 -1
- package/src/player/store/playerStore.ts +29 -0
- package/src/player/store/readinessSlice.ts +34 -6
- package/src/utils/blockInstaller.ts +0 -3
- package/src/utils/previewPrefetch.test.ts +22 -0
- package/src/utils/previewPrefetch.ts +14 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/razorSplitTransaction.ts +1 -1
- package/src/utils/sdkEditTransaction.ts +0 -3
- package/src/utils/studioFileHistory.test.ts +0 -9
- package/src/utils/studioFileHistory.ts +1 -5
- package/src/utils/timelineCompositionInsert.ts +0 -1
- package/dist/assets/hyperframes-player-Bs9qG2hg.js +0 -464
- package/dist/chunk-7BP32G62.js.map +0 -1
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +0 -60
- package/src/utils/editHistory.test.ts +0 -534
- package/src/utils/editHistory.ts +0 -236
- package/src/utils/editHistoryStorage.test.ts +0 -37
- package/src/utils/editHistoryStorage.ts +0 -99
- /package/dist/{domEditingLayers-UKMVYXT6.js.map → domEditingLayers-2XBFXXSW.js.map} +0 -0
|
@@ -8,7 +8,7 @@ import { usePlayerStore } from "../player";
|
|
|
8
8
|
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
|
9
9
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
10
10
|
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
|
11
|
-
import { buildPatchTarget } from "./timelineEditingHelpers";
|
|
11
|
+
import { buildPatchTarget, removeIframeTimelineElements } from "./timelineEditingHelpers";
|
|
12
12
|
import { captureDurationRollback, readFileContent } from "./timelineTimingSync";
|
|
13
13
|
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
|
14
14
|
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
|
|
@@ -157,7 +157,6 @@ export function useTimelineDeleteOps({
|
|
|
157
157
|
await saveProjectFilesWithHistory({
|
|
158
158
|
projectId: pid,
|
|
159
159
|
label: deleteHistoryLabel,
|
|
160
|
-
kind: "timeline",
|
|
161
160
|
coalesceKey,
|
|
162
161
|
files: { [targetPath]: patchedContent },
|
|
163
162
|
readFile: async () => originalContent,
|
|
@@ -172,6 +171,7 @@ export function useTimelineDeleteOps({
|
|
|
172
171
|
throw error;
|
|
173
172
|
}
|
|
174
173
|
|
|
174
|
+
removeIframeTimelineElements(previewIframeRef.current, sameFile, activeCompPath);
|
|
175
175
|
const deletedKeys = new Set(sameFile.map((te) => te.key ?? te.id));
|
|
176
176
|
const survivors = timelineElements.filter((te) => !deletedKeys.has(te.key ?? te.id));
|
|
177
177
|
|
|
@@ -184,10 +184,11 @@ export function useTimelineDeleteOps({
|
|
|
184
184
|
sameFile,
|
|
185
185
|
usePlayerStore.getState().rippleEditEnabled,
|
|
186
186
|
);
|
|
187
|
-
|
|
187
|
+
const rippleChanges = rippleShifts ? resolveShiftedElements(survivors, rippleShifts) : null;
|
|
188
|
+
// The timeline follows the delete's write at once, as the owner of the ripple's optimistic update.
|
|
189
|
+
usePlayerStore.getState().setElements(applyRippleShifts(survivors, rippleChanges));
|
|
188
190
|
let rippleFailed = false;
|
|
189
|
-
if (
|
|
190
|
-
const rippleChanges = resolveShiftedElements(survivors, rippleShifts);
|
|
191
|
+
if (rippleChanges) {
|
|
191
192
|
try {
|
|
192
193
|
await handleTimelineGroupMove(rippleChanges, {
|
|
193
194
|
coalesceKey,
|
|
@@ -200,18 +201,17 @@ export function useTimelineDeleteOps({
|
|
|
200
201
|
// the generic one would tell the user about one action twice.
|
|
201
202
|
suppressFailureToast: true,
|
|
202
203
|
});
|
|
203
|
-
rippleApplied = rippleChanges;
|
|
204
204
|
} catch (error) {
|
|
205
205
|
// The delete already committed; a failed ripple leaves the gap the
|
|
206
206
|
// toggle-off behaviour would have left anyway — not worth undoing
|
|
207
207
|
// an otherwise-successful delete over.
|
|
208
208
|
rippleFailed = true;
|
|
209
|
+
usePlayerStore.getState().setElements(survivors);
|
|
209
210
|
console.error("[Timeline] ripple-edit failed to persist after delete", error);
|
|
210
211
|
showToast("Clip deleted, but the gap could not be closed.", "error");
|
|
211
212
|
}
|
|
212
213
|
}
|
|
213
214
|
|
|
214
|
-
usePlayerStore.getState().setElements(applyRippleShifts(survivors, rippleApplied));
|
|
215
215
|
usePlayerStore.getState().setSelectedElementId(null);
|
|
216
216
|
usePlayerStore.getState().setSelectedElementIds(new Set());
|
|
217
217
|
forceReloadSdkSession?.();
|
|
@@ -224,7 +224,7 @@ export function useTimelineDeleteOps({
|
|
|
224
224
|
"info",
|
|
225
225
|
);
|
|
226
226
|
}
|
|
227
|
-
if (
|
|
227
|
+
if (rippleChanges && !rippleFailed && !rippleNoticeShownRef.current) {
|
|
228
228
|
rippleNoticeShownRef.current = true;
|
|
229
229
|
showToast(
|
|
230
230
|
"Ripple closed the gap on the main track. Undo (⌘Z) restores it, or turn off " +
|
|
@@ -112,7 +112,6 @@ function renderTimelineEditingHook(input: {
|
|
|
112
112
|
writeProjectFile?: (path: string, content: string) => Promise<void>;
|
|
113
113
|
recordEdit?: (input: {
|
|
114
114
|
label: string;
|
|
115
|
-
kind: string;
|
|
116
115
|
coalesceKey?: string;
|
|
117
116
|
files: Record<string, { before: string; after: string }>;
|
|
118
117
|
}) => Promise<void>;
|
|
@@ -2,13 +2,11 @@ import type { MutableRefObject, RefObject } from "react";
|
|
|
2
2
|
import type { Composition } from "@hyperframes/sdk";
|
|
3
3
|
import type { TimelineElement } from "../player";
|
|
4
4
|
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
|
|
5
|
-
import type { EditHistoryKind } from "../utils/editHistory";
|
|
6
5
|
import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
7
6
|
import type { CanEditTimelineElement } from "./timelineEditPermission";
|
|
8
7
|
|
|
9
8
|
interface RecordEditInput {
|
|
10
9
|
label: string;
|
|
11
|
-
kind: EditHistoryKind;
|
|
12
10
|
coalesceKey?: string;
|
|
13
11
|
files: Record<string, { before: string; after: string }>;
|
|
14
12
|
}
|
package/src/hostMount.test.tsx
CHANGED
|
@@ -8,8 +8,15 @@ import { createRoot, type Root } from "react-dom/client";
|
|
|
8
8
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
9
|
// Backend/storage mocking only, standing in for a host's own server and
|
|
10
10
|
// persistence — not part of the host's integration code under test.
|
|
11
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
createStudioApi,
|
|
16
|
+
openProjectHistory,
|
|
17
|
+
type StudioApiAdapter,
|
|
18
|
+
} from "@hyperframes/studio-server";
|
|
11
19
|
import { studioFileContentVersion } from "./utils/studioFileVersion";
|
|
12
|
-
import { createMemoryEditHistoryStorage } from "./utils/editHistoryStorage";
|
|
13
20
|
import {
|
|
14
21
|
flushStudioPendingEdits,
|
|
15
22
|
useTimelineEditing,
|
|
@@ -27,45 +34,66 @@ import {
|
|
|
27
34
|
const ORIGINAL_HTML = '<div id="clip" data-start="0" data-track-index="0"></div>';
|
|
28
35
|
|
|
29
36
|
let root: Root | null = null;
|
|
30
|
-
|
|
37
|
+
const cleanups: Array<() => unknown> = [];
|
|
38
|
+
afterEach(async () => {
|
|
31
39
|
act(() => root?.unmount());
|
|
32
40
|
document.body.innerHTML = "";
|
|
33
41
|
vi.unstubAllGlobals();
|
|
42
|
+
for (const step of cleanups.splice(0).reverse()) await step();
|
|
34
43
|
});
|
|
35
44
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
45
|
+
function tempDir(prefix: string): string {
|
|
46
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
47
|
+
cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
|
|
48
|
+
return dir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A project folder with etag-guarded PUT, faithful enough to exercise a real
|
|
52
|
+
* conflict: a stale If-Match gets a 409 with the current version + content.
|
|
53
|
+
* History requests go to the real history routes over the real engine. */
|
|
54
|
+
async function stubHostProjectDisk(
|
|
39
55
|
initial: Record<string, string>,
|
|
40
56
|
options?: { editElsewhereAfterFirstRead?: boolean },
|
|
41
57
|
) {
|
|
42
|
-
const
|
|
58
|
+
const dir = tempDir("hf-host-mount-");
|
|
59
|
+
for (const [path, text] of Object.entries(initial)) writeFileSync(join(dir, path), text);
|
|
60
|
+
const history = await openProjectHistory({
|
|
61
|
+
projectDir: dir,
|
|
62
|
+
historyRoot: tempDir("hf-host-mount-history-"),
|
|
63
|
+
});
|
|
64
|
+
cleanups.push(() => history.close());
|
|
65
|
+
const api = createStudioApi({
|
|
66
|
+
listProjects: () => [],
|
|
67
|
+
resolveProject: (id: string) => (id === "p1" ? { id, dir } : null),
|
|
68
|
+
history: () => history,
|
|
69
|
+
} as unknown as StudioApiAdapter);
|
|
43
70
|
const readsSeen = new Set<string>();
|
|
71
|
+
const read = (path: string) => readFileSync(join(dir, path), "utf8");
|
|
44
72
|
// The real backend's etag is a content hash (useProjectFileWriter derives
|
|
45
73
|
// If-Match the same way) — an arbitrary counter here would make every
|
|
46
74
|
// write from a host that hasn't just read the file look stale.
|
|
47
|
-
const versionOf = (path: string) => studioFileContentVersion(
|
|
75
|
+
const versionOf = (path: string) => studioFileContentVersion(read(path));
|
|
48
76
|
|
|
49
77
|
async function handlePut(path: string, init: RequestInit | undefined) {
|
|
50
78
|
const ifMatch = new Headers(init?.headers).get("If-Match");
|
|
51
79
|
const currentVersion = await versionOf(path);
|
|
52
80
|
if (ifMatch && ifMatch !== currentVersion) {
|
|
53
|
-
return new Response(JSON.stringify({ currentVersion, currentContent:
|
|
81
|
+
return new Response(JSON.stringify({ currentVersion, currentContent: read(path) }), {
|
|
54
82
|
status: 409,
|
|
55
83
|
});
|
|
56
84
|
}
|
|
57
|
-
|
|
85
|
+
writeFileSync(join(dir, path), String(init?.body ?? ""));
|
|
58
86
|
return new Response(JSON.stringify({ version: await versionOf(path) }), { status: 200 });
|
|
59
87
|
}
|
|
60
88
|
|
|
61
89
|
async function handleGet(path: string) {
|
|
62
|
-
const served =
|
|
90
|
+
const served = read(path);
|
|
63
91
|
// Simulates another writer landing between this read and the caller's
|
|
64
92
|
// own write: content moves on right after being served once, so the
|
|
65
93
|
// caller's hash of what it just read no longer matches disk.
|
|
66
94
|
if (options?.editElsewhereAfterFirstRead && !readsSeen.has(path)) {
|
|
67
95
|
readsSeen.add(path);
|
|
68
|
-
|
|
96
|
+
writeFileSync(join(dir, path), `${served} <!--edited elsewhere-->`);
|
|
69
97
|
}
|
|
70
98
|
return new Response(
|
|
71
99
|
JSON.stringify({ content: served, version: await studioFileContentVersion(served) }),
|
|
@@ -74,12 +102,15 @@ function stubHostProjectDisk(
|
|
|
74
102
|
}
|
|
75
103
|
|
|
76
104
|
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
77
|
-
const
|
|
78
|
-
|
|
105
|
+
const url = String(input);
|
|
106
|
+
if (url.includes("/history")) return api.request(url.replace(/^\/api/, ""), init);
|
|
107
|
+
const match = url.match(/\/files\/([^?]+)/);
|
|
108
|
+
if (!match) return new Response("{}", { status: 404 });
|
|
109
|
+
const path = decodeURIComponent(match[1]);
|
|
79
110
|
return (init?.method ?? "GET") === "PUT" ? handlePut(path, init) : handleGet(path);
|
|
80
111
|
});
|
|
81
112
|
vi.stubGlobal("fetch", fetchMock);
|
|
82
|
-
return {
|
|
113
|
+
return { read, fetchMock };
|
|
83
114
|
}
|
|
84
115
|
|
|
85
116
|
type TimelineEditingHandle = {
|
|
@@ -108,13 +139,10 @@ function mountHost(
|
|
|
108
139
|
const bannerHost = document.createElement("div");
|
|
109
140
|
document.body.append(bannerHost);
|
|
110
141
|
const bannerRoot = createRoot(bannerHost);
|
|
111
|
-
// Created once: a fresh storage object on every render would change
|
|
112
|
-
// usePersistentEditHistory's effect deps each time and loop forever.
|
|
113
|
-
const storage = createMemoryEditHistoryStorage();
|
|
114
142
|
|
|
115
143
|
function Harness() {
|
|
116
144
|
const writer = useProjectFileWriter({ projectId: "p1" });
|
|
117
|
-
const editHistory = usePersistentEditHistory({ projectId: "p1"
|
|
145
|
+
const editHistory = usePersistentEditHistory({ projectId: "p1" });
|
|
118
146
|
const historyActions = useEditHistoryActions({
|
|
119
147
|
editHistory,
|
|
120
148
|
readOptionalProjectFile: writer.readOptionalProjectFile,
|
|
@@ -170,7 +198,7 @@ function mountHost(
|
|
|
170
198
|
|
|
171
199
|
describe("public export surface: a host mounting hand editing outside EditorShell", () => {
|
|
172
200
|
it("writes a move through the history path and undo restores it", async () => {
|
|
173
|
-
const disk = stubHostProjectDisk({ "index.html": ORIGINAL_HTML });
|
|
201
|
+
const disk = await stubHostProjectDisk({ "index.html": ORIGINAL_HTML });
|
|
174
202
|
const iframe = document.createElement("iframe");
|
|
175
203
|
document.body.append(iframe);
|
|
176
204
|
iframe.contentDocument!.body.innerHTML = ORIGINAL_HTML;
|
|
@@ -187,16 +215,19 @@ describe("public export surface: a host mounting hand editing outside EditorShel
|
|
|
187
215
|
await act(async () => {
|
|
188
216
|
await timelineEditing.handleTimelineElementMove(clip, { start: 5, track: 0 });
|
|
189
217
|
});
|
|
190
|
-
expect(disk.
|
|
218
|
+
expect(disk.read("index.html")).toContain('data-start="5"');
|
|
191
219
|
|
|
192
220
|
await act(async () => {
|
|
193
221
|
await undo();
|
|
194
222
|
});
|
|
195
|
-
expect(disk.
|
|
223
|
+
expect(disk.read("index.html")).toContain('data-start="0"');
|
|
196
224
|
});
|
|
197
225
|
|
|
198
226
|
it("surfaces the conflict banner on a 409", async () => {
|
|
199
|
-
stubHostProjectDisk(
|
|
227
|
+
await stubHostProjectDisk(
|
|
228
|
+
{ "index.html": ORIGINAL_HTML },
|
|
229
|
+
{ editElsewhereAfterFirstRead: true },
|
|
230
|
+
);
|
|
200
231
|
const iframe = document.createElement("iframe");
|
|
201
232
|
document.body.append(iframe);
|
|
202
233
|
iframe.contentDocument!.body.innerHTML = ORIGINAL_HTML;
|
package/src/main.tsx
CHANGED
|
@@ -5,8 +5,11 @@ import { StudioApp } from "./App";
|
|
|
5
5
|
import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
|
|
6
6
|
import { readIconTokens } from "./styles/iconTokens";
|
|
7
7
|
import { trackStudioEvent } from "./utils/studioTelemetry";
|
|
8
|
+
import { prefetchPreviewForHash } from "./utils/previewPrefetch";
|
|
8
9
|
import "./styles/studio.css";
|
|
9
10
|
|
|
11
|
+
prefetchPreviewForHash(window.location.hash);
|
|
12
|
+
window.addEventListener("hashchange", () => prefetchPreviewForHash(window.location.hash));
|
|
10
13
|
trackStudioEvent("session_start");
|
|
11
14
|
|
|
12
15
|
function errorProps(value: unknown): {
|
|
@@ -24,6 +24,20 @@ interface CompositionThumbnailProps {
|
|
|
24
24
|
|
|
25
25
|
const CLIP_HEIGHT = 66;
|
|
26
26
|
const THUMBNAIL_URL_VERSION = "v3";
|
|
27
|
+
export const THUMBNAIL_SEEK_TIME_SECONDS = 3;
|
|
28
|
+
|
|
29
|
+
export function resolveThumbnailSeekTime(durationSeconds: number | null | undefined): number {
|
|
30
|
+
if (
|
|
31
|
+
Number.isFinite(durationSeconds) &&
|
|
32
|
+
durationSeconds != null &&
|
|
33
|
+
durationSeconds > 0 &&
|
|
34
|
+
durationSeconds <= THUMBNAIL_SEEK_TIME_SECONDS
|
|
35
|
+
) {
|
|
36
|
+
return durationSeconds / 2;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return THUMBNAIL_SEEK_TIME_SECONDS;
|
|
40
|
+
}
|
|
27
41
|
|
|
28
42
|
export function buildCompositionThumbnailUrl({
|
|
29
43
|
previewUrl,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
readPreviewErrorMessage,
|
|
10
10
|
shouldShowCompositionLoadingOverlay,
|
|
11
11
|
} from "./Player";
|
|
12
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
12
13
|
|
|
13
14
|
vi.mock("@hyperframes/player", () => ({}));
|
|
14
15
|
|
|
@@ -271,6 +272,54 @@ describe("ready to show", () => {
|
|
|
271
272
|
expect(onReadyToShowChange).toHaveBeenLastCalledWith(false);
|
|
272
273
|
});
|
|
273
274
|
|
|
275
|
+
it("marks the preview booted when it can show and play, not at ready", async () => {
|
|
276
|
+
usePlayerStore.setState({ previewBooted: false });
|
|
277
|
+
const { player } = await mountPlayer({});
|
|
278
|
+
const el = player as TestHyperframesPlayer;
|
|
279
|
+
|
|
280
|
+
loadAndReady(el);
|
|
281
|
+
act(() => void el.dispatchEvent(new Event("assetsready")));
|
|
282
|
+
await twoFrames();
|
|
283
|
+
expect(usePlayerStore.getState().previewBooted).toBe(false);
|
|
284
|
+
|
|
285
|
+
painted(el);
|
|
286
|
+
await twoFrames();
|
|
287
|
+
expect(usePlayerStore.getState().previewBooted).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("marks the preview booted at its first frame while media is still buffering", async () => {
|
|
291
|
+
usePlayerStore.setState({ previewBooted: false });
|
|
292
|
+
const onReadyToShowChange = vi.fn();
|
|
293
|
+
const { player } = await mountPlayer({ onReadyToShowChange });
|
|
294
|
+
const el = player as TestHyperframesPlayer;
|
|
295
|
+
document.body.appendChild(el.iframeElement);
|
|
296
|
+
const doc = el.iframeElement.contentDocument!;
|
|
297
|
+
const audio = doc.createElement("audio");
|
|
298
|
+
Object.defineProperty(audio, "readyState", { value: 0, configurable: true });
|
|
299
|
+
Object.defineProperty(audio, "networkState", { value: 2, configurable: true });
|
|
300
|
+
doc.body.appendChild(audio);
|
|
301
|
+
|
|
302
|
+
loadAndReady(el);
|
|
303
|
+
painted(el);
|
|
304
|
+
await twoFrames();
|
|
305
|
+
expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
|
|
306
|
+
expect(usePlayerStore.getState().previewBooted).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("stops deferring editing work when the preview never shows", async () => {
|
|
310
|
+
vi.useFakeTimers();
|
|
311
|
+
try {
|
|
312
|
+
usePlayerStore.setState({ previewBooted: false });
|
|
313
|
+
await mountPlayer({});
|
|
314
|
+
act(() => void vi.advanceTimersByTime(4999));
|
|
315
|
+
expect(usePlayerStore.getState().previewBooted).toBe(false);
|
|
316
|
+
act(() => void vi.advanceTimersByTime(1));
|
|
317
|
+
expect(usePlayerStore.getState().previewBooted).toBe(true);
|
|
318
|
+
} finally {
|
|
319
|
+
vi.useRealTimers();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
274
323
|
it("reports the document start time with the painted iframe", async () => {
|
|
275
324
|
const onPainted = vi.fn();
|
|
276
325
|
const now = vi.spyOn(performance, "now").mockReturnValue(100);
|
|
@@ -4,6 +4,7 @@ import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readin
|
|
|
4
4
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
5
5
|
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
|
|
6
6
|
import { HyperframesLoader } from "../../components/ui";
|
|
7
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
7
8
|
// Importing "@hyperframes/player" registers a class extending HTMLElement at
|
|
8
9
|
// module load, which throws under SSR, hence the dynamic import behind a
|
|
9
10
|
// `typeof window` guard. Kicking it here rather than in the mount effect puts
|
|
@@ -57,6 +58,7 @@ function getShaderTransitionLoading(event: Event): boolean | null {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
const COMPOSITION_LOADING_OVERLAY_DELAY_MS = 400;
|
|
61
|
+
const PREVIEW_BOOT_DEADLINE_MS = 5000;
|
|
60
62
|
const DEFAULT_PREVIEW_ERROR = "The composition preview did not become ready.";
|
|
61
63
|
|
|
62
64
|
export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
|
|
@@ -427,13 +429,13 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
427
429
|
setAssetsLoading(false);
|
|
428
430
|
};
|
|
429
431
|
|
|
430
|
-
const
|
|
431
|
-
loaded &&
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
432
|
+
const firstFrameShown =
|
|
433
|
+
loaded && painted && !compositionLoading && !shaderTransitionLoading && !previewError;
|
|
434
|
+
const readyToShow = firstFrameShown && !assetsLoading;
|
|
435
|
+
// Work waiting on the boot starts at the first frame, while media still buffers.
|
|
436
|
+
useEffect(() => {
|
|
437
|
+
if (firstFrameShown) usePlayerStore.getState().markPreviewBooted();
|
|
438
|
+
}, [firstFrameShown]);
|
|
437
439
|
// `painted` means the player's own loader has finished fading; two frames of grace on top.
|
|
438
440
|
useEffect(() => {
|
|
439
441
|
if (!readyToShow) {
|
|
@@ -450,6 +452,18 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
450
452
|
};
|
|
451
453
|
}, [readyToShow]);
|
|
452
454
|
|
|
455
|
+
useEffect(() => {
|
|
456
|
+
if (previewError) usePlayerStore.getState().markPreviewBooted();
|
|
457
|
+
}, [previewError]);
|
|
458
|
+
|
|
459
|
+
useEffect(() => {
|
|
460
|
+
const timer = setTimeout(
|
|
461
|
+
() => usePlayerStore.getState().markPreviewBooted(),
|
|
462
|
+
PREVIEW_BOOT_DEADLINE_MS,
|
|
463
|
+
);
|
|
464
|
+
return () => clearTimeout(timer);
|
|
465
|
+
}, [projectId]);
|
|
466
|
+
|
|
453
467
|
const showCompositionOverlay =
|
|
454
468
|
!suppressLoadingOverlay &&
|
|
455
469
|
!compositionOverlayDeferred &&
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { PLAYHEAD_HEAD_W } from "./timelineLayout";
|
|
13
13
|
|
|
14
|
+
/** Widest part of the playhead, centred on its line. */
|
|
15
|
+
export const PLAYHEAD_GLOW_W = 13;
|
|
16
|
+
|
|
14
17
|
interface PlayheadIndicatorProps {
|
|
15
18
|
/** CSS color, defaults to the HF accent variable */
|
|
16
19
|
color?: string;
|
|
@@ -50,7 +53,7 @@ export function PlayheadIndicator({
|
|
|
50
53
|
className="absolute top-0 bottom-0"
|
|
51
54
|
style={{
|
|
52
55
|
left: "50%",
|
|
53
|
-
width:
|
|
56
|
+
width: PLAYHEAD_GLOW_W,
|
|
54
57
|
transform: "translateX(-50%)",
|
|
55
58
|
background: `radial-gradient(closest-side, ${glowColor}, transparent)`,
|
|
56
59
|
}}
|
|
@@ -88,9 +88,7 @@ function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: str
|
|
|
88
88
|
if (!ruler) throw new Error("Missing timeline ruler");
|
|
89
89
|
const rulerOrigin = ruler.previousElementSibling;
|
|
90
90
|
if (!(rulerOrigin instanceof HTMLElement)) throw new Error("Missing timeline ruler origin");
|
|
91
|
-
const playhead =
|
|
92
|
-
(node) => node.style.zIndex === "100",
|
|
93
|
-
);
|
|
91
|
+
const playhead = host.querySelector<HTMLElement>("[data-timeline-playhead-layer] > div");
|
|
94
92
|
if (!playhead) throw new Error("Missing timeline playhead");
|
|
95
93
|
return { clip, trackHeader, rulerTick, rulerOrigin, playhead };
|
|
96
94
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { TIMELINE_SCROLL_SETTLE_MS } from "./useTimelineScrollViewport";
|
|
6
7
|
|
|
7
8
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
8
9
|
|
|
@@ -430,6 +431,26 @@ describe("Timeline without row virtualization", { timeout: 30_000 }, () => {
|
|
|
430
431
|
};
|
|
431
432
|
}
|
|
432
433
|
|
|
434
|
+
it("clips a scrolled-off playhead at the track headers once the scroll settles", async () => {
|
|
435
|
+
const { host, dispose } = await renderUnvirtualizedTimeline();
|
|
436
|
+
try {
|
|
437
|
+
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
|
|
438
|
+
const layer = () => host.querySelector<HTMLElement>("[data-timeline-playhead-layer]");
|
|
439
|
+
await scrollTimelineHorizontally(scroller, 166);
|
|
440
|
+
expect(layer()?.style.clipPath).toBe("");
|
|
441
|
+
await act(async () => {
|
|
442
|
+
await new Promise((resolve) => setTimeout(resolve, TIMELINE_SCROLL_SETTLE_MS + 20));
|
|
443
|
+
});
|
|
444
|
+
// The ruler's corner is as wide as the track headers; the glow may overhang by 6.5px.
|
|
445
|
+
const headerWidth = parseFloat(
|
|
446
|
+
host.querySelector<HTMLElement>(".sticky.left-0")!.style.width,
|
|
447
|
+
);
|
|
448
|
+
expect(layer()?.style.clipPath).toContain(`${166 + headerWidth - 6.5}px)`);
|
|
449
|
+
} finally {
|
|
450
|
+
dispose();
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
433
454
|
it("mounts every clip rather than a window", async () => {
|
|
434
455
|
const { host, dispose } = await renderUnvirtualizedTimeline();
|
|
435
456
|
try {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
2
|
import { TimelineRulerPart } from "./TimelineRulerPart";
|
|
3
3
|
import { PlayheadIndicator } from "./PlayheadIndicator";
|
|
4
|
+
import { TimelinePlayheadLayer } from "./TimelinePlayheadLayer";
|
|
4
5
|
import {
|
|
5
6
|
RULER_H,
|
|
6
7
|
CLIP_Y,
|
|
@@ -236,18 +237,19 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
|
|
|
236
237
|
the half-head offset baked into getTimelinePlayheadLeft keep the
|
|
237
238
|
inner 1px line's CENTER exactly on contentOrigin + t * pps (the ruler
|
|
238
239
|
ticks' center), instead of relying on shrink-wrap sizing. */}
|
|
239
|
-
<
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
240
|
+
<TimelinePlayheadLayer scrollRef={props.scrollRef} contentOrigin={props.contentOrigin}>
|
|
241
|
+
<div
|
|
242
|
+
ref={props.playheadRef}
|
|
243
|
+
className="absolute top-0 bottom-0 pointer-events-none"
|
|
244
|
+
style={{
|
|
245
|
+
left: `${getTimelinePlayheadLeft(0, 0, props.contentOrigin)}px`,
|
|
246
|
+
width: PLAYHEAD_HEAD_W,
|
|
247
|
+
display: beatDragging ? "none" : undefined,
|
|
248
|
+
}}
|
|
249
|
+
>
|
|
250
|
+
<PlayheadIndicator scrubbing={props.isScrubbing} />
|
|
251
|
+
</div>
|
|
252
|
+
</TimelinePlayheadLayer>
|
|
251
253
|
</div>
|
|
252
254
|
);
|
|
253
255
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ReactNode, RefObject } from "react";
|
|
2
|
+
import { PLAYHEAD_GLOW_W } from "./PlayheadIndicator";
|
|
3
|
+
import { useSettledScrollLeft } from "./useSettledScrollLeft";
|
|
4
|
+
|
|
5
|
+
// Clips the playhead at the track headers' edge once a scroll settles, so one scrolled off to
|
|
6
|
+
// the left does not draw over the track names. Half the glow may pass the edge so 00:00 looks
|
|
7
|
+
// as before; mid-scroll it is not clipped.
|
|
8
|
+
export function TimelinePlayheadLayer({
|
|
9
|
+
scrollRef,
|
|
10
|
+
contentOrigin,
|
|
11
|
+
children,
|
|
12
|
+
}: {
|
|
13
|
+
scrollRef: RefObject<HTMLDivElement | null>;
|
|
14
|
+
contentOrigin: number;
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
}) {
|
|
17
|
+
const settledScrollLeft = useSettledScrollLeft(scrollRef);
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
data-timeline-playhead-layer=""
|
|
21
|
+
className="absolute inset-0 pointer-events-none"
|
|
22
|
+
style={{
|
|
23
|
+
zIndex: 100,
|
|
24
|
+
clipPath:
|
|
25
|
+
settledScrollLeft === null
|
|
26
|
+
? undefined
|
|
27
|
+
: `inset(0 0 0 ${settledScrollLeft + contentOrigin - PLAYHEAD_GLOW_W / 2}px)`,
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
{children}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { memo,
|
|
2
|
-
import {
|
|
1
|
+
import { memo, type RefObject } from "react";
|
|
2
|
+
import { useSettledScrollLeft } from "./useSettledScrollLeft";
|
|
3
3
|
import type { TimelineTheme } from "./timelineTheme";
|
|
4
4
|
import { RULER_H, getTimelineBeatEntries } from "./timelineLayout";
|
|
5
5
|
import { formatTimelineTickLabel } from "./timelineRulerGeometry";
|
|
@@ -41,11 +41,8 @@ function tickIndexUnderHeader(major: number[], pps: number, scrollLeft: number):
|
|
|
41
41
|
return found;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
* on every tick crossing cost 2-3x the scroll budget on a large timeline, so mid-scroll a
|
|
47
|
-
* fragment can still show.
|
|
48
|
-
*/
|
|
44
|
+
// Paints over the one label the header corner would slice, once a scroll settles; mid-scroll a
|
|
45
|
+
// fragment can still show.
|
|
49
46
|
const HeaderSlicedLabelMask = memo(function HeaderSlicedLabelMask({
|
|
50
47
|
scrollRef,
|
|
51
48
|
major,
|
|
@@ -57,39 +54,9 @@ const HeaderSlicedLabelMask = memo(function HeaderSlicedLabelMask({
|
|
|
57
54
|
pps: number;
|
|
58
55
|
background: string;
|
|
59
56
|
}) {
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
const el = scrollRef.current;
|
|
64
|
-
let settle: ReturnType<typeof setTimeout> | undefined;
|
|
65
|
-
const onScroll = () => {
|
|
66
|
-
if (!scrollingRef.current) {
|
|
67
|
-
scrollingRef.current = true;
|
|
68
|
-
onChange();
|
|
69
|
-
}
|
|
70
|
-
clearTimeout(settle);
|
|
71
|
-
settle = setTimeout(() => {
|
|
72
|
-
scrollingRef.current = false;
|
|
73
|
-
onChange();
|
|
74
|
-
}, TIMELINE_SCROLL_SETTLE_MS);
|
|
75
|
-
};
|
|
76
|
-
el?.addEventListener("scroll", onScroll, { passive: true });
|
|
77
|
-
return () => {
|
|
78
|
-
clearTimeout(settle);
|
|
79
|
-
scrollingRef.current = false;
|
|
80
|
-
el?.removeEventListener("scroll", onScroll);
|
|
81
|
-
};
|
|
82
|
-
},
|
|
83
|
-
[scrollRef],
|
|
84
|
-
);
|
|
85
|
-
const index = useSyncExternalStore(
|
|
86
|
-
subscribe,
|
|
87
|
-
() =>
|
|
88
|
-
scrollingRef.current
|
|
89
|
-
? -1
|
|
90
|
-
: tickIndexUnderHeader(major, pps, scrollRef.current?.scrollLeft ?? 0),
|
|
91
|
-
() => -1,
|
|
92
|
-
);
|
|
57
|
+
const settledScrollLeft = useSettledScrollLeft(scrollRef);
|
|
58
|
+
const index =
|
|
59
|
+
settledScrollLeft === null ? -1 : tickIndexUnderHeader(major, pps, settledScrollLeft);
|
|
93
60
|
if (index < 0) return null;
|
|
94
61
|
const labelLeft = major[index]! * pps - 0.5 + TICK_LABEL_INSET_PX;
|
|
95
62
|
const next = major[index + 1];
|