Compare commits

..

2 Commits

Author SHA1 Message Date
midzelis 748e5dfcd5 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
2026-03-24 15:52:32 +00:00
midzelis 148f7a7fdb feat(web): hero view transitions for memory viewer
Change-Id: I6221557a6b8561122baccbc651a48ae46a6a6964
2026-03-24 15:43:06 +00:00
19 changed files with 1016 additions and 329 deletions
+1
View File
@@ -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",
+8
View File
@@ -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
+1
View File
@@ -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",
+242 -29
View File
@@ -328,8 +328,189 @@
}
::view-transition-new(hero) {
animation: none;
align-content: center;
}
::view-transition-old(memory-overlay),
::view-transition-old(memory-controls),
::view-transition-new(memory-overlay),
::view-transition-new(memory-controls) {
width: 100%;
height: 100%;
object-fit: none;
object-position: left top;
}
html:active-view-transition-type(memory) {
&::view-transition-group(hero),
&::view-transition-group(hero-out) {
animation-duration: var(--vt-duration-memory);
animation-timing-function: var(--vt-memory-easing);
overflow: hidden;
z-index: 1;
}
&::view-transition-group(memory-overlay),
&::view-transition-group(memory-controls) {
animation: none;
z-index: 5;
}
&::view-transition-group(memory-overlay-prev),
&::view-transition-group(memory-overlay-next) {
animation: none;
z-index: 2;
opacity: 0.25;
}
&::view-transition-image-pair(memory-overlay),
&::view-transition-image-pair(memory-controls) {
isolation: auto;
}
&::view-transition-old(memory-overlay),
&::view-transition-old(memory-controls) {
animation: 120ms linear fadeOut forwards;
}
&::view-transition-new(memory-overlay),
&::view-transition-new(memory-controls) {
animation: 200ms linear calc(var(--vt-duration-memory) - 200ms) fadeIn forwards;
opacity: 0;
}
&::view-transition-old(memory-overlay-prev),
&::view-transition-old(memory-overlay-next) {
display: none;
}
&::view-transition-new(memory-overlay-prev),
&::view-transition-new(memory-overlay-next) {
animation: none;
width: 100%;
height: 100%;
object-fit: none;
object-position: left top;
}
&::view-transition-image-pair(hero) {
isolation: auto;
}
&::view-transition-old(hero) {
display: none;
}
&::view-transition-new(hero) {
animation: none;
object-fit: cover;
width: 100%;
height: 100%;
}
&::view-transition-image-pair(hero-out) {
isolation: auto;
}
&::view-transition-old(hero-out) {
display: none;
}
&::view-transition-new(hero-out) {
animation: var(--vt-duration-memory) var(--vt-memory-easing) dimDown forwards;
object-fit: cover;
width: 100%;
height: 100%;
}
&::view-transition-group(memory-departing) {
animation: none;
}
&::view-transition-old(memory-departing) {
animation: calc(var(--vt-duration-memory) * 0.4) linear fadeFromDim forwards;
}
&::view-transition-new(memory-departing) {
animation: none;
visibility: hidden;
}
}
html:active-view-transition-type(memory-enter) {
&::view-transition-group(hero) {
animation-duration: var(--vt-duration-hero);
animation-timing-function: var(--vt-memory-easing);
overflow: hidden;
}
&::view-transition-old(hero),
&::view-transition-new(hero) {
animation: none;
object-fit: cover;
width: 100%;
height: 100%;
}
&::view-transition-group(memory-overlay),
&::view-transition-group(memory-controls),
&::view-transition-group(memory-nav-buttons) {
animation: none;
z-index: 5;
}
&::view-transition-old(memory-overlay),
&::view-transition-old(memory-controls),
&::view-transition-old(memory-nav-buttons) {
animation: none;
visibility: hidden;
}
&::view-transition-new(memory-overlay),
&::view-transition-new(memory-controls),
&::view-transition-new(memory-nav-buttons) {
animation: 200ms linear var(--vt-duration-hero) fadeIn forwards;
opacity: 0;
}
}
::view-transition-old(memory-fade-out) {
animation: 500ms linear crossfadeOut forwards;
}
::view-transition-new(memory-fade-in) {
animation: 500ms linear crossfadeIn forwards;
}
html:active-view-transition-type(memory-nav-fast) {
&::view-transition-old(memory-fade-out) {
animation-duration: 250ms;
}
&::view-transition-new(memory-fade-in) {
animation-duration: 250ms;
}
&::view-transition-old(memory-overlay),
&::view-transition-old(memory-controls) {
animation-duration: 100ms;
}
&::view-transition-new(memory-overlay),
&::view-transition-new(memory-controls) {
animation: 100ms linear 150ms fadeIn forwards;
opacity: 0;
}
}
html:active-view-transition-type(memory-nav) {
&::view-transition-group(memory-overlay),
&::view-transition-group(memory-controls) {
animation: none;
z-index: 5;
}
&::view-transition-image-pair(memory-overlay),
&::view-transition-image-pair(memory-controls) {
isolation: auto;
}
&::view-transition-old(memory-overlay),
&::view-transition-old(memory-controls) {
animation: 150ms linear fadeOut forwards;
}
&::view-transition-new(memory-overlay),
&::view-transition-new(memory-controls) {
animation: 200ms linear 300ms fadeIn forwards;
opacity: 0;
}
&::view-transition-group(memory-overlay-prev),
&::view-transition-group(memory-overlay-next) {
animation: none;
opacity: 0.25;
}
&::view-transition-old(memory-overlay-prev),
&::view-transition-old(memory-overlay-next) {
display: none;
}
&::view-transition-new(memory-overlay-prev),
&::view-transition-new(memory-overlay-next) {
animation: none;
}
}
::view-transition-old(next),
::view-transition-old(next-old),
::view-transition-new(next),
@@ -381,6 +562,24 @@
z-index: -1;
}
@keyframes fadeFromDim {
from {
opacity: 0.25;
}
to {
opacity: 0;
}
}
@keyframes dimDown {
from {
opacity: 1;
}
to {
opacity: 0.25;
}
}
@keyframes flyInLeft {
from {
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
@@ -539,36 +738,50 @@
background-color: transparent;
}
::view-transition-group(previous),
::view-transition-group(previous-old),
::view-transition-group(next),
::view-transition-group(next-old) {
width: 100% !important;
height: 100% !important;
transform: none !important;
html:active-view-transition-type(viewer-nav) {
&::view-transition-group(previous),
&::view-transition-group(previous-old),
&::view-transition-group(next),
&::view-transition-group(next-old) {
width: 100% !important;
height: 100% !important;
transform: none !important;
}
&::view-transition-old(previous),
&::view-transition-old(previous-old),
&::view-transition-old(next),
&::view-transition-old(next-old) {
animation: var(--vt-duration-viewer-navigation) fadeOut forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
overflow: hidden;
}
&::view-transition-new(previous),
&::view-transition-new(previous-new),
&::view-transition-new(next),
&::view-transition-new(next-new) {
animation: var(--vt-duration-viewer-navigation) fadeIn forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
}
}
::view-transition-old(previous),
::view-transition-old(previous-old),
::view-transition-old(next),
::view-transition-old(next-old) {
animation: var(--vt-duration-viewer-navigation) fadeOut forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
overflow: hidden;
}
::view-transition-new(previous),
::view-transition-new(previous-new),
::view-transition-new(next),
::view-transition-new(next-new) {
animation: var(--vt-duration-viewer-navigation) fadeIn forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
html:active-view-transition-type(memory-enter) {
&::view-transition-group(hero) {
animation-duration: 0s;
}
&::view-transition-old(hero) {
animation: var(--vt-duration-default) fadeOut forwards;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) fadeIn forwards;
}
}
}
}
@@ -1,6 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
@@ -18,19 +17,15 @@
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
import { Route } from '$lib/route';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { navigateToTimeline } from '$lib/utils/transition-utils';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions } from '$lib/services/asset.service';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { user } from '$lib/stores/user.store';
import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { isPhotosRoute } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetTypeEnum,
@@ -189,21 +184,7 @@
{#if !asset.isArchived && !asset.isTrashed}
<MenuOption
icon={mdiImageSearch}
onClick={async () => {
const assetId = stack?.primaryAssetId ?? asset.id;
if (isPhotosRoute(page.route.id) && viewTransitionManager.isSupported()) {
const transitionReady = eventManager.untilNext('ViewerCloseTransitionReady');
eventManager.emit('ViewerCloseTransition', { id: assetId });
await transitionReady;
await goto(Route.photos({ at: assetId }));
return;
}
navigateToTimeline(assetId, {
types: ['timeline'],
prepareOldSnapshot: () => eventManager.emit('ViewerOpenTransition'),
});
}}
onClick={() => goto(Route.photos({ at: stack?.primaryAssetId ?? asset.id }))}
text={$t('view_in_timeline')}
/>
{/if}
@@ -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;
@@ -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<HTMLDivElement | undefined>();
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<Size>({ width: 0, height: 0 });
let scaledDimensions = $state<Size>({ width: 0, height: 0 });
@@ -147,8 +158,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 | undefined>();
@@ -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();
}
});
</script>
<AssetViewerEvents {onCopy} {onZoom} />
@@ -194,7 +244,7 @@
{asset}
{sharedLink}
{container}
{objectFit}
objectFit={isCoverMode ? 'cover' : 'contain'}
{onUrlChange}
onImageReady={() => {
visibleImageReady = true;
@@ -1,59 +1,32 @@
<script lang="ts">
import { assetViewerFadeDuration } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getAssetMediaUrl } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize } from '@immich/sdk';
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
import type { Size } from '$lib/utils/container-utils';
import type { AssetResponseDto } from '@immich/sdk';
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
interface Props {
asset: TimelineAsset;
onImageLoad: () => void;
asset: AssetResponseDto;
transitionName?: string;
onImageLoad: () => void;
onError?: () => void;
}
const { asset, onImageLoad, transitionName }: Props = $props();
const { asset, transitionName, onImageLoad, onError }: Props = $props();
let assetFileUrl: string = $state('');
let imageLoaded: boolean = $state(false);
let loader = $state<HTMLImageElement>();
let containerWidth = $state(0);
let containerHeight = $state(0);
const onLoadCallback = () => {
imageLoaded = true;
assetFileUrl = imageLoaderUrl;
onImageLoad();
};
onMount(() => {
if (loader?.complete) {
onLoadCallback();
}
loader?.addEventListener('load', onLoadCallback);
return () => {
loader?.removeEventListener('load', onLoadCallback);
};
});
const imageLoaderUrl = $derived(getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview }));
const container: Size = $derived({ width: containerWidth, height: containerHeight });
</script>
{#if !imageLoaded}
<!-- svelte-ignore a11y_missing_attribute -->
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
{/if}
{#if !imageLoaded}
<DelayedLoadingSpinner />
{:else if imageLoaded}
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
<img
class="h-full w-full rounded-2xl object-contain transition-all"
src={assetFileUrl}
alt={$getAltText(asset)}
draggable="false"
style:view-transition-name={transitionName}
/>
</div>
{/if}
<div
class="relative h-full w-full overflow-hidden"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
{#if containerWidth > 0 && containerHeight > 0}
<AdaptiveImage {asset} {container} {transitionName} showLetterboxes={false} onImageReady={onImageLoad} {onError} />
{:else}
<DelayedLoadingSpinner />
{/if}
</div>
@@ -20,17 +20,19 @@
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { QueryParameter } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { navigateToTimeline } from '$lib/utils/transition-utils';
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
import { ActionButton, IconButton, toastManager } from '@immich/ui';
@@ -53,6 +55,7 @@
} from '@mdi/js';
import type { NavigationTarget, Page } from '@sveltejs/kit';
import { DateTime } from 'luxon';
import { tick } from 'svelte';
import { t } from 'svelte-i18n';
import type { Attachment } from 'svelte/attachments';
import { Tween } from 'svelte/motion';
@@ -65,6 +68,7 @@
let paused = $state(false);
let current = $state<MemoryAsset | undefined>(undefined);
const currentAssetId = $derived(current?.asset.id);
const currentAssetDto = $derived(current ? current.memory.assets[current.assetIndex] : undefined);
const currentMemoryAssetFull = $derived.by(async () =>
currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined,
);
@@ -77,6 +81,14 @@
let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0);
let transition = $state({
name: undefined as string | undefined,
previousPanel: undefined as string | undefined,
nextPanel: undefined as string | undefined,
active: false,
});
const showTransitionOverlays = $derived(transition.active || transition.name === 'hero');
const showNavButtonOverlay = $derived(transition.name === 'hero');
const { isViewing } = assetViewingStore;
const viewport: Viewport = $state({ width: 0, height: 0 });
@@ -87,18 +99,6 @@
let videoPlayer: HTMLVideoElement | undefined = $state();
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
const handleNavigate = async (asset?: { id: string }) => {
if ($isViewing) {
return asset;
}
if (!asset) {
return;
}
await goto(asHref(asset));
};
const setProgressDuration = (asset: TimelineAsset) => {
if (asset.isVideo) {
const timeParts = asset.duration!.split(':').map(Number);
@@ -113,11 +113,177 @@
}
};
const handleNextAsset = () => handleNavigate(current?.next?.asset);
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset);
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
const handleEscape = async () => goto(Route.photos());
const scrollToTop = () => {
if (window.scrollY === 0) {
return Promise.resolve();
}
window.scrollTo({ top: 0, behavior: 'smooth' });
return new Promise<void>((resolve) => {
const timeout = setTimeout(resolve, 500);
window.addEventListener(
'scrollend',
() => {
clearTimeout(timeout);
resolve();
},
{ once: true },
);
});
};
const withMemoryTransition = async (
asset: { id: string } | undefined,
config: Omit<Parameters<typeof viewTransitionManager.startTransition>[0], 'onFinished'> & {
onFinished?: () => void;
},
) => {
if ($isViewing || !asset) {
return;
}
await scrollToTop();
transition.active = true;
viewTransitionManager
.startTransition({
...config,
onFinished: () => {
transition.previousPanel = undefined;
transition.nextPanel = undefined;
transition.name = undefined;
transition.active = false;
config.onFinished?.();
},
})
.catch((error: unknown) => console.error('[Memory] transition failed:', error));
};
const navigateWithTransition = (asset?: { id: string }) =>
withMemoryTransition(asset, {
types: ['memory-nav'],
prepareOldSnapshot: () => {
transition.name = 'memory-fade-out';
},
performUpdate: async () => {
await goto(asHref(asset!));
await eventManager.untilNext('ViewerOpenTransitionReady');
},
prepareNewSnapshot: () => {
transition.name = 'memory-fade-in';
},
});
const handleNextAsset = () => {
const next = current?.next;
if (next && next.memory.id !== current?.memory.id) {
void navigateToMemory('next', next.asset);
} else {
void navigateWithTransition(next?.asset);
}
};
const handlePreviousAsset = () => {
const previous = current?.previous;
if (previous && previous.memory.id !== current?.memory.id) {
void navigateToMemory('previous', previous.asset);
} else {
void navigateWithTransition(previous?.asset);
}
};
const navigateToMemory = (direction: 'next' | 'previous', asset?: { id: string }) => {
const isNext = direction === 'next';
const useHeroMorph = !mediaQueryManager.reducedMotion;
return withMemoryTransition(asset, {
types: ['memory'],
prepareOldSnapshot: () => {
if (useHeroMorph) {
if (isNext) {
transition.nextPanel = 'hero';
transition.previousPanel = 'memory-departing';
} else {
transition.previousPanel = 'hero';
transition.nextPanel = 'memory-departing';
}
transition.name = 'hero-out';
} else {
transition.name = 'memory-fade-out';
}
},
performUpdate: async () => {
transition.nextPanel = undefined;
transition.previousPanel = undefined;
if (useHeroMorph) {
if (isNext) {
transition.previousPanel = 'hero-out';
} else {
transition.nextPanel = 'hero-out';
}
}
transition.name = useHeroMorph ? 'hero' : 'memory-fade-in';
await goto(asHref(asset!));
await eventManager.untilNext('ViewerOpenTransitionReady');
},
});
};
const handleNextMemory = () => void navigateToMemory('next', current?.nextMemory?.assets[0]);
const handlePreviousMemory = () => void navigateToMemory('previous', current?.previousMemory?.assets[0]);
const closeMemoryViewer = () => {
if (current && current.assetIndex > 0 && !mediaQueryManager.reducedMotion) {
const firstAsset = current.memory.assets[0];
void withMemoryTransition(firstAsset, {
types: ['memory-nav', 'memory-nav-fast'],
prepareOldSnapshot: () => {
transition.name = 'memory-fade-out';
},
performUpdate: async () => {
await goto(asHref(firstAsset));
await eventManager.untilNext('ViewerOpenTransitionReady');
},
prepareNewSnapshot: () => {
transition.name = 'memory-fade-in';
},
onFinished: () => closeToTimeline(),
});
} else {
closeToTimeline();
}
};
const closeToTimeline = () => {
const memoryId = current?.memory.id;
let cardImage: HTMLElement | null | undefined;
void viewTransitionManager.startTransition({
types: ['memory-enter'],
prepareOldSnapshot: () => {
transition.name = 'hero';
},
performUpdate: async () => {
transition.name = undefined;
await goto(Route.photos());
await tick();
const memoryCard = memoryId
? document.querySelector<HTMLElement>(`[data-memory-id="${CSS.escape(memoryId)}"]`)
: null;
memoryCard?.scrollIntoView({ behavior: 'instant', inline: 'nearest', block: 'nearest' });
cardImage = memoryCard?.querySelector<HTMLElement>('img');
if (cardImage) {
cardImage.style.viewTransitionName = 'hero';
await tick();
}
},
onFinished: () => {
if (cardImage) {
cardImage.style.viewTransitionName = '';
cardImage = null;
}
},
});
};
const handleEscape = closeMemoryViewer;
const handleSelectAll = () =>
assetInteraction.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
@@ -161,13 +327,17 @@
}
};
const handleProgress = async (progress: number) => {
const handleProgress = (progress: number) => {
if (!progressBarController) {
return;
}
if (progress === 1 && !paused) {
await (current?.next ? handleNextAsset() : handlePromiseError(handleAction('handleProgressLast', 'pause')));
if (progress === 1 && !paused && !transition.active) {
if (current?.next) {
handleNextAsset();
} else {
handlePromiseError(handleAction('handleProgressLast', 'pause'));
}
}
};
@@ -227,20 +397,6 @@
handlePromiseError(handleAction('galleryInView', 'pause'));
};
let memoryTransitionName = $state<string | undefined>(undefined);
const viewInTimeline = (assetId: string) => {
navigateToTimeline(assetId, {
types: ['memory-enter'],
prepareOldSnapshot: () => {
memoryTransitionName = 'hero';
},
onFinished: () => {
memoryTransitionName = undefined;
},
});
};
const handleGalleryScrollsOutOfView = () => {
galleryInView = false;
// only call play after the first page load. When page first loads the gallery will not be visible
@@ -285,7 +441,18 @@
playerInitialized = false;
};
const resetAndPlay = () => {
const resolveTransitionIfPending = () => {
if (viewTransitionManager.activeViewTransition) {
transition.name = 'hero';
eventManager.emit('ViewerOpenTransitionReady');
requestAnimationFrame(() => {
transition.name = undefined;
});
}
};
const handleMemoryImageReady = () => {
resolveTransitionIfPending();
handlePromiseError(handleAction('resetAndPlay', 'reset'));
handlePromiseError(handleAction('resetAndPlay', 'play'));
};
@@ -300,7 +467,7 @@
handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
} else if (isVideo) {
// Image assets will start playing when the image is loaded. Only autostart video assets.
resetAndPlay();
handleMemoryImageReady();
}
playerInitialized = true;
};
@@ -328,7 +495,7 @@
$effect(() => {
if (progressBarController) {
handlePromiseError(handleProgress(progressBarController.current));
handleProgress(progressBarController.current);
}
});
@@ -397,7 +564,7 @@
bind:clientWidth={viewport.width}
>
{#if current}
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow>
<ControlAppBar onClose={closeMemoryViewer} forceDark multiRow>
{#snippet leading()}
{#if current}
<p class="text-lg">
@@ -473,7 +640,11 @@
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] md:h-[calc(100vh-180px)] w-[300%] items-center justify-center gap-10 overflow-hidden"
>
<!-- PREVIOUS MEMORY -->
<div class="h-1/2 w-[20vw] rounded-2xl {current.previousMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
<div
class="h-1/2 w-[20vw] rounded-2xl opacity-25 transition-opacity duration-150 hover:opacity-70 {current.previousMemory
? ''
: 'opacity-0!'}"
>
<button
type="button"
class="relative h-full w-full rounded-2xl"
@@ -486,6 +657,7 @@
src={getAssetMediaUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
alt={$t('previous_memory')}
draggable="false"
style:view-transition-name={transition.previousPanel}
/>
{:else}
<enhanced:img
@@ -498,7 +670,10 @@
{/if}
{#if current.previousMemory}
<div class="absolute bottom-4 end-4 text-start text-white">
<div
class="absolute bottom-4 end-4 text-start text-white"
style:view-transition-name={transition.active ? 'memory-overlay-prev' : undefined}
>
<p class="uppercase text-xs font-semibold text-gray-200">{$t('previous')}</p>
<p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p>
</div>
@@ -507,43 +682,42 @@
</div>
<!-- CURRENT MEMORY -->
<div
class="main-view relative flex h-full w-[70vw] place-content-center place-items-center rounded-2xl bg-black"
>
<div class="relative h-full w-full rounded-2xl bg-black">
{#key current.asset.id}
{#if current.asset.isVideo}
<MemoryVideoViewer
asset={current.asset}
bind:videoPlayer
videoViewerMuted={$videoViewerMuted}
videoViewerVolume={$videoViewerVolume}
/>
{:else}
<MemoryPhotoViewer
asset={current.asset}
onImageLoad={resetAndPlay}
transitionName={memoryTransitionName}
/>
{/if}
{/key}
<div class="main-view relative isolate h-full w-[70vw] rounded-2xl bg-black">
{#key current.asset.id}
{#if current.asset.isVideo}
<MemoryVideoViewer
asset={current.asset}
bind:videoPlayer
videoViewerMuted={$videoViewerMuted}
videoViewerVolume={$videoViewerVolume}
/>
{:else if currentAssetDto}
<MemoryPhotoViewer
asset={currentAssetDto}
transitionName={transition.name}
onImageLoad={handleMemoryImageReady}
onError={resolveTransitionIfPending}
/>
{/if}
{/key}
<div
class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark"
class:opacity-0={galleryInView}
class:opacity-100={!galleryInView}
>
<div class="flex items-center">
<IconButton
icon={isSaved ? mdiHeart : mdiHeartOutline}
shape="round"
variant="ghost"
color="secondary"
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
onclick={() => handleSaveMemory()}
class="w-12 h-12"
/>
<!-- <IconButton
<div
class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark"
class:opacity-0={galleryInView}
class:opacity-100={!galleryInView}
style:view-transition-name={showTransitionOverlays ? 'memory-controls' : undefined}
>
<div class="flex items-center">
<IconButton
icon={isSaved ? mdiHeart : mdiHeartOutline}
shape="round"
variant="ghost"
color="secondary"
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
onclick={() => handleSaveMemory()}
class="w-12 h-12"
/>
<!-- <IconButton
icon={mdiShareVariantOutline}
shape="round"
variant="ghost"
@@ -551,46 +725,46 @@
color="secondary"
aria-label={$t('share')}
/> -->
<ButtonContextMenu
icon={mdiDotsVertical}
title={$t('menu')}
onclick={() => handlePromiseError(handleAction('ContextMenuClick', 'pause'))}
direction="left"
size="medium"
align="bottom-right"
>
<MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
<MenuOption
onClick={() => handleDeleteMemoryAsset()}
text={$t('remove_photo_from_memory')}
icon={mdiImageMinusOutline}
/>
<!-- shortcut={{ key: 'l', shift: shared }} -->
</ButtonContextMenu>
</div>
<div>
{#await currentMemoryAssetFull then asset}
{#if asset}
<IconButton
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
icon={mdiImageSearch}
aria-label={$t('view_in_timeline')}
color="secondary"
variant="ghost"
shape="round"
onclick={(event: MouseEvent) => {
event.preventDefault();
viewInTimeline(asset.stack?.primaryAssetId ?? asset.id);
}}
/>
{/if}
{/await}
</div>
<ButtonContextMenu
icon={mdiDotsVertical}
title={$t('menu')}
onclick={() => handlePromiseError(handleAction('ContextMenuClick', 'pause'))}
direction="left"
size="medium"
align="bottom-right"
>
<MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
<MenuOption
onClick={() => handleDeleteMemoryAsset()}
text={$t('remove_photo_from_memory')}
icon={mdiImageMinusOutline}
/>
<!-- shortcut={{ key: 'l', shift: shared }} -->
</ButtonContextMenu>
</div>
<!-- CONTROL BUTTONS -->
<div>
{#await currentMemoryAssetFull then asset}
{#if asset}
<IconButton
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
icon={mdiImageSearch}
aria-label={$t('view_in_timeline')}
color="secondary"
variant="ghost"
shape="round"
/>
{/if}
{/await}
</div>
</div>
<!-- CONTROL BUTTONS -->
<div
class="absolute inset-0 pointer-events-none"
style:view-transition-name={showNavButtonOverlay ? 'memory-nav-buttons' : undefined}
>
{#if current.previous}
<div class="absolute top-1/2 start-0 ms-4 dark">
<div class="absolute top-1/2 inset-s-0 ms-4 dark pointer-events-auto">
<IconButton
shape="round"
aria-label={$t('previous_memory')}
@@ -604,7 +778,7 @@
{/if}
{#if current.next}
<div class="absolute top-1/2 end-0 me-4 dark">
<div class="absolute top-1/2 inset-e-0 me-4 dark pointer-events-auto">
<IconButton
shape="round"
aria-label={$t('next_memory')}
@@ -616,25 +790,32 @@
/>
</div>
{/if}
</div>
<div class="absolute start-8 top-4 text-sm font-medium text-white">
<p>
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
locale: $locale,
})}
</p>
<p>
{#await currentMemoryAssetFull then asset}
{asset?.exifInfo?.city || ''}
{asset?.exifInfo?.country || ''}
{/await}
</p>
</div>
<div
class="absolute start-8 top-4 text-sm font-medium text-white"
style:view-transition-name={showTransitionOverlays ? 'memory-overlay' : undefined}
>
<p>
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
locale: $locale,
})}
</p>
<p>
{#await currentMemoryAssetFull then asset}
{asset?.exifInfo?.city || ''}
{asset?.exifInfo?.country || ''}
{/await}
</p>
</div>
</div>
<!-- NEXT MEMORY -->
<div class="h-1/2 w-[20vw] rounded-2xl {current.nextMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
<div
class="h-1/2 w-[20vw] rounded-2xl opacity-25 transition-opacity duration-150 hover:opacity-70 {current.nextMemory
? ''
: 'opacity-0!'}"
>
<button
type="button"
class="relative h-full w-full rounded-2xl"
@@ -647,6 +828,7 @@
src={getAssetMediaUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
alt={$t('next_memory')}
draggable="false"
style:view-transition-name={transition.nextPanel}
/>
{:else}
<enhanced:img
@@ -659,7 +841,10 @@
{/if}
{#if current.nextMemory}
<div class="absolute bottom-4 start-4 text-start text-white">
<div
class="absolute bottom-4 start-4 text-start text-white"
style:view-transition-name={transition.active ? 'memory-overlay-next' : undefined}
>
<p class="uppercase text-xs font-semibold text-gray-200">{$t('up_next')}</p>
<p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p>
</div>
@@ -703,8 +888,6 @@
<style>
.main-view {
box-shadow:
0 4px 4px 0 rgba(0, 0, 0, 0.3),
0 8px 12px 6px rgba(0, 0, 0, 0.15);
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.15));
}
</style>
@@ -218,14 +218,11 @@
scrolled = await scrollAndLoadAsset(scrollTarget);
}
if (!scrolled) {
// if the asset is not found, scroll to the top
timelineManager.scrollTo(0);
if (scrollTarget) {
eventManager.emit('TimelineScrolledToAsset', { id: scrollTarget });
}
} else if (scrollTarget) {
await tick();
focusAsset(scrollTarget);
eventManager.emit('TimelineScrolledToAsset', { id: scrollTarget });
}
invisible = isAssetViewerRoute(page) ? true : false;
};
@@ -48,21 +48,34 @@ describe('ViewTransitionManager', () => {
});
describe('when a transition is already active', () => {
it('should skip the second transition', async () => {
let resolveFinished!: () => void;
const finished = new Promise<void>((resolve) => {
resolveFinished = resolve;
});
let resolveUpdate!: () => void;
const updateCallbackDone = new Promise<void>((resolve) => {
resolveUpdate = resolve;
it('should skip the first transition and run the second', async () => {
let resolveFirstUpdate!: () => void;
const firstUpdateCallbackDone = new Promise<void>((resolve) => {
resolveFirstUpdate = resolve;
});
const firstFinished = new Promise<void>(() => {});
const firstSkipTransition = vi.fn();
let callCount = 0;
// eslint-disable-next-line tscompat/tscompat
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
callCount++;
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
void updateFn();
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
if (callCount === 1) {
return {
updateCallbackDone: firstUpdateCallbackDone,
finished: firstFinished,
ready: Promise.resolve(),
skipTransition: firstSkipTransition,
};
}
return {
updateCallbackDone: Promise.resolve(),
finished: Promise.resolve(),
ready: Promise.resolve(),
skipTransition: vi.fn(),
};
});
const secondPerformUpdate = vi.fn().mockResolvedValue(undefined);
@@ -75,13 +88,13 @@ describe('ViewTransitionManager', () => {
// Flush microtasks so the first transition reaches the startViewTransition call
await new Promise<void>((r) => queueMicrotask(r));
// While first is active, try a second — should be skipped
// While first is active, start a second — should skip the first and proceed
await manager.startTransition({ performUpdate: secondPerformUpdate });
expect(secondPerformUpdate).not.toHaveBeenCalled();
expect(firstSkipTransition).toHaveBeenCalledOnce();
expect(secondPerformUpdate).toHaveBeenCalledOnce();
// Clean up
resolveUpdate();
resolveFinished();
// Clean up first promise
resolveFirstUpdate();
await firstPromise;
});
});
@@ -10,6 +10,7 @@ interface TransitionRequest {
export class ViewTransitionManager {
#activeViewTransition = $state<ViewTransition | null>(null);
#activeOnFinished: (() => void) | undefined;
get activeViewTransition() {
return this.#activeViewTransition;
@@ -23,6 +24,9 @@ export class ViewTransitionManager {
const skipped = !!this.#activeViewTransition;
this.#activeViewTransition?.skipTransition();
this.#activeViewTransition = null;
const onFinished = this.#activeOnFinished;
this.#activeOnFinished = undefined;
onFinished?.();
return skipped;
}
@@ -34,7 +38,7 @@ export class ViewTransitionManager {
onFinished,
}: TransitionRequest) {
if (this.#activeViewTransition) {
return;
this.skipTransitions();
}
if (!this.isSupported()) {
@@ -68,6 +72,7 @@ export class ViewTransitionManager {
}
this.#activeViewTransition = transition;
this.#activeOnFinished = onFinished;
// eslint-disable-next-line tscompat/tscompat
void transition.ready.catch((error: unknown) => {
@@ -80,8 +85,11 @@ export class ViewTransitionManager {
void transition.finished
.catch(() => {})
.finally(() => {
this.#activeViewTransition = null;
onFinished?.();
if (this.#activeViewTransition === transition) {
this.#activeViewTransition = null;
this.#activeOnFinished = undefined;
onFinished?.();
}
});
// Wait only until the DOM update completes (both snapshots captured),
@@ -98,6 +98,7 @@ export type Events = {
ViewerCloseTransitionReady: [];
ViewerOpenTransition: [];
ViewerOpenTransitionReady: [];
ViewTransitionOldSnapshotPending: [];
};
export const eventManager = new BaseEventManager<Events>();
@@ -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>
+2
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,
};
}
+237
View File
@@ -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<Point | undefined> {
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<KenBurnsInput, 'smartCropCenter'> & { 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;
}
}
+24 -64
View File
@@ -1,18 +1,17 @@
import { goto } from '$app/navigation';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { Route } from '$lib/route';
import { tick } from 'svelte';
export function startViewerTransition(
assetId: string,
function startHeroTransition(
type: string,
id: string,
navigate: () => void,
setTransitionId: (id: string | null) => void,
) {
void viewTransitionManager.startTransition({
types: ['viewer'],
types: [type],
prepareOldSnapshot: () => {
setTransitionId(assetId);
setTransitionId(id);
},
performUpdate: async (signal) => {
setTransitionId(null);
@@ -25,6 +24,22 @@ export function startViewerTransition(
});
}
export function startViewerTransition(
assetId: string,
navigate: () => void,
setTransitionId: (id: string | null) => void,
) {
startHeroTransition('viewer', assetId, navigate, setTransitionId);
}
export function startMemoryTransition(
memoryId: string,
navigate: () => void,
setTransitionId: (id: string | null) => void,
) {
startHeroTransition('memory-enter', memoryId, navigate, setTransitionId);
}
let activeOverlay: HTMLElement | undefined;
export function removeCrossfadeOverlay() {
@@ -34,64 +49,6 @@ export function removeCrossfadeOverlay() {
}
}
export function navigateToTimeline(
assetId: string,
options: { types: string[]; prepareOldSnapshot?: () => void; onFinished?: () => void },
) {
let heroOverlay: HTMLElement | null = null;
let hiddenElement: HTMLElement | null = null;
void viewTransitionManager.startTransition({
types: options.types,
prepareOldSnapshot: options.prepareOldSnapshot,
performUpdate: async () => {
const scrolled = eventManager.untilNext('TimelineScrolledToAsset');
await goto(Route.photos({ at: assetId }));
await scrolled;
await tick();
const element = document.querySelector<HTMLElement>(`[data-asset-id="${CSS.escape(assetId)}"]`);
if (!element) {
return;
}
const rect = element.getBoundingClientRect();
const img = element.querySelector('img');
hiddenElement = element;
element.style.visibility = 'hidden';
heroOverlay = document.createElement('div');
heroOverlay.style.cssText = `
position: fixed;
top: ${rect.top}px;
left: ${rect.left}px;
width: ${rect.width}px;
height: ${rect.height}px;
view-transition-name: hero;
pointer-events: none;
z-index: -1;
overflow: hidden;
`;
if (img?.src) {
heroOverlay.style.backgroundImage = `url("${CSS.escape(img.src)}")`;
heroOverlay.style.backgroundSize = 'cover';
heroOverlay.style.backgroundPosition = 'center';
}
document.body.append(heroOverlay);
},
onFinished: () => {
heroOverlay?.remove();
heroOverlay = null;
if (hiddenElement) {
hiddenElement.style.visibility = '';
hiddenElement = null;
}
options.onFinished?.();
},
});
}
export async function crossfadeViewerContent(updateFn: () => void | Promise<void>, duration = 200) {
const viewerContent = document.querySelector<HTMLElement>('[data-viewer-content]');
if (!viewerContent) {
@@ -101,12 +58,15 @@ export async function crossfadeViewerContent(updateFn: () => void | Promise<void
removeCrossfadeOverlay();
eventManager.emit('ViewTransitionOldSnapshotPending');
const clone = viewerContent.cloneNode(true) as HTMLElement;
Object.assign(clone.style, {
position: 'absolute',
inset: '0',
zIndex: '1',
pointerEvents: 'none',
backgroundColor: 'black',
});
delete clone.dataset.viewerContent;
if (!viewerContent.parentElement) {
+4
View File
@@ -31,4 +31,8 @@ export const TUNABLES = {
IMAGE_THUMBNAIL: {
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 100),
},
KEN_BURNS: {
SMARTCROP: getBoolean(storage.getItem('KEN_BURNS.SMARTCROP'), true),
FACE_BOOST: getBoolean(storage.getItem('KEN_BURNS.FACE_BOOST'), true),
},
};
@@ -1,5 +1,5 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { beforeNavigate, goto } from '$app/navigation';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
@@ -38,8 +38,9 @@
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { startMemoryTransition } from '$lib/utils/transition-utils';
import { AssetVisibility } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel, type CarouselImageItem } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -99,6 +100,16 @@
src: getAssetMediaUrl({ id: memory.assets[0].id }),
})),
);
let memoryTransitionId = $state<string | null>(null);
const handleMemoryCardClick = (item: CarouselImageItem) => {
startMemoryTransition(
item.id ?? item.href,
() => void goto(item.href),
(id) => (memoryTransitionId = id),
);
};
</script>
<UserPageLayout hideNavbar={assetInteraction.selectionActive} scrollbar={false}>
@@ -112,7 +123,33 @@
withStacked
>
{#if $preferences.memories.enabled}
<ImageCarousel {items} />
{#snippet memoryCard(item: CarouselImageItem)}
<a
class="relative me-2 inline-block aspect-3/4 h-54 rounded-xl last:me-0 max-md:h-37.5 md:me-4 md:aspect-4/3 xl:aspect-video"
href={item.href}
data-memory-id={item.id}
onclick={(e) => {
e.preventDefault();
handleMemoryCardClick(item);
}}
style:box-shadow="rgba(60, 64, 67, 0.3) 0px 1px 2px 0px, rgba(60, 64, 67, 0.15) 0px 1px 3px 1px"
>
<img
class="h-full w-full rounded-xl object-cover"
src={item.src}
alt={item.alt ?? item.title}
draggable="false"
style:view-transition-name={memoryTransitionId === (item.id ?? item.href) ? 'hero' : undefined}
/>
<div
class="absolute inset-s-0 top-0 h-full w-full rounded-xl bg-linear-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"
></div>
<p class="absolute inset-s-4 bottom-2 text-lg text-white max-md:text-sm">
{item.title}
</p>
</a>
{/snippet}
<ImageCarousel {items} child={memoryCard} />
{/if}
{#snippet empty()}
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mt-10 mx-auto" />