mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
perf(studio): grow composition duration live on extend, no preview remount
Extending a clip past the video end used to force the server-fallback path that fully remounts the preview iframe (the SDK fast path can't express the root composition's data-duration, and the runtime bakes+drops data-duration at load so it can't be patched live). On a large comp that remount is a visible hitch. Add a runtime control-bridge action set-root-duration -> clock.setDuration, so the studio can grow the transport length in place. On an extend the studio now posts it (and patches the clip's own timing live) instead of reloading; it only reloads when a GSAP source rewrite actually happened (the gsap-mutation endpoints now report a mutated flag). Non-animated extends — the common case — commit as fast as a normal edit. Verified: bridge dispatch + studio no-reload/post-message paths unit- tested; core/studio/studio-server typecheck + suites green; the built runtime artifact carries the handler; E2E confirms the extend no longer remounts the preview and still persists.
This commit is contained in:
@@ -190,6 +190,23 @@ export function patchIframeDomTiming(
|
||||
}
|
||||
}
|
||||
|
||||
export function postRootDurationToPreview(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
durationSeconds: number,
|
||||
): void {
|
||||
const duration = Number(durationSeconds);
|
||||
if (!Number.isFinite(duration) || duration <= 0) return;
|
||||
iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
source: "hf-parent",
|
||||
type: "control",
|
||||
action: "set-root-duration",
|
||||
durationSeconds: duration,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function resolveResizePlaybackStart(
|
||||
original: string,
|
||||
@@ -317,6 +334,47 @@ export async function readFileContent(projectId: string, targetPath: string): Pr
|
||||
return data.content;
|
||||
}
|
||||
|
||||
export type GsapMutationStatus = { mutated: boolean };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function readMutationStatus(value: unknown): GsapMutationStatus {
|
||||
if (!isRecord(value)) return { mutated: false };
|
||||
return { mutated: value.mutated === true || value.changed === true };
|
||||
}
|
||||
|
||||
function readMutationError(value: unknown, fallback: string): string {
|
||||
if (isRecord(value) && typeof value.error === "string") return value.error;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function finishTimelineTimingFallback(input: {
|
||||
iframe: HTMLIFrameElement | null;
|
||||
needsExtension: boolean;
|
||||
rootDurationSeconds: number;
|
||||
reloadPreview: () => void;
|
||||
gsapMutation?: () => Promise<GsapMutationStatus>;
|
||||
onGsapError: (error: unknown) => void;
|
||||
}): Promise<void> {
|
||||
let gsapMutated = false;
|
||||
if (input.gsapMutation) {
|
||||
try {
|
||||
gsapMutated = (await input.gsapMutation()).mutated;
|
||||
} catch (error) {
|
||||
input.onGsapError(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (input.needsExtension) {
|
||||
postRootDurationToPreview(input.iframe, input.rootDurationSeconds);
|
||||
if (gsapMutated) input.reloadPreview();
|
||||
return;
|
||||
}
|
||||
input.reloadPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift all GSAP animation positions targeting a given element by a time delta.
|
||||
* Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
|
||||
@@ -326,8 +384,8 @@ export async function shiftGsapPositions(
|
||||
filePath: string,
|
||||
elementId: string,
|
||||
delta: number,
|
||||
): Promise<void> {
|
||||
if (delta === 0 || !elementId) return;
|
||||
): Promise<GsapMutationStatus> {
|
||||
if (delta === 0 || !elementId) return { mutated: false };
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
||||
{
|
||||
@@ -342,8 +400,9 @@ export async function shiftGsapPositions(
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error((err as { error?: string })?.error ?? "shift-positions failed");
|
||||
throw new Error(readMutationError(err, "shift-positions failed"));
|
||||
}
|
||||
return readMutationStatus(await res.json().catch(() => null));
|
||||
}
|
||||
|
||||
export async function scaleGsapPositions(
|
||||
@@ -354,9 +413,9 @@ export async function scaleGsapPositions(
|
||||
oldDuration: number,
|
||||
newStart: number,
|
||||
newDuration: number,
|
||||
): Promise<void> {
|
||||
if (!elementId || oldDuration <= 0 || newDuration <= 0) return;
|
||||
if (oldStart === newStart && oldDuration === newDuration) return;
|
||||
): Promise<GsapMutationStatus> {
|
||||
if (!elementId || oldDuration <= 0 || newDuration <= 0) return { mutated: false };
|
||||
if (oldStart === newStart && oldDuration === newDuration) return { mutated: false };
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
||||
{
|
||||
@@ -374,8 +433,9 @@ export async function scaleGsapPositions(
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error((err as { error?: string })?.error ?? "scale-positions failed");
|
||||
throw new Error(readMutationError(err, "scale-positions failed"));
|
||||
}
|
||||
return readMutationStatus(await res.json().catch(() => null));
|
||||
}
|
||||
|
||||
// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
|
||||
|
||||
@@ -230,12 +230,18 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const recordEdit = vi.fn(async () => {});
|
||||
const forceReloadSdkSession = vi.fn();
|
||||
const reloadPreview = vi.fn();
|
||||
const iframeWindow = iframe.contentWindow;
|
||||
if (!iframeWindow) throw new Error("Expected iframe window");
|
||||
const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) {
|
||||
return jsonResponse({ ok: true, mutated: false });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}),
|
||||
);
|
||||
@@ -249,6 +255,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
recordEdit,
|
||||
sdkSession,
|
||||
forceReloadSdkSession,
|
||||
reloadPreview,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -262,6 +269,16 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"');
|
||||
expect(usePlayerStore.getState().duration).toBe(5);
|
||||
expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
expect(postMessageSpy).toHaveBeenCalledWith(
|
||||
{
|
||||
source: "hf-parent",
|
||||
type: "control",
|
||||
action: "set-root-duration",
|
||||
durationSeconds: 5,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
@@ -279,12 +296,18 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
||||
const recordEdit = vi.fn(async () => {});
|
||||
const forceReloadSdkSession = vi.fn();
|
||||
const reloadPreview = vi.fn();
|
||||
const iframeWindow = iframe.contentWindow;
|
||||
if (!iframeWindow) throw new Error("Expected iframe window");
|
||||
const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
|
||||
if (url.includes("/api/projects/p1/gsap-mutations/")) {
|
||||
return jsonResponse({ ok: true, mutated: false });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}),
|
||||
);
|
||||
@@ -298,6 +321,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
recordEdit,
|
||||
sdkSession,
|
||||
forceReloadSdkSession,
|
||||
reloadPreview,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -311,6 +335,16 @@ describe("useTimelineEditing timeline z-index reorder", () => {
|
||||
expect(writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5"></div>');
|
||||
expect(usePlayerStore.getState().duration).toBe(5);
|
||||
expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
|
||||
expect(reloadPreview).not.toHaveBeenCalled();
|
||||
expect(postMessageSpy).toHaveBeenCalledWith(
|
||||
{
|
||||
source: "hf-parent",
|
||||
type: "control",
|
||||
action: "set-root-duration",
|
||||
durationSeconds: 5,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
formatTimelineAttributeNumber,
|
||||
shiftGsapPositions,
|
||||
scaleGsapPositions,
|
||||
finishTimelineTimingFallback,
|
||||
extendRootDurationIfNeeded,
|
||||
buildTimelineMoveTimingPatch,
|
||||
buildTimelineResizeTimingPatch,
|
||||
@@ -149,22 +150,25 @@ export function useTimelineEditing({
|
||||
return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration);
|
||||
};
|
||||
// Server-path fallback (no SDK session): persist the attr patch, then
|
||||
// shift GSAP tween positions on the server and reload the preview — the
|
||||
// SDK path folds both into setTiming, but the fallback must do them
|
||||
// explicitly or the clip moves while its GSAP tweens stay put + the
|
||||
// preview never refreshes. coalesceKey mirrors the SDK branch so undo
|
||||
// granularity is identical on either path.
|
||||
// shift GSAP tween positions on the server. Extending edits can keep the
|
||||
// iframe live unless a GSAP source rewrite needs a fresh run.
|
||||
const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
|
||||
const moveFallback = () =>
|
||||
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => {
|
||||
const pid = projectIdRef.current;
|
||||
const delta = updates.start - element.start;
|
||||
if (delta !== 0 && element.domId && pid) {
|
||||
return shiftGsapPositions(pid, targetPath, element.domId, delta)
|
||||
.then(() => reloadPreview())
|
||||
.catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err));
|
||||
}
|
||||
return reloadPreview();
|
||||
const domId = element.domId;
|
||||
return finishTimelineTimingFallback({
|
||||
iframe: previewIframeRef.current,
|
||||
needsExtension,
|
||||
rootDurationSeconds: updates.start + element.duration,
|
||||
reloadPreview,
|
||||
gsapMutation:
|
||||
delta !== 0 && domId && pid
|
||||
? () => shiftGsapPositions(pid, targetPath, domId, delta)
|
||||
: undefined,
|
||||
onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
|
||||
});
|
||||
});
|
||||
const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
|
||||
if (sdkSession && element.hfId && !needsExtension) {
|
||||
@@ -238,10 +242,8 @@ export function useTimelineEditing({
|
||||
updates.playbackStart != null ||
|
||||
(updates.start !== element.start && element.playbackStart != null);
|
||||
// Server-path fallback: after persisting the attr patch, scale GSAP tween
|
||||
// positions/durations on the server and reload the preview. The SDK path
|
||||
// folds both into setTiming; the fallback must do them explicitly or the
|
||||
// clip resizes while its GSAP tweens keep their old timing + the preview
|
||||
// never refreshes. coalesceKey mirrors the SDK branch for undo parity.
|
||||
// positions/durations on the server. Extending edits can keep the iframe
|
||||
// live unless a GSAP source rewrite needs a fresh run.
|
||||
const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
|
||||
const timingChanged =
|
||||
updates.start !== element.start || updates.duration !== element.duration;
|
||||
@@ -249,20 +251,27 @@ export function useTimelineEditing({
|
||||
const resizeFallback = () =>
|
||||
enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => {
|
||||
const pid = projectIdRef.current;
|
||||
if (timingChanged && element.domId && pid) {
|
||||
return scaleGsapPositions(
|
||||
pid,
|
||||
targetPath,
|
||||
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();
|
||||
const domId = element.domId;
|
||||
return finishTimelineTimingFallback({
|
||||
iframe: previewIframeRef.current,
|
||||
needsExtension,
|
||||
rootDurationSeconds: updates.start + updates.duration,
|
||||
reloadPreview,
|
||||
gsapMutation:
|
||||
timingChanged && domId && pid
|
||||
? () =>
|
||||
scaleGsapPositions(
|
||||
pid,
|
||||
targetPath,
|
||||
domId,
|
||||
element.start,
|
||||
element.duration,
|
||||
updates.start,
|
||||
updates.duration,
|
||||
)
|
||||
: undefined,
|
||||
onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
|
||||
});
|
||||
});
|
||||
if (sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension) {
|
||||
return sdkTimingPersist(
|
||||
|
||||
Reference in New Issue
Block a user