feat(gui): replace AI studio with product suite
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -60,6 +60,7 @@ TAB_TITLES = [
|
||||
"③ 更新蝦皮",
|
||||
"④ 账号管理",
|
||||
"⑤ 设置",
|
||||
"⑥ 商品套图",
|
||||
]
|
||||
|
||||
TAB_STYLE = """
|
||||
|
||||
+157
-1
@@ -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
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user