@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.
Files changed (50) hide show
  1. package/dist/assets/hyperframes-player-Daj5djxa.js +418 -0
  2. package/dist/assets/index-B0twsRu0.css +1 -0
  3. package/dist/assets/index-Cfye9xzo.js +251 -0
  4. package/dist/assets/{index-CAANLw9Q.js → index-HveJ0MuV.js} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +4 -4
  7. package/src/App.tsx +10 -5
  8. package/src/components/SaveQueuePausedBanner.tsx +23 -0
  9. package/src/components/StudioPreviewArea.tsx +7 -0
  10. package/src/components/StudioRightPanel.tsx +1 -38
  11. package/src/components/editor/DomEditOverlay.test.ts +169 -29
  12. package/src/components/editor/DomEditOverlay.tsx +13 -23
  13. package/src/components/editor/GestureRecordControl.tsx +98 -0
  14. package/src/components/editor/PropertyPanel.tsx +22 -38
  15. package/src/components/editor/domEditing.test.ts +84 -0
  16. package/src/components/editor/domEditingLayers.ts +19 -0
  17. package/src/components/editor/domEditingRootLayer.ts +64 -0
  18. package/src/components/editor/manualEditingAvailability.test.ts +1 -2
  19. package/src/components/editor/manualEditingAvailability.ts +0 -7
  20. package/src/contexts/DomEditContext.tsx +1 -6
  21. package/src/hooks/gsapScriptCommitHelpers.ts +128 -0
  22. package/src/hooks/useDomEditCommits.ts +97 -123
  23. package/src/hooks/useDomEditPositionPatchCommit.ts +53 -0
  24. package/src/hooks/useDomEditSession.ts +59 -65
  25. package/src/hooks/useFileManager.ts +19 -5
  26. package/src/hooks/useGsapAnimationFetchFallback.ts +19 -0
  27. package/src/hooks/useGsapInteractionFailureTelemetry.ts +25 -0
  28. package/src/hooks/useGsapScriptCommits.ts +152 -140
  29. package/src/hooks/useGsapSelectionHandlers.ts +38 -8
  30. package/src/hooks/usePreviewPersistence.ts +90 -51
  31. package/src/hooks/useSafeGsapCommitMutation.ts +66 -0
  32. package/src/hooks/useStudioContextValue.ts +3 -19
  33. package/src/player/hooks/useTimelinePlayer.ts +25 -28
  34. package/src/player/lib/playbackAdapter.test.ts +86 -1
  35. package/src/player/lib/playbackAdapter.ts +62 -0
  36. package/src/utils/domEditSaveQueue.test.ts +117 -0
  37. package/src/utils/domEditSaveQueue.ts +87 -0
  38. package/src/utils/studioHelpers.ts +1 -1
  39. package/src/utils/studioSaveDiagnostics.test.ts +127 -0
  40. package/src/utils/studioSaveDiagnostics.ts +200 -0
  41. package/src/utils/studioUrlState.test.ts +0 -1
  42. package/src/utils/studioUrlState.ts +2 -8
  43. package/dist/assets/hyperframes-player-0esDKGRk.js +0 -418
  44. package/dist/assets/index-DujOjou6.js +0 -251
  45. package/dist/assets/index-rm9tn9nH.css +0 -1
  46. package/src/components/editor/EaseCurveEditor.tsx +0 -221
  47. package/src/components/editor/MotionPanel.tsx +0 -277
  48. package/src/components/editor/MotionPanelFields.tsx +0 -185
  49. package/src/components/editor/MotionPathOverlay.tsx +0 -146
  50. 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: _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 domEditSaveQueueRef = useRef(Promise.resolve());
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
- const queuedSave = domEditSaveQueueRef.current.catch(() => undefined).then(save);
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.catch(() => undefined);
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
- let doc: Document | null = null;
89
- try {
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
- const changedPath = readStudioFileChangePath(payload);
169
- if (!changedPath) return;
170
- const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 4000;
171
- if (pendingTimelineEditPathRef?.current.has(changedPath)) {
172
- pendingTimelineEditPathRef.current.delete(changedPath);
173
- return;
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 motionPanelActive =
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, domEditSelection, isGestureRecording]);
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, RuntimePlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
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
- player: RuntimePlaybackAdapter | PlaybackAdapter;
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) return adapter;
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
- // `[data-composition-id]` element is the master. Without this,
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) return adapter;
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
- const cached = staticSeekAdapterRef.current;
188
- if (cached?.player === bestAdapter && cached.duration === effectiveDuration) {
189
- return cached.adapter;
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
- getDefaultStaticSeekPlaybackClock(win),
196
- () => usePlayerStore.getState().playbackRate,
197
- );
198
- staticSeekAdapterRef.current = {
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 { createStaticSeekPlaybackAdapter, wrapTimeline } from "./playbackAdapter";
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
  // ---------------------------------------------------------------------------