diff --git a/AGENTS.md b/AGENTS.md index c58ac11..f5089c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,8 @@ ASS)在**同一个进程**内运行,不再启动子进程、不再走节点 vrsub/ ├── src/wov_sdk/ # 协议数据模型(NodeManifest/InvokeRequest/InvokeResponse/ │ # WorkflowDefinition 等),与分布式版保持一致 -├── src/wov_app/ # 应用层:main/config/db/registry/scheduler/seed/routers -│ └── routers/ # apps.py(用户端)、workflows.py(管理端) +├── 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(模型/链路均为数据,改模型不改代码) @@ -40,7 +40,7 @@ vrsub/ 任务,按工作流 DAG 拓扑顺序调用节点,产物按 `data/storage/runs//steps//` 落盘并登记到 artifacts 表。 - **前端**:由 FastAPI 静态挂载 `web/`,节点注册/实例管理页面已移除, - 仅保留应用中心、任务管理、管理后台(工作流)与工作流编排。 + 仅保留应用中心、任务管理、批量处理、管理后台(工作流)与工作流编排。 - **工作流编排页(web/workflow.html)**:支持新建工作流(空表单预填演示模板), 从列表"编辑"加载任一工作流的最新定义(ID 锁定,保存即追加新版本);"版本" 查看全部历史版本并可"加载到编辑器"(对比/回滚后另存新版本);管理后台 @@ -58,9 +58,9 @@ vrsub/ | `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)。**帧文件必须按帧号数值排序读取**(`_sorted_frame_files`):ffmpeg `%04d` 编号超过 9999 帧后扩为 5 位,字典序 `sorted()` 会把 5 位编号排在 4 位之前导致时间与图像错位(真实发生于 run_339ec7ee437f 的 14236 帧任务,回归测试见 `test_frame_files_read_order_matches_frame_number`) | +| `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)正则确定性删除——横线装饰、URL/邮箱/**裸网址域名**(含中文夹杂的注册地址)、**HTML/水印模式**(html code/标签/javascript 等)、overlay token(html/marketing 等)、单双 ASCII 字符;②**LLM 五类分类**(garbage/overlay/noise 删,repeat/dialogue 留,未识别回退保留)每条连同前后各 `context_size`(默认 10)条纯文本分批判断——**上下文净化**:喂给 LLM 的是**过滤后的字幕**,规则层确定性垃圾从上下文中剔除(原文不进 LLM),避免覆盖层垃圾污染场景判断误删真实对话(回归:run_011d01f19999 曾 190 条含 ≥4 汉字对话被误删);**长文本保护**:≥`min_keep_len`(默认 12)时 noise 不构成删除依据——LLM 判定不稳定,长度是必要兜底(实测移除保护后新增误删 124 条真实长对话)。**限流自适应**:LLM 调用 429/5xx 指数退避重试(最多 3 次,1s/2s/4s),worker 捕获限流错误时调用线程池 `report_failure()` **内存中临时降低最大线程数并缩容**(连续无错误窗口后逐步回升),失败条目在收紧后的并发下**重试一轮**,二次仍失败才整体失败——20 并发一拥而上触发 429 时自动收敛到配额内而不打挂任务。**按文本去重**(忽略空白/大小写,相同文本只调一次 LLM,上下文取首次出现)保证判定一致并省调用。参数:`context_size`、`min_keep_len`、`overlay_tokens`(JSON 数组)、`dedupe`(默认开)、`model`、`pool_min_workers`/`pool_max_workers`/`pool_window_seconds`/`pool_fast_threshold`/`pool_slow_threshold`。回归数据:testdata/ocr_srt_run_ac7f480a3ccb.srt(真实任务 1666 条 OCR 输出) | +| `llm-filter` | `srt_uri` | `srt_uri`、`kept`、`removed` | 两级过滤:①**规则层**(不调 LLM)正则确定性删除——横线装饰、URL/邮箱/**裸网址域名**(含中文夹杂的注册地址)、**HTML/水印模式**(html code/标签/javascript 等)、overlay token(html/marketing 等)、单双 ASCII 字符;②**LLM 五类分类**(garbage/overlay/noise 删,repeat/dialogue 留,未识别回退保留)每条连同前后各 `context_size`(默认 10)条纯文本分批判断——**上下文净化**:喂给 LLM 的是**过滤后的字幕**,规则层确定性垃圾从上下文中剔除(原文不进 LLM),避免覆盖层垃圾污染场景判断误删真实对话(回归:run_011d01f19999 曾 190 条含 ≥4 汉字对话被误删);**长文本保护**:≥`min_keep_len`(默认 12)时 noise 不构成删除依据——LLM 判定不稳定,长度是必要兜底(实测移除保护后新增误删 124 条真实长对话)。**限流自适应**:LLM 调用 429/5xx 指数退避重试(最多 3 次,1s/2s/4s),worker 捕获限流错误时调用线程池 `report_failure()` **内存中临时降低最大线程数并缩容**(连续无错误窗口后逐步回升),失败条目在收紧后的并发下**重试一轮**,二次仍失败才整体失败——20 并发一拥而上触发 429 时自动收敛到配额内而不打挂任务。**节点级断点存档**(2026-08):每条判定成功立即追加 `filter_partial.jsonl`(`{"index","category"}`,多线程加锁串行化),失败/中断后重跑只重判未判定条目,已判定结果复用(与 OCR 存档同机制)。**按文本去重**(忽略空白/大小写,相同文本只调一次 LLM,上下文取首次出现)保证判定一致并省调用。参数:`context_size`、`min_keep_len`、`overlay_tokens`(JSON 数组)、`dedupe`(默认开)、`model`、`pool_min_workers`/`pool_max_workers`/`pool_window_seconds`/`pool_fast_threshold`/`pool_slow_threshold`。回归数据:testdata/ocr_srt_run_ac7f480a3ccb.srt(真实任务 1666 条 OCR 输出) | | `srt-to-dual-eye-ass` | `cn_srt_uri` | `ass_uri` | 参数:`resolution`,如 `3840x1920` | ### 模型权重解析(本地优先) @@ -190,6 +190,8 @@ subtitle-ocr 逐帧调 vlm-ocr 时使用 `nodes/adaptive_pool.py` 的自适应 | `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 兼容接口 | @@ -274,6 +276,53 @@ http://127.0.0.1:8000/docs API 文档 (线程池 `on_progress` 回调,每任务完成触发); - whisper:分块转写打印"分块 X/Y 完成 offset=... 耗时 Zs (Nx 实时, 累计 ...s)"; - frame-extract:ffmpeg `-progress` 输出解析 `frame=N`,打印"抽帧进度 X/Y 帧 (Z 帧/s)"。 +## 文件夹批量处理(2026-08) + +本地版核心能力:**不把视频上传到工作目录**,直接读取用户所选文件夹下的全部 +视频,逐个执行所选流水线。入口为批量处理页(`web/batch.html`,导航"批量处理"), +后端为 `src/wov_app/batch.py` 的 `BatchWorker`(单线程轮询线程,处理 +`source=batch` 的运行,与主调度器互不抢占)与 `routers/batch.py`。 +- **路径选择(2026-08 起不用手敲路径)**:批量页点击"选择文件夹…"按钮弹出 + 目录树选择器(懒加载),选完回填只读路径框。浏览器拿不到所选文件夹的绝对 + 路径,因此由**本地后端**提供目录浏览:`GET /api/batch/roots`(Windows 盘符 / + POSIX 根 + 家目录)、`GET /api/batch/dirs?path=`(列直接子目录,隐藏目录 + 过滤;不存在/不可读返回空列表不报 500)。只暴露目录名,不返回文件内容。 +- **数据落盘**:每个视频的中间态(`runs//steps/...`)与最终产物都放在 + **视频所在目录的同名文件夹**(`movie.mp4` → `movie/`,去掉扩展名, + `work_dir_for` 推导);最终产物在任务完成后从节点产物目录**复制**到同名 + 文件夹根目录,并写 `batch.done.json` 完成标记(记录 workflow_id 与产物文件名)。 +- **已处理过的不再处理**:同名文件夹已有**同工作流**完成标记且产物文件齐全 → + 直接 SKIPPED;不同工作流的标记不互相误判(换流水线会重新处理)。 +- **任务参数**:`POST /api/batch/jobs {folder, workflow_id, recursive}` 创建批量 + 任务(校验文件夹/已发布工作流/有版本/至少一个视频,失败 422);任务入 + `batch_jobs` 表,每个视频一行 `batch_videos`(PENDING/RUNNING/PAUSED/ + COMPLETED/FAILED/SKIPPED)。可用任意已发布流水线(demo / zh-direct / + ocr-subtitle 等),前端下拉选择。 +- **执行复用**:每个视频创建一个 `source=batch` 的 run(`input_uri` 直接指向 + 本地视频路径),用 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)用红色徽章醒目标示。 +- **产物下载**:`GET /api/batch/jobs/{id}/videos/{vid}/download?alias=result` + 从完成标记解析产物文件并返回(只读同名文件夹根目录)。 +- **孤儿清理保护**:`source=batch` 的运行**跳过**自动清理——产物不在主存储 + 目录下,普通孤儿逻辑会误删记录并连带删除 `input_uri` 的父目录(用户的整个 + 视频文件夹)。 +- **删除任务**:`DELETE /api/batch/jobs/{id}` 只清理数据库记录(含关联 run), + 磁盘上的同名文件夹与产物属于用户数据,保留不删。 +- **环境变量**:`WOV_BATCH_ENABLED`(默认 1)、`WOV_BATCH_INTERVAL_SECONDS` + (默认 1.0)。 + ## 测试与覆盖率