@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/player.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  Player
3
- } from "./chunk-246VB2G3.js";
3
+ } from "./chunk-DRVWEPO5.js";
4
4
  import "./chunk-5VSXRGIX.js";
5
- import "./chunk-5EKWJZLM.js";
5
+ import "./chunk-TDWGXBUT.js";
6
6
  import "./chunk-PZ5AY32C.js";
7
7
  export {
8
8
  Player
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.127",
3
+ "version": "0.8.128",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/parsers": "0.8.127",
64
- "@hyperframes/sdk": "0.8.127",
65
- "@hyperframes/player": "0.8.127",
66
- "@hyperframes/core": "0.8.127",
67
- "@hyperframes/shader-transitions": "0.8.127",
68
- "@hyperframes/studio-server": "0.8.127"
63
+ "@hyperframes/player": "0.8.128",
64
+ "@hyperframes/parsers": "0.8.128",
65
+ "@hyperframes/sdk": "0.8.128",
66
+ "@hyperframes/shader-transitions": "0.8.128",
67
+ "@hyperframes/studio-server": "0.8.128",
68
+ "@hyperframes/core": "0.8.128"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.127"
84
+ "@hyperframes/producer": "0.8.128"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -11,7 +11,7 @@ import {
11
11
  isPlayheadWithinTween,
12
12
  type EnableKeyframesSession,
13
13
  } from "../hooks/useEnableKeyframes";
14
- import { computeElementPercentage, KEYFRAME_PCT_MATCH } from "../hooks/gsapShared";
14
+ import { computeElementPercentage, playsNear } from "../hooks/gsapShared";
15
15
  import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard";
16
16
  import {
17
17
  getNextTimelineZoomPercent,
@@ -73,9 +73,8 @@ const NO_KEYFRAME_TOGGLE: KeyframeToggleState = {
73
73
  function isMotionPathEndpoint(animation: GsapAnimation | undefined, percentage: number): boolean {
74
74
  if (!animation?.keyframes) return false;
75
75
  const keyframes = animation.keyframes.keyframes;
76
- return (
77
- Math.abs((keyframes[0]?.percentage ?? -Infinity) - percentage) <= KEYFRAME_PCT_MATCH ||
78
- Math.abs((keyframes.at(-1)?.percentage ?? Infinity) - percentage) <= KEYFRAME_PCT_MATCH
76
+ return [keyframes[0], keyframes.at(-1)].some(
77
+ (keyframe) => keyframe && playsNear(animation, keyframe.percentage, percentage),
79
78
  );
80
79
  }
81
80
 
@@ -93,14 +92,14 @@ function resolveKeyframeToggleState(
93
92
  if (!animation?.keyframes) return NO_KEYFRAME_TOGGLE;
94
93
 
95
94
  const isMotionPath = Boolean(arcAnimation);
96
- if (!isPlayheadWithinTween(animation, currentTime, session.domEditSelection)) {
95
+ if (!isPlayheadWithinTween(animation, currentTime)) {
97
96
  return { state: "inactive", isMotionPath, pathEndpoint: false, willExtend: true };
98
97
  }
99
98
 
100
99
  const percentage = computeElementPercentage(currentTime, session.domEditSelection, animation);
101
100
  const pathEndpoint = isMotionPathEndpoint(arcAnimation, percentage);
102
- const active = animation.keyframes.keyframes.some(
103
- (keyframe) => Math.abs(keyframe.percentage - percentage) <= KEYFRAME_PCT_MATCH,
101
+ const active = animation.keyframes.keyframes.some((keyframe) =>
102
+ playsNear(animation, keyframe.percentage, percentage),
104
103
  );
105
104
  return {
106
105
  state: pathEndpoint ? "none" : active ? "active" : "inactive",
@@ -207,6 +207,13 @@ async function select(animations: GsapAnimation[] | null, props: Partial<EditorP
207
207
  return view;
208
208
  }
209
209
 
210
+ async function selectLoopedPair() {
211
+ const a = resolved(element("a"));
212
+ const b = resolved(element("b"));
213
+ const view = await select([loop({ x: 120 }, "#a")], { selection: a, groups: [a, b] });
214
+ return { a, b, ...view };
215
+ }
216
+
210
217
  describe("handles follow what Studio would commit", () => {
211
218
  beforeEach(() => {
212
219
  HTMLElement.prototype.setPointerCapture = () => undefined;
@@ -442,6 +449,31 @@ describe("handles follow what Studio would commit", () => {
442
449
  expect(spies.onManualDragStart).not.toHaveBeenCalled();
443
450
  });
444
451
 
452
+ it("re-checks only the members that changed when the group is handed back anew", async () => {
453
+ const { a, b, seen, render } = await selectLoopedPair();
454
+ const [narrowedA, narrowedB] = seen.groups;
455
+
456
+ render({ selection: a, groups: [a, b] });
457
+ expect(seen.groups[0]).toBe(narrowedA);
458
+ expect(seen.groups[1]).toBe(narrowedB);
459
+ const movedA = resolved(a.element);
460
+ render({ selection: movedA, groups: [movedA, b] });
461
+ expect(seen.groups[0]).not.toBe(narrowedA);
462
+ expect(flags(seen.groups[0]!)[0]).toBe(false);
463
+ expect(seen.groups[1]).toBe(narrowedB);
464
+ });
465
+
466
+ it("checks again on a new cache version even when that re-read fails", async () => {
467
+ const { a, b, seen, render } = await selectLoopedPair();
468
+ const [narrowedA] = seen.groups;
469
+
470
+ parses.fetch.mockResolvedValueOnce(null);
471
+ render({ selection: a, groups: [a, b], version: 1 });
472
+ await settle();
473
+ expect(seen.groups[0]).not.toBe(narrowedA);
474
+ expect(flags(seen.groups[0]!)[0]).toBe(false);
475
+ });
476
+
445
477
  it.each([
446
478
  ["rotation", loop({ rotation: 90 }), [true, true, false], { dots: 4, rotate: false }],
447
479
  ["resize", loop({ width: 320, height: 90 }), [true, false, true], { dots: 0, rotate: true }],
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment jsdom
2
- import { describe, expect, it } from "vitest";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
3
  import {
4
4
  collectDomEditLayerItems,
5
5
  resolveDomEditSelection,
@@ -100,6 +100,37 @@ describe("resolveDomEditSelection — hfId from data-hf-id", () => {
100
100
  });
101
101
  });
102
102
 
103
+ describe("resolveDomEditSelection — source probe on re-resolve", () => {
104
+ afterEach(() => vi.unstubAllGlobals());
105
+ const probing = { activeCompositionPath: "index.html", isMasterView: true, projectId: "p" };
106
+
107
+ it("asks the server once per node and target; re-resolving the same pair keeps the answer", async () => {
108
+ const fetchMock = vi.fn(() =>
109
+ Promise.resolve({ ok: true, json: () => Promise.resolve({ exists: [false] }) }),
110
+ );
111
+ vi.stubGlobal("fetch", fetchMock);
112
+ const el = document.createElement("div");
113
+ el.id = "generated";
114
+ document.body.appendChild(el);
115
+
116
+ const first = await resolveDomEditSelection(el, probing);
117
+ const again = await resolveDomEditSelection(el, { ...probing, previous: first });
118
+ el.id = "renamed";
119
+ await resolveDomEditSelection(el, { ...probing, previous: again });
120
+ el.id = "generated";
121
+ const replaced = document.createElement("div");
122
+ replaced.id = "generated";
123
+ el.replaceWith(replaced);
124
+ await resolveDomEditSelection(replaced, { ...probing, previous: again });
125
+ replaced.remove();
126
+
127
+ expect(first?.existsInSource).toBe(false);
128
+ expect(again?.existsInSource).toBe(false);
129
+ expect(again?.capabilities.canMove).toBe(false);
130
+ expect(fetchMock).toHaveBeenCalledTimes(3);
131
+ });
132
+ });
133
+
103
134
  describe("resolveDomEditSelection — data-hf-group capture", () => {
104
135
  // <div id="parent"><div data-hf-group="Group 1"><div data-hf-group="Group 2">
105
136
  // <span id="child"/></div></div></div>
@@ -236,17 +267,19 @@ describe("buildTextFieldChildLocator", () => {
236
267
  });
237
268
  });
238
269
 
239
- describe("collectDomEditLayerItems item budget", () => {
240
- function documentWith(count: number): HTMLElement {
241
- const root = document.createElement("div");
242
- root.setAttribute("data-composition-id", "index.html");
243
- for (let i = 0; i < count; i++) {
244
- const child = document.createElement("div");
245
- child.id = `el-${i}`;
246
- root.append(child);
247
- }
248
- return root;
270
+ function compositionWith(count: number, child: (el: HTMLElement, i: number) => void): HTMLElement {
271
+ const root = document.createElement("div");
272
+ root.setAttribute("data-composition-id", "index.html");
273
+ for (let i = 0; i < count; i++) {
274
+ const el = document.createElement("div");
275
+ child(el, i);
276
+ root.append(el);
249
277
  }
278
+ return root;
279
+ }
280
+
281
+ describe("collectDomEditLayerItems item budget", () => {
282
+ const documentWith = (count: number) => compositionWith(count, (el, i) => (el.id = `el-${i}`));
250
283
 
251
284
  it("returns the whole document by default", () => {
252
285
  // A default cap here silently truncated the marquee's candidate list: a drag
@@ -264,13 +297,7 @@ describe("collectDomEditLayerItems selector-index cost", () => {
264
297
  // Attached, unlike the fixture above: a detached subtree is invisible to
265
298
  // document.querySelectorAll, so the occurrence lookup would find nothing.
266
299
  function attachedRootWithSharedClass(count: number): HTMLElement {
267
- const root = document.createElement("div");
268
- root.setAttribute("data-composition-id", "index.html");
269
- for (let i = 0; i < count; i++) {
270
- const child = document.createElement("div");
271
- child.className = "box";
272
- root.append(child);
273
- }
300
+ const root = compositionWith(count, (el) => (el.className = "box"));
274
301
  document.body.append(root);
275
302
  return root;
276
303
  }
@@ -1,4 +1,4 @@
1
- import { probeSourceElement } from "./probeSourceElement";
1
+ import { knownSourceAnswer, probeSourceElement, type ProbeTarget } from "./probeSourceElement";
2
2
  import { isAudibleVideoNode } from "../../player/lib/timelineElementHelpers";
3
3
  import type { PatchOperation } from "../../utils/sourcePatcher";
4
4
  import {
@@ -288,6 +288,7 @@ export async function resolveDomEditSelection(
288
288
  projectId?: string | null;
289
289
  skipSourceProbe?: boolean;
290
290
  exactTarget?: boolean;
291
+ previous?: DomEditSelection | null;
291
292
  },
292
293
  ): Promise<DomEditSelection | null> {
293
294
  if (!startEl) return null;
@@ -330,14 +331,14 @@ export async function resolveDomEditSelection(
330
331
  isCompositionRootLayer(current, doc, computedStyles);
331
332
  const textFields = collectDomEditTextFields(current);
332
333
  const isInsideLocked = Boolean(findClosestByAttribute(current, ["data-timeline-locked"]));
333
- let existsInSource: boolean | undefined;
334
- if (!options.skipSourceProbe && options.projectId && (current.id || selector || hfId)) {
335
- const probeTarget: { id?: string; hfId?: string; selector?: string; selectorIndex?: number } =
336
- {};
337
- if (current.id) probeTarget.id = current.id;
338
- if (hfId) probeTarget.hfId = hfId;
339
- if (selector) probeTarget.selector = selector;
340
- if (selectorIndex != null) probeTarget.selectorIndex = selectorIndex;
334
+ const probeTarget: ProbeTarget = {};
335
+ if (current.id) probeTarget.id = current.id;
336
+ if (hfId) probeTarget.hfId = hfId;
337
+ if (selector) probeTarget.selector = selector;
338
+ if (selectorIndex != null) probeTarget.selectorIndex = selectorIndex;
339
+ let existsInSource = knownSourceAnswer(options.previous, current, sourceFile, probeTarget);
340
+ const probe = existsInSource === undefined && !options.skipSourceProbe;
341
+ if (probe && options.projectId && (current.id || selector || hfId)) {
341
342
  existsInSource = await probeSourceElement(options.projectId, sourceFile, probeTarget);
342
343
  }
343
344
  const capabilities = resolveEditingAffordances(
@@ -380,6 +381,7 @@ export async function resolveDomEditSelection(
380
381
  computedStyles,
381
382
  textFields,
382
383
  capabilities,
384
+ existsInSource,
383
385
  };
384
386
  }
385
387
 
@@ -92,6 +92,7 @@ export interface DomEditSelection extends PatchTarget {
92
92
  computedStyles: Record<string, string>;
93
93
  textFields: DomEditTextField[];
94
94
  capabilities: DomEditCapabilities;
95
+ existsInSource?: boolean;
95
96
  gsapAnimations?: GsapAnimation[];
96
97
  }
97
98
 
@@ -14,6 +14,7 @@
14
14
  * Kept pure (no React/store/GSAP) so the trickiest math is unit-testable. The
15
15
  * caller supplies the resolved tween window + the drop's absolute time.
16
16
  */
17
+ import { progressAtTime, timeAtProgress } from "../../utils/gsapKeyframeEases";
17
18
 
18
19
  export interface RetimeKeyframe {
19
20
  /** Tween-relative percentage (the writer/runtime key on this). */
@@ -92,6 +93,7 @@ function resolveFlatTweenBoundaryRetime(opts: {
92
93
  * - `draggedTweenPct`: identifies which keyframe is being dragged (closest match).
93
94
  * - `tweenStart` / `tweenDuration`: the tween's resolved absolute window.
94
95
  * - `dropAbsTime`: the drop's absolute time (handler converts clip-% → seconds).
96
+ * - `runEase`: the tween's run ease, which decides when each keyframe plays.
95
97
  */
96
98
  export function resolveKeyframeRetime(opts: {
97
99
  keyframes: ReadonlyArray<RetimeKeyframe>;
@@ -99,8 +101,9 @@ export function resolveKeyframeRetime(opts: {
99
101
  tweenStart: number;
100
102
  tweenDuration: number;
101
103
  dropAbsTime: number;
104
+ runEase?: string;
102
105
  }): KeyframeRetimeResult {
103
- const { keyframes, draggedTweenPct, tweenStart, tweenDuration, dropAbsTime } = opts;
106
+ const { keyframes, draggedTweenPct, tweenStart, tweenDuration, dropAbsTime, runEase } = opts;
104
107
  if (tweenDuration <= 0) return { kind: "noop" };
105
108
  const tweenEnd = tweenStart + tweenDuration;
106
109
 
@@ -124,7 +127,8 @@ export function resolveKeyframeRetime(opts: {
124
127
  // Round here, not at the return: the no-op test below and the value written
125
128
  // to source must be the same number. The resize branch already rounds, so
126
129
  // this keeps both write paths at the authored 3dp precision.
127
- const toTweenPct = round3(clamp(((dropAbsTime - tweenStart) / tweenDuration) * 100, 0, 100));
130
+ const dropTimePct = clamp(((dropAbsTime - tweenStart) / tweenDuration) * 100, 0, 100);
131
+ const toTweenPct = round3(progressAtTime(runEase, dropTimePct));
128
132
  if (Math.abs(toTweenPct - draggedTweenPct) < NOOP_EPSILON_PCT) return { kind: "noop" };
129
133
  return { kind: "move", toTweenPct };
130
134
  }
@@ -154,8 +158,11 @@ export function resolveKeyframeRetime(opts: {
154
158
  // survive verbatim (no rebuilt keyframes array).
155
159
  const pctRemap: KeyframePctRemap[] = keyframes.map((kf, i) => {
156
160
  const absTime =
157
- i === draggedIdx ? dropAbsTime : tweenStart + (kf.percentage / 100) * tweenDuration;
158
- return { from: kf.percentage, to: round3(((absTime - newStart) / newDuration) * 100) };
161
+ i === draggedIdx
162
+ ? dropAbsTime
163
+ : tweenStart + (timeAtProgress(runEase, kf.percentage) / 100) * tweenDuration;
164
+ const newTimePct = ((absTime - newStart) / newDuration) * 100;
165
+ return { from: kf.percentage, to: round3(progressAtTime(runEase, newTimePct)) };
159
166
  });
160
167
 
161
168
  return {
@@ -1,7 +1,23 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
2
  import { studioApiFetch } from "../../utils/studioApiFetch";
3
+ import type { DomEditSelection } from "./domEditingTypes";
3
4
 
4
- type ProbeTarget = { id?: string; hfId?: string; selector?: string; selectorIndex?: number };
5
+ export type ProbeTarget = { id?: string; hfId?: string; selector?: string; selectorIndex?: number };
6
+
7
+ export function knownSourceAnswer(
8
+ previous: DomEditSelection | null | undefined,
9
+ element: HTMLElement,
10
+ sourceFile: string,
11
+ target: ProbeTarget,
12
+ ): boolean | undefined {
13
+ if (!previous || previous.element !== element || previous.sourceFile !== sourceFile) return;
14
+ const same =
15
+ previous.id === target.id &&
16
+ previous.hfId === target.hfId &&
17
+ previous.selector === target.selector &&
18
+ previous.selectorIndex === target.selectorIndex;
19
+ return same ? previous.existsInSource : undefined;
20
+ }
5
21
 
6
22
  interface PendingProbe {
7
23
  target: ProbeTarget;
@@ -79,24 +79,14 @@ function mountMirrorHarness(history: {
79
79
  moveCoalesceKeys: string[];
80
80
  records: RecordedWrite[];
81
81
  }) {
82
- const record = (
83
- label: string,
84
- coalesceKey: string,
85
- _coalesceMs: number | undefined,
86
- after: string,
87
- ) => {
82
+ const record = (label: string, coalesceKey: string, after: string) => {
88
83
  history.records.push({ label, coalesceKey, after });
89
84
  history.fileContent.current = after;
90
85
  };
91
86
 
92
- const onMoveElements: TimelineEditCallbacks["onMoveElements"] = (
93
- _edits,
94
- coalesceKey,
95
- _operation,
96
- coalesceMs,
97
- ) => {
87
+ const onMoveElements: TimelineEditCallbacks["onMoveElements"] = (_edits, coalesceKey) => {
98
88
  history.moveCoalesceKeys.push(coalesceKey ?? "<none>");
99
- record("Move timeline clips", coalesceKey ?? "<none>", coalesceMs, "C-move");
89
+ record("Move timeline clips", coalesceKey ?? "<none>", "C-move");
100
90
  };
101
91
 
102
92
  const api: Partial<HarnessApi> = {};
@@ -104,7 +94,7 @@ function mountMirrorHarness(history: {
104
94
  const { handleDomZIndexReorderCommit } = useElementLifecycleOps(
105
95
  makeLifecycleOpsParams({
106
96
  commitDomEditPatchBatches: async (_batches, options) => {
107
- record(options.label, options.coalesceKey, options.coalesceMs, "B-z");
97
+ record(options.label, options.coalesceKey, "B-z");
108
98
  return { durable: true, allMatched: true, changed: true };
109
99
  },
110
100
  }),
@@ -163,13 +163,6 @@ function useMirrorLaneMoveCommit(): (
163
163
  // stacking sync must not re-trigger and fight the just-set z values.
164
164
  },
165
165
  coalesceKey,
166
- // Unbounded fold window: this record lands only AFTER the z persist's
167
- // server round-trip resolved, so the gap between the gesture's two
168
- // records exceeds editHistory's 300ms default under real latency and
169
- // the fold would silently split into two undo entries. The shared key
170
- // is unique per gesture (zReorderCoalesceKey's gesture seq), so the
171
- // unbounded window can never merge two distinct user actions.
172
- Number.POSITIVE_INFINITY,
173
166
  );
174
167
  },
175
168
  [onMoveElements],
@@ -679,6 +679,33 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
679
679
  view.unmount();
680
680
  });
681
681
 
682
+ it("retimes a duration-less tween's keyframe in the window GSAP plays it, not the clip", async () => {
683
+ const durationless = { ...authoredInteriorAnimation(), duration: undefined };
684
+ mocks.animations = [durationless];
685
+ usePlayerStore.setState({ gsapAnimations: new Map([["box", [durationless]]]) });
686
+ const view = renderCallbacks();
687
+
688
+ // GSAP plays it 0-0.5 s of the 1 s clip, so a drop at 0.4 s is 80% of the tween.
689
+ await view.callbacks.onMoveKeyframe?.(
690
+ "box",
691
+ {
692
+ percentage: 25,
693
+ propertyGroup: "position",
694
+ tweenPercentage: 50,
695
+ animationId: durationless.id,
696
+ },
697
+ 40,
698
+ );
699
+
700
+ expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
701
+ durationless.id,
702
+ 50,
703
+ 80,
704
+ mocks.selection,
705
+ );
706
+ view.unmount();
707
+ });
708
+
682
709
  // A drag starts on whatever diamond the pointer is over, which need not be the
683
710
  // selected element. Resolving against the selection would retime the selected
684
711
  // element's tween and commit it through the selected element's file.
@@ -777,46 +804,4 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
777
804
  expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
778
805
  view.unmount();
779
806
  });
780
-
781
- it("uses the clip timing basis when retiming a duration-less tween", async () => {
782
- const durationless = {
783
- ...authoredInteriorAnimation(),
784
- position: 3.2,
785
- resolvedStart: 3.2,
786
- duration: undefined,
787
- };
788
- const wideElement = { ...element, start: 10.94, duration: 16.26 };
789
- mocks.animations = [durationless];
790
- usePlayerStore.setState({
791
- elements: [wideElement],
792
- gsapAnimations: new Map([["box", [durationless]]]),
793
- });
794
- const view = renderCallbacks();
795
-
796
- await expect(
797
- view.callbacks.onMoveKeyframe?.(
798
- "box",
799
- {
800
- percentage: 19.1,
801
- propertyGroup: "position",
802
- tweenPercentage: 50,
803
- animationId: durationless.id,
804
- },
805
- 40,
806
- ),
807
- ).resolves.toBe(true);
808
-
809
- // The whole point of the clip basis: the drop lands at 10.94 + 0.40 * 16.26 =
810
- // 17.444s, and the duration-less tween borrows the clip's 16.26s window from
811
- // its 3.2s start, so 17.444 - 3.2 over 16.26 is 87.601%. Any other basis (a
812
- // zero-length tween, or the clip's own 0-100 %) produces a different number.
813
- expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
814
- durationless.id,
815
- 50,
816
- expect.closeTo(87.601, 3),
817
- mocks.selection,
818
- );
819
- expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
820
- view.unmount();
821
- });
822
807
  });
@@ -12,6 +12,7 @@ import {
12
12
  useDomEditSelectionContext,
13
13
  } from "../../contexts/DomEditContext";
14
14
  import { resolveTweenStart, resolveTweenDuration } from "../../utils/globalTimeCompiler";
15
+ import { runEaseOf } from "../../utils/gsapKeyframeEases";
15
16
  import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache";
16
17
  import { elementCacheKeys } from "../../hooks/gsapKeyframeCacheHelpers";
17
18
  import { resolveKeyframeRetime } from "../editor/keyframeRetime";
@@ -32,7 +33,6 @@ export interface TimelineEditCallbackDeps {
32
33
  edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
33
34
  coalesceKey?: string,
34
35
  operation?: TimelineMoveOperation,
35
- coalesceMs?: number,
36
36
  ) => Promise<void> | void;
37
37
  handleTimelineElementResize: (
38
38
  element: TimelineElement,
@@ -332,7 +332,7 @@ export function useTimelineEditCallbacks({
332
332
  elements,
333
333
  domClipChildren,
334
334
  );
335
- const tweenDuration = resolveTweenDuration(anim, elDuration);
335
+ const tweenDuration = resolveTweenDuration(anim);
336
336
  const dropAbsTime = elStart + (toClipPct / 100) * elDuration;
337
337
  const decision = resolveKeyframeRetime({
338
338
  keyframes: anim.keyframes?.keyframes ?? [],
@@ -340,6 +340,7 @@ export function useTimelineEditCallbacks({
340
340
  tweenStart,
341
341
  tweenDuration,
342
342
  dropAbsTime,
343
+ runEase: runEaseOf(anim),
343
344
  });
344
345
  if (decision.kind === "move" && decision.toTweenPct != null) {
345
346
  return handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct, sel);
@@ -5,7 +5,11 @@
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
7
  import { usePlayerStore } from "../player/store/playerStore";
8
- import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
8
+ import {
9
+ percentageToAbsoluteForAnimation,
10
+ resolveTweenStart,
11
+ resolveTweenDuration,
12
+ } from "../utils/globalTimeCompiler";
9
13
  import { roundTo3, roundToLayoutPx } from "../utils/rounding";
10
14
  import { computeElementPercentage, keyframeEases, writeTargetSelector } from "./gsapShared";
11
15
  import { computeDraggedGsapPosition } from "./draggedGsapPosition";
@@ -54,10 +58,9 @@ export function computeCurrentPercentage(
54
58
  // 1.2 start), so the post-commit reseek renders the element's base pose and the edit
55
59
  // looks like it snapped away. Keeping the playhead on the edited keyframe avoids that.
56
60
  export function parkPlayheadOnKeyframe(anim: GsapAnimation, pct: number): void {
57
- const ts = resolveTweenStart(anim);
58
- const td = resolveTweenDuration(anim);
59
- if (ts == null || !td || td <= 0) return;
60
- usePlayerStore.getState().requestSeek(roundTo3(ts + (pct / 100) * td));
61
+ const time = percentageToAbsoluteForAnimation(pct, anim);
62
+ if (time === null || resolveTweenDuration(anim) <= 0) return;
63
+ usePlayerStore.getState().requestSeek(roundTo3(time));
61
64
  }
62
65
 
63
66
  async function replaceKeyframedPositionHold(