diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 585b44e..dcbcb75 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -8,6 +8,19 @@ import { Worker } from 'worker_threads' import { ConfigService } from './config' import { wcdbService } from './wcdbService' +// 获取 ffmpeg-static 的路径 +function getStaticFfmpegPath(): string | null { + try { + const ffmpegStatic = require('ffmpeg-static') + if (typeof ffmpegStatic === 'string') { + return ffmpegStatic + } + return null + } catch { + return null + } +} + type DecryptResult = { success: boolean localPath?: string @@ -238,20 +251,39 @@ export class ImageDecryptService { const aesKey = this.resolveAesKey(aesKeyRaw) this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey }) - const decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey) + let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey) - const ext = this.detectImageExtension(decrypted) || '.jpg' + // 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据 + const wxgfResult = await this.unwrapWxgf(decrypted) + decrypted = wxgfResult.data - const outputPath = this.getCacheOutputPathFromDat(datPath, ext, payload.sessionId) + let ext = this.detectImageExtension(decrypted) + + // 如果是 wxgf 格式且没检测到扩展名 + if (wxgfResult.isWxgf && !ext) { + ext = '.hevc' + } + + const finalExt = ext || '.jpg' + + const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId) await writeFile(outputPath, decrypted) this.logInfo('解密成功', { outputPath, size: decrypted.length }) + // 对于 hevc 格式,返回错误提示 + if (finalExt === '.hevc') { + return { + success: false, + error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示', + isThumb: this.isThumbnailPath(datPath) + } + } const isThumb = this.isThumbnailPath(datPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) if (!isThumb) { this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) } - const dataUrl = this.bufferToDataUrl(decrypted, ext) + const dataUrl = this.bufferToDataUrl(decrypted, finalExt) const localPath = dataUrl || this.filePathToUrl(outputPath) this.emitCacheResolved(payload, cacheKey, localPath) return { success: true, localPath, isThumb } @@ -1406,6 +1438,152 @@ export class ImageDecryptService { return mostCommonKey } + /** + * 解包 wxgf 格式 + * wxgf 是微信的图片格式,内部使用 HEVC 编码 + */ + private async unwrapWxgf(buffer: Buffer): Promise<{ data: Buffer; isWxgf: boolean }> { + // 检查是否是 wxgf 格式 (77 78 67 66 = "wxgf") + if (buffer.length < 20 || + buffer[0] !== 0x77 || buffer[1] !== 0x78 || + buffer[2] !== 0x67 || buffer[3] !== 0x66) { + return { data: buffer, isWxgf: false } + } + + // 先尝试搜索内嵌的传统图片签名 + for (let i = 4; i < Math.min(buffer.length - 12, 4096); i++) { + if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) { + return { data: buffer.subarray(i), isWxgf: false } + } + if (buffer[i] === 0x89 && buffer[i + 1] === 0x50 && + buffer[i + 2] === 0x4e && buffer[i + 3] === 0x47) { + return { data: buffer.subarray(i), isWxgf: false } + } + } + + // 提取 HEVC NALU 裸流 + const hevcData = this.extractHevcNalu(buffer) + if (!hevcData || hevcData.length < 100) { + return { data: buffer, isWxgf: true } + } + + // 尝试用 ffmpeg 转换 + try { + const jpgData = await this.convertHevcToJpg(hevcData) + if (jpgData && jpgData.length > 0) { + return { data: jpgData, isWxgf: false } + } + } catch { + // ffmpeg 转换失败 + } + + return { data: hevcData, isWxgf: true } + } + + /** + * 从 wxgf 数据中提取 HEVC NALU 裸流 + */ + private extractHevcNalu(buffer: Buffer): Buffer | null { + const nalUnits: Buffer[] = [] + let i = 4 + + while (i < buffer.length - 4) { + if (buffer[i] === 0x00 && buffer[i + 1] === 0x00 && + buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01) { + let nalStart = i + let nalEnd = buffer.length + + for (let j = i + 4; j < buffer.length - 3; j++) { + if (buffer[j] === 0x00 && buffer[j + 1] === 0x00) { + if (buffer[j + 2] === 0x01 || + (buffer[j + 2] === 0x00 && j + 3 < buffer.length && buffer[j + 3] === 0x01)) { + nalEnd = j + break + } + } + } + + const nalUnit = buffer.subarray(nalStart, nalEnd) + if (nalUnit.length > 3) { + nalUnits.push(nalUnit) + } + i = nalEnd + } else { + i++ + } + } + + if (nalUnits.length === 0) { + for (let j = 4; j < buffer.length - 4; j++) { + if (buffer[j] === 0x00 && buffer[j + 1] === 0x00 && + buffer[j + 2] === 0x00 && buffer[j + 3] === 0x01) { + return buffer.subarray(j) + } + } + return null + } + + return Buffer.concat(nalUnits) + } + + /** + * 获取 ffmpeg 可执行文件路径 + */ + private getFfmpegPath(): string { + const staticPath = getStaticFfmpegPath() + if (staticPath) { + const unpackedPath = staticPath.replace('app.asar', 'app.asar.unpacked') + if (existsSync(unpackedPath)) { + return unpackedPath + } + if (existsSync(staticPath)) { + return staticPath + } + } + return 'ffmpeg' + } + + /** + * 使用 ffmpeg 将 HEVC 裸流转换为 JPG + */ + private convertHevcToJpg(hevcData: Buffer): Promise { + const ffmpeg = this.getFfmpegPath() + + return new Promise((resolve) => { + const { spawn } = require('child_process') + const chunks: Buffer[] = [] + + const proc = spawn(ffmpeg, [ + '-hide_banner', + '-loglevel', 'error', + '-f', 'hevc', + '-i', 'pipe:0', + '-vframes', '1', + '-q:v', '3', + '-f', 'mjpeg', + 'pipe:1' + ], { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }) + + proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)) + + proc.on('close', (code: number) => { + if (code === 0 && chunks.length > 0) { + resolve(Buffer.concat(chunks)) + } else { + resolve(null) + } + }) + + proc.on('error', () => resolve(null)) + + proc.stdin.write(hevcData) + proc.stdin.end() + }) + } + // 保留原有的解密到文件方法(用于兼容) async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise { const version = this.getDatVersion(inputPath) diff --git a/package.json b/package.json index 15de675..c0d253d 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "echarts-for-react": "^3.0.2", "electron-store": "^10.0.0", "electron-updater": "^6.3.9", + "ffmpeg-static": "^5.3.0", "fzstd": "^0.1.1", "html2canvas": "^1.4.1", "jieba-wasm": "^2.2.0", @@ -97,6 +98,9 @@ "files": [ "dist/**/*", "dist-electron/**/*" + ], + "asarUnpack": [ + "node_modules/ffmpeg-static/**/*" ] } } \ No newline at end of file