@hyperframes/studio 0.6.122-alpha.0 → 0.7.1

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 (100) hide show
  1. package/dist/assets/{index-i-MLqhmm.js → index-CnYOrmoA.js} +1 -1
  2. package/dist/assets/index-DzWIinxk.css +1 -0
  3. package/dist/assets/index-Z4bA3Fi0.js +374 -0
  4. package/dist/assets/{index-DSLrl2tB.js → index-wWkKTkIq.js} +24 -24
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.html +3 -3
  7. package/dist/index.js +3768 -2245
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/App.tsx +1 -0
  11. package/src/components/StudioHeader.tsx +2 -3
  12. package/src/components/StudioPreviewArea.tsx +12 -0
  13. package/src/components/TimelineToolbar.tsx +29 -16
  14. package/src/components/editor/DomEditOverlay.test.ts +0 -13
  15. package/src/components/editor/DomEditOverlay.tsx +3 -10
  16. package/src/components/editor/KeyframeNavigation.test.ts +36 -0
  17. package/src/components/editor/KeyframeNavigation.tsx +34 -4
  18. package/src/components/editor/MotionPathNode.tsx +98 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +481 -0
  20. package/src/components/editor/SnapToolbar.tsx +28 -3
  21. package/src/components/editor/SourceEditor.tsx +21 -9
  22. package/src/components/editor/domEditOverlayGeometry.ts +12 -0
  23. package/src/components/editor/domEditOverlayGestures.ts +5 -1
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
  25. package/src/components/editor/domEditingDom.ts +24 -9
  26. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  27. package/src/components/editor/manualEditingAvailability.ts +1 -11
  28. package/src/components/editor/manualEdits.ts +1 -0
  29. package/src/components/editor/manualEditsDom.ts +68 -41
  30. package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
  31. package/src/components/editor/manualOffsetDrag.test.ts +12 -0
  32. package/src/components/editor/manualOffsetDrag.ts +79 -4
  33. package/src/components/editor/motionPathCommit.test.ts +130 -0
  34. package/src/components/editor/motionPathCommit.ts +83 -0
  35. package/src/components/editor/motionPathGeometry.test.ts +127 -0
  36. package/src/components/editor/motionPathGeometry.ts +116 -0
  37. package/src/components/editor/motionPathSelection.ts +33 -0
  38. package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
  39. package/src/components/editor/useDomEditOverlayRects.ts +6 -0
  40. package/src/components/editor/useMotionPathData.ts +135 -0
  41. package/src/components/nle/NLELayout.tsx +17 -6
  42. package/src/components/panels/SlideshowPanel.tsx +2 -1
  43. package/src/components/panels/SlideshowSubPanels.tsx +2 -1
  44. package/src/components/renders/RenderQueue.tsx +1 -1
  45. package/src/components/renders/useRenderQueue.ts +3 -2
  46. package/src/hooks/draggedGsapPosition.ts +47 -0
  47. package/src/hooks/gsapDragCommit.test.ts +435 -0
  48. package/src/hooks/gsapDragCommit.ts +278 -290
  49. package/src/hooks/gsapDragPositionCommit.ts +410 -0
  50. package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
  51. package/src/hooks/gsapRuntimeBridge.ts +86 -69
  52. package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
  53. package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
  54. package/src/hooks/gsapRuntimePatch.test.ts +462 -0
  55. package/src/hooks/gsapRuntimePatch.ts +159 -0
  56. package/src/hooks/gsapScriptCommitTypes.ts +11 -1
  57. package/src/hooks/gsapShared.test.ts +58 -0
  58. package/src/hooks/gsapShared.ts +28 -10
  59. package/src/hooks/gsapTargetCache.ts +0 -3
  60. package/src/hooks/useDomEditPreviewSync.ts +3 -0
  61. package/src/hooks/useDomEditSession.ts +0 -4
  62. package/src/hooks/useDomEditWiring.ts +1 -0
  63. package/src/hooks/useDomSelection.ts +12 -5
  64. package/src/hooks/useEnableKeyframes.test.ts +130 -0
  65. package/src/hooks/useEnableKeyframes.ts +298 -45
  66. package/src/hooks/useGestureCommit.ts +121 -53
  67. package/src/hooks/useGestureRecording.ts +61 -42
  68. package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
  69. package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
  70. package/src/hooks/useGsapAnimationOps.ts +1 -1
  71. package/src/hooks/useGsapAwareEditing.ts +38 -40
  72. package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
  73. package/src/hooks/useGsapScriptCommits.ts +102 -6
  74. package/src/hooks/useGsapTweenCache.ts +12 -11
  75. package/src/hooks/useRazorSplit.ts +21 -2
  76. package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
  77. package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
  78. package/src/hooks/useServerConnection.ts +4 -1
  79. package/src/hooks/useStudioContextValue.ts +3 -2
  80. package/src/hooks/useStudioUrlState.ts +97 -37
  81. package/src/player/components/ShortcutsPanel.tsx +8 -0
  82. package/src/player/components/TimelineClipDiamonds.tsx +10 -12
  83. package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
  84. package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
  85. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  86. package/src/player/lib/mediaProbe.ts +7 -1
  87. package/src/player/store/playerStore.ts +38 -1
  88. package/src/telemetry/config.ts +3 -2
  89. package/src/utils/editDebugLog.ts +16 -0
  90. package/src/utils/generateId.ts +7 -0
  91. package/src/utils/gsapSoftReload.test.ts +217 -13
  92. package/src/utils/gsapSoftReload.ts +228 -50
  93. package/src/utils/studioHelpers.test.ts +31 -1
  94. package/src/utils/studioHelpers.ts +24 -0
  95. package/src/utils/studioPreviewHelpers.test.ts +26 -1
  96. package/src/utils/studioPreviewHelpers.ts +30 -6
  97. package/src/utils/studioTelemetry.ts +3 -1
  98. package/dist/assets/index-Baz2T-9U.js +0 -269
  99. package/dist/assets/index-BvEhf7SS.css +0 -1
  100. package/src/player/components/TimelinePropertyRows.tsx +0 -126
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Commit helpers for the motion-path overlay. Each maps a canvas gesture to a
3
+ * GSAP source mutation routed through the (selection-bound) commit facade, which
4
+ * handles the soft reload, undo snapshot, and save-failure feedback.
5
+ */
6
+ import type { MotionNodeRef } from "./motionPathGeometry";
7
+
8
+ export type CommitFn = (
9
+ mutation: Record<string, unknown>,
10
+ options: { label: string; softReload?: boolean },
11
+ ) => Promise<void>;
12
+
13
+ const NEW_PATH_DURATION = 1.5;
14
+
15
+ export function commitNode(
16
+ ref: MotionNodeRef,
17
+ x: number,
18
+ y: number,
19
+ animationId: string,
20
+ commit: CommitFn,
21
+ ): Promise<void> {
22
+ const mutation: Record<string, unknown> =
23
+ ref.type === "keyframe"
24
+ ? { type: "update-keyframe", animationId, percentage: ref.pct, properties: { x, y } }
25
+ : { type: "update-motion-path-point", animationId, pointIndex: ref.index, x, y };
26
+ return commit(mutation, {
27
+ label: ref.type === "keyframe" ? "Move keyframe" : "Move waypoint",
28
+ softReload: true,
29
+ });
30
+ }
31
+
32
+ export function commitAddWaypoint(
33
+ animationId: string,
34
+ index: number,
35
+ x: number,
36
+ y: number,
37
+ commit: CommitFn,
38
+ ): Promise<void> {
39
+ return commit(
40
+ { type: "add-motion-path-point", animationId, index, x, y },
41
+ { label: "Add waypoint", softReload: true },
42
+ );
43
+ }
44
+
45
+ export function commitAddKeyframe(
46
+ animationId: string,
47
+ percentage: number,
48
+ x: number,
49
+ y: number,
50
+ commit: CommitFn,
51
+ ): Promise<void> {
52
+ // percentage is tween-relative (matches MotionNodeRef.keyframe.pct). The parser's
53
+ // addKeyframeToScript inserts a new "P%": { x, y } stop (or merges if one exists
54
+ // at that pct) and converts a flat tween to keyframes form when needed.
55
+ return commit(
56
+ { type: "add-keyframe", animationId, percentage, properties: { x, y } },
57
+ { label: "Add keyframe", softReload: true },
58
+ );
59
+ }
60
+
61
+ export function commitRemoveWaypoint(
62
+ animationId: string,
63
+ index: number,
64
+ commit: CommitFn,
65
+ ): Promise<void> {
66
+ return commit(
67
+ { type: "remove-motion-path-point", animationId, index },
68
+ { label: "Remove waypoint", softReload: true },
69
+ );
70
+ }
71
+
72
+ export function commitCreatePath(
73
+ targetSelector: string,
74
+ position: number,
75
+ x: number,
76
+ y: number,
77
+ commit: CommitFn,
78
+ ): Promise<void> {
79
+ return commit(
80
+ { type: "add-motion-path", targetSelector, position, duration: NEW_PATH_DURATION, x, y },
81
+ { label: "Create motion path", softReload: true },
82
+ );
83
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildMotionPathGeometry, nearestPointOnPath } from "./motionPathGeometry";
3
+ import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes";
4
+
5
+ const kf = (percentage: number, x: number, y: number) => ({ percentage, properties: { x, y } });
6
+
7
+ describe("buildMotionPathGeometry", () => {
8
+ it("builds a linear path with keyframe-ref nodes from an x/y tween", () => {
9
+ const read: ReadTween = { keyframes: [kf(0, 10, 20), kf(100, 200, 80)] };
10
+ const geo = buildMotionPathGeometry(read);
11
+ expect(geo).not.toBeNull();
12
+ expect(geo!.kind).toBe("linear");
13
+ expect(geo!.points).toBe("10,20 200,80");
14
+ expect(geo!.nodes).toEqual([
15
+ { x: 10, y: 20, ref: { type: "keyframe", pct: 0 } },
16
+ { x: 200, y: 80, ref: { type: "keyframe", pct: 100 } },
17
+ ]);
18
+ });
19
+
20
+ it("preserves order and percentages for intermediate keyframes", () => {
21
+ const read: ReadTween = { keyframes: [kf(0, 0, 0), kf(50, 50, 90), kf(100, 100, 0)] };
22
+ const geo = buildMotionPathGeometry(read);
23
+ expect(geo!.nodes.map((n) => n.ref)).toEqual([
24
+ { type: "keyframe", pct: 0 },
25
+ { type: "keyframe", pct: 50 },
26
+ { type: "keyframe", pct: 100 },
27
+ ]);
28
+ });
29
+
30
+ it("builds an arc path with waypoint-index refs when arcPath is present", () => {
31
+ const read: ReadTween = {
32
+ keyframes: [kf(0, 0, 0), kf(50, 60, 40), kf(100, 120, 10)],
33
+ arcPath: { enabled: true, autoRotate: false, segments: [{ curviness: 1 }, { curviness: 1 }] },
34
+ };
35
+ const geo = buildMotionPathGeometry(read);
36
+ expect(geo!.kind).toBe("arc");
37
+ expect(geo!.nodes.map((n) => n.ref)).toEqual([
38
+ { type: "waypoint", index: 0 },
39
+ { type: "waypoint", index: 1 },
40
+ { type: "waypoint", index: 2 },
41
+ ]);
42
+ });
43
+
44
+ it("returns null for a tween with no positional keyframes", () => {
45
+ const read: ReadTween = {
46
+ keyframes: [
47
+ { percentage: 0, properties: { opacity: 0 } },
48
+ { percentage: 100, properties: { opacity: 1 } },
49
+ ],
50
+ };
51
+ expect(buildMotionPathGeometry(read)).toBeNull();
52
+ });
53
+
54
+ it("draws a single-axis (x-only) tween, defaulting the missing axis to 0", () => {
55
+ // Regression: an `x`-only tween (e.g. `to({ x: -260 })`) carries no `y`, so the
56
+ // builder used to skip every node → no path until the user added the 2nd axis.
57
+ const read: ReadTween = {
58
+ keyframes: [
59
+ { percentage: 0, properties: { x: 0 } },
60
+ { percentage: 100, properties: { x: -260 } },
61
+ ],
62
+ };
63
+ const geo = buildMotionPathGeometry(read);
64
+ expect(geo).not.toBeNull();
65
+ expect(geo!.points).toBe("0,0 -260,0"); // y defaults to 0 → horizontal path
66
+ });
67
+
68
+ it("draws a y-only tween too (x defaults to 0)", () => {
69
+ const read: ReadTween = {
70
+ keyframes: [
71
+ { percentage: 0, properties: { y: 0 } },
72
+ { percentage: 100, properties: { y: 500 } },
73
+ ],
74
+ };
75
+ expect(buildMotionPathGeometry(read)!.points).toBe("0,0 0,500");
76
+ });
77
+
78
+ it("excludes keyframes missing a coordinate without throwing", () => {
79
+ const read: ReadTween = {
80
+ keyframes: [kf(0, 10, 20), { percentage: 50, properties: { x: 100 } }, kf(100, 200, 80)],
81
+ };
82
+ const geo = buildMotionPathGeometry(read);
83
+ expect(geo!.nodes).toHaveLength(2);
84
+ expect(geo!.points).toBe("10,20 200,80");
85
+ });
86
+
87
+ it("returns null when fewer than two valid nodes remain", () => {
88
+ const read: ReadTween = { keyframes: [kf(0, 10, 20)] };
89
+ expect(buildMotionPathGeometry(read)).toBeNull();
90
+ });
91
+
92
+ it("returns null for null input", () => {
93
+ expect(buildMotionPathGeometry(null)).toBeNull();
94
+ });
95
+ });
96
+
97
+ describe("nearestPointOnPath", () => {
98
+ const nodes = [
99
+ { x: 0, y: 0 },
100
+ { x: 100, y: 0 },
101
+ { x: 100, y: 100 },
102
+ ];
103
+
104
+ it("projects onto the nearest segment and reports its index + fraction", () => {
105
+ const p = nearestPointOnPath(50, 20, nodes);
106
+ expect(p).toEqual({ x: 50, y: 0, segIndex: 0, t: 0.5, dist: 20 });
107
+ });
108
+
109
+ it("reports t at the segment endpoints (0 at start, clamps to 1 past the end)", () => {
110
+ expect(nearestPointOnPath(0, 5, nodes)).toMatchObject({ segIndex: 0, t: 0 });
111
+ expect(nearestPointOnPath(110, 0, nodes)).toMatchObject({ segIndex: 0, t: 1 });
112
+ });
113
+
114
+ it("picks the second segment when closer to it", () => {
115
+ const p = nearestPointOnPath(120, 50, nodes);
116
+ expect(p).toMatchObject({ x: 100, y: 50, segIndex: 1 });
117
+ });
118
+
119
+ it("clamps to an endpoint when the projection falls past the segment", () => {
120
+ const p = nearestPointOnPath(-40, -10, nodes);
121
+ expect(p).toMatchObject({ x: 0, y: 0, segIndex: 0 });
122
+ });
123
+
124
+ it("returns null for fewer than two nodes", () => {
125
+ expect(nearestPointOnPath(0, 0, [{ x: 0, y: 0 }])).toBeNull();
126
+ });
127
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Convert a live tween (from `readRuntimeKeyframes`) into renderable motion-path
3
+ * geometry for the on-canvas overlay. Pure — no React/DOM — so it unit-tests in
4
+ * isolation. Coordinates are in composition space (the same space the overlay's
5
+ * viewBox uses), so the caller renders nodes/points directly.
6
+ */
7
+ import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes";
8
+
9
+ /** Which source edit a dragged node maps to. */
10
+ export type MotionNodeRef =
11
+ | { type: "keyframe"; pct: number } // x/y position keyframe at this tween-relative %
12
+ | { type: "waypoint"; index: number }; // motionPath waypoint (anchor) at this index
13
+
14
+ export interface MotionPathNode {
15
+ x: number;
16
+ y: number;
17
+ ref: MotionNodeRef;
18
+ }
19
+
20
+ export interface MotionPathGeometry {
21
+ /** "linear" = x/y keyframes; "arc" = motionPath tween. */
22
+ kind: "linear" | "arc";
23
+ /** SVG polyline points: "x,y x,y ...". */
24
+ points: string;
25
+ nodes: MotionPathNode[];
26
+ }
27
+
28
+ /**
29
+ * Build motion-path geometry, or null when the tween carries no positional path
30
+ * (fewer than two keyframes with both x and y). For motionPath tweens the
31
+ * keyframes are the arc waypoints (anchors), index-aligned with the source path
32
+ * — so a waypoint node at index `i` rewrites source waypoint `i`.
33
+ *
34
+ * ponytail: the arc is drawn as a polyline through its waypoints (matching the
35
+ * angular dotted look of the reference), not GSAP's resolved curve. Dense
36
+ * curve sampling is a later refinement if the straight-segment preview proves
37
+ * insufficient.
38
+ */
39
+ /**
40
+ * Nearest point on a polyline to (px, py), with the index of the segment it
41
+ * lies on and `t` = how far along that segment the returned point sits.
42
+ *
43
+ * `t` semantics: clamped to the inclusive range [0, 1].
44
+ * - `t === 0` → the point is at (or projects before) the segment's start node
45
+ * (`segIndex`); a perpendicular dropped from (px, py) falls at or behind `a`.
46
+ * - `0 < t < 1` → the point is strictly interior to the segment.
47
+ * - `t === 1` → the point is at (or projects PAST) the segment's end node
48
+ * (`segIndex + 1`); past-the-end projections are clamped back onto the endpoint,
49
+ * so the returned (x, y) is exactly `nodes[segIndex + 1]`. Callers can read
50
+ * `t === 1` as "snapped to the end anchor of this segment" (equivalently, the
51
+ * start anchor of the next segment).
52
+ * A degenerate zero-length segment (`a === b`) yields `t === 0`.
53
+ *
54
+ * Used to position the ghost "add" node and decide where a new node goes: a
55
+ * motionPath waypoint inserts between `segIndex`/`segIndex + 1`, a keyframe
56
+ * interpolates its tween-% from the two adjacent keyframes via `t`.
57
+ * Coordinates are whatever space the caller passes (overlay uses absolute px).
58
+ */
59
+ export function nearestPointOnPath(
60
+ px: number,
61
+ py: number,
62
+ nodes: Array<{ x: number; y: number }>,
63
+ ): { x: number; y: number; segIndex: number; t: number; dist: number } | null {
64
+ if (nodes.length < 2) return null;
65
+ let best: { x: number; y: number; segIndex: number; t: number; dist: number } | null = null;
66
+ for (let i = 0; i < nodes.length - 1; i++) {
67
+ const a = nodes[i]!;
68
+ const b = nodes[i + 1]!;
69
+ const dx = b.x - a.x;
70
+ const dy = b.y - a.y;
71
+ const len2 = dx * dx + dy * dy;
72
+ const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - a.x) * dx + (py - a.y) * dy) / len2));
73
+ const cx = a.x + t * dx;
74
+ const cy = a.y + t * dy;
75
+ const dist = Math.hypot(px - cx, py - cy);
76
+ if (!best || dist < best.dist) best = { x: cx, y: cy, segIndex: i, t, dist };
77
+ }
78
+ return best;
79
+ }
80
+
81
+ export function buildMotionPathGeometry(read: ReadTween | null): MotionPathGeometry | null {
82
+ if (!read) return null;
83
+ const isArc = Boolean(read.arcPath);
84
+ const nodes: MotionPathNode[] = [];
85
+
86
+ // Index by source position so a waypoint node maps to the matching source
87
+ // anchor. Arc waypoints always carry x/y (never filtered), so source index
88
+ // and node order stay aligned.
89
+ // Which axes does the tween animate at all? A single-axis tween (e.g.
90
+ // `to({ x: -260 })`) only carries x; its y stays at the base (0, the GSAP
91
+ // transform identity), so we default it and still draw a path. But if the tween
92
+ // DOES animate an axis and a given keyframe omits it, that value is interpolated
93
+ // (not 0) and can't be placed here → skip that node (the prior behavior).
94
+ const finite = (v: unknown): v is number => typeof v === "number" && isFinite(v);
95
+ const tweenHasX = read.keyframes.some((kf) => finite(kf.properties.x));
96
+ const tweenHasY = read.keyframes.some((kf) => finite(kf.properties.y));
97
+ if (!tweenHasX && !tweenHasY) return null; // no positional motion (opacity/scale only)
98
+
99
+ read.keyframes.forEach((kf, i) => {
100
+ if (tweenHasX && !finite(kf.properties.x)) return;
101
+ if (tweenHasY && !finite(kf.properties.y)) return;
102
+ nodes.push({
103
+ x: tweenHasX ? (kf.properties.x as number) : 0,
104
+ y: tweenHasY ? (kf.properties.y as number) : 0,
105
+ ref: isArc ? { type: "waypoint", index: i } : { type: "keyframe", pct: kf.percentage },
106
+ });
107
+ });
108
+
109
+ if (nodes.length < 2) return null;
110
+
111
+ return {
112
+ kind: isArc ? "arc" : "linear",
113
+ points: nodes.map((n) => `${n.x},${n.y}`).join(" "),
114
+ nodes,
115
+ };
116
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Resolving the selected element and the animation whose path is editable.
3
+ * Shared by the overlay and its diagnostics (kept here to avoid a circular
4
+ * import between the two).
5
+ */
6
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
7
+ import type { DomEditSelection } from "./domEditing";
8
+
9
+ export function selectorFor(sel: DomEditSelection | null): string | null {
10
+ if (!sel) return null;
11
+ if (sel.id) return `#${CSS.escape(sel.id)}`;
12
+ return sel.selector ?? null;
13
+ }
14
+
15
+ /** The animation whose path is editable on-canvas: literal, statically resolved,
16
+ * and matching the rendered geometry kind. Returns null when the path can only
17
+ * be displayed (dynamic/helper tweens) — those nodes stay read-only. */
18
+ export function editableAnimationId(
19
+ animations: GsapAnimation[],
20
+ kind: "linear" | "arc",
21
+ ): string | null {
22
+ const ok = (a: GsapAnimation) =>
23
+ !a.hasUnresolvedKeyframes && !a.hasUnresolvedSelector && !a.provenance;
24
+ if (kind === "arc") return animations.find((a) => a.arcPath?.enabled && ok(a))?.id ?? null;
25
+ const a = animations.find(
26
+ (anim) =>
27
+ anim.keyframes &&
28
+ ok(anim) &&
29
+ (anim.propertyGroup === "position" ||
30
+ anim.keyframes.keyframes.some((k) => "x" in k.properties || "y" in k.properties)),
31
+ );
32
+ return a?.id ?? null;
33
+ }
@@ -9,6 +9,7 @@ import { type DomEditSelection } from "./domEditing";
9
9
  import {
10
10
  applyManualOffsetDragCommit,
11
11
  applyManualOffsetDragDraft,
12
+ applyRotationDraftViaGsap,
12
13
  endManualOffsetDragMembers,
13
14
  restoreManualOffsetDragMembers,
14
15
  resumeGsapTimelines,
@@ -161,19 +162,21 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
161
162
  let dy = e.clientY - g.startY;
162
163
 
163
164
  if (g.kind === "rotate") {
164
- applyStudioRotationDraft(
165
- sel.element,
166
- resolveDomEditRotationGesture({
167
- centerX: g.centerX,
168
- centerY: g.centerY,
169
- startX: g.startX,
170
- startY: g.startY,
171
- currentX: e.clientX,
172
- currentY: e.clientY,
173
- actualAngle: g.actualRotation,
174
- snap: e.shiftKey,
175
- }),
176
- );
165
+ // Single source of truth: preview the rotation through the GSAP channel (the
166
+ // same channel the commit lands in), not the `--hf-studio-rotation` CSS var.
167
+ const rotated = resolveDomEditRotationGesture({
168
+ centerX: g.centerX,
169
+ centerY: g.centerY,
170
+ startX: g.startX,
171
+ startY: g.startY,
172
+ currentX: e.clientX,
173
+ currentY: e.clientY,
174
+ actualAngle: g.actualRotation,
175
+ snap: e.shiftKey,
176
+ });
177
+ if (!applyRotationDraftViaGsap(sel.element, rotated.angle)) {
178
+ applyStudioRotationDraft(sel.element, rotated);
179
+ }
177
180
  return;
178
181
  }
179
182
 
@@ -393,25 +396,38 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
393
396
  actualAngle: g.actualRotation,
394
397
  snap: e.shiftKey,
395
398
  });
