diff --git a/packages/player-core/src/__test__/integration/Player.test.ts b/packages/player-core/src/__test__/integration/Player.test.ts index 7cefc94..41fbf7d 100644 --- a/packages/player-core/src/__test__/integration/Player.test.ts +++ b/packages/player-core/src/__test__/integration/Player.test.ts @@ -231,6 +231,94 @@ describe("Player (mount.ts) integration", () => { expect(transforms.some((t) => /scale\(1\)/.test(t))).toBe(false); }); + it("carries a zoomed video's framing straight into the zoomed photo step after it", async () => { + // no real clip to decode here: play() is stubbed and the video step is ended by hand + vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(); + const onStepChange = vi.fn(); + player = new Player({ + container, + onStepChange, + demo: { + id: "demo-4", + title: "Zoomed video then zoomed photo", + theme: { wrapper: "none", autoplay: false, appearance: "light" }, + video: { + src: "data:video/webm;base64,", + width: 800, + height: 600, + durationSec: 10, + }, + steps: [ + { + id: "clip", + type: "video", + startTime: 0, + endTime: 5, + panZoom: { x: 0.7, y: 0.6, scale: 1.5 }, + }, + { + id: "photo", + type: "photo", + image: { src: TINY_PNG, width: 800, height: 600 }, + hotspot: { x: 0.5, y: 0.5, label: "Click here" }, + panZoom: { x: 0.2, y: 0.3, scale: 2 }, + }, + ], + }, + }); + player.mount(); + await vi.waitFor(() => expect(onStepChange).toHaveBeenCalled()); + const img = container.querySelector( + "img.media:last-of-type", + ) as HTMLImageElement; + const shownTransforms: string[] = []; + new MutationObserver(() => { + if (img.style.display === "block") + shownTransforms.push(img.style.transform); + }).observe(img, { attributes: true, attributeFilter: ["style"] }); + + player.next(); + + await vi.waitFor(() => expect(img.style.transform).toContain("scale(2)")); + expect(shownTransforms[0]).toContain("scale(1.5)"); + expect(shownTransforms.some((t) => /scale\(1\)/.test(t))).toBe(false); + }); + + it("moves straight from one zoomed photo's framing to the next one's", async () => { + const onStepChange = vi.fn(); + const demo = createDemo(); + demo.steps = [ + { + id: "zoom-a", + type: "photo", + image: { src: TINY_PNG, width: 800, height: 600 }, + panZoom: { x: 0.7, y: 0.6, scale: 1.5, duration: 50 }, + }, + { + id: "zoom-b", + type: "photo", + image: { src: TINY_PNG, width: 800, height: 600 }, + panZoom: { x: 0.2, y: 0.3, scale: 2 }, + }, + ]; + player = new Player({ container, demo, onStepChange }); + player.mount(); + const img = container.querySelector( + "img.media:last-of-type", + ) as HTMLImageElement; + await vi.waitFor(() => expect(img.style.transform).toContain("scale(1.5)")); + const transforms: string[] = []; + new MutationObserver(() => transforms.push(img.style.transform)).observe( + img, + { attributes: true, attributeFilter: ["style"] }, + ); + + player.next(); + + await vi.waitFor(() => expect(img.style.transform).toContain("scale(2)")); + expect(transforms.some((t) => /scale\(1\)/.test(t))).toBe(false); + }); + it("destroys cleanly, leaving the container empty", async () => { player = new Player({ container, demo: createDemo() }); player.mount(); diff --git a/packages/player-core/src/__test__/unit/video-step.test.ts b/packages/player-core/src/__test__/unit/video-step.test.ts index efcd6cb..4c426cd 100644 --- a/packages/player-core/src/__test__/unit/video-step.test.ts +++ b/packages/player-core/src/__test__/unit/video-step.test.ts @@ -167,6 +167,69 @@ describe("startVideoStep", () => { expect(onZoomOut).toHaveBeenCalledTimes(1); }); + it("keeps its zoom to the end when the next step is a zoomed photo, which picks up the framing", () => { + const video = createVideo({ currentTime: 9.99 }); + const onZoomOut = vi.fn(); + startVideoStep( + video as never, + { ...baseStep, panZoom: { x: 0.5, y: 0.5, scale: 2 } }, + true, + { onProgress: vi.fn(), onZoomOut, onEnded: vi.fn() }, + { + id: "p", + type: "photo", + image: { src: "a.webp", width: 1, height: 1 }, + panZoom: { x: 0.3, y: 0.3, scale: 1.5 }, + }, + ); + + raf.tick(0); + + expect(onZoomOut).not.toHaveBeenCalled(); + }); + + it("keeps its zoom to the end when the next step is a zoomed clip", () => { + const video = createVideo({ currentTime: 9.99 }); + const onZoomOut = vi.fn(); + startVideoStep( + video as never, + { ...baseStep, panZoom: { x: 0.5, y: 0.5, scale: 2 } }, + true, + { onProgress: vi.fn(), onZoomOut, onEnded: vi.fn() }, + { + id: "v2", + type: "video", + startTime: 10, + endTime: 20, + panZoom: { x: 0.3, y: 0.3, scale: 1.5 }, + }, + ); + + raf.tick(0); + + expect(onZoomOut).not.toHaveBeenCalled(); + }); + + it("still zooms out before the end when the next step is a photo without zoom", () => { + const video = createVideo({ currentTime: 9.99 }); + const onZoomOut = vi.fn(); + startVideoStep( + video as never, + { ...baseStep, panZoom: { x: 0.5, y: 0.5, scale: 2 } }, + true, + { onProgress: vi.fn(), onZoomOut, onEnded: vi.fn() }, + { + id: "p", + type: "photo", + image: { src: "a.webp", width: 1, height: 1 }, + }, + ); + + raf.tick(0); + + expect(onZoomOut).toHaveBeenCalledTimes(1); + }); + it("does not fire onZoomOut when the step has no panZoom", () => { const video = createVideo({ currentTime: 9.99 }); const onZoomOut = vi.fn(); diff --git a/packages/player-core/src/components/Player.svelte b/packages/player-core/src/components/Player.svelte index 65db324..9b54039 100644 --- a/packages/player-core/src/components/Player.svelte +++ b/packages/player-core/src/components/Player.svelte @@ -26,7 +26,6 @@ import { import { StepMachine } from "../core/step-machine"; import type { VideoClipWatcherHandle } from "../core/video-clip-watcher"; import { - continuesInto, isContiguousPlayback, startVideoStep, whenAtTime, @@ -121,6 +120,7 @@ let segments = $state( let videoObjectUrl: string | undefined; let videoWatchHandle: VideoClipWatcherHandle | undefined; let currentStepRef: Step | undefined; +let previousStepRef: Step | undefined; const overlay = new PhotoOverlayController({ setHotspot: (hotspot) => { @@ -187,6 +187,7 @@ function renderStep(newIndex: number, previousIndex: number): void { const step = demo.steps[newIndex]; if (!step) return; index = newIndex; + previousStepRef = demo.steps[previousIndex]; currentStepRef = step; videoWatchHandle?.cancel(); videoWatchHandle = undefined; @@ -289,7 +290,7 @@ function renderVideoStep(step: VideoStep, previousIndex: number): void { next(); }, }, - continuesInto(step, demo.steps[index + 1]), + demo.steps[index + 1], ); }); } @@ -333,23 +334,43 @@ function handlePhotoReady(): void { video.transform = IDENTITY_ZOOM_TRANSFORM; photo.visible = true; - applyMediaZoom( - { - setTransform: (transform) => { - photo.transform = transform; - }, - setTransformInstant: (instant) => { - photo.transformInstant = instant; - }, - setTransitionTiming: (ms, easing) => { - photo.transitionMs = ms; - photo.transitionEasing = easing; - }, + const photoTarget = { + setTransform: (transform: string) => { + photo.transform = transform; }, - step.panZoom, - wasHidden, - () => currentStepRef === step, - ); + setTransformInstant: (instant: boolean) => { + photo.transformInstant = instant; + }, + setTransitionTiming: (ms: number, easing: string) => { + photo.transitionMs = ms; + photo.transitionEasing = easing; + }, + }; + const previousStep = previousStepRef; + // A zoomed photo right after a zoomed clip: the clip kept its zoom to the end (see + // handsZoomTo), so the photo picks up that framing and moves on to its own zoom from there + // instead of restarting from full frame. + if ( + wasHidden && + step.panZoom && + previousStep && + isVideoStep(previousStep) && + previousStep.panZoom + ) { + applyInheritedZoom( + photoTarget, + previousStep.panZoom, + step.panZoom, + () => currentStepRef === step, + ); + } else { + applyMediaZoom( + photoTarget, + step.panZoom, + wasHidden, + () => currentStepRef === step, + ); + } const anchor = step.hotspot ? computeAnchor(step, step.hotspot) : undefined; // The hotspot/tooltip are positioned at their POST-zoom coordinates (see computeAnchorPoint) diff --git a/packages/player-core/src/core/video-step.ts b/packages/player-core/src/core/video-step.ts index e912b4f..8bcb2c2 100644 --- a/packages/player-core/src/core/video-step.ts +++ b/packages/player-core/src/core/video-step.ts @@ -48,22 +48,33 @@ export interface VideoStepCallbacks { onEnded: () => void; } +/** + * Whether a zoomed clip hands its framing over to `next` instead of zooming out before its + * end: a zoomed step moves on to its own zoom straight from the clip's framing (a photo via + * `applyInheritedZoom`, a clip because the video layer just keeps its transform), so easing + * out first would only make the camera bounce. + */ +export function handsZoomTo(next: Step | undefined): boolean { + return next?.panZoom != null; +} + /** Seeks (unless contiguous), starts playback, and wires up the clip watcher for a video step. */ export function startVideoStep( video: HTMLVideoElement, step: VideoStep, wasContiguous: boolean, callbacks: VideoStepCallbacks, - continuesIntoNext = false, + next?: Step, ): VideoClipWatcherHandle { video.playbackRate = step.playbackRate ?? 1; if (!wasContiguous) video.currentTime = step.startTime; void playVideo(video); - const zoomOutLeadSec = step.panZoom - ? (step.panZoom.duration ?? defaultPanZoomTiming.duration) / 1000 + - ZOOM_OUT_SAFETY_MARGIN_SEC - : undefined; + const zoomOutLeadSec = + step.panZoom && !handsZoomTo(next) + ? (step.panZoom.duration ?? defaultPanZoomTiming.duration) / 1000 + + ZOOM_OUT_SAFETY_MARGIN_SEC + : undefined; return watchVideoClip( video, @@ -71,7 +82,7 @@ export function startVideoStep( startTime: step.startTime, endTime: step.endTime, zoomOutLeadSec, - continuesIntoNext, + continuesIntoNext: continuesInto(step, next), }, { onProgress: callbacks.onProgress,