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:
Miguel Ángel
2026-10-06 23:16:21 +00:00
committed by GitHub
parent f7bba2288d
commit e8cfd6b73e
9 changed files with 84 additions and 34 deletions
+1 -1
View File
@@ -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
// ---------------------------------------------------------------------------
+5 -4
View File
@@ -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;