mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-12 20:47:30 +00:00
fix(studio): duration readout updates live again after soft-reload edits
Post-rebase regression: the optimistic setDuration on move/resize was being immediately reverted — the soft-reload path's __hfForceTimelineRebind makes the runtime re-read the iframe's root data-duration and re-post it, overwriting the store with the stale pre-edit value. Fix: the optimistic sync now also patches the iframe document's root data-duration (grow AND shrink) before the rebind, so the runtime re-posts the CORRECT value and the readout stays live. Covered by patchDocumentRootDuration tests.
This commit is contained in:
@@ -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(`
|
||||
<div data-composition-id="root" data-duration="15.18">
|
||||
<div data-composition-id="scene" data-start="0" data-duration="8"></div>
|
||||
<div data-start="12" data-duration="8"></div>
|
||||
</div>
|
||||
`);
|
||||
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(`<div data-composition-id="root" data-duration="15.18"></div>`);
|
||||
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(`
|
||||
<section data-composition-id="root" data-duration="10">
|
||||
<div data-composition-id="inner" data-duration="4"></div>
|
||||
</section>
|
||||
`);
|
||||
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(`<div data-composition-id="root" data-duration="15.18"></div>`);
|
||||
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(`<div data-start="0" data-duration="5"></div>`);
|
||||
expect(patchDocumentRootDuration(doc, 10)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a null document", () => {
|
||||
expect(patchDocumentRootDuration(null, 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user