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:
Miguel Ángel
2026-09-30 11:27:13 +00:00
committed by GitHub
parent 9a27b9f934
commit ae8d1780ff
6 changed files with 106 additions and 3 deletions
@@ -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">