diff --git a/README.md b/README.md index ee0ce20..0bea0ff 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,9 @@ Basic example: .venv\Scripts\python.exe main.py "https://youtube.com/watch?v=VIDEO_ID" --lang es ``` -### Guardio Web UI +### Gradio Web UI -Guardio provides a local browser UI for starting dub jobs, watching logs, and downloading finished videos: +Gradio provides a local browser UI for starting dub jobs, watching logs, and downloading finished videos: ```powershell .venv\Scripts\python.exe web_app.py diff --git a/requirements.txt b/requirements.txt index a2749d3..761b452 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,4 @@ tqdm pathlib typing-extensions pytest -flask +gradio diff --git a/static/guardio.css b/static/guardio.css deleted file mode 100644 index 8e4563e..0000000 --- a/static/guardio.css +++ /dev/null @@ -1,284 +0,0 @@ -:root { - color-scheme: light; - --ink: #18211f; - --muted: #61706b; - --line: #d8e2dc; - --field: #f8fbf8; - --panel: #ffffff; - --accent: #0c7c59; - --accent-dark: #085f45; - --warn: #a24f00; - --page: #eef5f0; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-height: 100vh; - background: var(--page); - color: var(--ink); - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -button, -input, -select { - font: inherit; -} - -button { - border: 0; - border-radius: 6px; - background: var(--accent); - color: #fff; - cursor: pointer; - font-weight: 700; - min-height: 40px; - padding: 0 16px; -} - -button:hover { - background: var(--accent-dark); -} - -.shell { - width: min(1180px, calc(100% - 32px)); - margin: 0 auto; - padding: 28px 0 42px; -} - -.hero { - display: flex; - justify-content: space-between; - align-items: end; - gap: 24px; - padding: 18px 0 26px; -} - -.eyebrow { - margin: 0 0 6px; - color: var(--accent); - font-size: 0.78rem; - font-weight: 800; - letter-spacing: 0; - text-transform: uppercase; -} - -h1, -h2, -p { - margin-top: 0; -} - -h1 { - margin-bottom: 8px; - font-size: clamp(2.2rem, 5vw, 4.4rem); - line-height: 0.95; - letter-spacing: 0; -} - -h2 { - margin-bottom: 0; - font-size: 1rem; -} - -.lede { - max-width: 660px; - margin-bottom: 0; - color: var(--muted); - font-size: 1.05rem; -} - -.status-strip { - display: flex; - gap: 8px; - flex-wrap: wrap; - justify-content: flex-end; -} - -.status-strip span, -.job-pill { - border: 1px solid var(--line); - border-radius: 999px; - background: rgba(255, 255, 255, 0.72); - color: var(--muted); - padding: 7px 12px; - font-size: 0.86rem; - font-weight: 700; -} - -.workspace { - display: grid; - grid-template-columns: minmax(320px, 420px) minmax(0, 1fr); - gap: 18px; - align-items: start; -} - -.panel, -.outputs { - border: 1px solid var(--line); - border-radius: 8px; - background: var(--panel); - box-shadow: 0 16px 40px rgba(32, 55, 45, 0.08); -} - -.panel { - padding: 18px; -} - -.panel-heading, -.section-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 16px; -} - -label { - display: grid; - gap: 7px; - color: var(--muted); - font-size: 0.86rem; - font-weight: 700; -} - -input, -select { - width: 100%; - min-height: 42px; - border: 1px solid var(--line); - border-radius: 6px; - background: var(--field); - color: var(--ink); - padding: 0 11px; -} - -input:focus, -select:focus { - border-color: var(--accent); - outline: 3px solid rgba(12, 124, 89, 0.16); -} - -.grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; - margin-top: 12px; -} - -form > label, -details { - margin-top: 12px; -} - -details { - border-top: 1px solid var(--line); - padding-top: 14px; -} - -summary { - cursor: pointer; - color: var(--ink); - font-weight: 800; -} - -.check { - display: flex; - align-items: center; - gap: 8px; - margin-top: 12px; -} - -.check input { - width: 18px; - min-height: 18px; -} - -.monitor { - min-height: 480px; -} - -.jobs { - display: flex; - gap: 8px; - flex-wrap: wrap; - min-height: 32px; - margin-bottom: 12px; -} - -.job-pill { - cursor: pointer; -} - -.job-pill.active { - border-color: var(--accent); - color: var(--accent-dark); -} - -pre { - min-height: 360px; - max-height: 560px; - overflow: auto; - margin: 0; - border: 1px solid var(--line); - border-radius: 6px; - background: #101816; - color: #d7eee4; - padding: 14px; - white-space: pre-wrap; - word-break: break-word; -} - -.outputs { - margin-top: 18px; - padding: 18px; -} - -.output-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 12px; -} - -.output-card { - display: grid; - gap: 8px; - min-height: 88px; - border: 1px solid var(--line); - border-radius: 8px; - color: var(--ink); - padding: 14px; - text-decoration: none; -} - -.output-card:hover { - border-color: var(--accent); -} - -.output-card span, -.empty { - color: var(--muted); -} - -@media (max-width: 820px) { - .hero, - .workspace { - display: grid; - } - - .workspace { - grid-template-columns: 1fr; - } - - .grid { - grid-template-columns: 1fr; - } - - .status-strip { - justify-content: flex-start; - } -} diff --git a/static/guardio.js b/static/guardio.js deleted file mode 100644 index 49f1a2f..0000000 --- a/static/guardio.js +++ /dev/null @@ -1,100 +0,0 @@ -const form = document.querySelector("#job-form"); -const jobsEl = document.querySelector("#jobs"); -const logEl = document.querySelector("#log"); -const activeCountEl = document.querySelector("#active-count"); -const lastStateEl = document.querySelector("#last-state"); -const outputsEl = document.querySelector("#outputs"); -let selectedJobId = null; - -async function fetchJson(url, options) { - const response = await fetch(url, options); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || "Request failed"); - } - return data; -} - -function renderJobs(jobs) { - jobsEl.innerHTML = ""; - const running = jobs.filter((job) => ["queued", "running"].includes(job.status)); - activeCountEl.textContent = `${running.length} running`; - lastStateEl.textContent = jobs[0] ? `${jobs[0].id} ${jobs[0].status}` : "Ready"; - - if (!selectedJobId && jobs[0]) { - selectedJobId = jobs[0].id; - } - - jobs.forEach((job) => { - const button = document.createElement("button"); - button.type = "button"; - button.className = `job-pill${job.id === selectedJobId ? " active" : ""}`; - button.textContent = `${job.id} - ${job.status}`; - button.addEventListener("click", () => { - selectedJobId = job.id; - logEl.textContent = job.log || "Waiting for log output..."; - renderJobs(jobs); - }); - jobsEl.appendChild(button); - }); - - const selected = jobs.find((job) => job.id === selectedJobId); - if (selected) { - logEl.textContent = selected.log || "Waiting for log output..."; - } else if (!jobs.length) { - logEl.textContent = "No jobs yet."; - } -} - -async function loadJobs() { - const jobs = await fetchJson("/api/jobs"); - renderJobs(jobs); -} - -async function loadOutputs() { - const outputs = await fetchJson("/api/outputs"); - outputsEl.innerHTML = ""; - if (!outputs.length) { - const empty = document.createElement("p"); - empty.className = "empty"; - empty.textContent = "Finished videos will appear here."; - outputsEl.appendChild(empty); - return; - } - - outputs.forEach((output) => { - const link = document.createElement("a"); - link.className = "output-card"; - link.href = `/outputs/${encodeURIComponent(output.name)}`; - link.innerHTML = `${output.name}${output.size_mb} MB`; - outputsEl.appendChild(link); - }); -} - -form.addEventListener("submit", async (event) => { - event.preventDefault(); - const submit = form.querySelector("button[type='submit']"); - submit.disabled = true; - try { - const job = await fetchJson("/api/jobs", { - method: "POST", - body: new FormData(form), - }); - selectedJobId = job.id; - form.reset(); - form.elements.lang.value = "es"; - form.elements.mix_mode.value = "instrumental-only"; - await loadJobs(); - } catch (error) { - logEl.textContent = error.message; - } finally { - submit.disabled = false; - } -}); - -document.querySelector("#refresh").addEventListener("click", loadJobs); -document.querySelector("#reload-outputs").addEventListener("click", loadOutputs); - -loadJobs(); -setInterval(loadJobs, 2500); -setInterval(loadOutputs, 10000); diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index d44dce3..0000000 --- a/templates/index.html +++ /dev/null @@ -1,127 +0,0 @@ - - - - - - Guardio | YouTube Auto Dub - - - -
-
-
-

Guardio

-

YouTube Auto Dub

-

Launch local dubbing jobs, watch the pipeline log, and collect finished videos from one quiet control surface.

-
-
- 0 running - Ready -
-
- -
-
-
-

New Dub

- -
- - - -
- - -
- -
- - -
- - - -
- Translation Settings -
- - -
- -
-
- -
-
-

Run Log

- -
-
-
No jobs yet.
-
-
- -
-
-

Outputs

- -
-
- {% for output in outputs %} - - {{ output.name }} - {{ output.size_mb }} MB - - {% else %} -

Finished videos will appear here.

- {% endfor %} -
-
-
- - - diff --git a/tests/test_web_app.py b/tests/test_web_app.py index 50efb65..4e24578 100644 --- a/tests/test_web_app.py +++ b/tests/test_web_app.py @@ -1,4 +1,4 @@ -"""Tests for the Guardio web UI command adapter.""" +"""Tests for the Gradio web UI command adapter.""" from __future__ import annotations @@ -38,11 +38,7 @@ def test_build_pipeline_command_accepts_optional_settings(): assert "--gpu" in command -def test_index_renders_guardio_ui(): +def test_create_app_builds_gradio_blocks(): app = create_app() - app.config.update(TESTING=True) - response = app.test_client().get("/") - - assert response.status_code == 200 - assert b"Guardio" in response.data + assert app.title == "Gradio YouTube Auto Dub" diff --git a/web_app.py b/web_app.py index 6fe1ba1..1fa1a65 100644 --- a/web_app.py +++ b/web_app.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Guardio web UI for launching YouTube Auto Dub jobs.""" +"""Gradio web UI for launching YouTube Auto Dub jobs.""" from __future__ import annotations @@ -12,7 +12,7 @@ import sys import threading import uuid -from flask import Flask, abort, jsonify, render_template, request, send_from_directory +import gradio as gr from main import build_parser from src.audio_separation import DEFAULT_MIX_MODE @@ -20,7 +20,7 @@ from src.engines import OUTPUT_DIR BASE_DIR = Path(__file__).resolve().parent -LOG_DIR = BASE_DIR / "logs" / "guardio" +LOG_DIR = BASE_DIR / "logs" / "gradio" @dataclass @@ -46,7 +46,7 @@ def _utc_iso(value: datetime | None) -> str | None: return value.astimezone(timezone.utc).isoformat() -def build_pipeline_command(form: dict[str, str]) -> list[str]: +def build_pipeline_command(form: dict[str, str | bool]) -> list[str]: """Build a validated command for the existing CLI pipeline.""" parser = build_parser() args = parser.parse_args(_form_to_cli_args(form)) @@ -58,9 +58,9 @@ def build_pipeline_command(form: dict[str, str]) -> list[str]: args.lang, "--mix-mode", args.mix_mode, - "--translation-backend", - args.translation_backend, ] + if args.translation_backend: + command.extend(["--translation-backend", args.translation_backend]) optional_flags = { "--browser": args.browser, @@ -79,7 +79,7 @@ def build_pipeline_command(form: dict[str, str]) -> list[str]: return command -def _form_to_cli_args(form: dict[str, str]) -> list[str]: +def _form_to_cli_args(form: dict[str, str | bool]) -> list[str]: url = (form.get("url") or "").strip() if not url: raise ValueError("A YouTube URL is required.") @@ -107,21 +107,27 @@ def _form_to_cli_args(form: dict[str, str]) -> list[str]: if value: cli_args.extend([flag, value]) - if form.get("gpu") in {"1", "true", "on", "yes"}: + gpu_value = form.get("gpu") + if gpu_value is True or str(gpu_value).lower() in {"1", "true", "on", "yes"}: cli_args.append("--gpu") return cli_args -def _serialize_job(job: DubJob) -> dict[str, object]: - return { - "id": job.id, - "status": job.status, - "returncode": job.returncode, - "created_at": _utc_iso(job.created_at), - "completed_at": _utc_iso(job.completed_at), - "log": _read_log_tail(job.log_path), - } +def _format_job_status(job: DubJob | None) -> str: + if job is None: + return "Ready" + + lines = [ + f"Job: {job.id}", + f"Status: {job.status}", + f"Created: {_utc_iso(job.created_at)}", + ] + if job.completed_at: + lines.append(f"Completed: {_utc_iso(job.completed_at)}") + if job.returncode is not None: + lines.append(f"Return code: {job.returncode}") + return "\n".join(lines) def _read_log_tail(log_path: Path, max_chars: int = 20000) -> str: @@ -139,7 +145,7 @@ def _run_job(job: DubJob) -> None: env["PYTHONUNBUFFERED"] = "1" with job.log_path.open("w", encoding="utf-8", errors="replace") as log_file: - log_file.write("Guardio started a YouTube Auto Dub job.\n") + log_file.write("Gradio started a YouTube Auto Dub job.\n") log_file.write(f"Command: {' '.join(job.command)}\n\n") log_file.flush() @@ -159,80 +165,166 @@ def _run_job(job: DubJob) -> None: job.status = "succeeded" if returncode == 0 else "failed" -def _list_outputs() -> list[dict[str, object]]: +def _list_outputs() -> list[Path]: OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - files = sorted( + return sorted( (path for path in OUTPUT_DIR.glob("*") if path.is_file()), key=lambda path: path.stat().st_mtime, reverse=True, ) - return [ - { - "name": path.name, - "size_mb": round(path.stat().st_size / (1024 * 1024), 1), - "modified_at": datetime.fromtimestamp(path.stat().st_mtime, timezone.utc).isoformat(), - } - for path in files[:20] - ] + +def _output_choices() -> list[str]: + return [path.name for path in _list_outputs()[:20]] -def create_app() -> Flask: - """Create the Guardio Flask app.""" - app = Flask(__name__) +def _start_job( + url: str, + lang: str, + whisper_model: str, + mix_mode: str, + browser: str, + cookies: str, + lmstudio_base_url: str, + lmstudio_model: str, + gpu: bool, +) -> tuple[str, str, str, gr.Dropdown]: + form = { + "url": url, + "lang": lang, + "whisper_model": whisper_model, + "mix_mode": mix_mode, + "browser": browser, + "cookies": cookies, + "translation_backend": "lmstudio", + "lmstudio_base_url": lmstudio_base_url, + "lmstudio_model": lmstudio_model, + "gpu": gpu, + } - @app.get("/") - def index(): - return render_template("index.html", outputs=_list_outputs()) + try: + command = build_pipeline_command(form) + except (SystemExit, ValueError) as exc: + message = str(exc) or "Invalid job options." + return "", message, message, gr.update(choices=_output_choices()) - @app.get("/api/jobs") - def list_jobs(): - with JOBS_LOCK: - jobs = sorted(JOBS.values(), key=lambda item: item.created_at, reverse=True) - return jsonify([_serialize_job(job) for job in jobs]) + LOG_DIR.mkdir(parents=True, exist_ok=True) + job_id = uuid.uuid4().hex[:12] + job = DubJob( + id=job_id, + command=command, + log_path=LOG_DIR / f"{job_id}.log", + ) - @app.post("/api/jobs") - def create_job(): - try: - command = build_pipeline_command(request.form) - except (SystemExit, ValueError) as exc: - return jsonify({"error": str(exc) or "Invalid job options."}), 400 + with JOBS_LOCK: + JOBS[job.id] = job - LOG_DIR.mkdir(parents=True, exist_ok=True) - job_id = uuid.uuid4().hex[:12] - job = DubJob( - id=job_id, - command=command, - log_path=LOG_DIR / f"{job_id}.log", + thread = threading.Thread(target=_run_job, args=(job,), daemon=True) + thread.start() + return job.id, _format_job_status(job), _read_log_tail(job.log_path), gr.update(choices=_output_choices()) + + +def _refresh_job(job_id: str) -> tuple[str, str, gr.Dropdown]: + with JOBS_LOCK: + job = JOBS.get(job_id) + + if job is None: + return "Ready", "No job selected.", gr.update(choices=_output_choices()) + + return _format_job_status(job), _read_log_tail(job.log_path), gr.update(choices=_output_choices()) + + +def _select_output(filename: str | None) -> str | None: + if not filename: + return None + + output_path = OUTPUT_DIR / filename + if not output_path.exists() or not output_path.is_file(): + return None + return str(output_path) + + +def create_app() -> gr.Blocks: + """Create the Gradio app.""" + with gr.Blocks(title="Gradio YouTube Auto Dub") as demo: + gr.Markdown( + """ + # YouTube Auto Dub + Start local dubbing jobs, watch the pipeline log, and collect finished videos. + """ ) + job_id = gr.State("") - with JOBS_LOCK: - JOBS[job.id] = job + with gr.Row(): + with gr.Column(scale=5): + url = gr.Textbox(label="YouTube URL", placeholder="https://www.youtube.com/watch?v=...") + with gr.Row(): + lang = gr.Textbox(label="Target Language", value="es", max_lines=1) + whisper_model = gr.Dropdown( + label="Whisper Model", + choices=["", "tiny", "base", "small", "medium", "large-v3"], + value="", + ) + with gr.Row(): + mix_mode = gr.Dropdown( + label="Mix Mode", + choices=[DEFAULT_MIX_MODE, "dub-only", "original-audio"], + value=DEFAULT_MIX_MODE, + ) + browser = gr.Dropdown( + label="Browser Cookies", + choices=["", "chrome", "edge", "firefox", "brave"], + value="", + ) + cookies = gr.Textbox(label="Cookies File", placeholder=r"C:\path\to\cookies.txt") - thread = threading.Thread(target=_run_job, args=(job,), daemon=True) - thread.start() - return jsonify(_serialize_job(job)), 202 + with gr.Accordion("Translation Settings", open=False): + lmstudio_base_url = gr.Textbox( + label="LM Studio URL", + placeholder="http://127.0.0.1:1234/v1", + ) + lmstudio_model = gr.Textbox(label="Model", placeholder="gemma-3-4b-it") + gpu = gr.Checkbox(label="Prefer GPU", value=False) - @app.get("/api/jobs/") - def get_job(job_id: str): - with JOBS_LOCK: - job = JOBS.get(job_id) - if job is None: - abort(404) - return jsonify(_serialize_job(job)) + start = gr.Button("Start Dub", variant="primary") - @app.get("/api/outputs") - def list_outputs(): - return jsonify(_list_outputs()) + with gr.Column(scale=7): + status = gr.Textbox(label="Job Status", value="Ready", lines=5, interactive=False) + log = gr.Textbox( + label="Run Log", + value="No jobs yet.", + lines=20, + interactive=False, + ) + refresh = gr.Button("Refresh") - @app.get("/outputs/") - def download_output(filename: str): - return send_from_directory(OUTPUT_DIR, filename, as_attachment=True) + with gr.Row(): + output_choice = gr.Dropdown(label="Finished Outputs", choices=_output_choices(), interactive=True) + output_file = gr.File(label="Download Selected Output", interactive=False) - return app + inputs = [ + url, + lang, + whisper_model, + mix_mode, + browser, + cookies, + lmstudio_base_url, + lmstudio_model, + gpu, + ] + start.click( + _start_job, + inputs=inputs, + outputs=[job_id, status, log, output_choice], + ) + refresh.click(_refresh_job, inputs=[job_id], outputs=[status, log, output_choice]) + output_choice.change(_select_output, inputs=[output_choice], outputs=[output_file]) + + return demo app = create_app() if __name__ == "__main__": - app.run(host="127.0.0.1", port=7860, debug=True) + app.launch(server_name="127.0.0.1", server_port=7860)