@hyperframes/studio 0.7.61 → 0.7.62

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 (84) hide show
  1. package/dist/assets/{hyperframes-player-Xvx2hkrc.js → hyperframes-player-CWpmBjzv.js} +1 -1
  2. package/dist/assets/{index-BCpoiv9S.js → index-BJzSiIBj.js} +1 -1
  3. package/dist/assets/index-Ceyz8Qt2.css +1 -0
  4. package/dist/assets/{index-D-uFclFj.js → index-DJQPB78e.js} +1 -1
  5. package/dist/assets/index-vDRVQtTK.js +426 -0
  6. package/dist/index.d.ts +12 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +3504 -2955
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +15 -10
  12. package/src/components/EditorShell.tsx +12 -0
  13. package/src/components/StudioLeftSidebar.tsx +3 -0
  14. package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
  15. package/src/components/editor/PropertyPanelFlat.tsx +6 -0
  16. package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
  17. package/src/components/editor/domEditing.test.ts +34 -0
  18. package/src/components/editor/propertyPanelColor.test.tsx +31 -0
  19. package/src/components/editor/propertyPanelColor.tsx +150 -28
  20. package/src/components/editor/propertyPanelCommitField.tsx +178 -0
  21. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +34 -0
  22. package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -0
  23. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +5 -1
  24. package/src/components/editor/propertyPanelFlatStyleSections.tsx +6 -0
  25. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
  26. package/src/components/editor/propertyPanelFlatTextSection.tsx +8 -0
  27. package/src/components/editor/propertyPanelInputCoverage.test.tsx +4 -1
  28. package/src/components/editor/propertyPanelPrimitives.tsx +3 -100
  29. package/src/components/editor/propertyPanelSections.tsx +2 -2
  30. package/src/components/editor/propertyPanelTypes.ts +2 -0
  31. package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
  32. package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
  33. package/src/components/nle/TimelinePane.tsx +6 -0
  34. package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
  35. package/src/components/sidebar/CompositionsTab.tsx +45 -2
  36. package/src/components/sidebar/LeftSidebar.tsx +3 -0
  37. package/src/contexts/StudioContext.tsx +4 -0
  38. package/src/hooks/timelineEditingHelpers.ts +10 -4
  39. package/src/hooks/usePreviewPersistence.ts +7 -1
  40. package/src/hooks/useRazorSplit.history.test.tsx +21 -28
  41. package/src/hooks/useRazorSplit.test.tsx +20 -9
  42. package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
  43. package/src/hooks/useRazorSplit.ts +78 -350
  44. package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
  45. package/src/hooks/useTimelineAssetDropOps.ts +52 -2
  46. package/src/hooks/useTimelineEditing.ts +19 -17
  47. package/src/hooks/useTimelineGroupEditing.ts +6 -7
  48. package/src/player/components/Timeline.tsx +12 -1
  49. package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
  50. package/src/player/components/timelineAuthoredTrack.ts +29 -0
  51. package/src/player/components/timelineCallbacks.ts +4 -0
  52. package/src/player/components/timelineClipDragCommit.test.ts +176 -0
  53. package/src/player/components/timelineClipDragCommit.ts +77 -105
  54. package/src/player/components/timelineClipDragPreview.test.ts +33 -1
  55. package/src/player/components/timelineClipDragPreview.ts +2 -1
  56. package/src/player/components/timelineCollision.test.ts +18 -0
  57. package/src/player/components/timelineCollision.ts +12 -9
  58. package/src/player/components/timelineDragDrop.ts +28 -7
  59. package/src/player/components/timelineEditCapabilities.ts +4 -1
  60. package/src/player/components/timelineGroupEditing.test.ts +10 -0
  61. package/src/player/components/timelineGroupEditing.ts +4 -3
  62. package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
  63. package/src/player/components/timelineLayout.ts +3 -1
  64. package/src/player/components/timelineZMirror.ts +1 -1
  65. package/src/player/components/useTimelineClipDrag.ts +5 -0
  66. package/src/player/components/useTimelineEditPinning.ts +12 -0
  67. package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
  68. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  69. package/src/player/lib/playbackTypes.ts +2 -0
  70. package/src/player/lib/timelineDOM.test.ts +58 -0
  71. package/src/player/lib/timelineDOM.ts +18 -1
  72. package/src/player/lib/timelineElementHelpers.ts +21 -11
  73. package/src/player/store/playerStore.ts +2 -1
  74. package/src/utils/razorSplitTransaction.test.ts +175 -0
  75. package/src/utils/razorSplitTransaction.ts +200 -0
  76. package/src/utils/studioPreviewHelpers.test.ts +27 -0
  77. package/src/utils/timelineCompositionDrop.test.ts +12 -0
  78. package/src/utils/timelineCompositionDrop.ts +16 -0
  79. package/src/utils/timelineCompositionInsert.test.ts +142 -0
  80. package/src/utils/timelineCompositionInsert.ts +90 -0
  81. package/src/utils/timelineElementSplit.test.ts +14 -2
  82. package/src/utils/timelineElementSplit.ts +5 -1
  83. package/dist/assets/index-2mxh_HSy.js +0 -426
  84. package/dist/assets/index-BhWig0mx.css +0 -1
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
- import { computeDragPreview, type DragPreviewContext } from "./timelineClipDragPreview";
3
+ import {
4
+ computeDragPreview,
5
+ computeResizePreview,
6
+ type DragPreviewContext,
7
+ } from "./timelineClipDragPreview";
4
8
  import type { DraggedClipState } from "./timelineClipDragTypes";
