fix(studio): keyframe cache propertyGroup tagging + timeline UI (#1357)

* fix(core): per-property-group keyframe foundations

Add PropertyGroupName type system (position/scale/size/rotation/visual/other),
PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup
functions. Parser generates group-aware animation IDs, resolves position strings
(+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs
across all mutations.

* fix(core): add split-into-property-groups and replace-with-keyframes mutations

Server-side mutations for atomic property-group splitting and keyframe
replacement. Client commitMutation returns early on changed:false instead
of throwing.

* fix(studio): per-property-group intercept routing + drag/resize fixes

Rewire GSAP runtime bridge for property-group routing: drag sends only {x,y}
to position group, resize routes to scale group via data-hf-studio-original-width,
rotation routes to rotation group. Add resolveGroupTween helper, from-extend
with split-first-then-position-only pattern, autoKeyframeEnabled guards,
GSAP base + delta fix in drag draft, cancel-restores-GSAP-x/y from data attrs.

* fix(studio): keyframe cache propertyGroup tagging + timeline UI fixes

Tag cached keyframes with propertyGroup for group-aware operations.
Add tweenPercentage for accurate keyframe matching, activeKeyframePct
for diamond-click targeting, context menu offset, selected diamond z-index,
clearProps after kill in soft reload.
This commit is contained in:
Miguel Ángel
2026-06-12 00:19:23 -04:00
committed by GitHub
parent 95029e083e
commit caf23eff8a
8 changed files with 113 additions and 28 deletions
@@ -21,18 +21,23 @@ export function updateKeyframeCacheFromParsed(
// Convert tween-relative percentages to clip-relative so diamonds // Convert tween-relative percentages to clip-relative so diamonds
// render at the correct position within the timeline clip. // render at the correct position within the timeline clip.
const tweenPos = typeof anim.position === "number" ? anim.position : 0; const tweenPos = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
const tweenDur = anim.duration ?? 1; const tweenDur = anim.duration ?? 1;
const timelineEl = elements.find( const timelineEl = elements.find(
(el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`, (el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
); );
const elStart = timelineEl?.start ?? 0; const elStart = timelineEl?.start ?? 0;
const elDuration = timelineEl?.duration ?? 4; const elDuration = timelineEl?.duration ?? 1;
const clipKeyframes = anim.keyframes.keyframes.map((kf) => { const clipKeyframes = anim.keyframes.keyframes.map((kf) => {
const absTime = tweenPos + (kf.percentage / 100) * tweenDur; const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
const clipPct = const clipPct =
elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage; elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage;
return { ...kf, percentage: clipPct }; return {
...kf,
percentage: clipPct,
tweenPercentage: kf.percentage,
propertyGroup: anim.propertyGroup,
};
}); });
const existing = merged.get(id); const existing = merged.get(id);
@@ -66,7 +71,7 @@ export function updateKeyframeCacheFromParsed(
} }
} }
export function buildCacheKey(sourceFile: string, elementId: string): string { function buildCacheKey(sourceFile: string, elementId: string): string {
return `${sourceFile}#${elementId}`; return `${sourceFile}#${elementId}`;
} }
@@ -449,7 +449,9 @@ export function useGsapScriptCommits({
apply: () => { apply: () => {
const prev = readKeyframeSnapshot(sf, elementId); const prev = readKeyframeSnapshot(sf, elementId);
if (prev) { if (prev) {
const newKeyframes = prev.keyframes.filter((kf) => kf.percentage !== percentage); const newKeyframes = prev.keyframes.filter(
(kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 0.2,
);
writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes }); writeKeyframeCache(sf, elementId, { ...prev, keyframes: newKeyframes });
} }
return prev; return prev;
+30 -10
View File
@@ -264,9 +264,11 @@ export function useGsapAnimationsForElement(
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`, (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
); );
const elStart = timelineEl?.start ?? 0; const elStart = timelineEl?.start ?? 0;
const elDuration = timelineEl?.duration ?? 4; const elDuration = timelineEl?.duration ?? 1;
const allKeyframes: GsapKeyframesData["keyframes"] = []; const allKeyframes: Array<
GsapKeyframesData["keyframes"][0] & { tweenPercentage?: number; propertyGroup?: string }
> = [];
let format: GsapKeyframesData["format"] = "percentage"; let format: GsapKeyframesData["format"] = "percentage";
let ease: string | undefined; let ease: string | undefined;
let easeEach: string | undefined; let easeEach: string | undefined;
@@ -275,7 +277,8 @@ export function useGsapAnimationsForElement(
if (!kf) continue; if (!kf) continue;
// Convert tween-relative percentages to clip-relative so diamonds // Convert tween-relative percentages to clip-relative so diamonds
// render at the correct position within the timeline clip. // render at the correct position within the timeline clip.
const tweenPos = typeof anim.position === "number" ? anim.position : 0; const tweenPos =
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
const tweenDur = anim.duration ?? elDuration; const tweenDur = anim.duration ?? elDuration;
for (const k of kf.keyframes) { for (const k of kf.keyframes) {
const absTime = tweenPos + (k.percentage / 100) * tweenDur; const absTime = tweenPos + (k.percentage / 100) * tweenDur;
@@ -283,7 +286,12 @@ export function useGsapAnimationsForElement(
elDuration > 0 elDuration > 0
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
: k.percentage; : k.percentage;
allKeyframes.push({ ...k, percentage: clipPct }); allKeyframes.push({
...k,
percentage: clipPct,
tweenPercentage: k.percentage,
propertyGroup: anim.propertyGroup,
});
} }
format = kf.format; format = kf.format;
if (kf.ease) ease = kf.ease; if (kf.ease) ease = kf.ease;
@@ -305,6 +313,9 @@ export function useGsapAnimationsForElement(
}; };
const { setKeyframeCache } = usePlayerStore.getState(); const { setKeyframeCache } = usePlayerStore.getState();
setKeyframeCache(`${sourceFile}#${elementId}`, merged); setKeyframeCache(`${sourceFile}#${elementId}`, merged);
// PropertyPanel reads the cache by bare elementId (without sourceFile prefix),
// so write a duplicate entry under the bare key for cross-component lookups.
setKeyframeCache(elementId, merged);
}, [elementId, sourceFile, animations]); }, [elementId, sourceFile, animations]);
return { animations, multipleTimelines, unsupportedTimelinePattern }; return { animations, multipleTimelines, unsupportedTimelinePattern };
@@ -327,13 +338,14 @@ export function usePopulateKeyframeCacheForFile(
version: number, version: number,
iframeRef?: React.RefObject<HTMLIFrameElement | null>, iframeRef?: React.RefObject<HTMLIFrameElement | null>,
): void { ): void {
const elementCount = usePlayerStore((s) => s.elements.length);
const lastFetchKeyRef = useRef(""); const lastFetchKeyRef = useRef("");
const runtimeScanDoneRef = useRef(""); const runtimeScanDoneRef = useRef("");
const astFetchDoneRef = useRef(""); const astFetchDoneRef = useRef("");
useEffect(() => { useEffect(() => {
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`; const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}`;
if (fetchKey === lastFetchKeyRef.current) return; if (fetchKey === lastFetchKeyRef.current) return;
lastFetchKeyRef.current = fetchKey; lastFetchKeyRef.current = fetchKey;
runtimeScanDoneRef.current = ""; runtimeScanDoneRef.current = "";
@@ -358,21 +370,26 @@ export function usePopulateKeyframeCacheForFile(
if (!id) continue; if (!id) continue;
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim); const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
if (!kfData) continue; if (!kfData) continue;
// Convert tween-relative percentages to clip-relative. const tweenPos =
const tweenPos = typeof anim.position === "number" ? anim.position : 0; anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
const tweenDur = anim.duration ?? 1; const tweenDur = anim.duration ?? 1;
const timelineEl = elements.find( const timelineEl = elements.find(
(el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`, (el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`,
); );
const elStart = timelineEl?.start ?? 0; const elStart = timelineEl?.start ?? 0;
const elDuration = timelineEl?.duration ?? 4; const elDuration = timelineEl?.duration ?? 1;
const clipKeyframes = kfData.keyframes.map((kf) => { const clipKeyframes = kfData.keyframes.map((kf) => {
const absTime = tweenPos + (kf.percentage / 100) * tweenDur; const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
const clipPct = const clipPct =
elDuration > 0 elDuration > 0
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
: kf.percentage; : kf.percentage;
return { ...kf, percentage: clipPct }; return {
...kf,
percentage: clipPct,
tweenPercentage: kf.percentage,
propertyGroup: anim.propertyGroup,
};
}); });
const existing = mergedByElement.get(id); const existing = mergedByElement.get(id);
if (existing) { if (existing) {
@@ -388,7 +405,10 @@ export function usePopulateKeyframeCacheForFile(
} }
astFetchDoneRef.current = fetchKey; astFetchDoneRef.current = fetchKey;
}); });
}, [projectId, sourceFile, version]); // elementCount is in the deps because new timeline elements (e.g. after a
// sub-composition expand) need their keyframe cache populated immediately;
// without it the effect won't re-run when elements appear/disappear.
}, [projectId, sourceFile, version, elementCount]);
// Separate effect for runtime keyframe discovery — polls until the iframe // Separate effect for runtime keyframe discovery — polls until the iframe
// has loaded GSAP timelines, independent of the AST fetch lifecycle. // has loaded GSAP timelines, independent of the AST fetch lifecycle.
@@ -7,6 +7,7 @@ export interface KeyframeDiamondContextMenuState {
y: number; y: number;
elementId: string; elementId: string;
percentage: number; percentage: number;
tweenPercentage?: number;
currentEase?: string; currentEase?: string;
} }
@@ -113,7 +114,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
type="button" type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left" className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
onClick={() => { onClick={() => {
onDelete(state.elementId, state.percentage); onDelete(state.elementId, state.tweenPercentage ?? state.percentage);
onClose(); onClose();
}} }}
> >
@@ -448,6 +448,9 @@ export const Timeline = memo(function Timeline({
onSelectElement?.(el); onSelectElement?.(el);
const absTime = el.start + (pct / 100) * el.duration; const absTime = el.start + (pct / 100) * el.duration;
onSeek?.(absTime); onSeek?.(absTime);
const kfData = keyframeCache?.get(elKey);
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5);
usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
}} }}
onShiftClickKeyframe={(elId, pct) => { onShiftClickKeyframe={(elId, pct) => {
toggleSelectedKeyframe(`${elId}:${pct}`); toggleSelectedKeyframe(`${elId}:${pct}`);
@@ -464,12 +467,13 @@ export const Timeline = memo(function Timeline({
onSeek?.(absTime); onSeek?.(absTime);
} }
const kfData = keyframeCache.get(elId); const kfData = keyframeCache.get(elId);
const kf = kfData?.keyframes.find((k) => k.percentage === pct); const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
setKfContextMenu({ setKfContextMenu({
x: e.clientX, x: e.clientX + 4,
y: e.clientY, y: e.clientY + 2,
elementId: elId, elementId: elId,
percentage: pct, percentage: pct,
tweenPercentage: kf?.tweenPercentage,
currentEase: kf?.ease ?? kfData?.ease, currentEase: kf?.ease ?? kfData?.ease,
}); });
}} }}
@@ -123,7 +123,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
const kfKey = `${elementId}:${kf.percentage}`; const kfKey = `${elementId}:${kf.percentage}`;
const isKfSelected = selectedKeyframes.has(kfKey); const isKfSelected = selectedKeyframes.has(kfKey);
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5; const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
const color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3"; const isHighlighted = isKfSelected || atPlayhead;
const color = isHighlighted ? accentColor : "#a3a3a3";
return ( return (
<button <button
key={`${i}-${kf.percentage}`} key={`${i}-${kf.percentage}`}
@@ -135,6 +136,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
transform: "translateY(-50%)", transform: "translateY(-50%)",
width: diamondSize, width: diamondSize,
height: diamondSize, height: diamondSize,
zIndex: isHighlighted ? 2 : 1,
pointerEvents: "auto", pointerEvents: "auto",
background: "none", background: "none",
border: "none", border: "none",
@@ -28,10 +28,26 @@ function buildMockIframe(overrides: Record<string, unknown> = {}) {
...overrides, ...overrides,
}; };
// Intercept appendChild: when a <script> is appended, simulate execution by
// repopulating __timelines (mimicking what the real GSAP script would do).
const realAppendChild = container.appendChild.bind(container);
container.appendChild = <T extends Node>(node: T): T => {
const result = realAppendChild(node);
if (node instanceof HTMLScriptElement && node.textContent?.includes("gsap.timeline")) {
// Simulate the script populating __timelines
const cw = contentWindow as { __timelines?: Record<string, unknown> };
if (cw.__timelines) {
cw.__timelines.root = { kill: vi.fn(), pause: vi.fn() };
}
}
return result;
};
const contentDocument = { const contentDocument = {
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []), querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
createElement: (tag: string) => document.createElement(tag), createElement: (tag: string) => document.createElement(tag),
body: container, body: container,
head: document.createElement("div"),
}; };
return { return {
+43 -8
View File
@@ -7,6 +7,8 @@ type IframeWindow = Window & {
gsap?: { gsap?: {
timeline?: (...args: unknown[]) => unknown; timeline?: (...args: unknown[]) => unknown;
registerPlugin?: (...plugins: unknown[]) => unknown; registerPlugin?: (...plugins: unknown[]) => unknown;
set?: (targets: Element | Element[], vars: Record<string, unknown>) => void;
globalTimeline?: { getChildren?: (deep: boolean) => Array<{ kill?: () => void }> };
}; };
MotionPathPlugin?: unknown; MotionPathPlugin?: unknown;
}; };
@@ -29,6 +31,14 @@ function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
return results; return results;
} }
/** Check that the new script repopulated __timelines with at least one entry. */
function verifyTimelinesPopulated(win: IframeWindow): boolean {
const tlKeys = win.__timelines
? Object.keys(win.__timelines).filter((k) => k !== "__proxied")
: [];
return tlKeys.length > 0;
}
/** /**
* Replace the GSAP script in the live iframe without reloading. This preserves * Replace the GSAP script in the live iframe without reloading. This preserves
* the WebGL context and shader transition cache. * the WebGL context and shader transition cache.
@@ -56,24 +66,30 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
const currentTime = win.__player?.getTime?.() ?? 0; const currentTime = win.__player?.getTime?.() ?? 0;
// Track whether the MotionPath async path was taken. When it is, the script
// executes inside pluginScript.onload — after applySoftReload has already
// returned. We optimistically return true because the script WILL execute
// once the plugin loads; the alternative (returning false) would trigger a
// full iframe reload that destroys the very WebGL context we're preserving.
let deferredToAsync = false;
const doReload = () => { const doReload = () => {
const timelines = win.__timelines; const timelines = win.__timelines;
const allTargets: Element[] = [];
if (timelines) { if (timelines) {
for (const key of Object.keys(timelines)) { for (const key of Object.keys(timelines)) {
if (key === "__proxied") continue;
try { try {
const tl = timelines[key] as { const tl = timelines[key] as {
kill?: () => void; kill?: () => void;
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>; getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
}; };
const allTargets: Element[] = [];
if (tl?.getChildren) { if (tl?.getChildren) {
try { try {
for (const child of tl.getChildren(true)) { for (const child of tl.getChildren(true)) {
if (typeof child.targets === "function") { if (typeof child.targets === "function") {
for (const t of child.targets()) { for (const t of child.targets()) allTargets.push(t);
allTargets.push(t);
delete (t as unknown as Record<string, unknown>)._gsap;
}
} }
} }
} catch {} } catch {}
@@ -84,6 +100,23 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
} }
} }
// Kill bare gsap.to/from tweens not registered on __timelines
if (win.gsap?.globalTimeline?.getChildren) {
try {
for (const child of win.gsap.globalTimeline.getChildren(false)) {
child.kill?.();
}
} catch {}
}
// Clear residual inline transforms left by killed tweens so from() tweens
// don't read stale end values from the DOM on re-execution
if (allTargets.length > 0 && win.gsap?.set) {
try {
win.gsap.set(allTargets, { clearProps: "all" });
} catch {}
}
oldScriptEl.remove(); oldScriptEl.remove();
const executeScript = () => { const executeScript = () => {
@@ -98,10 +131,9 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
win.__hfStudioManualEditsApply?.(); win.__hfStudioManualEditsApply?.();
}; };
// Load MotionPathPlugin on demand if the script uses motionPath.
// Uses the same CDN as composition templates (GSAP_CDN in constants.ts).
const needsMotionPath = /motionPath\s*[:{]/.test(scriptText); const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);
if (needsMotionPath && !win.MotionPathPlugin && win.gsap) { if (needsMotionPath && !win.MotionPathPlugin && win.gsap) {
deferredToAsync = true;
const pluginScript = doc.createElement("script"); const pluginScript = doc.createElement("script");
pluginScript.src = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js"; pluginScript.src = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
pluginScript.onload = () => executeScript(); pluginScript.onload = () => executeScript();
@@ -119,7 +151,10 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
} else { } else {
doReload(); doReload();
} }
return true; // When MotionPath needs async loading, the script hasn't executed yet —
// skip the __timelines check and return true optimistically.
if (deferredToAsync) return true;
return verifyTimelinesPopulated(win);
} catch { } catch {
return false; return false;
} }