@hyperframes/studio 0.6.46 → 0.6.48

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/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-DpbZouXZ.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-B2QGnquo.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-SKRp8mGz.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.46",
3
+ "version": "0.6.48",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,8 +31,8 @@
31
31
  "@codemirror/view": "6.40.0",
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "mediabunny": "^1.45.3",
34
- "@hyperframes/core": "0.6.46",
35
- "@hyperframes/player": "0.6.46"
34
+ "@hyperframes/player": "0.6.48",
35
+ "@hyperframes/core": "0.6.48"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/react": "19",
@@ -46,7 +46,7 @@
46
46
  "vite": "^6.4.2",
47
47
  "vitest": "^3.2.4",
48
48
  "zustand": "^5.0.0",
49
- "@hyperframes/producer": "0.6.46"
49
+ "@hyperframes/producer": "0.6.48"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "19",
@@ -3,7 +3,7 @@
3
3
  import React, { act, createRef } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
- import { NLEPreview, getPreviewPlayerKey } from "./NLEPreview";
6
+ import { NLEPreview, getPreviewPlayerKey, resolvePreviewStageSize } from "./NLEPreview";
7
7
 
8
8
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
9
9
 
@@ -133,6 +133,22 @@ describe("getPreviewPlayerKey", () => {
133
133
  });
134
134
  });
135
135
 
