@hyperframes/studio 0.7.57 → 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 (112) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
  2. package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
  3. package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-Bf1x1y8H.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 +15 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +5954 -4474
  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 +27 -14
  17. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  18. package/src/components/editor/DomEditOverlay.tsx +5 -2
  19. package/src/components/editor/LayersPanel.tsx +110 -99
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  21. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  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/useLayerRevealOverride.test.ts +169 -0
  26. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  27. package/src/components/nle/PreviewOverlays.tsx +27 -2
  28. package/src/components/nle/TimelinePane.test.ts +3 -0
  29. package/src/components/nle/TimelinePane.tsx +11 -1
  30. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  31. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  32. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  33. package/src/components/nle/zLaneGesture.test.ts +176 -0
  34. package/src/components/nle/zLaneGesture.ts +77 -0
  35. package/src/hooks/domEditCommitTypes.ts +17 -1
  36. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  37. package/src/hooks/fetchStubTestUtils.ts +16 -0
  38. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  39. package/src/hooks/timelineEditingHelpers.ts +32 -15
  40. package/src/hooks/timelineMoveAdapter.ts +2 -1
  41. package/src/hooks/timelineTimingSync.test.ts +703 -33
  42. package/src/hooks/timelineTimingSync.ts +282 -97
  43. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  44. package/src/hooks/timelineTrackVisibility.ts +4 -3
  45. package/src/hooks/useClipboard.ts +19 -17
  46. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  47. package/src/hooks/useDomEditCommits.ts +143 -78
  48. package/src/hooks/useDomEditSession.test.tsx +3 -1
  49. package/src/hooks/useDomEditSession.ts +1 -1
  50. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  51. package/src/hooks/useElementLifecycleOps.ts +174 -81
  52. package/src/hooks/usePreviewPersistence.ts +2 -2
  53. package/src/hooks/useTimelineEditing.test.tsx +302 -154
  54. package/src/hooks/useTimelineEditing.ts +97 -93
  55. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  56. package/src/hooks/useTimelineGroupEditing.ts +35 -3
  57. package/src/player/components/Timeline.test.ts +31 -21
  58. package/src/player/components/Timeline.tsx +62 -91
  59. package/src/player/components/TimelineCanvas.tsx +36 -5
  60. package/src/player/components/TimelineLanes.tsx +32 -10
  61. package/src/player/components/TimelineOverlays.tsx +36 -0
  62. package/src/player/components/TimelineRuler.tsx +18 -5
  63. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  64. package/src/player/components/timelineCallbacks.ts +4 -1
  65. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  66. package/src/player/components/timelineClipDragCommit.ts +173 -71
  67. package/src/player/components/timelineGapCommit.test.ts +163 -0
  68. package/src/player/components/timelineGapCommit.ts +103 -0
  69. package/src/player/components/timelineGaps.test.ts +221 -0
  70. package/src/player/components/timelineGaps.ts +182 -0
  71. package/src/player/components/timelineLayout.test.ts +2 -1
  72. package/src/player/components/timelineLayout.ts +22 -6
  73. package/src/player/components/timelineMarquee.test.ts +20 -10
  74. package/src/player/components/timelineMarquee.ts +9 -2
  75. package/src/player/components/timelineStackingSync.test.ts +42 -1
  76. package/src/player/components/timelineStackingSync.ts +31 -16
  77. package/src/player/components/timelineZMirror.test.ts +667 -0
  78. package/src/player/components/timelineZMirror.ts +393 -0
  79. package/src/player/components/timelineZoom.test.ts +2 -2
  80. package/src/player/components/timelineZoom.ts +7 -3
  81. package/src/player/components/useTimelineClipDrag.ts +1 -1
  82. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  83. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  84. package/src/player/components/useTimelinePlayhead.ts +4 -3
  85. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  86. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  87. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  88. package/src/player/components/useTimelineStackingSync.ts +5 -3
  89. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  90. package/src/player/components/useTrackGapMenu.ts +155 -0
  91. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  92. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  93. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  94. package/src/player/lib/layerOrdering.test.ts +1 -15
  95. package/src/player/lib/layerOrdering.ts +5 -28
  96. package/src/player/lib/timelineDOM.test.ts +72 -0
  97. package/src/player/lib/timelineDOM.ts +3 -0
  98. package/src/player/lib/timelineElementHelpers.ts +35 -11
  99. package/src/player/store/playerStore.ts +12 -1
  100. package/src/telemetry/events.test.ts +10 -0
  101. package/src/telemetry/events.ts +1 -0
  102. package/src/utils/domEditSaveQueue.ts +4 -3
  103. package/src/utils/editHistory.test.ts +82 -0
  104. package/src/utils/editHistory.ts +14 -2
  105. package/src/utils/gsapSoftReload.test.ts +54 -115
  106. package/src/utils/gsapSoftReload.ts +42 -152
  107. package/src/utils/gsapUndoRestore.test.ts +269 -0
  108. package/src/utils/gsapUndoRestore.ts +269 -0
  109. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  110. package/dist/assets/index-_pqzyxB1.css +0 -1
  111. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  112. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -31,6 +31,7 @@ import {
31
31
  useTimelineTrackVisibilityEditing,
32
32
  } from "./timelineTrackVisibility";
