mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(studio): undoing an agent's build takes its sections off the timeline too (#4757)
* fix(studio): undoing an agent's build takes its sections off the timeline too * fix(studio): a reloaded preview's timeline is read from the new document, sections included * fix(studio): a section still in the preview stays on the timeline, and trims are unchanged
This commit is contained in:
@@ -118,9 +118,9 @@ export function useShadowPreviewReload({
|
||||
const liveTime = live?.getTime();
|
||||
const playing = usePlayerStore.getState().isPlaying;
|
||||
live?.pause();
|
||||
// The store takes the new document's timeline only now that it is the one on screen.
|
||||
pending.commit();
|
||||
// The store takes the new document's timeline only now that it is the one on screen, and reads it there.
|
||||
iframeRef.current = shadow;
|
||||
pending.commit();
|
||||
shadowIframeRef.current = null;
|
||||
attachIframeShortcutListeners();
|
||||
applyPreviewAudioState();
|
||||
|
||||
@@ -543,6 +543,75 @@ describe("shadow reload store ownership and readiness", () => {
|
||||
unmount(root);
|
||||
});
|
||||
|
||||
it("takes a section the edit removed off the timeline, though the old document still has it", () => {
|
||||
const { getApi, gen, liveIframe, shadowIframe, root } = setup();
|
||||
const host = (id: string) =>
|
||||
`<div id="${id}" data-composition-id="${id}" data-composition-src="compositions/${id}.html" data-start="0" data-duration="3"></div>`;
|
||||
liveIframe.contentDocument!.body.innerHTML = `<div data-composition-id="main">${host("benefit")}<p id="a"></p></div>`;
|
||||
shadowIframe.contentDocument!.body.innerHTML = `<div data-composition-id="main"><p id="a"></p></div>`;
|
||||
const a = { id: "a", domId: "a", tag: "p", start: 0, duration: 3, track: 0 };
|
||||
const benefit = {
|
||||
id: "benefit",
|
||||
domId: "benefit",
|
||||
tag: "div",
|
||||
kind: "composition" as const,
|
||||
start: 0,
|
||||
duration: 3,
|
||||
track: 1,
|
||||
compositionSrc: "compositions/benefit.html",
|
||||
};
|
||||
act(() => usePlayerStore.getState().setElements([a, benefit]));
|
||||
(shadowIframe.contentWindow as Record<string, unknown>).__clipManifest = {
|
||||
source: "hf-preview",
|
||||
type: "timeline",
|
||||
durationInFrames: 1260,
|
||||
clips: [
|
||||
{ id: "a", label: "A", start: 0, duration: 3, track: 0, kind: "element", tagName: "p" },
|
||||
],
|
||||
};
|
||||
act(() => getApi().onShadowIframeLoad(gen));
|
||||
act(() => getApi().onShadowReadyChange(gen, true));
|
||||
expect(usePlayerStore.getState().elements.map((element) => element.id)).toEqual(["a"]);
|
||||
unmount(root);
|
||||
});
|
||||
|
||||
it("keeps a section the new document still hosts but its manifest misses, without dropping it once", () => {
|
||||
const { getApi, gen, liveIframe, shadowIframe, root } = setup();
|
||||
const film = `<div data-composition-id="main"><div id="benefit" data-composition-id="benefit"
|
||||
data-composition-src="compositions/benefit.html" data-start="0" data-duration="3"></div><p id="a"></p></div>`;
|
||||
liveIframe.contentDocument!.body.innerHTML = film;
|
||||
shadowIframe.contentDocument!.body.innerHTML = film;
|
||||
const a = { id: "a", domId: "a", tag: "p", start: 0, duration: 3, track: 0 };
|
||||
// As enrichment adds it: no kind.
|
||||
const benefit = {
|
||||
id: "benefit",
|
||||
domId: "benefit",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 3,
|
||||
track: 1,
|
||||
compositionSrc: "compositions/benefit.html",
|
||||
};
|
||||
act(() => usePlayerStore.getState().setElements([a, benefit]));
|
||||
const seen: string[][] = [];
|
||||
const stop = usePlayerStore.subscribe((state) =>
|
||||
seen.push(state.elements.map((element) => element.id)),
|
||||
);
|
||||
(shadowIframe.contentWindow as Record<string, unknown>).__clipManifest = {
|
||||
source: "hf-preview",
|
||||
type: "timeline",
|
||||
durationInFrames: 1260,
|
||||
clips: [
|
||||
{ id: "a", label: "A", start: 0, duration: 3, track: 0, kind: "element", tagName: "p" },
|
||||
],
|
||||
};
|
||||
act(() => getApi().onShadowIframeLoad(gen));
|
||||
act(() => getApi().onShadowReadyChange(gen, true));
|
||||
stop();
|
||||
expect(seen.every((ids) => ids.includes("benefit"))).toBe(true);
|
||||
unmount(root);
|
||||
});
|
||||
|
||||
it("never writes a failed shadow's timeline into the store", () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const { getApi, gen, root } = setup();
|
||||
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
|
||||
import { releaseStaticSeekCache, type StaticSeekCacheEntry } from "../lib/playbackAdapter";
|
||||
import { mergeTimelineElementsPreservingDowngrades } from "../lib/timelineDOM";
|
||||
import { findTimelineElementInIframe } from "../../hooks/timelineEditingHelpers";
|
||||
import { normalizeToZones } from "../components/timelineZones";
|
||||
import { applyPreviewAudioFlags, setPreviewPlaybackRate } from "../lib/timelineIframeHelpers";
|
||||
import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
|
||||
@@ -92,6 +93,11 @@ export function useTimelinePlayer({
|
||||
elements,
|
||||
state.duration,
|
||||
resolvedDuration,
|
||||
(element) =>
|
||||
findTimelineElementInIframe(iframeRef.current, {
|
||||
...element,
|
||||
kind: "composition",
|
||||
}) !== null,
|
||||
),
|
||||
state.timelineProjectId,
|
||||
),
|
||||
|
||||
@@ -534,6 +534,17 @@ describe("mergeTimelineElementsPreservingDowngrades — genuine removal vs trans
|
||||
expect(merged.map((e) => e.id).sort()).toEqual(["a", "sub-child"]);
|
||||
});
|
||||
|
||||
it("drops a section whose host left the preview (undo of an agent's build)", () => {
|
||||
const current = [
|
||||
el("a"),
|
||||
el("benefit-fresh", { compositionSrc: "compositions/benefit-fresh.html" }),
|
||||
];
|
||||
const next = [el("a")]; // the reverted film's manifest: the built section is gone
|
||||
const inPreview = (element: { id: string }) => element.id !== "benefit-fresh";
|
||||
const merged = mergeTimelineElementsPreservingDowngrades(current, next, 30, 30, inPreview);
|
||||
expect(merged.map((e) => e.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("trusts the fresh scan fully when it is not shorter", () => {
|
||||
const current = [el("a"), el("b", { compositionSrc: "sub.html" })];
|
||||
const next = [el("a"), el("c")];
|
||||
|
||||
@@ -382,6 +382,7 @@ export function mergeTimelineElementsPreservingDowngrades(
|
||||
nextElements: TimelineElement[],
|
||||
currentDuration: number,
|
||||
nextDuration: number,
|
||||
stillInPreview: (element: TimelineElement) => boolean = () => true,
|
||||
): TimelineElement[] {
|
||||
const safeCurrentDuration = Number.isFinite(currentDuration) ? currentDuration : 0;
|
||||
const safeNextDuration = Number.isFinite(nextDuration) ? nextDuration : 0;
|
||||
@@ -403,7 +404,8 @@ export function mergeTimelineElementsPreservingDowngrades(
|
||||
// re-adds. A TOP-LEVEL element missing from the fresh scan was genuinely
|
||||
// removed (undo of a split, a delete), so let it go — otherwise undoing a
|
||||
// split leaves a ghost clip in the timeline even though the file is reverted.
|
||||
element.compositionSrc != null,
|
||||
element.compositionSrc != null &&
|
||||
stillInPreview(element),
|
||||
);
|
||||
if (preserved.length === 0) return nextElements;
|
||||
return [...nextElements, ...preserved];
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
stopScrubPreviewAudio,
|
||||
} from "./timelineIframeHelpers";
|
||||
import type { IframeWindow } from "./playbackTypes";
|
||||
import { findTimelineElementInIframe } from "../../hooks/timelineEditingHelpers";
|
||||
|
||||
function makeDoc(html: string): Document {
|
||||
const d = document.implementation.createHTMLDocument();
|
||||
@@ -42,6 +43,20 @@ describe("buildMissingCompositionElements — hfId (R7)", () => {
|
||||
expect([patched, updatedEls[0]?.compositionSrc]).toEqual([true, "compositions/scene.html"]);
|
||||
});
|
||||
|
||||
it("adds a host the lookup finds in its preview, as a composition", () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
<div id="benefit-fresh" data-composition-id="benefit-fresh" data-composition-src="compositions/benefit-fresh.html"
|
||||
data-start="0" data-duration="3"></div>
|
||||
</div>
|
||||
`);
|
||||
const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 10);
|
||||
const iframe = { contentDocument: doc } as unknown as HTMLIFrameElement;
|
||||
expect(findTimelineElementInIframe(iframe, { ...missing[0]!, kind: "composition" })).toBe(
|
||||
doc.getElementById("benefit-fresh"),
|
||||
);
|
||||
});
|
||||
|
||||
it("harvests hfId from data-hf-id on composition host elements", () => {
|
||||
const doc = makeDoc(`
|
||||
<div data-composition-id="root">
|
||||
|
||||
Reference in New Issue
Block a user