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。
176 lines
7.4 KiB
Python
Executable File
176 lines
7.4 KiB
Python
Executable File
"""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)})
|
||
|