| import gradio as gr |
| import json |
| import anthropic |
|
|
| client = anthropic.Anthropic() |
|
|
| LANGUAGES = [ |
| "Hindi", "Arabic", "French", "German", "Spanish", "Chinese", |
| "Japanese", "Russian", "Urdu", "Turkish", "Korean", "Italian", |
| "Portuguese", "English", |
| ] |
|
|
| GAP_MS = 200 |
| MAX_SPEED = 2.5 |
| MAX_CHARS_RATIO = MAX_SPEED |
|
|
|
|
| def group_words_into_lines(words: list, gap_ms: float = GAP_MS) -> list: |
| """ |
| Group word-level timestamps into subtitle lines. |
| A new line starts whenever the gap between two consecutive words |
| exceeds gap_ms milliseconds. |
| |
| Each word dict must have: {"start": float, "end": float, "word": str} |
| Returns list of line dicts: |
| {"start": float, "end": float, "text": str, "words": [...]} |
| """ |
| if not words: |
| return [] |
|
|
| lines = [] |
| current_words = [words[0]] |
|
|
| for w in words[1:]: |
| prev_end = current_words[-1]["end"] |
| gap_secs = w["start"] - prev_end |
| gap_ms_val = gap_secs * 1000 |
|
|
| if gap_ms_val > gap_ms: |
| |
| lines.append({ |
| "start": current_words[0]["start"], |
| "end": current_words[-1]["end"], |
| "text": " ".join(cw["word"].strip() for cw in current_words), |
| "words": current_words, |
| }) |
| current_words = [w] |
| else: |
| current_words.append(w) |
|
|
| |
| if current_words: |
| lines.append({ |
| "start": current_words[0]["start"], |
| "end": current_words[-1]["end"], |
| "text": " ".join(cw["word"].strip() for cw in current_words), |
| "words": current_words, |
| }) |
|
|
| return lines |
|
|
|
|
| def fmt_time(s: float) -> str: |
| m = int(s) // 60 |
| sec = s - m * 60 |
| return f"{m:02d}:{sec:05.2f}" |
|
|
|
|
| def translate_line(text: str, src_lang: str, tgt_lang: str, |
| max_chars: int) -> str: |
| """ |
| Translate one line via Claude, instructing it to stay under max_chars |
| while NEVER cutting content β just use natural concise phrasing. |
| """ |
| prompt = ( |
| f"Translate the following {src_lang} text to {tgt_lang}.\n" |
| f"Rules:\n" |
| f"1. Never cut or omit any meaning or content.\n" |
| f"2. Try to keep the translation under {max_chars} characters " |
| f"by using natural, concise phrasing in {tgt_lang}.\n" |
| f"3. If it is impossible to stay under {max_chars} characters " |
| f"without cutting content, go slightly over β completeness wins.\n" |
| f"4. Output ONLY the translated text, nothing else.\n\n" |
| f"Text: {text}" |
| ) |
|
|
| message = client.messages.create( |
| model="claude-sonnet-4-6", |
| max_tokens=1000, |
| messages=[{"role": "user", "content": prompt}], |
| ) |
| return message.content[0].text.strip() |
|
|
|
|
| def process(words_json: str, src_lang: str, tgt_lang: str, |
| max_speed: float, gap_ms: float) -> tuple: |
| """ |
| Main processing function. |
| Returns (display_text, json_output). |
| """ |
| if not words_json.strip(): |
| return "β Please paste word-level JSON.", "" |
|
|
| try: |
| data = json.loads(words_json) |
| except json.JSONDecodeError as e: |
| return f"β Invalid JSON: {e}", "" |
|
|
| |
| if isinstance(data, dict): |
| words = data.get("words", []) |
| elif isinstance(data, list): |
| words = data |
| else: |
| return "β JSON must be a list of words or {\"words\": [...]}", "" |
|
|
| if not words: |
| return "β No words found in JSON.", "" |
|
|
| |
| required = {"start", "end", "word"} |
| if not required.issubset(words[0].keys()): |
| return f"β Each word must have: {required}", "" |
|
|
| lines = group_words_into_lines(words, gap_ms_override := gap_ms) |
|
|
| results = [] |
| display = [] |
| warnings = [] |
|
|
| for i, line in enumerate(lines): |
| src_text = line["text"] |
| src_chars = len(src_text) |
| max_chars = int(src_chars * max_speed) |
|
|
| |
| tgt_text = translate_line(src_text, src_lang, tgt_lang, max_chars) |
| tgt_chars = len(tgt_text) |
|
|
| |
| |
| ratio = tgt_chars / max(src_chars, 1) |
| required_speed = round(ratio, 3) |
| over_limit = required_speed > max_speed |
|
|
| slot_start = line["start"] |
| slot_end = line["end"] |
|
|
| result = { |
| "line": i + 1, |
| "start": round(slot_start, 3), |
| "end": round(slot_end, 3), |
| "original": src_text, |
| "translated": tgt_text, |
| "original_chars": src_chars, |
| "translated_chars": tgt_chars, |
| "char_ratio": round(ratio, 3), |
| "required_speed": required_speed, |
| "max_speed": max_speed, |
| "over_limit": over_limit, |
| } |
| results.append(result) |
|
|
| |
| flag = "β οΈ " if over_limit else "β
" |
| display.append( |
| f"{flag}[{fmt_time(slot_start)} β {fmt_time(slot_end)}]\n" |
| f" {src_lang}: {src_text}\n" |
| f" {tgt_lang}: {tgt_text}\n" |
| f" chars: {src_chars} β {tgt_chars} " |
| f"speed: {required_speed}x" |
| + (" β οΈ OVER {:.1f}x LIMIT".format(max_speed) if over_limit else "") |
| ) |
|
|
| if over_limit: |
| warnings.append( |
| f"Line {i+1}: needs {required_speed}x but limit is {max_speed}x" |
| ) |
|
|
| summary = ( |
| f"β
{len(lines)} lines processed. " |
| f"{'β οΈ ' + str(len(warnings)) + ' lines over speed limit.' if warnings else 'All within speed limit.'}" |
| ) |
|
|
| display_text = summary + "\n\n" + "\n\n".join(display) |
| json_out = json.dumps(results, ensure_ascii=False, indent=2) |
|
|
| return display_text, json_out |
|
|
|
|
| with gr.Blocks(title="Dubbing Line Builder") as demo: |
| gr.Markdown(""" |
| # π¬ Dubbing Line Builder |
| Paste word-level timestamps β groups into lines (200ms gap rule) β |
| translates β calculates required TTS speed per line. |
| |
| **Input JSON format** (from your Whisper API): |
| ```json |
| [{"start": 0.5, "end": 0.9, "word": "Hello"}, |
| {"start": 1.0, "end": 1.4, "word": "world"}] |
| ``` |
| or `{"words": [...]}` shape also accepted. |
| """) |
|
|
| with gr.Row(): |
| with gr.Column(scale=1): |
| words_input = gr.Textbox( |
| label="π Word-level JSON", |
| lines=15, |
| placeholder='[{"start": 0.5, "end": 0.9, "word": "Hello"}, ...]' |
| ) |
| with gr.Row(): |
| src_lang = gr.Dropdown( |
| label="π’ Source language", |
| choices=LANGUAGES, value="English" |
| ) |
| tgt_lang = gr.Dropdown( |
| label="π― Target language", |
| choices=LANGUAGES, value="Hindi" |
| ) |
| with gr.Row(): |
| max_speed_input = gr.Slider( |
| label="β‘ Max TTS speed (x)", |
| minimum=1.0, maximum=3.0, step=0.1, value=2.5, |
| info="Max chars = original Γ this value" |
| ) |
| gap_input = gr.Slider( |
| label="β± Gap rule (ms)", |
| minimum=50, maximum=1000, step=50, value=200, |
| info="New line if silence > this many ms" |
| ) |
| btn = gr.Button("π Build Lines & Translate", variant="primary") |
|
|
| with gr.Column(scale=1): |
| display_out = gr.Textbox(label="π Result", lines=25) |
| json_out = gr.Textbox(label="π¦ JSON output (for your app)", lines=15) |
|
|
| btn.click( |
| fn=process, |
| inputs=[words_input, src_lang, tgt_lang, max_speed_input, gap_input], |
| outputs=[display_out, json_out], |
| ) |
|
|
| gr.api(process, api_name="build_lines") |
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|