"""统一历史 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())