feat(tree): 字幕模块 — SRT 解析 + 完整性检查 + Voronoi 分配

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 01:54:09 -04:00
parent e3b027ce34
commit fb6f9964d8
2 changed files with 426 additions and 0 deletions
+308
View File
@@ -0,0 +1,308 @@
"""字幕模块:SRT 解析、完整性检查、时间范围提取、Voronoi 分配。
提供四个核心函数:
- parse_srt: 解析 SRT 文件为结构化条目列表
- check_subtitle_completeness: 检查字幕覆盖率与完整性
- extract_subtitle_for_range: 提取指定时间范围内的字幕文本
- assign_subtitles_voronoi: 使用 Voronoi 中点策略将字幕分配给 L3 节点
迁移来源:
- TRM4 core/tree/enhance/merge.py (parse_srt)
- TRM3 tools/generate_subtitles.py (Voronoi 逻辑)
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING
from loguru import logger
if TYPE_CHECKING:
from app.tree.index import TreeIndex
# ---------------------------------------------------------------------------
# 正则表达式
# ---------------------------------------------------------------------------
_HTML_TAG_RE = re.compile(r"<[^>]+>")
_MUSIC_ONLY_RE = re.compile(r"^[\s♪♫]*$")
_TIMECODE_RE = re.compile(r"(\d+):(\d+):(\d+)[,.](\d+)\s*-->\s*(\d+):(\d+):(\d+)[,.](\d+)")
# ---------------------------------------------------------------------------
# 数据类型
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class SRTEntry:
"""单条 SRT 字幕条目。
属性:
start: 开始时间(秒)。
end: 结束时间(秒)。
text: 字幕文本(已清洗 HTML 标签)。
"""
start: float
end: float
text: str
@dataclass(frozen=True)
class SubtitleReport:
"""字幕完整性检查报告。
属性:
total_entries: 字幕条目总数。
coverage_ratio: SRT 覆盖时长 / 视频总时长。
max_gap_sec: 最大连续无字幕间隔(秒)。
usable: 覆盖率是否达到最低要求。
"""
total_entries: int
coverage_ratio: float
max_gap_sec: float
usable: bool
# ---------------------------------------------------------------------------
# 内部辅助
# ---------------------------------------------------------------------------
def _ts_to_seconds(h: str, m: str, s: str, ms: str) -> float:
"""SRT 时间戳组件 (HH:MM:SS,mmm) 转秒数。
参数:
h: 小时。
m: 分钟。
s: 秒。
ms: 毫秒。
返回:
浮点秒数。
"""
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000
# ---------------------------------------------------------------------------
# 公共 API
# ---------------------------------------------------------------------------
def parse_srt(srt_path: str) -> list[SRTEntry]:
"""解析 SRT 字幕文件,返回结构化条目列表。
- 剥离 HTML 标签(如 <i>、<b>
- 跳过纯音乐符号行(仅含空白和 ♪♫)
- 多行字幕合并为单行(空格连接)
- 跳过格式异常的块(容错处理)
参数:
srt_path: SRT 文件的绝对路径。
返回:
按时间顺序排列的 SRTEntry 列表;空文件或无有效条目返回空列表。
迁移来源:
TRM4 core/tree/enhance/merge.py parse_srt
TRM3 tools/generate_subtitles.py parse_srt
"""
with open(srt_path, encoding="utf-8") as f:
content = f.read()
if not content.strip():
return []
entries: list[SRTEntry] = []
blocks = re.split(r"\n\s*\n", content.strip())
for block in blocks:
lines = block.strip().split("\n")
if len(lines) < 2:
continue
# 在块内搜索时间码行(可能是第 1 行或第 2 行)
ts_match = None
ts_line_idx = -1
for i, line in enumerate(lines):
ts_match = _TIMECODE_RE.search(line)
if ts_match:
ts_line_idx = i
break
if not ts_match:
continue
groups = [int(x) for x in ts_match.groups()]
start = _ts_to_seconds(str(groups[0]), str(groups[1]), str(groups[2]), str(groups[3]))
end = _ts_to_seconds(str(groups[4]), str(groups[5]), str(groups[6]), str(groups[7]))
# 时间码行之后的所有行为字幕文本
text_lines = lines[ts_line_idx + 1 :]
raw_text = " ".join(text_lines)
clean_text = _HTML_TAG_RE.sub("", raw_text).strip()
# 跳过空文本和纯音乐符号行
if not clean_text or _MUSIC_ONLY_RE.match(clean_text):
continue
entries.append(SRTEntry(start=start, end=end, text=clean_text))
logger.debug("SRT 解析完成: {} 条有效条目, 文件={}", len(entries), srt_path)
return entries
def check_subtitle_completeness(
entries: list[SRTEntry],
duration: float,
min_coverage: float = 0.3,
) -> SubtitleReport:
"""检查字幕完整性:覆盖率、最大间隔、可用性判定。
参数:
entries: 已排序的 SRTEntry 列表。
duration: 视频总时长(秒),必须 > 0。
min_coverage: 最低可用覆盖率阈值(0~1)。
返回:
SubtitleReport 包含覆盖率、最大间隔和可用性判定。
"""
assert duration > 0, f"视频时长必须 > 0,实际={duration}"
if not entries:
return SubtitleReport(
total_entries=0,
coverage_ratio=0.0,
max_gap_sec=duration,
usable=False,
)
# 按开始时间排序
sorted_entries = sorted(entries, key=lambda e: e.start)
# 计算覆盖时长(合并重叠区间)
merged_intervals: list[tuple[float, float]] = []
for entry in sorted_entries:
if merged_intervals and entry.start <= merged_intervals[-1][1]:
# 与上一区间重叠,扩展
merged_intervals[-1] = (
merged_intervals[-1][0],
max(merged_intervals[-1][1], entry.end),
)
else:
merged_intervals.append((entry.start, entry.end))
covered = sum(end - start for start, end in merged_intervals)
coverage_ratio = min(covered / duration, 1.0)
# 计算最大间隔(包括视频开头到第一条字幕、最后一条到视频结尾)
max_gap = merged_intervals[0][0] # 视频开头到第一条字幕
for i in range(1, len(merged_intervals)):
gap = merged_intervals[i][0] - merged_intervals[i - 1][1]
max_gap = max(max_gap, gap)
# 最后一条字幕到视频结尾
max_gap = max(max_gap, duration - merged_intervals[-1][1])
return SubtitleReport(
total_entries=len(entries),
coverage_ratio=coverage_ratio,
max_gap_sec=max_gap,
usable=coverage_ratio >= min_coverage,
)
def extract_subtitle_for_range(
entries: list[SRTEntry],
time_range: tuple[float, float],
) -> str:
"""提取与指定时间范围重叠的字幕文本。
重叠判定:entry.start < range_end 且 entry.end > range_start。
参数:
entries: SRTEntry 列表。
time_range: (start, end) 时间范围(秒)。
返回:
匹配的字幕文本,多条用换行符连接;无匹配返回空字符串。
"""
range_start, range_end = time_range
matched = [
entry.text for entry in entries if entry.start < range_end and entry.end > range_start
]
return "\n".join(matched)
def assign_subtitles_voronoi(
index: TreeIndex,
entries: list[SRTEntry],
) -> None:
"""使用 Voronoi 中点策略将字幕分配给 L3 节点。
对每个 L2 节点内的 L3 子节点,按 timestamp 排序后计算 Voronoi 有效范围:
- 相邻 L3 节点之间取中点作为边界
- 首个 L3 的左边界扩展到 L2 的 time_range 起点
- 末个 L3 的右边界扩展到 L2 的 time_range 终点
然后用 extract_subtitle_for_range 提取每个 L3 有效范围内的字幕文本。
参数:
index: 树索引,包含 L1→L2→L3 嵌套结构。
entries: 已解析的 SRTEntry 列表。
副作用:
直接修改每个 L3Node.subtitle 字段。
迁移来源:
TRM3 tools/generate_subtitles.py compute_effective_ranges + assign_subtitles
"""
for l1 in index.roots:
for l2 in l1.children:
if not l2.children:
continue
# 按 timestamp 排序 L3 子节点(保留原列表引用以便赋值)
siblings = sorted(
l2.children,
key=lambda n: n.timestamp if n.timestamp is not None else 0.0,
)
# L2 的时间范围作为边界
l2_start = l2.time_range[0] if l2.time_range else 0.0
l2_end = l2.time_range[1] if l2.time_range else 0.0
for idx, l3 in enumerate(siblings):
ts = l3.timestamp if l3.timestamp is not None else 0.0
# 计算 Voronoi 有效范围
if idx == 0:
left = l2_start
else:
prev_ts = (
siblings[idx - 1].timestamp
if siblings[idx - 1].timestamp is not None
else 0.0
)
left = (prev_ts + ts) / 2.0
if idx == len(siblings) - 1:
right = l2_end
else:
next_ts = (
siblings[idx + 1].timestamp
if siblings[idx + 1].timestamp is not None
else 0.0
)
right = (ts + next_ts) / 2.0
subtitle_text = extract_subtitle_for_range(entries, (left, right))
l3.subtitle = subtitle_text if subtitle_text else None
logger.debug(
"Voronoi 字幕分配完成: {} 个 L1 节点, {} 条字幕条目",
len(index.roots),
len(entries),
)
+118
View File
@@ -0,0 +1,118 @@
"""字幕模块单元测试。"""
from __future__ import annotations
from app.tree.subtitle import (
SRTEntry,
assign_subtitles_voronoi,
check_subtitle_completeness,
extract_subtitle_for_range,
parse_srt,
)
_SAMPLE_SRT = """\
1
00:00:01,000 --> 00:00:03,500
Hello world.
2
00:00:05,000 --> 00:00:08,000
<i>This is italic</i> text.
3
00:00:10,000 --> 00:00:12,000
Final line.
"""
class TestParseSrt:
def test_basic_parse(self, tmp_path):
srt_file = tmp_path / "test.srt"
srt_file.write_text(_SAMPLE_SRT, encoding="utf-8")
entries = parse_srt(str(srt_file))
assert len(entries) == 3
assert entries[0] == SRTEntry(start=1.0, end=3.5, text="Hello world.")
assert entries[1].text == "This is italic text."
def test_empty_srt(self, tmp_path):
srt_file = tmp_path / "empty.srt"
srt_file.write_text("", encoding="utf-8")
entries = parse_srt(str(srt_file))
assert entries == []
def test_malformed_srt_skips_bad_blocks(self, tmp_path):
bad_srt = "garbage\n\n1\n00:00:01,000 --> 00:00:02,000\nGood line.\n"
srt_file = tmp_path / "bad.srt"
srt_file.write_text(bad_srt, encoding="utf-8")
entries = parse_srt(str(srt_file))
assert len(entries) == 1
assert entries[0].text == "Good line."
class TestCompletenessCheck:
def test_good_coverage(self):
entries = [SRTEntry(0.0, 5.0, "a"), SRTEntry(5.0, 10.0, "b")]
report = check_subtitle_completeness(entries, duration=10.0, min_coverage=0.5)
assert report.usable is True
assert report.coverage_ratio >= 0.5
def test_poor_coverage(self):
entries = [SRTEntry(0.0, 1.0, "short")]
report = check_subtitle_completeness(entries, duration=100.0, min_coverage=0.3)
assert report.usable is False
def test_max_gap(self):
entries = [SRTEntry(0.0, 1.0, "a"), SRTEntry(50.0, 51.0, "b")]
report = check_subtitle_completeness(entries, duration=60.0)
assert report.max_gap_sec >= 49.0
class TestExtractForRange:
def test_overlap(self):
entries = [
SRTEntry(0.0, 5.0, "first"),
SRTEntry(4.0, 8.0, "second"),
SRTEntry(10.0, 12.0, "third"),
]
text = extract_subtitle_for_range(entries, (3.0, 9.0))
assert "first" in text
assert "second" in text
assert "third" not in text
class TestVoronoiAssign:
def test_assigns_to_l3_nodes(self):
from app.tree.index import (
IndexMeta,
L1Card,
L1Node,
L2Card,
L2Node,
L3Card,
L3Node,
TreeIndex,
)
l3_0 = L3Node(id="l1_0_l2_0_l3_0", card=L3Card("desc0", [], [], [], "", {}), timestamp=2.0)
l3_1 = L3Node(id="l1_0_l2_0_l3_1", card=L3Card("desc1", [], [], [], "", {}), timestamp=6.0)
l2 = L2Node(
id="l1_0_l2_0",
card=L2Card("evt", [], [], [], [], "", None),
time_range=(0.0, 10.0),
children=[l3_0, l3_1],
)
l1 = L1Node(
id="l1_0",
card=L1Card("scene", "", [], [], [], [], ""),
time_range=(0.0, 10.0),
children=[l2],
)
index = TreeIndex(metadata=IndexMeta("/test.mp4", "video"), roots=[l1])
entries = [SRTEntry(1.0, 3.0, "hello"), SRTEntry(5.0, 7.0, "world")]
assign_subtitles_voronoi(index, entries)
assert l3_0.subtitle is not None
assert "hello" in l3_0.subtitle
assert l3_1.subtitle is not None
assert "world" in l3_1.subtitle