feat(product-suite): add direct job state machine

This commit is contained in:
chengma
2026-07-20 18:12:44 +08:00
parent 2a2cbad7bd
commit aa000ff5e6
11 changed files with 820 additions and 101 deletions
+13 -1
View File
@@ -2776,7 +2776,19 @@ def _save_jpeg(image_bytes, out_path, resolution, jpg_quality):
def _resolution_size(resolution): 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): def _resolution_size_text(resolution):
+34
View File
@@ -335,6 +335,9 @@ CREATE TABLE IF NOT EXISTS image_studio_assets (
remote_url TEXT, remote_url TEXT,
local_path TEXT, local_path TEXT,
aspect_ratio 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, parent_asset_id INTEGER REFERENCES image_studio_assets(id) ON DELETE SET NULL,
prompt TEXT, prompt TEXT,
status TEXT NOT NULL DEFAULT 'available', status TEXT NOT NULL DEFAULT 'available',
@@ -369,6 +372,7 @@ CREATE TABLE IF NOT EXISTS image_studio_jobs (
call_id TEXT, call_id TEXT,
generation_round_key TEXT, generation_round_key TEXT,
generation_slot_index INTEGER, generation_slot_index INTEGER,
run_session_id TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
submitted_at TEXT, 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_job_recovery_columns(database)
_ensure_image_studio_generation_round_columns(database) _ensure_image_studio_generation_round_columns(database)
_ensure_image_studio_job_reference_asset_ids_column(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): 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" "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: 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] 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] files = [os.path.abspath(file_path) for file_path in file_paths]
+30 -1
View File
@@ -6,7 +6,7 @@ import os
import sys import sys
from dataclasses import replace 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 ..version import APP_NAME, APP_VERSION, display_name
from . import widgets as _widgets from . import widgets as _widgets
from .widgets import * from .widgets import *
@@ -155,10 +155,39 @@ def main() -> int:
update_health.write_health(health_context, "environment_blocked", str(exc)) update_health.write_health(health_context, "environment_blocked", str(exc))
QMessageBox.critical(None, "启动配置错误", str(exc)) QMessageBox.critical(None, "启动配置错误", str(exc))
return 1 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( window = MainWindow(
config=startup["config"], config=startup["config"],
startup_status=startup["message"], startup_status=startup["message"],
) )
if runtime_lease is not None:
app.aboutToQuit.connect(runtime_lease.release)
window.show() window.show()
QTimer.singleShot( QTimer.singleShot(
0, 0,
+11 -9
View File
@@ -262,7 +262,7 @@ class ImageStudioDownloadOriginalWorker(BaseWorker):
class ImageStudioGenerateJobsWorker(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__( def __init__(
self, self,
@@ -285,12 +285,13 @@ class ImageStudioGenerateJobsWorker(BaseWorker):
self.job_type = str(job_type or "main") self.job_type = str(job_type or "main")
self.aspect_ratio = str(aspect_ratio or "1:1") self.aspect_ratio = str(aspect_ratio or "1:1")
self.db_path = db_path self.db_path = db_path
backend = appconfig.ai_backend(config)
self.config = ai.freeze_runtime_config( self.config = ai.freeze_runtime_config(
config, config,
cmhub_config_path=cmhub_config_path, cmhub_config_path=cmhub_config_path,
models_path=(config or {}).get("ai_models_path", appconfig.AI_MODELS_PATH), models_path=(config or {}).get("ai_models_path", appconfig.AI_MODELS_PATH),
include_cmhub=True, include_cmhub=backend == "cmhub",
include_direct_models=False, include_direct_models=backend == "direct",
) )
self.cmhub_config_path = cmhub_config_path self.cmhub_config_path = cmhub_config_path
self._done = 0 self._done = 0
@@ -364,12 +365,13 @@ class ProductSuiteGenerateWorker(BaseWorker):
self.generation_round_key = str(generation_round_key or "").strip() self.generation_round_key = str(generation_round_key or "").strip()
self.aspect_ratio = str(aspect_ratio or "1:1") self.aspect_ratio = str(aspect_ratio or "1:1")
self.db_path = db_path self.db_path = db_path
backend = appconfig.ai_backend(config)
self.config = ai.freeze_runtime_config( self.config = ai.freeze_runtime_config(
config, config,
cmhub_config_path=cmhub_config_path, cmhub_config_path=cmhub_config_path,
models_path=(config or {}).get("ai_models_path", appconfig.AI_MODELS_PATH), models_path=(config or {}).get("ai_models_path", appconfig.AI_MODELS_PATH),
include_cmhub=True, include_cmhub=backend == "cmhub",
include_direct_models=False, include_direct_models=backend == "direct",
) )
self.cmhub_config_path = cmhub_config_path self.cmhub_config_path = cmhub_config_path
self.job_ids = [] self.job_ids = []
@@ -378,12 +380,11 @@ class ProductSuiteGenerateWorker(BaseWorker):
self._lock = threading.Lock() self._lock = threading.Lock()
def execute(self): def execute(self):
if appconfig.ai_backend(self.config) != "cmhub":
raise ValueError("商品套图仅支持默认网关,请到⑤设置切换后再生成")
total = len(self.job_specs) total = len(self.job_specs)
if total <= 0: if total <= 0:
raise ValueError("商品套图生成任务不能为空") raise ValueError("商品套图生成任务不能为空")
jobs = [] jobs = []
source = image_studio_generation.generation_source_for_config(self.config)
for spec in self.job_specs: for spec in self.job_specs:
jobs.append( jobs.append(
image_studio.create_job( image_studio.create_job(
@@ -392,8 +393,8 @@ class ProductSuiteGenerateWorker(BaseWorker):
reference_asset_ids=spec.get("reference_asset_ids"), reference_asset_ids=spec.get("reference_asset_ids"),
job_type=spec.get("job_type") or "套图", job_type=spec.get("job_type") or "套图",
prompt=spec.get("prompt") or "", prompt=spec.get("prompt") or "",
generation_source="cmhub", generation_source=source["generation_source"],
provider="cmhub", provider=source["provider"],
generation_round_key=spec.get("generation_round_key") or self.generation_round_key or None, generation_round_key=spec.get("generation_round_key") or self.generation_round_key or None,
generation_slot_index=spec.get("generation_slot_index"), generation_slot_index=spec.get("generation_slot_index"),
path=self.db_path, path=self.db_path,
@@ -435,6 +436,7 @@ class ProductSuiteGenerateWorker(BaseWorker):
path=self.db_path, path=self.db_path,
should_stop=self.should_cancel, should_stop=self.should_cancel,
on_event=on_event, on_event=on_event,
run_session_id=self.run_token,
) )
summary["project_id"] = self.project_id summary["project_id"] = self.project_id
summary["job_ids"] = list(self.job_ids) summary["job_ids"] = list(self.job_ids)
+115 -6
View File
@@ -33,6 +33,14 @@ JOB_RECOVERY_ACTIONS = {
JOB_RECOVERY_RESUME, JOB_RECOVERY_RESUME,
JOB_RECOVERY_REGENERATE, 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"} SELECTION_TYPES = {"main", "detail"}
@@ -65,6 +73,9 @@ class ImageStudioAsset:
remote_url: Optional[str] remote_url: Optional[str]
local_path: Optional[str] local_path: Optional[str]
aspect_ratio: Optional[str] aspect_ratio: Optional[str]
requested_output_size: Optional[str]
rendered_width: Optional[int]
rendered_height: Optional[int]
parent_asset_id: Optional[int] parent_asset_id: Optional[int]
prompt: Optional[str] prompt: Optional[str]
status: str status: str
@@ -95,6 +106,7 @@ class ImageStudioJob:
call_id: Optional[str] call_id: Optional[str]
generation_round_key: Optional[str] generation_round_key: Optional[str]
generation_slot_index: Optional[int] generation_slot_index: Optional[int]
run_session_id: Optional[str]
created_at: str created_at: str
updated_at: str updated_at: str
submitted_at: Optional[str] submitted_at: Optional[str]
@@ -705,6 +717,9 @@ def add_asset(
remote_url=None, remote_url=None,
local_path=None, local_path=None,
aspect_ratio=None, aspect_ratio=None,
requested_output_size=None,
rendered_width=None,
rendered_height=None,
parent_asset_id=None, parent_asset_id=None,
prompt=None, prompt=None,
status=ASSET_STATUS_AVAILABLE, status=ASSET_STATUS_AVAILABLE,
@@ -720,8 +735,9 @@ def add_asset(
""" """
INSERT INTO image_studio_assets INSERT INTO image_studio_assets
(project_id, kind, remote_url, local_path, aspect_ratio, (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) parent_asset_id, prompt, status, source_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
int(project_id), int(project_id),
@@ -729,6 +745,9 @@ def add_asset(
remote_url, remote_url,
abs_local_path, abs_local_path,
aspect_ratio, 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, parent_asset_id,
prompt, prompt,
str(status or ASSET_STATUS_AVAILABLE), str(status or ASSET_STATUS_AVAILABLE),
@@ -1161,6 +1180,11 @@ def create_job(
path=None, path=None,
conn=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() now = _now()
task_key = str(task_key or _task_key(project_id)) task_key = str(task_key or _task_key(project_id))
reference_ids = _parse_reference_asset_ids(reference_asset_ids, source_asset_id) reference_ids = _parse_reference_asset_ids(reference_asset_ids, source_asset_id)
@@ -1200,8 +1224,8 @@ def create_job(
int(project_id), int(project_id),
source_asset_id, source_asset_id,
reference_json, reference_json,
str(generation_source or "cmhub"), source,
str(provider or "cmhub"), provider,
str(job_type), str(job_type),
task_key, task_key,
str(prompt or ""), str(prompt or ""),
@@ -1664,6 +1688,14 @@ def set_job_submitted(job_id, task_id, *, call_id=None, points_cost=None, points
now = _now() now = _now()
with _connection(conn, path) as database: with _connection(conn, path) as database:
with 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( database.execute(
""" """
UPDATE image_studio_jobs UPDATE image_studio_jobs
@@ -1700,6 +1732,7 @@ def update_job_status(
points_balance=None, points_balance=None,
recovery_action=None, recovery_action=None,
increment_attempts=False, increment_attempts=False,
run_session_id=None,
path=None, path=None,
conn=None, conn=None,
): ):
@@ -1726,6 +1759,7 @@ def update_job_status(
recovery_action = COALESCE(?, recovery_action), recovery_action = COALESCE(?, recovery_action),
output_asset_id = COALESCE(?, output_asset_id), output_asset_id = COALESCE(?, output_asset_id),
points_balance = COALESCE(?, points_balance), points_balance = COALESCE(?, points_balance),
run_session_id = COALESCE(?, run_session_id),
attempts = attempts + ?, attempts = attempts + ?,
finished_at = CASE WHEN ? THEN ? ELSE finished_at END, finished_at = CASE WHEN ? THEN ? ELSE finished_at END,
updated_at = ? updated_at = ?
@@ -1737,6 +1771,7 @@ def update_job_status(
recovery_action, recovery_action,
output_asset_id, output_asset_id,
points_balance, points_balance,
str(run_session_id).strip() if run_session_id else None,
1 if increment_attempts else 0, 1 if increment_attempts else 0,
1 if terminal else 0, 1 if terminal else 0,
now, now,
@@ -1751,13 +1786,35 @@ def list_resumable_jobs(path=None, conn=None, project_id=None, include_failed_do
if include_failed_downloads: if include_failed_downloads:
clauses = [ clauses = [
"task_id IS NOT NULL", "task_id IS NOT NULL",
"generation_source = ?",
"provider = ?",
"recovery_action = ?", "recovery_action = ?",
"status IN (?, ?, ?, ?)", "status IN (?, ?, ?, ?)",
] ]
params = [JOB_RECOVERY_RESUME, "submitted", "running", "failed", "cancelled"] params = [
GENERATION_SOURCE_CMHUB,
PROVIDER_CMHUB,
JOB_RECOVERY_RESUME,
"submitted",
"running",
"failed",
"cancelled",
]
else: else:
clauses = ["status IN (?, ?)", "task_id IS NOT NULL", "recovery_action = ?"] clauses = [
params = ["submitted", "running", JOB_RECOVERY_RESUME] "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: if project_id is not None:
clauses.append("project_id = ?") clauses.append("project_id = ?")
params.append(int(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) 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): def replace_selections(project_id, selection_type, asset_ids: Iterable[int], path=None, conn=None):
selection = str(selection_type) selection = str(selection_type)
if selection not in SELECTION_TYPES: if selection not in SELECTION_TYPES:
+351 -54
View File
@@ -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 from __future__ import annotations
@@ -6,21 +6,97 @@ import os
import threading import threading
import time import time
import urllib.parse import urllib.parse
import uuid
from concurrent.futures import CancelledError, FIRST_COMPLETED, ThreadPoolExecutor, wait from concurrent.futures import CancelledError, FIRST_COMPLETED, ThreadPoolExecutor, wait
from . import ai, appconfig, image_studio from . import ai, appconfig, image_studio
from .version import APP_VERSION from .version import APP_VERSION
MAX_CMHUB_IMAGE_STUDIO_WORKERS = 5 MAX_IMAGE_STUDIO_WORKERS = 5
_GLOBAL_IMAGE_STUDIO_SLOTS = threading.BoundedSemaphore(MAX_CMHUB_IMAGE_STUDIO_WORKERS) _GLOBAL_IMAGE_STUDIO_SLOTS = threading.BoundedSemaphore(MAX_IMAGE_STUDIO_WORKERS)
CMHUB_IMAGE_STUDIO_MAX_INPUT_IMAGES = 8 CMHUB_IMAGE_STUDIO_MAX_INPUT_IMAGES = 8
CMHUB_IMAGE_STUDIO_MAX_SINGLE_INPUT_BYTES = 10 * 1024 * 1024 CMHUB_IMAGE_STUDIO_MAX_SINGLE_INPUT_BYTES = 10 * 1024 * 1024
CMHUB_IMAGE_STUDIO_MAX_TOTAL_INPUT_BYTES = 32 * 1024 * 1024 CMHUB_IMAGE_STUDIO_MAX_TOTAL_INPUT_BYTES = 32 * 1024 * 1024
class ImageStudioGenerationError(RuntimeError): 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): def _notify(callback, event):
@@ -36,16 +112,73 @@ def _runtime(config, cmhub_config_path):
return ai._cmhub_runtime(config, "image", 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): def _is_default_gateway_job(job):
return ( return (
str(getattr(job, "generation_source", "") or "").strip().lower() == "cmhub" str(getattr(job, "generation_source", "") or "").strip().lower()
and str(getattr(job, "provider", "") or "").strip().lower() == "cmhub" == 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): def _ensure_new_submission_allowed(config):
if appconfig.ai_backend(config) != "cmhub": if appconfig.ai_backend(config) not in {"cmhub", "direct"}:
raise ImageStudioGenerationError("商品套图仅支持默认网关,请到⑤设置切换后再生成") raise ImageStudioGenerationError("商品套图生成网关配置无效,请到⑤设置检查")
def _generated_kind(job_type): def _generated_kind(job_type):
@@ -104,6 +237,7 @@ def create_generation_jobs(
total = max(0, int(count or 0)) total = max(0, int(count or 0))
if total <= 0: if total <= 0:
raise ImageStudioGenerationError("生成数量必须大于0") raise ImageStudioGenerationError("生成数量必须大于0")
source = generation_source_for_config(config)
jobs = [] jobs = []
for _ in range(total): for _ in range(total):
jobs.append( jobs.append(
@@ -112,8 +246,8 @@ def create_generation_jobs(
source_asset_id=source_asset_id, source_asset_id=source_asset_id,
job_type=job_type, job_type=job_type,
prompt=prompt, prompt=prompt,
generation_source="cmhub", generation_source=source["generation_source"],
provider="cmhub", provider=source["provider"],
path=path, path=path,
) )
) )
@@ -133,6 +267,7 @@ def generate_image_jobs(
path=None, path=None,
should_stop=None, should_stop=None,
on_event=None, on_event=None,
run_session_id=None,
): ):
jobs = create_generation_jobs( jobs = create_generation_jobs(
project_id, project_id,
@@ -151,6 +286,7 @@ def generate_image_jobs(
path=path, path=path,
should_stop=should_stop, should_stop=should_stop,
on_event=on_event, on_event=on_event,
run_session_id=run_session_id,
) )
@@ -163,6 +299,7 @@ def resume_image_jobs(
path=None, path=None,
should_stop=None, should_stop=None,
on_event=None, on_event=None,
run_session_id=None,
): ):
jobs = image_studio.list_resumable_jobs( jobs = image_studio.list_resumable_jobs(
path=path, path=path,
@@ -177,6 +314,7 @@ def resume_image_jobs(
path=path, path=path,
should_stop=should_stop, should_stop=should_stop,
on_event=on_event, on_event=on_event,
run_session_id=run_session_id,
) )
@@ -189,16 +327,18 @@ def run_jobs(
path=None, path=None,
should_stop=None, should_stop=None,
on_event=None, on_event=None,
run_session_id=None,
): ):
cfg = appconfig.load_config() if config is None else config cfg = appconfig.load_config() if config is None else config
job_list = list(jobs or []) job_list = list(jobs or [])
if not job_list: if not job_list:
return {"total": 0, "success": 0, "failed": 0, "cancelled": 0, "jobs": []} return {"total": 0, "success": 0, "failed": 0, "cancelled": 0, "jobs": []}
rejected = [ rejected = []
job for job in job_list:
for job in job_list if _is_direct_gateway_job(job) and getattr(job, "task_id", None):
if getattr(job, "task_id", None) and not _is_default_gateway_job(job) 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] job_list = [job for job in job_list if job not in rejected]
summary = { summary = {
"total": len(job_list) + len(rejected), "total": len(job_list) + len(rejected),
@@ -209,7 +349,7 @@ def run_jobs(
{ {
"job": job, "job": job,
"status": "failed", "status": "failed",
"error": "该已提交任务不属于默认网关,不能继续查询", "error": "AI工场任务来源无效,不能执行",
} }
for job in rejected for job in rejected
], ],
@@ -217,10 +357,23 @@ def run_jobs(
if not job_list: if not job_list:
return summary return summary
runtime = _runtime(cfg, cmhub_config_path)
ai_cfg = appconfig.ai_config(cfg) ai_cfg = appconfig.ai_config(cfg)
image_root = appconfig.image_dir(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) should_stop = should_stop or (lambda: False)
lock = threading.Lock() lock = threading.Lock()
@@ -239,13 +392,14 @@ def run_jobs(
executor.submit( executor.submit(
_run_one_job_with_global_slot, _run_one_job_with_global_slot,
job.id, job.id,
runtime, runtimes,
cfg, cfg,
image_root, image_root,
aspect_ratio, output_spec,
path, path,
should_stop, should_stop,
on_event, on_event,
run_session_id,
): job ): job
for job in job_list for job in job_list
} }
@@ -273,6 +427,7 @@ def run_jobs(
) )
futures.pop(future, None) futures.pop(future, None)
record({"job": job, "status": "cancelled", "error": "用户停止"}) record({"job": job, "status": "cancelled", "error": "用户停止"})
summary["output"] = dict(output_spec)
return summary return summary
@@ -281,7 +436,17 @@ def _run_one_job_with_global_slot(*args):
return _run_one_job(*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) job = image_studio.get_job(job_id, path=db_path)
if job is None: if job is None:
raise ImageStudioGenerationError("AI工场生图任务不存在") 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, path=db_path,
) )
return {"job": updated, "status": "failed", "error": "项目或源图不存在"} 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: 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 = () reference_assets = ()
if not job.task_id: if not job.task_id:
reference_assets = _reference_assets_for_job(job, db_path) 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) out_path = _output_path(project, job, image_root)
saved_path = _download_and_save_job_image( if is_direct:
request_result, request_result = _submit_direct_job(
out_path, job,
config, source_asset,
on_event, runtime,
job.id, config,
should_stop=should_stop, output_spec,
) should_stop,
try: 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) _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: try:
if os.path.isfile(saved_path): _raise_if_stopped(should_stop)
os.remove(saved_path) except ImageStudioGenerationError:
finally: try:
raise 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( asset = image_studio.add_asset(
project.id, project.id,
_generated_kind(job.job_type), _generated_kind(job.job_type),
remote_url=request_result["image_url"], remote_url=remote_url,
local_path=saved_path, 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, parent_asset_id=source_asset.id,
prompt=job.prompt, prompt=job.prompt,
path=db_path, 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"}) _notify(on_event, {"job_id": job.id, "step": "job_done", "result": "success"})
return {"job": updated, "asset": asset, "status": "succeeded"} return {"job": updated, "asset": asset, "status": "succeeded"}
except Exception as exc: 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" 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) current_job = image_studio.get_job(job.id, path=db_path)
updated = image_studio.update_job_status( updated = image_studio.update_job_status(
job.id, job.id,
@@ -398,6 +617,84 @@ def _reference_assets_for_job(job, db_path):
return assets 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( def _submit_or_resume_job(
job, job,
source_asset, source_asset,
+5 -2
View File
@@ -452,7 +452,10 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
- 进度:标题和图片两条进度分开显示;只生成标题时图片进度显示本轮未生成/0 张,并在运行日志写明本轮生成内容。 - 进度:标题和图片两条进度分开显示;只生成标题时图片进度显示本轮未生成/0 张,并在运行日志写明本轮生成内容。
- 任一组件生成后 `stage=generated`;**不设逐条人工审核阶段**。若只有标题,③可选择只更新标题;若只有封面,③可选择只更新封面,②标题状态仍为待生成,后续补标题会保留已有封面且不重复生图。双击任务弹窗查看旧封面、新封面和历史候选图;T-577 后弹窗内「重置图片」只清当前任务 `new_cover_path` 并归档旧图,不启动单条 `GenerateWorker`,用户退出后用状态筛选「待生成」批量补生成封面。②「重置生成结果」提供标题/封面/全部的多选或当前筛选范围重置,默认不删除本地新封面文件;已生成且未提交线上的新标题可在②表格本地微调。 - 任一组件生成后 `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”。 - 并发数、重试、分辨率、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-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-658b 后 `image_studio_jobs.reference_asset_ids` 以有序 JSON 图片 ID 列表冻结每个商品套图 job 的参考图;新一轮未勾选逐图主图时,第1张为主图、后续最多7张写入快照,勾选时写空数组。历史 `NULL` 行继续按单图任务处理。恢复或重试只读 job 快照,不回读当前原图列表;参考图资产或本地文件缺失时提交前失败,不静默减少提交数量。
- T-658d 后逐图主图 checkbox 右侧显示可换行中文 helper,说明多SKU应逐图生成、同商品多角度应作为参考图一同提交。常规生成确认框从最终 specs 计算主图/参考图数量;未开启逐图主图且可用原图超过8张时,明确第1张主图加前7张参考图的上限和忽略数量。确认总点数仍只按 job 请求数计算,不因单请求图片数量重复计算。 - T-658d 后逐图主图 checkbox 右侧显示可换行中文 helper,说明多SKU应逐图生成、同商品多角度应作为参考图一同提交。常规生成确认框从最终 specs 计算主图/参考图数量;未开启逐图主图且可用原图超过8张时,明确第1张主图加前7张参考图的上限和忽略数量。确认总点数仍只按 job 请求数计算,不因单请求图片数量重复计算。
@@ -463,7 +466,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
- 在商品套图中,已选账号但未填写商品 ID 时允许输入卖点、导入、拖入或粘贴本地图片。非空卖点在现有防抖稳定后会创建一个可恢复临时草稿并保存到 `image_studio_projects.draft_prompt`;首次有效图片导入也会创建草稿。空白卖点、取消选择和全部导入失败不保留空草稿。只有卖点的草稿同样属于可恢复业务内容;卖点清空且没有资产/job 时仍可按空草稿规则清理。草稿可管理本地图片、AI 帮写、生成套图、查看历史和打开结果目录,但在创建 worker、启动 Chrome 或执行 CDP 前禁止「拉取蝦皮主图」。输入合法数字商品 ID 后,经确认原地绑定同一个 `project_id`;资产、job、selection、提示词、套图设置和 `storage_key` 均保持不变。若同账号目标 ID(含软删除项目)已存在则拒绝覆盖或合并。 - 在商品套图中,已选账号但未填写商品 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/下载迟到结果不得覆盖新一轮状态。 - 商品套图拉取蝦皮主图使用独立内存 `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-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-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 不受历史窗口影响。 - T-646 后商品套图主结果区仍只显示项目的当前轮次;「历史生成」改为全局非模态只读窗口,默认跨所有未软删除商品项目按轮次创建时间倒序读取(同一时间以稳定 job ID 补序),首屏及每次翻页最多30轮。窗口只读取 SQLite 的轮次摘要、每槽最新有效 job 和受限尺寸缩略图,不扫描图片目录或一次加载原图;店铺/账号、商品 ID 与“仅当前商品”筛选都在查询层完成,当前商品快捷筛选默认关闭。正常 `generation_round_key` 一轮一行,单张重试只更新该槽位当前状态并计入重试数,不新增历史行;NULL 轮次继续作为“旧版历史记录”兼容,不猜测轮次边界。每行最多显示5张缩略图,余量显示 `+N`;文件缺失只显示中文占位,不删除 DB。双击缩略图从被点图片打开该轮所有可用图片的自适应原尺寸浏览;“导出本轮”在后台仅复制成功且本地存在的输出 asset 到用户选择目录下安全命名的新子目录,确定性追加序号避免覆盖,不移动/重命名/删除内部 asset。窗口不提供批量导出、删除、重试、设为当前轮或重新生成;关闭商品任务不关闭全局窗口,应用退出时协作停止导出并释放窗口资源,生成 worker 不受历史窗口影响。
+9 -4
View File
@@ -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] list_assets(project_id, kind=None, include_missing=True, path=None) -> list[ImageStudioAsset]
reorder_original_assets(project_id, asset_ids, 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] 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_jobs(project_id, statuses=None, path=None) -> list[ImageStudioJob]
list_resumable_jobs(project_id=None, include_failed_downloads=False, 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] list_generation_rounds(project_id, limit=None, offset=0, path=None) -> list[ImageStudioGenerationRound]
get_successful_generation_history_summary(project_id, path=None) -> ImageStudioSuccessfulGenerationHistorySummary get_successful_generation_history_summary(project_id, path=None) -> ImageStudioSuccessfulGenerationHistorySummary
list_global_history_accounts(path=None) -> list[ImageStudioHistoryAccount] 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] build_job_specs(source_assets, base_prompt, settings, item_id) -> list[dict]
# app/image_studio_generation.py # app/image_studio_generation.py
generate_image_jobs(project_id, source_asset_id, prompt, count, job_type="main", 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", ...) -> 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 # app/image_studio_export.py
export_project_selection(project_id, parent_dir, existing_mode="fail", path=None, config=None) -> ExportResult 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()`,不上传、不拖拽、不点击更新。 - 拉取蝦皮原主图只读:复用 `editor.open_product(..., bring_to_front=False)` 和 `editor.read_product_image_urls()`,不上传、不拖拽、不点击更新。
- 原图下载走 `image_studio_images` 的公网 URL、大小、Content-Type、重定向和 PIL 解码校验;只在用户单击时落盘。 - 原图下载走 `image_studio_images` 的公网 URL、大小、Content-Type、重定向和 PIL 解码校验;只在用户单击时落盘。
- `remove_original_assets_if_unused()` 会先校验整批原图的项目归属、资产类型及 job/终选引用,再在单个事务中删除资产行并连续重排 `source_order`;任一图片不可删除时整批不变,本地源文件和蝦皮线上图片始终保留。 - `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-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` 快照返回空列表,恢复/重试不根据当前商品原图补图。 - 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 的任务继续查询,用于下载失败或本地保存失败恢复。 - `include_failed_downloads=True` 允许 failed 但已有 `task_id`、无输出 asset 的任务继续查询,用于下载失败或本地保存失败恢复。
+6 -2
View File
@@ -3,7 +3,7 @@ id: T-679a
title: 商品套图自定义网关多来源任务状态机 title: 商品套图自定义网关多来源任务状态机
phase: 7 phase: 7
deps: [T-678] deps: [T-678]
status: TODO status: DONE
created: 2026-07-20 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 项)。
+12
View File
@@ -76,8 +76,20 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
"generation_round_key", "generation_round_key",
"generation_slot_index", "generation_slot_index",
"reference_asset_ids", "reference_asset_ids",
"run_session_id",
}.issubset(jobs_columns) }.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 = { indexes = {
row["name"] row["name"]
for row in conn.execute( for row in conn.execute(
+234 -22
View File
@@ -4,6 +4,7 @@ import sys
import threading import threading
import time import time
import unittest import unittest
import base64
from concurrent.futures import CancelledError from concurrent.futures import CancelledError
from unittest import mock from unittest import mock
@@ -11,7 +12,7 @@ sys.path.insert(0, os.path.dirname(__file__))
from _helpers import TempDirMixin 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): class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase):
@@ -74,6 +75,51 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase):
"download_with_curl": "false", "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): def test_generate_image_jobs_creates_independent_jobs_and_assets(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir) cfg, project, source = self._project_source(temp_dir)
@@ -149,27 +195,28 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) 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: with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir) cfg, project, source = self._project_source(temp_dir)
cfg["ai"]["backend"] = "direct" cfg["ai"]["backend"] = "direct"
with mock.patch("app.image_studio_generation.image_studio.create_job") as create_job: jobs = image_studio_generation.create_generation_jobs(
with self.assertRaises(image_studio_generation.ImageStudioGenerationError): project.id,
image_studio_generation.create_generation_jobs( source.id,
project.id, "提交前来源快照",
source.id, 1,
"不应提交", config=cfg,
1, path=cfg["db_path"],
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) 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: with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir) cfg, project, source = self._project_source(temp_dir)
cfg["ai"]["backend"] = "direct" cfg["ai"]["backend"] = "direct"
@@ -179,16 +226,22 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase):
job_type="白底图", job_type="白底图",
prompt="旧任务", prompt="旧任务",
generation_source="direct", generation_source="direct",
provider="direct", provider="openai_images_edits",
path=cfg["db_path"],
)
job = image_studio.set_job_submitted(
job.id,
"custom-task-1",
path=cfg["db_path"], 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( summary = image_studio_generation.run_jobs(
[job], [job],
config=cfg, config=cfg,
@@ -196,9 +249,168 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase):
) )
runtime.assert_not_called() runtime.assert_not_called()
direct_runtime.assert_not_called()
self.assertEqual(1, summary["total"]) self.assertEqual(1, summary["total"])
self.assertEqual(1, summary["failed"]) 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) self.assert_removed(temp_dir)