Show job progress steps in web UI
This commit is contained in:
@@ -68,7 +68,7 @@ Basic example:
|
|||||||
|
|
||||||
### Gradio Web UI
|
### Gradio Web UI
|
||||||
|
|
||||||
Gradio 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 progress, and downloading finished videos:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.venv\Scripts\python.exe web_app.py
|
.venv\Scripts\python.exe web_app.py
|
||||||
@@ -80,6 +80,8 @@ The OpenAI-compatible translation endpoint, API key, and model can be changed in
|
|||||||
|
|
||||||
You can also upload a local `.mp4` instead of entering a YouTube URL. Uploaded videos are staged under `.cache/uploads` and processed with the same transcription, translation, dubbing, and render pipeline. Restricted YouTube videos can use the **Upload Cookies File** control instead of typing a local cookies path.
|
You can also upload a local `.mp4` instead of entering a YouTube URL. Uploaded videos are staged under `.cache/uploads` and processed with the same transcription, translation, dubbing, and render pipeline. Restricted YouTube videos can use the **Upload Cookies File** control instead of typing a local cookies path.
|
||||||
|
|
||||||
|
The web UI automatically refreshes job status, progress, steps, and output choices every few seconds while it is open. The manual **Refresh** button is still available.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
Build and run the Gradio UI in a container:
|
Build and run the Gradio UI in a container:
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import sys
|
|||||||
|
|
||||||
import web_app
|
import web_app
|
||||||
from web_app import (
|
from web_app import (
|
||||||
|
DubJob,
|
||||||
|
_job_progress,
|
||||||
_stage_uploaded_cookies,
|
_stage_uploaded_cookies,
|
||||||
build_pipeline_command,
|
build_pipeline_command,
|
||||||
create_app,
|
create_app,
|
||||||
@@ -123,3 +125,27 @@ def test_stage_uploaded_cookies_rejects_unsupported_extension(tmp_path):
|
|||||||
assert "Expected one of" in str(exc)
|
assert "Expected one of" in str(exc)
|
||||||
else:
|
else:
|
||||||
raise AssertionError("Expected ValueError for unsupported cookie upload")
|
raise AssertionError("Expected ValueError for unsupported cookie upload")
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_progress_tracks_pipeline_steps(tmp_path):
|
||||||
|
log_path = tmp_path / "job.log"
|
||||||
|
log_path.write_text("STEP 1: PREPARING CONTENT\nSTEP 2: SPEECH TRANSCRIPTION\n", encoding="utf-8")
|
||||||
|
job = DubJob(id="demo", command=[], log_path=log_path, status="running")
|
||||||
|
|
||||||
|
progress, steps_html = _job_progress(job)
|
||||||
|
|
||||||
|
assert progress == 25
|
||||||
|
assert "[done]" in steps_html
|
||||||
|
assert "[active]" in steps_html
|
||||||
|
assert "Speech transcription" in steps_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_progress_marks_succeeded_complete(tmp_path):
|
||||||
|
log_path = tmp_path / "job.log"
|
||||||
|
log_path.write_text("", encoding="utf-8")
|
||||||
|
job = DubJob(id="demo", command=[], log_path=log_path, status="succeeded")
|
||||||
|
|
||||||
|
progress, steps_html = _job_progress(job)
|
||||||
|
|
||||||
|
assert progress == 100
|
||||||
|
assert "[todo]" not in steps_html
|
||||||
|
|||||||
99
web_app.py
99
web_app.py
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
@@ -30,6 +31,16 @@ BASE_DIR = Path(__file__).resolve().parent
|
|||||||
LOG_DIR = BASE_DIR / "logs" / "gradio"
|
LOG_DIR = BASE_DIR / "logs" / "gradio"
|
||||||
SETTINGS_FILE = BASE_DIR / ".cache" / "web_settings.json"
|
SETTINGS_FILE = BASE_DIR / ".cache" / "web_settings.json"
|
||||||
UPLOAD_DIR = BASE_DIR / ".cache" / "uploads"
|
UPLOAD_DIR = BASE_DIR / ".cache" / "uploads"
|
||||||
|
PIPELINE_STEPS = [
|
||||||
|
("STEP 1", "Preparing content"),
|
||||||
|
("STEP 2", "Speech transcription"),
|
||||||
|
("STEP 3", "Intelligent chunking"),
|
||||||
|
("STEP 4", "Translation"),
|
||||||
|
("STEP 5", "Dub audio synthesis"),
|
||||||
|
("STEP 6", "Subtitle generation"),
|
||||||
|
("STEP 7", "Audio bed preparation"),
|
||||||
|
("STEP 8", "Final video rendering"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -237,6 +248,60 @@ def _read_log_tail(log_path: Path, max_chars: int = 20000) -> str:
|
|||||||
return text[-max_chars:]
|
return text[-max_chars:]
|
||||||
|
|
||||||
|
|
||||||
|
def _job_progress(job: DubJob | None) -> tuple[int, str]:
|
||||||
|
"""Return a coarse progress percentage and HTML step summary."""
|
||||||
|
if job is None:
|
||||||
|
return 0, _render_steps_html(0, "queued")
|
||||||
|
|
||||||
|
log_text = _read_log_tail(job.log_path)
|
||||||
|
current_step = 0
|
||||||
|
for index, (marker, _) in enumerate(PIPELINE_STEPS, start=1):
|
||||||
|
if marker in log_text:
|
||||||
|
current_step = index
|
||||||
|
|
||||||
|
if job.status == "succeeded":
|
||||||
|
return 100, _render_steps_html(len(PIPELINE_STEPS), job.status)
|
||||||
|
|
||||||
|
progress = int((current_step / len(PIPELINE_STEPS)) * 100)
|
||||||
|
if job.status == "running" and progress == 0:
|
||||||
|
progress = 3
|
||||||
|
return progress, _render_steps_html(current_step, job.status)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_steps_html(current_step: int, status: str) -> str:
|
||||||
|
rows = []
|
||||||
|
failed = status == "failed"
|
||||||
|
for index, (_, label) in enumerate(PIPELINE_STEPS, start=1):
|
||||||
|
if failed and index == max(current_step, 1):
|
||||||
|
state = "failed"
|
||||||
|
elif index < current_step or status == "succeeded":
|
||||||
|
state = "done"
|
||||||
|
elif index == current_step and status in {"queued", "running"}:
|
||||||
|
state = "active"
|
||||||
|
else:
|
||||||
|
state = "todo"
|
||||||
|
|
||||||
|
rows.append(
|
||||||
|
"<li>"
|
||||||
|
f"<strong>[{html.escape(state)}]</strong> "
|
||||||
|
f"{index}. {html.escape(label)}"
|
||||||
|
"</li>"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "<ul>" + "".join(rows) + "</ul>"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_progress_html(progress: int) -> str:
|
||||||
|
bounded_progress = max(0, min(100, int(progress)))
|
||||||
|
return (
|
||||||
|
"<div>"
|
||||||
|
"<label><strong>Progress</strong></label>"
|
||||||
|
f"<progress value='{bounded_progress}' max='100' style='width: 100%; height: 24px;'></progress>"
|
||||||
|
f"<div>{bounded_progress}%</div>"
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _run_job(job: DubJob) -> None:
|
def _run_job(job: DubJob) -> None:
|
||||||
with JOBS_LOCK:
|
with JOBS_LOCK:
|
||||||
job.status = "running"
|
job.status = "running"
|
||||||
@@ -290,7 +355,7 @@ def _start_job(
|
|||||||
lmstudio_api_key: str,
|
lmstudio_api_key: str,
|
||||||
lmstudio_model: str,
|
lmstudio_model: str,
|
||||||
gpu: bool,
|
gpu: bool,
|
||||||
) -> tuple[str, str, str, gr.Dropdown]:
|
) -> tuple[str, str, str, str, gr.Dropdown]:
|
||||||
saved_settings = load_translation_settings()
|
saved_settings = load_translation_settings()
|
||||||
base_url = (lmstudio_base_url or "").strip() or saved_settings["base_url"]
|
base_url = (lmstudio_base_url or "").strip() or saved_settings["base_url"]
|
||||||
api_key = (lmstudio_api_key or "").strip() or saved_settings["api_key"]
|
api_key = (lmstudio_api_key or "").strip() or saved_settings["api_key"]
|
||||||
@@ -299,12 +364,12 @@ def _start_job(
|
|||||||
input_file = _stage_uploaded_mp4(uploaded_mp4)
|
input_file = _stage_uploaded_mp4(uploaded_mp4)
|
||||||
except (OSError, ValueError) as exc:
|
except (OSError, ValueError) as exc:
|
||||||
message = str(exc) or "Invalid uploaded MP4."
|
message = str(exc) or "Invalid uploaded MP4."
|
||||||
return "", message, message, gr.update(choices=_output_choices())
|
return "", message, _render_progress_html(0), _render_steps_html(0, "failed"), gr.update(choices=_output_choices())
|
||||||
try:
|
try:
|
||||||
cookies = _stage_uploaded_cookies(cookies_upload)
|
cookies = _stage_uploaded_cookies(cookies_upload)
|
||||||
except (OSError, ValueError) as exc:
|
except (OSError, ValueError) as exc:
|
||||||
message = str(exc) or "Invalid uploaded cookies file."
|
message = str(exc) or "Invalid uploaded cookies file."
|
||||||
return "", message, message, gr.update(choices=_output_choices())
|
return "", message, _render_progress_html(0), _render_steps_html(0, "failed"), gr.update(choices=_output_choices())
|
||||||
|
|
||||||
form = {
|
form = {
|
||||||
"url": url,
|
"url": url,
|
||||||
@@ -324,7 +389,7 @@ def _start_job(
|
|||||||
command = build_pipeline_command(form)
|
command = build_pipeline_command(form)
|
||||||
except (SystemExit, ValueError) as exc:
|
except (SystemExit, ValueError) as exc:
|
||||||
message = str(exc) or "Invalid job options."
|
message = str(exc) or "Invalid job options."
|
||||||
return "", message, message, gr.update(choices=_output_choices())
|
return "", message, _render_progress_html(0), _render_steps_html(0, "failed"), gr.update(choices=_output_choices())
|
||||||
|
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
job_id = uuid.uuid4().hex[:12]
|
job_id = uuid.uuid4().hex[:12]
|
||||||
@@ -344,17 +409,19 @@ def _start_job(
|
|||||||
|
|
||||||
thread = threading.Thread(target=_run_job, args=(job,), daemon=True)
|
thread = threading.Thread(target=_run_job, args=(job,), daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
return job.id, _format_job_status(job), _read_log_tail(job.log_path), gr.update(choices=_output_choices())
|
progress_value, steps_html = _job_progress(job)
|
||||||
|
return job.id, _format_job_status(job), _render_progress_html(progress_value), steps_html, gr.update(choices=_output_choices())
|
||||||
|
|
||||||
|
|
||||||
def _refresh_job(job_id: str) -> tuple[str, str, gr.Dropdown]:
|
def _refresh_job(job_id: str) -> tuple[str, str, str, gr.Dropdown]:
|
||||||
with JOBS_LOCK:
|
with JOBS_LOCK:
|
||||||
job = JOBS.get(job_id)
|
job = JOBS.get(job_id)
|
||||||
|
|
||||||
if job is None:
|
if job is None:
|
||||||
return "Ready", "No job selected.", gr.update(choices=_output_choices())
|
return "Ready", _render_progress_html(0), _render_steps_html(0, "queued"), gr.update(choices=_output_choices())
|
||||||
|
|
||||||
return _format_job_status(job), _read_log_tail(job.log_path), gr.update(choices=_output_choices())
|
progress_value, steps_html = _job_progress(job)
|
||||||
|
return _format_job_status(job), _render_progress_html(progress_value), steps_html, gr.update(choices=_output_choices())
|
||||||
|
|
||||||
|
|
||||||
def _select_output(filename: str | None) -> str | None:
|
def _select_output(filename: str | None) -> str | None:
|
||||||
@@ -374,10 +441,11 @@ def create_app() -> gr.Blocks:
|
|||||||
gr.Markdown(
|
gr.Markdown(
|
||||||
"""
|
"""
|
||||||
# YouTube Auto Dub
|
# YouTube Auto Dub
|
||||||
Start local dubbing jobs, watch the pipeline log, and collect finished videos.
|
Start local dubbing jobs, watch progress, and collect finished videos.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
job_id = gr.State("")
|
job_id = gr.State("")
|
||||||
|
log_timer = gr.Timer(value=2.0, active=True)
|
||||||
|
|
||||||
with gr.Row():
|
with gr.Row():
|
||||||
with gr.Column(scale=5):
|
with gr.Column(scale=5):
|
||||||
@@ -440,12 +508,8 @@ def create_app() -> gr.Blocks:
|
|||||||
|
|
||||||
with gr.Column(scale=7):
|
with gr.Column(scale=7):
|
||||||
status = gr.Textbox(label="Job Status", value="Ready", lines=5, interactive=False)
|
status = gr.Textbox(label="Job Status", value="Ready", lines=5, interactive=False)
|
||||||
log = gr.Textbox(
|
progress = gr.HTML(value=_render_progress_html(0))
|
||||||
label="Run Log",
|
steps = gr.HTML(label="Steps", value=_render_steps_html(0, "queued"))
|
||||||
value="No jobs yet.",
|
|
||||||
lines=20,
|
|
||||||
interactive=False,
|
|
||||||
)
|
|
||||||
refresh = gr.Button("Refresh")
|
refresh = gr.Button("Refresh")
|
||||||
|
|
||||||
with gr.Row():
|
with gr.Row():
|
||||||
@@ -473,9 +537,10 @@ def create_app() -> gr.Blocks:
|
|||||||
start.click(
|
start.click(
|
||||||
_start_job,
|
_start_job,
|
||||||
inputs=inputs,
|
inputs=inputs,
|
||||||
outputs=[job_id, status, log, output_choice],
|
outputs=[job_id, status, progress, steps, output_choice],
|
||||||
)
|
)
|
||||||
refresh.click(_refresh_job, inputs=[job_id], outputs=[status, log, output_choice])
|
refresh.click(_refresh_job, inputs=[job_id], outputs=[status, progress, steps, output_choice])
|
||||||
|
log_timer.tick(_refresh_job, inputs=[job_id], outputs=[status, progress, steps, output_choice])
|
||||||
output_choice.change(_select_output, inputs=[output_choice], outputs=[output_file])
|
output_choice.change(_select_output, inputs=[output_choice], outputs=[output_file])
|
||||||
|
|
||||||
return demo
|
return demo
|
||||||
|
|||||||
Reference in New Issue
Block a user