From 748e5dfcd5a4455996bced1d1373b06fa4cbe85d Mon Sep 17 00:00:00 2001 From: midzelis Date: Tue, 17 Mar 2026 21:01:03 +0000 Subject: [PATCH] feat: 'ken burns' style slideshow effect Change-Id: I22c2bfbf08b5b0e0f4eb9f25e1efe0e16a6a6964 feat(web): manual compositor-driven crossfade for slideshow transitions Change-Id: If1119429abd2689595defcb8831442506a6a6964 resolve conflict: event rename Change-Id: I4d7c8d2a16237b42e49b87734764e18f6a6a6964 Change-Id: I22c2bfbf08b5b0e0f4eb9f25e1efe0e16a6a6964 resolve xxn conflicts Change-Id: I86c287c6c8b59a5549153f21d1c092586a6a6964 --- i18n/en.json | 1 + pnpm-lock.yaml | 8 + web/package.json | 1 + .../asset-viewer/asset-viewer.svelte | 15 +- .../asset-viewer/photo-viewer.svelte | 66 ++++- web/src/lib/managers/event-manager.svelte.ts | 1 + .../lib/modals/SlideshowSettingsModal.svelte | 7 + web/src/lib/stores/slideshow.store.ts | 2 + web/src/lib/utils/ken-burns.ts | 237 ++++++++++++++++++ web/src/lib/utils/transition-utils.ts | 3 + web/src/lib/utils/tunables.ts | 4 + 11 files changed, 335 insertions(+), 10 deletions(-) create mode 100644 web/src/lib/utils/ken-burns.ts diff --git a/i18n/en.json b/i18n/en.json index f427d51abb..7d043884a0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2160,6 +2160,7 @@ "skip_to_folders": "Skip to folders", "skip_to_tags": "Skip to tags", "slideshow": "Slideshow", + "slideshow_ken_burns_effect": "Ken Burns effect", "slideshow_repeat": "Repeat slideshow", "slideshow_repeat_description": "Loop back to beginning when slideshow ends", "slideshow_settings": "Slideshow settings", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52ce8e9561..bd1503ab6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -824,6 +824,9 @@ importers: simple-icons: specifier: ^15.15.0 version: 15.22.0 + smartcrop: + specifier: ^2.0.5 + version: 2.0.5 socket.io-client: specifier: ~4.8.0 version: 4.8.3 @@ -10904,6 +10907,9 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smartcrop@2.0.5: + resolution: {integrity: sha512-aXoHTM8XlC51g96kgZkYxZ2mx09/ibOrIVLiUNOFozV/MHmFSgEr1/5CKVBoFD5vd+re2wSy0xra21CyjRITzA==} + snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} @@ -24106,6 +24112,8 @@ snapshots: smart-buffer@4.2.0: {} + smartcrop@2.0.5: {} + snake-case@3.0.4: dependencies: dot-case: 3.0.4 diff --git a/web/package.json b/web/package.json index 961b2f2c5a..a15498fbf4 100644 --- a/web/package.json +++ b/web/package.json @@ -53,6 +53,7 @@ "pmtiles": "^4.3.0", "qrcode": "^1.5.4", "simple-icons": "^15.15.0", + "smartcrop": "^2.0.5", "socket.io-client": "~4.8.0", "svelte-gestures": "^5.2.2", "svelte-i18n": "^4.0.1", diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index ea0ae8ae79..ba1aaafd1e 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -297,8 +297,19 @@ const targetAsset = order === 'previous' ? previousAsset : nextAsset; const slideshowAllowsTransition = !slideShowPlaying || $slideshowTransition; - const useTransition = canTransition && slideshowAllowsTransition && (slideShowShuffle || !!targetAsset); - const hasNext = useTransition ? await startTransition(types, targetTransition, navigate) : await navigate(); + const useTransition = slideshowAllowsTransition && (slideShowShuffle || !!targetAsset); + + let hasNext: boolean; + if (slideShowPlaying && useTransition) { + hasNext = false; + await crossfadeViewerContent(async () => { + hasNext = await navigate(); + }, 1000); + } else if (canTransition && useTransition) { + hasNext = await startTransition(types, targetTransition, navigate); + } else { + hasNext = await navigate(); + } if (!slideShowPlaying) { return; diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index a95ef4774b..f37d0e9ab5 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -9,6 +9,7 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; + import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte'; import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; @@ -17,11 +18,12 @@ import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; import type { Size } from '$lib/utils/container-utils'; import { handleError } from '$lib/utils/handle-error'; + import { KenBurnsAnimation } from '$lib/utils/ken-burns'; import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils'; import { getBoundingBox } from '$lib/utils/people-utils'; import { type SharedLinkResponseDto } from '@immich/sdk'; import { toastManager } from '@immich/ui'; - import { onDestroy, untrack } from 'svelte'; + import { onDestroy, onMount, untrack } from 'svelte'; import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; import { t } from 'svelte-i18n'; import type { AssetCursor } from './asset-viewer.svelte'; @@ -46,13 +48,17 @@ onSwipe, }: Props = $props(); - const { slideshowState, slideshowLook } = slideshowStore; - const objectFit = $derived( - $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain', - ); + const { slideshowState, slideshowLook, kenBurnsEffect, slideshowDelay } = slideshowStore; const asset = $derived(cursor.current); let visibleImageReady: boolean = $state(false); + const kenBurns = new KenBurnsAnimation(); + let adaptiveImage = $state(); + onMount(() => + eventManager.on({ + ViewTransitionOldSnapshotPending: () => kenBurns.freeze(), + }), + ); let previousAssetId: string | undefined; $effect.pre(() => { @@ -62,13 +68,16 @@ } previousAssetId = id; untrack(() => { + kenBurns.cancel(); assetViewerManager.resetZoomState(); visibleImageReady = false; $boundingBoxesArray = []; + adaptiveImage?.style.removeProperty('transform'); }); }); onDestroy(() => { + kenBurns.cancel(); $boundingBoxesArray = []; }); @@ -80,6 +89,8 @@ height: containerHeight, }); + const isCoverMode = $derived($slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover); + let imageDimensions = $state({ width: 0, height: 0 }); let scaledDimensions = $state({ width: 0, height: 0 }); @@ -147,8 +158,6 @@ $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash, ); - let adaptiveImage = $state(); - const faceToNameMap = $derived.by(() => { // eslint-disable-next-line svelte/prefer-svelte-reactivity const map = new Map(); @@ -167,6 +176,47 @@ const faces = $derived(Array.from(faceToNameMap.keys())); const boundingBoxes = $derived(getBoundingBox(faces, overlaySize)); const activeBoundingBoxes = $derived(boundingBoxes.filter((box) => $boundingBoxesArray.some((f) => f.id === box.id))); + + const unassignedFaces = $derived((asset.unassignedFaces ?? []) as Faces[]); + + const kenBurnsActive = $derived($slideshowState === SlideshowState.PlaySlideshow && $kenBurnsEffect); + + $effect(() => { + if (!kenBurnsActive || !visibleImageReady || !adaptiveImage || !assetViewerManager.imgRef) { + return; + } + + assetViewerManager.zoomState = { ...untrack(() => assetViewerManager.zoomState), enable: false }; + + void kenBurns.startWithSmartCrop(adaptiveImage, { + imgRef: assetViewerManager.imgRef, + faces, + fallbackFaces: unassignedFaces, + contentWidth: overlaySize.width, + contentHeight: overlaySize.height, + containerWidth, + containerHeight, + slideshowLook: $slideshowLook, + isCoverMode, + slideshowDelay: $slideshowDelay, + assetId: asset.id, + }); + + return () => { + kenBurns.cancel(); + if (viewTransitionManager.activeViewTransition === null) { + assetViewerManager.resetZoomState(); + } + }; + }); + + $effect(() => { + if (viewTransitionManager.activeViewTransition === null) { + kenBurns.resume(); + } else { + kenBurns.pause(); + } + }); @@ -194,7 +244,7 @@ {asset} {sharedLink} {container} - {objectFit} + objectFit={isCoverMode ? 'cover' : 'contain'} {onUrlChange} onImageReady={() => { visibleImageReady = true; diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 7039a04f05..24ee9b6bdc 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -98,6 +98,7 @@ export type Events = { ViewerCloseTransitionReady: []; ViewerOpenTransition: []; ViewerOpenTransitionReady: []; + ViewTransitionOldSnapshotPending: []; }; export const eventManager = new BaseEventManager(); diff --git a/web/src/lib/modals/SlideshowSettingsModal.svelte b/web/src/lib/modals/SlideshowSettingsModal.svelte index d4230f8bf4..caf8d2fe10 100644 --- a/web/src/lib/modals/SlideshowSettingsModal.svelte +++ b/web/src/lib/modals/SlideshowSettingsModal.svelte @@ -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.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 @@ + + + + diff --git a/web/src/lib/stores/slideshow.store.ts b/web/src/lib/stores/slideshow.store.ts index 4b8fd83369..89b6e6d27a 100644 --- a/web/src/lib/stores/slideshow.store.ts +++ b/web/src/lib/stores/slideshow.store.ts @@ -41,6 +41,7 @@ function createSlideshowStore() { const slideshowTransition = persisted('slideshow-transition', true); const slideshowAutoplay = persisted('slideshow-autoplay', true, {}); const slideshowRepeat = persisted('slideshow-repeat', false); + const kenBurnsEffect = persisted('slideshow-ken-burns-effect', false); return { restartProgress: { @@ -73,6 +74,7 @@ function createSlideshowStore() { slideshowTransition, slideshowAutoplay, slideshowRepeat, + kenBurnsEffect, }; } diff --git a/web/src/lib/utils/ken-burns.ts b/web/src/lib/utils/ken-burns.ts new file mode 100644 index 0000000000..f9e0b9242a --- /dev/null +++ b/web/src/lib/utils/ken-burns.ts @@ -0,0 +1,237 @@ +import type { Faces } from '$lib/stores/people.store'; +import { SlideshowLook } from '$lib/stores/slideshow.store'; +import type { Point } from '$lib/utils/container-utils'; +import { TUNABLES } from '$lib/utils/tunables'; +import { clamp } from 'lodash-es'; +import smartcrop from 'smartcrop'; + +const KEN_BURNS_MAX_ZOOM_SPEED = 0.08; +const KEN_BURNS_MAX_PAN_SPEED = 8; + +export interface KenBurnsKeyframes { + startTransform: string; + endTransform: string; + duration: number; +} + +interface KenBurnsInput { + faces: Faces[]; + fallbackFaces: Faces[]; + smartCropCenter: Point | undefined; + contentWidth: number; + contentHeight: number; + containerWidth: number; + containerHeight: number; + slideshowLook: SlideshowLook; + isCoverMode: boolean; + slideshowDelay: number; + assetId: string; +} + +export async function computeSmartCropCenter( + imgRef: HTMLImageElement, + faces: Faces[], + containerWidth: number, + containerHeight: number, +): Promise { + if (!TUNABLES.KEN_BURNS.SMARTCROP) { + return undefined; + } + if (!TUNABLES.KEN_BURNS.FACE_BOOST && faces.length > 0) { + return undefined; + } + + const boosts = + TUNABLES.KEN_BURNS.FACE_BOOST && 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; + + const result = await smartcrop.crop(imgRef, { + width: containerWidth, + height: containerHeight, + ...(boosts && { boost: boosts }), + }); + + const { x, y, width, height } = result.topCrop; + return { + x: (x + width / 2) / imgRef.naturalWidth, + y: (y + height / 2) / imgRef.naturalHeight, + }; +} + +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 function computeKenBurnsKeyframes({ + faces, + fallbackFaces, + smartCropCenter, + contentWidth, + contentHeight, + containerWidth, + containerHeight, + slideshowLook, + isCoverMode, + slideshowDelay, + assetId, +}: KenBurnsInput): KenBurnsKeyframes { + const blurredBackground = slideshowLook === SlideshowLook.BlurredBackground; + + const minZoom = + !blurredBackground && contentWidth > 0 && contentHeight > 0 + ? Math.min(Math.max(containerWidth / contentWidth, containerHeight / contentHeight), 2) + : 1; + + const slideDurationMs = slideshowDelay * 1000; + const maxZoomChange = KEN_BURNS_MAX_ZOOM_SPEED * (slideDurationMs / 1000); + + const face = selectKenBurnsFace(faces) ?? selectKenBurnsFace(fallbackFaces); + + let targetScale: number; + let endX = 0; + let endY = 0; + + if (face && contentWidth > 0) { + const faceHeightFraction = (face.boundingBoxY2 - face.boundingBoxY1) / face.imageHeight; + const faceTargetZoom = (0.4 * containerHeight) / (faceHeightFraction * contentHeight); + targetScale = clamp(faceTargetZoom, Math.max(minZoom, 1.2), 2); + targetScale = clamp(targetScale, Math.max(minZoom, 1.2), minZoom + maxZoomChange); + + const targetNormalizedX = + TUNABLES.KEN_BURNS.FACE_BOOST && smartCropCenter + ? smartCropCenter.x + : (face.boundingBoxX1 + face.boundingBoxX2) / 2 / face.imageWidth; + const targetNormalizedY = + TUNABLES.KEN_BURNS.FACE_BOOST && smartCropCenter + ? smartCropCenter.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); + targetScale = clamp(targetScale, Math.max(minZoom, 1.2), minZoom + maxZoomChange); + + if (smartCropCenter && contentWidth > 0) { + endX = (((0.5 - smartCropCenter.x) * contentWidth) / containerWidth) * 100; + endY = (((0.5 - smartCropCenter.y) * contentHeight) / containerHeight) * 100; + } + } + + 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); + + 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; + } + + const zoomIn = Number.parseInt(assetId.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%)`; + + return { startTransform, endTransform, duration: slideDurationMs }; +} + +export class KenBurnsAnimation { + #animation: Animation | undefined; + #element: HTMLElement | undefined; + #cancelToken: { value: boolean } | undefined; + + async startWithSmartCrop( + element: HTMLElement, + input: Omit & { imgRef: HTMLImageElement }, + ) { + this.cancel(); + const token = { value: false }; + this.#cancelToken = token; + + const { imgRef, faces, fallbackFaces, containerWidth, containerHeight, ...rest } = input; + const allFaces = faces.length > 0 ? faces : fallbackFaces; + const smartCropCenter = await computeSmartCropCenter(imgRef, allFaces, containerWidth, containerHeight); + + if (token.value) { + return; + } + + const keyframes = computeKenBurnsKeyframes({ + faces, + fallbackFaces, + smartCropCenter, + containerWidth, + containerHeight, + ...rest, + }); + this.start(element, keyframes); + } + + start(element: HTMLElement, { startTransform, endTransform, duration }: KenBurnsKeyframes) { + this.cancel(); + this.#element = element; + + element.style.transformOrigin = '50% 50%'; + element.style.transform = startTransform; + + const keyframes: Keyframe[] = [{ transform: startTransform, easing: 'ease-in-out' }, { transform: endTransform }]; + this.#animation = element.animate(keyframes, { duration, fill: 'forwards' }); + } + + freeze() { + if (!this.#animation || !this.#element) { + return; + } + const frozen = getComputedStyle(this.#element).transform; + this.#animation.cancel(); + this.#animation = undefined; + if (frozen && frozen !== 'none') { + this.#element.style.transform = frozen; + } + } + + pause() { + this.#animation?.pause(); + } + + resume() { + this.#animation?.play(); + } + + cancel() { + if (this.#cancelToken) { + this.#cancelToken.value = true; + this.#cancelToken = undefined; + } + this.#animation?.cancel(); + this.#animation = undefined; + this.#element?.style.removeProperty('transform-origin'); + this.#element = undefined; + } +} diff --git a/web/src/lib/utils/transition-utils.ts b/web/src/lib/utils/transition-utils.ts index 006272378d..3aff784161 100644 --- a/web/src/lib/utils/transition-utils.ts +++ b/web/src/lib/utils/transition-utils.ts @@ -58,12 +58,15 @@ export async function crossfadeViewerContent(updateFn: () => void | Promise