"""Gradio UI for brainrotinator. Tabs: - Edit: upload a file or YouTube URL, run the splitter, watch logs stream. - Library: list / preview / delete clips in done_split/. - Settings: read/write config.json. The CLI (`main.py -e/-u`) still works; this UI is additive. """ from __future__ import annotations import io import json import logging import os import re import shutil import sys import threading import time from contextlib import redirect_stdout from pathlib import Path import gradio as gr from brainrotinator.video_editor import VideoEditor from config import Config from downloader.downloadVid import download_vid PROJECT_ROOT = Path(__file__).resolve().parent TO_SPLIT = PROJECT_ROOT / "to_split" DONE_SPLIT = PROJECT_ROOT / "done_split" EDITED = TO_SPLIT / "edited" class _StreamCapture(io.TextIOBase): """Tee writes into a thread-safe buffer so a Gradio generator can drain it.""" def __init__(self): self._buf: list[str] = [""] self._lock = threading.Lock() self._ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') def write(self, s: str) -> int: with self._lock: # Strip ANSI color codes so the Gradio textbox is readable clean_s = self._ansi_escape.sub('', s) # Handle carriage returns so tqdm progress bars don't spam multiple lines if '\r' in clean_s: parts = clean_s.split('\r') if parts[0]: self._buf[-1] += parts[0] for p in parts[1:]: if p: self._buf[-1] = p else: if '\n' in clean_s: lines = clean_s.split('\n') self._buf[-1] += lines[0] self._buf.extend(lines[1:]) else: self._buf[-1] += clean_s return len(s) def drain(self) -> str: with self._lock: # We join the buffer but we don't clear it. # This allows Gradio updates to be built cleanly over carriage returns without duplicates. return "\n".join(self._buf).strip() ACTIVE_EDITOR = None def _list_to_split() -> list[str]: if not TO_SPLIT.exists(): return [] return sorted(p.name for p in TO_SPLIT.glob("*.*") if p.suffix.lower() in [".mp4", ".mov", ".mkv"]) def _run_edit_job( uploaded_file: str | None, youtube_url: str, existing_file: str | None, chunk_duration: int, blur: bool, use_whisper: bool, filter_profanity: bool, subtitle_font_size: int, subtitle_margin_v: int, ): """Generator that yields cumulative log text as the edit job runs.""" global ACTIVE_EDITOR TO_SPLIT.mkdir(exist_ok=True) DONE_SPLIT.mkdir(exist_ok=True) EDITED.mkdir(exist_ok=True) cfg = Config.load() capture = _StreamCapture() def get_state(final=False): if final: return gr.update(value="Run", interactive=True), gr.update(value="Stop", interactive=False) return gr.update(value="Running...", interactive=False), gr.update(value="Stop", interactive=True) def emit_sys(line: str, final=False): # Directly write system log changes to the stream capture buffer capture.write(line) st = get_state(final) return capture.drain(), st[0], st[1] yield emit_sys("Preparing input...\n") # Resolve input. if uploaded_file: dest = TO_SPLIT / Path(uploaded_file).name shutil.copy(uploaded_file, dest) input_path = dest yield emit_sys(f"Copied uploaded file to {dest}\n") elif existing_file: input_path = TO_SPLIT / existing_file yield emit_sys(f"Using existing file: {input_path}\n") elif youtube_url.strip(): yield emit_sys(f"Downloading {youtube_url}...\n") try: with redirect_stdout(capture): download_vid(youtube_url.strip(), str(TO_SPLIT)) st = get_state() yield capture.drain(), st[0], st[1] except Exception as e: yield emit_sys(f"Download failed: {e}\n", final=True) return # Pick the newest mp4 in to_split that isn't in edited/. mp4s = sorted( [p for p in TO_SPLIT.glob("*.mp4") if p.is_file()], key=lambda p: p.stat().st_mtime, reverse=True, ) if not mp4s: yield emit_sys("No mp4 found after download.\n", final=True) return input_path = mp4s[0] else: yield emit_sys("Provide a file or a YouTube URL.\n", final=True) return yield emit_sys(f"Editing: {input_path}\n") editor = VideoEditor( input_path=str(input_path), output_folder=str(DONE_SPLIT), chunk_duration=int(chunk_duration), name=input_path.stem, useWhisper=use_whisper, filterProfanityInSubtitles=filter_profanity, voskModelDir=cfg.voskModelDir, tinyLlamaDir=cfg.tinyLlamaDir, subtitleFontSize=int(subtitle_font_size), subtitleMarginV=int(subtitle_margin_v), ) ACTIVE_EDITOR = editor # Run the splitter on a background thread so we can stream prints. error_box: list[BaseException] = [] def _runner(): try: with redirect_stdout(capture): if blur: editor.split_video_into_chunks_blur() else: editor.split_video_into_chunks() except BaseException as e: error_box.append(e) t = threading.Thread(target=_runner, daemon=True) t.start() while t.is_alive(): time.sleep(0.4) chunk = capture.drain() if chunk: st = get_state() yield chunk, st[0], st[1] chunk = capture.drain() if chunk: st = get_state() yield chunk, st[0], st[1] if error_box: yield emit_sys(f"\nERROR: {error_box[0]}\n", final=True) ACTIVE_EDITOR = None return # Move source to edited/. try: shutil.move(str(input_path), str(EDITED / input_path.name)) yield emit_sys(f"\nMoved source to {EDITED / input_path.name}\n") except Exception as e: yield emit_sys(f"\nCouldn't move source: {e}\n") yield emit_sys("\nDone.\n", final=True) ACTIVE_EDITOR = None def _list_clips() -> list[str]: if not DONE_SPLIT.exists(): return [] return sorted(str(p) for p in DONE_SPLIT.glob("*.mp4")) def _delete_clip(path: str) -> tuple[list[str], str]: if path and os.path.exists(path): os.remove(path) return _list_clips(), f"Deleted {path}" return _list_clips(), "Nothing to delete." def _update_preview(font_size: int, margin_v: int) -> str: """Returns HTML for a phone-framed 9:16 preview matching the ASS subtitle render.""" # ASS renders font_size on a 1080x1920 canvas. # 1% cqw = container_width/100; font occupies font_size/1080 of canvas width. font_size_pct = (font_size / 1080) * 100 # margin_v is pixels from top on a 1920px canvas. top_pct = (margin_v / 1920) * 100 # ASS outline=3 at font_size=100 → ~3% of font size; 0.04em is a reasonable approximation. shadow = "0.04em 0.04em 0 #000, -0.04em -0.04em 0 #000, 0.04em -0.04em 0 #000, -0.04em 0.04em 0 #000, 0 0.04em 0 #000, 0 -0.04em 0 #000, 0.04em 0 0 #000, -0.04em 0 0 #000" return f'''