Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions packages/player-core/src/__test__/integration/Player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
35 changes: 31 additions & 4 deletions packages/player-core/src/__test__/unit/media-zoom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand All @@ -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));
Expand All @@ -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
Expand Down
14 changes: 7 additions & 7 deletions packages/player-core/src/components/Player.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
IDENTITY_ZOOM_TRANSFORM,
} from "../core/geometry";
import {
applyInheritedZoomOut,
applyInheritedZoom,
applyMediaZoom,
resolveTiming,
ZOOM_ANIMATION_DELAY_MS,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -244,9 +243,10 @@ function renderVideoStep(step: VideoStep, previousIndex: number): void {
};

if (inheritedZoom) {
applyInheritedZoomOut(
applyInheritedZoom(
videoTarget,
inheritedZoom,
step.panZoom,
() => currentStepRef === step,
);
} else {
Expand Down
22 changes: 12 additions & 10 deletions packages/player-core/src/core/media-zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Loading