@hyperframes/studio 0.7.6 → 0.7.8

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 (58) hide show
  1. package/dist/assets/{index-B_NnmU6q.js → index--lOAjFJl.js} +1 -1
  2. package/dist/assets/{index-ysftPins.js → index-BSyZKYmH.js} +204 -203
  3. package/dist/assets/{index-DsBhGFPe.js → index-Dgeszckd.js} +1 -1
  4. package/dist/assets/index-svYFaNuq.css +1 -0
  5. package/dist/index.d.ts +5 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +3650 -2431
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/components/StudioRightPanel.tsx +7 -1
  11. package/src/components/editor/AnimationCard.tsx +6 -0
  12. package/src/components/editor/DomEditOverlay.tsx +4 -12
  13. package/src/components/editor/EaseCurveSection.tsx +175 -99
  14. package/src/components/editor/GsapAnimationSection.tsx +2 -0
  15. package/src/components/editor/KeyframeEaseList.tsx +67 -3
  16. package/src/components/editor/MarqueeOverlay.tsx +40 -0
  17. package/src/components/editor/OffCanvasIndicators.tsx +2 -7
  18. package/src/components/editor/PropertyPanel.tsx +23 -2
  19. package/src/components/editor/Transform3DCube.tsx +313 -0
  20. package/src/components/editor/gsapAnimationCallbacks.ts +2 -0
  21. package/src/components/editor/gsapAnimationConstants.ts +11 -35
  22. package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
  23. package/src/components/editor/marqueeCommit.ts +63 -20
  24. package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
  25. package/src/components/editor/propertyPanelHelpers.ts +46 -18
  26. package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
  27. package/src/components/editor/transform3dProjection.test.ts +99 -0
  28. package/src/components/editor/transform3dProjection.ts +172 -0
  29. package/src/components/editor/useMotionPathData.ts +2 -1
  30. package/src/components/sidebar/AssetContextMenu.tsx +97 -0
  31. package/src/components/sidebar/AssetsTab.tsx +364 -266
  32. package/src/components/sidebar/AudioRow.tsx +202 -0
  33. package/src/components/sidebar/assetHelpers.ts +40 -0
  34. package/src/contexts/DomEditContext.tsx +8 -0
  35. package/src/hooks/gsapDragCommit.test.ts +9 -5
  36. package/src/hooks/gsapDragCommit.ts +129 -9
  37. package/src/hooks/gsapRuntimeBridge.ts +22 -0
  38. package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
  39. package/src/hooks/gsapRuntimeKeyframes.ts +63 -5
  40. package/src/hooks/gsapRuntimePatch.ts +162 -35
  41. package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
  42. package/src/hooks/useDomEditCommits.ts +0 -2
  43. package/src/hooks/useDomEditSession.ts +24 -2
  44. package/src/hooks/useDomEditWiring.ts +3 -0
  45. package/src/hooks/useDomGeometryCommits.ts +3 -31
  46. package/src/hooks/useGestureCommit.ts +0 -4
  47. package/src/hooks/useGsapAwareEditing.ts +31 -5
  48. package/src/hooks/useGsapKeyframeOps.ts +4 -1
  49. package/src/hooks/useGsapScriptCommits.ts +0 -12
  50. package/src/hooks/useGsapSelectionHandlers.ts +12 -9
  51. package/src/hooks/useGsapTweenCache.test.ts +45 -1
  52. package/src/hooks/useGsapTweenCache.ts +125 -42
  53. package/src/hooks/useMusicBeatAnalysis.ts +36 -21
  54. package/src/hooks/useRazorSplit.ts +0 -3
  55. package/src/utils/marqueeGeometry.test.ts +15 -98
  56. package/src/utils/marqueeGeometry.ts +1 -158
  57. package/dist/assets/index-wJZf6FK3.css +0 -1
  58. package/src/utils/editDebugLog.ts +0 -9
@@ -1,5 +1,4 @@
1
1
  import { useCallback, useMemo, useRef } from "react";
2
- import { editLog } from "../utils/editDebugLog";
3
2
  import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
4
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
4
  import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
@@ -130,12 +129,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
130
129
  const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
131
130
  const pid = projectIdRef.current;
