mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
fix: 修复视频定位与运行时依赖打包
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "wechatexplorer",
|
"name": "wechatexplorer",
|
||||||
"version": "2.1.8",
|
"version": "2.1.8",
|
||||||
|
"packageManager": "pnpm@7.33.7",
|
||||||
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
|
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"wechat",
|
"wechat",
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
|
||||||
const { chmodSync, existsSync, renameSync } = require('node:fs')
|
const { chmodSync, existsSync, renameSync } = require('node:fs')
|
||||||
const { execFileSync } = require('node:child_process')
|
const { execFileSync } = require('node:child_process')
|
||||||
const path = require('node:path')
|
const path = require('node:path')
|
||||||
|
const asar = require('@electron/asar')
|
||||||
|
|
||||||
const COMPATIBILITY_NAME = 'Electron'
|
const COMPATIBILITY_NAME = 'Electron'
|
||||||
const HELPER_SUFFIXES = ['', ' (Plugin)', ' (Renderer)', ' (GPU)']
|
const HELPER_SUFFIXES = ['', ' (Plugin)', ' (Renderer)', ' (GPU)']
|
||||||
|
const REQUIRED_RUNTIME_PACKAGES = [
|
||||||
|
'@electron-toolkit/preload',
|
||||||
|
'@electron-toolkit/utils',
|
||||||
|
'archiver',
|
||||||
|
'electron-updater',
|
||||||
|
'ffmpeg-static',
|
||||||
|
'fs-extra',
|
||||||
|
'jsonrepair',
|
||||||
|
'koffi'
|
||||||
|
]
|
||||||
|
|
||||||
function getRuntimeResources(context) {
|
function getRuntimeResources(context) {
|
||||||
const productName = context.packager.appInfo.productFilename
|
const productName = context.packager.appInfo.productFilename
|
||||||
@@ -41,12 +53,29 @@ function validateFfmpegRuntime(runtimeResources, platform = process.platform) {
|
|||||||
return ffmpegPath
|
return ffmpegPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateAsarRuntimeDependencies(runtimeResources) {
|
||||||
|
const asarPath = path.join(runtimeResources, 'app.asar')
|
||||||
|
if (!existsSync(asarPath)) throw new Error(`Missing packaged application archive: ${asarPath}`)
|
||||||
|
|
||||||
|
const entries = new Set(asar.listPackage(asarPath))
|
||||||
|
const missingPackages = REQUIRED_RUNTIME_PACKAGES.filter(
|
||||||
|
(packageName) => !entries.has(`/node_modules/${packageName}/package.json`)
|
||||||
|
)
|
||||||
|
if (missingPackages.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing packaged runtime dependencies: ${missingPackages.join(', ')}. ` +
|
||||||
|
'Use pnpm 7.33.7 so electron-builder can read pnpm-lock.yaml.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setPlistValue(plistPath, key, value) {
|
function setPlistValue(plistPath, key, value) {
|
||||||
execFileSync('/usr/libexec/PlistBuddy', ['-c', `Set :${key} ${value}`, plistPath])
|
execFileSync('/usr/libexec/PlistBuddy', ['-c', `Set :${key} ${value}`, plistPath])
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.default = async function afterPack(context) {
|
exports.default = async function afterPack(context) {
|
||||||
const runtimeResources = getRuntimeResources(context)
|
const runtimeResources = getRuntimeResources(context)
|
||||||
|
validateAsarRuntimeDependencies(runtimeResources)
|
||||||
validateSilkWasmRuntime(runtimeResources)
|
validateSilkWasmRuntime(runtimeResources)
|
||||||
const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName)
|
const ffmpegPath = validateFfmpegRuntime(runtimeResources, context.electronPlatformName)
|
||||||
|
|
||||||
@@ -117,5 +146,6 @@ exports.default = async function afterPack(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
exports.getRuntimeResources = getRuntimeResources
|
exports.getRuntimeResources = getRuntimeResources
|
||||||
|
exports.validateAsarRuntimeDependencies = validateAsarRuntimeDependencies
|
||||||
exports.validateFfmpegRuntime = validateFfmpegRuntime
|
exports.validateFfmpegRuntime = validateFfmpegRuntime
|
||||||
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
|
exports.validateSilkWasmRuntime = validateSilkWasmRuntime
|
||||||
|
|||||||
@@ -728,7 +728,12 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
} else if (hashes.length === 0) {
|
} else if (hashes.length === 0) {
|
||||||
keepMediaError(request, message, '视频标识不完整,无法定位本地视频')
|
keepMediaError(request, message, '视频标识不完整,无法定位本地视频')
|
||||||
} else {
|
} else {
|
||||||
const resolved = videoService.resolve(hashes)
|
const resolved = await videoService.resolve(hashes, {
|
||||||
|
createTime: message.createTime,
|
||||||
|
duration: message.contentData.duration,
|
||||||
|
width: message.contentData.width,
|
||||||
|
height: message.contentData.height
|
||||||
|
})
|
||||||
const source = resolved.url ? videoService.pathForUrl(resolved.url) : undefined
|
const source = resolved.url ? videoService.pathForUrl(resolved.url) : undefined
|
||||||
if (!resolved.success || !source) {
|
if (!resolved.success || !source) {
|
||||||
keepMediaError(request, message, resolved.error || '视频文件缺失或已移动')
|
keepMediaError(request, message, resolved.error || '视频文件缺失或已移动')
|
||||||
|
|||||||
+14
-7
@@ -1176,14 +1176,21 @@ app.whenReady().then(async () => {
|
|||||||
return stickerService.resolveSticker(cdnUrl, md5)
|
return stickerService.resolveSticker(cdnUrl, md5)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('db:getVideo', async (_, hashes: string[]) => {
|
ipcMain.handle(
|
||||||
if (!videoAssetService) {
|
'db:getVideo',
|
||||||
const client = chat.getChatDb()?.getWcdb4Client()
|
async (
|
||||||
if (!client) return { success: false, error: '数据库尚未连接' }
|
_,
|
||||||
videoAssetService = new VideoAssetService(client)
|
hashes: string[],
|
||||||
|
options?: { createTime?: number; duration?: number; width?: number; height?: number }
|
||||||
|
) => {
|
||||||
|
if (!videoAssetService) {
|
||||||
|
const client = chat.getChatDb()?.getWcdb4Client()
|
||||||
|
if (!client) return { success: false, error: '数据库尚未连接' }
|
||||||
|
videoAssetService = new VideoAssetService(client)
|
||||||
|
}
|
||||||
|
return videoAssetService.resolve(Array.isArray(hashes) ? hashes : [], options)
|
||||||
}
|
}
|
||||||
return videoAssetService.resolve(Array.isArray(hashes) ? hashes : [])
|
)
|
||||||
})
|
|
||||||
|
|
||||||
// -------- Settings & API service --------
|
// -------- Settings & API service --------
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,39 @@ type VideoAsset = {
|
|||||||
posterPath?: string
|
posterPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VideoResolveOptions = {
|
||||||
|
createTime?: number
|
||||||
|
duration?: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageDimensions = {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mp4Box = {
|
||||||
|
type: string
|
||||||
|
size: number
|
||||||
|
contentOffset: number
|
||||||
|
}
|
||||||
|
|
||||||
export class VideoAssetService {
|
export class VideoAssetService {
|
||||||
private readonly urlTokens = new Map<string, string>()
|
private readonly urlTokens = new Map<string, string>()
|
||||||
private readonly fileTokens = new Map<string, string>()
|
private readonly fileTokens = new Map<string, string>()
|
||||||
|
private readonly monthAssets = new Map<string, VideoAsset[]>()
|
||||||
|
private readonly fileHashes = new Map<string, Promise<string | undefined>>()
|
||||||
|
private readonly videoDurations = new Map<string, number | undefined>()
|
||||||
|
private readonly imageDimensions = new Map<string, ImageDimensions | undefined>()
|
||||||
private index: Map<string, VideoAsset> | null = null
|
private index: Map<string, VideoAsset> | null = null
|
||||||
|
|
||||||
constructor(private readonly client: Wcdb4Client) {}
|
constructor(private readonly client: Wcdb4Client) {}
|
||||||
|
|
||||||
resolve(hashes: string[]): { success: boolean; url?: string; poster?: string; error?: string } {
|
async resolve(
|
||||||
|
hashes: string[],
|
||||||
|
options: VideoResolveOptions = {}
|
||||||
|
): Promise<{ success: boolean; url?: string; poster?: string; error?: string }> {
|
||||||
const candidates = Array.from(
|
const candidates = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
hashes
|
hashes
|
||||||
@@ -53,6 +78,15 @@ export class VideoAssetService {
|
|||||||
poster: asset.posterPath ? this.createLocalMediaUrl(asset.posterPath) : undefined
|
poster: asset.posterPath ? this.createLocalMediaUrl(asset.posterPath) : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fallback = await this.resolveFromLocalMetadata(candidates, options)
|
||||||
|
if (fallback) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
url: this.createLocalMediaUrl(fallback.filePath),
|
||||||
|
poster: fallback.posterPath ? this.createLocalMediaUrl(fallback.posterPath) : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
return { success: false, error: '本地未找到该视频文件' }
|
return { success: false, error: '本地未找到该视频文件' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,22 +139,206 @@ export class VideoAssetService {
|
|||||||
for (const month of fs.readdirSync(root)) {
|
for (const month of fs.readdirSync(root)) {
|
||||||
const monthPath = path.join(root, month)
|
const monthPath = path.join(root, month)
|
||||||
if (!fs.statSync(monthPath).isDirectory()) continue
|
if (!fs.statSync(monthPath).isDirectory()) continue
|
||||||
|
const monthly = new Map<string, VideoAsset>()
|
||||||
for (const name of fs.readdirSync(monthPath)) {
|
for (const name of fs.readdirSync(monthPath)) {
|
||||||
const match = /^([a-f0-9]{32})(?:(_raw))?\.(mp4|jpg)$/i.exec(name)
|
const videoMatch = /^([a-f0-9]{32})(?:(_raw))?\.mp4$/i.exec(name)
|
||||||
|
const posterMatch = /^([a-f0-9]{32})(?:(_raw))?(?:_thumb)?\.jpg$/i.exec(name)
|
||||||
|
const match = videoMatch || posterMatch
|
||||||
if (!match) continue
|
if (!match) continue
|
||||||
const key = `${match[1].toLowerCase()}${match[2] || ''}`
|
const key = `${match[1].toLowerCase()}${match[2] || ''}`
|
||||||
const fullPath = path.join(monthPath, name)
|
const fullPath = path.join(monthPath, name)
|
||||||
const existing = result.get(key) || { filePath: '' }
|
const existing = monthly.get(key) || { filePath: '' }
|
||||||
if (match[3].toLowerCase() === 'mp4') existing.filePath = fullPath
|
if (videoMatch) existing.filePath = fullPath
|
||||||
else if (!existing.posterPath) existing.posterPath = fullPath
|
else if (!existing.posterPath) existing.posterPath = fullPath
|
||||||
result.set(key, existing)
|
monthly.set(key, existing)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, asset] of result) {
|
const assets: VideoAsset[] = []
|
||||||
if (!asset.filePath) result.delete(key)
|
for (const [key, asset] of monthly) {
|
||||||
|
if (!asset.filePath) continue
|
||||||
|
result.set(key, asset)
|
||||||
|
assets.push(asset)
|
||||||
|
}
|
||||||
|
this.monthAssets.set(month, assets)
|
||||||
}
|
}
|
||||||
this.index = result
|
this.index = result
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveFromLocalMetadata(
|
||||||
|
hashes: string[],
|
||||||
|
options: VideoResolveOptions
|
||||||
|
): Promise<VideoAsset | undefined> {
|
||||||
|
const month = this.monthForCreateTime(options.createTime)
|
||||||
|
if (!month) return undefined
|
||||||
|
|
||||||
|
this.getIndex()
|
||||||
|
const assets = this.monthAssets.get(month) || []
|
||||||
|
if (assets.length === 0) return undefined
|
||||||
|
|
||||||
|
let narrowed = assets
|
||||||
|
let appliedCriteria = 0
|
||||||
|
const width = Number(options.width)
|
||||||
|
const height = Number(options.height)
|
||||||
|
if (width > 0 && height > 0) {
|
||||||
|
const matches = narrowed.filter((asset) => {
|
||||||
|
const dimensions = asset.posterPath ? this.readImageDimensions(asset.posterPath) : undefined
|
||||||
|
return dimensions?.width === width && dimensions.height === height
|
||||||
|
})
|
||||||
|
if (matches.length > 0) {
|
||||||
|
narrowed = matches
|
||||||
|
appliedCriteria += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Number(options.duration)
|
||||||
|
if (duration > 0) {
|
||||||
|
const matches = narrowed.filter((asset) => {
|
||||||
|
const actual = this.readMp4Duration(asset.filePath)
|
||||||
|
return actual !== undefined && Math.abs(actual - duration) <= 1.5
|
||||||
|
})
|
||||||
|
if (matches.length > 0) {
|
||||||
|
narrowed = matches
|
||||||
|
appliedCriteria += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appliedCriteria >= 2 && narrowed.length === 1) return narrowed[0]
|
||||||
|
|
||||||
|
const hashPool = narrowed.length > 0 ? narrowed : assets
|
||||||
|
const contentMatches: VideoAsset[] = []
|
||||||
|
for (const asset of hashPool) {
|
||||||
|
const contentHash = await this.hashFile(asset.filePath)
|
||||||
|
if (contentHash && hashes.includes(contentHash)) contentMatches.push(asset)
|
||||||
|
}
|
||||||
|
return contentMatches.length === 1 ? contentMatches[0] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private monthForCreateTime(createTime?: number): string | undefined {
|
||||||
|
const raw = Number(createTime)
|
||||||
|
if (!Number.isFinite(raw) || raw <= 0) return undefined
|
||||||
|
const date = new Date(raw > 10_000_000_000 ? raw : raw * 1000)
|
||||||
|
if (Number.isNaN(date.getTime())) return undefined
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashFile(filePath: string): Promise<string | undefined> {
|
||||||
|
const cached = this.fileHashes.get(filePath)
|
||||||
|
if (cached) return cached
|
||||||
|
const pending = new Promise<string | undefined>((resolve) => {
|
||||||
|
const hash = crypto.createHash('md5')
|
||||||
|
const stream = fs.createReadStream(filePath)
|
||||||
|
stream.on('data', (chunk) => hash.update(chunk))
|
||||||
|
stream.on('error', () => resolve(undefined))
|
||||||
|
stream.on('end', () => resolve(hash.digest('hex')))
|
||||||
|
})
|
||||||
|
this.fileHashes.set(filePath, pending)
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
private readImageDimensions(filePath: string): ImageDimensions | undefined {
|
||||||
|
if (this.imageDimensions.has(filePath)) return this.imageDimensions.get(filePath)
|
||||||
|
let dimensions: ImageDimensions | undefined
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(filePath)
|
||||||
|
if (data.length >= 4 && data[0] === 0xff && data[1] === 0xd8) {
|
||||||
|
let offset = 2
|
||||||
|
const startOfFrame = new Set([
|
||||||
|
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf
|
||||||
|
])
|
||||||
|
while (offset + 8 < data.length) {
|
||||||
|
if (data[offset] !== 0xff) {
|
||||||
|
offset += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
while (offset < data.length && data[offset] === 0xff) offset += 1
|
||||||
|
const marker = data[offset]
|
||||||
|
offset += 1
|
||||||
|
if (marker === 0xd8 || marker === 0x01) continue
|
||||||
|
if (marker === 0xd9 || marker === 0xda || offset + 2 > data.length) break
|
||||||
|
const length = data.readUInt16BE(offset)
|
||||||
|
if (length < 2 || offset + length > data.length) break
|
||||||
|
if (startOfFrame.has(marker) && length >= 7) {
|
||||||
|
dimensions = {
|
||||||
|
height: data.readUInt16BE(offset + 3),
|
||||||
|
width: data.readUInt16BE(offset + 5)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset += length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
dimensions = undefined
|
||||||
|
}
|
||||||
|
this.imageDimensions.set(filePath, dimensions)
|
||||||
|
return dimensions
|
||||||
|
}
|
||||||
|
|
||||||
|
private readMp4Duration(filePath: string): number | undefined {
|
||||||
|
if (this.videoDurations.has(filePath)) return this.videoDurations.get(filePath)
|
||||||
|
let duration: number | undefined
|
||||||
|
let descriptor: number | undefined
|
||||||
|
try {
|
||||||
|
descriptor = fs.openSync(filePath, 'r')
|
||||||
|
const fileSize = fs.fstatSync(descriptor).size
|
||||||
|
const moov = this.findMp4Box(descriptor, 0, fileSize, 'moov')
|
||||||
|
const mvhd = moov
|
||||||
|
? this.findMp4Box(descriptor, moov.contentOffset, moov.contentOffset + moov.size, 'mvhd')
|
||||||
|
: undefined
|
||||||
|
if (mvhd) {
|
||||||
|
const header = Buffer.alloc(32)
|
||||||
|
const bytesRead = fs.readSync(descriptor, header, 0, header.length, mvhd.contentOffset)
|
||||||
|
const version = header[0]
|
||||||
|
if (version === 0 && bytesRead >= 20) {
|
||||||
|
const timescale = header.readUInt32BE(12)
|
||||||
|
const ticks = header.readUInt32BE(16)
|
||||||
|
if (timescale > 0) duration = ticks / timescale
|
||||||
|
} else if (version === 1 && bytesRead >= 32) {
|
||||||
|
const timescale = header.readUInt32BE(20)
|
||||||
|
const ticks = Number(header.readBigUInt64BE(24))
|
||||||
|
if (timescale > 0 && Number.isSafeInteger(ticks)) duration = ticks / timescale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
duration = undefined
|
||||||
|
} finally {
|
||||||
|
if (descriptor !== undefined) fs.closeSync(descriptor)
|
||||||
|
}
|
||||||
|
this.videoDurations.set(filePath, duration)
|
||||||
|
return duration
|
||||||
|
}
|
||||||
|
|
||||||
|
private findMp4Box(
|
||||||
|
descriptor: number,
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
target: string
|
||||||
|
): Mp4Box | undefined {
|
||||||
|
let offset = start
|
||||||
|
const header = Buffer.alloc(16)
|
||||||
|
while (offset + 8 <= end) {
|
||||||
|
const bytesRead = fs.readSync(descriptor, header, 0, header.length, offset)
|
||||||
|
if (bytesRead < 8) return undefined
|
||||||
|
const size32 = header.readUInt32BE(0)
|
||||||
|
const type = header.toString('ascii', 4, 8)
|
||||||
|
let headerSize = 8
|
||||||
|
let size = size32
|
||||||
|
if (size32 === 1) {
|
||||||
|
if (bytesRead < 16) return undefined
|
||||||
|
const extendedSize = header.readBigUInt64BE(8)
|
||||||
|
if (extendedSize > BigInt(Number.MAX_SAFE_INTEGER)) return undefined
|
||||||
|
size = Number(extendedSize)
|
||||||
|
headerSize = 16
|
||||||
|
} else if (size32 === 0) {
|
||||||
|
size = end - offset
|
||||||
|
}
|
||||||
|
if (size < headerSize || offset + size > end) return undefined
|
||||||
|
if (type === target) {
|
||||||
|
return { type, size: size - headerSize, contentOffset: offset + headerSize }
|
||||||
|
}
|
||||||
|
offset += size
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
-1
@@ -219,7 +219,8 @@ declare global {
|
|||||||
filePath?: string
|
filePath?: string
|
||||||
}>
|
}>
|
||||||
getVideo: (
|
getVideo: (
|
||||||
hashes: string[]
|
hashes: string[],
|
||||||
|
options?: { createTime?: number; duration?: number; width?: number; height?: number }
|
||||||
) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }>
|
) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }>
|
||||||
getSticker: (
|
getSticker: (
|
||||||
cdnUrl?: string,
|
cdnUrl?: string,
|
||||||
|
|||||||
@@ -82,7 +82,10 @@ const api = {
|
|||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
|
options?: { force?: boolean; preferThumbnail?: boolean; priority?: number }
|
||||||
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||||
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
|
getVideo: (
|
||||||
|
hashes: string[],
|
||||||
|
options?: { createTime?: number; duration?: number; width?: number; height?: number }
|
||||||
|
) => ipcRenderer.invoke('db:getVideo', hashes, options),
|
||||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||||
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
|
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
|
||||||
cancelExport: (jobId: string) => ipcRenderer.invoke('export:cancel', jobId),
|
cancelExport: (jobId: string) => ipcRenderer.invoke('export:cancel', jobId),
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ interface VideoBubbleProps {
|
|||||||
md5?: string
|
md5?: string
|
||||||
newMd5?: string
|
newMd5?: string
|
||||||
rawMd5?: string
|
rawMd5?: string
|
||||||
|
createTime?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoBubble({
|
export function VideoBubble({
|
||||||
md5,
|
md5,
|
||||||
newMd5,
|
newMd5,
|
||||||
rawMd5,
|
rawMd5,
|
||||||
duration
|
createTime,
|
||||||
|
duration,
|
||||||
|
width,
|
||||||
|
height
|
||||||
}: VideoBubbleProps): React.ReactElement {
|
}: VideoBubbleProps): React.ReactElement {
|
||||||
const hashes = useMemo(
|
const hashes = useMemo(
|
||||||
() => [rawMd5, newMd5, md5].filter((value): value is string => Boolean(value)),
|
() => [rawMd5, newMd5, md5].filter((value): value is string => Boolean(value)),
|
||||||
@@ -22,7 +28,7 @@ export function VideoBubble({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
window.api
|
window.api
|
||||||
.getVideo(hashes)
|
.getVideo(hashes, { createTime, duration, width, height })
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (!cancelled) setMedia(result.success ? result : { error: result.error })
|
if (!cancelled) setMedia(result.success ? result : { error: result.error })
|
||||||
})
|
})
|
||||||
@@ -32,7 +38,7 @@ export function VideoBubble({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [hashes])
|
}, [createTime, duration, hashes, height, width])
|
||||||
|
|
||||||
if (!media.url) {
|
if (!media.url) {
|
||||||
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
|
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
|
||||||
|
|||||||
@@ -81,7 +81,10 @@ export function MessageBubble({
|
|||||||
md5={message.contentData.md5}
|
md5={message.contentData.md5}
|
||||||
newMd5={message.contentData.newMd5}
|
newMd5={message.contentData.newMd5}
|
||||||
rawMd5={message.contentData.rawMd5}
|
rawMd5={message.contentData.rawMd5}
|
||||||
|
createTime={message.createTime}
|
||||||
duration={message.contentData.duration}
|
duration={message.contentData.duration}
|
||||||
|
width={message.contentData.width}
|
||||||
|
height={message.contentData.height}
|
||||||
/>
|
/>
|
||||||
) : isRichMedia && message.contentData ? (
|
) : isRichMedia && message.contentData ? (
|
||||||
<RichMessageBubble
|
<RichMessageBubble
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ const state = vi.hoisted(() => ({
|
|||||||
videoPath: '',
|
videoPath: '',
|
||||||
messages: [] as Message[],
|
messages: [] as Message[],
|
||||||
messagesByUser: {} as Record<string, Message[]>,
|
messagesByUser: {} as Record<string, Message[]>,
|
||||||
|
videoLookups: [] as {
|
||||||
|
createTime?: number
|
||||||
|
duration?: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}[],
|
||||||
imageLookups: [] as {
|
imageLookups: [] as {
|
||||||
allowThumbnail?: boolean
|
allowThumbnail?: boolean
|
||||||
preferThumbnail?: boolean
|
preferThumbnail?: boolean
|
||||||
@@ -120,7 +126,11 @@ vi.mock('../../src/main/image-decrypt-service', () => ({
|
|||||||
}))
|
}))
|
||||||
vi.mock('../../src/main/video-asset-service', () => ({
|
vi.mock('../../src/main/video-asset-service', () => ({
|
||||||
VideoAssetService: class {
|
VideoAssetService: class {
|
||||||
resolve(): { success: boolean; url: string } {
|
resolve(
|
||||||
|
_hashes: string[],
|
||||||
|
options?: { createTime?: number; duration?: number; width?: number; height?: number }
|
||||||
|
): { success: boolean; url: string } {
|
||||||
|
state.videoLookups.push(options || {})
|
||||||
return { success: true, url: 'wxe-media://local/fixture-video' }
|
return { success: true, url: 'wxe-media://local/fixture-video' }
|
||||||
}
|
}
|
||||||
pathForUrl(): string {
|
pathForUrl(): string {
|
||||||
@@ -175,6 +185,7 @@ describe('media export flow', () => {
|
|||||||
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
|
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
|
||||||
)
|
)
|
||||||
state.imageLookups = []
|
state.imageLookups = []
|
||||||
|
state.videoLookups = []
|
||||||
state.messagesByUser = {}
|
state.messagesByUser = {}
|
||||||
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
|
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
|
||||||
mkdirSync(fileMonth, { recursive: true })
|
mkdirSync(fileMonth, { recursive: true })
|
||||||
@@ -203,7 +214,13 @@ describe('media export flow', () => {
|
|||||||
message({
|
message({
|
||||||
id: 'video',
|
id: 'video',
|
||||||
type: '视频',
|
type: '视频',
|
||||||
contentData: { type: 'video', md5: 'b'.repeat(32) }
|
contentData: {
|
||||||
|
type: 'video',
|
||||||
|
md5: 'b'.repeat(32),
|
||||||
|
duration: 68,
|
||||||
|
width: 279,
|
||||||
|
height: 630
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
message({
|
message({
|
||||||
id: 'file',
|
id: 'file',
|
||||||
@@ -264,6 +281,12 @@ describe('media export flow', () => {
|
|||||||
sessionMd5: 'fixture-user',
|
sessionMd5: 'fixture-user',
|
||||||
createTime: 1_785_549_600
|
createTime: 1_785_549_600
|
||||||
})
|
})
|
||||||
|
expect(state.videoLookups[0]).toEqual({
|
||||||
|
createTime: 1_785_549_600,
|
||||||
|
duration: 68,
|
||||||
|
width: 279,
|
||||||
|
height: 630
|
||||||
|
})
|
||||||
expect(progress.length).toBeGreaterThan(0)
|
expect(progress.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import { dirname, join, resolve } from 'path'
|
|||||||
import { afterAll, describe, expect, it } from 'vitest'
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
const nodeRequire = createRequire(import.meta.url)
|
const nodeRequire = createRequire(import.meta.url)
|
||||||
const { validateFfmpegRuntime, validateSilkWasmRuntime } = nodeRequire(
|
const asar = nodeRequire('@electron/asar') as {
|
||||||
'../../scripts/after-pack.cjs'
|
createPackage: (source: string, destination: string) => Promise<void>
|
||||||
) as {
|
|
||||||
validateFfmpegRuntime: (runtimeResources: string, platform?: NodeJS.Platform) => void
|
|
||||||
validateSilkWasmRuntime: (runtimeResources: string) => void
|
|
||||||
}
|
}
|
||||||
|
const { validateAsarRuntimeDependencies, validateFfmpegRuntime, validateSilkWasmRuntime } =
|
||||||
|
nodeRequire('../../scripts/after-pack.cjs') as {
|
||||||
|
validateAsarRuntimeDependencies: (runtimeResources: string) => void
|
||||||
|
validateFfmpegRuntime: (runtimeResources: string, platform?: NodeJS.Platform) => void
|
||||||
|
validateSilkWasmRuntime: (runtimeResources: string) => void
|
||||||
|
}
|
||||||
const root = mkdtempSync(join(tmpdir(), 'wxe-runtime-package-'))
|
const root = mkdtempSync(join(tmpdir(), 'wxe-runtime-package-'))
|
||||||
|
|
||||||
describe('production runtime packaging', () => {
|
describe('production runtime packaging', () => {
|
||||||
@@ -32,6 +35,19 @@ describe('production runtime packaging', () => {
|
|||||||
expect(config).toContain('node_modules/silk-wasm/**')
|
expect(config).toContain('node_modules/silk-wasm/**')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects an app archive with missing runtime dependencies', async () => {
|
||||||
|
const resources = join(root, 'asar-resources')
|
||||||
|
const source = join(root, 'asar-source')
|
||||||
|
mkdirSync(source, { recursive: true })
|
||||||
|
writeFileSync(join(source, 'package.json'), '{}')
|
||||||
|
mkdirSync(resources, { recursive: true })
|
||||||
|
await asar.createPackage(source, join(resources, 'app.asar'))
|
||||||
|
|
||||||
|
expect(() => validateAsarRuntimeDependencies(resources)).toThrow(
|
||||||
|
/Missing packaged runtime dependencies:.*@electron-toolkit\/utils/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('requires and unpacks the bundled ffmpeg-static executable', () => {
|
it('requires and unpacks the bundled ffmpeg-static executable', () => {
|
||||||
const resources = join(root, 'ffmpeg-resources')
|
const resources = join(root, 'ffmpeg-resources')
|
||||||
const ffmpegPath = join(
|
const ffmpegPath = join(
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { VideoAssetService } from '../../src/main/video-asset-service'
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = []
|
||||||
|
|
||||||
|
const box = (type: string, payload: Buffer): Buffer => {
|
||||||
|
const header = Buffer.alloc(8)
|
||||||
|
header.writeUInt32BE(header.length + payload.length, 0)
|
||||||
|
header.write(type, 4, 4, 'ascii')
|
||||||
|
return Buffer.concat([header, payload])
|
||||||
|
}
|
||||||
|
|
||||||
|
const mp4Fixture = (durationSeconds: number, marker: string): Buffer => {
|
||||||
|
const movieHeader = Buffer.alloc(20)
|
||||||
|
movieHeader.writeUInt32BE(1000, 12)
|
||||||
|
movieHeader.writeUInt32BE(Math.round(durationSeconds * 1000), 16)
|
||||||
|
return Buffer.concat([
|
||||||
|
box('ftyp', Buffer.from('isom0000', 'ascii')),
|
||||||
|
box('moov', box('mvhd', movieHeader)),
|
||||||
|
box('mdat', Buffer.from(marker, 'utf8'))
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const jpegFixture = (width: number, height: number): Buffer =>
|
||||||
|
Buffer.from([
|
||||||
|
0xff,
|
||||||
|
0xd8,
|
||||||
|
0xff,
|
||||||
|
0xc0,
|
||||||
|
0x00,
|
||||||
|
0x11,
|
||||||
|
0x08,
|
||||||
|
(height >> 8) & 0xff,
|
||||||
|
height & 0xff,
|
||||||
|
(width >> 8) & 0xff,
|
||||||
|
width & 0xff,
|
||||||
|
0x03,
|
||||||
|
0x01,
|
||||||
|
0x11,
|
||||||
|
0x00,
|
||||||
|
0x02,
|
||||||
|
0x11,
|
||||||
|
0x00,
|
||||||
|
0x03,
|
||||||
|
0x11,
|
||||||
|
0x00,
|
||||||
|
0xff,
|
||||||
|
0xd9
|
||||||
|
])
|
||||||
|
|
||||||
|
const createService = (): {
|
||||||
|
accountRoot: string
|
||||||
|
service: VideoAssetService
|
||||||
|
} => {
|
||||||
|
const accountRoot = mkdtempSync(join(tmpdir(), 'wxe-video-assets-'))
|
||||||
|
temporaryDirectories.push(accountRoot)
|
||||||
|
return {
|
||||||
|
accountRoot,
|
||||||
|
service: new VideoAssetService({
|
||||||
|
getAccountRoot: () => accountRoot,
|
||||||
|
resolveVideoHardlink: () => null
|
||||||
|
} as never)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthTimestamp = (year: number, month: number): number =>
|
||||||
|
Math.floor(new Date(year, month - 1, 15, 12).getTime() / 1000)
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const directory of temporaryDirectories.splice(0)) {
|
||||||
|
rmSync(directory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('VideoAssetService local fallback', () => {
|
||||||
|
it('finds a video by its content MD5 when the hardlink mapping is absent', async () => {
|
||||||
|
const { accountRoot, service } = createService()
|
||||||
|
const month = join(accountRoot, 'msg', 'video', '2026-07')
|
||||||
|
mkdirSync(month, { recursive: true })
|
||||||
|
const content = mp4Fixture(22, 'content-md5-match')
|
||||||
|
const filePath = join(month, `${'2'.repeat(32)}.mp4`)
|
||||||
|
writeFileSync(filePath, content)
|
||||||
|
const contentHash = createHash('md5').update(content).digest('hex')
|
||||||
|
|
||||||
|
const result = await service.resolve([contentHash], {
|
||||||
|
createTime: monthTimestamp(2026, 7)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(service.pathForUrl(result.url!)).toBe(filePath)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds a uniquely matching video by month, thumbnail size, and duration', async () => {
|
||||||
|
const { accountRoot, service } = createService()
|
||||||
|
const month = join(accountRoot, 'msg', 'video', '2025-11')
|
||||||
|
mkdirSync(month, { recursive: true })
|
||||||
|
const stem = '66cecd68e095d87175fb5ed138de3cef'
|
||||||
|
const filePath = join(month, `${stem}.mp4`)
|
||||||
|
const posterPath = join(month, `${stem}_thumb.jpg`)
|
||||||
|
writeFileSync(filePath, mp4Fixture(68.441, 'metadata-match'))
|
||||||
|
writeFileSync(posterPath, jpegFixture(279, 630))
|
||||||
|
|
||||||
|
const result = await service.resolve(
|
||||||
|
['c92c54c8eae4471be9cc18396daf8015', '021e8a18a765ce14f4c54f40065db98e'],
|
||||||
|
{
|
||||||
|
createTime: monthTimestamp(2025, 11),
|
||||||
|
duration: 68,
|
||||||
|
width: 279,
|
||||||
|
height: 630
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(service.pathForUrl(result.url!)).toBe(filePath)
|
||||||
|
expect(service.pathForUrl(result.poster!)).toBe(posterPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not guess when multiple files match the same metadata', async () => {
|
||||||
|
const { accountRoot, service } = createService()
|
||||||
|
const month = join(accountRoot, 'msg', 'video', '2026-02')
|
||||||
|
mkdirSync(month, { recursive: true })
|
||||||
|
for (const stem of ['a'.repeat(32), 'b'.repeat(32)]) {
|
||||||
|
writeFileSync(join(month, `${stem}.mp4`), mp4Fixture(12, stem))
|
||||||
|
writeFileSync(join(month, `${stem}_thumb.jpg`), jpegFixture(224, 398))
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.resolve(['c'.repeat(32)], {
|
||||||
|
createTime: monthTimestamp(2026, 2),
|
||||||
|
duration: 12,
|
||||||
|
width: 224,
|
||||||
|
height: 398
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: false, error: '本地未找到该视频文件' })
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user