Merge pull request #2164 from heygen-com/07-10_refactor_studio_gate_acorn_writer_migration

refactor(studio): gate Acorn writer migration
This commit is contained in:
James Russo
2026-07-18 10:55:48 -04:00
committed by GitHub
11 changed files with 573 additions and 23 deletions
+1
View File
@@ -2924,6 +2924,7 @@ describe("base gsap.set (off-timeline global hold)", () => {
});
expect(script).toContain('gsap.set("#box"');
expect(script).not.toContain('tl.set("#box"');
expect(script.indexOf('gsap.set("#box"')).toBeLessThan(script.indexOf("gsap.timeline"));
});
it("updates a global set in place, keeping it gsap.set", () => {
+7 -1
View File
@@ -1543,7 +1543,13 @@ export function addAnimationToScript(
const id = `anim-${Date.now()}`;
const statementCode = buildTweenStatementCode(parsed.timelineVar, animation);
const newStatement = parseScript(statementCode).program.body[0];
insertAfterAnchor(parsed, newStatement);
if (animation.method === "set" && animation.global) {
const timeline = findTimelineDeclarationPath(parsed.ast, parsed.timelineVar);
if (timeline) timeline.insertBefore(newStatement);
else insertAfterAnchor(parsed, newStatement);
} else {
insertAfterAnchor(parsed, newStatement);
}
return { script: recast.print(parsed.ast).code, id };
}
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
addMotionPathPointInScript as addAcorn,
addMotionPathToScript as addPathAcorn,
removeMotionPathPointInScript as removeAcorn,
syncPositionHoldsBeforeKeyframes as syncAcorn,
updateMotionPathPointInScript as updateAcorn,
} from "./gsapWriterAcorn.js";
import {
addMotionPathPointInScript as addRecast,
addMotionPathToScript as addPathRecast,
parseGsapScript,
removeMotionPathPointInScript as removeRecast,
syncPositionHoldsBeforeKeyframes as syncRecast,
updateMotionPathPointInScript as updateRecast,
} from "./gsapParser.js";
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
const SCRIPT = `// keep-this-comment
const untouched = { spacing: "verbatim" };
const tl = gsap.timeline({ paused: true });
tl.to("#box", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: -50 }, { x: 200, y: 0 }], curviness: 1.2 }, duration: 1 }, 2);
window.__timelines = { main: tl };`;
function idOf(script: string): string {
const id = parseGsapScript(script).animations.find((animation) => animation.arcPath)?.id;
if (!id) throw new Error("motion path fixture did not parse");
return id;
}
function motionModel(script: string) {
return parseGsapScriptAcorn(script).animations.map((animation) => ({
targetSelector: animation.targetSelector,
method: animation.method,
position: animation.position,
duration: animation.duration,
ease: animation.ease,
keyframes: animation.keyframes,
arcPath: animation.arcPath,
}));
}
function expectParity(acorn: string, recast: string): void {
expect(motionModel(acorn)).toEqual(motionModel(recast));
expect(acorn).toContain("// keep-this-comment");
expect(acorn).toContain('const untouched = { spacing: "verbatim" };');
}
describe("Acorn motion-path writer parity", () => {
it("moves an anchor while preserving path semantics and untouched source", () => {
const id = idOf(SCRIPT);
expectParity(
updateAcorn(SCRIPT, id, 1, { x: 120, y: -80 }),
updateRecast(SCRIPT, id, 1, { x: 120, y: -80 }),
);
});
it("adds and removes anchors with the same segment behavior as Recast", () => {
const id = idOf(SCRIPT);
expectParity(
addAcorn(SCRIPT, id, 1, { x: 50, y: -20 }),
addRecast(SCRIPT, id, 1, { x: 50, y: -20 }),
);
expectParity(removeAcorn(SCRIPT, id, 1), removeRecast(SCRIPT, id, 1));
});
it("authors a new path with the same parsed model", () => {
const base = "const tl = gsap.timeline({ paused: true });\nwindow.__timelines = { main: tl };";
const acorn = addPathAcorn(base, "#hero", 1.5, 2, { x: 300, y: -100 }).script;
const recast = addPathRecast(base, "#hero", 1.5, 2, { x: 300, y: -100 }).script;
expect(motionModel(acorn)).toEqual(motionModel(recast));
});
it("synchronizes delayed position holds without Recast and remains idempotent", () => {
const acorn = syncAcorn(SCRIPT);
const recast = syncRecast(SCRIPT);
expect(motionModel(acorn)).toEqual(motionModel(recast));
expect(acorn).toContain('data: "hf-hold"');
expect(syncAcorn(acorn)).toBe(acorn);
});
});
+162
View File
@@ -2074,6 +2074,105 @@ export function updateArcSegmentInScript(
return ms.toString();
}
function hasCubicSegments(segments: ArcPathSegment[]): boolean {
return segments.some((segment) => segment.cp1 != null || segment.cp2 != null);
}
function writeMotionPathValue(
script: string,
target: ParsedGsapAcornForWrite["located"][number],
waypoints: Array<{ x: number; y: number }>,
segments: ArcPathSegment[],
autoRotate: boolean | number,
): string {
const motionPath = findPropertyNode(target.call.varsArg, "motionPath");
if (!motionPath) return script;
const code = buildMotionPathObjectCode({ waypoints, segments, autoRotate });
const ms = new MagicString(script);
ms.overwrite(motionPath.value.start, motionPath.value.end, code);
return ms.toString();
}
export function updateMotionPathPointInScript(
script: string,
animationId: string,
pointIndex: number,
point: { x: number; y: number },
): string {
const parsed = parseGsapScriptAcornForWrite(script);
const target = parsed?.located.find((entry) => entry.id === animationId);
if (!target?.animation.arcPath?.enabled) return script;
const waypoints = extractArcWaypoints(target.animation);
if (waypoints.length < 2 || pointIndex < 0 || pointIndex >= waypoints.length) return script;
const next = waypoints.map((waypoint, index) => (index === pointIndex ? { ...point } : waypoint));
return writeMotionPathValue(
script,
target,
next,
target.animation.arcPath.segments,
target.animation.arcPath.autoRotate,
);
}
export function addMotionPathPointInScript(
script: string,
animationId: string,
index: number,
point: { x: number; y: number },
): string {
const parsed = parseGsapScriptAcornForWrite(script);
const target = parsed?.located.find((entry) => entry.id === animationId);
const arc = target?.animation.arcPath;
if (!target || !arc?.enabled || hasCubicSegments(arc.segments)) return script;
const waypoints = extractArcWaypoints(target.animation);
if (index < 1 || index > waypoints.length - 1) return script;
const segments = [...arc.segments];
waypoints.splice(index, 0, { ...point });
segments.splice(index - 1, 0, { curviness: segments[index - 1]?.curviness ?? 1 });
return writeMotionPathValue(script, target, waypoints, segments, arc.autoRotate);
}
export function removeMotionPathPointInScript(
script: string,
animationId: string,
index: number,
): string {
const parsed = parseGsapScriptAcornForWrite(script);
const target = parsed?.located.find((entry) => entry.id === animationId);
const arc = target?.animation.arcPath;
if (!target || !arc?.enabled || hasCubicSegments(arc.segments)) return script;
const waypoints = extractArcWaypoints(target.animation);
if (waypoints.length <= 2 || index < 0 || index >= waypoints.length) return script;
const segments = [...arc.segments];
waypoints.splice(index, 1);
segments.splice(Math.min(index, segments.length - 1), 1);
return writeMotionPathValue(script, target, waypoints, segments, arc.autoRotate);
}
export function addMotionPathToScript(
script: string,
targetSelector: string,
position: number,
duration: number,
point: { x: number; y: number },
ease = "power1.inOut",
): { script: string; id: string | null } {
const motionPath = buildMotionPathObjectCode({
waypoints: [{ x: 0, y: 0 }, { ...point }],
segments: [{ curviness: 1 }],
autoRotate: false,
});
const result = addAnimationToScript(script, {
targetSelector,
method: "to",
position,
duration,
ease,
properties: { motionPath: `__raw:${motionPath}` },
});
return { script: result.script, id: result.id || null };
}
export function removeArcPathFromScript(script: string, animationId: string): string {
return setArcPathInScript(script, animationId, {
enabled: false,
@@ -2133,6 +2232,69 @@ function insertInheritedStateSetInScript(
return ms.toString();
}
const STUDIO_HOLD_MARKER = "hf-hold";
function isStudioHoldSet(animation: GsapAnimation): boolean {
return animation.method === "set" && animation.properties.data === STUDIO_HOLD_MARKER;
}
function removeStudioHoldSets(script: string, parsed: ParsedGsapAcornForWrite): string {
const staleHolds = parsed.located.filter((entry) => isStudioHoldSet(entry.animation));
if (staleHolds.length === 0) return script;
const ms = new MagicString(script);
for (const hold of staleHolds) removeCallFromMagicString(ms, hold.call, script);
return ms.toString();
}
function animationStart(animation: GsapAnimation): number {
if (animation.resolvedStart !== undefined) return animation.resolvedStart;
return typeof animation.position === "number" ? animation.position : 0;
}
function positionProperties(
properties: Record<string, number | string>,
): Record<string, number | string> {
const position: Record<string, number | string> = {};
for (const [property, value] of Object.entries(properties)) {
if (classifyPropertyGroup(property) === "position" && typeof value === "number") {
position[property] = value;
}
}
return position;
}
function positionHoldForAnimation(
animation: GsapAnimation,
): Record<string, number | string> | null {
if (!animation.keyframes) return null;
if (!(animationStart(animation) > 0.001)) return null;
const first = [...animation.keyframes.keyframes].sort(
(left, right) => left.percentage - right.percentage,
)[0];
if (!first) return null;
const position = positionProperties(first.properties);
return Object.keys(position).length > 0 ? position : null;
}
/** Acorn-native, byte-preserving hold synchronization used after mutations. */
export function syncPositionHoldsBeforeKeyframes(script: string): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
let result = removeStudioHoldSets(script, parsed);
const current = parseGsapScriptAcornForWrite(result);
if (!current) return result;
for (const entry of current.located) {
const animation = entry.animation;
const position = positionHoldForAnimation(animation);
if (!position) continue;
result = insertInheritedStateSetInScript(result, animation.targetSelector, 0, {
...position,
data: STUDIO_HOLD_MARKER,
});
}
return result;
}
/**
* Compute, in forward (timeline) order, the inherited-props baseline available
* BEFORE each matching tween, plus the final cumulative state at the split point.
+2
View File
@@ -127,8 +127,10 @@
"scripts": {
"build": "tsup",
"test": "vitest run",
"test:acorn-writer": "bun scripts/run-acorn-writer-tests.ts",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"report:gsap-writers": "bun src/routes/gsapMutationCapabilities.report.ts",
"prepublishOnly": "echo skip"
},
"dependencies": {
@@ -0,0 +1,19 @@
import { fileURLToPath } from "node:url";
const child = Bun.spawn(
[
"bunx",
"vitest",
"run",
"src/routes/files.test.ts",
"src/routes/gsapMutationCapabilities.test.ts",
],
{
cwd: fileURLToPath(new URL("..", import.meta.url)),
env: { ...process.env, HYPERFRAMES_GSAP_WRITER: "acorn" },
stdout: "inherit",
stderr: "inherit",
},
);
process.exitCode = await child.exited;
@@ -193,6 +193,36 @@ describe("registerFileRoutes", () => {
expect(response.status).toBe(404);
});
it("returns a clean 400 for an invalid GSAP writer flag", async () => {
const previous = process.env.HYPERFRAMES_GSAP_WRITER;
process.env.HYPERFRAMES_GSAP_WRITER = "true";
try {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
'<div id="box"></div><script>const tl = gsap.timeline(); tl.to("#box", { x: 10 });</script>',
);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request(
"http://localhost/projects/demo/gsap-mutations/index.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "shift-positions", targetSelector: "#box", delta: 1 }),
},
);
const payload = (await response.json()) as { error?: string };
expect(response.status).toBe(400);
expect(payload.error).toContain("expected recast or acorn");
} finally {
if (previous === undefined) delete process.env.HYPERFRAMES_GSAP_WRITER;
else process.env.HYPERFRAMES_GSAP_WRITER = previous;
}
});
it("returns empty content for missing files when caller marks the read optional", async () => {
const projectDir = createProjectDir();
const app = new Hono();
+82 -22
View File
@@ -55,6 +55,10 @@ import {
unrollDynamicAnimations,
setArcPathInScript,
updateArcSegmentInScript,
updateMotionPathPointInScript,
addMotionPathPointInScript,
removeMotionPathPointInScript,
addMotionPathToScript,
removeArcPathFromScript,
addAnimationWithKeyframesToScript,
splitAnimationsInScript,
@@ -62,6 +66,7 @@ import {
shiftPositionsInScript,
scalePositionsInScript,
dedupePositionWritesInScript,
syncPositionHoldsBeforeKeyframes,
} from "@hyperframes/parsers/gsap-writer-acorn";
import {
removeElementFromHtml,
@@ -79,26 +84,18 @@ import {
CompositionInsertionError,
insertCompositionIntoSource,
} from "../helpers/compositionInsertion.js";
import { resolveGsapWriter } from "./gsapMutationCapabilities.js";
// ── Server cutover flag ─────────────────────────────────────────────────────
/**
* Mirror of the client STUDIO_SDK_CUTOVER_ENABLED flag for server-side writer
* selection. When true, the acorn writer handles GSAP mutations; otherwise the
* recast writer (gsapParser.ts) is used. Default false recast.
*
* Enable with: STUDIO_SDK_CUTOVER_ENABLED=true (or =1)
* Mirrors the client Vite env var name so one env switch flips both sides.
* Writer selection is deliberately independent from the Studio SDK cutover.
* Recast remains the default until the capability report has no parity blockers.
*/
function isAcornGsapWriterEnabled(): boolean {
const val = process.env["STUDIO_SDK_CUTOVER_ENABLED"];
return val === "true" || val === "1";
}
/**
* Lazy-load gsapParser for write ops (recast-backed) the default server writer.
* The read path uses the browser-safe acorn parser; this loader is only needed
* for the recast write path (the default when STUDIO_SDK_CUTOVER_ENABLED is off).
* for the recast write path (the default until the migration gate graduates).
*/
async function loadGsapParser() {
return import("@hyperframes/parsers/gsap-parser-recast");
@@ -820,7 +817,7 @@ function bakeVisibilityOnDelete(document: Document, anim: GsapAnimation): void {
// ── GSAP mutation types ─────────────────────────────────────────────────────
type GsapMutationRequest =
export type GsapMutationRequest =
| {
type: "update-property";
animationId: string;
@@ -1097,10 +1094,11 @@ async function executeGsapMutation(
body: GsapMutationRequest,
block: NonNullable<ReturnType<typeof extractGsapScriptBlock>>,
respond: (data: unknown, status?: number) => Response,
writer: "recast" | "acorn",
): Promise<GsapMutationResult | Response> {
// When the server cutover flag is enabled, delegate to the acorn writer;
// otherwise use the recast writer (gsapParser.ts) as the default.
if (!isAcornGsapWriterEnabled()) {
// Keep writer selection explicit at the route boundary so a batch cannot
// switch implementations between operations.
if (writer === "recast") {
return executeGsapMutationRecast(body, block, respond);
}
return executeGsapMutationAcorn(body, block, respond);
@@ -1192,17 +1190,27 @@ async function applyGsapMutations(
const skippedSelectors = new Set<string>();
const respond = (data: unknown, status?: number) =>
status ? c.json(data, status) : c.json(data);
let writer: "recast" | "acorn";
try {
writer = resolveGsapWriter({
HYPERFRAMES_GSAP_WRITER: process.env["HYPERFRAMES_GSAP_WRITER"],
});
} catch (error) {
return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
}
for (const mutation of mutations) {
const result = await executeGsapMutation(mutation, block, respond);
const result = await executeGsapMutation(mutation, block, respond, writer);
if (result instanceof Response) return result;
let newScript = typeof result === "string" ? result : result.script;
if (typeof result !== "string") {
for (const selector of result.skippedSelectors) skippedSelectors.add(selector);
}
if (HOLD_SYNC_MUTATION_TYPES.has(mutation.type)) {
const parser = await loadGsapParser();
newScript = parser.syncPositionHoldsBeforeKeyframes(newScript);
newScript =
writer === "acorn"
? syncPositionHoldsBeforeKeyframes(newScript)
: (await loadGsapParser()).syncPositionHoldsBeforeKeyframes(newScript);
}
block.scriptText = newScript;
}
@@ -1299,6 +1307,14 @@ function executeGsapMutationAcorn(
if (body.fromProperties && body.method !== "fromTo") {
return respond({ error: "fromProperties is only valid for method=fromTo" }, 400);
}
if (
Object.keys(body.properties).some((key) => {
const group = classifyPropertyGroup(key);
return group === "position" || group === "rotation";
})
) {
stripStudioEditsFromTarget(block.document, body.targetSelector);
}
const result = addAnimationToScript(block.scriptText, {
targetSelector: body.targetSelector,
method: body.method,
@@ -1436,10 +1452,38 @@ function executeGsapMutationAcorn(
...(body.cp2 ? { cp2: body.cp2 } : {}),
});
}
case "update-motion-path-point": {
return updateMotionPathPointInScript(block.scriptText, body.animationId, body.pointIndex, {
x: body.x,
y: body.y,
});
}
case "add-motion-path-point": {
return addMotionPathPointInScript(block.scriptText, body.animationId, body.index, {
x: body.x,
y: body.y,
});
}
case "remove-motion-path-point": {
return removeMotionPathPointInScript(block.scriptText, body.animationId, body.index);
}
case "add-motion-path": {
return addMotionPathToScript(
block.scriptText,
body.targetSelector,
body.position,
body.duration,
{ x: body.x, y: body.y },
body.ease,
).script;
}
case "remove-arc-path": {
return removeArcPathFromScript(block.scriptText, body.animationId);
}
case "add-with-keyframes": {
if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) {
stripStudioEditsFromTarget(block.document, body.targetSelector);
}
const result = addAnimationWithKeyframesToScript(
block.scriptText,
body.targetSelector,
@@ -1452,6 +1496,9 @@ function executeGsapMutationAcorn(
return result.script;
}
case "replace-with-keyframes": {
if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) {
stripStudioEditsFromTarget(block.document, body.targetSelector);
}
const script = removeAnimationFromScript(block.scriptText, body.animationId);
const added = addAnimationWithKeyframesToScript(
script,
@@ -1814,6 +1861,7 @@ async function executeGsapMutationRecast(
body.duration,
body.keyframes,
body.ease,
body.easeEach,
);
return result.script;
}
@@ -1927,6 +1975,7 @@ async function foldAtomicCutFile(
file: AtomicCutFileRequest,
absPath: string,
before: string,
writer: "recast" | "acorn",
): Promise<FoldedAtomicCutFile | Response> {
let after = before;
let splitCount = 0;
@@ -1986,6 +2035,7 @@ async function foldAtomicCutFile(
},
block,
respond,
writer,
);
if (result instanceof Response) return result;
let script = typeof result === "string" ? result : result.script;
@@ -1993,8 +2043,10 @@ async function foldAtomicCutFile(
for (const selector of result.skippedSelectors) skippedSelectors.add(selector);
}
if (script !== block.scriptText) {
const parser = await loadGsapParser();
script = parser.syncPositionHoldsBeforeKeyframes(script);
script =
writer === "acorn"
? syncPositionHoldsBeforeKeyframes(script)
: (await loadGsapParser()).syncPositionHoldsBeforeKeyframes(script);
after = block.replaceScript(script);
}
}
@@ -2377,6 +2429,14 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
const files = body.files as AtomicCutFileRequest[];
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
let writer: "recast" | "acorn";
try {
writer = resolveGsapWriter({
HYPERFRAMES_GSAP_WRITER: process.env["HYPERFRAMES_GSAP_WRITER"],
});
} catch (error) {
return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
}
return serializeAtomicCut(async () => {
const seen = new Set<string>();
@@ -2407,7 +2467,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
}
let folded: FoldedAtomicCutFile | Response;
try {
folded = await foldAtomicCutFile(c, file, absPath, before);
folded = await foldAtomicCutFile(c, file, absPath, before, writer);
} catch (error) {
const message = error instanceof Error ? error.message : "Cut transform failed";
return c.json({ error: message }, 400);
@@ -0,0 +1,3 @@
import { renderGsapMutationCapabilityReport } from "./gsapMutationCapabilities.js";
process.stdout.write(renderGsapMutationCapabilityReport());
@@ -0,0 +1,77 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import {
GSAP_MUTATION_CAPABILITIES,
acornDefaultBlockers,
renderGsapMutationCapabilityReport,
resolveGsapWriter,
} from "./gsapMutationCapabilities.js";
const source = readFileSync(resolve(process.cwd(), "src/routes/files.ts"), "utf8");
function dispatcherCases(startMarker: string, endMarker: string): string[] {
const start = source.indexOf(startMarker);
const end = source.indexOf(endMarker, start + startMarker.length);
if (start < 0 || end < 0) throw new Error(`Could not locate dispatcher ${startMarker}`);
return [...source.slice(start, end).matchAll(/case\s+"([^"]+)"/g)].map((match) => match[1]!);
}
describe("GSAP writer capability matrix", () => {
const operations = Object.keys(GSAP_MUTATION_CAPABILITIES).sort();
it("matches every Acorn and Recast dispatcher case", () => {
expect(
dispatcherCases(
"function executeGsapMutationAcorn",
"async function executeGsapMutationRecast",
).sort(),
).toEqual(operations);
expect(
dispatcherCases(
"async function executeGsapMutationRecast",
"function registerFileRoutes",
).sort(),
).toEqual(operations);
});
it("keeps the Acorn dispatcher free of Recast and gates hold sync with the selected writer", () => {
const acornBody = source.slice(
source.indexOf("function executeGsapMutationAcorn"),
source.indexOf("async function executeGsapMutationRecast"),
);
const mutationHelperBody = source.slice(
source.indexOf("async function applyGsapMutations"),
source.indexOf("function executeGsapMutationAcorn"),
);
const atomicCutHelperBody = source.slice(
source.indexOf("async function foldAtomicCutFile"),
source.indexOf("function registerFileRoutes"),
);
expect(acornBody).not.toContain("loadGsapParser");
expect(mutationHelperBody).toContain('writer === "acorn"');
expect(mutationHelperBody).toContain("? syncPositionHoldsBeforeKeyframes(newScript)");
expect(mutationHelperBody).toContain(
"(await loadGsapParser()).syncPositionHoldsBeforeKeyframes",
);
expect(atomicCutHelperBody).toContain('writer === "acorn"');
expect(atomicCutHelperBody).toContain("? syncPositionHoldsBeforeKeyframes(script)");
expect(atomicCutHelperBody).toContain(
"(await loadGsapParser()).syncPositionHoldsBeforeKeyframes(script)",
);
});
it("renders every classified operation and keeps default blocked until parity is differential", () => {
const report = renderGsapMutationCapabilityReport();
for (const operation of operations) expect(report).toContain(`| ${operation} |`);
expect(acornDefaultBlockers().length).toBeGreaterThan(0);
});
it("defaults to Recast and requires an explicit Acorn canary selection", () => {
expect(resolveGsapWriter({})).toBe("recast");
expect(resolveGsapWriter({ HYPERFRAMES_GSAP_WRITER: "acorn" })).toBe("acorn");
expect(() => resolveGsapWriter({ HYPERFRAMES_GSAP_WRITER: "unknown" })).toThrow(
"expected recast or acorn",
);
});
});
@@ -0,0 +1,109 @@
import type { GsapMutationRequest } from "./files.js";
export type GsapMutationType = GsapMutationRequest["type"];
type GsapMutationFamily = "animation" | "keyframe" | "motion_path" | "structure" | "timing";
type GsapParityEvidence = "differential" | "behavioral";
interface GsapMutationCapability {
family: GsapMutationFamily;
acorn: "supported";
recast: "supported";
parity: GsapParityEvidence;
}
const differential = (family: GsapMutationFamily): GsapMutationCapability => ({
family,
acorn: "supported",
recast: "supported",
parity: "differential",
});
const behavioral = (family: GsapMutationFamily): GsapMutationCapability => ({
family,
acorn: "supported",
recast: "supported",
parity: "behavioral",
});
/** Compile-time exhaustive over the request union; runtime tests match both dispatchers. */
export const GSAP_MUTATION_CAPABILITIES = {
"update-property": differential("animation"),
"update-properties": differential("animation"),
"update-from-property": differential("animation"),
"update-meta": differential("animation"),
add: differential("animation"),
delete: differential("animation"),
"add-property": differential("animation"),
"add-from-property": differential("animation"),
"remove-property": differential("animation"),
"remove-from-property": differential("animation"),
"add-keyframe": differential("keyframe"),
"remove-keyframe": differential("keyframe"),
"move-keyframe": behavioral("keyframe"),
"resize-keyframed-tween": behavioral("keyframe"),
"update-keyframe": differential("keyframe"),
"convert-to-keyframes": behavioral("keyframe"),
"remove-all-keyframes": behavioral("keyframe"),
"materialize-keyframes": behavioral("keyframe"),
"set-arc-path": behavioral("motion_path"),
"update-arc-segment": behavioral("motion_path"),
"update-motion-path-point": differential("motion_path"),
"add-motion-path-point": differential("motion_path"),
"remove-motion-path-point": differential("motion_path"),
"add-motion-path": differential("motion_path"),
"remove-arc-path": behavioral("motion_path"),
"add-with-keyframes": behavioral("keyframe"),
"replace-with-keyframes": behavioral("keyframe"),
"split-animations": behavioral("structure"),
"split-into-property-groups": behavioral("structure"),
"delete-all-for-selector": behavioral("structure"),
"consolidate-position-writes": behavioral("structure"),
"unroll-timeline": behavioral("structure"),
"shift-positions": behavioral("timing"),
"shift-positions-batch": behavioral("timing"),
"scale-positions": behavioral("timing"),
} as const satisfies Record<GsapMutationType, GsapMutationCapability>;
const GSAP_WRITER_MIGRATION = Object.freeze({
flag: "HYPERFRAMES_GSAP_WRITER",
owner: "studio-foundations",
deadline: "2026-09-30",
graduationCriteria:
"Every operation has differential parity, the Acorn path imports no Recast runtime, and canary divergence is zero for the agreed soak window.",
});
export function resolveGsapWriter(env: { HYPERFRAMES_GSAP_WRITER?: string }): "recast" | "acorn" {
const configured = env.HYPERFRAMES_GSAP_WRITER ?? "recast";
if (configured === "recast" || configured === "acorn") return configured;
throw new Error(`Invalid ${GSAP_WRITER_MIGRATION.flag}=${configured}; expected recast or acorn`);
}
export function acornDefaultBlockers(): GsapMutationType[] {
return (
Object.entries(GSAP_MUTATION_CAPABILITIES) as Array<[GsapMutationType, GsapMutationCapability]>
)
.filter(([, capability]) => capability.parity !== "differential")
.map(([type]) => type);
}
export function renderGsapMutationCapabilityReport(): string {
const rows = (
Object.entries(GSAP_MUTATION_CAPABILITIES) as Array<[GsapMutationType, GsapMutationCapability]>
).map(
([type, capability]) =>
`| ${type} | ${capability.family} | ${capability.acorn} | ${capability.recast} | ${capability.parity} |`,
);
return [
"# GSAP writer capability report",
"",
`Owner: ${GSAP_WRITER_MIGRATION.owner}`,
`Deadline: ${GSAP_WRITER_MIGRATION.deadline}`,
`Flag: \`${GSAP_WRITER_MIGRATION.flag}=recast|acorn\``,
"",
"| Operation | Family | Acorn | Recast | Parity evidence |",
"| --- | --- | --- | --- | --- |",
...rows,
"",
`Default blockers: ${acornDefaultBlockers().join(", ") || "none"}`,
"",
].join("\n");
}