docs: 注释规范要求精简可读,并清理生产代码中的历史叙事

AGENTS.md 的注释规范新增三节可执行约束:

- 只写代码真实逻辑:注释只回答"做什么"与"为什么必须这么做",禁止写决策/
  修改时间、历史版本对比、实测数据与实验结论、事故与缺陷编号(run_xxxx /
  batch_xxxx / R01 等)——这些属 docs/decisions.md 与审查跟踪文件;当前生效
  的约束可以写,但不附带它何时因何变成这样。
- 精简可读:单段连续注释不超过 3 行;docstring 一句话概括职责,不重复函数名
  已表达的信息;不写逐行翻译代码的废话注释,只在非显然处(业务规则、边界、
  易错点、外部约束)加注。
- 覆盖范围:测试注释只说明验证什么行为,回归用例可保留一句溯源;并明确
  参数说明应写在**参数读取处**附近,而不是把多个参数的解释堆在离使用位置
  很远的注释块里。

按此清理生产代码(注释净减 70 行,18 个文件),典型处理:

- nodes/whisper.py:删掉堆在一起、含"用户 2026-08 决定 / 实测 savr-1054"
  等叙事的参数块,把各参数说明移到各自的读取处与 model.transcribe 调用处;
- nodes/llm_filter.py、nodes/subtitle_cleanup.py:模块 docstring 去掉英文
  背景叙事与条数统计,保留"默认只跑规则层""整条删除而非 '-' 占位"等当前
  行为;
- src/wov_app/{batch,db,scheduler}.py 与 routers:去掉 batch_xxx/run_xxx 事故
  编号与"修复前……"对比,改为一句"否则会出现什么问题";
- nodes/ass.py、frame_extract.py:去掉废弃值对比与日期,保留判据本身。

