Files
cmshoppe/app/image_studio_generation.py
T

547 lines
18 KiB
Python

"""cmhub hosted generation orchestration for AI image studio jobs."""
from __future__ import annotations
import os
import threading
import time
import urllib.parse
from concurrent.futures import CancelledError, FIRST_COMPLETED, ThreadPoolExecutor, wait
from . import ai, appconfig, image_studio
from .version import APP_VERSION
MAX_CMHUB_IMAGE_STUDIO_WORKERS = 5
_GLOBAL_IMAGE_STUDIO_SLOTS = threading.BoundedSemaphore(MAX_CMHUB_IMAGE_STUDIO_WORKERS)
CMHUB_IMAGE_STUDIO_MAX_INPUT_IMAGES = 8
CMHUB_IMAGE_STUDIO_MAX_SINGLE_INPUT_BYTES = 10 * 1024 * 1024
CMHUB_IMAGE_STUDIO_MAX_TOTAL_INPUT_BYTES = 32 * 1024 * 1024
class ImageStudioGenerationError(RuntimeError):
"""Raised when AI studio hosted generation cannot complete."""
def _notify(callback, event):
if callback is None:
return
try:
callback(dict(event))
except Exception:
pass
def _runtime(config, cmhub_config_path):
return ai._cmhub_runtime(config, "image", cmhub_config_path)
def _generated_kind(job_type):
return "generated_detail" if str(job_type) == "detail" else "generated_main"
def _output_path(project, job, image_root):
dirs = image_studio.project_image_dirs(image_root, project)
filename = "job_%06d_%s.jpg" % (int(job.id), _generated_kind(job.job_type))
return os.path.join(dirs["generated"], filename)
def _source_path(source_asset):
path = str(getattr(source_asset, "local_path", "") or "").strip()
if not path or not os.path.isfile(path):
raise ImageStudioGenerationError("源图尚未下载到本地,不能生成")
return path
def _build_cmhub_images(source_asset, reference_assets=()):
"""Build the ordered cmhub image input array from local image assets."""
candidates = [source_asset] + list(reference_assets or [])
selected = candidates[:CMHUB_IMAGE_STUDIO_MAX_INPUT_IMAGES]
omitted_count = max(0, len(candidates) - len(selected))
images = []
total_bytes = 0
for index, asset in enumerate(selected, 1):
source_path = _source_path(asset)
file_size = os.path.getsize(source_path)
if file_size > CMHUB_IMAGE_STUDIO_MAX_SINGLE_INPUT_BYTES:
role = "主图" if index == 1 else "参考图"
raise ImageStudioGenerationError("商品套图%s超过10MiB,不能提交" % role)
data_url = ai._image_data_url(source_path)
total_bytes += len(data_url.encode("utf-8"))
if total_bytes > CMHUB_IMAGE_STUDIO_MAX_TOTAL_INPUT_BYTES:
raise ImageStudioGenerationError(
"商品套图提交图片总大小超过32MiB,请减少图片或更换较小原图"
)
images.append({"image_base64": data_url})
if not images:
raise ImageStudioGenerationError("商品套图至少需要一张本地原图")
return images, omitted_count
def create_generation_jobs(
project_id,
source_asset_id,
prompt,
count,
*,
job_type="main",
path=None,
):
total = max(0, int(count or 0))
if total <= 0:
raise ImageStudioGenerationError("生成数量必须大于0")
jobs = []
for _ in range(total):
jobs.append(
image_studio.create_job(
project_id,
source_asset_id=source_asset_id,
job_type=job_type,
prompt=prompt,
generation_source="cmhub",
provider="cmhub",
path=path,
)
)
return jobs
def generate_image_jobs(
project_id,
source_asset_id,
prompt,
count,
*,
job_type="main",
aspect_ratio="1:1",
config=None,
cmhub_config_path=None,
path=None,
should_stop=None,
on_event=None,
):
jobs = create_generation_jobs(
project_id,
source_asset_id,
prompt,
count,
job_type=job_type,
path=path,
)
return run_jobs(
jobs,
aspect_ratio=aspect_ratio,
config=config,
cmhub_config_path=cmhub_config_path,
path=path,
should_stop=should_stop,
on_event=on_event,
)
def resume_image_jobs(
*,
project_id=None,
aspect_ratio="1:1",
config=None,
cmhub_config_path=None,
path=None,
should_stop=None,
on_event=None,
):
jobs = image_studio.list_resumable_jobs(
path=path,
project_id=project_id,
include_failed_downloads=True,
)
return run_jobs(
jobs,
aspect_ratio=aspect_ratio,
config=config,
cmhub_config_path=cmhub_config_path,
path=path,
should_stop=should_stop,
on_event=on_event,
)
def run_jobs(
jobs,
*,
aspect_ratio="1:1",
config=None,
cmhub_config_path=None,
path=None,
should_stop=None,
on_event=None,
):
cfg = appconfig.load_config() if config is None else config
job_list = list(jobs or [])
if not job_list:
return {"total": 0, "success": 0, "failed": 0, "cancelled": 0, "jobs": []}
runtime = _runtime(cfg, cmhub_config_path)
ai_cfg = appconfig.ai_config(cfg)
image_root = appconfig.image_dir(cfg)
max_workers = min(MAX_CMHUB_IMAGE_STUDIO_WORKERS, max(1, int(ai_cfg.get("image_concurrency", 1) or 1)), len(job_list))
should_stop = should_stop or (lambda: False)
lock = threading.Lock()
summary = {"total": len(job_list), "success": 0, "failed": 0, "cancelled": 0, "jobs": []}
def record(result):
with lock:
summary["jobs"].append(result)
if result.get("status") == "succeeded":
summary["success"] += 1
elif result.get("status") == "cancelled":
summary["cancelled"] += 1
else:
summary["failed"] += 1
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
_run_one_job_with_global_slot,
job.id,
runtime,
cfg,
image_root,
aspect_ratio,
path,
should_stop,
on_event,
): job
for job in job_list
}
while futures:
done, _ = wait(
set(futures),
timeout=0.2,
return_when=FIRST_COMPLETED,
)
for future in done:
futures.pop(future)
try:
record(future.result())
except Exception as exc:
record({"status": "failed", "error": str(exc)})
if should_stop():
for future, job in list(futures.items()):
if future.cancel():
image_studio.update_job_status(
job.id,
"cancelled",
error="用户停止",
recovery_action=_recovery_action_for_job(job),
path=path,
)
futures.pop(future, None)
record({"job": job, "status": "cancelled", "error": "用户停止"})
return summary
def _run_one_job_with_global_slot(*args):
with _GLOBAL_IMAGE_STUDIO_SLOTS:
return _run_one_job(*args)
def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, should_stop, on_event):
job = image_studio.get_job(job_id, path=db_path)
if job is None:
raise ImageStudioGenerationError("AI工场生图任务不存在")
if should_stop():
updated = image_studio.update_job_status(
job.id,
"cancelled",
error="用户停止",
recovery_action=_recovery_action_for_job(job),
path=db_path,
)
return {"job": updated, "status": "cancelled", "error": "用户停止"}
project = image_studio.get_project(job.project_id, path=db_path)
source_asset = image_studio.get_asset(job.source_asset_id, path=db_path)
if project is None or source_asset is None:
updated = image_studio.update_job_status(
job.id,
"failed",
error="项目或源图不存在",
recovery_action=image_studio.JOB_RECOVERY_REGENERATE,
path=db_path,
)
return {"job": updated, "status": "failed", "error": "项目或源图不存在"}
try:
image_studio.update_job_status(job.id, "running", path=db_path)
request_result = _submit_or_resume_job(
job,
source_asset,
runtime,
config,
aspect_ratio,
db_path,
should_stop,
on_event,
)
image_studio.update_job_status(job.id, "running", path=db_path)
request_result = _poll_job(job.id, request_result["task_id"], runtime, request_result, db_path, should_stop, on_event)
_raise_if_stopped(should_stop)
out_path = _output_path(project, job, image_root)
saved_path = _download_and_save_job_image(
request_result,
out_path,
config,
on_event,
job.id,
should_stop=should_stop,
)
try:
_raise_if_stopped(should_stop)
except ImageStudioGenerationError:
try:
if os.path.isfile(saved_path):
os.remove(saved_path)
finally:
raise
asset = image_studio.add_asset(
project.id,
_generated_kind(job.job_type),
remote_url=request_result["image_url"],
local_path=saved_path,
aspect_ratio=aspect_ratio,
parent_asset_id=source_asset.id,
prompt=job.prompt,
path=db_path,
)
updated = image_studio.update_job_status(
job.id,
"succeeded",
output_asset_id=asset.id,
points_balance=request_result.get("points_balance"),
path=db_path,
)
_notify(on_event, {"job_id": job.id, "step": "job_done", "result": "success"})
return {"job": updated, "asset": asset, "status": "succeeded"}
except Exception as exc:
cancelled = isinstance(exc, CancelledError) or "停止" in str(exc)
status = "cancelled" if cancelled else "failed"
error = "用户已停止,已提交任务可稍后继续查询" if cancelled else str(exc)
current_job = image_studio.get_job(job.id, path=db_path)
updated = image_studio.update_job_status(
job.id,
status,
error=error,
recovery_action=_recovery_action_for_job(current_job),
path=db_path,
)
_notify(
on_event,
{
"job_id": job.id,
"step": "job_done",
"result": status,
"detail": error,
},
)
return {"job": updated, "status": status, "error": error}
def _submit_or_resume_job(
job,
source_asset,
runtime,
config,
aspect_ratio,
db_path,
should_stop,
on_event,
reference_assets=(),
):
if job.task_id:
_notify(on_event, {"job_id": job.id, "step": "cover_request", "result": "resume", "task_id": job.task_id})
return _request_result(job.task_id, runtime, config)
_raise_if_stopped(should_stop)
ai_cfg = appconfig.ai_config(config)
resolution = str(ai_cfg.get("resolution", "1k") or "1k")
images, omitted_count = _build_cmhub_images(source_asset, reference_assets)
if omitted_count:
_notify(
on_event,
{
"job_id": job.id,
"step": "cover_submit",
"result": "warning",
"detail": "图片最多提交8张,已忽略%d张参考图" % omitted_count,
},
)
payload = {
"prompt": str(job.prompt or ""),
"model": runtime["alias"],
"images": images,
"resolution": ai._normalize_cmhub_resolution(resolution),
"aspect_ratio": str(aspect_ratio or "1:1"),
}
_notify(on_event, {"job_id": job.id, "step": "cover_submit", "result": "start"})
data = ai._cmhub_call_with_retry(
"POST",
appconfig.cmhub_request_url(runtime["base_url"], "/api/v1/generate/image/tasks"),
runtime["api_key"],
payload=payload,
connect_timeout=runtime["connect_timeout"],
read_timeout=ai.CMHUB_IMAGE_SUBMIT_READ_TIMEOUT_SECONDS,
attempts=max(1, int(ai_cfg.get("retry", 0) or 0) + 1),
headers_extra={
"Idempotency-Key": job.task_key,
"X-Client-Version": str(APP_VERSION),
},
)
image_task_id = str(data.get("task_id") or "").strip()
if not image_task_id:
raise ImageStudioGenerationError("cmhub 生图任务提交返回格式错误")
submitted = image_studio.set_job_submitted(
job.id,
image_task_id,
call_id=data.get("call_id"),
points_cost=data.get("points_cost"),
points_balance=data.get("points_balance"),
path=db_path,
)
_notify(
on_event,
{
"job_id": job.id,
"step": "cover_submit",
"result": "success",
"task_id": submitted.task_id,
"call_id": submitted.call_id,
"points_cost": submitted.points_cost,
"points_balance": submitted.points_balance,
},
)
return _request_result(image_task_id, runtime, config, points_balance=data.get("points_balance"))
def _request_result(task_id, runtime, config, points_balance=None):
ai_cfg = appconfig.ai_config(config)
resolution = str(ai_cfg.get("resolution", "1k") or "1k")
return {
"task_id": task_id,
"connect_timeout": runtime["connect_timeout"],
"read_timeout": ai.CMHUB_IMAGE_READ_TIMEOUT_SECONDS,
"resolution": resolution,
"quality": ai._jpg_quality(ai_cfg.get("jpg_quality", 90)),
"use_system_proxy": runtime["use_system_proxy"],
"download_with_curl": runtime["download_with_curl"],
"points_balance": points_balance,
}
def _poll_job(job_id, task_id, runtime, request_result, db_path, should_stop, on_event):
poll_url = appconfig.cmhub_request_url(
runtime["base_url"],
"/api/v1/generate/image/tasks/%s" % urllib.parse.quote(str(task_id), safe=""),
)
started = time.perf_counter()
deadline = started + max(1, int(ai.CMHUB_IMAGE_READ_TIMEOUT_SECONDS))
poll_index = 0
while True:
_raise_if_stopped(should_stop)
if time.perf_counter() >= deadline:
raise ImageStudioGenerationError("等待 cmhub 生图任务完成超时,下次可继续查询")
_notify(on_event, {"job_id": job_id, "step": "cover_poll", "result": "start", "task_id": task_id})
data = ai._cmhub_call_once(
"GET",
poll_url,
runtime["api_key"],
payload=None,
connect_timeout=runtime["connect_timeout"],
read_timeout=ai.CMHUB_IMAGE_POLL_READ_TIMEOUT_SECONDS,
headers_extra={"X-Client-Version": str(APP_VERSION)},
)
_raise_if_stopped(should_stop)
status = str(data.get("status") or "").strip().lower()
if status in {"queued", "running"}:
_notify(on_event, {"job_id": job_id, "step": "cover_poll", "result": status, "task_id": task_id})
ai._sleep_cmhub_poll(poll_index, should_stop)
poll_index += 1
continue
if status == "succeeded":
image_url = ai._extract_cmhub_image_url(data, runtime["base_url"])
if not image_url:
raise ImageStudioGenerationError("cmhub 生图任务成功但没有图片地址")
merged = dict(request_result)
merged["image_url"] = image_url
merged["points_balance"] = data.get("points_balance", merged.get("points_balance"))
_notify(on_event, {"job_id": job_id, "step": "cover_poll", "result": "success", "task_id": task_id})
return merged
if status in {"failed", "expired"}:
error = data.get("error") if isinstance(data.get("error"), dict) else {}
message = str(error.get("message") or error.get("code") or status)
image_studio.update_job_status(
job_id,
status,
error=message,
recovery_action=image_studio.JOB_RECOVERY_REGENERATE,
path=db_path,
)
raise ImageStudioGenerationError(message)
raise ImageStudioGenerationError("cmhub 生图任务状态返回格式错误")
def _download_and_save_job_image(
request_result,
out_path,
config,
on_event,
job_id,
should_stop=None,
):
_raise_if_stopped(should_stop)
_notify(on_event, {"job_id": job_id, "step": "cover_download", "result": "start"})
try:
image_bytes, _ = ai._download_cmhub_image_with_retry(
request_result["image_url"],
connect_timeout=request_result["connect_timeout"],
read_timeout=request_result["read_timeout"],
use_system_proxy=request_result.get("use_system_proxy", False),
download_with_curl=request_result.get("download_with_curl", "auto"),
should_stop=should_stop,
)
except CancelledError as exc:
raise ImageStudioGenerationError(
"用户已停止,已提交任务可稍后继续查询"
) from exc
_raise_if_stopped(should_stop)
saved_path = ai._save_jpeg(
image_bytes,
out_path,
request_result.get("resolution") or appconfig.ai_config(config).get("resolution", "1k"),
request_result.get("quality") or appconfig.ai_config(config).get("jpg_quality", 90),
)
try:
_raise_if_stopped(should_stop)
except ImageStudioGenerationError:
try:
if os.path.isfile(saved_path):
os.remove(saved_path)
finally:
raise
_notify(on_event, {"job_id": job_id, "step": "cover_download", "result": "success"})
return saved_path
def _raise_if_stopped(should_stop):
try:
stopped = bool(should_stop and should_stop())
except Exception:
stopped = False
if stopped:
raise ImageStudioGenerationError("用户已停止,已提交任务可稍后继续查询")
def _recovery_action_for_job(job):
if (
job is not None
and getattr(job, "task_id", None)
and getattr(job, "recovery_action", None) == image_studio.JOB_RECOVERY_RESUME
):
return image_studio.JOB_RECOVERY_RESUME
return image_studio.JOB_RECOVERY_REGENERATE