@hyperframes/studio 0.8.127 → 0.8.128

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 (114) hide show
  1. package/dist/assets/{hyperframes-player-uqzDaxVL.js → hyperframes-player-CKCx0CFA.js} +1 -1
  2. package/dist/assets/{index-DXSYlqmV.js → index-CF31Pq1E.js} +1 -1
  3. package/dist/assets/{index-COhbL1xq.js → index-ClLzyuwb.js} +171 -171
  4. package/dist/assets/{index-CLBNYlDY.js → index-CwS08gWW.js} +1 -1
  5. package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
  6. package/dist/chunk-7UQWVN52.js.map +1 -0
  7. package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
  8. package/dist/chunk-DRVWEPO5.js.map +1 -0
  9. package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
  10. package/dist/chunk-TDWGXBUT.js.map +1 -0
  11. package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
  12. package/dist/index.d.ts +8 -9
  13. package/dist/index.html +1 -1
  14. package/dist/index.js +1193 -869
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/package.json +8 -8
  18. package/src/components/TimelineToolbar.tsx +6 -7
  19. package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
  20. package/src/components/editor/domEditingLayers.test.ts +45 -18
  21. package/src/components/editor/domEditingLayers.ts +11 -9
  22. package/src/components/editor/domEditingTypes.ts +1 -0
  23. package/src/components/editor/keyframeRetime.ts +11 -4
  24. package/src/components/editor/probeSourceElement.ts +17 -1
  25. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
  26. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
  27. package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
  28. package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
  29. package/src/hooks/gsapDragCommit.ts +8 -5
  30. package/src/hooks/gsapDragPositionCommit.ts +90 -17
  31. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
  32. package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
  33. package/src/hooks/gsapMutationClient.ts +35 -7
  34. package/src/hooks/gsapOvershootEase.test.ts +67 -0
  35. package/src/hooks/gsapParsedTween.ts +5 -6
  36. package/src/hooks/gsapPositionDetection.ts +5 -2
  37. package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
  38. package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
  39. package/src/hooks/gsapRuntimeKeyframes.ts +19 -59
  40. package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
  41. package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
  42. package/src/hooks/gsapShared.test.ts +48 -35
  43. package/src/hooks/gsapShared.ts +30 -32
  44. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +93 -3
  45. package/src/hooks/gsapValueAtPlayhead.test.ts +13 -0
  46. package/src/hooks/gsapValueAtPlayhead.ts +3 -4
  47. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  48. package/src/hooks/timelineEditingHelpers.ts +40 -14
  49. package/src/hooks/timelineLinkEditPlan.ts +6 -4
  50. package/src/hooks/timelineMoveAdapter.ts +3 -5
  51. package/src/hooks/timelineTimingSync.test.ts +83 -125
  52. package/src/hooks/timelineTimingSync.ts +62 -42
  53. package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
  54. package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
  55. package/src/hooks/useCommitPreflightCapabilities.ts +42 -7
  56. package/src/hooks/useDomEditPreviewSync.ts +8 -2
  57. package/src/hooks/useDomSelection.test.ts +27 -0
  58. package/src/hooks/useDomSelection.ts +12 -13
  59. package/src/hooks/useDomSelectionTypes.ts +1 -0
  60. package/src/hooks/useEditHistoryActions.test.tsx +54 -3
  61. package/src/hooks/useEditHistoryActions.ts +9 -6
  62. package/src/hooks/useEnableKeyframes.test.ts +20 -15
  63. package/src/hooks/useEnableKeyframes.ts +54 -71
  64. package/src/hooks/useGestureCommit.ts +2 -1
  65. package/src/hooks/useGsapTweenCache.ts +2 -17
  66. package/src/hooks/usePersistentEditHistory.test.ts +45 -0
  67. package/src/hooks/usePersistentEditHistory.ts +13 -2
  68. package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
  69. package/src/hooks/usePreviewPersistence.ts +26 -4
  70. package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
  71. package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
  72. package/src/hooks/useTimelineDeleteOps.ts +5 -13
  73. package/src/hooks/useTimelineEditing.test.tsx +63 -4
  74. package/src/hooks/useTimelineEditing.ts +21 -295
  75. package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
  76. package/src/hooks/useTimelineGroupEditing.ts +15 -38
  77. package/src/player/components/Timeline.test.ts +12 -4
  78. package/src/player/components/TimelineClip.tsx +3 -0
  79. package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
  80. package/src/player/components/TimelineLanes.test.tsx +61 -5
  81. package/src/player/components/TimelineLanes.tsx +20 -28
  82. package/src/player/components/TimelinePropertyLanes.tsx +10 -1
  83. package/src/player/components/timelineCallbacks.ts +1 -4
  84. package/src/player/components/timelineClipDragCommit.test.ts +31 -0
  85. package/src/player/components/timelineClipDragCommit.ts +39 -28
  86. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  87. package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
  88. package/src/player/components/timelineClipGestureHandlers.ts +2 -20
  89. package/src/player/components/timelineEditing.ts +1 -3
  90. package/src/player/components/timelineGroupResizeCommit.ts +10 -9
  91. package/src/player/components/timelineLinkSelection.ts +1 -35
  92. package/src/player/components/timelineOptimisticRevision.ts +7 -4
  93. package/src/player/components/trackHeaderLaneState.ts +9 -2
  94. package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
  95. package/src/player/components/trackHeaderLaneValues.ts +54 -13
  96. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
  97. package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
  98. package/src/player/components/useTimelineClipDrag.ts +0 -3
  99. package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
  100. package/src/player/store/batchElementUpdates.test.ts +43 -0
  101. package/src/player/store/batchElementUpdates.ts +9 -0
  102. package/src/player/store/elementPatchQueue.ts +35 -0
  103. package/src/player/store/playerStore.ts +5 -6
  104. package/src/utils/domEditHelpers.ts +5 -0
  105. package/src/utils/globalTimeCompiler.test.ts +0 -4
  106. package/src/utils/globalTimeCompiler.ts +6 -4
  107. package/src/utils/gsapKeyframeEases.ts +79 -0
  108. package/src/utils/sameData.ts +9 -0
  109. package/src/utils/studioFileHistory.ts +1 -0
  110. package/src/utils/studioPendingEdits.ts +15 -1
  111. package/dist/chunk-246VB2G3.js.map +0 -1
  112. package/dist/chunk-3F47EFCT.js.map +0 -1
  113. package/dist/chunk-5EKWJZLM.js.map +0 -1
  114. /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  STUDIO_PREVIEW_FPS,
10
10
  applyPreviewVariablesToUrl,
11
11
  clampToDuration,
12
+ collectElementPatches,
12
13
  computePinnedZoomPercent,
13
14
  createTimelineResetState,
14
15
  effectiveThumbnailMode,
@@ -26,6 +27,7 @@ import {
26
27
  liveTime,
27
28
  panelsInZone,
28
29
  parseDockLayout,
30
+ patchElements,
29
31
  readStudioUiPreferences,
30
32
  secondsToFrame,
31
33
  stepFrameTime,
@@ -37,7 +39,7 @@ import {
37
39
  usePreviewVariablesStore,
38
40
  whenPreviewBooted,
39
41
  writeStudioUiPreferences
40
- } from "./chunk-246VB2G3.js";
42
+ } from "./chunk-DRVWEPO5.js";
41
43
  import {
42
44
  Button,
43
45
  ContextMenu,
@@ -102,7 +104,7 @@ import {
102
104
  resolveDomEditSelection,
103
105
  serializeDomEditTextFields,
104
106
  studioApiFetch
105
- } from "./chunk-3F47EFCT.js";
107
+ } from "./chunk-7UQWVN52.js";
106
108
  import {
107
109
  LAYER_REVEAL_PRIOR_POSITION_ATTR,
108
110
  LAYER_REVEAL_PRIOR_Z_ATTR,
@@ -162,7 +164,7 @@ import {
162
164
  splitTimelineElementKey,
163
165
  toHexColor,
164
166
  wrapTimeline
165
- } from "./chunk-5EKWJZLM.js";
167
+ } from "./chunk-TDWGXBUT.js";
166
168
  import "./chunk-PZ5AY32C.js";
167
169
 
168
170
  // src/components/EditorShell.tsx
@@ -2779,7 +2781,6 @@ function resolveTimelineResize(input, edge, clientX) {
2779
2781
  const clampedDelta = clamp2(deltaTime, minDelta, maxDelta);
2780
2782
  return applyClipStartTrimDelta(input, clampedDelta);
2781
2783
  }