安全验证:用 AST 对比(剥离 docstring 后比较语法树)确认 18 个文件**零逻辑
变更**;`nodes/proper_nouns.py` 的规则表 reason 字段会注入 LLM 提示词,属于
数据而非注释,已恢复原值。全量测试 476 passed。
This commit is contained in:
2026-09-13 16:37:49 +08:00
parent 8f6083f8cf
commit 7a7212f70c
18 changed files with 192 additions and 262 deletions
+34 -5
View File
@@ -124,11 +124,40 @@
## 代码注释规范 ## 代码注释规范
- 本仓库所有源码(Python、JavaScript、HTML、CSS、TOML 等支持注释的文件) ### 只写代码真实逻辑
必须配有详细中文注释,说明模块/文件职责、核心类与函数的作用以及关键逻辑,
确保后续维护人员无需通读全部实现即可快速理解工作原理。 - 注释只回答两个问题:**这段代码做什么**、**为什么必须这么做**(不这样写会出
- 新增或修改代码时,必须同步补充或更新对应注释;不得删除已有注释 什么错)。读代码的人需要的是当前逻辑,不是它的来历
- 测试代码同样必须配有中文注释,说明每条测试验证的行为与覆盖的路径。 - **禁止写进代码注释**(这些属于 [docs/decisions.md](./docs/decisions.md) 与
[docs/代码审查问题跟踪.md](./docs/代码审查问题跟踪.md)):
- 修改/决策时间(“用户 2026-08 决定”、“2026-09 起”);
- 历史版本对比(“旧版是 X,现在改为 Y”、“修复前……”);
- 实测数据与实验结论(具体条数、耗时、模型名、实验目录);
- 事故与缺陷编号(`run_xxxx``batch_xxxx`、R01/R02 等):改用一句
“否则会出现什么问题” 描述后果即可;
- 变更原因的长篇叙述、将来计划、TODO 式背景;需要时在 docs 记录并链接。
- 例外:**当前生效的约束**可以写(如“默认 60 秒一块,切块失败回退整段”),
但不能附带它何时、因何改为如此。
### 精简可读
- **单段连续注释不超过 3 行**(含行)。超过说明它很可能在讲历史或设计辩论,
应压缩为 1~3 行;确实需要展开的写进 docs/ 并在此链接一句。
- 函数/类 docstring 用一句话概括职责,必要时补 1~2 句关键行为或参数语义;
不重复函数名已表达的信息(`def parse_srt` 不必再写“解析 SRT”)。
- 不写“废话注释”:逐行翻译代码、`# 返回结果``# 循环处理` 这类无信息量的
句子;只在**非显然处**加注释(业务规则、边界、易错点、外部约束)。
- 中文说明,术语与代码标识符保持英文;保持注释与代码同步,改代码必须
同步改注释(不留过期注释),但**不要为了“补充说明”把注释越写越长**。
### 覆盖范围
- 模块/文件职责、公开类与函数的职责与非显然行为必须有注释;私有工具函数
仅在逻辑非显然时加简注。
- 测试代码同样配中文注释,但只说明**验证什么行为**(一句话),不重复测试
步骤的机械描述。例外:回归用例可保留**简要的溯源**(如"曾因 XX 导致
时间轴错位"一句),因为它解释了"为什么必须有这条用例";但仍不写长叙事、
不贴大段实测数据,详细经过放 [docs/decisions.md](./docs/decisions.md)。
- JSON 数据文件(如 `manifests/*.json``workflows/*.json`)按 JSON 规范不支持 - JSON 数据文件(如 `manifests/*.json``workflows/*.json`)按 JSON 规范不支持
注释,字段语义以 `src/wov_sdk/models.py` 的模型注释和 [docs/](./docs/) 文档为准; 注释,字段语义以 `src/wov_sdk/models.py` 的模型注释和 [docs/](./docs/) 文档为准;
修改 JSON 字段时须同步更新文档。 修改 JSON 字段时须同步更新文档。
+13 -22
View File
@@ -11,24 +11,16 @@ from pathlib import Path
from wov_sdk.models import InvokeRequest, InvokeResponse from wov_sdk.models import InvokeRequest, InvokeResponse
# --------------------------------------------------------------------------- # 统一 ASS 样式常量(单一事实来源):新生成字幕(write_ass/invoke)与历史
# 统一 ASS 样式常量(单一事实来源) # 字幕统一脚本(scripts/unify_ass_style.py)共用,调整样式只改这里,两条输出
# --------------------------------------------------------------------------- # 路径不会各自漂移。
# 说明:新生成字幕(write_ass/invoke)与"历史字幕统一脚本"
# scripts/unify_ass_style.py)共用下面这套样式定义——要调整字幕样式
# (位置/透明度/描边等)只改这里,两条输出路径保持一致,不会各自漂移。
#
# 2026-09 调整:默认顶部安全边距 DEFAULT_MARGIN_TOP 由 120 改为 700。
# 旧值 120 顶部对齐时字幕贴近画面最顶端,VR 头盔里需抬头才看得到;
# 700 为实测合适值,字幕落在更接近视线自然平视的高度。
DEFAULT_MARGIN_TOP = 700 DEFAULT_MARGIN_TOP = 700
# 左右眼样式行字段(列顺序与 ASS Style Format 一一对应): # 左右眼样式行字段(列顺序与 ASS Style Format 一一对应):
# - PrimaryColour &HB3FFFFFF:约 70% 透明文字填充,弱化对画面的遮挡; # - PrimaryColour &HB3FFFFFF:约 70% 透明文字填充,降低对画面的遮挡;
# - OutlineColour &H80000000:半透明黑描边(取代早期实心纯黑),保留可读性 # - OutlineColour &H80000000:半透明黑描边,兼顾可读性与不产生生硬黑框;
# 又不产生生硬黑框; # - Alignment 8\an8 顶部居中):配合 MarginV 形成顶部安全区,避开画面中央
# - Alignment 8\an8 顶部居中):配合 MarginV 形成顶部安全区——避开画面 # 人脸区,并落在视线自然高度。
# 中央人脸高发区,同时落在视线自然高度。
_ASS_FONT = "Arial" _ASS_FONT = "Arial"
_ASS_FONT_SIZE = 50 _ASS_FONT_SIZE = 50
_ASS_PRIMARY = "&HB3FFFFFF" _ASS_PRIMARY = "&HB3FFFFFF"
@@ -56,9 +48,9 @@ ASS_EVENT_FORMAT = "Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,E
def style_row(eye: str, width: int, margin_top: int = DEFAULT_MARGIN_TOP) -> str: def style_row(eye: str, width: int, margin_top: int = DEFAULT_MARGIN_TOP) -> str:
"""生成单眼(LeftEye/RightEye)的完整 ASS 样式行。 """生成单眼(LeftEye/RightEye)的完整 ASS 样式行。
左眼占左半幅(左缘留 _EYE_PAD 内边距、向中线收 50%),右眼占右半幅 左眼占左半幅(左缘留 _EYE_PAD 内边距、向中线收 50%),右眼占右半幅
两眼水平相对位置一致 → 零视差A-1):字幕渲染在屏幕平面,不产生 两眼水平相对位置一致 → 零视差,字幕落在屏幕平面,不引入额外景深冲突。
额外景深冲突。margin_top 即该眼样式的 MarginV(距画面上缘的安全边距)。""" margin_top 即该眼样式的 MarginV(距画面上缘的安全边距)。"""
mid = width // 2 mid = width // 2
margin_l, margin_r = (_EYE_PAD, mid) if eye == "LeftEye" else (mid, _EYE_PAD) margin_l, margin_r = (_EYE_PAD, mid) if eye == "LeftEye" else (mid, _EYE_PAD)
return ( return (
@@ -149,8 +141,8 @@ def write_ass(
) -> None: ) -> None:
"""把解析后的条目写入 ASS 文件,每个条目输出左右眼两行 Dialogue。 """把解析后的条目写入 ASS 文件,每个条目输出左右眼两行 Dialogue。
margin_top 控制字幕距画面顶部的安全边距(默认 7002026-09 起),顶部对齐(\an8 margin_top 字幕距画面顶部的安全边距,配合顶部对齐(\an8让字幕落在
使字幕整体落在顶部安全区下方。左右眼使用相同文本与水平相对位置(零视差A-1)。""" 顶部安全区。左右眼使用相同文本与水平相对位置(零视差)。"""
width, height = (int(part) for part in resolution.lower().split("x", 1)) width, height = (int(part) for part in resolution.lower().split("x", 1))
header = ass_header(width, height, margin_top=margin_top) header = ass_header(width, height, margin_top=margin_top)
# an8 对齐到屏幕顶部,配合 MarginV 形成顶部安全区,避开中央人脸区域。 # an8 对齐到屏幕顶部,配合 MarginV 形成顶部安全区,避开中央人脸区域。
@@ -176,8 +168,7 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
output_path = output_dir / "dual_eye.ass" output_path = output_dir / "dual_eye.ass"
# 分辨率默认 3840x1920,覆盖常见 VR 视频尺寸。 # 分辨率默认 3840x1920,覆盖常见 VR 视频尺寸。
resolution = str(request.params.get("resolution", "3840x1920")) resolution = str(request.params.get("resolution", "3840x1920"))
# margin_top 可选:顶部安全边距(默认 700,2026-09 起),不同分辨率/内容 # margin_top 可选:不同分辨率/内容可用工作流参数微调顶部安全边距。
# 仍可用工作流参数微调(历史 120 已过时,勿再使用)。
margin_top = int(request.params.get("margin_top", 700)) margin_top = int(request.params.get("margin_top", 700))
write_ass(entries, output_path, resolution, margin_top=margin_top) write_ass(entries, output_path, resolution, margin_top=margin_top)
return InvokeResponse(status="completed", outputs={"ass_uri": str(output_path)}) return InvokeResponse(status="completed", outputs={"ass_uri": str(output_path)})
+4 -6
View File
@@ -115,12 +115,10 @@ def _parse_progress_line(line: str) -> int | None:
def _sorted_frame_files(frames_dir: Path) -> list[Path]: def _sorted_frame_files(frames_dir: Path) -> list[Path]:
"""按文件名中的帧号数值排序返回帧文件列表(自然排序,非字典序)。 """按文件名中的帧号数值排序返回帧文件列表(自然排序,非字典序)。
关键点:ffmpeg 的 %04d 编号超过 9999 帧后会自动扩为 5 位 ffmpeg 的 %04d 编号超过 9999 帧后会扩为 5 位,字典序会把 5 位编号排在
frame_10000.png 等),此时 sorted() 默认的字典序会把 5 位编号排在 4 位之前("frame_10009" < "frame_1009"),导致帧号回退、帧时间与图像
4 位编号之前(如 "frame_10009" < "frame_1009"),导致帧号回退、 错位。必须解析帧号按数值排序,才能保证“第 k 个文件 = 第 k 个选中帧 =
manifest 时间与图像错位(曾真实发生于 run_339ec7ee437f 的 14236 帧 时间 index*step/fps”成立。
任务,全片后半段时间轴全部错乱)。必须解析出帧号按数值排序,
才能保证"第 k 个文件 = 第 k 个选中帧 = 时间 index*step/fps"成立。
""" """
def frame_number(path: Path) -> int: def frame_number(path: Path) -> int:
# 文件名形如 frame_0001.png,取下划线后的数字部分。 # 文件名形如 frame_0001.png,取下划线后的数字部分。
+9 -16
View File
@@ -1,20 +1,13 @@
"""LLM 翻译节点。 """LLM 翻译节点:SRT → 纯文本分批翻译 → 回填时间轴
单体版中作为进程内节点模块,由调度器直接调用。接收 SRT,提取纯文本行 要点:
分批调用 LLM,再把译文回填到原 SRT 结构并输出 cn.srt。 - **ID 对齐**:以 JSON `{id, text}` 条目请求翻译,逐项校验 ID 集合、类型与
正文;乱序按 ID 回填,缺失/重复/坏结构重试整批。时间戳不进入模型,只在
关键修复(见 tests/test_translation_line_alignment.py): 本地按 cue 回填,避免模型重排断句时译文贴错时间轴。
- **提示词**:要求逐行独立翻译、碎片句按语境独立成行、禁止合并或拆分。
1. **提示词强化**:要求"逐行独立翻译 + 碎片句按语境独立成行 + 禁止合并/拆分" - **严格错误处理**:结构重试耗尽立即失败,不用补空或合并掩盖对应关系丢失。
从源头减少 LLM 因语义碎片而重排断句、导致行数不一致。 - 拼接 system_prompt 时用 `+` 显式连成单个字符串:括号内的隐式字符串拼接
遇到 f-string 表达式会失效,生成 tuple 后序列化成数组,API 会返回 400。
2. **ID 对齐(审查 R05)**:历史按行数合并/补空不能定位中间缺失,曾造成
run_51242078d76e 译文贴错时间。改为 JSON id/text 条目逐项校验,缺失、
重复、未知 ID 或坏结构重试整批,耗尽即失败;时间戳留在本地按 cue 回填。
3. **system_prompt 拼接 bug**:圆括号内一旦出现 f-string 赋值(表达式),
隐式字符串拼接失效,整体变成 tuple;json 序列化后发出去的 content 是数组,
API 返回 400 invalid parameter。必须用 + 显式拼接为单个字符串。
""" """
from __future__ import annotations from __future__ import annotations
+25 -36
View File
@@ -1,31 +1,23 @@
"""LLM 字幕过滤节点。 """LLM 字幕过滤节点:对 OCR 识别出的 SRT 做二次过滤
对 OCR 识别出的 SRT 字幕做二次过滤,两级判断: 两级判断,**默认只跑规则层**(use_llm=0;LLM 层误删真实对话的代价过高,
依据见 docs/decisions.md):
1. **确定性规则层**(不调 LLM):横线装饰、HTML/水印 token、URL/邮箱、 1. **确定性规则层**(不调 LLM):横线装饰、HTML/水印 token、URL/邮箱、
单双 ASCII 字符等 OCR 噪声直接删除——这些模式稳定可判的,走规则 单双 ASCII 字符等 OCR 噪声直接删除模式稳定可判,省 token 且结果确定。
既省 token 又保证结果确定(真实数据中占删除量的 65%+)。
2. **LLM 五类分类层**:把目标字幕连同前后各 context_size 条纯文本分批 2. **LLM 五类分类层**use_llm=1 时启用):把目标字幕连同前后各
提供给 LLM模型输出五个类别之一: context_size 条纯文本交给 LLM,输出五个类别之一:
- garbage:垃圾字符(乱码残缺装饰性符号)→ 删除 - garbage(乱码/残缺/装饰)、overlay(水印/播放器覆盖层)、
- overlay:水印/网页/播放器等覆盖层文本 → 删除 noise(无实义杂项)→ 删除
- noise:与上下文无关、无实际语义的杂项 → 删除 - repeat(内容性重复,如语气词/呻吟)、dialogue(正常对话)→ 保留。
- repeat:内容性重复(语气词、呻吟、重复感叹,属于内容本身)→ 保留 未识别输出一律回退 dialogue(宁滥勿缺)。
- dialogue:正常对话 → 保留
未识别输出一律回退 dialogue(宁滥勿缺,避免误删真实对话)。
3. **文本去重**:相同文本(忽略全部空白与大小写差异)只调一次 LLM, 该层的两个配套机制:
上下文取首次出现位置,结果缓存复用——修复"同一句字幕 5 留 6 删" - **文本去重**:相同文本(忽略空白与大小写)只调一次 LLM,结果复用,
判定一致,同时把长视频的 LLM 调用量降到唯一文本数。 保证同一句话判定一致并减少调用量;
- **上下文净化**:喂给 LLM 的上下文先剔除规则层已判定的垃圾,避免覆盖层
4. **上下文净化**:喂给 LLM 的上下文是**过滤后的字幕**——规则层确定性的 噪声污染场景判断而误删真实对话。
垃圾(横线/HTML/网址/水印等)从上下文中剔除,只留下有意义的对白,
避免覆盖层垃圾污染 LLM 的场景判断导致误删真实对话。
参考真实任务 run_ac7f480a3ccb2026-08OCR 1666 条):旧实现把 394 条
真实对话当噪声删掉(占删除 35%)、同文本判定不一致;新实现按上述机制
回归测试已固化在 tests/test_llm_filter.py。
""" """
from __future__ import annotations from __future__ import annotations
@@ -120,7 +112,7 @@ _HTML_MARK_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
# 规则层正则:播放器/作品编号水印(VLM 常把画面角落的编号识别成短串)。 # 规则层正则:播放器/作品编号水印(VLM 常把画面角落的编号识别成短串)。
# 实测真实数据命中:SPHO-1 / PHO一号馆 / NO.1专用 / PHD-手術 / SP10-1型。 # 覆盖水印编号形态,如 SPHO-1 / PHO一号馆 / NO.1专用 / PHD-手術 / SP10-1型。
# 限定为**不含汉字的编号形态**(或纯形态串),避免误伤正常英文对白。 # 限定为**不含汉字的编号形态**(或纯形态串),避免误伤正常英文对白。
_SERIAL_MARK_RE = re.compile( _SERIAL_MARK_RE = re.compile(
r"^(?:SPH|SPHO|SPIO|SPNO|SP10|PHO|PH0|PHD|P10|NO\.|SP\s*\d)" r"^(?:SPH|SPHO|SPIO|SPNO|SP10|PHO|PH0|PHD|P10|NO\.|SP\s*\d)"
@@ -142,14 +134,14 @@ _CAST_MARK_RE = re.compile(r"^[(]\s*(?:出演|主演|配役|监督|スタッ
DEFAULT_OVERLAY_TOKENS = frozenset( DEFAULT_OVERLAY_TOKENS = frozenset(
{"html", "background", "___", "cleaning", "buffering", "loading", "marketing"} {"html", "background", "___", "cleaning", "buffering", "loading", "marketing"}
) )
# 长文本保护阈值:≥ 该长度的文本,noise 类别不构成删除依据 # 长文本保护阈值:≥ 该长度的文本,noise 类别不构成删除依据LLM 会把完整
# LLM 判定不稳定(实测把完整对话句误判 noise,长度是必要兜底而非删除依据 # 对话句误判 noise,长度是兜底手段,只用于保护不用于删除)
DEFAULT_MIN_KEEP_LEN = 12 DEFAULT_MIN_KEEP_LEN = 12
# 纯数字/小数点组合("4.0"/"2.0"/"10-1期B" 中的纯数值形态)。 # 纯数字/小数点组合("4.0"/"2.0"/"10-1期B" 中的纯数值形态)。
_NUMBER_ONLY_RE = re.compile(r"^\d{1,3}[.,]\d{1,2}$") _NUMBER_ONLY_RE = re.compile(r"^\d{1,3}[.,]\d{1,2}$")
# LLM 分类层开关(2026-09 默认关闭):见模块与 nodes/llm_filter.py 说明 # LLM 分类层开关:默认关闭,只跑确定性规则层(原因见模块 docstring)
DEFAULT_USE_LLM = False DEFAULT_USE_LLM = False
@@ -232,9 +224,8 @@ def _should_delete(category: str, text: str, min_keep_len: int) -> bool:
"""按 LLM 类别与长文本保护决定是否删除。 """按 LLM 类别与长文本保护决定是否删除。
repeat/dialogue 一律保留;garbage/overlay 一律删除(明确的垃圾信号); repeat/dialogue 一律保留;garbage/overlay 一律删除(明确的垃圾信号);
noise 对短文本删除,但 ≥min_keep_len 的长文本不删——LLM 判定不稳定, noise 对短文本删除:LLM 会把完整对话句误判为 noise,≥min_keep_len 的
完整对话句常被误判 noise,长度保护是必要兜底(实测移除后新增误删 长文本一律保留。长度只用于保护,不用于删除。
124 条真实长对话)。长度只用于"保护",不用于"删除"
""" """
if category not in DELETE_CATEGORIES: if category not in DELETE_CATEGORIES:
return False return False
@@ -362,10 +353,8 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
} }
# 去重开关:默认开;关掉时每个条目独立调用 LLM(不省调用,判定各自独立)。 # 去重开关:默认开;关掉时每个条目独立调用 LLM(不省调用,判定各自独立)。
dedupe = str(request.params.get("dedupe", "1")) not in ("0", "false", "False") dedupe = str(request.params.get("dedupe", "1")) not in ("0", "false", "False")
# LLM 分类层开关(2026-09 默认关闭):实测该层额外删除的 131 条中 56% 是 # LLM 分类层开关:默认关闭,只跑确定性规则层(宁多留不漏删);关闭原因
# 真实对话(run_ac7f480a3ccb 逐类人工审查),真正抓到而规则层抓不到的仅 58 # 见模块 docstring 与 docs/decisions.md。需要该层时用 params.use_llm=1。
# 条(已大部分下沉为规则)。默认只跑确定性规则层,宁多留不漏删;
# 需要旧行为时用 params.use_llm=1 显式打开。
use_llm = str(request.params.get("use_llm", "1" if DEFAULT_USE_LLM else "0")) not in ( use_llm = str(request.params.get("use_llm", "1" if DEFAULT_USE_LLM else "0")) not in (
"0", "false", "False", "0", "false", "False",
) )
@@ -414,7 +403,7 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
done, total, rate, avg_time, workers, pool.max_workers, done, total, rate, avg_time, workers, pool.max_workers,
) )
# 自适应并发调用 LLM:按实测负载弹性伸缩,避免压垮 LLM 接口。 # 自适应并发调用 LLM:按响应快慢弹性伸缩,避免压垮接口。
pool = AdaptiveThreadPool( pool = AdaptiveThreadPool(
worker=judge_one, worker=judge_one,
on_progress=log_progress, on_progress=log_progress,
+19 -39
View File
@@ -1,28 +1,18 @@
"""Subtitle cleanup: remove long-duration closing/greeting hallucinations. """字幕清洗:删除寒暄幻觉与纯呻吟碎片。
Background (real run 20260905115050): after fixing the timing alignment, ASRwhisper 无 VAD 解码)与 LLM 翻译在无语音段会输出固定套话(如“晚安/
subtitles still contain "closing/greeting hallucination words" - fixed 感谢观看”/“おやすみなさい”),且在翻译产物里反复出现。处理方式为**连带
phrases like 'wan an / gan xie guan kan / gan xie nin de guan kan' 时间戳整条删除** cue 并重新编号:不能改成 '-' 占位,否则会一路渲染成可见
(good night / thanks for watching) that the ASR/LLM repeatedly emits on 减号,并在翻译/过滤阶段需要额外特判。
empty segments, filling a full 30s block, unrelated to video content.
Some 2s 'good night' might be real dialogue, so it must be kept.
处理策略(2026-09 用户确认改为"整条剔除"):
幻觉识别出后(展示时长 ≥ 阈值 且 文本命中套话词表),应**连带时间戳把整条
字幕 cue 删除**(剩余条目重新编号),而不是把文本替换成 '-' 占位留给下游——
占位会一路流到 ASS 渲染成可见的"减号"、在翻译/过滤阶段都要额外特殊处理,
处理位置绕且不彻底。因此清洗统一为:**在幻觉产生处(whisper 转录后 / LLM
翻译后)整条删除**,时间轴随之消失,字幕序号重新连续编号。
两类词表: 两类词表:
- HALLUCINATION_TOKENS:中文(LLM 翻译产物中的寒暄,如"晚安/感谢观看"); - HALLUCINATION_TOKENS:中文(LLM 翻译产物中的寒暄);
- JAPANESE_HALLUCINATION_TOKENS:日文(whisper decode_full 无 VAD 解码在 - JAPANESE_HALLUCINATION_TOKENS:日文(whisper 无 VAD 解码直接输出的套话)。
无语音段直接输出的套话,如"おやすみなさい/ご視聴ありがとうございました")。
阈值从真实运行实测数据判定:30s 幻觉占位 vs 2s 真实词,分界明显, 删除只针对展示时长 ≥ 阈值的长条目:短时相同词可能是剧情真实台词(如真实
默认 threshold=15s(≥15s 才删除;<15s 的相同词可能是剧情真实道晚安,保留)。 互道晚安),必须保留。另外删除纯呻吟/喘息碎片(判据见 _is_pure_moan)。
Pure functions, unit-testable (tests/test_hallucination_mask.py). 纯函数,不修改输入。
""" """
from __future__ import annotations from __future__ import annotations
@@ -39,11 +29,8 @@ HALLUCINATION_TOKENS = (
# Display-duration threshold (seconds): only remove entries longer than this. # Display-duration threshold (seconds): only remove entries longer than this.
DEFAULT_THRESHOLD_SECONDS = 15.0 DEFAULT_THRESHOLD_SECONDS = 15.0
# 日文 ASR 直出(whisper 节点 decode_full 无 VAD 整段解码)的收尾/寒暄幻觉词。 # 日文套话词表:whisper 无 VAD 解码会把无语音/音乐段当语音,从而重复输出
# 无 VAD 解码会把无语音/音乐/呻吟段当语音,whisper 常在这些段重复输出套话 # 这些寒暄;短时相同词可能是剧情真实台词,靠时长阈值区分(同中文表机制)。
# (实测 savr-1054 全片 119-149s/600-630s 等出现 30s 长"おやすみなさい"
# "ご視聴ありがとうございました")。短时(≤阈值)的相同词可能是剧情里真实
# 互道晚安,须保留;仅删除展示时长 ≥ 阈值的条目(与中文表同一机制)。
JAPANESE_HALLUCINATION_TOKENS = ( JAPANESE_HALLUCINATION_TOKENS = (
"おやすみなさい", # 晚安 "おやすみなさい", # 晚安
"ご視聴ありがとうございました", # 感谢观看 "ご視聴ありがとうございました", # 感谢观看
@@ -58,14 +45,10 @@ JAPANESE_HALLUCINATION_TOKENS = (
"Thank you for watching", "Thank you for watching",
) )
# 纯呻吟/喘息字符集合decode_full 救回弱语音后的去噪,2026-09 用户决策)。 # 纯呻吟/喘息字符集合:文本(去空白/标点)全部由本集合字符组成、且有效假名数
# # ≤ 阈值时才判为噪声删除。集合**刻意排除** そ/こ/ね/や/ば/だ/く/へ 等假名——
# 背景(实测 savr-1054-2 前 600s):decode_full 无 VAD 解码会把呻吟/BGM 混叠 # 真实短对话(そこ/やばい/ねえ/やだ/えへへ)都含这些字符,因此天然不命中,从
# 的弱语音也整段救回,但其中混有大量**纯语气词碎片**(あ…/ん?/はぁ…/あ!あ! # 判据根源上避免误删真实短句。
# /んふふ 等),这类内容放进字幕是噪声。判据:文本(去空白/标点)**全部由本
# 集合字符组成**且有效假名数 ≤ 阈值才删除。集合**刻意排除** そ/こ/ね/や/ば/だ
# /く/へ 等假名——真实短对话(そこ/やばい/ねえ/やだ/えへへ)都含这些字符,
# 含任意非集合字符的条目天然不命中,从根上避免误删真实短句。
MOAN_CHARS = frozenset( MOAN_CHARS = frozenset(
# 平假名元音与ん/ふ/は(呻吟与喘息气流音的主干) # 平假名元音与ん/ふ/は(呻吟与喘息气流音的主干)
"あいうえおんふはっ" "あいうえおんふはっ"
@@ -144,12 +127,9 @@ def remove_short_moan_entries(
) -> str: ) -> str:
"""删除 SRT 中'纯呻吟/喘息碎片'的**整条 cue**whisper decode_full 去噪)。 """删除 SRT 中'纯呻吟/喘息碎片'的**整条 cue**whisper decode_full 去噪)。
decode_full 无 VAD 解码会把呻吟也整段救回,字幕混入大量纯语气词碎片 无 VAD 解码会把呻吟也整段救回,字幕因此混入纯语气词碎片(あ…/ん?)。
(あ…/ん?/はぁ…)。判据:文本全部由 MOAN_CHARS 组成且有效假名数 ≤ 判据见 _is_pure_moan:文本全部由 MOAN_CHARS 组成且有效假名数 ≤ max_chars
max_chars(默认 3)才删除(见 _is_pure_moan),真实短对话(そこ/やばい/ 才删除;max_chars=0 时关闭过滤。纯函数,不修改输入。
ねえ/やだ/えへへ/行く行く行く)天然不命中。max_chars=0 时关闭过滤(原
样返回)。仅在 whisper 节点 decode_full=true 时调用(用户 2026-09 决策,
不作用于 learn-translate 等 VAD 链路)。纯函数,不修改输入。
""" """
if max_chars <= 0: if max_chars <= 0:
return srt_text return srt_text
+4 -5
View File
@@ -56,8 +56,8 @@ def _load_partial(output_dir: Path) -> dict[int, str]:
except json.JSONDecodeError: except json.JSONDecodeError:
# 进程被杀时可能残留半行写入:跳过该行,对应帧视为未处理。 # 进程被杀时可能残留半行写入:跳过该行,对应帧视为未处理。
continue continue
# 存档区分成功空帧与确定跳过(超长输出);失败不写成功存档。 # 存档区分"成功空帧/skipped(超长跳过)"与失败——失败不写成功存档。
# 旧版空串可能来自网络故障,恢复时重新识别;旧版非空结果可复用。 # 无 status 的历史空串无法区分超时与无文字,需重新识别;非空结果可复用。
text = str(item["text"]) text = str(item["text"])
if item.get("status") in ("completed", "skipped") or ("status" not in item and text): if item.get("status") in ("completed", "skipped") or ("status" not in item and text):
result[int(item["frame"])] = text result[int(item["frame"])] = text
@@ -248,9 +248,8 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
_eta_suffix(done, total, rate), _eta_suffix(done, total, rate),
) )
# 自适应并发调用 vlm-ocr10s 窗口内平均响应 < 0.3s 则加 1 线程(上限 # 自适应并发调用 vlm-ocr窗口内平均响应快慢增/减额度(上限
# pool_max_workers),> pool_slow_threshold 则减 1 线程(下限 1), # pool_max_workers、下限 1),避免盲目并发压垮本地 Ollama。
# 按实测负载弹性伸缩,避免盲目并发压垮本地 Ollama。
pool = AdaptiveThreadPool( pool = AdaptiveThreadPool(
worker=ocr_frame, worker=ocr_frame,
on_progress=log_progress, on_progress=log_progress,
+4 -4
View File
@@ -1,10 +1,10 @@
"""每视频自适应 VAD 调参(信号分析 + 片段网格验证)。 """每视频自适应 VAD 调参(信号分析 + 片段网格验证)。
背景(实测 CJOD-255:全程 BGM 覆盖的视频几乎没有纯静音,固定 VAD 背景:全程 BGM 覆盖的视频几乎没有纯静音,固定 VAD 参数会把音乐当语音,
参数会把音乐当语音,导致 whisper 全段解码 → 碎片化(80%)、漏句(32%)、 导致 whisper 全段解码、字幕碎片化并漏句。不同视频声学差异大,需**每视频
敏感段丢失。不同视频声学差异大,需**每视频独立分析后再定 VAD 参数**。 独立分析后再定 VAD 参数**。
方案(用户确认) 方案:
1. 信号分析(秒级,无模型)缩窄参数空间:1s 能量分布 → 静音比例、 1. 信号分析(秒级,无模型)缩窄参数空间:1s 能量分布 → 静音比例、
BGM 底噪、语音疏密,推出 threshold/min_silence/speech_pad 候选; BGM 底噪、语音疏密,推出 threshold/min_silence/speech_pad 候选;
2. 片段小网格验证(90s 代表片段,3~5 组参数跑 whisper): 2. 片段小网格验证(90s 代表片段,3~5 组参数跑 whisper):
+6 -8
View File
@@ -6,14 +6,12 @@
与 llm-translate 节点相互独立:本节点只做视觉 OCR,不做翻译,避免把两类 与 llm-translate 节点相互独立:本节点只做视觉 OCR,不做翻译,避免把两类
职责混在一起。调用协议见 Ollama 官方文档:POST /api/chat。 职责混在一起。调用协议见 Ollama 官方文档:POST /api/chat。
请求约定2026-08 调整,直接请求 API 版本) 请求约定:
- 使用流式传输(stream=True逐行接收生成内容,命中终止序列立即停止接收, - 流式传输(stream=True)逐行接收,命中终止序列立即停止拉取,避免模型重复
避免模型重复循环时无限拉取输出;响应体整体受 5 秒截止时间约束。 循环时无限输出;整体受超时截止约束(timeout_seconds / VLM_TIMEOUT_SECONDS
- 采样 temperature 默认 0.3(可参数覆盖),并携带终止序列列表 默认 5 秒),超时即终止而不继续等待后续块。
`["\n", "\n", ""]`(输出首个换行即停 + 阻止“答:”式重复循环); - 采样 temperature 默认 0.3,并携带终止序列 `["\n", "\n", ""]`(输出
模型生成遇到任一标记即停止。 首个换行即停,同时阻止“答:”式重复循环),模型遇到任一标记即停止。
- 每次调用整体超时 5 秒(timeout_seconds 参数 / VLM_TIMEOUT_SECONDS 环境变量),
超过即终止,不再继续等待后续流式块。
""" """
from __future__ import annotations from __future__ import annotations
+18 -26
View File
@@ -225,28 +225,22 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
device=device, device=device,
compute_type=compute_type, compute_type=compute_type,
) )
# language 默认日语;vad_filter 默认开启(用户 2026-08 决定):过滤静音 # 常用参数默认值见各参数的读取处;完整参数手册见
# 段以提速并减少无语音处幻觉;长静音时 VAD 压缩时间轴可能轻微错位, # workflows/learn-translate.json 的 params._node_help。
# 如需极致对齐可在工作流参数中显式关闭。
# decode_full=true(默认 false):VAD 对呻吟/轻语/BGM 混叠声学切段会
# 把真话当非语音剔除(实测 savr-1054 全片仅召回 115 条),开启后强制
# 无 VAD 整段解码(vad_filter=False 且跳过自动 VAD 分析)以召回弱语音,
# 代价是无语音段会产生长时套话幻觉,由下方日语幻觉清洗兜底移除。
# 另:decode_full 救回的弱语音中混有纯语气词碎片(あ/ん?等),由
# short_moan_max_chars(默认 3,0=关闭)参数控制短呻吟整条删除。
# task 默认 transcribe,中文直出模型可传 translate 直接翻译为目标语言。
# condition_on_previous_text 默认 False:长音频下开启会导致重复/漂移,
# 关闭后每个 30s 窗口独立解码,是 faster-whisper 官方建议的长音频方案。
output_dir = Path(request.output_dir) output_dir = Path(request.output_dir)
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
# 分块转写:默认每 1 分钟一块(chunk_seconds=60),切块失败自动回退整段。 # 分块转写:默认每 1 分钟一块(chunk_seconds=60),切块失败自动回退整段。
chunk_seconds = int(request.params.get("chunk_seconds", 60)) chunk_seconds = int(request.params.get("chunk_seconds", 60))
chunks = _split_audio(audio_path, output_dir, chunk_seconds, _ffmpeg_bin()) chunks = _split_audio(audio_path, output_dir, chunk_seconds, _ffmpeg_bin())
# decode_full 时强制无 VAD:不传 vad_filter/vad_parameters也不跑自动 VAD # decode_full:忽略 vad_filtervad_parameters整段无 VAD 解码以召回
# 分析(分析结果对呻吟/轻语类音频无效,只会把整块切碎/剔除真话)。 # 被 VAD 当非语音剔除的弱语音(代价是无语音段产生长时幻觉,由末尾清洗
# 处理)。为 False 时是否启用 VAD 由下方 vad_filter / 自动分析决定。
decode_full = bool(request.params.get("decode_full", False)) decode_full = bool(request.params.get("decode_full", False))
vad_parameters = request.params.get("vad_parameters") vad_parameters = request.params.get("vad_parameters")
# 默认开 VAD:滤掉静音段提速并减少无语音处幻觉;代价是长静音下时间轴
# 会被压缩回映射,需极致对齐的素材可显式关掉。
vad_filter = bool(request.params.get("vad_filter", True)) vad_filter = bool(request.params.get("vad_filter", True))
# 未显式给参且开启 VAD 时,按音频信号分析自动推荐参数(可 WOV_AUTO_VAD=0 关)。
if not decode_full and vad_filter and not vad_parameters and os.getenv("WOV_AUTO_VAD", "1") == "1": if not decode_full and vad_filter and not vad_parameters and os.getenv("WOV_AUTO_VAD", "1") == "1":
try: try:
from nodes.vad_profiler import vad_parameters_for_audio from nodes.vad_profiler import vad_parameters_for_audio
@@ -278,14 +272,18 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
if (Path(request.output_dir).parent.parent / PAUSE_FLAG).exists(): if (Path(request.output_dir).parent.parent / PAUSE_FLAG).exists():
raise RuntimeError(f"whisper 被暂停(run {request.run_id}") raise RuntimeError(f"whisper 被暂停(run {request.run_id}")
chunk_started = time.monotonic() chunk_started = time.monotonic()
# decode_full=true 时 vad_filter 传 False:跳过 VAD 剔除弱语音段。
segments, _info = model.transcribe( segments, _info = model.transcribe(
str(chunk), str(chunk),
# 源语言默认日语;中文直出模型配合 task=translate 可直接出中文。
language=str(request.params.get("language", "ja")), language=str(request.params.get("language", "ja")),
task=str(request.params.get("task", "transcribe")), task=str(request.params.get("task", "transcribe")),
# beam_size=1(贪心)足够且最快,提高只对难句有微弱收益。
beam_size=int(request.params.get("beam_size", 1)), beam_size=int(request.params.get("beam_size", 1)),
# decode_full 时关闭 VAD,跳过对弱语音段的剔除。
vad_filter=False if decode_full else vad_filter, vad_filter=False if decode_full else vad_filter,
vad_parameters=None if decode_full else vad_parameters, vad_parameters=None if decode_full else vad_parameters,
# 默认 False:长音频下开启会累积上下文导致重复/漂移,
# 关闭后每个 30s 窗口独立解码。
condition_on_previous_text=bool( condition_on_previous_text=bool(
request.params.get("condition_on_previous_text", False) request.params.get("condition_on_previous_text", False)
), ),
@@ -302,17 +300,9 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
chunk_seconds / chunk_elapsed if chunk_elapsed > 0 else 0.0, chunk_seconds / chunk_elapsed if chunk_elapsed > 0 else 0.0,
time.monotonic() - transcribe_started, time.monotonic() - transcribe_started,
) )
# decode_full(无 VAD)副作用:无语音/音乐/呻吟段会产生长时寒暄套话幻觉 # decode_full(无 VAD副作用清理:无语音段的长时寒暄幻觉与纯语气词
# (おやすみなさい/ご視聴ありがとうございました 等),在此**连带时间戳整条 # 碎片都是噪声,整条删除(序列号重排,不留 '-' 占位污染下游);判据与
# 剔除**(序号/时间轴/文本全删、剩余重编号),不留下 '-' 占位污染下游 # 细节见 nodes/subtitle_cleanup.py。仅 decode_full 时启用。
# (占位会渲染进 ASS 成减号、翻译/过滤都要额外处理);短时(≤15s)相同词
# 可能是剧情真实道晚安,保留。见 nodes/subtitle_cleanup.py。
# 其次(2026-09 用户决策):decode_full 也会把呻吟/BGM 混叠的弱语音整段
# 救回,其中混有大量**纯语气词碎片**(あ…/ん?/はぁ…/あ!あ!/んふふ 等),
# 这类噪声影响字幕观感;在此按'全部字符∈纯呻吟集合 且 有效假名≤max_chars'
# 判据**整条删除**remove_short_moan_entries),真实短对话(そこ/やばい/
# ねえ/やだ)天然不命中。仅 decode_full 生效,learn-translate 等 VAD 链路不受影响;
# 参数 short_moan_max_chars 可调(默认 3,设 0 关闭)。
if decode_full: if decode_full:
from nodes.subtitle_cleanup import ( from nodes.subtitle_cleanup import (
clean_japanese_hallucinations, clean_japanese_hallucinations,
@@ -320,6 +310,8 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
) )
body = clean_japanese_hallucinations("\n".join(lines)) body = clean_japanese_hallucinations("\n".join(lines))
# short_moan_max_chars:有效假名 ≤ 该值的纯呻吟碎片整条删除,
# 设 0 关闭(真实短对话不会命中,判据见 subtitle_cleanup)。
body = remove_short_moan_entries( body = remove_short_moan_entries(
body, body,
max_chars=int(request.params.get("short_moan_max_chars", 3)), max_chars=int(request.params.get("short_moan_max_chars", 3)),
+18 -33
View File
@@ -4,7 +4,7 @@
全部视频,逐个调用现有的工作流流水线(复用 WorkflowScheduler 的 DAG 执行与 全部视频,逐个调用现有的工作流流水线(复用 WorkflowScheduler 的 DAG 执行与
断点续跑逻辑)。 断点续跑逻辑)。
处理约定2026-09 起) 处理约定:
- **创建任务时一次性定位**:`create_job` 扫描文件夹并把每个视频登记为 - **创建任务时一次性定位**:`create_job` 扫描文件夹并把每个视频登记为
batch_videos 明细;视频所在目录(视频旁)若已存在**文件名包含视频名**的 batch_videos 明细;视频所在目录(视频旁)若已存在**文件名包含视频名**的
@@ -67,9 +67,8 @@ SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt"}
# 暂停信号文件名:与节点约定一致,位于 run 根目录(<work_dir>/runs/<run_id>/)。 # 暂停信号文件名:与节点约定一致,位于 run 根目录(<work_dir>/runs/<run_id>/)。
PAUSE_FLAG = "paused.flag" PAUSE_FLAG = "paused.flag"
# 旧版批量完成标记文件名(位于视频同名文件夹根目录)。新逻辑不再写入该标记 # 兼容读取的历史完成标记文件名(旧任务用它记录产物路径)。当前逻辑不再
# 产物直接放视频旁、靠旁挂字幕文件识别完成);仍保留读取能力,用于兼容 # 写入,产物直接放视频旁;保留读取能力以便旧任务的详情/下载仍可用。
# 旧版任务在详情/下载接口中展示产物。
MARKER_NAME = "batch.done.json" MARKER_NAME = "batch.done.json"
# 批量处理私有工作空间根目录:位于应用存储目录下(data/storage/batch)。 # 批量处理私有工作空间根目录:位于应用存储目录下(data/storage/batch)。
@@ -154,7 +153,7 @@ def _sidecar_product_name(video: Path, source: Path) -> str:
def load_marker(work_dir: Path) -> dict | None: def load_marker(work_dir: Path) -> dict | None:
"""读取同名文件夹里的旧版完成标记;不存在或损坏时返回 None。""" """读取历史完成标记;不存在或损坏时返回 None。"""
path = work_dir / MARKER_NAME path = work_dir / MARKER_NAME
if not path.is_file(): if not path.is_file():
return None return None
@@ -351,23 +350,20 @@ class BatchWorker:
definition.validate() definition.validate()
items = self.db.list_batch_videos(job_id) items = self.db.list_batch_videos(job_id)
# total 创建任务时已固定为"无字幕需处理的视频数",这里不覆盖; # total 创建时已固定为"无字幕需处理的视频数",此处不覆盖;历史任务
# 老任务(历史口径 total=全部视频数)由 sync_batch_job_progress 在读取 # 的旧口径由 sync_batch_job_progress 在读取时修正为不含 SKIPPED。
# 时自我修正为不含 SKIPPED 的口径。
self.db.update_batch_job( self.db.update_batch_job(
job_id, status="RUNNING", progress=0, job_id, status="RUNNING", progress=0,
current_video=None, error=None, updated_at=_now_iso(), current_video=None, error=None, updated_at=_now_iso(),
) )
# total 用于进度条分母;无字幕项为 0 表示整批跳过(创建即 COMPLETED # total 进度条分母;为 0 表示整批跳过(创建即 COMPLETED)。
# 正常不会进入本循环)。
total = int(job["total"] or 0) total = int(job["total"] or 0)
for item in items: for item in items:
# 暂停检查:批量任务被暂停后停止处理后续视频,等待用户继续。 # 暂停检查:批量任务被暂停后停止处理后续视频,等待用户继续。
current = self.db.get_batch_job(job_id) current = self.db.get_batch_job(job_id)
if current is None or current["status"] == "PAUSED": if current is None or current["status"] == "PAUSED":
# 停下前把已完成/失败的视频实时入账,让暂停中的前端也能看到 # 停下前把已完成/失败入账,让暂停中的前端看到真实进度。
# 真实进度(回归 batch_fee668175444)。
self.db.sync_batch_job_progress(job_id) self.db.sync_batch_job_progress(job_id)
logger.info("批量任务 %s 已暂停,停止在视频 %s", job_id, item["video_path"]) logger.info("批量任务 %s 已暂停,停止在视频 %s", job_id, item["video_path"])
return return
@@ -407,17 +403,9 @@ class BatchWorker:
self.db.update_batch_job(job_id, status="PAUSED", updated_at=_now_iso()) self.db.update_batch_job(job_id, status="PAUSED", updated_at=_now_iso())
return return
# 全部视频处理完成:先用明细实时对齐汇总(done 只计实际完成的, # 先按明细实时对齐汇总(done 不计 SKIPPED),再判断能否收尾。
# 不含 SKIPPED),再置 COMPLETED。 # 仍有未结束视频时不能标 COMPLETED,否则会出现“还有待处理视频却已完成”
# # 的僵尸状态;此时保持 RUNNING,由引擎下一轮续跑。
# 置 COMPLETED 前必须校验**没有未处理完的视频残留**:若本轮循环因
# 视频处理中断/异常(_process_video 返回但视频仍 PENDING,等同进程
# 在处理中被杀)而没有真正处理完所有 PENDING,就**不能**标完成——
# 否则会出现"明细还有 N 个待处理、任务却已完成"的僵尸状态
# batch_969fabe74b83 等 3 个任务真实发生:引擎串行处理到 9.9GB
# 大视频时中断,10 个视频留 PENDING 却被无条件置 COMPLETED)。
# 此时保持 RUNNING,让引擎下一轮(重启后重新拾起 RUNNING 任务)
# 继续处理剩余 PENDING,全部结束才真正置 COMPLETED。
self.db.sync_batch_job_progress(job_id) self.db.sync_batch_job_progress(job_id)
job = self.db.get_batch_job(job_id) job = self.db.get_batch_job(job_id)
if job is None: if job is None:
@@ -427,8 +415,7 @@ class BatchWorker:
if v["status"] not in ("SKIPPED", "COMPLETED", "FAILED") if v["status"] not in ("SKIPPED", "COMPLETED", "FAILED")
] ]
if leftovers: if leftovers:
# 有未处理完的视频PENDING/PAUSED/QUEUED 等):保持 RUNNING # 有未处理完的视频:保持 RUNNING,由引擎下一轮续跑。
# 由引擎下一轮续跑;记录日志便于排查中断位置。
logger.warning( logger.warning(
"批量任务 %s 仍有 %d 个视频未处理完(%s…),保持 RUNNING 待续跑,不置 COMPLETED", "批量任务 %s 仍有 %d 个视频未处理完(%s…),保持 RUNNING 待续跑,不置 COMPLETED",
job_id, len(leftovers), Path(leftovers[0]["video_path"]).name, job_id, len(leftovers), Path(leftovers[0]["video_path"]).name,
@@ -464,11 +451,11 @@ class BatchWorker:
work_dir.mkdir(parents=True, exist_ok=True) work_dir.mkdir(parents=True, exist_ok=True)
run_id = item.get("run_id") run_id = item.get("run_id")
if run_id is not None and self.db.get_run(run_id) is None: if run_id is not None and self.db.get_run(run_id) is None:
# run 记录已不存在(此前收尾异常删除了 run 但状态未同步):重新新建。 # run 记录已不存在(收尾异常删除了 run 但状态未同步):重新新建。
run_id = None run_id = None
if run_id is None: if run_id is None:
# 首次处理:创建 source=batch 的运行,input_uri 直接指向本地视频 # 首次处理:创建 source=batch 的运行,input_uri 指向本地视频
# (不上传副本),调度器按工作流 DAG 动态组装节点执行。 # (不上传副本),调度器按 DAG 执行。
run_id = f"run_{uuid.uuid4().hex[:12]}" run_id = f"run_{uuid.uuid4().hex[:12]}"
now = _now_iso() now = _now_iso()
self.db.create_run({ self.db.create_run({
@@ -486,7 +473,7 @@ class BatchWorker:
self.db.update_batch_video(item["id"], run_id=run_id, updated_at=_now_iso()) self.db.update_batch_video(item["id"], run_id=run_id, updated_at=_now_iso())
run = self.db.get_run(run_id) run = self.db.get_run(run_id)
# 已完成(例如上次收尾前中断):直接放置产物并清理后返回 # 已完成(收尾前中断):直接补做收尾
if run["status"] == "COMPLETED": if run["status"] == "COMPLETED":
self._finalize_video(item, run_id, video, work_dir, definition) self._finalize_video(item, run_id, video, work_dir, definition)
return return
@@ -494,10 +481,8 @@ class BatchWorker:
if run["status"] == "PAUSED": if run["status"] == "PAUSED":
self.db.resume_run(run_id, _now_iso()) self.db.resume_run(run_id, _now_iso())
elif run["status"] == "FAILED": elif run["status"] == "FAILED":
# 失败重跑:**保留**已完成节点的产物记录,只恢复 QUEUED—— # 失败重跑:保留产物记录只置 QUEUED,由 execute_run 跳过已完成
# execute_run 从产物表重建已完成节点并跳过,只重跑失败节点 # 节点、仅重跑失败节点,避免浪费抽帧/OCR 等长耗时成果
# 不再 reset_run 清空产物:extract/ocr 等长耗时节点的成果会被
# 白白丢弃重做(run_e2b74e89e232 实测 22222 帧 OCR)。
self.db.update_run(run_id, status="QUEUED", error=None, updated_at=_now_iso()) self.db.update_run(run_id, status="QUEUED", error=None, updated_at=_now_iso())
elif run["status"] == "RUNNING": elif run["status"] == "RUNNING":
# 上次进程被杀残留:恢复 QUEUED(保留产物)由 execute_run 续跑。 # 上次进程被杀残留:恢复 QUEUED(保留产物)由 execute_run 续跑。
+12 -21
View File
@@ -353,11 +353,10 @@ class Database:
def next_queued_run(self) -> dict[str, Any] | None: def next_queued_run(self) -> dict[str, Any] | None:
"""按创建时间返回最早一条排队(QUEUED)任务。 """按创建时间返回最早一条排队(QUEUED)任务。
只取 QUEUEDPAUSED 任务必须由用户显式 resume(转回 QUEUED)后调度器 只取 QUEUEDPAUSED 必须由用户显式 resume 后才执行,否则暂停会被
才重新执行。修复回归——此前把 PAUSED 也当可执行任务拾起,execute_run execute_run 立刻覆盖成 RUNNING。
会先置 RUNNING 再检查暂停,导致"点击暂停反而开始任务" 排除 source=batch:批量运行由批量引擎在私有工作空间执行,主调度器
同时排除 source=batch 的批量运行:批量任务由批量引擎使用视频旁的 拾起会用错存储目录。
同名文件夹作为 storage 执行,主调度器拾起会用错存储目录。
""" """
with self._connect() as conn: with self._connect() as conn:
row = conn.execute( row = conn.execute(
@@ -388,13 +387,10 @@ class Database:
def recover_interrupted_batch_jobs(self, updated_at: str) -> int: def recover_interrupted_batch_jobs(self, updated_at: str) -> int:
"""重启恢复:把遗留 RUNNING 的批量任务恢复为 QUEUED,返回恢复数量。 """重启恢复:把遗留 RUNNING 的批量任务恢复为 QUEUED,返回恢复数量。
批量引擎处理视频(尤其大文件)时进程被杀/重启,批量任务停在 批量任务停在 RUNNINGnext_queued_batch_job 只拾取 QUEUED
RUNNING:其关联 run 由 recover_interrupted_runs 恢复为 QUEUED,但 永远不会重新驱动它,未处理完的 PENDING 视频会永久残留;恢复为
批量任务本身若保持 RUNNINGnext_queued_batch_job 只拾取 QUEUED QUEUED 后引擎从断点(剩余视频 + 已恢复的 run)继续。用户主动暂停的
永远不会重新驱动它 → 未处理完的 PENDING 视频永久残留 PAUSED 保持不变。
batch_969fabe74b83 事故链路之一)。恢复为 QUEUED 后引擎重新拾起,
从断点(剩余 PENDING 视频 + 已恢复的 run)继续处理。用户主动暂停的
PAUSED 批量任务保持不变,等待显式 resume。
""" """
with self._connect() as conn: with self._connect() as conn:
cur = conn.execute( cur = conn.execute(
@@ -579,15 +575,10 @@ class Database:
def sync_batch_job_progress(self, job_id: str) -> None: def sync_batch_job_progress(self, job_id: str) -> None:
"""按视频明细实时对齐任务的 total/done/failed 汇总并落库。 """按视频明细实时对齐任务的 total/done/failed 汇总并落库。
统计口径(2026-09 用户确认):total = 本批**无字幕、需要处理**的视频数 口径:total = 需处理的视频数(非 SKIPPED,创建时固定);done 只计
(= 明细里非 SKIPPED 的数量,创建时已固定,运行中 SKIPPED 不会变化); COMPLETEDSKIPPED 不计);failed 为失败数。引擎在暂停、视频间与
done = 实际**处理完成**的视频数(仅 COMPLETEDSKIPPED 不计); 收尾边界调用,router 读取前也调用,保证前端进度与明细一致(即使任务
failed = 处理失败的视频数。已有字幕直接跳过的视频不参与 total/done 被暂停或进程被终止)。任务不存在时静默返回
引擎在暂停、视频间检查、收尾等边界调用,router 在读取前也调用,保证
前端看到的进度始终与明细一致——即使任务被暂停或进程被终止(回归
batch_fee668175444:整批 431 个含 383 个已有字幕,应显示 15/48 而非
0/431)。任务不存在时静默返回。
""" """
with self._connect() as conn: with self._connect() as conn:
counts = conn.execute( counts = conn.execute(
+10 -19
View File
@@ -37,10 +37,9 @@ def _product_finals(video: dict) -> dict[str, str]:
"""列出该视频可下载的最终产物(键为下载 alias,值为文件名)。 """列出该视频可下载的最终产物(键为下载 alias,值为文件名)。
来源合并两处: 来源合并两处:
- 旧版完成标记 `batch.done.json`(位于 work_dir/同名文件夹),键为语义 - 历史完成标记 `batch.done.json` 里的语义别名(兼容旧任务);
别名(如 cn_srt/ass),用于兼容旧版批量任务; - 视频所在目录中**文件名含视频名**的字幕文件(当前产物即放视频旁),
- 视频所在目录(视频旁)中**文件名含视频名**的字幕文件,键即文件名。 键与值都是文件名。
新版处理完成后产物放到视频旁,靠旁挂字幕文件即可列出与下载。
""" """
finals: dict[str, str] = {} finals: dict[str, str] = {}
marker = batch_engine.load_marker(Path(video["work_dir"])) marker = batch_engine.load_marker(Path(video["work_dir"]))
@@ -52,7 +51,7 @@ def _product_finals(video: dict) -> dict[str, str]:
def _enrich_videos(db: Database, videos: list[dict]) -> list[dict]: def _enrich_videos(db: Database, videos: list[dict]) -> list[dict]:
"""为每个视频补充最终产物清单(视频旁字幕文件 + 旧版完成标记)。 """为每个视频补充最终产物清单(视频旁字幕 + 历史完成标记)。
finals 形如 {alias: 文件名},前端据此渲染下载链接;未完成的视频没有产物。 finals 形如 {alias: 文件名},前端据此渲染下载链接;未完成的视频没有产物。
""" """
@@ -81,10 +80,8 @@ def create_batch_job(
def list_batch_jobs(db: Database = Depends(_get_db)) -> list[dict]: def list_batch_jobs(db: Database = Depends(_get_db)) -> list[dict]:
"""返回最近的批量任务列表(不含视频明细,明细按需单独查询)。 """返回最近的批量任务列表(不含视频明细,明细按需单独查询)。
返回前对每个任务实时对齐 total/done/failed任务被暂停或引擎不在运行时 返回前对每个任务实时对齐 total/done/failed,保证任务被暂停或引擎运行时
汇总字段也能与明细一致,前端列表的进度数字不会停留在 0 进度数字仍与明细一致(否则会一直显示 0)。
(回归 batch_fee668175444431 个含 383 个已有字幕,应显示实际处理进度
而非 0/431)。
""" """
jobs = db.list_batch_jobs() jobs = db.list_batch_jobs()
for job in jobs: for job in jobs:
@@ -163,10 +160,8 @@ def download_batch_video(
) -> FileResponse: ) -> FileResponse:
"""下载视频的最终产物:解析 alias 对应的文件后返回。 """下载视频的最终产物:解析 alias 对应的文件后返回。
alias 解析顺序: alias 解析顺序:历史完成标记里的语义别名(文件在 work_dir)→ 视频旁
1. 旧版完成标记里的语义别名(如 cn_srt/ass)→ 文件位于 work_dir 文件名含视频名的字幕文件。只有文件真实落盘才可下载。
2. 视频旁(视频所在目录)文件名含视频名的字幕文件名 → 直接返回该文件。
只有存在且文件真实落盘的产物才可下载。
""" """
video = db.get_batch_video(video_id) video = db.get_batch_video(video_id)
if video is None or video["job_id"] != job_id: if video is None or video["job_id"] != job_id:
@@ -191,12 +186,8 @@ def download_batch_video(
return FileResponse(target, filename=target.name) return FileResponse(target, filename=target.name)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 本地目录浏览(目录树选择器) # 本地目录浏览(目录树选择器):浏览器拿不到所选文件夹的绝对路径,改由本地
# # 后端提供——roots 返回可浏览根,dirs 返回直接子目录,前端懒加载成目录树。
# 浏览器出于安全限制拿不到所选文件夹的绝对路径,因此由**本地后端**提供目录
# 浏览能力:roots 返回可浏览的根(Windows 盘符 / POSIX 根 + 家目录),dirs
# 返回指定目录的直接子目录,前端据此渲染懒加载目录树,点击选择后回填路径。
# ---------------------------------------------------------------------------
@router.get("/api/batch/roots") @router.get("/api/batch/roots")
+4 -4
View File
@@ -36,8 +36,8 @@ def _validate_definition(raw: dict) -> WorkflowDefinition:
"""解析并校验 DAG 定义,非法时转换为 422 HTTP 异常。 """解析并校验 DAG 定义,非法时转换为 422 HTTP 异常。
除 `WorkflowDefinition.validate()` 的结构校验(名称/版本/节点 ID 唯一/ 除 `WorkflowDefinition.validate()` 的结构校验(名称/版本/节点 ID 唯一/
边引用存在)之外,还要求 DAG **可拓扑排序**:环形依赖虽然结构上合法 边引用存在)之外,还要求 DAG **可拓扑排序**:环形依赖结构上合法但无法
但执行时无法确定节点顺序,必须拒绝保存与发布R04 确定执行顺序,必须拒绝保存与发布。
""" """
try: try:
definition = WorkflowDefinition.from_dict(raw) definition = WorkflowDefinition.from_dict(raw)
@@ -127,8 +127,8 @@ def publish_workflow(workflow_id: str, db: Database = Depends(_get_db)) -> dict:
raise HTTPException(status_code=404, detail="workflow not found") raise HTTPException(status_code=404, detail="workflow not found")
if workflow["latest_version"] == 0: if workflow["latest_version"] == 0:
raise HTTPException(status_code=422, detail="workflow has no version") raise HTTPException(status_code=422, detail="workflow has no version")
# 发布前重新校验待发布版本:历史遗留的无效定义(例如修复前保存的环形 DAG) # 发布前重新校验:无效定义(如环形 DAG)不能进入应用中心,否则任务会在
# 不能进入用户应用中心,否则创建出来的任务会在执行期失败(R04) # 执行期失败
latest = db.get_latest_workflow_version(workflow_id) latest = db.get_latest_workflow_version(workflow_id)
if latest is None: if latest is None:
raise HTTPException(status_code=422, detail="workflow has no version") raise HTTPException(status_code=422, detail="workflow has no version")
+6 -12
View File
@@ -104,9 +104,8 @@ class WorkflowScheduler:
else: else:
time.sleep(self.interval_seconds) time.sleep(self.interval_seconds)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
# 单次轮询异常不杀死调度线程:曾因 next_queued_run/execute_run # 单次轮询异常不杀死调度线程:否则任务会永远停在 QUEUED
# 的未捕获异常导致线程退出,任务永远停留在 QUEUED 不被拾起 # 无人拾起。记录后跳过本轮,下一轮继续。
# run_011d01f19999 实际发生)。记录后跳过本轮,下一轮继续。
logger.exception("调度器轮询异常,跳过本轮") logger.exception("调度器轮询异常,跳过本轮")
time.sleep(self.interval_seconds) time.sleep(self.interval_seconds)
def _resolve_ref( def _resolve_ref(
@@ -131,10 +130,8 @@ class WorkflowScheduler:
# 任务不存在或不在可执行状态(排队/暂停)时直接返回,避免重复执行。 # 任务不存在或不在可执行状态(排队/暂停)时直接返回,避免重复执行。
if run is None or run["status"] not in ("QUEUED", "PAUSED"): if run is None or run["status"] not in ("QUEUED", "PAUSED"):
return return
# 已暂停的任务不自动续跑:直接返回保持 PAUSED,等用户显式 resume # 已暂停的任务不自动续跑:直接返回保持 PAUSED,等用户显式 resume
# resume 把状态转回 QUEUED 后才会真正执行)。修复回归——此前以 # resume 转回 QUEUED 后才执行);否则暂停会被立刻覆盖成 RUNNING。
# PAUSED 进入后立即置 RUNNING,节点循环的暂停检查永远不成立,
# 任务被复活继续执行("点击暂停反而开始任务")。
if run["status"] == "PAUSED": if run["status"] == "PAUSED":
return return
@@ -150,11 +147,8 @@ class WorkflowScheduler:
return return
# 解析并校验 DAG,随后计算拓扑执行顺序。 # 解析并校验 DAG,随后计算拓扑执行顺序。
#
# 任何预检异常(缺字段/边引用不存在/环形依赖)都必须在这里把任务标 # 任何预检异常(缺字段/边引用不存在/环形依赖)都必须在这里把任务标
# FAILED修复前这段在 try 之外,异常直接冒到 _loop 被吞掉,任务停在 # FAILED否则队首记录会一直停在 QUEUED 堵塞后续任务。
# QUEUEDnext_queued_run 每轮拾起同一条队首记录,后续任务全部堵塞
# (R04:环形 DAG 卡死队列)。历史无效版本无法删除,只能就地判失败。
try: try:
definition = WorkflowDefinition.from_dict(version["definition"]) definition = WorkflowDefinition.from_dict(version["definition"])
definition.validate() definition.validate()
@@ -311,7 +305,7 @@ class WorkflowScheduler:
""" """
source = Path(resolved) source = Path(resolved)
if not source.is_file(): if not source.is_file():
# 兼容旧版本:原文件已改名,但最终别名仍记录有效路径时直接复用。 # 兼容历史记录:原文件已改名,但最终别名仍指向有效路径时复用。
existing = self.db.get_artifact(run["id"], alias) existing = self.db.get_artifact(run["id"], alias)
if existing is not None and Path(existing["uri"]).is_file(): if existing is not None and Path(existing["uri"]).is_file():
return existing["uri"] return existing["uri"]
+1 -1
View File
@@ -130,7 +130,7 @@ def test_style_row_uses_translucent_fill_and_outline() -> None:
def test_default_margin_top_is_700() -> None: def test_default_margin_top_is_700() -> None:
"""默认顶部安全边距常量 7002026-09 调整,历史 120 已废弃)。""" """默认顶部安全边距常量 700样式单一事实来源,改动需同步文档)。"""
# 数据:模块常量。 # 数据:模块常量。
# 测试过程与验证结果 # 测试过程与验证结果
assert DEFAULT_MARGIN_TOP == 700 assert DEFAULT_MARGIN_TOP == 700
+2 -2
View File
@@ -401,8 +401,8 @@ def test_invoke_fails_when_input_missing(tmp_path: Path) -> None:
def test_rule_layer_on_real_ocr_output(tmp_path: Path) -> None: def test_rule_layer_on_real_ocr_output(tmp_path: Path) -> None:
"""真实任务 1666 条 OCR 输出:规则层产出与已确认基线一致。 """真实任务 1666 条 OCR 输出:规则层产出与已确认基线一致。
基线2026-09 人工审查确认):保留 863 条、删除 803 条,且不再有任何 基线:保留 863 条、删除 803 条,且无真实对话被误删。基线变动需同步
真实对话被误删(旧 LLM 层误删 73 条)。基线变动需同步 docs/decisions.md。 docs/decisions.md 与本文件的期望值
""" """
# 数据:真实任务 run_ac7f480a3ccb 的 OCR 输出。 # 数据:真实任务 run_ac7f480a3ccb 的 OCR 输出。
if not REAL_OCR_SRT.is_file(): if not REAL_OCR_SRT.is_file():
+3 -3
View File
@@ -450,7 +450,7 @@ def test_invoke_keeps_moan_when_filter_disabled(tmp_path: Path, monkeypatch) ->
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 已废弃的模型目录(不得用于测试):V3 已全面停用,全部改用 V2 # 已废弃的模型目录:全系统只用 V2,V3 权重不得进入测试
_DEPRECATED_MODEL_DIRS = ("faster-whisper-large-v3",) _DEPRECATED_MODEL_DIRS = ("faster-whisper-large-v3",)
@@ -460,8 +460,8 @@ def _v2_model_candidates() -> list[Path]:
解析顺序: 解析顺序:
1. `nodes.whisper` 文档化的默认候选(通用 V2 转写模型); 1. `nodes.whisper` 文档化的默认候选(通用 V2 转写模型);
2. `model/` 下其它已下载的 V2 权重(例如中文直出模型)。 2. `model/` 下其它已下载的 V2 权重(例如中文直出模型)。
V3 权重已全面停用(用户 2026-09 决定:均改用 V2),即使留在盘上也不得 V3 权重已全面停用(全系统只用 V2),即使留在盘上也不得被测试使用,
被测试使用,否则测的不是线上实际运行的模型。 否则测的不是线上实际运行的模型。
""" """
candidates = [p for p in _local_model_candidates() if p.name not in _DEPRECATED_MODEL_DIRS] candidates = [p for p in _local_model_candidates() if p.name not in _DEPRECATED_MODEL_DIRS]
model_root = Path(__file__).resolve().parents[3] / "model" model_root = Path(__file__).resolve().parents[3] / "model"