Files
vrsub/scripts/unify_ass_style.py
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

228 lines
8.9 KiB
Python
Raw Permalink 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 双目字幕(*.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())