Merge branch 'feat/panorama-tiling' into feat/panorama-tiles

This commit is contained in:
Mees Frensel
2025-12-19 12:09:21 +01:00
20 changed files with 455 additions and 57 deletions
@@ -1,8 +1,7 @@
<script lang="ts">
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAssetOriginalUrl, getAssetThumbnailUrl } from '$lib/utils';
import { getAssetOriginalUrl, getAssetThumbnailUrl, getAssetTileUrl } from '$lib/utils';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -14,19 +13,31 @@
let { asset, zoomToggle = $bindable() }: Props = $props();
const loadAssetData = async (id: string) => {
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
return URL.createObjectURL(data);
};
const tileconfig =
asset.id === '6e899018-32fe-4fd5-b6ac-b3a525b8e61f'
? {
width: 12_988,
// height: 35,
cols: 16,
rows: 8,
}
: undefined;
const baseUrl = getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey: asset.thumbhash });
// TODO: determine whether to return null based on 1. if asset has tiles, 2. if tile is inside 'cropped' bounds.
const tileUrl = (col: number, row: number, level: number) =>
tileconfig ? getAssetTileUrl({ id: asset.id, level, col, row, cacheKey: asset.thumbhash }) : null;
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
{#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])}
{#await import('./photo-sphere-viewer-adapter.svelte')}
<LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]}
{:then { default: PhotoSphereViewer }}
<PhotoSphereViewer
bind:zoomToggle
panorama={data}
{baseUrl}
{tileUrl}
{tileconfig}
originalPanorama={isWebCompatibleImage(asset)
? getAssetOriginalUrl(asset.id)
: getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Fullsize, cacheKey: asset.thumbhash })}
@@ -11,6 +11,7 @@
type PluginConstructor,
} from '@photo-sphere-viewer/core';
import '@photo-sphere-viewer/core/index.css';
import { EquirectangularTilesAdapter } from '@photo-sphere-viewer/equirectangular-tiles-adapter';
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
import '@photo-sphere-viewer/markers-plugin/index.css';
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
@@ -26,21 +27,39 @@
strokeLinejoin: 'round',
};
const SHARED_VIEWER_CONFIG = {
touchmoveTwoFingers: false,
mousewheelCtrlKey: false,
navbar: false,
minFov: 15,
maxFov: 90,
zoomSpeed: 0.5,
fisheye: false,
};
type TileConfig = {
width: number;
cols: number;
rows: number;
};
type Props = {
panorama: string | { source: string };
baseUrl: string | { source: string };
tileUrl?: (col: number, row: number, level: number) => string | null;
tileconfig?: TileConfig;
originalPanorama?: string | { source: string };
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
navbar?: boolean;
zoomToggle?: (() => void) | null;
};
let {
panorama,
baseUrl,
tileUrl,
tileconfig,
originalPanorama,
adapter = EquirectangularAdapter,
plugins = [],
navbar = false,
zoomToggle = $bindable(),
}: Props = $props();
@@ -116,20 +135,32 @@
return;
}
viewer = new Viewer({
adapter,
plugins: [
MarkersPlugin,
SettingsPlugin,
[
ResolutionPlugin,
{
if (tileconfig) {
viewer = new Viewer({
adapter: EquirectangularTilesAdapter,
panorama: {
...tileconfig,
baseUrl,
tileUrl,
},
plugins: [MarkersPlugin, ...plugins],
container,
...SHARED_VIEWER_CONFIG,
});
} else {
viewer = new Viewer({
adapter,
panorama: baseUrl,
plugins: [
MarkersPlugin,
SettingsPlugin,
ResolutionPlugin.withConfig({
defaultResolution: $alwaysLoadOriginalFile && originalPanorama ? 'original' : 'default',
resolutions: [
{
id: 'default',
label: 'Default',
panorama,
panorama: baseUrl,
},
...(originalPanorama
? [
@@ -141,39 +172,34 @@
]
: []),
],
},
}),
...plugins,
],
...plugins,
],
container,
touchmoveTwoFingers: false,
mousewheelCtrlKey: false,
navbar,
minFov: 15,
maxFov: 90,
zoomSpeed: 0.5,
fisheye: false,
});
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100]
photoZoomState.set({
...$photoZoomState,
currentZoom: zoomLevel / 50,
container,
...SHARED_VIEWER_CONFIG,
});
if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) {
// Replace the preview with the original
void resolutionPlugin.setResolution('original');
hasChangedResolution = true;
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100]
photoZoomState.set({
...$photoZoomState,
currentZoom: zoomLevel / 50,
});
if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) {
// Replace the preview with the original
void resolutionPlugin.setResolution('original');
hasChangedResolution = true;
}
};
if (originalPanorama && !$alwaysLoadOriginalFile) {
viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler, { passive: true });
}
};
if (originalPanorama && !$alwaysLoadOriginalFile) {
viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler, { passive: true });
return () => viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
}
return () => viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
});
onDestroy(() => {
@@ -23,11 +23,10 @@
<LoadingSpinner />
{:then [PhotoSphereViewer, adapter, videoPlugin]}
<PhotoSphereViewer
panorama={{ source: getAssetPlaybackUrl(assetId) }}
baseUrl={{ source: getAssetPlaybackUrl(assetId) }}
originalPanorama={{ source: getAssetOriginalUrl(assetId) }}
plugins={[videoPlugin]}
{adapter}
navbar
/>
{:catch}
{$t('errors.failed_to_load_asset')}
+8
View File
@@ -11,6 +11,7 @@ import {
getAssetOriginalPath,
getAssetPlaybackPath,
getAssetThumbnailPath,
getAssetTilePath,
getBaseUrl,
getPeopleThumbnailPath,
getUserProfileImagePath,
@@ -215,6 +216,13 @@ export const getAssetPlaybackUrl = (options: string | AssetUrlOptions) => {
return createUrl(getAssetPlaybackPath(id), { ...authManager.params, c: cacheKey });
};
type TileUrlOptions = { id: string, level: number, col: number, row: number, cacheKey?: string | null };
export const getAssetTileUrl = (options: TileUrlOptions) => {
const { id, level, col, row, cacheKey } = options;
return createUrl(getAssetTilePath(id, level, col, row), { ...authManager.params, c: cacheKey });
}
export const getProfileImageUrl = (user: UserResponseDto) =>
createUrl(getUserProfileImagePath(user.id), { updatedAt: user.profileChangedAt });