@hyperframes/studio 0.8.111 → 0.8.113

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 (82) hide show
  1. package/dist/assets/{hyperframes-player-DvJeHBHi.js → hyperframes-player-DzJbtQVO.js} +1 -1
  2. package/dist/assets/{index-SFktf7O1.js → index-DRhF3v4Z.js} +194 -194
  3. package/dist/assets/{index-C7apTvpr.css → index-DbfKVTId.css} +1 -1
  4. package/dist/assets/{index--G6c4Q_x.js → index-DgwDQK5G.js} +1 -1
  5. package/dist/assets/{index-Bt4vDkfw.js → index-n5FUn1xv.js} +1 -1
  6. package/dist/{chunk-PQFEBX32.js → chunk-BIUXP3PG.js} +2 -2
  7. package/dist/{chunk-6LUNLTHR.js → chunk-K47E2ZJA.js} +13 -8
  8. package/dist/chunk-K47E2ZJA.js.map +1 -0
  9. package/dist/{chunk-QUXZVHOJ.js → chunk-VWT7STY3.js} +2 -2
  10. package/dist/{domEditingLayers-JQPQGGT7.js → domEditingLayers-V67MS4WP.js} +3 -3
  11. package/dist/index.d.ts +80 -46
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +7294 -6844
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +2 -2
  16. package/dist/styles.css +1 -1
  17. package/package.json +10 -8
  18. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +2 -2
  19. package/src/hooks/timelineEditingHelpers.ts +7 -0
  20. package/src/hooks/timelineMoveAdapter.test.ts +12 -0
  21. package/src/hooks/timelineMoveAdapter.ts +9 -4
  22. package/src/hooks/useTimelineGroupEditing.test.tsx +77 -0
  23. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  24. package/src/index.ts +7 -0
  25. package/src/main.tsx +4 -0
  26. package/src/player/components/ClipBadges.tsx +5 -5
  27. package/src/player/components/Timeline.ghostLane.test.tsx +8 -61
  28. package/src/player/components/Timeline.trackPadding.test.tsx +11 -56
  29. package/src/player/components/TimelineCanvas.tsx +6 -2
  30. package/src/player/components/TimelineClip.filmstrip.test.tsx +156 -0
  31. package/src/player/components/TimelineClip.test.tsx +7 -7
  32. package/src/player/components/TimelineClip.tsx +23 -7
  33. package/src/player/components/TimelineClipFades.tsx +41 -43
  34. package/src/player/components/TimelineGestureOverlay.test.tsx +41 -0
  35. package/src/player/components/TimelineGestureOverlay.tsx +59 -21
  36. package/src/player/components/TimelineLanes.tsx +18 -16
  37. package/src/player/components/timelineCallbacks.ts +6 -4
  38. package/src/player/components/timelineClipChildren.tsx +7 -13
  39. package/src/player/components/timelineClipDragCommit.test.ts +51 -0
  40. package/src/player/components/timelineClipDragCommit.ts +63 -21
  41. package/src/player/components/timelineClipDragGestureLifecycle.ts +18 -2
  42. package/src/player/components/timelineClipDragPreview.test.ts +47 -31
  43. package/src/player/components/timelineClipDragPreview.ts +81 -17
  44. package/src/player/components/timelineClipDragTypes.ts +3 -3
  45. package/src/player/components/timelineClipFadeGeometry.test.ts +29 -0
  46. package/src/player/components/timelineClipFadeGeometry.ts +74 -0
  47. package/src/player/components/timelineCollision.ts +51 -11
  48. package/src/player/components/timelineDragLanding.ts +20 -6
  49. package/src/player/components/timelineLaneProps.ts +3 -8
  50. package/src/player/components/timelineMotionStyles.test.ts +6 -13
  51. package/src/player/components/timelineMountFixtures.tsx +65 -0
  52. package/src/player/components/timelineOptimisticRevision.ts +15 -4
  53. package/src/player/components/timelineTheme.ts +2 -0
  54. package/src/player/components/timelineTrackDisplay.ts +1 -1
  55. package/src/player/components/timelineTrackInsertLayout.test.ts +249 -0
  56. package/src/player/components/timelineTrackInsertLayout.ts +71 -0
  57. package/src/player/components/timelineZMirror.ts +5 -11
  58. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +163 -0
  59. package/src/player/components/useTimelineClipDrag.ts +116 -12
  60. package/src/player/components/useTimelineDisplayLayout.test.tsx +59 -0
  61. package/src/player/components/useTimelineKeyboardActor.ts +86 -7
  62. package/src/player/components/useTimelineProviderState.tsx +3 -1
  63. package/src/player/components/useTimelineRowVirtualization.test.tsx +56 -0
  64. package/src/player/components/useTimelineRowVirtualization.ts +3 -1
  65. package/src/player/components/useTimelineTrackDerivations.test.ts +14 -1
  66. package/src/player/components/useTimelineTrackDerivations.ts +32 -18
  67. package/src/player/components/useTimelineTrackLayout.ts +43 -14
  68. package/src/player/hooks/timelinePlayerSync.test.ts +7 -0
  69. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  70. package/src/player/lib/automationStoreSync.test.ts +15 -0
  71. package/src/player/lib/automationStoreSync.ts +13 -5
  72. package/src/player/lib/storedVolume.ts +7 -0
  73. package/src/player/lib/thumbnailPageLifecycle.test.ts +123 -0
  74. package/src/player/lib/thumbnailPageLifecycle.ts +27 -0
  75. package/src/player/lib/thumbnailScheduler.ts +20 -2
  76. package/src/player/lib/timelineElementHelpers.ts +2 -8
  77. package/src/styles/components.css +71 -19
  78. package/src/styles/theme.css +4 -1
  79. package/dist/chunk-6LUNLTHR.js.map +0 -1
  80. /package/dist/{chunk-PQFEBX32.js.map → chunk-BIUXP3PG.js.map} +0 -0
  81. /package/dist/{chunk-QUXZVHOJ.js.map → chunk-VWT7STY3.js.map} +0 -0
  82. /package/dist/{domEditingLayers-JQPQGGT7.js.map → domEditingLayers-V67MS4WP.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.111",
3
+ "version": "0.8.113",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/player": "0.8.111",
64
- "@hyperframes/core": "0.8.111",
65
- "@hyperframes/shader-transitions": "0.8.111",
66
- "@hyperframes/parsers": "0.8.111",
67
- "@hyperframes/sdk": "0.8.111",
68
- "@hyperframes/studio-server": "0.8.111"
63
+ "@hyperframes/core": "0.8.113",
64
+ "@hyperframes/player": "0.8.113",
65
+ "@hyperframes/sdk": "0.8.113",
66
+ "@hyperframes/parsers": "0.8.113",
67
+ "@hyperframes/studio-server": "0.8.113",
68
+ "@hyperframes/shader-transitions": "0.8.113"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.111"
84
+ "@hyperframes/producer": "0.8.113"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -94,7 +94,9 @@
94
94
  "typecheck": "tsc --noEmit",
95
95
  "test": "vitest run",
96
96
  "test:edit-accuracy": "bun tests/e2e/edit-accuracy/run.mjs",
97
+ "test:filmstrip-controls": "node tests/e2e/filmstrip-controls.mjs",
97
98
  "test:webmcp-edit-loop": "node tests/e2e/webmcp-edit-loop.mjs",
99
+ "test:thumbnail-save": "node tests/e2e/thumbnail-save.mjs",
98
100
  "test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
99
101
  "test:watch": "vitest",
100
102
  "report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts",
@@ -2,13 +2,13 @@ import { useCallback, useRef } from "react";
2
2
  import { usePlayerStore } from "../../player";
3
3
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
4
4
  import {
5
- displayTrackOrder,
6
5
  resolveRepositionLaneMove,
7
6
  resolveZMirrorLaneMove,
8
7
  type ZMirrorAction,
9
8
  type ZMirrorLaneMove,
10
9
  } from "../../player/components/timelineZMirror";
11
10
  import type { TimelineElement } from "../../player/store/playerStore";
11
+ import { timelineTrackOrder } from "../../player/components/timelineTrackDisplay";
12
12
  import { commitZMirrorLaneMove } from "../../player/components/timelineClipDragCommit";
13
13
  import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
14
14
  import { buildStableSelector, getSelectorIndex } from "../editor/domEditingDom";
@@ -156,7 +156,7 @@ function useMirrorLaneMoveCommit(): (
156
156
  move,
157
157
  {
158
158
  elements: els,
159
- trackOrder: displayTrackOrder(els),
159
+ trackOrder: timelineTrackOrder(els),
160
160
  updateElement: (key, updates) => usePlayerStore.getState().updateElement(key, updates),
161
161
  onMoveElements,
162
162
  // NO readZIndex / onStackingPatches: see the hook doc — the lane→z
@@ -238,6 +238,7 @@ export function buildTimelineMoveTimingPatch(
238
238
  start: number,
239
239
  duration: number,
240
240
  track?: number,
241
+ audioGroup?: null,
241
242
  ): string {
242
243
  if (!Number.isFinite(start) || !Number.isFinite(duration)) {
243
244
  console.warn(
@@ -257,6 +258,12 @@ export function buildTimelineMoveTimingPatch(
257
258
  value: formatTimelineAttributeNumber(track),
258
259
  });
259
260
  }
261
+ if (audioGroup === null)
262
+ patched = applyPatchByTarget(patched, target, {
263
+ type: "attribute",
264
+ property: "data-audio-group",
265
+ value: null,
266
+ });
260
267
  // Content-driven duration: sync data-duration to the furthest clip end read
261
268
  // from the PATCHED SOURCE (raw data-duration), so it grows if a clip moved
262
269
  // past the end and shrinks if the furthest clip moved left. Measured from the
@@ -68,6 +68,18 @@ describe("persistTimelineMoveEditsAtomically", () => {
68
68
  });
69
69
  });
70
70
 
71
+ it("carries explicit detachment through the atomic move", async () => {
72
+ const edit = {
73
+ element: { ...element("voice", 1), audioGroup: "group" },
74
+ updates: { start: 1, track: 0, audioGroup: null },
75
+ };
76
+ const handle = await runMove([edit], "move", "track-insert");
77
+ expect(handle).toHaveBeenCalledWith(
78
+ [{ element: edit.element, start: 1, track: 0, audioGroup: null }],
79
+ { coalesceKey: "move" },
80
+ );
81
+ });
82
+
71
83
  it("rejects without retrying individual members when the atomic batch fails", async () => {
72
84
  const failure = new Error("batch failed");
73
85
  const handleTimelineGroupMove = vi.fn().mockRejectedValue(failure);
@@ -5,9 +5,13 @@ import type {
5
5
  TimelineGroupMoveChange,
6
6
  } from "./useTimelineGroupEditing";
7
7
 
8
- export interface TimelineMoveEdit {
8
+ export type TimelineAtomicMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
9
+ audioGroup?: null;
10
+ };
11
+
12
+ export interface TimelineAtomicMoveEdit {
9
13
  element: TimelineElement;
10
- updates: Pick<TimelineElement, "start" | "track">;
14
+ updates: TimelineAtomicMoveUpdates;
11
15
  }
12
16
 
13
17
  interface AtomicMoveDeps {
@@ -20,14 +24,14 @@ interface AtomicMoveDeps {
20
24
  export type TimelineMoveOperation = "timing" | "lane-reorder" | "track-insert";
21
25
 
22
26
  export type TimelineMoveEditsHandler = (
23
- edits: TimelineMoveEdit[],
27
+ edits: TimelineAtomicMoveEdit[],
24
28
  coalesceKey?: string,
25
29
  operation?: TimelineMoveOperation,
26
30
  coalesceMs?: number,
27
31
  ) => Promise<void>;
28
32
 
29
33
  export function persistTimelineMoveEditsAtomically(
30
- edits: TimelineMoveEdit[],
34
+ edits: TimelineAtomicMoveEdit[],
31
35
  coalesceKey: string | undefined,
32
36
  operation: TimelineMoveOperation,
33
37
  deps: AtomicMoveDeps,
@@ -37,6 +41,7 @@ export function persistTimelineMoveEditsAtomically(
37
41
  edits.map(({ element, updates }) => ({
38
42
  element,
39
43
  start: updates.start,
44
+ audioGroup: updates.audioGroup,
40
45
  // Stable track lanes: a lane is the authored data-track-index, so every
41
46
  // vertical gesture (lane-reorder AND track-insert) must persist the track;
42
47
  // z is paint order only and is synced separately. Plain horizontal moves
@@ -4,6 +4,7 @@ import { act } from "react";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import type { Root } from "react-dom/client";
6
6
  import type { TimelineElement } from "../player";
7
+ import { persistTimelineMoveEditsAtomically } from "./timelineMoveAdapter";
7
8
  import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
8
9
  import { installReactActEnvironment, mountReactHarness } from "./domSelectionTestHarness";
9
10
 
@@ -72,3 +73,79 @@ describe("useTimelineGroupEditing: handleTimelineGroupMove suppressFailureToast"
72
73
  expect(showToast).toHaveBeenCalledTimes(1);
73
74
  });
74
75
  });
76
+
77
+ it.each([false, true])(
78
+ "keeps detachment and the lane atomic, including a refused history write ($0)",
79
+ async (refused) => {
80
+ const before =
81
+ '<main data-composition-id="root" data-duration="4"><audio id="voice" data-start="0" data-duration="2" data-track-index="1" data-audio-group="group"></audio></main>';
82
+ let disk = before;
83
+ const failure = new Error("history refused");
84
+ const recordEdit = refused ? vi.fn().mockRejectedValue(failure) : vi.fn();
85
+ const iframe = document.createElement("iframe");
86
+ document.body.append(iframe);
87
+ iframe.contentDocument!.body.innerHTML = before;
88
+ const writeProjectFile = vi.fn(async (_path: string, content: string) => {
89
+ disk = content;
90
+ });
91
+ vi.stubGlobal(
92
+ "fetch",
93
+ vi.fn(
94
+ async () =>
95
+ new Response(JSON.stringify({ content: disk }), {
96
+ headers: { "content-type": "application/json" },
97
+ }),
98
+ ),
99
+ );
100
+ let hook: ReturnType<typeof useTimelineGroupEditing>;
101
+ function Harness() {
102
+ hook = useTimelineGroupEditing({
103
+ activeCompPath: "index.html",
104
+ editQueueRef: { current: Promise.resolve() },
105
+ pendingTimelineEditPathRef: { current: new Set() },
106
+ previewIframeRef: { current: iframe },
107
+ projectIdRef: { current: "fixture" },
108
+ recordEdit,
109
+ writeProjectFile,
110
+ reloadPreview: vi.fn(),
111
+ showToast: vi.fn(),
112
+ });
113
+ return null;
114
+ }
115
+ const root = mountReactHarness(<Harness />);
116
+ try {
117
+ await act(async () => {
118
+ const moved = persistTimelineMoveEditsAtomically(
119
+ [
120
+ {
121
+ element: { ...el("voice", 0, 2, 1), tag: "audio", audioGroup: "group" },
122
+ updates: { start: 0, track: 0, audioGroup: null },
123
+ },
124
+ ],
125
+ "insert",
126
+ "track-insert",
127
+ { handleTimelineGroupMove: hook!.handleTimelineGroupMove },
128
+ );
129
+ if (refused) await expect(moved).rejects.toBe(failure);
130
+ else await moved;
131
+ });
132
+ expect(
133
+ iframe.contentDocument!.getElementById("voice")?.getAttribute("data-audio-group"),
134
+ ).toBe("group");
135
+ if (refused) {
136
+ expect(disk).toBe(before);
137
+ expect(writeProjectFile).toHaveBeenCalledTimes(2);
138
+ return;
139
+ }
140
+ expect(writeProjectFile).toHaveBeenCalledOnce();
141
+ expect(recordEdit).toHaveBeenCalledOnce();
142
+ expect(disk).not.toContain("data-audio-group");
143
+ expect(disk).toContain('data-track-index="0"');
144
+ expect(recordEdit.mock.calls[0][0].files["index.html"]).toEqual({ before, after: disk });
145
+ } finally {
146
+ act(() => root.unmount());
147
+ iframe.remove();
148
+ vi.unstubAllGlobals();
149
+ }
150
+ },
151
+ );
@@ -37,6 +37,7 @@ export interface TimelineGroupMoveChange {
37
37
  element: TimelineElement;
38
38
  start: number;
39
39
  track?: number;
40
+ audioGroup?: null;
40
41
  }
41
42
 
42
43
  export interface TimelineGroupResizeChange {
@@ -296,7 +297,9 @@ export function useTimelineGroupEditing({
296
297
  sdkChanges: toSdkTimingChanges(changes, (change) => ({
297
298
  start: toAuthoredStart(change.element, change.start),
298
299
  })),
299
- eligible: changes.every((change) => change.track == null),
300
+ eligible: changes.every(
301
+ (change) => change.track == null && change.audioGroup === undefined,
302
+ ),
300
303
  needsExtension,
301
304
  label,
302
305
  coalesceKey,
@@ -315,6 +318,7 @@ export function useTimelineGroupEditing({
315
318
  toAuthoredStart(change.element, change.start),
316
319
  change.element.duration,
317
320
  change.track,
321
+ change.audioGroup,
318
322
  ),
319
323
  })),
320
324
  coalesceKey,
package/src/index.ts CHANGED
@@ -62,6 +62,13 @@ export { TimelineProvider, useTimelineContext } from "./player/components/Timeli
62
62
  export type { TimelineTheme } from "./player/components/timelineTheme";
63
63
  export { TRACK_H } from "./player/components/timelineLayout";
64
64
  export type { TimelineTrackPadding } from "./player/components/timelineLayout";
65
+ export { displayTrackOrder } from "./player/components/useTimelineTrackDerivations";
66
+ export { fadeHandleBoxes } from "./player/components/timelineClipFadeGeometry";
67
+ export type {
68
+ FadeEdge,
69
+ FadeHandleBox,
70
+ FadeHandleClipBox,
71
+ } from "./player/components/timelineClipFadeGeometry";
65
72
 
66
73
  // Clip content thumbnails: used by a host rendering its own timeline lane.
67
74
  export { AudioWaveform } from "./player/components/AudioWaveform";
package/src/main.tsx CHANGED
@@ -7,8 +7,12 @@ import { StudioErrorBoundary } from "./components/StudioErrorBoundary";
7
7
  import { readIconTokens } from "./styles/iconTokens";
8
8
  import { trackStudioEvent } from "./utils/studioTelemetry";
9
9
  import { prefetchPreviewForHash } from "./utils/previewPrefetch";
10
+ import { bindThumbnailPageLifecycle } from "./player/lib/thumbnailPageLifecycle";
10
11
  import "./styles/studio.css";
11
12
 
13
+ const unbindThumbnailPageLifecycle = bindThumbnailPageLifecycle(window, document);
14
+ import.meta.hot?.dispose(unbindThumbnailPageLifecycle);
15
+
12
16
  prefetchPreviewForHash(window.location.hash);
13
17
  window.addEventListener("hashchange", () => prefetchPreviewForHash(window.location.hash));
14
18
  trackStudioEvent("session_start");
@@ -24,7 +24,7 @@ function SpeakerGlyph({ muted }: { muted: boolean }) {
24
24
  const BADGE_CLASS =
25
25
  "timeline-clip__badge inline-flex items-center gap-0.5 rounded-[3px] border bg-black/55 px-1 text-[9px] leading-[14px] whitespace-nowrap";
26
26
  const FX_ON = "border-white/40 text-white font-semibold";
27
- const FX_OFF = "border-white/10 text-white/35";
27
+ const FX_OFF = "border-white/10 text-white/60";
28
28
 
29
29
  /** Premiere's fx badge — grey with no effects, white with any; hover lists them, click opens the clip menu. */
30
30
  export const ClipBadges = memo(function ClipBadges({
@@ -44,21 +44,21 @@ export const ClipBadges = memo(function ClipBadges({
44
44
  };
45
45
  return (
46
46
  <span
47
- className="pointer-events-none absolute right-1.5 top-0.5 z-[3] flex gap-1"
47
+ className="pointer-events-none absolute right-1.5 top-0.5 z-[31] flex max-w-[calc(100%-12px)] gap-1 overflow-hidden"
48
48
  data-testid="clip-badges"
49
49
  >
50
50
  {volume && (
51
51
  <span
52
- className={`${BADGE_CLASS} border-white/20 text-white/90`}
52
+ className={`${BADGE_CLASS} min-w-0 overflow-hidden border-white/20 text-white/90`}
53
53
  title={volume}
54
54
  data-badge="volume"
55
55
  >
56
56
  <SpeakerGlyph muted={volume === "Muted"} />
57
- {volume === "Muted" ? null : <span>{volume}</span>}
57
+ {volume === "Muted" ? null : <span className="truncate">{volume}</span>}
58
58
  </span>
59
59
  )}
60
60
  <span
61
- className={`${BADGE_CLASS} pointer-events-auto cursor-pointer ${hasEffects ? FX_ON : FX_OFF}`}
61
+ className={`${BADGE_CLASS} pointer-events-auto shrink-0 cursor-pointer ${hasEffects ? FX_ON : FX_OFF}`}
62
62
  title={hasEffects ? effects.join(" · ") : "No effects"}
63
63
  data-badge="fx"
64
64
  data-fx-active={hasEffects ? "true" : "false"}
@@ -1,14 +1,14 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import React, { act } from "react";
4
- import { createRoot } from "react-dom/client";
3
+ import { act } from "react";
5
4
  import { afterEach, describe, expect, it, vi } from "vitest";
6
- import { Timeline } from "./Timeline";
5
+ import {
6
+ mountThreeTrackTimeline as mountThreeTracks,
7
+ dragTimelineFixtureAsset,
8
+ pointerTimelineFixture as pointer,
9
+ } from "./timelineMountFixtures";
7
10
  import { installTimelineMountEnv } from "./timelineMountTestEnv";
8
- import { usePlayerStore } from "../store/playerStore";
9
11
  import { CLIP_Y, TRACK_H, getTimelineRowTop } from "./timelineLayout";
10
- import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
11
- import type { TimelineProps } from "./TimelineTypes";
12
12
 
13
13
  (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
14
 
@@ -21,32 +21,6 @@ afterEach(() => {
21
21
  const GHOST_MID = getTimelineRowTop(3) + TRACK_H / 2;
22
22
  const ROW1_MID = getTimelineRowTop(1) + TRACK_H / 2;
23
23
 
24
- async function mountThreeTracks(props: TimelineProps = {}) {
25
- usePlayerStore.setState({
26
- duration: 10,
27
- currentTime: 0,
28
- timelineReady: true,
29
- gsapAnimations: new Map(),
30
- selectedElementId: null,
31
- selectedElementIds: new Set(),
32
- elements: [0, 1, 2].map((track) => ({
33
- id: `c${track}`,
34
- key: `c${track}`,
35
- domId: `c${track}`,
36
- tag: "div",
37
- start: 0,
38
- duration: 2,
39
- track,
40
- })),
41
- });
42
- const host = document.createElement("div");
43
- document.body.append(host);
44
- const root = createRoot(host);
45
- await act(async () => root.render(<Timeline {...props} />));
46
- const ghost = () => host.querySelector<HTMLElement>('[data-testid="timeline-ghost-lane"]');
47
- return { host, root, ghost };
48
- }
49
-
50
24
  describe("Timeline ghost lane", () => {
51
25
  it("draws one empty track below the last with a drop hint", async () => {
52
26
  const { root, ghost } = await mountThreeTracks({ onAssetDrop: vi.fn() });
@@ -70,22 +44,8 @@ describe("Timeline ghost lane", () => {
70
44
  const onAssetDrop = vi.fn();
71
45
  const { host, root, ghost } = await mountThreeTracks({ onAssetDrop });
72
46
  const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
73
- const drag = (type: string, clientY: number) => {
74
- const event = new Event(type, { bubbles: true, cancelable: true });
75
- Object.defineProperties(event, {
76
- clientX: { value: 400 },
77
- clientY: { value: clientY },
78
- dataTransfer: {
79
- value: {
80
- types: [TIMELINE_ASSET_MIME],
81
- files: [],
82
- dropEffect: "none",
83
- getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
84
- },
85
- },
86
- });
87
- act(() => void viewport.dispatchEvent(event));
88
- };
47
+ const drag = (type: string, clientY: number) =>
48
+ dragTimelineFixtureAsset(viewport, type, clientY);
89
49
  drag("dragover", ROW1_MID);
90
50
  expect(ghost()?.dataset.active).toBeUndefined();
91
51
  drag("dragover", GHOST_MID);
@@ -100,19 +60,6 @@ describe("Timeline ghost lane", () => {
100
60
  onMoveElement: vi.fn(),
101
61
  onResizeElement: vi.fn(),
102
62
  });
103
- const pointer = (target: EventTarget, type: string, clientY: number) =>
104
- act(
105
- () =>
106
- void target.dispatchEvent(
107
- new PointerEvent(type, {
108
- bubbles: true,
109
- button: 0,
110
- pointerId: 1,
111
- clientX: 400,
112
- clientY,
113
- }),
114
- ),
115
- );
116
63
  const clipMid = getTimelineRowTop(track) + TRACK_H / 2;
117
64
  pointer(host.querySelector(`[data-clip][data-el-id="c${track}"]`)!, "pointerdown", clipMid);
118
65
  pointer(window, "pointermove", ROW1_MID);
@@ -7,7 +7,11 @@ import { Timeline } from "./Timeline";
7
7
  import { installTimelineMountEnv } from "./timelineMountTestEnv";
8
8
  import { usePlayerStore } from "../store/playerStore";
9
9
  import { CLIP_Y, RULER_H, TRACK_H, TRACKS_BOTTOM_PAD, TRACKS_TOP_PAD } from "./timelineLayout";
10
- import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
10
+ import {
11
+ mountThreeTrackTimeline,
12
+ dragTimelineFixtureAsset,
13
+ pointerTimelineFixture as pointer,
14
+ } from "./timelineMountFixtures";
11
15
  import type { TimelineProps } from "./TimelineTypes";
12
16
 
13
17
  (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -59,29 +63,7 @@ describe("Timeline trackPadding", () => {
59
63
  const hostPads = { top: 0, bottom: TRACK_H };
60
64
 
61
65
  async function mountThreeTracks(props: TimelineProps = {}) {
62
- usePlayerStore.setState({
63
- duration: 10,
64
- currentTime: 0,
65
- timelineReady: true,
66
- gsapAnimations: new Map(),
67
- selectedElementId: null,
68
- selectedElementIds: new Set(),
69
- elements: [0, 1, 2].map((track) => ({
70
- id: `c${track}`,
71
- key: `c${track}`,
72
- domId: `c${track}`,
73
- tag: "div",
74
- start: 0,
75
- duration: 2,
76
- track,
77
- })),
78
- });
79
- const host = document.createElement("div");
80
- document.body.append(host);
81
- const root = createRoot(host);
82
- await act(async () => root.render(<Timeline trackPadding={hostPads} {...props} />));
83
- const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
84
- return { host, root, viewport };
66
+ return mountThreeTrackTimeline({ trackPadding: hostPads, ...props });
85
67
  }
86
68
 
87
69
  const top = (el: Element | null | undefined) => (el as HTMLElement | null)?.style.top;
@@ -94,22 +76,8 @@ describe("Timeline trackPadding", () => {
94
76
  `${RULER_H + TRACK_H + CLIP_Y}px`,
95
77
  );
96
78
 
97
- const drag = (type: string, clientY: number) => {
98
- const event = new Event(type, { bubbles: true, cancelable: true });
99
- Object.defineProperties(event, {
100
- clientX: { value: 400 },
101
- clientY: { value: clientY },
102
- dataTransfer: {
103
- value: {
104
- types: [TIMELINE_ASSET_MIME],
105
- files: [],
106
- dropEffect: "none",
107
- getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
108
- },
109
- },
110
- });
111
- act(() => void viewport.dispatchEvent(event));
112
- };
79
+ const drag = (type: string, clientY: number) =>
80
+ dragTimelineFixtureAsset(viewport, type, clientY);
113
81
  drag("dragover", ROW1_MID);
114
82
  expect(top(host.querySelector('[data-testid="timeline-drop-preview"]'))).toBe(
115
83
  `${RULER_H + TRACK_H + CLIP_Y}px`,
@@ -128,19 +96,6 @@ describe("Timeline trackPadding", () => {
128
96
  onMoveElement: vi.fn(),
129
97
  onResizeElement: vi.fn(),
130
98
  });
131
- const pointer = (target: EventTarget, type: string, clientY: number) =>
132
- act(
133
- () =>
134
- void target.dispatchEvent(
135
- new PointerEvent(type, {
136
- bubbles: true,
137
- button: 0,
138
- pointerId: 1,
139
- clientX: 400,
140
- clientY,
141
- }),
142
- ),
143
- );
144
99
  const clip = host.querySelector('[data-clip][data-el-id="c0"]')!;
145
100
  pointer(clip, "pointerdown", RULER_H + TRACK_H / 2);
146
101
  pointer(window, "pointermove", ROW1_MID);
@@ -149,9 +104,9 @@ describe("Timeline trackPadding", () => {
149
104
  ).find((el) => el.style.zIndex === "30" && !el.dataset.testid);
150
105
  expect(top(ghost)).toBe(`${RULER_H + TRACK_H + CLIP_Y}px`);
151
106
  pointer(window, "pointermove", RULER_H + 3 * TRACK_H + TRACK_H / 2);
152
- expect(top(host.querySelector('[data-testid="timeline-insert-line"]'))).toBe(
153
- `${RULER_H + 3 * TRACK_H - 0.5}px`,
154
- );
107
+ const lane = host.querySelector<HTMLElement>("[data-timeline-new-track-lane]");
108
+ expect(top(lane)).toBe(`${RULER_H + 3 * TRACK_H}px`);
109
+ expect(lane?.style.height).toBe(`${TRACK_H}px`);
155
110
  act(() => root.unmount());
156
111
  });
157
112
  });
@@ -16,7 +16,9 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
16
16
  const props = state.canvas;
17
17
  const { draggedClip, scrollRef, displayTrackOrder } = props;
18
18
  const draggedRowIndex =
19
- draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1;
19
+ draggedClip?.started === true
20
+ ? (draggedClip.insertRow ?? displayTrackOrder.indexOf(draggedClip.previewTrack))
21
+ : -1;
20
22
  const dropTrackIndex = props.dropPreview
21
23
  ? displayTrackOrder.indexOf(props.dropPreview.track)
22
24
  : -1;
@@ -168,7 +170,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
168
170
 
169
171
  {/* Insertion line — a new track will be inserted at this boundary on drop.
170
172
  Shown while the pointer is near a lane boundary (insert mode). */}
171
- {insertLineRow != null && (
173
+ {insertLineRow != null && !draggedClip?.started && (
172
174
  <div
173
175
  data-testid="timeline-insert-line"
174
176
  className="absolute pointer-events-none"
@@ -211,6 +213,8 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
211
213
  scrollRef={scrollRef}
212
214
  pixelsPerSecond={props.pps}
213
215
  rowHeight={draggedClipHeight}
216
+ rowGeometry={props.rowGeometry}
217
+ contentOrigin={props.contentOrigin}
214
218
  selectedElementId={props.selectedElementId}
215
219
  currentTime={props.currentTime}
216
220
  theme={props.theme}