mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
perf(studio): swap an edited scene into the live preview instead of reloading (#4499)
* perf(studio): swap an edited scene into the live preview instead of reloading On a preview reload, Studio fetches the rebuilt preview document and asks the preview to swap in only the scenes that changed. When the preview cannot or will not swap (it predates the swap, a full reload is already running, or the document changed outside its scenes), Studio does the full shadow reload as before. A document from an older reload is never swapped in. * fix(studio): re-sync selection and layers after a scene swap A swap replaces scene DOM without a load event, so selection, manual edits, the Layers panel and the edit callout kept pointing at the replaced nodes. They now listen for either a load or a swap. * fix(studio): drop a stale scene swap once the preview is replaced A swap still pending when the preview reloaded or switched composition could fall back to reloading the old one. Color grading and its compare view are also re-sent after a swap. * docs(studio): say exactly when the preview generation changes * refactor(studio): cut scene swap comments to what names and types do not say * fix(studio): fall back to a full reload when a scene swap download stalls * fix(studio): give up on a scene swap that stalls at any step, not only the download * fix(runtime): a scene swap refuses when an animation outside the scene moves its elements * test(runtime): pin the scene swap volume re-probe, host CSS variables and diagnostics-free swap * fix(runtime): a scene swap keeps a video its scene script animates instead of reloading it * fix(runtime): a scene swap stops every animation the old scene script started * fix(runtime): record scene script animations only on pages a scene swap can act on * docs(core): reword the scene animation recording comment * fix(runtime): watch media as the page parses only on pages a scene swap can act on * perf(runtime): a scene swap checks for outside tweens in one pass over the film's tweens * test(runtime): pin the single revert and the cleared animation record of a scene swap * test(studio): pin that a playing film keeps playing through a scene swap * fix(runtime): a scene swap rebuilds media when the edit changes the scene script * fix(runtime): a scene swap undoes the sets a scene script made * fix(runtime): a scene swap rechecks outside tweens and runs new scripts after every host * test(runtime): pin that a tween started during the caption wait leaves the page untouched * fix(runtime): a scene swap refuses when its script set a value on an element the swap keeps * docs(core): say the scene recording drops a finished tween that the timeline would keep when paused * fix(runtime): a scene swap refuses when two of its animations write to an element it keeps * fix(runtime): a scene swap resets kept media and refuses animations outside the scene * fix(runtime): a scene swap keeps a moved video in place and swaps tweens on plain objects * chore(core): regenerate the inlined position-edits script for the kept-video move reapply * fix(runtime): a scene swap checks the timeline registry as it is after the caption wait * fix(runtime): a scene swap refuses when a scene changed during the wait or a revert re-registers * fix(runtime): a scene swap stops every animation a revert chain registers and rechecks its hosts * fix(runtime): a scene swap refuses a moved host or a cancelled swap and stops at a shortened end * fix(studio): a scene swap that times out is cancelled before the reload starts * fix(runtime): a scene swap refuses when a scene leaves its place or the film during the wait * fix(studio): a newer edit cancels an older scene swap still waiting * fix(runtime): a scene swap checks only its host's place, so a later body append does not refuse it * fix(studio): a scene swap cancelled before it starts does not run * fix(runtime): a scene swap refuses when the film's scene layout changed during the wait * fix(runtime): a scene swap refuses when the order around a scene changed during the wait * test(runtime): pin that a scene swap checks the film root order when every scene is wrapped * fix(runtime): a scene swap gives kept media the mute, rate and loop a fresh load gives it * fix(runtime): a scene swap rebuilds media a script wrote attributes on, keeping the rest * fix(runtime): a scene swap keeps media carrying the runtime classes or reordered attributes * fix(runtime): a scene swap resets a kept video's volume as a load does, not through the volume probe
This commit is contained in:
@@ -9,6 +9,7 @@ import type { DomEditLayerItem } from "./domEditingTypes";
|
||||
import { createRafThrottle, LayersPanel, sortLayersByZIndex } from "./LayersPanel";
|
||||
import { isLayerDraggable } from "./useLayerDrag";
|
||||
import { liveTime } from "../../player";
|
||||
import { sceneSwapFor } from "../../player/sceneSwap";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -292,4 +293,17 @@ describe("LayersPanel preview promotion", () => {
|
||||
});
|
||||
expect(collectedRootIds()).toEqual(["b"]);
|
||||
});
|
||||
it("recollects layers when edited scenes are swapped into the live document", async () => {
|
||||
const a = makeIframe("a");
|
||||
Object.defineProperty(a, "contentWindow", { value: { __hfSwapScenes: async () => {} } });
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html></html>"));
|
||||
mocks.previewIframeRef.current = a;
|
||||
usePreviewIframeStore.setState({ iframe: a });
|
||||
act(() => root.render(createElement(LayersPanel)));
|
||||
mocks.collect.mockClear();
|
||||
|
||||
await act(() => sceneSwapFor(a)!("/preview", () => true));
|
||||
expect(collectedRootIds()).toEqual(["a"]);
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
|
||||
import { onPreviewContentReplaced } from "../../player/sceneSwap";
|
||||
import { memo, useState, useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
collectDomEditLayerItems,
|
||||
@@ -164,8 +165,7 @@ export const LayersPanel = memo(function LayersPanel() {
|
||||
prevDocVersionRef.current += 1;
|
||||
collectLayers();
|
||||
};
|
||||
iframe.addEventListener("load", handleLoad);
|
||||
return () => iframe.removeEventListener("load", handleLoad);
|
||||
return onPreviewContentReplaced(iframe, handleLoad);
|
||||
}, [previewIframeRef, livePreviewIframe, collectLayers]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
|
||||
import { onPreviewContentReplaced } from "../../player/sceneSwap";
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@@ -119,8 +120,7 @@ export function TopologyLens({ iframeRef, activeCompositionPath }: TopologyLensP
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe || !callId) return;
|
||||
const dismiss = () => studioEditLifecycle.dismiss(callId);
|
||||
iframe.addEventListener("load", dismiss);
|
||||
return () => iframe.removeEventListener("load", dismiss);
|
||||
return onPreviewContentReplaced(iframe, dismiss);
|
||||
}, [callId, iframeRef, livePreviewIframe]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
|
||||
import { runtimeProtocolMetadata } from "@hyperframes/core/runtime/protocol";
|
||||
import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
|
||||
import { useColorGradingController } from "./useColorGradingController";
|
||||
import { sceneSwapFor } from "../../player/sceneSwap";
|
||||
import type { DomEditSelection } from "./domEditing";
|
||||
|
||||
function brightPopGrading() {
|
||||
@@ -390,6 +391,29 @@ describe("useColorGradingController", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("re-sends the grade to the preview after edited scenes are swapped in", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { contentWindow, iframe } = createPreviewFrame();
|
||||
(contentWindow as unknown as { __hfSwapScenes: () => Promise<void> }).__hfSwapScenes =
|
||||
async () => {};
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html></html>"));
|
||||
const postMessage = vi.spyOn(contentWindow, "postMessage");
|
||||
const { root, getState } = renderHook(vi.fn(), makeElement(), { current: iframe });
|
||||
act(() => getState().commitColorGrading(brightPopGrading()));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
postMessage.mockClear();
|
||||
|
||||
await act(() => sceneSwapFor(iframe)!("/preview", () => true));
|
||||
const replayed = colorGradingMessages(postMessage.mock.calls);
|
||||
expect(replayed.at(-1)?.grading).toMatchObject({ preset: "bright-pop" });
|
||||
|
||||
act(() => root.unmount());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("animates only the selected video card without starting the project player", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { contentWindow, iframe } = createPreviewFrame();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mediaMetadataUrl } from "../../utils/studioHelpers";
|
||||
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
|
||||
import { onPreviewContentReplaced } from "../../player/sceneSwap";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
HF_COLOR_GRADING_ATTR,
|
||||
@@ -445,11 +446,11 @@ export function useColorGradingController({
|
||||
if (!acceptStudioRuntimeMessage(data)) return;
|
||||
refreshAndReplay();
|
||||
};
|
||||
iframe.addEventListener("load", refreshAndReplay);
|
||||
window.addEventListener("message", onMessage);
|
||||
const timer = window.setTimeout(refreshAndReplay, 80);
|
||||
const stopReplay = onPreviewContentReplaced(iframe, refreshAndReplay);
|
||||
return () => {
|
||||
iframe.removeEventListener("load", refreshAndReplay);
|
||||
stopReplay();
|
||||
window.removeEventListener("message", onMessage);
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createElement, act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
|
||||
import { sceneSwapFor } from "../player/sceneSwap";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("useDomEditPreviewSync", () => {
|
||||
it("re-syncs the preview when edited scenes are swapped in, and not when the swap is refused", async () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
let refuse = false;
|
||||
Object.defineProperty(iframe, "contentWindow", {
|
||||
value: {
|
||||
__hfSwapScenes: async () => {
|
||||
if (refuse) throw new Error("the film changed outside its scenes");
|
||||
},
|
||||
},
|
||||
});
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(""));
|
||||
const applyManualEdits = vi.fn(async () => {});
|
||||
const refreshVersion = vi.fn();
|
||||
function Harness() {
|
||||
useDomEditPreviewSync({
|
||||
previewIframe: iframe,
|
||||
activeCompPath: null,
|
||||
captionEditMode: false,
|
||||
domEditSelectionRef: { current: null },
|
||||
domEditGroupSelectionsRef: { current: [] },
|
||||
domEditSelection: null,
|
||||
refreshDomEditGroupSelectionsFromPreview: async () => {},
|
||||
applyDomSelection: () => {},
|
||||
buildDomSelectionFromTarget: async () => null,
|
||||
refreshPreviewDocumentVersion: refreshVersion,
|
||||
syncPreviewHotkeys: () => {},
|
||||
applyStudioManualEditsToPreviewRef: { current: applyManualEdits },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const root = createRoot(document.createElement("div"));
|
||||
act(() => root.render(createElement(Harness)));
|
||||
applyManualEdits.mockClear();
|
||||
refreshVersion.mockClear();
|
||||
|
||||
refuse = true;
|
||||
await act(() => sceneSwapFor(iframe)!("/preview", () => true).catch(() => {}));
|
||||
expect(refreshVersion).not.toHaveBeenCalled();
|
||||
|
||||
refuse = false;
|
||||
await act(() => sceneSwapFor(iframe)!("/preview", () => true));
|
||||
expect(applyManualEdits).toHaveBeenCalledWith(iframe);
|
||||
expect(refreshVersion).toHaveBeenCalledTimes(1);
|
||||
act(() => root.unmount());
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits"
|
||||
import { useDockLayoutStore } from "../components/dock/dockLayoutStore";
|
||||
import type { PatchTarget } from "../utils/sourcePatcher";
|
||||
import { logSelect } from "../utils/selectDebug";
|
||||
import { onPreviewContentReplaced } from "../player/sceneSwap";
|
||||
|
||||
interface UseDomEditPreviewSyncParams {
|
||||
previewIframe: HTMLIFrameElement | null;
|
||||
@@ -113,10 +114,7 @@ export function useDomEditPreviewSync({
|
||||
refreshPreviewDocumentVersion();
|
||||
};
|
||||
|
||||
previewIframe.addEventListener("load", handleLoad);
|
||||
return () => {
|
||||
previewIframe.removeEventListener("load", handleLoad);
|
||||
};
|
||||
return onPreviewContentReplaced(previewIframe, handleLoad);
|
||||
}, [
|
||||
activeCompPath,
|
||||
applyDomSelection,
|
||||
|
||||
@@ -140,6 +140,7 @@ export function useShadowPreviewReload({
|
||||
|
||||
const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
|
||||
const isCurrentShadow = useCallback((gen?: number) => gen === shadowGenRef.current, []);
|
||||
const previewGeneration = useCallback(() => shadowGenRef.current, []);
|
||||
const markAdapterReady = useCallback(
|
||||
(iframe: HTMLIFrameElement | null, gen: number | undefined, commit: () => void) => {
|
||||
if (gen == null || gen !== shadowGenRef.current) return;
|
||||
@@ -261,5 +262,6 @@ export function useShadowPreviewReload({
|
||||
setShadowIframeNode,
|
||||
beginShadowReload,
|
||||
resetPreviewSlots,
|
||||
previewGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -649,3 +649,194 @@ function stubVisibility(initial: DocumentVisibilityState) {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
};
|
||||
}
|
||||
|
||||
describe("useTimelinePlayer scene swap", () => {
|
||||
function liveFilm(swap?: (html: string) => Promise<void>) {
|
||||
const { adapter, win } = makeAdapterWindow();
|
||||
const iframe = makeFakeIframe(swap ? { ...win, __hfSwapScenes: swap } : win);
|
||||
iframe.src = "http://localhost/api/projects/demo/preview";
|
||||
const harness = renderTimelinePlayerHarness();
|
||||
act(() => {
|
||||
harness.getApi().iframeRef.current = iframe;
|
||||
harness.getApi().onIframeLoad();
|
||||
});
|
||||
return { ...harness, adapter };
|
||||
}
|
||||
function playingFilm(swap: (html: string) => Promise<void>) {
|
||||
const film = liveFilm(swap);
|
||||
usePlayerStore.setState({ timelineReady: true });
|
||||
act(() => film.getApi().play());
|
||||
film.adapter.pause.mockClear();
|
||||
return film;
|
||||
}
|
||||
const roles = (api: ReturnType<ReturnType<typeof liveFilm>["getApi"]>) =>
|
||||
api.previewSlots.map((slot) => slot.role);
|
||||
const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0))));
|
||||
|
||||
it("swaps the rebuilt preview's scenes into the live preview with no shadow reload", async () => {
|
||||
const swap = vi.fn(async () => {});
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
||||
const { getApi } = liveFilm(swap);
|
||||
act(() => getApi().refreshPlayer());
|
||||
await settle();
|
||||
expect(swap).toHaveBeenCalledWith("<html>v2</html>", expect.anything());
|
||||
expect(roles(getApi())).toEqual(["live"]);
|
||||
});
|
||||
|
||||
it("falls back to the full reload when the preview refuses the swap", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
||||
const { getApi } = liveFilm(async () => {
|
||||
throw new Error("the film changed outside its scenes");
|
||||
});
|
||||
act(() => getApi().refreshPlayer());
|
||||
await settle();
|
||||
expect(roles(getApi())).toEqual(["live", "shadow"]);
|
||||
});
|
||||
|
||||
it("reloads the whole film at once when the preview cannot swap", () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
const { getApi } = liveFilm();
|
||||
act(() => getApi().refreshPlayer());
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(roles(getApi())).toEqual(["live", "shadow"]);
|
||||
});
|
||||
|
||||
it("never swaps in an older document that arrives after a newer reload started", async () => {
|
||||
const swap = vi.fn(async () => {});
|
||||
const replies: Array<(r: Response) => void> = [];
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
() => new Promise<Response>((resolve) => replies.push(resolve)),
|
||||
);
|
||||
const { getApi } = liveFilm(swap);
|
||||
act(() => getApi().refreshPlayer());
|
||||
act(() => getApi().refreshPlayer());
|
||||
replies[1]!(new Response("<html>v3</html>"));
|
||||
await settle();
|
||||
replies[0]!(new Response("<html>v2</html>"));
|
||||
await settle();
|
||||
expect(swap.mock.calls).toEqual([["<html>v3</html>", expect.anything()]]);
|
||||
expect(roles(getApi())).toEqual(["live"]);
|
||||
});
|
||||
|
||||
it("cancels an older swap still waiting when a newer edit starts, so it cannot land after that edit's reload", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("<html>v</html>"));
|
||||
let captionsArrive = () => {};
|
||||
const captions = new Promise<void>((resolve) => (captionsArrive = resolve));
|
||||
const landed: string[] = [];
|
||||
let calls = 0;
|
||||
// As the runtime does: the first waits for caption overrides, then refuses if cancelled; the second is refused.
|
||||
const swap = async (_html: string, signal?: AbortSignal) => {
|
||||
if (++calls > 1) throw new Error("the film changed outside its scenes");
|
||||
await captions;
|
||||
if (signal?.aborted) throw new Error("the swap was cancelled");
|
||||
landed.push("edit 1");
|
||||
};
|
||||
const { getApi } = liveFilm(swap);
|
||||
act(() => getApi().refreshPlayer());
|
||||
await settle();
|
||||
act(() => getApi().refreshPlayer());
|
||||
await settle();
|
||||
expect(roles(getApi())).toContain("shadow");
|
||||
captionsArrive();
|
||||
await settle();
|
||||
expect(landed).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a pending swap's fallback once the preview was replaced, e.g. by a composition switch", async () => {
|
||||
let refuse!: (e: Error) => void;
|
||||
const swap = vi.fn(() => new Promise<void>((_, reject) => (refuse = reject)));
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
||||
const { getApi } = liveFilm(swap);
|
||||
act(() => getApi().refreshPlayer());
|
||||
await settle();
|
||||
expect(swap).toHaveBeenCalledTimes(1);
|
||||
act(() => getApi().resetPreviewSlots());
|
||||
const next = makeFakeIframe(makeAdapterWindow().win);
|
||||
next.src = "http://localhost/api/projects/demo/preview/comp/compositions/intro.html";
|
||||
act(() => {
|
||||
getApi().iframeRef.current = next;
|
||||
getApi().onIframeLoad();
|
||||
});
|
||||
refuse(new Error("the preview was torn down during the swap"));
|
||||
await settle();
|
||||
expect(roles(getApi())).toEqual(["live"]);
|
||||
});
|
||||
|
||||
it("skips the swap for an edit made while a full reload is in flight", async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("<html>v2</html>"));
|
||||
const { getApi } = liveFilm(async () => {
|
||||
throw new Error("the film changed outside its scenes");
|
||||
});
|
||||
act(() => getApi().refreshPlayer());
|
||||
await settle();
|
||||
expect(roles(getApi())).toEqual(["live", "shadow"]);
|
||||
const firstShadow = getApi().previewSlots[1]!.gen;
|
||||
fetchSpy.mockClear();
|
||||
act(() => getApi().refreshPlayer());
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(getApi().previewSlots[1]!.gen).toBeGreaterThan(firstShadow);
|
||||
});
|
||||
|
||||
it("keeps a playing film playing through a swap, ready to swap the next edit", async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockImplementation(async () => new Response("<html>v2</html>"));
|
||||
const swap = vi.fn(async () => {});
|
||||
const { getApi, adapter } = playingFilm(swap);
|
||||
act(() => getApi().refreshPlayer());
|
||||
await settle();
|
||||
expect(adapter.pause).not.toHaveBeenCalled();
|
||||
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
||||
expect(roles(getApi())).toEqual(["live"]);
|
||||
act(() => getApi().refreshPlayer());
|
||||
await settle();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(swap).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows a playing film stopped at its new end when a swap cuts it short of the playhead", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
||||
let length = 6;
|
||||
// As the runtime's swap does when the edit ends the film at or before the playhead.
|
||||
const cutShort = async () => {
|
||||
length = 5;
|
||||
adapter.pause();
|
||||
adapter.seek(5);
|
||||
};
|
||||
const { getApi, adapter } = playingFilm(cutShort);
|
||||
adapter.getDuration = () => length;
|
||||
adapter.seek(5.5);
|
||||
act(() => getApi().refreshPlayer());
|
||||
await settle();
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 50))));
|
||||
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
||||
expect(usePlayerStore.getState().currentTime).toBe(5);
|
||||
expect(adapter.play).toHaveBeenCalledTimes(1);
|
||||
expect(roles(getApi())).toEqual(["live"]);
|
||||
});
|
||||
|
||||
it("keeps a playing film playing through a refused swap, from where the live frame had reached", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
||||
const { getApi, adapter } = playingFilm(async () => {
|
||||
throw new Error("the film changed outside its scenes");
|
||||
});
|
||||
adapter.seek(3);
|
||||
act(() => getApi().refreshPlayer());
|
||||
// The live frame plays on while the swap is tried; the fallback reload starts from here.
|
||||
adapter.seek(4.5);
|
||||
await settle();
|
||||
expect(adapter.pause).not.toHaveBeenCalled();
|
||||
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
||||
const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
|
||||
const shadow = makeShadowWithSpies();
|
||||
act(() => {
|
||||
getApi().setShadowIframeNode(shadow.iframe);
|
||||
getApi().onShadowIframeLoad(gen);
|
||||
getApi().onShadowReadyChange(gen, true);
|
||||
});
|
||||
expect(shadow.adapter.getTime()).toBe(4.5);
|
||||
expect(shadow.adapter.isPlaying()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
|
||||
import { createPreviewMessageHandler } from "./previewMessageRouter";
|
||||
import { timelineElementsChanged } from "./timelinePlayerSync";
|
||||
import { safeContentDocument } from "./timelineSyncHydration";
|
||||
import { sceneSwapFor } from "../sceneSwap";
|
||||
|
||||
export interface UseTimelinePlayerOptions {
|
||||
/** Runs right after a reloaded preview becomes the live iframe. */
|
||||
@@ -391,6 +392,7 @@ export function useTimelinePlayer({
|
||||
setShadowIframeNode,
|
||||
beginShadowReload,
|
||||
resetPreviewSlots,
|
||||
previewGeneration,
|
||||
} = useShadowPreviewReload({
|
||||
iframeRef,
|
||||
getAdapter,
|
||||
@@ -443,19 +445,39 @@ export function useTimelinePlayer({
|
||||
setIsPlaying(false);
|
||||
return false;
|
||||
}, [getAdapter, stopRAFLoop, setIsPlaying, stopReverseLoop]);
|
||||
const reloadWholeFilm = useCallback(
|
||||
(url: string) => {
|
||||
// The old iframe is no longer navigated away, so stop its playback (and audio) here.
|
||||
if (!saveSeekPosition()) getAdapter()?.pause();
|
||||
// The live iframe is never hidden; the reload loads in a shadow and is promoted once painted.
|
||||
beginShadowReload(url);
|
||||
},
|
||||
[saveSeekPosition, getAdapter, beginShadowReload],
|
||||
);
|
||||
const refreshGenRef = useRef(0);
|
||||
const swapCancelRef = useRef<AbortController | null>(null);
|
||||
const refreshPlayer = useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
swapCancelRef.current?.abort(new Error("superseded by a newer edit"));
|
||||
const cancel = new AbortController();
|
||||
swapCancelRef.current = cancel;
|
||||
logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack }));
|
||||
// The old iframe is no longer navigated away, so stop its playback (and audio) here.
|
||||
if (!saveSeekPosition()) getAdapter()?.pause();
|
||||
// The live iframe is never hidden; the reload loads in a shadow and is promoted once painted.
|
||||
const src = iframe.src;
|
||||
const url = new URL(src, window.location.origin);
|
||||
const url = new URL(iframe.src, window.location.origin);
|
||||
url.searchParams.set("_t", String(Date.now()));
|
||||
applyPreviewVariablesToUrl(url);
|
||||
beginShadowReload(url.toString());
|
||||
}, [saveSeekPosition, getAdapter, beginShadowReload]);
|
||||
const gen = ++refreshGenRef.current;
|
||||
const slot = previewGeneration();
|
||||
// A newer edit, or anything replacing the live preview (a reload, a composition switch), wins.
|
||||
const isCurrent = () => gen === refreshGenRef.current && slot === previewGeneration();
|
||||
const swap = sceneSwapFor(iframe);
|
||||
if (!swap || isRefreshingRef.current) return reloadWholeFilm(url.toString());
|
||||
swap(url.toString(), isCurrent, cancel.signal).catch((error: unknown) => {
|
||||
if (!isCurrent()) return;
|
||||
logReload("scene-swap-refused", { reason: String(error) });
|
||||
reloadWholeFilm(url.toString());
|
||||
});
|
||||
}, [reloadWholeFilm, previewGeneration]);
|
||||
const pauseRef = useRef(pause);
|
||||
pauseRef.current = pause;
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SCENE_SWAP_MS, sceneSwapFor } from "./sceneSwap";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
type Swap = (html: string, signal?: AbortSignal) => Promise<void>;
|
||||
|
||||
function swappableIframe(swap: Swap = vi.fn(async () => {})) {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
Object.assign(iframe.contentWindow as object, { __hfSwapScenes: swap });
|
||||
return { iframe, swap };
|
||||
}
|
||||
|
||||
describe("sceneSwapFor", () => {
|
||||
it("gives up on a preview download that never finishes, so the caller can reload", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
(_url, init) =>
|
||||
new Promise((_resolve, reject) =>
|
||||
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason)),
|
||||
),
|
||||
);
|
||||
const { iframe, swap } = swappableIframe();
|
||||
const swapping = sceneSwapFor(iframe)!("/preview", () => true);
|
||||
const outcome = expect(swapping).rejects.toThrow("took too long");
|
||||
await vi.advanceTimersByTimeAsync(SCENE_SWAP_MS);
|
||||
await outcome;
|
||||
expect(swap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a body that never finishes", async () => new Response(new ReadableStream())],
|
||||
["a runtime swap that never finishes", async () => new Response("<html></html>")],
|
||||
])("gives up on %s, so the caller can reload", async (_stall, respond) => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(respond);
|
||||
const { iframe } = swappableIframe(vi.fn(() => new Promise<void>(() => {})));
|
||||
const swapped = vi.fn();
|
||||
iframe.addEventListener("hf-scenes-swapped", swapped);
|
||||
const outcome = expect(sceneSwapFor(iframe)!("/preview", () => true)).rejects.toThrow(
|
||||
"took too long",
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(SCENE_SWAP_MS);
|
||||
await outcome;
|
||||
expect(swapped).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels the runtime's swap as it gives up, so captions arriving later replace nothing", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("<html></html>"));
|
||||
let captionsArrive = () => {};
|
||||
const captions = new Promise<void>((resolve) => (captionsArrive = resolve));
|
||||
let replaced = false;
|
||||
// As the runtime does: it waits for the caption overrides, then refuses a cancelled swap.
|
||||
const { iframe } = swappableIframe(async (_html, signal) => {
|
||||
await captions;
|
||||
if (signal?.aborted) throw new Error("the swap was cancelled");
|
||||
replaced = true;
|
||||
});
|
||||
const outcome = expect(sceneSwapFor(iframe)!("/preview", () => true)).rejects.toThrow(
|
||||
"took too long",
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(SCENE_SWAP_MS);
|
||||
await outcome;
|
||||
captionsArrive();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(replaced).toBe(false);
|
||||
});
|
||||
|
||||
it("does not start a swap whose cancel signal was aborted before the call", async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockImplementation(async () => new Response("<html></html>"));
|
||||
const { iframe, swap } = swappableIframe();
|
||||
const cancel = new AbortController();
|
||||
cancel.abort(new Error("superseded by a newer edit"));
|
||||
await expect(sceneSwapFor(iframe)!("/preview", () => true, cancel.signal)).rejects.toThrow(
|
||||
"superseded",
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(swap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swaps a document that arrives in time", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("<html></html>"));
|
||||
const { iframe, swap } = swappableIframe();
|
||||
await sceneSwapFor(iframe)!("/preview", () => true);
|
||||
expect(swap).toHaveBeenCalledWith("<html></html>", expect.anything());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
type SwapWindow = Window & {
|
||||
__hfSwapScenes?: (html: string, signal?: AbortSignal) => Promise<void>;
|
||||
};
|
||||
|
||||
const SCENES_SWAPPED = "hf-scenes-swapped";
|
||||
export const SCENE_SWAP_MS = 5000;
|
||||
|
||||
export function onPreviewContentReplaced(
|
||||
iframe: HTMLIFrameElement,
|
||||
onReplaced: () => void,
|
||||
): () => void {
|
||||
iframe.addEventListener("load", onReplaced);
|
||||
iframe.addEventListener(SCENES_SWAPPED, onReplaced);
|
||||
return () => {
|
||||
iframe.removeEventListener("load", onReplaced);
|
||||
iframe.removeEventListener(SCENES_SWAPPED, onReplaced);
|
||||
};
|
||||
}
|
||||
|
||||
/** Null when the preview cannot swap; rejects when superseded, cancelled or a full reload is needed. */
|
||||
export function sceneSwapFor(
|
||||
iframe: HTMLIFrameElement,
|
||||
): ((url: string, isCurrent: () => boolean, cancel?: AbortSignal) => Promise<void>) | null {
|
||||
const win = iframe.contentWindow as SwapWindow | null;
|
||||
const swap = win?.__hfSwapScenes;
|
||||
if (typeof swap !== "function") return null;
|
||||
return async (url, isCurrent, cancel) => {
|
||||
if (cancel?.aborted) throw cancel.reason;
|
||||
const deadline = new AbortController();
|
||||
const timer = setTimeout(
|
||||
() => deadline.abort(new Error("the preview took too long to swap")),
|
||||
SCENE_SWAP_MS,
|
||||
);
|
||||
cancel?.addEventListener("abort", () => deadline.abort(cancel.reason), { once: true });
|
||||
const swapping = (async () => {
|
||||
const response = await fetch(url, { signal: deadline.signal });
|
||||
if (!response.ok) throw new Error(`preview request failed with ${response.status}`);
|
||||
const html = await response.text();
|
||||
if (!isCurrent()) throw new Error("superseded by a newer reload");
|
||||
// Aborted with the deadline, so a swap still waiting cannot land after the reload has begun.
|
||||
await swap.call(win, html, deadline.signal);
|
||||
})();
|
||||
const expired = new Promise<never>((_resolve, reject) =>
|
||||
deadline.signal.addEventListener("abort", () => reject(deadline.signal.reason)),
|
||||
);
|
||||
try {
|
||||
await Promise.race([swapping, expired]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
iframe.dispatchEvent(new Event(SCENES_SWAPPED));
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user