@@ -8,9 +8,10 @@ import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
88import { usePlayerStore } from "../player/store/playerStore" ;
99import {
1010 clearKeyframeCacheForFile ,
11+ elementCacheKeys ,
1112 writeGsapAnimationsForElement ,
1213} from "./gsapKeyframeCacheHelpers" ;
13- import { toAbsoluteTime } from "./gsapShared" ;
14+ import { toClipKeyframes } from "./gsapShared" ;
1415import {
1516 deduplicateKeyframes ,
1617 isStaticPositionHold ,
@@ -56,10 +57,35 @@ export function resolveSelectorElementIds(
5657 }
5758 return Array . from ( ids ) ;
5859}
60+ /**
61+ * The slice of the parse response callers actually read. The endpoint returns
62+ * the full `ParsedGsap` (preamble/postamble and all), but nothing downstream of
63+ * this fetch touches the source-text fields, so the guard below only has to
64+ * vouch for what gets used.
65+ */
66+ type ParsedGsapAnimations = Pick <
67+ ParsedGsap ,
68+ "animations" | "multipleTimelines" | "unsupportedTimelinePattern"
69+ > ;
70+
71+ /**
72+ * A proxy, an error page, or a stale server can answer 200 with something that
73+ * has no `animations` array — the case where the old blind cast crashed on
74+ * `.animations.filter`.
75+ */
76+ function hasAnimations ( value : unknown ) : value is ParsedGsapAnimations {
77+ return (
78+ typeof value === "object" &&
79+ value !== null &&
80+ "animations" in value &&
81+ Array . isArray ( value . animations )
82+ ) ;
83+ }
84+
5985export async function fetchParsedAnimations (
6086 projectId : string ,
6187 sourceFile : string ,
62- ) : Promise < ParsedGsap | null > {
88+ ) : Promise < ParsedGsapAnimations | null > {
6389 try {
6490 const res = await fetch (
6591 `/api/projects/${ encodeURIComponent ( projectId ) } /gsap-animations/${ encodeURIComponent ( sourceFile ) } ` ,
@@ -68,7 +94,8 @@ export async function fetchParsedAnimations(
6894 { cache : "no-store" } ,
6995 ) ;
7096 if ( ! res . ok ) return null ;
71- const parsed = ( await res . json ( ) ) as ParsedGsap ;
97+ const parsed : unknown = await res . json ( ) ;
98+ if ( ! hasAnimations ( parsed ) ) return null ;
7299 // Studio-emitted pre-keyframe hold `set`s are an internal runtime detail (they
73100 // hold an element's first keyframe before its tween). They must not surface as
74101 // user animations — otherwise they pollute the keyframe cache / timeline diamonds.
@@ -131,8 +158,6 @@ export async function populateKeyframeCacheFromAst(
131158 if ( isStaticPositionHold ( anim ) ) continue ;
132159 const kfData = anim . keyframes ?? synthesizeFlatTweenKeyframes ( anim ) ;
133160 if ( ! kfData ) continue ;
134- const tweenPos = anim . resolvedStart ?? ( typeof anim . position === "number" ? anim . position : 0 ) ;
135- const tweenDur = anim . duration ?? 1 ;
136161 // Attribute the tween to every element it animates (handles class /
137162 // group / descendant selectors, not just `#id`).
138163 for ( const id of resolveSelectorElementIds ( anim . targetSelector , doc ) ) {
@@ -142,22 +167,7 @@ export async function populateKeyframeCacheFromAst(
142167 // below records, or expanded lanes have nothing to render.
143168 sourceByElement . set ( id , [ ...( sourceByElement . get ( id ) ?? [ ] ) , anim ] ) ;
144169 const { elStart, elDuration } = resolveClipTimingBasis ( id , sf , elements , domClipChildren ) ;
145- const clipKeyframes = kfData . keyframes . map ( ( kf ) => {
146- const absTime = toAbsoluteTime ( tweenPos , tweenDur , kf . percentage ) ;
147- // 0.001% precision (see useGsapAnimationsForElement) so a beat-snapped
148- // keyframe centers on the beat dot and both caches agree.
149- const clipPct =
150- elDuration > 0
151- ? Math . round ( ( ( absTime - elStart ) / elDuration ) * 100000 ) / 1000
152- : kf . percentage ;
153- return {
154- ...kf ,
155- percentage : clipPct ,
156- tweenPercentage : kf . percentage ,
157- propertyGroup : anim . propertyGroup ,
158- animationId : anim . id , // parity with other cache writers; inline ease needs it
159- } ;
160- } ) ;
170+ const clipKeyframes = toClipKeyframes ( kfData . keyframes , anim , elStart , elDuration ) ;
161171 const existing = mergedByElement . get ( id ) ;
162172 if ( existing ) {
163173 existing . keyframes = deduplicateKeyframes ( [ ...existing . keyframes , ...clipKeyframes ] ) ;
@@ -167,9 +177,7 @@ export async function populateKeyframeCacheFromAst(
167177 }
168178 }
169179 for ( const [ id , kfData ] of mergedByElement ) {
170- setKeyframeCache ( `${ sf } #${ id } ` , kfData ) ;
171- setKeyframeCache ( id , kfData ) ;
172- if ( sf !== "index.html" ) setKeyframeCache ( `index.html#${ id } ` , kfData ) ;
180+ for ( const key of elementCacheKeys ( sf , id ) ) setKeyframeCache ( key , kfData ) ;
173181 writeGsapAnimationsForElement ( sf , id , sourceByElement . get ( id ) ) ;
174182 }
175183}
0 commit comments