Compare commits
3
Commits
5c12bbcb74
...
a032855210
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a032855210 | ||
|
|
fcdcfe020b | ||
|
|
2e8bbb15a4 |
@@ -61,7 +61,7 @@ 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 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` |
|
||||
| `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`(半透明黑),降低遮挡感 |
|
||||
|
||||
### 模型权重解析(本地优先)
|
||||
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
# VR 双目字幕景深与遮挡问题调查报告
|
||||
|
||||
> 调查日期:2026-09
|
||||
> 范围:`vrsub` 单体版 `srt-to-dual-eye-ass` 节点(`nodes/ass.py`)生成的 VR 双眼
|
||||
> 字幕在头显中"景深/位置漂移、挡住人脸"问题的原理与配置/解决方案。
|
||||
> 文档性质:原理调查报告 + 可选配置方案;**不含代码改动**(改动需另行评审)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 问题现象与现状定位
|
||||
|
||||
### 1.1 用户观察到的现象
|
||||
|
||||
用 VR 头显观看 SBS(左右眼各半幅并排)双目视频时:
|
||||
|
||||
1. 字幕在虚拟环境中"自带"一个深度位置,时常与视频主体(人脸/近景物体)**不在同一景深**,
|
||||
眼睛在字幕与画面主体之间来回对焦、会聚,有"飘浮感"或疲劳感;
|
||||
2. 字幕有时**正好压在人物面部**上,遮挡人脸,影响观看。
|
||||
|
||||
### 1.2 当前生成逻辑(代码事实)
|
||||
|
||||
`nodes/ass.py` 的做法是:
|
||||
|
||||
- 输出一个 `PlayResX×PlayResY`(默认 `3840x1920`)的 ASS 画布;
|
||||
- 为**同一句字幕文案**生成两条 Dialogue,分别套用 `LeftEye` / `RightEye` 两个样式;
|
||||
- `LeftEye` 样式的文本区域落在**左半幅**(`MarginL=50, MarginR=1920`),
|
||||
`RightEye` 落在**右半幅**(`MarginL=1920, MarginR=50`);
|
||||
- 两眼的垂直位置完全一致(`MarginV = height/2 + 60`,`{\an2}` 底部中央对齐);
|
||||
- 左右两半幅中的字幕**横向相对位置也完全一致**。
|
||||
|
||||
播放器把 SBS 帧的左半幅给左眼、右半幅给右眼后,左右眼画面中字幕落在各自视野的
|
||||
**同一方向角**上——这正是"零视差 / 屏幕平面"的定义(详见 §2)。
|
||||
|
||||
结论:**当前实现把字幕固定渲染在"屏幕平面"上**(即双眼融合位置 = 头显虚拟屏幕所在
|
||||
深度,通常约 1.3~2 m 光学距离)。而 VR 视频内容本身带有左右视差:人脸、近景道具等
|
||||
可能凸出屏幕(负视差/交叉视差,视觉上更近)或陷入屏幕后(正视差,更远)。于是:
|
||||
|
||||
- 当画面主体在屏幕前或屏幕后较远时,主体与字幕不在同一深度 —— 这就是"景深不匹配";
|
||||
- 当画面主体(人脸)位于屏幕中央附近、字幕恰好也画在中央(当前 `MarginV` 使字幕位于
|
||||
画面中部偏下)时,二者在屏幕坐标上重叠 —— 这就是"挡脸"。
|
||||
|
||||
> 也就是说:**"景深不匹配"与"挡脸"是两个不同层面的问题**。
|
||||
> 前者由"字幕双眼视差 ≠ 画面主体双眼视差"引起(深度维度);
|
||||
> 后者由"字幕与主体占用同一块屏幕二维坐标"引起(平面维度)。
|
||||
> 解决方案必须分别处理,不能混为一谈(见 §4、§5)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 双目视觉与立体显示的基本原理
|
||||
|
||||
### 2.1 人眼如何感知深度
|
||||
|
||||
人眼判断深度依赖多类线索,立体显示主要利用的是**双眼线索**:
|
||||
|
||||
- **双眼视差(binocular disparity)**:左右眼从不同位置观察同一物体,成像在视网膜上
|
||||
存在水平位置差。大脑通过视差量级与符号判断物体的相对距离。
|
||||
- **会聚(vergence)**:双眼注视近处物体时眼球向内转(会聚),看远处时近乎平行。
|
||||
会聚角直接由注视距离决定,是一个**肌肉本体感受**线索。
|
||||
- **调节(accommodation)**:晶状体通过改变曲率把注视点清晰成像在视网膜,调节量对应
|
||||
注视距离,也是一个**肌肉线索**。
|
||||
- 视差又分两类:
|
||||
- **交叉视差(负视差 / crossed / 出屏)**:物体在屏幕前方,左右眼看到的像互相"交叉",
|
||||
双眼会聚于屏幕**前**;
|
||||
- **非交叉视差(正视差 / uncrossed / 入屏)**:物体在屏幕后方,双眼会聚于屏幕**后**。
|
||||
- 物体恰好在屏幕平面时视差为零,双眼会聚点正好落在屏幕上。
|
||||
|
||||
在自然世界中,**调节与会聚指向同一距离**:看多近就聚焦多远、双眼就转多近。二者联动
|
||||
且有同一套肌肉反馈。立体显示器(含 VR 头显)打破了这个联动,见 §2.2。
|
||||
|
||||
### 2.2 会聚-调节冲突(VAC)是立体观看疲劳的根源
|
||||
|
||||
Hoffman 等人(2008,J Vis 8(3):33,PMID 18484839)在"会聚-调节冲突损害视觉表现并
|
||||
造成视觉疲劳"一文中指出:
|
||||
|
||||
> 立体显示器把所有图像呈现在**同一个物理表面**上,因此**调节与模糊线索始终指向屏幕
|
||||
> (或头显透镜的固定光学距离)**,而会聚却被双眼视差拉向画面内容所在的深度。两者被
|
||||
> 强制解耦,导致:(a) 需要更长的时间才能识别立体刺激;(b) 限时任务的立体敏锐度下降;
|
||||
> (c) 感知深度失真;(d) 疲劳与不适增加。
|
||||
|
||||
**VR 头显的特殊性**:HMD 的每只眼睛通过透镜观看一块近屏,光学上等效于把屏幕放在一个
|
||||
**固定距离**(多数消费级头显约 1.3~2 m,部分早期设备更近),并且**没有真实环境参照**,
|
||||
眼睛无法通过"看屏幕以外的东西"来放松调节。因此 HMD 中 VAC 是持续存在的(Kramida,
|
||||
IEEE TVCG 2016 对此有综述)。画面中任何"深度偏离屏幕平面较远"的内容,都会让使用者
|
||||
承受相应大小的调节-会聚冲突。
|
||||
|
||||
Shibata、Kim、Hoffman、Banks(2011,J Vis 11(8):11,PMID 21778252)进一步量化了
|
||||
"舒适区(zone of comfort)":
|
||||
|
||||
> 对**给定观看距离**,存在一个能让绝大多数人不感到不适的**有限深度范围**;冲突的屈光度
|
||||
> 越大越不适,且"内容在屏幕前"与"内容在屏幕后"在不同观看距离下不适程度不对称。
|
||||
> 临床上的融合/调节测量(隐斜、单眼清晰融像范围)可以预测个体的易感性。
|
||||
|
||||
工程上常引用的经验结论是:在立体显示器上把内容深度限制在屏幕前后约 **±0.3~0.5 屈光度
|
||||
(D)**(约屏幕前后 0.2~0.6 m,取决于观看距离)以内,绝大多数观众可舒适观看;超出此
|
||||
范围疲劳与融像失败显著上升。注意**这是对常规直视型显示器**的结论,对 HMD 同样适用、
|
||||
甚至更严格(因为 HMD 完全没有真实环境放松调节的途径)。
|
||||
|
||||
### 2.3 视觉疲劳研究的其他关键结论
|
||||
|
||||
- **Yano 等(Displays 2004)**:"立体 HDTV 图像视觉疲劳的两个因素"——疲劳与
|
||||
① 会聚-调节冲突的累积、② 画面内大视差/快速视差变化都有关系;视差越大、变化越快越疲劳。
|
||||
- **Speranza 等(Proc. SPIE 6055, 2006)**:立体图像中**大视差与画面运动叠加**会显著
|
||||
降低舒适度——即使单帧视差不大,运动中反复跨越会聚点也使人疲劳。
|
||||
- **Lambooij 等(J. Imaging Sci. Technol. 2009,综述)**:系统地总结了立体显示
|
||||
不适/疲劳的诱因:过大的视差、过快的视差变化、会聚-调节冲突、观看时间、个体差异等。
|
||||
|
||||
对**字幕**的启示(结合上述原理与行业实践,如 SMPTE ST 428-10/429-12 的数字影院
|
||||
字幕惯例、各 3D 电影制作规范):
|
||||
|
||||
1. **文本是"强调节刺激"**:阅读要求眼睛持续、精确地聚焦在字符上。把字幕放在零视差
|
||||
(屏幕平面)时,调节与会聚指向同一处,阅读负担最低、字符最清晰;
|
||||
2. **字幕若带视差**(放在场景深度),会让观众在"读字"与"看场景"之间来回切换会聚点,
|
||||
且字幕常常持续整句数秒——属于"长时间、恒定的大视差",正踩中疲劳研究的雷区;
|
||||
3. 因此,3D 电影/电视的字幕在**绝大多数情况下被放在零视差平面**,只有少数"叙事性
|
||||
字幕/内嵌文字"会随物体入画。
|
||||
|
||||
> **核心结论 A(对应"景深不匹配")**:字幕在虚拟空间中的"位置/景深"完全由**左右眼
|
||||
> 字幕渲染的水平错位(视差)**决定,与字幕在屏幕上画在哪里(上下左右)是**两回事**。
|
||||
> 想要"把字幕放到某个深度",唯一要改的就是**左右眼各自字幕的水平位置差**。
|
||||
|
||||
---
|
||||
|
||||
## 3. 把"深度配置"落到 ASS 几何上
|
||||
|
||||
### 3.1 SBS 视频的字幕如何变成"双眼视差"
|
||||
|
||||
设 SBS 帧总宽为 `W`,左半幅给左眼、右半幅给右眼;`w = W/2` 为每眼半幅宽。
|
||||
当前 ASS 以 `PlayRes = 3840x1920`(等于两条 1920 宽的半幅)建模:
|
||||
|
||||
- 字幕在左半幅中的水平位置记为 `xL`(0..w),右半幅中记为 `xR`(0..w);
|
||||
- 当前代码:`xL == xR`(两半幅内相对位置相同)⇒ 两眼中字幕位于**同一方向角** ⇒
|
||||
**零视差,字幕在屏幕平面**;
|
||||
- 若想让字幕**比屏幕更近(出屏)**:左眼图像中的字幕应**向右**移,右眼图像中的字幕应
|
||||
**向左**移,即 `xL > xR`(交叉视差,双眼会聚点移到屏幕前);
|
||||
- 若想让字幕**比屏幕更远(入屏)**:反过来 `xL < xR`(非交叉视差,会聚点移到屏幕后)。
|
||||
|
||||
> 方向上的一句话记忆:**"左眼看得偏左、右眼看得偏右" ⇒ 物体比屏幕近**;
|
||||
> **"左眼看得偏右、右眼看得偏左" ⇒ 物体比屏幕远**。
|
||||
> 与真实世界一致:近物在左右眼视网膜上位置相反(交叉),远物相同方向。
|
||||
|
||||
### 3.2 从"目标深度"反推每眼要偏移多少像素
|
||||
|
||||
设:
|
||||
|
||||
- `S` = 虚拟屏幕距离(≈ 头显光学焦距对应的观看距离,通常 1.3~2 m,可查头显规格);
|
||||
- `C` = 想让字幕出现的深度距离(`C < S` 出屏更近,`C > S` 入屏更远);
|
||||
- `IPD` = 瞳距,成人均值约 0.063~0.065 m;
|
||||
- `p` = **单眼像素的角宽度**(度/像素)= 单眼水平视场角 ÷ 每眼水平分辨率。
|
||||
例如某头显单眼水平 FOV ≈ 100°、每眼 2048 px ⇒ `p ≈ 0.049 °/px`(等距柱状画面中央
|
||||
区域近似;边缘因投影会拉伸,但字幕通常位于画面中央,近似够用)。
|
||||
|
||||
两步换算:
|
||||
|
||||
1. **需要的双眼视差角**(小角度近似):
|
||||
|
||||
`η (rad) ≈ IPD × (1/C − 1/S)`,转为度数 `× 180/π`。
|
||||
例:`IPD=0.065 m, S=1.6 m, C=1.1 m`(比屏幕近 0.5 m)⇒
|
||||
`η ≈ 0.065 × (0.909 − 0.625) = 0.0185 rad ≈ 1.06°`。
|
||||
又例:`C=2.2 m`(比屏幕远 0.6 m)⇒ `η ≈ 0.065 × (0.455 − 0.625) = −0.0111 rad ≈ −0.63°`。
|
||||
|
||||
2. **每眼水平平移量**:让左眼画面右移 `δ` 像素、右眼画面左移 `δ` 像素,会产生约
|
||||
`2δ·p` 的双眼视差角,所以:
|
||||
|
||||
`δ (px) ≈ η(度) / (2p)`。
|
||||
|
||||
接上例(`p=0.049°/px`):`η=1.06° ⇒ δ ≈ 10.8 px`;`η=−0.63° ⇒ δ ≈ −6.4 px`。
|
||||
|
||||
> 必须提醒:**`p` 因头显与播放器而异**(不同 FOV、是否等距柱状重投影、画面是否被
|
||||
> "拉近"播放)。因此最稳妥的工程做法是**把"深度"暴露成可调参数而不是写死像素值**,
|
||||
> 让用户在自己设备上微调一次即可(见 §4.2)。本节公式只用于给出合理初值。
|
||||
|
||||
### 3.3 舒适区边界对应的像素量级(实用参考)
|
||||
|
||||
把 `|Δ = 1/S − 1/C|`(屈光度差)限制在约 0.3 D 内作为**舒适初值**,以
|
||||
`IPD=0.065 m, S=1.6 m, p=0.049°/px` 测算:
|
||||
|
||||
| 字幕目标深度 C | 相对屏幕 | 视差方向 | 双眼视差角 | 每眼偏移 δ |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1.6 m | 屏幕平面(0) | 零视差 | 0° | 0 px |
|
||||
| ≈1.22 m | 出屏约 0.38 m(+0.19 D) | 交叉(近) | ≈ +0.73° | ≈ +7 px |
|
||||
| ≈1.08 m | 出屏约 0.52 m(+0.30 D,舒适初值上限) | 交叉(近) | ≈ +1.12° | ≈ +11 px |
|
||||
| ≈2.3 m | 入屏约 0.7 m(−0.19 D) | 非交叉(远) | ≈ −0.71° | ≈ −7 px |
|
||||
| ≈3.05 m | 入屏约 1.45 m(−0.30 D,舒适初值上限) | 非交叉(远) | ≈ −1.11° | ≈ −11 px |
|
||||
|
||||
**量级结论**:让字幕"明显换一个深度",通常只需**每眼 5~15 个像素的水平错位**;
|
||||
±1° 双眼视差(每眼约 ±10 px @2048 半幅)就已是"明显出屏/入屏"的观感,同时也是
|
||||
多数观众能长时间舒适融合的边界附近。做配置时**不要一上来就给几十上百像素的错位**。
|
||||
|
||||
### 3.4 在 ASS 里如何表达这个水平错位(可行性说明)
|
||||
|
||||
当前实现让左/右眼字幕各自居中于半幅,水平位置由样式的 `MarginL/MarginR` 决定。
|
||||
要加入视差偏移,有两种**不改变协议、不动播放链路**的表达方式:
|
||||
|
||||
- **按样式偏移**:给 `LeftEye` 样式的文本区整体右移 `δ`、`RightEye` 整体左移 `δ`
|
||||
(即在各自半幅内把 `MarginL/MarginR` 同时平移),实现"整片字幕一个深度";
|
||||
- **按句偏移**:在 Dialogue 的文本前缀使用 `{\an2\pos(xL+δ, y)}` / `{\an2\pos(xR−δ, y)}`
|
||||
覆盖水平位置,实现**逐句不同的深度**(例如对白在屏幕平面、需要强调/场景绑定字幕
|
||||
放到特定深度)。
|
||||
|
||||
垂直方向(上下)只影响"放在画面哪里",**不影响深度**。
|
||||
|
||||
---
|
||||
|
||||
## 4. 方案 A:让字幕处于"正确"的深度
|
||||
|
||||
目标:消除"字幕景深与视频主体不匹配"的漂移感,同时把疲劳风险压到最低。
|
||||
|
||||
### 4.1 A-1 默认零视差(屏幕平面)——推荐的保守默认
|
||||
|
||||
- 做法:维持现状(`xL == xR`),把字幕放零视差平面。
|
||||
- 理由:
|
||||
1. 文本是强调节刺激,字幕放在屏幕平面时**调节=会聚**,阅读最轻松(§2.2/§2.3);
|
||||
2. VR 视频观看距离固定,屏幕平面本来就处在头显最舒适的深度区(VAC 为 0);
|
||||
3. 字幕与所有"也在屏幕平面的内容"深度一致,漂移感最小;
|
||||
4. 全行业 3D 字幕的主流做法。
|
||||
- 适用:绝大多数对白字幕。
|
||||
- **它与当前代码的差别**:当前实现其实**已经是零视差**,但有两个真正的缺陷需要修:
|
||||
- 播放器若以"整帧平面"方式渲染 ASS(很多播放器把字幕贴在全景帧上)没问题;但如果
|
||||
播放器把字幕当作 **2D 悬浮层**叠加在 SBS 帧之外,深度由播放器决定,与 ASS 无关——
|
||||
此时需要确认播放链路(见 §6 局限);
|
||||
- 垂直位置默认在画面中部(`MarginV = height/2+60`),导致挡脸高发——见 §5。
|
||||
|
||||
### 4.2 A-2 "贴合主体深度"模式(可调参数,谨慎使用)
|
||||
|
||||
若希望字幕"跟随"视频主体所在的深度(例如大部分时间人物位于屏幕前约 0.5 m),可让字幕
|
||||
带一个**固定的、小的、与主体同方向的视差**,把字幕放到主体的平均深度附近:
|
||||
|
||||
- 在 `srt-to-dual-eye-ass` 节点增加可选参数,例如:
|
||||
- `depth_mode`:`screen`(零视差,默认)| `scene_fixed`(固定目标深度)|
|
||||
`per_scene`(逐场景,需深度估计,远期);
|
||||
- `target_depth_m` / `depth_diopter`:目标深度或相对屏幕的屈光度差(推荐用屈光度,
|
||||
因为它与屏幕距离无关、头显间可移植性好);
|
||||
- 运行时按 §3.2 换算成每眼偏移像素,写入样式或逐句 `\pos`。
|
||||
|
||||
约束(否则会重蹈"疲劳"覆辙):
|
||||
- **把相对屏幕的屈光度差限制在约 0.3 D 以内**(对照 §3.3,即每眼偏移几像素到十几像素);
|
||||
- **出屏(近)方向比入屏(远)方向更易疲劳**(Shibata 2011:近距离处正/负冲突不对称),
|
||||
字幕尽量别比人脸更近;
|
||||
- **不要给单条长字幕恒定大视差**:一条字幕通常停留 2~6 秒,等于让眼睛长时间固定在一个
|
||||
偏离屏幕的会聚点上(Yano 2004 的"累积疲劳")。
|
||||
- **需要先估计主体的平均深度**。无深度图时可用近似规则:
|
||||
- VR 成人/剧情类视频大量为"人物近景、脸出屏 0.2~0.5 m"构图,可设
|
||||
`target_depth_m = S − 0.3` 起步并允许用户微调;
|
||||
- 镜头/人物位置多变时,"固定一个深度"反而可能与某些镜头不匹配——此时建议退回零视差。
|
||||
|
||||
### 4.3 A-3 动态/逐场景深度(远期路线)
|
||||
|
||||
更高阶做法是给字幕逐句/逐场景分配深度:
|
||||
|
||||
- 用深度估计(如 MiDaS/双目立体匹配)或预置的视差图,求每句字幕对应时间段内
|
||||
"画面主体/安全区"的平均视差,把字幕深度绑定到主体深度(或主体稍后 0.1~0.2 D);
|
||||
- 通过既有 OCR/抽帧流水线就能拿到关键帧,**人脸检测 + 主体视差估计**可以组合起来(见 §5);
|
||||
- 每句之间视差**平滑过渡**,禁止突跳(Speranza 2006 的"运动+视差变化"是疲劳来源)。
|
||||
|
||||
路线建议:**A-1 打底 → A-2 固定深度参数(近期可做)→ A-3 动态深度(远期)**。
|
||||
|
||||
---
|
||||
|
||||
## 5. 方案 B:解决"字幕挡住人脸"
|
||||
|
||||
挡脸是**二维平面重叠**问题,深度方案救不了它——把人脸放到不同深度也不能让字幕"穿
|
||||
过"人脸不遮挡视线(在立体显示中,若字幕深度比人脸更近,观感是字幕盖在人脸前;若更
|
||||
远,则是人脸挡住字幕,同样干扰阅读)。因此主力方案是**二维位置避让 + 视觉降噪**。
|
||||
|
||||
### 5.1 B-1 默认位置下移:避开人脸高频区(零成本,先做)
|
||||
|
||||
当前字幕画在画面**中部偏下**(`an2` + `MarginV = height/2 + 60`)。VR 人物近景构图里
|
||||
人脸常处于画面中央区域,这是挡脸高发的直接原因。
|
||||
|
||||
- 把默认垂直位置改为**贴近底部安全区**(如 `MarginV ≈ 80~150`,可配参数);
|
||||
- 若视频主体/对白字幕本来就常出现在底部(少见,但可配 `top` 模式放到顶部),给
|
||||
`position: bottom|top|center` 与 `margin_v` 两个可选参数;
|
||||
- 注意:VR 全景视频的画面**底部可能是地面/近景道具**,顶部可能是天花板/光斑——
|
||||
没有"永远安全"的固定位置,所以还需要 B-2/B-3 的动态手段。
|
||||
|
||||
### 5.2 B-2 半透明底框 + 描边(视觉降噪,成本低)
|
||||
|
||||
挡脸的另一半观感来自"字压内容"。采用:
|
||||
|
||||
- ASS 样式:`BorderStyle=1`(描边)+ `Shadow`,必要时用 `BorderStyle=3`(不透明框)
|
||||
或增大 `BackColour` 透明度做半透明底板;
|
||||
- 可同时**轻微缩小字号**、限制每行字数(中文字幕建议单行 ≤ 14~16 字),减小覆盖面积;
|
||||
- 底框颜色选择接近视频暗部、半透明,观感为"字幕浮在画面上方"而非"糊在人脸上"。
|
||||
|
||||
这不能消除遮挡,但能显著降低"看不清人脸/看不清字幕"的双输观感,成本几乎为零。
|
||||
|
||||
### 5.3 B-3 人脸/主体检测驱动的动态避让(推荐的中期方案)
|
||||
|
||||
在**字幕生成阶段**做一次"占位冲突检测",复用项目已有的抽帧与 OCR 基础设施:
|
||||
|
||||
1. 对每条字幕的起止时间段,从视频抽取 1~3 个关键帧;
|
||||
2. 用人脸检测(或显著性/主体检测)得到每帧人脸包围盒,映射到 ASS 画布坐标;
|
||||
3. 若字幕默认摆放位置与该时间段内的人脸框重叠,则按优先级移动:
|
||||
- ① 垂直移到对侧安全带(顶部/底部);
|
||||
- ② 同一侧上下微调避开具体人脸框;
|
||||
- ③ 仍冲突则缩短/分行/加底框兜底;
|
||||
4. 对移动做**时间平滑**(相邻句子的位置不要跳来跳去),每句只需知道"前一句的位置"。
|
||||
|
||||
- 与现有代码衔接点:`subtitle-ocr`/`frame-extract` 已产出 `frames_manifest` 与帧图,
|
||||
人脸检测可插在抽帧后;也可以做成独立可选节点(`face-aware-place`),不污染对白链路。
|
||||
- 检测失败(无 GPU/无模型)时自动回退 B-1 的底部默认,保证可用性。
|
||||
- 进阶:把"人脸框的中心深度"作为 A-3 动态深度的输入(§4.3),让字幕即不挡脸又贴合景深。
|
||||
|
||||
### 5.4 B-4 字幕与人物"绑视差"(针对对白语义的高级玩法,谨慎)
|
||||
|
||||
对"对白字幕"可考虑让字幕深度**跟随当前说话人**(把字幕放到说话人所在深度、且稍微偏下
|
||||
避开嘴部)。但这要求精确的主体视差/深度追踪,工程量大、且若说话人贴近屏幕(0.5 m 内)
|
||||
字幕被迫也出屏很多,反而违背舒适区原则。**不推荐作为默认**,仅作为特殊场景的可选项。
|
||||
|
||||
### 5.5 B-5 方案对比小结
|
||||
|
||||
| 方案 | 成本 | 解决程度 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| B-1 默认下移到底部安全区 | 极低(改默认参数) | 中 | 先做,立刻降低挡脸频率 |
|
||||
| B-2 半透明底框+描边+字号/行数限制 | 低 | 中(观感) | 与 B-1 组合默认启用 |
|
||||
| B-3 人脸检测动态避让 | 中(人脸模型+调度逻辑) | 高 | 推荐中期实现 |
|
||||
| B-4 字幕绑说话人视差 | 高 | 中高但疲劳风险 | 仅特殊场景 |
|
||||
| A-2/A-3 深度贴靠 | 中-高 | 解决"景深不匹配" | 与 B 系列正交,组合使用 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 局限与需要注意的链路问题
|
||||
|
||||
1. **播放器如何渲染 ASS 决定一切**。当前假设"播放器把 ASS 渲染进 SBS 帧后分眼"。
|
||||
若用户使用的 VR 播放器把字幕作为**头显 2D 悬浮 UI**(固定渲染在屏幕平面上、不随
|
||||
视频视差移动),则 ASS 里任何深度配置都无效,只能靠播放器自身的字幕位置设置;
|
||||
验证方法:在某句字幕上故意加 20 px 视差看是否产生出屏/入屏效果。
|
||||
2. **SBS / 上下 / 帧封装格式**:本报告按 SBS(左右各半幅)推导;若是上下格式(TB)则
|
||||
视差仍为水平方向,但需要把"半幅"概念换成上下半幅,且左右眼各自的水平错位关系不变。
|
||||
3. **等距柱状投影的角分辨率不均**:画面中央角分辨率高、边缘被拉伸;字幕若放在画面边缘,
|
||||
§3.2 的 `p` 不准确。字幕通常放中央/下部,影响有限,但精确配置建议按"字幕所在区域"
|
||||
校准 `p`。
|
||||
4. **个体差异**:舒适视差范围因人而异(Shibata 2011 用隐斜测量预测个体易感性)。
|
||||
参数化 + 允许用户微调,比"一刀切最佳值"更实际。
|
||||
5. **动态内容**:镜头运动、人物出入画会改变主体视差;任何"固定深度/固定避让"都可能在
|
||||
部分镜头失效——这正是 A-3/B-3 动态方案存在的意义,也是建议先做保守默认的原因。
|
||||
6. **本报告不构成代码改动**:以上参数化(`depth_mode`、`position`、`margin_v`、
|
||||
底框样式、人脸避让节点)都尚未实现,需要时另行按仓库 TDD 流程开发。
|
||||
|
||||
---
|
||||
|
||||
## 7. 建议的落地优先级(结论)
|
||||
|
||||
1. **立刻可做(无新模型/无协议改动)**:
|
||||
- 字幕默认垂直位置从"画面中部"改到**底部安全区**,并暴露
|
||||
`position`/`margin_v` 参数(对应 B-1);
|
||||
- 增加**半透明底框 + 描边**样式并限制单行字数(B-2);
|
||||
- 明确记录"当前实现 = 零视差/屏幕平面"这一事实到 AGENTS.md 与 ass 节点注释。
|
||||
2. **近期可做(少量参数 + 换算函数)**:
|
||||
- 给 `srt-to-dual-eye-ass` 增加 `depth_mode`(`screen` 默认 / `scene_fixed`)、
|
||||
`depth_diopter` 参数,按 §3.2 公式换算每眼像素偏移(A-2);
|
||||
- 配换算与边界检查(限制 |Δ| ≤ ~0.3 D、输出像素偏移范围、防越界);
|
||||
- 100% 覆盖率的单测(纯函数,可测)。
|
||||
3. **中期可做**:
|
||||
- 人脸检测驱动的动态避让节点(B-3);
|
||||
- 场景主体深度估计 + 动态深度(A-3),二者可共享同一套关键帧与检测基础设施。
|
||||
4. **设计原则**(沿用仓库北极星):
|
||||
- 深度/位置全部是**节点参数与 ASS 数据**,不写死业务代码;
|
||||
- 节点保持无状态、只经产物 URI 交换;
|
||||
- 任何新的"检测"能力做成独立可复用节点,不绑定单一工作流。
|
||||
|
||||
---
|
||||
|
||||
## 8. 参考文献
|
||||
|
||||
以下文献的关键结论均经过原文/摘要核验(PubMed/Crossref/arXiv):
|
||||
|
||||
1. Hoffman DM, Girshick AR, Akeley K, Banks MS. **Vergence-accommodation conflicts
|
||||
hinder visual performance and cause visual fatigue.** J Vis. 2008;8(3):33.
|
||||
doi:10.1167/8.3.33. PMID 18484839.
|
||||
—— 会聚-调节冲突损害视觉表现并导致疲劳;显示器的调节线索固定指向屏幕。
|
||||
2. Shibata T, Kim J, Hoffman DM, Banks MS. **The zone of comfort: Predicting visual
|
||||
discomfort with stereo displays.** J Vis. 2011;11(8):11. doi:10.1167/11.8.11.
|
||||
PMID 21778252.
|
||||
—— 定义并量化"舒适区";冲突大小与正/负方向、观看距离、个体差异对不适的影响。
|
||||
3. Lambooij M, IJsselsteijn W, Fortuin M, Heynderickx I. **Visual discomfort and
|
||||
visual fatigue of stereoscopic displays: A review.** J Imaging Sci Technol.
|
||||
2009;53(3):030201. doi:10.2352/J.ImagingSci.Technol.2009.53.3.030201.
|
||||
—— 立体显示视觉不适/疲劳诱因综述(视差幅度、视差变化率、观看时间等)。
|
||||
4. Yano S, Emoto M, Mitsuhashi T. **Two factors in visual fatigue caused by
|
||||
stereoscopic HDTV images.** Displays. 2004;25(4):141-150.
|
||||
doi:10.1016/j.displa.2004.09.002.
|
||||
—— 大/持续视差与视差变动是疲劳的两个主要因素。
|
||||
5. Speranza F, Tam WJ, Renaud R, Hur N. **Effect of disparity and motion on visual
|
||||
comfort of stereoscopic images.** Proc SPIE 6055 (Stereoscopic Displays and
|
||||
Virtual Reality Systems XIII). 2006. doi:10.1117/12.640865.
|
||||
—— 视差与画面运动叠加显著降低立体观看舒适度。
|
||||
6. Kramida G. **Resolving the vergence-accommodation conflict in head-mounted
|
||||
displays.** IEEE Trans Vis Comput Graph. 2016;22(7):1912-1931.
|
||||
doi:10.1109/TVCG.2015.2476178.
|
||||
—— HMD 中 VAC 的机制与解决路线综述(调节固定 ⇒ VAC 持续存在)。
|
||||
7. **Dynamic lens and monovision 3D displays to improve viewer comfort.** arXiv,
|
||||
2015(Banks 组)。
|
||||
—— 常规 S3D 显示调节线索错误、造成调节-会聚不匹配的机制说明。
|
||||
8. SMPTE ST 428-10 / ST 429-12(D-Cinema Distribution Master / Packaging —
|
||||
Closed Caption and Closed Subtitle)。
|
||||
—— 数字影院字幕/隐藏字幕以平面叠加呈现的行业惯例(非深度规定,仅作惯例佐证)。
|
||||
|
||||
> 免责:第 3.2/3.3 节的换算公式与数值为**面向本项目场景的工程推导**(IPD、观看距离、
|
||||
> 角分辨率取典型值),用于给出可操作初值,并非某篇论文的直接结论;实际参数应以
|
||||
> 目标头显规格与主观验证为准。
|
||||
@@ -0,0 +1,82 @@
|
||||
# 专有名词(不应直译)处理表
|
||||
|
||||
对于日语字幕的"日 → 中文"翻译,**片假名专有名词**(人名、品牌、角色名、
|
||||
产品或道具名)是 LLM 误译的重灾区:模型常按罗马音/读音硬译,产生
|
||||
"芒果"(ジンゴ)这类与原文无对应、甚至语义荒谬的结果。
|
||||
|
||||
下表依据**日语-中文翻译的通用规则 + 字幕场景实测**整理。当待翻译字幕
|
||||
中出现表中"日文原文"时,应向翻译提示词动态注入对应规则(见
|
||||
`nodes/proper_nouns.py` 的 `build_proper_noun_rule`),让 LLM 正确处理。
|
||||
|
||||
## 规则类别(处理策略)
|
||||
|
||||
| 类别 | 处理方式 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| 人名 | 音译(保留姓氏/称谓),不直译字面义 | カンタくん → 康太君 / 坎塔君 |
|
||||
| 品牌/产品名 | 保留原文或使用约定译名 | ニトリ → 尼达利 / ニトリ |
|
||||
| 角色/道具专名 | 保留原文或按上下文意译,禁止按读音硬译 | ジンゴ → 保留"Jingo/ジンゴ",勿译"芒果" |
|
||||
| 拟声/拟态词 | 用中文对应拟声词,不直译 | グリグリ → 钻、搅动 |
|
||||
| 外来语缩写 | 还原英文含义而非音译 | リラックス → 放松(非"丽拉库斯") |
|
||||
|
||||
## 专名表(日文原文 → 处理建议 → 说明)
|
||||
|
||||
| 日文原文 | 音频读音 | 中文处理建议 | 说明(来源/原因) |
|
||||
| --- | --- | --- | --- |
|
||||
| ジンゴ | jingo | **保留原文"ジンゴ/Jingo"**,或按上下文意译为角色/道具名 | 实测被误译"芒果"(4500s)。片假名专名,勿按读音硬译 |
|
||||
| マンゴー | mango | **保留"マンゴー/Mango"**;仅当确指水果作"芒果" | 品牌/专名可能,注意与"芒果"同音误译 |
|
||||
| カンタくん | kanta-kun | 音译"康太君/坎塔君" | 人名(3803s),勿留日文或直译 |
|
||||
| 松井 | matusui | 松井(姓氏,汉字保留) | 人名,参考字幕确认"松井小姐" |
|
||||
| ひな子 | hinako | 日奈子 / 雏子 | 人名(参考字幕"松井日奈子") |
|
||||
| カリン | karin | 果林 / 花梨 | 人名(参考字幕"北冈果林/果林前辈") |
|
||||
| 北岡 | kitaoka | 北冈(汉字保留) | 人名 |
|
||||
| 権藤 | gondo | 权藤 / 昆藤 | 人名(参考"医务室长权藤") |
|
||||
| 金山 | kanayama | 金山(汉字保留) | 人名(参考"金山先生") |
|
||||
|
||||
## 高频"伪专名"(拟声/口语,勿按字面直译)
|
||||
|
||||
| 日文 | 正确中文处理 | 错误直译(勿用) |
|
||||
| --- | --- | --- |
|
||||
| パンパン | 鼓胀、饱满、涨满 | 砰砰 |
|
||||
| グリグリ | 用力碾/钻、搅动 | 咕噜咕噜 |
|
||||
| チンポ | 肉棒/鸡巴(俗语) | 金宝(音译) |
|
||||
| ビクビク | 一颤一颤、哆嗦 | 比库比库 |
|
||||
| ヌルヌル | 滑溜溜、黏糊糊 | 奴鲁奴鲁 |
|
||||
| ビンビン | 硬邦邦、精神十足 | 宾宾 |
|
||||
| マット | 垫子(借词) | 马特 |
|
||||
| リラックス | 放松(借词) | 丽拉库斯 |
|
||||
| ララ | (含语音节)保持原文/按语境 | 拉拉 |
|
||||
|
||||
## 用法
|
||||
|
||||
翻译节点 `nodes/llm.py` 的 `translate_lines` 在组装系统提示词时,调用
|
||||
`nodes/proper_nouns.py` 的 `build_proper_noun_rule(terms)`,把"若字幕中出现
|
||||
下表专名 → 附加对应翻译规则"的指令拼入提示词;匹配基于原文文本包含判断,
|
||||
未命中时不注入(避免干扰普通翻译)。
|
||||
## 成人语境隐语/俗称表(新增,实测来源)
|
||||
|
||||
AV/成人内容字幕中,LLM 最易按**字面直译**造成严重错译。以下为高频隐语,
|
||||
翻译时应按"实际含义"的常用中文翻译处理:
|
||||
|
||||
| 日文原文 | 读音 | 实际含义 | 对应中文常用翻译 |
|
||||
| --- | --- | --- | --- |
|
||||
| マンゴー | Mango | 女性生殖器 | 小穴、鲍鱼、妹妹(若谐音梗可保留"芒果")|
|
||||
| バナナ | Banana | 男性生殖器 | 肉棒、鸡鸡、老二(画面打码可译"香肠")|
|
||||
| リンゴ | Ringo | 睾丸(阴囊) | 蛋蛋(若出自"淫梦"梗可保留"苹果"并注释)|
|
||||
| ちんぽう | chinpou | 男性生殖器 | 阳具、鸡巴(粗俗)|
|
||||
| ちんちん | chinchin | 男性生殖器 | 小鸡鸡(委婉,对应童语)|
|
||||
| 金玉 | kintama | 睾丸 | 蛋蛋、睾丸(正式)|
|
||||
| にくつぼ | nikutsubo | 女性生殖器 | 肉洞、蜜穴(偏文学性);小穴(口语)|
|
||||
| おまんこ | omanko | 女性生殖器 | 小穴、阴部 |
|
||||
| おそそ | ososo | 女性生殖器 | 那里、下面(委婉)|
|
||||
| オチンチン | ochinchin | 男性生殖器 | 小弟弟(常用);网络流行语可直译"欧金金"|
|
||||
| ほんばん | honban | 真实插入的性行为 | 真枪实弹、来真的、本番(音译)|
|
||||
| すまた | sumata | 股间摩擦(不插入)| 素股(音译最常用)、腿交、磨大腿 |
|
||||
| せいかん | seikan | 户外/野外性行为 | 野战、户外做爱 |
|
||||
| エッチ | etchi | 性行为/色情的 | 做爱、嘿咻、H(音译)|
|
||||
| アヘ顔 | ahegao | 高潮时翻白眼吐舌的表情 | 阿黑颜(音译)、高潮脸、失神脸 |
|
||||
|
||||
> 以上隐语**仅在成人/色情语境成立**。若文本语境明确为普通水果/物品(如菜谱、
|
||||
> 生活对话),仍按字面翻译。规则注入时结合"成人隐语"提示,交由 LLM 判断语境。
|
||||
|
||||
代码实现见 `nodes/proper_nouns.py` 的 `ADULT_EUPHEMISMS` 表与
|
||||
`build_proper_noun_rule`。
|
||||
+30
-13
@@ -11,8 +11,19 @@ from pathlib import Path
|
||||
|
||||
from wov_sdk.models import InvokeRequest, InvokeResponse
|
||||
|
||||
def _ass_header(resolution: str) -> str:
|
||||
"""生成 ASS 文件头:脚本信息、左右眼样式和事件格式。"""
|
||||
def _ass_header(resolution: str, margin_top: int = 120) -> str:
|
||||
"""生成 ASS 文件头:脚本信息、左右眼样式和事件格式。
|
||||
|
||||
样式说明(B-1 顶部安全区 + 透明度):
|
||||
- 对齐 8(顶部居中,\an8),MarginV=margin_top:字幕渲染在画面顶部安全区,
|
||||
避开人脸/近景主体常出现的画面中央偏下区域,降低遮挡;
|
||||
- PrimaryColour &HB3FFFFFF:约 70% 透明文字填充,比原先 &H80FFFFFF(约 50%)
|
||||
更通透,减少对场景的遮挡感;
|
||||
- OutlineColour &H80000000:半透明黑描边(不再是实心纯黑),
|
||||
在保留可读性的同时不产生生硬黑框;
|
||||
- 左右眼水平边距一致(LeftEye/RightEye 各占左右半幅)⇒ 水平相对位置相同,
|
||||
即零视差(A-1):字幕被渲染在屏幕平面,不产生额外景深冲突。
|
||||
"""
|
||||
width, height = resolution.lower().split("x", 1)
|
||||
# 左眼样式占左半边,右眼样式占右半边,各留 50px 内边距。
|
||||
left_margin = 50
|
||||
@@ -28,8 +39,8 @@ ScaledBorderAndShadow: yes
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding
|
||||
Style: LeftEye,Arial,50,&H80FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,{left_margin},{int(width) // 2},{int(height) // 2 + 60},1
|
||||
Style: RightEye,Arial,50,&H80FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,{int(width) // 2},{right_margin},{int(height) // 2 + 60},1
|
||||
Style: LeftEye,Arial,50,&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,{left_margin},{int(width) // 2},{margin_top},1
|
||||
Style: RightEye,Arial,50,&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,{int(width) // 2},{right_margin},{margin_top},1
|
||||
|
||||
[Events]
|
||||
Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text
|
||||
@@ -67,15 +78,19 @@ def parse_srt(text: str) -> list[tuple[str, str, str]]:
|
||||
return entries
|
||||
|
||||
|
||||
def write_ass(entries: list[tuple[str, str, str]], output_path: Path, resolution: str) -> None:
|
||||
"""把解析后的条目写入 ASS 文件,每个条目输出左右眼两行 Dialogue。"""
|
||||
lines = [_ass_header(resolution)]
|
||||
for start, end, text in entries:
|
||||
# an2 对齐到屏幕中央偏下,保证双眼字幕视线自然。
|
||||
lines.append(f"Dialogue: 0,{start},{end},LeftEye,,0,0,0,,{{\\an2}}{text}")
|
||||
lines.append(f"Dialogue: 0,{start},{end},RightEye,,0,0,0,,{{\\an2}}{text}")
|
||||
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
def write_ass(
|
||||
entries: list[tuple[str, str, str]], output_path: Path, resolution: str, margin_top: int = 120
|
||||
) -> None:
|
||||
"""把解析后的条目写入 ASS 文件,每个条目输出左右眼两行 Dialogue。
|
||||
|
||||
margin_top 控制字幕距画面顶部的安全边距(默认 120),顶部对齐(\an8)
|
||||
使字幕整体落在顶部安全区。左右眼使用相同文本与水平相对位置(零视差,A-1)。"""
|
||||
lines = [_ass_header(resolution, margin_top=margin_top)]
|
||||
for start, end, text in entries:
|
||||
# an8 对齐到屏幕顶部,配合 MarginV 形成顶部安全区,避开中央人脸区域。
|
||||
lines.append(f"Dialogue: 0,{start},{end},LeftEye,,0,0,0,,{{\\an8}}{text}")
|
||||
lines.append(f"Dialogue: 0,{start},{end},RightEye,,0,0,0,,{{\\an8}}{text}")
|
||||
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||
"""把 cn_srt_uri 指向的 SRT 转为 dual_eye.ass 产物。"""
|
||||
@@ -93,6 +108,8 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||
output_path = output_dir / "dual_eye.ass"
|
||||
# 分辨率默认 3840x1920,覆盖常见 VR 视频尺寸。
|
||||
resolution = str(request.params.get("resolution", "3840x1920"))
|
||||
write_ass(entries, output_path, resolution)
|
||||
# margin_top 可选:顶部安全边距,不同分辨率/内容可用工作流参数微调。
|
||||
margin_top = int(request.params.get("margin_top", 120))
|
||||
write_ass(entries, output_path, resolution, margin_top=margin_top)
|
||||
return InvokeResponse(status="completed", outputs={"ass_uri": str(output_path)})
|
||||
|
||||
|
||||
+23
-5
@@ -28,7 +28,8 @@ import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from wov_sdk.models import InvokeRequest, InvokeResponse
|
||||
|
||||
from nodes.subtitle_cleanup import clean_srt_text
|
||||
from nodes.proper_nouns import build_proper_noun_rule
|
||||
# 单次 LLM 请求携带的字幕行数;过大会超出模型上下文,过小则请求次数过多。
|
||||
CHUNK_SIZE = 20
|
||||
|
||||
@@ -144,18 +145,28 @@ def _translate_batch(
|
||||
system_prompt: str,
|
||||
request_timeout: float,
|
||||
) -> list[str]:
|
||||
"""翻译单个批次:行数不一致时多行合并、少行重试,返回与 chunk 等长译文。"""
|
||||
"""翻译单个批次:行数不一致时多行合并、少行重试,返回与 chunk 等长译文。
|
||||
|
||||
每批调用前根据本批原文命中情况动态拼接专名/隐语规则(build_proper_noun_rule),
|
||||
注入到系统提示词,让 LLM 正确处理片假名专名与成人语境隐语。"""
|
||||
# 本批命中的专名/隐语规则(无命中返回 None)。
|
||||
rule = build_proper_noun_rule(chunk)
|
||||
batch_system = system_prompt
|
||||
if rule:
|
||||
batch_system = system_prompt + "\n\n" + rule
|
||||
attempt = 0
|
||||
while True:
|
||||
content = _call_llm(
|
||||
api_base,
|
||||
api_key,
|
||||
model,
|
||||
system_prompt,
|
||||
batch_system,
|
||||
"\n".join(chunk),
|
||||
request_timeout,
|
||||
)
|
||||
batch = [line.strip() for line in content.splitlines() if line.strip()]
|
||||
# 保留所有行:先 rstrip 尾随换行避免多出末尾空行,再 splitlines 保留
|
||||
# 内容中的空串行(空行可能是合法的空字幕,过滤掉会误判行数)。
|
||||
batch = content.rstrip("\n").splitlines()
|
||||
if len(batch) == len(chunk):
|
||||
return batch
|
||||
if len(batch) > len(chunk):
|
||||
@@ -193,8 +204,15 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||
for index, text_index in enumerate(text_indices):
|
||||
lines[text_index] = translated_lines[index]
|
||||
|
||||
# 长时寒暄幻觉词清洗:对展示时长超过阈值且含收尾/开场寒暄(晚安、感谢观看
|
||||
# 等)的条目,文本替换为 '-'(由后续过滤流程移除),避免幻觉占位污染正片;
|
||||
# 短时(≤阈值)如剧情中真实互道'晚安'则保留,不误删。见
|
||||
# nodes/subtitle_cleanup.py。
|
||||
srt_body = "\n".join(lines) + "\n"
|
||||
srt_body = clean_srt_text(srt_body)
|
||||
|
||||
output_dir = Path(request.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = output_dir / "cn.srt"
|
||||
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
output_path.write_text(srt_body, encoding="utf-8")
|
||||
return InvokeResponse(status="completed", outputs={"cn_srt_uri": str(output_path)})
|
||||
@@ -0,0 +1,121 @@
|
||||
"""专有名词与隐语(不应直译)处理规则。
|
||||
|
||||
日语字幕"日 → 中文"翻译中,两类词是 LLM 误译重灾区:
|
||||
1. **片假名专有名词**(人名/品牌/角色/道具名):模型常按读音硬译
|
||||
(如 ジンゴ → "芒果"),产生与原文无对应的荒谬结果。
|
||||
2. **成人语境隐语/俗称**(AV/色情内容的委婉说法):模型常按字面直译
|
||||
(如 マンゴー → 芒果、バナナ → 香蕉、金玉 → 金玉),实际这些词在
|
||||
色情语境中是生殖器官或性行为的代称。
|
||||
|
||||
本模块维护三张规则表(专名、拟声/口语、成人语境隐语),并提供规则构建
|
||||
函数 build_proper_noun_rule,供翻译节点在系统提示词中动态注入,让 LLM
|
||||
按正确语义处理。
|
||||
|
||||
处理策略(详见 docs/proper_nouns.md):
|
||||
- 人名:音译(保留姓氏/称谓),不直译字面义;
|
||||
- 品牌/产品名:保留原文或使用约定译名;
|
||||
- 角色/道具专名:保留原文或按上下文意译,禁止按读音硬译;
|
||||
- 拟声/拟态词:用中文对应拟声词,不直译;
|
||||
- **成人语境隐语:按"实际含义"的常用中文翻译处理,禁止字面直译**。
|
||||
|
||||
匹配基于"原文文本是否包含专名"判断,未命中时不注入规则(避免干扰普通
|
||||
翻译)。纯函数,可独立测试(tests/test_proper_nouns.py)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# 一、专名表:{日文原文: (中文处理建议, 说明)}。
|
||||
# 命中即注入对应指令。
|
||||
PROPER_NOUNS: dict[str, tuple[str, str]] = {
|
||||
# 角色/道具专名:勿按读音硬译
|
||||
"ジンゴ": ("保留原英文'Jingo'或按上下文意译;禁止译作'芒果'",
|
||||
"实测被误译'芒果'(4500s),片假名专名"),
|
||||
"マンゴー": ("保留'マンゴー/Mango';仅当确指水果时译'芒果'",
|
||||
"与'芒果'同音易误译,见成人隐语表"),
|
||||
# 人名:音译,勿留日文
|
||||
"カンタくん": ("音译'康太君/坎塔君',勿保留'カンタ君'", "人名(3803s)"),
|
||||
"松井": ("保留'松井'(姓氏汉字)", "人名,参考'松井小姐'"),
|
||||
"ひな子": ("音译'日奈子/雏子'", "人名,参考'松井日奈子'"),
|
||||
"カリン": ("音译'果林/花梨'", "人名,参考'北冈果林'"),
|
||||
"北岡": ("保留'北冈'(姓氏汉字)", "人名"),
|
||||
"権藤": ("音译'权藤/昆藤'", "人名,参考'医务室长权藤'"),
|
||||
"金山": ("保留'金山'(姓氏汉字)", "人名,参考'金山先生'"),
|
||||
}
|
||||
|
||||
# 二、高频"伪专名"(拟声/口语):勿按字面直译
|
||||
ONOMATOPOEIA: dict[str, tuple[str, str]] = {
|
||||
"パンパン": ("按语境译'鼓胀、饱满、涨满'", "勿译'砰砰'"),
|
||||
"グリグリ": ("用力碾/钻、搅动", "勿译'咕噜咕噜'"),
|
||||
"チンポ": ("按上下文译为俗语(肉棒/鸡巴)", "勿音译'金宝'"),
|
||||
"ビクビク": ("一颤一颤、哆嗦", "勿译'比库比库'"),
|
||||
"ヌルヌル": ("滑溜溜、黏糊糊", "勿译'奴鲁奴鲁'"),
|
||||
"ビンビン": ("硬邦邦、精神十足", "勿译'宾宾'"),
|
||||
"マット": ("垫子(日语借词)", "勿译'马特'"),
|
||||
"リラックス": ("放松(外来语还原含义)", "勿音译'丽拉库斯'"),
|
||||
}
|
||||
|
||||
# 三、成人语境隐语/俗称:{日文原文: (实际含义, 对应中文常用翻译, 说明)}。
|
||||
# 这些词在色情语境中是生殖器官/性行为的代称,LLM 若按字面直译会严重错译。
|
||||
# 结构:词条 -> (实际含义, 常用中文翻译, 补充说明/约束)
|
||||
ADULT_EUPHEMISMS: dict[str, tuple[str, str, str]] = {
|
||||
# 水果谐音/形状类(画面常见比喻)
|
||||
"マンゴー": ("女性生殖器", "小穴、鲍鱼、妹妹;若为谐音梗可保留'芒果'",
|
||||
"与'マンコ'同音,直译'芒果'为常见误译"),
|
||||
"バナナ": ("男性生殖器", "肉棒、鸡鸡、老二;画面打码可译'香肠'",
|
||||
"水果形状比喻,直译'香蕉'错误"),
|
||||
"リンゴ": ("睾丸(阴囊)", "蛋蛋;若出自'淫梦'梗可保留'苹果'并加注释",
|
||||
"食物比喻,直译'苹果'错误"),
|
||||
# 物品比喻类
|
||||
"ちんぽう": ("男性生殖器", "阳具、鸡巴(粗俗)", "与'珍宝(ちんぽう)'谐音"),
|
||||
"ちんちん": ("男性生殖器", "小鸡鸡(委婉)", "小朋友用语,女优常用来装可爱"),
|
||||
"金玉": ("睾丸", "蛋蛋、睾丸(正式)", "直译'金玉'会让人摸不着头脑"),
|
||||
"にくつぼ": ("女性生殖器", "肉洞、蜜穴(偏文学性);小穴(口语)",
|
||||
"字面'肉壶',实为色情比喻"),
|
||||
"おまんこ": ("女性生殖器", "小穴、阴部(正式/粗俗)", "最常用称,勿照搬"),
|
||||
"おそそ": ("女性生殖器", "那里、下面(委婉)", "儿童语/婉语,直译会破坏语气"),
|
||||
"オチンチン": ("男性生殖器", "小弟弟(常用);网络流行语可直译'欧金金'",
|
||||
"与'欧金金'同源网络梗"),
|
||||
# 状态/动作类
|
||||
"ほんばん": ("真实插入的性行为", "真枪实弹、来真的、本番(音译)",
|
||||
"AV 术语,勿直译'本番'为'正本'"),
|
||||
"すまた": ("股间摩擦(不插入)", "素股(音译最常用)、腿交、磨大腿",
|
||||
"AV 术语"),
|
||||
"せいかん": ("户外/野外性行为", "野战、户外做爱", "字面'青姦',勿照搬"),
|
||||
"エッチ": ("性行为/色情的", "做爱、嘿咻、H(音译)", "通用隐语,勿直译'H'"),
|
||||
"アヘ顔": ("高潮时翻白眼吐舌的表情", "阿黑颜(音译)、高潮脸、失神脸",
|
||||
"网络亚文化词,直译'阿嘿脸'外行"),
|
||||
}
|
||||
|
||||
|
||||
def build_proper_noun_rule(terms: str | list[str]) -> str | None:
|
||||
"""根据待翻译文本中的专名/隐语,构建翻译提示词片段。
|
||||
|
||||
参数 terms: 待翻译的原文行(字符串列表或整体文本)。
|
||||
返回注入提示词的规则字符串;若原文未命中任何专名/隐语则返回 None
|
||||
(不注入,避免干扰普通翻译)。
|
||||
"""
|
||||
if isinstance(terms, str):
|
||||
joined = terms
|
||||
else:
|
||||
joined = "\n".join(terms)
|
||||
|
||||
rules: list[str] = []
|
||||
for token, (advice, reason) in PROPER_NOUNS.items():
|
||||
if token in joined:
|
||||
rules.append(f" 日文'{token}':{advice}({reason})。")
|
||||
for token, (advice, reason) in ONOMATOPOEIA.items():
|
||||
if token in joined:
|
||||
rules.append(f" 日文'{token}':{advice}({reason})。")
|
||||
for token, (meaning, cn, note) in ADULT_EUPHEMISMS.items():
|
||||
if token in joined:
|
||||
rules.append(
|
||||
f" 日文'{token}'是成人语境隐语(实际含义:{meaning}),"
|
||||
f"请译为'{cn}',切勿按字面直译。{note}。"
|
||||
)
|
||||
|
||||
if not rules:
|
||||
return None
|
||||
return (
|
||||
"注意以下专有名词/隐语按规则处理(不要按读音或字面硬译):\n"
|
||||
+ "\n".join(rules)
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Subtitle cleanup: mask long-duration closing/greeting hallucinations.
|
||||
|
||||
Background (real run 20260905115050): after fixing the timing alignment,
|
||||
subtitles still contain "closing/greeting hallucination words" - fixed
|
||||
phrases like 'wan an / gan xie guan kan / gan xie nin de guan kan'
|
||||
(good night / thanks for watching) that the ASR/LLM repeatedly emits on
|
||||
empty segments, filling a full 30s block, unrelated to video content.
|
||||
Some 2s 'good night' might be real dialogue, so it must be kept.
|
||||
|
||||
Plan (confirmed by user): after translation, mask subtitle entries whose
|
||||
*display duration* exceeds a threshold AND whose text contains a greeting
|
||||
hallucination token - replace the text with '-' so the downstream SRT/filter
|
||||
pipeline drops it. The duration threshold protects short real greetings.
|
||||
|
||||
Threshold is derived from real run data: 30s hallucinations vs 2s real words,
|
||||
a clear gap; default 15s (>=15s masks, <15s keeps).
|
||||
|
||||
Pure functions, unit-testable (tests/test_hallucination_mask.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Greeting/closing hallucination tokens that LLM repeats on empty/end segments.
|
||||
HALLUCINATION_TOKENS = (
|
||||
"谢谢观看", "感谢观看", "感谢收看", "谢谢收看", "感谢您的观看", "感谢您的收看",
|
||||
"晚安", "下次再见", "再会", "敬请期待", "感谢您的光临", "欢迎光临",
|
||||
"再见", "多谢观看", "观看愉快",
|
||||
)
|
||||
|
||||
# Display-duration threshold (seconds): only mask entries longer than this.
|
||||
DEFAULT_THRESHOLD_SECONDS = 15.0
|
||||
|
||||
_SRT_BLOCK = re.compile(
|
||||
r"(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})\s*\n(.*?)(?=\n\s*\d+\s*\n|\Z)",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def mask_hallucination_text(
|
||||
entries: list[dict],
|
||||
threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS,
|
||||
) -> list[dict]:
|
||||
"""Return a new list where long-duration greeting entries have text='-'.
|
||||
|
||||
duration = end - start. Only entries whose duration >= threshold AND text
|
||||
contains any HALLUCINATION_TOKENS are masked. Input list is not mutated.
|
||||
"""
|
||||
cleaned = []
|
||||
for entry in entries:
|
||||
duration = entry.get("end", 0.0) - entry.get("start", 0.0)
|
||||
text = entry.get("text", "")
|
||||
if duration >= threshold_seconds and any(t in text for t in HALLUCINATION_TOKENS):
|
||||
entry = dict(entry, text="-")
|
||||
cleaned.append(entry)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _ts_to_seconds(ts: str) -> float:
|
||||
"""Convert an SRT timestamp HH:MM:SS,mmm to seconds (float)."""
|
||||
hours, minutes, rest = ts.split(":")
|
||||
seconds, millis = rest.split(",")
|
||||
return int(hours) * 3600 + int(minutes) * 60 + int(seconds) + int(millis) / 1000
|
||||
|
||||
|
||||
def clean_srt_text(
|
||||
srt_text: str,
|
||||
threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS,
|
||||
) -> str:
|
||||
"""Mask long-duration greeting hallucinations in an SRT string.
|
||||
|
||||
Parses each cue's start/end/text, applies mask_hallucination_text, and
|
||||
rewrites the block keeping the original time line when not masked.
|
||||
"""
|
||||
def _replace(match) -> str:
|
||||
start = _ts_to_seconds(match.group(1))
|
||||
end = _ts_to_seconds(match.group(2))
|
||||
text = match.group(3).strip()
|
||||
entry = {"start": start, "end": end, "text": text}
|
||||
cleaned = mask_hallucination_text([entry], threshold_seconds)
|
||||
new_text = cleaned[0]["text"]
|
||||
return f"{match.group(1)} --> {match.group(2)}\n{new_text}"
|
||||
|
||||
return _SRT_BLOCK.sub(_replace, srt_text)
|
||||
@@ -109,23 +109,50 @@ def prompt_rule_candidates() -> list[Path]:
|
||||
# SRT 解析(纯函数,供参考与产物共同使用)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SRT_BLOCK_RE = re.compile(
|
||||
r"(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})\s*\n(.*?)(?=\n\s*\d+\s*\n|\Z)",
|
||||
re.DOTALL,
|
||||
# 匹配 SRT 时间轴行(时间戳 --> 时间戳),作为条目边界。
|
||||
_SRT_TIME_LINE_RE = re.compile(
|
||||
r"(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})",
|
||||
)
|
||||
|
||||
|
||||
def parse_srt_entries(text: str) -> list[dict]:
|
||||
"""解析 SRT 为 [{start, end, text}](秒为单位)。"""
|
||||
"""解析 SRT 为 [{start, end, text}](秒为单位)。
|
||||
|
||||
按行分块:一个条目 = 序号行 + 时间轴行 + 若干文本行(可空)。即使文本为
|
||||
空串(如翻译补空占位、空字幕)也计入一条,时间轴不丢失。"""
|
||||
entries: list[dict] = []
|
||||
for match in _SRT_BLOCK_RE.finditer(text):
|
||||
lines = text.splitlines()
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
line = lines[index].strip()
|
||||
# 跳过序号行与空行,找时间轴行。
|
||||
if not line or not _SRT_TIME_LINE_RE.search(line):
|
||||
index += 1
|
||||
continue
|
||||
match = _SRT_TIME_LINE_RE.search(line)
|
||||
start = _ts_to_seconds(match.group(1))
|
||||
end = _ts_to_seconds(match.group(2))
|
||||
index += 1
|
||||
# 收集后续非序号、非时间轴的文本行(可空/多行),直到空行或序号行。
|
||||
text_parts: list[str] = []
|
||||
while index < len(lines):
|
||||
nxt = lines[index].strip()
|
||||
if not nxt:
|
||||
break # 空行:条目结束
|
||||
if _SRT_TIME_LINE_RE.search(nxt):
|
||||
break # 下一个时间轴:条目结束
|
||||
if nxt.isdigit():
|
||||
break # 下一个序号:条目结束
|
||||
text_parts.append(nxt)
|
||||
index += 1
|
||||
entries.append(
|
||||
{
|
||||
"start": _ts_to_seconds(match.group(1)),
|
||||
"end": _ts_to_seconds(match.group(2)),
|
||||
"text": match.group(3).strip().replace("\n", " "),
|
||||
"start": start,
|
||||
"end": end,
|
||||
"text": " ".join(text_parts),
|
||||
}
|
||||
)
|
||||
index += 1
|
||||
return entries
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""长时寒暄幻觉词清洗测试(先红后绿)。
|
||||
|
||||
背景(实测 run 20260905115050):修复时间对齐后,字幕仍残留四类问题,
|
||||
其中"寒暄/收尾幻觉词"最具确定性、可规则化:
|
||||
|
||||
- 产物里 '晚安 / 感谢观看 / 感谢收看 / 感谢您的观看' 等固定套话出现 36 次;
|
||||
- 其中 **33 条展示时长 = 30s(整块占满)**,明显是 ASR/LLM 对无内容段
|
||||
的音量幻觉占位,与视频内容毫无关系;
|
||||
- 仅 2 条时长 ~2s(如 720.00-722.00 '晚安')可能是剧情里真的说了"晚安",
|
||||
属于真实内容,不应误删。
|
||||
|
||||
方案(用户确认):日文转译完成后,对**展示时长过长**(≥阈值)且文本匹配
|
||||
寒暄词表的条目,把文本替换为 '-' 占位,由后续处理(SRT/过滤流程)移除。
|
||||
这样既清掉幻觉占位,又用"时长阈值"保住可能为真实对话的短时寒暄词。
|
||||
|
||||
阈值从本次真实运行实测数据判定:30s 幻觉占位 vs 2s 真实词,分界明显,
|
||||
本测试选 threshold=15s(>15s 才视为幻觉;≤15s 保留)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
# 清洗逻辑来自生产模块 nodes/subtitle_cleanup.py(纯函数),测试只 import。
|
||||
from nodes.subtitle_cleanup import (
|
||||
DEFAULT_THRESHOLD_SECONDS,
|
||||
HALLUCINATION_TOKENS,
|
||||
mask_hallucination_text,
|
||||
)
|
||||
|
||||
|
||||
def _mk(start: float, end: float, text: str) -> dict:
|
||||
"""构造一个字幕条目(测试辅助)。"""
|
||||
return {"start": start, "end": end, "text": text}
|
||||
|
||||
|
||||
def test_long_hallucination_masked() -> None:
|
||||
"""30s 的'晚安/感谢观看'(幻觉占位)必须被替换为 '-'。"""
|
||||
entries = [
|
||||
_mk(60.0, 90.0, "晚安"),
|
||||
_mk(90.0, 120.0, "感谢您的观看"),
|
||||
_mk(1260.0, 1289.98, "感谢您的观看"),
|
||||
]
|
||||
out = mask_hallucination_text(entries)
|
||||
assert all(e["text"] == "-" for e in out)
|
||||
|
||||
|
||||
def test_short_hallucination_preserved() -> None:
|
||||
"""2s 的'晚安'(可能为剧情真实对话)必须保留,不误删。"""
|
||||
entries = [
|
||||
_mk(720.0, 722.0, "晚安"),
|
||||
_mk(238.0, 240.0, "非常感谢您的观看。"),
|
||||
]
|
||||
out = mask_hallucination_text(entries)
|
||||
assert out[0]["text"] == "晚安"
|
||||
assert out[1]["text"] == "非常感谢您的观看。"
|
||||
|
||||
|
||||
def test_non_hallucination_always_preserved() -> None:
|
||||
"""普通内容(即使很长)绝不能被当成寒暄幻觉处理。"""
|
||||
entries = [
|
||||
_mk(0.0, 30.0, "今天我将为您提供精神调适服务"),
|
||||
_mk(10.0, 40.0, "请尽量放松,无论多少次都能感到舒适愉悦"),
|
||||
]
|
||||
out = mask_hallucination_text(entries)
|
||||
assert out[0]["text"] == "今天我将为您提供精神调适服务"
|
||||
assert out[1]["text"] == "请尽量放松,无论多少次都能感到舒适愉悦"
|
||||
|
||||
|
||||
def test_threshold_boundary() -> None:
|
||||
"""阈值边界:刚好 ≥ 阈值才清洗;< 阈值保留。"""
|
||||
entries = [
|
||||
_mk(0.0, 15.0, "晚安"), # 恰好 15s → 清洗(≥ threshold)
|
||||
_mk(0.0, 14.99, "晚安"), # 14.99s → 保留
|
||||
]
|
||||
out = mask_hallucination_text(entries, threshold_seconds=15.0)
|
||||
assert out[0]["text"] == "-"
|
||||
assert out[1]["text"] == "晚安"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_mask_does_not_mutate_input() -> None:
|
||||
"""清洗不得修改原始条目对象(纯函数约束)。"""
|
||||
entries = [_mk(60.0, 90.0, "晚安")]
|
||||
original_text = entries[0]["text"]
|
||||
mask_hallucination_text(entries)
|
||||
assert entries[0]["text"] == original_text
|
||||
@@ -666,6 +666,56 @@ def test_ass_parse_and_write(tmp_path) -> None:
|
||||
assert r"第一行\N第二行" in content
|
||||
|
||||
|
||||
def test_ass_top_aligned_and_translucent(tmp_path) -> None:
|
||||
"""验证字幕默认渲染到顶部安全区且文字/描边带透明度。
|
||||
|
||||
B-1:对齐 an8(顶部居中),MarginV 取顶部安全边距默认 120;
|
||||
透明度:文字填充 &HB3FFFFFF(约 70% 透明),描边 &H80000000(半透明黑),
|
||||
而左右眼水平相对位置一致保持零视差(A-1,字幕在屏幕平面)。"""
|
||||
entries = parse_srt(SAMPLE_SRT)
|
||||
output = tmp_path / "out.ass"
|
||||
write_ass(entries, output, "3840x1920")
|
||||
content = output.read_text(encoding="utf-8")
|
||||
# 顶部对齐 an8、顶部安全边距默认 120。
|
||||
assert r"{\an8}" in content
|
||||
assert r"{\an2}" not in content
|
||||
# 样式行:MarginV=120(顶部安全区),填充 &HB3FFFFFF,描边 &H80000000。
|
||||
assert "&HB3FFFFFF" in content
|
||||
assert "&H80000000" in content
|
||||
assert ",0,0,0,0,50,100,0,0,1,4,0,8,50,1920,120,1" in content
|
||||
# 左右眼两行文本一致(水平相对位置相同 → 零视差/A-1)。
|
||||
assert content.count(r"第一行\N第二行") == 2
|
||||
|
||||
|
||||
def test_ass_write_custom_margin_top(tmp_path) -> None:
|
||||
"""验证可通过 margin_top 覆盖顶部安全边距(不同分辨率下微调)。"""
|
||||
entries = parse_srt(SAMPLE_SRT)
|
||||
output = tmp_path / "out.ass"
|
||||
write_ass(entries, output, "1920x1080", margin_top=200)
|
||||
content = output.read_text(encoding="utf-8")
|
||||
# 1920 宽:每半幅 960;MarginV 用自定义 200。
|
||||
assert ",0,0,0,0,50,100,0,0,1,4,0,8,50,960,200,1" in content
|
||||
|
||||
|
||||
def test_ass_invoke_reads_margin_top(tmp_path) -> None:
|
||||
"""验证 invoke 从请求参数读取 margin_top 并反映到产物样式。"""
|
||||
source = tmp_path / "in.srt"
|
||||
source.write_text(SAMPLE_SRT, encoding="utf-8")
|
||||
response = ass_invoke(
|
||||
InvokeRequest(
|
||||
run_id="run_mt",
|
||||
node_instance_id="ni_mt",
|
||||
inputs={"cn_srt_uri": str(source)},
|
||||
params={"resolution": "1920x1080", "margin_top": 90},
|
||||
output_dir=str(tmp_path / "out"),
|
||||
)
|
||||
)
|
||||
assert response.status == "completed"
|
||||
content = Path(response.outputs["ass_uri"]).read_text(encoding="utf-8")
|
||||
assert "an8" in content
|
||||
assert ",0,0,0,0,50,100,0,0,1,4,0,8,50,960,90,1" in content
|
||||
|
||||
|
||||
def test_ass_parse_malformed(tmp_path) -> None:
|
||||
"""验证畸形 SRT 不会抛出异常且返回空条目或忽略坏行。"""
|
||||
source = tmp_path / "bad.srt"
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""专有名词(不应直译)处理规则测试(先红后绿)。
|
||||
|
||||
背景(实测 run 20260905115050):字幕中片假名专名被 LLM 按读音硬译——
|
||||
'ジンゴ' 被误译成 '芒果'(4500s"看,跟芒果摩擦好多"),参考正确为
|
||||
'肉棒摩擦小穴' 等;人名 'カンタくん' 被保留日文而非音译。LLM 需被告知
|
||||
这些专名/拟声词的正确处理方式。
|
||||
|
||||
方案(用户确认):整理专名表,翻译时若原文命中则向提示词动态注入规则,
|
||||
让 LLM 正确处理。本测试验证 `build_proper_noun_rule` 的命中与注入行为
|
||||
(纯函数),以及真实数据场景的端到端效果(真实 LLM 集成)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nodes.proper_nouns import build_proper_noun_rule
|
||||
|
||||
WORKSPACE = Path(__file__).resolve().parent.parent
|
||||
# 真实中文字幕产物(含"芒果"误译)。目录按实际路径调整。
|
||||
PROD = Path("/home/cat/Downloads/39.105.149.197/202609051952/CJOD-255-长视频-单行字幕.zh-CN.20260905115050.srt")
|
||||
# 真实日文原文 transcript(含 ジンゴ/カンタくん 等专名)。
|
||||
TRANSCR = Path("/home/cat/Downloads/39.105.149.197/202609051737/run_51242078d76e/steps/asr/transcript.srt")
|
||||
|
||||
|
||||
def test_rule_returns_none_when_no_proper_noun() -> None:
|
||||
"""原文不含任何专名时,不注入规则(返回 None),避免干扰普通翻译。"""
|
||||
plain = "今天天气真好。\n我们一起去散步吧。"
|
||||
assert build_proper_noun_rule(plain) is None
|
||||
|
||||
|
||||
def test_rule_injects_for_jingo() -> None:
|
||||
"""原文含'ジンゴ'(角色/道具专名)时,注入'禁止译作芒果'的规则。"""
|
||||
text = "クモ穴にパンパンになって ジンゴがここで味わいませんか"
|
||||
rule = build_proper_noun_rule(text)
|
||||
assert rule is not None
|
||||
assert "ジンゴ" in rule
|
||||
assert "芒果" in rule # 提示禁止硬译
|
||||
assert "不要按读音或字面硬译" in rule # 提示禁止硬译
|
||||
|
||||
def test_rule_injects_for_kanta_kun() -> None:
|
||||
"""原文含'カンタくん'(人名)时,注入'音译勿保留日文'的规则。"""
|
||||
text = "ねえ、カンタくん、4つんばんになってください"
|
||||
rule = build_proper_noun_rule(text)
|
||||
assert rule is not None
|
||||
assert "カンタくん" in rule
|
||||
assert "康太君" in rule or "坎塔君" in rule
|
||||
|
||||
|
||||
def test_rule_injects_onomatopoeia() -> None:
|
||||
"""原文含拟声词'パンパン'时,注入'鼓胀、饱满'而非'砰砰'的规则。"""
|
||||
text = "クモ穴にパンパンになって"
|
||||
rule = build_proper_noun_rule(text)
|
||||
assert rule is not None
|
||||
assert "パンパン" in rule
|
||||
assert "砰砰" in rule # 提示禁止直译
|
||||
|
||||
|
||||
def test_rule_list_input() -> None:
|
||||
"""传入列表(每批字幕行)也能命中。"""
|
||||
lines = ["音楽", "ご来店ありがとうございます", "ジンゴがここで味わいませんか"]
|
||||
rule = build_proper_noun_rule(lines)
|
||||
assert rule is not None
|
||||
assert "ジンゴ" in rule
|
||||
|
||||
|
||||
def test_rule_injects_adult_euphemism_mango() -> None:
|
||||
"""成人隐语'マンゴー':应注入'小穴/鲍鱼'而非直译'芒果'。"""
|
||||
text = "クモ穴にマンゴーをこすり合わせて"
|
||||
rule = build_proper_noun_rule(text)
|
||||
assert rule is not None
|
||||
assert "マンゴー" in rule
|
||||
assert "小穴" in rule or "鲍鱼" in rule
|
||||
assert "芒果" in rule
|
||||
|
||||
|
||||
def test_rule_injects_adult_euphemism_kintama() -> None:
|
||||
"""成人隐语'金玉':应注入'蛋蛋/睾丸'而非直译'金玉'。"""
|
||||
text = "金玉が大きくなってきた"
|
||||
rule = build_proper_noun_rule(text)
|
||||
assert rule is not None
|
||||
assert "金玉" in rule
|
||||
assert "蛋蛋" in rule or "睾丸" in rule
|
||||
|
||||
|
||||
def test_rule_injects_adult_euphemism_banana() -> None:
|
||||
"""成人隐语'バナナ':应注入'肉棒'而非直译'香蕉'。"""
|
||||
text = "バナナをしゃぶって"
|
||||
rule = build_proper_noun_rule(text)
|
||||
assert rule is not None
|
||||
assert "バナナ" in rule
|
||||
assert "肉棒" in rule or "鸡鸡" in rule
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_real_proper_noun_rule_matches_production_data() -> None:
|
||||
"""用真实 transcript 验证:含'ジンゴ'的批次确实命中并注入规则。"""
|
||||
if not TRANSCR.is_file():
|
||||
pytest.skip("缺少真实 transcript.srt,跳过")
|
||||
from tests.realdata_contract import parse_srt_entries
|
||||
|
||||
entries = parse_srt_entries(TRANSCR.read_text(encoding="utf-8"))
|
||||
# 找到含 ジンゴ 的批次(4500-4518s 那批)。
|
||||
batch = [e["text"] for e in entries if 4490 <= e["start"] <= 4520]
|
||||
rule = build_proper_noun_rule(batch)
|
||||
assert rule is not None, "真实数据中含 ジンゴ,应命中专名规则"
|
||||
assert "ジンゴ" in rule and "芒果" in rule
|
||||
Reference in New Issue
Block a user