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
+27 -4
View File
@@ -678,20 +678,21 @@ def test_ass_parse_and_write(tmp_path) -> None:
def test_ass_top_aligned_and_translucent(tmp_path) -> None:
"""验证字幕默认渲染到顶部安全区且文字/描边带透明度。
B-1:对齐 an8(顶部居中),MarginV 取顶部安全边距默认 120
B-1:对齐 an8(顶部居中),MarginV 取顶部安全边距默认 7002026-09 起,
字幕置于视线自然可读位置;旧默认 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。
# 顶部对齐 an8、顶部安全边距默认 700。
assert r"{\an8}" in content
assert r"{\an2}" not in content
# 样式行:MarginV=120(顶部安全区),填充 &HB3FFFFFF,描边 &H80000000。
# 样式行:MarginV=700(顶部安全区),填充 &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
assert ",0,0,0,0,50,100,0,0,1,4,0,8,50,1920,700,1" in content
# 左右眼两行文本一致(水平相对位置相同 → 零视差/A-1)。
assert content.count(r"第一行\N第二行") == 2
@@ -725,6 +726,28 @@ def test_ass_invoke_reads_margin_top(tmp_path) -> None:
assert ",0,0,0,0,50,100,0,0,1,4,0,8,50,960,90,1" in content
def test_ass_invoke_default_margin_top_is_700(tmp_path) -> None:
"""验证 invoke 未显式传 margin_top 时默认生成 MarginV=700 的样式。
保证之后生成的字幕默认都落在顶部安全区下方(2026-09 起),
不再回退到旧的 120(需抬头观看)。"""
source = tmp_path / "in.srt"
source.write_text(SAMPLE_SRT, encoding="utf-8")
response = ass_invoke(
InvokeRequest(
run_id="run_def700",
node_instance_id="ni_def700",
inputs={"cn_srt_uri": str(source)},
params={"resolution": "1920x1080"},
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,700,1" in content
def test_ass_parse_malformed(tmp_path) -> None:
"""验证畸形 SRT 不会抛出异常且返回空条目或忽略坏行。"""
source = tmp_path / "bad.srt"
+231
View File
@@ -0,0 +1,231 @@
"""历史 VR 双目字幕统一脚本(scripts/unify_ass_style.py)测试。
背景
----
`scripts/unify_ass_style.py` 负责把媒体库中由旧版本生成、混有多种历史样式
的 *.CN_dual_eye.ass 原地改写为 nodes/ass.py 当前统一样式
an8 顶部对齐 + 70% 透明 + DEFAULT_MARGIN_TOP=700)。
本测试验证三条关键路径:
1. 各历史样式(底部实心白 an2+MarginV1200 / 底部半透明 an2+MarginV1020 /
顶部 120 / 已是 700)都能正确重建为统一的 700 样式;
2. 重建结果与 nodes/ass.py 的 ass_header()/dialogue_line()(新生成字幕的
唯一出口)**逐字节一致**,防止"新字幕""历史改写"两套逻辑漂移;
3. 脚本文件能被直接加载执行(仓库根运行 uv run python scripts/...),
且 rewrite_content 对非 VR 字幕返回 None(不误伤普通 ASS 文件)。
测试使用真实 ASS 内容构造(Script Info + 样式 + 事件),不 mock 文件系统
之外的任何逻辑。
"""
from __future__ import annotations
import importlib.util
import subprocess
import sys
from pathlib import Path
import pytest
# 仓库根(tests/ 的上一级),用于导入 scripts/unify_ass_style.py。
WORKSPACE = Path(__file__).resolve().parent.parent
SCRIPT_PATH = WORKSPACE / "scripts" / "unify_ass_style.py"
# 用真实可执行的统一脚本构造各历史样式样本:以下文本代表旧版实际输出格式。
HEADER_3840_OLD = """[Script Info]
Title: VR Dual-Eye Subtitle
ScriptType: v4.00+
Collisions: Normal
PlayResX: 3840
PlayResY: 1920
WrapStyle: 1
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
"""
def _load_module():
"""按文件路径加载 scripts/unify_ass_style.pyscripts 不是包,无法 import)。
与仓库运行方式一致:uv run python scripts/unify_ass_style.py 时 pythonpath
含仓库根,模块内 `from nodes.ass import ...` 可正常解析。"""
spec = importlib.util.spec_from_file_location("unify_ass_style", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
assert spec is not None and spec.loader is not None
spec.loader.exec_module(module)
return module
U = _load_module()
@pytest.fixture
def sandbox(tmp_path):
"""构造含三种历史样式 + 一个非 VR 文件的临时媒体库。
每个测试独立临时目录(function scope),CLI 写盘测试不会污染其他测试。"""
root = tmp_path
# 1) 最早期:底部 an2 + 实心白 &H00FFFFFF + MarginV=1200。
(root / "old_solid.CN_dual_eye.ass").write_text(
HEADER_3840_OLD
+ "Style: LeftEye,Arial,50,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,50,1970,1200,1\n"
+ "Style: RightEye,Arial,50,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,1970,50,1200,1\n"
+ "\n[Events]\n"
+ "Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text\n"
+ 'Dialogue: 0,00:00:00.000,00:00:03.000,LeftEye,,0,0,0,,{\\an2}第一行\\N第二行\n'
+ 'Dialogue: 0,00:00:00.000,00:00:03.000,RightEye,,0,0,0,,{\\an2}第一行\\N第二行\n',
encoding="utf-8",
)
# 2) 过渡期:底部 an2 + 半透明 &H80FFFFFF + MarginV=1020。
(root / "mid_translucent.CN_dual_eye.ass").write_text(
HEADER_3840_OLD
+ "Style: LeftEye,Arial,50,&H80FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,50,1970,1020,1\n"
+ "Style: RightEye,Arial,50,&H80FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,1970,50,1020,1\n"
+ "\n[Events]\n"
+ "Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text\n"
+ 'Dialogue: 0,00:00:00.000,00:00:03.000,LeftEye,,0,0,0,,{\\an2}你好\n',
encoding="utf-8",
)
# 3) 已是顶部 120 样式(旧代码默认),仍应规整为 700。
(root / "top120.CN_dual_eye.ass").write_text(
HEADER_3840_OLD
+ "Style: LeftEye,Arial,50,&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,50,1920,120,1\n"
+ "Style: RightEye,Arial,50,&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,1920,50,120,1\n"
+ "\n[Events]\n"
+ "Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text\n"
+ 'Dialogue: 0,00:00:00.000,00:00:03.000,LeftEye,,0,0,0,,{\\an8}第三行\n',
encoding="utf-8",
)
# 4) 非 VR 字幕(无 LeftEye/RightEye 样式),脚本不应改动。
(root / "not_vr.CN_dual_eye.ass").write_text(
"[Script Info]\nPlayResX: 1920\nPlayResY: 1080\n\n[V4+ Styles]\n"
"Format: Name,Fontname,Fontsize,PrimaryColour,...\nStyle: Default,Arial,20,&H00FFFFFF,...\n",
encoding="utf-8",
)
return root
def _read(path: Path) -> str:
"""以 UTF-8 读取测试文件内容。"""
return path.read_text(encoding="utf-8")
def test_rewrite_converges_all_generations(sandbox) -> None:
"""三种历史样式统一后都含 an8 顶部对齐 + 70% 透明 + MarginV=700。"""
# (文件名, 该文件期望保留的事件文本)
expected_text = {
"old_solid": r"第一行\N第二行", # 最早期多行字幕(an2 底部)
"mid_translucent": "你好", # 过渡期
"top120": "第三行", # 旧顶部 120
}
for name, text in expected_text.items():
original = _read(sandbox / f"{name}.CN_dual_eye.ass")
rewritten = U.rewrite_content(original)
assert rewritten is not None, f"{name} 应被改写"
# 左/右眼样式都落到统一值:70%透明 + an8 + MarginV=700。
assert "&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,50,1920,700,1" in rewritten
assert "&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,1920,50,700,1" in rewritten
# 对齐标签全部为 an8,不再残留 an2。
assert r"{\an8}" in rewritten
assert r"{\an2}" not in rewritten
# 事件文本原样保留。
assert text in rewritten
def test_rewrite_byte_identical_to_write_ass(sandbox) -> None:
"""历史字幕统一后的输出与 write_ass() 生成的新字幕逐字节一致。
防止"统一脚本""节点生成"两套样式逻辑漂移:两边都经由 ass_header()
/dialogue_line()nodes/ass.py 单一出口)。"""
from nodes.ass import DEFAULT_MARGIN_TOP, ass_header, dialogue_line
# 取 old_solid 样本的事件,用 write_ass 语义重建期望文本。
original = _read(sandbox / "old_solid.CN_dual_eye.ass")
rewritten = U.rewrite_content(original)
assert rewritten is not None
hdr = ass_header(3840, 1920, margin_top=DEFAULT_MARGIN_TOP).rstrip("\n")
expected = "\n".join(
[
hdr,
dialogue_line("LeftEye", "00:00:00.000", "00:00:03.000", "第一行\\N第二行"),
dialogue_line("RightEye", "00:00:00.000", "00:00:03.000", "第一行\\N第二行"),
]
) + "\n"
assert rewritten == expected
def test_rewrite_skips_non_vr_and_already_canonical(sandbox) -> None:
"""非 VR 字幕返回 None;已是目标样式(MarginV=700)也返回 None(幂等)。"""
not_vr = _read(sandbox / "not_vr.CN_dual_eye.ass")
assert U.rewrite_content(not_vr) is None
# 构造一份已是 700 的新样式文本,改写应返回 None(内容不变)。
from nodes.ass import ass_header, dialogue_line
canonical = (
ass_header(3840, 1920, margin_top=U.DEFAULT_MARGIN_TOP).rstrip("\n")
+ "\n"
+ dialogue_line("LeftEye", "00:00:00.000", "00:00:03.000", "")
+ "\n"
)
assert U.rewrite_content(canonical) is None
def test_module_runs_as_cli_dry_run(sandbox) -> None:
"""脚本可作为 CLI 以 dry-run 方式运行(uv run python scripts/...),不写盘。
复现仓库根运行方式;断言 dry-run 输出含"旧样式分布""将改写"
且运行后沙盒内文件一个都没被改动(dry-run 语义)。"""
before = {p.name: _read(p) for p in sandbox.glob("*.ass")}
# 不带 --apply = dry-run:应只打印不写盘。
proc = subprocess.run(
[sys.executable, str(SCRIPT_PATH), str(sandbox)],
capture_output=True,
text=True,
cwd=WORKSPACE, # 与 uv run python 一致:仓库根在 sys.path
)
assert proc.returncode == 0, proc.stderr
out = proc.stdout
assert "[将改写]" in out # 预览行标记
assert "[改写]" not in out # 未真正写盘
after = {p.name: _read(p) for p in sandbox.glob("*.ass")}
assert before == after # dry-run 不改动任何文件
def test_module_cli_apply_writes_files(sandbox) -> None:
"""脚本加 --apply 后真正原地改写,写盘结果与 rewrite_content 一致。
覆盖 main() 的写盘分支:统计输出显示 [改写],且文件内容已变为
MarginV=700 的统一新样式(第二次运行幂等,不再改写)。"""
# 备份一个将改写的样本,确保 CLI 走的是与单元层相同的重建逻辑。
old_solid = sandbox / "old_solid.CN_dual_eye.ass"
expected = U.rewrite_content(old_solid.read_text(encoding="utf-8"))
assert expected is not None
proc = subprocess.run(
[sys.executable, str(SCRIPT_PATH), str(sandbox), "--apply"],
capture_output=True,
text=True,
cwd=WORKSPACE,
)
assert proc.returncode == 0, proc.stderr
assert "[改写]" in proc.stdout
assert old_solid.read_text(encoding="utf-8") == expected
# 幂等:第二次 --apply 不再有任何 [改写](文件已是目标样式)。
proc2 = subprocess.run(
[sys.executable, str(SCRIPT_PATH), str(sandbox), "--apply"],
capture_output=True,
text=True,
cwd=WORKSPACE,
)
assert proc2.returncode == 0, proc2.stderr
assert "[改写]" not in proc2.stdout
def test_module_loads_and_exports() -> None:
"""模块可加载且暴露 rewrite_content / DEFAULT_MARGIN_TOP。"""
assert U.DEFAULT_MARGIN_TOP == 700
assert callable(U.rewrite_content)