diff --git a/electron/main.ts b/electron/main.ts index 13ed5e4..86421fa 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -397,7 +397,7 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe /** * 创建独立的图片查看窗口 */ -function createImageViewerWindow(imagePath: string) { +function createImageViewerWindow(imagePath: string, liveVideoPath?: string) { const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') @@ -430,7 +430,8 @@ function createImageViewerWindow(imagePath: string) { win.show() }) - const imageParam = `imagePath=${encodeURIComponent(imagePath)}` + let imageParam = `imagePath=${encodeURIComponent(imagePath)}` + if (liveVideoPath) imageParam += `&liveVideoPath=${encodeURIComponent(liveVideoPath)}` if (process.env.VITE_DEV_SERVER_URL) { win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${imageParam}`) @@ -1119,8 +1120,18 @@ function registerIpcHandlers() { }) // 打开图片查看窗口 - ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string) => { - createImageViewerWindow(imagePath) + ipcMain.handle('window:openImageViewerWindow', async (_, imagePath: string, liveVideoPath?: string) => { + // 如果是 dataUrl,写入临时文件 + if (imagePath.startsWith('data:')) { + const commaIdx = imagePath.indexOf(',') + const meta = imagePath.slice(5, commaIdx) // e.g. "image/jpeg;base64" + const ext = meta.split('/')[1]?.split(';')[0] || 'jpg' + const tmpPath = join(app.getPath('temp'), `weflow_preview_${Date.now()}.${ext}`) + await writeFile(tmpPath, Buffer.from(imagePath.slice(commaIdx + 1), 'base64')) + createImageViewerWindow(`file://${tmpPath.replace(/\\/g, '/')}`, liveVideoPath) + } else { + createImageViewerWindow(imagePath, liveVideoPath) + } }) // 完成引导,关闭引导窗口并显示主窗口 diff --git a/electron/preload.ts b/electron/preload.ts index b67d65a..674ee21 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -78,8 +78,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight), resizeToFitVideo: (videoWidth: number, videoHeight: number) => ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight), - openImageViewerWindow: (imagePath: string) => - ipcRenderer.invoke('window:openImageViewerWindow', imagePath), + openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => + ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath), openChatHistoryWindow: (sessionId: string, messageId: number) => ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId) }, diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 0618f58..b3c8b05 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -45,6 +45,7 @@ type DecryptResult = { localPath?: string error?: string isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) + liveVideoPath?: string // 实况照片的视频路径 } type HardlinkState = { @@ -61,6 +62,7 @@ export class ImageDecryptService { private cacheIndexed = false private cacheIndexing: Promise | null = null private updateFlags = new Map() + private noLiveSet = new Set() // 已确认无 live 视频的图片路径 private logInfo(message: string, meta?: Record): void { if (!this.configService.get('logEnabled')) return @@ -116,8 +118,9 @@ export class ImageDecryptService { } else { this.updateFlags.delete(key) } + const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(cached) this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached)) - return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate } + return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate, liveVideoPath } } if (cached && !this.isImageFile(cached)) { this.resolvedCache.delete(key) @@ -136,8 +139,9 @@ export class ImageDecryptService { } else { this.updateFlags.delete(key) } + const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing) this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing)) - return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate } + return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate, liveVideoPath } } } this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName }) @@ -156,8 +160,9 @@ export class ImageDecryptService { if (cached && existsSync(cached) && this.isImageFile(cached)) { const dataUrl = this.fileToDataUrl(cached) const localPath = dataUrl || this.filePathToUrl(cached) + const liveVideoPath = this.isThumbnailPath(cached) ? undefined : this.checkLiveVideoCache(cached) this.emitCacheResolved(payload, cacheKey, localPath) - return { success: true, localPath } + return { success: true, localPath, liveVideoPath } } if (cached && !this.isImageFile(cached)) { this.resolvedCache.delete(cacheKey) @@ -235,8 +240,9 @@ export class ImageDecryptService { const dataUrl = this.fileToDataUrl(existing) const localPath = dataUrl || this.filePathToUrl(existing) const isThumb = this.isThumbnailPath(existing) + const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing) this.emitCacheResolved(payload, cacheKey, localPath) - return { success: true, localPath, isThumb } + return { success: true, localPath, isThumb, liveVideoPath } } } @@ -296,7 +302,15 @@ export class ImageDecryptService { const dataUrl = this.bufferToDataUrl(decrypted, finalExt) const localPath = dataUrl || this.filePathToUrl(outputPath) this.emitCacheResolved(payload, cacheKey, localPath) - return { success: true, localPath, isThumb } + + // 检测实况照片(Motion Photo) + let liveVideoPath: string | undefined + if (!isThumb && (finalExt === '.jpg' || finalExt === '.jpeg')) { + const videoPath = await this.extractMotionPhotoVideo(outputPath, decrypted) + if (videoPath) liveVideoPath = this.filePathToUrl(videoPath) + } + + return { success: true, localPath, isThumb, liveVideoPath } } catch (e) { this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName }) return { success: false, error: String(e) } @@ -1681,6 +1695,76 @@ export class ImageDecryptService { return mostCommonKey } + /** + * 检查图片对应的 live 视频缓存,返回 file:// URL 或 undefined + * 已确认无 live 的路径会被记录,下次直接跳过 + */ + private checkLiveVideoCache(imagePath: string): string | undefined { + if (this.noLiveSet.has(imagePath)) return undefined + const livePath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4') + if (existsSync(livePath)) return this.filePathToUrl(livePath) + this.noLiveSet.add(imagePath) + return undefined + } + + /** + * 检测并分离 Motion Photo(实况照片) + * Google Motion Photo = JPEG + MP4 拼接在一起 + * 返回视频文件路径,如果不是实况照片则返回 null + */ + private async extractMotionPhotoVideo(imagePath: string, imageBuffer: Buffer): Promise { + // 只处理 JPEG 文件 + if (imageBuffer.length < 8) return null + if (imageBuffer[0] !== 0xff || imageBuffer[1] !== 0xd8) return null + + // 从末尾向前搜索 MP4 ftyp 原子签名 + // ftyp 原子结构: [4字节大小][ftyp(66 74 79 70)][品牌...] + // 实际起始位置在 ftyp 前4字节(大小字段) + const ftypSig = [0x66, 0x74, 0x79, 0x70] // 'ftyp' + let videoOffset: number | null = null + + const searchEnd = Math.max(0, imageBuffer.length - 8) + for (let i = searchEnd; i > 0; i--) { + if (imageBuffer[i] === ftypSig[0] && + imageBuffer[i + 1] === ftypSig[1] && + imageBuffer[i + 2] === ftypSig[2] && + imageBuffer[i + 3] === ftypSig[3]) { + // ftyp 前4字节是 box size,实际 MP4 从这里开始 + videoOffset = i - 4 + break + } + } + + // 备用:从 XMP 元数据中读取偏移量 + if (videoOffset === null || videoOffset <= 0) { + try { + const text = imageBuffer.toString('latin1') + const match = text.match(/MediaDataOffset="(\d+)"/i) || text.match(/MicroVideoOffset="(\d+)"/i) + if (match) { + const offset = parseInt(match[1], 10) + if (offset > 0 && offset < imageBuffer.length) { + videoOffset = imageBuffer.length - offset + } + } + } catch { } + } + + if (videoOffset === null || videoOffset <= 100) return null + + // 验证视频部分确实以有效 MP4 数据开头 + const videoStart = imageBuffer[videoOffset + 4] === 0x66 && + imageBuffer[videoOffset + 5] === 0x74 && + imageBuffer[videoOffset + 6] === 0x79 && + imageBuffer[videoOffset + 7] === 0x70 + if (!videoStart) return null + + // 写出视频文件 + const videoPath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4') + const videoBuffer = imageBuffer.slice(videoOffset) + await writeFile(videoPath, videoBuffer) + return videoPath + } + /** * 解包 wxgf 格式 * wxgf 是微信的图片格式,内部使用 HEVC 编码 diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 40ad9ca..8a41b65 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1581,6 +1581,23 @@ position: relative; display: inline-block; -webkit-app-region: no-drag; + + .media-badge.live { + position: absolute; + top: 8px; + right: 8px; + left: auto; + width: 24px; + height: 24px; + background: rgba(0, 0, 0, 0.4); + border-radius: 50%; + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + color: white; + pointer-events: none; + } } .image-update-button { diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index e038c99..89422bd 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -7,6 +7,7 @@ import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' import type { ChatSession, Message } from '../types/models' import { getEmojiPath } from 'wechat-emojis' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' +import { LivePhotoIcon } from '../components/LivePhotoIcon' import { AnimatedStreamingText } from '../components/AnimatedStreamingText' import JumpToDateDialog from '../components/JumpToDateDialog' import * as configService from '../services/config' @@ -2625,6 +2626,7 @@ function MessageBubble({ const [imageInView, setImageInView] = useState(false) const imageForceHdAttempted = useRef(null) const imageForceHdPending = useRef(false) + const [imageLiveVideoPath, setImageLiveVideoPath] = useState(undefined) const [voiceError, setVoiceError] = useState(false) const [voiceLoading, setVoiceLoading] = useState(false) const [isVoicePlaying, setIsVoicePlaying] = useState(false) @@ -2853,6 +2855,7 @@ function MessageBubble({ imageDataUrlCache.set(imageCacheKey, result.localPath) setImageLocalPath(result.localPath) setImageHasUpdate(false) + if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath) return } } @@ -2920,7 +2923,7 @@ function MessageBubble({ sessionId: session.username, imageMd5: message.imageMd5 || undefined, imageDatName: message.imageDatName - }).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }) => { + }).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }) => { if (cancelled) return if (result.success && result.localPath) { imageDataUrlCache.set(imageCacheKey, result.localPath) @@ -2928,6 +2931,7 @@ function MessageBubble({ setImageLocalPath(result.localPath) setImageError(false) } + if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath) setImageHasUpdate(Boolean(result.hasUpdate)) } }).catch(() => { }) @@ -3423,14 +3427,17 @@ function MessageBubble({ alt="图片" className="image-message" onClick={() => { - if (imageHasUpdate) { - void requestImageDecrypt(true, true) - } - void window.electronAPI.window.openImageViewerWindow(imageLocalPath) + if (imageHasUpdate) void requestImageDecrypt(true, true) + void window.electronAPI.window.openImageViewerWindow(imageLocalPath!, imageLiveVideoPath || undefined) }} onLoad={() => setImageError(false)} onError={() => setImageError(true)} /> + {imageLiveVideoPath && ( +
+ +
+ )} )} diff --git a/src/pages/ImageWindow.scss b/src/pages/ImageWindow.scss index a1acf6c..3004bbf 100644 --- a/src/pages/ImageWindow.scss +++ b/src/pages/ImageWindow.scss @@ -78,7 +78,7 @@ cursor: grabbing; } - img { + img, video { max-width: none; max-height: none; object-fit: contain; diff --git a/src/pages/ImageWindow.tsx b/src/pages/ImageWindow.tsx index 90ac068..9e5b4eb 100644 --- a/src/pages/ImageWindow.tsx +++ b/src/pages/ImageWindow.tsx @@ -2,15 +2,20 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react' +import { LivePhotoIcon } from '../components/LivePhotoIcon' import './ImageWindow.scss' export default function ImageWindow() { const [searchParams] = useSearchParams() const imagePath = searchParams.get('imagePath') + const liveVideoPath = searchParams.get('liveVideoPath') + const [showLive, setShowLive] = useState(false) + const videoRef = useRef(null) const [scale, setScale] = useState(1) const [rotation, setRotation] = useState(0) const [position, setPosition] = useState({ x: 0, y: 0 }) const [initialScale, setInitialScale] = useState(1) + const [imgNatural, setImgNatural] = useState({ w: 0, h: 0 }) const viewportRef = useRef(null) // 使用 ref 存储拖动状态,避免闭包问题 @@ -39,6 +44,7 @@ export default function ImageWindow() { const img = e.currentTarget const naturalWidth = img.naturalWidth const naturalHeight = img.naturalHeight + setImgNatural({ w: naturalWidth, h: naturalHeight }) if (viewportRef.current) { const viewportWidth = viewportRef.current.clientWidth * 0.9 @@ -131,6 +137,23 @@ export default function ImageWindow() {
+ {liveVideoPath && ( + + )} {Math.round(displayScale * 100)}% @@ -140,18 +163,35 @@ export default function ImageWindow() {
-
+ {liveVideoPath && ( +