feat(gui): replace AI studio with product suite

This commit is contained in:
chengma
2026-07-14 09:53:13 +08:00
parent fb873aae90
commit bc115ba0d7
27 changed files with 3453 additions and 81 deletions
+14
View File
@@ -299,6 +299,7 @@ CREATE TABLE IF NOT EXISTS image_studio_projects (
target_main_count INTEGER NOT NULL DEFAULT 9,
target_detail_count INTEGER NOT NULL DEFAULT 12,
draft_prompt TEXT,
suite_settings_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
@@ -478,6 +479,7 @@ def init_db(path=None, conn=None) -> None:
_ensure_batch_delete_columns(database)
_ensure_task_image_task_columns(database)
_ensure_task_cover_reset_columns(database)
_ensure_image_studio_project_suite_columns(database)
_ensure_image_studio_job_recovery_columns(database)
@@ -505,6 +507,18 @@ def _ensure_task_cover_reset_columns(database):
database.execute("ALTER TABLE tasks ADD COLUMN cover_reset_at TEXT")
def _ensure_image_studio_project_suite_columns(database):
columns = {
row["name"]
for row in database.execute("PRAGMA table_info(image_studio_projects)").fetchall()
}
if "suite_settings_json" not in columns:
database.execute(
"ALTER TABLE image_studio_projects "
"ADD COLUMN suite_settings_json TEXT NOT NULL DEFAULT '{}'"
)
def _ensure_image_studio_job_recovery_columns(database):
columns = {
row["name"] for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall()
+4
View File
@@ -29,6 +29,9 @@ if QT_IMPORT_ERROR is None:
ImageStudioGenerateJobsWorker,
ImageStudioPullImagesWorker,
ImageStudioResumeJobsWorker,
ProductSuiteAiWriteWorker,
ProductSuiteGenerateWorker,
ProductSuiteImportImagesWorker,
WriteBackWorker,
)
from .tabs.accounts import AccountDialog, AccountsTab
@@ -36,6 +39,7 @@ if QT_IMPORT_ERROR is None:
from .tabs.collect import CollectTab
from .tabs.generate import GenerateTab
from .tabs.image_studio import ImageStudioPreviewDialog, ImageStudioTab
from .tabs.product_suite import ProductSuitePreviewDialog, ProductSuiteTab
from .tabs.settings import SettingsTab
from .main_window import MainWindow
from .update_dialog import ForcedUpdateDialog, UpdatePreparationWorker
+3 -4
View File
@@ -7,7 +7,7 @@ from .tabs.accounts import AccountsTab
from .tabs.apply import ApplyTab
from .tabs.collect import CollectTab
from .tabs.generate import GenerateTab
from .tabs.image_studio import ImageStudioTab
from .tabs.product_suite import ProductSuiteTab
from .tabs.settings import SettingsTab
from .widgets import *
@@ -166,13 +166,12 @@ class MainWindow(QMainWindow):
ai_models_path=self.ai_models_path,
status_callback=self.show_status,
)
if title == "⑥ AI工场":
return ImageStudioTab(
if title == "⑥ 商品套图":
return ProductSuiteTab(
db_path=self.db_path,
config=self.config,
config_path=self.config_path,
status_callback=self.show_status,
prompts_dir=appconfig.image_studio_prompts_dir(self.config),
)
raise ValueError(f"未知主界面模块:{title}")
File diff suppressed because it is too large Load Diff
+1
View File
@@ -60,6 +60,7 @@ TAB_TITLES = [
"③ 更新蝦皮",
"④ 账号管理",
"⑤ 设置",
"⑥ 商品套图",
]
TAB_STYLE = """
+157 -1
View File
@@ -7,7 +7,7 @@ import re
import threading
import time
from .. import image_studio, image_studio_export, image_studio_generation, image_studio_images
from .. import ai, image_studio, image_studio_export, image_studio_generation, image_studio_images
from ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts
from .widgets import *
@@ -67,6 +67,10 @@ def _format_image_studio_event(event):
return prefix
def _format_product_suite_event(event):
return _format_image_studio_event(event).replace("[AI工场]", "[商品套图]", 1)
def _image_studio_user_detail(detail):
text = diagnostics.redact_log_text(str(detail or "")).replace("\r", " ").replace("\n", " ").strip()
text = _USER_LOG_URL_RE.sub("[链接已隐藏]", text)
@@ -286,6 +290,158 @@ class ImageStudioGenerateJobsWorker(BaseWorker):
return summary
class ProductSuiteGenerateWorker(BaseWorker):
"""Create and run independently configured product-suite jobs."""
def __init__(
self,
project_id,
job_specs,
*,
aspect_ratio="1:1",
db_path=None,
config=None,
cmhub_config_path=None,
):
super().__init__()
self.project_id = int(project_id)
self.job_specs = [dict(spec) for spec in (job_specs or [])]
self.aspect_ratio = str(aspect_ratio or "1:1")
self.db_path = db_path
self.config = config
self.cmhub_config_path = cmhub_config_path
self._done = 0
self._failed = 0
self._lock = threading.Lock()
def execute(self):
total = len(self.job_specs)
if total <= 0:
raise ValueError("商品套图生成任务不能为空")
jobs = []
for spec in self.job_specs:
if self.should_cancel():
break
jobs.append(
image_studio.create_job(
self.project_id,
source_asset_id=spec.get("source_asset_id"),
job_type=spec.get("job_type") or "套图",
prompt=spec.get("prompt") or "",
generation_source="cmhub",
provider="cmhub",
path=self.db_path,
)
)
self.progress.emit(
{
"total": len(jobs),
"done": 0,
"failed": 0,
"job_ids": [job.id for job in jobs],
}
)
def on_event(payload):
event = dict(payload or {})
self.log.emit(_format_product_suite_event(event))
if event.get("step") == "job_done":
with self._lock:
self._done += 1
if event.get("result") != "success":
self._failed += 1
progress = {
"total": len(jobs),
"done": self._done,
"failed": self._failed,
"job_ids": [job.id for job in jobs],
}
self.progress.emit(progress)
summary = image_studio_generation.run_jobs(
jobs,
aspect_ratio=self.aspect_ratio,
config=self.config,
cmhub_config_path=self.cmhub_config_path,
path=self.db_path,
should_stop=self.should_cancel,
on_event=on_event,
)
summary["project_id"] = self.project_id
summary["job_ids"] = [job.id for job in jobs]
return summary
class ProductSuiteAiWriteWorker(BaseWorker):
"""Generate product selling-point copy without blocking the suite workspace."""
def __init__(
self,
instruction,
context,
*,
config=None,
cmhub_config_path=None,
):
super().__init__()
self.instruction = str(instruction or "")
self.context = str(context or "")
self.config = config
self.cmhub_config_path = cmhub_config_path
def execute(self):
if self.should_cancel():
return {"cancelled": True}
text = ai.gen_title(
self.instruction,
self.context,
config=self.config,
cmhub_config_path=self.cmhub_config_path,
)
if self.should_cancel():
return {"cancelled": True}
return {"text": str(text or "").strip()}
class ProductSuiteImportImagesWorker(BaseWorker):
"""Validate and copy local product images outside the GUI thread."""
def __init__(
self,
project_id,
*,
file_paths=None,
image_bytes=None,
filename_hint="clipboard.png",
db_path=None,
config=None,
):
super().__init__()
self.project_id = int(project_id)
self.file_paths = list(file_paths or [])
self.image_bytes = bytes(image_bytes) if image_bytes is not None else None
self.filename_hint = str(filename_hint or "clipboard.png")
self.db_path = db_path
self.config = config
def execute(self):
if self.image_bytes is not None:
asset = image_studio_images.import_original_bytes(
self.project_id,
self.image_bytes,
filename_hint=self.filename_hint,
path=self.db_path,
config=self.config,
)
return {"assets": [asset], "errors": [], "limit": 16}
return image_studio_images.import_original_files(
self.project_id,
self.file_paths,
path=self.db_path,
config=self.config,
)
class ImageStudioResumeJobsWorker(BaseWorker):
"""Resume submitted/running or failed-download AI studio jobs."""
+99 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import os
import uuid
from contextlib import contextmanager
@@ -41,6 +42,7 @@ class ImageStudioProject:
target_main_count: int
target_detail_count: int
draft_prompt: Optional[str]
suite_settings_json: str
status: str
created_at: str
updated_at: str
@@ -357,6 +359,32 @@ def update_project_prompt(project_id, draft_prompt, path=None, conn=None):
return get_project(project_id, conn=database)
def project_suite_settings(project) -> dict:
raw = _get(project, "suite_settings_json", "{}") or "{}"
try:
value = json.loads(str(raw))
except (TypeError, ValueError):
return {}
return value if isinstance(value, dict) else {}
def update_project_suite_settings(project_id, settings, path=None, conn=None):
if not isinstance(settings, dict):
raise db.DbError("商品套图设置必须是对象")
payload = json.dumps(settings, ensure_ascii=False, sort_keys=True)
with _connection(conn, path) as database:
with database:
database.execute(
"""
UPDATE image_studio_projects
SET suite_settings_json = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
""",
(payload, _now(), int(project_id)),
)
return get_project(project_id, conn=database)
def soft_delete_project(project_id, reason="", path=None, conn=None):
now = _now()
with _connection(conn, path) as database:
@@ -461,6 +489,47 @@ def list_assets(project_id, kind=None, include_missing=True, path=None, conn=Non
return _fetch_all(database, sql, params, ImageStudioAsset)
def reorder_original_assets(project_id, asset_ids, path=None, conn=None):
ordered_ids = [int(asset_id) for asset_id in asset_ids]
if len(ordered_ids) != len(set(ordered_ids)):
raise db.DbError("商品原图排序不能包含重复图片")
with _connection(conn, path) as database:
rows = database.execute(
"""
SELECT id FROM image_studio_assets
WHERE project_id = ? AND kind = ?
ORDER BY source_order, id
""",
(int(project_id), ASSET_KIND_ORIGINAL),
).fetchall()
existing_ids = [int(row["id"]) for row in rows]
if set(existing_ids) != set(ordered_ids):
raise db.DbError("商品原图排序必须包含当前项目的全部原图")
now = _now()
with database:
for source_order, asset_id in enumerate(ordered_ids, 1):
database.execute(
"""
UPDATE image_studio_assets
SET source_order = ?, updated_at = ?
WHERE id = ? AND project_id = ? AND kind = ?
""",
(
source_order,
now,
asset_id,
int(project_id),
ASSET_KIND_ORIGINAL,
),
)
return list_assets(
project_id,
kind=ASSET_KIND_ORIGINAL,
path=path,
conn=database,
)
def asset_reference_counts(asset_id, path=None, conn=None):
"""Return selection/job references for one asset before pool removal."""
@@ -506,7 +575,7 @@ def remove_asset_if_unused(asset_id, path=None, conn=None):
return asset
def sync_original_asset_urls(project_id, image_urls, path=None, conn=None):
def sync_original_asset_urls(project_id, image_urls, path=None, conn=None, max_assets=16):
"""Store the read-only Shopee main image URL snapshot as remote-only assets."""
normalized = []
@@ -538,6 +607,13 @@ def sync_original_asset_urls(project_id, image_urls, path=None, conn=None):
for row in existing_rows
if row["remote_url"]
}
local_only_count = sum(
1
for row in existing_rows
if not row["remote_url"] and row["status"] != ASSET_STATUS_MISSING
)
remote_limit = max(0, int(max_assets) - local_only_count)
normalized = normalized[:remote_limit]
active_ids = set()
for item in normalized:
row = by_url.get(item["src"])
@@ -577,7 +653,11 @@ def sync_original_asset_urls(project_id, image_urls, path=None, conn=None):
),
)
active_ids.add(int(cursor.lastrowid))
missing_ids = [int(row["id"]) for row in existing_rows if int(row["id"]) not in active_ids]
missing_ids = [
int(row["id"])
for row in existing_rows
if row["remote_url"] and int(row["id"]) not in active_ids
]
if missing_ids:
placeholders = ",".join("?" for _ in missing_ids)
database.execute(
@@ -677,6 +757,23 @@ def get_job(job_id, path=None, conn=None):
)
def list_jobs(project_id, *, statuses=None, path=None, conn=None):
clauses = ["project_id = ?"]
params = [int(project_id)]
requested_statuses = [str(status) for status in (statuses or []) if str(status)]
if requested_statuses:
invalid = set(requested_statuses) - JOB_STATUSES
if invalid:
raise db.DbError("图片生成任务状态无效")
placeholders = ",".join("?" for _ in requested_statuses)
clauses.append(f"status IN ({placeholders})")
params.extend(requested_statuses)
sql = "SELECT * FROM image_studio_jobs WHERE " + " AND ".join(clauses)
sql += " ORDER BY updated_at DESC, id DESC"
with _connection(conn, path) as database:
return _fetch_all(database, sql, params, ImageStudioJob)
def set_job_submitted(job_id, task_id, *, call_id=None, points_cost=None, points_balance=None, path=None, conn=None):
now = _now()
with _connection(conn, path) as database:
+28 -3
View File
@@ -13,6 +13,7 @@ from .version import APP_VERSION
MAX_CMHUB_IMAGE_STUDIO_WORKERS = 5
_GLOBAL_IMAGE_STUDIO_SLOTS = threading.BoundedSemaphore(MAX_CMHUB_IMAGE_STUDIO_WORKERS)
class ImageStudioGenerationError(RuntimeError):
@@ -171,7 +172,7 @@ def run_jobs(
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
_run_one_job,
_run_one_job_with_global_slot,
job.id,
runtime,
cfg,
@@ -206,6 +207,11 @@ def run_jobs(
return summary
def _run_one_job_with_global_slot(*args):
with _GLOBAL_IMAGE_STUDIO_SLOTS:
return _run_one_job(*args)
def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, should_stop, on_event):
job = image_studio.get_job(job_id, path=db_path)
if job is None:
@@ -237,14 +243,24 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho
source_asset,
runtime,
config,
aspect_ratio,
db_path,
should_stop,
on_event,
)
image_studio.update_job_status(job.id, "running", path=db_path)
request_result = _poll_job(job.id, request_result["task_id"], runtime, request_result, db_path, should_stop, on_event)
_raise_if_stopped(should_stop)
out_path = _output_path(project, job, image_root)
saved_path = _download_and_save_job_image(request_result, out_path, config, on_event, job.id)
try:
_raise_if_stopped(should_stop)
except ImageStudioGenerationError:
try:
if os.path.isfile(saved_path):
os.remove(saved_path)
finally:
raise
asset = image_studio.add_asset(
project.id,
_generated_kind(job.job_type),
@@ -278,7 +294,16 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho
return {"job": updated, "status": status, "error": str(exc)}
def _submit_or_resume_job(job, source_asset, runtime, config, db_path, should_stop, on_event):
def _submit_or_resume_job(
job,
source_asset,
runtime,
config,
aspect_ratio,
db_path,
should_stop,
on_event,
):
if job.task_id:
_notify(on_event, {"job_id": job.id, "step": "cover_request", "result": "resume", "task_id": job.task_id})
return _request_result(job.task_id, runtime, config)
@@ -291,7 +316,7 @@ def _submit_or_resume_job(job, source_asset, runtime, config, db_path, should_st
"model": runtime["alias"],
"image_base64": ai._image_data_url(source_path),
"resolution": ai._normalize_cmhub_resolution(resolution),
"aspect_ratio": "1:1",
"aspect_ratio": str(aspect_ratio or "1:1"),
}
_notify(on_event, {"job_id": job.id, "step": "cover_submit", "result": "start"})
data = ai._cmhub_call_with_retry(
+198
View File
@@ -25,6 +25,7 @@ DEFAULT_CONNECT_TIMEOUT_SECONDS = 5
DEFAULT_READ_TIMEOUT_SECONDS = 30
DEFAULT_THUMBNAIL_SIZE = 220
DEFAULT_THUMBNAIL_WORKERS = 4
MAX_ORIGINAL_ASSETS = 16
class ImageStudioImageError(RuntimeError):
@@ -311,6 +312,203 @@ def _existing_local_asset(asset):
return None
def import_original_files(
project_id,
file_paths,
*,
path=None,
config=None,
image_root=None,
max_assets=MAX_ORIGINAL_ASSETS,
):
"""Validate and atomically copy local product images into one studio project."""
imported = []
errors = []
for file_path in file_paths or []:
try:
with open(os.path.abspath(str(file_path)), "rb") as fh:
content = fh.read(int(ORIGINAL_MAX_BYTES) + 1)
imported.append(
import_original_bytes(
project_id,
content,
filename_hint=os.path.basename(str(file_path)),
path=path,
config=config,
image_root=image_root,
max_assets=max_assets,
)
)
except Exception as exc:
errors.append({"path": os.path.abspath(str(file_path)), "error": str(exc)})
return {"assets": imported, "errors": errors, "limit": int(max_assets)}
def import_original_bytes(
project_id,
content,
*,
filename_hint="clipboard.png",
path=None,
config=None,
image_root=None,
max_assets=MAX_ORIGINAL_ASSETS,
):
cfg = appconfig.load_config() if config is None else config
database_path = path or appconfig.db_path(cfg)
project = image_studio.get_project(project_id, path=database_path)
if project is None:
raise ImageStudioImageError("商品套图任务不存在")
image_bytes = bytes(content or b"")
if not image_bytes:
raise ImageStudioImageError("商品原图内容为空")
if len(image_bytes) > int(ORIGINAL_MAX_BYTES):
raise ImageStudioImageError("商品原图超过大小上限")
info = _image_info(image_bytes)
originals = image_studio.list_assets(
project.id,
kind=image_studio.ASSET_KIND_ORIGINAL,
path=database_path,
)
active_originals = [
asset for asset in originals if asset.status != image_studio.ASSET_STATUS_MISSING
]
digest = hashlib.sha256(image_bytes).hexdigest()[:16]
duplicate = next(
(
asset
for asset in originals
if digest in os.path.basename(str(asset.local_path or ""))
and _existing_local_asset(asset) is not None
),
None,
)
if duplicate is not None:
return duplicate
if len(active_originals) >= int(max_assets):
raise ImageStudioImageError("商品原图最多只能添加%d张" % int(max_assets))
directory = image_studio.project_image_dirs(
image_root or appconfig.image_dir(cfg),
project,
)["originals"]
os.makedirs(directory, exist_ok=True)
order = max([int(asset.source_order or 0) for asset in originals] + [0]) + 1
extension = _extension_for_format(info["format"])
safe_hint = os.path.splitext(os.path.basename(str(filename_hint or "image")))[0]
safe_hint = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in safe_hint)
safe_hint = safe_hint.strip("_")[:32] or "image"
final_path = os.path.join(
directory,
"original_%02d_%s_%s%s" % (order, safe_hint, digest, extension),
)
temp_path = final_path + ".tmp-" + uuid.uuid4().hex
created = False
try:
with open(temp_path, "wb") as fh:
fh.write(image_bytes)
with open(temp_path, "rb") as fh:
_image_info(fh.read())
os.replace(temp_path, final_path)
created = True
return image_studio.add_asset(
project.id,
image_studio.ASSET_KIND_ORIGINAL,
local_path=final_path,
aspect_ratio="%d:%d" % (int(info["width"]), int(info["height"])),
source_order=order,
path=database_path,
)
except Exception as exc:
for candidate in (temp_path, final_path if created else None):
if candidate and os.path.exists(candidate):
try:
os.remove(candidate)
except OSError:
pass
if isinstance(exc, ImageStudioImageError):
raise
raise ImageStudioImageError(f"保存商品原图失败:{exc}") from exc
def trash_generated_asset(asset_id, *, path=None, config=None, image_root=None):
"""Move one generated image to the app-managed trash without losing DB history."""
cfg = appconfig.load_config() if config is None else config
database_path = path or appconfig.db_path(cfg)
asset = image_studio.get_asset(asset_id, path=database_path)
if asset is None or not str(asset.kind or "").startswith("generated_"):
raise ImageStudioImageError("只能删除商品套图生成结果")
source_path = os.path.abspath(str(asset.local_path or ""))
if not source_path or not os.path.isfile(source_path):
raise ImageStudioImageError("生成图片文件不存在")
project = image_studio.get_project(asset.project_id, path=database_path)
if project is None:
raise ImageStudioImageError("商品套图任务不存在")
root = image_studio.project_image_dirs(
image_root or appconfig.image_dir(cfg),
project,
)["root"]
trash_dir = os.path.join(root, ".trash")
os.makedirs(trash_dir, exist_ok=True)
trash_path = os.path.join(
trash_dir,
"%s_%s" % (uuid.uuid4().hex, os.path.basename(source_path)),
)
try:
os.replace(source_path, trash_path)
image_studio.update_asset_local_path(
asset.id,
trash_path,
status=image_studio.ASSET_STATUS_MISSING,
path=database_path,
)
except Exception as exc:
if os.path.isfile(trash_path) and not os.path.exists(source_path):
try:
os.replace(trash_path, source_path)
except OSError:
pass
raise ImageStudioImageError(f"删除生成图片失败:{exc}") from exc
return {
"asset_id": asset.id,
"original_path": source_path,
"trash_path": trash_path,
}
def restore_trashed_asset(record, *, path=None, config=None):
cfg = appconfig.load_config() if config is None else config
database_path = path or appconfig.db_path(cfg)
payload = dict(record or {})
asset = image_studio.get_asset(payload.get("asset_id"), path=database_path)
if asset is None:
raise ImageStudioImageError("待撤销的生成图片记录不存在")
trash_path = os.path.abspath(str(payload.get("trash_path") or ""))
original_path = os.path.abspath(str(payload.get("original_path") or ""))
if not os.path.isfile(trash_path):
raise ImageStudioImageError("废纸篓中的生成图片不存在")
if os.path.exists(original_path):
stem, extension = os.path.splitext(original_path)
original_path = "%s_restored_%s%s" % (stem, uuid.uuid4().hex[:8], extension)
os.makedirs(os.path.dirname(original_path), exist_ok=True)
os.replace(trash_path, original_path)
try:
return image_studio.update_asset_local_path(
asset.id,
original_path,
status=image_studio.ASSET_STATUS_AVAILABLE,
path=database_path,
)
except Exception as exc:
try:
os.replace(original_path, trash_path)
except OSError:
pass
raise ImageStudioImageError(f"撤销删除生成图片失败:{exc}") from exc
def download_original_asset(asset_id, *, path=None, config=None, image_root=None, session=None):
"""Download one Shopee original image into originals/ and mark its asset available."""
+148
View File
@@ -0,0 +1,148 @@
"""Pure product-suite configuration and generation planning helpers."""
from __future__ import annotations
from collections import OrderedDict
FIXED_CATEGORIES = ("白底图", "场景图", "卖点图")
DEFAULT_CATEGORY_COUNTS = OrderedDict(
(("白底图", 1), ("场景图", 2), ("卖点图", 2))
)
RATIOS = ("1:1", "3:4", "4:3", "16:9", "9:16")
MAX_CATEGORY_NAME_LENGTH = 10
MAX_GENERATION_COUNT_WITHOUT_CONFIRM = 16
def default_suite_settings():
return {
"platform": "Shopee",
"country": "中国台湾",
"language": "繁体中文",
"ratio": "1:1",
"per_image_primary": False,
"categories": dict(DEFAULT_CATEGORY_COUNTS),
"custom_category_order": [],
}
def normalize_suite_settings(value=None):
raw = dict(value or {}) if isinstance(value, dict) else {}
normalized = default_suite_settings()
normalized["platform"] = str(raw.get("platform") or "Shopee")
normalized["country"] = str(raw.get("country") or "中国台湾")
normalized["language"] = str(raw.get("language") or "繁体中文")
ratio = str(raw.get("ratio") or "1:1")
normalized["ratio"] = ratio if ratio in RATIOS else "1:1"
normalized["per_image_primary"] = bool(raw.get("per_image_primary", False))
raw_categories = raw.get("categories") if isinstance(raw.get("categories"), dict) else {}
categories = OrderedDict()
for name, default_count in DEFAULT_CATEGORY_COUNTS.items():
categories[name] = _count(raw_categories.get(name, default_count))
requested_order = raw.get("custom_category_order")
requested_order = requested_order if isinstance(requested_order, list) else []
seen = set(FIXED_CATEGORIES)
custom_order = []
for candidate in list(requested_order) + list(raw_categories):
name = str(candidate or "")
if name in seen or suite_name_error(name, categories):
continue
seen.add(name)
custom_order.append(name)
categories[name] = _count(raw_categories.get(name, 1))
normalized["categories"] = dict(categories)
normalized["custom_category_order"] = custom_order
return normalized
def suite_name_error(name, existing=None, old_name=""):
value = str(name or "")
if not value.strip():
return "分类名称不能为空"
if value != value.strip() or any(character.isspace() for character in value):
return "分类名称不能包含空格"
if len(value) > MAX_CATEGORY_NAME_LENGTH:
return "分类名称不能超过10个字"
names = set(existing or ())
if value in names and value != str(old_name or ""):
return "分类名称已存在"
return ""
def category_order(settings):
normalized = normalize_suite_settings(settings)
categories = normalized["categories"]
custom = [
name
for name in normalized["custom_category_order"]
if name in categories and name not in FIXED_CATEGORIES
]
return list(FIXED_CATEGORIES) + custom
def suite_total_count(settings, image_count):
normalized = normalize_suite_settings(settings)
categories = normalized["categories"]
base = sum(_count(categories.get(name, 0)) for name in category_order(normalized))
if not normalized["per_image_primary"]:
return base
white_count = _count(categories.get("白底图", 0))
other_count = max(0, base - white_count)
return white_count + other_count * max(1, int(image_count or 0))
def build_suite_prompt(base_prompt, settings, category, item_id, source_index=1):
normalized = normalize_suite_settings(settings)
context = [
"生成一张电商商品套图。",
"平台:%s" % normalized["platform"],
"国家地区:%s" % normalized["country"],
"输出语言:%s" % normalized["language"],
"图片比例:%s" % normalized["ratio"],
"套图分类:%s" % str(category),
"商品ID:%s" % str(item_id or ""),
"当前主参考图序号:%d" % max(1, int(source_index or 1)),
"商品卖点与要求:%s" % str(base_prompt or "").strip(),
"保持商品主体、款式、颜色和关键细节准确,不添加无依据的功能或参数。",
]
return "\n".join(context)
def build_job_specs(source_assets, base_prompt, settings, item_id):
assets = list(source_assets or [])
if not assets:
return []
normalized = normalize_suite_settings(settings)
specs = []
for category in category_order(normalized):
count = _count(normalized["categories"].get(category, 0))
if count <= 0:
continue
targets = assets if normalized["per_image_primary"] and category != "白底图" else assets[:1]
for source_index, asset in enumerate(targets, 1):
for category_index in range(1, count + 1):
specs.append(
{
"source_asset_id": int(getattr(asset, "id", asset)),
"job_type": str(category),
"category": str(category),
"category_index": category_index,
"source_index": source_index,
"prompt": build_suite_prompt(
base_prompt,
normalized,
category,
item_id,
source_index=source_index,
),
}
)
return specs
def _count(value):
try:
return max(0, int(value or 0))
except (TypeError, ValueError):
return 0