feat(subtitle-translator): add contextual chunk translation

This commit is contained in:
2026-06-25 08:46:51 +03:00
parent 7735c3d426
commit c584265235
+232 -110
View File
@@ -4,6 +4,7 @@
# /// # ///
import argparse import argparse
from concurrent.futures import ThreadPoolExecutor
import json import json
import logging import logging
import os import os
@@ -13,15 +14,21 @@ from typing import Iterator
import srt import srt
import httpx import httpx
logger = logging.getLogger(__name__)
api_key = os.getenv("OPENROUTER_API_KEY") api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key: if not api_key:
raise RuntimeError("OPENROUTER_API_KEY environment variable not set") raise RuntimeError("OPENROUTER_API_KEY environment variable not set")
client = httpx.Client( client = httpx.Client(
headers={"Authorization": f"Bearer {api_key}"}, headers={"Authorization": f"Bearer {api_key}"},
base_url="https://openrouter.ai/api/v1", base_url="https://openrouter.ai/api/v1",
timeout=120, timeout=30,
) )
pool = ThreadPoolExecutor(max_workers=5)
# retry is a decorator to retry the function on failure # retry is a decorator to retry the function on failure
def retry(max_retries: int = 3, delay: float = 1.0): def retry(max_retries: int = 3, delay: float = 1.0):
@@ -31,7 +38,7 @@ def retry(max_retries: int = 3, delay: float = 1.0):
try: try:
return func(*args, **kwargs) return func(*args, **kwargs)
except Exception as e: except Exception as e:
logging.debug(f"Error: {e}, retrying {attempt + 1}/{max_retries}...") logger.warning(f"Error: {e}, retrying {attempt + 1}/{max_retries}...")
if attempt < max_retries - 1: if attempt < max_retries - 1:
time.sleep(delay) time.sleep(delay)
raise RuntimeError("Max retries exceeded") raise RuntimeError("Max retries exceeded")
@@ -41,92 +48,82 @@ def retry(max_retries: int = 3, delay: float = 1.0):
return decorator return decorator
def combine_entries(entries: list[srt.Subtitle], max_entries: float = 2) -> list[srt.Subtitle]: def combine_entries(entries: list[srt.Subtitle], max_entries: int = 2) -> list[srt.Subtitle]:
""" """
Merge adjacent subtitles in groups of 'factor' if: Merge adjacent subtitles in groups of up to 'max_entries' if:
- They are no more than 1s apart (start of next - end of prev <= 1s) - The gap between the end of one and the start of the next is <= 1 second
- The total word count is fewer than 8 - The total word count of the group is fewer than 8
""" """
if not entries: if not entries:
return [] return []
merged = [] merged_subtitles = []
current_index = 1
i = 0 i = 0
idx = 1 total_subs = len(entries)
n = len(entries)
while i < n: while i < total_subs:
group = [entries[i]] group = [entries[i]]
j = 1 group_size = 1
while j < int(max_entries) and (i + j) < n:
prev = group[-1] while group_size < max_entries and (i + group_size) < total_subs:
curr = entries[i + j] last_sub = group[-1]
gap = (curr.start - prev.end).total_seconds() next_sub = entries[i + group_size]
total_words = sum(len(s.content.split()) for s in group) + len(curr.content.split()) time_gap = (next_sub.start - last_sub.end).total_seconds()
if gap <= 1 and total_words < 8: group_word_count = sum(len(sub.content.split()) for sub in group) + len(next_sub.content.split())
group.append(curr)
j += 1 if time_gap <= 1 and group_word_count < 18:
group.append(next_sub)
group_size += 1
else: else:
break break
start_time = group[0].start start_time = group[0].start
end_time = group[-1].end end_time = group[-1].end
combined_text = " ".join(x.content.replace("\n", " ") for x in group) combined_text = " ".join(sub.content.replace("\n", " ") for sub in group)
words = combined_text.split() words = combined_text.split()
formatted_text = format_subtitle_text(words) formatted_text = format_subtitle_text(words)
merged.append(srt.Subtitle(index=idx, start=start_time, end=end_time, content=formatted_text, proprietary="")) merged_subtitles.append(srt.Subtitle(index=current_index, start=start_time, end=end_time, content=formatted_text, proprietary=""))
idx += 1 current_index += 1
i += len(group) i += len(group)
return merged
return merged_subtitles
def format_subtitle_text(words: list[str], max_words_per_line: int = 10) -> str: def format_subtitle_text(words: list[str], max_words_per_line: int = 8) -> str:
""" """
Format a list of words into subtitle text with appropriate line breaks. Format a list of words into subtitle text with balanced line breaks.
Try to keep lines with the same amount of words, but put at most max_words_per_line words per line.
Args: If there's 1 word left for the last line, join it with the previous line.
words: List of words to format
max_words_per_line: Maximum words allowed per line
Returns:
Formatted subtitle text with line breaks
""" """
if not words: if not words:
return "" return ""
if len(words) <= max_words_per_line: n = len(words)
if n <= max_words_per_line:
return " ".join(words) return " ".join(words)
num_lines = (n + max_words_per_line - 1) // max_words_per_line
# Try to distribute words as evenly as possible
base = n // num_lines
extra = n % num_lines
lines = [] lines = []
i = 0 idx = 0
for i in range(num_lines):
while i < len(words): # Distribute the remainder words to the first 'extra' lines
remaining = len(words) - i count = base + (1 if i < extra else 0)
# If this is the last line and only 1 word left, join it with previous
# Handle orphan words (single word on last line) if i == num_lines - 1 and count == 1 and lines:
if remaining <= 1 and lines: lines[-1] += " " + words[idx]
lines[-1] += " " + words[i]
break break
lines.append(" ".join(words[idx : idx + count]))
# Calculate words for this line idx += count
words_for_line = min(max_words_per_line, remaining)
lines.append(" ".join(words[i : i + words_for_line]))
i += words_for_line
return "\n".join(lines) return "\n".join(lines)
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
p.add_argument("input", type=Path, help="Input SRT file")
p.add_argument("output", type=Path, help="Output SRT file")
p.add_argument("--translate", type=str, help="Language code for translation")
p.add_argument(
"--condense",
type=int,
help="Merge adjacent short subtitles in groups of N if close together",
)
return p.parse_args()
def clean_entries(subs: list[srt.Subtitle]) -> list[srt.Subtitle]: def clean_entries(subs: list[srt.Subtitle]) -> list[srt.Subtitle]:
""" """
Remove all occurrences of '\\h' from subtitle content. Remove all occurrences of '\\h' from subtitle content.
@@ -145,12 +142,6 @@ def remove_overlaps(subs: list[srt.Subtitle]) -> list[srt.Subtitle]:
If the beginning of a subtitle entry matches the end of the previous entry, If the beginning of a subtitle entry matches the end of the previous entry,
those duplicate lines are removed from the current subtitle. those duplicate lines are removed from the current subtitle.
Args:
subs: List of subtitle entries
Returns:
List of cleaned subtitle entries with duplicates removed
""" """
if not subs: if not subs:
return [] return []
@@ -219,107 +210,238 @@ TOOL_SCHEMA_SAVE_SUBTITLES = {
} }
def translate_entries(entries: list[srt.Subtitle], lang: str) -> list[srt.Subtitle]: @retry(max_retries=8)
logging.info(f"Translating subtitles to {lang} via OpenRouter") def _translate_chunk_with_context(
chunk: list[srt.Subtitle],
@retry(max_retries=5) context_before: list[srt.Subtitle],
def translate_chunk(chunk: list[srt.Subtitle]) -> list[srt.Subtitle]: context_after: list[srt.Subtitle],
lang: str,
model: str,
) -> list[srt.Subtitle]:
"""
Translates a single chunk of subtitles using preceding and succeeding subtitles as context.
"""
prompt = f""" prompt = f"""
You are an expert subtitle translator. You are an expert subtitle translator.
Translate the following subtitle entries from English to {lang}. You will be provided with a JSON object containing three lists: 'context_before', 'main_chunk', and 'context_after'.
For each entry, only translate the 'content' field. Do not change the 'id'.
Do NOT translate proper nouns, names, or technical terms; keep them in English.
Do NOT add, remove, or merge entries. Do not include any explanations or comments.
Return exactly the same number of entries as provided. For each input entry, there must be one output entry with the same 'id'. Your task is to translate ONLY the subtitles in the 'main_chunk' from English to {lang}.
If an entry is empty, return it as empty. Use the 'context_before' and 'context_after' lists to understand the full conversational flow, resolve ambiguities, and ensure tonal consistency.
DO NOT translate the content of the 'context_before' or 'context_after' lists in your final output.
- Translate idiomatically, as a native speaker would.
- If the content is too verbose, rewrite it to be more concise while preserving the original meaning.
- Do not censor profanity or slang; keep the original tone and style.
- Add punctuation and capitalization as needed.
- Do NOT translate proper nouns, names, or technical terms; keep them in English.
- Do NOT add, remove, or merge entries. Maintain the exact number of entries.
- Ensure the translated subtitles are in the same order as the original 'main_chunk'.
- If an entry is part of a continuing sentence, you may use '...' at the end/start if it helps readability.
- The output must contain {len(chunk)} entries.
- Each entry must have the same 'id' as the corresponding entry in 'main_chunk'.
- You are translating for a {lang}-speaking audience. Because {lang} word order is different from English, you are allowed to move information between entries within the 'main_chunk' to ensure the Turkish is grammatically correct and the verb is properly placed. However, you MUST return exactly the same number of entries as provided.
Return ONLY the translated entries for the 'main_chunk'. The number of entries in your response must exactly match the number of entries in the input 'main_chunk'.
For each input entry in 'main_chunk', there must be one output entry with the same 'id'.
Use your save_subtitles tool to return the results. Use your save_subtitles tool to return the results.
""" """
chunk_json = json.dumps( # Create the structured payload for the API
[ payload_data = {
{ "context_before": [{"id": s.index, "content": s.content.strip()} for s in context_before],
"id": s.index, "main_chunk": [{"id": s.index, "content": s.content.strip()} for s in chunk],
"content": s.content.strip(), "context_after": [{"id": s.index, "content": s.content.strip()} for s in context_after],
} }
for s in chunk
],
ensure_ascii=False,
)
payload = { payload = {
"model": "google/gemini-2.5-flash-lite", "model": model,
"messages": [ "messages": [
{"role": "system", "content": prompt}, {"role": "system", "content": prompt},
{"role": "user", "content": chunk_json}, {"role": "user", "content": json.dumps(payload_data, ensure_ascii=False)},
], ],
"tools": [TOOL_SCHEMA_SAVE_SUBTITLES], "tools": [TOOL_SCHEMA_SAVE_SUBTITLES],
"tool_choice": {"type": "function", "function": {"name": TOOL_SCHEMA_SAVE_SUBTITLES["function"]["name"]}}, "tool_choice": {"type": "function", "function": {"name": TOOL_SCHEMA_SAVE_SUBTITLES["function"]["name"]}},
} }
res = client.post(url="/chat/completions", json=payload) res = client.post(url="/chat/completions", json=payload)
if res.is_error: if res.is_error:
logging.error(f"Error translating subtitles: {res.text}") logger.error(f"Error translating subtitles: {res.text}")
res.raise_for_status() res.raise_for_status()
data = res.json() data = res.json()
tool_calls = data["choices"][0]["message"]["tool_calls"] tool_calls = data["choices"][0]["message"]["tool_calls"]
for call in tool_calls: for call in tool_calls:
if call["function"]["name"] != TOOL_SCHEMA_SAVE_SUBTITLES["function"]["name"]: if call["function"]["name"] != TOOL_SCHEMA_SAVE_SUBTITLES["function"]["name"]:
continue continue
parsed = json.loads(call["function"]["arguments"]) parsed = json.loads(call["function"]["arguments"])
translated_entries = parsed["entries"] translated_entries = parsed["entries"]
if len(translated_entries) != len(chunk): if len(translated_entries) != len(chunk):
raise ValueError(f"Expected {len(chunk)} translated entries, got {len(translated_entries)}") raise ValueError(f"Translation returned {len(translated_entries)} entries, but the chunk had {len(chunk)}.")
# Reassemble subtitles with translated content # Reassemble subtitles with translated content
id_to_sub = {s.index: s for s in chunk} id_to_sub = {s.index: s for s in chunk}
result = [] result = []
for entry in translated_entries: for entry in translated_entries:
orig = id_to_sub.get(entry["id"]) original_sub = id_to_sub.get(entry["id"])
if orig: if original_sub:
orig.content = entry["translated"].strip() original_sub.content = entry["translated"].strip()
result.append(orig) result.append(original_sub)
return result return result
combined = [] raise ValueError("The model did not return any translated subtitles via the specified tool.")
chunks = chunkify(entries, 20)
for i, chunk in enumerate(chunks, start=1):
logging.info(f"Translating chunk {i}/{len(chunks)}") def translate_entries(
translated = translate_chunk(chunk) entries: list[srt.Subtitle],
combined.extend(translated) model: str,
# Reindex lang: str,
for i, sub in enumerate(combined, 1): chunk_size: int = 30,
context_size: int = 4,
) -> list[srt.Subtitle]:
"""
Translates a list of subtitle entries using a sliding window for context.
"""
logger.info(f"Translating {len(entries)} entries to {lang}")
if not entries:
return []
chunks = list(chunkify(entries, chunk_size))
futures = []
for i, chunk in enumerate(chunks):
logger.info(f"Submitting chunk {i + 1}/{len(chunks)} for translation...")
# Get context from the previous chunk
context_before = []
if i > 0:
prev_chunk = chunks[i - 1]
context_before = prev_chunk[-context_size:]
# Get context from the next chunk
context_after = []
if i < len(chunks) - 1:
next_chunk = chunks[i + 1]
context_after = next_chunk[:context_size]
# Submit the translation task to the thread pool
future = pool.submit(
_translate_chunk_with_context,
chunk=chunk,
context_before=context_before,
context_after=context_after,
lang=lang,
model=model,
)
futures.append(future)
# Collect results as they complete
translated_subs = []
for i, future in enumerate(futures):
logger.info(f"Processing result of chunk {i + 1}/{len(chunks)}...")
try:
translated_chunk = future.result()
translated_subs.extend(translated_chunk)
except Exception as e:
logger.error(f"Error translating chunk {i + 1}: {e}")
# Optionally, decide how to handle failed chunks, e.g., skip or retry.
# For now, we'll just log the error and continue.
# Sort subtitles by start time to ensure correct order
translated_subs.sort(key=lambda s: s.start)
# Re-index all subtitles to ensure they are sequential
for i, sub in enumerate(translated_subs, 1):
sub.index = i sub.index = i
return combined
logger.info("Translation complete.")
return translated_subs
def translate(subtitle_path: Path, lang: str, save_path: Path, condense: int | None = None) -> None: def translate(
subtitle_path: Path,
lang: str,
save_path: Path,
model: str,
condense: int | None = None,
chunk_size: int | None = None,
) -> None:
""" """
Translate the subtitles in the given SRT file to the specified language. Translate the subtitles in the given SRT file to the specified language.
Returns a new SRT file with translated subtitles. Returns a new SRT file with translated subtitles.
""" """
entries = list(srt.parse(subtitle_path.read_text())) original = list(srt.parse(subtitle_path.read_text()))
entries = clean_entries(entries) cleaned = clean_entries(original)
entries = remove_overlaps(entries) without_overlaps = remove_overlaps(cleaned)
entries = without_overlaps
if condense: if condense:
entries = combine_entries(entries, max_entries=condense) condensed = combine_entries(entries, max_entries=condense)
save_path.with_name(f"{save_path.stem}.condensed.srt").write_text(srt.compose(condensed))
logger.info(f"Condensed subtitles from {len(entries)} to {len(condensed)} entries")
entries = condensed
if not entries: if not entries:
raise ValueError("No valid subtitle entries found to translate") raise ValueError("No valid subtitle entries found to translate")
translated = translate_entries(entries, lang=lang) translated = translate_entries(
entries=entries,
lang=lang,
model=model,
chunk_size=chunk_size,
)
# wrap the translated version as well
if condense:
translated = combine_entries(entries=translated, max_entries=condense)
save_path.write_text(srt.compose(translated)) save_path.write_text(srt.compose(translated))
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
description="""
Translate subtitles from SRT file using OpenRouter API.
This script reads an SRT file, translates the subtitles to the specified language,
and saves the translated subtitles to a new SRT file.
""",
)
p.add_argument("input", type=Path, help="Input SRT file")
p.add_argument("output", type=Path, help="Output SRT file")
p.add_argument("--translate", type=str, help="Language code for translation")
p.add_argument(
"--model",
type=str,
default="google/gemini-2.5-flash",
help="Model name for translation",
)
p.add_argument(
"--condense",
type=int,
help="Merge adjacent short subtitles in groups of N if close together",
)
p.add_argument(
"--chunk-size",
type=int,
default=30,
help="Number of subtitle entries to process in each chunk",
)
return p.parse_args()
def main(): def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") logging.basicConfig(level=logging.INFO, format="%(name)s: %(asctime)s %(levelname)s: %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING) # Suppress httpx debug logs
args = parse_args() args = parse_args()
translate( translate(
subtitle_path=args.input, subtitle_path=args.input,
lang=args.translate, lang=args.translate,
save_path=args.output, save_path=args.output,
model=args.model,
condense=args.condense, condense=args.condense,
chunk_size=args.chunk_size,
) )