@hyperframes/studio 0.7.5 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/assets/{index-BwFzbjZQ.js → index-B2YXvFxf.js} +1 -1
  2. package/dist/assets/index-BSkUuN8g.css +1 -0
  3. package/dist/assets/index-BeRh2hMe.js +375 -0
  4. package/dist/assets/{index-C5NAfiPa.js → index-BoASKOeE.js} +1 -1
  5. package/dist/chunk-KZXYQYIU.js +876 -0
  6. package/dist/chunk-KZXYQYIU.js.map +1 -0
  7. package/dist/domEditingLayers-SSXQZHHQ.js +41 -0
  8. package/dist/domEditingLayers-SSXQZHHQ.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +3326 -2939
  12. package/dist/index.js.map +1 -1
  13. package/package.json +5 -5
  14. package/src/captions/hooks/useCaptionSync.ts +6 -1
  15. package/src/components/StudioPreviewArea.tsx +10 -3
  16. package/src/components/StudioRightPanel.tsx +4 -0
  17. package/src/components/editor/AnimationCard.tsx +77 -250
  18. package/src/components/editor/AnimationCardParts.tsx +220 -0
  19. package/src/components/editor/BlockParamsPanel.tsx +4 -8
  20. package/src/components/editor/DomEditOverlay.tsx +132 -48
  21. package/src/components/editor/EaseCurveSection.tsx +177 -101
  22. package/src/components/editor/GsapAnimationSection.tsx +4 -0
  23. package/src/components/editor/KeyframeEaseList.tsx +127 -0
  24. package/src/components/editor/OffCanvasIndicators.tsx +111 -0
  25. package/src/components/editor/PropertyPanel.tsx +63 -56
  26. package/src/components/editor/gsapAnimationCallbacks.ts +3 -0
  27. package/src/components/editor/gsapAnimationConstants.ts +11 -32
  28. package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
  29. package/src/components/editor/manualOffsetDrag.ts +8 -0
  30. package/src/components/editor/marqueeCommit.ts +168 -0
  31. package/src/components/editor/propertyPanelHelpers.ts +7 -0
  32. package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
  33. package/src/components/editor/snapTargetCollection.ts +0 -5
  34. package/src/components/editor/useMotionPathData.ts +2 -1
  35. package/src/components/panels/SlideshowPanel.tsx +0 -1
  36. package/src/components/sidebar/AssetContextMenu.tsx +97 -0
  37. package/src/components/sidebar/AssetsTab.tsx +542 -254
  38. package/src/components/sidebar/assetHelpers.ts +40 -0
  39. package/src/contexts/DomEditContext.tsx +12 -0
  40. package/src/hooks/gsapDragCommit.ts +101 -0
  41. package/src/hooks/gsapDragPositionCommit.ts +1 -2
  42. package/src/hooks/gsapRuntimeBridge.ts +28 -2
  43. package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
  44. package/src/hooks/gsapRuntimeKeyframes.ts +13 -0
  45. package/src/hooks/gsapRuntimeReaders.ts +1 -6
  46. package/src/hooks/useDomEditCommits.ts +0 -4
  47. package/src/hooks/useDomEditPreviewSync.ts +5 -0
  48. package/src/hooks/useDomEditSession.ts +43 -0
  49. package/src/hooks/useDomEditTextCommits.ts +3 -12
  50. package/src/hooks/useDomEditWiring.ts +3 -0
  51. package/src/hooks/useDomSelection.ts +46 -0
  52. package/src/hooks/useGestureCommit.ts +26 -7
  53. package/src/hooks/useGestureRecording.ts +2 -16
  54. package/src/hooks/useGsapAnimationOps.ts +2 -2
  55. package/src/hooks/useGsapAwareEditing.ts +0 -4
  56. package/src/hooks/useGsapScriptCommits.ts +0 -12
  57. package/src/hooks/useGsapTweenCache.test.ts +45 -1
  58. package/src/hooks/useGsapTweenCache.ts +151 -42
  59. package/src/hooks/useMusicBeatAnalysis.ts +36 -21
  60. package/src/hooks/useRazorSplit.ts +0 -3
  61. package/src/player/components/Player.tsx +0 -5
  62. package/src/player/components/timelineIcons.tsx +2 -1
  63. package/src/player/hooks/useTimelinePlayer.ts +4 -14
  64. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -9
  65. package/src/player/lib/timelineIframeHelpers.ts +2 -6
  66. package/src/telemetry/client.ts +2 -0
  67. package/src/utils/gestureSmoother.test.ts +48 -0
  68. package/src/utils/gestureSmoother.ts +46 -0
  69. package/src/utils/marqueeGeometry.test.ts +123 -0
  70. package/src/utils/marqueeGeometry.ts +172 -0
  71. package/src/utils/optimisticUpdate.ts +1 -2
  72. package/src/utils/sourcePatcher.ts +0 -10
  73. package/src/utils/velocityEaseFitter.test.ts +58 -0
  74. package/src/utils/velocityEaseFitter.ts +121 -0
  75. package/dist/assets/index-D_JGXmfx.js +0 -374
  76. package/dist/assets/index-DzWIinxk.css +0 -1
  77. package/src/utils/editDebugLog.ts +0 -16
@@ -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 = { percentage: number; properties: Record<string, number | string> };
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({ percentage: kf.percentage, properties: props });
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 keyframes = sortedPcts.map((pct) => ({
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 (err) {
378
+ } catch {
389
379
  // Preview failed — disable it for the rest of the gesture (recording
390
- // continues). Surface in dev so a dead preview isn't silent; `r.runtime`
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
- return lastSimple ? matchers.has(lastSimple) : false;
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) return;
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 && target) {
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
- targetId || targetSelector
199
- ? getAnimationsForElement(allAnimations, { id: targetId, selector: targetSelector })
200
- : [],
201
- [allAnimations, targetId, targetSelector],
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
- clearKeyframeCacheForElement(sourceFile, elementId);
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
- const timelineEl = elements.find(
385
- (el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`,
386
- );
387
- const elStart = timelineEl?.start ?? 0;
388
- const elDuration = timelineEl?.duration ?? 1;
389
- const clipKeyframes = kfData.keyframes.map((kf) => {
390
- const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
391
- // 0.001% precision (matching useGsapAnimationsForElement above) so a
392
- // beat-snapped keyframe centers exactly on the beat dot and the two
393
- // caches agree on a keyframe's percentage.
394
- const clipPct =
395
- elDuration > 0
396
- ? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
397
- : kf.percentage;
398
- return {
399
- ...kf,
400
- percentage: clipPct,
401
- tweenPercentage: kf.percentage,
402
- propertyGroup: anim.propertyGroup,
403
- };
404
- });
405
- const existing = mergedByElement.get(id);
406
- if (existing) {
407
- existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
408
- } else {
409
- mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
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
- 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;