@hyperframes/studio 0.8.75 → 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-BiFKcNjs.js → index-COX0scwJ.js} +1 -1
- package/dist/assets/{index-BlUYw1MB.js → index-DeHir1up.js} +156 -156
- package/dist/assets/{index-HjexPsny.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 +34 -69
- package/dist/index.html +1 -1
- package/dist/index.js +4147 -4253
- 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 +0 -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.ts +0 -1
- 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/timelineClipDragCommit.test.ts +7 -39
- 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 +1 -0
- package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
- package/src/player/hooks/useTimelinePlayer.ts +1 -0
- 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-WbBVbWuO.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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
3
|
+
import type { PreviewCompositionSize } from "../../utils/previewCompositionSize";
|
|
4
|
+
|
|
5
|
+
/** Frame 0 as the thumbnail route last rendered it; `cachedOnly` never starts a render. */
|
|
6
|
+
function previewPosterUrl(projectId: string, cachedOnly: boolean): string {
|
|
7
|
+
const query = `t=0&output=source${cachedOnly ? "&cached=1" : ""}`;
|
|
8
|
+
return buildProjectApiPath(projectId, `/thumbnail/index.html?${query}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const PREVIEW_POSTER_STYLE: React.CSSProperties = {
|
|
12
|
+
position: "absolute",
|
|
13
|
+
inset: 0,
|
|
14
|
+
width: "100%",
|
|
15
|
+
height: "100%",
|
|
16
|
+
objectFit: "contain",
|
|
17
|
+
zIndex: 2,
|
|
18
|
+
pointerEvents: "none",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Covers the live preview with the cached frame 0; reports its size so the stage fits it. */
|
|
22
|
+
export function PreviewPoster({
|
|
23
|
+
projectId,
|
|
24
|
+
hidden,
|
|
25
|
+
onSize,
|
|
26
|
+
onLoaded,
|
|
27
|
+
onMissing,
|
|
28
|
+
}: {
|
|
29
|
+
projectId: string;
|
|
30
|
+
hidden: boolean;
|
|
31
|
+
onSize: (size: PreviewCompositionSize) => void;
|
|
32
|
+
onLoaded: () => void;
|
|
33
|
+
onMissing: () => void;
|
|
34
|
+
}) {
|
|
35
|
+
return (
|
|
36
|
+
<img
|
|
37
|
+
src={previewPosterUrl(projectId, true)}
|
|
38
|
+
alt=""
|
|
39
|
+
aria-hidden
|
|
40
|
+
data-testid="preview-poster"
|
|
41
|
+
hidden={hidden}
|
|
42
|
+
style={PREVIEW_POSTER_STYLE}
|
|
43
|
+
onLoad={(event) => {
|
|
44
|
+
const { naturalWidth: width, naturalHeight: height } = event.currentTarget;
|
|
45
|
+
if (!hidden && width > 0 && height > 0) onSize({ width, height });
|
|
46
|
+
onLoaded();
|
|
47
|
+
}}
|
|
48
|
+
onError={onMissing}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The poster covers the live slot until its first frame is ready; a missing poster is rendered
|
|
54
|
+
* after both are known, in either order, off the open path, for the next open. */
|
|
55
|
+
export function usePreviewPoster(
|
|
56
|
+
projectId: string,
|
|
57
|
+
activeKey: string,
|
|
58
|
+
directUrl: string | undefined,
|
|
59
|
+
) {
|
|
60
|
+
const visit = useVisitNumber(activeKey);
|
|
61
|
+
const [coverDoneFor, setCoverDoneFor] = useState<number | null>(null);
|
|
62
|
+
const [posterSettledFor, setPosterSettledFor] = useState<number | null>(null);
|
|
63
|
+
const missingForRef = useRef<number | null>(null);
|
|
64
|
+
const liveReadyForRef = useRef<number | null>(null);
|
|
65
|
+
const renderMissingPoster = useCallback(() => {
|
|
66
|
+
if (missingForRef.current !== visit || liveReadyForRef.current !== visit) return;
|
|
67
|
+
missingForRef.current = null;
|
|
68
|
+
void fetch(previewPosterUrl(projectId, false)).catch(() => {});
|
|
69
|
+
}, [visit, projectId]);
|
|
70
|
+
const onLiveReadyToShowChange = useCallback(
|
|
71
|
+
(ready: boolean) => {
|
|
72
|
+
if (!ready) return;
|
|
73
|
+
setCoverDoneFor(visit);
|
|
74
|
+
liveReadyForRef.current = visit;
|
|
75
|
+
renderMissingPoster();
|
|
76
|
+
},
|
|
77
|
+
[visit, renderMissingPoster],
|
|
78
|
+
);
|
|
79
|
+
return {
|
|
80
|
+
mountPoster: !directUrl && (coverDoneFor !== visit || posterSettledFor !== visit),
|
|
81
|
+
hidePoster: coverDoneFor === visit,
|
|
82
|
+
onLiveReadyToShowChange,
|
|
83
|
+
onPreviewError: () => setCoverDoneFor(visit),
|
|
84
|
+
onPosterLoaded: () => setPosterSettledFor(visit),
|
|
85
|
+
onPosterMissing: () => {
|
|
86
|
+
setCoverDoneFor(visit);
|
|
87
|
+
setPosterSettledFor(visit);
|
|
88
|
+
missingForRef.current = visit;
|
|
89
|
+
renderMissingPoster();
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function useVisitNumber(key: string): number {
|
|
95
|
+
const [seen, setSeen] = useState({ key, visit: 0 });
|
|
96
|
+
if (seen.key === key) return seen.visit;
|
|
97
|
+
setSeen({ key, visit: seen.visit + 1 });
|
|
98
|
+
return seen.visit + 1;
|
|
99
|
+
}
|
|
@@ -12,12 +12,6 @@ import {
|
|
|
12
12
|
} from "./useCanvasZOrderTimelineMirror";
|
|
13
13
|
import { makeLifecycleOpsParams } from "../../hooks/elementLifecycleOpsTestUtils";
|
|
14
14
|
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
|
15
|
-
import {
|
|
16
|
-
buildEditHistoryEntry,
|
|
17
|
-
createEmptyEditHistory,
|
|
18
|
-
pushEditHistoryEntry,
|
|
19
|
-
type EditHistoryState,
|
|
20
|
-
} from "../../utils/editHistory";
|
|
21
15
|
|
|
22
16
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
23
17
|
|
|
@@ -61,45 +55,38 @@ interface HarnessApi {
|
|
|
61
55
|
mirror: (input: MirrorZOrderInput) => Promise<boolean>;
|
|
62
56
|
}
|
|
63
57
|
|
|
58
|
+
interface RecordedWrite {
|
|
59
|
+
label: string;
|
|
60
|
+
coalesceKey: string;
|
|
61
|
+
after: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
64
|
/**
|
|
65
65
|
* Mount the REAL wiring pair PreviewOverlays composes — handleDomZIndexReorderCommit
|
|
66
|
-
* (z persist) + useCanvasZOrderTimelineMirror (lane mirror)
|
|
67
|
-
*
|
|
68
|
-
*
|
|
66
|
+
* (z persist) + useCanvasZOrderTimelineMirror (lane mirror). The server (not this
|
|
67
|
+
* test) owns folding two records that share a coalesceKey into one undo entry
|
|
68
|
+
* (projectHistory.ts, "claim: a writer that records after writing"), so this harness
|
|
69
|
+
* only captures what Studio sends on each write:
|
|
69
70
|
*
|
|
70
71
|
* - the z sink mimics commitDomEditPatchBatches' recordEdit call verbatim
|
|
71
|
-
* (
|
|
72
|
+
* (options.coalesceKey — see useDomEditCommits.ts), and
|
|
72
73
|
* - the move sink mimics persistTimelineBatchEdit → saveProjectFilesWithHistory
|
|
73
|
-
* (
|
|
74
|
-
* timelineEditingHelpers.ts / studioFileHistory.ts)
|
|
75
|
-
*
|
|
76
|
-
* with a deterministic clock inside the reducer's 300ms coalesce window.
|
|
74
|
+
* (the coalesceKey forwarded through onMoveElements — see
|
|
75
|
+
* timelineEditingHelpers.ts / studioFileHistory.ts).
|
|
77
76
|
*/
|
|
78
77
|
function mountMirrorHarness(history: {
|
|
79
|
-
state: EditHistoryState;
|
|
80
|
-
now: () => number;
|
|
81
78
|
fileContent: { current: string };
|
|
82
79
|
moveCoalesceKeys: string[];
|
|
80
|
+
records: RecordedWrite[];
|
|
83
81
|
}) {
|
|
84
82
|
const record = (
|
|
85
83
|
label: string,
|
|
86
|
-
kind: "manual" | "timeline",
|
|
87
84
|
coalesceKey: string,
|
|
88
|
-
|
|
85
|
+
_coalesceMs: number | undefined,
|
|
89
86
|
after: string,
|
|
90
87
|
) => {
|
|
91
|
-
|
|
92
|
-
id: `e-${history.now()}`,
|
|
93
|
-
projectId: "p",
|
|
94
|
-
label,
|
|
95
|
-
kind,
|
|
96
|
-
coalesceKey,
|
|
97
|
-
coalesceMs,
|
|
98
|
-
now: history.now(),
|
|
99
|
-
files: { "index.html": { before: history.fileContent.current, after } },
|
|
100
|
-
});
|
|
88
|
+
history.records.push({ label, coalesceKey, after });
|
|
101
89
|
history.fileContent.current = after;
|
|
102
|
-
history.state = pushEditHistoryEntry(history.state, entry);
|
|
103
90
|
};
|
|
104
91
|
|
|
105
92
|
const onMoveElements: TimelineEditCallbacks["onMoveElements"] = (
|
|
@@ -109,7 +96,7 @@ function mountMirrorHarness(history: {
|
|
|
109
96
|
coalesceMs,
|
|
110
97
|
) => {
|
|
111
98
|
history.moveCoalesceKeys.push(coalesceKey ?? "<none>");
|
|
112
|
-
record("Move timeline clips",
|
|
99
|
+
record("Move timeline clips", coalesceKey ?? "<none>", coalesceMs, "C-move");
|
|
113
100
|
};
|
|
114
101
|
|
|
115
102
|
const api: Partial<HarnessApi> = {};
|
|
@@ -117,7 +104,7 @@ function mountMirrorHarness(history: {
|
|
|
117
104
|
const { handleDomZIndexReorderCommit } = useElementLifecycleOps(
|
|
118
105
|
makeLifecycleOpsParams({
|
|
119
106
|
commitDomEditPatchBatches: async (_batches, options) => {
|
|
120
|
-
record(options.label,
|
|
107
|
+
record(options.label, options.coalesceKey, options.coalesceMs, "B-z");
|
|
121
108
|
return { durable: true, allMatched: true, changed: true };
|
|
122
109
|
},
|
|
123
110
|
}),
|
|
@@ -135,17 +122,10 @@ function mountMirrorHarness(history: {
|
|
|
135
122
|
}
|
|
136
123
|
|
|
137
124
|
function makeHistory() {
|
|
138
|
-
let tick = 1000;
|
|
139
125
|
return {
|
|
140
|
-
state: createEmptyEditHistory(),
|
|
141
|
-
// Deterministic clock: consecutive records land 400ms apart — PAST the
|
|
142
|
-
// reducer's default 300ms coalesce window, as in the live flow where the
|
|
143
|
-
// mirror is dispatched only after the z persist's server round-trip
|
|
144
|
-
// resolves (real network latency exceeds 300ms). The fold must therefore
|
|
145
|
-
// ride the gesture's explicit coalesceMs window, not the default.
|
|
146
|
-
now: () => (tick += 400),
|
|
147
126
|
fileContent: { current: "A-original" },
|
|
148
127
|
moveCoalesceKeys: [] as string[],
|
|
128
|
+
records: [] as RecordedWrite[],
|
|
149
129
|
};
|
|
150
130
|
}
|
|
151
131
|
|
|
@@ -208,13 +188,12 @@ describe("useCanvasZOrderTimelineMirror", () => {
|
|
|
208
188
|
|
|
209
189
|
// The move persist received the EXACT z coalesce key…
|
|
210
190
|
expect(history.moveCoalesceKeys).toEqual([coalesceKey]);
|
|
211
|
-
// …and
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
expect(history.
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
});
|
|
191
|
+
// …and both writes carry that SAME non-empty coalesceKey: that is what lets
|
|
192
|
+
// the server (projectHistory.ts) fold them into one undo entry. One Cmd+Z
|
|
193
|
+
// then reverts both writes.
|
|
194
|
+
expect(history.records.map((r) => r.coalesceKey)).toEqual([coalesceKey, coalesceKey]);
|
|
195
|
+
expect(history.records[0]?.after).toBe("B-z");
|
|
196
|
+
expect(history.records[1]?.after).toBe("C-move");
|
|
218
197
|
// Timeline UI reflects the lane change without a reload: optimistic store update.
|
|
219
198
|
const t = usePlayerStore.getState().elements.find((e) => e.key === "index.html#t");
|
|
220
199
|
expect(t?.track).toBe(0);
|
|
@@ -265,8 +244,10 @@ describe("useCanvasZOrderTimelineMirror", () => {
|
|
|
265
244
|
}
|
|
266
245
|
|
|
267
246
|
expect(keys[0]).not.toBe(keys[1]); // fresh key per gesture
|
|
268
|
-
// Each gesture
|
|
269
|
-
|
|
247
|
+
// Each gesture's z+move pair shares that gesture's own key, so the server folds
|
|
248
|
+
// each pair on its own — but the two gestures never share a key, so they stay apart.
|
|
249
|
+
expect(history.records).toHaveLength(4);
|
|
250
|
+
expect(history.records.map((r) => r.coalesceKey)).toEqual([keys[0], keys[0], keys[1], keys[1]]);
|
|
270
251
|
expect(history.moveCoalesceKeys).toEqual(keys);
|
|
271
252
|
});
|
|
272
253
|
|
|
@@ -299,7 +280,7 @@ describe("useCanvasZOrderTimelineMirror", () => {
|
|
|
299
280
|
});
|
|
300
281
|
|
|
301
282
|
expect(history.moveCoalesceKeys).toEqual([]); // no lane persist dispatched
|
|
302
|
-
expect(history.
|
|
283
|
+
expect(history.records).toHaveLength(1); // just the z write
|
|
303
284
|
const t = usePlayerStore.getState().elements.find((e) => e.key === "index.html#t");
|
|
304
285
|
expect(t?.track).toBe(1); // lane unchanged
|
|
305
286
|
});
|
|
@@ -5,7 +5,6 @@ import type {
|
|
|
5
5
|
VariableUsageReport,
|
|
6
6
|
VariableValidationIssue,
|
|
7
7
|
} from "@hyperframes/sdk";
|
|
8
|
-
import type { EditHistoryKind } from "../../utils/editHistory";
|
|
9
8
|
import type { PublishSdkSession } from "../../utils/sdkCutover";
|
|
10
9
|
import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
|
|
11
10
|
import { useDomEditContext } from "../../contexts/DomEditContext";
|
|
@@ -37,7 +36,6 @@ export interface StudioEditPersistenceProps {
|
|
|
37
36
|
reloadPreview: () => void;
|
|
38
37
|
recordEdit: (entry: {
|
|
39
38
|
label: string;
|
|
40
|
-
kind: EditHistoryKind;
|
|
41
39
|
files: Record<string, { before: string; after: string }>;
|
|
42
40
|
}) => Promise<void>;
|
|
43
41
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
7
7
|
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
|
|
8
8
|
import { CompositionsTab } from "./CompositionsTab";
|
|
@@ -14,6 +14,10 @@ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
|
14
14
|
|
|
15
15
|
let root: Root | null = null;
|
|
16
16
|
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
usePlayerStore.setState({ previewBooted: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
17
21
|
afterEach(() => {
|
|
18
22
|
if (root) act(() => root?.unmount());
|
|
19
23
|
root = null;
|
|
@@ -39,6 +39,20 @@ function mount(compositions: string[], masterCompositionPath: string | null) {
|
|
|
39
39
|
return host;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
describe("CompositionsTab card thumbnails", () => {
|
|
43
|
+
it("waits for the live preview to boot and keeps them through edit reloads", () => {
|
|
44
|
+
usePlayerStore.getState().reset();
|
|
45
|
+
const host = mount(["index.html"], "index.html");
|
|
46
|
+
expect(host.querySelector("img")).toBeNull();
|
|
47
|
+
|
|
48
|
+
act(() => usePlayerStore.getState().markPreviewBooted());
|
|
49
|
+
expect(host.querySelector("img")?.getAttribute("src")).toContain("/thumbnail/index.html");
|
|
50
|
+
|
|
51
|
+
act(() => usePlayerStore.getState().setTimelineReady(false));
|
|
52
|
+
expect(host.querySelector("img")).not.toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
42
56
|
describe("CompositionsTab root badge", () => {
|
|
43
57
|
it("marks the composition matching masterCompositionPath as root", () => {
|
|
44
58
|
const compositions = ["index.html", "compositions/headline.html"];
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
resolveThumbnailSeekTime,
|
|
5
|
-
syncIframePlayback,
|
|
6
|
-
} from "./CompositionsTab";
|
|
2
|
+
import { resolveThumbnailSeekTime } from "../../player/components/CompositionThumbnail";
|
|
3
|
+
import { resolveCompositionPreviewScale, syncIframePlayback } from "./CompositionsTab";
|
|
7
4
|
|
|
8
5
|
describe("resolveCompositionPreviewScale", () => {
|
|
9
6
|
it("scales a 16:9 stage to fit the composition card", () => {
|
|
@@ -45,8 +42,9 @@ describe("resolveThumbnailSeekTime", () => {
|
|
|
45
42
|
expect(resolveThumbnailSeekTime(6)).toBe(3);
|
|
46
43
|
});
|
|
47
44
|
|
|
48
|
-
it("uses the midpoint for compositions shorter
|
|
45
|
+
it("uses the midpoint for compositions of 3s or shorter, never their end frame", () => {
|
|
49
46
|
expect(resolveThumbnailSeekTime(2)).toBe(1);
|
|
47
|
+
expect(resolveThumbnailSeekTime(3)).toBe(1.5);
|
|
50
48
|
});
|
|
51
49
|
|
|
52
50
|
it("falls back to the default 3s frame when duration is unknown", () => {
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
2
2
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
buildCompositionThumbnailUrl,
|
|
5
|
+
resolveThumbnailSeekTime,
|
|
6
|
+
THUMBNAIL_SEEK_TIME_SECONDS,
|
|
7
|
+
} from "../../player/components/CompositionThumbnail";
|
|
4
8
|
import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
|
|
5
9
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
6
10
|
import { thumbnailRevisionOf } from "../../player/store/thumbnailSlice";
|
|
11
|
+
import { encodePreviewPath } from "../../player/components/thumbnailUtils";
|
|
7
12
|
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
|
|
8
13
|
import { Tooltip } from "../ui/Tooltip";
|
|
9
14
|
|
|
@@ -23,7 +28,6 @@ interface CompositionsTabProps {
|
|
|
23
28
|
const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 };
|
|
24
29
|
const CARD_W = 80;
|
|
25
30
|
const CARD_H = 45;
|
|
26
|
-
const THUMBNAIL_SEEK_TIME_SECONDS = 3;
|
|
27
31
|
const THUMBNAIL_PLAYBACK_SYNC_ATTEMPTS = 10;
|
|
28
32
|
|
|
29
33
|
type PreviewWindow = Window & {
|
|
@@ -54,17 +58,22 @@ export function resolveCompositionPreviewScale(input: {
|
|
|
54
58
|
return Math.min(scaleX, scaleY);
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
durationSeconds != null &&
|
|
61
|
-
durationSeconds > 0 &&
|
|
62
|
-
durationSeconds < THUMBNAIL_SEEK_TIME_SECONDS
|
|
63
|
-
) {
|
|
64
|
-
return durationSeconds / 2;
|
|
65
|
-
}
|
|
61
|
+
function compositionPreviewUrl(projectId: string, comp: string): string {
|
|
62
|
+
return buildProjectApiPath(projectId, `/preview/comp/${encodePreviewPath(comp)}`);
|
|
63
|
+
}
|
|
66
64
|
|
|
67
|
-
|
|
65
|
+
export function compositionCardThumbnailUrl(
|
|
66
|
+
projectId: string,
|
|
67
|
+
comp: string,
|
|
68
|
+
contentRevision: number,
|
|
69
|
+
): string {
|
|
70
|
+
return buildCompositionThumbnailUrl({
|
|
71
|
+
previewUrl: compositionPreviewUrl(projectId, comp),
|
|
72
|
+
seekTime: THUMBNAIL_SEEK_TIME_SECONDS,
|
|
73
|
+
duration: 0,
|
|
74
|
+
origin: window.location.origin,
|
|
75
|
+
contentRevision,
|
|
76
|
+
});
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
function parsePositiveNumber(value: string | null): number | null {
|
|
@@ -127,6 +136,7 @@ function CompCard({
|
|
|
127
136
|
lintInfo,
|
|
128
137
|
onAddToTimeline,
|
|
129
138
|
contentRevision,
|
|
139
|
+
previewBooted,
|
|
130
140
|
}: {
|
|
131
141
|
projectId: string;
|
|
132
142
|
comp: string;
|
|
@@ -138,6 +148,7 @@ function CompCard({
|
|
|
138
148
|
lintInfo?: { count: number; messages: string[] };
|
|
139
149
|
onAddToTimeline?: () => void;
|
|
140
150
|
contentRevision: number;
|
|
151
|
+
previewBooted: boolean;
|
|
141
152
|
}) {
|
|
142
153
|
const [hovered, setHovered] = useState(false);
|
|
143
154
|
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
|
@@ -179,14 +190,8 @@ function CompCard({
|
|
|
179
190
|
setLivePreviewLoaded(false);
|
|
180
191
|
};
|
|
181
192
|
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
|
182
|
-
const previewUrl =
|
|
183
|
-
const thumbnailUrl =
|
|
184
|
-
previewUrl,
|
|
185
|
-
seekTime: THUMBNAIL_SEEK_TIME_SECONDS,
|
|
186
|
-
duration: 0,
|
|
187
|
-
origin: window.location.origin,
|
|
188
|
-
contentRevision,
|
|
189
|
-
});
|
|
193
|
+
const previewUrl = compositionPreviewUrl(projectId, comp);
|
|
194
|
+
const thumbnailUrl = compositionCardThumbnailUrl(projectId, comp, contentRevision);
|
|
190
195
|
const thumbnailFailed = failedThumbnailUrl === thumbnailUrl;
|
|
191
196
|
const previewScale = resolveCompositionPreviewScale({
|
|
192
197
|
cardWidth: CARD_W,
|
|
@@ -250,7 +255,7 @@ function CompCard({
|
|
|
250
255
|
<div className="absolute inset-0 flex items-center justify-center px-1 text-center text-[8px] leading-tight text-neutral-600">
|
|
251
256
|
Preview unavailable
|
|
252
257
|
</div>
|
|
253
|
-
) : (
|
|
258
|
+
) : !previewBooted ? null : (
|
|
254
259
|
<img
|
|
255
260
|
src={thumbnailUrl}
|
|
256
261
|
alt=""
|
|
@@ -390,6 +395,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
|
|
390
395
|
lintFindingsByFile,
|
|
391
396
|
}: CompositionsTabProps) {
|
|
392
397
|
const thumbnailRevisions = usePlayerStore((state) => state.thumbnailRevisions);
|
|
398
|
+
const previewBooted = usePlayerStore((state) => state.previewBooted);
|
|
393
399
|
if (compositions.length === 0) {
|
|
394
400
|
return (
|
|
395
401
|
<div className="flex-1 flex items-center justify-center px-4">
|
|
@@ -413,6 +419,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
|
|
413
419
|
isRendering={isRendering}
|
|
414
420
|
lintInfo={lintFindingsByFile?.get(comp)}
|
|
415
421
|
contentRevision={thumbnailRevisionOf(thumbnailRevisions, comp)}
|
|
422
|
+
previewBooted={previewBooted}
|
|
416
423
|
/>
|
|
417
424
|
))}
|
|
418
425
|
</div>
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { EditHistoryKind } from "../utils/editHistory";
|
|
2
1
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
3
2
|
import { patchMediaColorGradingInHtml } from "./editor/colorGradingScopePatch";
|
|
4
3
|
import { hasRelativeLutSource } from "./studioMediaJobs";
|
|
@@ -11,7 +10,6 @@ type ProjectFileWriter = (path: string, content: string) => Promise<void>;
|
|
|
11
10
|
type ShowToast = (message: string, tone?: "error" | "info") => void;
|
|
12
11
|
type RecordEdit = (entry: {
|
|
13
12
|
label: string;
|
|
14
|
-
kind: EditHistoryKind;
|
|
15
13
|
files: Record<string, { before: string; after: string }>;
|
|
16
14
|
}) => Promise<void>;
|
|
17
15
|
|
|
@@ -105,7 +103,6 @@ export async function applyColorGradingScopeUpdate({
|
|
|
105
103
|
const changedPaths = await saveProjectFilesWithHistory({
|
|
106
104
|
projectId,
|
|
107
105
|
label: value ? "Apply color grading" : "Clear color grading",
|
|
108
|
-
kind: "manual",
|
|
109
106
|
files,
|
|
110
107
|
readFile: readProjectFile,
|
|
111
108
|
writeFile: writeProjectFile,
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
9
|
-
import type { EditHistoryKind } from "../utils/editHistory";
|
|
10
9
|
import type { TimelineElement } from "../player";
|
|
11
10
|
import { findMatchingTimelineElementId } from "../utils/studioHelpers";
|
|
12
11
|
|
|
@@ -14,7 +13,6 @@ import { findMatchingTimelineElementId } from "../utils/studioHelpers";
|
|
|
14
13
|
* supplies it. */
|
|
15
14
|
export interface RecordEditInput {
|
|
16
15
|
label: string;
|
|
17
|
-
kind: EditHistoryKind;
|
|
18
16
|
coalesceKey?: string;
|
|
19
17
|
files: Record<string, { before: string; after: string }>;
|
|
20
18
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
2
2
|
import type { Composition } from "@hyperframes/sdk";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
|
-
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
4
|
import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
6
5
|
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
|
7
6
|
|
|
@@ -96,7 +95,6 @@ export interface GsapScriptCommitsParams {
|
|
|
96
95
|
editHistory: {
|
|
97
96
|
recordEdit: (entry: {
|
|
98
97
|
label: string;
|
|
99
|
-
kind: EditHistoryKind;
|
|
100
98
|
coalesceKey?: string;
|
|
101
99
|
coalesceMs?: number;
|
|
102
100
|
files: Record<string, { before: string; after: string }>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { fetchParsedAnimations } from "./keyframeCacheAstLoad";
|
|
3
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Parsing a composition is a whole-file read + parse on the server, and a
|
|
@@ -9,10 +10,39 @@ import { fetchParsedAnimations } from "./keyframeCacheAstLoad";
|
|
|
9
10
|
* pre-write answer.
|
|
10
11
|
*/
|
|
11
12
|
describe("fetchParsedAnimations — in-flight sharing", () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
usePlayerStore.setState({ timelineProjectId: "p", previewBooted: true });
|
|
15
|
+
});
|
|
12
16
|
afterEach(() => {
|
|
13
17
|
vi.unstubAllGlobals();
|
|
14
18
|
});
|
|
15
19
|
|
|
20
|
+
it("does not ask the server to parse until the live preview has booted", async () => {
|
|
21
|
+
usePlayerStore.setState({ previewBooted: false });
|
|
22
|
+
const fetchStub = stubFetch();
|
|
23
|
+
|
|
24
|
+
const parsed = fetchParsedAnimations("p", "boot.html");
|
|
25
|
+
await Promise.resolve();
|
|
26
|
+
expect(fetchStub.calls()).toBe(0);
|
|
27
|
+
|
|
28
|
+
usePlayerStore.getState().markPreviewBooted();
|
|
29
|
+
await vi.waitFor(() => expect(fetchStub.calls()).toBe(1));
|
|
30
|
+
fetchStub.settle();
|
|
31
|
+
expect((await parsed)?.animations).toHaveLength(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("drops a parse for a project the user left before its preview booted", async () => {
|
|
35
|
+
usePlayerStore.setState({ previewBooted: false });
|
|
36
|
+
const fetchStub = stubFetch();
|
|
37
|
+
|
|
38
|
+
const parsed = fetchParsedAnimations("p", "left.html");
|
|
39
|
+
usePlayerStore.getState().beginTimelineSession("q");
|
|
40
|
+
usePlayerStore.getState().markPreviewBooted();
|
|
41
|
+
|
|
42
|
+
expect(await parsed).toBeNull();
|
|
43
|
+
expect(fetchStub.calls()).toBe(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
16
46
|
function stubFetch(): { calls: () => number; settle: () => void } {
|
|
17
47
|
let calls = 0;
|
|
18
48
|
const pending: Array<() => void> = [];
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
7
7
|
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
|
|
8
|
-
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
|
+
import { isPreviewBooted, usePlayerStore, whenPreviewBooted } from "../player/store/playerStore";
|
|
9
9
|
import { replaceKeyframeCacheForFile } from "./gsapKeyframeCacheHelpers";
|
|
10
10
|
import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
|
|
11
11
|
import {
|
|
@@ -74,6 +74,7 @@ async function requestParsedAnimations(
|
|
|
74
74
|
projectId: string,
|
|
75
75
|
sourceFile: string,
|
|
76
76
|
): Promise<ParsedGsapAnimations | null> {
|
|
77
|
+
if (!isPreviewBooted(projectId) && !(await whenPreviewBooted(projectId))) return null;
|
|
77
78
|
try {
|
|
78
79
|
const res = await fetch(
|
|
79
80
|
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
|
|
@@ -237,7 +237,6 @@ export async function createAudioGroupAndAssignMembers({
|
|
|
237
237
|
label: groupLabel
|
|
238
238
|
? `Group ${elements.length} clips as ${groupLabel}`
|
|
239
239
|
: `Group ${elements.length} voice clips`,
|
|
240
|
-
kind: "timeline",
|
|
241
240
|
files,
|
|
242
241
|
readFile: async (path) => {
|
|
243
242
|
const original = originalByPath.get(path);
|
|
@@ -318,7 +318,6 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom
|
|
|
318
318
|
await saveProjectFilesWithHistory({
|
|
319
319
|
projectId: input.projectId,
|
|
320
320
|
label: input.label,
|
|
321
|
-
kind: "timeline",
|
|
322
321
|
coalesceKey: input.coalesceKey,
|
|
323
322
|
files: { [targetPath]: patchedContent },
|
|
324
323
|
readFile: async () => originalContent,
|
|
@@ -388,7 +387,6 @@ export async function persistTimelineBatchEdit(
|
|
|
388
387
|
await saveProjectFilesWithHistory({
|
|
389
388
|
projectId: input.projectId,
|
|
390
389
|
label: input.label,
|
|
391
|
-
kind: "timeline",
|
|
392
390
|
coalesceKey: input.coalesceKey,
|
|
393
391
|
coalesceMs: input.coalesceMs,
|
|
394
392
|
files,
|
|
@@ -466,7 +464,6 @@ export async function persistElementAttribute({
|
|
|
466
464
|
const changedPaths = await saveProjectFilesWithHistory({
|
|
467
465
|
projectId,
|
|
468
466
|
label,
|
|
469
|
-
kind: "timeline",
|
|
470
467
|
files: { [targetPath]: patched },
|
|
471
468
|
readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)),
|
|
472
469
|
writeFile: writeProjectFile,
|
|
@@ -399,7 +399,6 @@ async function foldGsapMutationIntoHistory(input: {
|
|
|
399
399
|
if (Object.keys(files).length > 0) {
|
|
400
400
|
await input.recordEdit({
|
|
401
401
|
label: input.label,
|
|
402
|
-
kind: "timeline",
|
|
403
402
|
coalesceKey: input.coalesceKey,
|
|
404
403
|
coalesceMs: GSAP_HISTORY_COALESCE_MS,
|
|
405
404
|
files,
|
|
@@ -8,7 +8,6 @@ import { usePlayerStore } from "../player";
|
|
|
8
8
|
import { addBlockToProject } from "../utils/blockInstaller";
|
|
9
9
|
import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
|
|
10
10
|
import type { BlockParam } from "@hyperframes/core/registry";
|
|
11
|
-
import type { EditHistoryKind } from "../utils/editHistory";
|
|
12
11
|
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
13
12
|
import type { MediaOverlayPlacement } from "../components/editor/propertyPanelTypes";
|
|
14
13
|
|
|
@@ -19,7 +18,6 @@ interface BlockCtxDeps {
|
|
|
19
18
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
20
19
|
recordEdit: (entry: {
|
|
21
20
|
label: string;
|
|
22
|
-
kind: EditHistoryKind;
|
|
23
21
|
coalesceKey?: string;
|
|
24
22
|
files: Record<string, { before: string; after: string }>;
|
|
25
23
|
}) => Promise<void>;
|