-
Notifications
You must be signed in to change notification settings - Fork 3.6k
feat(studio): add keyframe timeline state #2683
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; | ||
| import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore"; | ||
| import { toAbsoluteTime } from "./gsapShared"; | ||
| import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth"; | ||
|
|
||
| export function updateKeyframeCacheFromParsed( | ||
| animations: GsapAnimation[], | ||
|
|
@@ -15,10 +16,16 @@ export function updateKeyframeCacheFromParsed( | |
| const { setKeyframeCache, elements } = usePlayerStore.getState(); | ||
| const idsWithKeyframes = new Set<string>(); | ||
| const merged = new Map<string, KeyframeCacheEntry>(); | ||
| const sourceAnimations = new Map<string, GsapAnimation[]>(); | ||
| for (const anim of animations) { | ||
| const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1]; | ||
| if (!id || !anim.keyframes) continue; | ||
| const kfSource = | ||
| anim.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(anim)?.keyframes ?? []; | ||
| if (!id || kfSource.length === 0) continue; | ||
| idsWithKeyframes.add(id); | ||
| if (anim.propertyGroup) { | ||
| sourceAnimations.set(id, [...(sourceAnimations.get(id) ?? []), anim]); | ||
| } | ||
|
|
||
| // Convert tween-relative percentages to clip-relative so diamonds | ||
| // render at the correct position within the timeline clip. | ||
|
|
@@ -29,7 +36,7 @@ export function updateKeyframeCacheFromParsed( | |
| ); | ||
| const elStart = timelineEl?.start ?? 0; | ||
| const elDuration = timelineEl?.duration ?? 1; | ||
| const clipKeyframes = anim.keyframes.keyframes.map((kf) => { | ||
| const clipKeyframes = kfSource.map((kf) => { | ||
| const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage); | ||
| const clipPct = | ||
| elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage; | ||
|
|
@@ -38,6 +45,7 @@ export function updateKeyframeCacheFromParsed( | |
| percentage: clipPct, | ||
| tweenPercentage: kf.percentage, | ||
| propertyGroup: anim.propertyGroup, | ||
| animationId: anim.id, | ||
| }; | ||
| }); | ||
|
|
||
|
|
@@ -48,20 +56,36 @@ export function updateKeyframeCacheFromParsed( | |
| const prev = byPct.get(kf.percentage); | ||
| if (prev) { | ||
| prev.properties = { ...prev.properties, ...kf.properties }; | ||
| // Mirror deduplicateKeyframes: a same-% collision across different | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π‘ Ambiguity-flag logic is inlined identically in two places β contract drift risk across foundation slice if (
prev.animationId !== undefined &&
kf.animationId !== undefined &&
prev.animationId !== kf.animationId
) {
prev.easeAmbiguous = true;
}
if (kf.ease) prev.ease = kf.ease;Same 6-line ambiguity check appears in Fix: Extract a |
||
| // source animations is an ambiguous merged segment (the button can | ||
| // only target one arbitrary animation). Flag it so the collapsed row | ||
| // suppresses the inline ease button there. | ||
| if ( | ||
| prev.animationId !== undefined && | ||
| kf.animationId !== undefined && | ||
| prev.animationId !== kf.animationId | ||
| ) { | ||
| prev.easeAmbiguous = true; | ||
| } | ||
| if (kf.ease) prev.ease = kf.ease; | ||
| } else { | ||
| byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } }); | ||
| } | ||
| } | ||
| existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage); | ||
| } else { | ||
| merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes }); | ||
| merged.set(id, { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π‘ Synthesized flat-tween's top-level ease is dropped when written into keyframeCache } else {
merged.set(id, {
...anim.keyframes,
format: anim.keyframes?.format ?? "percentage",
keyframes: clipKeyframes,
});
}For a flat tween, Fix: Compute the source shape once: |
||
| ...anim.keyframes, | ||
| format: anim.keyframes?.format ?? "percentage", | ||
| keyframes: clipKeyframes, | ||
| }); | ||
| } | ||
| } | ||
| for (const [id, entry] of merged) { | ||
| setKeyframeCache(`${targetPath}#${id}`, entry); | ||
| setKeyframeCache(id, entry); | ||
| if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry); | ||
| writeGsapAnimationsForElement(targetPath, id, sourceAnimations.get(id)); | ||
| } | ||
| const targetId = | ||
| (mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ?? | ||
|
|
@@ -84,13 +108,12 @@ export function updateKeyframeCacheFromParsed( | |
| * a new cache map and re-render every subscriber. | ||
| */ | ||
| export function clearKeyframeCacheForElement(sourceFile: string, elementId: string): void { | ||
| const { keyframeCache, setKeyframeCache } = usePlayerStore.getState(); | ||
| const keys = | ||
| sourceFile === "index.html" | ||
| ? [`index.html#${elementId}`, elementId] | ||
| : [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId]; | ||
| const { keyframeCache, setKeyframeCache, gsapAnimations, setGsapAnimations } = | ||
| 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); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -102,11 +125,11 @@ export function clearKeyframeCacheForElement(sourceFile: string, elementId: stri | |
| * absent from the re-scan) leaves no stale bare entry behind. | ||
| */ | ||
| export function clearKeyframeCacheForFile(sourceFile: string): void { | ||
| const { keyframeCache } = usePlayerStore.getState(); | ||
| const { keyframeCache, gsapAnimations } = usePlayerStore.getState(); | ||
| const sfPrefix = `${sourceFile}#`; | ||
| const fallbackPrefix = "index.html#"; | ||
| const ids = new Set<string>(); | ||
| for (const key of keyframeCache.keys()) { | ||
| for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) { | ||
| const matchesFile = | ||
| key.startsWith(sfPrefix) || (sourceFile !== "index.html" && key.startsWith(fallbackPrefix)); | ||
| if (!matchesFile) continue; | ||
|
|
@@ -118,6 +141,23 @@ export function clearKeyframeCacheForFile(sourceFile: string): void { | |
| } | ||
| } | ||
|
|
||
| function elementCacheKeys(sourceFile: string, elementId: string): string[] { | ||
| return sourceFile === "index.html" | ||
| ? [`index.html#${elementId}`, elementId] | ||
| : [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId]; | ||
| } | ||
|
|
||
| export function writeGsapAnimationsForElement( | ||
| sourceFile: string, | ||
| elementId: string, | ||
| animations: GsapAnimation[] | undefined, | ||
| ): void { | ||
| const { setGsapAnimations } = usePlayerStore.getState(); | ||
| for (const key of elementCacheKeys(sourceFile, elementId)) { | ||
| setGsapAnimations(key, animations); | ||
| } | ||
| } | ||
|
|
||
| function buildCacheKey(sourceFile: string, elementId: string): string { | ||
| return `${sourceFile}#${elementId}`; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π‘ id extraction regex /^#([\w-]+)/ isn't paired with the new idSelector attribute-selector output
This PR introduces
idSelector(gsapShared.ts:81) which emits[id="01-hook-hero-word"]for digit-leading / dotted / spaced ids and is now used by gsapDragCommit.materializeIfDynamic, gsapScriptCommitHelpers.ensureElementAddressable, useGestureCommit.commit, and selectorFromSelection. When such a selector is written into the source file, the next parse returnsanim.targetSelector = '[id="01-hook-hero-word"]'. The^#([\w-]+)regex on :21 (and again on :91 for the mutation target) returns undefined for that shape, so the animation is silently skipped and the keyframe cache is never updated for that element. Same regex is unchanged, but the new idSelector primitive is a fresh source of selectors it can't parse β the two changes together create a latent bug for exactly the case idSelector is meant to make safe.Fix: Extract id via a helper handling both shapes:
sel.match(/^#([\w-]+)/)?.[1] ?? sel.match(/^\[id="((?:[^"\\]|\\.)+)"\]/)?.[1]?.replace(/\\(["\\])/g, '$1'). Cover the mutation-target site (:91) too.β Review by Rames D Jusso