399
+ const restoreRotation = () => {
400
+ // Single source of truth: snap the GSAP rotation back to the gesture's base
401
+ // angle; fall back to the legacy CSS-var restore when gsap is unavailable.
402
+ if (!applyRotationDraftViaGsap(sel.element, g.actualRotation)) {
403
+ restoreStudioRotation(sel.element, g.initialRotation);
404
+ }
405
+ };
396
406
  if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
397
- restoreStudioRotation(sel.element, g.initialRotation);
407
+ restoreRotation();
398
408
  endStudioManualEditGesture(sel.element, g.manualEditDragToken);
399
409
  return;
400
410
  }
401
- applyStudioRotation(sel.element, finalRotation);
411
+ // Keep the preview at the final angle through the GSAP channel (NOT the CSS var)
412
+ // while the commit lands a `tl.set`/keyframe rotation on the timeline.
413
+ if (!applyRotationDraftViaGsap(sel.element, finalRotation.angle)) {
414
+ applyStudioRotation(sel.element, finalRotation);
415
+ }
402
416
  void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
403
417
  .catch(() => {
404
418
  if (
405
419
  g.manualEditDragToken &&
406
420
  isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
407
421
  )
408
- restoreStudioRotation(sel.element, g.initialRotation);
422
+ restoreRotation();
409
423
  })
