diff --git a/AGENTS.md b/AGENTS.md index 6e753d0..5db12af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,23 @@ -# VRSub(单体版) +# AGENTS.md — VRSub 协作规则 -本仓库是"为视频生成 VR 双眼字幕"的单体应用(WOV AI Workflow Platform 的 -单机实现):FastAPI 后端、工作流调度器与全部节点(提音 / 转写 / 翻译 / -ASS)在**同一个进程**内运行,不再启动子进程、不再走节点 HTTP 协议。 -由原分布式多仓库(wov-api / wov-web / wov-sdk / wov-node-*)合并而来, -本 AGENTS.md 汇总了各仓库的约定与规范。 +本文件只写**对 AI 编码代理的要求**(行为规则、流程约束、执行环境)。 +项目信息(架构是什么、有哪些节点、怎么配置)不写在这里,按需阅读: + +| 想了解 | 阅读 | +| --- | --- | +| 项目定位、快速开始、目录结构、文档导航 | [README.md](./README.md) | +| 架构、核心机制、北极星不变式、单体化说明 | [docs/architecture.md](./docs/architecture.md) | +| 节点输入输出协议、模型权重解析、长音频、OCR 并发 | [docs/node-protocol.md](./docs/node-protocol.md) | +| 内置工作流、参数标注约定、切换模型、产物命名 | [docs/workflows.md](./docs/workflows.md) | +| 环境变量全表、启动方式、uv 依赖管理 | [docs/configuration.md](./docs/configuration.md) | +| 批量处理、暂停/继续、孤儿清理、进度日志 | [docs/operations.md](./docs/operations.md) | +| 测试资产清单、真实模型集成测试 | [docs/testing.md](./docs/testing.md) | +| 设计决策与踩坑记录(含实验结论) | [docs/decisions.md](./docs/decisions.md) | +| 代码缺陷跟踪与修复验收标准 | [docs/代码审查问题跟踪.md](./docs/代码审查问题跟踪.md) | + +**开工前先做两件事**:读 [README.md](./README.md) 建立项目认知, +再读 [docs/architecture.md](./docs/architecture.md#关键设计约束北极星不变式) +确认不能破坏的硬约束。 ## 提交规则(强制,2026-08) @@ -28,449 +41,84 @@ ASS)在**同一个进程**内运行,不再启动子进程、不再走节点 - 测试与构建也遵循此规则:已知 `uv run pytest` 全量约 70 秒,用后台+轮询, 不要写 `timeout 1800`。 -## 架构概览 +## 开发流程:TDD(红-绿-重构) -``` -vrsub/ -├── src/wov_sdk/ # 协议数据模型(NodeManifest/InvokeRequest/InvokeResponse/ -│ # WorkflowDefinition 等),与分布式版保持一致 -├── src/wov_app/ # 应用层:main/config/db/registry/scheduler/batch/seed/routers -│ └── routers/ # apps.py(用户端)、workflows.py(管理端)、batch.py(批量处理) -├── nodes/ # 进程内节点实现:echo/ffmpeg/whisper/llm/ass -├── manifests/ # 各节点清单 JSON(echo.json/ffmpeg.json/...) -├── workflows/ # 默认工作流定义 JSON(模型/链路均为数据,改模型不改代码) -├── web/ # 静态前端(index/tasks/admin/workflow + assets) -├── model/ # 本地 whisper 权重(gitignored) -├── data/ # SQLite + 上传/产物存储(gitignored) -└── tests/ # 单元/API/冒烟测试(覆盖核心路径,不强制 100%) -``` +- 任何新功能/修复必须先写失败测试(红),再实现最小代码让其通过(绿), + 最后重构保持整洁;不允许先写实现后补测试。 +- 小改动只跑相关测试,避免每次都完整跑全量测试;改动影响面大时才跑全量。 +- 测试运行方式与资产说明见 [docs/testing.md](./docs/testing.md)。 -### 核心机制 +## 测试规则 -- **节点注册表**(`src/wov_app/registry.py`):启动时把 `manifests/*.json` 与 - `nodes/*.py` 的 `invoke` 处理器静态注册到进程内字典,调度器按 - `node_type` 直接调用。协议数据模型不变,为将来回退分布式保留兼容桥梁。 -- **调度器**(`src/wov_app/scheduler.py`):后台线程轮询 SQLite 中的 QUEUED - 任务,按工作流 DAG 拓扑顺序调用节点,产物按 - `data/storage/runs//steps//` 落盘并登记到 artifacts 表。 -- **前端**:由 FastAPI 静态挂载 `web/`,节点注册/实例管理页面已移除, - 仅保留应用中心、任务管理、批量处理、管理后台(工作流)与工作流编排。 -- **工作流编排页(web/workflow.html)**:支持新建工作流(空表单预填演示模板), - 从列表"编辑"加载任一工作流的最新定义(ID 锁定,保存即追加新版本);"版本" - 查看全部历史版本并可"加载到编辑器"(对比/回滚后另存新版本);管理后台 - (admin.html)无编辑器,点"编辑"自动跳转 `workflow.html?edit=` 加载。 +### 模块的边界 -## 节点输入/输出协议 +「模块」指**由独立功能、在程序运行时会真实被使用到的代码块构成的代码上下文**: +它可独立运行,也可和其他代码上下文组合运行。 +例如 `nodes/whisper.py`、`nodes/llm_filter.py`、`src/wov_app/batch.py`、 +`src/wov_sdk/models.py` 各是一个模块;`nodes/subtitle_ocr.py` 与其 +`nodes/adaptive_pool.py` 是两个模块(后者可独立使用)。 -节点统一签名 `invoke(request: InvokeRequest) -> InvokeResponse`,通过产物 URI -交换数据(节点之间不直接调用,不共享内存状态)。 +### 按模块组织测试代码 -| 节点 ID(node_type) | 输入 | 输出 | 说明 | -| --- | --- | --- | --- | -| `echo` | `text` / `file_uri` | `text`、`file_uri` | 示例节点,验证协议链路 | -| `ffmpeg-extract` | `video_uri` | `audio_uri`(WAV) | 参数:`sample_rate`、`channels` | -| `faster-whisper` | `audio_uri`(16kHz 单声道) | `srt_uri` | 参数:`language`、`task`、`model_path`、`device`、`compute_type`、`beam_size`、`vad_filter`(默认开)、`condition_on_previous_text`、`chunk_seconds` | -| `llm-translate` | `srt_uri` | `cn_srt_uri` | 参数:`target_language`、`model` | -| `vlm-ocr` | `image_uri` | `text`、`text_uri` | 直接调本地 Ollama 多模态模型(glm-ocr)的 `/api/chat` 做视频帧 OCR(流式 + 5s 上限),参数:`model`、`ollama_host`、`prompt`、`timeout_seconds`、`keep_alive`、`num_predict`、`temperature`、`repeat_penalty` | -| `frame-extract` | `video_uri` | `frames_manifest`、`frame_count` | 按**帧间隔**抽帧(解析 fps → step=round(间隔秒×fps),ffmpeg select 按帧号精确取帧,帧时间=帧号/fps 无累计偏差)并 crop 裁切字幕区域,参数:`interval_seconds`(默认 0.5)、`crop`([x,y,w,h] 0~1,**默认画面底部 1/4** `[0,0.75,1,0.25]`——字幕很少出现在画面上半部分,2026-08 调整)。**帧文件必须按帧号数值排序读取**(`_sorted_frame_files`):ffmpeg `%04d` 编号超过 9999 帧后扩为 5 位,字典序 `sorted()` 会把 5 位编号排在 4 位之前导致时间与图像错位(真实发生于 run_339ec7ee437f 的 14236 帧任务,回归测试见 `test_frame_files_read_order_matches_frame_number`) | -| `subtitle-ocr` | `frames_manifest` | `srt_uri`、`count` | 自适应线程池并发逐帧调 vlm-ocr → 垃圾过滤(无文字帧)→ 相同字幕合并(记录最后可见帧)→ 组装 SRT,消失时间=最后可见帧+采样间隔(间隔从帧清单推导),参数:`min_chars`、`min_alnum_ratio`、`garbage_tokens`、`pool_min_workers`/`pool_max_workers`/`pool_window_seconds`/`pool_fast_threshold`/`pool_slow_threshold` | -| `llm-filter` | `srt_uri` | `srt_uri`、`kept`、`removed` | 两级过滤:①**规则层**(不调 LLM,**2026-09 起为默认且唯一启用的层级**)正则确定性删除——横线装饰、URL/邮箱/**裸网址域名**(含中文夹杂的注册地址)、**HTML/水印模式**(html code/标签/javascript 等)、overlay token(html/marketing 等)、单双 ASCII 字符、**水印编号**(SPHO-1/PHO一号馆/NO.1专用)、**日期/数值**(2011-11-27/4.0)、**VLM 提示回显**(no text is visible)、**角色标注**((出演));②**LLM 五类分类**(garbage/overlay/noise 删,repeat/dialogue 留)—— **默认关闭**(`use_llm`,默认 0),需显式 `use_llm=1` 才启用。**关闭原因(run_ac7f480a3ccb 逐类人工审查)**:LLM 层额外删除的 131 条中 **56%(73 条)是真实对话**(`好好教育她一番吧`/`腿不要合上`/`这家医院 为VIP患者提供了特殊服务`),而它真正抓住而规则层抓不到的仅 58 条且大半可正则化(已下沉);`repeat` 类别 67 条判定零删除;长文本保护等五套机制全在给不稳定分类器兜底。关闭后真实数据保留 863 条(旧 588 条)、**误删真对话 0 条**(旧 73 条)、无 LLM 调用。启用时保留原机制:每条连同前后各 `context_size`(默认 10)条**过滤后**文本判断(上下文净化),≥`min_keep_len`(默认 12)时 noise 不构成删除依据(长文本保护),429/5xx 指数退避 + 自适应线程池 `report_failure()` 降并发后重试一轮,判定成功即追加 `filter_partial.jsonl` 断点存档;按文本去重。参数:`context_size`、`min_keep_len`、`overlay_tokens`(JSON 数组)、`dedupe`(默认开)、`use_llm`(默认 0)、`model`、`pool_*`。回归数据:testdata/ocr_srt_run_ac7f480a3ccb.srt(真实任务 1666 条 OCR 输出) | -| `srt-to-dual-eye-ass` | `cn_srt_uri` | `ass_uri` | 参数:`resolution`(如 `3840x1920`)、`margin_top`(顶部安全边距,**默认 700**——2026-09 调整:120 落在画面最顶需抬头看,700 使字幕处于视线自然可读位置)。左右眼各占左右半幅且水平相对位置一致(**A-1 零视差**:字幕固定在屏幕平面,不做景深偏移);对齐 `an8` 顶部居中 + `MarginV=margin_top`(**B-1 顶部安全区**,避开画面中央人脸区);文字填充 `&HB3FFFFFF`(约 70% 透明)描边 `&H80000000`(半透明黑),降低遮挡感 | +- **测试代码必须按模块编写**:一个模块对应测试目录下的**一个模块目录**, + 不得把同一模块的测试拆成多个互不相干的平铺文件。 +- **单文件过长时**,在同一模块目录内拆成多个文件(按行为分组,如 + `test_rules.py` / `test_llm_layer.py`),而**不是**拆到模块目录之外。 +- 目录布局固定为 `tests/<层级>/<模块>/`,`<层级>` 对应被测代码位置 + (`nodes/` → `tests/nodes/`,`src/wov_app/` → `tests/app/`, + `src/wov_sdk/` → `tests/sdk/`),模块目录名即模块名。 +- 每个模块目录必须有 `__init__.py`,保证不同模块目录下的同名测试文件可共存。 +- 具体目录示例见 [docs/testing.md](./docs/testing.md#目录结构与模块对应)。 +### 数据 / 测试过程 / 验证结果 -#### 字幕样式统一(2026-09 起) +- 每个测试必须写成**数据 → 测试过程 → 验证结果**三段结构:先准备输入数据, + 再调用真实生产代码,最后断言输出结果;顺序清晰、一读即懂。 +- **测试必须可独立运行**:单个测试文件或单个模块目录被单独执行时结果不变。 + **不依赖全局 `conftest.py`(不保留全局 conftest)**,不依赖仓库其他测试的 + 配置、执行顺序或共享状态。 +- **数据与产物目录就是测试代码所在目录**:模块专用数据(JSON/期望输出等) + 写在测试代码内的常量或模块目录下;视频、音频等无法写进代码的大体积素材, + 放**同一个模块目录内**(如 `data/`)与其他测试区分,不使用仓库根级中央数据目录。 +- 测试过程**必须调用真实的生产代码**(导入 `nodes/`、`wov_app`、`wov_sdk` + 的真实实现),我们只构建测试数据、验证输出结果; + 不得在测试里重写业务逻辑来模拟被测功能,也不得用伪造结构冒充被测数据。 +- 需要磁盘或环境隔离时,由**模块自己的 fixture**(模块目录下的 `conftest.py` + 或测试文件内部)创建临时目录并设置变量,不依赖外部预先存在的配置。 -`nodes/ass.py` 顶部的 `DEFAULT_MARGIN_TOP=700` 与左右眼样式常量是**单一事实来源**: -新生成的字幕(`write_ass`/`invoke`)与历史字幕统一脚本共用 `ass_header()`/ -`style_row()`/`dialogue_line()` 同一出口,两边永不漂移。历史媒体库里由旧版本 -批量生成的 `*.CN_dual_eye.ass` 混有多种旧样式(底部 an2 实心白 / 底部半透明 / -顶部 120),用 `scripts/unify_ass_style.py` 统一原地改写为当前新样式: +### 功能覆盖优先,不追求代码覆盖率 -```bash -# 先 dry-run 预览将改哪些文件(默认不改盘) -uv run python scripts/unify_ass_style.py /mnt/fnOS/123 -# 确认无误后真正改写(原地,不产生 .bak) -uv run python scripts/unify_ass_style.py /mnt/fnOS/123 --apply -``` +- **不追求测试的代码覆盖率 100%,追求功能性代码覆盖率 100%**。 +- 一个大模块的功能正常,就无须对这个大模块中的辅助函数单独测试: + 私有工具函数、内部转换、仅供主流程调用的分支,由主功能用例顺带覆盖即可, + **不为了凑行覆盖率给辅助函数补无意义用例**。 +- 但**每个独立功能模块必须被测试覆盖**(即上文“模块的边界”中定义的模块), + 不得出现“完全无测试的模块”。模块清单与当前覆盖状态见 + [docs/testing.md](./docs/testing.md#功能模块覆盖清单)。 +- 新增独立功能模块时,必须同时补上覆盖其功能的测试,并同步更新上述清单; + 只写生产代码不补测试的改动视为未完成。 -脚本解析旧文件分辨率与全部 Dialogue 事件后经 `ass_header()`/`dialogue_line()` -重建,输出与代码新产物**逐字节一致**;非 VR 字幕(无 LeftEye/RightEye 样式行) -自动跳过。相关测试见 `tests/test_unify_ass_style.py`。 -### 模型权重解析(本地优先) - -whisper 节点按以下顺序解析模型路径,默认避免从远端下载: - -1. 请求参数 `model_path`;裸模型名(不含路径分隔符)会在 `model/<名称>` 下解析。 -2. 环境变量 `WHISPER_MODEL_PATH`。 -3. 本地候选目录(存在且含 `model.bin` 即使用): - - 单体根目录 `model/faster-whisper-large-v2`。 - - `nodes/model/faster-whisper-large-v2`。 -4. 兜底:`large-v2`(需要联网从 Hugging Face 下载)。 - -把权重放在 `model/` 目录即可完全离线运行。当前已下载模型: - -- `model/faster-whisper-large-v2`:通用转写模型(demo/learn-translate 工作流, - 2026-09 从 large-v3 切换:savr-1054 全片 A/B 实测无VAD 幻觉长段归零、开头漏句 - 救回,VAD 链路条数与覆盖小幅领先,见 `data/experiments/whisper_v2_vs_v3/`)。 -- `model/faster-whisper-large-v3`:旧通用转写模型,已无工作流引用,保留在盘上。 -- `model/whisper-large-v2-translate-zh-v0.2-st-ct2`:中文直出模型 - (`chickenrice0721/whisper-large-v2-translate-zh-v0.2-st-ct2`),配合 - `task=translate` 直接生成中文,无需 LLM 翻译(zh-direct 工作流)。 -- `model/whisper-large-v3-translate-zh-v0.1-lt-ct2`:早期中文直出模型, - 已无工作流引用,保留在盘上待处理。 - -### 内置工作流 - -| ID | 名称 | 链路 | 说明 | -| --- | --- | --- | --- | -| `demo` | 视频字幕生成 | 提音 → 转写 → LLM 翻译 → ASS | 通用链路,翻译走 SiliconFlow | -| `zh-direct` | 中文直出字幕 | 提音 → 中文转写 → ASS | 中文直出模型,无 LLM 步骤 | -| `ocr-subtitle` | 字幕OCR提取 | 抽帧 → 逐帧 OCR → 汇总 SRT → LLM 过滤 | 提取烧录字幕做基准数据;前端框选 crop;LLM 过滤多余/无意义字幕 | -| `learn-translate` | 学习资料转译+翻译字幕 | 提音 → 转写(decode_full) → LLM 翻译 → ASS | 面向讲解/学习类视频;应用本次修复的 decode_full 无 VAD 整段解码 + 日语幻觉清洗,优先"说了的话不漏"(弱语音/快速讲解召回),再由幻觉清洗移除无语音段长套话 | - -**工作流参数标注约定**(learn-translate 示范,可复用到任何工作流):JSON 不支持注释, -因此"参数理由"以节点 `params` 内 `_note_<参数名>` 键存放(`_` 前缀说明键,节点执行时 -只读真实参数键、忽略 `_note_*`,零运行影响);节点级参数手册放 `params._node_help` -(多行字符串,含关键参数解释与正反例)。`WorkflowNode.from_dict` 会完整保留 params -全部键(不清洗未知键),seed 入库/前端展示均不丢。查看方式:管理后台/工作流编排页 -打开工作流 definition JSON 即可见每个参数旁的理由说明。 - -**decode_full 参数**(本次修复,faster-whisper 节点):默认 `false`(保持 VAD 现状); -置 `true` 时强制无 VAD 整段解码并跳过自动 VAD 分析,救回被 silero VAD 当非语音剔除的 -弱语音/呻吟/BGM 混叠人声(实测 savr-1054 全片 115 条 → 340 条),副作用为无语音段 -长时寒暄幻觉,处理方式:whisper 转录后立即**连带时间戳把整条 cue 删除**(剩余重编号, -见 `nodes/subtitle_cleanup.py` 的 `clean_japanese_hallucinations`),不留下 `-` 占位污染 -下游(占位会渲染进 ASS 成可见减号);llm-translate 翻译后同样整条删除中文长时寒暄 -幻觉(`clean_srt_text`)。短时(≤15s)相同词可能是剧情真实道晚安,保留。 -**短呻吟过滤**(2026-09):decode_full 救回的弱语音中混有大量**纯语气词碎片** -(あ…/ん?/はぁ…/あ!あ!/んふふ 等 ≤3 假名),影响字幕观感;whisper 转录后按 -'全部字符 ∈ 纯呻吟字符集合(`MOAN_CHARS`)且有效假名数 ≤ `short_moan_max_chars`(默认 3,设 0 关闭)' -判据**整条删除**(`remove_short_moan_entries`)。集合刻意排除 そ/こ/ね/や/ば/だ -等假名,真实短对话(そこ/やばい/ねえ/やだ/えへへ)天然不命中。仅 decode_full -生效,demo 等 VAD 链路不受影响。 - -最终产物按 `上传文件名.标识.时间戳` 命名(如 `test01.zh-CN.20260815123000.srt`), -标识优先取节点的 `target_language` 参数,否则用产物别名。**审查 R03 修复**: -保留节点原始文件及 URI,把成品副本存入 `runs//finals/output-<编码别名>/`; -时间戳固定取 run 创建时间,重复收尾覆盖相同路径,多个别名分目录避免冲突。 -复制先写同目录临时文件,再原子替换目标,失败不登记残缺文件;`final_outputs` -声明的引用或文件缺失时任务失败,不能标完成。旧版本原文件已改名但最终别名记录 -仍指向有效文件时允许复用;原文件与成品都丢失时明确报错。 - -### 翻译条目对齐(审查 R05) - -`llm-translate` 经 `nodes/srt.py` 按 cue 解析(支持 BOM/CRLF、多行、空正文), -以全局位置 ID 的 JSON `{id,text}` 数组请求翻译;时间戳不进入模型。返回的 ID -集合、类型、唯一性和非空正文必须校验通过,乱序结果按 ID 回填。结构错误最多 -尝试 3 次,耗尽返回 failed,不再在末尾合并或补空。空 cue 不调模型但保留时间轴; -原有长时幻觉清洗继续生效。短句真实 LLM 校准见翻译对齐测试。 - -### OCR 空帧与故障恢复(审查 R06) - -相同字幕只合并相邻帧,空帧结束当前段。OCR 临时失败/异常不进入成功存档, -失败帧降并发后重试一轮,仍失败则节点 failed,成功帧保留供恢复。JSONL 新增 -`status=completed`(含成功空文字)或 `skipped`(超长输出按既有规则跳过)。 -旧存档非空结果复用;无状态的旧空串可能由超时产生,重新识别一次。全量 14236 -帧回归以真实单线程新结果 1942 条为基线,原 1666 条历史文件保留供比对, -新增逐帧覆盖检查防止跨空白合并,不再要求与旧错误时间轴逐字节一致。 - -### 任务参数覆盖(前端框选) - -创建任务时可携带可选 `params` 表单字段(JSON):`{"节点ID": {"参数": 值}}`, -随任务持久化(param_overrides),调度执行时合并进对应节点参数。字幕 OCR -前端把框选的 `crop` 按此传给 `frame-extract` 节点。 - -### 切换模型不改代码 - -- 模型是工作流 DAG 中 asr 节点的 `model_path` 参数(**数据**),内置工作流 - 均已显式声明:demo/learn-translate 用 `faster-whisper-large-v2`,zh-direct 用中文直出模型。 -- 切换模型 = 改 `workflows/*.json` 或管理页面 DAG JSON → 保存新版本 → 发布, - 全程不涉及代码;新库启动时从 JSON 重新 seed。 -- 默认工作流定义存放在 `workflows/*.json`(数据文件),代码只负责加载。 - -### 长音频处理 - -**当前策略:分块转写,默认每 1 分钟一块**(`chunk_seconds=60`,2026-08 调整)。 -whisper 节点内部用 ffmpeg 把音频切成块 → 逐块转写 → 按偏移合并为完整 SRT: - -- 内存/显存有界(模型 + 单块音频),任意时长可处理,失败粒度小。 -- 分块是**应用层工程策略**,与模型训练格式无关:whisper 训练/推理都按 30s - 窗口解码,任意块大小均适用。 -- 每块 `offset = 块序号 × chunk_seconds`,SRT 序号连续;切块失败自动回退 - 整段单次转写。 -- `chunk_seconds=0` 可关闭分块;大小按工作流 DAG 参数(数据)调整。 -- 同时默认 `condition_on_previous_text=false`(每块/每窗口独立解码,防重复)。 -- **`vad_filter` 默认开启**(2026-08 用户决定):过滤静音段提速并减少无语音处 - 幻觉。注意 VAD 靠压缩时间轴回映射(SpeechTimestampsMap),长静音场景曾实测 - 错位(30s 静音致第二段语音从 ~40s 落到 10s);如需极致对齐可显式传 - `vad_filter=false`。 -- **分块偏移按每块实际时长累积**(WAV 头精确):ffmpeg 切出的块实际时长不等于 - 块长(如 60.05s),用 `块序号×块长` 的假设值会随块数累积漂移;改为按真实 - 时长累加后,字幕时间轴与原始音频严格一致。 -- 参考:openai/whisper 重复问题(issue #1026/#1046,PR #1052/#1253)、 - SYSTRAN/faster-whisper issue #465。 - -### glm-ocr 重复循环问题与源头修复(2026-08) - -- **根因**:glm-ocr 生成阶段存在已知 bug(M-RoPE delta 未传递,大图触发 - 重复循环;GitHub #454 / #16892)。`keep_alive` 与其无关(实测无效)。 -- **源头修复**: - 1. `frame-extract` 裁切后把帧**压缩到 720p 内**(仅缩小,保持宽高比)—— - 过大输入图是触发条件之一。 - 2. `vlm` 请求体 `options.repeat_penalty`(默认 1.2)+ `num_predict` - (默认 256)压制重复。 - 3. `subtitle-ocr` 增加 `max_result_chars`(默认 200):模型输出超长视为 - 异常(重复循环等),**直接报错并跳过该帧**。 -- **glm-ocr 调用结构**:走 Ollama `/api/chat`,识别指令放**系统提示词**, - 用户消息只携带图片(content 为空、images 传 base64),`stream=True` - 逐行接收,`stop: ["\n", "\n答", "答"]` 命中即停止(输出首个换行即停 + 阻止“答:”式重复循环),`temperature` 默认 - 0.3、`repeat_penalty` 默认 1、`num_predict`(默认 256)随请求透传。 -- **gettext 标签防御性提取**(2026-08):若模型输出含 `` - 标签(旧提示词要求)则取第一个标签内文本,多个标签取第一个防重复循环; - 未按格式输出时回退原文。当前默认提示词为"提取图像中的文字,不要描述 - 图片中的内容"(字幕流水线在 ocr-subtitle 工作流的 subtitle-ocr 节点参数 - 中显式指定,经 subtitle-ocr 透传给 vlm-ocr)。 -- **每次调用 5 秒上限**(2026-08 调整):vlm 请求 `stream=True` 逐行读取, - 每次调用整体受 5 秒截止时间约束(`timeout_seconds` 参数 / - `VLM_TIMEOUT_SECONDS`,默认 5),超过即终止返回 failed,不再等待后续 - 流式块。 -- **不做文本加工**:除协议要求的 gettext 标签提取外,不再对模型输出做 - 过滤/去重等文本加工,结果原样使用,仅受长度上限约束。 - -### VLM OCR 自适应并发(2026-08) - -subtitle-ocr 逐帧调 vlm-ocr 时使用 `nodes/adaptive_pool.py` 的自适应线程池 -弹性并发: - -- 从 `pool_min_workers`(默认 1)起步,按**滚动窗口**(`pool_window_seconds`, - 默认 10s)统计已完成任务的平均响应时间; -- 平均响应 < `pool_fast_threshold`(默认 0.3s)→ 线程数 +1(上限 - `pool_max_workers`,默认 16)——服务端空闲就加大并发加速处理; -- 平均响应 > `pool_slow_threshold`(默认 1.0s)→ 线程数 -1(下限 1)—— - 服务端变慢就退避,避免盲目并发压垮本地 Ollama; -- 结果按帧顺序返回,SRT 时间轴不受并发影响;worker 需无共享可变状态 - (vlm-ocr 处理器为纯函数,线程安全)。 -**并发实现修复(审查 R02)**:`AdaptiveThreadPool` 使用标准线程执行器复用线程, -由 `map` 控制在途任务数,不一次性把全片任务压入执行器队列。上面的“线程数” -及进度日志中的 N 指目标在途并发额度,不是执行器已创建的线程总数。降低目标后, -已发出的请求允许完成,后续提交立即遵守新额度;不再向积压队列尾部追加退出哨兵。 -`report_failure()` 只保持或降低当前额度,绝不因上限从 20 降到 19 就把当前 1 -并发扩为 19。错误窗口不扩容,干净窗口逐步恢复有效上限;有效上限跨重试 `map` -保留,每批重新统计耗时窗口。进度回调与结果汇总由 map 线程串行处理,窗口未满 -时平均耗时取 worker 实际耗时均值。`cancel()` 保持原约定,仅抑制进度回调, -节点自行检测暂停并返回异常。相关回归见 `tests/test_adaptive_pool.py`。 - -### 前端 OCR 框选 - -首页选择工作流后,若 DAG 中存在声明 `crop` 参数的节点(frame-extract), -自动切换到框选面板:视频预览 + 拖动框选字幕区域 → 生成 crop 比例 → -框选完成后才可提交(未框选时提交按钮禁用)。矩形↔crop 换算为纯函数 -(`web/assets/crop.js`,含 letterbox 处理),由 node 单测覆盖。 - -## 环境变量 - -| 变量 | 默认值 | 说明 | -| --- | --- | --- | -| `WOV_DATA_DIR` | `<根>/data` | 数据目录 | -| `WOV_DB_PATH` | `<根>/data/wov.db` | SQLite 路径 | -| `WOV_STORAGE_DIR` | `<根>/data/storage` | 上传与产物根目录 | -| `WOV_AUTO_SEED` | `1` | 启动时创建 demo 工作流 | -| `WOV_SCHEDULER_ENABLED` | `1` | 启动后台调度器 | -| `WOV_SCHEDULER_INTERVAL_SECONDS` | `1.0` | 调度轮询间隔 | -| `WOV_CLEANUP_ENABLED` | `1` | 开启孤儿数据定时清理 | -| `WOV_CLEANUP_INTERVAL_SECONDS` | `3600` | 孤儿清理扫描周期(秒) | -| `WOV_CLEANUP_GRACE_SECONDS` | `3600` | 孤儿清理宽限期(秒) | -| `WOV_BATCH_ENABLED` | `1` | 开启文件夹批量处理引擎(处理 source=batch 任务) | -| `WOV_BATCH_INTERVAL_SECONDS` | `1.0` | 批量引擎轮询间隔 | -| `WHISPER_MODEL_PATH` | 见上 | 显式指定 whisper 模型路径 | -| `WHISPER_DEVICE` | `auto` | 转写设备 | -| `LLM_API_BASE` | `https://api.siliconflow.cn/v1/chat/completions` | LLM 兼容接口 | -| `LLM_API_KEY` | 空(读 `.env`) | SiliconFlow Bearer Key,存于 gitignored 的 `.env` | -| `LLM_MODEL` | `Qwen/Qwen3.5-35B-A3B` | LLM 模型名(2026-09 由 `Qwen/Qwen3.6-35B-A3B` 切换:同片 A/B 全量评测质量持平、0.232 s/行(基线档最快),见 `data/experiments/translate_models/REPORT.md`)。**例外**:`subtitle-correction` 节点**有意**保留旧兜底 `Qwen/Qwen3.6-35B-A3B`——该节点错听泛化实测新模型 0/4、旧模型 4/4(复现:同一误听场景各跑 4 次),参见 `tests/test_llm_default_model.py` | -| `LLM_TIMEOUT_SECONDS` | `600` | LLM 单请求超时 | -| `OLLAMA_HOST` | `http://192.168.123.70:11434` | Ollama 服务地址 | -| `VLM_MODEL` | `glm-ocr:latest` | VLM OCR 模型 | -| `VLM_PROMPT` | 提取图像中的文字,不要描述图片中的内容 | OCR 提示词(字幕流水线在 ocr-subtitle 工作流的 subtitle-ocr 节点参数中显式指定同一提示词) | -| `VLM_TIMEOUT_SECONDS` | `5` | VLM 单请求整体超时上限(每次调用 5 秒,超时即终止;流式读取同样受此截止约束) | -| `FFMPEG_BIN` | 空 | 显式 ffmpeg 路径(否则 PATH → imageio-ffmpeg) | - -## 启动 - -```bash -uv sync -uv run uvicorn wov_app.main:app --reload -``` - -访问: - -``` -http://127.0.0.1:8000/ 应用中心(上传视频 → 字幕生成) -http://127.0.0.1:8000/tasks.html 任务管理 -http://127.0.0.1:8000/admin.html 管理后台(工作流) -http://127.0.0.1:8000/workflow.html 工作流编排(DAG JSON) -http://127.0.0.1:8000/docs API 文档 -``` - -## Python 环境与 uv 管理 - -- 统一使用 uv 管理虚拟环境和依赖,禁止直接使用 pip 修改依赖。 -- 基础命令:`uv sync`(安装含 dev 组依赖)、`uv run `、 - `uv add `、`uv lock`。 -- 虚拟环境位于 `.venv`,测试依赖在 `[dependency-groups] dev`。 -- 新增依赖时使用 `uv add`,不修改系统 Python 或全局环境。 - -## 孤儿数据清理 - -应用内置后台清理器(`src/wov_app/maintenance.py`),按周期自动清理死数据: - -- **自动删除**:无任务记录的上传/步骤残留目录;COMPLETED 且产物文件全部丢失、 - 超过宽限期(默认 1 小时)的任务记录(下载已全部 404)。 -- **绝不自动删除**:FAILED 任务(可重试)、QUEUED/RUNNING 任务、宽限期内的任务、 - 仍有产物文件的任务。 -- 手动删除任务仅通过删除接口(`DELETE /api/runs/{run_id}`)或管理界面进行。 - -## 任务暂停/继续(2026-08) - -- **状态机**:`QUEUED / RUNNING / PAUSED / COMPLETED / FAILED`。排队中或运行中的 - 任务可暂停(`POST /api/runs/{run_id}/pause`),PAUSED 可继续 - (`POST /api/runs/{run_id}/resume` → 恢复 QUEUED)。 -- **调度器语义**:`next_queued_run` **只取 QUEUED**——PAUSED 任务不会被调度器 - 自动拾起(修复回归:此前 PAUSED 被拾起后 `execute_run` 先置 RUNNING 再检查, - 节点循环读到的是刚改的 RUNNING,"暂停检查"永远不成立 → 任务被复活继续跑, - 表现为"点击暂停反而开始任务")。`execute_run` 以 PAUSED 进入时直接返回保持暂停, - 必须用户显式 resume(PAUSED → QUEUED)后才真正执行;运行中被暂停的任务在每个 - 节点边界检查状态停下保持 PAUSED(当前节点执行完后才停);继续时从产物表 - (`restore_run_outputs`,剥去"节点ID."前缀还原输出名)重建已完成节点的输出, - **跳过已完成节点断点续跑**,最后补做 final_outputs 收尾。 -- **重启恢复**:进程被杀/重启时遗留的 RUNNING 任务在启动时被 - `recover_interrupted_runs` 恢复为 QUEUED(保留产物),调度器自动断点续跑; - PAUSED 任务保持不变,等待显式 resume。 -- **节点级断点(subtitle-ocr)**:OCR 每帧完成后立即把 `{frame, text}` 追加到 - `steps/ocr/ocr_partial.jsonl`(多线程下加锁串行化)。invoke 启动时读取存档, - 只对未处理帧调用 vlm-ocr,存档文本与新增结果合并后组装 SRT——2 小时视频级 - OCR 任务中断/重启后不重复已处理帧,产物与一次跑完逐字节一致。 -- **节点内暂停响应(subtitle-ocr)**:暂停接口(`POST /api/runs/{run_id}/pause`) - 除置 PAUSED 外还向 run 根目录写入 `paused.flag`;OCR 工作线程**逐帧检查**该 - 信号,存在即立即中止(不 OCR、不入存档,恢复时重跑该帧),invoke 返回 - failed;调度器捕获节点异常时若任务已是 PAUSED 则**保持 PAUSED 不标 FAILED**, - resume 时清除信号并从断点存档继续——点击暂停后 OCR 秒级停下,不再等整个 - 节点跑完。继续/重试接口与调度器执行前都会清理残留信号。 -- **前端**:任务管理页为 QUEUED/RUNNING 提供"暂停"、PAUSED 提供"继续"按钮。 -- **进度日志(数据处理速度)**: - - 调度器:每节点完成打印"任务 X 进度 i/N 节点: Y 耗时 Zs, 运行累计 Ws"; - - subtitle-ocr:`OCR 进度 X/Y 帧 (Z 帧/s, 平均 Ws/帧, 线程 N/M)`;llm-filter: - `字幕判定进度 X/Y 条 (Z 条/s, 平均 Ws/条, 线程 N/M)`——`W` 为最近窗口 - 平均单任务耗时(窗口未满时回退累计平均),`N` 为当前目标线程数、`M` 为 - `pool_max_workers` 上限,用于判断多线程是否因单次处理过慢(窗口平均 ≥ - `pool_fast_threshold`)而未扩容; - (线程池 `on_progress` 回调,每任务完成触发); - - whisper:分块转写打印"分块 X/Y 完成 offset=... 耗时 Zs (Nx 实时, 累计 ...s)"; - - frame-extract:ffmpeg `-progress` 输出解析 `frame=N`,打印"抽帧进度 X/Y 帧 (Z 帧/s)"。 -## 文件夹批量处理(2026-09 更新) - -本地版核心能力:**不把视频上传到工作目录**,直接读取用户所选文件夹下的全部 -视频,逐个执行所选流水线。入口为批量处理页(`web/batch.html`,导航"批量处理"), -后端为 `src/wov_app/batch.py` 的 `BatchWorker`(单线程轮询线程,处理 -`source=batch` 的运行,与主调度器互不抢占)与 `routers/batch.py`。 -- **路径选择**:批量页点击"选择文件夹…"按钮弹出目录树选择器(懒加载),选完 - 回填只读路径框。浏览器拿不到所选文件夹的绝对路径,因此由**本地后端**提供目录 - 浏览:`GET /api/batch/roots`(Windows 盘符 / POSIX 根 + 家目录)、 - `GET /api/batch/dirs?path=`(列直接子目录,隐藏目录过滤;不存在/不可读返回 - 空列表不报 500)。只暴露目录名,不返回文件内容。 -- **创建任务时一次性定位(2026-09 起)**:`POST /api/batch/jobs {folder, - workflow_id, recursive}` 只扫描一次文件夹并把每个视频登记为 `batch_videos` - 明细(PENDING/RUNNING/PAUSED/COMPLETED/FAILED/SKIPPED)。**视频所在目录 - (视频旁)若已存在文件名含视频名的字幕文件**(`.srt/.ass/.ssa/.vtt`, - `list_sidecar_subtitles` 判定,如 `movie.CN.srt`、`movie.CN_dual_eye.ass`), - 说明该视频已有字幕,创建即记 **SKIPPED**——不为它触发任何流水线。运行时 - `BatchWorker` **只消费这批已定位的明细,不再重新扫描文件夹**(运行期间新增/ - 删除的视频不会改变本次任务的范围)。校验失败(文件夹不存在/未发布工作流/ - 无版本/一个视频都没有)返回 422。 -- **产物放在视频旁**:每个视频处理完成后,把工作流 `final_outputs` 对应的最终 - 产物文件(字幕流水线即中文 `.srt` 与双目 `.ass`)**复制一份到视频所在目录**, - 与 .mp4 放在一起(`_place_products`)。文件名**对齐媒体库既有约定**:中文字幕 - 存为 `<视频名>.CN.srt`、双目字幕存为 `<视频名>.CN_dual_eye.ass`(稳定无时间戳, - `_sidecar_product_name` 映射,其余扩展名产物保留原文件名;同名目标直接覆盖)。 - 文件名含视频主名,下次批量扫描会命中"已有字幕"规则直接跳过该视频。 -- **成品放置校验(审查 R03)**:先预检全部 `final_outputs` 对应的记录与文件, - 缺任一项即失败,不开始覆盖视频旁成品;全部齐备后逐文件原子替换。复制失败时 - 视频记 FAILED,保留 run、工作空间和已放置的完整成品,供修复后幂等重试。 - 原子替换保证单文件完整,不代表多个成品的跨文件事务或断电持久性。 -- **过程文件清理(2026-09 起)**:视频收尾完成后删除该视频的整个工作空间与 - run 记录(音频/分块/帧图/节点产物不留残),防止媒体库把切片数据当视频入库。 - 工作空间位于**应用私有目录** `data/storage/batch///` - (不再放视频同名文件夹),与用户视频库天然隔离;暂停/失败的视频保留工作空间 - 以便断点续跑。per-video 的 `WorkflowScheduler` 实例以该目录为 storage—— - 完整复用 DAG 拓扑执行、产物表登记与**断点续跑**逻辑。 -- **暂停/继续**:`POST /api/batch/jobs/{id}/pause` 把任务置 PAUSED 并暂停当前 - run(写 `paused.flag`;whisper **分块间**检查、OCR 逐帧检查后中止,当前节点 - 执行完才停);`resume` 恢复 QUEUED,引擎从断点继续——PAUSED 视频的 run 显式 - resume 后从产物表续跑,未开始的视频接着处理。重启进程后 RUNNING 残留 run 由 - `recover_interrupted_runs` 恢复,暂停的继续处理。 -- **失败容错**:单个视频失败(节点失败/文件缺失)记为 FAILED,批量任务继续 - 处理后续视频,结束后统计 done/failed;DAG 解析/任务级异常把任务置 FAILED。 - **重跑保留产物**(2026-08,修复 run_e2b74e89e232 实测):FAILED 视频重新处理 - 时不再 reset_run 清空产物记录,而是保留已完成节点的 artifacts 恢复 QUEUED, - execute_run 从产物表跳过已完成节点、只重跑失败节点——extract/ocr 等长耗时 - 成果不浪费;配合 llm-filter/OCR 的节点级断点存档,失败节点自身也只重判未完成 - 条目。前端对"部分失败"(COMPLETED 且 failed>0)用红色徽章醒目标示。 -- **完成任务判定(2026-09 修复)**:`_run_job` 置 COMPLETED 前**校验全部非 - SKIPPED 视频都已结束**(无 PENDING/PAUSED 残留),否则保持 RUNNING 交引擎 - 下一轮续跑——修复僵尸状态:引擎串行处理到 9.9GB 大视频时中断,`_run_job` - 无条件收尾把任务置 COMPLETED,留下"N 个 PENDING 待处理却已完成"的假完成 - (batch_969fabe74b83 等 3 个任务实测:遗留的 10 个 PENDING 完全相同且卡在 - kiwvr-887 大文件前)。**崩溃恢复**:重启时除 `recover_interrupted_runs` 外, - 新增 `recover_interrupted_batch_jobs` 把 RUNNING 的批量任务恢复为 QUEUED - (否则停在 RUNNING 的批量任务永远不会被 `next_queued_batch_job` 再次拾起, - 未处理完的 PENDING 永久残留)。历史僵尸数据修复脚本见 - `scripts/fix_zombie_batch_jobs.py`(把误标 COMPLETED 的任务置回 QUEUED 续跑)。 -- **产物下载**:`GET /api/batch/jobs/{id}/videos/{vid}/download?alias=<文件名>` - 解析并返回视频旁的字幕文件;旧版 `batch.done.json` 完成标记里的语义别名 - (位于旧 work_dir)仍兼容可下载。详情/创建响应里每个视频的 `finals` 合并上述 - 两处来源。 -- **孤儿清理保护**:`source=batch` 的运行**跳过**自动清理——其 run 位于私有 - `storage/batch/...` 下,普通孤儿逻辑会误判删除,且 `_remove_run` 还会删除 - `input_uri` 的父目录(用户的整个视频文件夹)。 -- **删除任务**:`DELETE /api/batch/jobs/{id}` 清理数据库记录(含关联 run)与 - 应用私有工作空间残留;视频旁已放置的产物属于用户数据,保留不删。 -- **环境变量**:`WOV_BATCH_ENABLED`(默认 1)、`WOV_BATCH_INTERVAL_SECONDS` - (默认 1.0)。 - - -## 测试 +### 测试质量与运行 - 测试以保证功能可用为目标,**不强制 100% 行覆盖率**(pytest 已移除 `--cov-fail-under=100` 门槛);需要查看覆盖率时可手动追加 `uv run pytest --cov=src --cov=nodes`。 -- **小改动只跑相关测试**,避免每次都完整跑全量测试浪费时间;改动涉及 - 哪个模块就跑对应测试文件(如 `uv run pytest tests/test_batch.py`), - 确认相关用例通过、功能可用即可。完整跑全量测试只在改动影响面大时进行。 -- 测试必须调用真实代码路径,不得在测试类中重写业务逻辑来模拟被测功能。 - **测试必须使用真实数据**:真实音频(合法 WAV/PCM)、真实 JSON/数据库/文件; 禁止用占位字节(如 `b"x"`)或伪造结构冒充被测数据——假数据测试只能凑覆盖率, 无法验证真实行为,视为无意义测试。 - 只允许在 I/O 边界使用 mock/stub:文件系统、网络、子进程、环境变量、时间、 - **模型推理**(重模型不进入单元测试;注入的假模型必须返回结构真实的分段, - 且必须配套真实模型集成测试,见下)。 -- **真实模型集成测试**:使用真实 faster-whisper 模型 + 真实音频素材验证 - 端到端转写(`tests/test_integration_whisper.py`);本地无模型或素材时跳过, - 有则必须执行,作为对假模型单测的校准。 -- **测试资产存放 `testdata/`**:图片(`ocr_text.png`)、语音(`speech_60s.wav`) - 等测试媒体一次性生成后入库,测试直接复用,**禁止在测试执行时再生成**; - 缺失时测试跳过而非现场生成。大体积视频素材放 `data/testdata/`(gitignored)。OCR 相关资产: -`ocr_text.png`(有文字)、`ocr_notext.png`(无文字帧)、`subtitle_10s.mp4` -(烧录 SUB 001@1-4s / SUB 002@6-9s 的 10s 测试视频)、`test_real_hav_sub.png` -(真实视频字幕截图,VLM 集成测试期望识别出"还有没有什么困扰 或者奇怪的地方吗")、 -`ocr_srt_run_ac7f480a3ccb.srt`(真实任务 1666 条 OCR 输出,llm-filter 回归)、 -`frames_manifest_full.json` + `ocr_frames_full.json`(真实任务 run_ac7f480a3ccb -**全部 14236 帧**的帧清单与逐帧 OCR 文本,多线程顺序测试常驻夹具;配合 -`ocr_srt_run_ac7f480a3ccb.srt` 作为单线程确认基线,见 -`tests/test_subtitle_ocr_order_threading.py`)。 -- **开发流程强制 TDD(红-绿-重构)**:任何新功能/修复必须先写失败测试(红), - 再实现最小代码让其通过(绿),最后重构保持整洁;不允许先写实现后补测试。 -- 测试运行:`uv run pytest`;全部测试位于 `tests/`。 -- 测试(无论是否测覆盖率)只保证代码路径被执行,不覆盖端口占用、防火墙、 - 权限等外部环境状态;端口问题用启动检查、端口检查与 uvicorn 冒烟测试补充。 + **模型推理**(重模型不进入单元测试;注入的假模型必须返回结构真实的分段)。 +- **真实模型集成测试**必须配套:真实 faster-whisper 模型 + 真实音频素材验证 + 端到端转写,本地缺模型/素材时跳过,有则必须执行(清单见 + [docs/testing.md](./docs/testing.md#真实模型集成测试))。 +- 测试素材必须**一次性准备后随模块目录入库**(放模块目录内),禁止在测试执行时 + 现场生成;缺失时跳过而非生成。 +- **外部环境状态缺失时应跳过而不是判失败**:未配置的密钥、账户余额/配额、 + 限流、模型/服务不可达、缺失的真实素材都属于环境状态,不是被测代码的行为; + 这类用例用 `pytest.skip()` 并说明原因(如 `pytest.mark.integration` 用例)。 + 但代码抛异常、返回结构错误、断言不成立仍必须失败,不得用跳过掩盖回归。 +- 测试只保证代码路径被执行,不覆盖端口占用、防火墙、权限等外部环境状态; + 端口问题用启动检查、端口检查与 uvicorn 冒烟测试补充。 - 本地出现 `WinError 10013` / `WinError 10048` 时,先用 `netstat -ano | findstr :` 确认是否有残留监听进程。 @@ -481,8 +129,8 @@ http://127.0.0.1:8000/docs API 文档 确保后续维护人员无需通读全部实现即可快速理解工作原理。 - 新增或修改代码时,必须同步补充或更新对应注释;不得删除已有注释。 - 测试代码同样必须配有中文注释,说明每条测试验证的行为与覆盖的路径。 -- JSON 数据文件(`manifests/*.json`)按 JSON 规范不支持注释,字段语义以 - `src/wov_sdk/models.py` 的 `NodeManifest` 模型注释和本文档为准; +- JSON 数据文件(如 `manifests/*.json`、`workflows/*.json`)按 JSON 规范不支持 + 注释,字段语义以 `src/wov_sdk/models.py` 的模型注释和 [docs/](./docs/) 文档为准; 修改 JSON 字段时须同步更新文档。 ## 目标运行环境 @@ -507,7 +155,7 @@ http://127.0.0.1:8000/docs API 文档 - 参数含空格、括号、中文、`&|;><$` 或引号时默认用单引号。 - 外部程序路径可能有空格时,用 `& 'C:\path with spaces\tool.exe' arg1`。 - 文件操作优先 PowerShell 原生命令和 `-LiteralPath`。 -- 复杂 Python 不用 `python -c`;涉及 SQL、JSON、中文、反斜杠路径、换行或 +- 复杂 Python 不用 `python -c`;涉及 SQL、JSON、中文、反斜杠、换行或 多层引号时,用仓库脚本或临时 `.py` 文件。 - 禁止在 PowerShell 用 Bash here-doc。临时传 Python 源码只允许 PowerShell here-string,且尽量保持 ASCII。 @@ -519,27 +167,13 @@ http://127.0.0.1:8000/docs API 文档 - 同一 PowerShell 命令连续失败两次后,停止微调长命令;改短命令、脚本文件、 数组 splatting 或分步验证。 -## 关键设计约束(北极星不变式) +## 文档维护规则 -- 节点之间不直接调用,只通过产物 URI 交换数据;中间产物落在共享存储 - (`data/storage`),不放在节点模块内部。 -- 工作流必须是数据文件或数据库记录(workflow_versions 表存 DAG JSON), - 不允许把步骤顺序写死在应用代码里。 -- 节点注册表是节点调用的唯一入口;API 与调度器不绕过 registry 直接执行 - 节点逻辑。 -- 协议数据模型(wov_sdk)要长期稳定,宁可先少做功能,也不轻易改协议。 -- 存储、队列、调度器都要通过抽象边界隔离,方便从单机实现替换为分布式实现。 -- 用户端永远只看到"输入 -> 进度 -> 结果",不暴露工作流细节。 -- 单体对分布式版的三处降级:无子进程隔离、无空闲 TTL 回收(模型常驻, - 仅懒加载)、非 OCR 慢任务无法强制中断(由节点自身超时兜底;OCR 节点支持 - 暂停信号逐帧中断)。 - -## 单体化说明 - -- 由原 7 个独立仓库合并:wov-api、wov-web、wov-sdk、wov-node-echo、 - wov-node-ffmpeg、wov-node-whisper、wov-node-llm、wov-node-ass。 -- 删除内容:`NodeManager`(子进程生命周期)、节点 HTTP 服务端 - (`wov_sdk.server`)、节点注册/实例管理 API 与页面、node_instances 表、 - 各节点的 `__main__` 进程入口。 -- 保留内容:协议数据模型、工作流 DAG 数据化、调度拓扑执行、上传/进度/下载/ - 重试 API、静态前端、SQLite Repository 层、本地优先模型加载。 +- 本文件**只放对代理的要求**。新增项目知识(架构、协议、参数默认值、运维语义、 + 实验结果)写进 [README.md](./README.md) 或 [docs/](./docs/) , + 不得写进本文件。 +- 新增/移动文档时同步更新 README 的文档导航表与本文件顶部的索引表, + 保证链接可达;文档中的相对链接指向真实文件。 +- 决策与踩坑("为什么这样做")写 [docs/decisions.md](./docs/decisions.md), + 缺陷跟踪写 [docs/代码审查问题跟踪.md](./docs/代码审查问题跟踪.md), + 专题文档只保留结论并链接过去,避免同一内容多处维护。 diff --git a/README.md b/README.md index 64c8d7a..35c237b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # VRSub 为视频生成 **VR 双眼字幕**的单体应用:API、调度器与全部节点在同一进程内运行, -单仓库、单环境、单命令启动。由原分布式 WOV 多仓库合并而来,详细约定见 -[AGENTS.md](./AGENTS.md)。 +单仓库、单环境、单命令启动。由原分布式 WOV 多仓库合并而来。 + +用户上传视频,选择工作流,即可得到中文 `.srt` 与 VR 双目 `.ass` 字幕; +也可以按文件夹批量处理整个媒体库,产物直接落在视频旁。 ## 快速开始 @@ -11,35 +13,81 @@ uv sync uv run uvicorn wov_app.main:app --reload ``` -访问 `http://127.0.0.1:8000/` 上传视频,自动执行"提音 → 转写 → 翻译 → ASS"。 +访问: -- 模型权重本地优先:把 whisper 模型放在 `model/faster-whisper-large-v3` +``` +http://127.0.0.1:8000/ 应用中心(上传视频 → 字幕生成) +http://127.0.0.1:8000/tasks.html 任务管理 +http://127.0.0.1:8000/batch.html 批量处理(按文件夹) +http://127.0.0.1:8000/admin.html 管理后台(工作流) +http://127.0.0.1:8000/workflow.html 工作流编排(DAG JSON) +http://127.0.0.1:8000/docs API 文档 +``` + +- 模型权重本地优先:把 whisper 模型放在 `model/faster-whisper-large-v2` (含 `model.bin`)即可完全离线运行。 -- LLM 翻译默认使用 SiliconFlow(`Qwen/Qwen3.5-35B-A3B`),API Key 放在 - gitignored 的 `.env`(`LLM_API_KEY=sk-...`),启动时自动加载;也可用 - `LLM_API_BASE` / `LLM_MODEL` 环境变量覆盖。 -- 默认模型切换为 `Qwen/Qwen3.5-35B-A3B`(2026-09):对同片 2 小时日语 ASR 做 - 8 个模型全量对比,该模型质量与旧默认 `Qwen/Qwen3.6-35B-A3B` 持平, - 速度 0.232 s/行(全评测最快),评测报告见 - `data/experiments/translate_models/REPORT.md`。 -- `subtitle-correction` 节点**不跟随**该默认值,有意保留 - `Qwen/Qwen3.6-35B-A3B`:错听泛化实测新模型 0/4、旧模型 4/4。 -- `llm-filter` 节点的 LLM 五类分类层**默认关闭**(`use_llm=0`): - 实测该层额外删除的 131 条中 56% 是真实对话,净收益为负; - 现只跑确定性规则层,误删真对话从 73 条降为 0 条且无 LLM 调用。 +- LLM 翻译默认使用 SiliconFlow,API Key 放在 gitignored 的 `.env` + (`LLM_API_KEY=sk-...`),启动时自动加载。 +- 环境变量全表与依赖管理见 [docs/configuration.md](./docs/configuration.md)。 + +## 文档导航 + +| 文档 | 内容 | +| --- | --- | +| [docs/architecture.md](./docs/architecture.md) | 目录结构、核心机制(registry/scheduler/前端)、北极星不变式、单体化说明 | +| [docs/node-protocol.md](./docs/node-protocol.md) | 节点输入输出协议表、字幕样式统一、模型权重解析、长音频分块、VLM OCR 并发、暂停断点、前端框选 | +| [docs/workflows.md](./docs/workflows.md) | 内置工作流一览、`_note_` 参数标注约定、decode_full 与幻觉清洗、切换模型、产物命名 | +| [docs/configuration.md](./docs/configuration.md) | 环境变量全表、启动方式、Python/uv 管理 | +| [docs/operations.md](./docs/operations.md) | 孤儿数据清理、任务暂停/继续、进度日志、文件夹批量处理 | +| [docs/testing.md](./docs/testing.md) | 测试结构(按模块目录组织)、测试资产归属、真实模型集成测试 | +| [docs/decisions.md](./docs/decisions.md) | 设计决策与踩坑记录(模型选型、glm-ocr 循环、帧号排序等) | +| [docs/代码审查问题跟踪.md](./docs/代码审查问题跟踪.md) | 缺陷清单 R01–R08、修复记录与验收标准 | +| [docs/VR双目字幕景深与遮挡调查报告.md](./docs/VR双目字幕景深与遮挡调查报告.md) | VR 字幕景深/遮挡方案的调研全文 | +| [docs/调研-whisper漏句与decode_full验证.md](./docs/调研-whisper漏句与decode_full验证.md) | whisper 漏句与 decode_full 的实验记录 | +| [docs/adaptive_vad.md](./docs/adaptive_vad.md) | 每视频自适应 VAD 调参方案 | +| [docs/proper_nouns.md](./docs/proper_nouns.md) | 专有名词处理表 | + +代理协作规则(TDD、提交许可、测试与注释规范、执行环境)见 +[AGENTS.md](./AGENTS.md),该文件只写规则、不含项目信息。 ## 目录 | 路径 | 说明 | | --- | --- | | `src/wov_sdk/` | 协议数据模型(与分布式版兼容) | -| `src/wov_app/` | 应用层:API、注册表、调度器、数据库 | -| `nodes/` | 进程内节点实现(echo/ffmpeg/whisper/llm/ass) | +| `src/wov_app/` | 应用层:API、注册表、调度器、批量引擎、数据库 | +| `nodes/` | 进程内节点实现(echo/ffmpeg/whisper/llm/vlm/ass/ocr/filter) | | `manifests/` | 节点清单 JSON | +| `workflows/` | 默认工作流定义 JSON(模型与链路均为数据) | | `web/` | 静态前端 | | `model/` | 本地模型权重(gitignored) | | `data/` | SQLite 与存储(gitignored) | -| `tests/` | 测试(100% 行覆盖率) | +| `docs/` | 设计与运维文档 | +| `tests/` | 按模块组织的测试(见 [docs/testing.md](./docs/testing.md)) | + +## 内置工作流 + +| ID | 名称 | 链路 | +| --- | --- | --- | +| `zh-direct` | 中文直出字幕 | 提音 → 中文转写 → ASS | +| `ocr-subtitle` | 字幕OCR提取 | 抽帧 → 逐帧 OCR → 汇总 SRT → LLM 过滤 | +| `learn-translate` | 学习资料转译+翻译字幕 | 提音 → 转写(decode_full) → LLM 翻译 → ASS | + +链路细节与参数约定见 [docs/workflows.md](./docs/workflows.md)。 + +## 模型与实验结论摘要 + +- 默认 LLM 为 `Qwen/Qwen3.5-35B-A3B`(2026-09 切换):对同片 2 小时日语 ASR 做 + 8 个模型全量对比,质量与旧默认 `Qwen/Qwen3.6-35B-A3B` 持平,速度 0.232 s/行 + (全评测最快),评测报告见 `data/experiments/translate_models/REPORT.md`。 +- `subtitle-correction` 节点**不跟随**该默认值,有意保留 + `Qwen/Qwen3.6-35B-A3B`:错听泛化实测新模型 0/4、旧模型 4/4。 +- 通用转写模型为 `faster-whisper-large-v2`(2026-09 由 large-v3 切换), + 依据见 [docs/decisions.md](./docs/decisions.md#whisper-large-v2-vs-large-v3-切换)。 +- `llm-filter` 节点的 LLM 五类分类层**默认关闭**(`use_llm=0`): + 实测该层额外删除的 131 条中 56% 是真实对话,净收益为负; + 现只跑确定性规则层,误删真对话从 73 条降为 0 条且无 LLM 调用。 + 详细依据见 [docs/decisions.md](./docs/decisions.md#llm-filter-的-llm-分类层默认关闭)。 ## 测试 @@ -47,8 +95,12 @@ uv run uvicorn wov_app.main:app --reload uv run pytest ``` +测试资产与集成测试说明见 [docs/testing.md](./docs/testing.md), +测试规则(TDD、真实数据要求)见 [AGENTS.md](./AGENTS.md#测试规则)。 + ## 与分布式版的关系 - 协议数据模型、工作流 DAG 数据化、调度拓扑执行保持不变。 - 已移除:子进程节点、节点 HTTP 协议、节点注册/实例管理、TTL 回收。 - 未来回退分布式时,只需为 `registry.invoke` 重新加上进程边界。 +- 详见 [docs/architecture.md](./docs/architecture.md#单体化说明)。 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d9bfa30 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,75 @@ +# 架构 + +VRSub 是"为视频生成 VR 双眼字幕"的单体应用(WOV AI Workflow Platform 的 +单机实现):FastAPI 后端、工作流调度器与全部节点(提音 / 转写 / 翻译 / +ASS)在**同一个进程**内运行,不再启动子进程、不再走节点 HTTP 协议。 +由原分布式多仓库(wov-api / wov-web / wov-sdk / wov-node-*)合并而来。 + +## 目录结构 + +``` +vrsub/ +├── src/wov_sdk/ # 协议数据模型(NodeManifest/InvokeRequest/InvokeResponse/ +│ # WorkflowDefinition 等),与分布式版保持一致 +├── src/wov_app/ # 应用层:main/config/db/registry/scheduler/batch/seed/routers +│ └── routers/ # apps.py(用户端)、workflows.py(管理端)、batch.py(批量处理) +├── nodes/ # 进程内节点实现:echo/ffmpeg/whisper/llm/ass +├── manifests/ # 各节点清单 JSON(echo.json/ffmpeg.json/...) +├── workflows/ # 默认工作流定义 JSON(模型/链路均为数据,改模型不改代码) +├── web/ # 静态前端(index/tasks/admin/workflow + assets) +├── model/ # 本地 whisper 权重(gitignored) +├── data/ # SQLite + 上传/产物存储(gitignored) +├── docs/ # 设计与运维文档(本目录) +└── tests/ # 单元/API/冒烟测试(覆盖核心路径,不强制 100%) +``` + +## 核心机制 + +- **节点注册表**(`src/wov_app/registry.py`):启动时把 `manifests/*.json` 与 + `nodes/*.py` 的 `invoke` 处理器静态注册到进程内字典,调度器按 + `node_type` 直接调用。协议数据模型不变,为将来回退分布式保留兼容桥梁。 +- **调度器**(`src/wov_app/scheduler.py`):后台线程轮询 SQLite 中的 QUEUED + 任务,按工作流 DAG 拓扑顺序调用节点,产物按 + `data/storage/runs//steps//` 落盘并登记到 artifacts 表。 +- **前端**:由 FastAPI 静态挂载 `web/`,节点注册/实例管理页面已移除, + 仅保留应用中心、任务管理、批量处理、管理后台(工作流)与工作流编排。 +- **工作流编排页(web/workflow.html)**:支持新建工作流(空表单预填演示模板), + 从列表"编辑"加载任一工作流的最新定义(ID 锁定,保存即追加新版本);"版本" + 查看全部历史版本并可"加载到编辑器"(对比/回滚后另存新版本);管理后台 + (admin.html)无编辑器,点"编辑"自动跳转 `workflow.html?edit=` 加载。 +- **批量引擎**(`src/wov_app/batch.py`):独立的单线程轮询线程处理 + `source=batch` 的运行,与主调度器互不抢占;详见 + [operations.md](./operations.md#文件夹批量处理)。 + +## 关键设计约束(北极星不变式) + +- 节点之间不直接调用,只通过产物 URI 交换数据;中间产物落在共享存储 + (`data/storage`),不放在节点模块内部。 +- 工作流必须是数据文件或数据库记录(workflow_versions 表存 DAG JSON), + 不允许把步骤顺序写死在应用代码里。 +- 节点注册表是节点调用的唯一入口;API 与调度器不绕过 registry 直接执行 + 节点逻辑。 +- 协议数据模型(wov_sdk)要长期稳定,宁可先少做功能,也不轻易改协议。 +- 存储、队列、调度器都要通过抽象边界隔离,方便从单机实现替换为分布式实现。 +- 用户端永远只看到"输入 -> 进度 -> 结果",不暴露工作流细节。 +- 单体对分布式版的三处降级:无子进程隔离、无空闲 TTL 回收(模型常驻, + 仅懒加载)、非 OCR 慢任务无法强制中断(由节点自身超时兜底;OCR 节点支持 + 暂停信号逐帧中断)。 + +## 单体化说明 + +- 由原 7 个独立仓库合并:wov-api、wov-web、wov-sdk、wov-node-echo、 + wov-node-ffmpeg、wov-node-whisper、wov-node-llm、wov-node-ass。 +- 删除内容:`NodeManager`(子进程生命周期)、节点 HTTP 服务端 + (`wov_sdk.server`)、节点注册/实例管理 API 与页面、node_instances 表、 + 各节点的 `__main__` 进程入口。 +- 保留内容:协议数据模型、工作流 DAG 数据化、调度拓扑执行、上传/进度/下载/ + 重试 API、静态前端、SQLite Repository 层、本地优先模型加载。 + +## 相关文档 + +- 节点输入输出协议、模型权重解析:[node-protocol.md](./node-protocol.md) +- 内置工作流与参数约定:[workflows.md](./workflows.md) +- 环境变量与启动:[configuration.md](./configuration.md) +- 批量处理、暂停/继续、孤儿清理:[operations.md](./operations.md) +- 设计决策与事故记录:[decisions.md](./decisions.md) diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..b9032f2 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,60 @@ +# 配置与启动 + +## 启动 + +```bash +uv sync +uv run uvicorn wov_app.main:app --reload +``` + +访问: + +``` +http://127.0.0.1:8000/ 应用中心(上传视频 → 字幕生成) +http://127.0.0.1:8000/tasks.html 任务管理 +http://127.0.0.1:8000/batch.html 批量处理(按文件夹) +http://127.0.0.1:8000/admin.html 管理后台(工作流) +http://127.0.0.1:8000/workflow.html 工作流编排(DAG JSON) +http://127.0.0.1:8000/docs API 文档 +``` + +## 环境变量 + +| 变量 | 默认值 | 说明 | +| --- | --- | --- | +| `WOV_DATA_DIR` | `<根>/data` | 数据目录 | +| `WOV_DB_PATH` | `<根>/data/wov.db` | SQLite 路径 | +| `WOV_STORAGE_DIR` | `<根>/data/storage` | 上传与产物根目录 | +| `WOV_AUTO_SEED` | `1` | 启动时创建内置工作流 | +| `WOV_SCHEDULER_ENABLED` | `1` | 启动后台调度器 | +| `WOV_SCHEDULER_INTERVAL_SECONDS` | `1.0` | 调度轮询间隔 | +| `WOV_CLEANUP_ENABLED` | `1` | 开启孤儿数据定时清理 | +| `WOV_CLEANUP_INTERVAL_SECONDS` | `3600` | 孤儿清理扫描周期(秒) | +| `WOV_CLEANUP_GRACE_SECONDS` | `3600` | 孤儿清理宽限期(秒) | +| `WOV_BATCH_ENABLED` | `1` | 开启文件夹批量处理引擎(处理 source=batch 任务) | +| `WOV_BATCH_INTERVAL_SECONDS` | `1.0` | 批量引擎轮询间隔 | +| `WOV_AUTO_VAD` | `1` | 开启每视频自适应 VAD 调参(详见 [adaptive_vad.md](./adaptive_vad.md)) | +| `WHISPER_MODEL_PATH` | 见 [模型权重解析](./node-protocol.md#模型权重解析本地优先) | 显式指定 whisper 模型路径 | +| `WHISPER_DEVICE` | `auto` | 转写设备 | +| `LLM_API_BASE` | `https://api.siliconflow.cn/v1/chat/completions` | LLM 兼容接口 | +| `LLM_API_KEY` | 空(读 `.env`) | SiliconFlow Bearer Key,存于 gitignored 的 `.env` | +| `LLM_MODEL` | `Qwen/Qwen3.5-35B-A3B` | LLM 模型名(默认值与例外说明见 [decisions.md](./decisions.md#翻译模型默认值切换与-subtitle-correction-例外)) | +| `LLM_TIMEOUT_SECONDS` | `600` | LLM 单请求超时(llm-filter 内部默认 60) | +| `OLLAMA_HOST` | `http://192.168.123.70:11434` | Ollama 服务地址 | +| `VLM_MODEL` | `glm-ocr:latest` | VLM OCR 模型 | +| `VLM_PROMPT` | 提取图像中的文字,不要描述图片中的内容 | OCR 提示词(字幕流水线在 ocr-subtitle 工作流的 subtitle-ocr 节点参数中显式指定同一提示词) | +| `VLM_TIMEOUT_SECONDS` | `5` | VLM 单请求整体超时上限(每次调用 5 秒,超时即终止;流式读取同样受此截止约束) | +| `FFMPEG_BIN` | 空 | 显式 ffmpeg 路径(否则 PATH → imageio-ffmpeg) | + +## Python 环境与 uv 管理 + +- 统一使用 uv 管理虚拟环境和依赖,禁止直接使用 pip 修改依赖。 +- 基础命令:`uv sync`(安装含 dev 组依赖)、`uv run `、 + `uv add `、`uv lock`。 +- 虚拟环境位于 `.venv`,测试依赖在 `[dependency-groups] dev`。 +- 新增依赖时使用 `uv add`,不修改系统 Python 或全局环境。 + +## 相关文档 + +- 本地模型权重目录:[node-protocol.md](./node-protocol.md#模型权重解析本地优先) +- 运维语义(清理、暂停、批量):[operations.md](./operations.md) diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..6c6d64a --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,78 @@ +# 设计决策与踩坑记录 + +本文件记录"为什么这样做":模型/参数选择的实测依据、历史事故与踩坑。 +**当前生效的规则**以对应专题文档为准(本文件只解释理由): + +- 节点参数与默认值:[node-protocol.md](./node-protocol.md) +- 工作流与模型配置:[workflows.md](./workflows.md) / [configuration.md](./configuration.md) +- 代码缺陷与修复过程:[代码审查问题跟踪.md](./代码审查问题跟踪.md) + +## 帧文件必须按帧号数值排序(14236 帧事故) + +- **现象**:run_339ec7ee437f 的 14236 帧任务中,字幕时间与图像错位。 +- **根因**:ffmpeg `%04d` 编号超过 9999 帧后扩为 5 位,字典序 `sorted()` + 会把 5 位编号排在 4 位之前。 +- **结论**:帧文件必须按帧号数值排序读取(`_sorted_frame_files`), + 回归测试见 `test_frame_files_read_order_matches_frame_number`。 + +## glm-ocr 重复循环问题与源头修复 + +- **根因**:glm-ocr 生成阶段存在已知 bug(M-RoPE delta 未传递,大图触发 + 重复循环;GitHub #454 / #16892)。`keep_alive` 与其无关(实测无效)。 +- **修复路径**(现行防护措施见 + [node-protocol.md](./node-protocol.md#glm-ocr-调用与重复循环防护)): + 1. `frame-extract` 裁切后把帧压缩到 720p 内——过大输入图是触发条件之一。 + 2. `vlm` 请求体加 `repeat_penalty` + `num_predict` 压制重复。 + 3. `subtitle-ocr` 用 `max_result_chars` 把超长输出判为异常并跳过该帧。 + +## llm-filter 的 LLM 分类层默认关闭 + +- **决策(2026-09)**:`llm-filter` 只跑确定性规则层,LLM 五类分类层默认关闭 + (`use_llm=0`),需显式 `use_llm=1` 才启用。 +- **依据(run_ac7f480a3ccb 逐类人工审查)**:LLM 层额外删除的 131 条中 + **56%(73 条)是真实对话**(`好好教育她一番吧`/`腿不要合上`/ + `这家医院 为VIP患者提供了特殊服务`),而它真正抓住而规则层抓不到的仅 + 58 条且大半可正则化(已下沉到规则层);`repeat` 类别 67 条判定零删除; + 长文本保护等五套机制全在给不稳定分类器兜底。 +- **效果**:关闭后真实数据保留 863 条(旧 588 条)、**误删真对话 0 条** + (旧 73 条)、无 LLM 调用。 +- **启用时保留的机制**:每条连同前后各 `context_size`(默认 10)条**过滤后** + 文本判断(上下文净化),≥`min_keep_len`(默认 12)时 noise 不构成删除依据 + (长文本保护),429/5xx 指数退避 + 自适应线程池 `report_failure()` 降并发后 + 重试一轮,判定成功即追加 `filter_partial.jsonl` 断点存档;按文本去重。 +- **复现**:`scripts/regenerate_filter_ac7f480a3ccb.py`, + 回归数据 `tests/nodes/test_llm_filter/data/ocr_srt_run_ac7f480a3ccb.srt`。 + +## 翻译模型默认值切换与 subtitle-correction 例外 + +- **默认切换(2026-09)**:`LLM_MODEL` 由 `Qwen/Qwen3.6-35B-A3B` 改为 + `Qwen/Qwen3.5-35B-A3B`——同片 2 小时日语 ASR 全量对比,质量持平、 + 0.232 s/行(评测中最快)。 +- **例外**:`subtitle-correction` 节点**有意**保留旧兜底 + `Qwen/Qwen3.6-35B-A3B`——该节点错听泛化实测新模型 0/4、旧模型 4/4 + (复现:同一误听场景各跑 4 次),见 `tests/test_llm_default_model.py`。 +- **评测数据**:`data/experiments/translate_models/REPORT.md`, + 工具链 `scripts/bench_translate_models.py` 等。 + +## whisper large-v2 vs large-v3 切换 + +- **决策(2026-09)**:通用转写模型从 large-v3 + 切到 `faster-whisper-large-v2`。 +- **依据**:savr-1054 全片 A/B 实测,无 VAD 幻觉长段归零、开头漏句救回, + VAD 链路条数与覆盖小幅领先。 +- **数据**:`data/experiments/whisper_v2_vs_v3/`, + 脚本 `scripts/compare_whisper_v2_vs_v3.py`, + 调研记录 [调研-whisper漏句与decode_full验证.md](./调研-whisper漏句与decode_full验证.md)。 + +## VR 字幕景深与遮挡方案选型 + +- **结论**:采用 **A-1 零视差**(字幕固定在屏幕平面,不做景深偏移)+ + **B-1 顶部安全区**(`an8` 顶部居中,`margin_top` 默认 700), + 并用半透明填充 + 半透明描边降低遮挡感。 +- **调研全文**:[VR双目字幕景深与遮挡调查报告.md](./VR双目字幕景深与遮挡调查报告.md)。 + +## 相关文档 + +- 当前生效的参数与协议:[node-protocol.md](./node-protocol.md) +- 运维语义与批量流程:[operations.md](./operations.md) +- 缺陷 ID 与验收标准:[代码审查问题跟踪.md](./代码审查问题跟踪.md) diff --git a/docs/node-protocol.md b/docs/node-protocol.md new file mode 100644 index 0000000..3c6c340 --- /dev/null +++ b/docs/node-protocol.md @@ -0,0 +1,189 @@ +# 节点协议 + +节点统一签名 `invoke(request: InvokeRequest) -> InvokeResponse`,通过产物 URI +交换数据(节点之间不直接调用,不共享内存状态)。清单文件位于 +`manifests/*.json`,处理器位于 `nodes/*.py`,由 +`src/wov_app/registry.py` 在启动时静态注册。 + +## 节点输入/输出一览 + +| 节点 ID(node_type) | 输入 | 输出 | 说明 | +| --- | --- | --- | --- | +| `echo` | `text` / `file_uri` | `text`、`file_uri` | 示例节点,验证协议链路 | +| `ffmpeg-extract` | `video_uri` | `audio_uri`(WAV) | 参数:`sample_rate`、`channels` | +| `faster-whisper` | `audio_uri`(16kHz 单声道) | `srt_uri` | 参数:`language`、`task`、`model_path`、`device`、`compute_type`、`beam_size`、`vad_filter`(默认开)、`condition_on_previous_text`、`chunk_seconds` | +| `llm-translate` | `srt_uri` | `cn_srt_uri` | 参数:`target_language`、`model` | +| `vlm-ocr` | `image_uri` | `text`、`text_uri` | 直接调本地 Ollama 多模态模型(glm-ocr)的 `/api/chat` 做视频帧 OCR(流式 + 5s 上限),参数:`model`、`ollama_host`、`prompt`、`timeout_seconds`、`keep_alive`、`num_predict`、`temperature`、`repeat_penalty` | +| `frame-extract` | `video_uri` | `frames_manifest`、`frame_count` | 按**帧间隔**抽帧(解析 fps → step=round(间隔秒×fps),ffmpeg select 按帧号精确取帧,帧时间=帧号/fps 无累计偏差)并 crop 裁切字幕区域,参数:`interval_seconds`(默认 0.5)、`crop`([x,y,w,h] 0~1,**默认画面底部 1/4** `[0,0.75,1,0.25]`——字幕很少出现在画面上半部分,2026-08 调整)。**帧文件必须按帧号数值排序读取**(`_sorted_frame_files`):ffmpeg `%04d` 编号超过 9999 帧后扩为 5 位,字典序 `sorted()` 会把 5 位编号排在 4 位之前导致时间与图像错位(真实发生于 run_339ec7ee437f 的 14236 帧任务,回归测试见 `test_frame_files_read_order_matches_frame_number`) | +| `subtitle-ocr` | `frames_manifest` | `srt_uri`、`count` | 自适应线程池并发逐帧调 vlm-ocr → 垃圾过滤(无文字帧)→ 相同字幕合并(记录最后可见帧)→ 组装 SRT,消失时间=最后可见帧+采样间隔(间隔从帧清单推导),参数:`min_chars`、`min_alnum_ratio`、`garbage_tokens`、`max_result_chars`、`pool_min_workers`/`pool_max_workers`/`pool_window_seconds`/`pool_fast_threshold`/`pool_slow_threshold` | +| `llm-filter` | `srt_uri` | `srt_uri`、`kept`、`removed` | 两级过滤:①**规则层**(不调 LLM,**2026-09 起为默认且唯一启用的层级**)正则确定性删除——横线装饰、URL/邮箱/**裸网址域名**(含中文夹杂的注册地址)、**HTML/水印模式**(html code/标签/javascript 等)、overlay token(html/marketing 等)、单双 ASCII 字符、**水印编号**(SPHO-1/PHO一号馆/NO.1专用)、**日期/数值**(2011-11-27/4.0)、**VLM 提示回显**(no text is visible)、**角色标注**((出演));②**LLM 五类分类**(garbage/overlay/noise 删,repeat/dialogue 留)—— **默认关闭**(`use_llm`,默认 0),需显式 `use_llm=1` 才启用。参数:`context_size`、`min_keep_len`、`overlay_tokens`(JSON 数组)、`dedupe`(默认开)、`use_llm`(默认 0)、`model`、`pool_*`。关闭原因与实测数据见 [decisions.md](./decisions.md#llm-filter-的-llm-分类层默认关闭),回归数据见 [testing.md](./testing.md) | +| `srt-to-dual-eye-ass` | `cn_srt_uri` | `ass_uri` | 参数:`resolution`(如 `3840x1920`)、`margin_top`(顶部安全边距,**默认 700**——2026-09 调整:120 落在画面最顶需抬头看,700 使字幕处于视线自然可读位置)。左右眼各占左右半幅且水平相对位置一致(**A-1 零视差**:字幕固定在屏幕平面,不做景深偏移);对齐 `an8` 顶部居中 + `MarginV=margin_top`(**B-1 顶部安全区**,避开画面中央人脸区);文字填充 `&HB3FFFFFF`(约 70% 透明)描边 `&H80000000`(半透明黑),降低遮挡感 | +| `subtitle-correction` | `srt_uri` | `srt_uri` | 字幕领域纠错(专名/误听),参数:`model`。默认模型**有意**保留 `Qwen/Qwen3.6-35B-A3B`(不跟随 `LLM_MODEL` 全局默认),原因见 [decisions.md](./decisions.md#翻译模型默认值切换与-subtitle-correction-例外) | + +## 字幕样式统一(2026-09 起) + +`nodes/ass.py` 顶部的 `DEFAULT_MARGIN_TOP=700` 与左右眼样式常量是**单一事实来源**: +新生成的字幕(`write_ass`/`invoke`)与历史字幕统一脚本共用 `ass_header()`/ +`style_row()`/`dialogue_line()` 同一出口,两边永不漂移。历史媒体库里由旧版本 +批量生成的 `*.CN_dual_eye.ass` 混有多种旧样式(底部 an2 实心白 / 底部半透明 / +顶部 120),用 `scripts/unify_ass_style.py` 统一原地改写为当前新样式: + +```bash +# 先 dry-run 预览将改哪些文件(默认不改盘) +uv run python scripts/unify_ass_style.py /mnt/fnOS/123 +# 确认无误后真正改写(原地,不产生 .bak) +uv run python scripts/unify_ass_style.py /mnt/fnOS/123 --apply +``` + +脚本解析旧文件分辨率与全部 Dialogue 事件后经 `ass_header()`/`dialogue_line()` +重建,输出与代码新产物**逐字节一致**;非 VR 字幕(无 LeftEye/RightEye 样式行) +自动跳过。相关测试见 `tests/test_unify_ass_style.py`。 + +## 模型权重解析(本地优先) + +whisper 节点按以下顺序解析模型路径,默认避免从远端下载: + +1. 请求参数 `model_path`;裸模型名(不含路径分隔符)会在 `model/<名称>` 下解析。 +2. 环境变量 `WHISPER_MODEL_PATH`。 +3. 本地候选目录(存在且含 `model.bin` 即使用): + - 单体根目录 `model/faster-whisper-large-v2`。 + - `nodes/model/faster-whisper-large-v2`。 +4. 兜底:`large-v2`(需要联网从 Hugging Face 下载)。 + +把权重放在 `model/` 目录即可完全离线运行。 + +**全系统只使用 Whisper V2 权重**(2026-09 决定,V3 已全面停用);V3 判据是 +`preprocessor_config.json` 的 `feature_size`(V2=80,V3=128,不依赖目录名)。 + +当前已下载模型: + +**在用(V2)** + +- `model/faster-whisper-large-v2`:通用转写模型(learn-translate 工作流的 + `model_path`)。2026-09 从 large-v3 切换:savr-1054 全片 A/B 实测无 VAD + 幻觉长段归零、开头漏句救回,VAD 链路条数与覆盖小幅领先,见 + `data/experiments/whisper_v2_vs_v3/`。 +- `model/whisper-large-v2-translate-zh-v0.2-st-ct2`:中文直出模型 + (`chickenrice0721/whisper-large-v2-translate-zh-v0.2-st-ct2`),配合 + `task=translate` 直接生成中文,无需 LLM 翻译(zh-direct 工作流)。 + +**已废弃(V3,保留在盘上仅作对照实验,不得用于生产与测试)** + +- `model/faster-whisper-large-v3`:旧通用转写模型(`feature_size=128`)。 +- `model/whisper-large-v3-translate-zh-v0.1-lt-ct2`:早期中文直出模型。 + +对照实验脚本:`scripts/compare_whisper_v2_vs_v3.py`。测试侧由 +`tests/nodes/test_whisper/` 的 `_v2_model_candidates()` 自动排除 V3。 + +## 长音频处理 + +**当前策略:分块转写,默认每 1 分钟一块**(`chunk_seconds=60`,2026-08 调整)。 +whisper 节点内部用 ffmpeg 把音频切成块 → 逐块转写 → 按偏移合并为完整 SRT: + +- 内存/显存有界(模型 + 单块音频),任意时长可处理,失败粒度小。 +- 分块是**应用层工程策略**,与模型训练格式无关:whisper 训练/推理都按 30s + 窗口解码,任意块大小均适用。 +- 每块 `offset = 块序号 × chunk_seconds`,SRT 序号连续;切块失败自动回退 + 整段单次转写。 +- `chunk_seconds=0` 可关闭分块;大小按工作流 DAG 参数(数据)调整。 +- 同时默认 `condition_on_previous_text=false`(每块/每窗口独立解码,防重复)。 +- **`vad_filter` 默认开启**(2026-08 用户决定):过滤静音段提速并减少无语音处 + 幻觉。注意 VAD 靠压缩时间轴回映射(SpeechTimestampsMap),长静音场景曾实测 + 错位(30s 静音致第二段语音从 ~40s 落到 10s);如需极致对齐可显式传 + `vad_filter=false`。 +- **分块偏移按每块实际时长累积**(WAV 头精确):ffmpeg 切出的块实际时长不等于 + 块长(如 60.05s),用 `块序号×块长` 的假设值会随块数累积漂移;改为按真实 + 时长累加后,字幕时间轴与原始音频严格一致。 +- 参考:openai/whisper 重复问题(issue #1026/#1046,PR #1052/#1253)、 + SYSTRAN/faster-whisper issue #465。 + +## glm-ocr 调用与重复循环防护 + +- **glm-ocr 调用结构**:走 Ollama `/api/chat`,识别指令放**系统提示词**, + 用户消息只携带图片(content 为空、images 传 base64),`stream=True` + 逐行接收,`stop: ["\n", "\n答", "答"]` 命中即停止(输出首个换行即停 + + 阻止“答:”式重复循环),`temperature` 默认 0.3、`repeat_penalty` 默认 1、 + `num_predict`(默认 256)随请求透传。 +- **防护措施**: + 1. `frame-extract` 裁切后把帧**压缩到 720p 内**(仅缩小,保持宽高比)—— + 过大输入图是触发重复循环的条件之一。 + 2. `vlm` 请求体 `options.repeat_penalty`(默认 1.2)+ `num_predict` + (默认 256)压制重复。 + 3. `subtitle-ocr` 增加 `max_result_chars`(默认 200):模型输出超长视为 + 异常(重复循环等),**直接报错并跳过该帧**。 +- **gettext 标签防御性提取**(2026-08):若模型输出含 `` + 标签(旧提示词要求)则取第一个标签内文本,多个标签取第一个防重复循环; + 未按格式输出时回退原文。当前默认提示词为"提取图像中的文字,不要描述 + 图片中的内容"(字幕流水线在 ocr-subtitle 工作流的 subtitle-ocr 节点参数 + 中显式指定,经 subtitle-ocr 透传给 vlm-ocr)。 +- **每次调用 5 秒上限**(2026-08 调整):vlm 请求 `stream=True` 逐行读取, + 每次调用整体受 5 秒截止时间约束(`timeout_seconds` 参数 / + `VLM_TIMEOUT_SECONDS`,默认 5),超过即终止返回 failed,不再等待后续 + 流式块。 +- **不做文本加工**:除协议要求的 gettext 标签提取外,不再对模型输出做 + 过滤/去重等文本加工,结果原样使用,仅受长度上限约束。 + +问题根因与修复史见 [decisions.md](./decisions.md#glm-ocr-重复循环问题与源头修复)。 + +## VLM OCR 自适应并发(2026-08) + +subtitle-ocr 逐帧调 vlm-ocr 时使用 `nodes/adaptive_pool.py` 的自适应线程池 +弹性并发: + +- 从 `pool_min_workers`(默认 1)起步,按**滚动窗口**(`pool_window_seconds`, + 默认 10s)统计已完成任务的平均响应时间; +- 平均响应 < `pool_fast_threshold`(默认 0.3s)→ 线程数 +1(上限 + `pool_max_workers`,默认 16)——服务端空闲就加大并发加速处理; +- 平均响应 > `pool_slow_threshold`(默认 1.0s)→ 线程数 -1(下限 1)—— + 服务端变慢就退避,避免盲目并发压垮本地 Ollama; +- 结果按帧顺序返回,SRT 时间轴不受并发影响;worker 需无共享可变状态 + (vlm-ocr 处理器为纯函数,线程安全)。 + +**并发实现约定(审查 R02)**:`AdaptiveThreadPool` 使用标准线程执行器复用线程, +由 `map` 控制在途任务数,不一次性把全片任务压入执行器队列。上面的“线程数” +及进度日志中的 N 指目标在途并发额度,不是执行器已创建的线程总数。降低目标后, +已发出的请求允许完成,后续提交立即遵守新额度;不再向积压队列尾部追加退出哨兵。 +`report_failure()` 只保持或降低当前额度,绝不因上限从 20 降到 19 就把当前 1 +并发扩为 19。错误窗口不扩容,干净窗口逐步恢复有效上限;有效上限跨重试 `map` +保留,每批重新统计耗时窗口。进度回调与结果汇总由 map 线程串行处理,窗口未满 +时平均耗时取 worker 实际耗时均值。`cancel()` 保持原约定,仅抑制进度回调, +节点自行检测暂停并返回异常。相关回归见 `tests/test_adaptive_pool.py`, +修复背景见 [代码审查问题跟踪.md](./代码审查问题跟踪.md#r02-修复记录)。 + +## 翻译条目对齐(审查 R05) + +`llm-translate` 经 `nodes/srt.py` 按 cue 解析(支持 BOM/CRLF、多行、空正文), +以全局位置 ID 的 JSON `{id,text}` 数组请求翻译;时间戳不进入模型。返回的 ID +集合、类型、唯一性和非空正文必须校验通过,乱序结果按 ID 回填。结构错误最多 +尝试 3 次,耗尽返回 failed,不再在末尾合并或补空。空 cue 不调模型但保留时间轴; +原有长时幻觉清洗继续生效。短句真实 LLM 校准见翻译对齐测试,修复背景见 +[代码审查问题跟踪.md](./代码审查问题跟踪.md#r05--r06-修复记录)。 + +## OCR 空帧与故障恢复(审查 R06) + +相同字幕只合并相邻帧,空帧结束当前段。OCR 临时失败/异常不进入成功存档, +失败帧降并发后重试一轮,仍失败则节点 failed,成功帧保留供恢复。JSONL 新增 +`status=completed`(含成功空文字)或 `skipped`(超长输出按既有规则跳过)。 +旧存档非空结果复用;无状态的旧空串可能由超时产生,重新识别一次。全量 14236 +帧回归以真实单线程新结果 1942 条为基线,原 1666 条历史文件保留供比对, +新增逐帧覆盖检查防止跨空白合并,不再要求与旧错误时间轴逐字节一致。 +修复背景见 [代码审查问题跟踪.md](./代码审查问题跟踪.md#r05--r06-修复记录)。 + +## 任务参数覆盖(前端框选) + +创建任务时可携带可选 `params` 表单字段(JSON):`{"节点ID": {"参数": 值}}`, +随任务持久化(param_overrides),调度执行时合并进对应节点参数。字幕 OCR +前端把框选的 `crop` 按此传给 `frame-extract` 节点。 + +### 前端 OCR 框选 + +首页选择工作流后,若 DAG 中存在声明 `crop` 参数的节点(frame-extract), +自动切换到框选面板:视频预览 + 拖动框选字幕区域 → 生成 crop 比例 → +框选完成后才可提交(未框选时提交按钮禁用)。矩形↔crop 换算为纯函数 +(`web/assets/crop.js`,含 letterbox 处理),由 node 单测覆盖。 + +## 相关文档 + +- 内置工作流定义与参数标注约定:[workflows.md](./workflows.md) +- 环境变量(模型、LLM、VLM 等):[configuration.md](./configuration.md) +- 测试资产与回归夹具:[testing.md](./testing.md) diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..f440cc5 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,130 @@ +# 运维 + +## 孤儿数据清理 + +应用内置后台清理器(`src/wov_app/maintenance.py`),按周期自动清理死数据: + +- **自动删除**:无任务记录的上传/步骤残留目录;COMPLETED 且产物文件全部丢失、 + 超过宽限期(默认 1 小时)的任务记录(下载已全部 404)。 +- **绝不自动删除**:FAILED 任务(可重试)、QUEUED/RUNNING 任务、宽限期内的任务、 + 仍有产物文件的任务。 +- 手动删除任务仅通过删除接口(`DELETE /api/runs/{run_id}`)或管理界面进行。 + +## 任务暂停/继续(2026-08) + +- **状态机**:`QUEUED / RUNNING / PAUSED / COMPLETED / FAILED`。排队中或运行中的 + 任务可暂停(`POST /api/runs/{run_id}/pause`),PAUSED 可继续 + (`POST /api/runs/{run_id}/resume` → 恢复 QUEUED)。 +- **调度器语义**:`next_queued_run` **只取 QUEUED**——PAUSED 任务不会被调度器 + 自动拾起(修复回归:此前 PAUSED 被拾起后 `execute_run` 先置 RUNNING 再检查, + 节点循环读到的是刚改的 RUNNING,"暂停检查"永远不成立 → 任务被复活继续跑, + 表现为"点击暂停反而开始任务")。`execute_run` 以 PAUSED 进入时直接返回保持暂停, + 必须用户显式 resume(PAUSED → QUEUED)后才真正执行;运行中被暂停的任务在每个 + 节点边界检查状态停下保持 PAUSED(当前节点执行完后才停);继续时从产物表 + (`restore_run_outputs`,剥去"节点ID."前缀还原输出名)重建已完成节点的输出, + **跳过已完成节点断点续跑**,最后补做 final_outputs 收尾。 +- **重启恢复**:进程被杀/重启时遗留的 RUNNING 任务在启动时被 + `recover_interrupted_runs` 恢复为 QUEUED(保留产物),调度器自动断点续跑; + PAUSED 任务保持不变,等待显式 resume。 +- **节点级断点(subtitle-ocr)**:OCR 每帧完成后立即把 `{frame, text}` 追加到 + `steps/ocr/ocr_partial.jsonl`(多线程下加锁串行化)。invoke 启动时读取存档, + 只对未处理帧调用 vlm-ocr,存档文本与新增结果合并后组装 SRT——2 小时视频级 + OCR 任务中断/重启后不重复已处理帧,产物与一次跑完逐字节一致。 +- **节点内暂停响应(subtitle-ocr)**:暂停接口(`POST /api/runs/{run_id}/pause`) + 除置 PAUSED 外还向 run 根目录写入 `paused.flag`;OCR 工作线程**逐帧检查**该 + 信号,存在即立即中止(不 OCR、不入存档,恢复时重跑该帧),invoke 返回 + failed;调度器捕获节点异常时若任务已是 PAUSED 则**保持 PAUSED 不标 FAILED**, + resume 时清除信号并从断点存档继续——点击暂停后 OCR 秒级停下,不再等整个 + 节点跑完。继续/重试接口与调度器执行前都会清理残留信号。 +- **前端**:任务管理页为 QUEUED/RUNNING 提供"暂停"、PAUSED 提供"继续"按钮。 + +## 进度日志(数据处理速度) + +- 调度器:每节点完成打印"任务 X 进度 i/N 节点: Y 耗时 Zs, 运行累计 Ws"; +- subtitle-ocr:`OCR 进度 X/Y 帧 (Z 帧/s, 平均 Ws/帧, 线程 N/M)`; + llm-filter:`字幕判定进度 X/Y 条 (Z 条/s, 平均 Ws/条, 线程 N/M)` + ——`W` 为最近窗口平均单任务耗时(窗口未满时回退累计平均),`N` 为当前目标 + 线程数、`M` 为 `pool_max_workers` 上限,用于判断多线程是否因单次处理过慢 + (窗口平均 ≥ `pool_fast_threshold`)而未扩容(线程池 `on_progress` 回调, + 每任务完成触发); +- whisper:分块转写打印"分块 X/Y 完成 offset=... 耗时 Zs (Nx 实时, 累计 ...s)"; +- frame-extract:ffmpeg `-progress` 输出解析 `frame=N`,打印"抽帧进度 X/Y 帧 (Z 帧/s)"。 + +## 文件夹批量处理 + +本地版核心能力:**不把视频上传到工作目录**,直接读取用户所选文件夹下的全部 +视频,逐个执行所选流水线。入口为批量处理页(`web/batch.html`,导航"批量处理"), +后端为 `src/wov_app/batch.py` 的 `BatchWorker`(单线程轮询线程,处理 +`source=batch` 的运行,与主调度器互不抢占)与 `routers/batch.py`。 + +- **路径选择**:批量页点击"选择文件夹…"按钮弹出目录树选择器(懒加载),选完 + 回填只读路径框。浏览器拿不到所选文件夹的绝对路径,因此由**本地后端**提供目录 + 浏览:`GET /api/batch/roots`(Windows 盘符 / POSIX 根 + 家目录)、 + `GET /api/batch/dirs?path=`(列直接子目录,隐藏目录过滤;不存在/不可读返回 + 空列表不报 500)。只暴露目录名,不返回文件内容。 +- **创建任务时一次性定位(2026-09 起)**:`POST /api/batch/jobs {folder, + workflow_id, recursive}` 只扫描一次文件夹并把每个视频登记为 `batch_videos` + 明细(PENDING/RUNNING/PAUSED/COMPLETED/FAILED/SKIPPED)。**视频所在目录 + (视频旁)若已存在文件名含视频名的字幕文件**(`.srt/.ass/.ssa/.vtt`, + `list_sidecar_subtitles` 判定,如 `movie.CN.srt`、`movie.CN_dual_eye.ass`), + 说明该视频已有字幕,创建即记 **SKIPPED**——不为它触发任何流水线。运行时 + `BatchWorker` **只消费这批已定位的明细,不再重新扫描文件夹**(运行期间新增/ + 删除的视频不会改变本次任务的范围)。校验失败(文件夹不存在/未发布工作流/ + 无版本/一个视频都没有)返回 422。 +- **产物放在视频旁**:每个视频处理完成后,把工作流 `final_outputs` 对应的最终 + 产物文件(字幕流水线即中文 `.srt` 与双目 `.ass`)**复制一份到视频所在目录**, + 与 .mp4 放在一起(`_place_products`)。文件名**对齐媒体库既有约定**:中文字幕 + 存为 `<视频名>.CN.srt`、双目字幕存为 `<视频名>.CN_dual_eye.ass`(稳定无时间戳, + `_sidecar_product_name` 映射,其余扩展名产物保留原文件名;同名目标直接覆盖)。 + 文件名含视频主名,下次批量扫描会命中"已有字幕"规则直接跳过该视频。 +- **成品放置校验(审查 R03)**:先预检全部 `final_outputs` 对应的记录与文件, + 缺任一项即失败,不开始覆盖视频旁成品;全部齐备后逐文件原子替换。复制失败时 + 视频记 FAILED,保留 run、工作空间和已放置的完整成品,供修复后幂等重试。 + 原子替换保证单文件完整,不代表多个成品的跨文件事务或断电持久性。详见 + [代码审查问题跟踪.md](./代码审查问题跟踪.md#r03-修复记录)。 +- **过程文件清理(2026-09 起)**:视频收尾完成后删除该视频的整个工作空间与 + run 记录(音频/分块/帧图/节点产物不留残),防止媒体库把切片数据当视频入库。 + 工作空间位于**应用私有目录** `data/storage/batch///` + (不再放视频同名文件夹),与用户视频库天然隔离;暂停/失败的视频保留工作空间 + 以便断点续跑。per-video 的 `WorkflowScheduler` 实例以该目录为 storage—— + 完整复用 DAG 拓扑执行、产物表登记与**断点续跑**逻辑。 +- **暂停/继续**:`POST /api/batch/jobs/{id}/pause` 把任务置 PAUSED 并暂停当前 + run(写 `paused.flag`;whisper **分块间**检查、OCR 逐帧检查后中止,当前节点 + 执行完才停);`resume` 恢复 QUEUED,引擎从断点继续——PAUSED 视频的 run 显式 + resume 后从产物表续跑,未开始的视频接着处理。重启进程后 RUNNING 残留 run 由 + `recover_interrupted_runs` 恢复,暂停的继续处理。 +- **失败容错**:单个视频失败(节点失败/文件缺失)记为 FAILED,批量任务继续 + 处理后续视频,结束后统计 done/failed;DAG 解析/任务级异常把任务置 FAILED。 + **重跑保留产物**(2026-08,修复 run_e2b74e89e232 实测):FAILED 视频重新处理 + 时不再 reset_run 清空产物记录,而是保留已完成节点的 artifacts 恢复 QUEUED, + execute_run 从产物表跳过已完成节点、只重跑失败节点——extract/ocr 等长耗时 + 成果不浪费;配合 llm-filter/OCR 的节点级断点存档,失败节点自身也只重判未完成 + 条目。前端对"部分失败"(COMPLETED 且 failed>0)用红色徽章醒目标示。 +- **完成任务判定(2026-09 修复)**:`_run_job` 置 COMPLETED 前**校验全部非 + SKIPPED 视频都已结束**(无 PENDING/PAUSED 残留),否则保持 RUNNING 交引擎 + 下一轮续跑——修复僵尸状态:引擎串行处理到 9.9GB 大视频时中断,`_run_job` + 无条件收尾把任务置 COMPLETED,留下"N 个 PENDING 待处理却已完成"的假完成 + (batch_969fabe74b83 等 3 个任务实测:遗留的 10 个 PENDING 完全相同且卡在 + kiwvr-887 大文件前)。**崩溃恢复**:重启时除 `recover_interrupted_runs` 外, + 新增 `recover_interrupted_batch_jobs` 把 RUNNING 的批量任务恢复为 QUEUED + (否则停在 RUNNING 的批量任务永远不会被 `next_queued_batch_job` 再次拾起, + 未处理完的 PENDING 永久残留)。历史僵尸数据修复脚本见 + `scripts/fix_zombie_batch_jobs.py`(把误标 COMPLETED 的任务置回 QUEUED 续跑)。 +- **产物下载**:`GET /api/batch/jobs/{id}/videos/{vid}/download?alias=<文件名>` + 解析并返回视频旁的字幕文件;旧版 `batch.done.json` 完成标记里的语义别名 + (位于旧 work_dir)仍兼容可下载。详情/创建响应里每个视频的 `finals` 合并上述 + 两处来源。 +- **孤儿清理保护**:`source=batch` 的运行**跳过**自动清理——其 run 位于私有 + `storage/batch/...` 下,普通孤儿逻辑会误判删除,且 `_remove_run` 还会删除 + `input_uri` 的父目录(用户的整个视频文件夹)。详见 + [代码审查问题跟踪.md](./代码审查问题跟踪.md#r01-修复记录)。 +- **删除任务**:`DELETE /api/batch/jobs/{id}` 清理数据库记录(含关联 run)与 + 应用私有工作空间残留;视频旁已放置的产物属于用户数据,保留不删。 +- **环境变量**:`WOV_BATCH_ENABLED`(默认 1)、`WOV_BATCH_INTERVAL_SECONDS` + (默认 1.0),完整列表见 [configuration.md](./configuration.md)。 + +## 相关文档 + +- 环境变量全表:[configuration.md](./configuration.md) +- 产物命名与 finalization 规则:[workflows.md](./workflows.md#最终产物命名) +- 历史缺陷与修复记录:[代码审查问题跟踪.md](./代码审查问题跟踪.md) diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..c3e3ae6 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,186 @@ +# 测试 + +测试代码位于 `tests/`,运行方式: + +```bash +uv run pytest # 全部测试 +uv run pytest tests/nodes/test_whisper # 只跑某个模块目录 +uv run pytest tests/app/test_routers # 只跑某组 API 测试 +uv run pytest --cov=src --cov=nodes # 需要覆盖率时手动追加 +``` + +测试的**规则要求**写在 [../AGENTS.md](../AGENTS.md#测试规则):模块边界、 +按模块组织、数据/过程/验证三段结构、功能覆盖优先、真实数据与真实生产代码。 + +## 目录结构与模块对应 + +测试按**模块**组织:被测代码的层级与模块名直接映射到 `tests/` 下的目录。 +一个模块 = 一个模块目录;单文件过长时在模块目录**内部**按行为拆文件。 + +``` +tests/ +├── nodes/ # 对应 nodes/ +│ ├── test_srt/ # SRT 解析/序列化(parsing + data/) +│ ├── test_whisper/ # 转写:模型解析/分块/时间轴/幻觉清洗 +│ ├── test_ass/ # SRT → VR 双目 ASS +│ ├── test_ffmpeg/ # ffmpeg 定位 + 提音(真实视频) +│ ├── test_frame_extract/ # 抽帧/crop/帧号排序(含 frames_boundary 回归) +│ ├── test_vlm/ # 单帧 OCR(真实图片 + Ollama 集成) +│ ├── test_subtitle_ocr/ # 逐帧 OCR 汇总、断点、暂停(14236 帧夹具) +│ ├── test_llm/ # 翻译:ID 对齐/重试/专名注入 +│ ├── test_llm_filter/ # 规则层 + LLM 层(真实 OCR 回归数据) +│ ├── test_subtitle_cleanup/ # 幻觉删除 + 短呻吟过滤 +│ ├── test_subtitle_correction/# 领域纠错(上下文/误听泛化) +│ ├── test_proper_nouns/ # 专名/隐语规则表 +│ ├── test_adaptive_pool/ # 自适应并发额度 +│ ├── test_vad_profiler/ # VAD 信号分析与评分 +│ └── test_echo/ # 示例节点 +├── app/ # 对应 src/wov_app/ +│ ├── test_db/ # SQLite Repository +│ ├── test_scheduler/ # DAG 调度、断点、暂停 +│ ├── test_batch/ # 批量扫描/放置/引擎 +│ ├── test_maintenance/ # 孤儿清理 +│ ├── test_registry/ # 节点注册表(唯一调用入口) +│ ├── test_seed/ # 内置工作流加载 +│ ├── test_storage/ # 原子复制 +│ ├── test_config/ # 环境变量与路径(子进程验证) +│ ├── test_logging/ # 日志配置 +│ ├── test_main/ # 应用装配与生命周期 +│ └── test_routers/ # 三组 API(apps / workflows / batch) +├── sdk/test_models/ # 对应 src/wov_sdk/(协议数据模型) +├── web/test_crop/ # 对应 web/assets/(框选几何换算) +└── shared/ # 跨模块公共设施 + ├── realdata_contract.py # 真实数据契约与对齐量化 + ├── srt_entries.py # 按秒解析 SRT + ├── env_isolation.py # 环境/临时目录隔离 + ├── data/alignment/ # 跨模块共享的真实素材 + 参考字幕 + ├── test_srt_entries/ # 公共设施自身的模块测试 + └── test_alignment/ # 真实语音时间对齐集成测试 +``` + +规则要点(完整表述见 [../AGENTS.md](../AGENTS.md#测试规则)): + +- 每个模块目录含 `__init__.py`,因此不同模块下的同名测试文件可共存。 +- 每个测试写成**数据 → 测试过程 → 验证结果**三段,可单独运行。 +- **不保留全局 `conftest.py`**(已删除):隔离由模块自己的 fixture 完成 + (`tests/shared/env_isolation.py` 或模块内 `fixture`)。 +- 测试过程调用真实生产代码(`nodes/`、`wov_app`、`wov_sdk`), + 只自行构建测试数据并验证输出;mock 仅用于 I/O 边界与模型推理。 + +## 测试数据 + +测试媒体一次性准备后**随模块目录入库**,测试直接复用,禁止在执行时现场生成; +缺失时跳过而非现场生成。小体量数据(JSON 片段、期望输出)直接写在测试代码内。 + +| 素材 | 内容 | 归属模块 | +| --- | --- | --- | +| `data/speech_60s.wav` | 真实语音(合法 16kHz 单声道 WAV) | `tests/nodes/test_whisper/` | +| `data/clip_with_audio.mp4` | 10 秒真实视频 + 真实语音音轨(提音用例) | `tests/nodes/test_ffmpeg/` | +| `data/subtitle_10s.mp4` | 烧录 SUB 001/SUB 002 的 10s 视频(无音轨,验证失败路径) | `tests/nodes/test_ffmpeg/`、`tests/nodes/test_frame_extract/` | +| `data/frames_boundary/*.png` | 跨越 9999 帧边界的真实帧文件名(14236 帧事故回归) | `tests/nodes/test_frame_extract/` | +| `data/ocr_text.png` / `ocr_notext.png` | 有文字帧 / 无文字帧 | `tests/nodes/test_vlm/` | +| `data/test_real_hav_sub.png` | 真实视频字幕截图(期望识别出"还有没有什么困扰 或者奇怪的地方吗") | `tests/nodes/test_vlm/` | +| `data/ocr_srt_run_ac7f480a3ccb.srt` | 真实任务 1666 条 OCR 输出(规则层回归基线) | `tests/nodes/test_llm_filter/` | +| `data/frames_manifest_full.json` + `ocr_frames_full.json` | 真实任务 **全部 14236 帧**清单与逐帧 OCR 文本 | `tests/nodes/test_subtitle_ocr/` | +| `data/sample.reference.srt` | 真实参考字幕(5 条,含装饰行) | `tests/nodes/test_srt/` | +| `data/alignment/*.mp4` + `*.reference.srt` | 真实音视频 + 人工校对参考字幕(大体积 mp4 已 gitignored) | `tests/shared/data/alignment/` | +| `scripts/data/translate_eval/*.jsonl` | 翻译模型评测集(评测脚本专用) | `scripts/` | + +## 真实模型集成测试 + +使用真实模型/服务 + 真实素材验证端到端行为;本地缺模型或素材时跳过, +有则必须执行,作为对替身单测的校准。 + +**外部环境状态的两种处理**(都不算代码缺陷): + +| 状态 | 处理 | +| --- | --- | +| 未配置密钥、账户余额/配额、限流(HTTP 401/402/403/429)、服务不可达 | `tests/shared/llm_service.py` 统一识别并跳过 | +| GPU 显存不足 / CUDA OOM | `tests/shared/gpu_memory.py` 运行时探测并跳过 | + +`gpu_memory` 的两层防护:运行前按权重体量估算(实测 V2 权重 2.87GB → 峰值 +增量约 4.1GB,系数 1.45)判断能否跑完;运行中若仍发生 CUDA OOM(临界卡上 +的分配碎片化)则转为跳过。`fits_with_margin()` 进一步区分"显存充裕"与"临界": +充裕时走生产默认的分块路径,临界时退化为整段单次推理,避免整组跳过。 + +**权重版本**:测试只使用 **V2** 权重。V3 已全面停用(均改用 V2),即使留在 +盘上也不得被测试使用;判据是 `preprocessor_config.json` 的 `feature_size` +(V2=80,V3=128),不依赖目录名。 + +| 模块目录 | 覆盖内容 | +| --- | --- | +| `tests/nodes/test_whisper/` | 真实 faster-whisper **V2** 模型端到端转写 | +| `tests/nodes/test_vlm/` | 真实 Ollama glm-ocr(有文字帧 + 无文字帧) | +| `tests/nodes/test_subtitle_ocr/` | 真实 14236 帧清单重组装一致性 | +| `tests/nodes/test_llm/` | 真实 LLM 翻译(专名不硬译) | +| `tests/nodes/test_llm_filter/` | 真实 OCR 输出规则层回归(保留 863 / 删除 803 基线) | +| `tests/nodes/test_subtitle_correction/` | 真实 LLM 误听泛化 | +| `tests/shared/test_alignment/` | 真实语音转写 vs 人工参考字幕的时间对齐量化 | + +## 功能模块覆盖清单 + +规则要求**追求功能性代码覆盖率 100%、每个独立功能模块必须被测试覆盖** +(不追求行覆盖率 100%,大模块内辅助函数由主功能用例顺带覆盖即可, +完整表述见 [../AGENTS.md](../AGENTS.md#功能覆盖优先不追求代码覆盖率))。 +本表是新增/修改模块时的核对清单:**新增独立功能模块必须同时补测试并更新本表**。 + +| 模块 | 职责 | 覆盖测试 | 状态 | +| --- | --- | --- | --- | +| `nodes/echo.py` | 示例回显节点 | `tests/nodes/test_echo/` | 已覆盖 | +| `nodes/ffmpeg.py` | 提音与 ffmpeg 定位 | `tests/nodes/test_ffmpeg/`(真实视频) | 已覆盖 | +| `nodes/whisper.py` | 转写、分块、模型解析、清洗入口 | `tests/nodes/test_whisper/`(含真实模型)+ `tests/shared/test_alignment/` | 已覆盖 | +| `nodes/llm.py` | LLM 翻译节点 | `tests/nodes/test_llm/`(含真实 LLM) | 已覆盖 | +| `nodes/ass.py` | SRT → VR 双目 ASS | `tests/nodes/test_ass/` | 已覆盖 | +| `nodes/vlm.py` | 单帧 OCR(Ollama) | `tests/nodes/test_vlm/`(含真实模型) | 已覆盖 | +| `nodes/frame_extract.py` | 抽帧与 crop | `tests/nodes/test_frame_extract/`(真实视频 + 帧号边界) | 已覆盖 | +| `nodes/subtitle_ocr.py` | 逐帧 OCR → 汇总 SRT、断点/暂停 | `tests/nodes/test_subtitle_ocr/`(14236 帧) | 已覆盖 | +| `nodes/adaptive_pool.py` | 自适应并发线程池 | `tests/nodes/test_adaptive_pool/` | 已覆盖 | +| `nodes/llm_filter.py` | 字幕规则/LLM 两级过滤 | `tests/nodes/test_llm_filter/` | 已覆盖 | +| `nodes/subtitle_cleanup.py` | 幻觉与短呻吟清洗 | `tests/nodes/test_subtitle_cleanup/` | 已覆盖 | +| `nodes/subtitle_correction.py` | 字幕领域纠错 | `tests/nodes/test_subtitle_correction/` | 已覆盖 | +| `nodes/proper_nouns.py` | 专名/隐语规则表 | `tests/nodes/test_proper_nouns/` | 已覆盖 | +| `nodes/srt.py` | SRT 解析/序列化/时间戳 | `tests/nodes/test_srt/` | 已覆盖 | +| `nodes/vad_profiler.py` | VAD 信号分析与评分 | `tests/nodes/test_vad_profiler/` | 已覆盖 | +| `wov_app/registry.py` | 节点注册表(唯一调用入口) | `tests/app/test_registry/` | 已覆盖 | +| `wov_app/scheduler.py` | DAG 调度与断点续跑 | `tests/app/test_scheduler/` | 已覆盖 | +| `wov_app/batch.py` | 文件夹批量引擎 | `tests/app/test_batch/` | 已覆盖 | +| `wov_app/db.py` | SQLite Repository | `tests/app/test_db/` | 已覆盖 | +| `wov_app/main.py` | 应用装配与生命周期 | `tests/app/test_main/` | 已覆盖 | +| `wov_app/routers/apps.py` | 用户端 API | `tests/app/test_routers/test_apps_api.py` | 已覆盖 | +| `wov_app/routers/workflows.py` | 管理端工作流 API | `tests/app/test_routers/test_workflows_api.py` | 已覆盖 | +| `wov_app/routers/batch.py` | 批量任务 API | `tests/app/test_routers/test_batch_api.py` | 已覆盖 | +| `wov_app/seed.py` | 内置工作流加载 | `tests/app/test_seed/` | 已覆盖 | +| `wov_app/maintenance.py` | 孤儿数据清理 | `tests/app/test_maintenance/` | 已覆盖 | +| `wov_app/storage.py` | 原子复制 | `tests/app/test_storage/` | 已覆盖 | +| `wov_app/config.py` | 路径与环境配置 | `tests/app/test_config/`(子进程验证真实解析) | 已覆盖 | +| `wov_app/logging.py` | 日志装配 | `tests/app/test_logging/` | 已覆盖 | +| `wov_app/schemas.py` | 请求模型(Pydantic) | `tests/app/test_main/`(schema 用例) | 已覆盖 | +| `wov_sdk/models.py` | 协议数据模型 | `tests/sdk/test_models/` | 已覆盖 | +| `web/assets/crop.js` | 框选几何换算 | `tests/web/test_crop/`(真实 node 执行) | 已覆盖 | +| `tests/shared/srt_entries.py` | 按秒解析 SRT(测试公共设施) | `tests/shared/test_srt_entries/` | 已覆盖 | +| `tests/shared/realdata_contract.py` | 真实数据契约与对齐量化 | `tests/shared/test_alignment/` | 已覆盖 | +| `tests/shared/env_isolation.py` | 环境/临时目录隔离 | 被 `tests/app/test_config` 等间接覆盖 | 已覆盖(间接) | + +核对方式(查看某模块是否真被执行): + +```bash +uv run pytest tests/ -q --cov=wov_app --cov-report=term-missing +``` + +## 重构历史(已完成) + +按新规则对 `tests/` 做了一次完整重写:旧平铺测试(40 个文件、约 10900 行) +已删除,旧内容仅作为**参考与测试数据来源**;现有测试全部按模块目录重写。 +重构期间发现并修复的真实缺陷: + +| 缺陷 | 发现方式 | 修复 | +| --- | --- | --- | +| `parse_srt` 在相邻条目缺少空行时把下一条时间轴吞进正文(静默错位) | `tests/nodes/test_srt/` 的红灯用例 | 正文行遇时间戳行即报错(`nodes/srt.py`) | +| `scheduler._file_size` 只捕获 `OSError`,含 `\\x00` 的产物 URI 抛 `ValueError` 导致任务误判失败 | `tests/app/test_batch/` 端到端用例 | 同时捕获 `ValueError`(`src/wov_app/scheduler.py`) | +| 生产节点 `subtitle_correction` 依赖测试包 `tests.realdata_contract` 解析 SRT | 模块化拆分时发现 | 改用生产模块 `nodes/srt.py` 的严格解析器 | + +## 相关文档 + +- 测试规则(模块边界、三段结构、功能覆盖、TDD):[../AGENTS.md](../AGENTS.md#测试规则) +- 缺陷回归要求与验收标准:[代码审查问题跟踪.md](./代码审查问题跟踪.md) +- 模型/参数选择的实验依据:[decisions.md](./decisions.md) diff --git a/docs/workflows.md b/docs/workflows.md new file mode 100644 index 0000000..cee5b9a --- /dev/null +++ b/docs/workflows.md @@ -0,0 +1,68 @@ +# 内置工作流 + +工作流是**数据**:默认定义存放在 `workflows/*.json`,启动时由 seed 写入 +workflow_versions 表;切换模型或调整链路只改数据,不改代码。 + +## 工作流一览 + +| ID | 名称 | 链路 | 说明 | +| --- | --- | --- | --- | +| `zh-direct` | 中文直出字幕 | 提音 → 中文转写 → ASS | 中文直出模型,无 LLM 步骤 | +| `ocr-subtitle` | 字幕OCR提取 | 抽帧 → 逐帧 OCR → 汇总 SRT → LLM 过滤 | 提取烧录字幕做基准数据;前端框选 crop;LLM 过滤多余/无意义字幕 | +| `learn-translate` | 学习资料转译+翻译字幕 | 提音 → 转写(decode_full) → LLM 翻译 → ASS | 面向讲解/学习类视频;应用 decode_full 无 VAD 整段解码 + 日语幻觉清洗,优先"说了的话不漏"(弱语音/快速讲解召回),再由幻觉清洗移除无语音段长套话 | + +## 工作流参数标注约定 + +JSON 不支持注释,因此"参数理由"以节点 `params` 内 `_note_<参数名>` 键存放 +(`_` 前缀说明键,节点执行时只读真实参数键、忽略 `_note_*`,零运行影响); +节点级参数手册放 `params._node_help`(多行字符串,含关键参数解释与正反例)。 +`WorkflowNode.from_dict` 会完整保留 params 全部键(不清洗未知键),seed 入库/ +前端展示均不丢。查看方式:管理后台/工作流编排页打开工作流 definition JSON +即可见每个参数旁的理由说明。该约定由 learn-translate 示范,可复用到任何工作流。 + +## decode_full 与幻觉/呻吟清洗 + +**decode_full 参数**(faster-whisper 节点):默认 `false`(保持 VAD 现状); +置 `true` 时强制无 VAD 整段解码并跳过自动 VAD 分析,救回被 silero VAD 当非语音剔除的 +弱语音/呻吟/BGM 混叠人声(实测 savr-1054 全片 115 条 → 340 条),副作用为无语音段 +长时寒暄幻觉,处理方式:whisper 转录后立即**连带时间戳把整条 cue 删除**(剩余重编号, +见 `nodes/subtitle_cleanup.py` 的 `clean_japanese_hallucinations`),不留下 `-` 占位污染 +下游(占位会渲染进 ASS 成可见减号);llm-translate 翻译后同样整条删除中文长时寒暄 +幻觉(`clean_srt_text`)。短时(≤15s)相同词可能是剧情真实道晚安,保留。 + +**短呻吟过滤**(2026-09):decode_full 救回的弱语音中混有大量**纯语气词碎片** +(あ…/ん?/はぁ…/あ!あ!/んふふ 等 ≤3 假名),影响字幕观感;whisper 转录后按 +'全部字符 ∈ 纯呻吟字符集合(`MOAN_CHARS`)且有效假名数 ≤ `short_moan_max_chars`(默认 3,设 0 关闭)' +判据**整条删除**(`remove_short_moan_entries`)。集合刻意排除 そ/こ/ね/や/ば/だ +等假名,真实短对话(そこ/やばい/ねえ/やだ/えへへ)天然不命中。仅 decode_full +生效,learn-translate 等 VAD 链路不受影响。 + +调研过程与结论见 [调研-whisper漏句与decode_full验证.md](./调研-whisper漏句与decode_full验证.md)。 + +## 切换模型不改代码 + +- 模型是工作流 DAG 中 asr 节点的 `model_path` 参数(**数据**),内置工作流 + 均已显式声明:learn-translate 用 `faster-whisper-large-v2`, + zh-direct 用中文直出模型。 +- 切换模型 = 改 `workflows/*.json` 或管理页面 DAG JSON → 保存新版本 → 发布, + 全程不涉及代码;新库启动时从 JSON 重新 seed。 +- 默认工作流定义存放在 `workflows/*.json`(数据文件),代码只负责加载。 +- 模型解析顺序与本地权重目录见 + [node-protocol.md](./node-protocol.md#模型权重解析本地优先)。 + +## 最终产物命名 + +最终产物按 `上传文件名.标识.时间戳` 命名(如 `test01.zh-CN.20260815123000.srt`), +标识优先取节点的 `target_language` 参数,否则用产物别名。**审查 R03 修复**: +保留节点原始文件及 URI,把成品副本存入 `runs//finals/output-<编码别名>/`; +时间戳固定取 run 创建时间,重复收尾覆盖相同路径,多个别名分目录避免冲突。 +复制先写同目录临时文件,再原子替换目标,失败不登记残缺文件;`final_outputs` +声明的引用或文件缺失时任务失败,不能标完成。旧版本原文件已改名但最终别名记录 +仍指向有效文件时允许复用;原文件与成品都丢失时明确报错。批量场景下成品还会 +复制到视频旁,命名约定见 [operations.md](./operations.md#文件夹批量处理)。 + +## 相关文档 + +- 节点参数与 I/O:[node-protocol.md](./node-protocol.md) +- 默认模型选择与例外:[decisions.md](./decisions.md) +- 环境变量(`LLM_MODEL` 等):[configuration.md](./configuration.md) diff --git a/docs/代码审查问题跟踪.md b/docs/代码审查问题跟踪.md index 028ebe5..415a48b 100644 --- a/docs/代码审查问题跟踪.md +++ b/docs/代码审查问题跟踪.md @@ -40,9 +40,9 @@ - 根因:普通任务删除接口未区分 `source=batch`,直接递归删除 `Path(input_uri).parent`;自动清理器也使用输入路径推导删除目录。 - 处理约定:普通删除接口拒绝批量 run(422),提示通过批量任务入口删除;上传任务只清理 `/uploads/` 和 `/runs/`。批量任务已有专用删除入口,保留源视频和视频旁成品。 -- 测试要求:使用 `testdata/` 真实视频与字幕副本、临时数据库及私有存储;覆盖批量任务拒绝删除、正常上传清理、历史外部输入路径保护和自动清理路径保护。 +- 测试要求:使用各模块 `data/` 下的真实视频与字幕副本、临时数据库及私有存储;覆盖批量任务拒绝删除、正常上传清理、历史外部输入路径保护和自动清理路径保护。 - 实现:[普通删除接口](../src/wov_app/routers/apps.py) 在删除前拒绝批量 run;[自动清理器](../src/wov_app/maintenance.py) 和普通接口均按私有目录布局定位清理范围。 -- 回归测试:[test_run_deletion_safety.py](../tests/test_run_deletion_safety.py),覆盖五种状态的批量 run、外部输入路径与旁挂字幕保护、其他任务目录保护。 +- 回归测试:`tests/app/test_batch/`(批量删除与旁挂字幕保护)、`tests/app/test_maintenance/`(自动清理路径保护)、`tests/app/test_routers/test_apps_api.py`(删除接口拒绝批量 run)。测试代码已按模块化重构,原平铺文件 `tests/test_run_deletion_safety.py` 已删除,用例覆盖范围不变(详见 [testing.md](./testing.md#重构历史已完成))。 - TDD 红:`uv run pytest tests/test_run_deletion_safety.py -q --tb=short`,7 failed;批量 run 被错误删除,两个外部路径用例复现媒体文件丢失。 - TDD 绿及相关回归:`uv run pytest tests/test_run_deletion_safety.py tests/test_apps_api.py tests/test_batch.py tests/test_maintenance.py -q`,73 passed,7.73 秒;仅有既存 Starlette/httpx 弃用警告。 - 状态:修复及验证完成,纳入 `fix/review-improvements` 分支;未部署。运行中的旧进程需加载新代码后才能获得保护。 diff --git a/docs/调研-whisper漏句与decode_full验证.md b/docs/调研-whisper漏句与decode_full验证.md index 248a5a6..1be8c80 100644 --- a/docs/调研-whisper漏句与decode_full验证.md +++ b/docs/调研-whisper漏句与decode_full验证.md @@ -13,7 +13,7 @@ 用户反馈:whisper 转写时**动态配置参数**(自动 VAD 调参),生成的字幕中有时出现 "有人说话但没识别出来"的情况,怀疑与动态参数配置有关。 -链路(demo/learn-translate):提音(ffmpeg-extract) → 转写(faster-whisper) → 翻译(llm) → ASS。 +链路(当时为 demo/learn-translate,demo 已于 2026-09 移除):提音(ffmpeg-extract) → 转写(faster-whisper) → 翻译(llm) → ASS。 whisper 节点关键动态逻辑(改动前): - `vad_filter=true`(默认开启) diff --git a/scripts/check_doc_links.py b/scripts/check_doc_links.py new file mode 100644 index 0000000..ae27eee --- /dev/null +++ b/scripts/check_doc_links.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""校验 vrsub 文档中的相对 Markdown 链接是否都指向真实文件。 + +用法:uv run python scripts/check_doc_links.py(在 vrsub 目录下执行) +职责:扫描 README.md、AGENTS.md 与 docs/*.md 中的所有相对链接 +(含 #锚点),确认目标文件存在、锚点能在目标文件中找到对应标题。 +退出码非 0 表示存在断链,供 CI 或提交前检查使用。 +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +# Markdown 链接语法 [文本](目标),目标不含协议头即为仓库内相对链接 +LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)") + +# GitHub 风格锚点生成:小写、去反引号与标点、空格转连字符(此处按中文文档习惯保留中文) +_ANCHOR_STRIP = re.compile(r"[^\w\u4e00-\u9fff\s-]") + + +def slug(title: str) -> str: + """把 Markdown 标题转成锚点 id,规则与 GitHub/Gitea 一致。""" + text = title.strip().lower() + text = _ANCHOR_STRIP.sub("", text) + return text.replace(" ", "-") + + +def collect_anchors(path: Path) -> set[str]: + """收集文件中全部标题的锚点,用于校验 #片段。""" + anchors: set[str] = set() + for line in path.read_text(encoding="utf-8").splitlines(): + if line.startswith("#"): + anchors.add(slug(line.lstrip("#").strip())) + return anchors + + +def main() -> int: + root = Path(__file__).resolve().parent.parent + files = [root / "README.md", root / "AGENTS.md", *sorted((root / "docs").glob("*.md"))] + problems: list[str] = [] + for md in files: + if not md.is_file(): + problems.append(f"缺少文档:{md.relative_to(root)}") + continue + for target in LINK_RE.findall(md.read_text(encoding="utf-8")): + if target.startswith(("http://", "https://", "mailto:")): + continue + path_part, _, anchor = target.partition("#") + resolved = (md.parent / path_part).resolve() if path_part else md.resolve() + # 目录链接(如 ./docs/)视为可达,只要求路径存在 + if path_part and not resolved.exists(): + problems.append(f"{md.relative_to(root)} -> 文件不存在:{target}") + continue + if anchor and resolved.suffix == ".md" and anchor not in collect_anchors(resolved): + problems.append(f"{md.relative_to(root)} -> 锚点不存在:{target}") + if problems: + print("\n".join(problems)) + print(f"\n共 {len(problems)} 处断链") + return 1 + print(f"检查 {len(files)} 个文档,全部链接与锚点可达") + return 0 + + +if __name__ == "__main__": + sys.exit(main())