33
33
  import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
34
+ import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
34
35
  import { sdkTimingPersist } from "../utils/sdkCutover";
35
36
  import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
36
37
 
@@ -127,105 +128,108 @@ export function useTimelineEditing({
127
128
  const handleTimelineElementMove = useCallback(
128
129
  // fallow-ignore-next-line complexity
129
130
  (element: TimelineElement, updates: TimelineMoveUpdates) => {
130
- const targetPath = element.sourceFile || activeCompPath || "index.html";
131
- const startChanged = updates.start !== element.start;
132
- // A vertical-only lane move arrives with start unchanged but track changed
133
- // (on this single-element path the drag commit has already folded the
134
- // AUTHORED persist track into updates.track). It must persist like any
135
- // other move early-returning on !startChanged alone silently dropped
136
- // the file write, so the lane snapped back on reload.
137
- const trackChanged = updates.track !== element.track;
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;
138
140
 
139
- if (startChanged || trackChanged) {
140
- const liveAttrs: Array<[string, string]> = [];
141
- if (startChanged) {
142
- liveAttrs.push(["data-start", formatTimelineAttributeNumber(updates.start)]);
143
- }
144
- if (trackChanged) {
145
- liveAttrs.push(["data-track-index", formatTimelineAttributeNumber(updates.track)]);
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);
146
150
  }
147
- patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
148
- }
149
151
 
150
- const reorderDone = applyTimelineStackingReorder({
151
- element,
152
- stackingReorder: updates.stackingReorder,
153
- timelineElements,
154
- iframe: previewIframeRef.current,
155
- activeCompPath,
156
- commit: handleDomZIndexReorderCommitRef?.current,
157
- });
152
+ const reorderDone = applyTimelineStackingReorder({
153
+ element,
154
+ stackingReorder: updates.stackingReorder,
155
+ timelineElements,
156
+ iframe: previewIframeRef.current,
157
+ activeCompPath,
158
+ commit: handleDomZIndexReorderCommitRef?.current,
159
+ });
158
160
 
159
- if (!startChanged && !trackChanged) return reorderDone;
161
+ if (!startChanged && !trackChanged) return reorderDone;
160
162
 
161
- // Snapshot the duration BEFORE the optimistic updates below so a failed
162
- // persist can roll the readout + live root back (see captureDurationRollback).
163
- const rollbackDuration = captureDurationRollback(previewIframeRef.current);
164
- // needsExtension gates the SDK path (setTiming can't grow the root duration), so read the store BEFORE the readout sync below optimistically updates it.
165
- const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
166
- // Optimistic duration readout: content-driven (grow AND shrink), from the just-patched live DOM. See syncPreviewContentDuration.
167
- syncPreviewContentDuration(previewIframeRef.current);
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);
168
170
 
169
- const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
170
- // Persist lane changes too — data-start-only writes let reload snap the lane back.
171
- const track = trackChanged ? updates.track : undefined;
172
- return buildTimelineMoveTimingPatch(
173
- original,
174
- target,
175
- updates.start,
176
- element.duration,
177
- track,
178
- );
179
- };
180
- const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
181
- const moveFallback = () =>
182
- enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() =>
183
- // Soft-reload with the server's rewritten GSAP script — the timing-only move already patched
184
- // DOM + store, so swapping the script avoids the all-clips flash; falls back to reloadPreview().
185
- finishClipTimingFallback({
186
- iframe: previewIframeRef.current,
187
- reloadPreview,
188
- projectId: projectIdRef.current,
189
- targetPath,
190
- domId: element.domId,
191
- label: "Move timeline clip",
192
- coalesceKey,
193
- recordEdit,
194
- edit: { kind: "shift", delta: updates.start - element.start },
195
- }),
196
- );
197
- return reorderDone
198
- .then(() => {
199
- // The SDK setTiming path writes start only — a lane change must take
200
- // the fallback, whose patch builder writes data-track-index too.
201
- if (sdkSession && element.hfId && !needsExtension && !trackChanged) {
202
- return sdkTimingPersist(
203
- element.hfId,
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,
189
+ reloadPreview,
190
+ projectId: projectIdRef.current,
204
191
  targetPath,
205
- { start: updates.start },
206
- sdkSession,
207
- {
208
- editHistory: { recordEdit },
209
- writeProjectFile,
210
- reloadPreview,
211
- domEditSaveTimestampRef,
212
- compositionPath: activeCompPath,
213
- // Capture on-disk bytes as the undo `before` so undoing a timing move
214
- // restores the file verbatim, not a normalized full-DOM re-emit.
215
- readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
216
- },
217
- { label: "Move timeline clip", coalesceKey },
218
- ).then((handled) => {
219
- if (!handled) return moveFallback();
220
- });
221
- }
222
- return moveFallback();
223
- })
224
- .catch((error) => {
225
- // Failed persist: revert the optimistic duration readout + live root.
226
- rollbackDuration();
227
- throw error;
228
- });
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;
230
+ });
231
+ };
232
+ return updates.stackingReorder ? serializeZLaneGesture(commitMove) : commitMove();
229
233
  },
