@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.
Files changed (77) hide show
  1. package/dist/assets/{index-BwFzbjZQ.js → index-B2YXvFxf.js} +1 -1
  2. package/dist/assets/index-BSkUuN8g.css +1 -0
  3. package/dist/assets/index-BeRh2hMe.js +375 -0
  4. package/dist/assets/{index-C5NAfiPa.js → index-BoASKOeE.js} +1 -1
  5. package/dist/chunk-KZXYQYIU.js +876 -0
  6. package/dist/chunk-KZXYQYIU.js.map +1 -0
  7. package/dist/domEditingLayers-SSXQZHHQ.js +41 -0
  8. package/dist/domEditingLayers-SSXQZHHQ.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +3326 -2939
  12. package/dist/index.js.map +1 -1
  13. package/package.json +5 -5
  14. package/src/captions/hooks/useCaptionSync.ts +6 -1
  15. package/src/components/StudioPreviewArea.tsx +10 -3
  16. package/src/components/StudioRightPanel.tsx +4 -0
  17. package/src/components/editor/AnimationCard.tsx +77 -250
  18. package/src/components/editor/AnimationCardParts.tsx +220 -0
  19. package/src/components/editor/BlockParamsPanel.tsx +4 -8
  20. package/src/components/editor/DomEditOverlay.tsx +132 -48
  21. package/src/components/editor/EaseCurveSection.tsx +177 -101
  22. package/src/components/editor/GsapAnimationSection.tsx +4 -0
  23. package/src/components/editor/KeyframeEaseList.tsx +127 -0
  24. package/src/components/editor/OffCanvasIndicators.tsx +111 -0
  25. package/src/components/editor/PropertyPanel.tsx +63 -56
  26. package/src/components/editor/gsapAnimationCallbacks.ts +3 -0
  27. package/src/components/editor/gsapAnimationConstants.ts +11 -32
  28. package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
  29. package/src/components/editor/manualOffsetDrag.ts +8 -0
  30. package/src/components/editor/marqueeCommit.ts +168 -0
  31. package/src/components/editor/propertyPanelHelpers.ts +7 -0
  32. package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
  33. package/src/components/editor/snapTargetCollection.ts +0 -5
  34. package/src/components/editor/useMotionPathData.ts +2 -1
  35. package/src/components/panels/SlideshowPanel.tsx +0 -1
  36. package/src/components/sidebar/AssetContextMenu.tsx +97 -0
  37. package/src/components/sidebar/AssetsTab.tsx +542 -254
  38. package/src/components/sidebar/assetHelpers.ts +40 -0
  39. package/src/contexts/DomEditContext.tsx +12 -0
  40. package/src/hooks/gsapDragCommit.ts +101 -0
  41. package/src/hooks/gsapDragPositionCommit.ts +1 -2
  42. package/src/hooks/gsapRuntimeBridge.ts +28 -2
  43. package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
  44. package/src/hooks/gsapRuntimeKeyframes.ts +13 -0
  45. package/src/hooks/gsapRuntimeReaders.ts +1 -6
  46. package/src/hooks/useDomEditCommits.ts +0 -4
  47. package/src/hooks/useDomEditPreviewSync.ts +5 -0
  48. package/src/hooks/useDomEditSession.ts +43 -0
  49. package/src/hooks/useDomEditTextCommits.ts +3 -12
  50. package/src/hooks/useDomEditWiring.ts +3 -0
  51. package/src/hooks/useDomSelection.ts +46 -0
  52. package/src/hooks/useGestureCommit.ts +26 -7
  53. package/src/hooks/useGestureRecording.ts +2 -16
  54. package/src/hooks/useGsapAnimationOps.ts +2 -2
  55. package/src/hooks/useGsapAwareEditing.ts +0 -4
  56. package/src/hooks/useGsapScriptCommits.ts +0 -12
  57. package/src/hooks/useGsapTweenCache.test.ts +45 -1
  58. package/src/hooks/useGsapTweenCache.ts +151 -42
  59. package/src/hooks/useMusicBeatAnalysis.ts +36 -21
  60. package/src/hooks/useRazorSplit.ts +0 -3
  61. package/src/player/components/Player.tsx +0 -5
  62. package/src/player/components/timelineIcons.tsx +2 -1
  63. package/src/player/hooks/useTimelinePlayer.ts +4 -14
  64. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -9
  65. package/src/player/lib/timelineIframeHelpers.ts +2 -6
  66. package/src/telemetry/client.ts +2 -0
  67. package/src/utils/gestureSmoother.test.ts +48 -0
  68. package/src/utils/gestureSmoother.ts +46 -0
  69. package/src/utils/marqueeGeometry.test.ts +123 -0
  70. package/src/utils/marqueeGeometry.ts +172 -0
  71. package/src/utils/optimisticUpdate.ts +1 -2
  72. package/src/utils/sourcePatcher.ts +0 -10
  73. package/src/utils/velocityEaseFitter.test.ts +58 -0
  74. package/src/utils/velocityEaseFitter.ts +121 -0
  75. package/dist/assets/index-D_JGXmfx.js +0 -374
  76. package/dist/assets/index-DzWIinxk.css +0 -1
  77. 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
- if (!tag) console.warn("[Timeline] getTrackStyle received empty tag, defaulting to div");
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 (err) {
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 (err) {
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 (err) {
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 (err) {
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 (err) {
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 (err) {
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 (err) {
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 (err) {
143
- console.warn("[useTimelinePlayer] Failed to set preview playback rate", err);
144
- }
140
+ } catch {}
145
141
  }
146
142
 
147
143
  /**
@@ -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 (error) {
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
+ });