2782
- var MAX_HAND_EDIT_CLIPS = 3;
2783
2784
  function resolveBlockedTimelineEditIntent(input) {
2784
2785
  if (input.capabilities.canMove) {
2785
2786
  return null;
@@ -4348,52 +4349,6 @@ var useLinkedClipPreferences = create2((set) => ({
4348
4349
  }));
4349
4350
  var isLinkedSelectionOn = () => useLinkedClipPreferences.getState().linkedSelection;
4350
4351
 
4351
- // src/player/components/timelineLinkSelection.ts
4352
- function selectClipWithLinks(key2, altKey, setSelectedElementId) {
4353
- const state = usePlayerStore.getState();
4354
- if (altKey || !isLinkedSelectionOn()) {
4355
- state.setSelection([key2], key2);
4356
- return;
4357
- }
4358
- setSelectedElementId(key2);
4359
- const { selectedElementIds, elements } = usePlayerStore.getState();
4360
- const expanded = expandToLinkedMembers(selectedElementIds, elements);
4361
- if (expanded.size > selectedElementIds.size) state.setSelection(expanded, key2);
4362
- }
4363
- function toggleClipWithLinks(key2, altKey) {
4364
- const state = usePlayerStore.getState();
4365
- const element = state.elements.find((el) => (el.key ?? el.id) === key2);
4366
- const members = element ? linkedMembersOf(element, state.elements, !altKey && isLinkedSelectionOn()) : [];
4367
- const memberKeys = members.length > 0 ? members.map((el) => el.key ?? el.id) : [key2];
4368
- const next = new Set(state.selectedElementIds);
4369
- const removing = next.has(key2);
4370
- for (const memberKey of memberKeys) {
4371
- if (removing) next.delete(memberKey);
4372
- else next.add(memberKey);
4373
- }
4374
- state.setSelection(next, removing ? state.selectedElementId : key2);
4375
- const primary = usePlayerStore.getState().selectedElementId;
4376
- return state.elements.find((el) => (el.key ?? el.id) === primary) ?? null;
4377
- }
4378
- function exceedsHandEditLimit(grabbed, altKey, edge) {
4379
- const { selectedElementIds, elements } = usePlayerStore.getState();
4380
- const grabbedKey = grabbed.key ?? grabbed.id;
4381
- const keys = linkedGestureKeys(
4382
- selectedElementIds,
4383
- grabbed,
4384
- elements,
4385
- altKey,
4386
- isLinkedSelectionOn()
4387
- );
4388
- const changed = edge ? buildTimelineGroupResizeMembers(
4389
- elements,
4390
- dropMisalignedTrimPartners(keys, grabbed, elements, edge),
4391
- grabbedKey,
4392
- edge
4393
- ) : resolveGroupMovers(elements, keys, grabbedKey);
4394
- return (changed?.length ?? 1) > MAX_HAND_EDIT_CLIPS;
4395
- }
4396
-
4397
4352
  // src/player/components/timelineReadOnly.ts
4398
4353
  import { createContext as createContext2, useContext as useContext2, useEffect as useEffect7, useEffectEvent } from "react";
4399
4354
  var TimelineReadOnlyContext = createContext2(null);
@@ -4504,20 +4459,86 @@ function timelineEaseFocusId(elementId2, target) {
4504
4459
  import { useCallback as useCallback8, useMemo as useMemo6 } from "react";
4505
4460
 
4506
4461
  // src/player/components/TimelinePropertyLanes.tsx
4507
- import { useMemo as useMemo3 } from "react";
4462
+ import {
4463
+ useMemo as useMemo3
4464
+ } from "react";
4508
4465
  import {
4509
4466
  classifyPropertyGroup
4510
4467
  } from "@hyperframes/core/gsap-parser";
4511
4468
 
4512
4469
  // src/hooks/gsapShared.ts
4513
4470
  import { isHtmlElement as isHtmlElement2 } from "@hyperframes/core/runtime/dom-realm";
4471
+ import { getObjectArrayKeyframeTiming } from "@hyperframes/parsers/gsap-parser";
4472
+
4473
+ // src/utils/gsapKeyframeEases.ts
4474
+ import gsap2 from "gsap";
4475
+ var PERCENTAGE_SEGMENT_EASE = "power1.inOut";
4476
+ var ARRAY_STEP_EASE = "none";
4477
+ var MOTION_PATH_RUN_EASE = "power1.out";
4478
+ function keyframedTweenEases(anim) {
4479
+ const data = anim.keyframes;
4480
+ const fallback = data?.format === "object-array" ? ARRAY_STEP_EASE : data?.easeEach ?? PERCENTAGE_SEGMENT_EASE;
4481
+ return {
4482
+ run: data ? data.ease ?? anim.ease ?? (data.fromMotionPath ? MOTION_PATH_RUN_EASE : void 0) : void 0,
4483
+ segment: (arriving) => arriving.ease ?? fallback
4484
+ };
4485
+ }
4486
+ function easeFunction(name) {
4487
+ if (!name || name === "none" || name === "linear") return null;
4488
+ return gsap2.parseEase(name) ?? null;
4489
+ }
4490
+ var runEaseOf = (anim) => keyframedTweenEases(anim).run;
4491
+ var SCAN_STEPS = 200;
4492
+ var BISECT_STEPS = 40;
4493
+ var timingEases = /* @__PURE__ */ new Map();
4494
+ function timingEase(runEase) {
4495
+ if (!runEase) return null;
4496
+ if (!timingEases.has(runEase)) {
4497
+ const run = easeFunction(runEase);
4498
+ const samples = Array.from(
4499
+ { length: SCAN_STEPS + 1 },
4500
+ (_, step) => run?.(step / SCAN_STEPS) ?? 0
4501
+ );
4502
+ const oneToOne = samples.every((v, i) => v >= 0 && v <= 1 && (i === 0 || v > samples[i - 1]));
4503
+ timingEases.set(runEase, run && oneToOne ? run : null);
4504
+ }
4505
+ return timingEases.get(runEase) ?? null;
4506
+ }
4507
+ function progressAtTime(runEase, timePercentage) {
4508
+ const run = timingEase(runEase);
4509
+ return run ? run(timePercentage / 100) * 100 : timePercentage;
4510
+ }
4511
+ function timeAtProgress(runEase, percentage) {
4512
+ const run = timingEase(runEase);
4513
+ if (!run || percentage <= 0 || percentage >= 100) return percentage;
4514
+ const target = percentage / 100;
4515
+ let low = 0;
4516
+ let high = 1;
4517
+ for (let step = 1; step <= SCAN_STEPS; step++) {
4518
+ if (run(step / SCAN_STEPS) >= target) {
4519
+ low = (step - 1) / SCAN_STEPS;
4520
+ high = step / SCAN_STEPS;
4521
+ break;
4522
+ }
4523
+ }
4524
+ for (let step = 0; step < BISECT_STEPS; step++) {
4525
+ const mid = (low + high) / 2;
4526
+ if (run(mid) >= target) high = mid;
4527
+ else low = mid;
4528
+ }
4529
+ return high * 100;
4530
+ }
4514
4531
 
4515
4532
  // src/utils/globalTimeCompiler.ts
4533
+ import { GSAP_DEFAULT_DURATION } from "@hyperframes/parsers/gsap-parser";
4516
4534
  function absoluteToPercentage(time, tweenStart, tweenDuration) {
4517
4535
  if (tweenDuration <= 0) return 0;
4518
4536
  const raw = (time - tweenStart) / tweenDuration * 100;
4519
4537
  return Math.max(0, Math.min(100, Math.round(raw * 1e3) / 1e3));
4520
4538
  }
4539
+ function percentageToAbsolute(pct, tweenStart, tweenDuration) {
4540
+ return tweenStart + pct / 100 * tweenDuration;
4541
+ }
4521
4542
  function isTimeWithinTween(time, tweenStart, tweenDuration) {
4522
4543
  return time >= tweenStart && time <= tweenStart + tweenDuration;
4523
4544
  }
@@ -4528,8 +4549,8 @@ function resolveTweenStart(animation) {
4528
4549
  if (!Number.isNaN(parsed)) return parsed;
4529
4550
  return null;
4530
4551
  }
4531
- function resolveTweenDuration(animation, fallback = 0.5) {
4532
- return animation.duration ?? fallback;
4552
+ function resolveTweenDuration(animation) {
4553
+ return animation.duration ?? GSAP_DEFAULT_DURATION;
4533
4554
  }
4534
4555
  function findTweenAtTime(time, animations, selector) {
4535
4556
  for (const anim of animations) {
@@ -4545,7 +4566,13 @@ function absoluteToPercentageForAnimation(time, animation) {
4545
4566
  const start = resolveTweenStart(animation);
4546
4567
  if (start === null) return null;
4547
4568
  const duration = resolveTweenDuration(animation);
4548
- return absoluteToPercentage(time, start, duration);
4569
+ return progressAtTime(runEaseOf(animation), absoluteToPercentage(time, start, duration));
4570
+ }
4571
+ function percentageToAbsoluteForAnimation(pct, animation) {
4572
+ const start = resolveTweenStart(animation);
4573
+ if (start === null) return null;
4574
+ const duration = resolveTweenDuration(animation);
4575
+ return percentageToAbsolute(timeAtProgress(runEaseOf(animation), pct), start, duration);
4549
4576
  }
4550
4577
  function matchesSelector(tweenSelector, querySelector) {
4551
4578
  return tweenSelector.split(",").some((part) => part.trim() === querySelector);
@@ -4572,6 +4599,11 @@ function isInstantHold(animation) {
4572
4599
  }
4573
4600
  var SAFE_HASH_ID = /^-?[A-Za-z_][\w-]*$/;
4574
4601
  var KEYFRAME_PCT_MATCH = 1;
4602
+ function playsNear(anim, percentage, progress) {
4603
+ const runEase = runEaseOf(anim);
4604
+ const gap = timeAtProgress(runEase, percentage) - timeAtProgress(runEase, progress);
4605
+ return Math.abs(gap) <= KEYFRAME_PCT_MATCH;
4606
+ }
4575
4607
  function idSelector(id) {
4576
4608
  return SAFE_HASH_ID.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`;
4577
4609
  }
@@ -4682,20 +4714,16 @@ function tweenTargetsElement(targetSelector, selector, element) {
4682
4714
  if (!element || !doc) return false;
4683
4715
  return matchesExactlyOne(doc, targetSelector, element);
4684
4716
  }
4685
- function resolveEditableTweenDuration(animation, selection) {
4686
- const clipDuration2 = Number.parseFloat(selection.dataAttributes?.duration ?? "");
4687
- return resolveTweenDuration(
4688
- animation,
4689
- Number.isFinite(clipDuration2) && clipDuration2 > 0 ? clipDuration2 : 0.5
4690
- );
4691
- }
4692
4717
  function computeElementPercentage(currentTime, selection, animation) {
4693
4718
  if (animation) {
4694
4719
  const start = resolveTweenStart(animation);
4695
- const duration = resolveEditableTweenDuration(animation, selection);
4720
+ const duration = resolveTweenDuration(animation);
4696
4721
  if (duration <= 0) return 0;
4697
4722
  if (start !== null) {
4698
- return absoluteToPercentage(currentTime, start, duration);
4723
+ return progressAtTime(
4724
+ runEaseOf(animation),
4725
+ absoluteToPercentage(currentTime, start, duration)
4726
+ );
4699
4727
  }
4700
4728
  }
4701
4729
  const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
@@ -4732,11 +4760,16 @@ function parsePercentageKeyframes(kfObj) {
4732
4760
  let easeEach;
4733
4761
  if (Array.isArray(kfObj)) {
4734
4762
  const steps = kfObj;
4763
+ const timing = getObjectArrayKeyframeTiming(
4764
+ steps.map((entry2) => entry2?.duration)
4765
+ );
4766
+ if (!timing) return null;
4735
4767
  steps.forEach((entry2, i) => {
4736
4768
  if (!entry2 || typeof entry2 !== "object") return;
4737
- const percentage = steps.length > 1 ? Math.round(i / (steps.length - 1) * 1e3) / 10 : 0;
4738
4769
  const properties = collectAnimatableKeyframeProperties(entry2);
4739
- if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties, step: i });
4770
+ if (Object.keys(properties).length > 0) {
4771
+ keyframes.push({ percentage: timing.percentages[i], properties, step: i });
4772
+ }
4740
4773
  });
4741
4774
  return keyframes.length > 0 ? { keyframes } : null;
4742
4775
  }
@@ -4783,11 +4816,15 @@ function toClipPercentage(absoluteTime, clipStart2, clipDuration2, fallbackPerce
4783
4816
  }
4784
4817
  function toClipKeyframes(source, anim, clipStart2, clipDuration2) {
4785
4818
  const tweenStart = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
4786
- const tweenDuration = anim.duration ?? clipDuration2;
4819
+ const tweenDuration = resolveTweenDuration(anim);
4787
4820
  return source.map((keyframe) => ({
4788
4821
  ...keyframe,
4789
4822
  percentage: toClipPercentage(
4790
- toAbsoluteTime(tweenStart, tweenDuration, keyframe.percentage),
4823
+ toAbsoluteTime(
4824
+ tweenStart,
4825
+ tweenDuration,
4826
+ timeAtProgress(runEaseOf(anim), keyframe.percentage)
4827
+ ),
4791
4828
  clipStart2,
4792
4829
  clipDuration2,
4793
4830
  keyframe.percentage
@@ -7109,10 +7146,22 @@ function subscribeTimelineKeyframeRetimePreview(source, listener2) {
7109
7146
  listener2(coordinator.preview);
7110
7147
  return () => coordinator.previewListeners.delete(listener2);
7111
7148
  }
7149
+ function retimedRunEase(actor) {
7150
+ const id = actor.target.animationId;
7151
+ if (id === void 0) return void 0;
7152
+ const { gsapAnimations } = usePlayerStore.getState();
7153
+ const animations = gsapAnimations.get(actor.elementId) ?? [...gsapAnimations.values()].flat();
7154
+ const animation = animations.find((candidate) => candidate.id === id);
7155
+ return animation ? runEaseOf(animation) : void 0;
7156
+ }
7112
7157
  function resolveRetimeTweenPercentage(actor, toClipPercentage2) {
7113
7158
  const animationKeyframes2 = actor.target.animationId === void 0 ? actor.keyframes : actor.keyframes.filter((keyframe) => keyframe.animationId === actor.target.animationId);
7114
7159
  const tweenPercentages = animationKeyframes2.map((keyframe) => keyframe.tweenPercentage).filter((value) => typeof value === "number");
7115
- const mapped = clipToTweenPercentage(animationKeyframes2, toClipPercentage2);
7160
+ const runEase = retimedRunEase(actor);
7161
+ const timed = animationKeyframes2.map(
7162
+ (keyframe) => keyframe.tweenPercentage === void 0 ? keyframe : { ...keyframe, tweenPercentage: timeAtProgress(runEase, keyframe.tweenPercentage) }
7163
+ );
7164
+ const mapped = progressAtTime(runEase, clipToTweenPercentage(timed, toClipPercentage2));
7116
7165
  if (tweenPercentages.length === 0) return mapped;
7117
7166
  return Math.max(Math.min(...tweenPercentages), Math.min(Math.max(...tweenPercentages), mapped));
7118
7167
  }
@@ -7819,6 +7868,7 @@ function getTimelinePropertyLanes(animations, clipStart2, clipDuration2) {
7819
7868
  return Array.from(sourceGroups(animations), ([group, groupAnimations]) => ({
7820
7869
  group,
7821
7870
  animations: groupAnimations,
7871
+ elementAnimations: animations,
7822
7872
  keyframes: groupKeyframes(groupAnimations, group, clipStart2, clipDuration2)
7823
7873
  })).filter((lane) => lane.keyframes.length > 0);
7824
7874
  }
@@ -7829,6 +7879,7 @@ function TimelinePropertyLanes({
7829
7879
  clipDuration: clipDuration2,
7830
7880
  clipLeftPx,
7831
7881
  clipWidthPx,
7882
+ passengerStyle,
7832
7883
  accentColor,
7833
7884
  isSelected: isSelected2,
7834
7885
  currentPercentage,
@@ -7868,7 +7919,8 @@ function TimelinePropertyLanes({
7868
7919
  left: clipLeftPx,
7869
7920
  top: getTimelineLaneTop(laneIndex),
7870
7921
  width: clipWidthPx,
7871
- height: LANE_H
7922
+ height: LANE_H,
7923
+ ...passengerStyle
7872
7924
  },
7873
7925
  children: /* @__PURE__ */ jsx16(
7874
7926
  TimelineDiamondLane,
@@ -9145,6 +9197,13 @@ function computeStackingPatches(elements, editedKeys) {
9145
9197
  return patches;
9146
9198
  }
9147
9199
 
9200
+ // src/player/store/batchElementUpdates.ts
9201
+ function batchElementUpdates(run) {
9202
+ const patches = collectElementPatches(run);
9203
+ if (!patches || patches.size === 0) return;
9204
+ usePlayerStore.setState((state) => ({ elements: patchElements(state.elements, patches) }));
9205
+ }
9206
+
9148
9207
  // src/player/components/timelineOptimisticRevision.ts
9149
9208
  var revisionsByUpdater = /* @__PURE__ */ new WeakMap();
9150
9209
  function beginTimelineOptimisticGesture(updateElement, keys, scope = "timing") {
@@ -9170,11 +9229,13 @@ function isLatestTimelineOptimisticGesture(updateElement, gesture, key2, scope =
9170
9229
  return gesture.has(key2) && revisionsByUpdater.get(updateElement)?.get(scope)?.get(key2) === gesture.get(key2);
9171
9230
  }
9172
9231
  function rollbackLatestTimelineOptimisticGesture(updateElement, gesture, rollbacks) {
9173
- for (const rollback of rollbacks) {
9174
- if (isLatestTimelineOptimisticGesture(updateElement, gesture, rollback.key)) {
9175
- updateElement(rollback.key, rollback.updates);
9232
+ batchElementUpdates(() => {
9233
+ for (const rollback of rollbacks) {
9234
+ if (isLatestTimelineOptimisticGesture(updateElement, gesture, rollback.key)) {
9235
+ updateElement(rollback.key, rollback.updates);
9236
+ }
9176
9237
  }
9177
- }
9238
+ });
9178
9239
  }
9179
9240
 
9180
9241
  // src/components/nle/zLaneGesture.ts
@@ -9225,6 +9286,400 @@ function refreshAfterDurableLaneMove(pending2, deps) {
9225
9286
  });
9226
9287
  }
9227
9288
 
9289
+ // src/utils/studioSaveDiagnostics.ts
9290
+ var STUDIO_SAVE_ATTEMPT_PROPERTY = "__studioSaveAttempt";
9291
+ var StudioSaveHttpError = class extends Error {
9292
+ statusCode;
9293
+ alreadyToasted;
9294
+ constructor(message, statusCode, options = {}) {
9295
+ super(message);
9296
+ this.name = "StudioSaveHttpError";
9297
+ this.statusCode = statusCode;
9298
+ this.alreadyToasted = options.alreadyToasted ?? false;
9299
+ }
9300
+ };
9301
+ var StudioSaveNetworkError = class extends Error {
9302
+ constructor(message, options) {
9303
+ super(message, options);
9304
+ this.name = "StudioSaveNetworkError";
9305
+ }
9306
+ };
9307
+ var StudioFileConflictError = class extends StudioSaveHttpError {
9308
+ filePath;
9309
+ currentVersion;
9310
+ currentContent;
9311
+ attemptedContent;
9312
+ constructor(input) {
9313
+ super(`Save conflict: ${input.filePath} changed outside this Studio session`, 409);
9314
+ this.name = "StudioFileConflictError";
9315
+ this.filePath = input.filePath;
9316
+ this.currentVersion = input.currentVersion;
9317
+ this.currentContent = input.currentContent;
9318
+ this.attemptedContent = input.attemptedContent;
9319
+ }
9320
+ };
9321
+ function readNumericProperty(value, key2) {
9322
+ const record = value;
9323
+ const property = record[key2];
9324
+ return typeof property === "number" && Number.isFinite(property) ? property : void 0;
9325
+ }
9326
+ function createStudioSaveAbortError() {
9327
+ if (typeof DOMException !== "undefined") return new DOMException("Save aborted", "AbortError");
9328
+ const error = new Error("Save aborted");
9329
+ error.name = "AbortError";
9330
+ return error;
9331
+ }
9332
+ function throwIfStudioSaveAborted(signal) {
9333
+ if (signal?.aborted) throw createStudioSaveAbortError();
9334
+ }
9335
+ function attachStudioSaveAttempt(error, attempt) {
9336
+ if (!error || typeof error !== "object") return error;
9337
+ try {
9338
+ Object.defineProperty(error, STUDIO_SAVE_ATTEMPT_PROPERTY, {
9339
+ value: attempt,
9340
+ configurable: true
9341
+ });
9342
+ } catch {
9343
+ }
9344
+ return error;
9345
+ }
9346
+ function getStudioSaveErrorMessage(error) {
9347
+ if (error instanceof Error && error.message) return error.message;
9348
+ if (typeof error === "string" && error.trim()) return error;
9349
+ return "Unknown save failure";
9350
+ }
9351
+ function markStudioSaveErrorAlreadyToasted(error) {
9352
+ if (!error || typeof error !== "object") return error;
9353
+ try {
9354
+ Object.defineProperty(error, "alreadyToasted", { value: true, configurable: true });
9355
+ } catch {
9356
+ }
9357
+ return error;
9358
+ }
9359
+ function isStudioSaveErrorAlreadyToasted(error) {
9360
+ if (!error || typeof error !== "object") return false;
9361
+ if (error.alreadyToasted === true) return true;
9362
+ const cause = error.cause;
9363
+ return cause !== error && isStudioSaveErrorAlreadyToasted(cause);
9364
+ }
9365
+ function getStudioSaveStatusCode(error) {
9366
+ if (!error || typeof error !== "object") return void 0;
9367
+ const direct = readNumericProperty(error, "statusCode") ?? readNumericProperty(error, "status") ?? readNumericProperty(error, "status_code");
9368
+ if (direct != null) return direct;
9369
+ const cause = error.cause;
9370
+ if (cause && cause !== error) return getStudioSaveStatusCode(cause);
9371
+ return void 0;
9372
+ }
9373
+ function getStudioSaveAttempt(error) {
9374
+ if (!error || typeof error !== "object") return void 0;
9375
+ const direct = readNumericProperty(error, STUDIO_SAVE_ATTEMPT_PROPERTY);
9376
+ if (direct != null) return direct;
9377
+ const cause = error.cause;
9378
+ if (cause && cause !== error) return getStudioSaveAttempt(cause);
9379
+ return void 0;
9380
+ }
9381
+ function isStudioSaveAbortError(error) {
9382
+ return error instanceof Error && error.name === "AbortError";
9383
+ }
9384
+ function isRetryableStudioSaveError(error) {
9385
+ if (isStudioSaveAbortError(error)) return false;
9386
+ if (error instanceof StudioSaveNetworkError) return true;
9387
+ const statusCode = getStudioSaveStatusCode(error);
9388
+ if (statusCode == null) return false;
9389
+ return statusCode === 408 || statusCode === 425 || statusCode === 429 || statusCode >= 500;
9390
+ }
9391
+ function buildStudioSaveFailureProperties(input) {
9392
+ const statusCode = input.statusCode ?? getStudioSaveStatusCode(input.error) ?? null;
9393
+ const attempt = input.attempt ?? getStudioSaveAttempt(input.error) ?? void 0;
9394
+ return {
9395
+ source: input.source,
9396
+ error_message: getStudioSaveErrorMessage(input.error),
9397
+ status_code: statusCode,
9398
+ file_path: input.filePath ?? input.targetSourceFile ?? void 0,
9399
+ mutation_type: input.mutationType ?? void 0,
9400
+ attempt,
9401
+ label: input.label ?? void 0,
9402
+ target_id: input.targetId ?? void 0,
9403
+ target_selector: input.targetSelector ?? void 0,
9404
+ target_source_file: input.targetSourceFile ?? void 0,
9405
+ block_detail: gsapBlockDetail(input.error)
9406
+ };
9407
+ }
9408
+ function gsapBlockDetail(error) {
9409
+ if (!(error instanceof Error) || error.name !== "GsapEditBlockedError") return void 0;
9410
+ const detail = error.detail;
9411
+ return typeof detail === "string" ? detail : void 0;
9412
+ }
9413
+ function trackStudioSaveFailure(input) {
9414
+ trackStudioEvent("save_failure", buildStudioSaveFailureProperties(input));
9415
+ }
9416
+ function trackStudioEditBlocked(input) {
9417
+ trackStudioEvent("edit_blocked", buildStudioSaveFailureProperties(input));
9418
+ }
9419
+ async function createStudioSaveHttpError(response, fallbackMessage, options = {}) {
9420
+ let body = "";
9421
+ try {
9422
+ body = await response.text();
9423
+ } catch {
9424
+ body = "";
9425
+ }
9426
+ const detail = body.trim().slice(0, 300);
9427
+ const message = detail ? `${fallbackMessage} (${response.status}): ${detail}` : `${fallbackMessage} (${response.status})`;
9428
+ return new StudioSaveHttpError(message, response.status, options);
9429
+ }
9430
+ async function retryStudioSave(operation, options = {}) {
9431
+ const retries = options.retries ?? 3;
9432
+ const baseDelayMs = options.baseDelayMs ?? 500;
9433
+ const maxDelayMs = options.maxDelayMs ?? 8e3;
9434
+ const jitterRatio = options.jitterRatio ?? 0.25;
9435
+ const random = options.random ?? Math.random;
9436
+ const shouldRetry = options.shouldRetry ?? isRetryableStudioSaveError;
9437
+ const sleep = options.sleep ?? ((delayMs, signal) => new Promise((resolve, reject) => {
9438
+ throwIfStudioSaveAborted(signal);
9439
+ const onAbort = () => {
9440
+ globalThis.clearTimeout(timeout);
9441
+ reject(createStudioSaveAbortError());
9442
+ };
9443
+ const timeout = globalThis.setTimeout(() => {
9444
+ signal?.removeEventListener("abort", onAbort);
9445
+ resolve();
9446
+ }, delayMs);
9447
+ signal?.addEventListener("abort", onAbort, { once: true });
9448
+ }));
9449
+ const maxAttempts = retries + 1;
9450
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
9451
+ try {
9452
+ throwIfStudioSaveAborted(options.signal);
9453
+ return await operation(attempt);
9454
+ } catch (error) {
9455
+ const failure = attachStudioSaveAttempt(error, attempt);
9456
+ if (attempt >= maxAttempts || !shouldRetry(failure, attempt)) throw failure;
9457
+ const retryIndex = attempt - 1;
9458
+ const exponentialDelay = Math.min(baseDelayMs * 2 ** retryIndex, maxDelayMs);
9459
+ const jitterSpan = exponentialDelay * jitterRatio;
9460
+ const jitteredDelay = Math.round(exponentialDelay + (random() * 2 - 1) * jitterSpan);
9461
+ const delayMs = Math.max(0, Math.min(maxDelayMs, jitteredDelay));
9462
+ await sleep(delayMs, options.signal);
9463
+ }
9464
+ }
9465
+ throw new Error("Save retry loop exited unexpectedly");
9466
+ }
9467
+
9468
+ // src/utils/typingTarget.ts
9469
+ function isTypingTarget(target) {
9470
+ const element = asElement(target);
9471
+ if (!element) return false;
9472
+ if (element.isContentEditable) return true;
9473
+ return element.closest(TYPING_SELECTOR) !== null;
9474
+ }
9475
+ function ownsPlainKeys(target) {
9476
+ return isTypingTarget(target) || asElement(target)?.closest(KEY_OWNING_CONTROLS) != null;
9477
+ }
9478
+ var KEY_OWNING_CONTROLS = "[role='slider'],video[controls],audio[controls]";
9479
+ var TYPING_SELECTOR = [
9480
+ "input",
9481
+ "textarea",
9482
+ "select",
9483
+ "[contenteditable]:not([contenteditable='false'])",
9484
+ "[role='textbox']",
9485
+ "[role='searchbox']",
9486
+ "[role='combobox']",
9487
+ // A switch is a button in the DOM, but the native checkbox it replaces was an
9488
+ // `input` and matched above. Without this row the shared Toggle would let a
9489
+ // global shortcut claim Space instead of flipping the control (KTD13).
9490
+ "[role='switch']",
9491
+ "[role='dialog'][aria-modal='true']",
9492
+ ".cm-editor"
9493
+ ].join(",");
9494
+ function asElement(target) {
9495
+ if (!target || typeof target !== "object") return null;
9496
+ const candidate = target;
9497
+ return typeof candidate.closest === "function" ? target : null;
9498
+ }
9499
+
9500
+ // src/utils/studioPendingEdits.ts
9501
+ var STUDIO_FLUSH_PENDING_EDITS_EVENT = "hf-studio-flush-pending-edits";
9502
+ var pendingEdits = /* @__PURE__ */ new Map();
9503
+ var historyClaims = () => 0;
9504
+ function setStudioPendingEditClaimClock(read2) {
9505
+ historyClaims = read2 ?? (() => 0);
9506
+ }
9507
+ var NOT_SAVED = () => Promise.resolve(false);
9508
+ var adopting = null;
9509
+ function adoptingStudioPendingEdit() {
9510
+ return adopting;
9511
+ }
9512
+ function waitForPostBlurEffects() {
9513
+ return new Promise((resolve) => setTimeout(resolve, 0));
9514
+ }
9515
+ function inspectDrainFailures(results) {
9516
+ let firstFailure;
9517
+ for (const result of results) {
9518
+ if (result.status !== "rejected") continue;
9519
+ if (result.reason instanceof StudioFileConflictError) return { conflict: result.reason };
9520
+ firstFailure ??= result;
9521
+ }
9522
+ return { firstFailure };
9523
+ }
9524
+ function focusedField() {
9525
+ const active = document.activeElement;
9526
+ return active instanceof HTMLElement && isTypingTarget(active) ? active : null;
9527
+ }
9528
+ function hasStudioPendingEdits() {
9529
+ return pendingEdits.size > 0 || focusedField() !== null;
9530
+ }
9531
+ function isStudioEditSaving() {
9532
+ return pendingEdits.size > 0;
9533
+ }
9534
+ function afterStudioPendingEdits(run) {
9535
+ let waiting = true;
9536
+ const check = () => {
9537
+ if (!waiting) return;
9538
+ if (isStudioEditSaving()) {
9539
+ void Promise.allSettled([...pendingEdits.keys()]).then(check);
9540
+ return;
9541
+ }
9542
+ waiting = false;
9543
+ run();
9544
+ };
9545
+ check();
9546
+ return () => {
9547
+ waiting = false;
9548
+ };
9549
+ }
9550
+ function trackStudioPendingEdit(result) {
9551
+ if (!result) return void 0;
9552
+ const promise = Promise.resolve(result);
9553
+ if (adopting) return promise;
9554
+ pendingEdits.set(promise, {
9555
+ revert: null,
9556
+ landed: NOT_SAVED,
9557
+ redraws: [],
9558
+ showAgain: null,
9559
+ claimsAtBegin: historyClaims()
9560
+ });
9561
+ promise.then(
9562
+ () => pendingEdits.delete(promise),
9563
+ () => pendingEdits.delete(promise)
9564
+ );
9565
+ return promise;
9566
+ }
9567
+ function trackedStudioEdit(edit) {
9568
+ return (...args) => {
9569
+ const result = edit(...args);
9570
+ if (result instanceof Promise) trackStudioPendingEdit(result);
9571
+ return result;
9572
+ };
9573
+ }
9574
+ function beginStudioPendingEdit(revert) {
9575
+ let settle;
9576
+ const promise = trackStudioPendingEdit(new Promise((resolve) => settle = resolve));
9577
+ const entry2 = pendingEdits.get(promise);
9578
+ entry2.revert = revert;
9579
+ let landed = Promise.resolve(false);
9580
+ let saved2 = false;
9581
+ entry2.landed = () => landed;
9582
+ const inFlight = {
9583
+ reverted: () => entry2.revert === null && revert !== null,
9584
+ within(run) {
9585
+ const outer = adopting;
9586
+ adopting = inFlight;
9587
+ try {
9588
+ return run();
9589
+ } finally {
9590
+ adopting = outer;
9591
+ }
9592
+ },
9593
+ drawUnlessUndone(draw) {
9594
+ if (inFlight.reverted()) entry2.redraws.push(draw);
9595
+ else draw();
9596
+ },
9597
+ drawKeepingUndone(draw) {
9598
+ if (!inFlight.reverted()) return draw();
9599
+ entry2.showAgain?.();
9600
+ try {
9601
+ return draw();
9602
+ } finally {
9603
+ entry2.showAgain = revert();
9604
+ }
9605
+ },
9606
+ markSaved: () => void (saved2 = true)
9607
+ };
9608
+ return {
9609
+ settle,
9610
+ reverted: inFlight.reverted,
9611
+ // Only what `start` registers synchronously is adopted; a later write joins only through `within`.
9612
+ adopt(start) {
9613
+ try {
9614
+ const committed = inFlight.within(start);
9615
+ landed = Promise.resolve(committed).then(
9616
+ () => true,
9617
+ () => saved2
9618
+ );
9619
+ return committed;
9620
+ } catch (error) {
9621
+ settle();
9622
+ throw error;
9623
+ }
9624
+ }
9625
+ };
9626
+ }
9627
+ function paintBackNewestStudioPendingEdit() {
9628
+ const newest = [...pendingEdits.values()].at(-1);
9629
+ const revert = newest?.revert;
9630
+ if (!newest || !revert) return null;
9631
+ newest.revert = null;
9632
+ newest.showAgain = revert();
9633
+ return {
9634
+ showAgain: () => {
9635
+ newest.showAgain?.();
9636
+ for (const redraw of newest.redraws.splice(0)) redraw();
9637
+ },
9638
+ landed: newest.landed,
9639
+ claimsAtBegin: newest.claimsAtBegin
9640
+ };
9641
+ }
9642
+ function revertNewestStudioPendingEdit() {
9643
+ return paintBackNewestStudioPendingEdit()?.showAgain ?? null;
9644
+ }
9645
+ async function flushStudioPendingEdits({
9646
+ onlyCurrent = false
9647
+ } = {}) {
9648
+ const active = focusedField();
9649
+ if (active) {
9650
+ active.blur();
9651
+ await Promise.resolve();
9652
+ await waitForPostBlurEffects();
9653
+ }
9654
+ const detail = { promises: [] };
9655
+ window.dispatchEvent(
9656
+ new CustomEvent(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail })
9657
+ );
9658
+ const current = onlyCurrent ? new Set(pendingEdits.keys()) : null;
9659
+ const waiting = () => [...pendingEdits.keys()].filter((edit) => !current || current.has(edit));
9660
+ let conflict;
9661
+ let firstFailure;
9662
+ while (detail.promises.length > 0 || waiting().length > 0) {
9663
+ const promises = [...detail.promises, ...waiting()];
9664
+ detail.promises = [];
9665
+ const batchFailures = inspectDrainFailures(await Promise.allSettled(promises));
9666
+ conflict ??= batchFailures.conflict;
9667
+ firstFailure ??= batchFailures.firstFailure;
9668
+ }
9669
+ if (conflict) return { status: "conflict", error: conflict };
9670
+ return firstFailure ? { status: "failed", error: firstFailure.reason } : { status: "clean" };
9671
+ }
9672
+ function addStudioPendingEditFlushListener(handler) {
9673
+ const listener2 = (event) => {
9674
+ const detail = event.detail;
9675
+ if (!detail?.promises) return;
9676
+ const promise = trackStudioPendingEdit(handler());
9677
+ if (promise) detail.promises.push(promise);
9678
+ };
9679
+ window.addEventListener(STUDIO_FLUSH_PENDING_EDITS_EVENT, listener2);
9680
+ return () => window.removeEventListener(STUDIO_FLUSH_PENDING_EDITS_EVENT, listener2);
9681
+ }
9682
+
9228
9683
  // src/player/components/timelineClipDragCommit.ts
9229
9684
  var keyOf6 = (e) => e.key ?? e.id;
9230
9685
  var round3 = (v) => Math.round(v * 1e3) / 1e3;
@@ -9237,11 +9692,11 @@ function moveStoreUpdates(edit) {
9237
9692
  detach: audioGroup === null
9238
9693
  };
9239
9694
  }
9240
- function persistMoveEdits(edits, deps, coalesceKey, operation = "timing", coalesceMs) {
9695
+ function persistMoveEdits(edits, deps, coalesceKey, operation = "timing") {
9241
9696
  if (edits.length === 0) return Promise.resolve(true);
9242
9697
  edits = edits.map((e) => {
9243
- const start = clampToHostStart(e.element, e.updates.start);
9244
- return start === e.updates.start ? e : { ...e, updates: { ...e.updates, start } };
9698
+ const start2 = clampToHostStart(e.element, e.updates.start);
9699
+ return start2 === e.updates.start ? e : { ...e, updates: { ...e.updates, start: start2 } };
9245
9700
  });
9246
9701
  const { updateElement, onMoveElement, onMoveElements } = deps;
9247
9702
  if (!onMoveElements) {
@@ -9279,31 +9734,44 @@ function persistMoveEdits(edits, deps, coalesceKey, operation = "timing", coales
9279
9734
  }
9280
9735
  if (Object.keys(updates).length) updateElement(key2, updates);
9281
9736
  };
9282
- for (const e of edits) applyEdit(e);
9737
+ const applyEdits = (reassert = false) => batchElementUpdates(() => edits.forEach((e) => applyEdit(e, reassert)));
9738
+ applyEdits();
9283
9739
  const persistEdits = edits.map(
9284
9740
  (e) => e.persistTrack == null || e.persistTrack === e.updates.track ? e : { element: e.element, updates: { ...e.updates, track: e.persistTrack } }
9285
9741
  );
9286
- const persisted = onMoveElements ? onMoveElements(persistEdits, coalesceKey, operation, coalesceMs) : Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
9287
- return Promise.resolve(persisted).then(
9742
+ const restorePrev = () => batchElementUpdates(() => {
9743
+ for (const p of prev) {
9744
+ const { audioGroup, ...timing } = p.updates;
9745
+ const updates = {};
9746
+ if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
9747
+ Object.assign(updates, timing);
9748
+ }
9749
+ if (isLatestTimelineOptimisticGesture(updateElement, membershipRevision, p.key, "membership"))
9750
+ updates.audioGroup = audioGroup;
9751
+ if (Object.keys(updates).length) updateElement(p.key, updates);
9752
+ }
9753
+ });
9754
+ const saving = onMoveElements ? beginStudioPendingEdit(() => {
9755
+ restorePrev();
9756
+ return () => applyEdits(true);
9757
+ }) : null;
9758
+ const start = () => onMoveElements ? onMoveElements(persistEdits, coalesceKey, operation) : Promise.all(
9759
+ persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates)))
9760
+ );
9761
+ const persisted = saving ? saving.adopt(start) : start();
9762
+ const done = Promise.resolve(persisted).then(
9288
9763
  () => {
9289
- for (const e of edits) applyEdit(e, true);
9764
+ if (!saving?.reverted()) applyEdits(true);
9290
9765
  return true;
9291
9766
  },
9292
9767
  (error) => {
9293
- for (const p of prev) {
9294
- const { audioGroup, ...timing } = p.updates;
9295
- const updates = {};
9296
- if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
9297
- Object.assign(updates, timing);
9298
- }
9299
- if (isLatestTimelineOptimisticGesture(updateElement, membershipRevision, p.key, "membership"))
9300
- updates.audioGroup = audioGroup;
9301
- if (Object.keys(updates).length) updateElement(p.key, updates);
9302
- }
9768
+ if (!saving?.reverted()) restorePrev();
9303
9769
  console.error("[Timeline] Failed to persist clip edits", error);
9304
9770
  return false;
9305
9771
  }
9306
9772
  );
9773
+ saving?.settle(done);
9774
+ return done;
9307
9775
  }
9308
9776
  function resolveMultiSelection(drag, deps) {
9309
9777
  const dragKey = keyOf6(drag.element);
@@ -9488,7 +9956,7 @@ function commitTrackInsert(drag, deps, multi) {
9488
9956
  deps
9489
9957
  ).catch(() => void 0);
9490
9958
  }
9491
- function commitZMirrorLaneMove(element, move, deps, coalesceKey, coalesceMs) {
9959
+ function commitZMirrorLaneMove(element, move, deps, coalesceKey) {
9492
9960
  if (move.kind === "move") {
9493
9961
  const edit = {
9494
9962
  element,
@@ -9496,14 +9964,14 @@ function commitZMirrorLaneMove(element, move, deps, coalesceKey, coalesceMs) {
9496
9964
  persistTrack: move.persistTrack
9497
9965
  };
9498
9966
  return refreshAfterDurableLaneMove(
9499
- persistMoveEdits([edit], deps, coalesceKey, "lane-reorder", coalesceMs),
9967
+ persistMoveEdits([edit], deps, coalesceKey, "lane-reorder"),
9500
9968
  deps
9501
9969
  );
9502
9970
  }
9503
9971
  const built = buildTrackInsertEdits(element, element.start, move.insertRow, null, deps);
9504
9972
  if (!built || built.edits.length === 0) return Promise.resolve(false);
9505
9973
  return refreshAfterDurableLaneMove(
9506
- persistMoveEdits(built.edits, deps, coalesceKey, "track-insert", coalesceMs),
9974
+ persistMoveEdits(built.edits, deps, coalesceKey, "track-insert"),
9507
9975
  deps
9508
9976
  );
9509
9977
  }
@@ -9539,13 +10007,15 @@ function commitTimelineGroupResize(session, updateElement, persist2) {
9539
10007
  updateElement,
9540
10008
  changes.map((change) => change.key)
9541
10009
  );
9542
- for (const change of changes) {
9543
- updateElement(change.key, {
9544
- start: change.start,
9545
- duration: change.duration,
9546
- playbackStart: change.playbackStart
9547
- });
9548
- }
10010
+ batchElementUpdates(() => {
10011
+ for (const change of changes) {
10012
+ updateElement(change.key, {
10013
+ start: change.start,
10014
+ duration: change.duration,
10015
+ playbackStart: change.playbackStart
10016
+ });
10017
+ }
10018
+ });
9549
10019
  if (!persist2) {
9550
10020
  rollbackLatestTimelineOptimisticGesture(
9551
10021
  updateElement,
@@ -9554,7 +10024,6 @@ function commitTimelineGroupResize(session, updateElement, persist2) {
9554
10024
  );
9555
10025
  return;
9556
10026
  }
9557
- const coalesceKey = `clip-group-resize:${changes.map((change) => change.key).join(":")}`;
9558
10027
  Promise.resolve(
9559
10028
  persist2(
9560
10029
  changes.map((change) => ({
@@ -9562,8 +10031,7 @@ function commitTimelineGroupResize(session, updateElement, persist2) {
9562
10031
  start: change.start,
9563
10032
  duration: change.duration,
9564
10033
  playbackStart: change.playbackStart
9565
- })),
9566
- { coalesceKey }
10034
+ }))
9567
10035
  )
9568
10036
  ).catch((error) => {
9569
10037
  rollbackLatestTimelineOptimisticGesture(
@@ -9684,7 +10152,7 @@ function mountTimelineClipDragGestureLifecycle({
9684
10152
  event.clientX - blocked.originClientX,
9685
10153
  event.clientY - blocked.originClientY
9686
10154
  );
9687
- const threshold = blocked.intent === "move" || blocked.intent === "edit-many" ? 4 : 2;
10155
+ const threshold = blocked.intent === "move" ? 4 : 2;
9688
10156
  if (!blocked.started && distance < threshold) return;
9689
10157
  if (!blocked.started) {
9690
10158
  blocked.started = true;
@@ -10273,8 +10741,6 @@ function useTimelineClipDrag({
10273
10741
  if (!element || !getClipCapabilities(element).canMove) return;
10274
10742
  event.preventDefault();
10275
10743
  event.stopImmediatePropagation();
10276
- if (exceedsHandEditLimit(element, false, null))
10277
- return void onBlockedEditAttemptRef.current?.(element, "edit-many");
10278
10744
  setShowPopover(false);
10279
10745
  setRangeSelectionRef.current?.(null);
10280
10746
  const row = keyboardPickupInsertRow(
@@ -10544,6 +11010,36 @@ function useTimelineDragPreviewState(draggedClip, resizingClip, selectedElementI
10544
11010
 
10545
11011
  // src/player/components/useTimelineClipContextMenu.ts
10546
11012
  import { useCallback as useCallback12 } from "react";
11013
+
11014
+ // src/player/components/timelineLinkSelection.ts
11015
+ function selectClipWithLinks(key2, altKey, setSelectedElementId) {
11016
+ const state = usePlayerStore.getState();
11017
+ if (altKey || !isLinkedSelectionOn()) {
11018
+ state.setSelection([key2], key2);
11019
+ return;
11020
+ }
11021
+ setSelectedElementId(key2);
11022
+ const { selectedElementIds, elements } = usePlayerStore.getState();
11023
+ const expanded = expandToLinkedMembers(selectedElementIds, elements);
11024
+ if (expanded.size > selectedElementIds.size) state.setSelection(expanded, key2);
11025
+ }
11026
+ function toggleClipWithLinks(key2, altKey) {
11027
+ const state = usePlayerStore.getState();
11028
+ const element = state.elements.find((el) => (el.key ?? el.id) === key2);
11029
+ const members = element ? linkedMembersOf(element, state.elements, !altKey && isLinkedSelectionOn()) : [];
11030
+ const memberKeys = members.length > 0 ? members.map((el) => el.key ?? el.id) : [key2];
11031
+ const next = new Set(state.selectedElementIds);
11032
+ const removing = next.has(key2);
11033
+ for (const memberKey of memberKeys) {
11034
+ if (removing) next.delete(memberKey);
11035
+ else next.add(memberKey);
11036
+ }
11037
+ state.setSelection(next, removing ? state.selectedElementId : key2);
11038
+ const primary = usePlayerStore.getState().selectedElementId;
11039
+ return state.elements.find((el) => (el.key ?? el.id) === primary) ?? null;
11040
+ }
11041
+
11042
+ // src/player/components/useTimelineClipContextMenu.ts
10547
11043
  function useClipContextMenu(onSelectElement, dismissGapMenu, setClipContextMenu) {
10548
11044
  const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
10549
11045
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
@@ -11620,391 +12116,6 @@ import { useCallback as useCallback17 } from "react";
11620
12116
 
11621
12117
  // src/contexts/DomEditContext.tsx
11622
12118
  import { useCallback as useCallback16, useContext as useContext3, useMemo as useMemo12, useRef as useRef17 } from "react";
11623
-
11624
- // src/utils/studioSaveDiagnostics.ts
11625
- var STUDIO_SAVE_ATTEMPT_PROPERTY = "__studioSaveAttempt";
11626
- var StudioSaveHttpError = class extends Error {
11627
- statusCode;
11628
- alreadyToasted;
11629
- constructor(message, statusCode, options = {}) {
11630
- super(message);
11631
- this.name = "StudioSaveHttpError";
11632
- this.statusCode = statusCode;
11633
- this.alreadyToasted = options.alreadyToasted ?? false;
11634
- }
11635
- };
11636
- var StudioSaveNetworkError = class extends Error {
11637
- constructor(message, options) {
11638
- super(message, options);
11639
- this.name = "StudioSaveNetworkError";
11640
- }
11641
- };
11642
- var StudioFileConflictError = class extends StudioSaveHttpError {
11643
- filePath;
11644
- currentVersion;
11645
- currentContent;
11646
- attemptedContent;
11647
- constructor(input) {
11648
- super(`Save conflict: ${input.filePath} changed outside this Studio session`, 409);
11649
- this.name = "StudioFileConflictError";
11650
- this.filePath = input.filePath;
11651
- this.currentVersion = input.currentVersion;
11652
- this.currentContent = input.currentContent;
11653
- this.attemptedContent = input.attemptedContent;
11654
- }
11655
- };
11656
- function readNumericProperty(value, key2) {
11657
- const record = value;
11658
- const property = record[key2];
11659
- return typeof property === "number" && Number.isFinite(property) ? property : void 0;
11660
- }
11661
- function createStudioSaveAbortError() {
11662
- if (typeof DOMException !== "undefined") return new DOMException("Save aborted", "AbortError");
11663
- const error = new Error("Save aborted");
11664
- error.name = "AbortError";
11665
- return error;
11666
- }
11667
- function throwIfStudioSaveAborted(signal) {
11668
- if (signal?.aborted) throw createStudioSaveAbortError();
11669
- }
11670
- function attachStudioSaveAttempt(error, attempt) {
11671
- if (!error || typeof error !== "object") return error;
11672
- try {
11673
- Object.defineProperty(error, STUDIO_SAVE_ATTEMPT_PROPERTY, {
11674
- value: attempt,
11675
- configurable: true
11676
- });
11677
- } catch {
11678
- }
11679
- return error;
11680
- }
11681
- function getStudioSaveErrorMessage(error) {
11682
- if (error instanceof Error && error.message) return error.message;
11683
- if (typeof error === "string" && error.trim()) return error;
11684
- return "Unknown save failure";
11685
- }
11686
- function markStudioSaveErrorAlreadyToasted(error) {
11687
- if (!error || typeof error !== "object") return error;
11688
- try {
11689
- Object.defineProperty(error, "alreadyToasted", { value: true, configurable: true });
11690
- } catch {
11691
- }
11692
- return error;
11693
- }
11694
- function isStudioSaveErrorAlreadyToasted(error) {
11695
- if (!error || typeof error !== "object") return false;
11696
- if (error.alreadyToasted === true) return true;
11697
- const cause = error.cause;
11698
- return cause !== error && isStudioSaveErrorAlreadyToasted(cause);
11699
- }
11700
- function getStudioSaveStatusCode(error) {
11701
- if (!error || typeof error !== "object") return void 0;
11702
- const direct = readNumericProperty(error, "statusCode") ?? readNumericProperty(error, "status") ?? readNumericProperty(error, "status_code");
11703
- if (direct != null) return direct;
11704
- const cause = error.cause;
11705
- if (cause && cause !== error) return getStudioSaveStatusCode(cause);
11706
- return void 0;
11707
- }
11708
- function getStudioSaveAttempt(error) {
11709
- if (!error || typeof error !== "object") return void 0;
11710
- const direct = readNumericProperty(error, STUDIO_SAVE_ATTEMPT_PROPERTY);
11711
- if (direct != null) return direct;
11712
- const cause = error.cause;
11713
- if (cause && cause !== error) return getStudioSaveAttempt(cause);
11714
- return void 0;
11715
- }
11716
- function isStudioSaveAbortError(error) {
11717
- return error instanceof Error && error.name === "AbortError";
11718
- }
11719
- function isRetryableStudioSaveError(error) {
11720
- if (isStudioSaveAbortError(error)) return false;
11721
- if (error instanceof StudioSaveNetworkError) return true;
11722
- const statusCode = getStudioSaveStatusCode(error);
11723
- if (statusCode == null) return false;
11724
- return statusCode === 408 || statusCode === 425 || statusCode === 429 || statusCode >= 500;
11725
- }
11726
- function buildStudioSaveFailureProperties(input) {
11727
- const statusCode = input.statusCode ?? getStudioSaveStatusCode(input.error) ?? null;
11728
- const attempt = input.attempt ?? getStudioSaveAttempt(input.error) ?? void 0;
11729
- return {
11730
- source: input.source,
11731
- error_message: getStudioSaveErrorMessage(input.error),
11732
- status_code: statusCode,
11733
- file_path: input.filePath ?? input.targetSourceFile ?? void 0,
11734
- mutation_type: input.mutationType ?? void 0,
11735
- attempt,
11736
- label: input.label ?? void 0,
11737
- target_id: input.targetId ?? void 0,
11738
- target_selector: input.targetSelector ?? void 0,
11739
- target_source_file: input.targetSourceFile ?? void 0,
11740
- block_detail: gsapBlockDetail(input.error)
11741
- };
11742
- }
11743
- function gsapBlockDetail(error) {
11744
- if (!(error instanceof Error) || error.name !== "GsapEditBlockedError") return void 0;
11745
- const detail = error.detail;
11746
- return typeof detail === "string" ? detail : void 0;
11747
- }
11748
- function trackStudioSaveFailure(input) {
11749
- trackStudioEvent("save_failure", buildStudioSaveFailureProperties(input));
11750
- }
11751
- function trackStudioEditBlocked(input) {
11752
- trackStudioEvent("edit_blocked", buildStudioSaveFailureProperties(input));
11753
- }
11754
- async function createStudioSaveHttpError(response, fallbackMessage, options = {}) {
11755
- let body = "";
11756
- try {
11757
- body = await response.text();
11758
- } catch {
11759
- body = "";
11760
- }
11761
- const detail = body.trim().slice(0, 300);
11762
- const message = detail ? `${fallbackMessage} (${response.status}): ${detail}` : `${fallbackMessage} (${response.status})`;
11763
- return new StudioSaveHttpError(message, response.status, options);
11764
- }
11765
- async function retryStudioSave(operation, options = {}) {
11766
- const retries = options.retries ?? 3;
11767
- const baseDelayMs = options.baseDelayMs ?? 500;
11768
- const maxDelayMs = options.maxDelayMs ?? 8e3;
11769
- const jitterRatio = options.jitterRatio ?? 0.25;
11770
- const random = options.random ?? Math.random;
11771
- const shouldRetry = options.shouldRetry ?? isRetryableStudioSaveError;
11772
- const sleep = options.sleep ?? ((delayMs, signal) => new Promise((resolve, reject) => {
11773
- throwIfStudioSaveAborted(signal);
11774
- const onAbort = () => {
11775
- globalThis.clearTimeout(timeout);
11776
- reject(createStudioSaveAbortError());
11777
- };
11778
- const timeout = globalThis.setTimeout(() => {
11779
- signal?.removeEventListener("abort", onAbort);
11780
- resolve();
11781
- }, delayMs);
11782
- signal?.addEventListener("abort", onAbort, { once: true });
11783
- }));
11784
- const maxAttempts = retries + 1;
11785
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
11786
- try {
11787
- throwIfStudioSaveAborted(options.signal);
11788
- return await operation(attempt);
11789
- } catch (error) {
11790
- const failure = attachStudioSaveAttempt(error, attempt);
11791
- if (attempt >= maxAttempts || !shouldRetry(failure, attempt)) throw failure;
11792
- const retryIndex = attempt - 1;
11793
- const exponentialDelay = Math.min(baseDelayMs * 2 ** retryIndex, maxDelayMs);
11794
- const jitterSpan = exponentialDelay * jitterRatio;
11795
- const jitteredDelay = Math.round(exponentialDelay + (random() * 2 - 1) * jitterSpan);
11796
- const delayMs = Math.max(0, Math.min(maxDelayMs, jitteredDelay));
11797
- await sleep(delayMs, options.signal);
11798
- }
11799
- }
11800
- throw new Error("Save retry loop exited unexpectedly");
11801
- }
11802
-
11803
- // src/utils/typingTarget.ts
11804
- function isTypingTarget(target) {
11805
- const element = asElement(target);
11806
- if (!element) return false;
11807
- if (element.isContentEditable) return true;
11808
- return element.closest(TYPING_SELECTOR) !== null;
11809
- }
11810
- function ownsPlainKeys(target) {
11811
- return isTypingTarget(target) || asElement(target)?.closest(KEY_OWNING_CONTROLS) != null;
11812
- }
11813
- var KEY_OWNING_CONTROLS = "[role='slider'],video[controls],audio[controls]";
11814
- var TYPING_SELECTOR = [
11815
- "input",
11816
- "textarea",
11817
- "select",
11818
- "[contenteditable]:not([contenteditable='false'])",
11819
- "[role='textbox']",
11820
- "[role='searchbox']",
11821
- "[role='combobox']",
11822
- // A switch is a button in the DOM, but the native checkbox it replaces was an
11823
- // `input` and matched above. Without this row the shared Toggle would let a
11824
- // global shortcut claim Space instead of flipping the control (KTD13).
11825
- "[role='switch']",
11826
- "[role='dialog'][aria-modal='true']",
11827
- ".cm-editor"
11828
- ].join(",");
11829
- function asElement(target) {
11830
- if (!target || typeof target !== "object") return null;
11831
- const candidate = target;
11832
- return typeof candidate.closest === "function" ? target : null;
11833
- }
11834
-
11835
- // src/utils/studioPendingEdits.ts
11836
- var STUDIO_FLUSH_PENDING_EDITS_EVENT = "hf-studio-flush-pending-edits";
11837
- var pendingEdits = /* @__PURE__ */ new Map();
11838
- var NOT_SAVED = () => Promise.resolve(false);
11839
- var adopting = null;
11840
- function adoptingStudioPendingEdit() {
11841
- return adopting;
11842
- }
11843
- function waitForPostBlurEffects() {
11844
- return new Promise((resolve) => setTimeout(resolve, 0));
11845
- }
11846
- function inspectDrainFailures(results) {
11847
- let firstFailure;
11848
- for (const result of results) {
11849
- if (result.status !== "rejected") continue;
11850
- if (result.reason instanceof StudioFileConflictError) return { conflict: result.reason };
11851
- firstFailure ??= result;
11852
- }
11853
- return { firstFailure };
11854
- }
11855
- function focusedField() {
11856
- const active = document.activeElement;
11857
- return active instanceof HTMLElement && isTypingTarget(active) ? active : null;
11858
- }
11859
- function hasStudioPendingEdits() {
11860
- return pendingEdits.size > 0 || focusedField() !== null;
11861
- }
11862
- function isStudioEditSaving() {
11863
- return pendingEdits.size > 0;
11864
- }
11865
- function afterStudioPendingEdits(run) {
11866
- let waiting = true;
11867
- const check = () => {
11868
- if (!waiting) return;
11869
- if (isStudioEditSaving()) {
11870
- void Promise.allSettled([...pendingEdits.keys()]).then(check);
11871
- return;
11872
- }
11873
- waiting = false;
11874
- run();
11875
- };
11876
- check();
11877
- return () => {
11878
- waiting = false;
11879
- };
11880
- }
11881
- function trackStudioPendingEdit(result) {
11882
- if (!result) return void 0;
11883
- const promise = Promise.resolve(result);
11884
- if (adopting) return promise;
11885
- pendingEdits.set(promise, { revert: null, landed: NOT_SAVED, redraws: [], showAgain: null });
11886
- promise.then(
11887
- () => pendingEdits.delete(promise),
11888
- () => pendingEdits.delete(promise)
11889
- );
11890
- return promise;
11891
- }
11892
- function trackedStudioEdit(edit) {
11893
- return (...args) => {
11894
- const result = edit(...args);
11895
- if (result instanceof Promise) trackStudioPendingEdit(result);
11896
- return result;
11897
- };
11898
- }
11899
- function beginStudioPendingEdit(revert) {
11900
- let settle;
11901
- const promise = trackStudioPendingEdit(new Promise((resolve) => settle = resolve));
11902
- const entry2 = pendingEdits.get(promise);
11903
- entry2.revert = revert;
11904
- let landed = Promise.resolve(false);
11905
- let saved2 = false;
11906
- entry2.landed = () => landed;
11907
- const inFlight = {
11908
- reverted: () => entry2.revert === null && revert !== null,
11909
- within(run) {
11910
- const outer = adopting;
11911
- adopting = inFlight;
11912
- try {
11913
- return run();
11914
- } finally {
11915
- adopting = outer;
11916
- }
11917
- },
11918
- drawUnlessUndone(draw) {
11919
- if (inFlight.reverted()) entry2.redraws.push(draw);
11920
- else draw();
11921
- },
11922
- drawKeepingUndone(draw) {
11923
- if (!inFlight.reverted()) return draw();
11924
- entry2.showAgain?.();
11925
- try {
11926
- return draw();
11927
- } finally {
11928
- entry2.showAgain = revert();
11929
- }
11930
- },
11931
- markSaved: () => void (saved2 = true)
11932
- };
11933
- return {
11934
- settle,
11935
- reverted: inFlight.reverted,
11936
- // Only what `start` registers synchronously is adopted; a later write joins only through `within`.
11937
- adopt(start) {
11938
- try {
11939
- const committed = inFlight.within(start);
11940
- landed = Promise.resolve(committed).then(
11941
- () => true,
11942
- () => saved2
11943
- );
11944
- return committed;
11945
- } catch (error) {
11946
- settle();
11947
- throw error;
11948
- }
11949
- }
11950
- };
11951
- }
11952
- function paintBackNewestStudioPendingEdit() {
11953
- const newest = [...pendingEdits.values()].at(-1);
11954
- const revert = newest?.revert;
11955
- if (!newest || !revert) return null;
11956
- newest.revert = null;
11957
- newest.showAgain = revert();
11958
- return {
11959
- showAgain: () => {
11960
- newest.showAgain?.();
11961
- for (const redraw of newest.redraws.splice(0)) redraw();
11962
- },
11963
- landed: newest.landed
11964
- };
11965
- }
11966
- function revertNewestStudioPendingEdit() {
11967
- return paintBackNewestStudioPendingEdit()?.showAgain ?? null;
11968
- }
11969
- async function flushStudioPendingEdits({
11970
- onlyCurrent = false
11971
- } = {}) {
11972
- const active = focusedField();
11973
- if (active) {
11974
- active.blur();
11975
- await Promise.resolve();
11976
- await waitForPostBlurEffects();
11977
- }
11978
- const detail = { promises: [] };
11979
- window.dispatchEvent(
11980
- new CustomEvent(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail })
11981
- );
11982
- const current = onlyCurrent ? new Set(pendingEdits.keys()) : null;
11983
- const waiting = () => [...pendingEdits.keys()].filter((edit) => !current || current.has(edit));
11984
- let conflict;
11985
- let firstFailure;
11986
- while (detail.promises.length > 0 || waiting().length > 0) {
11987
- const promises = [...detail.promises, ...waiting()];
11988
- detail.promises = [];
11989
- const batchFailures = inspectDrainFailures(await Promise.allSettled(promises));
11990
- conflict ??= batchFailures.conflict;
11991
- firstFailure ??= batchFailures.firstFailure;
11992
- }
11993
- if (conflict) return { status: "conflict", error: conflict };
11994
- return firstFailure ? { status: "failed", error: firstFailure.reason } : { status: "clean" };
11995
- }
11996
- function addStudioPendingEditFlushListener(handler) {
11997
- const listener2 = (event) => {
11998
- const detail = event.detail;
11999
- if (!detail?.promises) return;
12000
- const promise = trackStudioPendingEdit(handler());
12001
- if (promise) detail.promises.push(promise);
12002
- };
12003
- window.addEventListener(STUDIO_FLUSH_PENDING_EDITS_EVENT, listener2);
12004
- return () => window.removeEventListener(STUDIO_FLUSH_PENDING_EDITS_EVENT, listener2);
12005
- }
12006
-
12007
- // src/contexts/DomEditContext.tsx
12008
12119
  import { jsx as jsx19 } from "react/jsx-runtime";
12009
12120
  var SAVES_AN_EDIT = {
12010
12121
  handleTimelineElementSelect: false,
@@ -17540,20 +17651,32 @@ function requestGsapMutation(projectId, route, filePath, body) {
17540
17651
  }
17541
17652
  );
17542
17653
  }
17543
- async function postGsapMutation(projectId, filePath, mutation, fallback) {
17654
+ async function postOwnedGsapMutation(route, projectId, filePath, body, fallback) {
17544
17655
  let response;
17545
17656
  try {
17546
- response = await requestGsapMutation(projectId, "gsap-mutations", filePath, mutation);
17657
+ response = await requestGsapMutation(projectId, route, filePath, body);
17547
17658
  } catch (error) {
17548
17659
  throw new GsapPreviewConvergenceError(`${fallback}: mutation outcome unknown`, {
17549
17660
  cause: error
17550
17661
  });
17551
17662
  }
17552
- const body = await response.json().catch(() => null);
17663
+ const payload = await response.json().catch(() => null);
17553
17664
  if (!response.ok) {
17554
- throw new GsapPreviewConvergenceError(readMutationError(body, fallback));
17665
+ throw new GsapPreviewConvergenceError(readMutationError(payload, fallback));
17555
17666
  }
17556
- return readMutationStatus(body);
17667
+ return readMutationStatus(payload);
17668
+ }
17669
+ function postGsapMutation(projectId, filePath, mutation, fallback) {
17670
+ return postOwnedGsapMutation("gsap-mutations", projectId, filePath, mutation, fallback);
17671
+ }
17672
+ function postGsapMutations(projectId, filePath, mutations, fallback) {
17673
+ return postOwnedGsapMutation(
17674
+ "gsap-mutations-batch",
17675
+ projectId,
17676
+ filePath,
17677
+ { mutations },
17678
+ fallback
17679
+ );
17557
17680
  }
17558
17681
 
17559
17682
  // src/hooks/timelineTimingSync.ts
@@ -17616,8 +17739,9 @@ function syncTimingEditPreview(iframe, outcome, currentTime, reloadPreview, rebi
17616
17739
  });
17617
17740
  if (result === "cannot-soft-reload") reloadPreview();
17618
17741
  }
17742
+ var UNMUTATED = { mutated: false, scriptText: null };
17619
17743
  async function finishTimelineTimingFallback(input) {
17620
- let outcome = { mutated: false, scriptText: null };
17744
+ let outcome = UNMUTATED;
17621
17745
  if (input.gsapMutation) {
17622
17746
  try {
17623
17747
  outcome = await input.gsapMutation();
@@ -17644,7 +17768,14 @@ async function finishTimelineTimingFallback(input) {
17644
17768
  nestedFiles
17645
17769
  );
17646
17770
  }
17647
- var GSAP_HISTORY_COALESCE_MS = 1e4;
17771
+ var UNTIL_NEXT_EDIT = Number.POSITIVE_INFINITY;
17772
+ var timingGestures = 0;
17773
+ function timingGestureStep(kind, given) {
17774
+ return {
17775
+ coalesceKey: given?.coalesceKey ?? `${kind}:${++timingGestures}`,
17776
+ coalesceMs: UNTIL_NEXT_EDIT
17777
+ };
17778
+ }
17648
17779
  async function rollbackMutatedFiles(projectId, ownedSteps, onError) {
17649
17780
  let convergenceRequired = false;
17650
17781
  for (let index = ownedSteps.length - 1; index >= 0; index -= 1) {
@@ -17675,7 +17806,7 @@ async function foldGsapMutationInQueue(input) {
17675
17806
  const ownedSteps = [];
17676
17807
  const runOwnedMutation = async (path, mutation) => {
17677
17808
  const pending2 = mutation();
17678
- if (!pending2) return { mutated: false, scriptText: null };
17809
+ if (!pending2) return UNMUTATED;
17679
17810
  const status2 = await pending2;
17680
17811
  if (!status2.mutated) return status2;
17681
17812
  if (status2.before === void 0 || status2.after === void 0) {
@@ -17720,7 +17851,7 @@ async function foldGsapMutationInQueue(input) {
17720
17851
  await input.recordEdit({
17721
17852
  label: input.label,
17722
17853
  coalesceKey: input.coalesceKey,
17723
- coalesceMs: GSAP_HISTORY_COALESCE_MS,
17854
+ coalesceMs: UNTIL_NEXT_EDIT,
17724
17855
  files
17725
17856
  });
17726
17857
  }
@@ -17737,37 +17868,31 @@ function foldGsapMutationIntoHistory(input) {
17737
17868
  () => foldGsapMutationInQueue(input)
17738
17869
  );
17739
17870
  }
17871
+ function shiftGsapMutation(elementId2, delta) {
17872
+ if (delta === 0 || !elementId2) return null;
17873
+ return { type: "shift-positions", targetSelector: `#${elementId2}`, delta };
17874
+ }
17875
+ function scaleGsapMutation(elementId2, oldStart, oldDuration, newStart, newDuration) {
17876
+ if (!elementId2 || oldDuration <= 0 || newDuration <= 0) return null;
17877
+ if (oldStart === newStart && oldDuration === newDuration) return null;
17878
+ return {
17879
+ type: "scale-positions",
17880
+ targetSelector: `#${elementId2}`,
17881
+ oldStart,
17882
+ oldDuration,
17883
+ newStart,
17884
+ newDuration
17885
+ };
17886
+ }
17740
17887
  async function shiftGsapPositions(projectId, filePath, elementId2, delta) {
17741
- if (delta === 0 || !elementId2) return { mutated: false, scriptText: null };
17742
- return postGsapMutation(
17743
- projectId,
17744
- filePath,
17745
- {
17746
- type: "shift-positions",
17747
- targetSelector: `#${elementId2}`,
17748
- delta
17749
- },
17750
- "shift-positions failed"
17751
- );
17888
+ const mutation = shiftGsapMutation(elementId2, delta);
17889
+ if (!mutation) return UNMUTATED;
17890
+ return postGsapMutation(projectId, filePath, mutation, "shift-positions failed");
17752
17891
  }
17753
17892
  async function scaleGsapPositions(projectId, filePath, elementId2, oldStart, oldDuration, newStart, newDuration) {
17754
- if (!elementId2 || oldDuration <= 0 || newDuration <= 0)
17755
- return { mutated: false, scriptText: null };
17756
- if (oldStart === newStart && oldDuration === newDuration)
17757
- return { mutated: false, scriptText: null };
17758
- return postGsapMutation(
17759
- projectId,
17760
- filePath,
17761
- {
17762
- type: "scale-positions",
17763
- targetSelector: `#${elementId2}`,
17764
- oldStart,
17765
- oldDuration,
17766
- newStart,
17767
- newDuration
17768
- },
17769
- "scale-positions failed"
17770
- );
17893
+ const mutation = scaleGsapMutation(elementId2, oldStart, oldDuration, newStart, newDuration);
17894
+ if (!mutation) return UNMUTATED;
17895
+ return postGsapMutation(projectId, filePath, mutation, "scale-positions failed");
17771
17896
  }
17772
17897
  function sdkTimingGsapSync(result) {
17773
17898
  const timelineText = (html2) => {
@@ -17844,11 +17969,17 @@ async function finishGroupTimingGsapFallback(input) {
17844
17969
  let mutated = false;
17845
17970
  let scriptText = null;
17846
17971
  let after;
17972
+ const byPath = /* @__PURE__ */ new Map();
17847
17973
  for (const change of input.changes) {
17974
+ const mutation = input.mutationFor(change);
17975
+ if (!mutation) continue;
17848
17976
  const changePath = input.resolveChangePath(change.element);
17977
+ byPath.set(changePath, [...byPath.get(changePath) ?? [], mutation]);
17978
+ }
17979
+ for (const [changePath, mutations] of byPath) {
17849
17980
  const status = await runOwnedMutation(
17850
17981
  changePath,
17851
- () => input.mutateChange(change, changePath)
17982
+ () => postGsapMutations(input.projectId, changePath, mutations, input.errorLabel)
17852
17983
  );
17853
17984
  mutated = mutated || status.mutated;
17854
17985
  if (changePath === activePath) ({ scriptText, after } = status);
@@ -18015,10 +18146,10 @@ function resolveResizePlaybackStart(original, target, element, updates) {
18015
18146
  )
18016
18147
  };
18017
18148
  }
18018
- function buildTimelineMoveTimingPatch(original, target, start, duration, track2, audioGroup) {
18149
+ function applyTimelineMoveAttributes(original, target, start, duration, track2, audioGroup) {
18019
18150
  if (!Number.isFinite(start) || !Number.isFinite(duration)) {
18020
18151
  console.warn(
18021
- `[Timeline] buildTimelineMoveTimingPatch: non-finite timing (start=${start}, duration=${duration}) \u2014 patch skipped`
18152
+ `[Timeline] applyTimelineMoveAttributes: non-finite timing (start=${start}, duration=${duration}) \u2014 patch skipped`
18022
18153
  );
18023
18154
  return original;
18024
18155
  }
@@ -18040,9 +18171,16 @@ function buildTimelineMoveTimingPatch(original, target, start, duration, track2,
18040
18171
  property: "data-audio-group",
18041
18172
  value: null
18042
18173
  });
18043
- return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
18174
+ return patched;
18175
+ }
18176
+ function syncCompositionDurationToContent(source) {
18177
+ return setCompositionDurationToContent(source, furthestClipEndFromSource(source));
18178
+ }
18179
+ function buildTimelineMoveTimingPatch(original, target, start, duration, track2, audioGroup) {
18180
+ const patched = applyTimelineMoveAttributes(original, target, start, duration, track2, audioGroup);
18181
+ return !(Number.isFinite(start) && Number.isFinite(duration)) ? patched : syncCompositionDurationToContent(patched);
18044
18182
  }
18045
- function buildTimelineResizeTimingPatch(original, target, element, updates) {
18183
+ function applyTimelineResizeAttributes(original, target, element, updates) {
18046
18184
  const pbs = resolveResizePlaybackStart(original, target, element, updates);
18047
18185
  let patched = applyPatchByTarget(original, target, {
18048
18186
  type: "attribute",
@@ -18061,7 +18199,10 @@ function buildTimelineResizeTimingPatch(original, target, element, updates) {
18061
18199
  value: formatTimelineMediaOffset(pbs.value)
18062
18200
  });
18063
18201
  }
18064
- return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
18202
+ return patched;
18203
+ }
18204
+ function buildTimelineResizeTimingPatch(...args) {
18205
+ return syncCompositionDurationToContent(applyTimelineResizeAttributes(...args));
18065
18206
  }
18066
18207
  async function persistTimelineEdit(input) {
18067
18208
  const targetPath = input.element.sourceFile || input.activeCompPath || "index.html";
@@ -18074,6 +18215,7 @@ async function persistTimelineEdit(input) {
18074
18215
  projectId: input.projectId,
18075
18216
  label: input.label,
18076
18217
  coalesceKey: input.coalesceKey,
18218
+ coalesceMs: input.coalesceMs,
18077
18219
  files: {
18078
18220
  [targetPath]: (current) => {
18079
18221
  const patched = input.buildPatches(current, patchTarget);
@@ -18113,7 +18255,12 @@ async function persistTimelineBatchEdit(input) {
18113
18255
  changesByPath.set(targetPath, [...changesByPath.get(targetPath) ?? [], change]);
18114
18256
  }
18115
18257
  const buildFile = (targetPath) => (original) => {
18116
- const next = patchTimelineChangesInSource(original, targetPath, changesByPath.get(targetPath));
18258
+ const patched = patchTimelineChangesInSource(
18259
+ original,
18260
+ targetPath,
18261
+ changesByPath.get(targetPath)
18262
+ );
18263
+ const next = syncCompositionDurationToContent(patched);
18117
18264
  if (next !== original) input.pendingTimelineEditPathRef.current.add(targetPath);
18118
18265
  return next;
18119
18266
  };
@@ -18521,6 +18668,7 @@ import { jsx as jsx33, jsxs as jsxs20 } from "react/jsx-runtime";
18521
18668
  var TimelineClip = memo13(function TimelineClip2({
18522
18669
  el,
18523
18670
  pps,
18671
+ passengerStyle,
18524
18672
  clipY,
18525
18673
  clipHeight,
18526
18674
  isSelected: isSelected2,
@@ -18592,7 +18740,8 @@ var TimelineClip = memo13(function TimelineClip2({
18592
18740
  font: "inherit",
18593
18741
  padding: 0,
18594
18742
  textAlign: "left",
18595
- transform: isDragging ? "translateY(-1px)" : void 0
18743
+ transform: isDragging ? "translateY(-1px)" : void 0,
18744
+ ...passengerStyle
18596
18745
  };
18597
18746
  return /* @__PURE__ */ jsxs20(
18598
18747
  "button",
@@ -18728,6 +18877,7 @@ function TimelineCompactDiamonds({
18728
18877
  beatsActive,
18729
18878
  accentColor,
18730
18879
  isSelected: isSelected2,
18880
+ passengerStyle,
18731
18881
  currentTime,
18732
18882
  selectedKeyframes,
18733
18883
  rovingTargetId,
@@ -18748,7 +18898,8 @@ function TimelineCompactDiamonds({
18748
18898
  top: CLIP_Y,
18749
18899
  width,
18750
18900
  height: rowHeight - 2 * CLIP_Y,
18751
- zIndex: isSelected2 ? 11 : 6
18901
+ zIndex: isSelected2 ? 11 : 6,
18902
+ ...passengerStyle
18752
18903
  },
18753
18904
  children: /* @__PURE__ */ jsx34(
18754
18905
  TimelineClipDiamonds,
@@ -21850,7 +22001,6 @@ function LayerDisclosureRow({
21850
22001
  }
21851
22002
 
21852
22003
  // src/player/components/trackHeaderLaneValues.ts
21853
- import gsap2 from "gsap";
21854
22004
  import {
21855
22005
  classifyPropertyGroup as classifyPropertyGroup2
21856
22006
  } from "@hyperframes/core/gsap-parser";
@@ -21858,24 +22008,25 @@ function roundValue(value) {
21858
22008
  return String(Math.round(value * 100) / 100);
21859
22009
  }
21860
22010
  function easedProgress(progress, animation, ease) {
21861
- const resolved2 = ease ?? animation.keyframes?.easeEach ?? animation.ease;
21862
- if (!resolved2 || resolved2 === "none") return progress;
21863
- return gsap2.parseEase(resolved2)?.(progress) ?? progress;
22011
+ return easeFunction(keyframedTweenEases(animation).segment({ ease }))?.(progress) ?? progress;
21864
22012
  }
21865
- function propertyStops(animation, property) {
21866
- return (animation.keyframes?.keyframes ?? []).filter((keyframe) => property in keyframe.properties).map((keyframe) => ({
22013
+ function propertyStops(animation, property, start) {
22014
+ const stops = (animation.keyframes?.keyframes ?? []).filter((keyframe) => property in keyframe.properties).map((keyframe) => ({
21867
22015
  percentage: keyframe.percentage,
21868
22016
  value: keyframe.properties[property],
21869
22017
  ease: keyframe.ease
21870
22018
  }));
22019
+ const first = stops[0];
22020
+ if (!first || first.percentage <= 0 || typeof first.value !== "number") return stops;
22021
+ return [{ percentage: 0, value: start ?? PROPERTY_DEFAULTS[property] ?? 0 }, ...stops];
21871
22022
  }
21872
22023
  function interpolableEnds(before, after) {
21873
22024
  if (typeof before.value !== "number" || typeof after.value !== "number") return null;
21874
22025
  if (before.percentage === after.percentage) return null;
21875
22026
  return { from: before.value, to: after.value };
21876
22027
  }
21877
- function propertyValueAt(animation, property, tweenPercentage) {
21878
- const stops = propertyStops(animation, property);
22028
+ function propertyValueAt(animation, property, tweenPercentage, start) {
22029
+ const stops = propertyStops(animation, property, start);
21879
22030
  const before = stops.filter((stop2) => stop2.percentage <= tweenPercentage).at(-1);
21880
22031
  const after = stops.find((stop2) => stop2.percentage >= tweenPercentage);
21881
22032
  if (!before) return after?.value;
@@ -21885,7 +22036,7 @@ function propertyValueAt(animation, property, tweenPercentage) {
21885
22036
  const progress = (tweenPercentage - before.percentage) / (after.percentage - before.percentage);
21886
22037
  return ends.from + (ends.to - ends.from) * easedProgress(progress, animation, after.ease);
21887
22038
  }
21888
- function valuesAt(animation, group, tweenPercentage) {
22039
+ function valuesAt(animation, group, tweenPercentage, startValues = {}) {
21889
22040
  const propertyNames = /* @__PURE__ */ new Set();
21890
22041
  for (const keyframe of animation.keyframes?.keyframes ?? []) {
21891
22042
  for (const property of Object.keys(keyframe.properties)) {
@@ -21894,11 +22045,34 @@ function valuesAt(animation, group, tweenPercentage) {
21894
22045
  }
21895
22046
  const values = {};
21896
22047
  for (const property of propertyNames) {
21897
- const value = propertyValueAt(animation, property, tweenPercentage);
22048
+ const value = propertyValueAt(animation, property, tweenPercentage, startValues[property]);
21898
22049
  if (value !== void 0) values[property] = value;
21899
22050
  }
21900
22051
  return values;
21901
22052
  }
22053
+ var endValue = (animation, property) => animation.keyframes?.keyframes.findLast((keyframe) => property in keyframe.properties)?.properties[property] ?? animation.properties[property];
22054
+ function valuesBefore(animation, animations) {
22055
+ const start = resolveTweenStart(animation);
22056
+ if (start == null) return {};
22057
+ const endOf2 = (other) => (resolveTweenStart(other) ?? Infinity) + (other.method === "set" ? 0 : resolveTweenDuration(other));
22058
+ const index = animations.indexOf(animation);
22059
+ const finished = animations.filter((other, i) => {
22060
+ if (other.method === "from" || i === index) return false;
22061
+ return endOf2(other) < start || endOf2(other) === start && i < index;
22062
+ }).sort((a, b) => endOf2(a) - endOf2(b));
22063
+ const values = {};
22064
+ for (const other of finished) {
22065
+ const keyframes = other.keyframes?.keyframes ?? [{ properties: other.properties }];
22066
+ for (const property of new Set(
22067
+ keyframes.flatMap((keyframe) => Object.keys(keyframe.properties))
22068
+ )) {
22069
+ const value = endValue(other, property);
22070
+ if (typeof value === "number") values[property] = value;
22071
+ else delete values[property];
22072
+ }
22073
+ }
22074
+ return values;
22075
+ }
21902
22076
  function groupLabel(group, properties) {
21903
22077
  if (group === "visual" && ("opacity" in properties || "autoAlpha" in properties)) {
21904
22078
  return "Opacity";
@@ -21975,7 +22149,12 @@ function resolveLaneHeaderState(lane, currentTime, clipPercentage) {
21975
22149
  currentTime,
21976
22150
  clipPercentage
21977
22151
  );
21978
- const values = animation ? valuesAt(animation, lane.group, tweenPercentage) : {};
22152
+ const values = animation ? valuesAt(
22153
+ animation,
22154
+ lane.group,
22155
+ tweenPercentage,
22156
+ valuesBefore(animation, lane.elementAnimations)
22157
+ ) : {};
21979
22158
  return {
21980
22159
  navigation,
21981
22160
  values,
@@ -23078,7 +23257,7 @@ function isIntentBlocked(intent, capabilities, onResizeElement, onMoveElement) {
23078
23257
  if (capabilities.readOnly) return true;
23079
23258
  return intent === "move" ? Boolean(onMoveElement) : Boolean(onResizeElement);
23080
23259
  }
23081
- function resolvePointerDownAction(e, grabbed, capabilities, onResizeElement, onMoveElement) {
23260
+ function resolvePointerDownAction(e, capabilities, onResizeElement, onMoveElement) {
23082
23261
  if (e.button !== 0) return { kind: "ignore" };
23083
23262
  if (usePlayerStore.getState().activeTool === "razor") return { kind: "ignore" };
23084
23263
  if (isAdditiveClick(e)) return { kind: "ignore" };
@@ -23093,8 +23272,6 @@ function resolvePointerDownAction(e, grabbed, capabilities, onResizeElement, onM
23093
23272
  return { kind: "block", intent, rect };
23094
23273
  }
23095
23274
  if (!onMoveElement || !capabilities.canMove) return { kind: "ignore" };
23096
- if (exceedsHandEditLimit(grabbed, e.altKey, null))
23097
- return { kind: "block", intent: "edit-many", rect };
23098
23275
  return { kind: "move", rect };
23099
23276
  }
23100
23277
  function createClipGestureHandlers(el, elementKey3, previewElement, capabilities, deps) {
@@ -23117,17 +23294,6 @@ function createClipGestureHandlers(el, elementKey3, previewElement, capabilities
23117
23294
  const onResizeStart = (edge, e) => {
23118
23295
  if (!canStartResize(edge, e, capabilities, onResizeElement)) return;
23119
23296
  e.stopPropagation();
23120
- if (exceedsHandEditLimit(el, e.altKey, edge)) {
23121
- blockedClipRef.current = {
23122
- pointerId: e.pointerId,
23123
- element: el,
23124
- intent: "edit-many",
23125
- originClientX: e.clientX,
23126
- originClientY: e.clientY,
23127
- started: false
23128
- };
23129
- return;
23130
- }
23131
23297
  blockedClipRef.current = null;
23132
23298
  setShowPopover(false);
23133
23299
  setRangeSelection(null);
@@ -23145,7 +23311,7 @@ function createClipGestureHandlers(el, elementKey3, previewElement, capabilities
23145
23311
  });
23146
23312
  };
23147
23313
  const onPointerDown = (e) => {
23148
- const action = resolvePointerDownAction(e, el, capabilities, onResizeElement, onMoveElement);
23314
+ const action = resolvePointerDownAction(e, capabilities, onResizeElement, onMoveElement);
23149
23315
  if (action.kind === "ignore") return;
23150
23316
  if (action.kind === "block") {
23151
23317
  blockedClipRef.current = {
@@ -23352,6 +23518,14 @@ function TimelineTransitionOverlays({
23352
23518
 
23353
23519
  // src/player/components/TimelineLanes.tsx
23354
23520
  import { jsx as jsx57, jsxs as jsxs37 } from "react/jsx-runtime";
23521
+ function passengerStyleAt(offsetPx) {
23522
+ return {
23523
+ transform: `translateX(${offsetPx}px)`,
23524
+ opacity: 0.85,
23525
+ zIndex: 20,
23526
+ pointerEvents: "none"
23527
+ };
23528
+ }
23355
23529
  function TimelineLanes({
23356
23530
  pps,
23357
23531
  contentOrigin,
@@ -23658,7 +23832,7 @@ function TimelineLanes({
23658
23832
  isSelected2 || hoveredClip === clipKey || pinnedClipIdentities.has(clipKey)
23659
23833
  );
23660
23834
  const isPassenger = multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview);
23661
- const passengerOffsetPx = isPassenger ? multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview) : 0;
23835
+ const passengerStyle = isPassenger ? passengerStyleAt(multiDragPassengerOffsetPx(clipKey, pps, multiDragPreview)) : void 0;
23662
23836
  const clipGestures = createClipGestureHandlers(
23663
23837
  el,
23664
23838
  elementKey3,
@@ -23690,6 +23864,7 @@ function TimelineLanes({
23690
23864
  },
23691
23865
  el: previewElement,
23692
23866
  pps,
23867
+ passengerStyle,
23693
23868
  clipY: CLIP_Y,
23694
23869
  clipHeight: clipBarHeight,
23695
23870
  isSelected: isSelected2,
@@ -23733,6 +23908,7 @@ function TimelineLanes({
23733
23908
  beatsActive: beatStripOnTrack,
23734
23909
  accentColor: clipStyle.accent,
23735
23910
  isSelected: isSelected2,
23911
+ passengerStyle,
23736
23912
  currentTime,
23737
23913
  selectedKeyframes,
23738
23914
  rovingTargetId: keyboard.rovingTargetId,
@@ -23754,6 +23930,7 @@ function TimelineLanes({
23754
23930
  clipDuration: previewElement.duration,
23755
23931
  clipLeftPx: previewElement.start * pps,
23756
23932
  clipWidthPx: Math.max(previewElement.duration * pps, 4),
23933
+ passengerStyle,
23757
23934
  accentColor: clipStyle.accent,
23758
23935
  isSelected: isSelected2,
23759
23936
  currentPercentage: previewElement.duration > 0 ? (currentTime - previewElement.start) / previewElement.duration * 100 : 0,
@@ -23769,31 +23946,11 @@ function TimelineLanes({
23769
23946
  },
23770
23947
  `${clipKey}-property-lanes`
23771
23948
  );
23772
- if (!isPassenger) {
23773
- return /* @__PURE__ */ jsxs37(Fragment18, { children: [
23774
- clip,
23775
- compactDiamonds,
23776
- propertyLanes
23777
- ] }, clipKey);
23778
- }
23779
- return /* @__PURE__ */ jsxs37(
23780
- "div",
23781
- {
23782
- className: "absolute inset-0",
23783
- style: {
23784
- transform: `translateX(${passengerOffsetPx}px)`,
23785
- opacity: 0.85,
23786
- zIndex: 20,
23787
- pointerEvents: "none"
23788
- },
23789
- children: [
23790
- clip,
23791
- compactDiamonds,
23792
- propertyLanes
23793
- ]
23794
- },
23795
- clipKey
23796
- );
23949
+ return /* @__PURE__ */ jsxs37(Fragment18, { children: [
23950
+ clip,
23951
+ compactDiamonds,
23952
+ propertyLanes
23953
+ ] }, clipKey);
23797
23954
  }),
23798
23955
  /* @__PURE__ */ jsx57(
23799
23956
  TimelineTransitionOverlays,
@@ -29383,8 +29540,75 @@ var STUDIO_MANUAL_EDITS_WRAPPED_PROP = "__hfStudioManualEditsWrapped";
29383
29540
  var STUDIO_MANUAL_EDITS_PLAYBACK_FRAME_PROP = "__hfStudioManualEditsPlaybackFrame";
29384
29541
  var STUDIO_ROTATION_TRANSFORM_ORIGIN = "center center";
29385
29542
 
29386
- // src/hooks/gsapRuntimeKeyframes.ts
29543
+ // src/hooks/gsapRuntimeMotionPath.ts
29387
29544
  import { buildArcPath } from "@hyperframes/core/gsap-parser-acorn";
29545
+ function isXY(p) {
29546
+ return !!p && typeof p.x === "number" && typeof p.y === "number";
29547
+ }
29548
+ function coordsFromMotionPath(mp) {
29549
+ if (!mp || typeof mp !== "object") return null;
29550
+ const obj = mp;
29551
+ const pathVal = Array.isArray(mp) ? mp : obj.path;
29552
+ if (!Array.isArray(pathVal)) return null;
29553
+ const coords = pathVal.filter(isXY).map((p) => ({ x: p.x, y: p.y }));
29554
+ if (coords.length < 2) return null;
29555
+ const curviness = typeof obj.curviness === "number" ? obj.curviness : 1;
29556
+ const autoRotate = typeof obj.autoRotate === "number" ? obj.autoRotate : obj.autoRotate === true;
29557
+ return { coords, curviness, autoRotate, isCubic: obj.type === "cubic" };
29558
+ }
29559
+ function readMotionPathTween(vars, runEase) {
29560
+ const mp = coordsFromMotionPath(vars.motionPath);
29561
+ const shape = mp && buildArcPath(mp.coords, mp.curviness, mp.autoRotate, mp.isCubic);
29562
+ if (!shape) return null;
29563
+ const n = shape.waypoints.length;
29564
+ const keyframes = shape.waypoints.map((wp, i) => ({
29565
+ percentage: n > 1 ? Math.round(i / (n - 1) * 100) : 0,
29566
+ properties: { x: wp.x, y: wp.y }
29567
+ }));
29568
+ return { keyframes, arcPath: shape.arcPath, runEase: runEase ?? MOTION_PATH_RUN_EASE };
29569
+ }
29570
+
29571
+ // src/hooks/gsapRuntimeTweenIndex.ts
29572
+ function matchesElement(tween, el) {
29573
+ if (!tween.targets) return false;
29574
+ for (const t2 of tween.targets()) {
29575
+ if (t2 === el || el.id && t2.id === el.id) return true;
29576
+ }
29577
+ return false;
29578
+ }
29579
+ var tweenIndexes = null;
29580
+ function withTweenIndex(run) {
29581
+ const outer = tweenIndexes;
29582
+ tweenIndexes ??= /* @__PURE__ */ new WeakMap();
29583
+ try {
29584
+ return run();
29585
+ } finally {
29586
+ tweenIndexes = outer;
29587
+ }
29588
+ }
29589
+ function indexTweens(children) {
29590
+ const index = { byElement: /* @__PURE__ */ new Map(), byId: /* @__PURE__ */ new Map() };
29591
+ const add = (map, key2, tween) => {
29592
+ const list = map.get(key2);
29593
+ if (!list) map.set(key2, [tween]);
29594
+ else if (list.at(-1) !== tween) list.push(tween);
29595
+ };
29596
+ for (const tween of children) {
29597
+ for (const target of tween.targets?.() ?? []) {
29598
+ add(index.byElement, target, tween);
29599
+ const id = target.id;
29600
+ if (id) add(index.byId, id, tween);
29601
+ }
29602
+ }
29603
+ return index;
29604
+ }
29605
+ function tweensTargeting(timeline, el) {
29606
+ if (!timeline || typeof timeline !== "object" || !tweenIndexes)
29607
+ return (timeline?.getChildren?.(true) ?? []).filter((tween) => matchesElement(tween, el));
29608
+ let index = tweenIndexes.get(timeline);
29609
+ if (!index) tweenIndexes.set(timeline, index = indexTweens(timeline.getChildren?.(true) ?? []));
29610
+ return (el.id ? index.byId.get(el.id) : index.byElement.get(el)) ?? [];
29611
+ }
29388
29612
 
29389
29613
  // src/hooks/gsapParsedTween.ts
29390
29614
  var PARSED_NAME = { scale: "scaleX", rotate: "rotation" };
@@ -29547,14 +29771,13 @@ var BUILT_IN_EASES = [
29547
29771
  function withExactStepTimes(anim, tween) {
29548
29772
  const data = anim.keyframes;
29549
29773
  const parts = tween?.timeline?.getChildren?.() ?? [];
29550
- const total = tween?.timeline?.duration?.() ?? 0;
29551
- if (data?.format !== "object-array" || parts.length !== data.keyframes.length || !(total > 0))
29552
- return anim;
29553
- const ends = parts.map((part) => ((part.startTime?.() ?? 0) + (part.duration?.() ?? 0)) / total);
29554
- if (ends.some((end) => !Number.isFinite(end))) return anim;
29774
+ if (data?.format !== "object-array" || parts.length !== data.keyframes.length) return anim;
29775
+ const ends = parts.map((part) => (part.startTime?.() ?? 0) + (part.duration?.() ?? 0));
29776
+ const total = Math.max(...ends);
29777
+ if (!(total > 0) || ends.some((end) => !Number.isFinite(end))) return anim;
29555
29778
  const keyframes = data.keyframes.map((kf, i) => ({
29556
29779
  ...kf,
29557
- percentage: Math.round(ends[i] * 1e5) / 1e3
29780
+ percentage: Math.round(ends[i] / total * 1e5) / 1e3
29558
29781
  }));
29559
29782
  return { ...anim, keyframes: { ...data, keyframes } };
29560
29783
  }
@@ -29716,23 +29939,9 @@ function timelinesOf(iframe) {
29716
29939
  return null;
29717
29940
  }
29718
29941
  }
29719
- function isXY(p) {
29720
- return !!p && typeof p.x === "number" && typeof p.y === "number";
29721
- }
29722
29942
  function isZeroDurationSet(duration) {
29723
29943
  return !(duration > 0);
29724
29944
  }
29725
- function coordsFromMotionPath(mp) {
29726
- if (!mp || typeof mp !== "object") return null;
29727
- const obj = mp;
29728
- const pathVal = Array.isArray(mp) ? mp : obj.path;
29729
- if (!Array.isArray(pathVal)) return null;
29730
- const coords = pathVal.filter(isXY).map((p) => ({ x: p.x, y: p.y }));
29731
- if (coords.length < 2) return null;
29732
- const curviness = typeof obj.curviness === "number" ? obj.curviness : 1;
29733
- const autoRotate = typeof obj.autoRotate === "number" ? obj.autoRotate : obj.autoRotate === true;
29734
- return { coords, curviness, autoRotate, isCubic: obj.type === "cubic" };
29735
- }
29736
29945
  function flatTweenKeyframes(vars) {
29737
29946
  const properties = {};
29738
29947
  for (const [k, v] of Object.entries(vars)) {
@@ -29746,33 +29955,19 @@ function flatTweenKeyframes(vars) {
29746
29955
  { percentage: 100, properties }
29747
29956
  ];
29748
29957
  }
29958
+ var stringOr = (value) => typeof value === "string" ? value : void 0;
29749
29959
  function readTween(vars) {
29960
+ const runEase = stringOr(vars.ease);
29750
29961
  if (vars.keyframes && typeof vars.keyframes === "object") {
29751
29962
  const parsed = parsePercentageKeyframes(vars.keyframes);
29752
- if (parsed) return parsed;
29753
- }
29754
- const mp = coordsFromMotionPath(vars.motionPath);
29755
- if (mp) {
29756
- const shape = buildArcPath(mp.coords, mp.curviness, mp.autoRotate, mp.isCubic);
29757
- if (shape) {
29758
- const n = shape.waypoints.length;
29759
- const keyframes = shape.waypoints.map((wp, i) => ({
29760
- percentage: n > 1 ? Math.round(i / (n - 1) * 100) : 0,
29761
- properties: { x: wp.x, y: wp.y }
29762
- }));
29763
- return { keyframes, arcPath: shape.arcPath };
29764
- }
29963
+ const keyframesEase = stringOr(vars.keyframes.ease);
29964
+ if (parsed) return { ...parsed, runEase: keyframesEase ?? runEase };
29765
29965
  }
29966
+ const path = readMotionPathTween(vars, runEase);
29967
+ if (path) return path;
29766
29968
  const flat = flatTweenKeyframes(vars);
29767
29969
  return flat ? { keyframes: flat } : null;
29768
29970
  }
29769
- function matchesElement(tween, el) {
29770
- if (!tween.targets) return false;
29771
- for (const t2 of tween.targets()) {
29772
- if (t2 === el || el.id && t2.id === el.id) return true;
29773
- }
29774
- return false;
29775
- }
29776
29971
  function tweenTiming(tween) {
29777
29972
  const rawStart = typeof tween.startTime === "function" ? tween.startTime() : 0;
29778
29973
  const rawDur = typeof tween.duration === "function" ? tween.duration() : 0;
@@ -29904,8 +30099,8 @@ var GSAP_TRANSFORM_KEYS = new Set(
29904
30099
  function gsapWritesChannels(el, channels) {
29905
30100
  const win = el.ownerDocument.defaultView;
29906
30101
  return Object.values(win?.__timelines ?? {}).some(
29907
- (tl) => (tl?.getChildren?.(true) ?? []).some(
29908
- (tween) => !!tween.vars && matchesElement(tween, el) && (channels.some((ch) => ch in tween.vars) || keyframeVarsCarryChannel(tween.vars, channels))
30102
+ (tl) => tweensTargeting(tl, el).some(
30103
+ (tween) => !!tween.vars && (channels.some((ch) => ch in tween.vars) || keyframeVarsCarryChannel(tween.vars, channels))
29909
30104
  )
29910
30105
  );
29911
30106
  }
@@ -29950,8 +30145,8 @@ function elementHasNonHoldTween(el, channels) {
29950
30145
  function hasNonHoldTween(timelines, targetEl, channels, compositionId) {
29951
30146
  if (!timelines) return false;
29952
30147
  for (const tlId of compositionId ? [compositionId] : Object.keys(timelines)) {
29953
- for (const tween of timelines[tlId]?.getChildren?.(true) ?? []) {
29954
- if (!tween.vars || !matchesElement(tween, targetEl)) continue;
30148
+ for (const tween of tweensTargeting(timelines[tlId], targetEl)) {
30149
+ if (!tween.vars) continue;
29955
30150
  const dur = typeof tween.duration === "function" ? tween.duration() : 0;
29956
30151
  if (isZeroDurationSet(dur)) continue;
29957
30152
  if (channels && keyframeVarsCarryChannel(tween.vars, channels)) return true;
@@ -29961,16 +30156,17 @@ function hasNonHoldTween(timelines, targetEl, channels, compositionId) {
29961
30156
  }
29962
30157
  return false;
29963
30158
  }
29964
- function toClipRelative(keyframes, tweenStart, tweenDuration, clip) {
30159
+ function toClipRelative(read2, tweenStart, tweenDuration, clip) {
30160
+ const { keyframes, runEase } = read2;
29965
30161
  if (!clip || clip.duration <= 0) return keyframes;
29966
30162
  return keyframes.map((kf) => {
29967
- const abs = toAbsoluteTime(tweenStart, tweenDuration, kf.percentage);
30163
+ const abs = toAbsoluteTime(tweenStart, tweenDuration, timeAtProgress(runEase, kf.percentage));
29968
30164
  return { ...kf, percentage: Math.round((abs - clip.start) / clip.duration * 1e5) / 1e3 };
29969
30165
  });
29970
30166
  }
29971
30167
  function buildEntry(read2, start, duration, clip) {
29972
30168
  return {
29973
- keyframes: toClipRelative(read2.keyframes, start, duration, clip),
30169
+ keyframes: toClipRelative(read2, start, duration, clip),
29974
30170
  tweenStart: start,
29975
30171
  tweenDuration: duration,
29976
30172
  ...read2.easeEach ? { easeEach: read2.easeEach } : {},
@@ -31971,7 +32167,7 @@ function NLEProvider({
31971
32167
  }, [projectId, setCompIdToSrc]);
31972
32168
  useEffect37(() => {
31973
32169
  if (compIdToSrc.size === 0) return;
31974
- const patchElements = (elements) => {
32170
+ const patchElements2 = (elements) => {
31975
32171
  const map = compIdToSrcRef.current;
31976
32172
  if (map.size === 0) return null;
31977
32173
  let patched2 = false;
@@ -31986,7 +32182,7 @@ function NLEProvider({
31986
32182
  });
31987
32183
  return patched2 ? updated : null;
31988
32184
  };
31989
- const patched = patchElements(usePlayerStore.getState().elements);
32185
+ const patched = patchElements2(usePlayerStore.getState().elements);
31990
32186
  if (patched) usePlayerStore.getState().setElements(patched);
31991
32187
  let patching = false;
31992
32188
  return usePlayerStore.subscribe((state, prev) => {
@@ -31994,7 +32190,7 @@ function NLEProvider({
31994
32190
  if (state.elements === prev.elements || state.elements.length === 0) return;
31995
32191
  if (state.elements.every((el) => el.compositionSrc)) return;
31996
32192
  patching = true;
31997
- const result = patchElements(state.elements);
32193
+ const result = patchElements2(state.elements);
31998
32194
  if (result) state.setElements(result);
31999
32195
  patching = false;
32000
32196
  });
@@ -36477,7 +36673,7 @@ function OffCanvasIndicators({
36477
36673
  const selectOffCanvas = async () => {
36478
36674
  const el = elements.current.get(r.key);
36479
36675
  if (!el) return;
36480
- const { resolveDomEditSelection: resolveDomEditSelection2 } = await import("./domEditingLayers-OL7GLSS4.js");
36676
+ const { resolveDomEditSelection: resolveDomEditSelection2 } = await import("./domEditingLayers-IEVLO54Z.js");
36481
36677
  const acp = activeCompositionPathRef.current ?? "index.html";
36482
36678
  const sel = await resolveDomEditSelection2(el, {
36483
36679
  activeCompositionPath: acp,
@@ -43729,14 +43925,7 @@ function useMirrorLaneMoveCommit() {
43729
43925
  // NO readZIndex / onStackingPatches: see the hook doc — the lane→z
43730
43926
  // stacking sync must not re-trigger and fight the just-set z values.
43731
43927
  },
43732
- coalesceKey,
43733
- // Unbounded fold window: this record lands only AFTER the z persist's
43734
- // server round-trip resolved, so the gap between the gesture's two
43735
- // records exceeds editHistory's 300ms default under real latency and
43736
- // the fold would silently split into two undo entries. The shared key
43737
- // is unique per gesture (zReorderCoalesceKey's gesture seq), so the
43738
- // unbounded window can never merge two distinct user actions.
43739
- Number.POSITIVE_INFINITY
43928
+ coalesceKey
43740
43929
  );
43741
43930
  },
43742
43931
  [onMoveElements]
@@ -44869,7 +45058,23 @@ function TopologyLens({ iframeRef, activeCompositionPath }) {
44869
45058
  );
44870
45059
  }
44871
45060
 
45061
+ // src/utils/sameData.ts
45062
+ function sameData(a, b) {
45063
+ if (Object.is(a, b)) return true;
45064
+ if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
45065
+ if ("nodeType" in a || "nodeType" in b || Array.isArray(a) !== Array.isArray(b)) return false;
45066
+ const keys = Object.keys(a);
45067
+ if (keys.length !== Object.keys(b).length) return false;
45068
+ return keys.every((key2) => sameData(a[key2], b[key2]));
45069
+ }
45070
+
44872
45071
  // src/hooks/gsapKeyframeCacheHelpers.ts
45072
+ function keepEqualEntries(before, draft) {
45073
+ for (const [key2, value] of draft) {
45074
+ const shown2 = before.get(key2);
45075
+ if (shown2 !== void 0 && shown2 !== value && sameData(shown2, value)) draft.set(key2, shown2);
45076
+ }
45077
+ }
44873
45078
  function sameEntries(before, after) {
44874
45079
  if (before.size !== after.size) return false;
44875
45080
  for (const [key2, value] of before) {
@@ -44884,6 +45089,8 @@ function publishKeyframeCache(edit) {
44884
45089
  gsapAnimations: new Map(gsapAnimations)
44885
45090
  };
44886
45091
  edit(draft);
45092
+ keepEqualEntries(keyframeCache, draft.keyframeCache);
45093
+ keepEqualEntries(gsapAnimations, draft.gsapAnimations);
44887
45094
  if (sameEntries(keyframeCache, draft.keyframeCache) && sameEntries(gsapAnimations, draft.gsapAnimations))
44888
45095
  return;
44889
45096
  usePlayerStore.setState({
@@ -44953,8 +45160,15 @@ function cachedElementIdsForFile(sourceFile, keyframeCache, gsapAnimations) {
44953
45160
  const sfPrefix = `${sourceFile}#`;
44954
45161
  const ids = /* @__PURE__ */ new Set();
44955
45162
  for (const key2 of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
44956
- if (!key2.startsWith(sfPrefix)) continue;
44957
- ids.add(key2.slice(sfPrefix.length));
45163
+ if (key2.startsWith(sfPrefix)) ids.add(key2.slice(sfPrefix.length));
45164
+ }
45165
+ if (sourceFile !== "index.html") return ids;
45166
+ for (const [key2, entry2] of keyframeCache) {
45167
+ const hash = key2.indexOf("#");
45168
+ const id = key2.slice(hash + 1);
45169
+ if (hash > 0 && !key2.startsWith(sfPrefix) && keyframeCache.get(sfPrefix + id) === entry2) {
45170
+ ids.delete(id);
45171
+ }
44958
45172
  }
44959
45173
  return ids;
44960
45174
  }
@@ -45362,10 +45576,9 @@ function computeCurrentPercentage(selection, animation) {
45362
45576
  return computeElementPercentage(usePlayerStore.getState().currentTime, selection, animation);
45363
45577
  }
45364
45578
  function parkPlayheadOnKeyframe(anim, pct) {
45365
- const ts = resolveTweenStart(anim);
45366
- const td = resolveTweenDuration(anim);
45367
- if (ts == null || !td || td <= 0) return;
45368
- usePlayerStore.getState().requestSeek(roundTo3(ts + pct / 100 * td));
45579
+ const time = percentageToAbsoluteForAnimation(pct, anim);
45580
+ if (time === null || resolveTweenDuration(anim) <= 0) return;
45581
+ usePlayerStore.getState().requestSeek(roundTo3(time));
45369
45582
  }
45370
45583
  async function replaceKeyframedPositionHold(selection, existingSet, properties, commitMutation) {
45371
45584
  const target = newTweenTarget(selection);
@@ -45740,7 +45953,7 @@ function findGsapPositionAnimation(animations, selector) {
45740
45953
  }
45741
45954
  function playheadProximity(a, currentTime) {
45742
45955
  const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
45743
- const dur = a.duration ?? 0;
45956
+ const dur = resolveTweenDuration(a);
45744
45957
  if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) return 50;
45745
45958
  return -Math.round(Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5);
45746
45959
  }
@@ -45749,7 +45962,9 @@ function statesValueAt(anim, time) {
45749
45962
  if (!(duration > 0)) return false;
45750
45963
  const pct = (time - (resolveTweenStart(anim) ?? 0)) / duration * 100;
45751
45964
  const near3 = (keyframe) => Math.abs(keyframe - pct) <= KEYFRAME_PCT_MATCH;
45752
- if (anim.keyframes) return anim.keyframes.keyframes.some((kf) => near3(kf.percentage));
45965
+ const runEase = runEaseOf(anim);
45966
+ if (anim.keyframes)
45967
+ return anim.keyframes.keyframes.some((kf) => near3(timeAtProgress(runEase, kf.percentage)));
45753
45968
  if (anim.method === "from") return near3(0);
45754
45969
  return near3(100) || anim.method === "fromTo" && near3(0);
45755
45970
  }
@@ -46054,8 +46269,6 @@ function tweensForThisElement(selection, animations) {
46054
46269
  }
46055
46270
 
46056
46271
  // src/hooks/gsapValueAtPlayhead.ts
46057
- var PERCENTAGE_SEGMENT_EASE = "power1.inOut";
46058
- var ARRAY_STEP_EASE = "none";
46059
46272
  var roundPct2 = (pct) => Math.round(pct * 1e3) / 1e3;
46060
46273
  var isLinear = (ease) => !ease || ease === "none" || ease === "linear";
46061
46274
  function refuse(reason) {
@@ -46070,7 +46283,7 @@ function normalize(edit) {
46070
46283
  const blocked = stepListBlock(anim);
46071
46284
  if (blocked) return { reason: blocked };
46072
46285
  return {
46073
- runEase: data.ease ?? anim.ease,
46286
+ runEase: runEaseOf(anim),
46074
46287
  keyframes: data.keyframes.map((kf) => ({
46075
46288
  ...kf,
46076
46289
  properties: { ...kf.properties },
@@ -47451,7 +47664,7 @@ function readAllAnimatedProperties(iframe, selector, anim, group) {
47451
47664
  // src/hooks/gsapDragPositionCommit.ts
47452
47665
  function buildTemporalArcKeyframes(anim, percentage, properties) {
47453
47666
  return [
47454
- ...(anim.keyframes?.keyframes ?? []).filter((keyframe) => Math.abs(keyframe.percentage - percentage) > KEYFRAME_PCT_MATCH).map((keyframe) => ({
47667
+ ...linearArcKeyframes(anim).filter((keyframe) => Math.abs(keyframe.percentage - percentage) > KEYFRAME_PCT_MATCH).map((keyframe) => ({
47455
47668
  percentage: keyframe.percentage,
47456
47669
  properties: { ...keyframe.properties },
47457
47670
  ...keyframe.ease ? { ease: keyframe.ease } : {}
@@ -47459,6 +47672,35 @@ function buildTemporalArcKeyframes(anim, percentage, properties) {
47459
47672
  { percentage, properties }
47460
47673
  ].sort((a, b) => a.percentage - b.percentage);
47461
47674
  }
47675
+ function linearArcKeyframes(anim) {
47676
+ const runEase = runEaseOf(anim);
47677
+ return (anim.keyframes?.keyframes ?? []).map((keyframe) => ({
47678
+ ...keyframe,
47679
+ percentage: roundTo3(timeAtProgress(runEase, keyframe.percentage))
47680
+ }));
47681
+ }
47682
+ function buildExtendedKeyframes(anim, currentTime, position, sourceDuration = resolveTweenDuration(anim), writeEase = runEaseOf(anim)) {
47683
+ const oldStart = resolveTweenStart(anim) ?? 0;
47684
+ const oldDuration = sourceDuration;
47685
+ const newStart = Math.min(oldStart, currentTime);
47686
+ const newEnd = Math.max(oldStart + oldDuration, currentTime);
47687
+ const newDuration = roundTo3(newEnd - newStart);
47688
+ const oldRunEase = runEaseOf(anim);
47689
+ const toPct = (absoluteTime) => {
47690
+ if (newDuration <= 0) return 0;
47691
+ const progress = progressAtTime(writeEase, (absoluteTime - newStart) / newDuration * 100);
47692
+ return Math.max(0, Math.min(100, Math.round(progress * 10) / 10));
47693
+ };
47694
+ const stops = anim.keyframes?.keyframes ?? [];
47695
+ const rescaled = stops.map((stop2) => ({
47696
+ percentage: toPct(oldStart + timeAtProgress(oldRunEase, stop2.percentage) / 100 * oldDuration),
47697
+ properties: stop2.properties,
47698
+ ...stop2.ease ? { ease: stop2.ease } : {}
47699
+ }));
47700
+ const added = { percentage: toPct(currentTime), properties: position };
47701
+ const keyframes = [...rescaled, added].sort((a, b) => a.percentage - b.percentage);
47702
+ return { position: roundTo3(newStart), duration: newDuration, keyframes };
47703
+ }
47462
47704
  function gsapPositionFromDragOutcome(selection, anim, studioOffset, gsapPos, iframe) {
47463
47705
  if (anim.arcPath?.enabled) return { status: "persisted" };
47464
47706
  const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
@@ -47487,12 +47729,38 @@ async function commitGsapPositionFromDrag(selection, anim, studioOffset, gsapPos
47487
47729
  el.removeAttribute("data-hf-drag-initial-offset-y");
47488
47730
  };
47489
47731
  if (anim.arcPath?.enabled) {
47490
- const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
47732
+ const { activeKeyframePct, currentTime, setActiveKeyframePct } = usePlayerStore.getState();
47733
+ const tweenStart = resolveTweenStart(anim);
47734
+ const tweenDuration = resolveTweenDuration(anim);
47735
+ if (activeKeyframePct === null && tweenStart !== null && !isTimeWithinTween(currentTime, tweenStart, tweenDuration)) {
47736
+ const extended = buildExtendedKeyframes(
47737
+ anim,
47738
+ currentTime,
47739
+ { x: newX, y: newY },
47740
+ tweenDuration,
47741
+ "none"
47742
+ );
47743
+ await callbacks.commitMutation(
47744
+ selection,
47745
+ {
47746
+ type: "replace-with-keyframes",
47747
+ animationId: anim.id,
47748
+ targetSelector: anim.targetSelector,
47749
+ ...extended,
47750
+ ease: "none"
47751
+ },
47752
+ {
47753
+ label: "Move layer (new keyframe)",
47754
+ keyframeAction: "add",
47755
+ softReload: true,
47756
+ beforeReload: restoreOffset
47757
+ }
47758
+ );
47759
+ return { status: "persisted" };
47760
+ }
47491
47761
  const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
47492
47762
  const keyframes = anim.keyframes?.keyframes ?? [];
47493
- const pointIndex = keyframes.findIndex(
47494
- (kf) => Math.abs(kf.percentage - pct) <= KEYFRAME_PCT_MATCH
47495
- );
47763
+ const pointIndex = keyframes.findIndex((kf) => playsNear(anim, kf.percentage, pct));
47496
47764
  if (pointIndex >= 0) {
47497
47765
  await callbacks.commitMutation(
47498
47766
  selection,
@@ -47509,11 +47777,10 @@ async function commitGsapPositionFromDrag(selection, anim, studioOffset, gsapPos
47509
47777
  parkPlayheadOnKeyframe(anim, pct);
47510
47778
  return { status: "persisted" };
47511
47779
  }
47512
- const tweenStart = resolveTweenStart(anim);
47513
- const tweenDuration = resolveEditableTweenDuration(anim, selection);
47514
47780
  if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2)
47515
47781
  return { status: "persisted" };
47516
- const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY });
47782
+ const timePct = roundTo3(timeAtProgress(runEaseOf(anim), pct));
47783
+ const temporalKeyframes = buildTemporalArcKeyframes(anim, timePct, { x: newX, y: newY });
47517
47784
  await callbacks.commitMutation(
47518
47785
  selection,
47519
47786
  {
@@ -48010,19 +48277,7 @@ function useGsapAnimationsForElement(projectId, sourceFile, target, version, ifr
48010
48277
  if (isStaticPositionHold(anim)) continue;
48011
48278
  const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
48012
48279
  if (!kf) continue;
48013
- const tweenPos = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
48014
- const tweenDur = anim.duration ?? elDuration;
48015
- for (const k of kf.keyframes) {
48016
- const absTime = toAbsoluteTime(tweenPos, tweenDur, k.percentage);
48017
- const clipPct = toClipPercentage(absTime, elStart, elDuration, k.percentage);
48018
- allKeyframes.push({
48019
- ...k,
48020
- percentage: clipPct,
48021
- tweenPercentage: k.percentage,
48022
- propertyGroup: anim.propertyGroup,
48023
- animationId: anim.id
48024
- });
48025
- }
48280
+ allKeyframes.push(...toClipKeyframes(kf.keyframes, anim, elStart, elDuration));
48026
48281
  format = kf.format;
48027
48282
  if (kf.ease) ease = kf.ease;
48028
48283
  if (kf.easeEach) easeEach = kf.easeEach;
@@ -48074,11 +48329,11 @@ function usePopulateKeyframeCacheForFile(projectId, sourceFile, version, iframeR
48074
48329
  const files = Array.from(
48075
48330
  /* @__PURE__ */ new Set([sourceFile, ...compositionSrcKey ? compositionSrcKey.split("|") : []])
48076
48331
  );
48077
- const sameData = loadedRef.current.dataKey === dataKey;
48078
- const stale = sameData ? files.filter((sf) => !loadedRef.current.files.has(sf)) : files;
48079
- const covered = sameData ? /* @__PURE__ */ new Set([...loadedRef.current.files, ...files]) : new Set(files);
48332
+ const sameData2 = loadedRef.current.dataKey === dataKey;
48333
+ const stale = sameData2 ? files.filter((sf) => !loadedRef.current.files.has(sf)) : files;
48334
+ const covered = sameData2 ? /* @__PURE__ */ new Set([...loadedRef.current.files, ...files]) : new Set(files);
48080
48335
  loadedRef.current = { dataKey, files: covered };
48081
- if (!sameData) pruneKeyframeCacheToFiles(files);
48336
+ if (!sameData2) pruneKeyframeCacheToFiles(files);
48082
48337
  if (stale.length === 0) return;
48083
48338
  runtimeScanDoneRef.current = "";
48084
48339
  const doc = iframeRef?.current?.contentDocument;
@@ -48155,7 +48410,7 @@ function resolveFlatTweenBoundaryRetime(opts) {
48155
48410
  };
48156
48411
  }
48157
48412
  function resolveKeyframeRetime(opts) {
48158
- const { keyframes, draggedTweenPct, tweenStart, tweenDuration, dropAbsTime } = opts;
48413
+ const { keyframes, draggedTweenPct, tweenStart, tweenDuration, dropAbsTime, runEase } = opts;
48159
48414
  if (tweenDuration <= 0) return { kind: "noop" };
48160
48415
  const tweenEnd = tweenStart + tweenDuration;
48161
48416
  const flatBoundary = resolveFlatTweenBoundaryRetime({
@@ -48167,7 +48422,8 @@ function resolveKeyframeRetime(opts) {
48167
48422
  });
48168
48423
  if (flatBoundary) return flatBoundary;
48169
48424
  if (dropAbsTime >= tweenStart - EPSILON_TIME && dropAbsTime <= tweenEnd + EPSILON_TIME) {
48170
- const toTweenPct = round33(clamp5((dropAbsTime - tweenStart) / tweenDuration * 100, 0, 100));
48425
+ const dropTimePct = clamp5((dropAbsTime - tweenStart) / tweenDuration * 100, 0, 100);
48426
+ const toTweenPct = round33(progressAtTime(runEase, dropTimePct));
48171
48427
  if (Math.abs(toTweenPct - draggedTweenPct) < NOOP_EPSILON_PCT2) return { kind: "noop" };
48172
48428
  return { kind: "move", toTweenPct };
48173
48429
  }
@@ -48185,8 +48441,9 @@ function resolveKeyframeRetime(opts) {
48185
48441
  }
48186
48442
  });
48187
48443
  const pctRemap = keyframes.map((kf, i) => {
48188
- const absTime = i === draggedIdx ? dropAbsTime : tweenStart + kf.percentage / 100 * tweenDuration;
48189
- return { from: kf.percentage, to: round33((absTime - newStart) / newDuration * 100) };
48444
+ const absTime = i === draggedIdx ? dropAbsTime : tweenStart + timeAtProgress(runEase, kf.percentage) / 100 * tweenDuration;
48445
+ const newTimePct = (absTime - newStart) / newDuration * 100;
48446
+ return { from: kf.percentage, to: round33(progressAtTime(runEase, newTimePct)) };
48190
48447
  });
48191
48448
  return {
48192
48449
  kind: "resize",
@@ -48365,14 +48622,15 @@ function useTimelineEditCallbacks({
48365
48622
  elements,
48366
48623
  domClipChildren
48367
48624
  );
48368
- const tweenDuration = resolveTweenDuration(anim, elDuration);
48625
+ const tweenDuration = resolveTweenDuration(anim);
48369
48626
  const dropAbsTime = elStart + toClipPct / 100 * elDuration;
48370
48627
  const decision = resolveKeyframeRetime({
48371
48628
  keyframes: anim.keyframes?.keyframes ?? [],
48372
48629
  draggedTweenPct: target.tweenPct,
48373
48630
  tweenStart,
48374
48631
  tweenDuration,
48375
- dropAbsTime
48632
+ dropAbsTime,
48633
+ runEase: runEaseOf(anim)
48376
48634
  });
48377
48635
  if (decision.kind === "move" && decision.toTweenPct != null) {
48378
48636
  return handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct, sel);
@@ -73362,6 +73620,7 @@ function usePersistentEditHistory({ projectId }) {
73362
73620
  const projectIdRef = useRef125(projectId);
73363
73621
  projectIdRef.current = projectId;
73364
73622
  const [own] = useState111(createOwnHistory);
73623
+ const [pageKeyScope] = useState111(generateId);
73365
73624
  const refresh2 = useCallback100(async () => {
73366
73625
  if (!projectId) return;
73367
73626
  const seen = own.changes();
@@ -73379,6 +73638,10 @@ function usePersistentEditHistory({ projectId }) {
73379
73638
  own.clear();
73380
73639
  void refresh2().finally(() => setLoaded(true));
73381
73640
  }, [refresh2, own]);
73641
+ useEffect111(() => {
73642
+ setStudioPendingEditClaimClock(own.claimCount);
73643
+ return () => setStudioPendingEditClaimClock(null);
73644
+ }, [own]);
73382
73645
  const recordEdit = useCallback100(
73383
73646
  async ({ label, coalesceKey, coalesceMs, files, created = [] }) => {
73384
73647
  if (!projectId) return;
@@ -73388,7 +73651,10 @@ function usePersistentEditHistory({ projectId }) {
73388
73651
  label,
73389
73652
  paths,
73390
73653
  overwrote: await overwroteVersions(files),
73391
- ...coalesceKey && { coalesceKey, idleMs: coalesceMs ?? DEFAULT_COALESCE_MS }
73654
+ ...coalesceKey && {
73655
+ coalesceKey: `${pageKeyScope}:${coalesceKey}`,
73656
+ idleMs: coalesceMs ?? DEFAULT_COALESCE_MS
73657
+ }
73392
73658
  });
73393
73659
  const claimed = claimHeld(reply, label);
73394
73660
  if (claimed) {
@@ -73398,7 +73664,7 @@ function usePersistentEditHistory({ projectId }) {
73398
73664
  heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null;
73399
73665
  await refresh2();
73400
73666
  },
73401
- [projectId, refresh2, own]
73667
+ [projectId, refresh2, own, pageKeyScope]
73402
73668
  );
73403
73669
  const step = useCallback100(
73404
73670
  async (direction, callbacks) => {
@@ -74665,6 +74931,10 @@ function usePreviewPersistence({
74665
74931
  [applyCurrentStudioManualEditsToPreview, previewIframeRef]
74666
74932
  );
74667
74933
  applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
74934
+ const heldPreviewDoc = useCallback106(() => {
74935
+ const doc = previewIframeRef.current?.contentDocument;
74936
+ return doc && isStudioManualEditGestureLiveIn(doc) ? doc : null;
74937
+ }, [previewIframeRef]);
74668
74938
  const syncHistoryPreviewAfterApply = useCallback106(
74669
74939
  async (restore2) => {
74670
74940
  const projectId = usePlayerStore.getState().timelineProjectId;
@@ -74676,6 +74946,15 @@ function usePreviewPersistence({
74676
74946
  (path) => projectId ? readProjectFileContent(projectId, path) : Promise.reject(new Error("No project is open to read nested files from."))
74677
74947
  )
74678
74948
  );
74949
+ const held = heldPreviewDoc();
74950
+ if (held) {
74951
+ const paths = restore2.paths ?? Object.keys(restore2.files ?? {});
74952
+ afterStudioManualEditGestures(held, () => {
74953
+ markScenesStale(previewIframeRef.current, paths);
74954
+ reloadPreview();
74955
+ });
74956
+ return;
74957
+ }
74679
74958
  const strategy = applyUndoRestoreToPreview(
74680
74959
  previewIframeRef.current,
74681
74960
  activeCompPathRef.current,
@@ -74690,21 +74969,23 @@ function usePreviewPersistence({
74690
74969
  }
74691
74970
  syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
74692
74971
  },
74693
- [previewIframeRef, activeCompPathRef, reloadPreview]
74972
+ [previewIframeRef, activeCompPathRef, reloadPreview, heldPreviewDoc]
74694
74973
  );
74695
74974
  const showHistoryRestoreNow = useCallback106(
74696
74975
  (files) => {
74697
- if (!domEditSaveQueueRef.current?.isIdle() || hasStudioPendingEdits()) return null;
74976
+ if (!domEditSaveQueueRef.current?.isIdle() || hasStudioPendingEdits() || heldPreviewDoc())
74977
+ return null;
74698
74978
  const iframe = previewIframeRef.current;
74699
74979
  const now2 = () => usePlayerStore.getState().currentTime;
74700
74980
  const putBack = showRestoreInPlace(iframe, activeCompPathRef.current, files, now2());
74701
74981
  if (!putBack) return null;
74702
74982
  return () => {
74703
- if (putBack(now2())) syncStoredAutomationFromPreview(iframe?.contentDocument ?? null);
74983
+ if (!heldPreviewDoc() && putBack(now2()))
74984
+ syncStoredAutomationFromPreview(iframe?.contentDocument ?? null);
74704
74985
  else void syncHistoryPreviewAfterApply({ paths: Object.keys(files) });
74705
74986
  };
74706
74987
  },
74707
- [previewIframeRef, activeCompPathRef, syncHistoryPreviewAfterApply]
74988
+ [previewIframeRef, activeCompPathRef, syncHistoryPreviewAfterApply, heldPreviewDoc]
74708
74989
  );
74709
74990
  useMountEffect(() => {
74710
74991
  void clearLegacyStudioMotionFile(_readOptionalProjectFile, _writeProjectFile);
@@ -74750,7 +75031,7 @@ function usePreviewDocumentVersion() {
74750
75031
  }
74751
75032
 
74752
75033
  // src/hooks/useTimelineEditing.ts
74753
- import { useCallback as useCallback123, useRef as useRef139 } from "react";
75034
+ import { useRef as useRef139 } from "react";
74754
75035
 
74755
75036
  // src/hooks/useRazorSplit.ts
74756
75037
  import { useCallback as useCallback109, useRef as useRef133 } from "react";
@@ -76050,7 +76331,6 @@ function applyRippleShifts(survivors, changes) {
76050
76331
  return newStart != null ? { ...te, start: newStart } : te;
76051
76332
  });
76052
76333
  }
76053
- var deleteGestureSeq = 0;
76054
76334
  function unlinkInSource(source, survivors) {
76055
76335
  const targets = survivors.map(buildPatchTarget).filter((target) => target !== null);
76056
76336
  return targets.length > 0 ? setLinkInSource(source, targets, null) : source;
@@ -76086,7 +76366,7 @@ function useTimelineDeleteOps({
76086
76366
  (candidate) => (candidate.sourceFile || activeCompPath || "index.html") === targetPath
76087
76367
  );
76088
76368
  try {
76089
- const coalesceKey = `main-track-ripple-delete:${deleteGestureSeq++}`;
76369
+ const step = timingGestureStep("main-track-ripple-delete");
76090
76370
  const deleteHistoryLabel = "Delete timeline clip";
76091
76371
  let rollbackDuration = () => {
76092
76372
  };
@@ -76095,7 +76375,7 @@ function useTimelineDeleteOps({
76095
76375
  projectId: pid,
76096
76376
  path: targetPath,
76097
76377
  label: deleteHistoryLabel,
76098
- coalesceKey,
76378
+ ...step,
76099
76379
  writeFile: writeProjectFile,
76100
76380
  recordEdit,
76101
76381
  rewrite: async (originalContent) => {
@@ -76152,8 +76432,7 @@ function useTimelineDeleteOps({
76152
76432
  if (rippleChanges) {
76153
76433
  try {
76154
76434
  await handleTimelineGroupMove(rippleChanges, {
76155
- coalesceKey,
76156
- coalesceMs: Number.POSITIVE_INFINITY,
76435
+ coalesceKey: step.coalesceKey,
76157
76436
  // Coalescing keeps the LAST entry's label; without this the undo
76158
76437
  // toast reads "Undid Move timeline clips" after a delete, naming
76159
76438
  // the ripple's mechanics instead of what the user actually did.
@@ -76281,7 +76560,7 @@ function planSyncEdit(edit) {
76281
76560
  property: "start",
76282
76561
  value: value2
76283
76562
  });
76284
- return setCompositionDurationToContent(moved, furthestClipEndFromSource(moved));
76563
+ return syncCompositionDurationToContent(moved);
76285
76564
  }
76286
76565
  };
76287
76566
  }
@@ -76925,12 +77204,6 @@ function allChangesSharePath(changes, activeCompPath) {
76925
77204
  if (!firstPath) return null;
76926
77205
  return changes.every((change) => targetPathFor(change.element, activeCompPath) === firstPath) ? firstPath : null;
76927
77206
  }
76928
- function moveCoalesceKey(changes) {
76929
- return `timeline-group-move:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
76930
- }
76931
- function resizeCoalesceKey(changes) {
76932
- return `timeline-group-resize:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
76933
- }
76934
77207
  function toSdkTimingChanges(changes, timingUpdate) {
76935
77208
  return changes.map(
76936
77209
  (change) => change.element.hfId ? { hfId: change.element.hfId, timingUpdate: timingUpdate(change) } : null
@@ -77053,8 +77326,7 @@ function useTimelineGroupEditing({
77053
77326
  const rollbackDuration = captureDurationRollback(previewIframeRef.current);
77054
77327
  const needsExtension = extendRootDurationIfNeeded(maxEnd);
77055
77328
  syncPreviewContentDuration(previewIframeRef.current);
77056
- const coalesceKey = options?.coalesceKey ?? moveCoalesceKey(changes);
77057
- const coalesceMs = options?.coalesceMs;
77329
+ const { coalesceKey, coalesceMs } = timingGestureStep("timeline-group-move", options);
77058
77330
  const label = options?.label ?? "Move timeline clips";
77059
77331
  return enqueueGroupOperation(label, async (projectId) => {
77060
77332
  await options?.beforeTiming;
@@ -77077,7 +77349,7 @@ function useTimelineGroupEditing({
77077
77349
  label,
77078
77350
  changes.map((change) => ({
77079
77351
  element: change.element,
77080
- buildPatches: (original, target) => buildTimelineMoveTimingPatch(
77352
+ buildPatches: (original, target) => applyTimelineMoveAttributes(
77081
77353
  original,
77082
77354
  target,
77083
77355
  toAuthoredStart(change.element, change.start),
@@ -77105,12 +77377,7 @@ function useTimelineGroupEditing({
77105
77377
  changes,
77106
77378
  sdkGsap: sdk?.sdkGsap,
77107
77379
  resolveChangePath: (element) => targetPathFor(element, activeCompPath),
77108
- mutateChange: (change, changePath) => {
77109
- const delta = change.start - change.element.start;
77110
- const domId = change.element.domId;
77111
- if (delta === 0 || !domId) return null;
77112
- return shiftGsapPositions(projectId, changePath, domId, delta);
77113
- }
77380
+ mutationFor: (change) => shiftGsapMutation(change.element.domId, change.start - change.element.start)
77114
77381
  });
77115
77382
  } finally {
77116
77383
  invalidateGsapCache?.();
@@ -77159,8 +77426,7 @@ function useTimelineGroupEditing({
77159
77426
  const rollbackDuration = captureDurationRollback(previewIframeRef.current);
77160
77427
  const needsExtension = extendRootDurationIfNeeded(maxEnd);
77161
77428
  syncPreviewContentDuration(previewIframeRef.current);
77162
- const coalesceKey = options?.coalesceKey ?? resizeCoalesceKey(changes);
77163
- const coalesceMs = options?.coalesceMs;
77429
+ const { coalesceKey, coalesceMs } = timingGestureStep("timeline-group-resize", options);
77164
77430
  return enqueueGroupOperation("Resize timeline clips", async (projectId) => {
77165
77431
  await options?.beforeTiming;
77166
77432
  const sdk = await trySdkBatchPersist({
@@ -77181,7 +77447,7 @@ function useTimelineGroupEditing({
77181
77447
  "Resize timeline clips",
77182
77448
  changes.map((change) => ({
77183
77449
  element: change.element,
77184
- buildPatches: (original, target) => buildTimelineResizeTimingPatch(original, target, change.element, {
77450
+ buildPatches: (original, target) => applyTimelineResizeAttributes(original, target, change.element, {
77185
77451
  start: change.start,
77186
77452
  duration: change.duration,
77187
77453
  playbackStart: change.playbackStart
@@ -77205,20 +77471,13 @@ function useTimelineGroupEditing({
77205
77471
  changes,
77206
77472
  sdkGsap: sdk?.sdkGsap,
77207
77473
  resolveChangePath: (element) => targetPathFor(element, activeCompPath),
77208
- mutateChange: (change, changePath) => {
77209
- const domId = change.element.domId;
77210
- const timingChanged = change.start !== change.element.start || change.duration !== change.element.duration;
77211
- if (!timingChanged || !domId) return null;
77212
- return scaleGsapPositions(
77213
- projectId,
77214
- changePath,
77215
- domId,
77216
- toCompositionTime(change.element, change.element.start),
77217
- change.element.duration,
77218
- toCompositionTime(change.element, change.start),
77219
- change.duration
77220
- );
77221
- }
77474
+ mutationFor: (change) => scaleGsapMutation(
77475
+ change.element.domId,
77476
+ toCompositionTime(change.element, change.element.start),
77477
+ change.element.duration,
77478
+ toCompositionTime(change.element, change.start),
77479
+ change.duration
77480
+ )
77222
77481
  });
77223
77482
  } finally {
77224
77483
  invalidateGsapCache?.();
@@ -77245,54 +77504,28 @@ function useTimelineGroupEditing({
77245
77504
  return { handleTimelineGroupMove, handleTimelineGroupResize };
77246
77505
  }
77247
77506
 
77248
- // src/hooks/useBlockedTimelineEditToast.ts
77249
- import { useCallback as useCallback122, useRef as useRef138 } from "react";
77250
- var BLOCKED_TOAST_INTERVAL_MS = 1500;
77251
- function useBlockedTimelineEditToast(showToast) {
77252
- const lastAtRef = useRef138(0);
77253
- return useCallback122(
77254
- (_element, intent) => {
77255
- const now2 = Date.now();
77256
- if (now2 - lastAtRef.current < BLOCKED_TOAST_INTERVAL_MS) return;
77257
- lastAtRef.current = now2;
77258
- showToast(
77259
- intent === "edit-many" ? `Select ${MAX_HAND_EDIT_CLIPS} or fewer clips to move or resize by hand, or describe the change.` : "This clip can't be moved or resized from the timeline yet.",
77260
- "info"
77261
- );
77262
- },
77263
- [showToast]
77264
- );
77265
- }
77266
-
77267
- // src/hooks/useTimelineEditing.ts
77268
- function useTimelineEditing({
77269
- projectId,
77507
+ // src/hooks/useTimelineClipTimingEditing.ts
77508
+ import { useCallback as useCallback122 } from "react";
77509
+ function useTimelineClipTimingEditing({
77270
77510
  activeCompPath,
77271
77511
  timelineElements,
77272
77512
  showToast,
77273
77513
  writeProjectFile,
77274
- observeProjectFileVersion,
77275
77514
  recordEdit,
77276
77515
  reloadPreview,
77277
77516
  previewIframeRef,
77278
77517
  pendingTimelineEditPathRef,
77279
- uploadProjectFiles,
77280
77518
  isRecordingRef,
77281
77519
  sdkSession,
77282
77520
  publishSdkSession,
77283
77521
  forceReloadSdkSession,
77284
77522
  invalidateGsapCache,
77285
77523
  handleDomZIndexReorderCommitRef,
77286
- canEdit
77524
+ editQueueRef,
77525
+ projectIdRef
77287
77526
  }) {
77288
- const projectIdRef = useRef139(projectId);
77289
- projectIdRef.current = projectId;
77290
- const editQueueRef = useRef139(Promise.resolve());
77291
- const track2 = useTrackPendingTimelineEdit();
77292
- const checkEditable = useTimelineEditGate(canEdit, showToast);
77293
- const guard = useTimelineEditGuard(canEdit, showToast);
77294
- const enqueueEdit = useCallback123(
77295
- (element, label, buildPatches, coalesceKey) => {
77527
+ const enqueueEdit = useCallback122(
77528
+ (element, label, buildPatches, step) => {
77296
77529
  if (isRecordingRef?.current) {
77297
77530
  showToast("Cannot edit timeline while recording", "error");
77298
77531
  return Promise.resolve();
@@ -77309,7 +77542,7 @@ function useTimelineEditing({
77309
77542
  writeProjectFile,
77310
77543
  recordEdit,
77311
77544
  pendingTimelineEditPathRef,
77312
- coalesceKey
77545
+ ...step
77313
77546
  })
77314
77547
  ).then(() => {
77315
77548
  forceReloadSdkSession?.();
@@ -77326,26 +77559,12 @@ function useTimelineEditing({
77326
77559
  pendingTimelineEditPathRef,
77327
77560
  showToast,
77328
77561
  isRecordingRef,
77329
- forceReloadSdkSession
77562
+ forceReloadSdkSession,
77563
+ editQueueRef,
77564
+ projectIdRef
77330
77565
  ]
77331
77566
  );
77332
- const groupEditing = useTimelineGroupEditing({
77333
- activeCompPath,
77334
- editQueueRef,
77335
- forceReloadSdkSession,
77336
- invalidateGsapCache,
77337
- isRecordingRef,
77338
- pendingTimelineEditPathRef,
77339
- previewIframeRef,
77340
- projectIdRef,
77341
- recordEdit,
77342
- reloadPreview,
77343
- sdkSession,
77344
- publishSdkSession,
77345
- showToast,
77346
- writeProjectFile
77347
- });
77348
- const handleTimelineElementMove = useCallback123(
77567
+ const handleTimelineElementMove = useCallback122(
77349
77568
  // fallow-ignore-next-line complexity
77350
77569
  (element, updates) => {
77351
77570
  const commitMove = () => {
@@ -77375,16 +77594,16 @@ function useTimelineEditing({
77375
77594
  const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
77376
77595
  syncPreviewContentDuration(previewIframeRef.current);
77377
77596
  const buildMovePatches = (original, target) => {
77378
- const track3 = trackChanged ? updates.track : void 0;
77597
+ const track2 = trackChanged ? updates.track : void 0;
77379
77598
  return buildTimelineMoveTimingPatch(
77380
77599
  original,
77381
77600
  target,
77382
77601
  authoredStart,
77383
77602
  element.duration,
77384
- track3
77603
+ track2
77385
77604
  );
77386
77605
  };
77387
- const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
77606
+ const step = timingGestureStep("timeline-move");
77388
77607
  const finishMoveGsapSync = (sdkGsap) => (
77389
77608
  // One GSAP sync per edit: the SDK commit's own (sdkGsap), else the server rewrite here.
77390
77609
  finishClipTimingFallback({
@@ -77394,14 +77613,14 @@ function useTimelineEditing({
77394
77613
  targetPath,
77395
77614
  domId: element.domId,
77396
77615
  label: "Move timeline clip",
77397
- coalesceKey,
77616
+ coalesceKey: step.coalesceKey,
77398
77617
  recordEdit,
77399
77618
  writeProjectFile,
77400
77619
  edit: { kind: "shift", delta: updates.start - element.start },
77401
77620
  sdkGsap
77402
77621
  }).finally(() => invalidateGsapCache?.())
77403
77622
  );
77404
- const moveFallback = () => enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(
77623
+ const moveFallback = () => enqueueEdit(element, "Move timeline clip", buildMovePatches, step).then(
77405
77624
  () => finishMoveGsapSync()
77406
77625
  );
77407
77626
  return reorderDone.then(() => {
@@ -77421,7 +77640,7 @@ function useTimelineEditing({
77421
77640
  readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
77422
77641
  publishSession: publishSdkSession
77423
77642
  },
77424
- { label: "Move timeline clip", coalesceKey, skipRefresh: true }
77643
+ { label: "Move timeline clip", ...step, skipRefresh: true }
77425
77644
  ).then((result) => {
77426
77645
  if (!cutoverCommittedOrThrow(result)) return moveFallback();
77427
77646
  return finishMoveGsapSync(sdkTimingGsapSync(result));
@@ -77448,10 +77667,11 @@ function useTimelineEditing({
77448
77667
  timelineElements,
77449
77668
  handleDomZIndexReorderCommitRef,
77450
77669
  showToast,
77451
- invalidateGsapCache
77670
+ invalidateGsapCache,
77671
+ projectIdRef
77452
77672
  ]
77453
77673
  );
77454
- const handleTimelineElementResize = useCallback123(
77674
+ const handleTimelineElementResize = useCallback122(
77455
77675
  // fallow-ignore-next-line complexity
77456
77676
  (element, updates) => {
77457
77677
  const authoredStart = toAuthoredStart(element, updates.start);
@@ -77472,7 +77692,7 @@ function useTimelineEditing({
77472
77692
  return buildTimelineResizeTimingPatch(original, target, element, updates);
77473
77693
  };
77474
77694
  const hasPbsAdjustment = updates.playbackStart != null || updates.start !== element.start && element.playbackStart != null;
77475
- const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
77695
+ const step = timingGestureStep("timeline-resize");
77476
77696
  const finishResizeGsapSync = (sdkGsap) => finishClipTimingFallback({
77477
77697
  iframe: previewIframeRef.current,
77478
77698
  reloadPreview,
@@ -77480,7 +77700,7 @@ function useTimelineEditing({
77480
77700
  targetPath,
77481
77701
  domId: element.domId,
77482
77702
  label: "Resize timeline clip",
77483
- coalesceKey,
77703
+ coalesceKey: step.coalesceKey,
77484
77704
  recordEdit,
77485
77705
  writeProjectFile,
77486
77706
  edit: {
@@ -77490,7 +77710,7 @@ function useTimelineEditing({
77490
77710
  },
77491
77711
  sdkGsap
77492
77712
  }).finally(() => invalidateGsapCache?.());
77493
- const resizeFallback = () => enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(
77713
+ const resizeFallback = () => enqueueEdit(element, "Resize timeline clip", buildResizePatches, step).then(
77494
77714
  () => finishResizeGsapSync()
77495
77715
  );
77496
77716
  const persistDone = sdkSession && element.hfId && !element.link && !hasPbsAdjustment && !needsExtension ? sdkTimingPersist(
@@ -77508,7 +77728,7 @@ function useTimelineEditing({
77508
77728
  readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
77509
77729
  publishSession: publishSdkSession
77510
77730
  },
77511
- { label: "Resize timeline clip", coalesceKey, skipRefresh: true }
77731
+ { label: "Resize timeline clip", ...step, skipRefresh: true }
77512
77732
  ).then((result) => {
77513
77733
  if (!cutoverCommittedOrThrow(result)) return resizeFallback();
77514
77734
  return finishResizeGsapSync(sdkTimingGsapSync(result));
@@ -77529,9 +77749,90 @@ function useTimelineEditing({
77529
77749
  writeProjectFile,
77530
77750
  reloadPreview,
77531
77751
  showToast,
77532
- invalidateGsapCache
77752
+ invalidateGsapCache,
77753
+ projectIdRef
77533
77754
  ]
77534
77755
  );
77756
+ return { handleTimelineElementMove, handleTimelineElementResize };
77757
+ }
77758
+
77759
+ // src/hooks/useBlockedTimelineEditToast.ts
77760
+ import { useCallback as useCallback123, useRef as useRef138 } from "react";
77761
+ var BLOCKED_TOAST_INTERVAL_MS = 1500;
77762
+ function useBlockedTimelineEditToast(showToast) {
77763
+ const lastAtRef = useRef138(0);
77764
+ return useCallback123(
77765
+ (_element) => {
77766
+ const now2 = Date.now();
77767
+ if (now2 - lastAtRef.current < BLOCKED_TOAST_INTERVAL_MS) return;
77768
+ lastAtRef.current = now2;
77769
+ showToast("This clip can't be moved or resized from the timeline yet.", "info");
77770
+ },
77771
+ [showToast]
77772
+ );
77773
+ }
77774
+
77775
+ // src/hooks/useTimelineEditing.ts
77776
+ function useTimelineEditing({
77777
+ projectId,
77778
+ activeCompPath,
77779
+ timelineElements,
77780
+ showToast,
77781
+ writeProjectFile,
77782
+ observeProjectFileVersion,
77783
+ recordEdit,
77784
+ reloadPreview,
77785
+ previewIframeRef,
77786
+ pendingTimelineEditPathRef,
77787
+ uploadProjectFiles,
77788
+ isRecordingRef,
77789
+ sdkSession,
77790
+ publishSdkSession,
77791
+ forceReloadSdkSession,
77792
+ invalidateGsapCache,
77793
+ handleDomZIndexReorderCommitRef,
77794
+ canEdit
77795
+ }) {
77796
+ const projectIdRef = useRef139(projectId);
77797
+ projectIdRef.current = projectId;
77798
+ const editQueueRef = useRef139(Promise.resolve());
77799
+ const track2 = useTrackPendingTimelineEdit();
77800
+ const checkEditable = useTimelineEditGate(canEdit, showToast);
77801
+ const guard = useTimelineEditGuard(canEdit, showToast);
77802
+ const groupEditing = useTimelineGroupEditing({
77803
+ activeCompPath,
77804
+ editQueueRef,
77805
+ forceReloadSdkSession,
77806
+ invalidateGsapCache,
77807
+ isRecordingRef,
77808
+ pendingTimelineEditPathRef,
77809
+ previewIframeRef,
77810
+ projectIdRef,
77811
+ recordEdit,
77812
+ reloadPreview,
77813
+ sdkSession,
77814
+ publishSdkSession,
77815
+ showToast,
77816
+ writeProjectFile
77817
+ });
77818
+ const { handleTimelineElementMove, handleTimelineElementResize } = useTimelineClipTimingEditing({
77819
+ activeCompPath,
77820
+ timelineElements,
77821
+ showToast,
77822
+ writeProjectFile,
77823
+ recordEdit,
77824
+ reloadPreview,
77825
+ previewIframeRef,
77826
+ pendingTimelineEditPathRef,
77827
+ isRecordingRef,
77828
+ sdkSession,
77829
+ publishSdkSession,
77830
+ forceReloadSdkSession,
77831
+ invalidateGsapCache,
77832
+ handleDomZIndexReorderCommitRef,
77833
+ editQueueRef,
77834
+ projectIdRef
77835
+ });
77535
77836
  const handleToggleTrackHidden = useTimelineTrackVisibilityEditing({
77536
77837
  projectIdRef,
77537
77838
  activeCompPath,
@@ -77753,7 +78054,7 @@ function useTimelineEditing({
77753
78054
 
77754
78055
  // src/hooks/timelineMoveAdapter.ts
77755
78056
  import { useCallback as useCallback124 } from "react";
77756
- function persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs) {
78057
+ function persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps) {
77757
78058
  return deps.handleTimelineGroupMove(
77758
78059
  edits.map(({ element, updates }) => ({
77759
78060
  element,
@@ -77765,14 +78066,14 @@ function persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps,
77765
78066
  // ("timing") omit it so they stay eligible for the SDK fast path.
77766
78067
  track: operation === "timing" ? void 0 : updates.track
77767
78068
  })),
77768
- { coalesceKey, coalesceMs }
78069
+ { coalesceKey }
77769
78070
  );
77770
78071
  }
77771
78072
  function useTimelineMoveEditsHandler(handleTimelineGroupMove) {
77772
78073
  return useCallback124(
77773
- async (edits, coalesceKey, operation = "timing", coalesceMs) => {
78074
+ async (edits, coalesceKey, operation = "timing") => {
77774
78075
  const deps = { handleTimelineGroupMove };
77775
- await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs);
78076
+ await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps);
77776
78077
  },
77777
78078
  [handleTimelineGroupMove]
77778
78079
  );
@@ -77902,6 +78203,9 @@ function domEditSelectionsTargetSame(a, b) {
77902
78203
  if (!a || !b) return false;
77903
78204
  return getDomEditTargetKey(a) === getDomEditTargetKey(b);
77904
78205
  }
78206
+ function domEditSelectionsEqual(a, b) {
78207
+ return sameData(a, b);
78208
+ }
77905
78209
  function domEditSelectionInGroup(group, selection) {
77906
78210
  if (!selection) return false;
77907
78211
  return group.some((entry2) => domEditSelectionsTargetSame(entry2, selection));
@@ -78168,6 +78472,8 @@ function useDomSelection({
78168
78472
  const currentSelection = domEditSelectionRef.current;
78169
78473
  const previousGroup = domEditGroupSelectionsRef.current;
78170
78474
  const isRepeatedSingleSelection = !isAdditiveSelection && !options?.preserveGroup && previousGroup.length === 1 && domEditSelectionsTargetSame(currentSelection, selection) && domEditSelectionsTargetSame(previousGroup[0], selection);
78475
+ const isUnchangedRefresh = options?.preserveGroup && currentSelection !== null && (previousGroup.length === 0 || previousGroup.includes(currentSelection)) && domEditSelectionsEqual(currentSelection, selection);
78476
+ if (isUnchangedRefresh) return;
78171
78477
  if (isRepeatedSingleSelection) {
78172
78478
  if (options?.revealPanel !== false) {
78173
78479
  setRightCollapsed(false);
@@ -78229,6 +78535,7 @@ function useDomSelection({
78229
78535
  preferClipAncestor: options?.preferClipAncestor,
78230
78536
  skipSourceProbe: options?.skipSourceProbe,
78231
78537
  exactTarget: options?.exactTarget,
78538
+ previous: options?.previous,
78232
78539
  activeGroupElement: options && "activeGroupElement" in options ? options.activeGroupElement : activeGroupElementRef.current,
78233
78540
  projectId
78234
78541
  });
@@ -78351,7 +78658,7 @@ function useDomSelection({
78351
78658
  applyDomSelection(null, { revealPanel: false });
78352
78659
  return;
78353
78660
  }
78354
- const nextSelection = await buildDomSelectionFromTarget(element);
78661
+ const nextSelection = await buildDomSelectionFromTarget(element, { previous: selection });
78355
78662
  if (nextSelection) {
78356
78663
  applyDomSelection(nextSelection, {
78357
78664
  revealPanel: false,
@@ -78376,7 +78683,7 @@ function useDomSelection({
78376
78683
  for (const selection of selections) {
78377
78684
  const element = findElementForSelection(doc, selection, activeCompPath);
78378
78685
  if (!element) continue;
78379
- const nextSelection2 = await buildDomSelectionFromTarget(element);
78686
+ const nextSelection2 = await buildDomSelectionFromTarget(element, { previous: selection });
78380
78687
  if (nextSelection2) nextGroup.push(nextSelection2);
78381
78688
  }
78382
78689
  if (nextGroup.length === 0) return;
@@ -82052,7 +82359,9 @@ function useDomEditPreviewSync({
82052
82359
  applyDomSelection(null, { revealPanel: false });
82053
82360
  return;
82054
82361
  }
82055
- const nextSelection = await buildDomSelectionFromTarget(nextElement);
82362
+ const nextSelection = await buildDomSelectionFromTarget(nextElement, {
82363
+ previous: currentSelection
82364
+ });
82056
82365
  if (nextSelection) {
82057
82366
  applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
82058
82367
  }
@@ -83223,19 +83532,17 @@ async function commitKeyframeProps(selection, anim, props, propEntries, primaryP
83223
83532
  const newStart = Math.min(ct, ts);
83224
83533
  const newEnd = Math.max(ct, ts + td);
83225
83534
  const newDuration = Math.max(0.01, newEnd - newStart);
83535
+ const runEase = runEaseOf(anim);
83536
+ const toNewPct = (absTime) => Math.round(progressAtTime(runEase, (absTime - newStart) / newDuration * 100) * 10) / 10;
83226
83537
  const remapped = kfs.map((kf) => {
83227
- const absTime = ts + kf.percentage / 100 * td;
83228
- const newPct = Math.round((absTime - newStart) / newDuration * 1e3) / 10;
83538
+ const newPct = toNewPct(ts + timeAtProgress(runEase, kf.percentage) / 100 * td);
83229
83539
  const p = { ...kf.properties };
83230
83540
  for (const k of Object.keys(properties)) {
83231
83541
  if (!(k in p) && backfillDefaults[k] != null) p[k] = backfillDefaults[k];
83232
83542
  }
83233
83543
  return { percentage: newPct, properties: p };
83234
83544
  });
83235
- remapped.push({
83236
- percentage: Math.round((ct - newStart) / newDuration * 1e3) / 10,
83237
- properties
83238
- });
83545
+ remapped.push({ percentage: toNewPct(ct), properties });
83239
83546
  remapped.sort((a, b) => a.percentage - b.percentage);
83240
83547
  await commit2(
83241
83548
  selection,
@@ -83924,6 +84231,19 @@ function runCommitPreflights(selection, fileAnimations, iframe, group) {
83924
84231
  };
83925
84232
  }
83926
84233
  var resolvedSelections = /* @__PURE__ */ new WeakMap();
84234
+ var checkedSelections = /* @__PURE__ */ new WeakMap();
84235
+ function checkedPreflight(resolved2, inputs) {
84236
+ const known = checkedSelections.get(resolved2);
84237
+ const same = known?.animations === inputs.animations && known.iframe === inputs.iframe && known.group === inputs.group && known.version === inputs.version;
84238
+ if (same) return known;
84239
+ const { animations, iframe, group } = inputs;
84240
+ const checked = {
84241
+ ...inputs,
84242
+ preflight: runCommitPreflights(resolved2, animations, iframe, group)
84243
+ };
84244
+ checkedSelections.set(resolved2, checked);
84245
+ return checked;
84246
+ }
83927
84247
  var resolvedOf = (selection) => resolvedSelections.get(selection) ?? selection;
83928
84248
  var MANUAL_FLAGS = [
83929
84249
  ["canApplyManualOffset", "offset"],
@@ -83986,17 +84306,21 @@ function useCommitPreflightCapabilities({
83986
84306
  void parseTick;
83987
84307
  if (!enabled || !projectId) return { selection, groupSelections };
83988
84308
  const group = groupSelections.length > 1;
84309
+ const iframe = previewIframeRef.current;
83989
84310
  const narrow = (target) => {
83990
84311
  const file = gsapSourceFileForSelection(target);
83991
84312
  const animations = parsesRef.current.get(parseCacheKey(projectId, file))?.animations;
83992
- const preflight = animations ? runCommitPreflights(resolvedOf(target), animations, previewIframeRef.current, group) : null;
83993
- return narrowCapabilities(target, preflight);
84313
+ if (!animations) return narrowCapabilities(target, null);
84314
+ const inputs = { animations, iframe, group, version };
84315
+ const checked = checkedPreflight(resolvedOf(target), inputs);
84316
+ checked.narrowed ??= narrowCapabilities(target, checked.preflight);
84317
+ return checked.narrowed;
83994
84318
  };
83995
- return {
84319
+ return withTweenIndex(() => ({
83996
84320
  selection: selection && narrow(selection),
83997
84321
  groupSelections: groupSelections.map(narrow)
83998
- };
83999
- }, [enabled, projectId, selection, groupSelections, parseTick, previewIframeRef]);
84322
+ }));
84323
+ }, [enabled, projectId, selection, groupSelections, parseTick, previewIframeRef, version]);
84000
84324
  }
84001
84325
 
84002
84326
  // src/hooks/domEditDeleteMembers.ts
@@ -85956,11 +86280,11 @@ function useEditHistoryActions({
85956
86280
  const apply = useCallback153(
85957
86281
  async (direction) => {
85958
86282
  const noun = direction === "undo" ? "Undo" : "Redo";
85959
- const predicted = editHistory.predict?.(direction) ?? null;
86283
+ const pendingEditShown = direction === "undo" ? paintBackNewestStudioPendingEdit() : null;
86284
+ const predicted = pendingEditShown ? null : editHistory.predict?.(direction) ?? null;
85960
86285
  const predictedShown = predicted ? showHistoryRestoreNow?.(predicted.files) ?? null : null;
85961
- const pendingEditShown = !predictedShown && direction === "undo" ? paintBackNewestStudioPendingEdit() : null;
85962
86286
  const putBack = predictedShown ?? pendingEditShown?.showAgain;
85963
- const claimedAfter = direction === "undo" && hasStudioPendingEdits() ? editHistory.claims?.() : void 0;
86287
+ const claimedAfter = pendingEditShown?.claimsAtBegin ?? (direction === "undo" && hasStudioPendingEdits() ? editHistory.claims?.() : void 0);
85964
86288
  let result = { ok: false, reason: "failed" };
85965
86289
  let serverSteppedShown = false;
85966
86290
  let revertIsTheUndo = false;
@@ -85974,7 +86298,8 @@ function useEditHistoryActions({
85974
86298
  claimedAfter
85975
86299
  });
85976
86300
  const stepped = Boolean(result.ok && result.label);
85977
- serverSteppedShown = predictedShown ? stepped && result.undoes === predicted?.id : stepped && Boolean(pendingEditShown);
86301
+ const editClaimed = pendingEditShown !== null && (editHistory.claims?.() ?? 0) > pendingEditShown.claimsAtBegin;
86302
+ serverSteppedShown = predictedShown ? stepped && result.undoes === predicted?.id : stepped && editClaimed;
85978
86303
  } finally {
85979
86304
  if (putBack && !serverSteppedShown && !revertIsTheUndo) putBack();
85980
86305
  }
@@ -88972,7 +89297,7 @@ function useGestureCommit({
88972
89297
  );
88973
89298
  } else {
88974
89299
  const tweenStart = existingPositionTween.resolvedStart ?? 0;
88975
- const tweenDur = existingPositionTween.duration ?? duration;
89300
+ const tweenDur = resolveTweenDuration(existingPositionTween);
88976
89301
  const tweenEnd = tweenStart + tweenDur;
88977
89302
  const recEnd = recStart + duration;
88978
89303
  const overlaps3 = recStart < tweenEnd + 0.05 && recEnd > tweenStart - 0.05;
@@ -96737,31 +97062,10 @@ function animatedProps(anim) {
96737
97062
  }
96738
97063
  return ["x", "y"];
96739
97064
  }
96740
- function isPlayheadWithinTween(anim, currentTime, selection) {
97065
+ function isPlayheadWithinTween(anim, currentTime) {
96741
97066
  const start = resolveTweenStart(anim);
96742
97067
  if (start === null) return true;
96743
- const duration = selection ? resolveEditableTweenDuration(anim, selection) : resolveTweenDuration(anim);
96744
- return isTimeWithinTween(currentTime, start, duration);
96745
- }
96746
- function buildExtendedKeyframes(anim, currentTime, position, sourceDuration = resolveTweenDuration(anim)) {
96747
- const oldStart = resolveTweenStart(anim) ?? 0;
96748
- const oldDuration = sourceDuration;
96749
- const newStart = Math.min(oldStart, currentTime);
96750
- const newEnd = Math.max(oldStart + oldDuration, currentTime);
96751
- const newDuration = roundTo3(newEnd - newStart);
96752
- const toPct = (absoluteTime) => newDuration > 0 ? Math.max(
96753
- 0,
96754
- Math.min(100, Math.round((absoluteTime - newStart) / newDuration * 1e3) / 10)
96755
- ) : 0;
96756
- const stops = anim.keyframes?.keyframes ?? [];
96757
- const rescaled = stops.map((stop2) => ({
96758
- percentage: toPct(oldStart + stop2.percentage / 100 * oldDuration),
96759
- properties: stop2.properties,
96760
- ...stop2.ease ? { ease: stop2.ease } : {}
96761
- }));
96762
- const added = { percentage: toPct(currentTime), properties: position };
96763
- const keyframes = [...rescaled, added].sort((a, b) => a.percentage - b.percentage);
96764
- return { position: roundTo3(newStart), duration: newDuration, keyframes };
97068
+ return isTimeWithinTween(currentTime, start, resolveTweenDuration(anim));
96765
97069
  }
96766
97070
  async function replaceSetWithSingleKeyframe(session, sel, setAnim, t2, iframe, selector) {
96767
97071
  const position = readElementPosition(iframe, sel, setAnim);
@@ -96852,10 +97156,16 @@ async function extendKeyframedTweenToPlayhead(session, sel, anim, currentTime, d
96852
97156
  }
96853
97157
  );
96854
97158
  }
96855
- async function applyKeyframeAtPlayhead(session, sel, kfAnim, t2, iframe, commitOverrides) {
96856
- const duration = resolveEditableTweenDuration(kfAnim, sel);
97159
+ async function applyKeyframeAtPlayhead(session, sel, kfAnim, t2, iframe, commitOverrides, liveTween) {
97160
+ const keepAuthoredDuration = kfAnim.durationUnresolved === true;
97161
+ if (keepAuthoredDuration) {
97162
+ kfAnim = withLiveTiming(kfAnim, liveTween ?? findParsedTween(iframe, sel.element, kfAnim));
97163
+ }
97164
+ if (keepAuthoredDuration && kfAnim.duration == null) return;
97165
+ const duration = resolveTweenDuration(kfAnim);
96857
97166
  const start = resolveTweenStart(kfAnim);
96858
97167
  if (start !== null && !isTimeWithinTween(t2, start, duration)) {
97168
+ if (keepAuthoredDuration) return;
96859
97169
  await extendKeyframedTweenToPlayhead(
96860
97170
  session,
96861
97171
  sel,
@@ -96867,10 +97177,8 @@ async function applyKeyframeAtPlayhead(session, sel, kfAnim, t2, iframe, commitO
96867
97177
  );
96868
97178
  return;
96869
97179
  }
96870
- const pct = start === null ? computeElementPercentage(t2, sel) : absoluteToPercentage(t2, start, duration);
96871
- const existing = kfAnim.keyframes?.keyframes.find(
96872
- (k) => Math.abs(k.percentage - pct) <= KEYFRAME_PCT_MATCH
96873
- );
97180
+ const pct = start === null ? computeElementPercentage(t2, sel) : absoluteToPercentageForAnimation(t2, kfAnim) ?? 0;
97181
+ const existing = kfAnim.keyframes?.keyframes.find((k) => playsNear(kfAnim, k.percentage, pct));
96874
97182
  if (existing) {
96875
97183
  session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
96876
97184
  return;
@@ -96922,21 +97230,26 @@ async function applyArcKeyframeAtPlayhead(session, sel, arcAnim, t2, iframe) {
96922
97230
  const targetSelector = existingTweenTargetSelector(arcAnim, sel);
96923
97231
  if (!targetSelector) return;
96924
97232
  const start = resolveTweenStart(arcAnim) ?? 0;
96925
- const duration = resolveEditableTweenDuration(arcAnim, sel);
97233
+ const duration = resolveTweenDuration(arcAnim);
96926
97234
  if (!isTimeWithinTween(t2, start, duration)) {
96927
- if (t2 > start) {
96928
- await session.commitMutation(
96929
- {
96930
- type: "update-meta",
96931
- animationId: arcAnim.id,
96932
- updates: { duration: roundTo3(t2 - start) }
96933
- },
96934
- { label: "Extend motion path", softReload: true }
96935
- );
96936
- }
97235
+ const held = readElementPosition(iframe, sel, arcAnim);
97236
+ if (t2 <= start || typeof held.x !== "number" || typeof held.y !== "number") return;
97237
+ const extended = buildExtendedKeyframes(arcAnim, t2, { x: held.x, y: held.y }, duration, "none");
97238
+ await session.commitMutation(
97239
+ {
97240
+ type: "replace-with-keyframes",
97241
+ animationId: arcAnim.id,
97242
+ targetSelector,
97243
+ position: extended.position,
97244
+ duration: extended.duration,
97245
+ keyframes: extended.keyframes,
97246
+ ease: "none"
97247
+ },
97248
+ { label: "Add keyframe", keyframeAction: "add", softReload: true }
97249
+ );
96937
97250
  return;
96938
97251
  }
96939
- const nodes = arcAnim.keyframes?.keyframes ?? [];
97252
+ const nodes = linearArcKeyframes(arcAnim);
96940
97253
  const playheadPercentage = absoluteToPercentage(t2, start, duration);
96941
97254
  const timedNodeIndex = nodes.findIndex(
96942
97255
  (node) => Math.abs(node.percentage - playheadPercentage) <= KEYFRAME_PCT_MATCH
@@ -96997,6 +97310,7 @@ function useEnableKeyframes(sessionRef) {
96997
97310
  } else if (setAnim) {
96998
97311
  await promoteSetToKeyframes(session, sel, setAnim, t2, iframe);
96999
97312
  } else if (flatAnim) {
97313
+ const flatLive = flatAnim.durationUnresolved ? findParsedTween(iframe, sel.element, flatAnim) : void 0;
97000
97314
  enableKeyframesTransactionCounter += 1;
97001
97315
  const coalesceKey = `enable-keyframes:${flatAnim.id}:${enableKeyframesTransactionCounter}`;
97002
97316
  const convertCommitOverrides = {
@@ -97022,7 +97336,15 @@ function useEnableKeyframes(sessionRef) {
97022
97336
  // conversion splits into two undo entries under real latency.
97023
97337
  coalesceMs: Number.POSITIVE_INFINITY
97024
97338
  };
97025
- await applyKeyframeAtPlayhead(session, sel, converted, t2, iframe, applyCommitOverrides);
97339
+ await applyKeyframeAtPlayhead(
97340
+ session,
97341
+ sel,
97342
+ converted,
97343
+ t2,
97344
+ iframe,
97345
+ applyCommitOverrides,
97346
+ flatLive
97347
+ );
97026
97348
  }
97027
97349
  } else {
97028
97350
  const position = readElementPosition(iframe, sel, null);
@@ -97331,7 +97653,9 @@ var NO_KEYFRAME_TOGGLE = {
97331
97653
  function isMotionPathEndpoint(animation, percentage) {
97332
97654
  if (!animation?.keyframes) return false;
97333
97655
  const keyframes = animation.keyframes.keyframes;
97334
- return Math.abs((keyframes[0]?.percentage ?? -Infinity) - percentage) <= KEYFRAME_PCT_MATCH || Math.abs((keyframes.at(-1)?.percentage ?? Infinity) - percentage) <= KEYFRAME_PCT_MATCH;
97656
+ return [keyframes[0], keyframes.at(-1)].some(
97657
+ (keyframe) => keyframe && playsNear(animation, keyframe.percentage, percentage)
97658
+ );
97335
97659
  }
97336
97660
  function resolveKeyframeToggleState(session, currentTime) {
97337
97661
  if (!session?.domEditSelection) return NO_KEYFRAME_TOGGLE;
@@ -97341,13 +97665,13 @@ function resolveKeyframeToggleState(session, currentTime) {
97341
97665
  const animation = arcAnimation ?? session.selectedGsapAnimations.find((candidate) => candidate.keyframes && !candidate.arcPath);
97342
97666
  if (!animation?.keyframes) return NO_KEYFRAME_TOGGLE;
97343
97667
  const isMotionPath = Boolean(arcAnimation);
97344
- if (!isPlayheadWithinTween(animation, currentTime, session.domEditSelection)) {
97668
+ if (!isPlayheadWithinTween(animation, currentTime)) {
97345
97669
  return { state: "inactive", isMotionPath, pathEndpoint: false, willExtend: true };
97346
97670
  }
97347
97671
  const percentage = computeElementPercentage(currentTime, session.domEditSelection, animation);
97348
97672
  const pathEndpoint = isMotionPathEndpoint(arcAnimation, percentage);
97349
97673
  const active = animation.keyframes.keyframes.some(
97350
- (keyframe) => Math.abs(keyframe.percentage - percentage) <= KEYFRAME_PCT_MATCH
97674
+ (keyframe) => playsNear(animation, keyframe.percentage, percentage)
97351
97675
  );
97352
97676
  return {
97353
97677
  state: pathEndpoint ? "none" : active ? "active" : "inactive",