From 291c82f0e24bcb6ce21172b97049c1c8bc814d42 Mon Sep 17 00:00:00 2001 From: Nanin <7017924+nanin@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:53:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dbug=EF=BC=8C=E7=AC=AC?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E5=AF=BC=E5=87=BA=E7=BC=A9=E7=95=A5=E5=9B=BE?= =?UTF-8?q?=EF=BC=8C=E5=90=8E=E9=9D=A2=E6=9C=89=E4=BA=86=E9=AB=98=E6=B8=85?= =?UTF-8?q?=E5=9B=BE=E5=90=8E=E5=86=8D=E6=AC=A1=E5=AF=BC=E5=87=BA=E5=BA=94?= =?UTF-8?q?=E8=AF=A5=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按原图、中图和缩略图对本地图片资源分级,始终优先选择高清变体。 增量导出仅复用满足清晰度要求的图片,重新探测旧缩略图并在高清图出现后替换;仅缩略图降级时显示提示。 --- src/main/export-service.ts | 13 ++- src/main/image-decrypt-service.ts | 46 ++++++---- src/main/services/chat-service.ts | 2 + src/shared/export-media.ts | 9 +- src/shared/image-quality.ts | 24 ++++++ src/shared/types.ts | 3 + tests/integration/export-media-flow.test.ts | 96 ++++++++++++++++++++- tests/unit/export-media.test.ts | 4 +- tests/unit/image-decrypt.test.ts | 20 +++++ 9 files changed, 185 insertions(+), 32 deletions(-) create mode 100644 src/shared/image-quality.ts diff --git a/src/main/export-service.ts b/src/main/export-service.ts index f9f04ed..3a43928 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -23,6 +23,7 @@ import { getImageExportAttempts } from '../shared/export-media' import { FileAssetService } from './file-asset-service' import { mergeCachedSelfInfo } from './services/bootstrap-cache' import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case' +import { imageFileQuality } from '../shared/image-quality' const jobs = new Set() const activeArchives = new Map() @@ -250,6 +251,7 @@ const mergeArchiveMessage = (previous: Message, current: Message): Message => { 'exportMediaUrl', 'exportMediaType', 'exportMediaName', + 'exportMediaQuality', 'exportAvatarUrl' ] for (const key of preserveWhenMissing) { @@ -706,6 +708,7 @@ export async function runExport( message.exportMediaUrl = undefined message.exportMediaType = undefined message.exportMediaName = undefined + message.exportMediaQuality = undefined message.exportMediaError = undefined message.voiceDataUrl = undefined message.voiceTranscript = undefined @@ -1063,15 +1066,20 @@ export async function runExport( : message.contentData.type === 'share' && message.contentData.typeVal === '6' ? 'file' : null + const reusableImageQuality = + previous?.exportMediaQuality === 'original' || + (request.preferOriginal === false && previous?.exportMediaQuality === 'thumbnail') if ( reusableMediaType && previous?.exportMediaUrl && (!previous.exportMediaType || previous.exportMediaType === reusableMediaType) && + (reusableMediaType !== 'image' || reusableImageQuality) && (await resourceExists(previous.exportMediaUrl)) ) { message.exportMediaUrl = previous.exportMediaUrl message.exportMediaType = reusableMediaType message.exportMediaName = previous.exportMediaName + message.exportMediaQuality = previous.exportMediaQuality send({ jobId: request.jobId, phase: 'media', @@ -1087,7 +1095,6 @@ export async function runExport( } else { let fileFound = false let decryptedImage: { data: string; filePath: string } | null = null - let usedFallback = false for (const attempt of getImageExportAttempts(request)) { const file = await imageService.findImageFileAsync( message.contentData.md5, @@ -1116,7 +1123,6 @@ export async function runExport( } if (!decrypted) continue decryptedImage = decrypted - usedFallback = attempt.fallback || imageService.isThumbnailFile(decrypted.filePath) break } const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null @@ -1129,7 +1135,8 @@ export async function runExport( } message.exportMediaUrl = mediaUrl message.exportMediaType = 'image' - if (usedFallback) { + message.exportMediaQuality = imageFileQuality(decryptedImage!.filePath) + if (request.preferOriginal !== false && message.exportMediaQuality === 'thumbnail') { keepMediaError(request, message, '原图不可用,已降级使用缩略图') } } else if (!fileFound) { diff --git a/src/main/image-decrypt-service.ts b/src/main/image-decrypt-service.ts index e197b21..5d6cf1d 100644 --- a/src/main/image-decrypt-service.ts +++ b/src/main/image-decrypt-service.ts @@ -7,6 +7,7 @@ import { execFile } from 'child_process' import { Worker } from 'worker_threads' import ffmpegStaticPath from 'ffmpeg-static' import type { ImageDecoderSource, ImageDecoderStatus } from '../shared/image-decryption' +import { imageFileQuality, imageQualityRank } from '../shared/image-quality' import { loadSettings } from './services/settings-store' import { Wcdb4Client } from './wcdb4-client' @@ -208,6 +209,13 @@ function isThumbnailName(fileName) { return /(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower) } +function imageQualityRank(fileName) { + const lower = fileName.toLowerCase() + if (/(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower)) return 1 + if (/(?:_hd|\.hd|_h_m|_h|\.h)\.dat$/i.test(lower)) return 3 + return 2 +} + function buildPreferredDatNames(baseName) { const base = normalizeDatBase(baseName) if (!base) return [] @@ -436,8 +444,8 @@ function collectCandidates(datPath, allowThumbnail) { .map((name) => path.join(directory, name)) .filter((candidate) => fs.existsSync(candidate)) .sort((left, right) => { - const thumbnailOrder = Number(isThumbnailName(path.basename(left))) - Number(isThumbnailName(path.basename(right))) - return thumbnailOrder || fs.statSync(right).size - fs.statSync(left).size + const qualityOrder = imageQualityRank(path.basename(right)) - imageQualityRank(path.basename(left)) + return qualityOrder || fs.statSync(right).size - fs.statSync(left).size }) return Array.from(new Set(candidates.concat(siblings))) } @@ -1824,17 +1832,17 @@ export class ImageDecryptService { .sort((left, right) => right.size - left.size) const thumbnail = toSized( - paths.filter((candidate) => this.isThumbnailName(basename(candidate))) + paths.filter((candidate) => imageFileQuality(candidate) === 'thumbnail') ) if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate - const nonThumb = toSized( - paths.filter((candidate) => !this.isThumbnailName(basename(candidate))) - ) - if (nonThumb[0]) return nonThumb[0].candidate - if (!allowThumbnail) return null - - const existing = toSized(paths) - return existing[0]?.candidate || null + const allowed = toSized(paths) + .filter((entry) => allowThumbnail || imageFileQuality(entry.candidate) !== 'thumbnail') + .sort( + (left, right) => + imageQualityRank(imageFileQuality(right.candidate)) - + imageQualityRank(imageFileQuality(left.candidate)) || right.size - left.size + ) + return allowed[0]?.candidate || null } private async getLargestExistingPathAsync( @@ -1858,17 +1866,21 @@ export class ImageDecryptService { .sort((left, right) => right.size - left.size) if (preferThumbnail) { - const thumbnail = sized.find((entry) => this.isThumbnailName(basename(entry.candidate))) + const thumbnail = sized.find((entry) => imageFileQuality(entry.candidate) === 'thumbnail') if (thumbnail) return thumbnail.candidate } - const nonThumbnail = sized.find((entry) => !this.isThumbnailName(basename(entry.candidate))) - if (nonThumbnail) return nonThumbnail.candidate - return allowThumbnail ? sized[0]?.candidate || null : null + const allowed = sized + .filter((entry) => allowThumbnail || imageFileQuality(entry.candidate) !== 'thumbnail') + .sort( + (left, right) => + imageQualityRank(imageFileQuality(right.candidate)) - + imageQualityRank(imageFileQuality(left.candidate)) || right.size - left.size + ) + return allowed[0]?.candidate || null } private isThumbnailName(fileName: string): boolean { - const lower = fileName.toLowerCase() - return /(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower) + return imageFileQuality(fileName) === 'thumbnail' } isThumbnailFile(filePath: string): boolean { diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index 48ece7b..6b8a511 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -10,6 +10,7 @@ import type { DatabaseKeyValidationResult } from '../../shared/database-key' import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service' +import type { ExportImageQuality } from '../../shared/image-quality' export function getCurrentKey(): string { if (!dbRef) return '' @@ -60,6 +61,7 @@ export interface FormattedMessage { exportMediaUrl?: string exportMediaType?: 'image' | 'video' | 'sticker' | 'file' exportMediaName?: string + exportMediaQuality?: ExportImageQuality exportShowAvatar?: boolean exportMediaError?: string exportAvatarUrl?: string diff --git a/src/shared/export-media.ts b/src/shared/export-media.ts index 6e11855..32a9b8e 100644 --- a/src/shared/export-media.ts +++ b/src/shared/export-media.ts @@ -3,20 +3,17 @@ import type { ExportRequest } from './export' export type ImageExportAttempt = { allowThumbnail: boolean preferThumbnail: boolean - fallback: boolean } export function getImageExportAttempts( request: Pick ): ImageExportAttempt[] { if (request.preferOriginal === false) { - return [{ allowThumbnail: true, preferThumbnail: true, fallback: false }] + return [{ allowThumbnail: true, preferThumbnail: true }] } - const attempts: ImageExportAttempt[] = [ - { allowThumbnail: false, preferThumbnail: false, fallback: false } - ] + const attempts: ImageExportAttempt[] = [{ allowThumbnail: false, preferThumbnail: false }] if (request.fallbackThumbnail !== false) { - attempts.push({ allowThumbnail: true, preferThumbnail: true, fallback: true }) + attempts.push({ allowThumbnail: true, preferThumbnail: true }) } return attempts } diff --git a/src/shared/image-quality.ts b/src/shared/image-quality.ts new file mode 100644 index 0000000..db12258 --- /dev/null +++ b/src/shared/image-quality.ts @@ -0,0 +1,24 @@ +export type ExportImageQuality = 'original' | 'medium' | 'thumbnail' + +const fileNameOf = (filePath: string): string => + String(filePath || '') + .trim() + .toLowerCase() + .split(/[\\/]/) + .pop() || '' + +export const imageFileQuality = (filePath: string): ExportImageQuality => { + const fileName = fileNameOf(filePath) + if (!fileName.endsWith('.dat')) return 'original' + if (/(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(fileName)) { + return 'thumbnail' + } + if (/(?:_hd|\.hd|_h_m|_h|\.h)\.dat$/i.test(fileName)) return 'original' + return 'medium' +} + +export const imageQualityRank = (quality: ExportImageQuality): number => { + if (quality === 'original') return 3 + if (quality === 'medium') return 2 + return 1 +} diff --git a/src/shared/types.ts b/src/shared/types.ts index b31498d..e2e908d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,3 +1,5 @@ +import type { ExportImageQuality } from './image-quality' + export interface Contact { m_nsUsrName: string m_nsNickName: string @@ -36,6 +38,7 @@ export interface Message { exportMediaUrl?: string exportMediaType?: 'image' | 'video' | 'sticker' | 'file' exportMediaName?: string + exportMediaQuality?: ExportImageQuality exportShowAvatar?: boolean exportMediaError?: string exportAvatarUrl?: string diff --git a/tests/integration/export-media-flow.test.ts b/tests/integration/export-media-flow.test.ts index 1ab42c8..cb21b8b 100644 --- a/tests/integration/export-media-flow.test.ts +++ b/tests/integration/export-media-flow.test.ts @@ -41,7 +41,10 @@ const state = vi.hoisted(() => ({ sessionId?: string sessionMd5?: string createTime?: number - }[] + }[], + imageFilePath: 'fixture_h.dat', + imageData: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=' })) vi.mock('electron', () => ({ @@ -128,7 +131,7 @@ vi.mock('../../src/main/image-decrypt-service', () => ({ } ): string { state.imageLookups.push(options) - return 'fixture-original.dat' + return state.imageFilePath } async findImageFileAsync( md5: string, @@ -145,8 +148,8 @@ vi.mock('../../src/main/image-decrypt-service', () => ({ } decryptImageToBase64WithFallback(): { data: string; filePath: string } { return { - data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=', - filePath: 'fixture-original.dat' + data: state.imageData, + filePath: state.imageFilePath } } async decryptImageToBase64WithFallbackAsync(): Promise<{ @@ -229,6 +232,9 @@ describe('media export flow', () => { Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex') ) state.imageLookups = [] + state.imageFilePath = 'fixture_h.dat' + state.imageData = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=' state.videoLookups = [] state.messagesByUser = {} state.exportReads = [] @@ -595,6 +601,88 @@ describe('media export flow', () => { expect(existsSync(imagePath)).toBe(true) }) + it('rechecks a previous low-quality image and upgrades it when an original appears', async () => { + const { runExport } = await import('../../src/main/export-service') + const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } + const request = { + targets: [target('fixture-user', '图片升级会话')], + format: 'html' as const, + outputName: 'image-quality-upgrade-fixture', + kinds: ['image'] as const, + includeMedia: true, + preferOriginal: true, + fallbackThumbnail: true, + keepMissing: true + } + state.messages = [ + message({ + id: 'upgrade-image', + type: '图片', + sessionId: 'fixture-session', + contentData: { type: 'image', md5: 'c'.repeat(32), datName: 'upgrade.dat' } + }) + ] + state.imageFilePath = 'upgrade_t.dat' + state.imageData = `data:image/png;base64,${Buffer.from('thumbnail-image').toString('base64')}` + + const first = await runExport( + { ...request, jobId: 'image-quality-upgrade-first', kinds: [...request.kinds] }, + win as never + ) + expect(first.success, first.error).toBe(true) + const firstImage = readArchive(first.outputPath!).messages[0] + const firstImagePath = join(dirname(first.outputPath!), firstImage.exportMediaUrl!) + expect(firstImage.exportMediaQuality).toBe('thumbnail') + expect(firstImage.exportMediaError).toBe('原图不可用,已降级使用缩略图') + + state.imageFilePath = 'upgrade_h.dat' + state.imageData = `data:image/png;base64,${Buffer.from('high-resolution-image').toString('base64')}` + const second = await runExport( + { ...request, jobId: 'image-quality-upgrade-second', kinds: [...request.kinds] }, + win as never + ) + expect(second.success, second.error).toBe(true) + const upgradedImage = readArchive(second.outputPath!).messages[0] + expect(state.imageLookups).toHaveLength(2) + expect(upgradedImage.exportMediaQuality).toBe('original') + expect(upgradedImage.exportMediaError).toBeUndefined() + expect(upgradedImage.exportMediaUrl).not.toBe(firstImage.exportMediaUrl) + expect(existsSync(join(dirname(second.outputPath!), upgradedImage.exportMediaUrl!))).toBe(true) + expect(existsSync(firstImagePath)).toBe(false) + }) + + it('does not show a warning for a medium-quality image', async () => { + const { runExport } = await import('../../src/main/export-service') + state.messages = [ + message({ + id: 'medium-image', + type: '图片', + contentData: { type: 'image', md5: 'd'.repeat(32), datName: 'medium.dat' } + }) + ] + state.imageFilePath = 'medium.dat' + + const result = await runExport( + { + jobId: 'medium-image-export', + targets: [target()], + format: 'html', + outputName: 'medium-image-fixture', + kinds: ['image'], + includeMedia: true, + preferOriginal: true, + fallbackThumbnail: true, + keepMissing: true + }, + { isDestroyed: () => true, webContents: { send: vi.fn() } } as never + ) + + expect(result.success, result.error).toBe(true) + const exported = readArchive(result.outputPath!).messages[0] + expect(exported.exportMediaQuality).toBe('medium') + expect(exported.exportMediaError).toBeUndefined() + }) + it('keeps historical avatars and creates a new version only after a real visual change', async () => { const { runExport } = await import('../../src/main/export-service') const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } diff --git a/tests/unit/export-media.test.ts b/tests/unit/export-media.test.ts index 0cb7f50..7e0f810 100644 --- a/tests/unit/export-media.test.ts +++ b/tests/unit/export-media.test.ts @@ -15,8 +15,8 @@ describe('export media', () => { const repeated = getImageExportAttempts({ preferOriginal: true, fallbackThumbnail: true }) expect(first).toEqual([ - { allowThumbnail: false, preferThumbnail: false, fallback: false }, - { allowThumbnail: true, preferThumbnail: true, fallback: true } + { allowThumbnail: false, preferThumbnail: false }, + { allowThumbnail: true, preferThumbnail: true } ]) expect(repeated).toEqual(first) }) diff --git a/tests/unit/image-decrypt.test.ts b/tests/unit/image-decrypt.test.ts index 3fe52ba..48e3254 100644 --- a/tests/unit/image-decrypt.test.ts +++ b/tests/unit/image-decrypt.test.ts @@ -13,6 +13,7 @@ vi.mock('../../src/main/services/settings-store', () => ({ vi.mock('../../src/main/wcdb4-client', () => ({ Wcdb4Client: class {} })) import { ImageDecryptService } from '../../src/main/image-decrypt-service' +import { imageFileQuality } from '../../src/shared/image-quality' const aesKey = '0123456789abcdef' const xorKey = 0x40 @@ -109,6 +110,7 @@ describe('DAT image decryption', () => { mediumFile ) expect(service.decryptImageToBase64(mediumFile)).toMatch(/^data:image\/png;base64,/) + expect(imageFileQuality(mediumFile)).toBe('medium') expect(service.getLastDecodeDiagnostic()).toMatchObject({ code: 'DIRECT_IMAGE', imageFormat: 'PNG' @@ -121,6 +123,7 @@ describe('DAT image decryption', () => { const thumbnailFile = join(imageDirectory, `${thumbnailBase}_t_M.dat`) writeFileSync(thumbnailFile, Buffer.from([0xff, 0xd8, 0xff, 0x00])) expect(service.isThumbnailFile(thumbnailFile)).toBe(true) + expect(imageFileQuality(thumbnailFile)).toBe('thumbnail') await expect( service.findImageFileAsync(undefined, `${thumbnailBase}_t_M.dat`, { allowThumbnail: false, @@ -164,5 +167,22 @@ describe('DAT image decryption', () => { await expect(service.findImageFileAsync(undefined, bubbleBase, bubbleOptions)).resolves.toBe( bubblePreview ) + + const qualityBase = '6a62000000000000000000000000cafe' + const standardFile = join(imageDirectory, `${qualityBase}.dat`) + const highFile = join(imageDirectory, `${qualityBase}_h.dat`) + const largeThumbnailFile = join(imageDirectory, `${qualityBase}_t.dat`) + writeFileSync(standardFile, Buffer.alloc(200, 1)) + writeFileSync(highFile, Buffer.alloc(20, 2)) + writeFileSync(largeThumbnailFile, Buffer.alloc(400, 3)) + expect(imageFileQuality(highFile)).toBe('original') + await expect( + service.findImageFileAsync(undefined, qualityBase, { + allowThumbnail: false, + accountDir: accountRoot, + sessionMd5, + createTime: Math.floor(new Date(2025, 9, 15).getTime() / 1000) + }) + ).resolves.toBe(highFile) }) })