mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix(studio): fit preview reset to composition dimensions (#1085)
* fix(studio): fit preview reset to composition dimensions * fix(core): keep runtime root resolution explicit * fix(studio): resume playback after keep-playing seek
This commit is contained in:
@@ -25,6 +25,20 @@ function TimelinePlayerHarness({
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderTimelinePlayerHarness() {
|
||||
let api: ReturnType<typeof useTimelinePlayer> | null = null;
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
act(() => {
|
||||
root.render(React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }));
|
||||
});
|
||||
|
||||
if (!api) throw new Error("useTimelinePlayer did not mount");
|
||||
return { api, root };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
resetPlayerStore();
|
||||
@@ -35,19 +49,25 @@ function attachIframeAdapter(
|
||||
options: {
|
||||
postMessage?: (message: unknown, targetOrigin: string) => void;
|
||||
timelines?: Record<string, unknown>;
|
||||
duration?: number;
|
||||
} = {},
|
||||
) {
|
||||
const iframe = document.createElement("iframe");
|
||||
let currentTime = 0;
|
||||
let playing = false;
|
||||
const adapter = {
|
||||
play: () => {},
|
||||
pause: () => {},
|
||||
play: vi.fn(() => {
|
||||
playing = true;
|
||||
}),
|
||||
pause: vi.fn(() => {
|
||||
playing = false;
|
||||
}),
|
||||
seek: (time: number) => {
|
||||
currentTime = time;
|
||||
},
|
||||
getTime: () => currentTime,
|
||||
getDuration: () => 30,
|
||||
isPlaying: () => false,
|
||||
getDuration: () => options.duration ?? 30,
|
||||
isPlaying: () => playing,
|
||||
};
|
||||
Object.defineProperty(iframe, "contentWindow", {
|
||||
value: {
|
||||
@@ -71,90 +91,77 @@ function attachIframeAdapter(
|
||||
return adapter;
|
||||
}
|
||||
|
||||
function renderAttachedTimelinePlayer() {
|
||||
const { api, root } = renderTimelinePlayerHarness();
|
||||
const adapter = attachIframeAdapter(api);
|
||||
return { api, root, adapter };
|
||||
}
|
||||
|
||||
function setStorePlaying() {
|
||||
act(() => {
|
||||
usePlayerStore.setState({ isPlaying: true });
|
||||
});
|
||||
}
|
||||
|
||||
function seekWithAct(
|
||||
api: ReturnType<typeof useTimelinePlayer>,
|
||||
time: number,
|
||||
options?: { keepPlaying?: boolean },
|
||||
) {
|
||||
act(() => {
|
||||
api.seek(time, options);
|
||||
});
|
||||
}
|
||||
|
||||
function unmountWithAct(root: ReturnType<typeof createRoot>) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
|
||||
function expectStorePlaybackState(
|
||||
root: ReturnType<typeof createRoot>,
|
||||
expected: { isPlaying: boolean; currentTime: number },
|
||||
) {
|
||||
expect(usePlayerStore.getState().isPlaying).toBe(expected.isPlaying);
|
||||
expect(usePlayerStore.getState().currentTime).toBe(expected.currentTime);
|
||||
unmountWithAct(root);
|
||||
}
|
||||
|
||||
describe("useTimelinePlayer seek hydration", () => {
|
||||
it("keeps an external seek request until the iframe adapter is ready", () => {
|
||||
let api: ReturnType<typeof useTimelinePlayer> | null = null;
|
||||
const observedTimes: number[] = [];
|
||||
const unsubscribe = liveTime.subscribe((time) => {
|
||||
observedTimes.push(time);
|
||||
});
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
|
||||
);
|
||||
});
|
||||
const { api, root } = renderTimelinePlayerHarness();
|
||||
|
||||
act(() => {
|
||||
usePlayerStore.getState().requestSeek(4.2);
|
||||
});
|
||||
|
||||
expect(api).not.toBeNull();
|
||||
expect(usePlayerStore.getState().currentTime).toBe(0);
|
||||
expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
let currentTime = 0;
|
||||
const adapter = {
|
||||
play: () => {},
|
||||
pause: () => {},
|
||||
seek: (time: number) => {
|
||||
currentTime = time;
|
||||
},
|
||||
getTime: () => currentTime,
|
||||
getDuration: () => 30,
|
||||
isPlaying: () => false,
|
||||
};
|
||||
Object.defineProperty(iframe, "contentWindow", {
|
||||
value: {
|
||||
__player: adapter,
|
||||
postMessage: () => {},
|
||||
scrollTo: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(iframe, "contentDocument", {
|
||||
value: document.implementation.createHTMLDocument("preview"),
|
||||
configurable: true,
|
||||
});
|
||||
const adapter = attachIframeAdapter(api);
|
||||
|
||||
act(() => {
|
||||
api!.iframeRef.current = iframe;
|
||||
api!.onIframeLoad();
|
||||
});
|
||||
|
||||
expect(currentTime).toBe(4.2);
|
||||
expect(adapter.getTime()).toBe(4.2);
|
||||
expect(usePlayerStore.getState().currentTime).toBe(4.2);
|
||||
expect(usePlayerStore.getState().timelineReady).toBe(true);
|
||||
expect(observedTimes).toContain(4.2);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
unmountWithAct(root);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTimelinePlayer audio controls (#835)", () => {
|
||||
it("applies playback-rate changes immediately and auto-mutes audio above 1x", () => {
|
||||
let api: ReturnType<typeof useTimelinePlayer> | null = null;
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
const { api, root } = renderTimelinePlayerHarness();
|
||||
const postMessage = vi.fn();
|
||||
const timeScale = vi.fn();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
|
||||
);
|
||||
});
|
||||
attachIframeAdapter(api!, {
|
||||
attachIframeAdapter(api, {
|
||||
postMessage,
|
||||
timelines: {
|
||||
root: { timeScale },
|
||||
@@ -202,24 +209,14 @@ describe("useTimelinePlayer audio controls (#835)", () => {
|
||||
"*",
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
unmountWithAct(root);
|
||||
});
|
||||
|
||||
it("keeps explicit Studio mute active at 1x", () => {
|
||||
let api: ReturnType<typeof useTimelinePlayer> | null = null;
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
const { api, root } = renderTimelinePlayerHarness();
|
||||
const postMessage = vi.fn();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
|
||||
);
|
||||
});
|
||||
attachIframeAdapter(api!, { postMessage });
|
||||
attachIframeAdapter(api, { postMessage });
|
||||
postMessage.mockClear();
|
||||
|
||||
act(() => {
|
||||
@@ -235,95 +232,50 @@ describe("useTimelinePlayer audio controls (#835)", () => {
|
||||
"*",
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
unmountWithAct(root);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTimelinePlayer seek keepPlaying option (#834)", () => {
|
||||
it("default seek() clears isPlaying when the store reports playing", () => {
|
||||
let api: ReturnType<typeof useTimelinePlayer> | null = null;
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
const { api, root } = renderAttachedTimelinePlayer();
|
||||
setStorePlaying();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
|
||||
);
|
||||
});
|
||||
attachIframeAdapter(api!);
|
||||
seekWithAct(api, 5);
|
||||
|
||||
act(() => {
|
||||
usePlayerStore.setState({ isPlaying: true });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
api!.seek(5);
|
||||
});
|
||||
|
||||
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
||||
expect(usePlayerStore.getState().currentTime).toBe(5);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
expectStorePlaybackState(root, { isPlaying: false, currentTime: 5 });
|
||||
});
|
||||
|
||||
it("seek(time, { keepPlaying: true }) preserves isPlaying=true so A/E shortcuts don't pause the timeline", () => {
|
||||
let api: ReturnType<typeof useTimelinePlayer> | null = null;
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
const { api, root, adapter } = renderAttachedTimelinePlayer();
|
||||
setStorePlaying();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
|
||||
);
|
||||
});
|
||||
attachIframeAdapter(api!);
|
||||
seekWithAct(api, 5, { keepPlaying: true });
|
||||
|
||||
act(() => {
|
||||
usePlayerStore.setState({ isPlaying: true });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
api!.seek(5, { keepPlaying: true });
|
||||
});
|
||||
|
||||
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
||||
expect(usePlayerStore.getState().currentTime).toBe(5);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
expect(adapter.play).toHaveBeenCalledTimes(1);
|
||||
expectStorePlaybackState(root, { isPlaying: true, currentTime: 5 });
|
||||
});
|
||||
|
||||
it("seek(time, { keepPlaying: true }) from paused state stays paused (no spurious resume)", () => {
|
||||
let api: ReturnType<typeof useTimelinePlayer> | null = null;
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
|
||||
);
|
||||
});
|
||||
attachIframeAdapter(api!);
|
||||
const { api, root } = renderAttachedTimelinePlayer();
|
||||
|
||||
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
||||
|
||||
act(() => {
|
||||
api!.seek(5, { keepPlaying: true });
|
||||
});
|
||||
seekWithAct(api, 5, { keepPlaying: true });
|
||||
|
||||
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
||||
expect(usePlayerStore.getState().currentTime).toBe(5);
|
||||
expectStorePlaybackState(root, { isPlaying: false, currentTime: 5 });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
it("seek(time, { keepPlaying: true }) restarts playback when the iframe adapter was paused", () => {
|
||||
const { api, root, adapter } = renderAttachedTimelinePlayer();
|
||||
setStorePlaying();
|
||||
|
||||
expect(adapter.isPlaying()).toBe(false);
|
||||
|
||||
seekWithAct(api, 0, { keepPlaying: true });
|
||||
|
||||
expect(adapter.play).toHaveBeenCalledTimes(1);
|
||||
expect(adapter.isPlaying()).toBe(true);
|
||||
expectStorePlaybackState(root, { isPlaying: true, currentTime: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,19 +4,16 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
|
||||
import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
|
||||
|
||||
// Re-export public API consumed by tests and external modules.
|
||||
// All of these were previously defined in this file; they now live in focused
|
||||
// sub-modules but are re-exported here so existing import sites don't change.
|
||||
export type { ClipManifestClip } from "../lib/playbackTypes";
|
||||
export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
|
||||
export {
|
||||
getTimelineElementSelector,
|
||||
readTimelineDurationFromDocument,
|
||||
parseTimelineFromDOM,
|
||||
buildStandaloneRootTimelineElement,
|
||||
createTimelineElementFromManifestClip,
|
||||
findTimelineDomNodeForClip,
|
||||
buildStandaloneRootTimelineElement,
|
||||
getTimelineElementSelector,
|
||||
mergeTimelineElementsPreservingDowngrades,
|
||||
parseTimelineFromDOM,
|
||||
readTimelineDurationFromDocument,
|
||||
resolveStandaloneRootCompositionSrc,
|
||||
resolveIframe,
|
||||
} from "../lib/timelineDOM";
|
||||
@@ -43,10 +40,7 @@ import {
|
||||
shouldMutePreviewAudio,
|
||||
} from "../lib/timelineIframeHelpers";
|
||||
import { probeMediaUrl, getCachedProbe } from "../lib/mediaProbe";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
|
||||
|
||||
export function useTimelinePlayer() {
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
@@ -65,8 +59,6 @@ export function useTimelinePlayer() {
|
||||
adapter: PlaybackAdapter;
|
||||
} | null>(null);
|
||||
|
||||
// ZERO store subscriptions — this hook never causes re-renders.
|
||||
// All reads use getState() (point-in-time), all writes use the stable setters.
|
||||
const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } =
|
||||
usePlayerStore.getState();
|
||||
|
||||
@@ -383,8 +375,6 @@ export function useTimelinePlayer() {
|
||||
}, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
|
||||
const seek = useCallback(
|
||||
(time: number, options?: { keepPlaying?: boolean }) => {
|
||||
// Reverse shuttle is always stopped: the RAF reverse tick can't survive
|
||||
// a seek anyway, so `keepPlaying` only preserves forward playback.
|
||||
const wasReverseShuttle = shuttleDirectionRef.current === "backward";
|
||||
stopReverseLoop();
|
||||
const adapter = getAdapter();
|
||||
@@ -394,10 +384,27 @@ export function useTimelinePlayer() {
|
||||
}
|
||||
const duration = Math.max(0, adapter.getDuration());
|
||||
const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
|
||||
const keepPlaying = options?.keepPlaying === true;
|
||||
const shouldResumeAfterSeek = shouldResumeForwardPlaybackAfterSeek({
|
||||
keepPlaying,
|
||||
wasReverseShuttle,
|
||||
storeWasPlaying: usePlayerStore.getState().isPlaying,
|
||||
duration,
|
||||
nextTime,
|
||||
});
|
||||
adapter.seek(nextTime, options);
|
||||
liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
|
||||
setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
|
||||
if (!options?.keepPlaying || wasReverseShuttle) {
|
||||
if (shouldResumeAfterSeek) {
|
||||
stopRAFLoop();
|
||||
applyPlaybackRate(usePlayerStore.getState().playbackRate);
|
||||
applyPreviewAudioState();
|
||||
adapter.play();
|
||||
setIsPlaying(true);
|
||||
shuttleDirectionRef.current = "forward";
|
||||
shuttleSpeedIndexRef.current = 0;
|
||||
startRAFLoop();
|
||||
} else if (shouldStopAfterSeek({ keepPlaying, wasReverseShuttle })) {
|
||||
stopRAFLoop();
|
||||
if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
|
||||
shuttleDirectionRef.current = null;
|
||||
@@ -410,14 +417,16 @@ export function useTimelinePlayer() {
|
||||
pendingSeekRef,
|
||||
setCurrentTime,
|
||||
setIsPlaying,
|
||||
startRAFLoop,
|
||||
stopRAFLoop,
|
||||
stopReverseLoop,
|
||||
applyPlaybackRate,
|
||||
applyPreviewAudioState,
|
||||
shuttleDirectionRef,
|
||||
shuttleSpeedIndexRef,
|
||||
],
|
||||
);
|
||||
|
||||
// Handle seek requests from outside the player loop (e.g. LayersPanel).
|
||||
useEffect(() => {
|
||||
return usePlayerStore.subscribe((state, prev) => {
|
||||
if (state.requestedSeekTime !== null && state.requestedSeekTime !== prev.requestedSeekTime) {
|
||||
@@ -480,12 +489,8 @@ export function useTimelinePlayer() {
|
||||
const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
|
||||
const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
|
||||
|
||||
// Listen for timeline messages from the iframe runtime.
|
||||
// The runtime sends this AFTER all external compositions load,
|
||||
// so we get the complete clip list (not just the first few).
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
const data = e.data;
|
||||
// Only process messages from the main preview iframe — ignore MediaPanel/ClipThumbnail iframes
|
||||
const ourIframe = iframeRef.current;
|
||||
if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
|
||||
return;
|
||||
@@ -499,10 +504,6 @@ export function useTimelinePlayer() {
|
||||
processTimelineMessageRef.current(manifest);
|
||||
}
|
||||
}
|
||||
// Enrich only when the timeline has settled — skip during the window
|
||||
// right after a "timeline" message to avoid the enrichment adding
|
||||
// elements that fight with the manifest's authoritative element list,
|
||||
// causing duration oscillation.
|
||||
const msSinceTimeline = Date.now() - lastTimelineMessageRef.current;
|
||||
if (msSinceTimeline > 500) {
|
||||
enrichMissingCompositionsRef.current();
|
||||
@@ -535,7 +536,6 @@ export function useTimelinePlayer() {
|
||||
}
|
||||
};
|
||||
|
||||
// Pause video when tab loses focus
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden && usePlayerStore.getState().isPlaying) {
|
||||
const adapter = getAdapterRef.current?.();
|
||||
@@ -564,7 +564,6 @@ export function useTimelinePlayer() {
|
||||
};
|
||||
});
|
||||
|
||||
/** Reset the player store (elements, duration, etc.) — call when switching sessions. */
|
||||
const resetPlayer = useCallback(() => {
|
||||
stopRAFLoop();
|
||||
stopReverseLoop();
|
||||
|
||||
Reference in New Issue
Block a user