feat(studio): runtime-first dynamic keyframe system [8/10] (#1190)

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines

Baselines regenerated inside Dockerfile.test on the devbox to match
the current runtime init.ts changes. Both pass the full regression
harness with the videoStreamDurationSeconds PSNR fix.

* feat(studio): design panel integration, timeline polish, feature flag

* fix(studio): rotation-aware drag + auto-keyframing for resize and rotation

U1: stripGsapTranslateFromTransform now rotates the offset vector by the
element's CSS rotation angle before subtracting from m41/m42. Fixes
elements drifting from cursor during drag when rotated.

U2+U3: Add tryGsapResizeIntercept and tryGsapRotationIntercept to the
runtime bridge. Resize and rotation handle changes now create keyframes
via the same async pipeline as position drag. CSS path guards prevent
double-persistence for GSAP-animated elements.

* fix(studio): counter-rotate drag offset for css-rotated elements

CSS compose order is translate → rotate → transform. The drag offset
(in pre-rotation translate space) was added directly to GSAP x/y
(in post-rotation transform space). Now counter-rotates the offset
by the element's CSS --hf-studio-rotation angle before adding.

* feat(studio): add 'delete all keyframes' to diamond context menu

* fix(studio): include all animated properties in every keyframe commit

Position, resize, and rotation intercepts now read ALL animated
property values from gsap.getProperty() at commit time and include
them in the keyframe. Prevents other properties from jumping to
interpolated values between surrounding keyframes when only one
property (e.g., width) was explicitly changed.

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* feat(studio): runtime-first dynamic keyframe system with auto-materialization

Read GSAP keyframe data from the live runtime instead of only the AST parser.
Dynamic keyframes (loops, variables, computed selectors) now show diamonds
on timeline clips and animation cards in the design panel.

On first edit, dynamic code is automatically materialized:
- Unresolved keyframes (keyframes: kf) replaced with static object
- Unresolved selectors (tl.to(sel, ...)) entire loop unrolled into
  individual static tl.to() calls per element

Key changes:
- Parser: hasUnresolvedKeyframes/hasUnresolvedSelector flags
- Runtime bridge: scanAllRuntimeKeyframes reads tween.vars from iframe
- Tween cache: interval-based runtime scan for dynamic animations
- materializeKeyframesInScript + unrollDynamicAnimations parser functions
- Keyframe cache dual-writes both sourceFile#id and index.html#id keys
- commitMutation updates cache from mutation response
- easeEach placement fix (inside keyframes object, not tween vars)
This commit is contained in:
Miguel Ángel
2026-06-05 12:08:03 -04:00
committed by GitHub
parent d1aad77fd7
commit c1699ec98b
10 changed files with 774 additions and 39 deletions
+204 -23
View File
@@ -419,11 +419,8 @@ function findAllTweenCalls(
this.traverse(path);
return;
}
const selectorValue = resolveTargetSelector(args[0], path, scope, targetBindings);
if (!selectorValue) {
this.traverse(path);
return;
}
const selectorValue =
resolveTargetSelector(args[0], path, scope, targetBindings) ?? "__unresolved__";
if (method === "fromTo") {
results.push({
@@ -697,6 +694,7 @@ function tweenCallToAnimation(
const properties: Record<string, number | string> = {};
const extras: Record<string, unknown> = {};
let keyframesData: GsapKeyframesData | undefined;
let hasUnresolvedKeyframes = false;
for (const [key, val] of Object.entries(vars)) {
if (BUILTIN_VAR_KEYS.has(key)) continue;
@@ -705,6 +703,7 @@ function tweenCallToAnimation(
if (key === "keyframes") {
const kfNode = findPropertyNode(call.varsArg, "keyframes");
keyframesData = parseKeyframesNode(kfNode, scope);
if (!keyframesData && kfNode) hasUnresolvedKeyframes = true;
continue;
}
@@ -763,6 +762,8 @@ function tweenCallToAnimation(
};
if (Object.keys(extras).length > 0) anim.extras = extras;
if (keyframesData) anim.keyframes = keyframesData;
if (hasUnresolvedKeyframes) anim.hasUnresolvedKeyframes = true;
if (call.selector === "__unresolved__") anim.hasUnresolvedSelector = true;
return anim;
}
@@ -1174,6 +1175,7 @@ export function addKeyframeToScript(
percentage: number,
properties: Record<string, number | string>,
ease?: string,
backfillDefaults?: Record<string, number | string>,
): string {
const loc = locateAnimation(script, animationId);
if (!loc) return script;
@@ -1189,25 +1191,48 @@ export function addKeyframeToScript(
);
if (existingIdx !== -1) {
kfNode.properties[existingIdx].value = newValueNode;
return recast.print(loc.parsed.ast).code;
} else {
// Build the new property node with a quoted percentage key
const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0];
newProp.value = newValueNode;
// Insert in sorted order by percentage
let insertIdx = kfNode.properties.length;
for (let i = 0; i < kfNode.properties.length; i++) {
const key = isObjectProperty(kfNode.properties[i])
? propKeyName(kfNode.properties[i])
: undefined;
if (typeof key === "string" && percentageFromKey(key) > percentage) {
insertIdx = i;
break;
}
}
kfNode.properties.splice(insertIdx, 0, newProp);
}
// Build the new property node with a quoted percentage key
const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0];
newProp.value = newValueNode;
// Insert in sorted order by percentage
let insertIdx = kfNode.properties.length;
for (let i = 0; i < kfNode.properties.length; i++) {
const key = isObjectProperty(kfNode.properties[i])
? propKeyName(kfNode.properties[i])
: undefined;
if (typeof key === "string" && percentageFromKey(key) > percentage) {
insertIdx = i;
break;
// Backfill: when the new keyframe introduces properties absent from other
// keyframes, add default values so GSAP can interpolate them.
if (backfillDefaults) {
const newPropKeys = Object.keys(properties);
const pctProps = filterPercentageProps(kfNode);
for (const prop of pctProps) {
const key = propKeyName(prop);
if (key === pctKey) continue;
const valObj = prop.value;
if (!valObj || valObj.type !== "ObjectExpression") continue;
const existingKeys = new Set(
valObj.properties.filter((p: any) => isObjectProperty(p)).map((p: any) => propKeyName(p)),
);
for (const pk of newPropKeys) {
if (existingKeys.has(pk)) continue;
const defaultVal = backfillDefaults[pk];
if (defaultVal == null) continue;
const fillProp = parseExpr(`{ ${safeKey(pk)}: ${valueToCode(defaultVal)} }`).properties[0];
valObj.properties.push(fillProp);
}
}
}
kfNode.properties.splice(insertIdx, 0, newProp);
return recast.print(loc.parsed.ast).code;
}
@@ -1329,10 +1354,12 @@ function insertKeyframesProp(
varsArg: any,
fromProps: Record<string, number | string>,
toProps: Record<string, number | string>,
easeEach?: string,
): void {
const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} } }`;
const easeEntry = easeEach ? `, easeEach: ${JSON.stringify(easeEach)}` : "";
const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} }${easeEntry} }`;
const kfProp = parseExpr(`{ keyframes: {} }`).properties[0];
kfProp.value = parseExpr(kfCode);
if (varsArg?.type === "ObjectExpression") varsArg.properties.unshift(kfProp);
@@ -1359,10 +1386,9 @@ export function convertToKeyframesInScript(
const originalEase = anim.ease;
stripEditableAndEase(varsArg);
insertKeyframesProp(varsArg, fromProps, toProps);
insertKeyframesProp(varsArg, fromProps, toProps, originalEase || undefined);
if (originalEase) {
setVarsKey(varsArg, "easeEach", originalEase);
setVarsKey(varsArg, "ease", "none");
}
@@ -1400,3 +1426,158 @@ export function removeAllKeyframesFromScript(script: string, animationId: string
return recast.print(loc.parsed.ast).code;
}
/**
* Replace a dynamic `keyframes: <expr>` with a static percentage-keyframes object.
* Called when the user first edits a dynamically-generated keyframe in the studio.
*/
export function materializeKeyframesInScript(
script: string,
animationId: string,
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}>,
easeEach?: string,
resolvedSelector?: string,
): string {
const loc = locateAnimation(script, animationId);
if (!loc) return script;
const varsArg = loc.target.call.varsArg;
// Replace dynamic selector with resolved static string
if (resolvedSelector && loc.target.call.node.arguments[0]) {
loc.target.call.node.arguments[0] = parseExpr(JSON.stringify(resolvedSelector));
}
const entries: string[] = [];
const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
for (const kf of sorted) {
const propEntries = Object.entries(kf.properties).map(
([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`,
);
if (kf.ease) propEntries.push(`ease: ${JSON.stringify(kf.ease)}`);
entries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`);
}
if (easeEach) {
entries.push(`easeEach: ${JSON.stringify(easeEach)}`);
}
const kfObjCode = `{ ${entries.join(", ")} }`;
const kfParent = varsArg.properties.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === "keyframes",
);
if (kfParent) {
kfParent.value = parseExpr(kfObjCode);
} else {
const kfProp = parseExpr(`{ keyframes: ${kfObjCode} }`).properties[0];
varsArg.properties.unshift(kfProp);
}
removeVarsKey(varsArg, "easeEach");
return recast.print(loc.parsed.ast).code;
}
/**
* Replace a dynamic loop that generates multiple tween calls with individual
* static `tl.to()` calls — one per element. Finds the loop containing the
* animation and replaces the entire loop body with unrolled static calls.
*/
export function unrollDynamicAnimations(
script: string,
animationId: string,
elements: Array<{
selector: string;
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
easeEach?: string;
}>,
): string {
const loc = locateAnimation(script, animationId);
if (!loc) return script;
const varsArg = loc.target.call.varsArg;
// Read duration and ease from the original tween vars
const durationVal = extractLiteralValue(findPropertyNode(varsArg, "duration"), loc.parsed.scope);
const easeVal = extractLiteralValue(findPropertyNode(varsArg, "ease"), loc.parsed.scope);
const duration = typeof durationVal === "number" ? durationVal : 8;
const ease = typeof easeVal === "string" ? easeVal : "none";
const posArg = loc.target.call.positionArg;
const position = posArg ? extractLiteralValue(posArg, loc.parsed.scope) : 0;
const posCode =
typeof position === "number"
? String(position)
: typeof position === "string"
? JSON.stringify(position)
: "0";
// Find the enclosing loop (for/forEach) by walking up the AST path
let loopNode: any = null;
let current = loc.target.call.path;
while (current) {
const node = current.node ?? current.value;
if (
node?.type === "ForStatement" ||
node?.type === "ForInStatement" ||
node?.type === "ForOfStatement" ||
node?.type === "WhileStatement"
) {
loopNode = node;
break;
}
if (
node?.type === "ExpressionStatement" &&
node.expression?.type === "CallExpression" &&
node.expression.callee?.property?.name === "forEach"
) {
loopNode = node;
break;
}
current = current.parent ?? current.parentPath;
}
// Build replacement code: individual tl.to() calls for each element
const calls: string[] = [];
for (const el of elements) {
const kfEntries: string[] = [];
const sorted = el.keyframes.slice().sort((a, b) => a.percentage - b.percentage);
for (const kf of sorted) {
const propEntries = Object.entries(kf.properties).map(
([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`,
);
kfEntries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`);
}
if (el.easeEach) {
kfEntries.push(`easeEach: ${JSON.stringify(el.easeEach)}`);
}
calls.push(
`tl.to(${JSON.stringify(el.selector)}, { keyframes: { ${kfEntries.join(", ")} }, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`,
);
}
const replacement = calls.join("\n ");
if (loopNode) {
// Replace the entire loop with the unrolled calls
const start = loopNode.start ?? loopNode.range?.[0];
const end = loopNode.end ?? loopNode.range?.[1];
if (typeof start === "number" && typeof end === "number") {
return script.slice(0, start) + replacement + script.slice(end);
}
}
// Fallback: replace just the tween call's enclosing expression statement
const stmtNode = loc.target.call.path?.parent?.node ?? loc.target.call.path?.parentPath?.node;
if (stmtNode?.type === "ExpressionStatement") {
const start = stmtNode.start ?? stmtNode.range?.[0];
const end = stmtNode.end ?? stmtNode.range?.[1];
if (typeof start === "number" && typeof end === "number") {
return script.slice(0, start) + replacement + script.slice(end);
}
}
return script;
}
@@ -23,6 +23,10 @@ export interface GsapAnimation {
extras?: Record<string, unknown>;
/** Native GSAP keyframes data — present when the tween uses keyframes: { ... }. */
keyframes?: GsapKeyframesData;
/** True when the tween has a `keyframes` property that couldn't be statically resolved (dynamic). */
hasUnresolvedKeyframes?: boolean;
/** True when the tween's target selector couldn't be statically resolved (dynamic). */
hasUnresolvedSelector?: boolean;
}
export interface GsapPercentageKeyframe {
+68 -1
View File
@@ -22,6 +22,7 @@ import {
removeElementFromHtml,
patchElementInHtml,
probeElementInSource,
splitElementInHtml,
type PatchOperation,
} from "../helpers/sourceMutation.js";
import { parseHTML } from "linkedom";
@@ -316,6 +317,39 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
);
});
api.post("/projects/:id/file-mutations/split-element/*", async (c) => {
const ctx = await resolveFileMutationContext(c, adapter, "split-element");
if ("error" in ctx) return ctx.error;
const parsed = await parseMutationBody<{
target?: { id?: string; selector?: string; selectorIndex?: number };
splitTime?: number;
newId?: string;
}>(c);
if ("error" in parsed) return parsed.error;
if (typeof parsed.body.splitTime !== "number" || !parsed.body.newId) {
return c.json({ error: "target, splitTime, and newId required" }, 400);
}
let originalContent: string;
try {
originalContent = readFileSync(ctx.absPath, "utf-8");
} catch {
return c.json({ error: "not found" }, 404);
}
const result = splitElementInHtml(
originalContent,
parsed.target,
parsed.body.splitTime,
parsed.body.newId,
);
if (!result.matched) {
return c.json({ ok: false, changed: false, content: originalContent });
}
writeFileSync(ctx.absPath, result.html, "utf-8");
return c.json({ ok: true, changed: true, content: result.html, newId: result.newId });
});
api.post("/projects/:id/file-mutations/patch-element/*", async (c) => {
const ctx = await resolveFileMutationContext(c, adapter, "patch-element");
if ("error" in ctx) return ctx.error;
@@ -586,6 +620,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
percentage: number;
properties: Record<string, number | string>;
ease?: string;
backfillDefaults?: Record<string, number | string>;
}
| { type: "remove-keyframe"; animationId: string; percentage: number }
| {
@@ -600,7 +635,23 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
animationId: string;
resolvedFromValues?: Record<string, number | string>;
}
| { type: "remove-all-keyframes"; animationId: string };
| { type: "remove-all-keyframes"; animationId: string }
| {
type: "materialize-keyframes";
animationId: string;
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}>;
easeEach?: string;
resolvedSelector?: string;
allElements?: Array<{
selector: string;
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
easeEach?: string;
}>;
};
api.post("/projects/:id/gsap-mutations/*", async (c) => {
const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, {
@@ -735,6 +786,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
body.percentage,
body.properties,
body.ease,
body.backfillDefaults,
);
break;
}
@@ -768,6 +820,21 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
newScript = removeAllKeyframesFromScript(block.scriptText, body.animationId);
break;
}
case "materialize-keyframes": {
const { materializeKeyframesInScript, unrollDynamicAnimations } = await loadGsapParser();
if (body.allElements && body.allElements.length > 0) {
newScript = unrollDynamicAnimations(block.scriptText, body.animationId, body.allElements);
} else {
newScript = materializeKeyframesInScript(
block.scriptText,
body.animationId,
body.keyframes,
body.easeEach,
body.resolvedSelector,
);
}
break;
}
default:
return c.json({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
}