@hyperframes/studio 0.6.105 → 0.6.106

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.
@@ -1,9 +1,15 @@
1
1
  /**
2
2
  * Read GSAP keyframe data from the live runtime in the preview iframe.
3
3
  * Used to discover dynamic keyframes that the AST parser can't resolve
4
- * (loops, variables, computed selectors).
4
+ * (data-driven loops, fetched values, computed selectors).
5
+ *
6
+ * Keyframe percentages returned here are TWEEN-RELATIVE (0–100 within the
7
+ * tween), matching the static parser. Callers convert to clip-relative via
8
+ * `toAbsoluteTime` + the element's clip start/duration. `scanAllRuntimeKeyframes`
9
+ * does that conversion itself when given a `clipById` map.
5
10
  */
6
- import { parsePercentageKeyframes } from "./gsapShared";
11
+ import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
12
+ import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
7
13
  import { roundTo3 } from "../utils/rounding";
8
14
 
9
15
  interface RuntimeTween {
@@ -18,155 +24,224 @@ interface RuntimeTimeline {
18
24
  duration?: () => number;
19
25
  }
20
26
 
21
- export function readRuntimeKeyframes(
22
- iframe: HTMLIFrameElement | null,
23
- selector: string,
24
- compositionId?: string,
25
- ): {
26
- keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
27
+ type Pct = { percentage: number; properties: Record<string, number | string> };
28
+ type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig };
29
+
30
+ export interface RuntimeKeyframeEntry {
31
+ keyframes: Pct[];
27
32
  easeEach?: string;
28
- } | null {
29
- if (!iframe?.contentWindow) return null;
33
+ /** Present when the live tween uses motionPath — drives the Arc Motion panel. */
34
+ arcPath?: ArcPathConfig;
35
+ /** Absolute start time of the source tween (seconds). */
36
+ tweenStart: number;
37
+ /** Duration of the source tween (seconds). */
38
+ tweenDuration: number;
39
+ }
30
40
 
31
- let timelines: Record<string, RuntimeTimeline | undefined> | undefined;
41
+ /** Clip start/duration per element id, to convert tween-relative % to clip-relative. */
42
+ export type ClipDims = Map<string, { start: number; duration: number }>;
43
+
44
+ const FLAT_SKIP_KEYS = new Set([
45
+ "ease",
46
+ "duration",
47
+ "delay",
48
+ "stagger",
49
+ "motionPath",
50
+ "overwrite",
51
+ "immediateRender",
52
+ "onComplete",
53
+ "onUpdate",
54
+ "onStart",
55
+ "keyframes",
56
+ ]);
57
+
58
+ function timelinesOf(iframe: HTMLIFrameElement | null): Record<string, RuntimeTimeline> | null {
59
+ if (!iframe?.contentWindow) return null;
32
60
  try {
33
- timelines = (
34
- iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> }
35
- ).__timelines;
61
+ return (
62
+ (iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> })
63
+ .__timelines ?? null
64
+ );
36
65
  } catch {
37
66
  return null;
38
67
  }
