Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ export function StudioApp() {
pendingTimelineEditPathRef,
uploadProjectFiles: fileManager.uploadProjectFiles,
isRecordingRef: isGestureRecordingRef,
sdkSession,
});
const {
activeBlockParams,
Expand Down Expand Up @@ -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);
Expand Down
80 changes: 73 additions & 7 deletions packages/studio/src/hooks/useTimelineEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -51,6 +53,8 @@ interface UseTimelineEditingOptions {
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
isRecordingRef?: React.RefObject<boolean>;
/** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */
sdkSession?: Composition | null;
}

// ── Hook ──
Expand All @@ -68,6 +72,7 @@ export function useTimelineEditing({
pendingTimelineEditPathRef,
uploadProjectFiles,
isRecordingRef,
sdkSession,
}: UseTimelineEditingOptions) {
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;
Expand Down Expand Up @@ -116,13 +121,16 @@ export function useTimelineEditing({
],
);

// fallow-ignore-next-line complexity
const handleTimelineElementMove = useCallback(
// fallow-ignore-next-line complexity
(element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
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",
Expand All @@ -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<TimelineElement, "start" | "duration" | "playbackStart">,
Expand All @@ -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",
Expand All @@ -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");
Expand Down Expand Up @@ -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<TimelineElement, "start" | "track">,
Expand Down Expand Up @@ -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<TimelineElement, "start" | "track">) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
Expand Down
81 changes: 80 additions & 1 deletion packages/studio/src/utils/sdkCutover.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -359,6 +364,80 @@ describe("sdkDeletePersist", () => {
});
});

describe("sdkTimingPersist", () => {
const makeRef = <T>(val: T): MutableRefObject<T> => ({ 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("<html>before</html>")
.mockReturnValue("<html>after</html>"),
}) as unknown as Parameters<typeof sdkTimingPersist>[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", "<html>after</html>");
});

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: "<html>before</html>", after: "<html>after</html>" } },
}),
);
});

it("returns false and does not write on setTiming error", async () => {
const deps = makeDeps();
const session = makeSession(true);
(session!.setTiming as ReturnType<typeof vi.fn>).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 = <T>(val: T): MutableRefObject<T> => ({ current: val });
const makeDeps = () => ({
Expand Down
21 changes: 21 additions & 0 deletions packages/studio/src/utils/sdkCutover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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,
Expand Down
Loading