mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(studio): keep a keyframe edit from copying other tweens' channels (#4918)
This commit is contained in:
@@ -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<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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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 = [
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user