Files
playground/embed_subs.py

197 lines
6.1 KiB
Python
Executable File

#!/usr/bin/env -S uv run --script
# /// script
# dependencies = []
# ///
import argparse
import json
import logging
import re
import subprocess
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Embed subtitle files into videos while preserving existing subtitle tracks. Supports processing multiple videos at once.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"files",
type=Path,
nargs="+",
help="Video files and subtitle files (in any order). Subtitle files must follow pattern: {video_stem}.{lang}.srt",
)
parser.add_argument(
"--output-dir",
"-d",
type=Path,
help="Output directory for processed videos (default: same directory as input video)",
)
return parser.parse_args()
def extract_language_from_subtitle(video_stem: str, subtitle_path: Path) -> str | None:
pattern = re.compile(rf"^{re.escape(video_stem)}\.([a-z]{{2}})\.srt$", re.IGNORECASE)
match = pattern.match(subtitle_path.name)
if not match:
return None
return match.group(1).lower()
def match_videos_and_subtitles(paths: list[Path]) -> dict[Path, dict[str, Path]]:
video_extensions = [".mp4", ".mkv"]
video_files = [f for f in paths if f.suffix.lower() in video_extensions]
subtitle_files = [f for f in paths if f.suffix.lower() == ".srt"]
if len(video_files) == 0:
raise ValueError("No video files provided. Videos must have .mp4 or .mkv extension.")
if len(subtitle_files) == 0:
raise ValueError("No subtitle files provided. Subtitle files must have .srt extension.")
other_files = [f for f in paths if f not in video_files and f not in subtitle_files]
if other_files:
raise ValueError(f"Unknown file types: {[str(f) for f in other_files]}")
video_stems = {video.stem: video for video in video_files}
grouped = {video: {} for video in video_files}
for subtitle_path in subtitle_files:
matched = False
for video_stem, video_path in video_stems.items():
lang = extract_language_from_subtitle(video_stem=video_stem, subtitle_path=subtitle_path)
if lang is not None:
if lang in grouped[video_path]:
raise ValueError(
f"Duplicate language code '{lang}' for video '{video_path.name}': {subtitle_path.name} and {grouped[video_path][lang].name}"
)
grouped[video_path][lang] = subtitle_path
matched = True
break
if not matched:
logging.warning(f"Skipping subtitle file '{subtitle_path.name}': does not match any video file pattern")
return grouped
def probe_existing_subtitles(video_path: Path) -> int:
cmd = [
"ffprobe",
"-v",
"error",
"-select_streams",
"s",
"-show_entries",
"stream=index",
"-of",
"json",
str(video_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
data = json.loads(result.stdout)
streams = data.get("streams", [])
return len(streams)
def detect_subtitle_codec(output_path: Path) -> str:
ext = output_path.suffix.lower()
if ext == ".mp4":
return "mov_text"
elif ext == ".mkv":
return "srt"
else:
raise ValueError(f"Unsupported output container format: {ext}. Supported formats: .mp4, .mkv")
def embed_subtitles(
video_path: Path,
subtitles: dict[str, Path],
output_path: Path,
) -> Path:
existing_subtitle_count = probe_existing_subtitles(video_path=video_path)
subtitle_codec = detect_subtitle_codec(output_path=output_path)
logging.info(f"Video: {video_path.name}")
logging.info(f"Existing subtitle tracks: {existing_subtitle_count}")
logging.info(f"Adding {len(subtitles)} new subtitle track(s): {list(subtitles.keys())}")
logging.info(f"Output: {output_path.name}")
cmd = [
"ffmpeg",
"-y",
"-i",
str(video_path),
]
for subtitle_path in subtitles.values():
cmd.extend(["-i", str(subtitle_path)])
cmd.extend(["-c:v", "copy", "-c:a", "copy"])
cmd.extend(["-map", "0"])
for i in range(len(subtitles)):
cmd.extend(["-map", f"{i + 1}:s"])
for i, lang in enumerate(subtitles.keys()):
subtitle_index = existing_subtitle_count + i
cmd.extend([f"-c:s:{subtitle_index}", subtitle_codec])
cmd.extend([f"-metadata:s:s:{subtitle_index}", f"language={lang}"])
cmd.append(str(output_path))
logging.info("Executing ffmpeg command...")
subprocess.run(cmd, check=True)
return output_path
def main():
args = parse_args()
for file_path in args.files:
if not file_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
if args.output_dir and not args.output_dir.exists():
raise FileNotFoundError(f"Output directory not found: {args.output_dir}")
grouped_subtitles = match_videos_and_subtitles(paths=args.files)
videos_processed = 0
videos_skipped = 0
for video_path, subtitles in grouped_subtitles.items():
if len(subtitles) == 0:
logging.info(f"Skipping '{video_path.name}': no matching subtitle files found")
videos_skipped += 1
continue
if args.output_dir:
output_path = args.output_dir / f"{video_path.stem}.subbed{video_path.suffix}"
else:
output_path = video_path.with_name(f"{video_path.stem}.subbed{video_path.suffix}")
logging.info(f"\n{'=' * 60}")
result_path = embed_subtitles(
video_path=video_path,
subtitles=subtitles,
output_path=output_path,
)
logging.info(f"✓ Successfully embedded subtitles: {result_path}")
videos_processed += 1
logging.info(f"\n{'=' * 60}")
logging.info(f"Summary: {videos_processed} video(s) processed, {videos_skipped} video(s) skipped")
if __name__ == "__main__":
main()