Preserve paused preview camera and defer composition until seek completion

This commit is contained in:
webadderall
2026-09-17 19:45:15 +10:00
parent 43db532cde
commit e2d4a1ec4e
3 changed files with 26 additions and 7 deletions
@@ -1044,9 +1044,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
});
cropBoundsRef.current = result.cropBounds;
// Reset camera container to identity
cameraContainer.scale.set(1);
cameraContainer.position.set(0, 0);
// Layout updates the media geometry, not the composed camera pose.
// In particular, a ResizeObserver notification while paused must not
// replace the exported spring position with an unzoomed frame.
const selectedId = selectedZoomIdRef.current;
const activeRegion = selectedId
@@ -1893,6 +1893,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
video,
getClips: () => clipRegionsRef.current,
onTime: (time, source) => {
timelineTimeRef.current = time;
if (source !== null) currentTimeRef.current = source * 1000;
onTimeUpdate(time);
},
@@ -1909,6 +1910,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
transport.seek(timelineTimeRef.current);
const handleSeeked = () => {
isSeekingRef.current = false;
shouldSnapPausedFrameRef.current = true;
};
const handleSeeking = () => {
isSeekingRef.current = true;
@@ -2016,6 +2018,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
if (
!shouldComposePreviewFrame({
motionMode,
isSeeking: isSeekingRef.current || Boolean(videoRef.current?.seeking),
contentTimeChanged,
shouldSnapPausedFrame: shouldSnapPausedFrameRef.current,
})
@@ -43,13 +43,13 @@ describe("resolveSceneZoomTarget", () => {
});
describe("resolvePreviewMotionMode", () => {
it("preserves the composed frame on a plain pause", () => {
it.each([false, true])("preserves a plain pause with classic mode %s", (zoomClassicMode) => {
expect(
resolvePreviewMotionMode({
isPlaying: false,
isSeeking: false,
shouldSnapPausedFrame: false,
zoomClassicMode: false,
zoomClassicMode,
}),
).toBe("preserve");
});
@@ -97,3 +97,16 @@ describe("shouldComposePreviewFrame", () => {
).toBe(true);
});
});
describe("preview seek completion", () => {
it("holds the composed frame until seeking finishes, even with a pending refresh", () => {
const pending = {
motionMode: "snap" as const,
contentTimeChanged: true,
shouldSnapPausedFrame: true,
};
expect(shouldComposePreviewFrame({ ...pending, isSeeking: true })).toBe(false);
expect(shouldComposePreviewFrame({ ...pending, isSeeking: false })).toBe(true);
});
});
@@ -32,7 +32,7 @@ export function resolvePreviewMotionMode({
shouldSnapPausedFrame: boolean;
zoomClassicMode: boolean;
}): PreviewMotionMode {
if (isSeeking || shouldSnapPausedFrame || zoomClassicMode) {
if (isSeeking || shouldSnapPausedFrame || (isPlaying && zoomClassicMode)) {
return "snap";
}
@@ -42,14 +42,17 @@ export function resolvePreviewMotionMode({
/** Match export's one-composition-per-media-frame behavior. */
export function shouldComposePreviewFrame({
motionMode,
isSeeking = false,
contentTimeChanged,
shouldSnapPausedFrame,
}: {
motionMode: PreviewMotionMode;
isSeeking?: boolean;
contentTimeChanged: boolean;
shouldSnapPausedFrame: boolean;
}): boolean {
if (motionMode === "preserve") {
// Do not consume the pending composition against the old decoded image.
if (isSeeking || motionMode === "preserve") {
return false;
}