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:
+109
-40
@@ -11,40 +11,103 @@ from pathlib import Path
|
||||
|
||||
from wov_sdk.models import InvokeRequest, InvokeResponse
|
||||
|
||||
def _ass_header(resolution: str, margin_top: int = 120) -> str:
|
||||
"""生成 ASS 文件头:脚本信息、左右眼样式和事件格式。
|
||||
# ---------------------------------------------------------------------------
|
||||
# 统一 ASS 样式常量(单一事实来源)
|
||||
# ---------------------------------------------------------------------------
|
||||
# 说明:新生成字幕(write_ass/invoke)与"历史字幕统一脚本"
|
||||
# (scripts/unify_ass_style.py)共用下面这套样式定义——要调整字幕样式
|
||||
# (位置/透明度/描边等)只改这里,两条输出路径保持一致,不会各自漂移。
|
||||
#
|
||||
# 2026-09 调整:默认顶部安全边距 DEFAULT_MARGIN_TOP 由 120 改为 700。
|
||||
# 旧值 120 顶部对齐时字幕贴近画面最顶端,VR 头盔里需抬头才看得到;
|
||||
# 700 为实测合适值,字幕落在更接近视线自然平视的高度。
|
||||
DEFAULT_MARGIN_TOP = 700
|
||||
|
||||
样式说明(B-1 顶部安全区 + 透明度):
|
||||
- 对齐 8(顶部居中,\an8),MarginV=margin_top:字幕渲染在画面顶部安全区,
|
||||
避开人脸/近景主体常出现的画面中央偏下区域,降低遮挡;
|
||||
- PrimaryColour &HB3FFFFFF:约 70% 透明文字填充,比原先 &H80FFFFFF(约 50%)
|
||||
更通透,减少对场景的遮挡感;
|
||||
- OutlineColour &H80000000:半透明黑描边(不再是实心纯黑),
|
||||
在保留可读性的同时不产生生硬黑框;
|
||||
- 左右眼水平边距一致(LeftEye/RightEye 各占左右半幅)⇒ 水平相对位置相同,
|
||||
即零视差(A-1):字幕被渲染在屏幕平面,不产生额外景深冲突。
|
||||
"""
|
||||
width, height = resolution.lower().split("x", 1)
|
||||
# 左眼样式占左半边,右眼样式占右半边,各留 50px 内边距。
|
||||
left_margin = 50
|
||||
right_margin = 50
|
||||
return f"""[Script Info]
|
||||
Title: VR Dual-Eye Subtitle
|
||||
ScriptType: v4.00+
|
||||
Collisions: Normal
|
||||
PlayResX: {width}
|
||||
PlayResY: {height}
|
||||
WrapStyle: 1
|
||||
ScaledBorderAndShadow: yes
|
||||
# 左右眼样式行字段(列顺序与 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 # 左眼距屏幕左缘 / 右眼距右缘的水平内边距
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding
|
||||
Style: LeftEye,Arial,50,&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,{left_margin},{int(width) // 2},{margin_top},1
|
||||
Style: RightEye,Arial,50,&HB3FFFFFF,&H000000FF,&H80000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,8,{int(width) // 2},{right_margin},{margin_top},1
|
||||
# 样式/事件表头格式行(列顺序即上面注释的顺序,勿改动)。
|
||||
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"
|
||||
|
||||
[Events]
|
||||
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]]:
|
||||
@@ -79,17 +142,22 @@ def parse_srt(text: str) -> list[tuple[str, str, str]]:
|
||||
|
||||
|
||||
def write_ass(
|
||||
entries: list[tuple[str, str, str]], output_path: Path, resolution: str, margin_top: int = 120
|
||||
entries: list[tuple[str, str, str]],
|
||||
output_path: Path,
|
||||
resolution: str,
|
||||
margin_top: int = DEFAULT_MARGIN_TOP,
|
||||
) -> None:
|
||||
"""把解析后的条目写入 ASS 文件,每个条目输出左右眼两行 Dialogue。
|
||||
|
||||
margin_top 控制字幕距画面顶部的安全边距(默认 120),顶部对齐(\an8)
|
||||
使字幕整体落在顶部安全区。左右眼使用相同文本与水平相对位置(零视差,A-1)。"""
|
||||
lines = [_ass_header(resolution, margin_top=margin_top)]
|
||||
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:
|
||||
# an8 对齐到屏幕顶部,配合 MarginV 形成顶部安全区,避开中央人脸区域。
|
||||
lines.append(f"Dialogue: 0,{start},{end},LeftEye,,0,0,0,,{{\\an8}}{text}")
|
||||
lines.append(f"Dialogue: 0,{start},{end},RightEye,,0,0,0,,{{\\an8}}{text}")
|
||||
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:
|
||||
@@ -108,8 +176,9 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||
output_path = output_dir / "dual_eye.ass"
|
||||
# 分辨率默认 3840x1920,覆盖常见 VR 视频尺寸。
|
||||
resolution = str(request.params.get("resolution", "3840x1920"))
|
||||
# margin_top 可选:顶部安全边距,不同分辨率/内容可用工作流参数微调。
|
||||
margin_top = int(request.params.get("margin_top", 120))
|
||||
# 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)})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user