Guardio
+YouTube Auto Dub
+Launch local dubbing jobs, watch the pipeline log, and collect finished videos from one quiet control surface.
+Run Log
+ +No jobs yet.+
Outputs
+ +Finished videos will appear here.
+ {% endfor %} +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
+Launch local dubbing jobs, watch the pipeline log, and collect finished videos from one quiet control surface.
+No jobs yet.+
Finished videos will appear here.
+ {% endfor %} +