@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.
- package/dist/assets/{index-B_NnmU6q.js → index--lOAjFJl.js} +1 -1
- package/dist/assets/{index-ysftPins.js → index-BSyZKYmH.js} +204 -203
- package/dist/assets/{index-DsBhGFPe.js → index-Dgeszckd.js} +1 -1
- package/dist/assets/index-svYFaNuq.css +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.html +2 -2
- package/dist/index.js +3650 -2431
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/components/StudioRightPanel.tsx +7 -1
- package/src/components/editor/AnimationCard.tsx +6 -0
- package/src/components/editor/DomEditOverlay.tsx +4 -12
- package/src/components/editor/EaseCurveSection.tsx +175 -99
- package/src/components/editor/GsapAnimationSection.tsx +2 -0
- package/src/components/editor/KeyframeEaseList.tsx +67 -3
- package/src/components/editor/MarqueeOverlay.tsx +40 -0
- package/src/components/editor/OffCanvasIndicators.tsx +2 -7
- package/src/components/editor/PropertyPanel.tsx +23 -2
- package/src/components/editor/Transform3DCube.tsx +313 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +2 -0
- package/src/components/editor/gsapAnimationConstants.ts +11 -35
- package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
- package/src/components/editor/marqueeCommit.ts +63 -20
- package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
- package/src/components/editor/propertyPanelHelpers.ts +46 -18
- package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
- package/src/components/editor/transform3dProjection.test.ts +99 -0
- package/src/components/editor/transform3dProjection.ts +172 -0
- package/src/components/editor/useMotionPathData.ts +2 -1
- package/src/components/sidebar/AssetContextMenu.tsx +97 -0
- package/src/components/sidebar/AssetsTab.tsx +364 -266
- package/src/components/sidebar/AudioRow.tsx +202 -0
- package/src/components/sidebar/assetHelpers.ts +40 -0
- package/src/contexts/DomEditContext.tsx +8 -0
- package/src/hooks/gsapDragCommit.test.ts +9 -5
- package/src/hooks/gsapDragCommit.ts +129 -9
- package/src/hooks/gsapRuntimeBridge.ts +22 -0
- package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +63 -5
- package/src/hooks/gsapRuntimePatch.ts +162 -35
- package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
- package/src/hooks/useDomEditCommits.ts +0 -2
- package/src/hooks/useDomEditSession.ts +24 -2
- package/src/hooks/useDomEditWiring.ts +3 -0
- package/src/hooks/useDomGeometryCommits.ts +3 -31
- package/src/hooks/useGestureCommit.ts +0 -4
- package/src/hooks/useGsapAwareEditing.ts +31 -5
- package/src/hooks/useGsapKeyframeOps.ts +4 -1
- package/src/hooks/useGsapScriptCommits.ts +0 -12
- package/src/hooks/useGsapSelectionHandlers.ts +12 -9
- package/src/hooks/useGsapTweenCache.test.ts +45 -1
- package/src/hooks/useGsapTweenCache.ts +125 -42
- package/src/hooks/useMusicBeatAnalysis.ts +36 -21
- package/src/hooks/useRazorSplit.ts +0 -3
- package/src/utils/marqueeGeometry.test.ts +15 -98
- package/src/utils/marqueeGeometry.ts +1 -158
- package/dist/assets/index-wJZf6FK3.css +0 -1
- 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(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
)
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 {
|
|
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("
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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("
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
});
|