532 lines
41 KiB
Markdown
532 lines
41 KiB
Markdown
# VRSub(单体版)
|
||
|
||
本仓库是"为视频生成 VR 双眼字幕"的单体应用(WOV AI Workflow Platform 的
|
||
单机实现):FastAPI 后端、工作流调度器与全部节点(提音 / 转写 / 翻译 /
|
||
ASS)在**同一个进程**内运行,不再启动子进程、不再走节点 HTTP 协议。
|
||
由原分布式多仓库(wov-api / wov-web / wov-sdk / wov-node-*)合并而来,
|
||
本 AGENTS.md 汇总了各仓库的约定与规范。
|
||
|
||
## 提交规则(强制,2026-08)
|
||
|
||
- **未经用户明确许可,禁止执行 `git commit` / `git push`**,包括"小步提交"。
|
||
- 完成改动后只汇报改动内容与验证结果,等待用户指示;用户明确说出
|
||
"提交 / push / 推上去" 等指令时才可执行提交。
|
||
- 需要用户确认的场景:新增功能、修复、文档改动、工作流/清单数据改动等
|
||
一切涉及 git 写操作的行为。
|
||
|
||
## 架构概览
|
||
|
||
```
|
||
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%)
|
||
```
|
||
|
||
### 核心机制
|
||
|
||
- **节点注册表**(`src/wov_app/registry.py`):启动时把 `manifests/*.json` 与
|
||
`nodes/*.py` 的 `invoke` 处理器静态注册到进程内字典,调度器按
|
||
`node_type` 直接调用。协议数据模型不变,为将来回退分布式保留兼容桥梁。
|
||
- **调度器**(`src/wov_app/scheduler.py`):后台线程轮询 SQLite 中的 QUEUED
|
||
任务,按工作流 DAG 拓扑顺序调用节点,产物按
|
||
`data/storage/runs/<run_id>/steps/<node_id>/` 落盘并登记到 artifacts 表。
|
||
- **前端**:由 FastAPI 静态挂载 `web/`,节点注册/实例管理页面已移除,
|
||
仅保留应用中心、任务管理、批量处理、管理后台(工作流)与工作流编排。
|
||
- **工作流编排页(web/workflow.html)**:支持新建工作流(空表单预填演示模板),
|
||
从列表"编辑"加载任一工作流的最新定义(ID 锁定,保存即追加新版本);"版本"
|
||
查看全部历史版本并可"加载到编辑器"(对比/回滚后另存新版本);管理后台
|
||
(admin.html)无编辑器,点"编辑"自动跳转 `workflow.html?edit=<id>` 加载。
|
||
|
||
## 节点输入/输出协议
|
||
|
||
节点统一签名 `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)正则确定性删除——横线装饰、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`)、`margin_top`(顶部安全边距,**默认 700**——2026-09 调整:120 落在画面最顶需抬头看,700 使字幕处于视线自然可读位置)。左右眼各占左右半幅且水平相对位置一致(**A-1 零视差**:字幕固定在屏幕平面,不做景深偏移);对齐 `an8` 顶部居中 + `MarginV=margin_top`(**B-1 顶部安全区**,避开画面中央人脸区);文字填充 `&HB3FFFFFF`(约 70% 透明)描边 `&H80000000`(半透明黑),降低遮挡感 |
|
||
|
||
|
||
#### 字幕样式统一(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/` 目录即可完全离线运行。当前已下载模型:
|
||
|
||
- `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/<run_id>/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):若模型输出含 `<gettext></gettext>`
|
||
标签(旧提示词要求)则取第一个标签内文本,多个标签取第一个防重复循环;
|
||
未按格式输出时回退原文。当前默认提示词为"提取图像中的文字,不要描述
|
||
图片中的内容"(字幕流水线在 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.6-35B-A3B` | LLM 模型名 |
|
||
| `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 <command>`、
|
||
`uv add <package>`、`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/<job_id>/<bv_id>/`
|
||
(不再放视频同名文件夹),与用户视频库天然隔离;暂停/失败的视频保留工作空间
|
||
以便断点续跑。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 冒烟测试补充。
|
||
- 本地出现 `WinError 10013` / `WinError 10048` 时,先用
|
||
`netstat -ano | findstr :<port>` 确认是否有残留监听进程。
|
||
|
||
## 代码注释规范
|
||
|
||
- 本仓库所有源码(Python、JavaScript、HTML、CSS、TOML 等支持注释的文件)
|
||
必须配有详细中文注释,说明模块/文件职责、核心类与函数的作用以及关键逻辑,
|
||
确保后续维护人员无需通读全部实现即可快速理解工作原理。
|
||
- 新增或修改代码时,必须同步补充或更新对应注释;不得删除已有注释。
|
||
- 测试代码同样必须配有中文注释,说明每条测试验证的行为与覆盖的路径。
|
||
- JSON 数据文件(`manifests/*.json`)按 JSON 规范不支持注释,字段语义以
|
||
`src/wov_sdk/models.py` 的 `NodeManifest` 模型注释和本文档为准;
|
||
修改 JSON 字段时须同步更新文档。
|
||
|
||
## 目标运行环境
|
||
|
||
- 本服务的最终部署目标是 Linux,通常以 Docker/Kubernetes 容器运行。
|
||
- 当前 Windows 只作为本地开发环境,不允许在业务代码中写死 Windows 路径、
|
||
盘符或 Windows 专用命令。
|
||
- 路径处理统一使用 `pathlib`。
|
||
- ffmpeg 在 Linux 上可使用系统包,也允许通过 `imageio-ffmpeg` 使用内置
|
||
二进制,节点代码不能假设 ffmpeg 一定在 PATH。
|
||
- 测试必须可以在 Windows 和 Linux 上运行;涉及平台分支的代码应同时覆盖
|
||
两种路径解析。
|
||
|
||
## Windows / PowerShell 执行规则
|
||
|
||
- 默认 shell 视为 Windows PowerShell 5.1;不要假设 Bash、zsh 或 PowerShell 7。
|
||
必要时先查 `$PSVersionTable.PSVersion`。
|
||
- 禁止把 Bash 语法交给 PowerShell:`python - <<'PY'`、`cat <<EOF`、`export`、
|
||
`source`、`rm -rf`、Bash 后台 `&` 等。
|
||
- PowerShell 中 `&` 是调用运算符;URL 或参数含 `&` 时整体单引号引用。
|
||
- 避免 PowerShell 5.1 下使用 Bash 风格 `&&` / `||`;顺序步骤用多行 PowerShell。
|
||
- 参数含空格、括号、中文、`&|;><$` 或引号时默认用单引号。
|
||
- 外部程序路径可能有空格时,用 `& 'C:\path with spaces\tool.exe' arg1`。
|
||
- 文件操作优先 PowerShell 原生命令和 `-LiteralPath`。
|
||
- 复杂 Python 不用 `python -c`;涉及 SQL、JSON、中文、反斜杠路径、换行或
|
||
多层引号时,用仓库脚本或临时 `.py` 文件。
|
||
- 禁止在 PowerShell 用 Bash here-doc。临时传 Python 源码只允许 PowerShell
|
||
here-string,且尽量保持 ASCII。
|
||
- Python 源码含中文常量时,不通过 PowerShell 管道传给 `python -`;用 UTF-8
|
||
脚本文件、仓库脚本或 `\uXXXX`。
|
||
- 搜索文本/文件优先 `rg` / `rg --files`。
|
||
- 数据库或生产内容写操作前先查询当前数据;写入必须有明确筛选条件,禁止
|
||
无条件 `DELETE` / `UPDATE`。
|
||
- 同一 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 层、本地优先模型加载。
|