mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
perf(studio): parse saved HTML once when syncing timeline length (#5092)
* perf(studio): parse the saved file once to sync the composition length after a timeline edit * perf(studio): keep empty duration updates free of parsing * test(studio): pin one parse for rootless moves and the delete length readout * test(studio): check the delete's length readout before its save lands
This commit is contained in:
+1
-1
@@ -142,7 +142,7 @@
|
||||
},
|
||||
{
|
||||
"file": "packages/studio/src/player/lib/timelineElementHelpers.ts",
|
||||
"exports": ["furthestClipEndFromDocument", "furthestClipEndFromSource"],
|
||||
"exports": ["furthestClipEndFromDocument"],
|
||||
},
|
||||
{
|
||||
"file": "packages/studio/src/components/sidebar/AssetContextMenu.tsx",
|
||||
|
||||
@@ -233,6 +233,28 @@ describe("extendRootDurationIfNeeded", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTimelineMoveTimingPatch", () => {
|
||||
it.each([
|
||||
{ root: ' data-duration="20"', length: 'data-composition-id="c" data-duration="8"' },
|
||||
{ root: "", length: 'data-composition-id="c">' },
|
||||
])(
|
||||
"parses the saved file once to move a clip and sync the length (root$root)",
|
||||
({ root, length }) => {
|
||||
const source = `<div data-composition-id="c"${root}><div id="a" class="clip" data-start="1" data-duration="3"></div></div>`;
|
||||
const parse = vi.spyOn(DOMParser.prototype, "parseFromString");
|
||||
try {
|
||||
const patched = buildTimelineMoveTimingPatch(source, { id: "a" }, 5, 3);
|
||||
|
||||
expect(patched).toContain('data-start="5"');
|
||||
expect(patched).toContain(length);
|
||||
expect(parse).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
parse.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("buildTimelineResizeTimingPatch", () => {
|
||||
it("moves a source-only in-point by the caller's own start change", () => {
|
||||
const source = `<div id="root"><video id="a" class="clip" data-start="5" data-duration="3" data-media-start="0.337"></video></div>`;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "../player/components/timelineEditing";
|
||||
import { getElementZIndex } from "../player/lib/layerOrdering";
|
||||
import {
|
||||
furthestClipEndFromSource,
|
||||
furthestClipEndFromDocument,
|
||||
getTimelineElementIdentity,
|
||||
playbackStartAttributeForElement,
|
||||
readPlaybackStartAttributes,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
||||
import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
|
||||
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
||||
import { rootCompositionDurationOf } from "../utils/rootDuration";
|
||||
import { readFileContent } from "./timelineTimingSync";
|
||||
import {
|
||||
findElementForSelection,
|
||||
@@ -263,7 +264,22 @@ export function applyTimelineMoveAttributes(
|
||||
// Root data-duration follows the furthest clip end in the source; the store's durations are
|
||||
// truncated to the current length, so reading them would ratchet the duration down every edit.
|
||||
export function syncCompositionDurationToContent(source: string): string {
|
||||
return setCompositionDurationToContent(source, furthestClipEndFromSource(source));
|
||||
return syncCompositionDurationWithEnd(source).source;
|
||||
}
|
||||
|
||||
/** The synced source and the clip end it was synced to, from one parse of the source. */
|
||||
export function syncCompositionDurationWithEnd(source: string): {
|
||||
source: string;
|
||||
contentEnd: number;
|
||||
} {
|
||||
const doc = new DOMParser().parseFromString(source, "text/html");
|
||||
const contentEnd = furthestClipEndFromDocument(doc);
|
||||
const synced = setCompositionDurationToContent(
|
||||
source,
|
||||
contentEnd,
|
||||
rootCompositionDurationOf(doc),
|
||||
);
|
||||
return { source: synced, contentEnd };
|
||||
}
|
||||
|
||||
export function buildTimelineMoveTimingPatch(
|
||||
|
||||
@@ -275,6 +275,16 @@ describe("useTimelineDeleteOps: undo race", () => {
|
||||
await flushStudioPendingEdits();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the shortened length while the last clip's delete is still saving", async () => {
|
||||
const { gate, recordEdit, deleteSelection } = mountRaceHarness();
|
||||
usePlayerStore.getState().setDuration(6);
|
||||
const deletePromise = deleteSelection();
|
||||
await vi.waitFor(() => expect(recordEdit).toHaveBeenCalled());
|
||||
expect(usePlayerStore.getState().duration).toBe(4);
|
||||
gate.resolve();
|
||||
await deletePromise;
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: the live preview kept a deleted clip until its reload landed, and composition
|
||||
|
||||
@@ -8,11 +8,13 @@ import { usePlayerStore } from "../player";
|
||||
import { saveServerRewriteWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
||||
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
||||
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
||||
import { buildPatchTarget, removeIframeTimelineElements } from "./timelineEditingHelpers";
|
||||
import {
|
||||
buildPatchTarget,
|
||||
removeIframeTimelineElements,
|
||||
syncCompositionDurationWithEnd,
|
||||
} from "./timelineEditingHelpers";
|
||||
import { captureDurationRollback, timingGestureStep } from "./timelineTimingSync";
|
||||
import { setLinkInSource } from "../components/editor/mediaLinkEdits";
|
||||
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
||||
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
|
||||
import {
|
||||
resolveMainTrackDeleteRippleShifts,
|
||||
resolveShiftedElements,
|
||||
@@ -143,16 +145,13 @@ export function useTimelineDeleteOps({
|
||||
typeof removeData.content === "string" ? removeData.content : originalContent;
|
||||
// Shrink to the furthest remaining clip end, read from the post-removal source:
|
||||
// store durations are runtime-truncated.
|
||||
const deleteContentEnd = furthestClipEndFromSource(removedContent);
|
||||
const patchedContent = unlinkInSource(
|
||||
setCompositionDurationToContent(removedContent, deleteContentEnd),
|
||||
alsoUnlink,
|
||||
);
|
||||
const synced = syncCompositionDurationWithEnd(removedContent);
|
||||
const patchedContent = unlinkInSource(synced.source, alsoUnlink);
|
||||
// Optimistically reflect the shrunk length in the readout/seek bar,
|
||||
// rolling it back if the persist below fails (see captureDurationRollback).
|
||||
rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
||||
if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
|
||||
usePlayerStore.getState().setDuration(deleteContentEnd);
|
||||
if (synced.contentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
|
||||
usePlayerStore.getState().setDuration(synced.contentEnd);
|
||||
}
|
||||
return { disk: removedContent, after: patchedContent };
|
||||
},
|
||||
|
||||
@@ -130,17 +130,6 @@ export function readTimelineDurationFromDocument(doc: Document | null | undefine
|
||||
return furthestClipEndFromDocument(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Furthest clip end parsed straight from a composition SOURCE STRING (the HTML
|
||||
* being saved). Uses raw `data-duration`, so it is the correct input for syncing
|
||||
* the root duration after an edit — reading the store instead would use the
|
||||
* runtime-truncated durations and shrink the composition (the feedback loop).
|
||||
*/
|
||||
export function furthestClipEndFromSource(source: string): number {
|
||||
if (!source) return 0;
|
||||
return furthestClipEndFromDocument(new DOMParser().parseFromString(source, "text/html"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM element type guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -33,10 +33,11 @@ const DATA_DURATION_ATTR_RE = /(\bdata-duration\s*=\s*)(["'])[^"']*\2/i;
|
||||
* Deterministic and render-safe: DOMParser is the only DOM global used.
|
||||
*/
|
||||
export function readRootCompositionDuration(source: string): number | null {
|
||||
const root = new DOMParser()
|
||||
.parseFromString(source, "text/html")
|
||||
.querySelector("[data-composition-id]");
|
||||
const raw = root?.getAttribute("data-duration");
|
||||
return rootCompositionDurationOf(new DOMParser().parseFromString(source, "text/html"));
|
||||
}
|
||||
|
||||
export function rootCompositionDurationOf(doc: Document): number | null {
|
||||
const raw = doc.querySelector("[data-composition-id]")?.getAttribute("data-duration");
|
||||
if (raw == null) return null;
|
||||
return Number.parseFloat(raw);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildTimelineFileDropPlacements,
|
||||
buildTimelineAssetInsertHtml,
|
||||
@@ -24,9 +24,18 @@ describe("setCompositionDurationToContent", () => {
|
||||
expect(setCompositionDurationToContent(src(5), 12)).toContain('data-duration="12"');
|
||||
});
|
||||
|
||||
it("is a no-op when content end is 0 (empty timeline keeps its declared length)", () => {
|
||||
expect(setCompositionDurationToContent(src(12), 0)).toBe(src(12));
|
||||
});
|
||||
it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
|
||||
"keeps the declared length without parsing when content end is %s",
|
||||
(contentEnd) => {
|
||||
const parse = vi.spyOn(DOMParser.prototype, "parseFromString");
|
||||
try {
|
||||
expect(setCompositionDurationToContent(src(12), contentEnd)).toBe(src(12));
|
||||
expect(parse).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
parse.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("is a no-op when already equal", () => {
|
||||
expect(setCompositionDurationToContent(src(9), 9)).toBe(src(9));
|
||||
|
||||
@@ -155,9 +155,13 @@ export function extendCompositionDurationIfNeeded(source: string, requiredEnd: n
|
||||
* reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
|
||||
* an empty timeline keeps its declared duration instead of collapsing to 0.
|
||||
*/
|
||||
export function setCompositionDurationToContent(source: string, contentEnd: number): string {
|
||||
export function setCompositionDurationToContent(
|
||||
source: string,
|
||||
contentEnd: number,
|
||||
rootDuration?: number | null,
|
||||
): string {
|
||||
if (!Number.isFinite(contentEnd) || contentEnd <= 0) return source;
|
||||
const rootDur = readRootCompositionDuration(source);
|
||||
const rootDur = rootDuration === undefined ? readRootCompositionDuration(source) : rootDuration;
|
||||
if (rootDur == null) return source;
|
||||
const next = roundToCenti(contentEnd);
|
||||
if (rootDur === next) return source;
|
||||
|
||||
Reference in New Issue
Block a user