extract.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. # /// script
  2. # dependencies = ["requests", "silero-vad", "torch"]
  3. # ///
  4. """Extract timed text from video subtitles; transcribe via ASR; grab frames.
  5. Commands:
  6. parse <input> [--outdir <dir>] extract timed text from subtitles
  7. transcribe <video> [--outpath <path>] transcribe speech via VAD+ASR
  8. frame <video> <timestamp> --outpath <p> extract single frame
  9. """
  10. import argparse
  11. import os
  12. import re
  13. import subprocess
  14. import sys
  15. import tempfile
  16. from pathlib import Path
  17. SRT_PATTERN = re.compile(
  18. r"(\d+)\s+(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*((?:(?!\n\n).)*)",
  19. re.DOTALL,
  20. )
  21. ASS_DIALOGUE = re.compile(
  22. r"^Dialogue:\s*\d+,(\d:\d{2}:\d{2}\.\d{2}),(\d:\d{2}:\d{2}\.\d{2}),.*?,(.*?)$",
  23. re.MULTILINE,
  24. )
  25. ASS_TAG = re.compile(r"\{[^}]*\}")
  26. ASS_LINEBREAK = re.compile(r"\\[Nn]")
  27. def find_subtitle(video_path: Path) -> Path | None:
  28. """Return matching .srt (prefer Chinese) or .ass for video_path, or None."""
  29. def _candidates(base: str) -> list[Path]:
  30. parent = video_path.parent
  31. out: list[Path] = []
  32. for f in parent.iterdir():
  33. if not f.is_file():
  34. continue
  35. stem = f.stem
  36. if not stem.startswith(base):
  37. continue
  38. if f.suffix == ".srt" and "_英文" not in stem and "_English" not in stem:
  39. out.append(f)
  40. elif f.suffix == ".ass":
  41. out.append(f)
  42. return out
  43. base = video_path.stem
  44. cands = _candidates(base)
  45. srt = [c for c in cands if c.suffix == ".srt"]
  46. ass = [c for c in cands if c.suffix == ".ass"]
  47. chosen = srt[0] if srt else (ass[0] if ass else None)
  48. if chosen is None:
  49. shorter = base.split("-")[0] if "-" in base else base
  50. if shorter != base:
  51. cands = _candidates(shorter)
  52. srt = [c for c in cands if c.suffix == ".srt"]
  53. ass = [c for c in cands if c.suffix == ".ass"]
  54. chosen = srt[0] if srt else (ass[0] if ass else None)
  55. return chosen
  56. def _ts_to_sec(ts: str) -> float:
  57. ts = ts.replace(",", ".")
  58. parts = ts.split(":")
  59. h, m = int(parts[0]), int(parts[1])
  60. s = float(parts[2]) if len(parts) == 3 else 0.0
  61. return h * 3600 + m * 60 + s
  62. def sec_to_ts(sec: float) -> str:
  63. h = int(sec // 3600)
  64. m = int((sec % 3600) // 60)
  65. s = sec % 60
  66. return f"{h:02d}:{m:02d}:{s:06.3f}"
  67. def parse_srt(path: Path) -> list[tuple[float, str]]:
  68. text = path.read_text(encoding="utf-8-sig")
  69. segs: list[tuple[float, str]] = []
  70. for m in SRT_PATTERN.finditer(text):
  71. start = _ts_to_sec(m.group(2))
  72. raw = m.group(4).strip().replace("\n", " ")
  73. if raw:
  74. segs.append((start, raw))
  75. return segs
  76. def parse_ass(path: Path) -> list[tuple[float, str]]:
  77. text = path.read_text(encoding="utf-8-sig")
  78. segs: list[tuple[float, str]] = []
  79. for m in ASS_DIALOGUE.finditer(text):
  80. start = _ts_to_sec(m.group(1))
  81. raw = m.group(3).strip()
  82. raw = ASS_TAG.sub("", raw)
  83. raw = ASS_LINEBREAK.sub(" ", raw)
  84. if raw:
  85. segs.append((start, raw))
  86. return segs
  87. def collect_videos(input_path: Path) -> list[Path]:
  88. if input_path.is_file():
  89. return [input_path]
  90. return sorted(input_path.rglob("*.mp4"))
  91. def cmd_parse(args: argparse.Namespace) -> None:
  92. input_path = Path(args.input)
  93. outdir = Path(args.outdir)
  94. videos = collect_videos(input_path)
  95. for vp in videos:
  96. sub = find_subtitle(vp)
  97. if sub is None:
  98. print(f"[WARN] No subtitle for: {vp.name}", file=sys.stderr)
  99. continue
  100. segs: list[tuple[float, str]] = []
  101. if sub.suffix == ".srt":
  102. segs = parse_srt(sub)
  103. elif sub.suffix == ".ass":
  104. segs = parse_ass(sub)
  105. if input_path.is_dir():
  106. rel = vp.relative_to(input_path)
  107. out = outdir / rel.with_suffix(".txt")
  108. else:
  109. out = outdir / vp.with_suffix(".txt").name
  110. out.parent.mkdir(parents=True, exist_ok=True)
  111. lines = [f"[{sec_to_ts(s)}] {t}" for s, t in segs]
  112. out.write_text("\n".join(lines), encoding="utf-8")
  113. print(f"[OK] {vp.name} -> {out} ({len(segs)} segments)")
  114. def parse_ts(s: str) -> float:
  115. s = s.strip()
  116. if ":" in s:
  117. parts = s.split(":")
  118. h, m = int(parts[0]), int(parts[1])
  119. sec = float(parts[2]) if len(parts) == 3 else 0.0
  120. return h * 3600 + m * 60 + sec
  121. return float(s)
  122. def cmd_frame(args: argparse.Namespace) -> None:
  123. video = Path(args.video)
  124. if not video.exists():
  125. print(f"[ERR] Video not found: {video}", file=sys.stderr)
  126. sys.exit(1)
  127. ts = parse_ts(args.timestamp)
  128. out = Path(args.outpath)
  129. out.parent.mkdir(parents=True, exist_ok=True)
  130. cmd = [
  131. "ffmpeg", "-ss", str(ts), "-i", str(video),
  132. "-vframes", "1", "-q:v", "2", "-y", str(out),
  133. ]
  134. result = subprocess.run(cmd, capture_output=True, text=True)
  135. if result.returncode != 0:
  136. print(f"[ERR] ffmpeg failed: {result.stderr.strip()}", file=sys.stderr)
  137. sys.exit(1)
  138. print(f"[OK] Frame saved -> {out}")
  139. def extract_audio(video_path: Path, out_wav: Path) -> None:
  140. """Extract 16kHz mono WAV from video."""
  141. cmd = [
  142. "ffmpeg", "-y", "-i", str(video_path),
  143. "-vn", "-ar", "16000", "-ac", "1",
  144. str(out_wav),
  145. ]
  146. subprocess.run(cmd, capture_output=True, check=True)
  147. def read_audio_tensor(wav_path: Path) -> "torch.Tensor":
  148. """Read WAV file as mono float32 tensor (compat with silero-vad)."""
  149. import numpy as np
  150. import torch
  151. # Use ffmpeg to decode to raw PCM float32
  152. result = subprocess.run(
  153. ["ffmpeg", "-y", "-i", str(wav_path),
  154. "-f", "f32le", "-ac", "1", "-ar", "16000", "-"],
  155. capture_output=True, check=True,
  156. )
  157. arr = np.frombuffer(result.stdout, dtype=np.float32).copy()
  158. return torch.from_numpy(arr).squeeze()
  159. def _find_model_dir(model_size: str) -> str | None:
  160. """Find cached faster-whisper model directory."""
  161. repo = f"Systran/faster-whisper-{model_size}"
  162. cache = Path(os.path.expanduser("~/.cache/huggingface/hub"))
  163. repo_dir = cache / f"models--{repo.replace('/', '--')}"
  164. if not repo_dir.exists():
  165. return None
  166. snapshots = repo_dir / "snapshots"
  167. if not snapshots.exists():
  168. return None
  169. snaps = list(snapshots.iterdir())
  170. return str(snaps[0]) if snaps else None
  171. def cmd_transcribe(args: argparse.Namespace) -> None:
  172. video = Path(args.video)
  173. if not video.exists():
  174. print(f"[ERR] Video not found: {video}", file=sys.stderr)
  175. sys.exit(1)
  176. outpath = Path(args.outpath) if args.outpath else None
  177. model_size = args.model
  178. model_dir = _find_model_dir(model_size)
  179. if model_dir is None:
  180. print(f"[ERR] Model '{model_size}' not cached locally", file=sys.stderr)
  181. sys.exit(1)
  182. print(f"[...] Extracting audio from {video.name} ...", file=sys.stderr)
  183. wav = Path(tempfile.mktemp(suffix=".wav"))
  184. try:
  185. extract_audio(video, wav)
  186. print(f"[...] Loading faster-whisper ({model_size}) ...", file=sys.stderr)
  187. import os as _os
  188. _os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1"
  189. from faster_whisper import WhisperModel
  190. model = WhisperModel(model_dir, device="cpu", compute_type="int8")
  191. print(f"[...] Transcribing. This may take a few minutes ...", file=sys.stderr)
  192. segments, _info = model.transcribe(
  193. str(wav), beam_size=5, vad_filter=True,
  194. vad_parameters=dict(min_silence_duration_ms=300),
  195. )
  196. segs: list[tuple[float, str]] = []
  197. for seg in segments:
  198. text = seg.text.strip()
  199. if text:
  200. segs.append((seg.start, text))
  201. print(f"[...] {len(segs)} segments", file=sys.stderr)
  202. for s, t in segs:
  203. print(f" [{sec_to_ts(s)}] {t[:80]}", file=sys.stderr)
  204. finally:
  205. wav.unlink(missing_ok=True)
  206. if not segs:
  207. print(f"[WARN] No speech transcribed", file=sys.stderr)
  208. return
  209. lines = [f"[{sec_to_ts(s)}] {t}" for s, t in segs]
  210. if outpath:
  211. outpath.parent.mkdir(parents=True, exist_ok=True)
  212. outpath.write_text("\n".join(lines), encoding="utf-8")
  213. print(f"[OK] Transcribed -> {outpath} ({len(segs)} segments)")
  214. else:
  215. print("\n".join(lines))
  216. def main() -> None:
  217. parser = argparse.ArgumentParser(description="Video subtitle extractor & frame grabber")
  218. sub = parser.add_subparsers(dest="command")
  219. p = sub.add_parser("parse", help="Extract timed text from subtitles")
  220. p.add_argument("input", help="Video file or directory")
  221. p.add_argument("--outdir", default="./transcripts", help="Output directory")
  222. p = sub.add_parser("transcribe", help="Transcribe speech via local faster-whisper")
  223. p.add_argument("video", help="Path to video file")
  224. p.add_argument("--outpath", help="Output transcript path (default: stdout)")
  225. p.add_argument("--model", default="base", help="Model size: tiny/base/small/medium/large")
  226. p = sub.add_parser("frame", help="Extract single frame as JPEG")
  227. p.add_argument("video", help="Path to video file")
  228. p.add_argument("timestamp", help="Timestamp (HH:MM:SS or seconds)")
  229. p.add_argument("--outpath", required=True, help="Output image path")
  230. args = parser.parse_args()
  231. if args.command == "parse":
  232. cmd_parse(args)
  233. elif args.command == "transcribe":
  234. cmd_transcribe(args)
  235. elif args.command == "frame":
  236. cmd_frame(args)
  237. else:
  238. parser.print_help()
  239. sys.exit(1)
  240. if __name__ == "__main__":
  241. main()