mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Backfill defaults for add-keyframe ops.
|
||||
*
|
||||
* When an add-keyframe op introduces a property absent from the other keyframes,
|
||||
* the writer needs a rest value to seed those keyframes with so GSAP interpolates
|
||||
* instead of snapping. The SDK derives the numeric-default set here so the acorn
|
||||
* writer matches the recast writer the server uses.
|
||||
*
|
||||
* Only props with a real numeric default get a backfill value. Defaulting an
|
||||
* unknown or string-valued prop to 0 (e.g. `color: 0`, `filter: 0`) emits invalid
|
||||
* GSAP, so such props are SKIPPED — the writer then leaves them out of the other
|
||||
* keyframes (GSAP reads the rest value from the DOM), matching recast (which skips
|
||||
* any prop whose default is null).
|
||||
*/
|
||||
|
||||
// Numeric rest values for editable transform/style props. Props absent here have
|
||||
// no safe static default and are intentionally omitted from the backfill set.
|
||||
//
|
||||
// KEEP IN SYNC WITH packages/studio/src/hooks/gsapShared.ts:PROPERTY_DEFAULTS —
|
||||
// the studio (recast) and SDK (acorn) paths must derive the same defaults or
|
||||
// SDK-written keyframes drift from server-written ones (the exact bug this fixes).
|
||||
// TODO: lift the canonical table into @hyperframes/core and import from both.
|
||||
const KEYFRAME_PROPERTY_DEFAULTS: Record<string, number> = {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotation: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
};
|
||||
|
||||
/** Derive the backfillDefaults for an add-keyframe op (numeric-default props only). */
|
||||
export function deriveKeyframeBackfillDefaults(
|
||||
value: Record<string, number | string>,
|
||||
): Record<string, number | string> {
|
||||
const defaults: Record<string, number | string> = {};
|
||||
for (const key of Object.keys(value)) {
|
||||
const def = KEYFRAME_PROPERTY_DEFAULTS[key];
|
||||
if (def !== undefined) defaults[key] = def;
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
@@ -288,6 +288,38 @@ describe("addGsapKeyframe", () => {
|
||||
expect(newScript).toContain('"25%"');
|
||||
expect(newScript).toContain("opacity: 0.3");
|
||||
});
|
||||
|
||||
it("backfills a NEW property into the other keyframes, matching the recast writer", async () => {
|
||||
const parsed = fresh(KF_SCRIPT);
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
|
||||
const result = applyOp(parsed, {
|
||||
type: "addGsapKeyframe",
|
||||
animationId: animId,
|
||||
position: 25,
|
||||
// `x` is brand-new to this keyframe set: it must be backfilled into the
|
||||
// existing keyframes so GSAP interpolates rather than snaps.
|
||||
value: { opacity: 0.3, x: 120 },
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
|
||||
// Parse the SDK-written script and compare against the recast writer fed the
|
||||
// same backfillDefaults the studio always sends (`PROPERTY_DEFAULTS[k] ?? 0`).
|
||||
const { parseGsapScript, addKeyframeToScript } = await import("@hyperframes/core/gsap-parser");
|
||||
const recast = addKeyframeToScript(KF_SCRIPT, animId, 25, { opacity: 0.3, x: 120 }, undefined, {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
});
|
||||
const kfOf = (s: string) =>
|
||||
parseGsapScript(s)
|
||||
.animations[0]?.keyframes?.keyframes?.slice()
|
||||
.sort((a, b) => a.percentage - b.percentage)
|
||||
.map((k) => ({ percentage: k.percentage, properties: k.properties }));
|
||||
expect(kfOf(newScript)).toEqual(kfOf(recast));
|
||||
|
||||
// Every keyframe carries `x` (the new prop backfilled at its default 0).
|
||||
expect(newScript).toContain("x: 0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setGsapKeyframe", () => {
|
||||
@@ -333,6 +365,33 @@ describe("setGsapKeyframe", () => {
|
||||
expect(newScript).toContain("opacity: 0.7");
|
||||
});
|
||||
|
||||
it("move with a new prop threads backfill defaults into sibling keyframes (matches add path)", async () => {
|
||||
const parsed = fresh(KF_SCRIPT);
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
|
||||
// Move the 50% keyframe to 60% while introducing a NEW prop `x`. The move
|
||||
// path (remove + re-add) must seed `x` into the other keyframes with its
|
||||
// default, exactly like the add path does.
|
||||
const result = applyOp(parsed, {
|
||||
type: "setGsapKeyframe",
|
||||
animationId: animId,
|
||||
keyframeIndex: 1,
|
||||
position: 60,
|
||||
value: { opacity: 0.5, x: 120 },
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
|
||||
// The 0% and 100% keyframes should now carry `x` backfilled at its default 0.
|
||||
const { parseGsapScript } = await import("@hyperframes/core/gsap-parser");
|
||||
const kfs = parseGsapScript(newScript)
|
||||
.animations[0]?.keyframes?.keyframes?.slice()
|
||||
.sort((a, b) => a.percentage - b.percentage);
|
||||
expect(kfs?.map((k) => k.percentage)).toEqual([0, 60, 100]);
|
||||
expect(kfs?.find((k) => k.percentage === 0)?.properties.x).toBe(0);
|
||||
expect(kfs?.find((k) => k.percentage === 100)?.properties.x).toBe(0);
|
||||
expect(kfs?.find((k) => k.percentage === 60)?.properties.x).toBe(120);
|
||||
});
|
||||
|
||||
it("ease-only update (same position, no value) does not corrupt keyframe", () => {
|
||||
const kfWithEase = KF_SCRIPT.replace(
|
||||
'"0%": { opacity: 0 }',
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
addLabelToScript,
|
||||
removeLabelFromScript,
|
||||
} from "@hyperframes/core/gsap-writer-acorn";
|
||||
import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js";
|
||||
|
||||
export interface MutationResult {
|
||||
forward: JsonPatchOp[];
|
||||
@@ -699,7 +700,17 @@ function handleSetGsapKeyframe(
|
||||
let newScript = script;
|
||||
if (targetPct !== currentPct) {
|
||||
newScript = removeKeyframeFromScript(newScript, animationId, currentPct);
|
||||
newScript = addKeyframeToScript(newScript, animationId, targetPct, props, resolvedEase);
|
||||
// Thread the same backfill defaults the add path uses so a move (remove +
|
||||
// re-add at a new percentage) seeds new props into sibling keyframes the same
|
||||
// way, keeping both entry points behaviorally identical.
|
||||
newScript = addKeyframeToScript(
|
||||
newScript,
|
||||
animationId,
|
||||
targetPct,
|
||||
props,
|
||||
resolvedEase,
|
||||
deriveKeyframeBackfillDefaults(props),
|
||||
);
|
||||
} else {
|
||||
newScript = updateKeyframeInScript(newScript, animationId, currentPct, props, resolvedEase);
|
||||
}
|
||||
@@ -717,11 +728,14 @@ function handleAddGsapKeyframe(
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
const props = value as Record<string, number | string>;
|
||||
const newScript = addKeyframeToScript(
|
||||
script,
|
||||
animationId,
|
||||
percentage,
|
||||
value as Record<string, number | string>,
|
||||
props,
|
||||
undefined,
|
||||
deriveKeyframeBackfillDefaults(props),
|
||||
);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
|
||||
Reference in New Issue
Block a user