Compare commits

...

2 Commits

Author SHA1 Message Date
Alex
374a764cb1 Merge branch 'main' into fix/chrome_hdr_seam_lines 2026-04-15 20:23:33 -05:00
midzelis
d47a0579ef fix(web): work around Chrome HDR image seam lines during zoom
Change-Id: Ic5a5b1a476c2af93b465ef23dabc601a6a6a6964
2026-04-15 12:49:21 +00:00
2 changed files with 82 additions and 12 deletions

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/broken-asset.svelte';
@@ -98,14 +149,27 @@
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 { width, height } = scaleFn(imageDimensions, container);
if (maxRasterPixels === 0) {
return {
insetInlineStart: (container.width - width) / 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 {
width: width + 'px',
height: height + 'px',
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?.()}
<div
class="absolute inset-0 pointer-events-none"
class="absolute pointer-events-none"
style:inset-inline-start={insetInlineStart}
style:top
style:width
style:height
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 />
@@ -174,8 +241,8 @@
{#if show.thumbnail}
<ImageLayer
{adaptiveImageLoader}
{width}
{height}
width={rasterWidth}
height={rasterHeight}
quality="thumbnail"
src={status.urls.thumbnail}
alt=""
@@ -192,8 +259,8 @@
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
width={rasterWidth}
height={rasterHeight}
{overlays}
quality="preview"
src={status.urls.preview}
@@ -205,8 +272,8 @@
<ImageLayer
{adaptiveImageLoader}
{alt}
{width}
{height}
width={rasterWidth}
height={rasterHeight}
{overlays}
quality="original"
src={status.urls.original}

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),
},
};