mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user