Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.

Commit 980fe52

Browse files
committed
feat: migrate blurhash → thumbhash
1 parent c746c33 commit 980fe52

7 files changed

Lines changed: 49 additions & 156 deletions

File tree

apps/admin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@
4949
"@xterm/xterm": "6.0.0",
5050
"ansi_up": "6.0.6",
5151
"better-auth": "1.4.18",
52-
"blurhash": "2.0.5",
5352
"buffer": "6.0.3",
5453
"canvas-confetti": "1.9.4",
5554
"date-fns": "4.1.0",
@@ -83,6 +82,7 @@
8382
"shiki": "3.21.0",
8483
"socket.io-client": "4.8.3",
8584
"sortablejs": "1.15.7",
85+
"thumbhash": "0.1.1",
8686
"umi-request": "1.4.0",
8787
"validator": "13.15.26",
8888
"vue": "3.5.30",

apps/admin/src/components/drawer/components/image-detail-section.tsx

Lines changed: 27 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,13 @@
1-
import { decode } from 'blurhash'
21
import { uniqBy } from 'es-toolkit/compat'
32
import { ChevronDownIcon, ExternalLinkIcon, Trash2Icon } from 'lucide-vue-next'
4-
import {
5-
NButton,
6-
NCheckbox,
7-
NColorPicker,
8-
NInput,
9-
NInputNumber,
10-
} from 'naive-ui'
11-
import { computed, defineComponent, onMounted, ref } from 'vue'
3+
import { NButton, NColorPicker, NInput, NInputNumber } from 'naive-ui'
4+
import { thumbHashToDataURL } from 'thumbhash'
5+
import { computed, defineComponent, ref } from 'vue'
126
import { toast } from 'vue-sonner'
137
import type { Image as ImageModel } from '~/models/base'
148
import type { PropType } from 'vue'
159

16-
import { useStorage } from '@vueuse/core'
17-
18-
import {
19-
encodeImageToBlurhash,
20-
encodeImageToBlurhashWebgl,
21-
getDominantColor,
22-
} from '~/utils/image'
10+
import { getDominantColor, getThumbhash } from '~/utils/image'
2311
import { isVideoExt, pickImagesFromMarkdown } from '~/utils/markdown'
2412

