subtitle-studio: 字幕生成系统
- 选择文件夹/单文件 → SenseVoice 转录 → LLM 修正 → 多路线翻译 → 双语 SRT - 路线:直译中文 / 英转中 / 仅转录 - 并发流水线:转录(可调) + LLM 并发(可调),降噪拆锁并发 - 断点续跑:.subtitle-work/ 中间产物,三阶段独立续跑 + 翻译段级续跑 - 去重:history 跟随视频文件夹,防重复任务保护 - 实时进度:SSE 推送 + 耗时显示 + 子任务状态 + 重新生成按钮 - 时间戳调优:VAD silence_schedule + noisereduce 降噪 + 完整性校验
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
||||
# 运行时产物
|
||||
logs/
|
||||
output/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# 环境与密钥
|
||||
.env
|
||||
env.local
|
||||
*.env
|
||||
|
||||
# 临时
|
||||
temp/
|
||||
*.log
|
||||
|
||||
# 中间产物(在视频目录下,不提交)
|
||||
.subtitle-work/
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
# 字幕生成系统 subtitle-studio — 架构设计
|
||||
|
||||
> 设计日期:2026-08-15 | 架构:纯 Windows 本地,自包含 | 目标:文件夹/单文件 → 多路线字幕
|
||||
|
||||
## 1. 总体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────── Windows 本机 ───────────────────────────┐
|
||||
│ │
|
||||
│ [Web 前端] 浏览器页面 │
|
||||
│ - 选择文件夹 / 单个文件 │
|
||||
│ - 选择翻译路线(直译 / 英转中 / 仅转录) │
|
||||
│ - 实时查看任务进度(SSE 推送) │
|
||||
│ - 已完成视频列表 + 字幕文件下载/打开 │
|
||||
│ │ HTTP/SSE │
|
||||
│ [FastAPI 后端] :8788 │
|
||||
│ - 任务管理器(队列 + 并发控制,默认 1 任务串行防 GPU 争抢) │
|
||||
│ - 进度状态机:pending → transcribing → fixing → translating → srt → done │
|
||||
│ │ │
|
||||
│ [管道核心] subsudio.pipeline │
|
||||
│ - transcribe.py SenseVoice + fsmn-vad(GPU,16kHz wav) │
|
||||
│ - fix.py LLM 分块并发修正日文/韩文转录 │
|
||||
│ - translate.py LLM 全文翻译(路线1 直译 / 路线2 英转中) │
|
||||
│ - srt.py 生成 .ja.zh.srt / .zh.srt / .zh.en.srt │
|
||||
│ │ │
|
||||
│ [运行时] py312_cuda(conda) + OCG Router (192.168.1.246:19878) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 2. 设计决策
|
||||
|
||||
### D1. 复用 py312_cuda 环境,不新建 venv
|
||||
- 转录需要 faster-whisper/funasr/torch-CUDA,已全部就绪于 `D:\ProgramData\anaconda3\envs\py312_cuda`
|
||||
- 后端 FastAPI/uvicorn 也装这个环境(检查缺包再补)
|
||||
- 避免重复装 CUDA 依赖(虚拟环境铁律:不污染系统,但可复用专用环境)
|
||||
|
||||
### D2. LLM 走 OCG Router(本地中转,自动选 key)
|
||||
- base_url `http://192.168.1.246:19878/v1`,apiKey `ocg-router-local`
|
||||
- 模型 `deepseek-v4-flash`(1M 上下文,全文一次请求)
|
||||
- 关键经验:**不指定 max_tokens**(推理模型会烧光截断);全文翻译 1746 段 105 秒
|
||||
|
||||
### D3. 翻译路线(用户可选)
|
||||
| 路线 | 流程 | 产物 |
|
||||
|------|------|------|
|
||||
| direct (默认) | 转录 → LLM修正 → 直译中文 | .ja.zh.srt + .zh.srt |
|
||||
| via_en | 转录 → LLM修正 → 英转中 | .r2.zh.en.srt + .r2.zh.srt |
|
||||
| transcribe_only | 只转录,不翻译 | _transcript.json |
|
||||
|
||||
### D4. 任务队列串行执行
|
||||
- GPU 转录是重资源操作,一次只跑 1 个任务
|
||||
- 翻译阶段可并发分块(fix 用 4 worker)
|
||||
- 进度通过 SSE 实时推送到前端
|
||||
|
||||
### D5. 输出目录
|
||||
- 默认:字幕文件生成到**视频同目录**(PotPlayer 自动加载)
|
||||
- 同时复制一份到 `output/<task_id>/` 便于管理/下载
|
||||
|
||||
## 3. 目录布局
|
||||
|
||||
```
|
||||
subtitle-studio/
|
||||
├── src/substudio/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI 入口(uvicorn)
|
||||
│ ├── taskmanager.py # 任务队列 + 状态机 + SSE 广播
|
||||
│ ├── pipeline/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── transcribe.py # SenseVoice 转录(复用技能逻辑)
|
||||
│ │ ├── fix.py # LLM 分块并发修正
|
||||
│ │ ├── translate.py # LLM 全文翻译(路线选择)
|
||||
│ │ └── srt.py # SRT 生成
|
||||
│ ├── config.py # 路径/模型/API 配置
|
||||
│ └── llm.py # OCG Router 客户端(流式兼容)
|
||||
├── templates/index.html # 前端页面
|
||||
├── static/app.js # 前端逻辑
|
||||
├── output/ # 任务输出
|
||||
├── scripts/start.bat # 启动脚本
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 4. 进度状态机
|
||||
|
||||
```
|
||||
pending → transcribing → fixing → translating → srt → done
|
||||
└────── error(失败即停,可重试)
|
||||
```
|
||||
|
||||
每阶段有 `progress`(0-100)和 `message`(如 "转录中 320/1746 段")
|
||||
SSE 事件:`task_update` 推送 `{task_id, status, progress, message}`
|
||||
|
||||
## 5. 关键技术点
|
||||
|
||||
### 5.1 转录(复用已验证逻辑)
|
||||
- ffmpeg 提取 16kHz 单声道 wav
|
||||
- **降噪(默认开启)**:noisereduce 频谱门控,prop_decrease=0.7(0.9 过度会字间隙),实测 RMS 降 64%,句子边界更清晰
|
||||
- **fsmn-vad 切段(时间戳精准关键)**:
|
||||
- `max_single_segment_time` 参数**无效**(实测)
|
||||
- 必须传自定义 `silence_schedule`:`[(8000,500),(12000,300),(20000,200),(inf,100)]`
|
||||
- 效果:段长从默认 15.67s → 4.64s 上限,平均 1.59s,时间戳严重错位修复
|
||||
- 根因:默认 schedule 产生长段 → 只能按字符比例估算句内时间 → 字幕与说话时间对不上
|
||||
- SenseVoiceSmall 逐段转录,rich_transcription_postprocess 清理
|
||||
- 输出句子级时间戳 JSON
|
||||
|
||||
### 5.2 修正(分块并发)
|
||||
- 按字符量分块(块边界=段边界,不切断句子)
|
||||
- 每块一个 worker 进程并发(4 worker)
|
||||
- prompt:结合上下文修正 ASR 错误,保留 [idx]
|
||||
|
||||
### 5.3 翻译(全文一次请求)
|
||||
- **不指定 max_tokens**(1M 上下文)
|
||||
- 全文 30K 字符一次请求,1746 段 105 秒
|
||||
- 输出 [idx] 行式解析
|
||||
|
||||
### 5.4 OCG Router 兼容
|
||||
- 兼容 SSE 流式返回(data: {...} 解析)
|
||||
- 有限重试(3 次退避,不疯狂重试)
|
||||
- 失败即停 + 进度保存(断点续传)
|
||||
|
||||
## 6. 安全与资源
|
||||
|
||||
- 无 Docker 操作,纯本地文件处理
|
||||
- 只读视频,写入同目录 SRT + output/
|
||||
- 一次一个任务(GPU 转录),翻译并发仅限 LLM API
|
||||
- 不碰系统环境(复用 py312_cuda 专用环境)
|
||||
@@ -0,0 +1,63 @@
|
||||
# subtitle-studio 字幕生成系统
|
||||
|
||||
选择文件夹或单个视频 → 自动生成多路线双语字幕,实时查看进度。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```powershell
|
||||
# 方式一(推荐):后台启动,无窗口,日志写 logs/server.log
|
||||
scripts\start_hidden.bat
|
||||
|
||||
# 方式二:前台启动(关窗口即停止)
|
||||
scripts\start.bat
|
||||
|
||||
# 停止后台服务
|
||||
scripts\stop.bat
|
||||
```
|
||||
|
||||
浏览器打开 **http://127.0.0.1:8788**(start_hidden.bat 会自动打开)
|
||||
|
||||
## 功能
|
||||
|
||||
1. **选择视频**:浏览文件系统(支持盘符切换/上级目录),或直接输入路径;选文件夹批量处理
|
||||
2. **选择路线**:
|
||||
- 路线 A 直译中文:转录 → LLM 修正 → 日文直译中文 → `.ja.zh.srt` + `.zh.srt`
|
||||
- 路线 B 英转中:转录 → LLM 修正 → 日→英→中 → `.r2.zh.en.srt` + `.r2.zh.srt`
|
||||
- 仅转录:只做语音识别 → `_transcript.json`
|
||||
3. **实时进度**:SSE 推送任务状态(等待/转录/修正/翻译/生成字幕/完成/失败)
|
||||
4. **完成列表**:已生成字幕文件可直接下载,失败任务可一键重试
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| 前端 | 原生 HTML/JS,SSE 实时进度 |
|
||||
| 后端 | FastAPI + uvicorn(:8788) |
|
||||
| 转录 | SenseVoiceSmall + fsmn-vad(GPU,py312_cuda 环境) |
|
||||
| 修正 | LLM 分块并发(4 worker) |
|
||||
| 翻译 | DeepSeek V4 Flash 全文一次请求(1M 上下文,不指定 max_tokens) |
|
||||
| LLM | OCG Router(192.168.1.246:19878,自动选 key) |
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
subtitle-studio/
|
||||
├── src/substudio/
|
||||
│ ├── main.py # FastAPI 入口
|
||||
│ ├── taskmanager.py # 任务队列 + 状态机 + SSE
|
||||
│ ├── llm.py # OCG Router 客户端(流式兼容)
|
||||
│ ├── config.py # 配置
|
||||
│ └── pipeline/
|
||||
│ ├── transcribe.py # SenseVoice 转录
|
||||
│ ├── fix.py # 分块并发修正
|
||||
│ ├── translate.py # 路线翻译
|
||||
│ └── srt.py # SRT 生成
|
||||
├── templates/index.html
|
||||
├── static/app.js
|
||||
├── output/ # 任务输出(含副本)
|
||||
└── scripts/start.bat
|
||||
```
|
||||
|
||||
## 架构文档
|
||||
|
||||
见 [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
@@ -0,0 +1,46 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
title subtitle-studio 字幕生成系统
|
||||
setlocal
|
||||
|
||||
REM ===== subtitle-studio 启动脚本 =====
|
||||
REM 自动定位项目根目录(bat 所在目录的上上级)
|
||||
set SCRIPT_DIR=%~dp0
|
||||
set PROJECT_ROOT=%SCRIPT_DIR%..
|
||||
set APP_DIR=%PROJECT_ROOT%\src
|
||||
set PYTHON=D:\ProgramData\anaconda3\envs\py312_cuda\python.exe
|
||||
set PORT=8788
|
||||
|
||||
REM 检查 Python 环境
|
||||
if not exist "%PYTHON%" (
|
||||
echo [错误] 找不到 Python 环境: %PYTHON%
|
||||
echo 请确认 py312_cuda 环境存在
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM 检查端口是否已被占用(已有实例在跑)
|
||||
netstat -ano | findstr ":%PORT%" | findstr "LISTENING" >nul 2>&1
|
||||
if %errorlevel%==0 (
|
||||
echo subtitle-studio 已在运行: http://127.0.0.1:%PORT%
|
||||
start http://127.0.0.1:%PORT%
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
echo ============================================
|
||||
echo subtitle-studio 字幕生成系统
|
||||
echo 服务地址: http://127.0.0.1:%PORT%
|
||||
echo 关闭本窗口即停止服务
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
REM 后台清理旧日志(可选)
|
||||
if exist "%PROJECT_ROOT%\logs\server.log" del "%PROJECT_ROOT%\logs\server.log" >nul 2>&1
|
||||
|
||||
REM 启动服务(前台,窗口关闭即停止)
|
||||
cd /d "%PROJECT_ROOT%"
|
||||
"%PYTHON%" -m uvicorn substudio.main:app --host 127.0.0.1 --port %PORT% --app-dir "%APP_DIR%"
|
||||
|
||||
echo.
|
||||
echo 服务已停止。
|
||||
pause
|
||||
@@ -0,0 +1,56 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
title subtitle-studio 启动器
|
||||
setlocal
|
||||
|
||||
REM ===== subtitle-studio 后台启动(无窗口)=====
|
||||
set SCRIPT_DIR=%~dp0
|
||||
set PROJECT_ROOT=%SCRIPT_DIR%..
|
||||
set APP_DIR=%PROJECT_ROOT%\src
|
||||
set PYTHON=D:\ProgramData\anaconda3\envs\py312_cuda\python.exe
|
||||
set PORT=8788
|
||||
set LOG_DIR=%PROJECT_ROOT%\logs
|
||||
|
||||
if not exist "%PYTHON%" (
|
||||
echo [错误] 找不到 Python 环境: %PYTHON%
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
netstat -ano | findstr ":%PORT%" | findstr "LISTENING" >nul 2>&1
|
||||
if %errorlevel%==0 (
|
||||
echo subtitle-studio 已在运行: http://127.0.0.1:%PORT%
|
||||
start http://127.0.0.1:%PORT%
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||
|
||||
REM 后台启动:用 cmd /c 包装重定向,start 本身不阻塞
|
||||
cd /d "%PROJECT_ROOT%"
|
||||
start "subtitle-studio-server" /min cmd /c ""%PYTHON%" -m uvicorn substudio.main:app --host 127.0.0.1 --port %PORT% --app-dir "%APP_DIR%" >"%LOG_DIR%\server.log" 2>&1"
|
||||
|
||||
REM 等待端口就绪
|
||||
set /a tries=0
|
||||
:waitloop
|
||||
set /a tries+=1
|
||||
if %tries% GTR 20 goto timeout
|
||||
netstat -ano | findstr ":%PORT%" | findstr "LISTENING" >nul 2>&1
|
||||
if %errorlevel%==0 goto ready
|
||||
timeout /t 1 /nobreak >nul
|
||||
goto waitloop
|
||||
|
||||
:ready
|
||||
echo.
|
||||
echo ✅ subtitle-studio 已启动
|
||||
echo http://127.0.0.1:%PORT%
|
||||
echo 日志: %LOG_DIR%\server.log
|
||||
echo.
|
||||
start http://127.0.0.1:%PORT%
|
||||
exit /b 0
|
||||
|
||||
:timeout
|
||||
echo.
|
||||
echo ⚠️ 启动超时,请查看日志: %LOG_DIR%\server.log
|
||||
pause
|
||||
exit /b 1
|
||||
@@ -0,0 +1,55 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
subtitle-studio 后台启动器(无阻塞)
|
||||
用 subprocess.Popen 启动 uvicorn,shell 调用立即返回
|
||||
日志写到 logs/server.log
|
||||
用法: python start_server.py [port]
|
||||
"""
|
||||
import sys, os, subprocess, time, threading
|
||||
|
||||
PYTHON = sys.executable
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
APP_DIR = os.path.join(PROJECT_ROOT, "src")
|
||||
LOG_DIR = os.path.join(PROJECT_ROOT, "logs")
|
||||
PORT = sys.argv[1] if len(sys.argv) > 1 else "8788"
|
||||
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
log_path = os.path.join(LOG_DIR, "server.log")
|
||||
|
||||
|
||||
def is_running(port):
|
||||
"""端口是否已被监听"""
|
||||
try:
|
||||
import socket
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(1)
|
||||
return s.connect_ex(("127.0.0.1", int(port))) == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
if is_running(PORT):
|
||||
print(f"subtitle-studio 已在运行: http://127.0.0.1:{PORT}")
|
||||
return
|
||||
|
||||
# 打开日志文件(追加模式)
|
||||
logf = open(log_path, 'a', encoding='utf-8', buffering=1)
|
||||
|
||||
# 真正的异步启动:不等待,不继承句柄
|
||||
proc = subprocess.Popen(
|
||||
[PYTHON, "-m", "uvicorn", "substudio.main:app",
|
||||
"--host", "127.0.0.1", "--port", PORT, "--app-dir", APP_DIR],
|
||||
cwd=PROJECT_ROOT,
|
||||
stdout=logf,
|
||||
stderr=subprocess.STDOUT,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
print(f"已启动 subtitle-studio (PID {proc.pid})")
|
||||
print(f"服务地址: http://127.0.0.1:{PORT}")
|
||||
print(f"日志: {log_path}")
|
||||
# 不等待进程,直接返回
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,23 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
title subtitle-studio 停止
|
||||
setlocal
|
||||
|
||||
REM ===== 停止 subtitle-studio 服务 =====
|
||||
set PORT=8788
|
||||
|
||||
echo 查找端口 %PORT% 上的进程...
|
||||
for /f "tokens=5" %%p in ('netstat -ano ^| findstr ":%PORT%" ^| findstr "LISTENING"') do (
|
||||
echo 停止 PID %%p
|
||||
taskkill /f /pid %%p >nul 2>&1
|
||||
)
|
||||
|
||||
timeout /t 1 /nobreak >nul
|
||||
|
||||
netstat -ano | findstr ":%PORT%" | findstr "LISTENING" >nul 2>&1
|
||||
if %errorlevel%==0 (
|
||||
echo ⚠️ 端口仍被占用,可能需要手动结束
|
||||
) else (
|
||||
echo ✅ subtitle-studio 已停止
|
||||
)
|
||||
pause
|
||||
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""subtitle-studio 配置"""
|
||||
import os
|
||||
|
||||
# 环境路径
|
||||
PYTHON = r"D:\ProgramData\anaconda3\envs\py312_cuda\python.exe"
|
||||
FFMPEG = r"D:\ProgramData\anaconda3\envs\py312_cuda\ffmpeg.exe"
|
||||
|
||||
# SenseVoice 模型缓存
|
||||
MODEL_DIR = r"C:\Users\hmo\.cache\modelscope\models\iic--SenseVoiceSmall\snapshots\master"
|
||||
VAD_DIR = r"C:\Users\hmo\.cache\modelscope\models\iic--speech_fsmn_vad_zh-cn-16k-common-pytorch\snapshots\master"
|
||||
|
||||
# OCG Router(可用环境变量覆盖:LLM_BASE_URL / LLM_API_KEY / LLM_MODEL)
|
||||
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "http://192.168.1.246:19878/v1")
|
||||
LLM_API_KEY = os.environ.get("LLM_API_KEY", "ocg-router-local")
|
||||
LLM_MODEL = os.environ.get("LLM_MODEL", "deepseek-v4-flash")
|
||||
|
||||
# 翻译路线
|
||||
ROUTES = {
|
||||
"direct": "直译中文(转录→修正→日文直译中文)",
|
||||
"via_en": "英转中(转录→修正→日文→英文→中文)",
|
||||
"transcribe_only": "仅转录(不翻译,输出 transcript.json)",
|
||||
}
|
||||
|
||||
# 并发
|
||||
FIX_WORKERS = 4 # 修正分块并发数
|
||||
FIX_BLOCK_CHARS = 5000 # 每块字符数
|
||||
MAX_CONCURRENT_TASKS = 2 # 转录并发(GPU 模型单例共享,16G 显存可并发 2;CPU 为实际瓶颈)
|
||||
LLM_CONCURRENCY = 6 # LLM 并发数(修正/翻译,3 key × 2,可调)
|
||||
|
||||
# 输出(项目根 output/,即 src/substudio/config.py 上三级)
|
||||
_OUTPUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "output")
|
||||
OUTPUT_DIR = _OUTPUT_DIR
|
||||
|
||||
# 中间产物统一子文件夹(放在视频所在文件夹下,固定名,一个文件夹存所有中间产物)
|
||||
WORK_DIRNAME = ".subtitle-work"
|
||||
@@ -0,0 +1,69 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""OCG Router LLM 客户端(兼容 SSE 流式返回 + 有限重试)"""
|
||||
import json, time, re
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
from .config import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL
|
||||
|
||||
|
||||
def call_llm(messages, timeout=180, temperature=0.3):
|
||||
"""调用 LLM,兼容 SSE 流式返回和普通 JSON 返回。
|
||||
关键:不指定 max_tokens(推理模型会烧光截断)
|
||||
timeout=180:OCG Router 内部已有 30s 首字节超时+换key,客户端 180s 足够,
|
||||
避免卡住请求等 20 分钟才重试
|
||||
"""
|
||||
body = json.dumps({
|
||||
"model": LLM_MODEL,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}).encode('utf-8')
|
||||
req = urllib.request.Request(LLM_BASE_URL + "/chat/completions", data=body, headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {LLM_API_KEY}",
|
||||
"Accept": "text/event-stream",
|
||||
})
|
||||
last_ex = None
|
||||
for attempt in range(3): # 有限重试,失败即停,不疯狂重试
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = resp.read()
|
||||
text = data.decode('utf-8', errors='replace')
|
||||
if text.strip().startswith('data:'):
|
||||
# SSE 流式:拼 content
|
||||
full = []
|
||||
for ev in text.split('\n\n'):
|
||||
for line in ev.split('\n'):
|
||||
if line.startswith('data: '):
|
||||
ds = line[6:].strip()
|
||||
if ds == '[DONE]':
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(ds)
|
||||
delta = obj["choices"][0]["delta"].get("content", "")
|
||||
if delta:
|
||||
full.append(delta)
|
||||
except Exception:
|
||||
pass
|
||||
return "".join(full)
|
||||
return json.loads(text)["choices"][0]["message"].get("content", "")
|
||||
except urllib.error.HTTPError as ex:
|
||||
last_ex = ex
|
||||
if ex.code in (400, 429) or 500 <= ex.code < 600:
|
||||
time.sleep(2 ** attempt)
|
||||
continue
|
||||
raise
|
||||
except Exception as ex:
|
||||
last_ex = ex
|
||||
time.sleep(2 ** attempt)
|
||||
raise last_ex
|
||||
|
||||
|
||||
def parse_idx_lines(resp):
|
||||
"""解析 [idx] 行式输出 -> {idx: text}"""
|
||||
result = {}
|
||||
for line in resp.splitlines():
|
||||
m = re.match(r'^\[(\d+)\]\s*(.+)$', line.strip())
|
||||
if m and m.group(2).strip():
|
||||
result[int(m.group(1))] = m.group(2).strip()
|
||||
return result
|
||||
@@ -0,0 +1,300 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""subtitle-studio 主入口:FastAPI Web 服务"""
|
||||
import os, sys, json, queue, glob
|
||||
import threading
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
|
||||
from substudio.taskmanager import manager
|
||||
from substudio.config import ROUTES
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
app = FastAPI(title="subtitle-studio")
|
||||
templates = Jinja2Templates(directory=os.path.join(PROJECT_ROOT, "templates"))
|
||||
|
||||
# 静态文件
|
||||
static_dir = os.path.join(PROJECT_ROOT, "static")
|
||||
if os.path.isdir(static_dir):
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def no_cache(request, call_next):
|
||||
"""禁用缓存:开发期确保前端改动立即生效"""
|
||||
response = await call_next(request)
|
||||
if request.url.path.startswith("/static") or request.url.path == "/":
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
class CreateTaskReq(BaseModel):
|
||||
source: str
|
||||
route: str = "direct"
|
||||
language: str = "ja"
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
return templates.TemplateResponse("index.html", {"request": request, "routes": ROUTES})
|
||||
|
||||
|
||||
@app.post("/api/tasks")
|
||||
def create_task(req: CreateTaskReq):
|
||||
if not os.path.exists(req.source):
|
||||
return JSONResponse({"error": f"路径不存在: {req.source}"}, status_code=400)
|
||||
if req.route not in ROUTES:
|
||||
return JSONResponse({"error": f"未知路线: {req.route}"}, status_code=400)
|
||||
task, skipped, reason = manager.create_task(req.source, req.route, req.language)
|
||||
if task is None:
|
||||
# 正在处理中 或 全部已处理过
|
||||
notice = reason or f"所选内容已用该路线处理过({len(skipped)} 个视频),未重复创建任务"
|
||||
return JSONResponse({
|
||||
"error": None,
|
||||
"skipped_all": True,
|
||||
"skipped": [os.path.basename(s) for s in skipped],
|
||||
"notice": notice,
|
||||
}, status_code=200)
|
||||
resp = task.to_dict()
|
||||
if skipped:
|
||||
resp["skipped"] = [os.path.basename(s) for s in skipped]
|
||||
resp["notice"] = f"跳过已处理的 {len(skipped)} 个视频(同路线去重)"
|
||||
return resp
|
||||
|
||||
|
||||
@app.get("/api/tasks")
|
||||
def list_tasks():
|
||||
return manager.list_tasks()
|
||||
|
||||
|
||||
@app.get("/api/tasks/{tid}")
|
||||
def get_task(tid: str):
|
||||
t = manager.get_task(tid)
|
||||
if not t:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
return t
|
||||
|
||||
|
||||
@app.post("/api/tasks/{tid}/retry")
|
||||
def retry_task(tid: str):
|
||||
t = manager.retry_task(tid)
|
||||
if not t:
|
||||
return JSONResponse({"error": "无法重试"}, status_code=400)
|
||||
return t
|
||||
|
||||
|
||||
@app.post("/api/tasks/{tid}/cancel")
|
||||
def cancel_task(tid: str):
|
||||
t = manager.cancel_task(tid)
|
||||
if not t:
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
return t
|
||||
|
||||
|
||||
@app.get("/api/events")
|
||||
def sse(request: Request):
|
||||
"""SSE 实时进度推送"""
|
||||
q = queue.Queue()
|
||||
manager.subscribe(q)
|
||||
|
||||
def gen():
|
||||
try:
|
||||
# 先发当前全部任务快照
|
||||
yield f"data: {json.dumps({'type': 'snapshot', 'tasks': manager.list_tasks()}, ensure_ascii=False)}\n\n"
|
||||
while True:
|
||||
if request.is_disconnected:
|
||||
break
|
||||
try:
|
||||
ev = q.get(timeout=10)
|
||||
yield f"data: {ev}\n\n"
|
||||
except queue.Empty:
|
||||
yield ": keepalive\n\n"
|
||||
finally:
|
||||
manager.unsubscribe(q)
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@app.get("/api/browse")
|
||||
def browse(path: str = ""):
|
||||
"""浏览文件系统,用于前端选择文件/文件夹"""
|
||||
base = path if path else os.path.expanduser("~")
|
||||
if not os.path.isdir(base):
|
||||
base = os.path.dirname(base)
|
||||
entries = []
|
||||
try:
|
||||
for name in sorted(os.listdir(base)):
|
||||
full = os.path.join(base, name)
|
||||
entries.append({
|
||||
"name": name,
|
||||
"path": full,
|
||||
"is_dir": os.path.isdir(full),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return {"cwd": base, "entries": entries}
|
||||
|
||||
|
||||
@app.get("/api/drives")
|
||||
def drives():
|
||||
import string
|
||||
out = []
|
||||
for letter in string.ascii_uppercase:
|
||||
if os.path.exists(f"{letter}:\\"):
|
||||
out.append(f"{letter}:\\")
|
||||
return {"drives": out}
|
||||
|
||||
|
||||
@app.get("/api/download")
|
||||
def download(path: str = ""):
|
||||
if not path or not os.path.isfile(path):
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
return FileResponse(path, filename=os.path.basename(path))
|
||||
|
||||
|
||||
@app.post("/api/video/regenerate")
|
||||
def regenerate_video(req: CreateTaskReq):
|
||||
"""重新生成单个视频:清空 .subtitle-work/ 中间产物 + SRT + history 记录,然后重跑"""
|
||||
from substudio.config import WORK_DIRNAME
|
||||
video = req.source
|
||||
route = req.route
|
||||
if not os.path.isfile(video):
|
||||
return JSONResponse({"error": f"不是文件: {video}"}, status_code=400)
|
||||
folder = os.path.dirname(video)
|
||||
base = os.path.splitext(os.path.basename(video))[0]
|
||||
work_dir = os.path.join(folder, WORK_DIRNAME)
|
||||
|
||||
# 1. 删除中间产物
|
||||
removed = []
|
||||
for suffix in ("_transcript.json", "_fixed.json", "_translated.json", "_audio.wav"):
|
||||
fp = os.path.join(work_dir, f"{base}{suffix}")
|
||||
if os.path.exists(fp):
|
||||
os.remove(fp)
|
||||
removed.append(os.path.basename(fp))
|
||||
|
||||
# 2. 删除 SRT(视频同目录)
|
||||
for suffix in (".ja.zh.srt", ".zh.srt", ".r2.zh.en.srt", ".r2.zh.srt"):
|
||||
fp = os.path.splitext(video)[0] + suffix
|
||||
if os.path.exists(fp):
|
||||
os.remove(fp)
|
||||
removed.append(os.path.basename(fp))
|
||||
|
||||
# 3. 删除 history 记录
|
||||
from substudio.taskmanager import _load_history, _save_history
|
||||
history = _load_history(video)
|
||||
hkey = f"{video}|{route}"
|
||||
if hkey in history:
|
||||
del history[hkey]
|
||||
_save_history(video, history)
|
||||
|
||||
# 4. 创建新任务重跑
|
||||
task, skipped, reason = manager.create_task(video, route, req.language)
|
||||
return {"removed": removed, "task_id": task.id if task else None, "status": "ok", "notice": reason}
|
||||
|
||||
|
||||
@app.get("/api/folder_status")
|
||||
def folder_status(request: Request):
|
||||
"""选定文件夹后,返回每个视频的处理状态(供任务列表自动加载)
|
||||
状态推断:.subtitle-work/ 中间产物 + history
|
||||
"""
|
||||
from substudio.taskmanager import _load_history
|
||||
from substudio.config import WORK_DIRNAME
|
||||
from .pipeline.transcribe import VIDEO_EXTS
|
||||
|
||||
path = request.query_params.get("path", "")
|
||||
route = request.query_params.get("route", "direct")
|
||||
if not os.path.isdir(path):
|
||||
return {"error": f"不是文件夹: {path}"}
|
||||
|
||||
work_dir = os.path.join(path, WORK_DIRNAME)
|
||||
history = _load_history(path)
|
||||
|
||||
videos = []
|
||||
for f in sorted(os.listdir(path)):
|
||||
if not f.lower().endswith(VIDEO_EXTS):
|
||||
continue
|
||||
full = os.path.join(path, f)
|
||||
base = os.path.splitext(f)[0]
|
||||
# 中间产物存在性
|
||||
has_transcript = os.path.exists(os.path.join(work_dir, f"{base}_transcript.json"))
|
||||
has_fixed = os.path.exists(os.path.join(work_dir, f"{base}_fixed.json"))
|
||||
has_translated = os.path.exists(os.path.join(work_dir, f"{base}_translated.json"))
|
||||
# history 完成记录
|
||||
hkey = f"{full}|{route}"
|
||||
done = hkey in history
|
||||
warnings = history[hkey].get("warnings", []) if done else []
|
||||
|
||||
# 推断状态
|
||||
if done:
|
||||
status = "done"
|
||||
progress = 100
|
||||
message = "已完成" + ("(含警告)" if warnings else "")
|
||||
elif has_translated:
|
||||
status = "translating" # 翻译中(部分完成,可续跑)
|
||||
progress = 70
|
||||
message = "翻译部分完成(可续跑)"
|
||||
elif has_fixed:
|
||||
status = "fixing"
|
||||
progress = 45
|
||||
message = "修正完成,待翻译(可续跑)"
|
||||
elif has_transcript:
|
||||
status = "transcribing"
|
||||
progress = 20
|
||||
message = "转录完成,待修正(可续跑)"
|
||||
else:
|
||||
status = "pending"
|
||||
progress = 0
|
||||
message = "未开始"
|
||||
|
||||
videos.append({
|
||||
"video": full,
|
||||
"name": f,
|
||||
"status": status,
|
||||
"progress": progress,
|
||||
"message": message,
|
||||
"warnings": warnings,
|
||||
})
|
||||
|
||||
return {"path": path, "work_dir": work_dir, "videos": videos}
|
||||
|
||||
|
||||
class ConcurrencyReq(BaseModel):
|
||||
llm: int = 6
|
||||
transcribe: int = 2
|
||||
|
||||
|
||||
@app.get("/api/concurrency")
|
||||
def get_concurrency():
|
||||
"""获取当前并发配置"""
|
||||
from substudio.config import MAX_CONCURRENT_TASKS, LLM_CONCURRENCY
|
||||
return {
|
||||
"transcribe": manager.transcribe_concurrency, # 转录并发(GPU 模型共享)
|
||||
"llm": manager.llm_concurrency, # LLM 并发(可调)
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/concurrency")
|
||||
def set_concurrency(req: ConcurrencyReq):
|
||||
"""调整并发数(transcribe 1-4, llm 1-20,可调)"""
|
||||
t = manager.set_transcribe_concurrency(req.transcribe) if req.transcribe else None
|
||||
l = manager.set_llm_concurrency(req.llm) if req.llm else None
|
||||
notice = []
|
||||
if t is not None:
|
||||
notice.append(f"转录并发 {t}")
|
||||
if l is not None:
|
||||
notice.append(f"LLM 并发 {l}")
|
||||
return {"transcribe": manager.transcribe_concurrency, "llm": manager.llm_concurrency,
|
||||
"notice": ",".join(notice)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8788
|
||||
print(f"subtitle-studio http://127.0.0.1:{port}")
|
||||
uvicorn.run(app, host="127.0.0.1", port=port)
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""修正 worker:单块日文转录修正(流式接收)"""
|
||||
import sys, io, os, json, time, re
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
from substudio.llm import call_llm, parse_idx_lines
|
||||
|
||||
FIX_PROMPT = (
|
||||
"You are an expert in Japanese speech recognition (ASR) post-processing. "
|
||||
"The following lines are automatic speech recognition output from a Japanese video. "
|
||||
"Many lines contain recognition errors: wrong kanji, missing or extra particles, "
|
||||
"misheard words, broken segments. "
|
||||
"\n\n"
|
||||
"For EACH line:\n"
|
||||
"1. Fix obvious ASR errors using the surrounding context to infer the correct Japanese.\n"
|
||||
"2. If a line is already correct, return it unchanged.\n"
|
||||
"3. Keep the [index] prefix exactly like [0], [1].\n"
|
||||
"4. Return ONLY the CORRECTED Japanese text, one line per input line, in the same order.\n"
|
||||
"5. Do NOT translate, do NOT merge lines, do NOT omit any line."
|
||||
)
|
||||
|
||||
|
||||
def fix_block(items):
|
||||
lines = "\n".join(f"[{i}] {it['text']}" for i, it in enumerate(items))
|
||||
resp = call_llm([
|
||||
{"role": "system", "content": FIX_PROMPT},
|
||||
{"role": "user", "content": lines},
|
||||
], temperature=0.1)
|
||||
result = parse_idx_lines(resp)
|
||||
out = []
|
||||
for i, it in enumerate(items):
|
||||
fixed = result.get(i, it['text'])
|
||||
out.append({**it, 'text': fixed})
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
in_path = sys.argv[1]
|
||||
out_path = sys.argv[2]
|
||||
with open(in_path, encoding='utf-8') as f:
|
||||
items = json.load(f)
|
||||
out = fix_block(items)
|
||||
with open(out_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(out, f, ensure_ascii=False, indent=1)
|
||||
print(f"worker DONE: {len(out)} -> {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,93 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""LLM 修正管道:按字符量分块,并发 worker 修正,保留 [idx]"""
|
||||
import os, json, subprocess, sys, time
|
||||
|
||||
from ..config import PYTHON, FIX_WORKERS, FIX_BLOCK_CHARS
|
||||
|
||||
WORKER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_fix_worker.py")
|
||||
|
||||
|
||||
def split_blocks(items, num_blocks):
|
||||
"""按字符量尽量均分 N 块(块边界=段边界,不切断句子)"""
|
||||
total_chars = sum(len(it['text']) for it in items)
|
||||
target = total_chars / num_blocks
|
||||
blocks = []
|
||||
cur_block, cur_chars = [], 0
|
||||
for i, it in enumerate(items):
|
||||
cur_block.append(i)
|
||||
cur_chars += len(it['text'])
|
||||
if cur_chars >= target and len(blocks) < num_blocks - 1:
|
||||
blocks.append(cur_block)
|
||||
cur_block, cur_chars = [], 0
|
||||
if cur_block:
|
||||
blocks.append(cur_block)
|
||||
return blocks
|
||||
|
||||
|
||||
def fix(items, progress_cb=None):
|
||||
"""修正转录文本(分块并发)。items: [{start_ms, end_ms, text}] -> 修正后的同结构 list"""
|
||||
def report(msg, pct):
|
||||
if progress_cb:
|
||||
progress_cb(msg, pct)
|
||||
|
||||
n = len(items)
|
||||
# 按字符量切块(动态块数,每块 ≤ FIX_BLOCK_CHARS)
|
||||
blocks = []
|
||||
cur_block, cur_chars = [], 0
|
||||
for i, it in enumerate(items):
|
||||
cur_block.append(i)
|
||||
cur_chars += len(it['text'])
|
||||
if cur_chars >= FIX_BLOCK_CHARS:
|
||||
blocks.append(cur_block)
|
||||
cur_block, cur_chars = [], 0
|
||||
if cur_block:
|
||||
blocks.append(cur_block)
|
||||
|
||||
report(f"修正分块 {len(blocks)} 块,启动并发 worker...", 0)
|
||||
tmp_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_fix_tmp")
|
||||
os.makedirs(tmp_dir, exist_ok=True)
|
||||
|
||||
procs = []
|
||||
tasks = []
|
||||
for b_idx, blk in enumerate(blocks):
|
||||
blk_items = [dict(items[i]) for i in blk]
|
||||
bfile = os.path.join(tmp_dir, f"block{b_idx}.json")
|
||||
bfile_out = os.path.join(tmp_dir, f"block{b_idx}_out.json")
|
||||
with open(bfile, 'w', encoding='utf-8') as f:
|
||||
json.dump(blk_items, f, ensure_ascii=False, indent=1)
|
||||
tasks.append((b_idx, blk, bfile, bfile_out))
|
||||
p = subprocess.Popen(
|
||||
[PYTHON, WORKER, bfile, bfile_out],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
procs.append(p)
|
||||
|
||||
t0 = time.time()
|
||||
done_blocks = 0
|
||||
while any(p.poll() is None for p in procs):
|
||||
time.sleep(3)
|
||||
cur_done = sum(1 for p in procs if p.poll() is not None)
|
||||
if cur_done > done_blocks:
|
||||
done_blocks = cur_done
|
||||
pct = int(done_blocks / len(procs) * 95)
|
||||
report(f"修正中 {done_blocks}/{len(procs)} 块完成", pct)
|
||||
for p in procs:
|
||||
p.wait()
|
||||
|
||||
# 合并
|
||||
out_list = [dict(it) for it in items]
|
||||
total_fixed = 0
|
||||
for b_idx, blk, bfile, bfile_out in tasks:
|
||||
if os.path.exists(bfile_out):
|
||||
with open(bfile_out, encoding='utf-8') as f:
|
||||
fixed_items = json.load(f)
|
||||
if len(fixed_items) == len(blk):
|
||||
for j, orig_idx in enumerate(blk):
|
||||
if fixed_items[j].get('text'):
|
||||
out_list[orig_idx]['text'] = fixed_items[j]['text']
|
||||
total_fixed += 1
|
||||
for fp in (bfile, bfile_out):
|
||||
if os.path.exists(fp):
|
||||
os.remove(fp)
|
||||
report(f"修正完成 {total_fixed}/{n} 段", 100)
|
||||
return out_list
|
||||
@@ -0,0 +1,70 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""SRT 生成模块"""
|
||||
import os
|
||||
|
||||
|
||||
def fmt_ts(ms):
|
||||
ms = max(0, int(ms))
|
||||
return "%02d:%02d:%02d,%03d" % (ms // 3600000, (ms % 3600000) // 60000, (ms % 60000) // 1000, ms % 1000)
|
||||
|
||||
|
||||
def build_srt_ja_zh(items):
|
||||
"""日文+中文双语(日上中下)"""
|
||||
lines = []
|
||||
for i, it in enumerate(items, 1):
|
||||
ja = it.get('text', '').strip()
|
||||
zh = it.get('zh', '').strip()
|
||||
if not ja and not zh:
|
||||
continue
|
||||
lines.append(f"{i}\n{fmt_ts(it['start_ms'])} --> {fmt_ts(it['end_ms'])}\n{ja}\n{zh}\n")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_srt_zh(items):
|
||||
lines = []
|
||||
for i, it in enumerate(items, 1):
|
||||
zh = it.get('zh', '').strip()
|
||||
if not zh:
|
||||
continue
|
||||
lines.append(f"{i}\n{fmt_ts(it['start_ms'])} --> {fmt_ts(it['end_ms'])}\n{zh}\n")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_srt_zh_en(items):
|
||||
"""中英双语(中上英下)"""
|
||||
lines = []
|
||||
for i, it in enumerate(items, 1):
|
||||
zh = it.get('zh', '').strip()
|
||||
en = it.get('en', '').strip()
|
||||
if not zh and not en:
|
||||
continue
|
||||
lines.append(f"{i}\n{fmt_ts(it['start_ms'])} --> {fmt_ts(it['end_ms'])}\n{zh}\n{en}\n")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write_srts(items, video_path, route, out_dir=None):
|
||||
"""按路线生成 SRT 到视频同目录 + 可选 output 目录。返回生成的文件列表"""
|
||||
base = os.path.splitext(video_path)[0]
|
||||
generated = []
|
||||
|
||||
def _write(path, content):
|
||||
with open(path, 'w', encoding='utf-8-sig') as f:
|
||||
f.write(content)
|
||||
generated.append(path)
|
||||
# 副本到 output 目录
|
||||
if out_dir:
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
dup = os.path.join(out_dir, os.path.basename(path))
|
||||
with open(dup, 'w', encoding='utf-8-sig') as f:
|
||||
f.write(content)
|
||||
generated.append(dup)
|
||||
|
||||
if route == "direct":
|
||||
_write(base + ".ja.zh.srt", build_srt_ja_zh(items))
|
||||
_write(base + ".zh.srt", build_srt_zh(items))
|
||||
elif route == "via_en":
|
||||
_write(base + ".r2.zh.en.srt", build_srt_zh_en(items))
|
||||
_write(base + ".r2.zh.srt", build_srt_zh(items))
|
||||
elif route == "transcribe_only":
|
||||
pass # 只输出 transcript.json
|
||||
return generated
|
||||
@@ -0,0 +1,258 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""转录管道:ffmpeg 提取音频 → fsmn-vad 切段 → SenseVoice 逐段转录 → 句子级时间戳"""
|
||||
import os, json, re, subprocess, tempfile
|
||||
|
||||
import numpy as np
|
||||
import wave
|
||||
|
||||
from funasr import AutoModel
|
||||
from funasr.utils.postprocess_utils import rich_transcription_postprocess
|
||||
|
||||
from ..config import FFMPEG, MODEL_DIR, VAD_DIR
|
||||
|
||||
VIDEO_EXTS = ('.mp4', '.mkv', '.avi', '.mov', '.flv', '.webm', '.ts', '.m4v')
|
||||
|
||||
# ---- 模型单例(全局复用,避免每次转录重新加载 ~3.2G 显存)----
|
||||
_model_lock = __import__('threading').Lock()
|
||||
_models = {"vad": None, "asr": None}
|
||||
|
||||
|
||||
def get_models():
|
||||
"""懒加载 + 线程安全获取 VAD 和 SenseVoice 模型(全局单例)"""
|
||||
global _models
|
||||
with _model_lock:
|
||||
if _models["vad"] is None:
|
||||
_models["vad"] = AutoModel(model=VAD_DIR, device="cuda:0", disable_update=True)
|
||||
if _models["asr"] is None:
|
||||
_models["asr"] = AutoModel(model=MODEL_DIR, device="cuda:0", disable_update=True)
|
||||
return _models["vad"], _models["asr"]
|
||||
|
||||
|
||||
def extract_audio(video_path, wav_path):
|
||||
subprocess.run([FFMPEG, '-y', '-hide_banner', '-loglevel', 'error', '-i', video_path,
|
||||
'-vn', '-acodec', 'pcm_s16le', '-ar', '16000', '-ac', '1', wav_path],
|
||||
check=True)
|
||||
|
||||
|
||||
def load_wav_int16(path):
|
||||
with wave.open(path, 'rb') as w:
|
||||
sr = w.getframerate()
|
||||
n = w.getnframes()
|
||||
data = np.frombuffer(w.readframes(n), dtype=np.int16)
|
||||
return data, sr
|
||||
|
||||
|
||||
def write_segment_wav(path, samples):
|
||||
with wave.open(path, 'wb') as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(16000)
|
||||
w.writeframes(samples.astype(np.int16).tobytes())
|
||||
|
||||
|
||||
def split_sentences(text):
|
||||
parts = re.split(r'(?<=[。!?!?])', text)
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
|
||||
|
||||
def refine_segment(seg):
|
||||
sentences = split_sentences(seg['text'])
|
||||
if len(sentences) <= 1:
|
||||
return [seg]
|
||||
total_chars = sum(len(s) for s in sentences)
|
||||
dur = seg['end_ms'] - seg['start_ms']
|
||||
out, cursor = [], seg['start_ms']
|
||||
for s in sentences:
|
||||
frac = len(s) / total_chars
|
||||
seg_dur = dur * frac
|
||||
out.append({'start_ms': int(cursor), 'end_ms': int(cursor + seg_dur), 'text': s})
|
||||
cursor += seg_dur
|
||||
return out
|
||||
|
||||
|
||||
def merge_fragments(segs, min_ms=2000):
|
||||
if not segs:
|
||||
return []
|
||||
merged = [dict(segs[0])]
|
||||
for seg in segs[1:]:
|
||||
last = merged[-1]
|
||||
gap = seg['start_ms'] - last['end_ms']
|
||||
new_dur = seg['end_ms'] - last['start_ms']
|
||||
if (last['end_ms'] - last['start_ms']) < min_ms and gap < 500 and new_dur < 8000:
|
||||
last['end_ms'] = seg['end_ms']
|
||||
last['text'] += seg['text']
|
||||
else:
|
||||
merged.append(dict(seg))
|
||||
return merged
|
||||
|
||||
|
||||
def split_long_segment(s_ms, e_ms, window_ms=8000):
|
||||
if e_ms - s_ms <= window_ms:
|
||||
return [(s_ms, e_ms)]
|
||||
out, cur = [], s_ms
|
||||
while cur < e_ms:
|
||||
nxt = min(cur + window_ms, e_ms)
|
||||
out.append((cur, nxt))
|
||||
cur = nxt
|
||||
return out
|
||||
|
||||
|
||||
def extract_and_denoise(video_path, wav_path, denoise=True, progress_cb=None):
|
||||
"""提取音频 + 降噪(纯 CPU,可并发执行,不占 GPU)
|
||||
返回 wav_path;denoise=True 时原地降噪
|
||||
"""
|
||||
def report(msg, pct):
|
||||
if progress_cb:
|
||||
progress_cb(msg, pct)
|
||||
|
||||
report("提取音频...", 30)
|
||||
os.makedirs(os.path.dirname(wav_path), exist_ok=True)
|
||||
extract_audio(video_path, wav_path)
|
||||
|
||||
if denoise:
|
||||
try:
|
||||
report("降噪中...", 60)
|
||||
import noisereduce as nr
|
||||
data, sr = load_wav_int16(wav_path)
|
||||
float_data = data.astype(np.float32) / 32768.0
|
||||
# prop_decrease=0.7:平衡降噪效果与语音保留(0.9 会过度降噪产生字间隙)
|
||||
denoised = nr.reduce_noise(y=float_data, sr=sr, prop_decrease=0.7)
|
||||
out16 = (denoised * 32768).astype(np.int16)
|
||||
write_segment_wav(wav_path, out16)
|
||||
except Exception as ex:
|
||||
print(f"降噪跳过: {ex}", flush=True)
|
||||
report("音频就绪", 100)
|
||||
return wav_path
|
||||
|
||||
|
||||
def transcribe(video_path, out_json, language="ja", max_seg_ms=8000, progress_cb=None, denoise=True, wav_path=None):
|
||||
"""完整转录(提取音频+降噪+VAD+ASR)。progress_cb(msg, percent) 可选回调
|
||||
denoise=True: 用 noisereduce 降噪(压制背景噪音,提升 ASR 准确率)
|
||||
wav_path: 指定音频路径(若已存在则跳过提取/降噪,只做 GPU 转录)
|
||||
"""
|
||||
t0 = __import__('time').time()
|
||||
|
||||
def report(msg, pct):
|
||||
if progress_cb:
|
||||
progress_cb(msg, pct)
|
||||
|
||||
# 1. 提取音频 + 降噪(CPU,可并发)——若 wav 已存在(调用方已做)则跳过
|
||||
tmp_dir = os.path.dirname(out_json)
|
||||
os.makedirs(tmp_dir, exist_ok=True)
|
||||
if wav_path is None:
|
||||
# 唯一命名,避免并发冲突
|
||||
base_name = os.path.splitext(os.path.basename(video_path))[0] if os.path.isfile(video_path) else "audio"
|
||||
safe_base = re.sub(r'[^\w\-.]', '_', base_name)[:30]
|
||||
wav_path = os.path.join(tmp_dir, f"_audio_{safe_base}_{os.getpid()}.wav")
|
||||
if not os.path.exists(wav_path) or os.path.getsize(wav_path) == 0:
|
||||
extract_and_denoise(video_path, wav_path, denoise, progress_cb)
|
||||
else:
|
||||
report("音频已就绪(跳过提取/降噪)", 10)
|
||||
|
||||
# 2. VAD(全局单例)
|
||||
report("加载 VAD 模型...", 5)
|
||||
vad, asr = get_models()
|
||||
report("VAD 语音检测...", 8)
|
||||
# 积极切分:自定义 silence_schedule 让 VAD 在短静音就切段,
|
||||
# 得到 ≤4.6s 的自然句段(默认会产生 15s 长段,只能靠字符比例估算时间 → 字幕错位)
|
||||
# max_single_segment_time 参数实测无效,必须用 silence_schedule
|
||||
AGGRESSIVE_SILENCE = [(8000, 500), (12000, 300), (20000, 200), (float('inf'), 100)]
|
||||
vad_res = vad.generate(input=wav_path, silence_schedule=AGGRESSIVE_SILENCE)
|
||||
segments = vad_res[0]["value"]
|
||||
total = len(segments)
|
||||
report(f"VAD 检测到 {total} 段", 10)
|
||||
|
||||
# 3. SenseVoice(asr 已从 get_models 获得)
|
||||
|
||||
results = []
|
||||
samples_all, sr = load_wav_int16(wav_path)
|
||||
# 临时段文件按视频唯一命名(并发转录时避免文件冲突 WinError 32)
|
||||
base_name = os.path.splitext(os.path.basename(video_path))[0] if os.path.isfile(video_path) else "seg"
|
||||
safe_base = re.sub(r'[^\w\-.]', '_', base_name)[:30]
|
||||
tmp_wav = os.path.join(tmp_dir, f"_seg_tmp_{safe_base}_{os.getpid()}.wav")
|
||||
|
||||
for i, (s_ms, e_ms) in enumerate(segments):
|
||||
for seg_s, seg_e in split_long_segment(s_ms, e_ms, max_seg_ms):
|
||||
s, e = int(seg_s / 1000 * sr), int(seg_e / 1000 * sr)
|
||||
seg_samples = samples_all[max(0, s):e]
|
||||
if len(seg_samples) < sr * 0.3:
|
||||
continue
|
||||
write_segment_wav(tmp_wav, seg_samples)
|
||||
try:
|
||||
res = asr.generate(input=tmp_wav, language=language, use_itn=True, batch_size_s=60)
|
||||
raw_text = res[0]["text"]
|
||||
text = rich_transcription_postprocess(raw_text)
|
||||
text = re.sub(r'<\|[^|]*\|>', '', text).strip()
|
||||
except Exception:
|
||||
continue
|
||||
if not text:
|
||||
continue
|
||||
for refined in refine_segment({'start_ms': seg_s, 'end_ms': seg_e, 'text': text}):
|
||||
results.append(refined)
|
||||
if (i + 1) % 50 == 0 or (i + 1) == total:
|
||||
pct = 12 + int((i + 1) / total * 78)
|
||||
report(f"转录中 {i+1}/{total} 段", pct)
|
||||
|
||||
# 转录完成前读音频时长(用于完整性校验)
|
||||
audio_duration_ms = 0
|
||||
try:
|
||||
with wave.open(wav_path, 'rb') as w:
|
||||
audio_duration_ms = int(w.getnframes() / w.getframerate() * 1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if os.path.exists(tmp_wav):
|
||||
os.remove(tmp_wav)
|
||||
if os.path.exists(wav_path):
|
||||
os.remove(wav_path)
|
||||
|
||||
results = merge_fragments(results)
|
||||
|
||||
# 完整性校验:残缺转录直接抛错;空洞仅警告(视频可能本身无对白)
|
||||
ok, warnings = validate_transcript(results, audio_duration_ms)
|
||||
if not ok:
|
||||
raise RuntimeError(f"转录校验失败: {warnings[0] if warnings else '未知'}")
|
||||
|
||||
with open(out_json, 'w', encoding='utf-8') as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=1)
|
||||
report(f"转录完成 {len(results)} 段(覆盖 {audio_duration_ms/1000:.0f}s)", 95)
|
||||
# 返回 (out_json, warnings) 供上层记录警告
|
||||
return out_json, warnings
|
||||
|
||||
def validate_transcript(results, audio_duration_ms, min_coverage=0.2, min_segments=5,
|
||||
max_gap_ms=30000):
|
||||
"""转录完整性校验:防止转录残缺但被标记完成
|
||||
返回 (ok, warnings)
|
||||
- 硬失败:空结果 / 段数过少 / 覆盖 <20%(AV 视频语音占比低,实测 ~25%)
|
||||
- 警告(不失败):段间空洞 >30s(可能是视频本身无对白,仅记录)
|
||||
"""
|
||||
warnings = []
|
||||
if not results:
|
||||
return False, ["转录结果为空(0 段)"]
|
||||
if len(results) < min_segments:
|
||||
return False, [f"转录段数过少: {len(results)} < {min_segments}"]
|
||||
if audio_duration_ms <= 0:
|
||||
return False, ["音频时长为 0"]
|
||||
|
||||
# 1. 总覆盖比例(硬失败)
|
||||
covered = results[-1]['end_ms'] - results[0]['start_ms']
|
||||
ratio = covered / audio_duration_ms
|
||||
if ratio < min_coverage:
|
||||
return False, [f"转录覆盖不足: {covered/1000:.0f}s/{audio_duration_ms/1000:.0f}s ({ratio:.0%} < {min_coverage:.0%})"]
|
||||
|
||||
# 2. 空洞检测(警告,不失败——视频可能本身无对白)
|
||||
prev_end = results[0]['end_ms']
|
||||
for i in range(1, len(results)):
|
||||
gap = results[i]['start_ms'] - prev_end
|
||||
if gap > max_gap_ms:
|
||||
warnings.append(f"段{i}前有 {gap/1000:.0f}s 无对白间隔(可能正常)")
|
||||
prev_end = max(prev_end, results[i]['end_ms'])
|
||||
|
||||
# 3. 尾部未覆盖(警告)
|
||||
tail = audio_duration_ms - results[-1]['end_ms']
|
||||
if tail > max_gap_ms * 2:
|
||||
warnings.append(f"音频尾部有 {tail/1000:.0f}s 未覆盖(可能无对白)")
|
||||
|
||||
return True, warnings
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""LLM 翻译管道:全文一次请求(1M 上下文)+ 段级增量续跑
|
||||
支持 existing: 已有翻译结果的段跳过,只翻缺失段(断点续跑)
|
||||
"""
|
||||
from ..llm import call_llm, parse_idx_lines
|
||||
|
||||
TRANSLATE_PROMPT_TMPL = (
|
||||
"You are a professional subtitle translator. Translate ALL the following "
|
||||
"subtitle lines into {lang_name}. "
|
||||
"Preserve the [index] prefix exactly like [0], [1]. "
|
||||
"Translate naturally for subtitle display. One translation per line, "
|
||||
"in the same order. Output ONLY the translated lines, nothing else. "
|
||||
"Do not omit, merge, or skip any line."
|
||||
)
|
||||
|
||||
LANG_NAMES = {"en": "English", "zh": "Simplified Chinese (简体中文)"}
|
||||
|
||||
|
||||
def translate_all(items, target_lang, progress_cb=None, existing=None):
|
||||
"""翻译(支持增量续跑)。
|
||||
items: [{text}] 全部段
|
||||
existing: {idx: 已翻译文本} —— 这些段跳过,只翻缺失段
|
||||
返回 {idx: text}
|
||||
"""
|
||||
existing = existing or {}
|
||||
# 找出缺失段
|
||||
missing_idx = [i for i in range(len(items)) if i not in existing or not existing[i]]
|
||||
if not missing_idx:
|
||||
if progress_cb:
|
||||
progress_cb(f"全部已翻译,跳过", 100)
|
||||
return dict(existing)
|
||||
|
||||
# 缺失段按字符量分组(每块 ~5000 字符,避免超长请求截断)
|
||||
BLOCK_CHARS = 5000
|
||||
blocks = []
|
||||
cur_block, cur_chars = [], 0
|
||||
for idx in missing_idx:
|
||||
cur_block.append(idx)
|
||||
cur_chars += len(items[idx]['text'])
|
||||
if cur_chars >= BLOCK_CHARS:
|
||||
blocks.append(cur_block)
|
||||
cur_block, cur_chars = [], 0
|
||||
if cur_block:
|
||||
blocks.append(cur_block)
|
||||
|
||||
result = dict(existing)
|
||||
lang_name = LANG_NAMES[target_lang]
|
||||
total_blocks = len(blocks)
|
||||
for b_idx, blk in enumerate(blocks):
|
||||
# 组内重新编号 [0..n] 发给模型,再映射回原 idx
|
||||
blk_items = [items[i] for i in blk]
|
||||
rel_map = {j: orig_idx for j, orig_idx in enumerate(blk)} # 组内位置 -> 原idx
|
||||
lines = "\n".join(f"[{j}] {it['text']}" for j, it in enumerate(blk_items))
|
||||
if progress_cb:
|
||||
progress_cb(f"翻译为{'英文' if target_lang=='en' else '中文'}:块 {b_idx+1}/{total_blocks} ({len(blk)} 段)", 5 + int(b_idx / total_blocks * 90))
|
||||
resp = call_llm([
|
||||
{"role": "system", "content": TRANSLATE_PROMPT_TMPL.format(lang_name=lang_name)},
|
||||
{"role": "user", "content": lines},
|
||||
], timeout=600) # 翻译大请求生成慢,超时放宽到 600s
|
||||
parsed = parse_idx_lines(resp)
|
||||
for j, orig_idx in rel_map.items():
|
||||
if j in parsed and parsed[j]:
|
||||
result[orig_idx] = parsed[j]
|
||||
if progress_cb:
|
||||
progress_cb(f"翻译完成 {len(result)}/{len(items)} 段", 100)
|
||||
return result
|
||||
|
||||
|
||||
def translate_direct(items, progress_cb=None, existing_zh=None):
|
||||
"""路线1:直译中文(支持增量续跑)。
|
||||
items: [{start_ms,end_ms,text}] -> 同结构 + zh 字段
|
||||
existing_zh: {idx: 已翻译中文}
|
||||
"""
|
||||
out = [dict(it) for it in items]
|
||||
zh_map = translate_all(items, "zh", progress_cb, existing=existing_zh)
|
||||
for i, it in enumerate(out):
|
||||
if i in zh_map and zh_map[i]:
|
||||
it['zh'] = zh_map[i]
|
||||
return out
|
||||
|
||||
|
||||
def translate_via_en(items, progress_cb=None, existing_en=None, existing_zh=None):
|
||||
"""路线2:日文→英文→中文(支持增量续跑)。返回 items + en + zh 字段"""
|
||||
out = [dict(it) for it in items]
|
||||
# 步骤1:日→英
|
||||
en_map = translate_all(items, "en", progress_cb, existing=existing_en)
|
||||
for i, it in enumerate(out):
|
||||
if i in en_map and en_map[i]:
|
||||
it['en'] = en_map[i]
|
||||
# 步骤2:英→中
|
||||
en_items = [{'text': it.get('en', it['text'])} for it in out]
|
||||
zh_map = translate_all(en_items, "zh", progress_cb, existing=existing_zh)
|
||||
for i, it in enumerate(out):
|
||||
if i in zh_map and zh_map[i]:
|
||||
it['zh'] = zh_map[i]
|
||||
return out
|
||||
@@ -0,0 +1,470 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""任务管理器:队列 + 状态机 + SSE 广播"""
|
||||
import os, json, threading, time, uuid, shutil
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from .config import OUTPUT_DIR, MAX_CONCURRENT_TASKS, LLM_CONCURRENCY, WORK_DIRNAME
|
||||
from .pipeline.transcribe import transcribe, extract_and_denoise, VIDEO_EXTS
|
||||
from .pipeline.fix import fix
|
||||
from .pipeline.translate import translate_direct, translate_via_en
|
||||
from .pipeline.srt import write_srts
|
||||
|
||||
# 去重记录文件:放在被处理视频的所在文件夹下
|
||||
HISTORY_FILENAME = ".subtitle-history.json"
|
||||
|
||||
|
||||
def _history_path(video_path):
|
||||
"""视频/文件夹的 history 文件路径(跟视频在同一文件夹)"""
|
||||
if os.path.isdir(video_path):
|
||||
folder = video_path
|
||||
else:
|
||||
folder = os.path.dirname(video_path)
|
||||
return os.path.join(folder, HISTORY_FILENAME)
|
||||
|
||||
|
||||
def _load_history(video_path):
|
||||
"""从视频所在文件夹加载 history(按 视频|路线 记录)"""
|
||||
hp = _history_path(video_path)
|
||||
if os.path.exists(hp):
|
||||
try:
|
||||
with open(hp, encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _save_history(video_path, history):
|
||||
"""保存 history 到视频所在文件夹"""
|
||||
hp = _history_path(video_path)
|
||||
try:
|
||||
with open(hp, 'w', encoding='utf-8') as f:
|
||||
json.dump(history, f, ensure_ascii=False, indent=1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
id: str
|
||||
source: str # 文件或文件夹路径
|
||||
route: str # direct / via_en / transcribe_only
|
||||
language: str = "ja"
|
||||
status: str = "pending" # pending/transcribing/fixing/translating/srt/done/error
|
||||
progress: int = 0
|
||||
message: str = ""
|
||||
created: float = field(default_factory=time.time)
|
||||
finished: float = None
|
||||
error: str = ""
|
||||
files: list = field(default_factory=list) # 生成的 SRT 文件
|
||||
transcript_path: str = ""
|
||||
video_errors: list = field(default_factory=list) # 单视频失败记录 [{video, error}]
|
||||
processed_count: int = 0 # 成功处理视频数
|
||||
failed_count: int = 0 # 失败视频数
|
||||
total_videos: int = 0 # 总视频数(文件夹)
|
||||
started_at: float = None # 任务开始时间(总体耗时)
|
||||
subtasks: dict = field(default_factory=dict) # {video: {status, progress, message, started_at, elapsed}} 子任务状态
|
||||
|
||||
def to_dict(self):
|
||||
d = asdict(self)
|
||||
# 动态耗时:运行中 = now - started_at, 完成 = finished - created
|
||||
if self.finished:
|
||||
d["elapsed"] = round(self.finished - self.created, 1)
|
||||
elif self.started_at:
|
||||
d["elapsed"] = round(time.time() - self.started_at, 1)
|
||||
else:
|
||||
d["elapsed"] = 0
|
||||
return d
|
||||
|
||||
|
||||
class TaskManager:
|
||||
def __init__(self):
|
||||
self.tasks = {} # id -> Task
|
||||
self.queue = [] # 等待队列(串行执行)
|
||||
self.lock = threading.Lock()
|
||||
self.worker = None # 当前执行线程
|
||||
self.subscribers = [] # SSE 客户端队列
|
||||
# 并发控制
|
||||
self.transcribe_lock = threading.BoundedSemaphore(MAX_CONCURRENT_TASKS) # 转录并发(GPU 模型共享,默认 2)
|
||||
self.llm_sem = threading.BoundedSemaphore(LLM_CONCURRENCY) # LLM 并发信号量
|
||||
self.llm_concurrency = LLM_CONCURRENCY
|
||||
self.transcribe_concurrency = MAX_CONCURRENT_TASKS
|
||||
|
||||
def set_llm_concurrency(self, n):
|
||||
"""动态调整 LLM 并发数(重建信号量)"""
|
||||
n = max(1, min(int(n), 20))
|
||||
self.llm_concurrency = n
|
||||
self.llm_sem = threading.BoundedSemaphore(n)
|
||||
return n
|
||||
|
||||
def set_transcribe_concurrency(self, n):
|
||||
"""动态调整转录并发数(1-4,重建信号量)"""
|
||||
n = max(1, min(int(n), 4))
|
||||
self.transcribe_concurrency = n
|
||||
self.transcribe_lock = threading.BoundedSemaphore(n)
|
||||
return n
|
||||
|
||||
# ---- SSE ----
|
||||
def subscribe(self, q):
|
||||
self.subscribers.append(q)
|
||||
|
||||
def unsubscribe(self, q):
|
||||
if q in self.subscribers:
|
||||
self.subscribers.remove(q)
|
||||
|
||||
def _broadcast(self, task):
|
||||
ev = json.dumps({"type": "task_update", "task": task.to_dict()}, ensure_ascii=False)
|
||||
for q in list(self.subscribers):
|
||||
try:
|
||||
q.put(ev)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- 任务管理 ----
|
||||
def create_task(self, source, route, language="ja"):
|
||||
# 防重复:同 source+route 已有 pending/running 任务则拒绝(避免重复处理)
|
||||
with self.lock:
|
||||
for t in self.tasks.values():
|
||||
if t.source == source and t.route == route and t.status in ("pending", "transcribing", "fixing", "translating", "srt"):
|
||||
return None, [], f"该内容正在处理中(任务 {t.id[:8]}),请等待完成后再提交"
|
||||
|
||||
# 去重检查:文件夹 → 找出未处理/处理过的视频清单
|
||||
skipped = []
|
||||
if os.path.isdir(source):
|
||||
history = _load_history(source)
|
||||
for f in sorted(os.listdir(source)):
|
||||
if f.lower().endswith(VIDEO_EXTS):
|
||||
key = os.path.join(source, f)
|
||||
hkey = f"{key}|{route}"
|
||||
if hkey in history:
|
||||
skipped.append(key)
|
||||
if skipped and len(skipped) == sum(1 for f in os.listdir(source) if f.lower().endswith(VIDEO_EXTS)):
|
||||
# 全部已处理过,拒绝创建
|
||||
return None, skipped, ""
|
||||
elif os.path.isfile(source):
|
||||
# 单文件也检查:同路线已处理过则拒绝
|
||||
history = _load_history(source)
|
||||
hkey = f"{source}|{route}"
|
||||
if hkey in history:
|
||||
return None, [source], ""
|
||||
|
||||
task = Task(id=uuid.uuid4().hex[:12], source=source, route=route, language=language)
|
||||
with self.lock:
|
||||
self.tasks[task.id] = task
|
||||
self.queue.append(task.id)
|
||||
self._broadcast(task)
|
||||
self._maybe_start()
|
||||
return task, skipped, ""
|
||||
|
||||
def _maybe_start(self):
|
||||
with self.lock:
|
||||
if self.worker and self.worker.is_alive():
|
||||
return
|
||||
running = [t for t in self.tasks.values() if t.status in ("transcribing", "fixing", "translating", "srt")]
|
||||
if len(running) >= MAX_CONCURRENT_TASKS:
|
||||
return
|
||||
if not self.queue:
|
||||
return
|
||||
task_id = self.queue.pop(0)
|
||||
task = self.tasks[task_id]
|
||||
task.status = "transcribing"
|
||||
task.message = "开始处理"
|
||||
self.worker = threading.Thread(target=self._run_task, args=(task,), daemon=True)
|
||||
self.worker.start()
|
||||
self._broadcast(task)
|
||||
|
||||
def _run_task(self, task):
|
||||
try:
|
||||
self._process(task)
|
||||
task.status = "done"
|
||||
task.progress = 100
|
||||
if task.failed_count > 0:
|
||||
task.message = f"部分完成: 成功 {task.processed_count}, 失败 {task.failed_count}"
|
||||
else:
|
||||
task.message = f"完成 ({task.processed_count} 个视频)"
|
||||
task.finished = time.time()
|
||||
except Exception as ex:
|
||||
task.status = "error"
|
||||
task.error = str(ex)
|
||||
task.message = f"失败: {ex}"
|
||||
task.finished = time.time()
|
||||
self._broadcast(task)
|
||||
# 处理下一个
|
||||
self._maybe_start()
|
||||
|
||||
def _update(self, task, msg, pct=None):
|
||||
task.message = msg
|
||||
if pct is not None:
|
||||
task.progress = int(pct)
|
||||
self._broadcast(task)
|
||||
|
||||
def _process(self, task):
|
||||
# 收集视频文件
|
||||
videos = []
|
||||
skipped_in_run = []
|
||||
if os.path.isfile(task.source):
|
||||
if task.source.lower().endswith(VIDEO_EXTS):
|
||||
videos = [task.source]
|
||||
elif os.path.isdir(task.source):
|
||||
# 过滤已处理(同路线)的视频
|
||||
history = _load_history(task.source)
|
||||
for f in sorted(os.listdir(task.source)):
|
||||
if f.lower().endswith(VIDEO_EXTS):
|
||||
key = os.path.join(task.source, f)
|
||||
if f"{key}|{task.route}" in history:
|
||||
skipped_in_run.append(key)
|
||||
else:
|
||||
videos.append(key)
|
||||
if skipped_in_run:
|
||||
task.message = f"跳过 {len(skipped_in_run)} 个已处理视频(同路线去重)"
|
||||
self._update(task, task.message, 1)
|
||||
if not videos:
|
||||
raise ValueError(f"所选内容均已处理过,无需重复处理: {task.source}")
|
||||
|
||||
out_dir = os.path.join(OUTPUT_DIR, task.id)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
total = len(videos)
|
||||
task.total_videos = total
|
||||
task.started_at = time.time()
|
||||
# 初始化子任务状态
|
||||
for v in videos:
|
||||
task.subtasks[v] = {"status": "pending", "progress": 0, "message": "等待中", "started_at": None, "elapsed": 0}
|
||||
task.message = f"开始处理 {total} 个视频(转录 {MAX_CONCURRENT_TASKS} + LLM {self.llm_concurrency} 并发)"
|
||||
self._update(task, task.message, 2)
|
||||
|
||||
# 流水线并发:每个视频一个 worker 线程
|
||||
# 转录内部拿 GPU 锁(串行),修正/翻译拿 LLM 信号量(限并发)
|
||||
done_count = 0
|
||||
done_lock = threading.Lock()
|
||||
|
||||
def _update_overall():
|
||||
"""更新总体进度:已完成/失败/总数"""
|
||||
done_total = task.processed_count + task.failed_count
|
||||
overall_pct = int(done_total / total * 100) if total else 100
|
||||
# 聚合消息
|
||||
running = [st for st in task.subtasks.values() if st["status"] in ("transcribing", "fixing", "translating", "srt")]
|
||||
running_names = [v for v, st in task.subtasks.items() if st["status"] in ("transcribing", "fixing", "translating", "srt")]
|
||||
msg = f"总体 {done_total}/{total} 完成"
|
||||
if running_names:
|
||||
msg += f" | 处理中: {', '.join(os.path.basename(n) for n in running_names[:3])}"
|
||||
if task.failed_count:
|
||||
msg += f" | 失败 {task.failed_count}"
|
||||
task.message = msg
|
||||
self._update(task, msg, overall_pct)
|
||||
|
||||
def _sub_update(video, status, progress, message):
|
||||
"""更新单个视频子任务状态(含耗时)"""
|
||||
cur = task.subtasks.get(video, {})
|
||||
started = cur.get("started_at")
|
||||
if started is None:
|
||||
started = time.time()
|
||||
elapsed = (time.time() - started) if started else 0
|
||||
task.subtasks[video] = {
|
||||
"status": status, "progress": progress, "message": message,
|
||||
"started_at": started, "elapsed": round(elapsed, 1),
|
||||
}
|
||||
self._broadcast(task)
|
||||
|
||||
def _video_worker(v_idx, video):
|
||||
nonlocal done_count
|
||||
try:
|
||||
_sub_update(video, "transcribing", 5, "转录中")
|
||||
self._process_one(task, video, out_dir, v_idx, total, _sub_update)
|
||||
with done_lock:
|
||||
task.processed_count += 1
|
||||
done_count += 1
|
||||
_sub_update(video, "done", 100, "完成")
|
||||
except Exception as ex:
|
||||
with done_lock:
|
||||
task.failed_count += 1
|
||||
done_count += 1
|
||||
task.video_errors.append({"video": video, "error": str(ex)[:300]})
|
||||
# 记录完整 traceback 到日志(排查用)
|
||||
try:
|
||||
import traceback as _tb
|
||||
tb = _tb.format_exc()
|
||||
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_error.log"), 'a', encoding='utf-8') as _ef:
|
||||
_ef.write(f"\n=== {os.path.basename(video)} ===\n{tb}\n")
|
||||
except Exception:
|
||||
pass
|
||||
_sub_update(video, "error", 0, f"失败: {str(ex)[:80]}")
|
||||
finally:
|
||||
_update_overall()
|
||||
|
||||
# 并发执行(worker 数 = 转录锁 + LLM 并发,转录阶段大部分在等锁)
|
||||
pool_size = max(MAX_CONCURRENT_TASKS, self.llm_concurrency)
|
||||
with ThreadPoolExecutor(max_workers=pool_size) as pool:
|
||||
futures = [pool.submit(_video_worker, i, v) for i, v in enumerate(videos)]
|
||||
# 等待全部完成(含失败)
|
||||
for f in futures:
|
||||
f.result()
|
||||
|
||||
if task.failed_count > 0:
|
||||
task.message = f"部分完成: 成功 {task.processed_count}, 失败 {task.failed_count}"
|
||||
else:
|
||||
task.message = f"完成 ({task.processed_count} 个视频)"
|
||||
self._update(task, task.message, 100)
|
||||
|
||||
def _process_one(self, task, video, out_dir, v_idx=0, total=1, sub_update=None):
|
||||
"""处理单个视频(转录→修正→翻译→SRT)——支持断点续跑
|
||||
中间产物在 视频所在文件夹/.subtitle-work/(固定目录,不按视频分)
|
||||
续跑:有 transcript 无 fixed → 从修正开始;有 fixed → 从翻译开始
|
||||
转录拿 GPU 锁(串行),修正/翻译拿 LLM 信号量(限并发)
|
||||
"""
|
||||
def sub(status, progress, message):
|
||||
if sub_update:
|
||||
sub_update(video, status, progress, message)
|
||||
|
||||
base = os.path.splitext(os.path.basename(video))[0]
|
||||
fname = os.path.basename(video)
|
||||
# 中间产物目录:视频所在文件夹 .subtitle-work/
|
||||
work_dir = os.path.join(os.path.dirname(video), WORK_DIRNAME)
|
||||
os.makedirs(work_dir, exist_ok=True)
|
||||
transcript_json = os.path.join(work_dir, f"{base}_transcript.json")
|
||||
fixed_json = os.path.join(work_dir, f"{base}_fixed.json")
|
||||
translated_json = os.path.join(work_dir, f"{base}_translated.json")
|
||||
wav_path = os.path.join(work_dir, f"{base}_audio.wav")
|
||||
|
||||
# ===== 阶段 1: 转录(可续跑)=====
|
||||
if os.path.exists(transcript_json):
|
||||
sub("transcribing", 100, f"转录已存在(续跑){fname}")
|
||||
else:
|
||||
# 提取音频 + 降噪(CPU,可并发——在 GPU 锁外做)
|
||||
sub("transcribing", 2, f"提取音频+降噪 {fname}")
|
||||
extract_and_denoise(video, wav_path, denoise=True,
|
||||
progress_cb=lambda msg, pct: sub("transcribing", 2 + int(pct * 0.06), f"{msg} {fname}"))
|
||||
# 转录(GPU 锁)
|
||||
with self.transcribe_lock:
|
||||
sub("transcribing", 10, f"转录中 {fname}")
|
||||
def _tcb(msg, pct, _fname=fname):
|
||||
if msg.startswith("转录中"):
|
||||
sub("transcribing", 10 + int(pct * 0.3), f"{_fname}: {msg.split('转录中')[1].strip()}")
|
||||
else:
|
||||
sub("transcribing", 10 + int(pct * 0.3), f"{msg} {_fname}")
|
||||
_result, _warnings = transcribe(video, transcript_json, language=task.language,
|
||||
progress_cb=_tcb, wav_path=wav_path)
|
||||
if _warnings:
|
||||
task.video_errors.append({"video": video, "error": ";".join(_warnings), "warn": True})
|
||||
task.transcript_path = transcript_json
|
||||
|
||||
if task.route == "transcribe_only":
|
||||
task.files.append(transcript_json)
|
||||
self._save_history_record(video, task, [transcript_json])
|
||||
return
|
||||
|
||||
with open(transcript_json, encoding='utf-8') as f:
|
||||
items = json.load(f)
|
||||
|
||||
# ===== 阶段 2+3: 修正 + 翻译(LLM 信号量,可续跑)=====
|
||||
with self.llm_sem:
|
||||
# 修正(可续跑)
|
||||
if os.path.exists(fixed_json):
|
||||
sub("fixing", 100, f"修正已存在(续跑){fname}")
|
||||
with open(fixed_json, encoding='utf-8') as f:
|
||||
items = json.load(f)
|
||||
else:
|
||||
sub("fixing", 45, f"LLM 修正 {fname}")
|
||||
items = fix(items, progress_cb=lambda msg, pct: sub("fixing", 45 + int(pct * 0.15), f"{msg} [{fname}]"))
|
||||
with open(fixed_json, 'w', encoding='utf-8') as f:
|
||||
json.dump(items, f, ensure_ascii=False, indent=1)
|
||||
|
||||
# 翻译(可续跑:从 translated_json 加载已有结果,只翻缺失段)
|
||||
existing_zh = {}
|
||||
existing_en = {}
|
||||
if os.path.exists(translated_json):
|
||||
try:
|
||||
with open(translated_json, encoding='utf-8') as f:
|
||||
prev = json.load(f)
|
||||
# 提取已有翻译
|
||||
for i, it in enumerate(prev):
|
||||
if it.get('zh'):
|
||||
existing_zh[i] = it['zh']
|
||||
if it.get('en'):
|
||||
existing_en[i] = it['en']
|
||||
sub("translating", 30, f"翻译已有 {len(existing_zh)}/{len(items)} 段(续跑){fname}")
|
||||
except Exception:
|
||||
existing_zh, existing_en = {}, {}
|
||||
|
||||
sub("translating", 40, f"翻译中 {fname}")
|
||||
if task.route == "direct":
|
||||
items = translate_direct(items,
|
||||
progress_cb=lambda msg, pct: sub("translating", 40 + int(pct * 0.5), f"{msg} [{fname}]"),
|
||||
existing_zh=existing_zh)
|
||||
elif task.route == "via_en":
|
||||
items = translate_via_en(items,
|
||||
progress_cb=lambda msg, pct: sub("translating", 40 + int(pct * 0.5), f"{msg} [{fname}]"),
|
||||
existing_en=existing_en, existing_zh=existing_zh)
|
||||
with open(translated_json, 'w', encoding='utf-8') as f:
|
||||
json.dump(items, f, ensure_ascii=False, indent=1)
|
||||
|
||||
# 4. SRT(写视频同目录,PotPlayer 需要;副本到 output)
|
||||
sub("srt", 92, f"生成 SRT {fname}")
|
||||
srt_files = write_srts(items, video, task.route, out_dir)
|
||||
task.files.extend(srt_files)
|
||||
sub("srt", 98, f"SRT 完成 {fname}")
|
||||
|
||||
# 5. 记录 history
|
||||
self._save_history_record(video, task, srt_files)
|
||||
|
||||
def _save_history_record(self, video, task, srt_files):
|
||||
"""记录 history(精确到视频,存在视频所在文件夹)"""
|
||||
try:
|
||||
history = _load_history(video)
|
||||
hkey = f"{video}|{task.route}"
|
||||
# 收集警告(transcribe 的 warnings 记录在 video_errors 里 warn=true)
|
||||
warnings = []
|
||||
for ve in task.video_errors:
|
||||
if ve.get("video") == video and ve.get("warn"):
|
||||
warnings.append(ve["error"])
|
||||
history[hkey] = {
|
||||
"video": video,
|
||||
"route": task.route,
|
||||
"files": list(srt_files),
|
||||
"warnings": warnings,
|
||||
"time": time.time(),
|
||||
}
|
||||
_save_history(video, history)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- API ----
|
||||
def list_tasks(self):
|
||||
return [t.to_dict() for t in sorted(self.tasks.values(), key=lambda t: -t.created)]
|
||||
|
||||
def get_task(self, tid):
|
||||
t = self.tasks.get(tid)
|
||||
return t.to_dict() if t else None
|
||||
|
||||
def retry_task(self, tid):
|
||||
t = self.tasks.get(tid)
|
||||
if not t or t.status not in ("error", "done"):
|
||||
return None
|
||||
# 复用 ID 重新入队
|
||||
t.status = "pending"
|
||||
t.error = ""
|
||||
t.message = "重试"
|
||||
t.progress = 0
|
||||
with self.lock:
|
||||
self.queue.append(t.id)
|
||||
self._broadcast(t)
|
||||
self._maybe_start()
|
||||
return t.to_dict()
|
||||
|
||||
def cancel_task(self, tid):
|
||||
"""从队列移除(无法中断运行中的,只移出等待队列)"""
|
||||
t = self.tasks.get(tid)
|
||||
if not t:
|
||||
return None
|
||||
if t.status == "pending":
|
||||
with self.lock:
|
||||
if tid in self.queue:
|
||||
self.queue.remove(tid)
|
||||
t.status = "cancelled"
|
||||
t.message = "已取消"
|
||||
self._broadcast(t)
|
||||
return t.to_dict()
|
||||
|
||||
|
||||
manager = TaskManager()
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
// subtitle-studio 前端逻辑
|
||||
let selectedPath = "";
|
||||
let tasksMap = {};
|
||||
|
||||
// ---- 文件浏览 ----
|
||||
async function loadDrives() {
|
||||
const r = await fetch("/api/drives");
|
||||
const d = await r.json();
|
||||
const sel = document.getElementById("drive-select");
|
||||
sel.innerHTML = d.drives.map(x => `<option value="${x}">${x}</option>`).join("");
|
||||
sel.onchange = () => { document.getElementById("path-input").value = sel.value; browse(sel.value); };
|
||||
if (d.drives.length) { document.getElementById("path-input").value = d.drives[0]; browse(d.drives[0]); }
|
||||
}
|
||||
|
||||
async function browse(path) {
|
||||
path = cleanPath(path);
|
||||
const r = await fetch("/api/browse?path=" + encodeURIComponent(path));
|
||||
const d = await r.json();
|
||||
document.getElementById("path-input").value = d.cwd;
|
||||
const list = document.getElementById("browser-list");
|
||||
list.innerHTML = "";
|
||||
const entries = d.entries.filter(e => !e.name.startsWith("$"));
|
||||
for (const e of entries) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "browser-item" + (e.path === selectedPath ? " selected" : "");
|
||||
div.innerHTML = `<span class="icon">${e.is_dir ? "📁" : "🎬"}</span><span>${e.name}</span>` +
|
||||
(e.is_dir ? "" : '<span class="is-file">文件</span>');
|
||||
div.onclick = () => {
|
||||
selectedPath = e.path;
|
||||
updateSelection(e);
|
||||
document.querySelectorAll(".browser-item").forEach(x => x.classList.remove("selected"));
|
||||
div.classList.add("selected");
|
||||
};
|
||||
if (e.is_dir) {
|
||||
div.ondblclick = () => browse(e.path);
|
||||
}
|
||||
list.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelection(e) {
|
||||
const preview = document.getElementById("sel-preview");
|
||||
if (e.is_dir) {
|
||||
preview.innerHTML = `已选择文件夹:<b>${e.path}</b>(将批量处理其中所有视频)`;
|
||||
loadFolderStatus(e.path); // 自动加载该文件夹视频状态
|
||||
} else {
|
||||
preview.innerHTML = `已选择文件:<b>${e.path}</b>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载文件夹内所有视频的处理状态(含已完成/警告/续跑点)
|
||||
async function loadFolderStatus(path) {
|
||||
const route = document.querySelector('input[name="route"]:checked')?.value || "direct";
|
||||
try {
|
||||
const r = await fetch(`/api/folder_status?path=${encodeURIComponent(path)}&route=${route}`);
|
||||
const d = await r.json();
|
||||
if (d.error) { return; }
|
||||
renderFolderVideos(d.videos);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 渲染文件夹视频状态为任务卡片列表
|
||||
function renderFolderVideos(videos) {
|
||||
const list = document.getElementById("task-list");
|
||||
if (!videos || !videos.length) { return; }
|
||||
folderVideos = videos; // 保存状态,供 mergeTaskProgress 更新
|
||||
folderRendered = true;
|
||||
// 转成任务格式渲染(模拟一个总任务 + 子任务)
|
||||
const total = videos.length;
|
||||
const doneCount = videos.filter(v => v.status === "done").length;
|
||||
const subTasks = {};
|
||||
videos.forEach(v => {
|
||||
let msg = v.message;
|
||||
if (v.warnings && v.warnings.length) {
|
||||
msg += ' ⚠️ ' + v.warnings[0].slice(0, 60);
|
||||
}
|
||||
subTasks[v.video] = {
|
||||
status: v.status, progress: v.progress, message: msg,
|
||||
started_at: null, elapsed: 0, warnings: v.warnings || []
|
||||
};
|
||||
});
|
||||
const fakeTask = {
|
||||
id: "folder-" + Math.random().toString(36).slice(2, 8),
|
||||
source: videos[0].video.split(/[\\/]/).slice(0, -1).join("\\"),
|
||||
route: "direct", status: doneCount === total ? "done" : "pending",
|
||||
progress: Math.round(doneCount / total * 100),
|
||||
message: `文件夹状态:${doneCount}/${total} 已完成`,
|
||||
created: Date.now() / 1000, files: [], subtasks: subTasks,
|
||||
total_videos: total, processed_count: doneCount, failed_count: 0,
|
||||
};
|
||||
// 临时切到 tasks 视图渲染 folder 卡片(renderTasks 有 guard 会挡 folder)
|
||||
currentView = "tasks";
|
||||
renderTasks([fakeTask]);
|
||||
currentView = "folder"; // 渲染完成后切回 folder 视图,阻止任务轮询覆盖
|
||||
}
|
||||
|
||||
function goUp() {
|
||||
const cur = document.getElementById("path-input").value;
|
||||
const parent = cur.replace(/[\\/][^\\/]*[\\/]?$/, "") || cur;
|
||||
if (parent !== cur) browse(parent);
|
||||
}
|
||||
|
||||
function refreshBrowse() { browse(document.getElementById("path-input").value); }
|
||||
|
||||
// 清理路径:去除首尾双引号(用户从资源管理器复制)、多余空白、尾部斜杠
|
||||
function cleanPath(p) {
|
||||
if (!p) return "";
|
||||
let s = p.trim();
|
||||
// 去掉首尾双引号(成对或单个)
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
s = s.slice(1, -1);
|
||||
} else if (s.startsWith('"')) {
|
||||
s = s.slice(1);
|
||||
} else if (s.endsWith('"')) {
|
||||
s = s.slice(0, -1);
|
||||
}
|
||||
// 去除尾部多余斜杠(保留盘符根如 C:\)
|
||||
s = s.replace(/[\\/]+$/, "");
|
||||
if (/^[A-Za-z]:$/.test(s)) s += "\\";
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
// 手动输入路径(回车触发 change)——若是文件夹自动加载视频状态
|
||||
document.getElementById("path-input").addEventListener("change", () => {
|
||||
const p = cleanPath(document.getElementById("path-input").value);
|
||||
document.getElementById("path-input").value = p;
|
||||
selectedPath = p;
|
||||
document.getElementById("sel-preview").innerHTML = `路径:<b>${p}</b>`;
|
||||
// 文件夹 → 自动加载任务列表(loadFolderStatus 内部会判断,文件路径不渲染)
|
||||
loadFolderStatus(p);
|
||||
});
|
||||
// 回车键也触发(有些浏览器回车不触发 change)
|
||||
document.getElementById("path-input").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
const p = cleanPath(document.getElementById("path-input").value);
|
||||
document.getElementById("path-input").value = p;
|
||||
selectedPath = p;
|
||||
document.getElementById("sel-preview").innerHTML = `路径:<b>${p}</b>`;
|
||||
loadFolderStatus(p);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 非阻塞提示 toast ----
|
||||
function showToast(msg, type) {
|
||||
let toast = document.getElementById("toast");
|
||||
if (!toast) {
|
||||
toast = document.createElement("div");
|
||||
toast.id = "toast";
|
||||
toast.style.cssText = "position:fixed;top:16px;right:16px;z-index:9999;max-width:380px;background:#1c212b;border:1px solid #2a2f3a;border-radius:8px;padding:10px 14px;font-size:13px;box-shadow:0 4px 16px rgba(0,0,0,.4);";
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
toast.innerHTML = `<span style="margin-right:8px">${type === "warn" ? "⚠️" : type === "err" ? "❌" : "✅"}</span>${msg}`;
|
||||
toast.style.borderColor = type === "warn" ? "#d29922" : type === "err" ? "#f85149" : "#3fb950";
|
||||
clearTimeout(toast._t);
|
||||
toast._t = setTimeout(() => toast.remove(), 5000);
|
||||
}
|
||||
|
||||
// ---- 区域折叠 ----
|
||||
function collapseSections() {
|
||||
const s1 = document.getElementById("section-1");
|
||||
const s2 = document.getElementById("section-2");
|
||||
if (s1) s1.removeAttribute("open");
|
||||
if (s2) s2.removeAttribute("open");
|
||||
const s3 = document.getElementById("section-3");
|
||||
if (s3 && !s3.hasAttribute("open")) s3.setAttribute("open", "");
|
||||
}
|
||||
|
||||
// ---- 任务创建 ----
|
||||
async function createTask() {
|
||||
selectedPath = cleanPath(selectedPath);
|
||||
document.getElementById("path-input").value = selectedPath;
|
||||
if (!selectedPath) { showToast("请先选择文件或文件夹", "warn"); return; }
|
||||
const route = document.querySelector('input[name="route"]:checked').value;
|
||||
const language = document.getElementById("lang-select").value;
|
||||
const btn = document.getElementById("submit-btn");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch("/api/tasks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source: selectedPath, route, language }),
|
||||
});
|
||||
let data = null;
|
||||
try { data = await r.json(); } catch (e) {
|
||||
showToast("服务器错误: " + (r.status || "") , "err");
|
||||
return;
|
||||
}
|
||||
if (data.error) { showToast(data.error, "err"); return; }
|
||||
if (data.skipped_all) { showToast(data.notice || "所选内容已处理过", "warn"); return; }
|
||||
if (data.notice) { showToast(data.notice, "warn"); }
|
||||
// 提交成功:折叠 1、2 区。若在 folder 视图则保持(实时进度会合并进来)
|
||||
collapseSections();
|
||||
if (currentView === "folder") {
|
||||
loadFolderStatus(selectedPath); // 刷新 folder 状态(含新任务进度)
|
||||
} else {
|
||||
fetch("/api/tasks").then(r => r.json()).then(renderTasks);
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 耗时格式化 ----
|
||||
function fmtDur(sec) {
|
||||
sec = Math.max(0, Math.round(sec || 0));
|
||||
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
||||
if (h > 0) return `${h}h${m}m`;
|
||||
if (m > 0) return `${m}m${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
// ---- 任务渲染 ----
|
||||
const STATUS_LABEL = {
|
||||
pending: "等待中", transcribing: "转录中", fixing: "修正中",
|
||||
translating: "翻译中", srt: "生成字幕", done: "完成", error: "失败", cancelled: "已取消",
|
||||
};
|
||||
|
||||
// 当前视图:'tasks'=真实任务列表, 'folder'=文件夹状态视图
|
||||
let currentView = "tasks";
|
||||
let folderVideos = null; // 最近一次 folder_status 返回的视频状态
|
||||
let folderRendered = false; // folder 视图是否已渲染
|
||||
|
||||
// 合并真实任务进度到 folder 视图(已完成的视频保留,运行中的更新进度)
|
||||
function mergeTaskProgress(tasks) {
|
||||
if (!folderVideos) return;
|
||||
let changed = false;
|
||||
for (const t of tasks) {
|
||||
if (!t.subtasks) continue;
|
||||
for (const [video, st] of Object.entries(t.subtasks)) {
|
||||
const fv = folderVideos.find(v => v.video === video);
|
||||
if (fv) {
|
||||
if (fv.status !== st.status || fv.progress !== st.progress) {
|
||||
fv.status = st.status;
|
||||
fv.progress = st.progress;
|
||||
fv.message = st.message || fv.message;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) renderFolderVideos(folderVideos);
|
||||
}
|
||||
|
||||
function renderTasks(tasks) {
|
||||
// folder 视图下:合并真实任务进度到 folder 视图,不覆盖已完成的视频
|
||||
if (currentView === "folder") {
|
||||
if (folderVideos && folderRendered) {
|
||||
mergeTaskProgress(tasks);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const list = document.getElementById("task-list");
|
||||
if (!tasks.length) {
|
||||
if (!list.querySelector(".empty")) { list.innerHTML = '<div class="empty">暂无任务,选择视频开始吧</div>'; }
|
||||
return;
|
||||
}
|
||||
|
||||
// 已存在的任务卡片(避免重建列表导致滚动位置丢失)
|
||||
const existingIds = new Set();
|
||||
for (const t of tasks) {
|
||||
existingIds.add("task-" + t.id);
|
||||
let card = document.getElementById("task-" + t.id);
|
||||
if (card) {
|
||||
// 增量更新:只更新卡片内容,保留 DOM 节点(滚动位置稳定)
|
||||
card.innerHTML = buildTaskCard(t);
|
||||
} else {
|
||||
card = document.createElement("div");
|
||||
card.className = "task";
|
||||
card.id = "task-" + t.id;
|
||||
card.innerHTML = buildTaskCard(t);
|
||||
list.appendChild(card);
|
||||
}
|
||||
}
|
||||
// 移除已消失的任务卡片
|
||||
list.querySelectorAll(".task").forEach(c => {
|
||||
if (!existingIds.has(c.id)) c.remove();
|
||||
});
|
||||
// 移除空状态
|
||||
const empty = list.querySelector(".empty");
|
||||
if (empty) empty.remove();
|
||||
}
|
||||
|
||||
function buildTaskCard(t) {
|
||||
const name = t.source.split(/[\\/]/).pop();
|
||||
const barCls = t.status === "done" ? "done" : (t.status === "error" ? "error" : "");
|
||||
let filesHtml = "";
|
||||
if (t.files && t.files.length) {
|
||||
filesHtml = '<div class="task-files">' + t.files.map(f =>
|
||||
`<a href="/api/download?path=${encodeURIComponent(f)}" download>⬇ ${f.split(/[\\/]/).pop()}</a>`
|
||||
).join("") + "</div>";
|
||||
}
|
||||
let actions = "";
|
||||
if (t.status === "error") {
|
||||
actions = '<div class="task-actions"><button onclick="retryTask(\'' + t.id + '\')">重试</button></div>';
|
||||
} else if (t.status === "pending") {
|
||||
actions = '<div class="task-actions"><button onclick="cancelTask(\'' + t.id + '\')">取消</button></div>';
|
||||
}
|
||||
|
||||
// 子任务(多视频并发)渲染 - 完成时保留进度条
|
||||
let subtasksHtml = "";
|
||||
if (t.subtasks && Object.keys(t.subtasks).length > 1) {
|
||||
const now = Date.now() / 1000;
|
||||
const subRows = Object.entries(t.subtasks).map(([v, st]) => {
|
||||
const vname = v.split(/[\\/]/).pop();
|
||||
const sCls = st.status === "done" ? "done" : (st.status === "error" ? "error" : "");
|
||||
// 耗时:运行中的用本地时钟实时估算(后端广播的 elapsed + 本地增量),完成/失败用固定值
|
||||
let stDur = st.elapsed ? fmtDur(st.elapsed) : "";
|
||||
if ((st.status === "transcribing" || st.status === "fixing" || st.status === "translating" || st.status === "srt")
|
||||
&& st.started_at) {
|
||||
stDur = fmtDur(now - st.started_at);
|
||||
}
|
||||
const stTime = st.status === "done" || st.status === "error"
|
||||
? `<span style="font-size:11px;color:var(--dim);margin-left:auto;white-space:nowrap;">${stDur}</span>`
|
||||
: `<span style="font-size:11px;color:var(--accent);margin-left:auto;white-space:nowrap;">${stDur}</span>`;
|
||||
return `
|
||||
<div style="padding:4px 0;border-bottom:1px solid #1c212b;">
|
||||
<div style="display:flex;align-items:center;gap:8px;font-size:12px;">
|
||||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${v}">${vname}</span>
|
||||
<span class="badge ${st.status}" style="font-size:10px;">${STATUS_LABEL[st.status] || st.status}</span>
|
||||
${stTime}
|
||||
<button onclick="regenerateVideo('${v.replace(/'/g, "\\'")}')" title="清空中间产物和结果,重新生成"
|
||||
style="background:transparent;border:1px solid var(--border);color:var(--dim);border-radius:4px;padding:1px 8px;font-size:10px;cursor:pointer;white-space:nowrap;">↻ 重新生成</button>
|
||||
</div>
|
||||
<div class="task-progress" style="margin-top:4px;"><div class="bar ${sCls}" style="width:${st.progress}%"></div></div>
|
||||
<div style="font-size:11px;color:var(--dim);margin-top:2px;">${st.message || ""}</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
subtasksHtml = `<div style="margin-top:10px;padding-top:8px;border-top:1px solid #2a2f3a;">${subRows}</div>`;
|
||||
}
|
||||
|
||||
// 总体进度标签(文件夹:已完成/总数)+ 总耗时
|
||||
let overall = "";
|
||||
let overallTime = "";
|
||||
if (t.total_videos > 1) {
|
||||
const doneTotal = (t.processed_count || 0) + (t.failed_count || 0);
|
||||
overall = `<span style="font-size:12px;color:var(--dim);">${doneTotal}/${t.total_videos} 个视频</span>`;
|
||||
}
|
||||
if (t.elapsed) {
|
||||
overallTime = `<span style="font-size:11px;color:var(--dim);margin-left:auto;white-space:nowrap;">耗时 ${fmtDur(t.elapsed)}</span>`;
|
||||
} else if (t.started_at && t.status !== "done" && t.status !== "error") {
|
||||
// 运行中:本地时钟实时估算
|
||||
overallTime = `<span style="font-size:11px;color:var(--dim);margin-left:auto;white-space:nowrap;">耗时 ${fmtDur(Date.now()/1000 - t.started_at)}</span>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="task-head">
|
||||
<span class="task-name" title="${t.source}">${name}</span>
|
||||
<span class="badge ${t.status}">${STATUS_LABEL[t.status] || t.status}</span>
|
||||
${overall}
|
||||
${overallTime}
|
||||
<span class="task-time">${new Date(t.created * 1000).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
<div class="task-progress"><div class="bar ${barCls}" style="width:${t.progress}%"></div></div>
|
||||
<div class="task-msg">${t.message || ""}</div>
|
||||
${t.error ? `<div class="error-text">${t.error}</div>` : ""}
|
||||
${subtasksHtml}
|
||||
${filesHtml}
|
||||
${actions}
|
||||
`;
|
||||
}
|
||||
|
||||
async function retryTask(id) { await fetch(`/api/tasks/${id}/retry`, { method: "POST" }); }
|
||||
async function cancelTask(id) { await fetch(`/api/tasks/${id}/cancel`, { method: "POST" }); }
|
||||
|
||||
// 重新生成单个视频(清中间产物 + SRT + history,重跑)
|
||||
async function regenerateVideo(videoPath) {
|
||||
if (!confirm(`重新生成该视频?\n将删除中间产物和已生成的字幕,然后重新处理。\n\n${videoPath}`)) return;
|
||||
const route = document.querySelector('input[name="route"]:checked')?.value || "direct";
|
||||
try {
|
||||
const r = await fetch("/api/video/regenerate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source: videoPath, route, language: "ja" }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.error) { showToast(d.error, "err"); return; }
|
||||
showToast(`已开始重新生成(清理 ${(d.removed || []).length} 个文件)`, "ok");
|
||||
// 切回真实任务视图查看进度
|
||||
currentView = "tasks";
|
||||
setTimeout(() => fetch("/api/tasks").then(r => r.json()).then(renderTasks), 2000);
|
||||
} catch (e) { showToast("重新生成失败", "err"); }
|
||||
}
|
||||
|
||||
// ---- SSE ----
|
||||
function connectSSE() {
|
||||
const es = new EventSource("/api/events");
|
||||
es.onmessage = (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.data);
|
||||
if (data.type === "snapshot") { renderTasks(data.tasks); }
|
||||
else if (data.type === "task_update") {
|
||||
const t = data.task;
|
||||
const prev = tasksMap[t.id];
|
||||
// 去重:仅当 status/progress/message/subtasks 都相同才跳过
|
||||
if (prev && prev.status === t.status && prev.progress === t.progress &&
|
||||
prev.message === t.message && JSON.stringify(prev.subtasks) === JSON.stringify(t.subtasks)) return;
|
||||
tasksMap[t.id] = t;
|
||||
// 刷新列表
|
||||
fetch("/api/tasks").then(r => r.json()).then(renderTasks);
|
||||
}
|
||||
} catch (e) { /* 忽略 keepalive */ }
|
||||
};
|
||||
es.onerror = () => { es.close(); setTimeout(connectSSE, 3000); };
|
||||
}
|
||||
|
||||
loadDrives();
|
||||
connectSSE();
|
||||
setInterval(() => { fetch("/api/tasks").then(r => r.json()).then(renderTasks); }, 5000);
|
||||
|
||||
// ---- 并发数设置 ----
|
||||
async function loadConcurrency() {
|
||||
try {
|
||||
const r = await fetch("/api/concurrency");
|
||||
const d = await r.json();
|
||||
document.getElementById("llm-conc-input").value = d.llm;
|
||||
document.getElementById("tr-conc-input").value = d.transcribe;
|
||||
document.getElementById("conc-status").textContent = `转录并发 ${d.transcribe}(GPU 模型共享),LLM 并发 ${d.llm}`;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function saveConcurrency() {
|
||||
const llm = parseInt(document.getElementById("llm-conc-input").value, 10);
|
||||
const tr = parseInt(document.getElementById("tr-conc-input").value, 10);
|
||||
if (!llm || llm < 1 || llm > 20) { showToast("LLM 并发需在 1-20 之间", "warn"); return; }
|
||||
if (!tr || tr < 1 || tr > 4) { showToast("转录并发需在 1-4 之间", "warn"); return; }
|
||||
try {
|
||||
const r = await fetch("/api/concurrency", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ llm, transcribe: tr }),
|
||||
});
|
||||
const d = await r.json();
|
||||
showToast(d.notice || "并发设置已保存", "ok");
|
||||
loadConcurrency();
|
||||
} catch (e) { showToast("保存失败", "err"); }
|
||||
}
|
||||
|
||||
loadConcurrency();
|
||||
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>字幕生成系统 subtitle-studio</title>
|
||||
<style>
|
||||
:root { --bg:#0f1115; --card:#171a21; --border:#2a2f3a; --text:#e6e8eb; --dim:#8b93a3;
|
||||
--accent:#4f8cff; --ok:#3fb950; --warn:#d29922; --err:#f85149; }
|
||||
* { box-sizing:border-box; margin:0; padding:0; }
|
||||
body { background:var(--bg); color:var(--text); font-family:"Segoe UI","Microsoft YaHei",sans-serif; font-size:14px; }
|
||||
.container { max-width:1200px; margin:0 auto; padding:20px; }
|
||||
h1 { font-size:20px; margin-bottom:16px; display:flex; align-items:center; gap:10px; }
|
||||
h1 .dot { width:10px; height:10px; border-radius:50%; background:var(--ok); animation:pulse 2s infinite; }
|
||||
@keyframes pulse { 50% { opacity:.4; } }
|
||||
.card { background:var(--card); border:1px solid var(--border); border-radius:8px; padding:16px; margin-bottom:16px; }
|
||||
.card h2 { font-size:15px; margin-bottom:12px; color:var(--dim); font-weight:600; }
|
||||
/* 路径选择 */
|
||||
.browser { display:flex; flex-direction:column; gap:8px; }
|
||||
.browser-bar { display:flex; gap:8px; align-items:center; }
|
||||
.browser-bar input { flex:1; background:#0d0f14; border:1px solid var(--border); color:var(--text); border-radius:6px; padding:8px 10px; font-size:13px; }
|
||||
.browser-bar button { background:var(--accent); color:#fff; border:none; border-radius:6px; padding:8px 16px; cursor:pointer; font-size:13px; }
|
||||
.browser-bar button:hover { opacity:.85; }
|
||||
.browser-list { max-height:220px; overflow-y:auto; border:1px solid var(--border); border-radius:6px; background:#0d0f14; }
|
||||
.browser-item { display:flex; align-items:center; gap:8px; padding:6px 10px; cursor:pointer; font-size:13px; }
|
||||
.browser-item:hover { background:#1c212b; }
|
||||
.browser-item.selected { background:#223049; }
|
||||
.browser-item .icon { width:16px; text-align:center; color:var(--dim); }
|
||||
.browser-item .is-file { color:var(--dim); margin-left:auto; font-size:11px; }
|
||||
.sel-preview { margin-top:8px; font-size:13px; color:var(--dim); }
|
||||
.sel-preview b { color:var(--text); }
|
||||
/* 路线选择 */
|
||||
.routes { display:flex; flex-direction:column; gap:8px; }
|
||||
.route-opt { display:flex; align-items:flex-start; gap:10px; padding:10px 12px; border:1px solid var(--border); border-radius:6px; cursor:pointer; }
|
||||
.route-opt:hover { border-color:var(--accent); }
|
||||
.route-opt input { margin-top:3px; }
|
||||
.route-opt .r-title { font-weight:600; }
|
||||
.route-opt .r-desc { color:var(--dim); font-size:12px; margin-top:2px; }
|
||||
.submit-row { display:flex; gap:10px; margin-top:14px; align-items:center; }
|
||||
.submit-row button { background:var(--accent); color:#fff; border:none; border-radius:6px; padding:10px 24px; font-size:14px; cursor:pointer; }
|
||||
.submit-row button:disabled { opacity:.4; cursor:not-allowed; }
|
||||
.submit-row select { background:#0d0f14; border:1px solid var(--border); color:var(--text); border-radius:6px; padding:8px; }
|
||||
.submit-row .hint { color:var(--dim); font-size:12px; }
|
||||
/* 任务列表 */
|
||||
.task { border:1px solid var(--border); border-radius:8px; padding:12px 14px; margin-bottom:10px; background:#14171f; }
|
||||
.task-head { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.task-name { font-weight:600; font-size:13px; max-width:60%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.badge { padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; }
|
||||
.badge.pending { background:#2a2f3a; color:var(--dim); }
|
||||
.badge.transcribing,.badge.fixing,.badge.translating,.badge.srt { background:#223049; color:var(--accent); }
|
||||
.badge.done { background:#13271a; color:var(--ok); }
|
||||
.badge.error { background:#2a1618; color:var(--err); }
|
||||
.badge.cancelled { background:#2a2f3a; color:var(--dim); }
|
||||
.task-progress { margin-top:8px; height:6px; background:#0d0f14; border-radius:3px; overflow:hidden; }
|
||||
.task-progress .bar { height:100%; background:var(--accent); transition:width .3s; }
|
||||
.task-progress .bar.done { background:var(--ok); }
|
||||
.task-progress .bar.error { background:var(--err); }
|
||||
.task-msg { margin-top:6px; font-size:12px; color:var(--dim); }
|
||||
.task-files { margin-top:8px; display:flex; flex-wrap:wrap; gap:6px; }
|
||||
.task-files a { background:#1c212b; border:1px solid var(--border); color:var(--accent); text-decoration:none; font-size:12px; padding:3px 10px; border-radius:4px; }
|
||||
.task-files a:hover { border-color:var(--accent); }
|
||||
.task-actions { margin-top:8px; display:flex; gap:8px; }
|
||||
.task-actions button { background:#1c212b; border:1px solid var(--border); color:var(--text); border-radius:4px; padding:3px 12px; font-size:12px; cursor:pointer; }
|
||||
.task-actions button:hover { border-color:var(--accent); }
|
||||
.task-time { font-size:11px; color:var(--dim); margin-left:auto; }
|
||||
.empty { color:var(--dim); text-align:center; padding:30px 0; font-size:13px; }
|
||||
.error-text { color:var(--err); font-size:12px; margin-top:4px; word-break:break-all; }
|
||||
/* 可折叠区域 */
|
||||
details.card { background:var(--card); border:1px solid var(--border); border-radius:8px; margin-bottom:16px; }
|
||||
details.card > summary { cursor:pointer; padding:14px 16px; font-size:15px; font-weight:600; color:var(--dim); list-style:none; display:flex; align-items:center; gap:8px; user-select:none; }
|
||||
details.card > summary::-webkit-details-marker { display:none; }
|
||||
details.card > summary:hover { color:var(--text); }
|
||||
details.card > summary .arrow { transition:transform .2s; font-size:12px; color:var(--accent); }
|
||||
details.card[open] > summary .arrow { transform:rotate(90deg); }
|
||||
details.card > .card-body { padding:0 16px 16px; }
|
||||
details.card[open] > summary { border-bottom:1px solid var(--border); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1><span class="dot"></span> 字幕生成系统</h1>
|
||||
|
||||
<details class="card" open id="section-1">
|
||||
<summary><span class="arrow">▶</span>① 选择视频</summary>
|
||||
<div class="card-body">
|
||||
<div class="browser">
|
||||
<div class="browser-bar">
|
||||
<select id="drive-select" style="background:#0d0f14;border:1px solid var(--border);color:var(--text);border-radius:6px;padding:8px;"></select>
|
||||
<input id="path-input" placeholder="输入路径或浏览选择...">
|
||||
<button onclick="goUp()">⬆ 上级</button>
|
||||
<button onclick="refreshBrowse()">刷新</button>
|
||||
</div>
|
||||
<div id="browser-list" class="browser-list"></div>
|
||||
<div id="sel-preview" class="sel-preview">未选择</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="card" open id="section-2">
|
||||
<summary><span class="arrow">▶</span>② 选择翻译路线</summary>
|
||||
<div class="card-body">
|
||||
<div class="routes">
|
||||
<label class="route-opt"><input type="radio" name="route" value="direct" checked>
|
||||
<div><div class="r-title">路线 A:直译中文</div>
|
||||
<div class="r-desc">转录 → LLM 修正 → 日文直译中文。生成 .ja.zh.srt(日上中下)+ .zh.srt</div></div></label>
|
||||
<label class="route-opt"><input type="radio" name="route" value="via_en">
|
||||
<div><div class="r-title">路线 B:英转中</div>
|
||||
<div class="r-desc">转录 → LLM 修正 → 日文→英文→中文。生成 .r2.zh.en.srt(中英双语)+ .r2.zh.srt</div></div></label>
|
||||
<label class="route-opt"><input type="radio" name="route" value="transcribe_only">
|
||||
<div><div class="r-title">仅转录</div>
|
||||
<div class="r-desc">只做语音识别,不翻译。输出 _transcript.json</div></div></label>
|
||||
</div>
|
||||
<div class="submit-row">
|
||||
<button id="submit-btn" onclick="createTask()">开始生成字幕</button>
|
||||
<select id="lang-select">
|
||||
<option value="ja" selected>日语</option>
|
||||
<option value="ko">韩语</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="en">英语</option>
|
||||
<option value="yue">粤语</option>
|
||||
</select>
|
||||
<span class="hint">文件夹会批量处理其中所有视频(转录串行,LLM 并发)</span>
|
||||
</div>
|
||||
<div class="submit-row" style="margin-top:8px;">
|
||||
<span class="hint" style="color:var(--dim);">转录并发:</span>
|
||||
<input id="tr-conc-input" type="number" min="1" max="4" value="2" style="width:55px;background:#0d0f14;border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px;">
|
||||
<span class="hint" style="color:var(--dim);">LLM 并发(修正/翻译):</span>
|
||||
<input id="llm-conc-input" type="number" min="1" max="20" value="6" style="width:55px;background:#0d0f14;border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px;">
|
||||
<button onclick="saveConcurrency()" style="background:#1c212b;border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 14px;cursor:pointer;font-size:13px;">保存</button>
|
||||
<span class="hint" id="conc-status" style="color:var(--dim);"></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="card" open id="section-3">
|
||||
<summary><span class="arrow">▶</span>③ 任务列表</summary>
|
||||
<div class="card-body">
|
||||
<div id="task-list"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user