@hyperframes/studio 0.7.5 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/assets/{index-BwFzbjZQ.js → index-B2YXvFxf.js} +1 -1
  2. package/dist/assets/index-BSkUuN8g.css +1 -0
  3. package/dist/assets/index-BeRh2hMe.js +375 -0
  4. package/dist/assets/{index-C5NAfiPa.js → index-BoASKOeE.js} +1 -1
  5. package/dist/chunk-KZXYQYIU.js +876 -0
  6. package/dist/chunk-KZXYQYIU.js.map +1 -0
  7. package/dist/domEditingLayers-SSXQZHHQ.js +41 -0
  8. package/dist/domEditingLayers-SSXQZHHQ.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +3326 -2939
  12. package/dist/index.js.map +1 -1
  13. package/package.json +5 -5
  14. package/src/captions/hooks/useCaptionSync.ts +6 -1
  15. package/src/components/StudioPreviewArea.tsx +10 -3
  16. package/src/components/StudioRightPanel.tsx +4 -0
  17. package/src/components/editor/AnimationCard.tsx +77 -250
  18. package/src/components/editor/AnimationCardParts.tsx +220 -0
  19. package/src/components/editor/BlockParamsPanel.tsx +4 -8
  20. package/src/components/editor/DomEditOverlay.tsx +132 -48
  21. package/src/components/editor/EaseCurveSection.tsx +177 -101
  22. package/src/components/editor/GsapAnimationSection.tsx +4 -0
  23. package/src/components/editor/KeyframeEaseList.tsx +127 -0
  24. package/src/components/editor/OffCanvasIndicators.tsx +111 -0
  25. package/src/components/editor/PropertyPanel.tsx +63 -56
  26. package/src/components/editor/gsapAnimationCallbacks.ts +3 -0
  27. package/src/components/editor/gsapAnimationConstants.ts +11 -32
  28. package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
  29. package/src/components/editor/manualOffsetDrag.ts +8 -0
  30. package/src/components/editor/marqueeCommit.ts +168 -0
  31. package/src/components/editor/propertyPanelHelpers.ts +7 -0
  32. package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
  33. package/src/components/editor/snapTargetCollection.ts +0 -5
  34. package/src/components/editor/useMotionPathData.ts +2 -1
  35. package/src/components/panels/SlideshowPanel.tsx +0 -1
  36. package/src/components/sidebar/AssetContextMenu.tsx +97 -0
  37. package/src/components/sidebar/AssetsTab.tsx +542 -254
  38. package/src/components/sidebar/assetHelpers.ts +40 -0
  39. package/src/contexts/DomEditContext.tsx +12 -0
  40. package/src/hooks/gsapDragCommit.ts +101 -0
  41. package/src/hooks/gsapDragPositionCommit.ts +1 -2
  42. package/src/hooks/gsapRuntimeBridge.ts +28 -2
  43. package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
  44. package/src/hooks/gsapRuntimeKeyframes.ts +13 -0
  45. package/src/hooks/gsapRuntimeReaders.ts +1 -6
  46. package/src/hooks/useDomEditCommits.ts +0 -4
  47. package/src/hooks/useDomEditPreviewSync.ts +5 -0
  48. package/src/hooks/useDomEditSession.ts +43 -0
  49. package/src/hooks/useDomEditTextCommits.ts +3 -12
  50. package/src/hooks/useDomEditWiring.ts +3 -0
  51. package/src/hooks/useDomSelection.ts +46 -0
  52. package/src/hooks/useGestureCommit.ts +26 -7
  53. package/src/hooks/useGestureRecording.ts +2 -16
  54. package/src/hooks/useGsapAnimationOps.ts +2 -2
  55. package/src/hooks/useGsapAwareEditing.ts +0 -4
  56. package/src/hooks/useGsapScriptCommits.ts +0 -12
  57. package/src/hooks/useGsapTweenCache.test.ts +45 -1
  58. package/src/hooks/useGsapTweenCache.ts +151 -42
  59. package/src/hooks/useMusicBeatAnalysis.ts +36 -21
  60. package/src/hooks/useRazorSplit.ts +0 -3
  61. package/src/player/components/Player.tsx +0 -5
  62. package/src/player/components/timelineIcons.tsx +2 -1
  63. package/src/player/hooks/useTimelinePlayer.ts +4 -14
  64. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -9
  65. package/src/player/lib/timelineIframeHelpers.ts +2 -6
  66. package/src/telemetry/client.ts +2 -0
  67. package/src/utils/gestureSmoother.test.ts +48 -0
  68. package/src/utils/gestureSmoother.ts +46 -0
  69. package/src/utils/marqueeGeometry.test.ts +123 -0
  70. package/src/utils/marqueeGeometry.ts +172 -0
  71. package/src/utils/optimisticUpdate.ts +1 -2
  72. package/src/utils/sourcePatcher.ts +0 -10
  73. package/src/utils/velocityEaseFitter.test.ts +58 -0
  74. package/src/utils/velocityEaseFitter.ts +121 -0
  75. package/dist/assets/index-D_JGXmfx.js +0 -374
  76. package/dist/assets/index-DzWIinxk.css +0 -1
  77. package/src/utils/editDebugLog.ts +0 -16
