fix(ai-outfit): 停止生成打断当前目录行 + 「停止中…」反馈 (§19.24)

修「开始生成→几秒后停止→两个按钮都灰几十秒像卡死」:非死锁,是温和停止
在等当前目录行收尾,而 _generate_directory_outfit 一次性 submit 全部图片、
shutdown(wait=True) 等全跑完,停止信号进不到该循环;且 _stop 后无反馈。

- outfit_batch:run() 经 _accepts_should_stop 给 generate_func 传 should_stop
  (= _stop_event.is_set),2-arg 才传、1-arg 仍兼容(含既有 mock)
- ai_outfit:generate_outfit_image/_generate_directory_outfit 增 should_stop;
  目录循环改有界提交——始终最多 image_concurrency 张在飞,每张完成后、提交
  下一张前查 should_stop,已停止则停止提交、在飞收尾即返回(结果注明
  「已停止,N 张未生成」,已生成的带 output_paths)
- 面板:_OutfitWorker.gen(task, should_stop) 透传;_stop() 把停止按钮改
  「停止中…」;_set_running(False) 复位「停止生成」

测试:目录行 should_stop 触发后只生成到停止点、返回「已停止」结果;2-arg
generate_func 收到 should_stop;既有用例(1-arg mock、目录并发/部分失败)保持绿。
全套 py37 通过(test_config_service 的 packaging 模板失败属并行 §19.13,无关)。
离屏冒烟:stop 截断 run 且 runner 及时返回;面板停止按钮「停止中…」→结束复位。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 16:46:49 +08:00
co-authored by Claude Opus 4.8
parent 61d8e20d86
commit 7313c8a443
7 changed files with 136 additions and 18 deletions
+6 -2
View File
@@ -108,15 +108,17 @@ class _OutfitWorker(QObject):
self.finished.emit(OutfitBatchSummary(total=0))
return
def gen(task):
def gen(task, should_stop=None):
# request_interval/image_log pace and narrate directory rows that
# fan out into many images (docs/11 §9.1); single-file rows ignore them.
# should_stop lets a directory row halt remaining images on 停止 (§19.24).
return generate_outfit_image(
task, self._prompt, self._output_dir, self._model_config,
quality=self._quality, resolution=self._resolution,
request_interval=self._options.request_interval,
image_concurrency=self._options.concurrency,
image_log=self.log.emit,
should_stop=should_stop,
)
def on_progress(completed, total, result):
@@ -1135,8 +1137,9 @@ class AiOutfitPanel(QWidget):
def _stop(self):
if self._worker is not None:
self._worker.stop()
self._append_log("已请求停止:不再提交新任务,进行中的任务会收尾。")
self._append_log("已请求停止:不再提交新行,当前行的剩余图片会停止提交、在飞的收尾后结束。")
self._stop_btn.setEnabled(False)
self._stop_btn.setText("停止中…")
def _selected_model_config(self):
if not self._models:
@@ -1155,6 +1158,7 @@ class AiOutfitPanel(QWidget):
def _set_running(self, running):
self._start_btn.setEnabled(not running)
self._stop_btn.setEnabled(running)
self._stop_btn.setText("停止生成") # reset 「停止中…」 (§19.24)
self._excel_edit.setEnabled(not running)
self._model_combo.setEnabled(not running and bool(self._models))
self._title_btn.setEnabled(not running) # mutually exclusive with 生成标题
+44 -13
View File
@@ -3,7 +3,7 @@ import os
import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from io import BytesIO
from pathlib import Path
@@ -148,13 +148,15 @@ def generate_outfit_image(
request_interval=0.0,
image_concurrency=1,
image_log=None,
should_stop=None,
):
"""Generate outfit image(s) for one Excel row and return OutfitResult.
If task.garment_path is a directory (docs/11 §4.1), generate one image per
source picture into output_dir/<dir leaf>/ and return a single aggregated
result. Otherwise generate one image to output_dir/<product_id>.jpg. Never
raises.
raises. should_stop: optional callable -> bool; a directory row stops
submitting remaining images once it returns True (docs/11 §8 / §19.24).
"""
if not isinstance(task, OutfitTask):
raise TypeError("task must be OutfitTask")
@@ -164,7 +166,7 @@ def generate_outfit_image(
task, prompt_template, output_dir, model_config,
quality=quality, resolution=resolution, api_client=api_client,
request_interval=request_interval, image_concurrency=image_concurrency,
image_log=image_log,
image_log=image_log, should_stop=should_stop,
)
try:
@@ -204,6 +206,7 @@ def _generate_directory_outfit(
request_interval,
image_concurrency,
image_log,
should_stop=None,
):
"""Fan one directory row out into per-image generations (docs/11 §4.1/§9.1).
@@ -256,20 +259,48 @@ def _generate_directory_outfit(
task.row_index, index, total, image_path.name, exc))
return False, "", "{}:{}".format(image_path.name, exc)
def stop_now():
return bool(should_stop and should_stop())
total = len(images)
items = list(enumerate(images, start=1)) # (index, image_path), in name order
next_idx = 0
outputs = []
failures = []
stopped = False
# Bounded submission: keep at most `workers` in flight; before topping up,
# check should_stop so a 停止 request halts the remaining images (§19.24).
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = [
executor.submit(generate_one, index, image_path)
for index, image_path in enumerate(images, start=1)
]
for future in as_completed(futures):
ok, output, error = future.result()
if ok:
outputs.append(output)
else:
failures.append(error)
futures = set()
while next_idx < total and len(futures) < workers and not stop_now():
i, image_path = items[next_idx]; next_idx += 1
futures.add(executor.submit(generate_one, i, image_path))
while futures:
done, futures = wait(futures, return_when=FIRST_COMPLETED)
futures = set(futures)
for future in done:
ok, output, error = future.result()
if ok:
outputs.append(output)
else:
failures.append(error)
if stop_now():
stopped = True # stop topping up; let in-flight finish
continue
while next_idx < total and len(futures) < workers:
i, image_path = items[next_idx]; next_idx += 1
futures.add(executor.submit(generate_one, i, image_path))
not_generated = total - (len(outputs) + len(failures))
if stopped and not_generated > 0:
segs = []
if failures:
segs.append("{} 张失败:{}".format(len(failures), ";".join(failures)))
segs.append("已停止,{} 张未生成".format(not_generated))
error = "{} 张中 ".format(total) + ",".join(segs)
logger.warning("Outfit dir row %s stopped: %s", task.row_index, error)
return OutfitResult(task=task, success=False, output_path=subdir_path,
error=error, attempts=1, output_paths=outputs)
if failures:
error = "{} 张中 {} 张失败:{}".format(total, len(failures), ";".join(failures))
+23 -1
View File
@@ -1,3 +1,4 @@
import inspect
import logging
import threading
import time
@@ -76,6 +77,9 @@ class OutfitBatchRunner:
):
self.tasks = list(tasks)
self.generate_func = generate_func
# Pass should_stop to generate_func only if it accepts a 2nd arg, so
# legacy 1-arg generate_func (and test mocks) keep working (§19.24).
self._gen_wants_stop = _accepts_should_stop(generate_func)
self.options = options or OutfitBatchOptions()
self.progress_callback = progress_callback
self.log_callback = log_callback
@@ -170,7 +174,10 @@ class OutfitBatchRunner:
for attempt in range(1, attempts_allowed + 1):
if self._stop_event.is_set() and attempt > 1:
break
result = self.generate_func(task)
if self._gen_wants_stop:
result = self.generate_func(task, self._stop_event.is_set)
else:
result = self.generate_func(task)
result.attempts = attempt
if result.success:
if self.options.task_cooldown > 0:
@@ -215,6 +222,21 @@ class _RunningTask:
last_heartbeat_at: float = 0.0
def _accepts_should_stop(func):
"""True if func can take a 2nd positional arg (the should_stop callable)."""
try:
params = inspect.signature(func).parameters.values()
except (TypeError, ValueError):
return False
positional = 0
for p in params:
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD):
positional += 1
elif p.kind == p.VAR_POSITIONAL:
return True
return positional >= 2
def retry_delay_seconds(error, attempt):
"""Short stepped retry delay; rate-limit errors back off more."""
text = str(error or "").lower()