@hyperframes/studio 0.8.74 → 0.8.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/assets/hyperframes-player-BkDxgSKG.js +464 -0
  2. package/dist/assets/{index-vccPjvCl.js → index-COX0scwJ.js} +1 -1
  3. package/dist/assets/{index-DhTyI9zH.js → index-DeHir1up.js} +178 -178
  4. package/dist/assets/{index-B86QTmD2.js → index-FmKUmYhC.js} +1 -1
  5. package/dist/{chunk-7BP32G62.js → chunk-IYDE66BY.js} +6 -1
  6. package/dist/chunk-IYDE66BY.js.map +1 -0
  7. package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-2XBFXXSW.js} +4 -2
  8. package/dist/index.d.ts +35 -70
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +4518 -4568
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/components/StudioRightPanels.types.ts +0 -2
  14. package/src/components/editor/DomEditOverlay.test.ts +5 -0
  15. package/src/components/editor/LayersPanel.tsx +3 -14
  16. package/src/components/editor/PreviewOverlayProvider.test.tsx +6 -1
  17. package/src/components/editor/domEditing.ts +1 -0
  18. package/src/components/editor/domEditingLayers.test.ts +27 -0
  19. package/src/components/editor/domEditingLayers.ts +9 -0
  20. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +57 -35
  21. package/src/components/editor/overlayFrameLoop.test.ts +16 -0
  22. package/src/components/editor/overlayFrameLoop.ts +11 -0
  23. package/src/components/editor/usePreviewCompositionRect.test.tsx +6 -1
  24. package/src/components/nle/NLEPreview.test.ts +94 -17
  25. package/src/components/nle/NLEPreview.tsx +14 -0
  26. package/src/components/nle/PreviewPoster.tsx +99 -0
  27. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +30 -49
  28. package/src/components/panels/VariablesPanel.tsx +0 -2
  29. package/src/components/sidebar/CompositionsTab.drag.test.tsx +5 -1
  30. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +14 -0
  31. package/src/components/sidebar/CompositionsTab.test.ts +4 -6
  32. package/src/components/sidebar/CompositionsTab.tsx +28 -21
  33. package/src/components/studioColorGradingScope.ts +0 -3
  34. package/src/hooks/domEditDeleteMembers.ts +0 -2
  35. package/src/hooks/gsapScriptCommitTypes.ts +0 -2
  36. package/src/hooks/keyframeCacheAstLoad.test.ts +31 -1
  37. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  38. package/src/hooks/timelineAudioGroupCreate.ts +0 -1
  39. package/src/hooks/timelineEditingHelpers.ts +11 -3
  40. package/src/hooks/timelineTimingSync.ts +0 -1
  41. package/src/hooks/timelineTrackVisibility.ts +0 -1
  42. package/src/hooks/useBlockHandlers.ts +0 -2
  43. package/src/hooks/useClipboard.test.ts +69 -10
  44. package/src/hooks/useClipboard.ts +28 -19
  45. package/src/hooks/useDomEditCommits.test.tsx +0 -1
  46. package/src/hooks/useDomEditCommits.ts +0 -4
  47. package/src/hooks/useEditHistoryActions.test.tsx +31 -14
  48. package/src/hooks/useEditHistoryActions.ts +12 -7
  49. package/src/hooks/useEditorSave.ts +0 -3
  50. package/src/hooks/useElementLifecycleOps.ts +0 -1
  51. package/src/hooks/useEnableKeyframes.test.ts +9 -1
  52. package/src/hooks/useFileManager.ts +0 -2
  53. package/src/hooks/useGroupCommits.ts +0 -1
  54. package/src/hooks/useGsapScriptCommits.ts +0 -1
  55. package/src/hooks/useLintModal.test.tsx +34 -1
  56. package/src/hooks/useLintModal.ts +32 -30
  57. package/src/hooks/usePersistentEditHistory.test.ts +221 -315
  58. package/src/hooks/usePersistentEditHistory.ts +155 -342
  59. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +2 -0
  60. package/src/hooks/usePreviewPersistence.ts +0 -2
  61. package/src/hooks/useProjectCompositionVariables.ts +1 -3
  62. package/src/hooks/useRazorSplit.history.test.tsx +46 -67
  63. package/src/hooks/useRazorSplit.test.ts +17 -47
  64. package/src/hooks/useRenderClipContent.test.ts +29 -0
  65. package/src/hooks/useRenderClipContent.ts +6 -3
  66. package/src/hooks/useSdkSession.lifecycle.test.tsx +34 -4
  67. package/src/hooks/useSdkSession.ts +8 -2
  68. package/src/hooks/useSlideshowPersist.ts +0 -2
  69. package/src/hooks/useStudioContextValue.ts +6 -1
  70. package/src/hooks/useTimelineAssetDropOps.ts +0 -1
  71. package/src/hooks/useTimelineDeleteOps.test.tsx +85 -2
  72. package/src/hooks/useTimelineDeleteOps.ts +8 -8
  73. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  74. package/src/hooks/useTimelineEditingTypes.ts +0 -2
  75. package/src/hostMount.test.tsx +53 -22
  76. package/src/main.tsx +3 -0
  77. package/src/player/components/CompositionThumbnail.tsx +14 -0
  78. package/src/player/components/Player.test.ts +49 -0
  79. package/src/player/components/Player.tsx +21 -7
  80. package/src/player/components/PlayheadIndicator.tsx +4 -1
  81. package/src/player/components/Timeline.test.ts +1 -3
  82. package/src/player/components/Timeline.virtualization.test.tsx +21 -0
  83. package/src/player/components/TimelineCanvas.tsx +14 -12
  84. package/src/player/components/TimelinePlayheadLayer.tsx +33 -0
  85. package/src/player/components/TimelineRuler.tsx +7 -40
  86. package/src/player/components/timelineClipDragCommit.test.ts +7 -39
  87. package/src/player/components/useSettledScrollLeft.ts +38 -0
  88. package/src/player/hooks/timelineDomBinding.test.ts +50 -0
  89. package/src/player/hooks/timelineSyncHydration.test.ts +25 -1
  90. package/src/player/hooks/timelineSyncHydration.ts +3 -3
  91. package/src/player/hooks/useShadowPreviewReload.ts +20 -1
  92. package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
  93. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +85 -0
  94. package/src/player/hooks/useTimelinePlayer.ts +15 -3
  95. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  96. package/src/player/lib/timelineElementHelpers.ts +45 -9
  97. package/src/player/store/playerStore.test.ts +35 -1
  98. package/src/player/store/playerStore.ts +29 -0
  99. package/src/player/store/readinessSlice.ts +34 -6
  100. package/src/utils/blockInstaller.ts +0 -3
  101. package/src/utils/previewPrefetch.test.ts +22 -0
  102. package/src/utils/previewPrefetch.ts +14 -0
  103. package/src/utils/razorSplitTransaction.test.ts +0 -1
  104. package/src/utils/razorSplitTransaction.ts +1 -1
  105. package/src/utils/sdkEditTransaction.ts +0 -3
  106. package/src/utils/studioFileHistory.test.ts +0 -9
  107. package/src/utils/studioFileHistory.ts +1 -5
  108. package/src/utils/timelineCompositionInsert.ts +0 -1
  109. package/dist/assets/hyperframes-player-Bs9qG2hg.js +0 -464
  110. package/dist/chunk-7BP32G62.js.map +0 -1
  111. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +0 -60
  112. package/src/utils/editHistory.test.ts +0 -534
  113. package/src/utils/editHistory.ts +0 -236
  114. package/src/utils/editHistoryStorage.test.ts +0 -37
  115. package/src/utils/editHistoryStorage.ts +0 -99
  116. /package/dist/{domEditingLayers-UKMVYXT6.js.map → domEditingLayers-2XBFXXSW.js.map} +0 -0
