Use Gradio for web UI
This commit is contained in:
234
web_app.py
234
web_app.py
@@ -1,5 +1,5 @@
|
||||
#!/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
|
||||
|
||||
@@ -12,7 +12,7 @@ import sys
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
from flask import Flask, abort, jsonify, render_template, request, send_from_directory
|
||||
import gradio as gr
|
||||
|
||||
from main import build_parser
|
||||
from src.audio_separation import DEFAULT_MIX_MODE
|
||||
@@ -20,7 +20,7 @@ from src.engines import OUTPUT_DIR
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
LOG_DIR = BASE_DIR / "logs" / "guardio"
|
||||
LOG_DIR = BASE_DIR / "logs" / "gradio"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -46,7 +46,7 @@ def _utc_iso(value: datetime | None) -> str | None:
|
||||
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."""
|
||||
parser = build_parser()
|
||||
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,
|
||||
"--mix-mode",
|
||||
args.mix_mode,
|
||||
"--translation-backend",
|
||||
args.translation_backend,
|
||||
]
|
||||
if args.translation_backend:
|
||||
command.extend(["--translation-backend", args.translation_backend])
|
||||
|
||||
optional_flags = {
|
||||
"--browser": args.browser,
|
||||
@@ -79,7 +79,7 @@ def build_pipeline_command(form: dict[str, str]) -> list[str]:
|
||||
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()
|
||||
if not url:
|
||||
raise ValueError("A YouTube URL is required.")
|
||||
@@ -107,21 +107,27 @@ def _form_to_cli_args(form: dict[str, str]) -> list[str]:
|
||||
if 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")
|
||||
|
||||
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 _format_job_status(job: DubJob | None) -> str:
|
||||
if job is None:
|
||||
return "Ready"
|
||||
|
||||
lines = [
|
||||
f"Job: {job.id}",
|
||||
f"Status: {job.status}",
|
||||
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:
|
||||
@@ -139,7 +145,7 @@ def _run_job(job: DubJob) -> None:
|
||||
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("Gradio started a YouTube Auto Dub job.\n")
|
||||
log_file.write(f"Command: {' '.join(job.command)}\n\n")
|
||||
log_file.flush()
|
||||
|
||||
@@ -159,80 +165,166 @@ def _run_job(job: DubJob) -> None:
|
||||
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)
|
||||
files = sorted(
|
||||
return 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 _output_choices() -> list[str]:
|
||||
return [path.name for path in _list_outputs()[:20]]
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""Create the Guardio Flask app."""
|
||||
app = Flask(__name__)
|
||||
def _start_job(
|
||||
url: str,
|
||||
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("/")
|
||||
def index():
|
||||
return render_template("index.html", outputs=_list_outputs())
|
||||
try:
|
||||
command = build_pipeline_command(form)
|
||||
except (SystemExit, ValueError) as exc:
|
||||
message = str(exc) or "Invalid job options."
|
||||
return "", message, message, gr.update(choices=_output_choices())
|
||||
|
||||
@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])
|
||||
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",
|
||||
)
|
||||
|
||||
@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
|
||||
with JOBS_LOCK:
|
||||
JOBS[job.id] = job
|
||||
|
||||
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",
|
||||
thread = threading.Thread(target=_run_job, args=(job,), daemon=True)
|
||||
thread.start()
|
||||
return job.id, _format_job_status(job), _read_log_tail(job.log_path), gr.update(choices=_output_choices())
|
||||
|
||||
|
||||
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:
|
||||
JOBS[job.id] = job
|
||||
with gr.Row():
|
||||
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)
|
||||
thread.start()
|
||||
return jsonify(_serialize_job(job)), 202
|
||||
with gr.Accordion("Translation Settings", open=False):
|
||||
lmstudio_base_url = gr.Textbox(
|
||||
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>")
|
||||
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))
|
||||
start = gr.Button("Start Dub", variant="primary")
|
||||
|
||||
@app.get("/api/outputs")
|
||||
def list_outputs():
|
||||
return jsonify(_list_outputs())
|
||||
with gr.Column(scale=7):
|
||||
status = gr.Textbox(label="Job Status", value="Ready", lines=5, interactive=False)
|
||||
log = gr.Textbox(
|
||||
label="Run Log",
|
||||
value="No jobs yet.",
|
||||
lines=20,
|
||||
interactive=False,
|
||||
)
|
||||
refresh = gr.Button("Refresh")
|
||||
|
||||
@app.get("/outputs/<path:filename>")
|
||||
def download_output(filename: str):
|
||||
return send_from_directory(OUTPUT_DIR, filename, as_attachment=True)
|
||||
with gr.Row():
|
||||
output_choice = gr.Dropdown(label="Finished Outputs", choices=_output_choices(), interactive=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()
|
||||
|
||||
|
||||
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