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_main_count INTEGER NOT NULL DEFAULT 9,
|
||||||
target_detail_count INTEGER NOT NULL DEFAULT 12,
|
target_detail_count INTEGER NOT NULL DEFAULT 12,
|
||||||
draft_prompt TEXT,
|
draft_prompt TEXT,
|
||||||
|
suite_settings_json TEXT NOT NULL DEFAULT '{}',
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_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_batch_delete_columns(database)
|
||||||
_ensure_task_image_task_columns(database)
|
_ensure_task_image_task_columns(database)
|
||||||
_ensure_task_cover_reset_columns(database)
|
_ensure_task_cover_reset_columns(database)
|
||||||
|
_ensure_image_studio_project_suite_columns(database)
|
||||||
_ensure_image_studio_job_recovery_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")
|
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):
|
def _ensure_image_studio_job_recovery_columns(database):
|
||||||
columns = {
|
columns = {
|
||||||
row["name"] for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall()
|
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,
|
ImageStudioGenerateJobsWorker,
|
||||||
ImageStudioPullImagesWorker,
|
ImageStudioPullImagesWorker,
|
||||||
ImageStudioResumeJobsWorker,
|
ImageStudioResumeJobsWorker,
|
||||||
|
ProductSuiteAiWriteWorker,
|
||||||
|
ProductSuiteGenerateWorker,
|
||||||
|
ProductSuiteImportImagesWorker,
|
||||||
WriteBackWorker,
|
WriteBackWorker,
|
||||||
)
|
)
|
||||||
from .tabs.accounts import AccountDialog, AccountsTab
|
from .tabs.accounts import AccountDialog, AccountsTab
|
||||||
@@ -36,6 +39,7 @@ if QT_IMPORT_ERROR is None:
|
|||||||
from .tabs.collect import CollectTab
|
from .tabs.collect import CollectTab
|
||||||
from .tabs.generate import GenerateTab
|
from .tabs.generate import GenerateTab
|
||||||
from .tabs.image_studio import ImageStudioPreviewDialog, ImageStudioTab
|
from .tabs.image_studio import ImageStudioPreviewDialog, ImageStudioTab
|
||||||
|
from .tabs.product_suite import ProductSuitePreviewDialog, ProductSuiteTab
|
||||||
from .tabs.settings import SettingsTab
|
from .tabs.settings import SettingsTab
|
||||||
from .main_window import MainWindow
|
from .main_window import MainWindow
|
||||||
from .update_dialog import ForcedUpdateDialog, UpdatePreparationWorker
|
from .update_dialog import ForcedUpdateDialog, UpdatePreparationWorker
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from .tabs.accounts import AccountsTab
|
|||||||
from .tabs.apply import ApplyTab
|
from .tabs.apply import ApplyTab
|
||||||
from .tabs.collect import CollectTab
|
from .tabs.collect import CollectTab
|
||||||
from .tabs.generate import GenerateTab
|
from .tabs.generate import GenerateTab
|
||||||
from .tabs.image_studio import ImageStudioTab
|
from .tabs.product_suite import ProductSuiteTab
|
||||||
from .tabs.settings import SettingsTab
|
from .tabs.settings import SettingsTab
|
||||||
from .widgets import *
|
from .widgets import *
|
||||||
|
|
||||||
@@ -166,13 +166,12 @@ class MainWindow(QMainWindow):
|
|||||||
ai_models_path=self.ai_models_path,
|
ai_models_path=self.ai_models_path,
|
||||||
status_callback=self.show_status,
|
status_callback=self.show_status,
|
||||||
)
|
)
|
||||||
if title == "⑥ AI工场":
|
if title == "⑥ 商品套图":
|
||||||
return ImageStudioTab(
|
return ProductSuiteTab(
|
||||||
db_path=self.db_path,
|
db_path=self.db_path,
|
||||||
config=self.config,
|
config=self.config,
|
||||||
config_path=self.config_path,
|
config_path=self.config_path,
|
||||||
status_callback=self.show_status,
|
status_callback=self.show_status,
|
||||||
prompts_dir=appconfig.image_studio_prompts_dir(self.config),
|
|
||||||
)
|
)
|
||||||
raise ValueError(f"未知主界面模块:{title}")
|
raise ValueError(f"未知主界面模块:{title}")
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -60,6 +60,7 @@ TAB_TITLES = [
|
|||||||
"③ 更新蝦皮",
|
"③ 更新蝦皮",
|
||||||
"④ 账号管理",
|
"④ 账号管理",
|
||||||
"⑤ 设置",
|
"⑤ 设置",
|
||||||
|
"⑥ 商品套图",
|
||||||
]
|
]
|
||||||
|
|
||||||
TAB_STYLE = """
|
TAB_STYLE = """
|
||||||
|
|||||||
+157
-1
@@ -7,7 +7,7 @@ import re
|
|||||||
import threading
|
import threading
|
||||||
import time
|
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 ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts
|
||||||
from .widgets import *
|
from .widgets import *
|
||||||
|
|
||||||
@@ -67,6 +67,10 @@ def _format_image_studio_event(event):
|
|||||||
return prefix
|
return prefix
|
||||||
|
|
||||||
|
|
||||||
|
def _format_product_suite_event(event):
|
||||||
|
return _format_image_studio_event(event).replace("[AI工场]", "[商品套图]", 1)
|
||||||
|
|
||||||
|
|
||||||
def _image_studio_user_detail(detail):
|
def _image_studio_user_detail(detail):
|
||||||
text = diagnostics.redact_log_text(str(detail or "")).replace("\r", " ").replace("\n", " ").strip()
|
text = diagnostics.redact_log_text(str(detail or "")).replace("\r", " ").replace("\n", " ").strip()
|
||||||
text = _USER_LOG_URL_RE.sub("[链接已隐藏]", text)
|
text = _USER_LOG_URL_RE.sub("[链接已隐藏]", text)
|
||||||
@@ -286,6 +290,158 @@ class ImageStudioGenerateJobsWorker(BaseWorker):
|
|||||||
return summary
|
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):
|
class ImageStudioResumeJobsWorker(BaseWorker):
|
||||||
"""Resume submitted/running or failed-download AI studio jobs."""
|
"""Resume submitted/running or failed-download AI studio jobs."""
|
||||||
|
|
||||||
|
|||||||
+99
-2
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
@@ -41,6 +42,7 @@ class ImageStudioProject:
|
|||||||
target_main_count: int
|
target_main_count: int
|
||||||
target_detail_count: int
|
target_detail_count: int
|
||||||
draft_prompt: Optional[str]
|
draft_prompt: Optional[str]
|
||||||
|
suite_settings_json: str
|
||||||
status: str
|
status: str
|
||||||
created_at: str
|
created_at: str
|
||||||
updated_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)
|
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):
|
def soft_delete_project(project_id, reason="", path=None, conn=None):
|
||||||
now = _now()
|
now = _now()
|
||||||
with _connection(conn, path) as database:
|
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)
|
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):
|
def asset_reference_counts(asset_id, path=None, conn=None):
|
||||||
"""Return selection/job references for one asset before pool removal."""
|
"""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
|
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."""
|
"""Store the read-only Shopee main image URL snapshot as remote-only assets."""
|
||||||
|
|
||||||
normalized = []
|
normalized = []
|
||||||
@@ -538,6 +607,13 @@ def sync_original_asset_urls(project_id, image_urls, path=None, conn=None):
|
|||||||
for row in existing_rows
|
for row in existing_rows
|
||||||
if row["remote_url"]
|
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()
|
active_ids = set()
|
||||||
for item in normalized:
|
for item in normalized:
|
||||||
row = by_url.get(item["src"])
|
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))
|
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:
|
if missing_ids:
|
||||||
placeholders = ",".join("?" for _ in missing_ids)
|
placeholders = ",".join("?" for _ in missing_ids)
|
||||||
database.execute(
|
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):
|
def set_job_submitted(job_id, task_id, *, call_id=None, points_cost=None, points_balance=None, path=None, conn=None):
|
||||||
now = _now()
|
now = _now()
|
||||||
with _connection(conn, path) as database:
|
with _connection(conn, path) as database:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from .version import APP_VERSION
|
|||||||
|
|
||||||
|
|
||||||
MAX_CMHUB_IMAGE_STUDIO_WORKERS = 5
|
MAX_CMHUB_IMAGE_STUDIO_WORKERS = 5
|
||||||
|
_GLOBAL_IMAGE_STUDIO_SLOTS = threading.BoundedSemaphore(MAX_CMHUB_IMAGE_STUDIO_WORKERS)
|
||||||
|
|
||||||
|
|
||||||
class ImageStudioGenerationError(RuntimeError):
|
class ImageStudioGenerationError(RuntimeError):
|
||||||
@@ -171,7 +172,7 @@ def run_jobs(
|
|||||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
futures = {
|
futures = {
|
||||||
executor.submit(
|
executor.submit(
|
||||||
_run_one_job,
|
_run_one_job_with_global_slot,
|
||||||
job.id,
|
job.id,
|
||||||
runtime,
|
runtime,
|
||||||
cfg,
|
cfg,
|
||||||
@@ -206,6 +207,11 @@ def run_jobs(
|
|||||||
return summary
|
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):
|
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)
|
job = image_studio.get_job(job_id, path=db_path)
|
||||||
if job is None:
|
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,
|
source_asset,
|
||||||
runtime,
|
runtime,
|
||||||
config,
|
config,
|
||||||
|
aspect_ratio,
|
||||||
db_path,
|
db_path,
|
||||||
should_stop,
|
should_stop,
|
||||||
on_event,
|
on_event,
|
||||||
)
|
)
|
||||||
image_studio.update_job_status(job.id, "running", path=db_path)
|
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)
|
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(request_result, out_path, config, on_event, job.id)
|
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(
|
asset = image_studio.add_asset(
|
||||||
project.id,
|
project.id,
|
||||||
_generated_kind(job.job_type),
|
_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)}
|
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:
|
if job.task_id:
|
||||||
_notify(on_event, {"job_id": job.id, "step": "cover_request", "result": "resume", "task_id": 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)
|
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"],
|
"model": runtime["alias"],
|
||||||
"image_base64": ai._image_data_url(source_path),
|
"image_base64": ai._image_data_url(source_path),
|
||||||
"resolution": ai._normalize_cmhub_resolution(resolution),
|
"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"})
|
_notify(on_event, {"job_id": job.id, "step": "cover_submit", "result": "start"})
|
||||||
data = ai._cmhub_call_with_retry(
|
data = ai._cmhub_call_with_retry(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ DEFAULT_CONNECT_TIMEOUT_SECONDS = 5
|
|||||||
DEFAULT_READ_TIMEOUT_SECONDS = 30
|
DEFAULT_READ_TIMEOUT_SECONDS = 30
|
||||||
DEFAULT_THUMBNAIL_SIZE = 220
|
DEFAULT_THUMBNAIL_SIZE = 220
|
||||||
DEFAULT_THUMBNAIL_WORKERS = 4
|
DEFAULT_THUMBNAIL_WORKERS = 4
|
||||||
|
MAX_ORIGINAL_ASSETS = 16
|
||||||
|
|
||||||
|
|
||||||
class ImageStudioImageError(RuntimeError):
|
class ImageStudioImageError(RuntimeError):
|
||||||
@@ -311,6 +312,203 @@ def _existing_local_asset(asset):
|
|||||||
return None
|
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):
|
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."""
|
"""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
|
||||||
@@ -4,10 +4,10 @@
|
|||||||
|
|
||||||
## 一句话定位
|
## 一句话定位
|
||||||
|
|
||||||
蝦皮圈優化助手(代号 cmshopee)是一个 Windows 本地桌面自动化工具(PySide6,当前正式界面显示 5 个工作流 Tab),让运营管理多个 Shopee 卖家账号,并用 CDP 驱动 Chrome + AI 批量改商品标题、换商品封面。AI工场图片候选工作区的代码与数据能力保留,但当前暂不在主界面展示入口。
|
蝦皮圈優化助手(代号 cmshopee)是一个 Windows 本地桌面自动化工具(PySide6,当前正式界面显示 6 个工作流 Tab),让运营管理多个 Shopee 卖家账号,并用 CDP 驱动 Chrome + AI 批量改商品标题、换商品封面;⑥「商品套图」用于按账号与商品生成本地电商套图,不自动上传蝦皮。
|
||||||
|
|
||||||
主流水线(工作流优先顺序):
|
主流水线(工作流优先顺序):
|
||||||
**① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置**。⑥ AI工场是保留的图片项目/照片池/cmhub 托管生图工作区,当前隐藏主界面入口且不自动上传蝦皮。
|
**① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置 → ⑥ 商品套图**。①~③是 Excel 批量更新主流水线;⑥是独立的本地商品套图工作区,复用 cmhub 托管生图与既有 `image_studio_*` 数据,不自动上传蝦皮。
|
||||||
|
|
||||||
目标闭环:④ 配账号并登录 → ① 导入 Excel(按“别名”列关联账号)、采集旧标题/旧封面并回写 → ② 用提示词 AI 生成新标题/新封面(不设逐条确认阶段)→ ③ 对已生成任务点击「开始更新」,弹窗确认后批量改标题+换封面并点「更新」提交 → 结果实时存 SQLite、批量回写原 Excel。
|
目标闭环:④ 配账号并登录 → ① 导入 Excel(按“别名”列关联账号)、采集旧标题/旧封面并回写 → ② 用提示词 AI 生成新标题/新封面(不设逐条确认阶段)→ ③ 对已生成任务点击「开始更新」,弹窗确认后批量改标题+换封面并点「更新」提交 → 结果实时存 SQLite、批量回写原 Excel。
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
## 当前阶段
|
## 当前阶段
|
||||||
|
|
||||||
当前项目处于:**V0 单账号流程已验证,正在实现 V1 多账号 + Excel + AI 的 5 Tab GUI 工作台;AI工场入口暂时隐藏**。
|
当前项目处于:**V0 单账号流程已验证,V1 多账号 + Excel + AI 的 6 Tab GUI 工作台持续收口中;⑥商品套图已接入**。
|
||||||
|
|
||||||
优先路径:
|
优先路径:
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
|
|
||||||
**V1 当前 coding 目标**:
|
**V1 当前 coding 目标**:
|
||||||
|
|
||||||
- 当前正式界面为 5 Tab 工作台:① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置;⑥ AI工场的代码与本地数据保留,但主界面入口暂时隐藏,不自动提交线上。
|
- 当前正式界面为 6 Tab 工作台:① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置 → ⑥ 商品套图。旧 `ImageStudioTab` 只保留内部兼容,主界面入口由 `ProductSuiteTab` 替代;⑥只生成和管理本地图片,不自动提交线上。
|
||||||
- GUI 固定为 PySide6;后台采集/生成/更新用 `QObject` worker + `QThread` + signal 回传进度。
|
- GUI 固定为 PySide6;后台采集/生成/更新用 `QObject` worker + `QThread` + signal 回传进度。
|
||||||
- 多账号管理;账号以独立 user-data-dir 隔离。③ 更新默认串行,提供「检查本轮更新」按钮;⑤ 可开启按账号并行和设置每批最大更新条数。
|
- 多账号管理;账号以独立 user-data-dir 隔离。③ 更新默认串行,提供「检查本轮更新」按钮;⑤ 可开启按账号并行和设置每批最大更新条数。
|
||||||
- Excel 导入/回写 + SQLite 实时落库 + 本地图片目录。
|
- Excel 导入/回写 + SQLite 实时落库 + 本地图片目录。
|
||||||
|
|||||||
@@ -32,9 +32,9 @@
|
|||||||
| 换封面 | 上传一张本地图片,自动设为商品封面(满 9 张时必须先确认本地旧封面备份存在,再删原封面并上传) | P0 |
|
| 换封面 | 上传一张本地图片,自动设为商品封面(满 9 张时必须先确认本地旧封面备份存在,再删原封面并上传) | P0 |
|
||||||
| 提交更新 | 在显式确认后点击「更新」,把改动提交到线上;原型脚本默认不提交 | P0 |
|
| 提交更新 | 在显式确认后点击「更新」,把改动提交到线上;原型脚本默认不提交 | P0 |
|
||||||
|
|
||||||
### V1 当前目标(多账号 + Excel + AI,5 Tab 流水线)
|
### V1 当前目标(多账号 + Excel + AI,6 Tab 工作台)
|
||||||
|
|
||||||
5 个 Tab,顺序:① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置。
|
6 个 Tab,顺序:① 导入采集 → ② AI生成 → ③ 更新蝦皮 → ④ 账号管理 → ⑤ 设置 → ⑥ 商品套图。
|
||||||
|
|
||||||
| 功能 | 用户能做什么 | 优先级 |
|
| 功能 | 用户能做什么 | 优先级 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -49,6 +49,7 @@
|
|||||||
| 检查本轮更新 / 运行日志 / 多账号并行(③/⑤) | ③ 提供「检查本轮更新」按钮,不打开 Shopee、不提交、不改任务状态,只显示当前筛选范围、店铺分布、预计分批、更新内容和会更新字段;点击「检查本轮更新」或「开始更新」时清空③界面旧日志,只显示本轮检查/更新日志;真实更新写运行日志;可在⑤开启多账号并行,同账号内仍串行 | V2 已接入,检查按钮已接入 |
|
| 检查本轮更新 / 运行日志 / 多账号并行(③/⑤) | ③ 提供「检查本轮更新」按钮,不打开 Shopee、不提交、不改任务状态,只显示当前筛选范围、店铺分布、预计分批、更新内容和会更新字段;点击「检查本轮更新」或「开始更新」时清空③界面旧日志,只显示本轮检查/更新日志;真实更新写运行日志;可在⑤开启多账号并行,同账号内仍串行 | V2 已接入,检查按钮已接入 |
|
||||||
| 结果存储与回写 | 各阶段结果实时存 SQLite;该文件全部完成后把旧/新数据+状态批量回写原 Excel | P0 |
|
| 结果存储与回写 | 各阶段结果实时存 SQLite;该文件全部完成后把旧/新数据+状态批量回写原 Excel | P0 |
|
||||||
| 设置(⑤) | AI 模型管理(下拉+新增/删除/详情/测试连接,至少各一个文本+图像模型);标题/图片大模型角色选择;分辨率(512/1k/2k/4k,普通默认 cmhub 模式下只控制生图尺寸,返回超时只读展示实际口径:标题 600 秒 / 图片 900 秒;direct 兼容路径仍按分辨率映射);并发/重试/jpg质量,其中 cmhub 生图请求与图片下载实际并发最大 5,超过后内部限流且运行日志展示实际并发;图片目录/Chrome 路径/端口;设置页采用居中内容区、适度左右留白和响应式三列表单,长字段跨列显示;点击「保存设置」成功后弹轻量提示框 | P0 |
|
| 设置(⑤) | AI 模型管理(下拉+新增/删除/详情/测试连接,至少各一个文本+图像模型);标题/图片大模型角色选择;分辨率(512/1k/2k/4k,普通默认 cmhub 模式下只控制生图尺寸,返回超时只读展示实际口径:标题 600 秒 / 图片 900 秒;direct 兼容路径仍按分辨率映射);并发/重试/jpg质量,其中 cmhub 生图请求与图片下载实际并发最大 5,超过后内部限流且运行日志展示实际并发;图片目录/Chrome 路径/端口;设置页采用居中内容区、适度左右留白和响应式三列表单,长字段跨列显示;点击「保存设置」成功后弹轻量提示框 | P0 |
|
||||||
|
| 商品套图(⑥) | 以账号+商品ID建立本地图片项目;可拉取蝦皮主图或添加本地原图,按白底图/场景图/卖点图及自定义分类生成套图;支持多任务并行、历史结果、单图重试、删除撤销;只写本地项目,不自动上传或修改蝦皮 | P1 |
|
||||||
| 首次引导保护 | 未配账号、对应账号 Chrome 未启动或未登录时,① ③ 执行按钮禁用/执行前拦截并提示去④;③ 不自动打开缺失账号 Chrome,必须中止本轮更新 | P0 |
|
| 首次引导保护 | 未配账号、对应账号 Chrome 未启动或未登录时,① ③ 执行按钮禁用/执行前拦截并提示去④;③ 不自动打开缺失账号 Chrome,必须中止本轮更新 | P0 |
|
||||||
|
|
||||||
### 后续迭代
|
### 后续迭代
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
## 二、决策记录与演进
|
## 二、决策记录与演进
|
||||||
|
|
||||||
- **CDP 自研而非 playwright**:当前已验证根目录 `cdp.py`,正式代码迁入 `app/cdp.py`;它零重依赖、完全可控,并已在开发环境绕开了代理(`*_proxy` 指向本地 :1080)和 Chrome 的 Origin 403 两个坑。未来若交互复杂度大幅上升,再评估 playwright。
|
- **CDP 自研而非 playwright**:当前已验证根目录 `cdp.py`,正式代码迁入 `app/cdp.py`;它零重依赖、完全可控,并已在开发环境绕开了代理(`*_proxy` 指向本地 :1080)和 Chrome 的 Origin 403 两个坑。未来若交互复杂度大幅上升,再评估 playwright。
|
||||||
- **GUI 选 PySide6**:V1 是 6 Tab 运营工作台,包含任务表格、筛选、图片预览、后台采集/生成/更新、AI工场照片池、进度与停止。当前环境已安装 PySide6,且 Tkinter 不可用;Qt 的 `QThread`/signal-slot 比 Tkinter 手动 queue/after 更适合长任务回传 UI。
|
- **GUI 选 PySide6**:V1 是 6 Tab 运营工作台,包含任务表格、筛选、图片预览、后台采集/生成/更新、商品套图多任务、进度与停止。⑥采用原生 `ProductSuiteTab`,不引入 QtWebEngine;Qt 的 `QThread`/signal-slot 用于长任务回传 UI。
|
||||||
- **存储拆两层**:应用设置进 `data/config.json`,账号/任务/结果进 SQLite。判据:少量人改无需查询 → 配置文件;成行增长要查询/导出 → DB。同一事实只存一处,不重复。取代早期的 `accounts.json` 方案。
|
- **存储拆两层**:应用设置进 `data/config.json`,账号/任务/结果进 SQLite。判据:少量人改无需查询 → 配置文件;成行增长要查询/导出 → DB。同一事实只存一处,不重复。取代早期的 `accounts.json` 方案。
|
||||||
- **Excel 用 openpyxl**:运营用真实 .xlsx;stdlib 无法读写 xlsx,引入一个轻依赖比改用 CSV 更贴合用户习惯。
|
- **Excel 用 openpyxl**:运营用真实 .xlsx;stdlib 无法读写 xlsx,引入一个轻依赖比改用 CSV 更贴合用户习惯。
|
||||||
- **多账号隔离用独立 user-data-dir,不用 Chrome profile**:profile 共享同一 user-data-dir/进程/调试端口,无法每账号独立 CDP 与并行;独立 user-data-dir 才契合自动化。详见 [架构 3.0](04-architecture.md)。
|
- **多账号隔离用独立 user-data-dir,不用 Chrome profile**:profile 共享同一 user-data-dir/进程/调试端口,无法每账号独立 CDP 与并行;独立 user-data-dir 才契合自动化。详见 [架构 3.0](04-architecture.md)。
|
||||||
|
|||||||
+17
-14
@@ -5,14 +5,14 @@
|
|||||||
|
|
||||||
## 一、系统结构
|
## 一、系统结构
|
||||||
|
|
||||||
Windows 本地桌面自动化工具,无后端服务。当前正式 GUI 显示 5 个工作流 Tab;AI工场图片工作区的代码、数据库与本地资产能力保留,但主界面入口暂时隐藏。
|
Windows 本地桌面自动化工具,无后端服务。当前正式 GUI 显示 6 个工作流 Tab;⑥商品套图使用新的原生 PySide6 界面,并复用既有 `image_studio_*` 数据与生图服务。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
运营(人)
|
运营(人)
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
GUI(PySide6 QTabWidget,当前显示 5 Tab)
|
GUI(PySide6 QTabWidget,当前显示 6 Tab)
|
||||||
① 导入采集 ② AI生成 ③ 更新蝦皮 ④ 账号管理 ⑤ 设置
|
① 导入采集 ② AI生成 ③ 更新蝦皮 ④ 账号管理 ⑤ 设置 ⑥ 商品套图
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
核心模块(Python)
|
核心模块(Python)
|
||||||
@@ -25,8 +25,9 @@ GUI(PySide6 QTabWidget,当前显示 5 Tab)
|
|||||||
├── cdp CDP 客户端(连接、找/开 tab、执行 JS、拖拽)
|
├── cdp CDP 客户端(连接、找/开 tab、执行 JS、拖拽)
|
||||||
├── editor 登录检测 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新
|
├── editor 登录检测 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新
|
||||||
├── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面)
|
├── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面)
|
||||||
├── image_studio AI工场项目/资产/job/终选顺序数据服务
|
├── image_studio 商品套图项目/资产/job数据服务(兼容旧AI工场终选)
|
||||||
├── image_studio_images 远程原图安全下载、缩略图、原图落盘
|
├── product_suite 套图设置归一化、数量计算、完整提示词与job规划
|
||||||
|
├── image_studio_images 远程原图安全下载、本地原图导入、生成图废纸篓
|
||||||
├── image_studio_generation cmhub 托管多图异步 submit/poll/download 编排
|
├── image_studio_generation cmhub 托管多图异步 submit/poll/download 编排
|
||||||
└── image_studio_export 终选图片本地 JPEG 转码、目录安全导出
|
└── image_studio_export 终选图片本地 JPEG 转码、目录安全导出
|
||||||
|
|
|
|
||||||
@@ -39,8 +40,8 @@ Shopee 卖家中心页面 / 本地图片目录
|
|||||||
|
|
||||||
真实组件:
|
真实组件:
|
||||||
|
|
||||||
- GUI 入口:根目录 `main.py` 调用 `app/gui/` 包(PySide6 + `QMainWindow` + `QTabWidget`,当前正式界面显示 5 Tab);包入口 `app/gui/__init__.py` 提供 `main()` 并兼容 `from app import gui` / `from app.gui import MainWindow`;也支持 `python -m app`。`ImageStudioTab` 及其服务层仍保留在代码和发布包中,但当前不加入顶层 `QTabWidget`。
|
- GUI 入口:根目录 `main.py` 调用 `app/gui/` 包(PySide6 + `QMainWindow` + `QTabWidget`,当前正式界面显示 6 Tab);包入口 `app/gui/__init__.py` 提供 `main()` 并兼容 `from app import gui` / `from app.gui import MainWindow`;也支持 `python -m app`。第六 Tab 只创建 `ProductSuiteTab`;旧 `ImageStudioTab` 保留内部兼容,但不加入顶层 `QTabWidget`。
|
||||||
- 核心模块统一放在正式代码包 `app/`:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`accounts.py`、`chrome.py`、`editor.py`、`workers.py`、`ai.py`、`prompts.py`、`image_studio.py`、`image_studio_images.py`、`image_studio_generation.py`、`image_studio_export.py`;CDP 底座迁入 `app/cdp.py`(当前根目录 `cdp.py` 为已验证来源)。
|
- 核心模块统一放在正式代码包 `app/`:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`accounts.py`、`chrome.py`、`editor.py`、`workers.py`、`ai.py`、`prompts.py`、`image_studio.py`、`product_suite.py`、`image_studio_images.py`、`image_studio_generation.py`、`image_studio_export.py`;CDP 底座迁入 `app/cdp.py`(当前根目录 `cdp.py` 为已验证来源)。
|
||||||
- 已验证脚本(重构进模块):`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py`、`prototypes/get_title.py`、`prototypes/cookies.py`、`prototypes/inspect_images.py`、`prototypes/grab.py`。
|
- 已验证脚本(重构进模块):`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py`、`prototypes/get_title.py`、`prototypes/cookies.py`、`prototypes/inspect_images.py`、`prototypes/grab.py`。
|
||||||
- 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像;普通产品默认 cmhub 网关,由 `data/config.json` 的 `ai.cmhub` + `data/config/cmhub.json` 配置;direct 直连模型清单仅作为内部兼容/手工回滚路径保留);`openpyxl`。
|
- 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像;普通产品默认 cmhub 网关,由 `data/config.json` 的 `ai.cmhub` + `data/config/cmhub.json` 配置;direct 直连模型清单仅作为内部兼容/手工回滚路径保留);`openpyxl`。
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ imported → collected → generated → applied
|
|||||||
|
|
||||||
## 三、职责划分
|
## 三、职责划分
|
||||||
|
|
||||||
**GUI(当前显示 5 Tab)**:见 [routes.md](routes.md)。只做交互与预览,不写业务逻辑;耗时操作走 PySide6 `QObject` worker + `QThread`,用 signal 回主线程刷新 UI。**① 采集点击后会为本轮匹配到的账号自动确保 Chrome 就绪:已打开则复用,未打开才启动;随后只检测登录态,未登录账号的任务跳过并汇总提示去 ④人工登录。③ 更新蝦皮仍是线上提交高风险链路:执行前只检测账号 Chrome/CDP/登录态,不自动启动缺失账号 Chrome。⑥ AI工场入口当前隐藏;其只读拉图、下载图片和 cmhub 托管生成代码仍保留,且不会自动上传蝦皮。**
|
**GUI(当前显示 6 Tab)**:见 [routes.md](routes.md)。只做交互与预览,不写业务逻辑;耗时操作走 PySide6 `QObject` worker + `QThread`,用 signal 回主线程刷新 UI。**① 采集点击后会为本轮匹配到的账号自动确保 Chrome 就绪:已打开则复用,未打开才启动;随后只检测登录态,未登录账号的任务跳过并汇总提示去 ④人工登录。③ 更新蝦皮仍是线上提交高风险链路:执行前只检测账号 Chrome/CDP/登录态,不自动启动缺失账号 Chrome。⑥商品套图的只读拉图、原图下载/导入、AI帮写和 cmhub 生成均在独立 worker 中运行;多任务可并行,且不会自动上传蝦皮。**
|
||||||
|
|
||||||
**核心模块**
|
**核心模块**
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ imported → collected → generated → applied
|
|||||||
- cmhub 网关 Key → `data/config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。
|
- cmhub 网关 Key → `data/config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。
|
||||||
- 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。
|
- 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。
|
||||||
- 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。
|
- 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。
|
||||||
- 提示词 → 标题当前工作文本存单文件 `data/title_prompt.txt`;标题命名模板存 `data/prompts/title/<名称>.txt`;封面命名模板存 `data/prompts/cover/<名称>.txt`;AI工场完整提示词模板存 `data/prompts/image_studio/<名称>.txt`。
|
- 提示词 → 标题当前工作文本存单文件 `data/title_prompt.txt`;标题命名模板存 `data/prompts/title/<名称>.txt`;封面命名模板存 `data/prompts/cover/<名称>.txt`;旧 AI工场模板目录 `data/prompts/image_studio/<名称>.txt` 仅保留兼容,⑥商品套图直接把卖点文本与结构化设置保存在项目表。
|
||||||
- 登录态 → 各账号 `data/chrome_user_data_dir/<slug>/`。
|
- 登录态 → 各账号 `data/chrome_user_data_dir/<slug>/`。
|
||||||
|
|
||||||
T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源码运行默认项目根 `data/`。`config.json` 中 `user_data_root`、`image_dir`、`db_path` 默认仍保存为 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时由 `appconfig` 解析到 `data/` 下;绝对路径作为高级自定义仍按原值使用。启动时会迁移 T-524 旧包的 exe 顶层数据到 `data/`,并检测 `data/` 可写。
|
T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源码运行默认项目根 `data/`。`config.json` 中 `user_data_root`、`image_dir`、`db_path` 默认仍保存为 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时由 `appconfig` 解析到 `data/` 下;绝对路径作为高级自定义仍按原值使用。启动时会迁移 T-524 旧包的 exe 顶层数据到 `data/`,并检测 `data/` 可写。
|
||||||
@@ -141,7 +142,7 @@ T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/data`,源
|
|||||||
`ai` 段只放**选择 + 全局生成参数**:
|
`ai` 段只放**选择 + 全局生成参数**:
|
||||||
|
|
||||||
- `backend`:内部字段,取值仍支持 `cmhub` / `direct`;普通产品默认 `cmhub`,⑤设置页不再展示「AI 后端」label 或 direct/cmhub 下拉,保存设置固定写 `cmhub`。`direct` 仅保留为内部兼容/手工回滚路径。
|
- `backend`:内部字段,取值仍支持 `cmhub` / `direct`;普通产品默认 `cmhub`,⑤设置页不再展示「AI 后端」label 或 direct/cmhub 下拉,保存设置固定写 `cmhub`。`direct` 仅保留为内部兼容/手工回滚路径。
|
||||||
- `cmhub`:cmhub 网关配置,`base_url` 为网关根地址,保存和请求前会规整为 scheme+host(+port),去掉 `/api`、`/api/v1`、其它路径、查询串和片段;`title_alias` / `image_alias` 为 `GET /api/v1/models` 发现的能力别名,`connect_timeout` 为连接超时;API Key 不在此处保存。⑤设置页与⑥AI工场只展示 cmhub 托管“默认档 / 高质量档 / 省点档”、展示名、用途和扣点提示,不展示 OpenAI Key、Provider URL、上游接口路径或直连模型 slug;执行层仍只保存 alias。
|
- `cmhub`:cmhub 网关配置,`base_url` 为网关根地址,保存和请求前会规整为 scheme+host(+port),去掉 `/api`、`/api/v1`、其它路径、查询串和片段;`title_alias` / `image_alias` 为 `GET /api/v1/models` 发现的能力别名,`connect_timeout` 为连接超时;API Key 不在此处保存。⑤设置页展示托管档位、别名和扣点提示;⑥商品套图直接使用已保存的生图 alias,不展示 OpenAI Key、Provider URL、上游接口路径或直连模型 slug。
|
||||||
- `default_text_model` / `default_image_model`:仅 direct 内部兼容模式下引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型);普通 cmhub 模式不读取这些模型定义,⑤设置页不再展示标题/图片模型角色下拉。
|
- `default_text_model` / `default_image_model`:仅 direct 内部兼容模式下引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型);普通 cmhub 模式不读取这些模型定义,⑤设置页不再展示标题/图片模型角色下拉。
|
||||||
- `generate_mode`:②「生成内容」下拉的主字段,取值 `title` / `cover` / `title_cover`,分别表示只生成标题、只生成封面、生成标题和封面;默认 `title`,避免用户无意产生封面生成成本。
|
- `generate_mode`:②「生成内容」下拉的主字段,取值 `title` / `cover` / `title_cover`,分别表示只生成标题、只生成封面、生成标题和封面;默认 `title`,避免用户无意产生封面生成成本。
|
||||||
- `generate_cover`:旧兼容字段;保存配置时仍写回,值由 `generate_mode` 推导。旧配置 `false` 会迁移为 `title`,`true` 会迁移为 `title_cover`。GUI 和生成逻辑以 `generate_mode` 为准。
|
- `generate_cover`:旧兼容字段;保存配置时仍写回,值由 `generate_mode` 推导。旧配置 `false` 会迁移为 `title`,`true` 会迁移为 `title_cover`。GUI 和生成逻辑以 `generate_mode` 为准。
|
||||||
@@ -419,7 +420,9 @@ 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”。
|
||||||
- ⑥AI工场固定使用⑤保存的 cmhub 生图 alias。生成区显示“cmhub 托管默认档 / 高质量档 / 省点档”、当前生图别名、余额和扣点;档位是产品说明层,真实模型可由 cmhub 后台调整,客户端不保存 OpenAI slug。
|
- ⑥商品套图固定使用⑤保存的 cmhub 生图 alias;平台、国家地区、输出语言、比例、分类、商品ID、参考图序号和卖点文本由 `product_suite.build_suite_prompt()` 组成每个 job 的完整提示词。比例同时传入 `image_studio_generation.run_jobs(aspect_ratio=...)`,最终进入 cmhub 请求与输出资产元数据。
|
||||||
|
- `image_studio_projects.suite_settings_json` 持久化套图设置,旧数据库由 `db.init_db()` 原位补列,默认 `{}`;`draft_prompt` 继续保存卖点文本。`image_studio_assets` 中有效商品原图最多16张,历史 missing 记录不占有效名额;手工原图不会因再次同步蝦皮 URL 被误标 missing。
|
||||||
|
- 第六 Tab 的多个 `SuiteTaskState` 各自保留 generation/pull/import/AI/download worker 与线程引用;切换任务不取消任务。多个任务可并行,但 `image_studio_generation` 使用进程级 semaphore 保证所有套图任务合计最多5个 cmhub 在途 job。线程还在运行时关闭任务只请求协作式停止,模块级引用保留到 `QThread.finished`,不得提前销毁线程对象;下载前后均检查停止信号,停止后的临时文件不入资产库。
|
||||||
|
|
||||||
提示词管理:
|
提示词管理:
|
||||||
|
|
||||||
@@ -461,14 +464,14 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
|
|||||||
| 代理干扰 | 清除 `*_proxy`(requests `trust_env=False`),否则连本地 CDP 超时 |
|
| 代理干扰 | 清除 `*_proxy`(requests `trust_env=False`),否则连本地 CDP 超时 |
|
||||||
| WebSocket Origin | `websocket-client` `suppress_origin=True` |
|
| WebSocket Origin | `websocket-client` `suppress_origin=True` |
|
||||||
| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭;③ 更新时程序自动新建的商品页成功/失败都关闭,成功提交且确认跳回商品列表页时关闭前等待 2 秒;③ 复用用户已有商品页时不关闭页面 |
|
| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭;③ 更新时程序自动新建的商品页成功/失败都关闭,成功提交且确认跳回商品列表页时关闭前等待 2 秒;③ 复用用户已有商品页时不关闭页面 |
|
||||||
| 前台激活 | ①采集和⑥AI工场只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交每条任务都以前台方式新建或激活商品 tab,并执行 `Page.bringToFront`,保障上传、图片管理器刷新和拖拽排序稳定;后台态封面恢复逻辑仅保留给兼容直接调用,不作为正常③批量路径 |
|
| 前台激活 | ①采集和⑥商品套图只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交每条任务都以前台方式新建或激活商品 tab,并执行 `Page.bringToFront`,保障上传、图片管理器刷新和拖拽排序稳定;后台态封面恢复逻辑仅保留给兼容直接调用,不作为正常③批量路径 |
|
||||||
| SPA 就绪 | 不用 load 事件;轮询“标题输入框 + 图片 itembox + 上传输入框”三者都在 |
|
| SPA 就绪 | 不用 load 事件;轮询“标题输入框 + 图片 itembox + 上传输入框”三者都在 |
|
||||||
| 商品页错误 toast | Shopee 错误提示使用 `.eds-toasts` / `.eds-toast__content`,可能很快隐藏或 `display:none`。打开商品页/等待 SPA 就绪前应注入 `MutationObserver` 或等价监听,把 toast 文本、`outerHTML`、当前 URL、时间、可见状态保存到页面缓存(如 `window.__cmshopee_toasts`);等待详情页关键元素超时时,再兜底读取当前 DOM 中的 toast。最近错误 toast 应优先成为 `open_product` 失败原因,并写入 DB 运行日志和本地脱敏诊断日志。只有明确商品失效/不存在/无权限类 toast 才驱动①阶段列显示“商品失效”;网络、CDP、未登录、页面超时、风控等其他失败仍显示“失败” |
|
| 商品页错误 toast | Shopee 错误提示使用 `.eds-toasts` / `.eds-toast__content`,可能很快隐藏或 `display:none`。打开商品页/等待 SPA 就绪前应注入 `MutationObserver` 或等价监听,把 toast 文本、`outerHTML`、当前 URL、时间、可见状态保存到页面缓存(如 `window.__cmshopee_toasts`);等待详情页关键元素超时时,再兜底读取当前 DOM 中的 toast。最近错误 toast 应优先成为 `open_product` 失败原因,并写入 DB 运行日志和本地脱敏诊断日志。只有明确商品失效/不存在/无权限类 toast 才驱动①阶段列显示“商品失效”;网络、CDP、未登录、页面超时、风控等其他失败仍显示“失败” |
|
||||||
|
|
||||||
| 标题输入框 | XPath `//input[@class='eds-input__input' and string-length(@modelvalue)>24]` |
|
| 标题输入框 | XPath `//input[@class='eds-input__input' and string-length(@modelvalue)>24]` |
|
||||||
| 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 |
|
| 写标题 | 原生 setter + 派发 `input`/`change`;`value`==`modelvalue`==新值 |
|
||||||
| 读旧封面 | 第一张 itembox 的 `img.src`(`susercontent` CDN),下载到本地 |
|
| 读旧封面 | 第一张 itembox 的 `img.src`(`susercontent` CDN),下载到本地 |
|
||||||
| AI工场原主图读取 | 复用 `open_product(..., bring_to_front=False)` 后台只读打开商品详情页,使用已验证 itembox 顺序读取全部主图 `img.src` 并返回 `{index, src}`;不要求上传 input 之外的新选择器、不下载图片、不改标题/封面、不拖拽、不点击更新;本轮自动新建 tab 按采集规则关闭,复用用户已有 tab 不关闭 |
|
| 商品套图原主图读取 | 复用 `open_product(..., bring_to_front=False)` 后台只读打开商品详情页,使用已验证 itembox 顺序读取全部主图 `img.src` 并返回 `{index, src}`;不要求上传 input 之外的新选择器、不改标题/封面、不拖拽、不点击更新;URL 读取完成后再由最多2个图片下载 worker 落盘;本轮自动新建 tab 按采集规则关闭,复用用户已有 tab 不关闭 |
|
||||||
| 上传输入框 | `.shopee-image-manager__upload input[type=file]`;上传前先点击 `.shopee-image-manager__upload` 上传块以模拟人工选择图片入口,短暂等待后重新获取 input,再用 `DOM.setFileInputFiles` 传 Windows 路径并派发 `input`/`change` |
|
| 上传输入框 | `.shopee-image-manager__upload input[type=file]`;上传前先点击 `.shopee-image-manager__upload` 上传块以模拟人工选择图片入口,短暂等待后重新获取 input,再用 `DOM.setFileInputFiles` 传 Windows 路径并派发 `input`/`change` |
|
||||||
| 上传成功 | 上传前先等图片管理器稳定。注意分两种状态:未满 9 张时,上传前要求图片 src 连续稳定、无 loading/blob、上传 input 存在且未禁用;满 9 张时,删除第一张之前只要求现有图片列表稳定,不得要求上传 input 可用,因为 Shopee 可能因满格隐藏/禁用上传入口;删除成功后再要求上传 input 恢复可用。上传后等新图 src 为 `susercontent`。若手动上传成功但自动上传一直转圈,优先检查是否绕过了上传块点击导致 Shopee 前端上传队列未完整初始化;代码应走“点击上传块 → 等待 → 重新取 input → `DOM.setFileInputFiles`”的人工等价路径。T-404 补丁后超时失败会返回 `upload_state`,区分仍在转圈(`UPLOAD_STILL_PROCESSING`)、图片上传错误(`UPLOAD_PAGE_ERROR`)、裁剪弹窗(`UPLOAD_CROP_REQUIRED`)和上传入口未恢复(`UPLOAD_INPUT_NOT_READY`);上传阶段只能把图片管理器内错误或图片/文件/上传相关 toast 归为封面上传错误,物流/备货等页面级校验错误不能阻断封面上传,应留到点击「更新」提交阶段处理;`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即失败并提示新封面与现有商品图片重复 |
|
| 上传成功 | 上传前先等图片管理器稳定。注意分两种状态:未满 9 张时,上传前要求图片 src 连续稳定、无 loading/blob、上传 input 存在且未禁用;满 9 张时,删除第一张之前只要求现有图片列表稳定,不得要求上传 input 可用,因为 Shopee 可能因满格隐藏/禁用上传入口;删除成功后再要求上传 input 恢复可用。上传后等新图 src 为 `susercontent`。若手动上传成功但自动上传一直转圈,优先检查是否绕过了上传块点击导致 Shopee 前端上传队列未完整初始化;代码应走“点击上传块 → 等待 → 重新取 input → `DOM.setFileInputFiles`”的人工等价路径。T-404 补丁后超时失败会返回 `upload_state`,区分仍在转圈(`UPLOAD_STILL_PROCESSING`)、图片上传错误(`UPLOAD_PAGE_ERROR`)、裁剪弹窗(`UPLOAD_CROP_REQUIRED`)和上传入口未恢复(`UPLOAD_INPUT_NOT_READY`);上传阶段只能把图片管理器内错误或图片/文件/上传相关 toast 归为封面上传错误,物流/备货等页面级校验错误不能阻断封面上传,应留到点击「更新」提交阶段处理;`有1張重複的圖片` / `重複` / `重复` / `duplicate` 属于封面上传错误,必须立即失败并提示新封面与现有商品图片重复 |
|
||||||
| 封面=第一位 | `Input.dispatchMouseEvent` 拖到第一位,落点 `第一张.left - 0.30*宽` |
|
| 封面=第一位 | `Input.dispatchMouseEvent` 拖到第一位,落点 `第一张.left - 0.30*宽` |
|
||||||
@@ -516,7 +519,7 @@ cmshopee/
|
|||||||
│ └── prompts/
|
│ └── prompts/
|
||||||
│ ├── title/<名称>.txt # 标题提示词命名模板
|
│ ├── title/<名称>.txt # 标题提示词命名模板
|
||||||
│ ├── cover/<名称>.txt # 封面提示词命名模板
|
│ ├── cover/<名称>.txt # 封面提示词命名模板
|
||||||
│ └── image_studio/<名称>.txt # AI工场完整提示词模板
|
│ └── image_studio/<名称>.txt # 旧AI工场模板兼容目录,商品套图不依赖
|
||||||
└── prototypes/ # 已验证原型/探查脚本(demo/set_*/get_title/cookies/inspect_images/grab/1.py)
|
└── prototypes/ # 已验证原型/探查脚本(demo/set_*/get_title/cookies/inspect_images/grab/1.py)
|
||||||
# 逻辑待并入 app/editor.py 后清理;见 prototypes/README.md
|
# 逻辑待并入 app/editor.py 后清理;见 prototypes/README.md
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 一句话定位
|
## 一句话定位
|
||||||
|
|
||||||
蝦皮圈優化助手(代号 cmshopee)是一个给**电商运营**使用的 Windows PySide6 桌面自动化工具,用于**管理多个 Shopee 卖家账号、并用 CDP 驱动 Chrome 批量修改商品标题、替换商品封面图**。V0 已验证单账号「改标题 + 换封面」闭环;当前 V1 工作台为 6 Tab:①导入采集 → ②AI生成 → ③点击「开始更新」并确认后批量提交 → 回写结果,④账号管理,⑤设置,⑥AI工场图片候选生成。
|
蝦皮圈優化助手(代号 cmshopee)是一个给**电商运营**使用的 Windows PySide6 桌面自动化工具,用于**管理多个 Shopee 卖家账号、并用 CDP 驱动 Chrome 批量修改商品标题、替换商品封面图**。V0 已验证单账号「改标题 + 换封面」闭环;当前 V1 工作台为 6 Tab:①导入采集 → ②AI生成 → ③点击「开始更新」并确认后批量提交 → 回写结果,④账号管理,⑤设置,⑥商品套图生成。
|
||||||
|
|
||||||
## 文档导航
|
## 文档导航
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
- [任务看板(历史归档)](06-tasks.md):T-000~T-549 历史任务,已冻结,不再新增。
|
- [任务看板(历史归档)](06-tasks.md):T-000~T-549 历史任务,已冻结,不再新增。
|
||||||
- [模块 / CLI 合约](api.md):本地模块接口、Chrome 启动参数、账号配置 schema。
|
- [模块 / CLI 合约](api.md):本地模块接口、Chrome 启动参数、账号配置 schema。
|
||||||
- [界面与流程结构](routes.md):GUI 窗口、操作流程、按钮职责(无前端路由,用 GUI 流程替代)。
|
- [界面与流程结构](routes.md):GUI 窗口、操作流程、按钮职责(无前端路由,用 GUI 流程替代)。
|
||||||
- [AI工场端到端验收](ai-studio-e2e-checklist.md):⑥ AI工场 cmhub 托管主线的自动化覆盖、人工只读验收和发布检查。
|
- [旧 AI工场兼容层端到端验收](ai-studio-e2e-checklist.md):T-622 前图片工作区的自动化覆盖;当前⑥商品套图继续复用其中的数据与 cmhub 服务。
|
||||||
- [AI工场托管模型评测与默认档位策略](ai-studio-model-evaluation.md):OpenAI / GPT 托管能力经 cmhub 别名落地前的评测样本、档位策略、上线门槛与运营用法。
|
- [AI工场托管模型评测与默认档位策略](ai-studio-model-evaluation.md):OpenAI / GPT 托管能力经 cmhub 别名落地前的评测样本、档位策略、上线门槛与运营用法。
|
||||||
- [AI工场 BYOK 启动门禁记录](byok-startup-gate.md):自定义模型 / BYOK 开工前的授权、设备凭证、Provider 白名单、task secret 和接口契约检查记录。
|
- [AI工场 BYOK 启动门禁记录](byok-startup-gate.md):自定义模型 / BYOK 开工前的授权、设备凭证、Provider 白名单、task secret 和接口契约检查记录。
|
||||||
- [当前实现状态](current-state.md):当前代码现实、可运行命令、下一步可做任务。
|
- [当前实现状态](current-state.md):当前代码现实、可运行命令、下一步可做任务。
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# AI工场端到端验收与发布检查
|
# AI工场端到端验收与发布检查
|
||||||
|
|
||||||
|
> T-622 后主窗口入口已由「⑥商品套图」替换;本文保留为旧 `ImageStudioTab` 与共享 `image_studio_*` 服务的兼容回归清单,不再代表当前第六 Tab 的界面验收。当前商品套图交互以 [`tasks/T-622.md`](tasks/T-622.md) 和 [`routes.md`](routes.md) 为准。
|
||||||
|
|
||||||
> T-595 阶段性收口文档。本文只记录 AI工场当前已实现的 cmhub 托管主线,不包含 BYOK、自定义 Provider、自动上传蝦皮或本地图片导入。
|
> T-595 阶段性收口文档。本文只记录 AI工场当前已实现的 cmhub 托管主线,不包含 BYOK、自定义 Provider、自动上传蝦皮或本地图片导入。
|
||||||
|
|
||||||
## 已实现闭环
|
## 已实现闭环
|
||||||
|
|||||||
+25
-6
@@ -355,9 +355,9 @@ render_prompt(template_text, task) -> str
|
|||||||
- `list_cover_templates()` 不会在启动时创建文件;只有保存/新建/另存为才写 `data/prompts/cover/*.txt`。
|
- `list_cover_templates()` 不会在启动时创建文件;只有保存/新建/另存为才写 `data/prompts/cover/*.txt`。
|
||||||
- 模板名不可为空,不允许路径分隔符、`..` 或 Windows 非法文件名字符;重命名时目标重名会报错。
|
- 模板名不可为空,不允许路径分隔符、`..` 或 Windows 非法文件名字符;重命名时目标重名会报错。
|
||||||
|
|
||||||
## AI工场模块(`app/image_studio*.py`,已建)
|
## 商品套图模块(`app/product_suite.py` + `app/image_studio*.py`,已建)
|
||||||
|
|
||||||
AI工场是 ⑥ Tab 的本地图片项目工作区,不自动上传蝦皮、不修改线上商品。
|
商品套图是 ⑥ Tab 的本地图片项目工作区,不自动上传蝦皮、不修改线上商品。底层继续复用旧 AI工场的数据表与生成服务,避免迁移既有项目数据。
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# app/image_studio.py
|
# app/image_studio.py
|
||||||
@@ -365,9 +365,13 @@ ImageStudioProject / ImageStudioAsset / ImageStudioJob / ImageStudioSelection
|
|||||||
create_or_get_project(account_or_fields, item_id, ...) -> ImageStudioProject
|
create_or_get_project(account_or_fields, item_id, ...) -> ImageStudioProject
|
||||||
list_projects(path=None) -> list[ImageStudioProject]
|
list_projects(path=None) -> list[ImageStudioProject]
|
||||||
update_project_prompt(project_id, draft_prompt, path=None) -> ImageStudioProject
|
update_project_prompt(project_id, draft_prompt, path=None) -> ImageStudioProject
|
||||||
|
project_suite_settings(project) -> dict
|
||||||
|
update_project_suite_settings(project_id, settings, path=None) -> ImageStudioProject
|
||||||
sync_original_asset_urls(project_id, image_urls, path=None) -> list[ImageStudioAsset]
|
sync_original_asset_urls(project_id, image_urls, path=None) -> list[ImageStudioAsset]
|
||||||
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]
|
||||||
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="", ...) -> 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]
|
||||||
replace_selections(project_id, selection_type, asset_ids, path=None) -> list[ImageStudioSelection]
|
replace_selections(project_id, selection_type, asset_ids, path=None) -> list[ImageStudioSelection]
|
||||||
pull_remote_main_image_urls(account_or_alias, item_id, path=None, config=None) -> dict
|
pull_remote_main_image_urls(account_or_alias, item_id, path=None, config=None) -> dict
|
||||||
@@ -376,6 +380,16 @@ pull_remote_main_image_urls(account_or_alias, item_id, path=None, config=None) -
|
|||||||
download_remote_image(url, max_bytes=..., timeout=(connect, read)) -> RemoteImage
|
download_remote_image(url, max_bytes=..., timeout=(connect, read)) -> RemoteImage
|
||||||
load_thumbnail(url, key=None, max_size=220) -> ThumbnailResult
|
load_thumbnail(url, key=None, max_size=220) -> ThumbnailResult
|
||||||
download_original_asset(asset_id, path=None, config=None) -> ImageStudioAsset
|
download_original_asset(asset_id, path=None, config=None) -> ImageStudioAsset
|
||||||
|
import_original_files(project_id, file_paths, path=None, config=None) -> dict
|
||||||
|
import_original_bytes(project_id, content, filename_hint="clipboard.png", ...) -> ImageStudioAsset
|
||||||
|
trash_generated_asset(asset_id, path=None, config=None) -> dict
|
||||||
|
restore_trashed_asset(record, path=None, config=None) -> ImageStudioAsset
|
||||||
|
|
||||||
|
# app/product_suite.py
|
||||||
|
normalize_suite_settings(value=None) -> dict
|
||||||
|
suite_total_count(settings, image_count) -> int
|
||||||
|
build_suite_prompt(base_prompt, settings, category, item_id, source_index=1) -> str
|
||||||
|
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", ...) -> dict
|
||||||
@@ -387,10 +401,11 @@ export_project_selection(project_id, parent_dir, existing_mode="fail", path=None
|
|||||||
|
|
||||||
要点:
|
要点:
|
||||||
|
|
||||||
- 项目唯一键为账号别名 + 商品 ID;图片文件默认在 `data/images/pool/<slug>/<item_id>/` 下分 `originals/generated/exports`。
|
- 项目唯一键为账号别名 + 商品 ID;图片文件默认在 `data/images/pool/<slug>/<item_id>/` 下分 `originals/generated/exports`,删除生成图进入项目内 `.trash` 并可撤销。
|
||||||
|
- `image_studio_projects.suite_settings_json` 保存平台/国家/语言/比例/逐图主图/分类数量;`draft_prompt` 保存商品卖点。有效原图上限16张,missing 历史不占名额。
|
||||||
- 拉取蝦皮原主图只读:复用 `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 解码校验;只在用户单击时落盘。
|
||||||
- cmhub 托管生图每张都是独立 job:保存 `task_key/task_id/status/call_id/points_cost/points_balance`;已有 `task_id` 时只 poll/download,不重复 submit。AI工场生成区只展示托管档位、生图别名、扣点和余额,不展示 Provider URL、OpenAI Key 或上游接口路径。
|
- cmhub 托管生图每张都是独立 job:保存 `task_key/task_id/status/call_id/points_cost/points_balance`;已有 `task_id` 时只 poll/download,不重复 submit。商品套图把平台/国家/语言/比例等上下文写入每个 job prompt,并把比例实参传到 cmhub;界面不展示 Provider URL、OpenAI Key 或上游接口路径。
|
||||||
- `include_failed_downloads=True` 允许 failed 但已有 `task_id`、无输出 asset 的任务继续查询,用于下载失败或本地保存失败恢复。
|
- `include_failed_downloads=True` 允许 failed 但已有 `task_id`、无输出 asset 的任务继续查询,用于下载失败或本地保存失败恢复。
|
||||||
- 终选顺序由 `replace_selections()` 事务替换,主图/详情图同类别去重、跨类别可复用。
|
- 终选顺序由 `replace_selections()` 事务替换,主图/详情图同类别去重、跨类别可复用。
|
||||||
- 导出只写 JPEG 图片文件,透明图铺白底;商品目录已存在时只能覆盖受管命名文件或新建带时间目录,不合并、不递归清空。
|
- 导出只写 JPEG 图片文件,透明图铺白底;商品目录已存在时只能覆盖受管命名文件或新建带时间目录,不合并、不递归清空。
|
||||||
@@ -406,7 +421,8 @@ class CollectTab(QWidget) # ① 导入采集:导入 Excel +
|
|||||||
class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 生成封面图片成本开关 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志
|
class GenerateTab(QWidget) # ② AI生成:提示词管理 + 筛选任务 + 生成封面图片成本开关 + 开始/停止生成 + 新旧封面预览 + AI生成运行日志
|
||||||
class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 检查本轮更新 + 缺失内容校验 + 确认后分批真实更新 + 运行日志
|
class ApplyTab(QWidget) # ③ 更新蝦皮:筛选已生成任务 + 检查本轮更新 + 缺失内容校验 + 确认后分批真实更新 + 运行日志
|
||||||
class SettingsTab(QWidget) # ⑤ 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪
|
class SettingsTab(QWidget) # ⑤ 设置:cmhub 网关配置 + 响应式三列布局 + 角色/生成参数/路径端口 + 蝦皮更新安全 + 未保存状态追踪
|
||||||
class ImageStudioTab(QWidget) # ⑥ AI工场:项目、原图、照片池、终选、cmhub托管生成、导出
|
class ProductSuiteTab(QWidget) # ⑥ 商品套图:多任务、原图、结构配置、AI帮写、cmhub生成、历史结果
|
||||||
|
class ImageStudioTab(QWidget) # 旧AI工场兼容实现;主窗口不再创建
|
||||||
class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed
|
class CollectWorker(BaseWorker) # ① 后台采集:账号就绪预检 -> editor.collect -> db.set_collected/mark_skipped/mark_failed
|
||||||
class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/set_generated_cover/mark_failed + 进度
|
class GenerateWorker(BaseWorker) # ② 后台生成:ai.generate_batch -> db.set_generated/set_generated_cover/mark_failed + 进度
|
||||||
class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped
|
class ApplyWorker(BaseWorker) # ③ 后台更新:账号就绪预检 -> 检查或按批调用 editor.apply_task(...) -> db.set_applied/mark_skipped
|
||||||
@@ -417,6 +433,9 @@ class ImageStudioDownloadOriginalWorker(BaseWorker)# ⑥ 后台下载远程原
|
|||||||
class ImageStudioGenerateJobsWorker(BaseWorker) # ⑥ 后台提交/查询/下载 cmhub 生图 job
|
class ImageStudioGenerateJobsWorker(BaseWorker) # ⑥ 后台提交/查询/下载 cmhub 生图 job
|
||||||
class ImageStudioResumeJobsWorker(BaseWorker) # ⑥ 后台恢复已有 task_id 的生图 job
|
class ImageStudioResumeJobsWorker(BaseWorker) # ⑥ 后台恢复已有 task_id 的生图 job
|
||||||
class ImageStudioExportWorker(BaseWorker) # ⑥ 后台导出终选 JPEG
|
class ImageStudioExportWorker(BaseWorker) # ⑥ 后台导出终选 JPEG
|
||||||
|
class ProductSuiteImportImagesWorker(BaseWorker) # ⑥ 后台校验并复制本地/剪贴板商品原图
|
||||||
|
class ProductSuiteGenerateWorker(BaseWorker) # ⑥ 按套图job规划提交/查询/下载
|
||||||
|
class ProductSuiteAiWriteWorker(BaseWorker) # ⑥ 后台生成商品卖点与画面要求
|
||||||
class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过”
|
class TaskTableModel(QAbstractTableModel) # 任务表格模型:账号/别名/商品ID/阶段;未匹配别名显示“略过”
|
||||||
class GenerateTaskTableModel(QAbstractTableModel) # ② 任务表格模型:店铺/商品ID/旧标题/新标题/状态;generated/未提交/非运行中新标题可本地编辑
|
class GenerateTaskTableModel(QAbstractTableModel) # ② 任务表格模型:店铺/商品ID/旧标题/新标题/状态;generated/未提交/非运行中新标题可本地编辑
|
||||||
class ApplyTaskTableModel(QAbstractTableModel) # ③ 任务表格模型:店铺/商品ID/新标题/新封面/阶段/结果;保持只读,重置更新状态走右键菜单
|
class ApplyTaskTableModel(QAbstractTableModel) # ③ 任务表格模型:店铺/商品ID/新标题/新封面/阶段/结果;保持只读,重置更新状态走右键菜单
|
||||||
@@ -428,7 +447,7 @@ TAB_STYLE: str # 顶层 Tab 栏防误点样式:
|
|||||||
|
|
||||||
T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负责旧导入路径兼容与 `main()`;`main_window.py` 放 `MainWindow`;`models.py` 放 3 个 TableModel;`widgets.py` 放色板、空状态卡、批次总览和日志 helper;`workers.py` 放具体 GUI worker;`tabs/` 下按 ①~⑥ 拆分各 Tab。对外仍保留 `from app import gui`、`from app.gui import MainWindow/CollectTab/GenerateWorker/...`。
|
T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负责旧导入路径兼容与 `main()`;`main_window.py` 放 `MainWindow`;`models.py` 放 3 个 TableModel;`widgets.py` 放色板、空状态卡、批次总览和日志 helper;`workers.py` 放具体 GUI worker;`tabs/` 下按 ①~⑥ 拆分各 Tab。对外仍保留 `from app import gui`、`from app.gui import MainWindow/CollectTab/GenerateWorker/...`。
|
||||||
|
|
||||||
`MainWindow` 已实现六 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新蝦皮筛选列表与检查/确认后分批真实更新、④ 账号管理、⑤ cmhub 设置、⑥ AI工场图片候选生成。缺 PySide6 时 `main()` 返回 1 并输出明确提示。
|
`MainWindow` 已实现六 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新蝦皮筛选列表与检查/确认后分批真实更新、④ 账号管理、⑤ cmhub 设置、⑥ 商品套图生成。缺 PySide6 时 `main()` 返回 1 并输出明确提示。
|
||||||
|
|
||||||
主 Tab 栏必须在 `MainWindow` 初始化时应用 `TAB_STYLE`:6 个 Tab 不使用 Qt 默认紧凑宽度,需保证点击区域稳定、间距清晰、当前 Tab 高亮明显。该样式属于全局导航基础,不归后续业务 Tab 任务重复实现。
|
主 Tab 栏必须在 `MainWindow` 初始化时应用 `TAB_STYLE`:6 个 Tab 不使用 Qt 默认紧凑宽度,需保证点击区域稳定、间距清晰、当前 Tab 高亮明显。该样式属于全局导航基础,不归后续业务 Tab 任务重复实现。
|
||||||
|
|
||||||
|
|||||||
+28
-34
@@ -1,11 +1,11 @@
|
|||||||
# 界面与流程结构
|
# 界面与流程结构
|
||||||
|
|
||||||
> 桌面工具,无前端路由。当前正式界面用 **5 Tab GUI(PySide6 `QMainWindow` + `QTabWidget`)+ 流水线** 约定界面职责与导航;AI工场代码与数据保留,但主界面入口暂时隐藏。
|
> 桌面工具,无前端路由。当前正式界面用 **6 Tab GUI(PySide6 `QMainWindow` + `QTabWidget`)+ 流水线** 约定界面职责与导航;⑥商品套图已替换旧 AI工场入口。
|
||||||
|
|
||||||
## Tab 顺序与职责(工作流优先)
|
## Tab 顺序与职责(工作流优先)
|
||||||
|
|
||||||
```
|
```
|
||||||
① 导入采集 │ ② AI生成 │ ③ 更新蝦皮 │ ④ 账号管理 │ ⑤ 设置
|
① 导入采集 │ ② AI生成 │ ③ 更新蝦皮 │ ④ 账号管理 │ ⑤ 设置 │ ⑥ 商品套图
|
||||||
```
|
```
|
||||||
|
|
||||||
| Tab | 职责 | 风险 |
|
| Tab | 职责 | 风险 |
|
||||||
@@ -15,18 +15,19 @@
|
|||||||
| ③ 更新蝦皮 | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** |
|
| ③ 更新蝦皮 | 对**已生成**任务点击「开始更新」后弹窗确认;确认后打开编辑页换标题+封面并逐条点「更新」提交;结果回写 Excel | **写线上,高** |
|
||||||
| ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;启动登录必须复用已打开的同账号 Chrome,避免重复开窗口;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 |
|
| ④ 账号管理 | Shopee 账号(账号名/别名/数据目录/端口/密码本地明文仅参考/登录状态);启动登录、检测登录、生成快捷方式;启动登录必须复用已打开的同账号 Chrome,避免重复开窗口;检测登录遇到 `accounts.shopee.tw/seller/login` 必须显示未登录 | 中 |
|
||||||
| ⑤ 设置 | cmhub 网关/API Key、生文/生图别名、托管档位提示、生成参数、Chrome 路径、默认端口、蝦皮更新执行参数等 | — |
|
| ⑤ 设置 | cmhub 网关/API Key、生文/生图别名、托管档位提示、生成参数、Chrome 路径、默认端口、蝦皮更新执行参数等 | — |
|
||||||
|
| ⑥ 商品套图 | 按账号+商品ID管理本地图片项目;拉取/添加商品原图,按套图分类异步生图,查看历史与重试 | 本地生成,中 |
|
||||||
|
|
||||||
⑥ AI工场原有代码、SQLite项目与图片资产继续保留,但当前不加入主窗口Tab栏;后续重新开放入口需另立任务。隐藏入口不会删除或迁移用户已有AI工场数据。
|
旧 `ImageStudioTab` 与 `image_studio_*` SQLite/图片资产服务继续保留作内部兼容;主窗口只创建 `ProductSuiteTab`,不会并列暴露两套第六 Tab,也不会删除或迁移用户既有项目数据。
|
||||||
|
|
||||||
任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,点击「开始更新」后必须弹窗确认当前筛选范围、任务数量和线上提交风险。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。
|
任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,点击「开始更新」后必须弹窗确认当前筛选范围、任务数量和线上提交风险。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。
|
||||||
|
|
||||||
## 启动强制升级门禁
|
## 启动强制升级门禁
|
||||||
|
|
||||||
创建五个业务Tab之前先请求版本接口。服务端明确要求强制升级时,不创建 `MainWindow`,而是显示「必须升级」模态进度窗口:用户点击「立即升级」后可看到下载、校验、准备新版和重启阶段,以及百分比和字节数;运行中可「取消并退出」,失败后可重试。校验完成后软件启动安装目录外的独立更新器并退出,更新器替换程序后自动重启新版。版本接口完全不可达或非法时仍失败放行;一旦已明确强制,元数据缺失或后续失败都不允许进入旧版主界面。
|
创建六个业务Tab之前先请求版本接口。服务端明确要求强制升级时,不创建 `MainWindow`,而是显示「必须升级」模态进度窗口:用户点击「立即升级」后可看到下载、校验、准备新版和重启阶段,以及百分比和字节数;运行中可「取消并退出」,失败后可重试。校验完成后软件启动安装目录外的独立更新器并退出,更新器替换程序后自动重启新版。版本接口完全不可达或非法时仍失败放行;一旦已明确强制,元数据缺失或后续失败都不允许进入旧版主界面。
|
||||||
|
|
||||||
## 全局 Tab 栏可用性
|
## 全局 Tab 栏可用性
|
||||||
|
|
||||||
当前 5 个主 Tab 是高频导航入口,不能使用 Qt 默认的紧凑宽度。`MainWindow` 必须为 `QTabWidget/QTabBar` 设置基础样式:
|
当前 6 个主 Tab 是高频导航入口,不能使用 Qt 默认的紧凑宽度。`MainWindow` 必须为 `QTabWidget/QTabBar` 设置基础样式:
|
||||||
|
|
||||||
- 每个 Tab 设置稳定最小宽度和足够左右 padding,避免文字贴边或窄到误点。
|
- 每个 Tab 设置稳定最小宽度和足够左右 padding,避免文字贴边或窄到误点。
|
||||||
- Tab 之间保留明显间距,当前 Tab 有清晰背景/边框高亮。
|
- Tab 之间保留明显间距,当前 Tab 有清晰背景/边框高亮。
|
||||||
@@ -179,37 +180,30 @@
|
|||||||
- ③「更新内容」默认只更新标题,每批最大更新条数默认 1,同时更新蝦皮账号默认 1。
|
- ③「更新内容」默认只更新标题,每批最大更新条数默认 1,同时更新蝦皮账号默认 1。
|
||||||
- ③ 点击「开始更新」会先按「更新内容」校验缺失内容,再弹确认框。
|
- ③ 点击「开始更新」会先按「更新内容」校验缺失内容,再弹确认框。
|
||||||
|
|
||||||
## ⑥ AI工场(入口暂时隐藏)
|
## ⑥ 商品套图
|
||||||
|
|
||||||
```
|
```
|
||||||
┌ AI工场 ──────────────────────────────────────────────────────┐
|
┌ 套图任务1 │ 套图任务2 │ + ───────────────────────────────────┐
|
||||||
│ 左轨:账号[▼] 商品ID[____] [打开项目] [拉取主图] [打开项目文件夹] │
|
│ 账号[▼] 商品ID[____________] [拉取蝦皮主图] │
|
||||||
│ 项目列表:账号 / 商品ID / 更新时间 │
|
├ 左侧配置(滚动)────────────┬ 右侧生成结果 ─────────────────────┤
|
||||||
│ 中区:蝦皮原主图(单击下载进池,双击预览) │
|
│ 商品原图:主图/参考1..5/添加 │ 共N张·成功M张 [历史生成][打开文件夹] │
|
||||||
│ 照片池:原图/主图/详情图角标、比例、排队/生成/失败状态 │
|
│ 平台/国家/语言/比例 │ [结果卡][结果卡][失败卡·重试] │
|
||||||
│ 右侧:模板[▼] [新建][重命名][保存][删除] │
|
│ 每张上传图分别作为主图生成 │ │
|
||||||
│ [完整提示词输入框] │
|
│ 商品卖点与要求 [AI帮写/取消] │ │
|
||||||
│ 类型[主图▼] 数量[4] 比例[1:1▼] cmhub扣点/余额提示 │
|
│ 白底图/场景图/卖点图/自定义类 │ 进度条 套图X/Y(秒)·失败N │
|
||||||
│ [开始生成][停止] 进度条 运行日志 │
|
│ [生成套图(N) ⇄ 停止生成] │ │
|
||||||
│ 底部:主图终选 / 详情图终选(拖入、插入、重排、移出) [导出终选] │
|
└─────────────────────────────┴──────────────────────────────────┘
|
||||||
└───────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- 项目以 `账号别名 + 商品ID` 唯一;打开项目只创建/切换本地项目,不修改蝦皮。
|
- 每个顶部任务标签持有独立账号、商品ID、设置、原图、当前 job 集合和 worker;任务可并行生成。切换任务不停止后台操作;关闭运行中任务先确认并协作式取消,线程引用保留到真正结束,避免 `QThread: Destroyed while thread is still running`。
|
||||||
- 「拉取主图」复用已验证只读 CDP:后台打开商品详情页读取主图 URL,写入 `image_studio_assets(kind=original)`;不下载图片、不改标题/封面、不点击更新。
|
- 项目仍以 `账号别名 + 商品ID` 唯一,复用 `image_studio_projects/assets/jobs`。`suite_settings_json` 保存平台、国家地区、语言、比例、逐图主图模式和分类数量;卖点文本继续使用 `draft_prompt`。
|
||||||
- 原主图抽屉单击时才下载对应远程原图到项目 `originals/` 并设为源图;双击远程原图会先下载再打开大图预览。
|
- 商品原图最多16张。前6个槽位固定显示主图与参考1~5;支持文件选择、外部拖入、剪贴板粘贴和列表内排序。历史失效远程图不占有效名额;第1张是主参考图。
|
||||||
- 照片池只展示本地文件存在且可解码使用的原图、生成主图和生成详情图;单击可用图片设为源图,双击打开大图;右键移除只删除未被任务或终选引用的照片池记录,不删除本地图片文件。照片池附近的「生成任务」区单独展示尚未产出可用图片的等待提交、已提交、生成中、生成失败、任务过期和已停止任务,并显示脱敏后的中文原因摘要、恢复方式和已有计费信息;任务卡不能设为源图或拖入终选。
|
- 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作。
|
||||||
- 右侧只有一个完整提示词框;模板目录固定为 `data/prompts/image_studio/`,与②标题/封面模板隔离。界面不显示“主提示词 / 每张动作词”。
|
- 套图只有一个图片类型,不再展示详情图、终选盘或模板 CRUD。默认分类为白底图1、场景图2、卖点图2;自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。
|
||||||
- 生图固定走 cmhub 托管模型,使用⑤设置里的 cmhub Base URL/API Key/生图别名和图片并发;界面显示当前托管档位(默认/高质量/省点)、生图别名、扣点、余额、进度、失败,不展示自定义 Provider、API Key、生成来源选择或“导入本地图片”入口。
|
- 平台、国家地区、语言和比例都写进每个 job 的完整提示词;比例还透传到 cmhub 生图请求,不是装饰字段。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。
|
||||||
- 「继续查询任务」只恢复当前项目中已保存 `task_id` 且标记为可恢复的 cmhub 生图任务,包括已提交、生成中及下载/保存中断后可继续处理的任务;恢复时只 poll/download 原任务,不再次 submit,不重复扣点。上游已终态失败、任务过期或尚未取得 `task_id` 即停止的任务会明确提示需要重新生成,可能产生新的计费;已有 `task_id` 后被用户停止的任务仍可继续查询。
|
- 生成按钮按当前总数显示并在运行时切换为停止。结果区显示本轮或历史 job;成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。
|
||||||
- 底部终选盘分为主图和详情图两列;照片池中已下载/已生成且本地文件可用的图片可拖入终选,落到已有位置时按插入顺延,同一类别内同一照片只能出现一次,主图和详情图之间允许复用同一照片。
|
- AI帮写和生图按任务独立运行。AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏 URL/接口路径和敏感信息。
|
||||||
- 终选列表内可拖动重排,Delete 或右键「移出终选」只移出终选,不删除照片池资产或本地文件;拖放/移出失败时刷新回 SQLite 中的持久化顺序。
|
- ⑥只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。
|
||||||
- 主图推荐 1:1;比例不匹配只用黄色轻提示和 tooltip 提醒,不硬拦。文件缺失或尚未下载的照片不能拖入终选。
|
|
||||||
- 拉主图、下载原图、生图 submit/poll/download 均通过 worker 执行,主线程只刷新 UI。拉主图、导出等互斥 worker 运行时禁用项目切换和工作区写操作;生图或继续查询任务运行时仍只允许一个耗时 worker,但当前项目的照片池浏览/源图选择、主图和详情图终选拖放、提示词模板编辑以及下一轮类型/数量/比例设置保持可用。这些修改只作用于下一轮,当前已提交任务的参数不变;此时仍禁用项目切换、原图区下载、拉主图、开始生成、继续查询、导出和删除项目,停止为协作式停止。
|
|
||||||
- 「导出终选」可在主图/详情图未满目标数量时导出当前终选;主图和详情图总数为 0 时阻断。用户选择导出父目录后,程序在其下创建商品 ID 子目录,按终选顺序转码为 `商品ID_主图_1.jpg`、`商品ID_详情图_1.jpg`,透明图铺白底输出真正 JPEG。
|
|
||||||
- 商品目录已存在时只提供三选:覆盖本软件导出的图片(仅删除匹配当前商品命名规则的旧主图/详情图,保留用户其它文件)、新建带时间目录、取消;不提供合并,也不递归清空用户目录。
|
|
||||||
- 导出前会预检所有终选源文件和图片解码,先写 staging,转码失败不创建商品目录、不留下半套新图;成功后中文提示实际目录和主图/详情图数量,并提供打开目录。
|
|
||||||
- 本小节当前覆盖 T-591/T-593:主界面、终选排序和本地导出已接入;不自动上传或修改蝦皮,BYOK/自定义 Provider 仍后置。
|
|
||||||
|
|
||||||
## 流程导航
|
## 流程导航
|
||||||
|
|
||||||
@@ -239,12 +233,12 @@
|
|||||||
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 缺失内容阻断 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
|
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 缺失内容阻断 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
|
||||||
| `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式;登录检测把 Shopee accounts 登录页判为未登录 |
|
| `AccountsTab(QWidget)` | ④ | 账号增删改、启动登录、检测登录、生成快捷方式;登录检测把 Shopee accounts 登录页判为未登录 |
|
||||||
| `SettingsTab(QWidget)` | ⑤ | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新执行;数据路径字段隐藏但保留配置兼容 |
|
| `SettingsTab(QWidget)` | ⑤ | cmhub 网关配置 + 响应式三列设置表单 + 生成参数 + Chrome/端口配置 + 蝦皮更新执行;数据路径字段隐藏但保留配置兼容 |
|
||||||
| `ImageStudioTab(QWidget)` | ⑥ | AI工场项目列表、只读拉蝦皮主图、原图下载进池、照片池、大图预览、完整提示词模板 CRUD、cmhub 托管多图生成控制 |
|
| `ProductSuiteTab(QWidget)` | ⑥ | 商品套图多任务、账号+商品ID上下文、原图导入/排序、套图分类、AI帮写、cmhub 异步生成、结果历史与删除撤销 |
|
||||||
| `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 |
|
| `TaskTableModel(QAbstractTableModel)` | ①②③ | 任务表格数据模型,供 `QTableView` 使用 |
|
||||||
| `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals |
|
| `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals |
|
||||||
| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、检查本轮更新、按每批最大条数分批、按账号并行或串行调用 `editor.apply_task(...)`、逐条 `set_applied()`,失败继续,写运行日志 |
|
| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、检查本轮更新、按每批最大条数分批、按账号并行或串行调用 `editor.apply_task(...)`、逐条 `set_applied()`,失败继续,写运行日志 |
|
||||||
| `AIModelTestWorker(BaseWorker)` | ⑤ | 后台调用 `appconfig.test_ai_model()` 测试模型连接 |
|
| `AIModelTestWorker(BaseWorker)` | ⑤ | 后台调用 `appconfig.test_ai_model()` 测试模型连接 |
|
||||||
| `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 |
|
| `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 |
|
||||||
| `ImageStudioPullImagesWorker / ImageStudioDownloadOriginalWorker / ImageStudioGenerateJobsWorker` | ⑥ | 后台执行只读拉主图、远程原图下载、cmhub 托管生图 submit/poll/download;不直接操作 QWidget |
|
| `ImageStudioPullImagesWorker / ImageStudioDownloadOriginalWorker / ProductSuiteImportImagesWorker / ProductSuiteGenerateWorker / ProductSuiteAiWriteWorker` | ⑥ | 后台执行只读拉主图、远程原图下载、本地图片校验复制、cmhub 套图生成与AI帮写;不直接操作 QWidget |
|
||||||
|
|
||||||
> 采集、生成、更新都是耗时操作,使用 `QObject` worker + `QThread`。Worker 不直接操作 QWidget,只通过 signal 通知主线程刷新 UI。
|
> 采集、生成、更新都是耗时操作,使用 `QObject` worker + `QThread`。Worker 不直接操作 QWidget,只通过 signal 通知主线程刷新 UI。
|
||||||
|
|||||||
+12
-5
@@ -3,7 +3,7 @@ id: T-622
|
|||||||
title: ⑥用「商品套图」模块替换现有 AI工场(迁移电商图生成器套餐模块)
|
title: ⑥用「商品套图」模块替换现有 AI工场(迁移电商图生成器套餐模块)
|
||||||
phase: 7
|
phase: 7
|
||||||
deps: [T-564]
|
deps: [T-564]
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-13
|
created: 2026-07-13
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,11 +42,11 @@ UI 已定稿:`docs/ui/tab6-suite-package-v1.svg`(本仓库家风:`#2b3a55`
|
|||||||
- 结果网格:每张卡片显示图片/生成中骨架/失败(带「重试」);右键单图可 预览 / 复制路径 / 重新生成。
|
- 结果网格:每张卡片显示图片/生成中骨架/失败(带「重试」);右键单图可 预览 / 复制路径 / 重新生成。
|
||||||
- 底部一行(与左侧「生成套图」按钮同高):进度条 + `套图 X/Y(秒)· 失败 N` 汇总。
|
- 底部一行(与左侧「生成套图」按钮同高):进度条 + `套图 X/Y(秒)· 失败 N` 汇总。
|
||||||
|
|
||||||
## 待决 / 选型(实现前确认)
|
## 实现决策
|
||||||
|
|
||||||
1. **技术栈**:推荐**原生 PySide6 重写 UI**、复用现有 `image_studio_*` 后端;已排除内嵌 WebView(避免 QtWebEngine 打包体积与依赖)。若坚持搬 WebView 需另评估。
|
1. **技术栈**:采用原生 PySide6 `ProductSuiteTab`,复用现有 `image_studio_*` 后端,不引入 QtWebEngine;旧 `ImageStudioTab` 仅保留内部兼容。
|
||||||
2. 平台/国家/语言下拉的选中值如何进入生成上下文(提示词模板变量 or 生成请求参数),需与 `image_studio_generation` 对齐。
|
2. **生成上下文**:平台/国家/语言/比例/分类/商品ID/参考图序号统一由 `product_suite.build_suite_prompt()` 写入每个 job prompt;比例另透传到 cmhub `aspect_ratio` 请求字段。
|
||||||
3. 效果图当前为「滚动区裁剪」呈现;如需一屏展示全部配置项,属实现期布局细节。
|
3. **布局**:配置区可滚动,生成按钮固定;缩略图和卖点框设稳定高度,结构分类用常驻 chip + 单项展开计数,基准窗口首屏可见。
|
||||||
|
|
||||||
## 验收要点
|
## 验收要点
|
||||||
|
|
||||||
@@ -102,3 +102,10 @@ UI 已定稿:`docs/ui/tab6-suite-package-v1.svg`(本仓库家风:`#2b3a55`
|
|||||||
- 不动 ①~⑤ 既有标签与流程。
|
- 不动 ①~⑤ 既有标签与流程。
|
||||||
- 不引入 `QtWebEngine`(采纳原生重写方案时)。
|
- 不引入 `QtWebEngine`(采纳原生重写方案时)。
|
||||||
- 安全红线:不写真实凭证到代码/文档/日志;`config.json`、`cmshopee.db`、`images/` 等不提交;不绕过 Shopee 风控;生成/导出等本地操作不触碰 ③ 的线上提交边界。
|
- 安全红线:不写真实凭证到代码/文档/日志;`config.json`、`cmshopee.db`、`images/` 等不提交;不绕过 Shopee 风控;生成/导出等本地操作不触碰 ③ 的线上提交边界。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-14:完成第六 Tab 从旧 AI工场入口切换为原生「商品套图」。新增多任务状态、账号+商品ID上下文、16张商品原图导入/拖放/粘贴/排序/后台拉取、结构分类与AI帮写、生成/停止、结果历史、重试、预览、废纸篓删除与撤销;旧 `ImageStudioTab` 保留但主窗口不再创建。
|
||||||
|
- 2026-07-14:扩展 `image_studio_projects.suite_settings_json` 及原位迁移,新增套图纯逻辑、项目 job 查询/原图排序、本地图片原子导入、生成图废纸篓;cmhub 比例从 UI 真实透传,并以进程级闸门限制多个套图任务合计最多5个在途 job。停止信号在下载前后生效,停止后的临时图片不入资产库。
|
||||||
|
- 2026-07-14:同步 AI 开发入口、需求、技术栈、架构、API、routes、文档导航与 UI 索引;新增领域、worker、GUI、迁移、图片边界、比例透传和停止清理测试。
|
||||||
|
- 验证:在只包含 T-622 暂存文件的干净 worktree 中运行 `py -3.10 -m unittest discover -s tests`,459 项全部通过;`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 全部通过。原工作区另有任务开始前即存在的默认封面提示词改名(删除 `papa1.txt`、新增 `默认.txt`),会使3个仍断言 `papa1` 的旧测试失败,该改动未回退、未纳入本任务提交。
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
| 文件 | 内容 |
|
| 文件 | 内容 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [overview-pipeline.svg](overview-pipeline.svg) | 主流水线总览(stage:imported→collected→generated→applied;⑥ AI工场为图片候选工作区) |
|
| [overview-pipeline.svg](overview-pipeline.svg) | 主流水线总览(stage:imported→collected→generated→applied;图中旧⑥AI工场入口已由商品套图替换) |
|
||||||
| [tab1-import-collect.svg](tab1-import-collect.svg) | ① 导入采集:导入 Excel、任务列表、采集旧标题/旧封面、回写、日志 |
|
| [tab1-import-collect.svg](tab1-import-collect.svg) | ① 导入采集:导入 Excel、任务列表、采集旧标题/旧封面、回写、日志 |
|
||||||
| [tab2-ai-generate.svg](tab2-ai-generate.svg) | ② AI生成:提示词、生成、新旧封面对照、重置图片后批量补生成 |
|
| [tab2-ai-generate.svg](tab2-ai-generate.svg) | ② AI生成:提示词、生成、新旧封面对照、重置图片后批量补生成 |
|
||||||
| [tab2-ai-generate-T584.svg](tab2-ai-generate-T584.svg) | ② AI生成(T-584 提案):标题提示词加模板控件行、封面控件压一行、插入类按钮下移、标题输入框增高 |
|
| [tab2-ai-generate-T584.svg](tab2-ai-generate-T584.svg) | ② AI生成(T-584 提案):标题提示词加模板控件行、封面控件压一行、插入类按钮下移、标题输入框增高 |
|
||||||
@@ -14,6 +14,6 @@
|
|||||||
| [tab6-image-studio.svg](tab6-image-studio.svg) | ⑥ 图片精修(提案·初版):商品列表、原主图九宫、照片池、生成控制、终选排序 |
|
| [tab6-image-studio.svg](tab6-image-studio.svg) | ⑥ 图片精修(提案·初版):商品列表、原主图九宫、照片池、生成控制、终选排序 |
|
||||||
| [tab6-image-studio-v2.svg](tab6-image-studio-v2.svg) | ⑥ 图片精修(提案·重排):细图标轨、照片池放大、提示词中心整列、全宽出片盘 |
|
| [tab6-image-studio-v2.svg](tab6-image-studio-v2.svg) | ⑥ 图片精修(提案·重排):细图标轨、照片池放大、提示词中心整列、全宽出片盘 |
|
||||||
| [tab6-ai-studio-v3.svg](tab6-ai-studio-v3.svg) | ⑥ AI工场(需求定稿·v3):合并提示词、完整模板 CRUD、异步任务照片池、部分导出与终选排序 |
|
| [tab6-ai-studio-v3.svg](tab6-ai-studio-v3.svg) | ⑥ AI工场(需求定稿·v3):合并提示词、完整模板 CRUD、异步任务照片池、部分导出与终选排序 |
|
||||||
| [tab6-suite-package-v1.svg](tab6-suite-package-v1.svg) | ⑥ 商品套图(迁移提案·v1):从「虾皮圈电商图生成器」商品套餐模块迁移,替换 AI工场;已去掉「详情图」(与套图合并为单一图片类型)。采用本仓库既有模块风格(#2b3a55 头栏 / #2f6fed 蓝 / 微软雅黑)。多任务标签页、左侧配置面板(商品原图上传、生成设置、AI 帮写、套图结构配置:白底/场景/模特/细节/卖点+自定义分类计数)、右侧生成结果网格、底部进度与生成/停止 |
|
| [tab6-suite-package-v1.svg](tab6-suite-package-v1.svg) | ⑥ 商品套图(T-622 已实现):从「虾皮圈电商图生成器」商品套餐交互迁移并替换主界面 AI工场入口;单一套图类型,多任务标签,商品原图,平台/国家/语言/比例,AI 帮写,白底/场景/卖点及自定义分类,结果历史、重试、删除撤销与生成进度 |
|
||||||
|
|
||||||
> 仅为线框效果图,最终样式以实现为准。带 `-T584`/`-v2` 等后缀的为**改版提案**,未定稿。界面职责与流程见 [../routes.md](../routes.md)。
|
> 仅为线框效果图,最终样式以实现为准。带 `-T584`/`-v2` 等后缀的为**改版提案**,未定稿。界面职责与流程见 [../routes.md](../routes.md)。
|
||||||
|
|||||||
+3
-1
@@ -56,6 +56,7 @@ from app.gui import (
|
|||||||
ForcedUpdateDialog,
|
ForcedUpdateDialog,
|
||||||
ImageStudioTab,
|
ImageStudioTab,
|
||||||
MainWindow,
|
MainWindow,
|
||||||
|
ProductSuiteTab,
|
||||||
SettingsTab,
|
SettingsTab,
|
||||||
TAB_STYLE,
|
TAB_STYLE,
|
||||||
TAB_TITLES,
|
TAB_TITLES,
|
||||||
@@ -491,7 +492,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
self.addCleanup(window.close)
|
self.addCleanup(window.close)
|
||||||
|
|
||||||
self.assertEqual(gui.display_name(), window.windowTitle())
|
self.assertEqual(gui.display_name(), window.windowTitle())
|
||||||
self.assertEqual(5, window.tabs.count())
|
self.assertEqual(6, window.tabs.count())
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
TAB_TITLES,
|
TAB_TITLES,
|
||||||
[window.tabs.tabText(index) for index in range(window.tabs.count())],
|
[window.tabs.tabText(index) for index in range(window.tabs.count())],
|
||||||
@@ -511,6 +512,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
self.assertIsInstance(window.tabs.widget(2), ApplyTab)
|
self.assertIsInstance(window.tabs.widget(2), ApplyTab)
|
||||||
self.assertIsInstance(window.tabs.widget(3), AccountsTab)
|
self.assertIsInstance(window.tabs.widget(3), AccountsTab)
|
||||||
self.assertIsInstance(window.tabs.widget(4), SettingsTab)
|
self.assertIsInstance(window.tabs.widget(4), SettingsTab)
|
||||||
|
self.assertIsInstance(window.tabs.widget(5), ProductSuiteTab)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
any(
|
any(
|
||||||
isinstance(window.tabs.widget(index), ImageStudioTab)
|
isinstance(window.tabs.widget(index), ImageStudioTab)
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
|
|||||||
"item_id",
|
"item_id",
|
||||||
"target_main_count",
|
"target_main_count",
|
||||||
"target_detail_count",
|
"target_detail_count",
|
||||||
|
"suite_settings_json",
|
||||||
"deleted_at",
|
"deleted_at",
|
||||||
}.issubset(projects_columns)
|
}.issubset(projects_columns)
|
||||||
)
|
)
|
||||||
@@ -72,6 +73,50 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_init_db_migrates_legacy_projects_with_default_suite_settings(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
db_path = os.path.join(temp_dir, "legacy-project.db")
|
||||||
|
conn = db.connect(db_path)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE image_studio_projects (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
account_alias TEXT NOT NULL,
|
||||||
|
account_slug TEXT NOT NULL,
|
||||||
|
account_name TEXT,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
target_main_count INTEGER NOT NULL DEFAULT 9,
|
||||||
|
target_detail_count INTEGER NOT NULL DEFAULT 12,
|
||||||
|
draft_prompt TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
deleted_at TEXT,
|
||||||
|
deleted_reason TEXT,
|
||||||
|
UNIQUE(account_alias, item_id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO image_studio_projects
|
||||||
|
(id, account_alias, account_slug, item_id, created_at, updated_at)
|
||||||
|
VALUES (1, 'alias', 'alias_slug', '51100639510', '2026-07-14', '2026-07-14')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
db.init_db(db_path)
|
||||||
|
project = image_studio.get_project(1, path=db_path)
|
||||||
|
|
||||||
|
self.assertEqual("{}", project.suite_settings_json)
|
||||||
|
self.assertEqual({}, image_studio.project_suite_settings(project))
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_project_crud_unique_per_account_and_image_dirs(self):
|
def test_project_crud_unique_per_account_and_image_dirs(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
db_path = os.path.join(temp_dir, "cmshopee.db")
|
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||||
@@ -110,6 +155,15 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
updated = image_studio.update_project_prompt(project.id, "二次提示词", path=db_path)
|
updated = image_studio.update_project_prompt(project.id, "二次提示词", path=db_path)
|
||||||
self.assertEqual("二次提示词", updated.draft_prompt)
|
self.assertEqual("二次提示词", updated.draft_prompt)
|
||||||
|
suite_updated = image_studio.update_project_suite_settings(
|
||||||
|
project.id,
|
||||||
|
{"ratio": "3:4", "categories": {"白底图": 1}},
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{"ratio": "3:4", "categories": {"白底图": 1}},
|
||||||
|
image_studio.project_suite_settings(suite_updated),
|
||||||
|
)
|
||||||
|
|
||||||
dirs = image_studio.project_image_dirs(os.path.join(temp_dir, "images"), project)
|
dirs = image_studio.project_image_dirs(os.path.join(temp_dir, "images"), project)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -215,6 +269,43 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_reorder_original_assets_requires_complete_project_order(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||||
|
db.init_db(db_path)
|
||||||
|
project = image_studio.create_or_get_project(
|
||||||
|
account_alias="alias",
|
||||||
|
account_slug="alias_slug",
|
||||||
|
item_id="51100639510",
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
first = image_studio.add_asset(
|
||||||
|
project.id,
|
||||||
|
image_studio.ASSET_KIND_ORIGINAL,
|
||||||
|
local_path=os.path.join(temp_dir, "first.png"),
|
||||||
|
source_order=1,
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
second = image_studio.add_asset(
|
||||||
|
project.id,
|
||||||
|
image_studio.ASSET_KIND_ORIGINAL,
|
||||||
|
local_path=os.path.join(temp_dir, "second.png"),
|
||||||
|
source_order=2,
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
reordered = image_studio.reorder_original_assets(
|
||||||
|
project.id,
|
||||||
|
[second.id, first.id],
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([second.id, first.id], [asset.id for asset in reordered])
|
||||||
|
with self.assertRaisesRegex(db.DbError, "全部原图"):
|
||||||
|
image_studio.reorder_original_assets(project.id, [first.id], path=db_path)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_remove_asset_only_when_not_referenced(self):
|
def test_remove_asset_only_when_not_referenced(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
db_path = os.path.join(temp_dir, "cmshopee.db")
|
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||||
@@ -287,6 +378,55 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_sync_original_asset_urls_preserves_local_upload_and_caps_active_assets(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||||
|
db.init_db(db_path)
|
||||||
|
project = image_studio.create_or_get_project(
|
||||||
|
account_alias="alias",
|
||||||
|
account_slug="alias_slug",
|
||||||
|
item_id="51100639510",
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
local = image_studio.add_asset(
|
||||||
|
project.id,
|
||||||
|
image_studio.ASSET_KIND_ORIGINAL,
|
||||||
|
local_path=os.path.join(temp_dir, "local.png"),
|
||||||
|
source_order=1,
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
image_studio.sync_original_asset_urls(
|
||||||
|
project.id,
|
||||||
|
[
|
||||||
|
{"index": index, "src": "https://susercontent.com/%d.jpg" % index}
|
||||||
|
for index in range(1, 8)
|
||||||
|
],
|
||||||
|
max_assets=3,
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
active = image_studio.list_assets(
|
||||||
|
project.id,
|
||||||
|
kind=image_studio.ASSET_KIND_ORIGINAL,
|
||||||
|
include_missing=False,
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(3, len(active))
|
||||||
|
self.assertIn(local.id, [asset.id for asset in active])
|
||||||
|
self.assertEqual(
|
||||||
|
image_studio.ASSET_STATUS_AVAILABLE,
|
||||||
|
image_studio.get_asset(local.id, path=db_path).status,
|
||||||
|
)
|
||||||
|
|
||||||
|
image_studio.sync_original_asset_urls(project.id, [], max_assets=3, path=db_path)
|
||||||
|
self.assertEqual(
|
||||||
|
image_studio.ASSET_STATUS_AVAILABLE,
|
||||||
|
image_studio.get_asset(local.id, path=db_path).status,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_job_lifecycle_and_resumable_query(self):
|
def test_job_lifecycle_and_resumable_query(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
db_path = os.path.join(temp_dir, "cmshopee.db")
|
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||||
@@ -376,6 +516,23 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
|
|||||||
[],
|
[],
|
||||||
image_studio.list_resumable_jobs(path=db_path, include_failed_downloads=True),
|
image_studio.list_resumable_jobs(path=db_path, include_failed_downloads=True),
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[terminal.id, job.id],
|
||||||
|
[item.id for item in image_studio.list_jobs(project.id, path=db_path)],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[terminal.id],
|
||||||
|
[
|
||||||
|
item.id
|
||||||
|
for item in image_studio.list_jobs(
|
||||||
|
project.id,
|
||||||
|
statuses=["failed"],
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(db.DbError, "状态无效"):
|
||||||
|
image_studio.list_jobs(project.id, statuses=["unknown"], path=db_path)
|
||||||
|
|
||||||
with self.assertRaises(db.DbError):
|
with self.assertRaises(db.DbError):
|
||||||
image_studio.create_job(
|
image_studio.create_job(
|
||||||
|
|||||||
@@ -146,6 +146,95 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_generate_image_jobs_sends_selected_aspect_ratio(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg, project, source = self._project_source(temp_dir)
|
||||||
|
submitted_payloads = []
|
||||||
|
|
||||||
|
def fake_submit(method, url, api_key, **kwargs):
|
||||||
|
submitted_payloads.append(dict(kwargs["payload"]))
|
||||||
|
return {"task_id": "cmhub-ratio", "status": "queued"}
|
||||||
|
|
||||||
|
with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \
|
||||||
|
mock.patch(
|
||||||
|
"app.image_studio_generation.ai._cmhub_call_with_retry",
|
||||||
|
side_effect=fake_submit,
|
||||||
|
), \
|
||||||
|
mock.patch(
|
||||||
|
"app.image_studio_generation.ai._cmhub_call_once",
|
||||||
|
return_value={
|
||||||
|
"task_id": "cmhub-ratio",
|
||||||
|
"status": "succeeded",
|
||||||
|
"result": {"image_url": "https://cdn.example.com/ratio.png"},
|
||||||
|
},
|
||||||
|
), \
|
||||||
|
mock.patch(
|
||||||
|
"app.image_studio_generation.ai._download_cmhub_image_with_retry",
|
||||||
|
return_value=(self._png_bytes(), 0.1),
|
||||||
|
):
|
||||||
|
summary = image_studio_generation.generate_image_jobs(
|
||||||
|
project.id,
|
||||||
|
source.id,
|
||||||
|
"比例测试",
|
||||||
|
1,
|
||||||
|
aspect_ratio="3:4",
|
||||||
|
config=cfg,
|
||||||
|
path=cfg["db_path"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, summary["success"])
|
||||||
|
self.assertEqual("3:4", submitted_payloads[0]["aspect_ratio"])
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_stop_after_download_discards_temporary_result(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg, project, source = self._project_source(temp_dir)
|
||||||
|
stopped = {"value": False}
|
||||||
|
saved_paths = []
|
||||||
|
|
||||||
|
def fake_download(request_result, out_path, config, on_event, job_id):
|
||||||
|
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||||
|
with open(out_path, "wb") as fh:
|
||||||
|
fh.write(self._png_bytes())
|
||||||
|
saved_paths.append(out_path)
|
||||||
|
stopped["value"] = True
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \
|
||||||
|
mock.patch(
|
||||||
|
"app.image_studio_generation.ai._cmhub_call_with_retry",
|
||||||
|
return_value={"task_id": "cmhub-stop", "status": "queued"},
|
||||||
|
), \
|
||||||
|
mock.patch(
|
||||||
|
"app.image_studio_generation.ai._cmhub_call_once",
|
||||||
|
return_value={
|
||||||
|
"task_id": "cmhub-stop",
|
||||||
|
"status": "succeeded",
|
||||||
|
"result": {"image_url": "https://cdn.example.com/stop.png"},
|
||||||
|
},
|
||||||
|
), \
|
||||||
|
mock.patch(
|
||||||
|
"app.image_studio_generation._download_and_save_job_image",
|
||||||
|
side_effect=fake_download,
|
||||||
|
):
|
||||||
|
summary = image_studio_generation.generate_image_jobs(
|
||||||
|
project.id,
|
||||||
|
source.id,
|
||||||
|
"停止测试",
|
||||||
|
1,
|
||||||
|
config=cfg,
|
||||||
|
path=cfg["db_path"],
|
||||||
|
should_stop=lambda: stopped["value"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, summary["cancelled"])
|
||||||
|
self.assertEqual([], image_studio.list_assets(project.id, kind="generated_main", path=cfg["db_path"]))
|
||||||
|
self.assertEqual(1, len(saved_paths))
|
||||||
|
self.assertFalse(os.path.exists(saved_paths[0]))
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_resume_existing_job_polls_without_new_submit(self):
|
def test_resume_existing_job_polls_without_new_submit(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)
|
||||||
|
|||||||
@@ -246,6 +246,149 @@ class ImageStudioImageTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_import_original_files_copies_valid_images_deduplicates_and_limits(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||||
|
db.init_db(db_path)
|
||||||
|
project = image_studio.create_or_get_project(
|
||||||
|
account_alias="alias",
|
||||||
|
account_slug="alias_slug",
|
||||||
|
item_id="51100639510",
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
source = os.path.join(temp_dir, "商品图.png")
|
||||||
|
with open(source, "wb") as fh:
|
||||||
|
fh.write(self._png_bytes())
|
||||||
|
config = {
|
||||||
|
"data_dir": temp_dir,
|
||||||
|
"db_path": db_path,
|
||||||
|
"image_dir": os.path.join(temp_dir, "images"),
|
||||||
|
}
|
||||||
|
|
||||||
|
first = image_studio_images.import_original_files(
|
||||||
|
project.id,
|
||||||
|
[source],
|
||||||
|
path=db_path,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
duplicate = image_studio_images.import_original_files(
|
||||||
|
project.id,
|
||||||
|
[source],
|
||||||
|
path=db_path,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([], first["errors"])
|
||||||
|
self.assertEqual(first["assets"][0].id, duplicate["assets"][0].id)
|
||||||
|
self.assertNotEqual(os.path.abspath(source), first["assets"][0].local_path)
|
||||||
|
self.assertTrue(os.path.isfile(first["assets"][0].local_path))
|
||||||
|
with self.assertRaisesRegex(image_studio_images.ImageStudioImageError, "最多"):
|
||||||
|
image_studio_images.import_original_bytes(
|
||||||
|
project.id,
|
||||||
|
self._png_bytes(color=(10, 20, 30, 255)),
|
||||||
|
filename_hint="second.png",
|
||||||
|
path=db_path,
|
||||||
|
config=config,
|
||||||
|
max_assets=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_import_original_ignores_missing_history_when_enforcing_limit(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||||
|
db.init_db(db_path)
|
||||||
|
project = image_studio.create_or_get_project(
|
||||||
|
account_alias="alias",
|
||||||
|
account_slug="alias_slug",
|
||||||
|
item_id="51100639510",
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
config = {
|
||||||
|
"data_dir": temp_dir,
|
||||||
|
"db_path": db_path,
|
||||||
|
"image_dir": os.path.join(temp_dir, "images"),
|
||||||
|
}
|
||||||
|
first = image_studio_images.import_original_bytes(
|
||||||
|
project.id,
|
||||||
|
self._png_bytes(),
|
||||||
|
filename_hint="first.png",
|
||||||
|
path=db_path,
|
||||||
|
config=config,
|
||||||
|
max_assets=1,
|
||||||
|
)
|
||||||
|
image_studio.mark_asset_status(
|
||||||
|
first.id,
|
||||||
|
image_studio.ASSET_STATUS_MISSING,
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
second = image_studio_images.import_original_bytes(
|
||||||
|
project.id,
|
||||||
|
self._png_bytes(color=(10, 20, 30, 255)),
|
||||||
|
filename_hint="second.png",
|
||||||
|
path=db_path,
|
||||||
|
config=config,
|
||||||
|
max_assets=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotEqual(first.id, second.id)
|
||||||
|
self.assertEqual(image_studio.ASSET_STATUS_AVAILABLE, second.status)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_generated_asset_trash_and_restore_keep_database_history(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||||
|
db.init_db(db_path)
|
||||||
|
project = image_studio.create_or_get_project(
|
||||||
|
account_alias="alias",
|
||||||
|
account_slug="alias_slug",
|
||||||
|
item_id="51100639510",
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
config = {
|
||||||
|
"data_dir": temp_dir,
|
||||||
|
"db_path": db_path,
|
||||||
|
"image_dir": os.path.join(temp_dir, "images"),
|
||||||
|
}
|
||||||
|
generated_dir = image_studio.default_project_image_dirs(project, config=config)[
|
||||||
|
"generated"
|
||||||
|
]
|
||||||
|
os.makedirs(generated_dir, exist_ok=True)
|
||||||
|
generated_path = os.path.join(generated_dir, "result.png")
|
||||||
|
with open(generated_path, "wb") as fh:
|
||||||
|
fh.write(self._png_bytes())
|
||||||
|
asset = image_studio.add_asset(
|
||||||
|
project.id,
|
||||||
|
"generated_main",
|
||||||
|
local_path=generated_path,
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
record = image_studio_images.trash_generated_asset(
|
||||||
|
asset.id,
|
||||||
|
path=db_path,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
trashed = image_studio.get_asset(asset.id, path=db_path)
|
||||||
|
|
||||||
|
self.assertEqual(image_studio.ASSET_STATUS_MISSING, trashed.status)
|
||||||
|
self.assertTrue(os.path.isfile(record["trash_path"]))
|
||||||
|
self.assertFalse(os.path.exists(generated_path))
|
||||||
|
|
||||||
|
restored = image_studio_images.restore_trashed_asset(
|
||||||
|
record,
|
||||||
|
path=db_path,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(asset.id, restored.id)
|
||||||
|
self.assertEqual(image_studio.ASSET_STATUS_AVAILABLE, restored.status)
|
||||||
|
self.assertTrue(os.path.isfile(restored.local_path))
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from app import product_suite
|
||||||
|
|
||||||
|
|
||||||
|
class ProductSuiteTests(unittest.TestCase):
|
||||||
|
def test_defaults_and_per_image_total(self):
|
||||||
|
settings = product_suite.default_suite_settings()
|
||||||
|
|
||||||
|
self.assertEqual(["白底图", "场景图", "卖点图"], product_suite.category_order(settings))
|
||||||
|
self.assertEqual(5, product_suite.suite_total_count(settings, 3))
|
||||||
|
|
||||||
|
settings["per_image_primary"] = True
|
||||||
|
|
||||||
|
self.assertEqual(13, product_suite.suite_total_count(settings, 3))
|
||||||
|
|
||||||
|
def test_custom_category_validation_and_normalization(self):
|
||||||
|
self.assertEqual("分类名称不能为空", product_suite.suite_name_error(""))
|
||||||
|
self.assertEqual("分类名称不能包含空格", product_suite.suite_name_error("使用 场景"))
|
||||||
|
self.assertEqual("分类名称不能超过10个字", product_suite.suite_name_error("一二三四五六七八九十甲"))
|
||||||
|
self.assertEqual("分类名称已存在", product_suite.suite_name_error("场景图", {"场景图"}))
|
||||||
|
|
||||||
|
settings = product_suite.normalize_suite_settings(
|
||||||
|
{
|
||||||
|
"ratio": "3:4",
|
||||||
|
"categories": {"白底图": 2, "场景图": 0, "卖点图": 1, "尺寸图": 2},
|
||||||
|
"custom_category_order": ["尺寸图"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("3:4", settings["ratio"])
|
||||||
|
self.assertEqual(["白底图", "场景图", "卖点图", "尺寸图"], product_suite.category_order(settings))
|
||||||
|
self.assertEqual(5, product_suite.suite_total_count(settings, 1))
|
||||||
|
|
||||||
|
def test_job_specs_include_selected_context_and_source_assignment(self):
|
||||||
|
settings = product_suite.default_suite_settings()
|
||||||
|
settings.update(
|
||||||
|
{
|
||||||
|
"platform": "Shopee",
|
||||||
|
"country": "中国台湾",
|
||||||
|
"language": "繁体中文",
|
||||||
|
"ratio": "4:3",
|
||||||
|
"per_image_primary": True,
|
||||||
|
"categories": {"白底图": 1, "场景图": 1, "卖点图": 0},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assets = [SimpleNamespace(id=11), SimpleNamespace(id=12)]
|
||||||
|
|
||||||
|
specs = product_suite.build_job_specs(
|
||||||
|
assets,
|
||||||
|
"40小时续航,适合通勤",
|
||||||
|
settings,
|
||||||
|
"51100639510",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(3, len(specs))
|
||||||
|
self.assertEqual([11, 11, 12], [spec["source_asset_id"] for spec in specs])
|
||||||
|
self.assertEqual(["白底图", "场景图", "场景图"], [spec["job_type"] for spec in specs])
|
||||||
|
for spec in specs:
|
||||||
|
self.assertIn("平台:Shopee", spec["prompt"])
|
||||||
|
self.assertIn("国家地区:中国台湾", spec["prompt"])
|
||||||
|
self.assertIn("输出语言:繁体中文", spec["prompt"])
|
||||||
|
self.assertIn("图片比例:4:3", spec["prompt"])
|
||||||
|
self.assertIn("商品ID:51100639510", spec["prompt"])
|
||||||
|
self.assertIn("40小时续航", spec["prompt"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from _helpers import TempDirMixin
|
||||||
|
|
||||||
|
from app import accounts, image_studio, image_studio_images
|
||||||
|
from app import gui
|
||||||
|
|
||||||
|
if gui.QT_IMPORT_ERROR is not None:
|
||||||
|
raise unittest.SkipTest("PySide6 未安装")
|
||||||
|
|
||||||
|
from PySide6.QtGui import QImage
|
||||||
|
from PySide6.QtWidgets import QApplication, QLabel, QPushButton
|
||||||
|
|
||||||
|
from app.gui.tabs.product_suite import ProductSuiteTab, SuiteResultCard
|
||||||
|
|
||||||
|
|
||||||
|
class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.app = QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
for widget in QApplication.topLevelWidgets():
|
||||||
|
widget.close()
|
||||||
|
widget.deleteLater()
|
||||||
|
self.app.processEvents()
|
||||||
|
|
||||||
|
def _config(self, temp_dir):
|
||||||
|
return {
|
||||||
|
"chrome_path": "chrome.exe",
|
||||||
|
"user_data_root": os.path.join(temp_dir, "chrome_user_data_dir"),
|
||||||
|
"image_dir": os.path.join(temp_dir, "images"),
|
||||||
|
"db_path": os.path.join(temp_dir, "cmshopee.db"),
|
||||||
|
"debug_port_range": [9222, 9260],
|
||||||
|
"config_path": os.path.join(temp_dir, "config.json"),
|
||||||
|
"cmhub_config_path": os.path.join(temp_dir, "cmhub.json"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _write_image(self, path):
|
||||||
|
image = QImage(40, 30, QImage.Format_RGB32)
|
||||||
|
image.fill(0xFF336699)
|
||||||
|
self.assertTrue(image.save(path))
|
||||||
|
|
||||||
|
def test_tab_builds_suite_controls_without_old_detail_workspace(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
config = self._config(temp_dir)
|
||||||
|
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
|
||||||
|
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
|
||||||
|
self.addCleanup(tab.close)
|
||||||
|
|
||||||
|
self.assertEqual("productSuiteTab", tab.objectName())
|
||||||
|
self.assertEqual(1, tab.task_tabs.count())
|
||||||
|
self.assertEqual("套图任务 1", tab.task_tabs.tabText(0))
|
||||||
|
self.assertEqual("alias-a", tab.account_combo.currentData())
|
||||||
|
self.assertEqual("Shopee", tab.platform_combo.currentData())
|
||||||
|
self.assertEqual("中国台湾", tab.country_combo.currentData())
|
||||||
|
self.assertEqual("繁体中文", tab.language_combo.currentData())
|
||||||
|
self.assertEqual("1:1", tab.ratio_combo.currentData())
|
||||||
|
self.assertEqual("合计 5 张", tab.category_total_label.text())
|
||||||
|
self.assertEqual("生成套图(5)", tab.generate_button.text())
|
||||||
|
|
||||||
|
visible_text = " ".join(
|
||||||
|
[widget.text() for widget in tab.findChildren(QLabel)]
|
||||||
|
+ [widget.text() for widget in tab.findChildren(QPushButton)]
|
||||||
|
)
|
||||||
|
self.assertNotIn("详情图", visible_text)
|
||||||
|
self.assertNotIn("AI工场", visible_text)
|
||||||
|
self.assertIn("白底图", visible_text)
|
||||||
|
self.assertIn("场景图", visible_text)
|
||||||
|
self.assertIn("卖点图", visible_text)
|
||||||
|
|
||||||
|
tab.add_custom_category()
|
||||||
|
self.assertFalse(tab.custom_category_edit.isHidden())
|
||||||
|
tab.custom_category_edit.setText("尺寸图")
|
||||||
|
tab._commit_custom_category()
|
||||||
|
self.assertTrue(tab.custom_category_edit.isHidden())
|
||||||
|
self.assertIn("尺寸图", tab._displayed_state.settings["categories"])
|
||||||
|
self.assertEqual("尺寸图", tab._displayed_state.active_category)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_task_tabs_keep_independent_prompt_and_settings(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
config = self._config(temp_dir)
|
||||||
|
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
|
||||||
|
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
|
||||||
|
self.addCleanup(tab.close)
|
||||||
|
|
||||||
|
tab.prompt_edit.setPlainText("任务一卖点")
|
||||||
|
first_state = tab._displayed_state
|
||||||
|
tab.ratio_combo.setCurrentIndex(tab.ratio_combo.findData("3:4"))
|
||||||
|
second_state = tab.add_task(inherit=True)
|
||||||
|
|
||||||
|
self.assertEqual(2, tab.task_tabs.count())
|
||||||
|
self.assertEqual("任务一卖点", second_state.prompt)
|
||||||
|
self.assertEqual("3:4", second_state.settings["ratio"])
|
||||||
|
tab.prompt_edit.setPlainText("任务二卖点")
|
||||||
|
tab.ratio_combo.setCurrentIndex(tab.ratio_combo.findData("16:9"))
|
||||||
|
tab.task_tabs.setCurrentIndex(0)
|
||||||
|
|
||||||
|
self.assertIs(first_state, tab._displayed_state)
|
||||||
|
self.assertEqual("任务一卖点", tab.prompt_edit.toPlainText())
|
||||||
|
self.assertEqual("3:4", tab.ratio_combo.currentData())
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_project_settings_and_result_history_use_existing_backend(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
config = self._config(temp_dir)
|
||||||
|
account = accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
|
||||||
|
project = image_studio.create_or_get_project(
|
||||||
|
account,
|
||||||
|
item_id="51100639510",
|
||||||
|
path=config["db_path"],
|
||||||
|
)
|
||||||
|
source_path = os.path.join(temp_dir, "source.png")
|
||||||
|
self._write_image(source_path)
|
||||||
|
source = image_studio_images.import_original_files(
|
||||||
|
project.id,
|
||||||
|
[source_path],
|
||||||
|
path=config["db_path"],
|
||||||
|
config=config,
|
||||||
|
)["assets"][0]
|
||||||
|
job = image_studio.create_job(
|
||||||
|
project.id,
|
||||||
|
source_asset_id=source.id,
|
||||||
|
job_type="场景图",
|
||||||
|
prompt="场景卖点",
|
||||||
|
path=config["db_path"],
|
||||||
|
)
|
||||||
|
image_studio.update_job_status(
|
||||||
|
job.id,
|
||||||
|
"failed",
|
||||||
|
error="上游超时 https://example.invalid/private",
|
||||||
|
path=config["db_path"],
|
||||||
|
)
|
||||||
|
|
||||||
|
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
|
||||||
|
self.addCleanup(tab.close)
|
||||||
|
state = tab._displayed_state
|
||||||
|
state.account_alias = "alias-a"
|
||||||
|
state.item_id = "51100639510"
|
||||||
|
state.project_id = project.id
|
||||||
|
state.current_job_ids = [job.id]
|
||||||
|
tab._load_state(state)
|
||||||
|
|
||||||
|
self.assertEqual([source.id], tab.original_list.asset_ids())
|
||||||
|
self.assertEqual("共 1 张 · 成功 0 张", tab.result_summary_label.text())
|
||||||
|
cards = tab.findChildren(SuiteResultCard)
|
||||||
|
self.assertEqual(1, len(cards))
|
||||||
|
self.assertNotIn(
|
||||||
|
"https://",
|
||||||
|
" ".join(label.text() for label in cards[0].findChildren(QLabel)),
|
||||||
|
)
|
||||||
|
|
||||||
|
state.settings["ratio"] = "4:3"
|
||||||
|
state.prompt = "持久化卖点"
|
||||||
|
tab._persist_state(state)
|
||||||
|
stored = image_studio.get_project(project.id, path=config["db_path"])
|
||||||
|
self.assertEqual("持久化卖点", stored.draft_prompt)
|
||||||
|
self.assertEqual("4:3", image_studio.project_suite_settings(stored)["ratio"])
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from _helpers import TempDirMixin
|
||||||
|
|
||||||
|
from app import db, image_studio
|
||||||
|
from app import gui
|
||||||
|
|
||||||
|
if gui.QT_IMPORT_ERROR is not None:
|
||||||
|
raise unittest.SkipTest("PySide6 未安装")
|
||||||
|
|
||||||
|
from app.gui.workers import ProductSuiteGenerateWorker
|
||||||
|
|
||||||
|
|
||||||
|
class ProductSuiteWorkerTests(TempDirMixin, unittest.TestCase):
|
||||||
|
def test_generate_worker_creates_category_jobs_and_forwards_ratio(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
db_path = os.path.join(temp_dir, "cmshopee.db")
|
||||||
|
db.init_db(db_path)
|
||||||
|
project = image_studio.create_or_get_project(
|
||||||
|
account_alias="alias",
|
||||||
|
account_slug="alias_slug",
|
||||||
|
item_id="51100639510",
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
source = image_studio.add_asset(
|
||||||
|
project.id,
|
||||||
|
image_studio.ASSET_KIND_ORIGINAL,
|
||||||
|
local_path=os.path.join(temp_dir, "source.png"),
|
||||||
|
path=db_path,
|
||||||
|
)
|
||||||
|
worker = ProductSuiteGenerateWorker(
|
||||||
|
project.id,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"source_asset_id": source.id,
|
||||||
|
"job_type": "白底图",
|
||||||
|
"prompt": "白底商品图",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source_asset_id": source.id,
|
||||||
|
"job_type": "场景图",
|
||||||
|
"prompt": "通勤场景图",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
aspect_ratio="3:4",
|
||||||
|
db_path=db_path,
|
||||||
|
config={"db_path": db_path},
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"app.gui.workers.image_studio_generation.run_jobs",
|
||||||
|
return_value={
|
||||||
|
"total": 2,
|
||||||
|
"success": 2,
|
||||||
|
"failed": 0,
|
||||||
|
"cancelled": 0,
|
||||||
|
"jobs": [],
|
||||||
|
},
|
||||||
|
) as run_jobs:
|
||||||
|
result = worker.execute()
|
||||||
|
|
||||||
|
jobs = image_studio.list_jobs(project.id, path=db_path)
|
||||||
|
self.assertEqual(2, len(jobs))
|
||||||
|
self.assertEqual({"白底图", "场景图"}, {job.job_type for job in jobs})
|
||||||
|
self.assertEqual([job.id for job in reversed(jobs)], result["job_ids"])
|
||||||
|
self.assertEqual("3:4", run_jobs.call_args.kwargs["aspect_ratio"])
|
||||||
|
self.assertEqual(db_path, run_jobs.call_args.kwargs["path"])
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user