chore: merge main into feat/hero_view_transitions

Change-Id: I4b00ee8b7c4201c926bf53b12900a4ea6a6a6964
This commit is contained in:
midzelis
2026-05-18 01:04:28 +00:00
48 changed files with 1213 additions and 281 deletions
+143 -63
View File
@@ -1,3 +1,54 @@
<script module lang="ts">
import { TUNABLES } from '$lib/utils/tunables';
// Chrome renders HDR images with normally invisible seam lines in a regular
// grid pattern. When the user pinch/scroll zooms, these seams become visible
// and grow more prominent at higher zoom levels.
//
// Adding `will-change: transform` prevents the seams by converting the
// element into a GPU texture that Chrome rasterizes once and reuses. But
// this texture is frozen at a fixed resolution and never re-renders from
// the source image, so zooming in magnifies the frozen texture rather than
// the source, which can appear blurry.
//
// To keep the texture sharp, we size this div closer to the image's native
// dimensions and apply a CSS counter-scale. Chrome renders these textures
// as a grid of small tiles backed by a shared GPU memory budget — if the
// texture is too large, tiles go missing and show up as transparent gaps.
// We cap the texture size based on the device's GPU capability.
//
// This workaround is only needed in Chromium-based browsers. Firefox and
// Safari use different rasterization pipelines and don't exhibit this bug.
// See https://issues.chromium.org/issues/40084005
const isChromium = 'chrome' in globalThis;
function getMaxRasterPixels() {
const override = TUNABLES.IMAGE_RASTER.MAX_PIXELS;
if (override > 0) {
return override;
}
if (override < 0 || !isChromium) {
return 0;
}
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');
const maxTextureSize = gl?.getParameter(gl.MAX_TEXTURE_SIZE) ?? 0;
if (maxTextureSize >= 16_384) {
return 16_000_000;
}
if (maxTextureSize >= 8192) {
return 10_000_000;
}
return 4_000_000;
} catch {
return 4_000_000;
}
}
const maxRasterPixels = getMaxRasterPixels();
</script>
<script lang="ts">
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
import BrokenAsset from '$lib/components/assets/BrokenAsset.svelte';
@@ -102,16 +153,37 @@
return { width: 1, height: 1 };
});
const { width, height, insetInlineStart, top } = $derived.by(() => {
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
const { width, height } = scaleFn(imageDimensions, container);
return {
width: width + 'px',
height: height + 'px',
insetInlineStart: (container.width - width) / 2 + 'px',
top: (container.height - height) / 2 + 'px',
};
});
const { insetInlineStart, top, visualWidth, visualHeight, rasterWidth, rasterHeight, rasterScale } = $derived.by(
() => {
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
const { width, height } = scaleFn(imageDimensions, container);
const visualWidth = width + 'px';
const visualHeight = height + 'px';
if (maxRasterPixels === 0) {
return {
insetInlineStart: (container.width - width) / 2 + 'px',
top: (container.height - height) / 2 + 'px',
visualWidth,
visualHeight,
rasterWidth: visualWidth,
rasterHeight: visualHeight,
rasterScale: 1,
};
}
const nativeRatio = imageDimensions.width / width;
const budgetRatio = Math.sqrt(maxRasterPixels / Math.max(width * height, 1));
const rasterRatio = Math.max(1, Math.min(nativeRatio, budgetRatio));
return {
insetInlineStart: (container.width - width) / 2 + 'px',
top: (container.height - height) / 2 + 'px',
visualWidth,
visualHeight,
rasterWidth: width * rasterRatio + 'px',
rasterHeight: height * rasterRatio + 'px',
rasterScale: 1 / rasterRatio,
};
},
);
const { status } = $derived(adaptiveImageLoader);
const alt = $derived(status.urls.preview ? $getAltText(toTimelineAsset(asset)) : '');
@@ -156,67 +228,75 @@
{@render backdrop?.()}
<div
class={['pointer-events-none absolute inset-0', imageClass]}
class={['pointer-events-none absolute', imageClass]}
style:inset-inline-start={insetInlineStart}
style:top
style:width
style:height
style:width={visualWidth}
style:height={visualHeight}
style:view-transition-name={transitionName ?? assetViewerManager.transitionName}
>
{#if show.alphaBackground}
<AlphaBackground />
{/if}
{#if show.thumbhash}
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<Thumbhash base64ThumbHash={asset.thumbhash} class="absolute size-full" />
{:else if show.spinner}
<DelayedLoadingSpinner />
<div
style:width={rasterWidth}
style:height={rasterHeight}
style:transform="scale({rasterScale})"
style:transform-origin="0 0"
style:will-change={maxRasterPixels > 0 ? 'transform' : undefined}
>
{#if show.alphaBackground}
<AlphaBackground />
{/if}
{/if}
{#if show.thumbnail}
<ImageLayer
{adaptiveImageLoader}
{width}
{height}
quality="thumbnail"
src={status.urls.thumbnail}
alt=""
role="presentation"
bind:ref={thumbnailElement}
/>
{/if}
{#if show.thumbhash}
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<Thumbhash base64ThumbHash={asset.thumbhash} class="absolute size-full" />
{:else if show.spinner}
<DelayedLoadingSpinner />
{/if}
{/if}
{#if show.brokenAsset}
<BrokenAsset class="absolute size-full text-xl" />
{/if}
{#if show.thumbnail}
<ImageLayer
{adaptiveImageLoader}
width={rasterWidth}
height={rasterHeight}
quality="thumbnail"
src={status.urls.thumbnail}
alt=""
role="presentation"
bind:ref={thumbnailElement}
/>
{/if}
{#if show.preview}
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
{overlays}
quality="preview"
src={status.urls.preview}
bind:ref={previewElement}
/>
{/if}
{#if show.brokenAsset}
<BrokenAsset class="absolute size-full text-xl" />
{/if}
{#if show.original}
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
{overlays}
quality="original"
src={status.urls.original}
bind:ref={originalElement}
/>
{/if}
{#if show.preview}
<ImageLayer
{adaptiveImageLoader}
{alt}
width={rasterWidth}
height={rasterHeight}
{overlays}
quality="preview"
src={status.urls.preview}
bind:ref={previewElement}
/>
{/if}
{#if show.original}
<ImageLayer
{adaptiveImageLoader}
{alt}
width={rasterWidth}
height={rasterHeight}
{overlays}
quality="original"
src={status.urls.original}
bind:ref={originalElement}
/>
{/if}
</div>
</div>
</div>
+3
View File
@@ -31,4 +31,7 @@ export const TUNABLES = {
IMAGE_THUMBNAIL: {
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 100),
},
IMAGE_RASTER: {
MAX_PIXELS: getNumber(storage.getItem('IMAGE_RASTER.MAX_PIXELS'), 0),
},
};
+33 -4
View File
@@ -4,15 +4,18 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { Route } from '$lib/route';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { Icon } from '@immich/ui';
import { mdiHeart } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAltText } from '$lib/utils/thumbnail-util';
import Portal from '$lib/elements/Portal.svelte';
interface Props {
data: PageData;
@@ -40,6 +43,15 @@
}
}
};
const onViewAsset = async (id: string) => {
const asset = await getAssetInfo({ ...authManager.params, id });
assetViewerManager.setAsset(asset);
};
const assetCursor = $derived({
current: assetViewerManager.asset!,
});
</script>
<OnEvents {onPersonThumbnailReady} />
@@ -122,15 +134,20 @@
draggable="false">{$t('view_all')}</a
>
</div>
<div class="flex h-24 flex-wrap gap-x-1 overflow-hidden md:h-42">
<div class="flex h-24 max-w-fit flex-wrap gap-x-1 overflow-hidden md:h-42">
{#each recents as item (item.data.id)}
<a class="relative h-full flex-auto" href={Route.viewAsset({ id: item.data.id })} draggable="false">
<button
type="button"
class="relative h-full flex-auto"
onclick={() => onViewAsset(item.data.id)}
draggable="false"
>
<img
src={getAssetMediaUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
alt={$getAltText(toTimelineAsset(item.data))}
class="size-full min-w-max rounded-xl object-cover"
/>
</a>
</button>
{/each}
</div>
</div>
@@ -140,3 +157,15 @@
<EmptyPlaceholder text={$t('no_explore_results_message')} class="mx-auto mt-10" />
{/if}
</UserPageLayout>
{#if assetViewerManager.isViewing}
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
<Portal target="body">
<AssetViewer
cursor={assetCursor}
showNavigation={false}
onClose={() => assetViewerManager.showAssetViewer(false)}
/>
</Portal>
{/await}
{/if}