refactor(web): turn thumbhash action into Thumbhash component (#27741)

refactor(web): extract thumbhash canvas into Thumbhash component

Change-Id: If78955bed48b6e690df398e5e2ae61fb6a6a6964
This commit is contained in:
Min Idzelis 2026-04-15 21:18:49 -04:00 committed by GitHub
parent 2ff9f95527
commit 3d8df74b43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 51 additions and 50 deletions

View File

@ -1,29 +0,0 @@
import { decodeBase64 } from '$lib/utils';
import { thumbHashToRGBA } from 'thumbhash';
/**
* Renders a thumbnail onto a canvas from a base64 encoded hash.
*/
export function thumbhash(canvas: HTMLCanvasElement, options: { base64ThumbHash: string }) {
render(canvas, options);
return {
update(newOptions: { base64ThumbHash: string }) {
render(canvas, newOptions);
},
};
}
const render = (canvas: HTMLCanvasElement, options: { base64ThumbHash: string }) => {
const ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
const { w, h, rgba } = thumbHashToRGBA(decodeBase64(options.base64ThumbHash));
const pixels = ctx.createImageData(w, h);
canvas.width = w;
canvas.height = h;
pixels.data.set(rgba);
ctx.putImageData(pixels, 0, 0);
};

View File

@ -1,9 +1,9 @@
<script lang="ts">
import { thumbhash } from '$lib/actions/thumbhash';
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
import ImageLayer from '$lib/components/ImageLayer.svelte';
import Thumbhash from '$lib/components/Thumbhash.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { getAssetUrls } from '$lib/utils';
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
@ -165,7 +165,7 @@
{#if show.thumbhash}
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
<Thumbhash base64ThumbHash={asset.thumbhash} class="h-full w-full absolute" />
{:else if show.spinner}
<DelayedLoadingSpinner />
{/if}

View File

@ -0,0 +1,40 @@
<script lang="ts">
import { decodeBase64 } from '$lib/utils';
import { TUNABLES } from '$lib/utils/tunables';
import type { HTMLCanvasAttributes } from 'svelte/elements';
import { fade } from 'svelte/transition';
import { thumbHashToRGBA } from 'thumbhash';
type Props = HTMLCanvasAttributes & {
base64ThumbHash: string;
fadeOut?: boolean;
};
const { base64ThumbHash, fadeOut = false, class: className, ...restProps }: Props = $props();
const {
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
} = TUNABLES;
let canvas = $state<HTMLCanvasElement>();
$effect(() => {
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) {
return;
}
const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash));
canvas.width = w;
canvas.height = h;
const pixels = ctx.createImageData(w, h);
pixels.data.set(rgba);
ctx.putImageData(pixels, 0, 0);
});
</script>
<canvas
bind:this={canvas}
class={className}
out:fade={{ duration: fadeOut ? THUMBHASH_FADE_DURATION : 0 }}
{...restProps}
></canvas>

View File

@ -1,9 +1,9 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { thumbhash } from '$lib/actions/thumbhash';
import { zoomImageAction } from '$lib/actions/zoom-image';
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import Thumbhash from '$lib/components/Thumbhash.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
@ -242,10 +242,7 @@
>
{#snippet backdrop()}
{#if blurredSlideshow}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash! }}
class="absolute top-0 left-0 inset-s-0 h-dvh w-dvw"
></canvas>
<Thumbhash base64ThumbHash={asset.thumbhash!} class="absolute top-0 left-0 inset-s-0 h-dvh w-dvw" />
{/if}
{/snippet}
{#snippet overlays()}

View File

@ -1,5 +1,4 @@
<script lang="ts">
import { thumbhash } from '$lib/actions/thumbhash';
import { ProjectionType } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
@ -10,7 +9,6 @@
import { moveFocus } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { getAltText } from '$lib/utils/thumbnail-util';
import { TUNABLES } from '$lib/utils/tunables';
import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import {
@ -27,6 +25,7 @@
import { onMount } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import { fade } from 'svelte/transition';
import Thumbhash from '$lib/components/Thumbhash.svelte';
import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte';
interface Props {
@ -75,10 +74,6 @@
dimmed = false,
}: Props = $props();
let {
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
} = TUNABLES;
let usingMobileDevice = $derived(mediaQueryManager.pointerCoarse);
let element: HTMLElement | undefined = $state();
let mouseOver = $state(false);
@ -312,16 +307,14 @@
{/if}
{#if (!loaded || thumbError) && asset.thumbhash}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
<Thumbhash
base64ThumbHash={asset.thumbhash}
data-testid="thumbhash"
class="absolute top-0 object-cover group-focus-visible:rounded-lg"
style:width="{width}px"
style:height="{height}px"
class:rounded-xl={selected}
class={['absolute top-0 object-cover group-focus-visible:rounded-lg', { 'rounded-xl': selected }]}
style="width: {width}px; height: {height}px"
draggable="false"
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
></canvas>
fadeOut
/>
{/if}
<!-- icon overlay -->