feat(ai-studio): resume hosted image jobs

This commit is contained in:
chengma
2026-07-11 14:29:58 +08:00
parent cddadf6364
commit dcaf68fd07
9 changed files with 240 additions and 7 deletions
+1
View File
@@ -28,6 +28,7 @@ if QT_IMPORT_ERROR is None:
ImageStudioExportWorker,
ImageStudioGenerateJobsWorker,
ImageStudioPullImagesWorker,
ImageStudioResumeJobsWorker,
WriteBackWorker,
)
from .tabs.accounts import AccountDialog, AccountsTab
+56 -1
View File
@@ -16,6 +16,7 @@ from ..workers import (
from ..workers import ImageStudioExportWorker as _RealImageStudioExportWorker
from ..workers import ImageStudioGenerateJobsWorker as _RealImageStudioGenerateJobsWorker
from ..workers import ImageStudioPullImagesWorker as _RealImageStudioPullImagesWorker
from ..workers import ImageStudioResumeJobsWorker as _RealImageStudioResumeJobsWorker
ASSET_MIME_TYPE = "application/x-cmshopee-image-studio-asset"
@@ -57,6 +58,15 @@ def ImageStudioExportWorker(*args, **kwargs):
)
def ImageStudioResumeJobsWorker(*args, **kwargs):
return _call_package_attr(
"ImageStudioResumeJobsWorker",
_RealImageStudioResumeJobsWorker,
*args,
**kwargs,
)
def _drop_event_position(event):
if hasattr(event, "position"):
return event.position().toPoint()
@@ -432,10 +442,13 @@ class ImageStudioTab(QWidget):
action_layout = QHBoxLayout()
self.start_button = QPushButton("开始生成")
self.start_button.setObjectName("imageStudioStartButton")
self.resume_button = QPushButton("继续查询任务")
self.resume_button.setObjectName("imageStudioResumeButton")
self.stop_button = QPushButton("停止")
self.stop_button.setObjectName("imageStudioStopButton")
self.stop_button.setEnabled(False)
action_layout.addWidget(self.start_button)
action_layout.addWidget(self.resume_button)
action_layout.addWidget(self.stop_button)
layout.addLayout(action_layout)
@@ -512,6 +525,7 @@ class ImageStudioTab(QWidget):
self.template_delete_button.clicked.connect(self.delete_template)
self.prompt_edit.textChanged.connect(self._save_project_prompt)
self.start_button.clicked.connect(self.start_generation)
self.resume_button.clicked.connect(self.resume_generation_jobs)
self.stop_button.clicked.connect(self.stop_generation)
self.export_button.clicked.connect(self.export_selections)
@@ -730,7 +744,7 @@ class ImageStudioTab(QWidget):
values = [
"任务",
"-",
self.JOB_STATUS_LABELS.get(obj.status, obj.status),
_job_status_text(obj, self.JOB_STATUS_LABELS),
f"源图 #{obj.source_asset_id or '-'}",
obj.error or "",
]
@@ -1055,6 +1069,35 @@ class ImageStudioTab(QWidget):
self._append_log("[AI工场] 已请求停止,正在等待安全边界")
self._status("AI工场生成已请求停止", "warning")
def resume_generation_jobs(self, checked=False):
if self.current_project is None:
self._message("项目未打开", "请先打开一个AI工场项目。")
return
resumable = image_studio.list_resumable_jobs(
path=self.db_path,
project_id=self.current_project.id,
include_failed_downloads=True,
)
if not resumable:
self._status("当前项目没有可继续查询的 cmhub 生图任务", "muted")
self._message("没有可继续查询任务", "当前项目没有已提交、生成中或下载失败的 cmhub 生图任务。")
return
self.progress_bar.setRange(0, len(resumable))
self.progress_bar.setValue(0)
self._append_log(f"[AI工场] 继续查询 {len(resumable)} 个已提交任务")
worker = ImageStudioResumeJobsWorker(
project_id=self.current_project.id,
aspect_ratio=self.aspect_combo.currentData(),
db_path=self.db_path,
config=self.config,
cmhub_config_path=self.cmhub_config_path,
)
worker.progress.connect(self._on_generate_progress)
worker.log.connect(self._append_log)
worker.finished.connect(self._on_generation_finished)
worker.failed.connect(self._on_worker_failed)
self._start_worker(worker, "AI工场继续查询")
def export_selections(self, checked=False):
if self.current_project is None:
self._message("项目未打开", "请先打开一个AI工场项目。")
@@ -1203,6 +1246,7 @@ class ImageStudioTab(QWidget):
self.count_spin.setEnabled(not running)
self.aspect_combo.setEnabled(not running)
self.start_button.setEnabled(not running)
self.resume_button.setEnabled(not running)
self.stop_button.setEnabled(running)
self.export_button.setEnabled(not running)
@@ -1380,3 +1424,14 @@ def _selection_tooltip(selection_type, asset):
f"{_selection_label(selection_type)}:{_asset_badge(getattr(asset, 'kind', ''))} "
f"#{getattr(asset, 'id', '')},比例 {getattr(asset, 'aspect_ratio', None) or '未知'}"
)
def _job_status_text(job, labels):
parts = [labels.get(job.status, job.status)]
if job.points_cost is not None:
parts.append(f"扣点{job.points_cost}")
if job.points_balance is not None:
parts.append(f"余额{job.points_balance}")
if job.call_id:
parts.append(f"call_id={job.call_id}")
return ",".join(parts)
+47
View File
@@ -192,6 +192,53 @@ class ImageStudioGenerateJobsWorker(BaseWorker):
return summary
class ImageStudioResumeJobsWorker(BaseWorker):
"""Resume submitted/running or failed-download AI studio jobs."""
def __init__(
self,
*,
project_id=None,
aspect_ratio="1:1",
db_path=None,
config=None,
cmhub_config_path=None,
):
super().__init__()
self.project_id = int(project_id) if project_id is not None else None
self.aspect_ratio = str(aspect_ratio or "1:1")
self.db_path = db_path
self.config = config
self.cmhub_config_path = cmhub_config_path
self._done = 0
self._failed = 0
self._lock = threading.Lock()
def execute(self):
def on_event(payload):
event = dict(payload or {})
self.log.emit(_format_image_studio_event(event))
if event.get("step") == "job_done":
with self._lock:
self._done += 1
if event.get("result") not in {"success"}:
self._failed += 1
progress = {"done": self._done, "failed": self._failed}
self.progress.emit(progress)
summary = image_studio_generation.resume_image_jobs(
project_id=self.project_id,
aspect_ratio=self.aspect_ratio,
config=self.config,
cmhub_config_path=self.cmhub_config_path,
path=self.db_path,
should_stop=self.should_cancel,
on_event=on_event,
)
summary["project_id"] = self.project_id
return summary
class ImageStudioExportWorker(BaseWorker):
"""Export AI studio final selections to local JPEG files."""
+10 -3
View File
@@ -738,9 +738,16 @@ def update_job_status(
return get_job(job_id, conn=database)
def list_resumable_jobs(path=None, conn=None, project_id=None):
clauses = ["status IN (?, ?)", "task_id IS NOT NULL"]
params = ["submitted", "running"]
def list_resumable_jobs(path=None, conn=None, project_id=None, include_failed_downloads=False):
if include_failed_downloads:
clauses = [
"task_id IS NOT NULL",
"(status IN (?, ?) OR (status = ? AND output_asset_id IS NULL))",
]
params = ["submitted", "running", "failed"]
else:
clauses = ["status IN (?, ?)", "task_id IS NOT NULL"]
params = ["submitted", "running"]
if project_id is not None:
clauses.append("project_id = ?")
params.append(int(project_id))
+5 -1
View File
@@ -120,7 +120,11 @@ def resume_image_jobs(
should_stop=None,
on_event=None,
):
jobs = image_studio.list_resumable_jobs(path=path, project_id=project_id)
jobs = image_studio.list_resumable_jobs(
path=path,
project_id=project_id,
include_failed_downloads=True,
)
return run_jobs(
jobs,
aspect_ratio=aspect_ratio,