@hyperframes/studio 0.7.6 → 0.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-B_NnmU6q.js → index--lOAjFJl.js} +1 -1
- package/dist/assets/{index-ysftPins.js → index-BSyZKYmH.js} +204 -203
- package/dist/assets/{index-DsBhGFPe.js → index-Dgeszckd.js} +1 -1
- package/dist/assets/index-svYFaNuq.css +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.html +2 -2
- package/dist/index.js +3650 -2431
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/components/StudioRightPanel.tsx +7 -1
- package/src/components/editor/AnimationCard.tsx +6 -0
- package/src/components/editor/DomEditOverlay.tsx +4 -12
- package/src/components/editor/EaseCurveSection.tsx +175 -99
- package/src/components/editor/GsapAnimationSection.tsx +2 -0
- package/src/components/editor/KeyframeEaseList.tsx +67 -3
- package/src/components/editor/MarqueeOverlay.tsx +40 -0
- package/src/components/editor/OffCanvasIndicators.tsx +2 -7
- package/src/components/editor/PropertyPanel.tsx +23 -2
- package/src/components/editor/Transform3DCube.tsx +313 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +2 -0
- package/src/components/editor/gsapAnimationConstants.ts +11 -35
- package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
- package/src/components/editor/marqueeCommit.ts +63 -20
- package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
- package/src/components/editor/propertyPanelHelpers.ts +46 -18
- package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
- package/src/components/editor/transform3dProjection.test.ts +99 -0
- package/src/components/editor/transform3dProjection.ts +172 -0
- package/src/components/editor/useMotionPathData.ts +2 -1
- package/src/components/sidebar/AssetContextMenu.tsx +97 -0
- package/src/components/sidebar/AssetsTab.tsx +364 -266
- package/src/components/sidebar/AudioRow.tsx +202 -0
- package/src/components/sidebar/assetHelpers.ts +40 -0
- package/src/contexts/DomEditContext.tsx +8 -0
- package/src/hooks/gsapDragCommit.test.ts +9 -5
- package/src/hooks/gsapDragCommit.ts +129 -9
- package/src/hooks/gsapRuntimeBridge.ts +22 -0
- package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +63 -5
- package/src/hooks/gsapRuntimePatch.ts +162 -35
- package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
- package/src/hooks/useDomEditCommits.ts +0 -2
- package/src/hooks/useDomEditSession.ts +24 -2
- package/src/hooks/useDomEditWiring.ts +3 -0
- package/src/hooks/useDomGeometryCommits.ts +3 -31
- package/src/hooks/useGestureCommit.ts +0 -4
- package/src/hooks/useGsapAwareEditing.ts +31 -5
- package/src/hooks/useGsapKeyframeOps.ts +4 -1
- package/src/hooks/useGsapScriptCommits.ts +0 -12
- package/src/hooks/useGsapSelectionHandlers.ts +12 -9
- package/src/hooks/useGsapTweenCache.test.ts +45 -1
- package/src/hooks/useGsapTweenCache.ts +125 -42
- package/src/hooks/useMusicBeatAnalysis.ts +36 -21
- package/src/hooks/useRazorSplit.ts +0 -3
- package/src/utils/marqueeGeometry.test.ts +15 -98
- package/src/utils/marqueeGeometry.ts +1 -158
- package/dist/assets/index-wJZf6FK3.css +0 -1
- package/src/utils/editDebugLog.ts +0 -9
|
@@ -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("
|
|
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
|
|
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].
|
|
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("
|
|
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].
|
|
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
|
-
|
|
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
|
|
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
|
|
264
|
-
|
|
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 = {
|