@hyperframes/studio 0.7.6 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/assets/{index-B_NnmU6q.js → index--lOAjFJl.js} +1 -1
  2. package/dist/assets/{index-ysftPins.js → index-BSyZKYmH.js} +204 -203
  3. package/dist/assets/{index-DsBhGFPe.js → index-Dgeszckd.js} +1 -1
  4. package/dist/assets/index-svYFaNuq.css +1 -0
  5. package/dist/index.d.ts +5 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +3650 -2431
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/components/StudioRightPanel.tsx +7 -1
  11. package/src/components/editor/AnimationCard.tsx +6 -0
  12. package/src/components/editor/DomEditOverlay.tsx +4 -12
  13. package/src/components/editor/EaseCurveSection.tsx +175 -99
  14. package/src/components/editor/GsapAnimationSection.tsx +2 -0
  15. package/src/components/editor/KeyframeEaseList.tsx +67 -3
  16. package/src/components/editor/MarqueeOverlay.tsx +40 -0
  17. package/src/components/editor/OffCanvasIndicators.tsx +2 -7
  18. package/src/components/editor/PropertyPanel.tsx +23 -2
  19. package/src/components/editor/Transform3DCube.tsx +313 -0
  20. package/src/components/editor/gsapAnimationCallbacks.ts +2 -0
  21. package/src/components/editor/gsapAnimationConstants.ts +11 -35
  22. package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
  23. package/src/components/editor/marqueeCommit.ts +63 -20
  24. package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
  25. package/src/components/editor/propertyPanelHelpers.ts +46 -18
  26. package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
  27. package/src/components/editor/transform3dProjection.test.ts +99 -0
  28. package/src/components/editor/transform3dProjection.ts +172 -0
  29. package/src/components/editor/useMotionPathData.ts +2 -1
  30. package/src/components/sidebar/AssetContextMenu.tsx +97 -0
  31. package/src/components/sidebar/AssetsTab.tsx +364 -266
  32. package/src/components/sidebar/AudioRow.tsx +202 -0
  33. package/src/components/sidebar/assetHelpers.ts +40 -0
  34. package/src/contexts/DomEditContext.tsx +8 -0
  35. package/src/hooks/gsapDragCommit.test.ts +9 -5
  36. package/src/hooks/gsapDragCommit.ts +129 -9
  37. package/src/hooks/gsapRuntimeBridge.ts +22 -0
  38. package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
  39. package/src/hooks/gsapRuntimeKeyframes.ts +63 -5
  40. package/src/hooks/gsapRuntimePatch.ts +162 -35
  41. package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
  42. package/src/hooks/useDomEditCommits.ts +0 -2
  43. package/src/hooks/useDomEditSession.ts +24 -2
  44. package/src/hooks/useDomEditWiring.ts +3 -0
  45. package/src/hooks/useDomGeometryCommits.ts +3 -31
  46. package/src/hooks/useGestureCommit.ts +0 -4
  47. package/src/hooks/useGsapAwareEditing.ts +31 -5
  48. package/src/hooks/useGsapKeyframeOps.ts +4 -1
  49. package/src/hooks/useGsapScriptCommits.ts +0 -12
  50. package/src/hooks/useGsapSelectionHandlers.ts +12 -9
  51. package/src/hooks/useGsapTweenCache.test.ts +45 -1
  52. package/src/hooks/useGsapTweenCache.ts +125 -42
  53. package/src/hooks/useMusicBeatAnalysis.ts +36 -21
  54. package/src/hooks/useRazorSplit.ts +0 -3
  55. package/src/utils/marqueeGeometry.test.ts +15 -98
  56. package/src/utils/marqueeGeometry.ts +1 -158
  57. package/dist/assets/index-wJZf6FK3.css +0 -1
  58. package/src/utils/editDebugLog.ts +0 -9
