Merge pull request #2673 from heygen-com/codex/studio-timeline-a-parser-intent-v2

fix(parsers): preserve authored keyframe intent
This commit is contained in:
Miguel Ángel
2026-07-25 14:43:13 +02:00
committed by GitHub
7 changed files with 346 additions and 67 deletions
+23
View File
@@ -3057,6 +3057,29 @@ describe("single position write per element (consolidation)", () => {
expect(out).not.toMatch(/gsap\.set\("#box",\s*\{\s*x:/);
expect(parseGsapScript(out).animations.some((a) => "opacity" in a.properties)).toBe(true);
});
it("remove-all-keyframes collapses synthetic motionPath keyframes to a held position", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", {
motionPath: { path: [{ x: 0, y: 10 }, { x: 200, y: 40 }] },
duration: 2,
ease: "power1.inOut",
}, 1);
`;
const motionPathTween = parseGsapScript(script).animations.find(
(animation) => animation.arcPath?.enabled,
)!;
const out = removeAllKeyframesFromScript(script, motionPathTween.id);
const kept = posWritesFor(out, "#box");
expect(out).not.toBe(script);
expect(kept).toHaveLength(1);
expect(kept[0].arcPath).toBeUndefined();
expect(kept[0].duration).toBe(0);
expect(kept[0].properties).toMatchObject({ x: 200, y: 40 });
});
});
describe("recast writer never doubles vars keys", () => {
+69 -24
View File
@@ -21,6 +21,7 @@ import {
serializeValue as valueToCode,
safeJsKey as safeKey,
resolveConversionProps,
mergePercentageKeyframes,
} from "./gsapSerialize";
export type {
@@ -1967,7 +1968,7 @@ function buildKeyframeObjectCode(
}>,
options?: { easeEach?: string },
): string {
const entries = keyframes.map((kf) => {
const entries = mergePercentageKeyframes(keyframes).map((kf) => {
const props = keyframePropsToCode(kf);
if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`);
if (kf.auto) props.push(`_auto: 1`);
@@ -2361,10 +2362,9 @@ export function removeKeyframeFromScript(
/**
* Retime a keyframe: move the keyframe at `fromPercentage` to `toPercentage`,
* PRESERVING its properties and per-keyframe ease (the Studio "Move to Playhead"
* gesture). Re-sorts keyframes by percentage. If a keyframe already exists at
* `toPercentage`, it is overwritten by the moved one (no duplicate). No-op when
* the animation/keyframe isn't found, the tween has no object-form keyframes, or
* the move resolves onto the same keyframe. Acorn twin: moveKeyframeInScript.
* gesture). Re-sorts keyframes by percentage. No-op when the animation/keyframe
* isn't found, the tween has no object-form keyframes, the move resolves onto the
* same keyframe, or the destination is occupied. Acorn twin: moveKeyframeInScript.
*/
export function moveKeyframeInScript(
script: string,
@@ -2388,19 +2388,19 @@ export function moveKeyframeInScript(
// retime, because findKeyframePropByPct resolves the destination back onto the
// from-keyframe — so a deliberate 1% drag committed nothing. Acorn twin too.
if (Math.abs(fromPercentage - toPercentage) < MOVE_NOOP_EPSILON_PCT) return script;
// A destination keyframe is only a real collision (overwrite) when it's a
// DIFFERENT keyframe; resolving back onto the from-keyframe is not.
// Never overwrite another authored keyframe. Resolving the destination back
// onto the source keyframe is not a collision (the tolerance is intentionally
// wider than MOVE_NOOP_EPSILON_PCT).
const dest = findKeyframePropByPct(kfNode, toPercentage);
const collision = dest && dest.prop !== match.prop ? dest : null;
if (dest && dest.prop !== match.prop) return script;
// Reuse each keyframe's value node verbatim (preserves properties +
// per-keyframe ease + _auto). Drop the moved keyframe (and any destination
// keyframe it overwrites), re-key the moved value to toPercentage, then re-sort.
// per-keyframe ease + _auto). Drop the moved keyframe, re-key its value to
// toPercentage, then re-sort.
const movedValue = match.prop.value;
const entries: Array<{ pct: number; value: AstNode }> = [];
for (const prop of filterPercentageProps(kfNode)) {
if (prop === match.prop) continue;
if (collision && prop === collision.prop) continue;
const pct = percentageFromKey(propKeyName(prop) ?? "");
if (Number.isNaN(pct)) continue;
entries.push({ pct, value: prop.value });
@@ -2452,7 +2452,12 @@ export function resizeKeyframedTweenInScript(
match.prop.key = parseExpr(`{ ${JSON.stringify(`${to}%`)}: 0 }`).properties[0].key;
}
applyUpdatesToCall(loc.target.call, { position: newPosition, duration: newDuration });
applyUpdatesToCall(loc.target.call, {
position: newPosition,
// Resizing is an explicit duration-authoring gesture. Promote GSAP's
// implicit default so the dragged window is the window that plays.
duration: newDuration,
});
return recast.print(loc.parsed.ast).code;
}
@@ -2501,6 +2506,36 @@ export function updateKeyframeInScript(
return recast.print(arrLoc.parsed.ast).code;
}
// motionPath waypoints are exposed as synthetic keyframes. Their positions
// live in `motionPath.path`, while one tween-level ease owns every segment.
// Commit both parts when a Studio gesture carries x/y + ease.
if (arrLoc && !arrVal && arrLoc.target.animation.arcPath?.enabled) {
const propertyKeys = Object.keys(properties);
if (propertyKeys.some((key) => key !== "x" && key !== "y")) return script;
let next = script;
if (propertyKeys.length > 0) {
const waypoints = extractArcWaypoints(arrLoc.target.animation);
if (waypoints.length < 2) return script;
const pointIndex = Math.max(
0,
Math.min(waypoints.length - 1, Math.round((percentage / 100) * (waypoints.length - 1))),
);
const current = waypoints[pointIndex];
if (!current) return script;
const x = properties.x ?? current.x;
const y = properties.y ?? current.y;
if (typeof x !== "number" || typeof y !== "number") return script;
next = updateMotionPathPointInScript(next, animationId, pointIndex, { x, y });
}
if (ease !== undefined) {
const updated = locateAnimationWithFallback(next, animationId);
if (!updated) return script;
applyUpdatesToCall(updated.target.call, { ease });
next = recast.print(updated.parsed.ast).code;
}
return next;
}
const ctx = locateKeyframeCtx(script, animationId, percentage);
if (!ctx) return script;
const { loc, kfNode } = ctx;
@@ -2640,19 +2675,29 @@ export function removeAllKeyframesFromScript(script: string, animationId: string
const kfNode =
findKeyframesObjectNode(loc.target.call.varsArg) ??
convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;
const kfEntries = filterPercentageProps(kfNode)
.map((p: AstNode) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p }))
.filter((e) => !Number.isNaN(e.pct))
.sort((a, b) => a.pct - b.pct);
if (kfEntries.length === 0) return script;
// For to()/set(): collapse to last keyframe (the destination = visible state).
// For from(): collapse to first keyframe (the starting state).
const method = loc.target.call.method;
const collapseEntry = method === "from" ? kfEntries[0]! : kfEntries[kfEntries.length - 1]!;
const record = objectExpressionToRecord(collapseEntry.prop.value, loc.parsed.scope);
let record: Record<string, unknown>;
if (kfNode) {
const kfEntries = filterPercentageProps(kfNode)
.map((p: AstNode) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p }))
.filter((e) => !Number.isNaN(e.pct))
.sort((a, b) => a.pct - b.pct);
if (kfEntries.length === 0) return script;
const collapseEntry = method === "from" ? kfEntries[0]! : kfEntries[kfEntries.length - 1]!;
record = objectExpressionToRecord(collapseEntry.prop.value, loc.parsed.scope);
} else {
// motionPath waypoints are exposed to Studio as synthetic keyframes. They
// have no literal `keyframes` node, so collapse the parsed endpoint and
// remove the authored path instead of silently returning the original file.
const synthetic = loc.target.animation.arcPath?.enabled
? loc.target.animation.keyframes?.keyframes
: undefined;
if (!synthetic?.length) return script;
const sorted = [...synthetic].sort((a, b) => a.percentage - b.percentage);
record = (method === "from" ? sorted[0] : sorted[sorted.length - 1])!.properties;
removeVarsKey(loc.target.call.varsArg, "motionPath");
}
collapseKeyframesToFlat(loc.target.call.varsArg, record);
// Removing ALL keyframes HOLDS the element statically — collapse to a
// zero-duration immediateRender tween (a `gsap.set` equivalent), dropping the
+30
View File
@@ -90,6 +90,36 @@ export interface GsapPercentageKeyframe {
ease?: string;
}
export interface WritableGsapPercentageKeyframe extends GsapPercentageKeyframe {
auto?: boolean;
}
/**
* Collapse duplicate percentage entries before serializing an object literal.
* Matches addKeyframeToScript's merge contract: later properties/ease win while
* unrelated authored properties and an earlier ease survive. An explicit later
* `auto` value wins, and output is always sorted by percentage.
*/
export function mergePercentageKeyframes(
keyframes: readonly WritableGsapPercentageKeyframe[],
): WritableGsapPercentageKeyframe[] {
const byPercentage = new Map<number, WritableGsapPercentageKeyframe>();
for (const keyframe of keyframes) {
const existing = byPercentage.get(keyframe.percentage);
if (!existing) {
byPercentage.set(keyframe.percentage, {
...keyframe,
properties: { ...keyframe.properties },
});
continue;
}
existing.properties = { ...existing.properties, ...keyframe.properties };
if (keyframe.ease !== undefined) existing.ease = keyframe.ease;
if (keyframe.auto !== undefined) existing.auto = keyframe.auto;
}
return [...byPercentage.values()].sort((a, b) => a.percentage - b.percentage);
}
export type GsapKeyframeFormat = "percentage" | "object-array" | "simple-array";
export interface GsapKeyframesData {
+103 -12
View File
@@ -1049,6 +1049,10 @@ const UPD_ARRAY_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { keyframes: [{ x: 0, y: 0 }, { x: 50, y: 80 }, { x: 100, y: 0 }], duration: 1 }, 0.2);
`;
const UPD_MOTION_PATH_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#title", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 40 }] }, duration: 1, ease: "power1.inOut" }, 0.2);
`;
describe("parity: updateKeyframeInScript (recast vs acorn)", () => {
function expectParity(
@@ -1100,6 +1104,30 @@ describe("parity: updateKeyframeInScript (recast vs acorn)", () => {
);
});
it("updates the authored tween ease for synthetic motion-path keyframes", () => {
const id = acornId(UPD_MOTION_PATH_SCRIPT);
const ease = "spring(0.42)";
const acorn = updateKeyframeAcorn(UPD_MOTION_PATH_SCRIPT, id, 100, {}, ease);
const recast = updateKeyframeRecast(UPD_MOTION_PATH_SCRIPT, id, 100, {}, ease);
expect(modelOf(acorn)).toEqual(modelOf(recast));
expect(parseGsapScript(acorn).animations[0]?.ease).toBe(ease);
expect(parseGsapScript(recast).animations[0]?.ease).toBe(ease);
});
it("updates a synthetic motion-path point and its tween ease atomically", () => {
const id = acornId(UPD_MOTION_PATH_SCRIPT);
const ease = "spring(0.42)";
const properties = { x: 140, y: 60 };
const acorn = updateKeyframeAcorn(UPD_MOTION_PATH_SCRIPT, id, 100, properties, ease);
const recast = updateKeyframeRecast(UPD_MOTION_PATH_SCRIPT, id, 100, properties, ease);
expect(modelOf(acorn)).toEqual(modelOf(recast));
const animation = parseGsapScript(acorn).animations[0];
expect(animation?.ease).toBe(ease);
expect(animation?.keyframes?.keyframes.at(-1)?.properties).toMatchObject(properties);
});
// KNOWN DIVERGENCE (acorn-array bug, follow-up — NOT a test artifact):
// For PARTIAL props on ARRAY-form keyframes the two writers disagree. recast's
// array branch (gsapParser.updateKeyframeInScript) does a whole-value REPLACE
@@ -1124,8 +1152,9 @@ describe("parity: updateKeyframeInScript (recast vs acorn)", () => {
// ── moveKeyframeInScript (retime: preserve value + ease) ─────────────────────
// "Move to Playhead" retimes a keyframe in time, keeping its properties and
// per-keyframe ease. The moved keyframe must vanish from the source percentage
// and reappear (with identical value + ease) at the destination; a destination
// collision is overwritten, not duplicated. recast and acorn must agree.
// and reappear (with identical value + ease) at the destination. An occupied
// destination rejects the retime without mutating authored data. Both writers
// must agree.
const MOVE_KF_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { x: 0 }, "50%": { x: 100, opacity: 0.5, ease: "power2.in" }, "100%": { x: 200 } }, duration: 1 }, 0.2);
@@ -1145,16 +1174,10 @@ describe("moveKeyframeInScript: retime preserves value + ease (acorn) ", () => {
expect(pcts).not.toContain(50);
});
it("overwrites the destination keyframe on collision (no duplicate)", () => {
it("rejects an occupied destination without mutating either writer", () => {
const id = acornId(MOVE_KF_SCRIPT);
const out = moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 50, 100);
const kfs = shapeOf(out).keyframes?.keyframes ?? [];
const pcts = kfs.map((k) => k.percentage);
expect(pcts).toEqual([0, 100]);
const dest = kfs.find((k) => k.percentage === 100)!;
// The moved keyframe's value + ease replaced the old 100% { x: 200 }.
expect(dest.properties).toEqual({ x: 100, opacity: 0.5 });
expect(dest.ease).toBe("power2.in");
expect(moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 50, 100)).toBe(MOVE_KF_SCRIPT);
expect(moveKeyframeRecast(MOVE_KF_SCRIPT, id, 50, 100)).toBe(MOVE_KF_SCRIPT);
});
it("no-ops only for a negligible move (below the drag epsilon)", () => {
@@ -1203,7 +1226,7 @@ describe("parity: moveKeyframeInScript (recast vs acorn)", () => {
expectParity(MOVE_KF_SCRIPT, 50, 10);
});
it("retime onto an existing percentage (collision overwrite)", () => {
it("reject an occupied destination", () => {
expectParity(MOVE_KF_SCRIPT, 50, 100);
});
@@ -1242,6 +1265,12 @@ describe("moveKeyframeInScript: array-form keyframes (recast + acorn parity)", (
modelOf(moveKeyframeRecast(KF_ADD_ARRAY_SCRIPT, id, 50, 75)),
);
});
it("leaves array-form source untouched when the destination is occupied", () => {
const id = acornId(KF_ADD_ARRAY_SCRIPT);
expect(moveKeyframeAcorn(KF_ADD_ARRAY_SCRIPT, id, 50, 100)).toBe(KF_ADD_ARRAY_SCRIPT);
expect(moveKeyframeRecast(KF_ADD_ARRAY_SCRIPT, id, 50, 100)).toBe(KF_ADD_ARRAY_SCRIPT);
});
});
// ── resizeKeyframedTweenInScript (boundary drag: re-key + grow window) ────────
@@ -1253,6 +1282,10 @@ const RESIZE_KF_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { opacity: 0, _auto: 1 }, "50%": { opacity: 0.5, ease: "power2.in" }, "100%": { opacity: 1, _auto: 1 }, easeEach: "power1.inOut" }, duration: 1, ease: "power3.out" }, 0.2);
`;
const RESIZE_KF_NO_DURATION_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.5, ease: "power2.in" }, "100%": { opacity: 1 } }, ease: "power3.out" }, 0.2);
`;
// Window [0.2, 1.2]; drag the last keyframe (abs 1.2) out to abs 2.2 → [0.2, 2.2].
// abs 0.2/0.7/2.2 over the new 2.0s window → 0 / 25 / 100.
const RESIZE_REMAP = [
@@ -1290,6 +1323,17 @@ describe("resizeKeyframedTweenInScript: preserves author intent (acorn + recast)
it(`${label}: no-ops on unknown id`, () => {
expect(resize(RESIZE_KF_SCRIPT, "bad-id", 0.2, 2, RESIZE_REMAP)).toBe(RESIZE_KF_SCRIPT);
});
it(`${label}: authors the duration set by the resize gesture`, () => {
const id = acornId(RESIZE_KF_NO_DURATION_SCRIPT);
const out = resize(RESIZE_KF_NO_DURATION_SCRIPT, id, 0.4, 2, RESIZE_REMAP);
const shape = shapeOf(out);
expect(shape.duration).toBe(2);
expect(parseGsapScript(out).animations[0]!.position).toBeCloseTo(0.4, 5);
expect(shape.keyframes?.keyframes.map((keyframe) => keyframe.percentage)).toEqual([
0, 25, 100,
]);
});
}
it("parity: both writers reparse to the same model", () => {
@@ -1298,6 +1342,15 @@ describe("resizeKeyframedTweenInScript: preserves author intent (acorn + recast)
modelOf(resizeKeyframedTweenRecast(RESIZE_KF_SCRIPT, id, 0.2, 2, RESIZE_REMAP)),
);
});
it("parity: both writers promote an implicit duration to the resized value", () => {
const id = acornId(RESIZE_KF_NO_DURATION_SCRIPT);
expect(
modelOf(resizeKeyframedTweenAcorn(RESIZE_KF_NO_DURATION_SCRIPT, id, 0.4, 2, RESIZE_REMAP)),
).toEqual(
modelOf(resizeKeyframedTweenRecast(RESIZE_KF_NO_DURATION_SCRIPT, id, 0.4, 2, RESIZE_REMAP)),
);
});
});
// Regression: same array-form gap as moveKeyframeInScript above — boundary
@@ -1375,6 +1428,44 @@ function lastModelOf(script: string) {
return arr[arr.length - 1];
}
describe("keyframe object builders: duplicate percentage merge", () => {
const duplicateKeyframes = [
{ percentage: 100, properties: { x: 200 } },
{ percentage: 50, properties: { x: 100 }, ease: "power1.in" },
{ percentage: 50, properties: { x: 120, opacity: 0.5 }, auto: true },
{ percentage: 0, properties: { x: 0 } },
];
for (const [label, add] of [
["acorn", addWithKfAcorn],
["recast", addWithKfRecast],
] as const) {
it(`${label}: emits one merged key and preserves authored properties and ease`, () => {
const out = add(ADD_WITH_KF_BASE, "#hero", 0, 1, duplicateKeyframes).script;
expect(out.match(/["']50%["']\s*:/g)).toHaveLength(1);
const atFifty = lastModelOf(out)?.keyframes?.keyframes.filter(
(keyframe) => keyframe.percentage === 50,
);
expect(atFifty).toHaveLength(1);
expect(atFifty?.[0]?.properties).toEqual({ x: 120, opacity: 0.5, _auto: 1 });
expect(atFifty?.[0]?.ease).toBe("power1.in");
expect(lastModelOf(out)?.keyframes?.keyframes.map((keyframe) => keyframe.percentage)).toEqual(
[0, 50, 100],
);
});
}
it("an explicit later auto=false clears an earlier synthetic marker", () => {
const out = addWithKfAcorn(ADD_WITH_KF_BASE, "#hero", 0, 1, [
{ percentage: 0, properties: { x: 0 }, auto: true },
{ percentage: 0, properties: { opacity: 1 }, auto: false },
{ percentage: 100, properties: { x: 100 } },
]).script;
expect(lastModelOf(out)?.keyframes?.keyframes[0]?.properties).toEqual({ x: 0, opacity: 1 });
});
});
// NOTE (WS-3.F): recast is retired, so `recast` here is an alias of the acorn
// writer and the historical `toEqual(lastModelOf(recast))` comparisons are
// tautologies. The WS-3.C ops below instead pin the acorn output as golden
+92 -30
View File
@@ -17,6 +17,7 @@ import {
resolveConversionProps,
extractArcWaypoints,
buildMotionPathObjectCode,
mergePercentageKeyframes,
} from "./gsapSerialize.js";
import {
parseGsapScriptAcornForWrite,
@@ -865,6 +866,77 @@ function findKfPropByPct(kfNode: Node, percentage: number): { prop: Node; idx: n
return best;
}
function updateMotionPathPosition(
script: string,
target: ParsedGsapAcornForWrite["located"][number],
percentage: number,
properties: Record<string, number | string>,
): string | undefined {
const waypoints = extractArcWaypoints(target.animation);
if (waypoints.length < 2) return undefined;
const pointIndex = Math.max(
0,
Math.min(waypoints.length - 1, Math.round((percentage / 100) * (waypoints.length - 1))),
);
const current = waypoints[pointIndex];
if (!current) return undefined;
const x = properties.x ?? current.x;
const y = properties.y ?? current.y;
if (typeof x !== "number" || typeof y !== "number") return undefined;
return updateMotionPathPointInScript(script, target.id, pointIndex, { x, y });
}
function updateTweenEase(script: string, animationId: string, ease: string): string | undefined {
const reparsed = parseGsapScriptAcornForWrite(script);
const target = reparsed?.located.find((entry) => entry.id === animationId);
if (!target) return undefined;
const ms = new MagicString(script);
upsertProp(ms, target.call.varsArg, "ease", ease);
return ms.toString();
}
function updateMotionPathKeyframe(
script: string,
target: ParsedGsapAcornForWrite["located"][number],
percentage: number,
properties: Record<string, number | string>,
ease?: string,
): string | undefined {
if (!target.animation.arcPath?.enabled || !findPropertyNode(target.call.varsArg, "motionPath")) {
return undefined;
}
const propertyKeys = Object.keys(properties);
if (propertyKeys.some((key) => key !== "x" && key !== "y")) return undefined;
const next =
propertyKeys.length === 0
? script
: updateMotionPathPosition(script, target, percentage, properties);
if (next === undefined || ease === undefined) return next;
return updateTweenEase(next, target.id, ease);
}
function updateObjectKeyframe(
script: string,
prop: Node,
properties: Record<string, number | string>,
ease?: string,
): string {
const ms = new MagicString(script);
// Merge into the authored object so an edit cannot discard unrelated
// keyframed properties at the same percentage.
if (prop.value?.type === "ObjectExpression") {
for (const [key, value] of Object.entries(properties)) {
upsertProp(ms, prop.value, key, value);
}
if (ease !== undefined) upsertProp(ms, prop.value, "ease", ease);
} else {
const record: Record<string, number | string> = { ...properties };
if (ease) record.ease = ease;
ms.overwrite(prop.value.start, prop.value.end, recordToCode(record));
}
return ms.toString();
}
export function updateKeyframeInScript(
script: string,
animationId: string,
@@ -878,7 +950,12 @@ export function updateKeyframeInScript(
if (!target) return script;
const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes");
if (!kfPropNode) return script;
if (!kfPropNode) {
// motionPath waypoints are exposed to Studio as synthetic keyframes, but
// GSAP authors their positions in `motionPath.path` and one ease for the
// whole tween. Commit both parts when a Studio gesture carries x/y + ease.
return updateMotionPathKeyframe(script, target, percentage, properties, ease) ?? script;
}
// Array-form keyframes (`keyframes: [{x,y}, ...]`) carry no explicit percentages
// — GSAP distributes them evenly, and the runtime read assigns even percentages
@@ -894,23 +971,7 @@ export function updateKeyframeInScript(
const match = findKfPropByPct(kfPropNode.value, percentage);
if (!match) return script;
const ms = new MagicString(script);
// MERGE the edited props into the existing keyframe, preserving properties already
// keyframed at this percentage (z, transformPerspective, rotation, …). A whole-value
// overwrite DROPS every prop not in this edit — e.g. editing rotationY at the 0%
// keyframe would strip z / transformPerspective, so the lens then animates from 0 and
// the element pops. Mirrors addKeyframeToScript's merge-into-existing branch.
if (match.prop.value?.type === "ObjectExpression") {
for (const [k, v] of Object.entries(properties)) {
upsertProp(ms, match.prop.value, k, v);
}
if (ease !== undefined) upsertProp(ms, match.prop.value, "ease", ease);
} else {
const record: Record<string, number | string> = { ...properties };
if (ease) record.ease = ease;
ms.overwrite(match.prop.value.start, match.prop.value.end, recordToCode(record));
}
return ms.toString();
return updateObjectKeyframe(script, match.prop, properties, ease);
}
// ponytail: even-spacing index map; if array keyframes ever carry per-element
@@ -1271,10 +1332,9 @@ export function removeKeyframeFromScript(
/**
* Retime a keyframe: move the keyframe at `fromPercentage` to `toPercentage`,
* PRESERVING its properties and per-keyframe ease (the Studio "Move to Playhead"
* gesture). Re-sorts keyframes by percentage. If a keyframe already exists at
* `toPercentage`, it is overwritten by the moved one (no duplicate). No-op when
* the animation/keyframe isn't found, the tween has no object-form keyframes, or
* the move resolves onto the same keyframe.
* gesture). Re-sorts keyframes by percentage. No-op when the animation/keyframe
* isn't found, the tween has no object-form keyframes, the move resolves onto the
* same keyframe, or the destination is occupied.
*/
export function moveKeyframeInScript(
script: string,
@@ -1296,18 +1356,18 @@ export function moveKeyframeInScript(
// retime, because findKfPropByPct resolves the destination back onto the
// from-keyframe — so a deliberate 1% drag committed nothing.
if (Math.abs(fromPercentage - toPercentage) < MOVE_NOOP_EPSILON_PCT) return src;
// A destination keyframe is only a real collision (overwrite) when it's a
// DIFFERENT keyframe; resolving back onto the from-keyframe is not.
// Never overwrite another authored keyframe. Resolving the destination back
// onto the source keyframe is not a collision (the tolerance is intentionally
// wider than MOVE_NOOP_EPSILON_PCT).
const dest = findKfPropByPct(kfNode, toPercentage);
const collision = dest && dest.prop !== match.prop ? dest : null;
if (dest && dest.prop !== match.prop) return script;
// Rebuild the keyframes object: drop the moved keyframe (and any keyframe at
// the destination it overwrites), re-key the moved record to toPercentage,
// then re-sort. recordToCode round-trips properties + per-keyframe ease + _auto.
// Rebuild the keyframes object: drop the moved keyframe, re-key the moved
// record to toPercentage, then re-sort. recordToCode round-trips properties +
// per-keyframe ease + _auto.
const entries: Array<{ pct: number; record: Record<string, number | string> }> = [];
for (const prop of percentagePropsOf(kfNode)) {
if (prop === match.prop) continue;
if (collision && prop === collision.prop) continue;
const pct = percentageFromKey(propKeyName(prop) ?? "");
if (Number.isNaN(pct)) continue;
entries.push({ pct, record: valueNodeToRecord(prop.value, src) });
@@ -1365,6 +1425,8 @@ export function resizeKeyframedTweenInScript(
ms.overwrite(keyNode.start, keyNode.end, JSON.stringify(`${to}%`));
}
overwritePosition(ms, target.call, newPosition);
// Resizing is an explicit duration-authoring gesture. Promote GSAP's implicit
// default to source so the dragged window is the window that plays.
upsertProp(ms, target.call.varsArg, "duration", newDuration);
return ms.toString();
}
@@ -1555,7 +1617,7 @@ function buildKeyframeObjectCode(
}>,
easeEach?: string,
): string {
const entries = keyframes.map((kf) => {
const entries = mergePercentageKeyframes(keyframes).map((kf) => {
const props = Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`);
if (kf.auto) props.push(`_auto: 1`);
@@ -283,6 +283,21 @@ function mockFetchResult(over: Partial<MutationResult> = {}): void {
}
describe("runCommit — instantPatch wiring", () => {
it("explains a deliberate mutation that the server safely rejected as unchanged", async () => {
mockFetchResult({ changed: false });
const deps = renderCommitHook();
await act(async () => {
await deps.api.commitMutation(
selection,
{ type: "move-keyframe", fromPercentage: 50, toPercentage: 100 },
{ label: "Move keyframe" },
);
});
expect(deps.showToast).toHaveBeenCalledWith("A keyframe already exists at that time", "info");
});
it("no-op commit with an instantPatch still patches the runtime (paired x/y commits)", async () => {
patchRuntimeTweenInPlace.mockReturnValue(true);
mockFetchResult({ changed: false });
@@ -77,6 +77,17 @@ async function mutateGsapScriptBatch(
type ShowToast = (message: string, tone?: "error" | "info") => void;
function showUnchangedMutationFeedback(
mutations: Record<string, unknown>[],
result: MutationResult,
showToast: ShowToast | undefined,
): void {
if (result.changed !== false || mutations.length !== 1) return;
if (mutations[0]?.type === "move-keyframe") {
showToast?.("A keyframe already exists at that time", "info");
}
}
async function runMutationRequest(
mutations: Record<string, unknown>[],
options: CommitMutationOptions,
@@ -94,7 +105,9 @@ async function runMutationRequest(
);
}
try {
return await request();
const result = await request();
showUnchangedMutationFeedback(mutations, result, showToast);
return result;
} catch (error) {
if (error instanceof GsapMutationHttpError) {
showToast?.(formatGsapMutationRejectionToast(error), "error");