Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/studio/src/player/hooks/useShadowPreviewReload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions packages/studio/src/player/hooks/useTimelinePlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -92,6 +93,11 @@ export function useTimelinePlayer({
elements,
state.duration,
resolvedDuration,
(element) =>
findTimelineElementInIframe(iframeRef.current, {
...element,
kind: "composition",
}) !== null,
),
state.timelineProjectId,
),
Expand Down
11 changes: 11 additions & 0 deletions packages/studio/src/player/lib/timelineDOM.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")];
Expand Down
4 changes: 3 additions & 1 deletion packages/studio/src/player/lib/timelineDOM.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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];
Expand Down
15 changes: 15 additions & 0 deletions packages/studio/src/player/lib/timelineIframeHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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">
Expand Down
Loading