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:
@@ -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())
|
||||
Reference in New Issue
Block a user