@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
@@ -3,35 +3,27 @@ import { useCallback, useRef } from "react";
3
3
  import type { TimelineElement } from "../player";
4
4
  import { usePlayerStore } from "../player";
5
5
  import { useRazorSplit } from "./useRazorSplit";
6
- import {
7
- buildTimelineAssetId,
8
- buildTimelineAssetInsertHtml,
9
- buildTimelineFileDropPlacements,
10
- getTimelineAssetKind,
11
- insertTimelineAssetIntoSource,
12
- resolveTimelineAssetInitialGeometry,
13
- resolveTimelineAssetSrc,
14
- } from "../utils/timelineAssetDrop";
6
+ import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
15
7
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
16
- import {
17
- getTimelineElementLabel,
18
- collectHtmlIds,
19
- resolveDroppedAssetDuration,
20
- } from "../utils/studioHelpers";
8
+ import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
9
+ import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
10
+ import { getTimelineElementLabel } from "../utils/studioHelpers";
21
11
  import {
22
12
  applyTimelineStackingReorder,
23
13
  buildPatchTarget,
24
14
  patchIframeDomTiming,
25
15
  persistTimelineEdit,
26
- readFileContent,
27
- foldedShiftGsapMutation,
28
- foldedScaleGsapMutation,
29
16
  formatTimelineAttributeNumber,
30
- finishTimelineTimingFallback,
31
17
  extendRootDurationIfNeeded,
32
18
  buildTimelineMoveTimingPatch,
33
19
  buildTimelineResizeTimingPatch,
34
20
  } from "./timelineEditingHelpers";
21
+ import {
22
+ captureDurationRollback,
23
+ finishClipTimingFallback,
24
+ readFileContent,
25
+ syncPreviewContentDuration,
26
+ } from "./timelineTimingSync";
35
27
  import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
36
28
  import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
