@hyperframes/studio 0.7.55 → 0.7.57

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 (274) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/index-DfmYkU44.js +423 -0
  4. package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  7. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  8. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  9. package/dist/index.d.ts +172 -144
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +43613 -40346
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +106 -96
  15. package/src/components/EditorShell.tsx +253 -0
  16. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  17. package/src/components/StudioLeftSidebar.tsx +13 -3
  18. package/src/components/StudioRightPanel.tsx +8 -3
  19. package/src/components/TimelineToolbar.test.tsx +2 -2
  20. package/src/components/TimelineToolbar.tsx +179 -121
  21. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  22. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  23. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  24. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  25. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  26. package/src/components/editor/DomEditOverlay.tsx +126 -125
  27. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  28. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  29. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  30. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  31. package/src/components/editor/LayersPanel.test.ts +66 -2
  32. package/src/components/editor/LayersPanel.tsx +53 -2
  33. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  34. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  35. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  36. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  37. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  38. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  39. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  40. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  41. package/src/components/editor/domEditNudge.test.ts +80 -0
  42. package/src/components/editor/domEditNudge.ts +44 -0
  43. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  44. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  45. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  46. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  47. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  48. package/src/components/editor/domEditResizeLocal.ts +125 -0
  49. package/src/components/editor/domEditingDom.ts +1 -1
  50. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  51. package/src/components/editor/manualOffsetDrag.ts +55 -12
  52. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  53. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  54. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  55. package/src/components/editor/resizeDraft.ts +108 -0
  56. package/src/components/editor/snapEngine.test.ts +12 -78
  57. package/src/components/editor/snapEngine.ts +13 -53
  58. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  60. package/src/components/editor/useDomEditNudge.ts +254 -0
  61. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  62. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  63. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  64. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  65. package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
  66. package/src/components/nle/NLEContext.test.ts +144 -0
  67. package/src/components/nle/NLEContext.tsx +328 -0
  68. package/src/components/nle/NLEPreview.tsx +1 -1
  69. package/src/components/nle/PreviewOverlays.tsx +260 -0
  70. package/src/components/nle/PreviewPane.tsx +163 -0
  71. package/src/components/nle/TimelinePane.test.ts +60 -0
  72. package/src/components/nle/TimelinePane.tsx +293 -0
  73. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  74. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  75. package/src/components/nle/useCompositionStack.ts +17 -9
  76. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  77. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  78. package/src/components/sidebar/AssetCard.tsx +343 -0
  79. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  80. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  81. package/src/components/sidebar/AssetsTab.tsx +71 -206
  82. package/src/components/sidebar/AudioRow.tsx +56 -2
  83. package/src/components/sidebar/BlocksTab.tsx +8 -1
  84. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  85. package/src/components/sidebar/assetHelpers.ts +29 -0
  86. package/src/contexts/DomEditContext.tsx +9 -0
  87. package/src/contexts/StudioContext.tsx +9 -8
  88. package/src/contexts/TimelineEditContext.tsx +3 -7
  89. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  90. package/src/hooks/domEditCommitTypes.ts +23 -1
  91. package/src/hooks/domSelectionTestHarness.ts +14 -0
  92. package/src/hooks/gestureTransaction.test.ts +311 -0
  93. package/src/hooks/gestureTransaction.ts +199 -0
  94. package/src/hooks/gsapDragCommit.test.ts +141 -41
  95. package/src/hooks/gsapDragCommit.ts +64 -74
  96. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  97. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  98. package/src/hooks/gsapResizeIntercept.ts +38 -10
  99. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  100. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  101. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  102. package/src/hooks/gsapShared.test.ts +19 -1
  103. package/src/hooks/gsapShared.ts +14 -0
  104. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  105. package/src/hooks/timelineEditingGsap.ts +14 -0
  106. package/src/hooks/timelineEditingHelpers.test.ts +125 -1
  107. package/src/hooks/timelineEditingHelpers.ts +53 -292
  108. package/src/hooks/timelineMoveAdapter.test.ts +81 -0
  109. package/src/hooks/timelineMoveAdapter.ts +39 -0
  110. package/src/hooks/timelineTimingSync.test.ts +150 -0
  111. package/src/hooks/timelineTimingSync.ts +412 -0
  112. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  113. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  114. package/src/hooks/useAppHotkeys.ts +16 -25
  115. package/src/hooks/useBlockHandlers.ts +55 -27
  116. package/src/hooks/useCaptionDetection.ts +2 -0
  117. package/src/hooks/useCompositionDimensions.ts +32 -5
  118. package/src/hooks/useContextMenuDismiss.ts +31 -7
  119. package/src/hooks/useDomEditCommits.test.tsx +245 -1
  120. package/src/hooks/useDomEditCommits.ts +162 -3
  121. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  122. package/src/hooks/useDomEditSession.ts +2 -2
  123. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  124. package/src/hooks/useDomEditTextCommits.ts +8 -3
  125. package/src/hooks/useDomEditWiring.ts +7 -9
  126. package/src/hooks/useDomGeometryCommits.ts +17 -2
  127. package/src/hooks/useDomSelection.test.ts +2 -36
  128. package/src/hooks/useDomSelection.ts +81 -51
  129. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  130. package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
  131. package/src/hooks/useElementLifecycleOps.ts +53 -71
  132. package/src/hooks/useElementPicker.ts +7 -8
  133. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  134. package/src/hooks/useEnableKeyframes.ts +73 -29
  135. package/src/hooks/useGestureCommit.test.tsx +130 -0
  136. package/src/hooks/useGestureCommit.ts +32 -8
  137. package/src/hooks/useGroupCommits.ts +7 -28
  138. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  139. package/src/hooks/useGsapAwareEditing.ts +115 -20
  140. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  141. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  142. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  143. package/src/hooks/useGsapScriptCommits.ts +134 -50
  144. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  145. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  146. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  147. package/src/hooks/usePersistentEditHistory.ts +92 -58
  148. package/src/hooks/usePreviewPersistence.ts +25 -7
  149. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  150. package/src/hooks/useRazorSplit.test.ts +269 -0
  151. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  152. package/src/hooks/useRazorSplit.ts +107 -36
  153. package/src/hooks/useRenderClipContent.ts +24 -6
  154. package/src/hooks/useStudioContextValue.ts +15 -5
  155. package/src/hooks/useStudioUrlState.ts +2 -4
  156. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  157. package/src/hooks/useTimelineEditing.test.tsx +398 -241
  158. package/src/hooks/useTimelineEditing.ts +181 -265
  159. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  160. package/src/hooks/useTimelineGroupEditing.ts +167 -132
  161. package/src/index.ts +1 -1
  162. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  163. package/src/player/components/ImageThumbnail.tsx +160 -0
  164. package/src/player/components/PlayerControls.tsx +5 -5
  165. package/src/player/components/PlayheadIndicator.tsx +55 -5
  166. package/src/player/components/Timeline.test.ts +183 -23
  167. package/src/player/components/Timeline.tsx +194 -194
  168. package/src/player/components/TimelineCanvas.tsx +218 -523
  169. package/src/player/components/TimelineClip.tsx +4 -1
  170. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  171. package/src/player/components/TimelineEmptyState.tsx +3 -1
  172. package/src/player/components/TimelineLanes.tsx +487 -0
  173. package/src/player/components/TimelineOverlays.tsx +122 -0
  174. package/src/player/components/TimelineRuler.tsx +58 -48
  175. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  176. package/src/player/components/VideoThumbnail.tsx +28 -7
  177. package/src/player/components/thumbnailUtils.test.ts +127 -0
  178. package/src/player/components/thumbnailUtils.ts +54 -0
  179. package/src/player/components/timelineCallbacks.ts +18 -18
  180. package/src/player/components/timelineClipChildren.tsx +39 -0
  181. package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
  182. package/src/player/components/timelineClipDragCommit.ts +498 -0
  183. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  184. package/src/player/components/timelineClipDragPreview.ts +323 -38
  185. package/src/player/components/timelineClipDragTypes.ts +65 -0
  186. package/src/player/components/timelineCollision.test.ts +42 -2
  187. package/src/player/components/timelineDragDrop.ts +69 -60
  188. package/src/player/components/timelineEditCapabilities.ts +52 -0
  189. package/src/player/components/timelineEditing.test.ts +1 -1
  190. package/src/player/components/timelineEditing.ts +76 -59
  191. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  192. package/src/player/components/timelineGroupEditing.ts +133 -0
  193. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  194. package/src/player/components/timelineLayout.test.ts +95 -23
  195. package/src/player/components/timelineLayout.ts +207 -75
  196. package/src/player/components/timelineMarquee.test.ts +197 -0
  197. package/src/player/components/timelineMarquee.ts +112 -0
  198. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  199. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  200. package/src/player/components/timelineRevealScroll.ts +91 -0
  201. package/src/player/components/timelineStackingSync.test.ts +60 -0
  202. package/src/player/components/timelineStackingSync.ts +27 -2
  203. package/src/player/components/timelineTheme.ts +6 -1
  204. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  205. package/src/player/components/timelineZones.test.ts +122 -310
  206. package/src/player/components/timelineZones.ts +82 -135
  207. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  208. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  209. package/src/player/components/useTimelineClipDrag.ts +380 -386
  210. package/src/player/components/useTimelineEditPinning.ts +121 -0
  211. package/src/player/components/useTimelineGeometry.ts +129 -0
  212. package/src/player/components/useTimelinePlayhead.ts +4 -16
  213. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  214. package/src/player/components/useTimelineRevealClip.ts +56 -0
  215. package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
  216. package/src/player/components/useTimelineStackingSync.ts +94 -0
  217. package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
  218. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  219. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  220. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  221. package/src/player/hooks/useTimelinePlayer.ts +52 -53
  222. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  224. package/src/player/index.ts +5 -2
  225. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  226. package/src/player/lib/runtimeProtocol.ts +65 -0
  227. package/src/player/lib/time.test.ts +19 -19
  228. package/src/player/lib/time.ts +6 -2
  229. package/src/player/lib/timelineDOM.test.ts +25 -98
  230. package/src/player/lib/timelineDOM.ts +21 -81
  231. package/src/player/lib/timelineElementHelpers.ts +73 -5
  232. package/src/player/lib/timelineIframeHelpers.ts +4 -4
  233. package/src/player/store/playerStore.test.ts +38 -13
  234. package/src/player/store/playerStore.ts +99 -3
  235. package/src/styles/studio.css +27 -0
  236. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  237. package/src/utils/assetPreviewDismiss.ts +29 -0
  238. package/src/utils/assetPreviewStore.ts +33 -0
  239. package/src/utils/blockInstaller.ts +10 -2
  240. package/src/utils/editHistory.test.ts +35 -0
  241. package/src/utils/gsapSoftReload.test.ts +119 -1
  242. package/src/utils/gsapSoftReload.ts +153 -0
  243. package/src/utils/mediaTypes.ts +3 -2
  244. package/src/utils/resizeDebug.ts +55 -0
  245. package/src/utils/rootDuration.test.ts +90 -1
  246. package/src/utils/rootDuration.ts +76 -13
  247. package/src/utils/sdkCutover.ts +4 -0
  248. package/src/utils/studioFileHistory.ts +37 -7
  249. package/src/utils/studioHelpers.ts +62 -0
  250. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  251. package/src/utils/studioPreviewHelpers.ts +10 -0
  252. package/src/utils/studioTelemetry.ts +4 -1
  253. package/src/utils/studioUrlState.test.ts +0 -1
  254. package/src/utils/timelineAssetDrop.test.ts +144 -52
  255. package/src/utils/timelineAssetDrop.ts +67 -29
  256. package/src/utils/timelineDiscovery.ts +0 -17
  257. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  258. package/dist/assets/index-BRwkMj0w.js +0 -423
  259. package/dist/assets/index-Dq7FEg0K.css +0 -1
  260. package/dist/chunk-SOTCF4DF.js.map +0 -1
  261. package/src/components/StudioPreviewArea.tsx +0 -500
  262. package/src/components/nle/NLELayout.test.ts +0 -12
  263. package/src/components/nle/NLELayout.tsx +0 -591
  264. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  265. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  266. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  267. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  268. package/src/player/components/timelineSnapTargets.ts +0 -164
  269. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  270. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  271. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  272. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  273. package/src/utils/timelineDiscovery.test.ts +0 -90
  274. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -0,0 +1,94 @@
