mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(sdk,core): ws-3 prerequisites — acorn keyframe-collapse foundation + removeAllKeyframes (#1499)
* feat(sdk,core): ws-3 prerequisites — acorn keyframe-collapse foundation + removeAllKeyframes
P1: gsapWriter.parity.test.ts — recast-vs-acorn parity harness (reparse-equivalence).
P2: move pure keyframe-conversion transforms (resolveConversionProps, cssIdentityValue)
to recast-free gsapSerialize.ts so the acorn/SDK path can share them.
P3: MagicString splice primitives in gsapWriterAcorn.ts (buildVarsObjectCode, overwriteVarsArg).
P4: reference vertical slice — removeAllKeyframesFromScript ported to acorn writer +
removeAllKeyframes SDK op (types/mutate/can) + Studio cutover (useGsapKeyframeOps),
replacing the server-authoritative ponytail stub.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sdk,core): ws-3 — convertToKeyframes acorn port + SDK op + Studio cutover
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(sdk,core): ws-3 — materializeKeyframes + splitIntoPropertyGroups acorn ports + SDK ops
- acorn: buildKeyframeObjectCode, materializeKeyframesFromScript, addAnimationWithKeyframesToScript
- acorn: splitIntoPropertyGroupsFromScript with filterGroupKeyframes/filterGroupProperties helpers
- parity tests: materialize (2 positive + 1 no-op) and split (2 positive + 2 no-op) suites
- SDK types: materializeKeyframes + splitIntoPropertyGroups EditOp variants
- mutate.ts: handlers + can() gates for both new ops
- mutate.gsap.test.ts: 6 new tests (53 total passing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(sdk,core): ws-3 — splitAnimationsInScript acorn port + SDK op
- acorn: updateAnimationSelectorInScript, insertInheritedStateSetInScript helpers
- acorn: splitAnimationsInScript exported (parity with recast version)
- parity: 4 new fixtures (3 cases + no-op) — 23 total parity tests
- SDK types: splitAnimations EditOp variant
- mutate.ts: handleSplitAnimations + can() gate
- mutate.gsap.test.ts: 3 new tests (56 total passing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
Miguel Ángel
parent
ceb815c318
commit
a746db6017
@@ -510,6 +510,220 @@ describe("removeGsapKeyframe", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeAllKeyframes", () => {
|
||||
it("collapses keyframed to() tween to last keyframe's props", () => {
|
||||
const parsed = fresh(KF_SCRIPT);
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
|
||||
const result = applyOp(parsed, { type: "removeAllKeyframes", animationId: animId });
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).not.toContain("keyframes");
|
||||
expect(newScript).not.toContain('"50%"');
|
||||
expect(newScript).toContain("opacity: 1");
|
||||
});
|
||||
|
||||
it("no-op (empty patch) when animation id not found", () => {
|
||||
const parsed = fresh(KF_SCRIPT);
|
||||
const result = applyOp(parsed, { type: "removeAllKeyframes", animationId: "nope" });
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("no-op when tween has no keyframes", () => {
|
||||
const parsed = fresh(GSAP_SCRIPT);
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
|
||||
const result = applyOp(parsed, { type: "removeAllKeyframes", animationId: animId });
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── convertToKeyframes ────────────────────────────────────────────────────────
|
||||
|
||||
describe("convertToKeyframes", () => {
|
||||
// GSAP_SCRIPT: position 0.2 → id suffix "200"; opacity = visual group
|
||||
it("converts flat to() tween to percentage keyframes", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, { type: "convertToKeyframes", animationId: TWEEN_ANIM_ID });
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain("keyframes");
|
||||
expect(newScript).toContain('"0%"');
|
||||
expect(newScript).toContain('"100%"');
|
||||
expect(newScript).toContain("easeEach");
|
||||
expect(newScript).toContain('ease: "none"');
|
||||
});
|
||||
|
||||
it("passes resolvedFromValues into 0% endpoint", () => {
|
||||
const script = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 200, duration: 1 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const parsed = fresh(script);
|
||||
// position 0 → "0"; x = position group
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-position`;
|
||||
const result = applyOp(parsed, {
|
||||
type: "convertToKeyframes",
|
||||
animationId: animId,
|
||||
resolvedFromValues: { x: 42 },
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain("42");
|
||||
});
|
||||
|
||||
it("no-op when animation already has keyframes", () => {
|
||||
const parsed = fresh(KF_SCRIPT);
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
|
||||
const result = applyOp(parsed, { type: "convertToKeyframes", animationId: animId });
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("no-op when animation id not found", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, { type: "convertToKeyframes", animationId: "nope" });
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── materializeKeyframes ─────────────────────────────────────────────────────
|
||||
|
||||
describe("materializeKeyframes", () => {
|
||||
it("adds keyframes property to flat tween", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "materializeKeyframes",
|
||||
animationId: TWEEN_ANIM_ID,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 100, properties: { opacity: 1 } },
|
||||
],
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain("keyframes");
|
||||
expect(newScript).toContain('"0%"');
|
||||
expect(newScript).toContain('"100%"');
|
||||
});
|
||||
|
||||
it("injects easeEach into keyframes object", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "materializeKeyframes",
|
||||
animationId: TWEEN_ANIM_ID,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 100, properties: { opacity: 1 } },
|
||||
],
|
||||
easeEach: "power2.out",
|
||||
});
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain("easeEach");
|
||||
expect(newScript).toContain("power2.out");
|
||||
});
|
||||
|
||||
it("no-op when animation id not found", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "materializeKeyframes",
|
||||
animationId: "nope",
|
||||
keyframes: [{ percentage: 0, properties: { opacity: 0 } }],
|
||||
});
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── splitIntoPropertyGroups ──────────────────────────────────────────────────
|
||||
|
||||
describe("splitIntoPropertyGroups", () => {
|
||||
it("splits mixed tween into multiple group tweens", () => {
|
||||
const script = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, opacity: 0.5, duration: 1 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const parsed = fresh(script);
|
||||
// mixed tween has no propertyGroup → no group suffix in id
|
||||
const animId = `[data-hf-id="hf-box"]-to-0`;
|
||||
const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: animId });
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
// x is position group, opacity is visual group — expect 2 tweens
|
||||
const toCount = (newScript.match(/\.to\(/g) ?? []).length;
|
||||
expect(toCount).toBe(2);
|
||||
});
|
||||
|
||||
it("no-op when animation id not found", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: "nope" });
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("no-op when tween has only one property group", () => {
|
||||
// x + y = same "position" group → nothing to split
|
||||
const script = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, y: 50, duration: 1 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const parsed = fresh(script);
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-position`;
|
||||
const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: animId });
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── splitAnimations ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("splitAnimations", () => {
|
||||
const SPLIT_SCRIPT = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { x: 200, duration: 4 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
function freshSplit() {
|
||||
return parseMutable(`<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
||||
<div data-hf-id="hf-hero"></div>
|
||||
<script>${SPLIT_SCRIPT}</script>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
it("retargets post-split tween to newId", () => {
|
||||
const parsed = freshSplit();
|
||||
const result = applyOp(parsed, {
|
||||
type: "splitAnimations",
|
||||
originalId: "hero",
|
||||
newId: "hero-2",
|
||||
splitTime: 3,
|
||||
elementStart: 0,
|
||||
elementDuration: 4,
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain("#hero-2");
|
||||
});
|
||||
|
||||
it("spanning tween produces fromTo on new element", () => {
|
||||
const parsed = freshSplit();
|
||||
const result = applyOp(parsed, {
|
||||
type: "splitAnimations",
|
||||
originalId: "hero",
|
||||
newId: "hero-2",
|
||||
splitTime: 2,
|
||||
elementStart: 0,
|
||||
elementDuration: 4,
|
||||
});
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain(".fromTo(");
|
||||
expect(newScript).toContain("#hero-2");
|
||||
});
|
||||
|
||||
it("no-op when originalId not found", () => {
|
||||
const parsed = freshSplit();
|
||||
const result = applyOp(parsed, {
|
||||
type: "splitAnimations",
|
||||
originalId: "nonexistent",
|
||||
newId: "x",
|
||||
splitTime: 2,
|
||||
elementStart: 0,
|
||||
elementDuration: 4,
|
||||
});
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Label ops ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("addLabel", () => {
|
||||
|
||||
@@ -50,6 +50,11 @@ import {
|
||||
removePropertyFromAnimation,
|
||||
addKeyframeToScript,
|
||||
removeKeyframeFromScript,
|
||||
removeAllKeyframesFromScript,
|
||||
convertToKeyframesFromScript,
|
||||
materializeKeyframesFromScript,
|
||||
splitIntoPropertyGroupsFromScript,
|
||||
splitAnimationsInScript,
|
||||
updateKeyframeInScript,
|
||||
addLabelToScript,
|
||||
removeLabelFromScript,
|
||||
@@ -146,18 +151,8 @@ function dispatchRemoveGsapKeyframe(
|
||||
: handleRemoveGsapKeyframe(parsed, op.animationId, op.keyframeIndex);
|
||||
}
|
||||
|
||||
function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined {
|
||||
function applyGsapKeyframeOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined {
|
||||
switch (op.type) {
|
||||
case "addGsapTween":
|
||||
return handleAddGsapTween(parsed, op.target, op.tween);
|
||||
case "setGsapTween":
|
||||
return handleSetGsapTween(parsed, op.animationId, op.properties);
|
||||
case "removeGsapProperty":
|
||||
return handleRemoveGsapProperty(parsed, op.animationId, op.property, op.from);
|
||||
case "removeGsapTween":
|
||||
return handleRemoveGsapTween(parsed, op.animationId);
|
||||
case "deleteAllForSelector":
|
||||
return handleDeleteAllForSelector(parsed, op.selector);
|
||||
case "setGsapKeyframe":
|
||||
return handleSetGsapKeyframe(
|
||||
parsed,
|
||||
@@ -171,6 +166,41 @@ function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undef
|
||||
return handleAddGsapKeyframe(parsed, op.animationId, op.position, op.value);
|
||||
case "removeGsapKeyframe":
|
||||
return dispatchRemoveGsapKeyframe(parsed, op);
|
||||
case "removeAllKeyframes":
|
||||
return handleRemoveAllKeyframes(parsed, op.animationId);
|
||||
case "convertToKeyframes":
|
||||
return handleConvertToKeyframes(parsed, op.animationId, op.resolvedFromValues);
|
||||
case "materializeKeyframes":
|
||||
return handleMaterializeKeyframes(
|
||||
parsed,
|
||||
op.animationId,
|
||||
op.keyframes,
|
||||
op.easeEach,
|
||||
op.resolvedSelector,
|
||||
);
|
||||
case "splitIntoPropertyGroups":
|
||||
return handleSplitIntoPropertyGroups(parsed, op.animationId);
|
||||
case "splitAnimations":
|
||||
return handleSplitAnimations(parsed, op);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined {
|
||||
const kf = applyGsapKeyframeOp(parsed, op);
|
||||
if (kf !== undefined) return kf;
|
||||
switch (op.type) {
|
||||
case "addGsapTween":
|
||||
return handleAddGsapTween(parsed, op.target, op.tween);
|
||||
case "setGsapTween":
|
||||
return handleSetGsapTween(parsed, op.animationId, op.properties);
|
||||
case "removeGsapProperty":
|
||||
return handleRemoveGsapProperty(parsed, op.animationId, op.property, op.from);
|
||||
case "removeGsapTween":
|
||||
return handleRemoveGsapTween(parsed, op.animationId);
|
||||
case "deleteAllForSelector":
|
||||
return handleDeleteAllForSelector(parsed, op.selector);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -737,6 +767,83 @@ function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): Mut
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleRemoveAllKeyframes(parsed: ParsedDocument, animationId: string): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
const newScript = removeAllKeyframesFromScript(script, animationId);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleConvertToKeyframes(
|
||||
parsed: ParsedDocument,
|
||||
animationId: string,
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
const newScript = convertToKeyframesFromScript(script, animationId, resolvedFromValues);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleMaterializeKeyframes(
|
||||
parsed: ParsedDocument,
|
||||
animationId: string,
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>,
|
||||
easeEach?: string,
|
||||
resolvedSelector?: string,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
const newScript = materializeKeyframesFromScript(
|
||||
script,
|
||||
animationId,
|
||||
keyframes,
|
||||
easeEach,
|
||||
resolvedSelector,
|
||||
);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleSplitIntoPropertyGroups(
|
||||
parsed: ParsedDocument,
|
||||
animationId: string,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
const { script: newScript } = splitIntoPropertyGroupsFromScript(script, animationId);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleSplitAnimations(
|
||||
parsed: ParsedDocument,
|
||||
op: Extract<EditOp, { type: "splitAnimations" }>,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
const { script: newScript } = splitAnimationsInScript(script, {
|
||||
originalId: op.originalId,
|
||||
newId: op.newId,
|
||||
splitTime: op.splitTime,
|
||||
elementStart: op.elementStart,
|
||||
elementDuration: op.elementDuration,
|
||||
});
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleDeleteAllForSelector(parsed: ParsedDocument, selector: string): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
@@ -954,6 +1061,11 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
|
||||
case "removeGsapKeyframe":
|
||||
case "removeGsapProperty":
|
||||
case "removeGsapTween":
|
||||
case "removeAllKeyframes":
|
||||
case "convertToKeyframes":
|
||||
case "materializeKeyframes":
|
||||
case "splitIntoPropertyGroups":
|
||||
case "splitAnimations":
|
||||
case "deleteAllForSelector":
|
||||
case "removeLabel":
|
||||
if (getGsapScript(parsed.document) === null)
|
||||
|
||||
@@ -105,7 +105,33 @@ export type EditOp =
|
||||
| { type: "removeGsapKeyframe"; animationId: string; percentage: number }
|
||||
| { type: "removeGsapProperty"; animationId: string; property: string; from?: boolean }
|
||||
| { type: "removeGsapTween"; animationId: string }
|
||||
| { type: "removeAllKeyframes"; animationId: string }
|
||||
| {
|
||||
type: "convertToKeyframes";
|
||||
animationId: string;
|
||||
resolvedFromValues?: Record<string, number | string>;
|
||||
}
|
||||
| { type: "deleteAllForSelector"; selector: string }
|
||||
| {
|
||||
type: "materializeKeyframes";
|
||||
animationId: string;
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>;
|
||||
easeEach?: string;
|
||||
resolvedSelector?: string;
|
||||
}
|
||||
| { type: "splitIntoPropertyGroups"; animationId: string }
|
||||
| {
|
||||
type: "splitAnimations";
|
||||
originalId: string;
|
||||
newId: string;
|
||||
splitTime: number;
|
||||
elementStart: number;
|
||||
elementDuration: number;
|
||||
}
|
||||
| { type: "addLabel"; name: string; position: number }
|
||||
| { type: "removeLabel"; name: string };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user