@hyperframes/studio 0.7.55 → 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 (237) hide show
  1. package/dist/assets/{index-BXaqaVKt.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CPetwHFV.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 +40414 -37680
  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/DomEditCropHandles.test.tsx +74 -3
  21. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  22. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  23. package/src/components/editor/DomEditOverlay.tsx +158 -126
  24. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  25. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  26. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  27. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  28. package/src/components/editor/LayersPanel.test.ts +66 -2
  29. package/src/components/editor/LayersPanel.tsx +43 -1
  30. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  31. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  32. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  33. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  34. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  35. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  36. package/src/components/editor/domEditNudge.test.ts +80 -0
  37. package/src/components/editor/domEditNudge.ts +44 -0
  38. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  39. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  40. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  41. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  42. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  43. package/src/components/editor/domEditResizeLocal.ts +125 -0
  44. package/src/components/editor/domEditingDom.ts +1 -1
  45. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  46. package/src/components/editor/manualOffsetDrag.ts +55 -12
  47. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  48. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  49. package/src/components/editor/resizeDraft.ts +108 -0
  50. package/src/components/editor/snapEngine.test.ts +12 -78
  51. package/src/components/editor/snapEngine.ts +13 -53
  52. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  53. package/src/components/editor/useDomEditNudge.ts +254 -0
  54. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  55. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  56. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  57. package/src/components/nle/NLEContext.test.ts +144 -0
  58. package/src/components/nle/NLEContext.tsx +328 -0
  59. package/src/components/nle/NLEPreview.tsx +1 -1
  60. package/src/components/nle/PreviewOverlays.tsx +240 -0
  61. package/src/components/nle/PreviewPane.tsx +163 -0
  62. package/src/components/nle/TimelinePane.test.ts +60 -0
  63. package/src/components/nle/TimelinePane.tsx +293 -0
  64. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  65. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  66. package/src/components/nle/useCompositionStack.ts +17 -9
  67. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  68. package/src/components/sidebar/AssetCard.tsx +326 -0
  69. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  70. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  71. package/src/components/sidebar/AssetsTab.tsx +71 -206
  72. package/src/components/sidebar/AudioRow.tsx +39 -2
  73. package/src/components/sidebar/BlocksTab.tsx +8 -1
  74. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  75. package/src/components/sidebar/assetHelpers.ts +29 -0
  76. package/src/contexts/DomEditContext.tsx +9 -0
  77. package/src/contexts/StudioContext.tsx +9 -8
  78. package/src/contexts/TimelineEditContext.tsx +3 -7
  79. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  80. package/src/hooks/domEditCommitTypes.ts +12 -1
  81. package/src/hooks/domSelectionTestHarness.ts +14 -0
  82. package/src/hooks/gestureTransaction.test.ts +311 -0
  83. package/src/hooks/gestureTransaction.ts +199 -0
  84. package/src/hooks/gsapDragCommit.test.ts +141 -41
  85. package/src/hooks/gsapDragCommit.ts +64 -74
  86. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  87. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  88. package/src/hooks/gsapResizeIntercept.ts +38 -10
  89. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  90. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  91. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  92. package/src/hooks/gsapShared.test.ts +19 -1
  93. package/src/hooks/gsapShared.ts +14 -0
  94. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  95. package/src/hooks/timelineEditingGsap.ts +14 -0
  96. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  97. package/src/hooks/timelineEditingHelpers.ts +22 -48
  98. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  99. package/src/hooks/timelineMoveAdapter.ts +37 -0
  100. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  101. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  102. package/src/hooks/useAppHotkeys.ts +16 -25
  103. package/src/hooks/useBlockHandlers.ts +55 -27
  104. package/src/hooks/useContextMenuDismiss.ts +31 -7
  105. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  106. package/src/hooks/useDomEditCommits.ts +100 -3
  107. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  108. package/src/hooks/useDomEditSession.ts +2 -2
  109. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  110. package/src/hooks/useDomEditTextCommits.ts +8 -3
  111. package/src/hooks/useDomEditWiring.ts +7 -9
  112. package/src/hooks/useDomGeometryCommits.ts +17 -2
  113. package/src/hooks/useDomSelection.test.ts +2 -36
  114. package/src/hooks/useDomSelection.ts +81 -51
  115. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  116. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  117. package/src/hooks/useElementLifecycleOps.ts +40 -71
  118. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  119. package/src/hooks/useEnableKeyframes.ts +73 -29
  120. package/src/hooks/useGestureCommit.test.tsx +130 -0
  121. package/src/hooks/useGestureCommit.ts +32 -8
  122. package/src/hooks/useGroupCommits.ts +7 -28
  123. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  124. package/src/hooks/useGsapAwareEditing.ts +115 -20
  125. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  126. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  127. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  128. package/src/hooks/useGsapScriptCommits.ts +134 -50
  129. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  130. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  131. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  132. package/src/hooks/usePersistentEditHistory.ts +92 -58
  133. package/src/hooks/usePreviewPersistence.ts +25 -7
  134. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  135. package/src/hooks/useRazorSplit.test.ts +269 -0
  136. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  137. package/src/hooks/useRazorSplit.ts +107 -36
  138. package/src/hooks/useRenderClipContent.ts +24 -6
  139. package/src/hooks/useStudioContextValue.ts +15 -5
  140. package/src/hooks/useStudioUrlState.ts +2 -4
  141. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  142. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  143. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  144. package/src/index.ts +1 -1
  145. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  146. package/src/player/components/ImageThumbnail.tsx +160 -0
  147. package/src/player/components/PlayheadIndicator.tsx +50 -5
  148. package/src/player/components/Timeline.test.ts +102 -19
  149. package/src/player/components/Timeline.tsx +194 -194
  150. package/src/player/components/TimelineCanvas.tsx +207 -521
  151. package/src/player/components/TimelineClip.tsx +4 -1
  152. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  153. package/src/player/components/TimelineEmptyState.tsx +3 -1
  154. package/src/player/components/TimelineLanes.tsx +487 -0
  155. package/src/player/components/TimelineOverlays.tsx +122 -0
  156. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  157. package/src/player/components/VideoThumbnail.tsx +28 -7
  158. package/src/player/components/thumbnailUtils.test.ts +127 -0
  159. package/src/player/components/thumbnailUtils.ts +54 -0
  160. package/src/player/components/timelineCallbacks.ts +18 -18
  161. package/src/player/components/timelineClipChildren.tsx +39 -0
  162. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  163. package/src/player/components/timelineClipDragCommit.ts +401 -0
  164. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  165. package/src/player/components/timelineClipDragPreview.ts +323 -38
  166. package/src/player/components/timelineClipDragTypes.ts +65 -0
  167. package/src/player/components/timelineCollision.test.ts +42 -2
  168. package/src/player/components/timelineDragDrop.ts +69 -60
  169. package/src/player/components/timelineEditCapabilities.ts +52 -0
  170. package/src/player/components/timelineEditing.test.ts +1 -1
  171. package/src/player/components/timelineEditing.ts +76 -59
  172. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  173. package/src/player/components/timelineGroupEditing.ts +133 -0
  174. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  175. package/src/player/components/timelineLayout.test.ts +95 -23
  176. package/src/player/components/timelineLayout.ts +120 -61
  177. package/src/player/components/timelineMarquee.test.ts +197 -0
  178. package/src/player/components/timelineMarquee.ts +112 -0
  179. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  180. package/src/player/components/timelineTheme.ts +6 -1
  181. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  182. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  183. package/src/player/components/useTimelineClipDrag.ts +380 -386
  184. package/src/player/components/useTimelineEditPinning.ts +121 -0
  185. package/src/player/components/useTimelineGeometry.ts +129 -0
  186. package/src/player/components/useTimelinePlayhead.ts +4 -16
  187. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  188. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  189. package/src/player/components/useTimelineStackingSync.ts +83 -0
  190. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  191. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  192. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  193. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  194. package/src/player/index.ts +3 -2
  195. package/src/player/lib/time.test.ts +19 -19
  196. package/src/player/lib/time.ts +6 -2
  197. package/src/player/lib/timelineDOM.test.ts +25 -98
  198. package/src/player/lib/timelineDOM.ts +12 -81
  199. package/src/player/lib/timelineElementHelpers.ts +54 -5
  200. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  201. package/src/player/store/playerStore.ts +62 -3
  202. package/src/styles/studio.css +27 -0
  203. package/src/utils/assetPreviewStore.ts +33 -0
  204. package/src/utils/blockInstaller.ts +10 -2
  205. package/src/utils/editHistory.test.ts +35 -0
  206. package/src/utils/gsapSoftReload.test.ts +119 -1
  207. package/src/utils/gsapSoftReload.ts +153 -0
  208. package/src/utils/mediaTypes.ts +3 -2
  209. package/src/utils/resizeDebug.ts +55 -0
  210. package/src/utils/rootDuration.test.ts +90 -1
  211. package/src/utils/rootDuration.ts +76 -13
  212. package/src/utils/sdkCutover.ts +4 -0
  213. package/src/utils/studioFileHistory.ts +37 -7
  214. package/src/utils/studioHelpers.ts +62 -0
  215. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  216. package/src/utils/studioPreviewHelpers.ts +10 -0
  217. package/src/utils/studioTelemetry.ts +4 -1
  218. package/src/utils/studioUrlState.test.ts +0 -1
  219. package/src/utils/timelineAssetDrop.ts +62 -0
  220. package/src/utils/timelineDiscovery.ts +0 -17
  221. package/dist/assets/index-BRwkMj0w.js +0 -423
  222. package/dist/assets/index-Dq7FEg0K.css +0 -1
  223. package/dist/chunk-SOTCF4DF.js.map +0 -1
  224. package/src/components/StudioPreviewArea.tsx +0 -500
  225. package/src/components/nle/NLELayout.test.ts +0 -12
  226. package/src/components/nle/NLELayout.tsx +0 -591
  227. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  228. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  229. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  230. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  231. package/src/player/components/timelineSnapTargets.ts +0 -164
  232. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  233. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  234. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  235. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  236. package/src/utils/timelineDiscovery.test.ts +0 -90
  237. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -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
@@ -141,6 +141,22 @@ body {
141
141
  opacity: 1;
142
142
  }
143
143
 
144
+ /* Audio clips read as a distinct kind — a persistent violet tint vs the teal-
145
+ accented visual clips — so the audio zone at the bottom is visually separate
146
+ at a glance (the timeline's kinds: visual on top, audio below). */
147
+ .timeline-clip.is-audio {
148
+ background-color: rgba(167, 139, 250, 0.16);
149
+ border-color: rgba(167, 139, 250, 0.4);
150
+ }
151
+
152
+ .timeline-clip.is-audio.is-hovered {
153
+ background-color: rgba(167, 139, 250, 0.24);
154
+ }
155
+
156
+ .timeline-clip.is-audio.is-dragging {
157
+ background-color: rgba(60, 52, 84, 0.96);
158
+ }
159
+
144
160
  .timeline-clip.is-hovered {
145
161
  background-color: rgba(255, 255, 255, 0.09);
146
162
  }
@@ -158,9 +174,20 @@ body {
158
174
  }
159
175
 
160
176
  .timeline-clip.is-dragging {
177
+ /* Solid background so the picked-up clip reads clearly while dragging — audio
178
+ clips (waveform drawn on a separate layer) would otherwise look transparent. */
179
+ background-color: rgba(38, 42, 52, 0.96);
161
180
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
162
181
  }
163
182
 
183
+ /* Keep the white selection outline while dragging (a plain box-shadow alone gets
184
+ overridden by .is-dragging's drop shadow, so re-state both together). */
185
+ .timeline-clip.is-selected.is-dragging {
186
+ box-shadow:
187
+ 0 0 0 1.5px rgba(255, 255, 255, 0.85),
188
+ 0 8px 24px rgba(0, 0, 0, 0.4);
189
+ }
190
+
164
191
  .timeline-clip[data-active].is-selected.is-dragging {
165
192
  box-shadow:
166
193
  0 0 0 1.5px rgba(255, 255, 255, 0.85),
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Tiny Zustand slice that carries the "asset preview overlay" state.
3
+ *
4
+ * When a user clicks an asset card that has NOT yet been added to the
5
+ * timeline the overlay fires up: a dark scrim + centered media element
6
+ * (img / video / audio) + filename label rendered inside PreviewPane.
7
+ *
8
+ * State lives here so AssetsTab (sidebar) and PreviewPane (preview column)
9
+ * can communicate without prop-drilling through the multi-layer EditorShell
10
+ * tree. The store is project-scoped: NLEProvider (NLEContext.tsx) clears it
11
+ * whenever `projectId` changes, so a preview opened in one project can't
12
+ * bleed into another (the overlay itself stays mounted across project
13
+ * switches — EditorShell isn't keyed by projectId).
14
+ */
15
+ import { create } from "zustand";
16
+
17
+ interface AssetPreviewState {
18
+ /** Project-relative asset path currently being previewed, or null. */
19
+ previewAsset: string | null;
20
+ /** projectId for which the preview was opened (used to build the serve URL). */
21
+ previewProjectId: string | null;
22
+ /** Open a media preview for the given asset. */
23
+ setPreviewAsset: (asset: string, projectId: string) => void;
24
+ /** Close the preview overlay. */
25
+ clearPreviewAsset: () => void;
26
+ }
27
+
28
+ export const useAssetPreviewStore = create<AssetPreviewState>((set) => ({
29
+ previewAsset: null,
30
+ previewProjectId: null,
31
+ setPreviewAsset: (asset, projectId) => set({ previewAsset: asset, previewProjectId: projectId }),
32
+ clearPreviewAsset: () => set({ previewAsset: null, previewProjectId: null }),
33
+ }));
@@ -2,9 +2,10 @@ import type { RegistryItem } from "@hyperframes/core/registry";
2
2
  import type { TimelineElement } from "../player";
3
3
  import {
4
4
  insertTimelineAssetIntoSource,
5
- resolveTimelineAssetInitialGeometry,
5
+ resolveTimelineAssetCompositionSize,
6
6
  } from "./timelineAssetDrop";
7
7
  import { collectHtmlIds } from "./studioHelpers";
8
+ import { generateId } from "./generateId";
8
9
  import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
9
10
  import { saveProjectFilesWithHistory } from "./studioFileHistory";
10
11
  import type { EditHistoryKind } from "./editHistory";
@@ -120,7 +121,9 @@ export async function addBlockToProject(
120
121
  );
121
122
 
122
123
  const isBlock = block.type === "hyperframes:block";
123
- const hostDims = resolveTimelineAssetInitialGeometry(originalContent);
124
+ const { width: hostWidth, height: hostHeight } =
125
+ resolveTimelineAssetCompositionSize(originalContent);
126
+ const hostDims = { left: 0, top: 0, width: hostWidth, height: hostHeight };
124
127
 
125
128
  const currentTime = opts.currentTime ?? 0;
126
129
  const start = placement
@@ -152,6 +155,11 @@ export async function addBlockToProject(
152
155
 
153
156
  const subCompHtml = [
154
157
  `<div`,
158
+ // A stable id (+ hf-id) is what authored sub-comps carry; without it the
159
+ // timeline can't dedup the host and renders duplicate clips that multiply
160
+ // on every interaction. Matches the authored-comp shape.
161
+ ` id="${compId}"`,
162
+ ` data-hf-id="hf-${generateId()}"`,
155
163
  ` data-composition-id="${compId}"`,
156
164
  ` data-composition-src="${compositionFile}"`,
157
165
  ` data-start="${formatTimelineAttributeNumber(start)}"`,
@@ -209,6 +209,41 @@ describe("edit history", () => {
209
209
  expect(state.undo[0].files["index.html"].after).toBe("c");
210
210
  });
211
211
 
212
+ it("merges a lane-change move with its z-reorder past the default window via entry coalesceMs", () => {
213
+ // The z entry records only after the move persist's round-trip — often >300ms.
214
+ // Both sides pass coalesceMs: 5000 with the shared gesture key so the pair
215
+ // still folds into ONE undo step.
216
+ const move = buildEditHistoryEntry({
217
+ projectId: "project-1",
218
+ label: "Move timeline clips",
219
+ kind: "timeline",
220
+ coalesceKey: "clip-lane-move:1",
221
+ coalesceMs: 5000,
222
+ files: { "index.html": { before: "a", after: "b" } },
223
+ now: 100,
224
+ id: "move-entry",
225
+ });
226
+ const zReorder = buildEditHistoryEntry({
227
+ projectId: "project-1",
228
+ label: "Reorder layers",
229
+ kind: "manual",
230
+ coalesceKey: "clip-lane-move:1",
231
+ coalesceMs: 5000,
232
+ files: { "index.html": { before: "b", after: "c" } },
233
+ now: 500,
234
+ id: "z-entry",
235
+ });
236
+
237
+ const state = pushEditHistoryEntry(
238
+ pushEditHistoryEntry(createEmptyEditHistory(), move),
239
+ zReorder,
240
+ );
241
+
242
+ expect(state.undo).toHaveLength(1);
243
+ expect(state.undo[0].files["index.html"].before).toBe("a");
244
+ expect(state.undo[0].files["index.html"].after).toBe("c");
245
+ });
246
+
212
247
  it("folds a slow GSAP follow-up into the timing edit via a per-entry coalesceMs override", () => {
213
248
  const timing = buildEditHistoryEntry({
214
249
  projectId: "project-1",