469 lines
23 KiB
Python
469 lines
23 KiB
Python
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()
|