Files
vrsub/nodes/ass.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

185 lines
8.1 KiB
Python
Executable File
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.
"""SRT 转 ASS 节点。
单体版中作为进程内节点模块,由调度器直接调用。解析标准 SRT 后生成 ASS
文件,其中同一句字幕同时输出 LeftEye 与 RightEye 两个样式,分别落在屏幕
左右两半,形成 VR 双眼叠加效果。
"""
from __future__ import annotations
from pathlib import Path
from wov_sdk.models import InvokeRequest, InvokeResponse
# ---------------------------------------------------------------------------
# 统一 ASS 样式常量(单一事实来源)
# ---------------------------------------------------------------------------
# 说明:新生成字幕(write_ass/invoke)与"历史字幕统一脚本"
# scripts/unify_ass_style.py)共用下面这套样式定义——要调整字幕样式
# (位置/透明度/描边等)只改这里,两条输出路径保持一致,不会各自漂移。
#
# 2026-09 调整:默认顶部安全边距 DEFAULT_MARGIN_TOP 由 120 改为 700。
# 旧值 120 顶部对齐时字幕贴近画面最顶端,VR 头盔里需抬头才看得到;
# 700 为实测合适值,字幕落在更接近视线自然平视的高度。
DEFAULT_MARGIN_TOP = 700
# 左右眼样式行字段(列顺序与 ASS Style Format 一一对应):
# - PrimaryColour &HB3FFFFFF:约 70% 透明文字填充,弱化对画面的遮挡;
# - OutlineColour &H80000000:半透明黑描边(取代早期实心纯黑),保留可读性
# 又不产生生硬黑框;
# - Alignment 8\an8 顶部居中):配合 MarginV 形成顶部安全区——避开画面
# 中央人脸高发区,同时落在视线自然高度。
_ASS_FONT = "Arial"
_ASS_FONT_SIZE = 50
_ASS_PRIMARY = "&HB3FFFFFF"
_ASS_SECONDARY = "&H000000FF"
_ASS_OUTLINE = "&H80000000"
_ASS_BACK = "&H80000000"
_ASS_SCALE_X = 50
_ASS_SCALE_Y = 100
_ASS_BORDER_STYLE = 1
_ASS_OUTLINE_WIDTH = 4
_ASS_SHADOW = 0
_ASS_ALIGN = 8
_ASS_ENCODING = 1
_EYE_PAD = 50 # 左眼距屏幕左缘 / 右眼距右缘的水平内边距
# 样式/事件表头格式行(列顺序即上面注释的顺序,勿改动)。
ASS_STYLE_FORMAT = (
"Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,"
"BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,"
"BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding"
)
ASS_EVENT_FORMAT = "Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text"
def style_row(eye: str, width: int, margin_top: int = DEFAULT_MARGIN_TOP) -> str:
"""生成单眼(LeftEye/RightEye)的完整 ASS 样式行。
左眼占左半幅(左缘留 _EYE_PAD 内边距、向中线收 50%),右眼占右半幅,
两眼的水平相对位置一致 → 零视差(A-1):字幕渲染在屏幕平面,不产生
额外景深冲突。margin_top 即该眼样式的 MarginV(距画面上缘的安全边距)。"""
mid = width // 2
margin_l, margin_r = (_EYE_PAD, mid) if eye == "LeftEye" else (mid, _EYE_PAD)
return (
f"Style: {eye},{_ASS_FONT},{_ASS_FONT_SIZE},{_ASS_PRIMARY},{_ASS_SECONDARY},"
f"{_ASS_OUTLINE},{_ASS_BACK},0,0,0,0,{_ASS_SCALE_X},{_ASS_SCALE_Y},0,0,"
f"{_ASS_BORDER_STYLE},{_ASS_OUTLINE_WIDTH},{_ASS_SHADOW},{_ASS_ALIGN},"
f"{margin_l},{margin_r},{margin_top},{_ASS_ENCODING}"
)
def ass_header(width: int, height: int, margin_top: int = DEFAULT_MARGIN_TOP) -> str:
"""生成标准 ASS 文件头:Script Info(含分辨率)+ 左右眼样式 + 事件格式行。
新生成字幕与历史字幕统一脚本共用此函数,保证两者输出样式完全一致。"""
return (
"[Script Info]\n"
"Title: VR Dual-Eye Subtitle\n"
"ScriptType: v4.00+\n"
"Collisions: Normal\n"
f"PlayResX: {width}\n"
f"PlayResY: {height}\n"
"WrapStyle: 1\n"
"ScaledBorderAndShadow: yes\n"
"\n"
"[V4+ Styles]\n"
f"{ASS_STYLE_FORMAT}\n"
f"{style_row('LeftEye', width, margin_top)}\n"
f"{style_row('RightEye', width, margin_top)}\n"
"\n"
"[Events]\n"
f"{ASS_EVENT_FORMAT}\n"
)
def dialogue_line(style: str, start: str, end: str, text: str) -> str:
"""生成一行标准 Dialogue 事件。
文本前缀固定 \\an8 顶部居中对齐(与样式 Alignment 一致),使每句字幕
都落到样式定义的顶部安全区位置。"""
return f"Dialogue: 0,{start},{end},{style},,0,0,0,,{{\\an8}}{text}"
def _ass_header(resolution: str, margin_top: int = DEFAULT_MARGIN_TOP) -> str:
"""兼容旧接口的 ASS 头生成:resolution 形如 "3840x1920"。
内部委托给 ass_header()(统一样式出口),仅负责把字符串分辨率解析为
整数宽高。"""
width, height = (int(part) for part in resolution.lower().split("x", 1))
return ass_header(width, height, margin_top=margin_top)
def parse_srt(text: str) -> list[tuple[str, str, str]]:
"""把 SRT 文本解析为 (开始时间, 结束时间, 文本) 条目列表。"""
entries: list[tuple[str, str, str]] = []
lines = text.splitlines()
index = 0
while index < len(lines):
# 跳过序号前的空行,兼容文件开头有换行的情况。
if not lines[index].strip():
index += 1
continue
# 跳过序号行,直接读取下一行时间轴。
index += 1
if index >= len(lines):
break
time_line = lines[index].strip()
index += 1
# 时间轴必须包含分隔符,否则按畸形输入跳过。
if " --> " not in time_line:
continue
# SRT 使用逗号毫秒,ASS 使用点号,需要转换。
start, end = [part.replace(",", ".") for part in time_line.split(" --> ")]
# 连续读取非空行作为字幕文本,多行用 ASS 换行符 \N 连接。
text_lines: list[str] = []
while index < len(lines) and lines[index].strip():
text_lines.append(lines[index])
index += 1
entries.append((start, end, r"\N".join(text_lines)))
index += 1
return entries
def write_ass(
entries: list[tuple[str, str, str]],
output_path: Path,
resolution: str,
margin_top: int = DEFAULT_MARGIN_TOP,
) -> None:
"""把解析后的条目写入 ASS 文件,每个条目输出左右眼两行 Dialogue。
margin_top 控制字幕距画面顶部的安全边距(默认 700,2026-09 起),顶部对齐(\an8
使字幕整体落在顶部安全区下方。左右眼使用相同文本与水平相对位置(零视差,A-1)。"""
width, height = (int(part) for part in resolution.lower().split("x", 1))
header = ass_header(width, height, margin_top=margin_top)
# an8 对齐到屏幕顶部,配合 MarginV 形成顶部安全区,避开中央人脸区域。
lines = [header.rstrip("\n")]
for start, end, text in entries:
lines.append(dialogue_line("LeftEye", start, end, text))
lines.append(dialogue_line("RightEye", start, end, text))
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def invoke(request: InvokeRequest) -> InvokeResponse:
"""把 cn_srt_uri 指向的 SRT 转为 dual_eye.ass 产物。"""
srt_uri = request.inputs.get("cn_srt_uri")
if not srt_uri:
return InvokeResponse(status="failed", error="cn_srt_uri is required")
srt_path = Path(srt_uri)
if not srt_path.is_file():
return InvokeResponse(status="failed", error="srt file not found")
entries = parse_srt(srt_path.read_text(encoding="utf-8"))
output_dir = Path(request.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "dual_eye.ass"
# 分辨率默认 3840x1920,覆盖常见 VR 视频尺寸。
resolution = str(request.params.get("resolution", "3840x1920"))
# margin_top 可选:顶部安全边距(默认 700,2026-09 起),不同分辨率/内容
# 仍可用工作流参数微调(历史 120 已过时,勿再使用)。
margin_top = int(request.params.get("margin_top", 700))
write_ass(entries, output_path, resolution, margin_top=margin_top)
return InvokeResponse(status="completed", outputs={"ass_uri": str(output_path)})