@hyperframes/studio 0.6.95 → 0.6.97
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-Daj5djxa.js +418 -0
- package/dist/assets/index-B0twsRu0.css +1 -0
- package/dist/assets/index-Cfye9xzo.js +251 -0
- package/dist/assets/{index-CAANLw9Q.js → index-HveJ0MuV.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +10 -5
- package/src/components/SaveQueuePausedBanner.tsx +23 -0
- package/src/components/StudioPreviewArea.tsx +7 -0
- package/src/components/StudioRightPanel.tsx +1 -38
- package/src/components/editor/DomEditOverlay.test.ts +169 -29
- package/src/components/editor/DomEditOverlay.tsx +13 -23
- package/src/components/editor/GestureRecordControl.tsx +98 -0
- package/src/components/editor/PropertyPanel.tsx +22 -38
- package/src/components/editor/domEditing.test.ts +84 -0
- package/src/components/editor/domEditingLayers.ts +19 -0
- package/src/components/editor/domEditingRootLayer.ts +64 -0
- package/src/components/editor/manualEditingAvailability.test.ts +1 -2
- package/src/components/editor/manualEditingAvailability.ts +0 -7
- package/src/contexts/DomEditContext.tsx +1 -6
- package/src/hooks/gsapScriptCommitHelpers.ts +128 -0
- package/src/hooks/useDomEditCommits.ts +97 -123
- package/src/hooks/useDomEditPositionPatchCommit.ts +53 -0
- package/src/hooks/useDomEditSession.ts +59 -65
- package/src/hooks/useFileManager.ts +19 -5
- package/src/hooks/useGsapAnimationFetchFallback.ts +19 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +25 -0
- package/src/hooks/useGsapScriptCommits.ts +152 -140
- package/src/hooks/useGsapSelectionHandlers.ts +38 -8
- package/src/hooks/usePreviewPersistence.ts +90 -51
- package/src/hooks/useSafeGsapCommitMutation.ts +66 -0
- package/src/hooks/useStudioContextValue.ts +3 -19
- package/src/player/hooks/useTimelinePlayer.ts +25 -28
- package/src/player/lib/playbackAdapter.test.ts +86 -1
- package/src/player/lib/playbackAdapter.ts +62 -0
- package/src/utils/domEditSaveQueue.test.ts +117 -0
- package/src/utils/domEditSaveQueue.ts +87 -0
- package/src/utils/studioHelpers.ts +1 -1
- package/src/utils/studioSaveDiagnostics.test.ts +127 -0
- package/src/utils/studioSaveDiagnostics.ts +200 -0
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/studioUrlState.ts +2 -8
- package/dist/assets/hyperframes-player-0esDKGRk.js +0 -418
- package/dist/assets/index-DujOjou6.js +0 -251
- package/dist/assets/index-rm9tn9nH.css +0 -1
- package/src/components/editor/EaseCurveEditor.tsx +0 -221
- package/src/components/editor/MotionPanel.tsx +0 -277
- package/src/components/editor/MotionPanelFields.tsx +0 -185
- package/src/components/editor/MotionPathOverlay.tsx +0 -146
- package/src/components/editor/SpringEaseEditor.tsx +0 -256
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useRef } from "react";
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
2
|
import { useMountEffect } from "./useMountEffect";
|
|
3
3
|
import {
|
|
4
4
|
installStudioManualEditSeekReapply,
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
} from "../components/editor/manualEdits";
|
|
8
8
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
9
9
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
10
|
+
import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
|
|
11
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
10
12
|
|
|
11
13
|
// ── Types ──
|
|
12
14
|
|
|
@@ -35,11 +37,51 @@ interface UsePreviewPersistenceParams {
|
|
|
35
37
|
reloadPreview: () => void;
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
function readIframeDocument(iframe: HTMLIFrameElement): Document | null {
|
|
41
|
+
try {
|
|
42
|
+
return iframe.contentDocument;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function installManualEditReapply(iframe: HTMLIFrameElement): void {
|
|
49
|
+
const reapply = () => {
|
|
50
|
+
const doc = readIframeDocument(iframe);
|
|
51
|
+
if (doc) reapplyPositionEditsAfterSeek(doc);
|
|
52
|
+
};
|
|
53
|
+
const install = () => {
|
|
54
|
+
reapply();
|
|
55
|
+
if (iframe.contentWindow) installStudioManualEditSeekReapply(iframe.contentWindow, reapply);
|
|
56
|
+
};
|
|
57
|
+
const win = iframe.contentWindow;
|
|
58
|
+
install();
|
|
59
|
+
win?.requestAnimationFrame?.(install);
|
|
60
|
+
for (const delayMs of [80, 250, 500, 1000, 2000]) {
|
|
61
|
+
win?.setTimeout?.(install, delayMs);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function shouldReloadForStudioFileChange(
|
|
66
|
+
payload: unknown,
|
|
67
|
+
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>> | undefined,
|
|
68
|
+
domEditSaveTimestampRef: React.MutableRefObject<number>,
|
|
69
|
+
): boolean {
|
|
70
|
+
const changedPath = readStudioFileChangePath(payload);
|
|
71
|
+
if (!changedPath) return false;
|
|
72
|
+
const pendingTimelinePaths = pendingTimelineEditPathRef?.current;
|
|
73
|
+
if (pendingTimelinePaths?.has(changedPath)) {
|
|
74
|
+
pendingTimelinePaths.delete(changedPath);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return Date.now() - domEditSaveTimestampRef.current >= 4000;
|
|
78
|
+
}
|
|
79
|
+
|
|
38
80
|
// ── Hook ──
|
|
39
81
|
|
|
40
82
|
export function usePreviewPersistence({
|
|
41
83
|
projectId,
|
|
42
|
-
showToast
|
|
84
|
+
showToast,
|
|
43
85
|
readOptionalProjectFile: _readOptionalProjectFile,
|
|
44
86
|
writeProjectFile: _writeProjectFile,
|
|
45
87
|
recordEdit: _recordEdit,
|
|
@@ -49,16 +91,38 @@ export function usePreviewPersistence({
|
|
|
49
91
|
reloadPreview,
|
|
50
92
|
pendingTimelineEditPathRef,
|
|
51
93
|
}: UsePreviewPersistenceParams) {
|
|
52
|
-
void _showToast;
|
|
53
94
|
void _recordEdit;
|
|
54
95
|
void _activeCompPathRef;
|
|
55
96
|
|
|
97
|
+
const [domEditSaveQueuePaused, setDomEditSaveQueuePaused] = useState<string | null>(null);
|
|
98
|
+
|
|
56
99
|
const domTextCommitVersionRef = useRef(0);
|
|
57
|
-
const
|
|
100
|
+
const showToastRef = useRef(showToast);
|
|
101
|
+
showToastRef.current = showToast;
|
|
102
|
+
const domEditSaveQueueRef = useRef<ReturnType<typeof createDomEditSaveQueue> | null>(null);
|
|
58
103
|
const applyStudioManualEditsToPreviewRef = useRef<
|
|
59
104
|
(iframe?: HTMLIFrameElement | null) => Promise<void>
|
|
60
105
|
>(async () => {});
|
|
61
106
|
|
|
107
|
+
if (!domEditSaveQueueRef.current) {
|
|
108
|
+
domEditSaveQueueRef.current = createDomEditSaveQueue({
|
|
109
|
+
onOpen: (event) => {
|
|
110
|
+
const message = "Auto-save is paused. Check your connection.";
|
|
111
|
+
setDomEditSaveQueuePaused(message);
|
|
112
|
+
showToastRef.current(message, "error");
|
|
113
|
+
trackStudioEvent("save_queue_paused", {
|
|
114
|
+
source: "dom_edit",
|
|
115
|
+
error_message: event.errorMessage,
|
|
116
|
+
status_code: event.statusCode,
|
|
117
|
+
consecutive_failures: event.consecutiveFailures,
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
onReset: () => {
|
|
121
|
+
setDomEditSaveQueuePaused(null);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
62
126
|
// Keep a ref to the latest projectId so async save callbacks always read the
|
|
63
127
|
// current value, even when the callback was captured in a stale closure.
|
|
64
128
|
const projectIdRef = useRef(projectId);
|
|
@@ -67,55 +131,30 @@ export function usePreviewPersistence({
|
|
|
67
131
|
// ── Queue / drain helpers ──
|
|
68
132
|
|
|
69
133
|
const queueDomEditSave = useCallback((save: () => Promise<void>) => {
|
|
70
|
-
|
|
71
|
-
domEditSaveQueueRef.current = queuedSave.then(
|
|
72
|
-
() => undefined,
|
|
73
|
-
() => undefined,
|
|
74
|
-
);
|
|
75
|
-
return queuedSave;
|
|
134
|
+
return domEditSaveQueueRef.current?.enqueue(save) ?? save();
|
|
76
135
|
}, []);
|
|
77
136
|
|
|
78
137
|
const waitForPendingDomEditSaves = useCallback(async () => {
|
|
79
|
-
await domEditSaveQueueRef.current
|
|
138
|
+
await domEditSaveQueueRef.current?.waitForIdle();
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
const resetDomEditSaveQueueBreaker = useCallback(() => {
|
|
142
|
+
domEditSaveQueueRef.current?.reset();
|
|
143
|
+
setDomEditSaveQueuePaused(null);
|
|
80
144
|
}, []);
|
|
81
145
|
|
|
146
|
+
useMountEffect(() => () => {
|
|
147
|
+
domEditSaveQueueRef.current?.destroy();
|
|
148
|
+
});
|
|
149
|
+
|
|
82
150
|
// ── Apply manual edits (HTML-baked — install seek hooks) ──
|
|
83
151
|
// reapplyPositionEditsAfterSeek now also handles motion reapply from DOM attributes.
|
|
84
152
|
|
|
85
153
|
const applyCurrentStudioManualEditsToPreview = useCallback(
|
|
86
154
|
(iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
|
|
87
155
|
if (!iframe) return;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
doc = iframe.contentDocument;
|
|
91
|
-
} catch {
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
if (!doc) return;
|
|
95
|
-
|
|
96
|
-
const reapply = () => {
|
|
97
|
-
let d: Document | null = null;
|
|
98
|
-
try {
|
|
99
|
-
d = iframe.contentDocument;
|
|
100
|
-
} catch {
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
if (d) reapplyPositionEditsAfterSeek(d);
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
const install = () => {
|
|
107
|
-
reapply();
|
|
108
|
-
if (iframe.contentWindow) installStudioManualEditSeekReapply(iframe.contentWindow, reapply);
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
const win = iframe.contentWindow;
|
|
112
|
-
install();
|
|
113
|
-
win?.requestAnimationFrame?.(install);
|
|
114
|
-
win?.setTimeout?.(install, 80);
|
|
115
|
-
win?.setTimeout?.(install, 250);
|
|
116
|
-
win?.setTimeout?.(install, 500);
|
|
117
|
-
win?.setTimeout?.(install, 1000);
|
|
118
|
-
win?.setTimeout?.(install, 2000);
|
|
156
|
+
if (!readIframeDocument(iframe)) return;
|
|
157
|
+
installManualEditReapply(iframe);
|
|
119
158
|
},
|
|
120
159
|
[previewIframeRef],
|
|
121
160
|
);
|
|
@@ -165,16 +204,14 @@ export function usePreviewPersistence({
|
|
|
165
204
|
// ── Listen for external file changes (HMR / SSE) ──
|
|
166
205
|
useMountEffect(() => {
|
|
167
206
|
const handler = (payload?: unknown) => {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (!recentDomEditSave) {
|
|
207
|
+
if (
|
|
208
|
+
shouldReloadForStudioFileChange(
|
|
209
|
+
payload,
|
|
210
|
+
pendingTimelineEditPathRef,
|
|
211
|
+
domEditSaveTimestampRef,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
176
214
|
reloadPreview();
|
|
177
|
-
}
|
|
178
215
|
};
|
|
179
216
|
if (import.meta.hot) {
|
|
180
217
|
import.meta.hot.on("hf:file-change", handler);
|
|
@@ -192,6 +229,8 @@ export function usePreviewPersistence({
|
|
|
192
229
|
applyStudioManualEditsToPreviewRef,
|
|
193
230
|
queueDomEditSave,
|
|
194
231
|
waitForPendingDomEditSaves,
|
|
232
|
+
domEditSaveQueuePaused,
|
|
233
|
+
resetDomEditSaveQueueBreaker,
|
|
195
234
|
applyCurrentStudioManualEditsToPreview,
|
|
196
235
|
applyStudioManualEditsToPreview,
|
|
197
236
|
syncHistoryPreviewAfterApply,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
|
+
import { getStudioSaveErrorMessage, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
|
4
|
+
|
|
5
|
+
type CommitMutationOptions = {
|
|
6
|
+
label: string;
|
|
7
|
+
coalesceKey?: string;
|
|
8
|
+
softReload?: boolean;
|
|
9
|
+
skipReload?: boolean;
|
|
10
|
+
beforeReload?: () => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type CommitMutation = (
|
|
14
|
+
selection: DomEditSelection,
|
|
15
|
+
mutation: Record<string, unknown>,
|
|
16
|
+
options: CommitMutationOptions,
|
|
17
|
+
) => Promise<void>;
|
|
18
|
+
|
|
19
|
+
type TrackGsapSaveFailure = (
|
|
20
|
+
error: unknown,
|
|
21
|
+
selection: DomEditSelection,
|
|
22
|
+
mutation: Record<string, unknown>,
|
|
23
|
+
label?: string,
|
|
24
|
+
) => void;
|
|
25
|
+
|
|
26
|
+
function getGsapMutationType(mutation: Record<string, unknown>): string {
|
|
27
|
+
return typeof mutation.type === "string" ? mutation.type : "gsap";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useGsapSaveFailureTelemetry(activeCompPath: string | null): TrackGsapSaveFailure {
|
|
31
|
+
return useCallback(
|
|
32
|
+
(error, selection, mutation, label) => {
|
|
33
|
+
trackStudioSaveFailure({
|
|
34
|
+
source: "gsap_commit",
|
|
35
|
+
error,
|
|
36
|
+
filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
|
|
37
|
+
mutationType: getGsapMutationType(mutation),
|
|
38
|
+
label,
|
|
39
|
+
targetId: selection.id,
|
|
40
|
+
targetSelector: selection.selector,
|
|
41
|
+
targetSourceFile: selection.sourceFile,
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
[activeCompPath],
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useSafeGsapCommitMutation(
|
|
49
|
+
commitMutation: CommitMutation,
|
|
50
|
+
trackGsapSaveFailure: TrackGsapSaveFailure,
|
|
51
|
+
showToast?: (message: string, tone?: "error" | "info") => void,
|
|
52
|
+
) {
|
|
53
|
+
return useCallback(
|
|
54
|
+
(
|
|
55
|
+
selection: DomEditSelection,
|
|
56
|
+
mutation: Record<string, unknown>,
|
|
57
|
+
options: CommitMutationOptions,
|
|
58
|
+
) => {
|
|
59
|
+
void commitMutation(selection, mutation, options).catch((error) => {
|
|
60
|
+
trackGsapSaveFailure(error, selection, mutation, options.label);
|
|
61
|
+
showToast?.(`Couldn't save animation: ${getStudioSaveErrorMessage(error)}`, "error");
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
[commitMutation, trackGsapSaveFailure, showToast],
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import { useCallback, useMemo, useRef, useState, type DragEvent } from "react";
|
|
2
|
-
import {
|
|
3
|
-
STUDIO_INSPECTOR_PANELS_ENABLED,
|
|
4
|
-
STUDIO_MOTION_PANEL_ENABLED,
|
|
5
|
-
} from "../components/editor/manualEditingAvailability";
|
|
6
|
-
import { readStudioMotionFromElement } from "../components/editor/studioMotion";
|
|
2
|
+
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
7
3
|
import type { StudioContextValue } from "../contexts/StudioContext";
|
|
8
|
-
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
9
4
|
|
|
10
5
|
interface StudioContextInput {
|
|
11
6
|
projectId: string;
|
|
@@ -66,10 +61,8 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex
|
|
|
66
61
|
}
|
|
67
62
|
|
|
68
63
|
export interface InspectorState {
|
|
69
|
-
selectedStudioMotion: ReturnType<typeof readStudioMotionFromElement> | null;
|
|
70
64
|
layersPanelActive: boolean;
|
|
71
65
|
designPanelActive: boolean;
|
|
72
|
-
motionPanelActive: boolean;
|
|
73
66
|
inspectorPanelActive: boolean;
|
|
74
67
|
inspectorButtonActive: boolean;
|
|
75
68
|
shouldShowSelectedDomBounds: boolean;
|
|
@@ -79,32 +72,23 @@ export function useInspectorState(
|
|
|
79
72
|
rightPanelTab: string,
|
|
80
73
|
rightCollapsed: boolean,
|
|
81
74
|
isPlaying: boolean,
|
|
82
|
-
domEditSelection: DomEditSelection | null,
|
|
83
75
|
isGestureRecording?: boolean,
|
|
84
76
|
): InspectorState {
|
|
85
77
|
// fallow-ignore-next-line complexity
|
|
86
78
|
return useMemo(() => {
|
|
87
|
-
const selectedStudioMotion =
|
|
88
|
-
STUDIO_INSPECTOR_PANELS_ENABLED && domEditSelection
|
|
89
|
-
? readStudioMotionFromElement(domEditSelection.element)
|
|
90
|
-
: null;
|
|
91
79
|
const layersPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && rightPanelTab === "layers";
|
|
92
80
|
const designPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && rightPanelTab === "design";
|
|
93
|
-
const
|
|
94
|
-
STUDIO_INSPECTOR_PANELS_ENABLED && STUDIO_MOTION_PANEL_ENABLED && rightPanelTab === "motion";
|
|
95
|
-
const inspectorPanelActive = layersPanelActive || designPanelActive || motionPanelActive;
|
|
81
|
+
const inspectorPanelActive = layersPanelActive || designPanelActive;
|
|
96
82
|
return {
|
|
97
|
-
selectedStudioMotion,
|
|
98
83
|
layersPanelActive,
|
|
99
84
|
designPanelActive,
|
|
100
|
-
motionPanelActive,
|
|
101
85
|
inspectorPanelActive,
|
|
102
86
|
inspectorButtonActive:
|
|
103
87
|
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
|
|
104
88
|
shouldShowSelectedDomBounds:
|
|
105
89
|
inspectorPanelActive && !rightCollapsed && !isPlaying && !isGestureRecording,
|
|
106
90
|
};
|
|
107
|
-
}, [rightPanelTab, rightCollapsed, isPlaying,
|
|
91
|
+
}, [rightPanelTab, rightCollapsed, isPlaying, isGestureRecording]);
|
|
108
92
|
}
|
|
109
93
|
|
|
110
94
|
// fallow-ignore-next-line complexity
|
|
@@ -22,12 +22,14 @@ export {
|
|
|
22
22
|
shouldIgnorePlaybackShortcutTarget,
|
|
23
23
|
} from "../lib/playbackShortcuts";
|
|
24
24
|
|
|
25
|
-
import type { PlaybackAdapter,
|
|
25
|
+
import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
|
|
26
26
|
import {
|
|
27
27
|
getAdapterDuration,
|
|
28
28
|
wrapTimeline,
|
|
29
|
-
createStaticSeekPlaybackAdapter,
|
|
30
29
|
getDefaultStaticSeekPlaybackClock,
|
|
30
|
+
releaseStaticSeekCache,
|
|
31
|
+
resolveStaticSeekFallback,
|
|
32
|
+
type StaticSeekCacheEntry,
|
|
31
33
|
} from "../lib/playbackAdapter";
|
|
32
34
|
import {
|
|
33
35
|
readTimelineDurationFromDocument,
|
|
@@ -53,11 +55,8 @@ export function useTimelinePlayer() {
|
|
|
53
55
|
const shuttleSpeedIndexRef = useRef(0);
|
|
54
56
|
const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
|
|
55
57
|
const lastTimelineMessageRef = useRef<number>(0);
|
|
56
|
-
const staticSeekAdapterRef = useRef<
|
|
57
|
-
|
|
58
|
-
duration: number;
|
|
59
|
-
adapter: PlaybackAdapter;
|
|
60
|
-
} | null>(null);
|
|
58
|
+
const staticSeekAdapterRef = useRef<StaticSeekCacheEntry | null>(null);
|
|
59
|
+
const staticSeekWarnedRef = useRef(false);
|
|
61
60
|
|
|
62
61
|
const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } =
|
|
63
62
|
usePlayerStore.getState();
|
|
@@ -141,6 +140,7 @@ export function useTimelinePlayer() {
|
|
|
141
140
|
const adapterDur = getAdapterDuration(playerAdapter);
|
|
142
141
|
|
|
143
142
|
if (adapterDur > 0 && docDuration <= adapterDur) {
|
|
143
|
+
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
|
|
144
144
|
return playerAdapter;
|
|
145
145
|
}
|
|
146
146
|
|
|
@@ -148,24 +148,28 @@ export function useTimelinePlayer() {
|
|
|
148
148
|
if (win.__timeline) {
|
|
149
149
|
const adapter = wrapTimeline(win.__timeline);
|
|
150
150
|
const dur = getAdapterDuration(adapter);
|
|
151
|
-
if (dur > 0 && docDuration <= dur)
|
|
151
|
+
if (dur > 0 && docDuration <= dur) {
|
|
152
|
+
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
|
|
153
|
+
return adapter;
|
|
154
|
+
}
|
|
152
155
|
if (dur > 0) timelineAdapter ??= adapter;
|
|
153
156
|
}
|
|
154
157
|
|
|
155
158
|
if (win.__timelines) {
|
|
156
159
|
const keys = Object.keys(win.__timelines);
|
|
157
160
|
if (keys.length > 0) {
|
|
158
|
-
// Resolve the root composition id from the DOM — the outermost
|
|
159
|
-
//
|
|
160
|
-
// Object.keys() order would let a sub-composition's timeline
|
|
161
|
-
// hijack play/pause/seek and the duration readout.
|
|
161
|
+
// Resolve the root composition id from the DOM — the outermost [data-composition-id]
|
|
162
|
+
// is the master; otherwise Object.keys() order lets a sub-composition hijack transport.
|
|
162
163
|
const rootId = iframe?.contentDocument
|
|
163
164
|
?.querySelector("[data-composition-id]")
|
|
164
165
|
?.getAttribute("data-composition-id");
|
|
165
166
|
const key = rootId && rootId in win.__timelines ? rootId : keys[keys.length - 1];
|
|
166
167
|
const adapter = wrapTimeline(win.__timelines[key]);
|
|
167
168
|
const dur = getAdapterDuration(adapter);
|
|
168
|
-
if (dur > 0 && docDuration <= dur)
|
|
169
|
+
if (dur > 0 && docDuration <= dur) {
|
|
170
|
+
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
|
|
171
|
+
return adapter;
|
|
172
|
+
}
|
|
169
173
|
if (dur > 0) timelineAdapter ??= adapter;
|
|
170
174
|
}
|
|
171
175
|
}
|
|
@@ -184,23 +188,15 @@ export function useTimelinePlayer() {
|
|
|
184
188
|
effectiveDuration > 0 &&
|
|
185
189
|
("renderSeek" in bestAdapter || typeof bestAdapter.seek === "function")
|
|
186
190
|
) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
cached?.adapter.pause();
|
|
192
|
-
const adapter = createStaticSeekPlaybackAdapter(
|
|
191
|
+
return resolveStaticSeekFallback({
|
|
192
|
+
cache: staticSeekAdapterRef,
|
|
193
|
+
warned: staticSeekWarnedRef,
|
|
193
194
|
bestAdapter,
|
|
194
195
|
effectiveDuration,
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
player: bestAdapter,
|
|
200
|
-
duration: effectiveDuration,
|
|
201
|
-
adapter,
|
|
202
|
-
};
|
|
203
|
-
return adapter;
|
|
196
|
+
docDuration,
|
|
197
|
+
clock: getDefaultStaticSeekPlaybackClock(win),
|
|
198
|
+
getPlaybackRate: () => usePlayerStore.getState().playbackRate,
|
|
199
|
+
});
|
|
204
200
|
}
|
|
205
201
|
|
|
206
202
|
return bestAdapter;
|
|
@@ -561,6 +557,7 @@ export function useTimelinePlayer() {
|
|
|
561
557
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
562
558
|
stopRAFLoop();
|
|
563
559
|
stopReverseLoop();
|
|
560
|
+
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
|
|
564
561
|
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
565
562
|
};
|
|
566
563
|
});
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createStaticSeekPlaybackAdapter,
|
|
4
|
+
wrapTimeline,
|
|
5
|
+
resolveStaticSeekFallback,
|
|
6
|
+
releaseStaticSeekCache,
|
|
7
|
+
type StaticSeekCacheEntry,
|
|
8
|
+
} from "./playbackAdapter";
|
|
3
9
|
import type {
|
|
4
10
|
RuntimePlaybackAdapter,
|
|
5
11
|
StaticSeekPlaybackClock,
|
|
@@ -211,3 +217,82 @@ describe("createStaticSeekPlaybackAdapter seek keepPlaying option", () => {
|
|
|
211
217
|
expect(adapter.isPlaying()).toBe(false);
|
|
212
218
|
});
|
|
213
219
|
});
|
|
220
|
+
|
|
221
|
+
describe("static-seek fallback cache (resolveStaticSeekFallback / releaseStaticSeekCache)", () => {
|
|
222
|
+
function makeClock(): StaticSeekPlaybackClock {
|
|
223
|
+
return {
|
|
224
|
+
now: () => 0,
|
|
225
|
+
requestAnimationFrame: () => 0,
|
|
226
|
+
cancelAnimationFrame: () => {},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function makePlayer() {
|
|
231
|
+
return { getTime: () => 0, renderSeek: vi.fn() };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function resolve(
|
|
235
|
+
cache: { current: StaticSeekCacheEntry | null },
|
|
236
|
+
warned: { current: boolean },
|
|
237
|
+
player: ReturnType<typeof makePlayer>,
|
|
238
|
+
duration: number,
|
|
239
|
+
) {
|
|
240
|
+
return resolveStaticSeekFallback({
|
|
241
|
+
cache,
|
|
242
|
+
warned,
|
|
243
|
+
bestAdapter: player as unknown as RuntimePlaybackAdapter,
|
|
244
|
+
effectiveDuration: duration,
|
|
245
|
+
docDuration: duration,
|
|
246
|
+
clock: makeClock(),
|
|
247
|
+
getPlaybackRate: () => 1,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
it("warns once per downgrade streak and re-arms after release", () => {
|
|
252
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
253
|
+
const cache: { current: StaticSeekCacheEntry | null } = { current: null };
|
|
254
|
+
const warned = { current: false };
|
|
255
|
+
const player = makePlayer();
|
|
256
|
+
|
|
257
|
+
resolve(cache, warned, player, 10);
|
|
258
|
+
resolve(cache, warned, player, 11); // cache miss (new duration) — must not warn again
|
|
259
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
260
|
+
|
|
261
|
+
releaseStaticSeekCache(cache, warned);
|
|
262
|
+
resolve(cache, warned, player, 12);
|
|
263
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
264
|
+
warn.mockRestore();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("returns the cached adapter for the same player and duration", () => {
|
|
268
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
269
|
+
const cache: { current: StaticSeekCacheEntry | null } = { current: null };
|
|
270
|
+
const warned = { current: false };
|
|
271
|
+
const player = makePlayer();
|
|
272
|
+
|
|
273
|
+
const first = resolve(cache, warned, player, 10);
|
|
274
|
+
const second = resolve(cache, warned, player, 10);
|
|
275
|
+
expect(second).toBe(first);
|
|
276
|
+
warn.mockRestore();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("pauses the replaced adapter on cache miss and the cached adapter on release", () => {
|
|
280
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
281
|
+
const cache: { current: StaticSeekCacheEntry | null } = { current: null };
|
|
282
|
+
const warned = { current: false };
|
|
283
|
+
const player = makePlayer();
|
|
284
|
+
|
|
285
|
+
const first = resolve(cache, warned, player, 10);
|
|
286
|
+
first.play();
|
|
287
|
+
expect(first.isPlaying()).toBe(true);
|
|
288
|
+
const second = resolve(cache, warned, player, 20);
|
|
289
|
+
expect(first.isPlaying()).toBe(false);
|
|
290
|
+
|
|
291
|
+
second.play();
|
|
292
|
+
expect(second.isPlaying()).toBe(true);
|
|
293
|
+
releaseStaticSeekCache(cache, warned);
|
|
294
|
+
expect(second.isPlaying()).toBe(false);
|
|
295
|
+
expect(cache.current).toBeNull();
|
|
296
|
+
vi.restoreAllMocks();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -134,6 +134,68 @@ export function createStaticSeekPlaybackAdapter(
|
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Static-seek fallback cache
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
export type StaticSeekCacheEntry = {
|
|
142
|
+
player: RuntimePlaybackAdapter | PlaybackAdapter;
|
|
143
|
+
duration: number;
|
|
144
|
+
adapter: PlaybackAdapter;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
type StaticSeekCacheRef = { current: StaticSeekCacheEntry | null };
|
|
148
|
+
type WarnedRef = { current: boolean };
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Pause and drop the cached static-seek adapter. Must be called whenever
|
|
152
|
+
* adapter selection switches to a native adapter — a cached static-seek
|
|
153
|
+
* adapter that was mid-play keeps its private rAF loop seeking the player
|
|
154
|
+
* forever otherwise, fighting the native transport. Also re-arms the
|
|
155
|
+
* downgrade warning so a later re-downgrade is surfaced again.
|
|
156
|
+
*/
|
|
157
|
+
export function releaseStaticSeekCache(cache: StaticSeekCacheRef, warned: WarnedRef): void {
|
|
158
|
+
cache.current?.adapter.pause();
|
|
159
|
+
cache.current = null;
|
|
160
|
+
warned.current = false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolve (with caching) the seek-driven fallback adapter. Warns once per
|
|
165
|
+
* downgrade streak: seek-driven playback never starts media elements or
|
|
166
|
+
* WebAudio, so without the warning the downgrade silently loses audio.
|
|
167
|
+
*/
|
|
168
|
+
export function resolveStaticSeekFallback(opts: {
|
|
169
|
+
cache: StaticSeekCacheRef;
|
|
170
|
+
warned: WarnedRef;
|
|
171
|
+
bestAdapter: RuntimePlaybackAdapter | PlaybackAdapter;
|
|
172
|
+
effectiveDuration: number;
|
|
173
|
+
docDuration: number;
|
|
174
|
+
clock: StaticSeekPlaybackClock;
|
|
175
|
+
getPlaybackRate: () => number;
|
|
176
|
+
}): PlaybackAdapter {
|
|
177
|
+
const { cache, warned, bestAdapter, effectiveDuration, docDuration } = opts;
|
|
178
|
+
const cached = cache.current;
|
|
179
|
+
if (cached?.player === bestAdapter && cached.duration === effectiveDuration) {
|
|
180
|
+
return cached.adapter;
|
|
181
|
+
}
|
|
182
|
+
cached?.adapter.pause();
|
|
183
|
+
if (!warned.current) {
|
|
184
|
+
warned.current = true;
|
|
185
|
+
console.warn(
|
|
186
|
+
`[useTimelinePlayer] Selected adapter duration (${getAdapterDuration(bestAdapter)}s) does not cover the document duration (${docDuration}s); falling back to seek-driven playback, which never starts media elements or WebAudio. Audio will not play in preview — extend the GSAP timeline to cover the declared data-duration.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const adapter = createStaticSeekPlaybackAdapter(
|
|
190
|
+
bestAdapter,
|
|
191
|
+
effectiveDuration,
|
|
192
|
+
opts.clock,
|
|
193
|
+
opts.getPlaybackRate,
|
|
194
|
+
);
|
|
195
|
+
cache.current = { player: bestAdapter, duration: effectiveDuration, adapter };
|
|
196
|
+
return adapter;
|
|
197
|
+
}
|
|
198
|
+
|
|
137
199
|
// ---------------------------------------------------------------------------
|
|
138
200
|
// GSAP timeline wrapper
|
|
139
201
|
// ---------------------------------------------------------------------------
|