37
29
  import {
@@ -39,6 +31,7 @@ import {
39
31
  useTimelineTrackVisibilityEditing,
40
32
  } from "./timelineTrackVisibility";
41
33
  import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
34
+ import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
42
35
  import { sdkTimingPersist } from "../utils/sdkCutover";
43
36
  import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
44
37
 
@@ -135,80 +128,108 @@ export function useTimelineEditing({
135
128
  const handleTimelineElementMove = useCallback(
136
129
  // fallow-ignore-next-line complexity
137
130
  (element: TimelineElement, updates: TimelineMoveUpdates) => {
138
- const targetPath = element.sourceFile || activeCompPath || "index.html";
139
- const startChanged = updates.start !== element.start;
131
+ const commitMove = () => {
132
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
133
+ const startChanged = updates.start !== element.start;
134
+ // A vertical-only lane move arrives with start unchanged but track changed
135
+ // (on this single-element path the drag commit has already folded the
136
+ // AUTHORED persist track into updates.track). It must persist like any
137
+ // other move — early-returning on !startChanged alone silently dropped
138
+ // the file write, so the lane snapped back on reload.
139
+ const trackChanged = updates.track !== element.track;
140
140
 
141
- if (startChanged) {
142
- patchIframeDomTiming(previewIframeRef.current, element, [
143
- ["data-start", formatTimelineAttributeNumber(updates.start)],
144
- ]);
145
- }
141
+ if (startChanged || trackChanged) {
142
+ const liveAttrs: Array<[string, string]> = [];
143
+ if (startChanged) {
144
+ liveAttrs.push(["data-start", formatTimelineAttributeNumber(updates.start)]);
145
+ }
146
+ if (trackChanged) {
147
+ liveAttrs.push(["data-track-index", formatTimelineAttributeNumber(updates.track)]);
148
+ }
149
+ patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath);
150
+ }
146
151
 
147
- const reorderDone = applyTimelineStackingReorder({
148
- element,
149
- stackingReorder: updates.stackingReorder,
150
- timelineElements,
151
- iframe: previewIframeRef.current,
152
- activeCompPath,
153
- commit: handleDomZIndexReorderCommitRef?.current,
154
- });
152
+ const reorderDone = applyTimelineStackingReorder({
153
+ element,
154
+ stackingReorder: updates.stackingReorder,
155
+ timelineElements,
156
+ iframe: previewIframeRef.current,
157
+ activeCompPath,
158
+ commit: handleDomZIndexReorderCommitRef?.current,
159
+ });
155
160
 
156
- if (!startChanged) return reorderDone;
161
+ if (!startChanged && !trackChanged) return reorderDone;
157
162
 
158
- const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
159
- return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration);
160
- };
161
- const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
162
- const moveFallback = () =>
163
- enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => {
164
- const pid = projectIdRef.current;
165
- const delta = updates.start - element.start;
166
- const domId = element.domId;
167
- return finishTimelineTimingFallback({
168
- iframe: previewIframeRef.current,
169
- needsExtension,
170
- rootDurationSeconds: updates.start + element.duration,
171
- reloadPreview,
172
- gsapMutation:
173
- delta !== 0 && domId && pid
174
- ? foldedShiftGsapMutation({
175
- projectId: pid,
176
- targetPath,
177
- domId,
178
- delta,
179
- label: "Move timeline clip",
180
- coalesceKey,
181
- recordEdit,
182
- })
183
- : undefined,
184
- onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
185
- });
186
- });
187
- const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
188
- return reorderDone.then(() => {
189
- if (sdkSession && element.hfId && !needsExtension) {
190
- return sdkTimingPersist(
191
- element.hfId,
192
- targetPath,
193
- { start: updates.start },
194
- sdkSession,
195
- {
196
- editHistory: { recordEdit },
197
- writeProjectFile,
163
+ // Snapshot the duration BEFORE the optimistic updates below so a failed
164
+ // persist can roll the readout + live root back (see captureDurationRollback).
165
+ const rollbackDuration = captureDurationRollback(previewIframeRef.current);
166
+ // needsExtension gates the SDK path (setTiming can't grow the root duration), so read the store BEFORE the readout sync below optimistically updates it.
167
+ const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
168
+ // Optimistic duration readout: content-driven (grow AND shrink), from the just-patched live DOM. See syncPreviewContentDuration.
169
+ syncPreviewContentDuration(previewIframeRef.current);
170
+
171
+ const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
172
+ // Persist lane changes too — data-start-only writes let reload snap the lane back.
173
+ const track = trackChanged ? updates.track : undefined;
174
+ return buildTimelineMoveTimingPatch(
175
+ original,
176
+ target,
177
+ updates.start,
178
+ element.duration,
179
+ track,
180
+ );
181
+ };
182
+ const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
183
+ const moveFallback = () =>
184
+ enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() =>
185
+ // Soft-reload with the server's rewritten GSAP script — the timing-only move already patched
186
+ // DOM + store, so swapping the script avoids the all-clips flash; falls back to reloadPreview().
187
+ finishClipTimingFallback({
188
+ iframe: previewIframeRef.current,
198
189
  reloadPreview,
199
- domEditSaveTimestampRef,
200
- compositionPath: activeCompPath,
201
- // Capture on-disk bytes as the undo `before` so undoing a timing move
202
- // restores the file verbatim, not a normalized full-DOM re-emit.
203
- readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
204
- },
205
- { label: "Move timeline clip", coalesceKey },
206
- ).then((handled) => {
207
- if (!handled) return moveFallback();
190
+ projectId: projectIdRef.current,
191
+ targetPath,
192
+ domId: element.domId,
193
+ label: "Move timeline clip",
194
+ coalesceKey,
195
+ recordEdit,
196
+ edit: { kind: "shift", delta: updates.start - element.start },
197
+ }),
198
+ );
199
+ return reorderDone
200
+ .then(() => {
201
+ // The SDK setTiming path writes start only — a lane change must take
202
+ // the fallback, whose patch builder writes data-track-index too.
203
+ if (sdkSession && element.hfId && !needsExtension && !trackChanged) {
204
+ return sdkTimingPersist(
205
+ element.hfId,
206
+ targetPath,
207
+ { start: updates.start },
208
+ sdkSession,
209
+ {
210
+ editHistory: { recordEdit },
211
+ writeProjectFile,
212
+ reloadPreview,
213
+ domEditSaveTimestampRef,
214
+ compositionPath: activeCompPath,
215
+ // Capture on-disk bytes as the undo `before` so undoing a timing move
216
+ // restores the file verbatim, not a normalized full-DOM re-emit.
217
+ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
218
+ },
219
+ { label: "Move timeline clip", coalesceKey },
220
+ ).then((handled) => {
221
+ if (!handled) return moveFallback();
222
+ });
223
+ }
224
+ return moveFallback();
225
+ })
226
+ .catch((error) => {
227
+ // Failed persist: revert the optimistic duration readout + live root.
228
+ rollbackDuration();
229
+ throw error;
208
230
  });
209
- }
210
- return moveFallback();
211
- });
231
+ };
232
+ return updates.stackingReorder ? serializeZLaneGesture(commitMove) : commitMove();
212
233
  },
