mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
fix(studio): publish keyframe cache refresh atomically
This commit is contained in:
@@ -3,8 +3,8 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|||||||
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
|
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
|
||||||
import {
|
import {
|
||||||
clearKeyframeCacheForElement,
|
clearKeyframeCacheForElement,
|
||||||
clearKeyframeCacheForFile,
|
|
||||||
pruneKeyframeCacheToFiles,
|
pruneKeyframeCacheToFiles,
|
||||||
|
replaceKeyframeCacheForFile,
|
||||||
updateKeyframeCacheFromParsed,
|
updateKeyframeCacheFromParsed,
|
||||||
} from "./gsapKeyframeCacheHelpers";
|
} from "./gsapKeyframeCacheHelpers";
|
||||||
|
|
||||||
@@ -70,48 +70,6 @@ describe("clearKeyframeCacheForElement", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("clearKeyframeCacheForFile", () => {
|
|
||||||
it("clears the prefixed, fallback, and bare keys for every element of the file", () => {
|
|
||||||
seed("comp.html#a");
|
|
||||||
seed("index.html#a");
|
|
||||||
seed("a");
|
|
||||||
seed("comp.html#b");
|
|
||||||
seed("b");
|
|
||||||
|
|
||||||
clearKeyframeCacheForFile("comp.html");
|
|
||||||
|
|
||||||
for (const key of ["comp.html#a", "index.html#a", "a", "comp.html#b", "b"]) {
|
|
||||||
expect(cache().has(key)).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Several composition files re-scan concurrently, so a clear that walked the
|
|
||||||
// index.html alias would delete rows a sibling file had just written.
|
|
||||||
it("leaves an index.html-owned element alone when another file re-scans", () => {
|
|
||||||
seed("index.html#title");
|
|
||||||
seed("title");
|
|
||||||
seed("comp.html#a");
|
|
||||||
|
|
||||||
clearKeyframeCacheForFile("comp.html");
|
|
||||||
|
|
||||||
expect(cache().has("index.html#title")).toBe(true);
|
|
||||||
expect(cache().has("title")).toBe(true);
|
|
||||||
expect(cache().has("comp.html#a")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves entries that belong to a different source file", () => {
|
|
||||||
seed("comp.html#a");
|
|
||||||
seed("a");
|
|
||||||
seed("other.html#z");
|
|
||||||
seed("z");
|
|
||||||
|
|
||||||
clearKeyframeCacheForFile("comp.html");
|
|
||||||
|
|
||||||
expect(cache().has("other.html#z")).toBe(true);
|
|
||||||
expect(cache().has("z")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("pruneKeyframeCacheToFiles", () => {
|
describe("pruneKeyframeCacheToFiles", () => {
|
||||||
// Switching composition leaves the previous comp's elements cached with no
|
// Switching composition leaves the previous comp's elements cached with no
|
||||||
// owner left to clear them: each file only ever clears its own entries.
|
// owner left to clear them: each file only ever clears its own entries.
|
||||||
@@ -152,6 +110,99 @@ describe("pruneKeyframeCacheToFiles", () => {
|
|||||||
expect(cache().has("index.html#hero")).toBe(true);
|
expect(cache().has("index.html#hero")).toBe(true);
|
||||||
expect(cache().has("comp.html#a")).toBe(true);
|
expect(cache().has("comp.html#a")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The prune runs immediately before the atomic repopulate on every
|
||||||
|
// composition switch. One notification per stale element put every subscriber
|
||||||
|
// through a cache that was progressively emptier, which is the flash the
|
||||||
|
// atomic repopulate exists to avoid.
|
||||||
|
it("drops every stale file in one notification", () => {
|
||||||
|
seed("old.html#a");
|
||||||
|
seed("old.html#b");
|
||||||
|
seed("older.html#c");
|
||||||
|
seed("kept.html#k");
|
||||||
|
let notifications = 0;
|
||||||
|
const unsubscribe = usePlayerStore.subscribe(() => {
|
||||||
|
notifications += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
pruneKeyframeCacheToFiles(["kept.html"]);
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
expect(notifications).toBe(1);
|
||||||
|
expect([...cache().keys()]).toEqual(["kept.html#k"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A prune that finds nothing stale must not hand subscribers a fresh Map
|
||||||
|
// identity: every keyframe consumer re-renders on cache identity alone.
|
||||||
|
it("does not publish when nothing is stale", () => {
|
||||||
|
seed("kept.html#k");
|
||||||
|
const before = cache();
|
||||||
|
let notifications = 0;
|
||||||
|
const unsubscribe = usePlayerStore.subscribe(() => {
|
||||||
|
notifications += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
pruneKeyframeCacheToFiles(["kept.html"]);
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
expect(notifications).toBe(0);
|
||||||
|
expect(cache()).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replaceKeyframeCacheForFile", () => {
|
||||||
|
it("publishes complete keyframe and animation maps in one notification", () => {
|
||||||
|
const staleEntry = entry();
|
||||||
|
const otherEntry = entry();
|
||||||
|
const staleAnimation = animWithKeyframes("stale");
|
||||||
|
const freshAnimation = animWithKeyframes("fresh");
|
||||||
|
usePlayerStore.setState({
|
||||||
|
keyframeCache: new Map([
|
||||||
|
["scene.html#stale", staleEntry],
|
||||||
|
["index.html#stale", staleEntry],
|
||||||
|
["stale", staleEntry],
|
||||||
|
["other.html#other", otherEntry],
|
||||||
|
["other", otherEntry],
|
||||||
|
]),
|
||||||
|
gsapAnimations: new Map([
|
||||||
|
["scene.html#stale", [staleAnimation]],
|
||||||
|
["index.html#stale", [staleAnimation]],
|
||||||
|
["stale", [staleAnimation]],
|
||||||
|
["other.html#other", [staleAnimation]],
|
||||||
|
["other", [staleAnimation]],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const snapshots: Array<{ cacheKeys: string[]; animationKeys: string[] }> = [];
|
||||||
|
const unsubscribe = usePlayerStore.subscribe((state) => {
|
||||||
|
snapshots.push({
|
||||||
|
cacheKeys: [...state.keyframeCache.keys()].sort(),
|
||||||
|
animationKeys: [...state.gsapAnimations.keys()].sort(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
replaceKeyframeCacheForFile(
|
||||||
|
"scene.html",
|
||||||
|
new Map([["fresh", entry()]]),
|
||||||
|
new Map([["fresh", [freshAnimation]]]),
|
||||||
|
);
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
expect(snapshots).toEqual([
|
||||||
|
{
|
||||||
|
cacheKeys: ["fresh", "index.html#fresh", "other", "other.html#other", "scene.html#fresh"],
|
||||||
|
animationKeys: [
|
||||||
|
"fresh",
|
||||||
|
"index.html#fresh",
|
||||||
|
"other",
|
||||||
|
"other.html#other",
|
||||||
|
"scene.html#fresh",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(usePlayerStore.getState().gsapAnimations.get("scene.html#fresh")).toEqual([
|
||||||
|
freshAnimation,
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("updateKeyframeCacheFromParsed", () => {
|
describe("updateKeyframeCacheFromParsed", () => {
|
||||||
|
|||||||
@@ -11,6 +11,47 @@ import {
|
|||||||
type MergeableKeyframe,
|
type MergeableKeyframe,
|
||||||
} from "./gsapTweenSynth";
|
} from "./gsapTweenSynth";
|
||||||
|
|
||||||
|
/** Both cache maps mid-edit, before the single store publish. */
|
||||||
|
export interface KeyframeCacheDraft {
|
||||||
|
keyframeCache: Map<string, KeyframeCacheEntry>;
|
||||||
|
gsapAnimations: Map<string, GsapAnimation[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameEntries<V>(before: ReadonlyMap<string, V>, after: ReadonlyMap<string, V>): boolean {
|
||||||
|
if (before.size !== after.size) return false;
|
||||||
|
for (const [key, value] of before) {
|
||||||
|
if (after.get(key) !== value) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every multi-key cache writer publishes through here: clone both maps once,
|
||||||
|
* let the caller edit the drafts, publish once. The per-key store setters turned
|
||||||
|
* one logical refresh into N notifications, and every subscriber that rendered
|
||||||
|
* in between saw a cache that was half the old file and half the new one. The
|
||||||
|
* identity check keeps an edit that changed nothing from allocating fresh maps
|
||||||
|
* and re-rendering every subscriber, which is the guard the per-key setters used
|
||||||
|
* to carry individually.
|
||||||
|
*/
|
||||||
|
export function publishKeyframeCache(edit: (draft: KeyframeCacheDraft) => void): void {
|
||||||
|
const { keyframeCache, gsapAnimations } = usePlayerStore.getState();
|
||||||
|
const draft: KeyframeCacheDraft = {
|
||||||
|
keyframeCache: new Map(keyframeCache),
|
||||||
|
gsapAnimations: new Map(gsapAnimations),
|
||||||
|
};
|
||||||
|
edit(draft);
|
||||||
|
if (
|
||||||
|
sameEntries(keyframeCache, draft.keyframeCache) &&
|
||||||
|
sameEntries(gsapAnimations, draft.gsapAnimations)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
usePlayerStore.setState({
|
||||||
|
keyframeCache: draft.keyframeCache,
|
||||||
|
gsapAnimations: draft.gsapAnimations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function updateKeyframeCacheFromParsed(
|
export function updateKeyframeCacheFromParsed(
|
||||||
animations: GsapAnimation[],
|
animations: GsapAnimation[],
|
||||||
targetPath: string,
|
targetPath: string,
|
||||||
@@ -18,7 +59,7 @@ export function updateKeyframeCacheFromParsed(
|
|||||||
mutation: Record<string, unknown>,
|
mutation: Record<string, unknown>,
|
||||||
doc?: Document | null,
|
doc?: Document | null,
|
||||||
): void {
|
): void {
|
||||||
const { setKeyframeCache, elements, domClipChildren } = usePlayerStore.getState();
|
const { elements, domClipChildren } = usePlayerStore.getState();
|
||||||
const idsWithKeyframes = new Set<string>();
|
const idsWithKeyframes = new Set<string>();
|
||||||
// Attributed keyframes only: everything in here came from a parsed tween via
|
// Attributed keyframes only: everything in here came from a parsed tween via
|
||||||
// toClipKeyframes, so the merge can rely on the source identity. It widens
|
// toClipKeyframes, so the merge can rely on the source identity. It widens
|
||||||
@@ -70,15 +111,41 @@ export function updateKeyframeCacheFromParsed(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [id, entry] of merged) {
|
|
||||||
for (const key of elementCacheKeys(targetPath, id)) setKeyframeCache(key, entry);
|
|
||||||
writeGsapAnimationsForElement(targetPath, id, sourceAnimations.get(id));
|
|
||||||
}
|
|
||||||
const mutationSelector = (mutation as { targetSelector?: string }).targetSelector;
|
const mutationSelector = (mutation as { targetSelector?: string }).targetSelector;
|
||||||
const mutated = mutationSelector ? resolveSelectorElementIds(mutationSelector, doc) : [];
|
const mutated = mutationSelector ? resolveSelectorElementIds(mutationSelector, doc) : [];
|
||||||
const targetIds = mutated.length > 0 ? mutated : selectionId ? [selectionId] : [];
|
const targetIds = mutated.length > 0 ? mutated : selectionId ? [selectionId] : [];
|
||||||
for (const targetId of targetIds) {
|
publishKeyframeCache((draft) => {
|
||||||
if (!idsWithKeyframes.has(targetId)) clearKeyframeCacheForElement(targetPath, targetId);
|
for (const [id, entry] of merged) {
|
||||||
|
writeElementIntoDraft(draft, targetPath, id, entry, sourceAnimations.get(id));
|
||||||
|
}
|
||||||
|
for (const targetId of targetIds) {
|
||||||
|
if (!idsWithKeyframes.has(targetId)) deleteElementFromDraft(draft, targetPath, targetId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeElementIntoDraft(
|
||||||
|
draft: KeyframeCacheDraft,
|
||||||
|
sourceFile: string,
|
||||||
|
elementId: string,
|
||||||
|
entry: KeyframeCacheEntry,
|
||||||
|
animations: GsapAnimation[] | undefined,
|
||||||
|
): void {
|
||||||
|
for (const key of elementCacheKeys(sourceFile, elementId)) {
|
||||||
|
draft.keyframeCache.set(key, entry);
|
||||||
|
if (animations) draft.gsapAnimations.set(key, animations);
|
||||||
|
else draft.gsapAnimations.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteElementFromDraft(
|
||||||
|
draft: KeyframeCacheDraft,
|
||||||
|
sourceFile: string,
|
||||||
|
elementId: string,
|
||||||
|
): void {
|
||||||
|
for (const key of elementCacheKeys(sourceFile, elementId)) {
|
||||||
|
draft.keyframeCache.delete(key);
|
||||||
|
draft.gsapAnimations.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,50 +158,37 @@ export function updateKeyframeCacheFromParsed(
|
|||||||
* preview overlay) fall back to the bare id when an element has no
|
* preview overlay) fall back to the bare id when an element has no
|
||||||
* source-prefixed key — so a clear that drops only the prefixed keys leaves the
|
* source-prefixed key — so a clear that drops only the prefixed keys leaves the
|
||||||
* bare entry behind and those readers keep showing keyframes the element no
|
* bare entry behind and those readers keep showing keyframes the element no
|
||||||
* longer has. Each delete is guarded by `has` so an absent key doesn't allocate
|
* longer has. publishKeyframeCache skips the store write entirely when none of
|
||||||
* a new cache map and re-render every subscriber.
|
* the keys were present, so an absent element doesn't re-render every
|
||||||
|
* subscriber.
|
||||||
*/
|
*/
|
||||||
export function clearKeyframeCacheForElement(sourceFile: string, elementId: string): void {
|
export function clearKeyframeCacheForElement(sourceFile: string, elementId: string): void {
|
||||||
const { keyframeCache, setKeyframeCache, gsapAnimations, setGsapAnimations } =
|
publishKeyframeCache((draft) => deleteElementFromDraft(draft, sourceFile, elementId));
|
||||||
usePlayerStore.getState();
|
|
||||||
const keys = elementCacheKeys(sourceFile, elementId);
|
|
||||||
for (const key of keys) {
|
|
||||||
if (keyframeCache.has(key)) setKeyframeCache(key, undefined);
|
|
||||||
if (gsapAnimations.has(key)) setGsapAnimations(key, undefined);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function cachedElementIdsForFile(
|
||||||
* Clear every cached element of `sourceFile` before a full re-scan repopulates
|
sourceFile: string,
|
||||||
* it. Only the file's OWN prefixed keys name the ids to clear: every write sets
|
keyframeCache: ReadonlyMap<string, KeyframeCacheEntry>,
|
||||||
* the prefixed key (see elementCacheKeys), so the file's elements are all
|
gsapAnimations: ReadonlyMap<string, GsapAnimation[]>,
|
||||||
* reachable that way, and clearKeyframeCacheForElement then takes the
|
): Set<string> {
|
||||||
* index.html alias and the bare key with them — an element whose keyframes were
|
|
||||||
* removed (and so is absent from the re-scan) leaves no stale bare entry
|
|
||||||
* behind. Reading the alias prefix here instead would collect ids owned by
|
|
||||||
* OTHER files, and several files re-scan concurrently, so this file's clear
|
|
||||||
* would wipe the entries a sibling file had just written.
|
|
||||||
*/
|
|
||||||
export function clearKeyframeCacheForFile(sourceFile: string): void {
|
|
||||||
const { keyframeCache, gsapAnimations } = usePlayerStore.getState();
|
|
||||||
const sfPrefix = `${sourceFile}#`;
|
const sfPrefix = `${sourceFile}#`;
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
|
for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
|
||||||
if (!key.startsWith(sfPrefix)) continue;
|
if (!key.startsWith(sfPrefix)) continue;
|
||||||
ids.add(key.slice(sfPrefix.length));
|
ids.add(key.slice(sfPrefix.length));
|
||||||
}
|
}
|
||||||
for (const id of ids) {
|
return ids;
|
||||||
clearKeyframeCacheForElement(sourceFile, id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop every cached element owned by a file that is no longer on screen. Each
|
* Drop every cached element owned by a file that is no longer on screen. Each
|
||||||
* file only ever clears its OWN entries (see clearKeyframeCacheForFile), so
|
* file only ever writes its OWN prefixed entries (see elementCacheKeys), so
|
||||||
* switching composition left the previous composition's elements cached forever
|
* switching composition left the previous composition's elements cached forever
|
||||||
* — 240 entries per switch on a 120-clip comp, in both keyframeCache and
|
* (240 entries per switch on a 120-clip comp, in both keyframeCache and
|
||||||
* gsapAnimations, with nothing to evict them. Called once before a re-scan, with
|
* gsapAnimations, with nothing to evict them). Called once before a re-scan,
|
||||||
* the full set of files that scan covers.
|
* with the full set of files that scan covers, and it publishes once: the prune
|
||||||
|
* runs immediately before the atomic repopulate, so a per-element publish here
|
||||||
|
* would reintroduce the empty-cache flash that repopulate was made to avoid.
|
||||||
*/
|
*/
|
||||||
export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
|
export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
|
||||||
const keep = new Set(files);
|
const keep = new Set(files);
|
||||||
@@ -142,8 +196,8 @@ export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
|
|||||||
const stale = new Map<string, Set<string>>();
|
const stale = new Map<string, Set<string>>();
|
||||||
for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
|
for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
|
||||||
const hash = key.indexOf("#");
|
const hash = key.indexOf("#");
|
||||||
// Bare-id aliases carry no owner; clearKeyframeCacheForElement takes them
|
// Bare-id aliases carry no owner; deleteElementFromDraft takes them with
|
||||||
// with their prefixed key, so skipping them here loses nothing.
|
// their prefixed key, so skipping them here loses nothing.
|
||||||
if (hash < 0) continue;
|
if (hash < 0) continue;
|
||||||
const sourceFile = key.slice(0, hash);
|
const sourceFile = key.slice(0, hash);
|
||||||
if (keep.has(sourceFile)) continue;
|
if (keep.has(sourceFile)) continue;
|
||||||
@@ -151,9 +205,11 @@ export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
|
|||||||
ids.add(key.slice(hash + 1));
|
ids.add(key.slice(hash + 1));
|
||||||
stale.set(sourceFile, ids);
|
stale.set(sourceFile, ids);
|
||||||
}
|
}
|
||||||
for (const [sourceFile, ids] of stale) {
|
publishKeyframeCache((draft) => {
|
||||||
for (const id of ids) clearKeyframeCacheForElement(sourceFile, id);
|
for (const [sourceFile, ids] of stale) {
|
||||||
}
|
for (const id of ids) deleteElementFromDraft(draft, sourceFile, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -177,15 +233,37 @@ export function elementCacheKeys(sourceFile: string, elementId: string): string[
|
|||||||
: [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
|
: [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Replace one file's complete cache snapshot with one atomic store publish. */
|
||||||
|
export function replaceKeyframeCacheForFile(
|
||||||
|
sourceFile: string,
|
||||||
|
entries: ReadonlyMap<string, KeyframeCacheEntry>,
|
||||||
|
animationsByElement: ReadonlyMap<string, GsapAnimation[]>,
|
||||||
|
): void {
|
||||||
|
publishKeyframeCache((draft) => {
|
||||||
|
for (const id of cachedElementIdsForFile(
|
||||||
|
sourceFile,
|
||||||
|
draft.keyframeCache,
|
||||||
|
draft.gsapAnimations,
|
||||||
|
)) {
|
||||||
|
deleteElementFromDraft(draft, sourceFile, id);
|
||||||
|
}
|
||||||
|
for (const [id, entry] of entries) {
|
||||||
|
writeElementIntoDraft(draft, sourceFile, id, entry, animationsByElement.get(id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function writeGsapAnimationsForElement(
|
export function writeGsapAnimationsForElement(
|
||||||
sourceFile: string,
|
sourceFile: string,
|
||||||
elementId: string,
|
elementId: string,
|
||||||
animations: GsapAnimation[] | undefined,
|
animations: GsapAnimation[] | undefined,
|
||||||
): void {
|
): void {
|
||||||
const { setGsapAnimations } = usePlayerStore.getState();
|
publishKeyframeCache((draft) => {
|
||||||
for (const key of elementCacheKeys(sourceFile, elementId)) {
|
for (const key of elementCacheKeys(sourceFile, elementId)) {
|
||||||
setGsapAnimations(key, animations);
|
if (animations) draft.gsapAnimations.set(key, animations);
|
||||||
}
|
else draft.gsapAnimations.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCacheKey(sourceFile: string, elementId: string): string {
|
function buildCacheKey(sourceFile: string, elementId: string): string {
|
||||||
|
|||||||
@@ -6,11 +6,7 @@
|
|||||||
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
||||||
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
|
import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
|
||||||
import { usePlayerStore } from "../player/store/playerStore";
|
import { usePlayerStore } from "../player/store/playerStore";
|
||||||
import {
|
import { replaceKeyframeCacheForFile } from "./gsapKeyframeCacheHelpers";
|
||||||
clearKeyframeCacheForFile,
|
|
||||||
elementCacheKeys,
|
|
||||||
writeGsapAnimationsForElement,
|
|
||||||
} from "./gsapKeyframeCacheHelpers";
|
|
||||||
import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
|
import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
|
||||||
import {
|
import {
|
||||||
deduplicateKeyframes,
|
deduplicateKeyframes,
|
||||||
@@ -81,8 +77,6 @@ export async function populateKeyframeCacheFromAst(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const parsed = await fetchParsedAnimations(projectId, sf);
|
const parsed = await fetchParsedAnimations(projectId, sf);
|
||||||
if (!parsed) return;
|
if (!parsed) return;
|
||||||
const { setKeyframeCache } = usePlayerStore.getState();
|
|
||||||
clearKeyframeCacheForFile(sf);
|
|
||||||
const { elements, domClipChildren } = usePlayerStore.getState();
|
const { elements, domClipChildren } = usePlayerStore.getState();
|
||||||
const mergedByElement = new Map<string, GsapKeyframesData<MergeableKeyframe>>();
|
const mergedByElement = new Map<string, GsapKeyframesData<MergeableKeyframe>>();
|
||||||
const sourceByElement = new Map<string, GsapAnimation[]>();
|
const sourceByElement = new Map<string, GsapAnimation[]>();
|
||||||
@@ -109,8 +103,5 @@ export async function populateKeyframeCacheFromAst(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [id, kfData] of mergedByElement) {
|
replaceKeyframeCacheForFile(sf, mergedByElement, sourceByElement);
|
||||||
for (const key of elementCacheKeys(sf, id)) setKeyframeCache(key, kfData);
|
|
||||||
writeGsapAnimationsForElement(sf, id, sourceByElement.get(id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBrid
|
|||||||
import {
|
import {
|
||||||
clearKeyframeCacheForElement,
|
clearKeyframeCacheForElement,
|
||||||
pruneKeyframeCacheToFiles,
|
pruneKeyframeCacheToFiles,
|
||||||
|
publishKeyframeCache,
|
||||||
writeGsapAnimationsForElement,
|
writeGsapAnimationsForElement,
|
||||||
} from "./gsapKeyframeCacheHelpers";
|
} from "./gsapKeyframeCacheHelpers";
|
||||||
import { resolveClipTimingBasis, toAbsoluteTime, toClipPercentage } from "./gsapShared";
|
import { resolveClipTimingBasis, toAbsoluteTime, toClipPercentage } from "./gsapShared";
|
||||||
@@ -313,11 +314,15 @@ export function useGsapAnimationsForElement(
|
|||||||
...(ease ? { ease } : {}),
|
...(ease ? { ease } : {}),
|
||||||
...(easeEach ? { easeEach } : {}),
|
...(easeEach ? { easeEach } : {}),
|
||||||
};
|
};
|
||||||
const { setKeyframeCache } = usePlayerStore.getState();
|
// PropertyPanel reads the cache by bare elementId (without sourceFile
|
||||||
setKeyframeCache(`${sourceFile}#${elementId}`, merged);
|
// prefix), so the same entry is written under the bare key for
|
||||||
// PropertyPanel reads the cache by bare elementId (without sourceFile prefix),
|
// cross-component lookups. Both keys land in one publish: a reader that woke
|
||||||
// so write a duplicate entry under the bare key for cross-component lookups.
|
// between two separate writes saw the prefixed key updated and the bare one
|
||||||
setKeyframeCache(elementId, merged);
|
// still stale.
|
||||||
|
publishKeyframeCache((draft) => {
|
||||||
|
draft.keyframeCache.set(`${sourceFile}#${elementId}`, merged);
|
||||||
|
draft.keyframeCache.set(elementId, merged);
|
||||||
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [elementId, sourceFile, animations, domClipChildrenKey]);
|
}, [elementId, sourceFile, animations, domClipChildrenKey]);
|
||||||
|
|
||||||
@@ -416,29 +421,35 @@ export function usePopulateKeyframeCacheForFile(
|
|||||||
}
|
}
|
||||||
const scanned = scanAllRuntimeKeyframes(iframe, clipById);
|
const scanned = scanAllRuntimeKeyframes(iframe, clipById);
|
||||||
if (scanned.size === 0) return false;
|
if (scanned.size === 0) return false;
|
||||||
const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
|
// One publish for the whole scan: a scan of a 120-clip composition used to
|
||||||
for (const [id, data] of scanned) {
|
// emit up to three store notifications per element, and every subscriber
|
||||||
const cacheKey = `${sf}#${id}`;
|
// in between re-rendered against a cache only partly filled in.
|
||||||
const fallbackKey = `index.html#${id}`;
|
publishKeyframeCache((draft) => {
|
||||||
const alreadyCached =
|
for (const [id, data] of scanned) {
|
||||||
keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id);
|
const cacheKey = `${sf}#${id}`;
|
||||||
if (alreadyCached) continue;
|
const fallbackKey = `index.html#${id}`;
|
||||||
// Skip position-only set tweens from runtime too — same filter as AST path
|
const alreadyCached =
|
||||||
const isPosOnly =
|
draft.keyframeCache.has(cacheKey) ||
|
||||||
data.keyframes.length === 1 &&
|
draft.keyframeCache.has(fallbackKey) ||
|
||||||
Object.keys(data.keyframes[0].properties).every((k) => k === "x" || k === "y");
|
draft.keyframeCache.has(id);
|
||||||
if (isPosOnly) {
|
if (alreadyCached) continue;
|
||||||
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 } : {}),
|
||||||
|
};
|
||||||
|
draft.keyframeCache.set(cacheKey, entry);
|
||||||
|
if (sf !== "index.html") draft.keyframeCache.set(fallbackKey, entry);
|
||||||
|
draft.keyframeCache.set(id, entry);
|
||||||
}
|
}
|
||||||
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}`;
|
runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user