2513
export const ImageDetailSection = defineComponent({
@@ -44,8 +32,6 @@ export const ImageDetailSection = defineComponent({
4432
setup(props) {
4533
const loading = ref(false)
4634

47-
const useWebglFlag = useStorage('useWebglFlag', true)
48-
4935
const originImageMap = computed(() => {
5036
const map = new Map<string, ImageModel>()
5137
props.images.forEach((image) => {
@@ -72,7 +58,7 @@ export const ImageDetailSection = defineComponent({
7258
width: existImageInfo?.width,
7359
type: existImageInfo?.type,
7460
accent: existImageInfo?.accent,
75-
blurHash: existImageInfo?.blurHash,
61+
thumbhash: existImageInfo?.thumbhash,
7662
} as any
7763
})
7864
.concat(validPropsImages),
@@ -143,9 +129,7 @@ export const ImageDetailSection = defineComponent({
143129
src: item.src,
144130
type: ext,
145131
accent: getDominantColor($image),
146-
blurHash: useWebglFlag
147-
? encodeImageToBlurhashWebgl($image)
148-
: encodeImageToBlurhash($image),
132+
thumbhash: getThumbhash($image),
149133
})
150134
})
151135
$image.onerror = (err) => {
@@ -203,17 +187,6 @@ export const ImageDetailSection = defineComponent({
203187
</NButton>
204188
</div>
205189

206-
{/* WebGL 选项 */}
207-
<label class="mt-3 flex cursor-pointer items-center gap-2 text-xs text-neutral-400">
208-
<NCheckbox
209-
size="small"
210-
checked={useWebglFlag.value}
211-
onUpdateChecked={(e) => void (useWebglFlag.value = e)}
212-
aria-label="使用 WebGL 加速"
213-
/>
214-
<span>使用 WebGL 加速图片处理(实验性)</span>
215-
</label>
216-
217190
{/* 图片列表 */}
218191
{images.value.length > 0 && (
219192
<div class="mt-4 space-y-2">
@@ -328,13 +301,13 @@ export const ImageDetailSection = defineComponent({
328301
</div>
329302
</div>
330303

331-
{/* BlurHash 预览 */}
332-
{image.blurHash && (
304+
{/* Thumbhash 预览 */}
305+
{image.thumbhash && (
333306
<div>
334307
<label class="mb-1 block text-xs text-neutral-500">
335-
BlurHash 预览
308+
Thumbhash 预览
336309
</label>
337-
<BlurHashPreview hash={image.blurHash} />
310+
<ThumbhashPreview hash={image.thumbhash} />
338311
</div>
339312
)}
340313

@@ -391,33 +364,30 @@ export const ImageDetailSection = defineComponent({
391364
},
392365
})
393366

394-
const BlurHashPreview = defineComponent({
367+
const ThumbhashPreview = defineComponent({
395368
props: {
396369
hash: {
397370
type: String,
398371
required: true,
399372
},
400373
},
401374
setup(props) {
402-
const canvasRef = ref<HTMLCanvasElement | null>(null)
403-
404-
onMounted(() => {
405-
const canvas = canvasRef.value!
406-
const ctx = canvas.getContext('2d')!
407-
const pixels = decode(props.hash, 32, 32)
408-
const imageData = ctx.createImageData(32, 32)
409-
imageData.data.set(pixels)
410-
ctx.putImageData(imageData, 0, 0)
375+
const dataUrl = computed(() => {
376+
try {
377+
const u8 = Uint8Array.from(atob(props.hash), (c) => c.charCodeAt(0))
378+
return thumbHashToDataURL(u8)
379+
} catch {
380+
return undefined
381+
}
411382
})
412-
413-
return () => (
414-
<canvas
415-
ref={canvasRef}
416-
class="rounded bg-cover bg-center"
417-
height={32}
418-
width={32}
419-
aria-label="BlurHash 预览图"
420-
/>
421-
)
383+
return () =>
384+
dataUrl.value ? (
385+
<img
386+
src={dataUrl.value}
387+
class="rounded"
388+
alt="Thumbhash 预览图"
389+
aria-label="Thumbhash 预览图"
390+
/>
391+
) : null
422392
},
423393
})

apps/admin/src/models/base.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export interface Image {
2121
type: string
2222
accent?: string
2323
src: string
24-
blurHash?: string
24+
thumbhash?: string
2525
}
2626

2727
export class BaseModel {

apps/admin/src/models/enrichment.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export interface EnrichmentImage {
1717
width?: number
1818
height?: number
1919
alt?: string
20-
blurhash?: string
20+
thumbhash?: string
2121
palette?: EnrichmentImagePalette
2222
}
2323

@@ -76,7 +76,7 @@ export interface EnrichmentCaptureRow {
7676
bytes: number
7777
width: number
7878
height: number
79-
blurhash: string | null
79+
thumbhash: string | null
8080
palette: EnrichmentImagePalette | null
8181
createdAt: string
8282
lastAccessedAt: string

apps/admin/src/utils/image.ts

Lines changed: 14 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// @see https://stackoverflow.com/questions/2541481/get-average-color-of-image-via-javascript
22

3-
import { encode } from 'blurhash'
3+
import { rgbaToThumbHash } from 'thumbhash'
44

55
import { computeImageMeta } from '@haklex/rich-editor/renderers'
66

@@ -28,92 +28,20 @@ export function rgbObjectToHex(rgb: { r: number; g: number; b: number }) {
2828
return rgbToHex(rgb.r, rgb.g, rgb.b)
2929
}
3030

31-
export function getBlurHash(imageObject: HTMLImageElement) {
32-
const canvas = document.createElement('canvas'),
33-
ctx = canvas.getContext('2d')!
34-
35-
canvas.width = imageObject.naturalWidth
36-
canvas.height = imageObject.naturalHeight
37-
38-
ctx.drawImage(imageObject, 0, 0)
39-
40-
const imageData = ctx.getImageData(0, 0, 32, 32)
41-
const pixels = new Uint8ClampedArray(imageData.data)
42-
const componentX = 4
43-
const componentY = 4
44-
45-
return encode(pixels, 32, 32, componentX, componentY)
46-
}
47-
48-
const getImageData = (image: HTMLImageElement) => {
49-
const canvas = document.createElement('canvas')
50-
canvas.width = image.width
51-
canvas.height = image.height
52-
const context = canvas.getContext('2d')!
53-
context.drawImage(image, 0, 0)
54-
return context.getImageData(0, 0, image.width, image.height)
55-
}
56-
57-
export const encodeImageToBlurhash = (image: HTMLImageElement) => {
58-
const imageData = getImageData(image)
59-
return encode(imageData.data, imageData.width, imageData.height, 4, 4)
60-
}
61-
62-
export const encodeImageToBlurhashWebgl = (image: HTMLImageElement) => {
31+
export function getThumbhash(img: HTMLImageElement): string {
32+
const scale = Math.min(100 / img.naturalWidth, 100 / img.naturalHeight, 1)
33+
const sw = Math.max(1, Math.round(img.naturalWidth * scale))
34+
const sh = Math.max(1, Math.round(img.naturalHeight * scale))
6335
const canvas = document.createElement('canvas')
64-
const gl = (canvas.getContext('webgl') ||
65-
canvas.getContext('experimental-webgl')) as WebGLRenderingContext
66-
67-
if (!gl) {
68-
throw new Error('WebGL not supported')
69-
}
70-
71-
canvas.width = image.naturalWidth
72-
canvas.height = image.naturalHeight
73-
74-
const texture = gl.createTexture()
75-
gl.bindTexture(gl.TEXTURE_2D, texture)
76-
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
77-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
78-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
79-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
80-
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
81-
82-
const framebuffer = gl.createFramebuffer()
83-
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer)
84-
gl.framebufferTexture2D(
85-
gl.FRAMEBUFFER,
86-
gl.COLOR_ATTACHMENT0,
87-
gl.TEXTURE_2D,
88-
texture,
89-
0,
90-
)
91-
92-
const pixels = new Uint8Array(image.naturalWidth * image.naturalHeight * 4)
93-
gl.readPixels(
94-
0,
95-
0,
96-
image.naturalWidth,
97-
image.naturalHeight,
98-
gl.RGBA,
99-
gl.UNSIGNED_BYTE,
100-
pixels,
101-
)
102-
103-
const resizedCanvas = document.createElement('canvas')
104-
resizedCanvas.width = 32
105-
resizedCanvas.height = 32
106-
const resizedCtx = resizedCanvas.getContext('2d')!
107-
const imageData = new ImageData(
108-
new Uint8ClampedArray(pixels),
109-
image.naturalWidth,
110-
image.naturalHeight,
111-
)
112-
resizedCtx.putImageData(imageData, 0, 0)
113-
resizedCtx.drawImage(resizedCanvas, 0, 0, 32, 32)
114-
const resizedImageData = resizedCtx.getImageData(0, 0, 32, 32)
115-
116-
return encode(resizedImageData.data, 32, 32, 4, 4)
36+
canvas.width = sw
37+
canvas.height = sh
38+
const ctx = canvas.getContext('2d')!
39+
ctx.drawImage(img, 0, 0, sw, sh)
40+
const rgba = ctx.getImageData(0, 0, sw, sh).data
41+
const u8 = rgbaToThumbHash(sw, sh, rgba)
42+
let bin = ''
43+
for (let i = 0; i < u8.length; i++) bin += String.fromCharCode(u8[i])
44+
return btoa(bin)
11745
}
11846

11947
export const encodeImageToThumbhash = async (image: HTMLImageElement) => {

packages/rich-react/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export interface EnrichmentImage {
2121
width?: number
2222
height?: number
2323
alt?: string
24-
blurhash?: string
24+
thumbhash?: string
2525
palette?: EnrichmentImagePalette
2626
}
2727

pnpm-lock.yaml

Lines changed: 3 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)