diff --git a/packages/studio/src/hooks/gsapRuntimeReaders.test.ts b/packages/studio/src/hooks/gsapRuntimeReaders.test.ts index b65f59237e..4e2f5485ea 100644 --- a/packages/studio/src/hooks/gsapRuntimeReaders.test.ts +++ b/packages/studio/src/hooks/gsapRuntimeReaders.test.ts @@ -1,5 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it } from "vitest"; +import { gsap } from "gsap"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { COLOR_GRADING_SOURCE_HIDDEN_ATTR, @@ -109,3 +110,50 @@ describe("color-grading opacity truth", () => { expect(readGsapProperty(iframe, "#clip", "opacity")).toBe(0.3); }); }); + +describe("readAllAnimatedProperties with a sibling tween", () => { + it("leaves a sibling tween's rotation to that tween after an opacity keyframe edit", () => { + const el = document.createElement("div"); + el.id = "clip"; + document.body.appendChild(el); + const build = (keyframes: Record>) => { + const tl = gsap.timeline({ paused: true }); + tl.to(el, { duration: 4, ease: "none", keyframes }, 0); + tl.to(el, { rotation: 90, duration: 3 }, 0); + return tl; + }; + const live = build({ "0%": { opacity: 0 }, "100%": { opacity: 1 } }); + live.seek(2); + const iframe = { + contentWindow: { __timelines: { main: live }, gsap }, + contentDocument: document, + } as unknown as HTMLIFrameElement; + const anim = { + id: "#clip-to-0-visual", + targetSelector: "#clip", + method: "to", + properties: {}, + keyframes: { + keyframes: [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 100, properties: { opacity: 1 } }, + ], + }, + } as unknown as GsapAnimation; + + // commitKeyframeProps: these values plus the edit at the playhead, and backfilled into the rest. + const read = readAllAnimatedProperties(iframe, "#clip", anim); + live.kill(); + gsap.set(el, { clearProps: "all" }); + const edited = build({ + "0%": { ...read, opacity: 0 }, + "50%": { ...read, opacity: 0.3 }, + "100%": { ...read, opacity: 1 }, + }); + edited.seek(3.2); + edited.seek(3.5); + + expect(gsap.getProperty(el, "rotation")).toBe(90); + edited.kill(); + }); +}); diff --git a/packages/studio/src/hooks/gsapRuntimeReaders.ts b/packages/studio/src/hooks/gsapRuntimeReaders.ts index 48d2ed3ffc..0bc7203ac4 100644 --- a/packages/studio/src/hooks/gsapRuntimeReaders.ts +++ b/packages/studio/src/hooks/gsapRuntimeReaders.ts @@ -8,6 +8,7 @@ import { HF_COLOR_GRADING_CANVAS_ID_PREFIX, } from "@hyperframes/core/color-grading"; import { getIframeGsap, queryIframeElement, type IframeGsap } from "./gsapShared"; +import { gsapWritesChannels } from "./gsapRuntimeKeyframes"; import { roundTo3 } from "../utils/rounding"; /** @@ -50,27 +51,6 @@ export function readGsapProperty( } export const POSITION_PROPS = new Set(["x", "y", "xPercent", "yPercent"]); -const GSAP_CONFIG_KEYS = new Set([ - "duration", - "ease", - "delay", - "stagger", - "id", - "onComplete", - "onUpdate", - "onStart", - "onRepeat", - "repeat", - "yoyo", - "repeatDelay", - "paused", - "immediateRender", - "lazy", - "overwrite", - "keyframes", - "parent", -]); - export function readAllAnimatedProperties( iframe: HTMLIFrameElement | null, selector: string, @@ -115,74 +95,7 @@ export function readAllAnimatedProperties( } } - const otherTweenProps = new Set(); - try { - const win = iframe.contentWindow as unknown as { __timelines?: Record }; - const timelines = win.__timelines; - if (timelines) { - for (const tl of Object.values(timelines)) { - const tlObj = tl as { - getChildren?: ( - deep: boolean, - ) => Array<{ targets?: () => Element[]; vars?: Record }>; - }; - if (!tlObj?.getChildren) continue; - for (const child of tlObj.getChildren(true)) { - if (typeof child.targets !== "function") continue; - const targets = child.targets(); - if (!targets.includes(el)) continue; - const vars = child.vars; - if (!vars) continue; - for (const k of Object.keys(vars)) { - if (!GSAP_CONFIG_KEYS.has(k) && inGroup(k)) otherTweenProps.add(k); - } - } - } - } - } catch {} - for (const p of groupedPropKeys) otherTweenProps.delete(p); - - // Tier 1: Transform + visual properties with universal CSS defaults. - // Safe to compare against hardcoded values — these are always 0 or 1 - // regardless of the element's stylesheet. - const UNIVERSAL_BASELINE: Record = { - opacity: 1, - scale: 1, - scaleX: 1, - scaleY: 1, - scaleZ: 1, - rotation: 0, - rotationX: 0, - rotationY: 0, - skewX: 0, - skewY: 0, - z: 0, - xPercent: 0, - yPercent: 0, - transformPerspective: 0, - blur: 0, - brightness: 1, - contrast: 1, - saturate: 1, - hueRotate: 0, - grayscale: 0, - sepia: 0, - invert: 0, - }; - // Collect all properties that ANY tween on this element explicitly targets. - // Only capture baseline values for these — GSAP reports non-default values - // (scaleZ=0, brightness=0) for untouched properties, polluting keyframes. - const allTweenedProps = new Set([...groupedPropKeys, ...otherTweenProps]); - for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) { - if (prop in result) continue; - if (!allTweenedProps.has(prop)) continue; - const val = readLiveGsapValue(gsap, el, prop); - if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) { - result[prop] = roundTo3(val); - } - } - - // Tier 2: Element-dependent properties — their "default" depends on the + // Element-dependent properties — their "default" depends on the // stylesheet, so we compare GSAP's runtime value against the element's // computed CSS value. Only capture if GSAP has actively changed it. const COMPUTED_BASELINE = [ @@ -209,7 +122,7 @@ export function readAllAnimatedProperties( for (const prop of COMPUTED_BASELINE) { if (prop in result) continue; if (!inGroup(prop)) continue; - if (otherTweenProps.has(prop)) continue; + if (gsapWritesChannels(el, [prop])) continue; const gsapVal = Number(gsap.getProperty(el, prop)); if (!Number.isFinite(gsapVal)) continue; let cssVal = NaN;