@@ -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 } = slideshowStor e;
const objectFit = $derived (
$slideshowState !== SlideshowState . None && $slideshowLook === SlideshowLook . Cover ? 'cover' : 'contain' ,
) ;
const SMARTCROP_ENABLED = tru e;
// 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 ;