@hyperframes/studio 0.8.127 → 0.8.129

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 (119) hide show
  1. package/dist/assets/{hyperframes-player-uqzDaxVL.js → hyperframes-player-DfEcBl9r.js} +1 -1
  2. package/dist/assets/{index-DXSYlqmV.js → index-9ZaZFCMZ.js} +1 -1
  3. package/dist/assets/{index-CLBNYlDY.js → index-Bmy-KVib.js} +1 -1
  4. package/dist/assets/{index-COhbL1xq.js → index-C9YkEyN4.js} +189 -189
  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 +1396 -917
  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 +82 -63
  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/gsapLiveRetime.realGsap.test.ts +544 -0
  109. package/src/utils/gsapLiveRetime.ts +286 -0
  110. package/src/utils/gsapSoftReload.ts +16 -7
  111. package/src/utils/gsapUndoRestore.test.ts +43 -0
  112. package/src/utils/gsapUndoRestore.ts +6 -3
  113. package/src/utils/sameData.ts +9 -0
  114. package/src/utils/studioFileHistory.ts +1 -0
  115. package/src/utils/studioPendingEdits.ts +15 -1
  116. package/dist/chunk-246VB2G3.js.map +0 -1
  117. package/dist/chunk-3F47EFCT.js.map +0 -1
  118. package/dist/chunk-5EKWJZLM.js.map +0 -1
  119. /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
@@ -1,13 +1,5 @@
1
1
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
2
- import {
3
- dropMisalignedTrimPartners,
4
- expandToLinkedMembers,
5
- linkedGestureKeys,
6
- linkedMembersOf,
7
- } from "./audioClipLink";
8
- import { buildTimelineGroupResizeMembers } from "./timelineGroupEditing";
9
- import { resolveGroupMovers } from "./timelineMultiDragPreview";
10
- import { MAX_HAND_EDIT_CLIPS } from "./timelineEditing";
2
+ import { expandToLinkedMembers, linkedMembersOf } from "./audioClipLink";
11
3
  import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
12
4
 
13
5
  /**
@@ -47,29 +39,3 @@ export function toggleClipWithLinks(key: string, altKey: boolean): TimelineEleme
47
39
  const primary = usePlayerStore.getState().selectedElementId;
48
40
  return state.elements.find((el) => (el.key ?? el.id) === primary) ?? null;
49
41
  }
50
-
51
- /** Whether a hand drag on `grabbed` (trimming `edge`, or null to move) would change more clips than allowed. */
52
- export function exceedsHandEditLimit(
53
- grabbed: TimelineElement,
54
- altKey: boolean,
55
- edge: "start" | "end" | null,
56
- ): boolean {
57
- const { selectedElementIds, elements } = usePlayerStore.getState();
58
- const grabbedKey = grabbed.key ?? grabbed.id;
59
- const keys = linkedGestureKeys(
60
- selectedElementIds,
61
- grabbed,
62
- elements,
63
- altKey,
64
- isLinkedSelectionOn(),
65
- );
66
- const changed = edge
67
- ? buildTimelineGroupResizeMembers(
68
- elements,
69
- dropMisalignedTrimPartners(keys, grabbed, elements, edge),
70
- grabbedKey,
71
- edge,
72
- )
73
- : resolveGroupMovers(elements, keys, grabbedKey);
74
- return (changed?.length ?? 1) > MAX_HAND_EDIT_CLIPS;
75
- }
@@ -1,4 +1,5 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
+ import { batchElementUpdates } from "../store/batchElementUpdates";
2
3
 
3
4
  type UpdateElement = (key: string, updates: Partial<TimelineElement>) => void;
4
5
  type RevisionScope = "timing" | "membership";
