From d0ae9e601924c16502a56678448dfb4c4cc11a33 Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Tue, 28 Jul 2026 16:11:59 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=90=8E=E5=8F=B0=E5=8A=A0=E8=BD=BD=E4=B8=8E?= =?UTF-8?q?=E8=A7=A3=E5=AF=86=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 缩略图优先展示并在后台准备原图 - 增加图片请求去重与受控并发队列 - 缓存图片路径、账号目录和解密结果 --- src/main/image-decrypt-service.ts | 182 ++++++++++++++++---- src/main/index.ts | 28 ++- src/preload/index.d.ts | 2 +- src/preload/index.ts | 2 +- src/renderer/src/components/ImageBubble.tsx | 126 ++++++-------- src/renderer/src/components/image-loader.ts | 167 ++++++++++++++++++ 6 files changed, 400 insertions(+), 107 deletions(-) create mode 100644 src/renderer/src/components/image-loader.ts diff --git a/src/main/image-decrypt-service.ts b/src/main/image-decrypt-service.ts index b1cab03..c2d83e3 100644 --- a/src/main/image-decrypt-service.ts +++ b/src/main/image-decrypt-service.ts @@ -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() + private decodedImageCache = new Map() + 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))) ) diff --git a/src/main/index.ts b/src/main/index.ts index dfcc040..0df3d2c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 } ) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 929de27..d7ad952 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 781f8bd..7d028c3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/src/components/ImageBubble.tsx b/src/renderer/src/components/ImageBubble.tsx index 075d474..6380110 100644 --- a/src/renderer/src/components/ImageBubble.tsx +++ b/src/renderer/src/components/ImageBubble.tsx @@ -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() - -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(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(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 diff --git a/src/renderer/src/components/image-loader.ts b/src/renderer/src/components/image-loader.ts new file mode 100644 index 0000000..f3569b4 --- /dev/null +++ b/src/renderer/src/components/image-loader.ts @@ -0,0 +1,167 @@ +export type LoadedImage = { + data: string + isThumbnail: boolean +} + +export type ImageLoadOptions = { + force?: boolean + preferThumbnail?: boolean +} + +type QueueItem = { + priority: number + run: () => Promise + 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() +const imageCacheSizes = new Map() +const imageRequests = new Map>() +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 { + 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((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 +}