mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +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,
|
pendingTimelineEditPathRef,
|
||||||
uploadProjectFiles: fileManager.uploadProjectFiles,
|
uploadProjectFiles: fileManager.uploadProjectFiles,
|
||||||
isRecordingRef: isGestureRecordingRef,
|
isRecordingRef: isGestureRecordingRef,
|
||||||
|
sdkSession: sdkHandle.session,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
activeBlockParams,
|
activeBlockParams,
|
||||||
@@ -359,7 +360,6 @@ export function StudioApp() {
|
|||||||
resetErrors: resetConsoleErrors,
|
resetErrors: resetConsoleErrors,
|
||||||
} = useConsoleErrorCapture(previewIframe);
|
} = useConsoleErrorCapture(previewIframe);
|
||||||
const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
|
const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
|
||||||
|
|
||||||
// Gesture recording
|
// Gesture recording
|
||||||
const handleToggleRecordingRef = useRef<() => void>(() => {});
|
const handleToggleRecordingRef = useRef<() => void>(() => {});
|
||||||
const domEditSessionRef = useRef(domEditSession);
|
const domEditSessionRef = useRef(domEditSession);
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ import {
|
|||||||
readFileContent,
|
readFileContent,
|
||||||
applyPatchByTarget,
|
applyPatchByTarget,
|
||||||
formatTimelineAttributeNumber,
|
formatTimelineAttributeNumber,
|
||||||
shiftGsapPositions,
|
|
||||||
scaleGsapPositions,
|
|
||||||
} from "./timelineEditingHelpers";
|
} from "./timelineEditingHelpers";
|
||||||
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
||||||
|
import { sdkTimingPersist } from "../utils/sdkCutover";
|
||||||
|
import type { Composition } from "@hyperframes/sdk";
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
@@ -56,6 +56,8 @@ interface UseTimelineEditingOptions {
|
|||||||
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
||||||
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
||||||
isRecordingRef?: React.RefObject<boolean>;
|
isRecordingRef?: React.RefObject<boolean>;
|
||||||
|
/** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */
|
||||||
|
sdkSession?: Composition | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Hook ──
|
// ── Hook ──
|
||||||
@@ -73,6 +75,7 @@ export function useTimelineEditing({
|
|||||||
pendingTimelineEditPathRef,
|
pendingTimelineEditPathRef,
|
||||||
uploadProjectFiles,
|
uploadProjectFiles,
|
||||||
isRecordingRef,
|
isRecordingRef,
|
||||||
|
sdkSession,
|
||||||
}: UseTimelineEditingOptions) {
|
}: UseTimelineEditingOptions) {
|
||||||
const projectIdRef = useRef(projectId);
|
const projectIdRef = useRef(projectId);
|
||||||
projectIdRef.current = projectId;
|
projectIdRef.current = projectId;
|
||||||
@@ -121,15 +124,16 @@ export function useTimelineEditing({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
const handleTimelineElementMove = useCallback(
|
const handleTimelineElementMove = useCallback(
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
(element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
(element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
||||||
patchIframeDomTiming(previewIframeRef.current, element, [
|
patchIframeDomTiming(previewIframeRef.current, element, [
|
||||||
["data-start", formatTimelineAttributeNumber(updates.start)],
|
["data-start", formatTimelineAttributeNumber(updates.start)],
|
||||||
["data-track-index", String(updates.track)],
|
["data-track-index", String(updates.track)],
|
||||||
]);
|
]);
|
||||||
const delta = updates.start - element.start;
|
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||||
const filePath = element.sourceFile || activeCompPath || "index.html";
|
const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
|
||||||
return enqueueEdit(element, "Move timeline clip", (original, target) => {
|
|
||||||
let patched = applyPatchByTarget(original, target, {
|
let patched = applyPatchByTarget(original, target, {
|
||||||
type: "attribute",
|
type: "attribute",
|
||||||
property: "start",
|
property: "start",
|
||||||
@@ -140,39 +144,46 @@ export function useTimelineEditing({
|
|||||||
property: "track-index",
|
property: "track-index",
|
||||||
value: String(updates.track),
|
value: String(updates.track),
|
||||||
});
|
});
|
||||||
}).then(() => {
|
};
|
||||||
const pid = projectIdRef.current;
|
if (sdkSession && element.hfId) {
|
||||||
if (delta !== 0 && element.domId && pid) {
|
return sdkTimingPersist(
|
||||||
return shiftGsapPositions(pid, filePath, element.domId, delta)
|
element.hfId,
|
||||||
.then(() => reloadPreview())
|
targetPath,
|
||||||
.catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err));
|
{ 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(
|
const handleTimelineElementResize = useCallback(
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
(
|
(
|
||||||
element: TimelineElement,
|
element: TimelineElement,
|
||||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||||
) => {
|
) => {
|
||||||
const liveAttrs: Array<[string, string]> = [
|
patchIframeDomTiming(previewIframeRef.current, element, [
|
||||||
["data-start", formatTimelineAttributeNumber(updates.start)],
|
["data-start", formatTimelineAttributeNumber(updates.start)],
|
||||||
["data-duration", formatTimelineAttributeNumber(updates.duration)],
|
["data-duration", formatTimelineAttributeNumber(updates.duration)],
|
||||||
];
|
]);
|
||||||
if (updates.playbackStart != null) {
|
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||||
const liveAttr =
|
const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
|
||||||
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 pbs = resolveResizePlaybackStart(original, target, element, updates);
|
const pbs = resolveResizePlaybackStart(original, target, element, updates);
|
||||||
let patched = applyPatchByTarget(original, target, {
|
let patched = applyPatchByTarget(original, target, {
|
||||||
type: "attribute",
|
type: "attribute",
|
||||||
@@ -192,29 +203,40 @@ export function useTimelineEditing({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return patched;
|
return patched;
|
||||||
}).then(() => {
|
};
|
||||||
const pid = projectIdRef.current;
|
// SDK path: skip when a playback-start adjustment is needed (setTiming has no pbs field).
|
||||||
if (timingChanged && element.domId && pid) {
|
// Condition: no explicit pbs override AND (no start change OR element has no pbs attribute).
|
||||||
return scaleGsapPositions(
|
const hasPbsAdjustment =
|
||||||
pid,
|
updates.playbackStart != null ||
|
||||||
filePath,
|
(updates.start !== element.start && element.playbackStart != null);
|
||||||
element.domId,
|
if (sdkSession && element.hfId && !hasPbsAdjustment) {
|
||||||
element.start,
|
return sdkTimingPersist(
|
||||||
element.duration,
|
element.hfId,
|
||||||
updates.start,
|
targetPath,
|
||||||
updates.duration,
|
{ start: updates.start, duration: updates.duration },
|
||||||
)
|
sdkSession,
|
||||||
.then(() => reloadPreview())
|
{ editHistory: { recordEdit }, writeProjectFile, reloadPreview, domEditSaveTimestampRef },
|
||||||
.catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err));
|
{ label: "Resize timeline clip", coalesceKey: `timeline-resize:${element.hfId}` },
|
||||||
}
|
).then((handled) => {
|
||||||
return reloadPreview();
|
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(
|
const handleTimelineElementDelete = useCallback(
|
||||||
// Pre-existing handler complexity, unchanged by this PR.
|
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
async (element: TimelineElement) => {
|
async (element: TimelineElement) => {
|
||||||
if (isRecordingRef?.current) {
|
if (isRecordingRef?.current) {
|
||||||
@@ -289,8 +311,8 @@ export function useTimelineEditing({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
const handleTimelineAssetDrop = useCallback(
|
const handleTimelineAssetDrop = useCallback(
|
||||||
// Pre-existing handler complexity, unchanged by this PR.
|
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
async (
|
async (
|
||||||
assetPath: string,
|
assetPath: string,
|
||||||
@@ -373,8 +395,8 @@ export function useTimelineEditing({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
const handleTimelineFileDrop = useCallback(
|
const handleTimelineFileDrop = useCallback(
|
||||||
// Pre-existing handler complexity, unchanged by this PR.
|
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
|
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
|
||||||
if (isRecordingRef?.current) {
|
if (isRecordingRef?.current) {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
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 { openComposition } from "@hyperframes/sdk";
|
||||||
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
|
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
|
||||||
import type { PatchOperation } from "./sourcePatcher";
|
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)", () => {
|
describe("sdkCutoverPersist — GSAP script preservation (integration)", () => {
|
||||||
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
||||||
const makeDeps = () => ({
|
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(
|
export async function sdkDeletePersist(
|
||||||
hfId: string,
|
hfId: string,
|
||||||
originalContent: string,
|
originalContent: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user