@@ -45,9 +46,11 @@ export function rollbackLatestTimelineOptimisticGesture(
45
46
  gesture: ReadonlyMap<string, number>,
46
47
  rollbacks: ReadonlyArray<{ key: string; updates: Partial<TimelineElement> }>,
47
48
  ): void {
48
- for (const rollback of rollbacks) {
49
- if (isLatestTimelineOptimisticGesture(updateElement, gesture, rollback.key)) {
50
- updateElement(rollback.key, rollback.updates);
49
+ batchElementUpdates(() => {
50
+ for (const rollback of rollbacks) {
51
+ if (isLatestTimelineOptimisticGesture(updateElement, gesture, rollback.key)) {
52
+ updateElement(rollback.key, rollback.updates);
53
+ }
51
54
  }
52
- }
55
+ });
53
56
  }
@@ -17,7 +17,7 @@ import {
17
17
  } from "../../utils/globalTimeCompiler";
18
18
  import type { TimelinePropertyGroupKeyframeToggle } from "./timelineCallbacks";
19
19
  import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
20
- import { groupLabel, valuesAt, type LaneValues } from "./trackHeaderLaneValues";
20
+ import { groupLabel, valuesAt, valuesBefore, type LaneValues } from "./trackHeaderLaneValues";
21
21
 
22
22
  export type TimelinePropertyLane = ReturnType<typeof getTimelinePropertyLanes>[number];
23
23
  export type KeyframeNavigationState = ReturnType<
@@ -110,7 +110,14 @@ export function resolveLaneHeaderState(
110
110
  currentTime,
111
111
  clipPercentage,
112
112
  );
113
- const values = animation ? valuesAt(animation, lane.group, tweenPercentage) : {};
113
+ const values = animation
114
+ ? valuesAt(
115
+ animation,
116
+ lane.group,
117
+ tweenPercentage,
118
+ valuesBefore(animation, lane.elementAnimations),
119
+ )
120
+ : {};
114
121
 
115
122
  return {
116
123
  navigation,
@@ -0,0 +1,89 @@
1
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import { describe, expect, it } from "vitest";
3
+ import { progressAtTime, runEaseOf } from "../../utils/gsapKeyframeEases";
4
+ import { valuesAt, valuesBefore } from "./trackHeaderLaneValues";
5
+
6
+ const tween = (format: string, vars: { ease?: string; easeEach?: string }) =>
7
+ ({
8
+ id: "#x-to-0-position",
9
+ targetSelector: "#x",
10
+ method: "to",
11
+ position: 0,
12
+ duration: 1,
13
+ properties: {},
14
+ ...(vars.ease ? { ease: vars.ease } : {}),
15
+ keyframes: {
16
+ format,
17
+ keyframes: [
18
+ { percentage: 50, properties: { x: 60 } },
19
+ { percentage: 100, properties: { x: 120 } },
20
+ ],
21
+ ...(vars.easeEach ? { easeEach: vars.easeEach } : {}),
22
+ },
23
+ }) as unknown as GsapAnimation;
24
+
25
+ const xAt = (animation: GsapAnimation, timePercentage: number) =>
26
+ Number(
27
+ valuesAt(animation, "position", progressAtTime(runEaseOf(animation), timePercentage)).x.toFixed(
28
+ 2,
29
+ ),
30
+ );
31
+
32
+ // Each row is what GSAP 3.15 draws for x at 12.5, 37.5, 62.5 and 87.5% of the tween.
33
+ describe("valuesAt at the playhead", () => {
34
+ it.each([
35
+ ["a step list", tween("object-array", {}), [15, 45, 75, 105]],
36
+ [
37
+ "a step list with an outer ease",
38
+ tween("object-array", { ease: "power1.out" }),
39
+ [28.13, 73.13, 103.13, 118.13],
40
+ ],
41
+ ["percentage keyframes", tween("percentage", {}), [7.5, 52.5, 67.5, 112.5]],
42
+ [
43
+ "percentage keyframes with an outer ease",
44
+ tween("percentage", { ease: "power1.out", easeEach: "power1.out" }),
45
+ [43.07, 83.38, 115.25, 119.94],
46
+ ],
47
+ ])("reads %s as GSAP plays it", (_, animation, expected) => {
48
+ expect([12.5, 37.5, 62.5, 87.5].map((at) => xAt(animation, at))).toEqual(expected);
49
+ });
50
+ });
51
+
52
+ describe("valuesBefore", () => {
53
+ it("starts a step list from the value an earlier set left, not a default", () => {
54
+ const set = { id: "s", method: "set", position: 0, resolvedStart: 0, properties: { x: 300 } };
55
+ const stepList = { ...tween("object-array", { ease: "none" }), position: 1, resolvedStart: 1 };
56
+ stepList.keyframes!.keyframes = [
57
+ { percentage: 50, properties: { x: 300 } },
58
+ { percentage: 100, properties: { x: 500 } },
59
+ ];
60
+ const start = valuesBefore(stepList, [set as unknown as GsapAnimation, stepList]);
61
+ expect(start).toEqual({ x: 300 });
62
+ expect(valuesAt(stepList, "position", 25, start)).toEqual({ x: 300 });
63
+ });
64
+
65
+ it("takes a set placed at the tween's own start, and never a relative string", () => {
66
+ const at = (id: string, resolvedStart: number, x: number | string) =>
67
+ ({ id, method: "set", resolvedStart, properties: { x } }) as unknown as GsapAnimation;
68
+ const stepList = { ...tween("object-array", { ease: "none" }), resolvedStart: 1 };
69
+ const relative = at("r", 0.5, "+=50");
70
+ expect(valuesBefore(stepList, [at("s", 1, 300), stepList])).toEqual({ x: 300 });
71
+ expect(valuesBefore(stepList, [stepList, at("s", 1, 300)])).toEqual({});
72
+ expect(valuesBefore(stepList, [at("s", 0, 300), relative, stepList])).toEqual({});
73
+ });
74
+
75
+ it("skips a tween still running when this one starts", () => {
76
+ const set = { id: "s", method: "set", resolvedStart: 0, properties: { x: 300 } };
77
+ const running = {
78
+ id: "r",
79
+ method: "to",
80
+ resolvedStart: 0.5,
81
+ duration: 1,
82
+ properties: { x: 900 },
83
+ };
84
+ const stepList = { ...tween("object-array", { ease: "none" }), resolvedStart: 1 };
85
+ const others = [set, running] as unknown as GsapAnimation[];
86
+ expect(valuesBefore(stepList, [...others, stepList])).toEqual({ x: 300 });
87
+ expect(valuesBefore({ ...stepList, resolvedStart: undefined }, others)).toEqual({});
88
+ });
89
+ });
@@ -4,12 +4,14 @@
4
4
  * reads to a human. Kept apart from the header's JSX so a formatting change and
5
5
  * a layout change never touch the same file.
6
6
  */
7
- import gsap from "gsap";
8
7
  import {
9
8
  classifyPropertyGroup,
10
9
  type GsapAnimation,
11
10
  type PropertyGroupName,
12
11
  } from "@hyperframes/core/gsap-parser";
12
+ import { PROPERTY_DEFAULTS } from "../../hooks/gsapShared";
13
+ import { resolveTweenDuration, resolveTweenStart } from "../../utils/globalTimeCompiler";
14
+ import { easeFunction, keyframedTweenEases } from "../../utils/gsapKeyframeEases";
13
15
 
14
16
  export type LaneValues = Record<string, number | string>;
15
17
 
@@ -17,15 +19,8 @@ function roundValue(value: number): string {
17
19
  return String(Math.round(value * 100) / 100);
18
20
  }
19
21
 
20
- /** GSAP applies a keyframe's `ease` to the segment ARRIVING at it, so the curve
21
- * between two keyframes is named by the later one (with the tween-level
22
- * `easeEach`/`ease` as the fallback). Sampling linearly would report a value the
23
- * element never has at that time, and stamp that wrong value onto a keyframe
24
- * added from the track header. Unknown ease names parse to undefined -> linear. */
25
22
  function easedProgress(progress: number, animation: GsapAnimation, ease?: string): number {
26
- const resolved = ease ?? animation.keyframes?.easeEach ?? animation.ease;
27
- if (!resolved || resolved === "none") return progress;
28
- return gsap.parseEase(resolved)?.(progress) ?? progress;
23
+ return easeFunction(keyframedTweenEases(animation).segment({ ease }))?.(progress) ?? progress;
29
24
  }
30
25
 
31
26
  interface PropertyStop {
@@ -34,14 +29,23 @@ interface PropertyStop {
34
29
  ease?: string;
35
30
  }
36
31
 
37
- function propertyStops(animation: GsapAnimation, property: string): PropertyStop[] {
38
- return (animation.keyframes?.keyframes ?? [])
32
+ /** Before its first keyframe a tween animates from the value it starts at (`start`), or the
33
+ * property's base value as for a flat tween (synthesizeFlatTweenKeyframes). */
34
+ function propertyStops(
35
+ animation: GsapAnimation,
36
+ property: string,
37
+ start?: number | string,
38
+ ): PropertyStop[] {
39
+ const stops = (animation.keyframes?.keyframes ?? [])
39
40
  .filter((keyframe) => property in keyframe.properties)
40
41
  .map((keyframe) => ({
41
42
  percentage: keyframe.percentage,
42
43
  value: keyframe.properties[property],
43
44
  ease: keyframe.ease,
44
45
  }));
46
+ const first = stops[0];
47
+ if (!first || first.percentage <= 0 || typeof first.value !== "number") return stops;
48
+ return [{ percentage: 0, value: start ?? PROPERTY_DEFAULTS[property] ?? 0 }, ...stops];
45
49
  }
46
50
 
47
51
  /** A pair only interpolates when both ends are numeric and actually span time;
@@ -60,8 +64,9 @@ function propertyValueAt(
60
64
  animation: GsapAnimation,
61
65
  property: string,
62
66
  tweenPercentage: number,
67
+ start?: number | string,
63
68
  ): number | string | undefined {
64
- const stops = propertyStops(animation, property);
69
+ const stops = propertyStops(animation, property, start);
65
70
  const before = stops.filter((stop) => stop.percentage <= tweenPercentage).at(-1);
66
71
  const after = stops.find((stop) => stop.percentage >= tweenPercentage);
67
72
  if (!before) return after?.value;
@@ -77,6 +82,7 @@ export function valuesAt(
77
82
  animation: GsapAnimation,
78
83
  group: PropertyGroupName,
79
84
  tweenPercentage: number,
85
+ startValues: LaneValues = {},
80
86
  ): LaneValues {
81
87
  const propertyNames = new Set<string>();
82
88
  for (const keyframe of animation.keyframes?.keyframes ?? []) {
@@ -86,12 +92,47 @@ export function valuesAt(
86
92
  }
87
93
  const values: LaneValues = {};
88
94
  for (const property of propertyNames) {
89
- const value = propertyValueAt(animation, property, tweenPercentage);
95
+ const value = propertyValueAt(animation, property, tweenPercentage, startValues[property]);
90
96
  if (value !== undefined) values[property] = value;
91
97
  }
92
98
  return values;
93
99
  }
94
100
 
101
+ const endValue = (animation: GsapAnimation, property: string) =>
102
+ animation.keyframes?.keyframes.findLast((keyframe) => property in keyframe.properties)
103
+ ?.properties[property] ?? animation.properties[property];
104
+
105
+ /** Each property's value as `animation` starts: the end of the last tween done writing it by then. */
106
+ export function valuesBefore(
107
+ animation: GsapAnimation,
108
+ animations: readonly GsapAnimation[],
109
+ ): LaneValues {
110
+ const start = resolveTweenStart(animation);
111
+ if (start == null) return {};
112
+ const endOf = (other: GsapAnimation) =>
113
+ (resolveTweenStart(other) ?? Infinity) +
114
+ (other.method === "set" ? 0 : resolveTweenDuration(other));
115
+ const index = animations.indexOf(animation);
116
+ const finished = animations
117
+ .filter((other, i) => {
118
+ if (other.method === "from" || i === index) return false;
119
+ return endOf(other) < start || (endOf(other) === start && i < index);
120
+ })
121
+ .sort((a, b) => endOf(a) - endOf(b));
122
+ const values: LaneValues = {};
123
+ for (const other of finished) {
124
+ const keyframes = other.keyframes?.keyframes ?? [{ properties: other.properties }];
125
+ for (const property of new Set(
126
+ keyframes.flatMap((keyframe) => Object.keys(keyframe.properties)),
127
+ )) {
128
+ const value = endValue(other, property);
129
+ if (typeof value === "number") values[property] = value;
130
+ else delete values[property];
131
+ }
132
+ }
133
+ return values;
134
+ }
135
+
95
136
  export function groupLabel(group: PropertyGroupName, properties: LaneValues): string {
96
137
  if (group === "visual" && ("opacity" in properties || "autoAlpha" in properties)) {
97
138
  return "Opacity";
@@ -165,13 +165,11 @@ it("leaves Space on a nested fade slider to the slider", () => {
165
165
  act(() => view.root.unmount());
166
166
  });
167
167
 
168
- it("does not pick up a clip held in a selection above the hand-move limit", () => {
168
+ it("picks up a clip held in a selection of any size", () => {
169
169
  const view = mount();
170
170
  usePlayerStore.getState().setSelection(["top", "hero", "tail", "bottom"], "hero");
171
171
  view.key(" ");
172
- expect(view.drag).toBeNull();
173
- expect(view.onBlockedEditAttempt).toHaveBeenCalledWith(
174
- expect.objectContaining({ id: "hero" }),
175
- "edit-many",
176
- );
172
+ expect(view.drag).not.toBeNull();
173
+ expect(view.onBlockedEditAttempt).not.toHaveBeenCalled();
174
+ act(() => view.root.unmount());
177
175
  });
@@ -380,13 +380,10 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
380
380
 
381
381
  expect(h.onResizeElement).not.toHaveBeenCalled();
382
382
  expect(h.onResizeElements).toHaveBeenCalledTimes(1);
383
- expect(h.onResizeElements).toHaveBeenCalledWith(
384
- [
385
- expect.objectContaining({ element: a, duration: 2.5 }),
386
- expect.objectContaining({ element: b, duration: 3.5 }),
387
- ],
388
- { coalesceKey: expect.stringMatching(/^clip-group-resize:/) },
389
- );
383
+ expect(h.onResizeElements).toHaveBeenCalledWith([
384
+ expect.objectContaining({ element: a, duration: 2.5 }),
385
+ expect.objectContaining({ element: b, duration: 3.5 }),
386
+ ]);
390
387
  expect(h.storeById("a").duration).toBe(2.5);
391
388
  expect(h.getResizeProjection()).toHaveLength(0);
392
389
  expect(h.storeById("b").duration).toBe(3.5);
@@ -31,7 +31,6 @@ import type {
31
31
  } from "./timelineClipDragTypes";
32
32
  import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
33
33
  import { dropMisalignedTrimPartners, linkedGestureKeys } from "./audioClipLink";
34
- import { exceedsHandEditLimit } from "./timelineLinkSelection";
35
34
  import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
36
35
  import { useTimelineClipCapabilities } from "./timelineReadOnly";
37
36
  import { timelineClipFocusId } from "./timelineNavigationIdentity";
@@ -526,8 +525,6 @@ export function useTimelineClipDrag({
526
525
  if (!element || !getClipCapabilities(element).canMove) return;
527
526
  event.preventDefault();
528
527
  event.stopImmediatePropagation();
529
- if (exceedsHandEditLimit(element, false, null))
530
- return void onBlockedEditAttemptRef.current?.(element, "edit-many");
531
528
  setShowPopover(false);
532
529
  setRangeSelectionRef.current?.(null);
533
530
  const row = keyboardPickupInsertRow(
@@ -4,6 +4,7 @@ import {
4
4
  type PointerEvent as ReactPointerEvent,
5
5
  } from "react";
6
6
  import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
7
+ import { progressAtTime, runEaseOf, timeAtProgress } from "../../utils/gsapKeyframeEases";
7
8
  import {
8
9
  KEYFRAME_DRAG_THRESHOLD_PX,
9
10
  previewClipPct,
@@ -162,6 +163,15 @@ export function subscribeTimelineKeyframeRetimePreview(
162
163
  return () => coordinator.previewListeners.delete(listener);
163
164
  }
164
165
 
166
+ function retimedRunEase(actor: TimelineKeyframeRetimeActor): string | undefined {
167
+ const id = actor.target.animationId;
168
+ if (id === undefined) return undefined;
169
+ const { gsapAnimations } = usePlayerStore.getState();
170
+ const animations = gsapAnimations.get(actor.elementId) ?? [...gsapAnimations.values()].flat();
171
+ const animation = animations.find((candidate) => candidate.id === id);
172
+ return animation ? runEaseOf(animation) : undefined;
173
+ }
174
+
165
175
  function resolveRetimeTweenPercentage(
166
176
  actor: TimelineKeyframeRetimeActor,
167
177
  toClipPercentage: number,
@@ -173,7 +183,13 @@ function resolveRetimeTweenPercentage(
173
183
  const tweenPercentages = animationKeyframes
174
184
  .map((keyframe) => keyframe.tweenPercentage)
175
185
  .filter((value): value is number => typeof value === "number");
176
- const mapped = clipToTweenPercentage(animationKeyframes, toClipPercentage);
186
+ const runEase = retimedRunEase(actor);
187
+ const timed = animationKeyframes.map((keyframe) =>
188
+ keyframe.tweenPercentage === undefined
189
+ ? keyframe
190
+ : { ...keyframe, tweenPercentage: timeAtProgress(runEase, keyframe.tweenPercentage) },
191
+ );
192
+ const mapped = progressAtTime(runEase, clipToTweenPercentage(timed, toClipPercentage));
177
193
  if (tweenPercentages.length === 0) return mapped;
178
194
  return Math.max(Math.min(...tweenPercentages), Math.min(Math.max(...tweenPercentages), mapped));
179
195
  }
@@ -0,0 +1,43 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { batchElementUpdates } from "./batchElementUpdates";
4
+ import { usePlayerStore, type TimelineElement } from "./playerStore";
5
+
6
+ const clips = (n: number): TimelineElement[] =>
7
+ Array.from({ length: n }, (_, i) => ({
8
+ id: `c${i}`,
9
+ tag: "div",
10
+ start: i,
11
+ duration: 1,
12
+ track: 0,
13
+ }));
14
+
15
+ describe("batchElementUpdates", () => {
16
+ beforeEach(() => {
17
+ usePlayerStore.getState().reset();
18
+ usePlayerStore.getState().setElements(clips(4));
19
+ });
20
+
21
+ it("lands every update of a batch as one store change, as the same calls one by one would", () => {
22
+ const { updateElement } = usePlayerStore.getState();
23
+ let notified = 0;
24
+ const stop = usePlayerStore.subscribe(() => notified++);
25
+ batchElementUpdates(() => {
26
+ updateElement("c0", { start: 10 });
27
+ batchElementUpdates(() => updateElement("c2", { start: 12 }));
28
+ updateElement("c0", { track: 3 });
29
+ });
30
+ stop();
31
+
32
+ expect(notified).toBe(1);
33
+ const byId = new Map(usePlayerStore.getState().elements.map((el) => [el.id, el]));
34
+ expect(byId.get("c0")).toMatchObject({ start: 10, track: 3 });
35
+ expect(byId.get("c1")?.start).toBe(1);
36
+ expect(byId.get("c2")?.start).toBe(12);
37
+ });
38
+
39
+ it("applies updates outside a batch at once", () => {
40
+ usePlayerStore.getState().updateElement("c1", { start: 7 });
41
+ expect(usePlayerStore.getState().elements[1]?.start).toBe(7);
42
+ });
43
+ });
@@ -0,0 +1,9 @@
1
+ import { collectElementPatches, patchElements } from "./elementPatchQueue";
2
+ import { usePlayerStore } from "./playerStore";
3
+
4
+ /** `updateElement` calls inside `run` land as one store update: one pass over the list, one notification. */
5
+ export function batchElementUpdates(run: () => void): void {
6
+ const patches = collectElementPatches(run);
7
+ if (!patches || patches.size === 0) return;
8
+ usePlayerStore.setState((state) => ({ elements: patchElements(state.elements, patches) }));
9
+ }
@@ -0,0 +1,35 @@
1
+ import type { TimelineElement, TimelineElementPatch } from "./timelineElement";
2
+
3
+ let queued: Map<string, TimelineElementPatch> | null = null;
4
+
5
+ /** Queues `updates` while a batch is collecting; false means the caller applies it now. */
6
+ export function queueElementPatch(key: string, updates: TimelineElementPatch): boolean {
7
+ if (!queued) return false;
8
+ queued.set(key, { ...queued.get(key), ...updates });
9
+ return true;
10
+ }
11
+
12
+ /** Runs `run`, returning the patches it queued; null when an outer batch already collects them. */
13
+ export function collectElementPatches(run: () => void): Map<string, TimelineElementPatch> | null {
14
+ if (queued) {
15
+ run();
16
+ return null;
17
+ }
18
+ const patches = (queued = new Map());
19
+ try {
20
+ run();
21
+ } finally {
22
+ queued = null;
23
+ }
24
+ return patches;
25
+ }
26
+
27
+ export function patchElements(
28
+ elements: TimelineElement[],
29
+ patches: ReadonlyMap<string, TimelineElementPatch>,
30
+ ): TimelineElement[] {
31
+ return elements.map((el) => {
32
+ const patch = patches.get(el.key ?? el.id);
33
+ return patch ? { ...el, ...patch } : el;
34
+ });
35
+ }
@@ -21,6 +21,7 @@ import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
21
21
  import { createPlaybackReadinessSlice } from "./readinessSlice";
22
22
  import { createRangeSelectionSlice, type RangeSelectionSlice } from "./rangeSelectionSlice";
23
23
  import { createTimelineResetState } from "./timelineResetState";
24
+ import { patchElements, queueElementPatch } from "./elementPatchQueue";
24
25
  export type { KeyframeCacheEntry } from "./keyframeSlice";
25
26
  export { liveTime } from "./liveTime";
26
27
  import { liveTime } from "./liveTime";
@@ -547,12 +548,10 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
547
548
  focusedEaseSegment: id === s.selectedElementId ? s.focusedEaseSegment : null,
548
549
  };
549
550
  }),
550
- updateElement: (elementId, updates) =>
551
- set((state) => ({
552
- elements: state.elements.map((el) =>
553
- (el.key ?? el.id) === elementId ? { ...el, ...updates } : el,
554
- ),
555
- })),
551
+ updateElement: (elementId, updates) => {
552
+ if (queueElementPatch(elementId, updates)) return;
553
+ set((state) => ({ elements: patchElements(state.elements, new Map([[elementId, updates]])) }));
554
+ },
556
555
  // UI preferences intentionally survive reset. So do timelineSessionEpoch and
557
556
  // focusedEaseRequestNonce: the epoch advances only when project identity
558
557
  // changes, while a monotonic nonce prevents collisions with stale consumers.
@@ -1,5 +1,6 @@
1
1
  import type { DomEditSelection } from "../components/editor/domEditing";
2
2
  import { getDomEditTargetKey } from "../components/editor/domEditing";
3
+ import { sameData } from "./sameData";
3
4
 
4
5
  export function domEditSelectionsTargetSame(
5
6
  a: DomEditSelection | null,
@@ -10,6 +11,10 @@ export function domEditSelectionsTargetSame(
10
11
  return getDomEditTargetKey(a) === getDomEditTargetKey(b);
11
12
  }
12
13
 
14
+ export function domEditSelectionsEqual(a: DomEditSelection, b: DomEditSelection): boolean {
15
+ return sameData(a, b);
16
+ }
17
+
13
18
  export function domEditSelectionInGroup(
14
19
  group: DomEditSelection[],
15
20
  selection: DomEditSelection | null,
@@ -112,10 +112,6 @@ describe("resolveTweenDuration", () => {
112
112
  test("missing duration defaults to GSAP default (0.5)", () => {
113
113
  expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(0.5);
114
114
  });
115
-
116
- test("missing duration can use its editor timing basis", () => {
117
- expect(resolveTweenDuration(makeAnim({ duration: undefined }), 16.26)).toBe(16.26);
118
- });
119
115
  });
120
116
 
121
117
  describe("findTweenAtTime", () => {
@@ -1,4 +1,6 @@
1
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import { GSAP_DEFAULT_DURATION } from "@hyperframes/parsers/gsap-parser";
3
+ import { progressAtTime, runEaseOf, timeAtProgress } from "./gsapKeyframeEases";
2
4
 
3
5
  export function absoluteToPercentage(
4
6
  time: number,
@@ -34,8 +36,8 @@ export function resolveTweenStart(animation: GsapAnimation): number | null {
34
36
  return null;
35
37
  }
36
38
 
37
- export function resolveTweenDuration(animation: GsapAnimation, fallback = 0.5): number {
38
- return animation.duration ?? fallback;
39
+ export function resolveTweenDuration(animation: GsapAnimation): number {
40
+ return animation.duration ?? GSAP_DEFAULT_DURATION;
39
41
  }
40
42
 
41
43
  export function findTweenAtTime(
@@ -60,7 +62,7 @@ export function absoluteToPercentageForAnimation(
60
62
  const start = resolveTweenStart(animation);
61
63
  if (start === null) return null;
62
64
  const duration = resolveTweenDuration(animation);
63
- return absoluteToPercentage(time, start, duration);
65
+ return progressAtTime(runEaseOf(animation), absoluteToPercentage(time, start, duration));
64
66
  }
65
67
 
66
68
  export function percentageToAbsoluteForAnimation(
@@ -70,7 +72,7 @@ export function percentageToAbsoluteForAnimation(
70
72
  const start = resolveTweenStart(animation);
71
73
  if (start === null) return null;
72
74
  const duration = resolveTweenDuration(animation);
73
- return percentageToAbsolute(pct, start, duration);
75
+ return percentageToAbsolute(timeAtProgress(runEaseOf(animation), pct), start, duration);
74
76
  }
75
77
 
76
78
  function matchesSelector(tweenSelector: string, querySelector: string): boolean {
@@ -0,0 +1,79 @@
1
+ import gsap from "gsap";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+
4
+ // GSAP 3.15 defaults: a percentage keyframe eases its segment power1.inOut; an array step is linear.
5
+ export const PERCENTAGE_SEGMENT_EASE = "power1.inOut";
6
+ export const ARRAY_STEP_EASE = "none";
7
+ // A motionPath tween is a plain tween to GSAP, so it runs GSAP's default tween ease.
8
+ export const MOTION_PATH_RUN_EASE = "power1.out";
9
+
10
+ /** `run` warps a keyframed tween's whole progress; a segment eases with the keyframe it arrives
11
+ * at, else easeEach, else GSAP's default for the form. A flat tween has no run ease. */
12
+ export function keyframedTweenEases(anim: GsapAnimation): {
13
+ run?: string;
14
+ segment: (arriving: { ease?: string }) => string;
15
+ } {
16
+ const data = anim.keyframes;
17
+ const fallback =
18
+ data?.format === "object-array" ? ARRAY_STEP_EASE : (data?.easeEach ?? PERCENTAGE_SEGMENT_EASE);
19
+ return {
20
+ run: data
21
+ ? (data.ease ?? anim.ease ?? (data.fromMotionPath ? MOTION_PATH_RUN_EASE : undefined))
22
+ : undefined,
23
+ segment: (arriving) => arriving.ease ?? fallback,
24
+ };
25
+ }
26
+
27
+ export function easeFunction(name: string | undefined): ((progress: number) => number) | null {
28
+ if (!name || name === "none" || name === "linear") return null;
29
+ return gsap.parseEase(name) ?? null;
30
+ }
31
+
32
+ export const runEaseOf = (anim: GsapAnimation) => keyframedTweenEases(anim).run;
33
+
34
+ const SCAN_STEPS = 200;
35
+ const BISECT_STEPS = 40;
36
+ const timingEases = new Map<string, ((progress: number) => number) | null>();
37
+
38
+ /** The run ease as a one-to-one time/progress map; an ease that overshoots 0-1 or stalls times keys linearly. */
39
+ function timingEase(runEase: string | undefined): ((progress: number) => number) | null {
40
+ if (!runEase) return null;
41
+ if (!timingEases.has(runEase)) {
42
+ const run = easeFunction(runEase);
43
+ const samples = Array.from(
44
+ { length: SCAN_STEPS + 1 },
45
+ (_, step) => run?.(step / SCAN_STEPS) ?? 0,
46
+ );
47
+ const oneToOne = samples.every((v, i) => v >= 0 && v <= 1 && (i === 0 || v > samples[i - 1]!));
48
+ timingEases.set(runEase, run && oneToOne ? run : null);
49
+ }
50
+ return timingEases.get(runEase) ?? null;
51
+ }
52
+
53
+ /** The keyframe progress (0-100) GSAP shows at `timePercentage` of a tween with this run ease. */
54
+ export function progressAtTime(runEase: string | undefined, timePercentage: number): number {
55
+ const run = timingEase(runEase);
56
+ return run ? run(timePercentage / 100) * 100 : timePercentage;
57
+ }
58
+
59
+ /** The time (0-100 of the tween) at which GSAP first reaches keyframe progress `percentage`. */
60
+ export function timeAtProgress(runEase: string | undefined, percentage: number): number {
61
+ const run = timingEase(runEase);
62
+ if (!run || percentage <= 0 || percentage >= 100) return percentage;
63
+ const target = percentage / 100;
64
+ let low = 0;
65
+ let high = 1;
66
+ for (let step = 1; step <= SCAN_STEPS; step++) {
67
+ if (run(step / SCAN_STEPS) >= target) {
68
+ low = (step - 1) / SCAN_STEPS;
69
+ high = step / SCAN_STEPS;
70
+ break;
71
+ }
72
+ }
73
+ for (let step = 0; step < BISECT_STEPS; step++) {
74
+ const mid = (low + high) / 2;
75
+ if (run(mid) >= target) high = mid;
76
+ else low = mid;
77
+ }
78
+ return high * 100;
79
+ }