Skip to content

Commit 3874433

Browse files
authored
Merge pull request #75 from concrnt/blurhash
blurhash の実装をしようとしました。
2 parents d035b7f + 3bcbf7c commit 3874433

11 files changed

Lines changed: 233 additions & 6 deletions

File tree

app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"@tauri-apps/plugin-barcode-scanner": "^2.4.4",
1919
"@tauri-apps/plugin-haptics": "^2.3.2",
2020
"@tauri-apps/plugin-opener": "^2",
21+
"blurhash": "^2.0.5",
2122
"boring-avatars": "^2.0.4",
2223
"motion": "^12.36.0",
2324
"react": "^19.2.4",
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
import { decode } from 'blurhash'
3+
4+
interface BlurhashImageProps {
5+
src: string
6+
blurhash?: string
7+
alt?: string
8+
style?: React.CSSProperties
9+
}
10+
11+
const BLURHASH_WIDTH = 32
12+
const BLURHASH_HEIGHT = 32
13+
14+
/**
15+
* blurhashプレースホルダー付き画像コンポーネント。
16+
* blurhashが指定されていればロード中にぼかし画像を表示し、
17+
* 画像ロード完了後にフェードインする。
18+
*/
19+
export const BlurhashImage = (props: BlurhashImageProps) => {
20+
const [loaded, setLoaded] = useState(false)
21+
const canvasRef = useRef<HTMLCanvasElement>(null)
22+
23+
useEffect(() => {
24+
if (!props.blurhash || !canvasRef.current) return
25+
try {
26+
const pixels = decode(props.blurhash, BLURHASH_WIDTH, BLURHASH_HEIGHT)
27+
const ctx = canvasRef.current.getContext('2d')
28+
if (!ctx) return
29+
const imageData = ctx.createImageData(BLURHASH_WIDTH, BLURHASH_HEIGHT)
30+
imageData.data.set(pixels)
31+
ctx.putImageData(imageData, 0, 0)
32+
} catch (e) {
33+
console.warn('Failed to decode blurhash:', e)
34+
}
35+
}, [props.blurhash])
36+
37+
// blurhashがない場合は通常のimgを返す
38+
if (!props.blurhash) {
39+
return <img src={props.src} alt={props.alt ?? ''} style={props.style} />
40+
}
41+
42+
return (
43+
<div style={{ position: 'relative', overflow: 'hidden', lineHeight: 0 }}>
44+
<canvas
45+
ref={canvasRef}
46+
width={BLURHASH_WIDTH}
47+
height={BLURHASH_HEIGHT}
48+
style={{
49+
position: 'absolute',
50+
top: 0,
51+
left: 0,
52+
width: '100%',
53+
height: '100%',
54+
objectFit: 'cover',
55+
opacity: loaded ? 0 : 1,
56+
transition: 'opacity 0.3s ease'
57+
}}
58+
/>
59+
<img
60+
src={props.src}
61+
alt={props.alt ?? ''}
62+
onLoad={() => setLoaded(true)}
63+
style={{
64+
...props.style,
65+
opacity: loaded ? 1 : 0,
66+
transition: 'opacity 0.3s ease'
67+
}}
68+
/>
69+
</div>
70+
)
71+
}

app/src/components/Composer.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { CssVar } from '../types/Theme'
1010
import { ComposerMode } from '../contexts/Composer'
1111
import { MdImage, MdClose } from 'react-icons/md'
1212
import { uploadImage } from '../utils/uploadImage'
13+
import { computeBlurhash } from '../utils/computeBlurhash'
1314
import { hapticSuccess } from '../utils/haptics'
1415
import { MdSend } from 'react-icons/md'
1516
import { MdEmojiEmotions } from 'react-icons/md'
@@ -224,10 +225,14 @@ export const Composer = (props: Props) => {
224225
// 画像をアップロード
225226
const uploadedMedias = await Promise.all(
226227
mediaDrafts.map(async (media) => {
227-
const [url, typ] = await uploadImage(client, media.file)
228+
const [[url, typ], blurhash] = await Promise.all([
229+
uploadImage(client, media.file),
230+
computeBlurhash(media.file)
231+
])
228232
return {
229233
mediaURL: url,
230-
mediaType: typ
234+
mediaType: typ,
235+
...(blurhash ? { blurhash } : {})
231236
}
232237
})
233238
)

app/src/components/message/MediaMessage.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Avatar, CfmRenderer } from '@concrnt/ui'
99
import { MdPlayCircle, MdStop, MdViewInAr } from 'react-icons/md'
1010

