diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc
index e7642999fe..c746ae1e84 100644
--- a/.fallowrc.jsonc
+++ b/.fallowrc.jsonc
@@ -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",
diff --git a/packages/studio/src/hooks/timelineEditingHelpers.test.ts b/packages/studio/src/hooks/timelineEditingHelpers.test.ts
index 8d629f2542..a912e3e81b 100644
--- a/packages/studio/src/hooks/timelineEditingHelpers.test.ts
+++ b/packages/studio/src/hooks/timelineEditingHelpers.test.ts
@@ -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 = `
`;
+ 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 = ``;
diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts
index 93078796bd..381bc332fd 100644
--- a/packages/studio/src/hooks/timelineEditingHelpers.ts
+++ b/packages/studio/src/hooks/timelineEditingHelpers.ts
@@ -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(
diff --git a/packages/studio/src/hooks/useTimelineDeleteOps.test.tsx b/packages/studio/src/hooks/useTimelineDeleteOps.test.tsx
index 1c1bc63c37..ed2b71c960 100644
--- a/packages/studio/src/hooks/useTimelineDeleteOps.test.tsx
+++ b/packages/studio/src/hooks/useTimelineDeleteOps.test.tsx
@@ -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
diff --git a/packages/studio/src/hooks/useTimelineDeleteOps.ts b/packages/studio/src/hooks/useTimelineDeleteOps.ts
index a7d0155fe7..8c45aab0ab 100644
--- a/packages/studio/src/hooks/useTimelineDeleteOps.ts
+++ b/packages/studio/src/hooks/useTimelineDeleteOps.ts
@@ -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 };
},
diff --git a/packages/studio/src/player/lib/timelineElementHelpers.ts b/packages/studio/src/player/lib/timelineElementHelpers.ts
index 1a7363e34f..4979642e07 100644
--- a/packages/studio/src/player/lib/timelineElementHelpers.ts
+++ b/packages/studio/src/player/lib/timelineElementHelpers.ts
@@ -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
// ---------------------------------------------------------------------------
diff --git a/packages/studio/src/utils/rootDuration.ts b/packages/studio/src/utils/rootDuration.ts
index 32724c6a72..1b12c00dc1 100644
--- a/packages/studio/src/utils/rootDuration.ts
+++ b/packages/studio/src/utils/rootDuration.ts
@@ -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);
}
diff --git a/packages/studio/src/utils/timelineAssetDrop.test.ts b/packages/studio/src/utils/timelineAssetDrop.test.ts
index 2e354840e0..b7750fd994 100644
--- a/packages/studio/src/utils/timelineAssetDrop.test.ts
+++ b/packages/studio/src/utils/timelineAssetDrop.test.ts
@@ -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));
diff --git a/packages/studio/src/utils/timelineAssetDrop.ts b/packages/studio/src/utils/timelineAssetDrop.ts
index 840ca379b3..c138fa251a 100644
--- a/packages/studio/src/utils/timelineAssetDrop.ts
+++ b/packages/studio/src/utils/timelineAssetDrop.ts
@@ -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;