@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.
- package/dist/assets/{hyperframes-player-uqzDaxVL.js → hyperframes-player-CKCx0CFA.js} +1 -1
- package/dist/assets/{index-DXSYlqmV.js → index-CF31Pq1E.js} +1 -1
- package/dist/assets/{index-COhbL1xq.js → index-ClLzyuwb.js} +171 -171
- package/dist/assets/{index-CLBNYlDY.js → index-CwS08gWW.js} +1 -1
- package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
- package/dist/chunk-7UQWVN52.js.map +1 -0
- package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
- package/dist/chunk-DRVWEPO5.js.map +1 -0
- package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
- package/dist/chunk-TDWGXBUT.js.map +1 -0
- package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
- package/dist/index.d.ts +8 -9
- package/dist/index.html +1 -1
- package/dist/index.js +1193 -869
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/components/TimelineToolbar.tsx +6 -7
- package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
- package/src/components/editor/domEditingLayers.test.ts +45 -18
- package/src/components/editor/domEditingLayers.ts +11 -9
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/keyframeRetime.ts +11 -4
- package/src/components/editor/probeSourceElement.ts +17 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
- package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
- package/src/hooks/gsapDragCommit.ts +8 -5
- package/src/hooks/gsapDragPositionCommit.ts +90 -17
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
- package/src/hooks/gsapMutationClient.ts +35 -7
- package/src/hooks/gsapOvershootEase.test.ts +67 -0
- package/src/hooks/gsapParsedTween.ts +5 -6
- package/src/hooks/gsapPositionDetection.ts +5 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
- package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
- package/src/hooks/gsapRuntimeKeyframes.ts +19 -59
- package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
- package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
- package/src/hooks/gsapShared.test.ts +48 -35
- package/src/hooks/gsapShared.ts +30 -32
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +93 -3
- package/src/hooks/gsapValueAtPlayhead.test.ts +13 -0
- package/src/hooks/gsapValueAtPlayhead.ts +3 -4
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +40 -14
- package/src/hooks/timelineLinkEditPlan.ts +6 -4
- package/src/hooks/timelineMoveAdapter.ts +3 -5
- package/src/hooks/timelineTimingSync.test.ts +83 -125
- package/src/hooks/timelineTimingSync.ts +62 -42
- package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
- package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
- package/src/hooks/useCommitPreflightCapabilities.ts +42 -7
- package/src/hooks/useDomEditPreviewSync.ts +8 -2
- package/src/hooks/useDomSelection.test.ts +27 -0
- package/src/hooks/useDomSelection.ts +12 -13
- package/src/hooks/useDomSelectionTypes.ts +1 -0
- package/src/hooks/useEditHistoryActions.test.tsx +54 -3
- package/src/hooks/useEditHistoryActions.ts +9 -6
- package/src/hooks/useEnableKeyframes.test.ts +20 -15
- package/src/hooks/useEnableKeyframes.ts +54 -71
- package/src/hooks/useGestureCommit.ts +2 -1
- package/src/hooks/useGsapTweenCache.ts +2 -17
- package/src/hooks/usePersistentEditHistory.test.ts +45 -0
- package/src/hooks/usePersistentEditHistory.ts +13 -2
- package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
- package/src/hooks/usePreviewPersistence.ts +26 -4
- package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
- package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
- package/src/hooks/useTimelineDeleteOps.ts +5 -13
- package/src/hooks/useTimelineEditing.test.tsx +63 -4
- package/src/hooks/useTimelineEditing.ts +21 -295
- package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
- package/src/hooks/useTimelineGroupEditing.ts +15 -38
- package/src/player/components/Timeline.test.ts +12 -4
- package/src/player/components/TimelineClip.tsx +3 -0
- package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
- package/src/player/components/TimelineLanes.test.tsx +61 -5
- package/src/player/components/TimelineLanes.tsx +20 -28
- package/src/player/components/TimelinePropertyLanes.tsx +10 -1
- package/src/player/components/timelineCallbacks.ts +1 -4
- package/src/player/components/timelineClipDragCommit.test.ts +31 -0
- package/src/player/components/timelineClipDragCommit.ts +39 -28
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
- package/src/player/components/timelineClipGestureHandlers.ts +2 -20
- package/src/player/components/timelineEditing.ts +1 -3
- package/src/player/components/timelineGroupResizeCommit.ts +10 -9
- package/src/player/components/timelineLinkSelection.ts +1 -35
- package/src/player/components/timelineOptimisticRevision.ts +7 -4
- package/src/player/components/trackHeaderLaneState.ts +9 -2
- package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
- package/src/player/components/trackHeaderLaneValues.ts +54 -13
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
- package/src/player/components/useTimelineClipDrag.ts +0 -3
- package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
- package/src/player/store/batchElementUpdates.test.ts +43 -0
- package/src/player/store/batchElementUpdates.ts +9 -0
- package/src/player/store/elementPatchQueue.ts +35 -0
- package/src/player/store/playerStore.ts +5 -6
- package/src/utils/domEditHelpers.ts +5 -0
- package/src/utils/globalTimeCompiler.test.ts +0 -4
- package/src/utils/globalTimeCompiler.ts +6 -4
- package/src/utils/gsapKeyframeEases.ts +79 -0
- package/src/utils/sameData.ts +9 -0
- package/src/utils/studioFileHistory.ts +1 -0
- package/src/utils/studioPendingEdits.ts +15 -1
- package/dist/chunk-246VB2G3.js.map +0 -1
- package/dist/chunk-3F47EFCT.js.map +0 -1
- package/dist/chunk-5EKWJZLM.js.map +0 -1
- /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
|
-
|
|
49
|
-
|
|
50
|
-
updateElement
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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("
|
|
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).
|
|
174
|
-
|
|
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
|
-
|
|
386
|
-
|
|
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
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
|
38
|
-
return animation.duration ??
|
|
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
|
+
}
|