@hyperframes/studio 0.7.5 → 0.7.6
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/{index-BwFzbjZQ.js → index-B_NnmU6q.js} +1 -1
- package/dist/assets/{index-C5NAfiPa.js → index-DsBhGFPe.js} +1 -1
- package/dist/assets/{index-DzWIinxk.css → index-wJZf6FK3.css} +1 -1
- package/dist/assets/index-ysftPins.js +374 -0
- package/dist/chunk-KZXYQYIU.js +876 -0
- package/dist/chunk-KZXYQYIU.js.map +1 -0
- package/dist/domEditingLayers-SSXQZHHQ.js +41 -0
- package/dist/domEditingLayers-SSXQZHHQ.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.html +2 -2
- package/dist/index.js +2298 -2483
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/captions/hooks/useCaptionSync.ts +6 -1
- package/src/components/StudioPreviewArea.tsx +10 -3
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/editor/AnimationCard.tsx +72 -251
- package/src/components/editor/AnimationCardParts.tsx +220 -0
- package/src/components/editor/BlockParamsPanel.tsx +4 -8
- package/src/components/editor/DomEditOverlay.tsx +132 -48
- package/src/components/editor/EaseCurveSection.tsx +6 -6
- package/src/components/editor/GsapAnimationSection.tsx +2 -0
- package/src/components/editor/KeyframeEaseList.tsx +63 -0
- package/src/components/editor/OffCanvasIndicators.tsx +111 -0
- package/src/components/editor/PropertyPanel.tsx +52 -54
- package/src/components/editor/gsapAnimationCallbacks.ts +1 -0
- package/src/components/editor/gsapAnimationConstants.ts +8 -0
- package/src/components/editor/manualOffsetDrag.ts +8 -0
- package/src/components/editor/marqueeCommit.ts +168 -0
- package/src/components/editor/propertyPanelHelpers.ts +1 -0
- package/src/components/editor/snapTargetCollection.ts +0 -5
- package/src/components/panels/SlideshowPanel.tsx +0 -1
- package/src/contexts/DomEditContext.tsx +8 -0
- package/src/hooks/gsapDragPositionCommit.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.ts +6 -2
- package/src/hooks/gsapRuntimeReaders.ts +1 -6
- package/src/hooks/useDomEditCommits.ts +0 -4
- package/src/hooks/useDomEditPreviewSync.ts +5 -0
- package/src/hooks/useDomEditSession.ts +23 -0
- package/src/hooks/useDomEditTextCommits.ts +3 -12
- package/src/hooks/useDomSelection.ts +46 -0
- package/src/hooks/useGestureCommit.ts +26 -3
- package/src/hooks/useGestureRecording.ts +2 -16
- package/src/hooks/useGsapAnimationOps.ts +2 -2
- package/src/hooks/useGsapTweenCache.ts +32 -4
- package/src/player/components/Player.tsx +0 -5
- package/src/player/components/timelineIcons.tsx +2 -1
- package/src/player/hooks/useTimelinePlayer.ts +4 -14
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -9
- package/src/player/lib/timelineIframeHelpers.ts +2 -6
- package/src/telemetry/client.ts +2 -0
- package/src/utils/editDebugLog.ts +3 -10
- package/src/utils/gestureSmoother.test.ts +48 -0
- package/src/utils/gestureSmoother.ts +46 -0
- package/src/utils/marqueeGeometry.test.ts +123 -0
- package/src/utils/marqueeGeometry.ts +172 -0
- package/src/utils/optimisticUpdate.ts +1 -2
- package/src/utils/sourcePatcher.ts +0 -10
- package/src/utils/velocityEaseFitter.test.ts +58 -0
- package/src/utils/velocityEaseFitter.ts +121 -0
- package/dist/assets/index-D_JGXmfx.js +0 -374
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { marqueeIntersectsObb, rectsOverlap, type Point, type Rect } from "./marqueeGeometry";
|
|
3
|
+
|
|
4
|
+
type Corners = [Point, Point, Point, Point];
|
|
5
|
+
|
|
6
|
+
function rotateCorners(cx: number, cy: number, w: number, h: number, deg: number): Corners {
|
|
7
|
+
const rad = (deg * Math.PI) / 180;
|
|
8
|
+
const cos = Math.cos(rad);
|
|
9
|
+
const sin = Math.sin(rad);
|
|
10
|
+
const hw = w / 2;
|
|
11
|
+
const hh = h / 2;
|
|
12
|
+
const local: [number, number][] = [
|
|
13
|
+
[-hw, -hh],
|
|
14
|
+
[hw, -hh],
|
|
15
|
+
[hw, hh],
|
|
16
|
+
[-hw, hh],
|
|
17
|
+
];
|
|
18
|
+
return local.map(([lx, ly]) => ({
|
|
19
|
+
x: cx + cos * lx - sin * ly,
|
|
20
|
+
y: cy + sin * lx + cos * ly,
|
|
21
|
+
})) as Corners;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function aabbCorners(r: Rect): Corners {
|
|
25
|
+
return [
|
|
26
|
+
{ x: r.left, y: r.top },
|
|
27
|
+
{ x: r.left + r.width, y: r.top },
|
|
28
|
+
{ x: r.left + r.width, y: r.top + r.height },
|
|
29
|
+
{ x: r.left, y: r.top + r.height },
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("rectsOverlap", () => {
|
|
34
|
+
it("overlapping rects", () => {
|
|
35
|
+
expect(
|
|
36
|
+
rectsOverlap(
|
|
37
|
+
{ left: 0, top: 0, width: 10, height: 10 },
|
|
38
|
+
{ left: 5, top: 5, width: 10, height: 10 },
|
|
39
|
+
),
|
|
40
|
+
).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("non-overlapping rects", () => {
|
|
44
|
+
expect(
|
|
45
|
+
rectsOverlap(
|
|
46
|
+
{ left: 0, top: 0, width: 10, height: 10 },
|
|
47
|
+
{ left: 20, top: 20, width: 10, height: 10 },
|
|
48
|
+
),
|
|
49
|
+
).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("marqueeIntersectsObb", () => {
|
|
54
|
+
it("axis-aligned overlap", () => {
|
|
55
|
+
const marquee: Rect = { left: 0, top: 0, width: 100, height: 100 };
|
|
56
|
+
const corners = aabbCorners({ left: 50, top: 50, width: 80, height: 80 });
|
|
57
|
+
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("axis-aligned no overlap", () => {
|
|
61
|
+
const marquee: Rect = { left: 0, top: 0, width: 50, height: 50 };
|
|
62
|
+
const corners = aabbCorners({ left: 100, top: 100, width: 50, height: 50 });
|
|
63
|
+
expect(marqueeIntersectsObb(marquee, corners)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("marquee fully contains element", () => {
|
|
67
|
+
const marquee: Rect = { left: 0, top: 0, width: 200, height: 200 };
|
|
68
|
+
const corners = aabbCorners({ left: 50, top: 50, width: 20, height: 20 });
|
|
69
|
+
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("element fully contains marquee", () => {
|
|
73
|
+
const marquee: Rect = { left: 50, top: 50, width: 10, height: 10 };
|
|
74
|
+
const corners = aabbCorners({ left: 0, top: 0, width: 200, height: 200 });
|
|
75
|
+
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("45-degree rotated square: AABB overlaps but OBB does not", () => {
|
|
79
|
+
// 100x100 square rotated 45° centered at (200,200)
|
|
80
|
+
// Its AABB extends to ~(129,129)-(271,271)
|
|
81
|
+
// A marquee at (0,0)-(135,135) overlaps the AABB but NOT the diamond
|
|
82
|
+
const corners = rotateCorners(200, 200, 100, 100, 45);
|
|
83
|
+
const marquee: Rect = { left: 0, top: 0, width: 135, height: 135 };
|
|
84
|
+
expect(marqueeIntersectsObb(marquee, corners)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("45-degree rotated square: OBB overlaps", () => {
|
|
88
|
+
// Same rotated square, marquee reaches the diamond's left point
|
|
89
|
+
const corners = rotateCorners(200, 200, 100, 100, 45);
|
|
90
|
+
const marquee: Rect = { left: 0, top: 150, width: 155, height: 100 };
|
|
91
|
+
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("zero-width marquee returns false", () => {
|
|
95
|
+
const corners = aabbCorners({ left: 0, top: 0, width: 100, height: 100 });
|
|
96
|
+
expect(marqueeIntersectsObb({ left: 50, top: 50, width: 0, height: 50 }, corners)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("zero-area element returns false for degenerate OBB", () => {
|
|
100
|
+
const corners: Corners = [
|
|
101
|
+
{ x: 50, y: 50 },
|
|
102
|
+
{ x: 50, y: 50 },
|
|
103
|
+
{ x: 50, y: 50 },
|
|
104
|
+
{ x: 50, y: 50 },
|
|
105
|
+
];
|
|
106
|
+
const marquee: Rect = { left: 0, top: 0, width: 100, height: 100 };
|
|
107
|
+
// Degenerate point — SAT still works (projections are zero-length intervals)
|
|
108
|
+
// A point inside the marquee should still intersect
|
|
109
|
+
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("30-degree rotated rectangle clips marquee corner", () => {
|
|
113
|
+
const corners = rotateCorners(150, 150, 200, 50, 30);
|
|
114
|
+
const marquee: Rect = { left: 0, top: 0, width: 80, height: 130 };
|
|
115
|
+
expect(marqueeIntersectsObb(marquee, corners)).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("30-degree rotated rectangle misses marquee", () => {
|
|
119
|
+
const corners = rotateCorners(300, 300, 50, 50, 30);
|
|
120
|
+
const marquee: Rect = { left: 0, top: 0, width: 100, height: 100 };
|
|
121
|
+
expect(marqueeIntersectsObb(marquee, corners)).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
export interface Point {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface Rect {
|
|
7
|
+
left: number;
|
|
8
|
+
top: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Corners = [Point, Point, Point, Point];
|
|
14
|
+
|
|
15
|
+
function isIdentityMatrix(m: DOMMatrix): boolean {
|
|
16
|
+
const e = 1e-6;
|
|
17
|
+
return Math.abs(m.a - 1) < e && Math.abs(m.b) < e && Math.abs(m.c) < e && Math.abs(m.d - 1) < e;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function rectsOverlap(a: Rect, b: Rect): boolean {
|
|
21
|
+
return (
|
|
22
|
+
a.left < b.left + b.width &&
|
|
23
|
+
a.left + a.width > b.left &&
|
|
24
|
+
a.top < b.top + b.height &&
|
|
25
|
+
a.top + a.height > b.top
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function projectOntoAxis(corners: Corners, ax: number, ay: number): [number, number] {
|
|
30
|
+
let min = Infinity;
|
|
31
|
+
let max = -Infinity;
|
|
32
|
+
for (const c of corners) {
|
|
33
|
+
const dot = c.x * ax + c.y * ay;
|
|
34
|
+
if (dot < min) min = dot;
|
|
35
|
+
if (dot > max) max = dot;
|
|
36
|
+
}
|
|
37
|
+
return [min, max];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function projectionsOverlap(a: [number, number], b: [number, number]): boolean {
|
|
41
|
+
return a[0] <= b[1] && b[0] <= a[1];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* SAT intersection test between an axis-aligned marquee rect and a
|
|
46
|
+
* convex quadrilateral (the element's OBB corners in overlay space).
|
|
47
|
+
*
|
|
48
|
+
* Separating axes: 2 from the AABB (horizontal, vertical) + 2 from
|
|
49
|
+
* the OBB's edge normals. If projections overlap on all 4 axes, the
|
|
50
|
+
* shapes intersect.
|
|
51
|
+
*/
|
|
52
|
+
export function marqueeIntersectsObb(marquee: Rect, corners: Corners): boolean {
|
|
53
|
+
if (marquee.width <= 0 || marquee.height <= 0) return false;
|
|
54
|
+
|
|
55
|
+
const mCorners: Corners = [
|
|
56
|
+
{ x: marquee.left, y: marquee.top },
|
|
57
|
+
{ x: marquee.left + marquee.width, y: marquee.top },
|
|
58
|
+
{ x: marquee.left + marquee.width, y: marquee.top + marquee.height },
|
|
59
|
+
{ x: marquee.left, y: marquee.top + marquee.height },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
// AABB axes: (1,0) and (0,1)
|
|
63
|
+
const mProjX: [number, number] = [marquee.left, marquee.left + marquee.width];
|
|
64
|
+
const mProjY: [number, number] = [marquee.top, marquee.top + marquee.height];
|
|
65
|
+
|
|
66
|
+
const oProjX = projectOntoAxis(corners, 1, 0);
|
|
67
|
+
const oProjY = projectOntoAxis(corners, 0, 1);
|
|
68
|
+
|
|
69
|
+
if (!projectionsOverlap(mProjX, oProjX)) return false;
|
|
70
|
+
if (!projectionsOverlap(mProjY, oProjY)) return false;
|
|
71
|
+
|
|
72
|
+
// OBB edge normals (only need 2 — edges 0→1 and 1→2)
|
|
73
|
+
for (let i = 0; i < 2; i++) {
|
|
74
|
+
const edge = {
|
|
75
|
+
x: corners[i + 1].x - corners[i].x,
|
|
76
|
+
y: corners[i + 1].y - corners[i].y,
|
|
77
|
+
};
|
|
78
|
+
const len = Math.hypot(edge.x, edge.y);
|
|
79
|
+
if (len < 1e-9) continue;
|
|
80
|
+
const ax = -edge.y / len;
|
|
81
|
+
const ay = edge.x / len;
|
|
82
|
+
|
|
83
|
+
const mProj = projectOntoAxis(mCorners, ax, ay);
|
|
84
|
+
const oProj = projectOntoAxis(corners, ax, ay);
|
|
85
|
+
if (!projectionsOverlap(mProj, oProj)) return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compute the four corners of an element's OBB in overlay-pixel space.
|
|
93
|
+
*
|
|
94
|
+
* For elements with an identity transform, returns the axis-aligned
|
|
95
|
+
* corners from the element's BCR mapped to overlay space (fast path).
|
|
96
|
+
*/
|
|
97
|
+
// fallow-ignore-next-line complexity
|
|
98
|
+
export function elementObbCorners(
|
|
99
|
+
element: HTMLElement,
|
|
100
|
+
overlayEl: HTMLDivElement,
|
|
101
|
+
iframe: HTMLIFrameElement,
|
|
102
|
+
): Corners | null {
|
|
103
|
+
const doc = iframe.contentDocument;
|
|
104
|
+
if (!doc) return null;
|
|
105
|
+
|
|
106
|
+
const iframeRect = iframe.getBoundingClientRect();
|
|
107
|
+
const overlayRect = overlayEl.getBoundingClientRect();
|
|
108
|
+
const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement;
|
|
109
|
+
const declaredW = Number.parseFloat(root?.getAttribute("data-width") ?? "");
|
|
110
|
+
const declaredH = Number.parseFloat(root?.getAttribute("data-height") ?? "");
|
|
111
|
+
const rootW = declaredW > 0 ? declaredW : root?.getBoundingClientRect().width || 1;
|
|
112
|
+
const rootH = declaredH > 0 ? declaredH : root?.getBoundingClientRect().height || 1;
|
|
113
|
+
|
|
114
|
+
const scaleX = iframeRect.width / rootW;
|
|
115
|
+
const scaleY = iframeRect.height / rootH;
|
|
116
|
+
const offsetX = iframeRect.left - overlayRect.left;
|
|
117
|
+
const offsetY = iframeRect.top - overlayRect.top;
|
|
118
|
+
|
|
119
|
+
const win = element.ownerDocument.defaultView;
|
|
120
|
+
if (!win) return null;
|
|
121
|
+
|
|
122
|
+
const transform = win.getComputedStyle(element).transform;
|
|
123
|
+
const m = transform && transform !== "none" ? new DOMMatrix(transform) : new DOMMatrix();
|
|
124
|
+
|
|
125
|
+
if (isIdentityMatrix(m)) {
|
|
126
|
+
const r = element.getBoundingClientRect();
|
|
127
|
+
const left = offsetX + r.left * scaleX;
|
|
128
|
+
const top = offsetY + r.top * scaleY;
|
|
129
|
+
const w = r.width * scaleX;
|
|
130
|
+
const h = r.height * scaleY;
|
|
131
|
+
return [
|
|
132
|
+
{ x: left, y: top },
|
|
133
|
+
{ x: left + w, y: top },
|
|
134
|
+
{ x: left + w, y: top + h },
|
|
135
|
+
{ x: left, y: top + h },
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Walk offsetParent chain for pre-transform position
|
|
140
|
+
let ox = 0;
|
|
141
|
+
let oy = 0;
|
|
142
|
+
let el: HTMLElement | null = element;
|
|
143
|
+
while (el && el !== doc.body && el !== doc.documentElement) {
|
|
144
|
+
ox += el.offsetLeft;
|
|
145
|
+
oy += el.offsetTop;
|
|
146
|
+
el = el.offsetParent as HTMLElement | null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const w = element.offsetWidth;
|
|
150
|
+
const h = element.offsetHeight;
|
|
151
|
+
// ponytail: center-based transform — CSS transforms originate at 50% 50%
|
|
152
|
+
const cx = ox + w / 2;
|
|
153
|
+
const cy = oy + h / 2;
|
|
154
|
+
|
|
155
|
+
const localCorners: [number, number][] = [
|
|
156
|
+
[-w / 2, -h / 2],
|
|
157
|
+
[w / 2, -h / 2],
|
|
158
|
+
[w / 2, h / 2],
|
|
159
|
+
[-w / 2, h / 2],
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
return localCorners.map(([lx, ly]) => {
|
|
163
|
+
const tx = m.a * lx + m.c * ly + cx;
|
|
164
|
+
const ty = m.b * lx + m.d * ly + cy;
|
|
165
|
+
return {
|
|
166
|
+
x: offsetX + tx * scaleX,
|
|
167
|
+
y: offsetY + ty * scaleY,
|
|
168
|
+
};
|
|
169
|
+
}) as Corners;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export { rectsOverlap };
|
|
@@ -11,8 +11,7 @@ export async function executeOptimistic<T>(options: OptimisticUpdateOptions<T>):
|
|
|
11
11
|
const snapshot = options.apply();
|
|
12
12
|
try {
|
|
13
13
|
await options.persist();
|
|
14
|
-
} catch
|
|
14
|
+
} catch {
|
|
15
15
|
options.rollback(snapshot);
|
|
16
|
-
console.warn("[optimistic] Mutation failed, rolled back:", error);
|
|
17
16
|
}
|
|
18
17
|
}
|
|
@@ -239,16 +239,6 @@ function execDataAttrPattern(html: string, attr: string, value: string): TagMatc
|
|
|
239
239
|
const pattern = new RegExp(`(<[^>]*\\b${attr}=(["'])${escapeRegex(value)}\\2[^>]*)>`, "i");
|
|
240
240
|
const match = pattern.exec(html);
|
|
241
241
|
if (match?.index == null) return null;
|
|
242
|
-
// Defensive: a second exact match means a duplicate id/attr in the source
|
|
243
|
-
// (id drift). Don't silently patch the first while leaving the other stale —
|
|
244
|
-
// surface it. By the mint contract this should never fire.
|
|
245
|
-
const all = html.match(new RegExp(`<[^>]*\\b${attr}=(["'])${escapeRegex(value)}\\1[^>]*>`, "gi"));
|
|
246
|
-
if (all && all.length > 1) {
|
|
247
|
-
// eslint-disable-next-line no-console
|
|
248
|
-
console.warn(
|
|
249
|
-
`sourcePatcher: ${attr}="${value}" matched ${all.length} elements; patching the first. ids/attrs must be unique per document.`,
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
242
|
return { tag: match[1], start: match.index, end: match.index + match[1].length };
|
|
253
243
|
}
|
|
254
244
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { fitEasesFromVelocity, type FittedKeyframe } from "./velocityEaseFitter";
|
|
3
|
+
|
|
4
|
+
function makeSamples(
|
|
5
|
+
count: number,
|
|
6
|
+
duration: number,
|
|
7
|
+
velocityFn: (t: number) => number,
|
|
8
|
+
): { time: number; properties: Record<string, number> }[] {
|
|
9
|
+
const samples = [];
|
|
10
|
+
let pos = 0;
|
|
11
|
+
for (let i = 0; i <= count; i++) {
|
|
12
|
+
const t = (i / count) * duration;
|
|
13
|
+
const v = velocityFn(t / duration);
|
|
14
|
+
pos += v * (duration / count);
|
|
15
|
+
samples.push({ time: t, properties: { x: pos } });
|
|
16
|
+
}
|
|
17
|
+
return samples;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("fitEasesFromVelocity", () => {
|
|
21
|
+
it("constant speed → no ease assigned", () => {
|
|
22
|
+
const kfs: FittedKeyframe[] = [
|
|
23
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
24
|
+
{ percentage: 100, properties: { x: 100 } },
|
|
25
|
+
];
|
|
26
|
+
const samples = makeSamples(60, 1, () => 100);
|
|
27
|
+
const result = fitEasesFromVelocity(kfs, samples, 1);
|
|
28
|
+
expect(result[1].ease).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("decelerate at end → AE Easy Ease In (slow-end curve)", () => {
|
|
32
|
+
const kfs: FittedKeyframe[] = [
|
|
33
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
34
|
+
{ percentage: 100, properties: { x: 100 } },
|
|
35
|
+
];
|
|
36
|
+
// Start fast, end slow → playback must also be slow at the end (CP2 y=1).
|
|
37
|
+
const samples = makeSamples(60, 1, (t) => Math.max(0, 200 * (1 - t)));
|
|
38
|
+
const result = fitEasesFromVelocity(kfs, samples, 1);
|
|
39
|
+
expect(result[1].ease).toBe("custom(M0,0 C0.333,0.333 0.667,1 1,1)");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("accelerate from start → AE Easy Ease Out (slow-start curve)", () => {
|
|
43
|
+
const kfs: FittedKeyframe[] = [
|
|
44
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
45
|
+
{ percentage: 100, properties: { x: 100 } },
|
|
46
|
+
];
|
|
47
|
+
// Start slow, end fast → playback must also be slow at the start (CP1 y=0).
|
|
48
|
+
const samples = makeSamples(60, 1, (t) => 200 * t);
|
|
49
|
+
const result = fitEasesFromVelocity(kfs, samples, 1);
|
|
50
|
+
expect(result[1].ease).toBe("custom(M0,0 C0.333,0 0.667,0.667 1,1)");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("single keyframe → returns unchanged", () => {
|
|
54
|
+
const kfs: FittedKeyframe[] = [{ percentage: 0, properties: { x: 0 } }];
|
|
55
|
+
const result = fitEasesFromVelocity(kfs, [], 1);
|
|
56
|
+
expect(result).toEqual(kfs);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
interface TimedSample {
|
|
2
|
+
time: number;
|
|
3
|
+
value: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// After Effects convention (ease named by the keyframe side it acts on):
|
|
7
|
+
// Easy Ease — slow at both ends (cubic-bezier 0.333,0 0.667,1)
|
|
8
|
+
// Easy Ease In — eases *into* the keyframe → decelerates → slow at the END
|
|
9
|
+
// Easy Ease Out — eases *out of* the keyframe → accelerates → slow at the START
|
|
10
|
+
// The control-point y values must match that polarity (a flat tangent at the
|
|
11
|
+
// slow side): slow-end pins CP2 at y=1, slow-start pins CP1 at y=0.
|
|
12
|
+
const AE_EASE = "custom(M0,0 C0.333,0 0.667,1 1,1)";
|
|
13
|
+
const AE_EASE_IN = "custom(M0,0 C0.333,0.333 0.667,1 1,1)";
|
|
14
|
+
const AE_EASE_OUT = "custom(M0,0 C0.333,0 0.667,0.667 1,1)";
|
|
15
|
+
const VELOCITY_THRESHOLD = 0.3;
|
|
16
|
+
|
|
17
|
+
function averageSpeed(samples: TimedSample[], from: number, to: number): number {
|
|
18
|
+
const seg = samples.filter((s) => s.time >= from && s.time <= to);
|
|
19
|
+
if (seg.length < 2) return 0;
|
|
20
|
+
let total = 0;
|
|
21
|
+
for (let i = 1; i < seg.length; i++) {
|
|
22
|
+
const dt = seg[i].time - seg[i - 1].time;
|
|
23
|
+
if (dt > 0) total += Math.abs(seg[i].value - seg[i - 1].value) / dt;
|
|
24
|
+
}
|
|
25
|
+
return total / (seg.length - 1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function speedAtEdge(
|
|
29
|
+
samples: TimedSample[],
|
|
30
|
+
t: number,
|
|
31
|
+
window: number,
|
|
32
|
+
side: "start" | "end",
|
|
33
|
+
): number {
|
|
34
|
+
const near = samples.filter((s) =>
|
|
35
|
+
side === "start" ? s.time >= t && s.time <= t + window : s.time >= t - window && s.time <= t,
|
|
36
|
+
);
|
|
37
|
+
if (near.length < 2) return 0;
|
|
38
|
+
let total = 0;
|
|
39
|
+
for (let i = 1; i < near.length; i++) {
|
|
40
|
+
const dt = near[i].time - near[i - 1].time;
|
|
41
|
+
if (dt > 0) total += Math.abs(near[i].value - near[i - 1].value) / dt;
|
|
42
|
+
}
|
|
43
|
+
return total / (near.length - 1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface FittedKeyframe {
|
|
47
|
+
percentage: number;
|
|
48
|
+
properties: Record<string, number | string>;
|
|
49
|
+
ease?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Analyze velocity profile of raw samples between keyframes and assign
|
|
54
|
+
* per-keyframe eases based on deceleration/acceleration patterns.
|
|
55
|
+
*
|
|
56
|
+
* For each segment between consecutive keyframes:
|
|
57
|
+
* - Constant speed → linear ("none")
|
|
58
|
+
* - Decelerates at end → Easy Ease In
|
|
59
|
+
* - Accelerates from start → Easy Ease Out
|
|
60
|
+
* - Both → Easy Ease (full)
|
|
61
|
+
*/
|
|
62
|
+
// fallow-ignore-next-line complexity
|
|
63
|
+
export function fitEasesFromVelocity(
|
|
64
|
+
keyframes: FittedKeyframe[],
|
|
65
|
+
rawSamples: { time: number; properties: Record<string, number> }[],
|
|
66
|
+
totalDuration: number,
|
|
67
|
+
): FittedKeyframe[] {
|
|
68
|
+
if (keyframes.length < 2 || rawSamples.length < 3) return keyframes;
|
|
69
|
+
|
|
70
|
+
const result = [...keyframes.map((kf) => ({ ...kf }))];
|
|
71
|
+
|
|
72
|
+
for (let i = 1; i < result.length; i++) {
|
|
73
|
+
const prevPct = result[i - 1].percentage;
|
|
74
|
+
const currPct = result[i].percentage;
|
|
75
|
+
const segStart = (prevPct / 100) * totalDuration;
|
|
76
|
+
const segEnd = (currPct / 100) * totalDuration;
|
|
77
|
+
const segDur = segEnd - segStart;
|
|
78
|
+
if (segDur <= 0) continue;
|
|
79
|
+
|
|
80
|
+
// Use the dominant property (largest range) for velocity analysis
|
|
81
|
+
const props = Object.keys(result[i].properties);
|
|
82
|
+
let bestProp = props[0] ?? "x";
|
|
83
|
+
let bestRange = 0;
|
|
84
|
+
for (const p of props) {
|
|
85
|
+
const startVal = Number(result[i - 1].properties[p] ?? 0);
|
|
86
|
+
const endVal = Number(result[i].properties[p] ?? 0);
|
|
87
|
+
const range = Math.abs(endVal - startVal);
|
|
88
|
+
if (range > bestRange) {
|
|
89
|
+
bestRange = range;
|
|
90
|
+
bestProp = p;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const propSamples: TimedSample[] = rawSamples
|
|
95
|
+
.filter((s) => s.time >= segStart && s.time <= segEnd)
|
|
96
|
+
.map((s) => ({ time: s.time, value: s.properties[bestProp] ?? 0 }));
|
|
97
|
+
|
|
98
|
+
if (propSamples.length < 3) continue;
|
|
99
|
+
|
|
100
|
+
const edgeWindow = segDur * 0.25;
|
|
101
|
+
const avgSpd = averageSpeed(propSamples, segStart, segEnd);
|
|
102
|
+
if (avgSpd < 1e-6) continue;
|
|
103
|
+
|
|
104
|
+
const startSpd = speedAtEdge(propSamples, segStart, edgeWindow, "start");
|
|
105
|
+
const endSpd = speedAtEdge(propSamples, segEnd, edgeWindow, "end");
|
|
106
|
+
|
|
107
|
+
const slowStart = startSpd / avgSpd < VELOCITY_THRESHOLD;
|
|
108
|
+
const slowEnd = endSpd / avgSpd < VELOCITY_THRESHOLD;
|
|
109
|
+
|
|
110
|
+
if (slowStart && slowEnd) {
|
|
111
|
+
result[i].ease = AE_EASE;
|
|
112
|
+
} else if (slowEnd) {
|
|
113
|
+
result[i].ease = AE_EASE_IN;
|
|
114
|
+
} else if (slowStart) {
|
|
115
|
+
result[i].ease = AE_EASE_OUT;
|
|
116
|
+
}
|
|
117
|
+
// Otherwise leave ease undefined → linear (constant speed)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return result;
|
|
121
|
+
}
|