feat: 'ken burns' style slideshow effect

Change-Id: I22c2bfbf08b5b0e0f4eb9f25e1efe0e16a6a6964
This commit is contained in:
midzelis
2026-03-17 21:01:03 +00:00
parent 4bfdf87e3e
commit 4a9510fc2b
10 changed files with 242 additions and 15 deletions

View File

@@ -120,7 +120,6 @@
let slideShowPlaying = $derived($slideshowState === SlideshowState.PlaySlideshow);
let slideShowAscending = $derived($slideshowNavigation === SlideshowNavigation.AscendingOrder);
let slideShowShuffle = $derived($slideshowNavigation === SlideshowNavigation.Shuffle);
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
let slideshowStartAssetId = $state<string>();

View File

@@ -7,20 +7,23 @@
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { signalAssetViewerReady } from '$lib/managers/event-manager.svelte';
import { eventManager, signalAssetViewerReady } from '$lib/managers/event-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { getNaturalSize, scaleToCover, scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
import { scaleToCover, scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { getBoundingBox, selectKenBurnsFace } from '$lib/utils/people-utils';
import { type SharedLinkResponseDto } from '@immich/sdk';
import smartcrop from 'smartcrop';
import { toastManager } from '@immich/ui';
import { clamp } from 'lodash-es';
import { onDestroy, untrack } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
@@ -46,13 +49,33 @@
onSwipe,
}: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
const objectFit = $derived(
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain',
);
const SMARTCROP_ENABLED = true;
// When true, smartcrop runs on face images too, with face bounding boxes as boost hints,
// so the pan target is composition-aware rather than mechanically centered on the face.
const SMARTCROP_FACE_BOOST_ENABLED = true;
// Speed caps — decrease to slow Ken Burns down; increase or set Infinity to uncap.
// Zoom: max scale-units change per second (e.g. 0.08 = 1.0x→1.4x over 5 s).
const KEN_BURNS_MAX_ZOOM_SPEED = 0.08;
// Pan: max viewport-%-points per second (rarely the binding constraint vs letterbox clamp).
const KEN_BURNS_MAX_PAN_SPEED = 8;
const { slideshowState, slideshowLook, kenBurnsEffect, slideshowDelay } = slideshowStore;
const asset = $derived(cursor.current);
let visibleImageReady: boolean = $state(false);
let kenBurnsAnimation: Animation | undefined;
let adaptiveImage = $state<HTMLDivElement | undefined>();
let smartCropNormalizedCenter = $state<{ x: number; y: number } | undefined>(undefined);
const unsubscribeFreeze = eventManager.on({
ViewTransitionOldSnapshotPending: () => {
// Pause rather than cancel: pausing freezes the WAAPI animation at its current compositor
// position so the view-transition old snapshot captures the correct Ken Burns frame.
// cancel() would remove the animation and revert to the base inline style (the Ken Burns
// start transform, i.e. scale(1) for zoom-in animations), causing a visible snap.
kenBurnsAnimation?.pause();
},
});
let previousAssetId: string | undefined;
$effect.pre(() => {
@@ -62,13 +85,19 @@
}
previousAssetId = id;
untrack(() => {
kenBurnsAnimation?.cancel();
kenBurnsAnimation = undefined;
assetViewerManager.resetZoomState();
visibleImageReady = false;
smartCropNormalizedCenter = undefined;
$boundingBoxesArray = [];
adaptiveImage?.style.removeProperty('transform');
});
});
onDestroy(() => {
unsubscribeFreeze();
kenBurnsAnimation?.cancel();
$boundingBoxesArray = [];
});
@@ -80,14 +109,20 @@
height: containerHeight,
});
const isCoverMode = $derived($slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover);
const overlayMetrics = $derived.by((): ContentMetrics => {
if (!assetViewerManager.imgRef || !visibleImageReady) {
if (!visibleImageReady) {
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
}
const natural = getNaturalSize(assetViewerManager.imgRef);
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
const scaled = scaleFn(natural, { width: containerWidth, height: containerHeight });
const assetWidth = asset.width && asset.width > 0 ? asset.width : 1;
const assetHeight = asset.height && asset.height > 0 ? asset.height : 1;
const scaleFn = isCoverMode ? scaleToCover : scaleToFit;
const scaled = scaleFn(
{ width: assetWidth, height: assetHeight },
{ width: containerWidth, height: containerHeight },
);
return {
contentWidth: scaled.width,
@@ -159,8 +194,6 @@
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
);
let adaptiveImage = $state<HTMLDivElement | undefined>();
const faceToNameMap = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<Faces, string>();
@@ -180,6 +213,167 @@
const faces = $derived(Array.from(faceToNameMap.keys()));
const boundingBoxes = $derived(getBoundingBox(faces, overlayMetrics));
const activeBoundingBoxes = $derived(getBoundingBox($boundingBoxesArray, overlayMetrics));
const kenBurnsActive = $derived($slideshowState === SlideshowState.PlaySlideshow && $kenBurnsEffect);
$effect(() => {
if (!SMARTCROP_ENABLED || !kenBurnsActive || !visibleImageReady || !assetViewerManager.imgRef) {
return;
}
if (!SMARTCROP_FACE_BOOST_ENABLED && faces.length > 0) {
return;
}
if (smartCropNormalizedCenter !== undefined) {
return;
}
const imgRef = assetViewerManager.imgRef;
const boosts =
SMARTCROP_FACE_BOOST_ENABLED && faces.length > 0
? faces.map((face) => ({
x: (face.boundingBoxX1 / face.imageWidth) * imgRef.naturalWidth,
y: (face.boundingBoxY1 / face.imageHeight) * imgRef.naturalHeight,
width: ((face.boundingBoxX2 - face.boundingBoxX1) / face.imageWidth) * imgRef.naturalWidth,
height: ((face.boundingBoxY2 - face.boundingBoxY1) / face.imageHeight) * imgRef.naturalHeight,
weight: 1,
}))
: undefined;
void smartcrop
.crop(imgRef, { width: containerWidth, height: containerHeight, ...(boosts && { boost: boosts }) })
.then((result) => {
const { x, y, width, height } = result.topCrop;
smartCropNormalizedCenter = {
x: (x + width / 2) / imgRef.naturalWidth,
y: (y + height / 2) / imgRef.naturalHeight,
};
});
});
const kenBurnsCanStart = $derived(
kenBurnsActive &&
visibleImageReady &&
(!SMARTCROP_ENABLED ||
(!SMARTCROP_FACE_BOOST_ENABLED && faces.length > 0) ||
smartCropNormalizedCenter !== undefined),
);
$effect(() => {
if (!kenBurnsCanStart || !adaptiveImage) {
return;
}
assetViewerManager.zoomState = { ...untrack(() => assetViewerManager.zoomState), enable: false };
const contentWidth = overlayMetrics.contentWidth;
const contentHeight = overlayMetrics.contentHeight;
const blurredBackground = $slideshowLook === SlideshowLook.BlurredBackground;
// In blurred background mode the blur fills the container, so no clamping is needed.
// Otherwise require a minimum zoom to fully cover the container and hide letterboxes.
const minZoom =
!blurredBackground && contentWidth > 0 && contentHeight > 0
? Math.min(Math.max(containerWidth / contentWidth, containerHeight / contentHeight), 2)
: 1;
const slideDurationMs = $slideshowDelay * 1000;
// Pre-compute the maximum zoom change allowed this slide based on the speed cap.
// Speed = zoom-range / duration, so max-range = speed × duration.
const maxZoomChange = KEN_BURNS_MAX_ZOOM_SPEED * (slideDurationMs / 1000);
const face = selectKenBurnsFace(faces);
let targetScale: number;
let endX = 0;
let endY = 0;
if (face && contentWidth > 0) {
// Zoom so the face fills roughly 40% of the container height
const faceHeightFraction = (face.boundingBoxY2 - face.boundingBoxY1) / face.imageHeight;
const faceTargetZoom = (0.4 * containerHeight) / (faceHeightFraction * contentHeight);
targetScale = clamp(faceTargetZoom, Math.max(minZoom, 1.2), 2);
// Apply zoom speed cap; restore minimum if cap overshoots it.
targetScale = clamp(targetScale, Math.max(minZoom, 1.2), minZoom + maxZoomChange);
const targetNormalizedX =
SMARTCROP_FACE_BOOST_ENABLED && smartCropNormalizedCenter
? smartCropNormalizedCenter.x
: (face.boundingBoxX1 + face.boundingBoxX2) / 2 / face.imageWidth;
const targetNormalizedY =
SMARTCROP_FACE_BOOST_ENABLED && smartCropNormalizedCenter
? smartCropNormalizedCenter.y
: (face.boundingBoxY1 + face.boundingBoxY2) / 2 / face.imageHeight;
endX = (((0.5 - targetNormalizedX) * contentWidth) / containerWidth) * 100;
endY = (((0.5 - targetNormalizedY) * contentHeight) / containerHeight) * 100;
} else {
targetScale = clamp(minZoom, 1.2, 2);
// Apply zoom speed cap; restore minimum if cap overshoots it.
targetScale = clamp(targetScale, Math.max(minZoom, 1.2), minZoom + maxZoomChange);
if (smartCropNormalizedCenter && contentWidth > 0) {
endX = (((0.5 - smartCropNormalizedCenter.x) * contentWidth) / containerWidth) * 100;
endY = (((0.5 - smartCropNormalizedCenter.y) * contentHeight) / containerHeight) * 100;
}
}
// Clamp pan so no uncovered area is revealed. For blurred background mode the full
// container is covered by the blur, so clamp relative to the container; otherwise
// clamp relative to the image content so letterboxes are never exposed.
const clampWidth = blurredBackground || isCoverMode ? containerWidth : contentWidth;
const clampHeight = blurredBackground || isCoverMode ? containerHeight : contentHeight;
const maxTranslateX = Math.max(0, (clampWidth / (2 * containerWidth) - 1 / (2 * targetScale)) * 100);
const maxTranslateY = Math.max(0, (clampHeight / (2 * containerHeight) - 1 / (2 * targetScale)) * 100);
endX = clamp(endX, -maxTranslateX, maxTranslateX);
endY = clamp(endY, -maxTranslateY, maxTranslateY);
// Apply pan speed cap: √(endX²+endY²) / (slideDurationMs/1000) ≤ KEN_BURNS_MAX_PAN_SPEED.
const panDist = Math.hypot(endX, endY);
const maxPan = KEN_BURNS_MAX_PAN_SPEED * (slideDurationMs / 1000);
if (panDist > maxPan && panDist > 0) {
const ratio = maxPan / panDist;
endX *= ratio;
endY *= ratio;
}
// Alternate zoom direction per-asset so the effect doesn't feel repetitive
const zoomIn = Number.parseInt(asset.id.at(-1) ?? '0', 16) < 8;
const startTransform = zoomIn
? `scale(${minZoom}) translate(0%, 0%)`
: `scale(${targetScale}) translate(${endX}%, ${endY}%)`;
const endTransform = zoomIn
? `scale(${targetScale}) translate(${endX}%, ${endY}%)`
: `scale(${minZoom}) translate(0%, 0%)`;
// The zoom library sets transform-origin based on mouse position; reset it so the
// Ken Burns scale always originates from the center of the container.
adaptiveImage.style.transformOrigin = '50% 50%';
adaptiveImage.style.transform = startTransform;
const keyframes: Keyframe[] = [{ transform: startTransform, easing: 'ease-in-out' }, { transform: endTransform }];
kenBurnsAnimation = adaptiveImage.animate(keyframes, { duration: slideDurationMs, fill: 'forwards' });
// untrack: reading activeViewTransition here must not create a reactive dependency —
// if it did, changing activeViewTransition would re-run this effect and cancel the animation.
if (untrack(() => viewTransitionManager.activeViewTransition) !== null) {
kenBurnsAnimation.pause();
}
return () => {
kenBurnsAnimation?.cancel();
kenBurnsAnimation = undefined;
adaptiveImage?.style.removeProperty('transform-origin');
if (viewTransitionManager.activeViewTransition === null) {
assetViewerManager.resetZoomState();
}
};
});
$effect(() => {
if (viewTransitionManager.activeViewTransition === null) {
kenBurnsAnimation?.play();
}
});
</script>
<AssetViewerEvents {onCopy} {onZoom} />
@@ -207,7 +401,7 @@
{asset}
{sharedLink}
{container}
{objectFit}
objectFit={isCoverMode ? 'cover' : 'contain'}
{onUrlChange}
onImageReady={() => {
visibleImageReady = true;

View File

@@ -95,6 +95,7 @@ export function startViewerTransition(
types: ['viewer'],
prepareOldSnapshot: () => {
setTransitionId(assetId);
eventManager.emit('ViewTransitionOldSnapshotPending');
},
performUpdate: async () => {
setTransitionId(null);

View File

@@ -81,6 +81,7 @@ export type Events = {
TransitionToAssetViewer: [];
TransitionToTimeline: [{ id: string }];
TransitionToTimelineReady: [];
ViewTransitionOldSnapshotPending: [];
UserAdminCreate: [UserAdminResponseDto];
// soft deleted

View File

@@ -21,6 +21,7 @@
slideshowTransition,
slideshowAutoplay,
slideshowRepeat,
kenBurnsEffect,
slideshowState,
} = slideshowStore;
@@ -38,6 +39,7 @@
let tempSlideshowTransition = $state($slideshowTransition);
let tempSlideshowAutoplay = $state($slideshowAutoplay);
let tempSlideshowRepeat = $state($slideshowRepeat);
let tempKenBurnsEffect = $state($kenBurnsEffect);
const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
[SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
@@ -70,6 +72,7 @@
$slideshowTransition = tempSlideshowTransition;
$slideshowAutoplay = tempSlideshowAutoplay;
$slideshowRepeat = tempSlideshowRepeat;
$kenBurnsEffect = tempKenBurnsEffect;
$slideshowState = SlideshowState.PlaySlideshow;
onClose();
};
@@ -107,6 +110,10 @@
<Switch bind:checked={tempSlideshowTransition} />
</Field>
<Field label={$t('slideshow_ken_burns_effect')}>
<Switch bind:checked={tempKenBurnsEffect} />
</Field>
<Field label={$t('slideshow_repeat')} description={$t('slideshow_repeat_description')}>
<Switch bind:checked={tempSlideshowRepeat} />
</Field>

View File

@@ -41,6 +41,7 @@ function createSlideshowStore() {
const slideshowTransition = persisted<boolean>('slideshow-transition', true);
const slideshowAutoplay = persisted<boolean>('slideshow-autoplay', true, {});
const slideshowRepeat = persisted<boolean>('slideshow-repeat', false);
const kenBurnsEffect = persisted<boolean>('slideshow-ken-burns-effect', false);
return {
restartProgress: {
@@ -73,6 +74,7 @@ function createSlideshowStore() {
slideshowTransition,
slideshowAutoplay,
slideshowRepeat,
kenBurnsEffect,
};
}

View File

@@ -11,6 +11,19 @@ export interface BoundingBox {
height: number;
}
export const selectKenBurnsFace = (faces: Faces[]): Faces | null => {
let best: Faces | null = null;
let bestArea = 0;
for (const face of faces) {
const area = (face.boundingBoxX2 - face.boundingBoxX1) * (face.boundingBoxY2 - face.boundingBoxY1);
if (area > bestArea) {
best = face;
bestArea = area;
}
}
return best;
};
export const getBoundingBox = (faces: Faces[], metrics: ContentMetrics): BoundingBox[] => {
const boxes: BoundingBox[] = [];