@@ -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"];
@@ -61,6 +61,9 @@ export interface DomEditActionsValue extends Pick<
61
61
  | "invalidateGsapCache"
62
62
  | "previewIframeRef"
63
63
  | "commitMutation"
64
+ | "applyMarqueeSelection"
65
+ | "handleUpdateKeyframeEase"
66
+ | "handleSetAllKeyframeEases"
64
67
  > {}
65
68
 
66
69
  export interface DomEditSelectionValue extends Pick<
@@ -167,6 +170,9 @@ export function DomEditProvider({
167
170
  invalidateGsapCache,
168
171
  previewIframeRef,
169
172
  commitMutation,
173
+ applyMarqueeSelection,
174
+ handleUpdateKeyframeEase,
175
+ handleSetAllKeyframeEases,
170
176
  },
171
177
  children,
172
178
  }: {
@@ -238,6 +244,9 @@ export function DomEditProvider({
238
244
  invalidateGsapCache,
239
245
  previewIframeRef,
240
246
  commitMutation: stableCommitMutation,
247
+ applyMarqueeSelection,
248
+ handleUpdateKeyframeEase,
249
+ handleSetAllKeyframeEases,
241
250
  }),
242
251
  [
243
252
  handleTimelineElementSelect,
@@ -295,6 +304,9 @@ export function DomEditProvider({
295
304
  invalidateGsapCache,
296
305
  previewIframeRef,
297
306
  stableCommitMutation,
307
+ applyMarqueeSelection,
308
+ handleUpdateKeyframeEase,
309
+ handleSetAllKeyframeEases,
298
310
  ],
299
311
  );
300
312
 
@@ -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";
@@ -342,6 +346,103 @@ export async function commitStaticGsapSize(
342
346
  );
343
347
  }
344
348
 
349
+ /** Rounded `n` when it's a positive finite number, else `fallback`. */
350
+ function positiveOr(n: number, fallback: number): number {
351
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : fallback;
352
+ }
353
+
354
+ /**
355
+ * Prior size for a keyframed resize: the existing global set's value, else the
356
+ * element's pre-resize size (the draft saved it on the element before mutating
357
+ * el.style.width/height). Falls back to the new size when neither is available.
358
+ */
359
+ function resolvePriorSize(
360
+ sizeSet: GsapAnimation | null,
361
+ el: Element | null | undefined,
362
+ fallbackW: number,
363
+ fallbackH: number,
364
+ ): { width: number; height: number } {
365
+ if (sizeSet) {
366
+ return {
367
+ width: positiveOr(Number(sizeSet.properties.width), fallbackW),
368
+ height: positiveOr(Number(sizeSet.properties.height), fallbackH),
369
+ };
370
+ }
371
+ const ow = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_WIDTH_ATTR) ?? "");
372
+ const oh = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR) ?? "");
373
+ return { width: positiveOr(ow, fallbackW), height: positiveOr(oh, fallbackH) };
374
+ }
375
+
376
+ /**
377
+ * Resize an *animated* element by keyframing its size at the current playhead,
378
+ * instead of a global `gsap.set` hold. Builds a width/height keyframe tween
379
+ * aligned to the element's existing animation: every base keyframe keeps the
380
+ * prior size, only the keyframe nearest the playhead gets the new size — so
381
+ * resizing one keyframe leaves the others unchanged. Replaces any prior global
382
+ * size set. Returns false when there's no usable range (caller falls back to the
383
+ * static set).
384
+ */
385
+ export async function commitKeyframedSizeFromResize(
386
+ selection: DomEditSelection,
387
+ size: { width: number; height: number },
388
+ selector: string,
389
+ sizeSet: GsapAnimation | null,
390
+ animatedTween: GsapAnimation,
391
+ callbacks: GsapDragCommitCallbacks,
392
+ ): Promise<boolean> {
393
+ const ts = resolveTweenStart(animatedTween) ?? 0;
394
+ const td = resolveTweenDuration(animatedTween);
395
+ if (!(td > 0)) return false;
396
+
397
+ const newW = Math.round(size.width);
398
+ const newH = Math.round(size.height);
399
+ const prior = resolvePriorSize(sizeSet, selection.element, newW, newH);
400
+
401
+ const ct = usePlayerStore.getState().currentTime;
402
+ const pct = Math.max(0, Math.min(100, Math.round(((ct - ts) / td) * 1000) / 10));
403
+
404
+ // Base keyframe percentages from the animated tween (flat tween → 0 & 100),
405
+ // plus the endpoints and the playhead. Each keeps the prior size except the
406
+ // keyframe at the playhead, which gets the new size.
407
+ const pcts = new Set<number>(
408
+ animatedTween.keyframes?.keyframes.map((k) => k.percentage) ?? [0, 100],
409
+ );
410
+ pcts.add(0);
411
+ pcts.add(100);
412
+ pcts.add(pct);
413
+ const keyframes = Array.from(pcts)
414
+ .sort((a, b) => a - b)
415
+ .map((p) => ({
416
+ percentage: p,
417
+ properties: Math.abs(p - pct) < 0.05 ? { width: newW, height: newH } : { ...prior },
418
+ }));
419
+
420
+ // Add the size keyframe tween FIRST, then delete the old global hold. The two
421
+ // commits aren't transactional, so ordering matters: if the delete fails the
422
+ // size is preserved (animated, recoverable) rather than lost. Only the last
423
+ // commit triggers the reload.
424
+ const addLabel = `Resize (size keyframe ${pct.toFixed(0)}%)`;
425
+ await callbacks.commitMutation(
426
+ selection,
427
+ {
428
+ type: "add-with-keyframes",
429
+ targetSelector: selector,
430
+ position: roundTo3(ts),
431
+ duration: roundTo3(td),
432
+ keyframes,
433
+ },
434
+ sizeSet ? { label: addLabel, skipReload: true } : { label: addLabel, softReload: true },
435
+ );
436
+ if (sizeSet) {
437
+ await callbacks.commitMutation(
438
+ selection,
439
+ { type: "delete", animationId: sizeSet.id },
440
+ { label: "Resize layer", softReload: true },
441
+ );
442
+ }
443
+ return true;
444
+ }
445
+
345
446
  export { findSizeSetAnimation };
