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
+227
View File
@@ -0,0 +1,227 @@
"""统一历史 VR 双目字幕(*.CN_dual_eye.ass)的样式与位置。
背景
----
`nodes/ass.py` 生成的字幕样式在迭代中多次变化,媒体库(如 /mnt/fnOS/123
里由旧版本批量生成的 *.CN_dual_eye.ass 因此混有多种历史样式:
- 最早期:底部对齐(an2+ 实心白字(&H00FFFFFF),MarginV≈1200
- 过渡期:底部对齐 + 半透明白(&H80FFFFFF),MarginV≈1020
- 2026-09 新默认:顶部对齐(an8+ 70% 透明(&HB3FFFFFF+ MarginV=120
- 用户实测合适值:顶部对齐 + 70% 透明 + MarginV=700。
本脚本把库内所有目标字幕**原地改写**为当前 nodes/ass.py 的统一样式
an8 顶部对齐 + 70% 透明 + DEFAULT_MARGIN_TOP=700,单点定义见
nodes/ass.py 顶部常量),保证"之后生成的字幕""历史字幕"外观一致。
实现要点
--------
- 不对手写字符串做手术,而是解析旧文件的 [Script Info] 分辨率与全部
Dialogue 事件后,用 nodes/ass.py 的 ass_header()/dialogue_line()
重新拼装——与代码新生成产物共用同一出口,两边永不漂移;以后要调样式
只需改 nodes/ass.py 一处常量。
- 事件文本原样保留(含原有 \\N 多行分隔与事件内其它标签),只规整前导
\\an 对齐标签:历史 an2(底部)/an8(顶部)一律统一为 an8。
- 仅改写样式声明与事件对齐标签,不触碰时间轴、事件文本与左右眼对称性。
- 文件最终以 UTF-8 写回、行尾 \\n、末尾一个换行——与 write_ass() 一致。
用法(在 vrsub 根目录执行)
----------------------------
# 预览(默认 dry-run,不改盘)
uv run python scripts/unify_ass_style.py /mnt/fnOS/123
# 真正原地改写
uv run python scripts/unify_ass_style.py /mnt/fnOS/123 --apply
# 指定递归匹配模式 / 只处理某旧 MarginV
uv run python scripts/unify_ass_style.py /mnt/fnOS/123 --pattern "*.ass"
uv run python scripts/unify_ass_style.py /mnt/fnOS/123 --only-margin 1200
安全说明
--------
- 默认 --dry-run 只打印不写入;--apply 才真正原地改写。
- 改写前脚本先打印库内旧样式分布,便于核对范围。
- 不产生 .bak 副本(用户已确认直接原地改写);如需回滚请先自行备份。
"""
from __future__ import annotations
import argparse
import re
import sys
from collections import Counter
from pathlib import Path
# 复用节点单一事实来源:统一脚本必须与"新生成字幕"完全一致。
from nodes.ass import ( # noqa: E402
DEFAULT_MARGIN_TOP,
ass_header,
dialogue_line,
)
_DIALOGUE_RE = re.compile(r"^Dialogue: ")
_AN_TAG_RE = re.compile(r"\{\\an[0-9]\}")
def parse_resolution(text: str) -> tuple[int, int] | None:
"""从 [Script Info] 解析 PlayResX/PlayResY;解析失败返回 None。"""
width = height = None
for line in text.splitlines():
m = re.match(r"^\s*PlayResX:\s*(\d+)", line)
if m:
width = int(m.group(1))
continue
m = re.match(r"^\s*PlayResY:\s*(\d+)", line)
if m:
height = int(m.group(1))
if width is None or height is None:
return None
return width, height
def inspect_style(text: str) -> tuple[str, str] | None:
"""提取旧样式 (PrimaryColour, MarginV) 用于统计/过滤。
只有存在 "Style: LeftEye" 行才视为 VR 双眼字幕产物;普通 ASS 字幕
(无左右眼样式)跳过,避免误伤。"""
for line in text.splitlines():
if line.startswith("Style: LeftEye"):
fields = line.split(",")
# 字段:Name0,Fontname1,Fontsize2,Primary3,...,Alignment18,MarginL19,MarginR20,MarginV21
primary = fields[3].strip() if len(fields) > 3 else "?"
margin_v = fields[21].strip() if len(fields) > 21 else "?"
return primary, margin_v
return None
def parse_dialogues(text: str) -> list[tuple[str, str, str, str]]:
"""抽取全部 Dialogue 事件为 (开始, 结束, 样式, 文本)。
历史文件每条可能带前导 {\an2}/{\an8} 对齐标签;这里剥掉(统一由
dialogue_line 补 an8),文本其余内容原样保留。"""
entries: list[tuple[str, str, str, str]] = []
for line in text.splitlines():
if not _DIALOGUE_RE.match(line):
continue
# Dialogue: 0,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text
parts = line.split(",", 9)
if len(parts) < 10:
continue
text_field = _AN_TAG_RE.sub("", parts[9], count=1)
entries.append((parts[1], parts[2], parts[3], text_field))
return entries
def rewrite_content(original: str) -> str | None:
"""把整份 ASS 文本重建为统一样式;已符合目标样式时返回 None。
返回的新文本 = ass_header()(新样式行,MarginV=DEFAULT_MARGIN_TOP
+ dialogue_line() 重建的全部事件,即与 write_ass() 产物逐字节一致
(除事件文本本身)。"""
res = parse_resolution(original)
if res is None:
return None # 无 PlayResX/Y,非本工具产物
width, height = res
if inspect_style(original) is None:
return None # 无 LeftEye 样式行,非 VR 双目字幕
lines = [ass_header(width, height, margin_top=DEFAULT_MARGIN_TOP).rstrip("\n")]
for start, end, style, text in parse_dialogues(original):
lines.append(dialogue_line(style, start, end, text))
rewritten = "\n".join(lines) + "\n"
return None if rewritten == original else rewritten
def main() -> int:
parser = argparse.ArgumentParser(description="统一历史 VR 双目字幕样式与位置")
parser.add_argument("root", help="要扫描的媒体库根目录(递归)")
parser.add_argument(
"--pattern",
default="*.CN_dual_eye.ass",
help="递归匹配的文件名模式(默认 *.CN_dual_eye.ass",
)
parser.add_argument(
"--apply",
action="store_true",
help="真正原地改写文件;不带此参数等价于 dry-run 预览",
)
parser.add_argument(
"--only-margin",
type=int,
default=None,
help="只处理旧样式 MarginV 等于该值的文件(如 1200),其余跳过",
)
args = parser.parse_args()
root = Path(args.root)
if not root.is_dir():
print(f"错误:目录不存在或不可读: {root}", file=sys.stderr)
return 2
files = sorted(root.rglob(args.pattern))
if not files:
print(f"未找到匹配 {args.pattern} 的文件")
return 0
# 第一遍统计:库内旧样式分布 + 圈定目标文件。
distribution: Counter[tuple[str, str]] = Counter()
unreadable = 0
target_files: list[Path] = []
for p in files:
try:
text = p.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
unreadable += 1
continue
styled = inspect_style(text)
if styled is None:
unreadable += 1
continue
primary, margin = styled
try:
margin_i = int(margin)
except ValueError:
margin_i = -1
if args.only_margin is not None and margin_i != args.only_margin:
continue
distribution[(primary, margin)] += 1
target_files.append(p)
print(f"扫描 {root}:共 {len(files)} 个匹配文件,可识别 VR 字幕 {len(target_files)},跳过 {unreadable}")
print("旧样式分布(PrimaryColour, MarginV):")
for (primary, margin), count in distribution.most_common():
print(f" {count:4d} {primary} MarginV={margin}")
if not target_files:
print("没有需要统一的文件。")
return 0
print(f"\n目标样式:an8 顶部对齐 + 70%透明(&HB3FFFFFF) + MarginV={DEFAULT_MARGIN_TOP}(与代码新生成一致)")
changed = failed = 0
for p in target_files:
try:
original = p.read_text(encoding="utf-8")
rewritten = rewrite_content(original)
except Exception as exc: # 单文件失败不中断整体
print(f" [错误] {p}: {exc}")
failed += 1
continue
if rewritten is None:
continue # 已是目标样式,跳过
changed += 1
marker = "改写" if args.apply else "将改写"
if args.apply:
p.write_text(rewritten, encoding="utf-8")
# 摘要:旧样式 -> 新样式。
styled = inspect_style(original)
old_desc = f"{styled[0]}@{styled[1]}" if styled else "?"
print(f" [{marker}] {p.relative_to(root)} ({old_desc} -> &HB3FFFFFF@{DEFAULT_MARGIN_TOP})")
print(f"\n完成:{changed} 个文件{'' if args.apply else ''}统一,{failed} 个失败跳过,其余已是最新样式。")
if not args.apply:
print("(以上为预览,未改动任何文件;确认无误后加 --apply 执行真正改写)")
return 0
if __name__ == "__main__":
raise SystemExit(main())