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;