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
88 changes: 88 additions & 0 deletions packages/player-core/src/__test__/integration/Player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
63 changes: 63 additions & 0 deletions packages/player-core/src/__test__/unit/video-step.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
57 changes: 39 additions & 18 deletions packages/player-core/src/components/Player.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import {
import { StepMachine } from "../core/step-machine";
import type { VideoClipWatcherHandle } from "../core/video-clip-watcher";
import {
continuesInto,
isContiguousPlayback,
startVideoStep,
whenAtTime,
Expand Down Expand Up @@ -121,6 +120,7 @@ let segments = $state<NavbarSegment[]>(
let videoObjectUrl: string | undefined;
let videoWatchHandle: VideoClipWatcherHandle | undefined;
let currentStepRef: Step | undefined;
let previousStepRef: Step | undefined;

const overlay = new PhotoOverlayController({
setHotspot: (hotspot) => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -289,7 +290,7 @@ function renderVideoStep(step: VideoStep, previousIndex: number): void {
next();
},
},
continuesInto(step, demo.steps[index + 1]),
demo.steps[index + 1],
);
});
}
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 17 additions & 6 deletions packages/player-core/src/core/video-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,30 +48,41 @@ 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,
{
startTime: step.startTime,
endTime: step.endTime,
zoomOutLeadSec,
continuesIntoNext,
continuesIntoNext: continuesInto(step, next),
},
{
onProgress: callbacks.onProgress,
Expand Down
Loading