@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
|
@@ -3,16 +3,21 @@
|
|
|
3
3
|
* Extracted from App.tsx to keep file sizes under the 600-line limit.
|
|
4
4
|
*/
|
|
5
5
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
6
|
-
import { editLog } from "../utils/editDebugLog";
|
|
7
6
|
import { useGestureRecording } from "./useGestureRecording";
|
|
8
7
|
import { simplifyGestureSamples } from "../utils/rdpSimplify";
|
|
8
|
+
import { fitEasesFromVelocity } from "../utils/velocityEaseFitter";
|
|
9
|
+
import { smoothGestureKeyframes } from "../utils/gestureSmoother";
|
|
9
10
|
import { usePlayerStore } from "../player";
|
|
10
11
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
11
12
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
12
13
|
import { roundTo3 } from "../utils/rounding";
|
|
13
14
|
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
|
|
14
15
|
|
|
15
|
-
type RecordedKeyframe = {
|
|
16
|
+
type RecordedKeyframe = {
|
|
17
|
+
percentage: number;
|
|
18
|
+
properties: Record<string, number | string>;
|
|
19
|
+
ease?: string;
|
|
20
|
+
};
|
|
16
21
|
|
|
17
22
|
/**
|
|
18
23
|
* Split recorded keyframes into one keyframe-set per property group (position /
|
|
@@ -24,6 +29,7 @@ type RecordedKeyframe = { percentage: number; properties: Record<string, number
|
|
|
24
29
|
* Emitting one tween per group keeps the position tween tagged and editable.
|
|
25
30
|
* Keyframes with no prop in a group are dropped from that group's set.
|
|
26
31
|
*/
|
|
32
|
+
// fallow-ignore-next-line complexity
|
|
27
33
|
function partitionKeyframesByGroup(keyframes: RecordedKeyframe[]): RecordedKeyframe[][] {
|
|
28
34
|
// Preserve first-seen group order for deterministic, stable mutation ordering.
|
|
29
35
|
const groupOrder: string[] = [];
|
|
@@ -46,7 +52,11 @@ function partitionKeyframesByGroup(keyframes: RecordedKeyframe[]): RecordedKeyfr
|
|
|
46
52
|
byGroup.set(group, set);
|
|
47
53
|
groupOrder.push(group);
|
|
48
54
|
}
|
|
49
|
-
set.push({
|
|
55
|
+
set.push({
|
|
56
|
+
percentage: kf.percentage,
|
|
57
|
+
properties: props,
|
|
58
|
+
...(kf.ease ? { ease: kf.ease } : {}),
|
|
59
|
+
});
|
|
50
60
|
}
|
|
51
61
|
}
|
|
52
62
|
return groupOrder.map((group) => byGroup.get(group)!);
|
|
@@ -130,6 +140,7 @@ export function useGestureCommit({
|
|
|
130
140
|
|
|
131
141
|
// Per-property epsilon: small-range properties (opacity 0–1, scale ~0.01–10)
|
|
132
142
|
// need a much tighter tolerance than positional properties (x/y in px).
|
|
143
|
+
// fallow-ignore-next-line complexity
|
|
133
144
|
const simplified = simplifyGestureSamples(frozenSamples, duration, (key) => {
|
|
134
145
|
if (key === "opacity") return 0.01;
|
|
135
146
|
if (key === "scale" || key === "scaleX" || key === "scaleY") return 0.01;
|
|
@@ -150,10 +161,12 @@ export function useGestureCommit({
|
|
|
150
161
|
}
|
|
151
162
|
if (liveSession.commitMutation) {
|
|
152
163
|
const recStart = recordingStartTimeRef.current;
|
|
153
|
-
const
|
|
164
|
+
const rawKeyframes = sortedPcts.map((pct) => ({
|
|
154
165
|
percentage: pct,
|
|
155
166
|
properties: simplified.get(pct) as Record<string, number | string>,
|
|
156
167
|
}));
|
|
168
|
+
const smoothed = smoothGestureKeyframes(rawKeyframes, 3);
|
|
169
|
+
const keyframes = fitEasesFromVelocity(smoothed, frozenSamples, duration);
|
|
157
170
|
const hasPositionProps = keyframes.some((kf) =>
|
|
158
171
|
Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"),
|
|
159
172
|
);
|
|
@@ -206,6 +219,7 @@ export function useGestureCommit({
|
|
|
206
219
|
const mapped = keyframes.map((kf) => ({
|
|
207
220
|
percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct),
|
|
208
221
|
properties: kf.properties,
|
|
222
|
+
...(kf.ease ? { ease: kf.ease } : {}),
|
|
209
223
|
}));
|
|
210
224
|
|
|
211
225
|
const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
|
|
@@ -236,6 +250,11 @@ export function useGestureCommit({
|
|
|
236
250
|
position: roundTo3(recStart),
|
|
237
251
|
duration: roundTo3(duration),
|
|
238
252
|
keyframes: groupKfs,
|
|
253
|
+
// Linear fallback: the velocity fitter assigns a per-keyframe
|
|
254
|
+
// ease to non-constant segments and intentionally leaves
|
|
255
|
+
// constant-speed segments undefined → they must stay linear,
|
|
256
|
+
// not inherit a sigmoid.
|
|
257
|
+
easeEach: "none",
|
|
239
258
|
},
|
|
240
259
|
{ label: "Gesture recording (new range)", softReload: true },
|
|
241
260
|
);
|
|
@@ -252,6 +271,8 @@ export function useGestureCommit({
|
|
|
252
271
|
position: roundTo3(recStart),
|
|
253
272
|
duration: roundTo3(duration),
|
|
254
273
|
keyframes: groupKfs,
|
|
274
|
+
// Linear fallback (see above) — constant-speed segments stay linear.
|
|
275
|
+
easeEach: "none",
|
|
255
276
|
},
|
|
256
277
|
{ label: "Gesture recording", softReload: true },
|
|
257
278
|
);
|
|
@@ -270,10 +291,8 @@ export function useGestureCommit({
|
|
|
270
291
|
}
|
|
271
292
|
}, [gestureRecording, showToast, isGestureRecordingRef, domEditSessionRef]);
|
|
272
293
|
|
|
294
|
+
// fallow-ignore-next-line complexity
|
|
273
295
|
const handleToggleRecording = useCallback(() => {
|
|
274
|
-
editLog("gesture", gestureStateRef.current === "recording" ? "stop" : "start", {
|
|
275
|
-
id: domEditSessionRef.current.domEditSelection?.id,
|
|
276
|
-
});
|
|
277
296
|
if (gestureStateRef.current === "recording") {
|
|
278
297
|
void stopAndCommitRecording();
|
|
279
298
|
return;
|
|
@@ -1,16 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { usePlayerStore, liveTime } from "../player/store/playerStore";
|
|
3
3
|
|
|
4
|
-
// `import.meta.env` may be undefined in non-Vite bundlers (Next.js Turbopack),
|
|
5
|
-
// so guard the access like the telemetry client does.
|
|
6
|
-
function isDevBuild(): boolean {
|
|
7
|
-
try {
|
|
8
|
-
return import.meta.env.DEV === true;
|
|
9
|
-
} catch {
|
|
10
|
-
return false;
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
4
|
export interface GestureSample {
|
|
15
5
|
time: number;
|
|
16
6
|
properties: Record<string, number>;
|
|
@@ -385,13 +375,9 @@ export function useGestureRecording() {
|
|
|
385
375
|
if (r.runtime) {
|
|
386
376
|
try {
|
|
387
377
|
applyRuntimePreview(r.runtime, time, properties);
|
|
388
|
-
} catch
|
|
378
|
+
} catch {
|
|
389
379
|
// Preview failed — disable it for the rest of the gesture (recording
|
|
390
|
-
// continues).
|
|
391
|
-
// is nulled below so this warns at most once per gesture.
|
|
392
|
-
if (isDevBuild()) {
|
|
393
|
-
console.warn("[GR] live preview disabled — runtime threw:", err);
|
|
394
|
-
}
|
|
380
|
+
// continues). `r.runtime` is nulled so we don't retry on every frame.
|
|
395
381
|
r.runtime = null;
|
|
396
382
|
}
|
|
397
383
|
}
|
|
@@ -41,7 +41,7 @@ export function useGsapAnimationOps({
|
|
|
41
41
|
async (
|
|
42
42
|
selection: DomEditSelection,
|
|
43
43
|
animationId: string,
|
|
44
|
-
updates: { duration?: number; ease?: string; position?: number },
|
|
44
|
+
updates: { duration?: number; ease?: string; easeEach?: string; position?: number },
|
|
45
45
|
) => {
|
|
46
46
|
if (sdkSession && sdkDeps) {
|
|
47
47
|
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
|
@@ -57,7 +57,7 @@ export function useGsapAnimationOps({
|
|
|
57
57
|
commitMutationSafely(
|
|
58
58
|
selection,
|
|
59
59
|
{ type: "update-meta", animationId, updates },
|
|
60
|
-
{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta
|
|
60
|
+
{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, softReload: true },
|
|
61
61
|
);
|
|
62
62
|
},
|
|
63
63
|
[commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
* from the rest of the editing orchestration.
|
|
9
9
|
*/
|
|
10
10
|
import { useCallback } from "react";
|
|
11
|
-
import { editLog } from "../utils/editDebugLog";
|
|
12
11
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
13
12
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
14
13
|
import {
|
|
@@ -97,7 +96,6 @@ export function useGsapAwareEditing({
|
|
|
97
96
|
next: { x: number; y: number },
|
|
98
97
|
modifiers?: { altKey?: boolean },
|
|
99
98
|
) => {
|
|
100
|
-
editLog("manual-drag:move", { id: selection.id, next, altKey: modifiers?.altKey });
|
|
101
99
|
if (gsapCommitMutation) {
|
|
102
100
|
try {
|
|
103
101
|
await tryGsapDragIntercept(
|
|
@@ -126,7 +124,6 @@ export function useGsapAwareEditing({
|
|
|
126
124
|
|
|
127
125
|
const handleGsapAwareBoxSizeCommit = useCallback(
|
|
128
126
|
async (selection: DomEditSelection, next: { width: number; height: number }) => {
|
|
129
|
-
editLog("manual-drag:resize", { id: selection.id, next });
|
|
130
127
|
if (gsapCommitMutation) {
|
|
131
128
|
try {
|
|
132
129
|
const handled = await tryGsapResizeIntercept(
|
|
@@ -157,7 +154,6 @@ export function useGsapAwareEditing({
|
|
|
157
154
|
|
|
158
155
|
const handleGsapAwareRotationCommit = useCallback(
|
|
159
156
|
async (selection: DomEditSelection, next: { angle: number }) => {
|
|
160
|
-
editLog("manual-drag:rotate", { id: selection.id, next });
|
|
161
157
|
if (gsapCommitMutation) {
|
|
162
158
|
try {
|
|
163
159
|
// Single source of truth for rotation too: tryGsapRotationIntercept handles
|
|
@@ -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,
|
|
@@ -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
|
});
|
|
@@ -24,6 +24,7 @@ function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercenta
|
|
|
24
24
|
return Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// fallow-ignore-next-line complexity
|
|
27
28
|
function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
|
|
28
29
|
if (anim.method === "set") {
|
|
29
30
|
return {
|
|
@@ -68,6 +69,41 @@ function extractIdFromSelector(selector: string): string | null {
|
|
|
68
69
|
return match ? match[1] : null;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a tween's target selector to the ids of the element(s) it animates.
|
|
74
|
+
* A bare `#id` resolves directly; anything else (a class like `.dot`, a group
|
|
75
|
+
* `.a, .b`, or a descendant selector) is matched against the live preview DOM so
|
|
76
|
+
* class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every
|
|
77
|
+
* element they animate — not just one parsed from the string. Falls back to a
|
|
78
|
+
* leading `#id` when there's no DOM (so the cache still populates pre-iframe).
|
|
79
|
+
*/
|
|
80
|
+
// fallow-ignore-next-line complexity
|
|
81
|
+
export function resolveSelectorElementIds(
|
|
82
|
+
selector: string,
|
|
83
|
+
doc: Document | null | undefined,
|
|
84
|
+
): string[] {
|
|
85
|
+
const bareId = selector.match(/^#([\w-]+)$/);
|
|
86
|
+
if (bareId) return [bareId[1]];
|
|
87
|
+
if (!doc) {
|
|
88
|
+
const lead = extractIdFromSelector(selector);
|
|
89
|
+
return lead ? [lead] : [];
|
|
90
|
+
}
|
|
91
|
+
const ids = new Set<string>();
|
|
92
|
+
for (const part of selector.split(",")) {
|
|
93
|
+
const sel = part.trim();
|
|
94
|
+
if (!sel) continue;
|
|
95
|
+
try {
|
|
96
|
+
for (const el of Array.from(doc.querySelectorAll(sel))) {
|
|
97
|
+
if (el.id) ids.add(el.id);
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
const lead = extractIdFromSelector(sel);
|
|
101
|
+
if (lead) ids.add(lead);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return Array.from(ids);
|
|
105
|
+
}
|
|
106
|
+
|
|
71
107
|
/** The selected element's identity for matching tweens to it. */
|
|
72
108
|
export interface GsapElementTarget {
|
|
73
109
|
id?: string | null;
|
|
@@ -81,21 +117,37 @@ export interface GsapElementTarget {
|
|
|
81
117
|
* (`.clock-face, .clock-hand`, emitted for array/`toArray` targets). Real
|
|
82
118
|
* compositions target tweens by class via `querySelector`, so id-only matching
|
|
83
119
|
* misses them.
|
|
120
|
+
*
|
|
121
|
+
* When the live DOM `element` is supplied, each comma-part of a tween's selector
|
|
122
|
+
* is also tested with `element.matches(part)` — true CSS semantics — so a
|
|
123
|
+
* class/descendant tween shared across elements (e.g. `gsap.from(".dot", {stagger})`)
|
|
124
|
+
* is attributed to *every* matching element, not just the one whose exact
|
|
125
|
+
* selector string happens to equal the tween's.
|
|
84
126
|
*/
|
|
85
127
|
export function getAnimationsForElement(
|
|
86
128
|
animations: GsapAnimation[],
|
|
87
129
|
target: GsapElementTarget,
|
|
130
|
+
element?: Element | null,
|
|
88
131
|
): GsapAnimation[] {
|
|
89
132
|
const matchers = new Set<string>();
|
|
90
133
|
if (target.id) matchers.add(`#${target.id}`);
|
|
91
134
|
if (target.selector) matchers.add(target.selector);
|
|
92
|
-
if (matchers.size === 0) return [];
|
|
135
|
+
if (matchers.size === 0 && !element) return [];
|
|
93
136
|
return animations.filter((a) =>
|
|
94
137
|
a.targetSelector.split(",").some((part) => {
|
|
95
138
|
const trimmed = part.trim();
|
|
139
|
+
if (!trimmed) return false;
|
|
96
140
|
if (matchers.has(trimmed)) return true;
|
|
97
141
|
const lastSimple = trimmed.split(/\s+/).pop();
|
|
98
|
-
|
|
142
|
+
if (lastSimple && matchers.has(lastSimple)) return true;
|
|
143
|
+
if (element) {
|
|
144
|
+
try {
|
|
145
|
+
if (element.matches(trimmed)) return true;
|
|
146
|
+
} catch {
|
|
147
|
+
/* tween selector isn't a valid CSS selector for matches() — skip */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
99
151
|
}),
|
|
100
152
|
);
|
|
101
153
|
}
|
|
@@ -107,6 +159,9 @@ export async function fetchParsedAnimations(
|
|
|
107
159
|
try {
|
|
108
160
|
const res = await fetch(
|
|
109
161
|
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
|
|
162
|
+
// Always re-read the freshly-parsed source; no per-call timestamp (which
|
|
163
|
+
// would defeat caching forever and is a deterministic-render no-no).
|
|
164
|
+
{ cache: "no-store" },
|
|
110
165
|
);
|
|
111
166
|
if (!res.ok) return null;
|
|
112
167
|
const parsed = (await res.json()) as ParsedGsap;
|
|
@@ -156,7 +211,9 @@ export function useGsapAnimationsForElement(
|
|
|
156
211
|
|
|
157
212
|
let cancelled = false;
|
|
158
213
|
fetchParsedAnimations(projectId, sourceFile).then((parsed) => {
|
|
159
|
-
if (cancelled)
|
|
214
|
+
if (cancelled) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
160
217
|
if (!parsed) {
|
|
161
218
|
setAllAnimations([]);
|
|
162
219
|
setMultipleTimelines(false);
|
|
@@ -169,7 +226,7 @@ export function useGsapAnimationsForElement(
|
|
|
169
226
|
|
|
170
227
|
// Retry once if initial fetch returned 0 animations — handles
|
|
171
228
|
// cold-load race where the sourceFile isn't resolved yet.
|
|
172
|
-
if (parsed.animations.length === 0 &&
|
|
229
|
+
if (parsed.animations.length === 0 && targetKey) {
|
|
173
230
|
retryTimerRef.current = setTimeout(() => {
|
|
174
231
|
if (cancelled) return;
|
|
175
232
|
fetchParsedAnimations(projectId, sourceFile).then((retryParsed) => {
|
|
@@ -189,18 +246,36 @@ export function useGsapAnimationsForElement(
|
|
|
189
246
|
retryTimerRef.current = null;
|
|
190
247
|
}
|
|
191
248
|
};
|
|
192
|
-
}, [projectId, sourceFile, version, target]);
|
|
249
|
+
}, [projectId, sourceFile, version, target?.id, target?.selector]);
|
|
193
250
|
|
|
194
251
|
const targetId = target?.id ?? null;
|
|
195
252
|
const targetSelector = target?.selector ?? null;
|
|
196
|
-
const rawAnimations = useMemo(
|
|
197
|
-
()
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
253
|
+
const rawAnimations = useMemo(() => {
|
|
254
|
+
if (!targetId && !targetSelector) return [];
|
|
255
|
+
// Resolve the live element so class / descendant tweens (e.g.
|
|
256
|
+
// gsap.from(".dot", {stagger})) attribute to every matching element, not
|
|
257
|
+
// just the one whose exact selector equals the tween's. `version` re-runs
|
|
258
|
+
// this after composition reloads.
|
|
259
|
+
let element: Element | null = null;
|
|
260
|
+
const doc = iframeRef?.current?.contentDocument;
|
|
261
|
+
if (doc) {
|
|
262
|
+
try {
|
|
263
|
+
element =
|
|
264
|
+
(targetId ? doc.getElementById(targetId) : null) ??
|
|
265
|
+
(targetSelector ? doc.querySelector(targetSelector) : null);
|
|
266
|
+
} catch {
|
|
267
|
+
element = null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return getAnimationsForElement(
|
|
271
|
+
allAnimations,
|
|
272
|
+
{ id: targetId, selector: targetSelector },
|
|
273
|
+
element,
|
|
274
|
+
);
|
|
275
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
276
|
+
}, [allAnimations, targetId, targetSelector, version, iframeRef]);
|
|
203
277
|
|
|
278
|
+
// fallow-ignore-next-line complexity
|
|
204
279
|
const animations = useMemo(() => {
|
|
205
280
|
const iframe = iframeRef?.current;
|
|
206
281
|
let result = rawAnimations;
|
|
@@ -268,6 +343,7 @@ export function useGsapAnimationsForElement(
|
|
|
268
343
|
// Merges keyframes from ALL animations targeting this element and synthesizes
|
|
269
344
|
// flat tweens so the cache is never downgraded vs the bulk populate.
|
|
270
345
|
const elementId = target?.id ?? null;
|
|
346
|
+
// fallow-ignore-next-line complexity
|
|
271
347
|
useEffect(() => {
|
|
272
348
|
if (!elementId) return;
|
|
273
349
|
|
|
@@ -287,6 +363,11 @@ export function useGsapAnimationsForElement(
|
|
|
287
363
|
let ease: string | undefined;
|
|
288
364
|
let easeEach: string | undefined;
|
|
289
365
|
for (const anim of animations) {
|
|
366
|
+
if (
|
|
367
|
+
anim.method === "set" &&
|
|
368
|
+
Object.keys(anim.properties).every((k) => k === "x" || k === "y")
|
|
369
|
+
)
|
|
370
|
+
continue;
|
|
290
371
|
const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
|
291
372
|
if (!kf) continue;
|
|
292
373
|
// Convert tween-relative percentages to clip-relative so diamonds
|
|
@@ -314,7 +395,14 @@ export function useGsapAnimationsForElement(
|
|
|
314
395
|
if (kf.easeEach) easeEach = kf.easeEach;
|
|
315
396
|
}
|
|
316
397
|
if (allKeyframes.length === 0) {
|
|
317
|
-
|
|
398
|
+
// The per-element parsed-animation match can transiently miss class /
|
|
399
|
+
// selector tweens (e.g. `.dot`) that the file-wide populate or runtime
|
|
400
|
+
// scan already cached. Only clear when no source cached this element —
|
|
401
|
+
// otherwise selecting it would wipe its diamonds.
|
|
402
|
+
const { keyframeCache } = usePlayerStore.getState();
|
|
403
|
+
const hasCached =
|
|
404
|
+
keyframeCache.has(`${sourceFile}#${elementId}`) || keyframeCache.has(elementId);
|
|
405
|
+
if (!hasCached) clearKeyframeCacheForElement(sourceFile, elementId);
|
|
318
406
|
return;
|
|
319
407
|
}
|
|
320
408
|
const dedupedKeyframes = deduplicateKeyframes(allKeyframes);
|
|
@@ -366,47 +454,59 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
366
454
|
if (!projectId) return;
|
|
367
455
|
|
|
368
456
|
const sf = sourceFile;
|
|
457
|
+
// fallow-ignore-next-line complexity
|
|
369
458
|
fetchParsedAnimations(projectId, sf).then((parsed) => {
|
|
370
459
|
if (!parsed) return;
|
|
371
460
|
const { setKeyframeCache } = usePlayerStore.getState();
|
|
372
461
|
clearKeyframeCacheForFile(sf);
|
|
373
462
|
const { elements } = usePlayerStore.getState();
|
|
463
|
+
const doc = iframeRef?.current?.contentDocument;
|
|
374
464
|
const mergedByElement = new Map<string, GsapKeyframesData>();
|
|
375
465
|
for (const anim of parsed.animations) {
|
|
376
|
-
const id = extractIdFromSelector(anim.targetSelector);
|
|
377
|
-
if (!id) continue;
|
|
378
466
|
if (anim.hasUnresolvedKeyframes) continue;
|
|
467
|
+
// Position-only set tweens are static holds (created by drag), not
|
|
468
|
+
// keyframed animations — skip them so they don't show timeline diamonds.
|
|
469
|
+
if (anim.method === "set") {
|
|
470
|
+
const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender");
|
|
471
|
+
if (propKeys.every((k) => k === "x" || k === "y")) {
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
379
475
|
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
|
380
476
|
if (!kfData) continue;
|
|
381
477
|
const tweenPos =
|
|
382
478
|
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
|
|
383
479
|
const tweenDur = anim.duration ?? 1;
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
)
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
480
|
+
// Attribute the tween to every element it animates (handles class /
|
|
481
|
+
// group / descendant selectors, not just `#id`).
|
|
482
|
+
for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
|
|
483
|
+
const timelineEl = elements.find(
|
|
484
|
+
(el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`,
|
|
485
|
+
);
|
|
486
|
+
const elStart = timelineEl?.start ?? 0;
|
|
487
|
+
const elDuration = timelineEl?.duration ?? 1;
|
|
488
|
+
const clipKeyframes = kfData.keyframes.map((kf) => {
|
|
489
|
+
const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
|
|
490
|
+
// 0.001% precision (matching useGsapAnimationsForElement above) so a
|
|
491
|
+
// beat-snapped keyframe centers exactly on the beat dot and the two
|
|
492
|
+
// caches agree on a keyframe's percentage.
|
|
493
|
+
const clipPct =
|
|
494
|
+
elDuration > 0
|
|
495
|
+
? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
|
|
496
|
+
: kf.percentage;
|
|
497
|
+
return {
|
|
498
|
+
...kf,
|
|
499
|
+
percentage: clipPct,
|
|
500
|
+
tweenPercentage: kf.percentage,
|
|
501
|
+
propertyGroup: anim.propertyGroup,
|
|
502
|
+
};
|
|
503
|
+
});
|
|
504
|
+
const existing = mergedByElement.get(id);
|
|
505
|
+
if (existing) {
|
|
506
|
+
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
|
|
507
|
+
} else {
|
|
508
|
+
mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
|
|
509
|
+
}
|
|
410
510
|
}
|
|
411
511
|
}
|
|
412
512
|
for (const [id, kfData] of mergedByElement) {
|
|
@@ -419,6 +519,9 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
419
519
|
// elementCount is in the deps because new timeline elements (e.g. after a
|
|
420
520
|
// sub-composition expand) need their keyframe cache populated immediately;
|
|
421
521
|
// without it the effect won't re-run when elements appear/disappear.
|
|
522
|
+
// iframeRef is read for DOM selector resolution but intentionally not a dep
|
|
523
|
+
// (it's a stable ref; the separate runtime-scan effect owns iframe timing).
|
|
524
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
422
525
|
}, [projectId, sourceFile, version, elementCount]);
|
|
423
526
|
|
|
424
527
|
// Separate effect for runtime keyframe discovery — polls until the iframe
|
|
@@ -430,6 +533,7 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
430
533
|
let attempts = 0;
|
|
431
534
|
const maxAttempts = 10;
|
|
432
535
|
|
|
536
|
+
// fallow-ignore-next-line complexity
|
|
433
537
|
const tryRuntimeScan = () => {
|
|
434
538
|
if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
|
|
435
539
|
const iframe =
|
|
@@ -449,7 +553,12 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
449
553
|
const fallbackKey = `index.html#${id}`;
|
|
450
554
|
const alreadyCached =
|
|
451
555
|
keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id);
|
|
452
|
-
if (alreadyCached)
|
|
556
|
+
if (alreadyCached) continue;
|
|
557
|
+
// Skip position-only set tweens from runtime too — same filter as AST path
|
|
558
|
+
const isPosOnly =
|
|
559
|
+
data.keyframes.length === 1 &&
|
|
560
|
+
Object.keys(data.keyframes[0].properties).every((k) => k === "x" || k === "y");
|
|
561
|
+
if (isPosOnly) {
|
|
453
562
|
continue;
|
|
454
563
|
}
|
|
455
564
|
const entry = {
|
|
@@ -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;
|