@hyperframes/studio 0.8.90 → 0.8.92

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 (102) hide show
  1. package/dist/assets/{hyperframes-player-DDjER_ii.js → hyperframes-player-DKJPDHUp.js} +1 -1
  2. package/dist/assets/{index-BKE3_N4v.js → index-C5_j5-3u.js} +178 -178
  3. package/dist/assets/{index-5SFeUXP8.js → index-CHZ4kFWm.js} +1 -1
  4. package/dist/assets/{index-CVsCxnlz.js → index-DpzpqJ_j.js} +1 -1
  5. package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
  6. package/dist/chunk-QPSOTK6M.js.map +1 -0
  7. package/dist/index.d.ts +18 -14
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +9190 -8916
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/components/TimelineToolbar.test.tsx +12 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +5 -1
  15. package/src/components/editor/commitPreflightHandles.test.tsx +7 -20
  16. package/src/components/editor/elementPositionOffset.test.ts +62 -0
  17. package/src/components/editor/elementPositionOffset.ts +64 -0
  18. package/src/components/editor/motionPathCommit.test.ts +25 -5
  19. package/src/components/editor/motionPathSelection.ts +10 -3
  20. package/src/components/editor/useLayerRevealOverride.test.ts +8 -0
  21. package/src/components/editor/useLayerRevealOverride.ts +2 -1
  22. package/src/components/nle/NLEContext.tsx +1 -1
  23. package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
  24. package/src/hooks/domEditPersistFailure.ts +8 -6
  25. package/src/hooks/elementOffsetStager.ts +65 -0
  26. package/src/hooks/gestureTransaction.ts +4 -2
  27. package/src/hooks/gsapDragCommit.ts +1 -0
  28. package/src/hooks/gsapEditOutcome.ts +2 -1
  29. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
  30. package/src/hooks/gsapRuntimeBridge.ts +50 -14
  31. package/src/hooks/gsapShared.test.ts +1 -1
  32. package/src/hooks/gsapShared.ts +16 -12
  33. package/src/hooks/gsapTweenReach.ts +30 -0
  34. package/src/hooks/timelineEditingHelpers.ts +2 -1
  35. package/src/hooks/useClipboard.source.test.ts +58 -0
  36. package/src/hooks/useClipboard.ts +12 -3
  37. package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
  38. package/src/hooks/useDomEditCommits.ts +2 -0
  39. package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
  40. package/src/hooks/useDomEditSession.ts +2 -0
  41. package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
  42. package/src/hooks/useDomGeometryCommit.ts +42 -1
  43. package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
  44. package/src/hooks/useDomGeometryCommits.ts +14 -1
  45. package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
  46. package/src/hooks/useGsapAwareEditing.ts +44 -7
  47. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
  48. package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
  49. package/src/hooks/useRazorSplit.test.ts +5 -5
  50. package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
  51. package/src/hooks/useTimelineAssetDropOps.ts +40 -18
  52. package/src/hooks/useTimelineEditing.test.tsx +384 -0
  53. package/src/hooks/useTimelineEditing.ts +12 -12
  54. package/src/hooks/useTimelineGroupEditing.ts +16 -7
  55. package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
  56. package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
  57. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  58. package/src/player/components/TimelineProvider.tsx +1 -0
  59. package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
  60. package/src/player/components/TimelineTypes.ts +1 -1
  61. package/src/player/components/timelineClipDragCommit.test.ts +32 -66
  62. package/src/player/components/timelineClipDragCommit.ts +19 -37
  63. package/src/player/components/timelineClipDragPreview.test.ts +283 -24
  64. package/src/player/components/timelineClipDragPreview.ts +51 -83
  65. package/src/player/components/timelineClipDragTypes.ts +3 -8
  66. package/src/player/components/timelineCollision.test.ts +100 -302
  67. package/src/player/components/timelineCollision.ts +73 -160
  68. package/src/player/components/timelineDragDrop.test.tsx +19 -5
  69. package/src/player/components/timelineDragDrop.ts +7 -9
  70. package/src/player/components/timelineDragLanding.ts +0 -49
  71. package/src/player/components/timelineEditing.ts +2 -1
  72. package/src/player/components/timelineGroupEditing.ts +7 -2
  73. package/src/player/components/timelineLayout.test.ts +0 -25
  74. package/src/player/components/timelineLayout.ts +0 -16
  75. package/src/player/components/timelineMultiDragPreview.test.ts +34 -23
  76. package/src/player/components/timelineMultiDragPreview.ts +26 -25
  77. package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
  78. package/src/player/components/timelineProviderStateBuilders.ts +14 -6
  79. package/src/player/components/timelineShellMeta.tsx +99 -0
  80. package/src/player/components/timelineZMirror.test.ts +2 -2
  81. package/src/player/components/timelineZMirror.ts +1 -1
  82. package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
  83. package/src/player/components/useTimelineClipDrag.ts +3 -2
  84. package/src/player/components/useTimelineDragPreviewState.ts +34 -0
  85. package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
  86. package/src/player/components/useTimelinePlayhead.ts +56 -27
  87. package/src/player/components/useTimelineProviderState.tsx +29 -66
  88. package/src/player/components/useTimelineZoom.ts +9 -1
  89. package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -4
  91. package/src/player/lib/timelineDOM.test.ts +79 -0
  92. package/src/player/lib/timelineDOM.ts +16 -7
  93. package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
  94. package/src/player/lib/timelineIframeHelpers.ts +12 -3
  95. package/src/player/store/liveTime.ts +6 -0
  96. package/src/player/store/playerStore.ts +2 -0
  97. package/src/player/store/timelineElement.ts +28 -3
  98. package/src/utils/razorSplitTransaction.test.ts +1 -1
  99. package/src/utils/razorSplitTransaction.ts +3 -3
  100. package/src/utils/timelineDropTrackInsert.test.ts +60 -1
  101. package/src/utils/timelineDropTrackInsert.ts +36 -5
  102. package/dist/chunk-KU4F7QZ3.js.map +0 -1
