From 786802f60d816ec6c4890fe25cb0ba1e61565cc4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 21 Jul 2026 05:46:56 +0200 Subject: [PATCH] fix(parsers): preserve authored keyframe intent --- packages/parsers/src/gsapParser.test.ts | 23 +++++ packages/parsers/src/gsapParser.ts | 77 ++++++++++----- packages/parsers/src/gsapSerialize.ts | 29 ++++++ .../parsers/src/gsapWriter.parity.test.ts | 90 +++++++++++++++--- packages/parsers/src/gsapWriterAcorn.ts | 93 ++++++++++++------- 5 files changed, 245 insertions(+), 67 deletions(-) diff --git a/packages/parsers/src/gsapParser.test.ts b/packages/parsers/src/gsapParser.test.ts index 8292dadd10..ec21370a2f 100644 --- a/packages/parsers/src/gsapParser.test.ts +++ b/packages/parsers/src/gsapParser.test.ts @@ -3056,6 +3056,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", () => { diff --git a/packages/parsers/src/gsapParser.ts b/packages/parsers/src/gsapParser.ts index 917b8a537a..b217d413ee 100644 --- a/packages/parsers/src/gsapParser.ts +++ b/packages/parsers/src/gsapParser.ts @@ -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,11 @@ export function resizeKeyframedTweenInScript( match.prop.key = parseExpr(`{ ${JSON.stringify(`${to}%`)}: 0 }`).properties[0].key; } - applyUpdatesToCall(loc.target.call, { position: newPosition, duration: newDuration }); + const durationAuthored = findPropertyNode(loc.target.call.varsArg, "duration") !== undefined; + applyUpdatesToCall(loc.target.call, { + position: newPosition, + ...(durationAuthored ? { duration: newDuration } : {}), + }); return recast.print(loc.parsed.ast).code; } @@ -2501,6 +2505,21 @@ export function updateKeyframeInScript( return recast.print(arrLoc.parsed.ast).code; } + // motionPath waypoints are exposed to Studio as synthetic keyframes, while + // their source representation owns one tween-level ease. Route an ease-only + // segment edit to that authored owner instead of silently no-oping because a + // literal keyframes block does not exist. + if ( + arrLoc && + !arrVal && + ease !== undefined && + Object.keys(properties).length === 0 && + findPropertyNode(arrLoc.target.call.varsArg, "motionPath") + ) { + applyUpdatesToCall(arrLoc.target.call, { ease }); + return recast.print(arrLoc.parsed.ast).code; + } + const ctx = locateKeyframeCtx(script, animationId, percentage); if (!ctx) return script; const { loc, kfNode } = ctx; @@ -2640,19 +2659,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; + 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 diff --git a/packages/parsers/src/gsapSerialize.ts b/packages/parsers/src/gsapSerialize.ts index d4080a5338..cbabb98fb3 100644 --- a/packages/parsers/src/gsapSerialize.ts +++ b/packages/parsers/src/gsapSerialize.ts @@ -90,6 +90,35 @@ 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, an earlier ease, and endpoint auto markers survive. + */ +export function mergePercentageKeyframes( + keyframes: readonly WritableGsapPercentageKeyframe[], +): WritableGsapPercentageKeyframe[] { + const byPercentage = new Map(); + 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) existing.auto = true; + } + return [...byPercentage.values()]; +} + export type GsapKeyframeFormat = "percentage" | "object-array" | "simple-array"; export interface GsapKeyframesData { diff --git a/packages/parsers/src/gsapWriter.parity.test.ts b/packages/parsers/src/gsapWriter.parity.test.ts index 6f1e4478d4..f9e3abaf10 100644 --- a/packages/parsers/src/gsapWriter.parity.test.ts +++ b/packages/parsers/src/gsapWriter.parity.test.ts @@ -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,17 @@ 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); + }); + // 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 +1139,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 +1161,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 +1213,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 +1252,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 +1269,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 +1310,18 @@ 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}: preserves an unauthored duration`, () => { + const id = acornId(RESIZE_KF_NO_DURATION_SCRIPT); + const out = resize(RESIZE_KF_NO_DURATION_SCRIPT, id, 0.4, 2, RESIZE_REMAP); + expect(out).not.toMatch(/\bduration\s*:/); + const shape = shapeOf(out); + expect(shape.duration).toBeUndefined(); + 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 +1330,15 @@ describe("resizeKeyframedTweenInScript: preserves author intent (acorn + recast) modelOf(resizeKeyframedTweenRecast(RESIZE_KF_SCRIPT, id, 0.2, 2, RESIZE_REMAP)), ); }); + + it("parity: both writers preserve duration absence", () => { + 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 +1416,31 @@ function lastModelOf(script: string) { return arr[arr.length - 1]; } +describe("keyframe object builders: duplicate percentage merge", () => { + const duplicateKeyframes = [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 100 }, ease: "power1.in" }, + { percentage: 50, properties: { x: 120, opacity: 0.5 }, auto: true }, + { percentage: 100, properties: { x: 200 } }, + ]; + + 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"); + }); + } +}); + // 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 diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts index f1b481bee7..aeb4053773 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -17,6 +17,7 @@ import { resolveConversionProps, extractArcWaypoints, buildMotionPathObjectCode, + mergePercentageKeyframes, } from "./gsapSerialize.js"; import { parseGsapScriptAcornForWrite, @@ -865,6 +866,46 @@ function findKfPropByPct(kfNode: Node, percentage: number): { prop: Node; idx: n return best; } +function updateMotionPathEase( + script: string, + varsNode: Node, + properties: Record, + ease?: string, +): string | undefined { + if ( + ease === undefined || + Object.keys(properties).length !== 0 || + !findPropertyNode(varsNode, "motionPath") + ) { + return undefined; + } + const ms = new MagicString(script); + upsertProp(ms, varsNode, "ease", ease); + return ms.toString(); +} + +function updateObjectKeyframe( + script: string, + prop: Node, + properties: Record, + 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 = { ...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 +919,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 one ease for the whole motionPath tween. An ease-only edit + // therefore belongs on the tween instead of a nonexistent keyframes block. + return updateMotionPathEase(script, target.call.varsArg, 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 +940,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 = { ...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 +1301,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 +1325,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 }> = []; 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,7 +1394,9 @@ export function resizeKeyframedTweenInScript( ms.overwrite(keyNode.start, keyNode.end, JSON.stringify(`${to}%`)); } overwritePosition(ms, target.call, newPosition); - upsertProp(ms, target.call.varsArg, "duration", newDuration); + if (findPropertyNode(target.call.varsArg, "duration")) { + upsertProp(ms, target.call.varsArg, "duration", newDuration); + } return ms.toString(); } @@ -1555,7 +1586,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`);