From 9a89573354dac84f8f246fccbe649d8ed636828b Mon Sep 17 00:00:00 2001 From: Abian Suarez Date: Fri, 25 Sep 2026 07:37:41 +0100 Subject: [PATCH] fix(player-core): carry photo zoom into a zoomed video step --- .../src/__test__/integration/Player.test.ts | 54 +++++++++++++++++++ .../src/__test__/unit/media-zoom.test.ts | 35 ++++++++++-- .../player-core/src/components/Player.svelte | 14 ++--- packages/player-core/src/core/media-zoom.ts | 22 ++++---- 4 files changed, 104 insertions(+), 21 deletions(-) diff --git a/packages/player-core/src/__test__/integration/Player.test.ts b/packages/player-core/src/__test__/integration/Player.test.ts index 6a0361e..7cefc94 100644 --- a/packages/player-core/src/__test__/integration/Player.test.ts +++ b/packages/player-core/src/__test__/integration/Player.test.ts @@ -177,6 +177,60 @@ describe("Player (mount.ts) integration", () => { await vi.waitFor(() => expect(hotspot()).not.toBeNull()); }); + it("carries a zoomed photo's framing straight into the zoomed video step after it", async () => { + // no real clip to decode here: play() is stubbed and the video step never ends + vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(); + const onStepChange = vi.fn(); + player = new Player({ + container, + onStepChange, + demo: { + id: "demo-3", + title: "Zoomed photo then zoomed video", + theme: { wrapper: "none", autoplay: false, appearance: "light" }, + video: { + src: "data:video/webm;base64,", + width: 800, + height: 600, + durationSec: 10, + }, + steps: [ + { + id: "photo", + type: "photo", + image: { src: TINY_PNG, width: 800, height: 600 }, + panZoom: { x: 0.2, y: 0.3, scale: 2 }, + }, + { + id: "clip", + type: "video", + startTime: 0, + // long enough that the zoom-out cue before the clip's end doesn't fire right away + endTime: 5, + panZoom: { x: 0.7, y: 0.6, scale: 1.5 }, + }, + ], + }, + }); + player.mount(); + await vi.waitFor(() => expect(onStepChange).toHaveBeenCalled()); + const video = container.querySelector("video") as HTMLVideoElement; + const transforms: string[] = []; + new MutationObserver(() => transforms.push(video.style.transform)).observe( + video, + { attributes: true, attributeFilter: ["style"] }, + ); + + player.next(); + + await vi.waitFor(() => expect(video.style.display).toBe("block")); + await vi.waitFor(() => + expect(video.style.transform).toContain("scale(1.5)"), + ); + expect(transforms[0]).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/media-zoom.test.ts b/packages/player-core/src/__test__/unit/media-zoom.test.ts index 63663c5..ef7d671 100644 --- a/packages/player-core/src/__test__/unit/media-zoom.test.ts +++ b/packages/player-core/src/__test__/unit/media-zoom.test.ts @@ -2,7 +2,7 @@ import type { PanZoom } from "@rustrak/openshowcase-schema"; import { describe, expect, it, vi } from "vitest"; import { IDENTITY_ZOOM_TRANSFORM, zoomTransform } from "../../core/geometry"; import { - applyInheritedZoomOut, + applyInheritedZoom, applyMediaZoom, type MediaZoomScheduler, type MediaZoomTarget, @@ -121,7 +121,7 @@ describe("applyMediaZoom", () => { }); }); -describe("applyInheritedZoomOut", () => { +describe("applyInheritedZoom", () => { it("starts instantly at the given zoom (no re-entry flash) and eases back to identity using its own timing", () => { const target = createTarget(); const { scheduler, runFrames } = createFakeScheduler(); @@ -133,7 +133,7 @@ describe("applyInheritedZoomOut", () => { easing: "smooth", }; - applyInheritedZoomOut(target, panZoom, () => true, scheduler); + applyInheritedZoom(target, panZoom, undefined, () => true, scheduler); expect(target.setTransformInstant).toHaveBeenCalledWith(true); expect(target.setTransform).toHaveBeenCalledWith(zoomTransform(panZoom)); @@ -148,12 +148,39 @@ describe("applyInheritedZoomOut", () => { ); }); + it("moves straight from the inherited zoom to its own zoom, using its own timing", () => { + const target = createTarget(); + const { scheduler, runFrames } = createFakeScheduler(); + const from: PanZoom = { x: 0.2, y: 0.3, scale: 2, duration: 700 }; + const to: PanZoom = { + x: 0.7, + y: 0.6, + scale: 1.5, + duration: 400, + easing: "linear", + }; + + applyInheritedZoom(target, from, to, () => true, scheduler); + + expect(target.setTransformInstant).toHaveBeenCalledWith(true); + expect(target.setTransform).toHaveBeenCalledWith(zoomTransform(from)); + + runFrames(); + + expect(target.setTransformInstant).toHaveBeenLastCalledWith(false); + expect(target.setTransitionTiming).toHaveBeenCalledWith(400, LINEAR_CSS); + expect(target.setTransform).toHaveBeenLastCalledWith(zoomTransform(to)); + expect(target.setTransform).not.toHaveBeenCalledWith( + IDENTITY_ZOOM_TRANSFORM, + ); + }); + it("does not ease out once the step is no longer current", () => { const target = createTarget(); const { scheduler, runFrames } = createFakeScheduler(); const panZoom: PanZoom = { x: 0.5, y: 0.5, scale: 2 }; - applyInheritedZoomOut(target, panZoom, () => false, scheduler); + applyInheritedZoom(target, panZoom, undefined, () => false, scheduler); runFrames(); expect(target.setTransform).toHaveBeenCalledTimes(1); // only the instant starting frame diff --git a/packages/player-core/src/components/Player.svelte b/packages/player-core/src/components/Player.svelte index 3c4edec..65db324 100644 --- a/packages/player-core/src/components/Player.svelte +++ b/packages/player-core/src/components/Player.svelte @@ -13,7 +13,7 @@ import { IDENTITY_ZOOM_TRANSFORM, } from "../core/geometry"; import { - applyInheritedZoomOut, + applyInheritedZoom, applyMediaZoom, resolveTiming, ZOOM_ANIMATION_DELAY_MS, @@ -211,12 +211,11 @@ function renderStep(newIndex: number, previousIndex: number): void { function renderVideoStep(step: VideoStep, previousIndex: number): void { const previousStep = demo.steps[previousIndex]; - // A plain video step (no zoom of its own) right after a zoomed photo: rather than the - // photo's zoom-out vanishing unseen (it's hidden the instant this render happens), the - // video inherits that same framing and eases out of it itself. A video step with its OWN - // panZoom keeps its normal entry behavior — this only fills the "nothing configured" gap. + // A video step right after a zoomed photo: rather than restarting from full frame (the + // photo is hidden the instant this render happens), the video inherits that same framing + // and moves on from it itself — into its own zoom, or back out to identity if it has none. const inheritedZoom = - !step.panZoom && previousStep && isPhotoStep(previousStep) + previousStep && isPhotoStep(previousStep) ? previousStep.panZoom : undefined; @@ -244,9 +243,10 @@ function renderVideoStep(step: VideoStep, previousIndex: number): void { }; if (inheritedZoom) { - applyInheritedZoomOut( + applyInheritedZoom( videoTarget, inheritedZoom, + step.panZoom, () => currentStepRef === step, ); } else { diff --git a/packages/player-core/src/core/media-zoom.ts b/packages/player-core/src/core/media-zoom.ts index b0a7989..3e4dab4 100644 --- a/packages/player-core/src/core/media-zoom.ts +++ b/packages/player-core/src/core/media-zoom.ts @@ -82,25 +82,27 @@ export function applyMediaZoom( } /** - * For a video step with no zoom of its own, immediately following a photo step that WAS - * zoomed in: starts the video already at that same framing (no re-entry flash, no delay - * before the video appears) and eases it back to identity right away, using the outgoing - * photo's own duration/easing — the zoom-out plays out on the video instead of vanishing - * with the (now-hidden) photo. + * For a video step immediately following a photo step that WAS zoomed in: starts the video + * already at that same framing (no re-entry flash, no delay before the video appears) and + * moves on from it right away — to the video's own zoom with its own duration/easing, like + * two consecutive zoomed photos, or, when the video has no zoom, back to identity with the + * outgoing photo's timing, so the zoom-out plays out on the video instead of vanishing with + * the (now-hidden) photo. */ -export function applyInheritedZoomOut( +export function applyInheritedZoom( target: MediaZoomTarget, - panZoomToLeave: PanZoom, + inherited: PanZoom, + own: PanZoom | undefined, isStillCurrent: () => boolean, scheduler: MediaZoomScheduler = defaultScheduler, ): void { target.setTransformInstant(true); - target.setTransform(zoomTransform(panZoomToLeave)); + target.setTransform(zoomTransform(inherited)); scheduler.frame(() => { if (!isStillCurrent()) return; target.setTransformInstant(false); - const { ms, css } = resolveTiming(panZoomToLeave); + const { ms, css } = resolveTiming(own ?? inherited); target.setTransitionTiming(ms, css); - target.setTransform(IDENTITY_ZOOM_TRANSFORM); + target.setTransform(own ? zoomTransform(own) : IDENTITY_ZOOM_TRANSFORM); }); }