@hyperframes/studio 0.7.56 → 0.7.58

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 (146) hide show
  1. package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
  2. package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
  3. package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +41 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +7436 -5423
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
  17. package/src/components/editor/CanvasContextMenu.tsx +100 -20
  18. package/src/components/editor/DomEditOverlay.tsx +34 -62
  19. package/src/components/editor/LayersPanel.tsx +111 -91
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
  21. package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  26. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  27. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  28. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  29. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  30. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  31. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  32. package/src/components/nle/PreviewOverlays.tsx +61 -16
  33. package/src/components/nle/TimelinePane.test.ts +3 -0
  34. package/src/components/nle/TimelinePane.tsx +11 -1
  35. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  37. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  38. package/src/components/nle/zLaneGesture.test.ts +176 -0
  39. package/src/components/nle/zLaneGesture.ts +77 -0
  40. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  41. package/src/components/sidebar/AssetCard.tsx +19 -2
  42. package/src/components/sidebar/AudioRow.tsx +19 -2
  43. package/src/hooks/domEditCommitTypes.ts +29 -2
  44. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  45. package/src/hooks/fetchStubTestUtils.ts +16 -0
  46. package/src/hooks/timelineEditingHelpers.test.ts +183 -1
  47. package/src/hooks/timelineEditingHelpers.ts +63 -259
  48. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  49. package/src/hooks/timelineMoveAdapter.ts +7 -4
  50. package/src/hooks/timelineTimingSync.test.ts +820 -0
  51. package/src/hooks/timelineTimingSync.ts +597 -0
  52. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  53. package/src/hooks/timelineTrackVisibility.ts +4 -3
  54. package/src/hooks/useCaptionDetection.ts +2 -0
  55. package/src/hooks/useClipboard.ts +19 -17
  56. package/src/hooks/useCompositionDimensions.ts +32 -5
  57. package/src/hooks/useDomEditCommits.test.tsx +392 -23
  58. package/src/hooks/useDomEditCommits.ts +180 -53
  59. package/src/hooks/useDomEditSession.test.tsx +3 -1
  60. package/src/hooks/useDomEditSession.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
  62. package/src/hooks/useElementLifecycleOps.ts +175 -69
  63. package/src/hooks/useElementPicker.ts +7 -8
  64. package/src/hooks/usePreviewPersistence.ts +2 -2
  65. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  66. package/src/hooks/useTimelineEditing.test.tsx +591 -277
  67. package/src/hooks/useTimelineEditing.ts +202 -282
  68. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  69. package/src/hooks/useTimelineGroupEditing.ts +187 -134
  70. package/src/player/components/PlayerControls.tsx +5 -5
  71. package/src/player/components/PlayheadIndicator.tsx +7 -2
  72. package/src/player/components/Timeline.test.ts +105 -18
  73. package/src/player/components/Timeline.tsx +69 -98
  74. package/src/player/components/TimelineCanvas.tsx +47 -7
  75. package/src/player/components/TimelineLanes.tsx +32 -10
  76. package/src/player/components/TimelineOverlays.tsx +36 -0
  77. package/src/player/components/TimelineRuler.tsx +70 -47
  78. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  79. package/src/player/components/timelineCallbacks.ts +4 -1
  80. package/src/player/components/timelineClipDragCommit.test.ts +370 -11
  81. package/src/player/components/timelineClipDragCommit.ts +267 -68
  82. package/src/player/components/timelineGapCommit.test.ts +163 -0
  83. package/src/player/components/timelineGapCommit.ts +103 -0
  84. package/src/player/components/timelineGaps.test.ts +221 -0
  85. package/src/player/components/timelineGaps.ts +182 -0
  86. package/src/player/components/timelineLayout.test.ts +2 -1
  87. package/src/player/components/timelineLayout.ts +119 -30
  88. package/src/player/components/timelineMarquee.test.ts +20 -10
  89. package/src/player/components/timelineMarquee.ts +9 -2
  90. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  91. package/src/player/components/timelineRevealScroll.ts +91 -0
  92. package/src/player/components/timelineStackingSync.test.ts +102 -1
  93. package/src/player/components/timelineStackingSync.ts +45 -5
  94. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  95. package/src/player/components/timelineZMirror.test.ts +667 -0
  96. package/src/player/components/timelineZMirror.ts +393 -0
  97. package/src/player/components/timelineZones.test.ts +122 -310
  98. package/src/player/components/timelineZones.ts +82 -135
  99. package/src/player/components/timelineZoom.test.ts +2 -2
  100. package/src/player/components/timelineZoom.ts +7 -3
  101. package/src/player/components/useTimelineClipDrag.ts +1 -1
  102. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  103. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  104. package/src/player/components/useTimelinePlayhead.ts +4 -3
  105. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  106. package/src/player/components/useTimelineRevealClip.ts +56 -0
  107. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  108. package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
  109. package/src/player/components/useTimelineStackingSync.ts +17 -4
  110. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  111. package/src/player/components/useTrackGapMenu.ts +155 -0
  112. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  113. package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
  114. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  115. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  116. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  117. package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
  118. package/src/player/index.ts +3 -1
  119. package/src/player/lib/layerOrdering.test.ts +1 -15
  120. package/src/player/lib/layerOrdering.ts +5 -28
  121. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  122. package/src/player/lib/runtimeProtocol.ts +65 -0
  123. package/src/player/lib/timelineDOM.test.ts +72 -0
  124. package/src/player/lib/timelineDOM.ts +12 -0
  125. package/src/player/lib/timelineElementHelpers.ts +54 -11
  126. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  127. package/src/player/store/playerStore.test.ts +38 -13
  128. package/src/player/store/playerStore.ts +49 -1
  129. package/src/telemetry/events.test.ts +10 -0
  130. package/src/telemetry/events.ts +1 -0
  131. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  132. package/src/utils/assetPreviewDismiss.ts +29 -0
  133. package/src/utils/domEditSaveQueue.ts +4 -3
  134. package/src/utils/editHistory.test.ts +82 -0
  135. package/src/utils/editHistory.ts +14 -2
  136. package/src/utils/gsapSoftReload.test.ts +54 -115
  137. package/src/utils/gsapSoftReload.ts +42 -152
  138. package/src/utils/gsapUndoRestore.test.ts +269 -0
  139. package/src/utils/gsapUndoRestore.ts +269 -0
  140. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  141. package/src/utils/timelineAssetDrop.test.ts +144 -52
  142. package/src/utils/timelineAssetDrop.ts +62 -86
  143. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  144. package/dist/assets/index-D-GyYi2d.css +0 -1
  145. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  146. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -19,10 +19,13 @@ import {
19
19
  shouldAutoScrollTimeline,
20
20
  } from "./Timeline";
