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 = ``;
+ 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(`