diff --git a/app/ai.py b/app/ai.py index 3179161..2673493 100644 --- a/app/ai.py +++ b/app/ai.py @@ -2776,7 +2776,19 @@ def _save_jpeg(image_bytes, out_path, resolution, jpg_quality): def _resolution_size(resolution): - return _RESOLUTION_SIZES.get(str(resolution)) + text = str(resolution or "").strip().lower() + mapped = _RESOLUTION_SIZES.get(text) + if mapped: + return mapped + if "x" in text: + width, height = text.split("x", 1) + try: + width, height = int(width), int(height) + except (TypeError, ValueError): + return None + if width > 0 and height > 0: + return width, height + return None def _resolution_size_text(resolution): diff --git a/app/db.py b/app/db.py index e6da987..12befae 100644 --- a/app/db.py +++ b/app/db.py @@ -335,6 +335,9 @@ CREATE TABLE IF NOT EXISTS image_studio_assets ( remote_url TEXT, local_path TEXT, aspect_ratio TEXT, + requested_output_size TEXT, + rendered_width INTEGER, + rendered_height INTEGER, parent_asset_id INTEGER REFERENCES image_studio_assets(id) ON DELETE SET NULL, prompt TEXT, status TEXT NOT NULL DEFAULT 'available', @@ -369,6 +372,7 @@ CREATE TABLE IF NOT EXISTS image_studio_jobs ( call_id TEXT, generation_round_key TEXT, generation_slot_index INTEGER, + run_session_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, submitted_at TEXT, @@ -508,6 +512,8 @@ def init_db(path=None, conn=None) -> None: _ensure_image_studio_job_recovery_columns(database) _ensure_image_studio_generation_round_columns(database) _ensure_image_studio_job_reference_asset_ids_column(database) + _ensure_image_studio_asset_output_columns(database) + _ensure_image_studio_job_run_session_column(database) def _ensure_batch_delete_columns(database): @@ -724,6 +730,34 @@ def _ensure_image_studio_job_reference_asset_ids_column(database): "ALTER TABLE image_studio_jobs ADD COLUMN reference_asset_ids TEXT" ) + +def _ensure_image_studio_asset_output_columns(database): + columns = { + row["name"] for row in database.execute("PRAGMA table_info(image_studio_assets)").fetchall() + } + if "requested_output_size" not in columns: + database.execute( + "ALTER TABLE image_studio_assets ADD COLUMN requested_output_size TEXT" + ) + if "rendered_width" not in columns: + database.execute( + "ALTER TABLE image_studio_assets ADD COLUMN rendered_width INTEGER" + ) + if "rendered_height" not in columns: + database.execute( + "ALTER TABLE image_studio_assets ADD COLUMN rendered_height INTEGER" + ) + + +def _ensure_image_studio_job_run_session_column(database): + columns = { + row["name"] for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall() + } + if "run_session_id" not in columns: + database.execute( + "ALTER TABLE image_studio_jobs ADD COLUMN run_session_id TEXT" + ) + def create_batch(file_paths: Iterable[str], note=None, path=None, conn=None) -> str: batch_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:8] files = [os.path.abspath(file_path) for file_path in file_paths] diff --git a/app/gui/__init__.py b/app/gui/__init__.py index 3ba387a..67347df 100644 --- a/app/gui/__init__.py +++ b/app/gui/__init__.py @@ -6,7 +6,7 @@ import os import sys from dataclasses import replace -from .. import appconfig, chrome, diagnostics, update_check, update_health +from .. import appconfig, chrome, db, diagnostics, image_studio_generation, update_check, update_health from ..version import APP_NAME, APP_VERSION, display_name from . import widgets as _widgets from .widgets import * @@ -155,10 +155,39 @@ def main() -> int: update_health.write_health(health_context, "environment_blocked", str(exc)) QMessageBox.critical(None, "启动配置错误", str(exc)) return 1 + runtime_lease = None + try: + database_path = appconfig.db_path(startup["config"]) + db.init_db(database_path) + runtime_lease = image_studio_generation.acquire_startup_recovery_lease( + appconfig.data_dir(startup["config"]) + ) + recovery = image_studio_generation.recover_stale_direct_jobs_at_startup( + lease=runtime_lease, + path=database_path, + ) + recovered_count = len(recovery.get("recovered") or []) + if recovered_count: + diagnostics.write_diagnostic_log( + "启动时标记未确认的自定义网关套图任务", + level="WARNING", + step="image_studio_direct_recovery", + payload={"recovered_count": recovered_count}, + ) + elif recovery.get("skipped"): + diagnostics.write_diagnostic_log( + "检测到另一个程序实例,跳过自定义网关套图任务恢复", + level="INFO", + step="image_studio_direct_recovery", + ) + except Exception as exc: + _write_update_check_diagnostic("自定义网关套图任务恢复检查失败", exc=exc) window = MainWindow( config=startup["config"], startup_status=startup["message"], ) + if runtime_lease is not None: + app.aboutToQuit.connect(runtime_lease.release) window.show() QTimer.singleShot( 0, diff --git a/app/gui/workers.py b/app/gui/workers.py index 8f7a37a..3265475 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -262,7 +262,7 @@ class ImageStudioDownloadOriginalWorker(BaseWorker): class ImageStudioGenerateJobsWorker(BaseWorker): - """Run cmhub hosted image generation jobs for the AI studio.""" + """Run frozen-source image generation jobs for the AI studio.""" def __init__( self, @@ -285,12 +285,13 @@ class ImageStudioGenerateJobsWorker(BaseWorker): self.job_type = str(job_type or "main") self.aspect_ratio = str(aspect_ratio or "1:1") self.db_path = db_path + backend = appconfig.ai_backend(config) self.config = ai.freeze_runtime_config( config, cmhub_config_path=cmhub_config_path, models_path=(config or {}).get("ai_models_path", appconfig.AI_MODELS_PATH), - include_cmhub=True, - include_direct_models=False, + include_cmhub=backend == "cmhub", + include_direct_models=backend == "direct", ) self.cmhub_config_path = cmhub_config_path self._done = 0 @@ -364,12 +365,13 @@ class ProductSuiteGenerateWorker(BaseWorker): self.generation_round_key = str(generation_round_key or "").strip() self.aspect_ratio = str(aspect_ratio or "1:1") self.db_path = db_path + backend = appconfig.ai_backend(config) self.config = ai.freeze_runtime_config( config, cmhub_config_path=cmhub_config_path, models_path=(config or {}).get("ai_models_path", appconfig.AI_MODELS_PATH), - include_cmhub=True, - include_direct_models=False, + include_cmhub=backend == "cmhub", + include_direct_models=backend == "direct", ) self.cmhub_config_path = cmhub_config_path self.job_ids = [] @@ -378,12 +380,11 @@ class ProductSuiteGenerateWorker(BaseWorker): self._lock = threading.Lock() def execute(self): - if appconfig.ai_backend(self.config) != "cmhub": - raise ValueError("商品套图仅支持默认网关,请到⑤设置切换后再生成") total = len(self.job_specs) if total <= 0: raise ValueError("商品套图生成任务不能为空") jobs = [] + source = image_studio_generation.generation_source_for_config(self.config) for spec in self.job_specs: jobs.append( image_studio.create_job( @@ -392,8 +393,8 @@ class ProductSuiteGenerateWorker(BaseWorker): reference_asset_ids=spec.get("reference_asset_ids"), job_type=spec.get("job_type") or "套图", prompt=spec.get("prompt") or "", - generation_source="cmhub", - provider="cmhub", + generation_source=source["generation_source"], + provider=source["provider"], generation_round_key=spec.get("generation_round_key") or self.generation_round_key or None, generation_slot_index=spec.get("generation_slot_index"), path=self.db_path, @@ -435,6 +436,7 @@ class ProductSuiteGenerateWorker(BaseWorker): path=self.db_path, should_stop=self.should_cancel, on_event=on_event, + run_session_id=self.run_token, ) summary["project_id"] = self.project_id summary["job_ids"] = list(self.job_ids) diff --git a/app/image_studio.py b/app/image_studio.py index b5a0d28..f2d4a95 100644 --- a/app/image_studio.py +++ b/app/image_studio.py @@ -33,6 +33,14 @@ JOB_RECOVERY_ACTIONS = { JOB_RECOVERY_RESUME, JOB_RECOVERY_REGENERATE, } +GENERATION_SOURCE_CMHUB = "cmhub" +GENERATION_SOURCE_DIRECT = "direct" +PROVIDER_CMHUB = "cmhub" +PROVIDER_OPENAI_IMAGES_EDITS = "openai_images_edits" +GENERATION_SOURCE_PROVIDERS = { + GENERATION_SOURCE_CMHUB: PROVIDER_CMHUB, + GENERATION_SOURCE_DIRECT: PROVIDER_OPENAI_IMAGES_EDITS, +} SELECTION_TYPES = {"main", "detail"} @@ -65,6 +73,9 @@ class ImageStudioAsset: remote_url: Optional[str] local_path: Optional[str] aspect_ratio: Optional[str] + requested_output_size: Optional[str] + rendered_width: Optional[int] + rendered_height: Optional[int] parent_asset_id: Optional[int] prompt: Optional[str] status: str @@ -95,6 +106,7 @@ class ImageStudioJob: call_id: Optional[str] generation_round_key: Optional[str] generation_slot_index: Optional[int] + run_session_id: Optional[str] created_at: str updated_at: str submitted_at: Optional[str] @@ -705,6 +717,9 @@ def add_asset( remote_url=None, local_path=None, aspect_ratio=None, + requested_output_size=None, + rendered_width=None, + rendered_height=None, parent_asset_id=None, prompt=None, status=ASSET_STATUS_AVAILABLE, @@ -720,8 +735,9 @@ def add_asset( """ INSERT INTO image_studio_assets (project_id, kind, remote_url, local_path, aspect_ratio, + requested_output_size, rendered_width, rendered_height, parent_asset_id, prompt, status, source_order, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( int(project_id), @@ -729,6 +745,9 @@ def add_asset( remote_url, abs_local_path, aspect_ratio, + str(requested_output_size).strip() if requested_output_size else None, + int(rendered_width) if rendered_width is not None else None, + int(rendered_height) if rendered_height is not None else None, parent_asset_id, prompt, str(status or ASSET_STATUS_AVAILABLE), @@ -1161,6 +1180,11 @@ def create_job( path=None, conn=None, ): + source = str(generation_source or GENERATION_SOURCE_CMHUB).strip().lower() + expected_provider = GENERATION_SOURCE_PROVIDERS.get(source) + provider = str(provider or expected_provider or "").strip().lower() + if expected_provider is None or provider != expected_provider: + raise db.DbError("AI工场任务来源与服务商组合无效") now = _now() task_key = str(task_key or _task_key(project_id)) reference_ids = _parse_reference_asset_ids(reference_asset_ids, source_asset_id) @@ -1200,8 +1224,8 @@ def create_job( int(project_id), source_asset_id, reference_json, - str(generation_source or "cmhub"), - str(provider or "cmhub"), + source, + provider, str(job_type), task_key, str(prompt or ""), @@ -1664,6 +1688,14 @@ def set_job_submitted(job_id, task_id, *, call_id=None, points_cost=None, points now = _now() with _connection(conn, path) as database: with database: + job = get_job(job_id, conn=database) + if job is None: + raise db.DbError("AI工场任务不存在") + if ( + job.generation_source != GENERATION_SOURCE_CMHUB + or job.provider != PROVIDER_CMHUB + ): + raise db.DbError("只有默认网关任务可以记录查询任务编号") database.execute( """ UPDATE image_studio_jobs @@ -1700,6 +1732,7 @@ def update_job_status( points_balance=None, recovery_action=None, increment_attempts=False, + run_session_id=None, path=None, conn=None, ): @@ -1726,6 +1759,7 @@ def update_job_status( recovery_action = COALESCE(?, recovery_action), output_asset_id = COALESCE(?, output_asset_id), points_balance = COALESCE(?, points_balance), + run_session_id = COALESCE(?, run_session_id), attempts = attempts + ?, finished_at = CASE WHEN ? THEN ? ELSE finished_at END, updated_at = ? @@ -1737,6 +1771,7 @@ def update_job_status( recovery_action, output_asset_id, points_balance, + str(run_session_id).strip() if run_session_id else None, 1 if increment_attempts else 0, 1 if terminal else 0, now, @@ -1751,13 +1786,35 @@ def list_resumable_jobs(path=None, conn=None, project_id=None, include_failed_do if include_failed_downloads: clauses = [ "task_id IS NOT NULL", + "generation_source = ?", + "provider = ?", "recovery_action = ?", "status IN (?, ?, ?, ?)", ] - params = [JOB_RECOVERY_RESUME, "submitted", "running", "failed", "cancelled"] + params = [ + GENERATION_SOURCE_CMHUB, + PROVIDER_CMHUB, + JOB_RECOVERY_RESUME, + "submitted", + "running", + "failed", + "cancelled", + ] else: - clauses = ["status IN (?, ?)", "task_id IS NOT NULL", "recovery_action = ?"] - params = ["submitted", "running", JOB_RECOVERY_RESUME] + clauses = [ + "status IN (?, ?)", + "task_id IS NOT NULL", + "generation_source = ?", + "provider = ?", + "recovery_action = ?", + ] + params = [ + "submitted", + "running", + GENERATION_SOURCE_CMHUB, + PROVIDER_CMHUB, + JOB_RECOVERY_RESUME, + ] if project_id is not None: clauses.append("project_id = ?") params.append(int(project_id)) @@ -1767,6 +1824,58 @@ def list_resumable_jobs(path=None, conn=None, project_id=None, include_failed_do return _fetch_all(database, sql, params, ImageStudioJob) +def fail_stale_direct_jobs(*, active_run_session_ids=(), path=None, conn=None): + """Fail only confirmed stale synchronous direct jobs during application startup.""" + + active_ids = { + str(value).strip() + for value in (active_run_session_ids or ()) + if str(value).strip() + } + with _connection(conn, path) as database: + rows = database.execute( + """ + SELECT * FROM image_studio_jobs + WHERE generation_source = ? + AND provider = ? + AND status = 'running' + ORDER BY id + """, + (GENERATION_SOURCE_DIRECT, PROVIDER_OPENAI_IMAGES_EDITS), + ).fetchall() + stale_ids = [ + int(row["id"]) + for row in rows + if str(row["run_session_id"] or "").strip() not in active_ids + ] + if not stale_ids: + return [] + now = _now() + with database: + database.executemany( + """ + UPDATE image_studio_jobs + SET status = 'failed', + error = ?, + recovery_action = ?, + finished_at = ?, + updated_at = ? + WHERE id = ? + """, + [ + ( + "程序中断,无法确认生成结果,请手动重新生成", + JOB_RECOVERY_REGENERATE, + now, + now, + job_id, + ) + for job_id in stale_ids + ], + ) + return [get_job(job_id, conn=database) for job_id in stale_ids] + + def replace_selections(project_id, selection_type, asset_ids: Iterable[int], path=None, conn=None): selection = str(selection_type) if selection not in SELECTION_TYPES: diff --git a/app/image_studio_generation.py b/app/image_studio_generation.py index 643655a..5d66694 100644 --- a/app/image_studio_generation.py +++ b/app/image_studio_generation.py @@ -1,4 +1,4 @@ -"""cmhub hosted generation orchestration for AI image studio jobs.""" +"""Multi-source generation orchestration for AI image studio jobs.""" from __future__ import annotations @@ -6,21 +6,97 @@ 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_CMHUB_IMAGE_STUDIO_WORKERS = 5 -_GLOBAL_IMAGE_STUDIO_SLOTS = threading.BoundedSemaphore(MAX_CMHUB_IMAGE_STUDIO_WORKERS) +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 hosted generation cannot complete.""" + """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): @@ -36,16 +112,73 @@ 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() == "cmhub" - and str(getattr(job, "provider", "") or "").strip().lower() == "cmhub" + 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) != "cmhub": - raise ImageStudioGenerationError("商品套图仅支持默认网关,请到⑤设置切换后再生成") + if appconfig.ai_backend(config) not in {"cmhub", "direct"}: + raise ImageStudioGenerationError("商品套图生成网关配置无效,请到⑤设置检查") def _generated_kind(job_type): @@ -104,6 +237,7 @@ def create_generation_jobs( 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( @@ -112,8 +246,8 @@ def create_generation_jobs( source_asset_id=source_asset_id, job_type=job_type, prompt=prompt, - generation_source="cmhub", - provider="cmhub", + generation_source=source["generation_source"], + provider=source["provider"], path=path, ) ) @@ -133,6 +267,7 @@ def generate_image_jobs( path=None, should_stop=None, on_event=None, + run_session_id=None, ): jobs = create_generation_jobs( project_id, @@ -151,6 +286,7 @@ def generate_image_jobs( path=path, should_stop=should_stop, on_event=on_event, + run_session_id=run_session_id, ) @@ -163,6 +299,7 @@ def resume_image_jobs( path=None, should_stop=None, on_event=None, + run_session_id=None, ): jobs = image_studio.list_resumable_jobs( path=path, @@ -177,6 +314,7 @@ def resume_image_jobs( path=path, should_stop=should_stop, on_event=on_event, + run_session_id=run_session_id, ) @@ -189,16 +327,18 @@ def run_jobs( 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 = [ - job - for job in job_list - if getattr(job, "task_id", None) and not _is_default_gateway_job(job) - ] + 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), @@ -209,7 +349,7 @@ def run_jobs( { "job": job, "status": "failed", - "error": "该已提交任务不属于默认网关,不能继续查询", + "error": "AI工场任务来源无效,不能执行", } for job in rejected ], @@ -217,10 +357,23 @@ def run_jobs( if not job_list: return summary - 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)) + 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() @@ -239,13 +392,14 @@ def run_jobs( executor.submit( _run_one_job_with_global_slot, job.id, - runtime, + runtimes, cfg, image_root, - aspect_ratio, + output_spec, path, should_stop, on_event, + run_session_id, ): job for job in job_list } @@ -273,6 +427,7 @@ def run_jobs( ) futures.pop(future, None) record({"job": job, "status": "cancelled", "error": "用户停止"}) + summary["output"] = dict(output_spec) return summary @@ -281,7 +436,17 @@ def _run_one_job_with_global_slot(*args): return _run_one_job(*args) -def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, should_stop, on_event): +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工场生图任务不存在") @@ -305,48 +470,94 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho 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", path=db_path) + 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) - request_result = _submit_or_resume_job( - job, - source_asset, - runtime, - config, - 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) 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: + 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) - except ImageStudioGenerationError: + 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: - if os.path.isfile(saved_path): - os.remove(saved_path) - finally: - raise + _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=request_result["image_url"], + remote_url=remote_url, local_path=saved_path, - aspect_ratio=aspect_ratio, + 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, @@ -361,9 +572,17 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho _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) + cancelled = ( + not is_direct + and (isinstance(exc, CancelledError) or "停止" in str(exc)) + ) status = "cancelled" if cancelled else "failed" - error = "用户已停止,已提交任务可稍后继续查询" if cancelled else str(exc) + 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, @@ -398,6 +617,84 @@ def _reference_assets_for_job(job, db_path): 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, diff --git a/docs/04-architecture.md b/docs/04-architecture.md index f750bce..19858df 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -452,7 +452,10 @@ data/images///__new. # AI 生成的新 - 进度:标题和图片两条进度分开显示;只生成标题时图片进度显示本轮未生成/0 张,并在运行日志写明本轮生成内容。 - 任一组件生成后 `stage=generated`;**不设逐条人工审核阶段**。若只有标题,③可选择只更新标题;若只有封面,③可选择只更新封面,②标题状态仍为待生成,后续补标题会保留已有封面且不重复生图。双击任务弹窗查看旧封面、新封面和历史候选图;T-577 后弹窗内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条 `GenerateWorker`,用户退出后用状态筛选「待生成」批量补生成封面。②「重置生成结果」提供标题/封面/全部的多选或当前筛选范围重置,默认不删除本地新封面文件;已生成且未提交线上的新标题可在②表格本地微调。 - 并发数、重试、分辨率、jpg 质量、模型/Key 均来自设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。T-547 后标题并发和图片并发都限制为 1..5,失败重试次数限制为 0..10;旧 `config.json` 或手工配置的超限值会在加载/保存时夹紧。设置仍只展示一个「图片并发」设置;cmhub 模式下②运行日志显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”。 -- 商品套图固定使用设置保存的 cmhub 生图 alias。T-651 后,常规新一轮生成先完成历史确认,再按最终 `build_job_specs()` 在后台读取或复用短期模型目录缓存,最后才显示数量和扣点确认;逐图主图开启时白底图仅使用第一张原图,其他分类按每张原图展开。固定分类顺序为白底图、场景图、模特场景图、细节说明图、卖点图;默认数量为1、2、0、0、2,新增分类不因升级自动产生任务。只有当前生图别名存在唯一无条件 `points_cost` 时显示单张与总预估点数,总数严格按 planned job 数量计算;目录不可用或价格有条件时不显示数字。确认默认、Esc 或关闭均取消,计划在读取期间发生变化时不提交旧 specs;单图重试和恢复不进入该常规确认。预估不落库、不预扣,实际扣点仍以 job 的 cmhub 响应为准。T-637 后套图提示词事实来源分为:安装包只读默认 `app/default_prompts/product_suite/base.txt`、用户全局模板 `data/prompts/product_suite/base.txt`、`app/product_suite.py` 中的分类目标、结构化上下文与只读规则常量。用户模板首次缺失或为空时,必须先按完整占位符契约校验内置模板,再通过原子写入初始化;已有用户模板不被升级静默覆盖。模板无效时在创建 project/job/worker 和调用 cmhub 前阻断新一轮生成,单张历史重试继续使用原 `image_studio_jobs.prompt` 快照。 +- T-679a 后商品套图按 worker 启动时冻结的配置建立并持久化来源:默认网关 job 精确为 `cmhub/cmhub`,保留异步提交、轮询、下载、幂等键和点数账本;自定义网关 job 精确为 `direct/openai_images_edits`,走 T-678 的同步多图编辑、固定 `n=1`、无 `task_id`、无轮询和无自动重试。执行分派只读 job 持久化来源,不能受运行中设置变化影响;不匹配的来源/提供方或 direct job 带 task ID 均安全失败,避免跨来源串路。该状态机已就绪,但⑥入口、费用确认和完整发布验收由 T-679b/T-679c/T-679 继续完成。 +- direct job 提交前写入 `running + run_session_id`;停止只取消尚未提交的 job。同步请求已返回图片字节时,即使停止信号随后到达,仍保存资产并标成功。连接/读取超时或进程中断不自动补发、不进入 cmhub 恢复轮询,用户只能手动重新生成。应用仅在启动阶段持有单进程恢复租约时处理遗留 direct `running` job;活动会话跳过,确认陈旧的 job 标为“程序中断,无法确认生成结果,请手动重新生成”。进入项目、切换任务或刷新结果不做该清理。 +- 套图比例输出固定映射为 `1:1 -> 1024x1024`、`3:4/9:16 -> 1024x1536`、`4:3/16:9 -> 1536x1024`。`image_studio_assets.aspect_ratio` 保持用户原选项,新增 `requested_output_size`、`rendered_width`、`rendered_height` 记录请求尺寸与真实像素;旧记录保持空值,SQLite 迁移仅附加且幂等。服务返回近似比例结构化结果,T-679b 在确认界面呈现。 +- 商品套图的生图来源在 T-679 组全部验收前不得进入发布版本;图片理解「AI帮写」仍仅使用默认网关 `vision_alias`。 - T-658a 后商品套图异步生图提交统一使用 `images` 数组,单图也使用单元素 `{"image_base64": ...}`,不再提交顶层 `image_base64`。提交层最多传 8 张本地图片,单图原文件上限10MiB、编码后总输入上限32MiB;超限在请求前以中文错误阻断,后续多图参考的资产快照和提示词语义由 T-658b/T-658c 负责。 - T-658b 后 `image_studio_jobs.reference_asset_ids` 以有序 JSON 图片 ID 列表冻结每个商品套图 job 的参考图;新一轮未勾选逐图主图时,第1张为主图、后续最多7张写入快照,勾选时写空数组。历史 `NULL` 行继续按单图任务处理。恢复或重试只读 job 快照,不回读当前原图列表;参考图资产或本地文件缺失时提交前失败,不静默减少提交数量。 - T-658d 后逐图主图 checkbox 右侧显示可换行中文 helper,说明多SKU应逐图生成、同商品多角度应作为参考图一同提交。常规生成确认框从最终 specs 计算主图/参考图数量;未开启逐图主图且可用原图超过8张时,明确第1张主图加前7张参考图的上限和忽略数量。确认总点数仍只按 job 请求数计算,不因单请求图片数量重复计算。 @@ -463,7 +466,7 @@ data/images///__new. # AI 生成的新 - 在商品套图中,已选账号但未填写商品 ID 时允许输入卖点、导入、拖入或粘贴本地图片。非空卖点在现有防抖稳定后会创建一个可恢复临时草稿并保存到 `image_studio_projects.draft_prompt`;首次有效图片导入也会创建草稿。空白卖点、取消选择和全部导入失败不保留空草稿。只有卖点的草稿同样属于可恢复业务内容;卖点清空且没有资产/job 时仍可按空草稿规则清理。草稿可管理本地图片、AI 帮写、生成套图、查看历史和打开结果目录,但在创建 worker、启动 Chrome 或执行 CDP 前禁止「拉取蝦皮主图」。输入合法数字商品 ID 后,经确认原地绑定同一个 `project_id`;资产、job、selection、提示词、套图设置和 `storage_key` 均保持不变。若同账号目标 ID(含软删除项目)已存在则拒绝覆盖或合并。 - 商品套图拉取蝦皮主图使用独立内存 `pull_run_token` 覆盖 URL 读取和本轮后台下载。运行中按钮提供「停止并保留 / 停止并清除本次新增 / 继续拉取」;停止采用协作式安全边界,不强杀线程、Chrome 或已发出的 CDP/HTTP 请求。选择清除时只移除本轮新增、属于当前项目且未被 job/selection 引用的远程原图,并恢复拉取前已有原图的状态和顺序;本地手动导入图片、拉取前已有图片、用户文件和蝦皮线上图片不删除。旧 token 的 URL/下载迟到结果不得覆盖新一轮状态。 - 启动时恢复未软删除、至少含一条资产或生成任务的草稿为独立中文“临时草稿”标签,按最近更新时间排序。关闭非空草稿可选择保留、软删除或取消;软删除不物理删除图片目录。③「更新蝦皮」只处理正式任务,不接受临时草稿。 -- 第六 Tab 的多个 `SuiteTaskState` 各自保留 generation/pull/import/AI/download worker 与线程引用;切换任务不取消任务。多个任务可并行,但 `image_studio_generation` 使用进程级 semaphore 保证所有套图任务合计最多5个 cmhub 在途 job。线程还在运行时关闭任务只请求协作式停止,模块级引用保留到 `QThread.finished`,不得提前销毁线程对象;下载前后均检查停止信号,停止后的临时文件不入资产库。 +- 第六 Tab 的多个 `SuiteTaskState` 各自保留 generation/pull/import/AI/download worker 与线程引用;切换任务不取消任务。多个任务可并行,但 `image_studio_generation` 使用进程级来源中立 semaphore,保证默认网关与自定义网关生图合计最多5个在途 job。线程还在运行时关闭任务只请求协作式停止,模块级引用保留到 `QThread.finished`,不得提前销毁线程对象;下载前后均检查停止信号,停止后的临时文件不入资产库。 - T-639 后每轮套图生成使用仅存在内存的 `run_token` 隔离迟到信号,progress/finished/cancelled/failed 通过主线程绑定槽统一处理;正常 worker 结果、`QThread.finished` 和本轮 job 连续两次全部终态看门狗共同进入幂等 finalize。GUI 只按本轮明确 `job_ids` 判断完成,不用历史图片数量;即使最终信号丢失也会恢复按钮,旧线程引用仍保留到真实结束。停止为协作式:调度循环约每200ms检查标记并取消未开始 future,提交/轮询在有界请求返回后停止;requests 在流式数据块边界取消,Windows curl 由隐藏窗口 `Popen` 有界 terminate/kill。已有 `task_id` 的停止任务保留 resume,不假设服务端任务被取消或点数退回。 - T-643 后 `image_studio_projects.current_generation_round_key` 是商品套图主结果区的持久化当前轮次;每次常规「生成套图」创建独立 UUID `generation_round_key`,并为每条 `image_studio_jobs` 写入稳定的 `generation_slot_index`。内存 `run_token` 仍只用于线程和迟到信号隔离,不承担业务轮次语义。只有一轮中至少一条 job 成功时,才在同一 SQLite 事务内将该轮提升为当前轮;全失败或全取消保留原当前轮,部分成功则保留成功、失败槽位供后续重试。单张重试新建 job 但继承原轮次和槽位,当前展示按同轮同槽位最新 job、槽位顺序读取;所有尝试、错误、计费和输出资产均保留。旧 job 的轮次/槽位保持 NULL,查询层统一作为“旧版历史记录”,不以时间、文件名或数量猜测轮次;下一次成功的新格式整轮才建立当前轮。 - T-646 后商品套图主结果区仍只显示项目的当前轮次;「历史生成」改为全局非模态只读窗口,默认跨所有未软删除商品项目按轮次创建时间倒序读取(同一时间以稳定 job ID 补序),首屏及每次翻页最多30轮。窗口只读取 SQLite 的轮次摘要、每槽最新有效 job 和受限尺寸缩略图,不扫描图片目录或一次加载原图;店铺/账号、商品 ID 与“仅当前商品”筛选都在查询层完成,当前商品快捷筛选默认关闭。正常 `generation_round_key` 一轮一行,单张重试只更新该槽位当前状态并计入重试数,不新增历史行;NULL 轮次继续作为“旧版历史记录”兼容,不猜测轮次边界。每行最多显示5张缩略图,余量显示 `+N`;文件缺失只显示中文占位,不删除 DB。双击缩略图从被点图片打开该轮所有可用图片的自适应原尺寸浏览;“导出本轮”在后台仅复制成功且本地存在的输出 asset 到用户选择目录下安全命名的新子目录,确定性追加序号避免覆盖,不移动/重命名/删除内部 asset。窗口不提供批量导出、删除、重试、设为当前轮或重新生成;关闭商品任务不关闭全局窗口,应用退出时协作停止导出并释放窗口资源,生成 worker 不受历史窗口影响。 diff --git a/docs/api.md b/docs/api.md index 87fa090..2eee88f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -392,9 +392,10 @@ sync_original_asset_urls(project_id, image_urls, path=None) -> list[ImageStudioA list_assets(project_id, kind=None, include_missing=True, path=None) -> list[ImageStudioAsset] reorder_original_assets(project_id, asset_ids, path=None) -> list[ImageStudioAsset] remove_original_assets_if_unused(project_id, asset_ids, path=None) -> list[ImageStudioAsset] -create_job(project_id, source_asset_id=None, job_type="main", prompt="", ...) -> ImageStudioJob +create_job(project_id, source_asset_id=None, job_type="main", prompt="", generation_source="cmhub", provider="cmhub", ...) -> ImageStudioJob list_jobs(project_id, statuses=None, path=None) -> list[ImageStudioJob] list_resumable_jobs(project_id=None, include_failed_downloads=False, path=None) -> list[ImageStudioJob] +fail_stale_direct_jobs(active_run_session_ids=(), path=None) -> int list_generation_rounds(project_id, limit=None, offset=0, path=None) -> list[ImageStudioGenerationRound] get_successful_generation_history_summary(project_id, path=None) -> ImageStudioSuccessfulGenerationHistorySummary list_global_history_accounts(path=None) -> list[ImageStudioHistoryAccount] @@ -423,8 +424,10 @@ build_suite_prompt(base_prompt, settings, category, item_id, source_index=1) -> build_job_specs(source_assets, base_prompt, settings, item_id) -> list[dict] # app/image_studio_generation.py -generate_image_jobs(project_id, source_asset_id, prompt, count, job_type="main", aspect_ratio="1:1", ...) -> dict -resume_image_jobs(project_id=None, aspect_ratio="1:1", ...) -> dict +generate_image_jobs(project_id, source_asset_id, prompt, count, job_type="main", aspect_ratio="1:1", run_session_id=None, ...) -> dict +resume_image_jobs(project_id=None, aspect_ratio="1:1", run_session_id=None, ...) -> dict +requested_output_spec(aspect_ratio) -> dict +recover_stale_direct_jobs_at_startup(lease, path=None) -> int # app/image_studio_export.py export_project_selection(project_id, parent_dir, existing_mode="fail", path=None, config=None) -> ExportResult @@ -443,7 +446,9 @@ export_generation_round(project_id, generation_round_key, parent_dir, path=None, - 拉取蝦皮原主图只读:复用 `editor.open_product(..., bring_to_front=False)` 和 `editor.read_product_image_urls()`,不上传、不拖拽、不点击更新。 - 原图下载走 `image_studio_images` 的公网 URL、大小、Content-Type、重定向和 PIL 解码校验;只在用户单击时落盘。 - `remove_original_assets_if_unused()` 会先校验整批原图的项目归属、资产类型及 job/终选引用,再在单个事务中删除资产行并连续重排 `source_order`;任一图片不可删除时整批不变,本地源文件和蝦皮线上图片始终保留。 -- 默认网关托管生图每张都是独立 job:保存 `task_key/task_id/status/call_id/points_cost/points_balance`;已有 `task_id` 时只 poll/download,不重复 submit。商品套图把平台/国家/语言/比例等上下文写入每个 job prompt,并把比例实参传到默认网关;界面不展示 Provider URL、OpenAI Key 或上游接口路径。`create_generation_jobs()` 与无 `task_id` 的提交分支均拒绝非默认网关;`run_jobs()` 只放行同时满足 `generation_source="cmhub"`、`provider="cmhub"` 与非空 `task_id` 的旧任务继续查询,其他来源不得借 task ID 触发轮询或新提交。 +- T-679a 后,商品套图 job 固定持久化来源,不以运行时设置反推:默认网关为 `generation_source="cmhub"`、`provider="cmhub"`,沿用托管异步 `task_key/task_id/status/call_id/points_cost/points_balance` 及 submit/poll/download;自定义网关为 `generation_source="direct"`、`provider="openai_images_edits"`,`task_id` 必须为空,复用 T-678 的同步多图编辑请求,固定 `n=1`,收到图片字节即保存本地资产,不轮询、不写远程 URL、不自动重试。任何来源/提供方组合不精确匹配的 job 都拒绝执行,不能借 task ID 串到 cmhub。 +- direct job 进入 `running` 时保存仅用于本次进程的 `run_session_id`。只有应用启动阶段持有本地单进程恢复租约时,才将不属于活动会话的遗留 direct `running` job 标为失败,提示“程序中断,无法确认生成结果,请手动重新生成”;切换项目、刷新和恢复 cmhub 任务绝不触发该清理。同步请求返回图片后即使用户刚点击停止,也必须保存为成功;停止只取消未开始 job。连接/读取超时或进程中断的 direct job 不自动补发、不进入继续查询。 +- `image_studio_assets.aspect_ratio` 始终保存用户选择值;新生成资产另保存可空的 `requested_output_size`、`rendered_width`、`rendered_height`。比例映射为 `1:1 -> 1024x1024`、`3:4/9:16 -> 1024x1536`、`4:3/16:9 -> 1536x1024`,后四种由运行结果返回近似比例标记。旧资产字段保持 `NULL`;初始化迁移只附加字段且可重复执行。 - T-658a 后商品套图异步提交的图片字段统一为 `images` 数组(每项仅含本地编码的 `image_base64`),单图不再保留顶层 `image_base64` 兼容字段。提交层限制最多8张、单图原文件10MiB、编码后总输入32MiB;参考图快照字段由 T-658b 扩展。 - T-658b 后 `image_studio.create_job(..., reference_asset_ids=...)` 接收同项目、去重且不包含主图的有序图片 ID 列表,并以 JSON 快照写入 `image_studio_jobs.reference_asset_ids`;`job_reference_asset_ids(job)` 负责解析和校验。历史 `NULL` 快照返回空列表,恢复/重试不根据当前商品原图补图。 - `include_failed_downloads=True` 允许 failed 但已有 `task_id`、无输出 asset 的任务继续查询,用于下载失败或本地保存失败恢复。 diff --git a/docs/tasks/T-679a.md b/docs/tasks/T-679a.md index 444cd9e..63c82f5 100644 --- a/docs/tasks/T-679a.md +++ b/docs/tasks/T-679a.md @@ -3,7 +3,7 @@ id: T-679a title: 商品套图自定义网关多来源任务状态机 phase: 7 deps: [T-678] -status: TODO +status: DONE created: 2026-07-20 --- @@ -88,4 +88,8 @@ git diff --check ## 执行记录 -(做完在这里写:变更文件、状态机决策、验证命令及结果。) +- 已实现双来源任务状态机:默认网关固定持久化为 `cmhub/cmhub` 并保留异步 submit/poll/download;自定义网关固定为 `direct/openai_images_edits`,复用 T-678 同步多图编辑,`task_id` 始终为空、固定 `n=1`、不轮询且不自动重试。执行按 job 已保存来源分派,不读取运行中设置。 +- `image_studio_jobs` 新增 `run_session_id`,`image_studio_assets` 新增请求尺寸与真实像素字段;迁移附加式且幂等。启动期在单进程恢复租约下仅清理陈旧 direct `running` job,切换项目/刷新不触发恢复;已收到 direct 图片字节时停止信号不丢弃成果。 +- 新增比例映射和近似比例结构化结果;直连输出保存用户原比例、请求尺寸及真实图片尺寸。直连 URL、密钥、原始响应均不写入 SQLite、日志或用户错误文案。 +- 已更新 `docs/04-architecture.md`、`docs/api.md`,明确两来源生命周期和 T-679 发布门禁。 +- 验证:`py -3.10 -m unittest tests.test_image_studio_generation tests.test_image_studio tests.test_ai`(99 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 均通过;完整 `py -3.10 -m unittest discover -s tests` 通过(646 项)。 diff --git a/tests/test_image_studio.py b/tests/test_image_studio.py index b2eddda..4cfab95 100644 --- a/tests/test_image_studio.py +++ b/tests/test_image_studio.py @@ -76,8 +76,20 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase): "generation_round_key", "generation_slot_index", "reference_asset_ids", + "run_session_id", }.issubset(jobs_columns) ) + assets_columns = { + row["name"] + for row in conn.execute("PRAGMA table_info(image_studio_assets)").fetchall() + } + self.assertTrue( + { + "requested_output_size", + "rendered_width", + "rendered_height", + }.issubset(assets_columns) + ) indexes = { row["name"] for row in conn.execute( diff --git a/tests/test_image_studio_generation.py b/tests/test_image_studio_generation.py index b0c684b..8826d53 100644 --- a/tests/test_image_studio_generation.py +++ b/tests/test_image_studio_generation.py @@ -4,6 +4,7 @@ import sys import threading import time import unittest +import base64 from concurrent.futures import CancelledError from unittest import mock @@ -11,7 +12,7 @@ sys.path.insert(0, os.path.dirname(__file__)) from _helpers import TempDirMixin -from app import db, image_studio, image_studio_generation +from app import ai, appconfig, db, image_studio, image_studio_generation class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase): @@ -74,6 +75,51 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase): "download_with_curl": "false", } + def _direct_config(self, temp_dir): + cfg = self._config(temp_dir) + models_path = os.path.join(temp_dir, "ai_models.json") + cfg["ai"]["backend"] = "direct" + cfg["ai"]["default_text_model"] = "Text" + cfg["ai"]["default_image_model"] = "Direct Image" + cfg["ai_models_path"] = models_path + appconfig.save_ai_models_config( + { + "models": [ + { + "name": "Text", + "category": "text", + "enabled": True, + "url": "https://text.example.com/v1", + "model": "text-model", + "api_key": "sk-text", + "api_type": "chat", + "connect_timeout_seconds": 3, + "timeout_seconds": 10, + "extra_body": {}, + }, + { + "name": "Direct Image", + "category": "image", + "enabled": True, + "url": "https://image.example.com/v1", + "model": "image-model", + "api_key": "sk-image", + "api_type": "images_edits", + "connect_timeout_seconds": 7, + "timeout_seconds": 12, + "extra_body": {}, + }, + ] + }, + path=models_path, + ) + return ai.freeze_runtime_config( + cfg, + models_path=models_path, + include_cmhub=False, + include_direct_models=True, + ) + def test_generate_image_jobs_creates_independent_jobs_and_assets(self): with self.make_temp_dir() as temp_dir: cfg, project, source = self._project_source(temp_dir) @@ -149,27 +195,28 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) - def test_direct_gateway_rejects_new_suite_job_before_creation(self): + def test_direct_gateway_creates_direct_suite_job_before_execution(self): with self.make_temp_dir() as temp_dir: cfg, project, source = self._project_source(temp_dir) cfg["ai"]["backend"] = "direct" - with mock.patch("app.image_studio_generation.image_studio.create_job") as create_job: - with self.assertRaises(image_studio_generation.ImageStudioGenerationError): - image_studio_generation.create_generation_jobs( - project.id, - source.id, - "不应提交", - 1, - config=cfg, - path=cfg["db_path"], - ) + jobs = image_studio_generation.create_generation_jobs( + project.id, + source.id, + "提交前来源快照", + 1, + config=cfg, + path=cfg["db_path"], + ) - create_job.assert_not_called() + self.assertEqual(1, len(jobs)) + self.assertEqual("direct", jobs[0].generation_source) + self.assertEqual("openai_images_edits", jobs[0].provider) + self.assertIsNone(jobs[0].task_id) self.assert_removed(temp_dir) - def test_resume_rejects_non_default_gateway_task_without_reading_gateway_config(self): + def test_direct_job_with_invalid_task_id_is_not_sent_to_any_gateway(self): with self.make_temp_dir() as temp_dir: cfg, project, source = self._project_source(temp_dir) cfg["ai"]["backend"] = "direct" @@ -179,16 +226,22 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase): job_type="白底图", prompt="旧任务", generation_source="direct", - provider="direct", - path=cfg["db_path"], - ) - job = image_studio.set_job_submitted( - job.id, - "custom-task-1", + provider="openai_images_edits", path=cfg["db_path"], ) + conn = db.connect(cfg["db_path"]) + try: + conn.execute( + "UPDATE image_studio_jobs SET task_id = 'custom-task-1' WHERE id = ?", + (job.id,), + ) + conn.commit() + finally: + conn.close() + job = image_studio.get_job(job.id, path=cfg["db_path"]) - with mock.patch("app.image_studio_generation._runtime") as runtime: + with mock.patch("app.image_studio_generation._runtime") as runtime, \ + mock.patch("app.image_studio_generation._direct_runtime") as direct_runtime: summary = image_studio_generation.run_jobs( [job], config=cfg, @@ -196,9 +249,168 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase): ) runtime.assert_not_called() + direct_runtime.assert_not_called() self.assertEqual(1, summary["total"]) self.assertEqual(1, summary["failed"]) - self.assertIn("不属于默认网关", summary["jobs"][0]["error"]) + self.assertIn("来源无效", summary["jobs"][0]["error"]) + + self.assert_removed(temp_dir) + + def test_direct_job_uses_one_ordered_edit_request_and_records_output_dimensions(self): + with self.make_temp_dir() as temp_dir: + _cfg, project, source = self._project_source(temp_dir) + cfg = self._direct_config(temp_dir) + reference_path = os.path.join(temp_dir, "reference.png") + with open(reference_path, "wb") as fh: + fh.write(self._png_bytes()) + reference = image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + local_path=reference_path, + source_order=2, + path=cfg["db_path"], + ) + job = image_studio.create_job( + project.id, + source_asset_id=source.id, + reference_asset_ids=[reference.id], + job_type="场景图", + prompt="已冻结的套图提示词", + generation_source="direct", + provider="openai_images_edits", + path=cfg["db_path"], + ) + seen = [] + encoded = base64.b64encode(self._png_bytes()).decode("ascii") + + def fake_direct_call(model, body, config, attempts, **kwargs): + seen.append((model, body, config, attempts, kwargs)) + return {"data": [{"b64_json": encoded}]} + + with mock.patch( + "app.image_studio_generation.ai._call_with_retry", + side_effect=fake_direct_call, + ), mock.patch("app.image_studio_generation._runtime") as cmhub_runtime, \ + mock.patch("app.image_studio_generation.ai._cmhub_call_with_retry") as cmhub_submit, \ + mock.patch("app.image_studio_generation.ai._cmhub_call_once") as cmhub_poll: + summary = image_studio_generation.run_jobs( + [job], + aspect_ratio="3:4", + config=cfg, + path=cfg["db_path"], + run_session_id="direct-test-session", + ) + + self.assertEqual(1, summary["success"]) + self.assertEqual("1024x1536", summary["output"]["requested_output_size"]) + self.assertTrue(summary["output"]["approximate_ratio"]) + self.assertEqual(1, len(seen)) + _model, body, _config, attempts, kwargs = seen[0] + self.assertEqual(1, attempts) + self.assertEqual("multipart", kwargs["request_kind"]) + self.assertEqual(2, body.count(b'name="image[]"')) + self.assertLess(body.index(b"source.png"), body.index(b"reference.png")) + self.assertIn(b'name="n"', body) + self.assertIn(b"\r\n1\r\n", body) + cmhub_runtime.assert_not_called() + cmhub_submit.assert_not_called() + cmhub_poll.assert_not_called() + + stored = image_studio.get_job(job.id, path=cfg["db_path"]) + self.assertEqual("succeeded", stored.status) + self.assertEqual("direct", stored.generation_source) + self.assertEqual("openai_images_edits", stored.provider) + self.assertIsNone(stored.task_id) + self.assertEqual("direct-test-session", stored.run_session_id) + asset = image_studio.get_asset(stored.output_asset_id, path=cfg["db_path"]) + self.assertIsNone(asset.remote_url) + self.assertEqual("3:4", asset.aspect_ratio) + self.assertEqual("1024x1536", asset.requested_output_size) + self.assertEqual((1024, 1536), (asset.rendered_width, asset.rendered_height)) + + self.assert_removed(temp_dir) + + def test_direct_job_saves_returned_image_after_stop_and_never_retries(self): + with self.make_temp_dir() as temp_dir: + _cfg, project, source = self._project_source(temp_dir) + cfg = self._direct_config(temp_dir) + job = image_studio.create_job( + project.id, + source_asset_id=source.id, + prompt="停止后保存", + generation_source="direct", + provider="openai_images_edits", + path=cfg["db_path"], + ) + stopped = {"value": False} + encoded = base64.b64encode(self._png_bytes()).decode("ascii") + + def fake_direct_call(*args, **kwargs): + self.assertEqual(1, args[3]) + stopped["value"] = True + return {"data": [{"b64_json": encoded}]} + + with mock.patch( + "app.image_studio_generation.ai._call_with_retry", + side_effect=fake_direct_call, + ) as direct_call: + summary = image_studio_generation.run_jobs( + [job], + config=cfg, + path=cfg["db_path"], + should_stop=lambda: stopped["value"], + ) + + self.assertEqual(1, summary["success"]) + self.assertEqual(1, direct_call.call_count) + self.assertEqual("succeeded", image_studio.get_job(job.id, path=cfg["db_path"]).status) + + self.assert_removed(temp_dir) + + def test_startup_recovery_marks_only_stale_direct_running_jobs_failed(self): + with self.make_temp_dir() as temp_dir: + cfg, project, source = self._project_source(temp_dir) + stale = image_studio.create_job( + project.id, + source_asset_id=source.id, + prompt="已中断", + generation_source="direct", + provider="openai_images_edits", + path=cfg["db_path"], + ) + active = image_studio.create_job( + project.id, + source_asset_id=source.id, + prompt="仍在执行", + generation_source="direct", + provider="openai_images_edits", + path=cfg["db_path"], + ) + image_studio.update_job_status( + stale.id, + "running", + run_session_id="previous-session", + path=cfg["db_path"], + ) + image_studio.update_job_status( + active.id, + "running", + run_session_id="active-session", + path=cfg["db_path"], + ) + + recovered = image_studio.fail_stale_direct_jobs( + active_run_session_ids=["active-session"], + path=cfg["db_path"], + ) + + self.assertEqual([stale.id], [job.id for job in recovered]) + stale = image_studio.get_job(stale.id, path=cfg["db_path"]) + active = image_studio.get_job(active.id, path=cfg["db_path"]) + self.assertEqual("failed", stale.status) + self.assertIn("程序中断", stale.error) + self.assertEqual(image_studio.JOB_RECOVERY_REGENERATE, stale.recovery_action) + self.assertEqual("running", active.status) self.assert_removed(temp_dir)