21
21
  import {
22
+ FIT_ZOOM_HEADROOM,
22
23
  GUTTER,
23
24
  MIN_TIMELINE_EXTENT_S,
25
+ PLAYHEAD_HEAD_W,
24
26
  RULER_H,
25
27
  TRACK_H,
28
+ TRACKS_LEFT_PAD,
26
29
  getTimelineDisplayContentWidth,
27
30
  getTimelineFitPps,
28
31
  } from "./timelineLayout";
@@ -150,7 +153,8 @@ describe("Timeline provider boundary", () => {
150
153
  });
151
154
 
152
155
  const row = button.parentElement?.parentElement;
153
- const trackContent = row?.children.item(1);
156
+ // Row children: [sticky gutter, TRACKS_LEFT_PAD spacer, time-mapped content].
157
+ const trackContent = row?.children.item(2);
154
158
  expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false);
155
159
  expect(trackContent).toBeInstanceOf(HTMLElement);
156
160
  if (!(trackContent instanceof HTMLElement)) {
@@ -327,6 +331,60 @@ describe("generateTicks", () => {
327
331
  const { major } = generateTicks(180, 80);
328
332
  expect(major[1] - major[0]).toBe(2);
329
333
  });
334
+
335
+ it("picks 'nice' NLE steps across zoom levels (no 7s-style intervals)", () => {
336
+ // step = first nice interval whose px spacing >= 88 at that pps.
337
+ const cases: Array<[number, number]> = [
338
+ [2, 60], // 60s * 2pps = 120px
339
+ [10, 10], // 10s * 10pps = 100px
340
+ [20, 5], // 5s * 20pps = 100px
341
+ [50, 2], // 2s * 50pps = 100px
342
+ [100, 1], // 1s * 100pps = 100px
343
+ ];
344
+ for (const [pps, expected] of cases) {
345
+ const { major } = generateTicks(600, pps);
346
+ expect(major[1] - major[0]).toBe(expected);
347
+ }
348
+ });
349
+
350
+ it("uses minute/hour steps when zoomed far out instead of colliding 10m labels", () => {
351
+ // 0.05 pps → 600s step would be 30px apart (labels collide); 1800s = 90px.
352
+ const { major } = generateTicks(7200, 0.05);
353
+ expect(major[1] - major[0]).toBe(1800);
354
+ expect(major).toContain(3600);
355
+ });
356
+
357
+ it("does not drift on long rulers (ticks are exact multiples of the step)", () => {
358
+ const { major } = generateTicks(600, 100); // 1s step, 601 ticks
359
+ expect(major[599]).toBe(599);
360
+ });
361
+
362
+ describe("frame display mode (frameRate provided)", () => {
363
+ it("snaps sub-frame steps up to one whole frame (no duplicate frame labels)", () => {
364
+ // 4400 pps would pick a 0.02s step = 0.6 frames at 30fps → snapped to 1 frame.
365
+ const { major } = generateTicks(2, 4400, 30);
366
+ const frames = major.map((t) => Math.round(t * 30));
367
+ // Frame labels are consecutive integers — no duplicates, no gaps.
368
+ frames.forEach((f, i) => expect(f).toBe(i));
369
+ });
370
+
371
+ it("keeps major AND minor ticks on whole frames", () => {
372
+ // 200 pps → 0.5s step (15 frames); quarters (3.75f) are rejected in
373
+ // frame mode in favour of fifths (3f).
374
+ const { major, minor } = generateTicks(20, 200, 30);
375
+ expect(major[1]).toBeCloseTo(0.5);
376
+ expect(minor).toContain(0.1); // 3 frames
377
+ for (const t of [...major, ...minor]) {
378
+ const frames = t * 30;
379
+ expect(Math.abs(frames - Math.round(frames))).toBeLessThan(1e-3);
380
+ }
381
+ });
382
+
383
+ it("leaves whole-second steps unchanged", () => {
384
+ const { major } = generateTicks(60, 100, 30);
385
+ expect(major[1] - major[0]).toBe(1);
386
+ });
387
+ });
330
388
  });
