Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import threading | |
| from pathlib import Path | |
| import gradio as gr | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| MAIN = Path(__file__).parent / "main.py" | |
| _ANSI = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]|\r") | |
| _CLEANUP_DELAY = 30 * 60 # seconds to keep the output MKV available for download | |
| def _strip(text: str) -> str: | |
| return _ANSI.sub("", text) | |
| def _delete_later(path: str, delay: float = _CLEANUP_DELAY) -> None: | |
| """Delete a file or directory after `delay` seconds on a daemon thread.""" | |
| def _do() -> None: | |
| shutil.rmtree(path, ignore_errors=True) | |
| threading.Timer(delay, _do).start() | |
| def run( | |
| url: str, | |
| input_video: str | None, | |
| language: str, | |
| translate_to: str, | |
| resolution: int, | |
| max_length: int, | |
| ): | |
| source = (input_video or "").strip() | |
| url = (url or "").strip() | |
| if not source and not url: | |
| yield "Error: provide a YouTube URL or upload a video file.\n", gr.update(visible=False) | |
| return | |
| if source and url: | |
| yield "Error: provide either a YouTube URL or a video file, not both.\n", gr.update(visible=False) | |
| return | |
| tmpdir = tempfile.mkdtemp(prefix="transcriber-") | |
| cmd = [sys.executable, str(MAIN)] | |
| if source: | |
| cmd += ["--input-video", source] | |
| else: | |
| cmd += [url] | |
| cmd += ["--output-dir", tmpdir] | |
| if (language or "").strip(): | |
| cmd += ["--language", language.strip()] | |
| if (translate_to or "").strip(): | |
| cmd += ["--translate-to", translate_to.strip()] | |
| if resolution != 720: | |
| cmd += ["--resolution", str(int(resolution))] | |
| if max_length != 100: | |
| cmd += ["--max-length", str(int(max_length))] | |
| env = {**os.environ, "NO_COLOR": "1"} | |
| proc = subprocess.Popen( | |
| cmd, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.STDOUT, | |
| text=True, | |
| env=env, | |
| ) | |
| lines: list[str] = [] | |
| try: | |
| for line in proc.stdout: # type: ignore[union-attr] | |
| clean = _strip(line) | |
| if not clean.strip(): | |
| continue # skip blank lines and yt-dlp progress-bar clear lines | |
| # Collapse consecutive yt-dlp [download] progress lines in-place | |
| if clean.startswith("[download]") and lines and lines[-1].startswith("[download]"): | |
| lines[-1] = clean | |
| else: | |
| lines.append(clean) | |
| yield "".join(lines), gr.update(visible=False) | |
| except GeneratorExit: | |
| proc.terminate() | |
| try: | |
| proc.wait(timeout=5) | |
| except subprocess.TimeoutExpired: | |
| proc.kill() | |
| shutil.rmtree(tmpdir, ignore_errors=True) | |
| return | |
| finally: | |
| proc.wait() | |
| mkv_files = list(Path(tmpdir).glob("*.mkv")) | |
| if proc.returncode == 0 and mkv_files: | |
| # Copy MKV into a named temp dir so the download filename matches the | |
| # original title. Schedule the whole dir for deletion after the delay. | |
| out_dir = tempfile.mkdtemp(prefix="transcriber-out-") | |
| out_path = os.path.join(out_dir, mkv_files[0].name) | |
| shutil.copy2(mkv_files[0], out_path) | |
| shutil.rmtree(tmpdir, ignore_errors=True) | |
| _delete_later(out_dir) | |
| yield "".join(lines), gr.update(visible=True, value=out_path) | |
| else: | |
| shutil.rmtree(tmpdir, ignore_errors=True) | |
| if proc.returncode != 0: | |
| lines.append(f"\nProcess exited with code {proc.returncode}.\n") | |
| else: | |
| lines.append("\nNo output file produced.\n") | |
| yield "".join(lines), gr.update(visible=False) | |
| _CSS = """ | |
| #log textarea { font-family: monospace; font-size: 12px; } | |
| """ | |
| with gr.Blocks(title="transcriber", delete_cache=(_CLEANUP_DELAY, _CLEANUP_DELAY)) as demo: | |
| browser_state = gr.BrowserState( | |
| {"language": "", "translate_to": ""}, | |
| storage_key="transcriber-prefs", | |
| secret="transcriber-2026", | |
| ) | |
| gr.Markdown("## transcriber\nTranscribe a YouTube video and download it with embedded subtitles.") | |
| with gr.Row(): | |
| url_box = gr.Textbox( | |
| label="YouTube URL", | |
| placeholder="https://www.youtube.com/watch?v=...", | |
| scale=3, | |
| ) | |
| file_input = gr.File( | |
| label="Or upload a local video", | |
| file_types=[".mp4", ".mkv", ".mov", ".avi", ".webm"], | |
| scale=1, | |
| ) | |
| with gr.Accordion("Options", open=False): | |
| with gr.Row(): | |
| language = gr.Textbox( | |
| label="Source language (ISO 639-3)", | |
| placeholder="e.g. srb; blank = auto-detect", | |
| ) | |
| translate_to = gr.Textbox( | |
| label="Add subtitles translated to (ISO 639-3)", | |
| placeholder="e.g. rus; blank = skip translation", | |
| ) | |
| resolution = gr.Slider(144, 2160, step=1, value=720, label="Video size on shorter side (px)") | |
| max_length = gr.Slider(40, 200, step=1, value=100, label="Max chars per subtitle cue") | |
| with gr.Row(): | |
| run_btn = gr.Button("Run", variant="primary") | |
| stop_btn = gr.Button("Stop", variant="stop") | |
| log_box = gr.Textbox( | |
| lines=15, | |
| max_lines=40, | |
| interactive=False, | |
| autoscroll=True, | |
| label="Progress", | |
| elem_id="log", | |
| ) | |
| output_file = gr.DownloadButton(label="Download MKV", visible=False, variant="primary", size="lg") | |
| # Restore persisted language settings on page load | |
| def _restore(state: dict) -> tuple[str, str]: | |
| return state.get("language", ""), state.get("translate_to", "") | |
| # Save language settings whenever they change | |
| def _save(lang: str, tl: str) -> dict: | |
| return {"language": lang, "translate_to": tl} | |
| run_event = run_btn.click( | |
| fn=run, | |
| inputs=[url_box, file_input, language, translate_to, resolution, max_length], | |
| outputs=[log_box, output_file], | |
| show_progress="full", | |
| ) | |
| stop_btn.click(fn=None, cancels=[run_event]) | |
| demo.queue() | |
| if __name__ == "__main__": | |
| demo.launch(css=_CSS) | |