feat: VR字幕默认MarginV改为700并统一历史字幕样式

- srt-to-dual-eye-ass: 默认顶部安全边距 margin_top 120→700(2026-09),
  120落在画面最顶需抬头看,700为实测合适值、视线自然可读
- nodes/ass.py: 样式定义抽为单一事实来源(DEFAULT_MARGIN_TOP/颜色常量
  +ass_header/style_row/dialogue_line),新生成字幕与历史统一脚本共用同一
  出口,以后调样式只改一处、两边永不漂移
- 新增scripts/unify_ass_style.py: 解析旧文件分辨率与全部Dialogue后经
  ass_header/dialogue_line重建,把媒体库历史样式(底部an2实心白1200/
  半透明1020/顶部120)原地统一为an8顶部对齐+70%透明+MarginV=700;
  默认dry-run预览,--apply写盘;非VR字幕自动跳过
- /mnt/fnOS/123 库401个.CN_dual_eye.ass已全部改写(0失败),269764条
  字幕事件无an2残留,二次运行幂等
- 测试: ass默认值断言120→700,新增invoke默认700用例;新增
  tests/test_unify_ass_style.py(历史三世代收敛/与write_ass逐字节一致/
  幂等/CLI dry-run与apply)
This commit is contained in:
2026-09-06 18:02:31 +08:00
parent 44a607b636
commit 4d2a1912da
5 changed files with 614 additions and 45 deletions
+20 -1
View File
@@ -61,8 +61,27 @@ vrsub/
| `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 tokenhtml/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`(顶部安全边距,默认 120)。左右眼各占左右半幅且水平相对位置一致(**A-1 零视差**:字幕固定在屏幕平面,不做景深偏移);对齐 `an8` 顶部居中 + `MarginV=margin_top`(**B-1 顶部安全区**,避开画面中央人脸区2026-09);文字填充 `&HB3FFFFFF`(约 70% 透明)描边 `&H80000000`(半透明黑),降低遮挡感 |
| `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 节点按以下顺序解析模型路径,默认避免从远端下载: