Add Guardio web UI

This commit is contained in:
2026-05-22 19:42:08 +01:00
parent c6363dfa84
commit 82718e5e84
8 changed files with 810 additions and 1 deletions

3
.gitignore vendored
View File

@@ -1,5 +1,6 @@
__pycache__/ __pycache__/
*.pyc *.pyc
.venv/
.cache/ .cache/
temp/ temp/
output/ output/
@@ -7,4 +8,4 @@ output/
*.wav *.wav
*.mp3 *.mp3
logs/ logs/
*.log *.log

View File

@@ -66,6 +66,16 @@ Basic example:
.venv\Scripts\python.exe main.py "https://youtube.com/watch?v=VIDEO_ID" --lang es .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: Override the LM Studio endpoint or model from the CLI:
```powershell ```powershell

View File

@@ -10,3 +10,4 @@ tqdm
pathlib pathlib
typing-extensions typing-extensions
pytest pytest
flask

284
static/guardio.css Normal file
View File

@@ -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;
}
}

100
static/guardio.js Normal file
View File

@@ -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 = `<strong>${output.name}</strong><span>${output.size_mb} MB</span>`;
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);

127
templates/index.html Normal file
View File

@@ -0,0 +1,127 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Guardio | YouTube Auto Dub</title>
<link rel="stylesheet" href="{{ url_for('static', filename='guardio.css') }}">
</head>
<body>
<main class="shell">
<section class="hero">
<div>
<p class="eyebrow">Guardio</p>
<h1>YouTube Auto Dub</h1>
<p class="lede">Launch local dubbing jobs, watch the pipeline log, and collect finished videos from one quiet control surface.</p>
</div>
<div class="status-strip" aria-live="polite">
<span id="active-count">0 running</span>
<span id="last-state">Ready</span>
</div>
</section>
<section class="workspace">
<form id="job-form" class="panel">
<div class="panel-heading">
<h2>New Dub</h2>
<button type="submit">Start</button>
</div>
<label>
YouTube URL
<input name="url" type="url" placeholder="https://www.youtube.com/watch?v=..." required>
</label>
<div class="grid">
<label>
Target Language
<input name="lang" value="es" maxlength="12">
</label>
<label>
Whisper Model
<select name="whisper_model">
<option value="">Auto</option>
<option>tiny</option>
<option>base</option>
<option>small</option>
<option>medium</option>
<option>large-v3</option>
</select>
</label>
</div>
<div class="grid">
<label>
Mix Mode
<select name="mix_mode">
<option value="instrumental-only">Instrumental only</option>
<option value="dub-only">Dub only</option>
<option value="original-audio">Original audio</option>
</select>
</label>
<label>
Browser Cookies
<select name="browser">
<option value="">None</option>
<option>chrome</option>
<option>edge</option>
<option>firefox</option>
<option>brave</option>
</select>
</label>
</div>
<label>
Cookies File
<input name="cookies" placeholder="C:\path\to\cookies.txt">
</label>
<details>
<summary>Translation Settings</summary>
<div class="grid">
<label>
LM Studio URL
<input name="lmstudio_base_url" placeholder="http://127.0.0.1:1234/v1">
</label>
<label>
Model
<input name="lmstudio_model" placeholder="gemma-3-4b-it">
</label>
</div>
<label class="check">
<input name="gpu" type="checkbox">
<span>Prefer GPU</span>
</label>
</details>
</form>
<section class="panel monitor">
<div class="panel-heading">
<h2>Run Log</h2>
<button id="refresh" type="button">Refresh</button>
</div>
<div id="jobs" class="jobs"></div>
<pre id="log">No jobs yet.</pre>
</section>
</section>
<section class="outputs">
<div class="section-heading">
<h2>Outputs</h2>
<button id="reload-outputs" type="button">Reload</button>
</div>
<div id="outputs" class="output-grid">
{% for output in outputs %}
<a class="output-card" href="{{ url_for('download_output', filename=output.name) }}">
<strong>{{ output.name }}</strong>
<span>{{ output.size_mb }} MB</span>
</a>
{% else %}
<p class="empty">Finished videos will appear here.</p>
{% endfor %}
</div>
</section>
</main>
<script src="{{ url_for('static', filename='guardio.js') }}"></script>
</body>
</html>

48
tests/test_web_app.py Normal file
View File

@@ -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

238
web_app.py Normal file
View File

@@ -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/<job_id>")
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/<path:filename>")
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)