diff --git a/CLAUDE.md b/CLAUDE.md index e9934034..c39a5dd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,18 @@ const StyledComponent = styled.div` # Test - 使用 vitest 作为测试框架 +- **重要**:全局测试配置文件 `tests/setup.ts` 中 mock 了 `node:fs` 和 `node:fs/promises` 模块,这会影响需要真实文件系统操作的测试 +- 对于需要真实文件系统操作的测试(如文件清理、文件读写等),必须在测试文件开头使用 `vi.unmock('node:fs')` 和 `vi.unmock('node:fs/promises')` 来取消全局 mock +- 示例: + + ```typescript + // 在测试文件开头 + vi.unmock('node:fs') + vi.unmock('node:fs/promises') + + // 然后导入真实的 fs + import * as fs from 'fs' + ``` # Package Management @@ -82,10 +94,32 @@ const StyledComponent = styled.div` - 包管理器工具请使用 pnpm - logger 的使用例子: `logger.error('Error in MediaClock listener:', { error: error })`, 第二参数必须接收为 `{}` +## Resource Management & Cleanup + +- **临时文件清理规范**:所有生成临时文件的服务都应实现集中清理机制,参考 `FFmpegDownloadService.cleanupTempFiles()` 和 `SubtitleExtractorService.cleanupTempFiles()` 的实现模式 +- 临时文件清理的最佳实践: + 1. 在服务中实现 `cleanupTempFiles()` 方法,扫描并删除符合特定模式的临时文件 + 2. 在 `src/main/index.ts` 的 `app.on('will-quit')` 事件中调用清理方法 + 3. 通过 IPC 通道暴露清理接口,允许渲染进程手动触发清理(如 `SubtitleExtractor_CleanupTemp`) + 4. 使用正则表达式精确匹配临时文件模式,避免误删其他文件 + 5. 包含完整的错误处理和日志记录,跳过正在使用或无法删除的文件 +- 临时文件命名规范:使用 `__.` 格式(如 `subtitle_1234567890_abc123.srt`),便于模式匹配和清理 + +## File System Operations + +- **禁止使用同步文件操作**:在主进程中必须使用异步文件 API(`fs.promises.*`),避免阻塞事件循环导致应用冻结 +- 文件操作最佳实践: + - ❌ 错误示例:`fs.readdirSync()`, `fs.unlinkSync()`, `fs.readFileSync()` + - ✅ 正确示例:`await fs.promises.readdir()`, `await fs.promises.unlink()`, `await fs.promises.readFile()` + - 批量文件操作使用 `Promise.all()` 并行执行,提升性能 + - 所有文件操作方法应声明为 `async` 并返回 `Promise` + ## Issues & Solutions 1. DictionaryPopover 组件主题兼容性问题已修复:将硬编码的深色主题颜色(白色文字、深色背景)替换为 Ant Design CSS 变量(如 `var(--ant-color-text)`、`var(--ant-color-bg-elevated)`),实现浅色和深色主题的自动适配,包括文字颜色、背景色、边框、滚动条和交互状态的完整主题化。 +2. SubtitleExtractorService 临时文件清理机制已实现:在应用退出时自动清理系统临时目录中的字幕临时文件,防止磁盘空间浪费;支持通过 IPC 通道手动触发清理。 + ## Task Master AI Instructions **Import Task Master's development workflow commands and guidelines, treat as if import is in the main CLAUDE.md file.** diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 438dd4ed..9c665151 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -135,6 +135,11 @@ export enum IpcChannel { MediaInfo_GetVideoInfo = 'mediainfo:get-video-info', MediaInfo_GetVideoInfoWithStrategy = 'mediainfo:get-video-info-with-strategy', + // 字幕轨道相关 IPC 通道 / Subtitle stream related IPC channels + Media_GetSubtitleStreams = 'media:get-subtitle-streams', + Media_ExtractSubtitle = 'media:extract-subtitle', + SubtitleExtractor_CleanupTemp = 'subtitle-extractor:cleanup-temp', + // 文件系统相关 IPC 通道 / File system related IPC channels Fs_CheckFileExists = 'fs:check-file-exists', Fs_ReadFile = 'fs:read-file', diff --git a/src/main/index.ts b/src/main/index.ts index 152f2531..cf77462b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -182,6 +182,16 @@ if (!app.requestSingleInstanceLock()) { }) app.on('will-quit', async () => { + // Cleanup temporary subtitle files + try { + const SubtitleExtractorService = (await import('./services/SubtitleExtractorService')).default + const subtitleExtractorService = new SubtitleExtractorService() + await subtitleExtractorService.cleanupTempFiles() + logger.info('Temporary subtitle files cleaned up') + } catch (error) { + logger.error('Error cleaning up temporary subtitle files:', { error }) + } + // Close database connections try { const { closeDatabase } = await import('./db/index') diff --git a/src/main/ipc.ts b/src/main/ipc.ts index fedd248e..df15bc05 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -29,6 +29,7 @@ import { mediaServerService } from './services/MediaServerService' import NotificationService from './services/NotificationService' import { pythonVenvService } from './services/PythonVenvService' import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' +import SubtitleExtractorService from './services/SubtitleExtractorService' import { themeService } from './services/ThemeService' import { uvBootstrapperService } from './services/UvBootstrapperService' import { calculateDirectorySize, getResourcePath } from './utils' @@ -41,18 +42,18 @@ const fileManager = new FileStorage() const dictionaryService = new DictionaryService() const ffmpegService = new FFmpegService() const mediaParserService = new MediaParserService() +const subtitleExtractorService = new SubtitleExtractorService() /** - * Register all IPC handlers used by the main process. + * Registers all ipcMain handlers used by the main process. * - * Initializes updater and notification services and wires a comprehensive set of ipcMain.handle - * handlers exposing application control, system info, theming/language, spell-check, cache and - * file operations, dictionary lookups, FFmpeg operations, shortcut management, and database DAOs - * (Files, VideoLibrary, SubtitleLibrary) to renderer processes. + * Exposes application control, system information, theming/language, spell-check, cache and file + * operations, media tooling (FFmpeg, media parser, subtitle extraction), shortcuts, and database + * DAOs to renderer processes via ipcMain handlers. * - * This function has side effects: it registers handlers on ipcMain, may attach an app 'before-quit' - * listener when requested, and mutates Electron state (e.g., app paths, sessions). Call from the - * Electron main process once (typically during app initialization). + * This function mutates Electron state (registers handlers on ipcMain, may add/remove an app + * 'before-quit' listener, and updates sessions/paths) and must be invoked once from the main + * process during application initialization. */ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { const appUpdater = new AppUpdater(mainWindow) @@ -681,14 +682,45 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { } ) + // 字幕轨道相关 IPC 处理程序 / Subtitle stream-related IPC handlers + ipcMain.handle(IpcChannel.Media_GetSubtitleStreams, async (_, inputPath: string) => { + return await mediaParserService.getSubtitleStreams(inputPath) + }) + + ipcMain.handle( + IpcChannel.Media_ExtractSubtitle, + async ( + _, + options: { + videoPath: string + streamIndex: number + outputFormat?: string + subtitleCodec?: string + } + ) => { + return await subtitleExtractorService.extractSubtitle({ + videoPath: options.videoPath, + streamIndex: options.streamIndex, + outputFormat: (options.outputFormat as 'srt' | 'ass' | 'vtt') || 'srt', + subtitleCodec: options.subtitleCodec + }) + } + ) + + ipcMain.handle(IpcChannel.SubtitleExtractor_CleanupTemp, async () => { + const count = await subtitleExtractorService.cleanupTempFiles() + logger.info('触发字幕临时文件清理', { count }) + return count + }) + // 文件系统相关 IPC 处理程序 / File system-related IPC handlers ipcMain.handle(IpcChannel.Fs_CheckFileExists, async (_, filePath: string) => { try { - const exists = fs.existsSync(filePath) - logger.debug('检查文件存在性', { filePath, exists }) - return exists + await fs.promises.access(filePath, fs.constants.F_OK) + logger.debug('检查文件存在性', { filePath, exists: true }) + return true } catch (error) { - logger.error('检查文件存在性时出错', { filePath, error }) + logger.debug('检查文件存在性', { filePath, exists: false }) return false } }) diff --git a/src/main/services/FFmpegService.ts b/src/main/services/FFmpegService.ts index 81fae394..d6a2debc 100644 --- a/src/main/services/FFmpegService.ts +++ b/src/main/services/FFmpegService.ts @@ -1,3 +1,4 @@ +import { PathConverter } from '@shared/utils/PathConverter' import { PerformanceMonitor } from '@shared/utils/PerformanceMonitor' import { spawn } from 'child_process' import { app } from 'electron' @@ -38,81 +39,6 @@ class FFmpegService { // 构造函数可以用于初始化操作 } - // 将file://URL转换为本地文件路径 - private convertFileUrlToLocalPath(inputPath: string): string { - // 如果是file://URL,需要转换为本地路径 - if (inputPath.startsWith('file://')) { - try { - const url = new URL(inputPath) - let localPath = decodeURIComponent(url.pathname) - - // Windows路径处理:移除开头的斜杠 - if (process.platform === 'win32' && localPath.startsWith('/')) { - localPath = localPath.substring(1) - } - - // 添加详细的调试信息 - logger.info('🔄 URL路径转换详情', { - 原始路径: inputPath, - 'URL.pathname': url.pathname, - 解码前路径: url.pathname, - 解码后路径: localPath, - 平台: process.platform, - 文件是否存在: fs.existsSync(localPath) - }) - - // 额外验证:尝试列出目录内容来确认文件是否真的存在 - if (!fs.existsSync(localPath)) { - const dirPath = path.dirname(localPath) - const fileName = path.basename(localPath) - - logger.info('🔍 文件不存在,检查目录内容', { - 目录路径: dirPath, - 期望文件名: fileName, - 目录是否存在: fs.existsSync(dirPath) - }) - - if (fs.existsSync(dirPath)) { - try { - const filesInDir = fs.readdirSync(dirPath) - logger.info('📁 目录中的文件', { - 目录路径: dirPath, - 文件列表: filesInDir, - 文件数量: filesInDir.length - }) - - // 查找可能的匹配文件(大小写不敏感匹配) - const matchingFiles = filesInDir.filter( - (file) => - file.toLowerCase().includes('老友记') || - file.toLowerCase().includes('h265') || - file.toLowerCase().includes(fileName.toLowerCase()) - ) - - if (matchingFiles.length > 0) { - logger.info('🎯 找到可能匹配的文件', { matchingFiles }) - } - } catch (error) { - logger.error( - '无法读取目录内容:', - error instanceof Error ? error : new Error(String(error)) - ) - } - } - } - - return localPath - } catch (error) { - logger.error('URL路径转换失败:', error instanceof Error ? error : new Error(String(error))) - // 如果转换失败,返回原路径 - return inputPath - } - } - - // 如果不是file://URL,直接返回 - return inputPath - } - // 获取内置 FFmpeg 路径 private getBundledFFmpegPath(): string | null { try { @@ -243,20 +169,29 @@ class FFmpegService { } } + // 获取 FFprobe 路径 + public getFFprobePath(): string { + try { + return ffmpegDownloadService.getFFprobePath() + } catch (error) { + logger.warn('获取下载的 FFprobe 路径失败,使用系统 FFprobe', { + error: error instanceof Error ? error.message : String(error) + }) + // 降级到系统 FFprobe + return 'ffprobe' + } + } + // 快速检查 FFmpeg 是否存在(文件系统级别检查) - public fastCheckFFmpegExists(): boolean { + public async fastCheckFFmpegExists(): Promise { const startTime = Date.now() const ffmpegPath = this.getFFmpegPath() try { - // 检查文件是否存在 - if (!fs.existsSync(ffmpegPath)) { - logger.info('⚡ 快速检查: FFmpeg 文件不存在', { ffmpegPath }) - return false - } + // 检查文件是否存在并获取文件信息 + const stats = await fs.promises.stat(ffmpegPath) // 检查是否为文件(非目录) - const stats = fs.statSync(ffmpegPath) if (!stats.isFile()) { logger.info('⚡ 快速检查: FFmpeg 路径不是文件', { ffmpegPath }) return false @@ -319,7 +254,7 @@ class FFmpegService { }) try { - const fastCheckPassed = this.fastCheckFFmpegExists() + const fastCheckPassed = await this.fastCheckFFmpegExists() if (!fastCheckPassed) { // 快速检查失败,直接缓存结果并返回 FFmpegService.ffmpegAvailabilityCache[ffmpegPath] = false @@ -483,12 +418,21 @@ class FFmpegService { try { // 转换路径 - pm.startTiming(this.convertFileUrlToLocalPath) - const localInputPath = this.convertFileUrlToLocalPath(inputPath) - pm.endTiming(this.convertFileUrlToLocalPath) + pm.startTiming('convertToLocalPath') + const pathResult = PathConverter.convertToLocalPath(inputPath) + pm.endTiming('convertToLocalPath') + + if (!pathResult.isValid) { + logger.error(`❌ 路径转换失败: ${pathResult.error}`) + return null + } + + const localInputPath = pathResult.localPath // 检查文件是否存在 - if (!fs.existsSync(localInputPath)) { + try { + await fs.promises.access(localInputPath, fs.constants.F_OK) + } catch { logger.error(`❌ 文件不存在: ${localInputPath}`) return null } diff --git a/src/main/services/MediaParserService.ts b/src/main/services/MediaParserService.ts index 4c47bca1..bbf1780a 100644 --- a/src/main/services/MediaParserService.ts +++ b/src/main/services/MediaParserService.ts @@ -1,7 +1,8 @@ import { parseMedia } from '@remotion/media-parser' import { nodeReader } from '@remotion/media-parser/node' import { PathConverter } from '@shared/utils/PathConverter' -import type { FFmpegVideoInfo } from '@types' +import type { FFmpegVideoInfo, SubtitleStream, SubtitleStreamsResponse } from '@types' +import { spawn } from 'child_process' import * as fs from 'fs' import FFmpegService from './FFmpegService' @@ -16,42 +17,6 @@ class MediaParserService { this.ffmpegService = new FFmpegService() } - /** - * 将文件 URL 转换为本地路径 - */ - private convertFileUrlToLocalPath(inputPath: string): string { - // 如果是file://URL,需要转换为本地路径 - if (inputPath.startsWith('file://')) { - try { - const url = new URL(inputPath) - let localPath = decodeURIComponent(url.pathname) - - // Windows路径处理:移除开头的斜杠 - if (process.platform === 'win32' && localPath.startsWith('/')) { - localPath = localPath.substring(1) - } - - logger.info('🔄 URL路径转换', { - 原始路径: inputPath, - 转换后路径: localPath, - 平台: process.platform, - 文件是否存在: fs.existsSync(localPath) - }) - - return localPath - } catch (error) { - logger.error('URL路径转换失败:', { - error: error instanceof Error ? error : new Error(String(error)) - }) - // 如果转换失败,返回原路径 - return inputPath - } - } - - // 如果不是file://URL,直接返回 - return inputPath - } - /** * 将 Remotion parseMedia 结果转换为 FFmpegVideoInfo 格式 */ @@ -194,13 +159,15 @@ class MediaParserService { return null } - // 快速检查文件存在性 - if (!fs.existsSync(pathResult.localPath)) { + // 快速检查文件存在性并获取文件大小 + let fileSize: number + try { + const stats = await fs.promises.stat(pathResult.localPath) + fileSize = stats.size + } catch { logger.error(`❌ 文件不存在: ${pathResult.localPath}`) return null } - - const fileSize = fs.statSync(pathResult.localPath).size logger.info(`📊 文件大小: ${Math.round((fileSize / 1024 / 1024) * 100) / 100}MB`) // 根据策略选择解析器 @@ -288,7 +255,14 @@ class MediaParserService { try { // 转换文件路径 const pathConvertStartTime = Date.now() - const localInputPath = this.convertFileUrlToLocalPath(inputPath) + const pathResult = PathConverter.convertToLocalPath(inputPath) + + if (!pathResult.isValid) { + logger.error(`❌ 路径转换失败: ${pathResult.error}`) + return null + } + + const localInputPath = pathResult.localPath const pathConvertEndTime = Date.now() logger.info(`🔄 路径转换耗时: ${pathConvertEndTime - pathConvertStartTime}ms`, { @@ -296,27 +270,27 @@ class MediaParserService { localInputPath }) - // 检查文件是否存在 + // 检查文件是否存在并获取文件信息 const fileCheckStartTime = Date.now() - const fileExists = fs.existsSync(localInputPath) - const fileCheckEndTime = Date.now() - - logger.info(`📁 文件存在性检查耗时: ${fileCheckEndTime - fileCheckStartTime}ms`, { - fileExists - }) - - if (!fileExists) { + let fileStats: fs.Stats + let fileSize: number + try { + fileStats = await fs.promises.stat(localInputPath) + fileSize = fileStats.size + } catch { + const fileCheckEndTime = Date.now() + logger.info(`📁 文件存在性检查耗时: ${fileCheckEndTime - fileCheckStartTime}ms`, { + fileExists: false + }) logger.error(`❌ 文件不存在: ${localInputPath}`) return null } - - // 获取文件大小 - const fileStatsStartTime = Date.now() - const fileStats = fs.statSync(localInputPath) - const fileSize = fileStats.size const fileStatsEndTime = Date.now() - logger.info(`📊 文件信息获取耗时: ${fileStatsEndTime - fileStatsStartTime}ms`, { + logger.info(`📁 文件存在性检查耗时: ${fileStatsEndTime - fileCheckStartTime}ms`, { + fileExists: true + }) + logger.info(`📊 文件信息获取耗时: ${fileStatsEndTime - fileCheckStartTime}ms`, { fileSize: `${Math.round((fileSize / 1024 / 1024) * 100) / 100}MB` }) @@ -493,6 +467,213 @@ class MediaParserService { }) } + /** + * 使用 ffprobe 获取视频文件的字幕轨道信息 + */ + public async getSubtitleStreams(inputPath: string): Promise { + const startTime = Date.now() + logger.info('🔍 开始获取字幕轨道信息', { inputPath }) + + try { + // 转换文件路径 + const pathResult = PathConverter.convertToLocalPath(inputPath) + + if (!pathResult.isValid) { + logger.error(`❌ 路径转换失败: ${pathResult.error}`) + return null + } + + // 检查文件是否存在 + try { + await fs.promises.access(pathResult.localPath, fs.constants.F_OK) + } catch { + logger.error(`❌ 文件不存在: ${pathResult.localPath}`) + return null + } + + // 使用 ffprobe 获取流信息 + const streams = await this.probeSubtitleStreams(pathResult.localPath) + + if (!streams || streams.length === 0) { + logger.info('📄 此视频文件不含字幕轨道', { inputPath }) + return { + videoPath: inputPath, + streams: [], + textStreams: [], + imageStreams: [] + } + } + + // 分类字幕轨道(文本与图像) + const textStreams: SubtitleStream[] = [] + const imageStreams: SubtitleStream[] = [] + + for (const stream of streams) { + if (stream.isPGS) { + imageStreams.push(stream) + } else { + textStreams.push(stream) + } + } + + const totalTime = Date.now() - startTime + logger.info('✅ 成功获取字幕轨道信息', { + total: streams.length, + text: textStreams.length, + image: imageStreams.length, + duration: `${totalTime}ms` + }) + + return { + videoPath: inputPath, + streams, + textStreams, + imageStreams + } + } catch (error) { + const totalTime = Date.now() - startTime + logger.error(`❌ 获取字幕轨道失败,耗时: ${totalTime}ms`, { + inputPath, + error: error instanceof Error ? error.message : String(error) + }) + return null + } + } + + /** + * 使用 ffprobe 探测字幕轨道 + */ + private async probeSubtitleStreams(localPath: string): Promise { + return new Promise((resolve, reject) => { + const ffprobePath = this.ffmpegService.getFFprobePath() + let settled = false + + logger.debug('🔍 执行 ffprobe 命令', { + ffprobePath, + inputPath: localPath + }) + + const ffprobe = spawn(ffprobePath, [ + '-v', + 'quiet', + '-print_format', + 'json', + '-show_streams', + '-select_streams', + 's', + localPath + ]) + + let output = '' + let errorOutput = '' + + const timeoutHandle = setTimeout(() => { + if (settled) return + + logger.warn('⏱️ ffprobe 执行超时', { + inputPath: localPath + }) + + if (ffprobe && !ffprobe.killed) { + ffprobe.kill('SIGKILL') + } + + settled = true + resolve(null) + }, 15000) + + ffprobe.stdout?.on('data', (data) => { + output += data.toString() + }) + + ffprobe.stderr?.on('data', (data) => { + errorOutput += data.toString() + }) + + ffprobe.on('close', (code) => { + if (settled) return + + clearTimeout(timeoutHandle) + + if (code !== 0) { + logger.error('📄 ffprobe 执行失败', { + code, + error: errorOutput + }) + settled = true + resolve(null) + return + } + + try { + const result = JSON.parse(output) + const subtitleStreams = this.parseFFprobeSubtitleStreams(result.streams || []) + settled = true + resolve(subtitleStreams) + } catch (error) { + logger.error('解析 ffprobe 输出失败', { + error: error instanceof Error ? error.message : String(error), + output: output.slice(0, 500) + }) + settled = true + resolve(null) + } + }) + + ffprobe.on('error', (error) => { + if (settled) return + + clearTimeout(timeoutHandle) + logger.error('📄 ffprobe 进程错误', { + error: error.message + }) + settled = true + reject(error) + }) + }) + } + + /** + * 解析 ffprobe 输出中的字幕轨道 + */ + private parseFFprobeSubtitleStreams(streams: any[]): SubtitleStream[] { + const subtitleStreams: SubtitleStream[] = [] + const pgsCodecs = ['hdmv_pgs_subtitle', 'dvb_subtitle', 'xsub'] + + for (const stream of streams) { + // 只处理字幕轨道 + if (stream.codec_type !== 'subtitle') { + continue + } + + const codec = stream.codec_name || 'unknown' + const isPGS = pgsCodecs.includes(codec) + + const subtitleStream: SubtitleStream = { + index: stream.index, + streamId: `0:${stream.index}`, + codec: codec as any, + language: stream.tags?.language || undefined, + title: stream.tags?.title || undefined, + isDefault: stream.disposition?.default === 1, + isForced: stream.disposition?.forced === 1, + isPGS + } + + subtitleStreams.push(subtitleStream) + + logger.debug('📄 字幕轨道信息', { + index: subtitleStream.index, + codec: subtitleStream.codec, + language: subtitleStream.language, + title: subtitleStream.title, + isPGS: subtitleStream.isPGS + }) + } + + return subtitleStreams + } + /** * 清理资源 */ diff --git a/src/main/services/SubtitleExtractorService.ts b/src/main/services/SubtitleExtractorService.ts new file mode 100644 index 00000000..62d0298f --- /dev/null +++ b/src/main/services/SubtitleExtractorService.ts @@ -0,0 +1,305 @@ +import { spawn } from 'child_process' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' + +import FFmpegService from './FFmpegService' +import { loggerService } from './LoggerService' + +const logger = loggerService.withContext('SubtitleExtractorService') + +export interface ExtractSubtitleOptions { + videoPath: string + streamIndex: number + outputFormat?: 'srt' | 'ass' | 'vtt' + subtitleCodec?: string +} + +export interface ExtractSubtitleResult { + success: boolean + outputPath?: string + error?: string +} + +class SubtitleExtractorService { + private ffmpegService: FFmpegService + + constructor() { + this.ffmpegService = new FFmpegService() + } + + /** + * 从视频文件中提取字幕轨道 + */ + public async extractSubtitle(options: ExtractSubtitleOptions): Promise { + const startTime = Date.now() + logger.info('🎬 开始提取字幕轨道', { + videoPath: options.videoPath, + streamIndex: options.streamIndex, + subtitleCodec: options.subtitleCodec, + outputFormat: options.outputFormat + }) + + try { + // 验证输入 + if (!options.videoPath) { + logger.error('❌ 视频文件路径为空', { videoPath: options.videoPath }) + return { + success: false, + error: '视频文件不存在' + } + } + + try { + await fs.promises.access(options.videoPath, fs.constants.F_OK) + } catch { + logger.error('❌ 视频文件不存在', { videoPath: options.videoPath }) + return { + success: false, + error: '视频文件不存在' + } + } + + // 根据源字幕格式确定输出格式 + const outputFormat = this.getOutputFormatFromCodec( + options.subtitleCodec, + options.outputFormat + ) + + // 生成临时输出文件路径 + const outputPath = this.generateTempSubtitlePath(outputFormat) + + // 使用 FFmpeg 提取字幕 + const success = await this.runFFmpegExtract( + options.videoPath, + options.streamIndex, + outputPath, + options.subtitleCodec + ) + + if (!success) { + logger.error('❌ FFmpeg 提取字幕失败') + return { + success: false, + error: 'FFmpeg 提取字幕失败' + } + } + + // 验证输出文件 + try { + await fs.promises.access(outputPath, fs.constants.F_OK) + } catch { + logger.error('❌ 输出文件不存在', { outputPath }) + return { + success: false, + error: '输出文件生成失败' + } + } + + const totalTime = Date.now() - startTime + logger.info('✅ 成功提取字幕轨道', { + outputPath, + duration: `${totalTime}ms` + }) + + return { + success: true, + outputPath + } + } catch (error) { + const totalTime = Date.now() - startTime + const errorMsg = error instanceof Error ? error.message : String(error) + logger.error(`❌ 提取字幕失败,耗时: ${totalTime}ms`, { + error: errorMsg + }) + return { + success: false, + error: errorMsg + } + } + } + + /** + * 使用 FFmpeg 提取字幕轨道 + */ + private async runFFmpegExtract( + videoPath: string, + streamIndex: number, + outputPath: string, + subtitleCodec?: string + ): Promise { + return new Promise((resolve) => { + const ffmpegPath = this.ffmpegService.getFFmpegPath() + const args = ['-i', videoPath, '-map', `0:${streamIndex}`, '-c', 'copy', outputPath] + const fullCommand = `${ffmpegPath} ${args.map((arg) => (arg.includes(' ') ? `"${arg}"` : arg)).join(' ')}` + + logger.debug('🎬 执行 FFmpeg 提取命令', { + ffmpegPath, + videoPath, + streamIndex, + subtitleCodec, + outputPath, + command: fullCommand + }) + + const ffmpeg = spawn(ffmpegPath, args) + + let errorOutput = '' + + ffmpeg.stderr?.on('data', (data) => { + errorOutput += data.toString() + }) + + const timeoutHandle = setTimeout(() => { + if (ffmpeg && !ffmpeg.killed) { + ffmpeg.kill('SIGKILL') + } + logger.error('⏰ FFmpeg 提取超时') + resolve(false) + }, 30000) + + ffmpeg.on('close', (code) => { + clearTimeout(timeoutHandle) + + if (code === 0) { + logger.debug('✅ FFmpeg 提取成功', { code }) + resolve(true) + } else { + logger.error('❌ FFmpeg 提取失败', { + code, + error: errorOutput.slice(0, 500) + }) + resolve(false) + } + }) + + ffmpeg.on('error', (error) => { + clearTimeout(timeoutHandle) + logger.error('❌ FFmpeg 进程错误', { + error: error.message + }) + resolve(false) + }) + }) + } + + /** + * 根据字幕编解码器确定输出格式 + */ + private getOutputFormatFromCodec(subtitleCodec?: string, fallbackFormat?: string): string { + // 编解码器到文件格式的映射 + const codecToFormat: Record = { + subrip: 'srt', + ass: 'ass', + ssa: 'ass', + webvtt: 'vtt', + vtt: 'vtt', + mov_text: 'srt', + hdmv_pgs_subtitle: 'sup', + dvb_subtitle: 'sub', + xsub: 'sub' + } + + if (subtitleCodec && codecToFormat[subtitleCodec]) { + const format = codecToFormat[subtitleCodec] + logger.debug('📄 根据编解码器确定输出格式', { + subtitleCodec, + outputFormat: format + }) + return format + } + + // 如果编解码器不在映射表中,使用 fallback 格式或默认 srt + const defaultFormat = fallbackFormat || 'srt' + logger.debug('📄 使用默认输出格式', { + subtitleCodec, + defaultFormat + }) + return defaultFormat + } + + /** + * 生成临时字幕文件路径 + */ + private generateTempSubtitlePath(format: string): string { + const tempDir = os.tmpdir() + const timestamp = Date.now() + // 生成只包含小写字母和数字的随机字符串 + const randomStr = Math.random().toString(36).substring(7).toLowerCase() + const fileName = `subtitle_${timestamp}_${randomStr}.${format}` + return path.join(tempDir, fileName) + } + + /** + * 清理临时字幕文件 + */ + public async cleanupTempFile(filePath: string): Promise { + try { + await fs.promises.unlink(filePath) + logger.info('🧹 清理临时字幕文件', { filePath }) + return true + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError?.code === 'ENOENT') { + return false + } + logger.error('清理临时字幕文件失败', { + filePath, + error: error instanceof Error ? error.message : String(error) + }) + return false + } + } + + /** + * 清理所有临时字幕文件 + * 扫描系统临时目录中的所有 subtitle_* 格式的临时文件并清理 + */ + public async cleanupTempFiles(): Promise { + try { + const tempDir = os.tmpdir() + const files = await fs.promises.readdir(tempDir) + + // 匹配临时字幕文件的模式:subtitle__. + const subtitlePattern = /^subtitle_\d+_[a-z0-9]+\.(srt|ass|vtt|sup|sub)$/ + + let cleanedCount = 0 + const deletePromises: Promise[] = [] + + for (const file of files) { + if (subtitlePattern.test(file)) { + const filePath = path.join(tempDir, file) + const deletePromise = fs.promises + .unlink(filePath) + .then(() => { + cleanedCount++ + logger.debug('清理临时字幕文件', { filePath }) + }) + .catch((error) => { + // 跳过无法删除的文件(可能正在被使用) + logger.debug('无法清理临时字幕文件(可能正在使用)', { + filePath, + error: error instanceof Error ? error.message : String(error) + }) + }) + deletePromises.push(deletePromise) + } + } + + // 并行执行所有删除操作 + await Promise.all(deletePromises) + + if (cleanedCount > 0) { + logger.info('清理临时字幕文件完成', { count: cleanedCount }) + } else { + logger.info('未找到临时字幕文件可清理') + } + } catch (error) { + logger.error('清理临时字幕文件失败', { + error: error instanceof Error ? error.message : String(error) + }) + } + } +} + +export default SubtitleExtractorService diff --git a/src/main/services/__tests__/FFmpegService.integration.test.ts b/src/main/services/__tests__/FFmpegService.integration.test.ts index 2b8d5e7d..275f2dff 100644 --- a/src/main/services/__tests__/FFmpegService.integration.test.ts +++ b/src/main/services/__tests__/FFmpegService.integration.test.ts @@ -97,7 +97,7 @@ describe('FFmpegService Integration Tests', () => { }) describe('FFmpeg availability check', () => { - it('should return true for existing bundled FFmpeg', () => { + it('should return true for existing bundled FFmpeg', async () => { // Mock bundled FFmpeg exists with proper stats vi.mocked(fs.existsSync).mockReturnValue(true) vi.mocked(fs.statSync).mockReturnValue({ @@ -106,19 +106,35 @@ describe('FFmpegService Integration Tests', () => { size: 1024 * 1024 } as any) - const exists = ffmpegService.fastCheckFFmpegExists() + // Mock fs.promises.stat for async operation + const mockStat = vi.fn().mockResolvedValue({ + isFile: () => true, + mode: 0o755, + size: 1024 * 1024 + }) + vi.mocked(fs.promises).stat = mockStat + + const exists = await ffmpegService.fastCheckFFmpegExists() expect(exists).toBe(true) }) - it('should return false for non-existent FFmpeg', () => { + it('should return false for non-existent FFmpeg', async () => { // Mock FFmpeg does not exist vi.mocked(fs.existsSync).mockReturnValue(false) - const exists = ffmpegService.fastCheckFFmpegExists() + // Mock fs.promises.stat to throw ENOENT error + const mockStat = vi + .fn() + .mockRejectedValue( + Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }) + ) + vi.mocked(fs.promises).stat = mockStat + + const exists = await ffmpegService.fastCheckFFmpegExists() expect(exists).toBe(false) }) - it('should return false for directory instead of file', () => { + it('should return false for directory instead of file', async () => { // Mock path exists but is directory vi.mocked(fs.existsSync).mockReturnValue(true) vi.mocked(fs.statSync).mockReturnValue({ @@ -127,7 +143,15 @@ describe('FFmpegService Integration Tests', () => { size: 0 } as any) - const exists = ffmpegService.fastCheckFFmpegExists() + // Mock fs.promises.stat to return directory stats + const mockStat = vi.fn().mockResolvedValue({ + isFile: () => false, + mode: 0o755, + size: 0 + }) + vi.mocked(fs.promises).stat = mockStat + + const exists = await ffmpegService.fastCheckFFmpegExists() expect(exists).toBe(false) }) }) @@ -201,10 +225,18 @@ describe('FFmpegService Integration Tests', () => { size: 1024 * 1024 } as any) + // Mock fs.promises.stat for async operation + const mockStat = vi.fn().mockResolvedValue({ + isFile: () => true, + mode: 0o755, + size: 1024 * 1024 + }) + vi.mocked(fs.promises).stat = mockStat + // These methods should work without throwing const path = ffmpegService.getFFmpegPath() const info = ffmpegService.getFFmpegInfo() - const exists = ffmpegService.fastCheckFFmpegExists() + const exists = await ffmpegService.fastCheckFFmpegExists() expect(path).toBeTruthy() expect(info).toBeTruthy() @@ -213,15 +245,18 @@ describe('FFmpegService Integration Tests', () => { }) describe('Error handling', () => { - it('should handle filesystem errors gracefully', () => { + it('should handle filesystem errors gracefully', async () => { vi.mocked(fs.existsSync).mockImplementation(() => { throw new Error('Filesystem error') }) - expect(() => { - const exists = ffmpegService.fastCheckFFmpegExists() - expect(exists).toBe(false) - }).not.toThrow() + // Mock fs.promises.stat to throw error + const mockStat = vi.fn().mockRejectedValue(new Error('Filesystem error')) + vi.mocked(fs.promises).stat = mockStat + + // Should not throw, but return false gracefully + const exists = await ffmpegService.fastCheckFFmpegExists() + expect(exists).toBe(false) }) it('should handle missing download service gracefully', () => { diff --git a/src/main/services/__tests__/SubtitleExtractorService.unit.test.ts b/src/main/services/__tests__/SubtitleExtractorService.unit.test.ts new file mode 100644 index 00000000..18cfe6f9 --- /dev/null +++ b/src/main/services/__tests__/SubtitleExtractorService.unit.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +// 首先取消全局的 fs mock +vi.unmock('node:fs') +vi.unmock('node:fs/promises') + +// 然后导入真实的 fs +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' + +const mockLogger = { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn() +} + +vi.mock('../LoggerService', () => ({ + loggerService: { + withContext: () => mockLogger + } +})) + +describe('SubtitleExtractorService', () => { + let SubtitleExtractorService: any + let service: any + let tempDir: string + let testFiles: string[] = [] + + beforeAll(async () => { + // 动态导入服务 + const module = await import('../SubtitleExtractorService') + SubtitleExtractorService = module.default + }) + + beforeEach(() => { + service = new SubtitleExtractorService() + tempDir = os.tmpdir() + testFiles = [] + vi.clearAllMocks() + }) + + afterEach(() => { + // 清理测试创建的临时文件 + for (const file of testFiles) { + try { + if (fs.existsSync(file)) { + fs.unlinkSync(file) + } + } catch (error) { + // 忽略清理错误 + } + } + testFiles = [] + }) + + describe('cleanupTempFiles', () => { + it('should cleanup temporary subtitle files matching the pattern', async () => { + // 创建符合模式的临时字幕文件 + const subtitleFiles = [ + path.join(tempDir, 'subtitle_1234567890_abc123.srt'), + path.join(tempDir, 'subtitle_9876543210_xyz789.ass'), + path.join(tempDir, 'subtitle_1111111111_def456.vtt') + ] + + for (const file of subtitleFiles) { + fs.writeFileSync(file, 'test content') + testFiles.push(file) + } + + // 创建不符合模式的文件(不应该被删除) + const otherFile = path.join(tempDir, 'other_file.txt') + fs.writeFileSync(otherFile, 'other content') + testFiles.push(otherFile) + + // 验证文件存在 + for (const file of subtitleFiles) { + expect(fs.existsSync(file)).toBe(true) + } + expect(fs.existsSync(otherFile)).toBe(true) + + // 执行清理 + await service.cleanupTempFiles() + + // 验证符合模式的文件被删除 + for (const file of subtitleFiles) { + expect(fs.existsSync(file)).toBe(false) + } + + // 验证不符合模式的文件未被删除 + expect(fs.existsSync(otherFile)).toBe(true) + + // 验证日志记录 - 检查是否调用了正确的日志方法 + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringMatching(/清理临时字幕文件完成/), + expect.objectContaining({ count: expect.any(Number) }) + ) + }) + + it('should handle case when no temporary subtitle files exist', async () => { + // 执行清理(没有创建任何临时文件) + await service.cleanupTempFiles() + + // 验证日志记录 + expect(mockLogger.info).toHaveBeenCalledWith('未找到临时字幕文件可清理') + }) + + it('should match correct file patterns', async () => { + // 测试正则表达式模式 + const subtitlePattern = /^subtitle_\d+_[a-z0-9]+\.(srt|ass|vtt|sup|sub)$/ + + // 验证正则表达式匹配规则 + expect(subtitlePattern.test('subtitle_1234567890_abc123.srt')).toBe(true) + expect(subtitlePattern.test('subtitle_9876543210_xyz789.ass')).toBe(true) + expect(subtitlePattern.test('subtitle_1111111111_def456.vtt')).toBe(true) + + // 验证正则表达式不匹配规则 + expect(subtitlePattern.test('subtitle_1234567890_ABC123.srt')).toBe(false) // 大写字母 + expect(subtitlePattern.test('subtitle_1234567890.srt')).toBe(false) // 缺少随机字符串 + expect(subtitlePattern.test('other_file.srt')).toBe(false) // 不同前缀 + }) + }) + + describe('cleanupTempFile', () => { + it('should return false when file does not exist', async () => { + // 使用一个更独特的文件名来避免冲突 + const nonExistentFile = path.join(tempDir, `non_existent_file_${Date.now()}.srt`) + + // 验证文件确实不存在 + expect(fs.existsSync(nonExistentFile)).toBe(false) + + // 执行清理 + const result = await service.cleanupTempFile(nonExistentFile) + + // 验证返回 false + expect(result).toBe(false) + }) + + it('should cleanup a specific temporary file', async () => { + // 创建临时文件 + const tempFile = path.join(tempDir, 'test_subtitle.srt') + fs.writeFileSync(tempFile, 'test content') + testFiles.push(tempFile) + + // 验证文件存在 + expect(fs.existsSync(tempFile)).toBe(true) + + // 执行清理 + const result = await service.cleanupTempFile(tempFile) + + // 验证文件被删除 + expect(result).toBe(true) + expect(fs.existsSync(tempFile)).toBe(false) + expect(mockLogger.info).toHaveBeenCalledWith('🧹 清理临时字幕文件', { filePath: tempFile }) + }) + }) +}) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 6d470bbd..3e56423c 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -12,46 +12,28 @@ "title": "Documentation" }, "player": { - "mediaServerPrompt": { - "title": "Video Format Incompatible", - "subtitle": "Current video format is not supported", - "benefits": { - "title": "Installing Media Server can resolve this issue", - "compatibility": { - "title": "Perfect Compatibility", - "description": "Supports all common video formats, including HD and Blu-ray videos" - }, - "transcoding": { - "title": "Real-time Transcoding", - "description": "Automatic background transcoding, no waiting required, ready to use" - }, - "easySetup": { - "title": "One-click Installation", - "description": "Automated installation process, no manual configuration needed" - } - }, - "actions": { - "install": "Install Now", - "later": "Maybe Later" - } - }, "controls": { "subtitle": { - "mask-mode": { - "title": "Mask Mode", - "label": "Mask Mode", - "enable": { - "tooltip": "Enable mask mode to lock the hard subtitle area" + "background-type": { + "blur": { + "tooltip": "Blur background" }, - "disable": { - "tooltip": "Disable mask mode" + "solid-black": { + "tooltip": "Solid black background" }, - "background-locked": { - "tooltip": "Background style is forced to Gaussian blur while mask mode is active" + "solid-gray": { + "tooltip": "Solid gray background" }, - "onboarding": "Drag or resize the subtitle box to match the on-screen hard subtitles" + "title": "Background Style", + "transparent": { + "tooltip": "Transparent background" + } }, "display-mode": { + "bilingual": { + "label": "Bilingual", + "tooltip": "Show both languages (Ctrl+4)" + }, "hide": { "label": "Hide", "tooltip": "Hide subtitles (Ctrl+1)" @@ -60,47 +42,111 @@ "label": "Original", "tooltip": "Show original subtitles only (Ctrl+2)" }, + "title": "Display Mode", "translation": { "label": "Translation", "tooltip": "Show translated subtitles only (Ctrl+3)" - }, - "bilingual": { - "label": "Bilingual", - "tooltip": "Show both languages (Ctrl+4)" - }, - "title": "Display Mode" + } }, - "background-type": { - "title": "Background Style", - "transparent": { - "tooltip": "Transparent background" + "mask-mode": { + "background-locked": { + "tooltip": "Background style is forced to Gaussian blur while mask mode is active" }, - "blur": { - "tooltip": "Blur background" + "disable": { + "tooltip": "Disable mask mode" }, - "solid-black": { - "tooltip": "Solid black background" + "enable": { + "tooltip": "Enable mask mode to lock the hard subtitle area" }, - "solid-gray": { - "tooltip": "Solid gray background" - } + "label": "Mask Mode", + "onboarding": "Drag or resize the subtitle box to match the on-screen hard subtitles", + "title": "Mask Mode" } } }, + "mediaServerPrompt": { + "actions": { + "install": "Install Now", + "later": "Maybe Later" + }, + "benefits": { + "compatibility": { + "description": "Supports all common video formats, including HD and Blu-ray videos", + "title": "Perfect Compatibility" + }, + "easySetup": { + "description": "Automated installation process, no manual configuration needed", + "title": "One-click Installation" + }, + "title": "Installing Media Server can resolve this issue", + "transcoding": { + "description": "Automatic background transcoding, no waiting required, ready to use", + "title": "Real-time Transcoding" + } + }, + "subtitle": "Current video format is not supported", + "title": "Video Format Incompatible" + }, "subtitleList": { "empty": { + "description": "Choose a way to start adding subtitles", "title": "No matching subtitle file found", - "description": "You can choose a subtitle file with the button below or drag a subtitle file into this area." + "options": { + "embedded": { + "title": "Use Embedded Subtitles", + "description": "Video file contains subtitle tracks that can be imported directly", + "action": "Select" + }, + "external": { + "title": "Import External Subtitles", + "description": "Import SRT, VTT, and other subtitle formats from local files" + }, + "ai": { + "title": "AI-Generated Subtitles", + "description": "Generate word-level subtitles based on speech recognition", + "action": "Coming Soon" + } + } }, "search": { - "placeholder": "Search subtitles...", - "pending": "Searching...", "count": "Found {{count}} subtitle", "count_one": "Found {{count}} subtitle", "count_other": "Found {{count}} subtitles", - "none": "No subtitles match your search", + "emptySubtitle": "Try another keyword", "emptyTitle": "No matches found", - "emptySubtitle": "Try another keyword" + "none": "No subtitles match your search", + "pending": "Searching...", + "placeholder": "Search subtitles..." + } + }, + "subtitleTrackSelector": { + "title": "Import Embedded Subtitle Tracks", + "empty": "No subtitle tracks detected", + "sections": { + "text": "Text Subtitle Tracks", + "image": "PGS Subtitle Tracks (Image Subtitles)" + }, + "stream": { + "label": "Stream {{index}}", + "tags": { + "default": "Default", + "forced": "Forced", + "unsupported": "Unsupported" + } + }, + "warning": { + "pgs": "PGS is an image-based subtitle format that requires OCR technology support. Import is not currently supported." + }, + "actions": { + "cancel": "Cancel", + "import": "Import" + }, + "messages": { + "selectAtLeastOne": "Please select at least one subtitle track", + "extractFailed": "Failed to extract subtitle track {{index}}", + "importFailed": "Failed to extract subtitle tracks, please try again", + "importSuccess": "Imported subtitle: {{source}} ({{count}} items)", + "importMultipleSuccess": "Imported {{tracks}} subtitle tracks ({{count}} items total)" } } }, @@ -218,41 +264,41 @@ "warmup_failed": "Warmup failed, please check installation", "warmup_success": "Warmup successful, FFmpeg is now available" }, + "path": { + "browse": "Browse", + "browse_title": "Select FFmpeg Executable", + "invalid": "Invalid path or file does not exist", + "label": "Path", + "placeholder": "FFmpeg path will be auto-filled after download, or specify manually", + "valid": "Path validation successful", + "validation_failed": "Path validation failed" + }, "prompt": { - "title": "Video Processing Component Required", - "subtitle": "EchoPlayer needs FFmpeg to process this video file", + "actions": { + "download": "Download FFmpeg Now", + "later": "Handle Later" + }, "benefits": { - "title": "Benefits of installing FFmpeg:", "compatibility": { - "title": "Broader Format Support", - "description": "Support for almost all video formats including MP4, AVI, MKV, MOV, WMV and more" + "description": "Support for almost all video formats including MP4, AVI, MKV, MOV, WMV and more", + "title": "Broader Format Support" }, "performance": { - "title": "Faster Processing Speed", - "description": "Optimized decoding algorithms for smoother playback experience" + "description": "Optimized decoding algorithms for smoother playback experience", + "title": "Faster Processing Speed" }, "reliability": { - "title": "Higher Stability", - "description": "Professional-grade video processing capabilities, reducing parsing failures and playback errors" - } + "description": "Professional-grade video processing capabilities, reducing parsing failures and playback errors", + "title": "Higher Stability" + }, + "title": "Benefits of installing FFmpeg:" }, "effort": { - "title": "Easy and Quick Installation", - "description": "One-click automatic download, about 50MB, installation completes in 2-3 minutes. No manual configuration needed, ready to use immediately." + "description": "One-click automatic download, about 50MB, installation completes in 2-3 minutes. No manual configuration needed, ready to use immediately.", + "title": "Easy and Quick Installation" }, - "actions": { - "download": "Download FFmpeg Now", - "later": "Handle Later" - } - }, - "path": { - "browse": "Browse", - "browse_title": "Select FFmpeg Executable", - "invalid": "Invalid path or file does not exist", - "label": "Path", - "placeholder": "FFmpeg path will be auto-filled after download, or specify manually", - "valid": "Path validation successful", - "validation_failed": "Path validation failed" + "subtitle": "EchoPlayer needs FFmpeg to process this video file", + "title": "Video Processing Component Required" }, "status": { "available": "Available", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index cd09b2cf..b4c47ddc 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -5,6 +5,70 @@ "docs": { "title": "ドキュメント" }, + "player": { + "subtitleList": { + "empty": { + "description": "字幕の追加を開始する方法を選択してください", + "title": "動画ファイルと同じフォルダーに一致する字幕ファイルが見つかりません", + "options": { + "embedded": { + "title": "埋め込み字幕を使用", + "description": "動画ファイルに字幕トラックが含まれており、直接インポートできます", + "action": "選択" + }, + "external": { + "title": "外部字幕をインポート", + "description": "ローカルファイルからSRT、VTTなどの字幕形式をインポート" + }, + "ai": { + "title": "AI生成字幕", + "description": "音声認識に基づいて単語レベルの字幕を生成", + "action": "近日公開" + } + } + }, + "search": { + "count": "{{count}} 件の字幕が見つかりました", + "count_one": "{{count}} 件の字幕が見つかりました", + "count_other": "{{count}} 件の字幕が見つかりました", + "emptySubtitle": "別のキーワードを試してください", + "emptyTitle": "一致する結果がありません", + "none": "一致する字幕が見つかりません", + "pending": "検索中...", + "placeholder": "字幕を検索..." + } + }, + "subtitleTrackSelector": { + "title": "埋め込み字幕トラックをインポート", + "empty": "字幕トラックが検出されませんでした", + "sections": { + "text": "テキスト字幕トラック", + "image": "PGS字幕トラック(画像字幕)" + }, + "stream": { + "label": "ストリーム {{index}}", + "tags": { + "default": "デフォルト", + "forced": "強制", + "unsupported": "未対応" + } + }, + "warning": { + "pgs": "PGSは画像形式の字幕で、OCR技術のサポートが必要です。現在インポートは対応していません。" + }, + "actions": { + "cancel": "キャンセル", + "import": "インポート" + }, + "messages": { + "selectAtLeastOne": "少なくとも1つの字幕トラックを選択してください", + "extractFailed": "字幕トラック {{index}} の抽出に失敗しました", + "importFailed": "字幕トラックの抽出に失敗しました。もう一度お試しください", + "importSuccess": "字幕をインポートしました:{{source}}({{count}} 項目)", + "importMultipleSuccess": "{{tracks}} 個の字幕トラックをインポートしました(合計 {{count}} 項目)" + } + } + }, "settings": { "about": { "checkUpdate": { @@ -98,8 +162,8 @@ "show_settings": "設定を開く", "single_loop": "ループ再生", "title": "ショートカットキー", - "toggle_fullscreen": "全画面表示に切り替えます", "toggle_auto_pause": "自動一時停止の切り替え", + "toggle_fullscreen": "全画面表示に切り替えます", "toggle_new_context": "上下文をクリアする", "toggle_show_assistants": "アシスタント表示の切り替え", "toggle_show_topics": "トピックの切り替え表示", @@ -134,23 +198,5 @@ "show_mini_window": "快速助手", "show_window": "表示ウィンドウ" } - }, - "player": { - "subtitleList": { - "empty": { - "title": "動画ファイルと同じフォルダーに一致する字幕ファイルが見つかりません", - "description": "下のボタンから字幕ファイルを選択するか、このエリアにドラッグしてください" - }, - "search": { - "placeholder": "字幕を検索...", - "pending": "検索中...", - "count": "{{count}} 件の字幕が見つかりました", - "count_one": "{{count}} 件の字幕が見つかりました", - "count_other": "{{count}} 件の字幕が見つかりました", - "none": "一致する字幕が見つかりません", - "emptyTitle": "一致する結果がありません", - "emptySubtitle": "別のキーワードを試してください" - } - } } } diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index eeaac915..ee097711 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -2,6 +2,72 @@ "common": { "language": "язык" }, + "player": { + "subtitleList": { + "empty": { + "description": "Выберите способ начать добавлять субтитры", + "title": "В той же папке, что и видео, не найден подходящий файл субтитров", + "options": { + "embedded": { + "title": "Использовать встроенные субтитры", + "description": "Видеофайл содержит дорожки субтитров, которые можно импортировать напрямую", + "action": "Выбрать" + }, + "external": { + "title": "Импортировать внешние субтитры", + "description": "Импортируйте форматы субтитров SRT, VTT и другие из локальных файлов" + }, + "ai": { + "title": "Субтитры, созданные ИИ", + "description": "Генерация пословных субтитров на основе распознавания речи", + "action": "Скоро будет" + } + } + }, + "search": { + "count": "Найден {{count}} файл субтитров", + "count_few": "Найдено {{count}} файла субтитров", + "count_many": "Найдено {{count}} файлов субтитров", + "count_one": "Найден {{count}} файл субтитров", + "count_other": "Найдено {{count}} субтитров", + "emptySubtitle": "Попробуйте другой запрос", + "emptyTitle": "Совпадений не найдено", + "none": "Субтитры по запросу не найдены", + "pending": "Поиск...", + "placeholder": "Поиск субтитров..." + } + }, + "subtitleTrackSelector": { + "title": "Импорт встроенных дорожек субтитров", + "empty": "Дорожки субтитров не обнаружены", + "sections": { + "text": "Текстовые дорожки субтитров", + "image": "Дорожки субтитров PGS (изображения субтитров)" + }, + "stream": { + "label": "Поток {{index}}", + "tags": { + "default": "По умолчанию", + "forced": "Принудительно", + "unsupported": "Не поддерживается" + } + }, + "warning": { + "pgs": "PGS — это формат субтитров на основе изображений, который требует поддержки технологии OCR. Импорт в настоящее время не поддерживается." + }, + "actions": { + "cancel": "Отмена", + "import": "Импортировать" + }, + "messages": { + "selectAtLeastOne": "Выберите хотя бы одну дорожку субтитров", + "extractFailed": "Не удалось извлечь дорожку субтитров {{index}}", + "importFailed": "Не удалось извлечь дорожки субтитров, попробуйте еще раз", + "importSuccess": "Импортированы субтитры: {{source}} ({{count}} элементов)", + "importMultipleSuccess": "Импортировано {{tracks}} дорожек субтитров (всего {{count}} элементов)" + } + } + }, "settings": { "about": { "updateError": "Проверка обновления не удалась" @@ -58,8 +124,8 @@ "show_settings": "открыть настройки", "single_loop": "циклическое воспроизведение", "title": "Горячие клавиши", - "toggle_fullscreen": "переключить полноэкранный режим", "toggle_auto_pause": "Переключить автоматическую паузу", + "toggle_fullscreen": "переключить полноэкранный режим", "toggle_new_context": "Очистить контекст", "toggle_show_assistants": "Переключить отображение помощника", "toggle_show_topics": "переключить отображение темы", @@ -94,25 +160,5 @@ "show_mini_window": "Быстрый помощник", "show_window": "отображать окно" } - }, - "player": { - "subtitleList": { - "empty": { - "title": "В той же папке, что и видео, не найден подходящий файл субтитров", - "description": "Вы можете выбрать файл субтитров с помощью кнопки ниже или перетащить его в эту область" - }, - "search": { - "placeholder": "Поиск субтитров...", - "pending": "Поиск...", - "count": "Найден {{count}} файл субтитров", - "count_one": "Найден {{count}} файл субтитров", - "count_few": "Найдено {{count}} файла субтитров", - "count_many": "Найдено {{count}} файлов субтитров", - "count_other": "Найдено {{count}} субтитров", - "none": "Субтитры по запросу не найдены", - "emptyTitle": "Совпадений не найдено", - "emptySubtitle": "Попробуйте другой запрос" - } - } } } diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index e4b3843f..2432b31d 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -217,7 +217,23 @@ "subtitleList": { "empty": { "title": "在视频文件同目录下未找到匹配的字幕文件", - "description": "您可以点击下方按钮选择字幕文件,或将字幕文件拖拽到此区域" + "description": "选择一种方式开始添加字幕", + "options": { + "embedded": { + "title": "使用内嵌字幕", + "description": "视频文件包含字幕轨道,可直接导入", + "action": "选择" + }, + "external": { + "title": "导入外挂字幕", + "description": "从本地文件导入 SRT、VTT 等格式字幕" + }, + "ai": { + "title": "AI 生成字幕", + "description": "基于语音识别生成单词级字幕", + "action": "即将推出" + } + } }, "search": { "placeholder": "搜索字幕...", @@ -229,6 +245,36 @@ "emptyTitle": "未找到匹配结果", "emptySubtitle": "请尝试其他关键词" } + }, + "subtitleTrackSelector": { + "title": "导入内嵌字幕轨道", + "empty": "未检测到字幕轨道", + "sections": { + "text": "文本字幕轨道", + "image": "PGS 字幕轨(图像字幕)" + }, + "stream": { + "label": "Stream {{index}}", + "tags": { + "default": "默认", + "forced": "强制", + "unsupported": "暂不支持" + } + }, + "warning": { + "pgs": "PGS 是图像格式字幕,需要 OCR 技术支持,暂不支持导入。" + }, + "actions": { + "cancel": "取消", + "import": "导入" + }, + "messages": { + "selectAtLeastOne": "请选择至少一个字幕轨道", + "extractFailed": "提取字幕轨道 {{index}} 失败", + "importFailed": "字幕轨道提取失败,请重试", + "importSuccess": "已导入字幕:{{source}}(共 {{count}} 条)", + "importMultipleSuccess": "已导入 {{tracks}} 个字幕轨道(共 {{count}} 条)" + } } }, "search": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index d02b1cbf..bd349c89 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -2,6 +2,70 @@ "common": { "language": "語言" }, + "player": { + "subtitleList": { + "empty": { + "description": "選擇方式開始新增字幕", + "title": "在影片檔同目錄下未找到符合的字幕檔案", + "options": { + "embedded": { + "title": "使用內嵌字幕", + "description": "影片檔案包含字幕軌道,可直接匯入", + "action": "選擇" + }, + "external": { + "title": "匯入外掛字幕", + "description": "從本機檔案匯入 SRT、VTT 等格式字幕" + }, + "ai": { + "title": "AI 生成字幕", + "description": "基於語音辨識生成單字級字幕", + "action": "即將推出" + } + } + }, + "search": { + "count": "找到 {{count}} 筆字幕", + "count_one": "找到 {{count}} 筆字幕", + "count_other": "找到 {{count}} 筆字幕", + "emptySubtitle": "請嘗試其他關鍵字", + "emptyTitle": "找不到符合的結果", + "none": "未找到符合的字幕", + "pending": "搜尋中...", + "placeholder": "搜尋字幕..." + } + }, + "subtitleTrackSelector": { + "title": "匯入內嵌字幕軌道", + "empty": "未偵測到字幕軌道", + "sections": { + "text": "文字字幕軌道", + "image": "PGS 字幕軌(圖像字幕)" + }, + "stream": { + "label": "Stream {{index}}", + "tags": { + "default": "預設", + "forced": "強制", + "unsupported": "暫不支援" + } + }, + "warning": { + "pgs": "PGS 是圖像格式字幕,需要 OCR 技術支援,暫不支援匯入。" + }, + "actions": { + "cancel": "取消", + "import": "匯入" + }, + "messages": { + "selectAtLeastOne": "請選擇至少一個字幕軌道", + "extractFailed": "擷取字幕軌道 {{index}} 失敗", + "importFailed": "字幕軌道擷取失敗,請重試", + "importSuccess": "已匯入字幕:{{source}}(共 {{count}} 條)", + "importMultipleSuccess": "已匯入 {{tracks}} 個字幕軌道(共 {{count}} 條)" + } + } + }, "settings": { "about": { "updateError": "更新檢查失敗" @@ -58,9 +122,9 @@ "show_settings": "打開設定", "single_loop": "循環播放", "title": "快捷鍵", + "toggle_auto_pause": "切換自動暫停", "toggle_fullscreen": "切換全屏", "toggle_new_context": "清除上下文", - "toggle_auto_pause": "切換自動暫停", "toggle_show_assistants": "切換助手顯示", "toggle_show_topics": "切換話題顯示", "toggle_subtitle_panel": "切換字幕面板", @@ -95,23 +159,5 @@ "show_mini_window": "快速助手", "show_window": "顯示視窗" } - }, - "player": { - "subtitleList": { - "empty": { - "title": "在影片檔同目錄下未找到符合的字幕檔案", - "description": "您可以點擊下方按鈕選擇字幕檔,或將字幕檔拖曳到此區域" - }, - "search": { - "placeholder": "搜尋字幕...", - "pending": "搜尋中...", - "count": "找到 {{count}} 筆字幕", - "count_one": "找到 {{count}} 筆字幕", - "count_other": "找到 {{count}} 筆字幕", - "none": "未找到符合的字幕", - "emptyTitle": "找不到符合的結果", - "emptySubtitle": "請嘗試其他關鍵字" - } - } } } diff --git a/src/renderer/src/infrastructure/types/subtitle.ts b/src/renderer/src/infrastructure/types/subtitle.ts index 0d9720a6..be82280d 100644 --- a/src/renderer/src/infrastructure/types/subtitle.ts +++ b/src/renderer/src/infrastructure/types/subtitle.ts @@ -113,3 +113,34 @@ export interface SubtitleNavigationState { readonly canJumpToNext: boolean readonly canJumpToPrev: boolean } + +// 字幕轨道编码类型 / Subtitle Stream Codec Type +export type SubtitleCodecType = + | 'subrip' + | 'ass' + | 'ssa' + | 'pgs' + | 'dvb_subtitle' + | 'webvtt' + | 'mov_text' + | string + +// 字幕轨道信息接口 / Subtitle Stream Info Interface +export interface SubtitleStream { + readonly index: number // 流索引 (0, 1, 2...) + readonly streamId: string // 流标识 (0:6, 0:7...) + readonly codec: SubtitleCodecType // 编码格式 + readonly language?: string // 语言代码 (zh, en, ja...) + readonly title?: string // 轨道标题 + readonly isDefault?: boolean // 是否为默认轨道 + readonly isForced?: boolean // 是否为强制字幕 + readonly isPGS?: boolean // 是否为 PGS 图像字幕 +} + +// 字幕轨道列表响应 / Subtitle Streams Response +export interface SubtitleStreamsResponse { + readonly videoPath: string + readonly streams: SubtitleStream[] + readonly textStreams: SubtitleStream[] // 文本字幕轨 + readonly imageStreams: SubtitleStream[] // 图像字幕轨 (PGS) +} diff --git a/src/renderer/src/pages/player/PlayerPage.tsx b/src/renderer/src/pages/player/PlayerPage.tsx index f621fe30..47a14563 100644 --- a/src/renderer/src/pages/player/PlayerPage.tsx +++ b/src/renderer/src/pages/player/PlayerPage.tsx @@ -25,6 +25,7 @@ import { FONT_SIZES, SPACING } from '@renderer/infrastructure/styles/theme' +import type { SubtitleStreamsResponse } from '@types' import { ArrowLeft, PanelRightClose, PanelRightOpen, Search } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -38,6 +39,7 @@ import { ProgressBar, SettingsPopover, SubtitleListPanel, + SubtitleTrackSelector, VideoErrorRecovery } from './components' import { disposeGlobalOrchestrator } from './hooks/usePlayerEngine' @@ -63,23 +65,15 @@ interface VideoData { } /** - * Player page component that loads a video by ID from the route and renders the video player UI. + * Render the player page for the video identified by the current route and manage its loading, + * transcoding session, subtitle detection, and related UI state. * - * This component: - * - Reads the `id` route parameter and loads the corresponding video record and file from the database. - * - Constructs a file:// URL as the video source, stores per-page VideoData in local state and synchronizes it to the global per-video session store. - * - Renders a top navbar with a back button and title, a two-pane Splitter layout with the video surface and controls on the left and a subtitle list on the right, and a settings popover. - * - Shows a centered loading view while fetching data and an error view with a back button if loading fails or the video is missing. - * - Cleans up the per-video session state on unmount. + * Loads the video record and associated file, determines an appropriate playback source (original + * file or HLS from a transcoding session), synchronizes per-video state with global stores, + * detects embedded subtitle streams from the original file, and performs cleanup (including + * deleting any created transcoding session) when the component unmounts. * - * Side effects: - * - Performs async data fetches from the VideoLibraryService and the app database. - * - Updates a global player session store with the loaded VideoData and clears it when the component unmounts. - * - * Error handling: - * - Loading or playback errors set local error state and cause the component to render the error view. - * - * Note: This is a React component (returns JSX) and does not accept props; it derives the target video ID from route params. + * @returns The React element for the player page for the requested video */ function PlayerPage() { const navigate = useNavigate() @@ -111,10 +105,15 @@ function PlayerPage() { stage: string status: string } | null>(null) + const [subtitleStreams, setSubtitleStreams] = useState(null) + const [showSubtitleTrackSelector, setShowSubtitleTrackSelector] = useState(false) + const [userDismissedEmbeddedSubtitles, setUserDismissedEmbeddedSubtitles] = useState(false) // const { pokeInteraction } = usePlayerUI() // 保存转码会话 ID 用于清理 const sessionIdRef = useRef(null) + // 保存原始文件路径用于字幕检测(不是 HLS 播放源) + const originalFilePathRef = useRef(null) // 加载视频数据 useEffect(() => { @@ -206,6 +205,9 @@ function PlayerPage() { logger.info(`从数据库加载视频文件:`, { file }) + // 保存原始文件路径用于字幕检测 + originalFilePathRef.current = file.path + // 将 path 转为 file:// URL (Windows-safe) const fileUrl = toFileUrl(file.path) @@ -514,6 +516,50 @@ function PlayerPage() { } }, []) + // 检测字幕轨道 + useEffect(() => { + if (!videoData || !originalFilePathRef.current || userDismissedEmbeddedSubtitles) { + return + } + + const detectSubtitleStreams = async () => { + try { + // 使用原始文件路径检测字幕,而不是 HLS 播放源 + const detectionPath = originalFilePathRef.current + logger.info('🔍 开始检测字幕轨道', { + detectionPath, + playSource: videoData.src + }) + + const result = await window.electron.ipcRenderer.invoke( + IpcChannel.Media_GetSubtitleStreams, + detectionPath + ) + + if (result && result.streams && result.streams.length > 0) { + logger.info('✅ 检测到字幕轨道', { + total: result.streams.length, + text: result.textStreams?.length || 0, + image: result.imageStreams?.length || 0 + }) + + setSubtitleStreams(result) + } else { + logger.info('📄 此视频文件不含字幕轨道', { + path: detectionPath, + videoId + }) + } + } catch (error) { + logger.warn('检测字幕轨道失败', { + error: error instanceof Error ? error.message : String(error) + }) + } + } + + detectSubtitleStreams() + }, [videoData, userDismissedEmbeddedSubtitles, showMediaServerPrompt, videoId]) + // 键盘事件处理 useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -651,7 +697,12 @@ function PlayerPage() { }} > - + 0 + } + onOpenEmbeddedSubtitleSelector={() => setShowSubtitleTrackSelector(true)} + /> @@ -677,6 +728,16 @@ function PlayerPage() { open={showMediaServerPrompt} onClose={() => setShowMediaServerPrompt(false)} /> + + {/* 字幕轨道选择对话框 */} + setShowSubtitleTrackSelector(false)} + onImported={() => setShowSubtitleTrackSelector(false)} + onDismiss={() => setUserDismissedEmbeddedSubtitles(true)} + /> ) diff --git a/src/renderer/src/pages/player/components/SubtitleListPanel.tsx b/src/renderer/src/pages/player/components/SubtitleListPanel.tsx index fd06a32b..2edc0c99 100644 --- a/src/renderer/src/pages/player/components/SubtitleListPanel.tsx +++ b/src/renderer/src/pages/player/components/SubtitleListPanel.tsx @@ -2,7 +2,7 @@ import { BORDER_RADIUS, SPACING } from '@renderer/infrastructure/styles/theme' import { usePlayerUIStore } from '@renderer/state/stores/player-ui.store' import type { SubtitleItem } from '@types' import { Button } from 'antd' -import { Loader2, Search, X } from 'lucide-react' +import { FileText, Loader2, Search, Sparkles, Video, X } from 'lucide-react' import { ReactNode, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Virtuoso, VirtuosoHandle } from 'react-virtuoso' @@ -33,6 +33,10 @@ interface SubtitleListPannelProps { emptyDescription?: string /** 空状态操作按钮(可选) */ emptyActions?: EmptyAction[] + /** 是否有内置字幕 */ + hasEmbeddedSubtitles?: boolean + /** 打开内置字幕选择对话框 */ + onOpenEmbeddedSubtitleSelector?: () => void } type SubtitleSearchResult = { @@ -40,10 +44,23 @@ type SubtitleSearchResult = { index: number } +/** + * Render the subtitle list panel with search, virtualized list, and empty-state actions. + * + * Renders either an empty-state view with import/embedded/AI options and legacy actions when no subtitles are available, + * or a searchable, virtualized list of subtitles with time formatting, range-based scrolling state management, and item selection. + * + * @param emptyDescription - Optional custom description to display in the empty-state header. + * @param emptyActions - Optional list of custom action buttons to show in the empty-state legacy actions row. + * @param hasEmbeddedSubtitles - When true, show the embedded-subtitle option card in the empty state. + * @param onOpenEmbeddedSubtitleSelector - Callback invoked when the embedded-subtitle option's "select" button is clicked. + * @returns The subtitle list panel element. + */ function SubtitleListPanel({ - emptyTitle, emptyDescription, - emptyActions + emptyActions, + hasEmbeddedSubtitles, + onOpenEmbeddedSubtitleSelector }: SubtitleListPannelProps) { const subtitles = useSubtitles() usePlayerEngine() @@ -187,12 +204,63 @@ function SubtitleListPanel({ return ( - {emptyTitle ?? t('player.subtitleList.empty.title')} - - {emptyDescription ?? t('player.subtitleList.empty.description')} - + + + {emptyDescription ?? t('player.subtitleList.empty.description')} + + + + + {/* 内置字幕选项 */} + {hasEmbeddedSubtitles && onOpenEmbeddedSubtitleSelector && ( + + + + + {t('player.subtitleList.empty.options.embedded.title')} + + {t('player.subtitleList.empty.options.embedded.description')} + + + + + )} + + {/* 外挂字幕选项 */} + + + + + + {t('player.subtitleList.empty.options.external.title')} + + {t('player.subtitleList.empty.options.external.description')} + + + + + + {/* AI 生成选项 */} + + + + + + {t('player.subtitleList.empty.options.ai.title')} + + {t('player.subtitleList.empty.options.ai.description')} + + + + + + + {/* 保留旧的自定义操作按钮(如果有) */} {emptyActions && emptyActions.length > 0 && ( - + {emptyActions.map((action, idx) => ( ))} - + )} - {/* 直接在空态区域提供导入字幕按钮 */} - - - ) @@ -476,40 +540,112 @@ const EmptyState = styled.div` display: flex; flex-direction: column; align-items: center; - justify-content: center; + justify-content: flex-start; + padding: ${SPACING.XL}px ${SPACING.MD}px; + overflow-y: auto; + overflow-x: hidden; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--ant-color-border, #2a2a2a); + border-radius: 3px; + } +` + +const EmptyHeader = styled.div` text-align: center; - gap: 8px; - padding: 24px; + margin-bottom: ${SPACING.XL}px; ` -const PrimaryText = styled.div` - margin-top: 6px; - font-size: 16px; +const EmptyTitle = styled.div` + font-size: 18px; font-weight: 600; - color: var(--color-text-1, #ddd); + color: var(--ant-color-text, #ddd); + margin-bottom: ${SPACING.XS}px; ` -const SecondaryText = styled.div` - margin-top: 2px; - font-size: 13px; - color: var(--color-text-3, #666); - max-width: 460px; - line-height: 1.6; +const OptionsGrid = styled.div` + display: flex; + flex-direction: column; + gap: ${SPACING.MD}px; + width: 100%; + max-width: 480px; ` -const ActionsRow = styled.div` - margin-top: 12px; +const OptionCard = styled.div<{ $disabled?: boolean }>` + display: flex; + flex-direction: column; + gap: ${SPACING.SM}px; + padding: ${SPACING.MD}px; + background: var(--ant-color-bg-elevated, rgba(255, 255, 255, 0.04)); + border: 1px solid var(--ant-color-border, rgba(255, 255, 255, 0.08)); + border-radius: ${BORDER_RADIUS.LG}px; + transition: all 0.2s ease; + opacity: ${(p) => (p.$disabled ? 0.6 : 1)}; + cursor: ${(p) => (p.$disabled ? 'not-allowed' : 'default')}; + + &:hover { + ${(p) => + !p.$disabled && + ` + background: var(--ant-color-bg-container, rgba(255, 255, 255, 0.06)); + border-color: var(--ant-color-border-secondary, rgba(255, 255, 255, 0.12)); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + `} + } +` + +const OptionIconWrapper = styled.div<{ $color: string }>` + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: ${BORDER_RADIUS.BASE}px; + background: ${(p) => p.$color}20; + color: ${(p) => p.$color}; + flex-shrink: 0; +` + +const OptionContent = styled.div` + flex: 1; + display: flex; + flex-direction: column; + gap: ${SPACING.XXS}px; +` + +const OptionTitle = styled.div` + font-size: 14px; + font-weight: 600; + color: var(--ant-color-text, #ddd); +` + +const OptionDescription = styled.div` + font-size: 12px; + color: var(--ant-color-text-tertiary, #666); + line-height: 1.5; +` + +const LegacyActionsRow = styled.div` + margin-top: ${SPACING.LG}px; display: flex; align-items: center; justify-content: center; - gap: 10px; + gap: ${SPACING.SM}px; flex-wrap: wrap; ` const ActionButton = styled(Button)` height: 40px; - padding: 0 16px; - border-radius: 12px; + padding: 0 ${SPACING.MD}px; + border-radius: ${BORDER_RADIUS.LG}px; ` const SubtitleItem = styled.div<{ $active: boolean }>` diff --git a/src/renderer/src/pages/player/components/SubtitleTrackSelector.tsx b/src/renderer/src/pages/player/components/SubtitleTrackSelector.tsx new file mode 100644 index 00000000..a55a01ed --- /dev/null +++ b/src/renderer/src/pages/player/components/SubtitleTrackSelector.tsx @@ -0,0 +1,345 @@ +import { loggerService } from '@logger' +import { + BORDER_RADIUS, + FONT_SIZES, + FONT_WEIGHTS, + SPACING +} from '@renderer/infrastructure/styles/theme' +import { SubtitleLibraryService } from '@renderer/services/SubtitleLibrary' +import { SubtitleReader } from '@renderer/services/subtitles/SubtitleReader' +import { usePlayerSubtitlesStore } from '@renderer/state/stores/player-subtitles.store' +import { IpcChannel } from '@shared/IpcChannel' +import type { SubtitleStream, SubtitleStreamsResponse } from '@types' +import { Divider, Empty, message, Modal, Spin, Tag } from 'antd' +import { AlertCircle } from 'lucide-react' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { useCurrentVideo } from '../state/player-context' + +const logger = loggerService.withContext('SubtitleTrackSelector') + +interface SubtitleTrackSelectorProps { + visible: boolean + streams: SubtitleStreamsResponse | null + originalFilePath?: string + onClose: () => void + onImported?: () => void + onDismiss?: () => void +} + +const SubtitleTrackSelector: React.FC = ({ + visible, + streams, + originalFilePath, + onClose, + onImported, + onDismiss +}) => { + const [isLoading, setIsLoading] = useState(false) + const { t } = useTranslation() + + const currentVideoId = useCurrentVideo()?.id + const setSubtitles = usePlayerSubtitlesStore((s) => s.setSubtitles) + const setSource = usePlayerSubtitlesStore((s) => s.setSource) + + // 分离文本和图像字幕轨道 + const { textStreams, imageStreams } = useMemo(() => { + if (!streams) { + return { textStreams: [], imageStreams: [] } + } + return { + textStreams: streams.textStreams || [], + imageStreams: streams.imageStreams || [] + } + }, [streams]) + + // 处理导入 + const handleImport = useCallback( + async (streamIndex: number) => { + setIsLoading(true) + try { + const stream = streams?.streams.find((s) => s.index === streamIndex) + if (!stream) { + message.error(t('player.subtitleTrackSelector.messages.importFailed')) + return + } + + // 调用主进程提取字幕(使用原始文件路径,而不是 HLS 播放源) + const result = await window.electron.ipcRenderer.invoke(IpcChannel.Media_ExtractSubtitle, { + videoPath: originalFilePath || streams?.videoPath, + streamIndex: stream.index, + subtitleCodec: stream.codec + }) + + if (result.success && result.outputPath) { + // 读取提取的字幕文件 + const reader = SubtitleReader.create('SubtitleTrackSelector') + const items = await reader.readFromFile(result.outputPath) + + if (items && items.length > 0) { + const source = + stream.title || + stream.language || + t('player.subtitleTrackSelector.stream.label', { index: stream.index }) + + setSubtitles(items) + setSource({ type: 'embedded', name: source }) + + // 写入字幕库记录,包含解析后的字幕数据 + if (currentVideoId) { + try { + const svc = new SubtitleLibraryService() + await svc.addRecordWithSubtitles({ + videoId: currentVideoId, + filePath: result.outputPath, + subtitles: items + }) + logger.info('字幕数据已缓存到数据库', { count: items.length }) + } catch (e) { + logger.warn('写入字幕库记录失败(不影响本次使用)', { error: e }) + } + } else { + logger.warn('当前没有视频ID,无法持久化字幕数据到数据库') + } + + message.success( + t('player.subtitleTrackSelector.messages.importSuccess', { + source, + count: items.length + }) + ) + + onImported?.() + onClose() + } else { + message.error(t('player.subtitleTrackSelector.messages.importFailed')) + } + } else { + message.error(t('player.subtitleTrackSelector.messages.importFailed')) + } + } catch (error) { + const msg = + error instanceof Error + ? error.message + : t('player.subtitleTrackSelector.messages.importFailed') + message.error(msg) + logger.error('Import subtitle failed', { error }) + } finally { + setIsLoading(false) + } + }, + [streams, originalFilePath, currentVideoId, setSubtitles, setSource, onClose, onImported, t] + ) + + // 处理关闭 + const handleClose = useCallback(() => { + onDismiss?.() + onClose() + }, [onClose, onDismiss]) + + // 渲染字幕轨道项 + const renderStreamItem = (stream: SubtitleStream, disabled: boolean = false) => { + const label = + stream.title || + stream.language || + t('player.subtitleTrackSelector.stream.label', { index: stream.index }) + const codec = stream.codec.toUpperCase() + + const handleImportClick = async (e: React.MouseEvent) => { + e.stopPropagation() + if (disabled) return + + await handleImport(stream.index) + } + + return ( + + + {label} + {stream.isDefault && ( + {t('player.subtitleTrackSelector.stream.tags.default')} + )} + {stream.isForced && ( + {t('player.subtitleTrackSelector.stream.tags.forced')} + )} + + {codec} + {disabled ? ( + + {t('player.subtitleTrackSelector.stream.tags.unsupported')} + + ) : ( + + {t('player.subtitleTrackSelector.actions.import')} + + )} + + ) + } + + return ( + + + {!streams || streams.streams.length === 0 ? ( + + ) : ( + + {/* 文本字幕轨道 */} + {textStreams.length > 0 && ( +
+ {t('player.subtitleTrackSelector.sections.text')} + + {textStreams.map((stream) => renderStreamItem(stream, false))} + +
+ )} + + {/* 分隔线 */} + {textStreams.length > 0 && imageStreams.length > 0 && } + + {/* PGS 图像字幕轨道 */} + {imageStreams.length > 0 && ( +
+ + + {t('player.subtitleTrackSelector.sections.image')} + + {t('player.subtitleTrackSelector.warning.pgs')} + + {imageStreams.map((stream) => renderStreamItem(stream, true))} + +
+ )} +
+ )} +
+
+ ) +} + +export default SubtitleTrackSelector + +// Styled Components +const Container = styled.div` + padding: 12px 0; +` + +const Section = styled.div` + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } +` + +const SectionTitle = styled.div` + font-size: 14px; + font-weight: 600; + color: var(--color-text-1); + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--color-border); +` + +const SectionTitleWithWarning = styled.div` + font-size: 14px; + font-weight: 600; + color: var(--color-text-1); + margin-bottom: 8px; + display: flex; + align-items: center; +` + +const WarningText = styled.div` + font-size: 12px; + color: var(--color-text-3); + margin-bottom: 12px; + padding: 8px 12px; + background: rgba(250, 173, 20, 0.1); + border-left: 3px solid #faad14; + border-radius: 4px; +` + +const StreamsList = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +` + +const StreamItemContainer = styled.div<{ disabled?: boolean }>` + position: relative; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: 6px; + background: var(--color-bg-2); + border: 1px solid var(--color-border); + cursor: ${(props) => (props.disabled ? 'not-allowed' : 'pointer')}; + opacity: ${(props) => (props.disabled ? 0.6 : 1)}; + transition: all 0.2s ease; + + &:hover { + background: ${(props) => (props.disabled ? 'var(--color-bg-2)' : 'var(--color-bg-3)')}; + border-color: ${(props) => (props.disabled ? 'var(--color-border)' : 'var(--color-primary)')}; + } +` + +const StreamLabel = styled.div<{ disabled?: boolean }>` + flex: 1; + font-size: 13px; + color: ${(props) => (props.disabled ? 'var(--color-text-3)' : 'var(--color-text-1)')}; + display: flex; + align-items: center; + gap: 8px; +` + +const CodecTag = styled(Tag)` + margin: 0; + font-size: 11px; +` + +const UnsupportedTag = styled(Tag)` + margin: 0; + background: #faad14; + color: #fff; + border-color: #faad14; + font-size: 11px; +` + +const ImportButton = styled.button` + position: absolute; + right: 12px; + padding: ${SPACING.XS}px ${SPACING.MD}px; + background: var(--ant-color-primary); + border: 1px solid var(--ant-color-primary); + border-radius: ${BORDER_RADIUS.SM}px; + color: white; + font-size: ${FONT_SIZES.SM}px; + font-weight: ${FONT_WEIGHTS.MEDIUM}; + cursor: pointer; + transition: all 0.2s ease; + opacity: 0; + pointer-events: none; + + ${StreamItemContainer}:hover & { + opacity: 1; + pointer-events: auto; + } + + &:hover { + background: var(--ant-color-primary-hover); + border-color: var(--ant-color-primary-hover); + transform: translateY(-1px); + } +` diff --git a/src/renderer/src/pages/player/components/index.ts b/src/renderer/src/pages/player/components/index.ts index 5b9f5ecc..69f2e07a 100644 --- a/src/renderer/src/pages/player/components/index.ts +++ b/src/renderer/src/pages/player/components/index.ts @@ -9,6 +9,7 @@ export { default as SettingsPopover } from './SettingsPopover' export { default as SubtitleContent } from './SubtitleContent' export { default as SubtitleListPanel } from './SubtitleListPanel' export { default as SubtitleOverlay } from './SubtitleOverlay' +export { default as SubtitleTrackSelector } from './SubtitleTrackSelector' export { default as VideoErrorRecovery } from './VideoErrorRecovery' export { default as VideoSurface } from './VideoSurface' diff --git a/src/renderer/src/pages/player/hooks/useSubtitleOverlay.ts b/src/renderer/src/pages/player/hooks/useSubtitleOverlay.ts index adb8d62a..62d64232 100644 --- a/src/renderer/src/pages/player/hooks/useSubtitleOverlay.ts +++ b/src/renderer/src/pages/player/hooks/useSubtitleOverlay.ts @@ -60,42 +60,22 @@ export function useSubtitleOverlay(): SubtitleOverlay { } }, [currentSubtitle, currentIndex]) - // === 缓存当前字幕数据以防止不必要的重新渲染 === - const stableCurrentSubtitle = useMemo(() => { - if (!currentSubtitleData) return null - - // 只有当内容实际变化时才返回新对象 - return { - originalText: currentSubtitleData.originalText, - translatedText: currentSubtitleData.translatedText, - startTime: currentSubtitleData.startTime, - endTime: currentSubtitleData.endTime, - index: currentSubtitleData.index - } - }, [ - currentSubtitleData?.originalText, - currentSubtitleData?.translatedText, - currentSubtitleData?.startTime, - currentSubtitleData?.endTime, - currentSubtitleData?.index - ]) - // === 计算是否应该显示 === const shouldShow = useMemo(() => { // 基础条件:显示模式不为 NONE 且有字幕数据 - if (subtitleOverlayConfig.displayMode === SubtitleDisplayMode.NONE || !stableCurrentSubtitle) { + if (subtitleOverlayConfig.displayMode === SubtitleDisplayMode.NONE || !currentSubtitleData) { return false } // 优先检查:如果当前字幕索引与 engine 提供的索引一致,说明这是权威数据,直接显示 // 这可以避免用户跳转时因时间不同步导致的闪烁 - if (currentIndex >= 0 && stableCurrentSubtitle.index === currentIndex) { + if (currentIndex >= 0 && currentSubtitleData.index === currentIndex) { return true } // 正常的时间边界检查:确保当前播放时间在字幕的时间范围内 const isInTimeRange = - currentTime >= stableCurrentSubtitle.startTime && currentTime <= stableCurrentSubtitle.endTime + currentTime >= currentSubtitleData.startTime && currentTime <= currentSubtitleData.endTime // 如果在时间范围内,直接显示 if (isInTimeRange) { @@ -104,33 +84,33 @@ export function useSubtitleOverlay(): SubtitleOverlay { // 智能容差机制:处理播放时的短暂时间不同步问题 // 如果当前时间接近字幕开始时间,也应该显示(防止跳转闪烁) - const timeDiffToStart = Math.abs(currentTime - stableCurrentSubtitle.startTime) + const timeDiffToStart = Math.abs(currentTime - currentSubtitleData.startTime) const isNearStart = timeDiffToStart <= 2.0 // 2秒容差,处理跳转延迟 return isNearStart - }, [subtitleOverlayConfig.displayMode, stableCurrentSubtitle, currentTime, currentIndex]) + }, [subtitleOverlayConfig.displayMode, currentSubtitleData, currentTime, currentIndex]) // === 计算显示文本 === const displayText = useMemo(() => { - if (!stableCurrentSubtitle || !shouldShow || !subtitleOverlayConfig) return '' + if (!currentSubtitleData || !shouldShow || !subtitleOverlayConfig) return '' switch (subtitleOverlayConfig.displayMode) { case SubtitleDisplayMode.ORIGINAL: - return stableCurrentSubtitle.originalText + return currentSubtitleData.originalText case SubtitleDisplayMode.TRANSLATED: - return stableCurrentSubtitle.translatedText || stableCurrentSubtitle.originalText + return currentSubtitleData.translatedText || currentSubtitleData.originalText case SubtitleDisplayMode.BILINGUAL: - if (stableCurrentSubtitle.translatedText) { - return `${stableCurrentSubtitle.originalText}\n${stableCurrentSubtitle.translatedText}` + if (currentSubtitleData.translatedText) { + return `${currentSubtitleData.originalText}\n${currentSubtitleData.translatedText}` } - return stableCurrentSubtitle.originalText + return currentSubtitleData.originalText default: return '' } - }, [subtitleOverlayConfig, stableCurrentSubtitle, shouldShow]) + }, [subtitleOverlayConfig, currentSubtitleData, shouldShow]) // === 配置操作的包装器(添加 PlayerStore 同步) === const setDisplayModeWithSync = useCallback( @@ -208,7 +188,7 @@ export function useSubtitleOverlay(): SubtitleOverlay { ) return { - currentSubtitle: stableCurrentSubtitle, + currentSubtitle: currentSubtitleData, shouldShow, displayText, isMaskMode: subtitleOverlayConfig.isMaskMode, diff --git a/src/renderer/src/utils/ParallelVideoProcessor.ts b/src/renderer/src/utils/ParallelVideoProcessor.ts index 88728639..063968a4 100644 --- a/src/renderer/src/utils/ParallelVideoProcessor.ts +++ b/src/renderer/src/utils/ParallelVideoProcessor.ts @@ -5,6 +5,7 @@ import { loggerService } from '@logger' import type { FileMetadata } from '@shared/types/database' +import { PathConverter } from '@shared/utils/PathConverter' import { type FormatAnalysis, MediaFormatStrategy } from './MediaFormatStrategy' @@ -93,7 +94,19 @@ export class ParallelVideoProcessor { try { // 路径转换 - const localPath = await this.convertFileUrlToLocalPathAsync(file.path) + const pathResult = PathConverter.convertToLocalPath(file.path) + + if (!pathResult.isValid) { + return { + isValid: false, + localPath: file.path, + fileExists: false, + fileSize: 0, + error: pathResult.error || '路径转换失败' + } + } + + const localPath = pathResult.localPath // 检查文件存在性 const fileExists = await window.api.fs.checkFileExists(localPath) @@ -230,36 +243,6 @@ export class ParallelVideoProcessor { } } - /** - * 异步路径转换 - */ - private static async convertFileUrlToLocalPathAsync(inputPath: string): Promise { - return new Promise((resolve) => { - // 如果是file://URL,需要转换为本地路径 - if (inputPath.startsWith('file://')) { - try { - const url = new URL(inputPath) - let localPath = decodeURIComponent(url.pathname) - - // Windows路径处理:移除开头的斜杠 - if (process.platform === 'win32' && localPath.startsWith('/')) { - localPath = localPath.substring(1) - } - - resolve(localPath) - } catch (error) { - logger.warn('路径转换失败,使用原路径', { - inputPath, - error: error instanceof Error ? error.message : String(error) - }) - resolve(inputPath) - } - } else { - resolve(inputPath) - } - }) - } - /** * 验证处理上下文的完整性 */