213
234
  [
214
235
  previewIframeRef,
@@ -244,7 +265,14 @@ export function useTimelineEditing({
244
265
  : "data-media-start";
245
266
  liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
246
267
  }
247
- patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
268
+ patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath);
269
+ // Snapshot the duration BEFORE the optimistic updates below so a failed
270
+ // persist can roll the readout + live root back (see captureDurationRollback).
271
+ const rollbackDuration = captureDurationRollback(previewIframeRef.current);
272
+ // needsExtension gates the SDK path (setTiming can't grow the root duration), so read the store BEFORE the readout sync below optimistically updates it.
273
+ const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration);
274
+ // Optimistic duration readout: content-driven (grow AND shrink), from the just-patched live DOM. See syncPreviewContentDuration.
275
+ syncPreviewContentDuration(previewIframeRef.current);
248
276
  const targetPath = element.sourceFile || activeCompPath || "index.html";
249
277
  const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
250
278
  return buildTimelineResizeTimingPatch(original, target, element, updates);
@@ -253,59 +281,55 @@ export function useTimelineEditing({
253
281
  updates.playbackStart != null ||
254
282
  (updates.start !== element.start && element.playbackStart != null);
255
283
  // Server-path fallback: after persisting the attr patch, scale GSAP tween
256
- // positions/durations on the server. Extending edits can keep the iframe
257
- // live unless a GSAP source rewrite needs a fresh run.
284
+ // positions/durations on the server, then soft-reload with the rewritten
285
+ // script (timing-only resize) same no-flash path as move; full reload is
286
+ // the fallback.
258
287
  const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
259
- const timingChanged =
260
- updates.start !== element.start || updates.duration !== element.duration;
261
- const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration);
262
288
  const resizeFallback = () =>
263
- enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => {
264
- const pid = projectIdRef.current;
265
- const domId = element.domId;
266
- return finishTimelineTimingFallback({
289
+ enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() =>
290
+ finishClipTimingFallback({
267
291
  iframe: previewIframeRef.current,
268
- needsExtension,
269
- rootDurationSeconds: updates.start + updates.duration,
270
- reloadPreview,
271
- gsapMutation:
272
- timingChanged && domId && pid
273
- ? foldedScaleGsapMutation({
274
- projectId: pid,
275
- targetPath,
276
- domId,
277
- from: { start: element.start, duration: element.duration },
278
- to: { start: updates.start, duration: updates.duration },
279
- label: "Resize timeline clip",
280
- coalesceKey,
281
- recordEdit,
282
- })
283
- : undefined,
284
- onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
285
- });
286
- });
287
- if (sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension) {
288
- return sdkTimingPersist(
289
- element.hfId,
290
- targetPath,
291
- { start: updates.start, duration: updates.duration },
292
- sdkSession,
293
- {
294
- editHistory: { recordEdit },
295
- writeProjectFile,
296
292
  reloadPreview,
297
- domEditSaveTimestampRef,
298
- compositionPath: activeCompPath,
299
- // Capture on-disk bytes as the undo `before` so undoing a timing
300
- // resize restores the file verbatim, not a normalized full-DOM re-emit.
301
- readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
302
- },
303
- { label: "Resize timeline clip", coalesceKey },
304
- ).then((handled) => {
305
- if (!handled) return resizeFallback();
306
- });
307
- }
308
- return resizeFallback();
293
+ projectId: projectIdRef.current,
294
+ targetPath,
295
+ domId: element.domId,
296
+ label: "Resize timeline clip",
297
+ coalesceKey,
298
+ recordEdit,
299
+ edit: {
300
+ kind: "scale",
301
+ from: { start: element.start, duration: element.duration },
302
+ to: { start: updates.start, duration: updates.duration },
303
+ },
304
+ }),
305
+ );
306
+ const persistDone =
307
+ sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension
308
+ ? sdkTimingPersist(
309
+ element.hfId,
310
+ targetPath,
311
+ { start: updates.start, duration: updates.duration },
312
+ sdkSession,
313
+ {
314
+ editHistory: { recordEdit },
315
+ writeProjectFile,
316
+ reloadPreview,
317
+ domEditSaveTimestampRef,
318
+ compositionPath: activeCompPath,
319
+ // Capture on-disk bytes as the undo `before` so undoing a timing
320
+ // resize restores the file verbatim, not a normalized full-DOM re-emit.
321
+ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
322
+ },
323
+ { label: "Resize timeline clip", coalesceKey },
324
+ ).then((handled) => {
325
+ if (!handled) return resizeFallback();
326
+ })
327
+ : resizeFallback();
328
+ return persistDone.catch((error) => {
329
+ // Failed persist: revert the optimistic duration readout + live root.
330
+ rollbackDuration();
331
+ throw error;
332
+ });
309
333
  },