39
- if (!timelines) return null;
68
+ }
69
+
70
+ function isXY(p: unknown): p is { x: number; y: number } {
71
+ return !!p && typeof (p as any).x === "number" && typeof (p as any).y === "number";
72
+ }
73
+
74
+ /** Coordinates + curviness from a live `vars.motionPath` value (object or array form), or null. */
75
+ function coordsFromMotionPath(mp: unknown): {
76
+ coords: Array<{ x: number; y: number }>;
77
+ curviness: number;
78
+ autoRotate: boolean | number;
79
+ isCubic: boolean;
80
+ } | null {
81
+ if (!mp || typeof mp !== "object") return null;
82
+ const obj = mp as Record<string, unknown>;
83
+ const pathVal = Array.isArray(mp) ? mp : obj.path;
84
+ if (!Array.isArray(pathVal)) return null;
85
+ const coords = pathVal.filter(isXY).map((p) => ({ x: p.x, y: p.y }));
86
+ if (coords.length < 2) return null;
87
+ const curviness = typeof obj.curviness === "number" ? obj.curviness : 1;
88
+ const autoRotate = typeof obj.autoRotate === "number" ? obj.autoRotate : obj.autoRotate === true;
89
+ return { coords, curviness, autoRotate, isCubic: obj.type === "cubic" };
90
+ }
91
+
92
+ /** Build an arcPath config from a live `vars.motionPath` value. */
93
+ export function arcPathFromMotionPathValue(mp: unknown): ArcPathConfig | undefined {
94
+ const parsed = coordsFromMotionPath(mp);
95
+ if (!parsed) return undefined;
96
+ return buildArcPath(parsed.coords, parsed.curviness, parsed.autoRotate, parsed.isCubic)?.arcPath;
97
+ }
98
+
99
+ function flatTweenKeyframes(vars: Record<string, unknown>): Pct[] | null {
100
+ const properties: Record<string, number | string> = {};
101
+ for (const [k, v] of Object.entries(vars)) {
102
+ if (FLAT_SKIP_KEYS.has(k)) continue;
103
+ if (typeof v === "number") properties[k] = roundTo3(v);
104
+ else if (typeof v === "string") properties[k] = v;
105
+ }
106
+ if (Object.keys(properties).length === 0) return null;
107
+ return [
108
+ { percentage: 0, properties },
109
+ { percentage: 100, properties },
110
+ ];
111
+ }
112
+
113
+ /** Tween-relative keyframes + optional arcPath for one live tween, or null. */
114
+ function readTween(vars: Record<string, unknown>): ReadTween | null {
115
+ if (vars.keyframes && typeof vars.keyframes === "object") {
116
+ const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
117
+ if (parsed) return parsed;
118
+ }
119
+ const mp = coordsFromMotionPath(vars.motionPath);
120
+ if (mp) {
121
+ const shape = buildArcPath(mp.coords, mp.curviness, mp.autoRotate, mp.isCubic);
122
+ if (shape) {
123
+ const n = shape.waypoints.length;
124
+ const keyframes = shape.waypoints.map((wp, i) => ({
125
+ percentage: n > 1 ? Math.round((i / (n - 1)) * 100) : 0,
126
+ properties: { x: wp.x, y: wp.y },
127
+ }));
128
+ return { keyframes, arcPath: shape.arcPath };
129
+ }
130
+ }
131
+ const flat = flatTweenKeyframes(vars);
132
+ return flat ? { keyframes: flat } : null;
133
+ }
134
+
135
+ function matchesElement(tween: RuntimeTween, el: Element): boolean {
136
+ if (!tween.targets) return false;
137
+ for (const t of tween.targets()) {
138
+ if (t === el || (el.id && (t as Element).id === el.id)) return true;
139
+ }
140
+ return false;
141
+ }
40
142
 
