From 3525c7ff52139a5d63093f7a8863e7fb8ff980e6 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 11 Jul 2026 04:07:06 -0400 Subject: [PATCH] fix(studio): correct gesture commits for scaled and graded elements - resize on a scale-driven element commits per-axis scale (scaleX/scaleY for non-uniform drags) with keyframe normalization to the longhands, and clears the width/height draft so size can't double-apply; the intercept moves to gsapResizeIntercept.ts - the drop frame applies the corrected position synchronously in the same microtask chain as the soft reload (no network-window jump), and the draft pins the anchor through accumulated moves on scaled elements - gesture size/position math divides by the element's own content scale - convert-to-keyframes resolves current values through the property-group filter for ALL capture passes (opacity/rotationX from unrelated tweens no longer leak into a rotation commit), and a grading-hidden source's opacity is read from its canvas, not the inline hide - canvas pointer-down confirms the hover target with a synchronous hit-test before starting a marquee (stale-hover race lost selections) --- .../components/editor/DomEditOverlay.test.ts | 41 ++ .../src/components/editor/DomEditOverlay.tsx | 13 +- .../editor/domEditOverlayGestures.ts | 41 +- .../editor/domEditOverlayStartGesture.ts | 37 +- .../editor/useDomEditOverlayGestures.ts | 55 ++- .../studio/src/hooks/gsapResizeIntercept.ts | 379 ++++++++++++++++++ .../studio/src/hooks/gsapRuntimeBridge.ts | 229 +---------- .../src/hooks/gsapRuntimeReaders.test.ts | 111 +++++ .../studio/src/hooks/gsapRuntimeReaders.ts | 47 ++- .../studio/src/hooks/useGsapAwareEditing.ts | 7 +- 10 files changed, 703 insertions(+), 257 deletions(-) create mode 100644 packages/studio/src/hooks/gsapResizeIntercept.ts create mode 100644 packages/studio/src/hooks/gsapRuntimeReaders.test.ts diff --git a/packages/studio/src/components/editor/DomEditOverlay.test.ts b/packages/studio/src/components/editor/DomEditOverlay.test.ts index db1f2291b..fbc8eccb2 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.test.ts +++ b/packages/studio/src/components/editor/DomEditOverlay.test.ts @@ -535,6 +535,47 @@ describe("resolveDomEditResizeGesture", () => { }); }); + it("divides the cursor delta by the element's content scale (rescaled element)", () => { + // Element renders at 2x via a GSAP scale: a 30px cursor delta must grow the + // CSS box by only 15px so the RENDERED box tracks the pointer 1:1. + const next = resolveDomEditResizeGesture({ + originWidth: 480, // 240 css x 2 content scale (overlay px at editScale 1) + originHeight: 240, + actualWidth: 240, + actualHeight: 120, + scaleX: 1, + scaleY: 1, + contentScaleX: 2, + contentScaleY: 2, + dx: 30, + dy: 12, + uniform: false, + }); + expect(next.width).toBe(255); + expect(next.height).toBe(126); + // The overlay box keeps tracking the raw cursor. + expect(next.overlayWidth).toBe(510); + expect(next.overlayHeight).toBe(252); + }); + + it("treats a missing/invalid content scale as 1 (unscaled element)", () => { + const next = resolveDomEditResizeGesture({ + originWidth: 240, + originHeight: 120, + actualWidth: 240, + actualHeight: 120, + scaleX: 1, + scaleY: 1, + contentScaleX: 0, + contentScaleY: Number.NaN, + dx: 30, + dy: 12, + uniform: false, + }); + expect(next.width).toBe(270); + expect(next.height).toBe(132); + }); + it("snaps width and height to the same value when Shift is held", () => { expect( resolveDomEditResizeGesture({ diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 737a47455..5b444a7c7 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -1,4 +1,5 @@ import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers"; import { type DomEditSelection } from "./domEditing"; import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction"; import { useMarqueeGestures } from "./marqueeCommit"; @@ -305,8 +306,18 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const target = event.target as HTMLElement | null; if (target?.closest('[data-dom-edit-selection-box="true"]')) return; - // Start marquee if clicking on empty canvas (no element under pointer) + // Start marquee if clicking on empty canvas (no element under pointer). + // The hover selection is an ASYNC cache: on a fast click (or when the + // pointer was already resting over an element) it can still be empty while + // an element IS under the pointer — starting a marquee here would swallow + // the selection mousedown and the click would silently select nothing. + // Confirm emptiness with a fresh SYNCHRONOUS hit-test before committing. if (!hoverSelectionRef.current && onMarqueeSelectRef.current && compRect.width > 0) { + const iframe = iframeRef.current; + const freshTarget = iframe + ? getPreviewTargetFromPointer(iframe, event.clientX, event.clientY, activeCompositionPath) + : null; + if (freshTarget) return; const overlayEl = overlayRef.current; if (overlayEl) { const oRect = overlayEl.getBoundingClientRect(); diff --git a/packages/studio/src/components/editor/domEditOverlayGestures.ts b/packages/studio/src/components/editor/domEditOverlayGestures.ts index 7f3c63c80..2abc793b6 100644 --- a/packages/studio/src/components/editor/domEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/domEditOverlayGestures.ts @@ -39,6 +39,25 @@ export interface GestureState { actualRotation: number; editScaleX: number; editScaleY: number; + // Rendered-per-CSS-pixel factor of the element itself at gesture start (a GSAP + // scale() transform makes this > 1) — the resize draft divides by it so the box + // follows the cursor instead of overshooting by the live scale. + contentScaleX: number; + contentScaleY: number; + // Resize anchor pinning: with a live scale transform, growing the CSS box + // shifts the rendered box (scaling happens around the element center), so the + // un-dragged corner creeps during the draft. The move handler measures the + // gesture-start top-left drift each frame and counters it through the GSAP + // position channel; the pin accumulates so the correction converges. + // Present only on resize gestures. + resizeAnchor?: { + anchorX: number; + anchorY: number; + baseGsapX: number; + baseGsapY: number; + pinX: number; + pinY: number; + }; manualEditDragToken?: string; snapContext?: SnapContext; lastSnappedDx?: number; @@ -77,21 +96,31 @@ export function resolveDomEditResizeGesture(input: { actualHeight: number; scaleX: number; scaleY: number; + // Rendered-per-CSS-pixel factor of the element itself (its live GSAP scale). + // The CSS width/height the draft writes get multiplied by this on screen, so + // the cursor delta must be divided by it — otherwise the box outruns the + // pointer on a rescaled element and snaps back on release. Defaults to 1. + contentScaleX?: number; + contentScaleY?: number; dx: number; dy: number; uniform: boolean; }): { overlayWidth: number; overlayHeight: number; width: number; height: number } { const scaleX = input.scaleX > 0 ? input.scaleX : 1; const scaleY = input.scaleY > 0 ? input.scaleY : 1; + const contentScaleX = + input.contentScaleX !== undefined && input.contentScaleX > 0 ? input.contentScaleX : 1; + const contentScaleY = + input.contentScaleY !== undefined && input.contentScaleY > 0 ? input.contentScaleY : 1; if (input.uniform) { - const deltaX = input.dx / scaleX; - const deltaY = input.dy / scaleY; + const deltaX = input.dx / (scaleX * contentScaleX); + const deltaY = input.dy / (scaleY * contentScaleY); const delta = Math.abs(deltaX) >= Math.abs(deltaY) ? deltaX : deltaY; const side = Math.max(1, Math.max(input.actualWidth, input.actualHeight) + delta); return { - overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, side * scaleX), - overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, side * scaleY), + overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, side * scaleX * contentScaleX), + overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, side * scaleY * contentScaleY), width: side, height: side, }; @@ -100,8 +129,8 @@ export function resolveDomEditResizeGesture(input: { return { overlayWidth: Math.max(MIN_RESIZE_EDGE_PX, input.originWidth + input.dx), overlayHeight: Math.max(MIN_RESIZE_EDGE_PX, input.originHeight + input.dy), - width: Math.max(1, input.actualWidth + input.dx / scaleX), - height: Math.max(1, input.actualHeight + input.dy / scaleY), + width: Math.max(1, input.actualWidth + input.dx / (scaleX * contentScaleX)), + height: Math.max(1, input.actualHeight + input.dy / (scaleY * contentScaleY)), }; } diff --git a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts index f91a8a434..636265e20 100644 --- a/packages/studio/src/components/editor/domEditOverlayStartGesture.ts +++ b/packages/studio/src/components/editor/domEditOverlayStartGesture.ts @@ -2,6 +2,7 @@ * Gesture-begin functions: startGroupDrag and startGesture. * These are pure "start a new gesture" operations — no draft rect updates. */ +import { readElementGsapNumber } from "../../utils/elementGsap"; import { type DomEditSelection } from "./domEditing"; import { createManualOffsetDragMember, @@ -120,8 +121,37 @@ export function startGesture( // `--hf-studio-rotation` CSS var (old projects), so a rotate gesture starts from the // element's actual visual angle and commits an absolute angle to the timeline. const rotation = { angle: readGsapRotation(sel.element) + readStudioRotation(sel.element).angle }; - const actualWidth = size.width > 0 ? size.width : rect.width / rect.editScaleX; - const actualHeight = size.height > 0 ? size.height : rect.height / rect.editScaleY; + // The draft writes CSS width/height, so the resize base must be the CSS + // layout size. offsetWidth/Height are transform-free; the overlay-rect + // fallback (rect / editScale) includes the element's own GSAP scale and + // would make a rescaled element's draft grow from the RENDERED size. + const layoutWidth = sel.element.offsetWidth; + const layoutHeight = sel.element.offsetHeight; + const actualWidth = + size.width > 0 ? size.width : layoutWidth > 0 ? layoutWidth : rect.width / rect.editScaleX; + const actualHeight = + size.height > 0 ? size.height : layoutHeight > 0 ? layoutHeight : rect.height / rect.editScaleY; + // overlay rect = cssSize x contentScale x editScale, so the element's own + // render factor (its GSAP scale) falls out of the measured rect. 1 when + // unscaled or unmeasurable. + const rawContentScaleX = rect.width / (rect.editScaleX * actualWidth); + const rawContentScaleY = rect.height / (rect.editScaleY * actualHeight); + const contentScaleX = + Number.isFinite(rawContentScaleX) && rawContentScaleX > 0 ? rawContentScaleX : 1; + const contentScaleY = + Number.isFinite(rawContentScaleY) && rawContentScaleY > 0 ? rawContentScaleY : 1; + let resizeAnchor: GestureState["resizeAnchor"]; + if (kind === "resize") { + const startBcr = sel.element.getBoundingClientRect(); + resizeAnchor = { + anchorX: startBcr.x, + anchorY: startBcr.y, + baseGsapX: readElementGsapNumber(sel.element, "x") ?? 0, + baseGsapY: readElementGsapNumber(sel.element, "y") ?? 0, + pinX: 0, + pinY: 0, + }; + } let initialPathOffset = captureStudioPathOffset(sel.element); let manualEditDragToken: string | undefined; let pathOffsetMember: ManualOffsetDragMember | undefined; @@ -184,6 +214,9 @@ export function startGesture( actualRotation: rotation.angle, editScaleX: rect.editScaleX, editScaleY: rect.editScaleY, + contentScaleX, + contentScaleY, + resizeAnchor, manualEditDragToken, snapContext, }; diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts index bce380b30..83da0418e 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts @@ -4,6 +4,7 @@ * Owns: onPointerMove, onPointerUp, clearPointerState. * startGesture and startGroupDrag live in domEditOverlayStartGesture.ts. */ +import { setElementGsapPosition } from "../../utils/elementGsap"; import type { RefObject } from "react"; import { type DomEditSelection } from "./domEditing"; import { @@ -53,6 +54,13 @@ import { resolveEquidistanceGuides, SNAP_THRESHOLD_PX, } from "./snapEngine"; +/** Undo the resize draft's anchor pin: snap GSAP x/y back to the gesture base. */ +function restoreResizeAnchorPin(element: HTMLElement, g: GestureState): void { + const anchor = g.resizeAnchor; + if (!anchor || (anchor.pinX === 0 && anchor.pinY === 0)) return; + setElementGsapPosition(element, anchor.baseGsapX, anchor.baseGsapY); +} + export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) { const setDraftOverlayRect = (next: OverlayRect) => { opts.setOverlayRect(next); @@ -175,7 +183,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu actualAngle: g.actualRotation, snap: e.shiftKey, }); - if (!applyRotationDraftViaGsap(sel.element, rotated.angle)) { + const draftViaGsap = applyRotationDraftViaGsap(sel.element, rotated.angle); + if (!draftViaGsap) { applyStudioRotationDraft(sel.element, rotated); } return; @@ -278,12 +287,35 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu actualHeight: g.actualHeight, scaleX: g.editScaleX, scaleY: g.editScaleY, + contentScaleX: g.contentScaleX, + contentScaleY: g.contentScaleY, dx, dy, uniform: e.shiftKey, }); applyStudioBoxSizeDraft(sel.element, nextSize); - + // Pin the gesture anchor (top-left): with a live scale transform, the CSS + // size change shifts the rendered box around the element center. Measure + // the drift of the gesture-start corner and counter it via GSAP x/y — + // accumulated onto the previous pin so the correction converges instead + // of oscillating. The release-time position compensation re-measures the + // drop, so the pin composes with the commit. + const anchor = g.resizeAnchor; + if (anchor) { + const pinned = sel.element.getBoundingClientRect(); + const nextPinX = anchor.pinX + (anchor.anchorX - pinned.x); + const nextPinY = anchor.pinY + (anchor.anchorY - pinned.y); + if ( + setElementGsapPosition( + sel.element, + anchor.baseGsapX + nextPinX, + anchor.baseGsapY + nextPinY, + ) + ) { + anchor.pinX = nextPinX; + anchor.pinY = nextPinY; + } + } // Re-read BCR after applying dimensions. For elements with a GSAP // scale transform and centered transform-origin the visual top-left // drifts and the visual size diverges from the raw CSS size, so BCR @@ -382,6 +414,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu } if (g.kind === "resize" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) { + restoreResizeAnchorPin(sel.element, g); restoreStudioBoxSize(sel.element, g.initialBoxSize); endStudioManualEditGesture(sel.element, g.manualEditDragToken); if (box) { @@ -411,7 +444,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu restoreStudioRotation(sel.element, g.initialRotation); } }; - if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) { + const rotationChanged = hasDomEditRotationChanged(g.actualRotation, finalRotation.angle); + if (!rotationChanged) { restoreRotation(); endStudioManualEditGesture(sel.element, g.manualEditDragToken); return; @@ -422,14 +456,17 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu applyStudioRotation(sel.element, finalRotation); } void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation)) - .catch(() => { + .catch((error) => { + console.error("rotate commit failed", error); if ( g.manualEditDragToken && isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken) ) restoreRotation(); }) - .finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken)); + .finally(() => { + endStudioManualEditGesture(sel.element, g.manualEditDragToken); + }); } else if (g.kind === "drag") { const dx = g.lastSnappedDx ?? e.clientX - g.startX; const dy = g.lastSnappedDy ?? e.clientY - g.startY; @@ -469,12 +506,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu const finalSize = readStudioBoxSize(sel.element); applyStudioBoxSize(sel.element, finalSize); void Promise.resolve(opts.onBoxSizeCommitRef.current(sel, finalSize)) - .catch(() => { + .catch((error) => { + console.error("resize commit failed", error); if ( g.manualEditDragToken && isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken) - ) + ) { + restoreResizeAnchorPin(sel.element, g); restoreStudioBoxSize(sel.element, g.initialBoxSize); + } }) .finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken)); } @@ -494,6 +534,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu restoreGestureOverlayRect(g); } if (g?.mode === "box-size" && sel) { + restoreResizeAnchorPin(sel.element, g); restoreStudioBoxSize(sel.element, g.initialBoxSize); endStudioManualEditGesture(sel.element, g.manualEditDragToken); restoreGestureOverlayRect(g); diff --git a/packages/studio/src/hooks/gsapResizeIntercept.ts b/packages/studio/src/hooks/gsapResizeIntercept.ts new file mode 100644 index 000000000..aee92be6f --- /dev/null +++ b/packages/studio/src/hooks/gsapResizeIntercept.ts @@ -0,0 +1,379 @@ +/** + * Resize-gesture GSAP intercept: routes a manual resize on a scale-driven + * element into scale commits (per-axis longhands for non-uniform drags, with + * keyframe normalization), then settles position synchronously so the drop + * frame can't jump. Split from gsapRuntimeBridge, which owns the shared + * group-tween resolution used by the drag/resize/rotate intercepts. + */ +import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { clearStudioBoxSize } from "../components/editor/manualEdits"; +import { setElementGsapPosition } from "../utils/elementGsap"; +import { usePlayerStore } from "../player/store/playerStore"; +import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders"; +import { + commitStaticGsapPosition, + commitStaticGsapSize, + commitKeyframedSizeFromResize, + computeCurrentPercentage, + findSizeSetAnimation, + materializeIfDynamic, +} from "./gsapDragCommit"; +import type { GsapDragCommitCallbacks } from "./gsapDragCommit"; +import { pickClosestToPlayhead } from "./gsapPositionDetection"; +import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; +import { selectorFromSelection } from "./gsapShared"; +import { roundTo3 } from "../utils/rounding"; +import { resolveGroupTween, POSITION_CHANNELS } from "./gsapRuntimeBridge"; +import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes"; +import { readGsapPositionFromIframe } from "./gsapPositionDetection"; +import { findExistingPositionWrite } from "./gsapDragCommit"; +import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit"; + +const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]); + +/** Build identity (zero / one) values for each property in `source`. */ +function synthesizeIdentityProps( + source: Record, +): Record { + const id: Record = {}; + for (const [k, v] of Object.entries(source)) { + if (typeof v === "number") id[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0; + else id[k] = v; + } + return id; +} + +// ── Resize intercept ────────────────────────────────────────────────────── + +// fallow-ignore-next-line complexity +export async function tryGsapResizeIntercept( + selection: DomEditSelection, + size: { width: number; height: number }, + animations: GsapAnimation[], + iframe: HTMLIFrameElement | null, + commitMutation: GsapDragCommitCallbacks["commitMutation"], + fetchFallbackAnimations?: () => Promise, +): Promise { + // If the element already has a scale-group tween, resize should modify scale + // (the user is resizing something whose visual size is driven by scale). + // Otherwise, use the size group (width/height). + const hasScaleGroup = animations.some((a) => a.propertyGroup === "scale"); + const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size"; + const resolved = await resolveGroupTween( + resizeGroup, + animations, + selection, + commitMutation, + fetchFallbackAnimations, + ); + + let anim = resolved?.anim ?? null; + if (!anim || anim.method === "set") { + const sel = selectorFromSelection(selection); + if (!sel) return false; + const sizeSet = anim?.method === "set" ? anim : findSizeSetAnimation(animations, sel); + + // If the element is animated (has a real tween, not just a static size + // hold), keyframe the size at the playhead so other keyframes keep theirs — + // instead of a global set that resizes every frame. + if (resizeGroup === "size") { + const animatedTween = pickClosestToPlayhead( + animations.filter((a) => a.method !== "set" && resolveTweenDuration(a) > 0), + ); + if (animatedTween) { + const handled = await commitKeyframedSizeFromResize( + selection, + size, + sel, + sizeSet, + animatedTween, + { commitMutation, fetchAnimations: fetchFallbackAnimations }, + ); + if (handled) return true; + } + } + + await commitStaticGsapSize(selection, size, sel, sizeSet, { + commitMutation, + fetchAnimations: fetchFallbackAnimations, + }); + return true; + } + + const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); + const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); + if (activeKeyframePct != null) setActiveKeyframePct(null); + const coalesceKey = `gsap:resize:${anim.id}`; + + const selector = selectorFromSelection(selection); + const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; + + let resizeProps: Record; + let scaleDraftEl: HTMLElement | null = null; + let scaleDraftDropPoint: { x: number; y: number } | null = null; + let nonUniformScale = false; + if (resizeGroup === "scale") { + const el = iframe?.contentDocument?.querySelector(selector ?? "") as HTMLElement | null; + // The resize draft modifies el.style.width/height, so read the ORIGINAL + // dimensions saved by the draft system before it ran. + const origW = Number.parseFloat(el?.getAttribute("data-hf-studio-original-width") ?? ""); + const origH = Number.parseFloat(el?.getAttribute("data-hf-studio-original-height") ?? ""); + const cssW = Number.isFinite(origW) && origW > 0 ? origW : 200; + const cssH = Number.isFinite(origH) && origH > 0 ? origH : cssW; + // `size` is the draft's CSS box; on screen it is multiplied by the element's + // LIVE scale (the draft divides the cursor delta by it — see + // resolveDomEditResizeGesture). The committed keyframe REPLACES that live + // scale, so it must reproduce the rendered intent: css × live / original. + // Live scale is 1 on a fresh element (first resize), so this is a no-op there. + const rawLiveScaleX = readGsapProperty(iframe, selector ?? null, "scaleX") ?? 1; + const rawLiveScaleY = readGsapProperty(iframe, selector ?? null, "scaleY") ?? 1; + const liveScaleX = rawLiveScaleX > 0 ? rawLiveScaleX : 1; + const liveScaleY = rawLiveScaleY > 0 ? rawLiveScaleY : 1; + const newScaleX = roundTo3((size.width * liveScaleX) / cssW); + const newScaleY = roundTo3((size.height * liveScaleY) / cssH); + // A free-form corner drag is usually NON-uniform. A single `scale` value + // can't represent it — committing width-derived scale used to snap the + // height at drop. Commit scaleX/scaleY longhands instead; keep the uniform + // shorthand when the two agree (aspect-true drags, shift-drags). + nonUniformScale = Math.abs(newScaleX - newScaleY) > 0.01; + resizeProps = nonUniformScale ? { scaleX: newScaleX, scaleY: newScaleY } : { scale: newScaleX }; + scaleDraftEl = el; + // Where the user DROPPED the box: the draft (anchor-pinned to the + // gesture-start top-left) is still applied here, so this rect is exactly + // what the preview showed at release. The committed scale renders around + // the element CENTER instead — the finalize step below measures that + // difference and compensates, so release matches the drop pixel-for-pixel + // regardless of live scale or repeat resizes. + if (el) { + const dropRect = el.getBoundingClientRect(); + scaleDraftDropPoint = { x: dropRect.x, y: dropRect.y }; + } + } else { + resizeProps = { + width: Math.round(size.width), + height: Math.round(size.height), + }; + } + // Finalize a scale-route commit: tear down the gesture's inline width/height + // draft (leaving it applied compounds with the committed scale — the element + // jumps past the dragged size), then MEASURE where the committed scale + // actually rendered the box and shift the position hold by the residual so + // it lands back on the drop point. The compensation only applies to a STATIC + // position (a `tl.set` hold or none) — a keyframed position path has no + // single anchor to preserve, so it keeps the plain center-scale behavior. + // The size route commits the same width/height channels the draft wrote, so + // it needs none of this. + // ponytail: for a 3D-rotated element the rects are AABBs, so the anchor is + // approximate rather than corner-exact. + // fallow-ignore-next-line complexity + const finalizeScaleResizeCommit = async () => { + if (!scaleDraftEl) return; + clearStudioBoxSize(scaleDraftEl); + if (!scaleDraftDropPoint || !selector) return; + const hasLivePositionTween = hasNonHoldTweenForElement( + iframe, + selector, + undefined, + POSITION_CHANNELS, + ); + if (hasLivePositionTween) { + return; + } + // The scale commit has rendered (instant patch or soft-reload seek) and the + // draft is cleared — this rect is where the element ACTUALLY sits now. + const post = scaleDraftEl.getBoundingClientRect(); + const residual = { x: scaleDraftDropPoint.x - post.x, y: scaleDraftDropPoint.y - post.y }; + if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return; + if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) return; + const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 }; + // The ONE corrected position — rounded once so the live runtime and the + // persisted file agree exactly (commitStaticGsapPosition composes the same + // rounded value from this delta). + const corrected = { + x: Math.round(gsapPos.x + residual.x), + y: Math.round(gsapPos.y + residual.y), + }; + // Correct the LIVE runtime NOW, synchronously: the soft reload above just + // rendered the committed scale around the element center — NOT at the drop + // point — and everything up to here runs in the same microtask chain as + // that reload, so no frame has painted the uncorrected position yet. The + // server persist below costs network round-trips; without this set, the + // element visibly sits at the wrong spot for those frames (the drop + // "jump"). The persisted commit re-applies the same values (idempotent). + setElementGsapPosition(scaleDraftEl, corrected.x, corrected.y); + // Re-fetch: the scale commit above just rewrote the script, so the caller's + // animation list (and its ids) may be stale for the position lookup. + const currentAnimations = fetchFallbackAnimations + ? await fetchFallbackAnimations() + : (resolved?.animations ?? animations); + const existingSet = findExistingPositionWrite(currentAnimations, selector); + // Delta chosen so the drag-path math composes back to exactly `corrected` + // (no drag scratch attrs exist during a resize, so base = gsapPos). + await commitStaticGsapPosition( + selection, + { x: corrected.x - gsapPos.x, y: corrected.y - gsapPos.y }, + gsapPos, + selector, + existingSet, + { + commitMutation, + fetchAnimations: fetchFallbackAnimations, + }, + ); + }; + + // With auto-keyframe off (#1808), `anim` is already a real (non-"set") + // tween for this resize group, so nudge it as a whole rather than adding a + // keyframe at the playhead. + if (!usePlayerStore.getState().autoKeyframeEnabled) { + if (activeKeyframePct != null) setActiveKeyframePct(null); + await commitWholePropertyOffset( + selection, + anim, + resizeProps, + pct, + iframe, + { commitMutation, fetchAnimations: fetchFallbackAnimations }, + "Resize animation", + ); + await finalizeScaleResizeCommit(); + return true; + } + + const ct = usePlayerStore.getState().currentTime; + const ts = resolveTweenStart(anim); + const td = resolveTweenDuration(anim); + const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes. + // Outside-range uses the extend path which handles everything atomically. + if (!outsideRange) { + // fallow-ignore-next-line code-duplication + if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) { + const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); + if (newId) anim = { ...anim, id: newId }; + } else if (!anim.keyframes) { + const resolvedFromValues = selector + ? readAllAnimatedProperties(iframe, selector, anim) + : undefined; + await commitMutation( + selection, + { type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues }, + { label: "Convert to keyframes for resize", skipReload: true, coalesceKey }, + ); + if (fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + const refreshed = fresh.find( + (a) => a.targetSelector === anim!.targetSelector && a.keyframes, + ); + if (refreshed) anim = refreshed; + } + } + } + + // A NON-uniform scale must also take the full-rewrite path: it mixes + // scaleX/scaleY into a tween whose existing keyframes may carry the uniform + // `scale` shorthand, and GSAP's percentage keyframes animate each property + // name independently — a shorthand/longhand mix would leave the old `scale` + // sub-tween running against the new scaleX/scaleY. The rewrite below + // normalizes every keyframe to the longhands. For an in-range resize the + // min/max window math below degenerates to the tween's own start/duration, + // so timing is unchanged. + if ((outsideRange || nonUniformScale) && ts !== null) { + // For flat tweens, synthesize the keyframes from the tween's properties + const kfs = + anim.keyframes?.keyframes ?? + (() => { + const fromProps = + anim.method === "from" || anim.method === "fromTo" + ? { ...anim.properties } + : synthesizeIdentityProps(anim.properties); + const toProps = + anim.method === "from" + ? synthesizeIdentityProps(anim.properties) + : { ...anim.properties }; + return [ + { percentage: 0, properties: fromProps }, + { percentage: 100, properties: toProps }, + ]; + })(); + const newStart = Math.min(ct, ts); + const newEnd = Math.max(ct, ts + td); + const newDuration = Math.max(0.01, newEnd - newStart); + const existingKfs = kfs; + const remapped: Array<{ percentage: number; properties: Record }> = []; + for (const kf of existingKfs) { + const absTime = ts + (kf.percentage / 100) * td; + const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10; + const props = { ...kf.properties }; + // Normalize the uniform `scale` shorthand to longhands when this commit + // writes scaleX/scaleY, so the tween never mixes the two forms. + if (nonUniformScale && "scale" in props) { + const uniform = props.scale; + if (typeof uniform === "number") { + props.scaleX = uniform; + props.scaleY = uniform; + } + delete props.scale; + } + // Only backfill properties that the animation already had (x, y, scale). + // Don't backfill width/height — they should only appear on the resize keyframe. + for (const k of Object.keys(resizeProps)) { + if (k in props) continue; + if (k === "width" || k === "height") continue; + props[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0; + } + remapped.push({ percentage: newPct, properties: props }); + } + const targetPct = Math.round(((ct - newStart) / newDuration) * 1000) / 10; + // An in-range rewrite can land on an existing keyframe's percentage — + // merge into it instead of emitting a duplicate step. + const collidingKf = remapped.find((kf) => Math.abs(kf.percentage - targetPct) < 0.05); + if (collidingKf) Object.assign(collidingKf.properties, resizeProps); + else remapped.push({ percentage: targetPct, properties: resizeProps }); + remapped.sort((a, b) => a.percentage - b.percentage); + + await commitMutation( + selection, + { + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: anim.targetSelector, + position: roundTo3(newStart), + duration: roundTo3(newDuration), + keyframes: remapped, + }, + { + label: outsideRange + ? `Resize (extended to ${ct.toFixed(2)}s)` + : `Resize (keyframe ${Math.round(((ct - newStart) / newDuration) * 1000) / 10}%)`, + softReload: true, + coalesceKey, + }, + ); + await finalizeScaleResizeCommit(); + return true; + } + + const SIZE_PROPS = new Set(["width", "height"]); + const backfillDefaults: Record = {}; + for (const k of Object.keys(runtimeProps)) { + if (SIZE_PROPS.has(k)) continue; + backfillDefaults[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0; + } + + await commitMutation( + selection, + { + type: "add-keyframe", + animationId: anim.id, + percentage: pct, + properties: resizeProps, + backfillDefaults, + }, + { label: `Resize (keyframe ${pct}%)`, softReload: true, coalesceKey }, + ); + await finalizeScaleResizeCommit(); + return true; +} + +// ── Rotation intercept ──────────────────────────────────────────────────── diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index e328d5205..601532300 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -17,17 +17,14 @@ import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit"; import { commitStaticGsapPosition, commitStaticGsapRotation, - commitStaticGsapSize, - commitKeyframedSizeFromResize, commitWholePathOffset, computeCurrentPercentage, findExistingPositionWrite, findRotationSetAnimation, - findSizeSetAnimation, materializeIfDynamic, } from "./gsapDragCommit"; import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit"; -import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; +import { resolveTweenDuration } from "../utils/globalTimeCompiler"; import type { GsapDragCommitCallbacks } from "./gsapDragCommit"; import { selectorFromSelection } from "./gsapShared"; import { @@ -36,12 +33,11 @@ import { readGsapPositionFromIframe, } from "./gsapPositionDetection"; import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes"; -import { roundTo3 } from "../utils/rounding"; // Position channels — used to scope the "has a live position tween?" check so a // sibling rotation/scale animation never forces a static position hold into the // keyframe branch (which corrupts it into a frozen duration-0 keyframed tween). -const POSITION_CHANNELS = [ +export const POSITION_CHANNELS = [ "x", "y", "xPercent", @@ -67,7 +63,7 @@ const POSITION_CHANNELS = [ * re-fetch, then return the group tween * 3. null — caller must handle the missing-tween case */ -async function resolveGroupTween( +export async function resolveGroupTween( group: PropertyGroupName, animations: GsapAnimation[], selection: DomEditSelection, @@ -264,224 +260,6 @@ export { readGsapProperty, readAllAnimatedProperties }; // ── Identity-prop synthesis ─────────────────────────────────────────────── -const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]); - -/** Build identity (zero / one) values for each property in `source`. */ -function synthesizeIdentityProps( - source: Record, -): Record { - const id: Record = {}; - for (const [k, v] of Object.entries(source)) { - if (typeof v === "number") id[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0; - else id[k] = v; - } - return id; -} - -// ── Resize intercept ────────────────────────────────────────────────────── - -export async function tryGsapResizeIntercept( - selection: DomEditSelection, - size: { width: number; height: number }, - animations: GsapAnimation[], - iframe: HTMLIFrameElement | null, - commitMutation: GsapDragCommitCallbacks["commitMutation"], - fetchFallbackAnimations?: () => Promise, -): Promise { - // If the element already has a scale-group tween, resize should modify scale - // (the user is resizing something whose visual size is driven by scale). - // Otherwise, use the size group (width/height). - const hasScaleGroup = animations.some((a) => a.propertyGroup === "scale"); - const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size"; - const resolved = await resolveGroupTween( - resizeGroup, - animations, - selection, - commitMutation, - fetchFallbackAnimations, - ); - - let anim = resolved?.anim ?? null; - if (!anim || anim.method === "set") { - const sel = selectorFromSelection(selection); - if (!sel) return false; - const sizeSet = anim?.method === "set" ? anim : findSizeSetAnimation(animations, sel); - - // If the element is animated (has a real tween, not just a static size - // hold), keyframe the size at the playhead so other keyframes keep theirs — - // instead of a global set that resizes every frame. - if (resizeGroup === "size") { - const animatedTween = pickClosestToPlayhead( - animations.filter((a) => a.method !== "set" && resolveTweenDuration(a) > 0), - ); - if (animatedTween) { - const handled = await commitKeyframedSizeFromResize( - selection, - size, - sel, - sizeSet, - animatedTween, - { commitMutation, fetchAnimations: fetchFallbackAnimations }, - ); - if (handled) return true; - } - } - - await commitStaticGsapSize(selection, size, sel, sizeSet, { - commitMutation, - fetchAnimations: fetchFallbackAnimations, - }); - return true; - } - - const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); - const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); - if (activeKeyframePct != null) setActiveKeyframePct(null); - const coalesceKey = `gsap:resize:${anim.id}`; - - const selector = selectorFromSelection(selection); - const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; - - let resizeProps: Record; - if (resizeGroup === "scale") { - const el = iframe?.contentDocument?.querySelector(selector ?? "") as HTMLElement | null; - // The resize draft modifies el.style.width, so read the ORIGINAL width - // saved by the draft system before it ran. - const origW = Number.parseFloat(el?.getAttribute("data-hf-studio-original-width") ?? ""); - const cssW = Number.isFinite(origW) && origW > 0 ? origW : 200; - const newScale = roundTo3(size.width / cssW); - resizeProps = { scale: newScale }; - } else { - resizeProps = { - width: Math.round(size.width), - height: Math.round(size.height), - }; - } - - // With auto-keyframe off (#1808), `anim` is already a real (non-"set") - // tween for this resize group, so nudge it as a whole rather than adding a - // keyframe at the playhead. - if (!usePlayerStore.getState().autoKeyframeEnabled) { - if (activeKeyframePct != null) setActiveKeyframePct(null); - await commitWholePropertyOffset( - selection, - anim, - resizeProps, - pct, - iframe, - { commitMutation, fetchAnimations: fetchFallbackAnimations }, - "Resize animation", - ); - return true; - } - - const ct = usePlayerStore.getState().currentTime; - const ts = resolveTweenStart(anim); - const td = resolveTweenDuration(anim); - const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes. - // Outside-range uses the extend path which handles everything atomically. - if (!outsideRange) { - // fallow-ignore-next-line code-duplication - if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) { - const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); - if (newId) anim = { ...anim, id: newId }; - } else if (!anim.keyframes) { - const resolvedFromValues = selector - ? readAllAnimatedProperties(iframe, selector, anim) - : undefined; - await commitMutation( - selection, - { type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues }, - { label: "Convert to keyframes for resize", skipReload: true, coalesceKey }, - ); - if (fetchFallbackAnimations) { - const fresh = await fetchFallbackAnimations(); - const refreshed = fresh.find( - (a) => a.targetSelector === anim!.targetSelector && a.keyframes, - ); - if (refreshed) anim = refreshed; - } - } - } - - if (outsideRange && ts !== null) { - // For flat tweens, synthesize the keyframes from the tween's properties - const kfs = - anim.keyframes?.keyframes ?? - (() => { - const fromProps = - anim.method === "from" || anim.method === "fromTo" - ? { ...anim.properties } - : synthesizeIdentityProps(anim.properties); - const toProps = - anim.method === "from" - ? synthesizeIdentityProps(anim.properties) - : { ...anim.properties }; - return [ - { percentage: 0, properties: fromProps }, - { percentage: 100, properties: toProps }, - ]; - })(); - const newStart = Math.min(ct, ts); - const newEnd = Math.max(ct, ts + td); - const newDuration = Math.max(0.01, newEnd - newStart); - const existingKfs = kfs; - const remapped: Array<{ percentage: number; properties: Record }> = []; - for (const kf of existingKfs) { - const absTime = ts + (kf.percentage / 100) * td; - const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10; - const props = { ...kf.properties }; - // Only backfill properties that the animation already had (x, y, scale). - // Don't backfill width/height — they should only appear on the resize keyframe. - for (const k of Object.keys(resizeProps)) { - if (k in props) continue; - if (k === "width" || k === "height") continue; - props[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0; - } - remapped.push({ percentage: newPct, properties: props }); - } - const targetPct = Math.round(((ct - newStart) / newDuration) * 1000) / 10; - remapped.push({ percentage: targetPct, properties: resizeProps }); - remapped.sort((a, b) => a.percentage - b.percentage); - - await commitMutation( - selection, - { - type: "replace-with-keyframes", - animationId: anim.id, - targetSelector: anim.targetSelector, - position: roundTo3(newStart), - duration: roundTo3(newDuration), - keyframes: remapped, - }, - { label: `Resize (extended to ${ct.toFixed(2)}s)`, softReload: true, coalesceKey }, - ); - return true; - } - - const SIZE_PROPS = new Set(["width", "height"]); - const backfillDefaults: Record = {}; - for (const k of Object.keys(runtimeProps)) { - if (SIZE_PROPS.has(k)) continue; - backfillDefaults[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0; - } - - await commitMutation( - selection, - { - type: "add-keyframe", - animationId: anim.id, - percentage: pct, - properties: resizeProps, - backfillDefaults, - }, - { label: `Resize (keyframe ${pct}%)`, softReload: true, coalesceKey }, - ); - return true; -} - -// ── Rotation intercept ──────────────────────────────────────────────────── - export async function tryGsapRotationIntercept( selection: DomEditSelection, angle: number, @@ -517,7 +295,6 @@ export async function tryGsapRotationIntercept( // pointer sweep) or the inspector — so it IS the new rotation. No base re-add: the // gesture's live preview already gsap.set this value (single source of truth). const newRotation = Math.round(angle); - // STATIC case (single source of truth = GSAP timeline): no rotation tween, so the // angle belongs in a `tl.set("#el",{rotation})`, not a keyframe conversion — // mirroring the static position set. Idempotent: re-rotate updates an existing diff --git a/packages/studio/src/hooks/gsapRuntimeReaders.test.ts b/packages/studio/src/hooks/gsapRuntimeReaders.test.ts new file mode 100644 index 000000000..b65f59237 --- /dev/null +++ b/packages/studio/src/hooks/gsapRuntimeReaders.test.ts @@ -0,0 +1,111 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { + COLOR_GRADING_SOURCE_HIDDEN_ATTR, + HF_COLOR_GRADING_CANVAS_ID_PREFIX, +} from "@hyperframes/core/color-grading"; +import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders"; + +/** + * Regression: converting a property-group tween to keyframes resolves "current + * values" via readAllAnimatedProperties. Two ways that used to bake garbage + * into the composition file: + * + * 1. The group filter only pruned the tween's OWN properties — the baseline + * pass still captured every property ANY tween on the element touches, so + * a rotation commit carried `opacity` from the intro from() tween. + * 2. `gsap.getProperty(el, "opacity")` on a color-grading source reads the + * runtime hide (inline `opacity: 0 !important`), not the animated value — + * so the captured opacity was the transient 0, which then animated 0 → 0 + * on the next full load and the element disappeared. + */ + +function fakeIframe( + el: Element, + opts: { gsapValues: Record; otherTweenVars?: Record }, +): HTMLIFrameElement { + const children = opts.otherTweenVars + ? [{ targets: () => [el], vars: { duration: 0.8, ...opts.otherTweenVars } }] + : []; + return { + contentWindow: { + __timelines: { main: { getChildren: () => children } }, + gsap: { getProperty: (_el: Element, prop: string) => opts.gsapValues[prop] ?? 0 }, + }, + contentDocument: document, + } as unknown as HTMLIFrameElement; +} + +function rotationSetAnim(): GsapAnimation { + return { + id: "#clip-set-0-rotation", + targetSelector: "#clip", + method: "set", + properties: { rotation: 0 }, + } as unknown as GsapAnimation; +} + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("readAllAnimatedProperties group filter", () => { + it("keeps other tweens' out-of-group properties out of a grouped resolve", () => { + const el = document.createElement("div"); + el.id = "clip"; + document.body.appendChild(el); + const iframe = fakeIframe(el, { + gsapValues: { rotation: -28.1, opacity: 0, rotationX: 52, rotationY: -47 }, + otherTweenVars: { opacity: 0, rotationX: 52, rotationY: -47 }, + }); + + const result = readAllAnimatedProperties(iframe, "#clip", rotationSetAnim(), "rotation"); + + expect(result).toEqual({ rotation: -28.1 }); + }); +}); + +describe("color-grading opacity truth", () => { + function gradedElement(): HTMLElement { + const el = document.createElement("img"); + el.id = "clip"; + el.setAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR, ""); + const canvas = document.createElement("canvas"); + canvas.id = `${HF_COLOR_GRADING_CANVAS_ID_PREFIX}clip`; + canvas.style.opacity = "0.98"; + document.body.append(el, canvas); + return el; + } + + it("resolves opacity from the grading canvas, not the runtime hide", () => { + const el = gradedElement(); + const iframe = fakeIframe(el, { gsapValues: { opacity: 0 } }); + const anim = { + id: "#clip-from-200-visual", + targetSelector: "#clip", + method: "from", + properties: { opacity: 0.5 }, + } as unknown as GsapAnimation; + + const result = readAllAnimatedProperties(iframe, "#clip", anim); + + expect(result.opacity).toBe(0.98); + }); + + it("readGsapProperty takes the same detour", () => { + const el = gradedElement(); + const iframe = fakeIframe(el, { gsapValues: { opacity: 0 } }); + + expect(readGsapProperty(iframe, "#clip", "opacity")).toBe(0.98); + }); + + it("reads GSAP directly when the source is not grading-hidden", () => { + const el = document.createElement("div"); + el.id = "clip"; + document.body.appendChild(el); + const iframe = fakeIframe(el, { gsapValues: { opacity: 0.3 } }); + + expect(readGsapProperty(iframe, "#clip", "opacity")).toBe(0.3); + }); +}); diff --git a/packages/studio/src/hooks/gsapRuntimeReaders.ts b/packages/studio/src/hooks/gsapRuntimeReaders.ts index 966a1b850..3c78ec608 100644 --- a/packages/studio/src/hooks/gsapRuntimeReaders.ts +++ b/packages/studio/src/hooks/gsapRuntimeReaders.ts @@ -3,9 +3,33 @@ */ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { classifyPropertyGroup, type PropertyGroupName } from "@hyperframes/core/gsap-parser"; -import { getIframeGsap, queryIframeElement } from "./gsapShared"; +import { + COLOR_GRADING_SOURCE_HIDDEN_ATTR, + HF_COLOR_GRADING_CANVAS_ID_PREFIX, +} from "@hyperframes/core/color-grading"; +import { getIframeGsap, queryIframeElement, type IframeGsap } from "./gsapShared"; import { roundTo3 } from "../utils/rounding"; +/** + * The element's live value for `prop` as GSAP drives it. Opacity on a + * color-grading-hidden source needs a detour: the runtime hides the source + * with inline `opacity: 0 !important`, so computed opacity is the hide, not + * the animated value. The grading canvas mirrors the source's effective + * opacity every frame, so it is the truth for that one property — reading the + * raw 0 here is what bakes `opacity: 0` into committed keyframes. + */ +function readLiveGsapValue(gsap: IframeGsap, el: Element, prop: string): number { + if (prop === "opacity" && el.getAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR) != null && el.id) { + const canvas = el.ownerDocument.getElementById(HF_COLOR_GRADING_CANVAS_ID_PREFIX + el.id); + const win = el.ownerDocument.defaultView; + if (canvas && win) { + const val = Number(win.getComputedStyle(canvas).opacity); + if (Number.isFinite(val)) return val; + } + } + return Number(gsap.getProperty(el, prop)); +} + export function readGsapProperty( iframe: HTMLIFrameElement | null, selector: string | null, @@ -17,7 +41,7 @@ export function readGsapProperty( const el = queryIframeElement(iframe, selector); if (!el) return null; try { - const val = Number(gsap.getProperty(el, prop)); + const val = readLiveGsapValue(gsap, el, prop); if (!Number.isFinite(val)) return null; return POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val); } catch { @@ -77,15 +101,17 @@ export function readAllAnimatedProperties( for (const p of Object.keys(anim.properties)) propKeys.add(p); } - // When a group filter is specified, only keep properties belonging to that group. - if (group) { - for (const p of propKeys) { - if (classifyPropertyGroup(p) !== group) propKeys.delete(p); - } + // When a group filter is specified, only properties belonging to that group + // may enter the result — including the baseline passes below. The whole + // point of property-group tweens is that a rotation commit never carries + // opacity/rotationX/etc. captured from unrelated tweens on the element. + const inGroup = (p: string) => !group || classifyPropertyGroup(p) === group; + for (const p of propKeys) { + if (!inGroup(p)) propKeys.delete(p); } for (const prop of propKeys) { - const val = Number(gsap.getProperty(el, prop)); + const val = readLiveGsapValue(gsap, el, prop); if (Number.isFinite(val)) { result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : roundTo3(val); } @@ -110,7 +136,7 @@ export function readAllAnimatedProperties( const vars = child.vars; if (!vars) continue; for (const k of Object.keys(vars)) { - if (!GSAP_CONFIG_KEYS.has(k)) otherTweenProps.add(k); + if (!GSAP_CONFIG_KEYS.has(k) && inGroup(k)) otherTweenProps.add(k); } } } @@ -152,7 +178,7 @@ export function readAllAnimatedProperties( for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) { if (prop in result) continue; if (!allTweenedProps.has(prop)) continue; - const val = Number(gsap.getProperty(el, prop)); + const val = readLiveGsapValue(gsap, el, prop); if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) { result[prop] = roundTo3(val); } @@ -184,6 +210,7 @@ export function readAllAnimatedProperties( } catch {} for (const prop of COMPUTED_BASELINE) { if (prop in result) continue; + if (!inGroup(prop)) continue; if (otherTweenProps.has(prop)) continue; const gsapVal = Number(gsap.getProperty(el, prop)); if (!Number.isFinite(gsapVal)) continue; diff --git a/packages/studio/src/hooks/useGsapAwareEditing.ts b/packages/studio/src/hooks/useGsapAwareEditing.ts index f695595a5..9e22897a1 100644 --- a/packages/studio/src/hooks/useGsapAwareEditing.ts +++ b/packages/studio/src/hooks/useGsapAwareEditing.ts @@ -10,11 +10,8 @@ import { useCallback } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import { - tryGsapDragIntercept, - tryGsapResizeIntercept, - tryGsapRotationIntercept, -} from "./gsapRuntimeBridge"; +import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge"; +import { tryGsapResizeIntercept } from "./gsapResizeIntercept"; import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit"; import { useGsapSaveFailureTelemetry,