@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
@@ -6,17 +6,20 @@ import {
6
6
  buildTimelineMoveTimingPatch,
7
7
  buildTimelineResizeTimingPatch,
8
8
  extendRootDurationIfNeeded,
9
- finishTimelineTimingFallback,
10
- foldGsapMutationIntoHistory,
11
9
  formatTimelineAttributeNumber,
12
10
  patchIframeDomTiming,
13
11
  persistTimelineBatchEdit,
14
- readFileContent,
15
- scaleGsapPositions,
16
- shiftGsapPositions,
17
12
  type PersistTimelineBatchChange,
18
13
  type RecordEditInput,
19
14
  } from "./timelineEditingHelpers";
15
+ import {
16
+ captureDurationRollback,
17
+ finishGroupTimingGsapFallback,
18
+ readFileContent,
19
+ scaleGsapPositions,
20
+ shiftGsapPositions,
21
+ syncPreviewContentDuration,
22
+ } from "./timelineTimingSync";
20
23
 
21
24
  export interface TimelineGroupMoveChange {
22
25
  element: TimelineElement;
@@ -34,6 +37,8 @@ export interface TimelineGroupResizeChange {
34
37
  export interface TimelineGroupCommitOptions {
35
38
  beforeTiming?: Promise<void>;
36
39
  coalesceKey?: string;
40
+ /** Per-entry undo coalesce window override (ms) — see EditHistoryEntry.coalesceMs. */
41
+ coalesceMs?: number;
37
42
  }
38
43
 
39
44
  interface UseTimelineGroupEditingOptions {
@@ -75,6 +80,15 @@ function resizeCoalesceKey(changes: readonly TimelineGroupResizeChange[]): strin
75
80
  return `timeline-group-resize:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
76
81
  }
77
82
 
83
+ function toSdkTimingChanges<T extends { element: TimelineElement }>(
84
+ changes: readonly T[],
85
+ timingUpdate: (change: T) => { start: number; duration?: number },
86
+ ): Array<{ hfId: string; timingUpdate: { start: number; duration?: number } } | null> {
87
+ return changes.map((change) =>
88
+ change.element.hfId ? { hfId: change.element.hfId, timingUpdate: timingUpdate(change) } : null,
89
+ );
90
+ }
91
+
78
92
  function resizeHasPlaybackStartAdjustment(change: TimelineGroupResizeChange): boolean {
79
93
  return (
80
94
  change.playbackStart != null ||
@@ -125,6 +139,7 @@ export function useTimelineGroupEditing({
125
139
  label: string,
126
140
  batchChanges: PersistTimelineBatchChange[],
127
141
  coalesceKey: string,
142
+ coalesceMs?: number,
128
143
  ) => {
129
144
  await persistTimelineBatchEdit({
130
145
  projectId,
@@ -136,6 +151,7 @@ export function useTimelineGroupEditing({
136
151
  domEditSaveTimestampRef,
137
152
  pendingTimelineEditPathRef,
138
153
  coalesceKey,
154
+ coalesceMs,
139
155
  });
140
156
  forceReloadSdkSession?.();
141
157
  },
@@ -149,6 +165,56 @@ export function useTimelineGroupEditing({
149
165
  ],
150
166
  );
151
167
 
168
+ // Shared SDK fast path for group move/resize: eligible when nothing needs the
169
+ // server (no root-duration growth, one shared file, every change SDK-addressable
170
+ // and `eligible` per the caller's own gate). Returns whether the SDK handled it;
171
+ // false → caller falls through to the server batch persist.
172
+ const trySdkBatchPersist = useCallback(
173
+ async (input: {
174
+ changes: readonly { element: TimelineElement }[];
175
+ sdkChanges: Array<{
176
+ hfId: string;
177
+ timingUpdate: { start: number; duration?: number };
178
+ } | null>;
179
+ eligible: boolean;
180
+ needsExtension: boolean;
181
+ label: string;
182
+ coalesceKey: string;
183
+ coalesceMs?: number;
184
+ }): Promise<boolean> => {
185
+ const sharedPath = allChangesSharePath(input.changes, activeCompPath);
186
+ const canUseSdk =
187
+ !input.needsExtension &&
188
+ sharedPath !== null &&
189
+ input.eligible &&
190
+ input.sdkChanges.every((change) => change !== null);
191
+ if (!canUseSdk) return false;
192
+ return sdkTimingBatchPersist(
193
+ input.sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
194
+ sharedPath,
195
+ sdkSession,
196
+ {
197
+ editHistory: { recordEdit },
198
+ writeProjectFile,
199
+ reloadPreview,
200
+ domEditSaveTimestampRef,
201
+ compositionPath: activeCompPath,
202
+ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
203
+ },
204
+ { label: input.label, coalesceKey: input.coalesceKey, coalesceMs: input.coalesceMs },
205
+ );
206
+ },
207
+ [
208
+ activeCompPath,
209
+ domEditSaveTimestampRef,
210
+ projectIdRef,
211
+ recordEdit,
212
+ reloadPreview,
213
+ sdkSession,
214
+ writeProjectFile,
215
+ ],
216
+ );
217
+
152
218
  const handleTimelineGroupMove = useCallback(
153
219
  (changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => {
154
220
  if (changes.length === 0) return Promise.resolve();
@@ -159,42 +225,50 @@ export function useTimelineGroupEditing({
159
225
  if (change.track != null) {
160
226
  attrs.push(["data-track-index", formatTimelineAttributeNumber(change.track)]);
161
227
  }
162
- patchIframeDomTiming(previewIframeRef.current, change.element, attrs);
228
+ patchIframeDomTiming(previewIframeRef.current, change.element, attrs, activeCompPath);
163
229
  }
164
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
+
165
245
  const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration));
246
+ // Snapshot the duration BEFORE the optimistic updates below so a failed
247
+ // persist can roll the readout + live root back (see captureDurationRollback).
248
+ const rollbackDuration = captureDurationRollback(previewIframeRef.current);
249
+ // needsExtension gates the SDK path (setTiming can't grow the root duration),
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.
166
254
  const needsExtension = extendRootDurationIfNeeded(maxEnd);
255
+ // Optimistic duration readout: content-driven (grow AND shrink), read from
256
+ // the just-patched live DOM. See syncPreviewContentDuration.
257
+ syncPreviewContentDuration(previewIframeRef.current);
167
258
  const coalesceKey = options?.coalesceKey ?? moveCoalesceKey(changes);
259
+ const coalesceMs = options?.coalesceMs;
168
260
  return enqueueGroupOperation("Move timeline clips", async (projectId) => {
169
261
  await options?.beforeTiming;
170
- const sharedPath = allChangesSharePath(changes, activeCompPath);
171
- const sdkChanges = changes.map((change) =>
172
- change.element.hfId
173
- ? { hfId: change.element.hfId, timingUpdate: { start: change.start } }
174
- : null,
175
- );
176
- const canUseSdk =
177
- !needsExtension &&
178
- sharedPath !== null &&
179
- changes.every((change) => change.track == null) &&
180
- sdkChanges.every((change) => change !== null);
181
- if (canUseSdk) {
182
- const handled = await sdkTimingBatchPersist(
183
- sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
184
- sharedPath,
185
- sdkSession,
186
- {
187
- editHistory: { recordEdit },
188
- writeProjectFile,
189
- reloadPreview,
190
- domEditSaveTimestampRef,
191
- compositionPath: activeCompPath,
192
- readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
193
- },
194
- { label: "Move timeline clips", coalesceKey },
195
- );
196
- if (handled) return;
197
- }
262
+ const handledBySdk = await trySdkBatchPersist({
263
+ changes,
264
+ sdkChanges: toSdkTimingChanges(changes, (change) => ({ start: change.start })),
265
+ eligible: changes.every((change) => change.track == null),
266
+ needsExtension,
267
+ label: "Move timeline clips",
268
+ coalesceKey,
269
+ coalesceMs,
270
+ });
271
+ if (handledBySdk) return;
198
272
 
199
273
  await persistServerBatch(
200
274
  projectId,
@@ -211,51 +285,45 @@ export function useTimelineGroupEditing({
211
285
  ),
212
286
  })),
213
287
  coalesceKey,
288
+ coalesceMs,
214
289
  );
215
- await finishTimelineTimingFallback({
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;
294
+ await finishGroupTimingGsapFallback({
295
+ projectId,
216
296
  iframe: previewIframeRef.current,
217
- needsExtension,
218
- rootDurationSeconds: maxEnd,
219
297
  reloadPreview,
220
- gsapMutation: () =>
221
- foldGsapMutationIntoHistory({
222
- projectId,
223
- paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
224
- label: "Move timeline clips",
225
- coalesceKey,
226
- recordEdit,
227
- gsapMutation: async () => {
228
- let mutated = false;
229
- for (const change of changes) {
230
- const delta = change.start - change.element.start;
231
- const domId = change.element.domId;
232
- if (delta === 0 || !domId) continue;
233
- const status = await shiftGsapPositions(
234
- projectId,
235
- targetPathFor(change.element, activeCompPath),
236
- domId,
237
- delta,
238
- );
239
- mutated = mutated || status.mutated;
240
- }
241
- return { mutated };
242
- },
243
- }),
244
- onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
298
+ label: "Move timeline clips",
299
+ errorLabel: "Failed to shift GSAP positions",
300
+ coalesceKey,
301
+ recordEdit,
302
+ activeCompPath,
303
+ changes,
304
+ resolveChangePath: (element) => targetPathFor(element, activeCompPath),
305
+ mutateChange: (change, changePath) => {
306
+ const delta = change.start - change.element.start;
307
+ const domId = change.element.domId;
308
+ if (delta === 0 || !domId) return null;
309
+ return shiftGsapPositions(projectId, changePath, domId, delta);
310
+ },
245
311
  });
312
+ }).catch((error) => {
313
+ // Failed persist: revert the optimistic duration readout + live root
314
+ // alongside the gesture owner's store rollback.
315
+ rollbackDuration();
316
+ throw error;
246
317
  });
247
318
  },
248
319
  [
249
320
  activeCompPath,
250
- domEditSaveTimestampRef,
251
321
  enqueueGroupOperation,
252
322
  persistServerBatch,
253
323
  previewIframeRef,
254
- projectIdRef,
255
324
  recordEdit,
256
325
  reloadPreview,
257
- sdkSession,
258
- writeProjectFile,
326
+ trySdkBatchPersist,
259
327
  ],
260
328
  );
261
329
 
@@ -274,45 +342,36 @@ export function useTimelineGroupEditing({
274
342
  : "data-media-start";
275
343
  liveAttrs.push([liveAttr, formatTimelineAttributeNumber(change.playbackStart)]);
276
344
  }
277
- patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs);
345
+ patchIframeDomTiming(previewIframeRef.current, change.element, liveAttrs, activeCompPath);
278
346
  }
279
347
 
280
348
  const maxEnd = Math.max(...changes.map((change) => change.start + change.duration));
349
+ // Snapshot the duration BEFORE the optimistic updates below so a failed
350
+ // persist can roll the readout + live root back (see captureDurationRollback).
351
+ const rollbackDuration = captureDurationRollback(previewIframeRef.current);
352
+ // needsExtension gates the SDK path (setTiming can't grow the root duration),
353
+ // so read the store BEFORE the readout sync below optimistically updates it.
281
354
  const needsExtension = extendRootDurationIfNeeded(maxEnd);
355
+ // Optimistic duration readout: content-driven (grow AND shrink), read from
356
+ // the just-patched live DOM. See syncPreviewContentDuration.
357
+ syncPreviewContentDuration(previewIframeRef.current);
282
358
  const coalesceKey = options?.coalesceKey ?? resizeCoalesceKey(changes);
359
+ const coalesceMs = options?.coalesceMs;
283
360
  return enqueueGroupOperation("Resize timeline clips", async (projectId) => {
284
361
  await options?.beforeTiming;
285
- const sharedPath = allChangesSharePath(changes, activeCompPath);
286
- const sdkChanges = changes.map((change) =>
287
- change.element.hfId
288
- ? {
289
- hfId: change.element.hfId,
290
- timingUpdate: { start: change.start, duration: change.duration },
291
- }
292
- : null,
293
- );
294
- const canUseSdk =
295
- !needsExtension &&
296
- sharedPath !== null &&
297
- changes.every((change) => !resizeHasPlaybackStartAdjustment(change)) &&
298
- sdkChanges.every((change) => change !== null);
299
- if (canUseSdk) {
300
- const handled = await sdkTimingBatchPersist(
301
- sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
302
- sharedPath,
303
- sdkSession,
304
- {
305
- editHistory: { recordEdit },
306
- writeProjectFile,
307
- reloadPreview,
308
- domEditSaveTimestampRef,
309
- compositionPath: activeCompPath,
310
- readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
311
- },
312
- { label: "Resize timeline clips", coalesceKey },
313
- );
314
- if (handled) return;
315
- }
362
+ const handledBySdk = await trySdkBatchPersist({
363
+ changes,
364
+ sdkChanges: toSdkTimingChanges(changes, (change) => ({
365
+ start: change.start,
366
+ duration: change.duration,
367
+ })),
368
+ eligible: changes.every((change) => !resizeHasPlaybackStartAdjustment(change)),
369
+ needsExtension,
370
+ label: "Resize timeline clips",
371
+ coalesceKey,
372
+ coalesceMs,
373
+ });
374
+ if (handledBySdk) return;
316
375
 
317
376
  await persistServerBatch(
318
377
  projectId,
@@ -327,56 +386,50 @@ export function useTimelineGroupEditing({
327
386
  }),
328
387
  })),
329
388
  coalesceKey,
389
+ coalesceMs,
330
390
  );
331
- await finishTimelineTimingFallback({
391
+ await finishGroupTimingGsapFallback({
392
+ projectId,
332
393
  iframe: previewIframeRef.current,
333
- needsExtension,
334
- rootDurationSeconds: maxEnd,
335
394
  reloadPreview,
336
- gsapMutation: () =>
337
- foldGsapMutationIntoHistory({
395
+ label: "Resize timeline clips",
396
+ errorLabel: "Failed to scale GSAP positions",
397
+ coalesceKey,
398
+ recordEdit,
399
+ activeCompPath,
400
+ changes,
401
+ resolveChangePath: (element) => targetPathFor(element, activeCompPath),
402
+ mutateChange: (change, changePath) => {
403
+ const domId = change.element.domId;
404
+ const timingChanged =
405
+ change.start !== change.element.start || change.duration !== change.element.duration;
406
+ if (!timingChanged || !domId) return null;
407
+ return scaleGsapPositions(
338
408
  projectId,
339
- paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
340
- label: "Resize timeline clips",
341
- coalesceKey,
342
- recordEdit,
343
- gsapMutation: async () => {
344
- let mutated = false;
345
- for (const change of changes) {
346
- const domId = change.element.domId;
347
- const timingChanged =
348
- change.start !== change.element.start ||
349
- change.duration !== change.element.duration;
350
- if (!timingChanged || !domId) continue;
351
- const status = await scaleGsapPositions(
352
- projectId,
353
- targetPathFor(change.element, activeCompPath),
354
- domId,
355
- change.element.start,
356
- change.element.duration,
357
- change.start,
358
- change.duration,
359
- );
360
- mutated = mutated || status.mutated;
361
- }
362
- return { mutated };
363
- },
364
- }),
365
- onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
409
+ changePath,
410
+ domId,
411
+ change.element.start,
412
+ change.element.duration,
413
+ change.start,
414
+ change.duration,
415
+ );
416
+ },
366
417
  });
418
+ }).catch((error) => {
419
+ // Failed persist: revert the optimistic duration readout + live root
420
+ // alongside the gesture owner's store rollback.
421
+ rollbackDuration();
422
+ throw error;
367
423
  });
368
424
  },
369
425
  [
370
426
  activeCompPath,
371
- domEditSaveTimestampRef,
372
427
  enqueueGroupOperation,
373
428
  persistServerBatch,
374
429
  previewIframeRef,
375
- projectIdRef,
376
430
  recordEdit,
377
431
  reloadPreview,
378
- sdkSession,
379
- writeProjectFile,
432
+ trySdkBatchPersist,
380
433
  ],
381
434
  );
382
435
 
@@ -9,10 +9,8 @@ import { Tooltip } from "../../components/ui";
9
9
  import { ShortcutsPanel } from "./ShortcutsPanel";
10
10
  import { SpeedMenu } from "./SpeedMenu";
11
11
  import { useSeekBarDrag, resolveSeekPercent } from "./useSeekBarDrag";
12
- import { useState } from "react";
13
12
 
14
13
  export { resolveSeekPercent };
15
- type TimeDisplayMode = "time" | "frame";
16
14
 
17
15
  /* ── Icon sub-components ─────────────────────────────────────────── */
18
16
 
@@ -369,7 +367,8 @@ export const PlayerControls = memo(function PlayerControls({
369
367
  const outPoint = usePlayerStore((s) => s.outPoint);
370
368
  const setInPoint = usePlayerStore.getState().setInPoint;
371
369
  const setOutPoint = usePlayerStore.getState().setOutPoint;
372
- const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
370
+ const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
371
+ const setTimeDisplayMode = usePlayerStore.getState().setTimeDisplayMode;
373
372
 
374
373
  const progressFillRef = useRef<HTMLDivElement>(null);
375
374
  const progressThumbRef = useRef<HTMLDivElement>(null);
@@ -428,10 +427,11 @@ export const PlayerControls = memo(function PlayerControls({
428
427
 
429
428
  return (
430
429
  <div
430
+ // No own background/border: the transport blends into the preview
431
+ // panel's surface — buttons carry their own chrome.
431
432
  className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
432
433
  aria-disabled={disabled || undefined}
433
434
  style={{
434
- borderTop: "1px solid rgba(255,255,255,0.04)",
435
435
  paddingBottom: "calc(0.5rem + env(safe-area-inset-bottom))",
436
436
  }}
437
437
  >
@@ -456,7 +456,7 @@ export const PlayerControls = memo(function PlayerControls({
456
456
  >
457
457
  <button
458
458
  type="button"
459
- onClick={() => setTimeDisplayMode((m) => (m === "time" ? "frame" : "time"))}
459
+ onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
460
460
  disabled={disabled}
461
461
  className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px] text-left transition-colors disabled:pointer-events-none hover:opacity-80"
462
462
  style={{ color: "#A1A1AA", cursor: "pointer" }}
@@ -9,6 +9,8 @@
9
9
  * no matter how far the tracks are scrolled. The head is OUTLINE-only at rest and
10
10
  * FILLED while the playhead is actively held/scrubbed (`scrubbing`).
11
11
  */
12
+ import { PLAYHEAD_HEAD_W } from "./timelineLayout";
13
+
12
14
  interface PlayheadIndicatorProps {
13
15
  /** CSS color, defaults to the HF accent variable */
14
16
  color?: string;
@@ -31,8 +33,11 @@ export function PlayheadIndicator({
31
33
  }: PlayheadIndicatorProps) {
32
34
  // Head chip dimensions — used to compute the centering offset and the
33
35
  // point where the vertical line starts (so it begins at the head's bottom
34
- // edge rather than running through the hollow diamond center).
35
- const HEAD_W = 9;
36
+ // edge rather than running through the hollow diamond center). The width is
37
+ // the shared PLAYHEAD_HEAD_W constant: getTimelinePlayheadLeft shifts the
38
+ // wrapper by -PLAYHEAD_HEAD_W/2 so the 1px line (centered at 50% of the
39
+ // wrapper) lands exactly on GUTTER + time * pps — the ruler ticks' center x.
40
+ const HEAD_W = PLAYHEAD_HEAD_W;
36
41
  const HEAD_H = 9;
37
42
  // marginTop(1) + HEAD_H = where the line should start.
38
43
  const HEAD_TOTAL_H = 1 + HEAD_H;