136
+ describe("resolvePreviewStageSize", () => {
137
+ it("fits portrait composition dimensions by height in a narrow viewport", () => {
138
+ expect(resolvePreviewStageSize(512, 402, { width: 1080, height: 1920 }, undefined)).toEqual({
139
+ width: 217.125,
140
+ height: 386,
141
+ });
142
+ });
143
+
144
+ it("uses composition dimensions ahead of the legacy portrait fallback", () => {
145
+ expect(resolvePreviewStageSize(512, 402, { width: 1920, height: 1080 }, true)).toEqual({
146
+ width: 496,
147
+ height: 279,
148
+ });
149
+ });
150
+ });
151
+
136
152
  describe("NLEPreview", () => {
137
153
  beforeEach(() => {
138
154
  globalThis.ResizeObserver = MockResizeObserver as typeof ResizeObserver;
@@ -1,4 +1,4 @@
1
- import { memo, useCallback, useEffect, useRef, useState, type Ref } from "react";
1
+ import { memo, useCallback, useEffect, useRef, useState, type RefObject } from "react";
2
2
  import { Player } from "../../player";
3
3
  import {
4
4
  DEFAULT_PREVIEW_ZOOM,
@@ -14,7 +14,7 @@ import {
14
14
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
15
15
  interface NLEPreviewProps {
16
16
  projectId: string;
17
- iframeRef: Ref<HTMLIFrameElement>;
17
+ iframeRef: RefObject<HTMLIFrameElement | null>;
18
18
  onIframeLoad: () => void;
19
19
  onCompositionLoadingChange?: (loading: boolean) => void;
20
20
  portrait?: boolean;
@@ -37,6 +37,11 @@ const ZOOM_HUD_TIMEOUT_MS = 1200;
37
37
  const ZOOM_SETTLE_MS = 200;
38
38
  const PREVIEW_STAGE_INSET_PX = 16;
39
39
 
40
+ interface PreviewCompositionSize {
41
+ width: number;
42
+ height: number;
43
+ }
44
+
40
45
  function isPreviewAtFit(state: PreviewZoomState): boolean {
41
46
  return (
42
47
  Math.abs(state.zoomPercent - 100) < 0.5 &&
@@ -56,14 +61,41 @@ function loadInitialZoom(): PreviewZoomState {
56
61
  : DEFAULT_PREVIEW_ZOOM;
57
62
  }
58
63
 
59
- function resolvePreviewStageSize(
64
+ // fallow-ignore-next-line complexity
65
+ function readPreviewCompositionSize(
66
+ iframe: HTMLIFrameElement | null,
67
+ ): PreviewCompositionSize | null {
68
+ try {
69
+ const doc = iframe?.contentDocument;
70
+ const root =
71
+ doc?.querySelector("[data-composition-id][data-width][data-height]") ??
72
+ doc?.querySelector("[data-width][data-height]");
73
+ if (!root) return null;
74
+ const width = Number.parseInt(root.getAttribute("data-width") ?? "", 10);
75
+ const height = Number.parseInt(root.getAttribute("data-height") ?? "", 10);
76
+ if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
77
+ return null;
78
+ }
79
+ return { width, height };
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ export function resolvePreviewStageSize(
60
86
  viewportWidth: number,
61
87
  viewportHeight: number,
88
+ compositionSize: PreviewCompositionSize | null,
62
89
  portrait: boolean | undefined,
63
90
  ): { width: number; height: number } {
64
91
  const availableWidth = Math.max(0, viewportWidth - PREVIEW_STAGE_INSET_PX);
65
92
  const availableHeight = Math.max(0, viewportHeight - PREVIEW_STAGE_INSET_PX);
66
- const aspectRatio = portrait ? 9 / 16 : 16 / 9;
93
+ const aspectRatio =
94
+ compositionSize && compositionSize.width > 0 && compositionSize.height > 0
95
+ ? compositionSize.width / compositionSize.height
96
+ : portrait
97
+ ? 9 / 16
98
+ : 16 / 9;
67
99
 
68
100
  if (availableWidth === 0 || availableHeight === 0) {
69
101
  return { width: 0, height: 0 };
@@ -95,10 +127,12 @@ export const NLEPreview = memo(function NLEPreview({
95
127
  const activeKey = getPreviewPlayerKey({ projectId, directUrl });
96
128
  const viewportRef = useRef<HTMLDivElement>(null);
97
129
  const stageRef = useRef<HTMLDivElement>(null);
130
+ const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
98
131
  useEffect(() => {
99
132
  onStageRef?.(stageRef);
100
133
  }, [onStageRef]);
101
- const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, portrait));
134
+ const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(null);
135
+ const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
102
136
 
103
137
  const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
104
138
  const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(() => zoomRef.current);
@@ -127,14 +161,29 @@ export const NLEPreview = memo(function NLEPreview({
127
161
 
128
162
  const updateStageSize = () => {
129
163
  const rect = viewport.getBoundingClientRect();
130
- setStageSize(resolvePreviewStageSize(rect.width, rect.height, portrait));
164
+ setStageSize(resolvePreviewStageSize(rect.width, rect.height, compositionSize, portrait));
131
165
  };
132
166
 
133
167
  updateStageSize();
134
168
  const observer = new ResizeObserver(updateStageSize);
135
169
  observer.observe(viewport);
136
170
  return () => observer.disconnect();
137
- }, [portrait]);
171
+ }, [compositionSize, portrait]);
172
+
173
+ const updateCompositionSizeFromPreview = useCallback(() => {
174
+ const next = readPreviewCompositionSize(previewIframeRef.current);
175
+ setCompositionSize((prev) =>
176
+ prev?.width === next?.width && prev?.height === next?.height ? prev : next,
177
+ );
178
+ }, []);
179
+
180
+ const setPreviewIframeRef = useCallback(
181
+ (node: HTMLIFrameElement | null) => {
182
+ previewIframeRef.current = node;
183
+ iframeRef.current = node;
184
+ },
185
+ [iframeRef],
186
+ );
138
187
 
139
188
  const stageSizeRef = useRef(stageSize);
140
189
  stageSizeRef.current = stageSize;
@@ -403,10 +452,11 @@ export const NLEPreview = memo(function NLEPreview({
403
452
  )}
404
453
  <Player
405
454
  key={activeKey}
406
- ref={iframeRef}
455
+ ref={setPreviewIframeRef}
407
456
  projectId={directUrl ? undefined : projectId}
408
457
  directUrl={directUrl}
409
458
  onLoad={() => {
459
+ updateCompositionSizeFromPreview();
410
460
  onIframeLoad();
411
461
  applyInitialZoom();
412
462
  }}
@@ -25,6 +25,20 @@ function TimelinePlayerHarness({
25
25
  return null;
26
26
  }
27
27
 
28
+ function renderTimelinePlayerHarness() {
29
+ let api: ReturnType<typeof useTimelinePlayer> | null = null;
30
+ const host = document.createElement("div");
31
+ document.body.append(host);
32
+ const root = createRoot(host);
33
+
34
+ act(() => {
35
+ root.render(React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }));
36
+ });
37
+
38
+ if (!api) throw new Error("useTimelinePlayer did not mount");
39
+ return { api, root };
40
+ }
41
+
28
42
  afterEach(() => {
29
43
  document.body.innerHTML = "";
30
44
  resetPlayerStore();
@@ -35,19 +49,25 @@ function attachIframeAdapter(
35
49
  options: {
36
50
  postMessage?: (message: unknown, targetOrigin: string) => void;
37
51
  timelines?: Record<string, unknown>;
52
+ duration?: number;
38
53
  } = {},
39
54
  ) {
40
55
  const iframe = document.createElement("iframe");
41
56
  let currentTime = 0;
57
+ let playing = false;
42
58
  const adapter = {
43
- play: () => {},
44
- pause: () => {},
59
+ play: vi.fn(() => {
60
+ playing = true;
61
+ }),
62
+ pause: vi.fn(() => {
63
+ playing = false;
64
+ }),
45
65
  seek: (time: number) => {
46
66
  currentTime = time;
47
67
  },
48
68
  getTime: () => currentTime,
49
- getDuration: () => 30,
50
- isPlaying: () => false,
69
+ getDuration: () => options.duration ?? 30,
70
+ isPlaying: () => playing,
51
71
  };
52
72
  Object.defineProperty(iframe, "contentWindow", {
53
73
  value: {
@@ -71,90 +91,77 @@ function attachIframeAdapter(
71
91
  return adapter;
72
92
  }
73
93
 
94
+ function renderAttachedTimelinePlayer() {
95
+ const { api, root } = renderTimelinePlayerHarness();
96
+ const adapter = attachIframeAdapter(api);
97
+ return { api, root, adapter };
98
+ }
99
+
100
+ function setStorePlaying() {
101
+ act(() => {
102
+ usePlayerStore.setState({ isPlaying: true });
103
+ });
104
+ }
105
+
106
+ function seekWithAct(
107
+ api: ReturnType<typeof useTimelinePlayer>,
108
+ time: number,
109
+ options?: { keepPlaying?: boolean },
110
+ ) {
111
+ act(() => {
112
+ api.seek(time, options);
113
+ });
114
+ }
115
+
116
+ function unmountWithAct(root: ReturnType<typeof createRoot>) {
117
+ act(() => {
118
+ root.unmount();
119
+ });
120
+ }
121
+
122
+ function expectStorePlaybackState(
123
+ root: ReturnType<typeof createRoot>,
124
+ expected: { isPlaying: boolean; currentTime: number },
125
+ ) {
126
+ expect(usePlayerStore.getState().isPlaying).toBe(expected.isPlaying);
127
+ expect(usePlayerStore.getState().currentTime).toBe(expected.currentTime);
128
+ unmountWithAct(root);
129
+ }
130
+
74
131
  describe("useTimelinePlayer seek hydration", () => {
75
132
  it("keeps an external seek request until the iframe adapter is ready", () => {
76
- let api: ReturnType<typeof useTimelinePlayer> | null = null;
77
133
  const observedTimes: number[] = [];
78
134
  const unsubscribe = liveTime.subscribe((time) => {
79
135
  observedTimes.push(time);
80
136
  });
81
- const host = document.createElement("div");
82
- document.body.append(host);
83
- const root = createRoot(host);
84
-
85
- act(() => {
86
- root.render(
87
- React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
88
- );
89
- });
137
+ const { api, root } = renderTimelinePlayerHarness();
90
138
 
91
139
  act(() => {
92
140
  usePlayerStore.getState().requestSeek(4.2);
93
141
  });
94
142
 
95
- expect(api).not.toBeNull();
96
143
  expect(usePlayerStore.getState().currentTime).toBe(0);
97
144
  expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
98
145
 
99
- const iframe = document.createElement("iframe");
100
- let currentTime = 0;
101
- const adapter = {
102
- play: () => {},
103
- pause: () => {},
104
- seek: (time: number) => {
105
- currentTime = time;
106
- },
107
- getTime: () => currentTime,
108
- getDuration: () => 30,
109
- isPlaying: () => false,
110
- };
111
- Object.defineProperty(iframe, "contentWindow", {
112
- value: {
113
- __player: adapter,
114
- postMessage: () => {},
115
- scrollTo: () => {},
116
- addEventListener: () => {},
117
- removeEventListener: () => {},
118
- },
119
- configurable: true,
120
- });
121
- Object.defineProperty(iframe, "contentDocument", {
122
- value: document.implementation.createHTMLDocument("preview"),
123
- configurable: true,
124
- });
125
-
126
- act(() => {
127
- api!.iframeRef.current = iframe;
128
- api!.onIframeLoad();
129
- });
146
+ const adapter = attachIframeAdapter(api);
130
147
 
131
- expect(currentTime).toBe(4.2);
148
+ expect(adapter.getTime()).toBe(4.2);
132
149
  expect(usePlayerStore.getState().currentTime).toBe(4.2);
133
150
  expect(usePlayerStore.getState().timelineReady).toBe(true);
134
151
  expect(observedTimes).toContain(4.2);
135
152
 
136
- act(() => {
137
- root.unmount();
138
- });
153
+ unmountWithAct(root);
139
154
  unsubscribe();
140
155
  });
141
156
  });
142
157
 
143
158
  describe("useTimelinePlayer audio controls (#835)", () => {
144
159
  it("applies playback-rate changes immediately and auto-mutes audio above 1x", () => {
145
- let api: ReturnType<typeof useTimelinePlayer> | null = null;
146
- const host = document.createElement("div");
147
- document.body.append(host);
148
- const root = createRoot(host);
160
+ const { api, root } = renderTimelinePlayerHarness();
149
161
  const postMessage = vi.fn();
150
162
  const timeScale = vi.fn();
151
163
 
152
- act(() => {
153
- root.render(
154
- React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
155
- );
156
- });
157
- attachIframeAdapter(api!, {
164
+ attachIframeAdapter(api, {
158
165
  postMessage,
159
166
  timelines: {
160
167
  root: { timeScale },
@@ -202,24 +209,14 @@ describe("useTimelinePlayer audio controls (#835)", () => {
202
209
  "*",
203
210
  );
204
211
 
205
- act(() => {
206
- root.unmount();
207
- });
212
+ unmountWithAct(root);
208
213
  });
209
214
 
210
215
  it("keeps explicit Studio mute active at 1x", () => {
211
- let api: ReturnType<typeof useTimelinePlayer> | null = null;
212
- const host = document.createElement("div");
213
- document.body.append(host);
214
- const root = createRoot(host);
216
+ const { api, root } = renderTimelinePlayerHarness();
215
217
  const postMessage = vi.fn();
216
218
 
217
- act(() => {
218
- root.render(
219
- React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
220
- );
221
- });
222
- attachIframeAdapter(api!, { postMessage });
219
+ attachIframeAdapter(api, { postMessage });
223
220
  postMessage.mockClear();
224
221
 
225
222
  act(() => {
@@ -235,95 +232,50 @@ describe("useTimelinePlayer audio controls (#835)", () => {
235
232
  "*",
236
233
  );
237
234
 
238
- act(() => {
239
- root.unmount();
240
- });
235
+ unmountWithAct(root);
241
236
  });
242
237
  });
243
238
 
244
239
  describe("useTimelinePlayer seek keepPlaying option (#834)", () => {
245
240
  it("default seek() clears isPlaying when the store reports playing", () => {
246
- let api: ReturnType<typeof useTimelinePlayer> | null = null;
247
- const host = document.createElement("div");
248
- document.body.append(host);
249
- const root = createRoot(host);
250
-
251
- act(() => {
252
- root.render(
253
- React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
254
- );
255
- });
256
- attachIframeAdapter(api!);
257
-
258
- act(() => {
259
- usePlayerStore.setState({ isPlaying: true });
260
- });
241
+ const { api, root } = renderAttachedTimelinePlayer();
242
+ setStorePlaying();
261
243
 
262
- act(() => {
263
- api!.seek(5);
264
- });
265
-
266
- expect(usePlayerStore.getState().isPlaying).toBe(false);
267
- expect(usePlayerStore.getState().currentTime).toBe(5);
244
+ seekWithAct(api, 5);
268
245
 
269
- act(() => {
270
- root.unmount();
271
- });
246
+ expectStorePlaybackState(root, { isPlaying: false, currentTime: 5 });
272
247
  });
273
248
 
274
249
  it("seek(time, { keepPlaying: true }) preserves isPlaying=true so A/E shortcuts don't pause the timeline", () => {
275
- let api: ReturnType<typeof useTimelinePlayer> | null = null;
276
- const host = document.createElement("div");
277
- document.body.append(host);
278
- const root = createRoot(host);
279
-
280
- act(() => {
281
- root.render(
282
- React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
283
- );
284
- });
285
- attachIframeAdapter(api!);
250
+ const { api, root, adapter } = renderAttachedTimelinePlayer();
251
+ setStorePlaying();
286
252
 
287
- act(() => {
288
- usePlayerStore.setState({ isPlaying: true });
289
- });
290
-
291
- act(() => {
292
- api!.seek(5, { keepPlaying: true });
293
- });
253
+ seekWithAct(api, 5, { keepPlaying: true });
294
254
 
295
- expect(usePlayerStore.getState().isPlaying).toBe(true);
296
- expect(usePlayerStore.getState().currentTime).toBe(5);
297
-
298
- act(() => {
299
- root.unmount();
300
- });
255
+ expect(adapter.play).toHaveBeenCalledTimes(1);
256
+ expectStorePlaybackState(root, { isPlaying: true, currentTime: 5 });
301
257
  });
302
258
 
303
259
  it("seek(time, { keepPlaying: true }) from paused state stays paused (no spurious resume)", () => {
304
- let api: ReturnType<typeof useTimelinePlayer> | null = null;
305
- const host = document.createElement("div");
306
- document.body.append(host);
307
- const root = createRoot(host);
308
-
309
- act(() => {
310
- root.render(
311
- React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
312
- );
313
- });
314
- attachIframeAdapter(api!);
260
+ const { api, root } = renderAttachedTimelinePlayer();
315
261
 
316
262
  expect(usePlayerStore.getState().isPlaying).toBe(false);
317
263
 
318
- act(() => {
319
- api!.seek(5, { keepPlaying: true });
320
- });
264
+ seekWithAct(api, 5, { keepPlaying: true });
321
265
 
322
- expect(usePlayerStore.getState().isPlaying).toBe(false);
323
- expect(usePlayerStore.getState().currentTime).toBe(5);
266
+ expectStorePlaybackState(root, { isPlaying: false, currentTime: 5 });
267
+ });
324
268
 
325
- act(() => {
326
- root.unmount();
327
- });
269
+ it("seek(time, { keepPlaying: true }) restarts playback when the iframe adapter was paused", () => {
270
+ const { api, root, adapter } = renderAttachedTimelinePlayer();
271
+ setStorePlaying();
272
+
273
+ expect(adapter.isPlaying()).toBe(false);
274
+
275
+ seekWithAct(api, 0, { keepPlaying: true });
276
+
277
+ expect(adapter.play).toHaveBeenCalledTimes(1);
278
+ expect(adapter.isPlaying()).toBe(true);
279
+ expectStorePlaybackState(root, { isPlaying: true, currentTime: 0 });
328
280
  });
329
281
  });
@@ -4,19 +4,16 @@ import { useMountEffect } from "../../hooks/useMountEffect";
4
4
  import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
5
5
  import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
6
6
 
7
- // Re-export public API consumed by tests and external modules.
8
- // All of these were previously defined in this file; they now live in focused
9
- // sub-modules but are re-exported here so existing import sites don't change.
10
7
  export type { ClipManifestClip } from "../lib/playbackTypes";
11
8
  export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
12
9
  export {
13
- getTimelineElementSelector,
14
- readTimelineDurationFromDocument,
15
- parseTimelineFromDOM,
10
+ buildStandaloneRootTimelineElement,
16
11
  createTimelineElementFromManifestClip,
17
12
  findTimelineDomNodeForClip,
18
- buildStandaloneRootTimelineElement,
13
+ getTimelineElementSelector,
19
14
  mergeTimelineElementsPreservingDowngrades,
15
+ parseTimelineFromDOM,
16
+ readTimelineDurationFromDocument,
20
17
  resolveStandaloneRootCompositionSrc,
21
18
  resolveIframe,
22
19
  } from "../lib/timelineDOM";
@@ -43,10 +40,7 @@ import {
43
40
  shouldMutePreviewAudio,
44
41
  } from "../lib/timelineIframeHelpers";
45
42
  import { probeMediaUrl, getCachedProbe } from "../lib/mediaProbe";
46
-
47
- // ---------------------------------------------------------------------------
48
- // Hook
49
- // ---------------------------------------------------------------------------
43
+ import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
50
44
 
51
45
  export function useTimelinePlayer() {
52
46
  const iframeRef = useRef<HTMLIFrameElement | null>(null);
@@ -65,8 +59,6 @@ export function useTimelinePlayer() {
65
59
  adapter: PlaybackAdapter;
66
60
  } | null>(null);
67
61
 
68
- // ZERO store subscriptions — this hook never causes re-renders.
69
- // All reads use getState() (point-in-time), all writes use the stable setters.
70
62
  const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } =
71
63
  usePlayerStore.getState();
72
64
 
@@ -383,8 +375,6 @@ export function useTimelinePlayer() {
383
375
  }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
384
376
  const seek = useCallback(
385
377
  (time: number, options?: { keepPlaying?: boolean }) => {
386
- // Reverse shuttle is always stopped: the RAF reverse tick can't survive
387
- // a seek anyway, so `keepPlaying` only preserves forward playback.
388
378
  const wasReverseShuttle = shuttleDirectionRef.current === "backward";
389
379
  stopReverseLoop();
390
380
  const adapter = getAdapter();
@@ -394,10 +384,27 @@ export function useTimelinePlayer() {
394
384
  }
395
385
  const duration = Math.max(0, adapter.getDuration());
396
386
  const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
387
+ const keepPlaying = options?.keepPlaying === true;
388
+ const shouldResumeAfterSeek = shouldResumeForwardPlaybackAfterSeek({
389
+ keepPlaying,
390
+ wasReverseShuttle,
391
+ storeWasPlaying: usePlayerStore.getState().isPlaying,
392
+ duration,
393
+ nextTime,
394
+ });
397
395
  adapter.seek(nextTime, options);
398
396
  liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
399
397
  setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
400
- if (!options?.keepPlaying || wasReverseShuttle) {
398
+ if (shouldResumeAfterSeek) {
399
+ stopRAFLoop();
400
+ applyPlaybackRate(usePlayerStore.getState().playbackRate);
401
+ applyPreviewAudioState();
402
+ adapter.play();
403
+ setIsPlaying(true);
404
+ shuttleDirectionRef.current = "forward";
405
+ shuttleSpeedIndexRef.current = 0;
406
+ startRAFLoop();
407
+ } else if (shouldStopAfterSeek({ keepPlaying, wasReverseShuttle })) {
401
408
  stopRAFLoop();
402
409
  if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
403
410
  shuttleDirectionRef.current = null;
@@ -410,14 +417,16 @@ export function useTimelinePlayer() {
410
417
  pendingSeekRef,
411
418
  setCurrentTime,
412
419
  setIsPlaying,
420
+ startRAFLoop,
413
421
  stopRAFLoop,
414
422
  stopReverseLoop,
423
+ applyPlaybackRate,
424
+ applyPreviewAudioState,
415
425
  shuttleDirectionRef,
416
426
  shuttleSpeedIndexRef,
417
427
  ],
418
428
  );
419
429
 
420
- // Handle seek requests from outside the player loop (e.g. LayersPanel).
421
430
  useEffect(() => {
422
431
  return usePlayerStore.subscribe((state, prev) => {
423
432
  if (state.requestedSeekTime !== null && state.requestedSeekTime !== prev.requestedSeekTime) {
@@ -480,12 +489,8 @@ export function useTimelinePlayer() {
480
489
  const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
481
490
  const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
482
491
 
483
- // Listen for timeline messages from the iframe runtime.
484
- // The runtime sends this AFTER all external compositions load,
485
- // so we get the complete clip list (not just the first few).
486
492
  const handleMessage = (e: MessageEvent) => {
487
493
  const data = e.data;
488
- // Only process messages from the main preview iframe — ignore MediaPanel/ClipThumbnail iframes
489
494
  const ourIframe = iframeRef.current;
490
495
  if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
491
496
  return;
@@ -499,10 +504,6 @@ export function useTimelinePlayer() {
499
504
  processTimelineMessageRef.current(manifest);
500
505
  }
501
506
  }
502
- // Enrich only when the timeline has settled — skip during the window
503
- // right after a "timeline" message to avoid the enrichment adding
504
- // elements that fight with the manifest's authoritative element list,
505
- // causing duration oscillation.
506
507
  const msSinceTimeline = Date.now() - lastTimelineMessageRef.current;
507
508
  if (msSinceTimeline > 500) {
508
509
  enrichMissingCompositionsRef.current();
@@ -535,7 +536,6 @@ export function useTimelinePlayer() {
535
536
  }
536
537
  };
537
538
 
538
- // Pause video when tab loses focus
539
539
  const handleVisibilityChange = () => {
540
540
  if (document.hidden && usePlayerStore.getState().isPlaying) {
541
541
  const adapter = getAdapterRef.current?.();
@@ -564,7 +564,6 @@ export function useTimelinePlayer() {
564
564
  };
565
565
  });
566
566
 
567
- /** Reset the player store (elements, duration, etc.) — call when switching sessions. */
568
567
  const resetPlayer = useCallback(() => {
569
568
  stopRAFLoop();
570
569
  stopReverseLoop();
@@ -0,0 +1,21 @@
1
+ export function shouldResumeForwardPlaybackAfterSeek(input: {
2
+ keepPlaying: boolean;
3
+ wasReverseShuttle: boolean;
4
+ storeWasPlaying: boolean;
5
+ duration: number;
6
+ nextTime: number;
7
+ }): boolean {
8
+ return (
9
+ input.keepPlaying &&
10
+ !input.wasReverseShuttle &&
11
+ input.storeWasPlaying &&
12
+ (input.duration <= 0 || input.nextTime < input.duration)
13
+ );
14
+ }
15
+
16
+ export function shouldStopAfterSeek(input: {
17
+ keepPlaying: boolean;
18
+ wasReverseShuttle: boolean;
19
+ }): boolean {
20
+ return !input.keepPlaying || input.wasReverseShuttle;
21
+ }