Files
vrsub/nodes/ass.py
cat-shark 7a7212f70c docs: 注释规范要求精简可读,并清理生产代码中的历史叙事
AGENTS.md 的注释规范新增三节可执行约束:

- 只写代码真实逻辑:注释只回答"做什么"与"为什么必须这么做",禁止写决策/
  修改时间、历史版本对比、实测数据与实验结论、事故与缺陷编号(run_xxxx /
  batch_xxxx / R01 等)——这些属 docs/decisions.md 与审查跟踪文件;当前生效
  的约束可以写,但不附带它何时因何变成这样。
- 精简可读:单段连续注释不超过 3 行;docstring 一句话概括职责,不重复函数名
  已表达的信息;不写逐行翻译代码的废话注释,只在非显然处(业务规则、边界、
  易错点、外部约束)加注。
- 覆盖范围:测试注释只说明验证什么行为,回归用例可保留一句溯源;并明确
  参数说明应写在**参数读取处**附近,而不是把多个参数的解释堆在离使用位置
  很远的注释块里。

按此清理生产代码(注释净减 70 行,18 个文件),典型处理:

- nodes/whisper.py:删掉堆在一起、含"用户 2026-08 决定 / 实测 savr-1054"
  等叙事的参数块,把各参数说明移到各自的读取处与 model.transcribe 调用处;
- nodes/llm_filter.py、nodes/subtitle_cleanup.py:模块 docstring 去掉英文
  背景叙事与条数统计,保留"默认只跑规则层""整条删除而非 '-' 占位"等当前
  行为;
- src/wov_app/{batch,db,scheduler}.py 与 routers:去掉 batch_xxx/run_xxx 事故
  编号与"修复前……"对比,改为一句"否则会出现什么问题";
- nodes/ass.py、frame_extract.py:去掉废弃值对比与日期,保留判据本身。

安全验证:用 AST 对比(剥离 docstring 后比较语法树)确认 18 个文件**零逻辑
变更**;`nodes/proper_nouns.py` 的规则表 reason 字段会注入 LLM 提示词,属于
数据而非注释,已恢复原值。全量测试 476 passed。
2026-09-13 16:37:49 +08:00

176 lines
7.4 KiB
Python
Executable File
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.
"""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)共用,调整样式只改这里,两条输出
# 路径不会各自漂移。
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%),右眼占右半幅;
两眼水平相对位置一致 → 零视差,字幕落在屏幕平面,不引入额外景深冲突。
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 为字幕距画面顶部的安全边距,配合顶部对齐(\an8)让字幕落在
顶部安全区内。左右眼使用相同文本与水平相对位置(零视差)。"""
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 可选:不同分辨率/内容可用工作流参数微调顶部安全边距。
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)})