diff --git a/i18n/en.json b/i18n/en.json index 956ed03989..c56e383ee9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2159,6 +2159,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 9d47ba73f8..b52d0be21a 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 @@ -10901,6 +10904,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==} @@ -24101,6 +24107,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 9c63b2e5f5..ab0f2c78bb 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 ba14f35eac..bdd1b62338 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -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(); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index d094fdeec5..851399cb0a 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -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(); + 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(); - const faceToNameMap = $derived.by(() => { // eslint-disable-next-line svelte/prefer-svelte-reactivity const map = new Map(); @@ -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(); + } + }); @@ -207,7 +401,7 @@ {asset} {sharedLink} {container} - {objectFit} + objectFit={isCoverMode ? 'cover' : 'contain'} {onUrlChange} onImageReady={() => { visibleImageReady = true; diff --git a/web/src/lib/managers/ViewTransitionManager.svelte.ts b/web/src/lib/managers/ViewTransitionManager.svelte.ts index 74eaa43752..3caf1d1f88 100644 --- a/web/src/lib/managers/ViewTransitionManager.svelte.ts +++ b/web/src/lib/managers/ViewTransitionManager.svelte.ts @@ -95,6 +95,7 @@ export function startViewerTransition( types: ['viewer'], prepareOldSnapshot: () => { setTransitionId(assetId); + eventManager.emit('ViewTransitionOldSnapshotPending'); }, performUpdate: async () => { setTransitionId(null); diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 5581a802c3..a0aaa4572f 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -81,6 +81,7 @@ export type Events = { TransitionToAssetViewer: []; TransitionToTimeline: [{ id: string }]; TransitionToTimelineReady: []; + ViewTransitionOldSnapshotPending: []; UserAdminCreate: [UserAdminResponseDto]; // soft deleted 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/people-utils.ts b/web/src/lib/utils/people-utils.ts index 87b941eee1..36616bbbf2 100644 --- a/web/src/lib/utils/people-utils.ts +++ b/web/src/lib/utils/people-utils.ts @@ -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[] = [];