331
389
 
332
390
  describe("formatTime", () => {
@@ -397,33 +455,47 @@ describe("shouldAutoScrollTimeline", () => {
397
455
  });
398
456
  });
399
457
 
400
- describe("getTimelineFitPps (min 60s extent)", () => {
401
- const viewport = 632; // usable width = 632 - GUTTER - 2 = 598
458
+ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
459
+ const viewport = 632; // usable width = 632 - GUTTER - TRACKS_LEFT_PAD - 2
402
460
 
403
461
  it("computes fit pps against the 60s floor for short compositions", () => {
404
462
  // A 10s comp maps 60s onto the viewport → the comp takes ~1/6 of the width.
463
+ // (10 * 1.2 = 12s of headroom-padded content is still under the 60s floor.)
405
464
  const pps = getTimelineFitPps(viewport, 10);
406
- expect(pps).toBeCloseTo((viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S);
407
- expect(10 * pps).toBeCloseTo((viewport - GUTTER - 2) / 6);
465
+ expect(pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S);
466
+ expect(10 * pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / 6);
467
+ });
468
+
469
+ it("fits duration * FIT_ZOOM_HEADROOM (not the bare duration) for long compositions", () => {
470
+ expect(getTimelineFitPps(viewport, 60)).toBeCloseTo(
471
+ (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (60 * FIT_ZOOM_HEADROOM),
472
+ );
473
+ expect(getTimelineFitPps(viewport, 120)).toBeCloseTo(
474
+ (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (120 * FIT_ZOOM_HEADROOM),
475
+ );
408
476
  });
409
477
 
410
- it("keeps filling the viewport with the composition when it is 60s or longer", () => {
411
- expect(getTimelineFitPps(viewport, 60)).toBeCloseTo((viewport - GUTTER - 2) / 60);
412
- expect(getTimelineFitPps(viewport, 120)).toBeCloseTo((viewport - GUTTER - 2) / 120);
478
+ it("leaves CapCut-style trailing headroom: the comp ends at 1/1.2 of the usable width", () => {
479
+ const usable = viewport - GUTTER - TRACKS_LEFT_PAD - 2;
480
+ const pps = getTimelineFitPps(viewport, 120);
481
+ // Composition content occupies usable/1.2 px; the remaining ~17% is empty
482
+ // droppable ruler/lane surface past the end.
483
+ expect(120 * pps).toBeCloseTo(usable / FIT_ZOOM_HEADROOM);
484
+ expect(120 * pps).toBeLessThan(usable);
413
485
  });
414
486
 
415
487
  it("falls back to 100 pps before the viewport is measured", () => {
416
488
  expect(getTimelineFitPps(0, 10)).toBe(100);
417
- expect(getTimelineFitPps(GUTTER, 10)).toBe(100);
489
+ expect(getTimelineFitPps(GUTTER + TRACKS_LEFT_PAD, 10)).toBe(100);
418
490
  expect(getTimelineFitPps(Number.NaN, 10)).toBe(100);
419
491
  });
420
492
 
421
493
  it("uses the floor for zero/invalid durations", () => {
422
494
  expect(getTimelineFitPps(viewport, 0)).toBeCloseTo(
423
- (viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S,
495
+ (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S,
424
496
  );
425
497
  expect(getTimelineFitPps(viewport, Number.NaN)).toBeCloseTo(
426
- (viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S,
498
+ (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S,
427
499
  );
428
500
  });
429
501
  });
@@ -439,7 +511,7 @@ describe("getTimelineDisplayContentWidth", () => {
439
511
  it("still fills the viewport when that is larger than the 60s floor", () => {
440
512
  expect(
441
513
  getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 2000, pps: 5 }),
442
- ).toBe(2000 - GUTTER - 2);
514
+ ).toBe(2000 - GUTTER - TRACKS_LEFT_PAD - 2);
443
515
  });
444
516
 
445
517
  it("tracks a drag ghost past every other bound (drag-to-extend)", () => {
@@ -529,13 +601,28 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
529
601
  });
530
602
 
531
603
  describe("getTimelinePlayheadLeft", () => {
532
- it("converts time to a pixel offset from the gutter", () => {
533
- expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
604
+ it("offsets the wrapper by half the head width so the line CENTER = GUTTER + TRACKS_LEFT_PAD + t*pps", () => {
605
+ // Wrapper left + PLAYHEAD_HEAD_W/2 (where the 1px line is centered) must
606
+ // equal GUTTER + TRACKS_LEFT_PAD + t*pps at any zoom.
607
+ expect(getTimelinePlayheadLeft(4, 20) + PLAYHEAD_HEAD_W / 2).toBe(
608
+ GUTTER + TRACKS_LEFT_PAD + 4 * 20,
609
+ );
610
+ expect(getTimelinePlayheadLeft(10, 7.5) + PLAYHEAD_HEAD_W / 2).toBe(
611
+ GUTTER + TRACKS_LEFT_PAD + 75,
612
+ );
613
+ });
614
+
615
+ it("centers the line exactly on the left pad's end (the 00:00 tick) at t = 0", () => {
616
+ expect(getTimelinePlayheadLeft(0, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + TRACKS_LEFT_PAD);
534
617
  });
535
618
 
536
619
  it("guards invalid input", () => {
537
- expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(32);
538
- expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(32);
620
+ expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(
621
+ GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2,
622
+ );
623
+ expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(
624
+ GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2,
625
+ );
539
626
  });
540
627
  });
541
628
 
@@ -604,7 +691,7 @@ describe("resolveTimelineAssetDrop", () => {
604
691
  trackHeight: 72,
605
692
  trackOrder: [0, 3, 7],
606
693
  },
607
- 432,
694
+ 480, // rectLeft(100) + GUTTER + TRACKS_LEFT_PAD + 3s*100pps
608
695
  // clientY updated for TRACKS_TOP_PAD=72: rectTop(200) + RULER_H(24) +
609
696
  // TRACKS_TOP_PAD(72) + TRACK_H(48) + TRACK_H/2(24) = 368 → row 1 → track 3.
610
697
  368,
@@ -625,7 +712,7 @@ describe("resolveTimelineAssetDrop", () => {
625
712
  trackHeight: 72,
626
713
  trackOrder: [0, 3, 7],
627
714
  },
628
- 250,
715
+ 250 + TRACKS_LEFT_PAD,
629
716
  600,
630
717
  ),
631
718
  ).toEqual({ start: 1.18, track: 8 });
@@ -9,7 +9,7 @@ import { defaultTimelineTheme } from "./timelineTheme";
9
9
  import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
10
10
  import { useTimelinePlayhead } from "./useTimelinePlayhead";
11
11
  import { useTimelineActiveClips } from "./useTimelineActiveClips";
12
- import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
12
+ import { getTrackStyle } from "./timelineIcons";
13
13
  import { useTimelineZoom } from "./useTimelineZoom";
14
14
  import { useTimelineAssetDrop } from "./timelineDragDrop";
15
15
  import { TimelineEmptyState } from "./TimelineEmptyState";
@@ -20,14 +20,14 @@ import { TimelineOverlays } from "./TimelineOverlays";
20
20
  import { useTimelineEditPinning } from "./useTimelineEditPinning";
21
21
  import { useTimelineStackingSync } from "./useTimelineStackingSync";
22
22
  import { useTimelineGeometry } from "./useTimelineGeometry";
23
- import {
24
- GUTTER,
25
- generateTicks,
26
- getTimelineCanvasHeight,
27
- shouldShowTimelineShortcutHint,
28
- } from "./timelineLayout";
23
+ import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
24
+ import { GUTTER, TRACKS_LEFT_PAD, generateTicks, getTimelineCanvasHeight } from "./timelineLayout";
25
+ import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
26
+ import { STUDIO_PREVIEW_FPS } from "../lib/time";
29
27
  import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
30
28
  import type { TimelineProps } from "./TimelineTypes";
29
+ import { useTrackGapMenu } from "./useTrackGapMenu";
30
+ import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
31
31
 
32
32
  // Re-export pure utilities so existing imports from "./Timeline" still resolve.
33
33
  export {
@@ -95,6 +95,7 @@ export const Timeline = memo(function Timeline({
95
95
  [beatAnalysis, musicElement, beatEdits],
96
96
  );
97
97
  const duration = usePlayerStore((s) => s.duration);
98
+ const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
98
99
  const timelineReady = usePlayerStore((s) => s.timelineReady);
99
100
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
100
101
  const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
@@ -112,109 +113,41 @@ export const Timeline = memo(function Timeline({
112
113
  const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
113
114
 
114
115
  useMountEffect(() => {
115
- const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true);
116
- const up = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(false);
116
+ const key = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(e.type === "keydown");
117
117
  const blur = () => setShiftHeld(false);
118
- window.addEventListener("keydown", down);
119
- window.addEventListener("keyup", up);
118
+ window.addEventListener("keydown", key);
119
+ window.addEventListener("keyup", key);
120
120
  window.addEventListener("blur", blur);
121
121
  return () => {
122
- window.removeEventListener("keydown", down);
123
- window.removeEventListener("keyup", up);
122
+ window.removeEventListener("keydown", key);
123
+ window.removeEventListener("keyup", key);
124
124
  window.removeEventListener("blur", blur);
125
125
  };
126
126
  });
127
127
 
128
128
  const [showPopover, setShowPopover] = useState(false);
129
- const [showShortcutHint, setShowShortcutHint] = useState(true);
130
129
  const [kfContextMenu, setKfContextMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
131
130
  const [clipContextMenu, setClipContextMenu] = useState<{
132
131
  x: number;
133
132
  y: number;
134
133
  element: TimelineElement;
135
134
  } | null>(null);
136
- const [viewportWidth, setViewportWidth] = useState(0);
137
- const roRef = useRef<ResizeObserver | null>(null);
138
- const shortcutHintRafRef = useRef(0);
139
-
140
- const syncShortcutHintVisibility = useCallback(() => {
141
- const scroll = scrollRef.current;
142
- setShowShortcutHint(
143
- scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
144
- );
145
- }, []);
146
-
147
- const scheduleShortcutHintVisibilitySync = useCallback(() => {
148
- if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
149
- shortcutHintRafRef.current = requestAnimationFrame(() => {
150
- shortcutHintRafRef.current = 0;
151
- syncShortcutHintVisibility();
152
- });
153
- }, [syncShortcutHintVisibility]);
154
135
 
155
136
  const setContainerRef = useCallback((el: HTMLDivElement | null) => {
156
137
  containerRef.current = el;
157
138
  }, []);
158
139
 
159
- // Last horizontal scroll offset, tracked so it can be RESTORED across the
160
- // post-edit iframe reload: an edit re-derives elements (and may shrink the
161
- // content width), which the browser clamps into a scroll jump. Paired with the
162
- // pinned zoom (which keeps pps constant so the pixel offset stays meaningful),
163
- // restoring this keeps the user parked at the same spot after any edit.
140
+ // Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom).
164
141
  const lastScrollLeftRef = useRef(0);
165
- const setScrollRef = useCallback(
166
- (el: HTMLDivElement | null) => {
167
- if (roRef.current) {
168
- roRef.current.disconnect();
169
- roRef.current = null;
170
- }
171
- scrollRef.current = el;
172
- if (!el) return;
173
-
174
- const syncScrollViewport = () => {
175
- setViewportWidth(el.clientWidth);
176
- scheduleShortcutHintVisibilitySync();
177
- };
178
-
179
- syncScrollViewport();
180
- roRef.current = new ResizeObserver(syncScrollViewport);
181
- roRef.current.observe(el);
182
- },
183
- [scheduleShortcutHintVisibilitySync],
184
- );
185
-
186
- useMountEffect(() => () => {
187
- roRef.current?.disconnect();
188
- if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
189
- });
190
142
 
191
143
  const effectiveDuration = useMemo(() => {
192
144
  const safeDur = Number.isFinite(duration) ? duration : 0;
193
145
  if (rawElements.length === 0) return safeDur;
194
- const maxEnd = Math.max(...rawElements.map((el) => el.start + el.duration));
195
- const result = Math.max(safeDur, maxEnd);
146
+ const result = Math.max(safeDur, ...rawElements.map((el) => el.start + el.duration));
196
147
  return Number.isFinite(result) ? result : safeDur;
197
148
  }, [rawElements, duration]);
198
149
 
199
- const tracks = useMemo(() => {
200
- const map = new Map<number, typeof expandedElements>();
201
- for (const el of expandedElements) {
202
- const list = map.get(el.track) ?? [];
203
- list.push(el);
204
- map.set(el.track, list);
205
- }
206
- return Array.from(map.entries()).sort(([a], [b]) => a - b);
207
- }, [expandedElements]);
208
-
209
- const trackStyles = useMemo(() => {
210
- const map = new Map<number, TrackVisualStyle>();
211
- for (const [trackNum, els] of tracks) {
212
- map.set(trackNum, getTrackStyle(els[0]?.tag ?? ""));
213
- }
214
- return map;
215
- }, [tracks]);
216
-
217
- const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
150
+ const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
218
151
  const trackOrderRef = useRef(trackOrder);
219
152
  trackOrderRef.current = trackOrder;
220
153
  const expandedElementsRef = useRef(expandedElements);
@@ -223,8 +156,7 @@ export const Timeline = memo(function Timeline({
223
156
  const ppsRef = useRef(100);
224
157
  const durationRef = useRef(effectiveDuration);
225
158
  durationRef.current = effectiveDuration;
226
- // Declared here (used before the fitPps derivation below) so the edit-pin
227
- // wrappers can close over it; `fitPpsRef.current` is refreshed each render.
159
+ // Declared before the fitPps derivation so the edit-pin wrappers can close over it.
228
160
  const fitPpsRef = useRef(100);
229
161
 
230
162
  const {
@@ -253,6 +185,22 @@ export const Timeline = memo(function Timeline({
253
185
  expandedElementsRef,
254
186
  });
255
187
 
188
+ const {
189
+ gapMenuModel,
190
+ gapHighlight,
191
+ setHoveredGapAction,
192
+ openGapMenu,
193
+ dismissGapMenu,
194
+ closeTrackGap,
195
+ closeAllTrackGaps,
196
+ } = useTrackGapMenu({
197
+ tracks,
198
+ expandedElementsRef,
199
+ trackOrderRef,
200
+ onMoveElement: pinnedOnMoveElement,
201
+ onMoveElements: pinnedOnMoveElements,
202
+ });
203
+
256
204
  const {
257
205
  draggedClip,
258
206
  setDraggedClip,
@@ -294,6 +242,11 @@ export const Timeline = memo(function Timeline({
294
242
  }, [draggedClip, trackOrder]);
295
243
 
296
244
  const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
245
+ const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [
246
+ timelineReady,
247
+ expandedElements.length,
248
+ totalH,
249
+ ]);
297
250
  const keyframeCache = usePlayerStore((s) => s.keyframeCache);
298
251
  const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
299
252
  const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
@@ -329,6 +282,16 @@ export const Timeline = memo(function Timeline({
329
282
  lastScrollLeftRef,
330
283
  });
331
284
 
285
+ const laneGapStrips = useTimelineGapHighlights({
286
+ gapHighlight,
287
+ tracks,
288
+ selectedElementId,
289
+ selectedElementIds,
290
+ expandedElements,
291
+ dragActive: draggedClip?.started === true || resizingClip != null,
292
+ displayDuration,
293
+ });
294
+
332
295
  const { seekFromX, autoScrollDuringDrag, dragScrollRaf } = useTimelinePlayhead({
333
296
  playheadRef,
334
297
  scrollRef,
@@ -380,8 +343,7 @@ export const Timeline = memo(function Timeline({
380
343
  trackOrderRef,
381
344
  onSelectElement,
382
345
  });
383
- // Wire setRangeSelection into the stable ref consumed by useTimelineClipDrag
384
- setRangeSelectionRef.current = setRangeSelection;
346
+ setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag
385
347
 
386
348
  const prevSelectedRef = useRef(selectedElementRef.current);
387
349
  // eslint-disable-next-line no-restricted-syntax, react-hooks/exhaustive-deps
@@ -395,16 +357,14 @@ export const Timeline = memo(function Timeline({
395
357
  }
396
358
  });
397
359
 
360
+ // Frame display mode labels ruler ticks as frame numbers — pass the fps so ticks snap to frames.
361
+ const tickFps = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
398
362
  const { major, minor } = useMemo(
399
- () => generateTicks(displayDuration, pps),
400
- [displayDuration, pps],
363
+ () => generateTicks(displayDuration, pps, tickFps),
364
+ [displayDuration, pps, tickFps],
401
365
  );
402
366
  const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
403
367
 
404
- useEffect(() => {
405
- syncShortcutHintVisibility();
406
- }, [syncShortcutHintVisibility, timelineReady, expandedElements.length, totalH]);
407
-
408
368
  const getPreviewElement = useCallback(
409
369
  (element: TimelineElement): TimelineElement => {
410
370
  if (
@@ -458,8 +418,7 @@ export const Timeline = memo(function Timeline({
458
418
  tabIndex={-1}
459
419
  className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
460
420
  onScroll={(e) => {
461
- // Remember the live offset so it can be restored across a post-edit reload.
462
- lastScrollLeftRef.current = e.currentTarget.scrollLeft;
421
+ lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload
463
422
  }}
464
423
  onDragOver={handleAssetDragOver}
465
424
  onDragLeave={() => clearDropPreview()}
@@ -467,7 +426,8 @@ export const Timeline = memo(function Timeline({
467
426
  onPointerDown={(e) => {
468
427
  if (activeTool === "razor" && e.shiftKey && e.button === 0 && scrollRef.current) {
469
428
  const rect = scrollRef.current.getBoundingClientRect();
470
- const x = e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER;
429
+ const x =
430
+ e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
471
431
  const splitTime = Math.max(0, x / pps);
472
432
  onRazorSplitAll?.(splitTime);
473
433
  return;
@@ -488,6 +448,7 @@ export const Timeline = memo(function Timeline({
488
448
  majorTickInterval={majorTickInterval}
489
449
  rangeSelection={rangeSelection}
490
450
  marqueeRect={marqueeRect}
451
+ laneGapStrips={laneGapStrips}
491
452
  theme={theme}
492
453
  displayTrackOrder={displayTrackOrder}
493
454
  trackOrder={trackOrder}
@@ -526,8 +487,7 @@ export const Timeline = memo(function Timeline({
526
487
  const elKey = el.key ?? el.id;
527
488
  setSelectedElementId(elKey);
528
489
  onSelectElement?.(el);
529
- // Visually select the clicked diamond (matches shift-click / motion-path
530
- // selection); cleared above so this single-selects it.
490
+ // Select the clicked diamond (matches shift-click); cleared above so this single-selects.
531
491
  toggleSelectedKeyframe(`${elKey}:${pct}`);
532
492
  const absTime = el.start + (pct / 100) * el.duration;
533
493
  onSeek?.(absTime);
@@ -560,8 +520,14 @@ export const Timeline = memo(function Timeline({
560
520
  e.preventDefault();
561
521
  setSelectedElementId(el.key ?? el.id);
562
522
  onSelectElement?.(el);
523
+ dismissGapMenu();
563
524
  setClipContextMenu({ x: e.clientX, y: e.clientY, element: el });
564
525
  }}
526
+ onContextMenuLane={(e, track, time) => {
527
+ if (draggedClip?.started || resizingClip) return;
528
+ setClipContextMenu(null);
529
+ openGapMenu({ x: e.clientX, y: e.clientY, track, time });
530
+ }}
565
531
  />
566
532
  {activeTool === "razor" && razorGuideX !== null && (
567
533
  <div
@@ -594,6 +560,11 @@ export const Timeline = memo(function Timeline({
594
560
  onSplitElement={onSplitElement}
595
561
  pinZoomBeforeEdit={pinZoomBeforeEdit}
596
562
  onDeleteElement={_onDeleteElement}
563
+ gapContextMenu={gapMenuModel}
564
+ onDismissGapContextMenu={dismissGapMenu}
565
+ onCloseTrackGap={closeTrackGap}
566
+ onCloseAllTrackGaps={closeAllTrackGaps}
567
+ onHoverGapAction={setHoveredGapAction}
597
568
  />
598
569
  </div>
599
570
  );
@@ -10,6 +10,9 @@ import {
10
10
  CLIP_Y,
11
11
  TRACKS_TOP_PAD,
12
12
  TRACKS_BOTTOM_PAD,
13
+ TRACKS_LEFT_PAD,
14
+ PLAYHEAD_HEAD_W,
15
+ getTimelinePlayheadLeft,
13
16
  getTimelineRowTop,
14
17
  } from "./timelineLayout";
15
18
  import { usePlayerStore } from "../store/playerStore";
@@ -20,6 +23,8 @@ import type { Rect } from "../../utils/marqueeGeometry";
20
23
  import { TimelineClip } from "./TimelineClip";
21
24
  import { TimelineLanes, type TimelineLaneBaseProps } from "./TimelineLanes";
22
25
  import { renderClipChildren } from "./timelineClipChildren";
26
+ import { useTimelineRevealClip } from "./useTimelineRevealClip";
27
+ import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
23
28
 
24
29
  interface TimelineCanvasProps extends TimelineLaneBaseProps {
25
30
  major: number[];
@@ -34,6 +39,8 @@ interface TimelineCanvasProps extends TimelineLaneBaseProps {
34
39
  /** Playhead is being actively scrubbed — fills the grab-handle head. */
35
40
  isScrubbing: boolean;
36
41
  playheadRef: React.RefObject<HTMLDivElement | null>;
42
+ /** Gap strips: loud on gap-menu-row hover, quiet on the selected clip's lane. */
43
+ laneGapStrips: TimelineLaneGapStrips[];
37
44
  }
38
45
 
39
46
  export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) {
@@ -41,6 +48,8 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
41
48
  const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } =
42
49
  useTimelineEditContextOptional();
43
50
  const beatDragging = usePlayerStore((s) => s.beatDragging);
51
+ // Scroll a clip into view when the sidebar (asset card) requests a reveal.
52
+ useTimelineRevealClip(scrollRef);
44
53
  const draggedElement = draggedClip?.element ?? null;
45
54
  const activeDraggedElement =
46
55
  draggedClip?.started === true && draggedElement
@@ -92,7 +101,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
92
101
  return (
93
102
  <div
94
103
  className="relative"
95
- style={{ height: props.totalH, width: GUTTER + props.trackContentWidth }}
104
+ style={{ height: props.totalH, width: GUTTER + TRACKS_LEFT_PAD + props.trackContentWidth }}
96
105
  >
97
106
  <TimelineRuler
98
107
  major={props.major}
@@ -126,6 +135,31 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
126
135
  new bottom track comfortably (see TRACKS_BOTTOM_PAD / getTimelineCanvasHeight). */}
127
136
  <div aria-hidden="true" style={{ height: TRACKS_BOTTOM_PAD }} />
128
137
 
138
+ {/* Gap strips — loud dashed fill for the gap(s) a hovered "Close gap(s)"
139
+ menu row would collapse; a quiet tint for every gap on the selected
140
+ clip's lane. Geometry mirrors the drop placeholder (row top + clip
141
+ inset) so strips sit exactly where a clip body would. */}
142
+ {props.laneGapStrips.map((strip) => {
143
+ const rowIndex = displayTrackOrder.indexOf(strip.track);
144
+ if (rowIndex < 0) return null;
145
+ const loud = strip.kind === "hover";
146
+ return strip.intervals.map((gap) => (
147
+ <div
148
+ key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
149
+ className="pointer-events-none absolute"
150
+ style={{
151
+ top: getTimelineRowTop(rowIndex) + CLIP_Y,
152
+ left: GUTTER + TRACKS_LEFT_PAD + gap.start * props.pps,
153
+ width: Math.max((gap.end - gap.start) * props.pps, 2),
154
+ height: TRACK_H - CLIP_Y * 2,
155
+ background: loud ? "rgba(60,230,172,0.18)" : "rgba(60,230,172,0.055)",
156
+ borderRadius: 4,
157
+ zIndex: 25,
158
+ }}
159
+ />
160
+ ));
161
+ })}
162
+
129
163
  {/* Drop placeholder — a clip-sized slot at the exact landing spot (target
130
164
  lane + snapped start), parallel to the ghost. Hidden in insert mode. */}
131
165
  {draggedClip?.started && draggedClip.insertRow == null && draggedRowIndex >= 0 && (
@@ -133,7 +167,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
133
167
  className="absolute pointer-events-none"
134
168
  style={{
135
169
  top: getTimelineRowTop(draggedRowIndex) + CLIP_Y,
136
- left: GUTTER + draggedClip.previewStart * props.pps,
170
+ left: GUTTER + TRACKS_LEFT_PAD + draggedClip.previewStart * props.pps,
137
171
  width: Math.max(draggedClip.element.duration * props.pps, 4),
138
172
  height: TRACK_H - CLIP_Y * 2,
139
173
  border: "1px solid rgba(60,230,172,0.55)",
@@ -151,7 +185,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
151
185
  className="absolute pointer-events-none"
152
186
  style={{
153
187
  top: getTimelineRowTop(draggedClip.insertRow) - 0.5,
154
- left: GUTTER,
188
+ left: GUTTER + TRACKS_LEFT_PAD,
155
189
  width: props.trackContentWidth,
156
190
  height: 1,
157
191
  background: "#3CE6AC",
@@ -166,7 +200,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
166
200
  <div
167
201
  className="absolute pointer-events-none"
168
202
  style={{
169
- left: GUTTER + draggedClip.snapTime * props.pps,
203
+ left: GUTTER + TRACKS_LEFT_PAD + draggedClip.snapTime * props.pps,
170
204
  top: RULER_H,
171
205
  bottom: 0,
172
206
  width: 1,
@@ -246,7 +280,9 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
246
280
  className="absolute pointer-events-none"
247
281
  style={{
248
282
  left:
249
- GUTTER + Math.min(props.rangeSelection.start, props.rangeSelection.end) * props.pps,
283
+ GUTTER +
284
+ TRACKS_LEFT_PAD +
285
+ Math.min(props.rangeSelection.start, props.rangeSelection.end) * props.pps,
250
286
  width: Math.abs(props.rangeSelection.end - props.rangeSelection.start) * props.pps,
251
287
  top: RULER_H,
252
288
  bottom: 0,
@@ -259,12 +295,16 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
259
295
  )}
260
296
 
261
297
  {/* Playhead — hidden while dragging a beat so its guideline doesn't
262
- track the scrub and clutter the beat being moved. */}
298
+ track the scrub and clutter the beat being moved. Explicit width +
299
+ the half-head offset baked into getTimelinePlayheadLeft keep the
300
+ inner 1px line's CENTER exactly on GUTTER + t * pps (the ruler
301
+ ticks' center), instead of relying on shrink-wrap sizing. */}
263
302
  <div
264
303
  ref={props.playheadRef}
265
304
  className="absolute top-0 bottom-0 pointer-events-none"
266
305
  style={{
267
- left: `${GUTTER}px`,
306
+ left: `${getTimelinePlayheadLeft(0, 0)}px`,
307
+ width: PLAYHEAD_HEAD_W,
268
308
  zIndex: 100,
269
309
  display: beatDragging ? "none" : undefined,
270
310
  }}