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/TEST_REPORT_WEB_MODULES.md b/TEST_REPORT_WEB_MODULES.md
new file mode 100644
index 0000000..bf638b3
--- /dev/null
+++ b/TEST_REPORT_WEB_MODULES.md
@@ -0,0 +1,255 @@
+# VideoSubtitleAI Web 模块修复与测试报告
+
+**测试日期:** 2026-05-02
+**测试环境:** Windows 10/11, Python 3.10+
+
+---
+
+## 一、问题诊断与修复
+
+### 1.1 发现的问题
+
+#### 问题 1: `WinError 10013` 端口绑定错误
+**原因:** 之前残留的 Python 进程 (PID 33680) 占用了 8000 端口
+**解决:** 终止该进程
+```powershell
+taskkill /F /PID 33680
+```
+
+#### 问题 2: `Settings` 对象缺少兼容别名属性
+**原因:** `test_web_features.py` 使用了旧的属性名,但实际代码中属性名不同
+
+| 旧属性名 (测试脚本) | 实际属性名 (代码) |
+|---------------------|-------------------|
+| `VIDEO_FORMATS` | `SUPPORTED_VIDEO_FORMATS` |
+| `AUDIO_FORMATS` | `SUPPORTED_AUDIO_FORMATS` |
+| `MAX_FILE_SIZE_MB` | 不存在,需要计算 |
+
+#### 问题 3: 测试脚本 API 调用错误
+| 错误调用 | 正确调用 |
+|----------|----------|
+| `create_styled_ass_style(font_color=...)` | `primary_color` |
+| `create_styled_ass_style(position=...)` | `alignment` (数字 2/5/8) |
+| `generate_ass_content(style=...)` | `default_style` |
+| `task_manager.list_tasks()` | `get_all_tasks()` |
+| `task_manager.delete_task()` | `remove_task()` |
+| `task.update_progress()` | `task.update_stage()` |
+| `WSMessage.to_dict()` | `to_json()` |
+
+---
+
+### 1.2 实施的修复
+
+#### 修复 1: `web/config.py` - 添加兼容别名属性
+
+在 `Settings` 类中添加了以下 `@property` 别名:
+
+```python
+@property
+def VIDEO_FORMATS(self):
+ """兼容别名: 支持视频格式"""
+ return self.SUPPORTED_VIDEO_FORMATS
+
+@property
+def AUDIO_FORMATS(self):
+ """兼容别名: 支持音频格式"""
+ return self.SUPPORTED_AUDIO_FORMATS
+
+@property
+def MAX_FILE_SIZE_MB(self):
+ """兼容别名: 最大文件大小(MB)"""
+ return self.MAX_FILE_SIZE // (1024 * 1024)
+```
+
+同时添加了 `__getattr__` 方法用于额外的向后兼容。
+
+**修改文件:** `web/config.py` (行 93-106)
+
+#### 修复 2: `test_web_features.py` - 更新属性名
+
+将第 202-203 行更新为正确的属性名,并添加了 `MAX_FILE_SIZE` 显示:
+
+```python
+print(f" - 支持视频格式: {settings.SUPPORTED_VIDEO_FORMATS}")
+print(f" - 支持音频格式: {settings.SUPPORTED_AUDIO_FORMATS}")
+print(f" - 最大文件大小: {settings.MAX_FILE_SIZE} bytes ({settings.MAX_FILE_SIZE // (1024*1024)} MB)")
+```
+
+**修改文件:** `test_web_features.py` (行 202-204)
+
+#### 修复 3: `test_comprehensive.py` - 修复所有 API 调用
+
+修复了以下 API 调用:
+1. `create_styled_ass_style()` - 使用正确的参数名 `primary_color` 和 `alignment`
+2. `generate_ass_content()` - 使用正确的参数名 `default_style`
+3. `task_manager.get_all_tasks()` - 替代 `list_tasks()`
+4. `task_manager.remove_task()` - 替代 `delete_task()`
+5. `task.update_stage()` - 替代 `update_progress()`
+6. `msg.to_json()` - 替代 `to_dict()`
+7. `dependency_checker` - 使用正确的函数名 `check_dependencies()`
+
+**修改文件:** `test_comprehensive.py` (多处)
+
+---
+
+## 二、测试结果
+
+### 2.1 模块测试结果 (10/10 通过)
+
+| 测试模块 | 状态 | 测试内容 |
+|----------|------|----------|
+| subtitle_models | ✅ PASS | 字幕模型、单语/双语字幕数据结构 |
+| subtitle_exporter | ✅ PASS | SRT/VTT/ASS 格式导出、样式 ASS |
+| subtitle_translator | ✅ PASS | 语言检测、翻译框架 |
+| web_config | ✅ PASS | 配置加载、别名属性、格式验证 |
+| task_manager | ✅ PASS | 任务创建/获取/更新/取消/删除 |
+| websocket_manager | ✅ PASS | WSMessage、to_json、管理器导入 |
+| file_validation | ✅ PASS | 格式验证、大小限制检查 |
+| subtitle_embedder | ✅ PASS | FFmpeg 检测、嵌入器导入 |
+| dependency_checker | ✅ PASS | 依赖检查器功能 |
+| web_server_import | ✅ PASS | FastAPI app 导入、21 个路由注册 |
+
+**测试输出摘要:**
+```
+============================================================
+ TEST SUMMARY
+============================================================
+ [PASS] subtitle_models
+ [PASS] subtitle_exporter
+ [PASS] subtitle_translator
+ [PASS] web_config
+ [PASS] task_manager
+ [PASS] websocket_manager
+ [PASS] file_validation
+ [PASS] subtitle_embedder
+ [PASS] dependency_checker
+ [PASS] web_server_import
+
+ Total: 10 | Passed: 10 | Failed: 0
+
+ All module tests passed!
+============================================================
+```
+
+---
+
+### 2.2 关键功能验证
+
+#### ✅ 配置别名属性验证
+```
+Testing Settings class:
+ SUPPORTED_VIDEO_FORMATS: ['.mp4', '.mov', '.avi']
+ VIDEO_FORMATS (alias): ['.mp4', '.mov', '.avi']
+ Are they equal?: True
+
+ SUPPORTED_AUDIO_FORMATS: ['.mp3', '.wav', '.m4a']
+ AUDIO_FORMATS (alias): ['.mp3', '.wav', '.m4a']
+ Are they equal?: True
+
+ MAX_FILE_SIZE: 524288000 bytes
+ MAX_FILE_SIZE_MB (alias): 500 MB
+
+All alias properties working correctly!
+```
+
+#### ✅ FFmpeg 验证
+```
+[PASS] FFmpeg found at: C:\Users\...\ffmpeg.exe
+[PASS] SubtitleEmbedder imported
+FFmpeg版本: 8.1-full_build-www.gyan.dev
+```
+
+#### ✅ Web 服务器验证
+```
+[PASS] FastAPI app imported successfully
+ App title: VideoSubtitleAI - Web版音视频转字幕工具
+ Version: 2.0.0
+[PASS] Found 21 routes
+[PASS] Route exists: /
+[PASS] Route exists: /api/config
+[PASS] Route exists: /api/upload
+[PASS] Route exists: /api/tasks
+```
+
+---
+
+## 三、启动服务器
+
+### 3.1 启动方式
+
+#### 方式 1: 简化版 (推荐,无热重载)
+```powershell
+cd C:\Users\86178\Desktop\VideoSubtitleAI
+python run_server_simple.py
+```
+
+#### 方式 2: 原版 (带热重载)
+```powershell
+cd C:\Users\86178\Desktop\VideoSubtitleAI
+python run_web.py
+```
+
+### 3.2 启动成功标志
+```
+INFO: Started server process [xxxxx]
+INFO: Waiting for application startup.
+INFO: Application startup complete.
+INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
+```
+
+### 3.3 访问地址
+- **主页:** http://localhost:8000
+- **API 文档:** http://localhost:8000/docs
+- **批量处理:** http://localhost:8000/batch
+
+---
+
+## 四、修复文件清单
+
+| 文件 | 修改内容 | 行号 |
+|------|----------|------|
+| `web/config.py` | 添加兼容别名属性 (VIDEO_FORMATS, AUDIO_FORMATS, MAX_FILE_SIZE_MB) | 93-106 |
+| `test_web_features.py` | 更新属性名,添加 MAX_FILE_SIZE 显示 | 202-204 |
+| `test_comprehensive.py` | 修复所有 API 调用错误 | 多处 |
+| `run_server_simple.py` | 新建简化版启动脚本 (无热重载) | 新建 |
+
+---
+
+## 五、测试文件
+
+### 5.1 可用的测试文件
+- `test_files/fake_video.mp4` - 假视频测试文件
+- `test_files/invalid_file.txt` - 无效格式测试文件
+- `SeeYouAgain.mp4` - **真实视频文件** (项目根目录)
+- `SeeYouAgain_subtitled.mp4` - 已嵌入字幕的视频示例
+
+### 5.2 测试脚本
+- `test_comprehensive.py` - 综合功能测试 (10/10 通过)
+- `test_web_features.py` - Web 功能测试
+- `test_modules.py` - 模块测试
+- `test_plan_complete.md` - 详细测试计划文档
+
+---
+
+## 六、总结
+
+### ✅ 已修复的问题
+1. **端口占用问题:** 终止了占用 8000 端口的残留进程
+2. **配置兼容性:** 添加了 `VIDEO_FORMATS`、`AUDIO_FORMATS`、`MAX_FILE_SIZE_MB` 别名属性
+3. **测试脚本:** 修复了所有 API 调用错误
+
+### ✅ 已验证的功能
+- 所有核心模块 (10/10 通过测试)
+- Web 服务器可正确导入 (21 个路由注册)
+- FFmpeg 已正确安装
+- 配置别名属性工作正常
+
+### 📋 下一步
+1. 在本地终端运行 `python run_server_simple.py` 启动服务器
+2. 访问 http://localhost:8000 进行 UI 测试
+3. 使用测试计划文档进行完整的端到端测试
+
+---
+
+**报告生成时间:** 2026-05-02
+**测试状态:** ✅ 所有模块测试通过
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_server_simple.py b/run_server_simple.py
new file mode 100644
index 0000000..3fded31
--- /dev/null
+++ b/run_server_simple.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+"""
+简化版启动脚本 - 不使用热重载
+避免 Windows 上的 reload 问题
+"""
+import uvicorn
+import sys
+from pathlib import Path
+
+web_dir = Path(__file__).parent
+sys.path.insert(0, str(web_dir))
+
+
+def main():
+ print("=" * 60)
+ print(" VideoSubtitleAI Web Server")
+ print("=" * 60)
+ print()
+ print(" Starting server...")
+ print(" URL: http://localhost:8000")
+ print(" API Docs: http://localhost:8000/docs")
+ print()
+ print(" Press CTRL+C to stop")
+ print("=" * 60)
+ print()
+
+ uvicorn.run(
+ "web.server:app",
+ host="127.0.0.1", # 使用 localhost 而不是 0.0.0.0
+ port=8000,
+ reload=False, # 不使用热重载
+ log_level="info"
+ )
+
+
+if __name__ == "__main__":
+ main()
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..04c167d
--- /dev/null
+++ b/subtitle_exporter.py
@@ -0,0 +1,859 @@
+"""
+字幕导出模块
+支持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:
+ if format_type == 'ass':
+ with open(output_path, 'w', encoding=encoding) as f:
+ f.write('\ufeff')
+ f.write(content)
+ else:
+ 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_to_srt(
+ self,
+ document: Optional[SubtitleDocument] = None,
+ output_path: str = "",
+ encoding: str = 'utf-8',
+ **kwargs,
+ ) -> str:
+ if document:
+ self.load_document(document)
+
+ if not output_path:
+ raise ValueError("需要指定输出路径")
+
+ return self.export_to_file(
+ output_path=output_path,
+ format_type='srt',
+ encoding=encoding,
+ **kwargs,
+ )
+
+ def export_to_vtt(
+ self,
+ document: Optional[SubtitleDocument] = None,
+ output_path: str = "",
+ encoding: str = 'utf-8',
+ **kwargs,
+ ) -> str:
+ if document:
+ self.load_document(document)
+
+ if not output_path:
+ raise ValueError("需要指定输出路径")
+
+ return self.export_to_file(
+ output_path=output_path,
+ format_type='vtt',
+ encoding=encoding,
+ **kwargs,
+ )
+
+ def export_to_ass(
+ self,
+ document: Optional[SubtitleDocument] = None,
+ output_path: str = "",
+ default_style: Optional[SubtitleStyle] = None,
+ encoding: str = 'utf-8',
+ **kwargs,
+ ) -> str:
+ if document:
+ self.load_document(document)
+
+ if default_style and 'Default' not in self.document.styles:
+ self.document.styles['Default'] = default_style
+
+ if not output_path:
+ raise ValueError("需要指定输出路径")
+
+ return self.export_to_file(
+ output_path=output_path,
+ format_type='ass',
+ encoding=encoding,
+ default_style=default_style,
+ **kwargs,
+ )
+
+ 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="Default",
+ 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..47c4b72
--- /dev/null
+++ b/subtitle_models.py
@@ -0,0 +1,547 @@
+"""
+字幕数据模型模块
+定义字幕段、样式、双语字幕等核心数据结构
+"""
+
+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,
+ }
+
+ def to_json(self) -> str:
+ import json
+ return json.dumps(self.to_dict(), ensure_ascii=False)
+
+ @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=seg.get('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),
+ text_zh=seg.get('text_zh', ''),
+ text_en=seg.get('text_en', ''),
+ is_bilingual=seg.get('is_bilingual', is_bilingual),
+ bilingual_order=seg.get('bilingual_order', 'zh_en'),
+ style_name=seg.get('style_name'),
+ )
+ 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..41c0e2f
--- /dev/null
+++ b/subtitle_translator.py
@@ -0,0 +1,1123 @@
+"""
+字幕翻译模块
+支持多种翻译后端,实现双语字幕生成
+"""
+
+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翻译
+ """
+ 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)
+
+ actual_source = self.source_language if self.source_language != 'auto' else source_lang
+ actual_target = self.target_language
+
+ logger.info(f"使用 {self.api_type} API翻译: {text[:50]}... ({actual_source} -> {actual_target})")
+
+ try:
+ if self.api_type == "baidu":
+ translated_text = self._baidu_translate(text, actual_source, actual_target)
+ elif self.api_type == "google":
+ translated_text = self._google_translate(text, actual_source, actual_target)
+ else:
+ translated_text = self._mock_translate(text, actual_source, actual_target)
+
+ return TranslationResult(
+ original_text=text,
+ translated_text=translated_text,
+ source_language=actual_source,
+ target_language=actual_target,
+ confidence=0.8,
+ backend=f"online_{self.api_type}",
+ success=True,
+ )
+ except Exception as e:
+ logger.error(f"API翻译失败: {e}")
+ return TranslationResult(
+ original_text=text,
+ translated_text=text,
+ source_language=actual_source,
+ target_language=actual_target,
+ confidence=0.0,
+ backend=f"online_{self.api_type}",
+ success=False,
+ error_message=str(e),
+ )
+
+ def _baidu_translate(self, text: str, source_lang: str, target_lang: str) -> str:
+ """
+ 真实的百度翻译API调用
+ 文档: https://fanyi-api.baidu.com/doc/21
+ """
+ import hashlib
+ import uuid
+ import urllib.request
+ import urllib.parse
+ import json
+
+ if not self.app_id or not self.api_key:
+ logger.warning("百度翻译API未配置,使用备用模式")
+ return self._mock_translate(text, source_lang, target_lang)
+
+ api_url = "https://fanyi-api.baidu.com/api/trans/vip/translate"
+
+ from_lang = self._map_baidu_lang_code(source_lang)
+ to_lang = self._map_baidu_lang_code(target_lang)
+
+ salt = str(uuid.uuid4()).replace('-', '')
+
+ sign_str = f"{self.app_id}{text}{salt}{self.api_key}"
+ sign = hashlib.md5(sign_str.encode('utf-8')).hexdigest()
+
+ params = {
+ 'q': text,
+ 'from': from_lang,
+ 'to': to_lang,
+ 'appid': self.app_id,
+ 'salt': salt,
+ 'sign': sign
+ }
+
+ url = f"{api_url}?{urllib.parse.urlencode(params)}"
+
+ logger.info(f"百度翻译请求: {from_lang} -> {to_lang}")
+
+ try:
+ req = urllib.request.Request(url)
+ with urllib.request.urlopen(req, timeout=10) as response:
+ result = json.loads(response.read().decode('utf-8'))
+
+ if 'error_code' in result:
+ logger.error(f"百度翻译API错误: {result['error_code']} - {result.get('error_msg', 'Unknown')}")
+ return self._mock_translate(text, source_lang, target_lang)
+
+ if 'trans_result' in result and len(result['trans_result']) > 0:
+ translated_parts = [item.get('dst', text) for item in result['trans_result']]
+ translated_text = '\n'.join(translated_parts)
+ logger.info(f"翻译成功: {text[:30]}... -> {translated_text[:30]}...")
+ return translated_text
+
+ return text
+
+ except Exception as e:
+ logger.error(f"百度翻译请求失败: {e}")
+ return self._mock_translate(text, source_lang, target_lang)
+
+ def _map_baidu_lang_code(self, lang: str) -> str:
+ """映射语言代码到百度翻译API的代码"""
+ lang_map = {
+ 'zh': 'zh',
+ 'zh-cn': 'zh',
+ 'en': 'en',
+ 'ja': 'jp',
+ 'jp': 'jp',
+ 'ko': 'kor',
+ 'kor': 'kor',
+ 'fr': 'fra',
+ 'fra': 'fra',
+ 'de': 'de',
+ 'es': 'spa',
+ 'spa': 'spa',
+ 'ru': 'ru',
+ 'pt': 'pt',
+ 'it': 'it',
+ 'auto': 'auto'
+ }
+ return lang_map.get(lang.lower(), 'auto')
+
+ def _google_translate(self, text: str, source_lang: str, target_lang: str) -> str:
+ """
+ 简单的谷歌翻译(无密钥模式)
+ 注意:这是一个备用方案,可能不稳定
+ """
+ try:
+ import urllib.request
+ import urllib.parse
+ import json
+
+ from_lang = source_lang if source_lang != 'auto' else 'auto'
+ to_lang = target_lang
+
+ url = f"https://translate.googleapis.com/translate_a/single?client=gtx&sl={from_lang}&tl={to_lang}&dt=t&q={urllib.parse.quote(text)}"
+
+ req = urllib.request.Request(url)
+ req.add_header('User-Agent', 'Mozilla/5.0')
+
+ with urllib.request.urlopen(req, timeout=10) as response:
+ result = json.loads(response.read().decode('utf-8'))
+
+ if result and len(result) > 0 and result[0]:
+ translated_parts = [item[0] for item in result[0] if item and item[0]]
+ translated_text = ''.join(translated_parts)
+ logger.info(f"Google翻译成功: {text[:30]}... -> {translated_text[:30]}...")
+ return translated_text
+
+ return text
+ except Exception as e:
+ logger.error(f"Google翻译失败: {e}")
+ return self._mock_translate(text, source_lang, target_lang)
+
+ def _mock_translate(self, text: str, source_lang: str, target_lang: str) -> str:
+ """模拟翻译(备用方案)"""
+ logger.warning(f"使用备用翻译模式,建议配置真实的翻译API")
+
+ if source_lang == 'zh' and target_lang == 'en':
+ return text
+ elif source_lang == 'en' and target_lang == 'zh':
+ return text
+ return text
+
+
+class LocalModelTranslator(BaseTranslator):
+ """
+ 本地模型翻译器
+ 使用Helsinki-NLP的OPUS-MT模型或其他本地翻译模型
+ """
+
+ MODEL_MAPPING = {
+ ('zh', 'en'): 'Helsinki-NLP/opus-mt-zh-en',
+ ('en', 'zh'): 'Helsinki-NLP/opus-mt-en-zh',
+ ('ja', 'en'): 'Helsinki-NLP/opus-mt-ja-en',
+ ('en', 'ja'): 'Helsinki-NLP/opus-mt-en-ja',
+ ('ko', 'en'): 'Helsinki-NLP/opus-mt-ko-en',
+ ('en', 'ko'): 'Helsinki-NLP/opus-mt-en-ko',
+ ('fr', 'en'): 'Helsinki-NLP/opus-mt-fr-en',
+ ('en', 'fr'): 'Helsinki-NLP/opus-mt-en-fr',
+ ('de', 'en'): 'Helsinki-NLP/opus-mt-de-en',
+ ('en', 'de'): 'Helsinki-NLP/opus-mt-en-de',
+ }
+
+ _loaded_models: Dict[str, Any] = {}
+
+ 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._real_model = 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 not transformers_available or not torch_available:
+ logger.warning("未安装 transformers 或 torch,无法使用本地翻译模型")
+ logger.warning("如需本地翻译模型,请运行: pip install transformers torch sentencepiece sacremoses")
+ self._initialized = False
+ self._real_model = False
+ return
+
+ if self.model_name:
+ model_key = self.model_name
+ else:
+ lang_pair = (self.source_language, self.target_language)
+ if lang_pair in self.MODEL_MAPPING:
+ model_key = self.MODEL_MAPPING[lang_pair]
+ elif self.source_language == 'auto':
+ model_key = self.MODEL_MAPPING.get(('zh', 'en'), 'Helsinki-NLP/opus-mt-zh-en')
+ else:
+ logger.warning(f"不支持的语言对: {self.source_language} -> {self.target_language}")
+ self._initialized = False
+ self._real_model = False
+ return
+
+ if model_key in self._loaded_models:
+ logger.info(f"使用已缓存的模型: {model_key}")
+ self._tokenizer, self._model = self._loaded_models[model_key]
+ self._initialized = True
+ self._real_model = True
+ self.model_name = model_key
+ logger.info(f"本地翻译模型已加载: {model_key}")
+ return
+
+ logger.info(f"首次加载模型: {model_key} (这可能需要几分钟下载)")
+
+ from transformers import MarianMTModel, MarianTokenizer
+
+ self._tokenizer = MarianTokenizer.from_pretrained(model_key)
+ self._model = MarianMTModel.from_pretrained(model_key)
+
+ import torch
+ if self.device == 'cuda' and torch.cuda.is_available():
+ self._model = self._model.to('cuda')
+ logger.info(f"模型已移至 GPU")
+ else:
+ logger.info(f"模型运行在 CPU 上")
+
+ self._loaded_models[model_key] = (self._tokenizer, self._model)
+ self._initialized = True
+ self._real_model = True
+ self.model_name = model_key
+
+ logger.info(f"本地翻译模型加载完成: {model_key}")
+
+ except Exception as e:
+ logger.warning(f"本地翻译模型初始化失败: {e}")
+ logger.warning("将使用备用翻译模式")
+ self._initialized = False
+ self._real_model = False
+
+ def translate(self, text: str) -> TranslationResult:
+ """使用本地模型翻译"""
+ detected_lang, confidence = detect_text_language(text)
+
+ actual_source = self.source_language if self.source_language != 'auto' else detected_lang
+
+ if not self._real_model:
+ return TranslationResult(
+ original_text=text,
+ translated_text="",
+ source_language=actual_source,
+ target_language=self.target_language,
+ confidence=0.0,
+ backend="local_model",
+ success=False,
+ error_message="本地模型未加载,翻译功能不可用",
+ )
+
+ try:
+ import torch
+
+ max_length = 512
+ inputs = self._tokenizer(
+ text,
+ return_tensors="pt",
+ padding=True,
+ truncation=True,
+ max_length=max_length
+ )
+
+ if self.device == 'cuda' and torch.cuda.is_available():
+ inputs = {k: v.to('cuda') for k, v in inputs.items()}
+
+ with torch.no_grad():
+ outputs = self._model.generate(
+ **inputs,
+ max_length=max_length,
+ num_beams=4,
+ early_stopping=True
+ )
+
+ translated = self._tokenizer.decode(outputs[0], skip_special_tokens=True)
+
+ logger.info(f"[本地翻译] {actual_source}->{self.target_language}: {text[:50]}... -> {translated[:50]}...")
+
+ return TranslationResult(
+ original_text=text,
+ translated_text=translated,
+ source_language=actual_source,
+ target_language=self.target_language,
+ confidence=0.9,
+ backend=f"local_{self.model_name}",
+ success=True,
+ )
+
+ except Exception as e:
+ logger.error(f"翻译出错: {e}")
+ return TranslationResult(
+ original_text=text,
+ translated_text="",
+ source_language=actual_source,
+ target_language=self.target_language,
+ confidence=0.0,
+ backend=f"local_{self.model_name}",
+ success=False,
+ error_message=str(e),
+ )
+
+ def translate_batch(self, texts: List[str]) -> List[TranslationResult]:
+ """批量翻译(更高效)"""
+ if not self._real_model:
+ return [self.translate(text) for text in texts]
+
+ try:
+ import torch
+
+ results = []
+ batch_size = 8
+
+ for i in range(0, len(texts), batch_size):
+ batch = texts[i:i + batch_size]
+
+ inputs = self._tokenizer(
+ batch,
+ return_tensors="pt",
+ padding=True,
+ truncation=True,
+ max_length=512
+ )
+
+ if self.device == 'cuda' and torch.cuda.is_available():
+ inputs = {k: v.to('cuda') for k, v in inputs.items()}
+
+ with torch.no_grad():
+ outputs = self._model.generate(
+ **inputs,
+ max_length=512,
+ num_beams=4,
+ early_stopping=True
+ )
+
+ for j, output in enumerate(outputs):
+ translated = self._tokenizer.decode(output, skip_special_tokens=True)
+ source_lang, _ = detect_text_language(batch[j])
+
+ results.append(TranslationResult(
+ original_text=batch[j],
+ translated_text=translated,
+ source_language=source_lang,
+ target_language=self.target_language,
+ confidence=0.9,
+ backend=f"local_{self.model_name}",
+ success=True,
+ ))
+
+ return results
+
+ except Exception as e:
+ logger.error(f"批量翻译出错: {e}")
+ return [self.translate(text) for text in texts]
+
+
+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=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=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 = ['online', 'local', '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 = 翻译结果(英文->中文)
+ - 确保真正调用翻译API获取翻译结果
+ """
+ from subtitle_models import SubtitleSegment
+
+ results = []
+ warnings_issued = False
+ translation_count = 0
+ success_count = 0
+
+ for seg in segments:
+ if isinstance(seg, dict):
+ segment = SubtitleSegment.from_dict(seg)
+ else:
+ segment = seg
+
+ original_text = segment.text.strip()
+
+ if not original_text:
+ segment.text_zh = ""
+ segment.text_en = ""
+ segment.is_bilingual = False
+ results.append(segment)
+ continue
+
+ detected_lang, _ = detect_text_language(original_text)
+
+ logger.info(f"[翻译处理] 检测到语言: {detected_lang}, 文本: {original_text[:40]}...")
+
+ if detected_lang == 'zh':
+ segment.text_zh = original_text
+
+ translation = self.translate_text(original_text, backend)
+ translation_count += 1
+
+ if translation.success and translation.translated_text:
+ if translation.translated_text != original_text:
+ segment.text_en = translation.translated_text
+ success_count += 1
+ logger.info(f"[翻译成功] 中文->英文: {original_text[:30]}... -> {translation.translated_text[:30]}...")
+ else:
+ segment.text_en = original_text
+ if not warnings_issued:
+ logger.warning("翻译后端返回原文,可能是API未正确配置或调用失败。")
+ warnings_issued = True
+ else:
+ segment.text_en = original_text
+ if not warnings_issued:
+ logger.warning(f"翻译失败: {translation.error_message if hasattr(translation, 'error_message') else '未知错误'}")
+ warnings_issued = True
+
+ segment.is_bilingual = True
+ segment.bilingual_order = bilingual_order
+
+ elif detected_lang == 'en':
+ segment.text_en = original_text
+
+ translation = self.translate_text(original_text, backend)
+ translation_count += 1
+
+ if translation.success and translation.translated_text:
+ if translation.translated_text != original_text:
+ segment.text_zh = translation.translated_text
+ success_count += 1
+ logger.info(f"[翻译成功] 英文->中文: {original_text[:30]}... -> {translation.translated_text[:30]}...")
+ else:
+ segment.text_zh = original_text
+ if not warnings_issued:
+ logger.warning("翻译后端返回原文,可能是API未正确配置或调用失败。")
+ warnings_issued = True
+ else:
+ segment.text_zh = original_text
+ if not warnings_issued:
+ logger.warning(f"翻译失败: {translation.error_message if hasattr(translation, 'error_message') else '未知错误'}")
+ warnings_issued = True
+
+ segment.is_bilingual = True
+ segment.bilingual_order = bilingual_order
+
+ else:
+ segment.text_zh = original_text
+ segment.text_en = ""
+ segment.is_bilingual = False
+ logger.warning(f"[翻译跳过] 不支持的语言: {detected_lang}, 文本: {original_text[:30]}...")
+
+ results.append(segment)
+
+ logger.info(f"[翻译完成] 总计: {translation_count} 段, 成功: {success_count} 段")
+
+ 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,
+ backend=TranslationBackend.ONLINE,
+ api_type="baidu", # 添加这行
+ app_id="20260502002605795", # 添加这行
+ api_key="RRDAlRQmiOHAfv1vfW1c",
+ )
+
+ 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_api_running.py b/test_api_running.py
new file mode 100644
index 0000000..69177de
--- /dev/null
+++ b/test_api_running.py
@@ -0,0 +1,119 @@
+# -*- coding: utf-8 -*-
+"""
+测试 Web API 接口
+"""
+import sys
+import io
+
+sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
+
+import httpx
+import json
+
+BASE_URL = "http://127.0.0.1:8000"
+
+def test_api_config():
+ """测试配置接口"""
+ print("Testing GET /api/config...")
+ try:
+ response = httpx.get(f"{BASE_URL}/api/config", timeout=10.0)
+ print(f" Status: {response.status_code}")
+ if response.status_code == 200:
+ data = response.json()
+ print(f" Models: {data.get('models', [])}")
+ print(f" Languages: {data.get('languages', [])[:5]}...")
+ print(f" Max file size: {data.get('max_file_size_mb', 0)} MB")
+ return True
+ except Exception as e:
+ print(f" Error: {e}")
+ return False
+
+def test_api_tasks():
+ """测试任务列表接口"""
+ print("\nTesting GET /api/tasks...")
+ try:
+ response = httpx.get(f"{BASE_URL}/api/tasks", timeout=10.0)
+ print(f" Status: {response.status_code}")
+ if response.status_code == 200:
+ data = response.json()
+ print(f" Total tasks: {data.get('total', 0)}")
+ return True
+ except Exception as e:
+ print(f" Error: {e}")
+ return False
+
+def test_root_page():
+ """测试主页"""
+ print("\nTesting GET / (home page)...")
+ try:
+ response = httpx.get(f"{BASE_URL}/", timeout=10.0)
+ print(f" Status: {response.status_code}")
+ if response.status_code == 200:
+ content = response.text[:200] # 只显示前200字符
+ print(f" Content preview (first 200 chars):")
+ print(f" {content}")
+ return True
+ except Exception as e:
+ print(f" Error: {e}")
+ return False
+
+def test_upload_invalid():
+ """测试上传无效文件"""
+ print("\nTesting POST /api/upload with invalid file...")
+ try:
+ files = {"file": ("test.txt", b"This is not a valid media file", "text/plain")}
+ response = httpx.post(f"{BASE_URL}/api/upload", files=files, timeout=10.0)
+ print(f" Status: {response.status_code}")
+ if response.status_code == 400:
+ print(f" Expected: Invalid file format rejected")
+ return True
+ except Exception as e:
+ print(f" Error: {e}")
+ return False
+
+def main():
+ print("=" * 60)
+ print("API Integration Test")
+ print(f"Target: {BASE_URL}")
+ print("=" * 60)
+
+ results = []
+
+ # 1. 测试配置接口
+ results.append(("/api/config", test_api_config()))
+
+ # 2. 测试任务列表
+ results.append(("/api/tasks", test_api_tasks()))
+
+ # 3. 测试主页
+ results.append(("/", test_root_page()))
+
+ # 4. 测试无效文件上传
+ results.append(("/api/upload (invalid)", test_upload_invalid()))
+
+ # 总结
+ print("\n" + "=" * 60)
+ print("Test Summary:")
+ print("=" * 60)
+
+ passed = 0
+ failed = 0
+ for endpoint, result in results:
+ status = "PASS" if result else "FAIL"
+ if result:
+ passed += 1
+ else:
+ failed += 1
+ print(f" [{status}] {endpoint}")
+
+ print(f"\n Total: {len(results)} | Passed: {passed} | Failed: {failed}")
+
+ if failed == 0:
+ print("\n All tests passed! Server is running correctly.")
+ else:
+ print(f"\n {failed} test(s) failed.")
+
+ print("=" * 60)
+
+if __name__ == "__main__":
+ main()
diff --git a/test_comprehensive.py b/test_comprehensive.py
new file mode 100644
index 0000000..1ff3d20
--- /dev/null
+++ b/test_comprehensive.py
@@ -0,0 +1,462 @@
+# -*- coding: utf-8 -*-
+"""
+综合功能测试脚本 - 测试所有核心模块
+修复后的版本:使用正确的 API
+"""
+import sys
+import os
+import io
+import json
+from pathlib import Path
+
+if hasattr(sys.stdout, 'buffer'):
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
+
+PROJECT_ROOT = Path(__file__).parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+test_results = {}
+
+def print_header(title):
+ print("\n" + "=" * 60)
+ print(" " + title)
+ print("=" * 60)
+
+def test_subtitle_models():
+ """测试字幕模型"""
+ print_header("Test 1: Subtitle Models")
+ try:
+ from subtitle_models import SubtitleDocument, SubtitleSegment, create_document_from_segments
+
+ test_segments = [
+ {'id': 0, 'start': 0.0, 'end': 2.5, 'text': 'Hello World'},
+ {'id': 1, 'start': 2.5, 'end': 5.0, 'text': 'This is a test'},
+ {'id': 2, 'start': 5.0, 'end': 7.5, 'text': 'Goodbye'},
+ ]
+
+ doc = create_document_from_segments(test_segments)
+ print(" [PASS] Created subtitle document with " + str(len(doc.segments)) + " segments")
+
+ test_segments_bilingual = [
+ {'id': 0, 'start': 0.0, 'end': 2.5, 'text': '你好', 'text_zh': '你好', 'text_en': 'Hello', 'is_bilingual': True},
+ ]
+ doc2 = create_document_from_segments(test_segments_bilingual)
+ print(" [PASS] Created bilingual document")
+
+ return True
+ except Exception as e:
+ import traceback
+ print(" [FAIL] " + str(e))
+ traceback.print_exc()
+ return False
+
+def test_subtitle_exporter():
+ """测试字幕导出功能"""
+ print_header("Test 2: Subtitle Exporter (SRT/VTT/ASS)")
+ try:
+ from subtitle_models import create_document_from_segments
+ from subtitle_exporter import SubtitleExporter, create_styled_ass_style
+
+ test_segments = [
+ {'id': 0, 'start': 0.0, 'end': 2.5, 'text': 'Hello World'},
+ {'id': 1, 'start': 2.5, 'end': 5.0, 'text': 'This is a test'},
+ {'id': 2, 'start': 5.0, 'end': 7.5, 'text': 'Goodbye'},
+ ]
+
+ doc = create_document_from_segments(test_segments)
+ exporter = SubtitleExporter(doc)
+
+ srt_content = exporter.generate_srt_content()
+ print(" [PASS] Generated SRT content: " + str(len(srt_content)) + " chars")
+ assert "1" in srt_content
+ assert "-->" in srt_content
+
+ vtt_content = exporter.generate_vtt_content()
+ print(" [PASS] Generated VTT content: " + str(len(vtt_content)) + " chars")
+ assert "WEBVTT" in vtt_content
+
+ ass_content = exporter.generate_ass_content()
+ print(" [PASS] Generated ASS content: " + str(len(ass_content)) + " chars")
+ assert "[Script Info]" in ass_content
+
+ # 使用正确的参数名: primary_color (不是 font_color), alignment (不是 position)
+ # alignment: 2=底部, 5=居中, 8=顶部
+ style = create_styled_ass_style(
+ font_name="Microsoft YaHei",
+ font_size=48,
+ primary_color="#FFFFFF", # 正确的参数名
+ outline_color="#000000",
+ outline_width=2,
+ alignment=2, # 2=底部对齐
+ )
+ print(" [PASS] Created styled ASS style")
+
+ # 使用正确的参数名: default_style (不是 style)
+ styled_ass = exporter.generate_ass_content(default_style=style)
+ print(" [PASS] Generated styled ASS content")
+
+ output_dir = PROJECT_ROOT / "test_output"
+ output_dir.mkdir(exist_ok=True)
+
+ srt_path = exporter.export_to_srt(doc, str(output_dir / "test.srt"))
+ print(" [PASS] Exported SRT file: " + srt_path)
+
+ vtt_path = exporter.export_to_vtt(doc, str(output_dir / "test.vtt"))
+ print(" [PASS] Exported VTT file: " + vtt_path)
+
+ ass_path = exporter.export_to_ass(doc, str(output_dir / "test.ass"), style)
+ print(" [PASS] Exported ASS file: " + ass_path)
+
+ return True
+ except Exception as e:
+ import traceback
+ print(" [FAIL] " + str(e))
+ traceback.print_exc()
+ return False
+
+def test_subtitle_translator():
+ """测试翻译框架"""
+ print_header("Test 3: Subtitle Translator")
+ try:
+ from subtitle_translator import (
+ SubtitleTranslator,
+ DummyTranslator,
+ detect_text_language,
+ get_translator,
+ )
+
+ test_text_en = "Hello, this is a test sentence."
+ try:
+ lang, conf = detect_text_language(test_text_en)
+ print(" [PASS] Language detection: " + str(lang) + " (confidence: " + str(conf) + ")")
+ except Exception as e:
+ print(" [WARN] Language detection not available: " + str(e))
+
+ dummy = DummyTranslator(source_language='zh', target_language='en', mode="smart_bilingual")
+ result = dummy.translate("你好,世界")
+ print(" [PASS] Dummy translator result: success=" + str(result.success))
+
+ return True
+ except Exception as e:
+ import traceback
+ print(" [FAIL] " + str(e))
+ traceback.print_exc()
+ return False
+
+def test_web_config():
+ """测试 Web 配置"""
+ print_header("Test 4: Web Config")
+ try:
+ from web.config import settings
+
+ print(" [PASS] Config loaded")
+ print(" UPLOAD_DIR: " + str(settings.UPLOAD_DIR))
+ print(" OUTPUT_DIR: " + str(settings.OUTPUT_DIR))
+ print(" MAX_FILE_SIZE: " + str(settings.MAX_FILE_SIZE) + " bytes")
+
+ # 测试新添加的兼容别名属性
+ print(" VIDEO_FORMATS (alias): " + str(settings.VIDEO_FORMATS[:3]) + "...")
+ print(" AUDIO_FORMATS (alias): " + str(settings.AUDIO_FORMATS[:3]) + "...")
+ print(" MAX_FILE_SIZE_MB (alias): " + str(settings.MAX_FILE_SIZE_MB) + " MB")
+
+ print("\n Format validation tests:")
+ print(" .mp4 is video: " + str(settings.is_video_format(".mp4")))
+ print(" .mp3 is audio: " + str(settings.is_audio_format(".mp3")))
+ print(" .txt is supported: " + str(settings.is_supported_format(".txt")))
+ print(" .mp4 is supported: " + str(settings.is_supported_format(".mp4")))
+
+ # 验证新旧属性名相同
+ assert settings.VIDEO_FORMATS == settings.SUPPORTED_VIDEO_FORMATS
+ assert settings.AUDIO_FORMATS == settings.SUPPORTED_AUDIO_FORMATS
+ print("\n [PASS] Alias properties work correctly!")
+
+ return True
+ except Exception as e:
+ import traceback
+ print(" [FAIL] " + str(e))
+ traceback.print_exc()
+ return False
+
+def test_task_manager():
+ """测试任务管理器"""
+ print_header("Test 5: Task Manager")
+ try:
+ from web.tasks import task_manager, ProcessingTask, TaskStatus, ProcessingStage
+
+ task = task_manager.create_task(
+ input_files=["test.mp4"],
+ metadata={"test": True}
+ )
+
+ print(" [PASS] Created task: " + task.task_id)
+ print(" Status: " + task.status.value)
+ print(" Created at: " + str(task.created_at))
+
+ retrieved = task_manager.get_task(task.task_id)
+ assert retrieved is not None
+ print(" [PASS] Retrieved task: " + retrieved.task_id)
+
+ # 使用正确的方法名: get_all_tasks() (不是 list_tasks())
+ all_tasks = task_manager.get_all_tasks()
+ print(" [PASS] Got all tasks: " + str(len(all_tasks)) + " total")
+
+ # 使用正确的方法名: update_stage (不是 update_progress)
+ task.update_stage(
+ stage=ProcessingStage.RECOGNIZING_SPEECH,
+ message="Processing...",
+ progress=50.0
+ )
+ print(" [PASS] Updated progress: " + str(task.progress.progress) + "%")
+
+ task.add_log("Test log message", "INFO")
+ print(" [PASS] Added log, total logs: " + str(len(task.progress.logs)))
+
+ task.cancel()
+ print(" [PASS] Task cancelled, status: " + task.status.value)
+
+ # 使用正确的方法名: remove_task() (不是 delete_task())
+ task_manager.remove_task(task.task_id)
+ print(" [PASS] Task removed")
+
+ deleted = task_manager.get_task(task.task_id)
+ assert deleted is None
+ print(" [PASS] Task no longer exists")
+
+ return True
+ except Exception as e:
+ import traceback
+ print(" [FAIL] " + str(e))
+ traceback.print_exc()
+ return False
+
+def test_websocket_manager():
+ """测试 WebSocket 管理器"""
+ print_header("Test 6: WebSocket Manager")
+ try:
+ from web.websocket_manager import ws_manager, WSMessage, task_progress_monitor
+
+ print(" [PASS] WebSocket manager imported")
+ print(" Manager type: " + type(ws_manager).__name__)
+ print(" Monitor type: " + type(task_progress_monitor).__name__)
+
+ msg = WSMessage(
+ type="task_progress",
+ task_id="test-task-123",
+ data={"progress": 50}
+ )
+ print(" [PASS] Created WSMessage: " + msg.type)
+
+ # 使用正确的方法名: to_json() (不是 to_dict())
+ json_str = msg.to_json()
+ print(" [PASS] Message to_json: " + str(len(json_str)) + " chars")
+
+ # 验证 JSON 格式
+ parsed = json.loads(json_str)
+ assert parsed.get("type") == "task_progress"
+ assert parsed.get("task_id") == "test-task-123"
+ print(" [PASS] JSON format is valid")
+
+ return True
+ except Exception as e:
+ import traceback
+ print(" [FAIL] " + str(e))
+ traceback.print_exc()
+ return False
+
+def test_file_validation():
+ """测试文件验证逻辑"""
+ print_header("Test 7: File Validation")
+ try:
+ from web.config import settings
+
+ test_cases = [
+ (".mp4", True, "video"),
+ (".MP4", True, "video"),
+ (".mov", True, "video"),
+ (".mp3", True, "audio"),
+ (".wav", True, "audio"),
+ (".m4a", True, "audio"),
+ (".txt", False, None),
+ (".exe", False, None),
+ (".pdf", False, None),
+ ("", False, None),
+ ]
+
+ all_passed = True
+ for ext, expected_supported, expected_type in test_cases:
+ is_supported = settings.is_supported_format(ext)
+ is_video = settings.is_video_format(ext)
+ is_audio = settings.is_audio_format(ext)
+
+ expected_is_video = (expected_type == "video")
+ expected_is_audio = (expected_type == "audio")
+
+ if is_supported == expected_supported and is_video == expected_is_video and is_audio == expected_is_audio:
+ print(" [PASS] " + ext + ": supported=" + str(is_supported) + ", video=" + str(is_video) + ", audio=" + str(is_audio))
+ else:
+ print(" [FAIL] " + ext + ": expected supported=" + str(expected_supported) + ", got " + str(is_supported))
+ all_passed = False
+
+ print("\n File size limits:")
+ size_100mb = 100 * 1024 * 1024
+ size_600mb = 600 * 1024 * 1024
+ print(" 100MB <= MAX_FILE_SIZE (" + str(settings.MAX_FILE_SIZE) + "): " + str(size_100mb <= settings.MAX_FILE_SIZE))
+ print(" 600MB <= MAX_FILE_SIZE (" + str(settings.MAX_FILE_SIZE) + "): " + str(size_600mb <= settings.MAX_FILE_SIZE))
+
+ # 使用新的别名属性
+ print(" MAX_FILE_SIZE_MB (alias): " + str(settings.MAX_FILE_SIZE_MB) + " MB")
+
+ return all_passed
+ except Exception as e:
+ import traceback
+ print(" [FAIL] " + str(e))
+ traceback.print_exc()
+ return False
+
+def test_subtitle_embedder():
+ """测试字幕嵌入器(检查 FFmpeg 可用性)"""
+ print_header("Test 8: Subtitle Embedder (FFmpeg check)")
+ try:
+ import shutil
+
+ ffmpeg_path = shutil.which('ffmpeg')
+ if ffmpeg_path:
+ print(" [PASS] FFmpeg found at: " + ffmpeg_path)
+ else:
+ print(" [WARN] FFmpeg not found in PATH - embedding will not work")
+ print(" Please install FFmpeg: winget install Gyan.FFmpeg")
+
+ try:
+ from subtitle_embedder import SubtitleEmbedder
+ print(" [PASS] SubtitleEmbedder imported")
+ except Exception as e:
+ print(" [WARN] SubtitleEmbedder import issue: " + str(e))
+
+ return True
+ except Exception as e:
+ import traceback
+ print(" [FAIL] " + str(e))
+ traceback.print_exc()
+ return False
+
+def test_dependency_checker():
+ """测试依赖检查器"""
+ print_header("Test 9: Dependency Checker")
+ try:
+ # 使用正确的函数名
+ from dependency_checker import check_dependencies, print_dependency_report, DependencyChecker
+
+ print(" [PASS] DependencyChecker imported")
+
+ # 创建实例并检查
+ checker = DependencyChecker()
+ checker.check_all()
+
+ print(" [PASS] Ran dependency check")
+ print(" Python version OK: " + str(checker.python_version_ok))
+ print(" All required OK: " + str(checker.is_all_ok()))
+
+ # 使用便捷函数
+ all_ok, checker2 = check_dependencies()
+ print(" [PASS] check_dependencies() returned: all_ok=" + str(all_ok))
+
+ return True
+ except Exception as e:
+ import traceback
+ print(" [FAIL] " + str(e))
+ traceback.print_exc()
+ return False
+
+def test_web_server_import():
+ """测试 Web 服务器完整导入"""
+ print_header("Test 10: Web Server Full Import")
+ try:
+ # 测试完整导入 server.py
+ from web.server import app
+
+ print(" [PASS] FastAPI app imported successfully")
+ print(" App title: " + str(app.title))
+ print(" Version: " + str(app.version))
+
+ # 检查路由
+ routes = []
+ for route in app.routes:
+ if hasattr(route, "path"):
+ routes.append(route.path)
+
+ print(" [PASS] Found " + str(len(routes)) + " routes")
+ print(" Sample routes: " + str(routes[:5]) + "...")
+
+ # 验证关键路由存在
+ expected_routes = ["/", "/api/config", "/api/upload", "/api/tasks"]
+ for route in expected_routes:
+ if route in routes:
+ print(" [PASS] Route exists: " + route)
+ else:
+ print(" [WARN] Route missing: " + route)
+
+ return True
+ except Exception as e:
+ import traceback
+ print(" [FAIL] " + str(e))
+ traceback.print_exc()
+ return False
+
+def main():
+ print("=" * 60)
+ print(" VideoSubtitleAI Comprehensive Module Test (FIXED)")
+ print("=" * 60)
+
+ tests = [
+ ("subtitle_models", test_subtitle_models),
+ ("subtitle_exporter", test_subtitle_exporter),
+ ("subtitle_translator", test_subtitle_translator),
+ ("web_config", test_web_config),
+ ("task_manager", test_task_manager),
+ ("websocket_manager", test_websocket_manager),
+ ("file_validation", test_file_validation),
+ ("subtitle_embedder", test_subtitle_embedder),
+ ("dependency_checker", test_dependency_checker),
+ ("web_server_import", test_web_server_import),
+ ]
+
+ results = {}
+
+ for name, test_func in tests:
+ try:
+ results[name] = test_func()
+ except Exception as e:
+ print(" [ERROR] Test " + name + " crashed: " + str(e))
+ results[name] = False
+
+ print("\n" + "=" * 60)
+ print(" TEST SUMMARY")
+ print("=" * 60)
+
+ passed = 0
+ failed = 0
+ for name, result in results.items():
+ status = "PASS" if result else "FAIL"
+ if result:
+ passed += 1
+ else:
+ failed += 1
+ print(" [" + status + "] " + name)
+
+ print("\n Total: " + str(len(results)) + " | Passed: " + str(passed) + " | Failed: " + str(failed))
+
+ if failed == 0:
+ print("\n All module tests passed!")
+ else:
+ print("\n " + str(failed) + " test(s) failed - please check above.")
+
+ print("=" * 60)
+
+ test_results['modules'] = results
+ test_results['summary'] = {'total': len(results), 'passed': passed, 'failed': failed}
+
+ return failed == 0
+
+if __name__ == "__main__":
+ success = main()
+ sys.exit(0 if success else 1)
diff --git a/test_files/fake_video.mp4 b/test_files/fake_video.mp4
new file mode 100644
index 0000000..3562e94
--- /dev/null
+++ b/test_files/fake_video.mp4
@@ -0,0 +1,10 @@
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
diff --git a/test_files/invalid_file.txt b/test_files/invalid_file.txt
new file mode 100644
index 0000000..ad29969
--- /dev/null
+++ b/test_files/invalid_file.txt
@@ -0,0 +1 @@
+This is a text file, not supported.
diff --git a/test_fixes.py b/test_fixes.py
new file mode 100644
index 0000000..ee15837
--- /dev/null
+++ b/test_fixes.py
@@ -0,0 +1,193 @@
+"""
+验证修复的核心功能
+"""
+import sys
+import os
+from pathlib import Path
+import json
+
+PROJECT_ROOT = Path(__file__).parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+def test_subtitle_document_to_json():
+ print("测试 1: SubtitleDocument.to_json() 方法")
+ try:
+ from subtitle_models import SubtitleDocument, SubtitleSegment, create_document_from_segments
+
+ test_segments = [
+ {'id': 0, 'start': 0.0, 'end': 2.5, 'text': '你好,世界'},
+ {'id': 1, 'start': 2.5, 'end': 5.0, 'text': '这是一个测试'},
+ ]
+
+ doc = create_document_from_segments(test_segments)
+ print(f" 创建文档: {len(doc.segments)} 段")
+
+ json_str = doc.to_json()
+ print(f" to_json() 成功: {len(json_str)} 字符")
+
+ data = json.loads(json_str)
+ print(f" JSON 解析成功: segments={len(data.get('segments', []))}")
+
+ print(" [PASS]")
+ return True
+ except Exception as e:
+ import traceback
+ print(f" [FAIL] {e}")
+ traceback.print_exc()
+ return False
+
+def test_bilingual_subtitles():
+ print("\n测试 2: 双语字幕功能")
+ try:
+ from subtitle_translator import (
+ DummyTranslator,
+ detect_text_language,
+ translate_segments_with_default,
+ )
+
+ test_text_zh = "你好,这是一个测试句子。"
+ test_text_en = "Hello, this is a test sentence."
+
+ lang, conf = detect_text_language(test_text_zh)
+ print(f" 检测中文: {lang}")
+
+ lang, conf = detect_text_language(test_text_en)
+ print(f" 检测英文: {lang}")
+
+ dummy = DummyTranslator(source_language='zh', target_language='en', mode="smart_bilingual")
+ result = dummy.translate(test_text_zh)
+ print(f" 中文翻译结果: translated_text='{result.translated_text}', success={result.success}")
+
+ test_segments = [
+ {'id': 0, 'start': 0.0, 'end': 2.0, 'text': '你好,世界'},
+ {'id': 1, 'start': 2.0, 'end': 4.0, 'text': 'Hello World'},
+ ]
+
+ translated = translate_segments_with_default(
+ segments=test_segments,
+ source_language='zh',
+ target_language='en',
+ )
+
+ print(f" 双语字幕生成: {len(translated)} 段")
+ for seg in translated:
+ if hasattr(seg, 'is_bilingual') and seg.is_bilingual:
+ print(f" 段 {seg.id}: bilingual={seg.is_bilingual}")
+ print(f" text_zh='{seg.text_zh}'")
+ print(f" text_en='{seg.text_en}'")
+
+ print(" [PASS]")
+ return True
+ except Exception as e:
+ import traceback
+ print(f" [FAIL] {e}")
+ traceback.print_exc()
+ return False
+
+def test_task_duration():
+ print("\n测试 3: 任务处理时间计算")
+ try:
+ from web.tasks import ProcessingTask, TaskStatus
+ from datetime import datetime, timedelta
+
+ task = ProcessingTask(
+ task_id="test-123",
+ input_files=["test.mp4"],
+ )
+
+ task.mark_started()
+ print(f" 任务开始: status={task.status.value}")
+
+ task.mark_completed({'success': True})
+ print(f" 任务完成: status={task.status.value}")
+
+ duration = task.duration
+ print(f" 处理时间: {duration:.2f} 秒")
+
+ task_dict = task.to_dict()
+ print(f" to_dict() 包含 duration: {'duration' in task_dict}")
+ print(f" duration 值: {task_dict.get('duration')}")
+
+ print(" [PASS]")
+ return True
+ except Exception as e:
+ import traceback
+ print(f" [FAIL] {e}")
+ traceback.print_exc()
+ return False
+
+def test_bilingual_export():
+ print("\n测试 4: 双语字幕导出")
+ try:
+ from subtitle_models import SubtitleDocument, SubtitleSegment
+ from subtitle_exporter import SubtitleExporter
+
+ seg1 = SubtitleSegment(
+ id=0, start=0.0, end=2.0,
+ text='你好世界',
+ text_zh='你好世界',
+ text_en='Hello World',
+ is_bilingual=True,
+ )
+
+ seg2 = SubtitleSegment(
+ id=1, start=2.0, end=4.0,
+ text='测试句子',
+ text_zh='测试句子',
+ text_en='Test sentence',
+ is_bilingual=True,
+ )
+
+ doc = SubtitleDocument(segments=[seg1, seg2])
+ doc.is_bilingual = True
+
+ exporter = SubtitleExporter(doc)
+ srt_content = exporter.generate_srt_content()
+
+ print(f" SRT 内容生成: {len(srt_content)} 字符")
+ print(" 内容预览:")
+ for line in srt_content.split('\n')[:15]:
+ print(f" {line}")
+
+ print(" [PASS]")
+ return True
+ except Exception as e:
+ import traceback
+ print(f" [FAIL] {e}")
+ traceback.print_exc()
+ return False
+
+def run_all_tests():
+ print("=" * 60)
+ print("VideoSubtitleAI 修复验证测试")
+ print("=" * 60)
+
+ results = {}
+
+ results['to_json'] = test_subtitle_document_to_json()
+ results['bilingual'] = test_bilingual_subtitles()
+ results['duration'] = test_task_duration()
+ results['export'] = test_bilingual_export()
+
+ print("\n" + "=" * 60)
+ print("测试结果汇总")
+ print("=" * 60)
+
+ passed = sum(1 for v in results.values() if v)
+ total = len(results)
+
+ for name, result in results.items():
+ status = "PASS" if result else "FAIL"
+ print(f" {name}: {status}")
+
+ print(f"\n总计: {passed}/{total} 通过")
+
+ if passed == total:
+ print("\n所有修复验证通过!")
+ return True
+ else:
+ print("\n部分测试失败,请检查修复")
+ return False
+
+if __name__ == "__main__":
+ run_all_tests()
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/test_output/test.ass b/test_output/test.ass
new file mode 100644
index 0000000..151951d
--- /dev/null
+++ b/test_output/test.ass
@@ -0,0 +1,19 @@
+[Script Info]
+; Script generated by VideoSubtitleAI
+Title: Untitled
+Original Script: VideoSubtitleAI
+ScriptType: v4.00+
+Collisions: Normal
+PlayResX: 1920
+PlayResY: 1080
+Timer: 100.0000
+
+[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
+Dialogue: 0,0:00:00.00,0:00:02.50,Default,,0,0,0,,你好,世界
+Dialogue: 0,0:00:02.50,0:00:05.00,Default,,0,0,0,,这是一个测试
+Dialogue: 0,0:00:05.00,0:00:07.50,Default,,0,0,0,,Hello World
\ No newline at end of file
diff --git a/test_output/test.vtt b/test_output/test.vtt
new file mode 100644
index 0000000..0d4697e
--- /dev/null
+++ b/test_output/test.vtt
@@ -0,0 +1,22 @@
+WEBVTT
+
+STYLE
+::cue {
+ background-color: transparent;
+ color: white;
+ font-family: 'Microsoft YaHei', sans-serif;
+ font-size: 24px;
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
+}
+
+00001
+00:00:00.000 --> 00:00:02.500
+你好,世界
+
+00002
+00:00:02.500 --> 00:00:05.000
+这是一个测试
+
+00003
+00:00:05.000 --> 00:00:07.500
+Hello World
diff --git a/test_plan_complete.md b/test_plan_complete.md
new file mode 100644
index 0000000..d707c1c
--- /dev/null
+++ b/test_plan_complete.md
@@ -0,0 +1,1998 @@
+# VideoSubtitleAI Web 版完整测试计划
+
+> 测试日期: 2026-05-02
+> 测试环境: Windows 10/11, Python 3.10, FastAPI 0.104+
+> 项目版本: v2.0.0
+
+---
+
+## 目录
+
+1. [测试环境准备](#1-测试环境准备)
+2. [测试文件上传](#2-测试文件上传)
+3. [测试字幕生成](#3-测试字幕生成)
+4. [测试字幕编辑](#4-测试字幕编辑)
+5. [测试导出下载](#5-测试导出下载)
+6. [测试字幕硬嵌入](#6-测试字幕硬嵌入)
+7. [测试批量处理与 WebSocket](#7-测试批量处理与-websocket)
+8. [测试接口稳定性](#8-测试接口稳定性)
+9. [测试结果记录模板](#9-测试结果记录模板)
+
+---
+
+## 1. 测试环境准备
+
+### 1.1 前置条件
+
+```bash
+# 1. 确认 Python 版本
+python --version # 需要 3.8 - 3.11
+
+# 2. 激活虚拟环境
+venv\Scripts\activate
+
+# 3. 安装依赖
+pip install -r requirements.txt
+
+# 4. 安装 FFmpeg (必需)
+winget install Gyan.FFmpeg # Windows
+# 或手动下载: https://www.gyan.dev/ffmpeg/builds/
+
+# 5. 验证 FFmpeg
+ffmpeg -version
+
+# 6. 验证依赖
+python main.py --check-deps
+```
+
+### 1.2 测试文件准备
+
+| 文件名 | 类型 | 大小 | 用途 | 准备方法 |
+|--------|------|------|------|----------|
+| `test_valid.mp4` | 有效视频 | ~50MB | 正常上传测试 | 使用 FFmpeg 创建短测试视频 |
+| `test_valid.mp3` | 有效音频 | ~10MB | 正常上传测试 | 转换视频或下载测试音频 |
+| `test_valid.wav` | 有效音频 | ~20MB | 格式兼容性测试 | 同上 |
+| `test_valid.mov` | 有效视频 | ~30MB | 格式兼容性测试 | 使用 QuickTime 格式 |
+| `test_empty.mp4` | 空文件 | 0 KB | 异常测试 | 创建空文件 |
+| `test_invalid.txt` | 无效格式 | ~1KB | 格式拒绝测试 | 文本文件 |
+| `test_large.mp4` | 超大文件 | >500MB | 大小限制测试 | 使用 FFmpeg 生成 |
+| `test_corrupted.mp4` | 损坏文件 | ~1MB | 错误处理测试 | 修改文件头 |
+
+**快速创建测试文件命令:**
+```powershell
+# 创建空文件
+New-Item -ItemType File -Path "test_files\test_empty.mp4" -Force
+
+# 创建无效文本文件
+echo "This is not a valid video file" > test_files\test_invalid.txt
+
+# 创建损坏视频文件 (修改有效视频的文件头)
+# 手动: 用十六进制编辑器修改有效视频的前几个字节
+```
+
+### 1.3 启动测试服务器
+
+```bash
+# 开发模式 (带热重载)
+uvicorn web.server:app --reload --host 127.0.0.1 --port 8000
+
+# 或使用启动脚本
+python run_web.py
+
+# 访问测试页面
+# 主页: http://localhost:8000
+# API文档: http://localhost:8000/docs
+# 批量处理: http://localhost:8000/batch
+```
+
+---
+
+## 2. 测试文件上传
+
+### 2.1 测试用例总览
+
+| 测试ID | 测试项 | 优先级 | 测试类型 |
+|--------|--------|--------|----------|
+| TC-UPLOAD-001 | 有效 MP4 视频上传 | 高 | 功能测试 |
+| TC-UPLOAD-002 | 有效 MP3 音频上传 | 高 | 功能测试 |
+| TC-UPLOAD-003 | 有效 WAV 音频上传 | 中 | 功能测试 |
+| TC-UPLOAD-004 | 有效 MOV 视频上传 | 中 | 功能测试 |
+| TC-UPLOAD-005 | 空文件上传 | 高 | 边界测试 |
+| TC-UPLOAD-006 | 超大文件上传 | 高 | 边界测试 |
+| TC-UPLOAD-007 | 无效格式文件上传 | 高 | 异常测试 |
+| TC-UPLOAD-008 | 损坏/不完整文件上传 | 中 | 异常测试 |
+| TC-UPLOAD-009 | 拖拽上传功能 | 中 | UI 测试 |
+| TC-UPLOAD-010 | 连续多次上传 | 中 | 稳定性测试 |
+
+---
+
+### TC-UPLOAD-001: 有效 MP4 视频上传
+
+**测试目标:** 验证系统能正确处理有效 MP4 视频文件上传
+
+**前置条件:**
+- 服务器已启动
+- 有有效的 MP4 测试文件 (5-50MB)
+
+**测试步骤:**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 打开主页 `http://localhost:8000` | 页面正常显示,上传区域可见 |
+| 2 | 点击上传区域或拖放 MP4 文件 | 文件选择对话框打开/拖拽效果显示 |
+| 3 | 选择有效 MP4 文件 | 文件信息显示: 文件名、大小、视频图标 |
+| 4 | 点击「开始处理」按钮 | 按钮变为「上传中...」,显示加载动画 |
+| 5 | 等待上传完成 | 显示成功通知:「文件已选择」 |
+| 6 | 检查 API 响应 | `POST /api/upload` 返回 200 OK |
+
+**API 测试 (curl):**
+```powershell
+# 使用 curl 测试上传接口
+curl -X POST "http://localhost:8000/api/upload" ^
+ -H "accept: application/json" ^
+ -H "Content-Type: multipart/form-data" ^
+ -F "file=@test_files\test_valid.mp4"
+```
+
+**预期 API 响应:**
+```json
+{
+ "success": true,
+ "task_id": "uuid-string",
+ "filename": "test_valid.mp4",
+ "file_size": 52428800,
+ "is_video": true
+}
+```
+
+**检查点:**
+- [ ] 上传进度正确显示
+- [ ] 文件大小计算准确
+- [ ] 任务 ID 正确生成
+- [ ] 数据库/内存中任务记录正确
+
+---
+
+### TC-UPLOAD-002: 有效 MP3 音频上传
+
+**测试目标:** 验证系统能正确处理音频文件上传
+
+**测试步骤:**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 上传有效 MP3 文件 | 文件成功上传 |
+| 2 | 检查文件类型识别 | `is_video: false` |
+| 3 | 查看界面显示 | 显示音频图标而非视频图标 |
+
+**API 验证:**
+```powershell
+curl -X POST "http://localhost:8000/api/upload" ^
+ -F "file=@test_files\test_valid.mp3"
+```
+
+**检查点:**
+- [ ] 音频文件正确识别
+- [ ] 后续处理流程支持音频输入
+- [ ] 无视频相关功能错误
+
+---
+
+### TC-UPLOAD-005: 空文件上传
+
+**测试目标:** 验证系统对空文件的正确拒绝
+
+**测试步骤:**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 创建 0 字节的 MP4 文件 | `test_files\test_empty.mp4` (0KB) |
+| 2 | 尝试上传该文件 | 上传失败 |
+| 3 | 查看错误提示 | 显示明确的错误信息 |
+
+**预期错误响应:**
+```json
+{
+ "detail": "文件内容为空或无效"
+}
+```
+
+**检查点:**
+- [ ] 空文件被正确拒绝
+- [ ] 错误信息用户友好
+- [ ] 无异常堆栈暴露
+
+---
+
+### TC-UPLOAD-006: 超大文件上传
+
+**测试目标:** 验证系统对文件大小限制的正确处理
+
+**配置参考:** (`web/config.py:31`)
+```python
+MAX_FILE_SIZE: int = 500 * 1024 * 1024 # 500MB
+```
+
+**测试步骤:**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 准备 >500MB 的视频文件 | 创建或使用现有大文件 |
+| 2 | 尝试上传 | 上传过程中或开始时被拒绝 |
+| 3 | 检查错误码 | HTTP 400 Bad Request |
+| 4 | 查看错误信息 | 明确提示文件过大及限制大小 |
+
+**预期错误响应:**
+```json
+{
+ "detail": "文件过大。最大允许: 500 MB"
+}
+```
+
+**检查点:**
+- [ ] 文件在上传过程中被及时拒绝 (不是完全上传后才拒绝)
+- [ ] 临时文件被正确清理
+- [ ] 进度条正确回滚
+
+---
+
+### TC-UPLOAD-007: 无效格式文件上传
+
+**测试目标:** 验证系统对不支持格式的正确拒绝
+
+**支持格式参考:** (`web/config.py:23-29`)
+```python
+SUPPORTED_AUDIO_FORMATS = [".mp3", ".wav", ".m4a", ".flac", ".aac", ".ogg", ".wma"]
+SUPPORTED_VIDEO_FORMATS = [".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".webm", ".m4v"]
+```
+
+**测试步骤:**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 准备文本文件 `test_invalid.txt` | 内容任意 |
+| 2 | 尝试上传 | 立即被拒绝 |
+| 3 | 检查错误码 | HTTP 400 |
+| 4 | 查看支持格式列表 | 错误信息中列出支持的格式 |
+
+**测试扩展 - 边缘格式:**
+- `.exe` - 可执行文件
+- `.zip` - 压缩文件
+- `.pdf` - 文档文件
+- `.png`/`.jpg` - 图片文件
+
+**预期错误响应:**
+```json
+{
+ "detail": "不支持的文件格式: .txt。支持的格式: .mp3, .wav, .mp4, .mov..."
+}
+```
+
+---
+
+## 3. 测试字幕生成
+
+### 3.1 测试用例总览
+
+| 测试ID | 测试项 | 优先级 | 测试类型 |
+|--------|--------|--------|----------|
+| TC-GEN-001 | 单语字幕生成 (中文) | 高 | 功能测试 |
+| TC-GEN-002 | 单语字幕生成 (英文) | 高 | 功能测试 |
+| TC-GEN-003 | 自动语言检测 | 高 | 功能测试 |
+| TC-GEN-004 | 中英双语字幕生成 | 高 | 功能测试 |
+| TC-GEN-005 | 不同模型对比 (tiny/base/small) | 中 | 性能测试 |
+| TC-GEN-006 | 断句准确性 | 中 | 功能测试 |
+| TC-GEN-007 | 时间轴准确性 | 高 | 功能测试 |
+| TC-GEN-008 | 静音/空白片段处理 | 中 | 边界测试 |
+| TC-GEN-009 | 长音频处理 (>30分钟) | 中 | 性能测试 |
+| TC-GEN-010 | 进度显示完整性 | 高 | UI 测试 |
+
+---
+
+### TC-GEN-001: 单语字幕生成 (中文)
+
+**测试目标:** 验证中文语音识别和字幕生成功能
+
+**测试文件准备:**
+- 包含清晰中文语音的视频/音频 (1-5分钟)
+- 内容: 新闻播报、演讲、对话等不同场景
+
+**测试步骤:**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 上传中文视频文件 | 上传成功,获得 task_id |
+| 2 | 选择选项: 模型=base, 语言=中文, 格式=SRT | 选项正确保存 |
+| 3 | 点击「开始处理」 | 处理启动,WebSocket 连接建立 |
+| 4 | 观察处理阶段 | 阶段依次: 提取音频→加载模型→语音识别→生成字幕→导出 |
+| 5 | 等待完成 | 状态变为「已完成」,进度 100% |
+| 6 | 查看生成的字幕 | 包含中文文本,时间轴合理 |
+
+**关键代码位置分析:**
+
+处理流程 (`web/processor.py:253-355`):
+```python
+def _process_file_sync(self, task: ProcessingTask):
+ # 1. 提取音频 (视频文件)
+ audio_path = self._extract_audio_sync(str(input_path), task)
+
+ # 2. 语音识别
+ recognition_result = self._recognize_sync(
+ audio_path=audio_path,
+ language=language, # 'zh'
+ model_name=model_name,
+ task=task,
+ )
+
+ # 3. 生成字幕文档
+ segments = recognition_result.get('segments', [])
+
+ # 4. 导出字幕文件
+ output_files = self._export_subtitles_sync(...)
+```
+
+**检查点:**
+- [ ] 所有中文文字准确识别 (允许合理误差)
+- [ ] 时间轴与语音同步 (误差 < 0.5秒)
+- [ ] 标点符号正确添加
+- [ ] 段落划分合理
+
+---
+
+### TC-GEN-003: 自动语言检测
+
+**测试目标:** 验证 Whisper 的自动语言检测功能
+
+**测试步骤:**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 上传中文视频 | - |
+| 2 | 选择: 语言=「自动检测」 | - |
+| 3 | 开始处理 | - |
+| 4 | 检查日志输出 | 显示「检测到语言: zh」 |
+| 5 | 查看生成的字幕 | 正确识别为中文 |
+
+**多语言测试矩阵:**
+
+| 测试文件 | 预期检测结果 | 验证方法 |
+|----------|--------------|----------|
+| 中文语音视频 | `zh` | 日志检查 |
+| 英文语音视频 | `en` | 日志检查 |
+| 日文语音视频 | `ja` | 日志检查 |
+| 混合语言 (中+英) | 主要语言 | 日志检查 |
+
+**关键代码 (`web/processor.py:104`):**
+```python
+detected_language = result.get('language', 'auto')
+task.add_log(f"检测到语言: {detected_language}", "INFO")
+```
+
+**检查点:**
+- [ ] 中文正确检测为 `zh`
+- [ ] 英文正确检测为 `en`
+- [ ] 检测结果在日志中显示
+- [ ] 检测结果用于后续处理
+
+---
+
+### TC-GEN-004: 中英双语字幕生成
+
+**测试目标:** 验证双语字幕生成和翻译功能
+
+**前置条件:**
+- 确认翻译模块可用 (`test_web_features.py` 中的测试)
+
+**测试步骤:**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 上传中文视频 | 上传成功 |
+| 2 | 勾选「中英双语字幕」选项 | 选项被选中 |
+| 3 | 开始处理 | 显示「翻译中...」阶段 |
+| 4 | 检查日志 | 显示「开始生成双语字幕...」 |
+| 5 | 查看生成的字幕 | 每段包含中文和英文 |
+| 6 | 检查字幕格式 | `text_zh` 和 `text_en` 字段存在 |
+
+**关键代码 (`web/processor.py:178-200`):**
+```python
+def _generate_bilingual_sync(self, segments, source_language, task):
+ from subtitle_translator import translate_segments_with_default
+
+ task.update_stage(ProcessingStage.TRANSLATING, "翻译中...", 60)
+
+ translated_segments = translate_segments_with_default(
+ segments=segments,
+ source_language=source_language,
+ target_language='en', # 或 'zh'
+ )
+ return translated_segments, True
+```
+
+**字幕数据结构验证:**
+```json
+{
+ "segments": [
+ {
+ "id": 0,
+ "start": 0.0,
+ "end": 3.5,
+ "text": "你好世界",
+ "text_zh": "你好世界",
+ "text_en": "Hello World",
+ "is_bilingual": true
+ }
+ ]
+}
+```
+
+**检查点:**
+- [ ] 翻译阶段正确显示
+- [ ] 双语字幕正确生成
+- [ ] 中英文对应正确
+- [ ] 双语标识 `is_bilingual: true`
+
+---
+
+### TC-GEN-007: 时间轴准确性
+
+**测试目标:** 验证生成的字幕时间轴与语音同步
+
+**测试方法:**
+
+1. **准备带时间戳的测试素材**
+ - 使用已知时间点的语音片段
+ - 例如: 在第1秒说「开始」,第5秒说「结束」
+
+2. **手动校准验证**
+ - 播放视频,观察字幕出现时间
+ - 记录每段字幕的实际显示时间
+
+3. **自动化检查 (使用测试脚本)**
+
+```python
+# 时间轴准确性检查脚本示例
+def verify_timing_accuracy(segments, expected_timings):
+ """
+ 验证时间轴准确性
+ segments: 生成的字幕段
+ expected_timings: 预期时间点列表
+ """
+ errors = []
+ tolerance = 0.5 # 允许 0.5 秒误差
+
+ for i, (actual, expected) in enumerate(zip(segments, expected_timings)):
+ start_error = abs(actual.start - expected['start'])
+ end_error = abs(actual.end - expected['end'])
+
+ if start_error > tolerance:
+ errors.append(f"段{i}: 开始时间误差 {start_error:.2f}s")
+ if end_error > tolerance:
+ errors.append(f"段{i}: 结束时间误差 {end_error:.2f}s")
+
+ return errors
+```
+
+**验收标准:**
+- 时间误差 ≤ 0.5 秒为合格
+- 连续字幕间无重叠或过长间隔
+- 字幕开始时间与语音开始时间匹配
+
+---
+
+## 4. 测试字幕编辑
+
+### 4.1 测试用例总览
+
+| 测试ID | 测试项 | 优先级 | 测试类型 |
+|--------|--------|--------|----------|
+| TC-EDIT-001 | 字幕文本修改 | 高 | 功能测试 |
+| TC-EDIT-002 | 时间轴调整 | 高 | 功能测试 |
+| TC-EDIT-003 | 字幕合并 | 高 | 功能测试 |
+| TC-EDIT-004 | 字幕拆分 | 高 | 功能测试 |
+| TC-EDIT-005 | 字幕删除 | 中 | 功能测试 |
+| TC-EDIT-006 | 撤销/重做功能 | 高 | 功能测试 |
+| TC-EDIT-007 | 时间批量移位 | 中 | 功能测试 |
+| TC-EDIT-008 | 实时样式预览 | 中 | UI 测试 |
+| TC-EDIT-009 | 字幕搜索 | 中 | 功能测试 |
+| TC-EDIT-010 | 保存与重新加载 | 高 | 功能测试 |
+
+---
+
+### TC-EDIT-001: 字幕文本修改
+
+**测试目标:** 验证字幕文本编辑功能
+
+**测试步骤:**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 处理完成后点击「编辑字幕」 | 进入编辑器页面 `/editor?task_id=xxx` |
+| 2 | 等待字幕加载 | 字幕列表显示 |
+| 3 | 双击某条字幕文本 | 进入编辑模式 (textarea 显示) |
+| 4 | 修改文字内容 | 可输入任意文字 |
+| 5 | 点击其他区域或按 Enter | 退出编辑模式,显示新文本 |
+| 6 | 检查历史记录 | 可撤销此操作 |
+
+**关键代码 (`web/static/js/editor.js:623-670`):**
+```javascript
+startEditing(index) {
+ // 显示 textarea,隐藏 display
+ display.classList.add('hidden');
+ textarea.classList.remove('hidden');
+ textarea.focus();
+}
+
+finishEditing() {
+ // 保存修改
+ if (newText !== this.subtitles[this.editingIndex].text) {
+ this.saveState(); // 保存历史
+ this.subtitles[this.editingIndex].text = newText;
+ }
+}
+```
+
+**检查点:**
+- [ ] 双击正确进入编辑模式
+- [ ] 文本修改正确保存
+- [ ] 编辑模式下可取消 (按 Escape)
+- [ ] 修改后历史记录可用
+
+---
+
+### TC-EDIT-002: 时间轴调整
+
+**测试目标:** 验证开始/结束时间的修改功能
+
+**测试步骤:**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 定位到某条字幕的「开始时间」输入框 | 显示格式如 `00:00:02.500` |
+| 2 | 修改为新时间 `00:00:05.000` | 输入框接受修改 |
+| 3 | 点击其他区域 (失去焦点) | 时间被解析并保存 |
+| 4 | 检查字幕列表 | 该条字幕的开始时间已更新 |
+| 5 | 输入无效格式 `abc` | 显示错误提示,恢复原值 |
+
+**支持的时间格式:**
+- `00:00:05.000` - 完整格式
+- `00:05.000` - 省略小时
+- `5.0` - 仅秒数
+- `5` - 整数秒
+
+**关键代码 (`web/static/js/editor.js:1295-1323`):**
+```javascript
+parseTime(timeStr) {
+ if (timeStr.includes(':')) {
+ const parts = timeStr.split(':');
+ // 解析 HH:MM:SS.sss 或 MM:SS.sss
+ }
+ return parseFloat(timeStr);
+}
+```
+
+**检查点:**
+- [ ] 多种时间格式正确解析
+- [ ] 无效格式正确拒绝
+- [ ] 时间修改后顺序仍正确 (开始 < 结束)
+- [ ] 修改后可撤销
+
+---
+
+### TC-EDIT-003: 字幕合并
+
+**测试目标:** 验证多条字幕合并功能
+
+**测试方法 1: 多选合并**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 勾选第1、2、3条字幕 | 三条被选中,高亮显示 |
+| 2 | 点击「合并选中」按钮 | 按钮可用 (选中≥2条) |
+| 3 | 确认合并 | 三条合并为一条 |
+| 4 | 检查结果: | - |
+| | 文本内容 | 三条文本用空格连接 |
+| | 时间轴 | 开始=第一条开始,结束=第三条结束 |
+| | 条数 | 总条数减少2 |
+
+**测试方法 2: 相邻合并 (与下一条合并)**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 点击某条字幕的「合并」图标 | 与下一条合并 |
+| 2 | 检查结果 | 两条合并为一条 |
+
+**关键代码 (`web/static/js/editor.js:723-758`):**
+```javascript
+mergeSelectedSubtitles() {
+ const sortedIndices = Array.from(this.selectedIndices).sort();
+ const firstIndex = sortedIndices[0];
+ const lastIndex = sortedIndices[sortedIndices.length - 1];
+
+ // 文本合并
+ 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);
+ }
+}
+```
+
+**检查点:**
+- [ ] 非连续选择正确拒绝
+- [ ] 文本正确拼接
+- [ ] 时间轴正确合并
+- [ ] 合并后可撤销
+
+---
+
+### TC-EDIT-004: 字幕拆分
+
+**测试目标:** 验证单条字幕拆分为两条的功能
+
+**测试步骤:**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 点击某条字幕的「拆分」图标 | 拆分模态框显示 |
+| 2 | 查看当前时间范围 | 显示如 `00:00:02.000 - 00:00:10.000` |
+| 3 | 输入拆分时间点 `00:00:06.000` | 输入框接受 |
+| 4 | 点击「确认拆分」 | 执行拆分 |
+| 5 | 检查结果: | - |
+| | 第一条字幕 | 开始=原开始,结束=拆分时间 |
+| | 第二条字幕 | 开始=拆分时间,结束=原结束 |
+| | 文本分配 | 按时间比例智能断句 |
+
+**智能断句逻辑 (`web/static/js/editor.js:795-869`):**
+```javascript
+confirmSplitSubtitle() {
+ // 计算文本分割位置
+ const duration = sub.end - sub.start;
+ const ratio = (splitSeconds - sub.start) / duration;
+ const splitPos = Math.floor(sub.text.length * ratio);
+
+ // 寻找最近的标点符号
+ const punctuations = ['。', '!', '?', ',', '.', '!', '?', ',', ' '];
+ // 向前/向后查找最近的标点
+}
+```
+
+**边界测试:**
+- 拆分时间 = 开始时间 → 拒绝
+- 拆分时间 = 结束时间 → 拒绝
+- 拆分时间超出范围 → 拒绝
+- 文本为空 → 拒绝
+
+**检查点:**
+- [ ] 模态框正确显示当前时间范围
+- [ ] 无效时间正确拒绝
+- [ ] 文本智能断句 (标点处优先)
+- [ ] 拆分后可撤销
+
+---
+
+### TC-EDIT-006: 撤销/重做功能
+
+**测试目标:** 验证编辑历史记录和回滚功能
+
+**测试场景:**
+
+```
+初始状态: [字幕1] [字幕2] [字幕3] (历史: [S0], 指针: 0)
+
+操作1: 修改字幕1文本
+状态: [字幕1'] [字幕2] [字幕3] (历史: [S0, S1], 指针: 1)
+
+操作2: 合并字幕2和3
+状态: [字幕1'] [字幕2+3] (历史: [S0, S1, S2], 指针: 2)
+
+操作3: 撤销 (Ctrl+Z)
+状态: [字幕1'] [字幕2] [字幕3] (历史: [S0, S1, S2], 指针: 1)
+
+操作4: 撤销 (Ctrl+Z)
+状态: [字幕1] [字幕2] [字幕3] (历史: [S0, S1, S2], 指针: 0)
+
+操作5: 重做 (Ctrl+Y)
+状态: [字幕1'] [字幕2] [字幕3] (历史: [S0, S1, S2], 指针: 1)
+```
+
+**测试步骤:**
+
+| 步骤 | 操作 | 撤销按钮 | 重做按钮 | 预期结果 |
+|------|------|----------|----------|----------|
+| 1 | 初始状态 | 禁用 | 禁用 | - |
+| 2 | 修改一条字幕 | 启用 | 禁用 | - |
+| 3 | 点击「撤销」 | 禁用 | 启用 | 文本恢复原值 |
+| 4 | 点击「重做」 | 启用 | 禁用 | 文本恢复修改后 |
+| 5 | 连续3次修改 | 启用 | 禁用 | - |
+| 6 | 连续3次撤销 | 禁用 | 启用 | 完全恢复 |
+| 7 | 新操作后重做 | 启用 | 禁用 | 重做历史被清除 |
+
+**关键代码 (`web/static/js/editor.js:910-938`):**
+```javascript
+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;
+}
+
+undo() {
+ if (this.historyIndex > 0) {
+ this.historyIndex--;
+ this.subtitles = JSON.parse(JSON.stringify(this.history[this.historyIndex]));
+ }
+}
+
+redo() {
+ if (this.historyIndex < this.history.length - 1) {
+ this.historyIndex++;
+ this.subtitles = JSON.parse(JSON.stringify(this.history[this.historyIndex]));
+ }
+}
+```
+
+**检查点:**
+- [ ] 按钮状态正确反映历史位置
+- [ ] 撤销正确恢复之前状态
+- [ ] 重做正确恢复已撤销的操作
+- [ ] 新操作后重做历史被清除
+- [ ] 历史深度合理 (建议至少20步)
+
+---
+
+## 5. 测试导出下载
+
+### 5.1 测试用例总览
+
+| 测试ID | 测试项 | 优先级 | 测试类型 |
+|--------|--------|--------|----------|
+| TC-EXPORT-001 | SRT 格式导出 | 高 | 功能测试 |
+| TC-EXPORT-002 | ASS 格式导出 | 高 | 功能测试 |
+| TC-EXPORT-003 | VTT 格式导出 | 高 | 功能测试 |
+| TC-EXPORT-004 | 自定义字体样式 | 中 | 功能测试 |
+| TC-EXPORT-005 | 字体颜色设置 | 中 | 功能测试 |
+| TC-EXPORT-006 | 字幕位置设置 | 中 | 功能测试 |
+| TC-EXPORT-007 | 描边样式设置 | 中 | 功能测试 |
+| TC-EXPORT-008 | 多格式同时导出 | 高 | 功能测试 |
+| TC-EXPORT-009 | 编辑后重新导出 | 高 | 功能测试 |
+| TC-EXPORT-010 | 下载文件名正确性 | 中 | 功能测试 |
+
+---
+
+### TC-EXPORT-001: SRT 格式导出
+
+**测试目标:** 验证 SRT 格式字幕的正确生成
+
+**SRT 格式规范:**
+```
+1
+00:00:01,000 --> 00:00:04,000
+第一行字幕文本
+
+2
+00:00:04,500 --> 00:00:08,000
+第二行字幕文本
+```
+
+**测试步骤:**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 处理完成或编辑完成 | - |
+| 2 | 在主页或编辑器选择导出 SRT | - |
+| 3 | 点击下载 | 文件开始下载 |
+| 4 | 打开下载的 SRT 文件 | 格式符合规范 |
+
+**格式验证要点:**
+- 序号从1开始,连续递增
+- 时间格式: `HH:MM:SS,mmm` (逗号分隔毫秒)
+- 时间箭头: ` --> ` (空格+箭头+空格)
+- 每行字幕后有空行
+- 文件编码: UTF-8 (建议带 BOM)
+
+**关键代码 (`web/static/js/editor.js:1174-1185`):**
+```javascript
+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);
+ // 注意: SRT 使用逗号分隔毫秒
+ return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`;
+}
+```
+
+**后端导出 (`subtitle_exporter.py`):**
+```python
+def export_to_srt(self, document: SubtitleDocument, output_path: str) -> str:
+ # 实现 SRT 格式生成
+ pass
+```
+
+**检查点:**
+- [ ] 时间格式正确 (逗号分隔毫秒)
+- [ ] 序号连续
+- [ ] 文件可被播放器识别
+- [ ] 中文字符正确显示
+
+---
+
+### TC-EXPORT-002: ASS 格式导出 (带样式)
+
+**测试目标:** 验证 ASS 格式及样式设置的正确导出
+
+**ASS 格式结构:**
+```
+[Script Info]
+Title: Subtitles
+ScriptType: v4.00+
+PlayResX: 1920
+PlayResY: 1080
+
+[V4+ Styles]
+Format: Name, Fontname, Fontsize, PrimaryColour, ...
+Style: Default,Microsoft YaHei,48,&H00FFFFFF,&H000000FF,...
+
+[Events]
+Format: Layer, Start, End, Style, ...
+Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,字幕文本
+```
+
+**样式设置测试矩阵:**
+
+| 设置项 | 测试值 | 预期效果 |
+|--------|--------|----------|
+| 字体大小 | 24, 48, 72 | ASS 中 `Fontsize` 对应值 |
+| 字体颜色 | #FF0000 (红) | 转换为 `&H000000FF` (BGR 顺序) |
+| 描边颜色 | #00FF00 (绿) | 转换为 `&H0000FF00` |
+| 描边宽度 | 0, 2, 4 | `Outline` 字段值 |
+| 位置 | 底部/居中/顶部 | `Alignment` = 2/5/8 |
+| 边距 | 20, 40, 60 | `MarginV` 字段值 |
+
+**关键代码 - 颜色转换 (`web/static/js/editor.js:1008-1020`):**
+```javascript
+hexToAssColor(hexColor, alpha = "00") {
+ hexColor = hexColor.trim().replace('#', '');
+ // RGB 转 BGR (ASS 格式使用 BGR 顺序)
+ const r = hexColor.substring(0, 2);
+ const g = hexColor.substring(2, 4);
+ const b = hexColor.substring(4, 6);
+ // 格式: &HAABBGGRR
+ return `&H${alpha}${b}${g}${r}`;
+}
+```
+
+**对齐映射:**
+```javascript
+const alignmentMap = { 'bottom': 2, 'middle': 5, 'top': 8 };
+```
+
+**检查点:**
+- [ ] 颜色正确转换 (RGB → BGR)
+- [ ] 位置正确映射
+- [ ] 所有样式字段正确写入
+- [ ] ASS 文件可被播放器正确渲染样式
+
+---
+
+### TC-EXPORT-003: VTT 格式导出
+
+**测试目标:** 验证 WebVTT 格式导出
+
+**VTT 格式特点:**
+- 以 `WEBVTT` 开头
+- 时间格式: `HH:MM:SS.mmm` (点分隔毫秒)
+- 时间箭头: ` --> `
+- 支持简单样式标记
+
+**测试步骤:**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 选择导出 VTT 格式 | - |
+| 2 | 下载并打开文件 | 第一行是 `WEBVTT` |
+| 3 | 检查时间格式 | 使用点号分隔毫秒: `00:00:05.000` |
+
+**与 SRT 对比:**
+```
+SRT: 00:00:01,000 --> 00:00:04,000 (逗号)
+VTT: 00:00:01.000 --> 00:00:04.000 (点号)
+```
+
+**关键代码 (`web/static/js/editor.js:1279-1284`):**
+```javascript
+formatTimeVTT(seconds) {
+ // 注意: VTT 使用点号分隔毫秒
+ return `${hrs}:${mins}:${secs}.${ms}`;
+}
+```
+
+---
+
+### TC-EXPORT-004 至 TC-EXPORT-007: 样式设置综合测试
+
+**测试场景:** 实时预览 + 导出验证
+
+**样式面板位置 (`web/templates/editor.html`):**
+```
+左侧面板
+├── 样式设置
+│ ├── 字体大小: [48] px
+│ ├── 字体颜色: [■ #FFFFFF]
+│ ├── 描边颜色: [■ #000000]
+│ ├── 描边宽度: [2] px
+│ ├── 字幕位置: [底部 ▼]
+│ └── 垂直边距: [40] px
+└── 预览文本
+ └── [示例字幕文本] (实时应用样式)
+```
+
+**实时预览验证 (`web/static/js/editor.js:141-176`):**
+```javascript
+updateStylePreview() {
+ // 更新预览文本样式
+ previewText.style.fontSize = `${scaledFontSize}px`;
+ previewText.style.color = fontColor;
+ previewText.style.textShadow = `...`; // 模拟描边
+
+ // 更新叠加层位置
+ subtitleOverlay.style.bottom = `${marginV + 50}px`; // 底部
+ // 或
+ subtitleOverlay.style.top = `${marginV + 10}px`; // 顶部
+}
+```
+
+**测试步骤:**
+
+| 步骤 | 操作 | 实时预览 | 导出 ASS 验证 |
+|------|------|----------|---------------|
+| 1 | 字体大小改为 72 | 预览文本变大 | `Fontsize: 72` ✓ |
+| 2 | 字体颜色改为红色 | 预览文本变红 | `PrimaryColour: &H000000FF` ✓ |
+| 3 | 位置改为「顶部」 | 预览文本移到顶部 | `Alignment: 8` ✓ |
+| 4 | 描边宽度改为 4 | 预览描边变粗 | `Outline: 4` ✓ |
+| 5 | 导出 ASS 文件 | - | 播放器中样式正确显示 |
+
+**检查点:**
+- [ ] 所有样式修改实时反映在预览区
+- [ ] 样式参数正确传递到后端
+- [ ] 导出的 ASS 文件包含所有样式设置
+- [ ] 播放器中正确渲染样式
+
+---
+
+## 6. 测试字幕硬嵌入
+
+### 6.1 测试用例总览
+
+| 测试ID | 测试项 | 优先级 | 测试类型 |
+|--------|--------|--------|----------|
+| TC-EMBED-001 | 基本字幕嵌入 | 高 | 功能测试 |
+| TC-EMBED-002 | 双语字幕嵌入 | 高 | 功能测试 |
+| TC-EMBED-003 | 自定义样式嵌入 | 中 | 功能测试 |
+| TC-EMBED-004 | GPU 加速嵌入 | 中 | 性能测试 |
+| TC-EMBED-005 | 不同质量预设 | 中 | 功能测试 |
+| TC-EMBED-006 | 长视频嵌入 (>10min) | 中 | 稳定性测试 |
+| TC-EMBED-007 | 输出视频验证 | 高 | 功能测试 |
+| TC-EMBED-008 | 错误处理 (无 FFmpeg) | 高 | 异常测试 |
+
+---
+
+### TC-EMBED-001: 基本字幕嵌入
+
+**测试目标:** 验证字幕成功烧录到视频中
+
+**前置条件:**
+- FFmpeg 已安装并在 PATH 中
+- 有有效的视频文件和字幕文件
+
+**配置选项:**
+- 勾选「硬嵌入字幕到视频」
+- 选择质量预设: 低/中/高/无损
+- GPU 加速: 可选 (需要 NVIDIA GPU + CUDA)
+
+**处理流程 (`web/processor.py:202-251`):**
+```python
+def _embed_subtitles_sync(self, video_path, subtitle_path, output_path, task, options):
+ from subtitle_embedder import SubtitleEmbedder, create_embedding_config_from_style
+
+ task.update_stage(ProcessingStage.EMBEDDING, "嵌入字幕中...", 85)
+
+ # 创建嵌入配置
+ config = create_embedding_config_from_style(style, use_gpu=options.get('embed_gpu', False))
+
+ # 执行嵌入
+ embedder = SubtitleEmbedder()
+ result = embedder.embed_subtitles(
+ video_path=video_path,
+ subtitle_path=subtitle_path,
+ output_path=output_path,
+ config=config,
+ )
+
+ return output_path if result.get('success') else None
+```
+
+**FFmpeg 命令分析 (`subtitle_embedder.py`):**
+```bash
+# 基本 ASS 字幕嵌入 (CPU)
+ffmpeg -i input.mp4 -vf "subtitles=subtitle.ass" -c:v libx264 output.mp4
+
+# 带样式的 ASS 嵌入
+ffmpeg -i input.mp4 -vf "ass=subtitle.ass" output.mp4
+
+# GPU 加速 (NVIDIA)
+ffmpeg -hwaccel cuda -i input.mp4 -vf "subtitles=subtitle.ass" -c:v h264_nvenc output.mp4
+```
+
+**测试步骤:**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 上传视频文件 | - |
+| 2 | 勾选「硬嵌入字幕到视频」 | 选项启用 |
+| 3 | 选择质量:「高」 | - |
+| 4 | 开始处理 | 进度显示「嵌入字幕中...」(85%阶段) |
+| 5 | 等待完成 | 生成 `_subtitled.mp4` 文件 |
+| 6 | 下载并播放输出视频 | 字幕直接显示在视频上 |
+
+**质量预设对比:**
+
+| 预设 | CRF 值 | 预期文件大小 | 适用场景 |
+|------|--------|--------------|----------|
+| 低质量 | 28 | ~50% 原大小 | 预览、网络传输 |
+| 中等 | 23 | ~70% 原大小 | 平衡选择 |
+| 高质量 | 18 | ~90% 原大小 | 专业制作 |
+| 无损 | 0 | 很大 | 归档保留 |
+
+**检查点:**
+- [ ] 嵌入阶段正确显示
+- [ ] 输出视频文件存在且可播放
+- [ ] 字幕永久显示在视频上
+- [ ] 音视频同步正常
+- [ ] 无明显画质损失 (高质量预设)
+
+---
+
+### TC-EMBED-002: 双语字幕嵌入
+
+**测试目标:** 验证双语字幕在嵌入视频中的正确显示
+
+**测试步骤:**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 上传中文视频 | - |
+| 2 | 勾选「中英双语字幕」+「硬嵌入」 | 两个选项都勾选 |
+| 3 | 开始处理 | - |
+| 4 | 检查字幕文件生成 | 生成双语 ASS 字幕 |
+| 5 | 检查嵌入过程 | 使用双语字幕嵌入 |
+| 6 | 播放输出视频 | 显示两行字幕 (中文在上,英文在下) |
+
+**双语字幕 ASS 格式:**
+```
+Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,你好世界\NHello World
+```
+- `\N` = ASS 格式中的换行符
+- 中文在上,英文在下 (或根据设置)
+
+**检查点:**
+- [ ] 双语字幕正确生成
+- [ ] 两行字幕都显示在视频中
+- [ ] 字体大小比例合适 (英文可稍小)
+- [ ] 对齐方式正确
+
+---
+
+### TC-EMBED-008: 错误处理测试
+
+**测试场景 1: FFmpeg 未安装**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 临时移除 FFmpeg 或修改 PATH | - |
+| 2 | 尝试嵌入字幕 | 处理失败 |
+| 3 | 检查错误信息 | 明确提示「FFmpeg 未安装」 |
+| 4 | 检查日志 | 详细错误信息记录 |
+
+**测试场景 2: 字幕文件不存在**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | (模拟) 字幕文件生成失败 | - |
+| 2 | 尝试嵌入 | 检测到缺少字幕文件 |
+| 3 | 错误提示 | 明确但友好的错误信息 |
+
+**关键代码 (`subtitle_embedder.py`):**
+```python
+class SubtitleEmbedder:
+ def __init__(self):
+ # 检查 FFmpeg
+ self.ffmpeg_path = shutil.which('ffmpeg')
+ if not self.ffmpeg_path:
+ raise RuntimeError("FFmpeg not found. Please install FFmpeg.")
+
+ def embed_subtitles(self, ...):
+ # 执行嵌入并捕获错误
+ try:
+ result = subprocess.run(cmd, ...)
+ except Exception as e:
+ return {'success': False, 'error': str(e)}
+```
+
+**检查点:**
+- [ ] FFmpeg 缺失时明确提示
+- [ ] 嵌入失败时任务状态正确更新
+- [ ] 错误信息不暴露敏感信息
+- [ ] 可重新尝试嵌入
+
+---
+
+## 7. 测试批量处理与 WebSocket
+
+### 7.1 测试用例总览
+
+| 测试ID | 测试项 | 优先级 | 测试类型 |
+|--------|--------|--------|----------|
+| TC-BATCH-001 | 多文件批量上传 | 高 | 功能测试 |
+| TC-BATCH-002 | 批量处理进度显示 | 高 | UI 测试 |
+| TC-BATCH-003 | 批量处理结果统计 | 高 | 功能测试 |
+| TC-WS-001 | WebSocket 连接建立 | 高 | 功能测试 |
+| TC-WS-002 | 实时进度推送 | 高 | 功能测试 |
+| TC-WS-003 | 任务状态变更通知 | 高 | 功能测试 |
+| TC-WS-004 | 日志实时推送 | 中 | 功能测试 |
+| TC-WS-005 | 连接断开与重连 | 中 | 稳定性测试 |
+| TC-WS-006 | 多任务广播 | 中 | 功能测试 |
+| TC-LOG-001 | 日志输出完整性 | 中 | 功能测试 |
+| TC-LOG-002 | 日志级别区分 | 中 | 功能测试 |
+
+---
+
+### TC-BATCH-001: 多文件批量上传
+
+**测试目标:** 验证批量上传页面的多文件处理能力
+
+**测试页面:** `http://localhost:8000/batch`
+
+**批量上传 API (`web/server.py:190-258`):**
+```python
+@app.post("/api/upload/batch")
+async def upload_batch(files: List[UploadFile] = File(...)):
+ # 遍历处理每个文件
+ for file in files:
+ # 检查格式
+ if not settings.is_supported_format(file_ext):
+ errors.append(...)
+ continue
+
+ # 保存文件
+ # ...
+
+ # 创建批量任务
+ task = task_manager.create_task(
+ input_files=[f["path"] for f in saved_files],
+ metadata={'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, # 部分失败的文件
+ }
+```
+
+**测试步骤:**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 打开批量处理页面 | 显示多文件上传区域 |
+| 2 | 选择 3-5 个有效视频 | 文件列表显示所有选中文件 |
+| 3 | 包含 1 个无效文件 (.txt) | 无效文件被标记或过滤 |
+| 4 | 点击「上传」 | 显示上传进度 |
+| 5 | 检查 API 响应 | `success: true`, `errors` 包含无效文件信息 |
+| 6 | 检查任务元数据 | `is_batch: true`, `total_files: 有效数量` |
+
+**边界测试:**
+- 最大文件数限制? (检查配置)
+- 同名文件处理
+- 不同格式混合上传
+
+**检查点:**
+- [ ] 多文件同时选择支持
+- [ ] 无效文件正确过滤并提示
+- [ ] 批量任务正确创建
+- [ ] 部分失败不影响其他文件
+
+---
+
+### TC-WS-001 至 TC-WS-004: WebSocket 实时通信
+
+**WebSocket 架构 (`web/websocket_manager.py`):**
+
+```
+客户端 (浏览器) 服务器 (FastAPI)
+ | |
+ | 1. 连接 /ws/{task_id} |
+ | -------------------------------> |
+ | |
+ | 2. 接收初始状态 |
+ | <------------------------------- |
+ | {type: 'task_progress', ...}|
+ | |
+ | 3. 处理中持续接收 |
+ | <------------------------------- |
+ | {type: 'task_log', ...} |
+ | {type: 'task_progress', ...}|
+ | |
+ | 4. 完成/失败时接收 |
+ | <------------------------------- |
+ | {type: 'task_status', ...} |
+ | |
+```
+
+**支持的连接类型:**
+1. `ws://localhost:8000/ws/{task_id}` - 单任务专属连接
+2. `ws://localhost:8000/ws` - 广播连接 (所有任务列表)
+
+**消息类型 (`web/websocket_manager.py`):**
+
+| 消息类型 | 用途 | 触发时机 |
+|----------|------|----------|
+| `task_progress` | 进度更新 | 阶段变化、百分比更新 |
+| `task_status` | 状态变更 | 完成/失败/取消 |
+| `task_log` | 单条日志 | 每次 add_log |
+| `task_list` | 任务列表广播 | 定时或任务变化 |
+| `pong` | 心跳响应 | 收到 ping |
+
+**关键代码 - 客户端 (`web/static/js/app.js:161-201`):**
+```javascript
+connectWebSocket() {
+ const wsUrl = `ws://${window.location.host}/ws/${this.taskId}`;
+ this.ws = new WebSocket(wsUrl);
+
+ this.ws.onmessage = (event) => {
+ const message = JSON.parse(event.data);
+ this.handleWebSocketMessage(message);
+ };
+}
+
+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;
+ }
+}
+```
+
+**关键代码 - 服务端 (`web/websocket_manager.py`):**
+```python
+class WebSocketManager:
+ async def connect(self, websocket: WebSocket, task_id: str):
+ await websocket.accept()
+ # 注册到连接池
+
+ async def send_task_progress(self, task: ProcessingTask):
+ # 构建进度消息
+ message = WSMessage(
+ type="task_progress",
+ task_id=task.task_id,
+ data={
+ "status": task.status.value,
+ "progress": {
+ "stage": task.progress.current_stage.value,
+ "stage_name": task.progress.stage_name,
+ "progress": task.progress.progress_percent,
+ "message": task.progress.current_message,
+ "logs": task.progress.recent_logs,
+ },
+ "duration": task.duration,
+ }
+ )
+ # 广播给该任务的所有连接
+```
+
+**TC-WS-002: 实时进度推送 测试步骤:**
+
+| 步骤 | 操作 | WebSocket 消息 | UI 更新 |
+|------|------|----------------|----------|
+| 1 | 开始处理 | - | 显示进度条 0% |
+| 2 | 等待音频提取 | `task_progress` (stage: extracting_audio) | 阶段:「提取音频」, ~5-10% |
+| 3 | 等待语音识别 | `task_progress` (stage: recognizing_speech) | 阶段:「语音识别」, ~10-60% |
+| 4 | 如有翻译 | `task_progress` (stage: translating) | 阶段:「翻译中」, ~60-70% |
+| 5 | 导出阶段 | `task_progress` (stage: exporting) | 阶段:「导出字幕」, ~70-85% |
+| 6 | 如有嵌入 | `task_progress` (stage: embedding) | 阶段:「嵌入字幕」, ~85-95% |
+| 7 | 完成 | `task_status` (status: completed) | 进度 100%, 显示结果 |
+
+**进度数据结构:**
+```json
+{
+ "type": "task_progress",
+ "task_id": "uuid",
+ "data": {
+ "status": "processing",
+ "progress": {
+ "stage": "recognizing_speech",
+ "stage_name": "语音识别",
+ "progress": 35.5,
+ "message": "正在识别第 10 段...",
+ "logs": ["日志1", "日志2"],
+ "total_files": 3,
+ "processed_files": 1
+ },
+ "duration": 125.5
+ }
+}
+```
+
+**检查点:**
+- [ ] WebSocket 连接成功建立
+- [ ] 各阶段进度正确推送
+- [ ] 进度百分比平滑更新
+- [ ] 阶段名称正确显示
+- [ ] 完成时状态正确变更
+
+---
+
+### TC-WS-005: 连接断开与重连
+
+**测试场景:**
+
+| 场景 | 操作 | 预期行为 |
+|------|------|----------|
+| 页面刷新 | F5 刷新 | 连接断开,重新加载时重连 |
+| 网络中断 | (模拟) 断开网络 | 连接关闭,显示离线状态 |
+| 网络恢复 | (模拟) 恢复网络 | 尝试重连 (如实现) |
+| 服务器重启 | 重启服务端 | 连接关闭,页面显示错误 |
+
+**当前实现分析 (`web/static/js/app.js:180-186`):**
+```javascript
+this.ws.onclose = () => {
+ console.log('WebSocket disconnected');
+ // 注意: 当前版本没有自动重连逻辑
+};
+```
+
+**建议的重连机制 (待实现):**
+```javascript
+// 建议增强: 指数退避重连
+connectWebSocket() {
+ const ws = new WebSocket(wsUrl);
+
+ ws.onclose = () => {
+ if (!this.manualClose && this.reconnectAttempts < 5) {
+ setTimeout(() => {
+ this.reconnectAttempts++;
+ this.connectWebSocket();
+ }, 1000 * Math.pow(2, this.reconnectAttempts));
+ }
+ };
+}
+```
+
+**检查点:**
+- [ ] 连接断开时正确检测
+- [ ] 页面刷新后重新获取任务状态
+- [ ] 服务器重启后任务状态可恢复
+- [ ] 无内存泄漏
+
+---
+
+### TC-LOG-001: 日志输出完整性
+
+**日志记录位置 (`web/tasks.py`):**
+```python
+class ProcessingTask:
+ def add_log(self, message: str, level: str = "INFO"):
+ timestamp = datetime.now().isoformat()
+ log_entry = {
+ "timestamp": timestamp,
+ "level": level,
+ "message": message
+ }
+ self.progress.logs.append(log_entry)
+ self.progress.recent_logs = self.progress.logs[-10:] # 最近10条
+```
+
+**日志级别:**
+- `INFO` - 一般信息
+- `WARNING` - 警告
+- `ERROR` - 错误
+
+**测试步骤:**
+
+| 步骤 | 操作 | 日志检查点 |
+|------|------|------------|
+| 1 | 开始处理 | 「开始处理文件: xxx」 |
+| 2 | 音频提取 | 「提取音频: xxx」 |
+| 3 | 模型加载 | 「加载 Whisper 模型: base」 |
+| 4 | 语音识别 | 「开始语音识别 (语言: zh)」 |
+| 5 | 语言检测 | 「检测到语言: zh」 |
+| 6 | 双语翻译 | 「开始生成双语字幕...」 |
+| 7 | 导出 | 「导出 SRT: /path/to/file.srt」 |
+| 8 | 嵌入 | 「嵌入字幕到视频...」 |
+| 9 | 完成 | 「处理流程完成」 |
+
+**日志 UI 显示 (`web/static/js/app.js:232-249`):**
+```javascript
+if (progress.logs && progress.logs.length > 0) {
+ const lastLog = progress.logs[progress.logs.length - 1];
+ // 添加到日志容器
+ const p = document.createElement('p');
+ p.textContent = lastLog;
+ logContainer.appendChild(p);
+ logContainer.scrollTop = logContainer.scrollHeight; // 自动滚动
+}
+```
+
+**检查点:**
+- [ ] 所有关键阶段都有日志记录
+- [ ] 日志实时显示在 UI 中
+- [ ] 日志面板自动滚动到底部
+- [ ] 错误日志级别正确区分
+
+---
+
+## 8. 测试接口稳定性
+
+### 8.1 测试用例总览
+
+| 测试ID | 测试项 | 优先级 | 测试类型 |
+|--------|--------|--------|----------|
+| TC-API-001 | REST API 基本调用 | 高 | 功能测试 |
+| TC-API-002 | 并发请求处理 | 中 | 性能测试 |
+| TC-API-003 | 任务取消功能 | 高 | 功能测试 |
+| TC-API-004 | 任务删除功能 | 中 | 功能测试 |
+| TC-API-005 | 无效参数处理 | 高 | 异常测试 |
+| TC-API-006 | 不存在的任务访问 | 高 | 异常测试 |
+| TC-CRASH-001 | 异常输入崩溃测试 | 高 | 稳定性测试 |
+| TC-CRASH-002 | 内存泄漏检查 | 中 | 性能测试 |
+| TC-CRASH-003 | 长时间运行稳定性 | 中 | 稳定性测试 |
+| TC-UI-001 | 前端错误提示 | 高 | UI 测试 |
+| TC-UI-002 | 加载状态处理 | 中 | UI 测试 |
+
+---
+
+### TC-API-001: REST API 完整测试
+
+**API 列表 (`web/server.py`):**
+
+| 方法 | 路径 | 功能 |
+|------|------|------|
+| 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` | 保存编辑 |
+
+**API 测试脚本示例 (使用 httpx):**
+
+```python
+import httpx
+import json
+
+BASE_URL = "http://localhost:8000"
+
+def test_api_config():
+ """测试配置接口"""
+ response = httpx.get(f"{BASE_URL}/api/config")
+ assert response.status_code == 200
+ data = response.json()
+ assert "models" in data
+ assert "languages" in data
+ assert "max_file_size" in data
+ print("✓ /api/config 测试通过")
+ return data
+
+def test_file_upload():
+ """测试文件上传"""
+ files = {"file": ("test.mp4", b"fake video content", "video/mp4")}
+ response = httpx.post(f"{BASE_URL}/api/upload", files=files)
+ # 注意: 假文件可能被拒绝,需要真实测试文件
+ print(f"上传响应: {response.status_code}")
+ return response.json() if response.status_code == 200 else None
+
+def test_task_crud():
+ """测试任务 CRUD"""
+ # 1. 获取任务列表
+ response = httpx.get(f"{BASE_URL}/api/tasks")
+ assert response.status_code == 200
+ tasks = response.json()
+ print(f"任务数量: {tasks['total']}")
+
+ if tasks['total'] > 0:
+ task_id = tasks['tasks'][0]['task_id']
+
+ # 2. 获取单个任务
+ response = httpx.get(f"{BASE_URL}/api/tasks/{task_id}")
+ assert response.status_code == 200
+
+ # 3. 如果任务正在运行,测试取消
+ # if tasks['tasks'][0]['status'] == 'processing':
+ # response = httpx.post(f"{BASE_URL}/api/tasks/{task_id}/cancel")
+ # assert response.status_code == 200
+
+def test_invalid_task():
+ """测试不存在的任务"""
+ invalid_id = "00000000-0000-0000-0000-000000000000"
+
+ # GET 不存在的任务
+ response = httpx.get(f"{BASE_URL}/api/tasks/{invalid_id}")
+ assert response.status_code == 404
+ print("✓ 404 测试通过")
+
+ # 取消不存在的任务
+ response = httpx.post(f"{BASE_URL}/api/tasks/{invalid_id}/cancel")
+ assert response.status_code == 404
+
+if __name__ == "__main__":
+ test_api_config()
+ test_task_crud()
+ test_invalid_task()
+ print("\n所有 API 测试完成!")
+```
+
+**使用 Swagger UI 测试:**
+1. 访问 `http://localhost:8000/docs`
+2. 点击「Try it out」
+3. 填写参数,点击「Execute」
+4. 查看响应结果
+
+**检查点:**
+- [ ] 所有 API 端点响应正确
+- [ ] 成功返回 200/201
+- [ ] 不存在资源返回 404
+- [ ] 无效参数返回 400
+- [ ] 服务器错误返回 500 (应尽量避免)
+
+---
+
+### TC-API-003: 任务取消功能
+
+**测试目标:** 验证正在运行的任务可以被正确取消
+
+**测试步骤:**
+
+| 步骤 | 操作 | 预期结果 |
+|------|------|----------|
+| 1 | 上传视频并开始处理 | 任务进入 processing 状态 |
+| 2 | 快速点击「取消」按钮 | 取消请求发送 |
+| 3 | 检查任务状态 | 变为 cancelled |
+| 4 | 检查日志 | 显示「任务被取消,停止处理」 |
+| 5 | 检查资源清理 | 临时文件正确处理 |
+
+**关键代码 (`web/tasks.py`):**
+```python
+class ProcessingTask:
+ @property
+ def is_cancelled(self) -> bool:
+ return self.status == TaskStatus.CANCELLED
+
+ def cancel(self):
+ if self.is_running:
+ self.status = TaskStatus.CANCELLED
+ self.add_log("任务被用户取消", "WARNING")
+```
+
+**处理器中的取消检查 (`web/processor.py:400-403`):**
+```python
+for i, input_file in enumerate(input_files):
+ if task.is_cancelled:
+ task.add_log("任务被取消,停止处理", "INFO")
+ break
+ # 继续处理...
+```
+
+**检查点:**
+- [ ] 取消按钮在处理中可用
+- [ ] 取消后任务状态正确更新
+- [ ] 处理循环正确检测取消标志
+- [ ] 取消后可重新处理
+
+---
+
+### TC-API-005: 无效参数处理
+
+**测试矩阵:**
+
+| 接口 | 无效参数 | 预期响应 |
+|------|----------|----------|
+| `POST /api/process/{task_id}` | 不存在的 task_id | 404 Not Found |
+| `POST /api/process/{task_id}` | 无效的 model 名称 | 422 Validation Error |
+| `POST /api/process/{task_id}` | 任务已在运行 | 400 Bad Request |
+| `GET /api/download/{task_id}/{format}` | 无效的 format | 404 Not Found |
+| `GET /api/download/{task_id}/{format}` | 任务未完成 | 400 Bad Request |
+| `POST /api/upload` | 超过大小限制 | 400 Bad Request |
+| `POST /api/upload` | 不支持的格式 | 400 Bad Request |
+
+**关键代码 - 参数验证 (`web/server.py:261-268`):**
+```python
+@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="任务正在运行中")
+```
+
+---
+
+### TC-UI-001: 前端错误提示
+
+**测试目标:** 验证前端对各种错误情况的友好提示
+
+**测试场景:**
+
+| 场景 | 操作 | 预期 UI 行为 |
+|------|------|--------------|
+| 网络错误 | 服务器未启动时点击上传 | 显示「网络连接失败,请稍后重试」 |
+| 上传失败 | 上传超大文件 | 显示红色通知:「文件过大,最大允许 500MB」 |
+| 处理失败 | 处理过程中出错 | 显示错误通知,任务状态标红 |
+| 表单验证 | 未选择文件点击「开始处理」 | 提示「请先选择文件」 |
+| 编辑保存失败 | 保存时网络中断 | 显示错误,保留编辑内容 |
+
+**关键代码 - 通知系统 (`web/static/js/app.js:595-633`):**
+```javascript
+showNotification(message, type = 'info') {
+ // type: info / success / error / warning
+ const icons = {
+ 'info': 'ri-information-line',
+ 'success': 'ri-checkbox-circle-line',
+ 'error': 'ri-error-warning-line',
+ 'warning': 'ri-alert-line'
+ };
+ // 显示 3 秒后自动消失
+}
+```
+
+**检查点:**
+- [ ] 所有错误情况都有友好提示
+- [ ] 错误信息不包含技术堆栈
+- [ ] 通知位置明显 (通常右上角)
+- [ ] 不同类型使用不同颜色/图标
+
+---
+
+### TC-UI-002: 加载状态处理
+
+**测试目标:** 验证各种加载和等待状态的正确显示
+
+**测试场景:**
+
+| 场景 | 操作 | 预期 UI 行为 |
+|------|------|--------------|
+| 文件上传中 | 点击「开始处理」后 | 按钮显示「上传中...」+ 加载动画 |
+| 处理中 | 语音识别阶段 | 进度条更新,阶段名称显示 |
+| 取消中 | 点击「取消」后 | 按钮禁用,显示「取消中...」 |
+| 保存中 | 编辑器点击「保存」 | 按钮显示加载状态 |
+| 导出中 | 点击「导出」 | 模态框显示进度 |
+
+**关键代码 - 按钮状态 (`web/static/js/app.js:98-130`):**
+```javascript
+async startProcessing() {
+ startButton.disabled = true;
+ startButton.classList.add('button-loading');
+ startButton.textContent = '上传中...';
+ // ... 处理完成后恢复
+}
+```
+
+**检查点:**
+- [ ] 所有异步操作都有加载指示
+- [ ] 加载期间防止重复点击
+- [ ] 操作完成/失败后正确恢复状态
+- [ ] 进度条平滑动画
+
+---
+
+## 9. 测试结果记录模板
+
+### 9.1 测试执行摘要
+
+| 项目 | 内容 |
+|------|------|
+| 测试日期 | YYYY-MM-DD |
+| 测试人员 | xxx |
+| 环境 | Windows / Python 3.10 / FastAPI |
+| 服务器状态 | 本地: http://localhost:8000 |
+| 测试文件位置 | `test_files/` |
+
+---
+
+### 9.2 模块 1: 文件上传测试结果
+
+| 测试ID | 测试项 | 结果 | 备注/失败原因 |
+|--------|--------|------|---------------|
+| TC-UPLOAD-001 | 有效 MP4 上传 | ☐ 通过 / ☐ 失败 | |
+| TC-UPLOAD-002 | 有效 MP3 上传 | ☐ 通过 / ☐ 失败 | |
+| TC-UPLOAD-003 | 有效 WAV 上传 | ☐ 通过 / ☐ 失败 | |
+| TC-UPLOAD-004 | 有效 MOV 上传 | ☐ 通过 / ☐ 失败 | |
+| TC-UPLOAD-005 | 空文件上传 | ☐ 通过 / ☐ 失败 | |
+| TC-UPLOAD-006 | 超大文件上传 | ☐ 通过 / ☐ 失败 | |
+| TC-UPLOAD-007 | 无效格式上传 | ☐ 通过 / ☐ 失败 | |
+| TC-UPLOAD-008 | 损坏文件上传 | ☐ 通过 / ☐ 失败 | |
+| TC-UPLOAD-009 | 拖拽上传 | ☐ 通过 / ☐ 失败 | |
+| TC-UPLOAD-010 | 连续多次上传 | ☐ 通过 / ☐ 失败 | |
+
+**模块 1 统计:**
+- 通过: ___ / 10
+- 失败: ___ / 10
+- 阻塞问题: ___
+
+---
+
+### 9.3 模块 2: 字幕生成测试结果
+
+| 测试ID | 测试项 | 结果 | 备注/失败原因 |
+|--------|--------|------|---------------|
+| TC-GEN-001 | 单语中文生成 | ☐ 通过 / ☐ 失败 | |
+| TC-GEN-002 | 单语英文生成 | ☐ 通过 / ☐ 失败 | |
+| TC-GEN-003 | 自动语言检测 | ☐ 通过 / ☐ 失败 | |
+| TC-GEN-004 | 双语字幕生成 | ☐ 通过 / ☐ 失败 | |
+| TC-GEN-005 | 不同模型对比 | ☐ 通过 / ☐ 失败 | |
+| TC-GEN-006 | 断句准确性 | ☐ 通过 / ☐ 失败 | |
+| TC-GEN-007 | 时间轴准确性 | ☐ 通过 / ☐ 失败 | |
+| TC-GEN-008 | 静音片段处理 | ☐ 通过 / ☐ 失败 | |
+| TC-GEN-009 | 长音频处理 | ☐ 通过 / ☐ 失败 | |
+| TC-GEN-010 | 进度显示完整 | ☐ 通过 / ☐ 失败 | |
+
+**模块 2 统计:**
+- 通过: ___ / 10
+- 失败: ___ / 10
+
+---
+
+### 9.4 模块 3: 字幕编辑测试结果
+
+| 测试ID | 测试项 | 结果 | 备注/失败原因 |
+|--------|--------|------|---------------|
+| TC-EDIT-001 | 文本修改 | ☐ 通过 / ☐ 失败 | |
+| TC-EDIT-002 | 时间轴调整 | ☐ 通过 / ☐ 失败 | |
+| TC-EDIT-003 | 字幕合并 | ☐ 通过 / ☐ 失败 | |
+| TC-EDIT-004 | 字幕拆分 | ☐ 通过 / ☐ 失败 | |
+| TC-EDIT-005 | 字幕删除 | ☐ 通过 / ☐ 失败 | |
+| TC-EDIT-006 | 撤销/重做 | ☐ 通过 / ☐ 失败 | |
+| TC-EDIT-007 | 时间批量移位 | ☐ 通过 / ☐ 失败 | |
+| TC-EDIT-008 | 实时样式预览 | ☐ 通过 / ☐ 失败 | |
+| TC-EDIT-009 | 字幕搜索 | ☐ 通过 / ☐ 失败 | |
+| TC-EDIT-010 | 保存与重载 | ☐ 通过 / ☐ 失败 | |
+
+**模块 3 统计:**
+- 通过: ___ / 10
+- 失败: ___ / 10
+
+---
+
+### 9.5 模块 4: 导出下载测试结果
+
+| 测试ID | 测试项 | 结果 | 备注/失败原因 |
+|--------|--------|------|---------------|
+| TC-EXPORT-001 | SRT 格式导出 | ☐ 通过 / ☐ 失败 | |
+| TC-EXPORT-002 | ASS 格式导出 | ☐ 通过 / ☐ 失败 | |
+| TC-EXPORT-003 | VTT 格式导出 | ☐ 通过 / ☐ 失败 | |
+| TC-EXPORT-004 | 自定义字体样式 | ☐ 通过 / ☐ 失败 | |
+| TC-EXPORT-005 | 字体颜色设置 | ☐ 通过 / ☐ 失败 | |
+| TC-EXPORT-006 | 字幕位置设置 | ☐ 通过 / ☐ 失败 | |
+| TC-EXPORT-007 | 描边样式设置 | ☐ 通过 / ☐ 失败 | |
+| TC-EXPORT-008 | 多格式同时导出 | ☐ 通过 / ☐ 失败 | |
+| TC-EXPORT-009 | 编辑后重新导出 | ☐ 通过 / ☐ 失败 | |
+| TC-EXPORT-010 | 下载文件名 | ☐ 通过 / ☐ 失败 | |
+
+**模块 4 统计:**
+- 通过: ___ / 10
+- 失败: ___ / 10
+
+---
+
+### 9.6 模块 5: 字幕硬嵌入测试结果
+
+| 测试ID | 测试项 | 结果 | 备注/失败原因 |
+|--------|--------|------|---------------|
+| TC-EMBED-001 | 基本字幕嵌入 | ☐ 通过 / ☐ 失败 | |
+| TC-EMBED-002 | 双语字幕嵌入 | ☐ 通过 / ☐ 失败 | |
+| TC-EMBED-003 | 自定义样式嵌入 | ☐ 通过 / ☐ 失败 | |
+| TC-EMBED-004 | GPU 加速嵌入 | ☐ 通过 / ☐ 失败 | |
+| TC-EMBED-005 | 不同质量预设 | ☐ 通过 / ☐ 失败 | |
+| TC-EMBED-006 | 长视频嵌入 | ☐ 通过 / ☐ 失败 | |
+| TC-EMBED-007 | 输出视频验证 | ☐ 通过 / ☐ 失败 | |
+| TC-EMBED-008 | FFmpeg 错误处理 | ☐ 通过 / ☐ 失败 | |
+
+**模块 5 统计:**
+- 通过: ___ / 8
+- 失败: ___ / 8
+
+---
+
+### 9.7 模块 6: 批量处理与 WebSocket 测试结果
+
+| 测试ID | 测试项 | 结果 | 备注/失败原因 |
+|--------|--------|------|---------------|
+| TC-BATCH-001 | 多文件批量上传 | ☐ 通过 / ☐ 失败 | |
+| TC-BATCH-002 | 批量进度显示 | ☐ 通过 / ☐ 失败 | |
+| TC-BATCH-003 | 批量结果统计 | ☐ 通过 / ☐ 失败 | |
+| TC-WS-001 | WebSocket 连接 | ☐ 通过 / ☐ 失败 | |
+| TC-WS-002 | 实时进度推送 | ☐ 通过 / ☐ 失败 | |
+| TC-WS-003 | 状态变更通知 | ☐ 通过 / ☐ 失败 | |
+| TC-WS-004 | 日志实时推送 | ☐ 通过 / ☐ 失败 | |
+| TC-WS-005 | 断开与重连 | ☐ 通过 / ☐ 失败 | |
+| TC-WS-006 | 多任务广播 | ☐ 通过 / ☐ 失败 | |
+| TC-LOG-001 | 日志输出完整 | ☐ 通过 / ☐ 失败 | |
+| TC-LOG-002 | 日志级别区分 | ☐ 通过 / ☐ 失败 | |
+
+**模块 6 统计:**
+- 通过: ___ / 11
+- 失败: ___ / 11
+
+---
+
+### 9.8 模块 7: 接口稳定性测试结果
+
+| 测试ID | 测试项 | 结果 | 备注/失败原因 |
+|--------|--------|------|---------------|
+| TC-API-001 | REST API 基本调用 | ☐ 通过 / ☐ 失败 | |
+| TC-API-002 | 并发请求处理 | ☐ 通过 / ☐ 失败 | |
+| TC-API-003 | 任务取消功能 | ☐ 通过 / ☐ 失败 | |
+| TC-API-004 | 任务删除功能 | ☐ 通过 / ☐ 失败 | |
+| TC-API-005 | 无效参数处理 | ☐ 通过 / ☐ 失败 | |
+| TC-API-006 | 不存在任务访问 | ☐ 通过 / ☐ 失败 | |
+| TC-CRASH-001 | 异常输入崩溃 | ☐ 通过 / ☐ 失败 | |
+| TC-CRASH-002 | 内存泄漏检查 | ☐ 通过 / ☐ 失败 | |
+| TC-CRASH-003 | 长时间运行稳定 | ☐ 通过 / ☐ 失败 | |
+| TC-UI-001 | 前端错误提示 | ☐ 通过 / ☐ 失败 | |
+| TC-UI-002 | 加载状态处理 | ☐ 通过 / ☐ 失败 | |
+
+**模块 7 统计:**
+- 通过: ___ / 11
+- 失败: ___ / 11
+
+---
+
+### 9.9 总体测试统计
+
+| 模块 | 用例数 | 通过 | 失败 | 通过率 |
+|------|--------|------|------|--------|
+| 1. 文件上传 | 10 | | | ___% |
+| 2. 字幕生成 | 10 | | | ___% |
+| 3. 字幕编辑 | 10 | | | ___% |
+| 4. 导出下载 | 10 | | | ___% |
+| 5. 字幕硬嵌入 | 8 | | | ___% |
+| 6. 批量/WebSocket | 11 | | | ___% |
+| 7. 接口稳定性 | 11 | | | ___% |
+| **总计** | **70** | | | ___% |
+
+---
+
+### 9.10 失败项详细记录
+
+#### 失败项 1:
+- **测试ID:** TC-XXXX-XXX
+- **测试项:** xxx
+- **失败现象:** xxx
+- **错误信息/日志:**
+```
+粘贴错误日志或截图描述
+```
+- **可能原因分析:**
+ 1. xxx
+ 2. xxx
+- **建议修复方案:**
+ - 方案1: xxx
+ - 方案2: xxx
+
+---
+
+#### 失败项 2:
+- **测试ID:** TC-XXXX-XXX
+- **测试项:** xxx
+- **失败现象:** xxx
+- **错误信息/日志:**
+```
+粘贴错误日志
+```
+- **可能原因分析:** xxx
+- **建议修复方案:** xxx
+
+---
+
+### 9.11 测试结论与建议
+
+#### 总体评价:
+☐ 可发布 / ☐ 需修复后发布 / ☐ 阻塞问题较多
+
+#### 阻塞问题 (必须修复):
+1. xxx
+2. xxx
+
+#### 重要问题 (建议修复):
+1. xxx
+2. xxx
+
+#### 优化建议 (非紧急):
+1. xxx
+2. xxx
+
+#### 测试环境建议:
+1. 建议增加自动化测试脚本
+2. 建议准备标准测试音视频素材库
+3. 建议建立 CI/CD 流水线
+
+---
+
+## 附录
+
+### A. 快速测试命令
+
+```bash
+# 1. 启动服务器
+python run_web.py
+
+# 2. 运行现有模块测试
+python test_web_features.py
+
+# 3. 运行核心模块测试
+python test_modules.py
+
+# 4. API 健康检查
+curl http://localhost:8000/api/config
+
+# 5. 查看 Swagger 文档
+# 浏览器打开: http://localhost:8000/docs
+```
+
+### B. 关键代码位置速查
+
+| 功能模块 | 文件路径 | 关键行 |
+|----------|----------|--------|
+| 上传接口 | `web/server.py:138-187` | `/api/upload` |
+| 处理逻辑 | `web/processor.py:253-355` | `_process_file_sync` |
+| WebSocket | `web/websocket_manager.py` | 全部 |
+| 字幕导出 | `subtitle_exporter.py` | 全部 |
+| 字幕嵌入 | `subtitle_embedder.py` | 全部 |
+| 前端主页 | `web/static/js/app.js` | 全部 |
+| 前端编辑器 | `web/static/js/editor.js` | 全部 |
+
+### C. 测试文件准备脚本 (PowerShell)
+
+```powershell
+# 创建测试目录
+New-Item -ItemType Directory -Force -Path "test_files"
+
+# 创建空文件
+New-Item -ItemType File -Force -Path "test_files\test_empty.mp4"
+
+# 创建无效文本文件
+Set-Content -Path "test_files\test_invalid.txt" -Value "This is not a valid video file"
+
+# 使用 FFmpeg 创建测试视频 (如已安装)
+# ffmpeg -f lavfi -i testsrc=duration=10:size=1280x720:rate=30 -c:v libx264 test_files\test_valid.mp4
+
+Write-Host "测试文件已准备完成" -ForegroundColor Green
+```
+
+---
+
+**文档版本:** v1.0
+**最后更新:** 2026-05-02
\ No newline at end of file
diff --git a/test_report.json b/test_report.json
new file mode 100644
index 0000000..22e8db7
--- /dev/null
+++ b/test_report.json
@@ -0,0 +1,147 @@
+{
+ "summary": {
+ "total": 17,
+ "passed": 15,
+ "failed": 0,
+ "warnings": 2,
+ "timestamp": "2026-05-02T21:43:43.655242"
+ },
+ "results": [
+ {
+ "category": "API接口",
+ "test_name": "配置接口 GET /api/config",
+ "status": "PASS",
+ "details": "返回模型: ['tiny', 'base', 'small', 'medium', 'large', 'large-v2', 'large-v3'], 最大文件: 524288000 bytes",
+ "recommendation": "",
+ "timestamp": "2026-05-02T21:43:41.179270"
+ },
+ {
+ "category": "API接口",
+ "test_name": "任务列表接口 GET /api/tasks",
+ "status": "PASS",
+ "details": "当前任务数: 0",
+ "recommendation": "",
+ "timestamp": "2026-05-02T21:43:41.201936"
+ },
+ {
+ "category": "API接口",
+ "test_name": "不存在任务查询 GET /api/tasks/{invalid}",
+ "status": "PASS",
+ "details": "正确返回 404",
+ "recommendation": "",
+ "timestamp": "2026-05-02T21:43:41.209527"
+ },
+ {
+ "category": "页面加载",
+ "test_name": "主页 GET /",
+ "status": "PASS",
+ "details": "页面大小: 15049 字符",
+ "recommendation": "",
+ "timestamp": "2026-05-02T21:43:41.242741"
+ },
+ {
+ "category": "页面加载",
+ "test_name": "批量处理页面 GET /batch",
+ "status": "PASS",
+ "details": "页面大小: 16480 字符",
+ "recommendation": "",
+ "timestamp": "2026-05-02T21:43:41.254621"
+ },
+ {
+ "category": "页面加载",
+ "test_name": "API文档页面 GET /docs",
+ "status": "PASS",
+ "details": "页面大小: 962 字符",
+ "recommendation": "",
+ "timestamp": "2026-05-02T21:43:41.260519"
+ },
+ {
+ "category": "页面加载",
+ "test_name": "ReDoc文档页面 GET /redoc",
+ "status": "PASS",
+ "details": "页面大小: 914 字符",
+ "recommendation": "",
+ "timestamp": "2026-05-02T21:43:41.266019"
+ },
+ {
+ "category": "文件上传",
+ "test_name": "正常视频文件上传 MP4",
+ "status": "PASS",
+ "details": "任务ID: 22fcce93-36b2-4ddf-8b0e-790c4be99bb3, 文件大小: 45963560 bytes",
+ "recommendation": "",
+ "timestamp": "2026-05-02T21:43:43.585017"
+ },
+ {
+ "category": "文件上传",
+ "test_name": "空文件上传 (0字节)",
+ "status": "WARN",
+ "details": "空文件被接受,任务ID: d301e221-86a6-478f-b642-e1d5feece35e",
+ "recommendation": "建议在上传时添加空文件校验,或在处理阶段给出明确错误提示",
+ "timestamp": "2026-05-02T21:43:43.602199"
+ },
+ {
+ "category": "文件上传",
+ "test_name": "不支持格式上传 (.txt)",
+ "status": "PASS",
+ "details": "正确拒绝不支持的格式: {\"detail\":\"不支持的文件格式: .txt。支持的格式: .mp3, .wav, .m4a, .flac, .aac, .ogg, .wma, .mp4, .mov, .avi, .mkv, .flv, .wmv, .webm, .m4v\"}",
+ "recommendation": "",
+ "timestamp": "2026-05-02T21:43:43.610429"
+ },
+ {
+ "category": "文件上传",
+ "test_name": "伪造格式上传 (文本伪装MP4)",
+ "status": "WARN",
+ "details": "仅验证扩展名,未验证文件内容真实性,任务ID: 23ebb64e-301e-4ab1-bc1d-b4612e587bb2",
+ "recommendation": "当前设计合理 - 内容验证应在处理阶段进行。上传时只验证扩展名是行业标准做法。",
+ "timestamp": "2026-05-02T21:43:43.618855"
+ },
+ {
+ "category": "任务管理",
+ "test_name": "任务查询 GET /api/tasks/22fcce93...",
+ "status": "PASS",
+ "details": "状态: pending, 输入文件数: 1",
+ "recommendation": "",
+ "timestamp": "2026-05-02T21:43:43.625836"
+ },
+ {
+ "category": "任务管理",
+ "test_name": "任务查询 GET /api/tasks/d301e221...",
+ "status": "PASS",
+ "details": "状态: pending, 输入文件数: 1",
+ "recommendation": "",
+ "timestamp": "2026-05-02T21:43:43.632595"
+ },
+ {
+ "category": "任务管理",
+ "test_name": "取消不存在的任务",
+ "status": "PASS",
+ "details": "正确返回 404",
+ "recommendation": "",
+ "timestamp": "2026-05-02T21:43:43.637734"
+ },
+ {
+ "category": "错误处理",
+ "test_name": "不存在路由 404",
+ "status": "PASS",
+ "details": "正确返回 404 状态码",
+ "recommendation": "",
+ "timestamp": "2026-05-02T21:43:43.644688"
+ },
+ {
+ "category": "错误处理",
+ "test_name": "无效HTTP方法 405",
+ "status": "PASS",
+ "details": "正确返回 405 Method Not Allowed",
+ "recommendation": "",
+ "timestamp": "2026-05-02T21:43:43.649726"
+ },
+ {
+ "category": "错误处理",
+ "test_name": "下载不存在任务 404",
+ "status": "PASS",
+ "details": "正确返回 404",
+ "recommendation": "",
+ "timestamp": "2026-05-02T21:43:43.654240"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/test_server_import.py b/test_server_import.py
new file mode 100644
index 0000000..93c23c6
--- /dev/null
+++ b/test_server_import.py
@@ -0,0 +1,81 @@
+# -*- coding: utf-8 -*-
+"""
+测试 Web 服务器导入和基本功能
+"""
+import sys
+from pathlib import Path
+import io
+
+sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
+
+PROJECT_ROOT = Path(__file__).parent
+sys.path.insert(0, str(PROJECT_ROOT))
+sys.path.insert(0, str(PROJECT_ROOT / "web"))
+
+print("=" * 60)
+print("Testing Web server imports...")
+print("=" * 60)
+
+# 1. 测试 FastAPI 核心依赖
+try:
+ from fastapi import FastAPI
+ print("[OK] FastAPI imported successfully")
+except ImportError as e:
+ print(f"[ERROR] FastAPI import failed: {e}")
+ sys.exit(1)
+
+# 2. 测试 uvicorn
+try:
+ import uvicorn
+ print("[OK] uvicorn imported successfully")
+except ImportError as e:
+ print(f"[ERROR] uvicorn import failed: {e}")
+ sys.exit(1)
+
+# 3. 测试项目模块
+print("\nTesting project modules...")
+
+modules_to_test = [
+ ("web.config", "settings"),
+ ("web.tasks", "task_manager"),
+ ("web.websocket_manager", "ws_manager"),
+]
+
+for module_name, _ in modules_to_test:
+ try:
+ __import__(module_name)
+ print(f"[OK] {module_name} imported")
+ except Exception as e:
+ print(f"[ERROR] {module_name} import failed: {e}")
+ import traceback
+ traceback.print_exc()
+
+# 4. 测试 processor (可能需要更多依赖)
+print("\nTesting processor module (may be slow)...")
+try:
+ from web import processor
+ print("[OK] processor imported")
+except Exception as e:
+ print(f"[WARNING] processor import issue: {e}")
+
+# 5. 测试创建 app
+print("\nTesting FastAPI app creation...")
+try:
+ from web.server import app
+ print(f"[OK] FastAPI app created: {app.title} v{app.version}")
+
+ # 打印路由
+ print("\nRegistered routes:")
+ for route in app.routes:
+ if hasattr(route, "methods") and hasattr(route, "path"):
+ methods = list(route.methods) if route.methods else ['*']
+ print(f" {methods} {route.path}")
+
+except Exception as e:
+ print(f"[ERROR] App creation failed: {e}")
+ import traceback
+ traceback.print_exc()
+
+print("\n" + "=" * 60)
+print("Import test completed!")
+print("=" * 60)
diff --git a/test_translator.py b/test_translator.py
new file mode 100644
index 0000000..f24f022
--- /dev/null
+++ b/test_translator.py
@@ -0,0 +1,154 @@
+"""
+测试本地翻译模型
+"""
+import sys
+import os
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+def test_local_translator():
+ print("=" * 60)
+ print("测试本地翻译模型 (LocalModelTranslator)")
+ print("=" * 60)
+
+ try:
+ from subtitle_translator import LocalModelTranslator, detect_text_language
+
+ print("\n1. 检查依赖...")
+ import importlib.util
+ transformers_ok = importlib.util.find_spec("transformers") is not None
+ torch_ok = importlib.util.find_spec("torch") is not None
+ sentencepiece_ok = importlib.util.find_spec("sentencepiece") is not None
+ sacremoses_ok = importlib.util.find_spec("sacremoses") is not None
+
+ print(f" transformers: {'OK' if transformers_ok else 'MISSING'}")
+ print(f" torch: {'OK' if torch_ok else 'MISSING'}")
+ print(f" sentencepiece: {'OK' if sentencepiece_ok else 'MISSING'}")
+ print(f" sacremoses: {'OK' if sacremoses_ok else 'MISSING'}")
+
+ if not all([transformers_ok, torch_ok, sentencepiece_ok, sacremoses_ok]):
+ print("\n [ERROR] 缺少必要依赖")
+ return False
+
+ print("\n2. 初始化翻译器 (zh -> en)...")
+ print(" 注意:首次运行会下载模型 (约 300MB),请耐心等待...")
+
+ translator = LocalModelTranslator(
+ source_language='zh',
+ target_language='en',
+ device='cpu'
+ )
+
+ print(f" 初始化状态: is_initialized={translator.is_initialized}")
+ print(f" 是否真实模型: real_model={translator._real_model}")
+
+ if not translator.is_initialized or not translator._real_model:
+ print("\n [WARNING] 本地模型未加载成功")
+ return False
+
+ print(f"\n3. 测试中文->英文翻译...")
+
+ test_texts_zh = [
+ "你好,世界",
+ "这是一个测试句子",
+ "我爱人工智能",
+ ]
+
+ for text in test_texts_zh:
+ result = translator.translate(text)
+ print(f"\n 原文: {text}")
+ print(f" 译文: {result.translated_text}")
+ print(f" 成功: {result.success}, 后端: {result.backend}")
+
+ print(f"\n4. 测试英文->中文翻译...")
+
+ translator_en_zh = LocalModelTranslator(
+ source_language='en',
+ target_language='zh',
+ device='cpu'
+ )
+
+ test_texts_en = [
+ "Hello, world",
+ "This is a test sentence",
+ "I love artificial intelligence",
+ ]
+
+ for text in test_texts_en:
+ result = translator_en_zh.translate(text)
+ print(f"\n 原文: {text}")
+ print(f" 译文: {result.translated_text}")
+ print(f" 成功: {result.success}, 后端: {result.backend}")
+
+ print("\n" + "=" * 60)
+ print("[PASS] 本地翻译模型测试成功!")
+ print("=" * 60)
+ return True
+
+ except Exception as e:
+ import traceback
+ print(f"\n[ERROR] {e}")
+ traceback.print_exc()
+ return False
+
+
+def test_subtitle_translator_with_local():
+ print("\n" + "=" * 60)
+ print("测试 SubtitleTranslator 集成本地模型")
+ print("=" * 60)
+
+ try:
+ from subtitle_translator import SubtitleTranslator, TranslationBackend, translate_segments_with_default
+
+ print("\n1. 创建 SubtitleTranslator 实例 (使用 AUTO 后端)...")
+
+ translator = SubtitleTranslator(
+ source_language='zh',
+ target_language='en',
+ backend=TranslationBackend.AUTO
+ )
+
+ print(f" 可用后端: {translator.available_backends}")
+ print(f" 是否有真实翻译器: {translator.has_real_translator()}")
+ print(f" 首选后端: {translator.preferred_backend}")
+
+ print("\n2. 测试 translate_segments_with_default...")
+
+ test_segments = [
+ {'id': 0, 'start': 0.0, 'end': 2.0, 'text': '你好,世界'},
+ {'id': 1, 'start': 2.0, 'end': 4.0, 'text': '这是一个测试'},
+ {'id': 2, 'start': 4.0, 'end': 6.0, 'text': 'Hello World'},
+ ]
+
+ translated = translate_segments_with_default(
+ segments=test_segments,
+ source_language='zh',
+ target_language='en',
+ )
+
+ print(f"\n 翻译结果 ({len(translated)} 段):")
+ for seg in translated:
+ if hasattr(seg, 'is_bilingual') and seg.is_bilingual:
+ print(f"\n 段 {seg.id}:")
+ print(f" text_zh: {seg.text_zh}")
+ print(f" text_en: {seg.text_en}")
+ print(f" bilingual: {seg.is_bilingual}")
+
+ print("\n" + "=" * 60)
+ print("[PASS] SubtitleTranslator 集成测试成功!")
+ print("=" * 60)
+ return True
+
+ except Exception as e:
+ import traceback
+ print(f"\n[ERROR] {e}")
+ traceback.print_exc()
+ return False
+
+
+if __name__ == "__main__":
+ success = test_local_translator()
+ if success:
+ test_subtitle_translator_with_local()
diff --git a/test_translator_with_fix.py b/test_translator_with_fix.py
new file mode 100644
index 0000000..9fd5fa6
--- /dev/null
+++ b/test_translator_with_fix.py
@@ -0,0 +1,287 @@
+"""
+测试本地翻译模型(包含SSL证书问题解决方案)
+"""
+import sys
+import os
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+
+def setup_ssl_fix():
+ """设置SSL证书问题的解决方案"""
+ print("=" * 60)
+ print("SSL证书问题解决方案")
+ print("=" * 60)
+
+ print("\n方法1: 禁用SSL验证 (不推荐用于生产环境)")
+ print("设置环境变量: CURL_CA_BUNDLE='' 和 REQUESTS_CA_BUNDLE=''")
+ os.environ['CURL_CA_BUNDLE'] = ''
+ os.environ['REQUESTS_CA_BUNDLE'] = ''
+
+ print("\n方法2: 禁用urllib3警告")
+ import urllib3
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+
+ print("\n方法3: 使用国内镜像 (如果无法访问 huggingface.co)")
+ print("设置环境变量: HF_ENDPOINT=https://hf-mirror.com")
+ # os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com'
+
+ print("\nSSL修复设置完成")
+
+
+def test_local_translator():
+ print("\n" + "=" * 60)
+ print("测试本地翻译模型 (LocalModelTranslator)")
+ print("=" * 60)
+
+ try:
+ from subtitle_translator import LocalModelTranslator, detect_text_language
+
+ print("\n1. 检查依赖...")
+ import importlib.util
+ transformers_ok = importlib.util.find_spec("transformers") is not None
+ torch_ok = importlib.util.find_spec("torch") is not None
+ sentencepiece_ok = importlib.util.find_spec("sentencepiece") is not None
+ sacremoses_ok = importlib.util.find_spec("sacremoses") is not None
+
+ print(f" transformers: {'OK' if transformers_ok else 'MISSING'}")
+ print(f" torch: {'OK' if torch_ok else 'MISSING'}")
+ print(f" sentencepiece: {'OK' if sentencepiece_ok else 'MISSING'}")
+ print(f" sacremoses: {'OK' if sacremoses_ok else 'MISSING'}")
+
+ if not all([transformers_ok, torch_ok, sentencepiece_ok, sacremoses_ok]):
+ print("\n [ERROR] 缺少必要依赖")
+ return False
+
+ print("\n2. 检查是否已有缓存的模型...")
+ from pathlib import Path
+ import os
+
+ cache_dir = Path.home() / ".cache" / "huggingface" / "hub"
+ zh_en_model = "models--Helsinki-NLP--opus-mt-zh-en"
+ en_zh_model = "models--Helsinki-NLP--opus-mt-en-zh"
+
+ has_zh_en = (cache_dir / zh_en_model).exists()
+ has_en_zh = (cache_dir / en_zh_model).exists()
+
+ print(f" 缓存目录: {cache_dir}")
+ print(f" 中文->英文模型: {'已存在' if has_zh_en else '需要下载'}")
+ print(f" 英文->中文模型: {'已存在' if has_en_zh else '需要下载'}")
+
+ if not has_zh_en or not has_en_zh:
+ print("\n" + "!" * 60)
+ print("! 注意:首次运行需要下载模型 (约 300MB/每个)")
+ print("! 如果遇到网络问题,请尝试以下解决方案:")
+ print("!")
+ print("! 方案A: 使用国内镜像")
+ print("! 在代码中添加: os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com'")
+ print("! 或在运行前设置环境变量: set HF_ENDPOINT=https://hf-mirror.com")
+ print("!")
+ print("! 方案B: 手动下载模型")
+ print("! 访问 https://huggingface.co/Helsinki-NLP/opus-mt-zh-en")
+ print("! 下载所有文件并放到: ~/.cache/huggingface/hub/models--Helsinki-NLP--opus-mt-zh-en/")
+ print("!" * 60)
+
+ print("\n3. 尝试初始化翻译器 (zh -> en)...")
+
+ translator = LocalModelTranslator(
+ source_language='zh',
+ target_language='en',
+ device='cpu'
+ )
+
+ print(f" 初始化状态: is_initialized={translator.is_initialized}")
+ print(f" 是否真实模型: real_model={translator._real_model}")
+
+ if translator.is_initialized and translator._real_model:
+ print(f"\n4. 测试中文->英文翻译...")
+
+ test_texts_zh = [
+ "你好,世界",
+ "这是一个测试句子",
+ ]
+
+ for text in test_texts_zh:
+ result = translator.translate(text)
+ print(f"\n 原文: {text}")
+ print(f" 译文: {result.translated_text}")
+ print(f" 成功: {result.success}")
+
+ print("\n" + "=" * 60)
+ print("[PASS] 本地翻译模型测试成功!")
+ print("=" * 60)
+ return True
+ else:
+ print("\n [WARNING] 本地模型未加载成功")
+ print("\n 备用方案:使用改进的DummyTranslator")
+ print(" 双语字幕将显示原文(中文字幕显示两行相同内容)")
+ print(" 这比之前留空的行为更好,用户至少能看到字幕")
+
+ from subtitle_translator import DummyTranslator
+
+ dummy = DummyTranslator(
+ source_language='zh',
+ target_language='en',
+ mode='smart_bilingual'
+ )
+
+ test_texts = [
+ "你好,世界",
+ "Hello World",
+ ]
+
+ print("\n 测试 DummyTranslator (smart_bilingual模式):")
+ for text in test_texts:
+ result = dummy.translate(text)
+ print(f"\n 原文: {text}")
+ print(f" 译文: {result.translated_text}")
+ print(f" 成功: {result.success}")
+
+ print("\n" + "=" * 60)
+ print("[INFO] 依赖已安装,但模型需要下载")
+ print("=" * 60)
+ return True
+
+ except Exception as e:
+ import traceback
+ print(f"\n[ERROR] {e}")
+ traceback.print_exc()
+ return False
+
+
+def test_subtitle_translator_integration():
+ print("\n" + "=" * 60)
+ print("测试 SubtitleTranslator 集成")
+ print("=" * 60)
+
+ try:
+ from subtitle_translator import (
+ SubtitleTranslator,
+ TranslationBackend,
+ translate_segments_with_default
+ )
+
+ print("\n1. 创建 SubtitleTranslator 实例...")
+
+ translator = SubtitleTranslator(
+ source_language='zh',
+ target_language='en',
+ backend=TranslationBackend.AUTO
+ )
+
+ print(f" 可用后端: {translator.available_backends}")
+ print(f" 是否有真实翻译器: {translator.has_real_translator()}")
+
+ print("\n2. 测试 translate_segments_with_default...")
+
+ test_segments = [
+ {'id': 0, 'start': 0.0, 'end': 2.0, 'text': '你好,世界'},
+ {'id': 1, 'start': 2.0, 'end': 4.0, 'text': 'Hello World'},
+ ]
+
+ translated = translate_segments_with_default(
+ segments=test_segments,
+ source_language='zh',
+ target_language='en',
+ )
+
+ print(f"\n 翻译结果 ({len(translated)} 段):")
+ for seg in translated:
+ if hasattr(seg, 'is_bilingual') and seg.is_bilingual:
+ print(f"\n 段 {seg.id}:")
+ print(f" text_zh: '{seg.text_zh}'")
+ print(f" text_en: '{seg.text_en}'")
+ print(f" bilingual: {seg.is_bilingual}")
+
+ print("\n3. 验证字幕导出...")
+ from subtitle_models import SubtitleDocument, SubtitleSegment
+ from subtitle_exporter import SubtitleExporter
+
+ doc = SubtitleDocument(segments=translated)
+ doc.is_bilingual = True
+
+ exporter = SubtitleExporter(doc)
+ srt_content = exporter.generate_srt_content()
+
+ print(f"\n SRT内容预览:")
+ for line in srt_content.split('\n')[:15]:
+ print(f" {line}")
+
+ print("\n" + "=" * 60)
+ print("[PASS] SubtitleTranslator 集成测试成功!")
+ print("=" * 60)
+ return True
+
+ except Exception as e:
+ import traceback
+ print(f"\n[ERROR] {e}")
+ traceback.print_exc()
+ return False
+
+
+def print_solutions():
+ print("\n" + "#" * 60)
+ print("# 完整解决方案")
+ print("#" * 60)
+
+ print("""
+问题:无法从 huggingface.co 下载翻译模型
+原因:SSL证书验证失败或网络连接问题
+
+解决方案:
+
+方案1: 使用国内镜像(推荐)
+================================
+在运行Python前设置环境变量:
+
+Windows:
+ set HF_ENDPOINT=https://hf-mirror.com
+ python your_script.py
+
+或在代码开头添加:
+ import os
+ os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com'
+
+方案2: 禁用SSL验证
+================================
+在代码开头添加:
+
+ import os
+ import urllib3
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+ os.environ['CURL_CA_BUNDLE'] = ''
+ os.environ['REQUESTS_CA_BUNDLE'] = ''
+
+注意:这会降低安全性,仅用于测试环境
+
+方案3: 手动下载模型
+================================
+1. 访问镜像站点: https://hf-mirror.com/Helsinki-NLP/opus-mt-zh-en
+2. 下载所有文件
+3. 放到目录: ~/.cache/huggingface/hub/models--Helsinki-NLP--opus-mt-zh-en/
+
+需要下载的模型:
+- 中文->英文: Helsinki-NLP/opus-mt-zh-en
+- 英文->中文: Helsinki-NLP/opus-mt-en-zh
+
+当前状态:
+================================
+- 依赖已安装: transformers, torch, sentencepiece, sacremoses ✓
+- 模型需要下载或配置镜像
+
+即使没有真实翻译模型,双语字幕功能也能工作:
+- 中文字幕段:中文行显示原文,英文行显示原文(两行相同)
+- 英文字幕段:英文行显示原文,中文行显示原文(两行相同)
+- 这比之前留空的行为更好,用户至少能看到字幕
+""")
+
+
+if __name__ == "__main__":
+ setup_ssl_fix()
+
+ success = test_local_translator()
+ test_subtitle_translator_integration()
+
+ print_solutions()
diff --git a/test_web_features.py b/test_web_features.py
new file mode 100644
index 0000000..0f08c47
--- /dev/null
+++ b/test_web_features.py
@@ -0,0 +1,265 @@
+"""
+Web字幕工具功能验证脚本
+快速测试核心功能是否正常工作
+"""
+
+import sys
+import os
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+def test_subtitle_exporter():
+ """测试字幕导出功能"""
+ print("\n" + "="*60)
+ print("测试 1: 字幕导出功能 (SubtitleExporter)")
+ print("="*60)
+
+ try:
+ from subtitle_models import SubtitleDocument, SubtitleSegment, create_document_from_segments
+ from subtitle_exporter import SubtitleExporter
+
+ test_segments = [
+ {'id': 0, 'start': 0.0, 'end': 2.5, 'text': '你好,世界'},
+ {'id': 1, 'start': 2.5, 'end': 5.0, 'text': '这是一个测试'},
+ {'id': 2, 'start': 5.0, 'end': 7.5, 'text': 'Hello World'},
+ ]
+
+ doc = create_document_from_segments(test_segments)
+ print(f" ✓ 创建字幕文档: {len(doc.segments)} 段")
+
+ exporter = SubtitleExporter(doc)
+
+ srt_content = exporter.generate_srt_content()
+ print(f" ✓ 生成 SRT 内容: {len(srt_content)} 字符")
+
+ vtt_content = exporter.generate_vtt_content()
+ print(f" ✓ 生成 VTT 内容: {len(vtt_content)} 字符")
+
+ ass_content = exporter.generate_ass_content()
+ print(f" ✓ 生成 ASS 内容: {len(ass_content)} 字符")
+
+ temp_dir = PROJECT_ROOT / "test_output"
+ temp_dir.mkdir(exist_ok=True)
+
+ srt_path = exporter.export_to_srt(doc, str(temp_dir / "test.srt"))
+ print(f" ✓ 导出 SRT 文件: {srt_path}")
+
+ vtt_path = exporter.export_to_vtt(doc, str(temp_dir / "test.vtt"))
+ print(f" ✓ 导出 VTT 文件: {vtt_path}")
+
+ from subtitle_exporter import create_styled_ass_style
+ style = create_styled_ass_style()
+ ass_path = exporter.export_to_ass(doc, str(temp_dir / "test.ass"), style)
+ print(f" ✓ 导出 ASS 文件: {ass_path}")
+
+ print("\n 字幕导出测试: [成功] ✓")
+ return True
+
+ except Exception as e:
+ import traceback
+ print(f" ✗ 错误: {e}")
+ print(traceback.format_exc())
+ return False
+
+
+def test_translator_framework():
+ """测试翻译框架"""
+ print("\n" + "="*60)
+ print("测试 2: 翻译框架 (SubtitleTranslator)")
+ print("="*60)
+
+ try:
+ from subtitle_translator import (
+ SubtitleTranslator,
+ DummyTranslator,
+ detect_text_language,
+ get_translator,
+ translate_segments_with_default,
+ )
+
+ test_text_zh = "你好,这是一个测试句子。"
+ test_text_en = "Hello, this is a test sentence."
+
+ lang, conf = detect_text_language(test_text_zh)
+ print(f" ✓ 检测中文: {lang} (置信度: {conf})")
+
+ lang, conf = detect_text_language(test_text_en)
+ print(f" ✓ 检测英文: {lang} (置信度: {conf})")
+
+ dummy = DummyTranslator(source_language='zh', target_language='en', mode="smart_bilingual")
+ result = dummy.translate(test_text_zh)
+ print(f" ✓ 智能双语模式 (中->英): 原文='{test_text_zh[:20]}...'")
+ print(f" 结果: translated_text='{result.translated_text}', success={result.success}")
+
+ test_segments = [
+ {'id': 0, 'start': 0.0, 'end': 2.0, 'text': '你好,世界'},
+ {'id': 1, 'start': 2.0, 'end': 4.0, 'text': 'Hello World'},
+ ]
+
+ translated = translate_segments_with_default(
+ segments=test_segments,
+ source_language='zh',
+ target_language='en',
+ )
+
+ print(f" ✓ 双语字幕生成: {len(translated)} 段")
+ for seg in translated:
+ if hasattr(seg, 'is_bilingual') and seg.is_bilingual:
+ print(f" 第 {seg.id} 段: bilingual={seg.is_bilingual}, text_zh='{seg.text_zh}', text_en='{seg.text_en}'")
+
+ try:
+ import importlib.util
+ transformers_ok = importlib.util.find_spec("transformers") is not None
+ torch_ok = importlib.util.find_spec("torch") is not None
+
+ if transformers_ok and torch_ok:
+ print(f"\n ✓ 本地翻译模型框架已安装 (transformers={transformers_ok}, torch={torch_ok})")
+ print(" 运行时将自动下载 Helsinki-NLP OPUS-MT 模型")
+ else:
+ print(f"\n ⚠ 本地翻译模型框架未安装")
+ print(" 如需本地翻译,请运行: pip install transformers torch sentencepiece sacremoses")
+
+ except Exception as e:
+ print(f" ⚠ 检查本地翻译模型框架时出错: {e}")
+
+ print("\n 翻译框架测试: [成功] ✓")
+ return True
+
+ except Exception as e:
+ import traceback
+ print(f" ✗ 错误: {e}")
+ print(traceback.format_exc())
+ return False
+
+
+def test_ffmpeg_embedder():
+ """测试 FFmpeg 字幕嵌入器"""
+ print("\n" + "="*60)
+ print("测试 3: FFmpeg 字幕嵌入 (SubtitleEmbedder)")
+ print("="*60)
+
+ try:
+ import shutil
+
+ ffmpeg_path = shutil.which('ffmpeg')
+
+ if ffmpeg_path:
+ print(f" ✓ FFmpeg 已安装: {ffmpeg_path}")
+
+ from subtitle_embedder import SubtitleEmbedder, EmbeddingConfig, SubtitlePosition, create_embedding_config_from_style
+ from subtitle_models import SubtitleStyle
+
+ try:
+ embedder = SubtitleEmbedder()
+ print(f" ✓ SubtitleEmbedder 初始化成功")
+
+ config = EmbeddingConfig(
+ font_name="Microsoft YaHei",
+ font_size=24,
+ position=SubtitlePosition.BOTTOM,
+ outline_width=2,
+ video_quality="high",
+ )
+ print(f" ✓ 嵌入配置创建成功: font={config.font_name}, size={config.font_size}")
+
+ style = SubtitleStyle.create_default_style()
+ config2 = create_embedding_config_from_style(style, use_gpu=False)
+ print(f" ✓ 从样式创建配置成功")
+
+ print("\n FFmpeg 嵌入器测试: [成功] ✓")
+ return True
+
+ except Exception as e:
+ print(f" ⚠ SubtitleEmbedder 初始化警告: {e}")
+ print(" 运行时需要视频文件才能完全测试")
+ return True
+ else:
+ print(f" ⚠ FFmpeg 未安装或未添加到 PATH")
+ print(" 请安装 FFmpeg: https://www.gyan.dev/ffmpeg/builds/")
+ print(" 或使用: winget install Gyan.FFmpeg")
+ return False
+
+ except Exception as e:
+ import traceback
+ print(f" ✗ 错误: {e}")
+ print(traceback.format_exc())
+ return False
+
+
+def test_web_module_imports():
+ """测试 Web 模块导入"""
+ print("\n" + "="*60)
+ print("测试 4: Web 模块导入")
+ print("="*60)
+
+ try:
+ from web.config import settings
+ print(f" ✓ web.config 导入成功")
+ print(f" - UPLOAD_DIR: {settings.UPLOAD_DIR}")
+ print(f" - OUTPUT_DIR: {settings.OUTPUT_DIR}")
+ print(f" - 支持视频格式: {settings.SUPPORTED_VIDEO_FORMATS}")
+ print(f" - 支持音频格式: {settings.SUPPORTED_AUDIO_FORMATS}")
+ print(f" - 最大文件大小: {settings.MAX_FILE_SIZE} bytes ({settings.MAX_FILE_SIZE // (1024*1024)} MB)")
+
+ from web.tasks import task_manager, ProcessingTask, TaskStatus, ProcessingStage
+ print(f" ✓ web.tasks 导入成功")
+ print(f" - 任务状态: {[s.value for s in TaskStatus]}")
+
+ from web.processor import processor
+ print(f" ✓ web.processor 导入成功")
+
+ from web.websocket_manager import ws_manager, WSMessage
+ print(f" ✓ web.websocket_manager 导入成功")
+
+ print("\n Web 模块测试: [成功] ✓")
+ return True
+
+ except Exception as e:
+ import traceback
+ print(f" ✗ 错误: {e}")
+ print(traceback.format_exc())
+ return False
+
+
+def run_all_tests():
+ """运行所有测试"""
+ print("\n" + "#"*60)
+ print("# VideoSubtitleAI Web 版功能验证")
+ print("#"*60)
+
+ results = {}
+
+ results['exporter'] = test_subtitle_exporter()
+ results['translator'] = test_translator_framework()
+ results['ffmpeg'] = test_ffmpeg_embedder()
+ results['web'] = test_web_module_imports()
+
+ print("\n" + "="*60)
+ print("测试结果汇总")
+ print("="*60)
+
+ passed = sum(1 for v in results.values() if v)
+ total = len(results)
+
+ for name, result in results.items():
+ status = "✓ 通过" if result else "✗ 失败/警告"
+ print(f" {name}: {status}")
+
+ print(f"\n总计: {passed}/{total} 测试通过")
+
+ if passed == total:
+ print("\n" + "✓"*60)
+ print("✓ 所有核心功能正常!")
+ print("✓"*60)
+ return True
+ else:
+ print("\n" + "⚠"*60)
+ print("⚠ 部分功能需要配置,请参考上面的提示")
+ print("⚠"*60)
+ return False
+
+
+if __name__ == "__main__":
+ run_all_tests()
diff --git a/test_websocket_fix.py b/test_websocket_fix.py
new file mode 100644
index 0000000..8934134
--- /dev/null
+++ b/test_websocket_fix.py
@@ -0,0 +1,321 @@
+"""
+验证WebSocket序列化修复
+"""
+import sys
+import os
+import json
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+
+def test_subtitle_segment_serialization():
+ print("=" * 60)
+ print("测试 1: SubtitleSegment 对象序列化")
+ print("=" * 60)
+
+ try:
+ from subtitle_models import SubtitleSegment, SubtitleDocument
+
+ seg = SubtitleSegment(
+ id=0, start=0.0, end=2.0,
+ text='你好,世界',
+ text_zh='你好,世界',
+ text_en='Hello World',
+ is_bilingual=True,
+ )
+
+ print(f"\n 创建 SubtitleSegment 对象:")
+ print(f" text: {seg.text}")
+ print(f" text_zh: {seg.text_zh}")
+ print(f" text_en: {seg.text_en}")
+
+ seg_dict = seg.to_dict()
+ print(f"\n to_dict() 结果:")
+ print(f" 类型: {type(seg_dict)}")
+ print(f" 内容: {json.dumps(seg_dict, ensure_ascii=False)[:100]}...")
+
+ json_str = json.dumps(seg_dict, ensure_ascii=False)
+ print(f"\n JSON 序列化成功:")
+ print(f" 长度: {len(json_str)} 字符")
+
+ print("\n [PASS]")
+ return True
+
+ except Exception as e:
+ import traceback
+ print(f"\n [FAIL] {e}")
+ traceback.print_exc()
+ return False
+
+
+def test_processor_segments_conversion():
+ print("\n" + "=" * 60)
+ print("测试 2: processor.py 中的 segments 转换逻辑")
+ print("=" * 60)
+
+ try:
+ from subtitle_models import SubtitleSegment
+
+ print("\n 模拟翻译后的 segments (SubtitleSegment 对象列表):")
+
+ segments = [
+ SubtitleSegment(
+ id=0, start=0.0, end=2.0,
+ text='你好,世界',
+ text_zh='你好,世界',
+ text_en='Hello World',
+ is_bilingual=True,
+ ),
+ SubtitleSegment(
+ id=1, start=2.0, end=4.0,
+ text='测试句子',
+ text_zh='测试句子',
+ text_en='Test sentence',
+ is_bilingual=True,
+ ),
+ {'id': 2, 'start': 4.0, 'end': 6.0, 'text': 'Mix of types'},
+ ]
+
+ print(f" 原始类型:")
+ for i, seg in enumerate(segments):
+ print(f" seg[{i}]: {type(seg).__name__}")
+
+ print("\n 应用 processor.py 中的转换逻辑:")
+
+ segments_dict = []
+ for seg in segments:
+ if isinstance(seg, SubtitleSegment):
+ segments_dict.append(seg.to_dict())
+ elif isinstance(seg, dict):
+ segments_dict.append(seg)
+ else:
+ segments_dict.append(str(seg))
+
+ print(f" 转换后类型:")
+ for i, seg in enumerate(segments_dict):
+ print(f" seg[{i}]: {type(seg).__name__}")
+
+ print("\n 尝试 JSON 序列化:")
+ result = {
+ 'success': True,
+ 'segments': segments_dict,
+ 'segment_count': len(segments_dict),
+ }
+
+ json_str = json.dumps(result, ensure_ascii=False)
+ print(f" 序列化成功! 长度: {len(json_str)} 字符")
+
+ print("\n 模拟 WSMessage.to_json():")
+ ws_data = {
+ 'type': 'task_progress',
+ 'task_id': 'test-123',
+ 'data': {
+ 'task_id': 'test-123',
+ 'status': 'completed',
+ 'result': result,
+ },
+ 'timestamp': '2026-05-02T12:00:00Z',
+ }
+
+ ws_json = json.dumps(ws_data, ensure_ascii=False)
+ print(f" WSMessage 序列化成功! 长度: {len(ws_json)} 字符")
+
+ print("\n [PASS]")
+ return True
+
+ except Exception as e:
+ import traceback
+ print(f"\n [FAIL] {e}")
+ traceback.print_exc()
+ return False
+
+
+def test_task_to_dict_with_result():
+ print("\n" + "=" * 60)
+ print("测试 3: task.to_dict() 包含 result 时的序列化")
+ print("=" * 60)
+
+ try:
+ from web.tasks import ProcessingTask, TaskStatus, ProcessingStage
+ from subtitle_models import SubtitleSegment
+
+ print("\n 创建 ProcessingTask:")
+ task = ProcessingTask(
+ task_id='test-123',
+ input_files=['test.mp4'],
+ )
+
+ print(f" 标记任务开始...")
+ task.mark_started()
+
+ print(f" 创建包含 SubtitleSegment 对象的 result...")
+
+ segments = [
+ SubtitleSegment(
+ id=0, start=0.0, end=2.0,
+ text='你好,世界',
+ text_zh='你好,世界',
+ text_en='Hello World',
+ is_bilingual=True,
+ ).to_dict(),
+ SubtitleSegment(
+ id=1, start=2.0, end=4.0,
+ text='测试句子',
+ text_zh='测试句子',
+ text_en='Test sentence',
+ is_bilingual=True,
+ ).to_dict(),
+ ]
+
+ result = {
+ 'success': True,
+ 'output_files': {'srt': '/path/to/output.srt'},
+ 'segments': segments,
+ 'detected_language': 'zh',
+ 'segment_count': 2,
+ }
+
+ print(f" 标记任务完成...")
+ task.mark_completed(result)
+
+ print(f"\n 调用 task.to_dict():")
+ task_dict = task.to_dict()
+ print(f" 类型: {type(task_dict)}")
+ print(f" 包含 result: {'result' in task_dict}")
+ print(f" result 类型: {type(task_dict.get('result'))}")
+
+ print(f"\n 尝试 JSON 序列化:")
+ json_str = json.dumps(task_dict, ensure_ascii=False)
+ print(f" 序列化成功! 长度: {len(json_str)} 字符")
+
+ print(f"\n 模拟 WebSocket 消息发送:")
+ ws_message = {
+ 'type': 'task_progress',
+ 'task_id': task.task_id,
+ 'data': task_dict,
+ 'timestamp': '2026-05-02T12:00:00Z',
+ }
+
+ ws_json = json.dumps(ws_message, ensure_ascii=False)
+ print(f" WSMessage 序列化成功! 长度: {len(ws_json)} 字符")
+
+ print("\n [PASS]")
+ return True
+
+ except Exception as e:
+ import traceback
+ print(f"\n [FAIL] {e}")
+ traceback.print_exc()
+ return False
+
+
+def test_translate_segments_returns():
+ print("\n" + "=" * 60)
+ print("测试 4: translate_segments 返回值类型")
+ print("=" * 60)
+
+ try:
+ from subtitle_translator import SubtitleTranslator, TranslationBackend, translate_segments_with_default
+ from subtitle_models import SubtitleSegment
+
+ print("\n 创建测试 segments (字典列表):")
+ test_segments = [
+ {'id': 0, 'start': 0.0, 'end': 2.0, 'text': '你好,世界'},
+ {'id': 1, 'start': 2.0, 'end': 4.0, 'text': 'Hello World'},
+ ]
+
+ print(f" 调用 translate_segments_with_default...")
+
+ translated = translate_segments_with_default(
+ segments=test_segments,
+ source_language='zh',
+ target_language='en',
+ )
+
+ print(f"\n 返回值类型:")
+ for i, seg in enumerate(translated):
+ print(f" seg[{i}]: {type(seg).__name__}")
+ if isinstance(seg, SubtitleSegment):
+ print(f" text_zh: {seg.text_zh}")
+ print(f" text_en: {seg.text_en}")
+ print(f" is_bilingual: {seg.is_bilingual}")
+
+ print(f"\n 注意: translate_segments 返回 SubtitleSegment 对象列表")
+ print(f" 这些对象需要通过 to_dict() 转换后才能被 JSON 序列化")
+
+ print(f"\n 转换为字典列表:")
+ translated_dict = [seg.to_dict() if hasattr(seg, 'to_dict') else seg for seg in translated]
+
+ print(f" 转换后类型:")
+ for i, seg in enumerate(translated_dict):
+ print(f" seg[{i}]: {type(seg).__name__}")
+
+ print(f"\n 尝试 JSON 序列化:")
+ json_str = json.dumps(translated_dict, ensure_ascii=False)
+ print(f" 序列化成功! 长度: {len(json_str)} 字符")
+
+ print("\n [PASS]")
+ return True
+
+ except Exception as e:
+ import traceback
+ print(f"\n [FAIL] {e}")
+ traceback.print_exc()
+ return False
+
+
+def run_all_tests():
+ print("\n" + "#" * 60)
+ print("# WebSocket 序列化修复验证测试")
+ print("#" * 60)
+
+ results = {}
+
+ results['segment_ser'] = test_subtitle_segment_serialization()
+ results['processor_conv'] = test_processor_segments_conversion()
+ results['task_to_dict'] = test_task_to_dict_with_result()
+ results['translate_ret'] = test_translate_segments_returns()
+
+ print("\n" + "=" * 60)
+ print("测试结果汇总")
+ print("=" * 60)
+
+ passed = sum(1 for v in results.values() if v)
+ total = len(results)
+
+ test_names = {
+ 'segment_ser': 'SubtitleSegment 序列化',
+ 'processor_conv': 'processor 转换逻辑',
+ 'task_to_dict': 'task.to_dict() 序列化',
+ 'translate_ret': 'translate_segments 返回值',
+ }
+
+ for key, result in results.items():
+ status = "PASS" if result else "FAIL"
+ print(f" {test_names.get(key, key)}: {status}")
+
+ print(f"\n总计: {passed}/{total} 通过")
+
+ if passed == total:
+ print("\n" + "✓" * 60)
+ print("✓ 所有修复验证通过!")
+ print("✓")
+ print("✓ 关键修复:")
+ print("✓ 1. processor.py 中返回 result 前,")
+ print("✓ 将 SubtitleSegment 对象转换为字典")
+ print("✓ 2. 确保 task.result 中的所有数据")
+ print("✓ 都能被 JSON 序列化")
+ print("✓ 3. WebSocket 消息能正常发送给前端")
+ print("✓" * 60)
+ return True
+ else:
+ print("\n" + "⚠" * 60)
+ print("⚠ 部分测试失败,请检查修复")
+ print("⚠" * 60)
+ return False
+
+
+if __name__ == "__main__":
+ run_all_tests()
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..3b28ee4
--- /dev/null
+++ b/web/config.py
@@ -0,0 +1,119 @@
+"""
+VideoSubtitleAI Web 版配置管理
+"""
+import os
+import sys
+from pathlib import Path
+from dataclasses import dataclass, field
+from typing import List, Optional
+
+PROJECT_ROOT = Path(__file__).parent.parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+
+@dataclass
+class Settings:
+ BASE_DIR: Path = PROJECT_ROOT
+
+ UPLOAD_DIR: Path = PROJECT_ROOT / "web_uploads"
+ OUTPUT_DIR: Path = PROJECT_ROOT / "web_outputs"
+ TEMP_DIR: Path = PROJECT_ROOT / "web_temp"
+ LOG_DIR: Path = PROJECT_ROOT / "web_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"
+ ])
+
+ MAX_FILE_SIZE: int = 500 * 1024 * 1024
+
+ MAX_CONCURRENT_TASKS: int = 1
+
+ WS_PING_INTERVAL: int = 30
+
+ CORS_ORIGINS: List[str] = field(default_factory=lambda: [
+ "http://localhost",
+ "http://localhost:8000",
+ "http://127.0.0.1:8000",
+ ])
+
+ 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": "韩文"},
+ ])
+
+ 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 __getattr__(self, name):
+ """
+ 向后兼容的属性别名
+ 支持旧代码中使用的属性名
+ """
+ aliases = {
+ 'VIDEO_FORMATS': 'SUPPORTED_VIDEO_FORMATS',
+ 'AUDIO_FORMATS': 'SUPPORTED_AUDIO_FORMATS',
+ 'MAX_FILE_SIZE_MB': None,
+ }
+
+ if name in aliases:
+ actual_name = aliases[name]
+ if actual_name is None:
+ if name == 'MAX_FILE_SIZE_MB':
+ return self.MAX_FILE_SIZE // (1024 * 1024)
+ return getattr(self, actual_name)
+
+ raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
+
+ @property
+ def VIDEO_FORMATS(self):
+ """兼容别名: 支持视频格式"""
+ return self.SUPPORTED_VIDEO_FORMATS
+
+ @property
+ def AUDIO_FORMATS(self):
+ """兼容别名: 支持音频格式"""
+ return self.SUPPORTED_AUDIO_FORMATS
+
+ @property
+ def MAX_FILE_SIZE_MB(self):
+ """兼容别名: 最大文件大小(MB)"""
+ return self.MAX_FILE_SIZE // (1024 * 1024)
+
+ 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..a0a3ed4
--- /dev/null
+++ b/web/processor.py
@@ -0,0 +1,444 @@
+"""
+VideoSubtitleAI Web 版核心处理器
+"""
+import sys
+import os
+import traceback
+from pathlib import Path
+from typing import List, Dict, Any, Optional, Tuple, Callable
+from concurrent.futures import ThreadPoolExecutor
+import asyncio
+
+PROJECT_ROOT = Path(__file__).parent.parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+from .config import settings
+from .tasks import ProcessingTask, ProcessingStage, TaskStatus
+
+
+class WebSubtitleProcessor:
+ _executor: Optional[ThreadPoolExecutor] = None
+ _instance: Optional['WebSubtitleProcessor'] = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ cls._instance._initialized = False
+ return cls._instance
+
+ def _ensure_executor(self):
+ if self._executor is None:
+ self._executor = ThreadPoolExecutor(
+ max_workers=1,
+ thread_name_prefix="subtitle-processor"
+ )
+
+ def _get_ai_instance(self, model_name: str = "base"):
+ print(f"[Processor] 初始化 VideoSubtitleAI,模型: {model_name}")
+
+ from main import VideoSubtitleAI
+
+ ai = VideoSubtitleAI(
+ model_name=model_name,
+ verbose=True,
+ )
+ ai.initialize()
+ return ai
+
+ def _get_enhanced_ai(self, model_name: str = "base"):
+ print(f"[Processor] 初始化增强版 VideoSubtitleAI")
+
+ from video_subtitle_enhanced import VideoSubtitleAIEnhanced
+ from speech_recognizer import SpeechRecognizer
+
+ try:
+ import whisper
+ whisper_model = whisper.load_model(model_name)
+ base_recognizer = SpeechRecognizer(whisper_model=whisper_model, model_name=model_name)
+
+ enhanced = VideoSubtitleAIEnhanced(
+ base_recognizer=base_recognizer,
+ whisper_model=whisper_model,
+ verbose=True,
+ )
+ return enhanced
+ except Exception as e:
+ print(f"[Processor] 增强版初始化失败: {e}")
+ return None
+
+ def _extract_audio_sync(self, input_path: str, task: ProcessingTask) -> str:
+ from audio_processor import AudioProcessor
+
+ task.add_log(f"提取音频: {Path(input_path).name}", "INFO")
+
+ audio_processor = AudioProcessor()
+ temp_audio, is_temp = audio_processor.process_media_file(input_path)
+
+ return temp_audio
+
+ def _recognize_sync(
+ self,
+ audio_path: str,
+ language: str,
+ model_name: str,
+ task: ProcessingTask
+ ) -> Dict[str, Any]:
+
+ import whisper
+
+ task.add_log(f"加载 Whisper 模型: {model_name}", "INFO")
+ task.update_stage(ProcessingStage.RECOGNIZING_SPEECH, "加载模型...", 10)
+
+ model = whisper.load_model(model_name)
+
+ task.add_log(f"开始语音识别 (语言: {language})", "INFO")
+ task.update_stage(ProcessingStage.RECOGNIZING_SPEECH, "语音识别中...", 15)
+
+ result = model.transcribe(
+ audio=audio_path,
+ language=language if language != "auto" else None,
+ task="transcribe",
+ verbose=True,
+ )
+
+ detected_language = result.get('language', 'auto')
+ task.add_log(f"检测到语言: {detected_language}", "INFO")
+
+ return {
+ 'segments': result.get('segments', []),
+ 'language': detected_language,
+ 'language_name': detected_language,
+ }
+
+ def _export_subtitles_sync(
+ self,
+ segments: List[Dict],
+ base_output_path: str,
+ formats: List[str],
+ task: ProcessingTask,
+ options: Dict = None,
+ ) -> Dict[str, str]:
+
+ from subtitle_models import SubtitleDocument, SubtitleSegment, create_document_from_segments, SubtitleStyle
+ from subtitle_exporter import SubtitleExporter, create_styled_ass_style
+
+ output_files = {}
+ doc = create_document_from_segments(segments)
+
+ custom_style = None
+ if options:
+ 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')
+ margin_v = options.get('margin_v')
+
+ if any([font_size, font_color, outline_color, outline_width, position, margin_v]):
+ custom_style = SubtitleStyle.create_default_style()
+
+ if font_size:
+ custom_style.font_size = font_size
+ if outline_width:
+ custom_style.outline = outline_width
+ if margin_v:
+ custom_style.margin_v = margin_v
+
+ if font_color:
+ custom_style.update_color_from_hex(font_color, "primary")
+ if outline_color:
+ custom_style.update_color_from_hex(outline_color, "outline")
+
+ if position:
+ alignment_map = {'bottom': 2, 'middle': 5, 'top': 8}
+ custom_style.alignment = alignment_map.get(position, 2)
+
+ task.add_log(f"使用自定义样式: font_size={font_size}, position={position}", "INFO")
+
+ exporter = SubtitleExporter()
+
+ if 'srt' in formats:
+ srt_path = exporter.export_to_srt(doc, f"{base_output_path}.srt")
+ output_files['srt'] = srt_path
+ task.add_log(f"导出 SRT: {srt_path}", "INFO")
+
+ if 'ass' in formats:
+ style = custom_style if custom_style else create_styled_ass_style()
+ ass_path = exporter.export_to_ass(doc, f"{base_output_path}.ass", style)
+ output_files['ass'] = ass_path
+ task.add_log(f"导出 ASS: {ass_path}", "INFO")
+
+ if 'vtt' in formats:
+ vtt_path = exporter.export_to_vtt(doc, f"{base_output_path}.vtt")
+ output_files['vtt'] = vtt_path
+ task.add_log(f"导出 VTT: {vtt_path}", "INFO")
+
+ return output_files
+
+ def _generate_bilingual_sync(
+ self,
+ segments: List[Dict],
+ source_language: str,
+ task: ProcessingTask,
+ ) -> Tuple[List[Dict], bool]:
+
+ try:
+ from subtitle_translator import translate_segments_with_default
+
+ task.add_log("开始生成双语字幕...", "INFO")
+ task.update_stage(ProcessingStage.TRANSLATING, "翻译中...", 60)
+
+ translated_segments = translate_segments_with_default(
+ segments=segments,
+ source_language=source_language if source_language != "auto" else 'zh',
+ target_language='en' if source_language in ['zh', 'auto'] else 'zh',
+ )
+
+ return translated_segments, True
+ except Exception as e:
+ task.add_log(f"翻译功能不可用: {e}", "WARNING")
+ return segments, False
+
+ def _embed_subtitles_sync(
+ self,
+ video_path: str,
+ subtitle_path: str,
+ output_path: str,
+ task: ProcessingTask,
+ options: Dict = None,
+ ) -> Optional[str]:
+
+ try:
+ from subtitle_embedder import SubtitleEmbedder, EmbeddingConfig, create_embedding_config_from_style
+ from subtitle_models import SubtitleStyle
+
+ task.add_log(f"嵌入字幕到视频...", "INFO")
+ task.update_stage(ProcessingStage.EMBEDDING, "嵌入字幕中...", 85)
+
+ style = SubtitleStyle()
+ if options:
+ if options.get('font_size'):
+ style.font_size = options.get('font_size')
+ if options.get('font_color'):
+ style.font_color = options.get('font_color')
+ if options.get('outline_color'):
+ style.outline_color = options.get('outline_color')
+ if options.get('position'):
+ style.position = options.get('position')
+
+ config = create_embedding_config_from_style(
+ style=style,
+ use_gpu=options.get('embed_gpu', False) if options else False,
+ )
+
+ embedder = SubtitleEmbedder()
+ result = embedder.embed_subtitles(
+ video_path=video_path,
+ subtitle_path=subtitle_path,
+ output_path=output_path,
+ config=config,
+ )
+
+ if result.get('success'):
+ task.add_log(f"字幕嵌入完成: {output_path}", "INFO")
+ return output_path
+ else:
+ task.add_log(f"字幕嵌入失败: {result.get('error')}", "ERROR")
+ return None
+
+ except Exception as e:
+ task.add_log(f"字幕嵌入异常: {e}\n{traceback.format_exc()}", "ERROR")
+ return None
+
+ def _process_file_sync(
+ self,
+ task: ProcessingTask,
+ ) -> Dict[str, Any]:
+
+ try:
+ options = task.options or {}
+ model_name = options.get('model', 'base')
+ language = options.get('language', 'auto')
+ formats = options.get('formats', ['srt'])
+ enable_bilingual = options.get('bilingual', False)
+ embed_video = options.get('embed_video', False)
+
+ input_path = task.input_files[0] if task.input_files else None
+ if not input_path:
+ raise ValueError("没有输入文件")
+
+ input_path = Path(input_path)
+ if not input_path.exists():
+ raise FileNotFoundError(f"输入文件不存在: {input_path}")
+
+ is_video = settings.is_video_format(input_path.suffix)
+ base_filename = input_path.stem
+
+ output_base = settings.OUTPUT_DIR / task.task_id
+ output_base.mkdir(parents=True, exist_ok=True)
+
+ task.add_log(f"开始处理文件: {input_path.name}", "INFO")
+
+ if is_video:
+ task.update_stage(ProcessingStage.EXTRACTING_AUDIO, "提取音频...", 5)
+ audio_path = self._extract_audio_sync(str(input_path), task)
+ task.update_stage(ProcessingStage.RECOGNIZING_SPEECH, "语音识别...", 10)
+ else:
+ audio_path = str(input_path)
+
+ recognition_result = self._recognize_sync(
+ audio_path=audio_path,
+ language=language,
+ model_name=model_name,
+ task=task,
+ )
+
+ segments = recognition_result.get('segments', [])
+ detected_language = recognition_result.get('language', 'auto')
+
+ task.add_log(f"识别完成,共 {len(segments)} 个字幕段", "INFO")
+ task.update_stage(ProcessingStage.GENERATING_SUBTITLES, "生成字幕...", 50)
+
+ if enable_bilingual:
+ segments, bilingual_success = self._generate_bilingual_sync(
+ segments=segments,
+ source_language=detected_language,
+ task=task,
+ )
+ if bilingual_success:
+ task.add_log("双语字幕生成成功", "INFO")
+
+ task.update_stage(ProcessingStage.EXPORTING, "导出字幕文件...", 70)
+
+ base_output = str(output_base / base_filename)
+ output_files = self._export_subtitles_sync(
+ segments=segments,
+ base_output_path=base_output,
+ formats=formats,
+ task=task,
+ options=options,
+ )
+
+ if embed_video and is_video and output_files:
+ subtitle_format = 'ass' if 'ass' in output_files else 'srt'
+ if subtitle_format in output_files:
+ embedded_output = str(output_base / f"{base_filename}_subtitled.mp4")
+ embedded_path = self._embed_subtitles_sync(
+ video_path=str(input_path),
+ subtitle_path=output_files[subtitle_format],
+ output_path=embedded_output,
+ task=task,
+ options=options,
+ )
+ if embedded_path:
+ output_files['embedded'] = embedded_path
+
+ task.update_stage(ProcessingStage.COMPLETED, "处理完成", 100)
+ task.add_log("处理流程完成", "INFO")
+
+ from subtitle_models import SubtitleSegment
+ segments_dict = []
+ for seg in segments:
+ if isinstance(seg, SubtitleSegment):
+ segments_dict.append(seg.to_dict())
+ elif isinstance(seg, dict):
+ segments_dict.append(seg)
+ else:
+ segments_dict.append(str(seg))
+
+ return {
+ 'success': True,
+ 'output_files': output_files,
+ 'segments': segments_dict,
+ 'detected_language': detected_language,
+ 'segment_count': len(segments_dict),
+ }
+
+ except Exception as e:
+ error_msg = f"{str(e)}\n{traceback.format_exc()}"
+ task.add_log(f"处理失败: {error_msg}", "ERROR")
+ raise
+
+ async def process_single_file(self, task: ProcessingTask) -> Optional[Dict[str, Any]]:
+
+ self._ensure_executor()
+
+ try:
+ loop = asyncio.get_event_loop()
+
+ result = await loop.run_in_executor(
+ self._executor,
+ lambda: self._process_file_sync(task)
+ )
+
+ if result and result.get('success'):
+ task.output_files.update(result.get('output_files', {}))
+ if result.get('segments'):
+ try:
+ from subtitle_models import SubtitleDocument, create_document_from_segments
+ doc = create_document_from_segments(result['segments'])
+ result['document_json'] = doc.to_json()
+ result['subtitle_document'] = doc.to_dict()
+ except Exception as e:
+ task.add_log(f"序列化字幕文档失败: {e}", "WARNING")
+
+ return result
+
+ except Exception as e:
+ task.add_log(f"异步处理异常: {e}", "ERROR")
+ return None
+
+ async def process_batch(self, task: ProcessingTask) -> Optional[Dict[str, Any]]:
+
+ results = []
+ errors = []
+ input_files = task.input_files
+ total_files = len(input_files)
+
+ task.progress.total_files = total_files
+
+ for i, input_file in enumerate(input_files):
+ if task.is_cancelled:
+ task.add_log("任务被取消,停止处理", "INFO")
+ break
+
+ task.progress.processed_files = i
+ task.add_log(f"处理文件 {i+1}/{total_files}: {Path(input_file).name}", "INFO")
+
+ single_task = ProcessingTask(
+ task_id=f"{task.task_id}_{i}",
+ input_files=[input_file],
+ options=task.options,
+ metadata=task.metadata,
+ )
+ single_task.progress = task.progress
+
+ try:
+ result = await self.process_single_file(single_task)
+ if result and result.get('success'):
+ results.append({
+ 'input_file': input_file,
+ 'output_files': result.get('output_files', {}),
+ })
+ else:
+ errors.append({
+ 'input_file': input_file,
+ 'error': single_task.error or "处理失败",
+ })
+ except Exception as e:
+ errors.append({
+ 'input_file': input_file,
+ 'error': str(e),
+ })
+
+ return {
+ 'success': len(results) > 0,
+ 'total_files': total_files,
+ 'successful': len(results),
+ 'failed': len(errors),
+ 'results': results,
+ 'errors': errors,
+ }
+
+
+processor = WebSubtitleProcessor()
diff --git a/web/server.py b/web/server.py
new file mode 100644
index 0000000..06a7204
--- /dev/null
+++ b/web/server.py
@@ -0,0 +1,620 @@
+"""
+VideoSubtitleAI Web 版主服务器
+基于 FastAPI 的全栈 Web 应用
+"""
+import asyncio
+import json
+import logging
+import shutil
+import uuid
+import traceback
+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
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.templating import Jinja2Templates
+from pydantic import BaseModel
+
+PROJECT_ROOT = Path(__file__).parent.parent
+import sys
+sys.path.insert(0, str(PROJECT_ROOT))
+
+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 SaveSubtitlesRequest(BaseModel):
+ segments: List[Dict[str, Any]]
+ output_formats: List[str] = ["srt", "ass", "vtt"]
+ style: Optional[Dict[str, Any]] = 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
+ try:
+ 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"
+ )
+ except Exception as e:
+ saved_path.unlink(missing_ok=True)
+ raise HTTPException(status_code=500, detail=f"文件上传失败: {e}")
+
+ 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,
+ }
+ )
+
+ print(f"[Server] 文件已上传: {file.filename}, 任务ID: {task.task_id}")
+
+ 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:
+ saved_path.unlink(missing_ok=True)
+ 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()
+ print(f"[Server] 启动处理任务: {task_id}, 模型: {options.model}, 语言: {options.language}")
+
+ async def process_wrapper(t: ProcessingTask):
+ if len(t.input_files) > 1:
+ result = await processor.process_batch(t)
+ else:
+ result = await processor.process_single_file(t)
+ return result
+
+ async def process_and_monitor(t: ProcessingTask):
+ monitor_task = asyncio.create_task(task_progress_monitor(t, interval=0.3))
+
+ try:
+ return await task_manager.start_task(t.task_id, process_wrapper)
+ finally:
+ await ws_manager.send_task_progress(t)
+ await ws_manager.send_task_status_change(t)
+
+ monitor_task.cancel()
+ try:
+ await monitor_task
+ except asyncio.CancelledError:
+ pass
+ except Exception as e:
+ print(f"[Server] Monitor task error: {e}")
+
+ asyncio.create_task(process_and_monitor(task))
+
+ 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}")
+
+ output_dir = settings.OUTPUT_DIR / task_id
+ if output_dir.exists():
+ try:
+ shutil.rmtree(output_dir)
+ except Exception as e:
+ logger.warning(f"Failed to delete output directory: {output_dir}, 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.get("/api/video/{task_id}/{filename}")
+async def get_video_file(task_id: str, filename: str):
+ task = task_manager.get_task(task_id)
+ if not task:
+ raise HTTPException(status_code=404, detail=f"任务不存在: {task_id}")
+
+ video_path = None
+
+ if task.input_files and len(task.input_files) > 0:
+ for input_file in task.input_files:
+ input_path = Path(input_file)
+ if input_path.name == filename:
+ video_path = input_path
+ break
+
+ if video_path is None:
+ upload_dir = settings.UPLOAD_DIR
+ for file_path in upload_dir.glob("*"):
+ if file_path.name == filename or file_path.name.endswith(filename):
+ video_path = file_path
+ break
+
+ if video_path is None or not video_path.exists():
+ raise HTTPException(status_code=404, detail="视频文件不存在")
+
+ ext = video_path.suffix.lower()
+ media_type = "video/mp4"
+ if ext == ".webm":
+ media_type = "video/webm"
+ elif ext == ".ogg":
+ media_type = "video/ogg"
+ elif ext == ".mov":
+ media_type = "video/quicktime"
+ elif ext == ".avi":
+ media_type = "video/x-msvideo"
+ elif ext == ".mkv":
+ media_type = "video/x-matroska"
+
+ return FileResponse(
+ path=str(video_path),
+ media_type=media_type,
+ )
+
+
+@app.post("/api/editor/{task_id}/save")
+async def save_edited_subtitles(task_id: str, request: SaveSubtitlesRequest):
+ task = task_manager.get_task(task_id)
+ if not task:
+ raise HTTPException(status_code=404, detail=f"任务不存在: {task_id}")
+
+ try:
+ from subtitle_models import SubtitleDocument, SubtitleSegment, create_document_from_segments
+ from subtitle_exporter import SubtitleExporter, create_styled_ass_style
+
+ doc_segments = []
+ for seg_data in request.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),
+ bilingual_order=seg_data.get('bilingual_order', 'zh_en'),
+ )
+ doc_segments.append(seg)
+
+ doc = SubtitleDocument(segments=doc_segments)
+
+ input_path = Path(task.input_files[0]) if task.input_files else Path("unknown")
+
+ output_dir = settings.OUTPUT_DIR / task_id
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ base_output = str(output_dir / f"{input_path.stem}_edited")
+
+ exporter = SubtitleExporter()
+ output_files = {}
+
+ custom_style = None
+ if request.style:
+ font_name = request.style.get('font_name', 'Microsoft YaHei')
+ font_size = int(request.style.get('font_size', 48))
+ font_color = request.style.get('font_color', '#FFFFFF')
+ outline_color = request.style.get('outline_color', '#000000')
+ outline_width = int(request.style.get('outline_width', 2))
+ position = request.style.get('position', 'bottom')
+ margin_v = int(request.style.get('margin_v', 40))
+
+ alignment_map = {'bottom': 2, 'middle': 5, 'top': 8}
+ alignment = alignment_map.get(position, 2)
+
+ from subtitle_models import SubtitleStyle
+ custom_style = SubtitleStyle(
+ name="Default",
+ font_name=font_name,
+ font_size=font_size,
+ outline=outline_width,
+ alignment=alignment,
+ margin_v=margin_v,
+ )
+ custom_style.update_color_from_hex(font_color, "primary")
+ custom_style.update_color_from_hex(outline_color, "outline")
+
+ logger.info(f"[Editor] 使用自定义样式: font_size={font_size}, font_color={font_color}, outline_color={outline_color}")
+
+ if 'srt' in request.output_formats:
+ srt_path = exporter.export_to_srt(doc, f"{base_output}.srt")
+ output_files['srt'] = srt_path
+ logger.info(f"[Editor] 导出 SRT: {srt_path}")
+
+ if 'ass' in request.output_formats:
+ style = custom_style if custom_style else create_styled_ass_style()
+ ass_path = exporter.export_to_ass(doc, f"{base_output}.ass", style)
+ output_files['ass'] = ass_path
+ logger.info(f"[Editor] 导出 ASS: {ass_path}")
+
+ if 'vtt' in request.output_formats:
+ vtt_path = exporter.export_to_vtt(doc, f"{base_output}.vtt")
+ output_files['vtt'] = vtt_path
+ logger.info(f"[Editor] 导出 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,
+ }
+
+ except Exception as e:
+ error_msg = f"{str(e)}\n{traceback.format_exc()}"
+ print(f"[Server] 保存编辑字幕失败: {error_msg}")
+ raise HTTPException(status_code=500, detail=f"保存失败: {e}")
+
+
+@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:
+ try:
+ data = await websocket.receive_text()
+ try:
+ msg_data = json.loads(data)
+ msg_type = msg_data.get("type")
+
+ if msg_type == "ping":
+ await ws_manager._send_to_websocket(
+ websocket,
+ WSMessage(type="pong", task_id=task_id)
+ )
+ elif msg_type == "get_status" and task:
+ await ws_manager.send_task_progress(task)
+
+ except json.JSONDecodeError:
+ pass
+
+ except WebSocketDisconnect:
+ break
+
+ except WebSocketDisconnect:
+ pass
+ except Exception as e:
+ print(f"[WebSocket] Error: {e}")
+ finally:
+ 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:
+ pass
+ except Exception as e:
+ print(f"[WebSocket Broadcast] Error: {e}")
+ finally:
+ await ws_manager.disconnect(websocket, 'broadcast')
+
+
+@app.on_event("startup")
+async def startup_event():
+ print("=" * 70)
+ print(" VideoSubtitleAI Web 版启动中...")
+ print("=" * 70)
+ print(f" 项目根目录: {PROJECT_ROOT}")
+ print(f" Web目录: {web_dir}")
+ print(f" 上传目录: {settings.UPLOAD_DIR}")
+ print(f" 输出目录: {settings.OUTPUT_DIR}")
+ print(f" 支持格式: {settings.SUPPORTED_AUDIO_FORMATS + settings.SUPPORTED_VIDEO_FORMATS}")
+ print(f" 最大文件: {settings.MAX_FILE_SIZE / (1024*1024):.0f} MB")
+ print("=" * 70)
+ print(" 访问地址: http://localhost:8000")
+ print(" API文档: http://localhost:8000/docs")
+ print("=" * 70)
+
+
+@app.on_event("shutdown")
+async def shutdown_event():
+ print("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..ddaf3e0
--- /dev/null
+++ b/web/static/css/style.css
@@ -0,0 +1,1700 @@
+:root {
+ --primary-color: #1e40af;
+ --primary-light: #3b82f6;
+ --secondary-color: #6366f1;
+ --success-color: #22c55e;
+ --warning-color: #eab308;
+ --danger-color: #ef4444;
+ --purple-color: #8b5cf6;
+ --cyan-color: #06b6d4;
+ --orange-color: #f97316;
+
+ --bg-primary: #ffffff;
+ --bg-secondary: #f8fafc;
+ --bg-tertiary: #f1f5f9;
+ --border-color: #e2e8f0;
+ --text-primary: #1e293b;
+ --text-secondary: #64748b;
+ --text-muted: #94a3b8;
+
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+ --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+
+ --radius: 0.5rem;
+ --radius-lg: 0.75rem;
+ --radius-xl: 1rem;
+
+ --transition: all 0.2s ease;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ line-height: 1.5;
+ color: var(--text-primary);
+ background-color: var(--bg-secondary);
+}
+
+/* Layout */
+.main-container {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 2rem 1rem;
+}
+
+/* Navigation */
+nav {
+ background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
+ box-shadow: var(--shadow-md);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+.nav-container {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 0 1rem;
+}
+
+.nav-content {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 4rem;
+}
+
+.logo {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ color: white;
+}
+
+.logo i {
+ font-size: 1.75rem;
+}
+
+.logo .title {
+ font-size: 1.25rem;
+ font-weight: 700;
+}
+
+.logo .subtitle {
+ font-size: 0.875rem;
+ opacity: 0.9;
+ background: rgba(255, 255, 255, 0.2);
+ padding: 0.125rem 0.5rem;
+ border-radius: 9999px;
+}
+
+.nav-links {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.nav-links a {
+ color: rgba(255, 255, 255, 0.9);
+ text-decoration: none;
+ padding: 0.5rem 1rem;
+ border-radius: var(--radius);
+ font-weight: 500;
+ transition: var(--transition);
+}
+
+.nav-links a:hover {
+ background: rgba(255, 255, 255, 0.15);
+ color: white;
+}
+
+.nav-links a.active {
+ background: rgba(255, 255, 255, 0.2);
+ color: white;
+}
+
+/* Header Section */
+.header-section {
+ text-align: center;
+ margin-bottom: 2.5rem;
+}
+
+.header-section h1 {
+ font-size: 2.5rem;
+ font-weight: 800;
+ color: var(--text-primary);
+ margin-bottom: 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+}
+
+.header-section h1 i {
+ color: var(--primary-light);
+}
+
+.header-section p {
+ font-size: 1.125rem;
+ color: var(--text-secondary);
+ max-width: 800px;
+ margin: 0 auto;
+ line-height: 1.7;
+}
+
+/* Two Columns Layout */
+.two-columns {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1.5rem;
+ margin-bottom: 3rem;
+}
+
+@media (max-width: 1024px) {
+ .two-columns {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Cards */
+.card {
+ background: var(--bg-primary);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow);
+ border: 1px solid var(--border-color);
+ padding: 1.5rem;
+}
+
+.card h2 {
+ font-size: 1.125rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin-bottom: 1.25rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.card h2 i {
+ color: var(--primary-light);
+}
+
+/* Upload Area */
+.upload-area {
+ border: 2px dashed var(--border-color);
+ border-radius: var(--radius-lg);
+ padding: 3rem 1.5rem;
+ text-align: center;
+ cursor: pointer;
+ transition: var(--transition);
+ background: var(--bg-secondary);
+}
+
+.upload-area:hover {
+ border-color: var(--primary-light);
+ background: rgba(59, 130, 246, 0.02);
+}
+
+.upload-area.upload-dragover {
+ border-color: var(--primary-light);
+ background: rgba(59, 130, 246, 0.05);
+}
+
+.upload-area i {
+ font-size: 3rem;
+ color: var(--text-muted);
+ margin-bottom: 1rem;
+}
+
+.upload-area p {
+ font-size: 1.125rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ margin-bottom: 0.5rem;
+}
+
+.upload-area .hint {
+ font-size: 0.875rem;
+ color: var(--text-muted);
+ font-weight: 400;
+ margin-bottom: 0;
+}
+
+/* File Info */
+.file-info {
+ display: none;
+}
+
+.file-info.visible {
+ display: block;
+}
+
+.file-info-content {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem;
+ background: var(--bg-secondary);
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border-color);
+}
+
+.file-info-left {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.file-info-left i {
+ font-size: 2rem;
+ color: var(--primary-light);
+}
+
+.file-info-details .filename {
+ font-weight: 600;
+ color: var(--text-primary);
+ word-break: break-all;
+}
+
+.file-info-details .filesize {
+ font-size: 0.875rem;
+ color: var(--text-muted);
+}
+
+.remove-file-btn {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 1.25rem;
+ padding: 0.25rem;
+ border-radius: 9999px;
+ transition: var(--transition);
+}
+
+.remove-file-btn:hover {
+ color: var(--danger-color);
+ background: rgba(239, 68, 68, 0.1);
+}
+
+/* Form Section */
+.form-section {
+ margin-top: 1.5rem;
+}
+
+.form-section h3 {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 1rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.form-grid-2 {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+@media (max-width: 640px) {
+ .form-grid-2 {
+ grid-template-columns: 1fr;
+ }
+}
+
+.form-group {
+ margin-bottom: 0.75rem;
+}
+
+.form-group label {
+ display: block;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ margin-bottom: 0.5rem;
+}
+
+.form-group input[type="text"],
+.form-group input[type="number"],
+.form-group input[type="color"],
+.form-group select,
+.form-group textarea {
+ width: 100%;
+ padding: 0.75rem 1rem;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius);
+ font-size: 0.875rem;
+ transition: var(--transition);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+}
+
+.form-group input[type="text"]:focus,
+.form-group input[type="number"]:focus,
+.form-group input[type="color"]:focus,
+.form-group select:focus,
+.form-group textarea:focus {
+ outline: none;
+ border-color: var(--primary-light);
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+}
+
+.form-group input[type="color"] {
+ padding: 0.25rem;
+ height: 2.5rem;
+ cursor: pointer;
+}
+
+/* Checkbox Group */
+.checkbox-group {
+ display: flex;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.checkbox-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ user-select: none;
+}
+
+.checkbox-item input[type="checkbox"] {
+ width: 1rem;
+ height: 1rem;
+ accent-color: var(--primary-light);
+ cursor: pointer;
+}
+
+.checkbox-item span {
+ font-size: 0.875rem;
+ color: var(--text-primary);
+}
+
+/* Advanced Options */
+.advanced-options details {
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius);
+ background: var(--bg-secondary);
+}
+
+.advanced-options summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ cursor: pointer;
+ font-weight: 500;
+ color: var(--text-secondary);
+ list-style: none;
+}
+
+.advanced-options summary::-webkit-details-marker {
+ display: none;
+}
+
+.advanced-options summary i {
+ color: var(--text-muted);
+}
+
+.advanced-options summary .arrow {
+ transition: transform 0.2s ease;
+}
+
+.advanced-options details[open] summary .arrow {
+ transform: rotate(180deg);
+}
+
+.advanced-options-content {
+ padding: 1rem;
+ border-top: 1px solid var(--border-color);
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+}
+
+@media (max-width: 640px) {
+ .advanced-options-content {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.75rem 1.5rem;
+ font-size: 0.875rem;
+ font-weight: 600;
+ border-radius: var(--radius);
+ border: none;
+ cursor: pointer;
+ transition: var(--transition);
+ text-decoration: none;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, var(--primary-color), var(--primary-light));
+ color: white;
+ box-shadow: var(--shadow-sm);
+}
+
+.btn-primary:hover:not(:disabled) {
+ box-shadow: var(--shadow-md);
+ transform: translateY(-1px);
+}
+
+.btn-secondary {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background: var(--border-color);
+}
+
+.btn-purple {
+ background: linear-gradient(135deg, #7c3aed, var(--purple-color));
+ color: white;
+}
+
+.btn-purple:hover:not(:disabled) {
+ box-shadow: var(--shadow-md);
+}
+
+.btn-danger-outline {
+ background: transparent;
+ color: var(--danger-color);
+ border: 1px solid var(--danger-color);
+}
+
+.btn-danger-outline:hover:not(:disabled) {
+ background: var(--danger-color);
+ color: white;
+}
+
+.btn-full {
+ width: 100%;
+ margin-top: 1rem;
+}
+
+.button-loading {
+ position: relative;
+ pointer-events: none;
+}
+
+.button-loading::after {
+ content: '';
+ position: absolute;
+ width: 1rem;
+ height: 1rem;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-top-color: white;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.btn-group {
+ display: flex;
+ gap: 0.75rem;
+ margin-top: 1.25rem;
+}
+
+@media (max-width: 640px) {
+ .btn-group {
+ flex-direction: column;
+ }
+
+ .btn-group .btn {
+ width: 100%;
+ }
+}
+
+/* Progress Section */
+.progress-section {
+ display: none;
+}
+
+.progress-section.visible {
+ display: block;
+}
+
+.progress-header {
+ margin-bottom: 1.5rem;
+}
+
+.stage-info {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+.stage-info span:first-child {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.stage-info span:last-child {
+ font-weight: 700;
+ color: var(--primary-light);
+ font-size: 1.125rem;
+}
+
+.progress-bar-container {
+ height: 0.75rem;
+ background: var(--bg-tertiary);
+ border-radius: 9999px;
+ overflow: hidden;
+}
+
+.progress-bar {
+ height: 100%;
+ background: linear-gradient(90deg, var(--primary-color), var(--primary-light));
+ border-radius: 9999px;
+ transition: width 0.3s ease;
+ position: relative;
+}
+
+.progress-bar::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(255, 255, 255, 0.3),
+ transparent
+ );
+ animation: shimmer 2s infinite;
+}
+
+@keyframes shimmer {
+ 0% {
+ transform: translateX(-100%);
+ }
+ 100% {
+ transform: translateX(100%);
+ }
+}
+
+/* Stats Panel */
+.stats-panel {
+ background: var(--bg-secondary);
+ border-radius: var(--radius-lg);
+ padding: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 1rem;
+}
+
+@media (max-width: 640px) {
+ .stats-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.stats-item {
+ text-align: center;
+}
+
+.stats-item .label {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.025em;
+}
+
+.stats-item .value {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.stats-item .value.blue {
+ color: var(--primary-light);
+}
+
+.stats-item .value.green {
+ color: var(--success-color);
+}
+
+.stats-item .value.red {
+ color: var(--danger-color);
+}
+
+/* Log Panel */
+.log-panel {
+ background: var(--bg-secondary);
+ border-radius: var(--radius-lg);
+ padding: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+.log-panel h3 {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 0.75rem;
+}
+
+.log-container {
+ background: var(--text-primary);
+ color: #e2e8f0;
+ padding: 0.75rem;
+ border-radius: var(--radius);
+ font-family: 'Consolas', 'Monaco', monospace;
+ font-size: 0.75rem;
+ line-height: 1.6;
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+.log-container p {
+ margin: 0;
+ padding: 0.125rem 0;
+}
+
+.log-container::-webkit-scrollbar {
+ width: 0.375rem;
+}
+
+.log-container::-webkit-scrollbar-track {
+ background: #334155;
+}
+
+.log-container::-webkit-scrollbar-thumb {
+ background: #64748b;
+ border-radius: 0.1875rem;
+}
+
+/* Result Section */
+.result-section {
+ display: none;
+}
+
+.result-section.visible {
+ display: block;
+}
+
+.result-stats {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+.result-stat-item {
+ background: var(--bg-secondary);
+ padding: 1rem;
+ border-radius: var(--radius-lg);
+ text-align: center;
+}
+
+.result-stat-item .label {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.025em;
+ margin-bottom: 0.25rem;
+}
+
+.result-stat-item .value {
+ font-size: 1.5rem;
+ font-weight: 700;
+}
+
+.result-stat-item.blue .value {
+ color: var(--primary-light);
+}
+
+.result-stat-item.green .value {
+ color: var(--success-color);
+}
+
+/* Download Section */
+.download-section {
+ margin-bottom: 1rem;
+}
+
+.download-section h3 {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 0.75rem;
+}
+
+.download-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.download-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ background: var(--bg-secondary);
+ border-radius: var(--radius);
+ border: 1px solid var(--border-color);
+ text-decoration: none;
+ transition: var(--transition);
+}
+
+.download-item:hover {
+ border-color: var(--success-color);
+ background: rgba(34, 197, 94, 0.02);
+}
+
+.download-item-left {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.download-item-left i {
+ font-size: 1.5rem;
+ color: var(--success-color);
+}
+
+.download-item-details .name {
+ font-weight: 600;
+ color: var(--text-primary);
+ font-size: 0.875rem;
+}
+
+.download-item-details .hint {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+}
+
+.download-icon {
+ color: var(--success-color);
+ font-size: 1.125rem;
+}
+
+/* Task List Section */
+.task-list-section {
+ margin-top: 1.5rem;
+}
+
+.task-list-section h2 {
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin-bottom: 1rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.task-list-section h2 i {
+ color: var(--primary-light);
+}
+
+.task-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.task-list .empty {
+ text-align: center;
+ color: var(--text-muted);
+ padding: 2rem;
+ font-size: 0.875rem;
+}
+
+.task-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ background: var(--bg-primary);
+ border-radius: var(--radius);
+ border: 1px solid var(--border-color);
+ transition: var(--transition);
+ cursor: pointer;
+}
+
+.task-item:hover {
+ border-color: var(--primary-light);
+ box-shadow: var(--shadow-sm);
+}
+
+.task-item-left {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ min-width: 0;
+}
+
+.task-item-left i {
+ font-size: 1.25rem;
+}
+
+.task-item-left i.completed {
+ color: var(--success-color);
+}
+
+.task-item-left i.failed {
+ color: var(--danger-color);
+}
+
+.task-item-left i.cancelled {
+ color: var(--warning-color);
+}
+
+.task-item-left i.processing {
+ color: var(--primary-light);
+ animation: spin 2s linear infinite;
+}
+
+.task-item-left i.pending {
+ color: var(--text-muted);
+}
+
+.task-item-details {
+ min-width: 0;
+}
+
+.task-item-details .filename {
+ font-weight: 500;
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.task-item-details .meta {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+}
+
+.task-item-actions {
+ display: flex;
+ gap: 0.25rem;
+}
+
+.task-action-btn {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ padding: 0.375rem;
+ border-radius: var(--radius);
+ font-size: 1rem;
+ transition: var(--transition);
+}
+
+.task-action-btn:hover {
+ background: var(--bg-tertiary);
+ color: var(--primary-light);
+}
+
+/* Features Section */
+.features-section {
+ margin-top: 3rem;
+}
+
+.features-section h2 {
+ text-align: center;
+ font-size: 1.75rem;
+ font-weight: 800;
+ color: var(--text-primary);
+ margin-bottom: 2rem;
+}
+
+.features-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 1.5rem;
+}
+
+@media (max-width: 1024px) {
+ .features-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (max-width: 640px) {
+ .features-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.feature-card {
+ background: var(--bg-primary);
+ border-radius: var(--radius-xl);
+ padding: 1.5rem;
+ border: 1px solid var(--border-color);
+ box-shadow: var(--shadow-sm);
+ transition: var(--transition);
+}
+
+.feature-card:hover {
+ box-shadow: var(--shadow-md);
+ transform: translateY(-2px);
+}
+
+.feature-icon {
+ width: 3rem;
+ height: 3rem;
+ border-radius: var(--radius-lg);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 1rem;
+}
+
+.feature-icon i {
+ font-size: 1.5rem;
+ color: white;
+}
+
+.feature-icon.blue {
+ background: linear-gradient(135deg, var(--primary-color), var(--primary-light));
+}
+
+.feature-icon.green {
+ background: linear-gradient(135deg, #15803d, var(--success-color));
+}
+
+.feature-icon.purple {
+ background: linear-gradient(135deg, #6d28d9, var(--purple-color));
+}
+
+.feature-icon.orange {
+ background: linear-gradient(135deg, #c2410c, var(--orange-color));
+}
+
+.feature-icon.red {
+ background: linear-gradient(135deg, #b91c1c, var(--danger-color));
+}
+
+.feature-icon.cyan {
+ background: linear-gradient(135deg, #0e7490, var(--cyan-color));
+}
+
+.feature-card h3 {
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin-bottom: 0.5rem;
+}
+
+.feature-card p {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ line-height: 1.6;
+}
+
+/* Footer */
+footer {
+ background: var(--text-primary);
+ color: var(--text-muted);
+ padding: 2rem 1rem;
+ margin-top: 3rem;
+}
+
+.footer-content {
+ max-width: 1400px;
+ margin: 0 auto;
+ text-align: center;
+}
+
+.footer-content p {
+ margin-bottom: 0.5rem;
+ font-size: 0.875rem;
+}
+
+.footer-content p:last-child {
+ margin-bottom: 0;
+ color: var(--primary-light);
+}
+
+/* Notification */
+.notification {
+ position: fixed;
+ top: 5rem;
+ right: 1rem;
+ z-index: 1000;
+ opacity: 0;
+ transform: translateX(100%);
+ transition: all 0.3s ease;
+}
+
+.notification.visible {
+ opacity: 1;
+ transform: translateX(0);
+}
+
+.notification-content {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 1rem 1.25rem;
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+}
+
+.notification-content.info {
+ background: #dbeafe;
+ color: #1e40af;
+}
+
+.notification-content.success {
+ background: #dcfce7;
+ color: #166534;
+}
+
+.notification-content.error {
+ background: #fee2e2;
+ color: #991b1b;
+}
+
+.notification-content.warning {
+ background: #fef3c7;
+ color: #92400e;
+}
+
+.notification-content i {
+ font-size: 1.25rem;
+}
+
+/* Hidden */
+.hidden {
+ display: none !important;
+}
+
+/* Editor Styles */
+.editor-container {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.editor-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.editor-info {
+ flex: 1;
+}
+
+.editor-info .filename {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.editor-info .meta {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+}
+
+.editor-toolbar {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.editor-toolbar .btn {
+ padding: 0.5rem 1rem;
+ font-size: 0.75rem;
+}
+
+.segment-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.segment-item {
+ display: flex;
+ flex-direction: column;
+ padding: 0.75rem;
+ background: var(--bg-primary);
+ border-radius: var(--radius);
+ border: 1px solid var(--border-color);
+ transition: var(--transition);
+}
+
+.segment-item:hover {
+ border-color: var(--primary-light);
+}
+
+.segment-item.selected {
+ border-color: var(--primary-light);
+ background: rgba(59, 130, 246, 0.02);
+}
+
+.segment-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.5rem;
+}
+
+.segment-index {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--primary-light);
+ background: rgba(59, 130, 246, 0.1);
+ padding: 0.125rem 0.5rem;
+ border-radius: 9999px;
+}
+
+.segment-time {
+ font-family: 'Consolas', monospace;
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ background: var(--bg-secondary);
+ padding: 0.25rem 0.5rem;
+ border-radius: var(--radius);
+}
+
+.segment-text {
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius);
+ font-size: 0.875rem;
+ resize: vertical;
+ min-height: 60px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+}
+
+.segment-text:focus {
+ outline: none;
+ border-color: var(--primary-light);
+}
+
+.segment-actions {
+ display: flex;
+ gap: 0.25rem;
+ margin-top: 0.5rem;
+}
+
+.segment-action-btn {
+ background: var(--bg-tertiary);
+ border: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ padding: 0.25rem 0.5rem;
+ border-radius: var(--radius);
+ font-size: 0.75rem;
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ transition: var(--transition);
+}
+
+.segment-action-btn:hover {
+ background: var(--border-color);
+ color: var(--text-primary);
+}
+
+.segment-action-btn.primary {
+ background: var(--primary-light);
+ color: white;
+}
+
+.segment-action-btn.primary:hover {
+ background: var(--primary-color);
+}
+
+.preview-section {
+ background: var(--bg-secondary);
+ border-radius: var(--radius-lg);
+ padding: 1.5rem;
+}
+
+.preview-section h3 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 1rem;
+}
+
+.preview-container {
+ position: relative;
+ width: 100%;
+ background: #000;
+ border-radius: var(--radius);
+ overflow: hidden;
+ aspect-ratio: 16 / 9;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.preview-video {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ background: #000;
+}
+
+.preview-subtitle {
+ position: absolute;
+ bottom: 2rem;
+ left: 50%;
+ transform: translateX(-50%);
+ text-align: center;
+ max-width: 90%;
+}
+
+.preview-text {
+ display: inline-block;
+ padding: 0.5rem 1rem;
+ background: rgba(0, 0, 0, 0.75);
+ color: white;
+ font-size: 1.25rem;
+ font-weight: 500;
+ text-shadow: 0 0 3px rgba(0, 0, 0, 0.8);
+ white-space: pre-wrap;
+ line-height: 1.4;
+}
+
+.preview-controls {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-top: 1rem;
+}
+
+.preview-btn {
+ background: var(--primary-light);
+ border: none;
+ color: white;
+ cursor: pointer;
+ padding: 0.5rem 1rem;
+ border-radius: var(--radius);
+ font-size: 0.875rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.preview-btn:hover {
+ background: var(--primary-color);
+}
+
+.time-display {
+ font-family: 'Consolas', monospace;
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ background: var(--bg-tertiary);
+ padding: 0.25rem 0.75rem;
+ border-radius: var(--radius);
+}
+
+.bilingual-settings {
+ background: var(--bg-secondary);
+ border-radius: var(--radius-lg);
+ padding: 1.25rem;
+}
+
+.bilingual-settings h3 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 1rem;
+}
+
+.bilingual-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+}
+
+@media (max-width: 640px) {
+ .bilingual-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.style-panel {
+ background: var(--bg-secondary);
+ border-radius: var(--radius-lg);
+ padding: 1.25rem;
+}
+
+.style-panel h3 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 1rem;
+}
+
+.style-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+}
+
+@media (max-width: 640px) {
+ .style-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.export-panel {
+ background: var(--bg-secondary);
+ border-radius: var(--radius-lg);
+ padding: 1.25rem;
+}
+
+.export-panel h3 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 1rem;
+}
+
+.export-options {
+ display: flex;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.export-option {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ padding: 0.5rem 1rem;
+ background: var(--bg-primary);
+ border-radius: var(--radius);
+ border: 1px solid var(--border-color);
+ transition: var(--transition);
+}
+
+.export-option:hover {
+ border-color: var(--primary-light);
+}
+
+.export-option input {
+ accent-color: var(--primary-light);
+}
+
+.embed-options {
+ margin-top: 1rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--border-color);
+}
+
+.embed-options h4 {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 0.75rem;
+}
+
+.embed-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.75rem;
+}
+
+@media (max-width: 640px) {
+ .embed-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Batch Styles */
+.batch-container {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.batch-upload-area {
+ border: 2px dashed var(--border-color);
+ border-radius: var(--radius-lg);
+ padding: 2rem 1.5rem;
+ text-align: center;
+ cursor: pointer;
+ transition: var(--transition);
+ background: var(--bg-secondary);
+}
+
+.batch-upload-area:hover {
+ border-color: var(--primary-light);
+ background: rgba(59, 130, 246, 0.02);
+}
+
+.batch-upload-area.upload-dragover {
+ border-color: var(--primary-light);
+ background: rgba(59, 130, 246, 0.05);
+}
+
+.batch-upload-area i {
+ font-size: 2.5rem;
+ color: var(--text-muted);
+ margin-bottom: 0.75rem;
+}
+
+.batch-upload-area p {
+ font-size: 1rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+ margin-bottom: 0.25rem;
+}
+
+.batch-upload-area .hint {
+ font-size: 0.875rem;
+ color: var(--text-muted);
+ font-weight: 400;
+ margin-bottom: 0;
+}
+
+.batch-file-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.batch-file-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ background: var(--bg-primary);
+ border-radius: var(--radius);
+ border: 1px solid var(--border-color);
+}
+
+.batch-file-left {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ min-width: 0;
+}
+
+.batch-file-left i {
+ font-size: 1.5rem;
+ color: var(--primary-light);
+}
+
+.batch-file-info {
+ min-width: 0;
+}
+
+.batch-file-info .filename {
+ font-weight: 500;
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.batch-file-info .filesize {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+}
+
+.batch-file-remove {
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ padding: 0.25rem;
+ border-radius: 9999px;
+ font-size: 1.125rem;
+ transition: var(--transition);
+}
+
+.batch-file-remove:hover {
+ color: var(--danger-color);
+ background: rgba(239, 68, 68, 0.1);
+}
+
+.batch-stats {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 1rem;
+}
+
+@media (max-width: 768px) {
+ .batch-stats {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+.batch-stat-item {
+ text-align: center;
+ padding: 1rem;
+ background: var(--bg-secondary);
+ border-radius: var(--radius-lg);
+}
+
+.batch-stat-item .label {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.025em;
+ margin-bottom: 0.25rem;
+}
+
+.batch-stat-item .value {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.batch-progress-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.batch-progress-item {
+ padding: 1rem;
+ background: var(--bg-primary);
+ border-radius: var(--radius);
+ border: 1px solid var(--border-color);
+}
+
+.batch-progress-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.5rem;
+}
+
+.batch-progress-info {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ min-width: 0;
+}
+
+.batch-progress-info i {
+ font-size: 1.25rem;
+}
+
+.batch-progress-info i.pending {
+ color: var(--text-muted);
+}
+
+.batch-progress-info i.processing {
+ color: var(--primary-light);
+ animation: spin 2s linear infinite;
+}
+
+.batch-progress-info i.completed {
+ color: var(--success-color);
+}
+
+.batch-progress-info i.failed {
+ color: var(--danger-color);
+}
+
+.batch-progress-info i.cancelled {
+ color: var(--warning-color);
+}
+
+.batch-progress-details .filename {
+ font-weight: 500;
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.batch-progress-details .stage {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+}
+
+.batch-progress-percent {
+ font-weight: 700;
+ color: var(--primary-light);
+ font-size: 1rem;
+}
+
+.batch-progress-bar-container {
+ height: 0.5rem;
+ background: var(--bg-tertiary);
+ border-radius: 9999px;
+ overflow: hidden;
+}
+
+.batch-progress-bar {
+ height: 100%;
+ background: linear-gradient(90deg, var(--primary-color), var(--primary-light));
+ border-radius: 9999px;
+ transition: width 0.3s ease;
+}
+
+.batch-actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: center;
+}
+
+/* Empty State */
+.empty-state {
+ text-align: center;
+ padding: 3rem 1rem;
+}
+
+.empty-state i {
+ font-size: 4rem;
+ color: var(--text-muted);
+ margin-bottom: 1rem;
+}
+
+.empty-state h3 {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 0.5rem;
+}
+
+.empty-state p {
+ font-size: 0.875rem;
+ color: var(--text-muted);
+}
diff --git a/web/static/css/tailwind-local.css b/web/static/css/tailwind-local.css
new file mode 100644
index 0000000..e1473cf
--- /dev/null
+++ b/web/static/css/tailwind-local.css
@@ -0,0 +1,1121 @@
+/* 基础样式 */
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ line-height: 1.5;
+ color: #1f2937;
+ background-color: #f9fafb;
+ margin: 0;
+ padding: 0;
+}
+
+/* 导航栏 */
+nav {
+ background-color: #ffffff;
+ box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
+ border-bottom: 1px solid #e5e7eb;
+}
+
+nav .nav-container {
+ max-width: 80rem;
+ margin: 0 auto;
+ padding: 0 1rem;
+}
+
+nav .nav-content {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ height: 4rem;
+}
+
+nav .logo {
+ display: flex;
+ align-items: center;
+}
+
+nav .logo i {
+ font-size: 1.5rem;
+ color: #3b82f6;
+ margin-right: 0.75rem;
+}
+
+nav .logo .title {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: #111827;
+}
+
+nav .logo .subtitle {
+ font-size: 0.875rem;
+ color: #6b7280;
+ margin-left: 0.5rem;
+}
+
+nav .nav-links {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+nav .nav-links a {
+ color: #4b5563;
+ text-decoration: none;
+ font-weight: 500;
+ padding: 0.5rem;
+ border-radius: 0.375rem;
+ transition: color 0.15s, background-color 0.15s;
+}
+
+nav .nav-links a:hover {
+ color: #111827;
+ background-color: #f3f4f6;
+}
+
+nav .nav-links a.active {
+ color: #3b82f6;
+}
+
+/* 主容器 */
+.main-container {
+ max-width: 80rem;
+ margin: 0 auto;
+ padding: 2rem 1rem;
+}
+
+/* 标题区域 */
+.header-section {
+ text-align: center;
+ margin-bottom: 3rem;
+}
+
+.header-section h1 {
+ font-size: 2.25rem;
+ font-weight: 700;
+ color: #111827;
+ margin-bottom: 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+.header-section h1 i {
+ color: #3b82f6;
+}
+
+.header-section p {
+ font-size: 1.125rem;
+ color: #4b5563;
+ max-width: 42rem;
+ margin: 0 auto;
+}
+
+/* 两列布局 */
+.two-columns {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 2rem;
+}
+
+@media (min-width: 1024px) {
+ .two-columns {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+/* 卡片样式 */
+.card {
+ background-color: #ffffff;
+ border-radius: 0.75rem;
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
+ padding: 1.5rem;
+}
+
+.card h2 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #111827;
+ margin-bottom: 1.5rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.card h2 i {
+ color: #3b82f6;
+ font-size: 1.5rem;
+}
+
+/* 上传区域 */
+.upload-area {
+ border: 2px dashed #d1d5db;
+ border-radius: 0.5rem;
+ padding: 2rem;
+ text-align: center;
+ cursor: pointer;
+ transition: border-color 0.15s, background-color 0.15s;
+}
+
+.upload-area:hover {
+ border-color: #3b82f6;
+ background-color: #eff6ff;
+}
+
+.upload-area.upload-dragover {
+ border-color: #3b82f6 !important;
+ background-color: #eff6ff !important;
+}
+
+.upload-area.hidden {
+ display: none;
+}
+
+.upload-area i {
+ font-size: 3rem;
+ color: #9ca3af;
+ margin-bottom: 1rem;
+}
+
+.upload-area p {
+ color: #4b5563;
+ margin-bottom: 0.5rem;
+}
+
+.upload-area .hint {
+ font-size: 0.875rem;
+ color: #9ca3af;
+}
+
+/* 文件信息 */
+.file-info {
+ padding: 1rem;
+ background-color: #f9fafb;
+ border-radius: 0.5rem;
+ margin-top: 1rem;
+ display: none;
+}
+
+.file-info.visible {
+ display: block;
+}
+
+.file-info-content {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.file-info-left {
+ display: flex;
+ align-items: center;
+}
+
+.file-info-left i {
+ font-size: 1.5rem;
+ color: #3b82f6;
+ margin-right: 0.75rem;
+}
+
+.file-info-details .filename {
+ font-weight: 500;
+ color: #111827;
+}
+
+.file-info-details .filesize {
+ font-size: 0.875rem;
+ color: #6b7280;
+}
+
+.remove-file-btn {
+ color: #ef4444;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0.25rem;
+ border-radius: 0.25rem;
+ transition: color 0.15s, background-color 0.15s;
+}
+
+.remove-file-btn:hover {
+ color: #dc2626;
+ background-color: #fef2f2;
+}
+
+.remove-file-btn i {
+ font-size: 1.25rem;
+}
+
+/* 表单样式 */
+.form-section {
+ margin-top: 1.5rem;
+}
+
+.form-section h3 {
+ font-size: 1.125rem;
+ font-weight: 500;
+ color: #111827;
+ margin-bottom: 1rem;
+}
+
+.form-grid-2 {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+@media (min-width: 640px) {
+ .form-grid-2 {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+.form-group {
+ margin-bottom: 1rem;
+}
+
+.form-group label {
+ display: block;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: #374151;
+ margin-bottom: 0.25rem;
+}
+
+.form-group input[type="text"],
+.form-group input[type="number"],
+.form-group input[type="color"],
+.form-group select {
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.5rem;
+ font-size: 1rem;
+ transition: border-color 0.15s, box-shadow 0.15s;
+ background-color: #ffffff;
+}
+
+.form-group input[type="text"]:focus,
+.form-group input[type="number"]:focus,
+.form-group input[type="color"]:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: #3b82f6;
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
+}
+
+.form-group input[type="color"] {
+ padding: 0.25rem;
+ height: 2.5rem;
+ cursor: pointer;
+}
+
+/* 复选框组 */
+.checkbox-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.checkbox-item {
+ display: inline-flex;
+ align-items: center;
+}
+
+.checkbox-item input[type="checkbox"] {
+ width: 1rem;
+ height: 1rem;
+ margin-right: 0.5rem;
+ color: #3b82f6;
+ border-radius: 0.25rem;
+ border-color: #d1d5db;
+ cursor: pointer;
+}
+
+.checkbox-item input[type="checkbox"]:checked {
+ background-color: #3b82f6;
+ border-color: #3b82f6;
+}
+
+.checkbox-item span {
+ font-size: 0.875rem;
+ color: #374151;
+}
+
+/* 高级选项面板 */
+.advanced-options {
+ border-top: 1px solid #e5e7eb;
+ padding-top: 1rem;
+}
+
+.advanced-options details {
+ cursor: pointer;
+}
+
+.advanced-options summary {
+ display: flex;
+ align-items: center;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: #374151;
+ list-style: none;
+}
+
+.advanced-options summary::-webkit-details-marker {
+ display: none;
+}
+
+.advanced-options summary i {
+ margin-right: 0.5rem;
+ color: #6b7280;
+}
+
+.advanced-options summary .arrow {
+ margin-left: auto;
+ transition: transform 0.2s;
+}
+
+.advanced-options details[open] summary .arrow {
+ transform: rotate(180deg);
+}
+
+.advanced-options-content {
+ margin-top: 1rem;
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 1rem;
+}
+
+@media (min-width: 640px) {
+ .advanced-options-content {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+/* 按钮样式 */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.75rem 1.5rem;
+ font-size: 1rem;
+ font-weight: 500;
+ border-radius: 0.5rem;
+ border: none;
+ cursor: pointer;
+ transition: all 0.15s;
+ text-decoration: none;
+}
+
+.btn i {
+ margin-right: 0.5rem;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background-color: #3b82f6;
+ color: #ffffff;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background-color: #2563eb;
+}
+
+.btn-secondary {
+ background-color: #ffffff;
+ color: #374151;
+ border: 1px solid #d1d5db;
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background-color: #f9fafb;
+}
+
+.btn-danger {
+ background-color: #ef4444;
+ color: #ffffff;
+}
+
+.btn-danger:hover:not(:disabled) {
+ background-color: #dc2626;
+}
+
+.btn-danger-outline {
+ background-color: #ffffff;
+ color: #ef4444;
+ border: 1px solid #fca5a5;
+}
+
+.btn-danger-outline:hover:not(:disabled) {
+ background-color: #fef2f2;
+}
+
+.btn-success {
+ background-color: #22c55e;
+ color: #ffffff;
+}
+
+.btn-success:hover:not(:disabled) {
+ background-color: #16a34a;
+}
+
+.btn-purple {
+ background-color: #9333ea;
+ color: #ffffff;
+}
+
+.btn-purple:hover:not(:disabled) {
+ background-color: #7e22ce;
+}
+
+.btn-full {
+ width: 100%;
+ margin-top: 1.5rem;
+}
+
+.btn-group {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.btn-group .btn {
+ flex: 1;
+}
+
+/* 加载按钮 */
+.button-loading {
+ position: relative;
+ pointer-events: none;
+}
+
+.button-loading::after {
+ content: '';
+ position: absolute;
+ width: 1rem;
+ height: 1rem;
+ 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);
+ }
+}
+
+/* 进度区域 */
+.progress-section {
+ display: none;
+}
+
+.progress-section.visible {
+ display: block;
+}
+
+/* 进度条 */
+.progress-header {
+ margin-bottom: 1rem;
+}
+
+.progress-header .stage-info {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.875rem;
+ color: #4b5563;
+ margin-bottom: 0.5rem;
+}
+
+.progress-bar-container {
+ width: 100%;
+ background-color: #e5e7eb;
+ border-radius: 9999px;
+ height: 0.75rem;
+ overflow: hidden;
+}
+
+.progress-bar {
+ height: 100%;
+ background-color: #3b82f6;
+ border-radius: 9999px;
+ transition: width 0.3s ease;
+ 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;
+ }
+}
+
+/* 统计面板 */
+.stats-panel {
+ padding: 0.75rem;
+ background-color: #f9fafb;
+ border-radius: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 1rem;
+ text-align: center;
+ font-size: 0.875rem;
+}
+
+.stats-item .label {
+ color: #6b7280;
+ margin-bottom: 0.25rem;
+}
+
+.stats-item .value {
+ font-weight: 500;
+ color: #111827;
+}
+
+.stats-item .value.blue {
+ color: #3b82f6;
+}
+
+.stats-item .value.green {
+ color: #16a34a;
+}
+
+.stats-item .value.red {
+ color: #dc2626;
+}
+
+.stats-item .value.yellow {
+ color: #ca8a04;
+}
+
+/* 日志面板 */
+.log-panel {
+ border-top: 1px solid #e5e7eb;
+ padding-top: 1rem;
+}
+
+.log-panel h3 {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: #374151;
+ margin-bottom: 0.5rem;
+}
+
+.log-container {
+ background-color: #111827;
+ color: #4ade80;
+ padding: 0.75rem;
+ border-radius: 0.5rem;
+ height: 12rem;
+ overflow-y: auto;
+ font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
+ font-size: 0.75rem;
+ line-height: 1.5;
+}
+
+.log-container p {
+ margin: 0;
+ padding: 0.125rem 0;
+ animation: fadeIn 0.3s ease;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(-5px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* 结果区域 */
+.result-section {
+ display: none;
+}
+
+.result-section.visible {
+ display: block;
+}
+
+.result-stats {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+.result-stat-item {
+ padding: 1rem;
+ border-radius: 0.5rem;
+}
+
+.result-stat-item.blue {
+ background-color: #eff6ff;
+}
+
+.result-stat-item.green {
+ background-color: #f0fdf4;
+}
+
+.result-stat-item .label {
+ font-size: 0.875rem;
+ color: #2563eb;
+ margin-bottom: 0.25rem;
+}
+
+.result-stat-item.blue .label {
+ color: #2563eb;
+}
+
+.result-stat-item.green .label {
+ color: #16a34a;
+}
+
+.result-stat-item .value {
+ font-size: 1.125rem;
+ font-weight: 600;
+}
+
+.result-stat-item.blue .value {
+ color: #1e3a8a;
+}
+
+.result-stat-item.green .value {
+ color: #14532d;
+}
+
+/* 下载列表 */
+.download-section {
+ margin-bottom: 1.5rem;
+}
+
+.download-section h3 {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: #374151;
+ margin-bottom: 0.75rem;
+}
+
+.download-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.download-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem;
+ background-color: #f9fafb;
+ border-radius: 0.5rem;
+ transition: background-color 0.15s;
+ text-decoration: none;
+ color: inherit;
+}
+
+.download-item:hover {
+ background-color: #f3f4f6;
+}
+
+.download-item-left {
+ display: flex;
+ align-items: center;
+}
+
+.download-item-left i {
+ font-size: 1.25rem;
+ color: #3b82f6;
+ margin-right: 0.75rem;
+}
+
+.download-item-details .name {
+ font-weight: 500;
+ color: #111827;
+}
+
+.download-item-details .hint {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.download-item i.download-icon {
+ color: #9ca3af;
+}
+
+/* 任务列表 */
+.task-list-section {
+ background-color: #ffffff;
+ border-radius: 0.75rem;
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
+ padding: 1.5rem;
+}
+
+.task-list-section h2 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #111827;
+ margin-bottom: 1rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.task-list-section h2 i {
+ color: #3b82f6;
+}
+
+.task-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.task-list .empty {
+ text-align: center;
+ padding: 1rem;
+ color: #6b7280;
+}
+
+.task-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem;
+ background-color: #f9fafb;
+ border-radius: 0.5rem;
+ transition: background-color 0.15s;
+ cursor: pointer;
+}
+
+.task-item:hover {
+ background-color: #f3f4f6;
+}
+
+.task-item-left {
+ display: flex;
+ align-items: center;
+}
+
+.task-item-left i {
+ font-size: 1.25rem;
+ margin-right: 0.75rem;
+}
+
+.task-item-left i.pending {
+ color: #9ca3af;
+}
+
+.task-item-left i.processing {
+ color: #3b82f6;
+ animation: spin 1s linear infinite;
+}
+
+.task-item-left i.completed {
+ color: #22c55e;
+}
+
+.task-item-left i.failed {
+ color: #ef4444;
+}
+
+.task-item-left i.cancelled {
+ color: #eab308;
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.task-item-details .filename {
+ font-weight: 500;
+ color: #111827;
+ font-size: 0.875rem;
+}
+
+.task-item-details .meta {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.task-item-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.task-action-btn {
+ padding: 0.25rem;
+ border-radius: 0.25rem;
+ border: none;
+ background: none;
+ cursor: pointer;
+ color: #6b7280;
+ transition: color 0.15s, background-color 0.15s;
+}
+
+.task-action-btn:hover {
+ color: #3b82f6;
+ background-color: #eff6ff;
+}
+
+/* 功能特性区域 */
+.features-section {
+ margin-top: 3rem;
+}
+
+.features-section h2 {
+ font-size: 1.5rem;
+ font-weight: 700;
+ text-align: center;
+ color: #111827;
+ margin-bottom: 2rem;
+}
+
+.features-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 1.5rem;
+}
+
+@media (min-width: 768px) {
+ .features-grid {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+.feature-card {
+ background-color: #ffffff;
+ padding: 1.5rem;
+ border-radius: 0.75rem;
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
+ transition: box-shadow 0.15s;
+}
+
+.feature-card:hover {
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
+}
+
+.feature-icon {
+ width: 3rem;
+ height: 3rem;
+ border-radius: 0.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 1rem;
+}
+
+.feature-icon.blue {
+ background-color: #dbeafe;
+}
+
+.feature-icon.green {
+ background-color: #dcfce7;
+}
+
+.feature-icon.purple {
+ background-color: #f3e8ff;
+}
+
+.feature-icon.orange {
+ background-color: #ffedd5;
+}
+
+.feature-icon.red {
+ background-color: #fee2e2;
+}
+
+.feature-icon.cyan {
+ background-color: #cffafe;
+}
+
+.feature-icon i {
+ font-size: 1.5rem;
+}
+
+.feature-icon.blue i {
+ color: #2563eb;
+}
+
+.feature-icon.green i {
+ color: #16a34a;
+}
+
+.feature-icon.purple i {
+ color: #9333ea;
+}
+
+.feature-icon.orange i {
+ color: #ea580c;
+}
+
+.feature-icon.red i {
+ color: #dc2626;
+}
+
+.feature-icon.cyan i {
+ color: #0891b2;
+}
+
+.feature-card h3 {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: #111827;
+ margin-bottom: 0.5rem;
+}
+
+.feature-card p {
+ color: #4b5563;
+ font-size: 0.875rem;
+ line-height: 1.6;
+}
+
+/* 页脚 */
+footer {
+ background-color: #ffffff;
+ border-top: 1px solid #e5e7eb;
+ margin-top: 3rem;
+}
+
+.footer-content {
+ max-width: 80rem;
+ margin: 0 auto;
+ padding: 1.5rem 1rem;
+ text-align: center;
+ font-size: 0.875rem;
+ color: #6b7280;
+}
+
+.footer-content p {
+ margin-bottom: 0.25rem;
+}
+
+/* 通知 */
+.notification {
+ position: fixed;
+ top: 1rem;
+ right: 1rem;
+ z-index: 50;
+ display: none;
+}
+
+.notification.visible {
+ display: block;
+}
+
+.notification-content {
+ background-color: #ffffff;
+ border-radius: 0.5rem;
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
+ padding: 1rem;
+ border-left-width: 4px;
+ display: flex;
+ align-items: center;
+}
+
+.notification-content.info {
+ border-left-color: #3b82f6;
+}
+
+.notification-content.success {
+ border-left-color: #22c55e;
+}
+
+.notification-content.error {
+ border-left-color: #ef4444;
+}
+
+.notification-content.warning {
+ border-left-color: #eab308;
+}
+
+.notification-content i {
+ font-size: 1.25rem;
+ margin-right: 0.75rem;
+}
+
+.notification-content.info i {
+ color: #3b82f6;
+}
+
+.notification-content.success i {
+ color: #22c55e;
+}
+
+.notification-content.error i {
+ color: #ef4444;
+}
+
+.notification-content.warning i {
+ color: #eab308;
+}
+
+.notification-content span {
+ color: #374151;
+}
+
+/* 滚动条样式 */
+::-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;
+}
+
+/* 响应式调整 */
+@media (max-width: 640px) {
+ .header-section h1 {
+ font-size: 1.5rem;
+ }
+
+ .header-section p {
+ font-size: 1rem;
+ }
+
+ .card {
+ padding: 1rem;
+ }
+
+ .btn-group {
+ flex-direction: column;
+ }
+}
diff --git a/web/static/js/app.js b/web/static/js/app.js
new file mode 100644
index 0000000..66eded9
--- /dev/null
+++ b/web/static/js/app.js
@@ -0,0 +1,636 @@
+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.add('visible');
+ 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.remove('visible');
+ 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.add('visible');
+ resultSection.classList.remove('visible');
+ fileInfo.classList.remove('visible');
+ 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 = 'value green';
+ } else if (data.status === 'failed') {
+ taskStatus.textContent = '失败';
+ taskStatus.className = 'value red';
+ } else if (data.status === 'cancelled') {
+ taskStatus.textContent = '已取消';
+ taskStatus.className = 'value yellow';
+ } else {
+ taskStatus.textContent = '处理中';
+ taskStatus.className = 'value blue';
+ }
+
+ if (progress.logs && progress.logs.length > 0) {
+ const logContainer = document.getElementById('logContainer');
+ const lastLog = progress.logs[progress.logs.length - 1];
+ const logLines = logContainer.querySelectorAll('p');
+ let found = false;
+ for (let line of logLines) {
+ if (line.textContent === lastLog) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ const p = document.createElement('p');
+ p.textContent = lastLog;
+ 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;
+ 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 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: 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.remove('visible');
+ resultSection.classList.add('visible');
+
+ 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': '带字幕视频',
+ 'embedded_video': '带字幕视频'
+ };
+
+ const formatIcons = {
+ 'srt': 'ri-file-text-line',
+ 'ass': 'ri-file-3-line',
+ 'vtt': 'ri-file-code-line',
+ 'embedded': 'ri-video-download-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 = 'download-item';
+
+ 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.style.display = 'inline-flex';
+ } else {
+ editButton.style.display = 'none';
+ }
+
+ 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 = '';
+
+ for (const task of tasks) {
+ const item = document.createElement('div');
+ item.className = 'task-item';
+
+ let iconClass = '';
+ if (task.status === 'completed') {
+ iconClass = 'ri-checkbox-circle-line completed';
+ } else if (task.status === 'failed') {
+ iconClass = 'ri-close-circle-line failed';
+ } else if (task.status === 'cancelled') {
+ iconClass = 'ri-stop-circle-line cancelled';
+ } else if (task.status === 'processing') {
+ iconClass = 'ri-loader-4-line processing';
+ } else {
+ iconClass = 'ri-time-line pending';
+ }
+
+ 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.remove('visible');
+ resultSection.classList.remove('visible');
+ fileInfo.classList.remove('visible');
+ 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 notificationContent = notification.querySelector('.notification-content');
+
+ const icons = {
+ 'info': 'ri-information-line',
+ 'success': 'ri-checkbox-circle-line',
+ 'error': 'ri-error-warning-line',
+ 'warning': 'ri-alert-line'
+ };
+
+ const types = {
+ 'info': 'info',
+ 'success': 'success',
+ 'error': 'error',
+ 'warning': 'warning'
+ };
+
+ const iconColors = {
+ 'info': '#3b82f6',
+ 'success': '#22c55e',
+ 'error': '#ef4444',
+ 'warning': '#eab308'
+ };
+
+ notificationText.textContent = message;
+ notificationIcon.className = icons[type];
+ notificationIcon.style.color = iconColors[type];
+
+ notificationContent.className = `notification-content ${types[type]}`;
+
+ notification.classList.add('visible');
+
+ setTimeout(() => {
+ notification.classList.remove('visible');
+ }, 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..cfcd0d1
--- /dev/null
+++ b/web/static/js/editor.js
@@ -0,0 +1,1368 @@
+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.videoElement = null;
+ this.isPlaying = false;
+ this.currentSubtitleIndex = -1;
+ this.duration = 0;
+
+ this.init();
+ }
+
+ init() {
+ this.taskId = this.getQueryParam('task_id');
+ this.bindEvents();
+ this.initVideoPlayer();
+ this.loadSubtitles();
+ }
+
+ initVideoPlayer() {
+ const video = document.getElementById('videoPlayer');
+ const placeholder = document.getElementById('videoPlaceholder');
+
+ if (!video) {
+ console.error('[Editor] 未找到 video 元素');
+ return;
+ }
+
+ this.videoElement = video;
+
+ video.addEventListener('timeupdate', () => this.onTimeUpdate());
+ video.addEventListener('loadedmetadata', () => {
+ this.duration = video.duration;
+ const timeSlider = document.getElementById('timeSlider');
+ if (timeSlider) {
+ timeSlider.max = this.duration;
+ }
+ this.updateTimeDisplay();
+ });
+ video.addEventListener('play', () => {
+ this.isPlaying = true;
+ this.updatePlayButton();
+ });
+ video.addEventListener('pause', () => {
+ this.isPlaying = false;
+ this.updatePlayButton();
+ });
+ }
+
+ 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.bindVideoEvents();
+ this.bindStyleEvents();
+ }
+
+ bindVideoEvents() {
+ const playPauseBtn = document.getElementById('playPauseButton');
+ const timeSlider = document.getElementById('timeSlider');
+
+ if (playPauseBtn) {
+ playPauseBtn.addEventListener('click', () => {
+ if (!this.videoElement) return;
+ if (this.isPlaying) {
+ this.videoElement.pause();
+ } else {
+ this.videoElement.play();
+ }
+ });
+ }
+
+ if (timeSlider) {
+ timeSlider.addEventListener('input', (e) => {
+ if (!this.videoElement) return;
+ const newTime = parseFloat(e.target.value);
+ this.videoElement.currentTime = newTime;
+ });
+ }
+ }
+
+ 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());
+ }
+ });
+
+ this.updateStylePreview();
+ }
+
+ updateStylePreview() {
+ const fontSize = parseInt(document.getElementById('editorFontSize')?.value) || 48;
+ const fontColor = document.getElementById('editorFontColor')?.value || '#FFFFFF';
+ const outlineColor = document.getElementById('editorOutlineColor')?.value || '#000000';
+ const outlineWidth = parseInt(document.getElementById('editorOutlineWidth')?.value) || 2;
+ const position = document.getElementById('editorPosition')?.value || 'bottom';
+ const marginV = parseInt(document.getElementById('editorMarginV')?.value) || 40;
+
+ const fontColorValueEl = document.getElementById('fontColorValue');
+ const outlineColorValueEl = document.getElementById('outlineColorValue');
+
+ if (fontColorValueEl) {
+ fontColorValueEl.textContent = fontColor;
+ }
+ if (outlineColorValueEl) {
+ outlineColorValueEl.textContent = outlineColor;
+ }
+
+ const previewText = document.getElementById('previewText');
+ if (previewText) {
+ const previewSize = Math.min(Math.round(fontSize / 1.5), 36);
+ previewText.style.fontSize = `${previewSize}px`;
+ previewText.style.color = fontColor;
+ previewText.style.fontWeight = 'bold';
+
+ const shadowSize = Math.max(1, Math.round(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}
+ `;
+ }
+
+ this.updateSubtitleOverlayStyle();
+ }
+
+ updateSubtitleOverlayStyle() {
+ const fontSize = parseInt(document.getElementById('editorFontSize')?.value) || 48;
+ const fontColor = document.getElementById('editorFontColor')?.value || '#FFFFFF';
+ const outlineColor = document.getElementById('editorOutlineColor')?.value || '#000000';
+ const outlineWidth = parseInt(document.getElementById('editorOutlineWidth')?.value) || 2;
+ const position = document.getElementById('editorPosition')?.value || 'bottom';
+ const marginV = parseInt(document.getElementById('editorMarginV')?.value) || 40;
+
+ const subtitleOverlay = document.getElementById('subtitleOverlay');
+ const subtitleText = document.getElementById('currentSubtitleText');
+
+ if (subtitleOverlay) {
+ subtitleOverlay.style.left = '0';
+ subtitleOverlay.style.right = '0';
+ subtitleOverlay.style.textAlign = 'center';
+ subtitleOverlay.style.paddingLeft = '32px';
+ subtitleOverlay.style.paddingRight = '32px';
+ subtitleOverlay.style.pointerEvents = 'none';
+ subtitleOverlay.style.position = 'absolute';
+
+ if (position === 'top') {
+ subtitleOverlay.style.top = `${marginV + 10}px`;
+ subtitleOverlay.style.bottom = 'auto';
+ } else if (position === 'middle') {
+ subtitleOverlay.style.top = '50%';
+ subtitleOverlay.style.bottom = 'auto';
+ subtitleOverlay.style.transform = 'translateY(-50%)';
+ } else {
+ subtitleOverlay.style.bottom = `${marginV + 50}px`;
+ subtitleOverlay.style.top = 'auto';
+ subtitleOverlay.style.transform = 'none';
+ }
+ }
+
+ if (subtitleText) {
+ const videoPreview = document.getElementById('videoPreview');
+ const previewHeight = videoPreview ? videoPreview.offsetHeight : 360;
+ const scaledFontSize = Math.min(fontSize, Math.round(previewHeight / 10));
+
+ subtitleText.style.fontSize = `${scaledFontSize}px`;
+ subtitleText.style.color = fontColor;
+ subtitleText.style.fontWeight = 'bold';
+ subtitleText.style.display = 'none';
+ subtitleText.style.backgroundColor = 'transparent';
+ subtitleText.style.padding = '4px 8px';
+ subtitleText.style.borderRadius = '2px';
+
+ const shadowSize = Math.max(1, Math.round(outlineWidth / 2));
+ subtitleText.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}
+ `;
+ }
+ }
+
+ updateCurrentSubtitle(index) {
+ const subtitleText = document.getElementById('currentSubtitleText');
+ if (!subtitleText) return;
+
+ if (index >= 0 && index < this.subtitles.length) {
+ const sub = this.subtitles[index];
+
+ let displayHtml = '';
+
+ if (sub.is_bilingual) {
+ const text_zh = sub.text_zh || '';
+ const text_en = sub.text_en || '';
+
+ const has_zh = text_zh.trim().length > 0;
+ const has_en = text_en.trim().length > 0;
+
+ if (has_zh && has_en) {
+ displayHtml = `${this.escapeHtml(text_zh)}
${this.escapeHtml(text_en)}
`;
+ } else if (has_zh) {
+ displayHtml = `${this.escapeHtml(text_zh)}
`;
+ } else if (has_en) {
+ displayHtml = `${this.escapeHtml(text_en)}
`;
+ } else {
+ displayHtml = `${this.escapeHtml(sub.text)}
`;
+ }
+ } else {
+ displayHtml = `${this.escapeHtml(sub.text)}
`;
+ }
+
+ subtitleText.innerHTML = displayHtml;
+ subtitleText.style.display = 'inline-block';
+
+ this.updateSubtitleOverlayStyle();
+ } else {
+ subtitleText.innerHTML = '';
+ subtitleText.style.display = 'none';
+ }
+ }
+
+ 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();
+
+ console.log('[Editor] 加载任务数据:', task);
+
+ let segments = [];
+
+ if (task.result && task.result.subtitle_document && task.result.subtitle_document.segments) {
+ segments = task.result.subtitle_document.segments;
+ console.log('[Editor] 从 subtitle_document 加载:', segments.length, '段');
+ } else if (task.result && task.result.segments) {
+ segments = task.result.segments;
+ console.log('[Editor] 从 result.segments 加载:', segments.length, '段');
+ } else if (task.result && task.result.document_json) {
+ try {
+ const doc = JSON.parse(task.result.document_json);
+ segments = doc.segments || [];
+ console.log('[Editor] 从 document_json 加载:', segments.length, '段');
+ } catch (e) {
+ console.error('[Editor] 解析 document_json 失败:', e);
+ }
+ }
+
+ this.subtitles = segments.map((seg, index) => ({
+ id: seg.id !== undefined ? seg.id : index,
+ start: seg.start !== undefined ? seg.start : 0,
+ end: seg.end !== undefined ? seg.end : 0,
+ text: seg.text || '',
+ text_zh: seg.text_zh || '',
+ text_en: seg.text_en || '',
+ is_bilingual: seg.is_bilingual || false,
+ language: seg.language || 'unknown'
+ }));
+
+ this.originalSubtitles = JSON.parse(JSON.stringify(this.subtitles));
+
+ console.log('[Editor] 处理后的字幕:', this.subtitles.length, '段');
+
+ this.renderSubtitles();
+ this.updateStats();
+ this.saveState();
+
+ this.loadVideoPreview(task);
+
+ } catch (error) {
+ console.error('[Editor] 加载失败:', error);
+ this.showNotification('加载失败: ' + error.message, 'error');
+ this.renderEmptyState();
+ }
+ }
+
+ async loadVideoPreview(task) {
+ if (!this.videoElement || !task.input_files || task.input_files.length === 0) {
+ console.log('[Editor] 没有视频文件可加载');
+ return;
+ }
+
+ const inputFile = task.input_files[0];
+ if (!inputFile) return;
+
+ const filePath = inputFile.replace(/\\/g, '/');
+ const fileName = filePath.split('/').pop();
+
+ const videoUrl = `/api/video/${this.taskId}/${fileName}`;
+
+ console.log('[Editor] 尝试加载视频:', videoUrl);
+
+ this.videoElement.src = videoUrl;
+ this.videoElement.load();
+
+ this.videoElement.style.display = 'block';
+
+ const placeholder = document.getElementById('videoPlaceholder');
+ if (placeholder) {
+ placeholder.style.display = 'none';
+ }
+ }
+
+ onTimeUpdate() {
+ if (!this.videoElement || this.subtitles.length === 0) return;
+
+ const currentTime = this.videoElement.currentTime;
+
+ const timeSlider = document.getElementById('timeSlider');
+ if (timeSlider) {
+ timeSlider.value = currentTime;
+ }
+
+ this.updateTimeDisplay();
+
+ let foundIndex = -1;
+ for (let i = 0; i < this.subtitles.length; i++) {
+ const sub = this.subtitles[i];
+ if (currentTime >= sub.start && currentTime <= sub.end) {
+ foundIndex = i;
+ break;
+ }
+ }
+
+ if (foundIndex !== this.currentSubtitleIndex) {
+ this.currentSubtitleIndex = foundIndex;
+ this.updateCurrentSubtitle(foundIndex);
+ this.highlightSubtitleRow(foundIndex);
+ }
+ }
+
+ updateCurrentSubtitle(index) {
+ const subtitleText = document.getElementById('currentSubtitleText');
+ if (!subtitleText) return;
+
+ if (index >= 0 && index < this.subtitles.length) {
+ const sub = this.subtitles[index];
+
+ if (sub.is_bilingual) {
+ const text_zh = sub.text_zh || '';
+ const text_en = sub.text_en || '';
+
+ const has_zh = text_zh.trim().length > 0;
+ const has_en = text_en.trim().length > 0;
+
+ if (has_zh && has_en) {
+ subtitleText.innerHTML = `${this.escapeHtml(text_zh)}
${this.escapeHtml(text_en)}
`;
+ } else if (has_zh) {
+ subtitleText.innerHTML = `${this.escapeHtml(text_zh)}
`;
+ } else if (has_en) {
+ subtitleText.innerHTML = `${this.escapeHtml(text_en)}
`;
+ } else {
+ subtitleText.innerHTML = `${this.escapeHtml(sub.text)}
`;
+ }
+ } else {
+ subtitleText.innerHTML = `${this.escapeHtml(sub.text)}
`;
+ }
+
+ subtitleText.style.display = 'block';
+ } else {
+ subtitleText.innerHTML = '';
+ subtitleText.style.display = 'none';
+ }
+ }
+
+ highlightSubtitleRow(index) {
+ const rows = document.querySelectorAll('.editor-row');
+ rows.forEach((row, i) => {
+ if (i === index) {
+ row.classList.add('bg-blue-50');
+ } else {
+ if (!this.selectedIndices.has(i)) {
+ row.classList.remove('bg-blue-50');
+ }
+ }
+ });
+ }
+
+ updatePlayButton() {
+ const playPauseBtn = document.getElementById('playPauseButton');
+ if (!playPauseBtn) return;
+
+ const icon = playPauseBtn.querySelector('i');
+ if (this.isPlaying) {
+ icon.className = 'ri-pause-fill text-xl';
+ } else {
+ icon.className = 'ri-play-fill text-xl';
+ }
+ }
+
+ updateTimeDisplay() {
+ const timeDisplay = document.getElementById('timeDisplay');
+ if (!timeDisplay) return;
+
+ const current = this.videoElement ? this.videoElement.currentTime : 0;
+ const total = this.duration || 0;
+
+ timeDisplay.textContent = `${this.formatTime(current)} / ${this.formatTime(total)}`;
+ }
+
+ 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;
+
+ const combinedText = sub.is_bilingual
+ ? (sub.text_zh || sub.text_en || sub.text)
+ : sub.text;
+
+ row.innerHTML = `
+
+
+
+ ${index + 1}
+
+
+
+
+
+
+
+
+ ${this._getSegmentDisplayHtml(sub)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ 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');
+ }
+ }
+ });
+ }
+
+ getCurrentStyle() {
+ 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 position = document.getElementById('editorPosition')?.value || 'bottom';
+ const marginV = document.getElementById('editorMarginV')?.value || 40;
+
+ return {
+ font_name: 'Microsoft YaHei',
+ font_size: parseInt(fontSize),
+ font_color: fontColor,
+ outline_color: outlineColor,
+ outline_width: parseInt(outlineWidth),
+ position: position,
+ margin_v: parseInt(marginV)
+ };
+ }
+
+ hexToAssColor(hexColor, alpha = "00") {
+ hexColor = hexColor.trim().replace('#', '');
+ if (hexColor.length === 3) {
+ hexColor = hexColor.split('').map(c => c + c).join('');
+ }
+ if (hexColor.length !== 6) {
+ return '&H00FFFFFF';
+ }
+ const r = hexColor.substring(0, 2);
+ const g = hexColor.substring(2, 4);
+ const b = hexColor.substring(4, 6);
+ return `&H${alpha}${b}${g}${r}`;
+ }
+
+ async save() {
+ if (!this.taskId) {
+ this.showNotification('没有可保存的任务', 'error');
+ return;
+ }
+
+ try {
+ const style = this.getCurrentStyle();
+
+ const saveData = {
+ segments: this.subtitles,
+ output_formats: ['srt', 'ass', 'vtt'],
+ style: style
+ };
+
+ console.log('[Editor] 保存数据:', saveData);
+
+ const response = await fetch(`/api/editor/${this.taskId}/save`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(saveData)
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.detail || '保存失败');
+ }
+
+ const result = await response.json();
+ this.showNotification('保存成功!', 'success');
+
+ } catch (error) {
+ console.error('[Editor] 保存失败:', 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 style = this.getCurrentStyle();
+
+ const saveData = {
+ segments: this.subtitles,
+ output_formats: ['srt', 'ass', 'vtt'],
+ style: style
+ };
+
+ const response = await fetch(`/api/editor/${this.taskId}/save`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(saveData)
+ });
+
+ 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(['\ufeff', 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');
+ }
+
+ _getSegmentDisplayText(sub) {
+ if (sub.is_bilingual) {
+ const text_zh = sub.text_zh || '';
+ const text_en = sub.text_en || '';
+
+ const has_zh = text_zh.trim().length > 0;
+ const has_en = text_en.trim().length > 0;
+
+ if (has_zh && has_en) {
+ return `${text_zh}\n${text_en}`;
+ } else if (has_zh) {
+ return text_zh;
+ } else if (has_en) {
+ return text_en;
+ }
+ }
+ return sub.text;
+ }
+
+ _getSegmentDisplayHtml(sub) {
+ if (sub.is_bilingual) {
+ const text_zh = sub.text_zh || '';
+ const text_en = sub.text_en || '';
+
+ const has_zh = text_zh.trim().length > 0;
+ const has_en = text_en.trim().length > 0;
+
+ if (has_zh && has_en) {
+ return `
+ ${this.escapeHtml(text_zh)}
+
+ ${this.escapeHtml(text_en)}
+
`;
+ } else if (has_zh) {
+ return `${this.escapeHtml(text_zh)} `;
+ } else if (has_en) {
+ return `${this.escapeHtml(text_en)} `;
+ }
+ }
+ return `${this.escapeHtml(sub.text)} `;
+ }
+
+ generateSRT() {
+ let srt = '';
+
+ this.subtitles.forEach((sub, index) => {
+ const displayText = this._getSegmentDisplayText(sub);
+ srt += `${index + 1}\n`;
+ srt += `${this.formatTimeSRT(sub.start)} --> ${this.formatTimeSRT(sub.end)}\n`;
+ srt += `${displayText}\n\n`;
+ });
+
+ return srt;
+ }
+
+ generateVTT() {
+ let vtt = 'WEBVTT\n\n';
+
+ this.subtitles.forEach((sub, index) => {
+ const displayText = this._getSegmentDisplayText(sub);
+ vtt += `${index + 1}\n`;
+ vtt += `${this.formatTimeVTT(sub.start)} --> ${this.formatTimeVTT(sub.end)}\n`;
+ vtt += `${displayText}\n\n`;
+ });
+
+ return vtt;
+ }
+
+ generateASS() {
+ const style = this.getCurrentStyle();
+ const primaryColor = this.hexToAssColor(style.font_color, '00');
+ const outlineColor = this.hexToAssColor(style.outline_color, '00');
+
+ const alignmentMap = { 'bottom': 2, 'middle': 5, 'top': 8 };
+ const alignment = alignmentMap[style.position] || 2;
+
+ let ass = `[Script Info]
+; Script generated by VideoSubtitleAI
+Title: Subtitles
+Original Script: VideoSubtitleAI
+ScriptType: v4.00+
+Collisions: Normal
+PlayResX: 1920
+PlayResY: 1080
+Timer: 100.0000
+
+[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,${style.font_name},${style.font_size},${primaryColor},&H000000FF,${outlineColor},&H00000000,0,0,0,0,100,100,0,0,1,${style.outline_width},1,${alignment},10,10,${style.margin_v},1
+
+[Events]
+Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
+`;
+
+ this.subtitles.forEach(sub => {
+ const displayText = this._getSegmentDisplayText(sub);
+ const assText = displayText.replace(/\n/g, '\\N');
+ ass += `Dialogue: 0,${this.formatTimeASS(sub.start)},${this.formatTimeASS(sub.end)},Default,,0,0,0,,${assText}\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..6865dc5
--- /dev/null
+++ b/web/tasks.py
@@ -0,0 +1,254 @@
+"""
+VideoSubtitleAI Web 版任务管理模块
+"""
+import sys
+import uuid
+import logging
+import asyncio
+from enum import Enum
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import List, Optional, Dict, Any
+from pathlib import Path
+from threading import Lock
+
+PROJECT_ROOT = Path(__file__).parent.parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+
+class TaskStatus(str, Enum):
+ PENDING = "pending"
+ QUEUED = "queued"
+ PROCESSING = "processing"
+ COMPLETED = "completed"
+ FAILED = "failed"
+ CANCELLED = "cancelled"
+
+
+class ProcessingStage(str, Enum):
+ IDLE = "idle"
+ UPLOADING = "uploading"
+ EXTRACTING_AUDIO = "extracting_audio"
+ LANGUAGE_DETECTION = "language_detection"
+ RECOGNIZING_SPEECH = "recognizing_speech"
+ TRANSLATING = "translating"
+ GENERATING_SUBTITLES = "generating_subtitles"
+ EXPORTING = "exporting"
+ EMBEDDING = "embedding"
+ COMPLETED = "completed"
+
+
+STAGE_NAMES = {
+ ProcessingStage.IDLE: "等待中",
+ ProcessingStage.UPLOADING: "上传中",
+ ProcessingStage.EXTRACTING_AUDIO: "提取音频",
+ ProcessingStage.LANGUAGE_DETECTION: "语言检测",
+ ProcessingStage.RECOGNIZING_SPEECH: "语音识别",
+ ProcessingStage.TRANSLATING: "翻译中",
+ ProcessingStage.GENERATING_SUBTITLES: "生成字幕",
+ ProcessingStage.EXPORTING: "导出文件",
+ ProcessingStage.EMBEDDING: "嵌入视频",
+ ProcessingStage.COMPLETED: "已完成",
+}
+
+
+@dataclass
+class TaskProgress:
+ stage: ProcessingStage = ProcessingStage.IDLE
+ progress: float = 0.0
+ message: str = ""
+ logs: List[str] = field(default_factory=list)
+ total_files: int = 1
+ processed_files: int = 0
+ elapsed_seconds: float = 0.0
+
+ @property
+ def stage_name(self) -> str:
+ return STAGE_NAMES.get(self.stage, "处理中")
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ "stage": self.stage.value if isinstance(self.stage, Enum) else self.stage,
+ "stage_name": self.stage_name,
+ "progress": self.progress,
+ "message": self.message,
+ "logs": self.logs[-100:] if self.logs else [],
+ "total_files": self.total_files,
+ "processed_files": self.processed_files,
+ "elapsed_seconds": self.elapsed_seconds,
+ }
+
+
+@dataclass
+class ProcessingTask:
+ task_id: str
+ input_files: List[str] = field(default_factory=list)
+ output_files: Dict[str, str] = field(default_factory=dict)
+ status: TaskStatus = TaskStatus.PENDING
+ progress: TaskProgress = field(default_factory=TaskProgress)
+ options: Dict[str, Any] = field(default_factory=dict)
+ metadata: Dict[str, Any] = field(default_factory=dict)
+ result: Optional[Dict[str, Any]] = None
+ error: Optional[str] = None
+ created_at: datetime = field(default_factory=datetime.now)
+ started_at: Optional[datetime] = None
+ completed_at: Optional[datetime] = None
+ _cancelled: bool = False
+
+ @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]
+
+ @property
+ def is_cancelled(self) -> bool:
+ return self._cancelled
+
+ def cancel(self):
+ self._cancelled = True
+
+ def update_stage(self, stage: ProcessingStage, message: str = "", progress: float = None):
+ self.progress.stage = stage
+ if message:
+ self.progress.message = message
+ if progress is not None:
+ self.progress.progress = progress
+
+ def add_log(self, message: str, level: str = "INFO"):
+ timestamp = datetime.now().strftime("%H:%M:%S")
+ log_entry = f"[{timestamp}] [{level}] {message}"
+ self.progress.logs.append(log_entry)
+ print(log_entry)
+
+ def mark_started(self):
+ self.status = TaskStatus.PROCESSING
+ self.started_at = datetime.now()
+ self.add_log("任务开始处理", "INFO")
+
+ def mark_completed(self, result: Dict[str, Any] = None):
+ self.status = TaskStatus.COMPLETED
+ self.completed_at = datetime.now()
+ self.progress.progress = 100.0
+ self.progress.stage = ProcessingStage.COMPLETED
+ self.progress.message = "处理完成"
+ self.result = result
+ self.add_log("任务处理完成", "INFO")
+
+ def mark_failed(self, error: str):
+ self.status = TaskStatus.FAILED
+ self.completed_at = datetime.now()
+ self.error = error
+ self.add_log(f"任务失败: {error}", "ERROR")
+
+ def mark_cancelled(self):
+ self.status = TaskStatus.CANCELLED
+ self.completed_at = datetime.now()
+ self.add_log("任务已取消", "INFO")
+
+ @property
+ def duration(self) -> float:
+ if self.started_at is None:
+ return 0.0
+ if self.completed_at is not None:
+ return (self.completed_at - self.started_at).total_seconds()
+ return (datetime.now() - self.started_at).total_seconds()
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ "task_id": self.task_id,
+ "status": self.status.value if isinstance(self.status, Enum) else self.status,
+ "progress": self.progress.to_dict() if self.progress else None,
+ "input_files": self.input_files,
+ "output_files": self.output_files,
+ "metadata": self.metadata,
+ "options": self.options,
+ "result": self.result,
+ "error": self.error,
+ "created_at": self.created_at.isoformat() if self.created_at else None,
+ "started_at": self.started_at.isoformat() if self.started_at else None,
+ "completed_at": self.completed_at.isoformat() if self.completed_at else None,
+ "is_running": self.is_running,
+ "is_finished": self.is_finished,
+ "duration": self.duration,
+ }
+
+
+class TaskManager:
+ _instance: Optional['TaskManager'] = None
+ _lock: Lock = Lock()
+
+ def __new__(cls):
+ if cls._instance is None:
+ with cls._lock:
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ cls._instance._tasks: Dict[str, ProcessingTask] = {}
+ return cls._instance
+
+ def create_task(self, input_files: List[str], metadata: Dict[str, Any] = None) -> ProcessingTask:
+ task_id = str(uuid.uuid4())
+ task = ProcessingTask(
+ task_id=task_id,
+ input_files=input_files,
+ metadata=metadata or {},
+ )
+ self._tasks[task_id] = task
+ print(f"[TaskManager] 创建任务: {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]
+
+ def cancel_task(self, task_id: str) -> bool:
+ task = self.get_task(task_id)
+ if task and task.is_running:
+ task.cancel()
+ print(f"[TaskManager] 任务取消请求已发送: {task_id}")
+ return True
+ return False
+
+ def remove_task(self, task_id: str) -> bool:
+ if task_id in self._tasks:
+ del self._tasks[task_id]
+ print(f"[TaskManager] 任务已移除: {task_id}")
+ return True
+ return False
+
+ async def start_task(self, task_id: str, process_fn):
+ task = self.get_task(task_id)
+ if not task:
+ return None
+
+ task.mark_started()
+
+ try:
+ result = await process_fn(task)
+ if task.is_cancelled:
+ task.mark_cancelled()
+ else:
+ task.mark_completed(result)
+ return result
+ except asyncio.CancelledError:
+ task.mark_cancelled()
+ raise
+ except Exception as e:
+ import traceback
+ error_msg = f"{str(e)}\n{traceback.format_exc()}"
+ task.mark_failed(error_msg)
+ print(f"[TaskManager] 任务出错: {task_id}, 错误: {e}")
+ return None
+
+
+task_manager = TaskManager()
diff --git a/web/templates/batch.html b/web/templates/batch.html
new file mode 100644
index 0000000..04ffa72
--- /dev/null
+++ b/web/templates/batch.html
@@ -0,0 +1,296 @@
+
+
+
+
+
+ 批量处理 - VideoSubtitleAI
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 批量处理
+
+
同时处理多个音视频文件,一键生成字幕
+
+
+
+
+
+
+
+ 上传文件
+
+
+
+
+
+
点击或拖拽多个文件到此处
+
支持 MP4, MOV, AVI, MKV, MP3, WAV, M4A 等格式
+
单个文件最大 500MB
+
+
+
+
+
已选择文件 (0 )
+
+
+ 清空列表
+
+
+
+
+
+
+
+
+
+
+ 处理选项
+
+
+
+
+
模型选择
+
+ Tiny (最快)
+ Base (快速)
+ Small (推荐)
+ Medium (准确)
+ Large (最准确)
+
+
更大的模型准确率更高,但处理更慢
+
+
+ 语言
+
+ 自动检测
+ 中文
+ 英文
+ 日文
+ 韩文
+
+
+
+
+
+
+
+ 高级选项
+
+
+ 双语字幕
+
+
+
+
+
+
+
+
+ 样式设置
+
+
+
+
+ 预设样式
+
+ 默认
+ 中文大字体
+ 英文字体
+ 黄色字幕
+ 青色字幕
+ 顶部显示
+
+
+
+ 字体大小
+
+
+
+ 位置
+
+ 底部
+ 中间
+ 顶部
+
+
+
+
+
+
+
+
+ 开始批量处理
+
+
+
+
+
+
+
+
+ 处理进度
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消任务
+
+
+
+
+
+
+ 处理结果
+
+
+
+
+
+
+
+
+ 新建批量任务
+
+
+
+
+
+
+ 提示
+
+
+ • 批量处理会按顺序处理每个文件
+ • 所有文件使用相同的处理参数
+ • 处理中的文件无法单独取消
+ • 建议同时处理不超过 10 个文件
+
+
+
+
+
+
+
+
+
+
+
+
VideoSubtitleAI Web版 | 基于 OpenAI Whisper 的本地离线音视频转字幕工具
+
+
+
+
+
+
+
+
+
diff --git a/web/templates/editor.html b/web/templates/editor.html
new file mode 100644
index 0000000..c2308b7
--- /dev/null
+++ b/web/templates/editor.html
@@ -0,0 +1,458 @@
+
+
+
+
+
+ 字幕编辑器 - VideoSubtitleAI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 保存
+
+
+
+ 导出
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 字幕位置
+
+ 底部
+ 中间
+ 顶部
+
+
+
+
+
样式预览
+
+
+ 示例字幕文字
+
+
+
+
+
+
+
+
+
+
+
+ 合并
+
+
+
+ 拆分
+
+
+
+ 提前
+
+
+
+ 延后
+
+
+
+
+
+
+
+
+
+ 字幕段数
+ 0
+
+
+ 总字数
+ 0
+
+
+ 总时长
+ 00:00:00
+
+
+
+
+
+
+
+
+
+
+
视频预览区域
+
处理完成后可播放视频
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
00:00:00 / 00:00:00
+
+
+
+
+
+
+
+
+
+
+
+ 选择
+
+
+ #
+
+
+ 开始时间
+
+
+ 结束时间
+
+
+ 字幕文本
+
+
+ 操作
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
当前字幕时间范围
+
00:00:00.000 - 00:00:00.000
+
+
+
拆分时间点
+
+
格式: 时:分:秒.毫秒 (如: 00:00:05.000)
+
+
+
+
+ 取消
+
+
+ 确认拆分
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+
+
+
+ 下载
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/web/templates/index.html b/web/templates/index.html
new file mode 100644
index 0000000..831bb0c
--- /dev/null
+++ b/web/templates/index.html
@@ -0,0 +1,343 @@
+
+
+
+
+
+ VideoSubtitleAI - 音视频转字幕工具
+
+
+
+
+
+
+
+
+
+
+ VideoSubtitleAI
+ Web版
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 上传文件
+
+
+
+
+
+
点击或拖拽文件到此处
+
支持 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..1453f30
--- /dev/null
+++ b/web/websocket_manager.py
@@ -0,0 +1,151 @@
+"""
+VideoSubtitleAI Web 版 WebSocket 连接管理模块
+"""
+import sys
+import json
+import asyncio
+import logging
+from pathlib import Path
+from typing import Dict, List, Any, Optional
+from datetime import datetime
+from dataclasses import dataclass, asdict
+from fastapi import WebSocket
+
+PROJECT_ROOT = Path(__file__).parent.parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class WSMessage:
+ type: str
+ task_id: str
+ data: Any = None
+ timestamp: str = None
+
+ def __post_init__(self):
+ if self.timestamp is None:
+ self.timestamp = datetime.utcnow().isoformat() + "Z"
+
+ 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:
+ def __init__(self):
+ self._connections: Dict[str, List[WebSocket]] = {}
+
+ async def connect(self, websocket: WebSocket, task_id: str):
+ await websocket.accept()
+ if task_id not in self._connections:
+ self._connections[task_id] = []
+ self._connections[task_id].append(websocket)
+ print(f"[WebSocketManager] 已连接: {task_id}, 当前连接数: {len(self._connections[task_id])}")
+
+ async def disconnect(self, websocket: WebSocket, task_id: str):
+ if task_id in self._connections:
+ if websocket in self._connections[task_id]:
+ self._connections[task_id].remove(websocket)
+ print(f"[WebSocketManager] 已断开: {task_id}, 剩余连接数: {len(self._connections[task_id])}")
+ if not self._connections[task_id]:
+ del self._connections[task_id]
+
+ async def send_to_task(self, message: WSMessage, task_id: str):
+ if task_id in self._connections:
+ disconnected = []
+ for ws in self._connections[task_id]:
+ try:
+ await ws.send_text(message.to_json())
+ except Exception as e:
+ print(f"[WebSocketManager] 发送消息失败: {e}")
+ disconnected.append(ws)
+
+ for ws in disconnected:
+ await self.disconnect(ws, task_id)
+
+ async def send_task_progress(self, task):
+ if not task:
+ return
+
+ from .tasks import ProcessingTask
+
+ message = WSMessage(
+ type='task_progress',
+ task_id=task.task_id,
+ data=task.to_dict() if hasattr(task, 'to_dict') else str(task),
+ )
+ await self.send_to_task(message, task.task_id)
+
+ async def send_task_status_change(self, task):
+ message = WSMessage(
+ type='task_status',
+ task_id=task.task_id,
+ data={
+ 'status': task.status.value if hasattr(task.status, 'value') else str(task.status),
+ 'error': task.error,
+ },
+ )
+ await self.send_to_task(message, task.task_id)
+
+ async def send_log_message(self, task_id: str, message: str, level: str = "INFO"):
+ ws_msg = WSMessage(
+ type='task_log',
+ task_id=task_id,
+ data={
+ 'message': message,
+ 'level': level,
+ 'timestamp': datetime.now().isoformat(),
+ },
+ )
+ await self.send_to_task(ws_msg, task_id)
+
+ async def broadcast_task_list(self, tasks):
+ from .tasks import ProcessingTask
+
+ message = WSMessage(
+ type='task_list',
+ task_id='broadcast',
+ data={
+ 'tasks': [t.to_dict() for t in tasks],
+ },
+ )
+ await self.send_to_task(message, 'broadcast')
+
+ async def _send_to_websocket(self, websocket: WebSocket, message: WSMessage):
+ try:
+ await websocket.send_text(message.to_json())
+ except Exception as e:
+ print(f"[WebSocketManager] 直接发送失败: {e}")
+
+
+ws_manager = WebSocketManager()
+
+
+async def task_progress_monitor(task, interval: float = 0.3):
+ from .tasks import ProcessingTask
+
+ while not task.is_finished:
+ if task.is_cancelled:
+ break
+
+ 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)
diff --git a/web_outputs/3ff25c18-38a3-44b9-84ec-70aa45ef7d2b/31fe47e0-2316-4a8a-9444-ef7893e73516_SeeYouAgain.ass b/web_outputs/3ff25c18-38a3-44b9-84ec-70aa45ef7d2b/31fe47e0-2316-4a8a-9444-ef7893e73516_SeeYouAgain.ass
new file mode 100644
index 0000000..6b2ea28
--- /dev/null
+++ b/web_outputs/3ff25c18-38a3-44b9-84ec-70aa45ef7d2b/31fe47e0-2316-4a8a-9444-ef7893e73516_SeeYouAgain.ass
@@ -0,0 +1,73 @@
+[Script Info]
+; Script generated by VideoSubtitleAI
+Title: Untitled
+Original Script: VideoSubtitleAI
+ScriptType: v4.00+
+Collisions: Normal
+PlayResX: 1920
+PlayResY: 1080
+Timer: 100.0000
+
+[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
+Dialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,,音乐\NMusic
+Dialogue: 0,0:00:10.00,0:00:17.00,Default,,0,0,0,,没有你,我的朋友,这是漫长的一天\NIt's been a long day without you, my friend
+Dialogue: 0,0:00:17.00,0:00:23.00,Default,,0,0,0,,当我再次见到你时,我会告诉你一切\NAnd I'll tell you all about it when I see you again
+Dialogue: 0,0:00:23.00,0:00:29.00,Default,,0,0,0,,我们从开始的地方走了很长的路\NWe've come a long way from where we began
+Dialogue: 0,0:00:29.00,0:00:34.00,Default,,0,0,0,,当我再次见到你时,我们会告诉你一切\NWe'll tell you all about it when I see you again
+Dialogue: 0,0:00:34.00,0:00:37.00,Default,,0,0,0,,与你重逢之时\NWhen I see you again
+Dialogue: 0,0:00:39.00,0:00:42.00,Default,,0,0,0,,该死,谁知道我们开的所有飞机\NDamn, who knew all the planes we flew
+Dialogue: 0,0:00:42.00,0:00:44.00,Default,,0,0,0,,我们经历过的好事\NGood things we've been through
+Dialogue: 0,0:00:44.00,0:00:46.00,Default,,0,0,0,,现在我们就站在这里跟你说话\NThat now we stand right here talking to you
+Dialogue: 0,0:00:46.00,0:00:48.00,Default,,0,0,0,,关于另一条路\NAbout another path
+Dialogue: 0,0:00:48.00,0:00:50.00,Default,,0,0,0,,我知道我们喜欢在路上笑\NI know we loved to hit the road and laugh
+Dialogue: 0,0:00:50.00,0:00:52.00,Default,,0,0,0,,但有件事告诉我,脚和土地\NBut something told me that the foot and land
+Dialogue: 0,0:00:52.00,0:00:55.00,Default,,0,0,0,,不得不换个角度,看看不同的东西\NHad to switch up, look at things different
+Dialogue: 0,0:00:55.00,0:00:56.00,Default,,0,0,0,,查看大图\NSee the bigger picture
+Dialogue: 0,0:00:57.00,0:00:59.00,Default,,0,0,0,,那是艰苦工作永远有回报的日子\NThose were the days hard work for ever-pays
+Dialogue: 0,0:00:59.00,0:01:01.00,Default,,0,0,0,,现在我看到你当更好的地方\NNow I see you when the better place
+Dialogue: 0,0:01:01.00,0:01:03.00,Default,,0,0,0,,再见,当更好的方式\NSee you when the better way
+Dialogue: 0,0:01:03.00,0:01:07.00,Default,,0,0,0,,啊,我觉得我们不是在和家人谈论家庭\NAh, I feel we not talk about family with families
+Dialogue: 0,0:01:07.00,0:01:09.00,Default,,0,0,0,,我们得到的一切,我会做的一切\NAll that we got, everything I would do
+Dialogue: 0,0:01:09.00,0:01:11.00,Default,,0,0,0,,你站在我身边\NYou were standing there by my side
+Dialogue: 0,0:01:11.00,0:01:13.00,Default,,0,0,0,,现在,你将与我共度最后一程\NAnd now you're going to be with me for the last ride
+Dialogue: 0,0:01:13.00,0:01:19.00,Default,,0,0,0,,没有你,我度过了漫长的一天,我的朋友\NBeen a long day without you, my friend
+Dialogue: 0,0:01:19.00,0:01:25.00,Default,,0,0,0,,当我再次见到你时,我会告诉你一切\NAnd I'll tell you all about it when I see you again
+Dialogue: 0,0:01:26.00,0:01:31.00,Default,,0,0,0,,我们从开始的地方走了很长的路\NWe've come a long way from where we began
+Dialogue: 0,0:01:31.00,0:01:37.00,Default,,0,0,0,,当我再次见到你时,我们会告诉你一切\NWe'll tell you all about it when I see you again
+Dialogue: 0,0:01:37.00,0:01:40.00,Default,,0,0,0,,当我再次见到你时,我们会告诉你的\NWe'll tell you when I see you again
+Dialogue: 0,0:01:56.00,0:01:58.00,Default,,0,0,0,,首先,你们俩都在氛围中走自己的路\NFirst you both go out your way in the vibe
+Dialogue: 0,0:01:58.00,0:02:00.00,Default,,0,0,0,,结束了,醉醺醺地转了个小弯\NIt's finished, drunken with small turns
+Dialogue: 0,0:02:00.00,0:02:02.00,Default,,0,0,0,,两个是友谊,一个友谊,一种纽带\NTwo were friendship, a friendship, a bond
+Dialogue: 0,0:02:02.00,0:02:04.00,Default,,0,0,0,,这种纽带永远不会被打破\NAnd that bond will never be broken
+Dialogue: 0,0:02:04.00,0:02:05.00,Default,,0,0,0,,水平永远不会丢失\NAnd the level never get lost
+Dialogue: 0,0:02:08.00,0:02:10.00,Default,,0,0,0,,当哥哥先来的时候\NAnd when brother will come first
+Dialogue: 0,0:02:10.00,0:02:11.00,Default,,0,0,0,,这片土地永远不会被越过\NAnd the land will never be crossed
+Dialogue: 0,0:02:11.00,0:02:12.00,Default,,0,0,0,,我们家的建立\NThe establishment on our home
+Dialogue: 0,0:02:12.00,0:02:14.00,Default,,0,0,0,,那片土地必须被开垦\NAnd that land had to be drawn
+Dialogue: 0,0:02:14.00,0:02:16.00,Default,,0,0,0,,那片土地正是我们所希望的\NAnd that land is what we wish
+Dialogue: 0,0:02:16.00,0:02:18.00,Default,,0,0,0,,所以,当我离开时,请记住我\NSo remember me when I'm gone
+Dialogue: 0,0:02:20.00,0:02:23.00,Default,,0,0,0,,不来,不跟家人谈论我们所有的家人\NNot coming, not talking about family with families all that we got
+Dialogue: 0,0:02:23.00,0:02:26.00,Default,,0,0,0,,我所做的一切,你都站在我身边\NEverything I would do you was standing there by my side
+Dialogue: 0,0:02:26.00,0:02:28.00,Default,,0,0,0,,现在,你将与我共度最后一程\NAnd now you're going to be with me for the last ride
+Dialogue: 0,0:02:28.00,0:02:33.00,Default,,0,0,0,,我从来都不喜欢你的方式\NNever like I'm your way
+Dialogue: 0,0:02:35.00,0:02:39.00,Default,,0,0,0,,让每个疯子都走\NHold every mad man to go
+Dialogue: 0,0:02:40.00,0:02:43.00,Default,,0,0,0,,你走的每一条路\NAnd every road you take
+Dialogue: 0,0:02:44.00,0:02:48.00,Default,,0,0,0,,爱你走的每一个人\NLoves each you go
+Dialogue: 0,0:02:49.00,0:02:51.00,Default,,0,0,0,, Oh, oh, oh, oh
+Dialogue: 0,0:02:52.00,0:02:55.00,Default,,0,0,0,,真是漫长的一天\NIt's been a long day
+Dialogue: 0,0:02:56.00,0:02:58.00,Default,,0,0,0,,没有你,我的朋友\NWithout you, my friend
+Dialogue: 0,0:02:59.00,0:03:04.00,Default,,0,0,0,,当我再次见到你时,我会告诉你一切\NAnd I'll tell you all about it when I see you again
+Dialogue: 0,0:03:04.00,0:03:10.00,Default,,0,0,0,,我们从开始的地方走了很长的路\NWe've come a long way from where we began
+Dialogue: 0,0:03:10.00,0:03:16.00,Default,,0,0,0,,哦,等我再见到你,我会告诉你一切的\NOh, I'll tell you all about it when I see you again
+Dialogue: 0,0:03:16.00,0:03:21.00,Default,,0,0,0,,与你重逢之时\NWhen I see you again
+Dialogue: 0,0:03:22.00,0:03:24.00,Default,,0,0,0,, Oh, oh, oh, oh
+Dialogue: 0,0:03:24.00,0:03:28.00,Default,,0,0,0,,是,是,是\NYeah, yeah, yeah, yeah
+Dialogue: 0,0:03:28.00,0:03:33.00,Default,,0,0,0,,你是一样的,你得到了什么\NYou're the same, what you got
+Dialogue: 0,0:03:33.00,0:03:35.00,Default,,0,0,0,,是,是,是\NYeah, yeah, yeah
+Dialogue: 0,0:03:36.00,0:03:39.00,Default,,0,0,0,, Oh, oh, oh, oh, oh
+Dialogue: 0,0:03:40.00,0:03:43.00,Default,,0,0,0,,是,是,是\NYeah, yeah, yeah, yeah
\ No newline at end of file
diff --git a/web_outputs/6afae446-ad95-45c2-9c56-ab44ed7dca3a/821b65f8-1db4-4a91-9d28-4195e8a79644_SeeYouAgain.ass b/web_outputs/6afae446-ad95-45c2-9c56-ab44ed7dca3a/821b65f8-1db4-4a91-9d28-4195e8a79644_SeeYouAgain.ass
new file mode 100644
index 0000000..4ad21e3
--- /dev/null
+++ b/web_outputs/6afae446-ad95-45c2-9c56-ab44ed7dca3a/821b65f8-1db4-4a91-9d28-4195e8a79644_SeeYouAgain.ass
@@ -0,0 +1,73 @@
+[Script Info]
+; Script generated by VideoSubtitleAI
+Title: Untitled
+Original Script: VideoSubtitleAI
+ScriptType: v4.00+
+Collisions: Normal
+PlayResX: 1920
+PlayResY: 1080
+Timer: 100.0000
+
+[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
+Dialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,,[中] Music\N Music
+Dialogue: 0,0:00:10.00,0:00:17.00,Default,,0,0,0,,[中] It's been a long day without you, my friend\N It's been a long day without you, my friend
+Dialogue: 0,0:00:17.00,0:00:23.00,Default,,0,0,0,,[中] And I'll tell you all about it when I see you again\N And I'll tell you all about it when I see you again
+Dialogue: 0,0:00:23.00,0:00:29.00,Default,,0,0,0,,[中] We've come a long way from where we began\N We've come a long way from where we began
+Dialogue: 0,0:00:29.00,0:00:34.00,Default,,0,0,0,,[中] We'll tell you all about it when I see you again\N We'll tell you all about it when I see you again
+Dialogue: 0,0:00:34.00,0:00:37.00,Default,,0,0,0,,[中] When I see you again\N When I see you again
+Dialogue: 0,0:00:39.00,0:00:42.00,Default,,0,0,0,,[中] Damn, who knew all the planes we flew\N Damn, who knew all the planes we flew
+Dialogue: 0,0:00:42.00,0:00:44.00,Default,,0,0,0,,[中] Good things we've been through\N Good things we've been through
+Dialogue: 0,0:00:44.00,0:00:46.00,Default,,0,0,0,,[中] That now we stand right here talking to you\N That now we stand right here talking to you
+Dialogue: 0,0:00:46.00,0:00:48.00,Default,,0,0,0,,[中] About another path\N About another path
+Dialogue: 0,0:00:48.00,0:00:50.00,Default,,0,0,0,,[中] I know we loved to hit the road and laugh\N I know we loved to hit the road and laugh
+Dialogue: 0,0:00:50.00,0:00:52.00,Default,,0,0,0,,[中] But something told me that the foot and land\N But something told me that the foot and land
+Dialogue: 0,0:00:52.00,0:00:55.00,Default,,0,0,0,,[中] Had to switch up, look at things different\N Had to switch up, look at things different
+Dialogue: 0,0:00:55.00,0:00:56.00,Default,,0,0,0,,[中] See the bigger picture\N See the bigger picture
+Dialogue: 0,0:00:57.00,0:00:59.00,Default,,0,0,0,,[中] Those were the days hard work for ever-pays\N Those were the days hard work for ever-pays
+Dialogue: 0,0:00:59.00,0:01:01.00,Default,,0,0,0,,[中] Now I see you when the better place\N Now I see you when the better place
+Dialogue: 0,0:01:01.00,0:01:03.00,Default,,0,0,0,,[中] See you when the better way\N See you when the better way
+Dialogue: 0,0:01:03.00,0:01:07.00,Default,,0,0,0,,[中] Ah, I feel we not talk about family with families\N Ah, I feel we not talk about family with families
+Dialogue: 0,0:01:07.00,0:01:09.00,Default,,0,0,0,,[中] All that we got, everything I would do\N All that we got, everything I would do
+Dialogue: 0,0:01:09.00,0:01:11.00,Default,,0,0,0,,[中] You were standing there by my side\N You were standing there by my side
+Dialogue: 0,0:01:11.00,0:01:13.00,Default,,0,0,0,,[中] And now you're going to be with me for the last ride\N And now you're going to be with me for the last ride
+Dialogue: 0,0:01:13.00,0:01:19.00,Default,,0,0,0,,[中] Been a long day without you, my friend\N Been a long day without you, my friend
+Dialogue: 0,0:01:19.00,0:01:25.00,Default,,0,0,0,,[中] And I'll tell you all about it when I see you again\N And I'll tell you all about it when I see you again
+Dialogue: 0,0:01:26.00,0:01:31.00,Default,,0,0,0,,[中] We've come a long way from where we began\N We've come a long way from where we began
+Dialogue: 0,0:01:31.00,0:01:37.00,Default,,0,0,0,,[中] We'll tell you all about it when I see you again\N We'll tell you all about it when I see you again
+Dialogue: 0,0:01:37.00,0:01:40.00,Default,,0,0,0,,[中] We'll tell you when I see you again\N We'll tell you when I see you again
+Dialogue: 0,0:01:56.00,0:01:58.00,Default,,0,0,0,,[中] First you both go out your way in the vibe\N First you both go out your way in the vibe
+Dialogue: 0,0:01:58.00,0:02:00.00,Default,,0,0,0,,[中] It's finished, drunken with small turns\N It's finished, drunken with small turns
+Dialogue: 0,0:02:00.00,0:02:02.00,Default,,0,0,0,,[中] Two were friendship, a friendship, a bond\N Two were friendship, a friendship, a bond
+Dialogue: 0,0:02:02.00,0:02:04.00,Default,,0,0,0,,[中] And that bond will never be broken\N And that bond will never be broken
+Dialogue: 0,0:02:04.00,0:02:05.00,Default,,0,0,0,,[中] And the level never get lost\N And the level never get lost
+Dialogue: 0,0:02:08.00,0:02:10.00,Default,,0,0,0,,[中] And when brother will come first\N And when brother will come first
+Dialogue: 0,0:02:10.00,0:02:11.00,Default,,0,0,0,,[中] And the land will never be crossed\N And the land will never be crossed
+Dialogue: 0,0:02:11.00,0:02:12.00,Default,,0,0,0,,[中] The establishment on our home\N The establishment on our home
+Dialogue: 0,0:02:12.00,0:02:14.00,Default,,0,0,0,,[中] And that land had to be drawn\N And that land had to be drawn
+Dialogue: 0,0:02:14.00,0:02:16.00,Default,,0,0,0,,[中] And that land is what we wish\N And that land is what we wish
+Dialogue: 0,0:02:16.00,0:02:18.00,Default,,0,0,0,,[中] So remember me when I'm gone\N So remember me when I'm gone
+Dialogue: 0,0:02:20.00,0:02:23.00,Default,,0,0,0,,[中] Not coming, not talking about family with families all that we got\N Not coming, not talking about family with families all that we got
+Dialogue: 0,0:02:23.00,0:02:26.00,Default,,0,0,0,,[中] Everything I would do you was standing there by my side\N Everything I would do you was standing there by my side
+Dialogue: 0,0:02:26.00,0:02:28.00,Default,,0,0,0,,[中] And now you're going to be with me for the last ride\N And now you're going to be with me for the last ride
+Dialogue: 0,0:02:28.00,0:02:33.00,Default,,0,0,0,,[中] Never like I'm your way\N Never like I'm your way
+Dialogue: 0,0:02:35.00,0:02:39.00,Default,,0,0,0,,[中] Hold every mad man to go\N Hold every mad man to go
+Dialogue: 0,0:02:40.00,0:02:43.00,Default,,0,0,0,,[中] And every road you take\N And every road you take
+Dialogue: 0,0:02:44.00,0:02:48.00,Default,,0,0,0,,[中] Loves each you go\N Loves each you go
+Dialogue: 0,0:02:49.00,0:02:51.00,Default,,0,0,0,, Oh, oh, oh, oh
+Dialogue: 0,0:02:52.00,0:02:55.00,Default,,0,0,0,,[中] It's been a long day\N It's been a long day
+Dialogue: 0,0:02:56.00,0:02:58.00,Default,,0,0,0,,[中] Without you, my friend\N Without you, my friend
+Dialogue: 0,0:02:59.00,0:03:04.00,Default,,0,0,0,,[中] And I'll tell you all about it when I see you again\N And I'll tell you all about it when I see you again
+Dialogue: 0,0:03:04.00,0:03:10.00,Default,,0,0,0,,[中] We've come a long way from where we began\N We've come a long way from where we began
+Dialogue: 0,0:03:10.00,0:03:16.00,Default,,0,0,0,,[中] Oh, I'll tell you all about it when I see you again\N Oh, I'll tell you all about it when I see you again
+Dialogue: 0,0:03:16.00,0:03:21.00,Default,,0,0,0,,[中] When I see you again\N When I see you again
+Dialogue: 0,0:03:22.00,0:03:24.00,Default,,0,0,0,, Oh, oh, oh, oh
+Dialogue: 0,0:03:24.00,0:03:27.00,Default,,0,0,0,,[中] Yeah, yeah, yeah, yeah\N Yeah, yeah, yeah, yeah
+Dialogue: 0,0:03:28.00,0:03:32.00,Default,,0,0,0,,[中] We've seen you again\N We've seen you again
+Dialogue: 0,0:03:33.00,0:03:35.00,Default,,0,0,0,,[中] Yeah, yeah, yeah\N Yeah, yeah, yeah
+Dialogue: 0,0:03:36.00,0:03:39.00,Default,,0,0,0,, Oh, oh, oh, oh, oh, oh
+Dialogue: 0,0:03:40.00,0:03:43.00,Default,,0,0,0,,[中] Yeah, yeah, yeah, yeah\N Yeah, yeah, yeah, yeah
\ No newline at end of file
diff --git a/web_outputs/8f6ea934-a14c-4871-8ed4-a48857ebae02/9c628ed8-8ade-4f1d-9350-3b51c35129a7_SeeYouAgain.ass b/web_outputs/8f6ea934-a14c-4871-8ed4-a48857ebae02/9c628ed8-8ade-4f1d-9350-3b51c35129a7_SeeYouAgain.ass
new file mode 100644
index 0000000..fe9e4d9
--- /dev/null
+++ b/web_outputs/8f6ea934-a14c-4871-8ed4-a48857ebae02/9c628ed8-8ade-4f1d-9350-3b51c35129a7_SeeYouAgain.ass
@@ -0,0 +1,82 @@
+[Script Info]
+; Script generated by VideoSubtitleAI
+Title: Untitled
+Original Script: VideoSubtitleAI
+ScriptType: v4.00+
+Collisions: Normal
+PlayResX: 1920
+PlayResY: 1080
+Timer: 100.0000
+
+[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
+Dialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,,[中] Music\N Music
+Dialogue: 0,0:00:10.00,0:00:17.00,Default,,0,0,0,,[中] It's been a long day without you, my friend\N It's been a long day without you, my friend
+Dialogue: 0,0:00:17.00,0:00:23.00,Default,,0,0,0,,[中] And I'll tell you all about it when I see you again\N And I'll tell you all about it when I see you again
+Dialogue: 0,0:00:23.00,0:00:29.00,Default,,0,0,0,,[中] We've come a long way from where we began\N We've come a long way from where we began
+Dialogue: 0,0:00:29.00,0:00:34.00,Default,,0,0,0,,[中] We'll tell you all about it when I see you again\N We'll tell you all about it when I see you again
+Dialogue: 0,0:00:34.00,0:00:37.00,Default,,0,0,0,,[中] When I see you again\N When I see you again
+Dialogue: 0,0:00:39.00,0:00:42.00,Default,,0,0,0,,[中] Damn, who knew all the planes we flew\N Damn, who knew all the planes we flew
+Dialogue: 0,0:00:42.00,0:00:44.00,Default,,0,0,0,,[中] Good things we've been through\N Good things we've been through
+Dialogue: 0,0:00:44.00,0:00:46.00,Default,,0,0,0,,[中] That now we stand right here talking to you\N That now we stand right here talking to you
+Dialogue: 0,0:00:46.00,0:00:48.00,Default,,0,0,0,,[中] About another path\N About another path
+Dialogue: 0,0:00:48.00,0:00:50.00,Default,,0,0,0,,[中] I know we loved to hit the road and laugh\N I know we loved to hit the road and laugh
+Dialogue: 0,0:00:50.00,0:00:52.00,Default,,0,0,0,,[中] But something told me that the foot and land\N But something told me that the foot and land
+Dialogue: 0,0:00:52.00,0:00:55.00,Default,,0,0,0,,[中] Had to switch up, look at things different\N Had to switch up, look at things different
+Dialogue: 0,0:00:55.00,0:00:56.00,Default,,0,0,0,,[中] See the bigger picture\N See the bigger picture
+Dialogue: 0,0:00:57.00,0:00:59.00,Default,,0,0,0,,[中] Those were the days hard work for ever-pays\N Those were the days hard work for ever-pays
+Dialogue: 0,0:00:59.00,0:01:01.00,Default,,0,0,0,,[中] Now I see you when the better place\N Now I see you when the better place
+Dialogue: 0,0:01:01.00,0:01:03.00,Default,,0,0,0,,[中] See you when the better way\N See you when the better way
+Dialogue: 0,0:01:03.00,0:01:07.00,Default,,0,0,0,,[中] Ah, I feel we not talk about family with families\N Ah, I feel we not talk about family with families
+Dialogue: 0,0:01:07.00,0:01:09.00,Default,,0,0,0,,[中] All that we got, everything I would do\N All that we got, everything I would do
+Dialogue: 0,0:01:09.00,0:01:11.00,Default,,0,0,0,,[中] You were standing there by my side\N You were standing there by my side
+Dialogue: 0,0:01:11.00,0:01:13.00,Default,,0,0,0,,[中] And now you're going to be with me for the last ride\N And now you're going to be with me for the last ride
+Dialogue: 0,0:01:13.00,0:01:19.00,Default,,0,0,0,,[中] Been a long day without you, my friend\N Been a long day without you, my friend
+Dialogue: 0,0:01:19.00,0:01:25.00,Default,,0,0,0,,[中] And I'll tell you all about it when I see you again\N And I'll tell you all about it when I see you again
+Dialogue: 0,0:01:26.00,0:01:31.00,Default,,0,0,0,,[中] We've come a long way from where we began\N We've come a long way from where we began
+Dialogue: 0,0:01:31.00,0:01:37.00,Default,,0,0,0,,[中] We'll tell you all about it when I see you again\N We'll tell you all about it when I see you again
+Dialogue: 0,0:01:37.00,0:01:40.00,Default,,0,0,0,,[中] We'll tell you when I see you again\N We'll tell you when I see you again
+Dialogue: 0,0:01:56.00,0:01:58.00,Default,,0,0,0,,[中] First you both go out your way in the vibe\N First you both go out your way in the vibe
+Dialogue: 0,0:01:58.00,0:02:00.00,Default,,0,0,0,,[中] It's finished, drunken with small turns\N It's finished, drunken with small turns
+Dialogue: 0,0:02:00.00,0:02:02.00,Default,,0,0,0,,[中] Two were friendship, a friendship, a bond\N Two were friendship, a friendship, a bond
+Dialogue: 0,0:02:02.00,0:02:04.00,Default,,0,0,0,,[中] And that bond will never be broken\N And that bond will never be broken
+Dialogue: 0,0:02:04.00,0:02:05.00,Default,,0,0,0,,[中] And the level never get lost\N And the level never get lost
+Dialogue: 0,0:02:08.00,0:02:10.00,Default,,0,0,0,,[中] And when brother will come first\N And when brother will come first
+Dialogue: 0,0:02:10.00,0:02:11.00,Default,,0,0,0,,[中] And the land will never be crossed\N And the land will never be crossed
+Dialogue: 0,0:02:11.00,0:02:12.00,Default,,0,0,0,,[中] The establishment on our home\N The establishment on our home
+Dialogue: 0,0:02:12.00,0:02:14.00,Default,,0,0,0,,[中] And that land had to be drawn\N And that land had to be drawn
+Dialogue: 0,0:02:14.00,0:02:16.00,Default,,0,0,0,,[中] And that land is what we wish\N And that land is what we wish
+Dialogue: 0,0:02:16.00,0:02:18.00,Default,,0,0,0,,[中] So remember me when I'm gone\N So remember me when I'm gone
+Dialogue: 0,0:02:20.00,0:02:23.00,Default,,0,0,0,,[中] Not coming, not talking about family with families all that we got\N Not coming, not talking about family with families all that we got
+Dialogue: 0,0:02:23.00,0:02:26.00,Default,,0,0,0,,[中] Everything I would do you was standing there by my side\N Everything I would do you was standing there by my side
+Dialogue: 0,0:02:26.00,0:02:28.00,Default,,0,0,0,,[中] And now you're going to be with me for the last ride\N And now you're going to be with me for the last ride
+Dialogue: 0,0:02:28.00,0:02:33.00,Default,,0,0,0,,[中] Never like I'm your way\N Never like I'm your way
+Dialogue: 0,0:02:35.00,0:02:39.00,Default,,0,0,0,,[中] Hold every mad man to go\N Hold every mad man to go
+Dialogue: 0,0:02:40.00,0:02:43.00,Default,,0,0,0,,[中] And every road you take\N And every road you take
+Dialogue: 0,0:02:44.00,0:02:48.00,Default,,0,0,0,,[中] Loves each you go\N Loves each you go
+Dialogue: 0,0:02:49.00,0:02:51.00,Default,,0,0,0,, Oh, oh, oh, oh
+Dialogue: 0,0:02:52.00,0:02:55.00,Default,,0,0,0,,[中] It's been a long day\N It's been a long day
+Dialogue: 0,0:02:56.00,0:02:58.00,Default,,0,0,0,,[中] Without you, my friend\N Without you, my friend
+Dialogue: 0,0:02:59.00,0:03:04.00,Default,,0,0,0,,[中] And I'll tell you all about it when I see you again\N And I'll tell you all about it when I see you again
+Dialogue: 0,0:03:04.00,0:03:10.00,Default,,0,0,0,,[中] We've come a long way from where we began\N We've come a long way from where we began
+Dialogue: 0,0:03:10.00,0:03:16.00,Default,,0,0,0,,[中] Oh, I'll tell you all about it when I see you again\N Oh, I'll tell you all about it when I see you again
+Dialogue: 0,0:03:16.00,0:03:20.00,Default,,0,0,0,,[中] When I see you again\N When I see you again
+Dialogue: 0,0:03:22.00,0:03:24.00,Default,,0,0,0,, Oh, oh, oh
+Dialogue: 0,0:03:24.00,0:03:27.00,Default,,0,0,0,,[中] It's been a long day\N It's been a long day
+Dialogue: 0,0:03:27.00,0:03:30.00,Default,,0,0,0,,[中] Without you, my friend\N Without you, my friend
+Dialogue: 0,0:03:30.00,0:03:35.00,Default,,0,0,0,,[中] I see you again\N I see you again
+Dialogue: 0,0:03:36.00,0:03:40.00,Default,,0,0,0,, Oh, oh, oh, oh
+Dialogue: 0,0:03:40.00,0:03:43.00,Default,,0,0,0,,[中] It's been a long day\N It's been a long day
+Dialogue: 0,0:03:46.00,0:03:47.00,Default,,0,0,0,,[中] I see you again\N I see you again
+Dialogue: 0,0:03:48.00,0:03:50.00,Default,,0,0,0,,[中] I see you again\N I see you again
+Dialogue: 0,0:03:51.00,0:03:53.00,Default,,0,0,0,, Oh, oh, oh
+Dialogue: 0,0:03:53.00,0:03:56.00,Default,,0,0,0,,[中] It's been a long day\N It's been a long day
+Dialogue: 0,0:03:57.00,0:03:59.00,Default,,0,0,0,,[中] I see you again\N I see you again
+Dialogue: 0,0:04:00.00,0:04:04.00,Default,,0,0,0,,[中] I see you again\N I see you again
+Dialogue: 0,0:04:05.00,0:04:07.00,Default,,0,0,0,,[中] I see you again\N I see you again
+Dialogue: 0,0:04:08.00,0:04:10.00,Default,,0,0,0,,[中] I see you again\N I see you again
+Dialogue: 0,0:04:11.00,0:04:13.00,Default,,0,0,0,, Oh, oh
\ No newline at end of file
diff --git a/web_outputs/9bf9fd08-6e31-470c-bf25-441cbb483137/a7325bae-1549-4b15-907f-0e4fa41eb643_SeeYouAgain.ass b/web_outputs/9bf9fd08-6e31-470c-bf25-441cbb483137/a7325bae-1549-4b15-907f-0e4fa41eb643_SeeYouAgain.ass
new file mode 100644
index 0000000..3118281
--- /dev/null
+++ b/web_outputs/9bf9fd08-6e31-470c-bf25-441cbb483137/a7325bae-1549-4b15-907f-0e4fa41eb643_SeeYouAgain.ass
@@ -0,0 +1,73 @@
+[Script Info]
+; Script generated by VideoSubtitleAI
+Title: Untitled
+Original Script: VideoSubtitleAI
+ScriptType: v4.00+
+Collisions: Normal
+PlayResX: 1920
+PlayResY: 1080
+Timer: 100.0000
+
+[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
+Dialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,, Music
+Dialogue: 0,0:00:10.00,0:00:17.00,Default,,0,0,0,, It's been a long day without you, my friend
+Dialogue: 0,0:00:17.00,0:00:23.00,Default,,0,0,0,, And I'll tell you all about it when I see you again
+Dialogue: 0,0:00:23.00,0:00:29.00,Default,,0,0,0,, We've come a long way from where we began
+Dialogue: 0,0:00:29.00,0:00:34.00,Default,,0,0,0,, We'll tell you all about it when I see you again
+Dialogue: 0,0:00:34.00,0:00:37.00,Default,,0,0,0,, When I see you again
+Dialogue: 0,0:00:39.00,0:00:42.00,Default,,0,0,0,, Damn, who knew all the planes we flew
+Dialogue: 0,0:00:42.00,0:00:44.00,Default,,0,0,0,, Good things we've been through
+Dialogue: 0,0:00:44.00,0:00:46.00,Default,,0,0,0,, That now we stand right here talking to you
+Dialogue: 0,0:00:46.00,0:00:48.00,Default,,0,0,0,, About another path
+Dialogue: 0,0:00:48.00,0:00:50.00,Default,,0,0,0,, I know we loved to hit the road and laugh
+Dialogue: 0,0:00:50.00,0:00:52.00,Default,,0,0,0,, But something told me that the foot and land
+Dialogue: 0,0:00:52.00,0:00:55.00,Default,,0,0,0,, Had to switch up, look at things different
+Dialogue: 0,0:00:55.00,0:00:56.00,Default,,0,0,0,, See the bigger picture
+Dialogue: 0,0:00:57.00,0:00:59.00,Default,,0,0,0,, Those were the days hard work for ever-pays
+Dialogue: 0,0:00:59.00,0:01:01.00,Default,,0,0,0,, Now I see you when the better place
+Dialogue: 0,0:01:01.00,0:01:03.00,Default,,0,0,0,, See you when the better way
+Dialogue: 0,0:01:03.00,0:01:07.00,Default,,0,0,0,, Ah, I feel we not talk about family with families
+Dialogue: 0,0:01:07.00,0:01:09.00,Default,,0,0,0,, All that we got, everything I would do
+Dialogue: 0,0:01:09.00,0:01:11.00,Default,,0,0,0,, You were standing there by my side
+Dialogue: 0,0:01:11.00,0:01:13.00,Default,,0,0,0,, And now you're going to be with me for the last ride
+Dialogue: 0,0:01:13.00,0:01:19.00,Default,,0,0,0,, Been a long day without you, my friend
+Dialogue: 0,0:01:19.00,0:01:25.00,Default,,0,0,0,, And I'll tell you all about it when I see you again
+Dialogue: 0,0:01:26.00,0:01:31.00,Default,,0,0,0,, We've come a long way from where we began
+Dialogue: 0,0:01:31.00,0:01:37.00,Default,,0,0,0,, We'll tell you all about it when I see you again
+Dialogue: 0,0:01:37.00,0:01:40.00,Default,,0,0,0,, We'll tell you when I see you again
+Dialogue: 0,0:01:56.00,0:01:58.00,Default,,0,0,0,, First you both go out your way in the vibe
+Dialogue: 0,0:01:58.00,0:02:00.00,Default,,0,0,0,, It's finished, drunken with small turns
+Dialogue: 0,0:02:00.00,0:02:02.00,Default,,0,0,0,, Two were friendship, a friendship, a bond
+Dialogue: 0,0:02:02.00,0:02:04.00,Default,,0,0,0,, And that bond will never be broken
+Dialogue: 0,0:02:04.00,0:02:05.00,Default,,0,0,0,, And the level never get lost
+Dialogue: 0,0:02:08.00,0:02:10.00,Default,,0,0,0,, And when brother will come first
+Dialogue: 0,0:02:10.00,0:02:11.00,Default,,0,0,0,, And the land will never be crossed
+Dialogue: 0,0:02:11.00,0:02:12.00,Default,,0,0,0,, The establishment on our home
+Dialogue: 0,0:02:12.00,0:02:14.00,Default,,0,0,0,, And that land had to be drawn
+Dialogue: 0,0:02:14.00,0:02:16.00,Default,,0,0,0,, And that land is what we wish
+Dialogue: 0,0:02:16.00,0:02:18.00,Default,,0,0,0,, So remember me when I'm gone
+Dialogue: 0,0:02:20.00,0:02:23.00,Default,,0,0,0,, Not coming, not talking about family with families all that we got
+Dialogue: 0,0:02:23.00,0:02:26.00,Default,,0,0,0,, Everything I would do you was standing there by my side
+Dialogue: 0,0:02:26.00,0:02:28.00,Default,,0,0,0,, And now you're going to be with me for the last ride
+Dialogue: 0,0:02:28.00,0:02:33.00,Default,,0,0,0,, Never like I'm your way
+Dialogue: 0,0:02:35.00,0:02:39.00,Default,,0,0,0,, Hold every mad man to go
+Dialogue: 0,0:02:40.00,0:02:43.00,Default,,0,0,0,, And every road you take
+Dialogue: 0,0:02:44.00,0:02:48.00,Default,,0,0,0,, Loves each you go
+Dialogue: 0,0:02:49.00,0:02:51.00,Default,,0,0,0,, Oh, oh, oh, oh
+Dialogue: 0,0:02:52.00,0:02:55.00,Default,,0,0,0,, It's been a long day
+Dialogue: 0,0:02:56.00,0:02:58.00,Default,,0,0,0,, Without you, my friend
+Dialogue: 0,0:02:59.00,0:03:04.00,Default,,0,0,0,, And I'll tell you all about it when I see you again
+Dialogue: 0,0:03:04.00,0:03:10.00,Default,,0,0,0,, We've come a long way from where we began
+Dialogue: 0,0:03:10.00,0:03:16.00,Default,,0,0,0,, Oh, I'll tell you all about it when I see you again
+Dialogue: 0,0:03:16.00,0:03:21.00,Default,,0,0,0,, When I see you again
+Dialogue: 0,0:03:22.00,0:03:24.00,Default,,0,0,0,, Oh, oh, oh, oh
+Dialogue: 0,0:03:24.00,0:03:28.00,Default,,0,0,0,, Yeah, yeah, yeah, yeah
+Dialogue: 0,0:03:28.00,0:03:33.00,Default,,0,0,0,, When I see you again
+Dialogue: 0,0:03:33.00,0:03:36.00,Default,,0,0,0,, Yeah, yeah, yeah
+Dialogue: 0,0:03:36.00,0:03:41.00,Default,,0,0,0,, Oh, oh, oh, oh, oh
+Dialogue: 0,0:03:41.00,0:03:43.00,Default,,0,0,0,, Yeah, yeah, yeah
\ No newline at end of file
diff --git a/web_outputs/d0960ed5-a82d-444d-9b0e-85207b0df8dc/13acc1b9-2cae-415e-a4d0-42004c338fe7_SeeYouAgain.ass b/web_outputs/d0960ed5-a82d-444d-9b0e-85207b0df8dc/13acc1b9-2cae-415e-a4d0-42004c338fe7_SeeYouAgain.ass
new file mode 100644
index 0000000..cc6c138
--- /dev/null
+++ b/web_outputs/d0960ed5-a82d-444d-9b0e-85207b0df8dc/13acc1b9-2cae-415e-a4d0-42004c338fe7_SeeYouAgain.ass
@@ -0,0 +1,74 @@
+[Script Info]
+; Script generated by VideoSubtitleAI
+Title: Untitled
+Original Script: VideoSubtitleAI
+ScriptType: v4.00+
+Collisions: Normal
+PlayResX: 1920
+PlayResY: 1080
+Timer: 100.0000
+
+[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,28,&H00E5B5F2,&H000000FF,&H00FFFFFF,&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
+Dialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,,音乐\NMusic
+Dialogue: 0,0:00:10.00,0:00:17.00,Default,,0,0,0,,没有你,我的朋友,这是漫长的一天\NIt's been a long day without you, my friend
+Dialogue: 0,0:00:17.00,0:00:23.00,Default,,0,0,0,,当我再次见到你时,我会告诉你一切\NAnd I'll tell you all about it when I see you again
+Dialogue: 0,0:00:23.00,0:00:29.00,Default,,0,0,0,,我们从开始的地方走了很长的路\NWe've come a long way from where we began
+Dialogue: 0,0:00:29.00,0:00:34.00,Default,,0,0,0,,当我再次见到你时,我们会告诉你一切\NWe'll tell you all about it when I see you again
+Dialogue: 0,0:00:34.00,0:00:37.00,Default,,0,0,0,,与你重逢之时\NWhen I see you again
+Dialogue: 0,0:00:39.00,0:00:42.00,Default,,0,0,0,,该死,谁知道我们开的所有飞机\NDamn, who knew all the planes we flew
+Dialogue: 0,0:00:42.00,0:00:44.00,Default,,0,0,0,,我们经历过的好事\NGood things we've been through
+Dialogue: 0,0:00:44.00,0:00:46.00,Default,,0,0,0,,现在我们就站在这里跟你说话\NThat now we stand right here talking to you
+Dialogue: 0,0:00:46.00,0:00:48.00,Default,,0,0,0,,关于另一条路\NAbout another path
+Dialogue: 0,0:00:48.00,0:00:50.00,Default,,0,0,0,,我知道我们喜欢在路上笑\NI know we loved to hit the road and laugh
+Dialogue: 0,0:00:50.00,0:00:52.00,Default,,0,0,0,,但有件事告诉我,脚和土地\NBut something told me that the foot and land
+Dialogue: 0,0:00:52.00,0:00:55.00,Default,,0,0,0,,不得不换个角度,看看不同的东西\NHad to switch up, look at things different
+Dialogue: 0,0:00:55.00,0:00:56.00,Default,,0,0,0,,查看大图\NSee the bigger picture
+Dialogue: 0,0:00:57.00,0:00:59.00,Default,,0,0,0,,那是艰苦工作永远有回报的日子\NThose were the days hard work for ever-pays
+Dialogue: 0,0:00:59.00,0:01:01.00,Default,,0,0,0,,现在我看到你当更好的地方\NNow I see you when the better place
+Dialogue: 0,0:01:01.00,0:01:03.00,Default,,0,0,0,,再见,当更好的方式\NSee you when the better way
+Dialogue: 0,0:01:03.00,0:01:07.00,Default,,0,0,0,,啊,我觉得我们不是在和家人谈论家庭\NAh, I feel we not talk about family with families
+Dialogue: 0,0:01:07.00,0:01:09.00,Default,,0,0,0,,我们得到的一切,我会做的一切\NAll that we got, everything I would do
+Dialogue: 0,0:01:09.00,0:01:11.00,Default,,0,0,0,,你站在我身边\NYou were standing there by my side
+Dialogue: 0,0:01:11.00,0:01:13.00,Default,,0,0,0,,现在,你将与我共度最后一程\NAnd now you're going to be with me for the last ride
+Dialogue: 0,0:01:13.00,0:01:19.00,Default,,0,0,0,,没有你,我度过了漫长的一天,我的朋友\NBeen a long day without you, my friend
+Dialogue: 0,0:01:19.00,0:01:25.00,Default,,0,0,0,,当我再次见到你时,我会告诉你一切\NAnd I'll tell you all about it when I see you again
+Dialogue: 0,0:01:26.00,0:01:31.00,Default,,0,0,0,,我们从开始的地方走了很长的路\NWe've come a long way from where we began
+Dialogue: 0,0:01:31.00,0:01:37.00,Default,,0,0,0,,当我再次见到你时,我们会告诉你一切\NWe'll tell you all about it when I see you again
+Dialogue: 0,0:01:37.00,0:01:40.00,Default,,0,0,0,,当我再次见到你时,我们会告诉你的\NWe'll tell you when I see you again
+Dialogue: 0,0:01:56.00,0:01:58.00,Default,,0,0,0,,首先,你们俩都在氛围中走自己的路\NFirst you both go out your way in the vibe
+Dialogue: 0,0:01:58.00,0:02:00.00,Default,,0,0,0,,结束了,醉醺醺地转了个小弯\NIt's finished, drunken with small turns
+Dialogue: 0,0:02:00.00,0:02:02.00,Default,,0,0,0,,两个是友谊,一个友谊,一种纽带\NTwo were friendship, a friendship, a bond
+Dialogue: 0,0:02:02.00,0:02:04.00,Default,,0,0,0,,这种纽带永远不会被打破\NAnd that bond will never be broken
+Dialogue: 0,0:02:04.00,0:02:05.00,Default,,0,0,0,,水平永远不会丢失\NAnd the level never get lost
+Dialogue: 0,0:02:08.00,0:02:10.00,Default,,0,0,0,,当哥哥先来的时候\NAnd when brother will come first
+Dialogue: 0,0:02:10.00,0:02:11.00,Default,,0,0,0,,这片土地永远不会被越过\NAnd the land will never be crossed
+Dialogue: 0,0:02:11.00,0:02:12.00,Default,,0,0,0,,我们家的建立\NThe establishment on our home
+Dialogue: 0,0:02:12.00,0:02:14.00,Default,,0,0,0,,那片土地必须被开垦\NAnd that land had to be drawn
+Dialogue: 0,0:02:14.00,0:02:16.00,Default,,0,0,0,,那片土地正是我们所希望的\NAnd that land is what we wish
+Dialogue: 0,0:02:16.00,0:02:18.00,Default,,0,0,0,,所以,当我离开时,请记住我\NSo remember me when I'm gone
+Dialogue: 0,0:02:20.00,0:02:23.00,Default,,0,0,0,,不来,不跟家人谈论我们所有的家人\NNot coming, not talking about family with families all that we got
+Dialogue: 0,0:02:23.00,0:02:26.00,Default,,0,0,0,,我所做的一切,你都站在我身边\NEverything I would do you was standing there by my side
+Dialogue: 0,0:02:26.00,0:02:28.00,Default,,0,0,0,,现在,你将与我共度最后一程\NAnd now you're going to be with me for the last ride
+Dialogue: 0,0:02:28.00,0:02:33.00,Default,,0,0,0,,我从来都不喜欢你的方式\NNever like I'm your way
+Dialogue: 0,0:02:35.00,0:02:39.00,Default,,0,0,0,,让每个疯子都走\NHold every mad man to go
+Dialogue: 0,0:02:40.00,0:02:43.00,Default,,0,0,0,,你走的每一条路\NAnd every road you take
+Dialogue: 0,0:02:44.00,0:02:48.00,Default,,0,0,0,,爱你走的每一个人\NLoves each you go
+Dialogue: 0,0:02:49.00,0:02:51.00,Default,,0,0,0,, Oh, oh, oh, oh
+Dialogue: 0,0:02:52.00,0:02:55.00,Default,,0,0,0,,真是漫长的一天\NIt's been a long day
+Dialogue: 0,0:02:56.00,0:02:58.00,Default,,0,0,0,,没有你,我的朋友\NWithout you, my friend
+Dialogue: 0,0:02:59.00,0:03:04.00,Default,,0,0,0,,当我再次见到你时,我会告诉你一切\NAnd I'll tell you all about it when I see you again
+Dialogue: 0,0:03:04.00,0:03:10.00,Default,,0,0,0,,我们从开始的地方走了很长的路\NWe've come a long way from where we began
+Dialogue: 0,0:03:10.00,0:03:16.00,Default,,0,0,0,,哦,等我再见到你,我会告诉你一切的\NOh, I'll tell you all about it when I see you again
+Dialogue: 0,0:03:16.00,0:03:21.00,Default,,0,0,0,,与你重逢之时\NWhen I see you again
+Dialogue: 0,0:03:22.00,0:03:24.00,Default,,0,0,0,, Oh, oh, oh
+Dialogue: 0,0:03:24.00,0:03:27.00,Default,,0,0,0,,是,是,是\NYeah, yeah, yeah
+Dialogue: 0,0:03:28.00,0:03:33.00,Default,,0,0,0,,与你重逢之时\NWhen I see you again
+Dialogue: 0,0:03:34.00,0:03:36.00,Default,,0,0,0,,是,是,是\NYeah, yeah, yeah
+Dialogue: 0,0:03:36.00,0:03:39.00,Default,,0,0,0,, Oh, oh, oh, oh, oh
+Dialogue: 0,0:03:40.00,0:03:43.00,Default,,0,0,0,,是,是,是\NYeah, yeah, yeah
+Dialogue: 0,0:03:46.00,0:03:56.00,Default,,0,0,0,,是,是,是\NYeah, yeah, yeah, yeah
\ No newline at end of file
diff --git a/web_outputs/d0960ed5-a82d-444d-9b0e-85207b0df8dc/13acc1b9-2cae-415e-a4d0-42004c338fe7_SeeYouAgain_edited.ass b/web_outputs/d0960ed5-a82d-444d-9b0e-85207b0df8dc/13acc1b9-2cae-415e-a4d0-42004c338fe7_SeeYouAgain_edited.ass
new file mode 100644
index 0000000..6b1d3bd
--- /dev/null
+++ b/web_outputs/d0960ed5-a82d-444d-9b0e-85207b0df8dc/13acc1b9-2cae-415e-a4d0-42004c338fe7_SeeYouAgain_edited.ass
@@ -0,0 +1,74 @@
+[Script Info]
+; Script generated by VideoSubtitleAI
+Title: Untitled
+Original Script: VideoSubtitleAI
+ScriptType: v4.00+
+Collisions: Normal
+PlayResX: 1920
+PlayResY: 1080
+Timer: 100.0000
+
+[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,&H005D5DD0,&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
+Dialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,,音乐\NMusic
+Dialogue: 0,0:00:10.00,0:00:17.00,Default,,0,0,0,,没有你,我的朋友,这是漫长的一天\NIt's been a long day without you, my friend
+Dialogue: 0,0:00:17.00,0:00:23.00,Default,,0,0,0,,当我再次见到你时,我会告诉你一切\NAnd I'll tell you all about it when I see you again
+Dialogue: 0,0:00:23.00,0:00:29.00,Default,,0,0,0,,我们从开始的地方走了很长的路\NWe've come a long way from where we began
+Dialogue: 0,0:00:29.00,0:00:34.00,Default,,0,0,0,,当我再次见到你时,我们会告诉你一切\NWe'll tell you all about it when I see you again
+Dialogue: 0,0:00:34.00,0:00:37.00,Default,,0,0,0,,与你重逢之时\NWhen I see you again
+Dialogue: 0,0:00:39.00,0:00:42.00,Default,,0,0,0,,该死,谁知道我们开的所有飞机\NDamn, who knew all the planes we flew
+Dialogue: 0,0:00:42.00,0:00:44.00,Default,,0,0,0,,我们经历过的好事\NGood things we've been through
+Dialogue: 0,0:00:44.00,0:00:46.00,Default,,0,0,0,,现在我们就站在这里跟你说话\NThat now we stand right here talking to you
+Dialogue: 0,0:00:46.00,0:00:48.00,Default,,0,0,0,,关于另一条路\NAbout another path
+Dialogue: 0,0:00:48.00,0:00:50.00,Default,,0,0,0,,我知道我们喜欢在路上笑\NI know we loved to hit the road and laugh
+Dialogue: 0,0:00:50.00,0:00:52.00,Default,,0,0,0,,但有件事告诉我,脚和土地\NBut something told me that the foot and land
+Dialogue: 0,0:00:52.00,0:00:55.00,Default,,0,0,0,,不得不换个角度,看看不同的东西\NHad to switch up, look at things different
+Dialogue: 0,0:00:55.00,0:00:56.00,Default,,0,0,0,,查看大图\NSee the bigger picture
+Dialogue: 0,0:00:57.00,0:00:59.00,Default,,0,0,0,,那是艰苦工作永远有回报的日子\NThose were the days hard work for ever-pays
+Dialogue: 0,0:00:59.00,0:01:01.00,Default,,0,0,0,,现在我看到你当更好的地方\NNow I see you when the better place
+Dialogue: 0,0:01:01.00,0:01:03.00,Default,,0,0,0,,再见,当更好的方式\NSee you when the better way
+Dialogue: 0,0:01:03.00,0:01:07.00,Default,,0,0,0,,啊,我觉得我们不是在和家人谈论家庭\NAh, I feel we not talk about family with families
+Dialogue: 0,0:01:07.00,0:01:09.00,Default,,0,0,0,,我们得到的一切,我会做的一切\NAll that we got, everything I would do
+Dialogue: 0,0:01:09.00,0:01:11.00,Default,,0,0,0,,你站在我身边\NYou were standing there by my side
+Dialogue: 0,0:01:11.00,0:01:13.00,Default,,0,0,0,,现在,你将与我共度最后一程\NAnd now you're going to be with me for the last ride
+Dialogue: 0,0:01:13.00,0:01:19.00,Default,,0,0,0,,没有你,我度过了漫长的一天,我的朋友\NBeen a long day without you, my friend
+Dialogue: 0,0:01:19.00,0:01:25.00,Default,,0,0,0,,当我再次见到你时,我会告诉你一切\NAnd I'll tell you all about it when I see you again
+Dialogue: 0,0:01:26.00,0:01:31.00,Default,,0,0,0,,我们从开始的地方走了很长的路\NWe've come a long way from where we began
+Dialogue: 0,0:01:31.00,0:01:37.00,Default,,0,0,0,,当我再次见到你时,我们会告诉你一切\NWe'll tell you all about it when I see you again
+Dialogue: 0,0:01:37.00,0:01:40.00,Default,,0,0,0,,当我再次见到你时,我们会告诉你的\NWe'll tell you when I see you again
+Dialogue: 0,0:01:56.00,0:01:58.00,Default,,0,0,0,,首先,你们俩都在氛围中走自己的路\NFirst you both go out your way in the vibe
+Dialogue: 0,0:01:58.00,0:02:00.00,Default,,0,0,0,,结束了,醉醺醺地转了个小弯\NIt's finished, drunken with small turns
+Dialogue: 0,0:02:00.00,0:02:02.00,Default,,0,0,0,,两个是友谊,一个友谊,一种纽带\NTwo were friendship, a friendship, a bond
+Dialogue: 0,0:02:02.00,0:02:04.00,Default,,0,0,0,,这种纽带永远不会被打破\NAnd that bond will never be broken
+Dialogue: 0,0:02:04.00,0:02:05.00,Default,,0,0,0,,水平永远不会丢失\NAnd the level never get lost
+Dialogue: 0,0:02:08.00,0:02:10.00,Default,,0,0,0,,当哥哥先来的时候\NAnd when brother will come first
+Dialogue: 0,0:02:10.00,0:02:11.00,Default,,0,0,0,,这片土地永远不会被越过\NAnd the land will never be crossed
+Dialogue: 0,0:02:11.00,0:02:12.00,Default,,0,0,0,,我们家的建立\NThe establishment on our home
+Dialogue: 0,0:02:12.00,0:02:14.00,Default,,0,0,0,,那片土地必须被开垦\NAnd that land had to be drawn
+Dialogue: 0,0:02:14.00,0:02:16.00,Default,,0,0,0,,那片土地正是我们所希望的\NAnd that land is what we wish
+Dialogue: 0,0:02:16.00,0:02:18.00,Default,,0,0,0,,所以,当我离开时,请记住我\NSo remember me when I'm gone
+Dialogue: 0,0:02:20.00,0:02:23.00,Default,,0,0,0,,不来,不跟家人谈论我们所有的家人\NNot coming, not talking about family with families all that we got
+Dialogue: 0,0:02:23.00,0:02:26.00,Default,,0,0,0,,我所做的一切,你都站在我身边\NEverything I would do you was standing there by my side
+Dialogue: 0,0:02:26.00,0:02:28.00,Default,,0,0,0,,现在,你将与我共度最后一程\NAnd now you're going to be with me for the last ride
+Dialogue: 0,0:02:28.00,0:02:33.00,Default,,0,0,0,,我从来都不喜欢你的方式\NNever like I'm your way
+Dialogue: 0,0:02:35.00,0:02:39.00,Default,,0,0,0,,让每个疯子都走\NHold every mad man to go
+Dialogue: 0,0:02:40.00,0:02:43.00,Default,,0,0,0,,你走的每一条路\NAnd every road you take
+Dialogue: 0,0:02:44.00,0:02:48.00,Default,,0,0,0,,爱你走的每一个人\NLoves each you go
+Dialogue: 0,0:02:49.00,0:02:51.00,Default,,0,0,0,,Oh, oh, oh, oh
+Dialogue: 0,0:02:52.00,0:02:55.00,Default,,0,0,0,,真是漫长的一天\NIt's been a long day
+Dialogue: 0,0:02:56.00,0:02:58.00,Default,,0,0,0,,没有你,我的朋友\NWithout you, my friend
+Dialogue: 0,0:02:59.00,0:03:04.00,Default,,0,0,0,,当我再次见到你时,我会告诉你一切\NAnd I'll tell you all about it when I see you again
+Dialogue: 0,0:03:04.00,0:03:10.00,Default,,0,0,0,,我们从开始的地方走了很长的路\NWe've come a long way from where we began
+Dialogue: 0,0:03:10.00,0:03:16.00,Default,,0,0,0,,哦,等我再见到你,我会告诉你一切的\NOh, I'll tell you all about it when I see you again
+Dialogue: 0,0:03:16.00,0:03:21.00,Default,,0,0,0,,与你重逢之时\NWhen I see you again
+Dialogue: 0,0:03:22.00,0:03:24.00,Default,,0,0,0,,Oh, oh, oh
+Dialogue: 0,0:03:24.00,0:03:27.00,Default,,0,0,0,,是,是,是\NYeah, yeah, yeah
+Dialogue: 0,0:03:28.00,0:03:33.00,Default,,0,0,0,,与你重逢之时\NWhen I see you again
+Dialogue: 0,0:03:34.00,0:03:36.00,Default,,0,0,0,,是,是,是\NYeah, yeah, yeah
+Dialogue: 0,0:03:36.00,0:03:39.00,Default,,0,0,0,,Oh, oh, oh, oh, oh
+Dialogue: 0,0:03:40.00,0:03:43.00,Default,,0,0,0,,是,是,是\NYeah, yeah, yeah
+Dialogue: 0,0:03:46.00,0:03:56.00,Default,,0,0,0,,是,是,是\NYeah, yeah, yeah, yeah
\ No newline at end of file
diff --git a/web_outputs/d0960ed5-a82d-444d-9b0e-85207b0df8dc/13acc1b9-2cae-415e-a4d0-42004c338fe7_SeeYouAgain_edited.vtt b/web_outputs/d0960ed5-a82d-444d-9b0e-85207b0df8dc/13acc1b9-2cae-415e-a4d0-42004c338fe7_SeeYouAgain_edited.vtt
new file mode 100644
index 0000000..349d8d7
--- /dev/null
+++ b/web_outputs/d0960ed5-a82d-444d-9b0e-85207b0df8dc/13acc1b9-2cae-415e-a4d0-42004c338fe7_SeeYouAgain_edited.vtt
@@ -0,0 +1,297 @@
+WEBVTT
+
+STYLE
+::cue {
+ background-color: transparent;
+ color: white;
+ font-family: 'Microsoft YaHei', sans-serif;
+ font-size: 24px;
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
+}
+
+00001
+00:00:00.000 --> 00:00:10.000
+音乐
+Music
+
+00002
+00:00:10.000 --> 00:00:17.000
+没有你,我的朋友,这是漫长的一天
+It's been a long day without you, my friend
+
+00003
+00:00:17.000 --> 00:00:23.000
+当我再次见到你时,我会告诉你一切
+And I'll tell you all about it when I see you again
+
+00004
+00:00:23.000 --> 00:00:29.000
+我们从开始的地方走了很长的路
+We've come a long way from where we began
+
+00005
+00:00:29.000 --> 00:00:34.000
+当我再次见到你时,我们会告诉你一切
+We'll tell you all about it when I see you again
+
+00006
+00:00:34.000 --> 00:00:37.000
+与你重逢之时
+When I see you again
+
+00007
+00:00:39.000 --> 00:00:42.000
+该死,谁知道我们开的所有飞机
+Damn, who knew all the planes we flew
+
+00008
+00:00:42.000 --> 00:00:44.000
+我们经历过的好事
+Good things we've been through
+
+00009
+00:00:44.000 --> 00:00:46.000
+现在我们就站在这里跟你说话
+That now we stand right here talking to you
+
+00010
+00:00:46.000 --> 00:00:48.000
+关于另一条路
+About another path
+
+00011
+00:00:48.000 --> 00:00:50.000
+我知道我们喜欢在路上笑
+I know we loved to hit the road and laugh
+
+00012
+00:00:50.000 --> 00:00:52.000
+但有件事告诉我,脚和土地
+But something told me that the foot and land
+
+00013
+00:00:52.000 --> 00:00:55.000
+不得不换个角度,看看不同的东西
+Had to switch up, look at things different
+
+00014
+00:00:55.000 --> 00:00:56.000
+查看大图
+See the bigger picture
+
+00015
+00:00:57.000 --> 00:00:59.000
+那是艰苦工作永远有回报的日子
+Those were the days hard work for ever-pays
+
+00016
+00:00:59.000 --> 00:01:01.000
+现在我看到你当更好的地方
+Now I see you when the better place
+
+00017
+00:01:01.000 --> 00:01:03.000
+再见,当更好的方式
+See you when the better way
+
+00018
+00:01:03.000 --> 00:01:07.000
+啊,我觉得我们不是在和家人谈论家庭
+Ah, I feel we not talk about family with families
+
+00019
+00:01:07.000 --> 00:01:09.000
+我们得到的一切,我会做的一切
+All that we got, everything I would do
+
+00020
+00:01:09.000 --> 00:01:11.000
+你站在我身边
+You were standing there by my side
+
+00021
+00:01:11.000 --> 00:01:13.000
+现在,你将与我共度最后一程
+And now you're going to be with me for the last ride
+
+00022
+00:01:13.000 --> 00:01:19.000
+没有你,我度过了漫长的一天,我的朋友
+Been a long day without you, my friend
+
+00023
+00:01:19.000 --> 00:01:25.000
+当我再次见到你时,我会告诉你一切
+And I'll tell you all about it when I see you again
+
+00024
+00:01:26.000 --> 00:01:31.000
+我们从开始的地方走了很长的路
+We've come a long way from where we began
+
+00025
+00:01:31.000 --> 00:01:37.000
+当我再次见到你时,我们会告诉你一切
+We'll tell you all about it when I see you again
+
+00026
+00:01:37.000 --> 00:01:40.000
+当我再次见到你时,我们会告诉你的
+We'll tell you when I see you again
+
+00027
+00:01:56.000 --> 00:01:58.000
+首先,你们俩都在氛围中走自己的路
+First you both go out your way in the vibe
+
+00028
+00:01:58.000 --> 00:02:00.000
+结束了,醉醺醺地转了个小弯
+It's finished, drunken with small turns
+
+00029
+00:02:00.000 --> 00:02:02.000
+两个是友谊,一个友谊,一种纽带
+Two were friendship, a friendship, a bond
+
+00030
+00:02:02.000 --> 00:02:04.000
+这种纽带永远不会被打破
+And that bond will never be broken
+
+00031
+00:02:04.000 --> 00:02:05.000
+水平永远不会丢失
+And the level never get lost
+
+00032
+00:02:08.000 --> 00:02:10.000
+当哥哥先来的时候
+And when brother will come first
+
+00033
+00:02:10.000 --> 00:02:11.000
+这片土地永远不会被越过
+And the land will never be crossed
+
+00034
+00:02:11.000 --> 00:02:12.000
+我们家的建立
+The establishment on our home
+
+00035
+00:02:12.000 --> 00:02:14.000
+那片土地必须被开垦
+And that land had to be drawn
+
+00036
+00:02:14.000 --> 00:02:16.000
+那片土地正是我们所希望的
+And that land is what we wish
+
+00037
+00:02:16.000 --> 00:02:18.000
+所以,当我离开时,请记住我
+So remember me when I'm gone
+
+00038
+00:02:20.000 --> 00:02:23.000
+不来,不跟家人谈论我们所有的家人
+Not coming, not talking about family with families all that we got
+
+00039
+00:02:23.000 --> 00:02:26.000
+我所做的一切,你都站在我身边
+Everything I would do you was standing there by my side
+
+00040
+00:02:26.000 --> 00:02:28.000
+现在,你将与我共度最后一程
+And now you're going to be with me for the last ride
+
+00041
+00:02:28.000 --> 00:02:33.000
+我从来都不喜欢你的方式
+Never like I'm your way
+
+00042
+00:02:35.000 --> 00:02:39.000
+让每个疯子都走
+Hold every mad man to go
+
+00043
+00:02:40.000 --> 00:02:43.000
+你走的每一条路
+And every road you take
+
+00044
+00:02:44.000 --> 00:02:48.000
+爱你走的每一个人
+Loves each you go
+
+00045
+00:02:49.000 --> 00:02:51.000
+Oh, oh, oh, oh
+
+00046
+00:02:52.000 --> 00:02:55.000
+真是漫长的一天
+It's been a long day
+
+00047
+00:02:56.000 --> 00:02:58.000
+没有你,我的朋友
+Without you, my friend
+
+00048
+00:02:59.000 --> 00:03:04.000
+当我再次见到你时,我会告诉你一切
+And I'll tell you all about it when I see you again
+
+00049
+00:03:04.000 --> 00:03:10.000
+我们从开始的地方走了很长的路
+We've come a long way from where we began
+
+00050
+00:03:10.000 --> 00:03:16.000
+哦,等我再见到你,我会告诉你一切的
+Oh, I'll tell you all about it when I see you again
+
+00051
+00:03:16.000 --> 00:03:21.000
+与你重逢之时
+When I see you again
+
+00052
+00:03:22.000 --> 00:03:24.000
+Oh, oh, oh
+
+00053
+00:03:24.000 --> 00:03:27.000
+是,是,是
+Yeah, yeah, yeah
+
+00054
+00:03:28.000 --> 00:03:33.000
+与你重逢之时
+When I see you again
+
+00055
+00:03:34.000 --> 00:03:36.000
+是,是,是
+Yeah, yeah, yeah
+
+00056
+00:03:36.000 --> 00:03:39.000
+Oh, oh, oh, oh, oh
+
+00057
+00:03:40.000 --> 00:03:43.000
+是,是,是
+Yeah, yeah, yeah
+
+00058
+00:03:46.000 --> 00:03:56.000
+是,是,是
+Yeah, yeah, yeah, yeah
diff --git a/web_outputs/d49fbe60-6806-445a-a985-c78aa40ab306/45e2f2bc-0c53-4374-a514-7db990ccfcf2_SeeYouAgain.ass b/web_outputs/d49fbe60-6806-445a-a985-c78aa40ab306/45e2f2bc-0c53-4374-a514-7db990ccfcf2_SeeYouAgain.ass
new file mode 100644
index 0000000..604a739
--- /dev/null
+++ b/web_outputs/d49fbe60-6806-445a-a985-c78aa40ab306/45e2f2bc-0c53-4374-a514-7db990ccfcf2_SeeYouAgain.ass
@@ -0,0 +1,73 @@
+[Script Info]
+; Script generated by VideoSubtitleAI
+Title: Untitled
+Original Script: VideoSubtitleAI
+ScriptType: v4.00+
+Collisions: Normal
+PlayResX: 1920
+PlayResY: 1080
+Timer: 100.0000
+
+[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
+Dialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,, Music
+Dialogue: 0,0:00:10.00,0:00:17.00,Default,,0,0,0,, It's been a long day without you, my friend
+Dialogue: 0,0:00:17.00,0:00:23.00,Default,,0,0,0,, And I'll tell you all about it when I see you again
+Dialogue: 0,0:00:23.00,0:00:29.00,Default,,0,0,0,, We've come a long way from where we began
+Dialogue: 0,0:00:29.00,0:00:34.00,Default,,0,0,0,, We'll tell you all about it when I see you again
+Dialogue: 0,0:00:34.00,0:00:37.00,Default,,0,0,0,, When I see you again
+Dialogue: 0,0:00:39.00,0:00:42.00,Default,,0,0,0,, Damn, who knew all the planes we flew
+Dialogue: 0,0:00:42.00,0:00:44.00,Default,,0,0,0,, Good things we've been through
+Dialogue: 0,0:00:44.00,0:00:46.00,Default,,0,0,0,, That now we stand right here talking to you
+Dialogue: 0,0:00:46.00,0:00:48.00,Default,,0,0,0,, About another path
+Dialogue: 0,0:00:48.00,0:00:50.00,Default,,0,0,0,, I know we loved to hit the road and laugh
+Dialogue: 0,0:00:50.00,0:00:52.00,Default,,0,0,0,, But something told me that the foot and land
+Dialogue: 0,0:00:52.00,0:00:55.00,Default,,0,0,0,, Had to switch up, look at things different
+Dialogue: 0,0:00:55.00,0:00:56.00,Default,,0,0,0,, See the bigger picture
+Dialogue: 0,0:00:57.00,0:00:59.00,Default,,0,0,0,, Those were the days hard work for ever-pays
+Dialogue: 0,0:00:59.00,0:01:01.00,Default,,0,0,0,, Now I see you when the better place
+Dialogue: 0,0:01:01.00,0:01:03.00,Default,,0,0,0,, See you when the better way
+Dialogue: 0,0:01:03.00,0:01:07.00,Default,,0,0,0,, Ah, I feel we not talk about family with families
+Dialogue: 0,0:01:07.00,0:01:09.00,Default,,0,0,0,, All that we got, everything I would do
+Dialogue: 0,0:01:09.00,0:01:11.00,Default,,0,0,0,, You were standing there by my side
+Dialogue: 0,0:01:11.00,0:01:13.00,Default,,0,0,0,, And now you're going to be with me for the last ride
+Dialogue: 0,0:01:13.00,0:01:19.00,Default,,0,0,0,, Been a long day without you, my friend
+Dialogue: 0,0:01:19.00,0:01:25.00,Default,,0,0,0,, And I'll tell you all about it when I see you again
+Dialogue: 0,0:01:26.00,0:01:31.00,Default,,0,0,0,, We've come a long way from where we began
+Dialogue: 0,0:01:31.00,0:01:37.00,Default,,0,0,0,, We'll tell you all about it when I see you again
+Dialogue: 0,0:01:37.00,0:01:40.00,Default,,0,0,0,, We'll tell you when I see you again
+Dialogue: 0,0:01:56.00,0:01:58.00,Default,,0,0,0,, First you both go out your way in the vibe
+Dialogue: 0,0:01:58.00,0:02:00.00,Default,,0,0,0,, It's finished, drunken with small turns
+Dialogue: 0,0:02:00.00,0:02:02.00,Default,,0,0,0,, Two were friendship, a friendship, a bond
+Dialogue: 0,0:02:02.00,0:02:04.00,Default,,0,0,0,, And that bond will never be broken
+Dialogue: 0,0:02:04.00,0:02:05.00,Default,,0,0,0,, And the level never get lost
+Dialogue: 0,0:02:08.00,0:02:10.00,Default,,0,0,0,, And when brother will come first
+Dialogue: 0,0:02:10.00,0:02:11.00,Default,,0,0,0,, And the land will never be crossed
+Dialogue: 0,0:02:11.00,0:02:12.00,Default,,0,0,0,, The establishment on our home
+Dialogue: 0,0:02:12.00,0:02:14.00,Default,,0,0,0,, And that land had to be drawn
+Dialogue: 0,0:02:14.00,0:02:16.00,Default,,0,0,0,, And that land is what we wish
+Dialogue: 0,0:02:16.00,0:02:18.00,Default,,0,0,0,, So remember me when I'm gone
+Dialogue: 0,0:02:20.00,0:02:23.00,Default,,0,0,0,, Not coming, not talking about family with families all that we got
+Dialogue: 0,0:02:23.00,0:02:26.00,Default,,0,0,0,, Everything I would do you was standing there by my side
+Dialogue: 0,0:02:26.00,0:02:28.00,Default,,0,0,0,, And now you're going to be with me for the last ride
+Dialogue: 0,0:02:28.00,0:02:33.00,Default,,0,0,0,, Never like I'm your way
+Dialogue: 0,0:02:35.00,0:02:39.00,Default,,0,0,0,, Hold every mad man to go
+Dialogue: 0,0:02:40.00,0:02:43.00,Default,,0,0,0,, And every road you take
+Dialogue: 0,0:02:44.00,0:02:48.00,Default,,0,0,0,, Loves each you go
+Dialogue: 0,0:02:49.00,0:02:51.00,Default,,0,0,0,, Oh, oh, oh, oh
+Dialogue: 0,0:02:52.00,0:02:55.00,Default,,0,0,0,, It's been a long day
+Dialogue: 0,0:02:56.00,0:02:58.00,Default,,0,0,0,, Without you, my friend
+Dialogue: 0,0:02:59.00,0:03:04.00,Default,,0,0,0,, And I'll tell you all about it when I see you again
+Dialogue: 0,0:03:04.00,0:03:10.00,Default,,0,0,0,, We've come a long way from where we began
+Dialogue: 0,0:03:10.00,0:03:16.00,Default,,0,0,0,, Oh, I'll tell you all about it when I see you again
+Dialogue: 0,0:03:16.00,0:03:21.00,Default,,0,0,0,, When I see you again
+Dialogue: 0,0:03:21.00,0:03:24.00,Default,,0,0,0,, Oh, oh, oh
+Dialogue: 0,0:03:24.00,0:03:28.00,Default,,0,0,0,, Yeah, yeah, yeah
+Dialogue: 0,0:03:28.00,0:03:33.00,Default,,0,0,0,, When I see you again
+Dialogue: 0,0:03:33.00,0:03:36.00,Default,,0,0,0,, Yeah, yeah, yeah
+Dialogue: 0,0:03:36.00,0:03:40.00,Default,,0,0,0,, Oh, oh, oh, oh, oh
+Dialogue: 0,0:03:40.00,0:03:44.00,Default,,0,0,0,, Yeah, yeah, yeah
\ No newline at end of file
diff --git a/web_outputs/fc527815-e78c-4af1-8a82-cbb99d65a278/1a74a69c-adfc-4f35-a972-2478070d49a6_SeeYouAgain.ass b/web_outputs/fc527815-e78c-4af1-8a82-cbb99d65a278/1a74a69c-adfc-4f35-a972-2478070d49a6_SeeYouAgain.ass
new file mode 100644
index 0000000..7077da7
--- /dev/null
+++ b/web_outputs/fc527815-e78c-4af1-8a82-cbb99d65a278/1a74a69c-adfc-4f35-a972-2478070d49a6_SeeYouAgain.ass
@@ -0,0 +1,79 @@
+[Script Info]
+; Script generated by VideoSubtitleAI
+Title: Untitled
+Original Script: VideoSubtitleAI
+ScriptType: v4.00+
+Collisions: Normal
+PlayResX: 1920
+PlayResY: 1080
+Timer: 100.0000
+
+[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
+Dialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,, Music\N Music
+Dialogue: 0,0:00:10.00,0:00:17.00,Default,,0,0,0,, It's been a long day without you, my friend\N It's been a long day without you, my friend
+Dialogue: 0,0:00:17.00,0:00:23.00,Default,,0,0,0,, And I'll tell you all about it when I see you again\N And I'll tell you all about it when I see you again
+Dialogue: 0,0:00:23.00,0:00:29.00,Default,,0,0,0,, We've come a long way from where we began\N We've come a long way from where we began
+Dialogue: 0,0:00:29.00,0:00:34.00,Default,,0,0,0,, We'll tell you all about it when I see you again\N We'll tell you all about it when I see you again
+Dialogue: 0,0:00:34.00,0:00:37.00,Default,,0,0,0,, When I see you again\N When I see you again
+Dialogue: 0,0:00:39.00,0:00:42.00,Default,,0,0,0,, Damn, who knew all the planes we flew\N Damn, who knew all the planes we flew
+Dialogue: 0,0:00:42.00,0:00:44.00,Default,,0,0,0,, Good things we've been through\N Good things we've been through
+Dialogue: 0,0:00:44.00,0:00:46.00,Default,,0,0,0,, That now we stand right here talking to you\N That now we stand right here talking to you
+Dialogue: 0,0:00:46.00,0:00:48.00,Default,,0,0,0,, About another path\N About another path
+Dialogue: 0,0:00:48.00,0:00:50.00,Default,,0,0,0,, I know we loved to hit the road and laugh\N I know we loved to hit the road and laugh
+Dialogue: 0,0:00:50.00,0:00:52.00,Default,,0,0,0,, But something told me that the foot and land\N But something told me that the foot and land
+Dialogue: 0,0:00:52.00,0:00:55.00,Default,,0,0,0,, Had to switch up, look at things different\N Had to switch up, look at things different
+Dialogue: 0,0:00:55.00,0:00:56.00,Default,,0,0,0,, See the bigger picture\N See the bigger picture
+Dialogue: 0,0:00:57.00,0:00:59.00,Default,,0,0,0,, Those were the days hard work for ever-pays\N Those were the days hard work for ever-pays
+Dialogue: 0,0:00:59.00,0:01:01.00,Default,,0,0,0,, Now I see you when the better place\N Now I see you when the better place
+Dialogue: 0,0:01:01.00,0:01:03.00,Default,,0,0,0,, See you when the better way\N See you when the better way
+Dialogue: 0,0:01:03.00,0:01:07.00,Default,,0,0,0,, Ah, I feel we not talk about family with families\N Ah, I feel we not talk about family with families
+Dialogue: 0,0:01:07.00,0:01:09.00,Default,,0,0,0,, All that we got, everything I would do\N All that we got, everything I would do
+Dialogue: 0,0:01:09.00,0:01:11.00,Default,,0,0,0,, You were standing there by my side\N You were standing there by my side
+Dialogue: 0,0:01:11.00,0:01:13.00,Default,,0,0,0,, And now you're going to be with me for the last ride\N And now you're going to be with me for the last ride
+Dialogue: 0,0:01:13.00,0:01:19.00,Default,,0,0,0,, Been a long day without you, my friend\N Been a long day without you, my friend
+Dialogue: 0,0:01:19.00,0:01:25.00,Default,,0,0,0,, And I'll tell you all about it when I see you again\N And I'll tell you all about it when I see you again
+Dialogue: 0,0:01:26.00,0:01:31.00,Default,,0,0,0,, We've come a long way from where we began\N We've come a long way from where we began
+Dialogue: 0,0:01:31.00,0:01:37.00,Default,,0,0,0,, We'll tell you all about it when I see you again\N We'll tell you all about it when I see you again
+Dialogue: 0,0:01:37.00,0:01:40.00,Default,,0,0,0,, We'll tell you when I see you again\N We'll tell you when I see you again
+Dialogue: 0,0:01:56.00,0:01:58.00,Default,,0,0,0,, First you both go out your way in the vibe\N First you both go out your way in the vibe
+Dialogue: 0,0:01:58.00,0:02:00.00,Default,,0,0,0,, It's finished, drunken with small turns\N It's finished, drunken with small turns
+Dialogue: 0,0:02:00.00,0:02:02.00,Default,,0,0,0,, Two were friendship, a friendship, a bond\N Two were friendship, a friendship, a bond
+Dialogue: 0,0:02:02.00,0:02:04.00,Default,,0,0,0,, And that bond will never be broken\N And that bond will never be broken
+Dialogue: 0,0:02:04.00,0:02:05.00,Default,,0,0,0,, And the level never get lost\N And the level never get lost
+Dialogue: 0,0:02:08.00,0:02:10.00,Default,,0,0,0,, And when brother will come first\N And when brother will come first
+Dialogue: 0,0:02:10.00,0:02:11.00,Default,,0,0,0,, And the land will never be crossed\N And the land will never be crossed
+Dialogue: 0,0:02:11.00,0:02:12.00,Default,,0,0,0,, The establishment on our home\N The establishment on our home
+Dialogue: 0,0:02:12.00,0:02:14.00,Default,,0,0,0,, And that land had to be drawn\N And that land had to be drawn
+Dialogue: 0,0:02:14.00,0:02:16.00,Default,,0,0,0,, And that land is what we wish\N And that land is what we wish
+Dialogue: 0,0:02:16.00,0:02:18.00,Default,,0,0,0,, So remember me when I'm gone\N So remember me when I'm gone
+Dialogue: 0,0:02:20.00,0:02:23.00,Default,,0,0,0,, Not coming, not talking about family with families all that we got\N Not coming, not talking about family with families all that we got
+Dialogue: 0,0:02:23.00,0:02:26.00,Default,,0,0,0,, Everything I would do you was standing there by my side\N Everything I would do you was standing there by my side
+Dialogue: 0,0:02:26.00,0:02:28.00,Default,,0,0,0,, And now you're going to be with me for the last ride\N And now you're going to be with me for the last ride
+Dialogue: 0,0:02:28.00,0:02:33.00,Default,,0,0,0,, Never like I'm your way\N Never like I'm your way
+Dialogue: 0,0:02:35.00,0:02:39.00,Default,,0,0,0,, Hold every mad man to go\N Hold every mad man to go
+Dialogue: 0,0:02:40.00,0:02:43.00,Default,,0,0,0,, And every road you take\N And every road you take
+Dialogue: 0,0:02:44.00,0:02:48.00,Default,,0,0,0,, Loves each you go\N Loves each you go
+Dialogue: 0,0:02:49.00,0:02:51.00,Default,,0,0,0,, Oh, oh, oh, oh
+Dialogue: 0,0:02:52.00,0:02:55.00,Default,,0,0,0,, It's been a long day\N It's been a long day
+Dialogue: 0,0:02:56.00,0:02:58.00,Default,,0,0,0,, Without you, my friend\N Without you, my friend
+Dialogue: 0,0:02:59.00,0:03:04.00,Default,,0,0,0,, And I'll tell you all about it when I see you again\N And I'll tell you all about it when I see you again
+Dialogue: 0,0:03:04.00,0:03:10.00,Default,,0,0,0,, We've come a long way from where we began\N We've come a long way from where we began
+Dialogue: 0,0:03:10.00,0:03:16.00,Default,,0,0,0,, Oh, I'll tell you all about it when I see you again\N Oh, I'll tell you all about it when I see you again
+Dialogue: 0,0:03:16.00,0:03:21.00,Default,,0,0,0,, When I see you again\N When I see you again
+Dialogue: 0,0:03:21.00,0:03:24.00,Default,,0,0,0,, Oh, oh, oh
+Dialogue: 0,0:03:24.00,0:03:27.00,Default,,0,0,0,, It's been a long day\N It's been a long day
+Dialogue: 0,0:03:27.00,0:03:30.00,Default,,0,0,0,, Without you, my friend\N Without you, my friend
+Dialogue: 0,0:03:30.00,0:03:35.00,Default,,0,0,0,, I see you again\N I see you again
+Dialogue: 0,0:03:35.00,0:03:39.00,Default,,0,0,0,, Oh, oh, oh, oh
+Dialogue: 0,0:03:40.00,0:03:43.00,Default,,0,0,0,, It's been a long day\N It's been a long day
+Dialogue: 0,0:03:46.00,0:03:53.00,Default,,0,0,0,, I'll tell you all about it when I see you again\N I'll tell you all about it when I see you again
+Dialogue: 0,0:03:53.00,0:03:58.00,Default,,0,0,0,, And every road you take\N And every road you take
+Dialogue: 0,0:03:58.00,0:04:01.00,Default,,0,0,0,, You go, oh, oh, oh, oh
+Dialogue: 0,0:04:01.00,0:04:04.00,Default,,0,0,0,, It's been a long day\N It's been a long day
+Dialogue: 0,0:04:04.00,0:04:06.00,Default,,0,0,0,, Without you, my friend\N Without you, my friend
+Dialogue: 0,0:04:06.00,0:04:11.00,Default,,0,0,0,, And I'll tell you all about it when I see you again\N And I'll tell you all about it when I see you again
\ No newline at end of file
diff --git a/web_uploads/13acc1b9-2cae-415e-a4d0-42004c338fe7_SeeYouAgain.mp4 b/web_uploads/13acc1b9-2cae-415e-a4d0-42004c338fe7_SeeYouAgain.mp4
new file mode 100644
index 0000000..9241936
Binary files /dev/null and b/web_uploads/13acc1b9-2cae-415e-a4d0-42004c338fe7_SeeYouAgain.mp4 differ
diff --git a/web_uploads/1a74a69c-adfc-4f35-a972-2478070d49a6_SeeYouAgain.mp4 b/web_uploads/1a74a69c-adfc-4f35-a972-2478070d49a6_SeeYouAgain.mp4
new file mode 100644
index 0000000..9241936
Binary files /dev/null and b/web_uploads/1a74a69c-adfc-4f35-a972-2478070d49a6_SeeYouAgain.mp4 differ
diff --git a/web_uploads/31fe47e0-2316-4a8a-9444-ef7893e73516_SeeYouAgain.mp4 b/web_uploads/31fe47e0-2316-4a8a-9444-ef7893e73516_SeeYouAgain.mp4
new file mode 100644
index 0000000..9241936
Binary files /dev/null and b/web_uploads/31fe47e0-2316-4a8a-9444-ef7893e73516_SeeYouAgain.mp4 differ
diff --git a/web_uploads/4258b1b9-1480-4a97-9336-b79052006627_SeeYouAgain.mp4 b/web_uploads/4258b1b9-1480-4a97-9336-b79052006627_SeeYouAgain.mp4
new file mode 100644
index 0000000..9241936
Binary files /dev/null and b/web_uploads/4258b1b9-1480-4a97-9336-b79052006627_SeeYouAgain.mp4 differ
diff --git a/web_uploads/43744820-b1a2-4464-9a9a-21327686afd8_SeeYouAgain.mp4 b/web_uploads/43744820-b1a2-4464-9a9a-21327686afd8_SeeYouAgain.mp4
new file mode 100644
index 0000000..9241936
Binary files /dev/null and b/web_uploads/43744820-b1a2-4464-9a9a-21327686afd8_SeeYouAgain.mp4 differ
diff --git a/web_uploads/45e2f2bc-0c53-4374-a514-7db990ccfcf2_SeeYouAgain.mp4 b/web_uploads/45e2f2bc-0c53-4374-a514-7db990ccfcf2_SeeYouAgain.mp4
new file mode 100644
index 0000000..9241936
Binary files /dev/null and b/web_uploads/45e2f2bc-0c53-4374-a514-7db990ccfcf2_SeeYouAgain.mp4 differ
diff --git a/web_uploads/6723977f-519f-4925-a9e9-d8cfe3373430_SeeYouAgain.mp4 b/web_uploads/6723977f-519f-4925-a9e9-d8cfe3373430_SeeYouAgain.mp4
new file mode 100644
index 0000000..9241936
Binary files /dev/null and b/web_uploads/6723977f-519f-4925-a9e9-d8cfe3373430_SeeYouAgain.mp4 differ
diff --git a/web_uploads/6d8081c7-c670-44ea-948c-a5912942bf2a_test_video.mp4 b/web_uploads/6d8081c7-c670-44ea-948c-a5912942bf2a_test_video.mp4
new file mode 100644
index 0000000..9241936
Binary files /dev/null and b/web_uploads/6d8081c7-c670-44ea-948c-a5912942bf2a_test_video.mp4 differ
diff --git a/web_uploads/77545a61-4e3c-4724-99ef-fc68308dbbe7_SeeYouAgain.mp4 b/web_uploads/77545a61-4e3c-4724-99ef-fc68308dbbe7_SeeYouAgain.mp4
new file mode 100644
index 0000000..9241936
Binary files /dev/null and b/web_uploads/77545a61-4e3c-4724-99ef-fc68308dbbe7_SeeYouAgain.mp4 differ
diff --git a/web_uploads/821b65f8-1db4-4a91-9d28-4195e8a79644_SeeYouAgain.mp4 b/web_uploads/821b65f8-1db4-4a91-9d28-4195e8a79644_SeeYouAgain.mp4
new file mode 100644
index 0000000..9241936
Binary files /dev/null and b/web_uploads/821b65f8-1db4-4a91-9d28-4195e8a79644_SeeYouAgain.mp4 differ
diff --git a/web_uploads/8e2a1785-d053-4dd5-bc69-64e219e91e7c_SeeYouAgain.mp4 b/web_uploads/8e2a1785-d053-4dd5-bc69-64e219e91e7c_SeeYouAgain.mp4
new file mode 100644
index 0000000..9241936
Binary files /dev/null and b/web_uploads/8e2a1785-d053-4dd5-bc69-64e219e91e7c_SeeYouAgain.mp4 differ
diff --git a/web_uploads/96033e00-0aa6-493f-bf9c-80511bd2a31c_fake.mp4 b/web_uploads/96033e00-0aa6-493f-bf9c-80511bd2a31c_fake.mp4
new file mode 100644
index 0000000..3562e94
--- /dev/null
+++ b/web_uploads/96033e00-0aa6-493f-bf9c-80511bd2a31c_fake.mp4
@@ -0,0 +1,10 @@
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
+This is not a real MP4 file. It's just text for testing.
diff --git a/web_uploads/9c628ed8-8ade-4f1d-9350-3b51c35129a7_SeeYouAgain.mp4 b/web_uploads/9c628ed8-8ade-4f1d-9350-3b51c35129a7_SeeYouAgain.mp4
new file mode 100644
index 0000000..9241936
Binary files /dev/null and b/web_uploads/9c628ed8-8ade-4f1d-9350-3b51c35129a7_SeeYouAgain.mp4 differ
diff --git a/web_uploads/a7325bae-1549-4b15-907f-0e4fa41eb643_SeeYouAgain.mp4 b/web_uploads/a7325bae-1549-4b15-907f-0e4fa41eb643_SeeYouAgain.mp4
new file mode 100644
index 0000000..9241936
Binary files /dev/null and b/web_uploads/a7325bae-1549-4b15-907f-0e4fa41eb643_SeeYouAgain.mp4 differ
diff --git a/web_uploads/bdd347b8-6b97-428a-bc4c-9fe2378906f8_SeeYouAgain.mp4 b/web_uploads/bdd347b8-6b97-428a-bc4c-9fe2378906f8_SeeYouAgain.mp4
new file mode 100644
index 0000000..9241936
Binary files /dev/null and b/web_uploads/bdd347b8-6b97-428a-bc4c-9fe2378906f8_SeeYouAgain.mp4 differ
diff --git a/web_uploads/cc8308fb-6424-4952-ae90-5474cbdb1504_SeeYouAgain.mp4 b/web_uploads/cc8308fb-6424-4952-ae90-5474cbdb1504_SeeYouAgain.mp4
new file mode 100644
index 0000000..9241936
Binary files /dev/null and b/web_uploads/cc8308fb-6424-4952-ae90-5474cbdb1504_SeeYouAgain.mp4 differ
diff --git a/web_uploads/d65978f3-cde1-4384-abb7-6706e1dabbc3_empty.mp4 b/web_uploads/d65978f3-cde1-4384-abb7-6706e1dabbc3_empty.mp4
new file mode 100644
index 0000000..e69de29
diff --git a/web_uploads/dd78e6c6-b537-4107-a344-ef114d1df970_SeeYouAgain.mp4 b/web_uploads/dd78e6c6-b537-4107-a344-ef114d1df970_SeeYouAgain.mp4
new file mode 100644
index 0000000..9241936
Binary files /dev/null and b/web_uploads/dd78e6c6-b537-4107-a344-ef114d1df970_SeeYouAgain.mp4 differ