357 lines
13 KiB
Python
357 lines
13 KiB
Python
import os
|
|
import subprocess
|
|
import time
|
|
from tqdm import tqdm
|
|
from openai import OpenAI
|
|
import config
|
|
|
|
def format_timestamp(seconds):
|
|
"""Convert float seconds to SRT timestamp format: HH:MM:SS,mmm"""
|
|
hours = int(seconds // 3600)
|
|
minutes = int((seconds % 3600) // 60)
|
|
secs = int(seconds % 60)
|
|
milliseconds = int(round((seconds % 1) * 1000))
|
|
if milliseconds == 1000:
|
|
secs += 1
|
|
milliseconds = 0
|
|
return f"{hours:02d}:{minutes:02d}:{secs:02d},{milliseconds:03d}"
|
|
|
|
def extract_audio(video_path, audio_path):
|
|
"""Extract mono 16kHz audio from a video using FFmpeg"""
|
|
print(f"[Audio] Extracting audio from {os.path.basename(video_path)}...")
|
|
cmd = [
|
|
"ffmpeg", "-y",
|
|
"-i", video_path,
|
|
"-ar", "16000",
|
|
"-ac", "1",
|
|
"-c:a", "pcm_s16le",
|
|
audio_path
|
|
]
|
|
try:
|
|
subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
|
|
print("[Audio] Audio extraction complete.")
|
|
return True
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"[Audio Error] FFmpeg extraction failed: {e.stderr}")
|
|
return False
|
|
|
|
def transcribe_audio(audio_path, model_name=config.DEFAULT_WHISPER_MODEL):
|
|
"""Transcribe audio file using OpenAI's Whisper API and return segments with timestamps"""
|
|
if not config.OPENAI_API_KEY:
|
|
raise ValueError("OPENAI_API_KEY is not configured in .env file.")
|
|
|
|
print(f"[ASR] Sending audio to OpenAI Whisper API using model '{model_name}'...")
|
|
client = OpenAI(api_key=config.OPENAI_API_KEY)
|
|
|
|
start_time = time.time()
|
|
with open(audio_path, "rb") as audio_file:
|
|
transcript = client.audio.transcriptions.create(
|
|
model=model_name,
|
|
file=audio_file,
|
|
response_format="verbose_json"
|
|
)
|
|
duration = time.time() - start_time
|
|
print(f"[ASR] Transcription complete in {duration:.2f} seconds.")
|
|
|
|
# Extract segments
|
|
raw_segments = []
|
|
if hasattr(transcript, "segments"):
|
|
raw_segments = transcript.segments
|
|
elif isinstance(transcript, dict):
|
|
raw_segments = transcript.get("segments", [])
|
|
else:
|
|
raw_segments = getattr(transcript, "segments", [])
|
|
|
|
segments = []
|
|
for seg in raw_segments:
|
|
if isinstance(seg, dict):
|
|
segments.append({
|
|
"start": seg.get("start", 0.0),
|
|
"end": seg.get("end", 0.0),
|
|
"text": seg.get("text", "").strip()
|
|
})
|
|
else:
|
|
segments.append({
|
|
"start": getattr(seg, "start", 0.0),
|
|
"end": getattr(seg, "end", 0.0),
|
|
"text": getattr(seg, "text", "").strip()
|
|
})
|
|
|
|
print(f"[ASR] Transcribed {len(segments)} segments.")
|
|
return segments
|
|
|
|
class OpenAIAPITranslator:
|
|
def __init__(self, model=config.DEFAULT_OPENAI_MODEL, api_key=None):
|
|
key = api_key or config.OPENAI_API_KEY
|
|
if not key:
|
|
raise ValueError("OpenAI API key must be provided or set in environment.")
|
|
self.client = OpenAI(api_key=key)
|
|
self.model = model
|
|
|
|
def translate_text(self, text, source_lang, target_lang, context_history=None):
|
|
"""Translate a single block of text using OpenAI GPT model, with optional context history"""
|
|
source_name = config.SUPPORTED_LANGUAGES.get(source_lang, source_lang)
|
|
target_name = config.SUPPORTED_LANGUAGES.get(target_lang, target_lang)
|
|
|
|
system_prompt = (
|
|
f"You are an expert, professional subtitle translator.\n"
|
|
f"Your task is to translate the given text from {source_name} to {target_name}.\n\n"
|
|
f"Hard Rules:\n"
|
|
f"1. Preserve the meaning, tone, and slang style accurately.\n"
|
|
f"2. Output ONLY the translated text. Do not include any notes, explanations, introductory/outro remarks, quotes, or formatting.\n"
|
|
f"3. Make it natural and fitting for screen subtitles (short, punchy sentences where appropriate).\n"
|
|
f"4. If you cannot translate it (e.g. it's nonsensical), output the original text unchanged."
|
|
)
|
|
|
|
user_content = ""
|
|
if context_history:
|
|
user_content += "--- CONTEXT ---\n"
|
|
for prev_src, prev_trans in context_history:
|
|
user_content += f"Previous original: \"{prev_src}\"\n"
|
|
user_content += f"Previous translation: \"{prev_trans}\"\n\n"
|
|
user_content += "--- CURRENT SEGMENT TO TRANSLATE ---\n"
|
|
|
|
user_content += f"Original text: \"{text}\"\nTranslation:"
|
|
|
|
messages = [
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": user_content}
|
|
]
|
|
|
|
try:
|
|
response = self.client.chat.completions.create(
|
|
model=self.model,
|
|
messages=messages,
|
|
temperature=0.3,
|
|
max_tokens=256
|
|
)
|
|
translated = response.choices[0].message.content.strip()
|
|
# Remove surrounding quotes if model added them
|
|
if translated.startswith('"') and translated.endswith('"'):
|
|
translated = translated[1:-1]
|
|
return translated
|
|
except Exception as e:
|
|
print(f"\n[OpenAI Error] Translation failed: {e}")
|
|
return text
|
|
|
|
def translate_segments(segments, source_lang, target_lang, model=config.DEFAULT_OPENAI_MODEL):
|
|
"""Translate Whisper segments list using OpenAIAPITranslator with a rolling context window"""
|
|
print(f"[Translation] Translating segments using OpenAI model '{model}' from '{source_lang}' to '{target_lang}'...")
|
|
translator = OpenAIAPITranslator(model=model)
|
|
|
|
translated_segments = []
|
|
context_window = [] # Keep a rolling window of (original, translation) pairs (max 2)
|
|
|
|
for segment in tqdm(segments, desc="Translating", unit="segment"):
|
|
text = segment.get("text", "").strip()
|
|
if not text:
|
|
translated_segments.append({
|
|
"start": segment["start"],
|
|
"end": segment["end"],
|
|
"text": ""
|
|
})
|
|
continue
|
|
|
|
translated_text = translator.translate_text(
|
|
text=text,
|
|
source_lang=source_lang,
|
|
target_lang=target_lang,
|
|
context_history=context_window
|
|
)
|
|
|
|
translated_segments.append({
|
|
"start": segment["start"],
|
|
"end": segment["end"],
|
|
"text": translated_text
|
|
})
|
|
|
|
# Update rolling context
|
|
context_window.append((text, translated_text))
|
|
if len(context_window) > 2:
|
|
context_window.pop(0)
|
|
|
|
print(f"[Translation] Completed translation of {len(translated_segments)} segments.")
|
|
return translated_segments
|
|
|
|
def split_long_segments(segments, max_chars=40):
|
|
"""Split segments that exceed max_chars into smaller, proportionally-timed segments"""
|
|
if not max_chars or max_chars <= 0:
|
|
return segments
|
|
|
|
refined = []
|
|
for seg in segments:
|
|
text = seg["text"].strip()
|
|
start = seg["start"]
|
|
end = seg["end"]
|
|
duration = end - start
|
|
|
|
if len(text) <= max_chars or duration <= 0:
|
|
refined.append(seg)
|
|
continue
|
|
|
|
words = text.split()
|
|
chunks = []
|
|
current_chunk = []
|
|
current_len = 0
|
|
|
|
for word in words:
|
|
word_len = len(word)
|
|
added_len = word_len + (1 if current_len > 0 else 0)
|
|
|
|
if current_len + added_len > max_chars and current_chunk:
|
|
chunks.append(" ".join(current_chunk))
|
|
current_chunk = [word]
|
|
current_len = word_len
|
|
else:
|
|
current_chunk.append(word)
|
|
current_len += added_len
|
|
|
|
if current_chunk:
|
|
chunks.append(" ".join(current_chunk))
|
|
|
|
# Distribute timing proportionally
|
|
total_chars = sum(len(c) for c in chunks)
|
|
if total_chars == 0:
|
|
refined.append(seg)
|
|
continue
|
|
|
|
current_time = start
|
|
for chunk in chunks:
|
|
chunk_len = len(chunk)
|
|
chunk_dur = duration * (chunk_len / total_chars)
|
|
refined.append({
|
|
"start": current_time,
|
|
"end": current_time + chunk_dur,
|
|
"text": chunk
|
|
})
|
|
current_time += chunk_dur
|
|
|
|
print(f"[Cadence] Split {len(segments)} segments into {len(refined)} segments (max_chars={max_chars}).")
|
|
return refined
|
|
|
|
def write_srt(segments, srt_path):
|
|
"""Write subtitle segments to an SRT file"""
|
|
print(f"[Subtitles] Writing subtitles to {srt_path}...")
|
|
with open(srt_path, "w", encoding="utf-8") as f:
|
|
for idx, segment in enumerate(segments, 1):
|
|
start_str = format_timestamp(segment["start"])
|
|
end_str = format_timestamp(segment["end"])
|
|
text = segment["text"].replace("\n", " ").strip()
|
|
|
|
f.write(f"{idx}\n")
|
|
f.write(f"{start_str} --> {end_str}\n")
|
|
f.write(f"{text}\n\n")
|
|
print("[Subtitles] SRT file written.")
|
|
|
|
def check_subtitles_filter_support():
|
|
"""Check if the subtitles filter is available in FFmpeg"""
|
|
try:
|
|
result = subprocess.run(
|
|
["ffmpeg", "-filters"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True
|
|
)
|
|
return " subtitles " in result.stdout
|
|
except Exception:
|
|
return False
|
|
|
|
def embed_subtitles_soft(video_path, srt_path, output_path):
|
|
"""Embed subtitles as a soft track in the video using mov_text codec (requires no filter/libass)"""
|
|
print(f"[Video] Embedding subtitles as soft track into video...")
|
|
|
|
video_dir = os.path.dirname(os.path.abspath(video_path))
|
|
srt_name = os.path.basename(srt_path)
|
|
video_name = os.path.basename(video_path)
|
|
output_name = os.path.basename(output_path)
|
|
|
|
cmd = [
|
|
"ffmpeg", "-y",
|
|
"-i", video_name,
|
|
"-i", srt_name,
|
|
"-c:v", "copy",
|
|
"-c:a", "copy",
|
|
"-c:s", "mov_text",
|
|
output_name
|
|
]
|
|
|
|
try:
|
|
subprocess.run(
|
|
cmd,
|
|
cwd=video_dir,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
check=True
|
|
)
|
|
print(f"[Video] Subtitles successfully soft-embedded into {output_path}")
|
|
return True
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"[Video Error] Soft-embedding subtitles failed: {e.stderr}")
|
|
return False
|
|
|
|
def burn_subtitles(video_path, srt_path, output_path, style_preset="box", custom_style=None):
|
|
"""Adds subtitles to the video, either burning them (if supported) or softcoding them (as fallback)"""
|
|
if not check_subtitles_filter_support():
|
|
print("\n" + "!"*60)
|
|
print("[Warning] Your current FFmpeg build does not support hardcoding (burning) subtitles.")
|
|
print(" It is missing the 'subtitles' video filter (usually due to a missing libass library).")
|
|
print(" We are falling back to soft-coding (embedding a subtitle track) instead.")
|
|
print(" To fix this and enable hardcoded/burned captions, run:")
|
|
print(" brew reinstall ffmpeg")
|
|
print("!"*60 + "\n")
|
|
return embed_subtitles_soft(video_path, srt_path, output_path)
|
|
|
|
print(f"[Video] Burning subtitles from {os.path.basename(srt_path)} into video...")
|
|
|
|
video_dir = os.path.dirname(os.path.abspath(video_path))
|
|
srt_name = os.path.basename(srt_path)
|
|
video_name = os.path.basename(video_path)
|
|
output_name = os.path.basename(output_path)
|
|
|
|
# Resolve style string
|
|
style_str = config.SUBTITLE_PRESETS.get(style_preset, config.SUBTITLE_PRESETS["box"])
|
|
if custom_style:
|
|
style_str = custom_style
|
|
|
|
print(f"[Video] Using style settings: {style_str}")
|
|
|
|
cmd = [
|
|
"ffmpeg", "-y",
|
|
"-i", video_name,
|
|
"-vf", f"subtitles={srt_name}:force_style='{style_str}'",
|
|
"-c:a", "copy", # Copy audio stream without re-encoding
|
|
output_name
|
|
]
|
|
|
|
try:
|
|
print(f"[Video] Launching FFmpeg subtitle filter in directory: {video_dir}")
|
|
subprocess.run(
|
|
cmd,
|
|
cwd=video_dir,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
check=True
|
|
)
|
|
print(f"[Video] Subtitles successfully burned into {output_path}")
|
|
return True
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"[Video Error] FFmpeg subtitle burning failed: {e.stderr}")
|
|
print("[Video] Attempting absolute path fallback...")
|
|
escaped_srt = os.path.abspath(srt_path).replace(":", "\\:").replace("'", "'\\''")
|
|
fallback_cmd = [
|
|
"ffmpeg", "-y",
|
|
"-i", video_path,
|
|
"-vf", f"subtitles='{escaped_srt}':force_style='{style_str}'",
|
|
"-c:a", "copy",
|
|
output_path
|
|
]
|
|
try:
|
|
subprocess.run(fallback_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
|
|
print(f"[Video] Subtitles successfully burned into {output_path} (via absolute path)")
|
|
return True
|
|
except subprocess.CalledProcessError as err:
|
|
print(f"[Video Error] Fallback FFmpeg failed: {err.stderr}")
|
|
return False
|