5
9
  import { RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
6
10
 
@@ -142,3 +146,31 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
142
146
  expect(next.insertRow).toBe(0); // a new TOP track will be created on drop
143
147
  });
144
148
  });
149
+
150
+ describe("computeResizePreview — composition source continuity", () => {
151
+ it("seeds a legacy composition offset and advances it at playback rate", () => {
152
+ const element = {
153
+ ...clip("comp", 0, 2, 4, 0, "div"),
154
+ kind: "composition" as const,
155
+ playbackRate: 2,
156
+ };
157
+ const result = computeResizePreview(
158
+ {
159
+ element,
160
+ edge: "start",
161
+ originClientX: 0,
162
+ previewStart: 2,
163
+ previewDuration: 4,
164
+ started: true,
165
+ },
166
+ 100,
167
+ { scroll: fakeScroll(), pps: 100, buildSnapTargets: () => [] },
168
+ );
169
+
170
+ expect(result).toMatchObject({
171
+ previewStart: 3,
172
+ previewDuration: 3,
173
+ previewPlaybackStart: 2,
174
+ });
175
+ });
176
+ });
@@ -221,7 +221,8 @@ export function computeResizePreview(
221
221
  )
222
222
  : Number.POSITIVE_INFINITY;
223
223
  const normalizedTag = resize.element.tag.toLowerCase();
224
- const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
224
+ const canSeedPlaybackStart =
225
+ resize.element.kind === "composition" || normalizedTag === "audio" || normalizedTag === "video";
225
226
  const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1);
226
227
  // Trim limit = available source media only — NOT the composition length.
227
228
  // Duration is content-driven (the comp grows/shrinks to fit on commit), so
@@ -308,6 +308,24 @@ describe("resolveZoneDropPlacement (the whole drop decision, no same-track overl
308
308
  ).toEqual({ track: 2, insertRow: null });
309
309
  });
310
310
 
311
+ it("crosses an occupied aim instead of snapping the dragged clip back to its origin", () => {
312
+ expect(
313
+ resolveZoneDropPlacement({
314
+ ...base,
315
+ elements: [el("a", 0, 0, 5), el("b", 1, 0, 5), el("x", 2, 0, 5)],
316
+ desiredTrack: 1,
317
+ }),
318
+ ).toEqual({ track: 1, insertRow: 1 });
319
+
320
+ expect(
321
+ resolveZoneDropPlacement({
322
+ ...base,
323
+ elements: [el("x", 0, 0, 5), el("b", 1, 0, 5), el("a", 2, 0, 5)],
324
+ desiredTrack: 1,
325
+ }),
326
+ ).toEqual({ track: 1, insertRow: 2 });
327
+ });
328
+
311
329
  it("auto-creates a new track when EVERY lane in the zone is occupied at that time", () => {
312
330
  expect(
313
331
  resolveZoneDropPlacement({
@@ -115,7 +115,10 @@ export function resolveZoneDropPlacement(input: {
115
115
  trackOrder: zoneTracks,
116
116
  excludeKey: dragKey,
117
117
  });
118
- if (placement.needsInsert) {
118
+ const originTrack = elements.find((element) => (element.key ?? element.id) === dragKey)?.track;
119
+ const snappedBackToOrigin =
120
+ originTrack != null && desired !== originTrack && placement.track === originTrack;
121
+ if (placement.needsInsert || snappedBackToOrigin) {
119
122
  const desiredRow = order.indexOf(desired);
120
123
  if (desiredRow < 0) {
121
124
  return {
@@ -123,14 +126,14 @@ export function resolveZoneDropPlacement(input: {
123
126
  insertRow: outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired),
124
127
  };
125
128
  }
126
- // Prefer the gap NEAREST the pointer: insert above the aimed row when the
127
- // pointer sits in its upper half AND that boundary is in the clip's own zone
128
- // (else the visual/audio split would be crossed) otherwise fall to below.
129
- // `desired` is clamped into the zone, so both boundaries stay in-zone.
130
- const insertRow =
131
- preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio)
132
- ? desiredRow
133
- : desiredRow + 1;
129
+ // When collision fallback found only the origin lane, insert on the far side
130
+ // of the aimed lane so normalization cannot turn the gesture into a no-op.
131
+ // Otherwise prefer the gap nearest the pointer, preserving normal insertion.
132
+ const originRow = originTrack == null ? -1 : order.indexOf(originTrack);
133
+ const insertAbove = snappedBackToOrigin
134
+ ? originRow > desiredRow
135
+ : preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio);
136
+ const insertRow = insertAbove ? desiredRow : desiredRow + 1;
134
137
  return { track: desired, insertRow };
135
138
  }
136
139
  return { track: placement.track, insertRow: null };
@@ -1,5 +1,9 @@
1
1
  import { useCallback, useState, type RefObject } from "react";
2
2
  import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
3
+ import {
4
+ parseTimelineCompositionPayload,
5
+ TIMELINE_COMPOSITION_MIME,
6
+ } from "../../utils/timelineCompositionDrop";
3
7
  import { usePlayerStore } from "../store/playerStore";
4
8
  import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
5
9
  import type { TimelineDropCallbacks } from "./timelineCallbacks";
@@ -32,6 +36,11 @@ function applyJsonDropPayload(
32
36
  }
33
37
  }
34
38
 
39
+ function resolveDropStart(usePointerStart: boolean, pointerStart: number): number {
40
+ if (usePointerStart) return pointerStart;
41
+ return Math.max(0, usePlayerStore.getState().currentTime);
42
+ }
43
+
35
44
  /**
36
45
  * Dropping an asset/file/block onto the timeline places it at the PLAYHEAD —
37
46
  * start is the current playhead time, only the track comes from the drop y.
@@ -48,6 +57,7 @@ export function useTimelineAssetDrop({
48
57
  onFileDrop,
49
58
  onAssetDrop,
50
59
  onBlockDrop,
60
+ onCompositionDrop,
51
61
  }: UseTimelineAssetDropOptions) {
52
62
  const [isDragOver, setIsDragOver] = useState(false);
53
63
 
@@ -56,7 +66,8 @@ export function useTimelineAssetDrop({
56
66
  const hasFiles = types.includes("Files");
57
67
  const hasAsset = types.includes(TIMELINE_ASSET_MIME);
58
68
  const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
59
- if (!hasFiles && !hasAsset && !hasBlock) return;
69
+ const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME);
70
+ if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return;
60
71
  e.preventDefault();
61
72
  e.dataTransfer.dropEffect = "copy";
62
73
  setIsDragOver(true);
@@ -65,11 +76,10 @@ export function useTimelineAssetDrop({
65
76
  const clearDropPreview = useCallback(() => setIsDragOver(false), []);
66
77
 
67
78
  const resolveDropPlacement = useCallback(
68
- (clientX: number, clientY: number): TimelinePlacement => {
79
+ (clientX: number, clientY: number, usePointerStart = false): TimelinePlacement => {
69
80
  const scroll = scrollRef.current;
70
81
  const rect = scroll?.getBoundingClientRect();
71
- // Track comes from the vertical drop position; start is the playhead.
72
- const { track } = resolveTimelineAssetDrop(
82
+ const pointer = resolveTimelineAssetDrop(
73
83
  {
74
84
  rectLeft: rect?.left ?? 0,
75
85
  rectTop: rect?.top ?? 0,
@@ -77,14 +87,17 @@ export function useTimelineAssetDrop({
77
87
  scrollTop: scroll?.scrollTop ?? 0,
78
88
  pixelsPerSecond: ppsRef.current,
79
89
  duration: durationRef.current,
90
+ clampStartToDuration: !usePointerStart,
80
91
  trackHeight: TRACK_H,
81
92
  trackOrder: trackOrderRef.current,
82
93
  },
83
94
  clientX,
84
95
  clientY,
85
96
  );
86
- const start = Math.max(0, usePlayerStore.getState().currentTime);
87
- return { start, track };
97
+ return {
98
+ start: resolveDropStart(usePointerStart, pointer.start),
99
+ track: pointer.track,
100
+ };
88
101
  },
89
102
  [scrollRef, ppsRef, durationRef, trackOrderRef],
90
103
  );
@@ -93,6 +106,14 @@ export function useTimelineAssetDrop({
93
106
  (e: React.DragEvent) => {
94
107
  e.preventDefault();
95
108
  setIsDragOver(false);
109
+ const compositionPayload = parseTimelineCompositionPayload(
110
+ e.dataTransfer.getData(TIMELINE_COMPOSITION_MIME),
111
+ );
112
+ if (compositionPayload && onCompositionDrop) {
113
+ const placement = resolveDropPlacement(e.clientX, e.clientY, true);
114
+ void onCompositionDrop(compositionPayload.sourcePath, placement);
115
+ return;
116
+ }
96
117
  const placement = resolveDropPlacement(e.clientX, e.clientY);
97
118
 
98
119
  if (onFileDrop && e.dataTransfer.files.length > 0) {
@@ -109,7 +130,7 @@ export function useTimelineAssetDrop({
109
130
  applyJsonDropPayload(blockPayload, (p) => p.name, onBlockDrop, placement);
110
131
  }
111
132
  },
112
- [resolveDropPlacement, onFileDrop, onAssetDrop, onBlockDrop],
133
+ [resolveDropPlacement, onFileDrop, onAssetDrop, onBlockDrop, onCompositionDrop],
113
134
  );
114
135
 
115
136
  return { isDragOver, handleAssetDragOver, handleAssetDrop, clearDropPreview };
@@ -6,11 +6,13 @@ export interface TimelineEditCapabilities {
6
6
 
7
7
  function isDeterministicTimelineWindow(input: {
8
8
  tag: string;
9
+ kind?: "video" | "audio" | "image" | "element" | "composition";
9
10
  compositionSrc?: string;
10
11
  playbackStartAttr?: "media-start" | "playback-start";
11
12
  sourceDuration?: number;
12
13
  }): boolean {
13
- if (input.compositionSrc || input.playbackStartAttr != null) return true;
14
+ if (input.kind === "composition" || input.compositionSrc || input.playbackStartAttr != null)
15
+ return true;
14
16
  if (
15
17
  input.sourceDuration != null &&
16
18
  Number.isFinite(input.sourceDuration) &&
@@ -27,6 +29,7 @@ export function hasPatchableTimelineTarget(input: { domId?: string; selector?: s
27
29
 
28
30
  export function getTimelineEditCapabilities(input: {
29
31
  tag: string;
32
+ kind?: "video" | "audio" | "image" | "element" | "composition";
30
33
  duration: number;
31
34
  domId?: string;
32
35
  selector?: string;
@@ -53,6 +53,16 @@ describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => {
53
53
  ]);
54
54
  });
55
55
 
56
+ it("seeds legacy composition offsets and advances them at playback rate", () => {
57
+ const a = el("a", { kind: "composition", tag: "div", start: 2, playbackRate: 2 });
58
+ const b = el("b", { kind: "composition", tag: "div", start: 5, playbackRate: 0.5 });
59
+ const members = buildTimelineGroupResizeMembers([a, b], keys("a", "b"), "a", "start")!;
60
+
61
+ expect(members.map((member) => member.playbackStart)).toEqual([0, 0]);
62
+ const changes = resolveTimelineGroupResizeChanges(members, "start", 1);
63
+ expect(changes.map((change) => change.playbackStart)).toEqual([2, 0.5]);
64
+ });
65
+
56
66
  it("does not seed playbackStart on the END edge", () => {
57
67
  const grabbed = el("a", { tag: "audio", start: 0, duration: 2 });
58
68
  const b = el("b", { tag: "audio", start: 3, duration: 2 });
@@ -181,14 +181,15 @@ function elementKey(element: TimelineElement): string {
181
181
  return element.key ?? element.id;
182
182
  }
183
183
 
184
- function isMediaTimelineElement(element: TimelineElement): boolean {
184
+ function hasSourcePlaybackOffset(element: TimelineElement): boolean {
185
185
  const tag = element.tag.toLowerCase();
186
- return tag === "audio" || tag === "video";
186
+ return element.kind === "composition" || tag === "audio" || tag === "video";
187
187
  }
188
188
 
189
189
  function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): boolean {
190
190
  const caps = getTimelineEditCapabilities({
191
191
  tag: element.tag,
192
+ kind: element.kind,
192
193
  duration: element.duration,
193
194
  domId: element.domId,
194
195
  selector: element.selector,
@@ -228,7 +229,7 @@ export function buildTimelineGroupResizeMembers(
228
229
  start: element.start,
229
230
  duration: element.duration,
230
231
  playbackStart:
231
- edge === "start" && isMediaTimelineElement(element)
232
+ edge === "start" && hasSourcePlaybackOffset(element)
232
233
  ? (element.playbackStart ?? 0)
233
234
  : element.playbackStart,
234
235
  playbackRate: element.playbackRate,
@@ -0,0 +1,14 @@
1
+ export interface LaneMoveRefreshDeps {
2
+ refreshAfterLaneMove?: () => void;
3
+ }
4
+
5
+ /** Refresh only after the complete lane transaction persisted successfully. */
6
+ export function refreshAfterDurableLaneMove(
7
+ pending: Promise<boolean>,
8
+ deps: LaneMoveRefreshDeps,
9
+ ): Promise<boolean> {
10
+ return pending.then((persisted) => {
11
+ if (persisted) deps.refreshAfterLaneMove?.();
12
+ return persisted;
13
+ });
14
+ }
@@ -383,6 +383,7 @@ export function resolveTimelineAssetDrop(
383
383
  scrollTop: number;
384
384
  pixelsPerSecond: number;
385
385
  duration: number;
386
+ clampStartToDuration?: boolean;
386
387
  trackHeight: number;
387
388
  trackOrder: number[];
388
389
  },
@@ -391,9 +392,10 @@ export function resolveTimelineAssetDrop(
391
392
  ): { start: number; track: number } {
392
393
  const x = clientX - input.rectLeft + input.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
393
394
  const contentY = clientY - input.rectTop + input.scrollTop;
395
+ const pointerStart = Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100;
394
396
  const start = Math.max(
395
397
  0,
396
- Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100),
398
+ input.clampStartToDuration === false ? pointerStart : Math.min(input.duration, pointerStart),
397
399
  );
398
400
  // Row from the shared row→y inverse so the top pad is honoured; a drop in the
399
401
  // pad above the first lane floors to row 0, a drop in the bottom pad rounds
@@ -1,7 +1,7 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
2
  import { classifyZone } from "./timelineZones";
3
3
  import { isLaneFree, timeRangesOverlap } from "./timelineCollision";
4
- import { authoredTrackForLane, sameSourceFile } from "./timelineClipDragCommit";
4
+ import { authoredTrackForLane, sameSourceFile } from "./timelineAuthoredTrack";
5
5
  import { samePaintScope } from "./timelineStackingSync";
6
6
 
7
7
  /**
@@ -77,6 +77,7 @@ interface UseTimelineClipDragInput {
77
77
  */
78
78
  readZIndex?: (element: TimelineElement) => number;
79
79
  onStackingPatches?: (patches: StackingPatch[]) => Promise<unknown> | void;
80
+ refreshAfterLaneMove?: () => void;
80
81
  }
81
82
 
82
83
  export function useTimelineClipDrag({
@@ -93,6 +94,7 @@ export function useTimelineClipDrag({
93
94
  setRangeSelectionRef,
94
95
  readZIndex,
95
96
  onStackingPatches,
97
+ refreshAfterLaneMove,
96
98
  }: UseTimelineClipDragInput) {
97
99
  const updateElement = usePlayerStore((s) => s.updateElement);
98
100
  const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
@@ -213,6 +215,8 @@ export function useTimelineClipDrag({
213
215
  readZIndexRef.current = readZIndex;
214
216
  const onStackingPatchesRef = useRef(onStackingPatches);
215
217
  onStackingPatchesRef.current = onStackingPatches;
218
+ const refreshAfterLaneMoveRef = useRef(refreshAfterLaneMove);
219
+ refreshAfterLaneMoveRef.current = refreshAfterLaneMove;
216
220
 
217
221
  const clipDragScrollRaf = useRef(0);
218
222
  const clipDragPointerRef = useRef<{
@@ -499,6 +503,7 @@ export function useTimelineClipDrag({
499
503
  // deps (Timeline.tsx). Absent → commitDraggedClipMove skips the z-sync.
500
504
  readZIndex: readZIndexRef.current,
501
505
  onStackingPatches: onStackingPatchesRef.current,
506
+ refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
502
507
  });
503
508
  };
504
509
 
@@ -12,6 +12,7 @@ interface UseTimelineEditPinningInput {
12
12
  onFileDrop: TimelineDropCallbacks["onFileDrop"];
13
13
  onAssetDrop: TimelineDropCallbacks["onAssetDrop"];
14
14
  onBlockDrop: TimelineDropCallbacks["onBlockDrop"];
15
+ onCompositionDrop: TimelineDropCallbacks["onCompositionDrop"];
15
16
  }
16
17
 
17
18
  // Wrap every mutating timeline edit so the zoom pins to the current on-screen
@@ -29,6 +30,7 @@ export function useTimelineEditPinning({
29
30
  onFileDrop,
30
31
  onAssetDrop,
31
32
  onBlockDrop,
33
+ onCompositionDrop,
32
34
  }: UseTimelineEditPinningInput) {
33
35
  const pinTimelineZoom = usePlayerStore((s) => s.pinTimelineZoom);
34
36
  // Pin the timeline zoom to the current on-screen scale on the FIRST edit, so a
@@ -106,6 +108,15 @@ export function useTimelineEditPinning({
106
108
  }),
107
109
  [onBlockDrop, pinZoomBeforeEdit],
108
110
  );
111
+ const pinnedOnCompositionDrop = useMemo(
112
+ () =>
113
+ onCompositionDrop &&
114
+ ((...args: Parameters<typeof onCompositionDrop>) => {
115
+ pinZoomBeforeEdit();
116
+ return onCompositionDrop(...args);
117
+ }),
118
+ [onCompositionDrop, pinZoomBeforeEdit],
119
+ );
109
120
 
110
121
  return {
111
122
  pinZoomBeforeEdit,
@@ -117,5 +128,6 @@ export function useTimelineEditPinning({
117
128
  pinnedOnFileDrop,
118
129
  pinnedOnAssetDrop,
119
130
  pinnedOnBlockDrop,
131
+ pinnedOnCompositionDrop,
120
132
  };
121
133
  }
@@ -48,9 +48,51 @@ describe("buildExpandedElements", () => {
48
48
  const out = buildExpandedElements(elements, manifest, parentMap, "s3", "s3");
49
49
  const child = out.find((e) => e.domId === "stat-1")!;
50
50
  expect(child.expandedParentStart).toBe(16);
51
+ expect(child.expandedHostKey).toBe("s3");
51
52
  expect(child.sourceFile).toBe("stats.html");
52
53
  });
53
54
 
55
+ it("keeps repeated same-source composition hosts as distinct move identities", () => {
56
+ const elements = [
57
+ el({
58
+ id: "host-a",
59
+ key: "index.html#host-a",
60
+ start: 0,
61
+ duration: 5,
62
+ compositionSrc: "scene.html",
63
+ }),
64
+ el({
65
+ id: "host-b",
66
+ key: "index.html#host-b",
67
+ start: 8,
68
+ duration: 5,
69
+ compositionSrc: "scene.html",
70
+ }),
71
+ ];
72
+ const manifest = [
73
+ clip({ id: "host-a", start: 0, duration: 5, compositionSrc: "scene.html" }),
74
+ clip({ id: "child-a", start: 1, duration: 2 }),
75
+ clip({ id: "host-b", start: 8, duration: 5, compositionSrc: "scene.html" }),
76
+ clip({ id: "child-b", start: 9, duration: 2 }),
77
+ ];
78
+ const parentMap = new Map([
79
+ ["child-a", "host-a"],
80
+ ["child-b", "host-b"],
81
+ ]);
82
+
83
+ const childA = buildExpandedElements(elements, manifest, parentMap, "host-a", "host-a").find(
84
+ (element) => element.domId === "child-a",
85
+ );
86
+ const childB = buildExpandedElements(elements, manifest, parentMap, "host-b", "host-b").find(
87
+ (element) => element.domId === "child-b",
88
+ );
89
+
90
+ expect(childA?.sourceFile).toBe("scene.html");
91
+ expect(childB?.sourceFile).toBe("scene.html");
92
+ expect(childA?.expandedHostKey).toBe("index.html#host-a");
93
+ expect(childB?.expandedHostKey).toBe("index.html#host-b");
94
+ });
95
+
54
96
  // fallow-ignore-next-line code-duplication
55
97
  it("rebases a 2-level child onto its NESTED host, not the top-level scene", () => {
56
98
  // top host A@10 (a.html) embeds host B@12 (b.html); child C lives in b.html.
@@ -133,6 +133,7 @@ function buildChildElements(
133
133
  siblings: ClipManifestClip[],
134
134
  display: DisplayBounds,
135
135
  editBasis: { start: number; sourceFile: string | undefined },
136
+ expandedHostKey: string,
136
137
  ): TimelineElement[] {
137
138
  const result: TimelineElement[] = [];
138
139
  for (const child of siblings) {
@@ -182,6 +183,7 @@ function buildChildElements(
182
183
  authoredTrack: base.authoredTrack,
183
184
  stackingContextId: base.stackingContextId,
184
185
  expandedParentStart: editBasis.start,
186
+ expandedHostKey,
185
187
  domId,
186
188
  selector,
187
189
  sourceFile: editBasis.sourceFile,
@@ -260,6 +262,7 @@ export function buildExpandedElements(
260
262
  track: topLevelElement.track,
261
263
  },
262
264
  editBasis,
265
+ parentKey,
263
266
  );
264
267
  if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
265
268
 
@@ -46,6 +46,8 @@ export interface ClipManifestClip {
46
46
  compositionAncestors?: string[];
47
47
  parentCompositionId: string | null;
48
48
  compositionSrc: string | null;
49
+ playbackStart?: number;
50
+ playbackRate?: number;
49
51
  assetUrl: string | null;
50
52
  }
51
53
 
@@ -110,7 +110,65 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
110
110
  });
111
111
  });
112
112
 
113
+ describe("parseTimelineFromDOM — canonical playback rate", () => {
114
+ it.each([
115
+ ["10", 5],
116
+ ["0.01", 0.1],
117
+ ])("clamps authored rate %s to %s for trim and split math", (authored, expected) => {
118
+ const doc = makeDoc(`
119
+ <div data-composition-id="root">
120
+ <div id="nested" class="clip" data-composition-src="scene.html"
121
+ data-start="0" data-duration="5" data-playback-rate="${authored}"></div>
122
+ </div>
123
+ `);
124
+
125
+ const nested = parseTimelineFromDOM(doc, 10).find((entry) => entry.domId === "nested");
126
+
127
+ expect(nested?.playbackRate).toBe(expected);
128
+ });
129
+ });
130
+
113
131
  describe("createTimelineElementFromManifestClip — source-scoped selector identity", () => {
132
+ it("preserves composition kind and source timing on first translation", () => {
133
+ const doc = makeDoc(`
134
+ <div data-composition-id="root" data-composition-file="index.html">
135
+ <div id="host" data-composition-id="scene" data-composition-src="scene.html"
136
+ data-playback-start="1.5" data-playback-rate="2"></div>
137
+ </div>
138
+ `);
139
+ const host = doc.getElementById("host");
140
+
141
+ const element = createTimelineElementFromManifestClip({
142
+ clip: {
143
+ id: "host",
144
+ label: "Scene",
145
+ kind: "composition",
146
+ tagName: "div",
147
+ start: 2,
148
+ duration: 4,
149
+ track: 0,
150
+ compositionId: "scene",
151
+ parentCompositionId: "root",
152
+ compositionSrc: "scene.html",
153
+ playbackStart: 1.5,
154
+ playbackRate: 2,
155
+ assetUrl: null,
156
+ },
157
+ fallbackIndex: 0,
158
+ doc,
159
+ hostEl: host,
160
+ });
161
+
162
+ expect(element).toMatchObject({
163
+ kind: "composition",
164
+ compositionSrc: "scene.html",
165
+ playbackStart: 1.5,
166
+ playbackStartAttr: "playback-start",
167
+ playbackRate: 2,
168
+ domId: "host",
169
+ });
170
+ });
171
+
114
172
  it("ignores an index.html duplicate when indexing a scene.html selector", () => {
115
173
  const doc = makeDoc(`
116
174
  <div data-composition-id="root" data-composition-file="index.html">
@@ -111,6 +111,7 @@ export function createTimelineElementFromManifestClip(params: {
111
111
  id: identity.id,
112
112
  label,
113
113
  key: identity.key,
114
+ kind: clip.kind,
114
115
  tag: resolveClipTag(clip),
115
116
  start: clip.start,
116
117
  duration: clip.duration,
@@ -129,6 +130,8 @@ export function createTimelineElementFromManifestClip(params: {
129
130
  selector,
130
131
  selectorIndex,
131
132
  sourceFile,
133
+ playbackStart: clip.playbackStart,
134
+ playbackRate: clip.playbackRate,
132
135
  };
133
136
 
134
137
  if (hostEl) {
@@ -140,6 +143,8 @@ export function createTimelineElementFromManifestClip(params: {
140
143
  }
141
144
  if (clip.assetUrl) entry.src = clip.assetUrl;
142
145
  if (clip.kind === "composition" && clip.compositionId) {
146
+ entry.playbackStart ??= 0;
147
+ entry.playbackRate ??= 1;
143
148
  let resolvedSrc = clip.compositionSrc;
144
149
  if (!resolvedSrc) {
145
150
  hostEl =
@@ -293,6 +298,14 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
293
298
  id: identity.id,
294
299
  label,
295
300
  key: identity.key,
301
+ kind:
302
+ compId && compId !== rootComp?.getAttribute("data-composition-id")
303
+ ? "composition"
304
+ : tagLower === "video" || tagLower === "audio"
305
+ ? tagLower
306
+ : tagLower === "img"
307
+ ? "image"
308
+ : "element",
296
309
  tag: tagLower,
297
310
  start,
298
311
  duration: dur,
@@ -308,13 +321,13 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
308
321
  };
309
322
 
310
323
  const mediaEl = resolveMediaElement(el);
324
+ applyMediaMetadataFromElement(entry, el);
311
325
  if (mediaEl) {
312
326
  if (mediaEl.tagName === "IMG") {
313
327
  entry.tag = "img";
314
328
  }
315
329
  const vol = el.getAttribute("data-volume") ?? mediaEl.getAttribute("data-volume");
316
330
  if (vol) entry.volume = parseFloat(vol);
317
- applyMediaMetadataFromElement(entry, el);
318
331
  // Override AFTER the helper (which sets the raw relative attribute) so the
319
332
  // resolved absolute URL wins — the Studio can then fetch the asset
320
333
  // regardless of whether the attribute value was relative or absolute.
@@ -345,6 +358,10 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
345
358
  entry.tag = "video";
346
359
  }
347
360
  }
361
+ if (entry.kind === "composition") {
362
+ entry.playbackStart ??= 0;
363
+ entry.playbackRate ??= 1;
364
+ }
348
365
 
349
366
  els.push(entry);
350
367
  });