@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
@@ -1,8 +1,10 @@
1
+ import { useState } from "react";
1
2
  import type { DomEditSelection } from "./domEditingTypes";
2
3
  import { STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
3
4
  import { MetricField } from "./propertyPanelPrimitives";
4
5
  import { KeyframeNavigation } from "./KeyframeNavigation";
5
6
  import { formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
7
+ import { Transform3DCube, type CubePose } from "./Transform3DCube";
6
8
 
7
9
  type KeyframeEntry = Array<{
8
10
  percentage: number;
@@ -24,9 +26,216 @@ interface PropertyPanel3dTransformProps {
24
26
  property: string,
25
27
  value: number,
26
28
  ) => Promise<void>;
29
+ /** Batched commit — several props into one keyframe (the cube's rotationX/Y/Z). */
30
+ onCommitAnimatedProperties?: (
31
+ element: DomEditSelection,
32
+ props: Record<string, number | string>,
33
+ ) => Promise<void>;
27
34
  onSeekToTime?: (time: number) => void;
28
35
  onRemoveKeyframe?: (animId: string, pct: number) => void;
29
- onConvertToKeyframes?: (animId: string) => void;
36
+ onConvertToKeyframes?: (animId: string, duration?: number) => void;
37
+ /** Live-set props on the preview element during a cube drag (no source write). */
38
+ onLivePreviewProps?: (element: DomEditSelection, props: Record<string, number>) => void;
39
+ }
40
+
41
+ type CommitAnimatedProperty = (
42
+ element: DomEditSelection,
43
+ property: string,
44
+ value: number,
45
+ ) => Promise<void>;
46
+
47
+ /** The draggable cube + its commit/recenter/live-preview wiring. */
48
+ function Cube3dControl({
49
+ element,
50
+ gsapRuntimeValues,
51
+ onCommitAnimatedProperty,
52
+ onCommitAnimatedProperties,
53
+ onLivePreviewProps,
54
+ onKeyframe,
55
+ keyframed,
56
+ }: {
57
+ element: DomEditSelection;
58
+ gsapRuntimeValues: Record<string, number>;
59
+ onCommitAnimatedProperty: CommitAnimatedProperty;
60
+ onCommitAnimatedProperties?: (
61
+ element: DomEditSelection,
62
+ props: Record<string, number | string>,
63
+ ) => Promise<void>;
64
+ onLivePreviewProps?: (element: DomEditSelection, props: Record<string, number>) => void;
65
+ onKeyframe?: () => void;
66
+ keyframed?: boolean;
67
+ }) {
68
+ const pose: CubePose = {
69
+ rotationX: gsapRuntimeValues.rotationX ?? 0,
70
+ rotationY: gsapRuntimeValues.rotationY ?? 0,
71
+ rotationZ: gsapRuntimeValues.rotationZ ?? 0,
72
+ };
73
+ // Commit only the rotation axes the drag actually changed (each rounded to a
74
+ // whole degree). Reuses the keyframe-aware animated-property commit, so a drag
75
+ // at the playhead writes/updates a keyframe just like the numeric fields.
76
+ const commitPose = (next: CubePose) => {
77
+ const changedProps: Record<string, number> = {};
78
+ for (const axis of ["rotationX", "rotationY", "rotationZ"] as const) {
79
+ const rounded = Math.round(next[axis]);
80
+ if (rounded !== Math.round(pose[axis])) changedProps[axis] = rounded;
81
+ }
82
+ const axes = Object.keys(changedProps);
83
+ if (axes.length === 0) return;
84
+ // ONE keyframe for the whole pose change — avoids per-axis commits racing into
85
+ // adjacent duplicate keyframes. Fall back to per-axis if no batched commit.
86
+ if (onCommitAnimatedProperties) {
87
+ void onCommitAnimatedProperties(element, changedProps);
88
+ } else {
89
+ for (const [axis, v] of Object.entries(changedProps))
90
+ onCommitAnimatedProperty(element, axis, v);
91
+ }
92
+ };
93
+ const recenter = () => {
94
+ // ONE commit for the whole reset — six per-axis commits meant six soft-reloads
95
+ // (six flashes) for a single click. Batch like commitPose does.
96
+ const identity = {
97
+ rotationX: 0,
98
+ rotationY: 0,
99
+ rotationZ: 0,
100
+ z: 0,
101
+ scale: 1,
102
+ transformPerspective: 0,
103
+ };
104
+ if (onCommitAnimatedProperties) {
105
+ void onCommitAnimatedProperties(element, identity);
106
+ } else {
107
+ for (const [prop, v] of Object.entries(identity))
108
+ void onCommitAnimatedProperty(element, prop, v);
109
+ }
110
+ };
111
+ // Immediate element feedback while dragging — set the live transform without a
112
+ // source write; the release commits via onCommitAnimatedProperty.
113
+ const livePreview = (next: CubePose) =>
114
+ onLivePreviewProps?.(element, {
115
+ rotationX: next.rotationX,
116
+ rotationY: next.rotationY,
117
+ rotationZ: next.rotationZ,
118
+ });
119
+
120
+ return (
121
+ <div className="mb-2 px-2">
122
+ <div className="mx-auto max-w-[184px]">
123
+ <Transform3DCube
124
+ pose={pose}
125
+ perspective={gsapRuntimeValues.transformPerspective ?? 0}
126
+ onPoseDraft={livePreview}
127
+ onPoseCommit={commitPose}
128
+ onPerspectiveDraft={(px) => onLivePreviewProps?.(element, { transformPerspective: px })}
129
+ onPerspectiveCommit={(px) =>
130
+ void onCommitAnimatedProperty(element, "transformPerspective", px)
131
+ }
132
+ onRecenter={recenter}
133
+ onKeyframe={onKeyframe}
134
+ keyframed={keyframed}
135
+ />
136
+ <p className="mt-1 text-center text-[9px] leading-snug text-neutral-600">
137
+ Drag to tilt · Shift-drag to roll
138
+ </p>
139
+ </div>
140
+ </div>
141
+ );
142
+ }
143
+
144
+ interface FieldCtx {
145
+ element: DomEditSelection;
146
+ gsapRuntimeValues: Record<string, number>;
147
+ gsapKeyframes: KeyframeEntry;
148
+ gsapAnimId: string | null;
149
+ currentPct: number;
150
+ elStart: number;
151
+ elDuration: number;
152
+ resolveAnimIdForProp?: (prop: string) => string | null;
153
+ onCommitAnimatedProperty?: (
154
+ element: DomEditSelection,
155
+ property: string,
156
+ value: number,
157
+ ) => Promise<void>;
158
+ onSeekToTime?: (time: number) => void;
159
+ onRemoveKeyframe?: (animId: string, pct: number) => void;
160
+ onConvertToKeyframes?: (animId: string, duration?: number) => void;
161
+ }
162
+
163
+ const parseDeg = (s: string): number | null => {
164
+ const n = Number.parseFloat(s.replace("°", ""));
165
+ return Number.isFinite(n) ? n : null;
166
+ };
167
+ const parseScale = (s: string): number | null => {
168
+ const n = Number.parseFloat(s);
169
+ return Number.isFinite(n) ? n : null;
170
+ };
171
+ const parsePxNonNeg = (s: string): number | null => {
172
+ const v = parsePxMetricValue(s);
173
+ return v != null && v >= 0 ? v : null;
174
+ };
175
+
176
+ /**
177
+ * One 3D-transform field: a number/scrub input plus its keyframe diamond, so
178
+ * rotation / perspective / Z / scale can each be keyframed just like Layout's
179
+ * X / Y — the diamond was previously missing on the rotation + perspective rows.
180
+ */
181
+ function Transform3dField({
182
+ label,
183
+ prop,
184
+ scrub,
185
+ format,
186
+ parse,
187
+ defaultValue,
188
+ ctx,
189
+ }: {
190
+ label: string;
191
+ prop: string;
192
+ scrub?: boolean;
193
+ format: (v: number) => string;
194
+ parse: (s: string) => number | null;
195
+ defaultValue: number;
196
+ ctx: FieldCtx;
197
+ }) {
198
+ const { gsapAnimId, onCommitAnimatedProperty } = ctx;
199
+ const idFor = (p: string) => ctx.resolveAnimIdForProp?.(p) ?? gsapAnimId;
200
+ const current = ctx.gsapRuntimeValues[prop] ?? defaultValue;
201
+ return (
202
+ <div className="flex items-center gap-1">
203
+ <div className="flex-1">
204
+ <MetricField
205
+ label={label}
206
+ value={format(current)}
207
+ scrub={scrub}
208
+ onCommit={(next) => {
209
+ const v = parse(next);
210
+ if (v != null && onCommitAnimatedProperty) {
211
+ void onCommitAnimatedProperty(ctx.element, prop, v);
212
+ }
213
+ }}
214
+ />
215
+ </div>
216
+ {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
217
+ <KeyframeNavigation
218
+ property={prop}
219
+ keyframes={ctx.gsapKeyframes}
220
+ currentPercentage={ctx.currentPct}
221
+ onSeek={(pct) => ctx.onSeekToTime?.(ctx.elStart + (pct / 100) * ctx.elDuration)}
222
+ onAddKeyframe={() => {
223
+ if (onCommitAnimatedProperty) void onCommitAnimatedProperty(ctx.element, prop, current);
224
+ }}
225
+ onRemoveKeyframe={(pct) => {
226
+ const id = idFor(prop);
227
+ if (id) ctx.onRemoveKeyframe?.(id, pct);
228
+ }}
229
+ onConvertToKeyframes={() => {
230
+ const id = idFor(prop);
231
+ // Pass the element's clip duration so a converted static 3D `set`
232
+ // spans the whole clip (keyframes land in range at any playhead).
233
+ if (id) ctx.onConvertToKeyframes?.(id, ctx.elDuration);
234
+ }}
235
+ />
236
+ )}
237
+ </div>
238
+ );
30
239
  }
31
240
 
32
241
  export function PropertyPanel3dTransform({
@@ -39,110 +248,120 @@ export function PropertyPanel3dTransform({
39
248
  elDuration,
40
249
  element,
41
250
  onCommitAnimatedProperty,
251
+ onCommitAnimatedProperties,
42
252
  onSeekToTime,
43
253
  onRemoveKeyframe,
44
254
  onConvertToKeyframes,
255
+ onLivePreviewProps,
45
256
  }: PropertyPanel3dTransformProps) {
46
- const idFor = (prop: string) => resolveAnimIdForProp?.(prop) ?? gsapAnimId;
257
+ // Expanded by default the cube gizmo is the headline of this panel, so show
258
+ // it up front rather than hiding it behind a collapsed header.
259
+ const [collapsed, setCollapsed] = useState(false);
260
+ const ctx: FieldCtx = {
261
+ element,
262
+ gsapRuntimeValues,
263
+ gsapKeyframes,
264
+ gsapAnimId,
265
+ currentPct,
266
+ elStart,
267
+ elDuration,
268
+ resolveAnimIdForProp,
269
+ onCommitAnimatedProperty,
270
+ onSeekToTime,
271
+ onRemoveKeyframe,
272
+ onConvertToKeyframes,
273
+ };
274
+
47
275
  return (
48
276
  <div className="mt-3 border-t border-neutral-800/40 pt-3">
49
- <div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
50
- 3D Transform
51
- </div>
52
- <div className={RESPONSIVE_GRID}>
53
- <div className="flex items-center gap-1">
54
- <div className="flex-1">
55
- <MetricField
56
- label="Z"
57
- value={formatPxMetricValue(gsapRuntimeValues.z ?? 0)}
58
- scrub
59
- onCommit={(next) => {
60
- const v = parsePxMetricValue(next);
61
- if (v != null && onCommitAnimatedProperty) {
62
- void onCommitAnimatedProperty(element, "z", v);
63
- }
64
- }}
65
- />
66
- </div>
67
- {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
68
- <KeyframeNavigation
69
- property="z"
70
- keyframes={gsapKeyframes}
71
- currentPercentage={currentPct}
72
- onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
73
- onAddKeyframe={() => {
74
- if (onCommitAnimatedProperty) {
75
- void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0);
76
- }
77
- }}
78
- onRemoveKeyframe={(pct) => {
79
- const id = idFor("z");
80
- if (id) onRemoveKeyframe?.(id, pct);
81
- }}
82
- onConvertToKeyframes={() => {
83
- const id = idFor("z");
84
- if (id) onConvertToKeyframes?.(id);
277
+ <button
278
+ type="button"
279
+ onClick={() => setCollapsed((v) => !v)}
280
+ className="mb-2 flex w-full items-center justify-between text-[10px] font-medium uppercase tracking-wider text-neutral-600 hover:text-neutral-400"
281
+ >
282
+ <span>3D Transform</span>
283
+ <svg width="9" height="9" viewBox="0 0 10 10" fill="currentColor" aria-hidden>
284
+ {collapsed ? <path d="M3 2l4 3-4 3z" /> : <path d="M2 3l3 4 3-4z" />}
285
+ </svg>
286
+ </button>
287
+ {collapsed ? null : (
288
+ <>
289
+ {onCommitAnimatedProperty && (
290
+ <Cube3dControl
291
+ element={element}
292
+ gsapRuntimeValues={gsapRuntimeValues}
293
+ onCommitAnimatedProperty={onCommitAnimatedProperty}
294
+ onCommitAnimatedProperties={onCommitAnimatedProperties}
295
+ onLivePreviewProps={onLivePreviewProps}
296
+ keyframed={(gsapKeyframes ?? []).some(
297
+ (kf) =>
298
+ "rotationX" in kf.properties ||
299
+ "rotationY" in kf.properties ||
300
+ "rotationZ" in kf.properties,
301
+ )}
302
+ onKeyframe={() => {
303
+ // Convert the 3D ("other"-group) static set to keyframes so the
304
+ // cube can animate; spans the element's clip via elDuration.
305
+ const id = resolveAnimIdForProp?.("rotationX") ?? gsapAnimId;
306
+ if (id) onConvertToKeyframes?.(id, elDuration);
85
307
  }}
86
308
  />
87
309
  )}
88
- </div>
89
- <div className="flex items-center gap-1">
90
- <div className="flex-1">
91
- <MetricField
310
+ <div className={RESPONSIVE_GRID}>
311
+ <Transform3dField
312
+ ctx={ctx}
313
+ label="Z"
314
+ prop="z"
315
+ scrub
316
+ format={formatPxMetricValue}
317
+ parse={parsePxMetricValue}
318
+ defaultValue={0}
319
+ />
320
+ <Transform3dField
321
+ ctx={ctx}
92
322
  label="Scale"
93
- value={String(gsapRuntimeValues.scale ?? 1)}
323
+ prop="scale"
94
324
  scrub
95
- onCommit={(next) => {
96
- const v = Number.parseFloat(next);
97
- if (Number.isFinite(v) && onCommitAnimatedProperty) {
98
- void onCommitAnimatedProperty(element, "scale", v);
99
- }
100
- }}
325
+ format={(v) => String(v)}
326
+ parse={parseScale}
327
+ defaultValue={1}
101
328
  />
102
- </div>
103
- {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
104
- <KeyframeNavigation
105
- property="scale"
106
- keyframes={gsapKeyframes}
107
- currentPercentage={currentPct}
108
- onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
109
- onAddKeyframe={() => {
110
- if (onCommitAnimatedProperty) {
111
- void onCommitAnimatedProperty(element, "scale", gsapRuntimeValues?.scale ?? 1);
112
- }
113
- }}
114
- onRemoveKeyframe={(pct) => {
115
- const id = idFor("scale");
116
- if (id) onRemoveKeyframe?.(id, pct);
117
- }}
118
- onConvertToKeyframes={() => {
119
- const id = idFor("scale");
120
- if (id) onConvertToKeyframes?.(id);
121
- }}
329
+ <Transform3dField
330
+ ctx={ctx}
331
+ label="RotX"
332
+ prop="rotationX"
333
+ format={(v) => `${v}°`}
334
+ parse={parseDeg}
335
+ defaultValue={0}
122
336
  />
123
- )}
124
- </div>
125
- <MetricField
126
- label="RotX"
127
- value={`${gsapRuntimeValues.rotationX ?? 0}°`}
128
- onCommit={(next) => {
129
- const v = Number.parseFloat(next.replace("°", ""));
130
- if (Number.isFinite(v) && onCommitAnimatedProperty) {
131
- void onCommitAnimatedProperty(element, "rotationX", v);
132
- }
133
- }}
134
- />
135
- <MetricField
136
- label="RotY"
137
- value={`${gsapRuntimeValues.rotationY ?? 0}°`}
138
- onCommit={(next) => {
139
- const v = Number.parseFloat(next.replace("°", ""));
140
- if (Number.isFinite(v) && onCommitAnimatedProperty) {
141
- void onCommitAnimatedProperty(element, "rotationY", v);
142
- }
143
- }}
144
- />
145
- </div>
337
+ <Transform3dField
338
+ ctx={ctx}
339
+ label="RotY"
340
+ prop="rotationY"
341
+ format={(v) => `${v}°`}
342
+ parse={parseDeg}
343
+ defaultValue={0}
344
+ />
345
+ <Transform3dField
346
+ ctx={ctx}
347
+ label="RotZ"
348
+ prop="rotationZ"
349
+ format={(v) => `${v}°`}
350
+ parse={parseDeg}
351
+ defaultValue={0}
352
+ />
353
+ <Transform3dField
354
+ ctx={ctx}
355
+ label="Perspective"
356
+ prop="transformPerspective"
357
+ scrub
358
+ format={formatPxMetricValue}
359
+ parse={parsePxNonNeg}
360
+ defaultValue={0}
361
+ />
362
+ </div>
363
+ </>
364
+ )}
146
365
  </div>
147
366
  );
148
367
  }
@@ -67,12 +67,19 @@ export interface PropertyPanelProps {
67
67
  ) => void;
68
68
  onRemoveKeyframe?: (animationId: string, percentage: number) => void;
69
69
  onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
70
- onConvertToKeyframes?: (animationId: string) => void;
70
+ onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
71
+ onConvertToKeyframes?: (animationId: string, duration?: number) => void;
71
72
  onCommitAnimatedProperty?: (
72
73
  selection: DomEditSelection,
73
74
  property: string,
74
75
  value: number | string,
75
76
  ) => Promise<void>;
77
+ /** Batched variant: commit several props into ONE keyframe (e.g. the 3D cube's
78
+ * rotationX/Y/Z) so multi-axis edits don't race into adjacent duplicates. */
79
+ onCommitAnimatedProperties?: (
80
+ selection: DomEditSelection,
81
+ props: Record<string, number | string>,
82
+ ) => Promise<void>;
76
83
  onSeekToTime?: (time: number) => void;
77
84
  recordingState?: "idle" | "recording" | "preview";
78
85
  recordingDuration?: number;
@@ -211,8 +218,8 @@ export const LABEL = "text-[11px] font-medium text-panel-text-3";
211
218
  export const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3";
212
219
  export const EMPTY_STYLES: Record<string, string> = {};
213
220
 
214
- export const EMPTY_FILTER_VALUE = "none";
215
- export const BOX_SHADOW_PRESETS = {
221
+ const EMPTY_FILTER_VALUE = "none";
222
+ const BOX_SHADOW_PRESETS = {
216
223
  none: "none",
217
224
  soft: "0 12px 36px rgba(0, 0, 0, 0.28)",
218
225
  lift: "0 18px 54px rgba(0, 0, 0, 0.38)",
@@ -272,12 +279,7 @@ export function parsePxMetricValue(value: string): number | null {
272
279
  return token.value;
273
280
  }
274
281
 
275
- export function clampPanelNumber(
276
- value: number,
277
- min: number,
278
- max: number,
279
- fallback: number,
280
- ): number {
282
+ function clampPanelNumber(value: number, min: number, max: number, fallback: number): number {
281
283
  if (!Number.isFinite(value)) return fallback;
282
284
  return Math.max(min, Math.min(max, value));
283
285
  }
@@ -320,6 +322,7 @@ export function normalizeTextMetricValue(
320
322
  function splitCssFunctions(value: string): string[] {
321
323
  const functions: string[] = [];
322
324
  let current = "";
325
+ // fallow-ignore-next-line code-duplication -- pre-existing; surfaced in this file's diff by an unrelated line shift
323
326
  let depth = 0;
324
327
 
325
328
  for (const char of value.trim()) {
@@ -485,6 +488,39 @@ export function extractBackgroundImageUrl(value: string | undefined): string {
485
488
 
486
489
  // ── GSAP runtime value readers (used by PropertyPanel) ────────────────────
487
490
 
491
+ // fallow-ignore-next-line complexity -- pre-existing; surfaced in this file's diff by an unrelated line shift
492
+ // Core transform channels the panel ALWAYS reads live — even before a just-set
493
+ // value (e.g. rotationX) has re-parsed into `gsapAnimations`. Without this the
494
+ // cube + fields drop the prop and flicker to 0 on every commit; gsap.getProperty
495
+ // reflects the in-place instant patch, so it's the true current value.
496
+ const ALWAYS_READ_CHANNELS = [
497
+ "x",
498
+ "y",
499
+ "rotation",
500
+ "rotationX",
501
+ "rotationY",
502
+ "rotationZ",
503
+ "z",
504
+ "scale",
505
+ "transformPerspective",
506
+ "opacity",
507
+ ];
508
+
509
+ /** Every property key the panel should read for an element: animated props + the
510
+ * always-read transform channels. */
511
+ function collectPanelPropKeys(gsapAnimations: GsapAnimation[]): Set<string> {
512
+ const keys = new Set<string>(ALWAYS_READ_CHANNELS);
513
+ for (const anim of gsapAnimations) {
514
+ if (anim.keyframes) {
515
+ for (const kf of anim.keyframes.keyframes) {
516
+ for (const p of Object.keys(kf.properties)) keys.add(p);
517
+ }
518
+ }
519
+ for (const p of Object.keys(anim.properties)) keys.add(p);
520
+ }
521
+ return keys;
522
+ }
523
+
488
524
  export function readGsapRuntimeValuesForPanel(
489
525
  gsapAnimId: string | null,
490
526
  gsapAnimations: GsapAnimation[],
@@ -505,15 +541,7 @@ export function readGsapRuntimeValuesForPanel(
505
541
  if (!gsap?.getProperty) return null;
506
542
  const el = iframe.contentDocument?.querySelector(selector);
507
543
  if (!el) return null;
508
- const propKeys = new Set<string>();
509
- for (const anim of gsapAnimations) {
510
- if (anim.keyframes) {
511
- for (const kf of anim.keyframes.keyframes) {
512
- for (const p of Object.keys(kf.properties)) propKeys.add(p);
513
- }
514
- }
515
- for (const p of Object.keys(anim.properties)) propKeys.add(p);
516
- }
544
+ const propKeys = collectPanelPropKeys(gsapAnimations);
517
545
  const result: Record<string, number> = {};
518
546
  for (const prop of propKeys) {
519
547
  const v = Number(gsap.getProperty(el, prop));
@@ -1,3 +1,4 @@
1
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
1
2
  import { Clock } from "../../icons/SystemIcons";
2
3
  import type { DomEditSelection } from "./domEditing";
3
4
  import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
@@ -9,18 +10,45 @@ function parseTimingValue(input: string): number | null {
9
10
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
10
11
  }
11
12
 
13
+ /**
14
+ * Derive a time range from the element's GSAP tweens (earliest start → latest
15
+ * end) so an element animated purely by GSAP — with no `data-start` /
16
+ * `data-duration` — still shows a meaningful Timing range instead of 0s.
17
+ */
18
+ function deriveTimingFromAnimations(
19
+ animations: GsapAnimation[],
20
+ ): { start: number; duration: number } | null {
21
+ let lo = Infinity;
22
+ let hi = -Infinity;
23
+ for (const a of animations) {
24
+ const s = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
25
+ const d = a.duration ?? 0;
26
+ lo = Math.min(lo, s);
27
+ hi = Math.max(hi, s + d);
28
+ }
29
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return null;
30
+ return { start: lo, duration: hi - lo };
31
+ }
32
+
12
33
  export function TimingSection({
13
34
  element,
35
+ animations = [],
14
36
  onSetAttribute,
15
37
  }: {
16
38
  element: DomEditSelection;
39
+ animations?: GsapAnimation[];
17
40
  onSetAttribute: (attr: string, value: string) => void | Promise<void>;
18
41
  }) {
19
- const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
20
- const duration =
42
+ const explicitStart = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
43
+ const explicitDuration =
21
44
  Number.parseFloat(
22
45
  element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0",
23
46
  ) || 0;
47
+
48
+ // No authored clip timing → infer the range from the element's animations.
49
+ const derived = explicitDuration > 0 ? null : deriveTimingFromAnimations(animations);
50
+ const start = derived ? derived.start : explicitStart;
51
+ const duration = derived ? derived.duration : explicitDuration;
24
52
  const end = start + duration;
25
53
 
26
54
  const commitStart = (nextValue: string) => {
@@ -54,6 +82,11 @@ export function TimingSection({
54
82
  onCommit={commitDuration}
55
83
  />
56
84
  </div>
85
+ {derived && (
86
+ <p className="mt-2 text-[10px] leading-snug text-neutral-500">
87
+ Inferred from this element’s animation — edit to pin an explicit clip range.
88
+ </p>
89
+ )}
57
90
  </Section>
58
91
  );
59
92
  }