mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
co-authored by
Miguel Ángel
parent
bce571c2a1
commit
39f37e8aa8
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = () => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user