f4b4f1fc4f
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production Runtime Images (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
feat: ASR自动字幕能力
184 lines
5.6 KiB
Python
Executable File
184 lines
5.6 KiB
Python
Executable File
"""字幕领域模型 — 带时间轴的字幕片段。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import List
|
|
|
|
|
|
@dataclass
|
|
class SubtitleWord:
|
|
"""单个词级别的字幕单元,带精确时间戳。"""
|
|
|
|
text: str
|
|
start: float # 秒
|
|
end: float # 秒
|
|
|
|
@property
|
|
def duration(self) -> float:
|
|
return max(0.0, self.end - self.start)
|
|
|
|
|
|
@dataclass
|
|
class SubtitleSegment:
|
|
"""一段字幕(一句话),带时间轴和词级信息。"""
|
|
|
|
text: str
|
|
start: float # 秒
|
|
end: float # 秒
|
|
words: List[SubtitleWord] = field(default_factory=list)
|
|
|
|
@property
|
|
def duration(self) -> float:
|
|
return max(0.0, self.end - self.start)
|
|
|
|
@property
|
|
def char_count(self) -> int:
|
|
return len(self.text)
|
|
|
|
|
|
@dataclass
|
|
class SubtitleTimeline:
|
|
"""完整的字幕时间轴,由多个片段组成。"""
|
|
|
|
segments: List[SubtitleSegment] = field(default_factory=list)
|
|
language: str = "zh" # zh / en / ja 等
|
|
total_duration: float = 0.0 # 音频总时长(秒)
|
|
|
|
@property
|
|
def segment_count(self) -> int:
|
|
return len(self.segments)
|
|
|
|
@property
|
|
def total_chars(self) -> int:
|
|
return sum(s.char_count for s in self.segments)
|
|
|
|
def merge_short_segments(self, min_chars: int = 8) -> SubtitleTimeline:
|
|
"""合并过短的字幕片段,避免字幕跳动太快。"""
|
|
if len(self.segments) <= 1:
|
|
return self
|
|
|
|
merged: List[SubtitleSegment] = []
|
|
buffer: List[SubtitleSegment] = []
|
|
|
|
for seg in self.segments:
|
|
buffer.append(seg)
|
|
total_chars = sum(s.char_count for s in buffer)
|
|
if total_chars >= min_chars:
|
|
merged.append(self._merge_segments(buffer))
|
|
buffer = []
|
|
|
|
# 剩余的合并到最后一个或单独成段
|
|
if buffer:
|
|
if merged and sum(s.char_count for s in buffer) < min_chars:
|
|
# 太少了,合并到上一段
|
|
last = merged.pop()
|
|
merged.append(self._merge_segments([last] + buffer))
|
|
else:
|
|
merged.append(self._merge_segments(buffer))
|
|
|
|
return SubtitleTimeline(
|
|
segments=merged,
|
|
language=self.language,
|
|
total_duration=self.total_duration,
|
|
)
|
|
|
|
def split_long_segments(self, max_chars: int = 20) -> SubtitleTimeline:
|
|
"""拆分过长的字幕片段,按语义断句。"""
|
|
new_segments: List[SubtitleSegment] = []
|
|
|
|
for seg in self.segments:
|
|
if seg.char_count <= max_chars:
|
|
new_segments.append(seg)
|
|
continue
|
|
|
|
# 按标点符号拆分
|
|
parts = self._split_text_by_punctuation(seg.text, max_chars)
|
|
if len(parts) == 1:
|
|
new_segments.append(seg)
|
|
continue
|
|
|
|
# 按字数比例分配时间
|
|
total_chars = seg.char_count
|
|
current_time = seg.start
|
|
word_idx = 0
|
|
all_words = seg.words.copy()
|
|
|
|
for part in parts:
|
|
part_chars = len(part)
|
|
part_duration = seg.duration * (part_chars / total_chars)
|
|
part_end = min(current_time + part_duration, seg.end)
|
|
|
|
# 收集对应时间段的词
|
|
part_words = []
|
|
while word_idx < len(all_words) and all_words[word_idx].start < part_end:
|
|
part_words.append(all_words[word_idx])
|
|
word_idx += 1
|
|
|
|
new_segments.append(
|
|
SubtitleSegment(
|
|
text=part,
|
|
start=current_time,
|
|
end=part_end,
|
|
words=part_words,
|
|
)
|
|
)
|
|
current_time = part_end
|
|
|
|
return SubtitleTimeline(
|
|
segments=new_segments,
|
|
language=self.language,
|
|
total_duration=self.total_duration,
|
|
)
|
|
|
|
@staticmethod
|
|
def _merge_segments(segments: List[SubtitleSegment]) -> SubtitleSegment:
|
|
if not segments:
|
|
return SubtitleSegment(text="", start=0, end=0)
|
|
return SubtitleSegment(
|
|
text="".join(s.text for s in segments),
|
|
start=segments[0].start,
|
|
end=segments[-1].end,
|
|
words=[w for s in segments for w in s.words],
|
|
)
|
|
|
|
@staticmethod
|
|
def _split_text_by_punctuation(text: str, max_chars: int) -> List[str]:
|
|
"""按标点符号智能拆分长文本。"""
|
|
# 中文常见句末标点
|
|
sentence_end = "。!?!?"
|
|
clause_pause = ",;:,;:"
|
|
|
|
parts: List[str] = []
|
|
current = ""
|
|
|
|
for char in text:
|
|
current += char
|
|
|
|
if len(current) >= max_chars:
|
|
# 超过长度,找最近的标点断开
|
|
break_idx = -1
|
|
for i in range(len(current) - 1, -1, -1):
|
|
if current[i] in sentence_end or current[i] in clause_pause:
|
|
break_idx = i + 1
|
|
break
|
|
|
|
if break_idx > 0:
|
|
parts.append(current[:break_idx])
|
|
current = current[break_idx:]
|
|
else:
|
|
# 没有标点,硬切
|
|
parts.append(current[:max_chars])
|
|
current = current[max_chars:]
|
|
|
|
elif char in sentence_end:
|
|
# 句末标点,如果长度够就断开
|
|
if len(current) >= max_chars // 2:
|
|
parts.append(current)
|
|
current = ""
|
|
|
|
if current:
|
|
parts.append(current)
|
|
|
|
return parts
|