From 050bd2042a276f94a92405b673301674f452e51f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 16 Jul 2026 17:11:06 -0400 Subject: [PATCH] feat(studio): add keyframe timeline state --- packages/studio/src/hooks/gsapDragCommit.ts | 4 +- .../hooks/gsapKeyframeCacheHelpers.test.ts | 96 ++++++++++++++- .../src/hooks/gsapKeyframeCacheHelpers.ts | 60 ++++++++-- .../src/hooks/gsapScriptCommitHelpers.ts | 5 +- packages/studio/src/hooks/gsapShared.test.ts | 31 ++++- packages/studio/src/hooks/gsapShared.ts | 55 ++++++--- .../studio/src/hooks/gsapTweenSynth.test.ts | 31 ++++- packages/studio/src/hooks/gsapTweenSynth.ts | 49 +++++--- packages/studio/src/hooks/useDomSelection.ts | 4 + packages/studio/src/hooks/useGestureCommit.ts | 4 +- .../src/hooks/useGsapKeyframeOps.test.tsx | 38 ++++-- .../src/hooks/useGsapSelectionHandlers.ts | 22 ++-- .../studio/src/hooks/useGsapTweenCache.ts | 27 ++++- .../studio/src/hooks/useStudioTestHooks.ts | 51 ++++++++ .../studio/src/player/store/keyframeSlice.ts | 109 ++++++++++++++++++ .../src/player/store/playerStore.test.ts | 25 ++++ .../studio/src/player/store/playerStore.ts | 49 +------- packages/studio/src/telemetry/events.test.ts | 6 + packages/studio/src/telemetry/events.ts | 5 + 19 files changed, 558 insertions(+), 113 deletions(-) create mode 100644 packages/studio/src/hooks/useStudioTestHooks.ts create mode 100644 packages/studio/src/player/store/keyframeSlice.ts diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index 167ce438d5..4774c17e1b 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -12,7 +12,7 @@ import { usePlayerStore } from "../player/store/playerStore"; import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes"; import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; import { roundTo3 } from "../utils/rounding"; -import { computeElementPercentage } from "./gsapShared"; +import { computeElementPercentage, idSelector } from "./gsapShared"; import { computeDraggedGsapPosition } from "./draggedGsapPosition"; import type { RuntimeTweenChange } from "./gsapRuntimePatch"; import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction"; @@ -117,7 +117,7 @@ export async function materializeIfDynamic( const allScanned = scanAllRuntimeKeyframes(iframe); if (allScanned.size === 0) return; const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({ - selector: `#${id}`, + selector: idSelector(id), keyframes: data.keyframes, easeEach: data.easeEach, })); diff --git a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.test.ts b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.test.ts index 16fc62b206..ee72035f5e 100644 --- a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.test.ts +++ b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.test.ts @@ -28,7 +28,7 @@ const animWithKeyframes = (id: string): GsapAnimation => ({ }); beforeEach(() => { - usePlayerStore.setState({ keyframeCache: new Map(), elements: [] }); + usePlayerStore.setState({ keyframeCache: new Map(), gsapAnimations: new Map(), elements: [] }); }); describe("clearKeyframeCacheForElement", () => { @@ -98,6 +98,41 @@ describe("clearKeyframeCacheForFile", () => { }); describe("updateKeyframeCacheFromParsed", () => { + it("serializes a multi-keyframe tween with a stable shape and animation identity", () => { + const animation: GsapAnimation = { + ...animWithKeyframes("hero"), + duration: 2, + resolvedStart: 3, + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 100 }, ease: "power1.inOut" }, + { percentage: 100, properties: { x: 200 } }, + ], + easeEach: "power1.inOut", + }, + }; + usePlayerStore.setState({ + elements: [ + { + id: "hero-clip", + domId: "hero", + tag: "div", + start: 2, + duration: 4, + track: 0, + }, + ], + }); + + updateKeyframeCacheFromParsed([animation], "scene.html", "hero", {}); + + expect(JSON.stringify(cache().get("scene.html#hero"))).toBe( + '{"format":"percentage","keyframes":[{"percentage":25,"properties":{"x":0},"tweenPercentage":0,"propertyGroup":"position","animationId":"hero"},{"percentage":50,"properties":{"x":100},"ease":"power1.inOut","tweenPercentage":50,"propertyGroup":"position","animationId":"hero"},{"percentage":75,"properties":{"x":200},"tweenPercentage":100,"propertyGroup":"position","animationId":"hero"}],"easeEach":"power1.inOut"}', + ); + }); + it("clears the bare key when the selected element no longer has keyframes", () => { // Element previously had keyframes, so a bare entry exists (writes set both). seed("index.html#box"); @@ -118,4 +153,63 @@ describe("updateKeyframeCacheFromParsed", () => { expect(cache().has("index.html#hero")).toBe(true); expect(cache().has("hero")).toBe(true); }); + + it("caches flat tweens as clip-relative start and end keyframes", () => { + const animation: GsapAnimation = { + id: "flat-box", + targetSelector: "#box", + method: "to", + position: 1, + properties: { x: 420 }, + duration: 2, + resolvedStart: 1, + ease: "power2.out", + propertyGroup: "position", + }; + usePlayerStore.setState({ + elements: [{ id: "box-clip", domId: "box", tag: "div", start: 1, duration: 2, track: 0 }], + }); + + updateKeyframeCacheFromParsed([animation], "scene.html", "box", {}); + + expect(cache().get("scene.html#box")).toEqual({ + format: "percentage", + keyframes: [ + { + percentage: 0, + properties: { x: 0 }, + tweenPercentage: 0, + propertyGroup: "position", + animationId: "flat-box", + }, + { + percentage: 100, + properties: { x: 420 }, + ease: "power2.out", + tweenPercentage: 100, + propertyGroup: "position", + animationId: "flat-box", + }, + ], + }); + expect(usePlayerStore.getState().gsapAnimations.get("scene.html#box")).toEqual([animation]); + }); + + it("does not cache a flat tween without animatable numeric properties", () => { + const animation: GsapAnimation = { + id: "flat-box", + targetSelector: "#box", + method: "to", + position: 0, + properties: { backgroundColor: "#fff" }, + duration: 1, + propertyGroup: "visual", + }; + + updateKeyframeCacheFromParsed([animation], "scene.html", "box", {}); + + expect(cache().has("scene.html#box")).toBe(false); + expect(cache().has("box")).toBe(false); + expect(usePlayerStore.getState().gsapAnimations.has("scene.html#box")).toBe(false); + }); }); diff --git a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts index 1b5bef62b0..58da5c7aae 100644 --- a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts +++ b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts @@ -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(); const merged = new Map(); + const sourceAnimations = new Map(); 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,6 +56,17 @@ export function updateKeyframeCacheFromParsed( const prev = byPct.get(kf.percentage); if (prev) { prev.properties = { ...prev.properties, ...kf.properties }; + // Mirror deduplicateKeyframes: a same-% collision across different + // 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 } }); @@ -55,13 +74,18 @@ export function updateKeyframeCacheFromParsed( } existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage); } else { - merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes }); + merged.set(id, { + ...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(); - 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}`; } diff --git a/packages/studio/src/hooks/gsapScriptCommitHelpers.ts b/packages/studio/src/hooks/gsapScriptCommitHelpers.ts index d84c698608..e6d17338f3 100644 --- a/packages/studio/src/hooks/gsapScriptCommitHelpers.ts +++ b/packages/studio/src/hooks/gsapScriptCommitHelpers.ts @@ -2,12 +2,13 @@ import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mu import type { DomEditSelection } from "../components/editor/domEditingTypes"; export { PROPERTY_DEFAULTS } from "./gsapShared"; +import { idSelector } from "./gsapShared"; export function ensureElementAddressable(selection: DomEditSelection): { selector: string; autoId?: string; } { - if (selection.id) return { selector: `#${selection.id}` }; + if (selection.id) return { selector: idSelector(selection.id) }; if (selection.selector) return { selector: selection.selector }; const el = selection.element; @@ -20,7 +21,7 @@ export function ensureElementAddressable(selection: DomEditSelection): { id = `${tag}-${n}`; } el.setAttribute("id", id); - return { selector: `#${id}`, autoId: id }; + return { selector: idSelector(id), autoId: id }; } export class GsapMutationHttpError extends Error { diff --git a/packages/studio/src/hooks/gsapShared.test.ts b/packages/studio/src/hooks/gsapShared.test.ts index ba45743488..30ed300a07 100644 --- a/packages/studio/src/hooks/gsapShared.test.ts +++ b/packages/studio/src/hooks/gsapShared.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; -import { isInstantHold, parsePercentageKeyframes } from "./gsapShared"; +import { idSelector, isInstantHold, parsePercentageKeyframes } from "./gsapShared"; describe("isInstantHold", () => { const animation = (method: GsapAnimation["method"], duration?: number) => @@ -74,3 +74,32 @@ describe("parsePercentageKeyframes", () => { expect(parsePercentageKeyframes({})).toBeNull(); }); }); + +describe("idSelector", () => { + it("uses #id for valid CSS identifiers", () => { + expect(idSelector("hero-word")).toBe("#hero-word"); + expect(idSelector("el_1")).toBe("#el_1"); + }); + + it("uses an attribute selector for ids that #id can't address (digit-leading, dots, spaces)", () => { + // #01-... / #a.b / #a b throw a SyntaxError in querySelector / GSAP, crashing + // the preview when such a target is committed (e.g. dragging the element). + expect(idSelector("01-hook-hero-word")).toBe('[id="01-hook-hero-word"]'); + expect(idSelector("my.class")).toBe('[id="my.class"]'); + expect(idSelector("1box")).toBe('[id="1box"]'); + }); + + it("escapes quotes and backslashes in the attribute selector value", () => { + expect(idSelector('1"x')).toBe('[id="1\\"x"]'); + }); + + it("only ever emits #id for ids that can't break querySelector", () => { + // Every id resolves to either a plain #id (only when safe) or an attribute + // selector — never a #id that would throw a SyntaxError. + for (const id of ["hero-word", "01-hook", "a.b", "a b", "1", "--x", '1"q']) { + const sel = idSelector(id); + if (sel.startsWith("#")) expect(sel).toBe(`#${id}`); + else expect(sel.startsWith('[id="')).toBe(true); + } + }); +}); diff --git a/packages/studio/src/hooks/gsapShared.ts b/packages/studio/src/hooks/gsapShared.ts index 459f303b46..629e5976d1 100644 --- a/packages/studio/src/hooks/gsapShared.ts +++ b/packages/studio/src/hooks/gsapShared.ts @@ -53,8 +53,35 @@ export function isInstantHold(animation: GsapAnimation): boolean { * Returns `#id` if the selection has an id, otherwise the raw selector, * or null if neither exists. */ +/** + * A CSS-valid selector for an element id. `#id` for a valid CSS identifier, + * otherwise an `[id="..."]` attribute selector. IDs that start with a digit + * (e.g. "01-hook-hero-word") make `#id` an invalid selector, so + * `document.querySelector("#01-...")` / GSAP's `querySelectorAll` throw a + * SyntaxError — which surfaces as a masked cross-origin "Script error." and + * crashes the preview the moment such a target is committed (e.g. dragging). + */ +// Conservative: matches only ids that are unquestionably safe as a `#id` +// selector — ASCII identifier, starts with a letter/underscore (or a single +// leading hyphen), no dots/colons/spaces/digits-first. Anything it rejects +// (digit-leading like "01-hook-...", dots, spaces, non-ASCII, …) falls through +// to the attribute selector below, which is always valid. It can only ever err +// toward the safe form, never toward a `#id` that throws — and, unlike +// `CSS.escape`, it needs no browser global (this runs in node tests too). +const SAFE_HASH_ID = /^-?[A-Za-z_][\w-]*$/; + +export function idSelector(id: string): string { + // A `#id` selector is only valid for a CSS identifier. IDs that start with a + // digit (e.g. "01-hook-hero-word") make `document.querySelector("#01-...")` and + // GSAP's `querySelectorAll` throw a SyntaxError — surfacing as a masked + // cross-origin "Script error." that crashes the preview the moment such a + // target is committed (e.g. dragging the element). Address those via an + // attribute selector instead (quotes/backslashes escaped for the string). + return SAFE_HASH_ID.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`; +} + export function selectorFromSelection(selection: DomEditSelection): string | null { - if (selection.id) return `#${selection.id}`; + if (selection.id) return idSelector(selection.id); if (selection.selector) return selection.selector; return null; } @@ -118,6 +145,18 @@ export interface ParsedPercentageKeyframes { easeEach?: string; } +function collectAnimatableKeyframeProperties( + entry: Record, +): Record { + const properties: Record = {}; + for (const [property, value] of Object.entries(entry)) { + if (property === "ease") continue; + if (typeof value === "number") properties[property] = Math.round(value * 1000) / 1000; + else if (typeof value === "string") properties[property] = value; + } + return properties; +} + /** * Parse a GSAP percentage-keyframe object (`{ "0%": { x: 10 }, "100%": { x: 200 } }`) * into a sorted array of `{ percentage, properties }` entries. @@ -146,12 +185,7 @@ export function parsePercentageKeyframes( steps.forEach((entry, i) => { if (!entry || typeof entry !== "object") return; const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0; - const properties: Record = {}; - for (const [pk, pv] of Object.entries(entry as Record)) { - if (pk === "ease") continue; - if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000; - else if (typeof pv === "string") properties[pk] = pv; - } + const properties = collectAnimatableKeyframeProperties(entry as Record); if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties }); }); return keyframes.length > 0 ? { keyframes } : null; @@ -165,12 +199,7 @@ export function parsePercentageKeyframes( const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/); if (!pctMatch || !val || typeof val !== "object") continue; const percentage = parseFloat(pctMatch[1]); - const properties: Record = {}; - for (const [pk, pv] of Object.entries(val as Record)) { - if (pk === "ease") continue; - if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000; - else if (typeof pv === "string") properties[pk] = pv; - } + const properties = collectAnimatableKeyframeProperties(val as Record); if (Object.keys(properties).length > 0) { keyframes.push({ percentage, properties }); } diff --git a/packages/studio/src/hooks/gsapTweenSynth.test.ts b/packages/studio/src/hooks/gsapTweenSynth.test.ts index 3d8d771222..b5474cf1de 100644 --- a/packages/studio/src/hooks/gsapTweenSynth.test.ts +++ b/packages/studio/src/hooks/gsapTweenSynth.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; -import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth"; +import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth"; function anim(overrides: Partial): GsapAnimation { return { @@ -53,3 +53,32 @@ describe("synthesizeFlatTweenKeyframes", () => { expect(out).not.toBeNull(); }); }); + +describe("deduplicateKeyframes ease ambiguity", () => { + it("flags a same-% collision from different animations (different eases)", () => { + const merged = deduplicateKeyframes([ + { percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" }, + { percentage: 45, properties: { opacity: 1 }, ease: "power2.out", animationId: "#a-visual" }, + ]); + const kf = merged.find((k) => k.percentage === 45); + expect(kf?.easeAmbiguous).toBe(true); + }); + + it("flags a cross-animation collision even when the raw eases match", () => { + // The button can still only target one arbitrary animation, and each may + // inherit a different easeEach/animation ease that raw comparison misses. + const merged = deduplicateKeyframes([ + { percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" }, + { percentage: 45, properties: { opacity: 1 }, ease: "power2.in", animationId: "#a-visual" }, + ]); + expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBe(true); + }); + + it("does not flag a same-% collision within a single animation", () => { + const merged = deduplicateKeyframes([ + { percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" }, + { percentage: 45, properties: { y: 20 }, ease: "power2.out", animationId: "#a-position" }, + ]); + expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBeFalsy(); + }); +}); diff --git a/packages/studio/src/hooks/gsapTweenSynth.ts b/packages/studio/src/hooks/gsapTweenSynth.ts index edb849f28c..20daf0812f 100644 --- a/packages/studio/src/hooks/gsapTweenSynth.ts +++ b/packages/studio/src/hooks/gsapTweenSynth.ts @@ -5,14 +5,26 @@ import type { } from "@hyperframes/core/gsap-parser"; import { PROPERTY_DEFAULTS } from "./gsapShared"; -export function deduplicateKeyframes( - keyframes: GsapPercentageKeyframe[], -): GsapPercentageKeyframe[] { - const byPct = new Map(); +export function deduplicateKeyframes< + T extends GsapPercentageKeyframe & { animationId?: string; easeAmbiguous?: boolean }, +>(keyframes: T[]): T[] { + const byPct = new Map(); for (const kf of keyframes) { const existing = byPct.get(kf.percentage); if (existing) { existing.properties = { ...existing.properties, ...kf.properties }; + // Two DIFFERENT source animations with a keyframe at the same clip %: a + // single inline ease button can only target one of them, and which one is + // arbitrary (each may also inherit a different easeEach/animation ease, so + // comparing raw keyframe eases isn't enough). Flag it so the collapsed row + // hides the button there and the user edits per-lane instead. + if ( + existing.animationId !== undefined && + kf.animationId !== undefined && + existing.animationId !== kf.animationId + ) { + existing.easeAmbiguous = true; + } if (kf.ease) existing.ease = kf.ease; } else { byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } }); @@ -41,29 +53,40 @@ export function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframes const fromProps = anim.fromProperties; if (!toProps || Object.keys(toProps).length === 0) return null; - const startProps: Record = {}; - const endProps: Record = {}; + const rawStart: Record = {}; + const rawEnd: Record = {}; if (anim.method === "from") { for (const [k, v] of Object.entries(toProps)) { - startProps[k] = v; - endProps[k] = PROPERTY_DEFAULTS[k] ?? 0; + rawStart[k] = v; + rawEnd[k] = PROPERTY_DEFAULTS[k] ?? 0; } } else if (anim.method === "fromTo" && fromProps) { - Object.assign(startProps, fromProps); - Object.assign(endProps, toProps); + Object.assign(rawStart, fromProps); + Object.assign(rawEnd, toProps); } else { for (const [k, v] of Object.entries(toProps)) { - startProps[k] = PROPERTY_DEFAULTS[k] ?? 0; - endProps[k] = v; + rawStart[k] = PROPERTY_DEFAULTS[k] ?? 0; + rawEnd[k] = v; } } + // Only numeric props are keyframe-interpolatable — a flat tween of a + // non-numeric prop (e.g. backgroundColor: "#fff") can't be a 2-keyframe lane. + const numericKeys = Object.keys(rawEnd).filter( + (k) => typeof rawStart[k] === "number" && typeof rawEnd[k] === "number", + ); + if (numericKeys.length === 0) return null; + const startProps = Object.fromEntries(numericKeys.map((k) => [k, rawStart[k]])); + const endProps = Object.fromEntries(numericKeys.map((k) => [k, rawEnd[k]])); + return { format: "percentage", keyframes: [ { percentage: 0, properties: startProps }, - { percentage: 100, properties: endProps }, + // Segment ease lives on the destination keyframe (Figma/AE model) so the + // lane + cache surface it; also kept data-level for useGsapTweenCache. + { percentage: 100, properties: endProps, ...(anim.ease ? { ease: anim.ease } : {}) }, ], ...(anim.ease ? { ease: anim.ease } : {}), }; diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index 903894c7e9..568c964228 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -24,6 +24,7 @@ import { type DomEditSelection, } from "../components/editor/domEditing"; import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits"; +import { useStudioTestHooks } from "./useStudioTestHooks"; // ── Types ── @@ -506,6 +507,9 @@ export function useDomSelection({ applyDomSelection(null, { revealPanel: false }); }, [applyDomSelection, captionEditMode]); + // Dev-only headless-QA shortcut (window.__studioTest.selectByDomId). No-op in prod. + useStudioTestHooks({ previewIframeRef, buildDomSelectionFromTarget, applyDomSelection }); + const applyMarqueeSelection = useCallback( // fallow-ignore-next-line complexity (selections: DomEditSelection[], additive: boolean) => { diff --git a/packages/studio/src/hooks/useGestureCommit.ts b/packages/studio/src/hooks/useGestureCommit.ts index a0aba6747f..cdc51dcf97 100644 --- a/packages/studio/src/hooks/useGestureCommit.ts +++ b/packages/studio/src/hooks/useGestureCommit.ts @@ -13,7 +13,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { CommitMutationOptions } from "./gsapScriptCommitTypes"; import { roundTo3 } from "../utils/rounding"; import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser"; -import { isInstantHold } from "./gsapShared"; +import { isInstantHold, idSelector } from "./gsapShared"; type RecordedKeyframe = { percentage: number; @@ -168,7 +168,7 @@ export function useGestureCommit({ if (!sortedPcts.includes(0)) sortedPcts.unshift(0); } - const selector = sel.id ? `#${sel.id}` : sel.selector; + const selector = sel.id ? idSelector(sel.id) : sel.selector; if (!selector) { showToast("Cannot save — element has no selector", "error"); return; diff --git a/packages/studio/src/hooks/useGsapKeyframeOps.test.tsx b/packages/studio/src/hooks/useGsapKeyframeOps.test.tsx index 9a965d63db..5e01b9e6da 100644 --- a/packages/studio/src/hooks/useGsapKeyframeOps.test.tsx +++ b/packages/studio/src/hooks/useGsapKeyframeOps.test.tsx @@ -19,12 +19,18 @@ afterEach(() => { const selection: DomEditSelection = { id: "box", selector: "#box" } as DomEditSelection; +function successfulCommitMutation() { + return vi.fn<(...args: unknown[]) => Promise>(async () => ({ ok: true })); +} + function renderKeyframeOps(over: { commitMutation: (...args: unknown[]) => Promise; trackGsapSaveFailure: (...args: unknown[]) => void; }) { const captured: { api: HookApi | null } = { api: null }; + // This hook harness intentionally mirrors the separate script-commit harness. function Probe() { + // fallow-ignore-next-line code-duplication captured.api = useGsapKeyframeOps({ activeCompPath: "index.html", // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test doubles @@ -50,9 +56,7 @@ function renderKeyframeOps(over: { describe("useGsapKeyframeOps — resizeKeyframedTween", () => { it("issues a resize-keyframed-tween mutation with the remap + window", async () => { - const commitMutation = vi.fn<(...args: unknown[]) => Promise>(async () => ({ - ok: true, - })); + const commitMutation = successfulCommitMutation(); const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>(); const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure }); @@ -101,10 +105,28 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => { }); describe("useGsapKeyframeOps — keyframe transaction options", () => { + it("routes a flat-lane add through the add-keyframe writer mutation", async () => { + const commitMutation = successfulCommitMutation(); + const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() }); + + await act(async () => { + await api.addKeyframeBatch(selection, "box-to-0-position", 50, { x: 210 }); + }); + + expect(commitMutation).toHaveBeenCalledWith( + selection, + { + type: "add-keyframe", + animationId: "box-to-0-position", + percentage: 50, + properties: { x: 210 }, + }, + { label: "Add keyframe at 50%", softReload: true }, + ); + }); + it("soft-reloads a standalone convert when the SDK path is unavailable", async () => { - const commitMutation = vi.fn<(...args: unknown[]) => Promise>(async () => ({ - ok: true, - })); + const commitMutation = successfulCommitMutation(); const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() }); await act(async () => { @@ -122,9 +144,7 @@ describe("useGsapKeyframeOps — keyframe transaction options", () => { }); it("threads one coalesce key through skipped convert reload and terminal batch edit", async () => { - const commitMutation = vi.fn<(...args: unknown[]) => Promise>(async () => ({ - ok: true, - })); + const commitMutation = successfulCommitMutation(); const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() }); const coalesceKey = "enable-keyframes:box-to-0-opacity:1"; diff --git a/packages/studio/src/hooks/useGsapSelectionHandlers.ts b/packages/studio/src/hooks/useGsapSelectionHandlers.ts index 0b11d760de..efde12caa5 100644 --- a/packages/studio/src/hooks/useGsapSelectionHandlers.ts +++ b/packages/studio/src/hooks/useGsapSelectionHandlers.ts @@ -173,8 +173,8 @@ export function useGsapSelectionHandlers({ ); const handleGsapDeleteAnimation = useCallback( - (animId: string) => { - const sel = domEditSelection ?? lastSelectionRef.current; + (animId: string, selectionOverride?: DomEditSelection | null) => { + const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current; if (!sel) return; observeGsapMutation(deleteGsapAnimation(sel, animId), sel, "delete", "Delete GSAP animation"); }, @@ -298,17 +298,15 @@ export function useGsapSelectionHandlers({ percentage: number, properties: Record, commitOverrides?: Partial, + selectionOverride?: DomEditSelection | null, ) => { - if (!domEditSelection) return Promise.resolve(); - return addKeyframeBatch( - domEditSelection, - animId, - percentage, - properties, - commitOverrides, - ).catch((error) => { - trackGsapHandlerFailure(error, domEditSelection, "add-keyframe", "Add keyframe"); - }); + const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current; + if (!sel) return Promise.resolve(); + return addKeyframeBatch(sel, animId, percentage, properties, commitOverrides).catch( + (error) => { + trackGsapHandlerFailure(error, sel, "add-keyframe", "Add keyframe"); + }, + ); }, [domEditSelection, addKeyframeBatch, trackGsapHandlerFailure], ); diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 226ec4c194..6a8e9d5481 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -6,6 +6,7 @@ import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBrid import { clearKeyframeCacheForElement, clearKeyframeCacheForFile, + writeGsapAnimationsForElement, } from "./gsapKeyframeCacheHelpers"; import { toAbsoluteTime } from "./gsapShared"; import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth"; @@ -328,6 +329,12 @@ export function useGsapAnimationsForElement( // fallow-ignore-next-line complexity useEffect(() => { if (!elementId) return; + const sourceAnimations = animations.filter( + (animation) => + animation.propertyGroup && (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. @@ -340,7 +347,11 @@ export function useGsapAnimationsForElement( ); const allKeyframes: Array< - GsapKeyframesData["keyframes"][0] & { tweenPercentage?: number; propertyGroup?: string } + GsapKeyframesData["keyframes"][0] & { + tweenPercentage?: number; + propertyGroup?: string; + animationId?: string; + } > = []; let format: GsapKeyframesData["format"] = "percentage"; let ease: string | undefined; @@ -378,6 +389,7 @@ export function useGsapAnimationsForElement( percentage: clipPct, tweenPercentage: k.percentage, propertyGroup: anim.propertyGroup, + animationId: anim.id, }); } format = kf.format; @@ -459,6 +471,7 @@ export function usePopulateKeyframeCacheForFile( const { elements, domClipChildren } = usePlayerStore.getState(); const doc = iframeRef?.current?.contentDocument; const mergedByElement = new Map(); + const sourceByElement = new Map(); for (const anim of parsed.animations) { if (anim.hasUnresolvedKeyframes) continue; // Position-only static holds are not keyframed animations — skip them so @@ -479,12 +492,16 @@ export function usePopulateKeyframeCacheForFile( // Attribute the tween to every element it animates (handles class / // group / descendant selectors, not just `#id`). for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) { + // kfData is already resolved (real keyframes OR a synthesized flat + // tween), so a grouped flat tween joins the store like a keyframed one. + if (anim.propertyGroup) { + sourceByElement.set(id, [...(sourceByElement.get(id) ?? []), anim]); + } const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren); const clipKeyframes = kfData.keyframes.map((kf) => { const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage); - // 0.001% precision (matching useGsapAnimationsForElement above) so a - // beat-snapped keyframe centers exactly on the beat dot and the two - // caches agree on a keyframe's percentage. + // 0.001% precision (see useGsapAnimationsForElement) so a beat-snapped + // keyframe centers on the beat dot and both caches agree. const clipPct = elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000 @@ -494,6 +511,7 @@ export function usePopulateKeyframeCacheForFile( percentage: clipPct, tweenPercentage: kf.percentage, propertyGroup: anim.propertyGroup, + animationId: anim.id, // parity with other cache writers; inline ease needs it }; }); const existing = mergedByElement.get(id); @@ -508,6 +526,7 @@ export function usePopulateKeyframeCacheForFile( setKeyframeCache(`${sf}#${id}`, kfData); setKeyframeCache(id, kfData); if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, kfData); + writeGsapAnimationsForElement(sf, id, sourceByElement.get(id)); } astFetchDoneRef.current = fetchKey; }); diff --git a/packages/studio/src/hooks/useStudioTestHooks.ts b/packages/studio/src/hooks/useStudioTestHooks.ts new file mode 100644 index 0000000000..a00e01a455 --- /dev/null +++ b/packages/studio/src/hooks/useStudioTestHooks.ts @@ -0,0 +1,51 @@ +import { useEffect } from "react"; +import type { DomEditSelection } from "../components/editor/domEditing"; + +interface StudioTestHookDeps { + previewIframeRef: React.MutableRefObject; + buildDomSelectionFromTarget: (target: HTMLElement) => Promise; + applyDomSelection: ( + selection: DomEditSelection | null, + options?: { revealPanel?: boolean }, + ) => void; +} + +/** + * Dev-only headless-QA shortcut. Selecting an element normally requires a + * pixel-precise click inside the preview iframe, which automated verification + * can't reliably land. `window.__studioTest.selectByDomId(id)` resolves the + * DomEditSelection for a preview element by id and reveals the inspector — + * exactly what a click does — so a driver can open the property/ease panels and + * then focus a segment via `__playerStore.getState().setFocusedEaseSegment`. + * No-op in production builds. + */ +export function useStudioTestHooks({ + previewIframeRef, + buildDomSelectionFromTarget, + applyDomSelection, +}: StudioTestHookDeps): void { + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + let isDev = false; + try { + isDev = import.meta.env.DEV === true; + } catch { + isDev = false; + } + if (!isDev || typeof window === "undefined") return; + const api = { + selectByDomId: async (id: string): Promise => { + const element = previewIframeRef.current?.contentDocument?.getElementById(id) ?? null; + if (!element) return false; + const selection = await buildDomSelectionFromTarget(element); + if (!selection) return false; + applyDomSelection(selection, { revealPanel: true }); + return true; + }, + }; + (window as unknown as { __studioTest?: typeof api }).__studioTest = api; + return () => { + (window as unknown as { __studioTest?: typeof api }).__studioTest = undefined; + }; + }, [applyDomSelection, buildDomSelectionFromTarget, previewIframeRef]); +} diff --git a/packages/studio/src/player/store/keyframeSlice.ts b/packages/studio/src/player/store/keyframeSlice.ts new file mode 100644 index 0000000000..f219983bce --- /dev/null +++ b/packages/studio/src/player/store/keyframeSlice.ts @@ -0,0 +1,109 @@ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { StoreApi } from "zustand"; + +/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */ +export interface KeyframeCacheEntry { + format: string; + keyframes: Array<{ + percentage: number; + /** Original tween-relative percentage (server mutations need this, not the clip-relative `percentage`). */ + tweenPercentage?: number; + /** Which property group the source tween belongs to (position, scale, rotation, visual, etc.). */ + propertyGroup?: string; + /** Source tween id — lets the inline clip-row ease button target a specific segment. */ + animationId?: string; + properties: Record; + ease?: string; + /** Set when 2+ source animations collide at this percentage (a single inline + * ease button can't target one): the collapsed row hides the button here. */ + easeAmbiguous?: boolean; + }>; + ease?: string; + easeEach?: string; +} + +export interface KeyframeSlice { + /** Selected collapsed (`element:pct`) or expanded (`element:group:animation:clipPct`) diamonds. */ + selectedKeyframes: Set; + toggleSelectedKeyframe: (key: string) => void; + clearSelectedKeyframes: () => void; + + /** Clips whose keyframe property lanes are expanded in the timeline. */ + expandedClipIds: Set; + toggleClipExpanded: (id: string) => void; + setClipExpanded: (id: string, expanded: boolean) => void; + /** Union-expand clips (keyframed clips are expanded by default on load). */ + expandClips: (ids: readonly string[]) => void; + + /** elementId scopes the request to one element so a shared (class-selector) + * animation id can't open the ease editor on the wrong element. */ + focusedEaseSegment: { animationId: string; tweenPercentage: number; elementId: string } | null; + setFocusedEaseSegment: ( + target: { animationId: string; tweenPercentage: number; elementId: string } | null, + ) => void; + + /** Keyframe data per element id, populated from parsed GSAP animations. */ + keyframeCache: Map; + /** Unmerged source tweens per element; expanded property lanes read this, never keyframeCache. */ + gsapAnimations: Map; + setGsapAnimations: (elementId: string, animations: GsapAnimation[] | undefined) => void; + setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void; +} + +export function createKeyframeSlice(set: StoreApi["setState"]): KeyframeSlice { + return { + selectedKeyframes: new Set(), + toggleSelectedKeyframe: (key) => + set((state) => { + const next = new Set(state.selectedKeyframes); + if (next.has(key)) next.delete(key); + else next.add(key); + return { selectedKeyframes: next }; + }), + clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }), + + expandedClipIds: new Set(), + toggleClipExpanded: (id) => + set((state) => { + const next = new Set(state.expandedClipIds); + if (next.has(id)) next.delete(id); + else next.add(id); + return { expandedClipIds: next }; + }), + setClipExpanded: (id, expanded) => + set((state) => { + if (state.expandedClipIds.has(id) === expanded) return state; + const next = new Set(state.expandedClipIds); + if (expanded) next.add(id); + else next.delete(id); + return { expandedClipIds: next }; + }), + expandClips: (ids) => + set((state) => { + if (ids.every((id) => state.expandedClipIds.has(id))) return state; + const next = new Set(state.expandedClipIds); + for (const id of ids) next.add(id); + return { expandedClipIds: next }; + }), + + focusedEaseSegment: null, + setFocusedEaseSegment: (target) => set({ focusedEaseSegment: target }), + + keyframeCache: new Map(), + setKeyframeCache: (elementId, data) => + set((state) => { + const next = new Map(state.keyframeCache); + if (data) next.set(elementId, data); + else next.delete(elementId); + return { keyframeCache: next }; + }), + gsapAnimations: new Map(), + setGsapAnimations: (elementId, animations) => + set((state) => { + const next = new Map(state.gsapAnimations); + if (animations) next.set(elementId, animations); + else next.delete(elementId); + return { gsapAnimations: next }; + }), + }; +} diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts index ed86efaa36..1eb3f72c56 100644 --- a/packages/studio/src/player/store/playerStore.test.ts +++ b/packages/studio/src/player/store/playerStore.test.ts @@ -25,6 +25,31 @@ describe("usePlayerStore", () => { expect(state.loopEnabled).toBe(false); expect(state.zoomMode).toBe("fit"); expect(state.manualZoomPercent).toBe(100); + expect(state.expandedClipIds).toEqual(new Set()); + }); + }); + + describe("expandedClipIds", () => { + it("toggles clip membership", () => { + const store = usePlayerStore.getState(); + + store.toggleClipExpanded("clip-1"); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"])); + + store.toggleClipExpanded("clip-1"); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set()); + }); + + it("sets clip membership idempotently", () => { + const store = usePlayerStore.getState(); + + store.setClipExpanded("clip-1", true); + store.setClipExpanded("clip-1", true); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"])); + + store.setClipExpanded("clip-1", false); + store.setClipExpanded("clip-1", false); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set()); }); }); diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index d8efeecdb3..55d590a4ed 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -4,22 +4,9 @@ import type { BeatEditState } from "../../utils/beatEditing"; import type { ClipManifestClip } from "../lib/playbackTypes"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; import { computePinnedZoomPercent } from "../components/timelineZoom"; +import { createKeyframeSlice, type KeyframeSlice } from "./keyframeSlice"; -/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */ -export interface KeyframeCacheEntry { - format: string; - keyframes: Array<{ - percentage: number; - /** Original tween-relative percentage (server mutations need this, not the clip-relative `percentage`). */ - tweenPercentage?: number; - /** Which property group the source tween belongs to (position, scale, rotation, visual, etc.). */ - propertyGroup?: string; - properties: Record; - ease?: string; - }>; - ease?: string; - easeEach?: string; -} +export type { KeyframeCacheEntry } from "./keyframeSlice"; export interface TimelineElement { id: string; @@ -108,7 +95,7 @@ function resolveElementSelection( }; } -interface PlayerState { +interface PlayerState extends KeyframeSlice { isPlaying: boolean; currentTime: number; duration: number; @@ -140,11 +127,6 @@ interface PlayerState { activeTool: TimelineTool; setActiveTool: (tool: TimelineTool) => void; - /** Set of selected keyframe keys in format `${elementId}:${percentage}`. */ - selectedKeyframes: Set; - toggleSelectedKeyframe: (key: string) => void; - clearSelectedKeyframes: () => void; - /** Tween-relative percentage of the last-clicked keyframe diamond. Operations * (drag, resize, rotate) target this instead of recomputing from playhead. */ activeKeyframePct: number | null; @@ -193,10 +175,6 @@ interface PlayerState { toggleSelectedElementId: (id: string) => void; clearSelection: () => void; - /** Keyframe data per element id, populated from parsed GSAP animations. */ - keyframeCache: Map; - setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void; - setIsPlaying: (playing: boolean) => void; setCurrentTime: (time: number) => void; setDuration: (duration: number) => void; @@ -327,15 +305,7 @@ export const usePlayerStore = create((set, get) => ({ activeTool: "select", setActiveTool: (tool) => set({ activeTool: tool }), - selectedKeyframes: new Set(), - toggleSelectedKeyframe: (key) => - set((s) => { - const next = new Set(s.selectedKeyframes); - if (next.has(key)) next.delete(key); - else next.add(key); - return { selectedKeyframes: next }; - }), - clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }), + ...createKeyframeSlice(set), activeKeyframePct: null, setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }), @@ -363,15 +333,6 @@ export const usePlayerStore = create((set, get) => ({ }), clearSelection: () => set({ selectedElementId: null, selectedElementIds: new Set() }), - keyframeCache: new Map(), - setKeyframeCache: (elementId, data) => - set((s) => { - const next = new Map(s.keyframeCache); - if (data) next.set(elementId, data); - else next.delete(elementId); - return { keyframeCache: next }; - }), - requestedSeekTime: null, requestSeek: (time) => set({ requestedSeekTime: time }), clearSeekRequest: () => set({ requestedSeekTime: null }), @@ -573,9 +534,11 @@ export const usePlayerStore = create((set, get) => ({ outPoint: null, activeTool: "select", selectedKeyframes: new Set(), + expandedClipIds: new Set(), selectedElementIds: new Set(), clipRevealRequest: null, keyframeCache: new Map(), + gsapAnimations: new Map(), beatAnalysis: null, beatEdits: null, beatUndo: [], diff --git a/packages/studio/src/telemetry/events.test.ts b/packages/studio/src/telemetry/events.test.ts index 8a750720b4..d930adaba1 100644 --- a/packages/studio/src/telemetry/events.test.ts +++ b/packages/studio/src/telemetry/events.test.ts @@ -12,6 +12,7 @@ const { trackStudioRenderStart, trackStudioRazorSplit, trackStudioExpandedClipEdit, + trackStudioKeyframeLaneExpand, trackStudioSegmentEaseEdit, trackStudioFeedback, } = await import("./events"); @@ -72,6 +73,11 @@ describe("studio telemetry events", () => { expect(trackEvent).toHaveBeenCalledWith("studio_expanded_clip_edit", { action: "resize" }); }); + it("trackStudioKeyframeLaneExpand emits 'studio_keyframe_lane_expand' with expanded", () => { + trackStudioKeyframeLaneExpand({ expanded: true }); + expect(trackEvent).toHaveBeenCalledWith("studio_keyframe_lane_expand", { expanded: true }); + }); + it("trackStudioSegmentEaseEdit emits 'studio_segment_ease_edit' with action and ease", () => { trackStudioSegmentEaseEdit({ action: "commit", ease: "power2.out" }); expect(trackEvent).toHaveBeenCalledWith("studio_segment_ease_edit", { diff --git a/packages/studio/src/telemetry/events.ts b/packages/studio/src/telemetry/events.ts index 5758234461..ecc80e9bfd 100644 --- a/packages/studio/src/telemetry/events.ts +++ b/packages/studio/src/telemetry/events.ts @@ -63,6 +63,11 @@ export function trackStudioExpandedClipEdit(props: { trackEvent("studio_expanded_clip_edit", { action: props.action }); } +// Adoption signal for the per-clip keyframe-lane caret toggle. +export function trackStudioKeyframeLaneExpand(props: { expanded: boolean }): void { + trackEvent("studio_keyframe_lane_expand", { expanded: props.expanded }); +} + // Adoption signal for opening and committing the per-segment ease editor. export function trackStudioSegmentEaseEdit(props: { action: "open" | "commit";