Skip to content

Commit 514a219

Browse files
committed
feat(studio): add variable timeline timing and layout
1 parent e4d7bde commit 514a219

14 files changed

Lines changed: 671 additions & 188 deletions

packages/studio/src/App.tsx

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, useCallback, useRef, useMemo, useEffect, useLayoutEffect } from "react";
22
import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
33
import { useRenderQueue } from "./components/renders/useRenderQueue";
4-
import { usePlayerStore, type TimelineElement } from "./player";
4+
import { usePlayerStore } from "./player";
55
import { StudioOverlays } from "./components/StudioOverlays";
66
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
77
import { useCaptionStore } from "./captions/store";
@@ -12,9 +12,12 @@ import { useFileManager } from "./hooks/useFileManager";
1212
import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
1313
import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion";
1414
import { useTimelineEditing } from "./hooks/useTimelineEditing";
15-
import { persistTimelineMoveEditsAtomically } from "./hooks/timelineMoveAdapter";
15+
import {
16+
persistTimelineMoveEditsAtomically,
17+
type TimelineMoveEditsHandler,
18+
type TimelineMoveOperation,
19+
} from "./hooks/timelineMoveAdapter";
1620
import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes";
17-
import type { TimelineStackingReorderIntent } from "./player/components/timelineStacking";
1821
import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
1922
import { useDomEditSession } from "./hooks/useDomEditSession";
2023
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
@@ -62,7 +65,6 @@ import {
6265
} from "./utils/studioUrlState";
6366
import { trackStudioSessionStart } from "./telemetry/events";
6467
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
65-
type TimelineMoveOperation = Parameters<typeof persistTimelineMoveEditsAtomically>[2];
6668
// fallow-ignore-next-line complexity
6769
export function StudioApp() {
6870
const { projectId, resolving, waitingForServer } = useServerConnection();
@@ -154,6 +156,7 @@ export function StudioApp() {
154156
reloadPreview: () => setRefreshKey((k) => k + 1),
155157
pendingTimelineEditPathRef,
156158
});
159+
const invalidateGsapCacheRef = useRef<() => void>(() => {});
157160
const timelineEditing = useTimelineEditing({
158161
projectId,
159162
activeCompPath,
@@ -171,20 +174,11 @@ export function StudioApp() {
171174
sdkSession: editFlowSdkSession,
172175
publishSdkSession: sdkHandle.publish,
173176
forceReloadSdkSession: sdkHandle.forceReload,
177+
invalidateGsapCache: () => invalidateGsapCacheRef.current(),
174178
handleDomZIndexReorderCommitRef,
175179
});
176-
const handleTimelineElementsMove = useCallback(
177-
async (
178-
edits: Array<{
179-
element: TimelineElement;
180-
updates: Pick<TimelineElement, "start" | "track"> & {
181-
stackingReorder?: TimelineStackingReorderIntent | null;
182-
};
183-
}>,
184-
coalesceKey?: string,
185-
operation: TimelineMoveOperation = "timing",
186-
coalesceMs?: number,
187-
) => {
180+
const handleTimelineElementsMove: TimelineMoveEditsHandler = useCallback(
181+
async (edits, coalesceKey, operation: TimelineMoveOperation = "timing", coalesceMs) => {
188182
const deps = { handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove };
189183
await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs);
190184
},
@@ -228,7 +222,6 @@ export function StudioApp() {
228222
const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s);
229223
const resetKeyframesRef = useRef<() => boolean>(() => false);
230224
const deleteSelectedKeyframesRef = useRef<() => void>(() => {});
231-
const invalidateGsapCacheRef = useRef<() => void>(() => {});
232225
const { handleCopy, handlePaste, handleCut } = useClipboard({
233226
projectId,
234227
activeCompPath,

packages/studio/src/hooks/timelineMoveAdapter.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {
44
TimelineGroupMoveChange,
55
} from "./useTimelineGroupEditing";
66

7-
interface MoveEdit {
7+
export interface TimelineMoveEdit {
88
element: TimelineElement;
99
updates: Pick<TimelineElement, "start" | "track">;
1010
}
@@ -18,8 +18,15 @@ interface AtomicMoveDeps {
1818

1919
export type TimelineMoveOperation = "timing" | "lane-reorder" | "track-insert";
2020

21+
export type TimelineMoveEditsHandler = (
22+
edits: TimelineMoveEdit[],
23+
coalesceKey?: string,
24+
operation?: TimelineMoveOperation,
25+
coalesceMs?: number,
26+
) => Promise<void>;
27+
2128
export function persistTimelineMoveEditsAtomically(
22-
edits: MoveEdit[],
29+
edits: TimelineMoveEdit[],
2330
coalesceKey: string | undefined,
2431
operation: TimelineMoveOperation,
2532
deps: AtomicMoveDeps,

packages/studio/src/hooks/useTimelineEditing.test.tsx

Lines changed: 187 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
1010
import { useElementLifecycleOps } from "./useElementLifecycleOps";
1111
import { useTimelineEditing } from "./useTimelineEditing";
1212

13+
vi.mock("../components/editor/manualEditingAvailability", async (importOriginal) => {
14+
const actual =
15+
await importOriginal<typeof import("../components/editor/manualEditingAvailability")>();
16+
return {
17+
...actual,
18+
STUDIO_SDK_CUTOVER_ENABLED: true,
19+
STUDIO_SDK_CUTOVER_FAMILIES: new Set(["timing"]),
20+
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
21+
};
22+
});
23+
1324
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
1425

1526
type ZIndexEntry = {
@@ -108,7 +119,9 @@ function renderTimelineEditingHook(input: {
108119
}) => Promise<void>;
109120
reloadPreview?: () => void;
110121
sdkSession?: Awaited<ReturnType<typeof openComposition>> | null;
122+
publishSdkSession?: NonNullable<Parameters<typeof useTimelineEditing>[0]["publishSdkSession"]>;
111123
forceReloadSdkSession?: () => void;
124+
invalidateGsapCache?: () => void;
112125
showToast?: (message: string, kind?: string) => void;
113126
}): {
114127
move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
@@ -140,7 +153,9 @@ function renderTimelineEditingHook(input: {
140153
pendingTimelineEditPathRef: { current: new Set<string>() },
141154
uploadProjectFiles: vi.fn(),
142155
sdkSession: input.sdkSession,
156+
publishSdkSession: input.publishSdkSession,
143157
forceReloadSdkSession: input.forceReloadSdkSession,
158+
invalidateGsapCache: input.invalidateGsapCache,
144159
handleDomZIndexReorderCommitRef: commitRef,
145160
});
146161
move = hook.handleTimelineElementMove;
@@ -163,6 +178,9 @@ function renderTimelineEditingHook(input: {
163178
type TimelineRecordEdit = NonNullable<
164179
Parameters<typeof renderTimelineEditingHook>[0]["recordEdit"]
165180
>;
181+
type TimelinePublishSdkSession = NonNullable<
182+
Parameters<typeof renderTimelineEditingHook>[0]["publishSdkSession"]
183+
>;
166184

167185
function renderTimelineEditingHookWithLifecycle(input: {
168186
timelineElements: TimelineElement[];
@@ -227,28 +245,41 @@ async function flushAsyncWork(): Promise<void> {
227245
* with `gsapBody`. Returns the mock for call inspection.
228246
*/
229247
function stubProjectFetch(files: string | Record<string, string>, gsapBody?: unknown) {
230-
// Keep this test server's capability, file-read, and mutation routes together;
231-
// splitting the fixture would obscure the request sequence asserted by callers.
232-
// fallow-ignore-next-line complexity
233-
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
234-
const url = requestUrl(input);
235-
if (url.includes("/api/projects/p1/gsap-mutation-capabilities")) {
236-
return jsonResponse({ atomicOwnershipPairs: true });
237-
}
238-
if (url.includes("/api/projects/p1/files/")) {
239-
if (typeof files === "string") return jsonResponse({ content: files });
240-
const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
241-
return jsonResponse({ content: files[path] });
242-
}
243-
if (url.includes("/api/projects/p1/gsap-mutations/")) {
244-
const path = decodeURIComponent(url.split("/gsap-mutations/")[1] ?? "index.html");
245-
const content = typeof files === "string" ? files : (files[path] ?? "");
246-
return jsonResponse(
247-
gsapBody ?? { mutated: false, scriptText: null, before: content, after: content },
248-
);
249-
}
250-
throw new Error(`Unexpected fetch: ${url}`);
251-
});
248+
const pathAfter = (url: string, marker: string) =>
249+
decodeURIComponent(url.split(marker)[1] ?? "index.html");
250+
const fileContent = (path: string) => (typeof files === "string" ? files : files[path]);
251+
// One handler per route, so the mock itself stays a lookup: the request
252+
// sequence callers assert on is still readable top to bottom.
253+
const routes: Array<[marker: string, respond: (url: string) => Response]> = [
254+
[
255+
"/api/projects/p1/gsap-mutation-capabilities",
256+
() => jsonResponse({ atomicOwnershipPairs: true }),
257+
],
258+
[
259+
"/api/projects/p1/files/",
260+
(url) => jsonResponse({ content: fileContent(pathAfter(url, "/files/")) }),
261+
],
262+
[
263+
"/api/projects/p1/gsap-mutations/",
264+
(url) => {
265+
const content = fileContent(pathAfter(url, "/gsap-mutations/")) ?? "";
266+
return jsonResponse(
267+
gsapBody ?? { mutated: false, scriptText: null, before: content, after: content },
268+
);
269+
},
270+
],
271+
];
272+
const fetchMock = vi.fn(
273+
async (
274+
input: Parameters<typeof fetch>[0],
275+
_init?: Parameters<typeof fetch>[1],
276+
): Promise<Response> => {
277+
const url = requestUrl(input);
278+
const route = routes.find(([marker]) => url.includes(marker));
279+
if (!route) throw new Error(`Unexpected fetch: ${url}`);
280+
return route[1](url);
281+
},
282+
);
252283
vi.stubGlobal("fetch", fetchMock);
253284
return fetchMock;
254285
}
@@ -285,6 +316,39 @@ function setupSingleClipHarness(options?: {
285316
return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook };
286317
}
287318

319+
const SDK_KEYFRAMED_SOURCE = [
320+
`<div data-hf-id="hf-stage" data-hf-root data-composition-id="main" data-duration="10">`,
321+
` <div id="clip" data-hf-id="hf-clip" data-start="1" data-duration="2"></div>`,
322+
`</div>`,
323+
`<script>`,
324+
`const tl = gsap.timeline({ paused: true });`,
325+
`tl.to("#clip", { keyframes: [{ x: 0 }, { x: 100 }], duration: 2 }, 1);`,
326+
`window.__timelines = [tl];`,
327+
`</script>`,
328+
].join("\n");
329+
330+
async function setupSdkKeyframedClipHarness() {
331+
const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
332+
const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 1 });
333+
const sdkSession = await openComposition(SDK_KEYFRAMED_SOURCE);
334+
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
335+
const invalidateGsapCache = vi.fn();
336+
const fetchMock = stubProjectFetch(SDK_KEYFRAMED_SOURCE);
337+
usePlayerStore.getState().setDuration(10);
338+
const hook = renderTimelineEditingHook({
339+
timelineElements: [clip],
340+
iframe,
341+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
342+
projectId: "p1",
343+
writeProjectFile,
344+
recordEdit: vi.fn(async () => {}),
345+
sdkSession,
346+
publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
347+
invalidateGsapCache,
348+
});
349+
return { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile };
350+
}
351+
288352
/** Assert a lane write landed in both the live iframe DOM and the persisted file. */
289353
function expectLanePersisted(
290354
iframe: HTMLIFrameElement,
@@ -710,6 +774,58 @@ describe("useTimelineEditing timeline z-index reorder", () => {
710774
h.unmount();
711775
});
712776

777+
it("shifts authored GSAP positions after an SDK-backed clip move commits", async () => {
778+
const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } =
779+
await setupSdkKeyframedClipHarness();
780+
781+
await act(async () => {
782+
await hook.move(clip, { start: 2.25, track: clip.track });
783+
});
784+
785+
expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-start="2.25"');
786+
const mutationCall = fetchMock.mock.calls.find((call) =>
787+
requestUrl(call[0]).includes("/gsap-mutations/"),
788+
);
789+
expect(mutationCall).toBeDefined();
790+
const init = mutationCall?.[1] as RequestInit | undefined;
791+
expect(JSON.parse(String(init?.body))).toEqual({
792+
type: "shift-positions",
793+
targetSelector: "#clip",
794+
delta: 1.25,
795+
});
796+
expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
797+
798+
hook.unmount();
799+
});
800+
801+
it("scales authored GSAP positions after an SDK-backed clip resize commits", async () => {
802+
const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } =
803+
await setupSdkKeyframedClipHarness();
804+
805+
await act(async () => {
806+
await hook.resize(clip, { start: 2, duration: 4, playbackStart: undefined });
807+
});
808+
809+
expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-start="2"');
810+
expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-duration="4"');
811+
const mutationCall = fetchMock.mock.calls.find((call) =>
812+
requestUrl(call[0]).includes("/gsap-mutations/"),
813+
);
814+
expect(mutationCall).toBeDefined();
815+
const init = mutationCall?.[1] as RequestInit | undefined;
816+
expect(JSON.parse(String(init?.body))).toEqual({
817+
type: "scale-positions",
818+
targetSelector: "#clip",
819+
oldStart: 1,
820+
oldDuration: 2,
821+
newStart: 2,
822+
newDuration: 4,
823+
});
824+
expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
825+
826+
hook.unmount();
827+
});
828+
713829
it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => {
714830
// Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
715831
// pure lane change routed through onMoveElement (no onMoveElements wired)
@@ -821,6 +937,55 @@ describe("useTimelineEditing timeline z-index reorder", () => {
821937
unmount();
822938
});
823939

940+
it("shifts every keyed clip and invalidates the cache after an SDK-backed group move", async () => {
941+
const source = [
942+
`<div data-hf-id="hf-stage" data-hf-root data-duration="10">`,
943+
` <div id="a" data-hf-id="hf-a" data-start="0" data-duration="1"></div>`,
944+
` <div id="b" data-hf-id="hf-b" data-start="1" data-duration="1"></div>`,
945+
`</div>`,
946+
`<script>`,
947+
`const tl = gsap.timeline({ paused: true });`,
948+
`tl.to("#a", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 0);`,
949+
`tl.to("#b", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 1);`,
950+
`window.__timelines = [tl];`,
951+
`</script>`,
952+
].join("\n");
953+
const { iframe, a, b } = makeTwoClipPair();
954+
const sdkSession = await openComposition(source);
955+
const fetchMock = stubProjectFetch(source);
956+
const invalidateGsapCache = vi.fn();
957+
usePlayerStore.getState().setDuration(10);
958+
const hook = renderTimelineEditingHook({
959+
timelineElements: [a, b],
960+
iframe,
961+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
962+
projectId: "p1",
963+
writeProjectFile: vi.fn<(...args: unknown[]) => Promise<void>>(async () => {}),
964+
recordEdit: vi.fn(async () => {}),
965+
sdkSession,
966+
publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
967+
invalidateGsapCache,
968+
});
969+
970+
await act(async () => {
971+
await hook.groupMove([
972+
{ element: a, start: 1 },
973+
{ element: b, start: 2 },
974+
]);
975+
});
976+
977+
const mutations = fetchMock.mock.calls
978+
.filter((call) => requestUrl(call[0]).includes("/gsap-mutations/"))
979+
.map((call) => JSON.parse(String((call[1] as RequestInit | undefined)?.body)));
980+
expect(mutations).toEqual([
981+
{ type: "shift-positions", targetSelector: "#a", delta: 1 },
982+
{ type: "shift-positions", targetSelector: "#b", delta: 1 },
983+
]);
984+
expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
985+
986+
hook.unmount();
987+
});
988+
824989
it("partitions a group move by source file while keeping one undo entry", async () => {
825990
const files: Record<string, string> = {
826991
"index.html": '<div id="a" data-start="0" data-duration="1"></div>',

0 commit comments

Comments
 (0)