Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 10 additions & 17 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useCallback, useRef, useMemo, useEffect, useLayoutEffect } from "react";
import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
import { useRenderQueue } from "./components/renders/useRenderQueue";
import { usePlayerStore, type TimelineElement } from "./player";
import { usePlayerStore } from "./player";
import { StudioOverlays } from "./components/StudioOverlays";
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
import { useCaptionStore } from "./captions/store";
Expand All @@ -12,9 +12,12 @@ import { useFileManager } from "./hooks/useFileManager";
import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion";
import { useTimelineEditing } from "./hooks/useTimelineEditing";
import { persistTimelineMoveEditsAtomically } from "./hooks/timelineMoveAdapter";
import {
persistTimelineMoveEditsAtomically,
type TimelineMoveEditsHandler,
type TimelineMoveOperation,
} from "./hooks/timelineMoveAdapter";
import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes";
import type { TimelineStackingReorderIntent } from "./player/components/timelineStacking";
import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
import { useDomEditSession } from "./hooks/useDomEditSession";
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
Expand Down Expand Up @@ -62,7 +65,6 @@ import {
} from "./utils/studioUrlState";
import { trackStudioSessionStart } from "./telemetry/events";
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
type TimelineMoveOperation = Parameters<typeof persistTimelineMoveEditsAtomically>[2];
// fallow-ignore-next-line complexity
export function StudioApp() {
const { projectId, resolving, waitingForServer } = useServerConnection();
Expand Down Expand Up @@ -154,6 +156,7 @@ export function StudioApp() {
reloadPreview: () => setRefreshKey((k) => k + 1),
pendingTimelineEditPathRef,
});
const invalidateGsapCacheRef = useRef<() => void>(() => {});
const timelineEditing = useTimelineEditing({
projectId,
activeCompPath,
Expand All @@ -171,20 +174,11 @@ export function StudioApp() {
sdkSession: editFlowSdkSession,
publishSdkSession: sdkHandle.publish,
forceReloadSdkSession: sdkHandle.forceReload,
invalidateGsapCache: () => invalidateGsapCacheRef.current(),
handleDomZIndexReorderCommitRef,
});
const handleTimelineElementsMove = useCallback(
async (
edits: Array<{
element: TimelineElement;
updates: Pick<TimelineElement, "start" | "track"> & {
stackingReorder?: TimelineStackingReorderIntent | null;
};
}>,
coalesceKey?: string,
operation: TimelineMoveOperation = "timing",
coalesceMs?: number,
) => {
const handleTimelineElementsMove: TimelineMoveEditsHandler = useCallback(
async (edits, coalesceKey, operation: TimelineMoveOperation = "timing", coalesceMs) => {
const deps = { handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove };
await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs);
},
Expand Down Expand Up @@ -228,7 +222,6 @@ export function StudioApp() {
const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s);
const resetKeyframesRef = useRef<() => boolean>(() => false);
const deleteSelectedKeyframesRef = useRef<() => void>(() => {});
const invalidateGsapCacheRef = useRef<() => void>(() => {});
const { handleCopy, handlePaste, handleCut } = useClipboard({
projectId,
activeCompPath,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ describe("AnimationCard ease editing", () => {

expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledExactlyOnceWith({
action: "commit",
ease,
});
act(() => view.root.unmount());
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/components/editor/AnimationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions packages/studio/src/components/editor/keyframeRetime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,12 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
expect(r.kind).toBe("resize");
expect(r.position).toBeCloseTo(2, 5); // start unchanged
expect(r.duration).toBeCloseTo(6, 5); // 8 - 2
// abs 2/4/8 over the new [2,8] window → 0 / 33.3 / 100. pctRemap carries each
// abs 2/4/8 over the new [2,8] window → 0 / 33.333 / 100. pctRemap carries each
// existing keyframe's old→new tween-%; the commit re-keys in place (value +
// ease + _auto preserved by round-tripping the source node, not re-emitted here).
expect(r.pctRemap).toEqual([
{ from: 0, to: 0 },
{ from: 50, to: 33.3 },
{ from: 50, to: 33.333 },
{ from: 100, to: 100 },
]);
});
Expand All @@ -113,10 +113,10 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => {
expect(r.kind).toBe("resize");
expect(r.position).toBeCloseTo(0.5, 5);
expect(r.duration).toBeCloseTo(5.5, 5); // 6 - 0.5
// abs 0.5/4/6 over [0.5,6] → 0 / 63.6 / 100.
// abs 0.5/4/6 over [0.5,6] → 0 / 63.636 / 100.
expect(r.pctRemap).toEqual([
{ from: 0, to: 0 },
{ from: 50, to: 63.6 },
{ from: 50, to: 63.636 },
{ from: 100, to: 100 },
]);
});
Expand Down
3 changes: 1 addition & 2 deletions packages/studio/src/components/editor/keyframeRetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ const EPSILON_TIME = 1e-4;
const MIN_TWEEN_DURATION = 0.01;

const round3 = (n: number) => Math.round(n * 1000) / 1000;
const round1 = (n: number) => Math.round(n * 10) / 10; // 0.1% precision
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));

/** Resolve timing for a flat tween's synthesized start/end diamond. */
Expand Down Expand Up @@ -153,7 +152,7 @@ export function resolveKeyframeRetime(opts: {
const pctRemap: KeyframePctRemap[] = keyframes.map((kf, i) => {
const absTime =
i === draggedIdx ? dropAbsTime : tweenStart + (kf.percentage / 100) * tweenDuration;
return { from: kf.percentage, to: round1(((absTime - newStart) / newDuration) * 100) };
return { from: kf.percentage, to: round3(((absTime - newStart) / newDuration) * 100) };
});

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,13 +152,13 @@ export function useTimelineEditCallbacks({
// resizes the tween — position/duration grow so the dragged keyframe lands at
// the drop while every other keyframe keeps its absolute time (value+ease too).
// fallow-ignore-next-line complexity
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
onMoveKeyframe: async (_elId: string, fromClipPct: number, toClipPct: number) => {
const target = resolveKeyframeTarget(fromClipPct);
const sel = domEditSelection;
if (!target || !sel) return;
if (!target || !sel) return false;
const anim = selectedGsapAnimations.find((a) => a.id === target.animId);
const tweenStart = anim ? resolveTweenStart(anim) : null;
if (!anim || tweenStart === null) return;
if (!anim || tweenStart === null) return false;
const tweenDuration = anim.duration ?? resolveTweenDuration(anim);
const sourceFile = sel.sourceFile || activeCompPath || "index.html";
const { elements, domClipChildren } = usePlayerStore.getState();
Expand Down Expand Up @@ -200,7 +200,10 @@ export function useTimelineEditCallbacks({
duration: decision.duration,
});
}
} else {
return false;
}
return true;
},
onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => {
for (const anim of selectedGsapAnimations) {
Expand Down
4 changes: 2 additions & 2 deletions packages/studio/src/hooks/gsapDragCommit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
}));
Expand Down
96 changes: 95 additions & 1 deletion packages/studio/src/hooks/gsapKeyframeCacheHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
Expand All @@ -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);
});
});
Loading
Loading