346
447
 
347
448
  // ── Whole-path offset (plain drag on animated element) ──────────────────
@@ -165,8 +165,7 @@ async function commitFlatViaKeyframes(
165
165
  if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v);
166
166
  }
167
167
  mainTl.seek(ct);
168
- } catch (err) {
169
- console.warn("[gsap-drag] start-value read failed; using identity from values", err);
168
+ } catch {
170
169
  for (const key of Object.keys(resolvedFromValues)) delete resolvedFromValues[key];
171
170
  } finally {
172
171
  if (Object.keys(draggedValues).length > 0) gsapLib.set(el, draggedValues);
@@ -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,
@@ -239,7 +240,9 @@ export async function tryGsapDragIntercept(
239
240
  // `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in
240
241
  // place (idempotent), else add a new one. This also covers the stale-cache
241
242
  // phantom — committing a set is correct because the element genuinely has no live motion.
242
- if (!hasNonHoldTweenForElement(iframe, selector)) {
243
+ const hasNonHold = hasNonHoldTweenForElement(iframe, selector);
244
+
245
+ if (!hasNonHold) {
243
246
  const existingSet =
244
247
  posAnim && posAnim.method === "set" && posAnim.targetSelector === selector
245
248
  ? posAnim
@@ -251,7 +254,9 @@ export async function tryGsapDragIntercept(
251
254
  return true;
252
255
  }
253
256
 
254
- if (!posAnim) return false;
257
+ if (!posAnim) {
258
+ return false;
259
+ }
255
260
 
256
261
  // Verify the anim ID is still valid in the current file. The React-state
257
262
  // `animations` list can lag behind the file after a prior mutation changed
@@ -325,6 +330,27 @@ export async function tryGsapResizeIntercept(
325
330
  const sel = selectorFromSelection(selection);
326
331
  if (!sel) return false;
327
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
+
328
354
  await commitStaticGsapSize(selection, size, sel, sizeSet, {
329
355
  commitMutation,
330
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 = {
@@ -252,14 +252,26 @@ export function resolveRuntimeTween(
252
252
  return channelMatch ?? first;
253
253
  }
254
254
 
255
+ /** Whether a read carries at least one of `channels` as a keyframe property. */
256
+ function readCarriesChannel(read: ReadTween, channels: string[]): boolean {
257
+ return read.keyframes.some((kf) => channels.some((c) => kf.properties[c] != null));
258
+ }
259
+
255
260
  /**
256
261
  * Read keyframes (incl. motionPath arcs) for one selector from the live timeline.
257
262
  * Returns tween-relative percentages; callers convert to clip-relative.
263
+ *
264
+ * `requireChannels` restricts the scan to tweens whose read carries one of those
265
+ * properties — e.g. the motion-path overlay passes `["x","y"]` so it never picks
266
+ * up a co-located size/scale tween (which has no x/y and would blank the path
267
+ * whenever the playhead sits in that tween's range but outside the position
268
+ * tween's). Omitted → any keyframed tween qualifies (back-compat).
258
269
  */
259
270
  export function readRuntimeKeyframes(
260
271
  iframe: HTMLIFrameElement | null,
261
272
  selector: string,
262
273
  compositionId?: string,
274
+ requireChannels?: string[],
263
275
  ): ReadTween | null {
264
276
  const timelines = timelinesOf(iframe);
265
277
  if (!timelines) return null;
@@ -299,6 +311,7 @@ export function readRuntimeKeyframes(
299
311
  if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
300
312
  const read = readTween(tween.vars);
301
313
  if (!read) continue;
314
+ if (requireChannels && !readCarriesChannel(read, requireChannels)) continue;
302
315
  if (firstRead === null) firstRead = read;
303
316
  // Prefer the tween whose [start, start+dur] contains the playhead.
304
317
  if (now != null) {
@@ -115,12 +115,7 @@ export function readAllAnimatedProperties(
115
115
  }
116
116
  }
117
117
  }
118
- } catch (e) {
119
- console.warn(
120
- "Cross-tween guard failed — baseline capture may include values from other tweens",
121
- e,
122
- );
123
- }
118
+ } catch {}
124
119
  for (const p of propKeys) otherTweenProps.delete(p);
125
120
 
126
121
  // Tier 1: Transform + visual properties with universal CSS defaults.
@@ -224,10 +224,6 @@ export function useDomEditCommits({
224
224
  target_source_file: selection.sourceFile ?? undefined,
225
225
  composition: activeCompPath ?? undefined,
226
226
  });
227
- console.warn(
228
- `[studio] Element not found in source: ${targetKey}. ` +
229
- "This element may be generated at runtime and cannot be persisted.",
230
- );
231
227
  }
232
228
  }
233
229
  return;
@@ -68,6 +68,11 @@ export function useDomEditPreviewSync({
68
68
 
69
69
  const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
70
70
  if (!nextElement) {
71
+ // The selected element no longer resolves in the (re-synced) document
72
+ // — comp/hot reload, activeCompPath swap, or post-save replacement.
73
+ // Clear so overlay geometry isn't computed on a stale, detached node.
74
+ // (Drag-release-in-gray-zone is handled separately by
75
+ // suppressNextBoxClickRef; the dragged element still resolves here.)
71
76
  applyDomSelection(null, { revealPanel: false });
72
77
  return;
73
78
  }
@@ -1,3 +1,4 @@
1
+ import { useCallback } from "react";
1
2
  import type { TimelineElement } from "../player";
2
3
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
3
4
  import type { EditHistoryKind } from "../utils/editHistory";
@@ -123,6 +124,7 @@ export function useDomEditSession({
123
124
  buildDomSelectionForTimelineElement,
124
125
  handleTimelineElementSelect,
125
126
  refreshDomEditSelectionFromPreview,
127
+ applyMarqueeSelection,
126
128
  } = useDomSelection({
127
129
  projectId,
128
130
  activeCompPath,
@@ -389,6 +391,44 @@ export function useDomEditSession({
389
391
  updateArcSegment,
390
392
  });
391
393
 
394
+ const handleUpdateKeyframeEase = useCallback(
395
+ (animationId: string, percentage: number, ease: string) => {
396
+ const sel = domEditSelectionRef.current;
397
+ if (!sel) return;
398
+ gsapCommitMutation(
399
+ sel,
400
+ {
401
+ type: "update-keyframe",
402
+ animationId,
403
+ percentage,
404
+ properties: {},
405
+ ease,
406
+ },
407
+ { label: "Update keyframe ease", softReload: true },
408
+ );
409
+ },
410
+ [gsapCommitMutation, domEditSelectionRef],
411
+ );
412
+
413
+ // Apply one ease to every segment at once (AE select-all + F9): set easeEach
414
+ // and strip per-keyframe overrides in a single mutation.
415
+ const handleSetAllKeyframeEases = useCallback(
416
+ (animationId: string, ease: string) => {
417
+ const sel = domEditSelectionRef.current;
418
+ if (!sel) return;
419
+ gsapCommitMutation(
420
+ sel,
421
+ {
422
+ type: "update-meta",
423
+ animationId,
424
+ updates: { easeEach: ease, resetKeyframeEases: true },
425
+ },
426
+ { label: "Apply ease to all segments", softReload: true },
427
+ );
428
+ },
429
+ [gsapCommitMutation, domEditSelectionRef],
430
+ );
431
+
392
432
  return {
393
433
  // State
394
434
  domEditSelection,
@@ -429,6 +469,7 @@ export function useDomEditSession({
429
469
  buildDomSelectionFromTarget,
430
470
  buildDomSelectionForTimelineElement,
431
471
  updateDomEditHoverSelection,
472
+ applyMarqueeSelection,
432
473
  resolveImportedFontAsset,
433
474
  setAgentModalOpen,
434
475
  setAgentPromptSelectionContext,
@@ -454,6 +495,8 @@ export function useDomEditSession({
454
495
  handleGsapConvertToKeyframes,
455
496
  handleGsapRemoveAllKeyframes,
456
497
  handleResetSelectedElementKeyframes,
498
+ handleUpdateKeyframeEase,
499
+ handleSetAllKeyframeEases,
457
500
  commitAnimatedProperty,
458
501
  handleSetArcPath,
459
502
  handleUpdateArcSegment,
@@ -126,9 +126,7 @@ export function useDomEditTextCommits({
126
126
  ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
127
127
  : undefined,
128
128
  });
129
- } catch (err) {
130
- console.warn("[Studio] Style persist failed:", err instanceof Error ? err.message : err);
131
- }
129
+ } catch {}
132
130
  refreshDomEditSelectionFromPreview(domEditSelection);
133
131
  },
134
132
  [
@@ -162,9 +160,7 @@ export function useDomEditTextCommits({
162
160
  coalesceKey: `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`,
163
161
  skipRefresh: options.skipRefresh,
164
162
  });
165
- } catch (err) {
166
- console.warn(options.warningMessage, err instanceof Error ? err.message : err);
167
- }
163
+ } catch {}
168
164
  if (options.refreshAfter) {
169
165
  refreshDomEditSelectionFromPreview(domEditSelection);
170
166
  }
@@ -224,12 +220,7 @@ export function useDomEditTextCommits({
224
220
  coalesceKey: `html-attr:${attr}:${getDomEditTargetKey(domEditSelection)}`,
225
221
  skipRefresh: false,
226
222
  });
227
- } catch (err) {
228
- console.warn(
229
- "[Studio] HTML attribute persist failed:",
230
- err instanceof Error ? err.message : err,
231
- );
232
- }
223
+ } catch {}
233
224
  refreshDomEditSelectionFromPreview(domEditSelection);
234
225
  },
235
226
  [
@@ -197,6 +197,9 @@ export function useDomEditWiring({
197
197
  ? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null }
198
198
  : null,
199
199
  gsapCacheVersion,
200
+ // Pass the preview iframe so class/selector tweens (e.g. `.dot`) resolve to
201
+ // the live element and surface in the inspector — not just by #id match.
202
+ previewIframeRef,
200
203
  );
201
204
 
202
205
  // ── Telemetry & fallback ──
@@ -85,6 +85,7 @@ export interface UseDomSelectionReturn {
85
85
  handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
86
86
  refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
87
87
  refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
88
+ applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
88
89
  }
89
90
 
90
91
  // ── Hook ──
@@ -419,6 +420,50 @@ export function useDomSelection({
419
420
  applyDomSelection(null, { revealPanel: false });
420
421
  }, [applyDomSelection, captionEditMode]);
421
422
 
423
+ const applyMarqueeSelection = useCallback(
424
+ // fallow-ignore-next-line complexity
425
+ (selections: DomEditSelection[], additive: boolean) => {
426
+ // Honor the inspector-panels kill switch like applyDomSelection does, so
427
+ // marquee can't land selections while the inspector UI is suppressed.
428
+ if (!STUDIO_INSPECTOR_PANELS_ENABLED) {
429
+ domEditSelectionRef.current = null;
430
+ domEditGroupSelectionsRef.current = [];
431
+ setDomEditSelection(null);
432
+ setDomEditGroupSelections([]);
433
+ return;
434
+ }
435
+ if (selections.length === 0) {
436
+ if (!additive) applyDomSelection(null, { revealPanel: false });
437
+ return;
438
+ }
439
+ const current = domEditSelectionRef.current;
440
+ const currentGroup = domEditGroupSelectionsRef.current;
441
+ let nextGroup: DomEditSelection[];
442
+ if (additive) {
443
+ nextGroup = seedDomEditGroupWithSelection(currentGroup, current);
444
+ for (const s of selections) {
445
+ if (!domEditSelectionInGroup(nextGroup, s)) nextGroup = [...nextGroup, s];
446
+ }
447
+ } else {
448
+ nextGroup = selections;
449
+ }
450
+ const nextSelection = additive && current ? current : selections[0];
451
+ domEditSelectionRef.current = nextSelection;
452
+ domEditGroupSelectionsRef.current = nextGroup;
453
+ setDomEditSelection(nextSelection);
454
+ setDomEditGroupSelections(nextGroup);
455
+ const nextTimelineId =
456
+ findMatchingTimelineElementId(nextSelection, timelineElements) ??
457
+ findTimelineIdByAncestor(
458
+ nextSelection.element,
459
+ timelineElements,
460
+ nextSelection.sourceFile || "index.html",
461
+ );
462
+ setSelectedTimelineElementId(nextTimelineId);
463
+ },
464
+ [applyDomSelection, timelineElements, setSelectedTimelineElementId],
465
+ );
466
+
422
467
  // Disabled inspector effect
423
468
  // eslint-disable-next-line no-restricted-syntax
424
469
  useEffect(() => {
@@ -451,5 +496,6 @@ export function useDomSelection({
451
496
  handleTimelineElementSelect,
452
497
  refreshDomEditSelectionFromPreview,
453
498
  refreshDomEditGroupSelectionsFromPreview,
499
+ applyMarqueeSelection,
454
500
  };
455
501
  }