subtitle-studio: 字幕生成系统

- 选择文件夹/单文件 → SenseVoice 转录 → LLM 修正 → 多路线翻译 → 双语 SRT
- 路线:直译中文 / 英转中 / 仅转录
- 并发流水线:转录(可调) + LLM 并发(可调),降噪拆锁并发
- 断点续跑:.subtitle-work/ 中间产物,三阶段独立续跑 + 翻译段级续跑
- 去重:history 跟随视频文件夹,防重复任务保护
- 实时进度:SSE 推送 + 耗时显示 + 子任务状态 + 重新生成按钮
- 时间戳调优:VAD silence_schedule + noisereduce 降噪 + 完整性校验
This commit is contained in:
hmo
2026-08-16 20:17:14 +08:00
commit 7de5ab3902
20 changed files with 2410 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# 运行时产物
logs/
output/
__pycache__/
*.pyc
# 环境与密钥
.env
env.local
*.env
# 临时
temp/
*.log
# 中间产物(在视频目录下,不提交)
.subtitle-work/
+124
View File
@@ -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-vadGPU16kHz wav
│ - fix.py LLM 分块并发修正日文/韩文转录 │
│ - translate.py LLM 全文翻译(路线1 直译 / 路线2 英转中) │
│ - srt.py 生成 .ja.zh.srt / .zh.srt / .zh.en.srt │
│ │ │
│ [运行时] py312_cudaconda + 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.70.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 专用环境)
+63
View File
@@ -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/JSSSE 实时进度 |
| 后端 | FastAPI + uvicorn:8788 |
| 转录 | SenseVoiceSmall + fsmn-vadGPUpy312_cuda 环境) |
| 修正 | LLM 分块并发(4 worker |
| 翻译 | DeepSeek V4 Flash 全文一次请求(1M 上下文,不指定 max_tokens |
| LLM | OCG Router192.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)
+46
View File
@@ -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
+56
View File
@@ -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
+55
View File
@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
"""
subtitle-studio 后台启动器(无阻塞)
用 subprocess.Popen 启动 uvicornshell 调用立即返回
日志写到 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()
+23
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# -*- coding: utf-8 -*-
+36
View File
@@ -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"
+69
View File
@@ -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=180OCG 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
+300
View File
@@ -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)
View File
+50
View File
@@ -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()
+93
View File
@@ -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
+70
View File
@@ -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
+258
View File
@@ -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_pathdenoise=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. SenseVoiceasr 已从 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
+96
View File
@@ -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
+470
View File
@@ -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
View File
@@ -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();
+145
View File
@@ -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>