132
131
  if (!pid) return;
133
- editLog("gsap-commit", {
134
- type: mutation.type,
135
- id: selection.id,
136
- file: selection.sourceFile || activeCompPath,
137
- label: options.label,
138
- });
139
132
  const unsafeFields = findUnsafeMutationValues(mutation);
140
133
  if (unsafeFields.length > 0) {
141
134
  showToast?.("Couldn't read element layout — try again at a different playhead time", "error");
@@ -165,11 +158,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
165
158
  options.beforeReload?.();
166
159
  applyPreviewSync(previewIframeRef.current, result, options, reloadPreview);
167
160
  onCacheInvalidate();
168
- editLog("gsap-commit:done", {
169
- type: mutation.type,
170
- changed: result.changed,
171
- instant: Boolean(options.instantPatch),
172
- });
173
161
  }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]);
174
162
  // Every GSAP-script commit is a read-modify-write of one file. Overlapping
175
163
  // commits to the SAME file (any op type, any animation) interleave server-side,
@@ -76,6 +76,7 @@ export function useGsapSelectionHandlers({
76
76
  sel: DomEditSelection,
77
77
  animId: string,
78
78
  resolvedFromValues?: Record<string, number | string>,
79
+ duration?: number,
79
80
  ) => Promise<void>;
80
81
  removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
81
82
 
@@ -229,16 +230,18 @@ export function useGsapSelectionHandlers({
229
230
  );
230
231
 
231
232
  const handleGsapConvertToKeyframes = useCallback(
232
- (animId: string, resolvedFromValues?: Record<string, number | string>) => {
233
+ (animId: string, resolvedFromValues?: Record<string, number | string>, duration?: number) => {
233
234
  if (!domEditSelection) return Promise.resolve();
234
- return convertToKeyframes(domEditSelection, animId, resolvedFromValues).catch((error) => {
235
- trackGsapHandlerFailure(
236
- error,
237
- domEditSelection,
238
- "convert-to-keyframes",
239
- "Convert to keyframes",
240
- );
241
- });
235
+ return convertToKeyframes(domEditSelection, animId, resolvedFromValues, duration).catch(
236
+ (error) => {
237
+ trackGsapHandlerFailure(
238
+ error,
239
+ domEditSelection,
240
+ "convert-to-keyframes",
241
+ "Convert to keyframes",
242
+ );
243
+ },
244
+ );
242
245
  },
243
246
  [domEditSelection, convertToKeyframes, trackGsapHandlerFailure],
244
247
  );
@@ -1,6 +1,13 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
- import { getAnimationsForElement } from "./useGsapTweenCache";
3
+ import { getAnimationsForElement, resolveSelectorElementIds } from "./useGsapTweenCache";
4
+
5
+ // Minimal Document stub: querySelectorAll returns the elements mapped per selector.
6
+ function fakeDoc(map: Record<string, { id: string }[]>): Document {
7
+ return {
8
+ querySelectorAll: (sel: string) => (map[sel] ?? []) as unknown as NodeListOf<Element>,
9
+ } as unknown as Document;
10
+ }
4
11
 
5
12
  function anim(targetSelector: string): GsapAnimation {
6
13
  return {
@@ -46,4 +53,41 @@ describe("getAnimationsForElement", () => {
46
53
  expect(getAnimationsForElement(grouped, { selector: ".clock-hand" })).toHaveLength(1);
47
54
  expect(getAnimationsForElement(grouped, { selector: ".unrelated" })).toHaveLength(0);
48
55
  });
56
+
57
+ it("attributes a class tween to an id-selected element via element.matches", () => {
58
+ // gsap.from(".dot", {stagger}) — the element is selected by id (#dot-a), so
59
+ // its selector string never equals ".dot", but the live element matches it.
60
+ const dots = [anim(".dot")];
61
+ const el = { matches: (s: string) => s === ".dot" || s === "#dot-a" } as unknown as Element;
62
+ expect(getAnimationsForElement(dots, { id: "dot-a", selector: "#dot-a" }, el)).toHaveLength(1);
63
+ // Without the live element the class tween is still missed (legacy behavior).
64
+ expect(getAnimationsForElement(dots, { id: "dot-a", selector: "#dot-a" })).toHaveLength(0);
65
+ });
66
+
67
+ it("element.matches gates attribution — no over-matching", () => {
68
+ const dots = [anim(".dot")];
69
+ const el = { matches: () => false } as unknown as Element;
70
+ expect(getAnimationsForElement(dots, { id: "other", selector: "#other" }, el)).toHaveLength(0);
71
+ });
72
+ });
73
+
74
+ describe("resolveSelectorElementIds", () => {
75
+ it("resolves a bare #id without touching the DOM", () => {
76
+ expect(resolveSelectorElementIds("#hero", null)).toEqual(["hero"]);
77
+ });
78
+
79
+ it("resolves a class selector to every matching element id (the .dot+stagger case)", () => {
80
+ const doc = fakeDoc({ ".dot": [{ id: "dot-a" }, { id: "dot-b" }] });
81
+ expect(resolveSelectorElementIds(".dot", doc)).toEqual(["dot-a", "dot-b"]);
82
+ });
83
+
84
+ it("resolves a group selector across its parts (deduped)", () => {
85
+ const doc = fakeDoc({ ".a": [{ id: "x" }], ".b": [{ id: "y" }, { id: "x" }] });
86
+ expect(resolveSelectorElementIds(".a, .b", doc).sort()).toEqual(["x", "y"]);
87
+ });
88
+
89
+ it("falls back to a leading #id when there is no DOM", () => {
90
+ expect(resolveSelectorElementIds("#card .label", null)).toEqual(["card"]);
91
+ expect(resolveSelectorElementIds(".dot", null)).toEqual([]);
92
+ });
49
93
  });
@@ -27,10 +27,12 @@ function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercenta
27
27
  // fallow-ignore-next-line complexity
28
28
  function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
29
29
  if (anim.method === "set") {
30
- return {
31
- format: "percentage",
32
- keyframes: [{ percentage: 0, properties: { ...anim.properties } }],
33
- };
30
+ // A `set` is a STATIC HOLD — a value applied at one point, not an animated
31
+ // keyframe. It must NOT synthesize a keyframe, or the timeline + panel show a
32
+ // phantom diamond for a value that doesn't animate. This holds for a base
33
+ // `gsap.set` (off-timeline) AND an on-timeline `tl.set`, and aligns the AST
34
+ // path with the runtime scan, which already skips every zero-duration set.
35
+ return null;
34
36
  }
35
37
  const toProps = anim.properties;
36
38
  const fromProps = anim.fromProperties;
@@ -69,6 +71,41 @@ function extractIdFromSelector(selector: string): string | null {
69
71
  return match ? match[1] : null;
70
72
  }
71
73
 
74
+ /**
75
+ * Resolve a tween's target selector to the ids of the element(s) it animates.
76
+ * A bare `#id` resolves directly; anything else (a class like `.dot`, a group
77
+ * `.a, .b`, or a descendant selector) is matched against the live preview DOM so
78
+ * class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every
79
+ * element they animate — not just one parsed from the string. Falls back to a
80
+ * leading `#id` when there's no DOM (so the cache still populates pre-iframe).
81
+ */
82
+ // fallow-ignore-next-line complexity
83
+ export function resolveSelectorElementIds(
84
+ selector: string,
85
+ doc: Document | null | undefined,
86
+ ): string[] {
87
+ const bareId = selector.match(/^#([\w-]+)$/);
88
+ if (bareId) return [bareId[1]];
89
+ if (!doc) {
90
+ const lead = extractIdFromSelector(selector);
91
+ return lead ? [lead] : [];
92
+ }
93
+ const ids = new Set<string>();
94
+ for (const part of selector.split(",")) {
95
+ const sel = part.trim();
96
+ if (!sel) continue;
97
+ try {
98
+ for (const el of Array.from(doc.querySelectorAll(sel))) {
99
+ if (el.id) ids.add(el.id);
100
+ }
101
+ } catch {
102
+ const lead = extractIdFromSelector(sel);
103
+ if (lead) ids.add(lead);
104
+ }
105
+ }
106
+ return Array.from(ids);
107
+ }
108
+
72
109
  /** The selected element's identity for matching tweens to it. */
73
110
  export interface GsapElementTarget {
74
111
  id?: string | null;
@@ -82,21 +119,37 @@ export interface GsapElementTarget {
82
119
  * (`.clock-face, .clock-hand`, emitted for array/`toArray` targets). Real
83
120
  * compositions target tweens by class via `querySelector`, so id-only matching
84
121
  * misses them.
122
+ *
123
+ * When the live DOM `element` is supplied, each comma-part of a tween's selector
124
+ * is also tested with `element.matches(part)` — true CSS semantics — so a
125
+ * class/descendant tween shared across elements (e.g. `gsap.from(".dot", {stagger})`)
126
+ * is attributed to *every* matching element, not just the one whose exact
127
+ * selector string happens to equal the tween's.
85
128
  */
86
129
  export function getAnimationsForElement(
87
130
  animations: GsapAnimation[],
88
131
  target: GsapElementTarget,
132
+ element?: Element | null,
89
133
  ): GsapAnimation[] {
90
134
  const matchers = new Set<string>();
91
135
  if (target.id) matchers.add(`#${target.id}`);
92
136
  if (target.selector) matchers.add(target.selector);
93
- if (matchers.size === 0) return [];
137
+ if (matchers.size === 0 && !element) return [];
94
138
  return animations.filter((a) =>
95
139
  a.targetSelector.split(",").some((part) => {
96
140
  const trimmed = part.trim();
141
+ if (!trimmed) return false;
97
142
  if (matchers.has(trimmed)) return true;
98
143
  const lastSimple = trimmed.split(/\s+/).pop();
99
- return lastSimple ? matchers.has(lastSimple) : false;
144
+ if (lastSimple && matchers.has(lastSimple)) return true;
145
+ if (element) {
146
+ try {
147
+ if (element.matches(trimmed)) return true;
148
+ } catch {
149
+ /* tween selector isn't a valid CSS selector for matches() — skip */
150
+ }
151
+ }
152
+ return false;
100
153
  }),
101
154
  );
102
155
  }
@@ -199,13 +252,30 @@ export function useGsapAnimationsForElement(
199
252
 
200
253
  const targetId = target?.id ?? null;
201
254
  const targetSelector = target?.selector ?? null;
202
- const rawAnimations = useMemo(
203
- () =>
204
- targetId || targetSelector
205
- ? getAnimationsForElement(allAnimations, { id: targetId, selector: targetSelector })
206
- : [],
207
- [allAnimations, targetId, targetSelector],
208
- );
255
+ const rawAnimations = useMemo(() => {
256
+ if (!targetId && !targetSelector) return [];
257
+ // Resolve the live element so class / descendant tweens (e.g.
258
+ // gsap.from(".dot", {stagger})) attribute to every matching element, not
259
+ // just the one whose exact selector equals the tween's. `version` re-runs
260
+ // this after composition reloads.
261
+ let element: Element | null = null;
262
+ const doc = iframeRef?.current?.contentDocument;
263
+ if (doc) {
264
+ try {
265
+ element =
266
+ (targetId ? doc.getElementById(targetId) : null) ??
267
+ (targetSelector ? doc.querySelector(targetSelector) : null);
268
+ } catch {
269
+ element = null;
270
+ }
271
+ }
272
+ return getAnimationsForElement(
273
+ allAnimations,
274
+ { id: targetId, selector: targetSelector },
275
+ element,
276
+ );
277
+ // eslint-disable-next-line react-hooks/exhaustive-deps
278
+ }, [allAnimations, targetId, targetSelector, version, iframeRef]);
209
279
 
210
280
  // fallow-ignore-next-line complexity
211
281
  const animations = useMemo(() => {
@@ -327,7 +397,14 @@ export function useGsapAnimationsForElement(
327
397
  if (kf.easeEach) easeEach = kf.easeEach;
328
398
  }
329
399
  if (allKeyframes.length === 0) {
330
- clearKeyframeCacheForElement(sourceFile, elementId);
400
+ // The per-element parsed-animation match can transiently miss class /
401
+ // selector tweens (e.g. `.dot`) that the file-wide populate or runtime
402
+ // scan already cached. Only clear when no source cached this element —
403
+ // otherwise selecting it would wipe its diamonds.
404
+ const { keyframeCache } = usePlayerStore.getState();
405
+ const hasCached =
406
+ keyframeCache.has(`${sourceFile}#${elementId}`) || keyframeCache.has(elementId);
407
+ if (!hasCached) clearKeyframeCacheForElement(sourceFile, elementId);
331
408
  return;
332
409
  }
333
410
  const dedupedKeyframes = deduplicateKeyframes(allKeyframes);
@@ -385,10 +462,9 @@ export function usePopulateKeyframeCacheForFile(
385
462
  const { setKeyframeCache } = usePlayerStore.getState();
386
463
  clearKeyframeCacheForFile(sf);
387
464
  const { elements } = usePlayerStore.getState();
465
+ const doc = iframeRef?.current?.contentDocument;
388
466
  const mergedByElement = new Map<string, GsapKeyframesData>();
389
467
  for (const anim of parsed.animations) {
390
- const id = extractIdFromSelector(anim.targetSelector);
391
- if (!id) continue;
392
468
  if (anim.hasUnresolvedKeyframes) continue;
393
469
  // Position-only set tweens are static holds (created by drag), not
394
470
  // keyframed animations — skip them so they don't show timeline diamonds.
@@ -403,32 +479,36 @@ export function usePopulateKeyframeCacheForFile(
403
479
  const tweenPos =
404
480
  anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
405
481
  const tweenDur = anim.duration ?? 1;
406
- const timelineEl = elements.find(
407
- (el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`,
408
- );
409
- const elStart = timelineEl?.start ?? 0;
410
- const elDuration = timelineEl?.duration ?? 1;
411
- const clipKeyframes = kfData.keyframes.map((kf) => {
412
- const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
413
- // 0.001% precision (matching useGsapAnimationsForElement above) so a
414
- // beat-snapped keyframe centers exactly on the beat dot and the two
415
- // caches agree on a keyframe's percentage.
416
- const clipPct =
417
- elDuration > 0
418
- ? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
419
- : kf.percentage;
420
- return {
421
- ...kf,
422
- percentage: clipPct,
423
- tweenPercentage: kf.percentage,
424
- propertyGroup: anim.propertyGroup,
425
- };
426
- });
427
- const existing = mergedByElement.get(id);
428
- if (existing) {
429
- existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
430
- } else {
431
- mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
482
+ // Attribute the tween to every element it animates (handles class /
483
+ // group / descendant selectors, not just `#id`).
484
+ for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
485
+ const timelineEl = elements.find(
486
+ (el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`,
487
+ );
488
+ const elStart = timelineEl?.start ?? 0;
489
+ const elDuration = timelineEl?.duration ?? 1;
490
+ const clipKeyframes = kfData.keyframes.map((kf) => {
491
+ const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
492
+ // 0.001% precision (matching useGsapAnimationsForElement above) so a
493
+ // beat-snapped keyframe centers exactly on the beat dot and the two
494
+ // caches agree on a keyframe's percentage.
495
+ const clipPct =
496
+ elDuration > 0
497
+ ? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
498
+ : kf.percentage;
499
+ return {
500
+ ...kf,
501
+ percentage: clipPct,
502
+ tweenPercentage: kf.percentage,
503
+ propertyGroup: anim.propertyGroup,
504
+ };
505
+ });
506
+ const existing = mergedByElement.get(id);
507
+ if (existing) {
508
+ existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
509
+ } else {
510
+ mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
511
+ }
432
512
  }
433
513
  }
434
514
  for (const [id, kfData] of mergedByElement) {
@@ -441,6 +521,9 @@ export function usePopulateKeyframeCacheForFile(
441
521
  // elementCount is in the deps because new timeline elements (e.g. after a
442
522
  // sub-composition expand) need their keyframe cache populated immediately;
443
523
  // without it the effect won't re-run when elements appear/disappear.
524
+ // iframeRef is read for DOM selector resolution but intentionally not a dep
525
+ // (it's a stable ref; the separate runtime-scan effect owns iframe timing).
526
+ // eslint-disable-next-line react-hooks/exhaustive-deps
444
527
  }, [projectId, sourceFile, version, elementCount]);
445
528
 
446
529
  // Separate effect for runtime keyframe discovery — polls until the iframe
@@ -92,33 +92,48 @@ export function useMusicBeatAnalysis(): void {
92
92
  return;
93
93
  }
94
94
  let cancelled = false;
95
-
96
- let promise = analysisCache.get(musicSrc);
97
- if (!promise) {
98
- promise = analyzeMusicFromUrl(musicSrc);
99
- cacheAnalysis(musicSrc, promise);
100
- }
101
-
102
95
  const beatPath = beatFilePathForSrc(musicSrc);
103
- promise
104
- .then(async (analysis) => {
96
+ const io = ioRef.current;
97
+
98
+ // Only run expensive audio decode + beat analysis when the user has an
99
+ // explicit beats file saved. Without one, skip entirely — no surprise
100
+ // green lines on the timeline after dragging unrelated assets.
101
+ (async () => {
102
+ if (!beatPath || !io) return;
103
+ let hasSavedBeats = false;
104
+ try {
105
+ const content = await io.readOptionalProjectFile(beatPath);
106
+ const parsed = content ? parseBeats(content) : null;
107
+ hasSavedBeats = !!(parsed && parsed.times.length > 0);
108
+ } catch {
109
+ /* no file */
110
+ }
111
+ if (cancelled) return;
112
+ if (!hasSavedBeats) {
113
+ setBeatAnalysis(null);
114
+ return;
115
+ }
116
+
117
+ let promise = analysisCache.get(musicSrc);
118
+ if (!promise) {
119
+ promise = analyzeMusicFromUrl(musicSrc);
120
+ cacheAnalysis(musicSrc, promise);
121
+ }
122
+ try {
123
+ const analysis = await promise;
105
124
  const detected = { times: analysis.beatTimes, strengths: analysis.beatStrengths };
106
- const io = ioRef.current;
107
- if (!io) return;
108
- const { times, strengths, hasFile } = await resolveBeats(beatPath, detected, io);
125
+ const { times, strengths } = await resolveBeats(beatPath, detected, io);
109
126
  if (cancelled) return;
110
127
  setBeatEdits(null);
111
128
  resetBeatHistory();
112
129
  setBeatAnalysis({ ...analysis, beatTimes: times, beatStrengths: strengths });
113
- // Seed a missing file through the SAME debounced writer the edits use, so
114
- // the initial write can't race a near-simultaneous edit's persist.
115
- if (beatPath && !hasFile && times.length > 0) usePlayerStore.getState().beatPersist?.();
116
- })
117
- .catch(() => {
118
- if (cancelled) return;
119
- setBeatAnalysis(null);
120
- analysisCache.delete(musicSrc);
121
- });
130
+ } catch {
131
+ if (!cancelled) {
132
+ setBeatAnalysis(null);
133
+ analysisCache.delete(musicSrc);
134
+ }
135
+ }
136
+ })();
122
137
 
123
138
  return () => {
124
139
  cancelled = true;
@@ -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
  }
@@ -1,34 +1,5 @@
1
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
- }
2
+ import { rectsOverlap } from "./marqueeGeometry";
32
3
 
33
4
  describe("rectsOverlap", () => {
34
5
  it("overlapping rects", () => {
@@ -48,76 +19,22 @@ describe("rectsOverlap", () => {
48
19
  ),
49
20
  ).toBe(false);
50
21
  });
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
22
 
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);
23
+ it("touching edges do not overlap", () => {
24
+ expect(
25
+ rectsOverlap(
26
+ { left: 0, top: 0, width: 10, height: 10 },
27
+ { left: 10, top: 0, width: 10, height: 10 },
28
+ ),
29
+ ).toBe(false);
116
30
  });
117
31
 
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);
32
+ it("one rect fully inside another overlaps", () => {
33
+ expect(
34
+ rectsOverlap(
35
+ { left: 0, top: 0, width: 100, height: 100 },
36
+ { left: 25, top: 25, width: 10, height: 10 },
37
+ ),
38
+ ).toBe(true);
122
39
  });
123
40
  });