feat(studio): route timeline trim/move through SDK setTiming (§3.2) (#1466)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
Vance Ingalls
2026-06-17 16:40:07 -07:00
committed by GitHub
co-authored by Miguel Ángel
parent bce571c2a1
commit 39f37e8aa8
4 changed files with 173 additions and 51 deletions
+1 -1
View File
@@ -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);
+71 -49
View File
@@ -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<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 ──
@@ -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<TimelineElement, "start" | "track">) => {
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<TimelineElement, "start" | "duration" | "playbackStart">,
) => {
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<TimelineElement, "start" | "track">) => {
if (isRecordingRef?.current) {
+80 -1
View File
@@ -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 = <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 = () => ({
+21
View File
@@ -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<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,