1
+ import { useCallback, type RefObject } from "react";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
4
+ import { useStudioShellContextOptional } from "../../contexts/StudioContext";
5
+ import { findElementForSelection } from "../../components/editor/domEditingElement";
6
+ import { readEffectiveZIndex } from "../../components/editor/canvasContextMenuZOrder";
7
+ import type { StackingPatch } from "./timelineStackingSync";
8
+
9
+ interface UseTimelineStackingSyncInput {
10
+ expandedElementsRef: RefObject<TimelineElement[]>;
11
+ }
12
+
13
+ // Lane ↔ stacking unification (research/STAGE3-NEEDED-WIRING.md). Provision the
14
+ // two deps commitDraggedClipMove accepts so a lane-change drag also patches the
15
+ // edited clip's z-index. Both read the SAME preview iframe + z-order persist path
16
+ // the canvas right-click menu / LayersPanel use, so a timeline lane move and a
17
+ // menu z-edit produce one shared inline-style commit shape. Optional contexts:
18
+ // outside the NLE (standalone <Timeline>) these are null ⇒ deps undefined ⇒ the
19
+ // commit's z-sync is a no-op (backward compatible).
20
+ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStackingSyncInput) {
21
+ const domEditActions = useDomEditActionsContextOptional();
22
+ const shell = useStudioShellContextOptional();
23
+ const zSyncPreviewIframeRef = domEditActions?.previewIframeRef ?? null;
24
+ const handleDomZIndexReorderCommit = domEditActions?.handleDomZIndexReorderCommit;
25
+ const zSyncActiveCompPath = shell?.activeCompPath ?? null;
26
+
27
+ // Resolve a TimelineElement to its live iframe HTMLElement via the same
28
+ // hfId ?? id ?? selector[selectorIndex] resolver the timeline's DOM patches use.
29
+ const resolveIframeElement = useCallback(
30
+ (el: TimelineElement): HTMLElement | null => {
31
+ const doc = zSyncPreviewIframeRef?.current?.contentDocument ?? null;
32
+ if (!doc) return null;
33
+ return findElementForSelection(
34
+ doc,
35
+ {
36
+ hfId: el.hfId,
37
+ id: el.domId ?? el.id,
38
+ selector: el.selector,
39
+ selectorIndex: el.selectorIndex,
40
+ sourceFile: el.sourceFile,
41
+ },
42
+ zSyncActiveCompPath,
43
+ );
44
+ },
45
+ [zSyncPreviewIframeRef, zSyncActiveCompPath],
46
+ );
47
+
48
+ // NaN (NOT 0) when the element can't be resolved in the preview iframe — a
49
+ // nested / unmounted sub-comp node, or one outside the active file. Fabricating
50
+ // z=0 would enter computeStackingPatches as a real overlapping neighbour at the
51
+ // z-floor and skew the boundary math; a non-finite value tells it to EXCLUDE this
52
+ // clip instead. NaN (rather than null) keeps the return assignable to the
53
+ // `(el) => number` reader contract the drag hook / commit deps declare.
54
+ const readClipZIndex = useCallback(
55
+ (el: TimelineElement): number => {
56
+ const node = resolveIframeElement(el);
57
+ return node ? readEffectiveZIndex(node) : Number.NaN;
58
+ },
59
+ [resolveIframeElement],
60
+ );
61
+
62
+ const applyStackingPatches = useCallback(
63
+ (patches: StackingPatch[], coalesceKey?: string) => {
64
+ if (!handleDomZIndexReorderCommit) return;
65
+ const entries = patches.flatMap((p) => {
66
+ const el = expandedElementsRef.current.find((e) => (e.key ?? e.id) === p.key);
67
+ const node = el && resolveIframeElement(el);
68
+ if (!el || !node) return [];
69
+ return [
70
+ {
71
+ element: node,
72
+ zIndex: p.zIndex,
73
+ id: el.domId ?? el.id,
74
+ selector: el.selector,
75
+ selectorIndex: el.selectorIndex,
76
+ sourceFile: el.sourceFile ?? zSyncActiveCompPath ?? "index.html",
77
+ // The store key: lets the commit update the store's zIndex
78
+ // synchronously (and roll it back on failure).
79
+ key: p.key,
80
+ },
81
+ ];
82
+ });
83
+ // Forward the drag-commit's shared coalesce key so the z-reorder history
84
+ // entry merges with the lane change's move entry into one undo step.
85
+ if (entries.length) handleDomZIndexReorderCommit(entries, coalesceKey);
86
+ },
87
+ [handleDomZIndexReorderCommit, resolveIframeElement, zSyncActiveCompPath, expandedElementsRef],
88
+ );
89
+
90
+ // Engage the z-sync only when the persist path is present (inside the NLE).
91
+ const zSyncEnabled = Boolean(handleDomZIndexReorderCommit && zSyncPreviewIframeRef);
92
+
93
+ return { readClipZIndex, applyStackingPatches, zSyncEnabled };
94
+ }
@@ -163,7 +163,14 @@ function buildChildElements(
163
163
  key,
164
164
  start: clamped.start,
165
165
  duration: clamped.duration,
166
+ // `track` becomes a synthetic display row under the expanded host, but the
167
+ // factory-set `authoredTrack` (the child's data-track-index in ITS OWN
168
+ // file's coordinate space) and the runtime-computed `stackingContextId`
169
+ // must survive verbatim — lane persists and z-sync read them, they are
170
+ // never reconstructed from display lanes.
166
171
  track: display.track + result.length,
172
+ authoredTrack: base.authoredTrack,
173
+ stackingContextId: base.stackingContextId,
167
174
  expandedParentStart: editBasis.start,
168
175
  domId,
169
176
  selector,
@@ -192,12 +199,9 @@ function domSiblingClips(
192
199
  start: host.start,
193
200
  duration: host.duration,
194
201
  track: host.track,
195
- zIndex: 0,
196
- stackingContextId: host.stackingContextId ?? host.domId ?? host.id ?? null,
197
202
  kind: "element",
198
203
  tagName: null,
199
204
  compositionId: null,
200
- compositionAncestors: host.compositionAncestors ?? [],
201
205
  parentCompositionId: host.id ?? null,
202
206
  compositionSrc: host.compositionSrc ?? null,
203
207
  assetUrl: null,
@@ -9,6 +9,7 @@
9
9
  import { useRef, useCallback } from "react";
10
10
  import { useCaptionStore } from "../../captions/store";
11
11
  import { shouldIgnorePlaybackShortcutEvent, SHUTTLE_SPEEDS } from "../lib/playbackShortcuts";
12
+ import { canvasNudgeKeysClaimed } from "../../utils/canvasNudgeGate";
12
13
  import { usePlayerStore } from "../store/playerStore";
13
14
  import { stepFrameTime, STUDIO_PREVIEW_FPS } from "../lib/time";
14
15
  import type { PlaybackAdapter } from "../lib/playbackTypes";
@@ -97,6 +98,10 @@ export function usePlaybackKeyboard({
97
98
  togglePlay();
98
99
  return;
99
100
  }
101
+ // A nudgeable canvas selection owns the arrow keys (DomEditOverlay moves
102
+ // the element); frame-stepping would double-handle the same keystroke.
103
+ const arrowStep = e.code === "ArrowLeft" || e.code === "ArrowRight";
104
+ if (arrowStep && canvasNudgeKeysClaimed()) return;
100
105
  if (e.code === "ArrowLeft") {
101
106
  e.preventDefault();
102
107
  stepFrames(e.shiftKey ? -10 : -1);
@@ -153,6 +153,61 @@ describe("useTimelinePlayer seek hydration", () => {
153
153
  unmountWithAct(root);
154
154
  unsubscribe();
155
155
  });
156
+
157
+ it("does not settle from an unsupported runtime protocol message", () => {
158
+ const { api, root } = renderTimelinePlayerHarness();
159
+ const iframe = document.createElement("iframe");
160
+ const iframeWindow = {
161
+ postMessage: vi.fn(),
162
+ scrollTo: vi.fn(),
163
+ addEventListener: vi.fn(),
164
+ removeEventListener: vi.fn(),
165
+ } as Record<string, unknown>;
166
+ Object.defineProperty(iframe, "contentWindow", {
167
+ value: iframeWindow,
168
+ configurable: true,
169
+ });
170
+ Object.defineProperty(iframe, "contentDocument", {
171
+ value: document.implementation.createHTMLDocument("preview"),
172
+ configurable: true,
173
+ });
174
+
175
+ act(() => {
176
+ api.iframeRef.current = iframe;
177
+ api.onIframeLoad();
178
+ });
179
+ expect(usePlayerStore.getState().timelineReady).toBe(false);
180
+
181
+ iframeWindow.__player = {
182
+ play: vi.fn(),
183
+ pause: vi.fn(),
184
+ seek: vi.fn(),
185
+ getTime: () => 0,
186
+ getDuration: () => 30,
187
+ isPlaying: () => false,
188
+ };
189
+ act(() => {
190
+ window.dispatchEvent(
191
+ new MessageEvent("message", {
192
+ source: iframeWindow as unknown as Window,
193
+ data: { source: "hf-preview", type: "state", protocolVersion: 999 },
194
+ }),
195
+ );
196
+ });
197
+ expect(usePlayerStore.getState().timelineReady).toBe(false);
198
+
199
+ act(() => {
200
+ window.dispatchEvent(
201
+ new MessageEvent("message", {
202
+ source: iframeWindow as unknown as Window,
203
+ data: { source: "hf-preview", type: "state" },
204
+ }),
205
+ );
206
+ });
207
+ expect(usePlayerStore.getState().timelineReady).toBe(true);
208
+
209
+ unmountWithAct(root);
210
+ });
156
211
  });
157
212
 
158
213
  describe("useTimelinePlayer audio controls (#835)", () => {
@@ -437,12 +437,19 @@ describe("anonymous timeline identity", () => {
437
437
  });
438
438
 
439
439
  describe("mergeTimelineElementsPreservingDowngrades", () => {
440
- it("preserves missing current elements when a shorter manifest arrives", () => {
440
+ it("preserves missing sub-composition elements when a shorter manifest arrives", () => {
441
441
  expect(
442
442
  mergeTimelineElementsPreservingDowngrades(
443
443
  [
444
444
  { id: "hero", tag: "div", start: 0, duration: 4, track: 0 },
445
- { id: "cta", tag: "div", start: 4, duration: 2, track: 1 },
445
+ {
446
+ id: "cta",
447
+ tag: "div",
448
+ start: 4,
449
+ duration: 2,
450
+ track: 1,
451
+ compositionSrc: "scenes/cta.html",
452
+ },
446
453
  ],
447
454
  [{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }],
448
455
  8,
@@ -450,10 +457,31 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
450
457
  ),
451
458
  ).toEqual([
452
459
  { id: "hero", tag: "div", start: 0, duration: 4, track: 0 },
453
- { id: "cta", tag: "div", start: 4, duration: 2, track: 1 },
460
+ {
461
+ id: "cta",
462
+ tag: "div",
463
+ start: 4,
464
+ duration: 2,
465
+ track: 1,
466
+ compositionSrc: "scenes/cta.html",
467
+ },
454
468
  ]);
455
469
  });
456
470
 
471
+ it("drops missing top-level elements so undo does not leave ghost clips", () => {
472
+ expect(
473
+ mergeTimelineElementsPreservingDowngrades(
474
+ [
475
+ { id: "hero", tag: "div", start: 0, duration: 4, track: 0 },
476
+ { id: "split-clone", tag: "div", start: 4, duration: 2, track: 1 },
477
+ ],
478
+ [{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }],
479
+ 8,
480
+ 8,
481
+ ),
482
+ ).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
483
+ });
484
+
457
485
  it("accepts longer-duration or same-size updates as authoritative", () => {
458
486
  expect(
459
487
  mergeTimelineElementsPreservingDowngrades(
@@ -477,6 +505,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
477
505
  start: 0,
478
506
  duration: 3,
479
507
  track: 0,
508
+ compositionSrc: "scenes/cards.html",
480
509
  },
481
510
  {
482
511
  id: "Card",
@@ -486,6 +515,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
486
515
  start: 3,
487
516
  duration: 3,
488
517
  track: 1,
518
+ compositionSrc: "scenes/cards.html",
489
519
  },
490
520
  ],
491
521
  [
@@ -497,6 +527,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
497
527
  start: 0,
498
528
  duration: 3,
499
529
  track: 0,
530
+ compositionSrc: "scenes/cards.html",
500
531
  },
501
532
  ],
502
533
  8,
@@ -511,6 +542,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
511
542
  start: 0,
512
543
  duration: 3,
513
544
  track: 0,
545
+ compositionSrc: "scenes/cards.html",
514
546
  },
515
547
  {
516
548
  id: "Card",
@@ -520,6 +552,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
520
552
  start: 3,
521
553
  duration: 3,
522
554
  track: 1,
555
+ compositionSrc: "scenes/cards.html",
523
556
  },
524
557
  ]);
525
558
  });
@@ -3,6 +3,7 @@ import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerS
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
4
  import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
5
5
  import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
6
+ import { useTimelinePlayerLoop } from "./useTimelinePlayerLoop";
6
7
 
7
8
  export type { ClipManifestClip } from "../lib/playbackTypes";
8
9
  export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
@@ -15,7 +16,6 @@ export {
15
16
  parseTimelineFromDOM,
16
17
  readTimelineDurationFromDocument,
17
18
  resolveStandaloneRootCompositionSrc,
18
- resolveIframe,
19
19
  } from "../lib/timelineDOM";
20
20
  export {
21
21
  shouldIgnorePlaybackShortcutEvent,
@@ -36,6 +36,7 @@ import {
36
36
  mergeTimelineElementsPreservingDowngrades,
37
37
  parseTimelineFromDOM,
38
38
  } from "../lib/timelineDOM";
39
+ import { normalizeToZones } from "../components/timelineZones";
39
40
  import {
40
41
  setPreviewMediaMuted,
41
42
  setPreviewPlaybackRate,
@@ -45,6 +46,7 @@ import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
45
46
  import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
46
47
  import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
47
48
  import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
49
+ import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol";
48
50
 
49
51
  /**
50
52
  * Whether the derived elements differ from the current ones in any field that
@@ -90,12 +92,17 @@ export function useTimelinePlayer() {
90
92
  // applyCachedSourceDurations re-applies the cached probe duration: re-derived
91
93
  // elements (e.g. after a clip move) can arrive without sourceDuration, which
92
94
  // otherwise makes trimmed waveforms lose their window.
93
- const mergedElements = applyCachedSourceDurations(
94
- mergeTimelineElementsPreservingDowngrades(
95
- state.elements,
96
- elements,
97
- state.duration,
98
- resolvedDuration,
95
+ // Enforced CapCut zoning (overlay → main → audio): normalize track indices
96
+ // on every discovery. Idempotent — already-zoned input is returned as-is, so
97
+ // drops persist zoned indices and reloads re-zone to the same (no drift).
98
+ const mergedElements = normalizeToZones(
99
+ applyCachedSourceDurations(
100
+ mergeTimelineElementsPreservingDowngrades(
101
+ state.elements,
102
+ elements,
103
+ state.duration,
104
+ resolvedDuration,
105
+ ),
99
106
  ),
100
107
  );
101
108
 
@@ -207,49 +214,14 @@ export function useTimelinePlayer() {
207
214
  }
208
215
  }, []);
209
216
 
210
- const stopReverseLoop = useCallback(() => {
211
- cancelAnimationFrame(reverseRafRef.current);
212
- }, []);
213
-
214
- const startRAFLoop = useCallback(() => {
215
- // fallow-ignore-next-line complexity
216
- const tick = () => {
217
- const adapter = getAdapter();
218
- if (adapter) {
219
- const rawTime = adapter.getTime();
220
- const dur = adapter.getDuration();
221
- const time = dur > 0 ? Math.min(rawTime, dur) : rawTime;
222
- liveTime.notify(time); // direct DOM updates, no React re-render
223
- const { inPoint, outPoint } = usePlayerStore.getState();
224
- const rawLoopEnd = outPoint !== null ? Math.min(outPoint, dur) : dur;
225
- const rawLoopStart = inPoint !== null ? inPoint : 0;
226
- const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : dur;
227
- const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
228
- if (time >= loopEnd) {
229
- if (usePlayerStore.getState().loopEnabled && dur > 0) {
230
- // keepPlaying skips the adapter's implicit pause; play() below is then a no-op.
231
- adapter.seek(loopStart, { keepPlaying: true });
232
- liveTime.notify(loopStart);
233
- adapter.play();
234
- setIsPlaying(true);
235
- rafRef.current = requestAnimationFrame(tick);
236
- return;
237
- }
238
- if (adapter.isPlaying()) adapter.pause();
239
- setCurrentTime(time); // sync Zustand once at end
240
- setIsPlaying(false);
241
- cancelAnimationFrame(rafRef.current);
242
- return;
243
- }
244
- }
245
- rafRef.current = requestAnimationFrame(tick);
246
- };
247
- rafRef.current = requestAnimationFrame(tick);
248
- }, [getAdapter, setCurrentTime, setIsPlaying]);
217
+ const { startRAFLoop, stopRAFLoop, stopReverseLoop } = useTimelinePlayerLoop({
218
+ rafRef,
219
+ reverseRafRef,
220
+ getAdapter,
221
+ setCurrentTime,
222
+ setIsPlaying,
223
+ });
249
224
 
250
- const stopRAFLoop = useCallback(() => {
251
- cancelAnimationFrame(rafRef.current);
252
- }, []);
253
225
  const applyPlaybackRate = useCallback((rate: number) => {
254
226
  const iframe = iframeRef.current;
255
227
  if (!iframe) return;
@@ -463,10 +435,24 @@ export function useTimelinePlayer() {
463
435
  applyPreviewAudioState,
464
436
  });
465
437
  const saveSeekPosition = useCallback(() => {
466
- const adapter = getAdapter();
467
- pendingSeekRef.current = adapter
468
- ? adapter.getTime()
469
- : (usePlayerStore.getState().currentTime ?? 0);
438
+ // Never DEGRADE the saved position. Overlapping reloads (e.g. an external
439
+ // file drop = upload reload + insert reload back-to-back) call this while
440
+ // the iframe from the FIRST reload is mid-teardown: getAdapter() can still
441
+ // return that dying document's adapter, whose getTime() reads 0 — and the
442
+ // store's currentTime can lag the visual playhead. Overwriting the
443
+ // still-unconsumed pendingSeek with either value is exactly how the
444
+ // playhead used to end up at 0 after a Finder drop (verified live via a
445
+ // currentTime write-trace). So: while a refresh is already in flight and a
446
+ // save exists, keep it; otherwise trust the live adapter, then the store.
447
+ const refreshInFlight = isRefreshingRef.current && pendingSeekRef.current != null;
448
+ if (!refreshInFlight) {
449
+ const adapter = getAdapter();
450
+ if (adapter) {
451
+ pendingSeekRef.current = adapter.getTime();
452
+ } else if (pendingSeekRef.current == null) {
453
+ pendingSeekRef.current = usePlayerStore.getState().currentTime ?? 0;
454
+ }
455
+ }
470
456
  isRefreshingRef.current = true;
471
457
  stopRAFLoop();
472
458
  stopReverseLoop();
@@ -476,6 +462,16 @@ export function useTimelinePlayer() {
476
462
  const iframe = iframeRef.current;
477
463
  if (!iframe) return;
478
464
  saveSeekPosition();
465
+ // Hide the iframe across the full reload so the user never sees the reloading
466
+ // document's RAW DOM (every clip stacked and visible) in the window between the
467
+ // new document parsing and the runtime initializing + seeking. initializeAdapter
468
+ // reveals it again right after its restore seek renders the correct frame.
469
+ // Tradeoff: this shows the parent stage background (a brief "freeze"/blank, on
470
+ // the order of the reload time ~100-300ms) INSTEAD of the all-clips flash. A
471
+ // blank is far less jarring than a burst of every asset appearing at once.
472
+ // Only the FULL-reload edits (drops/inserts) hit this — timing edits now take
473
+ // the soft-reload path and never touch refreshPlayer.
474
+ iframe.style.visibility = "hidden";
479
475
  const src = iframe.src;
480
476
  const url = new URL(src, window.location.origin);
481
477
  url.searchParams.set("_t", String(Date.now()));
@@ -497,6 +493,9 @@ export function useTimelinePlayer() {
497
493
  if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
498
494
  return;
499
495
  }
496
+ if (data?.source === "hf-preview") {
497
+ if (!acceptStudioRuntimeMessage(data)) return;
498
+ }
500
499
  if (data?.source === "hf-preview" && data?.type === "state") {
501
500
  try {
502
501
  if (usePlayerStore.getState().elements.length === 0) {
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The forward playback loop for the timeline player.
3
+ *
4
+ * Owns the three requestAnimationFrame lifecycle callbacks that drive (and stop)
5
+ * playback:
6
+ * - startRAFLoop — the forward tick: advance liveTime, honour in/out loop
7
+ * points + loopEnabled, and pause + sync the store at the end.
8
+ * - stopRAFLoop — cancel the forward tick.
9
+ * - stopReverseLoop — cancel the reverse-shuttle tick (owned by the parent hook).
10
+ *
11
+ * Called unconditionally at the top level of useTimelinePlayer so its useCallback
12
+ * hooks run in a stable order; every dependency is passed in as an argument.
13
+ */
14
+
15
+ import { useCallback } from "react";
16
+ import { liveTime, usePlayerStore } from "../store/playerStore";
17
+ import type { PlaybackAdapter } from "../lib/playbackTypes";
18
+
19
+ interface UseTimelinePlayerLoopParams {
20
+ rafRef: React.MutableRefObject<number>;
21
+ reverseRafRef: React.MutableRefObject<number>;
22
+ getAdapter: () => PlaybackAdapter | null;
23
+ setCurrentTime: (v: number) => void;
24
+ setIsPlaying: (v: boolean) => void;
25
+ }
26
+
27
+ interface UseTimelinePlayerLoopResult {
28
+ startRAFLoop: () => void;
29
+ stopRAFLoop: () => void;
30
+ stopReverseLoop: () => void;
31
+ }
32
+
33
+ export function useTimelinePlayerLoop({
34
+ rafRef,
35
+ reverseRafRef,
36
+ getAdapter,
37
+ setCurrentTime,
38
+ setIsPlaying,
39
+ }: UseTimelinePlayerLoopParams): UseTimelinePlayerLoopResult {
40
+ const stopReverseLoop = useCallback(() => {
41
+ cancelAnimationFrame(reverseRafRef.current);
42
+ }, [reverseRafRef]);
43
+
44
+ const startRAFLoop = useCallback(() => {
45
+ // fallow-ignore-next-line complexity
46
+ const tick = () => {
47
+ const adapter = getAdapter();
48
+ if (adapter) {
49
+ const rawTime = adapter.getTime();
50
+ const dur = adapter.getDuration();
51
+ const time = dur > 0 ? Math.min(rawTime, dur) : rawTime;
52
+ liveTime.notify(time); // direct DOM updates, no React re-render
53
+ const { inPoint, outPoint } = usePlayerStore.getState();
54
+ const rawLoopEnd = outPoint !== null ? Math.min(outPoint, dur) : dur;
55
+ const rawLoopStart = inPoint !== null ? inPoint : 0;
56
+ const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : dur;
57
+ const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
58
+ if (time >= loopEnd) {
59
+ if (usePlayerStore.getState().loopEnabled && dur > 0) {
60
+ // keepPlaying skips the adapter's implicit pause; play() below is then a no-op.
61
+ adapter.seek(loopStart, { keepPlaying: true });
62
+ liveTime.notify(loopStart);
63
+ adapter.play();
64
+ setIsPlaying(true);
65
+ rafRef.current = requestAnimationFrame(tick);
66
+ return;
67
+ }
68
+ if (adapter.isPlaying()) adapter.pause();
69
+ setCurrentTime(time); // sync Zustand once at end
70
+ setIsPlaying(false);
71
+ cancelAnimationFrame(rafRef.current);
72
+ return;
73
+ }
74
+ }
75
+ rafRef.current = requestAnimationFrame(tick);
76
+ };
77
+ rafRef.current = requestAnimationFrame(tick);
78
+ }, [rafRef, getAdapter, setCurrentTime, setIsPlaying]);
79
+
80
+ const stopRAFLoop = useCallback(() => {
81
+ cancelAnimationFrame(rafRef.current);
82
+ }, [rafRef]);
83
+
84
+ return { startRAFLoop, stopRAFLoop, stopReverseLoop };
85
+ }
@@ -26,6 +26,7 @@ import {
26
26
  autoHealMissingCompositionIds,
27
27
  buildMissingCompositionElements,
28
28
  } from "../lib/timelineIframeHelpers";
29
+ import { acceptedRuntimeMessageFps, inspectStudioRuntimeMessage } from "../lib/runtimeProtocol";
29
30
 
30
31
  interface UseTimelineSyncCallbacksParams {
31
32
  iframeRef: React.RefObject<HTMLIFrameElement | null>;
@@ -132,6 +133,9 @@ export function useTimelineSyncCallbacks({
132
133
  clips: ClipManifestClip[];
133
134
  durationInFrames: number;
134
135
  scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
136
+ protocolVersion?: unknown;
137
+ capabilities?: unknown;
138
+ fps?: unknown;
135
139
  }) => {
136
140
  if (!data.clips || data.clips.length === 0) {
137
141
  return;
@@ -222,7 +226,7 @@ export function useTimelineSyncCallbacks({
222
226
  hostEl,
223
227
  });
224
228
  });
225
- const rawDuration = data.durationInFrames / 30;
229
+ const rawDuration = data.durationInFrames / acceptedRuntimeMessageFps(data);
226
230
  // Clamp non-finite or absurdly large durations — the runtime can emit
227
231
  // Infinity when it detects a loop-inflated GSAP timeline without an
228
232
  // explicit data-duration on the root composition. Floor the manifest total
@@ -418,6 +422,10 @@ export function useTimelineSyncCallbacks({
418
422
  if (e.source && iframe && e.source !== iframe.contentWindow) return;
419
423
  const data = e.data;
420
424
  if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
425
+ // The main message handler owns protocol-error diagnostics. This readiness-only
426
+ // listener mirrors its acceptance gate without dispatching a duplicate event:
427
+ // an unsupported runtime must not make the iframe appear successfully settled.
428
+ if (inspectStudioRuntimeMessage(data).status === "unsupported") return;
421
429
  trySettle();
422
430
  }
423
431
  };
@@ -6,11 +6,14 @@ export { VideoThumbnail } from "./components/VideoThumbnail";
6
6
  export { CompositionThumbnail } from "./components/CompositionThumbnail";
7
7
 
8
8
  // Hooks
9
- export { useTimelinePlayer, resolveIframe } from "./hooks/useTimelinePlayer";
9
+ export { useTimelinePlayer } from "./hooks/useTimelinePlayer";
10
+ export { resolveIframe } from "./lib/timelineDOM";
10
11
 
11
12
  // Store
12
13
  export { usePlayerStore, liveTime } from "./store/playerStore";
13
- export type { TimelineElement, ZoomMode } from "./store/playerStore";
14
+ // Public library surface; external consumers are invisible to the workspace analyzer.
15
+ // fallow-ignore-next-line unused-exports
16
+ export type { SelectElementOptions, TimelineElement, ZoomMode } from "./store/playerStore";
14
17
 
15
18
  // Utils
16
19
  export { formatTime } from "./lib/time";
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ acceptStudioRuntimeMessage,
4
+ acceptedRuntimeMessageFps,
5
+ createRuntimeControlMessage,
6
+ inspectStudioRuntimeMessage,
7
+ postRuntimeControlMessage,
8
+ } from "./runtimeProtocol";
9
+
10
+ describe("Studio runtime protocol", () => {
11
+ it("versions every control message and declares rational fps", () => {
12
+ expect(createRuntimeControlMessage("seek", { timeSeconds: 1.25 }, 60)).toEqual({
13
+ source: "hf-parent",
14
+ type: "control",
15
+ action: "seek",
16
+ protocolVersion: 1,
17
+ capabilities: ["seconds-time", "rational-fps", "seek-keep-playing"],
18
+ fps: { numerator: 60, denominator: 1 },
19
+ timeSeconds: 1.25,
20
+ });
21
+ });
22
+
23
+ it("posts the typed message to the target window", () => {
24
+ const target = { postMessage: vi.fn() };
25
+ postRuntimeControlMessage(target as unknown as Window, "pause");
26
+ expect(target.postMessage).toHaveBeenCalledWith(
27
+ expect.objectContaining({ action: "pause", protocolVersion: 1 }),
28
+ "*",
29
+ );
30
+ });
31
+
32
+ it("preserves legacy 30fps messages and rejects unknown majors", () => {
33
+ expect(inspectStudioRuntimeMessage({ source: "hf-preview" })).toEqual({
34
+ status: "legacy",
35
+ fps: 30,
36
+ });
37
+ expect(inspectStudioRuntimeMessage({ protocolVersion: 2 })).toMatchObject({
38
+ status: "unsupported",
39
+ code: "unsupported_protocol_version",
40
+ });
41
+ });
42
+
43
+ it("reads explicit fps for accepted timeline messages", () => {
44
+ const message = createRuntimeControlMessage("pause", {}, 60);
45
+ expect(acceptedRuntimeMessageFps(message)).toBe(60);
46
+ expect(acceptStudioRuntimeMessage(message)).toMatchObject({ status: "supported", fps: 60 });
47
+ });
48
+ });