| """Gom cảnh và ghép cue vụn (sentence-aware, same-speaker). |
| |
| `group_scenes`: cắt phim thành các `Scene` theo khoảng lặng và giới hạn độ dài. |
| `merge_adjacent_pairs`: ghép cue ngắn liền kề (cùng người nói, chưa kết câu). |
| """ |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass, field |
| from typing import List, Optional |
|
|
| from .srt import Cue |
|
|
| |
| _SENTENCE_ENDERS = "。!?!?…」』”》" |
| _CONTINUATION_ENDERS = ",、,;;::—-((【「『《“" |
|
|
|
|
| @dataclass |
| class Scene: |
| """Một cảnh: cụm cue gần nhau về thời gian.""" |
|
|
| scene_id: int |
| cues: List[Cue] = field(default_factory=list) |
|
|
| @property |
| def start(self) -> float: |
| return self.cues[0].start if self.cues else 0.0 |
|
|
| @property |
| def end(self) -> float: |
| return self.cues[-1].end if self.cues else 0.0 |
|
|
| @property |
| def duration(self) -> float: |
| return max(0.0, self.end - self.start) |
|
|
| @property |
| def text(self) -> str: |
| return "\n".join(c.text for c in self.cues) |
|
|
| @property |
| def speakers(self) -> List[str]: |
| seen: List[str] = [] |
| for c in self.cues: |
| if c.speaker and c.speaker not in seen: |
| seen.append(c.speaker) |
| return seen |
|
|
|
|
| def group_scenes( |
| cues: List[Cue], |
| max_gap: float = 1.5, |
| max_cues: int = 4, |
| max_dur: float = 20.0, |
| ) -> List[Scene]: |
| """Gom cue thành cảnh. |
| |
| Cắt cảnh mới khi: khoảng lặng > `max_gap`, hoặc cảnh đã đủ `max_cues`, |
| hoặc vượt `max_dur` giây. |
| """ |
| scenes: List[Scene] = [] |
| if not cues: |
| return scenes |
|
|
| current: List[Cue] = [] |
| scene_id = 0 |
| for cue in cues: |
| if not current: |
| current = [cue] |
| continue |
|
|
| gap = cue.start - current[-1].end |
| dur = cue.end - current[0].start |
| if gap > max_gap or len(current) >= max_cues or dur > max_dur: |
| scene_id += 1 |
| scenes.append(_finalize_scene(scene_id, current)) |
| current = [cue] |
| else: |
| current.append(cue) |
|
|
| if current: |
| scene_id += 1 |
| scenes.append(_finalize_scene(scene_id, current)) |
| return scenes |
|
|
|
|
| def _finalize_scene(scene_id: int, cues: List[Cue]) -> Scene: |
| for c in cues: |
| c.scene_id = scene_id |
| return Scene(scene_id=scene_id, cues=list(cues)) |
|
|
|
|
| def _ends_sentence(text: str) -> bool: |
| t = text.rstrip() |
| if not t: |
| return False |
| return t[-1] in _SENTENCE_ENDERS |
|
|
|
|
| def _ends_continuation(text: str) -> bool: |
| t = text.rstrip() |
| if not t: |
| return False |
| return t[-1] in _CONTINUATION_ENDERS |
|
|
|
|
| def merge_adjacent_pairs( |
| cues: List[Cue], |
| max_gap: float = 0.4, |
| max_merged_chars: int = 40, |
| ) -> List[Cue]: |
| """Ghép cue vụn liền kề thành câu trọn vẹn hơn. |
| |
| Điều kiện ghép cue[i] với cue[i+1]: |
| - khoảng cách ≤ `max_gap`, |
| - cùng speaker (hoặc cả hai chưa gán), |
| - cue[i] CHƯA kết thúc câu, |
| - tổng độ dài sau ghép ≤ `max_merged_chars`. |
| """ |
| if not cues: |
| return [] |
|
|
| merged: List[Cue] = [] |
| buf = _clone(cues[0]) |
| for nxt in cues[1:]: |
| gap = nxt.start - buf.end |
| |
| |
| |
| both_unknown = buf.speaker is None and nxt.speaker is None |
| same_known_speaker = buf.speaker is not None and buf.speaker == nxt.speaker |
| can_merge_speaker = same_known_speaker or ( |
| both_unknown and _ends_continuation(buf.text) |
| ) |
| combined_len = len(buf.text.replace("\n", "")) + len(nxt.text.replace("\n", "")) |
| if ( |
| gap <= max_gap |
| and can_merge_speaker |
| and not _ends_sentence(buf.text) |
| and combined_len <= max_merged_chars |
| ): |
| buf.end = nxt.end |
| sep = "" if buf.text and buf.text[-1] in _CONTINUATION_ENDERS else " " |
| buf.text = (buf.text + sep + nxt.text).strip() |
| else: |
| merged.append(buf) |
| buf = _clone(nxt) |
| merged.append(buf) |
|
|
| for i, c in enumerate(merged, start=1): |
| c.index = i |
| return merged |
|
|
|
|
| def _clone(cue: Cue) -> Cue: |
| return Cue( |
| index=cue.index, |
| start=cue.start, |
| end=cue.end, |
| text=cue.text, |
| speaker=cue.speaker, |
| translation=cue.translation, |
| note=cue.note, |
| scene_id=cue.scene_id, |
| ) |
|
|