@@ -6,6 +6,7 @@ import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
6
6
  import { mountReactHarness } from "./domSelectionTestHarness";
7
7
  import { usePlayerStore } from "../player/store/playerStore";
8
8
  import type { TimelineElement } from "../player";
9
+ import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
9
10
 
10
11
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
12
 
@@ -140,6 +141,143 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
140
141
  expect(written).toMatch(/<video id="clip"[^>]*data-track-index="1"/);
141
142
  });
142
143
 
144
+ // A clip shown on timeline row `row` and written on file track `track`.
145
+ const rowClip = (
146
+ id: string,
147
+ start: number,
148
+ duration: number,
149
+ shown: { tag?: string; row?: number; track?: number } = {},
150
+ ): TimelineElement => ({
151
+ id,
152
+ key: id,
153
+ tag: shown.tag ?? "div",
154
+ start,
155
+ duration,
156
+ track: shown.row ?? 0,
157
+ authoredTrack: shown.track ?? shown.row ?? 0,
158
+ hfId: `hf-${id}`,
159
+ domId: id,
160
+ });
161
+ const rowSource = (clips: TimelineElement[]) =>
162
+ [
163
+ '<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080">',
164
+ ...clips.map(
165
+ (c) =>
166
+ `<${c.tag} data-hf-id="hf-${c.id}" id="${c.id}" data-start="${c.start}" data-duration="${c.duration}" data-track-index="${c.authoredTrack}"></${c.tag}>`,
167
+ ),
168
+ "</main>",
169
+ ].join("\n");
170
+ const writtenClip = (written: string, tag: string) =>
171
+ written.match(new RegExp(`<${tag} id="[^"]*"[^>]*>`))?.[0] ?? "";
172
+
173
+ it("lands an asset dropped on an occupied row at that row's nearest free time", async () => {
174
+ const subtitle = rowClip("subtitle", 0, 6);
175
+ const writeProjectFile = vi.fn().mockResolvedValue(undefined);
176
+ const getDrop = renderDropHook(rowSource([subtitle]), writeProjectFile, [subtitle]);
177
+
178
+ await act(async () => {
179
+ await getDrop()("clip.mp4", { start: 2, track: 0 }, 5);
180
+ });
181
+
182
+ const [, written] = writeProjectFile.mock.calls[0] as [string, string];
183
+ const clip = writtenClip(written, "video");
184
+ expect(clip).toContain('data-start="6"');
185
+ expect(clip).toContain('data-track-index="0"');
186
+ expect(written).toContain(
187
+ 'id="subtitle" data-start="0" data-duration="6" data-track-index="0"',
188
+ );
189
+ });
190
+
191
+ // Drops two files (3 s images by default) onto `clips`; returns each written clip's start and track.
192
+ async function twoFileDrop(
193
+ clips: TimelineElement[],
194
+ placement: TimelineDropPlacement,
195
+ [names, tag] = [["one.png", "two.png"], "img"] as [string[], string],
196
+ ) {
197
+ const writeProjectFile = vi.fn().mockResolvedValue(undefined);
198
+ const uploadProjectFiles = vi.fn().mockResolvedValue(names);
199
+ const getDrop = renderDropHook(
200
+ rowSource(clips),
201
+ writeProjectFile,
202
+ clips,
203
+ undefined,
204
+ uploadProjectFiles,
205
+ );
206
+ const files = names.map((name) => new File([name], name));
207
+ await act(async () => {
208
+ await getDrop.file()(files, placement);
209
+ });
210
+ return writeProjectFile.mock.calls.map(([, written]) => {
211
+ const img = writtenClip(written as string, tag);
212
+ return [img.match(/data-start="([^"]*)"/)?.[1], img.match(/data-track-index="([^"]*)"/)?.[1]];
213
+ });
214
+ }
215
+
216
+ it("keeps a multi-file drop's clips off each other and off the row's clips", async () => {
217
+ const landed = await twoFileDrop([rowClip("a", 0, 4), rowClip("b", 8, 2)], {
218
+ start: 1,
219
+ track: 0,
220
+ });
221
+ // The first moves past a [0,4); the second is too long for [7,8) and goes after b.
222
+ expect(landed).toEqual([
223
+ ["4", "0"],
224
+ ["10", "0"],
225
+ ]);
226
+ });
227
+
228
+ it("puts a multi-file drop that opens a track back to back on that new track", async () => {
229
+ const landed = await twoFileDrop([rowClip("a", 0, 10)], {
230
+ start: 2,
231
+ track: 0,
232
+ insertRow: 0,
233
+ trackOrder: [0],
234
+ });
235
+ // Clip a moved down a row, so it no longer blocks the second file.
236
+ expect(landed).toEqual([
237
+ ["2", "0"],
238
+ ["5", "0"],
239
+ ]);
240
+ });
241
+
242
+ it("keeps a multi-file drop on the file track of the row it was dropped on", async () => {
243
+ const clips = [
244
+ rowClip("title", 0, 10, { track: 2 }),
245
+ rowClip("mid", 0, 1, { row: 1, track: 3 }),
246
+ rowClip("other", 0, 1, { row: 2, track: 5 }),
247
+ ];
248
+ // Row 0 is file track 2; the second file must not read that 2 back as row 2 (track 5).
249
+ expect(await twoFileDrop(clips, { start: 3, track: 0 })).toEqual([
250
+ ["10", "2"],
251
+ ["13", "2"],
252
+ ]);
253
+ });
254
+
255
+ it("reads a row's file track from every clip of the file, whether or not it names the file", async () => {
256
+ const unnamed = rowClip("unnamed", 0, 1, { row: 2, track: 0 });
257
+ const title = { ...rowClip("title", 0, 10), sourceFile: "index.html" };
258
+ const writeProjectFile = vi.fn().mockResolvedValue(undefined);
259
+ const getDrop = renderDropHook(rowSource([unnamed, title]), writeProjectFile, [unnamed, title]);
260
+ await act(async () => {
261
+ await getDrop()("pic.png", { start: 2, track: 0 }, 3);
262
+ });
263
+ const img = writtenClip(writeProjectFile.mock.calls[0][1] as string, "img");
264
+ expect(img).toContain('data-track-index="0"');
265
+ expect(img).toContain('data-start="10"');
266
+ });
267
+
268
+ it("keeps two audio files dropped on a visual row together, as a single one would go", async () => {
269
+ const clips = [
270
+ rowClip("title", 0, 10, { track: 1 }),
271
+ rowClip("music", 2, 3, { tag: "audio", row: 1, track: 0 }),
272
+ ];
273
+ const landed = await twoFileDrop(clips, { start: 12, track: 0 }, [["a.mp3", "b.mp3"], "audio"]);
274
+ // Audio length probing times out in this harness, so each file gets the 5 s default.
275
+ expect(landed).toEqual([
276
+ ["12", "0"],
277
+ ["17", "0"],
278
+ ]);
279
+ }, 30_000);
280
+
143
281
  it("refuses an asset drop before reading or writing when editing is blocked", async () => {
144
282
  const writeProjectFile = vi.fn().mockResolvedValue(undefined);
145
283
  const checkEditable = vi.fn(() => false);
@@ -30,14 +30,20 @@ import { extendRootDurationInSource } from "../utils/rootDuration";
30
30
  import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
31
31
  import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
32
32
 
33
- /** The first uploaded file opens the new track (if asked); the rest land on the lane it landed on. */
33
+ /** The first uploaded file opens the new track (if asked); each next one aims right after the previous. */
34
34
  function fileDropPlacement(
35
35
  index: number,
36
36
  next: { start: number; track: number },
37
37
  dropped: TimelineDropPlacement | undefined,
38
- landedTrack: number | undefined,
38
+ previous: TimelineElement | undefined,
39
39
  ): TimelineDropPlacement {
40
- return index === 0 ? { ...dropped, ...next } : { ...next, track: landedTrack ?? next.track };
40
+ if (index === 0) return { ...dropped, ...next };
41
+ return previous ? { start: previous.start + previous.duration, track: previous.track } : next;
42
+ }
43
+
44
+ interface DropGesture {
45
+ placed: TimelineElement[];
46
+ onNewTrack: boolean;
41
47
  }
42
48
 
43
49
  function timelineDropTarget(
@@ -90,7 +96,8 @@ export function useTimelineAssetDropOps({
90
96
  assetPath: string,
91
97
  placement: TimelineDropPlacement,
92
98
  durationOverride?: number,
93
- ): Promise<number | undefined> => {
99
+ gesture: DropGesture = { placed: [], onNewTrack: false },
100
+ ): Promise<TimelineElement | undefined> => {
94
101
  if (isRecordingRef?.current) {
95
102
  showToast("Cannot edit timeline while recording", "error");
96
103
  return undefined;
@@ -121,28 +128,32 @@ export function useTimelineAssetDropOps({
121
128
  const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
122
129
 
123
130
  const resolvedTargetPath = targetPath || "index.html";
124
- const relevantElements = timelineElements.filter(
125
- (te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
126
- );
127
- const newElementZIndex = Math.max(1, relevantElements.length + 1);
131
+ const fileElements = timelineElements
132
+ .filter((te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath)
133
+ .map((te) => ({ ...te, sourceFile: resolvedTargetPath }));
134
+ const newElementZIndex = Math.max(1, fileElements.length + gesture.placed.length + 1);
135
+ const tag = kind === "image" ? "img" : kind;
128
136
 
129
137
  let newId = "";
130
138
  let track = 0;
139
+ let start = normalizedStart;
131
140
  const insertAsset = (originalContent: string) => {
132
141
  newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
133
142
  const resolved = resolveDropTrack({
134
143
  source: originalContent,
135
- // insertRow counts the rows the timeline shows, so plan against those.
136
- elements: relevantElements,
144
+ // Rows and insertRow count the rows the timeline shows; a track this drop opened has no others.
145
+ elements: gesture.onNewTrack ? [] : fileElements,
146
+ placed: gesture.placed,
137
147
  placement,
138
148
  dropped: {
139
149
  id: newId,
140
- tag: kind === "image" ? "img" : kind,
150
+ tag,
141
151
  start: normalizedStart,
142
152
  duration: normalizedDuration,
143
153
  },
144
154
  });
145
155
  track = resolved.track;
156
+ start = resolved.start;
146
157
  return extendRootDurationInSource(
147
158
  insertTimelineAssetIntoSource(
148
159
  resolved.source,
@@ -151,7 +162,7 @@ export function useTimelineAssetDropOps({
151
162
  hfId: `hf-${generateId()}`,
152
163
  assetPath: resolvedAssetSrc,
153
164
  kind,
154
- start: normalizedStart,
165
+ start,
155
166
  duration: normalizedDuration,
156
167
  track,
157
168
  zIndex: newElementZIndex,
@@ -162,7 +173,7 @@ export function useTimelineAssetDropOps({
162
173
  ),
163
174
  }),
164
175
  ),
165
- normalizedStart + normalizedDuration,
176
+ start + normalizedDuration,
166
177
  );
167
178
  };
168
179
 
@@ -178,7 +189,15 @@ export function useTimelineAssetDropOps({
178
189
  selectAndRevealTimelineElement(deriveTimelineStoreKeyForDomId(newId, targetPath));
179
190
  forceReloadSdkSession?.();
180
191
  reloadPreview();
181
- return track;
192
+ const lane = placement.insertRow == null ? placement.track : track;
193
+ return {
194
+ id: newId,
195
+ tag,
196
+ start,
197
+ duration: normalizedDuration,
198
+ track: lane,
199
+ authoredTrack: track,
200
+ };
182
201
  } catch (error) {
183
202
  const message =
184
203
  error instanceof Error ? error.message : "Failed to drop asset onto timeline";
@@ -234,15 +253,18 @@ export function useTimelineAssetDropOps({
234
253
  placement ?? { start: 0, track: 0 },
235
254
  durations,
236
255
  );
237
- let landedTrack: number | undefined;
256
+ const gesture: DropGesture = { placed: [], onNewTrack: false };
238
257
  for (const [index, assetPath] of uploaded.entries()) {
239
258
  const next = placements[index] ?? placements[0];
240
- const track = await dropAssetAt(
259
+ const clip = await dropAssetAt(
241
260
  assetPath,
242
- fileDropPlacement(index, next, placement, landedTrack),
261
+ fileDropPlacement(index, next, placement, gesture.placed.at(-1)),
243
262
  durations[index],
263
+ gesture,
244
264
  );
245
- if (index === 0) landedTrack = track;
265
+ if (!clip) continue;
266
+ gesture.placed.push(clip);
267
+ if (index === 0 && placement?.insertRow != null) gesture.onNewTrack = true;
246
268
  }
247
269
  },
248
270
  [
@@ -9,6 +9,18 @@ import { usePlayerStore, type TimelineElement } from "../player";
9
9
  import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
10
10
  import { useElementLifecycleOps } from "./useElementLifecycleOps";
11
11
  import { useTimelineEditing } from "./useTimelineEditing";
12
+ import {
13
+ buildMissingCompositionElements,
14
+ createTimelineElementFromManifestClip,
15
+ parseTimelineFromDOM,
16
+ } from "../player/lib/timelineDOM";
17
+ import type { ClipManifestClip, IframeWindow } from "../player/lib/playbackTypes";
18
+ import { computeResizePreview } from "../player/components/timelineClipDragPreview";
19
+ import {
20
+ buildTimelineGroupResizeMembers,
21
+ resolveTimelineGroupResizeChanges,
22
+ } from "../player/components/timelineGroupEditing";
23
+ import { createRuntimeStartTimeResolver } from "@hyperframes/core/runtime/start-resolver";
12
24
 
13
25
  vi.mock("../components/editor/manualEditingAvailability", async (importOriginal) => {
14
26
  const actual =
@@ -1969,3 +1981,375 @@ describe("useTimelineEditing effect saves report what happened", () => {
1969
1981
  unmount();
1970
1982
  });
1971
1983
  });
1984
+
1985
+ // main 0 > intro 2 > logo 3 > badge 1: rows are master time, files are local.
1986
+ const NESTED_PREVIEW = `
1987
+ <div data-composition-id="main" data-start="0" data-duration="20">
1988
+ <div id="intro" data-hf-id="hf-intro" data-composition-id="intro" data-start="2" data-duration="10">
1989
+ <div data-composition-id="intro" data-composition-file="compositions/intro.html">
1990
+ <video id="vo" data-start="7" data-duration="2" data-hf-media-start-basis="global"></video>
1991
+ <div id="logo" data-hf-id="hf-logo" data-composition-id="logo" data-start="3" data-duration="5">
1992
+ <div data-composition-id="logo" data-composition-file="compositions/logo.html">
1993
+ <div id="badge" data-hf-id="hf-badge" class="clip" data-start="1" data-duration="2"></div>
1994
+ <div id="star" data-hf-id="hf-star" data-composition-id="star" data-start="1.5" data-duration="2">
1995
+ <div data-composition-id="star" data-composition-file="compositions/star.html"></div>
1996
+ </div>
1997
+ </div>
1998
+ </div>
1999
+ </div>
2000
+ </div>
2001
+ </div>`;
2002
+ const NESTED_FILES: Record<string, string> = {
2003
+ "index.html": [
2004
+ `<div data-composition-id="main" data-duration="20">`,
2005
+ ` <div id="intro" data-composition-id="intro" data-composition-src="compositions/intro.html" data-start="2" data-duration="10"></div>`,
2006
+ `</div>`,
2007
+ ].join("\n"),
2008
+ "compositions/intro.html": [
2009
+ `<div data-composition-id="intro" data-duration="10">`,
2010
+ ` <div id="intro-bg" class="clip" data-start="0" data-duration="10"></div>`,
2011
+ ` <video id="vo" data-start="7" data-duration="2" data-hf-media-start-basis="global"></video>`,
2012
+ ` <div id="logo" data-composition-id="logo" data-composition-src="compositions/logo.html" data-start="3" data-duration="5"></div>`,
2013
+ `</div>`,
2014
+ ].join("\n"),
2015
+ "compositions/logo.html": [
2016
+ `<div data-composition-id="logo" data-duration="5">`,
2017
+ ` <div id="logo-bg" class="clip" data-start="0" data-duration="5"></div>`,
2018
+ ` <div id="badge" class="clip" data-start="1" data-duration="2"></div>`,
2019
+ ` <div id="star" data-composition-id="star" data-composition-src="compositions/star.html" data-start="1.5" data-duration="2"></div>`,
2020
+ `</div>`,
2021
+ ].join("\n"),
2022
+ };
2023
+
2024
+ function setupNestedHarness() {
2025
+ const iframe = document.createElement("iframe");
2026
+ document.body.append(iframe);
2027
+ const doc = iframe.contentDocument!;
2028
+ doc.body.innerHTML = NESTED_PREVIEW;
2029
+ const row = (domId: string) => parseTimelineFromDOM(doc, 20).find((e) => e.domId === domId)!;
2030
+ // The app's path: the runtime manifest's root clips, then the missing-host pass.
2031
+ const manifestRow = (domId: string) => {
2032
+ const intro = { id: "intro", start: 2, duration: 10, track: 0, kind: "composition" };
2033
+ const clip = { ...intro, tagName: "div", compositionId: "intro" } as ClipManifestClip;
2034
+ const hostEl = doc.getElementById("intro");
2035
+ const roots = [createTimelineElementFromManifestClip({ clip, fallbackIndex: 0, doc, hostEl })];
2036
+ const win = iframe.contentWindow as IframeWindow;
2037
+ return buildMissingCompositionElements(doc, win, roots, 20).missing.find(
2038
+ (e) => e.domId === domId,
2039
+ )!;
2040
+ };
2041
+ const playsAt = (domId: string) => {
2042
+ const resolver = createRuntimeStartTimeResolver({
2043
+ includeAuthoredTimingAttrs: true,
2044
+ documentRef: doc,
2045
+ });
2046
+ const el = doc.getElementById(domId)!;
2047
+ return el.tagName === "VIDEO"
2048
+ ? resolver.resolveMediaStartForElement(el)
2049
+ : resolver.resolveStartForElement(el);
2050
+ };
2051
+ const writeProjectFile = vi.fn<(path: string, content: string) => Promise<void>>(async () => {});
2052
+ const written = (path: string, domId: string, attr: string) => {
2053
+ const call = writeProjectFile.mock.calls.find(([p]) => p === path);
2054
+ if (!call) throw new Error(`nothing written to ${path}`);
2055
+ const el = new DOMParser().parseFromString(call[1], "text/html").getElementById(domId);
2056
+ return el?.getAttribute(attr);
2057
+ };
2058
+ const writtenRootDuration = (path: string) => {
2059
+ const call = writeProjectFile.mock.calls.find(([p]) => p === path)!;
2060
+ return new DOMParser()
2061
+ .parseFromString(call[1], "text/html")
2062
+ .querySelector("[data-composition-id]")
2063
+ ?.getAttribute("data-duration");
2064
+ };
2065
+ const fetchMock = stubProjectFetch(NESTED_FILES);
2066
+ const scaleCalls = () =>
2067
+ fetchMock.mock.calls
2068
+ .filter((call) => requestUrl(call[0]).includes("/gsap-mutations/"))
2069
+ .map((call) => JSON.parse(String((call[1] as RequestInit).body)))
2070
+ .filter((body) => body.type === "scale-positions");
2071
+ usePlayerStore.getState().setDuration(20);
2072
+ const hook = renderTimelineEditingHook({
2073
+ timelineElements: parseTimelineFromDOM(doc, 20),
2074
+ iframe,
2075
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
2076
+ projectId: "p1",
2077
+ writeProjectFile,
2078
+ recordEdit: vi.fn(async () => {}),
2079
+ });
2080
+ return {
2081
+ doc,
2082
+ row,
2083
+ manifestRow,
2084
+ playsAt,
2085
+ written,
2086
+ writtenRootDuration,
2087
+ writeProjectFile,
2088
+ scaleCalls,
2089
+ ...hook,
2090
+ };
2091
+ }
2092
+
2093
+ describe("useTimelineEditing: nested rows write composition-local starts", () => {
2094
+ it("round-trips a depth-2 drag: drawn and played at the drop point, written local", async () => {
2095
+ const h = setupNestedHarness();
2096
+ const logo = h.row("logo");
2097
+ expect(logo.start).toBe(5);
2098
+
2099
+ await act(async () => {
2100
+ await h.move(logo, { start: 6, track: logo.track });
2101
+ });
2102
+
2103
+ expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("4");
2104
+ expect(h.doc.getElementById("logo")?.getAttribute("data-start")).toBe("4");
2105
+ expect(h.row("logo").start).toBe(6);
2106
+ expect(h.playsAt("logo")).toBe(6);
2107
+ expect(h.writtenRootDuration("compositions/intro.html")).toBe("10");
2108
+ h.unmount();
2109
+ });
2110
+
2111
+ it("round-trips a depth-3 drag through two enclosing hosts", async () => {
2112
+ const h = setupNestedHarness();
2113
+ const badge = h.row("badge");
2114
+ expect(badge.start).toBe(6);
2115
+
2116
+ await act(async () => {
2117
+ await h.move(badge, { start: 7, track: badge.track });
2118
+ });
2119
+
2120
+ expect(h.written("compositions/logo.html", "badge", "data-start")).toBe("2");
2121
+ expect(h.row("badge").start).toBe(7);
2122
+ expect(h.playsAt("badge")).toBe(7);
2123
+ expect(h.writtenRootDuration("compositions/logo.html")).toBe("5");
2124
+ h.unmount();
2125
+ });
2126
+
2127
+ it("round-trips a legacy root-time video inside a sub-composition, writing master time", async () => {
2128
+ const h = setupNestedHarness();
2129
+ const vo = h.row("vo");
2130
+ expect(vo.start).toBe(7);
2131
+
2132
+ await act(async () => {
2133
+ await h.move(vo, { start: 8, track: vo.track });
2134
+ });
2135
+
2136
+ expect(h.written("compositions/intro.html", "vo", "data-start")).toBe("8");
2137
+ expect(h.row("vo").start).toBe(8);
2138
+ expect(h.playsAt("vo")).toBe(8);
2139
+ h.unmount();
2140
+ });
2141
+
2142
+ it("writes a local start on a head trim and leaves a tail trim's start alone", async () => {
2143
+ const h = setupNestedHarness();
2144
+ await act(async () => {
2145
+ await h.resize(h.row("logo"), { start: 5.5, duration: 4.5, playbackStart: undefined });
2146
+ });
2147
+ expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("3.5");
2148
+ expect(h.written("compositions/intro.html", "logo", "data-duration")).toBe("4.5");
2149
+ expect(h.row("logo")).toMatchObject({ start: 5.5, duration: 4.5 });
2150
+
2151
+ h.writeProjectFile.mockClear();
2152
+ await act(async () => {
2153
+ await h.resize(h.row("logo"), { start: 5.5, duration: 3, playbackStart: undefined });
2154
+ });
2155
+ expect(h.doc.getElementById("logo")?.getAttribute("data-start")).toBe("3.5");
2156
+ expect(h.doc.getElementById("logo")?.getAttribute("data-duration")).toBe("3");
2157
+ h.unmount();
2158
+ });
2159
+
2160
+ it("scales a head-trimmed nested row's tweens over its local window", async () => {
2161
+ const h = setupNestedHarness();
2162
+ await act(async () => {
2163
+ await h.resize(h.row("logo"), { start: 5.5, duration: 4.5, playbackStart: undefined });
2164
+ });
2165
+ expect(h.scaleCalls()).toEqual([
2166
+ expect.objectContaining({ oldStart: 3, oldDuration: 5, newStart: 3.5, newDuration: 4.5 }),
2167
+ ]);
2168
+ h.unmount();
2169
+ });
2170
+
2171
+ it("writes local starts and a local scale window for a nested group resize", async () => {
2172
+ const h = setupNestedHarness();
2173
+ await act(async () => {
2174
+ await h.groupResize([{ element: h.row("logo"), start: 5.5, duration: 4.5 }]);
2175
+ });
2176
+ expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("3.5");
2177
+ expect(h.scaleCalls()).toEqual([
2178
+ expect.objectContaining({ oldStart: 3, oldDuration: 5, newStart: 3.5, newDuration: 4.5 }),
2179
+ ]);
2180
+ h.unmount();
2181
+ });
2182
+
2183
+ it("scales a legacy root-time video's tweens on its host's clock, single and group", async () => {
2184
+ const window = { oldStart: 5, oldDuration: 2, newStart: 5.5, newDuration: 1.5 };
2185
+ for (const group of [false, true]) {
2186
+ const h = setupNestedHarness();
2187
+ const vo = h.row("vo");
2188
+ await act(async () => {
2189
+ if (group) await h.groupResize([{ element: vo, start: 7.5, duration: 1.5 }]);
2190
+ else await h.resize(vo, { start: 7.5, duration: 1.5, playbackStart: undefined });
2191
+ });
2192
+ expect(h.scaleCalls()).toEqual([expect.objectContaining(window)]);
2193
+ h.unmount();
2194
+ vi.unstubAllGlobals();
2195
+ }
2196
+ });
2197
+
2198
+ it("stops a head trim past the host's start at the host's start, end held", async () => {
2199
+ const h = setupNestedHarness();
2200
+ const logo = h.manifestRow("logo");
2201
+ expect(logo).toMatchObject({ start: 5, duration: 5, parentCompositionStart: 2 });
2202
+ const pps = 100;
2203
+ const preview = computeResizePreview(
2204
+ {
2205
+ element: logo,
2206
+ edge: "start",
2207
+ originClientX: 0,
2208
+ previewStart: 5,
2209
+ previewDuration: 5,
2210
+ started: true,
2211
+ pointerId: 1,
2212
+ },
2213
+ -4 * pps,
2214
+ { scroll: null, pps, buildSnapTargets: () => [] },
2215
+ );
2216
+ expect(preview).toMatchObject({ previewStart: 2, previewDuration: 8 });
2217
+
2218
+ await act(async () => {
2219
+ await h.resize(logo, {
2220
+ start: preview.previewStart,
2221
+ duration: preview.previewDuration,
2222
+ playbackStart: undefined,
2223
+ });
2224
+ });
2225
+ expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("0");
2226
+ expect(h.written("compositions/intro.html", "logo", "data-duration")).toBe("8");
2227
+ expect(h.row("logo")).toMatchObject({ start: 2, duration: 8 });
2228
+ expect(h.scaleCalls()).toEqual([
2229
+ expect.objectContaining({ oldStart: 3, oldDuration: 5, newStart: 0, newDuration: 8 }),
2230
+ ]);
2231
+ h.unmount();
2232
+ });
2233
+
2234
+ it("stops a group head trim at the nested member's host start", async () => {
2235
+ const h = setupNestedHarness();
2236
+ const logo = h.manifestRow("logo");
2237
+ const outro: TimelineElement = {
2238
+ ...logo,
2239
+ id: "outro",
2240
+ key: "outro",
2241
+ domId: undefined,
2242
+ start: 12,
2243
+ parentCompositionStart: 0,
2244
+ };
2245
+ const members = buildTimelineGroupResizeMembers(
2246
+ [logo, outro],
2247
+ new Set([logo.key ?? logo.id, "outro"]),
2248
+ logo.key ?? logo.id,
2249
+ "start",
2250
+ )!;
2251
+ const changes = resolveTimelineGroupResizeChanges(members, "start", -4);
2252
+ const logoChange = changes.find((c) => c.key === (logo.key ?? logo.id))!;
2253
+ expect(logoChange).toMatchObject({ start: 2, duration: 8 });
2254
+
2255
+ await act(async () => {
2256
+ await h.groupResize([
2257
+ { element: logo, start: logoChange.start, duration: logoChange.duration },
2258
+ ]);
2259
+ });
2260
+ expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("0");
2261
+ expect(h.written("compositions/intro.html", "logo", "data-duration")).toBe("8");
2262
+ expect(h.scaleCalls()).toEqual([
2263
+ expect.objectContaining({ oldStart: 3, oldDuration: 5, newStart: 0, newDuration: 8 }),
2264
+ ]);
2265
+ h.unmount();
2266
+ });
2267
+
2268
+ it("round-trips a depth-3 host from the manifest path", async () => {
2269
+ const h = setupNestedHarness();
2270
+ const star = h.manifestRow("star");
2271
+ expect(star).toMatchObject({ start: 6.5, parentCompositionStart: 5 });
2272
+
2273
+ await act(async () => {
2274
+ await h.move(star, { start: 7.5, track: star.track });
2275
+ });
2276
+
2277
+ expect(h.written("compositions/logo.html", "star", "data-start")).toBe("2.5");
2278
+ expect(h.playsAt("star")).toBe(7.5);
2279
+ expect(h.manifestRow("star").start).toBe(7.5);
2280
+ h.unmount();
2281
+ });
2282
+
2283
+ it("clamps a nested row dropped before its host's start to the host's start", async () => {
2284
+ const h = setupNestedHarness();
2285
+ const logo = h.row("logo");
2286
+ await act(async () => {
2287
+ await h.move(logo, { start: 1, track: logo.track });
2288
+ });
2289
+ expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("0");
2290
+ expect(h.row("logo").start).toBe(2);
2291
+ h.unmount();
2292
+ });
2293
+
2294
+ it("writes local starts for nested rows in a group move", async () => {
2295
+ const h = setupNestedHarness();
2296
+ await act(async () => {
2297
+ await h.groupMove([{ element: h.row("logo"), start: 6 }]);
2298
+ });
2299
+ expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("4");
2300
+ expect(h.row("logo").start).toBe(6);
2301
+ h.unmount();
2302
+ });
2303
+
2304
+ it("leaves a top-level row's start as written", async () => {
2305
+ const h = setupNestedHarness();
2306
+ const intro = h.row("intro");
2307
+ expect(intro.parentCompositionStart).toBe(0);
2308
+
2309
+ await act(async () => {
2310
+ await h.move(intro, { start: 3, track: intro.track });
2311
+ });
2312
+
2313
+ expect(h.written("index.html", "intro", "data-start")).toBe("3");
2314
+ expect(h.row("intro").start).toBe(3);
2315
+ h.unmount();
2316
+ });
2317
+
2318
+ it("hands the SDK a local start for a nested clip in the open file", async () => {
2319
+ const source = [
2320
+ `<div data-hf-id="hf-main" data-hf-root data-composition-id="main" data-duration="20">`,
2321
+ ` <div id="intro" data-hf-id="hf-intro" data-composition-id="intro" data-start="2" data-duration="10">`,
2322
+ ` <div id="clip" data-hf-id="hf-clip" data-start="1" data-duration="2"></div>`,
2323
+ ` </div>`,
2324
+ `</div>`,
2325
+ ].join("\n");
2326
+ const iframe = document.createElement("iframe");
2327
+ document.body.append(iframe);
2328
+ iframe.contentDocument!.body.innerHTML = source;
2329
+ const clip = parseTimelineFromDOM(iframe.contentDocument!, 20).find((e) => e.domId === "clip")!;
2330
+ expect(clip.start).toBe(3);
2331
+ const sdkSession = await openComposition(source);
2332
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
2333
+ stubProjectFetch(source);
2334
+ usePlayerStore.getState().setDuration(20);
2335
+ const hook = renderTimelineEditingHook({
2336
+ timelineElements: [clip],
2337
+ iframe,
2338
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
2339
+ projectId: "p1",
2340
+ writeProjectFile,
2341
+ recordEdit: vi.fn(async () => {}),
2342
+ sdkSession,
2343
+ publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
2344
+ });
2345
+
2346
+ await act(async () => {
2347
+ await hook.move(clip, { start: 4, track: clip.track });
2348
+ });
2349
+
2350
+ expect(writeProjectFile.mock.calls[0]?.[1]).toContain(
2351
+ 'id="clip" data-hf-id="hf-clip" data-start="2"',
2352
+ );
2353
+ hook.unmount();
2354
+ });
2355
+ });