mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -419,11 +419,8 @@ function findAllTweenCalls(
|
|||||||
this.traverse(path);
|
this.traverse(path);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selectorValue = resolveTargetSelector(args[0], path, scope, targetBindings);
|
const selectorValue =
|
||||||
if (!selectorValue) {
|
resolveTargetSelector(args[0], path, scope, targetBindings) ?? "__unresolved__";
|
||||||
this.traverse(path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === "fromTo") {
|
if (method === "fromTo") {
|
||||||
results.push({
|
results.push({
|
||||||
@@ -697,6 +694,7 @@ function tweenCallToAnimation(
|
|||||||
const properties: Record<string, number | string> = {};
|
const properties: Record<string, number | string> = {};
|
||||||
const extras: Record<string, unknown> = {};
|
const extras: Record<string, unknown> = {};
|
||||||
let keyframesData: GsapKeyframesData | undefined;
|
let keyframesData: GsapKeyframesData | undefined;
|
||||||
|
let hasUnresolvedKeyframes = false;
|
||||||
|
|
||||||
for (const [key, val] of Object.entries(vars)) {
|
for (const [key, val] of Object.entries(vars)) {
|
||||||
if (BUILTIN_VAR_KEYS.has(key)) continue;
|
if (BUILTIN_VAR_KEYS.has(key)) continue;
|
||||||
@@ -705,6 +703,7 @@ function tweenCallToAnimation(
|
|||||||
if (key === "keyframes") {
|
if (key === "keyframes") {
|
||||||
const kfNode = findPropertyNode(call.varsArg, "keyframes");
|
const kfNode = findPropertyNode(call.varsArg, "keyframes");
|
||||||
keyframesData = parseKeyframesNode(kfNode, scope);
|
keyframesData = parseKeyframesNode(kfNode, scope);
|
||||||
|
if (!keyframesData && kfNode) hasUnresolvedKeyframes = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,6 +762,8 @@ function tweenCallToAnimation(
|
|||||||
};
|
};
|
||||||
if (Object.keys(extras).length > 0) anim.extras = extras;
|
if (Object.keys(extras).length > 0) anim.extras = extras;
|
||||||
if (keyframesData) anim.keyframes = keyframesData;
|
if (keyframesData) anim.keyframes = keyframesData;
|
||||||
|
if (hasUnresolvedKeyframes) anim.hasUnresolvedKeyframes = true;
|
||||||
|
if (call.selector === "__unresolved__") anim.hasUnresolvedSelector = true;
|
||||||
return anim;
|
return anim;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1174,6 +1175,7 @@ export function addKeyframeToScript(
|
|||||||
percentage: number,
|
percentage: number,
|
||||||
properties: Record<string, number | string>,
|
properties: Record<string, number | string>,
|
||||||
ease?: string,
|
ease?: string,
|
||||||
|
backfillDefaults?: Record<string, number | string>,
|
||||||
): string {
|
): string {
|
||||||
const loc = locateAnimation(script, animationId);
|
const loc = locateAnimation(script, animationId);
|
||||||
if (!loc) return script;
|
if (!loc) return script;
|
||||||
@@ -1189,25 +1191,48 @@ export function addKeyframeToScript(
|
|||||||
);
|
);
|
||||||
if (existingIdx !== -1) {
|
if (existingIdx !== -1) {
|
||||||
kfNode.properties[existingIdx].value = newValueNode;
|
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
|
// Backfill: when the new keyframe introduces properties absent from other
|
||||||
const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0];
|
// keyframes, add default values so GSAP can interpolate them.
|
||||||
newProp.value = newValueNode;
|
if (backfillDefaults) {
|
||||||
|
const newPropKeys = Object.keys(properties);
|
||||||
// Insert in sorted order by percentage
|
const pctProps = filterPercentageProps(kfNode);
|
||||||
let insertIdx = kfNode.properties.length;
|
for (const prop of pctProps) {
|
||||||
for (let i = 0; i < kfNode.properties.length; i++) {
|
const key = propKeyName(prop);
|
||||||
const key = isObjectProperty(kfNode.properties[i])
|
if (key === pctKey) continue;
|
||||||
? propKeyName(kfNode.properties[i])
|
const valObj = prop.value;
|
||||||
: undefined;
|
if (!valObj || valObj.type !== "ObjectExpression") continue;
|
||||||
if (typeof key === "string" && percentageFromKey(key) > percentage) {
|
const existingKeys = new Set(
|
||||||
insertIdx = i;
|
valObj.properties.filter((p: any) => isObjectProperty(p)).map((p: any) => propKeyName(p)),
|
||||||
break;
|
);
|
||||||
|
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;
|
return recast.print(loc.parsed.ast).code;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1329,10 +1354,12 @@ function insertKeyframesProp(
|
|||||||
varsArg: any,
|
varsArg: any,
|
||||||
fromProps: Record<string, number | string>,
|
fromProps: Record<string, number | string>,
|
||||||
toProps: Record<string, number | string>,
|
toProps: Record<string, number | string>,
|
||||||
|
easeEach?: string,
|
||||||
): void {
|
): void {
|
||||||
const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
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 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];
|
const kfProp = parseExpr(`{ keyframes: {} }`).properties[0];
|
||||||
kfProp.value = parseExpr(kfCode);
|
kfProp.value = parseExpr(kfCode);
|
||||||
if (varsArg?.type === "ObjectExpression") varsArg.properties.unshift(kfProp);
|
if (varsArg?.type === "ObjectExpression") varsArg.properties.unshift(kfProp);
|
||||||
@@ -1359,10 +1386,9 @@ export function convertToKeyframesInScript(
|
|||||||
const originalEase = anim.ease;
|
const originalEase = anim.ease;
|
||||||
|
|
||||||
stripEditableAndEase(varsArg);
|
stripEditableAndEase(varsArg);
|
||||||
insertKeyframesProp(varsArg, fromProps, toProps);
|
insertKeyframesProp(varsArg, fromProps, toProps, originalEase || undefined);
|
||||||
|
|
||||||
if (originalEase) {
|
if (originalEase) {
|
||||||
setVarsKey(varsArg, "easeEach", originalEase);
|
|
||||||
setVarsKey(varsArg, "ease", "none");
|
setVarsKey(varsArg, "ease", "none");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1400,3 +1426,158 @@ export function removeAllKeyframesFromScript(script: string, animationId: string
|
|||||||
|
|
||||||
return recast.print(loc.parsed.ast).code;
|
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>;
|
extras?: Record<string, unknown>;
|
||||||
/** Native GSAP keyframes data — present when the tween uses keyframes: { ... }. */
|
/** Native GSAP keyframes data — present when the tween uses keyframes: { ... }. */
|
||||||
keyframes?: GsapKeyframesData;
|
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 {
|
export interface GsapPercentageKeyframe {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
removeElementFromHtml,
|
removeElementFromHtml,
|
||||||
patchElementInHtml,
|
patchElementInHtml,
|
||||||
probeElementInSource,
|
probeElementInSource,
|
||||||
|
splitElementInHtml,
|
||||||
type PatchOperation,
|
type PatchOperation,
|
||||||
} from "../helpers/sourceMutation.js";
|
} from "../helpers/sourceMutation.js";
|
||||||
import { parseHTML } from "linkedom";
|
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) => {
|
api.post("/projects/:id/file-mutations/patch-element/*", async (c) => {
|
||||||
const ctx = await resolveFileMutationContext(c, adapter, "patch-element");
|
const ctx = await resolveFileMutationContext(c, adapter, "patch-element");
|
||||||
if ("error" in ctx) return ctx.error;
|
if ("error" in ctx) return ctx.error;
|
||||||
@@ -586,6 +620,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
|||||||
percentage: number;
|
percentage: number;
|
||||||
properties: Record<string, number | string>;
|
properties: Record<string, number | string>;
|
||||||
ease?: string;
|
ease?: string;
|
||||||
|
backfillDefaults?: Record<string, number | string>;
|
||||||
}
|
}
|
||||||
| { type: "remove-keyframe"; animationId: string; percentage: number }
|
| { type: "remove-keyframe"; animationId: string; percentage: number }
|
||||||
| {
|
| {
|
||||||
@@ -600,7 +635,23 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
|||||||
animationId: string;
|
animationId: string;
|
||||||
resolvedFromValues?: Record<string, number | 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) => {
|
api.post("/projects/:id/gsap-mutations/*", async (c) => {
|
||||||
const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, {
|
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.percentage,
|
||||||
body.properties,
|
body.properties,
|
||||||
body.ease,
|
body.ease,
|
||||||
|
body.backfillDefaults,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -768,6 +820,21 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
|||||||
newScript = removeAllKeyframesFromScript(block.scriptText, body.animationId);
|
newScript = removeAllKeyframesFromScript(block.scriptText, body.animationId);
|
||||||
break;
|
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:
|
default:
|
||||||
return c.json({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
|
return c.json({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
getTimelineZoomPercent,
|
getTimelineZoomPercent,
|
||||||
} from "../player/components/timelineZoom";
|
} from "../player/components/timelineZoom";
|
||||||
import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
|
import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
|
||||||
import { usePlayerStore } from "../player";
|
import { usePlayerStore, type TimelineElement } from "../player";
|
||||||
import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
|
import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
|
||||||
import { Tooltip } from "./ui";
|
import { Tooltip } from "./ui";
|
||||||
import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
||||||
@@ -85,6 +85,7 @@ interface DomEditSessionSlice {
|
|||||||
handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
|
handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
|
||||||
handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void;
|
handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void;
|
||||||
handleGsapConvertToKeyframes: (animId: string) => void;
|
handleGsapConvertToKeyframes: (animId: string) => void;
|
||||||
|
handleGsapMaterializeKeyframes: (animId: string) => Promise<void>;
|
||||||
handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
||||||
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
|
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
|
||||||
}
|
}
|
||||||
@@ -92,6 +93,7 @@ interface DomEditSessionSlice {
|
|||||||
interface TimelineToolbarProps {
|
interface TimelineToolbarProps {
|
||||||
toggleTimelineVisibility: () => void;
|
toggleTimelineVisibility: () => void;
|
||||||
domEditSession?: DomEditSessionSlice;
|
domEditSession?: DomEditSessionSlice;
|
||||||
|
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
@@ -119,9 +121,12 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
|||||||
|
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
const onToggle = sel
|
const onToggle = sel
|
||||||
? () => {
|
? async () => {
|
||||||
const t = usePlayerStore.getState().currentTime;
|
const t = usePlayerStore.getState().currentTime;
|
||||||
if (kfAnim?.keyframes) {
|
if (kfAnim?.keyframes) {
|
||||||
|
if (kfAnim.hasUnresolvedKeyframes) {
|
||||||
|
await session.handleGsapMaterializeKeyframes(kfAnim.id);
|
||||||
|
}
|
||||||
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
||||||
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
||||||
const pct =
|
const pct =
|
||||||
@@ -161,6 +166,7 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
|
|||||||
export function TimelineToolbar({
|
export function TimelineToolbar({
|
||||||
toggleTimelineVisibility,
|
toggleTimelineVisibility,
|
||||||
domEditSession,
|
domEditSession,
|
||||||
|
onSplitElement,
|
||||||
}: TimelineToolbarProps) {
|
}: TimelineToolbarProps) {
|
||||||
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
||||||
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
||||||
@@ -212,6 +218,38 @@ export function TimelineToolbar({
|
|||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{onSplitElement && (
|
||||||
|
<Tooltip label="Split clip at playhead (S)">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
|
||||||
|
if (!selectedElementId) return;
|
||||||
|
const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
|
||||||
|
if (el && currentTime > el.start && currentTime < el.start + el.duration) {
|
||||||
|
onSplitElement(el, currentTime);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded text-neutral-500 transition-colors hover:text-neutral-200"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<line x1="8" y1="2" x2="8" y2="14" />
|
||||||
|
<polyline points="5,5 3,2" />
|
||||||
|
<polyline points="11,5 13,2" />
|
||||||
|
<polyline points="5,11 3,14" />
|
||||||
|
<polyline points="11,11 13,14" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Tooltip label="Fit timeline to width">
|
<Tooltip label="Fit timeline to width">
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export function DomEditProvider({
|
|||||||
handleGsapAddKeyframe,
|
handleGsapAddKeyframe,
|
||||||
handleGsapRemoveKeyframe,
|
handleGsapRemoveKeyframe,
|
||||||
handleGsapConvertToKeyframes,
|
handleGsapConvertToKeyframes,
|
||||||
|
handleGsapMaterializeKeyframes,
|
||||||
handleGsapRemoveAllKeyframes,
|
handleGsapRemoveAllKeyframes,
|
||||||
handleResetSelectedElementKeyframes,
|
handleResetSelectedElementKeyframes,
|
||||||
invalidateGsapCache,
|
invalidateGsapCache,
|
||||||
@@ -135,6 +136,7 @@ export function DomEditProvider({
|
|||||||
handleGsapAddKeyframe,
|
handleGsapAddKeyframe,
|
||||||
handleGsapRemoveKeyframe,
|
handleGsapRemoveKeyframe,
|
||||||
handleGsapConvertToKeyframes,
|
handleGsapConvertToKeyframes,
|
||||||
|
handleGsapMaterializeKeyframes,
|
||||||
handleGsapRemoveAllKeyframes,
|
handleGsapRemoveAllKeyframes,
|
||||||
handleResetSelectedElementKeyframes,
|
handleResetSelectedElementKeyframes,
|
||||||
invalidateGsapCache,
|
invalidateGsapCache,
|
||||||
@@ -196,6 +198,7 @@ export function DomEditProvider({
|
|||||||
handleGsapAddKeyframe,
|
handleGsapAddKeyframe,
|
||||||
handleGsapRemoveKeyframe,
|
handleGsapRemoveKeyframe,
|
||||||
handleGsapConvertToKeyframes,
|
handleGsapConvertToKeyframes,
|
||||||
|
handleGsapMaterializeKeyframes,
|
||||||
handleGsapRemoveAllKeyframes,
|
handleGsapRemoveAllKeyframes,
|
||||||
handleResetSelectedElementKeyframes,
|
handleResetSelectedElementKeyframes,
|
||||||
invalidateGsapCache,
|
invalidateGsapCache,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|||||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||||
import { clearStudioPathOffset } from "../components/editor/manualEdits";
|
import { clearStudioPathOffset } from "../components/editor/manualEdits";
|
||||||
import { usePlayerStore } from "../player/store/playerStore";
|
import { usePlayerStore } from "../player/store/playerStore";
|
||||||
|
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
||||||
|
|
||||||
// ── Runtime reads ──────────────────────────────────────────────────────────
|
// ── Runtime reads ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -99,6 +100,52 @@ function computeCurrentPercentage(selection: DomEditSelection): number {
|
|||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dynamic keyframe materialization ──────────────────────────────────────
|
||||||
|
|
||||||
|
async function materializeIfDynamic(
|
||||||
|
anim: GsapAnimation,
|
||||||
|
iframe: HTMLIFrameElement | null,
|
||||||
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
||||||
|
selection: DomEditSelection,
|
||||||
|
): Promise<string | void> {
|
||||||
|
if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return;
|
||||||
|
|
||||||
|
if (anim.hasUnresolvedSelector) {
|
||||||
|
// Unroll: read ALL elements' keyframes from runtime and replace the loop
|
||||||
|
const allScanned = scanAllRuntimeKeyframes(iframe);
|
||||||
|
if (allScanned.size === 0) return;
|
||||||
|
const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
|
||||||
|
selector: `#${id}`,
|
||||||
|
keyframes: data.keyframes,
|
||||||
|
easeEach: data.easeEach,
|
||||||
|
}));
|
||||||
|
await commitMutation(
|
||||||
|
selection,
|
||||||
|
{
|
||||||
|
type: "materialize-keyframes",
|
||||||
|
animationId: anim.id,
|
||||||
|
keyframes: allScanned.get(selection.id ?? "")?.keyframes ?? [],
|
||||||
|
allElements,
|
||||||
|
},
|
||||||
|
{ label: "Unroll dynamic animations", skipReload: true },
|
||||||
|
);
|
||||||
|
return `${anim.targetSelector}-to-0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
|
||||||
|
if (!runtime || runtime.keyframes.length === 0) return;
|
||||||
|
await commitMutation(
|
||||||
|
selection,
|
||||||
|
{
|
||||||
|
type: "materialize-keyframes",
|
||||||
|
animationId: anim.id,
|
||||||
|
keyframes: runtime.keyframes,
|
||||||
|
easeEach: runtime.easeEach,
|
||||||
|
},
|
||||||
|
{ label: "Materialize dynamic keyframes", skipReload: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── High-level intercept ───────────────────────────────────────────────────
|
// ── High-level intercept ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface GsapDragCommitCallbacks {
|
export interface GsapDragCommitCallbacks {
|
||||||
@@ -192,10 +239,12 @@ async function commitGsapPositionFromDrag(
|
|||||||
const clearOffset = () => clearStudioPathOffset(selection.element);
|
const clearOffset = () => clearStudioPathOffset(selection.element);
|
||||||
|
|
||||||
if (anim.keyframes) {
|
if (anim.keyframes) {
|
||||||
|
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
||||||
|
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
|
||||||
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
|
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
|
||||||
await commitKeyframedPosition(
|
await commitKeyframedPosition(
|
||||||
selection,
|
selection,
|
||||||
anim,
|
effectiveAnim,
|
||||||
{ ...runtimeProps, x: newX, y: newY },
|
{ ...runtimeProps, x: newX, y: newY },
|
||||||
callbacks,
|
callbacks,
|
||||||
clearOffset,
|
clearOffset,
|
||||||
@@ -334,6 +383,24 @@ async function commitFromToPosition(
|
|||||||
|
|
||||||
// ── Runtime property reader ───────────────────────────────────────────────
|
// ── Runtime property reader ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function readGsapProperty(
|
||||||
|
iframe: HTMLIFrameElement | null,
|
||||||
|
selector: string | null,
|
||||||
|
prop: string,
|
||||||
|
): number | null {
|
||||||
|
if (!iframe?.contentWindow || !selector) return null;
|
||||||
|
try {
|
||||||
|
const gsap = (iframe.contentWindow as unknown as { gsap?: IframeGsap }).gsap;
|
||||||
|
if (!gsap?.getProperty) return null;
|
||||||
|
const el = iframe.contentDocument?.querySelector(selector);
|
||||||
|
if (!el) return null;
|
||||||
|
const val = Number(gsap.getProperty(el, prop));
|
||||||
|
return Number.isFinite(val) ? Math.round(val) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function readAllAnimatedProperties(
|
function readAllAnimatedProperties(
|
||||||
iframe: HTMLIFrameElement | null,
|
iframe: HTMLIFrameElement | null,
|
||||||
selector: string,
|
selector: string,
|
||||||
@@ -396,7 +463,10 @@ export async function tryGsapResizeIntercept(
|
|||||||
|
|
||||||
const pct = computeCurrentPercentage(selection);
|
const pct = computeCurrentPercentage(selection);
|
||||||
|
|
||||||
if (!anim.keyframes) {
|
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
||||||
|
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
||||||
|
if (newId) anim = { ...anim, id: newId };
|
||||||
|
} else if (!anim.keyframes) {
|
||||||
await commitMutation(
|
await commitMutation(
|
||||||
selection,
|
selection,
|
||||||
{ type: "convert-to-keyframes", animationId: anim.id },
|
{ type: "convert-to-keyframes", animationId: anim.id },
|
||||||
@@ -406,6 +476,17 @@ export async function tryGsapResizeIntercept(
|
|||||||
|
|
||||||
const selector = selectorForSelection(selection);
|
const selector = selectorForSelection(selection);
|
||||||
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
|
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
|
||||||
|
|
||||||
|
const backfillDefaults: Record<string, number> = { ...runtimeProps };
|
||||||
|
if (!("width" in runtimeProps)) {
|
||||||
|
const cssW = readGsapProperty(iframe, selector, "width");
|
||||||
|
backfillDefaults.width = cssW ?? Math.round(size.width);
|
||||||
|
}
|
||||||
|
if (!("height" in runtimeProps)) {
|
||||||
|
const cssH = readGsapProperty(iframe, selector, "height");
|
||||||
|
backfillDefaults.height = cssH ?? Math.round(size.height);
|
||||||
|
}
|
||||||
|
|
||||||
const properties = {
|
const properties = {
|
||||||
...runtimeProps,
|
...runtimeProps,
|
||||||
width: Math.round(size.width),
|
width: Math.round(size.width),
|
||||||
@@ -419,6 +500,7 @@ export async function tryGsapResizeIntercept(
|
|||||||
animationId: anim.id,
|
animationId: anim.id,
|
||||||
percentage: pct,
|
percentage: pct,
|
||||||
properties,
|
properties,
|
||||||
|
backfillDefaults,
|
||||||
},
|
},
|
||||||
{ label: `Resize (keyframe ${pct}%)`, softReload: true },
|
{ label: `Resize (keyframe ${pct}%)`, softReload: true },
|
||||||
);
|
);
|
||||||
@@ -466,7 +548,10 @@ export async function tryGsapRotationIntercept(
|
|||||||
const pct = computeCurrentPercentage(selection);
|
const pct = computeCurrentPercentage(selection);
|
||||||
const newRotation = Math.round(gsapRotation + angle);
|
const newRotation = Math.round(gsapRotation + angle);
|
||||||
|
|
||||||
if (!anim.keyframes) {
|
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
||||||
|
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
||||||
|
if (newId) anim = { ...anim, id: newId };
|
||||||
|
} else if (!anim.keyframes) {
|
||||||
await commitMutation(
|
await commitMutation(
|
||||||
selection,
|
selection,
|
||||||
{ type: "convert-to-keyframes", animationId: anim.id },
|
{ type: "convert-to-keyframes", animationId: anim.id },
|
||||||
@@ -475,6 +560,12 @@ export async function tryGsapRotationIntercept(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
|
const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
|
||||||
|
|
||||||
|
const backfillDefaults: Record<string, number> = { ...runtimeProps };
|
||||||
|
if (!("rotation" in runtimeProps)) {
|
||||||
|
backfillDefaults.rotation = readGsapProperty(iframe, selector, "rotation") ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
const properties = { ...runtimeProps, rotation: newRotation };
|
const properties = { ...runtimeProps, rotation: newRotation };
|
||||||
|
|
||||||
await commitMutation(
|
await commitMutation(
|
||||||
@@ -484,8 +575,11 @@ export async function tryGsapRotationIntercept(
|
|||||||
animationId: anim.id,
|
animationId: anim.id,
|
||||||
percentage: pct,
|
percentage: pct,
|
||||||
properties,
|
properties,
|
||||||
|
backfillDefaults,
|
||||||
},
|
},
|
||||||
{ label: `Rotate (keyframe ${pct}%)`, softReload: true },
|
{ label: `Rotate (keyframe ${pct}%)`, softReload: true },
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Read GSAP keyframe data from the live runtime in the preview iframe.
|
||||||
|
* Used to discover dynamic keyframes that the AST parser can't resolve
|
||||||
|
* (loops, variables, computed selectors).
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface RuntimeTween {
|
||||||
|
targets?: () => Element[];
|
||||||
|
vars?: Record<string, unknown>;
|
||||||
|
duration?: () => number;
|
||||||
|
startTime?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeTimeline {
|
||||||
|
getChildren?: (deep: boolean) => RuntimeTween[];
|
||||||
|
duration?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readRuntimeKeyframes(
|
||||||
|
iframe: HTMLIFrameElement | null,
|
||||||
|
selector: string,
|
||||||
|
compositionId?: string,
|
||||||
|
): {
|
||||||
|
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
||||||
|
easeEach?: string;
|
||||||
|
} | null {
|
||||||
|
if (!iframe?.contentWindow) return null;
|
||||||
|
|
||||||
|
let timelines: Record<string, RuntimeTimeline | undefined> | undefined;
|
||||||
|
try {
|
||||||
|
timelines = (
|
||||||
|
iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> }
|
||||||
|
).__timelines;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!timelines) return null;
|
||||||
|
|
||||||
|
const tlId = compositionId || Object.keys(timelines)[0];
|
||||||
|
if (!tlId) return null;
|
||||||
|
const timeline = timelines[tlId];
|
||||||
|
if (!timeline?.getChildren) return null;
|
||||||
|
|
||||||
|
let doc: Document | null = null;
|
||||||
|
try {
|
||||||
|
doc = iframe.contentDocument;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!doc) return null;
|
||||||
|
|
||||||
|
const targetEl = doc.querySelector(selector);
|
||||||
|
if (!targetEl) return null;
|
||||||
|
|
||||||
|
for (const tween of timeline.getChildren(true)) {
|
||||||
|
if (!tween.targets || !tween.vars) continue;
|
||||||
|
let matches = false;
|
||||||
|
for (const t of tween.targets()) {
|
||||||
|
if (t === targetEl || (targetEl.id && t.id === targetEl.id)) {
|
||||||
|
matches = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matches) continue;
|
||||||
|
|
||||||
|
const vars = tween.vars;
|
||||||
|
if (!vars.keyframes || typeof vars.keyframes !== "object") continue;
|
||||||
|
|
||||||
|
const kfObj = vars.keyframes as Record<string, unknown>;
|
||||||
|
const result: Array<{ percentage: number; properties: Record<string, number | string> }> = [];
|
||||||
|
let easeEach: string | undefined;
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(kfObj)) {
|
||||||
|
if (key === "easeEach") {
|
||||||
|
if (typeof val === "string") easeEach = val;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
|
||||||
|
if (!pctMatch || !val || typeof val !== "object") continue;
|
||||||
|
const percentage = parseFloat(pctMatch[1]);
|
||||||
|
const properties: Record<string, number | string> = {};
|
||||||
|
for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
|
||||||
|
if (pk === "ease") continue;
|
||||||
|
if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
|
||||||
|
else if (typeof pv === "string") properties[pk] = pv;
|
||||||
|
}
|
||||||
|
if (Object.keys(properties).length > 0) {
|
||||||
|
result.push({ percentage, properties });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
result.sort((a, b) => a.percentage - b.percentage);
|
||||||
|
return { keyframes: result, easeEach };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
||||||
|
easeEach?: string;
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
const result = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
||||||
|
easeEach?: string;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
if (!iframe?.contentWindow) return result;
|
||||||
|
|
||||||
|
let timelines: Record<string, RuntimeTimeline | undefined> | undefined;
|
||||||
|
try {
|
||||||
|
timelines = (
|
||||||
|
iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> }
|
||||||
|
).__timelines;
|
||||||
|
} catch {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (!timelines) return result;
|
||||||
|
|
||||||
|
for (const timeline of Object.values(timelines)) {
|
||||||
|
if (!timeline?.getChildren) continue;
|
||||||
|
for (const tween of timeline.getChildren(true)) {
|
||||||
|
if (!tween.targets || !tween.vars) continue;
|
||||||
|
const vars = tween.vars;
|
||||||
|
if (!vars.keyframes || typeof vars.keyframes !== "object") continue;
|
||||||
|
|
||||||
|
const kfObj = vars.keyframes as Record<string, unknown>;
|
||||||
|
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
||||||
|
[];
|
||||||
|
let easeEach: string | undefined;
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(kfObj)) {
|
||||||
|
if (key === "easeEach") {
|
||||||
|
if (typeof val === "string") easeEach = val;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
|
||||||
|
if (!pctMatch || !val || typeof val !== "object") continue;
|
||||||
|
const percentage = parseFloat(pctMatch[1]);
|
||||||
|
const properties: Record<string, number | string> = {};
|
||||||
|
for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
|
||||||
|
if (pk === "ease") continue;
|
||||||
|
if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
|
||||||
|
else if (typeof pv === "string") properties[pk] = pv;
|
||||||
|
}
|
||||||
|
if (Object.keys(properties).length > 0) {
|
||||||
|
keyframes.push({ percentage, properties });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyframes.length === 0) continue;
|
||||||
|
keyframes.sort((a, b) => a.percentage - b.percentage);
|
||||||
|
|
||||||
|
for (const target of tween.targets()) {
|
||||||
|
const id = (target as HTMLElement).id;
|
||||||
|
if (id && !result.has(id)) {
|
||||||
|
result.set(id, { keyframes, easeEach });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
tryGsapDragIntercept,
|
tryGsapDragIntercept,
|
||||||
tryGsapResizeIntercept,
|
tryGsapResizeIntercept,
|
||||||
tryGsapRotationIntercept,
|
tryGsapRotationIntercept,
|
||||||
|
readRuntimeKeyframes,
|
||||||
} from "./gsapRuntimeBridge";
|
} from "./gsapRuntimeBridge";
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
@@ -215,6 +216,7 @@ export function useDomEditSession({
|
|||||||
STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null,
|
STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null,
|
||||||
gsapSourceFile,
|
gsapSourceFile,
|
||||||
gsapCacheVersion,
|
gsapCacheVersion,
|
||||||
|
previewIframeRef,
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -228,6 +230,7 @@ export function useDomEditSession({
|
|||||||
? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null }
|
? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null }
|
||||||
: null,
|
: null,
|
||||||
gsapCacheVersion,
|
gsapCacheVersion,
|
||||||
|
previewIframeRef,
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -491,6 +494,49 @@ export function useDomEditSession({
|
|||||||
[domEditSelection, convertToKeyframes],
|
[domEditSelection, convertToKeyframes],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleGsapMaterializeKeyframes = useCallback(
|
||||||
|
async (animId: string) => {
|
||||||
|
if (!domEditSelection || !gsapCommitMutation) return;
|
||||||
|
const anim = selectedGsapAnimations.find((a) => a.id === animId);
|
||||||
|
if (!anim || (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) || !anim.keyframes)
|
||||||
|
return;
|
||||||
|
if (anim.hasUnresolvedSelector) {
|
||||||
|
const { scanAllRuntimeKeyframes } = await import("./gsapRuntimeKeyframes");
|
||||||
|
const allScanned = scanAllRuntimeKeyframes(previewIframeRef.current);
|
||||||
|
if (allScanned.size === 0) return;
|
||||||
|
const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
|
||||||
|
selector: `#${id}`,
|
||||||
|
keyframes: data.keyframes,
|
||||||
|
easeEach: data.easeEach,
|
||||||
|
}));
|
||||||
|
await gsapCommitMutation(
|
||||||
|
domEditSelection,
|
||||||
|
{
|
||||||
|
type: "materialize-keyframes",
|
||||||
|
animationId: animId,
|
||||||
|
keyframes: allScanned.get(domEditSelection.id ?? "")?.keyframes ?? [],
|
||||||
|
allElements,
|
||||||
|
},
|
||||||
|
{ label: "Unroll dynamic animations", skipReload: true },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const runtime = readRuntimeKeyframes(previewIframeRef.current, anim.targetSelector);
|
||||||
|
if (!runtime || runtime.keyframes.length === 0) return;
|
||||||
|
await gsapCommitMutation(
|
||||||
|
domEditSelection,
|
||||||
|
{
|
||||||
|
type: "materialize-keyframes",
|
||||||
|
animationId: animId,
|
||||||
|
keyframes: runtime.keyframes,
|
||||||
|
easeEach: runtime.easeEach,
|
||||||
|
},
|
||||||
|
{ label: "Materialize dynamic keyframes", skipReload: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[domEditSelection, selectedGsapAnimations, gsapCommitMutation, previewIframeRef],
|
||||||
|
);
|
||||||
|
|
||||||
const handleGsapRemoveAllKeyframes = useCallback(
|
const handleGsapRemoveAllKeyframes = useCallback(
|
||||||
(animId: string) => {
|
(animId: string) => {
|
||||||
if (!domEditSelection) return;
|
if (!domEditSelection) return;
|
||||||
@@ -656,6 +702,7 @@ export function useDomEditSession({
|
|||||||
handleGsapAddKeyframe,
|
handleGsapAddKeyframe,
|
||||||
handleGsapRemoveKeyframe,
|
handleGsapRemoveKeyframe,
|
||||||
handleGsapConvertToKeyframes,
|
handleGsapConvertToKeyframes,
|
||||||
|
handleGsapMaterializeKeyframes,
|
||||||
handleGsapRemoveAllKeyframes,
|
handleGsapRemoveAllKeyframes,
|
||||||
handleResetSelectedElementKeyframes,
|
handleResetSelectedElementKeyframes,
|
||||||
invalidateGsapCache: bumpGsapCache,
|
invalidateGsapCache: bumpGsapCache,
|
||||||
|
|||||||
@@ -164,6 +164,17 @@ export function useGsapScriptCommits({
|
|||||||
|
|
||||||
onCacheInvalidate();
|
onCacheInvalidate();
|
||||||
|
|
||||||
|
if (result.parsed?.animations) {
|
||||||
|
const { setKeyframeCache } = usePlayerStore.getState();
|
||||||
|
for (const anim of result.parsed.animations) {
|
||||||
|
if (!anim.keyframes) continue;
|
||||||
|
const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
|
||||||
|
if (!id) continue;
|
||||||
|
setKeyframeCache(`${targetPath}#${id}`, anim.keyframes);
|
||||||
|
if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (options.skipReload) return;
|
if (options.skipReload) return;
|
||||||
|
|
||||||
options.beforeReload?.();
|
options.beforeReload?.();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||||
import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
||||||
import { usePlayerStore } from "../player/store/playerStore";
|
import { usePlayerStore } from "../player/store/playerStore";
|
||||||
|
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
|
||||||
|
|
||||||
function extractIdFromSelector(selector: string): string | null {
|
function extractIdFromSelector(selector: string): string | null {
|
||||||
const match = selector.match(/^#([\w-]+)/);
|
const match = selector.match(/^#([\w-]+)/);
|
||||||
@@ -53,6 +54,7 @@ export function useGsapAnimationsForElement(
|
|||||||
sourceFile: string,
|
sourceFile: string,
|
||||||
target: GsapElementTarget | null,
|
target: GsapElementTarget | null,
|
||||||
version: number,
|
version: number,
|
||||||
|
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
|
||||||
): {
|
): {
|
||||||
animations: GsapAnimation[];
|
animations: GsapAnimation[];
|
||||||
multipleTimelines: boolean;
|
multipleTimelines: boolean;
|
||||||
@@ -94,9 +96,23 @@ export function useGsapAnimationsForElement(
|
|||||||
};
|
};
|
||||||
}, [projectId, sourceFile, version]);
|
}, [projectId, sourceFile, version]);
|
||||||
|
|
||||||
|
// Retry fetch if we have a target but no animations — handles cold-load race
|
||||||
|
// where the initial fetch runs before the drilled-down sourceFile is resolved
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId || !target || allAnimations.length > 0) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
fetchParsedAnimations(projectId, sourceFile).then((parsed) => {
|
||||||
|
if (parsed && parsed.animations.length > 0) {
|
||||||
|
setAllAnimations(parsed.animations);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 800);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [projectId, sourceFile, target, allAnimations.length]);
|
||||||
|
|
||||||
const targetId = target?.id ?? null;
|
const targetId = target?.id ?? null;
|
||||||
const targetSelector = target?.selector ?? null;
|
const targetSelector = target?.selector ?? null;
|
||||||
const animations = useMemo(
|
const rawAnimations = useMemo(
|
||||||
() =>
|
() =>
|
||||||
targetId || targetSelector
|
targetId || targetSelector
|
||||||
? getAnimationsForElement(allAnimations, { id: targetId, selector: targetSelector })
|
? getAnimationsForElement(allAnimations, { id: targetId, selector: targetSelector })
|
||||||
@@ -104,6 +120,66 @@ export function useGsapAnimationsForElement(
|
|||||||
[allAnimations, targetId, targetSelector],
|
[allAnimations, targetId, targetSelector],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const animations = useMemo(() => {
|
||||||
|
const iframe = iframeRef?.current;
|
||||||
|
let result = rawAnimations;
|
||||||
|
|
||||||
|
// Enrich animations with unresolved keyframes from runtime
|
||||||
|
if (iframe) {
|
||||||
|
result = result.map((anim) => {
|
||||||
|
if (!anim.hasUnresolvedKeyframes || anim.keyframes) return anim;
|
||||||
|
const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
|
||||||
|
if (!runtime) return anim;
|
||||||
|
return {
|
||||||
|
...anim,
|
||||||
|
keyframes: {
|
||||||
|
format: "percentage" as const,
|
||||||
|
keyframes: runtime.keyframes,
|
||||||
|
...(runtime.easeEach ? { easeEach: runtime.easeEach } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match unresolved-selector animations from the parser to runtime tweens
|
||||||
|
// targeting this element. This handles fully dynamic code (loop with variable selector).
|
||||||
|
if (iframe && targetId && result.length === 0) {
|
||||||
|
const unresolvedAnims = allAnimations.filter((a) => a.hasUnresolvedSelector);
|
||||||
|
if (unresolvedAnims.length > 0) {
|
||||||
|
const runtimeData = readRuntimeKeyframes(iframe, `#${targetId}`);
|
||||||
|
if (runtimeData) {
|
||||||
|
const scanned = scanAllRuntimeKeyframes(iframe);
|
||||||
|
const runtimeEntry = scanned.get(targetId);
|
||||||
|
if (runtimeEntry) {
|
||||||
|
// Find which unresolved animation index matches this element
|
||||||
|
// by correlating parser order with runtime tween order
|
||||||
|
const runtimeIds = Array.from(scanned.keys());
|
||||||
|
const runtimeIndex = runtimeIds.indexOf(targetId);
|
||||||
|
const matchedAnim =
|
||||||
|
runtimeIndex >= 0 && runtimeIndex < unresolvedAnims.length
|
||||||
|
? unresolvedAnims[runtimeIndex]
|
||||||
|
: unresolvedAnims[0];
|
||||||
|
if (matchedAnim) {
|
||||||
|
result = [
|
||||||
|
{
|
||||||
|
...matchedAnim,
|
||||||
|
targetSelector: `#${targetId}`,
|
||||||
|
keyframes: {
|
||||||
|
format: "percentage" as const,
|
||||||
|
keyframes: runtimeEntry.keyframes,
|
||||||
|
...(runtimeEntry.easeEach ? { easeEach: runtimeEntry.easeEach } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [rawAnimations, allAnimations, iframeRef, targetId]);
|
||||||
|
|
||||||
// Populate keyframe cache for the selected element.
|
// Populate keyframe cache for the selected element.
|
||||||
// Key format must match timeline element keys: "sourceFile#domId".
|
// Key format must match timeline element keys: "sourceFile#domId".
|
||||||
const elementId = target?.id ?? null;
|
const elementId = target?.id ?? null;
|
||||||
@@ -132,28 +208,72 @@ export function usePopulateKeyframeCacheForFile(
|
|||||||
projectId: string | null,
|
projectId: string | null,
|
||||||
sourceFile: string,
|
sourceFile: string,
|
||||||
version: number,
|
version: number,
|
||||||
|
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
|
||||||
): void {
|
): void {
|
||||||
const lastFetchKeyRef = useRef("");
|
const lastFetchKeyRef = useRef("");
|
||||||
|
|
||||||
|
const runtimeScanDoneRef = useRef("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`;
|
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`;
|
||||||
if (fetchKey === lastFetchKeyRef.current) return;
|
if (fetchKey === lastFetchKeyRef.current) return;
|
||||||
lastFetchKeyRef.current = fetchKey;
|
lastFetchKeyRef.current = fetchKey;
|
||||||
|
runtimeScanDoneRef.current = "";
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
||||||
let cancelled = false;
|
const sf = sourceFile;
|
||||||
fetchParsedAnimations(projectId, sourceFile).then((parsed) => {
|
fetchParsedAnimations(projectId, sf).then((parsed) => {
|
||||||
if (cancelled || !parsed) return;
|
if (!parsed) return;
|
||||||
const { setKeyframeCache } = usePlayerStore.getState();
|
const { setKeyframeCache } = usePlayerStore.getState();
|
||||||
for (const anim of parsed.animations) {
|
for (const anim of parsed.animations) {
|
||||||
if (!anim.keyframes) continue;
|
|
||||||
const id = extractIdFromSelector(anim.targetSelector);
|
const id = extractIdFromSelector(anim.targetSelector);
|
||||||
if (id) setKeyframeCache(`${sourceFile}#${id}`, anim.keyframes);
|
if (!id || !anim.keyframes) continue;
|
||||||
|
setKeyframeCache(`${sf}#${id}`, anim.keyframes);
|
||||||
|
if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes);
|
||||||
}
|
}
|
||||||
|
runtimeScanDoneRef.current = fetchKey;
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [projectId, sourceFile, version]);
|
}, [projectId, sourceFile, version]);
|
||||||
|
|
||||||
|
// Separate effect for runtime keyframe discovery — polls until the iframe
|
||||||
|
// has loaded GSAP timelines, independent of the AST fetch lifecycle.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) return;
|
||||||
|
const sf = sourceFile;
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 10;
|
||||||
|
|
||||||
|
const tryRuntimeScan = () => {
|
||||||
|
if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
|
||||||
|
const iframe = iframeRef?.current;
|
||||||
|
if (!iframe) return false;
|
||||||
|
const scanned = scanAllRuntimeKeyframes(iframe);
|
||||||
|
if (scanned.size === 0) return false;
|
||||||
|
const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
|
||||||
|
for (const [id, data] of scanned) {
|
||||||
|
const cacheKey = `${sf}#${id}`;
|
||||||
|
const fallbackKey = `index.html#${id}`;
|
||||||
|
if (keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey)) continue;
|
||||||
|
const entry = {
|
||||||
|
format: "percentage" as const,
|
||||||
|
keyframes: data.keyframes,
|
||||||
|
...(data.easeEach ? { easeEach: data.easeEach } : {}),
|
||||||
|
};
|
||||||
|
setKeyframeCache(cacheKey, entry);
|
||||||
|
if (sf !== "index.html") setKeyframeCache(fallbackKey, entry);
|
||||||
|
}
|
||||||
|
runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tryRuntimeScan()) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
attempts++;
|
||||||
|
if (tryRuntimeScan() || attempts >= maxAttempts) clearInterval(interval);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [projectId, sourceFile, version, iframeRef]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user