mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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.
461 lines
18 KiB
TypeScript
461 lines
18 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
|
import type { GsapAnimation, GsapKeyframesData } from "@hyperframes/core/gsap-parser";
|
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
|
|
import {
|
|
clearKeyframeCacheForElement,
|
|
pruneKeyframeCacheToFiles,
|
|
writeGsapAnimationsForElement,
|
|
} from "./gsapKeyframeCacheHelpers";
|
|
import { resolveClipTimingBasis, toAbsoluteTime, toClipPercentage } from "./gsapShared";
|
|
import {
|
|
deduplicateKeyframes,
|
|
isStaticPositionHold,
|
|
synthesizeFlatTweenKeyframes,
|
|
} from "./gsapTweenSynth";
|
|
import { fetchParsedAnimations, populateKeyframeCacheFromAst } from "./keyframeCacheAstLoad";
|
|
|
|
// Re-exported so callers keep importing the GSAP cache surface from one module.
|
|
export { resolveClipTimingBasis } from "./gsapShared";
|
|
export { fetchParsedAnimations, resolveSelectorElementIds } from "./keyframeCacheAstLoad";
|
|
|
|
/** The selected element's identity for matching tweens to it. */
|
|
export interface GsapElementTarget {
|
|
id?: string | null;
|
|
selector?: string | null;
|
|
}
|
|
|
|
/**
|
|
* A tween belongs to the selected element when its target selector addresses
|
|
* that element — by id (`#id`), by the exact CSS selector the element was
|
|
* selected through (`.kicker`), or as one member of a group selector
|
|
* (`.clock-face, .clock-hand`, emitted for array/`toArray` targets). Real
|
|
* compositions target tweens by class via `querySelector`, so id-only matching
|
|
* misses them.
|
|
*
|
|
* When the live DOM `element` is supplied, each comma-part of a tween's selector
|
|
* is also tested with `element.matches(part)` — true CSS semantics — so a
|
|
* class/descendant tween shared across elements (e.g. `gsap.from(".dot", {stagger})`)
|
|
* is attributed to *every* matching element, not just the one whose exact
|
|
* selector string happens to equal the tween's.
|
|
*/
|
|
export function getAnimationsForElement(
|
|
animations: GsapAnimation[],
|
|
target: GsapElementTarget,
|
|
element?: Element | null,
|
|
): GsapAnimation[] {
|
|
const matchers = new Set<string>();
|
|
if (target.id) matchers.add(`#${target.id}`);
|
|
if (target.selector) matchers.add(target.selector);
|
|
if (matchers.size === 0 && !element) return [];
|
|
return animations.filter((a) =>
|
|
a.targetSelector.split(",").some((part) => {
|
|
const trimmed = part.trim();
|
|
if (!trimmed) return false;
|
|
if (matchers.has(trimmed)) return true;
|
|
const lastSimple = trimmed.split(/\s+/).pop();
|
|
if (lastSimple && matchers.has(lastSimple)) return true;
|
|
if (element) {
|
|
try {
|
|
if (element.matches(trimmed)) return true;
|
|
} catch {
|
|
/* tween selector isn't a valid CSS selector for matches() — skip */
|
|
}
|
|
}
|
|
return false;
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function useGsapAnimationsForElement(
|
|
projectId: string | null,
|
|
sourceFile: string,
|
|
target: GsapElementTarget | null,
|
|
version: number,
|
|
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
|
|
): {
|
|
animations: GsapAnimation[];
|
|
multipleTimelines: boolean;
|
|
unsupportedTimelinePattern: boolean;
|
|
} {
|
|
const [allAnimations, setAllAnimations] = useState<GsapAnimation[]>([]);
|
|
const [multipleTimelines, setMultipleTimelines] = useState(false);
|
|
const [unsupportedTimelinePattern, setUnsupportedTimelinePattern] = useState(false);
|
|
const lastFetchKeyRef = useRef("");
|
|
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
// Re-run the per-element cache populate when sub-comp DOM children appear, so a
|
|
// sub-comp element gets its host-relative keyframe percentages (not elDuration=1).
|
|
const domClipChildrenKey = usePlayerStore((s) =>
|
|
s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"),
|
|
);
|
|
|
|
useEffect(() => {
|
|
const targetKey = target?.id ?? target?.selector ?? "";
|
|
const fetchKey = `${projectId}:${sourceFile}:${version}:${targetKey}`;
|
|
if (fetchKey === lastFetchKeyRef.current) return;
|
|
lastFetchKeyRef.current = fetchKey;
|
|
|
|
if (retryTimerRef.current) {
|
|
clearTimeout(retryTimerRef.current);
|
|
retryTimerRef.current = null;
|
|
}
|
|
|
|
if (!projectId) {
|
|
setAllAnimations([]);
|
|
setMultipleTimelines(false);
|
|
setUnsupportedTimelinePattern(false);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
fetchParsedAnimations(projectId, sourceFile).then((parsed) => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
if (!parsed) {
|
|
setAllAnimations([]);
|
|
setMultipleTimelines(false);
|
|
setUnsupportedTimelinePattern(false);
|
|
return;
|
|
}
|
|
setAllAnimations(parsed.animations);
|
|
setMultipleTimelines(parsed.multipleTimelines === true);
|
|
setUnsupportedTimelinePattern(parsed.unsupportedTimelinePattern === true);
|
|
|
|
// Retry once if initial fetch returned 0 animations — handles
|
|
// cold-load race where the sourceFile isn't resolved yet.
|
|
if (parsed.animations.length === 0 && targetKey) {
|
|
retryTimerRef.current = setTimeout(() => {
|
|
if (cancelled) return;
|
|
fetchParsedAnimations(projectId, sourceFile).then((retryParsed) => {
|
|
if (cancelled) return;
|
|
if (retryParsed && retryParsed.animations.length > 0) {
|
|
setAllAnimations(retryParsed.animations);
|
|
}
|
|
});
|
|
}, 800);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (retryTimerRef.current) {
|
|
clearTimeout(retryTimerRef.current);
|
|
retryTimerRef.current = null;
|
|
}
|
|
};
|
|
}, [projectId, sourceFile, version, target?.id, target?.selector]);
|
|
|
|
const targetId = target?.id ?? null;
|
|
const targetSelector = target?.selector ?? null;
|
|
const rawAnimations = useMemo(() => {
|
|
if (!targetId && !targetSelector) return [];
|
|
// Resolve the live element so class / descendant tweens (e.g.
|
|
// gsap.from(".dot", {stagger})) attribute to every matching element, not
|
|
// just the one whose exact selector equals the tween's. `version` re-runs
|
|
// this after composition reloads.
|
|
let element: Element | null = null;
|
|
const doc = iframeRef?.current?.contentDocument;
|
|
if (doc) {
|
|
try {
|
|
element =
|
|
(targetId ? doc.getElementById(targetId) : null) ??
|
|
(targetSelector ? doc.querySelector(targetSelector) : null);
|
|
} catch {
|
|
element = null;
|
|
}
|
|
}
|
|
return getAnimationsForElement(
|
|
allAnimations,
|
|
{ id: targetId, selector: targetSelector },
|
|
element,
|
|
);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [allAnimations, targetId, targetSelector, version, iframeRef]);
|
|
|
|
// fallow-ignore-next-line complexity
|
|
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 } : {}),
|
|
},
|
|
...(runtime.arcPath ? { arcPath: runtime.arcPath } : {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
// 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 } : {}),
|
|
},
|
|
...(runtimeEntry.arcPath ? { arcPath: runtimeEntry.arcPath } : {}),
|
|
},
|
|
];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}, [rawAnimations, allAnimations, iframeRef, targetId]);
|
|
|
|
// Populate keyframe cache for the selected element.
|
|
// Key format must match timeline element keys: "sourceFile#domId".
|
|
// 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;
|
|
// Same admission rule as the keyframe cache below (hold skip included) and
|
|
// no property-group filter: the two stores must agree, or a hold draws an
|
|
// expanded property lane with no collapsed diamond behind it and an
|
|
// ungrouped tween draws diamonds with no lane source.
|
|
const sourceAnimations = animations.filter(
|
|
(animation) =>
|
|
!isStaticPositionHold(animation) &&
|
|
(animation.keyframes || synthesizeFlatTweenKeyframes(animation)),
|
|
);
|
|
if (sourceAnimations.length > 0)
|
|
writeGsapAnimationsForElement(sourceFile, elementId, sourceAnimations);
|
|
|
|
// Resolve the element's time range from the player store so we can
|
|
// convert tween-relative keyframe percentages to clip-relative ones.
|
|
const { elements, domClipChildren } = usePlayerStore.getState();
|
|
const { elStart, elDuration } = resolveClipTimingBasis(
|
|
elementId,
|
|
sourceFile,
|
|
elements,
|
|
domClipChildren,
|
|
);
|
|
|
|
const allKeyframes: Array<
|
|
GsapKeyframesData["keyframes"][0] & {
|
|
tweenPercentage?: number;
|
|
propertyGroup?: string;
|
|
animationId?: string;
|
|
}
|
|
> = [];
|
|
let format: GsapKeyframesData["format"] = "percentage";
|
|
let ease: string | undefined;
|
|
let easeEach: string | undefined;
|
|
for (const anim of animations) {
|
|
if (isStaticPositionHold(anim)) continue;
|
|
const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
|
if (!kf) continue;
|
|
// Convert tween-relative percentages to clip-relative so diamonds
|
|
// render at the correct position within the timeline clip.
|
|
const tweenPos =
|
|
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
|
|
const tweenDur = anim.duration ?? elDuration;
|
|
for (const k of kf.keyframes) {
|
|
const absTime = toAbsoluteTime(tweenPos, tweenDur, k.percentage);
|
|
const clipPct = toClipPercentage(absTime, elStart, elDuration, k.percentage);
|
|
allKeyframes.push({
|
|
...k,
|
|
percentage: clipPct,
|
|
tweenPercentage: k.percentage,
|
|
propertyGroup: anim.propertyGroup,
|
|
animationId: anim.id,
|
|
});
|
|
}
|
|
format = kf.format;
|
|
if (kf.ease) ease = kf.ease;
|
|
if (kf.easeEach) easeEach = kf.easeEach;
|
|
}
|
|
if (allKeyframes.length === 0) {
|
|
// The per-element parsed-animation match can transiently miss class /
|
|
// selector tweens (e.g. `.dot`) that the file-wide populate or runtime
|
|
// scan already cached. Only clear when no source cached this element —
|
|
// otherwise selecting it would wipe its diamonds.
|
|
const { keyframeCache } = usePlayerStore.getState();
|
|
const hasCached =
|
|
keyframeCache.has(`${sourceFile}#${elementId}`) || keyframeCache.has(elementId);
|
|
if (!hasCached) clearKeyframeCacheForElement(sourceFile, elementId);
|
|
return;
|
|
}
|
|
const dedupedKeyframes = deduplicateKeyframes(allKeyframes);
|
|
const merged: GsapKeyframesData = {
|
|
format,
|
|
keyframes: dedupedKeyframes,
|
|
...(ease ? { ease } : {}),
|
|
...(easeEach ? { easeEach } : {}),
|
|
};
|
|
const { setKeyframeCache } = usePlayerStore.getState();
|
|
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);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [elementId, sourceFile, animations, domClipChildrenKey]);
|
|
|
|
return { animations, multipleTimelines, unsupportedTimelinePattern };
|
|
}
|
|
|
|
export function useGsapCacheVersion() {
|
|
const [version, setVersion] = useState(0);
|
|
const bump = useCallback(() => setVersion((v) => v + 1), []);
|
|
return { version, bump };
|
|
}
|
|
|
|
/**
|
|
* Fetch GSAP animations for a file and populate the keyframe cache for all
|
|
* elements. Called from the Timeline component so diamonds show without
|
|
* requiring a selection.
|
|
*/
|
|
|
|
export function usePopulateKeyframeCacheForFile(
|
|
projectId: string | null,
|
|
sourceFile: string,
|
|
version: number,
|
|
iframeRef?: React.RefObject<HTMLIFrameElement | null>,
|
|
): void {
|
|
const elementCount = usePlayerStore((s) => s.elements.length);
|
|
// Every sub-composition file the timeline shows rows for. The cache is loaded
|
|
// for all of them up front, so keyframe lanes are populated on open instead of
|
|
// only once a clip from that file is selected (which is what switches
|
|
// `sourceFile`). Only files reachable from the store's elements are covered;
|
|
// a composition nested inside another still loads on first selection.
|
|
const compositionSrcKey = usePlayerStore((s) =>
|
|
Array.from(new Set(s.elements.map((el) => el.compositionSrc).filter((src) => !!src)))
|
|
.sort()
|
|
.join("|"),
|
|
);
|
|
// Re-run when sub-comp DOM children appear (they supply the host bounds the
|
|
// clip-relative keyframe percentages are computed against; without this the
|
|
// cache is computed once before they exist and the percentages stay wrong).
|
|
const domClipChildrenKey = usePlayerStore((s) =>
|
|
s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"),
|
|
);
|
|
const lastFetchKeyRef = useRef("");
|
|
|
|
const runtimeScanDoneRef = useRef("");
|
|
const astFetchDoneRef = useRef("");
|
|
|
|
useEffect(() => {
|
|
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}:${compositionSrcKey}`;
|
|
if (fetchKey === lastFetchKeyRef.current) return;
|
|
lastFetchKeyRef.current = fetchKey;
|
|
runtimeScanDoneRef.current = "";
|
|
astFetchDoneRef.current = "";
|
|
if (!projectId) return;
|
|
|
|
// The active file first: it owns the selection, and each file clears only
|
|
// its own cache entries, so the order just decides who writes the bare
|
|
// `id` alias last.
|
|
const files = Array.from(
|
|
new Set([sourceFile, ...(compositionSrcKey ? compositionSrcKey.split("|") : [])]),
|
|
);
|
|
const doc = iframeRef?.current?.contentDocument;
|
|
// Everything the previous scan cached for a file this one no longer covers
|
|
// (the composition just switched away from) has no owner left to clear it.
|
|
pruneKeyframeCacheToFiles(files);
|
|
Promise.all(files.map((sf) => populateKeyframeCacheFromAst(projectId, sf, doc))).then(() => {
|
|
astFetchDoneRef.current = fetchKey;
|
|
});
|
|
// 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.
|
|
// iframeRef is read for DOM selector resolution but intentionally not a dep
|
|
// (it's a stable ref; the separate runtime-scan effect owns iframe timing).
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [projectId, sourceFile, version, elementCount, domClipChildrenKey, compositionSrcKey]);
|
|
|
|
// 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;
|
|
|
|
// fallow-ignore-next-line complexity
|
|
const tryRuntimeScan = () => {
|
|
if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
|
|
const iframe =
|
|
iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
|
|
if (!iframe) return false;
|
|
// Clip dims per element so the scan converts tween-relative keyframes to
|
|
// clip-relative (matching the static path) instead of timeline-relative.
|
|
const clipById = new Map<string, { start: number; duration: number }>();
|
|
for (const el of usePlayerStore.getState().elements) {
|
|
if (el.domId) clipById.set(el.domId, { start: el.start, duration: el.duration });
|
|
}
|
|
const scanned = scanAllRuntimeKeyframes(iframe, clipById);
|
|
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}`;
|
|
const alreadyCached =
|
|
keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id);
|
|
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 = {
|
|
format: "percentage" as const,
|
|
keyframes: data.keyframes,
|
|
...(data.easeEach ? { easeEach: data.easeEach } : {}),
|
|
};
|
|
setKeyframeCache(cacheKey, entry);
|
|
if (sf !== "index.html") setKeyframeCache(fallbackKey, entry);
|
|
setKeyframeCache(id, 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]);
|
|
}
|