mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
feat(studio): per-keyframe ease presets, velocity fitting, gesture smoothing (#1694)
Per-keyframe speed-curve editing, velocity-based ease fitting, and Gaussian gesture smoothing. Easy Ease presets, per-segment KeyframeEaseList with a bezier editor, AE-convention ease fitting, position-only set-tween rows, and AnimationCard extraction.
This commit is contained in:
@@ -24,6 +24,7 @@ function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercenta
|
||||
return Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
|
||||
if (anim.method === "set") {
|
||||
return {
|
||||
@@ -107,6 +108,9 @@ export async function fetchParsedAnimations(
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
|
||||
// Always re-read the freshly-parsed source; no per-call timestamp (which
|
||||
// would defeat caching forever and is a deterministic-render no-no).
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const parsed = (await res.json()) as ParsedGsap;
|
||||
@@ -156,7 +160,9 @@ export function useGsapAnimationsForElement(
|
||||
|
||||
let cancelled = false;
|
||||
fetchParsedAnimations(projectId, sourceFile).then((parsed) => {
|
||||
if (cancelled) return;
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (!parsed) {
|
||||
setAllAnimations([]);
|
||||
setMultipleTimelines(false);
|
||||
@@ -169,7 +175,7 @@ export function useGsapAnimationsForElement(
|
||||
|
||||
// Retry once if initial fetch returned 0 animations — handles
|
||||
// cold-load race where the sourceFile isn't resolved yet.
|
||||
if (parsed.animations.length === 0 && target) {
|
||||
if (parsed.animations.length === 0 && targetKey) {
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
fetchParsedAnimations(projectId, sourceFile).then((retryParsed) => {
|
||||
@@ -189,7 +195,7 @@ export function useGsapAnimationsForElement(
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [projectId, sourceFile, version, target]);
|
||||
}, [projectId, sourceFile, version, target?.id, target?.selector]);
|
||||
|
||||
const targetId = target?.id ?? null;
|
||||
const targetSelector = target?.selector ?? null;
|
||||
@@ -201,6 +207,7 @@ export function useGsapAnimationsForElement(
|
||||
[allAnimations, targetId, targetSelector],
|
||||
);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const animations = useMemo(() => {
|
||||
const iframe = iframeRef?.current;
|
||||
let result = rawAnimations;
|
||||
@@ -268,6 +275,7 @@ export function useGsapAnimationsForElement(
|
||||
// Merges keyframes from ALL animations targeting this element and synthesizes
|
||||
// flat tweens so the cache is never downgraded vs the bulk populate.
|
||||
const elementId = target?.id ?? null;
|
||||
// fallow-ignore-next-line complexity
|
||||
useEffect(() => {
|
||||
if (!elementId) return;
|
||||
|
||||
@@ -287,6 +295,11 @@ export function useGsapAnimationsForElement(
|
||||
let ease: string | undefined;
|
||||
let easeEach: string | undefined;
|
||||
for (const anim of animations) {
|
||||
if (
|
||||
anim.method === "set" &&
|
||||
Object.keys(anim.properties).every((k) => k === "x" || k === "y")
|
||||
)
|
||||
continue;
|
||||
const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
||||
if (!kf) continue;
|
||||
// Convert tween-relative percentages to clip-relative so diamonds
|
||||
@@ -366,6 +379,7 @@ export function usePopulateKeyframeCacheForFile(
|
||||
if (!projectId) return;
|
||||
|
||||
const sf = sourceFile;
|
||||
// fallow-ignore-next-line complexity
|
||||
fetchParsedAnimations(projectId, sf).then((parsed) => {
|
||||
if (!parsed) return;
|
||||
const { setKeyframeCache } = usePlayerStore.getState();
|
||||
@@ -376,6 +390,14 @@ export function usePopulateKeyframeCacheForFile(
|
||||
const id = extractIdFromSelector(anim.targetSelector);
|
||||
if (!id) continue;
|
||||
if (anim.hasUnresolvedKeyframes) continue;
|
||||
// Position-only set tweens are static holds (created by drag), not
|
||||
// keyframed animations — skip them so they don't show timeline diamonds.
|
||||
if (anim.method === "set") {
|
||||
const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender");
|
||||
if (propKeys.every((k) => k === "x" || k === "y")) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
||||
if (!kfData) continue;
|
||||
const tweenPos =
|
||||
@@ -430,6 +452,7 @@ export function usePopulateKeyframeCacheForFile(
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const tryRuntimeScan = () => {
|
||||
if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
|
||||
const iframe =
|
||||
@@ -449,7 +472,12 @@ export function usePopulateKeyframeCacheForFile(
|
||||
const fallbackKey = `index.html#${id}`;
|
||||
const alreadyCached =
|
||||
keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id);
|
||||
if (alreadyCached) {
|
||||
if (alreadyCached) continue;
|
||||
// Skip position-only set tweens from runtime too — same filter as AST path
|
||||
const isPosOnly =
|
||||
data.keyframes.length === 1 &&
|
||||
Object.keys(data.keyframes[0].properties).every((k) => k === "x" || k === "y");
|
||||
if (isPosOnly) {
|
||||
continue;
|
||||
}
|
||||
const entry = {
|
||||
|
||||
Reference in New Issue
Block a user