AI Transcriber tool
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
# AI Video Transcriber & Translator
|
||||
|
||||
This tool extracts audio from videos, transcribes it using OpenAI's Whisper model, and translates the transcript using Google's Gemini API.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Install Dependencies:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
*Note: You need `ffmpeg` installed on your system.*
|
||||
|
||||
2. **API Key:**
|
||||
Set your Gemini API key as an environment variable:
|
||||
```bash
|
||||
export GEMINI_API_KEY="your_api_key_here"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Quick Start (Wizard)
|
||||
For a user-friendly, interactive experience, run the wizard script in the root directory:
|
||||
|
||||
```bash
|
||||
./run_wizard.py
|
||||
```
|
||||
This will guide you through selecting files, languages, and enabling features like cleanup and embedding.
|
||||
|
||||
### Advanced (CLI)
|
||||
Run the `main.py` script directly:
|
||||
|
||||
```bash
|
||||
python ai_transcriber/main.py <path_to_video_or_folder> [options]
|
||||
```
|
||||
|
||||
### Options:
|
||||
* `--model`: Whisper model size (`tiny`, `base`, `small`, `medium`, `large`). Default: `base`.
|
||||
* `--lang`: Target language for translation. Default: `English`.
|
||||
* `--force`: Overwrite existing transcript/translation files.
|
||||
|
||||
### Examples:
|
||||
|
||||
**Single File:**
|
||||
```bash
|
||||
python main.py ../my_video.mp4
|
||||
```
|
||||
|
||||
**Entire Directory (Recursive):**
|
||||
```bash
|
||||
python main.py ../videos_folder/ --lang "Spanish" --model small
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,100 @@
|
||||
# Note: This feature requires pyannote.audio and a HuggingFace token.
|
||||
# If these are not present, this module will likely fail or raise errors.
|
||||
# Due to the complexity and weight of pyannote.audio, this is a placeholder
|
||||
# for where the logic would sit. Implementing full diarization requires
|
||||
# downloading models and handling complex segment merging.
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
def diarize_audio(audio_path, num_speakers=None, hf_token=None):
|
||||
"""
|
||||
Performs speaker diarization using pyannote.audio.
|
||||
|
||||
Args:
|
||||
audio_path (str): Path to audio file.
|
||||
num_speakers (int, optional): Number of speakers if known.
|
||||
hf_token (str): HuggingFace Auth Token.
|
||||
|
||||
Returns:
|
||||
list: List of segments with speaker labels [(start, end, speaker), ...].
|
||||
"""
|
||||
try:
|
||||
from pyannote.audio import Pipeline
|
||||
except ImportError:
|
||||
print("Error: pyannote.audio not installed. Diarization skipped.")
|
||||
return None
|
||||
|
||||
if not hf_token:
|
||||
print("Error: HuggingFace Token (HF_TOKEN) not found. Diarization skipped.")
|
||||
return None
|
||||
|
||||
print(f"Loading Diarization Pipeline (pyannote/speaker-diarization-3.1)...")
|
||||
try:
|
||||
# Note: 'use_auth_token' was deprecated in favor of 'token' in recent versions
|
||||
pipeline = Pipeline.from_pretrained(
|
||||
"pyannote/speaker-diarization-3.1",
|
||||
token=hf_token
|
||||
)
|
||||
|
||||
# Move to GPU if available
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
pipeline.to(torch.device("cuda"))
|
||||
|
||||
print(f"Diarizing {audio_path}...")
|
||||
diarization = pipeline(audio_path, num_speakers=num_speakers)
|
||||
|
||||
results = []
|
||||
for turn, _, speaker in diarization.itertracks(yield_label=True):
|
||||
results.append({
|
||||
"start": turn.start,
|
||||
"end": turn.end,
|
||||
"speaker": speaker
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during diarization: {e}")
|
||||
return None
|
||||
|
||||
def merge_diarization_with_transcript(transcript_segments, diarization_segments):
|
||||
"""
|
||||
Merges Whisper segments with Diarization speaker labels based on time overlap.
|
||||
|
||||
Args:
|
||||
transcript_segments (list): Whisper segments [{'start': 0.0, 'end': 1.0, 'text': 'Hi'}, ...]
|
||||
diarization_segments (list): Diarization segments [{'start': 0.1, 'end': 0.9, 'speaker': 'SPEAKER_00'}]
|
||||
|
||||
Returns:
|
||||
list: Enhanced transcript segments with 'speaker' key.
|
||||
"""
|
||||
if not diarization_segments:
|
||||
return transcript_segments
|
||||
|
||||
# Simple overlap matching logic
|
||||
for t_seg in transcript_segments:
|
||||
# Find diarization segment with max overlap
|
||||
t_start = t_seg['start']
|
||||
t_end = t_seg['end']
|
||||
|
||||
best_speaker = "Unknown"
|
||||
max_overlap = 0
|
||||
|
||||
for d_seg in diarization_segments:
|
||||
d_start = d_seg['start']
|
||||
d_end = d_seg['end']
|
||||
|
||||
# Calculate intersection
|
||||
overlap_start = max(t_start, d_start)
|
||||
overlap_end = min(t_end, d_end)
|
||||
overlap_duration = max(0, overlap_end - overlap_start)
|
||||
|
||||
if overlap_duration > max_overlap:
|
||||
max_overlap = overlap_duration
|
||||
best_speaker = d_seg['speaker']
|
||||
|
||||
t_seg['speaker'] = best_speaker
|
||||
|
||||
return transcript_segments
|
||||
@@ -0,0 +1,102 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
def extract_audio(video_path, output_path=None):
|
||||
"""
|
||||
Extracts audio from a video file using ffmpeg.
|
||||
|
||||
Args:
|
||||
video_path (str): Path to the input video file.
|
||||
output_path (str, optional): Path for the output audio file.
|
||||
If None, defaults to same name with .wav extension.
|
||||
|
||||
Returns:
|
||||
str: Path to the generated audio file.
|
||||
"""
|
||||
if not os.path.exists(video_path):
|
||||
raise FileNotFoundError(f"Video file not found: {video_path}")
|
||||
|
||||
if output_path is None:
|
||||
base_name = os.path.splitext(video_path)[0]
|
||||
output_path = f"{base_name}.wav"
|
||||
|
||||
# Check if output file already exists to avoid redundant processing
|
||||
if os.path.exists(output_path):
|
||||
print(f"Audio file already exists: {output_path}")
|
||||
return output_path
|
||||
|
||||
print(f"Extracting audio from {video_path}...")
|
||||
|
||||
# Command matching the user's preferred settings: 16kHz, Mono, PCM s16le
|
||||
# -y overwrites without asking (though we checked existence above, this is for safety if we force it)
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-i", video_path,
|
||||
"-ar", "16000",
|
||||
"-ac", "1",
|
||||
"-c:a", "pcm_s16le",
|
||||
"-y",
|
||||
"-v", "error", # Less verbose
|
||||
output_path
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(command, check=True)
|
||||
print(f"Audio extracted to: {output_path}")
|
||||
return output_path
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error extracting audio: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def embed_subtitles(video_path, srt_path, output_path=None):
|
||||
"""
|
||||
Embeds subtitles into the video file (Soft Subs) and sets them as primary.
|
||||
|
||||
Args:
|
||||
video_path (str): Path to the input video.
|
||||
srt_path (str): Path to the SRT file.
|
||||
output_path (str, optional): Path for the output video.
|
||||
"""
|
||||
if not os.path.exists(video_path) or not os.path.exists(srt_path):
|
||||
print("Error: Video or SRT file not found for embedding.")
|
||||
return
|
||||
|
||||
if output_path is None:
|
||||
base, ext = os.path.splitext(video_path)
|
||||
output_path = f"{base}.subbed{ext}"
|
||||
|
||||
print(f"Embedding subtitles into: {output_path}...")
|
||||
|
||||
# Determine subtitle codec based on container
|
||||
sub_codec = "mov_text" if video_path.lower().endswith(".mp4") else "srt"
|
||||
|
||||
# Command breakdown:
|
||||
# -map 0:v -map 0:a -> Keep all video and audio from source
|
||||
# -map 1:0 -> Add the subtitle from the 2nd input (srt_path)
|
||||
# -c copy -> Copy video/audio streams (no re-encoding)
|
||||
# -disposition:s:0 default -> Make the first subtitle track (ours) the default
|
||||
# -metadata:s:s:0 -> Set metadata for the first subtitle stream
|
||||
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-i", video_path,
|
||||
"-i", srt_path,
|
||||
"-map", "0:v",
|
||||
"-map", "0:a",
|
||||
"-map", "1:0",
|
||||
"-c", "copy",
|
||||
"-c:s", sub_codec,
|
||||
"-disposition:s:0", "default",
|
||||
"-metadata:s:s:0", "language=eng",
|
||||
"-metadata:s:s:0", "title=English (AI Translated)",
|
||||
"-y",
|
||||
"-v", "error",
|
||||
output_path
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(command, check=True)
|
||||
print(f"Subtitles embedded successfully: {output_path} (Set as primary)")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error embedding subtitles: {e}")
|
||||
@@ -0,0 +1,468 @@
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from central .env_files directory
|
||||
# Path: .../personal_development/video_transcription/ai_transcriber/main.py
|
||||
# Target: .../personal_development/.env_files/.env.aitranscribe
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
env_path = os.path.abspath(os.path.join(script_dir, '../../.env_files/.env.aitranscribe'))
|
||||
|
||||
if os.path.exists(env_path):
|
||||
load_dotenv(env_path)
|
||||
# print(f"Loaded configuration from: {env_path}") # Optional: Uncomment for debugging
|
||||
else:
|
||||
# Fallback: check local .env
|
||||
local_env = os.path.join(script_dir, '.env')
|
||||
if os.path.exists(local_env):
|
||||
load_dotenv(local_env)
|
||||
else:
|
||||
# Last resort: just try loading generic (cwd)
|
||||
load_dotenv()
|
||||
|
||||
from extractor import extract_audio, embed_subtitles
|
||||
from transcriber import transcribe_audio, save_as_srt
|
||||
from translator import translate_srt
|
||||
from utils import validate_and_repair_srt
|
||||
from diarizer import diarize_audio, merge_diarization_with_transcript
|
||||
|
||||
def save_srt_with_speakers(segments, output_path):
|
||||
"""Helper to save SRT with speaker labels prepended to text."""
|
||||
def format_timestamp(seconds: float):
|
||||
whole_seconds = int(seconds)
|
||||
milliseconds = int((seconds - whole_seconds) * 1000)
|
||||
hours = whole_seconds // 3600
|
||||
minutes = (whole_seconds % 3600) // 60
|
||||
seconds = whole_seconds % 60
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
for i, segment in enumerate(segments, start=1):
|
||||
start = format_timestamp(segment["start"])
|
||||
end = format_timestamp(segment["end"])
|
||||
text = segment["text"].strip()
|
||||
speaker = segment.get("speaker", "")
|
||||
|
||||
# Prepend speaker if present and not "Unknown"
|
||||
if speaker and speaker != "Unknown":
|
||||
text = f"[{speaker}]: {text}"
|
||||
|
||||
f.write(f"{i}\n")
|
||||
f.write(f"{start} --> {end}\n")
|
||||
f.write(f"{text}\n\n")
|
||||
print(f"SRT saved to: {output_path}")
|
||||
|
||||
def process_file(file_path, args, source_lang=None):
|
||||
print(f"\n=== Processing: {file_path} ===")
|
||||
|
||||
# 1. Extract Audio
|
||||
audio_path = extract_audio(file_path)
|
||||
|
||||
# 2. Transcribe (Generate SRT)
|
||||
transcript_file = os.path.splitext(file_path)[0] + ".srt"
|
||||
transcript_exists = os.path.exists(transcript_file) and not args.force
|
||||
|
||||
# Variable to hold final SRT path for embedding
|
||||
final_srt_path = transcript_file
|
||||
|
||||
if transcript_exists:
|
||||
print(f"Transcript exists: {transcript_file}. Skipping transcription.")
|
||||
with open(transcript_file, "r", encoding="utf-8") as f:
|
||||
srt_content = f.read()
|
||||
else:
|
||||
# Transcribe
|
||||
result = transcribe_audio(audio_path, model_size=args.model, language=source_lang)
|
||||
segments = result["segments"]
|
||||
|
||||
# Optional: Diarization
|
||||
if args.diarize:
|
||||
hf_token = args.hf_token or os.getenv("HF_TOKEN")
|
||||
if hf_token:
|
||||
print("Running Speaker Diarization...")
|
||||
diar_segments = diarize_audio(audio_path, hf_token=hf_token)
|
||||
if diar_segments:
|
||||
segments = merge_diarization_with_transcript(segments, diar_segments)
|
||||
print("Diarization merged into transcript.")
|
||||
else:
|
||||
print("Warning: --diarize requested but HF_TOKEN not provided. Skipping.")
|
||||
|
||||
# Save SRT
|
||||
# Use simple save if no speakers, or custom if speakers
|
||||
if args.diarize:
|
||||
save_srt_with_speakers(segments, transcript_file)
|
||||
else:
|
||||
save_as_srt(result, transcript_file)
|
||||
|
||||
# Validation
|
||||
validate_and_repair_srt(transcript_file)
|
||||
|
||||
with open(transcript_file, "r", encoding="utf-8") as f:
|
||||
srt_content = f.read()
|
||||
|
||||
# 3. Translate (Generate Translated SRT)
|
||||
translated_file = os.path.splitext(file_path)[0] + f".{args.lang}.srt"
|
||||
|
||||
translation_success = False
|
||||
|
||||
if os.path.exists(translated_file) and not args.force:
|
||||
print(f"Translation exists: {translated_file}. Skipping translation.")
|
||||
final_srt_path = translated_file
|
||||
translation_success = True
|
||||
else:
|
||||
# Only translate if there is content
|
||||
if srt_content:
|
||||
translated_srt_content = translate_srt(srt_content, target_language=args.lang)
|
||||
if translated_srt_content:
|
||||
with open(translated_file, "w", encoding="utf-8") as f:
|
||||
f.write(translated_srt_content)
|
||||
print(f"Translation saved to: {translated_file}")
|
||||
validate_and_repair_srt(translated_file)
|
||||
final_srt_path = translated_file
|
||||
translation_success = True
|
||||
else:
|
||||
print("⚠️ TRANSLATION FAILED.")
|
||||
translation_success = False
|
||||
|
||||
# 4. Embed Subtitles
|
||||
# SAFETY: If translation was intended but failed, do NOT embed/delete to prevent
|
||||
# replacing the video with one containing only untranslated subtitles.
|
||||
should_embed = args.embed
|
||||
if args.embed and not translation_success:
|
||||
print("\n❌ SAFETY HALT: Translation failed. Skipping embedding and deletion to preserve original file.")
|
||||
should_embed = False
|
||||
|
||||
if should_embed:
|
||||
embed_subtitles(file_path, final_srt_path)
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from central .env_files directory
|
||||
# Path: .../personal_development/video_transcription/ai_transcriber/main.py
|
||||
# Target: .../personal_development/.env_files/.env.aitranscribe
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
env_path = os.path.abspath(os.path.join(script_dir, '../../.env_files/.env.aitranscribe'))
|
||||
|
||||
if os.path.exists(env_path):
|
||||
load_dotenv(env_path)
|
||||
print(f"Loaded configuration from: {env_path}")
|
||||
else:
|
||||
# Fallback: check local .env
|
||||
local_env = os.path.join(script_dir, '.env')
|
||||
if os.path.exists(local_env):
|
||||
load_dotenv(local_env)
|
||||
else:
|
||||
# Last resort: just try loading generic (cwd)
|
||||
load_dotenv()
|
||||
|
||||
from extractor import extract_audio, embed_subtitles
|
||||
from transcriber import transcribe_audio, save_as_srt
|
||||
from translator import translate_srt
|
||||
from utils import validate_and_repair_srt
|
||||
from diarizer import diarize_audio, merge_diarization_with_transcript
|
||||
import tracker
|
||||
from tracker import JobStatus
|
||||
|
||||
def save_srt_with_speakers(segments, output_path):
|
||||
"""Helper to save SRT with speaker labels prepended to text."""
|
||||
def format_timestamp(seconds: float):
|
||||
whole_seconds = int(seconds)
|
||||
milliseconds = int((seconds - whole_seconds) * 1000)
|
||||
hours = whole_seconds // 3600
|
||||
minutes = (whole_seconds % 3600) // 60
|
||||
seconds = whole_seconds % 60
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
for i, segment in enumerate(segments, start=1):
|
||||
start = format_timestamp(segment["start"])
|
||||
end = format_timestamp(segment["end"])
|
||||
text = segment["text"].strip()
|
||||
speaker = segment.get("speaker", "")
|
||||
|
||||
# Prepend speaker if present and not "Unknown"
|
||||
if speaker and speaker != "Unknown":
|
||||
text = f"[{speaker}]: {text}"
|
||||
|
||||
f.write(f"{i}\n")
|
||||
f.write(f"{start} --> {end}\n")
|
||||
f.write(f"{text}\n\n")
|
||||
print(f"SRT saved to: {output_path}")
|
||||
|
||||
def process_file(file_path, args, source_lang=None):
|
||||
tracker.logger.info(f"=== Processing: {file_path} ===")
|
||||
|
||||
# Initialize Job
|
||||
job = tracker.get_job(file_path)
|
||||
|
||||
if job.status == JobStatus.COMPLETED and not args.force:
|
||||
tracker.logger.info("Job already completed. Skipping.")
|
||||
return
|
||||
|
||||
tracker.update_job_status(file_path, JobStatus.PROCESSING)
|
||||
|
||||
try:
|
||||
# 1. Extract Audio
|
||||
tracker.update_step(file_path, "step_extract", "processing")
|
||||
audio_path = extract_audio(file_path)
|
||||
tracker.update_step(file_path, "step_extract", "done")
|
||||
|
||||
# 2. Transcribe (Generate SRT)
|
||||
tracker.update_step(file_path, "step_transcribe", "processing")
|
||||
transcript_file = os.path.splitext(file_path)[0] + ".srt"
|
||||
transcript_exists = os.path.exists(transcript_file) and not args.force
|
||||
|
||||
# Variable to hold final SRT path for embedding
|
||||
final_srt_path = transcript_file
|
||||
|
||||
if transcript_exists:
|
||||
tracker.logger.info(f"Transcript exists: {transcript_file}. Skipping transcription.")
|
||||
with open(transcript_file, "r", encoding="utf-8") as f:
|
||||
srt_content = f.read()
|
||||
else:
|
||||
# Transcribe
|
||||
result = transcribe_audio(audio_path, model_size=args.model, language=source_lang)
|
||||
segments = result["segments"]
|
||||
|
||||
# Optional: Diarization
|
||||
if args.diarize:
|
||||
hf_token = args.hf_token or os.getenv("HF_TOKEN")
|
||||
if hf_token:
|
||||
tracker.logger.info("Running Speaker Diarization...")
|
||||
diar_segments = diarize_audio(audio_path, hf_token=hf_token)
|
||||
if diar_segments:
|
||||
segments = merge_diarization_with_transcript(segments, diar_segments)
|
||||
tracker.logger.info("Diarization merged into transcript.")
|
||||
else:
|
||||
tracker.logger.warning("Warning: --diarize requested but HF_TOKEN not provided. Skipping.")
|
||||
|
||||
# Save SRT
|
||||
if args.diarize:
|
||||
save_srt_with_speakers(segments, transcript_file)
|
||||
else:
|
||||
save_as_srt(result, transcript_file)
|
||||
|
||||
# Validation
|
||||
validate_and_repair_srt(transcript_file)
|
||||
|
||||
with open(transcript_file, "r", encoding="utf-8") as f:
|
||||
srt_content = f.read()
|
||||
tracker.update_step(file_path, "step_transcribe", "done")
|
||||
|
||||
# 3. Translate (Generate Translated SRT)
|
||||
tracker.update_step(file_path, "step_translate", "processing")
|
||||
translated_file = os.path.splitext(file_path)[0] + f".{args.lang}.srt"
|
||||
|
||||
translation_success = False
|
||||
|
||||
if os.path.exists(translated_file) and not args.force:
|
||||
tracker.logger.info(f"Translation exists: {translated_file}. Skipping translation.")
|
||||
final_srt_path = translated_file
|
||||
translation_success = True
|
||||
else:
|
||||
# Only translate if there is content
|
||||
if srt_content:
|
||||
translated_srt_content = translate_srt(srt_content, target_language=args.lang)
|
||||
if translated_srt_content:
|
||||
with open(translated_file, "w", encoding="utf-8") as f:
|
||||
f.write(translated_srt_content)
|
||||
tracker.logger.info(f"Translation saved to: {translated_file}")
|
||||
validate_and_repair_srt(translated_file)
|
||||
final_srt_path = translated_file
|
||||
translation_success = True
|
||||
else:
|
||||
tracker.logger.error("TRANSLATION FAILED.")
|
||||
tracker.update_step(file_path, "step_translate", "failed")
|
||||
translation_success = False
|
||||
|
||||
if translation_success:
|
||||
tracker.update_step(file_path, "step_translate", "done")
|
||||
|
||||
# 4. Embed Subtitles
|
||||
tracker.update_step(file_path, "step_embed", "processing")
|
||||
should_embed = args.embed
|
||||
if args.embed and not translation_success:
|
||||
tracker.logger.warning("SAFETY HALT: Translation failed. Skipping embedding and deletion to preserve original file.")
|
||||
should_embed = False
|
||||
|
||||
if should_embed:
|
||||
embed_subtitles(file_path, final_srt_path)
|
||||
|
||||
# 5. Delete Source File (Optional & Risky)
|
||||
if args.delete_source:
|
||||
if args.embed:
|
||||
# Safety: Ensure the new subbed video exists before deleting the old one
|
||||
base, ext = os.path.splitext(file_path)
|
||||
expected_output = f"{base}.subbed{ext}"
|
||||
|
||||
if os.path.exists(expected_output):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
tracker.logger.info(f"SOURCE DELETED: Original file '{file_path}' has been removed.")
|
||||
except OSError as e:
|
||||
tracker.logger.error(f"Error: Could not delete source file: {e}")
|
||||
else:
|
||||
tracker.logger.error(f"SAFETY ABORT: Source file NOT deleted. Could not find expected output '{expected_output}'.")
|
||||
else:
|
||||
tracker.logger.warning("SAFETY ABORT: Source file NOT deleted. You must enable --embed to safely replace the video.")
|
||||
tracker.update_step(file_path, "step_embed", "done")
|
||||
|
||||
# 5. Cleanup Audio
|
||||
if args.cleanup:
|
||||
try:
|
||||
os.remove(audio_path)
|
||||
tracker.logger.info(f"Cleanup: Removed temporary audio file {audio_path}")
|
||||
except OSError as e:
|
||||
tracker.logger.warning(f"Warning: Could not remove audio file: {e}")
|
||||
|
||||
# Mark Complete
|
||||
if translation_success:
|
||||
tracker.update_job_status(file_path, JobStatus.COMPLETED)
|
||||
else:
|
||||
# If translation failed but we didn't crash, we technically finished the run but result is partial
|
||||
tracker.update_job_status(file_path, JobStatus.FAILED, error="Translation failed")
|
||||
|
||||
except Exception as e:
|
||||
tracker.logger.exception(f"Job Failed for {file_path}")
|
||||
tracker.update_job_status(file_path, JobStatus.FAILED, error=str(e))
|
||||
# Don't exit, allow other files to process
|
||||
return
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="AI Video Transcriber & Translator")
|
||||
parser.add_argument("input", nargs='?', help="Path to video file or directory")
|
||||
parser.add_argument("--model", default="auto", choices=["auto", "tiny", "base", "small", "medium", "large"], help="Whisper model size (default: auto)")
|
||||
parser.add_argument("--lang", default="English", help="Target language for translation (default: English)")
|
||||
parser.add_argument("--source-lang", help="Source language of the audio (e.g., 'fr', 'es'). If omitted, you will be prompted.")
|
||||
parser.add_argument("--force", action="store_true", help="Overwrite existing transcript/translation files")
|
||||
|
||||
# New Arguments
|
||||
parser.add_argument("--cleanup", action="store_true", help="Delete the temporary .wav file after processing")
|
||||
parser.add_argument("--embed", action="store_true", help="Embed the final subtitles into the video (Soft Subs)")
|
||||
parser.add_argument("--diarize", action="store_true", help="Enable speaker diarization (requires HF_TOKEN)")
|
||||
parser.add_argument("--hf-token", help="HuggingFace Token for pyannote.audio (or set HF_TOKEN env var)")
|
||||
parser.add_argument("--delete-source", action="store_true", help="Delete the original video file AFTER successful embedding")
|
||||
parser.add_argument("--retry-failed", action="store_true", help="Retry only jobs marked as FAILED in the database")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.getenv("GEMINI_API_KEY"):
|
||||
print("Warning: GEMINI_API_KEY environment variable not set. Translation step will fail.")
|
||||
|
||||
# Handling Retry Logic
|
||||
if args.retry_failed:
|
||||
print("Retrying failed jobs from database...")
|
||||
failed_files = tracker.get_failed_jobs()
|
||||
if not failed_files:
|
||||
print("No failed jobs found.")
|
||||
return
|
||||
|
||||
# We need args.source_lang logic here too if needed, but for retries we might assume context
|
||||
# For simplicity, we'll prompt if missing just like normal run
|
||||
|
||||
# Determine source language (Prompt if missing)
|
||||
source_lang = args.source_lang
|
||||
if not source_lang:
|
||||
print("\n--- Audio Configuration ---")
|
||||
user_input = input("Enter the source language of the video(s) (e.g., 'French', 'es').\nPress Enter to use Whisper's auto-detection: ").strip()
|
||||
if user_input:
|
||||
source_lang = user_input
|
||||
else:
|
||||
source_lang = None # Let Whisper auto-detect
|
||||
print("Selected: Auto-detect")
|
||||
|
||||
for file_path in failed_files:
|
||||
if os.path.exists(file_path):
|
||||
process_file(file_path, args, source_lang)
|
||||
else:
|
||||
print(f"Skipping missing file: {file_path}")
|
||||
return
|
||||
|
||||
# Normal Logic
|
||||
if not args.input:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
# Determine source language (Prompt if missing)
|
||||
source_lang = args.source_lang
|
||||
if not source_lang:
|
||||
print("\n--- Audio Configuration ---")
|
||||
user_input = input("Enter the source language of the video(s) (e.g., 'French', 'es').\nPress Enter to use Whisper's auto-detection: ").strip()
|
||||
if user_input:
|
||||
source_lang = user_input
|
||||
else:
|
||||
source_lang = None # Let Whisper auto-detect
|
||||
print("Selected: Auto-detect")
|
||||
|
||||
if os.path.isfile(args.input):
|
||||
process_file(args.input, args, source_lang)
|
||||
elif os.path.isdir(args.input):
|
||||
video_extensions = ('.mp4', '.mkv', '.mov', '.avi', '.webm', '.flv', '.wmv', '.m4v')
|
||||
found_files = False
|
||||
for root, dirs, files in os.walk(args.input):
|
||||
for file in files:
|
||||
if file.lower().endswith(video_extensions):
|
||||
found_files = True
|
||||
file_path = os.path.join(root, file)
|
||||
process_file(file_path, args, source_lang)
|
||||
if not found_files:
|
||||
print(f"No video files found in {args.input}")
|
||||
else:
|
||||
print(f"Error: Invalid input path '{args.input}'")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="AI Video Transcriber & Translator")
|
||||
parser.add_argument("input", help="Path to video file or directory")
|
||||
parser.add_argument("--model", default="auto", choices=["auto", "tiny", "base", "small", "medium", "large"], help="Whisper model size (default: auto)")
|
||||
parser.add_argument("--lang", default="English", help="Target language for translation (default: English)")
|
||||
parser.add_argument("--source-lang", help="Source language of the audio (e.g., 'fr', 'es'). If omitted, you will be prompted.")
|
||||
parser.add_argument("--force", action="store_true", help="Overwrite existing transcript/translation files")
|
||||
|
||||
# New Arguments
|
||||
parser.add_argument("--cleanup", action="store_true", help="Delete the temporary .wav file after processing")
|
||||
parser.add_argument("--embed", action="store_true", help="Embed the final subtitles into the video (Soft Subs)")
|
||||
parser.add_argument("--diarize", action="store_true", help="Enable speaker diarization (requires HF_TOKEN)")
|
||||
parser.add_argument("--hf-token", help="HuggingFace Token for pyannote.audio (or set HF_TOKEN env var)")
|
||||
parser.add_argument("--delete-source", action="store_true", help="Delete the original video file AFTER successful embedding")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.getenv("GEMINI_API_KEY"):
|
||||
print("Warning: GEMINI_API_KEY environment variable not set. Translation step will fail.")
|
||||
|
||||
# Determine source language (Prompt if missing)
|
||||
source_lang = args.source_lang
|
||||
if not source_lang:
|
||||
print("\n--- Audio Configuration ---")
|
||||
user_input = input("Enter the source language of the video(s) (e.g., 'French', 'es').\nPress Enter to use Whisper's auto-detection: ").strip()
|
||||
if user_input:
|
||||
source_lang = user_input
|
||||
else:
|
||||
source_lang = None # Let Whisper auto-detect
|
||||
print("Selected: Auto-detect")
|
||||
|
||||
if os.path.isfile(args.input):
|
||||
process_file(args.input, args, source_lang)
|
||||
elif os.path.isdir(args.input):
|
||||
video_extensions = ('.mp4', '.mkv', '.mov', '.avi', '.webm', '.flv', '.wmv', '.m4v')
|
||||
found_files = False
|
||||
for root, dirs, files in os.walk(args.input):
|
||||
for file in files:
|
||||
if file.lower().endswith(video_extensions):
|
||||
found_files = True
|
||||
file_path = os.path.join(root, file)
|
||||
process_file(file_path, args, source_lang)
|
||||
if not found_files:
|
||||
print(f"No video files found in {args.input}")
|
||||
else:
|
||||
print(f"Error: Invalid input path '{args.input}'")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,8 @@
|
||||
openai-whisper
|
||||
google-generativeai
|
||||
ffmpeg-python
|
||||
torch
|
||||
numpy
|
||||
tenacity
|
||||
pysubs2
|
||||
pyannote.audio
|
||||
@@ -0,0 +1,84 @@
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Enum, Text
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
import enum
|
||||
|
||||
# Setup Logging
|
||||
log_dir = "logs"
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_file = os.path.join(log_dir, f"transcriber_{datetime.now().strftime('%Y%m%d')}.log")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(log_file),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Database Setup
|
||||
Base = declarative_base()
|
||||
DB_FILE = "job_history.db"
|
||||
|
||||
class JobStatus(enum.Enum):
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
class Job(Base):
|
||||
__tablename__ = 'jobs'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
file_path = Column(String, unique=True, nullable=False)
|
||||
status = Column(Enum(JobStatus), default=JobStatus.PENDING)
|
||||
error_message = Column(Text, nullable=True)
|
||||
last_updated = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Track progress of individual steps
|
||||
step_extract = Column(String, default="pending") # pending, done, failed
|
||||
step_transcribe = Column(String, default="pending")
|
||||
step_translate = Column(String, default="pending")
|
||||
step_embed = Column(String, default="pending")
|
||||
|
||||
engine = create_engine(f'sqlite:///{DB_FILE}')
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
|
||||
def get_job(file_path):
|
||||
session = Session()
|
||||
job = session.query(Job).filter_by(file_path=file_path).first()
|
||||
if not job:
|
||||
job = Job(file_path=file_path)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
session.close()
|
||||
return job
|
||||
|
||||
def update_job_status(file_path, status, error=None):
|
||||
session = Session()
|
||||
job = session.query(Job).filter_by(file_path=file_path).first()
|
||||
if job:
|
||||
job.status = status
|
||||
if error:
|
||||
job.error_message = str(error)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
def update_step(file_path, step_name, status):
|
||||
session = Session()
|
||||
job = session.query(Job).filter_by(file_path=file_path).first()
|
||||
if job:
|
||||
setattr(job, step_name, status)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
def get_failed_jobs():
|
||||
session = Session()
|
||||
jobs = session.query(Job).filter_by(status=JobStatus.FAILED).all()
|
||||
session.close()
|
||||
return [j.file_path for j in jobs]
|
||||
@@ -0,0 +1,168 @@
|
||||
import whisper
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import torch
|
||||
|
||||
def check_gpu_health():
|
||||
"""
|
||||
Performs a robust check for GPU availability and prints detailed troubleshooting
|
||||
info if issues are detected, specific to Bazzite/VS Code environments.
|
||||
"""
|
||||
print("Checking GPU health...")
|
||||
|
||||
# 1. Check if the OS/Driver sees the GPU
|
||||
nvidia_smi_ok = False
|
||||
try:
|
||||
subprocess.run(["nvidia-smi"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
||||
nvidia_smi_ok = True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
nvidia_smi_ok = False
|
||||
|
||||
# 2. Check if PyTorch sees the GPU
|
||||
torch_cuda_ok = torch.cuda.is_available()
|
||||
|
||||
if torch_cuda_ok:
|
||||
print(f"✅ GPU is accessible: {torch.cuda.get_device_name(0)}")
|
||||
print(f" CUDA Version: {torch.version.cuda}")
|
||||
return True
|
||||
|
||||
# --- Troubleshooting Block ---
|
||||
print("\n⚠️ WARNING: GPU not detected by PyTorch. Falling back to CPU.")
|
||||
print(" Transcription will be significantly slower.\n")
|
||||
|
||||
print("--- Diagnostic Report ---")
|
||||
if nvidia_smi_ok:
|
||||
print("1. [OK] 'nvidia-smi' command works. The system driver is installed and visible.")
|
||||
print("2. [FAIL] PyTorch cannot see the GPU.")
|
||||
print(" -> Likely Cause: You might have installed the CPU-only version of PyTorch.")
|
||||
print(" -> Solution: Reinstall PyTorch with CUDA support:")
|
||||
print(" pip uninstall torch torchvision torchaudio")
|
||||
print(" pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118")
|
||||
else:
|
||||
print("1. [FAIL] 'nvidia-smi' command failed or not found.")
|
||||
print(" -> Likely Cause: Nvidia drivers are missing, or the container/sandbox cannot access the GPU.")
|
||||
|
||||
print("\n --- Bazzite / VS Code / Container Specific Checks ---")
|
||||
print(" a. If you are running inside a dev container (DevBox/Distrobox/Toolbox):")
|
||||
print(" Ensure the container was created with nvidia support.")
|
||||
print(" (Bazzite usually handles this for 'distrobox', but check your config).")
|
||||
print(" b. If you are using VS Code Flatpak:")
|
||||
print(" Flatpak might be restricting access. Check Flatseal permissions for VS Code.")
|
||||
print(" c. Driver Check:")
|
||||
print(" Run 'rpm -qa | grep nvidia' in your host terminal to verify drivers are installed.")
|
||||
|
||||
print("-------------------------\n")
|
||||
return False
|
||||
|
||||
def get_vram_gb():
|
||||
"""Returns the total VRAM in GB of the first CUDA device, or 0 if no CUDA."""
|
||||
if not torch.cuda.is_available():
|
||||
return 0
|
||||
try:
|
||||
# returns bytes
|
||||
total_mem = torch.cuda.get_device_properties(0).total_memory
|
||||
return total_mem / (1024 ** 3)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def get_optimal_model_size():
|
||||
"""
|
||||
Determines the best Whisper model based on available VRAM.
|
||||
Rough estimates for VRAM usage (fp16):
|
||||
- large: ~10 GB
|
||||
- medium: ~5 GB
|
||||
- small: ~2 GB
|
||||
- base: ~1 GB
|
||||
- tiny: ~1 GB
|
||||
"""
|
||||
vram = get_vram_gb()
|
||||
if vram == 0:
|
||||
return "base"
|
||||
|
||||
print(f"Detected GPU with {vram:.2f} GB VRAM.")
|
||||
|
||||
if vram >= 11:
|
||||
return "large"
|
||||
elif vram >= 6:
|
||||
return "medium"
|
||||
elif vram >= 3:
|
||||
return "small"
|
||||
else:
|
||||
return "base"
|
||||
|
||||
def format_timestamp(seconds: float):
|
||||
"""Converts seconds to SRT timestamp format (HH:MM:SS,mmm)."""
|
||||
whole_seconds = int(seconds)
|
||||
milliseconds = int((seconds - whole_seconds) * 1000)
|
||||
|
||||
hours = whole_seconds // 3600
|
||||
minutes = (whole_seconds % 3600) // 60
|
||||
seconds = whole_seconds % 60
|
||||
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"
|
||||
|
||||
def save_as_srt(result, output_path):
|
||||
"""Saves the Whisper transcription result as an SRT file."""
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
for i, segment in enumerate(result["segments"], start=1):
|
||||
start = format_timestamp(segment["start"])
|
||||
end = format_timestamp(segment["end"])
|
||||
text = segment["text"].strip()
|
||||
|
||||
f.write(f"{i}\n")
|
||||
f.write(f"{start} --> {end}\n")
|
||||
f.write(f"{text}\n\n")
|
||||
print(f"SRT saved to: {output_path}")
|
||||
|
||||
def transcribe_audio(audio_path, model_size="auto", language=None):
|
||||
"""
|
||||
Transcribes an audio file using OpenAI's Whisper model.
|
||||
|
||||
Args:
|
||||
audio_path (str): Path to the input audio file.
|
||||
model_size (str): Size of the Whisper model to use. If "auto", selects based on VRAM.
|
||||
language (str, optional): Language code (e.g., "en", "fr", "es"). If None, auto-detects.
|
||||
|
||||
Returns:
|
||||
dict: The full transcription result containing segments and text.
|
||||
"""
|
||||
if not os.path.exists(audio_path):
|
||||
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
||||
|
||||
# Run health check once
|
||||
check_gpu_health()
|
||||
|
||||
# Determine model size if auto
|
||||
if model_size == "auto":
|
||||
model_size = get_optimal_model_size()
|
||||
print(f"Auto-selected model: '{model_size}'")
|
||||
|
||||
print(f"Loading Whisper model ('{model_size}')...")
|
||||
|
||||
# Check for GPU availability
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
print(f"Using device: {device}")
|
||||
|
||||
try:
|
||||
model = whisper.load_model(model_size, device=device)
|
||||
except RuntimeError as e:
|
||||
if "out of memory" in str(e).lower():
|
||||
print("Error: GPU Out of Memory. Try using a smaller model size.")
|
||||
else:
|
||||
print(f"Error loading model: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error loading model: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Transcribing {audio_path}...")
|
||||
try:
|
||||
# fp16=False is needed for CPU, but we can let whisper handle defaults usually.
|
||||
# language=None allows auto-detection.
|
||||
result = model.transcribe(audio_path, language=language)
|
||||
print("Transcription complete.")
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"Error during transcription: {e}")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,127 @@
|
||||
import os
|
||||
import sys
|
||||
import google.generativeai as genai
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
||||
|
||||
# Define a retry decorator
|
||||
# Waits 2^x * 1 seconds between retries (1s, 2s, 4s, 8s, 16s, 32s...)
|
||||
# With max=60, it will cap at waiting 60s per try.
|
||||
# Stop after 15 attempts (approx 15 minutes of trying before giving up)
|
||||
retry_policy = retry(
|
||||
stop=stop_after_attempt(15),
|
||||
wait=wait_exponential(multiplier=1, min=2, max=60),
|
||||
retry=retry_if_exception_type(Exception),
|
||||
reraise=True
|
||||
)
|
||||
|
||||
@retry_policy
|
||||
def _generate_with_retry(model, prompt):
|
||||
"""Internal function to wrap the API call with retry logic."""
|
||||
try:
|
||||
return model.generate_content(prompt)
|
||||
except Exception as e:
|
||||
if "429" in str(e) or "Resource has been exhausted" in str(e):
|
||||
print(f" [Rate Limit Hit] Waiting for quota reset... ({e})")
|
||||
raise e
|
||||
|
||||
def get_best_available_model():
|
||||
"""
|
||||
Queries the API to find the best available model for text generation.
|
||||
Priority: gemini-1.5-flash > gemini-1.5-pro > gemini-pro > any 'generateContent' model
|
||||
"""
|
||||
try:
|
||||
available_models = []
|
||||
for m in genai.list_models():
|
||||
if 'generateContent' in m.supported_generation_methods:
|
||||
available_models.append(m.name)
|
||||
|
||||
# Priority list
|
||||
priorities = [
|
||||
"models/gemini-1.5-flash",
|
||||
"models/gemini-1.5-pro",
|
||||
"models/gemini-pro"
|
||||
]
|
||||
|
||||
# Check for priorities first
|
||||
for p in priorities:
|
||||
if p in available_models:
|
||||
return p
|
||||
|
||||
# Fallback: check for aliases without 'models/' prefix just in case
|
||||
for p in priorities:
|
||||
short_name = p.replace("models/", "")
|
||||
# Some libraries might return short names, or custom handling
|
||||
# But genai.list_models() usually returns 'models/name'
|
||||
pass
|
||||
|
||||
# If priority not found, pick the first available gemini model
|
||||
for m in available_models:
|
||||
if "gemini" in m:
|
||||
return m
|
||||
|
||||
if available_models:
|
||||
return available_models[0]
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not list models ({e}). Defaulting to 'gemini-pro'.")
|
||||
|
||||
return "gemini-pro"
|
||||
|
||||
def translate_srt(srt_content, target_language="English", api_key=None):
|
||||
"""
|
||||
Translates SRT subtitle content using the Gemini API, preserving timestamps.
|
||||
|
||||
Args:
|
||||
srt_content (str): The raw text content of the SRT file.
|
||||
target_language (str): The target language for translation.
|
||||
api_key (str): Google Gemini API key. If None, checks env var GEMINI_API_KEY.
|
||||
|
||||
Returns:
|
||||
str: The translated SRT content.
|
||||
"""
|
||||
if not srt_content:
|
||||
return ""
|
||||
|
||||
key = api_key or os.getenv("GEMINI_API_KEY")
|
||||
if not key:
|
||||
print("Error: GEMINI_API_KEY not found. Please set the environment variable or pass the key.")
|
||||
sys.exit(1)
|
||||
|
||||
genai.configure(api_key=key)
|
||||
|
||||
# Automatically select the best model
|
||||
model_name = get_best_available_model()
|
||||
print(f"Using Gemini Model: {model_name}")
|
||||
|
||||
model = genai.GenerativeModel(model_name)
|
||||
|
||||
prompt = (
|
||||
"You are a professional subtitle translator. Your task is to translate the following SRT subtitle file "
|
||||
f"into {target_language}.\n\n"
|
||||
"RULES:\n"
|
||||
"1. PRESERVE the SRT format exactly. Do not modify timestamps (e.g., 00:00:01,000 --> 00:00:04,000) or sequence numbers.\n"
|
||||
"2. Only translate the dialogue text.\n"
|
||||
"3. Maintain the original tone and context.\n"
|
||||
"4. Output ONLY the translated SRT content, no markdown code blocks or explanations.\n\n"
|
||||
"SRT Content:\n"
|
||||
f"{srt_content}"
|
||||
)
|
||||
|
||||
print(f"Translating subtitles to {target_language} (with retries)...")
|
||||
try:
|
||||
# Call the retried internal function
|
||||
response = _generate_with_retry(model, prompt)
|
||||
print("Translation complete.")
|
||||
|
||||
# Cleanup: sometimes models wrap output in ```srt ... ``` or ``` ... ```
|
||||
cleaned_text = response.text.strip()
|
||||
if cleaned_text.startswith("```"):
|
||||
# Remove first line (```srt or ```) and last line (```)
|
||||
lines = cleaned_text.split('\n')
|
||||
if len(lines) >= 2:
|
||||
cleaned_text = '\n'.join(lines[1:-1])
|
||||
|
||||
return cleaned_text
|
||||
except Exception as e:
|
||||
print(f"Error during translation after retries: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,28 @@
|
||||
import pysubs2
|
||||
import os
|
||||
|
||||
def validate_and_repair_srt(srt_path):
|
||||
"""
|
||||
Validates an SRT file and attempts to repair it using pysubs2.
|
||||
|
||||
Args:
|
||||
srt_path (str): Path to the SRT file.
|
||||
|
||||
Returns:
|
||||
bool: True if valid/repaired, False if critical error.
|
||||
"""
|
||||
if not os.path.exists(srt_path):
|
||||
return False
|
||||
|
||||
print(f"Validating SRT: {srt_path}...")
|
||||
try:
|
||||
# Load the subtitle file. pysubs2 parser is robust and handles many errors automatically.
|
||||
subs = pysubs2.load(srt_path)
|
||||
|
||||
# Save it back ensures consistent formatting and fixes minor syntax issues
|
||||
subs.save(srt_path)
|
||||
print("SRT validation passed (file re-saved with correct formatting).")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Warning: SRT validation failed: {e}")
|
||||
return False
|
||||
Reference in New Issue
Block a user