fix: 修复媒体导出与 HTML 聊天档案体验

- 修复打包版图片、语音解析不可用,内置 FFmpeg 与必要运行时依赖
- 修复 HTML 导出原图查找、缩略图回退及增量档案媒体解析问题
- 修复本人昵称在软件和 HTML 导出中显示为微信号的问题,并兼容旧档案昵称迁移
- 修复 HTML 语音播放器超出消息气泡的问题
- 优化 HTML 双向滚动加载,大量消息时最多渲染 240 条,避免页面卡顿
- 移除图片解密页面中手动配置 FFmpeg 的相关提示
- 补充图片解密、语音运行时、打包资源和 HTML 导出相关测试
This commit is contained in:
Wxw-Gu
2026-08-04 17:03:07 +08:00
parent c6587c517a
commit 0a3d930298
24 changed files with 949 additions and 515 deletions
+54 -5
View File
@@ -19,6 +19,7 @@ import { VideoAssetService } from './video-asset-service'
import { StickerService } from './sticker-service'
import { getImageExportAttempts } from '../shared/export-media'
import { FileAssetService } from './file-asset-service'
import { mergeCachedSelfInfo } from './services/bootstrap-cache'
const jobs = new Set<string>()
const safeFilePart = (value: string): string =>
@@ -104,6 +105,26 @@ export function mergeHtmlArchiveMessages(
})
}
export function normalizeHtmlArchiveSelfNames(
messages: Message[],
selfInfo: { wxid: string; nickname: string } | null
): Message[] {
const wxid = String(selfInfo?.wxid || '').trim()
const nickname = String(selfInfo?.nickname || '').trim()
if (!nickname || nickname === wxid || /^wxid_/i.test(nickname)) return messages
return messages.map((message) => {
if (!message.isSender) return message
const currentName = String(message.name || '').trim()
const senderId = String(message.senderId || '').trim()
const usesRawAccount =
!currentName ||
currentName === wxid ||
(senderId === wxid && currentName === senderId) ||
/^wxid_/i.test(currentName)
return usesRawAccount ? { ...message, name: nickname } : message
})
}
export async function readHtmlArchive(
outputDir: string,
sourceId: string,
@@ -282,6 +303,15 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
const messages = (
await chat.listMessagesAsync(request.userMd5, request.startTime, request.endTime)
).filter((message) => request.kinds.includes(kindOf(message)))
const rawSelfInfo = await chat.getSelfAccountInfoAsync()
const selfInfo = rawSelfInfo ? mergeCachedSelfInfo(rawSelfInfo.accountRoot, rawSelfInfo) : null
const isUsableSelfName = (value: string | undefined): value is string => {
const name = String(value || '').trim()
if (!name) return false
if (name === selfInfo?.wxid) return false
if (/^wxid_/i.test(name)) return false
return true
}
for (const message of messages) {
message.exportMediaUrl = undefined
message.exportMediaType = undefined
@@ -290,7 +320,11 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
message.voiceDataUrl = undefined
message.exportShowAvatar = request.includeAvatars !== false
const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined
if (mappedName) message.name = mappedName
if (mappedName && (!message.isSender || isUsableSelfName(mappedName))) {
message.name = mappedName
} else if (message.isSender && isUsableSelfName(selfInfo?.nickname)) {
message.name = selfInfo.nickname
}
if (
request.format !== 'html' &&
['image', 'video', 'voice', 'sticker', 'file'].includes(kindOf(message))
@@ -431,21 +465,31 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
let decryptedImage: { data: string; filePath: string } | null = null
let usedFallback = false
for (const attempt of getImageExportAttempts(request)) {
const file = imageService.findImageFile(
const file = await imageService.findImageFileAsync(
message.contentData.md5,
message.contentData.datName,
{
allowThumbnail: attempt.allowThumbnail,
preferThumbnail: attempt.preferThumbnail,
sessionId: message.sessionId
sessionId: message.sessionId,
sessionMd5: request.userMd5,
createTime: message.createTime
}
)
if (!file) continue
fileFound = true
const decrypted = imageService.decryptImageToBase64WithFallback(
let decrypted = await imageService.decryptImageToBase64WithFallbackAsync(
file,
attempt.allowThumbnail
)
// Worker 启动异常时,普通 JPEG/PNG 仍可由主进程同步解析;
// WXGF/HEVC 原图则以 Worker 的 FFmpeg 结果为准。
if (!decrypted) {
decrypted = imageService.decryptImageToBase64WithFallback(
file,
attempt.allowThumbnail
)
}
if (!decrypted) continue
decryptedImage = decrypted
usedFallback = attempt.fallback || imageService.isThumbnailFile(decrypted.filePath)
@@ -536,12 +580,17 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
})
}
const mergedMessages = mergeHtmlArchiveMessages(
previousArchive.messages,
messages,
request.userMd5
)
const archive: HtmlExportArchive = {
version: 1,
sourceId: request.userMd5,
name: request.name,
exportedAt: new Date().toISOString(),
messages: mergeHtmlArchiveMessages(previousArchive.messages, messages, request.userMd5)
messages: normalizeHtmlArchiveSelfNames(mergedMessages, selfInfo)
}
await fs.writeFile(outputPath, renderExportPage(request.name), 'utf8')
await writeHtmlArchive(outputDir, archive)