diff --git a/packages/studio/src/player/hooks/useShadowPreviewReload.ts b/packages/studio/src/player/hooks/useShadowPreviewReload.ts index 3dde8546b4..02281c2a4f 100644 --- a/packages/studio/src/player/hooks/useShadowPreviewReload.ts +++ b/packages/studio/src/player/hooks/useShadowPreviewReload.ts @@ -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(); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts index ef1885383e..df958b9406 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts @@ -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) => + `
`; + liveIframe.contentDocument!.body.innerHTML = `
${host("benefit")}

`; + shadowIframe.contentDocument!.body.innerHTML = `

`; + 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).__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 = `

`; + 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).__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(); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 2612b594e6..5b5384efcc 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -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, ), diff --git a/packages/studio/src/player/lib/timelineDOM.test.ts b/packages/studio/src/player/lib/timelineDOM.test.ts index 9e2ad14893..10738b9407 100644 --- a/packages/studio/src/player/lib/timelineDOM.test.ts +++ b/packages/studio/src/player/lib/timelineDOM.test.ts @@ -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")]; diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts index b016ea7839..ce08dcac4e 100644 --- a/packages/studio/src/player/lib/timelineDOM.ts +++ b/packages/studio/src/player/lib/timelineDOM.ts @@ -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]; diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.test.ts b/packages/studio/src/player/lib/timelineIframeHelpers.test.ts index f58ca00dbc..ed38a6efd2 100644 --- a/packages/studio/src/player/lib/timelineIframeHelpers.test.ts +++ b/packages/studio/src/player/lib/timelineIframeHelpers.test.ts @@ -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(` +
+
+
+ `); + 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(`