修复bug,第一次导出缩略图,后面有了高清图后再次导出应该覆盖

按原图、中图和缩略图对本地图片资源分级,始终优先选择高清变体。

增量导出仅复用满足清晰度要求的图片,重新探测旧缩略图并在高清图出现后替换;仅缩略图降级时显示提示。
This commit is contained in:
Nanin
2026-08-08 20:53:40 +08:00
parent 666d8896ee
commit 291c82f0e2
9 changed files with 185 additions and 32 deletions
+10 -3
View File
@@ -23,6 +23,7 @@ import { getImageExportAttempts } from '../shared/export-media'
import { FileAssetService } from './file-asset-service' import { FileAssetService } from './file-asset-service'
import { mergeCachedSelfInfo } from './services/bootstrap-cache' import { mergeCachedSelfInfo } from './services/bootstrap-cache'
import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case' import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
import { imageFileQuality } from '../shared/image-quality'
const jobs = new Set<string>() const jobs = new Set<string>()
const activeArchives = new Map<string, Archiver>() const activeArchives = new Map<string, Archiver>()
@@ -250,6 +251,7 @@ const mergeArchiveMessage = (previous: Message, current: Message): Message => {
'exportMediaUrl', 'exportMediaUrl',
'exportMediaType', 'exportMediaType',
'exportMediaName', 'exportMediaName',
'exportMediaQuality',
'exportAvatarUrl' 'exportAvatarUrl'
] ]
for (const key of preserveWhenMissing) { for (const key of preserveWhenMissing) {
@@ -706,6 +708,7 @@ export async function runExport(
message.exportMediaUrl = undefined message.exportMediaUrl = undefined
message.exportMediaType = undefined message.exportMediaType = undefined
message.exportMediaName = undefined message.exportMediaName = undefined
message.exportMediaQuality = undefined
message.exportMediaError = undefined message.exportMediaError = undefined
message.voiceDataUrl = undefined message.voiceDataUrl = undefined
message.voiceTranscript = undefined message.voiceTranscript = undefined
@@ -1063,15 +1066,20 @@ export async function runExport(
: message.contentData.type === 'share' && message.contentData.typeVal === '6' : message.contentData.type === 'share' && message.contentData.typeVal === '6'
? 'file' ? 'file'
: null : null
const reusableImageQuality =
previous?.exportMediaQuality === 'original' ||
(request.preferOriginal === false && previous?.exportMediaQuality === 'thumbnail')
if ( if (
reusableMediaType && reusableMediaType &&
previous?.exportMediaUrl && previous?.exportMediaUrl &&
(!previous.exportMediaType || previous.exportMediaType === reusableMediaType) && (!previous.exportMediaType || previous.exportMediaType === reusableMediaType) &&
(reusableMediaType !== 'image' || reusableImageQuality) &&
(await resourceExists(previous.exportMediaUrl)) (await resourceExists(previous.exportMediaUrl))
) { ) {
message.exportMediaUrl = previous.exportMediaUrl message.exportMediaUrl = previous.exportMediaUrl
message.exportMediaType = reusableMediaType message.exportMediaType = reusableMediaType
message.exportMediaName = previous.exportMediaName message.exportMediaName = previous.exportMediaName
message.exportMediaQuality = previous.exportMediaQuality
send({ send({
jobId: request.jobId, jobId: request.jobId,
phase: 'media', phase: 'media',
@@ -1087,7 +1095,6 @@ export async function runExport(
} else { } else {
let fileFound = false let fileFound = false
let decryptedImage: { data: string; filePath: string } | null = null let decryptedImage: { data: string; filePath: string } | null = null
let usedFallback = false
for (const attempt of getImageExportAttempts(request)) { for (const attempt of getImageExportAttempts(request)) {
const file = await imageService.findImageFileAsync( const file = await imageService.findImageFileAsync(
message.contentData.md5, message.contentData.md5,
@@ -1116,7 +1123,6 @@ export async function runExport(
} }
if (!decrypted) continue if (!decrypted) continue
decryptedImage = decrypted decryptedImage = decrypted
usedFallback = attempt.fallback || imageService.isThumbnailFile(decrypted.filePath)
break break
} }
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null
@@ -1129,7 +1135,8 @@ export async function runExport(
} }
message.exportMediaUrl = mediaUrl message.exportMediaUrl = mediaUrl
message.exportMediaType = 'image' message.exportMediaType = 'image'
if (usedFallback) { message.exportMediaQuality = imageFileQuality(decryptedImage!.filePath)
if (request.preferOriginal !== false && message.exportMediaQuality === 'thumbnail') {
keepMediaError(request, message, '原图不可用,已降级使用缩略图') keepMediaError(request, message, '原图不可用,已降级使用缩略图')
} }
} else if (!fileFound) { } else if (!fileFound) {
+28 -16
View File
@@ -7,6 +7,7 @@ import { execFile } from 'child_process'
import { Worker } from 'worker_threads' import { Worker } from 'worker_threads'
import ffmpegStaticPath from 'ffmpeg-static' import ffmpegStaticPath from 'ffmpeg-static'
import type { ImageDecoderSource, ImageDecoderStatus } from '../shared/image-decryption' import type { ImageDecoderSource, ImageDecoderStatus } from '../shared/image-decryption'
import { imageFileQuality, imageQualityRank } from '../shared/image-quality'
import { loadSettings } from './services/settings-store' import { loadSettings } from './services/settings-store'
import { Wcdb4Client } from './wcdb4-client' import { Wcdb4Client } from './wcdb4-client'
@@ -208,6 +209,13 @@ function isThumbnailName(fileName) {
return /(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower) 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) { function buildPreferredDatNames(baseName) {
const base = normalizeDatBase(baseName) const base = normalizeDatBase(baseName)
if (!base) return [] if (!base) return []
@@ -436,8 +444,8 @@ function collectCandidates(datPath, allowThumbnail) {
.map((name) => path.join(directory, name)) .map((name) => path.join(directory, name))
.filter((candidate) => fs.existsSync(candidate)) .filter((candidate) => fs.existsSync(candidate))
.sort((left, right) => { .sort((left, right) => {
const thumbnailOrder = Number(isThumbnailName(path.basename(left))) - Number(isThumbnailName(path.basename(right))) const qualityOrder = imageQualityRank(path.basename(right)) - imageQualityRank(path.basename(left))
return thumbnailOrder || fs.statSync(right).size - fs.statSync(left).size return qualityOrder || fs.statSync(right).size - fs.statSync(left).size
}) })
return Array.from(new Set(candidates.concat(siblings))) return Array.from(new Set(candidates.concat(siblings)))
} }
@@ -1824,17 +1832,17 @@ export class ImageDecryptService {
.sort((left, right) => right.size - left.size) .sort((left, right) => right.size - left.size)
const thumbnail = toSized( const thumbnail = toSized(
paths.filter((candidate) => this.isThumbnailName(basename(candidate))) paths.filter((candidate) => imageFileQuality(candidate) === 'thumbnail')
) )
if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate
const nonThumb = toSized( const allowed = toSized(paths)
paths.filter((candidate) => !this.isThumbnailName(basename(candidate))) .filter((entry) => allowThumbnail || imageFileQuality(entry.candidate) !== 'thumbnail')
.sort(
(left, right) =>
imageQualityRank(imageFileQuality(right.candidate)) -
imageQualityRank(imageFileQuality(left.candidate)) || right.size - left.size
) )
if (nonThumb[0]) return nonThumb[0].candidate return allowed[0]?.candidate || null
if (!allowThumbnail) return null
const existing = toSized(paths)
return existing[0]?.candidate || null
} }
private async getLargestExistingPathAsync( private async getLargestExistingPathAsync(
@@ -1858,17 +1866,21 @@ export class ImageDecryptService {
.sort((left, right) => right.size - left.size) .sort((left, right) => right.size - left.size)
if (preferThumbnail) { 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 if (thumbnail) return thumbnail.candidate
} }
const nonThumbnail = sized.find((entry) => !this.isThumbnailName(basename(entry.candidate))) const allowed = sized
if (nonThumbnail) return nonThumbnail.candidate .filter((entry) => allowThumbnail || imageFileQuality(entry.candidate) !== 'thumbnail')
return allowThumbnail ? sized[0]?.candidate || null : null .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 { private isThumbnailName(fileName: string): boolean {
const lower = fileName.toLowerCase() return imageFileQuality(fileName) === 'thumbnail'
return /(?:_t(?:_m)?|_thumb|\.thumb|_b|_w|_c)\.dat$/i.test(lower)
} }
isThumbnailFile(filePath: string): boolean { isThumbnailFile(filePath: string): boolean {
+2
View File
@@ -10,6 +10,7 @@ import type {
DatabaseKeyValidationResult DatabaseKeyValidationResult
} from '../../shared/database-key' } from '../../shared/database-key'
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service' import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
import type { ExportImageQuality } from '../../shared/image-quality'
export function getCurrentKey(): string { export function getCurrentKey(): string {
if (!dbRef) return '' if (!dbRef) return ''
@@ -60,6 +61,7 @@ export interface FormattedMessage {
exportMediaUrl?: string exportMediaUrl?: string
exportMediaType?: 'image' | 'video' | 'sticker' | 'file' exportMediaType?: 'image' | 'video' | 'sticker' | 'file'
exportMediaName?: string exportMediaName?: string
exportMediaQuality?: ExportImageQuality
exportShowAvatar?: boolean exportShowAvatar?: boolean
exportMediaError?: string exportMediaError?: string
exportAvatarUrl?: string exportAvatarUrl?: string
+3 -6
View File
@@ -3,20 +3,17 @@ import type { ExportRequest } from './export'
export type ImageExportAttempt = { export type ImageExportAttempt = {
allowThumbnail: boolean allowThumbnail: boolean
preferThumbnail: boolean preferThumbnail: boolean
fallback: boolean
} }
export function getImageExportAttempts( export function getImageExportAttempts(
request: Pick<ExportRequest, 'preferOriginal' | 'fallbackThumbnail'> request: Pick<ExportRequest, 'preferOriginal' | 'fallbackThumbnail'>
): ImageExportAttempt[] { ): ImageExportAttempt[] {
if (request.preferOriginal === false) { if (request.preferOriginal === false) {
return [{ allowThumbnail: true, preferThumbnail: true, fallback: false }] return [{ allowThumbnail: true, preferThumbnail: true }]
} }
const attempts: ImageExportAttempt[] = [ const attempts: ImageExportAttempt[] = [{ allowThumbnail: false, preferThumbnail: false }]
{ allowThumbnail: false, preferThumbnail: false, fallback: false }
]
if (request.fallbackThumbnail !== false) { if (request.fallbackThumbnail !== false) {
attempts.push({ allowThumbnail: true, preferThumbnail: true, fallback: true }) attempts.push({ allowThumbnail: true, preferThumbnail: true })
} }
return attempts return attempts
} }
+24
View File
@@ -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
}
+3
View File
@@ -1,3 +1,5 @@
import type { ExportImageQuality } from './image-quality'
export interface Contact { export interface Contact {
m_nsUsrName: string m_nsUsrName: string
m_nsNickName: string m_nsNickName: string
@@ -36,6 +38,7 @@ export interface Message {
exportMediaUrl?: string exportMediaUrl?: string
exportMediaType?: 'image' | 'video' | 'sticker' | 'file' exportMediaType?: 'image' | 'video' | 'sticker' | 'file'
exportMediaName?: string exportMediaName?: string
exportMediaQuality?: ExportImageQuality
exportShowAvatar?: boolean exportShowAvatar?: boolean
exportMediaError?: string exportMediaError?: string
exportAvatarUrl?: string exportAvatarUrl?: string
+92 -4
View File
@@ -41,7 +41,10 @@ const state = vi.hoisted(() => ({
sessionId?: string sessionId?: string
sessionMd5?: string sessionMd5?: string
createTime?: number createTime?: number
}[] }[],
imageFilePath: 'fixture_h.dat',
imageData:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
})) }))
vi.mock('electron', () => ({ vi.mock('electron', () => ({
@@ -128,7 +131,7 @@ vi.mock('../../src/main/image-decrypt-service', () => ({
} }
): string { ): string {
state.imageLookups.push(options) state.imageLookups.push(options)
return 'fixture-original.dat' return state.imageFilePath
} }
async findImageFileAsync( async findImageFileAsync(
md5: string, md5: string,
@@ -145,8 +148,8 @@ vi.mock('../../src/main/image-decrypt-service', () => ({
} }
decryptImageToBase64WithFallback(): { data: string; filePath: string } { decryptImageToBase64WithFallback(): { data: string; filePath: string } {
return { return {
data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=', data: state.imageData,
filePath: 'fixture-original.dat' filePath: state.imageFilePath
} }
} }
async decryptImageToBase64WithFallbackAsync(): Promise<{ async decryptImageToBase64WithFallbackAsync(): Promise<{
@@ -229,6 +232,9 @@ describe('media export flow', () => {
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex') Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
) )
state.imageLookups = [] state.imageLookups = []
state.imageFilePath = 'fixture_h.dat'
state.imageData =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
state.videoLookups = [] state.videoLookups = []
state.messagesByUser = {} state.messagesByUser = {}
state.exportReads = [] state.exportReads = []
@@ -595,6 +601,88 @@ describe('media export flow', () => {
expect(existsSync(imagePath)).toBe(true) 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 () => { 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 { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } } const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
+2 -2
View File
@@ -15,8 +15,8 @@ describe('export media', () => {
const repeated = getImageExportAttempts({ preferOriginal: true, fallbackThumbnail: true }) const repeated = getImageExportAttempts({ preferOriginal: true, fallbackThumbnail: true })
expect(first).toEqual([ expect(first).toEqual([
{ allowThumbnail: false, preferThumbnail: false, fallback: false }, { allowThumbnail: false, preferThumbnail: false },
{ allowThumbnail: true, preferThumbnail: true, fallback: true } { allowThumbnail: true, preferThumbnail: true }
]) ])
expect(repeated).toEqual(first) expect(repeated).toEqual(first)
}) })
+20
View File
@@ -13,6 +13,7 @@ vi.mock('../../src/main/services/settings-store', () => ({
vi.mock('../../src/main/wcdb4-client', () => ({ Wcdb4Client: class {} })) vi.mock('../../src/main/wcdb4-client', () => ({ Wcdb4Client: class {} }))
import { ImageDecryptService } from '../../src/main/image-decrypt-service' import { ImageDecryptService } from '../../src/main/image-decrypt-service'
import { imageFileQuality } from '../../src/shared/image-quality'
const aesKey = '0123456789abcdef' const aesKey = '0123456789abcdef'
const xorKey = 0x40 const xorKey = 0x40
@@ -109,6 +110,7 @@ describe('DAT image decryption', () => {
mediumFile mediumFile
) )
expect(service.decryptImageToBase64(mediumFile)).toMatch(/^data:image\/png;base64,/) expect(service.decryptImageToBase64(mediumFile)).toMatch(/^data:image\/png;base64,/)
expect(imageFileQuality(mediumFile)).toBe('medium')
expect(service.getLastDecodeDiagnostic()).toMatchObject({ expect(service.getLastDecodeDiagnostic()).toMatchObject({
code: 'DIRECT_IMAGE', code: 'DIRECT_IMAGE',
imageFormat: 'PNG' imageFormat: 'PNG'
@@ -121,6 +123,7 @@ describe('DAT image decryption', () => {
const thumbnailFile = join(imageDirectory, `${thumbnailBase}_t_M.dat`) const thumbnailFile = join(imageDirectory, `${thumbnailBase}_t_M.dat`)
writeFileSync(thumbnailFile, Buffer.from([0xff, 0xd8, 0xff, 0x00])) writeFileSync(thumbnailFile, Buffer.from([0xff, 0xd8, 0xff, 0x00]))
expect(service.isThumbnailFile(thumbnailFile)).toBe(true) expect(service.isThumbnailFile(thumbnailFile)).toBe(true)
expect(imageFileQuality(thumbnailFile)).toBe('thumbnail')
await expect( await expect(
service.findImageFileAsync(undefined, `${thumbnailBase}_t_M.dat`, { service.findImageFileAsync(undefined, `${thumbnailBase}_t_M.dat`, {
allowThumbnail: false, allowThumbnail: false,
@@ -164,5 +167,22 @@ describe('DAT image decryption', () => {
await expect(service.findImageFileAsync(undefined, bubbleBase, bubbleOptions)).resolves.toBe( await expect(service.findImageFileAsync(undefined, bubbleBase, bubbleOptions)).resolves.toBe(
bubblePreview 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)
}) })
}) })