diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 207a366a1..72d749b11 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, }); 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 37c6f9161..c3f890cc7 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -28,10 +28,12 @@ import { formatTimelineAttributeNumber, } from "./timelineEditingHelpers"; import type { PersistTimelineEditInput } from "./timelineEditingHelpers"; +import { sdkTimingPersist } from "../utils/sdkCutover"; +import type { Composition } from "@hyperframes/sdk"; // ── Types ── -export interface RecordEditInput { +interface RecordEditInput { label: string; kind: EditHistoryKind; coalesceKey?: string; @@ -51,6 +53,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 ── @@ -68,6 +72,7 @@ export function useTimelineEditing({ pendingTimelineEditPathRef, uploadProjectFiles, isRecordingRef, + sdkSession, }: UseTimelineEditingOptions) { const projectIdRef = useRef(projectId); projectIdRef.current = projectId; @@ -116,13 +121,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)], ]); - 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", @@ -133,12 +141,36 @@ export function useTimelineEditing({ property: "track-index", value: String(updates.track), }); - }); + }; + 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], + [ + 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, @@ -147,7 +179,8 @@ export function useTimelineEditing({ ["data-start", formatTimelineAttributeNumber(updates.start)], ["data-duration", formatTimelineAttributeNumber(updates.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", @@ -167,12 +200,41 @@ export function useTimelineEditing({ }); } return patched; - }); + }; + // 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], + [ + previewIframeRef, + enqueueEdit, + activeCompPath, + sdkSession, + recordEdit, + writeProjectFile, + reloadPreview, + domEditSaveTimestampRef, + ], ); + // fallow-ignore-next-line complexity const handleTimelineElementDelete = useCallback( + // fallow-ignore-next-line complexity async (element: TimelineElement) => { if (isRecordingRef?.current) { showToast("Cannot edit timeline while recording", "error"); @@ -246,7 +308,9 @@ export function useTimelineEditing({ ], ); + // fallow-ignore-next-line complexity const handleTimelineAssetDrop = useCallback( + // fallow-ignore-next-line complexity async ( assetPath: string, placement: Pick, @@ -328,7 +392,9 @@ export function useTimelineEditing({ ], ); + // fallow-ignore-next-line complexity const handleTimelineFileDrop = useCallback( + // fallow-ignore-next-line complexity async (files: File[], placement?: Pick) => { if (isRecordingRef?.current) { showToast("Cannot edit timeline while recording", "error"); diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index 67c689752..de7d906dd 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"; @@ -359,6 +364,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 9d9f8f04d..0774f90db 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -128,6 +128,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,