143
+ function tweenTiming(tween: RuntimeTween): { start: number; duration: number } {
144
+ const rawStart = typeof tween.startTime === "function" ? tween.startTime() : 0;
145
+ const rawDur = typeof tween.duration === "function" ? tween.duration() : 0;
146
+ return {
147
+ start: Number.isFinite(rawStart) ? rawStart : 0,
148
+ duration: Number.isFinite(rawDur) ? rawDur : 0,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Read keyframes (incl. motionPath arcs) for one selector from the live timeline.
154
+ * Returns tween-relative percentages; callers convert to clip-relative.
155
+ */
156
+ export function readRuntimeKeyframes(
157
+ iframe: HTMLIFrameElement | null,
158
+ selector: string,
159
+ compositionId?: string,
160
+ ): ReadTween | null {
161
+ const timelines = timelinesOf(iframe);
162
+ if (!timelines) return null;
41
163
  const tlId = compositionId || Object.keys(timelines)[0];
42
164
  if (!tlId) return null;
43
165
  const timeline = timelines[tlId];
44
166
  if (!timeline?.getChildren) return null;
45
167
 
46
- let doc: Document | null = null;
168
+ let targetEl: Element | null = null;
47
169
  try {
48
- doc = iframe.contentDocument;
170
+ targetEl = iframe?.contentDocument?.querySelector(selector) ?? null;
49
171
  } catch {
50
172
  return null;
51
173
  }
52
- if (!doc) return null;
53
-
54
- const targetEl = doc.querySelector(selector);
55
174
  if (!targetEl) return null;
56
175
 
57
176
  for (const tween of timeline.getChildren(true)) {
58
- if (!tween.targets || !tween.vars) continue;
59
- let matches = false;
60
- for (const t of tween.targets()) {
61
- if (t === targetEl || (targetEl.id && t.id === targetEl.id)) {
62
- matches = true;
63
- break;
64
- }
65
- }
66
- if (!matches) continue;
67
-
68
- const vars = tween.vars;
69
- if (!vars.keyframes || typeof vars.keyframes !== "object") continue;
70
-
71
- const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
72
- if (parsed) return parsed;
177
+ if (!tween.vars || !matchesElement(tween, targetEl)) continue;
178
+ const read = readTween(tween.vars);
179
+ if (read) return read;
73
180
  }
74
181
  return null;
75
182
  }
76
183
 
77
- // fallow-ignore-next-line complexity
78
- export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map<
79
- string,
80
- {
81
- keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
82
- easeEach?: string;
83
- }
84
- > {
85
- const result = new Map<
86
- string,
87
- {
88
- keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
89
- easeEach?: string;
90
- }
91
- >();
92
- if (!iframe?.contentWindow) return result;
184
+ /** Convert tween-relative keyframes to clip-relative % using the element's clip dims. */
185
+ function toClipRelative(
186
+ keyframes: Pct[],
187
+ tweenStart: number,
188
+ tweenDuration: number,
189
+ clip: { start: number; duration: number } | undefined,
190
+ ): Pct[] {
191
+ if (!clip || clip.duration <= 0) return keyframes;
192
+ return keyframes.map((kf) => {
193
+ const abs = toAbsoluteTime(tweenStart, tweenDuration, kf.percentage);
194
+ return { ...kf, percentage: Math.round(((abs - clip.start) / clip.duration) * 100000) / 1000 };
195
+ });
196
+ }
93
197
 
94
- let timelines: Record<string, RuntimeTimeline | undefined> | undefined;
95
- try {
96
- timelines = (
97
- iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> }
98
- ).__timelines;
99
- } catch {
100
- return result;
198
+ function buildEntry(
199
+ read: ReadTween,
200
+ start: number,
201
+ duration: number,
202
+ clip: { start: number; duration: number } | undefined,
203
+ ): RuntimeKeyframeEntry {
204
+ return {
205
+ keyframes: toClipRelative(read.keyframes, start, duration, clip),
206
+ tweenStart: start,
207
+ tweenDuration: duration,
208
+ ...(read.easeEach ? { easeEach: read.easeEach } : {}),
209
+ ...(read.arcPath ? { arcPath: read.arcPath } : {}),
210
+ };
211
+ }
212
+
213
+ /** Record one tween's keyframes under each target id (first-tween-per-id wins). */
214
+ function addScanEntry(
215
+ result: Map<string, RuntimeKeyframeEntry>,
216
+ tween: RuntimeTween,
217
+ clipById?: ClipDims,
218
+ ): void {
219
+ if (!tween.targets || !tween.vars) return;
220
+ const read = readTween(tween.vars);
221
+ if (!read) return;
222
+ const { start, duration } = tweenTiming(tween);
223
+ for (const target of tween.targets()) {
224
+ const id = (target as HTMLElement).id;
225
+ if (id && !result.has(id)) result.set(id, buildEntry(read, start, duration, clipById?.get(id)));
101
226
  }
102
- if (!timelines) return result;
227
+ }
103
228
 
229
+ /**
230
+ * Scan every live tween, grouping keyframes by element id. Percentages are
231
+ * tween-relative unless `clipById` is supplied, in which case each entry's
232
+ * keyframes are converted to clip-relative. First keyframe-bearing tween per
233
+ * element wins (the common single-primary-tween case).
234
+ */
235
+ export function scanAllRuntimeKeyframes(
236
+ iframe: HTMLIFrameElement | null,
237
+ clipById?: ClipDims,
238
+ ): Map<string, RuntimeKeyframeEntry> {
239
+ const result = new Map<string, RuntimeKeyframeEntry>();
240
+ const timelines = timelinesOf(iframe);
241
+ if (!timelines) return result;
104
242
  for (const timeline of Object.values(timelines)) {
105
243
  if (!timeline?.getChildren) continue;
106
- const tlDuration = typeof timeline.duration === "function" ? timeline.duration() : 0;
107
-
108
- for (const tween of timeline.getChildren(true)) {
109
- if (!tween.targets || !tween.vars) continue;
110
- const vars = tween.vars;
111
-
112
- if (vars.keyframes && typeof vars.keyframes === "object") {
113
- const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
114
- if (parsed) {
115
- for (const target of tween.targets()) {
116
- const id = (target as HTMLElement).id;
117
- if (id && !result.has(id)) {
118
- result.set(id, parsed);
119
- }
120
- }
121
- continue;
122
- }
123
- }
124
-
125
- // Flat tweens: synthesize start + end keyframe entries
126
- if (!tlDuration || tlDuration <= 0) continue;
127
- const tweenStart = typeof tween.startTime === "function" ? tween.startTime() : undefined;
128
- if (typeof tweenStart !== "number" || !Number.isFinite(tweenStart)) continue;
129
- const tweenDur = typeof tween.duration === "function" ? tween.duration() : 0;
130
-
131
- const startPct = Math.round((tweenStart / tlDuration) * 1000) / 10;
132
- const endPct =
133
- tweenDur > 0 ? Math.round(((tweenStart + tweenDur) / tlDuration) * 1000) / 10 : startPct;
134
- const properties: Record<string, number | string> = {};
135
- const skip = new Set([
136
- "ease",
137
- "duration",
138
- "delay",
139
- "stagger",
140
- "motionPath",
141
- "overwrite",
142
- "immediateRender",
143
- "onComplete",
144
- "onUpdate",
145
- "onStart",
146
- ]);
147
- for (const [k, v] of Object.entries(vars)) {
148
- if (skip.has(k)) continue;
149
- if (typeof v === "number") properties[k] = roundTo3(v);
150
- else if (typeof v === "string") properties[k] = v;
151
- }
152
- if (Object.keys(properties).length === 0) continue;
153
-
154
- for (const target of tween.targets()) {
155
- const id = (target as HTMLElement).id;
156
- if (!id) continue;
157
- const existing = result.get(id);
158
- const entries = existing ?? { keyframes: [] };
159
- entries.keyframes.push({ percentage: startPct, properties });
160
- if (endPct !== startPct) {
161
- entries.keyframes.push({ percentage: endPct, properties });
162
- }
163
- if (!existing) result.set(id, entries);
164
- }
165
- }
166
- }
167
-
168
- for (const entry of result.values()) {
169
- entry.keyframes.sort((a, b) => a.percentage - b.percentage);
244
+ for (const tween of timeline.getChildren(true)) addScanEntry(result, tween, clipById);
170
245
  }
171
246
  return result;
172
247
  }
@@ -334,6 +334,7 @@ export function useDomEditSession({
334
334
  commitAnimatedProperty,
335
335
  handleSetArcPath,
336
336
  handleUpdateArcSegment,
337
+ handleUnroll,
337
338
  commitMutation,
338
339
  } = useGsapAwareEditing({
339
340
  domEditSelection,
@@ -420,6 +421,7 @@ export function useDomEditSession({
420
421
  commitAnimatedProperty,
421
422
  handleSetArcPath,
422
423
  handleUpdateArcSegment,
424
+ handleUnroll,
423
425
  invalidateGsapCache: bumpGsapCache,
424
426
  previewIframeRef,
425
427
  commitMutation,
@@ -42,7 +42,15 @@ export function useDomGeometryCommits({
42
42
  }: UseDomGeometryCommitsParams) {
43
43
  const handleDomPathOffsetCommit = useCallback(
44
44
  (selection: DomEditSelection, next: { x: number; y: number }) => {
45
- if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
45
+ const gsapBlocked = isElementGsapTargeted(previewIframeRef.current, selection.element);
46
+ console.log(
47
+ "[drag:7] handleDomPathOffsetCommit (CSS path)",
48
+ JSON.stringify({
49
+ sel: selection.id,
50
+ gsapBlocked,
51
+ }),
52
+ );
53
+ if (gsapBlocked) {
46
54
  const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
47
55
  showToast(error.message, "error");
48
56
  return Promise.reject(error);
@@ -98,6 +98,17 @@ export function useGsapAwareEditing({
98
98
  const handleGsapAwarePathOffsetCommit = useCallback(
99
99
  async (selection: DomEditSelection, next: { x: number; y: number }) => {
100
100
  const hasGsapAnims = selectedGsapAnimations.length > 0;
101
+ console.log(
102
+ "[drag:3] handleGsapAwarePathOffsetCommit",
103
+ JSON.stringify({
104
+ sel: selection.id,
105
+ offset: next,
106
+ hasGsapAnims,
107
+ interceptEnabled: STUDIO_GSAP_DRAG_INTERCEPT_ENABLED,
108
+ animCount: selectedGsapAnimations.length,
109
+ animIds: selectedGsapAnimations.map((a) => a.id).slice(0, 5),
110
+ }),
111
+ );
101
112
  if (hasGsapAnims && !STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) {
102
113
  showToast(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE, "error");
103
114
  throw new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
@@ -230,6 +241,15 @@ export function useGsapAwareEditing({
230
241
  [domEditSelection, gsapCommitMutation],
231
242
  );
232
243
 
244
+ // Unroll all computed (helper/loop) tweens in the active timeline into literal
245
+ // tweens, so the clicked keyframe becomes directly editable. Visual no-op.
246
+ const handleUnroll = useCallback(() => {
247
+ void commitMutation(
248
+ { type: "unroll-timeline" },
249
+ { label: "Unroll to literal tweens", softReload: true },
250
+ );
251
+ }, [commitMutation]);
252
+
233
253
  return {
234
254
  handleGsapAwarePathOffsetCommit,
235
255
  handleGsapAwareBoxSizeCommit,
@@ -237,6 +257,7 @@ export function useGsapAwareEditing({
237
257
  commitAnimatedProperty,
238
258
  handleSetArcPath,
239
259
  handleUpdateArcSegment,
260
+ handleUnroll,
240
261
  commitMutation,
241
262
  };
242
263
  }
@@ -211,6 +211,7 @@ export function useGsapAnimationsForElement(
211
211
  keyframes: runtime.keyframes,
212
212
  ...(runtime.easeEach ? { easeEach: runtime.easeEach } : {}),
213
213
  },
214
+ ...(runtime.arcPath ? { arcPath: runtime.arcPath } : {}),
214
215
  };
215
216
  });
216
217
  }
@@ -243,6 +244,7 @@ export function useGsapAnimationsForElement(
243
244
  keyframes: runtimeEntry.keyframes,
244
245
  ...(runtimeEntry.easeEach ? { easeEach: runtimeEntry.easeEach } : {}),
245
246
  },
247
+ ...(runtimeEntry.arcPath ? { arcPath: runtimeEntry.arcPath } : {}),
246
248
  },
247
249
  ];
248
250
  }
@@ -358,19 +360,30 @@ export function usePopulateKeyframeCacheForFile(
358
360
 
359
361
  const sf = sourceFile;
360
362
  fetchParsedAnimations(projectId, sf).then((parsed) => {
361
- if (!parsed) return;
363
+ if (!parsed) {
364
+ return;
365
+ }
362
366
  const { setKeyframeCache } = usePlayerStore.getState();
363
- // Drop the file's stale entries (including the bare keys consumers read)
364
- // before repopulating, so an element whose keyframes were removed and is
365
- // absent from this scan doesn't keep showing diamonds.
366
367
  clearKeyframeCacheForFile(sf);
367
368
  const { elements } = usePlayerStore.getState();
369
+ console.log(
370
+ "[kf:static] elements in store:",
371
+ elements
372
+ .map((e) => e.domId)
373
+ .filter(Boolean)
374
+ .join(", "),
375
+ );
368
376
  const mergedByElement = new Map<string, GsapKeyframesData>();
369
377
  for (const anim of parsed.animations) {
370
378
  const id = extractIdFromSelector(anim.targetSelector);
371
379
  if (!id) continue;
380
+ if (anim.hasUnresolvedKeyframes) {
381
+ continue;
382
+ }
372
383
  const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
373
- if (!kfData) continue;
384
+ if (!kfData) {
385
+ continue;
386
+ }
374
387
  const tweenPos =
375
388
  anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
376
389
  const tweenDur = anim.duration ?? 1;
@@ -402,6 +415,12 @@ export function usePopulateKeyframeCacheForFile(
402
415
  mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
403
416
  }
404
417
  }
418
+ console.log(
419
+ "[kf:static] merged elements:",
420
+ [...mergedByElement.keys()].join(", "),
421
+ "kf counts:",
422
+ [...mergedByElement.entries()].map(([k, v]) => `${k}:${v.keyframes.length}`).join(", "),
423
+ );
405
424
  for (const [id, kfData] of mergedByElement) {
406
425
  setKeyframeCache(`${sf}#${id}`, kfData);
407
426
  setKeyframeCache(id, kfData);
@@ -428,14 +447,37 @@ export function usePopulateKeyframeCacheForFile(
428
447
  const iframe =
429
448
  iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
430
449
  if (!iframe) return false;
431
- const scanned = scanAllRuntimeKeyframes(iframe);
450
+ // Clip dims per element so the scan converts tween-relative keyframes to
451
+ // clip-relative (matching the static path) instead of timeline-relative.
452
+ const clipById = new Map<string, { start: number; duration: number }>();
453
+ for (const el of usePlayerStore.getState().elements) {
454
+ if (el.domId) clipById.set(el.domId, { start: el.start, duration: el.duration });
455
+ }
456
+ const scanned = scanAllRuntimeKeyframes(iframe, clipById);
457
+ console.log(
458
+ "[kf:runtime] scanned",
459
+ scanned.size,
460
+ "elements:",
461
+ [...scanned.keys()].join(", "),
462
+ );
432
463
  if (scanned.size === 0) return false;
433
464
  const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
434
465
  for (const [id, data] of scanned) {
435
466
  const cacheKey = `${sf}#${id}`;
436
467
  const fallbackKey = `index.html#${id}`;
437
- if (keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id))
468
+ const alreadyCached =
469
+ keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id);
470
+ if (alreadyCached) {
438
471
  continue;
472
+ }
473
+ console.log(
474
+ "[kf:runtime] adding runtime entry:",
475
+ id,
476
+ "kfs:",
477
+ data.keyframes.length,
478
+ "arc:",
479
+ !!data.arcPath,
480
+ );
439
481
  const entry = {
440
482
  format: "percentage" as const,
441
483
  keyframes: data.keyframes,