extract.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. # /// script
  2. # dependencies = []
  3. # ///
  4. """Extract timed text from video subtitles; grab single frames.
  5. Commands:
  6. parse <input> [--outdir <dir>] extract timed text from subtitles
  7. frame <video> <timestamp> --outpath <path> extract single frame
  8. """
  9. import argparse
  10. import os
  11. import re
  12. import subprocess
  13. import sys
  14. from pathlib import Path
  15. SRT_PATTERN = re.compile(
  16. r"(\d+)\s+(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*((?:(?!\n\n).)*)",
  17. re.DOTALL,
  18. )
  19. ASS_DIALOGUE = re.compile(
  20. r"^Dialogue:\s*\d+,(\d:\d{2}:\d{2}\.\d{2}),(\d:\d{2}:\d{2}\.\d{2}),.*?,(.*?)$",
  21. re.MULTILINE,
  22. )
  23. ASS_TAG = re.compile(r"\{[^}]*\}")
  24. ASS_LINEBREAK = re.compile(r"\\[Nn]")
  25. def find_subtitle(video_path: Path) -> Path | None:
  26. """Return matching .srt (prefer Chinese) or .ass for video_path, or None."""
  27. def _candidates(base: str) -> list[Path]:
  28. parent = video_path.parent
  29. out: list[Path] = []
  30. for f in parent.iterdir():
  31. if not f.is_file():
  32. continue
  33. stem = f.stem
  34. if not stem.startswith(base):
  35. continue
  36. if f.suffix == ".srt" and "_英文" not in stem and "_English" not in stem:
  37. out.append(f)
  38. elif f.suffix == ".ass":
  39. out.append(f)
  40. return out
  41. base = video_path.stem
  42. cands = _candidates(base)
  43. srt = [c for c in cands if c.suffix == ".srt"]
  44. ass = [c for c in cands if c.suffix == ".ass"]
  45. chosen = srt[0] if srt else (ass[0] if ass else None)
  46. if chosen is None:
  47. shorter = base.split("-")[0] if "-" in base else base
  48. if shorter != base:
  49. cands = _candidates(shorter)
  50. srt = [c for c in cands if c.suffix == ".srt"]
  51. ass = [c for c in cands if c.suffix == ".ass"]
  52. chosen = srt[0] if srt else (ass[0] if ass else None)
  53. return chosen
  54. def _ts_to_sec(ts: str) -> float:
  55. ts = ts.replace(",", ".")
  56. parts = ts.split(":")
  57. h, m = int(parts[0]), int(parts[1])
  58. s = float(parts[2]) if len(parts) == 3 else 0.0
  59. return h * 3600 + m * 60 + s
  60. def sec_to_ts(sec: float) -> str:
  61. h = int(sec // 3600)
  62. m = int((sec % 3600) // 60)
  63. s = sec % 60
  64. return f"{h:02d}:{m:02d}:{s:06.3f}"
  65. def parse_srt(path: Path) -> list[tuple[float, str]]:
  66. text = path.read_text(encoding="utf-8-sig")
  67. segs: list[tuple[float, str]] = []
  68. for m in SRT_PATTERN.finditer(text):
  69. start = _ts_to_sec(m.group(2))
  70. raw = m.group(4).strip().replace("\n", " ")
  71. if raw:
  72. segs.append((start, raw))
  73. return segs
  74. def parse_ass(path: Path) -> list[tuple[float, str]]:
  75. text = path.read_text(encoding="utf-8-sig")
  76. segs: list[tuple[float, str]] = []
  77. for m in ASS_DIALOGUE.finditer(text):
  78. start = _ts_to_sec(m.group(1))
  79. raw = m.group(3).strip()
  80. raw = ASS_TAG.sub("", raw)
  81. raw = ASS_LINEBREAK.sub(" ", raw)
  82. if raw:
  83. segs.append((start, raw))
  84. return segs
  85. def collect_videos(input_path: Path) -> list[Path]:
  86. if input_path.is_file():
  87. return [input_path]
  88. return sorted(input_path.rglob("*.mp4"))
  89. def cmd_parse(args: argparse.Namespace) -> None:
  90. input_path = Path(args.input)
  91. outdir = Path(args.outdir)
  92. videos = collect_videos(input_path)
  93. for vp in videos:
  94. sub = find_subtitle(vp)
  95. if sub is None:
  96. print(f"[WARN] No subtitle for: {vp.name}", file=sys.stderr)
  97. continue
  98. segs: list[tuple[float, str]] = []
  99. if sub.suffix == ".srt":
  100. segs = parse_srt(sub)
  101. elif sub.suffix == ".ass":
  102. segs = parse_ass(sub)
  103. if input_path.is_dir():
  104. rel = vp.relative_to(input_path)
  105. out = outdir / rel.with_suffix(".txt")
  106. else:
  107. out = outdir / vp.with_suffix(".txt").name
  108. out.parent.mkdir(parents=True, exist_ok=True)
  109. lines = [f"[{sec_to_ts(s)}] {t}" for s, t in segs]
  110. out.write_text("\n".join(lines), encoding="utf-8")
  111. print(f"[OK] {vp.name} -> {out} ({len(segs)} segments)")
  112. def parse_ts(s: str) -> float:
  113. s = s.strip()
  114. if ":" in s:
  115. parts = s.split(":")
  116. h, m = int(parts[0]), int(parts[1])
  117. sec = float(parts[2]) if len(parts) == 3 else 0.0
  118. return h * 3600 + m * 60 + sec
  119. return float(s)
  120. def cmd_frame(args: argparse.Namespace) -> None:
  121. video = Path(args.video)
  122. if not video.exists():
  123. print(f"[ERR] Video not found: {video}", file=sys.stderr)
  124. sys.exit(1)
  125. ts = parse_ts(args.timestamp)
  126. out = Path(args.outpath)
  127. out.parent.mkdir(parents=True, exist_ok=True)
  128. cmd = [
  129. "ffmpeg", "-ss", str(ts), "-i", str(video),
  130. "-vframes", "1", "-q:v", "2", "-y", str(out),
  131. ]
  132. result = subprocess.run(cmd, capture_output=True, text=True)
  133. if result.returncode != 0:
  134. print(f"[ERR] ffmpeg failed: {result.stderr.strip()}", file=sys.stderr)
  135. sys.exit(1)
  136. print(f"[OK] Frame saved -> {out}")
  137. def main() -> None:
  138. parser = argparse.ArgumentParser(description="Video subtitle extractor & frame grabber")
  139. sub = parser.add_subparsers(dest="command")
  140. p = sub.add_parser("parse", help="Extract timed text from subtitles")
  141. p.add_argument("input", help="Video file or directory")
  142. p.add_argument("--outdir", default="./transcripts", help="Output directory")
  143. p = sub.add_parser("frame", help="Extract single frame as JPEG")
  144. p.add_argument("video", help="Path to video file")
  145. p.add_argument("timestamp", help="Timestamp (HH:MM:SS or seconds)")
  146. p.add_argument("--outpath", required=True, help="Output image path")
  147. args = parser.parse_args()
  148. if args.command == "parse":
  149. cmd_parse(args)
  150. elif args.command == "frame":
  151. cmd_frame(args)
  152. else:
  153. parser.print_help()
  154. sys.exit(1)
  155. if __name__ == "__main__":
  156. main()