1111
import { useMediaViewer } from '../../contexts/MediaViewer'
12+
import { BlurhashImage } from '../BlurhashImage'
1213
import { useAudioPlayer } from '../../contexts/AudioPlayer'
1314
import { MessageLayout } from './MessageLayout'
1415
import { TimeDiff } from '../TimeDiff'
@@ -92,8 +93,9 @@ export const MediaMessage = (props: MessageProps<MediaMessageSchema>) => {
9293
}}
9394
>
9495
{media.mediaType.startsWith('image/') ? (
95-
<img
96+
<BlurhashImage
9697
src={media.mediaURL}
98+
blurhash={media.blurhash}
9799
alt={media.altText ?? ''}
98100
style={{
99101
width: '100%',

app/src/utils/computeBlurhash.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { encode } from 'blurhash'
2+
3+
const RESIZE = 32
4+
const COMPONENT_X = 4
5+
const COMPONENT_Y = 3
6+
7+
/**
8+
* 画像ファイルからblurhashを計算する。
9+
* 画像以外のファイルが渡された場合はundefinedを返す。
10+
*/
11+
export const computeBlurhash = async (file: File): Promise<string | undefined> => {
12+
if (!file.type.startsWith('image/')) return undefined
13+
14+
const bitmap = await createImageBitmap(file)
15+
16+
const aspect = bitmap.width / bitmap.height
17+
const width = aspect >= 1 ? RESIZE : Math.round(RESIZE * aspect)
18+
const height = aspect >= 1 ? Math.round(RESIZE / aspect) : RESIZE
19+
20+
const canvas = new OffscreenCanvas(width, height)
21+
const ctx = canvas.getContext('2d')
22+
if (!ctx) return undefined
23+
24+
ctx.drawImage(bitmap, 0, 0, width, height)
25+
bitmap.close()
26+
27+
const imageData = ctx.getImageData(0, 0, width, height)
28+
return encode(imageData.data, width, height, COMPONENT_X, COMPONENT_Y)
29+
}

pnpm-lock.yaml

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

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@rjsf/utils": "^6.4.2",
2121
"@rjsf/validator-ajv8": "^6.4.2",
2222
"@types/react-google-recaptcha": "^2.1.9",
23+
"blurhash": "^2.0.5",
2324
"boring-avatars": "^2.0.4",
2425
"motion": "^12.36.0",
2526
"react": "^19.2.0",
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
import { decode } from 'blurhash'
3+
4+
interface BlurhashImageProps {
5+
src: string
6+
blurhash?: string
7+
alt?: string
8+
style?: React.CSSProperties
9+
}
10+
11+
const BLURHASH_WIDTH = 32
12+
const BLURHASH_HEIGHT = 32
13+
14+
/**
15+
* blurhashプレースホルダー付き画像コンポーネント。
16+
* blurhashが指定されていればロード中にぼかし画像を表示し、
17+
* 画像ロード完了後にフェードインする。
18+
*/
19+
export const BlurhashImage = (props: BlurhashImageProps) => {
20+
const [loaded, setLoaded] = useState(false)
21+
const canvasRef = useRef<HTMLCanvasElement>(null)
22+
23+
useEffect(() => {
24+
if (!props.blurhash || !canvasRef.current) return
25+
try {
26+
const pixels = decode(props.blurhash, BLURHASH_WIDTH, BLURHASH_HEIGHT)
27+
const ctx = canvasRef.current.getContext('2d')
28+
if (!ctx) return
29+
const imageData = ctx.createImageData(BLURHASH_WIDTH, BLURHASH_HEIGHT)
30+
imageData.data.set(pixels)
31+
ctx.putImageData(imageData, 0, 0)
32+
} catch (e) {
33+
console.warn('Failed to decode blurhash:', e)
34+
}
35+
}, [props.blurhash])
36+
37+
// blurhashがない場合は通常のimgを返す
38+
if (!props.blurhash) {
39+
return <img src={props.src} alt={props.alt ?? ''} style={props.style} />
40+
}
41+
42+
return (
43+
<div style={{ position: 'relative', overflow: 'hidden', lineHeight: 0 }}>
44+
<canvas
45+
ref={canvasRef}
46+
width={BLURHASH_WIDTH}
47+
height={BLURHASH_HEIGHT}
48+
style={{
49+
position: 'absolute',
50+
top: 0,
51+
left: 0,
52+
width: '100%',
53+
height: '100%',
54+
objectFit: 'cover',
55+
opacity: loaded ? 0 : 1,
56+
transition: 'opacity 0.3s ease'
57+
}}
58+
/>
59+
<img
60+
src={props.src}
61+
alt={props.alt ?? ''}
62+
onLoad={() => setLoaded(true)}
63+
style={{
64+
...props.style,
65+
opacity: loaded ? 1 : 0,
66+
transition: 'opacity 0.3s ease'
67+
}}
68+
/>
69+
</div>
70+
)
71+
}

web/src/components/Composer.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { CssVar } from '../types/Theme'
1010
import { ComposerMode } from '../contexts/Composer'
1111
import { MdImage, MdClose } from 'react-icons/md'
1212
import { uploadImage } from '../utils/uploadImage'
13+
import { computeBlurhash } from '../utils/computeBlurhash'
1314
import { hapticSuccess } from '../utils/haptics'
1415
import { MdSend } from 'react-icons/md'
1516
import { MdEmojiEmotions } from 'react-icons/md'
@@ -224,10 +225,14 @@ export const Composer = (props: Props) => {
224225
// 画像をアップロード
225226
const uploadedMedias = await Promise.all(
226227
mediaDrafts.map(async (media) => {
227-
const [url, typ] = await uploadImage(client, media.file)
228+
const [[url, typ], blurhash] = await Promise.all([
229+
uploadImage(client, media.file),
230+
computeBlurhash(media.file)
231+
])
228232
return {
229233
mediaURL: url,
230-
mediaType: typ
234+
mediaType: typ,
235+
...(blurhash ? { blurhash } : {})
231236
}
232237
})
233238
)

web/src/components/message/MediaMessage.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Avatar, CfmRenderer } from '@concrnt/ui'
55
import { MdPlayCircle, MdStop, MdViewInAr } from 'react-icons/md'
66

77
import { useMediaViewer } from '../../contexts/MediaViewer'
8+
import { BlurhashImage } from '../BlurhashImage'
89
import { useAudioPlayer } from '../../contexts/AudioPlayer'
910
import { MessageLayout } from './MessageLayout'
1011
import { TimeDiff } from '../TimeDiff'
@@ -89,8 +90,9 @@ export const MediaMessage = (props: MessageProps<MediaMessageSchema>) => {
8990
}}
9091
>
9192
{media.mediaType.startsWith('image/') ? (
92-
<img
93+
<BlurhashImage
9394
src={media.mediaURL}
95+
blurhash={media.blurhash}
9496
alt={media.altText ?? ''}
9597
style={{
9698
width: '100%',

0 commit comments

Comments
 (0)