From 7a7212f70c896f9d2cbcb15c14ed6465c4af7a74 Mon Sep 17 00:00:00 2001 From: catShark <1716967236@qq.com> Date: Sun, 13 Sep 2026 16:37:49 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B3=A8=E9=87=8A=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E8=A6=81=E6=B1=82=E7=B2=BE=E7=AE=80=E5=8F=AF=E8=AF=BB=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=B8=85=E7=90=86=E7=94=9F=E4=BA=A7=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E5=8E=86=E5=8F=B2=E5=8F=99=E4=BA=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- AGENTS.md | 39 +++++++++++-- nodes/ass.py | 35 +++++------- nodes/frame_extract.py | 10 ++-- nodes/llm.py | 25 +++------ nodes/llm_filter.py | 61 +++++++++------------ nodes/subtitle_cleanup.py | 58 +++++++------------- nodes/subtitle_ocr.py | 9 ++- nodes/vad_profiler.py | 8 +-- nodes/vlm.py | 14 ++--- nodes/whisper.py | 44 ++++++--------- src/wov_app/batch.py | 51 ++++++----------- src/wov_app/db.py | 33 ++++------- src/wov_app/routers/batch.py | 29 ++++------ src/wov_app/routers/workflows.py | 8 +-- src/wov_app/scheduler.py | 18 ++---- tests/nodes/test_ass/test_ass.py | 2 +- tests/nodes/test_llm_filter/test_filter.py | 4 +- tests/nodes/test_whisper/test_transcribe.py | 6 +- 18 files changed, 192 insertions(+), 262 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5db12af..ca43ac6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 规范不支持 注释,字段语义以 `src/wov_sdk/models.py` 的模型注释和 [docs/](./docs/) 文档为准; 修改 JSON 字段时须同步更新文档。 diff --git a/nodes/ass.py b/nodes/ass.py index 842f42a..abb3e7d 100755 --- a/nodes/ass.py +++ b/nodes/ass.py @@ -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 控制字幕距画面顶部的安全边距(默认 700,2026-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)}) diff --git a/nodes/frame_extract.py b/nodes/frame_extract.py index 3e5a942..40cfd3a 100644 --- a/nodes/frame_extract.py +++ b/nodes/frame_extract.py @@ -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,取下划线后的数字部分。 diff --git a/nodes/llm.py b/nodes/llm.py index 9568f51..75c5df4 100755 --- a/nodes/llm.py +++ b/nodes/llm.py @@ -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 diff --git a/nodes/llm_filter.py b/nodes/llm_filter.py index c4821f3..fc9dbd4 100644 --- a/nodes/llm_filter.py +++ b/nodes/llm_filter.py @@ -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_ac7f480a3ccb(2026-08,OCR 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, diff --git a/nodes/subtitle_cleanup.py b/nodes/subtitle_cleanup.py index aef424e..894691d 100644 --- a/nodes/subtitle_cleanup.py +++ b/nodes/subtitle_cleanup.py @@ -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 -翻译后)整条删除**,时间轴随之消失,字幕序号重新连续编号。 +ASR(whisper 无 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 diff --git a/nodes/subtitle_ocr.py b/nodes/subtitle_ocr.py index f22a06a..c6e9b31 100644 --- a/nodes/subtitle_ocr.py +++ b/nodes/subtitle_ocr.py @@ -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-ocr:10s 窗口内平均响应 < 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, diff --git a/nodes/vad_profiler.py b/nodes/vad_profiler.py index a81de4b..0201fab 100644 --- a/nodes/vad_profiler.py +++ b/nodes/vad_profiler.py @@ -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): diff --git a/nodes/vlm.py b/nodes/vlm.py index f8cd75d..c574085 100644 --- a/nodes/vlm.py +++ b/nodes/vlm.py @@ -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 diff --git a/nodes/whisper.py b/nodes/whisper.py index 6ed6e5a..bc61281 100755 --- a/nodes/whisper.py +++ b/nodes/whisper.py @@ -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_filter 与 vad_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)), diff --git a/src/wov_app/batch.py b/src/wov_app/batch.py index 64f8091..0b792ff 100644 --- a/src/wov_app/batch.py +++ b/src/wov_app/batch.py @@ -4,7 +4,7 @@ 全部视频,逐个调用现有的工作流流水线(复用 WorkflowScheduler 的 DAG 执行与 断点续跑逻辑)。 -处理约定(2026-09 起): +处理约定: - **创建任务时一次性定位**:`create_job` 扫描文件夹并把每个视频登记为 batch_videos 明细;视频所在目录(视频旁)若已存在**文件名包含视频名**的 @@ -67,9 +67,8 @@ SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt"} # 暂停信号文件名:与节点约定一致,位于 run 根目录(/runs//)。 PAUSE_FLAG = "paused.flag" -# 旧版批量完成标记文件名(位于视频同名文件夹根目录)。新逻辑不再写入该标记 -# (产物直接放视频旁、靠旁挂字幕文件识别完成);仍保留读取能力,用于兼容 -# 旧版任务在详情/下载接口中展示产物。 +# 兼容读取的历史完成标记文件名(旧任务用它记录产物路径)。当前逻辑不再 +# 写入,产物直接放视频旁;保留读取能力以便旧任务的详情/下载仍可用。 MARKER_NAME = "batch.done.json" # 批量处理私有工作空间根目录:位于应用存储目录下(data/storage/batch)。 @@ -154,7 +153,7 @@ def _sidecar_product_name(video: Path, source: Path) -> str: def load_marker(work_dir: Path) -> dict | None: - """读取同名文件夹里的旧版完成标记;不存在或损坏时返回 None。""" + """读取历史完成标记;不存在或损坏时返回 None。""" path = work_dir / MARKER_NAME if not path.is_file(): return None @@ -351,23 +350,20 @@ class BatchWorker: definition.validate() items = self.db.list_batch_videos(job_id) - # total 在创建任务时已固定为"无字幕需处理的视频数",这里不覆盖; - # 老任务(历史口径 total=全部视频数)由 sync_batch_job_progress 在读取 - # 时自我修正为不含 SKIPPED 的口径。 + # total 创建时已固定为"无字幕需处理的视频数",此处不覆盖;历史任务 + # 的旧口径由 sync_batch_job_progress 在读取时修正为不含 SKIPPED。 self.db.update_batch_job( job_id, status="RUNNING", progress=0, current_video=None, error=None, updated_at=_now_iso(), ) - # total 用于进度条分母;无字幕项为 0 时表示整批跳过(创建即 COMPLETED, - # 正常不会进入本循环)。 + # total 为进度条分母;为 0 表示整批跳过(创建即 COMPLETED)。 total = int(job["total"] or 0) for item in items: # 暂停检查:批量任务被暂停后停止处理后续视频,等待用户继续。 current = self.db.get_batch_job(job_id) if current is None or current["status"] == "PAUSED": - # 停下前把已完成/失败的视频实时入账,让暂停中的前端也能看到 - # 真实进度(回归 batch_fee668175444)。 + # 停下前先把已完成/失败项入账,让暂停中的前端看到真实进度。 self.db.sync_batch_job_progress(job_id) logger.info("批量任务 %s 已暂停,停止在视频 %s", job_id, item["video_path"]) return @@ -407,17 +403,9 @@ class BatchWorker: self.db.update_batch_job(job_id, status="PAUSED", updated_at=_now_iso()) return - # 全部视频处理完成:先用明细实时对齐汇总(done 只计实际完成的, - # 不含 SKIPPED),再置 COMPLETED。 - # - # 置 COMPLETED 前必须校验**没有未处理完的视频残留**:若本轮循环因 - # 视频处理中断/异常(_process_video 返回但视频仍 PENDING,等同进程 - # 在处理中被杀)而没有真正处理完所有 PENDING,就**不能**标完成—— - # 否则会出现"明细还有 N 个待处理、任务却已完成"的僵尸状态 - # (batch_969fabe74b83 等 3 个任务真实发生:引擎串行处理到 9.9GB - # 大视频时中断,10 个视频留 PENDING 却被无条件置 COMPLETED)。 - # 此时保持 RUNNING,让引擎下一轮(重启后重新拾起 RUNNING 任务) - # 继续处理剩余 PENDING,全部结束才真正置 COMPLETED。 + # 先按明细实时对齐汇总(done 不计 SKIPPED),再判断能否收尾。 + # 仍有未结束视频时不能标 COMPLETED,否则会出现“还有待处理视频却已完成” + # 的僵尸状态;此时保持 RUNNING,由引擎下一轮续跑。 self.db.sync_batch_job_progress(job_id) job = self.db.get_batch_job(job_id) if job is None: @@ -427,8 +415,7 @@ class BatchWorker: if v["status"] not in ("SKIPPED", "COMPLETED", "FAILED") ] if leftovers: - # 有未处理完的视频(PENDING/PAUSED/QUEUED 等):保持 RUNNING, - # 由引擎下一轮续跑;记录日志便于排查中断位置。 + # 有未处理完的视频:保持 RUNNING,由引擎下一轮续跑。 logger.warning( "批量任务 %s 仍有 %d 个视频未处理完(%s…),保持 RUNNING 待续跑,不置 COMPLETED", job_id, len(leftovers), Path(leftovers[0]["video_path"]).name, @@ -464,11 +451,11 @@ class BatchWorker: work_dir.mkdir(parents=True, exist_ok=True) run_id = item.get("run_id") if run_id is not None and self.db.get_run(run_id) is None: - # run 记录已不存在(此前收尾异常删除了 run 但状态未同步):重新新建。 + # run 记录已不存在(收尾异常删除了 run 但状态未同步):重新新建。 run_id = None if run_id is None: - # 首次处理:创建 source=batch 的运行,input_uri 直接指向本地视频 - # (不再上传副本),调度器按工作流 DAG 动态组装节点执行。 + # 首次处理:创建 source=batch 的运行,input_uri 指向本地视频 + # (不上传副本),由调度器按 DAG 执行。 run_id = f"run_{uuid.uuid4().hex[:12]}" now = _now_iso() 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()) run = self.db.get_run(run_id) - # 已完成(例如上次收尾前中断):直接放置产物并清理后返回。 + # 已完成(收尾前中断):直接补做收尾。 if run["status"] == "COMPLETED": self._finalize_video(item, run_id, video, work_dir, definition) return @@ -494,10 +481,8 @@ class BatchWorker: if run["status"] == "PAUSED": self.db.resume_run(run_id, _now_iso()) elif run["status"] == "FAILED": - # 失败重跑:**保留**已完成节点的产物记录,只恢复 QUEUED—— - # execute_run 从产物表重建已完成节点并跳过,只重跑失败节点。 - # 不再 reset_run 清空产物:extract/ocr 等长耗时节点的成果会被 - # 白白丢弃重做(run_e2b74e89e232 实测 22222 帧 OCR)。 + # 失败重跑:保留产物记录只置 QUEUED,由 execute_run 跳过已完成 + # 节点、仅重跑失败节点,避免浪费抽帧/OCR 等长耗时成果。 self.db.update_run(run_id, status="QUEUED", error=None, updated_at=_now_iso()) elif run["status"] == "RUNNING": # 上次进程被杀残留:恢复 QUEUED(保留产物)由 execute_run 续跑。 diff --git a/src/wov_app/db.py b/src/wov_app/db.py index 197b5b4..ea7421c 100644 --- a/src/wov_app/db.py +++ b/src/wov_app/db.py @@ -353,11 +353,10 @@ class Database: def next_queued_run(self) -> dict[str, Any] | None: """按创建时间返回最早一条排队(QUEUED)任务。 - 只取 QUEUED:PAUSED 任务必须由用户显式 resume(转回 QUEUED)后调度器 - 才重新执行。修复回归——此前把 PAUSED 也当可执行任务拾起,execute_run - 会先置 RUNNING 再检查暂停,导致"点击暂停反而开始任务"。 - 同时排除 source=batch 的批量运行:批量任务由批量引擎使用视频旁的 - 同名文件夹作为 storage 执行,主调度器拾起会用错存储目录。 + 只取 QUEUED:PAUSED 必须由用户显式 resume 后才执行,否则暂停会被 + execute_run 立刻覆盖成 RUNNING。 + 排除 source=batch:批量运行由批量引擎在私有工作空间执行,主调度器 + 拾起会用错存储目录。 """ with self._connect() as conn: row = conn.execute( @@ -388,13 +387,10 @@ class Database: def recover_interrupted_batch_jobs(self, updated_at: str) -> int: """重启恢复:把遗留 RUNNING 的批量任务恢复为 QUEUED,返回恢复数量。 - 批量引擎处理视频(尤其大文件)时进程被杀/重启,批量任务会停在 - RUNNING:其关联 run 由 recover_interrupted_runs 恢复为 QUEUED,但 - 批量任务本身若保持 RUNNING,next_queued_batch_job 只拾取 QUEUED, - 永远不会重新驱动它 → 未处理完的 PENDING 视频永久残留 - (batch_969fabe74b83 事故链路之一)。恢复为 QUEUED 后引擎重新拾起, - 从断点(剩余 PENDING 视频 + 已恢复的 run)继续处理。用户主动暂停的 - PAUSED 批量任务保持不变,等待显式 resume。 + 批量任务若停在 RUNNING,next_queued_batch_job 只拾取 QUEUED, + 永远不会重新驱动它,未处理完的 PENDING 视频会永久残留;恢复为 + QUEUED 后引擎从断点(剩余视频 + 已恢复的 run)继续。用户主动暂停的 + PAUSED 保持不变。 """ with self._connect() as conn: cur = conn.execute( @@ -579,15 +575,10 @@ class Database: def sync_batch_job_progress(self, job_id: str) -> None: """按视频明细实时对齐任务的 total/done/failed 汇总并落库。 - 统计口径(2026-09 用户确认):total = 本批**无字幕、需要处理**的视频数 - (= 明细里非 SKIPPED 的数量,创建时已固定,运行中 SKIPPED 不会变化); - done = 实际**处理完成**的视频数(仅 COMPLETED,SKIPPED 不计); - failed = 处理失败的视频数。已有字幕直接跳过的视频不参与 total/done。 - - 引擎在暂停、视频间检查、收尾等边界调用,router 在读取前也调用,保证 - 前端看到的进度始终与明细一致——即使任务被暂停或进程被终止(回归 - batch_fee668175444:整批 431 个含 383 个已有字幕,应显示 15/48 而非 - 0/431)。任务不存在时静默返回。 + 口径:total = 需处理的视频数(非 SKIPPED,创建时固定);done 只计 + COMPLETED(SKIPPED 不计);failed 为失败数。引擎在暂停、视频间与 + 收尾边界调用,router 读取前也调用,保证前端进度与明细一致(即使任务 + 被暂停或进程被终止)。任务不存在时静默返回。 """ with self._connect() as conn: counts = conn.execute( diff --git a/src/wov_app/routers/batch.py b/src/wov_app/routers/batch.py index dace4a3..38fe3aa 100644 --- a/src/wov_app/routers/batch.py +++ b/src/wov_app/routers/batch.py @@ -37,10 +37,9 @@ def _product_finals(video: dict) -> dict[str, str]: """列出该视频可下载的最终产物(键为下载 alias,值为文件名)。 来源合并两处: - - 旧版完成标记 `batch.done.json`(位于 work_dir/同名文件夹),键为语义 - 别名(如 cn_srt/ass),用于兼容旧版批量任务; - - 视频所在目录(视频旁)中**文件名含视频名**的字幕文件,键即文件名。 - 新版处理完成后产物放到视频旁,靠旁挂字幕文件即可列出与下载。 + - 历史完成标记 `batch.done.json` 里的语义别名(兼容旧任务); + - 视频所在目录中**文件名含视频名**的字幕文件(当前产物即放视频旁), + 键与值都是文件名。 """ finals: dict[str, str] = {} 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]: - """为每个视频补充最终产物清单(视频旁的字幕文件 + 旧版完成标记)。 + """为每个视频补充最终产物清单(视频旁字幕 + 历史完成标记)。 finals 形如 {alias: 文件名},前端据此渲染下载链接;未完成的视频没有产物。 """ @@ -81,10 +80,8 @@ def create_batch_job( def list_batch_jobs(db: Database = Depends(_get_db)) -> list[dict]: """返回最近的批量任务列表(不含视频明细,明细按需单独查询)。 - 返回前对每个任务实时对齐 total/done/failed:任务被暂停或引擎不在运行时, - 汇总字段也能与明细一致,前端列表的进度数字不会停留在 0 - (回归 batch_fee668175444:431 个含 383 个已有字幕,应显示实际处理进度 - 而非 0/431)。 + 返回前对每个任务实时对齐 total/done/failed,保证任务被暂停或引擎未运行时 + 进度数字仍与明细一致(否则会一直显示 0)。 """ jobs = db.list_batch_jobs() for job in jobs: @@ -163,10 +160,8 @@ def download_batch_video( ) -> FileResponse: """下载视频的最终产物:解析 alias 对应的文件后返回。 - alias 解析顺序: - 1. 旧版完成标记里的语义别名(如 cn_srt/ass)→ 文件位于 work_dir; - 2. 视频旁(视频所在目录)文件名含视频名的字幕文件名 → 直接返回该文件。 - 只有存在且文件真实落盘的产物才可下载。 + alias 解析顺序:历史完成标记里的语义别名(文件在 work_dir)→ 视频旁 + 文件名含视频名的字幕文件。只有文件真实落盘才可下载。 """ video = db.get_batch_video(video_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) # --------------------------------------------------------------------------- -# 本地目录浏览(目录树选择器) -# -# 浏览器出于安全限制拿不到所选文件夹的绝对路径,因此由**本地后端**提供目录 -# 浏览能力:roots 返回可浏览的根(Windows 盘符 / POSIX 根 + 家目录),dirs -# 返回指定目录的直接子目录,前端据此渲染懒加载目录树,点击选择后回填路径。 -# --------------------------------------------------------------------------- +# 本地目录浏览(目录树选择器):浏览器拿不到所选文件夹的绝对路径,改由本地 +# 后端提供——roots 返回可浏览根,dirs 返回直接子目录,前端懒加载成目录树。 @router.get("/api/batch/roots") diff --git a/src/wov_app/routers/workflows.py b/src/wov_app/routers/workflows.py index 40b609d..cb281e1 100755 --- a/src/wov_app/routers/workflows.py +++ b/src/wov_app/routers/workflows.py @@ -36,8 +36,8 @@ def _validate_definition(raw: dict) -> WorkflowDefinition: """解析并校验 DAG 定义,非法时转换为 422 HTTP 异常。 除 `WorkflowDefinition.validate()` 的结构校验(名称/版本/节点 ID 唯一/ - 边引用存在)之外,还要求 DAG **可拓扑排序**:环形依赖虽然结构上合法, - 但执行时无法确定节点顺序,必须拒绝保存与发布(R04)。 + 边引用存在)之外,还要求 DAG **可拓扑排序**:环形依赖结构上合法但无法 + 确定执行顺序,必须拒绝保存与发布。 """ try: 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") if workflow["latest_version"] == 0: raise HTTPException(status_code=422, detail="workflow has no version") - # 发布前重新校验待发布版本:历史遗留的无效定义(例如修复前保存的环形 DAG) - # 不能进入用户应用中心,否则创建出来的任务会在执行期失败(R04)。 + # 发布前重新校验:无效定义(如环形 DAG)不能进入应用中心,否则任务会在 + # 执行期失败。 latest = db.get_latest_workflow_version(workflow_id) if latest is None: raise HTTPException(status_code=422, detail="workflow has no version") diff --git a/src/wov_app/scheduler.py b/src/wov_app/scheduler.py index 975ec73..9c98f07 100755 --- a/src/wov_app/scheduler.py +++ b/src/wov_app/scheduler.py @@ -104,9 +104,8 @@ class WorkflowScheduler: else: time.sleep(self.interval_seconds) except Exception: # noqa: BLE001 - # 单次轮询异常不杀死调度线程:曾因 next_queued_run/execute_run - # 的未捕获异常导致线程退出,任务永远停留在 QUEUED 不被拾起 - # (run_011d01f19999 实际发生)。记录后跳过本轮,下一轮继续。 + # 单次轮询异常不能杀死调度线程:否则任务会永远停在 QUEUED + # 无人拾起。记录后跳过本轮,下一轮继续。 logger.exception("调度器轮询异常,跳过本轮") time.sleep(self.interval_seconds) def _resolve_ref( @@ -131,10 +130,8 @@ class WorkflowScheduler: # 任务不存在或不在可执行状态(排队/暂停)时直接返回,避免重复执行。 if run is None or run["status"] not in ("QUEUED", "PAUSED"): return - # 已暂停的任务不自动续跑:直接返回保持 PAUSED,等待用户显式 resume - # (resume 把状态转回 QUEUED 后才会真正执行)。修复回归——此前以 - # PAUSED 进入后立即置 RUNNING,节点循环的暂停检查永远不成立, - # 任务被复活继续执行("点击暂停反而开始任务")。 + # 已暂停的任务不自动续跑:直接返回保持 PAUSED,等用户显式 resume + # (resume 转回 QUEUED 后才执行);否则暂停会被立刻覆盖成 RUNNING。 if run["status"] == "PAUSED": return @@ -150,11 +147,8 @@ class WorkflowScheduler: return # 解析并校验 DAG,随后计算拓扑执行顺序。 - # # 任何预检异常(缺字段/边引用不存在/环形依赖)都必须在这里把任务标 - # FAILED:修复前这段在 try 之外,异常直接冒到 _loop 被吞掉,任务停在 - # QUEUED,next_queued_run 每轮拾起同一条队首记录,后续任务全部堵塞 - # (R04:环形 DAG 卡死队列)。历史无效版本无法删除,只能就地判失败。 + # FAILED:否则队首记录会一直停在 QUEUED 堵塞后续任务。 try: definition = WorkflowDefinition.from_dict(version["definition"]) definition.validate() @@ -311,7 +305,7 @@ class WorkflowScheduler: """ source = Path(resolved) if not source.is_file(): - # 兼容旧版本:原文件已改名,但最终别名仍记录有效路径时直接复用。 + # 兼容历史记录:原文件已改名,但最终别名仍指向有效路径时复用。 existing = self.db.get_artifact(run["id"], alias) if existing is not None and Path(existing["uri"]).is_file(): return existing["uri"] diff --git a/tests/nodes/test_ass/test_ass.py b/tests/nodes/test_ass/test_ass.py index 44d445d..905cdf8 100644 --- a/tests/nodes/test_ass/test_ass.py +++ b/tests/nodes/test_ass/test_ass.py @@ -130,7 +130,7 @@ def test_style_row_uses_translucent_fill_and_outline() -> None: def test_default_margin_top_is_700() -> None: - """默认顶部安全边距常量是 700(2026-09 调整,历史 120 已废弃)。""" + """默认顶部安全边距常量为 700(样式单一事实来源,改动需同步文档)。""" # 数据:模块常量。 # 测试过程与验证结果 assert DEFAULT_MARGIN_TOP == 700 diff --git a/tests/nodes/test_llm_filter/test_filter.py b/tests/nodes/test_llm_filter/test_filter.py index 7d40056..6f42c2a 100644 --- a/tests/nodes/test_llm_filter/test_filter.py +++ b/tests/nodes/test_llm_filter/test_filter.py @@ -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: """真实任务 1666 条 OCR 输出:规则层产出与已确认基线一致。 - 基线(2026-09 人工审查确认):保留 863 条、删除 803 条,且不再有任何 - 真实对话被误删(旧 LLM 层误删 73 条)。基线变动需同步 docs/decisions.md。 + 基线:保留 863 条、删除 803 条,且无真实对话被误删。基线变动需同步 + docs/decisions.md 与本文件的期望值。 """ # 数据:真实任务 run_ac7f480a3ccb 的 OCR 输出。 if not REAL_OCR_SRT.is_file(): diff --git a/tests/nodes/test_whisper/test_transcribe.py b/tests/nodes/test_whisper/test_transcribe.py index 5c6162b..a1efbed 100644 --- a/tests/nodes/test_whisper/test_transcribe.py +++ b/tests/nodes/test_whisper/test_transcribe.py @@ -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",) @@ -460,8 +460,8 @@ def _v2_model_candidates() -> list[Path]: 解析顺序: 1. `nodes.whisper` 文档化的默认候选(通用 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] model_root = Path(__file__).resolve().parents[3] / "model"