Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/studio/src/hooks/gsapRuntimeReaders.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string, Record<string, number>>) => {
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();
});
});
93 changes: 3 additions & 90 deletions packages/studio/src/hooks/gsapRuntimeReaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -115,74 +95,7 @@ export function readAllAnimatedProperties(
}
}

const otherTweenProps = new Set<string>();
try {
const win = iframe.contentWindow as unknown as { __timelines?: Record<string, unknown> };
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<string, unknown> }>;
};
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<string, number> = {
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 = [
Expand All @@ -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;
Expand Down
Loading