diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca5f499 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env_files/ diff --git a/video_transcription/ai_transcriber/README.md b/video_transcription/ai_transcriber/README.md new file mode 100644 index 0000000..b9fac30 --- /dev/null +++ b/video_transcription/ai_transcriber/README.md @@ -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 [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 +``` diff --git a/video_transcription/ai_transcriber/__init__.py b/video_transcription/ai_transcriber/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/video_transcription/ai_transcriber/__pycache__/__init__.cpython-313.pyc b/video_transcription/ai_transcriber/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..a5a3b71 Binary files /dev/null and b/video_transcription/ai_transcriber/__pycache__/__init__.cpython-313.pyc differ diff --git a/video_transcription/ai_transcriber/__pycache__/diarizer.cpython-313.pyc b/video_transcription/ai_transcriber/__pycache__/diarizer.cpython-313.pyc new file mode 100644 index 0000000..e6bb14d Binary files /dev/null and b/video_transcription/ai_transcriber/__pycache__/diarizer.cpython-313.pyc differ diff --git a/video_transcription/ai_transcriber/__pycache__/extractor.cpython-313.pyc b/video_transcription/ai_transcriber/__pycache__/extractor.cpython-313.pyc new file mode 100644 index 0000000..cdcfd4b Binary files /dev/null and b/video_transcription/ai_transcriber/__pycache__/extractor.cpython-313.pyc differ diff --git a/video_transcription/ai_transcriber/__pycache__/transcriber.cpython-313.pyc b/video_transcription/ai_transcriber/__pycache__/transcriber.cpython-313.pyc new file mode 100644 index 0000000..9ac7e8d Binary files /dev/null and b/video_transcription/ai_transcriber/__pycache__/transcriber.cpython-313.pyc differ diff --git a/video_transcription/ai_transcriber/__pycache__/translator.cpython-313.pyc b/video_transcription/ai_transcriber/__pycache__/translator.cpython-313.pyc new file mode 100644 index 0000000..fbb4dc1 Binary files /dev/null and b/video_transcription/ai_transcriber/__pycache__/translator.cpython-313.pyc differ diff --git a/video_transcription/ai_transcriber/__pycache__/utils.cpython-313.pyc b/video_transcription/ai_transcriber/__pycache__/utils.cpython-313.pyc new file mode 100644 index 0000000..c82c193 Binary files /dev/null and b/video_transcription/ai_transcriber/__pycache__/utils.cpython-313.pyc differ diff --git a/video_transcription/ai_transcriber/diarizer.py b/video_transcription/ai_transcriber/diarizer.py new file mode 100644 index 0000000..934324d --- /dev/null +++ b/video_transcription/ai_transcriber/diarizer.py @@ -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 diff --git a/video_transcription/ai_transcriber/extractor.py b/video_transcription/ai_transcriber/extractor.py new file mode 100644 index 0000000..986e38b --- /dev/null +++ b/video_transcription/ai_transcriber/extractor.py @@ -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}") diff --git a/video_transcription/ai_transcriber/main.py b/video_transcription/ai_transcriber/main.py new file mode 100644 index 0000000..7eb9ff8 --- /dev/null +++ b/video_transcription/ai_transcriber/main.py @@ -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() diff --git a/video_transcription/ai_transcriber/requirements.txt b/video_transcription/ai_transcriber/requirements.txt new file mode 100644 index 0000000..a9d3c11 --- /dev/null +++ b/video_transcription/ai_transcriber/requirements.txt @@ -0,0 +1,8 @@ +openai-whisper +google-generativeai +ffmpeg-python +torch +numpy +tenacity +pysubs2 +pyannote.audio diff --git a/video_transcription/ai_transcriber/tracker.py b/video_transcription/ai_transcriber/tracker.py new file mode 100644 index 0000000..234e0e1 --- /dev/null +++ b/video_transcription/ai_transcriber/tracker.py @@ -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] diff --git a/video_transcription/ai_transcriber/transcriber.py b/video_transcription/ai_transcriber/transcriber.py new file mode 100644 index 0000000..f202022 --- /dev/null +++ b/video_transcription/ai_transcriber/transcriber.py @@ -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) diff --git a/video_transcription/ai_transcriber/translator.py b/video_transcription/ai_transcriber/translator.py new file mode 100644 index 0000000..021c32f --- /dev/null +++ b/video_transcription/ai_transcriber/translator.py @@ -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 diff --git a/video_transcription/ai_transcriber/utils.py b/video_transcription/ai_transcriber/utils.py new file mode 100644 index 0000000..8c452bf --- /dev/null +++ b/video_transcription/ai_transcriber/utils.py @@ -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 diff --git a/video_transcription/extract_audio.sh b/video_transcription/extract_audio.sh new file mode 100755 index 0000000..8067c31 --- /dev/null +++ b/video_transcription/extract_audio.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +# Function to handle single file conversion +convert_file() { + local INPUT_FILE="$1" + + # Check if file exists + if [ ! -f "$INPUT_FILE" ]; then + echo "Warning: File '$INPUT_FILE' not found. Skipping." + return + fi + + # Get path and filename info + local DIRNAME=$(dirname -- "$INPUT_FILE") + local FILENAME=$(basename -- "$INPUT_FILE") + local FILENAME_NO_EXT="${FILENAME%.*}" + + # Define output filename in the same directory + local OUTPUT_FILE="${DIRNAME}/${FILENAME_NO_EXT}.wav" + + # Check if we are trying to convert a wav to wav (avoid redundant work or loops) + if [[ "$INPUT_FILE" == *.wav ]]; then + echo "Skipping .wav file: $INPUT_FILE" + return + fi + + echo "Processing '$INPUT_FILE' -> '$OUTPUT_FILE'..." + + # Run ffmpeg command + # -y overwrites output files without asking + # -v error -stats reduces output verbosity but keeps progress/errors + ffmpeg -i "$INPUT_FILE" -ar 16000 -ac 1 -c:a pcm_s16le -y "$OUTPUT_FILE" < /dev/null -v error -stats + + if [ $? -eq 0 ]; then + echo -e "\nSuccess: '$OUTPUT_FILE' created." + else + echo -e "\nError converting '$INPUT_FILE'." + fi + echo "----------------------------------------" +} + +# Check if at least one input is provided +if [ -z "$1" ]; then + echo "Usage: $0 [file_or_folder2 ...]" + exit 1 +fi + +# Loop through all provided arguments +for ARG in "$@"; do + # Check for SMB/Network URIs which standard shell tools don't support directly + if [[ "$ARG" == smb://* ]]; then + echo "Error: Network URI '$ARG' detected." + echo "This script works on filesystem paths. Please mount the network share first." + echo " - Linux (GVFS): Check /run/user/\$UID/gvfs/" + echo " - macOS: Check /Volumes/" + echo " - Windows (WSL): Mount the drive to a letter or /mnt/" + continue + fi + + if [ -d "$ARG" ]; then + # It's a directory: find files recursively + echo "Scanning directory '$ARG' for video files..." + + # Find common video files (case insensitive) + # Using -print0 and while read loop handles filenames with spaces correctly + find "$ARG" -type f \( \ + -iname "*.mp4" -o \ + -iname "*.mkv" -o \ + -iname "*.mov" -o \ + -iname "*.avi" -o \ + -iname "*.webm" -o \ + -iname "*.flv" -o \ + -iname "*.wmv" -o \ + -iname "*.m4v" -o \ + -iname "*.mpg" -o \ + -iname "*.mpeg" -o \ + -iname "*.3gp" -o \ + -iname "*.ts" \ + \) -print0 | while IFS= read -r -d '' FOUND_FILE; do + convert_file "$FOUND_FILE" + done + + elif [ -f "$ARG" ]; then + # It's a single file + convert_file "$ARG" + else + echo "Warning: '$ARG' is not a valid file or directory." + fi +done diff --git a/video_transcription/recover_and_fix.py b/video_transcription/recover_and_fix.py new file mode 100755 index 0000000..5a3e2cd --- /dev/null +++ b/video_transcription/recover_and_fix.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +import os +import sys +import argparse +import subprocess +from dotenv import load_dotenv + +# Load config +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) +else: + load_dotenv() + +# Add ai_transcriber to path so we can import modules +sys.path.append(os.path.join(script_dir, 'ai_transcriber')) + +from ai_transcriber.translator import translate_srt +from ai_transcriber.utils import validate_and_repair_srt + +def re_embed_subtitles(video_path, srt_path, output_path=None): + """ + Re-embeds subtitles into an EXISTING video file, replacing the old tracks. + Similar to embed_subtitles but we output to a temp file then overwrite. + """ + if not os.path.exists(video_path) or not os.path.exists(srt_path): + print("Error: Video or SRT file not found.") + return False + + temp_output = video_path + ".temp.mp4" + + print(f"Re-embedding subtitles into: {video_path}...") + + # We map 0:v and 0:a from source, but ignore existing subs (0:s?) + # Actually, simpler is to just map 0:v, 0:a and 1:0 (new sub) + # and tell ffmpeg to make the new one default. + # If the old file has subs, they might be kept unless we use -map -0:s (negative mapping) + + sub_codec = "mov_text" if video_path.lower().endswith(".mp4") else "srt" + + command = [ + "ffmpeg", + "-i", video_path, + "-i", srt_path, + "-map", "0:v", + "-map", "0:a", + "-map", "1:0", # The new subtitle + "-c", "copy", # Copy AV + "-c:s", sub_codec, # Encode Sub + "-disposition:s:0", "default", + "-metadata:s:s:0", "language=eng", + "-metadata:s:s:0", "title=English (AI Translated)", + "-y", + "-v", "error", + temp_output + ] + + try: + subprocess.run(command, check=True) + # Move temp over original + os.replace(temp_output, video_path) + print(f"✅ Fixed: {video_path}") + return True + except subprocess.CalledProcessError as e: + print(f"Error re-embedding: {e}") + if os.path.exists(temp_output): + os.remove(temp_output) + return False + +def process_recovery(folder_path, target_lang="English"): + print(f"Scanning {folder_path} for incomplete translations...") + + count_fixed = 0 + count_skipped = 0 + + video_extensions = ('.mp4', '.mkv', '.mov', '.avi') + + for root, dirs, files in os.walk(folder_path): + for file in files: + # We are looking for the SOURCE SRT files primarily + if file.endswith(".srt") and not file.endswith(f".{target_lang}.srt"): + source_srt_path = os.path.join(root, file) + base_name = os.path.splitext(file)[0] # e.g. "movie" or "movie.subbed" + + # Check if this is a "subbed" file's srt? + # Usually source srt is "movie.srt". + # Video might be "movie.subbed.mp4" (if original was deleted) + # OR "movie.mp4" (if not deleted) + + # Check if Translation exists + expected_translation = os.path.join(root, f"{base_name}.{target_lang}.srt") + + if os.path.exists(expected_translation): + # It's already translated, nothing to do unless we force re-embed + continue + + print(f"\nFound untranslated transcript: {file}") + + # Try to translate + with open(source_srt_path, "r", encoding="utf-8") as f: + content = f.read() + + new_srt_content = translate_srt(content, target_language=target_lang) + + if not new_srt_content: + print("❌ Translation failed (API Limit?). Skipping.") + continue + + # Save translation + with open(expected_translation, "w", encoding="utf-8") as f: + f.write(new_srt_content) + validate_and_repair_srt(expected_translation) + + # Now, find the video file to update + # Case 1: Original name + video_candidates = [ + os.path.join(root, base_name + ".mp4"), + os.path.join(root, base_name + ".mkv"), + # Case 2: .subbed name (if original deleted) + os.path.join(root, base_name + ".subbed.mp4"), + ] + + found_video = None + for v in video_candidates: + if os.path.exists(v): + found_video = v + break + + if found_video: + print(f"Found video to fix: {found_video}") + if re_embed_subtitles(found_video, expected_translation): + count_fixed += 1 + else: + print("Warning: Could not find a corresponding video file to embed into.") + + print(f"\nRecovery Complete. Fixed {count_fixed} files.") + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: ./recover_and_fix.py [target_lang]") + sys.exit(1) + + folder = sys.argv[1] + lang = sys.argv[2] if len(sys.argv) > 2 else "English" + + process_recovery(folder, lang) diff --git a/video_transcription/run_wizard.py b/video_transcription/run_wizard.py new file mode 100755 index 0000000..08c8d8c --- /dev/null +++ b/video_transcription/run_wizard.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +import os +import sys +import subprocess +import shutil +from pathlib import Path + +# Try to load the .env file so the wizard knows what's already configured +try: + from dotenv import load_dotenv + # Path logic matching main.py + script_dir = os.path.dirname(os.path.abspath(__file__)) + # Expected: .../video_transcription/../.env_files -> .../personal_development/.env_files + env_path = os.path.abspath(os.path.join(script_dir, '../.env_files/.env.aitranscribe')) + if os.path.exists(env_path): + load_dotenv(env_path) +except ImportError: + pass + +def clear_screen(): + os.system('cls' if os.name == 'nt' else 'clear') + +def get_input(prompt, default=None): + """Helper to get input with a default value.""" + if default: + user_input = input(f"{prompt} [{default}]: ").strip() + return user_input if user_input else default + else: + return input(f"{prompt}: ").strip() + +def get_yes_no(prompt, default="y"): + """Helper to get boolean input.""" + display_default = "Y/n" if default.lower() in ["y", "yes"] else "y/N" + choice = get_input(f"{prompt} ({display_default})", default).lower() + return choice in ["y", "yes", "true", "1"] + +def print_header(): + print("==========================================") + print(" AI Video Transcriber & Translator Wizard") + print("==========================================") + print("") + +def main(): + clear_screen() + print_header() + + # 1. Input File/Folder + while True: + input_path = get_input("Enter the path to the video file or folder") + + # Clean up input: + # 1. Remove surrounding quotes (common when pasting paths) + input_path = input_path.strip('"\'') + # 2. Handle escaped spaces (e.g., "My\ Folder" -> "My Folder") + input_path = input_path.replace(r'\ ', ' ') + + # Expand user (~) and resolve absolute path + input_path = os.path.abspath(os.path.expanduser(input_path)) + + if os.path.exists(input_path): + break + print(f"Error: Path '{input_path}' does not exist. Please try again.\n") + + print(f"Selected: {input_path}\n") + + # 2. Languages + source_lang = get_input("Source Language (e.g., French, es)", default="auto") + target_lang = get_input("Target Language for translation", default="English") + print("") + + # 3. Model Size + print("Model Size Options: tiny, base, small, medium, large, auto") + model_size = get_input("Whisper Model Size", default="auto") + print("") + + # 4. Features + do_cleanup = get_yes_no("Cleanup temporary audio files after processing?", default="y") + do_embed = get_yes_no("Embed subtitles into the video file (Soft Subs)?", default="y") + do_diarize = get_yes_no("Enable Speaker Diarization (Identify speakers)?", default="n") + + do_delete_source = False + if do_embed: + print("\n⚠️ WARNING: Using this next option will PERMANENTLY DELETE the original video files.") + print(" It will only run if the new subtitled video is successfully created.") + do_delete_source = get_yes_no("Delete original source files after embedding?", default="n") + + hf_token = None + if do_diarize: + if not os.getenv("HF_TOKEN"): + print("\nSpeaker Diarization requires a HuggingFace Token.") + hf_token = get_input("Enter your HuggingFace Token (hidden)", default="") + # In a real app we might use getpass, but standard input is fine for this wizard level + else: + print("Using HF_TOKEN from environment.") + + # 5. Build Command + # script is in ai_transcriber/main.py relative to this script + script_dir = os.path.dirname(os.path.abspath(__file__)) + main_script = os.path.join(script_dir, "ai_transcriber", "main.py") + + cmd = [sys.executable, main_script, input_path] + + cmd.extend(["--lang", target_lang]) + cmd.extend(["--model", model_size]) + + if source_lang != "auto": + cmd.extend(["--source-lang", source_lang]) + + if do_cleanup: + cmd.append("--cleanup") + + if do_embed: + cmd.append("--embed") + + if do_delete_source: + cmd.append("--delete-source") + + if do_diarize: + cmd.append("--diarize") + if hf_token: + cmd.extend(["--hf-token", hf_token]) + + # 6. Confirmation and Execution + clear_screen() + print_header() + print("Configuration Complete!") + print("-" * 30) + print(f"Input: {input_path}") + print(f"Source Lang: {source_lang}") + print(f"Target Lang: {target_lang}") + print(f"Model: {model_size}") + print(f"Cleanup: {do_cleanup}") + print(f"Embed Subs: {do_embed}") + print(f"Delete Src: {do_delete_source}") + print(f"Diarization: {do_diarize}") + print("-" * 30) + + if not get_yes_no("Run this job now?", default="y"): + print("Aborted.") + sys.exit(0) + + print("\nStarting Job...\n") + + try: + # Pass environment variables including HF_TOKEN if set + env = os.environ.copy() + if hf_token: + env["HF_TOKEN"] = hf_token + + subprocess.run(cmd, check=True, env=env) + print("\n✅ Job Complete!") + except subprocess.CalledProcessError as e: + print(f"\n❌ Job Failed with error code {e.returncode}") + except KeyboardInterrupt: + print("\nJob interrupted by user.") + +if __name__ == "__main__": + main()