mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
fix(web): work around Chrome HDR image seam lines during zoom (#27715)
Change-Id: Ic5a5b1a476c2af93b465ef23dabc601a6a6a6964 Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
@@ -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">
|
<script lang="ts">
|
||||||
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
|
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
|
||||||
import BrokenAsset from '$lib/components/assets/BrokenAsset.svelte';
|
import BrokenAsset from '$lib/components/assets/BrokenAsset.svelte';
|
||||||
@@ -98,14 +149,27 @@
|
|||||||
return { width: 1, height: 1 };
|
return { width: 1, height: 1 };
|
||||||
});
|
});
|
||||||
|
|
||||||
const { width, height, insetInlineStart, top } = $derived.by(() => {
|
const { insetInlineStart, top, rasterWidth, rasterHeight, rasterScale } = $derived.by(() => {
|
||||||
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
||||||
const { width, height } = scaleFn(imageDimensions, container);
|
const { width, height } = scaleFn(imageDimensions, container);
|
||||||
|
if (maxRasterPixels === 0) {
|
||||||
return {
|
return {
|
||||||
width: width + 'px',
|
|
||||||
height: height + 'px',
|
|
||||||
insetInlineStart: (container.width - width) / 2 + 'px',
|
insetInlineStart: (container.width - width) / 2 + 'px',
|
||||||
top: (container.height - height) / 2 + 'px',
|
top: (container.height - height) / 2 + 'px',
|
||||||
|
rasterWidth: width + 'px',
|
||||||
|
rasterHeight: height + 'px',
|
||||||
|
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',
|
||||||
|
rasterWidth: width * rasterRatio + 'px',
|
||||||
|
rasterHeight: height * rasterRatio + 'px',
|
||||||
|
rasterScale: 1 / rasterRatio,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,11 +216,14 @@
|
|||||||
{@render backdrop?.()}
|
{@render backdrop?.()}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-0"
|
class="pointer-events-none absolute"
|
||||||
style:inset-inline-start={insetInlineStart}
|
style:inset-inline-start={insetInlineStart}
|
||||||
style:top
|
style:top
|
||||||
style:width
|
style:width={rasterWidth}
|
||||||
style:height
|
style:height={rasterHeight}
|
||||||
|
style:transform="scale({rasterScale})"
|
||||||
|
style:transform-origin="0 0"
|
||||||
|
style:will-change={maxRasterPixels > 0 ? 'transform' : undefined}
|
||||||
>
|
>
|
||||||
{#if show.alphaBackground}
|
{#if show.alphaBackground}
|
||||||
<AlphaBackground />
|
<AlphaBackground />
|
||||||
@@ -174,8 +241,8 @@
|
|||||||
{#if show.thumbnail}
|
{#if show.thumbnail}
|
||||||
<ImageLayer
|
<ImageLayer
|
||||||
{adaptiveImageLoader}
|
{adaptiveImageLoader}
|
||||||
{width}
|
width={rasterWidth}
|
||||||
{height}
|
height={rasterHeight}
|
||||||
quality="thumbnail"
|
quality="thumbnail"
|
||||||
src={status.urls.thumbnail}
|
src={status.urls.thumbnail}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -192,8 +259,8 @@
|
|||||||
<ImageLayer
|
<ImageLayer
|
||||||
{adaptiveImageLoader}
|
{adaptiveImageLoader}
|
||||||
{alt}
|
{alt}
|
||||||
{width}
|
width={rasterWidth}
|
||||||
{height}
|
height={rasterHeight}
|
||||||
{overlays}
|
{overlays}
|
||||||
quality="preview"
|
quality="preview"
|
||||||
src={status.urls.preview}
|
src={status.urls.preview}
|
||||||
@@ -205,8 +272,8 @@
|
|||||||
<ImageLayer
|
<ImageLayer
|
||||||
{adaptiveImageLoader}
|
{adaptiveImageLoader}
|
||||||
{alt}
|
{alt}
|
||||||
{width}
|
width={rasterWidth}
|
||||||
{height}
|
height={rasterHeight}
|
||||||
{overlays}
|
{overlays}
|
||||||
quality="original"
|
quality="original"
|
||||||
src={status.urls.original}
|
src={status.urls.original}
|
||||||
|
|||||||
@@ -31,4 +31,7 @@ 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),
|
||||||
},
|
},
|
||||||
|
IMAGE_RASTER: {
|
||||||
|
MAX_PIXELS: getNumber(storage.getItem('IMAGE_RASTER.MAX_PIXELS'), 0),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user