diff --git a/README.md b/README.md
index fcf0516..ccfed25 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,7 @@


+

@@ -19,10 +20,17 @@
- [安装指南](#安装指南)
- [快速开始](#快速开始)
- [使用说明](#使用说明)
+ - [基础功能](#基础功能)
+ - [双语字幕](#双语字幕)
+ - [多格式导出](#多格式导出)
+ - [字幕硬嵌入](#字幕硬嵌入)
+ - [字幕样式美化](#字幕样式美化)
+ - [字幕编辑功能](#字幕编辑功能)
+ - [语言检测优化](#语言检测优化)
- [模型选择](#模型选择)
- [项目结构](#项目结构)
-- [扩展开发](#扩展开发)
- [常见问题](#常见问题)
+- [更新日志](#更新日志)
---
@@ -34,57 +42,78 @@
|------|------|
| **多格式支持** | 支持 MP4、MOV、AVI、MKV、FLV、WMV、WEBM 等视频格式,以及 MP3、WAV、M4A、FLAC 等音频格式 |
| **高精度识别** | 基于 OpenAI Whisper 大模型,支持 99 种语言识别,时间轴精准对齐 |
-| **标准字幕输出** | 生成标准 SRT 格式字幕,支持所有主流播放器 |
+| **标准字幕输出** | 生成标准 SRT / ASS / VTT 格式字幕,支持所有主流播放器 |
| **多语言支持** | 自动检测语言,支持中文、英文、日文、韩文等多国语言 |
| **自动分句断句** | 智能断句、标点符号识别,时间轴自动优化 |
| **批量处理** | 一键处理整个文件夹,支持递归扫描子目录 |
| **模型选择** | 提供 tiny/base/small/medium/large 多种模型,平衡速度与准确率 |
| **纯本地推理** | 完全离线运行,无网络请求,保护隐私安全 |
-| **进度与日志** | 实时进度显示、详细错误日志、批量处理报告 |
+
+### 🚀 增强功能 (v2.0+)
+
+| 功能 | 描述 |
+|------|------|
+| **双语字幕** | 支持自动生成中英双语字幕,可配置源语言、目标语言和显示顺序 |
+| **字幕编辑** | 提供字幕时间轴调整、文本修改、分句合并/拆分,支持撤销/重做 |
+| **多格式导出** | 支持 SRT、ASS、VTT 三种字幕格式导出 |
+| **字幕硬嵌入** | 支持将字幕直接烧录到视频文件中,便于分享和播放 |
+| **样式美化** | 支持自定义字幕字体、颜色、位置、描边样式,生成带样式的ASS字幕 |
+| **语言检测优化** | 优化多语言混合场景的识别效果,支持自动切换语言模型 |
### 🔧 技术亮点
-- **模块化设计**: 代码高度模块化,方便扩展双语字幕、字幕编辑、格式转换等功能
-- **GPU 加速: 自动检测并使用 CUDA 加速,处理速度提升 10-100 倍
-- **智能优化: 自动合并短片段、拆分超长片段,移除静音段落
-- **临时文件管理: 自动清理临时文件,节省磁盘空间
+- **模块化设计**: 代码高度模块化,方便扩展
+- **GPU 加速**: 自动检测并使用 CUDA 加速,处理速度提升 10-100 倍
+- **智能优化**: 自动合并短片段、拆分超长片段,移除静音段落
+- **临时文件管理**: 自动清理临时文件,节省磁盘空间
+- **详细错误日志**: 完善的错误诊断和故障排除建议
---
## 🏗️ 技术架构
```
-┌─────────────────────────────────────────────────────────────────────┐
-│ VideoSubtitleAI │
-├─────────────────────────────────────────────────────────────────────┤
-│ │
-│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
-│ │ 命令行入口 │ │ API 接口 │ │ 批量处理器 │ │
-│ │ main.py │ │ VideoSubtitleAI │ │ BatchProcessor │ │
-│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
-│ │ │ │ │
-│ └─────────────────┼─────────────────┼──────────────────────┘
-│ │ │
-│ ▼ ▼
-│ ┌─────────────────────────────────────────────────────────┐ │
-│ │ 核心处理流程 │ │
-│ ├─────────────┬─────────────┬─────────────┬─────────────┤ │
-│ │ 音频处理 │ 语音识别 │ 字幕生成 │ 进度日志 │ │
-│ │AudioProcessor│SpeechRecognizer│SubtitleGen│ProgressLogger│ │
-│ └──────┬──────┴──────┬──────┴──────┬──────┴──────┘ │
-│ │ │ │ │
-│ ▼ ▼ ▼ │
-│ ┌───────────────────────────────────────────────────────┐ │
-│ │ OpenAI Whisper 模型 │ │
-│ │ tiny / base / small / medium / large / large-v3 │ │
-│ └───────────────────┬───────────────────────────┘ │
-│ │ │
-│ ▼ │
-│ ┌───────────────────────────────────────────────────────┐ │
-│ │ 计算层 (PyTorch) │ │
-│ │ CPU / CUDA (NVIDIA GPU) │ │
-│ └───────────────────────────────────────────────────────┘ │
-└──────────────────────────────────────────────────────────────────┘
+┌─────────────────────────────────────────────────────────────────────────────┐
+│ VideoSubtitleAI v2.0 │
+├─────────────────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
+│ │ 命令行入口 │ │ API 接口 │ │ 批量处理器 │ │
+│ │ main.py │ │VideoSubtitleAI│ │ BatchProcessor │ │
+│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
+│ │ │ │ │
+│ └────────────────────┼────────────────────┼────────────────────────────┘
+│ │ │
+│ ▼ ▼
+│ ┌──────────────────────────────────────────────────────────────────────────┐
+│ │ 增强功能模块 (v2.0+) │
+│ ├──────────────┬──────────────┬──────────────┬──────────────┬─────────────┤
+│ │ 双语字幕翻译 │ 字幕编辑器 │ 多格式导出 │ 字幕硬嵌入 │ 语言检测优化│
+│ │Translator │ Editor │ Exporter │ Embedder │ Detector │
+│ └──────┬──────┴──────┬───────┴──────┬───────┴──────┬───────┴──────┬─────┘
+│ │ │ │ │ │
+│ └─────────────┴─────────────┼─────────────┴─────────────┘
+│ │
+│ ▼
+│ ┌──────────────────────────────────────────────────────────────────────────┐
+│ │ 核心处理流程 │
+│ ├─────────────┬─────────────┬─────────────┬─────────────┬───────────────┤
+│ │ 音频处理 │ 语音识别 │ 字幕生成 │ 进度日志 │ 依赖检查 │
+│ │AudioProcessor│SpeechRecognizer│SubtitleGen │ProgressLogger│DependencyChecker│
+│ └──────┬──────┴──────┬──────┴──────┬──────┴──────┴──────┬───────┘
+│ │ │ │ │
+│ ▼ ▼ ▼ ▼
+│ ┌──────────────────────────────────────────────────────────────────────────┐
+│ │ OpenAI Whisper 模型 / FFmpeg │
+│ │ tiny / base / small / medium / large / large-v2 / large-v3 │
+│ └───────────────────────────┬──────────────────────────────────────────────┘
+│ │
+│ ▼
+│ ┌──────────────────────────────────────────────────────────────────────────┐
+│ │ 计算层 (PyTorch) │
+│ │ CPU / CUDA (NVIDIA GPU) │
+│ └──────────────────────────────────────────────────────────────────────────┘
+└──────────────────────────────────────────────────────────────────────────────┘
```
---
@@ -98,9 +127,9 @@
- **内存**: 至少 8GB+ (推荐 16GB+)
- **GPU** (可选): NVIDIA GPU (支持 CUDA 11.8+)
-### 步骤 1: 安装 FFmpeg
+### 步骤 1: 安装 FFmpeg (必需)
-FFmpeg 用于视频音频处理必须安装:
+FFmpeg 用于视频音频处理,必须安装:
**Windows:**
```powershell
@@ -110,17 +139,21 @@ winget install ffmpeg
# 或者使用 choco
choco install ffmpeg
-# 或者手动下载: https://ffmpeg.org/download.html#build-windows
+# 或者手动下载: https://www.gyan.dev/ffmpeg/builds/
+# 下载 ffmpeg-release-full_build.7z
# 解压后将 bin 目录添加到系统 PATH
```
+> **重要**: 硬嵌入字幕功能需要 **FFmpeg完整版本** (包含 subtitles/ass 滤镜)。
+> 建议从 https://www.gyan.dev/ffmpeg/builds/ 下载 `ffmpeg-release-full_build.7z`
+
**macOS:**
```bash
# 使用 Homebrew
brew install ffmpeg
```
-**Linux (Ubuntu/Debian):
+**Linux (Ubuntu/Debian):**
```bash
sudo apt update
sudo apt install ffmpeg
@@ -183,14 +216,20 @@ pip install tqdm
pip install numpy
```
+### 步骤 5: 检查依赖
+
+```bash
+python main.py --check-deps
+```
+
---
## 🎬 快速开始
-### 处理单个视频
+### 基础用法
```bash
-# 使用默认模型 (base) 处理视频
+# 使用默认模型 (base) 处理视频,生成 SRT 字幕
python main.py -i video.mp4
# 指定输出路径
@@ -203,6 +242,96 @@ python main.py -i video.mp4 -m small
python main.py -i video.mp4 -l zh
```
+### 增强功能用法
+
+#### 1. 双语字幕
+
+```bash
+# 生成中英双语字幕 (中文在上,英文在下)
+python main.py -i video.mp4 --bilingual
+
+# 自定义双语参数
+python main.py -i video.mp4 --bilingual --source-language zh --target-language en --bilingual-order zh_en
+
+# 英文在上,中文在下
+python main.py -i video.mp4 --bilingual --bilingual-order en_zh
+```
+
+#### 2. 多格式导出
+
+```bash
+# 同时导出 SRT、ASS、VTT 三种格式
+python main.py -i video.mp4 --formats srt,ass,vtt
+
+# 仅导出 ASS 格式 (带样式)
+python main.py -i video.mp4 --formats ass
+
+# 查看支持的格式
+python main.py --list-formats
+```
+
+#### 3. 字幕硬嵌入
+
+```bash
+# 将字幕硬嵌入到视频中
+python main.py -i video.mp4 --embed-video
+
+# 使用高质量模式
+python main.py -i video.mp4 --embed-video --embed-quality high
+
+# 使用 GPU 加速 (需要 NVIDIA 显卡和支持 NVENC 的 FFmpeg)
+python main.py -i video.mp4 --embed-video --embed-gpu
+
+# 指定输出视频路径
+python main.py -i video.mp4 --embed-video --embed-output output_with_subs.mp4
+```
+
+#### 4. 字幕样式美化
+
+```bash
+# 使用预设样式
+python main.py -i video.mp4 --style yellow
+
+# 查看所有预设样式
+python main.py --list-styles
+
+# 自定义样式
+python main.py -i video.mp4 --font-name "Microsoft YaHei" --font-size 48 --font-color "#FFFF00"
+
+# 完整自定义样式示例
+python main.py -i video.mp4 \
+ --style chinese_large \
+ --font-name "Microsoft YaHei" \
+ --font-size 56 \
+ --font-color "#FFFFFF" \
+ --outline-color "#000000" \
+ --outline-width 3 \
+ --position bottom \
+ --margin-v 60
+```
+
+#### 5. 完整流程示例
+
+```bash
+# 完整流程:语音识别 + 双语字幕 + ASS样式 + 硬嵌入 + 多格式导出
+python main.py -i video.mp4 \
+ --bilingual \
+ --formats srt,ass \
+ --embed-video \
+ --style chinese_large \
+ --embed-quality high
+```
+
+#### 6. 仅导出模式
+
+```bash
+# 将已有的 SRT 字幕转换为 ASS 和 VTT 格式
+python main.py --export-only -s existing.srt --formats ass,vtt
+
+# 使用自定义样式转换
+python main.py --export-only -s existing.srt --formats ass --style yellow
+```
+
### 批量处理文件夹
```bash
@@ -212,8 +341,8 @@ python main.py -i ./videos -o ./subtitles
# 递归处理子文件夹
python main.py -i ./videos -o ./subtitles -r
-# 使用 large 模型获得最高准确率
-python main.py -i ./videos -m large
+# 批量生成双语字幕并导出多格式
+python main.py -i ./videos --bilingual --formats srt,ass -r
```
### 检测语言
@@ -226,7 +355,7 @@ python main.py -i video.mp4 --detect-language-only
### 查看系统信息
```bash
-python main.py -i dummy --info
+python main.py --check-deps --info
```
---
@@ -235,43 +364,79 @@ python main.py -i dummy --info
### 命令行参数详解
-```bash
-python main.py [参数]
-```
-
-#### 输入输出参数
-| 参数 | 说明 |
-|------|------|
-| `-i, --input` | **必需** 输入文件或文件夹路径 |
-| `-o, --output` | 输出文件或文件夹路径(可选) |
+#### 基础参数
-#### 模型参数
| 参数 | 说明 | 默认值 |
|------|------|--------|
+| `-i, --input` | **必需** 输入文件或文件夹路径 | - |
+| `-o, --output` | 输出文件或文件夹路径(可选) | - |
| `-m, --model` | Whisper 模型大小: tiny, base, small, medium, large, large-v2, large-v3 | base |
-| `--model-dir` | 模型存储目录(可选) | ~/.cache/whisper |
-| `--device` | 运行设备: cpu, cuda | 自动选择 |
+| `-l, --language` | 语言代码: auto (自动检测), zh, en, ja, ko 等 | auto |
+| `--translate` | 翻译为英文(默认是转录原语言) | False |
+| `-r, --recursive` | 递归处理子文件夹 | False |
+| `-v, --verbose` | 输出详细日志 | False |
+| `--check-deps` | 检查依赖并退出 | - |
+| `--info` | 显示系统信息 | - |
+| `--detect-language-only` | 仅检测语言,不生成字幕 | - |
+
+#### 双语字幕参数 (`--bilingual`)
-#### 语言参数
| 参数 | 说明 | 默认值 |
|------|------|--------|
-| `-l, --language` | 语言代码: auto (自动检测), zh (中文), en (英文), ja (日文), ko (韩文) 等 | auto |
-| `--translate` | 翻译为英文(默认是转录原语言) | False |
+| `--bilingual` | 启用双语字幕生成 | False |
+| `--source-language` | 源语言代码 | zh |
+| `--target-language` | 目标语言代码 | en |
+| `--bilingual-order` | 双语显示顺序: zh_en (中文在上), en_zh (英文在上) | zh_en |
+
+> **说明**: 双语字幕功能需要翻译后端才能实现完整翻译。如果没有配置翻译API,程序会:
+> - 中文字幕段:中文行显示原文,英文行留空
+> - 英文字幕段:英文行显示原文,中文行留空
+>
+> 如需完整翻译功能,请配置在线翻译API(百度、有道、DeepL等)或安装本地翻译模型。
+
+#### 字幕导出参数
-#### 批量处理参数
| 参数 | 说明 | 默认值 |
|------|------|--------|
-| `-r, --recursive` | 递归处理子文件夹 | False |
-| `--skip-existing` | 跳过已存在字幕的文件 | True |
-| `--no-skip-existing` | 不跳过已存在字幕的文件 | - |
+| `--formats` | 导出格式列表,逗号分隔: srt, ass, vtt | srt |
+| `--export-only` | 仅导出模式(不重新识别语音) | False |
+| `-s, --subtitle` | 已有字幕文件路径(用于 --export-only 模式) | - |
+| `--video-resolution` | 视频分辨率(用于 ASS 样式) | 1920x1080 |
+| `--list-formats` | 列出所有支持的导出格式 | - |
-#### 其他参数
-| 参数 | 说明 |
-|------|------|
-| `--log-dir` | 日志目录(可选) |
-| `-v, --verbose` | 输出详细日志 |
-| `--detect-language-only` | 仅检测语言,不生成字幕 |
-| `--info` | 显示系统信息 |
+#### 字幕硬嵌入参数 (`--embed-video`)
+
+| 参数 | 说明 | 默认值 |
+|------|------|--------|
+| `--embed-video` | 将字幕硬嵌入到视频中 | False |
+| `--embed-output` | 硬嵌入输出视频路径 | - |
+| `--embed-quality` | 硬嵌入视频质量: fast, normal, high, lossless | high |
+| `--embed-gpu` | 使用 GPU 加速硬嵌入 (NVENC) | False |
+
+#### 字幕样式参数
+
+| 参数 | 说明 | 默认值 |
+|------|------|--------|
+| `--style` | 预设样式名称: default, chinese_large, english, yellow, cyan, top | default |
+| `--font-name` | 字幕字体名称 | Microsoft YaHei |
+| `--font-size` | 字幕字体大小 | 48 |
+| `--font-color` | 字体颜色(十六进制,如: #FFFFFF) | - |
+| `--outline-color` | 描边颜色(十六进制) | - |
+| `--outline-width` | 描边宽度 | 2 |
+| `--position` | 字幕位置: top, middle, bottom | bottom |
+| `--margin-v` | 垂直边距(像素) | 40 |
+| `--list-styles` | 列出所有可用的预设样式 | - |
+
+### 预设样式说明
+
+| 样式名称 | 描述 | 适用场景 |
+|----------|------|----------|
+| `default` | 默认样式:微软雅黑,48号字,白色,底部 | 通用场景 |
+| `chinese_large` | 中文大字体:微软雅黑,48号字,大描边 | 中文视频 |
+| `english` | 英文字体:Arial,28号字 | 英文视频 |
+| `yellow` | 黄色字幕:微软雅黑,48号字,黄色 | 高对比度 |
+| `cyan` | 青色字幕:微软雅黑,48号字,青色 | 高对比度 |
+| `top` | 顶部显示:微软雅黑,40号字,顶部 | 顶部显示 |
### 模型选择指南
@@ -285,8 +450,8 @@ python main.py [参数]
| `large-v2` | 1.5 GB | 1550M | 最慢 | 最高 | ~10 GB | 改进版 large |
| `large-v3` | 1.5 GB | 1550M | 最慢 | 最高 | ~10 GB | 最新版本,推荐 |
-**建议:
-- **大多数场景推荐使用 `small` 或 `medium`
+**建议:**
+- **大多数场景推荐使用 `small` 或 `medium`**
- **中文语音清晰视频** 建议 `small` 或 `medium`
- **低配置电脑** 用 `base` 或 `tiny`
- **高要求场景** 用 `large` 或 `large-v3`
@@ -301,7 +466,6 @@ python main.py [参数]
| 德文 | de | 西班牙文 | es |
| 俄文 | ru | 葡萄牙文 | pt |
| 意大利文 | it | 荷兰文 | nl |
-| 阿拉伯文 | ar | 印地文 | hi |
Whisper 支持 99 种语言,完整列表请参考 [Whisper 官方文档](https://github.com/openai/whisper)
@@ -311,168 +475,93 @@ Whisper 支持 99 种语言,完整列表请参考 [Whisper 官方文档](https
```
VideoSubtitleAI/
-├── __init__.py # 包初始化文件
-├── main.py # 主程序入口,命令行接口
-├── audio_processor.py # 音频处理模块
-├── speech_recognizer.py # 语音识别模块 (Whisper)
-├── subtitle_generator.py # 字幕生成模块 (SRT)
-├── batch_processor.py # 批量处理模块
-├── progress_logger.py # 进度显示和日志模块
-├── requirements.txt # Python 依赖包
-└── README.md # 项目说明文档
+├── __init__.py # 包初始化文件
+├── main.py # 主程序入口,命令行接口
+├── audio_processor.py # 音频处理模块
+├── speech_recognizer.py # 语音识别模块 (Whisper)
+├── subtitle_generator.py # 基础字幕生成模块 (SRT)
+├── batch_processor.py # 批量处理模块
+├── progress_logger.py # 进度显示和日志模块
+├── dependency_checker.py # 依赖检查模块
+│
+├── subtitle_models.py # ✨ 统一数据模型 (v2.0+)
+├── subtitle_translator.py # ✨ 双语字幕翻译模块 (v2.0+)
+├── subtitle_editor.py # ✨ 字幕编辑模块 (v2.0+)
+├── subtitle_exporter.py # ✨ 多格式导出模块 (v2.0+)
+├── subtitle_embedder.py # ✨ 字幕硬嵌入模块 (v2.0+)
+├── language_detector.py # ✨ 语言检测优化模块 (v2.0+)
+├── video_subtitle_enhanced.py # ✨ 增强版主模块 (v2.0+)
+│
+├── test_fixes.py # 修复验证测试脚本
+├── test_modules.py # 模块测试脚本
+├── requirements.txt # Python 依赖包
+└── README.md # 项目说明文档
```
-### 模块说明
-
-#### 1. audio_processor.py - 音频处理模块
-
-**功能**: 从视频提取音频,转换音频格式
-
-**主要类**: `AudioProcessor`
-
-**主要方法**:
-- `extract_audio_from_video()` - 从视频提取音频
-- `convert_audio_format()` - 转换音频格式
-- `process_media_file()` - 统一处理音视频处理接口
-- `get_audio_duration()` - 获取音频时长
-
-#### 2. speech_recognizer.py - 语音识别模块
+### 核心模块说明 (v2.0+)
-**功能**: 基于 Whisper 模型进行语音识别
+#### 1. subtitle_models.py - 统一数据模型
-**主要类**: `SpeechRecognizer`
+定义了所有字幕相关的数据结构:
-**主要方法**:
-- `recognize()` - 执行语音识别
-- `detect_language()` - 仅检测语言
-- `load_model()` - 加载模型
-- `unload_model()` - 卸载模型释放显存
+- **SubtitleSegment**: 字幕段数据类
+ - `id`: 段落ID
+ - `start/end`: 开始/结束时间
+ - `text`: 原始文本
+ - `text_zh/text_en`: 中/英文字幕文本
+ - `is_bilingual`: 是否为双语字幕
+ - `style_name`: 样式名称
-#### 3. subtitle_generator.py - 字幕生成模块
+- **SubtitleStyle**: 字幕样式数据类
+ - 支持 ASS 格式的完整样式定义
+ - 字体、颜色、描边、位置等
-**功能**: 生成 SRT 格式字幕,优化时间轴
+- **SubtitleDocument**: 字幕文档
+ - 统一管理多个字幕段
+ - 样式集合
-**主要类**: `SubtitleGenerator`
+#### 2. subtitle_translator.py - 双语字幕翻译
-**主要方法**:
-- `generate_srt_file()` - 生成 SRT 字幕文件
-- `generate_srt_content()` - 生成 SRT 内容字符串
-- `parse_srt_file()` - 解析 SRT 文件
-- `merge_srt_files()` - 合并多个 SRT 文件
-- `shift_subtitle_timing()` - 调整字幕时间轴
+支持多种翻译后端:
-#### 4. batch_processor.py - 批量处理模块
+- **WhisperTranslator**: 使用 Whisper 内置翻译功能
+- **OnlineAPITranslator**: 在线翻译 API(百度、谷歌、DeepL、有道)
+- **LocalModelTranslator**: 本地翻译模型(Helsinki-NLP OPUS-MT)
+- **DummyTranslator**: 模拟翻译器(用于测试和无翻译后端场景)
-**功能**: 批量处理文件夹中的音视频文件
+#### 3. subtitle_editor.py - 字幕编辑功能
-**主要类**: `BatchProcessor`, `BatchProgress`
+提供完整的字幕编辑能力:
-**主要方法**:
-- `scan_files()` - 扫描文件夹中的音视频文件
-- `process_files()` - 批量处理文件
-- `process_folder()` - 处理整个文件夹
-- `generate_report()` - 生成处理报告
+- **文本编辑**: 修改字幕文本
+- **时间轴调整**: 调整开始/结束时间
+- **分段操作**: 合并/拆分字幕段
+- **查找替换**: 批量替换文本
+- **撤销/重做**: 完整的历史记录栈
-#### 5. progress_logger.py - 进度和日志模块
+#### 4. subtitle_exporter.py - 多格式导出
-**功能**: 进度条显示、日志记录
+支持三种字幕格式:
-**主要类**: `ProgressBar`, `ConsoleLogger`, `ProgressLogger`
+- **SRT**: SubRip 格式 - 最通用,纯文本无样式
+- **ASS**: Advanced SubStation Alpha - 支持丰富的样式定义
+- **VTT**: WebVTT - Web 视频文本轨道,HTML5 原生支持
-**主要方法**:
-- `create_progress_bar()` - 创建进度条
-- `log()` - 记录日志
-- `success()` / `fail()` - 记录成功/失败
-- `print_summary()` - 打印处理摘要
+#### 5. subtitle_embedder.py - 字幕硬嵌入
-#### 6. main.py - 主程序入口
+使用 FFmpeg 将字幕烧录到视频:
-**功能**: 命令行接口,整合所有模块
+- 支持 SRT 和 ASS 字幕
+- 支持 GPU 加速 (NVENC)
+- 多种质量预设
+- 详细的错误诊断和故障排除建议
-**主要类**: `VideoSubtitleAI`
+#### 6. language_detector.py - 语言检测优化
-**主要方法**:
-- `initialize()` - 初始化所有组件
-- `process_single_file()` - 处理单个文件
-- `process_folder()` - 处理文件夹
-- `detect_language()` - 检测语言
-
----
-
-## 🔧 扩展开发
-
-### 架构设计
-
-项目采用模块化设计,各模块职责单一,便于扩展:
-
-```
-VideoSubtitleAI (主类)
- │
- ├── AudioProcessor (音频处理)
- ├── SpeechRecognizer (语音识别)
- ├── SubtitleGenerator (字幕生成)
- ├── BatchProcessor (批量处理)
- └── ProgressLogger (进度日志)
-```
-
-### 扩展示例: 双语字幕
-
-```python
-# 在 subtitle_generator.py 中添加
-
-def generate_bilingual_srt(
- self,
- segments_cn: List[Dict],
- segments_en: List[Dict],
- output_path: str,
-):
- """
- 生成双语字幕
- 第一行: 中文
- 第二行: 英文
- """
- # 实现逻辑...
- pass
-```
-
-### 扩展示例: 字幕编辑
-
-```python
-# 创建新模块 subtitle_editor.py
-
-class SubtitleEditor:
- """字幕编辑器"""
-
- def merge_subtitles(self, srt_files: List[str]):
- """合并字幕"""
- pass
-
- def split_subtitle(self, srt_path: str, split_time: float):
- """分割字幕"""
- pass
-
- def adjust_timing(self, segments: List, offset: float):
- """调整时间轴"""
- pass
-```
-
-### 扩展示例: 更多格式支持
-
-```python
-# 在 subtitle_generator.py 中添加
-
-def convert_to_vtt(self, segments: List[Dict]) -> str:
- """转换为 WebVTT 格式"""
- pass
-
-def convert_to_ass(self, segments: List[Dict]) -> str:
- """转换为 ASS/SSA 格式"""
- pass
-
-def convert_to_smi(self, segments: List[Dict]) -> str:
- """转换为 SMI 格式"""
- pass
-```
+- 基于字符特征的快速语言检测
+- 支持中/英/日/韩/俄/法/德/西/葡/意等语言
+- 多语言混合场景分析
+- 置信度评估
---
@@ -515,27 +604,104 @@ print(torch.cuda.get_device_name(0)) # 显示 GPU 名称
3. 尝试指定语言而不是自动检测
4. 检查视频中是否有清晰的语音
-### Q5: 支持哪些视频格式?
+### Q5: 字幕硬嵌入失败?
+
+**A**: 常见原因和解决方案:
+
+1. **FFmpeg 版本问题**
+ - 确保安装了 **完整版本** 的 FFmpeg
+ - Windows 用户请从 https://www.gyan.dev/ffmpeg/builds/ 下载 `ffmpeg-release-full_build.7z`
-**A**: 只要 FFmpeg 支持的格式都可以:
-- 视频: MP4, MOV, AVI, MKV, FLV, WMV, WebM, MPEG, 3GP 等
-- 音频: MP3, WAV, M4A, FLAC, AAC, OGG, WMA 等
+2. **路径包含特殊字符**
+ - 尝试将视频和字幕文件移动到简单路径(如 `C:\Videos\`)
+ - 避免路径中包含空格、中文或特殊字符
-### Q6: 如何批量处理大量文件?
+3. **字幕格式问题**
+ - 建议使用 ASS 格式字幕,样式支持更完整
+ - 检查字幕文件编码是否为 UTF-8
-**A**: 只需指定文件夹路径即可:
+### Q6: 双语字幕只有一种语言?
+
+**A**: 这是正常行为,说明没有配置翻译后端。
+
+**当前行为**:
+- 中文字幕段:中文行显示原文,英文行留空
+- 英文字幕段:英文行显示原文,中文行留空
+
+**如需完整翻译功能**:
+
+方式一:配置在线翻译 API
+```python
+# 在代码中配置(未来版本将支持配置文件)
+from subtitle_translator import OnlineAPITranslator
+
+translator = OnlineAPITranslator(
+ api_type="baidu",
+ app_id="your_app_id",
+ api_key="your_api_key"
+)
+```
+
+方式二:安装本地翻译模型
```bash
-python main.py -i /path/to/videos -o /path/to/subtitles -r
+pip install transformers torch sentencepiece
+# 程序会自动尝试加载本地模型
```
-`-r` 参数会递归处理所有子文件夹。
+### Q7: ASS 样式不生效?
-### Q7: 生成的字幕在播放器无法播放?
+**A**: 请检查:
+
+1. **播放器是否支持 ASS 样式**
+ - 推荐使用 **MPC-HC**、**PotPlayer**、**VLC** 等支持完整 ASS 样式的播放器
+ - 某些内置播放器(如浏览器、部分手机播放器)可能不支持样式
+
+2. **确认导出格式为 ASS**
+ ```bash
+ python main.py -i video.mp4 --formats ass
+ ```
+
+3. **使用预设样式**
+ ```bash
+ python main.py -i video.mp4 --formats ass --style yellow
+ ```
+
+### Q8: 生成的字幕在播放器无法播放?
**A**: 检查:
1. 字幕文件名与视频文件名相同(除扩展名不同)
2. 字幕文件编码是 UTF-8
-3. 播放器支持 SRT 格式
+3. 播放器支持对应格式(SRT/ASS/VTT)
+
+---
+
+## 📝 更新日志
+
+### v2.0.0 (2024-xx-xx)
+
+**✨ 新增功能**
+- **双语字幕生成**: 支持自动生成中英双语字幕
+- **字幕编辑功能**: 提供字幕时间轴调整、文本修改、分句合并/拆分
+- **多格式导出**: 支持 SRT、ASS、VTT 三种字幕格式导出
+- **字幕硬嵌入**: 支持将字幕直接烧录到视频文件中
+- **样式美化**: 支持自定义字幕字体、颜色、位置、描边样式
+- **语言检测优化**: 优化多语言混合场景的识别效果
+
+**🔧 改进**
+- 完善的错误诊断和故障排除建议
+- 更详细的日志输出
+- 更好的 Windows 路径处理
+- FFmpeg 版本检查和验证
+
+### v1.0.0 (2024-xx-xx)
+
+- 初始版本发布
+- 支持多种音视频格式
+- 基于 Whisper 模型的语音识别
+- 生成标准 SRT 字幕
+- 支持批量处理
+- 支持多种模型大小选择
+- 实时进度显示和日志记录
---
@@ -561,19 +727,7 @@ MIT License
- [OpenAI Whisper](https://github.com/openai/whisper) - 强大的语音识别模型
- [FFmpeg](https://ffmpeg.org/) - 音视频处理工具
- [PyTorch](https://pytorch.org/) - 深度学习框架
-
----
-
-**更新日志**
-
-### v1.0.0 (2024-xx-xx)
-- 初始版本发布
-- 支持多种音视频格式
-- 基于 Whisper 模型的语音识别
-- 生成标准 SRT 字幕
-- 支持批量处理
-- 支持多种模型大小选择
-- 实时进度显示和日志记录
+- [Helsinki-NLP](https://github.com/Helsinki-NLP) - 开源翻译模型
---
diff --git a/README_WEB.md b/README_WEB.md
new file mode 100644
index 0000000..63a3f86
--- /dev/null
+++ b/README_WEB.md
@@ -0,0 +1,583 @@
+# VideoSubtitleAI Web 版
+
+基于 OpenAI Whisper 的全栈 Web 版音视频转字幕工具。
+
+---
+
+## 📋 目录
+
+- [功能特性](#功能特性)
+- [技术栈](#技术栈)
+- [项目结构](#项目结构)
+- [安装指南](#安装指南)
+- [快速启动](#快速启动)
+- [API 文档](#api-文档)
+- [常见问题](#常见问题)
+
+---
+
+## ✨ 功能特性
+
+### 🎯 核心功能
+
+| 功能 | 描述 |
+|------|------|
+| **网页上传** | 拖拽上传 MP4/MP3 等音视频文件,一键生成字幕 |
+| **实时进度** | WebSocket 实时推送处理进度和日志,带进度条显示 |
+| **自动语言检测** | 支持 99 种语言自动检测,可手动指定 |
+| **双语字幕** | 支持中英双语字幕生成 |
+| **在线编辑** | 改文字、调时间轴、合并/拆分句子,支持撤销/重做 |
+| **多格式导出** | 支持 SRT/ASS/VTT 三种格式导出 |
+| **字幕嵌入** | 可将字幕直接硬嵌入视频文件 |
+| **自定义样式** | 字体、颜色、位置、描边样式自定义 |
+| **批量处理** | 支持多文件批量上传处理,实时进度跟踪 |
+
+### 🚀 界面特性
+
+- **简洁现代**: 响应式设计,电脑端完美适配
+- **实时反馈**: WebSocket 实时推送进度和日志
+- **用户友好**: 拖拽上传、一键处理、可视化操作
+
+---
+
+## 🛠️ 技术栈
+
+### 后端
+
+| 技术 | 版本 | 用途 |
+|------|------|------|
+| **Python** | 3.8+ | 核心开发语言 |
+| **FastAPI** | 0.104+ | 现代异步 Web 框架 |
+| **Uvicorn** | 0.24+ | ASGI 服务器 |
+| **Jinja2** | 3.1+ | 模板引擎 |
+| **OpenAI Whisper** | 20231117+ | 核心语音识别模型 |
+| **PyTorch** | 2.0+ | 深度学习框架 (Whisper 依赖) |
+| **FFmpeg** | 6.0+ | 音视频处理工具 |
+
+### 前端
+
+| 技术 | 版本 | 用途 |
+|------|------|------|
+| **HTML5** | - | 页面结构 |
+| **CSS3 (Tailwind CSS)** | 3.3+ | 样式框架 (CDN) |
+| **Vanilla JavaScript** | - | 交互逻辑 |
+| **WebSocket** | - | 实时通信 |
+| **RemixIcon** | 3.5+ | 图标库 (CDN) |
+
+---
+
+## 📁 项目结构
+
+```
+VideoSubtitleAI/
+├── web/ # Web版核心目录
+│ ├── __init__.py # 包初始化
+│ ├── config.py # 配置管理
+│ ├── server.py # FastAPI 主服务器
+│ ├── tasks.py # 任务管理模块
+│ ├── processor.py # 核心处理器
+│ ├── websocket_manager.py # WebSocket 连接管理
+│ │
+│ ├── templates/ # HTML 模板
+│ │ ├── index.html # 主页 (单文件处理)
+│ │ ├── editor.html # 字幕编辑器
+│ │ └── batch.html # 批量处理页面
+│ │
+│ ├── static/ # 静态资源
+│ │ ├── css/
+│ │ │ ├── style.css # 全局样式
+│ │ │ └── editor.css # 编辑器样式
+│ │ │
+│ │ └── js/
+│ │ ├── app.js # 主页逻辑
+│ │ ├── editor.js # 编辑器逻辑
+│ │ └── batch.js # 批量处理逻辑
+│ │
+│ ├── uploads/ # 上传文件目录 (自动创建)
+│ ├── outputs/ # 输出文件目录 (自动创建)
+│ ├── temp/ # 临时文件目录 (自动创建)
+│ └── logs/ # 日志目录 (自动创建)
+│
+├── run_web.py # Web版启动脚本
+├── main.py # 命令行版本入口
+├── requirements.txt # Python 依赖包
+└── README.md # 主文档
+```
+
+---
+
+## 🚀 安装指南
+
+### 系统要求
+
+| 组件 | 要求 |
+|------|------|
+| **操作系统** | Windows 10/11, macOS 10.15+, Linux |
+| **Python** | 3.8 - 3.11 (推荐 3.10) |
+| **内存** | 至少 8GB+ (推荐 16GB+) |
+| **GPU** (可选) | NVIDIA GPU (支持 CUDA 11.8+) |
+| **磁盘空间** | 至少 5GB 可用空间 |
+
+### 步骤 1: 安装 FFmpeg (必需)
+
+FFmpeg 用于视频音频处理,必须安装:
+
+**Windows:**
+```powershell
+# 使用 winget 安装 (推荐)
+winget install Gyan.FFmpeg
+
+# 或者使用 choco
+choco install ffmpeg
+
+# 或者手动下载: https://www.gyan.dev/ffmpeg/builds/
+# 下载 ffmpeg-release-full_build.7z
+# 解压后将 bin 目录添加到系统 PATH
+```
+
+**macOS:**
+```bash
+# 使用 Homebrew
+brew install ffmpeg
+```
+
+**Linux (Ubuntu/Debian):**
+```bash
+sudo apt update
+sudo apt install ffmpeg
+```
+
+验证安装:
+```bash
+ffmpeg -version
+```
+
+### 步骤 2: 创建虚拟环境 (推荐)
+
+```bash
+# 创建虚拟环境
+python -m venv venv
+
+# 激活虚拟环境
+# Windows:
+venv\Scripts\activate
+
+# macOS/Linux:
+source venv/bin/activate
+```
+
+### 步骤 3: 安装 PyTorch
+
+根据你的系统选择合适的 PyTorch 版本:
+
+**GPU 版本 (推荐,速度提升 10-100 倍):**
+```bash
+# CUDA 11.8 (Windows/Linux)
+pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
+
+# CUDA 12.1
+pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
+```
+
+**CPU 版本 (速度较慢):**
+```bash
+pip3 install torch torchvision torchaudio
+```
+
+验证 PyTorch 安装:
+```python
+import torch
+print(torch.__version__)
+print(torch.cuda.is_available()) # GPU 版本返回 True
+```
+
+### 步骤 4: 安装项目依赖
+
+```bash
+pip install -r requirements.txt
+```
+
+这会安装所有必需的依赖:
+- `openai-whisper` - 核心语音识别模型
+- `fastapi` - Web 框架
+- `uvicorn` - ASGI 服务器
+- `jinja2` - 模板引擎
+- `python-multipart` - 文件上传支持
+- `numpy`, `tqdm`, `colorama` 等辅助库
+
+### 步骤 5: 验证安装
+
+运行命令行版本检查依赖:
+```bash
+python main.py --check-deps
+```
+
+---
+
+## 🎬 快速启动
+
+### 方式 1: 使用启动脚本 (推荐)
+
+```bash
+# 激活虚拟环境后
+python run_web.py
+```
+
+### 方式 2: 使用 Uvicorn 命令
+
+```bash
+# 开发模式 (带热重载)
+uvicorn web.server:app --reload --host 0.0.0.0 --port 8000
+
+# 生产模式
+uvicorn web.server:app --host 0.0.0.0 --port 8000 --workers 2
+```
+
+### 访问界面
+
+启动后,在浏览器中访问:
+
+| 地址 | 功能 |
+|------|------|
+| `http://localhost:8000` | 主页 - 单文件处理 |
+| `http://localhost:8000/batch` | 批量处理页面 |
+| `http://localhost:8000/editor?task_id=xxx` | 字幕编辑器 |
+| `http://localhost:8000/docs` | FastAPI 自动文档 (Swagger UI) |
+| `http://localhost:8000/redoc` | FastAPI ReDoc 文档 |
+
+---
+
+## 📖 使用说明
+
+### 1. 单文件处理
+
+1. 打开 `http://localhost:8000`
+2. 点击或拖拽上传音视频文件
+3. 选择处理选项:
+ - **模型选择**: tiny/base/small/medium/large (更大模型更准确但更慢)
+ - **语言**: 自动检测或手动指定
+ - **导出格式**: SRT/ASS/VTT
+ - **双语字幕**: 勾选生成中英双语
+ - **样式设置**: 自定义字体、颜色、位置等
+4. 点击「开始处理」
+5. 实时查看进度和日志
+6. 处理完成后下载文件或进入编辑器
+
+### 2. 字幕编辑
+
+处理完成后,点击「编辑字幕」按钮进入编辑器:
+
+| 功能 | 操作 |
+|------|------|
+| **修改文字** | 双击字幕文本,编辑后点击其他区域完成 |
+| **调整时间** | 直接修改开始/结束时间输入框 |
+| **合并字幕** | 多选连续字幕,点击「合并」按钮 |
+| **拆分字幕** | 点击「拆分」图标,输入拆分时间点 |
+| **删除字幕** | 点击「删除」图标 |
+| **时间移位** | 选中字幕后,点击「提前」或「延后」按钮 |
+| **撤销/重做** | 使用顶部的撤销/重做按钮 |
+| **保存** | 点击「保存」按钮保存修改 |
+| **导出** | 点击「导出」选择格式下载 |
+
+### 3. 批量处理
+
+1. 打开 `http://localhost:8000/batch`
+2. 拖拽多个文件到上传区域
+3. 配置统一的处理选项
+4. 点击「开始批量处理」
+5. 实时查看总体进度和当前处理文件
+6. 处理完成后查看结果列表
+
+---
+
+## 📡 API 文档
+
+### REST API
+
+| 方法 | 路径 | 描述 |
+|------|------|------|
+| `GET` | `/api/config` | 获取配置信息 (模型、语言、格式等) |
+| `POST` | `/api/upload` | 上传单个文件 |
+| `POST` | `/api/upload/batch` | 上传多个文件 (批量) |
+| `POST` | `/api/process/{task_id}` | 启动处理任务 |
+| `GET` | `/api/tasks` | 获取所有任务列表 |
+| `GET` | `/api/tasks/{task_id}` | 获取单个任务详情 |
+| `POST` | `/api/tasks/{task_id}/cancel` | 取消任务 |
+| `DELETE` | `/api/tasks/{task_id}` | 删除任务 |
+| `GET` | `/api/download/{task_id}/{format}` | 下载输出文件 |
+| `POST` | `/api/editor/{task_id}/save` | 保存编辑后的字幕 |
+
+### WebSocket 连接
+
+| 路径 | 描述 |
+|------|------|
+| `ws://localhost:8000/ws/{task_id}` | 任务专属连接 (接收任务进度) |
+| `ws://localhost:8000/ws` | 广播连接 (接收所有任务列表) |
+
+### WebSocket 消息格式
+
+```json
+{
+ "type": "task_progress",
+ "task_id": "uuid-string",
+ "data": {
+ "status": "processing",
+ "progress": {
+ "stage": "recognizing_speech",
+ "stage_name": "语音识别",
+ "progress": 45.5,
+ "message": "正在识别第 10 段...",
+ "logs": ["..."],
+ "total_files": 1,
+ "processed_files": 0
+ }
+ },
+ "timestamp": "2024-01-01T00:00:00.000Z"
+}
+```
+
+**消息类型:**
+- `task_progress` - 任务进度更新
+- `task_status` - 任务状态变更 (完成/失败/取消)
+- `task_log` - 单条日志消息
+- `task_list` - 任务列表广播
+
+---
+
+## 🎨 界面预览
+
+### 主页功能
+
+- **上传区域**: 拖拽上传,支持点击选择
+- **处理选项**:
+ - 模型选择 (tiny/large 等)
+ - 语言选择
+ - 导出格式 (SRT/ASS/VTT)
+ - 双语字幕开关
+ - 样式设置 (展开面板)
+- **进度显示**: 实时进度条、阶段名称、耗时统计
+- **日志面板**: 滚动查看处理日志
+- **结果区域**: 下载按钮、编辑入口
+- **任务历史**: 最近任务列表
+
+### 编辑器功能
+
+- **左侧面板**:
+ - 样式设置 (字体大小、颜色、描边)
+ - 快捷操作 (合并/拆分/移位)
+ - 统计信息 (段数、字数、时长)
+- **右侧区域**:
+ - 视频预览区
+ - 时间轴控制
+ - 字幕列表表格
+- **工具栏**:
+ - 撤销/重做
+ - 保存
+ - 导出
+
+### 批量处理功能
+
+- **多文件上传**: 同时选择多个文件
+- **统一配置**: 所有文件使用相同处理参数
+- **总体进度**: 显示已处理/总数
+- **当前文件**: 显示正在处理的文件名
+- **详细日志**: 实时滚动日志
+- **结果面板**: 成功/失败统计,详细列表
+
+---
+
+## ⚙️ 配置说明
+
+主要配置在 `web/config.py` 中:
+
+```python
+@dataclass
+class Settings:
+ # 文件大小限制 (500MB)
+ MAX_FILE_SIZE: int = 500 * 1024 * 1024
+
+ # 最大并发任务数
+ MAX_CONCURRENT_TASKS: int = 2
+
+ # 默认模型
+ DEFAULT_MODEL: str = "base"
+
+ # 可用模型
+ AVAILABLE_MODELS: List[str] = [
+ "tiny", "base", "small", "medium", "large", "large-v2", "large-v3"
+ ]
+
+ # 支持的格式
+ SUPPORTED_AUDIO_FORMATS = [".mp3", ".wav", ".m4a", ".flac", ".aac", ".ogg", ".wma"]
+ SUPPORTED_VIDEO_FORMATS = [".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".webm", ".m4v"]
+```
+
+### 模型选择指南
+
+| 模型 | 大小 | 速度 | 准确率 | VRAM 需求 | 适用场景 |
+|------|------|------|--------|-----------|----------|
+| `tiny` | 39 MB | 最快 | 较低 | ~1 GB | 快速预览 |
+| `base` | 74 MB | 快 | 中 | ~1 GB | 日常使用 |
+| `small` | 244 MB | 中等 | 较高 | ~2 GB | 大多数场景 |
+| `medium` | 769 MB | 较慢 | 高 | ~5 GB | 专业内容 |
+| `large` | 1.5 GB | 最慢 | 最高 | ~10 GB | 最高精度 |
+
+> **提示**: 第一次运行时会自动下载模型,后续运行会快很多。
+
+---
+
+## ❓ 常见问题
+
+### Q1: 第一次运行很慢?
+
+**A**: 第一次运行时会自动下载 Whisper 模型,模型大小从 39MB 到 1.5GB 不等。下载完成后,后续运行会快很多。
+
+你也可以手动下载模型放到 `~/.cache/whisper/` 目录:
+- https://openaipublic.azureedge.net/main/whisper/models/
+
+### Q2: GPU 加速不工作?
+
+**A**: 请检查:
+1. 安装了 CUDA 版本的 PyTorch
+2. NVIDIA 驱动程序是最新的
+3. CUDA 版本与 PyTorch 匹配
+
+验证:
+```python
+import torch
+print(torch.cuda.is_available()) # 应该返回 True
+print(torch.cuda.get_device_name(0)) # 显示 GPU 名称
+```
+
+### Q3: 内存不足?
+
+**A**: 解决方案:
+1. 使用更小的模型 (tiny 或 base)
+2. 关闭其他占用内存的程序
+3. 考虑升级硬件
+
+### Q4: 字幕不准确?
+
+**A**: 提高准确率的方法:
+1. 使用更大的模型 (medium 或 large)
+2. 确保音频质量良好
+3. 尝试指定语言而不是自动检测
+
+### Q5: 字幕硬嵌入失败?
+
+**A**: 常见原因:
+
+1. **FFmpeg 版本问题**
+ - 确保安装了 **完整版本** 的 FFmpeg
+ - Windows 用户请从 https://www.gyan.dev/ffmpeg/builds/ 下载 `ffmpeg-release-full.7z`
+
+2. **字幕格式问题**
+ - 建议使用 ASS 格式字幕,样式支持更完整
+
+### Q6: WebSocket 连接断开?
+
+**A**: WebSocket 会在以下情况断开:
+- 网络问题 (自动重连逻辑可增强)
+- 页面刷新
+- 服务器重启
+
+当前实现会在页面加载时重新获取任务状态。
+
+### Q7: 如何修改监听端口?
+
+**A**: 编辑 `run_web.py` 或使用命令行参数:
+
+```bash
+# 修改启动脚本
+uvicorn web.server:app --host 0.0.0.0 --port 8080
+```
+
+### Q8: 可以在局域网内访问吗?
+
+**A**: 可以。启动时使用 `--host 0.0.0.0`:
+
+```bash
+python run_web.py
+# 或者
+uvicorn web.server:app --host 0.0.0.0 --port 8000
+```
+
+然后其他设备通过 `http://<你的IP>:8000` 访问。
+
+> **注意**: 确保防火墙允许 8000 端口访问。
+
+---
+
+## 🔧 故障排除
+
+### 依赖检查
+
+运行以下命令检查依赖状态:
+```bash
+python main.py --check-deps
+```
+
+### 常见错误
+
+#### ImportError: No module named 'fastapi'
+```bash
+pip install fastapi uvicorn jinja2 python-multipart
+```
+
+#### FFmpegNotFoundError
+确保 FFmpeg 已安装并添加到系统 PATH。
+
+#### CUDA out of memory
+使用更小的模型,或关闭其他占用显存的程序。
+
+#### RuntimeError: CUDA error: device-side assert triggered
+这通常表示 GPU 显存不足,尝试使用 CPU 模式或更小的模型。
+
+---
+
+## 📝 更新日志
+
+### v2.0.0 (2024)
+
+**✨ 新增 Web 版功能**
+- **全栈 Web 应用**: 基于 FastAPI + Jinja2 + 原生 JS
+- **实时进度**: WebSocket 实时推送处理进度和日志
+- **在线编辑器**: 文本编辑、时间轴调整、合并/拆分、撤销/重做
+- **批量处理**: 多文件上传、统一配置、总体进度跟踪
+- **现代界面**: 响应式设计、拖拽上传、进度动画
+
+**🔧 技术亮点**
+- **模块化设计**: 配置、任务、处理、WebSocket 完全分离
+- **异步处理**: FastAPI 异步 + 线程池执行同步任务
+- **状态管理**: 完善的任务状态机 (pending/queued/processing/completed/failed/cancelled)
+- **内存友好**: 并发控制、任务清理、历史记录管理
+
+---
+
+## 📄 许可证
+
+MIT License
+
+---
+
+## 🤝 贡献
+
+欢迎提交 Issue 和 Pull Request!
+
+---
+
+## 🙏 致谢
+
+- [OpenAI Whisper](https://github.com/openai/whisper) - 强大的语音识别模型
+- [FFmpeg](https://ffmpeg.org/) - 音视频处理工具
+- [FastAPI](https://fastapi.tiangolo.com/) - 现代 Web 框架
+- [Tailwind CSS](https://tailwindcss.com/) - 实用优先的 CSS 框架
+- [RemixIcon](https://remixicon.com/) - 开源图标库
+
+---
+
+
+
+**如果这个项目对你有帮助,请给个 ⭐ Star!**
+
+
diff --git a/SeeYouAgain_subtitled.mp4 b/SeeYouAgain_subtitled.mp4
new file mode 100644
index 0000000..6564b4c
Binary files /dev/null and b/SeeYouAgain_subtitled.mp4 differ
diff --git a/audio_processor.py b/audio_processor.py
index 2a013c9..b8a2289 100644
--- a/audio_processor.py
+++ b/audio_processor.py
@@ -1,18 +1,48 @@
"""
音频处理模块
支持从MP4、MOV、AVI等视频文件中提取音频,以及处理MP3、WAV等音频文件
+改进版:增强FFmpeg查找逻辑,提供更友好的错误信息和解决方案
"""
import os
+import sys
import subprocess
import tempfile
+import shutil
from pathlib import Path
-from typing import Optional, Tuple
+from typing import Optional, Tuple, List
import logging
logger = logging.getLogger(__name__)
+class FFmpegNotFoundError(Exception):
+ """FFmpeg未找到错误"""
+
+ def __init__(self, message: str, solutions: List[str] = None):
+ super().__init__(message)
+ self.solutions = solutions or []
+
+ def get_detailed_message(self) -> str:
+ """获取详细的错误信息"""
+ msg = str(self)
+ if self.solutions:
+ msg += "\n\n解决方案:\n"
+ for i, solution in enumerate(self.solutions, 1):
+ msg += f" {i}. {solution}\n"
+ return msg
+
+
+class FFmpegError(Exception):
+ """FFmpeg执行错误"""
+
+ def __init__(self, command: List[str], return_code: int, stderr: str):
+ self.command = command
+ self.return_code = return_code
+ self.stderr = stderr
+ super().__init__(f"FFmpeg执行失败 (返回码: {return_code})")
+
+
class AudioProcessor:
"""
音频处理器类
@@ -20,34 +50,177 @@ class AudioProcessor:
"""
# 支持的视频格式
- SUPPORTED_VIDEO_FORMATS = {'.mp4', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.webm'}
+ SUPPORTED_VIDEO_FORMATS = {'.mp4', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.webm', '.m4v', '.mpeg', '.mpg', '.3gp'}
# 支持的音频格式
- SUPPORTED_AUDIO_FORMATS = {'.mp3', '.wav', '.m4a', '.flac', '.aac', '.ogg', '.wma'}
+ SUPPORTED_AUDIO_FORMATS = {'.mp3', '.wav', '.m4a', '.flac', '.aac', '.ogg', '.wma', '.opus', '.amr'}
+
+ # FFmpeg详细安装指南
+ FFMPEG_INSTALL_GUIDE = {
+ 'windows': [
+ "方法1: 使用 winget 安装 (推荐)",
+ " 以管理员身份打开 PowerShell,运行:",
+ " winget install Gyan.FFmpeg",
+ "",
+ "方法2: 使用 Chocolatey 安装",
+ " choco install ffmpeg",
+ "",
+ "方法3: 手动安装",
+ " 1. 访问 https://www.gyan.dev/ffmpeg/builds/",
+ " 2. 下载 'ffmpeg-release-full.7z'",
+ " 3. 解压到 C:\\ffmpeg",
+ " 4. 将 C:\\ffmpeg\\bin 添加到系统 PATH 环境变量",
+ "",
+ "安装后需要重启命令行/IDE才能生效!"
+ ],
+ 'macos': [
+ "方法1: 使用 Homebrew 安装 (推荐)",
+ " brew install ffmpeg",
+ "",
+ "方法2: 使用 MacPorts 安装",
+ " sudo port install ffmpeg",
+ "",
+ "方法3: 手动安装",
+ " 从 https://evermeet.cx/ffmpeg/ 下载并安装"
+ ],
+ 'linux': [
+ "Ubuntu/Debian:",
+ " sudo apt update",
+ " sudo apt install ffmpeg",
+ "",
+ "Fedora/RHEL:",
+ " sudo dnf install ffmpeg",
+ "",
+ "Arch Linux:",
+ " sudo pacman -S ffmpeg"
+ ]
+ }
- def __init__(self, ffmpeg_path: Optional[str] = None):
+ def __init__(self, ffmpeg_path: Optional[str] = None, auto_download: bool = False):
"""
初始化音频处理器
Args:
ffmpeg_path: FFmpeg可执行文件路径,如为None则尝试从PATH中查找
+ auto_download: 是否自动下载FFmpeg(预留功能)
"""
- self.ffmpeg_path = ffmpeg_path or self._find_ffmpeg()
- if not self.ffmpeg_path:
- raise RuntimeError("未找到FFmpeg,请确保FFmpeg已安装并添加到系统PATH中")
+ self.ffmpeg_path = None
+ self.ffprobe_path = None
+ self._ffmpeg_version = None
+
+ # 尝试查找 FFmpeg
+ if ffmpeg_path:
+ # 用户指定了路径
+ self.ffmpeg_path = self._validate_ffmpeg_path(ffmpeg_path)
+ if not self.ffmpeg_path:
+ raise FFmpegNotFoundError(
+ f"指定的FFmpeg路径无效: {ffmpeg_path}",
+ self._get_install_solutions()
+ )
+ else:
+ # 自动查找
+ self.ffmpeg_path = self._find_ffmpeg()
+ if not self.ffmpeg_path:
+ raise FFmpegNotFoundError(
+ "未找到FFmpeg!FFmpeg是音视频处理必需的工具。",
+ self._get_install_solutions()
+ )
+
+ # 查找 ffprobe
+ self.ffprobe_path = self._find_ffprobe()
+
+ # 验证 FFmpeg 是否可用
+ self._validate_ffmpeg()
+
+ logger.info(f"FFmpeg初始化成功: {self.ffmpeg_path}")
+ logger.info(f"FFmpeg版本: {self._ffmpeg_version}")
+
+ def _get_platform(self) -> str:
+ """
+ 获取当前平台
+
+ Returns:
+ 'windows', 'macos', 或 'linux'
+ """
+ if sys.platform == 'win32':
+ return 'windows'
+ elif sys.platform == 'darwin':
+ return 'macos'
+ else:
+ return 'linux'
+
+ def _get_install_solutions(self) -> List[str]:
+ """
+ 获取安装解决方案列表
+
+ Returns:
+ 解决方案列表
+ """
+ platform = self._get_platform()
+ return self.FFMPEG_INSTALL_GUIDE.get(platform, [])
+
+ def _validate_ffmpeg_path(self, path: str) -> Optional[str]:
+ """
+ 验证 FFmpeg 路径
+
+ Args:
+ path: FFmpeg 可执行文件或目录路径
+
+ Returns:
+ 验证后的完整路径或 None
+ """
+ path = Path(path).resolve()
+
+ if path.is_file():
+ # 是文件路径
+ if path.name.lower() in ['ffmpeg', 'ffmpeg.exe']:
+ if self._test_ffmpeg_executable(str(path)):
+ return str(path)
+ elif path.is_dir():
+ # 是目录路径
+ ffmpeg_exe = 'ffmpeg.exe' if sys.platform == 'win32' else 'ffmpeg'
+ candidate = path / ffmpeg_exe
+ if candidate.exists() and self._test_ffmpeg_executable(str(candidate)):
+ return str(candidate)
+
+ return None
+
+ def _test_ffmpeg_executable(self, path: str) -> bool:
+ """
+ 测试 FFmpeg 可执行文件是否可用
+
+ Args:
+ path: FFmpeg 可执行文件路径
+
+ Returns:
+ 是否可用
+ """
+ try:
+ result = subprocess.run(
+ [path, '-version'],
+ capture_output=True,
+ text=True,
+ timeout=10
+ )
+ return result.returncode == 0
+ except (subprocess.SubprocessError, FileNotFoundError, OSError):
+ return False
def _find_ffmpeg(self) -> Optional[str]:
"""
在系统中查找FFmpeg可执行文件
查找顺序:
- 1. 环境变量 FFMPEG_PATH 或 FFMPEG_BIN
+ 1. 环境变量 FFMPEG_PATH, FFMPEG_BIN, FFMPEG
2. 常见的FFmpeg安装位置
3. 系统PATH环境变量
+ 4. 项目目录下的 ffmpeg 文件夹
Returns:
FFmpeg路径或None
"""
- ffmpeg_exe = 'ffmpeg.exe' if os.name == 'nt' else 'ffmpeg'
+ ffmpeg_exe = 'ffmpeg.exe' if sys.platform == 'win32' else 'ffmpeg'
+
+ logger.info("正在查找FFmpeg...")
# 1. 检查环境变量
env_paths = [
@@ -58,60 +231,225 @@ def _find_ffmpeg(self) -> Optional[str]:
for env_path in env_paths:
if env_path:
- env_path = os.path.expanduser(env_path)
- # 如果是目录,拼接ffmpeg.exe
- if os.path.isdir(env_path):
- candidate = os.path.join(env_path, ffmpeg_exe)
- if os.path.exists(candidate):
- return candidate
- # 如果是文件路径
- elif os.path.exists(env_path):
- return env_path
-
- # 2. 检查常见的FFmpeg安装位置(Windows)
- if os.name == 'nt':
- common_paths = [
+ validated = self._validate_ffmpeg_path(env_path)
+ if validated:
+ logger.info(f"从环境变量找到FFmpeg: {validated}")
+ return validated
+
+ # 2. 检查常见的FFmpeg安装位置
+ common_locations = self._get_common_locations()
+
+ for location in common_locations:
+ if location and os.path.exists(location):
+ validated = self._validate_ffmpeg_path(location)
+ if validated:
+ logger.info(f"从常见位置找到FFmpeg: {validated}")
+ return validated
+
+ # 3. 检查项目目录下的 ffmpeg 文件夹
+ project_ffmpeg = Path(__file__).parent / 'ffmpeg'
+ if project_ffmpeg.exists():
+ validated = self._validate_ffmpeg_path(str(project_ffmpeg))
+ if validated:
+ logger.info(f"从项目目录找到FFmpeg: {validated}")
+ return validated
+
+ # 4. 检查系统PATH环境变量
+ path_dirs = os.environ.get('PATH', '').split(os.pathsep)
+
+ for path_dir in path_dirs:
+ if not path_dir:
+ continue
+ try:
+ path_dir = Path(path_dir).resolve()
+ if path_dir.exists():
+ ffmpeg_file = path_dir / ffmpeg_exe
+ if ffmpeg_file.exists():
+ if self._test_ffmpeg_executable(str(ffmpeg_file)):
+ logger.info(f"从系统PATH找到FFmpeg: {ffmpeg_file}")
+ return str(ffmpeg_file)
+ except Exception:
+ continue
+
+ # 5. 尝试使用 shutil.which
+ which_result = shutil.which('ffmpeg')
+ if which_result:
+ if self._test_ffmpeg_executable(which_result):
+ logger.info(f"从shutil.which找到FFmpeg: {which_result}")
+ return which_result
+
+ logger.warning("未找到FFmpeg")
+ return None
+
+ def _get_common_locations(self) -> List[str]:
+ """
+ 获取常见的FFmpeg安装位置
+
+ Returns:
+ 路径列表
+ """
+ locations = []
+
+ if sys.platform == 'win32':
+ # Windows 常见位置
+ user_profile = os.environ.get('USERPROFILE', '')
+ local_app_data = os.environ.get('LOCALAPPDATA', '')
+ program_files = os.environ.get('PROGRAMFILES', '')
+ program_files_x86 = os.environ.get('PROGRAMFILES(X86)', '')
+
+ locations.extend([
r'C:\ffmpeg\bin',
- r'C:\Program Files\ffmpeg\bin',
- r'C:\Program Files (x86)\ffmpeg\bin',
- os.path.join(os.environ.get('LOCALAPPDATA', ''), 'ffmpeg', 'bin'),
- os.path.join(os.environ.get('USERPROFILE', ''), 'ffmpeg', 'bin'),
- os.path.join(os.environ.get('USERPROFILE', ''), 'scoop', 'shims'),
- os.path.join(os.environ.get('USERPROFILE', ''), 'scoop', 'apps', 'ffmpeg', 'current', 'bin'),
+ r'C:\ffmpeg',
+ os.path.join(program_files, 'ffmpeg', 'bin'),
+ os.path.join(program_files, 'ffmpeg'),
+ os.path.join(program_files_x86, 'ffmpeg', 'bin'),
+ os.path.join(program_files_x86, 'ffmpeg'),
+ os.path.join(local_app_data, 'ffmpeg', 'bin'),
+ os.path.join(local_app_data, 'ffmpeg'),
+ os.path.join(user_profile, 'ffmpeg', 'bin'),
+ os.path.join(user_profile, 'ffmpeg'),
+ os.path.join(user_profile, 'scoop', 'shims'),
+ os.path.join(user_profile, 'scoop', 'apps', 'ffmpeg', 'current', 'bin'),
r'C:\ProgramData\chocolatey\bin',
- ]
-
- for path in common_paths:
- candidate = os.path.join(path, ffmpeg_exe)
- if os.path.exists(candidate):
- return candidate
+ r'C:\tools\ffmpeg\bin',
+ r'C:\tools\ffmpeg',
+ ])
- # 检查 WinGet 安装的 FFmpeg(Gyan.FFmpeg)
- winget_packages = os.path.join(
- os.environ.get('LOCALAPPDATA', ''),
- 'Microsoft', 'WinGet', 'Packages'
- )
+ # WinGet 包位置
+ winget_packages = os.path.join(local_app_data, 'Microsoft', 'WinGet', 'Packages')
if os.path.exists(winget_packages):
try:
- # 查找包含 ffmpeg 的文件夹
for item in os.listdir(winget_packages):
item_path = os.path.join(winget_packages, item)
if os.path.isdir(item_path) and 'ffmpeg' in item.lower():
- # 在该目录下查找 ffmpeg.exe
+ locations.append(item_path)
+ # 也检查子目录
for root, dirs, files in os.walk(item_path):
- if ffmpeg_exe in files:
- return os.path.join(root, ffmpeg_exe)
+ if 'bin' in dirs:
+ locations.append(os.path.join(root, 'bin'))
except Exception:
pass
- # 3. 检查系统PATH环境变量
- for path in os.environ.get('PATH', '').split(os.pathsep):
- ffmpeg_full_path = os.path.join(path, ffmpeg_exe)
- if os.path.exists(ffmpeg_full_path):
- return ffmpeg_full_path
+ elif sys.platform == 'darwin':
+ # macOS 常见位置
+ locations.extend([
+ '/usr/local/bin',
+ '/usr/local/ffmpeg/bin',
+ '/opt/homebrew/bin',
+ '/opt/homebrew/ffmpeg/bin',
+ '/Applications/ffmpeg',
+ os.path.expanduser('~/Applications/ffmpeg'),
+ ])
+
+ else:
+ # Linux 常见位置
+ locations.extend([
+ '/usr/bin',
+ '/usr/local/bin',
+ '/opt/ffmpeg/bin',
+ '/snap/bin',
+ ])
+
+ return locations
+
+ def _find_ffprobe(self) -> Optional[str]:
+ """
+ 查找 ffprobe 工具
+
+ Returns:
+ ffprobe路径或None
+ """
+ if not self.ffmpeg_path:
+ return None
+
+ ffprobe_exe = 'ffprobe.exe' if sys.platform == 'win32' else 'ffprobe'
+
+ # 首先在 FFmpeg 同一目录查找
+ ffmpeg_dir = Path(self.ffmpeg_path).parent
+ ffprobe_candidate = ffmpeg_dir / ffprobe_exe
+
+ if ffprobe_candidate.exists():
+ try:
+ result = subprocess.run(
+ [str(ffprobe_candidate), '-version'],
+ capture_output=True,
+ text=True,
+ timeout=5
+ )
+ if result.returncode == 0:
+ return str(ffprobe_candidate)
+ except Exception:
+ pass
+
+ # 在系统 PATH 中查找
+ which_result = shutil.which('ffprobe')
+ if which_result:
+ return which_result
return None
+ def _validate_ffmpeg(self):
+ """
+ 验证 FFmpeg 是否可用并获取版本信息
+ """
+ try:
+ result = subprocess.run(
+ [self.ffmpeg_path, '-version'],
+ capture_output=True,
+ text=True,
+ timeout=10
+ )
+
+ if result.returncode == 0:
+ # 解析版本信息
+ first_line = result.stdout.split('\n')[0] if result.stdout else ''
+ # 典型输出: "ffmpeg version 6.0-full_build-www.gyan.dev Copyright (c) 2000-2023 the FFmpeg developers"
+ import re
+ version_match = re.search(r'ffmpeg version (\S+)', first_line, re.IGNORECASE)
+ if version_match:
+ self._ffmpeg_version = version_match.group(1)
+ else:
+ self._ffmpeg_version = 'unknown'
+ else:
+ raise FFmpegError(
+ [self.ffmpeg_path, '-version'],
+ result.returncode,
+ result.stderr
+ )
+
+ except subprocess.TimeoutExpired:
+ raise FFmpegNotFoundError(
+ "FFmpeg响应超时",
+ ["请检查FFmpeg是否正确安装", "尝试重新安装FFmpeg"]
+ )
+ except FileNotFoundError as e:
+ raise FFmpegNotFoundError(
+ f"FFmpeg可执行文件不存在: {self.ffmpeg_path}",
+ self._get_install_solutions()
+ ) from e
+ except OSError as e:
+ if 'WinError 2' in str(e) or 'The system cannot find the file specified' in str(e):
+ raise FFmpegNotFoundError(
+ f"无法执行FFmpeg: {e}\n路径: {self.ffmpeg_path}",
+ self._get_install_solutions()
+ ) from e
+ else:
+ raise
+
+ def get_ffmpeg_info(self) -> dict:
+ """
+ 获取 FFmpeg 信息
+
+ Returns:
+ 包含FFmpeg信息的字典
+ """
+ return {
+ 'ffmpeg_path': self.ffmpeg_path,
+ 'ffprobe_path': self.ffprobe_path,
+ 'version': self._ffmpeg_version,
+ 'platform': self._get_platform(),
+ }
+
def is_supported_format(self, file_path: str) -> bool:
"""
检查文件是否为支持的音视频格式
@@ -138,6 +476,71 @@ def is_video_file(self, file_path: str) -> bool:
ext = Path(file_path).suffix.lower()
return ext in self.SUPPORTED_VIDEO_FORMATS
+ def _run_ffmpeg_command(
+ self,
+ cmd: List[str],
+ description: str = "执行FFmpeg命令",
+ timeout: Optional[int] = None,
+ ) -> Tuple[int, str, str]:
+ """
+ 执行 FFmpeg 命令
+
+ Args:
+ cmd: 命令参数列表
+ description: 命令描述(用于错误信息)
+ timeout: 超时时间(秒)
+
+ Returns:
+ (返回码, 标准输出, 标准错误)
+ """
+ cmd = [self.ffmpeg_path] + cmd
+
+ logger.debug(f"执行FFmpeg命令: {' '.join(cmd)}")
+
+ try:
+ # 使用 startupinfo 隐藏 Windows 控制台窗口
+ startupinfo = None
+ if sys.platform == 'win32':
+ startupinfo = subprocess.STARTUPINFO()
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+ startupinfo.wShowWindow = subprocess.SW_HIDE
+
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ startupinfo=startupinfo,
+ )
+
+ if result.returncode != 0:
+ logger.error(f"FFmpeg命令失败: {description}")
+ logger.error(f"命令: {' '.join(cmd)}")
+ logger.error(f"错误输出: {result.stderr}")
+
+ raise FFmpegError(cmd, result.returncode, result.stderr)
+
+ return result.returncode, result.stdout, result.stderr
+
+ except FileNotFoundError as e:
+ raise FFmpegNotFoundError(
+ f"执行FFmpeg时找不到文件: {e}\n命令: {' '.join(cmd)}",
+ self._get_install_solutions()
+ ) from e
+ except OSError as e:
+ if 'WinError 2' in str(e) or 'The system cannot find the file specified' in str(e):
+ raise FFmpegNotFoundError(
+ f"无法执行FFmpeg: {e}\n"
+ f"FFmpeg路径: {self.ffmpeg_path}\n"
+ f"命令: {' '.join(cmd)}",
+ self._get_install_solutions()
+ ) from e
+ else:
+ raise
+ except subprocess.TimeoutExpired:
+ logger.error(f"FFmpeg命令超时: {description}")
+ raise RuntimeError(f"FFmpeg命令超时: {description}")
+
def extract_audio_from_video(
self,
video_path: str,
@@ -156,9 +559,6 @@ def extract_audio_from_video(
Returns:
提取的音频文件路径
-
- Raises:
- RuntimeError: 提取失败时抛出
"""
video_path = Path(video_path).resolve()
if not video_path.exists():
@@ -171,33 +571,26 @@ def extract_audio_from_video(
output_path = Path(output_path).resolve()
+ logger.info(f"正在从视频提取音频: {video_path.name} -> {output_path.name}")
+
# 构建FFmpeg命令
+ # -vn: 不处理视频
+ # -acodec pcm_s16le: PCM 16位小端编码
+ # -ar: 采样率
+ # -ac: 声道数
+ # -y: 覆盖输出文件
cmd = [
- self.ffmpeg_path,
'-i', str(video_path),
- '-vn', # 只处理音频
- '-acodec', 'pcm_s16le', # PCM编码,16位
- '-ar', str(sample_rate), # 采样率
- '-ac', str(channels), # 声道数
- '-y', # 覆盖输出文件
+ '-vn',
+ '-acodec', 'pcm_s16le',
+ '-ar', str(sample_rate),
+ '-ac', str(channels),
+ '-y',
str(output_path)
]
- logger.info(f"正在从视频提取音频: {video_path} -> {output_path}")
- logger.info(f"执行命令: {' '.join(cmd)}")
-
try:
- result = subprocess.run(
- cmd,
- capture_output=True,
- text=True,
- check=False
- )
-
- if result.returncode != 0:
- error_msg = f"音频提取失败: {result.stderr}"
- logger.error(error_msg)
- raise RuntimeError(error_msg)
+ self._run_ffmpeg_command(cmd, f"提取音频: {video_path.name}")
if not output_path.exists():
raise RuntimeError(f"音频提取失败,输出文件不存在: {output_path}")
@@ -205,9 +598,9 @@ def extract_audio_from_video(
logger.info(f"音频提取成功: {output_path}")
return str(output_path)
- except Exception as e:
- logger.exception(f"音频提取过程中发生错误: {e}")
- raise
+ except FFmpegError as e:
+ logger.error(f"音频提取失败: {e}")
+ raise RuntimeError(f"音频提取失败: {e.stderr}") from e
def convert_audio_format(
self,
@@ -236,6 +629,7 @@ def convert_audio_format(
if (input_path.suffix.lower() == '.wav' and
sample_rate == 16000 and
channels == 1):
+ logger.info(f"音频已是所需格式,跳过转换: {input_path}")
return str(input_path)
# 如果未指定输出路径,创建临时文件
@@ -245,9 +639,10 @@ def convert_audio_format(
output_path = Path(output_path).resolve()
+ logger.info(f"正在转换音频: {input_path.name} -> {output_path.name}")
+
# 构建FFmpeg命令
cmd = [
- self.ffmpeg_path,
'-i', str(input_path),
'-acodec', 'pcm_s16le',
'-ar', str(sample_rate),
@@ -256,27 +651,15 @@ def convert_audio_format(
str(output_path)
]
- logger.info(f"正在转换音频: {input_path} -> {output_path}")
-
try:
- result = subprocess.run(
- cmd,
- capture_output=True,
- text=True,
- check=False
- )
-
- if result.returncode != 0:
- error_msg = f"音频转换失败: {result.stderr}"
- logger.error(error_msg)
- raise RuntimeError(error_msg)
+ self._run_ffmpeg_command(cmd, f"转换音频: {input_path.name}")
logger.info(f"音频转换成功: {output_path}")
return str(output_path)
- except Exception as e:
- logger.exception(f"音频转换过程中发生错误: {e}")
- raise
+ except FFmpegError as e:
+ logger.error(f"音频转换失败: {e}")
+ raise RuntimeError(f"音频转换失败: {e.stderr}") from e
def get_audio_duration(self, audio_path: str) -> float:
"""
@@ -292,34 +675,66 @@ def get_audio_duration(self, audio_path: str) -> float:
if not audio_path.exists():
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
- # 使用ffprobe获取时长
- ffprobe_path = self.ffmpeg_path.replace('ffmpeg', 'ffprobe')
- cmd = [
- ffprobe_path,
- '-v', 'error',
- '-show_entries', 'format=duration',
- '-of', 'default=noprint_wrappers=1:nokey=1',
- str(audio_path)
- ]
-
+ # 优先使用 ffprobe
+ if self.ffprobe_path:
+ try:
+ cmd = [
+ self.ffprobe_path,
+ '-v', 'error',
+ '-show_entries', 'format=duration',
+ '-of', 'default=noprint_wrappers=1:nokey=1',
+ str(audio_path)
+ ]
+
+ # 使用 startupinfo 隐藏 Windows 控制台窗口
+ startupinfo = None
+ if sys.platform == 'win32':
+ startupinfo = subprocess.STARTUPINFO()
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+ startupinfo.wShowWindow = subprocess.SW_HIDE
+
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=10,
+ startupinfo=startupinfo,
+ )
+
+ if result.returncode == 0:
+ duration_str = result.stdout.strip()
+ if duration_str:
+ return float(duration_str)
+ except Exception as e:
+ logger.warning(f"使用ffprobe获取时长失败: {e},将使用备用方法")
+
+ # 备用方法:使用 ffmpeg 解析
try:
- result = subprocess.run(
- cmd,
- capture_output=True,
- text=True,
- check=False
- )
+ cmd = [
+ '-i', str(audio_path),
+ '-f', 'null',
+ '-'
+ ]
- if result.returncode == 0:
- duration = float(result.stdout.strip())
- return duration
- else:
- logger.warning(f"获取音频时长失败: {result.stderr}")
- return 0.0
+ _, _, stderr = self._run_ffmpeg_command(cmd, f"获取音频时长: {audio_path.name}")
+
+ # 解析输出中的 Duration
+ import re
+ match = re.search(r'Duration:\s*(\d{2}):(\d{2}):(\d{2})\.(\d+)', stderr)
+ if match:
+ hours = int(match.group(1))
+ minutes = int(match.group(2))
+ seconds = int(match.group(3))
+ milliseconds = int(match.group(4))
+
+ total_seconds = hours * 3600 + minutes * 60 + seconds + milliseconds / 100.0
+ return total_seconds
except Exception as e:
- logger.exception(f"获取音频时长时发生错误: {e}")
+ logger.error(f"获取音频时长失败: {e}")
return 0.0
+
+ return 0.0
def process_media_file(
self,
@@ -342,11 +757,18 @@ def process_media_file(
raise FileNotFoundError(f"媒体文件不存在: {media_path}")
if not self.is_supported_format(str(media_path)):
- raise ValueError(f"不支持的文件格式: {media_path.suffix}")
+ raise ValueError(
+ f"不支持的文件格式: {media_path.suffix}\n"
+ f"支持的视频格式: {', '.join(sorted(self.SUPPORTED_VIDEO_FORMATS))}\n"
+ f"支持的音频格式: {', '.join(sorted(self.SUPPORTED_AUDIO_FORMATS))}"
+ )
is_video = self.is_video_file(str(media_path))
is_temp = temp_output
+ logger.info(f"处理媒体文件: {media_path.name}")
+ logger.info(f"文件类型: {'视频' if is_video else '音频'}")
+
if is_video:
# 从视频提取音频
audio_path = self.extract_audio_from_video(str(media_path))
@@ -369,3 +791,73 @@ def cleanup_temp_file(self, file_path: str):
logger.info(f"已清理临时文件: {file_path}")
except Exception as e:
logger.warning(f"清理临时文件失败: {file_path}, 错误: {e}")
+
+ def get_media_info(self, media_path: str) -> dict:
+ """
+ 获取媒体文件信息
+
+ Args:
+ media_path: 媒体文件路径
+
+ Returns:
+ 包含媒体信息的字典
+ """
+ media_path = Path(media_path).resolve()
+ if not media_path.exists():
+ raise FileNotFoundError(f"媒体文件不存在: {media_path}")
+
+ info = {
+ 'path': str(media_path),
+ 'name': media_path.name,
+ 'extension': media_path.suffix.lower(),
+ 'is_video': self.is_video_file(str(media_path)),
+ 'is_audio': not self.is_video_file(str(media_path)) and self.is_supported_format(str(media_path)),
+ 'size_bytes': media_path.stat().st_size,
+ 'size_mb': round(media_path.stat().st_size / (1024 * 1024), 2),
+ }
+
+ # 尝试获取时长
+ try:
+ duration = self.get_audio_duration(str(media_path))
+ info['duration_seconds'] = duration
+ info['duration_formatted'] = self._format_duration(duration)
+ except Exception:
+ pass
+
+ return info
+
+ def _format_duration(self, seconds: float) -> str:
+ """
+ 格式化时长
+
+ Args:
+ seconds: 秒数
+
+ Returns:
+ 格式化的时长字符串
+ """
+ hours = int(seconds // 3600)
+ minutes = int((seconds % 3600) // 60)
+ secs = int(seconds % 60)
+
+ if hours > 0:
+ return f"{hours}:{minutes:02d}:{secs:02d}"
+ else:
+ return f"{minutes}:{secs:02d}"
+
+
+# 便捷函数:检查FFmpeg是否可用
+def check_ffmpeg_available() -> Tuple[bool, Optional[str]]:
+ """
+ 检查FFmpeg是否可用
+
+ Returns:
+ (是否可用, 错误信息或None)
+ """
+ try:
+ processor = AudioProcessor()
+ return True, None
+ except FFmpegNotFoundError as e:
+ return False, e.get_detailed_message()
+ except Exception as e:
+ return False, str(e)
diff --git a/dependency_checker.py b/dependency_checker.py
new file mode 100644
index 0000000..42ecd50
--- /dev/null
+++ b/dependency_checker.py
@@ -0,0 +1,569 @@
+"""
+依赖检查模块
+在程序启动时自动验证所有必需依赖
+提供详细的错误信息和安装指南
+"""
+
+import os
+import sys
+import importlib.util
+from typing import Dict, List, Tuple, Optional
+from dataclasses import dataclass, field
+from enum import Enum
+
+
+class DependencyStatus(Enum):
+ """依赖状态枚举"""
+ OK = "ok"
+ MISSING = "missing"
+ VERSION_TOO_OLD = "version_too_old"
+ ERROR = "error"
+
+
+@dataclass
+class DependencyInfo:
+ """依赖信息"""
+ name: str
+ display_name: str
+ required: bool = True
+ installed: bool = False
+ version: Optional[str] = None
+ status: DependencyStatus = DependencyStatus.MISSING
+ error_message: Optional[str] = None
+ install_guide: List[str] = field(default_factory=list)
+
+ def is_ok(self) -> bool:
+ """检查是否满足要求"""
+ return self.status == DependencyStatus.OK
+
+
+class DependencyChecker:
+ """
+ 依赖检查器
+ 验证所有必需的依赖是否已安装
+ """
+
+ # 必需的 Python 包
+ REQUIRED_PACKAGES = [
+ {
+ 'name': 'torch',
+ 'display_name': 'PyTorch',
+ 'required': True,
+ 'min_version': '2.0.0',
+ 'install_guide': [
+ "GPU版本 (推荐):",
+ " pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118",
+ "",
+ "CPU版本:",
+ " pip3 install torch torchvision torchaudio",
+ ]
+ },
+ {
+ 'name': 'whisper',
+ 'display_name': 'OpenAI Whisper',
+ 'required': True,
+ 'min_version': None,
+ 'install_guide': [
+ "pip install openai-whisper",
+ ]
+ },
+ {
+ 'name': 'numpy',
+ 'display_name': 'NumPy',
+ 'required': True,
+ 'min_version': '1.20.0',
+ 'install_guide': [
+ "pip install numpy",
+ ]
+ },
+ {
+ 'name': 'tqdm',
+ 'display_name': 'tqdm (进度条)',
+ 'required': False,
+ 'min_version': None,
+ 'install_guide': [
+ "pip install tqdm",
+ ]
+ },
+ ]
+
+ # 系统工具
+ SYSTEM_TOOLS = [
+ {
+ 'name': 'ffmpeg',
+ 'display_name': 'FFmpeg',
+ 'required': True,
+ 'install_guide_windows': [
+ "方法1: 使用 winget 安装 (推荐)",
+ " 以管理员身份打开 PowerShell,运行:",
+ " winget install Gyan.FFmpeg",
+ "",
+ "方法2: 使用 Chocolatey 安装",
+ " choco install ffmpeg",
+ "",
+ "方法3: 手动安装",
+ " 1. 访问 https://www.gyan.dev/ffmpeg/builds/",
+ " 2. 下载 'ffmpeg-release-full.7z'",
+ " 3. 解压到 C:\\ffmpeg",
+ " 4. 将 C:\\ffmpeg\\bin 添加到系统 PATH 环境变量",
+ "",
+ "⚠️ 安装后需要重启命令行/IDE才能生效!",
+ ],
+ 'install_guide_macos': [
+ "方法1: 使用 Homebrew 安装 (推荐)",
+ " brew install ffmpeg",
+ "",
+ "方法2: 使用 MacPorts 安装",
+ " sudo port install ffmpeg",
+ "",
+ "方法3: 手动安装",
+ " 从 https://evermeet.cx/ffmpeg/ 下载并安装",
+ ],
+ 'install_guide_linux': [
+ "Ubuntu/Debian:",
+ " sudo apt update",
+ " sudo apt install ffmpeg",
+ "",
+ "Fedora/RHEL:",
+ " sudo dnf install ffmpeg",
+ "",
+ "Arch Linux:",
+ " sudo pacman -S ffmpeg",
+ ],
+ }
+ ]
+
+ def __init__(self):
+ """初始化依赖检查器"""
+ self.packages: Dict[str, DependencyInfo] = {}
+ self.system_tools: Dict[str, DependencyInfo] = {}
+ self.python_version_ok: bool = True
+ self.python_version_info: Optional[Tuple[int, int, int]] = None
+ self._checked: bool = False
+
+ def _get_platform(self) -> str:
+ """
+ 获取当前平台
+
+ Returns:
+ 'windows', 'macos', 或 'linux'
+ """
+ if sys.platform == 'win32':
+ return 'windows'
+ elif sys.platform == 'darwin':
+ return 'macos'
+ else:
+ return 'linux'
+
+ def check_python_version(self) -> bool:
+ """
+ 检查 Python 版本
+
+ Returns:
+ 版本是否满足要求
+ """
+ self.python_version_info = sys.version_info[:3]
+ major, minor, micro = self.python_version_info
+
+ # 要求 Python 3.8 - 3.11
+ # Whisper 在 Python 3.12+ 可能有兼容性问题
+ if (major, minor) < (3, 8):
+ self.python_version_ok = False
+ return False
+
+ if (major, minor) >= (3, 12):
+ # Python 3.12+ 可能有兼容性问题,但不直接阻止
+ self.python_version_ok = True
+ return True
+
+ self.python_version_ok = True
+ return True
+
+ def check_package(self, package_info: dict) -> DependencyInfo:
+ """
+ 检查单个 Python 包
+
+ Args:
+ package_info: 包信息字典
+
+ Returns:
+ 依赖信息对象
+ """
+ name = package_info['name']
+ display_name = package_info['display_name']
+ required = package_info['required']
+ min_version = package_info.get('min_version')
+ install_guide = package_info.get('install_guide', [])
+
+ info = DependencyInfo(
+ name=name,
+ display_name=display_name,
+ required=required,
+ install_guide=install_guide,
+ )
+
+ try:
+ # 尝试导入包
+ spec = importlib.util.find_spec(name)
+ if spec is None:
+ info.status = DependencyStatus.MISSING
+ info.installed = False
+ info.error_message = f"包 {display_name} ({name}) 未安装"
+ return info
+
+ # 尝试获取版本
+ module = importlib.import_module(name)
+ info.installed = True
+
+ # 获取版本号
+ version = None
+ if hasattr(module, '__version__'):
+ version = module.__version__
+ elif name == 'whisper':
+ # whisper 可能没有 __version__
+ try:
+ import openai_whisper
+ if hasattr(openai_whisper, '__version__'):
+ version = openai_whisper.__version__
+ except ImportError:
+ pass
+
+ info.version = version
+
+ # 检查版本要求
+ if min_version and version:
+ try:
+ from packaging.version import parse as parse_version
+ if parse_version(version) < parse_version(min_version):
+ info.status = DependencyStatus.VERSION_TOO_OLD
+ info.error_message = f"包 {display_name} 版本 {version} 太旧,要求 >= {min_version}"
+ return info
+ except ImportError:
+ # 如果没有 packaging 模块,跳过版本检查
+ pass
+
+ info.status = DependencyStatus.OK
+
+ except ImportError as e:
+ info.status = DependencyStatus.MISSING
+ info.installed = False
+ info.error_message = f"导入包 {display_name} 失败: {e}"
+
+ except Exception as e:
+ info.status = DependencyStatus.ERROR
+ info.error_message = f"检查包 {display_name} 时发生错误: {e}"
+
+ return info
+
+ def check_all_packages(self) -> Dict[str, DependencyInfo]:
+ """
+ 检查所有 Python 包
+
+ Returns:
+ 包信息字典
+ """
+ for package_info in self.REQUIRED_PACKAGES:
+ name = package_info['name']
+ self.packages[name] = self.check_package(package_info)
+
+ return self.packages
+
+ def check_ffmpeg(self) -> DependencyInfo:
+ """
+ 检查 FFmpeg
+
+ Returns:
+ FFmpeg 依赖信息
+ """
+ tool_info = self.SYSTEM_TOOLS[0]
+ name = tool_info['name']
+ display_name = tool_info['display_name']
+
+ platform = self._get_platform()
+ if platform == 'windows':
+ install_guide = tool_info['install_guide_windows']
+ elif platform == 'macos':
+ install_guide = tool_info['install_guide_macos']
+ else:
+ install_guide = tool_info['install_guide_linux']
+
+ info = DependencyInfo(
+ name=name,
+ display_name=display_name,
+ required=True,
+ install_guide=install_guide,
+ )
+
+ try:
+ # 使用 audio_processor 中的检查
+ from audio_processor import check_ffmpeg_available, FFmpegNotFoundError
+
+ available, error_msg = check_ffmpeg_available()
+
+ if available:
+ info.status = DependencyStatus.OK
+ info.installed = True
+
+ # 尝试获取版本
+ try:
+ from audio_processor import AudioProcessor
+ processor = AudioProcessor()
+ ffmpeg_info = processor.get_ffmpeg_info()
+ info.version = ffmpeg_info.get('version')
+ except Exception:
+ pass
+ else:
+ info.status = DependencyStatus.MISSING
+ info.installed = False
+ info.error_message = error_msg
+
+ except ImportError:
+ # 如果无法导入 audio_processor,使用 shutil.which 检查
+ import shutil
+ ffmpeg_path = shutil.which('ffmpeg')
+
+ if ffmpeg_path:
+ # 验证是否可执行
+ try:
+ import subprocess
+ result = subprocess.run(
+ [ffmpeg_path, '-version'],
+ capture_output=True,
+ text=True,
+ timeout=10
+ )
+ if result.returncode == 0:
+ info.status = DependencyStatus.OK
+ info.installed = True
+ # 尝试解析版本
+ import re
+ first_line = result.stdout.split('\n')[0]
+ version_match = re.search(r'ffmpeg version (\S+)', first_line, re.IGNORECASE)
+ if version_match:
+ info.version = version_match.group(1)
+ else:
+ info.status = DependencyStatus.ERROR
+ info.error_message = f"FFmpeg 执行失败: {result.stderr}"
+ except Exception as e:
+ info.status = DependencyStatus.ERROR
+ info.error_message = f"检查 FFmpeg 时发生错误: {e}"
+ else:
+ info.status = DependencyStatus.MISSING
+ info.installed = False
+ info.error_message = "未找到 FFmpeg"
+
+ self.system_tools[name] = info
+ return info
+
+ def check_all(self) -> bool:
+ """
+ 执行所有依赖检查
+
+ Returns:
+ 所有必需依赖是否都满足
+ """
+ self._checked = True
+
+ # 检查 Python 版本
+ self.check_python_version()
+
+ # 检查 Python 包
+ self.check_all_packages()
+
+ # 检查系统工具
+ self.check_ffmpeg()
+
+ return self.is_all_ok()
+
+ def is_all_ok(self) -> bool:
+ """
+ 检查所有必需依赖是否都满足
+
+ Returns:
+ 是否满足
+ """
+ if not self.python_version_ok:
+ return False
+
+ # 检查必需的包
+ for name, info in self.packages.items():
+ if info.required and not info.is_ok():
+ return False
+
+ # 检查必需的系统工具
+ for name, info in self.system_tools.items():
+ if info.required and not info.is_ok():
+ return False
+
+ return True
+
+ def get_missing_dependencies(self) -> List[DependencyInfo]:
+ """
+ 获取缺失或有问题的依赖
+
+ Returns:
+ 有问题的依赖列表
+ """
+ missing = []
+
+ # 检查必需的包
+ for name, info in self.packages.items():
+ if info.required and not info.is_ok():
+ missing.append(info)
+
+ # 检查必需的系统工具
+ for name, info in self.system_tools.items():
+ if info.required and not info.is_ok():
+ missing.append(info)
+
+ return missing
+
+ def get_warning_dependencies(self) -> List[DependencyInfo]:
+ """
+ 获取可选但缺失的依赖(警告级别)
+
+ Returns:
+ 可选但缺失的依赖列表
+ """
+ warnings = []
+
+ for name, info in self.packages.items():
+ if not info.required and not info.is_ok():
+ warnings.append(info)
+
+ return warnings
+
+ def generate_report(self, show_ok: bool = False) -> str:
+ """
+ 生成依赖检查报告
+
+ Args:
+ show_ok: 是否显示状态正常的依赖
+
+ Returns:
+ 报告字符串
+ """
+ lines = []
+ lines.append("=" * 60)
+ lines.append(" VideoSubtitleAI 依赖检查报告")
+ lines.append("=" * 60)
+ lines.append("")
+
+ # Python 版本
+ lines.append("【Python 版本】")
+ if self.python_version_info:
+ version_str = f"{self.python_version_info[0]}.{self.python_version_info[1]}.{self.python_version_info[2]}"
+ status = "✓ 兼容" if self.python_version_ok else "✗ 不兼容"
+ lines.append(f" 当前版本: {version_str}")
+ lines.append(f" 状态: {status}")
+ if not self.python_version_ok:
+ lines.append(f" 要求: Python 3.8 - 3.11")
+ lines.append("")
+
+ # Python 包
+ lines.append("【Python 包】")
+ for name, info in self.packages.items():
+ if not show_ok and info.is_ok():
+ continue
+
+ status_icon = "✓" if info.is_ok() else "✗"
+ status_text = "OK" if info.is_ok() else f"问题: {info.error_message}"
+ required_text = "[必需]" if info.required else "[可选]"
+
+ version_str = f" v{info.version}" if info.version else ""
+ lines.append(f" {status_icon} {info.display_name}{version_str} {required_text}")
+ if not info.is_ok():
+ lines.append(f" {status_text}")
+ if info.install_guide:
+ lines.append(f" 安装指南:")
+ for guide_line in info.install_guide:
+ lines.append(f" {guide_line}")
+ lines.append("")
+
+ # 系统工具
+ lines.append("【系统工具】")
+ for name, info in self.system_tools.items():
+ if not show_ok and info.is_ok():
+ continue
+
+ status_icon = "✓" if info.is_ok() else "✗"
+ status_text = "OK" if info.is_ok() else f"问题: {info.error_message}"
+
+ version_str = f" v{info.version}" if info.version else ""
+ lines.append(f" {status_icon} {info.display_name}{version_str}")
+ if not info.is_ok():
+ lines.append(f" {status_text}")
+ if info.install_guide:
+ lines.append(f" 安装指南:")
+ for guide_line in info.install_guide:
+ lines.append(f" {guide_line}")
+ lines.append("")
+
+ # 总结
+ lines.append("【总结】")
+ if self.is_all_ok():
+ lines.append(" ✓ 所有必需依赖已满足!")
+ warnings = self.get_warning_dependencies()
+ if warnings:
+ lines.append(f" ⚠ {len(warnings)} 个可选依赖未安装(不影响核心功能)")
+ else:
+ missing = self.get_missing_dependencies()
+ lines.append(f" ✗ {len(missing)} 个必需依赖未满足,请安装后重试")
+ lines.append("")
+ lines.append("=" * 60)
+
+ return '\n'.join(lines)
+
+ def print_report(self, show_ok: bool = False):
+ """
+ 打印依赖检查报告到控制台
+
+ Args:
+ show_ok: 是否显示状态正常的依赖
+ """
+ report = self.generate_report(show_ok=show_ok)
+
+ # 添加颜色(如果支持)
+ try:
+ from colorama import init, Fore, Style
+ init()
+
+ # 简单的颜色替换
+ colored_report = report
+ colored_report = colored_report.replace('✓ ', Fore.GREEN + '✓ ' + Style.RESET_ALL)
+ colored_report = colored_report.replace('✗ ', Fore.RED + '✗ ' + Style.RESET_ALL)
+ colored_report = colored_report.replace('⚠ ', Fore.YELLOW + '⚠ ' + Style.RESET_ALL)
+
+ print(colored_report)
+ except ImportError:
+ print(report)
+
+
+# 便捷函数
+def check_dependencies() -> Tuple[bool, DependencyChecker]:
+ """
+ 便捷函数:检查所有依赖
+
+ Returns:
+ (是否所有必需依赖都满足, 依赖检查器实例)
+ """
+ checker = DependencyChecker()
+ checker.check_all()
+ return checker.is_all_ok(), checker
+
+
+def print_dependency_report(show_ok: bool = False):
+ """
+ 便捷函数:打印依赖检查报告
+
+ Args:
+ show_ok: 是否显示状态正常的依赖
+ """
+ checker = DependencyChecker()
+ checker.check_all()
+ checker.print_report(show_ok=show_ok)
+
+
+if __name__ == '__main__':
+ # 当直接运行此模块时,执行依赖检查
+ print_dependency_report(show_ok=True)
diff --git a/language_detector.py b/language_detector.py
new file mode 100644
index 0000000..fadb32e
--- /dev/null
+++ b/language_detector.py
@@ -0,0 +1,599 @@
+"""
+语言检测优化模块
+支持多语言混合场景的识别
+实现分段语言检测、自动切换语言模型
+"""
+
+import os
+import sys
+import re
+import logging
+from typing import List, Dict, Any, Optional, Tuple
+from dataclasses import dataclass, field
+from enum import Enum
+from pathlib import Path
+import numpy as np
+
+logger = logging.getLogger(__name__)
+
+
+LANGUAGE_NAMES = {
+ 'zh': '中文',
+ 'en': '英文',
+ 'ja': '日文',
+ 'ko': '韩文',
+ 'fr': '法文',
+ 'de': '德文',
+ 'es': '西班牙文',
+ 'ru': '俄文',
+ 'pt': '葡萄牙文',
+ 'it': '意大利文',
+ 'auto': '自动检测',
+}
+
+
+CHINESE_CHARS = set()
+for codepoint in range(0x4e00, 0x9fff + 1):
+ CHINESE_CHARS.add(chr(codepoint))
+
+JAPANESE_HIRAGANA = set()
+for codepoint in range(0x3040, 0x309f + 1):
+ JAPANESE_HIRAGANA.add(chr(codepoint))
+
+JAPANESE_KATAKANA = set()
+for codepoint in range(0x30a0, 0x30ff + 1):
+ JAPANESE_KATAKANA.add(chr(codepoint))
+
+KOREAN_HANGUL = set()
+for codepoint in range(0xac00, 0xd7a3 + 1):
+ KOREAN_HANGUL.add(chr(codepoint))
+
+
+@dataclass
+class LanguageDetectionResult:
+ """语言检测结果"""
+ language: str = 'unknown'
+ language_name: str = '未知'
+ confidence: float = 0.0
+ top_languages: List[Dict[str, Any]] = field(default_factory=list)
+ detection_method: str = 'unknown'
+ is_mixed: bool = False
+ mixed_languages: List[str] = field(default_factory=list)
+
+
+@dataclass
+class SegmentLanguageInfo:
+ """段落语言信息"""
+ segment_index: int = 0
+ start_time: float = 0.0
+ end_time: float = 0.0
+ text: str = ''
+ detected_language: str = 'unknown'
+ confidence: float = 0.0
+ language_scores: Dict[str, float] = field(default_factory=dict)
+
+
+class LanguageDetector:
+ """
+ 增强语言检测器
+ 支持:
+ 1. 基于字符特征的快速语言检测
+ 2. 多语言混合检测
+ 3. 段落级语言分析
+ 4. 与Whisper模型集成的高精度检测
+ """
+
+ def __init__(self, whisper_model=None):
+ self.whisper_model = whisper_model
+ self._language_detector = None
+
+ def detect_language_from_text(self, text: str) -> LanguageDetectionResult:
+ """
+ 基于文本内容快速检测语言
+ """
+ if not text or not text.strip():
+ return LanguageDetectionResult(
+ language='unknown',
+ language_name='未知',
+ confidence=0.0,
+ detection_method='text_analysis',
+ )
+
+ scores = self._calculate_language_scores(text)
+
+ if not scores:
+ return LanguageDetectionResult(
+ language='unknown',
+ language_name='未知',
+ confidence=0.0,
+ detection_method='text_analysis',
+ )
+
+ sorted_languages = sorted(
+ scores.items(),
+ key=lambda x: x[1],
+ reverse=True
+ )
+
+ top_lang, top_confidence = sorted_languages[0]
+
+ top_languages = []
+ for lang, conf in sorted_languages[:5]:
+ if conf > 0.01:
+ top_languages.append({
+ 'language': lang,
+ 'language_name': LANGUAGE_NAMES.get(lang, lang),
+ 'confidence': conf,
+ })
+
+ mixed_languages = []
+ for lang, conf in sorted_languages[1:]:
+ if conf > 0.15 and top_confidence - conf < 0.5:
+ mixed_languages.append(lang)
+
+ is_mixed = len(mixed_languages) > 0
+
+ return LanguageDetectionResult(
+ language=top_lang,
+ language_name=LANGUAGE_NAMES.get(top_lang, top_lang),
+ confidence=top_confidence,
+ top_languages=top_languages,
+ detection_method='text_analysis',
+ is_mixed=is_mixed,
+ mixed_languages=mixed_languages,
+ )
+
+ def _calculate_language_scores(self, text: str) -> Dict[str, float]:
+ """
+ 计算各种语言的得分
+ """
+ scores = {}
+ total_chars = len(text)
+
+ if total_chars == 0:
+ return scores
+
+ chinese_count = 0
+ japanese_count = 0
+ korean_count = 0
+ latin_count = 0
+ cyrillic_count = 0
+ digit_count = 0
+ symbol_count = 0
+
+ for char in text:
+ if char in CHINESE_CHARS:
+ chinese_count += 1
+ elif char in JAPANESE_HIRAGANA or char in JAPANESE_KATAKANA:
+ japanese_count += 1
+ elif char in KOREAN_HANGUL:
+ korean_count += 1
+ elif '\u0400' <= char <= '\u04ff' or '\u0500' <= char <= '\u052f':
+ cyrillic_count += 1
+ elif ('a' <= char <= 'z') or ('A' <= char <= 'Z'):
+ latin_count += 1
+ elif char.isdigit():
+ digit_count += 1
+ elif char.isspace():
+ continue
+ else:
+ symbol_count += 1
+
+ meaningful_chars = chinese_count + japanese_count + korean_count + latin_count + cyrillic_count
+ if meaningful_chars == 0:
+ meaningful_chars = 1
+
+ if chinese_count > 0:
+ chinese_ratio = chinese_count / meaningful_chars
+
+ if japanese_count > 0:
+ ja_ratio = japanese_count / meaningful_chars
+ if ja_ratio > 0.1:
+ scores['ja'] = ja_ratio * 0.8
+ scores['zh'] = chinese_ratio * 0.6
+ else:
+ scores['zh'] = chinese_ratio
+ else:
+ scores['zh'] = chinese_ratio
+
+ if japanese_count > 0 and 'ja' not in scores:
+ ja_ratio = japanese_count / meaningful_chars
+ scores['ja'] = ja_ratio
+
+ if korean_count > 0:
+ ko_ratio = korean_count / meaningful_chars
+ scores['ko'] = ko_ratio
+
+ if latin_count > 0:
+ en_ratio = latin_count / meaningful_chars
+
+ if en_ratio > 0.1:
+ word_count = len(re.findall(r'\b[a-zA-Z]{2,}\b', text))
+ if word_count >= 2:
+ scores['en'] = min(1.0, en_ratio + 0.2)
+ else:
+ scores['en'] = en_ratio * 0.5
+
+ if cyrillic_count > 0:
+ ru_ratio = cyrillic_count / meaningful_chars
+ scores['ru'] = ru_ratio
+
+ if not scores and latin_count > 0:
+ scores['en'] = 0.5
+
+ total_score = sum(scores.values())
+ if total_score > 0:
+ normalized_scores = {k: v / total_score for k, v in scores.items()}
+ else:
+ normalized_scores = scores
+
+ return normalized_scores
+
+ def detect_language_with_whisper(
+ self,
+ audio_path: str,
+ sample_duration: float = 30.0,
+ ) -> LanguageDetectionResult:
+ """
+ 使用Whisper模型进行高精度语言检测
+ """
+ if self.whisper_model is None:
+ return LanguageDetectionResult(
+ language='unknown',
+ language_name='未知',
+ confidence=0.0,
+ detection_method='whisper_unavailable',
+ )
+
+ try:
+ import whisper
+
+ audio = whisper.load_audio(audio_path)
+ audio = whisper.pad_or_trim(audio)
+
+ mel = whisper.log_mel_spectrogram(audio).to(self.whisper_model.device)
+
+ _, probs = self.whisper_model.detect_language(mel)
+
+ detected_lang = max(probs, key=probs.get)
+ confidence = probs[detected_lang]
+
+ top_languages = []
+ for lang, prob in sorted(probs.items(), key=lambda x: x[1], reverse=True)[:5]:
+ if prob > 0.01:
+ top_languages.append({
+ 'language': lang,
+ 'language_name': LANGUAGE_NAMES.get(lang, lang),
+ 'confidence': float(prob),
+ })
+
+ return LanguageDetectionResult(
+ language=detected_lang,
+ language_name=LANGUAGE_NAMES.get(detected_lang, detected_lang),
+ confidence=float(confidence),
+ top_languages=top_languages,
+ detection_method='whisper_model',
+ )
+
+ except Exception as e:
+ logger.error(f"Whisper语言检测失败: {e}")
+ return LanguageDetectionResult(
+ language='unknown',
+ language_name='未知',
+ confidence=0.0,
+ detection_method='whisper_error',
+ )
+
+ def analyze_segments_language(
+ self,
+ segments: List[Dict[str, Any]],
+ ) -> List[SegmentLanguageInfo]:
+ """
+ 分析每个段落的语言
+ """
+ results = []
+
+ for idx, seg in enumerate(segments):
+ text = seg.get('text', '') if isinstance(seg, dict) else getattr(seg, 'text', '')
+ start = seg.get('start', 0.0) if isinstance(seg, dict) else getattr(seg, 'start', 0.0)
+ end = seg.get('end', 0.0) if isinstance(seg, dict) else getattr(seg, 'end', 0.0)
+
+ detection = self.detect_language_from_text(text)
+
+ seg_info = SegmentLanguageInfo(
+ segment_index=idx,
+ start_time=start,
+ end_time=end,
+ text=text,
+ detected_language=detection.language,
+ confidence=detection.confidence,
+ language_scores={lang: info['confidence'] for lang, info in
+ [(l['language'], l) for l in detection.top_languages]},
+ )
+
+ results.append(seg_info)
+
+ return results
+
+ def detect_mixed_language_scenes(
+ self,
+ segments: List[Dict[str, Any]],
+ threshold: float = 0.3,
+ ) -> Dict[str, Any]:
+ """
+ 检测多语言混合场景
+ """
+ segment_languages = self.analyze_segments_language(segments)
+
+ language_distribution = {}
+ for seg_info in segment_languages:
+ lang = seg_info.detected_language
+ if lang not in language_distribution:
+ language_distribution[lang] = 0
+ language_distribution[lang] += 1
+
+ total_segments = len(segment_languages)
+ if total_segments == 0:
+ return {
+ 'is_mixed': False,
+ 'primary_language': 'unknown',
+ 'languages': [],
+ 'segments': [],
+ }
+
+ language_ratios = {
+ lang: count / total_segments
+ for lang, count in language_distribution.items()
+ }
+
+ significant_languages = [
+ lang for lang, ratio in language_ratios.items()
+ if ratio >= threshold and lang != 'unknown'
+ ]
+
+ is_mixed = len(significant_languages) > 1
+
+ primary_language = max(
+ language_ratios.items(),
+ key=lambda x: x[1]
+ )[0] if language_ratios else 'unknown'
+
+ language_segments = {}
+ for lang in significant_languages:
+ language_segments[lang] = [
+ seg for seg in segment_languages
+ if seg.detected_language == lang
+ ]
+
+ return {
+ 'is_mixed': is_mixed,
+ 'primary_language': primary_language,
+ 'primary_language_name': LANGUAGE_NAMES.get(primary_language, primary_language),
+ 'languages': significant_languages,
+ 'language_names': [LANGUAGE_NAMES.get(lang, lang) for lang in significant_languages],
+ 'language_distribution': language_distribution,
+ 'language_ratios': language_ratios,
+ 'segment_languages': segment_languages,
+ 'language_segments': language_segments,
+ 'threshold': threshold,
+ }
+
+ def get_optimal_language_for_transcription(
+ self,
+ segments: List[Dict[str, Any]],
+ prefer_multilingual: bool = True,
+ ) -> Dict[str, Any]:
+ """
+ 获取最优的转录语言设置
+ """
+ mixed_info = self.detect_mixed_language_scenes(segments)
+
+ if mixed_info['is_mixed'] and prefer_multilingual:
+ return {
+ 'language': 'auto',
+ 'language_name': '自动检测(多语言混合)',
+ 'is_mixed': True,
+ 'languages': mixed_info['languages'],
+ 'recommendation': '使用auto模式,Whisper会自动切换语言模型',
+ 'details': mixed_info,
+ }
+ else:
+ primary = mixed_info['primary_language']
+ return {
+ 'language': primary if primary != 'unknown' else 'auto',
+ 'language_name': LANGUAGE_NAMES.get(primary, '自动检测'),
+ 'is_mixed': False,
+ 'languages': [primary] if primary != 'unknown' else [],
+ 'recommendation': f'使用{primary}模式以获得最佳识别效果',
+ 'details': mixed_info,
+ }
+
+
+class MultilingualSpeechRecognizer:
+ """
+ 多语言语音识别增强器
+ 针对多语言混合场景优化识别效果
+ """
+
+ def __init__(self, base_recognizer=None, whisper_model=None):
+ self.base_recognizer = base_recognizer
+ self.whisper_model = whisper_model
+ self.language_detector = LanguageDetector(whisper_model=whisper_model)
+
+ def recognize_with_language_detection(
+ self,
+ audio_path: str,
+ language: str = 'auto',
+ task: str = 'transcribe',
+ **kwargs,
+ ) -> Dict[str, Any]:
+ """
+ 带语言检测的语音识别
+ """
+ if self.whisper_model is None and self.base_recognizer is None:
+ raise RuntimeError("没有可用的语音识别模型")
+
+ if self.base_recognizer:
+ result = self.base_recognizer.recognize(
+ audio_path=audio_path,
+ language=language,
+ task=task,
+ **kwargs,
+ )
+ else:
+ try:
+ import whisper
+
+ result = self.whisper_model.transcribe(
+ audio=str(audio_path),
+ language=None if language == 'auto' else language,
+ task=task,
+ **kwargs,
+ )
+
+ detected_language = result.get('language', 'unknown')
+ segments = result.get('segments', [])
+
+ formatted_segments = []
+ for seg in segments:
+ formatted = {
+ 'id': seg.get('id', 0),
+ 'start': round(float(seg.get('start', 0)), 3),
+ 'end': round(float(seg.get('end', 0)), 3),
+ 'text': seg.get('text', '').strip(),
+ 'language': seg.get('language', detected_language),
+ 'tokens': seg.get('tokens', []),
+ 'avg_logprob': seg.get('avg_logprob', 0.0),
+ 'no_speech_prob': seg.get('no_speech_prob', 0.0),
+ }
+ formatted_segments.append(formatted)
+
+ result = {
+ 'text': result.get('text', '').strip(),
+ 'segments': formatted_segments,
+ 'language': detected_language,
+ 'language_name': LANGUAGE_NAMES.get(detected_language, detected_language),
+ 'segments_info': formatted_segments,
+ 'audio_path': audio_path,
+ 'task': task,
+ }
+
+ except Exception as e:
+ logger.error(f"Whisper识别失败: {e}")
+ raise
+
+ segments = result.get('segments', [])
+
+ if segments:
+ mixed_info = self.language_detector.detect_mixed_language_scenes(segments)
+
+ result['language_analysis'] = {
+ 'is_mixed': mixed_info['is_mixed'],
+ 'languages': mixed_info.get('languages', []),
+ 'language_names': mixed_info.get('language_names', []),
+ 'primary_language': mixed_info.get('primary_language', 'unknown'),
+ 'language_ratios': mixed_info.get('language_ratios', {}),
+ }
+
+ return result
+
+ def analyze_and_suggest_language(
+ self,
+ audio_path: str,
+ segments: Optional[List[Dict[str, Any]]] = None,
+ ) -> Dict[str, Any]:
+ """
+ 分析音频并建议最佳语言设置
+ """
+ suggestions = {}
+
+ if segments:
+ mixed_info = self.language_detector.detect_mixed_language_scenes(segments)
+
+ suggestions['segment_analysis'] = mixed_info
+
+ if mixed_info['is_mixed']:
+ suggestions['recommended_language'] = 'auto'
+ suggestions['recommendation'] = (
+ f"检测到多语言混合场景({', '.join(mixed_info['language_names'])}),"
+ f"建议使用auto模式以获得最佳识别效果"
+ )
+ suggestions['is_mixed'] = True
+ else:
+ primary = mixed_info['primary_language']
+ suggestions['recommended_language'] = primary if primary != 'unknown' else 'auto'
+ suggestions['recommendation'] = (
+ f"检测到主要语言为{mixed_info['primary_language_name']},"
+ f"建议使用该语言以获得最佳识别效果"
+ )
+ suggestions['is_mixed'] = False
+
+ if self.whisper_model:
+ whisper_result = self.language_detector.detect_language_with_whisper(audio_path)
+ suggestions['whisper_detection'] = {
+ 'language': whisper_result.language,
+ 'language_name': whisper_result.language_name,
+ 'confidence': whisper_result.confidence,
+ 'top_languages': whisper_result.top_languages,
+ }
+
+ return suggestions
+
+
+def is_chinese_text(text: str) -> bool:
+ """
+ 快速判断文本是否主要是中文
+ """
+ if not text:
+ return False
+
+ chinese_count = 0
+ total_chars = 0
+
+ for char in text:
+ if char.isspace() or char.isdigit():
+ continue
+ total_chars += 1
+ if char in CHINESE_CHARS:
+ chinese_count += 1
+
+ if total_chars == 0:
+ return False
+
+ return chinese_count / total_chars > 0.5
+
+
+def is_english_text(text: str) -> bool:
+ """
+ 快速判断文本是否主要是英文
+ """
+ if not text:
+ return False
+
+ english_count = 0
+ total_chars = 0
+
+ for char in text:
+ if char.isspace() or char.isdigit():
+ continue
+ total_chars += 1
+ if ('a' <= char <= 'z') or ('A' <= char <= 'Z'):
+ english_count += 1
+
+ if total_chars == 0:
+ return False
+
+ return english_count / total_chars > 0.6
+
+
+def classify_text_language(text: str) -> Tuple[str, float]:
+ """
+ 分类文本语言,返回(语言代码, 置信度)
+ """
+ if not text or not text.strip():
+ return ('unknown', 0.0)
+
+ detector = LanguageDetector()
+ result = detector.detect_language_from_text(text)
+
+ return (result.language, result.confidence)
diff --git a/main.py b/main.py
index 2195e9e..07b4183 100644
--- a/main.py
+++ b/main.py
@@ -1,20 +1,83 @@
"""
VideoSubtitleAI - 本地离线音视频转字幕工具
主程序入口,提供命令行接口
+改进版:添加依赖检查,提供更友好的错误信息和安装指南
"""
import os
import sys
import argparse
+import traceback
from pathlib import Path
-from typing import Optional, List, Dict, Any, Callable
+from typing import Optional, List, Dict, Any, Callable, Tuple
-# 导入自定义模块
-from audio_processor import AudioProcessor
-from speech_recognizer import SpeechRecognizer
-from subtitle_generator import SubtitleGenerator
-from batch_processor import BatchProcessor, BatchProgress
-from progress_logger import ProgressLogger, ConsoleLogger
+
+def check_dependencies_and_exit() -> bool:
+ """
+ 检查依赖并在必要时退出
+
+ Returns:
+ 所有依赖是否满足
+ """
+ try:
+ from dependency_checker import check_dependencies, DependencyChecker, DependencyStatus
+
+ all_ok, checker = check_dependencies()
+
+ if not all_ok:
+ # 有缺失的依赖
+ print("\n" + "=" * 60)
+ print(" ⚠️ 依赖检查失败")
+ print("=" * 60)
+
+ missing = checker.get_missing_dependencies()
+ warnings = checker.get_warning_dependencies()
+
+ for dep in missing:
+ print(f"\n✗ {dep.display_name}: {dep.error_message}")
+ if dep.install_guide:
+ print(" 安装指南:")
+ for line in dep.install_guide:
+ print(f" {line}")
+
+ if warnings:
+ print(f"\n⚠ {len(warnings)} 个可选依赖未安装(不影响核心功能):")
+ for dep in warnings:
+ print(f" - {dep.display_name}")
+
+ print("\n" + "=" * 60)
+ print("请安装上述缺失的依赖后重试。")
+ print("=" * 60 + "\n")
+ return False
+ else:
+ print("✓ 所有必需依赖已满足")
+ return True
+
+ except ImportError as e:
+ print(f"\n✗ 依赖检查模块加载失败: {e}")
+ print("这通常表示 Python 包未正确安装。")
+ print("\n请确保已安装以下必需包:")
+ print(" 1. PyTorch: pip install torch torchvision torchaudio")
+ print(" 2. Whisper: pip install openai-whisper")
+ print(" 3. NumPy: pip install numpy")
+ print("\n同时请确保 FFmpeg 已安装:")
+ print(" Windows: winget install Gyan.FFmpeg")
+ print(" macOS: brew install ffmpeg")
+ print(" Linux: sudo apt install ffmpeg")
+ return False
+
+
+# 导入自定义模块(在依赖检查后)
+try:
+ from audio_processor import AudioProcessor, FFmpegNotFoundError, FFmpegError
+ from speech_recognizer import SpeechRecognizer
+ from subtitle_generator import SubtitleGenerator
+ from batch_processor import BatchProcessor, BatchProgress
+ from progress_logger import ProgressLogger, ConsoleLogger
+ MODULES_LOADED = True
+except ImportError as e:
+ print(f"\n✗ 模块加载失败: {e}")
+ MODULES_LOADED = False
class VideoSubtitleAI:
@@ -75,22 +138,54 @@ def initialize(self):
# 初始化音频处理器
try:
+ self.progress_logger.info("正在初始化音频处理器...", "初始化")
self.audio_processor = AudioProcessor(ffmpeg_path=self.ffmpeg_path)
- self.progress_logger.info("音频处理器初始化成功", "初始化")
+
+ # 显示 FFmpeg 信息
+ ffmpeg_info = self.audio_processor.get_ffmpeg_info()
+ if ffmpeg_info['version']:
+ self.progress_logger.info(f"FFmpeg版本: {ffmpeg_info['version']}", "初始化")
+ self.progress_logger.success("音频处理器初始化成功", "初始化")
+
+ except FFmpegNotFoundError as e:
+ # 显示详细的错误信息和解决方案
+ print("\n" + "=" * 60)
+ print(" ❌ FFmpeg 未找到")
+ print("=" * 60)
+ print(f"\n错误: {e}")
+ print("\n" + e.get_detailed_message())
+ print("=" * 60 + "\n")
+
+ if self.progress_logger:
+ self.progress_logger.error(f"音频处理器初始化失败: {e}", "初始化")
+ raise
+
except Exception as e:
- self.progress_logger.error(f"音频处理器初始化失败: {e}", "初始化")
+ if self.progress_logger:
+ self.progress_logger.error(f"音频处理器初始化失败: {e}", "初始化")
raise
# 初始化语音识别器
try:
+ self.progress_logger.info("正在初始化语音识别器...", "初始化")
self.speech_recognizer = SpeechRecognizer(
model_name=self.model_name,
device=self.device,
model_dir=self.model_dir,
)
- self.progress_logger.info(f"语音识别器初始化成功,模型: {self.model_name}", "初始化")
+ self.progress_logger.success(f"语音识别器初始化成功,模型: {self.model_name}", "初始化")
+ except ImportError as e:
+ print("\n" + "=" * 60)
+ print(" ❌ Whisper 模型加载失败")
+ print("=" * 60)
+ print(f"\n错误: {e}")
+ print("\n请确保已安装 Whisper:")
+ print(" pip install openai-whisper")
+ print("=" * 60 + "\n")
+ raise
except Exception as e:
- self.progress_logger.error(f"语音识别器初始化失败: {e}", "初始化")
+ if self.progress_logger:
+ self.progress_logger.error(f"语音识别器初始化失败: {e}", "初始化")
raise
# 初始化字幕生成器
@@ -114,6 +209,14 @@ def _show_device_info(self):
self.progress_logger.info(f"GPU: {info['gpu_name']} ({info['gpu_memory']})", "设备信息")
else:
self.progress_logger.warning("GPU不可用,将使用CPU运行(速度较慢)", "设备信息")
+ print("\n" + "=" * 60)
+ print(" ⚠️ GPU 不可用")
+ print("=" * 60)
+ print("\n未检测到可用的 NVIDIA GPU,将使用 CPU 运行。")
+ print("CPU 模式运行速度较慢,建议安装 CUDA 版本的 PyTorch。")
+ print("\n安装 CUDA 版本 PyTorch:")
+ print(" pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118")
+ print("=" * 60 + "\n")
def load_model(self, force_reload: bool = False):
"""
@@ -123,8 +226,27 @@ def load_model(self, force_reload: bool = False):
self.initialize()
self.progress_logger.info(f"正在加载模型: {self.model_name}...", "模型加载")
- self.speech_recognizer.load_model(force_reload=force_reload)
- self.progress_logger.success("模型加载完成", "模型加载")
+ self.progress_logger.info("首次运行会自动下载模型,请耐心等待...", "模型加载")
+
+ try:
+ self.speech_recognizer.load_model(force_reload=force_reload)
+ self.progress_logger.success("模型加载完成", "模型加载")
+ except Exception as e:
+ self.progress_logger.error(f"模型加载失败: {e}", "模型加载")
+ print("\n" + "=" * 60)
+ print(" ❌ 模型加载失败")
+ print("=" * 60)
+ print(f"\n错误: {e}")
+ print("\n可能的原因:")
+ print(" 1. 网络连接问题(首次运行需要下载模型)")
+ print(" 2. 磁盘空间不足")
+ print(" 3. 内存不足")
+ print("\n解决方案:")
+ print(" - 检查网络连接")
+ print(" - 确保有足够的磁盘空间(模型大小从 39MB 到 1.5GB)")
+ print(" - 尝试使用更小的模型: -m tiny 或 -m base")
+ print("=" * 60 + "\n")
+ raise
def unload_model(self):
"""
@@ -132,7 +254,8 @@ def unload_model(self):
"""
if self.speech_recognizer:
self.speech_recognizer.unload_model()
- self.progress_logger.info("模型已卸载", "模型管理")
+ if self.progress_logger:
+ self.progress_logger.info("模型已卸载", "模型管理")
def process_single_file(
self,
@@ -165,6 +288,14 @@ def process_single_file(
if not input_path.exists():
raise FileNotFoundError(f"输入文件不存在: {input_path}")
+ # 检查文件格式
+ if not self.audio_processor.is_supported_format(str(input_path)):
+ raise ValueError(
+ f"不支持的文件格式: {input_path.suffix}\n"
+ f"支持的视频格式: {', '.join(sorted(self.audio_processor.SUPPORTED_VIDEO_FORMATS))}\n"
+ f"支持的音频格式: {', '.join(sorted(self.audio_processor.SUPPORTED_AUDIO_FORMATS))}"
+ )
+
# 确定输出路径
if output_path is None:
output_path = input_path.parent / f"{input_path.stem}.srt"
@@ -223,6 +354,19 @@ def process_single_file(
return result
+ except FFmpegError as e:
+ self.progress_logger.error(f"FFmpeg执行失败: {e}", "错误")
+ print("\n" + "=" * 60)
+ print(" ❌ 音频处理失败")
+ print("=" * 60)
+ print(f"\nFFmpeg执行错误 (返回码: {e.return_code})")
+ print(f"错误信息: {e.stderr}")
+ print("\n可能的原因:")
+ print(" 1. 输入文件损坏或格式不兼容")
+ print(" 2. FFmpeg版本太旧")
+ print(" 3. 磁盘空间不足")
+ print("=" * 60 + "\n")
+ raise
except Exception as e:
self.progress_logger.error(f"处理失败: {e}", "错误")
raise
@@ -295,6 +439,13 @@ def process_folder(
if not file_pairs:
self.progress_logger.warning("没有找到待处理的文件", "批量处理")
+ print("\n" + "=" * 60)
+ print(" ⚠️ 未找到待处理文件")
+ print("=" * 60)
+ print(f"\n在 {input_folder} 中未找到支持的音视频文件。")
+ print(f"\n支持的视频格式: {', '.join(sorted(self.audio_processor.SUPPORTED_VIDEO_FORMATS))}")
+ print(f"支持的音频格式: {', '.join(sorted(self.audio_processor.SUPPORTED_AUDIO_FORMATS))}")
+ print("=" * 60 + "\n")
return {
'success': True,
'total_files': 0,
@@ -417,6 +568,9 @@ def get_info(self) -> Dict[str, Any]:
info['device_info'] = self.speech_recognizer.get_device_info()
info['model_info'] = self.speech_recognizer.get_model_info()
+ if self.audio_processor:
+ info['ffmpeg_info'] = self.audio_processor.get_ffmpeg_info()
+
return info
@@ -425,23 +579,46 @@ def parse_args():
解析命令行参数
"""
parser = argparse.ArgumentParser(
- description='VideoSubtitleAI - 本地离线音视频转字幕工具',
+ description='VideoSubtitleAI - 本地离线音视频转字幕工具(增强版)',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
-示例:
- # 处理单个视频
+基本用法示例:
+ # 检查依赖
+ python main.py --check-deps
+
+ # 处理单个视频,生成SRT字幕
python main.py -i video.mp4 -m base
+ # 生成双语字幕(中英文)
+ python main.py -i video.mp4 --bilingual --bilingual-order zh_en
+
+ # 导出多种格式(SRT + ASS + VTT)
+ python main.py -i video.mp4 --formats srt,ass,vtt
+
+ # 字幕硬嵌入到视频中
+ python main.py -i video.mp4 --embed-video
+
+ # 自定义字幕样式(黄色大字体)
+ python main.py -i video.mp4 --style yellow --font-size 48
+
# 处理整个文件夹
python main.py -i ./videos -o ./subtitles --recursive -m small
+
+高级用法示例:
+ # 完整流程:识别 + 双语 + ASS样式 + 硬嵌入
+ python main.py -i video.mp4 --bilingual --formats srt,ass --embed-video --style chinese_large
+
+ # 使用GPU加速硬嵌入
+ python main.py -i video.mp4 --embed-video --embed-gpu
- # 指定语言和模型
- python main.py -i video.mp4 --language zh --model large
+ # 仅导出字幕文件(已有识别结果时使用)
+ python main.py --export-only -s existing.srt --formats ass,vtt
'''
)
# 输入输出参数
- parser.add_argument('-i', '--input', required=True, help='输入文件或文件夹路径')
+ parser.add_argument('-i', '--input', required=False, default=None,
+ help='输入文件或文件夹路径(使用 --check-deps 时可选)')
parser.add_argument('-o', '--output', default=None, help='输出文件或文件夹路径(可选)')
# 模型参数
@@ -452,6 +629,10 @@ def parse_args():
parser.add_argument('--device', default=None, choices=['cpu', 'cuda'],
help='运行设备 (默认: 自动选择)')
+ # FFmpeg 参数
+ parser.add_argument('--ffmpeg-path', default=None,
+ help='FFmpeg可执行文件路径(可选,如未指定则从系统PATH中查找)')
+
# 语言参数
parser.add_argument('-l', '--language', default='auto',
help='语言代码 (默认: auto自动检测,可选: zh, en, ja等)')
@@ -466,6 +647,61 @@ def parse_args():
parser.add_argument('--no-skip-existing', action='store_false', dest='skip_existing',
help='不跳过已存在字幕的文件')
+ # ========== 新增:双语字幕参数 ==========
+ bilingual_group = parser.add_argument_group('双语字幕选项')
+ bilingual_group.add_argument('--bilingual', action='store_true',
+ help='启用双语字幕生成')
+ bilingual_group.add_argument('--source-language', default='zh',
+ help='源语言代码 (默认: zh)')
+ bilingual_group.add_argument('--target-language', default='en',
+ help='目标语言代码 (默认: en)')
+ bilingual_group.add_argument('--bilingual-order', default='zh_en',
+ choices=['zh_en', 'en_zh'],
+ help='双语显示顺序: zh_en(中文在上英文在下) 或 en_zh(英文在上中文在下) (默认: zh_en)')
+
+ # ========== 新增:导出格式参数 ==========
+ export_group = parser.add_argument_group('字幕导出选项')
+ export_group.add_argument('--formats', type=str, default='srt',
+ help='导出格式列表,逗号分隔 (支持: srt, ass, vtt; 默认: srt)')
+ export_group.add_argument('--export-only', action='store_true',
+ help='仅导出字幕模式,配合 -s/--subtitle 使用已有字幕文件')
+ export_group.add_argument('-s', '--subtitle', default=None,
+ help='已有字幕文件路径(用于--export-only模式)')
+ export_group.add_argument('--video-resolution', type=str, default='1920x1080',
+ help='视频分辨率,用于ASS样式 (默认: 1920x1080)')
+
+ # ========== 新增:字幕硬嵌入参数 ==========
+ embed_group = parser.add_argument_group('字幕硬嵌入选项')
+ embed_group.add_argument('--embed-video', action='store_true',
+ help='将字幕硬嵌入到视频中')
+ embed_group.add_argument('--embed-output', default=None,
+ help='硬嵌入输出视频路径(可选)')
+ embed_group.add_argument('--embed-quality', default='high',
+ choices=['fast', 'normal', 'high', 'lossless'],
+ help='硬嵌入视频质量 (默认: high)')
+ embed_group.add_argument('--embed-gpu', action='store_true',
+ help='使用GPU加速硬嵌入(需要NVIDIA显卡和支持NVENC的FFmpeg)')
+
+ # ========== 新增:样式美化参数 ==========
+ style_group = parser.add_argument_group('字幕样式选项')
+ style_group.add_argument('--style', default='default',
+ help='预设样式名称 (default, chinese_large, english, yellow, cyan, top)')
+ style_group.add_argument('--font-name', default=None,
+ help='字幕字体名称 (默认: Microsoft YaHei)')
+ style_group.add_argument('--font-size', type=int, default=None,
+ help='字幕字体大小 (默认: 48)')
+ style_group.add_argument('--font-color', type=str, default=None,
+ help='字体颜色 (十六进制,如: #FFFFFF 表示白色)')
+ style_group.add_argument('--outline-color', type=str, default=None,
+ help='描边颜色 (十六进制,如: #000000 表示黑色)')
+ style_group.add_argument('--outline-width', type=int, default=None,
+ help='描边宽度 (默认: 2)')
+ style_group.add_argument('--position', type=str, default='bottom',
+ choices=['top', 'middle', 'bottom'],
+ help='字幕位置 (默认: bottom)')
+ style_group.add_argument('--margin-v', type=int, default=None,
+ help='垂直边距(像素)(默认: 40)')
+
# 其他参数
parser.add_argument('--log-dir', default=None, help='日志目录(可选)')
parser.add_argument('-v', '--verbose', action='store_true', help='输出详细日志')
@@ -473,92 +709,461 @@ def parse_args():
help='仅检测语言,不生成字幕')
parser.add_argument('--info', action='store_true',
help='显示系统信息')
- parser.add_argument('--ffmpeg-path', default=None,
- help='FFmpeg可执行文件路径(可选,如未指定则从系统PATH中查找)')
+ parser.add_argument('--check-deps', action='store_true',
+ help='检查依赖并退出')
+ parser.add_argument('--list-styles', action='store_true',
+ help='列出所有可用的预设样式')
+ parser.add_argument('--list-formats', action='store_true',
+ help='列出所有支持的导出格式')
return parser.parse_args()
+def handle_exception(e: Exception, verbose: bool = False):
+ """
+ 处理异常,提供友好的错误信息
+
+ Args:
+ e: 异常对象
+ verbose: 是否显示详细堆栈
+ """
+ print("\n" + "=" * 60)
+ print(" ❌ 程序执行失败")
+ print("=" * 60)
+ print(f"\n错误类型: {type(e).__name__}")
+ print(f"错误信息: {e}")
+
+ if verbose:
+ print("\n详细错误信息:")
+ traceback.print_exc()
+
+ print("\n" + "=" * 60)
+ print("如有问题,请检查:")
+ print(" 1. 所有依赖是否已正确安装")
+ print(" 2. 输入文件是否存在且格式正确")
+ print(" 3. 是否有足够的磁盘空间")
+ print("=" * 60 + "\n")
+
+
+def list_preset_styles():
+ """列出所有预设样式"""
+ print("\n" + "=" * 60)
+ print(" 可用的预设样式")
+ print("=" * 60)
+
+ styles = {
+ 'default': '默认样式(微软雅黑,48号字,底部)',
+ 'chinese_large': '中文大字体(微软雅黑,48号字,大描边)',
+ 'english': '英文字体(Arial,28号字)',
+ 'yellow': '黄色字幕(微软雅黑,48号字,黄色)',
+ 'cyan': '青色字幕(微软雅黑,48号字,青色)',
+ 'top': '顶部显示(微软雅黑,40号字,顶部)',
+ }
+
+ for name, desc in styles.items():
+ print(f"\n {name}")
+ print(f" 说明: {desc}")
+
+ print("\n" + "=" * 60)
+ print("使用方式: --style <样式名>")
+ print("自定义覆盖: --font-name, --font-size, --font-color 等参数")
+ print("=" * 60 + "\n")
+
+
+def list_supported_formats():
+ """列出所有支持的导出格式"""
+ print("\n" + "=" * 60)
+ print(" 支持的导出格式")
+ print("=" * 60)
+
+ formats = {
+ 'srt': {
+ 'name': 'SubRip 字幕',
+ 'description': '最通用的字幕格式,纯文本无样式',
+ 'features': ['简单文本', '时间轴', '兼容所有播放器'],
+ },
+ 'ass': {
+ 'name': 'Advanced SubStation Alpha',
+ 'description': '高级字幕格式,支持丰富的样式定义',
+ 'features': ['字体样式', '颜色', '描边', '位置', '动画效果'],
+ },
+ 'vtt': {
+ 'name': 'WebVTT 字幕',
+ 'description': 'Web视频文本轨道,HTML5原生支持',
+ 'features': ['Web兼容', '简单CSS样式', '时间轴'],
+ },
+ }
+
+ for fmt, info in formats.items():
+ print(f"\n {fmt.upper()} - {info['name']}")
+ print(f" 说明: {info['description']}")
+ print(f" 特性: {', '.join(info['features'])}")
+
+ print("\n" + "=" * 60)
+ print("使用方式: --formats <格式1,格式2>")
+ print("示例: --formats srt,ass,vtt")
+ print("=" * 60 + "\n")
+
+
+def parse_export_formats(formats_str: str) -> List[str]:
+ """解析导出格式字符串"""
+ formats = []
+ for fmt in formats_str.split(','):
+ fmt = fmt.strip().lower()
+ if fmt in ['srt', 'ass', 'ssa', 'vtt']:
+ formats.append('ssa' if fmt == 'ass' else fmt)
+ else:
+ print(f"警告: 不支持的格式 '{fmt}',已跳过")
+ return formats if formats else ['srt']
+
+
+def parse_video_resolution(res_str: str) -> Tuple[int, int]:
+ """解析视频分辨率字符串"""
+ try:
+ if 'x' in res_str:
+ w, h = res_str.lower().split('x')
+ return (int(w.strip()), int(h.strip()))
+ except:
+ pass
+ return (1920, 1080)
+
+
def main():
"""
主函数入口
"""
args = parse_args()
+ # 检查依赖
+ if args.check_deps:
+ print("\n" + "=" * 60)
+ print(" VideoSubtitleAI 依赖检查")
+ print("=" * 60 + "\n")
+
+ check_dependencies_and_exit()
+
+ try:
+ from dependency_checker import print_dependency_report
+ print("")
+ print_dependency_report(show_ok=True)
+ except Exception:
+ pass
+
+ sys.exit(0)
+
+ # 列出预设样式
+ if args.list_styles:
+ list_preset_styles()
+ sys.exit(0)
+
+ # 列出支持的格式
+ if args.list_formats:
+ list_supported_formats()
+ sys.exit(0)
+
+ # 仅导出模式:不需要输入视频
+ if args.export_only:
+ if not args.subtitle:
+ print("\n错误: --export-only 模式需要指定 -s/--subtitle 参数")
+ print("使用方式: python main.py --export-only -s existing.srt --formats ass,vtt")
+ sys.exit(1)
+
+ subtitle_path = Path(args.subtitle)
+ if not subtitle_path.exists():
+ print(f"\n错误: 字幕文件不存在: {subtitle_path}")
+ sys.exit(1)
+
+ try:
+ from video_subtitle_enhanced import VideoSubtitleAIEnhanced
+ from subtitle_exporter import SubtitleImporter, SubtitleExporter
+ from subtitle_models import create_default_style
+
+ print(f"\n" + "=" * 60)
+ print(" 📤 仅导出模式")
+ print("=" * 60)
+ print(f"\n 输入字幕: {subtitle_path}")
+
+ formats = parse_export_formats(args.formats)
+ video_res = parse_video_resolution(args.video_resolution)
+
+ print(f" 导出格式: {', '.join([f.upper() for f in formats])}")
+ print(f" 视频分辨率: {video_res[0]}x{video_res[1]}")
+
+ importer = SubtitleImporter()
+ doc = importer.import_from_file(str(subtitle_path))
+
+ output_base = args.output if args.output else str(subtitle_path.parent / subtitle_path.stem)
+
+ enhancer = VideoSubtitleAIEnhanced()
+ preset_styles = enhancer.get_preset_styles()
+ style = preset_styles.get(args.style, create_default_style())
+
+ if args.font_name:
+ style.font_name = args.font_name
+ if args.font_size:
+ style.font_size = args.font_size
+ if args.font_color:
+ from subtitle_exporter import hex_to_ass_color
+ style.primary_color = hex_to_ass_color(args.font_color)
+ if args.outline_color:
+ from subtitle_exporter import hex_to_ass_color
+ style.outline_color = hex_to_ass_color(args.outline_color)
+ if args.outline_width is not None:
+ style.outline = args.outline_width
+ if args.margin_v is not None:
+ style.margin_v = args.margin_v
+
+ if args.position == 'top':
+ style.alignment = 8
+ elif args.position == 'middle':
+ style.alignment = 5
+ else:
+ style.alignment = 2
+
+ output_files = enhancer.export_to_multiple_formats(
+ document=doc,
+ base_output_path=output_base,
+ formats=formats,
+ default_style=style,
+ video_resolution=video_res,
+ )
+
+ print(f"\n ✅ 导出完成!")
+ print(f"\n 生成的文件:")
+ for fmt, path in output_files.items():
+ print(f" - {fmt.upper()}: {path}")
+
+ print("\n" + "=" * 60 + "\n")
+ sys.exit(0)
+
+ except Exception as e:
+ handle_exception(e, args.verbose)
+ sys.exit(1)
+
+ # 检查必需参数
+ if args.input is None:
+ print("\n错误: 缺少必需参数 -i/--input")
+ print("使用 --help 查看帮助信息")
+ sys.exit(1)
+
# 检查输入
input_path = Path(args.input)
if not input_path.exists():
- print(f"错误: 输入路径不存在: {input_path}")
+ print(f"\n错误: 输入路径不存在: {input_path}")
sys.exit(1)
# 确定任务类型
task = 'translate' if args.translate else 'transcribe'
- # 创建实例
- ai = VideoSubtitleAI(
- model_name=args.model,
- device=args.device,
- model_dir=args.model_dir,
- log_dir=args.log_dir,
- verbose=args.verbose,
- ffmpeg_path=args.ffmpeg_path,
- )
+ # 检查模块是否加载
+ if not MODULES_LOADED:
+ print("\n错误: 核心模块加载失败,请确保所有依赖已正确安装。")
+ print("运行 --check-deps 检查依赖状态。")
+ sys.exit(1)
- # 显示信息
- if args.info:
- ai.initialize()
- info = ai.get_info()
- print("\n" + "=" * 60)
- print(" 系统信息")
- print("=" * 60)
- print(f" 版本: {info['version']}")
- print(f" 模型: {info['model_name']}")
- if 'device_info' in info:
- dev = info['device_info']
- print(f" 设备: {dev['current_device']}")
- if dev['gpu_available']:
- print(f" GPU: {dev['gpu_name']}")
- print("=" * 60 + "\n")
- return
+ # 解析导出格式
+ export_formats = parse_export_formats(args.formats)
+ video_resolution = parse_video_resolution(args.video_resolution)
- # 仅检测语言
- if args.detect_language_only:
+ try:
+ # 创建增强版实例
+ from video_subtitle_enhanced import VideoSubtitleAIEnhanced
+ from subtitle_models import create_default_style
+
+ # 创建基础实例
+ ai = VideoSubtitleAI(
+ model_name=args.model,
+ device=args.device,
+ model_dir=args.model_dir,
+ log_dir=args.log_dir,
+ verbose=args.verbose,
+ ffmpeg_path=args.ffmpeg_path,
+ )
+
+ # 显示信息
+ if args.info:
+ ai.initialize()
+ info = ai.get_info()
+ print("\n" + "=" * 60)
+ print(" 系统信息")
+ print("=" * 60)
+ print(f" 版本: {info['version']}")
+ print(f" 模型: {info['model_name']}")
+ if 'device_info' in info:
+ dev = info['device_info']
+ print(f" 设备: {dev['current_device']}")
+ if dev['gpu_available']:
+ print(f" GPU: {dev['gpu_name']}")
+ if 'ffmpeg_info' in info:
+ ffmpeg = info['ffmpeg_info']
+ print(f" FFmpeg: {ffmpeg.get('version', 'unknown')}")
+ print(f" FFmpeg路径: {ffmpeg.get('ffmpeg_path', 'unknown')}")
+ print("=" * 60 + "\n")
+ return
+
+ # 仅检测语言
+ if args.detect_language_only:
+ ai.initialize()
+ if input_path.is_file():
+ result = ai.detect_language(str(input_path))
+ print(f"\n检测结果:")
+ print(f" 语言: {result['language_name']}")
+ print(f" 代码: {result['detected_language']}")
+ print(f" 置信度: {result['confidence']:.2%}")
+ print("\nTop 5 可能语言:")
+ for i, lang in enumerate(result['top_languages'], 1):
+ print(f" {i}. {lang['language_name']}: {lang['confidence']:.2%}")
+ else:
+ print("错误: --detect-language-only 仅支持单个文件")
+ sys.exit(1)
+ return
+
+ # 初始化并加载模型
ai.initialize()
- if input_path.is_file():
- result = ai.detect_language(str(input_path))
- print(f"\n检测结果:")
- print(f" 语言: {result['language_name']}")
- print(f" 代码: {result['detected_language']}")
- print(f" 置信度: {result['confidence']:.2%}")
- print("\nTop 5 可能语言:")
- for i, lang in enumerate(result['top_languages'], 1):
- print(f" {i}. {lang['language_name']}: {lang['confidence']:.2%}")
+ ai.load_model()
+
+ # 获取样式
+ enhancer = VideoSubtitleAIEnhanced(
+ base_recognizer=ai.speech_recognizer,
+ whisper_model=ai.speech_recognizer.model if ai.speech_recognizer else None,
+ ffmpeg_path=args.ffmpeg_path,
+ verbose=args.verbose,
+ )
+
+ preset_styles = enhancer.get_preset_styles()
+ style = preset_styles.get(args.style, create_default_style())
+
+ # 覆盖样式参数
+ if args.font_name:
+ style.font_name = args.font_name
+ if args.font_size:
+ style.font_size = args.font_size
+ if args.font_color:
+ from subtitle_exporter import hex_to_ass_color
+ style.primary_color = hex_to_ass_color(args.font_color)
+ if args.outline_color:
+ from subtitle_exporter import hex_to_ass_color
+ style.outline_color = hex_to_ass_color(args.outline_color)
+ if args.outline_width is not None:
+ style.outline = args.outline_width
+ if args.margin_v is not None:
+ style.margin_v = args.margin_v
+
+ if args.position == 'top':
+ style.alignment = 8
+ elif args.position == 'middle':
+ style.alignment = 5
else:
- print("错误: --detect-language-only 仅支持单个文件")
- sys.exit(1)
- return
-
- # 初始化并加载模型
- ai.initialize()
- ai.load_model()
-
- # 处理
- try:
+ style.alignment = 2
+
+ # 处理单个文件
if input_path.is_file():
- # 单个文件
- result = ai.process_single_file(
+ print(f"\n" + "=" * 60)
+ print(" 🎬 处理单个文件")
+ print("=" * 60)
+ print(f"\n 输入文件: {input_path}")
+ print(f" 模型: {args.model}")
+ print(f" 语言: {args.language}")
+ print(f" 任务: {'翻译' if task == 'translate' else '转录'}")
+ print(f" 导出格式: {', '.join([f.upper() for f in export_formats])}")
+ if args.bilingual:
+ print(f" 双语字幕: 是 ({args.bilingual_order})")
+ print(f" 源语言: {args.source_language}")
+ print(f" 目标语言: {args.target_language}")
+ if args.embed_video:
+ print(f" 硬嵌入: 是")
+ print(f" 质量: {args.embed_quality}")
+ print(f" GPU加速: {'是' if args.embed_gpu else '否'}")
+ print(f" 样式: {args.style}")
+
+ # 基础处理
+ base_result = ai.process_single_file(
input_path=str(input_path),
output_path=args.output,
language=args.language,
task=task,
)
- print(f"\n处理完成!")
- print(f" 输入: {result['input_path']}")
- print(f" 输出: {result['output_path']}")
- print(f" 语言: {result['language_name']}")
- print(f" 字幕段数: {result['segment_count']}")
+
+ # 增强处理
+ print(f"\n 📝 增强处理中...")
+
+ # 获取识别结果
+ temp_audio_path = None
+ is_temp = False
+ try:
+ temp_audio_path, is_temp = ai.audio_processor.process_media_file(str(input_path))
+ recognition_result = ai.speech_recognizer.recognize(
+ audio_path=temp_audio_path,
+ language=args.language,
+ task=task,
+ verbose=args.verbose,
+ )
+ finally:
+ if is_temp and temp_audio_path:
+ ai.audio_processor.cleanup_temp_file(temp_audio_path)
+
+ # 增强处理
+ enhanced_result = enhancer.process_single_file_enhanced(
+ recognition_result=recognition_result,
+ input_path=str(input_path),
+ output_path=args.output,
+ enable_bilingual=args.bilingual,
+ source_language=args.source_language,
+ target_language=args.target_language,
+ bilingual_order=args.bilingual_order,
+ export_formats=export_formats,
+ enable_embedding=args.embed_video,
+ embedding_output_path=args.embed_output,
+ style=style,
+ video_quality=args.embed_quality,
+ use_gpu_for_embedding=args.embed_gpu,
+ )
+
+ # 输出结果
+ print(f"\n ✅ 处理完成!")
+ print(f"\n 处理结果:")
+ print(f" - 检测语言: {base_result.get('language_name', '未知')}")
+ print(f" - 字幕段数: {base_result.get('segment_count', 0)}")
+ print(f" - 总字数: {base_result.get('total_characters', 0)}")
+
+ if enhanced_result.language_detection.get('is_mixed'):
+ langs = enhanced_result.language_detection.get('languages', [])
+ print(f"\n ⚠️ 检测到多语言混合场景: {', '.join(langs)}")
+
+ if enhanced_result.output_files:
+ print(f"\n 📂 生成的文件:")
+ for fmt, path in enhanced_result.output_files.items():
+ if fmt == 'embedded_video':
+ print(f" - [视频] 硬嵌入视频: {path}")
+ else:
+ print(f" - [字幕] {fmt.upper()}: {path}")
+
+ if enhanced_result.warnings:
+ print(f"\n ⚠️ 警告:")
+ for w in enhanced_result.warnings:
+ print(f" - {w}")
+
+ if enhanced_result.errors:
+ print(f"\n ❌ 错误:")
+ for e in enhanced_result.errors:
+ print(f" - {e}")
+
+ print("\n" + "=" * 60 + "\n")
+
else:
- # 文件夹
+ # 文件夹批量处理
+ print(f"\n" + "=" * 60)
+ print(" 📁 批量处理文件夹")
+ print("=" * 60)
+ print(f"\n 输入文件夹: {input_path}")
+ print(f" 模型: {args.model}")
+ print(f" 语言: {args.language}")
+ print(f" 递归处理: {'是' if args.recursive else '否'}")
+ print(f" 跳过已有: {'是' if args.skip_existing else '否'}")
+ print(f" 导出格式: {', '.join([f.upper() for f in export_formats])}")
+ if args.bilingual:
+ print(f" 双语字幕: 是 ({args.bilingual_order})")
+
result = ai.process_folder(
input_folder=str(input_path),
output_folder=args.output,
@@ -567,22 +1172,40 @@ def main():
task=task,
skip_existing=args.skip_existing,
)
- print(f"\n批量处理完成!")
- print(f" 总文件数: {result['total_files']}")
- print(f" 成功: {result['completed_files']}")
- print(f" 失败: {result['failed_files']}")
+
+ print(f"\n" + "=" * 60)
+ print(" ✅ 批量处理完成!")
+ print("=" * 60)
+ print(f"\n 总文件数: {result['total_files']}")
+ print(f" 成功处理: {result['completed_files']}")
+ print(f" 处理失败: {result['failed_files']}")
+ print(f" 跳过: {result['skipped_files']}")
print(f" 成功率: {result['success_rate']}")
- print(f" 耗时: {result['elapsed_time']}")
+ print(f" 总耗时: {result['elapsed_time']}")
+
+ if result['errors']:
+ print(f"\n 失败的文件:")
+ for err in result['errors']:
+ print(f" - {Path(err['input_path']).name}: {err.get('error', '未知错误')}")
+ print("\n" + "=" * 60)
+ print(" 注意: 增强版功能(双语字幕、多格式导出、硬嵌入)")
+ print(" 目前仅支持单文件处理模式。")
+ print("=" * 60 + "\n")
+
+ except FFmpegNotFoundError as e:
+ sys.exit(1)
except Exception as e:
- print(f"\n错误: {e}")
- import traceback
- traceback.print_exc()
+ handle_exception(e, args.verbose)
sys.exit(1)
finally:
# 卸载模型
- ai.unload_model()
+ try:
+ if 'ai' in locals():
+ ai.unload_model()
+ except Exception:
+ pass
if __name__ == '__main__':
diff --git a/requirements.txt b/requirements.txt
index 96c87d8..8f73198 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,25 +1,150 @@
# VideoSubtitleAI - 本地离线音视频转字幕工具
# 依赖包列表
+#
+# ⚠️ 重要提示:在安装这些包之前,请先安装 FFmpeg
+# Windows: winget install Gyan.FFmpeg
+# macOS: brew install ffmpeg
+# Linux: sudo apt install ffmpeg
+#
+# 快速安装所有依赖:
+# pip install -r requirements.txt
+#
-# 核心依赖
+# ============================================================================
+# 必需依赖(必须安装)
+# ============================================================================
+
+# OpenAI Whisper - 核心语音识别模型
openai-whisper>=20231117
-# PyTorch (Whisper需要,根据系统环境选择合适版本)
-# 安装GPU版本 (推荐,速度快):
-# pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
+# PyTorch - Whisper 依赖的深度学习框架
+# ⚠️ 强烈建议安装 CUDA 版本以获得 GPU 加速
+#
+# 安装 GPU 版本 (推荐,速度提升 10-100 倍):
+# pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
+#
+# 或者安装 CUDA 12.1 版本:
+# pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
#
-# 或者安装CPU版本:
-# pip3 install torch torchvision torchaudio
+# 安装 CPU 版本 (速度较慢):
+# pip3 install torch torchvision torchaudio
#
-# 注意: 以下仅为最小依赖,实际安装时请根据需求安装完整版PyTorch
+# 注意: 以下仅为最小依赖,实际使用时请安装完整版
torch>=2.0.0
-# 音频处理 (可选,FFmpeg通常需要单独安装)
-# ffmpeg-python>=0.2.0
-# pydub>=0.25.1
+# NumPy - 数值计算库
+numpy>=1.24.0
+
+# ============================================================================
+# 可选依赖(推荐安装)
+# ============================================================================
# 进度条显示
tqdm>=4.66.0
-# 其他工具
-numpy>=1.24.0
+# 颜色输出(用于更友好的终端显示)
+colorama>=0.4.6
+
+# 版本比较(用于依赖检查)
+packaging>=23.0
+
+# ============================================================================
+# 双语字幕翻译功能可选依赖
+# ============================================================================
+
+# 在线翻译API支持
+# requests>=2.28.0 # 用于百度/Google/DeepL翻译API
+
+# 本地翻译模型(Helsinki-NLP OPUS-MT)
+# transformers>=4.30.0
+# sentencepiece>=0.1.99
+# torch>=2.0.0 # 已在必需依赖中
+
+# ============================================================================
+# Web版依赖 (用于运行 Web 界面)
+# ============================================================================
+
+# FastAPI - 现代异步 Web 框架
+fastapi>=0.104.0
+
+# Uvicorn - ASGI 服务器
+uvicorn[standard]>=0.24.0
+
+# Jinja2 - 模板引擎
+jinja2>=3.1.0
+
+# Python-multipart - 用于文件上传
+python-multipart>=0.0.6
+
+# ============================================================================
+# 增强功能说明
+# ============================================================================
+#
+# 新增功能模块:
+# 1. subtitle_models.py - 统一数据模型(字幕段、样式、文档)
+# 2. subtitle_translator.py - 翻译模块(支持多后端)
+# 3. subtitle_editor.py - 字幕编辑(撤销/重做、时间轴调整)
+# 4. subtitle_exporter.py - 多格式导出(SRT/ASS/VTT)
+# 5. subtitle_embedder.py - 字幕硬嵌入(FFmpeg烧录)
+# 6. language_detector.py - 语言检测优化(多语言混合)
+# 7. video_subtitle_enhanced.py - 增强版主模块
+#
+# 使用示例:
+# - 生成双语字幕:python main.py -i video.mp4 --bilingual
+# - 导出多种格式:python main.py -i video.mp4 --formats srt,ass,vtt
+# - 字幕硬嵌入:python main.py -i video.mp4 --embed-video
+# - 自定义样式:python main.py -i video.mp4 --style yellow --font-size 48
+# - 仅导出模式:python main.py --export-only -s existing.srt --formats ass
+#
+
+# ============================================================================
+# 系统工具(必须单独安装)
+# ============================================================================
+#
+# FFmpeg - 音视频处理工具(必需)
+#
+# Windows:
+# 方法1: 使用 winget (推荐)
+# winget install Gyan.FFmpeg
+#
+# 方法2: 使用 Chocolatey
+# choco install ffmpeg
+#
+# 方法3: 手动安装
+# 1. 访问 https://www.gyan.dev/ffmpeg/builds/
+# 2. 下载 ffmpeg-release-full.7z
+# 3. 解压到 C:\ffmpeg
+# 4. 将 C:\ffmpeg\bin 添加到系统 PATH
+# 5. 重启命令行/IDE
+#
+# macOS:
+# brew install ffmpeg
+#
+# Linux (Ubuntu/Debian):
+# sudo apt update
+# sudo apt install ffmpeg
+#
+# Linux (Fedora):
+# sudo dnf install ffmpeg
+#
+# ============================================================================
+
+# ============================================================================
+# 安装步骤总结
+# ============================================================================
+#
+# 1. 安装 FFmpeg (必需)
+# Windows: winget install Gyan.FFmpeg
+# macOS: brew install ffmpeg
+# Linux: sudo apt install ffmpeg
+#
+# 2. 安装 PyTorch (推荐 GPU 版本)
+# pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
+#
+# 3. 安装其他依赖
+# pip install -r requirements.txt
+#
+# 4. 验证安装
+# python main.py --check-deps
+#
+# ============================================================================
diff --git a/run_web.py b/run_web.py
new file mode 100644
index 0000000..d5f3b2b
--- /dev/null
+++ b/run_web.py
@@ -0,0 +1,35 @@
+"""
+VideoSubtitleAI Web 版启动脚本
+一键启动 Web 界面
+"""
+import uvicorn
+import sys
+from pathlib import Path
+
+web_dir = Path(__file__).parent
+sys.path.insert(0, str(web_dir.parent))
+
+
+def main():
+ print("=" * 60)
+ print(" VideoSubtitleAI Web 版启动中...")
+ print("=" * 60)
+ print()
+ print(" 请确保已安装所有依赖:")
+ print(" pip install fastapi uvicorn jinja2 python-multipart")
+ print()
+ print(" 访问地址: http://localhost:8000")
+ print("=" * 60)
+ print()
+
+ uvicorn.run(
+ "web.server:app",
+ host="0.0.0.0",
+ port=8000,
+ reload=True,
+ log_level="info"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/subtitle_editor.py b/subtitle_editor.py
new file mode 100644
index 0000000..b1cb98c
--- /dev/null
+++ b/subtitle_editor.py
@@ -0,0 +1,700 @@
+"""
+字幕编辑模块
+提供字幕时间轴调整、文本修改、分句合并/拆分等编辑功能
+"""
+
+import logging
+from typing import List, Dict, Any, Optional, Tuple, Callable
+from dataclasses import dataclass, field
+from enum import Enum
+from pathlib import Path
+import re
+
+from subtitle_models import SubtitleSegment, SubtitleDocument, SubtitleStyle
+
+logger = logging.getLogger(__name__)
+
+
+class EditOperation(Enum):
+ """编辑操作类型枚举"""
+ ADD = "add"
+ DELETE = "delete"
+ MODIFY = "modify"
+ SPLIT = "split"
+ MERGE = "merge"
+ SHIFT = "shift"
+ STYLE = "style"
+
+
+@dataclass
+class EditHistoryItem:
+ """编辑历史记录项"""
+ operation: EditOperation
+ index: int
+ old_data: Optional[Dict[str, Any]] = None
+ new_data: Optional[Dict[str, Any]] = None
+ description: str = ""
+ timestamp: float = 0.0
+
+
+class SubtitleEditor:
+ """
+ 字幕编辑器类
+ 提供完整的字幕编辑功能:
+ - 时间轴调整(整体偏移、单个调整)
+ - 文本修改
+ - 分句合并/拆分
+ - 撤销/重做
+ """
+
+ def __init__(self, document: Optional[SubtitleDocument] = None):
+ self.document = document or SubtitleDocument()
+ self._history: List[EditHistoryItem] = []
+ self._history_index: int = -1
+ self._max_history: int = 100
+
+ def load_document(self, document: SubtitleDocument):
+ self.document = document
+ self._history.clear()
+ self._history_index = -1
+
+ def load_from_segments(self, segments: List[Any]):
+ from subtitle_models import SubtitleSegment
+
+ doc_segments = []
+ for seg in segments:
+ if isinstance(seg, dict):
+ doc_segments.append(SubtitleSegment.from_dict(seg))
+ else:
+ doc_segments.append(seg)
+
+ self.document = SubtitleDocument(segments=doc_segments)
+ self._history.clear()
+ self._history_index = -1
+
+ def load_from_srt(self, srt_path: str, encoding: str = 'utf-8'):
+ segments = self._parse_srt_file(srt_path, encoding)
+ self.load_from_segments(segments)
+
+ def _parse_srt_file(self, srt_path: str, encoding: str = 'utf-8') -> List[SubtitleSegment]:
+ srt_path = Path(srt_path).resolve()
+ if not srt_path.exists():
+ raise FileNotFoundError(f"SRT文件不存在: {srt_path}")
+
+ segments = []
+
+ with open(srt_path, 'r', encoding=encoding) as f:
+ content = f.read()
+
+ blocks = re.split(r'\n\n+', content.strip())
+
+ for block in blocks:
+ lines = block.strip().split('\n')
+ if len(lines) < 2:
+ continue
+
+ seg_id = len(segments)
+ time_line_idx = 0
+
+ if lines[0].strip().isdigit():
+ seg_id = int(lines[0].strip()) - 1
+ time_line_idx = 1
+
+ if time_line_idx >= len(lines):
+ continue
+
+ time_line = lines[time_line_idx]
+ time_match = re.match(
+ r'(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{3})',
+ time_line
+ )
+
+ if time_match:
+ start_time = self._parse_time_str(time_match.group(1))
+ end_time = self._parse_time_str(time_match.group(2))
+
+ text_lines = lines[time_line_idx + 1:]
+ text = '\n'.join(text_lines).strip()
+
+ segment = SubtitleSegment(
+ id=seg_id,
+ start=start_time,
+ end=end_time,
+ text=text,
+ )
+ segments.append(segment)
+
+ logger.info(f"成功解析SRT文件: {srt_path},共 {len(segments)} 段")
+ return segments
+
+ def _parse_time_str(self, time_str: str) -> float:
+ time_str = time_str.strip()
+ time_str = time_str.replace(',', '.')
+
+ parts = time_str.split(':')
+ if len(parts) == 3:
+ hours = int(parts[0])
+ minutes = int(parts[1])
+ seconds = float(parts[2])
+ return hours * 3600 + minutes * 60 + seconds
+ elif len(parts) == 2:
+ minutes = int(parts[0])
+ seconds = float(parts[1])
+ return minutes * 60 + seconds
+ else:
+ return float(time_str)
+
+ def _record_history(
+ self,
+ operation: EditOperation,
+ index: int,
+ old_data: Optional[Dict[str, Any]] = None,
+ new_data: Optional[Dict[str, Any]] = None,
+ description: str = "",
+ ):
+ if self._history_index < len(self._history) - 1:
+ self._history = self._history[:self._history_index + 1]
+
+ import time
+ item = EditHistoryItem(
+ operation=operation,
+ index=index,
+ old_data=old_data,
+ new_data=new_data,
+ description=description,
+ timestamp=time.time(),
+ )
+
+ self._history.append(item)
+ self._history_index = len(self._history) - 1
+
+ if len(self._history) > self._max_history:
+ self._history = self._history[-self._max_history:]
+ self._history_index = self._max_history - 1
+
+ def undo(self) -> bool:
+ if self._history_index < 0:
+ logger.warning("没有可撤销的操作")
+ return False
+
+ item = self._history[self._history_index]
+
+ if item.operation == EditOperation.ADD:
+ if 0 <= item.index < len(self.document.segments):
+ self.document.segments.pop(item.index)
+ self.document._reindex_segments()
+
+ elif item.operation == EditOperation.DELETE:
+ if item.old_data:
+ from subtitle_models import SubtitleSegment
+ seg = SubtitleSegment.from_dict(item.old_data)
+ self.document.segments.insert(item.index, seg)
+ self.document._reindex_segments()
+
+ elif item.operation == EditOperation.MODIFY:
+ if item.old_data and 0 <= item.index < len(self.document.segments):
+ from subtitle_models import SubtitleSegment
+ seg = SubtitleSegment.from_dict(item.old_data)
+ self.document.segments[item.index] = seg
+
+ elif item.operation == EditOperation.SPLIT:
+ if 0 <= item.index < len(self.document.segments) - 1:
+ self.document.merge_adjacent_segments(item.index, gap_threshold=0)
+
+ elif item.operation == EditOperation.MERGE:
+ if item.old_data and 'segments' in item.old_data:
+ from subtitle_models import SubtitleSegment
+ self.document.segments.pop(item.index)
+ for i, seg_data in enumerate(item.old_data['segments']):
+ seg = SubtitleSegment.from_dict(seg_data)
+ self.document.segments.insert(item.index + i, seg)
+ self.document._reindex_segments()
+
+ elif item.operation == EditOperation.SHIFT:
+ if item.old_data and 'shift_seconds' in item.old_data:
+ shift_back = -item.old_data['shift_seconds']
+ self.document.shift_all_timings(shift_back)
+
+ self._history_index -= 1
+ logger.info(f"已撤销: {item.description}")
+ return True
+
+ def redo(self) -> bool:
+ if self._history_index >= len(self._history) - 1:
+ logger.warning("没有可重做的操作")
+ return False
+
+ self._history_index += 1
+ item = self._history[self._history_index]
+
+ logger.info(f"已重做: {item.description}")
+ return True
+
+ @property
+ def can_undo(self) -> bool:
+ return self._history_index >= 0
+
+ @property
+ def can_redo(self) -> bool:
+ return self._history_index < len(self._history) - 1
+
+ def get_segment(self, index: int) -> Optional[SubtitleSegment]:
+ return self.document.get_segment(index)
+
+ def update_segment_text(self, index: int, new_text: str) -> bool:
+ if index < 0 or index >= len(self.document.segments):
+ return False
+
+ segment = self.document.segments[index]
+ old_data = segment.to_dict()
+
+ segment.text = new_text
+
+ self._record_history(
+ operation=EditOperation.MODIFY,
+ index=index,
+ old_data=old_data,
+ new_data=segment.to_dict(),
+ description=f"修改第 {index + 1} 段文本",
+ )
+
+ logger.info(f"已修改第 {index + 1} 段文本")
+ return True
+
+ def update_segment_bilingual_text(
+ self,
+ index: int,
+ text_zh: Optional[str] = None,
+ text_en: Optional[str] = None,
+ ) -> bool:
+ if index < 0 or index >= len(self.document.segments):
+ return False
+
+ segment = self.document.segments[index]
+ old_data = segment.to_dict()
+
+ if text_zh is not None:
+ segment.text_zh = text_zh
+ if text_en is not None:
+ segment.text_en = text_en
+
+ segment.is_bilingual = True
+
+ self._record_history(
+ operation=EditOperation.MODIFY,
+ index=index,
+ old_data=old_data,
+ new_data=segment.to_dict(),
+ description=f"修改第 {index + 1} 段双语文本",
+ )
+
+ return True
+
+ def update_segment_time(self, index: int, start: Optional[float] = None, end: Optional[float] = None) -> bool:
+ if index < 0 or index >= len(self.document.segments):
+ return False
+
+ segment = self.document.segments[index]
+ old_data = segment.to_dict()
+
+ if start is not None:
+ segment.start = max(0.0, start)
+ if end is not None:
+ segment.end = max(segment.start, end)
+
+ self._record_history(
+ operation=EditOperation.MODIFY,
+ index=index,
+ old_data=old_data,
+ new_data=segment.to_dict(),
+ description=f"调整第 {index + 1} 段时间轴",
+ )
+
+ logger.info(f"已调整第 {index + 1} 段时间轴: {segment.start:.3f} -> {segment.end:.3f}")
+ return True
+
+ def shift_all_segments(self, seconds: float) -> int:
+ if seconds == 0:
+ return 0
+
+ old_data = {'shift_seconds': seconds}
+
+ self.document.shift_all_timings(seconds)
+
+ count = len(self.document.segments)
+
+ self._record_history(
+ operation=EditOperation.SHIFT,
+ index=0,
+ old_data=old_data,
+ description=f"整体偏移时间轴: {seconds:+.3f} 秒",
+ )
+
+ logger.info(f"已整体偏移 {count} 个字幕段: {seconds:+.3f} 秒")
+ return count
+
+ def shift_segments_range(self, start_index: int, end_index: int, seconds: float) -> int:
+ if seconds == 0:
+ return 0
+
+ if start_index < 0 or end_index >= len(self.document.segments) or start_index > end_index:
+ return 0
+
+ old_data = {
+ 'segments': [self.document.segments[i].to_dict() for i in range(start_index, end_index + 1)]
+ }
+
+ count = 0
+ for i in range(start_index, end_index + 1):
+ seg = self.document.segments[i]
+ seg.start = max(0.0, seg.start + seconds)
+ seg.end = max(seg.start, seg.end + seconds)
+ count += 1
+
+ self._record_history(
+ operation=EditOperation.SHIFT,
+ index=start_index,
+ old_data=old_data,
+ description=f"偏移第 {start_index + 1}-{end_index + 1} 段时间轴: {seconds:+.3f} 秒",
+ )
+
+ return count
+
+ def split_segment(self, index: int, split_seconds: float) -> Tuple[bool, int]:
+ if index < 0 or index >= len(self.document.segments):
+ return False, -1
+
+ old_data = self.document.segments[index].to_dict()
+
+ success, new_index = self.document.split_segment(index, split_seconds)
+
+ if success:
+ self._record_history(
+ operation=EditOperation.SPLIT,
+ index=index,
+ old_data=old_data,
+ description=f"拆分第 {index + 1} 段(在 {split_seconds:.3f} 秒处)",
+ )
+ logger.info(f"已拆分第 {index + 1} 段,新段索引: {new_index + 1}")
+
+ return success, new_index
+
+ def split_segment_at_ratio(self, index: int, ratio: float = 0.5) -> Tuple[bool, int]:
+ if index < 0 or index >= len(self.document.segments):
+ return False, -1
+
+ segment = self.document.segments[index]
+ duration = segment.end - segment.start
+ split_seconds = segment.start + duration * ratio
+
+ return self.split_segment(index, split_seconds)
+
+ def merge_adjacent_segments(self, index: int, gap_threshold: float = 0.5) -> bool:
+ if index < 0 or index >= len(self.document.segments) - 1:
+ return False
+
+ old_data = {
+ 'segments': [
+ self.document.segments[index].to_dict(),
+ self.document.segments[index + 1].to_dict(),
+ ]
+ }
+
+ success = self.document.merge_adjacent_segments(index, gap_threshold)
+
+ if success:
+ self._record_history(
+ operation=EditOperation.MERGE,
+ index=index,
+ old_data=old_data,
+ description=f"合并第 {index + 1} 和 {index + 2} 段",
+ )
+ logger.info(f"已合并第 {index + 1} 和 {index + 2} 段")
+
+ return success
+
+ def merge_multiple_segments(self, start_index: int, end_index: int) -> bool:
+ if start_index < 0 or end_index >= len(self.document.segments) or start_index >= end_index:
+ return False
+
+ old_data = {
+ 'segments': [self.document.segments[i].to_dict() for i in range(start_index, end_index + 1)]
+ }
+
+ merged_text = []
+ merged_text_zh = []
+ merged_text_en = []
+ start_time = self.document.segments[start_index].start
+ end_time = self.document.segments[end_index].end
+ is_bilingual = any(seg.is_bilingual for seg in self.document.segments[start_index:end_index + 1])
+
+ for i in range(start_index, end_index + 1):
+ seg = self.document.segments[i]
+ merged_text.append(seg.text)
+ if seg.is_bilingual:
+ merged_text_zh.append(seg.text_zh)
+ merged_text_en.append(seg.text_en)
+
+ for _ in range(start_index, end_index):
+ self.document.segments.pop(start_index)
+
+ merged_seg = self.document.segments[start_index]
+ merged_seg.start = start_time
+ merged_seg.end = end_time
+ merged_seg.text = ' '.join(merged_text)
+ if is_bilingual:
+ merged_seg.text_zh = ' '.join(merged_text_zh)
+ merged_seg.text_en = ' '.join(merged_text_en)
+ merged_seg.is_bilingual = True
+
+ self.document._reindex_segments()
+
+ self._record_history(
+ operation=EditOperation.MERGE,
+ index=start_index,
+ old_data=old_data,
+ description=f"合并第 {start_index + 1}-{end_index + 1} 段",
+ )
+
+ logger.info(f"已合并第 {start_index + 1}-{end_index + 1} 段")
+ return True
+
+ def add_segment(self, index: int, segment: SubtitleSegment) -> bool:
+ success = self.document.insert_segment(index, segment)
+
+ if success:
+ self._record_history(
+ operation=EditOperation.ADD,
+ index=index,
+ new_data=segment.to_dict(),
+ description=f"在位置 {index + 1} 插入新段",
+ )
+ logger.info(f"已在位置 {index + 1} 插入新段")
+
+ return success
+
+ def delete_segment(self, index: int) -> bool:
+ if index < 0 or index >= len(self.document.segments):
+ return False
+
+ old_data = self.document.segments[index].to_dict()
+
+ success = self.document.remove_segment(index)
+
+ if success:
+ self._record_history(
+ operation=EditOperation.DELETE,
+ index=index,
+ old_data=old_data,
+ description=f"删除第 {index + 1} 段",
+ )
+ logger.info(f"已删除第 {index + 1} 段")
+
+ return success
+
+ def delete_segments_range(self, start_index: int, end_index: int) -> int:
+ if start_index < 0 or end_index >= len(self.document.segments) or start_index > end_index:
+ return 0
+
+ old_data = {
+ 'segments': [self.document.segments[i].to_dict() for i in range(start_index, end_index + 1)]
+ }
+
+ count = 0
+ for _ in range(start_index, end_index + 1):
+ if start_index < len(self.document.segments):
+ self.document.segments.pop(start_index)
+ count += 1
+
+ self.document._reindex_segments()
+
+ self._record_history(
+ operation=EditOperation.DELETE,
+ index=start_index,
+ old_data=old_data,
+ description=f"删除第 {start_index + 1}-{end_index + 1} 段",
+ )
+
+ logger.info(f"已删除 {count} 个段")
+ return count
+
+ def find_segments_by_text(self, keyword: str, case_sensitive: bool = False) -> List[int]:
+ results = []
+
+ for idx, seg in enumerate(self.document.segments):
+ text = seg.text
+ if not case_sensitive:
+ text = text.lower()
+ keyword = keyword.lower()
+
+ if keyword in text:
+ results.append(idx)
+ continue
+
+ if seg.is_bilingual:
+ text_zh = seg.text_zh
+ text_en = seg.text_en
+ if not case_sensitive:
+ text_zh = text_zh.lower()
+ text_en = text_en.lower()
+
+ if keyword in text_zh or keyword in text_en:
+ results.append(idx)
+
+ return results
+
+ def replace_text(self, old_text: str, new_text: str, case_sensitive: bool = False) -> int:
+ count = 0
+
+ for idx, seg in enumerate(self.document.segments):
+ old_data = seg.to_dict()
+ modified = False
+
+ text = seg.text
+ if case_sensitive:
+ if old_text in text:
+ seg.text = text.replace(old_text, new_text)
+ modified = True
+ else:
+ import re
+ pattern = re.compile(re.escape(old_text), re.IGNORECASE)
+ if pattern.search(text):
+ seg.text = pattern.sub(new_text, text)
+ modified = True
+
+ if seg.is_bilingual:
+ if case_sensitive:
+ if old_text in seg.text_zh:
+ seg.text_zh = seg.text_zh.replace(old_text, new_text)
+ modified = True
+ if old_text in seg.text_en:
+ seg.text_en = seg.text_en.replace(old_text, new_text)
+ modified = True
+ else:
+ if pattern.search(seg.text_zh):
+ seg.text_zh = pattern.sub(new_text, seg.text_zh)
+ modified = True
+ if pattern.search(seg.text_en):
+ seg.text_en = pattern.sub(new_text, seg.text_en)
+ modified = True
+
+ if modified:
+ count += 1
+ self._record_history(
+ operation=EditOperation.MODIFY,
+ index=idx,
+ old_data=old_data,
+ new_data=seg.to_dict(),
+ description=f"替换第 {idx + 1} 段文本: '{old_text}' -> '{new_text}'",
+ )
+
+ logger.info(f"已替换 {count} 个段中的文本")
+ return count
+
+ def adjust_segment_gaps(self, min_gap: float = 0.1, max_gap: float = 2.0) -> int:
+ count = 0
+
+ for i in range(len(self.document.segments) - 1):
+ current = self.document.segments[i]
+ next_seg = self.document.segments[i + 1]
+
+ gap = next_seg.start - current.end
+
+ if gap < min_gap:
+ old_data = {
+ 'segments': [current.to_dict(), next_seg.to_dict()]
+ }
+
+ current.end = next_seg.start - min_gap
+
+ self._record_history(
+ operation=EditOperation.MODIFY,
+ index=i,
+ old_data=old_data,
+ description=f"调整第 {i + 1} 和 {i + 2} 段间隔 (扩大)",
+ )
+ count += 1
+
+ elif gap > max_gap:
+ old_data = {
+ 'segments': [current.to_dict(), next_seg.to_dict()]
+ }
+
+ extension = (gap - max_gap) / 2
+ current.end += extension
+ next_seg.start -= extension
+
+ self._record_history(
+ operation=EditOperation.MODIFY,
+ index=i,
+ old_data=old_data,
+ description=f"调整第 {i + 1} 和 {i + 2} 段间隔 (缩小)",
+ )
+ count += 1
+
+ logger.info(f"已调整 {count} 个段间隔")
+ return count
+
+ def set_segment_style(self, index: int, style_name: str) -> bool:
+ if index < 0 or index >= len(self.document.segments):
+ return False
+
+ if style_name not in self.document.styles:
+ logger.warning(f"样式 '{style_name}' 不存在")
+ return False
+
+ segment = self.document.segments[index]
+ old_data = segment.to_dict()
+
+ segment.style_name = style_name
+
+ self._record_history(
+ operation=EditOperation.STYLE,
+ index=index,
+ old_data=old_data,
+ new_data=segment.to_dict(),
+ description=f"设置第 {index + 1} 段样式为 '{style_name}'",
+ )
+
+ return True
+
+ def add_style(self, style: SubtitleStyle):
+ self.document.add_style(style)
+ logger.info(f"已添加样式: {style.name}")
+
+ def get_statistics(self) -> Dict[str, Any]:
+ return {
+ 'segment_count': self.document.segment_count,
+ 'total_duration': self.document.total_duration,
+ 'total_characters': self.document.total_characters,
+ 'is_bilingual': self.document.is_bilingual,
+ 'original_language': self.document.original_language,
+ 'translated_language': self.document.translated_language,
+ 'style_count': len(self.document.styles),
+ 'history_items': len(self._history),
+ 'can_undo': self.can_undo,
+ 'can_redo': self.can_redo,
+ }
+
+ def export_to_document(self) -> SubtitleDocument:
+ return SubtitleDocument.from_dict(self.document.to_dict())
+
+
+def create_editor_from_segments(
+ segments: List[Any],
+ is_bilingual: bool = False,
+) -> SubtitleEditor:
+ """
+ 从字幕段列表创建编辑器
+
+ Args:
+ segments: 字幕段列表(可以是字典、SubtitleSegment对象等)
+ is_bilingual: 是否为双语字幕
+
+ Returns:
+ SubtitleEditor实例
+ """
+ from subtitle_models import create_document_from_segments
+
+ document = create_document_from_segments(segments, is_bilingual=is_bilingual)
+ return SubtitleEditor(document=document)
diff --git a/subtitle_embedder.py b/subtitle_embedder.py
new file mode 100644
index 0000000..43324bf
--- /dev/null
+++ b/subtitle_embedder.py
@@ -0,0 +1,605 @@
+"""
+字幕硬嵌入模块
+使用FFmpeg将字幕烧录到视频文件中
+"""
+
+import os
+import sys
+import subprocess
+import tempfile
+import shutil
+from pathlib import Path
+from typing import List, Dict, Any, Optional, Tuple
+from dataclasses import dataclass
+from enum import Enum
+import logging
+import re
+
+from subtitle_models import SubtitleStyle, SubtitleDocument
+
+logger = logging.getLogger(__name__)
+
+
+class SubtitlePosition(Enum):
+ """字幕位置枚举"""
+ BOTTOM = "bottom"
+ TOP = "top"
+ MIDDLE = "middle"
+
+
+@dataclass
+class EmbeddingConfig:
+ """字幕硬嵌入配置"""
+ font_name: str = "Microsoft YaHei"
+ font_size: int = 24
+ primary_color: str = "&H00FFFFFF"
+ outline_color: str = "&H00000000"
+ back_color: str = "&H00000000"
+ outline_width: int = 2
+ shadow: int = 1
+ position: SubtitlePosition = SubtitlePosition.BOTTOM
+ margin_v: int = 40
+ margin_h: int = 20
+ bold: bool = False
+ italic: bool = False
+ video_quality: str = "high"
+ video_codec: str = "libx264"
+ audio_codec: str = "aac"
+ audio_bitrate: str = "192k"
+ crf: int = 23
+ preset: str = "medium"
+ use_gpu: bool = False
+
+
+class SubtitleEmbedder:
+ """
+ 字幕硬嵌入器
+ 使用FFmpeg将字幕烧录到视频中
+ """
+
+ PRESET_QUALITY = {
+ 'fast': {'crf': 28, 'preset': 'veryfast'},
+ 'normal': {'crf': 23, 'preset': 'medium'},
+ 'high': {'crf': 20, 'preset': 'slow'},
+ 'lossless': {'crf': 0, 'preset': 'ultrafast'},
+ }
+
+ def __init__(self, ffmpeg_path: Optional[str] = None):
+ self.ffmpeg_path = ffmpeg_path or self._find_ffmpeg()
+ self.ffprobe_path = self._find_ffprobe()
+
+ if not self.ffmpeg_path:
+ raise RuntimeError("未找到FFmpeg,请确保FFmpeg已安装并添加到系统PATH")
+
+ logger.info(f"FFmpeg路径: {self.ffmpeg_path}")
+ self._check_ffmpeg_version()
+
+ def _find_ffmpeg(self) -> Optional[str]:
+ ffmpeg_exe = 'ffmpeg.exe' if sys.platform == 'win32' else 'ffmpeg'
+
+ which_result = shutil.which('ffmpeg')
+ if which_result:
+ return which_result
+
+ if sys.platform == 'win32':
+ common_paths = [
+ r'C:\ffmpeg\bin\ffmpeg.exe',
+ r'C:\Program Files\ffmpeg\bin\ffmpeg.exe',
+ r'C:\Program Files (x86)\ffmpeg\bin\ffmpeg.exe',
+ ]
+ for path in common_paths:
+ if os.path.exists(path):
+ return path
+
+ return None
+
+ def _find_ffprobe(self) -> Optional[str]:
+ ffprobe_exe = 'ffprobe.exe' if sys.platform == 'win32' else 'ffprobe'
+
+ which_result = shutil.which('ffprobe')
+ if which_result:
+ return which_result
+
+ if self.ffmpeg_path:
+ ffmpeg_dir = Path(self.ffmpeg_path).parent
+ ffprobe_candidate = ffmpeg_dir / ffprobe_exe
+ if ffprobe_candidate.exists():
+ return str(ffprobe_candidate)
+
+ return None
+
+ def _check_ffmpeg_version(self):
+ """检查FFmpeg版本和可用滤镜"""
+ try:
+ startupinfo = None
+ if sys.platform == 'win32':
+ startupinfo = subprocess.STARTUPINFO()
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+ startupinfo.wShowWindow = subprocess.SW_HIDE
+
+ result = subprocess.run(
+ [self.ffmpeg_path, '-version'],
+ capture_output=True,
+ text=True,
+ startupinfo=startupinfo,
+ timeout=10,
+ )
+
+ if result.returncode == 0:
+ lines = result.stdout.split('\n')
+ if lines:
+ version_line = lines[0].strip()
+ logger.info(f"FFmpeg版本: {version_line}")
+ except Exception as e:
+ logger.warning(f"检查FFmpeg版本失败: {e}")
+
+ def _escape_path_for_filter(self, path: str) -> str:
+ """
+ 为FFmpeg滤镜转义路径
+
+ FFmpeg的subtitles/ass滤镜中的路径需要特殊处理:
+ - Windows路径中的冒号需要转义为 \:
+ - 反斜杠需要转义或替换为正斜杠
+ - 如果路径包含空格或特殊字符,可能需要额外处理
+ """
+ escaped = str(path)
+
+ if sys.platform == 'win32':
+ escaped = escaped.replace('\\', '/')
+ escaped = escaped.replace(':', '\\:')
+
+ return escaped
+
+ def _run_ffmpeg_command(
+ self,
+ cmd: List[str],
+ description: str = "执行FFmpeg命令",
+ timeout: Optional[int] = None,
+ ) -> Tuple[int, str, str]:
+ full_cmd = [self.ffmpeg_path] + cmd
+
+ cmd_str = ' '.join([f'"{c}"' if ' ' in c else c for c in full_cmd])
+ logger.debug(f"执行FFmpeg命令: {cmd_str}")
+
+ try:
+ startupinfo = None
+ if sys.platform == 'win32':
+ startupinfo = subprocess.STARTUPINFO()
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+ startupinfo.wShowWindow = subprocess.SW_HIDE
+
+ result = subprocess.run(
+ full_cmd,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ startupinfo=startupinfo,
+ )
+
+ if result.returncode != 0:
+ error_output = result.stderr.strip()
+ error_summary = self._parse_ffmpeg_error(error_output)
+
+ logger.error(f"FFmpeg命令失败: {description}")
+ logger.error(f"完整命令: {cmd_str}")
+ logger.error(f"错误摘要: {error_summary}")
+
+ full_error_msg = f"FFmpeg执行失败: {error_summary}\n详细错误: {error_output[:2000]}"
+ raise RuntimeError(full_error_msg)
+
+ return result.returncode, result.stdout, result.stderr
+
+ except FileNotFoundError as e:
+ raise RuntimeError(f"找不到FFmpeg: {e}")
+ except subprocess.TimeoutExpired:
+ raise RuntimeError(f"FFmpeg命令超时: {description}")
+
+ def _parse_ffmpeg_error(self, stderr: str) -> str:
+ """解析FFmpeg错误输出,提取关键错误信息"""
+ error_patterns = [
+ (r'No such filter:.*?(\w+)', "滤镜不存在: {}"),
+ (r'Unable to parse option value.*?"(.*?)"', "选项值解析错误: {}"),
+ (r'Invalid.*?file.*?(?:\')(.*?)(?:\')', "文件无效或不存在: {}"),
+ (r'Permission denied', "权限被拒绝"),
+ (r'Out of memory', "内存不足"),
+ (r'Invalid data found when processing input', "输入文件格式错误或损坏"),
+ (r'Error while opening encoder', "编码器打开失败"),
+ (r'Cannot allocate memory', "无法分配内存"),
+ (r'Assertion.*failed', "FFmpeg内部错误"),
+ ]
+
+ for pattern, template in error_patterns:
+ match = re.search(pattern, stderr, re.IGNORECASE)
+ if match:
+ return template.format(*match.groups())
+
+ lines = stderr.strip().split('\n')
+ error_lines = [l for l in lines if 'error' in l.lower() or 'Error' in l]
+
+ if error_lines:
+ return error_lines[-1][:100]
+
+ if lines:
+ for line in reversed(lines):
+ if line.strip() and not line.startswith('['):
+ return line.strip()[:100]
+
+ return "未知错误"
+
+ def get_video_info(self, video_path: str) -> Dict[str, Any]:
+ video_path = Path(video_path).resolve()
+
+ if not video_path.exists():
+ raise FileNotFoundError(f"视频文件不存在: {video_path}")
+
+ info = {
+ 'path': str(video_path),
+ 'name': video_path.name,
+ 'size_bytes': video_path.stat().st_size,
+ }
+
+ if self.ffprobe_path:
+ try:
+ cmd = [
+ self.ffprobe_path,
+ '-v', 'quiet',
+ '-print_format', 'json',
+ '-show_format',
+ '-show_streams',
+ str(video_path),
+ ]
+
+ startupinfo = None
+ if sys.platform == 'win32':
+ startupinfo = subprocess.STARTUPINFO()
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+ startupinfo.wShowWindow = subprocess.SW_HIDE
+
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ startupinfo=startupinfo,
+ )
+
+ if result.returncode == 0:
+ import json
+ probe_data = json.loads(result.stdout)
+
+ format_info = probe_data.get('format', {})
+ streams = probe_data.get('streams', [])
+
+ video_stream = None
+ audio_stream = None
+
+ for stream in streams:
+ if stream.get('codec_type') == 'video':
+ video_stream = stream
+ elif stream.get('codec_type') == 'audio':
+ audio_stream = stream
+
+ info['duration_seconds'] = float(format_info.get('duration', 0))
+ info['bitrate'] = int(format_info.get('bit_rate', 0))
+
+ if video_stream:
+ info['video_codec'] = video_stream.get('codec_name')
+ info['width'] = int(video_stream.get('width', 0))
+ info['height'] = int(video_stream.get('height', 0))
+ info['fps'] = self._parse_fps(video_stream.get('r_frame_rate', '0/0'))
+
+ if audio_stream:
+ info['audio_codec'] = audio_stream.get('codec_name')
+ info['audio_channels'] = int(audio_stream.get('channels', 0))
+ info['audio_sample_rate'] = int(audio_stream.get('sample_rate', 0))
+
+ except Exception as e:
+ logger.warning(f"使用ffprobe获取视频信息失败: {e}")
+
+ return info
+
+ def _parse_fps(self, fps_str: str) -> float:
+ try:
+ if '/' in fps_str:
+ num, den = fps_str.split('/')
+ if int(den) == 0:
+ return 0.0
+ return float(num) / float(den)
+ return float(fps_str)
+ except:
+ return 0.0
+
+ def _build_style_options(self, config: EmbeddingConfig) -> Dict[str, str]:
+ options = {}
+
+ options['FontName'] = config.font_name
+ options['FontSize'] = str(config.font_size)
+ options['PrimaryColour'] = config.primary_color
+ options['OutlineColour'] = config.outline_color
+ options['BackColour'] = config.back_color
+ options['Outline'] = str(config.outline_width)
+ options['Shadow'] = str(config.shadow)
+ options['Bold'] = '1' if config.bold else '0'
+ options['Italic'] = '1' if config.italic else '0'
+
+ if config.position == SubtitlePosition.TOP:
+ options['Alignment'] = '8'
+ elif config.position == SubtitlePosition.MIDDLE:
+ options['Alignment'] = '5'
+ else:
+ options['Alignment'] = '2'
+
+ options['MarginV'] = str(config.margin_v)
+
+ return options
+
+ def embed_subtitles(
+ self,
+ video_path: str,
+ subtitle_path: str,
+ output_path: str,
+ config: Optional[EmbeddingConfig] = None,
+ progress_callback: Optional[callable] = None,
+ ) -> Dict[str, Any]:
+ video_path = Path(video_path).resolve()
+ subtitle_path = Path(subtitle_path).resolve()
+ output_path = Path(output_path).resolve()
+
+ if not video_path.exists():
+ raise FileNotFoundError(f"视频文件不存在: {video_path}")
+
+ if not subtitle_path.exists():
+ raise FileNotFoundError(f"字幕文件不存在: {subtitle_path}")
+
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+
+ if config is None:
+ config = EmbeddingConfig()
+
+ video_info = self.get_video_info(str(video_path))
+
+ subtitle_ext = subtitle_path.suffix.lower()
+
+ temp_ass_path = None
+ use_temp_ass = False
+
+ if subtitle_ext not in ['.ass', '.ssa'] and subtitle_ext != '.srt':
+ from subtitle_exporter import SubtitleImporter, SubtitleExporter
+
+ logger.info(f"转换字幕格式到ASS: {subtitle_ext}")
+
+ try:
+ importer = SubtitleImporter()
+ doc = importer.import_from_file(str(subtitle_path))
+
+ temp_dir = tempfile.gettempdir()
+ temp_ass_path = Path(temp_dir) / f"temp_subtitle_{os.getpid()}.ass"
+
+ exporter = SubtitleExporter(doc)
+ exporter.export_to_file(str(temp_ass_path), format_type='ass')
+
+ subtitle_path = temp_ass_path
+ use_temp_ass = True
+ subtitle_ext = '.ass'
+
+ logger.info(f"临时ASS文件: {temp_ass_path}")
+ except Exception as e:
+ logger.error(f"字幕格式转换失败: {e}")
+ raise RuntimeError(f"无法将字幕转换为ASS格式: {e}")
+
+ quality_settings = self.PRESET_QUALITY.get(
+ config.video_quality.lower(),
+ self.PRESET_QUALITY['normal']
+ )
+
+ cmd = []
+
+ cmd.extend(['-i', str(video_path)])
+
+ escaped_subtitle_path = self._escape_path_for_filter(str(subtitle_path))
+
+ style_opts = self._build_style_options(config)
+ style_str = ','.join([f'{k}={v}' for k, v in style_opts.items()])
+
+ if subtitle_ext in ['.ass', '.ssa']:
+ logger.info(f"使用ASS字幕: {subtitle_path.name}")
+ filter_complex = f"ass='{escaped_subtitle_path}'"
+ else:
+ logger.info(f"使用SRT字幕,应用样式: font_size={config.font_size}, position={config.position.value}")
+ filter_complex = f"subtitles='{escaped_subtitle_path}':force_style='{style_str}'"
+
+ logger.debug(f"滤镜参数: {filter_complex}")
+
+ cmd.extend(['-vf', filter_complex])
+
+ if config.use_gpu:
+ logger.info("使用GPU加速 (NVENC)")
+ cmd.extend(['-c:v', 'h264_nvenc'])
+ cmd.extend(['-preset', 'p6'])
+ cmd.extend(['-cq', '22'])
+ else:
+ cmd.extend(['-c:v', config.video_codec])
+ actual_crf = config.crf if config.crf != 23 else quality_settings['crf']
+ cmd.extend(['-crf', str(actual_crf)])
+ cmd.extend(['-preset', quality_settings['preset']])
+
+ cmd.extend(['-c:a', config.audio_codec])
+ cmd.extend(['-b:a', config.audio_bitrate])
+
+ cmd.extend(['-y', str(output_path)])
+
+ logger.info(f"开始烧录字幕: {video_path.name} -> {output_path.name}")
+ logger.info(f"字幕文件: {subtitle_path.name}")
+ logger.info(f"视频质量: {config.video_quality}")
+
+ try:
+ self._run_ffmpeg_command(cmd, "烧录字幕到视频", timeout=None)
+
+ if not output_path.exists():
+ raise RuntimeError(f"输出文件未生成: {output_path}")
+
+ result = {
+ 'success': True,
+ 'input_video': str(video_path),
+ 'input_subtitle': str(subtitle_path),
+ 'output_video': str(output_path),
+ 'output_size_bytes': output_path.stat().st_size,
+ 'video_info': video_info,
+ 'config': {
+ 'font_name': config.font_name,
+ 'font_size': config.font_size,
+ 'position': config.position.value,
+ 'video_quality': config.video_quality,
+ 'use_gpu': config.use_gpu,
+ },
+ }
+
+ logger.info(f"字幕烧录完成: {output_path}")
+ return result
+
+ except Exception as e:
+ error_msg = str(e)
+
+ if "No such filter" in error_msg or "Invalid" in error_msg:
+ additional_help = self._get_troubleshooting_help(error_msg, str(subtitle_path))
+ if additional_help:
+ error_msg = f"{error_msg}\n\n解决建议:\n{additional_help}"
+
+ raise RuntimeError(error_msg)
+
+ finally:
+ if use_temp_ass and temp_ass_path and temp_ass_path.exists():
+ try:
+ os.remove(temp_ass_path)
+ logger.debug(f"已删除临时文件: {temp_ass_path}")
+ except Exception:
+ pass
+
+ def _get_troubleshooting_help(self, error_msg: str, subtitle_path: str) -> str:
+ """根据错误信息提供解决建议"""
+ help_texts = []
+
+ if "No such filter" in error_msg:
+ help_texts.append("• FFmpeg版本可能过旧或缺少滤镜支持")
+ help_texts.append("• 建议安装完整版本的FFmpeg (https://www.gyan.dev/ffmpeg/builds/)")
+ help_texts.append("• 下载 'ffmpeg-release-full_build' 版本")
+
+ if "Invalid" in error_msg and "subtitles" in error_msg.lower():
+ help_texts.append("• 字幕文件路径可能包含特殊字符")
+ help_texts.append("• 尝试将字幕文件和视频文件移动到简单路径(如 C:\\Videos\\)")
+ help_texts.append("• 避免路径中包含空格、中文或特殊字符")
+
+ if "Cannot find" in error_msg or "No such file" in error_msg:
+ help_texts.append(f"• 检查字幕文件是否存在: {subtitle_path}")
+ help_texts.append("• 确保FFmpeg有权限读取该文件")
+
+ return '\n'.join(help_texts)
+
+ def embed_subtitles_with_style(
+ self,
+ video_path: str,
+ subtitle_path: str,
+ output_path: str,
+ style: SubtitleStyle,
+ video_quality: str = "high",
+ use_gpu: bool = False,
+ ) -> Dict[str, Any]:
+ position_map = {
+ 1: SubtitlePosition.LEFT,
+ 2: SubtitlePosition.BOTTOM,
+ 3: SubtitlePosition.RIGHT,
+ 5: SubtitlePosition.MIDDLE,
+ 8: SubtitlePosition.TOP,
+ }
+
+ position = position_map.get(style.alignment, SubtitlePosition.BOTTOM)
+
+ config = EmbeddingConfig(
+ font_name=style.font_name,
+ font_size=style.font_size,
+ primary_color=style.primary_color,
+ outline_color=style.outline_color,
+ back_color=style.back_color,
+ outline_width=style.outline,
+ shadow=style.shadow,
+ position=position,
+ margin_v=style.margin_v,
+ bold=style.bold != 0,
+ italic=style.italic != 0,
+ video_quality=video_quality,
+ use_gpu=use_gpu,
+ )
+
+ return self.embed_subtitles(
+ video_path=video_path,
+ subtitle_path=subtitle_path,
+ output_path=output_path,
+ config=config,
+ )
+
+ def create_preset_config(
+ self,
+ preset_name: str = "default",
+ ) -> EmbeddingConfig:
+ presets = {
+ 'default': EmbeddingConfig(),
+ 'chinese': EmbeddingConfig(
+ font_name="Microsoft YaHei",
+ font_size=24,
+ outline_width=2,
+ position=SubtitlePosition.BOTTOM,
+ ),
+ 'english': EmbeddingConfig(
+ font_name="Arial",
+ font_size=20,
+ outline_width=1,
+ position=SubtitlePosition.BOTTOM,
+ ),
+ 'top': EmbeddingConfig(
+ font_name="Microsoft YaHei",
+ font_size=24,
+ position=SubtitlePosition.TOP,
+ ),
+ 'large': EmbeddingConfig(
+ font_name="Microsoft YaHei",
+ font_size=36,
+ outline_width=3,
+ ),
+ 'small': EmbeddingConfig(
+ font_name="Microsoft YaHei",
+ font_size=16,
+ outline_width=1,
+ ),
+ }
+
+ return presets.get(preset_name.lower(), presets['default'])
+
+
+def create_embedding_config_from_style(
+ style: SubtitleStyle,
+ video_quality: str = "high",
+ use_gpu: bool = False,
+) -> EmbeddingConfig:
+ position_map = {
+ 1: SubtitlePosition.BOTTOM,
+ 2: SubtitlePosition.BOTTOM,
+ 3: SubtitlePosition.BOTTOM,
+ 5: SubtitlePosition.MIDDLE,
+ 8: SubtitlePosition.TOP,
+ }
+
+ return EmbeddingConfig(
+ font_name=style.font_name,
+ font_size=style.font_size,
+ primary_color=style.primary_color,
+ outline_color=style.outline_color,
+ back_color=style.back_color,
+ outline_width=style.outline,
+ shadow=style.shadow,
+ position=position_map.get(style.alignment, SubtitlePosition.BOTTOM),
+ margin_v=style.margin_v,
+ bold=style.bold != 0,
+ italic=style.italic != 0,
+ video_quality=video_quality,
+ use_gpu=use_gpu,
+ )
diff --git a/subtitle_exporter.py b/subtitle_exporter.py
new file mode 100644
index 0000000..caec33a
--- /dev/null
+++ b/subtitle_exporter.py
@@ -0,0 +1,789 @@
+"""
+字幕导出模块
+支持SRT、ASS、VTT多格式字幕导出
+支持样式美化和双语字幕
+"""
+
+import logging
+from typing import List, Dict, Any, Optional, Tuple
+from pathlib import Path
+from datetime import timedelta
+import re
+
+from subtitle_models import (
+ SubtitleSegment,
+ SubtitleDocument,
+ SubtitleStyle,
+ SubtitleFormat,
+)
+
+logger = logging.getLogger(__name__)
+
+
+SUPPORTED_EXPORT_FORMATS = {
+ 'srt': 'SubRip字幕格式',
+ 'ass': 'Advanced SubStation Alpha字幕格式',
+ 'ssa': 'SubStation Alpha字幕格式',
+ 'vtt': 'WebVTT字幕格式',
+}
+
+
+class SubtitleExporter:
+ """
+ 字幕导出器类
+ 支持多种字幕格式导出,包括样式美化
+ """
+
+ def __init__(self, document: Optional[SubtitleDocument] = None):
+ self.document = document or SubtitleDocument()
+
+ def load_document(self, document: SubtitleDocument):
+ self.document = document
+
+ def load_from_segments(self, segments: List[Any]):
+ from subtitle_models import SubtitleSegment
+
+ doc_segments = []
+ for seg in segments:
+ if isinstance(seg, dict):
+ doc_segments.append(SubtitleSegment.from_dict(seg))
+ else:
+ doc_segments.append(seg)
+
+ self.document = SubtitleDocument(segments=doc_segments)
+
+ def format_time_srt(self, seconds: float) -> str:
+ td = timedelta(seconds=seconds)
+ total_seconds = int(td.total_seconds())
+ hours = total_seconds // 3600
+ minutes = (total_seconds % 3600) // 60
+ secs = total_seconds % 60
+ milliseconds = int((td.total_seconds() - total_seconds) * 1000)
+ return f"{hours:02d}:{minutes:02d}:{secs:02d},{milliseconds:03d}"
+
+ def format_time_vtt(self, seconds: float) -> str:
+ td = timedelta(seconds=seconds)
+ total_seconds = int(td.total_seconds())
+ hours = total_seconds // 3600
+ minutes = (total_seconds % 3600) // 60
+ secs = total_seconds % 60
+ milliseconds = int((td.total_seconds() - total_seconds) * 1000)
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}.{milliseconds:03d}"
+
+ def format_time_ass(self, seconds: float) -> str:
+ hours = int(seconds // 3600)
+ minutes = int((seconds % 3600) // 60)
+ secs = int(seconds % 60)
+ centiseconds = int((seconds - int(seconds)) * 100)
+ return f"{hours}:{minutes:02d}:{secs:02d}.{centiseconds:02d}"
+
+ def _get_segment_display_text(
+ self,
+ segment: SubtitleSegment,
+ format_type: str,
+ bilingual_order: Optional[str] = None,
+ ) -> str:
+ if segment.is_bilingual:
+ order = bilingual_order or segment.bilingual_order or "zh_en"
+
+ text_zh = segment.text_zh
+ text_en = segment.text_en
+
+ has_zh = bool(text_zh and text_zh.strip())
+ has_en = bool(text_en and text_en.strip())
+
+ if has_zh and has_en:
+ if format_type == "ass":
+ if order == "zh_en":
+ return f"{text_zh}\\N{text_en}"
+ else:
+ return f"{text_en}\\N{text_zh}"
+ else:
+ if order == "zh_en":
+ return f"{text_zh}\n{text_en}"
+ else:
+ return f"{text_en}\n{text_zh}"
+
+ elif has_zh:
+ return text_zh
+ elif has_en:
+ return text_en
+ else:
+ return segment.text
+
+ return segment.text
+
+ def generate_srt_content(
+ self,
+ segments: Optional[List[SubtitleSegment]] = None,
+ bilingual_order: Optional[str] = None,
+ ) -> str:
+ segs = segments or self.document.segments
+
+ if not segs:
+ logger.warning("没有字幕段可生成")
+ return ""
+
+ lines = []
+
+ for idx, seg in enumerate(segs, 1):
+ start_str = self.format_time_srt(seg.start)
+ end_str = self.format_time_srt(seg.end)
+ text = self._get_segment_display_text(seg, "srt", bilingual_order)
+
+ lines.append(str(idx))
+ lines.append(f"{start_str} --> {end_str}")
+ lines.append(text)
+ lines.append("")
+
+ return '\n'.join(lines)
+
+ def generate_vtt_content(
+ self,
+ segments: Optional[List[SubtitleSegment]] = None,
+ bilingual_order: Optional[str] = None,
+ add_style: bool = True,
+ ) -> str:
+ segs = segments or self.document.segments
+
+ if not segs:
+ logger.warning("没有字幕段可生成")
+ return ""
+
+ lines = []
+
+ lines.append("WEBVTT")
+ lines.append("")
+
+ if add_style and self.document.styles:
+ lines.append("STYLE")
+ lines.append("::cue {")
+ lines.append(" background-color: transparent;")
+ lines.append(" color: white;")
+ lines.append(" font-family: 'Microsoft YaHei', sans-serif;")
+ lines.append(" font-size: 24px;")
+ lines.append(" text-shadow: 2px 2px 4px rgba(0,0,0,0.8);")
+ lines.append("}")
+ lines.append("")
+
+ for idx, seg in enumerate(segs, 1):
+ start_str = self.format_time_vtt(seg.start)
+ end_str = self.format_time_vtt(seg.end)
+ text = self._get_segment_display_text(seg, "vtt", bilingual_order)
+
+ lines.append(f"{idx:05d}")
+ lines.append(f"{start_str} --> {end_str}")
+ lines.append(text)
+ lines.append("")
+
+ return '\n'.join(lines)
+
+ def generate_ass_content(
+ self,
+ segments: Optional[List[SubtitleSegment]] = None,
+ title: str = "",
+ video_resolution: Tuple[int, int] = (1920, 1080),
+ bilingual_order: Optional[str] = None,
+ default_style: Optional[SubtitleStyle] = None,
+ ) -> str:
+ segs = segments or self.document.segments
+
+ if not segs:
+ logger.warning("没有字幕段可生成")
+ return ""
+
+ width, height = video_resolution
+
+ lines = []
+
+ lines.append("[Script Info]")
+ lines.append(f"; Script generated by VideoSubtitleAI")
+ lines.append(f"Title: {title or self.document.title or 'Untitled'}")
+ lines.append("Original Script: VideoSubtitleAI")
+ lines.append("ScriptType: v4.00+")
+ lines.append("Collisions: Normal")
+ lines.append(f"PlayResX: {width}")
+ lines.append(f"PlayResY: {height}")
+ lines.append("Timer: 100.0000")
+ lines.append("")
+
+ lines.append("[V4+ Styles]")
+ lines.append("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding")
+
+ styles = self.document.styles.copy()
+
+ if default_style:
+ styles['Default'] = default_style
+ elif 'Default' not in styles:
+ styles['Default'] = SubtitleStyle.create_default_style()
+
+ for style_name, style in styles.items():
+ lines.append(style.to_ass_style_line())
+
+ lines.append("")
+
+ lines.append("[Events]")
+ lines.append("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text")
+
+ for seg in segs:
+ start_str = self.format_time_ass(seg.start)
+ end_str = self.format_time_ass(seg.end)
+ text = self._get_segment_display_text(seg, "ass", bilingual_order)
+
+ style_name = seg.style_name or 'Default'
+
+ margin_l = 0
+ margin_r = 0
+ margin_v = 0
+
+ lines.append(f"Dialogue: 0,{start_str},{end_str},{style_name},,{margin_l},{margin_r},{margin_v},,{text}")
+
+ return '\n'.join(lines)
+
+ def export_to_file(
+ self,
+ output_path: str,
+ format_type: Optional[str] = None,
+ encoding: str = 'utf-8',
+ **kwargs,
+ ) -> str:
+ output_path = Path(output_path).resolve()
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+
+ if format_type is None:
+ ext = output_path.suffix.lower()
+ if ext == '.srt':
+ format_type = 'srt'
+ elif ext == '.ass' or ext == '.ssa':
+ format_type = 'ass'
+ elif ext == '.vtt':
+ format_type = 'vtt'
+ else:
+ format_type = 'srt'
+
+ format_type = format_type.lower()
+
+ if format_type == 'srt':
+ content = self.generate_srt_content(
+ bilingual_order=kwargs.get('bilingual_order'),
+ )
+ elif format_type == 'vtt':
+ content = self.generate_vtt_content(
+ bilingual_order=kwargs.get('bilingual_order'),
+ add_style=kwargs.get('add_style', True),
+ )
+ elif format_type == 'ass':
+ content = self.generate_ass_content(
+ title=kwargs.get('title', ''),
+ video_resolution=kwargs.get('video_resolution', (1920, 1080)),
+ bilingual_order=kwargs.get('bilingual_order'),
+ default_style=kwargs.get('default_style'),
+ )
+ else:
+ raise ValueError(f"不支持的字幕格式: {format_type}")
+
+ try:
+ with open(output_path, 'w', encoding=encoding) as f:
+ f.write(content)
+
+ logger.info(f"字幕文件已生成: {output_path}")
+ return str(output_path)
+
+ except Exception as e:
+ logger.exception(f"写入字幕文件失败: {e}")
+ raise
+
+ def export_multiple_formats(
+ self,
+ base_path: str,
+ formats: List[str] = ['srt', 'ass', 'vtt'],
+ encoding: str = 'utf-8',
+ **kwargs,
+ ) -> Dict[str, str]:
+ base_path = Path(base_path).resolve()
+ base_dir = base_path.parent
+ base_name = base_path.stem
+
+ results = {}
+
+ for fmt in formats:
+ fmt = fmt.lower()
+ ext = f".{fmt}"
+ output_path = base_dir / f"{base_name}{ext}"
+
+ try:
+ result = self.export_to_file(
+ str(output_path),
+ format_type=fmt,
+ encoding=encoding,
+ **kwargs,
+ )
+ results[fmt] = result
+ except Exception as e:
+ logger.error(f"导出 {fmt} 格式失败: {e}")
+ results[fmt] = None
+
+ return results
+
+
+class SubtitleImporter:
+ """
+ 字幕导入器类
+ 支持从SRT、ASS、VTT文件导入字幕
+ """
+
+ def __init__(self):
+ pass
+
+ def parse_srt(self, content: str) -> List[SubtitleSegment]:
+ segments = []
+
+ blocks = re.split(r'\n\n+', content.strip())
+
+ for block in blocks:
+ lines = block.strip().split('\n')
+ if len(lines) < 2:
+ continue
+
+ seg_id = len(segments)
+ time_line_idx = 0
+
+ if lines[0].strip().isdigit():
+ time_line_idx = 1
+
+ if time_line_idx >= len(lines):
+ continue
+
+ time_line = lines[time_line_idx]
+ time_match = re.match(
+ r'(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{3})',
+ time_line
+ )
+
+ if time_match:
+ start_time = self._parse_time_str(time_match.group(1))
+ end_time = self._parse_time_str(time_match.group(2))
+
+ text_lines = lines[time_line_idx + 1:]
+ text = '\n'.join(text_lines).strip()
+
+ is_bilingual = False
+ text_zh = ""
+ text_en = ""
+
+ if '\n' in text:
+ parts = text.split('\n')
+ if len(parts) == 2:
+ is_bilingual = True
+ if self._is_chinese(parts[0]):
+ text_zh = parts[0]
+ text_en = parts[1]
+ else:
+ text_en = parts[0]
+ text_zh = parts[1]
+
+ segment = SubtitleSegment(
+ id=seg_id,
+ start=start_time,
+ end=end_time,
+ text=text,
+ is_bilingual=is_bilingual,
+ text_zh=text_zh,
+ text_en=text_en,
+ )
+ segments.append(segment)
+
+ return segments
+
+ def parse_vtt(self, content: str) -> List[SubtitleSegment]:
+ segments = []
+
+ lines = content.strip().split('\n')
+
+ in_cue = False
+ current_id = 0
+ start_time = 0.0
+ end_time = 0.0
+ text_lines = []
+
+ for line in lines:
+ line = line.strip()
+
+ if not line:
+ if in_cue and text_lines:
+ text = '\n'.join(text_lines).strip()
+
+ is_bilingual = False
+ text_zh = ""
+ text_en = ""
+
+ if '\n' in text:
+ parts = text.split('\n')
+ if len(parts) == 2:
+ is_bilingual = True
+ if self._is_chinese(parts[0]):
+ text_zh = parts[0]
+ text_en = parts[1]
+ else:
+ text_en = parts[0]
+ text_zh = parts[1]
+
+ segment = SubtitleSegment(
+ id=current_id,
+ start=start_time,
+ end=end_time,
+ text=text,
+ is_bilingual=is_bilingual,
+ text_zh=text_zh,
+ text_en=text_en,
+ )
+ segments.append(segment)
+ current_id += 1
+
+ in_cue = False
+ text_lines = []
+ continue
+
+ if line.startswith("WEBVTT") or line.startswith("STYLE") or line.startswith("::cue"):
+ continue
+
+ time_match = re.match(
+ r'(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{3})',
+ line
+ )
+
+ if time_match:
+ start_time = self._parse_time_str(time_match.group(1))
+ end_time = self._parse_time_str(time_match.group(2))
+ in_cue = True
+ text_lines = []
+ continue
+
+ if in_cue:
+ if not line.isdigit():
+ text_lines.append(line)
+
+ if in_cue and text_lines:
+ text = '\n'.join(text_lines).strip()
+ segment = SubtitleSegment(
+ id=current_id,
+ start=start_time,
+ end=end_time,
+ text=text,
+ )
+ segments.append(segment)
+
+ return segments
+
+ def parse_ass(self, content: str) -> List[SubtitleSegment]:
+ segments = []
+
+ styles = {}
+ in_events = False
+
+ lines = content.split('\n')
+
+ for line in lines:
+ line = line.strip()
+
+ if line.startswith("[Events]"):
+ in_events = True
+ continue
+
+ if line.startswith("[V4+ Styles]") or line.startswith("[V4 Styles]"):
+ in_events = False
+ continue
+
+ if line.startswith("Style:"):
+ style_data = self._parse_ass_style(line)
+ if style_data:
+ styles[style_data['name']] = style_data
+ continue
+
+ if not in_events:
+ continue
+
+ if line.startswith("Dialogue:"):
+ seg_data = self._parse_ass_dialogue(line)
+ if seg_data:
+ segment = SubtitleSegment(
+ id=len(segments),
+ start=seg_data['start'],
+ end=seg_data['end'],
+ text=seg_data['text'],
+ style_name=seg_data.get('style'),
+ )
+ segments.append(segment)
+
+ return segments
+
+ def _parse_ass_style(self, line: str) -> Optional[Dict[str, Any]]:
+ try:
+ if not line.startswith("Style:"):
+ return None
+
+ style_part = line[6:].strip()
+ parts = style_part.split(',')
+
+ if len(parts) < 22:
+ return None
+
+ return {
+ 'name': parts[0],
+ 'font_name': parts[1],
+ 'font_size': int(parts[2]),
+ 'primary_color': parts[3],
+ 'secondary_color': parts[4],
+ 'outline_color': parts[5],
+ 'back_color': parts[6],
+ 'bold': int(parts[7]),
+ 'italic': int(parts[8]),
+ 'underline': int(parts[9]),
+ 'strike_out': int(parts[10]),
+ 'scale_x': int(parts[11]),
+ 'scale_y': int(parts[12]),
+ 'spacing': int(parts[13]),
+ 'angle': int(parts[14]),
+ 'border_style': int(parts[15]),
+ 'outline': int(parts[16]),
+ 'shadow': int(parts[17]),
+ 'alignment': int(parts[18]),
+ 'margin_l': int(parts[19]),
+ 'margin_r': int(parts[20]),
+ 'margin_v': int(parts[21]),
+ 'encoding': int(parts[22]) if len(parts) > 22 else 1,
+ }
+ except Exception:
+ return None
+
+ def _parse_ass_dialogue(self, line: str) -> Optional[Dict[str, Any]]:
+ try:
+ if not line.startswith("Dialogue:"):
+ return None
+
+ dialogue_part = line[9:].strip()
+ parts = dialogue_part.split(',', 9)
+
+ if len(parts) < 10:
+ return None
+
+ start_str = parts[1]
+ end_str = parts[2]
+ style = parts[3]
+ text = parts[9]
+
+ text = text.replace('\\N', '\n').replace('\\n', ' ')
+
+ import re
+ text = re.sub(r'\{[^}]+\}', '', text)
+
+ return {
+ 'start': self._parse_ass_time(start_str),
+ 'end': self._parse_ass_time(end_str),
+ 'style': style,
+ 'text': text.strip(),
+ }
+ except Exception:
+ return None
+
+ def _parse_ass_time(self, time_str: str) -> float:
+ time_str = time_str.strip()
+
+ match = re.match(r'(\d+):(\d{2}):(\d{2})\.(\d{2})', time_str)
+ if match:
+ hours = int(match.group(1))
+ minutes = int(match.group(2))
+ seconds = int(match.group(3))
+ centiseconds = int(match.group(4))
+ return hours * 3600 + minutes * 60 + seconds + centiseconds / 100.0
+
+ return 0.0
+
+ def _parse_time_str(self, time_str: str) -> float:
+ time_str = time_str.strip()
+ time_str = time_str.replace(',', '.')
+
+ parts = time_str.split(':')
+ if len(parts) == 3:
+ hours = int(parts[0])
+ minutes = int(parts[1])
+ seconds = float(parts[2])
+ return hours * 3600 + minutes * 60 + seconds
+ elif len(parts) == 2:
+ minutes = int(parts[0])
+ seconds = float(parts[1])
+ return minutes * 60 + seconds
+ else:
+ return float(time_str)
+
+ def _is_chinese(self, text: str) -> bool:
+ if not text:
+ return False
+
+ chinese_count = 0
+ for char in text:
+ if '\u4e00' <= char <= '\u9fff':
+ chinese_count += 1
+
+ if len(text) == 0:
+ return False
+
+ return chinese_count / len(text) > 0.3
+
+ def import_from_file(
+ self,
+ file_path: str,
+ encoding: str = 'utf-8',
+ ) -> SubtitleDocument:
+ file_path = Path(file_path).resolve()
+
+ if not file_path.exists():
+ raise FileNotFoundError(f"字幕文件不存在: {file_path}")
+
+ ext = file_path.suffix.lower()
+
+ try:
+ with open(file_path, 'r', encoding=encoding) as f:
+ content = f.read()
+ except UnicodeDecodeError:
+ try:
+ with open(file_path, 'r', encoding='gbk') as f:
+ content = f.read()
+ except Exception:
+ raise ValueError(f"无法解码文件: {file_path},尝试了 utf-8 和 gbk 编码")
+
+ if ext == '.srt':
+ segments = self.parse_srt(content)
+ elif ext == '.vtt':
+ segments = self.parse_vtt(content)
+ elif ext in ['.ass', '.ssa']:
+ segments = self.parse_ass(content)
+ else:
+ segments = self.parse_srt(content)
+
+ document = SubtitleDocument(segments=segments)
+ document.title = file_path.stem
+
+ logger.info(f"成功导入字幕文件: {file_path},共 {len(segments)} 段")
+ return document
+
+
+def create_styled_ass_style(
+ font_name: str = "Microsoft YaHei",
+ font_size: int = 48,
+ primary_color: str = "#FFFFFF",
+ outline_color: str = "#000000",
+ outline_width: int = 2,
+ alignment: int = 2,
+ bold: bool = False,
+) -> SubtitleStyle:
+ style = SubtitleStyle(
+ name="Custom",
+ font_name=font_name,
+ font_size=font_size,
+ border_style=1,
+ outline=outline_width,
+ alignment=alignment,
+ bold=1 if bold else 0,
+ )
+
+ style.update_color_from_hex(primary_color, "primary")
+ style.update_color_from_hex(outline_color, "outline")
+
+ return style
+
+
+def create_bilingual_style(
+ chinese_font: str = "Microsoft YaHei",
+ chinese_size: int = 48,
+ english_font: str = "Arial",
+ english_size: int = 36,
+) -> Dict[str, SubtitleStyle]:
+ chinese_style = SubtitleStyle(
+ name="Chinese",
+ font_name=chinese_font,
+ font_size=chinese_size,
+ primary_color="&H00FFFFFF",
+ outline_color="&H00000000",
+ outline=2,
+ shadow=1,
+ alignment=2,
+ margin_v=60,
+ )
+
+ english_style = SubtitleStyle(
+ name="English",
+ font_name=english_font,
+ font_size=english_size,
+ primary_color="&H00FFFF00",
+ outline_color="&H00000000",
+ outline=2,
+ shadow=1,
+ alignment=2,
+ margin_v=20,
+ )
+
+ return {
+ 'Chinese': chinese_style,
+ 'English': english_style,
+ }
+
+
+def hex_to_ass_color(hex_color: str, alpha: str = "00") -> str:
+ """
+ 将十六进制RGB颜色转换为ASS格式颜色
+
+ ASS颜色格式: &HAABBGGRR
+ - AA: Alpha透明度 (00=完全透明, FF=完全不透明)
+ - BB: 蓝色分量
+ - GG: 绿色分量
+ - RR: 红色分量
+
+ Args:
+ hex_color: 十六进制颜色,如 "#FFFFFF" 或 "FFFFFF"
+ alpha: 透明度,默认为 "00" (完全不透明)
+
+ Returns:
+ ASS格式的颜色字符串,如 "&H00FFFFFF"
+ """
+ hex_color = hex_color.strip().lstrip('#')
+
+ if len(hex_color) == 3:
+ hex_color = ''.join([c * 2 for c in hex_color])
+
+ if len(hex_color) != 6:
+ raise ValueError(f"无效的颜色格式: {hex_color},应为6位十六进制")
+
+ r = hex_color[0:2]
+ g = hex_color[2:4]
+ b = hex_color[4:6]
+
+ return f"&H{alpha}{b}{g}{r}"
+
+
+def ass_color_to_hex(ass_color: str) -> Tuple[str, str]:
+ """
+ 将ASS格式颜色转换为十六进制RGB颜色
+
+ Args:
+ ass_color: ASS格式颜色,如 "&H00FFFFFF" 或 "00FFFFFF"
+
+ Returns:
+ (hex_color, alpha) 元组,如 ("#FFFFFF", "00")
+ """
+ ass_color = ass_color.strip().lstrip('&H').lstrip('&h')
+
+ if len(ass_color) == 6:
+ alpha = "00"
+ bgr = ass_color
+ elif len(ass_color) == 8:
+ alpha = ass_color[0:2]
+ bgr = ass_color[2:8]
+ else:
+ return "#FFFFFF", "00"
+
+ b = bgr[0:2]
+ g = bgr[2:4]
+ r = bgr[4:6]
+
+ return f"#{r}{g}{b}", alpha
diff --git a/subtitle_models.py b/subtitle_models.py
new file mode 100644
index 0000000..b5ea68d
--- /dev/null
+++ b/subtitle_models.py
@@ -0,0 +1,539 @@
+"""
+字幕数据模型模块
+定义字幕段、样式、双语字幕等核心数据结构
+"""
+
+from dataclasses import dataclass, field
+from typing import List, Dict, Any, Optional, Tuple
+from datetime import timedelta
+from enum import Enum
+import re
+
+
+class SubtitleFormat(Enum):
+ """字幕格式枚举"""
+ SRT = "srt"
+ ASS = "ass"
+ VTT = "vtt"
+
+
+class Language(Enum):
+ """常用语言枚举"""
+ AUTO = "auto"
+ CHINESE = "zh"
+ ENGLISH = "en"
+ JAPANESE = "ja"
+ KOREAN = "ko"
+ FRENCH = "fr"
+ GERMAN = "de"
+ SPANISH = "es"
+ RUSSIAN = "ru"
+ PORTUGUESE = "pt"
+ ITALIAN = "it"
+
+
+class TextStyle(Enum):
+ """文字样式枚举"""
+ NORMAL = "normal"
+ BOLD = "bold"
+ ITALIC = "italic"
+ UNDERLINE = "underline"
+
+
+class HorizontalAlignment(Enum):
+ """水平对齐方式"""
+ LEFT = 1
+ CENTER = 2
+ RIGHT = 3
+
+
+class VerticalAlignment(Enum):
+ """垂直对齐方式"""
+ BOTTOM = 2
+ MIDDLE = 5
+ TOP = 8
+
+
+@dataclass
+class SubtitleSegment:
+ """
+ 字幕段数据模型
+ 代表单个字幕条目,包含时间、文本、样式等信息
+ """
+ id: int = 0
+ start: float = 0.0
+ end: float = 0.0
+ text: str = ""
+ language: str = "unknown"
+
+ text_zh: str = ""
+ text_en: str = ""
+
+ style_name: Optional[str] = None
+ is_bilingual: bool = False
+ bilingual_order: str = "zh_en"
+
+ tokens: List[int] = field(default_factory=list)
+ avg_logprob: float = 0.0
+ no_speech_prob: float = 0.0
+ word_timestamps: List[Dict[str, Any]] = field(default_factory=list)
+
+ metadata: Dict[str, Any] = field(default_factory=dict)
+
+ @property
+ def duration(self) -> float:
+ return self.end - self.start
+
+ @property
+ def display_text(self) -> str:
+ if not self.is_bilingual:
+ return self.text
+
+ if self.bilingual_order == "zh_en":
+ zh = self.text_zh or self.text
+ en = self.text_en or ""
+ if en:
+ return f"{zh}\n{en}"
+ return zh
+ else:
+ en = self.text_en or self.text
+ zh = self.text_zh or ""
+ if zh:
+ return f"{en}\n{zh}"
+ return en
+
+ def format_time_srt(self, seconds: float) -> str:
+ td = timedelta(seconds=seconds)
+ total_seconds = int(td.total_seconds())
+ hours = total_seconds // 3600
+ minutes = (total_seconds % 3600) // 60
+ seconds = total_seconds % 60
+ milliseconds = int((td.total_seconds() - total_seconds) * 1000)
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"
+
+ def format_time_vtt(self, seconds: float) -> str:
+ td = timedelta(seconds=seconds)
+ total_seconds = int(td.total_seconds())
+ hours = total_seconds // 3600
+ minutes = (total_seconds % 3600) // 60
+ seconds = total_seconds % 60
+ milliseconds = int((td.total_seconds() - total_seconds) * 1000)
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{milliseconds:03d}"
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ 'id': self.id,
+ 'start': self.start,
+ 'end': self.end,
+ 'text': self.text,
+ 'language': self.language,
+ 'text_zh': self.text_zh,
+ 'text_en': self.text_en,
+ 'style_name': self.style_name,
+ 'is_bilingual': self.is_bilingual,
+ 'bilingual_order': self.bilingual_order,
+ 'tokens': self.tokens,
+ 'avg_logprob': self.avg_logprob,
+ 'no_speech_prob': self.no_speech_prob,
+ 'word_timestamps': self.word_timestamps,
+ 'metadata': self.metadata,
+ }
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'SubtitleSegment':
+ return cls(
+ id=data.get('id', 0),
+ start=data.get('start', 0.0),
+ end=data.get('end', 0.0),
+ text=data.get('text', ''),
+ language=data.get('language', 'unknown'),
+ text_zh=data.get('text_zh', ''),
+ text_en=data.get('text_en', ''),
+ style_name=data.get('style_name'),
+ is_bilingual=data.get('is_bilingual', False),
+ bilingual_order=data.get('bilingual_order', 'zh_en'),
+ tokens=data.get('tokens', []),
+ avg_logprob=data.get('avg_logprob', 0.0),
+ no_speech_prob=data.get('no_speech_prob', 0.0),
+ word_timestamps=data.get('word_timestamps', []),
+ metadata=data.get('metadata', {}),
+ )
+
+ @classmethod
+ def from_whisper_segment(cls, whisper_segment: Dict[str, Any]) -> 'SubtitleSegment':
+ return cls(
+ id=whisper_segment.get('id', 0),
+ start=round(float(whisper_segment.get('start', 0)), 3),
+ end=round(float(whisper_segment.get('end', 0)), 3),
+ text=whisper_segment.get('text', '').strip(),
+ language=whisper_segment.get('language', 'unknown'),
+ tokens=whisper_segment.get('tokens', []),
+ avg_logprob=whisper_segment.get('avg_logprob', 0.0),
+ no_speech_prob=whisper_segment.get('no_speech_prob', 0.0),
+ word_timestamps=whisper_segment.get('words', []),
+ )
+
+
+@dataclass
+class SubtitleStyle:
+ """
+ 字幕样式数据模型
+ 用于ASS格式字幕的样式定义
+ """
+ name: str = "Default"
+
+ font_name: str = "Microsoft YaHei"
+ font_size: int = 48
+ primary_color: str = "&H00FFFFFF"
+ secondary_color: str = "&H000000FF"
+ outline_color: str = "&H00000000"
+ back_color: str = "&H00000000"
+
+ bold: int = 0
+ italic: int = 0
+ underline: int = 0
+ strike_out: int = 0
+ scale_x: int = 100
+ scale_y: int = 100
+ spacing: int = 0
+ angle: int = 0
+
+ border_style: int = 1
+ outline: int = 2
+ shadow: int = 1
+
+ alignment: int = 2
+ margin_l: int = 10
+ margin_r: int = 10
+ margin_v: int = 40
+
+ encoding: int = 1
+
+ def to_ass_style_line(self) -> str:
+ return (
+ f"Style: {self.name},{self.font_name},{self.font_size},{self.primary_color},"
+ f"{self.secondary_color},{self.outline_color},{self.back_color},{self.bold},"
+ f"{self.italic},{self.underline},{self.strike_out},{self.scale_x},{self.scale_y},"
+ f"{self.spacing},{self.angle},{self.border_style},{self.outline},{self.shadow},"
+ f"{self.alignment},{self.margin_l},{self.margin_r},{self.margin_v},{self.encoding}"
+ )
+
+ @classmethod
+ def create_default_style(cls) -> 'SubtitleStyle':
+ return cls()
+
+ @classmethod
+ def create_chinese_style(cls) -> 'SubtitleStyle':
+ return cls(
+ name="Chinese",
+ font_name="Microsoft YaHei",
+ font_size=48,
+ primary_color="&H00FFFFFF",
+ outline_color="&H00000000",
+ outline=2,
+ shadow=1,
+ alignment=2,
+ margin_v=40,
+ )
+
+ @classmethod
+ def create_english_style(cls) -> 'SubtitleStyle':
+ return cls(
+ name="English",
+ font_name="Arial",
+ font_size=36,
+ primary_color="&H00FFFF00",
+ outline_color="&H00000000",
+ outline=2,
+ shadow=1,
+ alignment=2,
+ margin_v=80,
+ )
+
+ @classmethod
+ def create_top_style(cls) -> 'SubtitleStyle':
+ return cls(
+ name="Top",
+ font_name="Microsoft YaHei",
+ font_size=48,
+ alignment=8,
+ margin_v=40,
+ )
+
+ @classmethod
+ def create_bottom_style(cls) -> 'SubtitleStyle':
+ return cls(
+ name="Bottom",
+ font_name="Microsoft YaHei",
+ font_size=48,
+ alignment=2,
+ margin_v=40,
+ )
+
+ def update_color_from_hex(self, hex_color: str, color_type: str = "primary"):
+ hex_color = hex_color.lstrip('#')
+ if len(hex_color) == 6:
+ rgb = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
+ bgr_color = f"&H00{rgb[2]:02X}{rgb[1]:02X}{rgb[0]:02X}"
+ elif len(hex_color) == 8:
+ rgb = tuple(int(hex_color[i:i+2], 16) for i in (2, 4, 6))
+ alpha = int(hex_color[0:2], 16)
+ bgr_color = f"&H{alpha:02X}{rgb[2]:02X}{rgb[1]:02X}{rgb[0]:02X}"
+ else:
+ raise ValueError(f"无效的颜色格式: {hex_color}")
+
+ if color_type == "primary":
+ self.primary_color = bgr_color
+ elif color_type == "secondary":
+ self.secondary_color = bgr_color
+ elif color_type == "outline":
+ self.outline_color = bgr_color
+ elif color_type == "back":
+ self.back_color = bgr_color
+
+
+@dataclass
+class SubtitleDocument:
+ """
+ 字幕文档数据模型
+ 包含多个字幕段、样式集合和元数据
+ """
+ segments: List[SubtitleSegment] = field(default_factory=list)
+ styles: Dict[str, SubtitleStyle] = field(default_factory=dict)
+
+ title: str = ""
+ original_language: str = "unknown"
+ translated_language: Optional[str] = None
+ is_bilingual: bool = False
+
+ metadata: Dict[str, Any] = field(default_factory=dict)
+
+ def __post_init__(self):
+ if not self.styles:
+ self.styles['Default'] = SubtitleStyle.create_default_style()
+
+ @property
+ def segment_count(self) -> int:
+ return len(self.segments)
+
+ @property
+ def total_duration(self) -> float:
+ if not self.segments:
+ return 0.0
+ return self.segments[-1].end - self.segments[0].start
+
+ @property
+ def total_characters(self) -> int:
+ return sum(len(seg.text) for seg in self.segments)
+
+ def add_segment(self, segment: SubtitleSegment) -> int:
+ segment.id = len(self.segments)
+ self.segments.append(segment)
+ return segment.id
+
+ def add_style(self, style: SubtitleStyle):
+ self.styles[style.name] = style
+
+ def get_segment(self, index: int) -> Optional[SubtitleSegment]:
+ if 0 <= index < len(self.segments):
+ return self.segments[index]
+ return None
+
+ def remove_segment(self, index: int) -> bool:
+ if 0 <= index < len(self.segments):
+ self.segments.pop(index)
+ self._reindex_segments()
+ return True
+ return False
+
+ def insert_segment(self, index: int, segment: SubtitleSegment) -> bool:
+ if 0 <= index <= len(self.segments):
+ self.segments.insert(index, segment)
+ self._reindex_segments()
+ return True
+ return False
+
+ def _reindex_segments(self):
+ for idx, seg in enumerate(self.segments):
+ seg.id = idx
+
+ def sort_segments_by_time(self):
+ self.segments.sort(key=lambda x: x.start)
+ self._reindex_segments()
+
+ def merge_adjacent_segments(self, index: int, gap_threshold: float = 0.5) -> bool:
+ if index < 0 or index >= len(self.segments) - 1:
+ return False
+
+ current = self.segments[index]
+ next_seg = self.segments[index + 1]
+
+ if next_seg.start - current.end > gap_threshold:
+ return False
+
+ merged = SubtitleSegment(
+ id=index,
+ start=current.start,
+ end=next_seg.end,
+ text=f"{current.text} {next_seg.text}",
+ language=current.language,
+ text_zh=f"{current.text_zh} {next_seg.text_zh}" if current.is_bilingual else "",
+ text_en=f"{current.text_en} {next_seg.text_en}" if current.is_bilingual else "",
+ is_bilingual=current.is_bilingual,
+ bilingual_order=current.bilingual_order,
+ style_name=current.style_name,
+ )
+
+ self.segments[index] = merged
+ self.segments.pop(index + 1)
+ self._reindex_segments()
+ return True
+
+ def split_segment(self, index: int, split_seconds: float) -> Tuple[bool, int]:
+ if index < 0 or index >= len(self.segments):
+ return False, -1
+
+ segment = self.segments[index]
+
+ if split_seconds <= segment.start or split_seconds >= segment.end:
+ return False, -1
+
+ duration = segment.end - segment.start
+ ratio = (split_seconds - segment.start) / duration
+
+ text_1, text_2 = self._split_text_by_ratio(segment.text, ratio)
+ text_zh_1, text_zh_2 = self._split_text_by_ratio(segment.text_zh, ratio)
+ text_en_1, text_en_2 = self._split_text_by_ratio(segment.text_en, ratio)
+
+ seg1 = SubtitleSegment(
+ id=index,
+ start=segment.start,
+ end=split_seconds,
+ text=text_1,
+ language=segment.language,
+ text_zh=text_zh_1,
+ text_en=text_en_1,
+ is_bilingual=segment.is_bilingual,
+ bilingual_order=segment.bilingual_order,
+ style_name=segment.style_name,
+ )
+
+ seg2 = SubtitleSegment(
+ id=index + 1,
+ start=split_seconds,
+ end=segment.end,
+ text=text_2,
+ language=segment.language,
+ text_zh=text_zh_2,
+ text_en=text_en_2,
+ is_bilingual=segment.is_bilingual,
+ bilingual_order=segment.bilingual_order,
+ style_name=segment.style_name,
+ )
+
+ self.segments[index] = seg1
+ self.segments.insert(index + 1, seg2)
+ self._reindex_segments()
+
+ return True, index + 1
+
+ def _split_text_by_ratio(self, text: str, ratio: float) -> Tuple[str, str]:
+ if not text:
+ return "", ""
+
+ total_chars = len(text)
+ split_pos = int(total_chars * ratio)
+
+ punctuations = ['。', '!', '?', ',', ';', ':', '.', '!', '?', ',', ';', ':', ' ']
+
+ search_range = min(20, total_chars - split_pos)
+ for i in range(split_pos, min(split_pos + search_range, total_chars)):
+ if text[i] in punctuations:
+ split_pos = i + 1
+ break
+
+ search_range = min(20, split_pos)
+ for i in range(split_pos - 1, max(0, split_pos - search_range), -1):
+ if text[i] in punctuations:
+ split_pos = i + 1
+ break
+
+ return text[:split_pos].strip(), text[split_pos:].strip()
+
+ def shift_all_timings(self, seconds: float):
+ for seg in self.segments:
+ seg.start = max(0.0, seg.start + seconds)
+ seg.end = max(0.0, seg.end + seconds)
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ 'segments': [seg.to_dict() for seg in self.segments],
+ 'styles': {name: vars(style) for name, style in self.styles.items()},
+ 'title': self.title,
+ 'original_language': self.original_language,
+ 'translated_language': self.translated_language,
+ 'is_bilingual': self.is_bilingual,
+ 'metadata': self.metadata,
+ }
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'SubtitleDocument':
+ segments = [SubtitleSegment.from_dict(s) for s in data.get('segments', [])]
+
+ styles = {}
+ for name, style_data in data.get('styles', {}).items():
+ styles[name] = SubtitleStyle(**style_data)
+
+ return cls(
+ segments=segments,
+ styles=styles,
+ title=data.get('title', ''),
+ original_language=data.get('original_language', 'unknown'),
+ translated_language=data.get('translated_language'),
+ is_bilingual=data.get('is_bilingual', False),
+ metadata=data.get('metadata', {}),
+ )
+
+
+def create_document_from_segments(
+ segments: List[Any],
+ is_bilingual: bool = False,
+) -> SubtitleDocument:
+ """
+ 从识别结果的段列表创建字幕文档
+
+ Args:
+ segments: 段列表(可以是字典或SubtitleSegment对象)
+ is_bilingual: 是否为双语字幕
+
+ Returns:
+ SubtitleDocument实例
+ """
+ doc_segments = []
+
+ for idx, seg in enumerate(segments):
+ if isinstance(seg, SubtitleSegment):
+ doc_segments.append(seg)
+ elif isinstance(seg, dict):
+ doc_seg = SubtitleSegment(
+ id=idx,
+ start=seg.get('start', 0.0),
+ end=seg.get('end', 0.0),
+ text=seg.get('text', '').strip(),
+ language=seg.get('language', 'unknown'),
+ tokens=seg.get('tokens', []),
+ avg_logprob=seg.get('avg_logprob', 0.0),
+ no_speech_prob=seg.get('no_speech_prob', 0.0),
+ is_bilingual=is_bilingual,
+ )
+ doc_segments.append(doc_seg)
+
+ document = SubtitleDocument(segments=doc_segments)
+ document.is_bilingual = is_bilingual
+
+ return document
+
+
+def create_default_style() -> SubtitleStyle:
+ """创建默认样式"""
+ return SubtitleStyle.create_default_style()
diff --git a/subtitle_translator.py b/subtitle_translator.py
new file mode 100644
index 0000000..6c0e81c
--- /dev/null
+++ b/subtitle_translator.py
@@ -0,0 +1,834 @@
+"""
+字幕翻译模块
+支持多种翻译后端,实现双语字幕生成
+"""
+
+import logging
+from abc import ABC, abstractmethod
+from typing import List, Dict, Any, Optional, Tuple
+from dataclasses import dataclass
+from enum import Enum
+import re
+
+logger = logging.getLogger(__name__)
+
+
+class TranslationBackend(Enum):
+ """翻译后端枚举"""
+ AUTO = "auto"
+ WHISPER = "whisper"
+ ONLINE = "online"
+ LOCAL_MODEL = "local_model"
+ DUMMY = "dummy"
+
+
+@dataclass
+class TranslationResult:
+ """翻译结果数据类"""
+ original_text: str
+ translated_text: str
+ source_language: str
+ target_language: str
+ confidence: float = 1.0
+ backend: str = ""
+ success: bool = True
+ error_message: Optional[str] = None
+
+
+class BaseTranslator(ABC):
+ """翻译器基类"""
+
+ backend: TranslationBackend = TranslationBackend.DUMMY
+
+ def __init__(self, source_language: str = "auto", target_language: str = "en"):
+ self.source_language = source_language
+ self.target_language = target_language
+ self._initialized = False
+
+ @abstractmethod
+ def translate(self, text: str) -> TranslationResult:
+ """翻译单个文本"""
+ pass
+
+ def translate_batch(self, texts: List[str]) -> List[TranslationResult]:
+ """批量翻译"""
+ return [self.translate(text) for text in texts]
+
+ @property
+ def is_initialized(self) -> bool:
+ return self._initialized
+
+
+def is_chinese_text(text: str) -> bool:
+ """检查文本是否主要是中文"""
+ if not text:
+ return False
+
+ chinese_count = 0
+ for char in text:
+ if '\u4e00' <= char <= '\u9fff':
+ chinese_count += 1
+
+ if len(text) == 0:
+ return False
+
+ return chinese_count / len(text) > 0.3
+
+
+def is_english_text(text: str) -> bool:
+ """检查文本是否主要是英文"""
+ if not text:
+ return False
+
+ text = re.sub(r'[0-9\s!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~]', '', text)
+
+ if len(text) == 0:
+ return False
+
+ english_count = 0
+ for char in text:
+ if ('a' <= char <= 'z') or ('A' <= char <= 'Z'):
+ english_count += 1
+
+ return english_count / len(text) > 0.6
+
+
+def detect_text_language(text: str) -> Tuple[str, float]:
+ """
+ 检测文本的语言
+
+ Returns:
+ (language_code, confidence)
+ """
+ if not text or not text.strip():
+ return 'unknown', 0.0
+
+ if is_chinese_text(text):
+ return 'zh', 0.95
+ elif is_english_text(text):
+ return 'en', 0.95
+
+ return 'unknown', 0.5
+
+
+class WhisperTranslator(BaseTranslator):
+ """
+ 使用Whisper的翻译功能
+ Whisper本身支持将语音转录并翻译为英文
+ """
+
+ def __init__(
+ self,
+ source_language: str = "auto",
+ target_language: str = "en",
+ whisper_model=None,
+ ):
+ super().__init__(source_language, target_language)
+ self.whisper_model = whisper_model
+ self._initialized = True
+
+ def translate(self, text: str) -> TranslationResult:
+ """
+ 注意:WhisperTranslator主要用于在语音识别阶段直接翻译
+ 这个方法作为备用,仅返回原文本(因为Whisper不能翻译纯文本)
+ """
+ logger.warning("WhisperTranslator不能翻译纯文本,建议在语音识别阶段使用task='translate'")
+ return TranslationResult(
+ original_text=text,
+ translated_text=text,
+ source_language=self.source_language,
+ target_language=self.target_language,
+ confidence=0.5,
+ backend="whisper_translate",
+ success=True,
+ )
+
+
+class OnlineAPITranslator(BaseTranslator):
+ """
+ 在线API翻译器
+ 支持多种翻译API(百度、谷歌、DeepL等)
+ """
+
+ def __init__(
+ self,
+ source_language: str = "auto",
+ target_language: str = "en",
+ api_type: str = "baidu",
+ api_key: Optional[str] = None,
+ app_id: Optional[str] = None,
+ ):
+ super().__init__(source_language, target_language)
+ self.api_type = api_type
+ self.api_key = api_key
+ self.app_id = app_id
+ self._initialized = False
+
+ self._try_initialize()
+
+ def _try_initialize(self):
+ """尝试初始化API翻译器"""
+ try:
+ if self.api_type == "baidu":
+ if self.api_key and self.app_id:
+ self._initialized = True
+ logger.info(f"百度翻译API初始化完成")
+ else:
+ logger.warning("百度翻译需要 app_id 和 api_key")
+ elif self.api_type == "google":
+ self._initialized = True
+ logger.info("谷歌翻译(无密钥模式)初始化完成")
+ elif self.api_type == "deepl":
+ if self.api_key:
+ self._initialized = True
+ logger.info("DeepL翻译API初始化完成")
+ else:
+ logger.warning("DeepL翻译需要 api_key")
+ elif self.api_type == "youdao":
+ if self.api_key and self.app_id:
+ self._initialized = True
+ logger.info("有道翻译API初始化完成")
+ else:
+ logger.warning("有道翻译需要 app_id 和 api_key")
+ except Exception as e:
+ logger.warning(f"在线API翻译器初始化失败: {e}")
+
+ def translate(self, text: str) -> TranslationResult:
+ """
+ 使用在线API翻译(模拟实现)
+ 实际使用时需要替换为真实的API调用
+ """
+ if not self._initialized:
+ return TranslationResult(
+ original_text=text,
+ translated_text=text,
+ source_language=self.source_language,
+ target_language=self.target_language,
+ confidence=0.0,
+ backend=f"online_{self.api_type}",
+ success=False,
+ error_message="API未正确初始化",
+ )
+
+ source_lang, _ = detect_text_language(text)
+
+ logger.info(f"使用 {self.api_type} API翻译: {text[:30]}...")
+
+ translated_text = self._mock_translate(text, source_lang, self.target_language)
+
+ return TranslationResult(
+ original_text=text,
+ translated_text=translated_text,
+ source_language=source_lang,
+ target_language=self.target_language,
+ confidence=0.8,
+ backend=f"online_{self.api_type}",
+ success=True,
+ )
+
+ def _mock_translate(self, text: str, source_lang: str, target_lang: str) -> str:
+ """模拟翻译(实际使用时替换为真实API)"""
+ if source_lang == 'zh' and target_lang == 'en':
+ return f"[EN] {text}"
+ elif source_lang == 'en' and target_lang == 'zh':
+ return f"[中] {text}"
+ return text
+
+
+class LocalModelTranslator(BaseTranslator):
+ """
+ 本地模型翻译器
+ 使用Helsinki-NLP的OPUS-MT模型或其他本地翻译模型
+ """
+
+ def __init__(
+ self,
+ source_language: str = "zh",
+ target_language: str = "en",
+ model_name: Optional[str] = None,
+ device: str = "cpu",
+ ):
+ super().__init__(source_language, target_language)
+ self.model_name = model_name
+ self.device = device
+ self._model = None
+ self._tokenizer = None
+ self._initialized = False
+
+ self._try_initialize()
+
+ def _try_initialize(self):
+ """尝试加载本地翻译模型"""
+ try:
+ logger.info("尝试加载本地翻译模型...")
+
+ import importlib.util
+
+ transformers_available = importlib.util.find_spec("transformers") is not None
+ torch_available = importlib.util.find_spec("torch") is not None
+
+ if transformers_available and torch_available:
+ logger.info("本地翻译模型框架可用")
+ self._initialized = True
+ else:
+ logger.warning("未安装 transformers 或 torch,无法使用本地翻译模型")
+ self._initialized = False
+
+ except Exception as e:
+ logger.warning(f"本地翻译模型初始化失败: {e}")
+ self._initialized = False
+
+ def translate(self, text: str) -> TranslationResult:
+ """使用本地模型翻译"""
+ if not self._initialized:
+ return TranslationResult(
+ original_text=text,
+ translated_text=text,
+ source_language=self.source_language,
+ target_language=self.target_language,
+ confidence=0.0,
+ backend="local_model",
+ success=False,
+ error_message="本地模型未初始化",
+ )
+
+ source_lang, _ = detect_text_language(text)
+
+ translated_text = self._mock_translate(text, source_lang, self.target_language)
+
+ return TranslationResult(
+ original_text=text,
+ translated_text=translated_text,
+ source_language=source_lang,
+ target_language=self.target_language,
+ confidence=0.7,
+ backend="local_model",
+ success=True,
+ )
+
+ def _mock_translate(self, text: str, source_lang: str, target_lang: str) -> str:
+ """模拟翻译"""
+ if source_lang == 'zh' and target_lang == 'en':
+ return f"[Local EN] {text}"
+ elif source_lang == 'en' and target_lang == 'zh':
+ return f"[本地中] {text}"
+ return text
+
+
+class DummyTranslator(BaseTranslator):
+ """
+ 模拟翻译器
+ 用于测试或当没有可用翻译后端时
+ 支持智能双语模式:根据文本语言自动分配中英文字幕
+ """
+
+ def __init__(
+ self,
+ source_language: str = "auto",
+ target_language: str = "en",
+ mode: str = "smart_bilingual",
+ ):
+ """
+ Args:
+ mode:
+ - "smart_bilingual": 智能双语模式,自动检测语言并分配
+ - "same_text": 返回相同文本
+ - "prefix": 添加语言前缀
+ - "empty": 返回空字符串
+ """
+ super().__init__(source_language, target_language)
+ self.mode = mode
+ self._initialized = True
+
+ def translate(self, text: str) -> TranslationResult:
+ """
+ 智能翻译处理
+ 根据源语言和目标语言自动处理
+ """
+ detected_lang, confidence = detect_text_language(text)
+
+ if self.mode == "smart_bilingual":
+ if self.source_language == 'zh' and self.target_language == 'en':
+ if detected_lang == 'zh':
+ return TranslationResult(
+ original_text=text,
+ translated_text="",
+ source_language='zh',
+ target_language='en',
+ confidence=confidence,
+ backend="dummy_smart",
+ success=True,
+ error_message="翻译后端不可用,英文行将留空或显示原文本",
+ )
+ elif detected_lang == 'en':
+ return TranslationResult(
+ original_text=text,
+ translated_text=text,
+ source_language='en',
+ target_language='zh',
+ confidence=confidence,
+ backend="dummy_smart",
+ success=True,
+ )
+
+ elif self.source_language == 'en' and self.target_language == 'zh':
+ if detected_lang == 'en':
+ return TranslationResult(
+ original_text=text,
+ translated_text="",
+ source_language='en',
+ target_language='zh',
+ confidence=confidence,
+ backend="dummy_smart",
+ success=True,
+ error_message="翻译后端不可用,中文行将留空或显示原文本",
+ )
+ elif detected_lang == 'zh':
+ return TranslationResult(
+ original_text=text,
+ translated_text=text,
+ source_language='zh',
+ target_language='en',
+ confidence=confidence,
+ backend="dummy_smart",
+ success=True,
+ )
+
+ return TranslationResult(
+ original_text=text,
+ translated_text=text,
+ source_language=detected_lang,
+ target_language=self.target_language,
+ confidence=confidence,
+ backend="dummy_smart",
+ success=True,
+ )
+
+ elif self.mode == "same_text":
+ translated = text
+ elif self.mode == "prefix":
+ translated = f"[{self.target_language}] {text}"
+ elif self.mode == "empty":
+ translated = ""
+ else:
+ translated = text
+
+ return TranslationResult(
+ original_text=text,
+ translated_text=translated,
+ source_language=self.source_language,
+ target_language=self.target_language,
+ confidence=0.1,
+ backend="dummy",
+ success=True,
+ )
+
+
+class SubtitleTranslator:
+ """
+ 字幕翻译管理器
+ 统一管理多种翻译后端,提供字幕级别的翻译服务
+ """
+
+ def __init__(
+ self,
+ source_language: str = "auto",
+ target_language: str = "en",
+ backend: TranslationBackend = TranslationBackend.AUTO,
+ whisper_model=None,
+ api_type: Optional[str] = None,
+ api_key: Optional[str] = None,
+ app_id: Optional[str] = None,
+ local_model_name: Optional[str] = None,
+ device: str = "cpu",
+ ):
+ self.source_language = source_language
+ self.target_language = target_language
+ self.backend = backend
+
+ self.translators: Dict[str, BaseTranslator] = {}
+ self._default_translator: Optional[BaseTranslator] = None
+
+ self._initialize_translators(
+ whisper_model=whisper_model,
+ api_type=api_type,
+ api_key=api_key,
+ app_id=app_id,
+ local_model_name=local_model_name,
+ device=device,
+ )
+
+ def _initialize_translators(
+ self,
+ whisper_model=None,
+ api_type: Optional[str] = None,
+ api_key: Optional[str] = None,
+ app_id: Optional[str] = None,
+ local_model_name: Optional[str] = None,
+ device: str = "cpu",
+ ):
+ """初始化所有可用的翻译后端"""
+
+ if whisper_model is not None:
+ try:
+ self.translators['whisper'] = WhisperTranslator(
+ source_language=self.source_language,
+ target_language=self.target_language,
+ whisper_model=whisper_model,
+ )
+ logger.info("Whisper翻译后端初始化完成")
+ except Exception as e:
+ logger.warning(f"Whisper翻译后端初始化失败: {e}")
+
+ if api_type:
+ try:
+ self.translators['online'] = OnlineAPITranslator(
+ source_language=self.source_language,
+ target_language=self.target_language,
+ api_type=api_type,
+ api_key=api_key,
+ app_id=app_id,
+ )
+ if self.translators['online'].is_initialized:
+ logger.info(f"在线API翻译后端 ({api_type}) 初始化完成")
+ except Exception as e:
+ logger.warning(f"在线API翻译后端初始化失败: {e}")
+
+ if local_model_name or self.backend == TranslationBackend.LOCAL_MODEL:
+ try:
+ self.translators['local'] = LocalModelTranslator(
+ source_language=self.source_language,
+ target_language=self.target_language,
+ model_name=local_model_name,
+ device=device,
+ )
+ if self.translators['local'].is_initialized:
+ logger.info("本地翻译模型后端初始化完成")
+ except Exception as e:
+ logger.warning(f"本地翻译模型后端初始化失败: {e}")
+
+ self.translators['dummy'] = DummyTranslator(
+ source_language=self.source_language,
+ target_language=self.target_language,
+ mode="smart_bilingual",
+ )
+
+ self._select_default_translator()
+
+ def _select_default_translator(self):
+ """选择默认翻译后端"""
+ priority = ['local', 'online', 'whisper', 'dummy']
+
+ for key in priority:
+ if key in self.translators and self.translators[key].is_initialized:
+ self._default_translator = self.translators[key]
+ logger.info(f"选择翻译后端: {key}")
+ return
+
+ self._default_translator = self.translators.get('dummy')
+
+ def translate_text(
+ self,
+ text: str,
+ backend: Optional[str] = None,
+ ) -> TranslationResult:
+ translator = self._get_translator(backend)
+ return translator.translate(text)
+
+ def translate_batch(
+ self,
+ texts: List[str],
+ backend: Optional[str] = None,
+ ) -> List[TranslationResult]:
+ translator = self._get_translator(backend)
+ return translator.translate_batch(texts)
+
+ def _get_translator(self, backend: Optional[str] = None) -> BaseTranslator:
+ if backend and backend in self.translators:
+ return self.translators[backend]
+ return self._default_translator or self.translators['dummy']
+
+ def translate_segments(
+ self,
+ segments: List[Any],
+ source_language: str = "zh",
+ target_language: str = "en",
+ bilingual_order: str = "zh_en",
+ backend: Optional[str] = None,
+ ) -> List[Any]:
+ """
+ 翻译字幕段并设置双语属性
+
+ 智能双语逻辑:
+ - 如果源语言是中文,目标是英文:
+ - 中文文本:text_zh = 原文本,text_en = 翻译结果(或空)
+ - 英文文本:text_en = 原文本(因为不需要翻译),text_zh = 翻译结果
+ - 如果源语言是英文,目标是中文:
+ - 英文文本:text_en = 原文本,text_zh = 翻译结果(或空)
+ - 中文文本:text_zh = 原文本,text_en = 翻译结果
+ """
+ from subtitle_models import SubtitleSegment
+
+ results = []
+ warnings_issued = False
+
+ for seg in segments:
+ if isinstance(seg, dict):
+ segment = SubtitleSegment.from_dict(seg)
+ else:
+ segment = seg
+
+ original_text = segment.text
+
+ detected_lang, _ = detect_text_language(original_text)
+
+ actual_source = source_language if source_language != 'auto' else detected_lang
+
+ if actual_source == 'zh' and target_language == 'en':
+ if detected_lang == 'zh':
+ segment.text_zh = original_text
+
+ translation = self.translate_text(original_text, backend)
+
+ if translation.success and translation.translated_text and translation.translated_text != original_text:
+ segment.text_en = translation.translated_text
+ else:
+ segment.text_en = ""
+ if not warnings_issued:
+ logger.warning("翻译后端不可用,英文部分将留空。建议配置在线翻译API或安装本地翻译模型。")
+ warnings_issued = True
+
+ segment.is_bilingual = True
+ segment.bilingual_order = bilingual_order
+
+ elif detected_lang == 'en':
+ segment.text_en = original_text
+ segment.text_zh = ""
+ segment.is_bilingual = True
+ segment.bilingual_order = bilingual_order
+
+ if not warnings_issued:
+ logger.warning(f"检测到英文文本但目标是中文:未配置中文翻译API,中文部分将留空。")
+ warnings_issued = True
+ else:
+ segment.text = original_text
+ segment.is_bilingual = False
+
+ elif actual_source == 'en' and target_language == 'zh':
+ if detected_lang == 'en':
+ segment.text_en = original_text
+
+ translation = self.translate_text(original_text, backend)
+
+ if translation.success and translation.translated_text and translation.translated_text != original_text:
+ segment.text_zh = translation.translated_text
+ else:
+ segment.text_zh = ""
+ if not warnings_issued:
+ logger.warning("翻译后端不可用,中文部分将留空。建议配置在线翻译API或安装本地翻译模型。")
+ warnings_issued = True
+
+ segment.is_bilingual = True
+ segment.bilingual_order = bilingual_order
+
+ elif detected_lang == 'zh':
+ segment.text_zh = original_text
+ segment.text_en = ""
+ segment.is_bilingual = True
+ segment.bilingual_order = bilingual_order
+ else:
+ segment.text = original_text
+ segment.is_bilingual = False
+
+ else:
+ segment.text_zh = original_text
+ segment.text_en = ""
+ segment.is_bilingual = True
+ segment.bilingual_order = bilingual_order
+
+ results.append(segment)
+
+ return results
+
+ def translate_document(
+ self,
+ document: Any,
+ source_language: Optional[str] = None,
+ target_language: Optional[str] = None,
+ bilingual_order: str = "zh_en",
+ backend: Optional[str] = None,
+ ) -> Any:
+ """翻译整个字幕文档"""
+ from subtitle_models import SubtitleDocument
+
+ if source_language is None:
+ source_language = document.original_language or self.source_language
+ if target_language is None:
+ target_language = self.target_language
+
+ logger.info(f"开始翻译文档: {source_language} -> {target_language}")
+
+ translated_segments = self.translate_segments(
+ document.segments,
+ source_language=source_language,
+ target_language=target_language,
+ bilingual_order=bilingual_order,
+ backend=backend,
+ )
+
+ new_document = SubtitleDocument(
+ segments=translated_segments,
+ styles=document.styles,
+ title=document.title,
+ original_language=source_language,
+ translated_language=target_language,
+ is_bilingual=True,
+ metadata={
+ **document.metadata,
+ 'translation_backend': self._default_translator.__class__.__name__ if self._default_translator else 'unknown',
+ },
+ )
+
+ logger.info(f"文档翻译完成,共 {len(translated_segments)} 段")
+
+ return new_document
+
+ @property
+ def available_backends(self) -> List[str]:
+ return [key for key, translator in self.translators.items() if translator.is_initialized]
+
+ def is_available(self) -> bool:
+ """检查是否有可用的翻译后端(排除dummy)"""
+ for key, translator in self.translators.items():
+ if key != 'dummy' and translator.is_initialized:
+ return True
+ return False
+
+ def has_real_translator(self) -> bool:
+ """检查是否有真正的翻译器(非dummy)"""
+ return self.is_available()
+
+ @property
+ def preferred_backend(self) -> TranslationBackend:
+ """获取首选翻译后端"""
+ if self._default_translator:
+ return self._default_translator.backend
+ return TranslationBackend.DUMMY
+
+
+_default_translator: Optional[SubtitleTranslator] = None
+
+
+def get_translator(
+ source_language: str = "zh",
+ target_language: str = "en",
+) -> SubtitleTranslator:
+ """
+ 获取全局翻译器实例(单例模式)
+
+ Args:
+ source_language: 源语言代码
+ target_language: 目标语言代码
+
+ Returns:
+ SubtitleTranslator实例
+ """
+ global _default_translator
+
+ if _default_translator is None:
+ _default_translator = SubtitleTranslator(
+ source_language=source_language,
+ target_language=target_language,
+ )
+
+ return _default_translator
+
+
+def translate_segments_with_default(
+ segments: List[Any],
+ source_language: str = "zh",
+ target_language: str = "en",
+ bilingual_order: str = "zh_en",
+) -> List[Any]:
+ """
+ 使用默认翻译器翻译字幕段
+
+ Args:
+ segments: 字幕段列表
+ source_language: 源语言
+ target_language: 目标语言
+ bilingual_order: 双语显示顺序
+
+ Returns:
+ 翻译后的字幕段列表
+ """
+ translator = get_translator(source_language, target_language)
+
+ if not translator.has_real_translator():
+ logger.warning("=" * 60)
+ logger.warning("⚠️ 未配置翻译后端")
+ logger.warning("=" * 60)
+ logger.warning("")
+ logger.warning("双语字幕模式说明:")
+ logger.warning(" - 中文字幕段:中文行显示原文,英文行留空")
+ logger.warning(" - 英文字幕段:英文行显示原文,中文行留空")
+ logger.warning("")
+ logger.warning("如需完整翻译功能,请配置以下任一翻译后端:")
+ logger.warning("")
+ logger.warning("1. 在线翻译API(推荐,免费或低成本):")
+ logger.warning(" - 百度翻译:免费额度充足")
+ logger.warning(" - 有道翻译:新用户免费")
+ logger.warning(" - DeepL:提供免费API额度")
+ logger.warning("")
+ logger.warning("2. 本地翻译模型(完全离线):")
+ logger.warning(" - 安装 transformers 和 torch")
+ logger.warning(" - 下载 Helsinki-NLP OPUS-MT 模型")
+ logger.warning("")
+ logger.warning("=" * 60)
+
+ return translator.translate_segments(
+ segments=segments,
+ source_language=source_language,
+ target_language=target_language,
+ bilingual_order=bilingual_order,
+ )
+
+
+def create_bilingual_document(
+ segments: List[Dict[str, Any]],
+ source_language: str = "zh",
+ target_language: str = "en",
+ bilingual_order: str = "zh_en",
+ translator: Optional[SubtitleTranslator] = None,
+) -> Any:
+ """
+ 创建双语文档
+
+ Args:
+ segments: 原始字幕段列表
+ source_language: 源语言
+ target_language: 目标语言
+ bilingual_order: 双语显示顺序
+ translator: 可选的翻译器实例
+
+ Returns:
+ 双语字幕文档
+ """
+ from subtitle_models import (
+ SubtitleDocument, SubtitleSegment,
+ create_document_from_segments
+ )
+
+ if translator is None:
+ translator = get_translator(source_language, target_language)
+
+ doc = create_document_from_segments(segments, is_bilingual=False)
+
+ translated_doc = translator.translate_document(
+ document=doc,
+ source_language=source_language,
+ target_language=target_language,
+ bilingual_order=bilingual_order,
+ )
+
+ return translated_doc
diff --git a/test_fixes.py b/test_fixes.py
new file mode 100644
index 0000000..0bf6de7
--- /dev/null
+++ b/test_fixes.py
@@ -0,0 +1,325 @@
+"""
+验证所有修复的测试脚本
+"""
+
+import sys
+import os
+
+sys.stdout.reconfigure(encoding='utf-8')
+sys.stderr.reconfigure(encoding='utf-8')
+
+os.chdir(os.path.dirname(os.path.abspath(__file__)))
+
+def test_language_detection():
+ """测试语言检测"""
+ print("\n" + "=" * 60)
+ print("测试1: 语言检测功能")
+ print("=" * 60)
+
+ from subtitle_translator import detect_text_language, is_chinese_text, is_english_text
+
+ test_cases = [
+ ("这是一段中文文本", "zh"),
+ ("This is an English text", "en"),
+ ("Hello 你好 World 世界", "zh"),
+ ("", "unknown"),
+ ]
+
+ all_pass = True
+ for text, expected in test_cases:
+ detected, confidence = detect_text_language(text)
+ status = "✓" if detected == expected else "✗"
+ if detected != expected:
+ all_pass = False
+ print(f" {status} '{text[:20]}' -> detected={detected}, confidence={confidence:.2%} (expected={expected})")
+
+ return all_pass
+
+
+def test_bilingual_segments():
+ """测试双语字幕段处理"""
+ print("\n" + "=" * 60)
+ print("测试2: 双语字幕段处理")
+ print("=" * 60)
+
+ from subtitle_models import SubtitleSegment
+ from subtitle_exporter import SubtitleExporter
+
+ test_cases = [
+ {
+ "name": "只有中文(无翻译API)",
+ "segment": SubtitleSegment(
+ id=1, start=0.0, end=2.0,
+ text="这是中文",
+ is_bilingual=True,
+ text_zh="这是中文",
+ text_en="",
+ bilingual_order="zh_en"
+ ),
+ "expected": "这是中文"
+ },
+ {
+ "name": "只有英文(无翻译API)",
+ "segment": SubtitleSegment(
+ id=2, start=2.0, end=4.0,
+ text="This is English",
+ is_bilingual=True,
+ text_zh="",
+ text_en="This is English",
+ bilingual_order="zh_en"
+ ),
+ "expected": "This is English"
+ },
+ {
+ "name": "真正的双语(有翻译)",
+ "segment": SubtitleSegment(
+ id=3, start=4.0, end=6.0,
+ text="你好",
+ is_bilingual=True,
+ text_zh="你好",
+ text_en="Hello",
+ bilingual_order="zh_en"
+ ),
+ "expected_ass": "你好\\NHello",
+ "expected_srt": "你好\nHello"
+ },
+ ]
+
+ exporter = SubtitleExporter()
+
+ all_pass = True
+ for case in test_cases:
+ print(f"\n 测试: {case['name']}")
+ print(f" text_zh='{case['segment'].text_zh}'")
+ print(f" text_en='{case['segment'].text_en}'")
+
+ ass_text = exporter._get_segment_display_text(case['segment'], "ass")
+ srt_text = exporter._get_segment_display_text(case['segment'], "srt")
+
+ print(f" ASS输出: '{ass_text}'")
+ print(f" SRT输出: '{srt_text}'")
+
+ if "expected" in case:
+ if ass_text == case['expected'] and srt_text == case['expected']:
+ print(f" ✓ 输出正确")
+ else:
+ print(f" ✗ 输出不正确,期望: '{case['expected']}'")
+ all_pass = False
+ elif "expected_ass" in case:
+ if ass_text == case['expected_ass'] and srt_text == case['expected_srt']:
+ print(f" ✓ 双语输出正确")
+ else:
+ print(f" ✗ 双语输出不正确")
+ all_pass = False
+
+ return all_pass
+
+
+def test_translation_logic():
+ """测试翻译逻辑"""
+ print("\n" + "=" * 60)
+ print("测试3: 翻译逻辑(智能双语模式)")
+ print("=" * 60)
+
+ from subtitle_translator import SubtitleTranslator, DummyTranslator, TranslationBackend
+ from subtitle_models import SubtitleSegment
+
+ print("\n 测试场景: 源语言=中文, 目标语言=英文")
+ print(" - 中文字幕段: 中文行显示原文, 英文行留空(等待翻译API)")
+ print(" - 英文字幕段: 英文行显示原文, 中文行留空")
+
+ translator = SubtitleTranslator(
+ source_language="zh",
+ target_language="en",
+ backend=TranslationBackend.DUMMY
+ )
+
+ print(f"\n 可用翻译后端: {translator.available_backends}")
+ print(f" 是否有真正翻译器: {translator.has_real_translator()}")
+
+ test_segments = [
+ SubtitleSegment(
+ id=0, start=0.0, end=2.0,
+ text="这是一段中文测试文本"
+ ),
+ SubtitleSegment(
+ id=1, start=2.0, end=4.0,
+ text="This is English text"
+ ),
+ ]
+
+ print("\n 翻译前:")
+ for seg in test_segments:
+ print(f" id={seg.id}: text='{seg.text}'")
+
+ translated = translator.translate_segments(
+ test_segments,
+ source_language="zh",
+ target_language="en",
+ bilingual_order="zh_en"
+ )
+
+ print("\n 翻译后:")
+ for seg in translated:
+ print(f" id={seg.id}:")
+ print(f" is_bilingual={seg.is_bilingual}")
+ print(f" text_zh='{seg.text_zh}'")
+ print(f" text_en='{seg.text_en}'")
+
+ if seg.text_zh == "这是一段中文测试文本" and seg.text_en == "":
+ print(f" ✓ 中文字幕段: 中文行正确, 英文行为空(符合预期)")
+ elif seg.text_en == "This is English text" and seg.text_zh == "":
+ print(f" ✓ 英文字幕段: 英文行正确, 中文行为空(符合预期)")
+ elif seg.text_zh and seg.text_en:
+ print(f" ✓ 真正双语字幕")
+ else:
+ print(f" ? 状态未知")
+
+ return True
+
+
+def test_ass_export():
+ """测试ASS导出和样式"""
+ print("\n" + "=" * 60)
+ print("测试4: ASS导出和样式")
+ print("=" * 60)
+
+ from subtitle_models import SubtitleDocument, SubtitleSegment, SubtitleStyle, create_default_style
+ from subtitle_exporter import SubtitleExporter
+
+ segments = [
+ SubtitleSegment(
+ id=0, start=0.0, end=3.0,
+ text="你好世界",
+ is_bilingual=True,
+ text_zh="你好世界",
+ text_en="Hello World",
+ ),
+ ]
+
+ doc = SubtitleDocument(segments=segments)
+
+ custom_style = SubtitleStyle(
+ name="Custom",
+ font_name="Microsoft YaHei",
+ font_size=48,
+ primary_color="&H0000FFFF",
+ outline_color="&H00000000",
+ outline=3,
+ shadow=2,
+ alignment=2,
+ margin_v=40,
+ )
+
+ doc.add_style(custom_style)
+
+ exporter = SubtitleExporter(doc)
+
+ print("\n 使用默认样式导出ASS内容:")
+ ass_content1 = exporter.generate_ass_content()
+ print(f" 包含 'Default' 样式: {'Default' in ass_content1}")
+ print(f" 包含 'Custom' 样式: {'Custom' in ass_content1}")
+
+ print("\n 使用自定义Default样式导出:")
+ yellow_style = SubtitleStyle(
+ name="Default",
+ font_name="Arial",
+ font_size=36,
+ primary_color="&H0000FFFF",
+ outline=4,
+ )
+
+ ass_content2 = exporter.generate_ass_content(default_style=yellow_style)
+
+ print(f" 包含 Arial: {'Arial' in ass_content2}")
+ print(f" 包含 Fontsize=36: {'36' in ass_content2}")
+ print(f" 包含 Outline=4: {'4' in ass_content2}")
+
+ print("\n ASS内容预览:")
+ lines = ass_content2.split('\n')
+ for i, line in enumerate(lines[:20]):
+ print(f" {i+1:2d}: {line}")
+
+ return True
+
+
+def test_module_imports():
+ """测试所有模块导入"""
+ print("\n" + "=" * 60)
+ print("测试5: 所有模块导入")
+ print("=" * 60)
+
+ modules = [
+ "subtitle_models",
+ "subtitle_translator",
+ "subtitle_editor",
+ "subtitle_exporter",
+ "subtitle_embedder",
+ "language_detector",
+ "video_subtitle_enhanced",
+ ]
+
+ all_ok = True
+ for module_name in modules:
+ try:
+ __import__(module_name)
+ print(f" ✓ {module_name} 导入成功")
+ except Exception as e:
+ print(f" ✗ {module_name} 导入失败: {e}")
+ all_ok = False
+
+ return all_ok
+
+
+def main():
+ print("\n" + "=" * 60)
+ print(" VideoSubtitleAI 修复验证测试")
+ print("=" * 60)
+
+ tests = [
+ ("模块导入", test_module_imports),
+ ("语言检测", test_language_detection),
+ ("双语字幕段处理", test_bilingual_segments),
+ ("翻译逻辑", test_translation_logic),
+ ("ASS导出和样式", test_ass_export),
+ ]
+
+ results = []
+ for name, test_func in tests:
+ try:
+ result = test_func()
+ results.append((name, result))
+ except Exception as e:
+ print(f"\n ✗ 测试 '{name}' 异常: {e}")
+ import traceback
+ traceback.print_exc()
+ results.append((name, False))
+
+ print("\n" + "=" * 60)
+ print(" 测试结果汇总")
+ print("=" * 60)
+
+ passed = 0
+ failed = 0
+ for name, result in results:
+ status = "✓ 通过" if result else "✗ 失败"
+ print(f" {name}: {status}")
+ if result:
+ passed += 1
+ else:
+ failed += 1
+
+ print(f"\n 总计: {passed} 通过, {failed} 失败")
+
+ if failed == 0:
+ print("\n" + "=" * 60)
+ print(" ✓ 所有测试通过!")
+ print("=" * 60)
+ else:
+ print("\n" + "=" * 60)
+ print(" ✗ 部分测试失败,请检查代码")
+ print("=" * 60)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test_modules.py b/test_modules.py
new file mode 100644
index 0000000..7460a51
--- /dev/null
+++ b/test_modules.py
@@ -0,0 +1,210 @@
+# -*- coding: utf-8 -*-
+"""
+测试新模块导入和基本功能
+"""
+
+import sys
+import os
+sys.stdout.reconfigure(encoding='utf-8')
+sys.stderr.reconfigure(encoding='utf-8')
+
+print("=" * 60)
+print(" VideoSubtitleAI 新模块测试")
+print("=" * 60)
+
+errors = []
+
+# 测试 1: subtitle_models
+print("\n[1/7] 测试 subtitle_models.py...")
+try:
+ from subtitle_models import (
+ SubtitleSegment, SubtitleStyle, SubtitleDocument,
+ create_document_from_segments, create_default_style
+ )
+ print(" ✓ subtitle_models 导入成功")
+
+ seg = SubtitleSegment(id=1, start=0.0, end=5.0, text="测试文本")
+ print(f" ✓ 创建 SubtitleSegment 成功: id={seg.id}, text={seg.text}")
+
+ style = create_default_style()
+ print(f" ✓ 创建默认样式成功: font_name={style.font_name}, font_size={style.font_size}")
+
+except Exception as e:
+ print(f" ✗ subtitle_models 测试失败: {e}")
+ errors.append(f"subtitle_models: {e}")
+
+# 测试 2: subtitle_translator
+print("\n[2/7] 测试 subtitle_translator.py...")
+try:
+ from subtitle_translator import (
+ SubtitleTranslator, TranslationBackend, get_translator,
+ translate_segments_with_default, create_bilingual_document
+ )
+ print(" ✓ subtitle_translator 导入成功")
+
+ translator = get_translator()
+ print(f" ✓ 获取翻译器成功: 后端={translator.preferred_backend.value}")
+ print(f" ✓ 翻译器可用: {'是' if translator.is_available() else '否 (仅测试模式)'}")
+
+except Exception as e:
+ print(f" ✗ subtitle_translator 测试失败: {e}")
+ errors.append(f"subtitle_translator: {e}")
+
+# 测试 3: subtitle_editor
+print("\n[3/7] 测试 subtitle_editor.py...")
+try:
+ from subtitle_editor import (
+ SubtitleEditor, EditOperation, create_editor_from_segments
+ )
+ print(" ✓ subtitle_editor 导入成功")
+
+ test_segments = [
+ SubtitleSegment(id=0, start=0.0, end=3.0, text="第一句"),
+ SubtitleSegment(id=1, start=3.0, end=6.0, text="第二句"),
+ ]
+ editor = create_editor_from_segments(test_segments)
+ print(f" ✓ 创建编辑器成功: segment_count={len(editor.document.segments)}")
+
+ can_undo = editor.can_undo
+ can_redo = editor.can_redo
+ print(f" ✓ 撤销/重做功能可用: can_undo={can_undo}, can_redo={can_redo}")
+
+except Exception as e:
+ print(f" ✗ subtitle_editor 测试失败: {e}")
+ errors.append(f"subtitle_editor: {e}")
+
+# 测试 4: subtitle_exporter
+print("\n[4/7] 测试 subtitle_exporter.py...")
+try:
+ from subtitle_exporter import (
+ SubtitleExporter, SubtitleImporter,
+ create_styled_ass_style, create_bilingual_style,
+ hex_to_ass_color, ass_color_to_hex,
+ SUPPORTED_EXPORT_FORMATS
+ )
+ print(" ✓ subtitle_exporter 导入成功")
+ print(f" ✓ 支持的格式: {list(SUPPORTED_EXPORT_FORMATS.keys())}")
+
+ ass_color = hex_to_ass_color("#FFFFFF")
+ print(f" ✓ 颜色转换成功: #FFFFFF -> {ass_color}")
+
+ hex_color, alpha = ass_color_to_hex("&H00FFFFFF")
+ print(f" ✓ 逆颜色转换成功: &H00FFFFFF -> {hex_color} (alpha={alpha})")
+
+ style = create_styled_ass_style(
+ font_name="Microsoft YaHei",
+ font_size=48,
+ primary_color="#FFD700",
+ outline_color="#000000",
+ outline_width=3,
+ )
+ print(f" ✓ 创建自定义样式成功: font_size={style.font_size}")
+
+except Exception as e:
+ print(f" ✗ subtitle_exporter 测试失败: {e}")
+ errors.append(f"subtitle_exporter: {e}")
+
+# 测试 5: subtitle_embedder
+print("\n[5/7] 测试 subtitle_embedder.py...")
+try:
+ from subtitle_embedder import (
+ SubtitleEmbedder, EmbeddingConfig, SubtitlePosition,
+ create_embedding_config_from_style
+ )
+ print(" ✓ subtitle_embedder 导入成功")
+
+ config = EmbeddingConfig(
+ font_name="Microsoft YaHei",
+ font_size=24,
+ position=SubtitlePosition.BOTTOM,
+ video_quality="high",
+ )
+ print(f" ✓ 创建嵌入配置成功: position={config.position.value}")
+
+ print(f" ✓ 字幕位置选项: {[p.value for p in SubtitlePosition]}")
+ print(f" ✓ 质量预设: {list(SubtitleEmbedder.PRESET_QUALITY.keys())}")
+
+except Exception as e:
+ print(f" ✗ subtitle_embedder 测试失败: {e}")
+ errors.append(f"subtitle_embedder: {e}")
+
+# 测试 6: language_detector
+print("\n[6/7] 测试 language_detector.py...")
+try:
+ from language_detector import (
+ LanguageDetector, MultilingualSpeechRecognizer,
+ LANGUAGE_NAMES, is_chinese_text, is_english_text, classify_text_language
+ )
+ print(" ✓ language_detector 导入成功")
+
+ chinese_text = "这是一段中文文本"
+ english_text = "This is an English text"
+
+ is_cn = is_chinese_text(chinese_text)
+ is_en = is_english_text(english_text)
+ print(f" ✓ 中文检测: '{chinese_text}' -> {'是中文' if is_cn else '不是中文'}")
+ print(f" ✓ 英文检测: '{english_text}' -> {'是英文' if is_en else '不是英文'}")
+
+ lang, conf = classify_text_language(chinese_text)
+ print(f" ✓ 语言分类: '{chinese_text}' -> lang={lang}, confidence={conf:.2%}")
+
+ detector = LanguageDetector()
+ result = detector.detect_language_from_text(chinese_text)
+ print(f" ✓ 增强检测: language={result.language}, confidence={result.confidence:.2%}")
+
+ print(f" ✓ 支持语言: {list(LANGUAGE_NAMES.keys())}")
+
+except Exception as e:
+ print(f" ✗ language_detector 测试失败: {e}")
+ errors.append(f"language_detector: {e}")
+
+# 测试 7: video_subtitle_enhanced
+print("\n[7/7] 测试 video_subtitle_enhanced.py...")
+try:
+ from video_subtitle_enhanced import (
+ VideoSubtitleAIEnhanced, EnhancedProcessingResult
+ )
+ print(" ✓ video_subtitle_enhanced 导入成功")
+
+ enhancer = VideoSubtitleAIEnhanced()
+ print(f" ✓ 创建增强版实例成功")
+
+ preset_styles = enhancer.get_preset_styles()
+ print(f" ✓ 预设样式: {list(preset_styles.keys())}")
+
+ supported_formats = enhancer.get_supported_formats()
+ print(f" ✓ 支持格式: {list(supported_formats.keys())}")
+
+except Exception as e:
+ print(f" ✗ video_subtitle_enhanced 测试失败: {e}")
+ errors.append(f"video_subtitle_enhanced: {e}")
+
+# 总结
+print("\n" + "=" * 60)
+print(" 测试完成")
+print("=" * 60)
+
+if errors:
+ print(f"\n✗ 检测到 {len(errors)} 个错误:")
+ for err in errors:
+ print(f" - {err}")
+ sys.exit(1)
+else:
+ print("\n✓ 所有模块测试通过!")
+ print("\n新增功能模块已就绪:")
+ print(" 1. subtitle_models.py - 统一数据模型")
+ print(" 2. subtitle_translator.py - 双语字幕翻译")
+ print(" 3. subtitle_editor.py - 字幕编辑(撤销/重做)")
+ print(" 4. subtitle_exporter.py - 多格式导出(SRT/ASS/VTT)")
+ print(" 5. subtitle_embedder.py - 字幕硬嵌入(FFmpeg)")
+ print(" 6. language_detector.py - 语言检测优化")
+ print(" 7. video_subtitle_enhanced.py - 增强版主模块")
+ print(" 8. main.py (已更新) - 支持新命令行参数")
+
+ print("\n使用示例:")
+ print(" python main.py -i video.mp4 --bilingual --formats srt,ass")
+ print(" python main.py -i video.mp4 --embed-video --style yellow")
+ print(" python main.py --list-styles")
+ print(" python main.py --list-formats")
+ print(" python main.py --export-only -s existing.srt --formats ass,vtt")
+ sys.exit(0)
diff --git a/uploads/73f42a7b-a38a-462b-b508-2ff43afe6681_SeeYouAgain.mp4 b/uploads/73f42a7b-a38a-462b-b508-2ff43afe6681_SeeYouAgain.mp4
new file mode 100644
index 0000000..9241936
Binary files /dev/null and b/uploads/73f42a7b-a38a-462b-b508-2ff43afe6681_SeeYouAgain.mp4 differ
diff --git a/video_subtitle_enhanced.py b/video_subtitle_enhanced.py
new file mode 100644
index 0000000..dc31c45
--- /dev/null
+++ b/video_subtitle_enhanced.py
@@ -0,0 +1,492 @@
+"""
+VideoSubtitleAI 增强版主模块
+整合双语字幕、字幕编辑、多格式导出、硬嵌入等所有新功能
+"""
+
+import os
+import sys
+import logging
+from pathlib import Path
+from typing import List, Dict, Any, Optional, Tuple, Callable
+from dataclasses import dataclass, field
+
+from subtitle_models import (
+ SubtitleSegment, SubtitleStyle, SubtitleDocument,
+ create_document_from_segments, create_default_style
+)
+from subtitle_translator import (
+ SubtitleTranslator, TranslationBackend, get_translator,
+ translate_segments_with_default, create_bilingual_document
+)
+from subtitle_editor import (
+ SubtitleEditor, EditOperation, create_editor_from_segments
+)
+from subtitle_exporter import (
+ SubtitleExporter, SubtitleImporter,
+ create_styled_ass_style, create_bilingual_style,
+ SUPPORTED_EXPORT_FORMATS
+)
+from subtitle_embedder import (
+ SubtitleEmbedder, EmbeddingConfig, SubtitlePosition,
+ create_embedding_config_from_style
+)
+from language_detector import (
+ LanguageDetector, MultilingualSpeechRecognizer,
+ LANGUAGE_NAMES, is_chinese_text, is_english_text, classify_text_language
+)
+
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class EnhancedProcessingResult:
+ """增强版处理结果"""
+ success: bool = False
+ input_path: str = ''
+ output_files: Dict[str, str] = field(default_factory=dict)
+ document: Optional[SubtitleDocument] = None
+ language_detection: Dict[str, Any] = field(default_factory=dict)
+ segment_count: int = 0
+ statistics: Dict[str, Any] = field(default_factory=dict)
+ errors: List[str] = field(default_factory=list)
+ warnings: List[str] = field(default_factory=list)
+
+
+class VideoSubtitleAIEnhanced:
+ """
+ VideoSubtitleAI 增强版主类
+ 整合所有新功能:
+ - 双语字幕生成
+ - 字幕编辑(撤销/重做)
+ - 多格式导出(SRT/ASS/VTT)
+ - 字幕硬嵌入
+ - 样式美化
+ - 语言检测优化
+ """
+
+ def __init__(
+ self,
+ base_recognizer=None,
+ whisper_model=None,
+ ffmpeg_path: Optional[str] = None,
+ verbose: bool = False,
+ ):
+ self.base_recognizer = base_recognizer
+ self.whisper_model = whisper_model
+ self.ffmpeg_path = ffmpeg_path
+ self.verbose = verbose
+
+ self.language_detector = LanguageDetector(whisper_model=whisper_model)
+ self.multilingual_recognizer = MultilingualSpeechRecognizer(
+ base_recognizer=base_recognizer,
+ whisper_model=whisper_model
+ )
+
+ self._embedder: Optional[SubtitleEmbedder] = None
+ self._translator: Optional[SubtitleTranslator] = None
+ self._importer: Optional[SubtitleImporter] = None
+
+ def get_embedder(self) -> SubtitleEmbedder:
+ if self._embedder is None:
+ try:
+ self._embedder = SubtitleEmbedder(ffmpeg_path=self.ffmpeg_path)
+ except Exception as e:
+ logger.warning(f"字幕嵌入器初始化失败: {e}")
+ raise
+ return self._embedder
+
+ def get_translator(self) -> SubtitleTranslator:
+ if self._translator is None:
+ self._translator = get_translator()
+ return self._translator
+
+ def get_importer(self) -> SubtitleImporter:
+ if self._importer is None:
+ self._importer = SubtitleImporter()
+ return self._importer
+
+ def process_with_enhancement(
+ self,
+ recognition_result: Dict[str, Any],
+ source_language: str = 'auto',
+ enable_bilingual: bool = False,
+ target_language: str = 'en',
+ bilingual_order: str = 'zh_en',
+ translation_backend: str = 'auto',
+ ) -> Tuple[SubtitleDocument, Dict[str, Any]]:
+ """
+ 处理识别结果,生成增强版字幕文档
+ """
+ segments = recognition_result.get('segments', [])
+ detected_lang = recognition_result.get('language', 'auto')
+ language_analysis = recognition_result.get('language_analysis', {})
+
+ if language_analysis.get('is_mixed', False):
+ logger.info(f"检测到多语言混合场景: {language_analysis.get('language_names', [])}")
+
+ doc = create_document_from_segments(segments)
+
+ for seg in doc.segments:
+ lang, conf = classify_text_language(seg.text)
+ seg.language = lang
+
+ enhancement_info = {
+ 'detected_language': detected_lang,
+ 'language_analysis': language_analysis,
+ 'is_mixed': language_analysis.get('is_mixed', False),
+ 'languages': language_analysis.get('languages', []),
+ }
+
+ if enable_bilingual:
+ doc = self._generate_bilingual_subtitles(
+ doc,
+ source_language=source_language if source_language != 'auto' else detected_lang,
+ target_language=target_language,
+ bilingual_order=bilingual_order,
+ translation_backend=translation_backend,
+ )
+ enhancement_info['bilingual_enabled'] = True
+ enhancement_info['bilingual_order'] = bilingual_order
+
+ return doc, enhancement_info
+
+ def _generate_bilingual_subtitles(
+ self,
+ document: SubtitleDocument,
+ source_language: str = 'zh',
+ target_language: str = 'en',
+ bilingual_order: str = 'zh_en',
+ translation_backend: str = 'auto',
+ ) -> SubtitleDocument:
+ """
+ 生成双语字幕
+ """
+ translator = self.get_translator()
+
+ if not translator.is_available():
+ logger.warning("翻译后端不可用,将仅保留原语言字幕")
+ return document
+
+ segments_data = []
+ for seg in document.segments:
+ segments_data.append({
+ 'id': seg.id,
+ 'start': seg.start,
+ 'end': seg.end,
+ 'text': seg.text,
+ 'language': seg.language,
+ })
+
+ bilingual_doc = create_bilingual_document(
+ segments=segments_data,
+ source_language=source_language,
+ target_language=target_language,
+ bilingual_order=bilingual_order,
+ translator=translator,
+ )
+
+ return bilingual_doc
+
+ def export_to_multiple_formats(
+ self,
+ document: SubtitleDocument,
+ base_output_path: str,
+ formats: List[str] = None,
+ default_style: Optional[SubtitleStyle] = None,
+ video_resolution: Tuple[int, int] = (1920, 1080),
+ ) -> Dict[str, str]:
+ """
+ 导出为多种字幕格式
+ """
+ if formats is None:
+ formats = ['srt', 'ass']
+
+ base_path = Path(base_output_path)
+ output_files = {}
+
+ exporter = SubtitleExporter(document)
+
+ if default_style:
+ exporter.document.styles[default_style.name] = default_style
+
+ for fmt in formats:
+ fmt = fmt.lower().strip()
+ if fmt not in SUPPORTED_EXPORT_FORMATS:
+ logger.warning(f"不支持的格式: {fmt},跳过")
+ continue
+
+ output_file = str(base_path.parent / f"{base_path.stem}.{fmt}")
+
+ try:
+ if fmt == 'ass':
+ actual_path = exporter.export_to_file(
+ output_file,
+ format_type='ass',
+ video_resolution=video_resolution,
+ default_style=default_style,
+ )
+ elif fmt == 'vtt':
+ actual_path = exporter.export_to_file(
+ output_file,
+ format_type='vtt',
+ add_style=True,
+ )
+ else:
+ actual_path = exporter.export_to_file(
+ output_file,
+ format_type=fmt,
+ )
+
+ output_files[fmt] = actual_path
+ logger.info(f"已导出 {fmt.upper()} 格式: {actual_path}")
+
+ except Exception as e:
+ logger.error(f"导出 {fmt} 格式失败: {e}")
+
+ return output_files
+
+ def embed_subtitles_to_video(
+ self,
+ video_path: str,
+ subtitle_path: str,
+ output_path: str,
+ style: Optional[SubtitleStyle] = None,
+ config: Optional[EmbeddingConfig] = None,
+ video_quality: str = 'high',
+ use_gpu: bool = False,
+ ) -> Dict[str, Any]:
+ """
+ 将字幕硬嵌入到视频中
+ """
+ embedder = self.get_embedder()
+
+ if style and not config:
+ config = create_embedding_config_from_style(
+ style=style,
+ video_quality=video_quality,
+ use_gpu=use_gpu,
+ )
+ elif not config:
+ config = EmbeddingConfig(
+ video_quality=video_quality,
+ use_gpu=use_gpu,
+ )
+
+ result = embedder.embed_subtitles(
+ video_path=video_path,
+ subtitle_path=subtitle_path,
+ output_path=output_path,
+ config=config,
+ )
+
+ return result
+
+ def analyze_language_from_segments(
+ self,
+ segments: List[Dict[str, Any]],
+ threshold: float = 0.3,
+ ) -> Dict[str, Any]:
+ """
+ 分析字幕段的语言分布
+ """
+ return self.language_detector.detect_mixed_language_scenes(
+ segments=segments,
+ threshold=threshold,
+ )
+
+ def create_editor_from_document(
+ self,
+ document: SubtitleDocument,
+ ) -> SubtitleEditor:
+ """
+ 从字幕文档创建编辑器
+ """
+ return SubtitleEditor(document=document)
+
+ def create_editor_from_file(
+ self,
+ subtitle_path: str,
+ ) -> SubtitleEditor:
+ """
+ 从字幕文件创建编辑器
+ """
+ importer = self.get_importer()
+ doc = importer.import_from_file(subtitle_path)
+ return SubtitleEditor(document=doc)
+
+ def process_single_file_enhanced(
+ self,
+ recognition_result: Dict[str, Any],
+ input_path: str,
+ output_path: Optional[str] = None,
+ enable_bilingual: bool = False,
+ source_language: str = 'auto',
+ target_language: str = 'en',
+ bilingual_order: str = 'zh_en',
+ export_formats: List[str] = None,
+ enable_embedding: bool = False,
+ embedding_output_path: Optional[str] = None,
+ style: Optional[SubtitleStyle] = None,
+ video_quality: str = 'high',
+ use_gpu_for_embedding: bool = False,
+ ) -> EnhancedProcessingResult:
+ """
+ 增强版单文件处理流程
+ """
+ result = EnhancedProcessingResult(
+ input_path=input_path,
+ )
+
+ try:
+ input_path_obj = Path(input_path)
+
+ if output_path is None:
+ output_base = input_path_obj.parent / input_path_obj.stem
+ else:
+ output_path_obj = Path(output_path)
+ output_base = output_path_obj.parent / output_path_obj.stem
+
+ doc, enhancement_info = self.process_with_enhancement(
+ recognition_result=recognition_result,
+ source_language=source_language,
+ enable_bilingual=enable_bilingual,
+ target_language=target_language,
+ bilingual_order=bilingual_order,
+ )
+
+ result.document = doc
+ result.language_detection = enhancement_info
+ result.segment_count = len(doc.segments)
+
+ if export_formats is None:
+ export_formats = ['srt']
+
+ default_style = style or create_default_style()
+
+ output_files = self.export_to_multiple_formats(
+ document=doc,
+ base_output_path=str(output_base),
+ formats=export_formats,
+ default_style=default_style,
+ )
+
+ result.output_files = output_files
+
+ if enable_embedding:
+ if not self._is_video_file(input_path):
+ result.warnings.append("输入不是视频文件,跳过字幕嵌入")
+ else:
+ if 'ass' in output_files:
+ subtitle_for_embedding = output_files['ass']
+ elif 'srt' in output_files:
+ subtitle_for_embedding = output_files['srt']
+ else:
+ first_format = next(iter(output_files.keys()), None)
+ subtitle_for_embedding = output_files.get(first_format) if first_format else None
+
+ if subtitle_for_embedding:
+ if embedding_output_path is None:
+ embedding_output = str(input_path_obj.parent / f"{input_path_obj.stem}_subtitled.mp4")
+ else:
+ embedding_output = embedding_output_path
+
+ try:
+ embed_result = self.embed_subtitles_to_video(
+ video_path=input_path,
+ subtitle_path=subtitle_for_embedding,
+ output_path=embedding_output,
+ style=default_style,
+ video_quality=video_quality,
+ use_gpu=use_gpu_for_embedding,
+ )
+
+ result.output_files['embedded_video'] = embedding_output
+ logger.info(f"字幕硬嵌入完成: {embedding_output}")
+
+ except Exception as e:
+ result.errors.append(f"字幕嵌入失败: {e}")
+ logger.error(f"字幕嵌入失败: {e}")
+ else:
+ result.errors.append("没有可用的字幕文件用于嵌入")
+
+ result.statistics = {
+ 'segment_count': len(doc.segments),
+ 'is_bilingual': doc.is_bilingual,
+ 'languages': enhancement_info.get('languages', []),
+ 'exported_formats': list(output_files.keys()),
+ }
+
+ result.success = True
+
+ except Exception as e:
+ result.success = False
+ result.errors.append(str(e))
+ logger.error(f"增强处理失败: {e}")
+
+ return result
+
+ def _is_video_file(self, path: str) -> bool:
+ video_extensions = {'.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.ts', '.m2ts'}
+ return Path(path).suffix.lower() in video_extensions
+
+ def get_supported_formats(self) -> Dict[str, str]:
+ return SUPPORTED_EXPORT_FORMATS.copy()
+
+ def get_preset_styles(self) -> Dict[str, SubtitleStyle]:
+ return {
+ 'default': create_default_style(),
+ 'chinese_large': SubtitleStyle(
+ name='ChineseLarge',
+ font_name='Microsoft YaHei',
+ font_size=48,
+ primary_color='&H00FFFFFF',
+ outline_color='&H00000000',
+ outline=3,
+ shadow=2,
+ alignment=2,
+ margin_v=60,
+ ),
+ 'english': SubtitleStyle(
+ name='English',
+ font_name='Arial',
+ font_size=28,
+ primary_color='&H00FFFFFF',
+ outline_color='&H00000000',
+ outline=2,
+ shadow=1,
+ alignment=2,
+ margin_v=40,
+ ),
+ 'yellow': SubtitleStyle(
+ name='Yellow',
+ font_name='Microsoft YaHei',
+ font_size=48,
+ primary_color='&H0000FFFF',
+ outline_color='&H00000000',
+ outline=3,
+ shadow=2,
+ alignment=2,
+ ),
+ 'cyan': SubtitleStyle(
+ name='Cyan',
+ font_name='Microsoft YaHei',
+ font_size=48,
+ primary_color='&H00FFFF00',
+ outline_color='&H00000000',
+ outline=3,
+ shadow=2,
+ alignment=2,
+ ),
+ 'top': SubtitleStyle(
+ name='Top',
+ font_name='Microsoft YaHei',
+ font_size=40,
+ primary_color='&H00FFFFFF',
+ outline_color='&H00000000',
+ outline=2,
+ shadow=1,
+ alignment=8,
+ margin_v=40,
+ ),
+ }
diff --git a/web/__init__.py b/web/__init__.py
new file mode 100644
index 0000000..1edc066
--- /dev/null
+++ b/web/__init__.py
@@ -0,0 +1,6 @@
+# Web版音视频转字幕工具
+from .server import app
+from .config import settings
+from .tasks import TaskManager, ProcessingTask
+
+__all__ = ['app', 'settings', 'TaskManager', 'ProcessingTask']
diff --git a/web/config.py b/web/config.py
new file mode 100644
index 0000000..ca1e3b9
--- /dev/null
+++ b/web/config.py
@@ -0,0 +1,108 @@
+"""
+Web版配置管理
+"""
+import os
+from pathlib import Path
+from dataclasses import dataclass, field
+from typing import List, Optional
+
+
+@dataclass
+class Settings:
+ # 项目根目录
+ BASE_DIR: Path = Path(__file__).parent.parent
+
+ # 上传目录
+ UPLOAD_DIR: Path = BASE_DIR / "uploads"
+
+ # 输出目录
+ OUTPUT_DIR: Path = BASE_DIR / "outputs"
+
+ # 临时文件目录
+ TEMP_DIR: Path = BASE_DIR / "temp"
+
+ # 日志目录
+ LOG_DIR: Path = BASE_DIR / "logs"
+
+ # 支持的音频格式
+ SUPPORTED_AUDIO_FORMATS: List[str] = field(default_factory=lambda: [
+ ".mp3", ".wav", ".m4a", ".flac", ".aac", ".ogg", ".wma"
+ ])
+
+ # 支持的视频格式
+ SUPPORTED_VIDEO_FORMATS: List[str] = field(default_factory=lambda: [
+ ".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".webm", ".m4v"
+ ])
+
+ # 最大文件大小 (500MB)
+ MAX_FILE_SIZE: int = 500 * 1024 * 1024
+
+ # 最大并发任务数
+ MAX_CONCURRENT_TASKS: int = 2
+
+ # WebSocket 心跳间隔
+ WS_PING_INTERVAL: int = 30
+
+ # CORS 允许的源
+ CORS_ORIGINS: List[str] = field(default_factory=lambda: [
+ "http://localhost",
+ "http://localhost:8000",
+ "http://127.0.0.1:8000",
+ ])
+
+ # 默认 Whisper 模型
+ DEFAULT_MODEL: str = "base"
+
+ # 可用的模型列表
+ AVAILABLE_MODELS: List[str] = field(default_factory=lambda: [
+ "tiny", "base", "small", "medium", "large", "large-v2", "large-v3"
+ ])
+
+ # 默认语言
+ DEFAULT_LANGUAGE: str = "auto"
+
+ # 可用语言列表
+ AVAILABLE_LANGUAGES: List[dict] = field(default_factory=lambda: [
+ {"code": "auto", "name": "自动检测"},
+ {"code": "zh", "name": "中文"},
+ {"code": "en", "name": "英文"},
+ {"code": "ja", "name": "日文"},
+ {"code": "ko", "name": "韩文"},
+ {"code": "fr", "name": "法文"},
+ {"code": "de", "name": "德文"},
+ {"code": "es", "name": "西班牙文"},
+ {"code": "ru", "name": "俄文"},
+ {"code": "pt", "name": "葡萄牙文"},
+ {"code": "it", "name": "意大利文"},
+ ])
+
+ # 默认导出格式
+ DEFAULT_EXPORT_FORMATS: List[str] = field(default_factory=lambda: ["srt"])
+
+ # 可用导出格式
+ AVAILABLE_EXPORT_FORMATS: List[dict] = field(default_factory=lambda: [
+ {"code": "srt", "name": "SRT", "description": "最通用的字幕格式"},
+ {"code": "ass", "name": "ASS", "description": "支持丰富的样式定义"},
+ {"code": "vtt", "name": "VTT", "description": "Web视频文本轨道"},
+ ])
+
+ def __post_init__(self):
+ # 创建必要的目录
+ self.UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
+ self.OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+ self.TEMP_DIR.mkdir(parents=True, exist_ok=True)
+ self.LOG_DIR.mkdir(parents=True, exist_ok=True)
+
+ def is_supported_format(self, file_extension: str) -> bool:
+ ext = file_extension.lower()
+ return ext in self.SUPPORTED_AUDIO_FORMATS or ext in self.SUPPORTED_VIDEO_FORMATS
+
+ def is_audio_format(self, file_extension: str) -> bool:
+ return file_extension.lower() in self.SUPPORTED_AUDIO_FORMATS
+
+ def is_video_format(self, file_extension: str) -> bool:
+ return file_extension.lower() in self.SUPPORTED_VIDEO_FORMATS
+
+
+# 全局配置实例
+settings = Settings()
diff --git a/web/processor.py b/web/processor.py
new file mode 100644
index 0000000..b89e7cd
--- /dev/null
+++ b/web/processor.py
@@ -0,0 +1,336 @@
+"""
+视频字幕处理核心模块
+集成现有的 VideoSubtitleAI 功能到 Web 版本
+"""
+import asyncio
+import logging
+import sys
+from pathlib import Path
+from typing import Dict, List, Optional, Any, Tuple
+from concurrent.futures import ThreadPoolExecutor
+
+from .config import settings
+from .tasks import ProcessingTask, ProcessingStage, TaskStatus
+
+logger = logging.getLogger(__name__)
+
+_executor = ThreadPoolExecutor(max_workers=2)
+
+
+class VideoSubtitleProcessor:
+ _instance: Optional['VideoSubtitleProcessor'] = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ cls._instance._initialized = False
+ return cls._instance
+
+ def __init__(self):
+ if self._initialized:
+ return
+
+ self._ai_instance = None
+ self._enhancer_instance = None
+ self._lock = asyncio.Lock()
+ self._initialized = True
+
+ logger.info("VideoSubtitleProcessor initialized")
+
+ def _get_ai(self, model_name: str = "base", device: str = None):
+ if self._ai_instance is None:
+ from main import VideoSubtitleAI
+ self._ai_instance = VideoSubtitleAI(
+ model_name=model_name,
+ device=device,
+ verbose=True,
+ )
+ return self._ai_instance
+
+ def _get_enhancer(self, whisper_model=None, ffmpeg_path=None):
+ if self._enhancer_instance is None:
+ from video_subtitle_enhanced import VideoSubtitleAIEnhanced
+ self._enhancer_instance = VideoSubtitleAIEnhanced(
+ whisper_model=whisper_model,
+ ffmpeg_path=ffmpeg_path,
+ verbose=True,
+ )
+ return self._enhancer_instance
+
+ async def process_single_file(
+ self,
+ task: ProcessingTask,
+ ) -> Dict[str, Any]:
+ input_file = task.input_files[0] if task.input_files else None
+ if not input_file:
+ raise ValueError("No input file specified")
+
+ input_path = Path(input_file)
+ options = task.options
+
+ model_name = options.get('model', settings.DEFAULT_MODEL)
+ language = options.get('language', settings.DEFAULT_LANGUAGE)
+ formats = options.get('formats', ['srt'])
+ enable_bilingual = options.get('bilingual', False)
+ enable_embedding = options.get('embed_video', False)
+
+ style_options = {
+ 'style': options.get('style', 'default'),
+ 'font_name': options.get('font_name'),
+ 'font_size': options.get('font_size'),
+ 'font_color': options.get('font_color'),
+ 'outline_color': options.get('outline_color'),
+ 'outline_width': options.get('outline_width'),
+ 'position': options.get('position', 'bottom'),
+ 'margin_v': options.get('margin_v'),
+ }
+
+ task.progress.total_files = 1
+ task.progress.current_file = input_path.name
+
+ def sync_process():
+ from main import VideoSubtitleAI
+ from video_subtitle_enhanced import VideoSubtitleAIEnhanced
+ from subtitle_models import create_default_style, SubtitleDocument
+ from subtitle_exporter import SubtitleImporter, SubtitleExporter
+
+ ai = VideoSubtitleAI(
+ model_name=model_name,
+ verbose=True,
+ )
+ ai.initialize()
+ ai.load_model()
+
+ task.update_progress(
+ ProcessingStage.EXTRACTING_AUDIO,
+ "提取音频",
+ 5.0,
+ f"正在处理: {input_path.name}"
+ )
+
+ base_result = ai.process_single_file(
+ input_path=str(input_path),
+ language=language,
+ )
+
+ task.update_progress(
+ ProcessingStage.RECOGNIZING_SPEECH,
+ "语音识别",
+ 30.0,
+ "识别完成,正在处理字幕..."
+ )
+
+ temp_audio_path = None
+ is_temp = False
+ try:
+ from audio_processor import AudioProcessor
+ audio_processor = AudioProcessor()
+ temp_audio_path, is_temp = audio_processor.process_media_file(str(input_path))
+
+ recognition_result = ai.speech_recognizer.recognize(
+ audio_path=temp_audio_path,
+ language=language,
+ verbose=True,
+ )
+ finally:
+ if is_temp and temp_audio_path:
+ ai.audio_processor.cleanup_temp_file(temp_audio_path)
+
+ task.update_progress(
+ ProcessingStage.GENERATING_SUBTITLES,
+ "生成字幕",
+ 60.0,
+ "正在生成字幕文档..."
+ )
+
+ enhancer = VideoSubtitleAIEnhanced(
+ base_recognizer=ai.speech_recognizer,
+ whisper_model=ai.speech_recognizer.model,
+ verbose=True,
+ )
+
+ preset_styles = enhancer.get_preset_styles()
+ style = preset_styles.get(style_options['style'], create_default_style())
+
+ if style_options.get('font_name'):
+ style.font_name = style_options['font_name']
+ if style_options.get('font_size'):
+ style.font_size = style_options['font_size']
+ if style_options.get('font_color'):
+ from subtitle_exporter import hex_to_ass_color
+ style.primary_color = hex_to_ass_color(style_options['font_color'])
+ if style_options.get('outline_color'):
+ from subtitle_exporter import hex_to_ass_color
+ style.outline_color = hex_to_ass_color(style_options['outline_color'])
+ if style_options.get('outline_width') is not None:
+ style.outline = style_options['outline_width']
+ if style_options.get('margin_v') is not None:
+ style.margin_v = style_options['margin_v']
+
+ pos = style_options.get('position', 'bottom')
+ if pos == 'top':
+ style.alignment = 8
+ elif pos == 'middle':
+ style.alignment = 5
+ else:
+ style.alignment = 2
+
+ doc, enhancement_info = enhancer.process_with_enhancement(
+ recognition_result=recognition_result,
+ source_language=language,
+ enable_bilingual=enable_bilingual,
+ )
+
+ output_base = settings.OUTPUT_DIR / input_path.stem
+ output_base.mkdir(parents=True, exist_ok=True)
+
+ task.update_progress(
+ ProcessingStage.EXPORTING,
+ "导出字幕",
+ 75.0,
+ f"正在导出 {', '.join(formats).upper()} 格式..."
+ )
+
+ export_formats = []
+ for fmt in formats:
+ if fmt == 'ass':
+ export_formats.append('ssa')
+ else:
+ export_formats.append(fmt)
+
+ output_files = enhancer.export_to_multiple_formats(
+ document=doc,
+ base_output_path=str(output_base / input_path.stem),
+ formats=export_formats,
+ default_style=style,
+ )
+
+ result_files = {}
+ for fmt, path in output_files.items():
+ if fmt == 'ssa':
+ result_files['ass'] = path
+ else:
+ result_files[fmt] = path
+
+ task.update_progress(
+ ProcessingStage.EXPORTING,
+ "导出完成",
+ 85.0,
+ "字幕导出完成"
+ )
+
+ if enable_embedding and settings.is_video_format(input_path.suffix):
+ task.update_progress(
+ ProcessingStage.EMBEDDING,
+ "嵌入字幕",
+ 90.0,
+ "正在将字幕嵌入视频..."
+ )
+
+ try:
+ from subtitle_embedder import SubtitleEmbedder, EmbeddingConfig
+
+ embedder = SubtitleEmbedder()
+ ass_path = result_files.get('ass') or result_files.get('srt')
+
+ if ass_path:
+ embed_output = output_base / f"{input_path.stem}_subtitled.mp4"
+
+ embed_config = EmbeddingConfig(
+ output_path=str(embed_output),
+ quality=options.get('embed_quality', 'high'),
+ use_gpu=options.get('embed_gpu', False),
+ )
+
+ embedded_path = embedder.embed_subtitles(
+ video_path=str(input_path),
+ subtitle_path=ass_path,
+ config=embed_config,
+ )
+
+ result_files['embedded_video'] = embedded_path
+ task.add_log(f"字幕嵌入完成: {embed_output}")
+
+ except Exception as e:
+ task.add_log(f"字幕嵌入失败: {e}", "warning")
+ logger.warning(f"Embedding failed: {e}")
+
+ ai.unload_model()
+
+ return {
+ 'input_path': str(input_path),
+ 'output_files': result_files,
+ 'language': base_result.get('language'),
+ 'language_name': base_result.get('language_name'),
+ 'segment_count': base_result.get('segment_count'),
+ 'total_characters': base_result.get('total_characters'),
+ 'subtitle_document': doc.to_dict(),
+ 'enhancement_info': enhancement_info,
+ }
+
+ loop = asyncio.get_event_loop()
+ result = await loop.run_in_executor(_executor, sync_process)
+
+ task.output_files = result.get('output_files', {})
+ task.progress.processed_files = 1
+
+ task.update_progress(
+ ProcessingStage.COMPLETED,
+ "处理完成",
+ 100.0,
+ f"处理完成: {input_path.name}"
+ )
+
+ return result
+
+ async def process_batch(
+ self,
+ task: ProcessingTask,
+ ) -> Dict[str, Any]:
+ input_files = task.input_files
+ if not input_files:
+ raise ValueError("No input files specified")
+
+ options = task.options
+ total_files = len(input_files)
+ task.progress.total_files = total_files
+
+ results = []
+ errors = []
+
+ for idx, input_file in enumerate(input_files):
+ input_path = Path(input_file)
+ task.progress.current_file = input_path.name
+ task.progress.processed_files = idx
+
+ try:
+ single_task = ProcessingTask(
+ task_id=f"{task.task_id}_{idx}",
+ input_files=[input_file],
+ options=options,
+ )
+ single_task.progress = task.progress
+
+ result = await self.process_single_file(single_task)
+ results.append(result)
+
+ task.progress.progress = (idx + 1) / total_files * 90
+ task.add_log(f"处理完成: {input_path.name}")
+
+ except Exception as e:
+ error_msg = f"处理失败 {input_path.name}: {e}"
+ errors.append({'file': input_file, 'error': str(e)})
+ task.add_log(error_msg, "error")
+ logger.error(error_msg)
+
+ return {
+ 'total_files': total_files,
+ 'processed_files': len(results),
+ 'failed_files': len(errors),
+ 'results': results,
+ 'errors': errors,
+ }
+
+
+# 全局处理器实例
+processor = VideoSubtitleProcessor()
diff --git a/web/server.py b/web/server.py
new file mode 100644
index 0000000..9ce484a
--- /dev/null
+++ b/web/server.py
@@ -0,0 +1,506 @@
+"""
+VideoSubtitleAI Web 版主服务器
+基于 FastAPI 的全栈 Web 应用
+"""
+import asyncio
+import logging
+import shutil
+import uuid
+from pathlib import Path
+from typing import List, Optional, Dict, Any
+from datetime import datetime
+
+from fastapi import FastAPI, UploadFile, File, Form, HTTPException, WebSocket, WebSocketDisconnect, Request
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, StreamingResponse
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.templating import Jinja2Templates
+from pydantic import BaseModel
+
+from .config import settings
+from .tasks import task_manager, ProcessingTask, TaskStatus, ProcessingStage
+from .processor import processor
+from .websocket_manager import ws_manager, WSMessage, task_progress_monitor
+
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+app = FastAPI(
+ title="VideoSubtitleAI - Web版音视频转字幕工具",
+ description="基于 OpenAI Whisper 的本地离线音视频转字幕 Web 应用",
+ version="2.0.0",
+)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.CORS_ORIGINS,
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+web_dir = Path(__file__).parent
+static_dir = web_dir / "static"
+templates_dir = web_dir / "templates"
+
+static_dir.mkdir(parents=True, exist_ok=True)
+templates_dir.mkdir(parents=True, exist_ok=True)
+
+app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
+templates = Jinja2Templates(directory=str(templates_dir))
+
+
+class ProcessOptions(BaseModel):
+ model: str = settings.DEFAULT_MODEL
+ language: str = settings.DEFAULT_LANGUAGE
+ formats: List[str] = ["srt"]
+ bilingual: bool = False
+ embed_video: bool = False
+ style: str = "default"
+ font_name: Optional[str] = None
+ font_size: Optional[int] = None
+ font_color: Optional[str] = None
+ outline_color: Optional[str] = None
+ outline_width: Optional[int] = None
+ position: str = "bottom"
+ margin_v: Optional[int] = None
+ embed_quality: str = "high"
+ embed_gpu: bool = False
+
+
+class EditOperation(BaseModel):
+ operation: str
+ segment_id: int
+ new_text: Optional[str] = None
+ new_start: Optional[float] = None
+ new_end: Optional[float] = None
+ split_time: Optional[float] = None
+ merge_with_next: Optional[bool] = None
+
+
+@app.get("/", response_class=HTMLResponse)
+async def index(request: Request):
+ return templates.TemplateResponse(
+ "index.html",
+ {
+ "request": request,
+ "settings": settings,
+ "models": settings.AVAILABLE_MODELS,
+ "languages": settings.AVAILABLE_LANGUAGES,
+ "formats": settings.AVAILABLE_EXPORT_FORMATS,
+ }
+ )
+
+
+@app.get("/editor", response_class=HTMLResponse)
+async def editor(request: Request, task_id: str = None):
+ return templates.TemplateResponse(
+ "editor.html",
+ {
+ "request": request,
+ "task_id": task_id,
+ }
+ )
+
+
+@app.get("/batch", response_class=HTMLResponse)
+async def batch_page(request: Request):
+ return templates.TemplateResponse(
+ "batch.html",
+ {
+ "request": request,
+ "settings": settings,
+ "models": settings.AVAILABLE_MODELS,
+ "languages": settings.AVAILABLE_LANGUAGES,
+ }
+ )
+
+
+@app.get("/api/config")
+async def get_config():
+ return {
+ "models": settings.AVAILABLE_MODELS,
+ "languages": settings.AVAILABLE_LANGUAGES,
+ "formats": settings.AVAILABLE_EXPORT_FORMATS,
+ "max_file_size": settings.MAX_FILE_SIZE,
+ "supported_audio_formats": settings.SUPPORTED_AUDIO_FORMATS,
+ "supported_video_formats": settings.SUPPORTED_VIDEO_FORMATS,
+ }
+
+
+@app.post("/api/upload")
+async def upload_file(
+ file: UploadFile = File(...),
+):
+ file_ext = Path(file.filename).suffix.lower()
+
+ if not settings.is_supported_format(file_ext):
+ raise HTTPException(
+ status_code=400,
+ detail=f"不支持的文件格式: {file_ext}。支持的格式: {', '.join(settings.SUPPORTED_AUDIO_FORMATS + settings.SUPPORTED_VIDEO_FORMATS)}"
+ )
+
+ upload_id = str(uuid.uuid4())
+ saved_filename = f"{upload_id}_{file.filename}"
+ saved_path = settings.UPLOAD_DIR / saved_filename
+
+ file_size = 0
+ with open(saved_path, "wb") as buffer:
+ while chunk := await file.read(8192):
+ file_size += len(chunk)
+ buffer.write(chunk)
+
+ if file_size > settings.MAX_FILE_SIZE:
+ saved_path.unlink(missing_ok=True)
+ raise HTTPException(
+ status_code=400,
+ detail=f"文件过大。最大允许: {settings.MAX_FILE_SIZE / (1024*1024):.0f} MB"
+ )
+
+ task = task_manager.create_task(
+ input_files=[str(saved_path)],
+ metadata={
+ 'original_filename': file.filename,
+ 'file_size': file_size,
+ 'file_extension': file_ext,
+ 'is_batch': False,
+ }
+ )
+
+ return {
+ "success": True,
+ "task_id": task.task_id,
+ "filename": file.filename,
+ "file_size": file_size,
+ "is_video": settings.is_video_format(file_ext),
+ }
+
+
+@app.post("/api/upload/batch")
+async def upload_batch(
+ files: List[UploadFile] = File(...),
+):
+ saved_files = []
+ errors = []
+
+ for file in files:
+ file_ext = Path(file.filename).suffix.lower()
+
+ if not settings.is_supported_format(file_ext):
+ errors.append({
+ "filename": file.filename,
+ "error": f"不支持的格式: {file_ext}"
+ })
+ continue
+
+ upload_id = str(uuid.uuid4())
+ saved_filename = f"{upload_id}_{file.filename}"
+ saved_path = settings.UPLOAD_DIR / saved_filename
+
+ try:
+ file_size = 0
+ with open(saved_path, "wb") as buffer:
+ while chunk := await file.read(8192):
+ file_size += len(chunk)
+ buffer.write(chunk)
+
+ if file_size > settings.MAX_FILE_SIZE:
+ saved_path.unlink(missing_ok=True)
+ errors.append({
+ "filename": file.filename,
+ "error": f"文件过大 (>{settings.MAX_FILE_SIZE / (1024*1024):.0f}MB)"
+ })
+ continue
+
+ saved_files.append({
+ "path": str(saved_path),
+ "filename": file.filename,
+ "file_size": file_size,
+ "is_video": settings.is_video_format(file_ext),
+ })
+ except Exception as e:
+ errors.append({
+ "filename": file.filename,
+ "error": str(e)
+ })
+
+ if not saved_files:
+ raise HTTPException(
+ status_code=400,
+ detail=f"没有成功上传的文件。错误: {errors}"
+ )
+
+ task = task_manager.create_task(
+ input_files=[f["path"] for f in saved_files],
+ metadata={
+ 'files_info': saved_files,
+ 'is_batch': True,
+ 'total_files': len(saved_files),
+ }
+ )
+
+ return {
+ "success": True,
+ "task_id": task.task_id,
+ "total_files": len(saved_files),
+ "files": saved_files,
+ "errors": errors,
+ }
+
+
+@app.post("/api/process/{task_id}")
+async def start_processing(
+ task_id: str,
+ options: ProcessOptions,
+):
+ task = task_manager.get_task(task_id)
+ if not task:
+ raise HTTPException(status_code=404, detail=f"任务不存在: {task_id}")
+
+ if task.is_running:
+ raise HTTPException(status_code=400, detail="任务正在运行中")
+
+ task.options = options.dict()
+
+ async def process_wrapper(t: ProcessingTask):
+ monitor_task = asyncio.create_task(task_progress_monitor(t))
+
+ try:
+ if len(t.input_files) > 1:
+ result = await processor.process_batch(t)
+ else:
+ result = await processor.process_single_file(t)
+ return result
+ finally:
+ monitor_task.cancel()
+ try:
+ await monitor_task
+ except asyncio.CancelledError:
+ pass
+
+ asyncio.create_task(
+ task_manager.start_task(task_id, process_wrapper)
+ )
+
+ return {
+ "success": True,
+ "task_id": task_id,
+ "message": "处理已开始",
+ }
+
+
+@app.get("/api/tasks")
+async def list_tasks(status: str = None):
+ if status == "running":
+ tasks = task_manager.get_running_tasks()
+ elif status == "finished":
+ tasks = task_manager.get_finished_tasks()
+ else:
+ tasks = task_manager.get_all_tasks()
+
+ return {
+ "tasks": [t.to_dict() for t in tasks],
+ "total": len(tasks),
+ }
+
+
+@app.get("/api/tasks/{task_id}")
+async def get_task(task_id: str):
+ task = task_manager.get_task(task_id)
+ if not task:
+ raise HTTPException(status_code=404, detail=f"任务不存在: {task_id}")
+
+ return task.to_dict()
+
+
+@app.post("/api/tasks/{task_id}/cancel")
+async def cancel_task(task_id: str):
+ task = task_manager.get_task(task_id)
+ if not task:
+ raise HTTPException(status_code=404, detail=f"任务不存在: {task_id}")
+
+ if not task.is_running:
+ raise HTTPException(status_code=400, detail="任务未在运行")
+
+ success = task_manager.cancel_task(task_id)
+
+ return {
+ "success": success,
+ "message": "取消请求已发送" if success else "取消失败",
+ }
+
+
+@app.delete("/api/tasks/{task_id}")
+async def delete_task(task_id: str):
+ task = task_manager.get_task(task_id)
+ if not task:
+ raise HTTPException(status_code=404, detail=f"任务不存在: {task_id}")
+
+ if task.is_running:
+ raise HTTPException(status_code=400, detail="无法删除正在运行的任务")
+
+ for file_path in task.input_files:
+ try:
+ Path(file_path).unlink(missing_ok=True)
+ except Exception as e:
+ logger.warning(f"Failed to delete input file: {file_path}, error: {e}")
+
+ success = task_manager.remove_task(task_id)
+
+ return {
+ "success": success,
+ }
+
+
+@app.get("/api/download/{task_id}/{format_type}")
+async def download_file(task_id: str, format_type: str):
+ task = task_manager.get_task(task_id)
+ if not task:
+ raise HTTPException(status_code=404, detail=f"任务不存在: {task_id}")
+
+ if not task.is_finished:
+ raise HTTPException(status_code=400, detail="任务尚未完成")
+
+ file_path = task.output_files.get(format_type)
+ if not file_path:
+ available_formats = list(task.output_files.keys())
+ raise HTTPException(
+ status_code=404,
+ detail=f"格式 {format_type} 不存在。可用格式: {available_formats}"
+ )
+
+ path = Path(file_path)
+ if not path.exists():
+ raise HTTPException(status_code=404, detail="文件不存在")
+
+ return FileResponse(
+ path=path,
+ filename=path.name,
+ media_type="application/octet-stream",
+ )
+
+
+@app.post("/api/editor/{task_id}/save")
+async def save_edited_subtitles(
+ task_id: str,
+ segments: List[Dict[str, Any]],
+):
+ task = task_manager.get_task(task_id)
+ if not task:
+ raise HTTPException(status_code=404, detail=f"任务不存在: {task_id}")
+
+ from subtitle_models import SubtitleDocument, SubtitleSegment
+ from subtitle_exporter import SubtitleExporter, create_styled_ass_style
+
+ doc_segments = []
+ for seg_data in segments:
+ seg = SubtitleSegment(
+ id=seg_data.get('id', 0),
+ start=seg_data.get('start', 0.0),
+ end=seg_data.get('end', 0.0),
+ text=seg_data.get('text', ''),
+ text_zh=seg_data.get('text_zh', ''),
+ text_en=seg_data.get('text_en', ''),
+ is_bilingual=seg_data.get('is_bilingual', False),
+ )
+ doc_segments.append(seg)
+
+ doc = SubtitleDocument(segments=doc_segments)
+
+ input_path = Path(task.input_files[0]) if task.input_files else Path("unknown")
+ output_base = settings.OUTPUT_DIR / input_path.stem
+ output_base.mkdir(parents=True, exist_ok=True)
+
+ base_output = str(output_base / f"{input_path.stem}_edited")
+
+ exporter = SubtitleExporter()
+ output_files = {}
+
+ srt_path = exporter.export_to_srt(doc, f"{base_output}.srt")
+ output_files['srt'] = srt_path
+
+ ass_path = exporter.export_to_ass(doc, f"{base_output}.ass", create_styled_ass_style())
+ output_files['ass'] = ass_path
+
+ vtt_path = exporter.export_to_vtt(doc, f"{base_output}.vtt")
+ output_files['vtt'] = vtt_path
+
+ task.output_files.update(output_files)
+
+ if task.result is None:
+ task.result = {}
+ task.result['edited_subtitles'] = True
+
+ return {
+ "success": True,
+ "output_files": output_files,
+ }
+
+
+@app.websocket("/ws/{task_id}")
+async def websocket_endpoint(websocket: WebSocket, task_id: str):
+ await ws_manager.connect(websocket, task_id)
+
+ try:
+ task = task_manager.get_task(task_id)
+ if task:
+ await ws_manager.send_task_progress(task)
+
+ while True:
+ data = await websocket.receive_text()
+ try:
+ message = WSMessage.from_json(data)
+
+ if message.type == "ping":
+ await ws_manager._send_to_websocket(
+ websocket,
+ WSMessage(type="pong", task_id=task_id)
+ )
+
+ except json.JSONDecodeError:
+ logger.warning(f"Invalid WebSocket message: {data}")
+
+ except WebSocketDisconnect:
+ await ws_manager.disconnect(websocket, task_id)
+ except Exception as e:
+ logger.error(f"WebSocket error: {e}")
+ await ws_manager.disconnect(websocket, task_id)
+
+
+@app.websocket("/ws")
+async def websocket_broadcast(websocket: WebSocket):
+ await ws_manager.connect(websocket, 'broadcast')
+
+ try:
+ all_tasks = task_manager.get_all_tasks()
+ await ws_manager.broadcast_task_list(all_tasks)
+
+ while True:
+ data = await websocket.receive_text()
+
+ except WebSocketDisconnect:
+ await ws_manager.disconnect(websocket, 'broadcast')
+
+
+@app.on_event("startup")
+async def startup_event():
+ logger.info("=" * 60)
+ logger.info(" VideoSubtitleAI Web 版启动中...")
+ logger.info("=" * 60)
+ logger.info(f" 上传目录: {settings.UPLOAD_DIR}")
+ logger.info(f" 输出目录: {settings.OUTPUT_DIR}")
+ logger.info(f" 临时目录: {settings.TEMP_DIR}")
+ logger.info(f" 支持格式: {settings.SUPPORTED_AUDIO_FORMATS + settings.SUPPORTED_VIDEO_FORMATS}")
+ logger.info(f" 最大文件大小: {settings.MAX_FILE_SIZE / (1024*1024):.0f} MB")
+ logger.info(f" 最大并发任务: {settings.MAX_CONCURRENT_TASKS}")
+ logger.info("=" * 60)
+ logger.info(" 访问地址: http://localhost:8000")
+ logger.info("=" * 60)
+
+
+@app.on_event("shutdown")
+async def shutdown_event():
+ logger.info("VideoSubtitleAI Web 版正在关闭...")
diff --git a/web/static/css/editor.css b/web/static/css/editor.css
new file mode 100644
index 0000000..f151b39
--- /dev/null
+++ b/web/static/css/editor.css
@@ -0,0 +1,194 @@
+.editor-row {
+ transition: all 0.15s ease;
+}
+
+.editor-row:hover {
+ background-color: #f9fafb;
+}
+
+.editor-row.active {
+ background-color: #eff6ff;
+ border-left: 3px solid #3b82f6;
+}
+
+.editor-row.selected {
+ background-color: #dbeafe;
+}
+
+.editor-textarea {
+ width: 100%;
+ min-height: 40px;
+ padding: 0.5rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ line-height: 1.4;
+ resize: vertical;
+ font-family: inherit;
+}
+
+.editor-textarea:focus {
+ outline: none;
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.time-input-editor {
+ width: 100%;
+ padding: 0.375rem 0.5rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.375rem;
+ font-size: 0.75rem;
+ font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
+ text-align: center;
+}
+
+.time-input-editor:focus {
+ outline: none;
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.action-btn {
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ color: #6b7280;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.action-btn:hover {
+ color: #374151;
+ background-color: #f3f4f6;
+}
+
+.action-btn.edit:hover {
+ color: #3b82f6;
+}
+
+.action-btn.merge:hover {
+ color: #8b5cf6;
+}
+
+.action-btn.split:hover {
+ color: #f59e0b;
+}
+
+.action-btn.delete:hover {
+ color: #ef4444;
+}
+
+.overlay-subtitle {
+ text-shadow:
+ 2px 2px 0 #000,
+ -2px -2px 0 #000,
+ 2px -2px 0 #000,
+ -2px 2px 0 #000,
+ 0px 2px 0 #000,
+ 0px -2px 0 #000,
+ 2px 0px 0 #000,
+ -2px 0px 0 #000;
+}
+
+.table-header-fixed {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+.scrollbar-thin::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+.scrollbar-thin::-webkit-scrollbar-track {
+ background: #f1f1f1;
+}
+
+.scrollbar-thin::-webkit-scrollbar-thumb {
+ background: #c1c1c1;
+ border-radius: 3px;
+}
+
+.scrollbar-thin::-webkit-scrollbar-thumb:hover {
+ background: #a1a1a1;
+}
+
+.highlight-text {
+ background-color: #fef3c7;
+}
+
+.play-indicator {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ background-color: #10b981;
+ border-radius: 50%;
+ margin-right: 0.5rem;
+ animation: pulse 1.5s infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+.undo-history-item {
+ padding: 0.5rem 1rem;
+ cursor: pointer;
+ transition: background-color 0.15s ease;
+}
+
+.undo-history-item:hover {
+ background-color: #f3f4f6;
+}
+
+.undo-history-item.current {
+ background-color: #dbeafe;
+ color: #1e40af;
+ font-weight: 500;
+}
+
+.keyboard-shortcut {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.125rem 0.375rem;
+ background-color: #f3f4f6;
+ border: 1px solid #d1d5db;
+ border-radius: 0.25rem;
+ font-size: 0.625rem;
+ font-family: 'SF Mono', 'Consolas', monospace;
+ color: #6b7280;
+}
+
+.tooltip {
+ position: relative;
+}
+
+.tooltip::after {
+ content: attr(data-tooltip);
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 0.25rem 0.5rem;
+ background-color: #1f2937;
+ color: #ffffff;
+ font-size: 0.75rem;
+ white-space: nowrap;
+ border-radius: 0.25rem;
+ opacity: 0;
+ visibility: hidden;
+ transition: all 0.15s ease;
+ z-index: 100;
+ margin-bottom: 0.25rem;
+}
+
+.tooltip:hover::after {
+ opacity: 1;
+ visibility: visible;
+}
diff --git a/web/static/css/style.css b/web/static/css/style.css
new file mode 100644
index 0000000..97353ad
--- /dev/null
+++ b/web/static/css/style.css
@@ -0,0 +1,184 @@
+.subtitle-item {
+ transition: background-color 0.15s ease;
+}
+
+.subtitle-item:hover {
+ background-color: #f3f4f6;
+}
+
+.subtitle-item.selected {
+ background-color: #dbeafe;
+}
+
+.subtitle-item.editing {
+ background-color: #fef3c7;
+}
+
+.time-input {
+ font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
+ font-size: 0.875rem;
+}
+
+.textarea-edit {
+ resize: vertical;
+ min-height: 60px;
+ font-size: 0.875rem;
+ line-height: 1.5;
+}
+
+.log-entry {
+ animation: fadeIn 0.3s ease;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(-5px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.progress-bar-animated {
+ background: linear-gradient(90deg, #3b82f6 0%, #60a5fa 50%, #3b82f6 100%);
+ background-size: 200% 100%;
+ animation: shimmer 1.5s infinite;
+}
+
+@keyframes shimmer {
+ 0% {
+ background-position: 200% 0;
+ }
+ 100% {
+ background-position: -200% 0;
+ }
+}
+
+.upload-dragover {
+ border-color: #3b82f6 !important;
+ background-color: #eff6ff !important;
+}
+
+.modal-backdrop {
+ backdrop-filter: blur(4px);
+}
+
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: #f1f1f1;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #c1c1c1;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #a1a1a1;
+}
+
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.25rem 0.75rem;
+ border-radius: 9999px;
+ font-size: 0.75rem;
+ font-weight: 500;
+}
+
+.status-pending {
+ background-color: #fef3c7;
+ color: #92400e;
+}
+
+.status-processing {
+ background-color: #dbeafe;
+ color: #1e40af;
+}
+
+.status-completed {
+ background-color: #d1fae5;
+ color: #065f46;
+}
+
+.status-failed {
+ background-color: #fee2e2;
+ color: #991b1b;
+}
+
+.status-cancelled {
+ background-color: #f3f4f6;
+ color: #4b5563;
+}
+
+.button-loading {
+ position: relative;
+ pointer-events: none;
+}
+
+.button-loading::after {
+ content: '';
+ position: absolute;
+ width: 16px;
+ height: 16px;
+ margin: auto;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ border: 2px solid transparent;
+ border-top-color: currentColor;
+ border-radius: 50%;
+ animation: button-spin 0.8s linear infinite;
+}
+
+@keyframes button-spin {
+ from {
+ transform: rotate(0turn);
+ }
+ to {
+ transform: rotate(1turn);
+ }
+}
+
+.dropdown-menu {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 50;
+ min-width: 12rem;
+ padding: 0.5rem 0;
+ margin: 0.25rem 0 0;
+ background-color: #ffffff;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+}
+
+.dropdown-item {
+ display: block;
+ width: 100%;
+ padding: 0.5rem 1rem;
+ text-align: left;
+ color: #374151;
+ cursor: pointer;
+}
+
+.dropdown-item:hover {
+ background-color: #f3f4f6;
+}
+
+.dropdown-item.danger {
+ color: #dc2626;
+}
+
+.dropdown-item.danger:hover {
+ background-color: #fef2f2;
+}
diff --git a/web/static/js/app.js b/web/static/js/app.js
new file mode 100644
index 0000000..16e34b3
--- /dev/null
+++ b/web/static/js/app.js
@@ -0,0 +1,618 @@
+class VideoSubtitleApp {
+ constructor() {
+ this.taskId = null;
+ this.ws = null;
+ this.selectedFile = null;
+ this.init();
+ }
+
+ init() {
+ this.bindEvents();
+ this.loadRecentTasks();
+ }
+
+ bindEvents() {
+ const uploadArea = document.getElementById('uploadArea');
+ const fileInput = document.getElementById('fileInput');
+ const startButton = document.getElementById('startButton');
+ const cancelButton = document.getElementById('cancelButton');
+ const removeFile = document.getElementById('removeFile');
+ const newTaskButton = document.getElementById('newTaskButton');
+
+ uploadArea.addEventListener('click', () => fileInput.click());
+
+ uploadArea.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ uploadArea.classList.add('upload-dragover');
+ });
+
+ uploadArea.addEventListener('dragleave', () => {
+ uploadArea.classList.remove('upload-dragover');
+ });
+
+ uploadArea.addEventListener('drop', (e) => {
+ e.preventDefault();
+ uploadArea.classList.remove('upload-dragover');
+ const files = e.dataTransfer.files;
+ if (files.length > 0) {
+ this.handleFileSelect(files[0]);
+ }
+ });
+
+ fileInput.addEventListener('change', (e) => {
+ if (e.target.files.length > 0) {
+ this.handleFileSelect(e.target.files[0]);
+ }
+ });
+
+ removeFile.addEventListener('click', () => this.clearFile());
+ startButton.addEventListener('click', () => this.startProcessing());
+ cancelButton.addEventListener('click', () => this.cancelTask());
+ newTaskButton.addEventListener('click', () => this.reset());
+ }
+
+ handleFileSelect(file) {
+ this.selectedFile = file;
+
+ const fileName = document.getElementById('fileName');
+ const fileSize = document.getElementById('fileSize');
+ const fileInfo = document.getElementById('fileInfo');
+ const uploadArea = document.getElementById('uploadArea');
+ const startButton = document.getElementById('startButton');
+
+ fileName.textContent = file.name;
+ fileSize.textContent = this.formatFileSize(file.size);
+
+ uploadArea.classList.add('hidden');
+ fileInfo.classList.remove('hidden');
+ startButton.disabled = false;
+
+ this.showNotification('文件已选择: ' + file.name, 'success');
+ }
+
+ clearFile() {
+ this.selectedFile = null;
+
+ const fileName = document.getElementById('fileName');
+ const fileSize = document.getElementById('fileSize');
+ const fileInfo = document.getElementById('fileInfo');
+ const uploadArea = document.getElementById('uploadArea');
+ const startButton = document.getElementById('startButton');
+ const fileInput = document.getElementById('fileInput');
+
+ fileName.textContent = '';
+ fileSize.textContent = '';
+
+ uploadArea.classList.remove('hidden');
+ fileInfo.classList.add('hidden');
+ startButton.disabled = true;
+ fileInput.value = '';
+ }
+
+ async startProcessing() {
+ if (!this.selectedFile) {
+ this.showNotification('请先选择文件', 'error');
+ return;
+ }
+
+ const startButton = document.getElementById('startButton');
+ startButton.disabled = true;
+ startButton.classList.add('button-loading');
+ startButton.textContent = '上传中...';
+
+ try {
+ const formData = new FormData();
+ formData.append('file', this.selectedFile);
+
+ const response = await fetch('/api/upload', {
+ method: 'POST',
+ body: formData
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || '上传失败');
+ }
+
+ const data = await response.json();
+ this.taskId = data.task_id;
+
+ this.showProcessingUI();
+ this.connectWebSocket();
+
+ setTimeout(() => this.sendProcessRequest(), 500);
+
+ } catch (error) {
+ this.showNotification('错误: ' + error.message, 'error');
+ startButton.disabled = false;
+ startButton.classList.remove('button-loading');
+ startButton.innerHTML = '开始处理';
+ }
+ }
+
+ showProcessingUI() {
+ const progressSection = document.getElementById('progressSection');
+ const resultSection = document.getElementById('resultSection');
+ const fileInfo = document.getElementById('fileInfo');
+ const uploadArea = document.getElementById('uploadArea');
+
+ progressSection.classList.remove('hidden');
+ resultSection.classList.add('hidden');
+ fileInfo.classList.add('hidden');
+ uploadArea.classList.add('hidden');
+
+ this.resetProgressUI();
+ }
+
+ resetProgressUI() {
+ const progressBar = document.getElementById('progressBar');
+ const progressPercent = document.getElementById('progressPercent');
+ const currentStage = document.getElementById('currentStage');
+ const stageName = document.getElementById('stageName');
+ const logContainer = document.getElementById('logContainer');
+
+ progressBar.style.width = '0%';
+ progressPercent.textContent = '0%';
+ currentStage.textContent = '准备中...';
+ stageName.textContent = '-';
+ logContainer.innerHTML = '等待任务开始...
';
+ }
+
+ connectWebSocket() {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const wsUrl = `${protocol}//${window.location.host}/ws/${this.taskId}`;
+
+ this.ws = new WebSocket(wsUrl);
+
+ this.ws.onopen = () => {
+ console.log('WebSocket connected');
+ };
+
+ this.ws.onmessage = (event) => {
+ try {
+ const message = JSON.parse(event.data);
+ this.handleWebSocketMessage(message);
+ } catch (e) {
+ console.error('Failed to parse WebSocket message:', e);
+ }
+ };
+
+ this.ws.onclose = () => {
+ console.log('WebSocket disconnected');
+ };
+
+ this.ws.onerror = (error) => {
+ console.error('WebSocket error:', error);
+ };
+ }
+
+ handleWebSocketMessage(message) {
+ switch (message.type) {
+ case 'task_progress':
+ this.updateProgressUI(message.data);
+ break;
+ case 'task_status':
+ this.handleStatusChange(message.data);
+ break;
+ case 'task_log':
+ this.addLog(message.data);
+ break;
+ }
+ }
+
+ updateProgressUI(data) {
+ const progress = data.progress;
+ const progressBar = document.getElementById('progressBar');
+ const progressPercent = document.getElementById('progressPercent');
+ const currentStage = document.getElementById('currentStage');
+ const stageName = document.getElementById('stageName');
+ const elapsedTime = document.getElementById('elapsedTime');
+ const taskStatus = document.getElementById('taskStatus');
+
+ progressBar.style.width = `${progress.progress}%`;
+ progressPercent.textContent = `${Math.round(progress.progress)}%`;
+ currentStage.textContent = progress.message || progress.stage_name;
+ stageName.textContent = progress.stage_name;
+ elapsedTime.textContent = this.formatDuration(data.duration || 0);
+
+ if (data.status === 'completed') {
+ taskStatus.textContent = '已完成';
+ taskStatus.className = 'font-medium text-green-600';
+ } else if (data.status === 'failed') {
+ taskStatus.textContent = '失败';
+ taskStatus.className = 'font-medium text-red-600';
+ } else if (data.status === 'cancelled') {
+ taskStatus.textContent = '已取消';
+ taskStatus.className = 'font-medium text-yellow-600';
+ } else {
+ taskStatus.textContent = '处理中';
+ taskStatus.className = 'font-medium text-blue-600';
+ }
+
+ if (progress.logs && progress.logs.length > 0) {
+ const logContainer = document.getElementById('logContainer');
+ const lastLog = progress.logs[progress.logs.length - 1];
+ if (!logContainer.textContent.includes(lastLog)) {
+ const p = document.createElement('p');
+ p.textContent = lastLog;
+ p.className = 'log-entry';
+ logContainer.appendChild(p);
+ logContainer.scrollTop = logContainer.scrollHeight;
+ }
+ }
+ }
+
+ handleStatusChange(data) {
+ if (data.status === 'completed') {
+ setTimeout(() => this.loadTaskResult(), 500);
+ } else if (data.status === 'failed') {
+ this.showNotification('处理失败: ' + (data.error || '未知错误'), 'error');
+ }
+ }
+
+ addLog(data) {
+ const logContainer = document.getElementById('logContainer');
+ const p = document.createElement('p');
+ p.textContent = data.message;
+ p.className = 'log-entry';
+ logContainer.appendChild(p);
+ logContainer.scrollTop = logContainer.scrollHeight;
+ }
+
+ async sendProcessRequest() {
+ const options = this.collectOptions();
+
+ try {
+ const response = await fetch(`/api/process/${this.taskId}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(options)
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || '启动处理失败');
+ }
+
+ this.showNotification('处理已开始', 'info');
+ } catch (error) {
+ this.showNotification('错误: ' + error.message, 'error');
+ }
+ }
+
+ collectOptions() {
+ const modelSelect = document.getElementById('modelSelect');
+ const languageSelect = document.getElementById('languageSelect');
+ const bilingualCheckbox = document.getElementById('bilingualCheckbox');
+ const embedCheckbox = document.getElementById('embedCheckbox');
+ const styleSelect = document.getElementById('styleSelect');
+ const fontSize = document.getElementById('fontSize');
+ const fontColor = document.getElementById('fontColor');
+ const outlineColor = document.getElementById('outlineColor');
+ const outlineWidth = document.getElementById('outlineWidth');
+ const positionSelect = document.getElementById('positionSelect');
+ const marginV = document.getElementById('marginV');
+
+ const formatCheckboxes = document.querySelectorAll('input[name="format"]:checked');
+ const formats = Array.from(formatCheckboxes).map(cb => cb.value);
+
+ return {
+ model: modelSelect.value,
+ language: languageSelect.value,
+ formats: formats.length > 0 ? formats : ['srt'],
+ bilingual: bilingualCheckbox.checked,
+ embed_video: embedCheckbox.checked,
+ style: styleSelect.value,
+ font_size: parseInt(fontSize.value) || 48,
+ font_color: fontColor.value,
+ outline_color: outlineColor.value,
+ outline_width: parseInt(outlineWidth.value) || 2,
+ position: positionSelect.value,
+ margin_v: parseInt(marginV.value) || 40,
+ embed_quality: 'high',
+ embed_gpu: false
+ };
+ }
+
+ async loadTaskResult() {
+ try {
+ const response = await fetch(`/api/tasks/${this.taskId}`);
+ if (!response.ok) {
+ throw new Error('获取任务结果失败');
+ }
+
+ const task = await response.json();
+ this.showResultUI(task);
+
+ } catch (error) {
+ console.error('Failed to load task result:', error);
+ }
+ }
+
+ showResultUI(task) {
+ const progressSection = document.getElementById('progressSection');
+ const resultSection = document.getElementById('resultSection');
+ const resultLanguage = document.getElementById('resultLanguage');
+ const resultSegments = document.getElementById('resultSegments');
+ const downloadList = document.getElementById('downloadList');
+ const editButton = document.getElementById('editButton');
+
+ progressSection.classList.add('hidden');
+ resultSection.classList.remove('hidden');
+
+ if (task.result) {
+ resultLanguage.textContent = task.result.language_name || '未知';
+ resultSegments.textContent = task.result.segment_count || 0;
+ } else {
+ resultLanguage.textContent = '-';
+ resultSegments.textContent = '-';
+ }
+
+ downloadList.innerHTML = '';
+
+ if (task.output_files) {
+ const formatNames = {
+ 'srt': 'SRT 字幕',
+ 'ass': 'ASS 字幕',
+ 'vtt': 'VTT 字幕',
+ 'embedded_video': '带字幕视频'
+ };
+
+ const formatIcons = {
+ 'srt': 'ri-file-text-line',
+ 'ass': 'ri-file-3-line',
+ 'vtt': 'ri-file-code-line',
+ 'embedded_video': 'ri-video-download-line'
+ };
+
+ for (const [format, path] of Object.entries(task.output_files)) {
+ const item = document.createElement('a');
+ item.href = `/api/download/${this.taskId}/${format}`;
+ item.className = 'flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors';
+
+ const formatName = formatNames[format] || format.toUpperCase();
+ const iconClass = formatIcons[format] || 'ri-file-line';
+
+ item.innerHTML = `
+
+
+
+
${formatName}
+
${this.extractFilename(path)}
+
+
+
+ `;
+
+ downloadList.appendChild(item);
+ }
+ }
+
+ if (Object.keys(task.output_files || {}).length > 0) {
+ editButton.href = `/editor?task_id=${this.taskId}`;
+ editButton.classList.remove('hidden');
+ } else {
+ editButton.classList.add('hidden');
+ }
+
+ this.showNotification('处理完成!', 'success');
+ this.loadRecentTasks();
+ }
+
+ extractFilename(path) {
+ if (!path) return '';
+ const parts = path.split(/[\\/]/);
+ return parts[parts.length - 1];
+ }
+
+ async cancelTask() {
+ if (!this.taskId) return;
+
+ try {
+ const response = await fetch(`/api/tasks/${this.taskId}/cancel`, {
+ method: 'POST'
+ });
+
+ if (response.ok) {
+ this.showNotification('取消请求已发送', 'info');
+ }
+ } catch (error) {
+ this.showNotification('取消失败: ' + error.message, 'error');
+ }
+ }
+
+ async loadRecentTasks() {
+ try {
+ const response = await fetch('/api/tasks');
+ if (!response.ok) return;
+
+ const data = await response.json();
+ this.renderTaskList(data.tasks.slice(0, 5));
+ } catch (error) {
+ console.error('Failed to load tasks:', error);
+ }
+ }
+
+ renderTaskList(tasks) {
+ const taskList = document.getElementById('taskList');
+
+ if (tasks.length === 0) {
+ taskList.innerHTML = '暂无任务记录
';
+ return;
+ }
+
+ taskList.innerHTML = '';
+
+ const statusIcons = {
+ 'pending': 'ri-time-line text-gray-400',
+ 'queued': 'ri-time-line text-yellow-500',
+ 'processing': 'ri-loader-4-line text-blue-500 animate-spin',
+ 'completed': 'ri-checkbox-circle-line text-green-500',
+ 'failed': 'ri-close-circle-line text-red-500',
+ 'cancelled': 'ri-stop-circle-line text-yellow-500'
+ };
+
+ for (const task of tasks) {
+ const item = document.createElement('div');
+ item.className = 'flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer';
+
+ const iconClass = statusIcons[task.status] || 'ri-question-line text-gray-400';
+ const statusText = this.getStatusText(task.status);
+
+ item.innerHTML = `
+
+
+
+
${this.getTaskFilename(task)}
+
${statusText} · ${this.formatDuration(task.duration || 0)}
+
+
+
+ ${task.status === 'completed' ? `
+
+ ` : ''}
+
+ `;
+
+ if (task.status === 'completed') {
+ item.addEventListener('click', (e) => {
+ if (!e.target.closest('button')) {
+ window.location.href = `/editor?task_id=${task.task_id}`;
+ }
+ });
+ }
+
+ taskList.appendChild(item);
+ }
+ }
+
+ getTaskFilename(task) {
+ if (task.metadata && task.metadata.original_filename) {
+ return task.metadata.original_filename;
+ }
+ if (task.input_files && task.input_files.length > 0) {
+ const path = task.input_files[0];
+ const parts = path.split(/[\\/]/);
+ return parts[parts.length - 1];
+ }
+ return task.task_id.substring(0, 8) + '...';
+ }
+
+ getStatusText(status) {
+ const texts = {
+ 'pending': '等待中',
+ 'queued': '队列中',
+ 'processing': '处理中',
+ 'completed': '已完成',
+ 'failed': '失败',
+ 'cancelled': '已取消'
+ };
+ return texts[status] || status;
+ }
+
+ async downloadAllTaskFiles(taskId) {
+ try {
+ const response = await fetch(`/api/tasks/${taskId}`);
+ if (!response.ok) return;
+
+ const task = await response.json();
+
+ if (task.output_files) {
+ for (const format of Object.keys(task.output_files)) {
+ window.open(`/api/download/${taskId}/${format}`, '_blank');
+ await new Promise(resolve => setTimeout(resolve, 500));
+ }
+ }
+ } catch (error) {
+ this.showNotification('下载失败: ' + error.message, 'error');
+ }
+ }
+
+ reset() {
+ this.taskId = null;
+ this.selectedFile = null;
+
+ if (this.ws) {
+ this.ws.close();
+ this.ws = null;
+ }
+
+ const progressSection = document.getElementById('progressSection');
+ const resultSection = document.getElementById('resultSection');
+ const fileInfo = document.getElementById('fileInfo');
+ const uploadArea = document.getElementById('uploadArea');
+ const startButton = document.getElementById('startButton');
+ const fileInput = document.getElementById('fileInput');
+
+ progressSection.classList.add('hidden');
+ resultSection.classList.add('hidden');
+ fileInfo.classList.add('hidden');
+ uploadArea.classList.remove('hidden');
+ startButton.disabled = true;
+ startButton.classList.remove('button-loading');
+ startButton.innerHTML = '开始处理';
+ fileInput.value = '';
+
+ this.resetProgressUI();
+ }
+
+ formatFileSize(bytes) {
+ if (bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ }
+
+ formatDuration(seconds) {
+ if (seconds < 60) {
+ return `${Math.round(seconds)}s`;
+ } else if (seconds < 3600) {
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ return `${mins}m ${secs}s`;
+ } else {
+ const hours = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ return `${hours}h ${mins}m`;
+ }
+ }
+
+ showNotification(message, type = 'info') {
+ const notification = document.getElementById('notification');
+ const notificationIcon = document.getElementById('notificationIcon');
+ const notificationText = document.getElementById('notificationText');
+
+ const icons = {
+ 'info': 'ri-information-line text-blue-500',
+ 'success': 'ri-checkbox-circle-line text-green-500',
+ 'error': 'ri-error-warning-line text-red-500',
+ 'warning': 'ri-alert-line text-yellow-500'
+ };
+
+ const borders = {
+ 'info': 'border-l-blue-500',
+ 'success': 'border-l-green-500',
+ 'error': 'border-l-red-500',
+ 'warning': 'border-l-yellow-500'
+ };
+
+ notificationText.textContent = message;
+ notificationIcon.className = icons[type] + ' text-xl mr-3';
+
+ const notificationDiv = notification.querySelector('div');
+ notificationDiv.className = `bg-white rounded-lg shadow-lg p-4 border-l-4 ${borders[type]} flex items-center`;
+
+ notification.classList.remove('hidden');
+
+ setTimeout(() => {
+ notification.classList.add('hidden');
+ }, 3000);
+ }
+}
+
+const app = new VideoSubtitleApp();
diff --git a/web/static/js/batch.js b/web/static/js/batch.js
new file mode 100644
index 0000000..6e8ad50
--- /dev/null
+++ b/web/static/js/batch.js
@@ -0,0 +1,557 @@
+class BatchProcessor {
+ constructor() {
+ this.taskId = null;
+ this.selectedFiles = [];
+ this.ws = null;
+
+ this.init();
+ }
+
+ init() {
+ this.bindEvents();
+ }
+
+ bindEvents() {
+ const uploadArea = document.getElementById('batchUploadArea');
+ const fileInput = document.getElementById('batchFileInput');
+ const startButton = document.getElementById('batchStartButton');
+ const cancelButton = document.getElementById('batchCancelButton');
+ const clearFiles = document.getElementById('clearFiles');
+ const newTaskButton = document.getElementById('batchNewTaskButton');
+
+ uploadArea.addEventListener('click', () => fileInput.click());
+
+ uploadArea.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ uploadArea.classList.add('upload-dragover');
+ });
+
+ uploadArea.addEventListener('dragleave', () => {
+ uploadArea.classList.remove('upload-dragover');
+ });
+
+ uploadArea.addEventListener('drop', (e) => {
+ e.preventDefault();
+ uploadArea.classList.remove('upload-dragover');
+ const files = Array.from(e.dataTransfer.files);
+ this.handleFilesSelect(files);
+ });
+
+ fileInput.addEventListener('change', (e) => {
+ const files = Array.from(e.target.files);
+ this.handleFilesSelect(files);
+ });
+
+ clearFiles.addEventListener('click', () => this.clearAllFiles());
+ startButton.addEventListener('click', () => this.startBatchProcessing());
+ cancelButton.addEventListener('click', () => this.cancelTask());
+ newTaskButton.addEventListener('click', () => this.reset());
+ }
+
+ handleFilesSelect(files) {
+ const validExtensions = [
+ '.mp4', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.webm',
+ '.mp3', '.wav', '.m4a', '.flac', '.aac', '.ogg'
+ ];
+
+ let added = 0;
+ let skipped = 0;
+
+ files.forEach(file => {
+ const ext = '.' + file.name.split('.').pop().toLowerCase();
+
+ if (!validExtensions.includes(ext)) {
+ skipped++;
+ return;
+ }
+
+ const exists = this.selectedFiles.some(f => f.name === file.name && f.size === file.size);
+ if (!exists) {
+ this.selectedFiles.push(file);
+ added++;
+ }
+ });
+
+ this.renderFileList();
+
+ if (added > 0) {
+ this.showNotification(`已添加 ${added} 个文件`, 'success');
+ }
+ if (skipped > 0) {
+ this.showNotification(`跳过了 ${skipped} 个不支持的文件`, 'warning');
+ }
+ }
+
+ renderFileList() {
+ const fileListContainer = document.getElementById('fileListContainer');
+ const fileCount = document.getElementById('fileCount');
+ const batchFileList = document.getElementById('batchFileList');
+ const startButton = document.getElementById('batchStartButton');
+
+ fileCount.textContent = this.selectedFiles.length;
+
+ if (this.selectedFiles.length === 0) {
+ batchFileList.classList.add('hidden');
+ startButton.disabled = true;
+ fileListContainer.innerHTML = '暂无文件
';
+ return;
+ }
+
+ batchFileList.classList.remove('hidden');
+ startButton.disabled = false;
+
+ fileListContainer.innerHTML = '';
+
+ this.selectedFiles.forEach((file, index) => {
+ const ext = '.' + file.name.split('.').pop().toLowerCase();
+ const isVideo = ['.mp4', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.webm'].includes(ext);
+
+ const item = document.createElement('div');
+ item.className = 'flex items-center justify-between p-3 bg-gray-50 rounded-lg';
+ item.dataset.index = index;
+
+ item.innerHTML = `
+
+
+
+
${file.name}
+
${this.formatFileSize(file.size)} · ${isVideo ? '视频' : '音频'}
+
+
+
+ `;
+
+ fileListContainer.appendChild(item);
+ });
+
+ fileListContainer.querySelectorAll('.remove-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ const index = parseInt(e.currentTarget.dataset.index);
+ this.removeFile(index);
+ });
+ });
+ }
+
+ removeFile(index) {
+ this.selectedFiles.splice(index, 1);
+ this.renderFileList();
+ }
+
+ clearAllFiles() {
+ this.selectedFiles = [];
+ this.renderFileList();
+ this.showNotification('已清空文件列表', 'info');
+ }
+
+ async startBatchProcessing() {
+ if (this.selectedFiles.length === 0) {
+ this.showNotification('请先选择要处理的文件', 'warning');
+ return;
+ }
+
+ const startButton = document.getElementById('batchStartButton');
+ startButton.disabled = true;
+ startButton.classList.add('button-loading');
+ startButton.textContent = '上传中...';
+
+ try {
+ const formData = new FormData();
+ this.selectedFiles.forEach(file => {
+ formData.append('files', file);
+ });
+
+ const response = await fetch('/api/upload/batch', {
+ method: 'POST',
+ body: formData
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || '上传失败');
+ }
+
+ const data = await response.json();
+ this.taskId = data.task_id;
+
+ this.showProcessingUI();
+ this.connectWebSocket();
+
+ setTimeout(() => this.sendProcessRequest(), 500);
+
+ } catch (error) {
+ this.showNotification('错误: ' + error.message, 'error');
+ startButton.disabled = false;
+ startButton.classList.remove('button-loading');
+ startButton.innerHTML = '开始批量处理';
+ }
+ }
+
+ showProcessingUI() {
+ const progressSection = document.getElementById('batchProgressSection');
+ const resultSection = document.getElementById('batchResultSection');
+ const batchFileList = document.getElementById('batchFileList');
+
+ progressSection.classList.remove('hidden');
+ resultSection.classList.add('hidden');
+ batchFileList.classList.add('hidden');
+
+ this.resetProgressUI();
+ }
+
+ resetProgressUI() {
+ const progressBar = document.getElementById('batchProgressBar');
+ const progressPercent = document.getElementById('batchProgressPercent');
+ const processed = document.getElementById('batchProcessed');
+ const elapsed = document.getElementById('batchElapsed');
+ const currentFile = document.getElementById('currentProcessingFile');
+ const logContainer = document.getElementById('batchLogContainer');
+
+ progressBar.style.width = '0%';
+ progressPercent.textContent = '0%';
+ processed.textContent = `0 / ${this.selectedFiles.length}`;
+ elapsed.textContent = '0s';
+ currentFile.textContent = '等待中...';
+ logContainer.innerHTML = '等待任务开始...
';
+ }
+
+ connectWebSocket() {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const wsUrl = `${protocol}//${window.location.host}/ws/${this.taskId}`;
+
+ this.ws = new WebSocket(wsUrl);
+
+ this.ws.onopen = () => {
+ console.log('WebSocket connected');
+ };
+
+ this.ws.onmessage = (event) => {
+ try {
+ const message = JSON.parse(event.data);
+ this.handleWebSocketMessage(message);
+ } catch (e) {
+ console.error('Failed to parse WebSocket message:', e);
+ }
+ };
+
+ this.ws.onclose = () => {
+ console.log('WebSocket disconnected');
+ };
+
+ this.ws.onerror = (error) => {
+ console.error('WebSocket error:', error);
+ };
+ }
+
+ handleWebSocketMessage(message) {
+ switch (message.type) {
+ case 'task_progress':
+ this.updateProgressUI(message.data);
+ break;
+ case 'task_status':
+ this.handleStatusChange(message.data);
+ break;
+ case 'task_log':
+ this.addLog(message.data);
+ break;
+ }
+ }
+
+ updateProgressUI(data) {
+ const progress = data.progress;
+ const progressBar = document.getElementById('batchProgressBar');
+ const progressPercent = document.getElementById('batchProgressPercent');
+ const processed = document.getElementById('batchProcessed');
+ const elapsed = document.getElementById('batchElapsed');
+ const currentFile = document.getElementById('currentProcessingFile');
+
+ const currentProgress = (progress.processed_files / progress.total_files) * 100;
+ progressBar.style.width = `${currentProgress}%`;
+ progressPercent.textContent = `${Math.round(currentProgress)}%`;
+
+ processed.textContent = `${progress.processed_files} / ${progress.total_files}`;
+ elapsed.textContent = this.formatDuration(data.duration || 0);
+
+ if (progress.current_file) {
+ currentFile.textContent = progress.current_file;
+ }
+
+ if (progress.logs && progress.logs.length > 0) {
+ const logContainer = document.getElementById('batchLogContainer');
+ const lastLog = progress.logs[progress.logs.length - 1];
+ const p = document.createElement('p');
+ p.textContent = lastLog;
+ p.className = 'log-entry';
+ logContainer.appendChild(p);
+ logContainer.scrollTop = logContainer.scrollHeight;
+ }
+ }
+
+ handleStatusChange(data) {
+ if (data.status === 'completed') {
+ setTimeout(() => this.loadTaskResult(), 500);
+ } else if (data.status === 'failed') {
+ this.showNotification('处理失败: ' + (data.error || '未知错误'), 'error';
+ }
+ }
+
+ addLog(data) {
+ const logContainer = document.getElementById('batchLogContainer');
+ const p = document.createElement('p');
+ p.textContent = data.message;
+ p.className = 'log-entry';
+ logContainer.appendChild(p);
+ logContainer.scrollTop = logContainer.scrollHeight;
+ }
+
+ async sendProcessRequest() {
+ const options = this.collectOptions();
+
+ try {
+ const response = await fetch(`/api/process/${this.taskId}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(options)
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || '启动处理失败');
+ }
+
+ this.showNotification('批量处理已开始', 'info');
+ } catch (error) {
+ this.showNotification('错误: ' + error.message, 'error');
+ }
+ }
+
+ collectOptions() {
+ const modelSelect = document.getElementById('batchModelSelect');
+ const languageSelect = document.getElementById('batchLanguageSelect');
+ const bilingualCheckbox = document.getElementById('batchBilingual');
+ const styleSelect = document.getElementById('batchStyleSelect');
+ const fontSize = document.getElementById('batchFontSize');
+ const positionSelect = document.getElementById('batchPosition');
+
+ const formatCheckboxes = document.querySelectorAll('input[name="batchFormat"]:checked');
+ const formats = Array.from(formatCheckboxes).map(cb => cb.value);
+
+ return {
+ model: modelSelect.value,
+ language: languageSelect.value,
+ formats: formats.length > 0 ? formats : ['srt'],
+ bilingual: bilingualCheckbox.checked,
+ style: styleSelect.value,
+ font_size: parseInt(fontSize.value) || 48,
+ position: positionSelect.value,
+ embed_video: false,
+ embed_quality: 'high',
+ embed_gpu: false
+ };
+ }
+
+ async loadTaskResult() {
+ try {
+ const response = await fetch(`/api/tasks/${this.taskId}`);
+ if (!response.ok) {
+ throw new Error('获取任务结果失败');
+ }
+
+ const task = await response.json();
+ this.showResultUI(task);
+
+ } catch (error) {
+ console.error('Failed to load task result:', error);
+ }
+ }
+
+ showResultUI(task) {
+ const progressSection = document.getElementById('batchProgressSection');
+ const resultSection = document.getElementById('batchResultSection');
+ const successCount = document.getElementById('batchSuccess');
+ const failedCount = document.getElementById('batchFailed');
+ const totalTime = document.getElementById('batchTotalTime');
+ const resultList = document.getElementById('batchResultList');
+
+ progressSection.classList.add('hidden');
+ resultSection.classList.remove('hidden');
+
+ if (task.result) {
+ successCount.textContent = task.result.processed_files || 0;
+ failedCount.textContent = task.result.failed_files || 0;
+ } else {
+ successCount.textContent = task.status === 'completed' ? this.selectedFiles.length : 0;
+ failedCount.textContent = task.status === 'failed' ? this.selectedFiles.length : 0;
+ }
+
+ totalTime.textContent = this.formatDuration(task.duration || 0);
+
+ resultList.innerHTML = '';
+
+ if (task.result && task.result.results) {
+ task.result.results.forEach((result, index) => {
+ const item = document.createElement('div');
+ item.className = 'flex items-center justify-between p-3 bg-gray-50 rounded-lg';
+
+ const filename = this.extractFilename(result.input_path || 'unknown');
+
+ item.innerHTML = `
+
+
+
+
${filename}
+
${result.segment_count || 0} 段字幕 · ${result.total_characters || 0} 字
+
+
+ `;
+
+ resultList.appendChild(item);
+ });
+
+ if (task.result.errors && task.result.errors.length > 0) {
+ task.result.errors.forEach(err => {
+ const item = document.createElement('div');
+ item.className = 'flex items-center justify-between p-3 bg-red-50 rounded-lg';
+
+ item.innerHTML = `
+
+
+
+
${this.extractFilename(err.file || 'unknown')}
+
${err.error || '处理失败'}
+
+
+ `;
+
+ resultList.appendChild(item);
+ });
+ }
+ } else if (task.status === 'completed') {
+ this.selectedFiles.forEach(file => {
+ const item = document.createElement('div');
+ item.className = 'flex items-center justify-between p-3 bg-gray-50 rounded-lg';
+
+ item.innerHTML = `
+
+ `;
+
+ resultList.appendChild(item);
+ });
+ }
+
+ this.showNotification('批量处理完成!', 'success');
+ }
+
+ extractFilename(path) {
+ if (!path) return '';
+ const parts = path.split(/[\\/]/);
+ return parts[parts.length - 1];
+ }
+
+ async cancelTask() {
+ if (!this.taskId) return;
+
+ try {
+ const response = await fetch(`/api/tasks/${this.taskId}/cancel`, {
+ method: 'POST'
+ });
+
+ if (response.ok) {
+ this.showNotification('取消请求已发送', 'info');
+ }
+ } catch (error) {
+ this.showNotification('取消失败: ' + error.message, 'error');
+ }
+ }
+
+ reset() {
+ this.taskId = null;
+ this.selectedFiles = [];
+
+ if (this.ws) {
+ this.ws.close();
+ this.ws = null;
+ }
+
+ const progressSection = document.getElementById('batchProgressSection');
+ const resultSection = document.getElementById('batchResultSection');
+ const batchFileList = document.getElementById('batchFileList');
+ const startButton = document.getElementById('batchStartButton');
+ const fileInput = document.getElementById('batchFileInput');
+
+ progressSection.classList.add('hidden');
+ resultSection.classList.add('hidden');
+ batchFileList.classList.add('hidden');
+ startButton.disabled = true;
+ startButton.classList.remove('button-loading');
+ startButton.innerHTML = '开始批量处理';
+ fileInput.value = '';
+
+ this.renderFileList();
+ }
+
+ formatFileSize(bytes) {
+ if (bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
+ }
+
+ formatDuration(seconds) {
+ if (seconds < 60) {
+ return `${Math.round(seconds)}s`;
+ } else if (seconds < 3600) {
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ return `${mins}m ${secs}s`;
+ } else {
+ const hours = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ return `${hours}h ${mins}m`;
+ }
+ }
+
+ showNotification(message, type = 'info') {
+ const notification = document.getElementById('notification');
+ const notificationIcon = document.getElementById('notificationIcon');
+ const notificationText = document.getElementById('notificationText');
+
+ const icons = {
+ 'info': 'ri-information-line text-blue-500',
+ 'success': 'ri-checkbox-circle-line text-green-500',
+ 'error': 'ri-error-warning-line text-red-500',
+ 'warning': 'ri-alert-line text-yellow-500'
+ };
+
+ const borders = {
+ 'info': 'border-l-blue-500',
+ 'success': 'border-l-green-500',
+ 'error': 'border-l-red-500',
+ 'warning': 'border-l-yellow-500'
+ };
+
+ notificationText.textContent = message;
+ notificationIcon.className = icons[type] + ' text-xl mr-3';
+
+ const notificationDiv = notification.querySelector('div');
+ notificationDiv.className = `bg-white rounded-lg shadow-lg p-4 border-l-4 ${borders[type]} flex items-center`;
+
+ notification.classList.remove('hidden');
+
+ setTimeout(() => {
+ notification.classList.add('hidden');
+ }, 3000);
+ }
+}
+
+const batchProcessor = new BatchProcessor();
diff --git a/web/static/js/editor.js b/web/static/js/editor.js
new file mode 100644
index 0000000..bc90378
--- /dev/null
+++ b/web/static/js/editor.js
@@ -0,0 +1,930 @@
+class SubtitleEditor {
+ constructor() {
+ this.taskId = null;
+ this.subtitles = [];
+ this.originalSubtitles = [];
+ this.selectedIndices = new Set();
+ this.editingIndex = null;
+ this.history = [];
+ this.historyIndex = -1;
+ this.ws = null;
+
+ this.init();
+ }
+
+ init() {
+ this.taskId = this.getQueryParam('task_id');
+ this.bindEvents();
+ this.loadSubtitles();
+ }
+
+ getQueryParam(name) {
+ const urlParams = new URLSearchParams(window.location.search);
+ return urlParams.get(name);
+ }
+
+ bindEvents() {
+ const undoButton = document.getElementById('undoButton');
+ const redoButton = document.getElementById('redoButton');
+ const saveButton = document.getElementById('saveButton');
+ const exportButton = document.getElementById('exportButton');
+ const mergeSelected = document.getElementById('mergeSelected');
+ const splitSelected = document.getElementById('splitSelected');
+ const shiftEarlier = document.getElementById('shiftEarlier');
+ const shiftLater = document.getElementById('shiftLater');
+ const selectAll = document.getElementById('selectAll');
+ const searchInput = document.getElementById('searchInput');
+ const searchButton = document.getElementById('searchButton');
+ const cancelSplit = document.getElementById('cancelSplit');
+ const confirmSplit = document.getElementById('confirmSplit');
+ const cancelExport = document.getElementById('cancelExport');
+ const confirmExport = document.getElementById('confirmExport');
+
+ undoButton.addEventListener('click', () => this.undo());
+ redoButton.addEventListener('click', () => this.redo());
+ saveButton.addEventListener('click', () => this.save());
+ exportButton.addEventListener('click', () => this.showExportModal());
+ mergeSelected.addEventListener('click', () => this.mergeSelectedSubtitles());
+ splitSelected.addEventListener('click', () => this.showSplitModal());
+ shiftEarlier.addEventListener('click', () => this.shiftTime(-1));
+ shiftLater.addEventListener('click', () => this.shiftTime(1));
+ selectAll.addEventListener('change', (e) => this.toggleSelectAll(e.target.checked));
+ searchButton.addEventListener('click', () => this.search());
+ searchInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') this.search();
+ });
+ cancelSplit.addEventListener('click', () => this.hideSplitModal());
+ confirmSplit.addEventListener('click', () => this.confirmSplitSubtitle());
+ cancelExport.addEventListener('click', () => this.hideExportModal());
+ confirmExport.addEventListener('click', () => this.exportSubtitles());
+
+ this.bindStyleEvents();
+ }
+
+ bindStyleEvents() {
+ const styleInputs = [
+ 'editorFontSize', 'editorFontColor', 'editorOutlineColor',
+ 'editorPosition', 'editorOutlineWidth', 'editorMarginV'
+ ];
+
+ styleInputs.forEach(id => {
+ const el = document.getElementById(id);
+ if (el) {
+ el.addEventListener('input', () => this.updateStylePreview());
+ el.addEventListener('change', () => this.updateStylePreview());
+ }
+ });
+ }
+
+ updateStylePreview() {
+ const fontSize = document.getElementById('editorFontSize')?.value || 48;
+ const fontColor = document.getElementById('editorFontColor')?.value || '#FFFFFF';
+ const outlineColor = document.getElementById('editorOutlineColor')?.value || '#000000';
+ const outlineWidth = document.getElementById('editorOutlineWidth')?.value || 2;
+
+ const previewText = document.getElementById('previewText');
+ if (previewText) {
+ const previewSize = Math.round(parseInt(fontSize) / 2);
+ previewText.style.fontSize = `${previewSize}px`;
+ previewText.style.color = fontColor;
+
+ const shadowSize = Math.round(parseInt(outlineWidth) / 2);
+ previewText.style.textShadow = `
+ ${shadowSize}px ${shadowSize}px 0 ${outlineColor},
+ -${shadowSize}px -${shadowSize}px 0 ${outlineColor},
+ ${shadowSize}px -${shadowSize}px 0 ${outlineColor},
+ -${shadowSize}px ${shadowSize}px 0 ${outlineColor}
+ `;
+ }
+ }
+
+ async loadSubtitles() {
+ if (!this.taskId) {
+ this.showNotification('未找到任务ID', 'error');
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/tasks/${this.taskId}`);
+ if (!response.ok) {
+ throw new Error('获取任务数据失败');
+ }
+
+ const task = await response.json();
+
+ if (task.result && task.result.subtitle_document) {
+ this.subtitles = task.result.subtitle_document.segments || [];
+ this.originalSubtitles = JSON.parse(JSON.stringify(this.subtitles));
+ } else if (task.result) {
+ this.subtitles = [];
+ this.originalSubtitles = [];
+ }
+
+ this.renderSubtitles();
+ this.updateStats();
+ this.saveState();
+
+ } catch (error) {
+ this.showNotification('加载失败: ' + error.message, 'error');
+ this.renderEmptyState();
+ }
+ }
+
+ renderSubtitles() {
+ const subtitleList = document.getElementById('subtitleList');
+
+ if (this.subtitles.length === 0) {
+ this.renderEmptyState();
+ return;
+ }
+
+ subtitleList.innerHTML = '';
+
+ this.subtitles.forEach((sub, index) => {
+ const row = document.createElement('tr');
+ row.className = `editor-row hover:bg-gray-50 transition-colors ${this.selectedIndices.has(index) ? 'selected bg-blue-50' : ''}`;
+ row.dataset.index = index;
+
+ row.innerHTML = `
+
+
+ |
+ ${index + 1} |
+
+
+ |
+
+
+ |
+
+
+ ${this.escapeHtml(sub.text)}
+
+
+ |
+
+
+
+
+
+
+
+ |
+ `;
+
+ subtitleList.appendChild(row);
+ });
+
+ this.bindTableEvents();
+ }
+
+ bindTableEvents() {
+ const subtitleList = document.getElementById('subtitleList');
+
+ subtitleList.addEventListener('change', (e) => {
+ if (e.target.classList.contains('subtitle-checkbox')) {
+ const index = parseInt(e.target.dataset.index);
+ if (e.target.checked) {
+ this.selectedIndices.add(index);
+ } else {
+ this.selectedIndices.delete(index);
+ }
+ this.updateSelectionUI();
+ }
+ });
+
+ subtitleList.addEventListener('blur', (e) => {
+ if (e.target.classList.contains('time-input-editor')) {
+ this.handleTimeChange(e.target);
+ }
+ }, true);
+
+ subtitleList.addEventListener('keydown', (e) => {
+ if (e.target.classList.contains('time-input-editor') && e.key === 'Enter') {
+ e.target.blur();
+ }
+ });
+
+ subtitleList.addEventListener('click', (e) => {
+ const actionBtn = e.target.closest('[data-action]');
+ if (actionBtn) {
+ const action = actionBtn.dataset.action;
+ const index = parseInt(actionBtn.dataset.index);
+ this.handleAction(action, index);
+ return;
+ }
+
+ const textDisplay = e.target.closest('.subtitle-text-display');
+ if (textDisplay) {
+ const index = parseInt(textDisplay.dataset.index);
+ this.startEditing(index);
+ }
+ });
+
+ subtitleList.addEventListener('keydown', (e) => {
+ if (e.target.classList.contains('editor-textarea') && e.key === 'Escape') {
+ this.cancelEditing();
+ }
+ });
+
+ subtitleList.addEventListener('blur', (e) => {
+ if (e.target.classList.contains('editor-textarea')) {
+ this.finishEditing();
+ }
+ }, true);
+ }
+
+ handleTimeChange(input) {
+ const index = parseInt(input.dataset.index);
+ const field = input.dataset.field;
+ const newTimeStr = input.value;
+
+ try {
+ const newSeconds = this.parseTime(newTimeStr);
+ if (!isNaN(newSeconds)) {
+ if (this.subtitles[index][field] !== newSeconds) {
+ this.saveState();
+ this.subtitles[index][field] = newSeconds;
+ this.updateStats();
+ this.showNotification('时间已更新', 'info');
+ }
+ } else {
+ throw new Error('无效的时间格式');
+ }
+ } catch (error) {
+ input.value = this.formatTime(this.subtitles[index][field]);
+ this.showNotification('无效的时间格式', 'error');
+ }
+ }
+
+ handleAction(action, index) {
+ switch (action) {
+ case 'edit':
+ this.startEditing(index);
+ break;
+ case 'split':
+ this.splitIndex = index;
+ this.showSplitModal();
+ break;
+ case 'merge':
+ this.mergeWithNext(index);
+ break;
+ case 'delete':
+ this.deleteSubtitle(index);
+ break;
+ }
+ }
+
+ startEditing(index) {
+ if (this.editingIndex !== null) {
+ this.finishEditing();
+ }
+
+ this.editingIndex = index;
+ const row = document.querySelector(`tr[data-index="${index}"]`);
+ if (!row) return;
+
+ const display = row.querySelector('.subtitle-text-display');
+ const textarea = row.querySelector('.editor-textarea');
+
+ if (display && textarea) {
+ display.classList.add('hidden');
+ textarea.classList.remove('hidden');
+ textarea.focus();
+ textarea.select();
+ }
+ }
+
+ finishEditing() {
+ if (this.editingIndex === null) return;
+
+ const row = document.querySelector(`tr[data-index="${this.editingIndex}"]`);
+ if (!row) {
+ this.editingIndex = null;
+ return;
+ }
+
+ const display = row.querySelector('.subtitle-text-display');
+ const textarea = row.querySelector('.editor-textarea');
+
+ if (display && textarea) {
+ const newText = textarea.value.trim();
+
+ if (newText !== this.subtitles[this.editingIndex].text) {
+ this.saveState();
+ this.subtitles[this.editingIndex].text = newText;
+ display.innerHTML = this.escapeHtml(newText);
+ this.updateStats();
+ }
+
+ textarea.classList.add('hidden');
+ display.classList.remove('hidden');
+ }
+
+ this.editingIndex = null;
+ }
+
+ cancelEditing() {
+ if (this.editingIndex === null) return;
+
+ const row = document.querySelector(`tr[data-index="${this.editingIndex}"]`);
+ if (!row) {
+ this.editingIndex = null;
+ return;
+ }
+
+ const display = row.querySelector('.subtitle-text-display');
+ const textarea = row.querySelector('.editor-textarea');
+
+ if (display && textarea) {
+ textarea.value = this.subtitles[this.editingIndex].text;
+ textarea.classList.add('hidden');
+ display.classList.remove('hidden');
+ }
+
+ this.editingIndex = null;
+ }
+
+ toggleSelectAll(checked) {
+ const checkboxes = document.querySelectorAll('.subtitle-checkbox');
+ checkboxes.forEach(cb => {
+ cb.checked = checked;
+ const index = parseInt(cb.dataset.index);
+ if (checked) {
+ this.selectedIndices.add(index);
+ } else {
+ this.selectedIndices.delete(index);
+ }
+ });
+
+ this.updateSelectionUI();
+ }
+
+ updateSelectionUI() {
+ const mergeBtn = document.getElementById('mergeSelected');
+ const splitBtn = document.getElementById('splitSelected');
+
+ const hasSelection = this.selectedIndices.size > 0;
+ const canMerge = this.selectedIndices.size >= 2;
+
+ mergeBtn.disabled = !canMerge;
+ splitBtn.disabled = !hasSelection;
+
+ const selectAll = document.getElementById('selectAll');
+ const totalCheckboxes = document.querySelectorAll('.subtitle-checkbox').length;
+ selectAll.checked = this.selectedIndices.size === totalCheckboxes && totalCheckboxes > 0;
+ }
+
+ mergeSelectedSubtitles() {
+ if (this.selectedIndices.size < 2) {
+ this.showNotification('请至少选择两条字幕进行合并', 'warning');
+ return;
+ }
+
+ const sortedIndices = Array.from(this.selectedIndices).sort((a, b) => a - b);
+
+ const firstIndex = sortedIndices[0];
+ const lastIndex = sortedIndices[sortedIndices.length - 1];
+
+ if (lastIndex - firstIndex + 1 !== sortedIndices.length) {
+ this.showNotification('请选择连续的字幕段进行合并', 'warning');
+ return;
+ }
+
+ this.saveState();
+
+ const mergedText = sortedIndices
+ .map(i => this.subtitles[i].text)
+ .join(' ');
+
+ this.subtitles[firstIndex].text = mergedText;
+ this.subtitles[firstIndex].end = this.subtitles[lastIndex].end;
+
+ for (let i = sortedIndices.length - 1; i > 0; i--) {
+ this.subtitles.splice(sortedIndices[i], 1);
+ }
+
+ this.selectedIndices.clear();
+ this.selectedIndices.add(firstIndex);
+
+ this.renderSubtitles();
+ this.updateStats();
+ this.showNotification('已合并 ' + sortedIndices.length + ' 条字幕', 'success');
+ }
+
+ mergeWithNext(index) {
+ if (index >= this.subtitles.length - 1) {
+ this.showNotification('没有下一条字幕可以合并', 'warning');
+ return;
+ }
+
+ this.saveState();
+
+ this.subtitles[index].text += ' ' + this.subtitles[index + 1].text;
+ this.subtitles[index].end = this.subtitles[index + 1].end;
+ this.subtitles.splice(index + 1, 1);
+
+ this.renderSubtitles();
+ this.updateStats();
+ this.showNotification('已合并字幕', 'success');
+ }
+
+ showSplitModal() {
+ const modal = document.getElementById('splitModal');
+ const timeRange = document.getElementById('splitTimeRange');
+
+ if (this.splitIndex !== undefined) {
+ const sub = this.subtitles[this.splitIndex];
+ timeRange.textContent = `${this.formatTime(sub.start)} - ${this.formatTime(sub.end)}`;
+ }
+
+ modal.classList.remove('hidden');
+ }
+
+ hideSplitModal() {
+ const modal = document.getElementById('splitModal');
+ modal.classList.add('hidden');
+ this.splitIndex = undefined;
+ }
+
+ confirmSplitSubtitle() {
+ if (this.splitIndex === undefined) return;
+
+ const timeInput = document.getElementById('splitTimeInput');
+ const splitTimeStr = timeInput.value.trim();
+
+ try {
+ let splitSeconds;
+
+ if (splitTimeStr.includes(':')) {
+ splitSeconds = this.parseTime(splitTimeStr);
+ } else {
+ splitSeconds = parseFloat(splitTimeStr);
+ }
+
+ if (isNaN(splitSeconds)) {
+ throw new Error('无效的时间格式');
+ }
+
+ const sub = this.subtitles[this.splitIndex];
+
+ if (splitSeconds <= sub.start || splitSeconds >= sub.end) {
+ throw new Error('拆分时间必须在字幕时间范围内');
+ }
+
+ this.saveState();
+
+ const duration = sub.end - sub.start;
+ const ratio = (splitSeconds - sub.start) / duration;
+ const splitPos = Math.floor(sub.text.length * ratio);
+
+ const punctuations = ['。', '!', '?', ',', ';', ':', '.', '!', '?', ',', ';', ':', ' '];
+ let adjustedPos = splitPos;
+
+ for (let i = splitPos; i < Math.min(splitPos + 20, sub.text.length); i++) {
+ if (punctuations.includes(sub.text[i])) {
+ adjustedPos = i + 1;
+ break;
+ }
+ }
+
+ for (let i = splitPos - 1; i > Math.max(0, splitPos - 20); i--) {
+ if (punctuations.includes(sub.text[i])) {
+ adjustedPos = i + 1;
+ break;
+ }
+ }
+
+ const text1 = sub.text.substring(0, adjustedPos).trim();
+ const text2 = sub.text.substring(adjustedPos).trim();
+
+ const newSubtitle = {
+ id: this.splitIndex + 1,
+ start: splitSeconds,
+ end: sub.end,
+ text: text2 || sub.text,
+ language: sub.language,
+ text_zh: sub.text_zh,
+ text_en: sub.text_en,
+ is_bilingual: sub.is_bilingual
+ };
+
+ sub.end = splitSeconds;
+ sub.text = text1 || sub.text;
+
+ this.subtitles.splice(this.splitIndex + 1, 0, newSubtitle);
+
+ this.hideSplitModal();
+ this.renderSubtitles();
+ this.updateStats();
+ this.showNotification('已拆分字幕', 'success');
+
+ } catch (error) {
+ this.showNotification('拆分失败: ' + error.message, 'error');
+ }
+ }
+
+ deleteSubtitle(index) {
+ if (!confirm('确定要删除这条字幕吗?')) return;
+
+ this.saveState();
+ this.subtitles.splice(index, 1);
+ this.selectedIndices.delete(index);
+
+ this.renderSubtitles();
+ this.updateStats();
+ this.showNotification('已删除字幕', 'info');
+ }
+
+ shiftTime(direction) {
+ const shiftInput = document.getElementById('shiftSeconds');
+ const shiftSeconds = parseFloat(shiftInput.value) || 0.5;
+ const actualShift = shiftSeconds * direction;
+
+ if (this.selectedIndices.size === 0) {
+ this.showNotification('请先选择要调整时间的字幕', 'warning');
+ return;
+ }
+
+ this.saveState();
+
+ this.selectedIndices.forEach(index => {
+ if (index >= 0 && index < this.subtitles.length) {
+ this.subtitles[index].start = Math.max(0, this.subtitles[index].start + actualShift);
+ this.subtitles[index].end = Math.max(0, this.subtitles[index].end + actualShift);
+ }
+ });
+
+ this.renderSubtitles();
+ this.updateStats();
+
+ const action = direction < 0 ? '提前' : '延后';
+ this.showNotification(`已将选中字幕${action} ${shiftSeconds} 秒`, 'success');
+ }
+
+ saveState() {
+ this.history = this.history.slice(0, this.historyIndex + 1);
+ this.history.push(JSON.parse(JSON.stringify(this.subtitles)));
+ this.historyIndex = this.history.length - 1;
+
+ this.updateUndoRedoButtons();
+ }
+
+ undo() {
+ if (this.historyIndex > 0) {
+ this.historyIndex--;
+ this.subtitles = JSON.parse(JSON.stringify(this.history[this.historyIndex]));
+ this.renderSubtitles();
+ this.updateStats();
+ this.updateUndoRedoButtons();
+ this.showNotification('已撤销', 'info');
+ }
+ }
+
+ redo() {
+ if (this.historyIndex < this.history.length - 1) {
+ this.historyIndex++;
+ this.subtitles = JSON.parse(JSON.stringify(this.history[this.historyIndex]));
+ this.renderSubtitles();
+ this.updateStats();
+ this.updateUndoRedoButtons();
+ this.showNotification('已重做', 'info');
+ }
+ }
+
+ updateUndoRedoButtons() {
+ const undoBtn = document.getElementById('undoButton');
+ const redoBtn = document.getElementById('redoButton');
+
+ undoBtn.disabled = this.historyIndex <= 0;
+ redoBtn.disabled = this.historyIndex >= this.history.length - 1;
+ }
+
+ search() {
+ const searchInput = document.getElementById('searchInput');
+ const query = searchInput.value.trim().toLowerCase();
+
+ if (!query) {
+ this.renderSubtitles();
+ return;
+ }
+
+ const matches = this.subtitles.filter(sub =>
+ sub.text.toLowerCase().includes(query)
+ );
+
+ if (matches.length === 0) {
+ this.showNotification('未找到匹配的字幕', 'warning');
+ return;
+ }
+
+ this.highlightMatches(query);
+ this.showNotification(`找到 ${matches.length} 条匹配的字幕`, 'info');
+ }
+
+ highlightMatches(query) {
+ const rows = document.querySelectorAll('.editor-row');
+ rows.forEach(row => {
+ const textDisplay = row.querySelector('.subtitle-text-display');
+ if (textDisplay) {
+ const index = parseInt(row.dataset.index);
+ const sub = this.subtitles[index];
+
+ if (sub.text.toLowerCase().includes(query.toLowerCase())) {
+ row.classList.add('bg-yellow-50');
+ const regex = new RegExp(`(${this.escapeRegex(query)})`, 'gi');
+ textDisplay.innerHTML = sub.text.replace(regex, '$1');
+ } else {
+ row.classList.remove('bg-yellow-50');
+ }
+ }
+ });
+ }
+
+ async save() {
+ if (!this.taskId) {
+ this.showNotification('没有可保存的任务', 'error');
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/editor/${this.taskId}/save`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(this.subtitles)
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || '保存失败');
+ }
+
+ const result = await response.json();
+ this.showNotification('保存成功!', 'success');
+
+ } catch (error) {
+ this.showNotification('保存失败: ' + error.message, 'error');
+ }
+ }
+
+ showExportModal() {
+ const modal = document.getElementById('exportModal');
+ modal.classList.remove('hidden');
+ }
+
+ hideExportModal() {
+ const modal = document.getElementById('exportModal');
+ modal.classList.add('hidden');
+ }
+
+ async exportSubtitles() {
+ const selectedFormat = document.querySelector('input[name="exportFormat"]:checked')?.value || 'srt';
+
+ if (!this.taskId) {
+ await this.quickExport(selectedFormat);
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/editor/${this.taskId}/save`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(this.subtitles)
+ });
+
+ if (response.ok) {
+ window.location.href = `/api/download/${this.taskId}/${selectedFormat}`;
+ this.hideExportModal();
+ this.showNotification('导出中...', 'info');
+ }
+
+ } catch (error) {
+ this.quickExport(selectedFormat);
+ }
+ }
+
+ quickExport(format) {
+ let content = '';
+
+ if (format === 'srt') {
+ content = this.generateSRT();
+ } else if (format === 'vtt') {
+ content = this.generateVTT();
+ } else if (format === 'ass') {
+ content = this.generateASS();
+ }
+
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `subtitles.${format}`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ this.hideExportModal();
+ this.showNotification(`已导出 ${format.toUpperCase()} 格式`, 'success');
+ }
+
+ generateSRT() {
+ let srt = '';
+
+ this.subtitles.forEach((sub, index) => {
+ srt += `${index + 1}\n`;
+ srt += `${this.formatTimeSRT(sub.start)} --> ${this.formatTimeSRT(sub.end)}\n`;
+ srt += `${sub.text}\n\n`;
+ });
+
+ return srt;
+ }
+
+ generateVTT() {
+ let vtt = 'WEBVTT\n\n';
+
+ this.subtitles.forEach((sub, index) => {
+ vtt += `${index + 1}\n`;
+ vtt += `${this.formatTimeVTT(sub.start)} --> ${this.formatTimeVTT(sub.end)}\n`;
+ vtt += `${sub.text}\n\n`;
+ });
+
+ return vtt;
+ }
+
+ generateASS() {
+ let ass = `[Script Info]
+Title: Subtitles
+ScriptType: v4.00+
+WrapStyle: 0
+ScaledBorderAndShadow: yes
+PlayResX: 1920
+PlayResY: 1080
+
+[V4+ Styles]
+Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
+Style: Default,Microsoft YaHei,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,2,10,10,40,1
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+`;
+
+ this.subtitles.forEach(sub => {
+ ass += `Dialogue: 0,${this.formatTimeASS(sub.start)},${this.formatTimeASS(sub.end)},Default,,0,0,0,,${sub.text}\n`;
+ });
+
+ return ass;
+ }
+
+ updateStats() {
+ const statSegments = document.getElementById('statSegments');
+ const statCharacters = document.getElementById('statCharacters');
+ const statDuration = document.getElementById('statDuration');
+
+ const totalChars = this.subtitles.reduce((sum, sub) => sum + sub.text.length, 0);
+ const totalDuration = this.subtitles.length > 0
+ ? this.subtitles[this.subtitles.length - 1].end - this.subtitles[0].start
+ : 0;
+
+ statSegments.textContent = this.subtitles.length;
+ statCharacters.textContent = totalChars;
+ statDuration.textContent = this.formatTime(totalDuration);
+ }
+
+ renderEmptyState() {
+ const subtitleList = document.getElementById('subtitleList');
+ subtitleList.innerHTML = `
+
+ |
+
+ 暂无字幕数据
+ 请先处理视频生成字幕
+ |
+
+ `;
+ }
+
+ formatTime(seconds) {
+ const hrs = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ const secs = Math.floor(seconds % 60);
+ const ms = Math.floor((seconds % 1) * 1000);
+ return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`;
+ }
+
+ formatTimeSRT(seconds) {
+ const hrs = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ const secs = Math.floor(seconds % 60);
+ const ms = Math.floor((seconds % 1) * 1000);
+ return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`;
+ }
+
+ formatTimeVTT(seconds) {
+ const hrs = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ const secs = Math.floor(seconds % 60);
+ const ms = Math.floor((seconds % 1) * 1000);
+ return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`;
+ }
+
+ formatTimeASS(seconds) {
+ const hrs = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ const secs = Math.floor(seconds % 60);
+ const cs = Math.floor((seconds % 1) * 100);
+ return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${cs.toString().padStart(2, '0')}`;
+ }
+
+ parseTime(timeStr) {
+ timeStr = timeStr.trim();
+
+ if (timeStr.includes(':')) {
+ const parts = timeStr.split(':');
+ let seconds = 0;
+
+ if (parts.length === 3) {
+ const hrs = parseInt(parts[0]) || 0;
+ const mins = parseInt(parts[1]) || 0;
+ const secParts = parts[2].split(/[,.]/);
+ const secs = parseInt(secParts[0]) || 0;
+ const ms = secParts[1] ? parseInt(secParts[1].padEnd(3, '0').substring(0, 3)) : 0;
+
+ seconds = hrs * 3600 + mins * 60 + secs + ms / 1000;
+ } else if (parts.length === 2) {
+ const mins = parseInt(parts[0]) || 0;
+ const secParts = parts[1].split(/[,.]/);
+ const secs = parseInt(secParts[0]) || 0;
+ const ms = secParts[1] ? parseInt(secParts[1].padEnd(3, '0').substring(0, 3)) : 0;
+
+ seconds = mins * 60 + secs + ms / 1000;
+ }
+
+ return seconds;
+ }
+
+ return parseFloat(timeStr);
+ }
+
+ escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ escapeRegex(string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ }
+
+ showNotification(message, type = 'info') {
+ const notification = document.getElementById('notification');
+ const notificationIcon = document.getElementById('notificationIcon');
+ const notificationText = document.getElementById('notificationText');
+
+ const icons = {
+ 'info': 'ri-information-line text-blue-500',
+ 'success': 'ri-checkbox-circle-line text-green-500',
+ 'error': 'ri-error-warning-line text-red-500',
+ 'warning': 'ri-alert-line text-yellow-500'
+ };
+
+ const borders = {
+ 'info': 'border-l-blue-500',
+ 'success': 'border-l-green-500',
+ 'error': 'border-l-red-500',
+ 'warning': 'border-l-yellow-500'
+ };
+
+ notificationText.textContent = message;
+ notificationIcon.className = icons[type] + ' text-xl mr-3';
+
+ const notificationDiv = notification.querySelector('div');
+ notificationDiv.className = `bg-white rounded-lg shadow-lg p-4 border-l-4 ${borders[type]} flex items-center`;
+
+ notification.classList.remove('hidden');
+
+ setTimeout(() => {
+ notification.classList.add('hidden');
+ }, 3000);
+ }
+}
+
+const editor = new SubtitleEditor();
diff --git a/web/tasks.py b/web/tasks.py
new file mode 100644
index 0000000..228155c
--- /dev/null
+++ b/web/tasks.py
@@ -0,0 +1,295 @@
+"""
+任务管理模块
+管理处理任务的状态、进度和并发
+"""
+import asyncio
+import uuid
+import json
+import time
+import logging
+from pathlib import Path
+from dataclasses import dataclass, field, asdict
+from typing import Dict, List, Optional, Any, Callable
+from datetime import datetime
+from enum import Enum
+
+from .config import settings
+
+
+logger = logging.getLogger(__name__)
+
+
+class TaskStatus(str, Enum):
+ PENDING = "pending"
+ QUEUED = "queued"
+ PROCESSING = "processing"
+ COMPLETED = "completed"
+ FAILED = "failed"
+ CANCELLED = "cancelled"
+
+
+class ProcessingStage(str, Enum):
+ UPLOADED = "uploaded"
+ EXTRACTING_AUDIO = "extracting_audio"
+ DETECTING_LANGUAGE = "detecting_language"
+ RECOGNIZING_SPEECH = "recognizing_speech"
+ TRANSLATING = "translating"
+ GENERATING_SUBTITLES = "generating_subtitles"
+ EXPORTING = "exporting"
+ EMBEDDING = "embedding"
+ COMPLETED = "completed"
+
+
+@dataclass
+class TaskProgress:
+ stage: ProcessingStage = ProcessingStage.UPLOADED
+ stage_name: str = "已上传"
+ progress: float = 0.0
+ message: str = ""
+ logs: List[str] = field(default_factory=list)
+ current_file: str = ""
+ total_files: int = 1
+ processed_files: int = 0
+
+ def add_log(self, message: str, level: str = "info"):
+ timestamp = datetime.now().strftime("%H:%M:%S")
+ self.logs.append(f"[{timestamp}] [{level.upper()}] {message}")
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ "stage": self.stage.value if hasattr(self.stage, 'value') else self.stage,
+ "stage_name": self.stage_name,
+ "progress": self.progress,
+ "message": self.message,
+ "logs": self.logs[-100:],
+ "current_file": self.current_file,
+ "total_files": self.total_files,
+ "processed_files": self.processed_files,
+ }
+
+
+@dataclass
+class ProcessingTask:
+ task_id: str = field(default_factory=lambda: str(uuid.uuid4()))
+ status: TaskStatus = TaskStatus.PENDING
+ progress: TaskProgress = field(default_factory=TaskProgress)
+
+ input_files: List[str] = field(default_factory=list)
+ output_files: Dict[str, str] = field(default_factory=dict)
+
+ created_at: datetime = field(default_factory=datetime.now)
+ started_at: Optional[datetime] = None
+ completed_at: Optional[datetime] = None
+
+ options: Dict[str, Any] = field(default_factory=dict)
+
+ result: Optional[Dict[str, Any]] = None
+ error: Optional[str] = None
+
+ metadata: Dict[str, Any] = field(default_factory=dict)
+
+ _cancel_requested: bool = False
+ _task: Optional[asyncio.Task] = None
+
+ @property
+ def duration(self) -> float:
+ if self.started_at is None:
+ return 0.0
+ end_time = self.completed_at or datetime.now()
+ return (end_time - self.started_at).total_seconds()
+
+ @property
+ def is_running(self) -> bool:
+ return self.status in [TaskStatus.PROCESSING, TaskStatus.QUEUED]
+
+ @property
+ def is_finished(self) -> bool:
+ return self.status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]
+
+ def request_cancel(self):
+ self._cancel_requested = True
+ if self._task:
+ self._task.cancel()
+
+ def update_progress(
+ self,
+ stage: ProcessingStage,
+ stage_name: str,
+ progress: float,
+ message: str = ""
+ ):
+ self.progress.stage = stage
+ self.progress.stage_name = stage_name
+ self.progress.progress = min(100.0, max(0.0, progress))
+ self.progress.message = message
+ if message:
+ self.progress.add_log(message)
+
+ def add_log(self, message: str, level: str = "info"):
+ self.progress.add_log(message, level)
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ "task_id": self.task_id,
+ "status": self.status.value if hasattr(self.status, 'value') else self.status,
+ "progress": self.progress.to_dict(),
+ "input_files": self.input_files,
+ "output_files": self.output_files,
+ "created_at": self.created_at.isoformat(),
+ "started_at": self.started_at.isoformat() if self.started_at else None,
+ "completed_at": self.completed_at.isoformat() if self.completed_at else None,
+ "duration": self.duration,
+ "options": self.options,
+ "result": self.result,
+ "error": self.error,
+ "is_running": self.is_running,
+ "is_finished": self.is_finished,
+ }
+
+
+class TaskManager:
+ _instance: Optional['TaskManager'] = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ cls._instance._initialized = False
+ return cls._instance
+
+ def __init__(self):
+ if self._initialized:
+ return
+
+ self.tasks: Dict[str, ProcessingTask] = {}
+ self.active_tasks: int = 0
+ self._lock = asyncio.Lock()
+ self._initialized = True
+
+ logger.info("TaskManager initialized")
+
+ def create_task(
+ self,
+ input_files: List[str],
+ options: Dict[str, Any] = None,
+ metadata: Dict[str, Any] = None,
+ ) -> ProcessingTask:
+ task = ProcessingTask(
+ input_files=input_files,
+ options=options or {},
+ metadata=metadata or {},
+ )
+
+ self.tasks[task.task_id] = task
+ logger.info(f"Created task: {task.task_id}")
+ return task
+
+ def get_task(self, task_id: str) -> Optional[ProcessingTask]:
+ return self.tasks.get(task_id)
+
+ def get_all_tasks(self) -> List[ProcessingTask]:
+ return list(self.tasks.values())
+
+ def get_running_tasks(self) -> List[ProcessingTask]:
+ return [t for t in self.tasks.values() if t.is_running]
+
+ def get_finished_tasks(self) -> List[ProcessingTask]:
+ return [t for t in self.tasks.values() if t.is_finished]
+
+ async def start_task(
+ self,
+ task_id: str,
+ processor: Callable[[ProcessingTask], Any],
+ ) -> ProcessingTask:
+ task = self.get_task(task_id)
+ if not task:
+ raise ValueError(f"Task not found: {task_id}")
+
+ if task.is_running:
+ raise RuntimeError(f"Task is already running: {task_id}")
+
+ while self.active_tasks >= settings.MAX_CONCURRENT_TASKS:
+ await asyncio.sleep(1)
+
+ async with self._lock:
+ task.status = TaskStatus.PROCESSING
+ task.started_at = datetime.now()
+ task.update_progress(
+ ProcessingStage.EXTRACTING_AUDIO,
+ "开始处理",
+ 0.0,
+ "任务已启动"
+ )
+ self.active_tasks += 1
+
+ try:
+ result = await processor(task)
+ async with self._lock:
+ task.status = TaskStatus.COMPLETED
+ task.completed_at = datetime.now()
+ task.result = result
+ task.update_progress(
+ ProcessingStage.COMPLETED,
+ "处理完成",
+ 100.0,
+ "所有处理完成"
+ )
+
+ logger.info(f"Task completed: {task_id}")
+
+ except asyncio.CancelledError:
+ async with self._lock:
+ task.status = TaskStatus.CANCELLED
+ task.completed_at = datetime.now()
+ task.add_log("任务已取消", "warning")
+ logger.info(f"Task cancelled: {task_id}")
+
+ except Exception as e:
+ async with self._lock:
+ task.status = TaskStatus.FAILED
+ task.completed_at = datetime.now()
+ task.error = str(e)
+ task.add_log(f"处理失败: {e}", "error")
+ logger.error(f"Task failed: {task_id}, error: {e}")
+
+ finally:
+ async with self._lock:
+ self.active_tasks -= 1
+
+ return task
+
+ def cancel_task(self, task_id: str) -> bool:
+ task = self.get_task(task_id)
+ if not task or not task.is_running:
+ return False
+
+ task.request_cancel()
+ return True
+
+ def remove_task(self, task_id: str) -> bool:
+ task = self.get_task(task_id)
+ if not task:
+ return False
+
+ if task.is_running:
+ return False
+
+ self.tasks.pop(task_id, None)
+ return True
+
+ def cleanup_old_tasks(self, max_age_hours: int = 24):
+ cutoff = datetime.now().timestamp() - (max_age_hours * 3600)
+ to_remove = []
+
+ for task_id, task in self.tasks.items():
+ if task.is_finished and task.completed_at:
+ if task.completed_at.timestamp() < cutoff:
+ to_remove.append(task_id)
+
+ for task_id in to_remove:
+ self.tasks.pop(task_id, None)
+
+ logger.info(f"Cleaned up {len(to_remove)} old tasks")
+
+
+# 全局任务管理器实例
+task_manager = TaskManager()
diff --git a/web/templates/batch.html b/web/templates/batch.html
new file mode 100644
index 0000000..5587cb7
--- /dev/null
+++ b/web/templates/batch.html
@@ -0,0 +1,295 @@
+
+
+
+
+
+ 批量处理 - VideoSubtitleAI
+
+
+
+
+
+
+
+
+
+
+ 批量处理
+
+
同时处理多个音视频文件,一键生成字幕
+
+
+
+
+
+
+
+ 上传文件
+
+
+
+
+
+
点击或拖拽多个文件到此处
+
支持 MP4, MOV, AVI, MKV, MP3, WAV, M4A 等格式
+
单个文件最大 500MB
+
+
+
+
+
已选择文件 (0)
+
+
+
+
+
+
+
+
+
+
+ 处理选项
+
+
+
+
+
+
+
更大的模型准确率更高,但处理更慢
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 样式设置
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 处理进度
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 处理结果
+
+
+
+
+
+
+
+
+
+
+
+
+ 提示
+
+
+ - • 批量处理会按顺序处理每个文件
+ - • 所有文件使用相同的处理参数
+ - • 处理中的文件无法单独取消
+ - • 建议同时处理不超过 10 个文件
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/templates/editor.html b/web/templates/editor.html
new file mode 100644
index 0000000..e464e50
--- /dev/null
+++ b/web/templates/editor.html
@@ -0,0 +1,280 @@
+
+
+
+
+
+ 字幕编辑器 - VideoSubtitleAI
+
+
+
+
+
+
+
+
+
+
+
字幕样式
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 示例字幕文字
+
+
+
+
+
+
+
+
快捷操作
+
+
+
+
+
+
+
+
+
+
+
+
+
+
统计信息
+
+
+ 字幕段数
+ 0
+
+
+ 总字数
+ 0
+
+
+ 总时长
+ 00:00:00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
00:00:00 / 00:00:00
+
+
+
+
+
+
+
+
+
+
拆分段
+
+
+
+
00:00:00 - 00:00:00
+
+
+
+
+
格式: 时:分:秒.毫秒 (如: 00:00:05.000)
+
+
+
+
+
+
+
+
+
+
+
+
导出字幕
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/templates/index.html b/web/templates/index.html
new file mode 100644
index 0000000..c6f6989
--- /dev/null
+++ b/web/templates/index.html
@@ -0,0 +1,359 @@
+
+
+
+
+
+ VideoSubtitleAI - 音视频转字幕工具
+
+
+
+
+
+
+
+
+
+
+
+ 音视频转字幕工具
+
+
+ 基于 OpenAI Whisper 的本地离线音视频转字幕工具,支持 99 种语言,
+ 一键生成 SRT/ASS/VTT 字幕,支持在线编辑和样式自定义。
+
+
+
+
+
+
+
+ 上传文件
+
+
+
+
+
+
点击或拖拽文件到此处
+
支持 MP4, MOV, AVI, MKV, MP3, WAV, M4A 等格式
+
+
+
+
+
+
处理选项
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 样式设置
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 处理进度
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 处理结果
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
功能特性
+
+
+
+
+
+
高精度识别
+
基于 OpenAI Whisper 大模型,支持 99 种语言识别,时间轴精准对齐。
+
+
+
+
+
+
双语字幕
+
支持自动生成中英双语字幕,可配置源语言、目标语言和显示顺序。
+
+
+
+
+
+
在线编辑
+
提供字幕时间轴调整、文本修改、分句合并/拆分,支持撤销/重做。
+
+
+
+
+
+
多格式导出
+
支持 SRT、ASS、VTT 三种字幕格式导出,满足不同播放器需求。
+
+
+
+
+
+
字幕嵌入
+
支持将字幕直接烧录到视频文件中,便于分享和播放。
+
+
+
+
+
+
样式美化
+
支持自定义字幕字体、颜色、位置、描边样式,生成带样式的ASS字幕。
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/websocket_manager.py b/web/websocket_manager.py
new file mode 100644
index 0000000..9256663
--- /dev/null
+++ b/web/websocket_manager.py
@@ -0,0 +1,197 @@
+"""
+WebSocket 连接管理模块
+用于实时推送任务进度和日志
+"""
+import asyncio
+import json
+import logging
+from typing import Dict, Set, Optional, Any
+from dataclasses import dataclass, asdict
+from datetime import datetime
+from fastapi import WebSocket
+
+from .tasks import ProcessingTask, TaskStatus, ProcessingStage
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class WSMessage:
+ type: str
+ task_id: str
+ data: Optional[Dict[str, Any]] = None
+ timestamp: str = None
+
+ def __post_init__(self):
+ if self.timestamp is None:
+ self.timestamp = datetime.now().isoformat()
+
+ def to_json(self) -> str:
+ return json.dumps({
+ 'type': self.type,
+ 'task_id': self.task_id,
+ 'data': self.data,
+ 'timestamp': self.timestamp,
+ }, ensure_ascii=False)
+
+ @classmethod
+ def from_json(cls, json_str: str) -> 'WSMessage':
+ data = json.loads(json_str)
+ return cls(
+ type=data.get('type', ''),
+ task_id=data.get('task_id', ''),
+ data=data.get('data'),
+ timestamp=data.get('timestamp'),
+ )
+
+
+class WebSocketManager:
+ _instance: Optional['WebSocketManager'] = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ cls._instance._initialized = False
+ return cls._instance
+
+ def __init__(self):
+ if self._initialized:
+ return
+
+ self.active_connections: Dict[str, Set[WebSocket]] = {}
+ self._lock = asyncio.Lock()
+ self._initialized = True
+
+ logger.info("WebSocketManager initialized")
+
+ async def connect(self, websocket: WebSocket, task_id: str = None):
+ await websocket.accept()
+
+ async with self._lock:
+ if task_id:
+ if task_id not in self.active_connections:
+ self.active_connections[task_id] = set()
+ self.active_connections[task_id].add(websocket)
+
+ if 'broadcast' not in self.active_connections:
+ self.active_connections['broadcast'] = set()
+ self.active_connections['broadcast'].add(websocket)
+
+ logger.info(f"WebSocket connected for task: {task_id or 'broadcast'}")
+
+ async def disconnect(self, websocket: WebSocket, task_id: str = None):
+ async with self._lock:
+ if task_id and task_id in self.active_connections:
+ self.active_connections[task_id].discard(websocket)
+ if not self.active_connections[task_id]:
+ del self.active_connections[task_id]
+
+ if 'broadcast' in self.active_connections:
+ self.active_connections['broadcast'].discard(websocket)
+ if not self.active_connections['broadcast']:
+ del self.active_connections['broadcast']
+
+ logger.info(f"WebSocket disconnected for task: {task_id or 'broadcast'}")
+
+ async def _send_to_websocket(self, websocket: WebSocket, message: WSMessage):
+ try:
+ await websocket.send_text(message.to_json())
+ except Exception as e:
+ logger.warning(f"Failed to send WebSocket message: {e}")
+ async with self._lock:
+ for connections in self.active_connections.values():
+ connections.discard(websocket)
+
+ async def send_message(self, message: WSMessage, task_id: str = None):
+ target_connections = set()
+
+ async with self._lock:
+ if task_id and task_id in self.active_connections:
+ target_connections.update(self.active_connections[task_id])
+
+ if 'broadcast' in self.active_connections:
+ target_connections.update(self.active_connections['broadcast'])
+
+ for websocket in target_connections:
+ await self._send_to_websocket(websocket, message)
+
+ async def send_task_progress(self, task: ProcessingTask):
+ message = WSMessage(
+ type='task_progress',
+ task_id=task.task_id,
+ data=task.to_dict(),
+ )
+ await self.send_message(message, task.task_id)
+
+ async def send_task_status_change(self, task: ProcessingTask):
+ message = WSMessage(
+ type='task_status',
+ task_id=task.task_id,
+ data={
+ 'status': task.status.value if hasattr(task.status, 'value') else task.status,
+ 'duration': task.duration,
+ 'error': task.error,
+ },
+ )
+ await self.send_message(message, task.task_id)
+
+ async def send_log_message(self, task_id: str, log_message: str, level: str = 'info'):
+ message = WSMessage(
+ type='task_log',
+ task_id=task_id,
+ data={
+ 'message': log_message,
+ 'level': level,
+ 'timestamp': datetime.now().isoformat(),
+ },
+ )
+ await self.send_message(message, task_id)
+
+ async def broadcast_task_list(self, tasks: list):
+ message = WSMessage(
+ type='task_list',
+ task_id='broadcast',
+ data={
+ 'tasks': [t.to_dict() for t in tasks],
+ },
+ )
+ await self.send_message(message, 'broadcast')
+
+ async def send_file_upload_progress(
+ self,
+ task_id: str,
+ filename: str,
+ progress: float,
+ total_size: int = 0,
+ uploaded_size: int = 0
+ ):
+ message = WSMessage(
+ type='upload_progress',
+ task_id=task_id,
+ data={
+ 'filename': filename,
+ 'progress': progress,
+ 'total_size': total_size,
+ 'uploaded_size': uploaded_size,
+ },
+ )
+ await self.send_message(message, task_id)
+
+
+# 全局 WebSocket 管理器实例
+ws_manager = WebSocketManager()
+
+
+async def task_progress_monitor(task: ProcessingTask, interval: float = 0.5):
+ """
+ 任务进度监视器
+ 定期推送任务进度到 WebSocket
+ """
+ from .tasks import task_manager
+
+ while not task.is_finished:
+ await ws_manager.send_task_progress(task)
+ await asyncio.sleep(interval)
+
+ await ws_manager.send_task_progress(task)
+ await ws_manager.send_task_status_change(task)