Files
vrsub/tests/test_unify_ass_style.py
T
cat-shark 4d2a1912da 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)
2026-09-06 18:02:31 +08:00

232 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""历史 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)