| """Trích ngữ cảnh đa phương thức cho từng cảnh: frame video + audio đoạn cảnh. |
| |
| Dùng ffmpeg/ffprobe (gọi qua subprocess). Trên Colab L4 đã có sẵn ffmpeg. |
| Tất cả file tạm ghi vào `work_dir` để backend Omni nạp ở Bước 3 & 4. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import shutil |
| import subprocess |
| from dataclasses import dataclass, field |
| from pathlib import Path |
| from typing import List, Optional |
|
|
| from .scenes import Scene |
|
|
|
|
| def _require(tool: str) -> str: |
| path = shutil.which(tool) |
| if not path: |
| raise RuntimeError( |
| f"Không tìm thấy `{tool}` trong PATH. Cần ffmpeg để trích frame/audio." |
| ) |
| return path |
|
|
|
|
| def extract_audio( |
| video: str | Path, |
| out_path: str | Path, |
| *, |
| start: Optional[float] = None, |
| end: Optional[float] = None, |
| sample_rate: int = 16000, |
| ) -> Path: |
| """Trích audio mono 16 kHz (mặc định) cho [start, end]. Trả về path WAV.""" |
| ffmpeg = _require("ffmpeg") |
| out = Path(out_path) |
| out.parent.mkdir(parents=True, exist_ok=True) |
| cmd = [ffmpeg, "-y", "-loglevel", "error"] |
| if start is not None: |
| cmd += ["-ss", f"{start:.3f}"] |
| if end is not None and start is not None: |
| cmd += ["-t", f"{max(0.0, end - start):.3f}"] |
| cmd += [ |
| "-i", str(video), |
| "-vn", |
| "-ac", "1", |
| "-ar", str(sample_rate), |
| "-c:a", "pcm_s16le", |
| str(out), |
| ] |
| subprocess.run(cmd, check=True) |
| return out |
|
|
|
|
| def extract_frame( |
| video: str | Path, |
| out_path: str | Path, |
| *, |
| timestamp: float, |
| width: Optional[int] = 768, |
| ) -> Path: |
| """Trích 1 frame tại `timestamp` (giây). Trả về path ảnh JPG.""" |
| ffmpeg = _require("ffmpeg") |
| out = Path(out_path) |
| out.parent.mkdir(parents=True, exist_ok=True) |
| vf = f"scale={width}:-2" if width else "scale=iw:ih" |
| cmd = [ |
| ffmpeg, "-y", "-loglevel", "error", |
| "-ss", f"{timestamp:.3f}", |
| "-i", str(video), |
| "-frames:v", "1", |
| "-vf", vf, |
| str(out), |
| ] |
| subprocess.run(cmd, check=True) |
| return out |
|
|
|
|
| @dataclass |
| class SceneContext: |
| """Tài nguyên đa phương thức đã trích cho một cảnh.""" |
|
|
| scene_id: int |
| audio_path: Optional[Path] = None |
| frame_paths: List[Path] = field(default_factory=list) |
|
|
|
|
| def build_scene_context( |
| video: str | Path, |
| scene: Scene, |
| work_dir: str | Path, |
| *, |
| n_frames: int = 2, |
| sample_rate: int = 16000, |
| frame_width: int = 768, |
| ) -> SceneContext: |
| """Trích audio toàn cảnh + `n_frames` frame rải đều trong cảnh.""" |
| work = Path(work_dir) |
| work.mkdir(parents=True, exist_ok=True) |
|
|
| audio_path = extract_audio( |
| video, |
| work / f"scene_{scene.scene_id:04d}.wav", |
| start=scene.start, |
| end=scene.end, |
| sample_rate=sample_rate, |
| ) |
|
|
| frames: List[Path] = [] |
| dur = max(0.001, scene.duration) |
| for i in range(max(1, n_frames)): |
| |
| frac = (i + 1) / (n_frames + 1) |
| ts = scene.start + frac * dur |
| frames.append( |
| extract_frame( |
| video, |
| work / f"scene_{scene.scene_id:04d}_f{i}.jpg", |
| timestamp=ts, |
| width=frame_width, |
| ) |
| ) |
|
|
| return SceneContext(scene_id=scene.scene_id, audio_path=audio_path, frame_paths=frames) |
|
|
|
|
| def extract_speaker_clips( |
| video: str | Path, |
| turns: List["SpeakerTurn"], |
| work_dir: str | Path, |
| *, |
| clips_per_speaker: int = 5, |
| max_seconds_per_clip: float = 8.0, |
| sample_rate: int = 16000, |
| ) -> dict[str, List[Path]]: |
| """Trích các clip audio đại diện cho từng speaker (cho Bước 3 — hồ sơ giọng). |
| |
| Chọn các turn dài nhất của mỗi speaker, cắt ≤ `max_seconds_per_clip`. |
| """ |
| from collections import defaultdict |
|
|
| work = Path(work_dir) |
| work.mkdir(parents=True, exist_ok=True) |
|
|
| by_speaker: dict[str, list] = defaultdict(list) |
| for t in turns: |
| by_speaker[t.speaker].append(t) |
|
|
| result: dict[str, List[Path]] = {} |
| for speaker, sp_turns in by_speaker.items(): |
| sp_turns = sorted(sp_turns, key=lambda x: x.duration, reverse=True) |
| clips: List[Path] = [] |
| for i, t in enumerate(sp_turns[:clips_per_speaker]): |
| end = min(t.end, t.start + max_seconds_per_clip) |
| clips.append( |
| extract_audio( |
| video, |
| work / f"{speaker}_clip{i}.wav", |
| start=t.start, |
| end=end, |
| sample_rate=sample_rate, |
| ) |
| ) |
| result[speaker] = clips |
| return result |
|
|