@hyperframes/studio 0.7.56 → 0.7.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
  2. package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
  3. package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +41 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +7436 -5423
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
  17. package/src/components/editor/CanvasContextMenu.tsx +100 -20
  18. package/src/components/editor/DomEditOverlay.tsx +34 -62
  19. package/src/components/editor/LayersPanel.tsx +111 -91
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
  21. package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  26. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  27. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  28. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  29. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  30. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  31. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  32. package/src/components/nle/PreviewOverlays.tsx +61 -16
  33. package/src/components/nle/TimelinePane.test.ts +3 -0
  34. package/src/components/nle/TimelinePane.tsx +11 -1
  35. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  37. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  38. package/src/components/nle/zLaneGesture.test.ts +176 -0
  39. package/src/components/nle/zLaneGesture.ts +77 -0
  40. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  41. package/src/components/sidebar/AssetCard.tsx +19 -2
  42. package/src/components/sidebar/AudioRow.tsx +19 -2
  43. package/src/hooks/domEditCommitTypes.ts +29 -2
  44. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  45. package/src/hooks/fetchStubTestUtils.ts +16 -0
  46. package/src/hooks/timelineEditingHelpers.test.ts +183 -1
  47. package/src/hooks/timelineEditingHelpers.ts +63 -259
  48. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  49. package/src/hooks/timelineMoveAdapter.ts +7 -4
  50. package/src/hooks/timelineTimingSync.test.ts +820 -0
  51. package/src/hooks/timelineTimingSync.ts +597 -0
  52. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  53. package/src/hooks/timelineTrackVisibility.ts +4 -3
  54. package/src/hooks/useCaptionDetection.ts +2 -0
  55. package/src/hooks/useClipboard.ts +19 -17
  56. package/src/hooks/useCompositionDimensions.ts +32 -5
  57. package/src/hooks/useDomEditCommits.test.tsx +392 -23
  58. package/src/hooks/useDomEditCommits.ts +180 -53
  59. package/src/hooks/useDomEditSession.test.tsx +3 -1
  60. package/src/hooks/useDomEditSession.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
  62. package/src/hooks/useElementLifecycleOps.ts +175 -69
  63. package/src/hooks/useElementPicker.ts +7 -8
  64. package/src/hooks/usePreviewPersistence.ts +2 -2
  65. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  66. package/src/hooks/useTimelineEditing.test.tsx +591 -277
  67. package/src/hooks/useTimelineEditing.ts +202 -282
  68. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  69. package/src/hooks/useTimelineGroupEditing.ts +187 -134
  70. package/src/player/components/PlayerControls.tsx +5 -5
  71. package/src/player/components/PlayheadIndicator.tsx +7 -2
  72. package/src/player/components/Timeline.test.ts +105 -18
  73. package/src/player/components/Timeline.tsx +69 -98
  74. package/src/player/components/TimelineCanvas.tsx +47 -7
  75. package/src/player/components/TimelineLanes.tsx +32 -10
  76. package/src/player/components/TimelineOverlays.tsx +36 -0
  77. package/src/player/components/TimelineRuler.tsx +70 -47
  78. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  79. package/src/player/components/timelineCallbacks.ts +4 -1
  80. package/src/player/components/timelineClipDragCommit.test.ts +370 -11
  81. package/src/player/components/timelineClipDragCommit.ts +267 -68
  82. package/src/player/components/timelineGapCommit.test.ts +163 -0
  83. package/src/player/components/timelineGapCommit.ts +103 -0
  84. package/src/player/components/timelineGaps.test.ts +221 -0
  85. package/src/player/components/timelineGaps.ts +182 -0
  86. package/src/player/components/timelineLayout.test.ts +2 -1
  87. package/src/player/components/timelineLayout.ts +119 -30
  88. package/src/player/components/timelineMarquee.test.ts +20 -10
  89. package/src/player/components/timelineMarquee.ts +9 -2
  90. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  91. package/src/player/components/timelineRevealScroll.ts +91 -0
  92. package/src/player/components/timelineStackingSync.test.ts +102 -1
  93. package/src/player/components/timelineStackingSync.ts +45 -5
  94. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  95. package/src/player/components/timelineZMirror.test.ts +667 -0
  96. package/src/player/components/timelineZMirror.ts +393 -0
  97. package/src/player/components/timelineZones.test.ts +122 -310
  98. package/src/player/components/timelineZones.ts +82 -135
  99. package/src/player/components/timelineZoom.test.ts +2 -2
  100. package/src/player/components/timelineZoom.ts +7 -3
  101. package/src/player/components/useTimelineClipDrag.ts +1 -1
  102. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  103. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  104. package/src/player/components/useTimelinePlayhead.ts +4 -3
  105. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  106. package/src/player/components/useTimelineRevealClip.ts +56 -0
  107. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  108. package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
  109. package/src/player/components/useTimelineStackingSync.ts +17 -4
  110. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  111. package/src/player/components/useTrackGapMenu.ts +155 -0
  112. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  113. package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
  114. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  115. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  116. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  117. package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
  118. package/src/player/index.ts +3 -1
  119. package/src/player/lib/layerOrdering.test.ts +1 -15
  120. package/src/player/lib/layerOrdering.ts +5 -28
  121. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  122. package/src/player/lib/runtimeProtocol.ts +65 -0
  123. package/src/player/lib/timelineDOM.test.ts +72 -0
  124. package/src/player/lib/timelineDOM.ts +12 -0
  125. package/src/player/lib/timelineElementHelpers.ts +54 -11
  126. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  127. package/src/player/store/playerStore.test.ts +38 -13
  128. package/src/player/store/playerStore.ts +49 -1
  129. package/src/telemetry/events.test.ts +10 -0
  130. package/src/telemetry/events.ts +1 -0
  131. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  132. package/src/utils/assetPreviewDismiss.ts +29 -0
  133. package/src/utils/domEditSaveQueue.ts +4 -3
  134. package/src/utils/editHistory.test.ts +82 -0
  135. package/src/utils/editHistory.ts +14 -2
  136. package/src/utils/gsapSoftReload.test.ts +54 -115
  137. package/src/utils/gsapSoftReload.ts +42 -152
  138. package/src/utils/gsapUndoRestore.test.ts +269 -0
  139. package/src/utils/gsapUndoRestore.ts +269 -0
  140. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  141. package/src/utils/timelineAssetDrop.test.ts +144 -52
  142. package/src/utils/timelineAssetDrop.ts +62 -86
  143. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  144. package/dist/assets/index-D-GyYi2d.css +0 -1
  145. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  146. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -2,8 +2,12 @@
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
3
  import {
4
4
  applyTimelineStackingReorder,
5
+ buildTimelineMoveTimingPatch,
5
6
  deleteSelectedKeyframes,
6
7
  extendRootDurationIfNeeded,
8
+ patchIframeDomTiming,
9
+ persistTimelineBatchEdit,
10
+ type PersistTimelineBatchChange,
7
11
  } from "./timelineEditingHelpers";