230
234
  [
231
235
  previewIframeRef,
@@ -261,7 +265,7 @@ export function useTimelineEditing({
261
265
  : "data-media-start";
262
266
  liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
263
267
  }
264
- patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
268
+ patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath);
265
269
  // Snapshot the duration BEFORE the optimistic updates below so a failed
266
270
  // persist can roll the readout + live root back (see captureDurationRollback).
267
271
  const rollbackDuration = captureDurationRollback(previewIframeRef.current);
@@ -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;
@@ -37,6 +37,8 @@ export interface TimelineGroupResizeChange {
37
37
  export interface TimelineGroupCommitOptions {
38
38
  beforeTiming?: Promise<void>;
39
39
  coalesceKey?: string;
40
+ /** Per-entry undo coalesce window override (ms) — see EditHistoryEntry.coalesceMs. */
41
+ coalesceMs?: number;
40
42
  }
41
43
 
42
44
  interface UseTimelineGroupEditingOptions {
@@ -137,6 +139,7 @@ export function useTimelineGroupEditing({
137
139
  label: string,
138
140
  batchChanges: PersistTimelineBatchChange[],
139
141
  coalesceKey: string,
142
+ coalesceMs?: number,
140
143
  ) => {
141
144
  await persistTimelineBatchEdit({
142
145
  projectId,
@@ -148,6 +151,7 @@ export function useTimelineGroupEditing({
148
151
  domEditSaveTimestampRef,
149
152
  pendingTimelineEditPathRef,
150
153
  coalesceKey,
154
+ coalesceMs,
151
155
  });
152
156
  forceReloadSdkSession?.();
153
157
  },
@@ -176,6 +180,7 @@ export function useTimelineGroupEditing({
176
180
  needsExtension: boolean;
177
181
  label: string;
178
182
  coalesceKey: string;
183
+ coalesceMs?: number;
179
184
  }): Promise<boolean> => {
180
185
  const sharedPath = allChangesSharePath(input.changes, activeCompPath);
181
186
  const canUseSdk =
@@ -196,7 +201,7 @@ export function useTimelineGroupEditing({
196
201
  compositionPath: activeCompPath,
197
202
  readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
198
203
  },
199
- { label: input.label, coalesceKey: input.coalesceKey },
204
+ { label: input.label, coalesceKey: input.coalesceKey, coalesceMs: input.coalesceMs },
200
205
  );
201
206
  },
202
207
  [
@@ -220,20 +225,38 @@ export function useTimelineGroupEditing({
220
225
  if (change.track != null) {
221
226
  attrs.push(["data-track-index", formatTimelineAttributeNumber(change.track)]);
222
227
  }
223
- patchIframeDomTiming(previewIframeRef.current, change.element, attrs);
228
+ patchIframeDomTiming(previewIframeRef.current, change.element, attrs, activeCompPath);
224
229
  }
225
230
 
231
+ // TRACK-ONLY batch: every change keeps its start (moves never carry a
232
+ // duration change), so nothing timing-related changed — the batch only
233
+ // rewrites data-track-index, which the renderer never reads (documented
234
+ // in core runtime/timeline.ts; track is a studio lane concept). The live
235
+ // DOM patch above + the gesture owner's optimistic store update fully
236
+ // cover the UI, so after the persist there is nothing to GSAP-shift and
237
+ // nothing for the preview to recompute: skip the fallback below entirely.
238
+ // Running it anyway is what made the mirrored z-order lane move blink —
239
+ // a zero-delta batch yields no scriptText, and finishGroupTimingGsapFallback
240
+ // used to full-reload the iframe when there was no script to soft-swap
241
+ // (it now rebinds the runtime timing in place, but a track-only batch
242
+ // needs NO preview sync at all, so the skip stays).
243
+ const trackOnly = changes.every((change) => change.start === change.element.start);
244
+
226
245
  const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration));
227
246
  // Snapshot the duration BEFORE the optimistic updates below so a failed
228
247
  // persist can roll the readout + live root back (see captureDurationRollback).
229
248
  const rollbackDuration = captureDurationRollback(previewIframeRef.current);
230
249
  // needsExtension gates the SDK path (setTiming can't grow the root duration),
231
250
  // so read the store BEFORE the readout sync below optimistically updates it.
251
+ // Track-only batches leave every clip end unchanged, so both this and the
252
+ // readout sync below are provable no-ops there — kept unconditional so the
253
+ // duration machinery stays on one code path.
232
254
  const needsExtension = extendRootDurationIfNeeded(maxEnd);
233
255
  // Optimistic duration readout: content-driven (grow AND shrink), read from
234
256
  // the just-patched live DOM. See syncPreviewContentDuration.
235
257
  syncPreviewContentDuration(previewIframeRef.current);
236
258
  const coalesceKey = options?.coalesceKey ?? moveCoalesceKey(changes);
259
+ const coalesceMs = options?.coalesceMs;
237
260
  return enqueueGroupOperation("Move timeline clips", async (projectId) => {
238
261
  await options?.beforeTiming;
239
262
  const handledBySdk = await trySdkBatchPersist({
@@ -243,6 +266,7 @@ export function useTimelineGroupEditing({
243
266
  needsExtension,
244
267
  label: "Move timeline clips",
245
268
  coalesceKey,
269
+ coalesceMs,
246
270
  });
247
271
  if (handledBySdk) return;
248
272
 
@@ -261,7 +285,12 @@ export function useTimelineGroupEditing({
261
285
  ),
262
286
  })),
263
287
  coalesceKey,
288
+ coalesceMs,
264
289
  );
290
+ // Track-only: no timing delta → no GSAP positions to shift and no
291
+ // reload (see the trackOnly doc above). Mixed batches (any start
292
+ // change) keep the full fallback below.
293
+ if (trackOnly) return;
265
294
  await finishGroupTimingGsapFallback({
266
295
  projectId,
267
296
  iframe: previewIframeRef.current,
@@ -313,7 +342,7 @@ export function useTimelineGroupEditing({
313
342
  : "data-media-start";
314
343
  liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]);
315
344
  }
316
- patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs);
345
+ patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs, activeCompPath);
317
346
  }
318
347
 
319
348
  const maxEnd = Math.max(...changes.map((change) => change.start + change.duration));
@@ -327,6 +356,7 @@ export function useTimelineGroupEditing({
327
356
  // the just-patched live DOM. See syncPreviewContentDuration.
328
357
  syncPreviewContentDuration(previewIframeRef.current);
329
358
  const coalesceKey = options?.coalesceKey ?? resizeCoalesceKey(changes);
359
+ const coalesceMs = options?.coalesceMs;
330
360
  return enqueueGroupOperation("Resize timeline clips", async (projectId) => {
331
361
  await options?.beforeTiming;
332
362
  const handledBySdk = await trySdkBatchPersist({
@@ -339,6 +369,7 @@ export function useTimelineGroupEditing({
339
369
  needsExtension,
340
370
  label: "Resize timeline clips",
341
371
  coalesceKey,
372
+ coalesceMs,
342
373
  });
343
374
  if (handledBySdk) return;
344
375
 
@@ -355,6 +386,7 @@ export function useTimelineGroupEditing({
355
386
  }),
356
387
  })),
357
388
  coalesceKey,
389
+ coalesceMs,
358
390
  );
359
391
  await finishGroupTimingGsapFallback({
360
392
  projectId,
@@ -25,6 +25,7 @@ import {
25
25
  PLAYHEAD_HEAD_W,
26
26
  RULER_H,
27
27
  TRACK_H,
28
+ TRACKS_LEFT_PAD,
28
29
  getTimelineDisplayContentWidth,
29
30
  getTimelineFitPps,
30
31
  } from "./timelineLayout";
@@ -152,7 +153,8 @@ describe("Timeline provider boundary", () => {
152
153
  });
153
154
 
154
155
  const row = button.parentElement?.parentElement;
155
- const trackContent = row?.children.item(1);
156
+ // Row children: [sticky gutter, TRACKS_LEFT_PAD spacer, time-mapped content].
157
+ const trackContent = row?.children.item(2);
156
158
  expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false);
157
159
  expect(trackContent).toBeInstanceOf(HTMLElement);
158
160
  if (!(trackContent instanceof HTMLElement)) {
@@ -454,27 +456,27 @@ describe("shouldAutoScrollTimeline", () => {
454
456
  });
455
457
 
456
458
  describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
457
- const viewport = 632; // usable width = 632 - GUTTER - 2 = 598
459
+ const viewport = 632; // usable width = 632 - GUTTER - TRACKS_LEFT_PAD - 2
458
460
 
459
461
  it("computes fit pps against the 60s floor for short compositions", () => {
460
462
  // A 10s comp maps 60s onto the viewport → the comp takes ~1/6 of the width.
461
463
  // (10 * 1.2 = 12s of headroom-padded content is still under the 60s floor.)
462
464
  const pps = getTimelineFitPps(viewport, 10);
463
- expect(pps).toBeCloseTo((viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S);
464
- expect(10 * pps).toBeCloseTo((viewport - GUTTER - 2) / 6);
465
+ expect(pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S);
466
+ expect(10 * pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / 6);
465
467
  });
466
468
 
467
469
  it("fits duration * FIT_ZOOM_HEADROOM (not the bare duration) for long compositions", () => {
468
470
  expect(getTimelineFitPps(viewport, 60)).toBeCloseTo(
469
- (viewport - GUTTER - 2) / (60 * FIT_ZOOM_HEADROOM),
471
+ (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (60 * FIT_ZOOM_HEADROOM),
470
472
  );
471
473
  expect(getTimelineFitPps(viewport, 120)).toBeCloseTo(
472
- (viewport - GUTTER - 2) / (120 * FIT_ZOOM_HEADROOM),
474
+ (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (120 * FIT_ZOOM_HEADROOM),
473
475
  );
474
476
  });
475
477
 
476
478
  it("leaves CapCut-style trailing headroom: the comp ends at 1/1.2 of the usable width", () => {
477
- const usable = viewport - GUTTER - 2;
479
+ const usable = viewport - GUTTER - TRACKS_LEFT_PAD - 2;
478
480
  const pps = getTimelineFitPps(viewport, 120);
479
481
  // Composition content occupies usable/1.2 px; the remaining ~17% is empty
480
482
  // droppable ruler/lane surface past the end.
@@ -484,16 +486,16 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
484
486
 
485
487
  it("falls back to 100 pps before the viewport is measured", () => {
486
488
  expect(getTimelineFitPps(0, 10)).toBe(100);
487
- expect(getTimelineFitPps(GUTTER, 10)).toBe(100);
489
+ expect(getTimelineFitPps(GUTTER + TRACKS_LEFT_PAD, 10)).toBe(100);
488
490
  expect(getTimelineFitPps(Number.NaN, 10)).toBe(100);
489
491
  });
490
492
 
491
493
  it("uses the floor for zero/invalid durations", () => {
492
494
  expect(getTimelineFitPps(viewport, 0)).toBeCloseTo(
493
- (viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S,
495
+ (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S,
494
496
  );
495
497
  expect(getTimelineFitPps(viewport, Number.NaN)).toBeCloseTo(
496
- (viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S,
498
+ (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S,
497
499
  );
498
500
  });
499
501
  });
@@ -509,7 +511,7 @@ describe("getTimelineDisplayContentWidth", () => {
509
511
  it("still fills the viewport when that is larger than the 60s floor", () => {
510
512
  expect(
511
513
  getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 2000, pps: 5 }),
512
- ).toBe(2000 - GUTTER - 2);
514
+ ).toBe(2000 - GUTTER - TRACKS_LEFT_PAD - 2);
513
515
  });
514
516
 
515
517
  it("tracks a drag ghost past every other bound (drag-to-extend)", () => {
@@ -599,20 +601,28 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
599
601
  });
600
602
 
601
603
  describe("getTimelinePlayheadLeft", () => {
602
- it("offsets the wrapper by half the head width so the line CENTER = GUTTER + t*pps", () => {
604
+ it("offsets the wrapper by half the head width so the line CENTER = GUTTER + TRACKS_LEFT_PAD + t*pps", () => {
603
605
  // Wrapper left + PLAYHEAD_HEAD_W/2 (where the 1px line is centered) must
604
- // equal GUTTER + t*pps at any zoom.
605
- expect(getTimelinePlayheadLeft(4, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 4 * 20);
606
- expect(getTimelinePlayheadLeft(10, 7.5) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 75);
606
+ // equal GUTTER + TRACKS_LEFT_PAD + t*pps at any zoom.
607
+ expect(getTimelinePlayheadLeft(4, 20) + PLAYHEAD_HEAD_W / 2).toBe(
608
+ GUTTER + TRACKS_LEFT_PAD + 4 * 20,
609
+ );
610
+ expect(getTimelinePlayheadLeft(10, 7.5) + PLAYHEAD_HEAD_W / 2).toBe(
611
+ GUTTER + TRACKS_LEFT_PAD + 75,
612
+ );
607
613
  });
608
614
 
609
- it("centers the line exactly on the gutter (the 00:00 tick) at t = 0", () => {
610
- expect(getTimelinePlayheadLeft(0, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER);
615
+ it("centers the line exactly on the left pad's end (the 00:00 tick) at t = 0", () => {
616
+ expect(getTimelinePlayheadLeft(0, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + TRACKS_LEFT_PAD);
611
617
  });
612
618
 
613
619
  it("guards invalid input", () => {
614
- expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2);
615
- expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2);
620
+ expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(
621
+ GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2,
622
+ );
623
+ expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(
624
+ GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2,
625
+ );
616
626
  });
617
627
  });
618
628
 
@@ -681,7 +691,7 @@ describe("resolveTimelineAssetDrop", () => {
681
691
  trackHeight: 72,
682
692
  trackOrder: [0, 3, 7],
683
693
  },
684
- 432,
694
+ 480, // rectLeft(100) + GUTTER + TRACKS_LEFT_PAD + 3s*100pps
685
695
  // clientY updated for TRACKS_TOP_PAD=72: rectTop(200) + RULER_H(24) +
686
696
  // TRACKS_TOP_PAD(72) + TRACK_H(48) + TRACK_H/2(24) = 368 → row 1 → track 3.
687
697
  368,
@@ -702,7 +712,7 @@ describe("resolveTimelineAssetDrop", () => {
702
712
  trackHeight: 72,
703
713
  trackOrder: [0, 3, 7],
704
714
  },
705
- 250,
715
+ 250 + TRACKS_LEFT_PAD,
706
716
  600,
707
717
  ),
708
718
  ).toEqual({ start: 1.18, track: 8 });