Add Guardio web UI
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
.venv/
|
||||||
.cache/
|
.cache/
|
||||||
temp/
|
temp/
|
||||||
output/
|
output/
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -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
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ tqdm
|
|||||||
pathlib
|
pathlib
|
||||||
typing-extensions
|
typing-extensions
|
||||||
pytest
|
pytest
|
||||||
|
flask
|
||||||
|
|||||||
284
static/guardio.css
Normal file
284
static/guardio.css
Normal 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
100
static/guardio.js
Normal 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
127
templates/index.html
Normal 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
48
tests/test_web_app.py
Normal 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
238
web_app.py
Normal 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)
|
||||||
Reference in New Issue
Block a user