diff --git a/packages/studio/src/hooks/patchDocumentRootDuration.test.ts b/packages/studio/src/hooks/patchDocumentRootDuration.test.ts
new file mode 100644
index 0000000000..ab152914c8
--- /dev/null
+++ b/packages/studio/src/hooks/patchDocumentRootDuration.test.ts
@@ -0,0 +1,71 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from "vitest";
+import { patchDocumentRootDuration } from "./timelineEditingHelpers";
+
+// The regression these guard against: timing edits now soft-reload instead of
+// full-reloading the iframe, so the runtime recomputes the composition length
+// from the ROOT's `data-duration` and posts it back — overwriting the studio's
+// optimistic duration readout. Patching the live root's `data-duration` in the
+// same tick keeps the runtime's post-soft-reload report in agreement, so the
+// readout stays live on grow AND shrink instead of snapping back.
+function parse(html: string): Document {
+ return new DOMParser().parseFromString(html, "text/html");
+}
+
+describe("patchDocumentRootDuration", () => {
+ it("writes the content end onto the TOP-LEVEL composition root (grow)", () => {
+ const doc = parse(`
+
+ `);
+ expect(patchDocumentRootDuration(doc, 20)).toBe(true);
+ const root = doc.querySelector('[data-composition-id="root"]');
+ expect(root?.getAttribute("data-duration")).toBe("20");
+ // Nested sub-composition is untouched — only the outermost root is the length.
+ expect(doc.querySelector('[data-composition-id="scene"]')?.getAttribute("data-duration")).toBe(
+ "8",
+ );
+ });
+
+ it("shrinks the root duration too (not grow-only)", () => {
+ const doc = parse(``);
+ expect(patchDocumentRootDuration(doc, 6.5)).toBe(true);
+ expect(doc.querySelector("[data-composition-id]")?.getAttribute("data-duration")).toBe("6.5");
+ });
+
+ it("picks the composition with no ancestor composition as the root", () => {
+ // Root appears AFTER a nested one in document order — selection must be by
+ // ancestry, not first-match, matching the runtime's own root resolver.
+ const doc = parse(`
+
+ `);
+ patchDocumentRootDuration(doc, 12);
+ expect(doc.querySelector('[data-composition-id="root"]')?.getAttribute("data-duration")).toBe(
+ "12",
+ );
+ expect(doc.querySelector('[data-composition-id="inner"]')?.getAttribute("data-duration")).toBe(
+ "4",
+ );
+ });
+
+ it("no-ops on a non-positive or non-finite content end (never collapses to 0)", () => {
+ const doc = parse(``);
+ expect(patchDocumentRootDuration(doc, 0)).toBe(false);
+ expect(patchDocumentRootDuration(doc, -3)).toBe(false);
+ expect(patchDocumentRootDuration(doc, Number.NaN)).toBe(false);
+ expect(doc.querySelector("[data-composition-id]")?.getAttribute("data-duration")).toBe("15.18");
+ });
+
+ it("no-ops when there is no composition root and reports false", () => {
+ const doc = parse(``);
+ expect(patchDocumentRootDuration(doc, 10)).toBe(false);
+ });
+
+ it("returns false for a null document", () => {
+ expect(patchDocumentRootDuration(null, 10)).toBe(false);
+ });
+});
diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts
index f16da17785..644bc0fe29 100644
--- a/packages/studio/src/hooks/timelineEditingHelpers.ts
+++ b/packages/studio/src/hooks/timelineEditingHelpers.ts
@@ -73,6 +73,46 @@ export function patchIframeDomTiming(
}
}
+/**
+ * Pure: find the TOP-LEVEL composition root in `doc` (the `[data-composition-id]`
+ * with no ancestor composition, matching the runtime's own root resolution) and
+ * write `contentEnd` into its `data-duration`. Returns whether a write happened.
+ *
+ * A timing edit optimistically patches the moved clip's `data-start`/`-duration`
+ * in the live iframe, but NOT the root's `data-duration`. Timing edits now take
+ * the soft-reload path (no full iframe reload), which re-runs the GSAP script and
+ * lets the runtime recompute the composition length — it reads the root's
+ * `data-duration` as the authored floor (core/runtime/init.ts) and posts it back,
+ * so the studio store's duration is set from the STALE root and the readout
+ * reverts to the pre-edit length. Patching the root here keeps the runtime's
+ * post-soft-reload duration report in agreement with the optimistic readout, so
+ * the number stays live (grow AND shrink) instead of snapping back.
+ */
+export function patchDocumentRootDuration(
+ doc: Document | null | undefined,
+ contentEnd: number,
+): boolean {
+ if (!doc || !Number.isFinite(contentEnd) || contentEnd <= 0) return false;
+ const nodes = Array.from(doc.querySelectorAll("[data-composition-id]"));
+ const root =
+ nodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ?? nodes[0] ?? null;
+ if (!root) return false;
+ root.setAttribute("data-duration", formatTimelineAttributeNumber(contentEnd));
+ return true;
+}
+
+/** Best-effort live-iframe wrapper for patchDocumentRootDuration (see above). */
+export function patchIframeRootDuration(
+ iframe: HTMLIFrameElement | null,
+ contentEnd: number,
+): void {
+ try {
+ patchDocumentRootDuration(iframe?.contentDocument ?? null, contentEnd);
+ } catch {
+ // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
+ }
+}
+
// fallow-ignore-next-line complexity
export function resolveResizePlaybackStart(
original: string,
diff --git a/packages/studio/src/hooks/timelineElementsMove.ts b/packages/studio/src/hooks/timelineElementsMove.ts
index 7bc26f77d4..f538e79d26 100644
--- a/packages/studio/src/hooks/timelineElementsMove.ts
+++ b/packages/studio/src/hooks/timelineElementsMove.ts
@@ -11,6 +11,7 @@ import {
buildPatchTarget,
formatTimelineAttributeNumber,
patchIframeDomTiming,
+ patchIframeRootDuration,
readFileContent,
shiftGsapPositionsBatch,
syncTimingEditPreview,
@@ -136,9 +137,13 @@ export async function persistTimelineElementsMove(
// Optimistically reflect the new composition length in the player store so
// the duration readout + seek bar update immediately (they bind to
// store.duration; without this the number stays frozen until a manual
- // refresh). Only for the composition currently displayed.
+ // refresh). Only for the composition currently displayed. Also patch the live
+ // root's `data-duration`: a batched move takes the soft-reload path (no full
+ // iframe reload), so the runtime recomputes the length from the root's
+ // declared duration and posts it back — a stale root would revert this set.
if (contentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
usePlayerStore.getState().setDuration(contentEnd);
+ patchIframeRootDuration(previewIframe, contentEnd);
}
groupResults.push({ targetPath, original, patched, groupEdits });
diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts
index 7d1540d2e3..18e4e810db 100644
--- a/packages/studio/src/hooks/useTimelineEditing.ts
+++ b/packages/studio/src/hooks/useTimelineEditing.ts
@@ -33,6 +33,7 @@ import { generateId } from "../utils/generateId";
import {
buildPatchTarget,
patchIframeDomTiming,
+ patchIframeRootDuration,
resolveResizePlaybackStart,
persistTimelineEdit,
readFileContent,
@@ -130,7 +131,14 @@ export function useTimelineEditing({
// data-duration) so it's immune to the runtime's truncated live durations.
const syncReadoutDurationFromPreview = useCallback(() => {
const end = furthestClipEndFromDocument(previewIframeRef.current?.contentDocument ?? null);
- if (end > 0) usePlayerStore.getState().setDuration(end);
+ if (end > 0) {
+ usePlayerStore.getState().setDuration(end);
+ // Also write the content end into the live root's `data-duration`. Timing
+ // edits now take the soft-reload path (no full iframe reload), which lets
+ // the runtime recompute the length from the root's declared duration and
+ // post it back — reading the STALE root would revert this optimistic set.
+ patchIframeRootDuration(previewIframeRef.current, end);
+ }
}, [previewIframeRef]);
// fallow-ignore-next-line complexity