mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
perf: 优化图片消息后台加载与解密缓存
- 缩略图优先展示并在后台准备原图 - 增加图片请求去重与受控并发队列 - 缓存图片路径、账号目录和解密结果
This commit is contained in:
@@ -9,10 +9,23 @@ const imageDecryptLog = (...args: unknown[]): void => {
|
||||
if (imageDecryptDebugEnabled) console.log(...args)
|
||||
}
|
||||
|
||||
type DecodedImage = {
|
||||
data: string
|
||||
filePath: string
|
||||
isThumbnail: boolean
|
||||
}
|
||||
|
||||
const MAX_DECODED_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
|
||||
|
||||
export class ImageDecryptService {
|
||||
private xorKey: number = 0
|
||||
private aesKey: string = ''
|
||||
private wcdb4Client: Wcdb4Client | null = null
|
||||
private accountDirResolved = false
|
||||
private cachedAccountDir: string | null = null
|
||||
private imagePathCache = new Map<string, string>()
|
||||
private decodedImageCache = new Map<string, DecodedImage>()
|
||||
private decodedImageCacheBytes = 0
|
||||
|
||||
constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) {
|
||||
// 解析 XOR Key (支持 0x40 或 64 格式)
|
||||
@@ -32,9 +45,13 @@ export class ImageDecryptService {
|
||||
* 获取账号目录
|
||||
*/
|
||||
private getAccountDir(): string | null {
|
||||
if (this.accountDirResolved) return this.cachedAccountDir
|
||||
this.accountDirResolved = true
|
||||
|
||||
const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot()
|
||||
if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) {
|
||||
return wcdbAccountRoot
|
||||
this.cachedAccountDir = wcdbAccountRoot
|
||||
return this.cachedAccountDir
|
||||
}
|
||||
|
||||
const homeDir = os.homedir()
|
||||
@@ -69,7 +86,8 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
// 返回最新的账号目录
|
||||
return join(accountRoot, accounts[0].name)
|
||||
this.cachedAccountDir = join(accountRoot, accounts[0].name)
|
||||
return this.cachedAccountDir
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,16 +96,32 @@ export class ImageDecryptService {
|
||||
findImageFile(
|
||||
md5?: string,
|
||||
imageDatName?: string,
|
||||
options?: { allowThumbnail?: boolean; accountDir?: string }
|
||||
options?: { allowThumbnail?: boolean; accountDir?: string; preferThumbnail?: boolean }
|
||||
): string | null {
|
||||
// 测试场景下可显式指定根目录;不传则维持原 getAccountDir() 行为
|
||||
const accountDir =
|
||||
options?.accountDir && existsSync(options.accountDir) ? options.accountDir : this.getAccountDir()
|
||||
if (!accountDir) return null
|
||||
const allowThumbnail = options?.allowThumbnail !== false
|
||||
|
||||
const normalizedMd5 = this.normalizeDatBase(md5 || '')
|
||||
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
|
||||
const pathCacheKey = [
|
||||
normalizedMd5,
|
||||
normalizedDatName,
|
||||
allowThumbnail ? 'thumb' : 'original',
|
||||
options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original',
|
||||
options?.accountDir || ''
|
||||
].join('|')
|
||||
const cachedPath = this.imagePathCache.get(pathCacheKey)
|
||||
if (cachedPath && existsSync(cachedPath)) return cachedPath
|
||||
|
||||
const rememberPath = (path: string | null): string | null => {
|
||||
if (path) this.imagePathCache.set(pathCacheKey, path)
|
||||
return path
|
||||
}
|
||||
|
||||
// 测试场景下可显式指定根目录;不传则维持原 getAccountDir() 行为
|
||||
const accountDir =
|
||||
options?.accountDir && existsSync(options.accountDir)
|
||||
? options.accountDir
|
||||
: this.getAccountDir()
|
||||
if (!accountDir) return null
|
||||
imageDecryptLog('[ImageDecrypt] findImageFile:', {
|
||||
md5: normalizedMd5,
|
||||
imageDatName: normalizedDatName,
|
||||
@@ -99,10 +133,14 @@ export class ImageDecryptService {
|
||||
const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
|
||||
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
|
||||
if (fullPath && existsSync(fullPath)) {
|
||||
const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail)
|
||||
const selected = this.getPreferredDatVariantPath(
|
||||
fullPath,
|
||||
allowThumbnail,
|
||||
options?.preferThumbnail
|
||||
)
|
||||
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
|
||||
imageDecryptLog('[ImageDecrypt] hardlink hit:', selected)
|
||||
return selected
|
||||
return rememberPath(selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,28 +149,75 @@ export class ImageDecryptService {
|
||||
const attachDir = join(accountDir, 'msg', 'attach')
|
||||
if (!existsSync(attachDir)) {
|
||||
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
|
||||
return this.findImageFileInLegacyDirs(accountDir, normalizedMd5 || normalizedDatName)
|
||||
return rememberPath(
|
||||
this.findImageFileInLegacyDirs(
|
||||
accountDir,
|
||||
normalizedMd5 || normalizedDatName,
|
||||
allowThumbnail,
|
||||
options?.preferThumbnail
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const searchKeys = this.uniq([normalizedMd5, normalizedDatName])
|
||||
if (searchKeys.length === 0) return null
|
||||
|
||||
for (const key of searchKeys) {
|
||||
const directHit = this.fastProbabilisticSearch(attachDir, key, allowThumbnail)
|
||||
if (directHit) return directHit
|
||||
const directHit = this.fastProbabilisticSearch(
|
||||
attachDir,
|
||||
key,
|
||||
allowThumbnail,
|
||||
options?.preferThumbnail
|
||||
)
|
||||
if (directHit) return rememberPath(directHit)
|
||||
}
|
||||
|
||||
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail)
|
||||
if (legacyHit) return legacyHit
|
||||
const legacyHit = this.findImageFileInLegacyDirs(
|
||||
accountDir,
|
||||
searchKeys[0],
|
||||
allowThumbnail,
|
||||
options?.preferThumbnail
|
||||
)
|
||||
if (legacyHit) return rememberPath(legacyHit)
|
||||
|
||||
imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
||||
return null
|
||||
}
|
||||
|
||||
getCachedDecodedImage(key: string): DecodedImage | null {
|
||||
const cached = this.decodedImageCache.get(key)
|
||||
if (!cached) return null
|
||||
this.decodedImageCache.delete(key)
|
||||
this.decodedImageCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
cacheDecodedImage(key: string, image: DecodedImage): void {
|
||||
const size = image.data.length * 2
|
||||
const previous = this.decodedImageCache.get(key)
|
||||
if (previous) {
|
||||
this.decodedImageCacheBytes -= previous.data.length * 2
|
||||
this.decodedImageCache.delete(key)
|
||||
}
|
||||
this.decodedImageCache.set(key, image)
|
||||
this.decodedImageCacheBytes += size
|
||||
while (
|
||||
this.decodedImageCacheBytes > MAX_DECODED_IMAGE_CACHE_BYTES &&
|
||||
this.decodedImageCache.size > 1
|
||||
) {
|
||||
const oldestKey = this.decodedImageCache.keys().next().value
|
||||
if (!oldestKey) break
|
||||
const oldest = this.decodedImageCache.get(oldestKey)
|
||||
this.decodedImageCache.delete(oldestKey)
|
||||
this.decodedImageCacheBytes -= oldest?.data.length ? oldest.data.length * 2 : 0
|
||||
}
|
||||
}
|
||||
|
||||
private fastProbabilisticSearch(
|
||||
attachDir: string,
|
||||
datName: string,
|
||||
allowThumbnail = true
|
||||
allowThumbnail = true,
|
||||
preferThumbnail = false
|
||||
): string | null {
|
||||
const normalized = this.normalizeDatBase(datName)
|
||||
if (!normalized) return null
|
||||
@@ -149,7 +234,7 @@ export class ImageDecryptService {
|
||||
join(attachDir, dir1, dir2, 'Image', variant),
|
||||
join(attachDir, dir1, dir2, 'image', variant)
|
||||
]
|
||||
const found = this.getLargestExistingPath(candidates, allowThumbnail)
|
||||
const found = this.getLargestExistingPath(candidates, allowThumbnail, preferThumbnail)
|
||||
if (found) {
|
||||
imageDecryptLog('[ImageDecrypt] prefix path hit:', found)
|
||||
return found
|
||||
@@ -177,7 +262,8 @@ export class ImageDecryptService {
|
||||
|
||||
const found = this.getLargestExistingPath(
|
||||
variants.map((variant) => join(imgDir, variant)),
|
||||
allowThumbnail
|
||||
allowThumbnail,
|
||||
preferThumbnail
|
||||
)
|
||||
if (found) {
|
||||
imageDecryptLog('[ImageDecrypt] found at:', found)
|
||||
@@ -196,7 +282,8 @@ export class ImageDecryptService {
|
||||
private findImageFileInLegacyDirs(
|
||||
accountDir: string,
|
||||
datName: string,
|
||||
allowThumbnail = true
|
||||
allowThumbnail = true,
|
||||
preferThumbnail = false
|
||||
): string | null {
|
||||
const normalized = this.normalizeDatBase(datName)
|
||||
if (!normalized) return null
|
||||
@@ -208,7 +295,7 @@ export class ImageDecryptService {
|
||||
].filter((root) => existsSync(root))
|
||||
|
||||
for (const root of roots) {
|
||||
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail)
|
||||
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail, preferThumbnail)
|
||||
if (found) return found
|
||||
}
|
||||
|
||||
@@ -219,30 +306,52 @@ export class ImageDecryptService {
|
||||
dir: string,
|
||||
datName: string,
|
||||
depth: number,
|
||||
allowThumbnail = true
|
||||
allowThumbnail = true,
|
||||
preferThumbnail = false
|
||||
): string | null {
|
||||
if (depth < 0) return null
|
||||
|
||||
try {
|
||||
const variantNames = this.buildPreferredDatNames(datName).filter(
|
||||
(name) => allowThumbnail || !this.isThumbnailName(name)
|
||||
)
|
||||
const variants = new Set(
|
||||
this.buildPreferredDatNames(datName).filter(
|
||||
(name) => allowThumbnail || !this.isThumbnailName(name)
|
||||
)
|
||||
preferThumbnail
|
||||
? [
|
||||
...variantNames.filter((name) => this.isThumbnailName(name)),
|
||||
...variantNames.filter((name) => !this.isThumbnailName(name))
|
||||
]
|
||||
: variantNames
|
||||
)
|
||||
const entries = readdirSync(dir)
|
||||
const matchingFiles: string[] = []
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry)
|
||||
const stat = statSync(fullPath)
|
||||
if (stat.isFile() && variants.has(entry.toLowerCase())) {
|
||||
imageDecryptLog('[ImageDecrypt] legacy path hit:', fullPath)
|
||||
return fullPath
|
||||
matchingFiles.push(fullPath)
|
||||
}
|
||||
}
|
||||
const preferredFile = this.getLargestExistingPath(
|
||||
matchingFiles,
|
||||
allowThumbnail,
|
||||
preferThumbnail
|
||||
)
|
||||
if (preferredFile) {
|
||||
imageDecryptLog('[ImageDecrypt] legacy path hit:', preferredFile)
|
||||
return preferredFile
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry)
|
||||
if (!statSync(fullPath).isDirectory()) continue
|
||||
const found = this.recursiveFindDat(fullPath, datName, depth - 1, allowThumbnail)
|
||||
const found = this.recursiveFindDat(
|
||||
fullPath,
|
||||
datName,
|
||||
depth - 1,
|
||||
allowThumbnail,
|
||||
preferThumbnail
|
||||
)
|
||||
if (found) return found
|
||||
}
|
||||
} catch {
|
||||
@@ -491,7 +600,11 @@ export class ImageDecryptService {
|
||||
]
|
||||
}
|
||||
|
||||
private getPreferredDatVariantPath(inputPath: string, allowThumbnail: boolean): string {
|
||||
private getPreferredDatVariantPath(
|
||||
inputPath: string,
|
||||
allowThumbnail: boolean,
|
||||
preferThumbnail = false
|
||||
): string {
|
||||
const actualDir = dirname(inputPath)
|
||||
const base = this.normalizeDatBase(basename(inputPath))
|
||||
const variants = this.buildPreferredDatNames(base)
|
||||
@@ -500,13 +613,18 @@ export class ImageDecryptService {
|
||||
: variants.filter((name) => !this.isThumbnailName(name))
|
||||
const largest = this.getLargestExistingPath(
|
||||
ordered.map((variant) => join(actualDir, variant)),
|
||||
allowThumbnail
|
||||
allowThumbnail,
|
||||
preferThumbnail
|
||||
)
|
||||
if (largest) return largest
|
||||
return inputPath
|
||||
}
|
||||
|
||||
private getLargestExistingPath(paths: string[], allowThumbnail: boolean): string | null {
|
||||
private getLargestExistingPath(
|
||||
paths: string[],
|
||||
allowThumbnail: boolean,
|
||||
preferThumbnail = false
|
||||
): string | null {
|
||||
const toSized = (candidates: string[]): { candidate: string; size: number }[] =>
|
||||
candidates
|
||||
.filter((candidate) => existsSync(candidate))
|
||||
@@ -519,6 +637,10 @@ export class ImageDecryptService {
|
||||
})
|
||||
.sort((left, right) => right.size - left.size)
|
||||
|
||||
const thumbnail = toSized(
|
||||
paths.filter((candidate) => this.isThumbnailName(basename(candidate)))
|
||||
)
|
||||
if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate
|
||||
const nonThumb = toSized(
|
||||
paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
|
||||
)
|
||||
|
||||
+25
-3
@@ -702,7 +702,7 @@ app.whenReady().then(async () => {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
_sessionId?: string,
|
||||
options?: { force?: boolean }
|
||||
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||
) => {
|
||||
void _sessionId
|
||||
if (!imageDecryptService) {
|
||||
@@ -719,12 +719,28 @@ app.whenReady().then(async () => {
|
||||
|
||||
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
||||
const force = options?.force === true
|
||||
const preferThumbnail = options?.preferThumbnail === true
|
||||
const imageCacheKey = [
|
||||
imageMd5 || '',
|
||||
imageDatName || '',
|
||||
force ? 'original' : preferThumbnail ? 'thumbnail' : 'auto'
|
||||
].join('|')
|
||||
const cachedImage = imageDecryptService.getCachedDecodedImage(imageCacheKey)
|
||||
if (cachedImage) {
|
||||
return {
|
||||
success: true,
|
||||
data: cachedImage.data,
|
||||
isThumb: cachedImage.isThumbnail,
|
||||
filePath: cachedImage.filePath
|
||||
}
|
||||
}
|
||||
let filePath = force
|
||||
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
|
||||
: null
|
||||
if (!filePath) {
|
||||
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
|
||||
allowThumbnail: true
|
||||
allowThumbnail: true,
|
||||
preferThumbnail
|
||||
})
|
||||
}
|
||||
if (!filePath) {
|
||||
@@ -736,12 +752,18 @@ app.whenReady().then(async () => {
|
||||
return { success: false, error: '图片解密失败' }
|
||||
}
|
||||
|
||||
return {
|
||||
const result = {
|
||||
success: true,
|
||||
data: decrypted.data,
|
||||
isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
|
||||
filePath: decrypted.filePath
|
||||
}
|
||||
imageDecryptService.cacheDecodedImage(imageCacheKey, {
|
||||
data: result.data,
|
||||
filePath: result.filePath,
|
||||
isThumbnail: result.isThumb
|
||||
})
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -196,7 +196,7 @@ declare global {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
sessionId?: string,
|
||||
options?: { force?: boolean }
|
||||
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
data?: string
|
||||
|
||||
@@ -61,7 +61,7 @@ const api = {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
sessionId?: string,
|
||||
options?: { force?: boolean }
|
||||
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
|
||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||
|
||||
@@ -1,44 +1,6 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import type { JSX, MouseEvent } from 'react'
|
||||
|
||||
type CachedImage = { data: string; isThumbnail: boolean }
|
||||
|
||||
const MAX_IMAGE_CACHE_ENTRIES = 80
|
||||
const imageDataUrlCache = new Map<string, CachedImage>()
|
||||
|
||||
function imageCacheKeys(imageMd5?: string, imageDatName?: string): string[] {
|
||||
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
|
||||
Boolean
|
||||
)
|
||||
}
|
||||
|
||||
function getCachedImage(imageMd5?: string, imageDatName?: string): CachedImage | undefined {
|
||||
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
|
||||
const cached = imageDataUrlCache.get(key)
|
||||
if (cached) {
|
||||
imageDataUrlCache.delete(key)
|
||||
imageDataUrlCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function cacheImage(
|
||||
imageMd5: string | undefined,
|
||||
imageDatName: string | undefined,
|
||||
cached: CachedImage
|
||||
): void {
|
||||
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
|
||||
imageDataUrlCache.delete(key)
|
||||
imageDataUrlCache.set(key, cached)
|
||||
}
|
||||
while (imageDataUrlCache.size > MAX_IMAGE_CACHE_ENTRIES) {
|
||||
const oldestKey = imageDataUrlCache.keys().next().value
|
||||
if (!oldestKey) break
|
||||
imageDataUrlCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
import { getCachedLoadedImage, requestImage } from './image-loader'
|
||||
|
||||
interface ImageBubbleProps {
|
||||
imageMd5?: string
|
||||
@@ -53,11 +15,12 @@ export function ImageBubble({
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
isThumb = false,
|
||||
fallbackUrl,
|
||||
onImageClick
|
||||
}: ImageBubbleProps): JSX.Element {
|
||||
const initialCachedImage = getCachedImage(imageMd5, imageDatName)
|
||||
const initialCachedImage = getCachedLoadedImage(imageMd5, imageDatName, {
|
||||
preferThumbnail: true
|
||||
})
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(initialCachedImage?.data || null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [upgrading, setUpgrading] = useState(false)
|
||||
@@ -65,6 +28,26 @@ export function ImageBubble({
|
||||
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
|
||||
const [usingFallback, setUsingFallback] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mountedRef = useRef(true)
|
||||
const backgroundUpgradeRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const upgradeOriginalInBackground = useCallback(() => {
|
||||
if (!isThumbnail || backgroundUpgradeRef.current || (!imageMd5 && !imageDatName)) return
|
||||
backgroundUpgradeRef.current = true
|
||||
void requestImage(imageMd5, imageDatName, sessionId, { force: true }, 1)
|
||||
.then((original) => {
|
||||
if (!mountedRef.current) return
|
||||
setImageUrl(original.data)
|
||||
setIsThumbnail(false)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [imageDatName, imageMd5, isThumbnail, sessionId])
|
||||
|
||||
const loadImage = useCallback(async () => {
|
||||
if (imageUrl || loading) return
|
||||
@@ -81,37 +64,42 @@ export function ImageBubble({
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
|
||||
if (result.success && result.data?.startsWith('data:image/')) {
|
||||
cacheImage(imageMd5, imageDatName, {
|
||||
data: result.data,
|
||||
isThumbnail: Boolean(result.isThumb)
|
||||
})
|
||||
setImageUrl(result.data)
|
||||
setUsingFallback(false)
|
||||
setIsThumbnail(Boolean(result.isThumb))
|
||||
setError(null)
|
||||
} else {
|
||||
if (fallbackUrl) {
|
||||
setImageUrl(fallbackUrl)
|
||||
setUsingFallback(true)
|
||||
setError(null)
|
||||
} else {
|
||||
setError(result.error || '加载图片失败')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const result = await requestImage(
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
{ preferThumbnail: true },
|
||||
0
|
||||
)
|
||||
setImageUrl(result.data)
|
||||
setUsingFallback(false)
|
||||
setIsThumbnail(result.isThumbnail)
|
||||
setError(null)
|
||||
if (result.isThumbnail) upgradeOriginalInBackground()
|
||||
} catch (error) {
|
||||
if (fallbackUrl) {
|
||||
setImageUrl(fallbackUrl)
|
||||
setUsingFallback(true)
|
||||
setError(null)
|
||||
} else {
|
||||
setError('加载图片失败')
|
||||
setError(error instanceof Error ? error.message : '加载图片失败')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [fallbackUrl, imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
|
||||
}, [
|
||||
fallbackUrl,
|
||||
imageDatName,
|
||||
imageMd5,
|
||||
imageUrl,
|
||||
loading,
|
||||
sessionId,
|
||||
upgradeOriginalInBackground
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialCachedImage?.isThumbnail) upgradeOriginalInBackground()
|
||||
}, [initialCachedImage?.isThumbnail, upgradeOriginalInBackground])
|
||||
|
||||
useEffect(() => {
|
||||
if (imageUrl || loading || error) return
|
||||
@@ -154,17 +142,11 @@ export function ImageBubble({
|
||||
|
||||
setUpgrading(true)
|
||||
try {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId, {
|
||||
force: true
|
||||
})
|
||||
if (result.success && result.data?.startsWith('data:image/')) {
|
||||
cacheImage(imageMd5, imageDatName, {
|
||||
data: result.data,
|
||||
isThumbnail: Boolean(result.isThumb)
|
||||
})
|
||||
const result = await requestImage(imageMd5, imageDatName, sessionId, { force: true }, 0)
|
||||
if (result.data.startsWith('data:image/')) {
|
||||
setImageUrl(result.data)
|
||||
setUsingFallback(false)
|
||||
setIsThumbnail(Boolean(result.isThumb))
|
||||
setIsThumbnail(result.isThumbnail)
|
||||
setError(null)
|
||||
onImageClick?.(result.data)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
export type LoadedImage = {
|
||||
data: string
|
||||
isThumbnail: boolean
|
||||
}
|
||||
|
||||
export type ImageLoadOptions = {
|
||||
force?: boolean
|
||||
preferThumbnail?: boolean
|
||||
}
|
||||
|
||||
type QueueItem = {
|
||||
priority: number
|
||||
run: () => Promise<LoadedImage>
|
||||
resolve: (value: LoadedImage) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_IMAGE_LOADS = 3
|
||||
const MAX_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
|
||||
const imageCache = new Map<string, LoadedImage>()
|
||||
const imageCacheSizes = new Map<string, number>()
|
||||
const imageRequests = new Map<string, Promise<LoadedImage>>()
|
||||
const imageQueue: QueueItem[] = []
|
||||
let activeImageLoads = 0
|
||||
let imageCacheBytes = 0
|
||||
|
||||
function imageIdentityKeys(imageMd5?: string, imageDatName?: string): string[] {
|
||||
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
|
||||
Boolean
|
||||
)
|
||||
}
|
||||
|
||||
function cacheMode(options: ImageLoadOptions): string {
|
||||
if (options.force) return 'original'
|
||||
if (options.preferThumbnail) return 'thumbnail'
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
function cacheKeys(
|
||||
imageMd5: string | undefined,
|
||||
imageDatName: string | undefined,
|
||||
options: ImageLoadOptions
|
||||
): string[] {
|
||||
return imageIdentityKeys(imageMd5, imageDatName).map(
|
||||
(identity) => `${identity}:${cacheMode(options)}`
|
||||
)
|
||||
}
|
||||
|
||||
function getCachedImage(
|
||||
imageMd5: string | undefined,
|
||||
imageDatName: string | undefined,
|
||||
options: ImageLoadOptions
|
||||
): LoadedImage | undefined {
|
||||
const keys = cacheKeys(imageMd5, imageDatName, options)
|
||||
for (const key of keys) {
|
||||
const cached = imageCache.get(key)
|
||||
if (!cached) continue
|
||||
imageCache.delete(key)
|
||||
imageCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
if (!options.force && options.preferThumbnail) {
|
||||
for (const identity of imageIdentityKeys(imageMd5, imageDatName)) {
|
||||
const fallbackKey = `${identity}:auto`
|
||||
const cached = imageCache.get(fallbackKey)
|
||||
if (cached) return cached
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function cacheImage(
|
||||
imageMd5: string | undefined,
|
||||
imageDatName: string | undefined,
|
||||
options: ImageLoadOptions,
|
||||
image: LoadedImage
|
||||
): void {
|
||||
const keys = cacheKeys(imageMd5, imageDatName, options)
|
||||
const size = image.data.length * 2
|
||||
for (const key of keys) {
|
||||
const previousSize = imageCacheSizes.get(key) || 0
|
||||
imageCacheBytes -= previousSize
|
||||
imageCache.delete(key)
|
||||
imageCacheSizes.delete(key)
|
||||
imageCache.set(key, image)
|
||||
imageCacheSizes.set(key, size)
|
||||
imageCacheBytes += size
|
||||
}
|
||||
|
||||
while (imageCacheBytes > MAX_IMAGE_CACHE_BYTES && imageCache.size > 1) {
|
||||
const oldestKey = imageCache.keys().next().value
|
||||
if (!oldestKey) break
|
||||
imageCache.delete(oldestKey)
|
||||
imageCacheBytes -= imageCacheSizes.get(oldestKey) || 0
|
||||
imageCacheSizes.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
function pumpImageQueue(): void {
|
||||
while (activeImageLoads < MAX_CONCURRENT_IMAGE_LOADS && imageQueue.length > 0) {
|
||||
imageQueue.sort((left, right) => left.priority - right.priority)
|
||||
const item = imageQueue.shift()
|
||||
if (!item) return
|
||||
activeImageLoads += 1
|
||||
void item
|
||||
.run()
|
||||
.then(item.resolve, item.reject)
|
||||
.finally(() => {
|
||||
activeImageLoads -= 1
|
||||
pumpImageQueue()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedLoadedImage(
|
||||
imageMd5?: string,
|
||||
imageDatName?: string,
|
||||
options: ImageLoadOptions = {}
|
||||
): LoadedImage | undefined {
|
||||
return getCachedImage(imageMd5, imageDatName, options)
|
||||
}
|
||||
|
||||
export function requestImage(
|
||||
imageMd5: string | undefined,
|
||||
imageDatName: string | undefined,
|
||||
sessionId: string | undefined,
|
||||
options: ImageLoadOptions = {},
|
||||
priority = 0
|
||||
): Promise<LoadedImage> {
|
||||
const cached = getCachedImage(imageMd5, imageDatName, options)
|
||||
if (cached) return Promise.resolve(cached)
|
||||
|
||||
const identity = imageIdentityKeys(imageMd5, imageDatName)[0]
|
||||
if (!identity) return Promise.reject(new Error('缺少图片标识'))
|
||||
const requestKey = `${identity}:${cacheMode(options)}`
|
||||
const existingRequest = imageRequests.get(requestKey)
|
||||
if (existingRequest) return existingRequest
|
||||
|
||||
const request = new Promise<LoadedImage>((resolve, reject) => {
|
||||
imageQueue.push({
|
||||
priority,
|
||||
resolve,
|
||||
reject,
|
||||
run: async () => {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName, sessionId, options)
|
||||
if (!result.success || !result.data?.startsWith('data:image/')) {
|
||||
throw new Error(result.error || '加载图片失败')
|
||||
}
|
||||
const loadedImage = {
|
||||
data: result.data,
|
||||
isThumbnail: Boolean(result.isThumb)
|
||||
}
|
||||
cacheImage(imageMd5, imageDatName, options, loadedImage)
|
||||
return loadedImage
|
||||
}
|
||||
})
|
||||
pumpImageQueue()
|
||||
})
|
||||
imageRequests.set(requestKey, request)
|
||||
void request.then(
|
||||
() => imageRequests.delete(requestKey),
|
||||
() => imageRequests.delete(requestKey)
|
||||
)
|
||||
return request
|
||||
}
|
||||
Reference in New Issue
Block a user