fix(studio): compute keyframe percentages in the tween's own time frame

A sub-composition tween's resolvedStart is composition-local, while the timeline
element resolved for it is the sub-comp HOST, whose start is main-timeline
absolute. toClipPercentage subtracted the two frames from each other, so a host
mounted at 1.5s cached its 0s tween at -12% and its last tween's end keyframe at
88% instead of 100%. A clip-relative percentage can never be negative.

resolveClipTimingBasis now returns the clip start in the frame the tween's own
times are measured in: the composition mount (expandedParentStart for an
expanded child, the parent composition clip's start otherwise, 0 for a
root-composition element) is subtracted, and a sub-comp inner element that falls
back to its host's window starts at 0 in that window. It moves to gsapShared so
the post-commit cache writer can share it instead of resolving its own basis,
which also gives that writer the sub-comp host fallback it was missing.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-28 18:04:37 +02:00
parent 86f633f985
commit 3f0c20f633
5 changed files with 190 additions and 56 deletions
@@ -1,6 +1,6 @@
/**
* Reading a composition file's GSAP tweens into the keyframe cache: fetch,
* selector -> element id resolution, and the clip-relative timing basis.
* Reading a composition file's GSAP tweens into the keyframe cache: fetch and
* selector -> element id resolution.
* Split from useGsapTweenCache to keep that file under the 600-line limit.
*/
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
@@ -11,7 +11,7 @@ import {
elementCacheKeys,
writeGsapAnimationsForElement,
} from "./gsapKeyframeCacheHelpers";
import { idFromSelector, toClipKeyframes } from "./gsapShared";
import { idFromSelector, resolveClipTimingBasis, toClipKeyframes } from "./gsapShared";
import {
deduplicateKeyframes,
isStaticPositionHold,
@@ -103,37 +103,6 @@ export async function fetchParsedAnimations(
}
}
/**
* Clip-relative timing basis for an element. Sub-composition internals (e.g. pills
* inside a scene) aren't timeline clips themselves — they're derived at expand time
* — so they're absent from `elements`. Without a basis, elDuration defaulted to 1
* and clip-relative keyframe percentages blew past 100% (rendering off the clip).
* Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
* data-composition-src is stripped in the rendered DOM, so we can't query it).
*/
export function resolveClipTimingBasis(
elementId: string,
sourceFile: string,
elements: ReadonlyArray<{
domId?: string;
key?: string;
id: string;
start: number;
duration: number;
}>,
domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
): { elStart: number; elDuration: number } {
const direct = elements.find(
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
);
if (direct) return { elStart: direct.start, elDuration: direct.duration };
const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
const host = hostId
? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
: undefined;
return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 };
}
/**
* Read one composition file's tweens into the keyframe cache. Split out of the
* hook so the effect can run it per file without re-nesting the whole body.