@hyperframes/studio 0.7.54 → 0.7.56

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 (262) hide show
  1. package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
  3. package/dist/assets/index-CmVCjZjp.js +423 -0
  4. package/dist/assets/index-D-GyYi2d.css +1 -0
  5. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  6. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  7. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  8. package/dist/index.d.ts +146 -144
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +40582 -37803
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +106 -96
  14. package/src/components/EditorShell.tsx +253 -0
  15. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  16. package/src/components/StudioLeftSidebar.tsx +13 -3
  17. package/src/components/StudioRightPanel.tsx +8 -3
  18. package/src/components/TimelineToolbar.test.tsx +2 -2
  19. package/src/components/TimelineToolbar.tsx +179 -121
  20. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  21. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  22. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  23. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  24. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  25. package/src/components/editor/DomEditOverlay.tsx +158 -126
  26. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  27. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  28. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  29. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  30. package/src/components/editor/LayersPanel.test.ts +66 -2
  31. package/src/components/editor/LayersPanel.tsx +43 -1
  32. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  33. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  34. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  35. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  36. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  37. package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
  38. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  39. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  40. package/src/components/editor/domEditNudge.test.ts +80 -0
  41. package/src/components/editor/domEditNudge.ts +44 -0
  42. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  43. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  44. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  45. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  46. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  47. package/src/components/editor/domEditResizeLocal.ts +125 -0
  48. package/src/components/editor/domEditingDom.ts +1 -1
  49. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  50. package/src/components/editor/manualOffsetDrag.ts +55 -12
  51. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  52. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  53. package/src/components/editor/resizeDraft.ts +108 -0
  54. package/src/components/editor/snapEngine.test.ts +12 -78
  55. package/src/components/editor/snapEngine.ts +13 -53
  56. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  57. package/src/components/editor/useDomEditNudge.ts +254 -0
  58. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  59. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  60. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  61. package/src/components/nle/NLEContext.test.ts +144 -0
  62. package/src/components/nle/NLEContext.tsx +328 -0
  63. package/src/components/nle/NLEPreview.tsx +1 -1
  64. package/src/components/nle/PreviewOverlays.tsx +240 -0
  65. package/src/components/nle/PreviewPane.tsx +163 -0
  66. package/src/components/nle/TimelinePane.test.ts +60 -0
  67. package/src/components/nle/TimelinePane.tsx +293 -0
  68. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  69. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  70. package/src/components/nle/useCompositionStack.ts +17 -9
  71. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  72. package/src/components/sidebar/AssetCard.tsx +326 -0
  73. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  74. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  75. package/src/components/sidebar/AssetsTab.tsx +71 -206
  76. package/src/components/sidebar/AudioRow.tsx +39 -2
  77. package/src/components/sidebar/BlocksTab.tsx +8 -1
  78. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  79. package/src/components/sidebar/assetHelpers.ts +29 -0
  80. package/src/contexts/DomEditContext.tsx +9 -0
  81. package/src/contexts/StudioContext.tsx +9 -8
  82. package/src/contexts/TimelineEditContext.tsx +3 -7
  83. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  84. package/src/hooks/domEditCommitTypes.ts +12 -1
  85. package/src/hooks/domSelectionTestHarness.ts +14 -0
  86. package/src/hooks/gestureTransaction.test.ts +311 -0
  87. package/src/hooks/gestureTransaction.ts +199 -0
  88. package/src/hooks/gsapDragCommit.test.ts +141 -41
  89. package/src/hooks/gsapDragCommit.ts +64 -74
  90. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  91. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  92. package/src/hooks/gsapResizeIntercept.ts +38 -10
  93. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  94. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  95. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  96. package/src/hooks/gsapShared.test.ts +19 -1
  97. package/src/hooks/gsapShared.ts +14 -0
  98. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  99. package/src/hooks/timelineEditingGsap.ts +14 -0
  100. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  101. package/src/hooks/timelineEditingHelpers.ts +22 -48
  102. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  103. package/src/hooks/timelineMoveAdapter.ts +37 -0
  104. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  105. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  106. package/src/hooks/useAppHotkeys.ts +16 -25
  107. package/src/hooks/useBlockHandlers.ts +55 -27
  108. package/src/hooks/useContextMenuDismiss.ts +31 -7
  109. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  110. package/src/hooks/useDomEditCommits.ts +100 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  112. package/src/hooks/useDomEditSession.ts +2 -2
  113. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  114. package/src/hooks/useDomEditTextCommits.ts +8 -3
  115. package/src/hooks/useDomEditWiring.ts +7 -9
  116. package/src/hooks/useDomGeometryCommits.ts +17 -2
  117. package/src/hooks/useDomSelection.test.ts +2 -36
  118. package/src/hooks/useDomSelection.ts +81 -51
  119. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  120. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  121. package/src/hooks/useElementLifecycleOps.ts +40 -71
  122. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  123. package/src/hooks/useEnableKeyframes.ts +73 -29
  124. package/src/hooks/useGestureCommit.test.tsx +130 -0
  125. package/src/hooks/useGestureCommit.ts +32 -8
  126. package/src/hooks/useGroupCommits.ts +7 -28
  127. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  128. package/src/hooks/useGsapAwareEditing.ts +115 -20
  129. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  130. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  131. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  132. package/src/hooks/useGsapScriptCommits.ts +134 -50
  133. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  134. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  135. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  136. package/src/hooks/usePersistentEditHistory.ts +92 -58
  137. package/src/hooks/usePreviewPersistence.ts +25 -7
  138. package/src/hooks/useRazorSplit.history.test.tsx +303 -0
  139. package/src/hooks/useRazorSplit.test.ts +269 -0
  140. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  141. package/src/hooks/useRazorSplit.ts +107 -36
  142. package/src/hooks/useRenderClipContent.ts +24 -6
  143. package/src/hooks/useStudioContextValue.ts +15 -5
  144. package/src/hooks/useStudioUrlState.ts +2 -4
  145. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  146. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  147. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  148. package/src/index.ts +1 -1
  149. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  150. package/src/player/components/ImageThumbnail.tsx +160 -0
  151. package/src/player/components/PlayheadIndicator.tsx +50 -5
  152. package/src/player/components/Timeline.test.ts +102 -19
  153. package/src/player/components/Timeline.tsx +194 -194
  154. package/src/player/components/TimelineCanvas.tsx +207 -521
  155. package/src/player/components/TimelineClip.tsx +4 -1
  156. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  157. package/src/player/components/TimelineEmptyState.tsx +3 -1
  158. package/src/player/components/TimelineLanes.tsx +487 -0
  159. package/src/player/components/TimelineOverlays.tsx +122 -0
  160. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  161. package/src/player/components/VideoThumbnail.tsx +28 -7
  162. package/src/player/components/thumbnailUtils.test.ts +127 -0
  163. package/src/player/components/thumbnailUtils.ts +54 -0
  164. package/src/player/components/timelineCallbacks.ts +18 -18
  165. package/src/player/components/timelineClipChildren.tsx +39 -0
  166. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  167. package/src/player/components/timelineClipDragCommit.ts +401 -0
  168. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  169. package/src/player/components/timelineClipDragPreview.ts +323 -38
  170. package/src/player/components/timelineClipDragTypes.ts +65 -0
  171. package/src/player/components/timelineCollision.test.ts +477 -0
  172. package/src/player/components/timelineCollision.ts +263 -0
  173. package/src/player/components/timelineDragDrop.ts +69 -60
  174. package/src/player/components/timelineEditCapabilities.ts +52 -0
  175. package/src/player/components/timelineEditing.test.ts +1 -1
  176. package/src/player/components/timelineEditing.ts +76 -59
  177. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  178. package/src/player/components/timelineGroupEditing.ts +133 -0
  179. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  180. package/src/player/components/timelineLayout.test.ts +95 -23
  181. package/src/player/components/timelineLayout.ts +120 -61
  182. package/src/player/components/timelineMarquee.test.ts +197 -0
  183. package/src/player/components/timelineMarquee.ts +112 -0
  184. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  185. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  186. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  187. package/src/player/components/timelineSnapping.test.ts +134 -0
  188. package/src/player/components/timelineSnapping.ts +110 -0
  189. package/src/player/components/timelineStackingSync.test.ts +244 -0
  190. package/src/player/components/timelineStackingSync.ts +331 -0
  191. package/src/player/components/timelineTheme.ts +6 -1
  192. package/src/player/components/timelineZones.test.ts +425 -0
  193. package/src/player/components/timelineZones.ts +198 -0
  194. package/src/player/components/timelineZoom.test.ts +67 -0
  195. package/src/player/components/timelineZoom.ts +51 -0
  196. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  197. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  198. package/src/player/components/useTimelineClipDrag.ts +380 -386
  199. package/src/player/components/useTimelineEditPinning.ts +121 -0
  200. package/src/player/components/useTimelineGeometry.ts +129 -0
  201. package/src/player/components/useTimelinePlayhead.ts +4 -16
  202. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  203. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  204. package/src/player/components/useTimelineStackingSync.ts +83 -0
  205. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  206. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  207. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  208. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  209. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  210. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  211. package/src/player/index.ts +3 -2
  212. package/src/player/lib/time.test.ts +19 -19
  213. package/src/player/lib/time.ts +6 -2
  214. package/src/player/lib/timelineDOM.test.ts +25 -98
  215. package/src/player/lib/timelineDOM.ts +12 -81
  216. package/src/player/lib/timelineElementHelpers.ts +54 -5
  217. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  218. package/src/player/store/playerStore.ts +62 -3
  219. package/src/styles/studio.css +27 -0
  220. package/src/utils/assetClickBehavior.test.ts +104 -0
  221. package/src/utils/assetClickBehavior.ts +75 -0
  222. package/src/utils/assetPreviewStore.ts +33 -0
  223. package/src/utils/blockInstaller.ts +10 -2
  224. package/src/utils/canvasNudgeGate.test.ts +31 -0
  225. package/src/utils/canvasNudgeGate.ts +32 -0
  226. package/src/utils/editHistory.test.ts +35 -0
  227. package/src/utils/gsapSoftReload.test.ts +119 -1
  228. package/src/utils/gsapSoftReload.ts +153 -0
  229. package/src/utils/mediaTypes.ts +3 -2
  230. package/src/utils/resizeDebug.ts +55 -0
  231. package/src/utils/rootDuration.test.ts +90 -1
  232. package/src/utils/rootDuration.ts +76 -13
  233. package/src/utils/sdkCutover.ts +4 -0
  234. package/src/utils/studioFileHistory.ts +37 -7
  235. package/src/utils/studioHelpers.ts +62 -0
  236. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  237. package/src/utils/studioPreviewHelpers.ts +10 -0
  238. package/src/utils/studioTelemetry.ts +4 -1
  239. package/src/utils/studioUiPreferences.test.ts +38 -0
  240. package/src/utils/studioUiPreferences.ts +27 -0
  241. package/src/utils/studioUrlState.test.ts +0 -1
  242. package/src/utils/timelineAssetDrop.ts +62 -0
  243. package/src/utils/timelineDiscovery.ts +0 -17
  244. package/src/utils/timelineInspector.test.ts +121 -0
  245. package/src/utils/timelineInspector.ts +32 -1
  246. package/dist/assets/index-Dq7FEg0K.css +0 -1
  247. package/dist/assets/index-pRhCpGPz.js +0 -423
  248. package/dist/chunk-SOTCF4DF.js.map +0 -1
  249. package/src/components/StudioPreviewArea.tsx +0 -500
  250. package/src/components/nle/NLELayout.test.ts +0 -12
  251. package/src/components/nle/NLELayout.tsx +0 -591
  252. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  253. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  254. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  255. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  256. package/src/player/components/timelineSnapTargets.ts +0 -164
  257. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  258. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  259. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  260. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  261. package/src/utils/timelineDiscovery.test.ts +0 -90
  262. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -3,26 +3,26 @@ import { formatFrameTime, frameToSeconds, secondsToFrame, stepFrameTime, formatT
3
3
 
4
4
  describe("formatTime", () => {
5
5
  it("formats zero seconds", () => {
6
- expect(formatTime(0)).toBe("0:00");
6
+ expect(formatTime(0)).toBe("00:00");
7
7
  });
8
8
 
9
9
  // fallow-ignore-next-line code-duplication
10
10
  it("formats seconds less than a minute", () => {
11
- expect(formatTime(5)).toBe("0:05");
12
- expect(formatTime(30)).toBe("0:30");
13
- expect(formatTime(59)).toBe("0:59");
11
+ expect(formatTime(5)).toBe("00:05");
12
+ expect(formatTime(30)).toBe("00:30");
13
+ expect(formatTime(59)).toBe("00:59");
14
14
  });
15
15
 
16
16
  it("formats exact minutes", () => {
17
- expect(formatTime(60)).toBe("1:00");
18
- expect(formatTime(120)).toBe("2:00");
17
+ expect(formatTime(60)).toBe("01:00");
18
+ expect(formatTime(120)).toBe("02:00");
19
19
  expect(formatTime(600)).toBe("10:00");
20
20
  });
21
21
 
22
22
  it("formats minutes and seconds", () => {
23
- expect(formatTime(65)).toBe("1:05");
24
- expect(formatTime(90)).toBe("1:30");
25
- expect(formatTime(125)).toBe("2:05");
23
+ expect(formatTime(65)).toBe("01:05");
24
+ expect(formatTime(90)).toBe("01:30");
25
+ expect(formatTime(125)).toBe("02:05");
26
26
  });
27
27
 
28
28
  it("formats large values (over an hour)", () => {
@@ -32,28 +32,28 @@ describe("formatTime", () => {
32
32
  });
33
33
 
34
34
  it("floors fractional seconds", () => {
35
- expect(formatTime(0.9)).toBe("0:00");
36
- expect(formatTime(1.5)).toBe("0:01");
37
- expect(formatTime(59.99)).toBe("0:59");
38
- expect(formatTime(60.5)).toBe("1:00");
35
+ expect(formatTime(0.9)).toBe("00:00");
36
+ expect(formatTime(1.5)).toBe("00:01");
37
+ expect(formatTime(59.99)).toBe("00:59");
38
+ expect(formatTime(60.5)).toBe("01:00");
39
39
  });
40
40
 
41
- it("pads single-digit seconds with leading zero", () => {
42
- expect(formatTime(1)).toBe("0:01");
43
- expect(formatTime(61)).toBe("1:01");
41
+ it("pads single-digit minutes and seconds with leading zeros", () => {
42
+ expect(formatTime(1)).toBe("00:01");
43
+ expect(formatTime(61)).toBe("01:01");
44
44
  expect(formatTime(609)).toBe("10:09");
45
45
  });
46
46
 
47
47
  it("guards against negative values", () => {
48
- expect(formatTime(-1)).toBe("0:00");
48
+ expect(formatTime(-1)).toBe("00:00");
49
49
  });
50
50
 
51
51
  it("guards against NaN", () => {
52
- expect(formatTime(NaN)).toBe("0:00");
52
+ expect(formatTime(NaN)).toBe("00:00");
53
53
  });
54
54
 
55
55
  it("guards against Infinity", () => {
56
- expect(formatTime(Infinity)).toBe("0:00");
56
+ expect(formatTime(Infinity)).toBe("00:00");
57
57
  });
58
58
  });
59
59
 
@@ -1,10 +1,14 @@
1
1
  export const STUDIO_PREVIEW_FPS = 30;
2
2
 
3
3
  export function formatTime(time: number): string {
4
- if (!Number.isFinite(time) || time < 0) return "0:00";
4
+ if (!Number.isFinite(time) || time < 0) return "00:00";
5
5
  const mins = Math.floor(time / 60);
6
6
  const secs = Math.floor(time % 60);
7
- return `${mins}:${secs.toString().padStart(2, "0")}`;
7
+ // Zero-pad minutes as well as seconds so every readout is a stable-width
8
+ // MM:SS (e.g. "00:44", not "0:44"). Minutes past 99 keep their extra digits
9
+ // ("120:00"); an hours-style H:MM:SS grouping is applied by callers that need
10
+ // it (see formatTimelineTickLabel), never here.
11
+ return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
8
12
  }
9
13
 
10
14
  export function secondsToFrame(time: number, fps = STUDIO_PREVIEW_FPS): number {
@@ -4,8 +4,13 @@ import {
4
4
  createTimelineElementFromManifestClip,
5
5
  parseTimelineFromDOM,
6
6
  createImplicitTimelineLayersFromDOM,
7
- buildStandaloneRootTimelineElement,
7
+ mergeTimelineElementsPreservingDowngrades,
8
8
  } from "./timelineDOM";
9
+ import type { TimelineElement } from "../store/playerStore";
10
+
11
+ function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
12
+ return { id, tag: "img", start: 0, duration: 5, track: 0, ...extra };
13
+ }
9
14
 
10
15
  function makeDoc(html: string): Document {
11
16
  const d = document.implementation.createHTMLDocument();
@@ -13,29 +18,6 @@ function makeDoc(html: string): Document {
13
18
  return d;
14
19
  }
15
20
 
16
- function makeLiveDoc(html: string): Document {
17
- document.head.innerHTML = "";
18
- document.body.innerHTML = html;
19
- return document;
20
- }
21
-
22
- function mockComputedZIndex(doc: Document, zIndexById: ReadonlyMap<string, string>): void {
23
- const win = doc.defaultView;
24
- if (!win) throw new Error("Expected document window");
25
- const original = win.getComputedStyle.bind(win);
26
- Object.defineProperty(win, "getComputedStyle", {
27
- configurable: true,
28
- value: (element: Element, pseudoElt?: string | null) => {
29
- const style = original(element, pseudoElt);
30
- const zIndex = zIndexById.get(element.id);
31
- if (zIndex != null) {
32
- Object.defineProperty(style, "zIndex", { configurable: true, value: zIndex });
33
- }
34
- return style;
35
- },
36
- });
37
- }
38
-
39
21
  describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
40
22
  it("harvests hfId from a data-start element that has data-hf-id", () => {
41
23
  const doc = makeDoc(`
@@ -126,58 +108,6 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
126
108
 
127
109
  expect(element.hidden).toBe(true);
128
110
  });
129
-
130
- it("captures the effective z-index from the live element, not the runtime inline-only value", () => {
131
- // The runtime reports inline-only z-index (0 for CSS-rule authored z-index),
132
- // which must NOT override the live element's effective z-index — otherwise
133
- // the timeline collapses every CSS-styled clip to a z=0 tie and mis-orders.
134
- const doc = makeDoc(`
135
- <div data-composition-id="root">
136
- <div id="hero" class="clip" data-start="0" data-duration="5" style="z-index: 30"></div>
137
- </div>
138
- `);
139
- const hostEl = doc.getElementById("hero");
140
-
141
- const element = createTimelineElementFromManifestClip({
142
- clip: {
143
- id: "hero",
144
- label: "Hero",
145
- kind: "element",
146
- tagName: "div",
147
- start: 0,
148
- duration: 5,
149
- track: 0,
150
- zIndex: 0,
151
- compositionId: null,
152
- parentCompositionId: null,
153
- compositionSrc: null,
154
- assetUrl: null,
155
- },
156
- fallbackIndex: 0,
157
- doc,
158
- hostEl,
159
- });
160
-
161
- expect(element.zIndex).toBe(30);
162
- expect(element.hasExplicitZIndex).toBe(true);
163
- });
164
-
165
- it("marks parsed inline, CSS-rule, and auto z-index authorship accurately", () => {
166
- const doc = makeLiveDoc(`
167
- <div data-composition-id="root">
168
- <div id="inline" class="clip" data-start="0" data-duration="2" style="z-index: 3"></div>
169
- <div id="rule" class="clip" data-start="0" data-duration="2"></div>
170
- <div id="auto" class="clip" data-start="0" data-duration="2"></div>
171
- </div>
172
- `);
173
- mockComputedZIndex(doc, new Map([["rule", "12"]]));
174
-
175
- const elements = parseTimelineFromDOM(doc, 10);
176
-
177
- expect(elements.find((el) => el.id === "inline")?.hasExplicitZIndex).toBe(true);
178
- expect(elements.find((el) => el.id === "rule")?.hasExplicitZIndex).toBe(true);
179
- expect(elements.find((el) => el.id === "auto")?.hasExplicitZIndex).toBe(false);
180
- });
181
111
  });
182
112
 
183
113
  describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
@@ -211,31 +141,28 @@ describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
211
141
 
212
142
  expect(layers).toEqual([]);
213
143
  });
144
+ });
214
145
 
215
- it("marks implicit layer CSS z-index authorship from computed style", () => {
216
- const doc = makeLiveDoc(`
217
- <div data-composition-id="root">
218
- <div id="layer" class="clip"></div>
219
- </div>
220
- `);
221
- mockComputedZIndex(doc, new Map([["layer", "8"]]));
222
-
223
- const layers = createImplicitTimelineLayersFromDOM(doc, 10);
224
-
225
- expect(layers[0]?.zIndex).toBe(8);
226
- expect(layers[0]?.hasExplicitZIndex).toBe(true);
146
+ describe("mergeTimelineElementsPreservingDowngrades genuine removal vs transient downgrade", () => {
147
+ it("drops a removed TOP-LEVEL element (undo of a split) instead of ghosting it", () => {
148
+ const current = [el("a"), el("a-split")]; // post-split store: original + clone
149
+ const next = [el("a")]; // fresh scan of the reverted file: clone gone
150
+ const merged = mergeTimelineElementsPreservingDowngrades(current, next, 30, 30);
151
+ expect(merged.map((e) => e.id)).toEqual(["a"]);
227
152
  });
228
- });
229
153
 
230
- describe("buildStandaloneRootTimelineElement", () => {
231
- it("marks the standalone root as auto z-index", () => {
232
- const root = buildStandaloneRootTimelineElement({
233
- compositionId: "root",
234
- tagName: "div",
235
- rootDuration: 10,
236
- iframeSrc: "/preview/comp/index.html",
237
- });
154
+ it("still preserves an enriched sub-composition child a bare re-scan drops", () => {
155
+ const current = [el("a"), el("sub-child", { compositionSrc: "sub.html" })];
156
+ const next = [el("a")]; // bare DOM scan misses the enriched sub-comp child
157
+ const merged = mergeTimelineElementsPreservingDowngrades(current, next, 30, 30);
158
+ expect(merged.map((e) => e.id).sort()).toEqual(["a", "sub-child"]);
159
+ });
238
160
 
239
- expect(root?.hasExplicitZIndex).toBe(false);
161
+ it("trusts the fresh scan fully when it is not shorter", () => {
162
+ const current = [el("a"), el("b", { compositionSrc: "sub.html" })];
163
+ const next = [el("a"), el("c")];
164
+ expect(
165
+ mergeTimelineElementsPreservingDowngrades(current, next, 30, 30).map((e) => e.id),
166
+ ).toEqual(["a", "c"]);
240
167
  });
241
168
  });
@@ -10,7 +10,6 @@
10
10
 
11
11
  import type { TimelineElement } from "../store/playerStore";
12
12
  import type { ClipManifestClip } from "./playbackTypes";
13
- import { getElementZIndex, hasExplicitZIndex } from "./layerOrdering";
14
13
  import {
15
14
  resolveMediaElement,
16
15
  applyMediaMetadataFromElement,
@@ -24,6 +23,7 @@ import {
24
23
  buildTimelineElementIdentity,
25
24
  getTimelineElementIdentity,
26
25
  isTimelineIgnoredElement,
26
+ readTimelineElementZIndex,
27
27
  } from "./timelineElementHelpers";
28
28
 
29
29
  // Re-export helpers that were previously public from this module so that
@@ -66,49 +66,6 @@ function resolveClipTag(clip: ClipManifestClip): string {
66
66
  return clip.tagName || clip.kind || "div";
67
67
  }
68
68
 
69
- function resolveDomCompositionContext(
70
- element: Element,
71
- root: Element | null,
72
- ): {
73
- parentCompositionId: string | null;
74
- compositionAncestors: string[];
75
- stackingContextId: string | null;
76
- } {
77
- const ancestors: string[] = [];
78
- let parentCompositionId: string | null = null;
79
- let cursor = element.parentElement;
80
- while (cursor) {
81
- const compositionId = cursor.getAttribute("data-composition-id");
82
- if (compositionId) {
83
- ancestors.push(compositionId);
84
- if (!parentCompositionId && cursor !== root) {
85
- parentCompositionId = compositionId;
86
- }
87
- }
88
- cursor = cursor.parentElement;
89
- }
90
- const compositionAncestors = ancestors.reverse();
91
- return {
92
- parentCompositionId,
93
- compositionAncestors,
94
- stackingContextId: parentCompositionId ?? compositionAncestors[0] ?? null,
95
- };
96
- }
97
-
98
- function isHTMLElement(element: Element | null): element is HTMLElement {
99
- if (!element) return false;
100
- const HtmlElementCtor = element.ownerDocument.defaultView?.HTMLElement ?? globalThis.HTMLElement;
101
- return typeof HtmlElementCtor !== "undefined" && element instanceof HtmlElementCtor;
102
- }
103
-
104
- function getTimelineElementZIndex(element: Element | null): number | undefined {
105
- return isHTMLElement(element) ? getElementZIndex(element) : undefined;
106
- }
107
-
108
- function getTimelineElementHasExplicitZIndex(element: Element | null): boolean {
109
- return isHTMLElement(element) ? hasExplicitZIndex(element) : false;
110
- }
111
-
112
69
  // fallow-ignore-next-line complexity
113
70
  export function createTimelineElementFromManifestClip(params: {
114
71
  clip: ClipManifestClip;
@@ -130,14 +87,6 @@ export function createTimelineElementFromManifestClip(params: {
130
87
  let sourceFile: string | undefined;
131
88
 
132
89
  let hfId: string | undefined;
133
- const domContext = hostEl
134
- ? resolveDomCompositionContext(hostEl, doc?.querySelector("[data-composition-id]") ?? null)
135
- : null;
136
- const compositionAncestors = clip.compositionAncestors ?? domContext?.compositionAncestors;
137
- const parentCompositionId = clip.parentCompositionId ?? domContext?.parentCompositionId;
138
- const stackingContextId =
139
- clip.stackingContextId ?? parentCompositionId ?? compositionAncestors?.[0] ?? null;
140
-
141
90
  if (hostEl) {
142
91
  domId = hostEl.id || undefined;
143
92
  hfId = hostEl.getAttribute("data-hf-id") || undefined;
@@ -164,15 +113,6 @@ export function createTimelineElementFromManifestClip(params: {
164
113
  start: clip.start,
165
114
  duration: clip.duration,
166
115
  track: clip.track,
167
- // Prefer the effective (computed) z-index read from the live element — the
168
- // same read the reorder commit uses — so CSS-rule z-index (not just inline)
169
- // is captured. clip.zIndex from the runtime is inline-only (0 for CSS rules),
170
- // so it can only serve as a fallback when the element isn't live.
171
- zIndex: getTimelineElementZIndex(hostEl) ?? clip.zIndex ?? 0,
172
- hasExplicitZIndex: getTimelineElementHasExplicitZIndex(hostEl),
173
- stackingContextId,
174
- parentCompositionId,
175
- compositionAncestors,
176
116
  domId,
177
117
  hfId,
178
118
  selector,
@@ -185,6 +125,7 @@ export function createTimelineElementFromManifestClip(params: {
185
125
  if (hostEl.hasAttribute("data-hidden")) entry.hidden = true;
186
126
  const timelineRole = hostEl.getAttribute("data-timeline-role");
187
127
  if (timelineRole) entry.timelineRole = timelineRole;
128
+ entry.zIndex = readTimelineElementZIndex(hostEl);
188
129
  }
189
130
  if (clip.assetUrl) entry.src = clip.assetUrl;
190
131
  if (clip.kind === "composition" && clip.compositionId) {
@@ -207,8 +148,6 @@ export function createTimelineElementFromManifestClip(params: {
207
148
  }
208
149
  }
209
150
  if (hostEl) {
210
- entry.zIndex = getTimelineElementZIndex(hostEl) ?? entry.zIndex;
211
- entry.hasExplicitZIndex = getTimelineElementHasExplicitZIndex(hostEl);
212
151
  entry.domId = hostEl.id || undefined;
213
152
  entry.hfId = hostEl.getAttribute("data-hf-id") || undefined;
214
153
  entry.selector = getTimelineElementSelector(hostEl);
@@ -269,10 +208,10 @@ export function createImplicitTimelineLayersFromDOM(
269
208
  });
270
209
  if (existingKeys.has(identity.key) || existingKeys.has(identity.id)) continue;
271
210
 
272
- const compositionContext = resolveDomCompositionContext(child, rootComp);
273
211
  layers.push({
274
212
  domId: child.id || undefined,
275
213
  hfId: child.getAttribute("data-hf-id") || undefined,
214
+ zIndex: readTimelineElementZIndex(child),
276
215
  duration: rootDuration,
277
216
  id: identity.id,
278
217
  key: identity.key,
@@ -281,11 +220,6 @@ export function createImplicitTimelineLayersFromDOM(
281
220
  selectorIndex,
282
221
  sourceFile,
283
222
  start: 0,
284
- zIndex: getTimelineElementZIndex(child),
285
- hasExplicitZIndex: getTimelineElementHasExplicitZIndex(child),
286
- stackingContextId: compositionContext.stackingContextId,
287
- parentCompositionId: compositionContext.parentCompositionId,
288
- compositionAncestors: compositionContext.compositionAncestors,
289
223
  tag: child.tagName.toLowerCase(),
290
224
  timingSource: "implicit",
291
225
  track: maxTrack + 1 + layers.length,
@@ -347,7 +281,6 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
347
281
  selectorIndex,
348
282
  sourceFile,
349
283
  });
350
- const compositionContext = resolveDomCompositionContext(el, rootComp);
351
284
  const entry: TimelineElement = {
352
285
  id: identity.id,
353
286
  label,
@@ -356,17 +289,13 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
356
289
  start,
357
290
  duration: dur,
358
291
  track: isNaN(track) ? 0 : track,
359
- zIndex: getTimelineElementZIndex(el),
360
- hasExplicitZIndex: getTimelineElementHasExplicitZIndex(el),
361
- stackingContextId: compositionContext.stackingContextId,
362
- parentCompositionId: compositionContext.parentCompositionId,
363
- compositionAncestors: compositionContext.compositionAncestors,
364
292
  domId: el.id || undefined,
365
293
  hfId: el.getAttribute("data-hf-id") || undefined,
366
294
  selector,
367
295
  selectorIndex,
368
296
  sourceFile,
369
297
  timingSource: "authored",
298
+ zIndex: readTimelineElementZIndex(el),
370
299
  };
371
300
 
372
301
  const mediaEl = resolveMediaElement(el);
@@ -437,7 +366,14 @@ export function mergeTimelineElementsPreservingDowngrades(
437
366
 
438
367
  const nextIdentities = new Set(nextElements.map(getTimelineElementIdentity));
439
368
  const preserved = currentElements.filter(
440
- (element) => !nextIdentities.has(getTimelineElementIdentity(element)),
369
+ (element) =>
370
+ !nextIdentities.has(getTimelineElementIdentity(element)) &&
371
+ // Only preserve enriched sub-composition children (compositionSrc set),
372
+ // which a bare DOM re-scan legitimately drops and enrichMissingCompositions
373
+ // re-adds. A TOP-LEVEL element missing from the fresh scan was genuinely
374
+ // removed (undo of a split, a delete), so let it go — otherwise undoing a
375
+ // split leaves a ghost clip in the timeline even though the file is reverted.
376
+ element.compositionSrc != null,
441
377
  );
442
378
  if (preserved.length === 0) return nextElements;
443
379
  return [...nextElements, ...preserved];
@@ -481,11 +417,6 @@ export function buildStandaloneRootTimelineElement(params: {
481
417
  start: 0,
482
418
  duration: params.rootDuration,
483
419
  track: 0,
484
- zIndex: 0,
485
- hasExplicitZIndex: false,
486
- stackingContextId: params.compositionId,
487
- parentCompositionId: null,
488
- compositionAncestors: [params.compositionId],
489
420
  compositionSrc,
490
421
  selector: params.selector,
491
422
  selectorIndex: params.selectorIndex,
@@ -15,6 +15,31 @@ import { isFinitePositive } from "./playbackAdapter";
15
15
  // Duration attribute helpers
16
16
  // ---------------------------------------------------------------------------
17
17
 
18
+ /**
19
+ * Read a host element's effective CSS stacking order for the timeline's reverse
20
+ * z→lane mapping. Prefers the inline `style.zIndex` (what the canvas context
21
+ * menu and LayersPanel z-edits write via handleDomZIndexReorderCommit), falls
22
+ * back to computed style; "auto" / empty / unparseable ⇒ 0. Works with a
23
+ * detached parse Document (no defaultView) as well as a live iframe. Mirrors
24
+ * canvasContextMenuZOrder.parseZIndex semantics so the two directions agree.
25
+ */
26
+ export function readTimelineElementZIndex(el: Element): number {
27
+ const html = el as HTMLElement;
28
+ const parseZ = (value: string | null | undefined): number | null => {
29
+ if (value == null || value === "" || value === "auto") return null;
30
+ const n = Number.parseInt(value, 10);
31
+ return Number.isFinite(n) ? n : null;
32
+ };
33
+ const fromInline = parseZ(html.style?.zIndex);
34
+ if (fromInline != null) return fromInline;
35
+ const view = el.ownerDocument?.defaultView;
36
+ if (view?.getComputedStyle) {
37
+ const fromComputed = parseZ(view.getComputedStyle(html).zIndex);
38
+ if (fromComputed != null) return fromComputed;
39
+ }
40
+ return 0;
41
+ }
42
+
18
43
  function readDurationAttribute(el: Element | null | undefined): number {
19
44
  if (!el) return 0;
20
45
  const duration =
@@ -36,14 +61,20 @@ export function isTimelineIgnoredElement(el: Element): boolean {
36
61
  );
37
62
  }
38
63
 
39
- export function readTimelineDurationFromDocument(doc: Document | null | undefined): number {
64
+ /**
65
+ * Furthest clip end (start + RAW `data-duration`) over every non-root clip in the
66
+ * document. Reads the authored attribute, NOT any runtime-computed value — so it
67
+ * is immune to the runtime's clamp that truncates a clip's live duration to the
68
+ * composition length. This is the source of truth for content-driven duration:
69
+ * computing it from the store instead would feed the truncated value back in and
70
+ * make the composition length ratchet down (research HANDOFF-3 §6.1 feedback loop).
71
+ */
72
+ export function furthestClipEndFromDocument(doc: Document | null | undefined): number {
40
73
  if (!doc) return 0;
41
- const rootDuration = readDurationAttribute(doc.querySelector("[data-composition-id]"));
42
- if (rootDuration > 0) return rootDuration;
43
-
74
+ const root = doc.querySelector("[data-composition-id]");
44
75
  let maxEnd = 0;
45
76
  for (const node of Array.from(doc.querySelectorAll("[data-start]"))) {
46
- if (isTimelineIgnoredElement(node)) continue;
77
+ if (node === root || isTimelineIgnoredElement(node)) continue;
47
78
  const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
48
79
  const duration = readDurationAttribute(node);
49
80
  if (!Number.isFinite(start) || start < 0 || duration <= 0) continue;
@@ -52,6 +83,24 @@ export function readTimelineDurationFromDocument(doc: Document | null | undefine
52
83
  return maxEnd;
53
84
  }
54
85
 
86
+ export function readTimelineDurationFromDocument(doc: Document | null | undefined): number {
87
+ if (!doc) return 0;
88
+ const rootDuration = readDurationAttribute(doc.querySelector("[data-composition-id]"));
89
+ if (rootDuration > 0) return rootDuration;
90
+ return furthestClipEndFromDocument(doc);
91
+ }
92
+
93
+ /**
94
+ * Furthest clip end parsed straight from a composition SOURCE STRING (the HTML
95
+ * being saved). Uses raw `data-duration`, so it is the correct input for syncing
96
+ * the root duration after an edit — reading the store instead would use the
97
+ * runtime-truncated durations and shrink the composition (the feedback loop).
98
+ */
99
+ export function furthestClipEndFromSource(source: string): number {
100
+ if (!source) return 0;
101
+ return furthestClipEndFromDocument(new DOMParser().parseFromString(source, "text/html"));
102
+ }
103
+
55
104
  // ---------------------------------------------------------------------------
56
105
  // DOM element type guards
57
106
  // ---------------------------------------------------------------------------
@@ -18,6 +18,7 @@ import {
18
18
  getTimelineElementSelectorIndex,
19
19
  getTimelineElementDisplayLabel,
20
20
  buildTimelineElementIdentity,
21
+ readTimelineElementZIndex,
21
22
  } from "./timelineElementHelpers";
22
23
 
23
24
  // ---------------------------------------------------------------------------
@@ -377,6 +378,7 @@ export function buildMissingCompositionElements(
377
378
  selector,
378
379
  selectorIndex,
379
380
  sourceFile,
381
+ zIndex: readTimelineElementZIndex(el),
380
382
  };
381
383
  if (compSrc) {
382
384
  entry.compositionSrc = compSrc;
@@ -3,6 +3,7 @@ import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
3
3
  import type { BeatEditState } from "../../utils/beatEditing";
4
4
  import type { ClipManifestClip } from "../lib/playbackTypes";
5
5
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
6
+ import { computePinnedZoomPercent } from "../components/timelineZoom";
6
7
 
7
8
  /** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
8
9
  export interface KeyframeCacheEntry {
@@ -75,6 +76,10 @@ export interface TimelineElement {
75
76
  export type ZoomMode = "fit" | "manual";
76
77
  type TimelineTool = "select" | "razor";
77
78
 
79
+ export interface SelectElementOptions {
80
+ preserveSet?: boolean;
81
+ }
82
+
78
83
  function resolveElementSelection(
79
84
  ids: Iterable<string>,
80
85
  anchor?: string | null,
@@ -142,6 +147,25 @@ interface PlayerState {
142
147
 
143
148
  /** Multi-select: additional selected elements beyond selectedElementId. */
144
149
  selectedElementIds: Set<string>;
150
+ clearSelectedElementIds: () => void;
151
+ /** Replace the whole multi-selection at once (marquee live updates). */
152
+ setSelectedElementIds: (ids: Set<string>) => void;
153
+ /** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
154
+ timelineSnapEnabled: boolean;
155
+ setTimelineSnapEnabled: (enabled: boolean) => void;
156
+ /**
157
+ * Pin the timeline zoom to its current visual scale before a duration-changing
158
+ * edit, so a subsequent duration change (which recomputes fit-pps) stops
159
+ * rescaling every clip. No-op once already pinned (mode is "manual").
160
+ */
161
+ pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void;
162
+ /**
163
+ * The timeline's live pixels-per-second + fit basis, published by <Timeline> on
164
+ * every render. Non-reactive scratch state (never read as a render input).
165
+ */
166
+ timelinePps: number;
167
+ timelineFitPps: number;
168
+ setTimelineScale: (pps: number, fitPps: number) => void;
145
169
  setSelection: (ids: Iterable<string>, anchor?: string | null) => void;
146
170
  addSelectedElementId: (id: string) => void;
147
171
  toggleSelectedElementId: (id: string) => void;
@@ -160,7 +184,7 @@ interface PlayerState {
160
184
  setTimelineReady: (ready: boolean) => void;
161
185
  setBeatDragging: (dragging: boolean) => void;
162
186
  setElements: (elements: TimelineElement[]) => void;
163
- setSelectedElementId: (id: string | null) => void;
187
+ setSelectedElementId: (id: string | null, options?: SelectElementOptions) => void;
164
188
  /** Move the selection anchor within an active multi-selection without collapsing it. */
165
189
  setSelectionAnchor: (id: string | null) => void;
166
190
  updateElement: (
@@ -260,6 +284,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
260
284
  loopEnabled: false,
261
285
  zoomMode: "fit",
262
286
  manualZoomPercent: 100,
287
+ timelinePps: 100,
288
+ timelineFitPps: 100,
263
289
  inPoint: null,
264
290
  outPoint: null,
265
291
 
@@ -383,6 +409,32 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
383
409
  },
384
410
  setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
385
411
  setZoomMode: (mode) => set({ zoomMode: mode }),
412
+ clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
413
+ setSelectedElementIds: (ids: Set<string>) => set({ selectedElementIds: new Set(ids) }),
414
+ timelineSnapEnabled: readStudioUiPreferences().timelineSnapEnabled ?? true,
415
+ setTimelineSnapEnabled: (enabled) => {
416
+ writeStudioUiPreferences({ timelineSnapEnabled: enabled });
417
+ set({ timelineSnapEnabled: enabled });
418
+ },
419
+ pinTimelineZoom: (currentPixelsPerSecond, fitPixelsPerSecond) =>
420
+ set((s) => {
421
+ // Already pinned (or the user manually zoomed) — never clobber that.
422
+ if (s.zoomMode === "manual") return {};
423
+ const percent = computePinnedZoomPercent(currentPixelsPerSecond, fitPixelsPerSecond);
424
+ writeStudioUiPreferences({
425
+ timelineZoomMode: "manual",
426
+ timelineManualZoomPercent: percent,
427
+ });
428
+ return { zoomMode: "manual", manualZoomPercent: percent };
429
+ }),
430
+ setTimelineScale: (pps, fitPps) => {
431
+ // Non-reactive publish: mutate in place + reuse the same object identity so no
432
+ // subscriber re-renders (these fields are never a render input, only read
433
+ // imperatively before pinning).
434
+ const state = get();
435
+ state.timelinePps = pps;
436
+ state.timelineFitPps = fitPps;
437
+ },
386
438
  setInPoint: (time) =>
387
439
  set((state) => {
388
440
  const t = time !== null && Number.isFinite(time) ? time : null;
@@ -414,9 +466,16 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
414
466
  // A genuine single selection: always collapse the set to just this element. User
415
467
  // intent (timeline click, preview click via applyDomSelection) flows here; DOM sync
416
468
  // echoes that must preserve a group go through setSelectionAnchor instead.
417
- setSelectedElementId: (id) =>
469
+ setSelectedElementId: (id, options) =>
418
470
  set((s) => {
419
- const selectedElementIds = id ? new Set([id]) : new Set<string>();
471
+ const preserveSet = Boolean(options?.preserveSet && id && s.selectedElementIds.has(id));
472
+ const selectedElementIds = preserveSet
473
+ ? new Set(s.selectedElementIds)
474
+ : options?.preserveSet
475
+ ? new Set<string>()
476
+ : id
477
+ ? new Set([id])
478
+ : new Set<string>();
420
479
  // Selecting a different element drops any active keyframe selection — otherwise
421
480
  // a stale activeKeyframePct from a prior diamond click would force the next drag
422
481
  // to "modify" a keyframe on the new element. A diamond click sets the pct AFTER