@@ -8,11 +8,6 @@ import {
8
8
  type DragCommitDeps,
9
9
  type TimelineMoveEdit,
10
10
  } from "./timelineClipDragCommit";
11
- import {
12
- buildEditHistoryEntry,
13
- createEmptyEditHistory,
14
- pushEditHistoryEntry,
15
- } from "../../utils/editHistory";
16
11
  import { normalizeToZones } from "./timelineZones";
17
12
  import { resolveZMirrorLaneMove } from "./timelineZMirror";
18
13
  import type { StackingPatch } from "./timelineStackingSync";
@@ -1151,48 +1146,21 @@ describe("commitDraggedClipMove", () => {
1151
1146
  return { onMoveElements, onStackingPatches };
1152
1147
  };
1153
1148
 
1154
- it("threads ONE shared coalesceKey to both the move persist and the z-sync, so the two records merge into a single undo entry", async () => {
1149
+ it("threads ONE shared coalesceKey to both the move persist and the z-sync, so the server can fold them into a single undo entry", async () => {
1155
1150
  const { onMoveElements, onStackingPatches } = commitLaneChange(overlapping());
1156
1151
  await flushMicrotasks();
1157
1152
 
1158
- // Both sides receive the SAME non-empty gesture key (second arg).
1153
+ // Both sides receive the SAME non-empty gesture key (second arg). That
1154
+ // shared, non-empty coalesceKey is what lets the server
1155
+ // (projectHistory.ts, "claim: a writer that records after writing")
1156
+ // fold the "Move timeline clips" write and the "Reorder layers" z patch,
1157
+ // both to the same file, into one undo entry — folding itself is the
1158
+ // server's own tested behaviour, not re-proven here via a reducer.
1159
1159
  const moveKey = onMoveElements.mock.calls[0][1];
1160
1160
  const zKey = onStackingPatches.mock.calls[0][1];
1161
1161
  expect(typeof moveKey).toBe("string");
1162
1162
  expect(moveKey).not.toBe("");
1163
1163
  expect(zKey).toBe(moveKey);
1164
-
1165
- // With that shared key, editHistory folds the two consecutive records (the
1166
- // "Move timeline clips" write + the "Reorder layers" z patch, same file,
1167
- // inside the coalesce window) into ONE undo entry spanning before→after.
1168
- const now = 1_000;
1169
- const moveEntry = buildEditHistoryEntry({
1170
- id: "m",
1171
- projectId: "p",
1172
- label: "Move timeline clips",
1173
- kind: "timeline",
1174
- coalesceKey: moveKey,
1175
- now,
1176
- files: { "index.html": { before: "<v0>", after: "<v1>" } },
1177
- });
1178
- const zEntry = buildEditHistoryEntry({
1179
- id: "z",
1180
- projectId: "p",
1181
- label: "Reorder layers",
1182
- kind: "timeline",
1183
- coalesceKey: zKey,
1184
- now: now + 50,
1185
- files: { "index.html": { before: "<v1>", after: "<v2>" } },
1186
- });
1187
- const state = pushEditHistoryEntry(
1188
- pushEditHistoryEntry(createEmptyEditHistory(), moveEntry),
1189
- zEntry,
1190
- );
1191
- expect(state.undo).toHaveLength(1);
1192
- expect(state.undo[0].files["index.html"]).toMatchObject({
1193
- before: "<v0>",
1194
- after: "<v2>",
1195
- });
1196
1164
  });
1197
1165
 
1198
1166
  it("distinct gestures get distinct keys (independent moves never cross-merge)", async () => {
@@ -0,0 +1,38 @@
1
+ import { useCallback, useRef, useSyncExternalStore, type RefObject } from "react";
2
+ import { TIMELINE_SCROLL_SETTLE_MS } from "./useTimelineScrollViewport";
3
+
4
+ // The timeline viewport's scrollLeft once a scroll settles, or null while it is scrolling. Readers
5
+ // re-render twice per gesture, never per frame: per-frame work cost 2-3x the viewport gate's budget
6
+ // on a large unvirtualized timeline.
7
+ export function useSettledScrollLeft(scrollRef: RefObject<HTMLDivElement | null>): number | null {
8
+ const scrollingRef = useRef(false);
9
+ const subscribe = useCallback(
10
+ (onChange: () => void) => {
11
+ const el = scrollRef.current;
12
+ let settle: ReturnType<typeof setTimeout> | undefined;
13
+ const onScroll = () => {
14
+ if (!scrollingRef.current) {
15
+ scrollingRef.current = true;
16
+ onChange();
17
+ }
18
+ clearTimeout(settle);
19
+ settle = setTimeout(() => {
20
+ scrollingRef.current = false;
21
+ onChange();
22
+ }, TIMELINE_SCROLL_SETTLE_MS);
23
+ };
24
+ el?.addEventListener("scroll", onScroll, { passive: true });
25
+ return () => {
26
+ clearTimeout(settle);
27
+ scrollingRef.current = false;
28
+ el?.removeEventListener("scroll", onScroll);
29
+ };
30
+ },
31
+ [scrollRef],
32
+ );
33
+ return useSyncExternalStore(
34
+ subscribe,
35
+ () => (scrollingRef.current ? null : (scrollRef.current?.scrollLeft ?? 0)),
36
+ () => null,
37
+ );
38
+ }
@@ -47,6 +47,56 @@ describe("live DOM manifest hydration", () => {
47
47
  ).toHaveLength(2);
48
48
  }
49
49
  });
50
+ it("binds a root clip to its own element when a mounted sub-composition repeats its id", () => {
51
+ // The sub-composition's copy comes first in document order, so a bare id lookup finds it.
52
+ const doc = documentWith(
53
+ '<div id="strip" data-composition-id="strip" data-composition-src="compositions/strip.html" data-start="0" data-duration="4" data-track-index="1">' +
54
+ '<video id="frame-1" data-hf-id="hf-inner" data-start="0"></video></div>' +
55
+ '<video id="frame-1" data-hf-id="hf-root" data-start="6" data-duration="1" data-track-index="7"></video>',
56
+ );
57
+ const [element] = buildTimelineElementsFromClips(
58
+ [
59
+ clip({
60
+ id: "frame-1",
61
+ tagName: "video",
62
+ kind: "video",
63
+ start: 6,
64
+ duration: 1,
65
+ track: 7,
66
+ compositionAncestors: ["main"],
67
+ }),
68
+ ],
69
+ doc,
70
+ );
71
+ expect([element.hfId, element.sourceFile]).toEqual(["hf-root", undefined]);
72
+ });
73
+ it("keeps a unique id bound while its host's composition id is healed after the manifest", () => {
74
+ // The wrapper's data-composition-id arrived after the runtime recorded the clip's chain as ["main"].
75
+ const doc = documentWith(
76
+ '<div id="ho" data-hf-id="ho" data-start="0" data-duration="4"></div>' +
77
+ '<div id="hs-layer" data-composition-id="hs-comp">' +
78
+ '<div id="hs" data-hf-id="hs" data-start="0" data-duration="4"></div></div>',
79
+ );
80
+ const [element] = buildTimelineElementsFromClips(
81
+ [clip({ id: "hs", compositionAncestors: ["main"] })],
82
+ doc,
83
+ );
84
+ expect(element.hfId).toBe("hs");
85
+ });
86
+ it("binds an id-less host by its composition id over a lone id inside that sub-composition", () => {
87
+ const doc = documentWith(
88
+ '<div data-composition-id="card" data-composition-src="compositions/card.html" data-start="0" data-duration="4">' +
89
+ '<div data-hf-inner-root><div id="card" data-start="0" data-duration="4"></div></div></div>',
90
+ );
91
+ const host = doc.querySelector('[data-composition-src="compositions/card.html"]');
92
+ const card = clip({
93
+ id: "card",
94
+ kind: "composition",
95
+ compositionId: "card",
96
+ compositionAncestors: ["main"],
97
+ });
98
+ expect(findTimelineDomNodeForClip(doc, card, 0)).toBe(host);
99
+ });
50
100
  it("does not bind a cross-tag direct identity", () => {
51
101
  const doc = documentWith('<div id="collision" data-start="0" data-duration="4"></div>');
52
102
  expect(
@@ -43,7 +43,7 @@ function mountScene(): Document {
43
43
 
44
44
  // A composition clip is keyed by its ELEMENT id, not its `data-composition-id`
45
45
  // (the runtime's clip tree publishes `scene-2-slot`), so the collector resolves
46
- // the host with getElementById(clip.id) exactly as the sibling walk does.
46
+ // the host by clip.id in the clip's own composition, exactly as the sibling walk does.
47
47
  const sceneClips = [clip({ id: "scene-2-slot", kind: "composition", compositionId: "scene-2" })];
48
48
 
49
49
  describe("collectSubCompositionHostState", () => {
@@ -83,6 +83,30 @@ describe("collectSubCompositionHostState", () => {
83
83
  expect(state.get("scene-2-video")?.hidden).toBe(true);
84
84
  });
85
85
 
86
+ it("reads its own host when an earlier sub-composition repeats the host's id", () => {
87
+ document.body.innerHTML = `
88
+ <main data-composition-id="main">
89
+ <div id="intro-slot" data-composition-id="intro" data-composition-src="intro.html">
90
+ <div data-hf-inner-root data-composition-id="intro">
91
+ <div id="scene-2-slot"><p id="intro-note" data-hidden></p></div>
92
+ </div>
93
+ </div>
94
+ <div id="scene-2-slot" data-composition-id="scene-2" data-composition-src="scene-2.html">
95
+ <div data-hf-inner-root><div id="scene-2-title" data-hidden></div></div>
96
+ </div>
97
+ </main>`;
98
+ const host = clip({
99
+ id: "scene-2-slot",
100
+ kind: "composition",
101
+ compositionId: "scene-2",
102
+ compositionAncestors: ["main"],
103
+ });
104
+
105
+ expect([...collectSubCompositionHostState(document, [host]).keys()]).toEqual(["scene-2-title"]);
106
+ const rows = collectSubCompositionDomChildren(document, [host], new Map());
107
+ expect(rows.map((child) => child.id)).toEqual(["scene-2-title"]);
108
+ });
109
+
86
110
  it("returns empty without a document, rather than throwing", () => {
87
111
  expect(collectSubCompositionHostState(null, sceneClips).size).toBe(0);
88
112
  });
@@ -9,7 +9,7 @@
9
9
  * an argument, so each is callable — and readable — on its own.
10
10
  */
11
11
 
12
- import { createTimelineDomNodeResolver } from "../lib/timelineElementHelpers";
12
+ import { createTimelineDomNodeResolver, findClipElementById } from "../lib/timelineElementHelpers";
13
13
  import { usePlayerStore } from "../store/playerStore";
14
14
  import type { TimelineElement, DomClipChild, SubCompositionHostState } from "../store/playerStore";
15
15
  import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
@@ -128,7 +128,7 @@ export function collectSubCompositionDomChildren(
128
128
  if (!iframeDoc) return out;
129
129
  for (const clip of clips) {
130
130
  if (clip.kind !== "composition" || !clip.id) continue;
131
- const hostEl = iframeDoc.getElementById(clip.id);
131
+ const hostEl = findClipElementById(iframeDoc, clip);
132
132
  if (!hostEl) continue;
133
133
  const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl;
134
134
  collectHostDomChildren(clip.id, innerRoot, clip.id, parentMap, out);
@@ -172,7 +172,7 @@ export function collectSubCompositionHostState(
172
172
  if (!iframeDoc) return out;
173
173
  for (const clip of clips) {
174
174
  if (clip.kind !== "composition" || !clip.id) continue;
175
- const hostEl = iframeDoc.getElementById(clip.id);
175
+ const hostEl = findClipElementById(iframeDoc, clip);
176
176
  if (!hostEl) continue;
177
177
  for (const el of Array.from(hostEl.querySelectorAll("[id]"))) {
178
178
  const state = readSubCompositionHostState(el);
@@ -14,6 +14,7 @@ import {
14
14
  } from "./useTimelineSyncCallbacks";
15
15
  import type { PlaybackAdapter } from "../lib/playbackTypes";
16
16
  import { thumbnailScheduler } from "../lib/thumbnailScheduler";
17
+ import { usePlayerStore } from "../store/playerStore";
17
18
 
18
19
  // One wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
19
20
  // leaves about 6.5s for the document load and runtime boot. Nothing shorter may fail the swap.
@@ -35,6 +36,8 @@ type UseShadowPreviewReloadParams = Omit<
35
36
  onPromoted?: () => void;
36
37
  /** A shadow that never became ready was dropped; the live preview is unchanged. */
37
38
  onReloadFailed?: (message: string) => void;
39
+ /** Puts the promoted document at the live frame's time, playing if the live frame was. */
40
+ handOverPlayback: (time: number, playing: boolean) => void;
38
41
  };
39
42
 
40
43
  export function useShadowPreviewReload({
@@ -51,6 +54,7 @@ export function useShadowPreviewReload({
51
54
  applyPreviewAudioState,
52
55
  onPromoted,
53
56
  onReloadFailed,
57
+ handOverPlayback,
54
58
  }: UseShadowPreviewReloadParams) {
55
59
  const shadowIframeRef = useRef<HTMLIFrameElement | null>(null);
56
60
  const shadowProbeIntervalRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
@@ -62,6 +66,8 @@ export function useShadowPreviewReload({
62
66
  onPromotedRef.current = onPromoted;
63
67
  const onReloadFailedRef = useRef(onReloadFailed);
64
68
  onReloadFailedRef.current = onReloadFailed;
69
+ const handOverPlaybackRef = useRef(handOverPlayback);
70
+ handOverPlaybackRef.current = handOverPlayback;
65
71
  const readyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
66
72
  const budgetsSpentRef = useRef(0);
67
73
  // The shadow still owed a wait budget, so a hidden tab can resume it when it becomes visible.
@@ -102,6 +108,11 @@ export function useShadowPreviewReload({
102
108
  const ready = pending?.gen === gen && visuallyReadyGenRef.current === gen;
103
109
  if (!shadow || !pending || !ready || gen !== shadowGenRef.current) return;
104
110
  stopPendingShadow();
111
+ // The live frame kept playing, stopped at the end or was seeked while the shadow loaded.
112
+ const live = getAdapter();
113
+ const liveTime = live?.getTime();
114
+ const playing = usePlayerStore.getState().isPlaying;
115
+ live?.pause();
105
116
  // The store takes the new document's timeline only now that it is the one on screen.
106
117
  pending.commit();
107
118
  iframeRef.current = shadow;
@@ -109,10 +120,17 @@ export function useShadowPreviewReload({
109
120
  attachIframeShortcutListeners();
110
121
  applyPreviewAudioState();
111
122
  setPreviewSlots((prev) => planShadowPromotion(prev, gen));
123
+ if (liveTime != null) handOverPlaybackRef.current(liveTime, playing);
112
124
  onPromotedRef.current?.();
113
125
  thumbnailScheduler.setPreviewReloading(false);
114
126
  },
115
- [stopPendingShadow, iframeRef, attachIframeShortcutListeners, applyPreviewAudioState],
127
+ [
128
+ stopPendingShadow,
129
+ getAdapter,
130
+ iframeRef,
131
+ attachIframeShortcutListeners,
132
+ applyPreviewAudioState,
133
+ ],
116
134
  );
117
135
 
118
136
  const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
@@ -201,6 +219,7 @@ export function useShadowPreviewReload({
201
219
  shadowIframeRef.current = null;
202
220
  isRefreshingRef.current = false;
203
221
  pendingSeekRef.current = null;
222
+ usePlayerStore.getState().setTimelineReady(false);
204
223
  setPreviewSlots(planShadowDiscard);
205
224
  thumbnailScheduler.setPreviewReloading(false);
206
225
  }, [stopPendingShadow, isRefreshingRef, pendingSeekRef]);
@@ -0,0 +1,120 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { afterEach, expect, it, vi } from "vitest";
5
+ import {
6
+ makeAdapterWindow,
7
+ makeFakeIframe,
8
+ renderTimelinePlayerHarness,
9
+ resetPlayerStore,
10
+ } from "./timelinePlayerTestHarness";
11
+ import { usePlayerStore } from "../store/playerStore";
12
+
13
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
14
+
15
+ afterEach(() => {
16
+ document.body.innerHTML = "";
17
+ resetPlayerStore();
18
+ });
19
+
20
+ it("does not start playback before the timeline is ready, so iframe load cannot swallow it", () => {
21
+ const { api, root } = renderTimelinePlayerHarness();
22
+ const { adapter, win } = makeAdapterWindow();
23
+ // The runtime has booted (adapter exists) but the iframe's load event has not fired yet.
24
+ act(() => {
25
+ api.iframeRef.current = makeFakeIframe(win);
26
+ });
27
+
28
+ act(() => api.play());
29
+ expect(adapter.play).not.toHaveBeenCalled();
30
+ expect(usePlayerStore.getState().isPlaying).toBe(false);
31
+
32
+ act(() => {
33
+ api.onIframeLoad();
34
+ usePlayerStore.setState({ timelineReady: true });
35
+ });
36
+ act(() => api.play());
37
+ expect(adapter.play).toHaveBeenCalledTimes(1);
38
+ expect(usePlayerStore.getState().isPlaying).toBe(true);
39
+ act(() => root.unmount());
40
+ });
41
+
42
+ it("does not start playback while a switched-to composition's preview is still loading", () => {
43
+ const { api, root } = renderTimelinePlayerHarness();
44
+ act(() => {
45
+ api.iframeRef.current = makeFakeIframe(makeAdapterWindow().win);
46
+ api.onIframeLoad();
47
+ usePlayerStore.setState({ timelineReady: true });
48
+ });
49
+
50
+ // Drill into another composition: a fresh preview whose runtime is up but whose load step hasn't run.
51
+ const next = makeAdapterWindow();
52
+ act(() => {
53
+ api.resetPreviewSlots();
54
+ api.iframeRef.current = makeFakeIframe(next.win);
55
+ });
56
+ act(() => api.play());
57
+ expect(next.adapter.play).not.toHaveBeenCalled();
58
+ expect(usePlayerStore.getState().isPlaying).toBe(false);
59
+ act(() => root.unmount());
60
+ });
61
+
62
+ function postFromRuntime(win: object, data: Record<string, unknown>) {
63
+ act(() => {
64
+ window.dispatchEvent(
65
+ new MessageEvent("message", {
66
+ source: win as unknown as Window,
67
+ data: { source: "hf-preview", ...data },
68
+ }),
69
+ );
70
+ });
71
+ }
72
+
73
+ async function expectPlayStillOff() {
74
+ await new Promise((resolve) => setTimeout(resolve, 300));
75
+ expect(usePlayerStore.getState().timelineReady).toBe(false);
76
+ }
77
+
78
+ // Duration 0: the adapter cannot enable Play itself, so only the message path can, after the load step.
79
+ it.each([30, 0])(
80
+ "waits for the preview's load step before a timeline message enables Play (adapter duration %i)",
81
+ async (duration) => {
82
+ const { api, root } = renderTimelinePlayerHarness();
83
+ const { win } = makeAdapterWindow({ duration });
84
+ act(() => {
85
+ api.iframeRef.current = makeFakeIframe(win);
86
+ });
87
+ // The runtime reports its timeline before the document's load event, as a film with large images does.
88
+ const clip = { id: "clip", label: "Clip", start: 0, duration: 1, track: 0, kind: "element" };
89
+ postFromRuntime(win, { type: "timeline", clips: [clip], durationInFrames: 30, fps: 30 });
90
+ await expectPlayStillOff();
91
+
92
+ act(() => api.onIframeLoad());
93
+ await vi.waitFor(() => expect(usePlayerStore.getState().timelineReady).toBe(true));
94
+ act(() => root.unmount());
95
+ },
96
+ );
97
+
98
+ it("does not let the blank page's load step enable Play for the preview that replaces it", async () => {
99
+ const { api, root } = renderTimelinePlayerHarness();
100
+ // The iframe's first load is about:blank, before the runtime exists; its load step waits for runtime messages.
101
+ const iframe = makeFakeIframe({ ...makeAdapterWindow().win, __player: undefined });
102
+ act(() => {
103
+ api.iframeRef.current = iframe;
104
+ api.onIframeLoad();
105
+ });
106
+
107
+ // The same iframe navigates to the preview, whose runtime reports ready before its own load event.
108
+ const { win } = makeAdapterWindow();
109
+ Object.defineProperty(iframe, "contentWindow", { value: win, configurable: true });
110
+ Object.defineProperty(iframe, "contentDocument", {
111
+ value: document.implementation.createHTMLDocument("preview"),
112
+ configurable: true,
113
+ });
114
+ postFromRuntime(win, { type: "state" });
115
+ await expectPlayStillOff();
116
+
117
+ act(() => api.onIframeLoad());
118
+ await vi.waitFor(() => expect(usePlayerStore.getState().timelineReady).toBe(true));
119
+ act(() => root.unmount());
120
+ });
@@ -224,6 +224,91 @@ describe("shadow reload readiness and failure", () => {
224
224
  unmount(root);
225
225
  });
226
226
 
227
+ it("keeps a playing film playing through a reload, from where the live frame had reached", () => {
228
+ const { getApi, root } = renderTimelinePlayerHarness();
229
+ const { adapter: liveAdapter, win } = makeAdapterWindow();
230
+ act(() => {
231
+ getApi().iframeRef.current = makeFakeIframe(win);
232
+ getApi().onIframeLoad();
233
+ });
234
+ usePlayerStore.setState({ timelineReady: true });
235
+ act(() => getApi().play());
236
+ liveAdapter.pause.mockClear();
237
+ liveAdapter.seek(4.2);
238
+
239
+ act(() => getApi().refreshPlayer());
240
+ expect(usePlayerStore.getState().isPlaying).toBe(true);
241
+ expect(liveAdapter.pause).not.toHaveBeenCalled();
242
+
243
+ liveAdapter.seek(5.5);
244
+ const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
245
+ const shadow = makeShadowWithSpies();
246
+ act(() => {
247
+ getApi().setShadowIframeNode(shadow.iframe);
248
+ getApi().onShadowIframeLoad(gen);
249
+ getApi().onShadowReadyChange(gen, true);
250
+ });
251
+ expect(getApi().iframeRef.current).toBe(shadow.iframe);
252
+ expect(liveAdapter.pause).toHaveBeenCalled();
253
+ expect(shadow.adapter.getTime()).toBe(5.5);
254
+ expect(shadow.adapter.isPlaying()).toBe(true);
255
+ expect(usePlayerStore.getState().isPlaying).toBe(true);
256
+ unmount(root);
257
+ });
258
+
259
+ it("keeps where a film was paused while the reload loaded", () => {
260
+ const { getApi, root } = renderTimelinePlayerHarness();
261
+ const { adapter: liveAdapter, win } = makeAdapterWindow();
262
+ act(() => {
263
+ getApi().iframeRef.current = makeFakeIframe(win);
264
+ getApi().onIframeLoad();
265
+ });
266
+ usePlayerStore.setState({ timelineReady: true });
267
+ act(() => getApi().play());
268
+ liveAdapter.seek(4.2);
269
+ act(() => getApi().refreshPlayer());
270
+
271
+ liveAdapter.seek(7);
272
+ act(() => getApi().pause());
273
+ const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
274
+ const shadow = makeShadowWithSpies();
275
+ act(() => {
276
+ getApi().setShadowIframeNode(shadow.iframe);
277
+ getApi().onShadowIframeLoad(gen);
278
+ getApi().onShadowReadyChange(gen, true);
279
+ });
280
+ expect(getApi().iframeRef.current).toBe(shadow.iframe);
281
+ expect(shadow.adapter.getTime()).toBe(7);
282
+ expect(shadow.adapter.isPlaying()).toBe(false);
283
+ expect(usePlayerStore.getState().isPlaying).toBe(false);
284
+ unmount(root);
285
+ });
286
+
287
+ it("stops a playing film at the new end when the edit cut it short of the live time", () => {
288
+ const { getApi, root } = renderTimelinePlayerHarness();
289
+ const { adapter: liveAdapter, win } = makeAdapterWindow();
290
+ act(() => {
291
+ getApi().iframeRef.current = makeFakeIframe(win);
292
+ getApi().onIframeLoad();
293
+ });
294
+ usePlayerStore.setState({ timelineReady: true });
295
+ act(() => getApi().play());
296
+ act(() => getApi().refreshPlayer());
297
+
298
+ liveAdapter.seek(12);
299
+ const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
300
+ const shorter = makeAdapterWindow({ duration: 10 });
301
+ act(() => {
302
+ getApi().setShadowIframeNode(makeFakeIframe(shorter.win));
303
+ getApi().onShadowIframeLoad(gen);
304
+ getApi().onShadowReadyChange(gen, true);
305
+ });
306
+ expect(shorter.adapter.getTime()).toBe(10);
307
+ expect(shorter.adapter.isPlaying()).toBe(false);
308
+ expect(usePlayerStore.getState().isPlaying).toBe(false);
309
+ unmount(root);
310
+ });
311
+
227
312
  it("does not promote a shadow whose loader is still up, and promotes once it clears", () => {
228
313
  const { getApi, live, gen, root } = beginReload();
229
314
  const shadow = makeShadowWithSpies();
@@ -180,6 +180,7 @@ export function useTimelinePlayer({
180
180
  applyPreviewAudioFlags(iframeRef.current, audioMuted, audioVolume);
181
181
  }, []);
182
182
  const play = useCallback(() => {
183
+ if (!usePlayerStore.getState().timelineReady) return;
183
184
  stopRAFLoop();
184
185
  stopReverseLoop();
185
186
  stopScrubPreviewAudio();
@@ -400,6 +401,13 @@ export function useTimelinePlayer({
400
401
  applyPreviewAudioState,
401
402
  onPromoted: onShadowPromoted,
402
403
  onReloadFailed: onPreviewReloadFailed,
404
+ handOverPlayback: (time, playing) => {
405
+ // keepPlaying: move the playhead without the paused-seek audio scrub.
406
+ seek(time, { keepPlaying: true });
407
+ const adapter = getAdapter();
408
+ // An edit that cut the film short of the live time stops it at the new end, as playback does.
409
+ if (playing && adapter && adapter.getTime() < adapter.getDuration()) play();
410
+ },
403
411
  });
404
412
 
405
413
  const saveSeekPosition = useCallback(() => {
@@ -422,17 +430,21 @@ export function useTimelinePlayer({
422
430
  }
423
431
  }
424
432
  isRefreshingRef.current = true;
425
- stopRAFLoop();
433
+ // Forward playback runs on in the live frame; the promotion hands it to the new document.
434
+ const keepPlaying =
435
+ usePlayerStore.getState().isPlaying && shuttleDirectionRef.current !== "backward";
426
436
  stopReverseLoop();
437
+ if (keepPlaying) return true;
438
+ stopRAFLoop();
427
439
  setIsPlaying(false);
440
+ return false;
428
441
  }, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
429
442
  const refreshPlayer = useCallback(() => {
430
443
  const iframe = iframeRef.current;
431
444
  if (!iframe) return;
432
445
  logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack }));
433
- saveSeekPosition();
434
446
  // The old iframe is no longer navigated away, so stop its playback (and audio) here.
435
- getAdapter()?.pause();
447
+ if (!saveSeekPosition()) getAdapter()?.pause();
436
448
  // The live iframe is never hidden; the reload loads in a shadow and is promoted once painted.
437
449
  const src = iframe.src;
438
450
  const url = new URL(src, window.location.origin);
@@ -295,6 +295,8 @@ export function useTimelineSyncCallbacks({
295
295
 
296
296
  const onIframeLoad = useCallback(
297
297
  (context?: number) => {
298
+ const loadedDoc = safeContentDocument(iframeRef.current);
299
+ if (loadedDoc) usePlayerStore.getState().markPreviewLoadStep(loadedDoc);
298
300
  applyPreviewAudioState();
299
301
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
300
302
  stopWaitingRef.current?.();
@@ -428,14 +428,50 @@ function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean
428
428
  });
429
429
  }
430
430
 
431
- function findTimelineDomNode(doc: Document, id: string): Element | null {
432
- return (
433
- doc.getElementById(id) ??
434
- doc.querySelector(`[data-hf-id="${CSS.escape(id)}"]`) ??
435
- doc.querySelector(`[data-composition-id="${CSS.escape(id)}"]`) ??
436
- doc.querySelector(`.${CSS.escape(id)}`) ??
437
- null
438
- );
431
+ /** Whether `node` sits in the composition the clip was read from, the chain the runtime records outermost first
432
+ * (`resolveNearestCompositionContext` in core's runtime/timeline.ts). A clip without that scope accepts any node. */
433
+ function nodeInClipScope(node: Element, clip: ClipManifestClip): boolean {
434
+ const scope = clip.compositionAncestors;
435
+ if (!scope) return true;
436
+ const ids: string[] = [];
437
+ for (let cursor = node.parentElement; cursor; cursor = cursor.parentElement) {
438
+ const id = cursor.getAttribute("data-composition-id");
439
+ if (id) ids.unshift(id);
440
+ }
441
+ return ids.length === scope.length && ids.every((id, index) => id === scope[index]);
442
+ }
443
+
444
+ /** The first match in the clip's composition across `selectors`; a lone match stands only when none is in scope, as a
445
+ * healed host can stale the clip's chain for a pass. An id can repeat in a sub-composition earlier in the document. */
446
+ function findInClipScope(
447
+ doc: Document,
448
+ clip: ClipManifestClip,
449
+ selectors: string[],
450
+ ): Element | null {
451
+ let lone: Element | null = null;
452
+ for (const selector of selectors) {
453
+ const nodes = Array.from(doc.querySelectorAll(selector));
454
+ const scoped = nodes.find((node) => nodeInClipScope(node, clip));
455
+ if (scoped) return scoped;
456
+ if (nodes.length === 1) lone ??= nodes[0];
457
+ }
458
+ return lone;
459
+ }
460
+
461
+ export function findClipElementById(doc: Document, clip: ClipManifestClip): Element | null {
462
+ if (!clip.id) return null;
463
+ const first = doc.getElementById(clip.id);
464
+ if (!first || nodeInClipScope(first, clip)) return first;
465
+ return findInClipScope(doc, clip, [`[id="${CSS.escape(clip.id)}"]`]);
466
+ }
467
+
468
+ function findTimelineDomNode(doc: Document, clip: ClipManifestClip): Element | null {
469
+ if (!clip.id) return null;
470
+ const first = doc.getElementById(clip.id);
471
+ if (first && nodeInClipScope(first, clip)) return first;
472
+ const id = CSS.escape(clip.id);
473
+ const byOtherKeys = [`[data-hf-id="${id}"]`, `[data-composition-id="${id}"]`, `.${id}`];
474
+ return findInClipScope(doc, clip, first ? [`[id="${id}"]`, ...byOtherKeys] : byOtherKeys);
439
475
  }
440
476
 
441
477
  export function findTimelineDomNodeForClip(
@@ -445,7 +481,7 @@ export function findTimelineDomNodeForClip(
445
481
  usedNodes = new Set<Element>(),
446
482
  getCandidates = () => getTimelineDomNodes(doc),
447
483
  ): Element | null {
448
- const byIdentity = clip.id ? findTimelineDomNode(doc, clip.id) : null;
484
+ const byIdentity = findTimelineDomNode(doc, clip);
449
485
  if (byIdentity && !usedNodes.has(byIdentity) && nodeMatchesClipTag(byIdentity, clip))
450
486
  return byIdentity;
451
487