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
+13 -22
View File
@@ -11,24 +11,16 @@ from pathlib import Path
from wov_sdk.models import InvokeRequest, InvokeResponse
# ---------------------------------------------------------------------------
# 统一 ASS 样式常量(单一事实来源)
# ---------------------------------------------------------------------------
# 说明:新生成字幕(write_ass/invoke)与"历史字幕统一脚本"
# scripts/unify_ass_style.py)共用下面这套样式定义——要调整字幕样式
# (位置/透明度/描边等)只改这里,两条输出路径保持一致,不会各自漂移。
#
# 2026-09 调整:默认顶部安全边距 DEFAULT_MARGIN_TOP 由 120 改为 700。
# 旧值 120 顶部对齐时字幕贴近画面最顶端,VR 头盔里需抬头才看得到;
# 700 为实测合适值,字幕落在更接近视线自然平视的高度。
# 统一 ASS 样式常量(单一事实来源):新生成字幕(write_ass/invoke)与历史
# 字幕统一脚本(scripts/unify_ass_style.py)共用,调整样式只改这里,两条输出
# 路径不会各自漂移。
DEFAULT_MARGIN_TOP = 700
# 左右眼样式行字段(列顺序与 ASS Style Format 一一对应):
# - PrimaryColour &HB3FFFFFF:约 70% 透明文字填充,弱化对画面的遮挡;
# - OutlineColour &H80000000:半透明黑描边(取代早期实心纯黑),保留可读性
# 又不产生生硬黑框;
# - Alignment 8\an8 顶部居中):配合 MarginV 形成顶部安全区——避开画面
# 中央人脸高发区,同时落在视线自然高度。
# - PrimaryColour &HB3FFFFFF:约 70% 透明文字填充,降低对画面的遮挡;
# - OutlineColour &H80000000:半透明黑描边,兼顾可读性与不产生生硬黑框;
# - Alignment 8\an8 顶部居中):配合 MarginV 形成顶部安全区,避开画面中央
# 人脸区,并落在视线自然高度。
_ASS_FONT = "Arial"
_ASS_FONT_SIZE = 50
_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:
"""生成单眼(LeftEye/RightEye)的完整 ASS 样式行。
左眼占左半幅(左缘留 _EYE_PAD 内边距、向中线收 50%),右眼占右半幅
两眼水平相对位置一致 → 零视差A-1):字幕渲染在屏幕平面,不产生
额外景深冲突。margin_top 即该眼样式的 MarginV(距画面上缘的安全边距)。"""
左眼占左半幅(左缘留 _EYE_PAD 内边距、向中线收 50%),右眼占右半幅
两眼水平相对位置一致 → 零视差,字幕落在屏幕平面,不引入额外景深冲突。
margin_top 即该眼样式的 MarginV(距画面上缘的安全边距)。"""
mid = width // 2
margin_l, margin_r = (_EYE_PAD, mid) if eye == "LeftEye" else (mid, _EYE_PAD)
return (
@@ -149,8 +141,8 @@ def write_ass(
) -> None:
"""把解析后的条目写入 ASS 文件,每个条目输出左右眼两行 Dialogue。
margin_top 控制字幕距画面顶部的安全边距(默认 7002026-09 起),顶部对齐(\an8
使字幕整体落在顶部安全区下方。左右眼使用相同文本与水平相对位置(零视差A-1)。"""
margin_top 字幕距画面顶部的安全边距,配合顶部对齐(\an8让字幕落在
顶部安全区。左右眼使用相同文本与水平相对位置(零视差)。"""
width, height = (int(part) for part in resolution.lower().split("x", 1))
header = ass_header(width, height, margin_top=margin_top)
# an8 对齐到屏幕顶部,配合 MarginV 形成顶部安全区,避开中央人脸区域。
@@ -176,8 +168,7 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
output_path = output_dir / "dual_eye.ass"
# 分辨率默认 3840x1920,覆盖常见 VR 视频尺寸。
resolution = str(request.params.get("resolution", "3840x1920"))
# margin_top 可选:顶部安全边距(默认 700,2026-09 起),不同分辨率/内容
# 仍可用工作流参数微调(历史 120 已过时,勿再使用)。
# margin_top 可选:不同分辨率/内容可用工作流参数微调顶部安全边距。
margin_top = int(request.params.get("margin_top", 700))
write_ass(entries, output_path, resolution, margin_top=margin_top)
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]:
"""按文件名中的帧号数值排序返回帧文件列表(自然排序,非字典序)。
关键点:ffmpeg 的 %04d 编号超过 9999 帧后会自动扩为 5 位
frame_10000.png 等),此时 sorted() 默认的字典序会把 5 位编号排在
4 位编号之前(如 "frame_10009" < "frame_1009"),导致帧号回退、
manifest 时间与图像错位(曾真实发生于 run_339ec7ee437f 的 14236 帧
任务,全片后半段时间轴全部错乱)。必须解析出帧号按数值排序,
才能保证"第 k 个文件 = 第 k 个选中帧 = 时间 index*step/fps"成立。
ffmpeg 的 %04d 编号超过 9999 帧后会扩为 5 位,字典序会把 5 位编号排在
4 位之前("frame_10009" < "frame_1009"),导致帧号回退、帧时间与图像
错位。必须解析帧号按数值排序,才能保证“第 k 个文件 = 第 k 个选中帧 =
时间 index*step/fps”成立。
"""
def frame_number(path: Path) -> int:
# 文件名形如 frame_0001.png,取下划线后的数字部分。
+9 -16
View File
@@ -1,20 +1,13 @@
"""LLM 翻译节点。
"""LLM 翻译节点:SRT → 纯文本分批翻译 → 回填时间轴
单体版中作为进程内节点模块,由调度器直接调用。接收 SRT,提取纯文本行
分批调用 LLM,再把译文回填到原 SRT 结构并输出 cn.srt。
关键修复(见 tests/test_translation_line_alignment.py):
1. **提示词强化**:要求"逐行独立翻译 + 碎片句按语境独立成行 + 禁止合并/拆分"
从源头减少 LLM 因语义碎片而重排断句、导致行数不一致。
2. **ID 对齐(审查 R05)**:历史按行数合并/补空不能定位中间缺失,曾造成
run_51242078d76e 译文贴错时间。改为 JSON id/text 条目逐项校验,缺失、
重复、未知 ID 或坏结构重试整批,耗尽即失败;时间戳留在本地按 cue 回填。
3. **system_prompt 拼接 bug**:圆括号内一旦出现 f-string 赋值(表达式),
隐式字符串拼接失效,整体变成 tuple;json 序列化后发出去的 content 是数组,
API 返回 400 invalid parameter。必须用 + 显式拼接为单个字符串。
要点:
- **ID 对齐**:以 JSON `{id, text}` 条目请求翻译,逐项校验 ID 集合、类型与
正文;乱序按 ID 回填,缺失/重复/坏结构重试整批。时间戳不进入模型,只在
本地按 cue 回填,避免模型重排断句时译文贴错时间轴。
- **提示词**:要求逐行独立翻译、碎片句按语境独立成行、禁止合并或拆分。
- **严格错误处理**:结构重试耗尽立即失败,不用补空或合并掩盖对应关系丢失。
- 拼接 system_prompt 时用 `+` 显式连成单个字符串:括号内的隐式字符串拼接
遇到 f-string 表达式会失效,生成 tuple 后序列化成数组,API 会返回 400。
"""
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/邮箱、
单双 ASCII 字符等 OCR 噪声直接删除——这些模式稳定可判的,走规则
既省 token 又保证结果确定(真实数据中占删除量的 65%+)。
1. **确定性规则层**(不调 LLM):横线装饰、HTML/水印 token、URL/邮箱、
单双 ASCII 字符等 OCR 噪声直接删除模式稳定可判,省 token 且结果确定。
2. **LLM 五类分类层**:把目标字幕连同前后各 context_size 条纯文本分批
提供给 LLM模型输出五个类别之一:
- garbage:垃圾字符(乱码残缺装饰性符号)→ 删除
- overlay:水印/网页/播放器等覆盖层文本 → 删除
- noise:与上下文无关、无实际语义的杂项 → 删除
- repeat:内容性重复(语气词、呻吟、重复感叹,属于内容本身)→ 保留
- dialogue:正常对话 → 保留
未识别输出一律回退 dialogue(宁滥勿缺,避免误删真实对话)。
2. **LLM 五类分类层**use_llm=1 时启用):把目标字幕连同前后各
context_size 条纯文本交给 LLM,输出五个类别之一:
- garbage(乱码/残缺/装饰)、overlay(水印/播放器覆盖层)、
noise(无实义杂项)→ 删除
- repeat(内容性重复,如语气词/呻吟)、dialogue(正常对话)→ 保留。
未识别输出一律回退 dialogue(宁滥勿缺)。
3. **文本去重**:相同文本(忽略全部空白与大小写差异)只调一次 LLM,
上下文取首次出现位置,结果缓存复用——修复"同一句字幕 5 留 6 删"
判定一致,同时把长视频的 LLM 调用量降到唯一文本数。
4. **上下文净化**:喂给 LLM 的上下文是**过滤后的字幕**——规则层确定性的
垃圾(横线/HTML/网址/水印等)从上下文中剔除,只留下有意义的对白,
避免覆盖层垃圾污染 LLM 的场景判断导致误删真实对话。
参考真实任务 run_ac7f480a3ccb2026-08OCR 1666 条):旧实现把 394 条
真实对话当噪声删掉(占删除 35%)、同文本判定不一致;新实现按上述机制
回归测试已固化在 tests/test_llm_filter.py。
该层的两个配套机制:
- **文本去重**:相同文本(忽略空白与大小写)只调一次 LLM,结果复用,
保证同一句话判定一致并减少调用量;
- **上下文净化**:喂给 LLM 的上下文先剔除规则层已判定的垃圾,避免覆盖层
噪声污染场景判断而误删真实对话。
"""
from __future__ import annotations
@@ -120,7 +112,7 @@ _HTML_MARK_RE = re.compile(
re.IGNORECASE,
)
# 规则层正则:播放器/作品编号水印(VLM 常把画面角落的编号识别成短串)。
# 实测真实数据命中:SPHO-1 / PHO一号馆 / NO.1专用 / PHD-手術 / SP10-1型。
# 覆盖水印编号形态,如 SPHO-1 / PHO一号馆 / NO.1专用 / PHD-手術 / SP10-1型。
# 限定为**不含汉字的编号形态**(或纯形态串),避免误伤正常英文对白。
_SERIAL_MARK_RE = re.compile(
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(
{"html", "background", "___", "cleaning", "buffering", "loading", "marketing"}
)
# 长文本保护阈值:≥ 该长度的文本,noise 类别不构成删除依据
# LLM 判定不稳定(实测把完整对话句误判 noise,长度是必要兜底而非删除依据
# 长文本保护阈值:≥ 该长度的文本,noise 类别不构成删除依据LLM 会把完整
# 对话句误判 noise,长度是兜底手段,只用于保护不用于删除)
DEFAULT_MIN_KEEP_LEN = 12
# 纯数字/小数点组合("4.0"/"2.0"/"10-1期B" 中的纯数值形态)。
_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
@@ -232,9 +224,8 @@ def _should_delete(category: str, text: str, min_keep_len: int) -> bool:
"""按 LLM 类别与长文本保护决定是否删除。
repeat/dialogue 一律保留;garbage/overlay 一律删除(明确的垃圾信号);
noise 对短文本删除,但 ≥min_keep_len 的长文本不删——LLM 判定不稳定,
完整对话句常被误判 noise,长度保护是必要兜底(实测移除后新增误删
124 条真实长对话)。长度只用于"保护",不用于"删除"
noise 对短文本删除:LLM 会把完整对话句误判为 noise,≥min_keep_len 的
长文本一律保留。长度只用于保护,不用于删除。
"""
if category not in DELETE_CATEGORIES:
return False
@@ -362,10 +353,8 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
}
# 去重开关:默认开;关掉时每个条目独立调用 LLM(不省调用,判定各自独立)。
dedupe = str(request.params.get("dedupe", "1")) not in ("0", "false", "False")
# LLM 分类层开关(2026-09 默认关闭):实测该层额外删除的 131 条中 56% 是
# 真实对话(run_ac7f480a3ccb 逐类人工审查),真正抓到而规则层抓不到的仅 58
# 条(已大部分下沉为规则)。默认只跑确定性规则层,宁多留不漏删;
# 需要旧行为时用 params.use_llm=1 显式打开。
# LLM 分类层开关:默认关闭,只跑确定性规则层(宁多留不漏删);关闭原因
# 见模块 docstring 与 docs/decisions.md。需要该层时用 params.use_llm=1。
use_llm = str(request.params.get("use_llm", "1" if DEFAULT_USE_LLM else "0")) not in (
"0", "false", "False",
)
@@ -414,7 +403,7 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
done, total, rate, avg_time, workers, pool.max_workers,
)
# 自适应并发调用 LLM:按实测负载弹性伸缩,避免压垮 LLM 接口。
# 自适应并发调用 LLM:按响应快慢弹性伸缩,避免压垮接口。
pool = AdaptiveThreadPool(
worker=judge_one,
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,
subtitles still contain "closing/greeting hallucination words" - fixed
phrases like 'wan an / gan xie guan kan / gan xie nin de guan kan'
(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
翻译后)整条删除**,时间轴随之消失,字幕序号重新连续编号。
ASRwhisper 无 VAD 解码)与 LLM 翻译在无语音段会输出固定套话(如“晚安/
感谢观看”/“おやすみなさい”),且在翻译产物里反复出现。处理方式为**连带
时间戳整条删除** cue 并重新编号:不能改成 '-' 占位,否则会一路渲染成可见
减号,并在翻译/过滤阶段需要额外特判。
两类词表:
- HALLUCINATION_TOKENS:中文(LLM 翻译产物中的寒暄,如"晚安/感谢观看");
- JAPANESE_HALLUCINATION_TOKENS:日文(whisper decode_full 无 VAD 解码在
无语音段直接输出的套话,如"おやすみなさい/ご視聴ありがとうございました")。
- HALLUCINATION_TOKENS:中文(LLM 翻译产物中的寒暄);
- 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
@@ -39,11 +29,8 @@ HALLUCINATION_TOKENS = (
# Display-duration threshold (seconds): only remove entries longer than this.
DEFAULT_THRESHOLD_SECONDS = 15.0
# 日文 ASR 直出(whisper 节点 decode_full 无 VAD 整段解码)的收尾/寒暄幻觉词。
# 无 VAD 解码会把无语音/音乐/呻吟段当语音,whisper 常在这些段重复输出套话
# (实测 savr-1054 全片 119-149s/600-630s 等出现 30s 长"おやすみなさい"
# "ご視聴ありがとうございました")。短时(≤阈值)的相同词可能是剧情里真实
# 互道晚安,须保留;仅删除展示时长 ≥ 阈值的条目(与中文表同一机制)。
# 日文套话词表:whisper 无 VAD 解码会把无语音/音乐段当语音,从而重复输出
# 这些寒暄;短时相同词可能是剧情真实台词,靠时长阈值区分(同中文表机制)。
JAPANESE_HALLUCINATION_TOKENS = (
"おやすみなさい", # 晚安
"ご視聴ありがとうございました", # 感谢观看
@@ -58,14 +45,10 @@ JAPANESE_HALLUCINATION_TOKENS = (
"Thank you for watching",
)
# 纯呻吟/喘息字符集合decode_full 救回弱语音后的去噪,2026-09 用户决策)。
#
# 背景(实测 savr-1054-2 前 600s):decode_full 无 VAD 解码会把呻吟/BGM 混叠
# 的弱语音也整段救回,但其中混有大量**纯语气词碎片**(あ…/ん?/はぁ…/あ!あ!
# /んふふ 等),这类内容放进字幕是噪声。判据:文本(去空白/标点)**全部由本
# 集合字符组成**且有效假名数 ≤ 阈值才删除。集合**刻意排除** そ/こ/ね/や/ば/だ
# /く/へ 等假名——真实短对话(そこ/やばい/ねえ/やだ/えへへ)都含这些字符,
# 含任意非集合字符的条目天然不命中,从根上避免误删真实短句。
# 纯呻吟/喘息字符集合:文本(去空白/标点)全部由本集合字符组成、且有效假名数
# ≤ 阈值时才判为噪声删除。集合**刻意排除** そ/こ/ね/や/ば/だ/く/へ 等假名——
# 真实短对话(そこ/やばい/ねえ/やだ/えへへ)都含这些字符,因此天然不命中,从
# 判据根源上避免误删真实短句。
MOAN_CHARS = frozenset(
# 平假名元音与ん/ふ/は(呻吟与喘息气流音的主干)
"あいうえおんふはっ"
@@ -144,12 +127,9 @@ def remove_short_moan_entries(
) -> str:
"""删除 SRT 中'纯呻吟/喘息碎片'的**整条 cue**whisper decode_full 去噪)。
decode_full 无 VAD 解码会把呻吟也整段救回,字幕混入大量纯语气词碎片
(あ…/ん?/はぁ…)。判据:文本全部由 MOAN_CHARS 组成且有效假名数 ≤
max_chars(默认 3)才删除(见 _is_pure_moan),真实短对话(そこ/やばい/
ねえ/やだ/えへへ/行く行く行く)天然不命中。max_chars=0 时关闭过滤(原
样返回)。仅在 whisper 节点 decode_full=true 时调用(用户 2026-09 决策,
不作用于 learn-translate 等 VAD 链路)。纯函数,不修改输入。
无 VAD 解码会把呻吟也整段救回,字幕因此混入纯语气词碎片(あ…/ん?)。
判据见 _is_pure_moan:文本全部由 MOAN_CHARS 组成且有效假名数 ≤ max_chars
才删除;max_chars=0 时关闭过滤。纯函数,不修改输入。
"""
if max_chars <= 0:
return srt_text
+4 -5
View File
@@ -56,8 +56,8 @@ def _load_partial(output_dir: Path) -> dict[int, str]:
except json.JSONDecodeError:
# 进程被杀时可能残留半行写入:跳过该行,对应帧视为未处理。
continue
# 存档区分成功空帧与确定跳过(超长输出);失败不写成功存档。
# 旧版空串可能来自网络故障,恢复时重新识别;旧版非空结果可复用。
# 存档区分"成功空帧/skipped(超长跳过)"与失败——失败不写成功存档。
# 无 status 的历史空串无法区分超时与无文字,需重新识别;非空结果可复用。
text = str(item["text"])
if item.get("status") in ("completed", "skipped") or ("status" not in item and text):
result[int(item["frame"])] = text
@@ -248,9 +248,8 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
_eta_suffix(done, total, rate),
)
# 自适应并发调用 vlm-ocr10s 窗口内平均响应 < 0.3s 则加 1 线程(上限
# pool_max_workers),> pool_slow_threshold 则减 1 线程(下限 1),
# 按实测负载弹性伸缩,避免盲目并发压垮本地 Ollama。
# 自适应并发调用 vlm-ocr窗口内平均响应快慢增/减额度(上限
# pool_max_workers、下限 1),避免盲目并发压垮本地 Ollama。
pool = AdaptiveThreadPool(
worker=ocr_frame,
on_progress=log_progress,
+4 -4
View File
@@ -1,10 +1,10 @@
"""每视频自适应 VAD 调参(信号分析 + 片段网格验证)。
背景(实测 CJOD-255:全程 BGM 覆盖的视频几乎没有纯静音,固定 VAD
参数会把音乐当语音,导致 whisper 全段解码 → 碎片化(80%)、漏句(32%)、
敏感段丢失。不同视频声学差异大,需**每视频独立分析后再定 VAD 参数**。
背景:全程 BGM 覆盖的视频几乎没有纯静音,固定 VAD 参数会把音乐当语音,
导致 whisper 全段解码、字幕碎片化并漏句。不同视频声学差异大,需**每视频
独立分析后再定 VAD 参数**。
方案(用户确认)
方案:
1. 信号分析(秒级,无模型)缩窄参数空间:1s 能量分布 → 静音比例、
BGM 底噪、语音疏密,推出 threshold/min_silence/speech_pad 候选;
2. 片段小网格验证(90s 代表片段,3~5 组参数跑 whisper):
+6 -8
View File
@@ -6,14 +6,12 @@
与 llm-translate 节点相互独立:本节点只做视觉 OCR,不做翻译,避免把两类
职责混在一起。调用协议见 Ollama 官方文档:POST /api/chat。
请求约定2026-08 调整,直接请求 API 版本)
- 使用流式传输(stream=True逐行接收生成内容,命中终止序列立即停止接收,
避免模型重复循环时无限拉取输出;响应体整体受 5 秒截止时间约束。
- 采样 temperature 默认 0.3(可参数覆盖),并携带终止序列列表
`["\n", "\n", ""]`(输出首个换行即停 + 阻止“答:”式重复循环);
模型生成遇到任一标记即停止。
- 每次调用整体超时 5 秒(timeout_seconds 参数 / VLM_TIMEOUT_SECONDS 环境变量),
超过即终止,不再继续等待后续流式块。
请求约定:
- 流式传输(stream=True)逐行接收,命中终止序列立即停止拉取,避免模型重复
循环时无限输出;整体受超时截止约束(timeout_seconds / VLM_TIMEOUT_SECONDS
默认 5 秒),超时即终止而不继续等待后续块。
- 采样 temperature 默认 0.3,并携带终止序列 `["\n", "\n", ""]`(输出
首个换行即停,同时阻止“答:”式重复循环),模型遇到任一标记即停止。
"""
from __future__ import annotations
+18 -26
View File
@@ -225,28 +225,22 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
device=device,
compute_type=compute_type,
)
# language 默认日语;vad_filter 默认开启(用户 2026-08 决定):过滤静音
# 段以提速并减少无语音处幻觉;长静音时 VAD 压缩时间轴可能轻微错位,
# 如需极致对齐可在工作流参数中显式关闭。
# 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 官方建议的长音频方案。
# 常用参数默认值见各参数的读取处;完整参数手册见
# workflows/learn-translate.json 的 params._node_help。
output_dir = Path(request.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# 分块转写:默认每 1 分钟一块(chunk_seconds=60),切块失败自动回退整段。
chunk_seconds = int(request.params.get("chunk_seconds", 60))
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))
vad_parameters = request.params.get("vad_parameters")
# 默认开 VAD:滤掉静音段提速并减少无语音处幻觉;代价是长静音下时间轴
# 会被压缩回映射,需极致对齐的素材可显式关掉。
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":
try:
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():
raise RuntimeError(f"whisper 被暂停(run {request.run_id}")
chunk_started = time.monotonic()
# decode_full=true 时 vad_filter 传 False:跳过 VAD 剔除弱语音段。
segments, _info = model.transcribe(
str(chunk),
# 源语言默认日语;中文直出模型配合 task=translate 可直接出中文。
language=str(request.params.get("language", "ja")),
task=str(request.params.get("task", "transcribe")),
# 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_parameters=None if decode_full else vad_parameters,
# 默认 False:长音频下开启会累积上下文导致重复/漂移,
# 关闭后每个 30s 窗口独立解码。
condition_on_previous_text=bool(
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,
time.monotonic() - transcribe_started,
)
# decode_full(无 VAD)副作用:无语音/音乐/呻吟段会产生长时寒暄套话幻觉
# (おやすみなさい/ご視聴ありがとうございました 等),在此**连带时间戳整条
# 剔除**(序号/时间轴/文本全删、剩余重编号),不留下 '-' 占位污染下游
# (占位会渲染进 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 关闭)。
# decode_full(无 VAD副作用清理:无语音段的长时寒暄幻觉与纯语气词
# 碎片都是噪声,整条删除(序列号重排,不留 '-' 占位污染下游);判据与
# 细节见 nodes/subtitle_cleanup.py。仅 decode_full 时启用。
if decode_full:
from nodes.subtitle_cleanup import (
clean_japanese_hallucinations,
@@ -320,6 +310,8 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
)
body = clean_japanese_hallucinations("\n".join(lines))
# short_moan_max_chars:有效假名 ≤ 该值的纯呻吟碎片整条删除,
# 设 0 关闭(真实短对话不会命中,判据见 subtitle_cleanup)。
body = remove_short_moan_entries(
body,
max_chars=int(request.params.get("short_moan_max_chars", 3)),