feat: VRSub 单体应用(WOV 单机版)初始提交
为视频生成 VR 双眼字幕的单体实现:FastAPI 后端、调度器与全部节点 (提音/转写/翻译/ASS/抽帧/OCR/LLM 过滤)在单进程内运行。 - 节点协议(wov_sdk 数据模型)与分布式版保持一致,预留回退桥梁 - 工作流即数据:DAG 存于 workflows/*.json,模型/链路改动只改数据 - 调度器:拓扑顺序执行、断点续跑(产物重建)、任务暂停/继续 - 抽帧按帧间隔(select 按帧号精确取帧),VLM OCR 与 LLM 过滤使用 自适应线程池弹性并发,并打印数据处理速度进度日志 - 100% 行覆盖率(pytest --cov-fail-under=100)
This commit is contained in:
Executable
+98
@@ -0,0 +1,98 @@
|
||||
"""SRT 转 ASS 节点。
|
||||
|
||||
单体版中作为进程内节点模块,由调度器直接调用。解析标准 SRT 后生成 ASS
|
||||
文件,其中同一句字幕同时输出 LeftEye 与 RightEye 两个样式,分别落在屏幕
|
||||
左右两半,形成 VR 双眼叠加效果。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from wov_sdk.models import InvokeRequest, InvokeResponse
|
||||
|
||||
def _ass_header(resolution: str) -> str:
|
||||
"""生成 ASS 文件头:脚本信息、左右眼样式和事件格式。"""
|
||||
width, height = resolution.lower().split("x", 1)
|
||||
# 左眼样式占左半边,右眼样式占右半边,各留 50px 内边距。
|
||||
left_margin = 50
|
||||
right_margin = int(width) - 50
|
||||
return f"""[Script Info]
|
||||
Title: VR Dual-Eye Subtitle
|
||||
ScriptType: v4.00+
|
||||
Collisions: Normal
|
||||
PlayResX: {width}
|
||||
PlayResY: {height}
|
||||
WrapStyle: 1
|
||||
ScaledBorderAndShadow: yes
|
||||
|
||||
[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,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,{left_margin},{int(width) // 2},{int(height) // 2 + 60},1
|
||||
Style: RightEye,Arial,50,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,50,100,0,0,1,4,0,2,{int(width) // 2},{right_margin},{int(height) // 2 + 60},1
|
||||
|
||||
[Events]
|
||||
Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text
|
||||
"""
|
||||
|
||||
|
||||
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) -> None:
|
||||
"""把解析后的条目写入 ASS 文件,每个条目输出左右眼两行 Dialogue。"""
|
||||
lines = [_ass_header(resolution)]
|
||||
for start, end, text in entries:
|
||||
# an2 对齐到屏幕中央偏下,保证双眼字幕视线自然。
|
||||
lines.append(f"Dialogue: 0,{start},{end},LeftEye,,0,0,0,,{{\\an2}}{text}")
|
||||
lines.append(f"Dialogue: 0,{start},{end},RightEye,,0,0,0,,{{\\an2}}{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"))
|
||||
write_ass(entries, output_path, resolution)
|
||||
return InvokeResponse(status="completed", outputs={"ass_uri": str(output_path)})
|
||||
|
||||
Reference in New Issue
Block a user