feat(studio): add keyframe timeline state

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-25 21:40:44 +02:00
parent 3b3d4f559c
commit e4d7bde64f
21 changed files with 583 additions and 193 deletions
@@ -45,17 +45,22 @@ function selectPreset(host: HTMLElement, presetId: string): string {
return presetConfig.ease;
}
function renderExpandedCard({
animation,
/** Every test mounts the same card; only expansion, flat mode, and the spies differ. */
function renderCard({
animation = baseAnimation(),
defaultExpanded = true,
flat,
onUpdateMeta = vi.fn(),
onUpdateKeyframeEase = vi.fn(),
onDeleteAnimation = noop,
}: {
animation: GsapAnimation;
animation?: GsapAnimation;
defaultExpanded?: boolean;
flat?: boolean;
onUpdateMeta?: ReturnType<typeof vi.fn>;
onUpdateKeyframeEase?: ReturnType<typeof vi.fn>;
}) {
onDeleteAnimation?: (id: string) => void;
} = {}) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
@@ -63,11 +68,11 @@ function renderExpandedCard({
root.render(
<AnimationCard
animation={animation}
defaultExpanded
defaultExpanded={defaultExpanded}
flat={flat}
onUpdateProperty={noop}
onUpdateMeta={onUpdateMeta}
onDeleteAnimation={noop}
onDeleteAnimation={onDeleteAnimation}
onAddProperty={noop}
onRemoveProperty={noop}
onUpdateKeyframeEase={onUpdateKeyframeEase}
@@ -90,7 +95,7 @@ describe("AnimationCard ease editing", () => {
],
},
});
const view = renderExpandedCard({ animation, onUpdateKeyframeEase });
const view = renderCard({ animation, onUpdateKeyframeEase });
const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("0% → 50%"),
@@ -101,6 +106,7 @@ describe("AnimationCard ease editing", () => {
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledExactlyOnceWith({
action: "commit",
ease,
});
act(() => view.root.unmount());
@@ -110,7 +116,7 @@ describe("AnimationCard ease editing", () => {
const onUpdateMeta = vi.fn();
const onUpdateKeyframeEase = vi.fn();
const animation = baseAnimation({ id: "flat-tween" });
const view = renderExpandedCard({
const view = renderCard({
animation,
flat: true,
onUpdateMeta,
@@ -128,23 +134,7 @@ describe("AnimationCard ease editing", () => {
describe("AnimationCard flat branch", () => {
it("renders a mint border-left and panel-token colors when flat", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={false}
flat
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={noop}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
const { host, root } = renderCard({ defaultExpanded: false, flat: true });
const card = host.querySelector('[data-flat-effect-card="true"]');
expect(card).not.toBeNull();
expect(card?.className).toContain("border-panel-accent");
@@ -152,22 +142,7 @@ describe("AnimationCard flat branch", () => {
});
it("still renders the legacy (non-flat) appearance when flat is omitted", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={false}
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={noop}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
const { host, root } = renderCard({ defaultExpanded: false });
expect(host.querySelector('[data-flat-effect-card="true"]')).toBeNull();
expect(host.textContent).toContain("power2.out");
act(() => root.unmount());
@@ -175,23 +150,7 @@ describe("AnimationCard flat branch", () => {
it("toggles expanded state when the collapsed header button is clicked, in both modes", () => {
for (const flat of [false, true]) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={false}
flat={flat || undefined}
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={noop}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
const { host, root } = renderCard({ defaultExpanded: false, flat: flat || undefined });
expect(host.textContent).not.toContain("Remove");
const button = host.querySelector("button");
expect(button).not.toBeNull();
@@ -205,23 +164,7 @@ describe("AnimationCard flat branch", () => {
it("invokes onDeleteAnimation with the animation id when Remove is clicked, in flat mode", () => {
const onDeleteAnimation = vi.fn();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={true}
flat
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={onDeleteAnimation}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
const { host, root } = renderCard({ flat: true, onDeleteAnimation });
const buttons = Array.from(host.querySelectorAll("button"));
const removeButton = buttons.find((b) => b.textContent === "Remove");
expect(removeButton).not.toBeUndefined();
@@ -267,7 +267,7 @@ export const AnimationCard = memo(function AnimationCard({
onToggle={setExpandedKfPct}
onEaseCommit={(pct, ease) => {
onUpdateKeyframeEase(animation.id, pct, ease);
trackStudioSegmentEaseEdit({ ease });
trackStudioSegmentEaseEdit({ action: "commit", ease });
}}
onApplyAll={
onSetAllKeyframeEases
+2 -2
View File
@@ -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,
}));
@@ -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);
});
});
@@ -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,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<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}`;
}
@@ -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 {
+30 -1
View File
@@ -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);
}
});
});
+42 -13
View File
@@ -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<string, unknown>,
): Record<string, number | string> {
const properties: Record<string, number | string> = {};
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<string, number | string> = {};
for (const [pk, pv] of Object.entries(entry as Record<string, unknown>)) {
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<string, unknown>);
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<string, number | string> = {};
for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
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<string, unknown>);
if (Object.keys(properties).length > 0) {
keyframes.push({ percentage, properties });
}
@@ -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>): 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();
});
});
+36 -13
View File
@@ -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<number, GsapPercentageKeyframe>();
export function deduplicateKeyframes<
T extends GsapPercentageKeyframe & { animationId?: string; easeAmbiguous?: boolean },
>(keyframes: T[]): T[] {
const byPct = new Map<number, T>();
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<string, number | string> = {};
const endProps: Record<string, number | string> = {};
const rawStart: Record<string, number | string> = {};
const rawEnd: Record<string, number | string> = {};
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 } : {}),
};
@@ -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) => {
@@ -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;
@@ -19,11 +19,16 @@ afterEach(() => {
const selection: DomEditSelection = { id: "box", selector: "#box" } as DomEditSelection;
function successfulCommitMutation() {
return vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({ ok: true }));
}
function renderKeyframeOps(over: {
commitMutation: (...args: unknown[]) => Promise<unknown>;
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({
@@ -51,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<unknown>>(async () => ({
ok: true,
}));
const commitMutation = successfulCommitMutation();
const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>();
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
@@ -102,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<unknown>>(async () => ({
ok: true,
}));
const commitMutation = successfulCommitMutation();
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
await act(async () => {
@@ -123,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<unknown>>(async () => ({
ok: true,
}));
const commitMutation = successfulCommitMutation();
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
const coalesceKey = "enable-keyframes:box-to-0-opacity:1";
@@ -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<string, number | string>,
commitOverrides?: Partial<CommitMutationOptions>,
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],
);
+23 -4
View File
@@ -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<string, GsapKeyframesData>();
const sourceByElement = new Map<string, GsapAnimation[]>();
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;
});
@@ -0,0 +1,51 @@
import { useEffect } from "react";
import type { DomEditSelection } from "../components/editor/domEditing";
interface StudioTestHookDeps {
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
buildDomSelectionFromTarget: (target: HTMLElement) => Promise<DomEditSelection | null>;
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<boolean> => {
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]);
}
@@ -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<string, number | string>;
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<string>;
toggleSelectedKeyframe: (key: string) => void;
clearSelectedKeyframes: () => void;
/** Clips whose keyframe property lanes are expanded in the timeline. */
expandedClipIds: Set<string>;
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<string, KeyframeCacheEntry>;
/** Unmerged source tweens per element; expanded property lanes read this, never keyframeCache. */
gsapAnimations: Map<string, GsapAnimation[]>;
setGsapAnimations: (elementId: string, animations: GsapAnimation[] | undefined) => void;
setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void;
}
export function createKeyframeSlice(set: StoreApi<KeyframeSlice>["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 };
}),
};
}
@@ -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());
});
});
@@ -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<string, number | string>;
ease?: string;
}>;
ease?: string;
easeEach?: string;
}
export type { KeyframeCacheEntry } from "./keyframeSlice";
export interface TimelineElement {
id: string;
@@ -109,7 +96,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<string>;
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<string, KeyframeCacheEntry>;
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<PlayerState>((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<PlayerState>((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 }),
@@ -569,9 +530,11 @@ export const usePlayerStore = create<PlayerState>((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: [],
+7 -1
View File
@@ -12,6 +12,7 @@ const {
trackStudioRenderStart,
trackStudioRazorSplit,
trackStudioExpandedClipEdit,
trackStudioKeyframeLaneExpand,
trackStudioSegmentEaseEdit,
trackStudioFeedback,
} = await import("./events");
@@ -72,8 +73,13 @@ 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({ ease: "power2.out" });
trackStudioSegmentEaseEdit({ action: "commit", ease: "power2.out" });
expect(trackEvent).toHaveBeenCalledWith("studio_segment_ease_edit", {
action: "commit",
ease: "power2.out",
+11 -3
View File
@@ -63,9 +63,17 @@ export function trackStudioExpandedClipEdit(props: {
trackEvent("studio_expanded_clip_edit", { action: props.action });
}
// Adoption signal for committing an edit to a segment's ease.
export function trackStudioSegmentEaseEdit(props: { ease: string }): void {
trackEvent("studio_segment_ease_edit", { action: "commit", ease: props.ease });
// 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";
ease?: string;
}): void {
trackEvent("studio_segment_ease_edit", { action: props.action, ease: props.ease });
}
export function trackStudioFeedback(props: { rating: number; comment?: string }): void {