Use Gradio for web UI
This commit is contained in:
@@ -66,9 +66,9 @@ 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
|
### 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
|
```powershell
|
||||||
.venv\Scripts\python.exe web_app.py
|
.venv\Scripts\python.exe web_app.py
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ tqdm
|
|||||||
pathlib
|
pathlib
|
||||||
typing-extensions
|
typing-extensions
|
||||||
pytest
|
pytest
|
||||||
flask
|
gradio
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 = `<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);
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for the Guardio web UI command adapter."""
|
"""Tests for the Gradio web UI command adapter."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -38,11 +38,7 @@ def test_build_pipeline_command_accepts_optional_settings():
|
|||||||
assert "--gpu" in command
|
assert "--gpu" in command
|
||||||
|
|
||||||
|
|
||||||
def test_index_renders_guardio_ui():
|
def test_create_app_builds_gradio_blocks():
|
||||||
app = create_app()
|
app = create_app()
|
||||||
app.config.update(TESTING=True)
|
|
||||||
|
|
||||||
response = app.test_client().get("/")
|
assert app.title == "Gradio YouTube Auto Dub"
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert b"Guardio" in response.data
|
|
||||||
|
|||||||
234
web_app.py
234
web_app.py
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from flask import Flask, abort, jsonify, render_template, request, send_from_directory
|
import gradio as gr
|
||||||
|
|
||||||
from main import build_parser
|
from main import build_parser
|
||||||
from src.audio_separation import DEFAULT_MIX_MODE
|
from src.audio_separation import DEFAULT_MIX_MODE
|
||||||
@@ -20,7 +20,7 @@ from src.engines import OUTPUT_DIR
|
|||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
LOG_DIR = BASE_DIR / "logs" / "guardio"
|
LOG_DIR = BASE_DIR / "logs" / "gradio"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -46,7 +46,7 @@ def _utc_iso(value: datetime | None) -> str | None:
|
|||||||
return value.astimezone(timezone.utc).isoformat()
|
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."""
|
"""Build a validated command for the existing CLI pipeline."""
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(_form_to_cli_args(form))
|
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,
|
args.lang,
|
||||||
"--mix-mode",
|
"--mix-mode",
|
||||||
args.mix_mode,
|
args.mix_mode,
|
||||||
"--translation-backend",
|
|
||||||
args.translation_backend,
|
|
||||||
]
|
]
|
||||||
|
if args.translation_backend:
|
||||||
|
command.extend(["--translation-backend", args.translation_backend])
|
||||||
|
|
||||||
optional_flags = {
|
optional_flags = {
|
||||||
"--browser": args.browser,
|
"--browser": args.browser,
|
||||||
@@ -79,7 +79,7 @@ def build_pipeline_command(form: dict[str, str]) -> list[str]:
|
|||||||
return command
|
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()
|
url = (form.get("url") or "").strip()
|
||||||
if not url:
|
if not url:
|
||||||
raise ValueError("A YouTube URL is required.")
|
raise ValueError("A YouTube URL is required.")
|
||||||
@@ -107,21 +107,27 @@ def _form_to_cli_args(form: dict[str, str]) -> list[str]:
|
|||||||
if value:
|
if value:
|
||||||
cli_args.extend([flag, 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")
|
cli_args.append("--gpu")
|
||||||
|
|
||||||
return cli_args
|
return cli_args
|
||||||
|
|
||||||
|
|
||||||
def _serialize_job(job: DubJob) -> dict[str, object]:
|
def _format_job_status(job: DubJob | None) -> str:
|
||||||
return {
|
if job is None:
|
||||||
"id": job.id,
|
return "Ready"
|
||||||
"status": job.status,
|
|
||||||
"returncode": job.returncode,
|
lines = [
|
||||||
"created_at": _utc_iso(job.created_at),
|
f"Job: {job.id}",
|
||||||
"completed_at": _utc_iso(job.completed_at),
|
f"Status: {job.status}",
|
||||||
"log": _read_log_tail(job.log_path),
|
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:
|
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"
|
env["PYTHONUNBUFFERED"] = "1"
|
||||||
|
|
||||||
with job.log_path.open("w", encoding="utf-8", errors="replace") as log_file:
|
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.write(f"Command: {' '.join(job.command)}\n\n")
|
||||||
log_file.flush()
|
log_file.flush()
|
||||||
|
|
||||||
@@ -159,80 +165,166 @@ def _run_job(job: DubJob) -> None:
|
|||||||
job.status = "succeeded" if returncode == 0 else "failed"
|
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)
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
files = sorted(
|
return sorted(
|
||||||
(path for path in OUTPUT_DIR.glob("*") if path.is_file()),
|
(path for path in OUTPUT_DIR.glob("*") if path.is_file()),
|
||||||
key=lambda path: path.stat().st_mtime,
|
key=lambda path: path.stat().st_mtime,
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
return [
|
|
||||||
{
|
def _output_choices() -> list[str]:
|
||||||
"name": path.name,
|
return [path.name for path in _list_outputs()[:20]]
|
||||||
"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:
|
def _start_job(
|
||||||
"""Create the Guardio Flask app."""
|
url: str,
|
||||||
app = Flask(__name__)
|
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("/")
|
try:
|
||||||
def index():
|
command = build_pipeline_command(form)
|
||||||
return render_template("index.html", outputs=_list_outputs())
|
except (SystemExit, ValueError) as exc:
|
||||||
|
message = str(exc) or "Invalid job options."
|
||||||
|
return "", message, message, gr.update(choices=_output_choices())
|
||||||
|
|
||||||
@app.get("/api/jobs")
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
def list_jobs():
|
job_id = uuid.uuid4().hex[:12]
|
||||||
with JOBS_LOCK:
|
job = DubJob(
|
||||||
jobs = sorted(JOBS.values(), key=lambda item: item.created_at, reverse=True)
|
id=job_id,
|
||||||
return jsonify([_serialize_job(job) for job in jobs])
|
command=command,
|
||||||
|
log_path=LOG_DIR / f"{job_id}.log",
|
||||||
|
)
|
||||||
|
|
||||||
@app.post("/api/jobs")
|
with JOBS_LOCK:
|
||||||
def create_job():
|
JOBS[job.id] = 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)
|
thread = threading.Thread(target=_run_job, args=(job,), daemon=True)
|
||||||
job_id = uuid.uuid4().hex[:12]
|
thread.start()
|
||||||
job = DubJob(
|
return job.id, _format_job_status(job), _read_log_tail(job.log_path), gr.update(choices=_output_choices())
|
||||||
id=job_id,
|
|
||||||
command=command,
|
|
||||||
log_path=LOG_DIR / f"{job_id}.log",
|
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:
|
with gr.Row():
|
||||||
JOBS[job.id] = job
|
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)
|
with gr.Accordion("Translation Settings", open=False):
|
||||||
thread.start()
|
lmstudio_base_url = gr.Textbox(
|
||||||
return jsonify(_serialize_job(job)), 202
|
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/<job_id>")
|
start = gr.Button("Start Dub", variant="primary")
|
||||||
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")
|
with gr.Column(scale=7):
|
||||||
def list_outputs():
|
status = gr.Textbox(label="Job Status", value="Ready", lines=5, interactive=False)
|
||||||
return jsonify(_list_outputs())
|
log = gr.Textbox(
|
||||||
|
label="Run Log",
|
||||||
|
value="No jobs yet.",
|
||||||
|
lines=20,
|
||||||
|
interactive=False,
|
||||||
|
)
|
||||||
|
refresh = gr.Button("Refresh")
|
||||||
|
|
||||||
@app.get("/outputs/<path:filename>")
|
with gr.Row():
|
||||||
def download_output(filename: str):
|
output_choice = gr.Dropdown(label="Finished Outputs", choices=_output_choices(), interactive=True)
|
||||||
return send_from_directory(OUTPUT_DIR, filename, as_attachment=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()
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user