From 82718e5e84fdae5e0da1b06077e7eaa1e79e5026 Mon Sep 17 00:00:00 2001 From: oimwiodev Date: Fri, 22 May 2026 19:42:08 +0100 Subject: [PATCH] Add Guardio web UI --- .gitignore | 3 +- README.md | 10 ++ requirements.txt | 1 + static/guardio.css | 284 ++++++++++++++++++++++++++++++++++++++++++ static/guardio.js | 100 +++++++++++++++ templates/index.html | 127 +++++++++++++++++++ tests/test_web_app.py | 48 +++++++ web_app.py | 238 +++++++++++++++++++++++++++++++++++ 8 files changed, 810 insertions(+), 1 deletion(-) create mode 100644 static/guardio.css create mode 100644 static/guardio.js create mode 100644 templates/index.html create mode 100644 tests/test_web_app.py create mode 100644 web_app.py diff --git a/.gitignore b/.gitignore index 9ecb181..c0f45c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ __pycache__/ *.pyc +.venv/ .cache/ temp/ output/ @@ -7,4 +8,4 @@ output/ *.wav *.mp3 logs/ -*.log \ No newline at end of file +*.log diff --git a/README.md b/README.md index 772d29a..ee0ce20 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,16 @@ Basic example: .venv\Scripts\python.exe main.py "https://youtube.com/watch?v=VIDEO_ID" --lang es ``` +### Guardio Web UI + +Guardio provides a local browser UI for starting dub jobs, watching logs, and downloading finished videos: + +```powershell +.venv\Scripts\python.exe web_app.py +``` + +Open `http://127.0.0.1:7860` and submit a YouTube URL. Jobs run through the same `main.py` pipeline, so the CLI options and environment variables still apply. + Override the LM Studio endpoint or model from the CLI: ```powershell diff --git a/requirements.txt b/requirements.txt index 95fc6bc..a2749d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ tqdm pathlib typing-extensions pytest +flask diff --git a/static/guardio.css b/static/guardio.css new file mode 100644 index 0000000..8e4563e --- /dev/null +++ b/static/guardio.css @@ -0,0 +1,284 @@ +: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 new file mode 100644 index 0000000..49f1a2f --- /dev/null +++ b/static/guardio.js @@ -0,0 +1,100 @@ +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 new file mode 100644 index 0000000..d44dce3 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,127 @@ + + + + + + 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 new file mode 100644 index 0000000..50efb65 --- /dev/null +++ b/tests/test_web_app.py @@ -0,0 +1,48 @@ +"""Tests for the Guardio web UI command adapter.""" + +from __future__ import annotations + +import sys + +from web_app import build_pipeline_command, create_app + + +def test_build_pipeline_command_uses_cli_parser_defaults(): + command = build_pipeline_command({"url": "https://youtube.com/watch?v=demo"}) + + assert command[:3] == [sys.executable, command[1], "https://youtube.com/watch?v=demo"] + assert "--lang" in command + assert command[command.index("--lang") + 1] == "es" + assert "--mix-mode" in command + assert command[command.index("--mix-mode") + 1] == "instrumental-only" + + +def test_build_pipeline_command_accepts_optional_settings(): + command = build_pipeline_command( + { + "url": "https://youtube.com/watch?v=demo", + "lang": "fr", + "browser": "chrome", + "whisper_model": "small", + "lmstudio_base_url": "http://localhost:1234/v1", + "lmstudio_model": "gemma-custom", + "gpu": "on", + } + ) + + assert command[command.index("--lang") + 1] == "fr" + assert command[command.index("--browser") + 1] == "chrome" + assert command[command.index("--whisper_model") + 1] == "small" + assert command[command.index("--lmstudio-base-url") + 1] == "http://localhost:1234/v1" + assert command[command.index("--lmstudio-model") + 1] == "gemma-custom" + assert "--gpu" in command + + +def test_index_renders_guardio_ui(): + app = create_app() + app.config.update(TESTING=True) + + response = app.test_client().get("/") + + assert response.status_code == 200 + assert b"Guardio" in response.data diff --git a/web_app.py b/web_app.py new file mode 100644 index 0000000..6fe1ba1 --- /dev/null +++ b/web_app.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Guardio web UI for launching YouTube Auto Dub jobs.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +import os +import subprocess +import sys +import threading +import uuid + +from flask import Flask, abort, jsonify, render_template, request, send_from_directory + +from main import build_parser +from src.audio_separation import DEFAULT_MIX_MODE +from src.engines import OUTPUT_DIR + + +BASE_DIR = Path(__file__).resolve().parent +LOG_DIR = BASE_DIR / "logs" / "guardio" + + +@dataclass +class DubJob: + """Runtime state for a web-launched dub job.""" + + id: str + command: list[str] + log_path: Path + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + status: str = "queued" + returncode: int | None = None + completed_at: datetime | None = None + + +JOBS: dict[str, DubJob] = {} +JOBS_LOCK = threading.Lock() + + +def _utc_iso(value: datetime | None) -> str | None: + if value is None: + return None + return value.astimezone(timezone.utc).isoformat() + + +def build_pipeline_command(form: dict[str, str]) -> list[str]: + """Build a validated command for the existing CLI pipeline.""" + parser = build_parser() + args = parser.parse_args(_form_to_cli_args(form)) + command = [ + sys.executable, + str(BASE_DIR / "main.py"), + args.url, + "--lang", + args.lang, + "--mix-mode", + args.mix_mode, + "--translation-backend", + args.translation_backend, + ] + + optional_flags = { + "--browser": args.browser, + "--cookies": args.cookies, + "--whisper_model": args.whisper_model, + "--lmstudio-base-url": args.lmstudio_base_url, + "--lmstudio-model": args.lmstudio_model, + } + for flag, value in optional_flags.items(): + if value: + command.extend([flag, value]) + + if args.gpu: + command.append("--gpu") + + return command + + +def _form_to_cli_args(form: dict[str, str]) -> list[str]: + url = (form.get("url") or "").strip() + if not url: + raise ValueError("A YouTube URL is required.") + + cli_args = [url] + field_flags = { + "lang": "--lang", + "browser": "--browser", + "cookies": "--cookies", + "whisper_model": "--whisper_model", + "mix_mode": "--mix-mode", + "translation_backend": "--translation-backend", + "lmstudio_base_url": "--lmstudio-base-url", + "lmstudio_model": "--lmstudio-model", + } + + defaults = { + "lang": "es", + "mix_mode": DEFAULT_MIX_MODE, + "translation_backend": "lmstudio", + } + + for field_name, flag in field_flags.items(): + value = (form.get(field_name) or defaults.get(field_name) or "").strip() + if value: + cli_args.extend([flag, value]) + + if form.get("gpu") 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 _read_log_tail(log_path: Path, max_chars: int = 20000) -> str: + if not log_path.exists(): + return "" + text = log_path.read_text(encoding="utf-8", errors="replace") + return text[-max_chars:] + + +def _run_job(job: DubJob) -> None: + with JOBS_LOCK: + job.status = "running" + + env = os.environ.copy() + 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(f"Command: {' '.join(job.command)}\n\n") + log_file.flush() + + process = subprocess.Popen( + job.command, + cwd=BASE_DIR, + env=env, + stdout=log_file, + stderr=subprocess.STDOUT, + text=True, + ) + returncode = process.wait() + + with JOBS_LOCK: + job.returncode = returncode + job.completed_at = datetime.now(timezone.utc) + job.status = "succeeded" if returncode == 0 else "failed" + + +def _list_outputs() -> list[dict[str, object]]: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + files = 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 create_app() -> Flask: + """Create the Guardio Flask app.""" + app = Flask(__name__) + + @app.get("/") + def index(): + return render_template("index.html", outputs=_list_outputs()) + + @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]) + + @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 + + 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", + ) + + with JOBS_LOCK: + JOBS[job.id] = job + + thread = threading.Thread(target=_run_job, args=(job,), daemon=True) + thread.start() + return jsonify(_serialize_job(job)), 202 + + @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)) + + @app.get("/api/outputs") + def list_outputs(): + return jsonify(_list_outputs()) + + @app.get("/outputs/") + def download_output(filename: str): + return send_from_directory(OUTPUT_DIR, filename, as_attachment=True) + + return app + + +app = create_app() + + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=7860, debug=True)