mirror of
https://github.com/immich-app/immich.git
synced 2026-03-27 04:11:15 +03:00
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
This commit is contained in:
@@ -2160,6 +2160,7 @@
|
|||||||
"skip_to_folders": "Skip to folders",
|
"skip_to_folders": "Skip to folders",
|
||||||
"skip_to_tags": "Skip to tags",
|
"skip_to_tags": "Skip to tags",
|
||||||
"slideshow": "Slideshow",
|
"slideshow": "Slideshow",
|
||||||
|
"slideshow_ken_burns_effect": "Ken Burns effect",
|
||||||
"slideshow_repeat": "Repeat slideshow",
|
"slideshow_repeat": "Repeat slideshow",
|
||||||
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
|
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
|
||||||
"slideshow_settings": "Slideshow settings",
|
"slideshow_settings": "Slideshow settings",
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -824,6 +824,9 @@ importers:
|
|||||||
simple-icons:
|
simple-icons:
|
||||||
specifier: ^15.15.0
|
specifier: ^15.15.0
|
||||||
version: 15.22.0
|
version: 15.22.0
|
||||||
|
smartcrop:
|
||||||
|
specifier: ^2.0.5
|
||||||
|
version: 2.0.5
|
||||||
socket.io-client:
|
socket.io-client:
|
||||||
specifier: ~4.8.0
|
specifier: ~4.8.0
|
||||||
version: 4.8.3
|
version: 4.8.3
|
||||||
@@ -10904,6 +10907,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
||||||
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
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:
|
snake-case@3.0.4:
|
||||||
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
|
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
|
||||||
|
|
||||||
@@ -24106,6 +24112,8 @@ snapshots:
|
|||||||
|
|
||||||
smart-buffer@4.2.0: {}
|
smart-buffer@4.2.0: {}
|
||||||
|
|
||||||
|
smartcrop@2.0.5: {}
|
||||||
|
|
||||||
snake-case@3.0.4:
|
snake-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
dot-case: 3.0.4
|
dot-case: 3.0.4
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"pmtiles": "^4.3.0",
|
"pmtiles": "^4.3.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"simple-icons": "^15.15.0",
|
"simple-icons": "^15.15.0",
|
||||||
|
"smartcrop": "^2.0.5",
|
||||||
"socket.io-client": "~4.8.0",
|
"socket.io-client": "~4.8.0",
|
||||||
"svelte-gestures": "^5.2.2",
|
"svelte-gestures": "^5.2.2",
|
||||||
"svelte-i18n": "^4.0.1",
|
"svelte-i18n": "^4.0.1",
|
||||||
|
|||||||
@@ -297,8 +297,19 @@
|
|||||||
|
|
||||||
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
|
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
|
||||||
const slideshowAllowsTransition = !slideShowPlaying || $slideshowTransition;
|
const slideshowAllowsTransition = !slideShowPlaying || $slideshowTransition;
|
||||||
const useTransition = canTransition && slideshowAllowsTransition && (slideShowShuffle || !!targetAsset);
|
const useTransition = slideshowAllowsTransition && (slideShowShuffle || !!targetAsset);
|
||||||
const hasNext = useTransition ? await startTransition(types, targetTransition, navigate) : await navigate();
|
|
||||||
|
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) {
|
if (!slideShowPlaying) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||||
import { eventManager } from '$lib/managers/event-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 { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||||
@@ -17,11 +18,12 @@
|
|||||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||||
import type { Size } from '$lib/utils/container-utils';
|
import type { Size } from '$lib/utils/container-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { KenBurnsAnimation } from '$lib/utils/ken-burns';
|
||||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||||
import { type SharedLinkResponseDto } from '@immich/sdk';
|
import { type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { toastManager } from '@immich/ui';
|
import { toastManager } from '@immich/ui';
|
||||||
import { onDestroy, untrack } from 'svelte';
|
import { onDestroy, onMount, untrack } from 'svelte';
|
||||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { AssetCursor } from './asset-viewer.svelte';
|
import type { AssetCursor } from './asset-viewer.svelte';
|
||||||
@@ -46,13 +48,17 @@
|
|||||||
onSwipe,
|
onSwipe,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const { slideshowState, slideshowLook } = slideshowStore;
|
const { slideshowState, slideshowLook, kenBurnsEffect, slideshowDelay } = slideshowStore;
|
||||||
const objectFit = $derived(
|
|
||||||
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain',
|
|
||||||
);
|
|
||||||
const asset = $derived(cursor.current);
|
const asset = $derived(cursor.current);
|
||||||
|
|
||||||
let visibleImageReady: boolean = $state(false);
|
let visibleImageReady: boolean = $state(false);
|
||||||
|
const kenBurns = new KenBurnsAnimation();
|
||||||
|
let adaptiveImage = $state<HTMLDivElement | undefined>();
|
||||||
|
onMount(() =>
|
||||||
|
eventManager.on({
|
||||||
|
ViewTransitionOldSnapshotPending: () => kenBurns.freeze(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let previousAssetId: string | undefined;
|
let previousAssetId: string | undefined;
|
||||||
$effect.pre(() => {
|
$effect.pre(() => {
|
||||||
@@ -62,13 +68,16 @@
|
|||||||
}
|
}
|
||||||
previousAssetId = id;
|
previousAssetId = id;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
|
kenBurns.cancel();
|
||||||
assetViewerManager.resetZoomState();
|
assetViewerManager.resetZoomState();
|
||||||
visibleImageReady = false;
|
visibleImageReady = false;
|
||||||
$boundingBoxesArray = [];
|
$boundingBoxesArray = [];
|
||||||
|
adaptiveImage?.style.removeProperty('transform');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
kenBurns.cancel();
|
||||||
$boundingBoxesArray = [];
|
$boundingBoxesArray = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,6 +89,8 @@
|
|||||||
height: containerHeight,
|
height: containerHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isCoverMode = $derived($slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover);
|
||||||
|
|
||||||
let imageDimensions = $state<Size>({ width: 0, height: 0 });
|
let imageDimensions = $state<Size>({ width: 0, height: 0 });
|
||||||
let scaledDimensions = $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,
|
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
|
||||||
);
|
);
|
||||||
|
|
||||||
let adaptiveImage = $state<HTMLDivElement | undefined>();
|
|
||||||
|
|
||||||
const faceToNameMap = $derived.by(() => {
|
const faceToNameMap = $derived.by(() => {
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||||
const map = new Map<Faces, string | undefined>();
|
const map = new Map<Faces, string | undefined>();
|
||||||
@@ -167,6 +176,47 @@
|
|||||||
const faces = $derived(Array.from(faceToNameMap.keys()));
|
const faces = $derived(Array.from(faceToNameMap.keys()));
|
||||||
const boundingBoxes = $derived(getBoundingBox(faces, overlaySize));
|
const boundingBoxes = $derived(getBoundingBox(faces, overlaySize));
|
||||||
const activeBoundingBoxes = $derived(boundingBoxes.filter((box) => $boundingBoxesArray.some((f) => f.id === box.id)));
|
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>
|
</script>
|
||||||
|
|
||||||
<AssetViewerEvents {onCopy} {onZoom} />
|
<AssetViewerEvents {onCopy} {onZoom} />
|
||||||
@@ -194,7 +244,7 @@
|
|||||||
{asset}
|
{asset}
|
||||||
{sharedLink}
|
{sharedLink}
|
||||||
{container}
|
{container}
|
||||||
{objectFit}
|
objectFit={isCoverMode ? 'cover' : 'contain'}
|
||||||
{onUrlChange}
|
{onUrlChange}
|
||||||
onImageReady={() => {
|
onImageReady={() => {
|
||||||
visibleImageReady = true;
|
visibleImageReady = true;
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export type Events = {
|
|||||||
ViewerCloseTransitionReady: [];
|
ViewerCloseTransitionReady: [];
|
||||||
ViewerOpenTransition: [];
|
ViewerOpenTransition: [];
|
||||||
ViewerOpenTransitionReady: [];
|
ViewerOpenTransitionReady: [];
|
||||||
|
ViewTransitionOldSnapshotPending: [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const eventManager = new BaseEventManager<Events>();
|
export const eventManager = new BaseEventManager<Events>();
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
slideshowTransition,
|
slideshowTransition,
|
||||||
slideshowAutoplay,
|
slideshowAutoplay,
|
||||||
slideshowRepeat,
|
slideshowRepeat,
|
||||||
|
kenBurnsEffect,
|
||||||
slideshowState,
|
slideshowState,
|
||||||
} = slideshowStore;
|
} = slideshowStore;
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
let tempSlideshowTransition = $state($slideshowTransition);
|
let tempSlideshowTransition = $state($slideshowTransition);
|
||||||
let tempSlideshowAutoplay = $state($slideshowAutoplay);
|
let tempSlideshowAutoplay = $state($slideshowAutoplay);
|
||||||
let tempSlideshowRepeat = $state($slideshowRepeat);
|
let tempSlideshowRepeat = $state($slideshowRepeat);
|
||||||
|
let tempKenBurnsEffect = $state($kenBurnsEffect);
|
||||||
|
|
||||||
const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
|
const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
|
||||||
[SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
|
[SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
|
||||||
@@ -70,6 +72,7 @@
|
|||||||
$slideshowTransition = tempSlideshowTransition;
|
$slideshowTransition = tempSlideshowTransition;
|
||||||
$slideshowAutoplay = tempSlideshowAutoplay;
|
$slideshowAutoplay = tempSlideshowAutoplay;
|
||||||
$slideshowRepeat = tempSlideshowRepeat;
|
$slideshowRepeat = tempSlideshowRepeat;
|
||||||
|
$kenBurnsEffect = tempKenBurnsEffect;
|
||||||
$slideshowState = SlideshowState.PlaySlideshow;
|
$slideshowState = SlideshowState.PlaySlideshow;
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -107,6 +110,10 @@
|
|||||||
<Switch bind:checked={tempSlideshowTransition} />
|
<Switch bind:checked={tempSlideshowTransition} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field label={$t('slideshow_ken_burns_effect')}>
|
||||||
|
<Switch bind:checked={tempKenBurnsEffect} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field label={$t('slideshow_repeat')} description={$t('slideshow_repeat_description')}>
|
<Field label={$t('slideshow_repeat')} description={$t('slideshow_repeat_description')}>
|
||||||
<Switch bind:checked={tempSlideshowRepeat} />
|
<Switch bind:checked={tempSlideshowRepeat} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ function createSlideshowStore() {
|
|||||||
const slideshowTransition = persisted<boolean>('slideshow-transition', true);
|
const slideshowTransition = persisted<boolean>('slideshow-transition', true);
|
||||||
const slideshowAutoplay = persisted<boolean>('slideshow-autoplay', true, {});
|
const slideshowAutoplay = persisted<boolean>('slideshow-autoplay', true, {});
|
||||||
const slideshowRepeat = persisted<boolean>('slideshow-repeat', false);
|
const slideshowRepeat = persisted<boolean>('slideshow-repeat', false);
|
||||||
|
const kenBurnsEffect = persisted<boolean>('slideshow-ken-burns-effect', false);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
restartProgress: {
|
restartProgress: {
|
||||||
@@ -73,6 +74,7 @@ function createSlideshowStore() {
|
|||||||
slideshowTransition,
|
slideshowTransition,
|
||||||
slideshowAutoplay,
|
slideshowAutoplay,
|
||||||
slideshowRepeat,
|
slideshowRepeat,
|
||||||
|
kenBurnsEffect,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
237
web/src/lib/utils/ken-burns.ts
Normal file
237
web/src/lib/utils/ken-burns.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,12 +58,15 @@ export async function crossfadeViewerContent(updateFn: () => void | Promise<void
|
|||||||
|
|
||||||
removeCrossfadeOverlay();
|
removeCrossfadeOverlay();
|
||||||
|
|
||||||
|
eventManager.emit('ViewTransitionOldSnapshotPending');
|
||||||
|
|
||||||
const clone = viewerContent.cloneNode(true) as HTMLElement;
|
const clone = viewerContent.cloneNode(true) as HTMLElement;
|
||||||
Object.assign(clone.style, {
|
Object.assign(clone.style, {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: '0',
|
inset: '0',
|
||||||
zIndex: '1',
|
zIndex: '1',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
|
backgroundColor: 'black',
|
||||||
});
|
});
|
||||||
delete clone.dataset.viewerContent;
|
delete clone.dataset.viewerContent;
|
||||||
if (!viewerContent.parentElement) {
|
if (!viewerContent.parentElement) {
|
||||||
|
|||||||
@@ -31,4 +31,8 @@ export const TUNABLES = {
|
|||||||
IMAGE_THUMBNAIL: {
|
IMAGE_THUMBNAIL: {
|
||||||
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 100),
|
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),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user