mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
fix(core,sdk): rebuild acorn GSAP keyframe writer for recast parity (#1520)
The acorn addKeyframeToScript mixed ms.overwrite + ms.appendLeft on the
same _auto endpoint node, crashing MagicString ("Cannot split a chunk
that has already been edited") whenever an interior keyframe adjacent to
an _auto 0/100 endpoint introduced a new backfilled prop — the common SDK
path. It also replaced (not merged) existing keyframes, dropped ease and
_auto markers, corrupted commas on multi-prop backfill into empty {},
used a <0.001 percentage tolerance instead of recast's PCT_TOLERANCE=2,
and silently no-op'd on flat (non-keyframe) tweens.
Rebuild the node model to mirror recast: compute the FINAL property record
for every changed keyframe value node (target merge, _auto endpoint sync,
backfilled siblings) against the original AST, then emit exactly one
ms.overwrite per changed node (one insert for a brand-new key). No node is
ever both overwritten and appended into, so splices can never overlap.
- Merge: re-touching an existing keyframe merges new props over the
existing record, preserving untouched props, existing ease, and _auto.
- Convert-flat: first keyframe-add on a flat to()/from()/fromTo() tween
rebuilds its vars object to percentage keyframes (ease->easeEach,
ease:"none", from/fromTo->to) matching recast, then re-locates via the
-from-/-fromTo- -> -to- id fallback.
- Tolerance: PCT_TOLERANCE=2 for existing-keyframe detection.
- Shared serializeValue/safeJsKey for keyframe values (recast parity); the
tween-statement path keeps its local serializer for object/boolean extras.
- keyframeBackfill: only backfill props with a real numeric default; skip
unknown/string props so color:0 / filter:0 are never emitted.
- setGsapKeyframe move-path threads the same backfill defaults as the add
path so both entry points behave identically.
Differential tests (acorn vs recast parsed keyframe arrays) cover the
crash (2-endpoint + 0/25/100), empty-{} multi-prop backfill, merge with
extra props + ease, flat to()/fromTo() convert, "50.0%" non-byte-equal
key, near-% tolerance, and _auto-marker preservation onto an endpoint.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0ba52fc130
commit
b5ad518957
@@ -7,7 +7,7 @@
|
||||
* pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts.
|
||||
*/
|
||||
import MagicString from "magic-string";
|
||||
import type { GsapAnimation } from "./gsapSerialize.js";
|
||||
import { serializeValue, safeJsKey, type GsapAnimation } from "./gsapSerialize.js";
|
||||
import {
|
||||
parseGsapScriptAcornForWrite,
|
||||
type ParsedGsapAcornForWrite,
|
||||
@@ -17,6 +17,10 @@ import * as acornWalk from "acorn-walk";
|
||||
|
||||
// ── Code generation helpers ──────────────────────────────────────────────────
|
||||
|
||||
// Local serializer for the tween-statement path, which may carry boolean/object
|
||||
// extras (stagger config). serializeValue stringifies objects to "[object
|
||||
// Object]", so keep this richer JSON fallback for that path. Keyframe values are
|
||||
// always number|string and use the shared serializeValue (recast parity).
|
||||
function valueToCode(value: unknown): string {
|
||||
if (typeof value === "string" && value.startsWith("__raw:")) return value.slice(6);
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
@@ -264,31 +268,248 @@ export function removeAnimationFromScript(script: string, animationId: string):
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
// ── Flat-tween → keyframes conversion ──────────────────────────────────────────
|
||||
//
|
||||
// Mirror recast's convertToKeyframesInScript: when the first keyframe op lands
|
||||
// on a flat to()/from()/fromTo() tween, rewrite its vars object to
|
||||
// `{ keyframes: { "0%": {from}, "100%": {to} }, <preserved non-editable keys>,
|
||||
// ease: "none"? }` and convert from()/fromTo() to to(). We rebuild the whole
|
||||
// vars ObjectExpression in one ms.overwrite (single-edit-per-node), so the next
|
||||
// keyframe-add re-parses cleanly.
|
||||
|
||||
// Identity value for an editable transform/style prop (recast's CSS_IDENTITY).
|
||||
const CSS_IDENTITY: Record<string, number> = {
|
||||
opacity: 1,
|
||||
autoAlpha: 1,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
};
|
||||
|
||||
function cssIdentityValue(prop: string): number {
|
||||
return CSS_IDENTITY[prop] ?? 0;
|
||||
}
|
||||
|
||||
// Keys NOT in the editable set — preserved verbatim on the converted vars object
|
||||
// (matches the parser's classification: builtin/dropped/extras keys).
|
||||
const NON_EDITABLE_VAR_KEYS = new Set([
|
||||
"duration",
|
||||
"delay",
|
||||
"onComplete",
|
||||
"onStart",
|
||||
"onUpdate",
|
||||
"onRepeat",
|
||||
"stagger",
|
||||
"yoyo",
|
||||
"repeat",
|
||||
"repeatDelay",
|
||||
"snap",
|
||||
"overwrite",
|
||||
"immediateRender",
|
||||
]);
|
||||
|
||||
/** The CSS-identity counterpart of a props record (numbers → identity value). */
|
||||
function identityProps(
|
||||
properties: Record<string, number | string>,
|
||||
): Record<string, number | string> {
|
||||
const identity: Record<string, number | string> = {};
|
||||
for (const [k, v] of Object.entries(properties)) {
|
||||
if (v != null) identity[k] = typeof v === "number" ? cssIdentityValue(k) : v;
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
/** Resolve the 0%/100% endpoint records for a tween being converted. */
|
||||
function conversionEndpoints(animation: GsapAnimation): {
|
||||
fromProps: Record<string, number | string>;
|
||||
toProps: Record<string, number | string>;
|
||||
} {
|
||||
if (animation.method === "from") {
|
||||
return { fromProps: { ...animation.properties }, toProps: identityProps(animation.properties) };
|
||||
}
|
||||
if (animation.method === "fromTo") {
|
||||
return {
|
||||
fromProps: { ...(animation.fromProperties ?? {}) },
|
||||
toProps: { ...animation.properties },
|
||||
};
|
||||
}
|
||||
// to(): 0% is the CSS identity state, 100% is the authored props.
|
||||
return { fromProps: identityProps(animation.properties), toProps: { ...animation.properties } };
|
||||
}
|
||||
|
||||
/** Collect preserved (non-editable) `key: value` entries from the original vars node. */
|
||||
function preservedVarsEntries(varsNode: any, source: string): string[] {
|
||||
const entries: string[] = [];
|
||||
if (varsNode?.type !== "ObjectExpression") return entries;
|
||||
for (const prop of varsNode.properties ?? []) {
|
||||
if (!isObjectProperty(prop)) continue;
|
||||
const key = propKeyName(prop);
|
||||
if (typeof key !== "string" || !NON_EDITABLE_VAR_KEYS.has(key)) continue;
|
||||
entries.push(`${safeKey(key)}: ${source.slice(prop.value.start, prop.value.end)}`);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Build the rebuilt vars-object code for a converted flat tween. */
|
||||
function buildConvertedVarsCode(animation: GsapAnimation, varsNode: any, source: string): string {
|
||||
const { fromProps, toProps } = conversionEndpoints(animation);
|
||||
const easeEach = animation.ease;
|
||||
const easeEachEntry = easeEach ? `, easeEach: ${JSON.stringify(easeEach)}` : "";
|
||||
const kfCode = `{ "0%": ${recordToCode(fromProps)}, "100%": ${recordToCode(toProps)}${easeEachEntry} }`;
|
||||
const entries = [`keyframes: ${kfCode}`, ...preservedVarsEntries(varsNode, source)];
|
||||
if (easeEach) entries.push(`ease: "none"`);
|
||||
return `{ ${entries.join(", ")} }`;
|
||||
}
|
||||
|
||||
/** Rename a from()/fromTo() call to to(), dropping fromTo's leading from-vars arg. */
|
||||
function convertMethodToTo(
|
||||
ms: MagicString,
|
||||
animation: GsapAnimation,
|
||||
call: any,
|
||||
varsNode: any,
|
||||
): void {
|
||||
if (animation.method !== "from" && animation.method !== "fromTo") return;
|
||||
const calleeProp = call.node.callee?.property;
|
||||
if (calleeProp) ms.overwrite(calleeProp.start, calleeProp.end, "to");
|
||||
// Remove the from-vars arg and its trailing separator up to the to-vars arg.
|
||||
if (animation.method === "fromTo" && call.fromArg) ms.remove(call.fromArg.start, varsNode.start);
|
||||
}
|
||||
|
||||
function convertFlatTweenToKeyframes(script: string, target: any): string {
|
||||
const animation: GsapAnimation = target.animation;
|
||||
if (animation.keyframes || animation.method === "set") return script;
|
||||
const call = target.call;
|
||||
const varsNode = call.varsArg;
|
||||
if (varsNode?.type !== "ObjectExpression") return script;
|
||||
|
||||
const ms = new MagicString(script);
|
||||
ms.overwrite(varsNode.start, varsNode.end, buildConvertedVarsCode(animation, varsNode, script));
|
||||
convertMethodToTo(ms, animation, call, varsNode);
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
// ── Keyframe write ops ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Design: mirror the recast writer's rebuild-the-node model. The recast writer
|
||||
// mutates AST nodes in place and re-prints, so it never has an offset-overlap
|
||||
// problem. Here we instead compute the FINAL property record for every keyframe
|
||||
// value node that must change (the target merge, `_auto` endpoint sync, and
|
||||
// backfilled siblings) against the ORIGINAL parsed AST, then emit exactly ONE
|
||||
// `ms.overwrite(valueNode.start, valueNode.end, code)` per changed node (and a
|
||||
// single insert for a brand-new key). No node is ever both overwritten and
|
||||
// appended into, so the splices can never overlap.
|
||||
|
||||
const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/;
|
||||
|
||||
// Matches recast's PCT_TOLERANCE: percentages within 2 of an existing key are
|
||||
// treated as the same keyframe (merge), not a new insert.
|
||||
const PCT_TOLERANCE = 2;
|
||||
|
||||
function percentageFromKey(key: string): number {
|
||||
const m = PERCENTAGE_KEY_RE.exec(key);
|
||||
return m ? Number.parseFloat(m[1] ?? "0") : Number.NaN;
|
||||
}
|
||||
|
||||
function buildKeyframeValueCode(
|
||||
properties: Record<string, number | string>,
|
||||
ease?: string,
|
||||
): string {
|
||||
const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
if (ease) entries.push(`ease: ${JSON.stringify(ease)}`);
|
||||
/** Serialize a final keyframe property record (number|string values) to code. */
|
||||
function recordToCode(record: Record<string, number | string>): string {
|
||||
const entries = Object.entries(record).map(([k, v]) => `${safeJsKey(k)}: ${serializeValue(v)}`);
|
||||
return `{ ${entries.join(", ")} }`;
|
||||
}
|
||||
|
||||
/** Percentage-keyed property nodes of a keyframes ObjectExpression, in source order. */
|
||||
function percentagePropsOf(kfNode: any): any[] {
|
||||
return (kfNode.properties ?? []).filter((p: any) => {
|
||||
if (!isObjectProperty(p)) return false;
|
||||
const key = propKeyName(p);
|
||||
return typeof key === "string" && PERCENTAGE_KEY_RE.test(key);
|
||||
});
|
||||
}
|
||||
|
||||
const LITERAL_NODE_TYPES = new Set(["Literal", "NumericLiteral", "StringLiteral"]);
|
||||
|
||||
/** Read one value node: a number/string literal, a negative number, or raw source. */
|
||||
// fallow-ignore-next-line complexity
|
||||
function readValueNode(v: any, source: string): number | string {
|
||||
if (
|
||||
LITERAL_NODE_TYPES.has(v?.type) &&
|
||||
(typeof v.value === "number" || typeof v.value === "string")
|
||||
) {
|
||||
return v.value;
|
||||
}
|
||||
if (
|
||||
v?.type === "UnaryExpression" &&
|
||||
v.operator === "-" &&
|
||||
typeof v.argument?.value === "number"
|
||||
) {
|
||||
return -v.argument.value;
|
||||
}
|
||||
return `__raw:${source.slice(v.start, v.end)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a keyframe value ObjectExpression into a record, mirroring the parser's
|
||||
* `objectExpressionToRecord`: literals resolve to their value; anything else is
|
||||
* preserved as `__raw:<source>` so serializeValue round-trips it verbatim.
|
||||
* Keyframe values are literals in practice, so the raw fallback is rarely hit.
|
||||
*/
|
||||
function valueNodeToRecord(valueNode: any, source: string): Record<string, number | string> {
|
||||
const record: Record<string, number | string> = {};
|
||||
if (valueNode?.type !== "ObjectExpression") return record;
|
||||
for (const prop of valueNode.properties ?? []) {
|
||||
if (!isObjectProperty(prop)) continue;
|
||||
const key = propKeyName(prop);
|
||||
if (typeof key !== "string") continue;
|
||||
record[key] = readValueNode(prop.value, source);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
/** True when a keyframe value record carries the synthetic `_auto` marker. */
|
||||
function recordHasAuto(record: Record<string, number | string>): boolean {
|
||||
return "_auto" in record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute `_auto` endpoint overwrites: when the new keyframe is the immediate
|
||||
* neighbor of an `_auto` 0% or 100% endpoint, that endpoint is rewritten to
|
||||
* `{ ...newProps, _auto: 1 }`. Only fires for interior keyframes. Returns the
|
||||
* percentage→overwrite map so the caller can fold these into the per-node final
|
||||
* records (never a separate splice).
|
||||
*/
|
||||
function autoEndpointOverwrites(
|
||||
kfNode: any,
|
||||
source: string,
|
||||
percentage: number,
|
||||
properties: Record<string, number | string>,
|
||||
): Map<any, Record<string, number | string>> {
|
||||
const result = new Map<any, Record<string, number | string>>();
|
||||
if (percentage <= 0 || percentage >= 100) return result;
|
||||
const pctProps = percentagePropsOf(kfNode);
|
||||
const allPcts = pctProps
|
||||
.map((p: any) => percentageFromKey(propKeyName(p) ?? ""))
|
||||
.filter((n: number) => !Number.isNaN(n) && n !== percentage)
|
||||
.sort((a: number, b: number) => a - b);
|
||||
const leftNeighbor = allPcts.filter((p: number) => p < percentage).pop();
|
||||
const rightNeighbor = allPcts.find((p: number) => p > percentage);
|
||||
for (const endPct of [0, 100]) {
|
||||
const isNeighbor = endPct === 0 ? leftNeighbor === 0 : rightNeighbor === 100;
|
||||
if (!isNeighbor) continue;
|
||||
const endProp = pctProps.find((p: any) => percentageFromKey(propKeyName(p) ?? "") === endPct);
|
||||
if (!endProp) continue;
|
||||
const rec = valueNodeToRecord(endProp.value, source);
|
||||
if (!recordHasAuto(rec)) continue;
|
||||
result.set(endProp, { ...properties, _auto: 1 });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findKfPropByPct(kfNode: any, percentage: number): { prop: any; idx: number } | null {
|
||||
const props = kfNode.properties ?? [];
|
||||
for (let i = 0; i < props.length; i++) {
|
||||
const prop = props[i];
|
||||
if (!isObjectProperty(prop)) continue;
|
||||
const key = propKeyName(prop);
|
||||
if (typeof key === "string" && Math.abs(percentageFromKey(key) - percentage) < 0.001) {
|
||||
if (typeof key === "string" && Math.abs(percentageFromKey(key) - percentage) <= PCT_TOLERANCE) {
|
||||
return { prop, idx: i };
|
||||
}
|
||||
}
|
||||
@@ -313,62 +534,187 @@ export function updateKeyframeInScript(
|
||||
const match = findKfPropByPct(kfPropNode.value, percentage);
|
||||
if (!match) return script;
|
||||
|
||||
const record: Record<string, number | string> = { ...properties };
|
||||
if (ease) record.ease = ease;
|
||||
const ms = new MagicString(script);
|
||||
ms.overwrite(
|
||||
match.prop.value.start,
|
||||
match.prop.value.end,
|
||||
buildKeyframeValueCode(properties, ease),
|
||||
);
|
||||
ms.overwrite(match.prop.value.start, match.prop.value.end, recordToCode(record));
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
/**
|
||||
* Build the final property record for the keyframe at `percentage`. If a
|
||||
* keyframe already exists there, MERGE the new props over the existing record
|
||||
* (preserve untouched props, preserve `_auto`, preserve the existing per-keyframe
|
||||
* ease when the op omits one); otherwise it's just the new props.
|
||||
*/
|
||||
function buildTargetRecord(
|
||||
existing: { prop: any; idx: number } | null,
|
||||
source: string,
|
||||
properties: Record<string, number | string>,
|
||||
ease: string | undefined,
|
||||
): Record<string, number | string> {
|
||||
if (!existing || existing.prop.value?.type !== "ObjectExpression") {
|
||||
const record: Record<string, number | string> = { ...properties };
|
||||
if (ease) record.ease = ease;
|
||||
return record;
|
||||
}
|
||||
const existingRecord = valueNodeToRecord(existing.prop.value, source);
|
||||
const existingEase = typeof existingRecord.ease === "string" ? existingRecord.ease : undefined;
|
||||
const merged: Record<string, number | string> = { ...existingRecord };
|
||||
for (const [k, v] of Object.entries(properties)) merged[k] = v;
|
||||
const finalEase = ease ?? existingEase;
|
||||
if (finalEase) merged.ease = finalEase;
|
||||
else delete merged.ease;
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the backfilled final record for one sibling keyframe: append any of
|
||||
* `newPropKeys` it's missing, using the backfill default. Returns null when
|
||||
* nothing changes (so the caller emits no overwrite for it).
|
||||
*/
|
||||
function backfilledSiblingRecord(
|
||||
valueNode: any,
|
||||
source: string,
|
||||
newPropKeys: string[],
|
||||
backfillDefaults: Record<string, number | string>,
|
||||
): Record<string, number | string> | null {
|
||||
if (valueNode?.type !== "ObjectExpression") return null;
|
||||
const record = valueNodeToRecord(valueNode, source);
|
||||
let changed = false;
|
||||
for (const pk of newPropKeys) {
|
||||
const defaultVal = backfillDefaults[pk];
|
||||
if (pk in record || defaultVal == null) continue;
|
||||
record[pk] = defaultVal;
|
||||
changed = true;
|
||||
}
|
||||
return changed ? record : null;
|
||||
}
|
||||
|
||||
/** A located tween whose varsArg has a static keyframes ObjectExpression, or null. */
|
||||
function locateWithKeyframes(
|
||||
script: string,
|
||||
animationId: string,
|
||||
): { script: string; parsed: ParsedGsapAcornForWrite; target: any; kfNode: any } | null {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return null;
|
||||
// Converting from()/fromTo() to to() rewrites the content-derived id; match
|
||||
// recast's locateAnimationWithFallback by remapping the method segment.
|
||||
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
|
||||
const target =
|
||||
parsed.located.find((l) => l.id === animationId) ??
|
||||
parsed.located.find((l) => l.id === convertedId);
|
||||
if (!target) return null;
|
||||
const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes");
|
||||
if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return null;
|
||||
return { script, parsed, target, kfNode: kfPropNode.value };
|
||||
}
|
||||
|
||||
/** Locate a tween's keyframes object, converting a flat tween first if absent. */
|
||||
function ensureKeyframesNode(
|
||||
script: string,
|
||||
animationId: string,
|
||||
): { script: string; parsed: ParsedGsapAcornForWrite; target: any; kfNode: any } | null {
|
||||
const direct = locateWithKeyframes(script, animationId);
|
||||
if (direct) return direct;
|
||||
|
||||
// No static keyframes object — convert the flat tween, then re-locate.
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
const target = parsed?.located.find((l) => l.id === animationId);
|
||||
if (!target) return null;
|
||||
const converted = convertFlatTweenToKeyframes(script, target);
|
||||
if (converted === script) return null;
|
||||
return locateWithKeyframes(converted, animationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the sibling keyframe nodes that need a backfilled prop, excluding the
|
||||
* target keyframe and any node already being overwritten as an `_auto` endpoint.
|
||||
*/
|
||||
function collectBackfillOverwrites(
|
||||
kfNode: any,
|
||||
src: string,
|
||||
properties: Record<string, number | string>,
|
||||
backfillDefaults: Record<string, number | string> | undefined,
|
||||
skip: { existingProp: any; endpoints: Map<any, unknown> },
|
||||
): Map<any, Record<string, number | string>> {
|
||||
const result = new Map<any, Record<string, number | string>>();
|
||||
if (!backfillDefaults) return result;
|
||||
const newPropKeys = Object.keys(properties);
|
||||
for (const prop of percentagePropsOf(kfNode)) {
|
||||
if (prop === skip.existingProp || skip.endpoints.has(prop)) continue;
|
||||
const rec = backfilledSiblingRecord(prop.value, src, newPropKeys, backfillDefaults);
|
||||
if (rec) result.set(prop, rec);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function addKeyframeToScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
percentage: number,
|
||||
properties: Record<string, number | string>,
|
||||
ease?: string,
|
||||
backfillDefaults?: Record<string, number | string>,
|
||||
): string {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return script;
|
||||
const target = parsed.located.find((l) => l.id === animationId);
|
||||
if (!target) return script;
|
||||
|
||||
const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes");
|
||||
if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return script;
|
||||
const kfNode = kfPropNode.value;
|
||||
|
||||
const ms = new MagicString(script);
|
||||
const pctKey = `${percentage}%`;
|
||||
const valueCode = buildKeyframeValueCode(properties, ease);
|
||||
const located = ensureKeyframesNode(script, animationId);
|
||||
if (!located) return script;
|
||||
const { script: src, kfNode } = located;
|
||||
|
||||
const existing = findKfPropByPct(kfNode, percentage);
|
||||
|
||||
// Final record for the target keyframe (merge if it already exists).
|
||||
const targetRecord = buildTargetRecord(existing, src, properties, ease);
|
||||
// `_auto` endpoint syncs fire only on new inserts; a merge landing ON an
|
||||
// endpoint already preserves `_auto` via buildTargetRecord.
|
||||
const endpointOverwrites = existing
|
||||
? new Map<any, Record<string, number | string>>()
|
||||
: autoEndpointOverwrites(kfNode, src, percentage, properties);
|
||||
// Backfilled siblings (each node changes at most once).
|
||||
const backfillOverwrites = collectBackfillOverwrites(kfNode, src, properties, backfillDefaults, {
|
||||
existingProp: existing?.prop,
|
||||
endpoints: endpointOverwrites,
|
||||
});
|
||||
|
||||
// Emit exactly one overwrite per changed node, plus one insert for a new key.
|
||||
const ms = new MagicString(src);
|
||||
if (existing) {
|
||||
ms.overwrite(existing.prop.value.start, existing.prop.value.end, valueCode);
|
||||
ms.overwrite(existing.prop.value.start, existing.prop.value.end, recordToCode(targetRecord));
|
||||
} else {
|
||||
const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p));
|
||||
let insertBeforeProp: any = null;
|
||||
for (const prop of allProps) {
|
||||
const key = propKeyName(prop);
|
||||
if (typeof key === "string" && percentageFromKey(key) > percentage) {
|
||||
insertBeforeProp = prop;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (insertBeforeProp) {
|
||||
// Insert `"pct%": {...}, ` before the next higher-percentage prop
|
||||
ms.appendLeft(insertBeforeProp.start, `${JSON.stringify(pctKey)}: ${valueCode}, `);
|
||||
} else {
|
||||
// Append at end of kfNode properties
|
||||
const sep = allProps.length > 0 ? ", " : "";
|
||||
ms.appendLeft(kfNode.end - 1, `${sep}${JSON.stringify(pctKey)}: ${valueCode}`);
|
||||
}
|
||||
insertNewKeyframe(ms, kfNode, percentage, `${percentage}%`, recordToCode(targetRecord));
|
||||
}
|
||||
for (const [prop, rec] of [...endpointOverwrites, ...backfillOverwrites]) {
|
||||
ms.overwrite(prop.value.start, prop.value.end, recordToCode(rec));
|
||||
}
|
||||
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
/** Insert a brand-new `"pct%": {...}` property in sorted order. */
|
||||
function insertNewKeyframe(
|
||||
ms: MagicString,
|
||||
kfNode: any,
|
||||
percentage: number,
|
||||
pctKey: string,
|
||||
valueCode: string,
|
||||
): void {
|
||||
const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p));
|
||||
let insertBeforeProp: any = null;
|
||||
for (const prop of allProps) {
|
||||
const key = propKeyName(prop);
|
||||
if (typeof key === "string" && percentageFromKey(key) > percentage) {
|
||||
insertBeforeProp = prop;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (insertBeforeProp) {
|
||||
ms.appendLeft(insertBeforeProp.start, `${JSON.stringify(pctKey)}: ${valueCode}, `);
|
||||
} else {
|
||||
const sep = allProps.length > 0 ? ", " : "";
|
||||
ms.appendLeft(kfNode.end - 1, `${sep}${JSON.stringify(pctKey)}: ${valueCode}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeKeyframeFromScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
/**
|
||||
* Differential parity test: acorn writer vs recast writer for addKeyframeToScript.
|
||||
*
|
||||
* The SDK uses the acorn (magic-string) writer; the server uses the recast
|
||||
* writer. An SDK-written keyframe op must produce a GSAP timeline whose parsed
|
||||
* keyframe array matches the recast-written one, otherwise newly-added props
|
||||
* snap instead of tween and stale `_auto` endpoints persist.
|
||||
*
|
||||
* We compare the *parsed keyframe arrays* (not byte-for-byte source) because the
|
||||
* two writers format differently (recast pretty-prints, acorn splices).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { addKeyframeToScript as addAcorn } from "./gsapWriterAcorn.js";
|
||||
import { addKeyframeToScript as addRecast, parseGsapScript } from "./gsapParser.js";
|
||||
|
||||
// These fixtures hold exactly one tween. We look it up by index rather than by
|
||||
// id because the stable id is content-derived: adding/backfilling a property
|
||||
// changes the id, so a hardcoded lookup would spuriously return null.
|
||||
function keyframesOf(script: string) {
|
||||
const parsed = parseGsapScript(script);
|
||||
const anim = parsed.animations[0];
|
||||
const kf = anim?.keyframes;
|
||||
if (!kf || kf.format !== "percentage") return null;
|
||||
return kf.keyframes
|
||||
.slice()
|
||||
.sort((a, b) => a.percentage - b.percentage)
|
||||
.map((k) => ({ percentage: k.percentage, properties: k.properties, ease: k.ease }));
|
||||
}
|
||||
|
||||
// Script whose 0% / 100% endpoints carry the synthetic `_auto: 1` marker the
|
||||
// parser emits for auto-derived endpoints.
|
||||
const AUTO_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 1, _auto: 1 }, "100%": { opacity: 0, _auto: 1 } }, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// Script with three plain keyframes (no _auto), used for the backfill case.
|
||||
const PLAIN_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.7 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// _auto endpoints with an interior 25% plain keyframe (0/25/100). Exercises the
|
||||
// "interior keyframe adjacent to a 100% _auto endpoint" path that crashed.
|
||||
const AUTO_THREE_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 1, _auto: 1 }, "25%": { opacity: 0.5 }, "100%": { opacity: 0, _auto: 1 } }, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// An existing keyframe carrying extra props + a per-keyframe ease. Re-touching
|
||||
// one prop must MERGE (preserve the others + the ease), not replace wholesale.
|
||||
const MERGE_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 0, x: 0 }, "50%": { opacity: 0.7, x: 30, scale: 2, ease: "power2.in" }, "100%": { opacity: 1, x: 60 } }, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// An existing keyframe keyed "50.0%" (not byte-equal to "50%").
|
||||
const DECIMAL_KEY_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50.0%": { opacity: 0.7 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
// Flat tweens (no keyframes object) — the first keyframe-add must convert them.
|
||||
const FLAT_TO_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { opacity: 0.5, x: 100, duration: 0.5, ease: "power2.out" }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
const FLAT_FROMTO_SCRIPT = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#box", { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
function animId(script: string): string {
|
||||
const id = parseGsapScript(script).animations[0]?.id;
|
||||
if (!id) throw new Error("no animation in fixture");
|
||||
return id;
|
||||
}
|
||||
|
||||
describe("acorn↔recast addKeyframeToScript parity", () => {
|
||||
it("rewrites an _auto 100% endpoint when the inserted keyframe is its left neighbor", () => {
|
||||
const id = animId(AUTO_SCRIPT);
|
||||
const props = { opacity: 0.3, x: 50 };
|
||||
const recast = addRecast(AUTO_SCRIPT, id, 60, props);
|
||||
const acorn = addAcorn(AUTO_SCRIPT, id, 60, props);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
it("rewrites an _auto 0% endpoint when the inserted keyframe is its right neighbor", () => {
|
||||
const id = animId(AUTO_SCRIPT);
|
||||
const props = { opacity: 0.8, scale: 2 };
|
||||
const recast = addRecast(AUTO_SCRIPT, id, 40, props);
|
||||
const acorn = addAcorn(AUTO_SCRIPT, id, 40, props);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
it("backfills a NEW property into the other keyframes with its default value", () => {
|
||||
const id = animId(PLAIN_SCRIPT);
|
||||
const props = { opacity: 0.3, x: 120 };
|
||||
const backfill = { opacity: 1, x: 0 };
|
||||
const recast = addRecast(PLAIN_SCRIPT, id, 25, props, undefined, backfill);
|
||||
const acorn = addAcorn(PLAIN_SCRIPT, id, 25, props, undefined, backfill);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
it("no backfill arg → matches recast with no backfill (new prop left absent)", () => {
|
||||
const id = animId(PLAIN_SCRIPT);
|
||||
const props = { opacity: 0.3, x: 120 };
|
||||
const recast = addRecast(PLAIN_SCRIPT, id, 25, props);
|
||||
const acorn = addAcorn(PLAIN_SCRIPT, id, 25, props);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
it("plain insert in sorted order stays at parity", () => {
|
||||
const id = animId(PLAIN_SCRIPT);
|
||||
const props = { opacity: 0.3 };
|
||||
const recast = addRecast(PLAIN_SCRIPT, id, 25, props);
|
||||
const acorn = addAcorn(PLAIN_SCRIPT, id, 25, props);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
// ── Bug 1: crash — _auto endpoint sync + backfill of a new prop together ──────
|
||||
it("syncs an _auto 100% endpoint AND backfills a new prop (2-endpoint, the crash)", () => {
|
||||
const id = animId(AUTO_SCRIPT);
|
||||
const props = { opacity: 0.3, x: 50 };
|
||||
const backfill = { opacity: 1, x: 0 };
|
||||
const recast = addRecast(AUTO_SCRIPT, id, 60, props, undefined, backfill);
|
||||
const acorn = addAcorn(AUTO_SCRIPT, id, 60, props, undefined, backfill);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
it("syncs _auto endpoint AND backfills a new prop (0/25/100 interior, the crash)", () => {
|
||||
const id = animId(AUTO_THREE_SCRIPT);
|
||||
const props = { opacity: 0.4, x: 80 };
|
||||
const backfill = { opacity: 1, x: 0 };
|
||||
const recast = addRecast(AUTO_THREE_SCRIPT, id, 60, props, undefined, backfill);
|
||||
const acorn = addAcorn(AUTO_THREE_SCRIPT, id, 60, props, undefined, backfill);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
// ── Bug 2: no-comma corruption — backfill ≥2 props into an empty {} keyframe ──
|
||||
it("backfills ≥2 new props into an empty {} keyframe without dropping the comma", () => {
|
||||
const EMPTY_KF = `\
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": {}, "100%": { x: 100, y: 50 } }, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const id = animId(EMPTY_KF);
|
||||
const props = { x: 40, y: 20 };
|
||||
const backfill = { x: 0, y: 0 };
|
||||
const recast = addRecast(EMPTY_KF, id, 50, props, undefined, backfill);
|
||||
const acorn = addAcorn(EMPTY_KF, id, 50, props, undefined, backfill);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
// ── Bug 4 + 7: merge — preserve untouched props AND existing ease ──────────────
|
||||
it("merges new props over an existing keyframe, preserving its other props + ease", () => {
|
||||
const id = animId(MERGE_SCRIPT);
|
||||
const props = { opacity: 0.9 };
|
||||
const recast = addRecast(MERGE_SCRIPT, id, 50, props);
|
||||
const acorn = addAcorn(MERGE_SCRIPT, id, 50, props);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
// ── Bug 5: convert-flat — first keyframe-add on a flat tween ──────────────────
|
||||
it("converts a flat to() tween to keyframes on the first keyframe add", () => {
|
||||
const id = animId(FLAT_TO_SCRIPT);
|
||||
const props = { opacity: 0.8 };
|
||||
const backfill = { opacity: 1 };
|
||||
const recast = addRecast(FLAT_TO_SCRIPT, id, 50, props, undefined, backfill);
|
||||
const acorn = addAcorn(FLAT_TO_SCRIPT, id, 50, props, undefined, backfill);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
it("converts a flat fromTo() tween to keyframes on the first keyframe add", () => {
|
||||
const id = animId(FLAT_FROMTO_SCRIPT);
|
||||
const props = { y: 10 };
|
||||
const backfill = { y: 0 };
|
||||
const recast = addRecast(FLAT_FROMTO_SCRIPT, id, 50, props, undefined, backfill);
|
||||
const acorn = addAcorn(FLAT_FROMTO_SCRIPT, id, 50, props, undefined, backfill);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
// ── Bug 3: existing "50.0%" key, add at 50 (non-byte-equal % key) ─────────────
|
||||
it("merges into a non-byte-equal '50.0%' key when adding at 50", () => {
|
||||
const id = animId(DECIMAL_KEY_SCRIPT);
|
||||
const props = { opacity: 0.9 };
|
||||
const recast = addRecast(DECIMAL_KEY_SCRIPT, id, 50, props);
|
||||
const acorn = addAcorn(DECIMAL_KEY_SCRIPT, id, 50, props);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
// ── Bug 8: %-tolerance — existing 50, add 51 should MERGE (PCT_TOLERANCE=2) ────
|
||||
it("treats a near-coincident percentage (50 vs 51) as the same keyframe (merge)", () => {
|
||||
const id = animId(PLAIN_SCRIPT);
|
||||
const props = { opacity: 0.9 };
|
||||
const recast = addRecast(PLAIN_SCRIPT, id, 51, props);
|
||||
const acorn = addAcorn(PLAIN_SCRIPT, id, 51, props);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
|
||||
// ── Bug 7: adding ONTO a 0/100 _auto endpoint preserves the _auto marker ──────
|
||||
it("preserves the _auto marker when adding a prop directly onto a 0% _auto endpoint", () => {
|
||||
const id = animId(AUTO_SCRIPT);
|
||||
const props = { x: 25 };
|
||||
const recast = addRecast(AUTO_SCRIPT, id, 0, props);
|
||||
const acorn = addAcorn(AUTO_SCRIPT, id, 0, props);
|
||||
expect(keyframesOf(acorn)).toEqual(keyframesOf(recast));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user