Initial version
This commit is contained in:
parent
24c03dc92a
commit
6f332d46aa
4 changed files with 835 additions and 6 deletions
786
video_subtitle_gif.py
Executable file
786
video_subtitle_gif.py
Executable file
|
|
@ -0,0 +1,786 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Video Subtitle GIF Generator
|
||||
|
||||
Searches for text in video subtitles and creates GIF clips for each match.
|
||||
|
||||
Dependencies:
|
||||
- FFmpeg and FFprobe (required): Install from https://ffmpeg.org/
|
||||
- srt (optional): Install with 'pip install srt' for SRT subtitle support
|
||||
- webvtt-py (optional): Install with 'pip install webvtt-py' for VTT subtitle support
|
||||
- colorama (optional): Install with 'pip install colorama' for colored output
|
||||
|
||||
Usage:
|
||||
python video_subtitle_gif.py VIDEO_PATH SEARCH_TEXT [OPTIONS]
|
||||
|
||||
Example:
|
||||
python video_subtitle_gif.py movie.mp4 "hello world" --fps 15 --width 640
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
try:
|
||||
from colorama import Fore, Style, init as colorama_init
|
||||
colorama_init(autoreset=True)
|
||||
COLORS_ENABLED = True
|
||||
except ImportError:
|
||||
# Fallback if colorama is not installed
|
||||
COLORS_ENABLED = False
|
||||
class Fore:
|
||||
RED = GREEN = YELLOW = BLUE = CYAN = MAGENTA = WHITE = ''
|
||||
class Style:
|
||||
BRIGHT = RESET_ALL = ''
|
||||
|
||||
# Constants
|
||||
ERROR_PREVIEW_LENGTH = 200 # Characters to show from error messages
|
||||
SUBTITLE_PREVIEW_LENGTH = 60 # Characters to show from subtitle text
|
||||
DEFAULT_ENCODINGS = ['utf-8', 'utf-8-sig', 'latin-1'] # Encoding fallback order
|
||||
|
||||
try:
|
||||
import srt
|
||||
except ImportError:
|
||||
srt = None
|
||||
|
||||
try:
|
||||
import webvtt
|
||||
except ImportError:
|
||||
webvtt = None
|
||||
|
||||
try:
|
||||
import pgsrip
|
||||
except ImportError:
|
||||
pgsrip = None
|
||||
|
||||
try:
|
||||
import pytesseract
|
||||
except ImportError:
|
||||
pytesseract = None
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
Image = None
|
||||
|
||||
|
||||
class SubtitleError(Exception):
|
||||
"""Base exception for subtitle-related errors"""
|
||||
pass
|
||||
|
||||
|
||||
class FFmpegError(Exception):
|
||||
"""Exception for FFmpeg-related errors"""
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Exception for input validation errors"""
|
||||
pass
|
||||
|
||||
|
||||
def parse_arguments() -> argparse.Namespace:
|
||||
"""Parse command-line arguments using argparse"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Search video subtitles and create GIFs for matches"
|
||||
)
|
||||
parser.add_argument("video_path", help="Path to video file")
|
||||
parser.add_argument("search_text", help="Text to search in subtitles")
|
||||
parser.add_argument(
|
||||
"--output-prefix",
|
||||
default="output",
|
||||
help="Prefix for output GIF files (default: output)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fps",
|
||||
type=int,
|
||||
default=10,
|
||||
help="GIF frames per second (default: 10, range: 1-60)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--width",
|
||||
type=int,
|
||||
default=480,
|
||||
help="GIF width in pixels (default: 480, must be positive)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--context-before",
|
||||
type=float,
|
||||
default=0.5,
|
||||
help="Extra seconds before subtitle (default: 0.5, can be negative to trim)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--context-after",
|
||||
type=float,
|
||||
default=0.5,
|
||||
help="Extra seconds after subtitle (default: 0.5, can be negative to trim)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-surrounding-subtitles",
|
||||
action="store_true",
|
||||
help="Include subtitles from surrounding lines in the output"
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def check_ffmpeg_available() -> None:
|
||||
"""
|
||||
Check if FFmpeg and FFprobe are available in PATH.
|
||||
Raises FFmpegError if not found.
|
||||
"""
|
||||
for tool in ['ffmpeg', 'ffprobe']:
|
||||
try:
|
||||
subprocess.run(
|
||||
[tool, '-version'],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
timeout=5
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise FFmpegError(
|
||||
f"{tool} not found. Please install FFmpeg from https://ffmpeg.org/"
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
raise FFmpegError(f"{tool} is installed but not working correctly")
|
||||
except subprocess.TimeoutExpired:
|
||||
raise FFmpegError(f"{tool} is not responding")
|
||||
|
||||
|
||||
def validate_inputs(args: argparse.Namespace) -> None:
|
||||
"""
|
||||
Validate all input arguments.
|
||||
Raises ValidationError if any validation fails.
|
||||
"""
|
||||
# Validate video file
|
||||
if not os.path.exists(args.video_path):
|
||||
raise ValidationError(f"Video file not found: {args.video_path}")
|
||||
|
||||
if not os.path.isfile(args.video_path):
|
||||
raise ValidationError(f"Path is not a file: {args.video_path}")
|
||||
|
||||
# Validate search text
|
||||
if not args.search_text or len(args.search_text.strip()) == 0:
|
||||
raise ValidationError("Search text cannot be empty")
|
||||
|
||||
# Validate numeric arguments
|
||||
if args.fps < 1 or args.fps > 60:
|
||||
raise ValidationError(f"FPS must be between 1 and 60, got: {args.fps}")
|
||||
|
||||
if args.width < 1:
|
||||
raise ValidationError(f"Width must be positive, got: {args.width}")
|
||||
|
||||
|
||||
def find_or_extract_subtitles(video_path: str) -> Optional[str]:
|
||||
"""
|
||||
Find external subtitle file, extract embedded subtitles, or download from OpenSubtitles.
|
||||
Returns path to subtitle file or None if not found.
|
||||
"""
|
||||
video_dir = os.path.dirname(os.path.abspath(video_path))
|
||||
video_name = os.path.splitext(os.path.basename(video_path))[0]
|
||||
|
||||
# Check for external subtitle files
|
||||
for ext in ['.srt', '.ass', '.vtt']:
|
||||
subtitle_path = os.path.join(video_dir, video_name + ext)
|
||||
if os.path.exists(subtitle_path):
|
||||
print(f"{Fore.GREEN}✅ Found external subtitle: {subtitle_path}{Style.RESET_ALL}")
|
||||
return subtitle_path
|
||||
|
||||
# Try extracting embedded subtitles
|
||||
print(f"{Fore.YELLOW}🔍 No external subtitles found. Checking for embedded subtitles...{Style.RESET_ALL}")
|
||||
embedded_subs = extract_embedded_subtitles(video_path, video_dir, video_name)
|
||||
if embedded_subs:
|
||||
return embedded_subs
|
||||
|
||||
# Do not try downloading from OpenSubtitles
|
||||
print(f"{Fore.YELLOW}🌐 Trying to grab subtitles from the internet is too janky, just do it manually. {Style.RESET_ALL}")
|
||||
|
||||
|
||||
def extract_embedded_subtitles(video_path: str, output_dir: str, base_name: str) -> Optional[str]:
|
||||
"""
|
||||
Extract embedded subtitles using FFmpeg.
|
||||
Tries to find English subtitles first.
|
||||
Returns path to extracted subtitle file or None.
|
||||
"""
|
||||
# First, poke the video to find subtitle streams with detailed info
|
||||
probe_cmd = [
|
||||
'ffprobe', '-v', 'error',
|
||||
'-select_streams', 's',
|
||||
'-show_entries', 'stream=index:stream=codec_name:stream_tags=language,title',
|
||||
'-of', 'json',
|
||||
video_path
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
probe_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
probe_data = json.loads(result.stdout)
|
||||
|
||||
streams = probe_data.get('streams', [])
|
||||
if not streams:
|
||||
print(f"{Fore.YELLOW}❌ No embedded subtitles found{Style.RESET_ALL}")
|
||||
return None
|
||||
|
||||
print(f"{Fore.CYAN}📺 Found {len(streams)} subtitle stream(s):{Style.RESET_ALL}")
|
||||
|
||||
# Find English subtitle stream
|
||||
english_stream_idx = None
|
||||
first_stream_idx = None
|
||||
english_codec = None
|
||||
|
||||
for i, stream in enumerate(streams):
|
||||
stream_index = stream.get('index')
|
||||
codec_name = stream.get('codec_name', 'unknown')
|
||||
tags = stream.get('tags', {})
|
||||
language = tags.get('language', 'unknown').lower()
|
||||
title = tags.get('title', '')
|
||||
|
||||
print(f"{Fore.CYAN} 📝 Stream {i}: index={stream_index}, codec={codec_name}, language={language}, title={title}{Style.RESET_ALL}")
|
||||
|
||||
# Remember first stream as fallback
|
||||
if first_stream_idx is None:
|
||||
first_stream_idx = i
|
||||
|
||||
# Check if this is an English subtitle
|
||||
if language in ['en', 'eng', 'english'] or 'english' in title.lower():
|
||||
english_stream_idx = i
|
||||
english_codec = codec_name
|
||||
print(f"{Fore.GREEN} ✅ Selected English subtitle stream {i} (codec: {codec_name}){Style.RESET_ALL}")
|
||||
break
|
||||
|
||||
# Use English stream if found, otherwise use first stream
|
||||
selected_stream = english_stream_idx if english_stream_idx is not None else first_stream_idx
|
||||
selected_codec = english_codec if english_codec else streams[first_stream_idx].get('codec_name', 'unknown')
|
||||
|
||||
if selected_stream is None:
|
||||
print(f"{Fore.YELLOW}❌ No suitable subtitle stream found{Style.RESET_ALL}")
|
||||
return None
|
||||
|
||||
if english_stream_idx is None:
|
||||
print(f"{Fore.YELLOW} ⚠️ No English subtitle found, using first stream {selected_stream}{Style.RESET_ALL}")
|
||||
|
||||
# Extract selected subtitle stream
|
||||
# Try different extraction methods based on codec
|
||||
output_path = os.path.join(output_dir, f"{base_name}.srt")
|
||||
|
||||
# First attempt: Try converting to SRT
|
||||
extract_cmd = [
|
||||
'ffmpeg', '-v', 'error', '-i', video_path,
|
||||
'-map', f'0:s:{selected_stream}',
|
||||
'-c:s', 'srt',
|
||||
'-y',
|
||||
output_path
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
extract_cmd,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
# If SRT conversion failed, try extracting as-is and converting later
|
||||
if result.returncode != 0:
|
||||
print(f"{Fore.YELLOW} ⚠️ SRT conversion failed, trying alternative extraction...{Style.RESET_ALL}")
|
||||
print(f"{Fore.RED} ⚠️ Error: {result.stderr[:ERROR_PREVIEW_LENGTH]}{Style.RESET_ALL}")
|
||||
|
||||
# Try extracting with copy codec (no conversion)
|
||||
temp_output = os.path.join(output_dir, f"{base_name}.{selected_codec}")
|
||||
extract_cmd = [
|
||||
'ffmpeg', '-v', 'error', '-i', video_path,
|
||||
'-map', f'0:s:{selected_stream}',
|
||||
'-c:s', 'copy',
|
||||
'-y',
|
||||
temp_output
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
extract_cmd,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"{Fore.RED} ❌ Failed to extract subtitle: {result.stderr[:ERROR_PREVIEW_LENGTH]}{Style.RESET_ALL}")
|
||||
return None
|
||||
|
||||
# If extracted successfully, check if it's already text-based
|
||||
if selected_codec in ['srt', 'subrip', 'ass', 'ssa', 'vtt', 'webvtt']:
|
||||
# Rename to .srt for consistency
|
||||
if selected_codec in ['srt', 'subrip']:
|
||||
os.rename(temp_output, output_path)
|
||||
else:
|
||||
# Keep original extension for ASS/VTT
|
||||
output_path = temp_output
|
||||
else:
|
||||
# Image-based subtitle format detected
|
||||
print(f"{Fore.YELLOW} ⚠️ Detected image-based subtitle format: {selected_codec}{Style.RESET_ALL}")
|
||||
return None
|
||||
else:
|
||||
# Check if the codec is image-based
|
||||
if selected_codec in ['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub', 'pgssub']:
|
||||
print(f"{Fore.YELLOW} ⚠️ Found image-based subtitles ({selected_codec}){Style.RESET_ALL}")
|
||||
return None
|
||||
|
||||
print(f"{Fore.GREEN}✅ Extracted embedded subtitles to: {output_path}{Style.RESET_ALL}")
|
||||
return output_path
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"{Fore.RED}❌ Failed to extract subtitles: {e}{Style.RESET_ALL}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"{Fore.RED}❌ Error during subtitle extraction: {e}{Style.RESET_ALL}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_subtitles(subtitle_path: str) -> List[Dict]:
|
||||
"""
|
||||
Parse subtitle file and return list of subtitle entries.
|
||||
Each entry: {"index": int, "start": float, "end": float, "text": str}
|
||||
Raises SubtitleError if format is unsupported.
|
||||
"""
|
||||
ext = os.path.splitext(subtitle_path)[1].lower()
|
||||
|
||||
if ext == '.srt':
|
||||
return parse_srt(subtitle_path)
|
||||
elif ext == '.vtt':
|
||||
return parse_vtt(subtitle_path)
|
||||
elif ext == '.ass':
|
||||
return parse_ass(subtitle_path)
|
||||
else:
|
||||
raise SubtitleError(f"Unsupported subtitle format: {ext}")
|
||||
|
||||
|
||||
def parse_srt(subtitle_path: str) -> List[Dict]:
|
||||
"""
|
||||
Parse SRT subtitle file using srt library.
|
||||
Raises SubtitleError if library is missing or parsing fails.
|
||||
"""
|
||||
if srt is None:
|
||||
raise SubtitleError("'srt' library not installed. Install with: pip install srt")
|
||||
|
||||
subtitles = None
|
||||
last_error = None
|
||||
|
||||
for encoding in DEFAULT_ENCODINGS:
|
||||
try:
|
||||
with open(subtitle_path, 'r', encoding=encoding) as f:
|
||||
subtitle_generator = srt.parse(f.read())
|
||||
subtitles = list(subtitle_generator)
|
||||
break
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
last_error = f"Encoding {encoding} failed"
|
||||
if encoding == DEFAULT_ENCODINGS[-1]:
|
||||
raise SubtitleError(f"Could not decode subtitle file with any encoding: {', '.join(DEFAULT_ENCODINGS)}")
|
||||
continue
|
||||
except Exception as e:
|
||||
raise SubtitleError(f"Error parsing SRT file: {e}")
|
||||
|
||||
if subtitles is None:
|
||||
raise SubtitleError(f"Failed to parse SRT file: {last_error}")
|
||||
|
||||
entries = []
|
||||
for sub in subtitles:
|
||||
entries.append({
|
||||
"index": sub.index,
|
||||
"start": sub.start.total_seconds(),
|
||||
"end": sub.end.total_seconds(),
|
||||
"text": sub.content
|
||||
})
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def parse_vtt(subtitle_path: str) -> List[Dict]:
|
||||
"""
|
||||
Parse WebVTT subtitle file.
|
||||
Raises SubtitleError if library is missing or parsing fails.
|
||||
"""
|
||||
if webvtt is None:
|
||||
raise SubtitleError("'webvtt-py' library not installed. Install with: pip install webvtt-py")
|
||||
|
||||
try:
|
||||
vtt = webvtt.read(subtitle_path)
|
||||
except Exception as e:
|
||||
raise SubtitleError(f"Error parsing VTT file: {e}")
|
||||
|
||||
entries = []
|
||||
|
||||
for i, caption in enumerate(vtt, 1):
|
||||
# WebVTT timestamps are in format HH:MM:SS.mmm
|
||||
start = parse_vtt_timestamp(caption.start)
|
||||
end = parse_vtt_timestamp(caption.end)
|
||||
|
||||
entries.append({
|
||||
"index": i,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"text": caption.text
|
||||
})
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def parse_vtt_timestamp(timestamp_str: str) -> float:
|
||||
"""Convert VTT timestamp to seconds"""
|
||||
# Format: HH:MM:SS.mmm or MM:SS.mmm
|
||||
parts = timestamp_str.split(':')
|
||||
if len(parts) == 3:
|
||||
h, m, s = parts
|
||||
return int(h) * 3600 + int(m) * 60 + float(s)
|
||||
else:
|
||||
m, s = parts
|
||||
return int(m) * 60 + float(s)
|
||||
|
||||
|
||||
def parse_ass(subtitle_path: str) -> List[Dict]:
|
||||
"""
|
||||
Parse ASS/SSA subtitle file.
|
||||
Raises SubtitleError if parsing fails.
|
||||
"""
|
||||
entries = []
|
||||
index = 0
|
||||
last_error = None
|
||||
|
||||
for encoding in DEFAULT_ENCODINGS:
|
||||
try:
|
||||
with open(subtitle_path, 'r', encoding=encoding) as f:
|
||||
in_events = False
|
||||
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
|
||||
if line == '[Events]':
|
||||
in_events = True
|
||||
continue
|
||||
|
||||
if in_events and line.startswith('Dialogue:'):
|
||||
# Format: Dialogue: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text
|
||||
parts = line.split(',', 9)
|
||||
if len(parts) >= 10:
|
||||
start = parse_ass_timestamp(parts[1])
|
||||
end = parse_ass_timestamp(parts[2])
|
||||
text = parts[9]
|
||||
|
||||
# Remove ASS formatting tags
|
||||
text = re.sub(r'\{[^}]*\}', '', text)
|
||||
text = text.replace('\\N', '\n')
|
||||
|
||||
index += 1
|
||||
entries.append({
|
||||
"index": index,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"text": text
|
||||
})
|
||||
break
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
last_error = f"Encoding {encoding} failed"
|
||||
if encoding == DEFAULT_ENCODINGS[-1]:
|
||||
raise SubtitleError(f"Could not decode subtitle file with any encoding: {', '.join(DEFAULT_ENCODINGS)}")
|
||||
continue
|
||||
except Exception as e:
|
||||
raise SubtitleError(f"Error parsing ASS file: {e}")
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def parse_ass_timestamp(timestamp_str: str) -> float:
|
||||
"""Convert ASS timestamp (H:MM:SS.cc) to seconds"""
|
||||
# Format: H:MM:SS.cc
|
||||
h, m, s = timestamp_str.split(':')
|
||||
return int(h) * 3600 + int(m) * 60 + float(s)
|
||||
|
||||
|
||||
def search_subtitles(subtitle_entries: List[Dict], search_text: str) -> List[Dict]:
|
||||
"""
|
||||
Search subtitle entries for case-insensitive substring matches.
|
||||
Returns list of matching entries.
|
||||
"""
|
||||
search_lower = search_text.lower()
|
||||
matches = []
|
||||
|
||||
for entry in subtitle_entries:
|
||||
if search_lower in entry['text'].lower():
|
||||
matches.append(entry)
|
||||
|
||||
print(f"\n{Fore.GREEN}{Style.BRIGHT}✨ Found {len(matches)} matching subtitle(s):{Style.RESET_ALL}")
|
||||
for i, match in enumerate(matches, 1):
|
||||
preview_text = match['text'].replace('\n', ' ')[:SUBTITLE_PREVIEW_LENGTH]
|
||||
print(f"{Fore.CYAN} {i}. [{format_timestamp(match['start'])} - {format_timestamp(match['end'])}]: {Fore.WHITE}{preview_text}...{Style.RESET_ALL}")
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
def format_timestamp(seconds: float) -> str:
|
||||
"""Convert seconds to HH:MM:SS.mmm format"""
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = seconds % 60
|
||||
return f"{hours:02d}:{minutes:02d}:{secs:06.3f}"
|
||||
|
||||
|
||||
def create_single_subtitle_file(match: Dict, subtitle_entries: List[Dict], match_index: int,
|
||||
context_before: float, context_after: float, clip_start_time: float,
|
||||
include_surrounding: bool) -> str:
|
||||
"""
|
||||
Create a temporary SRT file with the matched subtitle and optionally surrounding subtitles.
|
||||
Adjusts timestamps relative to clip_start_time for input seeking.
|
||||
Returns path to temporary file (caller is responsible for cleanup).
|
||||
|
||||
Args:
|
||||
match: The matched subtitle entry
|
||||
subtitle_entries: All subtitle entries (needed for surrounding context)
|
||||
match_index: Index of the match in subtitle_entries
|
||||
context_before: Seconds before the subtitle
|
||||
context_after: Seconds after the subtitle
|
||||
clip_start_time: Start time of the video clip
|
||||
include_surrounding: Whether to include subtitles from surrounding lines
|
||||
"""
|
||||
if srt is None:
|
||||
raise SubtitleError("'srt' library not installed. Install with: pip install srt")
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
subtitles_to_include = []
|
||||
|
||||
if include_surrounding:
|
||||
# Calculate the time range of the clip
|
||||
clip_end_time = clip_start_time + (match['end'] + context_after - clip_start_time)
|
||||
clip_duration = clip_end_time - clip_start_time
|
||||
|
||||
# Find all subtitles that overlap with the clip time range
|
||||
for entry in subtitle_entries:
|
||||
# Check if subtitle overlaps with clip time range
|
||||
if entry['end'] >= clip_start_time and entry['start'] <= clip_end_time:
|
||||
# Adjust subtitle times to be relative to clip start
|
||||
adjusted_start = max(0, entry['start'] - clip_start_time)
|
||||
adjusted_end = entry['end'] - clip_start_time
|
||||
|
||||
# Clamp the end time to the clip duration
|
||||
adjusted_end = min(adjusted_end, clip_duration)
|
||||
|
||||
# Only add if valid duration
|
||||
if adjusted_end > adjusted_start and adjusted_end > 0:
|
||||
subtitles_to_include.append(srt.Subtitle(
|
||||
index=len(subtitles_to_include) + 1,
|
||||
start=timedelta(seconds=adjusted_start),
|
||||
end=timedelta(seconds=adjusted_end),
|
||||
content=entry['text']
|
||||
))
|
||||
else:
|
||||
# Only include the matched subtitle
|
||||
# Calculate the clip duration to clamp subtitle end time
|
||||
clip_duration = match['end'] + context_after - clip_start_time
|
||||
|
||||
adjusted_start = max(0, match['start'] - clip_start_time)
|
||||
adjusted_end = match['end'] - clip_start_time
|
||||
|
||||
# Clamp the end time to the clip duration
|
||||
adjusted_end = min(adjusted_end, clip_duration)
|
||||
|
||||
# Only add subtitle if it has valid duration
|
||||
if adjusted_end > adjusted_start and adjusted_end > 0:
|
||||
subtitles_to_include.append(srt.Subtitle(
|
||||
index=1,
|
||||
start=timedelta(seconds=adjusted_start),
|
||||
end=timedelta(seconds=adjusted_end),
|
||||
content=match['text']
|
||||
))
|
||||
|
||||
# Write to temporary file using context manager for proper resource handling
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.srt', delete=False, encoding='utf-8') as temp_file:
|
||||
temp_file.write(srt.compose(subtitles_to_include))
|
||||
temp_path = temp_file.name
|
||||
|
||||
return temp_path
|
||||
|
||||
|
||||
def generate_gifs(video_path: str, subtitle_path: str, matches: List[Dict],
|
||||
subtitle_entries: List[Dict], args: argparse.Namespace) -> None:
|
||||
"""Generate GIF for each matching subtitle"""
|
||||
output_prefix = args.output_prefix
|
||||
fps = args.fps
|
||||
width = args.width
|
||||
context_before = args.context_before
|
||||
context_after = args.context_after
|
||||
include_surrounding = args.include_surrounding_subtitles
|
||||
|
||||
for i, match in enumerate(matches, 1):
|
||||
# Calculate clip timestamps with context padding
|
||||
start_time = max(0, match['start'] - context_before)
|
||||
end_time = match['end'] + context_after
|
||||
duration = end_time - start_time
|
||||
|
||||
# Validate that we have a positive duration
|
||||
if duration <= 0:
|
||||
print(f"\n{Fore.YELLOW}⚠️ Skipping match {i}: negative duration ({duration:.3f}s){Style.RESET_ALL}")
|
||||
print(f"{Fore.YELLOW} 💡 Subtitle: {format_timestamp(match['start'])} - {format_timestamp(match['end'])}{Style.RESET_ALL}")
|
||||
print(f"{Fore.YELLOW} 💡 After trimming: {format_timestamp(start_time)} - {format_timestamp(end_time)}{Style.RESET_ALL}")
|
||||
print(f"{Fore.YELLOW} 💡 Try reducing --context-before or --context-after values{Style.RESET_ALL}")
|
||||
continue
|
||||
|
||||
output_gif = f"{output_prefix}_{i}.gif"
|
||||
|
||||
print(f"\n{Fore.MAGENTA}{Style.BRIGHT}🎬 Generating {output_gif}...{Style.RESET_ALL}")
|
||||
print(f"{Fore.CYAN} ⏱️ Time range: {format_timestamp(start_time)} - {format_timestamp(end_time)}{Style.RESET_ALL}")
|
||||
|
||||
# Create temporary subtitle file with this match (and optionally surrounding subtitles)
|
||||
# Adjust subtitle timestamps to be relative to start_time
|
||||
# Find the index of this match in subtitle_entries
|
||||
match_index = next((idx for idx, entry in enumerate(subtitle_entries)
|
||||
if entry['start'] == match['start'] and entry['text'] == match['text']), 0)
|
||||
|
||||
temp_subtitle_path = create_single_subtitle_file(
|
||||
match, subtitle_entries, match_index,
|
||||
context_before, context_after, start_time, include_surrounding
|
||||
)
|
||||
|
||||
# Create temporary video clip (fast stream copy, no re-encoding)
|
||||
temp_clip = tempfile.NamedTemporaryFile(suffix='.mkv', delete=False)
|
||||
temp_clip.close()
|
||||
temp_clip_path = temp_clip.name
|
||||
|
||||
try:
|
||||
# Step 1: Extract clip with precise seeking
|
||||
# Note: Using -ss after -i for accurate seeking, but slower
|
||||
# Using -t for duration instead of -to for better accuracy
|
||||
print(f"{Fore.YELLOW} ✂️ Extracting clip...{Style.RESET_ALL}")
|
||||
extract_cmd = [
|
||||
'ffmpeg',
|
||||
'-i', video_path,
|
||||
'-ss', str(start_time), # Seek after input for accuracy
|
||||
'-t', str(duration), # Duration of clip
|
||||
'-c:v', 'libx264', # Re-encode for precise cutting
|
||||
'-preset', 'ultrafast', # Fast encoding
|
||||
'-c:a', 'aac', # Audio codec
|
||||
'-y',
|
||||
temp_clip_path
|
||||
]
|
||||
|
||||
subprocess.run(
|
||||
extract_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
|
||||
# Step 2: Convert clip to GIF with subtitles
|
||||
print(f"{Fore.YELLOW} 🎨 Converting to GIF...{Style.RESET_ALL}")
|
||||
|
||||
# Escape special characters in subtitle path for FFmpeg filter
|
||||
# For Windows paths: escape backslashes and colons
|
||||
# For all paths: escape special filter chars
|
||||
escaped_subtitle_path = temp_subtitle_path.replace('\\', '/').replace(':', '\\:')
|
||||
|
||||
# Build the filter_complex string with proper quoting
|
||||
# Note: Use double backslash for the quote escaping in force_style
|
||||
filter_string = (
|
||||
# Subtitles are now relative to the clip start (timestamp 0)
|
||||
f"[0:v]subtitles={escaped_subtitle_path}:force_style='FontSize=24\\,Bold=1',fps={fps},scale={width}:-1:flags=lanczos[sub];"
|
||||
# Split for palette generation
|
||||
"[sub]split[s0][s1];"
|
||||
"[s0]palettegen[p];"
|
||||
"[s1][p]paletteuse[out]"
|
||||
)
|
||||
|
||||
gif_cmd = [
|
||||
'ffmpeg',
|
||||
'-i', temp_clip_path,
|
||||
'-filter_complex', filter_string,
|
||||
'-map', '[out]',
|
||||
'-loop', '0',
|
||||
'-y',
|
||||
output_gif
|
||||
]
|
||||
|
||||
subprocess.run(
|
||||
gif_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
|
||||
# Get file size
|
||||
size_mb = os.path.getsize(output_gif) / (1024 * 1024)
|
||||
print(f"{Fore.GREEN} ✅ Created: {output_gif} ({size_mb:.2f} MB){Style.RESET_ALL}")
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"{Fore.RED} ❌ Error creating GIF: {e}{Style.RESET_ALL}")
|
||||
print(f"{Fore.RED} FFmpeg stderr: {e.stderr}{Style.RESET_ALL}")
|
||||
finally:
|
||||
# Clean up temporary files
|
||||
try:
|
||||
os.unlink(temp_subtitle_path)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
os.unlink(temp_clip_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the script"""
|
||||
try:
|
||||
# Check dependencies
|
||||
check_ffmpeg_available()
|
||||
|
||||
# Parse and validate arguments
|
||||
args = parse_arguments()
|
||||
validate_inputs(args)
|
||||
|
||||
# Find or extract subtitles
|
||||
subtitle_file = find_or_extract_subtitles(args.video_path)
|
||||
if not subtitle_file:
|
||||
print(f"{Fore.RED}❌ Error: No subtitles found{Style.RESET_ALL}")
|
||||
print(f"{Fore.YELLOW}💡 Please download subtitles manually and place them next to the video file{Style.RESET_ALL}")
|
||||
sys.exit(1)
|
||||
|
||||
# Parse subtitles
|
||||
subtitle_entries = parse_subtitles(subtitle_file)
|
||||
|
||||
# Search for matches
|
||||
matches = search_subtitles(subtitle_entries, args.search_text)
|
||||
|
||||
if not matches:
|
||||
print(f"\n{Fore.YELLOW}🔍 No matches found for '{args.search_text}'{Style.RESET_ALL}")
|
||||
sys.exit(0)
|
||||
|
||||
# Generate GIFs
|
||||
print(f"\n{Fore.MAGENTA}{Style.BRIGHT}🎬 Generating GIFs...{Style.RESET_ALL}")
|
||||
generate_gifs(args.video_path, subtitle_file, matches, subtitle_entries, args)
|
||||
|
||||
print(f"\n{Fore.GREEN}{Style.BRIGHT}🎉 Successfully created {len(matches)} GIF(s)!{Style.RESET_ALL}")
|
||||
|
||||
except ValidationError as e:
|
||||
print(f"{Fore.RED}❌ Validation error: {e}{Style.RESET_ALL}")
|
||||
sys.exit(1)
|
||||
except FFmpegError as e:
|
||||
print(f"{Fore.RED}❌ FFmpeg error: {e}{Style.RESET_ALL}")
|
||||
sys.exit(1)
|
||||
except SubtitleError as e:
|
||||
print(f"{Fore.RED}❌ Subtitle error: {e}{Style.RESET_ALL}")
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n\n{Fore.YELLOW}⚠️ Operation cancelled by user{Style.RESET_ALL}")
|
||||
sys.exit(130)
|
||||
except Exception as e:
|
||||
print(f"{Fore.RED}❌ Unexpected error: {e}{Style.RESET_ALL}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue