@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
@@ -159,7 +159,7 @@ type TimelineRecordEdit = NonNullable<
159
159
  function renderTimelineEditingHookWithLifecycle(input: {
160
160
  timelineElements: TimelineElement[];
161
161
  iframe: HTMLIFrameElement;
162
- commitPositionPatchToHtml: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<void>>>;
162
+ commitDomEditPatchBatches: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<void>>>;
163
163
  }): {
164
164
  move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
165
165
  unmount: () => void;
@@ -176,7 +176,7 @@ function renderTimelineEditingHookWithLifecycle(input: {
176
176
  projectIdRef: { current: "p1" },
177
177
  reloadPreview: vi.fn(),
178
178
  clearDomSelection: vi.fn(),
179
- commitPositionPatchToHtml: input.commitPositionPatchToHtml,
179
+ commitDomEditPatchBatches: input.commitDomEditPatchBatches,
180
180
  });
181
181
  const commitRef = useRef(lifecycle.handleDomZIndexReorderCommit);
182
182
  commitRef.current = lifecycle.handleDomZIndexReorderCommit;
@@ -481,11 +481,11 @@ describe("useTimelineEditing timeline z-index reorder", () => {
481
481
  ]);
482
482
  const front = timelineElement({ id: "front", track: 0, zIndex: 0 });
483
483
  const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
484
- const commitPositionPatchToHtml = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
484
+ const commitDomEditPatchBatches = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
485
485
  const { move, unmount } = renderTimelineEditingHookWithLifecycle({
486
486
  timelineElements: [front, back],
487
487
  iframe,
488
- commitPositionPatchToHtml,
488
+ commitDomEditPatchBatches,
489
489
  });
490
490
 
491
491
  await act(async () => {
@@ -501,8 +501,11 @@ describe("useTimelineEditing timeline z-index reorder", () => {
501
501
  await flushAsyncWork();
502
502
  });
503
503
 
504
- expect(commitPositionPatchToHtml).toHaveBeenCalled();
505
- expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([
504
+ expect(commitDomEditPatchBatches).toHaveBeenCalled();
505
+ const batch = commitDomEditPatchBatches.mock.calls[0]![0] as Array<{
506
+ patches: Array<{ operations: unknown[] }>;
507
+ }>;
508
+ expect(batch[0]?.patches[0]?.operations).toEqual([
506
509
  { type: "inline-style", property: "z-index", value: "2" },
507
510
  { type: "inline-style", property: "position", value: "relative" },
508
511
  ]);
@@ -522,14 +525,13 @@ describe("useTimelineEditing timeline z-index reorder", () => {
522
525
  { ...back, hasExplicitZIndex: false },
523
526
  ]);
524
527
  const saveError = new Error("save failed");
525
- const commitPositionPatchToHtml = vi
528
+ const commitDomEditPatchBatches = vi
526
529
  .fn<(...args: unknown[]) => Promise<void>>()
527
- .mockResolvedValueOnce(undefined)
528
530
  .mockRejectedValueOnce(saveError);
529
531
  const { move, unmount } = renderTimelineEditingHookWithLifecycle({
530
532
  timelineElements: [front, back],
531
533
  iframe,
532
- commitPositionPatchToHtml,
534
+ commitDomEditPatchBatches,
533
535
  });
534
536
  const doc = iframe.contentDocument;
535
537
  if (!doc) throw new Error("Expected iframe document");
@@ -576,29 +578,24 @@ describe("useTimelineEditing timeline z-index reorder", () => {
576
578
  unmount();
577
579
  });
578
580
 
579
- it("waits for every lifecycle z-index save before resolving a reorder", async () => {
581
+ it("waits for the lifecycle z-index batch before resolving a reorder", async () => {
580
582
  const iframe = createPreviewIframe([
581
583
  { id: "front", track: 0, style: "position: relative; z-index: 1" },
582
584
  { id: "back", track: 1, style: "position: relative; z-index: 0" },
583
585
  ]);
584
586
  const front = timelineElement({ id: "front", track: 0, zIndex: 1 });
585
587
  const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
586
- let releaseFirst!: () => void;
587
- let releaseSecond!: () => void;
588
- const firstSave = new Promise<void>((resolve) => {
589
- releaseFirst = resolve;
588
+ let releaseBatch!: () => void;
589
+ const batchSave = new Promise<void>((resolve) => {
590
+ releaseBatch = resolve;
590
591
  });
591
- const secondSave = new Promise<void>((resolve) => {
592
- releaseSecond = resolve;
593
- });
594
- const commitPositionPatchToHtml = vi
592
+ const commitDomEditPatchBatches = vi
595
593
  .fn<(...args: unknown[]) => Promise<void>>()
596
- .mockReturnValueOnce(firstSave)
597
- .mockReturnValueOnce(secondSave);
594
+ .mockReturnValueOnce(batchSave);
598
595
  const { move, unmount } = renderTimelineEditingHookWithLifecycle({
599
596
  timelineElements: [front, back],
600
597
  iframe,
601
- commitPositionPatchToHtml,
598
+ commitDomEditPatchBatches,
602
599
  });
603
600
  let settled = false;
604
601
 
@@ -621,17 +618,11 @@ describe("useTimelineEditing timeline z-index reorder", () => {
621
618
  await flushAsyncWork();
622
619
  });
623
620
 
624
- expect(commitPositionPatchToHtml).toHaveBeenCalledTimes(2);
625
- expect(settled).toBe(false);
626
-
627
- await act(async () => {
628
- releaseFirst();
629
- await flushAsyncWork();
630
- });
621
+ expect(commitDomEditPatchBatches).toHaveBeenCalledTimes(1);
631
622
  expect(settled).toBe(false);
632
623
 
633
624
  await act(async () => {
634
- releaseSecond();
625
+ releaseBatch();
635
626
  await movePromise;
636
627
  await flushAsyncWork();
637
628
  });
@@ -22,6 +22,7 @@ export type TimelineZIndexReorderCommit = (
22
22
  sourceFile: string;
23
23
  key?: string;
24
24
  }>,
25
+ coalesceKey?: string,
25
26
  ) => Promise<void>;
26
27
 
27
28
  export interface UseTimelineEditingOptions {
@@ -43,3 +44,8 @@ export interface UseTimelineEditingOptions {
43
44
  forceReloadSdkSession?: () => void;
44
45
  handleDomZIndexReorderCommitRef?: MutableRefObject<TimelineZIndexReorderCommit | null>;
45
46
  }
47
+
48
+ export type TimelineFileDropHandler = (
49
+ files: File[],
50
+ placement?: { start: number; track: number },
51
+ ) => Promise<void>;
@@ -21,6 +21,7 @@ import {
21
21
  export interface TimelineGroupMoveChange {
22
22
  element: TimelineElement;
23
23
  start: number;
24
+ track?: number;
24
25
  }
25
26
 
26
27
  export interface TimelineGroupResizeChange {
@@ -152,9 +153,13 @@ export function useTimelineGroupEditing({
152
153
  (changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => {
153
154
  if (changes.length === 0) return Promise.resolve();
154
155
  for (const change of changes) {
155
- patchIframeDomTiming(previewIframeRef.current, change.element, [
156
+ const attrs: Array<[string, string]> = [
156
157
  ["data-start", formatTimelineAttributeNumber(change.start)],
157
- ]);
158
+ ];
159
+ if (change.track != null) {
160
+ attrs.push(["data-track-index", formatTimelineAttributeNumber(change.track)]);
161
+ }
162
+ patchIframeDomTiming(previewIframeRef.current, change.element, attrs);
158
163
  }
159
164
 
160
165
  const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration));
@@ -169,7 +174,10 @@ export function useTimelineGroupEditing({
169
174
  : null,
170
175
  );
171
176
  const canUseSdk =
172
- !needsExtension && sharedPath !== null && sdkChanges.every((change) => change !== null);
177
+ !needsExtension &&
178
+ sharedPath !== null &&
179
+ changes.every((change) => change.track == null) &&
180
+ sdkChanges.every((change) => change !== null);
173
181
  if (canUseSdk) {
174
182
  const handled = await sdkTimingBatchPersist(
175
183
  sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
@@ -194,7 +202,13 @@ export function useTimelineGroupEditing({
194
202
  changes.map((change) => ({
195
203
  element: change.element,
196
204
  buildPatches: (original, target) =>
197
- buildTimelineMoveTimingPatch(original, target, change.start, change.element.duration),
205
+ buildTimelineMoveTimingPatch(
206
+ original,
207
+ target,
208
+ change.start,
209
+ change.element.duration,
210
+ change.track,
211
+ ),
198
212
  })),
199
213
  coalesceKey,
200
214
  );
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // NLE Layout
2
- export { NLELayout } from "./components/nle/NLELayout";
2
+ export { EditorShell } from "./components/EditorShell";
3
3
  export { NLEPreview } from "./components/nle/NLEPreview";
4
4
  export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
5
5
  export type { CompositionLevel } from "./components/nle/CompositionBreadcrumb";
@@ -0,0 +1,173 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { ImageThumbnail } from "./ImageThumbnail";
7
+
8
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
9
+ configurable: true,
10
+ value: true,
11
+ });
12
+
13
+ // --- Observer stubs: fire "intersecting" immediately on observe ---
14
+ class MockIntersectionObserver {
15
+ private cb: IntersectionObserverCallback;
16
+ constructor(cb: IntersectionObserverCallback) {
17
+ this.cb = cb;
18
+ }
19
+ observe() {
20
+ this.cb(
21
+ [{ isIntersecting: true } as IntersectionObserverEntry],
22
+ this as unknown as IntersectionObserver,
23
+ );
24
+ }
25
+ disconnect() {}
26
+ unobserve() {}
27
+ takeRecords(): IntersectionObserverEntry[] {
28
+ return [];
29
+ }
30
+ }
31
+
32
+ class MockResizeObserver {
33
+ observe() {}
34
+ disconnect() {}
35
+ unobserve() {}
36
+ }
37
+
38
+ // --- Image stub: captures instances so tests fire load/error deterministically ---
39
+ class MockImage {
40
+ static instances: MockImage[] = [];
41
+ onload: (() => void) | null = null;
42
+ onerror: (() => void) | null = null;
43
+ naturalWidth = 0;
44
+ naturalHeight = 0;
45
+ src = "";
46
+ constructor() {
47
+ MockImage.instances.push(this);
48
+ }
49
+ }
50
+
51
+ const originalIO = globalThis.IntersectionObserver;
52
+ const originalRO = globalThis.ResizeObserver;
53
+ const originalImage = globalThis.Image;
54
+
55
+ let host: HTMLDivElement;
56
+ let root: Root | null = null;
57
+
58
+ beforeEach(() => {
59
+ globalThis.IntersectionObserver =
60
+ MockIntersectionObserver as unknown as typeof IntersectionObserver;
61
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
62
+ globalThis.Image = MockImage as unknown as typeof Image;
63
+ MockImage.instances = [];
64
+ host = document.createElement("div");
65
+ document.body.append(host);
66
+ });
67
+
68
+ afterEach(() => {
69
+ act(() => root?.unmount());
70
+ root = null;
71
+ globalThis.IntersectionObserver = originalIO;
72
+ globalThis.ResizeObserver = originalRO;
73
+ globalThis.Image = originalImage;
74
+ document.body.innerHTML = "";
75
+ });
76
+
77
+ function render(props: { imageSrc: string; label?: string; labelColor?: string }) {
78
+ root = createRoot(host);
79
+ act(() => {
80
+ root!.render(
81
+ <ImageThumbnail
82
+ imageSrc={props.imageSrc}
83
+ label={props.label ?? ""}
84
+ labelColor={props.labelColor ?? "#fff"}
85
+ />,
86
+ );
87
+ });
88
+ }
89
+
90
+ function lastProbe(): MockImage {
91
+ const probe = MockImage.instances.at(-1);
92
+ expect(probe).toBeDefined();
93
+ return probe!;
94
+ }
95
+
96
+ /** Assert at least one tile rendered and the first tile serves `expectedSrc`. */
97
+ function expectFirstTileSrc(expectedSrc: string): void {
98
+ const imgs = [...host.querySelectorAll("img")];
99
+ expect(imgs.length).toBeGreaterThanOrEqual(1);
100
+ expect(imgs[0].getAttribute("src")).toBe(expectedSrc);
101
+ }
102
+
103
+ describe("ImageThumbnail", () => {
104
+ it("shows the loading shimmer before the image resolves", () => {
105
+ render({ imageSrc: "/api/projects/p/preview/assets/pic.png" });
106
+ expect(host.querySelector(".animate-pulse")).not.toBeNull();
107
+ expect(host.querySelectorAll("img").length).toBe(0);
108
+ });
109
+
110
+ it("probes the resolved src and renders repeated object-cover tiles on load", () => {
111
+ render({ imageSrc: "/api/projects/p/preview/assets/pic.png" });
112
+ const probe = lastProbe();
113
+ expect(probe.src).toBe("/api/projects/p/preview/assets/pic.png");
114
+
115
+ act(() => {
116
+ probe.naturalWidth = 1920;
117
+ probe.naturalHeight = 1080;
118
+ probe.onload?.();
119
+ });
120
+
121
+ const imgs = [...host.querySelectorAll("img")];
122
+ expect(imgs.length).toBeGreaterThanOrEqual(1);
123
+ for (const img of imgs) {
124
+ expect(img.getAttribute("src")).toBe("/api/projects/p/preview/assets/pic.png");
125
+ expect(img.className).toContain("object-cover");
126
+ }
127
+ expect(host.querySelector(".animate-pulse")).toBeNull();
128
+ });
129
+
130
+ it("drops the shimmer and renders no tiles when a raster image fails to load", () => {
131
+ render({ imageSrc: "/api/projects/p/preview/assets/missing.png" });
132
+ act(() => lastProbe().onerror?.());
133
+ expect(host.querySelectorAll("img").length).toBe(0);
134
+ expect(host.querySelector(".animate-pulse")).toBeNull();
135
+ });
136
+
137
+ it("renders tiles at 16:9 when an SVG has no intrinsic dimensions (naturalWidth=0)", () => {
138
+ render({ imageSrc: "/api/projects/p/preview/assets/logo.svg" });
139
+ const probe = lastProbe();
140
+
141
+ act(() => {
142
+ // naturalWidth stays 0 — SVG with no width/height attribute
143
+ probe.onload?.();
144
+ });
145
+
146
+ expectFirstTileSrc("/api/projects/p/preview/assets/logo.svg");
147
+ expect(host.querySelector(".animate-pulse")).toBeNull();
148
+ });
149
+
150
+ it("renders SVG tiles at 16:9 fallback even when the probe fires onerror", () => {
151
+ // Some browser/sandbox environments fire onerror for SVGs even though the
152
+ // <img> element itself can render the file — we must not blank the strip.
153
+ render({ imageSrc: "/api/projects/p/preview/assets/icon.svg" });
154
+
155
+ act(() => lastProbe().onerror?.());
156
+
157
+ expectFirstTileSrc("/api/projects/p/preview/assets/icon.svg");
158
+ expect(host.querySelector(".animate-pulse")).toBeNull();
159
+ });
160
+
161
+ it("renders the label above the strip when provided", () => {
162
+ render({ imageSrc: "/x.png", label: "hero", labelColor: "#abc" });
163
+ act(() => {
164
+ const probe = lastProbe();
165
+ probe.naturalWidth = 100;
166
+ probe.naturalHeight = 100;
167
+ probe.onload?.();
168
+ });
169
+ const label = [...host.querySelectorAll("span")].find((s) => s.textContent === "hero");
170
+ expect(label).toBeDefined();
171
+ expect(label!.closest(".z-10")).not.toBeNull();
172
+ });
173
+ });
@@ -0,0 +1,160 @@
1
+ import { memo, useRef, useState, useCallback, useEffect } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
3
+ import { computeThumbnailStrip } from "./thumbnailUtils";
4
+
5
+ interface ImageThumbnailProps {
6
+ imageSrc: string;
7
+ label: string;
8
+ labelColor: string;
9
+ }
10
+
11
+ /**
12
+ * Renders a film-strip of a still image for a timeline clip. The image is a
13
+ * fixed-width tile (sized by its natural aspect ratio) repeated to fill the
14
+ * clip width — matching VideoThumbnail's visual pattern. Loading is lazy
15
+ * (IntersectionObserver) with the same shimmer fallback while decoding.
16
+ */
17
+ export const ImageThumbnail = memo(function ImageThumbnail({
18
+ imageSrc,
19
+ label,
20
+ labelColor,
21
+ }: ImageThumbnailProps) {
22
+ const [containerWidth, setContainerWidth] = useState(0);
23
+ const [visible, setVisible] = useState(false);
24
+ const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading");
25
+ const [aspect, setAspect] = useState(16 / 9);
26
+ const ioRef = useRef<IntersectionObserver | null>(null);
27
+ const roRef = useRef<ResizeObserver | null>(null);
28
+
29
+ const setContainerRef = useCallback((el: HTMLDivElement | null) => {
30
+ ioRef.current?.disconnect();
31
+ roRef.current?.disconnect();
32
+ if (!el) return;
33
+
34
+ const measured = el.parentElement?.clientWidth || el.clientWidth;
35
+ setContainerWidth(measured);
36
+
37
+ ioRef.current = new IntersectionObserver(
38
+ ([entry]) => {
39
+ if (entry.isIntersecting) {
40
+ setVisible(true);
41
+ ioRef.current?.disconnect();
42
+ }
43
+ },
44
+ { rootMargin: "200px" },
45
+ );
46
+ // fallow-ignore-next-line code-duplication
47
+ ioRef.current.observe(el);
48
+
49
+ const target = el.parentElement || el;
50
+ roRef.current = new ResizeObserver(([entry]) => {
51
+ setContainerWidth(entry.contentRect.width);
52
+ });
53
+ roRef.current.observe(target);
54
+ }, []);
55
+
56
+ useMountEffect(() => () => {
57
+ ioRef.current?.disconnect();
58
+ roRef.current?.disconnect();
59
+ });
60
+
61
+ // Probe the image once visible — measures the natural aspect ratio so the
62
+ // tile width matches, and flips to the error state (plain clip background)
63
+ // if the src can't load. The browser cache makes the tile <img>s free.
64
+ //
65
+ // SVG handling: SVGs without intrinsic width/height report naturalWidth=0 on
66
+ // load (treat as success with the 16:9 default aspect) and may fire onerror
67
+ // in some environments even though the file is valid and can be displayed —
68
+ // fall back to loaded-at-16:9 rather than hiding the strip entirely.
69
+ // eslint-disable-next-line no-restricted-syntax
70
+ useEffect(() => {
71
+ if (!visible) return;
72
+ let cancelled = false;
73
+ setStatus("loading");
74
+
75
+ const isSvg = /\.svg($|\?)/i.test(imageSrc);
76
+
77
+ const probe = new Image();
78
+ probe.onload = () => {
79
+ if (cancelled) return;
80
+ if (probe.naturalWidth > 0 && probe.naturalHeight > 0) {
81
+ setAspect(probe.naturalWidth / probe.naturalHeight);
82
+ }
83
+ // naturalWidth===0 (e.g. SVG with no intrinsic dimensions) falls through
84
+ // to "loaded" with the default 16:9 aspect already set in state.
85
+ setStatus("loaded");
86
+ };
87
+ probe.onerror = () => {
88
+ if (cancelled) return;
89
+ // SVGs can fail the probe in certain browser/sandbox environments even
90
+ // though the <img> tiles themselves render fine (different security
91
+ // context). Show the strip at the 16:9 fallback rather than blanking.
92
+ if (isSvg) {
93
+ setStatus("loaded");
94
+ } else {
95
+ setStatus("error");
96
+ }
97
+ };
98
+ probe.src = imageSrc;
99
+
100
+ return () => {
101
+ cancelled = true;
102
+ probe.onload = null;
103
+ probe.onerror = null;
104
+ probe.src = "";
105
+ };
106
+ }, [visible, imageSrc]);
107
+
108
+ const { frameW, frameCount } = computeThumbnailStrip(containerWidth, aspect);
109
+
110
+ return (
111
+ <div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
112
+ {visible && status === "loaded" && (
113
+ <div className="absolute inset-0 flex">
114
+ {Array.from({ length: frameCount }).map((_, i) => (
115
+ <div
116
+ key={i}
117
+ className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
118
+ style={{ width: frameW }}
119
+ >
120
+ <img
121
+ src={imageSrc}
122
+ alt=""
123
+ draggable={false}
124
+ loading="lazy"
125
+ className="absolute inset-0 w-full h-full object-cover"
126
+ />
127
+ </div>
128
+ ))}
129
+ </div>
130
+ )}
131
+
132
+ {visible && status === "loading" && (
133
+ <div
134
+ className="absolute inset-0 animate-pulse"
135
+ style={{
136
+ background:
137
+ "linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
138
+ }}
139
+ />
140
+ )}
141
+
142
+ {label && (
143
+ <div
144
+ className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
145
+ style={{
146
+ background:
147
+ "linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
148
+ }}
149
+ >
150
+ <span
151
+ className="text-[9px] font-semibold truncate block leading-tight"
152
+ style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
153
+ >
154
+ {label}
155
+ </span>
156
+ </div>
157
+ )}
158
+ </div>
159
+ );
160
+ });
@@ -2,20 +2,44 @@
2
2
  /**
3
3
  * Shared playhead visual used by TimelineCanvas (real playhead) and
4
4
  * TimelineEditorNotice (animated illustration).
5
+ *
6
+ * The vertical line + glow span the full track height; the grab-handle HEAD is
7
+ * `position: sticky; top: 0` so it pins to the top of the (vertically) scrolling
8
+ * track area — the ruler is sticky too, so the head stays visible and grabbable
9
+ * no matter how far the tracks are scrolled. The head is OUTLINE-only at rest and
10
+ * FILLED while the playhead is actively held/scrubbed (`scrubbing`).
5
11
  */
6
12
  interface PlayheadIndicatorProps {
7
13
  /** CSS color, defaults to the HF accent variable */
8
14
  color?: string;
9
15
  /** Glow shadow color, defaults to translucent accent */
10
16
  glowColor?: string;
17
+ /** Whether the playhead is being actively scrubbed — fills the head. */
18
+ scrubbing?: boolean;
19
+ /**
20
+ * When false, the head chip is rendered in normal flow (top:1) instead of the
21
+ * sticky pin — used by the static illustration where there is no scroll area.
22
+ */
23
+ stickyHead?: boolean;
11
24
  }
12
25
 
13
26
  export function PlayheadIndicator({
14
27
  color = "var(--hf-accent, #3CE6AC)",
15
28
  glowColor = "rgba(60,230,172,0.14)",
29
+ scrubbing = false,
30
+ stickyHead = true,
16
31
  }: PlayheadIndicatorProps) {
32
+ // Head chip dimensions — used to compute the centering offset and the
33
+ // point where the vertical line starts (so it begins at the head's bottom
34
+ // edge rather than running through the hollow diamond center).
35
+ const HEAD_W = 9;
36
+ const HEAD_H = 9;
37
+ // marginTop(1) + HEAD_H = where the line should start.
38
+ const HEAD_TOTAL_H = 1 + HEAD_H;
39
+
17
40
  return (
18
41
  <>
42
+ {/* Glow — spans full height, centered on the line. */}
19
43
  <div
20
44
  aria-hidden="true"
21
45
  className="absolute top-0 bottom-0"
@@ -26,23 +50,44 @@ export function PlayheadIndicator({
26
50
  background: `radial-gradient(closest-side, ${glowColor}, transparent)`,
27
51
  }}
28
52
  />
53
+ {/* Vertical line — starts at the bottom edge of the head chip so nothing
54
+ shows through the hollow diamond center. */}
29
55
  <div
30
- className="absolute top-0 bottom-0"
56
+ className="absolute bottom-0"
31
57
  style={{
32
58
  left: "50%",
59
+ top: HEAD_TOTAL_H,
33
60
  width: 1,
34
61
  marginLeft: -0.5,
35
62
  background: color,
36
63
  boxShadow: `0 0 6px ${glowColor}`,
37
64
  }}
38
65
  />
39
- <div className="absolute" style={{ left: "50%", top: 1, transform: "translateX(-50%)" }}>
66
+ {/* Head chip sticky so it pins to the ruler while tracks scroll.
67
+ Centering logic: wrapper width = HEAD_W (chip forces it). The line sits
68
+ at wrapper.left + HEAD_W/2 (left:"50%" of wrapper). The sticky element's
69
+ natural flow position is wrapper.left; so placing it there with no
70
+ horizontal translate puts its LEFT edge at wrapper.left and its CENTER
71
+ at wrapper.left + HEAD_W/2 — exactly on the line. */}
72
+ <div
73
+ className={stickyHead ? "sticky" : "absolute"}
74
+ style={{
75
+ left: 0,
76
+ top: stickyHead ? 0 : 1,
77
+ // Zero height keeps it from covering rows (sticky strip trick).
78
+ height: stickyHead ? 0 : undefined,
79
+ }}
80
+ >
40
81
  <div
41
82
  style={{
42
- width: 9,
43
- height: 9,
83
+ width: HEAD_W,
84
+ height: HEAD_H,
44
85
  borderRadius: 2,
45
- background: color,
86
+ marginTop: 1,
87
+ // Outline-only at rest, filled while scrubbing.
88
+ background: scrubbing ? color : "transparent",
89
+ border: `1.5px solid ${color}`,
90
+ boxSizing: "border-box",
46
91
  boxShadow: `0 1px 3px rgba(0,0,0,0.55), 0 0 5px ${glowColor}`,
47
92
  transform: "rotate(45deg)",
48
93
  }}