@hyperframes/studio 0.7.5 → 0.7.7
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-B2YXvFxf.js} +1 -1
- package/dist/assets/index-BSkUuN8g.css +1 -0
- package/dist/assets/index-BeRh2hMe.js +375 -0
- package/dist/assets/{index-C5NAfiPa.js → index-BoASKOeE.js} +1 -1
- 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 +2 -0
- package/dist/index.html +2 -2
- package/dist/index.js +3326 -2939
- 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 +4 -0
- package/src/components/editor/AnimationCard.tsx +77 -250
- 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 +177 -101
- package/src/components/editor/GsapAnimationSection.tsx +4 -0
- package/src/components/editor/KeyframeEaseList.tsx +127 -0
- package/src/components/editor/OffCanvasIndicators.tsx +111 -0
- package/src/components/editor/PropertyPanel.tsx +63 -56
- package/src/components/editor/gsapAnimationCallbacks.ts +3 -0
- package/src/components/editor/gsapAnimationConstants.ts +11 -32
- package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
- package/src/components/editor/manualOffsetDrag.ts +8 -0
- package/src/components/editor/marqueeCommit.ts +168 -0
- package/src/components/editor/propertyPanelHelpers.ts +7 -0
- package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
- package/src/components/editor/snapTargetCollection.ts +0 -5
- package/src/components/editor/useMotionPathData.ts +2 -1
- package/src/components/panels/SlideshowPanel.tsx +0 -1
- package/src/components/sidebar/AssetContextMenu.tsx +97 -0
- package/src/components/sidebar/AssetsTab.tsx +542 -254
- package/src/components/sidebar/assetHelpers.ts +40 -0
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/hooks/gsapDragCommit.ts +101 -0
- package/src/hooks/gsapDragPositionCommit.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.ts +28 -2
- package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -0
- 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 +43 -0
- package/src/hooks/useDomEditTextCommits.ts +3 -12
- package/src/hooks/useDomEditWiring.ts +3 -0
- package/src/hooks/useDomSelection.ts +46 -0
- package/src/hooks/useGestureCommit.ts +26 -7
- package/src/hooks/useGestureRecording.ts +2 -16
- package/src/hooks/useGsapAnimationOps.ts +2 -2
- package/src/hooks/useGsapAwareEditing.ts +0 -4
- package/src/hooks/useGsapScriptCommits.ts +0 -12
- package/src/hooks/useGsapTweenCache.test.ts +45 -1
- package/src/hooks/useGsapTweenCache.ts +151 -42
- package/src/hooks/useMusicBeatAnalysis.ts +36 -21
- package/src/hooks/useRazorSplit.ts +0 -3
- 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/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
- package/dist/assets/index-DzWIinxk.css +0 -1
- package/src/utils/editDebugLog.ts +0 -16
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
|
-
import { editLog } from "../utils/editDebugLog";
|
|
3
2
|
import type { TimelineElement } from "../player";
|
|
4
3
|
import { usePlayerStore } from "../player";
|
|
5
4
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
@@ -120,7 +119,6 @@ async function executeSplit(
|
|
|
120
119
|
if (!patchTarget) throw new Error("Clip is missing a patchable target.");
|
|
121
120
|
|
|
122
121
|
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
123
|
-
editLog("razor-split", { id: element.domId ?? element.id, splitTime, file: targetPath });
|
|
124
122
|
const originalContent = await readFileContent(pid, targetPath);
|
|
125
123
|
const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip");
|
|
126
124
|
|
|
@@ -134,7 +132,6 @@ async function executeSplit(
|
|
|
134
132
|
element.duration,
|
|
135
133
|
);
|
|
136
134
|
if (!splitResult.ok) throw new Error("Failed to split clip.");
|
|
137
|
-
editLog("razor-split:done", { changed: splitResult.changed, newId });
|
|
138
135
|
if (!splitResult.changed) {
|
|
139
136
|
return { targetPath, originalContent, patchedContent: originalContent, changed: false };
|
|
140
137
|
}
|
|
@@ -252,11 +252,6 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
252
252
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
|
253
253
|
assetPollRef.current = null;
|
|
254
254
|
setAssetsLoading(false);
|
|
255
|
-
if (lastUnloaded) {
|
|
256
|
-
console.debug(
|
|
257
|
-
"[Player] Asset-loading overlay timed out after 10s; hiding anyway. Check network or asset integrity.",
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
255
|
}
|
|
261
256
|
}, 100);
|
|
262
257
|
} else {
|
|
@@ -39,7 +39,8 @@ const ICONS: Record<string, ReactNode> = {
|
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
export function getTrackStyle(tag: string): TrackVisualStyle {
|
|
42
|
-
|
|
42
|
+
// Defensive: callers may pass an empty/undefined tag; fall back to "div"
|
|
43
|
+
// (restores the #1679 null-guard that a restack had dropped).
|
|
43
44
|
const safeTag = tag || "div";
|
|
44
45
|
const trackStyle = getTimelineTrackStyle(safeTag);
|
|
45
46
|
const normalized = safeTag.toLowerCase();
|
|
@@ -199,8 +199,7 @@ export function useTimelinePlayer() {
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
return bestAdapter;
|
|
202
|
-
} catch
|
|
203
|
-
console.warn("[useTimelinePlayer] Could not get playback adapter (cross-origin)", err);
|
|
202
|
+
} catch {
|
|
204
203
|
return null;
|
|
205
204
|
}
|
|
206
205
|
}, []);
|
|
@@ -264,9 +263,7 @@ export function useTimelinePlayer() {
|
|
|
264
263
|
}
|
|
265
264
|
}
|
|
266
265
|
}
|
|
267
|
-
} catch
|
|
268
|
-
console.warn("[useTimelinePlayer] Could not set playback rate (cross-origin)", err);
|
|
269
|
-
}
|
|
266
|
+
} catch {}
|
|
270
267
|
}, []);
|
|
271
268
|
const applyPreviewAudioState = useCallback((playbackRateOverride?: number) => {
|
|
272
269
|
const { audioMuted, playbackRate } = usePlayerStore.getState();
|
|
@@ -506,9 +503,7 @@ export function useTimelinePlayer() {
|
|
|
506
503
|
if (msSinceTimeline > 500) {
|
|
507
504
|
enrichMissingCompositionsRef.current();
|
|
508
505
|
}
|
|
509
|
-
} catch
|
|
510
|
-
console.warn("[useTimelinePlayer] Could not read clip manifest from iframe", err);
|
|
511
|
-
}
|
|
506
|
+
} catch {}
|
|
512
507
|
}
|
|
513
508
|
if (data?.source === "hf-preview" && data?.type === "timeline" && Array.isArray(data.clips)) {
|
|
514
509
|
lastTimelineMessageRef.current = Date.now();
|
|
@@ -524,12 +519,7 @@ export function useTimelinePlayer() {
|
|
|
524
519
|
syncTimelineElements(els);
|
|
525
520
|
}
|
|
526
521
|
}
|
|
527
|
-
} catch
|
|
528
|
-
console.warn(
|
|
529
|
-
"[useTimelinePlayer] Could not read timeline elements on navigate (cross-origin)",
|
|
530
|
-
err,
|
|
531
|
-
);
|
|
532
|
-
}
|
|
522
|
+
} catch {}
|
|
533
523
|
}
|
|
534
524
|
}
|
|
535
525
|
};
|
|
@@ -164,9 +164,7 @@ export function useTimelineSyncCallbacks({
|
|
|
164
164
|
const dedupedMissing = missing.filter((m) => !finalIds.has(m.id));
|
|
165
165
|
syncTimelineElements([...updatedEls, ...dedupedMissing]);
|
|
166
166
|
}
|
|
167
|
-
} catch
|
|
168
|
-
console.warn("[useTimelinePlayer] enrichMissingCompositions failed", err);
|
|
169
|
-
}
|
|
167
|
+
} catch {}
|
|
170
168
|
}, [iframeRef, syncTimelineElements]);
|
|
171
169
|
|
|
172
170
|
const initializeAdapter = useCallback(() => {
|
|
@@ -241,9 +239,7 @@ export function useTimelineSyncCallbacks({
|
|
|
241
239
|
if (fallbackElement) syncTimelineElements([fallbackElement]);
|
|
242
240
|
}
|
|
243
241
|
}
|
|
244
|
-
} catch
|
|
245
|
-
console.warn("[useTimelinePlayer] Could not read timeline elements from iframe", err);
|
|
246
|
-
}
|
|
242
|
+
} catch {}
|
|
247
243
|
return true;
|
|
248
244
|
}, [
|
|
249
245
|
getAdapter,
|
|
@@ -295,9 +291,6 @@ export function useTimelineSyncCallbacks({
|
|
|
295
291
|
probeIntervalRef.current = setTimeout(() => {
|
|
296
292
|
if (!settled) {
|
|
297
293
|
trySettle();
|
|
298
|
-
if (!settled) {
|
|
299
|
-
console.warn("[useTimelinePlayer] Runtime did not signal readiness within 5s");
|
|
300
|
-
}
|
|
301
294
|
}
|
|
302
295
|
window.removeEventListener("message", onMessage);
|
|
303
296
|
}, 5000) as unknown as ReturnType<typeof setInterval>;
|
|
@@ -121,9 +121,7 @@ export function setPreviewMediaMuted(iframe: HTMLIFrameElement | null, muted: bo
|
|
|
121
121
|
return;
|
|
122
122
|
}
|
|
123
123
|
postPreviewControl(iframe, "set-muted", { muted });
|
|
124
|
-
} catch
|
|
125
|
-
console.warn("[useTimelinePlayer] Failed to set preview media mute state", err);
|
|
126
|
-
}
|
|
124
|
+
} catch {}
|
|
127
125
|
}
|
|
128
126
|
|
|
129
127
|
export function setPreviewPlaybackRate(
|
|
@@ -139,9 +137,7 @@ export function setPreviewPlaybackRate(
|
|
|
139
137
|
return;
|
|
140
138
|
}
|
|
141
139
|
postPreviewControl(iframe, "set-playback-rate", { playbackRate: rate });
|
|
142
|
-
} catch
|
|
143
|
-
console.warn("[useTimelinePlayer] Failed to set preview playback rate", err);
|
|
144
|
-
}
|
|
140
|
+
} catch {}
|
|
145
141
|
}
|
|
146
142
|
|
|
147
143
|
/**
|
package/src/telemetry/client.ts
CHANGED
|
@@ -129,6 +129,8 @@ function send(url: string, payload: string): void {
|
|
|
129
129
|
function showNoticeOnce(): void {
|
|
130
130
|
if (hasShownNotice()) return;
|
|
131
131
|
markNoticeShown();
|
|
132
|
+
// Intentional one-time consent disclosure (not debug noise): tells users
|
|
133
|
+
// anonymous analytics are on and how to opt out. Kept behind a pragma.
|
|
132
134
|
// eslint-disable-next-line no-console
|
|
133
135
|
console.info(
|
|
134
136
|
"%c[HyperFrames]%c Anonymous studio usage analytics enabled. " +
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { smoothGestureKeyframes } from "./gestureSmoother";
|
|
3
|
+
|
|
4
|
+
describe("smoothGestureKeyframes", () => {
|
|
5
|
+
it("returns input unchanged for ≤2 keyframes", () => {
|
|
6
|
+
const kfs = [
|
|
7
|
+
{ percentage: 0, properties: { x: 0, y: 0 } },
|
|
8
|
+
{ percentage: 100, properties: { x: 100, y: 100 } },
|
|
9
|
+
];
|
|
10
|
+
expect(smoothGestureKeyframes(kfs, 3)).toEqual(kfs);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("pins first and last keyframes", () => {
|
|
14
|
+
const kfs = [
|
|
15
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
16
|
+
{ percentage: 50, properties: { x: 999 } },
|
|
17
|
+
{ percentage: 100, properties: { x: 200 } },
|
|
18
|
+
];
|
|
19
|
+
const result = smoothGestureKeyframes(kfs, 3);
|
|
20
|
+
expect(result[0].properties.x).toBe(0);
|
|
21
|
+
expect(result[result.length - 1].properties.x).toBe(200);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("smooths a zigzag into a gentler curve", () => {
|
|
25
|
+
const kfs = [
|
|
26
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
27
|
+
{ percentage: 25, properties: { x: 100 } },
|
|
28
|
+
{ percentage: 50, properties: { x: 0 } },
|
|
29
|
+
{ percentage: 75, properties: { x: 100 } },
|
|
30
|
+
{ percentage: 100, properties: { x: 0 } },
|
|
31
|
+
];
|
|
32
|
+
const result = smoothGestureKeyframes(kfs, 2);
|
|
33
|
+
const mid = result[2].properties.x as number;
|
|
34
|
+
// The sharp 0→100→0 zigzag should be softened — mid should be
|
|
35
|
+
// pulled toward the neighbors, not stay at exactly 0.
|
|
36
|
+
expect(mid).toBeGreaterThan(0);
|
|
37
|
+
expect(mid).toBeLessThan(100);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns input unchanged with radius 0", () => {
|
|
41
|
+
const kfs = [
|
|
42
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
43
|
+
{ percentage: 50, properties: { x: 999 } },
|
|
44
|
+
{ percentage: 100, properties: { x: 0 } },
|
|
45
|
+
];
|
|
46
|
+
expect(smoothGestureKeyframes(kfs, 0)).toEqual(kfs);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// ponytail: Gaussian-weighted moving average over gesture keyframes.
|
|
2
|
+
// Rounds off jittery corners from raw pointer input while preserving
|
|
3
|
+
// overall path shape. First/last keyframes are pinned (never moved).
|
|
4
|
+
// Upgrade path: Catmull-Rom spline if users need curve-fitted paths.
|
|
5
|
+
|
|
6
|
+
interface Keyframe {
|
|
7
|
+
percentage: number;
|
|
8
|
+
properties: Record<string, number | string>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function gaussianWeight(distance: number, sigma: number): number {
|
|
12
|
+
return Math.exp(-(distance * distance) / (2 * sigma * sigma));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function smoothGestureKeyframes(keyframes: Keyframe[], radius: number): Keyframe[] {
|
|
16
|
+
if (keyframes.length <= 2 || radius <= 0) return keyframes;
|
|
17
|
+
const sigma = radius / 2;
|
|
18
|
+
const numericKeys = new Set<string>();
|
|
19
|
+
for (const kf of keyframes) {
|
|
20
|
+
for (const [k, v] of Object.entries(kf.properties)) {
|
|
21
|
+
if (typeof v === "number") numericKeys.add(k);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (numericKeys.size === 0) return keyframes;
|
|
25
|
+
|
|
26
|
+
return keyframes.map((kf, i) => {
|
|
27
|
+
if (i === 0 || i === keyframes.length - 1) return kf;
|
|
28
|
+
const smoothed: Record<string, number | string> = { ...kf.properties };
|
|
29
|
+
for (const key of numericKeys) {
|
|
30
|
+
let weightSum = 0;
|
|
31
|
+
let valueSum = 0;
|
|
32
|
+
for (let j = Math.max(0, i - radius); j <= Math.min(keyframes.length - 1, i + radius); j++) {
|
|
33
|
+
const v = keyframes[j].properties[key];
|
|
34
|
+
if (typeof v !== "number") continue;
|
|
35
|
+
// Weight by index distance, not time. Samples here are roughly evenly
|
|
36
|
+
// spaced, so for the small radius (3) this is fine; switch to a
|
|
37
|
+
// percentage-domain distance if the window ever grows much larger.
|
|
38
|
+
const w = gaussianWeight(j - i, sigma);
|
|
39
|
+
weightSum += w;
|
|
40
|
+
valueSum += v * w;
|
|
41
|
+
}
|
|
42
|
+
if (weightSum > 0) smoothed[key] = Math.round((valueSum / weightSum) * 1000) / 1000;
|
|
43
|
+
}
|
|
44
|
+
return { percentage: kf.percentage, properties: smoothed };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -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
|
+
});
|