@@ -0,0 +1,202 @@
1
+ import { useState, useRef, useEffect, useCallback } from "react";
2
+ import { ContextMenu } from "./AssetContextMenu";
3
+ import { basename, getAudioSubtype } from "./assetHelpers";
4
+ import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
5
+
6
+ export function AudioRow({
7
+ projectId,
8
+ asset,
9
+ used,
10
+ meta,
11
+ onCopy,
12
+ isCopied,
13
+ onDelete,
14
+ onRename,
15
+ }: {
16
+ projectId: string;
17
+ asset: string;
18
+ used: boolean;
19
+ meta?: { description?: string; duration?: number };
20
+ onCopy: (path: string) => void;
21
+ isCopied: boolean;
22
+ onDelete?: (path: string) => void;
23
+ onRename?: (oldPath: string, newPath: string) => void;
24
+ }) {
25
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
26
+ const [playing, setPlaying] = useState(false);
27
+ const [bars, setBars] = useState<number[]>([]);
28
+ const audioRef = useRef<HTMLAudioElement | null>(null);
29
+ const actxRef = useRef<AudioContext | null>(null);
30
+ const analyserRef = useRef<AnalyserNode | null>(null);
31
+ const sourceRef = useRef<MediaElementAudioSourceNode | null>(null);
32
+ const animRef = useRef<number>(0);
33
+ const name = basename(asset);
34
+ const subtype = getAudioSubtype(asset);
35
+ const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
36
+
37
+ useEffect(() => {
38
+ return () => {
39
+ cancelAnimationFrame(animRef.current);
40
+ audioRef.current?.pause();
41
+ actxRef.current?.close();
42
+ };
43
+ }, []);
44
+
45
+ useEffect(() => {
46
+ if (playing) {
47
+ const barCount = 24;
48
+ const loop = () => {
49
+ const analyser = analyserRef.current;
50
+ if (!analyser) {
51
+ animRef.current = requestAnimationFrame(loop);
52
+ return;
53
+ }
54
+ const data = new Uint8Array(analyser.frequencyBinCount);
55
+ analyser.getByteFrequencyData(data);
56
+ const step = Math.floor(data.length / barCount);
57
+ const next: number[] = [];
58
+ for (let i = 0; i < barCount; i++) {
59
+ let sum = 0;
60
+ for (let j = 0; j < step; j++) sum += data[i * step + j];
61
+ next.push(sum / step / 255);
62
+ }
63
+ setBars(next);
64
+ if (audioRef.current && !audioRef.current.paused)
65
+ animRef.current = requestAnimationFrame(loop);
66
+ };
67
+ animRef.current = requestAnimationFrame(loop);
68
+ } else {
69
+ setBars([]);
70
+ }
71
+ return () => cancelAnimationFrame(animRef.current);
72
+ }, [playing]);
73
+
74
+ const togglePlay = useCallback(async () => {
75
+ if (playing) {
76
+ audioRef.current?.pause();
77
+ setPlaying(false);
78
+ cancelAnimationFrame(animRef.current);
79
+ return;
80
+ }
81
+
82
+ if (!actxRef.current) {
83
+ actxRef.current = new AudioContext();
84
+ analyserRef.current = actxRef.current.createAnalyser();
85
+ analyserRef.current.fftSize = 256;
86
+ analyserRef.current.smoothingTimeConstant = 0.7;
87
+ }
88
+
89
+ if (!audioRef.current) {
90
+ const el = new Audio();
91
+ el.onended = () => {
92
+ setPlaying(false);
93
+ cancelAnimationFrame(animRef.current);
94
+ };
95
+ audioRef.current = el;
96
+ sourceRef.current = actxRef.current.createMediaElementSource(el);
97
+ sourceRef.current.connect(analyserRef.current!);
98
+ analyserRef.current!.connect(actxRef.current.destination);
99
+ el.src = serveUrl;
100
+ }
101
+
102
+ if (actxRef.current.state === "suspended") await actxRef.current.resume();
103
+ audioRef.current.currentTime = 0;
104
+ await audioRef.current.play();
105
+ setPlaying(true);
106
+ }, [serveUrl, playing]);
107
+
108
+ return (
109
+ <>
110
+ <div
111
+ draggable
112
+ onClick={() => onCopy(asset)}
113
+ onDragStart={(e) => {
114
+ e.dataTransfer.effectAllowed = "copy";
115
+ e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
116
+ e.dataTransfer.setData("text/plain", asset);
117
+ }}
118
+ onContextMenu={(e) => {
119
+ e.preventDefault();
120
+ setContextMenu({ x: e.clientX, y: e.clientY });
121
+ }}
122
+ className={`group w-full text-left px-4 py-1.5 flex items-center gap-2.5 transition-all cursor-pointer ${
123
+ playing
124
+ ? "bg-panel-accent/[0.06]"
125
+ : isCopied
126
+ ? "bg-panel-accent/10"
127
+ : "hover:bg-panel-surface-hover"
128
+ }`}
129
+ >
130
+ <button
131
+ className={`w-7 h-7 rounded-md flex-shrink-0 flex items-center justify-center transition-all ${
132
+ playing
133
+ ? "bg-panel-accent/15 text-panel-accent"
134
+ : "text-panel-text-5 group-hover:text-panel-text-3"
135
+ }`}
136
+ onClick={(e) => {
137
+ e.stopPropagation();
138
+ togglePlay();
139
+ }}
140
+ >
141
+ {playing ? (
142
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
143
+ <rect x="6" y="4" width="4" height="16" rx="1" />
144
+ <rect x="14" y="4" width="4" height="16" rx="1" />
145
+ </svg>
146
+ ) : (
147
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
148
+ <polygon points="6,4 20,12 6,20" />
149
+ </svg>
150
+ )}
151
+ </button>
152
+ <div className="min-w-0 flex-1">
153
+ <div className="flex items-center gap-1.5">
154
+ <span
155
+ className={`text-[12px] font-medium truncate ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
156
+ >
157
+ {name}
158
+ </span>
159
+ {!playing && (
160
+ <span className="text-[11px] text-panel-text-5 flex-shrink-0">
161
+ {meta?.duration ? `${meta.duration}s · ` : ""}
162
+ {subtype}
163
+ </span>
164
+ )}
165
+ {used && (
166
+ <span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded flex-shrink-0">
167
+ in use
168
+ </span>
169
+ )}
170
+ </div>
171
+ {bars.length > 0 && (
172
+ <div className="flex items-end gap-[2px] h-[14px] mt-0.5">
173
+ {bars.map((v, i) => (
174
+ <div
175
+ key={i}
176
+ className="flex-1 rounded-[1px]"
177
+ style={{
178
+ height: `${Math.max(10, v * 100)}%`,
179
+ background: `linear-gradient(to top, rgba(60, 230, 172, ${0.3 + v * 0.5}), rgba(60, 230, 172, ${0.5 + v * 0.5}))`,
180
+ transition: "height 80ms ease-out",
181
+ }}
182
+ />
183
+ ))}
184
+ </div>
185
+ )}
186
+ </div>
187
+ </div>
188
+
189
+ {contextMenu && (
190
+ <ContextMenu
191
+ x={contextMenu.x}
192
+ y={contextMenu.y}
193
+ asset={asset}
194
+ onClose={() => setContextMenu(null)}
195
+ onCopy={onCopy}
196
+ onDelete={onDelete}
197
+ onRename={onRename}
198
+ />
199
+ )}
200
+ </>
201
+ );
202
+ }
@@ -0,0 +1,40 @@
1
+ import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT, FONT_EXT } from "../../utils/mediaTypes";
2
+
3
+ export type MediaCategory = "audio" | "images" | "video" | "fonts";
4
+
5
+ export function getCategory(path: string): MediaCategory | null {
6
+ if (AUDIO_EXT.test(path)) return "audio";
7
+ if (IMAGE_EXT.test(path)) return "images";
8
+ if (VIDEO_EXT.test(path)) return "video";
9
+ if (FONT_EXT.test(path)) return "fonts";
10
+ return null;
11
+ }
12
+
13
+ export function getAudioSubtype(path: string): string {
14
+ const lower = path.toLowerCase();
15
+ if (lower.includes("/bgm/") || lower.includes("/music/")) return "BGM";
16
+ if (lower.includes("/sfx/") || lower.includes("/sound")) return "SFX";
17
+ if (lower.includes("/voice/") || lower.includes("/narrat")) return "Voice";
18
+ return "Audio";
19
+ }
20
+
21
+ export function basename(path: string): string {
22
+ const name = path.split("/").pop() ?? path;
23
+ const dot = name.lastIndexOf(".");
24
+ return dot > 0 ? name.slice(0, dot) : name;
25
+ }
26
+
27
+ export function ext(path: string): string {
28
+ const name = path.split("/").pop() ?? path;
29
+ const dot = name.lastIndexOf(".");
30
+ return dot > 0 ? name.slice(dot + 1).toUpperCase() : "";
31
+ }
32
+
33
+ export const CATEGORY_LABELS: Record<MediaCategory, string> = {
34
+ audio: "Audio",
35
+ images: "Images",
36
+ video: "Video",
37
+ fonts: "Fonts",
38
+ };
39
+
40
+ export const FILTER_ORDER: MediaCategory[] = ["audio", "images", "video", "fonts"];
@@ -55,6 +55,7 @@ export interface DomEditActionsValue extends Pick<
55
55
  | "handleGsapRemoveAllKeyframes"
56
56
  | "handleResetSelectedElementKeyframes"
57
57
  | "commitAnimatedProperty"
58
+ | "commitAnimatedProperties"
58
59
  | "handleSetArcPath"
59
60
  | "handleUpdateArcSegment"
60
61
  | "handleUnroll"
@@ -63,6 +64,7 @@ export interface DomEditActionsValue extends Pick<
63
64
  | "commitMutation"
64
65
  | "applyMarqueeSelection"
65
66
  | "handleUpdateKeyframeEase"
67
+ | "handleSetAllKeyframeEases"
66
68
  > {}
67
69
 
68
70
  export interface DomEditSelectionValue extends Pick<
@@ -163,6 +165,7 @@ export function DomEditProvider({
163
165
  handleGsapRemoveAllKeyframes,
164
166
  handleResetSelectedElementKeyframes,
165
167
  commitAnimatedProperty,
168
+ commitAnimatedProperties,
166
169
  handleSetArcPath,
167
170
  handleUpdateArcSegment,
168
171
  handleUnroll,
@@ -171,6 +174,7 @@ export function DomEditProvider({
171
174
  commitMutation,
172
175
  applyMarqueeSelection,
173
176
  handleUpdateKeyframeEase,
177
+ handleSetAllKeyframeEases,
174
178
  },
175
179
  children,
176
180
  }: {
@@ -236,6 +240,7 @@ export function DomEditProvider({
236
240
  handleGsapRemoveAllKeyframes,
237
241
  handleResetSelectedElementKeyframes,
238
242
  commitAnimatedProperty,
243
+ commitAnimatedProperties,
239
244
  handleSetArcPath,
240
245
  handleUpdateArcSegment,
241
246
  handleUnroll,
@@ -244,6 +249,7 @@ export function DomEditProvider({
244
249
  commitMutation: stableCommitMutation,
245
250
  applyMarqueeSelection,
246
251
  handleUpdateKeyframeEase,
252
+ handleSetAllKeyframeEases,
247
253
  }),
248
254
  [
249
255
  handleTimelineElementSelect,
@@ -295,6 +301,7 @@ export function DomEditProvider({
295
301
  handleGsapRemoveAllKeyframes,
296
302
  handleResetSelectedElementKeyframes,
297
303
  commitAnimatedProperty,
304
+ commitAnimatedProperties,
298
305
  handleSetArcPath,
299
306
  handleUpdateArcSegment,
300
307
  handleUnroll,
@@ -303,6 +310,7 @@ export function DomEditProvider({
303
310
  stableCommitMutation,
304
311
  applyMarqueeSelection,
305
312
  handleUpdateKeyframeEase,
313
+ handleSetAllKeyframeEases,
306
314
  ],
307
315
  );
308
316
 
@@ -332,7 +332,7 @@ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
332
332
  expect(yPatch.change.props[xMutation.property]).toBe(xMutation.value);
333
333
  });
334
334
 
335
- it("does NOT attach instantPatch when ADDING a new set (structural new tween)", async () => {
335
+ it("ADDS a global gsap.set with a global-set instantPatch (off-timeline, no flash)", async () => {
336
336
  const { commits, callbacks } = optionRecordingCallbacks();
337
337
 
338
338
  await commitStaticGsapPosition(
@@ -340,13 +340,15 @@ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
340
340
  { x: -50, y: 30 },
341
341
  { x: 0, y: 0 },
342
342
  "#puck-a",
343
- null, // no existing set → `add` a new tween
343
+ null, // no existing set → `add` a new base gsap.set
344
344
  callbacks,
345
345
  );
346
346
 
347
347
  expect(commits).toHaveLength(1);
348
348
  expect(commits[0].mutation.type).toBe("add");
349
- expect(commits[0].options.instantPatch).toBeUndefined();
349
+ expect((commits[0].mutation as { global?: boolean }).global).toBe(true);
350
+ const patch = commits[0].options.instantPatch as { change: { kind: string } } | undefined;
351
+ expect(patch?.change.kind).toBe("global-set");
350
352
  });
351
353
  });
352
354
 
@@ -372,14 +374,16 @@ describe("commitStaticGsapRotation — instantPatch (value-only set)", () => {
372
374
  expect(patch.change.props[m.property]).toBe(m.value);
373
375
  });
374
376
 
375
- it("does NOT attach instantPatch when ADDING a new rotation set (structural)", async () => {
377
+ it("ADDS a global gsap.set with a global-set instantPatch (off-timeline, no flash)", async () => {
376
378
  const { commits, callbacks } = optionRecordingCallbacks();
377
379
 
378
380
  await commitStaticGsapRotation(selection(), 42, "#puck-a", null, callbacks);
379
381
 
380
382
  expect(commits).toHaveLength(1);
381
383
  expect(commits[0].mutation.type).toBe("add");
382
- expect(commits[0].options.instantPatch).toBeUndefined();
384
+ expect((commits[0].mutation as { global?: boolean }).global).toBe(true);
385
+ const patch = commits[0].options.instantPatch as { change: { kind: string } } | undefined;
386
+ expect(patch?.change.kind).toBe("global-set");
383
387
  });
384
388
  });
385
389
 
@@ -4,6 +4,10 @@
4
4
  */
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
+ import {
8
+ STUDIO_ORIGINAL_WIDTH_ATTR,
9
+ STUDIO_ORIGINAL_HEIGHT_ATTR,
10
+ } from "../components/editor/manualEditsTypes";
7
11
  import { usePlayerStore } from "../player/store/playerStore";
8
12
  import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
9
13
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
@@ -116,18 +120,22 @@ interface UpdatePropertyMutation {
116
120
  function setPatchFromUpdateProperties(
117
121
  selector: string,
118
122
  mutations: UpdatePropertyMutation[],
123
+ global = false,
119
124
  ): { selector: string; change: RuntimeTweenChange } {
120
125
  const props: SetPatchProps = {};
121
126
  for (const m of mutations) props[m.property as keyof SetPatchProps] = m.value;
122
- return { selector, change: { kind: "set", props } };
127
+ // An off-timeline `gsap.set` has no runtime tween to patch — apply it to the
128
+ // element directly. An on-timeline `tl.set` mutates its tween (so a re-seek keeps it).
129
+ return { selector, change: { kind: global ? "global-set" : "set", props } };
123
130
  }
124
131
 
125
132
  /** Single-mutation convenience over {@link setPatchFromUpdateProperties}. */
126
133
  function setPatchFromUpdateProperty(
127
134
  selector: string,
128
135
  mutation: UpdatePropertyMutation,
136
+ global = false,
129
137
  ): { selector: string; change: RuntimeTweenChange } {
130
- return setPatchFromUpdateProperties(selector, [mutation]);
138
+ return setPatchFromUpdateProperties(selector, [mutation], global);
131
139
  }
132
140
 
133
141
  /**
@@ -190,22 +198,25 @@ export async function commitStaticGsapPosition(
190
198
  // preview still reflects what DID persist. The x commit carries skipReload
191
199
  // (no reload), so its instantPatch gives instant feedback without a reload;
192
200
  // the y commit triggers the soft reload (skipped when the patch applies).
201
+ const global = !!existingSet.global;
193
202
  await callbacks.commitMutation(selection, xMutation, {
194
203
  label: "Move layer",
195
204
  skipReload: true,
196
205
  coalesceKey,
197
- instantPatch: setPatchFromUpdateProperty(selector, xMutation),
206
+ instantPatch: setPatchFromUpdateProperty(selector, xMutation, global),
198
207
  });
199
208
  await callbacks.commitMutation(selection, yMutation, {
200
209
  label: "Move layer",
201
210
  softReload: true,
202
211
  coalesceKey,
203
212
  // Final commit of the coalesced x/y pair: carry both channels so the
204
- // runtime `tl.set` lands the complete {x,y} pose in place.
205
- instantPatch: setPatchFromUpdateProperties(selector, [xMutation, yMutation]),
213
+ // runtime set lands the complete {x,y} pose in place.
214
+ instantPatch: setPatchFromUpdateProperties(selector, [xMutation, yMutation], global),
206
215
  });
207
216
  return;
208
217
  }
218
+ // New static hold → a base `gsap.set` (off-timeline, no 0% keyframe marker), with
219
+ // an instant patch so the first nudge shows immediately (no soft-reload flash).
209
220
  await callbacks.commitMutation(
210
221
  selection,
211
222
  {
@@ -214,8 +225,13 @@ export async function commitStaticGsapPosition(
214
225
  method: "set",
215
226
  position: 0,
216
227
  properties: { x: newX, y: newY },
228
+ global: true,
229
+ },
230
+ {
231
+ label: "Move layer",
232
+ softReload: true,
233
+ instantPatch: { selector, change: { kind: "global-set", props: { x: newX, y: newY } } },
217
234
  },
218
- { label: "Move layer", softReload: true },
219
235
  );
220
236
  }
221
237
 
@@ -260,11 +276,13 @@ export async function commitStaticGsapRotation(
260
276
  await callbacks.commitMutation(selection, rotationMutation, {
261
277
  label: "Rotate layer",
262
278
  softReload: true,
263
- // Value-only rotation set: patch the runtime `tl.set` rotation in place.
264
- instantPatch: setPatchFromUpdateProperty(selector, rotationMutation),
279
+ // Value-only rotation set patch the runtime in place (off-timeline gsap.set
280
+ // applies to the element directly; on-timeline tl.set patches its tween).
281
+ instantPatch: setPatchFromUpdateProperty(selector, rotationMutation, !!existingSet.global),
265
282
  });
266
283
  return;
267
284
  }
285
+ // New static hold → off-timeline `gsap.set` (no 0% keyframe marker) + instant patch.
268
286
  await callbacks.commitMutation(
269
287
  selection,
270
288
  {
@@ -273,8 +291,13 @@ export async function commitStaticGsapRotation(
273
291
  method: "set",
274
292
  position: 0,
275
293
  properties: { rotation: newRotation },
294
+ global: true,
295
+ },
296
+ {
297
+ label: "Rotate layer",
298
+ softReload: true,
299
+ instantPatch: { selector, change: { kind: "global-set", props: { rotation: newRotation } } },
276
300
  },
277
- { label: "Rotate layer", softReload: true },
278
301
  );
279
302
  }
280
303
 
@@ -342,6 +365,103 @@ export async function commitStaticGsapSize(
342
365
  );
343
366
  }
344
367
 
368
+ /** Rounded `n` when it's a positive finite number, else `fallback`. */
369
+ function positiveOr(n: number, fallback: number): number {
370
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : fallback;
371
+ }
372
+
373
+ /**
374
+ * Prior size for a keyframed resize: the existing global set's value, else the
375
+ * element's pre-resize size (the draft saved it on the element before mutating
376
+ * el.style.width/height). Falls back to the new size when neither is available.
377
+ */
378
+ function resolvePriorSize(
379
+ sizeSet: GsapAnimation | null,
380
+ el: Element | null | undefined,
381
+ fallbackW: number,
382
+ fallbackH: number,
383
+ ): { width: number; height: number } {
384
+ if (sizeSet) {
385
+ return {
386
+ width: positiveOr(Number(sizeSet.properties.width), fallbackW),
387
+ height: positiveOr(Number(sizeSet.properties.height), fallbackH),
388
+ };
389
+ }
390
+ const ow = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_WIDTH_ATTR) ?? "");
391
+ const oh = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR) ?? "");
392
+ return { width: positiveOr(ow, fallbackW), height: positiveOr(oh, fallbackH) };
393
+ }
394
+
395
+ /**
396
+ * Resize an *animated* element by keyframing its size at the current playhead,
397
+ * instead of a global `gsap.set` hold. Builds a width/height keyframe tween
398
+ * aligned to the element's existing animation: every base keyframe keeps the
399
+ * prior size, only the keyframe nearest the playhead gets the new size — so
400
+ * resizing one keyframe leaves the others unchanged. Replaces any prior global
401
+ * size set. Returns false when there's no usable range (caller falls back to the
402
+ * static set).
403
+ */
404
+ export async function commitKeyframedSizeFromResize(
405
+ selection: DomEditSelection,
406
+ size: { width: number; height: number },
407
+ selector: string,
408
+ sizeSet: GsapAnimation | null,
409
+ animatedTween: GsapAnimation,
410
+ callbacks: GsapDragCommitCallbacks,
411
+ ): Promise<boolean> {
412
+ const ts = resolveTweenStart(animatedTween) ?? 0;
413
+ const td = resolveTweenDuration(animatedTween);
414
+ if (!(td > 0)) return false;
415
+
416
+ const newW = Math.round(size.width);
417
+ const newH = Math.round(size.height);
418
+ const prior = resolvePriorSize(sizeSet, selection.element, newW, newH);
419
+
420
+ const ct = usePlayerStore.getState().currentTime;
421
+ const pct = Math.max(0, Math.min(100, Math.round(((ct - ts) / td) * 1000) / 10));
422
+
423
+ // Base keyframe percentages from the animated tween (flat tween → 0 & 100),
424
+ // plus the endpoints and the playhead. Each keeps the prior size except the
425
+ // keyframe at the playhead, which gets the new size.
426
+ const pcts = new Set<number>(
427
+ animatedTween.keyframes?.keyframes.map((k) => k.percentage) ?? [0, 100],
428
+ );
429
+ pcts.add(0);
430
+ pcts.add(100);
431
+ pcts.add(pct);
432
+ const keyframes = Array.from(pcts)
433
+ .sort((a, b) => a - b)
434
+ .map((p) => ({
435
+ percentage: p,
436
+ properties: Math.abs(p - pct) < 0.05 ? { width: newW, height: newH } : { ...prior },
437
+ }));
438
+
439
+ // Add the size keyframe tween FIRST, then delete the old global hold. The two
440
+ // commits aren't transactional, so ordering matters: if the delete fails the
441
+ // size is preserved (animated, recoverable) rather than lost. Only the last
442
+ // commit triggers the reload.
443
+ const addLabel = `Resize (size keyframe ${pct.toFixed(0)}%)`;
444
+ await callbacks.commitMutation(
445
+ selection,
446
+ {
447
+ type: "add-with-keyframes",
448
+ targetSelector: selector,
449
+ position: roundTo3(ts),
450
+ duration: roundTo3(td),
451
+ keyframes,
452
+ },
453
+ sizeSet ? { label: addLabel, skipReload: true } : { label: addLabel, softReload: true },
454
+ );
455
+ if (sizeSet) {
456
+ await callbacks.commitMutation(
457
+ selection,
458
+ { type: "delete", animationId: sizeSet.id },
459
+ { label: "Resize layer", softReload: true },
460
+ );
461
+ }
462
+ return true;
463
+ }
464
+
345
465
  export { findSizeSetAnimation };
346
466
 
347
467
  // ── Whole-path offset (plain drag on animated element) ──────────────────
@@ -18,6 +18,7 @@ import {
18
18
  commitStaticGsapPosition,
19
19
  commitStaticGsapRotation,
20
20
  commitStaticGsapSize,
21
+ commitKeyframedSizeFromResize,
21
22
  commitWholePathOffset,
22
23
  computeCurrentPercentage,
23
24
  findPositionSetAnimation,
@@ -329,6 +330,27 @@ export async function tryGsapResizeIntercept(
329
330
  const sel = selectorFromSelection(selection);
330
331
  if (!sel) return false;
331
332
  const sizeSet = anim?.method === "set" ? anim : findSizeSetAnimation(animations, sel);
333
+
334
+ // If the element is animated (has a real tween, not just a static size
335
+ // hold), keyframe the size at the playhead so other keyframes keep theirs —
336
+ // instead of a global set that resizes every frame.
337
+ if (resizeGroup === "size") {
338
+ const animatedTween = pickClosestToPlayhead(
339
+ animations.filter((a) => a.method !== "set" && resolveTweenDuration(a) > 0),
340
+ );
341
+ if (animatedTween) {
342
+ const handled = await commitKeyframedSizeFromResize(
343
+ selection,
344
+ size,
345
+ sel,
346
+ sizeSet,
347
+ animatedTween,
348
+ { commitMutation, fetchAnimations: fetchFallbackAnimations },
349
+ );
350
+ if (handled) return true;
351
+ }
352
+ }
353
+
332
354
  await commitStaticGsapSize(selection, size, sel, sizeSet, {
333
355
  commitMutation,
334
356
  fetchAnimations: fetchFallbackAnimations,
@@ -101,6 +101,53 @@ describe("readRuntimeKeyframes — multiple tweens pick the one under the playhe
101
101
  });
102
102
  });
103
103
 
104
+ describe("readRuntimeKeyframes — requireChannels keeps the motion path on the positional tween", () => {
105
+ // An animated element with a position tween AND a (longer) size tween, both at
106
+ // start 0 — the shape the per-keyframe size resize produces.
107
+ const el = { id: "dot-a" };
108
+ const positionTween = {
109
+ targets: () => [el],
110
+ vars: {
111
+ keyframes: [
112
+ { x: -1252, y: -394 },
113
+ { x: 244, y: -316 },
114
+ ],
115
+ duration: 2.4,
116
+ },
117
+ duration: () => 2.4,
118
+ startTime: () => 0, // range [0, 2.4]
119
+ };
120
+ const sizeTween = {
121
+ targets: () => [el],
122
+ vars: {
123
+ keyframes: [
124
+ { width: 120, height: 96 },
125
+ { width: 325, height: 300 },
126
+ ],
127
+ duration: 3.243,
128
+ },
129
+ duration: () => 3.243,
130
+ startTime: () => 0, // range [0, 3.243] — outlives the position tween
131
+ };
132
+
133
+ it("playhead past the position range (2.4–3.243s) still returns the position tween, not size", () => {
134
+ // Without the filter the size tween (the only one containing the playhead)
135
+ // would win and blank the path.
136
+ const read = readRuntimeKeyframes(
137
+ fakeIframe(el, [positionTween, sizeTween], 3.0),
138
+ "#dot-a",
139
+ undefined,
140
+ ["x", "y"],
141
+ );
142
+ expect(read?.keyframes[0]?.properties).toHaveProperty("x");
143
+ });
144
+
145
+ it("without requireChannels the size tween shadows the path past the position range (documents the bug)", () => {
146
+ const read = readRuntimeKeyframes(fakeIframe(el, [positionTween, sizeTween], 3.0), "#dot-a");
147
+ expect(read?.keyframes[0]?.properties).toHaveProperty("width");
148
+ });
149
+ });
150
+
104
151
  describe("hasNonHoldTweenForElement — strict live-tween existence (drag stale-parse guard)", () => {
105
152
  const el = { id: "puck-b" };
106
153
  const holdSet = {