902 lines
30 KiB
Python
902 lines
30 KiB
Python
"""Multi-source generation orchestration for AI image studio jobs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import threading
|
|
import time
|
|
import urllib.parse
|
|
import uuid
|
|
from concurrent.futures import CancelledError, FIRST_COMPLETED, ThreadPoolExecutor, wait
|
|
|
|
from . import ai, appconfig, image_studio
|
|
from .version import APP_VERSION
|
|
|
|
|
|
MAX_IMAGE_STUDIO_WORKERS = 5
|
|
_GLOBAL_IMAGE_STUDIO_SLOTS = threading.BoundedSemaphore(MAX_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 image generation cannot complete."""
|
|
|
|
|
|
class ImageStudioRuntimeLease:
|
|
"""A process-lifetime lock used only to make startup recovery safe."""
|
|
|
|
def __init__(self, handle, path):
|
|
self._handle = handle
|
|
self.path = path
|
|
self.session_id = "startup-" + uuid.uuid4().hex
|
|
|
|
def release(self):
|
|
handle, self._handle = self._handle, None
|
|
if handle is None:
|
|
return
|
|
try:
|
|
handle.seek(0)
|
|
if os.name == "nt":
|
|
import msvcrt
|
|
|
|
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
else: # pragma: no cover - Windows release is the supported path.
|
|
import fcntl
|
|
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
finally:
|
|
handle.close()
|
|
|
|
|
|
def acquire_startup_recovery_lease(data_dir):
|
|
"""Acquire a non-blocking process lease; return None when another app owns it."""
|
|
|
|
directory = os.path.abspath(str(data_dir or ""))
|
|
if not directory:
|
|
return None
|
|
try:
|
|
os.makedirs(directory, exist_ok=True)
|
|
path = os.path.join(directory, ".image_studio_generation.lock")
|
|
handle = open(path, "a+b")
|
|
if not handle.read(1):
|
|
handle.write(b"0")
|
|
handle.flush()
|
|
handle.seek(0)
|
|
if os.name == "nt":
|
|
import msvcrt
|
|
|
|
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
else: # pragma: no cover - retained for developer tests outside Windows.
|
|
import fcntl
|
|
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
return ImageStudioRuntimeLease(handle, path)
|
|
except (OSError, ImportError):
|
|
try:
|
|
handle.close()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def recover_stale_direct_jobs_at_startup(*, lease, path=None):
|
|
"""Recover interrupted direct jobs only while the process lease is held."""
|
|
|
|
if lease is None:
|
|
return {"recovered": [], "skipped": True}
|
|
recovered = image_studio.fail_stale_direct_jobs(path=path)
|
|
return {"recovered": recovered, "skipped": False}
|
|
|
|
|
|
RATIO_OUTPUT_SIZES = {
|
|
"1:1": ("1024x1024", False),
|
|
"3:4": ("1024x1536", True),
|
|
"9:16": ("1024x1536", True),
|
|
"4:3": ("1536x1024", True),
|
|
"16:9": ("1536x1024", True),
|
|
}
|
|
|
|
|
|
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 _direct_runtime(config):
|
|
ai_cfg = appconfig.ai_config(config)
|
|
models_path = appconfig.ai_models_config_path(config)
|
|
model = ai._role_model(
|
|
"image",
|
|
ai_cfg.get("default_image_model"),
|
|
models_path,
|
|
models=ai._runtime_direct_models(config),
|
|
)
|
|
error = appconfig.image_model_config_error(model)
|
|
if error:
|
|
raise ImageStudioGenerationError(error)
|
|
return {
|
|
"model": model,
|
|
"connect_timeout": ai._connect_timeout(model),
|
|
"read_timeout": ai._read_timeout(model, config),
|
|
"quality": ai._jpg_quality(ai_cfg.get("jpg_quality", 90)),
|
|
}
|
|
|
|
|
|
def _is_default_gateway_job(job):
|
|
return (
|
|
str(getattr(job, "generation_source", "") or "").strip().lower()
|
|
== image_studio.GENERATION_SOURCE_CMHUB
|
|
and str(getattr(job, "provider", "") or "").strip().lower()
|
|
== image_studio.PROVIDER_CMHUB
|
|
)
|
|
|
|
|
|
def _is_direct_gateway_job(job):
|
|
return (
|
|
str(getattr(job, "generation_source", "") or "").strip().lower()
|
|
== image_studio.GENERATION_SOURCE_DIRECT
|
|
and str(getattr(job, "provider", "") or "").strip().lower()
|
|
== image_studio.PROVIDER_OPENAI_IMAGES_EDITS
|
|
)
|
|
|
|
|
|
def generation_source_for_config(config):
|
|
"""Return the persisted source/provider pair for one frozen run config."""
|
|
|
|
if appconfig.ai_backend(config) == "direct":
|
|
return {
|
|
"generation_source": image_studio.GENERATION_SOURCE_DIRECT,
|
|
"provider": image_studio.PROVIDER_OPENAI_IMAGES_EDITS,
|
|
}
|
|
return {
|
|
"generation_source": image_studio.GENERATION_SOURCE_CMHUB,
|
|
"provider": image_studio.PROVIDER_CMHUB,
|
|
}
|
|
|
|
|
|
def requested_output_spec(aspect_ratio):
|
|
"""Map a product-suite ratio to an explicit request size and approximation flag."""
|
|
|
|
ratio = str(aspect_ratio or "1:1")
|
|
size, approximate = RATIO_OUTPUT_SIZES.get(ratio, RATIO_OUTPUT_SIZES["1:1"])
|
|
return {
|
|
"aspect_ratio": ratio,
|
|
"requested_output_size": size,
|
|
"approximate_ratio": bool(approximate),
|
|
}
|
|
|
|
|
|
def _ensure_new_submission_allowed(config):
|
|
if appconfig.ai_backend(config) not in {"cmhub", "direct"}:
|
|
raise ImageStudioGenerationError("商品套图生成网关配置无效,请到⑤设置检查")
|
|
|
|
|
|
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",
|
|
config=None,
|
|
path=None,
|
|
):
|
|
_ensure_new_submission_allowed(config)
|
|
total = max(0, int(count or 0))
|
|
if total <= 0:
|
|
raise ImageStudioGenerationError("生成数量必须大于0")
|
|
source = generation_source_for_config(config)
|
|
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=source["generation_source"],
|
|
provider=source["provider"],
|
|
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,
|
|
run_session_id=None,
|
|
):
|
|
jobs = create_generation_jobs(
|
|
project_id,
|
|
source_asset_id,
|
|
prompt,
|
|
count,
|
|
job_type=job_type,
|
|
config=config,
|
|
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,
|
|
run_session_id=run_session_id,
|
|
)
|
|
|
|
|
|
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,
|
|
run_session_id=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,
|
|
run_session_id=run_session_id,
|
|
)
|
|
|
|
|
|
def run_jobs(
|
|
jobs,
|
|
*,
|
|
aspect_ratio="1:1",
|
|
config=None,
|
|
cmhub_config_path=None,
|
|
path=None,
|
|
should_stop=None,
|
|
on_event=None,
|
|
run_session_id=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": []}
|
|
rejected = []
|
|
for job in job_list:
|
|
if _is_direct_gateway_job(job) and getattr(job, "task_id", None):
|
|
rejected.append(job)
|
|
elif not _is_default_gateway_job(job) and not _is_direct_gateway_job(job):
|
|
rejected.append(job)
|
|
job_list = [job for job in job_list if job not in rejected]
|
|
summary = {
|
|
"total": len(job_list) + len(rejected),
|
|
"success": 0,
|
|
"failed": len(rejected),
|
|
"cancelled": 0,
|
|
"jobs": [
|
|
{
|
|
"job": job,
|
|
"status": "failed",
|
|
"error": "AI工场任务来源无效,不能执行",
|
|
}
|
|
for job in rejected
|
|
],
|
|
}
|
|
if not job_list:
|
|
return summary
|
|
|
|
ai_cfg = appconfig.ai_config(cfg)
|
|
image_root = appconfig.image_dir(cfg)
|
|
runtimes = {}
|
|
if any(_is_default_gateway_job(job) for job in job_list):
|
|
runtimes[image_studio.GENERATION_SOURCE_CMHUB] = _runtime(
|
|
cfg,
|
|
cmhub_config_path,
|
|
)
|
|
if any(_is_direct_gateway_job(job) for job in job_list):
|
|
runtimes[image_studio.GENERATION_SOURCE_DIRECT] = _direct_runtime(cfg)
|
|
run_session_id = str(run_session_id or "").strip() or "direct-" + uuid.uuid4().hex
|
|
output_spec = requested_output_spec(aspect_ratio)
|
|
max_workers = min(
|
|
MAX_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()
|
|
|
|
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,
|
|
runtimes,
|
|
cfg,
|
|
image_root,
|
|
output_spec,
|
|
path,
|
|
should_stop,
|
|
on_event,
|
|
run_session_id,
|
|
): 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": "用户停止"})
|
|
summary["output"] = dict(output_spec)
|
|
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,
|
|
runtimes,
|
|
config,
|
|
image_root,
|
|
output_spec,
|
|
db_path,
|
|
should_stop,
|
|
on_event,
|
|
run_session_id,
|
|
):
|
|
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": "项目或源图不存在"}
|
|
is_direct = _is_direct_gateway_job(job)
|
|
if not (_is_default_gateway_job(job) or is_direct):
|
|
raise ImageStudioGenerationError("AI工场任务来源无效,不能执行")
|
|
runtime = runtimes.get(job.generation_source)
|
|
if runtime is None:
|
|
raise ImageStudioGenerationError("当前运行缺少该任务来源的配置快照")
|
|
try:
|
|
image_studio.update_job_status(
|
|
job.id,
|
|
"running",
|
|
run_session_id=run_session_id if is_direct else None,
|
|
path=db_path,
|
|
)
|
|
reference_assets = ()
|
|
if not job.task_id:
|
|
reference_assets = _reference_assets_for_job(job, db_path)
|
|
out_path = _output_path(project, job, image_root)
|
|
if is_direct:
|
|
request_result = _submit_direct_job(
|
|
job,
|
|
source_asset,
|
|
runtime,
|
|
config,
|
|
output_spec,
|
|
should_stop,
|
|
on_event,
|
|
reference_assets=reference_assets,
|
|
)
|
|
# A submitted direct request cannot be cancelled. If bytes arrive after
|
|
# stop was requested, save them before ending the remaining queue.
|
|
saved_path = _save_direct_job_image(
|
|
request_result,
|
|
out_path,
|
|
on_event,
|
|
job.id,
|
|
)
|
|
remote_url = None
|
|
else:
|
|
request_result = _submit_or_resume_job(
|
|
job,
|
|
source_asset,
|
|
runtime,
|
|
config,
|
|
output_spec["aspect_ratio"],
|
|
db_path,
|
|
should_stop,
|
|
on_event,
|
|
reference_assets=reference_assets,
|
|
)
|
|
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)
|
|
request_result["resolution"] = output_spec["requested_output_size"]
|
|
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
|
|
remote_url = request_result["image_url"]
|
|
rendered_width, rendered_height = _rendered_image_size(saved_path)
|
|
asset = image_studio.add_asset(
|
|
project.id,
|
|
_generated_kind(job.job_type),
|
|
remote_url=remote_url,
|
|
local_path=saved_path,
|
|
aspect_ratio=output_spec["aspect_ratio"],
|
|
requested_output_size=output_spec["requested_output_size"],
|
|
rendered_width=rendered_width,
|
|
rendered_height=rendered_height,
|
|
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 = (
|
|
not is_direct
|
|
and (isinstance(exc, CancelledError) or "停止" in str(exc))
|
|
)
|
|
status = "cancelled" if cancelled else "failed"
|
|
if cancelled:
|
|
error = "用户已停止,已提交任务可稍后继续查询"
|
|
elif is_direct:
|
|
error = _direct_failure_message(exc)
|
|
else:
|
|
error = 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 _reference_assets_for_job(job, db_path):
|
|
assets = []
|
|
for asset_id in image_studio.job_reference_asset_ids(job):
|
|
asset = image_studio.get_asset(asset_id, path=db_path)
|
|
if asset is None:
|
|
raise ImageStudioGenerationError("参考图资产不存在,无法继续生成")
|
|
try:
|
|
_source_path(asset)
|
|
except ImageStudioGenerationError as exc:
|
|
raise ImageStudioGenerationError("参考图尚未下载到本地,无法继续生成") from exc
|
|
assets.append(asset)
|
|
return assets
|
|
|
|
|
|
def _direct_image_paths(source_asset, reference_assets=()):
|
|
"""Return ordered local paths; the first source image is the subject image."""
|
|
|
|
return [_source_path(source_asset)] + [
|
|
_source_path(asset) for asset in (reference_assets or ())
|
|
]
|
|
|
|
|
|
def _submit_direct_job(
|
|
job,
|
|
source_asset,
|
|
runtime,
|
|
config,
|
|
output_spec,
|
|
should_stop,
|
|
on_event,
|
|
reference_assets=(),
|
|
):
|
|
if job.task_id:
|
|
raise ImageStudioGenerationError("自定义网关任务不能继续查询,请手动重新生成")
|
|
_raise_if_stopped(should_stop)
|
|
image_paths = _direct_image_paths(source_asset, reference_assets)
|
|
body, content_type = ai._image_edit_body(
|
|
runtime["model"],
|
|
job.prompt,
|
|
image_paths,
|
|
output_spec["requested_output_size"],
|
|
)
|
|
_notify(on_event, {"job_id": job.id, "step": "cover_submit", "result": "start"})
|
|
# Direct images/edits has no idempotency or query endpoint. Retrying an
|
|
# uncertain request could create and charge a second image, so one call only.
|
|
data = ai._call_with_retry(
|
|
runtime["model"],
|
|
body,
|
|
config,
|
|
1,
|
|
request_kind="multipart",
|
|
content_type=content_type,
|
|
)
|
|
image_bytes = ai._extract_image_bytes(data, runtime["model"], config)
|
|
_notify(on_event, {"job_id": job.id, "step": "cover_request", "result": "success"})
|
|
return {
|
|
"image_bytes": image_bytes,
|
|
"resolution": output_spec["requested_output_size"],
|
|
"quality": runtime["quality"],
|
|
}
|
|
|
|
|
|
def _save_direct_job_image(request_result, out_path, on_event, job_id):
|
|
_notify(on_event, {"job_id": job_id, "step": "cover_download", "result": "start"})
|
|
saved_path = ai._save_jpeg(
|
|
request_result["image_bytes"],
|
|
out_path,
|
|
request_result["resolution"],
|
|
request_result["quality"],
|
|
)
|
|
_notify(on_event, {"job_id": job_id, "step": "cover_download", "result": "success"})
|
|
return saved_path
|
|
|
|
|
|
def _rendered_image_size(path):
|
|
try:
|
|
from PIL import Image
|
|
|
|
with Image.open(path) as image:
|
|
width, height = image.size
|
|
return int(width), int(height)
|
|
except Exception as exc:
|
|
raise ImageStudioGenerationError("生成图片保存后无法读取尺寸") from exc
|
|
|
|
|
|
def _direct_failure_message(exc):
|
|
text = str(exc or "").lower()
|
|
if "timeout" in text or "超时" in text:
|
|
return "自定义网关生成超时,无法确认服务商是否已处理,请手动重新生成"
|
|
return "自定义网关生成失败,结果无法确认,请检查图片模型配置后手动重新生成"
|
|
|
|
|
|
def _submit_or_resume_job(
|
|
job,
|
|
source_asset,
|
|
runtime,
|
|
config,
|
|
aspect_ratio,
|
|
db_path,
|
|
should_stop,
|
|
on_event,
|
|
reference_assets=(),
|
|
):
|
|
if job.task_id:
|
|
if not _is_default_gateway_job(job):
|
|
raise ImageStudioGenerationError("该已提交任务不属于默认网关,不能继续查询")
|
|
_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)
|
|
_ensure_new_submission_allowed(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
|