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
-
-
-
-
-
-
-
-
-
Run Log
-
-
-
- No jobs yet.
-
-
-
-
-
-
Outputs
-
-
-
-
-
-
-
-
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)