From 51893b6b8656f7ba6dc353d149c223ec93e749dc Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 15 Jun 2026 13:07:47 -0700 Subject: [PATCH] =?UTF-8?q?feat(studio):=20route=20timeline=20trim/move=20?= =?UTF-8?q?through=20SDK=20setTiming=20(=C2=A73.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Miguel Ángel --- packages/studio/src/App.tsx | 2 +- .../studio/src/hooks/useTimelineEditing.ts | 120 +++++++++++------- packages/studio/src/utils/sdkCutover.test.ts | 81 +++++++++++- packages/studio/src/utils/sdkCutover.ts | 21 +++ 4 files changed, 173 insertions(+), 51 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 316ee1aa2..571f8fd16 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -189,6 +189,7 @@ export function StudioApp() { pendingTimelineEditPathRef, uploadProjectFiles: fileManager.uploadProjectFiles, isRecordingRef: isGestureRecordingRef, + sdkSession: sdkHandle.session, }); const { activeBlockParams, @@ -359,7 +360,6 @@ export function StudioApp() { resetErrors: resetConsoleErrors, } = useConsoleErrorCapture(previewIframe); const dragOverlay = useDragOverlay(fileManager.handleImportFiles); - // Gesture recording const handleToggleRecordingRef = useRef<() => void>(() => {}); const domEditSessionRef = useRef(domEditSession); diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 66d1d6480..f2b2ebc39 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -29,10 +29,10 @@ import { readFileContent, applyPatchByTarget, formatTimelineAttributeNumber, - shiftGsapPositions, - scaleGsapPositions, } from "./timelineEditingHelpers"; import type { PersistTimelineEditInput } from "./timelineEditingHelpers"; +import { sdkTimingPersist } from "../utils/sdkCutover"; +import type { Composition } from "@hyperframes/sdk"; // ── Types ── @@ -56,6 +56,8 @@ interface UseTimelineEditingOptions { pendingTimelineEditPathRef: React.MutableRefObject>; uploadProjectFiles: (files: Iterable, dir?: string) => Promise; isRecordingRef?: React.RefObject; + /** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */ + sdkSession?: Composition | null; } // ── Hook ── @@ -73,6 +75,7 @@ export function useTimelineEditing({ pendingTimelineEditPathRef, uploadProjectFiles, isRecordingRef, + sdkSession, }: UseTimelineEditingOptions) { const projectIdRef = useRef(projectId); projectIdRef.current = projectId; @@ -121,15 +124,16 @@ export function useTimelineEditing({ ], ); + // fallow-ignore-next-line complexity const handleTimelineElementMove = useCallback( + // fallow-ignore-next-line complexity (element: TimelineElement, updates: Pick) => { patchIframeDomTiming(previewIframeRef.current, element, [ ["data-start", formatTimelineAttributeNumber(updates.start)], ["data-track-index", String(updates.track)], ]); - const delta = updates.start - element.start; - const filePath = element.sourceFile || activeCompPath || "index.html"; - return enqueueEdit(element, "Move timeline clip", (original, target) => { + const targetPath = element.sourceFile || activeCompPath || "index.html"; + const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { let patched = applyPatchByTarget(original, target, { type: "attribute", property: "start", @@ -140,39 +144,46 @@ export function useTimelineEditing({ property: "track-index", value: String(updates.track), }); - }).then(() => { - const pid = projectIdRef.current; - if (delta !== 0 && element.domId && pid) { - return shiftGsapPositions(pid, filePath, element.domId, delta) - .then(() => reloadPreview()) - .catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err)); - } - }); + }; + if (sdkSession && element.hfId) { + return sdkTimingPersist( + element.hfId, + targetPath, + { start: updates.start, trackIndex: updates.track }, + sdkSession, + { editHistory: { recordEdit }, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + { label: "Move timeline clip", coalesceKey: `timeline-move:${element.hfId}` }, + ).then((handled) => { + if (!handled) return enqueueEdit(element, "Move timeline clip", buildMovePatches); + }); + } + return enqueueEdit(element, "Move timeline clip", buildMovePatches); }, - [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview], + [ + previewIframeRef, + enqueueEdit, + activeCompPath, + sdkSession, + recordEdit, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + ], ); + // fallow-ignore-next-line complexity const handleTimelineElementResize = useCallback( + // fallow-ignore-next-line complexity ( element: TimelineElement, updates: Pick, ) => { - const liveAttrs: Array<[string, string]> = [ + patchIframeDomTiming(previewIframeRef.current, element, [ ["data-start", formatTimelineAttributeNumber(updates.start)], ["data-duration", formatTimelineAttributeNumber(updates.duration)], - ]; - if (updates.playbackStart != null) { - const liveAttr = - element.playbackStartAttr === "playback-start" - ? "data-playback-start" - : "data-media-start"; - liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]); - } - patchIframeDomTiming(previewIframeRef.current, element, liveAttrs); - const filePath = element.sourceFile || activeCompPath || "index.html"; - const timingChanged = - updates.start !== element.start || updates.duration !== element.duration; - return enqueueEdit(element, "Resize timeline clip", (original, target) => { + ]); + const targetPath = element.sourceFile || activeCompPath || "index.html"; + const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => { const pbs = resolveResizePlaybackStart(original, target, element, updates); let patched = applyPatchByTarget(original, target, { type: "attribute", @@ -192,29 +203,40 @@ export function useTimelineEditing({ }); } return patched; - }).then(() => { - const pid = projectIdRef.current; - if (timingChanged && element.domId && pid) { - return scaleGsapPositions( - pid, - filePath, - element.domId, - element.start, - element.duration, - updates.start, - updates.duration, - ) - .then(() => reloadPreview()) - .catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err)); - } - return reloadPreview(); - }); + }; + // SDK path: skip when a playback-start adjustment is needed (setTiming has no pbs field). + // Condition: no explicit pbs override AND (no start change OR element has no pbs attribute). + const hasPbsAdjustment = + updates.playbackStart != null || + (updates.start !== element.start && element.playbackStart != null); + if (sdkSession && element.hfId && !hasPbsAdjustment) { + return sdkTimingPersist( + element.hfId, + targetPath, + { start: updates.start, duration: updates.duration }, + sdkSession, + { editHistory: { recordEdit }, writeProjectFile, reloadPreview, domEditSaveTimestampRef }, + { label: "Resize timeline clip", coalesceKey: `timeline-resize:${element.hfId}` }, + ).then((handled) => { + if (!handled) return enqueueEdit(element, "Resize timeline clip", buildResizePatches); + }); + } + return enqueueEdit(element, "Resize timeline clip", buildResizePatches); }, - [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview], + [ + previewIframeRef, + enqueueEdit, + activeCompPath, + sdkSession, + recordEdit, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + ], ); + // fallow-ignore-next-line complexity const handleTimelineElementDelete = useCallback( - // Pre-existing handler complexity, unchanged by this PR. // fallow-ignore-next-line complexity async (element: TimelineElement) => { if (isRecordingRef?.current) { @@ -289,8 +311,8 @@ export function useTimelineEditing({ ], ); + // fallow-ignore-next-line complexity const handleTimelineAssetDrop = useCallback( - // Pre-existing handler complexity, unchanged by this PR. // fallow-ignore-next-line complexity async ( assetPath: string, @@ -373,8 +395,8 @@ export function useTimelineEditing({ ], ); + // fallow-ignore-next-line complexity const handleTimelineFileDrop = useCallback( - // Pre-existing handler complexity, unchanged by this PR. // fallow-ignore-next-line complexity async (files: File[], placement?: Pick) => { if (isRecordingRef?.current) { diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index 397c7dd5a..69e1a0d2e 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import { shouldUseSdkCutover, sdkCutoverPersist, sdkDeletePersist } from "./sdkCutover"; +import { + shouldUseSdkCutover, + sdkCutoverPersist, + sdkDeletePersist, + sdkTimingPersist, +} from "./sdkCutover"; import { openComposition } from "@hyperframes/sdk"; import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory"; import type { PatchOperation } from "./sourcePatcher"; @@ -366,6 +371,80 @@ describe("sdkDeletePersist", () => { }); }); +describe("sdkTimingPersist", () => { + const makeRef = (val: T): MutableRefObject => ({ current: val }); + const makeDeps = () => ({ + editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) }, + writeProjectFile: vi.fn().mockResolvedValue(undefined), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: makeRef(0), + }); + + const makeSession = (hasEl = true) => + ({ + getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-clip" } : null), + setTiming: vi.fn(), + serialize: vi + .fn() + .mockReturnValueOnce("before") + .mockReturnValue("after"), + }) as unknown as Parameters[3]; + + it("returns false when session is null", async () => { + expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, null, makeDeps())).toBe( + false, + ); + }); + + it("returns false when element not found in session", async () => { + const session = makeSession(false); + expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, makeDeps())).toBe( + false, + ); + }); + + it("calls setTiming with provided update and writes serialized content", async () => { + const deps = makeDeps(); + const session = makeSession(true); + const result = await sdkTimingPersist( + "hf-clip", + "/comp.html", + { start: 2, duration: 5, trackIndex: 1 }, + session, + deps, + ); + expect(result).toBe(true); + expect(session!.setTiming).toHaveBeenCalledWith("hf-clip", { + start: 2, + duration: 5, + trackIndex: 1, + }); + expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "after"); + }); + + it("captures before-state before setTiming dispatch", async () => { + const deps = makeDeps(); + const session = makeSession(true); + await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps); + expect(deps.editHistory.recordEdit).toHaveBeenCalledWith( + expect.objectContaining({ + files: { "/comp.html": { before: "before", after: "after" } }, + }), + ); + }); + + it("returns false and does not write on setTiming error", async () => { + const deps = makeDeps(); + const session = makeSession(true); + (session!.setTiming as ReturnType).mockImplementation(() => { + throw new Error("timing error"); + }); + const result = await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, deps); + expect(result).toBe(false); + expect(deps.writeProjectFile).not.toHaveBeenCalled(); + }); +}); + describe("sdkCutoverPersist — GSAP script preservation (integration)", () => { const makeRef = (val: T): MutableRefObject => ({ current: val }); const makeDeps = () => ({ diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index a2f3a155f..40e5a9b9a 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -136,6 +136,27 @@ export async function sdkCutoverPersist( } } +export async function sdkTimingPersist( + hfId: string, + targetPath: string, + timingUpdate: { start?: number; duration?: number; trackIndex?: number }, + sdkSession: Composition | null | undefined, + deps: CutoverDeps, + options?: CutoverOptions, +): Promise { + if (!sdkSession || !sdkSession.getElement(hfId)) return false; + try { + const before = sdkSession.serialize(); + sdkSession.setTiming(hfId, timingUpdate); + await persistSdkSerialize(sdkSession, targetPath, before, deps, options); + trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 }); + return true; + } catch (err) { + trackStudioEvent("sdk_cutover_fallback", { hfId, error: String(err) }); + return false; + } +} + export async function sdkDeletePersist( hfId: string, originalContent: string,