8
12
  import type { TimelineElement } from "../player/store/playerStore";
9
13
  import { usePlayerStore } from "../player/store/playerStore";
@@ -39,7 +43,11 @@ describe("applyTimelineStackingReorder", () => {
39
43
  it("commits via the change's own locator even when the element is not in timelineElements", () => {
40
44
  // Sub-comp children live in the preview iframe but NOT in the top-level
41
45
  // timelineElements list — the intent must be self-contained.
42
- const iframe = makeIframeWith(`<div id="chip" style="z-index: 1"></div>`);
46
+ const iframe = makeIframeWith(`
47
+ <div data-composition-id="scene" data-composition-file="scenes/scene.html">
48
+ <div id="chip" style="z-index: 1"></div>
49
+ </div>
50
+ `);
43
51
  const commit = vi.fn<(entries: unknown[]) => void>();
44
52
 
45
53
  applyTimelineStackingReorder({
@@ -93,6 +101,92 @@ describe("applyTimelineStackingReorder", () => {
93
101
 
94
102
  expect(commit).not.toHaveBeenCalled();
95
103
  });
104
+
105
+ it("resolves selectorIndex within the change's source file", () => {
106
+ const iframe = makeIframeWith(`
107
+ <div data-composition-id="root">
108
+ <div class="chip">root</div>
109
+ <div data-composition-id="scene" data-composition-file="scenes/scene.html">
110
+ <div class="chip">scene zero</div>
111
+ <div class="chip" style="z-index: 1">scene one</div>
112
+ </div>
113
+ </div>
114
+ `);
115
+ const sceneOne = iframe.contentDocument?.querySelectorAll(".chip")[2];
116
+ const commit = vi.fn<(entries: unknown[]) => void>();
117
+
118
+ applyTimelineStackingReorder({
119
+ element: el({ id: "scene-one", tag: "div" }),
120
+ stackingReorder: {
121
+ contextKey: "scene",
122
+ placement: { type: "above", layerId: "layer:scene:x" },
123
+ zIndexChanges: [
124
+ {
125
+ key: "scenes/scene.html#.chip:1",
126
+ zIndex: 5,
127
+ selector: ".chip",
128
+ selectorIndex: 1,
129
+ sourceFile: "scenes/scene.html",
130
+ },
131
+ ],
132
+ },
133
+ timelineElements: [],
134
+ iframe,
135
+ activeCompPath: "index.html",
136
+ commit,
137
+ });
138
+
139
+ const entries = commit.mock.calls[0]![0] as Array<{ element: Element }>;
140
+ expect(entries[0]!.element).toBe(sceneOne);
141
+ });
142
+ });
143
+
144
+ describe("patchIframeDomTiming", () => {
145
+ it("resolves selectorIndex within the element's source file", () => {
146
+ const iframe = makeIframeWith(`
147
+ <div data-composition-id="root">
148
+ <div class="clip" data-start="1">root</div>
149
+ <div data-composition-id="scene" data-composition-file="scenes/scene.html">
150
+ <div class="clip" data-start="2">scene zero</div>
151
+ <div class="clip" data-start="3">scene one</div>
152
+ </div>
153
+ </div>
154
+ `);
155
+ const clips = iframe.contentDocument?.querySelectorAll<HTMLElement>(".clip");
156
+ const target = el({
157
+ id: "scene-one",
158
+ tag: "div",
159
+ selector: ".clip",
160
+ selectorIndex: 1,
161
+ sourceFile: "scenes/scene.html",
162
+ });
163
+
164
+ patchIframeDomTiming(iframe, target, [["data-start", "9"]], "index.html");
165
+
166
+ expect([...clips!].map((clip) => clip.dataset.start)).toEqual(["1", "2", "9"]);
167
+ });
168
+
169
+ it("resolves duplicate domId within the element's source file", () => {
170
+ const iframe = makeIframeWith(`
171
+ <div data-composition-id="root">
172
+ <div id="card" data-start="1">root</div>
173
+ <div data-composition-id="scene" data-composition-file="scenes/scene.html">
174
+ <div id="card" data-start="2">scene</div>
175
+ </div>
176
+ </div>
177
+ `);
178
+ const cards = iframe.contentDocument?.querySelectorAll<HTMLElement>("#card");
179
+ const target = el({
180
+ id: "scene-card",
181
+ tag: "div",
182
+ domId: "card",
183
+ sourceFile: "scenes/scene.html",
184
+ });
185
+
186
+ patchIframeDomTiming(iframe, target, [["data-start", "9"]], "index.html");
187
+
188
+ expect([...cards!].map((card) => card.dataset.start)).toEqual(["1", "9"]);
189
+ });
96
190
  });
97
191
 
98
192
  describe("extendRootDurationIfNeeded", () => {
@@ -108,6 +202,94 @@ describe("extendRootDurationIfNeeded", () => {
108
202
  });
109
203
  });
110
204
 
205
+ describe("persistTimelineBatchEdit", () => {
206
+ const SOURCE = `<div id="root"><video id="a" class="clip" data-start="1" data-track-index="0"></video><video id="b" class="clip" data-start="2" data-track-index="1"></video></div>`;
207
+
208
+ function batchInput(changes: PersistTimelineBatchChange[], writes: Array<[string, string]>) {
209
+ return {
210
+ projectId: "p1",
211
+ activeCompPath: "index.html",
212
+ label: "Move timeline clips",
213
+ changes,
214
+ writeProjectFile: async (path: string, content: string) => {
215
+ writes.push([path, content]);
216
+ },
217
+ recordEdit: async () => {},
218
+ domEditSaveTimestampRef: { current: 0 },
219
+ pendingTimelineEditPathRef: { current: new Set<string>() },
220
+ };
221
+ }
222
+
223
+ function stubReadFileContent(content: string) {
224
+ vi.stubGlobal(
225
+ "fetch",
226
+ vi.fn(async () => ({
227
+ ok: true,
228
+ json: async () => ({ content }),
229
+ })),
230
+ );
231
+ }
232
+
233
+ function moveMember(
234
+ id: string,
235
+ start: number,
236
+ fromTrack: number,
237
+ toTrack: number,
238
+ ): PersistTimelineBatchChange {
239
+ return {
240
+ element: el({ id, tag: "video", domId: id, start, track: fromTrack }),
241
+ buildPatches: (original, target) =>
242
+ buildTimelineMoveTimingPatch(original, target, start, 5, toTrack),
243
+ };
244
+ }
245
+
246
+ async function runBatch(changes: PersistTimelineBatchChange[]) {
247
+ stubReadFileContent(SOURCE);
248
+ const writes: Array<[string, string]> = [];
249
+ await persistTimelineBatchEdit(batchInput(changes, writes));
250
+ return writes;
251
+ }
252
+
253
+ afterEach(() => {
254
+ vi.unstubAllGlobals();
255
+ });
256
+
257
+ it("skips no-op members instead of aborting the batch (track-insert renumber)", async () => {
258
+ // A track-insert renumber can include a member whose attributes already
259
+ // hold the target values — its patch is string-identical. The batch must
260
+ // skip it and still persist the members that DID change.
261
+ const writes = await runBatch([
262
+ // no-op: data-start already "1", track already 0
263
+ moveMember("a", 1, 0, 0),
264
+ // real change: track 1 -> 2
265
+ moveMember("b", 2, 1, 2),
266
+ ]);
267
+
268
+ expect(writes).toHaveLength(1);
269
+ expect(writes[0]![0]).toBe("index.html");
270
+ expect(writes[0]![1]).toContain('id="b" class="clip" data-start="2" data-track-index="2"');
271
+ });
272
+
273
+ it("saves nothing when every member is a no-op", async () => {
274
+ const writes = await runBatch([moveMember("a", 1, 0, 0)]);
275
+
276
+ expect(writes).toHaveLength(0);
277
+ });
278
+
279
+ it("throws on a mistargeted member instead of silently dropping it", async () => {
280
+ // A member whose target does not resolve in the source (stale id) patches
281
+ // to the identical string too — but that is a targeting FAILURE, not an
282
+ // already-at-target no-op, and must abort the batch like the single path.
283
+ stubReadFileContent(SOURCE);
284
+ const writes: Array<[string, string]> = [];
285
+
286
+ await expect(
287
+ persistTimelineBatchEdit(batchInput([moveMember("ghost", 3, 0, 2)], writes)),
288
+ ).rejects.toThrow("Unable to patch timeline element ghost in index.html");
289
+ expect(writes).toHaveLength(0);
290
+ });
291
+ });
292
+
111
293
  describe("deleteSelectedKeyframes", () => {
112
294
  it("coalesces all removals and reloads only after the last one", () => {
113
295
  usePlayerStore.setState({
@@ -1,15 +1,21 @@
1
1
  import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
2
- import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
2
+ import { applyPatchByTarget, findTagByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
3
3
  import {
4
4
  formatTimelineAttributeNumber,
5
5
  type TimelineStackingReorderIntent,
6
6
  } from "../player/components/timelineEditing";
7
7
  import { getElementZIndex } from "../player/lib/layerOrdering";
8
- import { getTimelineElementIdentity } from "../player/lib/timelineElementHelpers";
8
+ import {
9
+ furthestClipEndFromSource,
10
+ getTimelineElementIdentity,
11
+ } from "../player/lib/timelineElementHelpers";
9
12
  import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
10
13
  import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
11
- import { extendRootDurationInSource } from "../utils/rootDuration";
14
+ import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
15
+ import { readFileContent } from "./timelineTimingSync";
16
+ import { findElementForSelection } from "../components/editor/domEditingElement";
12
17
  export { deleteSelectedKeyframes } from "./deleteSelectedKeyframes";
18
+ export { readFileContent };
13
19
  function isHTMLElement(element: Element | null): element is HTMLElement {
14
20
  if (!element) return false;
15
21
  // Use the element's OWN realm's HTMLElement: timeline clips live in the preview
@@ -48,12 +54,6 @@ export function applyTimelineStackingReorder(input: {
48
54
  input.timelineElements.map((el) => [getTimelineElementIdentity(el), el]),
49
55
  );
50
56
  const doc = input.iframe?.contentDocument ?? null;
51
- const findLive = (domId?: string, selector?: string, selectorIndex?: number): Element | null => {
52
- if (!doc) return null;
53
- if (domId) return doc.getElementById(domId);
54
- if (selector) return doc.querySelectorAll(selector)[selectorIndex ?? 0] ?? null;
55
- return null;
56
- };
57
57
  const commitEntries: Array<{
58
58
  element: HTMLElement;
59
59
  zIndex: number;
@@ -68,7 +68,15 @@ export function applyTimelineStackingReorder(input: {
68
68
  const domId = change.domId ?? sibling?.domId;
69
69
  const selector = change.selector ?? sibling?.selector;
70
70
  const selectorIndex = change.selectorIndex ?? sibling?.selectorIndex;
71
- const element = findLive(domId, selector, selectorIndex);
71
+ const sourceFile =
72
+ change.sourceFile ?? sibling?.sourceFile ?? input.activeCompPath ?? "index.html";
73
+ const element = doc
74
+ ? findElementForSelection(
75
+ doc,
76
+ { id: domId, selector, selectorIndex, sourceFile },
77
+ input.activeCompPath,
78
+ )
79
+ : null;
72
80
  if (!isHTMLElement(element)) return Promise.resolve();
73
81
  if (getElementZIndex(element) === change.zIndex) continue;
74
82
  commitEntries.push({
@@ -77,12 +85,15 @@ export function applyTimelineStackingReorder(input: {
77
85
  id: domId ?? sibling?.id ?? change.key,
78
86
  selector,
79
87
  selectorIndex,
80
- sourceFile: change.sourceFile ?? sibling?.sourceFile ?? input.activeCompPath ?? "index.html",
88
+ sourceFile,
81
89
  key: change.key,
82
90
  });
83
91
  }
84
92
  if (commitEntries.length === 0) return Promise.resolve();
85
- return input.commit?.(commitEntries, input.coalesceKey) ?? Promise.resolve();
93
+ // The durability report is for gesture-level callers (z→lane mirror); this
94
+ // lane-drag z-sync path has no dependent follow-up write — swallow it.
95
+ // Promise.resolve-wrapped: a commit implementation may return void.
96
+ return Promise.resolve(input.commit?.(commitEntries, input.coalesceKey)).then(() => undefined);
86
97
  }
87
98
  export function extendRootDurationIfNeeded(newEnd: number): boolean {
88
99
  const store = usePlayerStore.getState();
@@ -121,15 +132,22 @@ export type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
121
132
  export function findTimelineElementInIframe(
122
133
  iframe: HTMLIFrameElement | null,
123
134
  element: TimelineElement,
135
+ activeCompositionPath: string | null = null,
124
136
  ): Element | null {
125
137
  try {
126
138
  const doc = iframe?.contentDocument;
127
139
  if (!doc) return null;
128
- return element.domId
129
- ? doc.getElementById(element.domId)
130
- : element.selector
131
- ? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null)
132
- : null;
140
+ return findElementForSelection(
141
+ doc,
142
+ {
143
+ hfId: element.hfId,
144
+ id: element.domId,
145
+ selector: element.selector,
146
+ selectorIndex: element.selectorIndex,
147
+ sourceFile: element.sourceFile || activeCompositionPath || "index.html",
148
+ },
149
+ activeCompositionPath,
150
+ );
133
151
  } catch {
134
152
  return null;
135
153
  }
@@ -138,31 +156,16 @@ export function patchIframeDomTiming(
138
156
  iframe: HTMLIFrameElement | null,
139
157
  element: TimelineElement,
140
158
  attrs: Array<[string, string]>,
159
+ activeCompositionPath: string | null = null,
141
160
  ): void {
142
161
  try {
143
- const el = findTimelineElementInIframe(iframe, element);
162
+ const el = findTimelineElementInIframe(iframe, element, activeCompositionPath);
144
163
  if (!el) return;
145
164
  for (const [name, value] of attrs) el.setAttribute(name, value);
146
165
  } catch {
147
166
  // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
148
167
  }
149
168
  }
150
- function postRootDurationToPreview(
151
- iframe: HTMLIFrameElement | null,
152
- durationSeconds: number,
153
- ): void {
154
- const duration = Number(durationSeconds);
155
- if (!Number.isFinite(duration) || duration <= 0) return;
156
- iframe?.contentWindow?.postMessage(
157
- {
158
- source: "hf-parent",
159
- type: "control",
160
- action: "set-root-duration",
161
- durationSeconds: duration,
162
- },
163
- "*",
164
- );
165
- }
166
169
  // fallow-ignore-next-line complexity
167
170
  function resolveResizePlaybackStart(
168
171
  original: string,
@@ -215,7 +218,12 @@ export function buildTimelineMoveTimingPatch(
215
218
  value: formatTimelineAttributeNumber(track),
216
219
  });
217
220
  }
218
- return extendRootDurationInSource(patched, start + duration);
221
+ // Content-driven duration: sync data-duration to the furthest clip end read
222
+ // from the PATCHED SOURCE (raw data-duration), so it grows if a clip moved
223
+ // past the end and shrinks if the furthest clip moved left. Measured from the
224
+ // source, NOT the store — store durations are runtime-truncated to the current
225
+ // comp length, which would ratchet the duration down every move.
226
+ return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
219
227
  }
220
228
 
221
229
  export function buildTimelineResizeTimingPatch(
@@ -242,7 +250,10 @@ export function buildTimelineResizeTimingPatch(
242
250
  value: formatTimelineAttributeNumber(pbs.value),
243
251
  });
244
252
  }
245
- return extendRootDurationInSource(patched, updates.start + updates.duration);
253
+ // Content-driven duration from the PATCHED SOURCE (raw data-duration)
254
+ // grows/shrinks to the furthest clip end. Not from the store, whose
255
+ // durations are runtime-truncated.
256
+ return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
246
257
  }
247
258
 
248
259
  export interface PersistTimelineEditInput {
@@ -302,6 +313,8 @@ export interface PersistTimelineBatchEditInput {
302
313
  domEditSaveTimestampRef: React.MutableRefObject<number>;
303
314
  pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
304
315
  coalesceKey?: string;
316
+ /** Per-entry undo coalesce window override (ms) — see EditHistoryEntry.coalesceMs. */
317
+ coalesceMs?: number;
305
318
  }
306
319
 
307
320
  export async function persistTimelineBatchEdit(
@@ -322,13 +335,24 @@ export async function persistTimelineBatchEdit(
322
335
  }
323
336
 
324
337
  const current = patchedByPath.get(targetPath) ?? original;
325
- const patched = change.buildPatches(current, patchTarget);
326
- if (patched === current) {
338
+ // Resolve the target FIRST: byte-identical output below is only a legit
339
+ // no-op when the member actually resolved in the source. A mistargeted
340
+ // member (stale id/selector) must fail loudly like the single-edit path,
341
+ // not be silently dropped as "already at target".
342
+ if (!findTagByTarget(current, patchTarget)) {
327
343
  throw new Error(`Unable to patch timeline element ${change.element.id} in ${targetPath}`);
328
344
  }
345
+ const patched = change.buildPatches(current, patchTarget);
346
+ // The target resolved, so a member whose attributes already hold the target
347
+ // values patches to the identical string — e.g. a track-insert renumber
348
+ // where one clip's lane is already correct. That is a legitimate no-op:
349
+ // skip it instead of aborting (and rolling back) the whole batch.
350
+ if (patched === current) continue;
329
351
  patchedByPath.set(targetPath, patched);
330
352
  }
331
353
 
354
+ if (patchedByPath.size === 0) return;
355
+
332
356
  const files = Object.fromEntries(patchedByPath);
333
357
  for (const targetPath of Object.keys(files)) {
334
358
  input.pendingTimelineEditPathRef.current.add(targetPath);
@@ -339,6 +363,7 @@ export async function persistTimelineBatchEdit(
339
363
  label: input.label,
340
364
  kind: "timeline",
341
365
  coalesceKey: input.coalesceKey,
366
+ coalesceMs: input.coalesceMs,
342
367
  files,
343
368
  readFile: async (path) => originals.get(path) ?? readFileContent(input.projectId, path),
344
369
  writeFile: input.writeProjectFile,
@@ -347,227 +372,6 @@ export async function persistTimelineBatchEdit(
347
372
  input.domEditSaveTimestampRef.current = Date.now();
348
373
  }
349
374
 
350
- export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
351
- if (targetPath.includes("\0") || targetPath.includes("..")) {
352
- throw new Error(`Unsafe path: ${targetPath}`);
353
- }
354
- const response = await fetch(
355
- `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
356
- );
357
- if (!response.ok) {
358
- throw new Error(`Failed to read ${targetPath}`);
359
- }
360
- const data = (await response.json()) as { content?: string };
361
- if (typeof data.content !== "string") {
362
- throw new Error(`Missing file contents for ${targetPath}`);
363
- }
364
- return data.content;
365
- }
366
-
367
- export type GsapMutationStatus = { mutated: boolean };
368
-
369
- function isRecord(value: unknown): value is Record<string, unknown> {
370
- return typeof value === "object" && value !== null;
371
- }
372
-
373
- function readMutationStatus(value: unknown): GsapMutationStatus {
374
- if (!isRecord(value)) return { mutated: false };
375
- return { mutated: value.mutated === true || value.changed === true };
376
- }
377
-
378
- function readMutationError(value: unknown, fallback: string): string {
379
- if (isRecord(value) && typeof value.error === "string") return value.error;
380
- return fallback;
381
- }
382
-
383
- export async function finishTimelineTimingFallback(input: {
384
- iframe: HTMLIFrameElement | null;
385
- needsExtension: boolean;
386
- rootDurationSeconds: number;
387
- reloadPreview: () => void;
388
- gsapMutation?: () => Promise<GsapMutationStatus>;
389
- onGsapError: (error: unknown) => void;
390
- }): Promise<void> {
391
- let gsapMutated = false;
392
- if (input.gsapMutation) {
393
- try {
394
- gsapMutated = (await input.gsapMutation()).mutated;
395
- } catch (error) {
396
- input.onGsapError(error);
397
- return;
398
- }
399
- }
400
- if (input.needsExtension) {
401
- postRootDurationToPreview(input.iframe, input.rootDurationSeconds);
402
- if (gsapMutated) input.reloadPreview();
403
- return;
404
- }
405
- input.reloadPreview();
406
- }
407
-
408
- // Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
409
- // outlast one GSAP server round-trip, never a real second edit.
410
- const GSAP_HISTORY_COALESCE_MS = 10_000;
411
-
412
- /**
413
- * A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the
414
- * timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash
415
- * conflict. This snapshots every touched file, runs the mutation, then records a follow-up
416
- * edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip,
417
- * folding both writes into one undo step. Returns the mutation status for caller reloads.
418
- */
419
- export async function foldGsapMutationIntoHistory(input: {
420
- projectId: string;
421
- paths: string[];
422
- label: string;
423
- coalesceKey?: string;
424
- recordEdit: (edit: RecordEditInput) => Promise<void>;
425
- gsapMutation: () => Promise<GsapMutationStatus>;
426
- }): Promise<GsapMutationStatus> {
427
- const uniquePaths = [...new Set(input.paths)];
428
- const before = new Map<string, string>();
429
- for (const path of uniquePaths) {
430
- before.set(path, await readFileContent(input.projectId, path));
431
- }
432
- const status = await input.gsapMutation();
433
- if (status.mutated) {
434
- const files: Record<string, { before: string; after: string }> = {};
435
- for (const path of uniquePaths) {
436
- const priorContent = before.get(path);
437
- const finalContent = await readFileContent(input.projectId, path);
438
- if (priorContent !== undefined && finalContent !== priorContent) {
439
- files[path] = { before: priorContent, after: finalContent };
440
- }
441
- }
442
- if (Object.keys(files).length > 0) {
443
- await input.recordEdit({
444
- label: input.label,
445
- kind: "timeline",
446
- coalesceKey: input.coalesceKey,
447
- coalesceMs: GSAP_HISTORY_COALESCE_MS,
448
- files,
449
- });
450
- }
451
- }
452
- return status;
453
- }
454
-
455
- /**
456
- * Shift all GSAP animation positions targeting a given element by a time delta.
457
- * Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
458
- */
459
- export async function shiftGsapPositions(
460
- projectId: string,
461
- filePath: string,
462
- elementId: string,
463
- delta: number,
464
- ): Promise<GsapMutationStatus> {
465
- if (delta === 0 || !elementId) return { mutated: false };
466
- const res = await fetch(
467
- `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
468
- {
469
- method: "POST",
470
- headers: { "Content-Type": "application/json" },
471
- body: JSON.stringify({
472
- type: "shift-positions",
473
- targetSelector: `#${elementId}`,
474
- delta,
475
- }),
476
- },
477
- );
478
- if (!res.ok) {
479
- const err = await res.json().catch(() => null);
480
- throw new Error(readMutationError(err, "shift-positions failed"));
481
- }
482
- return readMutationStatus(await res.json().catch(() => null));
483
- }
484
-
485
- export async function scaleGsapPositions(
486
- projectId: string,
487
- filePath: string,
488
- elementId: string,
489
- oldStart: number,
490
- oldDuration: number,
491
- newStart: number,
492
- newDuration: number,
493
- ): Promise<GsapMutationStatus> {
494
- if (!elementId || oldDuration <= 0 || newDuration <= 0) return { mutated: false };
495
- if (oldStart === newStart && oldDuration === newDuration) return { mutated: false };
496
- const res = await fetch(
497
- `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
498
- {
499
- method: "POST",
500
- headers: { "Content-Type": "application/json" },
501
- body: JSON.stringify({
502
- type: "scale-positions",
503
- targetSelector: `#${elementId}`,
504
- oldStart,
505
- oldDuration,
506
- newStart,
507
- newDuration,
508
- }),
509
- },
510
- );
511
- if (!res.ok) {
512
- const err = await res.json().catch(() => null);
513
- throw new Error(readMutationError(err, "scale-positions failed"));
514
- }
515
- return readMutationStatus(await res.json().catch(() => null));
516
- }
517
-
518
- /** Single-clip move GSAP shift, folded into the timing edit's history entry (see above). */
519
- export function foldedShiftGsapMutation(input: {
520
- projectId: string;
521
- targetPath: string;
522
- domId: string;
523
- delta: number;
524
- label: string;
525
- coalesceKey?: string;
526
- recordEdit: (edit: RecordEditInput) => Promise<void>;
527
- }): () => Promise<GsapMutationStatus> {
528
- return () =>
529
- foldGsapMutationIntoHistory({
530
- projectId: input.projectId,
531
- paths: [input.targetPath],
532
- label: input.label,
533
- coalesceKey: input.coalesceKey,
534
- recordEdit: input.recordEdit,
535
- gsapMutation: () =>
536
- shiftGsapPositions(input.projectId, input.targetPath, input.domId, input.delta),
537
- });
538
- }
539
-
540
- /** Single-clip resize GSAP scale, folded into the timing edit's history entry (see above). */
541
- export function foldedScaleGsapMutation(input: {
542
- projectId: string;
543
- targetPath: string;
544
- domId: string;
545
- from: { start: number; duration: number };
546
- to: { start: number; duration: number };
547
- label: string;
548
- coalesceKey?: string;
549
- recordEdit: (edit: RecordEditInput) => Promise<void>;
550
- }): () => Promise<GsapMutationStatus> {
551
- return () =>
552
- foldGsapMutationIntoHistory({
553
- projectId: input.projectId,
554
- paths: [input.targetPath],
555
- label: input.label,
556
- coalesceKey: input.coalesceKey,
557
- recordEdit: input.recordEdit,
558
- gsapMutation: () =>
559
- scaleGsapPositions(
560
- input.projectId,
561
- input.targetPath,
562
- input.domId,
563
- input.from.start,
564
- input.from.duration,
565
- input.to.start,
566
- input.to.duration,
567
- ),
568
- });
569
- }
570
-
571
375
  export { applyPatchByTarget, formatTimelineAttributeNumber };
572
376
 
573
377
  export { patchDocumentRootDuration } from "./timelineEditingGsap";