import argparse import hashlib import json from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from sentence_analysis import detect_sentence_boundaries from sentence_api.store import normalize_video_hash ALGORITHM_VERSION = "silence-rms-v1" DEFAULT_INDEX_PATH = Path(__file__).resolve().parent / "data" / "sentence_boundaries.json" def sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as media_file: for chunk in iter(lambda: media_file.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def media_duration_ms(path: Path) -> int: try: import av except ImportError as exc: raise RuntimeError("PyAV is required to generate sentence boundaries.") from exc container = av.open(str(path)) try: if not container.duration or container.duration <= 0: raise RuntimeError("Could not determine media duration.") return int(round(container.duration / 1000)) finally: container.close() def make_entry(path: Path, video_hash: Optional[str] = None, min_silence: float = 0.30, min_sentence: float = 0.35) -> Tuple[Dict[str, Any], str]: duration_ms = media_duration_ms(path) starts = detect_sentence_boundaries( path, min_silence=min_silence, min_sentence=min_sentence, ) if not starts: raise RuntimeError("No sentence boundaries could be detected.") starts_ms = sorted({max(0, int(round(start * 1000))) for start in starts}) starts_ms = [start for start in starts_ms if start < duration_ms] sentences: List[Dict[str, Any]] = [] for index, start_ms in enumerate(starts_ms): end_ms = starts_ms[index + 1] if index + 1 < len(starts_ms) else duration_ms if end_ms <= start_ms: continue sentences.append({ "index": len(sentences), "start_ms": start_ms, "end_ms": end_ms, "text": None, }) if not sentences: raise RuntimeError("Detected boundaries do not form valid sentence ranges.") entry = { "duration_ms": duration_ms, "algorithm_version": ALGORITHM_VERSION, "sentences": sentences, } resolved_hash = normalize_video_hash(video_hash) if video_hash else sha256_file(path) return entry, resolved_hash def update_index(index_path: Path, video_hash: str, entry: Dict[str, Any]) -> None: if index_path.exists(): raw = json.loads(index_path.read_text(encoding="utf-8")) else: raw = {"videos": {}} videos = raw.setdefault("videos", {}) if not isinstance(videos, dict): raise ValueError("index JSON must contain a 'videos' object") videos[normalize_video_hash(video_hash)] = entry index_path.parent.mkdir(parents=True, exist_ok=True) temporary_path = index_path.with_suffix(index_path.suffix + ".tmp") temporary_path.write_text( json.dumps(raw, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) temporary_path.replace(index_path) def main() -> None: parser = argparse.ArgumentParser(description="Generate API sentence boundaries for a video.") parser.add_argument("video", type=Path) parser.add_argument("--index", type=Path, default=DEFAULT_INDEX_PATH) parser.add_argument("--video-hash", help="Override the calculated SHA-256 hash.") parser.add_argument("--min-silence", type=float, default=0.30) parser.add_argument("--min-sentence", type=float, default=0.35) args = parser.parse_args() if not args.video.is_file(): parser.error(f"Video file does not exist: {args.video}") entry, video_hash = make_entry( args.video, video_hash=args.video_hash, min_silence=args.min_silence, min_sentence=args.min_sentence, ) update_index(args.index, video_hash, entry) print(f"video_hash={video_hash}") print(f"sentences={len(entry['sentences'])}") print(f"index={args.index}") if __name__ == "__main__": main()