310
334
  [
311
335
  previewIframeRef,
@@ -384,19 +408,37 @@ export function useTimelineEditing({
384
408
  changed?: boolean;
385
409
  content?: string;
386
410
  };
387
- const patchedContent =
411
+ const removedContent =
388
412
  typeof removeData.content === "string" ? removeData.content : originalContent;
413
+ // Content-driven duration: shrink the composition to the furthest
414
+ // remaining clip end, read from the post-removal SOURCE (raw
415
+ // data-duration), so deleting the last/longest clip removes trailing
416
+ // empty space. Measured from the source, not the store, whose
417
+ // durations are runtime-truncated.
418
+ const deleteContentEnd = furthestClipEndFromSource(removedContent);
419
+ const patchedContent = setCompositionDurationToContent(removedContent, deleteContentEnd);
420
+ // Optimistically reflect the shrunk length in the readout/seek bar,
421
+ // rolling it back if the persist below fails (see captureDurationRollback).
422
+ const rollbackDuration = captureDurationRollback(previewIframeRef.current);
423
+ if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
424
+ usePlayerStore.getState().setDuration(deleteContentEnd);
425
+ }
389
426
 
390
427
  domEditSaveTimestampRef.current = Date.now();
391
- await saveProjectFilesWithHistory({
392
- projectId: pid,
393
- label: "Delete timeline clip",
394
- kind: "timeline",
395
- files: { [targetPath]: patchedContent },
396
- readFile: async () => originalContent,
397
- writeFile: writeProjectFile,
398
- recordEdit,
399
- });
428
+ try {
429
+ await saveProjectFilesWithHistory({
430
+ projectId: pid,
431
+ label: "Delete timeline clip",
432
+ kind: "timeline",
433
+ files: { [targetPath]: patchedContent },
434
+ readFile: async () => originalContent,
435
+ writeFile: writeProjectFile,
436
+ recordEdit,
437
+ });
438
+ } catch (error) {
439
+ rollbackDuration();
440
+ throw error;
441
+ }
400
442
 
401
443
  usePlayerStore
402
444
  .getState()
@@ -422,145 +464,23 @@ export function useTimelineEditing({
422
464
  reloadPreview,
423
465
  isRecordingRef,
424
466
  forceReloadSdkSession,
467
+ previewIframeRef,
425
468
  ],
426
469
  );
427
470
 
428
- // fallow-ignore-next-line complexity
429
- const handleTimelineAssetDrop = useCallback(
430
- // fallow-ignore-next-line complexity
431
- async (
432
- assetPath: string,
433
- placement: Pick<TimelineElement, "start" | "track">,
434
- durationOverride?: number,
435
- ) => {
436
- if (isRecordingRef?.current) {
437
- showToast("Cannot edit timeline while recording", "error");
438
- return;
439
- }
440
- const pid = projectIdRef.current;
441
- if (!pid) throw new Error("No active project");
442
-
443
- const kind = getTimelineAssetKind(assetPath);
444
- if (!kind) {
445
- showToast("Only image, video, and audio assets can be dropped onto the timeline.");
446
- return;
447
- }
448
-
449
- const targetPath = activeCompPath || "index.html";
450
- try {
451
- const originalContent = await readFileContent(pid, targetPath);
452
-
453
- const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
454
- const duration =
455
- Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
456
- ? durationOverride
457
- : await resolveDroppedAssetDuration(pid, assetPath, kind);
458
- const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
459
- const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
460
- const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
461
-
462
- const resolvedTargetPath = targetPath || "index.html";
463
- const relevantElements = timelineElements.filter(
464
- (te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
465
- );
466
- const newElementZIndex = Math.max(1, relevantElements.length + 1);
467
-
468
- const patchedContent = insertTimelineAssetIntoSource(
469
- originalContent,
470
- buildTimelineAssetInsertHtml({
471
- id: newId,
472
- assetPath: resolvedAssetSrc,
473
- kind,
474
- start: normalizedStart,
475
- duration: normalizedDuration,
476
- track: placement.track,
477
- zIndex: newElementZIndex,
478
- geometry: resolveTimelineAssetInitialGeometry(originalContent),
479
- }),
480
- );
481
-
482
- domEditSaveTimestampRef.current = Date.now();
483
- await saveProjectFilesWithHistory({
484
- projectId: pid,
485
- label: "Add timeline asset",
486
- kind: "timeline",
487
- files: { [targetPath]: patchedContent },
488
- readFile: async () => originalContent,
489
- writeFile: writeProjectFile,
490
- recordEdit,
491
- });
492
-
493
- forceReloadSdkSession?.();
494
- reloadPreview();
495
- } catch (error) {
496
- const message =
497
- error instanceof Error ? error.message : "Failed to drop asset onto timeline";
498
- showToast(message);
499
- }
500
- },
501
- [
502
- activeCompPath,
503
- recordEdit,
504
- showToast,
505
- timelineElements,
506
- writeProjectFile,
507
- domEditSaveTimestampRef,
508
- reloadPreview,
509
- isRecordingRef,
510
- forceReloadSdkSession,
511
- ],
512
- );
513
-
514
- // fallow-ignore-next-line complexity
515
- const handleTimelineFileDrop = useCallback(
516
- // fallow-ignore-next-line complexity
517
- async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
518
- if (isRecordingRef?.current) {
519
- showToast("Cannot edit timeline while recording", "error");
520
- return;
521
- }
522
- const pid = projectIdRef.current;
523
- if (!pid) return;
524
- const uploaded = await uploadProjectFiles(files);
525
- if (uploaded.length === 0) return;
526
- const durations: number[] = [];
527
- for (const assetPath of uploaded) {
528
- const kind = getTimelineAssetKind(assetPath);
529
- const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
530
- durations.push(Number(formatTimelineAttributeNumber(duration)));
531
- }
532
- const placements = buildTimelineFileDropPlacements(
533
- placement ?? { start: 0, track: 0 },
534
- durations,
535
- timelineElements
536
- .filter(
537
- (te) =>
538
- (te.sourceFile || activeCompPath || "index.html") ===
539
- (activeCompPath || "index.html"),
540
- )
541
- .map((te) => ({
542
- start: te.start,
543
- duration: te.duration,
544
- track: te.track,
545
- })),
546
- );
547
- for (const [index, assetPath] of uploaded.entries()) {
548
- await handleTimelineAssetDrop(
549
- assetPath,
550
- placements[index] ?? placements[0],
551
- durations[index],
552
- );
553
- }
554
- },
555
- [
556
- activeCompPath,
557
- handleTimelineAssetDrop,
558
- timelineElements,
559
- uploadProjectFiles,
560
- isRecordingRef,
561
- showToast,
562
- ],
563
- );
471
+ const { handleTimelineAssetDrop, handleTimelineFileDrop } = useTimelineAssetDropOps({
472
+ projectIdRef,
473
+ activeCompPath,
474
+ timelineElements,
475
+ showToast,
476
+ writeProjectFile,
477
+ recordEdit,
478
+ domEditSaveTimestampRef,
479
+ reloadPreview,
480
+ uploadProjectFiles,
481
+ isRecordingRef,
482
+ forceReloadSdkSession,
483
+ });
564
484
 
565
485
  const handleBlockedTimelineEdit = useCallback(
566
486
  (_element: TimelineElement) => {
@@ -23,7 +23,7 @@ export type TimelineZIndexReorderCommit = (
23
23
  key?: string;
24
24
  }>,
25
25
  coalesceKey?: string,
26
- ) => Promise<void>;
26
+ ) => Promise<import("./domEditCommitTypes").DomEditPatchBatchesResult | undefined | void>;
27
27
 
28
28
  export interface UseTimelineEditingOptions {
29
29
  projectId: string | null;