410
424
  .finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
411
425
  } else if (g.kind === "drag") {
412
426
  const dx = g.lastSnappedDx ?? e.clientX - g.startX;
413
427
  const dy = g.lastSnappedDy ?? e.clientY - g.startY;
414
- if (!g.pathOffsetMember) return;
428
+ if (!g.pathOffsetMember) {
429
+ return;
430
+ }
415
431
  const finalOffset = applyManualOffsetDragCommit(g.pathOffsetMember, dx, dy);
416
432
  const nextBoxLeft = g.originLeft + dx;
417
433
  const nextBoxTop = g.originTop + dy;
@@ -427,7 +443,9 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
427
443
  box.style.left = `${nextBoxLeft}px`;
428
444
  box.style.top = `${nextBoxTop}px`;
429
445
  }
430
- void Promise.resolve(opts.onPathOffsetCommitRef.current(sel, finalOffset))
446
+ void Promise.resolve(
447
+ opts.onPathOffsetCommitRef.current(sel, finalOffset, { altKey: e.altKey }),
448
+ )
431
449
  .catch(() => {
432
450
  if (
433
451
  g.pathOffsetMember?.gestureToken &&
@@ -148,6 +148,12 @@ export function useDomEditOverlayRects({
148
148
  activeCompositionPathRef.current,
149
149
  resolvedElementRef as ResolvedElementRef,
150
150
  );
151
+ // An explicitly-selected element's overlay must track it whenever it's laid
152
+ // out and not display:none/visibility:hidden/opacity:0 — use basic visibility,
153
+ // NOT the occlusion heuristic. Occlusion (isElementVisibleInPreview) treats any
154
+ // opacity:1 ancestor as an opaque cover even when it paints nothing (e.g. a
155
+ // backgroundless full-bleed scene above a subcomposition), which would wrongly
156
+ // hide the selection box. Occlusion stays for hover, where a false hide is cheap.
151
157
  if (el && isElementVisibleForOverlay(el)) {
152
158
  const nextRect = toOverlayRect(overlayEl, iframe, el);
153
159
  setOverlayRect(nextRect);
@@ -0,0 +1,135 @@
1
+ import { useEffect, useRef, useState, type RefObject } from "react";
2
+ import { readRuntimeKeyframes } from "../../hooks/gsapRuntimeKeyframes";
3
+ import { isElementVisibleForOverlay } from "./domEditOverlayGeometry";
4
+ import { buildMotionPathGeometry, type MotionPathGeometry } from "./motionPathGeometry";
5
+
6
+ type Rect = { left: number; top: number; width: number; height: number };
7
+
8
+ export function elementHome(el: HTMLElement): { x: number; y: number } {
9
+ let left = 0;
10
+ let top = 0;
11
+ let node: HTMLElement | null = el;
12
+ while (node) {
13
+ left += node.offsetLeft;
14
+ top += node.offsetTop;
15
+ const parent = node.offsetParent as HTMLElement | null;
16
+ if (!parent || parent.hasAttribute("data-composition-id")) break;
17
+ node = parent;
18
+ }
19
+ let x = left + el.offsetWidth / 2;
20
+ let y = top + el.offsetHeight / 2;
21
+ if ((el.style.translate ?? "").includes("var(")) {
22
+ x += Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-x")) || 0;
23
+ y += Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-y")) || 0;
24
+ }
25
+ return { x, y };
26
+ }
27
+
28
+ export function isPreviewHtmlElement(
29
+ node: Element | null | undefined,
30
+ iframe: HTMLIFrameElement | null,
31
+ ): node is HTMLElement {
32
+ const Ctor = (iframe?.contentWindow as unknown as { HTMLElement?: typeof HTMLElement } | null)
33
+ ?.HTMLElement;
34
+ return Boolean(node && Ctor && node instanceof Ctor);
35
+ }
36
+
37
+ function rectsClose(a: Rect, b: Rect): boolean {
38
+ return (
39
+ Math.abs(a.left - b.left) < 0.5 &&
40
+ Math.abs(a.top - b.top) < 0.5 &&
41
+ Math.abs(a.width - b.width) < 0.5 &&
42
+ Math.abs(a.height - b.height) < 0.5
43
+ );
44
+ }
45
+
46
+ export function hasMotionPathPlugin(iframe: HTMLIFrameElement | null): boolean {
47
+ try {
48
+ return Boolean(
49
+ (iframe?.contentWindow as unknown as { MotionPathPlugin?: unknown })?.MotionPathPlugin,
50
+ );
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ export function useMotionPathData(
57
+ iframeRef: RefObject<HTMLIFrameElement | null>,
58
+ selector: string | null,
59
+ ): {
60
+ rect: Rect | null;
61
+ geometry: MotionPathGeometry | null;
62
+ geometryResolved: boolean;
63
+ visibleInPreview: boolean;
64
+ home: { x: number; y: number } | null;
65
+ } {
66
+ const [rect, setRect] = useState<Rect | null>(null);
67
+ const [geometry, setGeometry] = useState<MotionPathGeometry | null>(null);
68
+ const resolvedForRef = useRef<string | null>(null);
69
+ const geometryResolved = resolvedForRef.current === selector;
70
+ const [visibleInPreview, setVisibleInPreview] = useState(true);
71
+ const [home, setHome] = useState<{ x: number; y: number } | null>(null);
72
+
73
+ useEffect(() => {
74
+ if (!selector) {
75
+ setRect(null);
76
+ setHome(null);
77
+ return;
78
+ }
79
+ setHome(null);
80
+ let raf = 0;
81
+ const tick = () => {
82
+ const el = iframeRef.current;
83
+ if (el) {
84
+ const r = el.getBoundingClientRect();
85
+ const surface = el.ownerDocument?.querySelector("[data-preview-pan-surface]");
86
+ const sRect = surface?.getBoundingClientRect();
87
+ const next = {
88
+ left: sRect ? r.left - sRect.left : r.left,
89
+ top: sRect ? r.top - sRect.top : r.top,
90
+ width: r.width,
91
+ height: r.height,
92
+ };
93
+ setRect((prev) => (prev && rectsClose(prev, next) ? prev : next));
94
+ let target: Element | null = null;
95
+ try {
96
+ target = el.contentDocument?.querySelector(selector) ?? null;
97
+ } catch {
98
+ /* cross-origin guard */
99
+ }
100
+ const live = isPreviewHtmlElement(target, el) ? target : null;
101
+ const vis = live ? isElementVisibleForOverlay(live) : true;
102
+ setVisibleInPreview((prev) => (prev === vis ? prev : vis));
103
+ if (live) {
104
+ const h = elementHome(live);
105
+ setHome((prev) =>
106
+ prev && Math.abs(prev.x - h.x) < 0.5 && Math.abs(prev.y - h.y) < 0.5 ? prev : h,
107
+ );
108
+ }
109
+ }
110
+ raf = requestAnimationFrame(tick);
111
+ };
112
+ raf = requestAnimationFrame(tick);
113
+ return () => cancelAnimationFrame(raf);
114
+ }, [selector, iframeRef]);
115
+
116
+ useEffect(() => {
117
+ if (!selector) {
118
+ setGeometry(null);
119
+ return;
120
+ }
121
+ const recompute = () => {
122
+ const read = readRuntimeKeyframes(iframeRef.current, selector);
123
+ const next = buildMotionPathGeometry(read);
124
+ setGeometry((prev) =>
125
+ prev?.points === next?.points && prev?.kind === next?.kind ? prev : next,
126
+ );
127
+ resolvedForRef.current = selector;
128
+ };
129
+ recompute();
130
+ const id = window.setInterval(recompute, 250);
131
+ return () => window.clearInterval(id);
132
+ }, [selector, iframeRef]);
133
+
134
+ return { rect, geometry, geometryResolved, visibleInPreview, home };
135
+ }
@@ -16,11 +16,13 @@ import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
16
16
  import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
17
17
  import { useCompositionStack } from "./useCompositionStack";
18
18
  import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
19
+ import { setCompositionSourceMap } from "../editor/domEditingDom";
19
20
  import { trackStudioExpandedClipEdit } from "../../telemetry/events";
20
21
  import {
21
22
  TIMELINE_TOGGLE_SHORTCUT_LABEL,
22
23
  getTimelineToggleTitle,
23
24
  } from "../../utils/timelineDiscovery";
25
+ import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
24
26
 
25
27
  interface NLELayoutProps {
26
28
  projectId: string;
@@ -122,12 +124,14 @@ export const NLELayout = memo(function NLELayout({
122
124
  refreshPlayer,
123
125
  } = useTimelinePlayer();
124
126
 
125
- // Reset timeline state when the project changes
126
- const prevProjectIdRef = useRef(projectId);
127
- if (prevProjectIdRef.current !== projectId) {
128
- prevProjectIdRef.current = projectId;
127
+ // Reset timeline state when the project changes. Done in an effect, not during
128
+ // render: reset() updates the player store, and updating another store/component
129
+ // mid-render triggers React's "Cannot update a component while rendering a
130
+ // different component" warning. The effect runs right after commit, so the new
131
+ // project's first frame may briefly show prior timeline state before it clears.
132
+ useEffect(() => {
129
133
  usePlayerStore.getState().reset();
130
- }
134
+ }, [projectId]);
131
135
 
132
136
  const stageRefForDrop = useRef<HTMLDivElement | null>(null);
133
137
  const handleStageRef = useCallback((ref: React.RefObject<HTMLDivElement | null>) => {
@@ -158,6 +162,10 @@ export const NLELayout = memo(function NLELayout({
158
162
 
159
163
  const onIframeLoad = useCallback(() => {
160
164
  baseOnIframeLoad();
165
+ // Pre-load + register MotionPathPlugin once so adding a motion path in the
166
+ // studio doesn't take the async plugin-load flash path on the first soft
167
+ // reload (the comp may not ship the plugin until it actually uses one).
168
+ ensureMotionPathPluginLoaded(iframeRef.current);
161
169
  onIframeRef?.(iframeRef.current);
162
170
  }, [baseOnIframeLoad, iframeRef, onIframeRef]);
163
171
 
@@ -294,6 +302,9 @@ export const NLELayout = memo(function NLELayout({
294
302
  if (id && src) map.set(id, src);
295
303
  }
296
304
  setCompIdToSrc(map);
305
+ // Let DOM source-resolution recover a subcomposition element's source file
306
+ // (the runtime drops the linkage when inlining — see getSourceFileForElement).
307
+ setCompositionSourceMap(map);
297
308
  onCompIdToSrcChange?.(map);
298
309
  })
299
310
  .catch(() => {});
@@ -427,7 +438,7 @@ export const NLELayout = memo(function NLELayout({
427
438
  {/* Preview + player controls */}
428
439
  <div className="flex-1 min-h-0 flex flex-col">
429
440
  <div
430
- className="flex-1 min-h-0 relative"
441
+ className="flex-1 min-h-0 relative overflow-hidden"
431
442
  data-preview-pan-surface="true"
432
443
  onPointerDown={(e) => {
433
444
  const el = iframeRef.current?.parentElement ?? iframeRef.current;