feat: snapshot product suite reference assets
This commit is contained in:
@@ -338,6 +338,7 @@ CREATE TABLE IF NOT EXISTS image_studio_jobs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES image_studio_projects(id) ON DELETE CASCADE,
|
||||
source_asset_id INTEGER REFERENCES image_studio_assets(id) ON DELETE SET NULL,
|
||||
reference_asset_ids TEXT,
|
||||
output_asset_id INTEGER REFERENCES image_studio_assets(id) ON DELETE SET NULL,
|
||||
generation_source TEXT NOT NULL DEFAULT 'cmhub',
|
||||
provider TEXT NOT NULL DEFAULT 'cmhub',
|
||||
@@ -488,6 +489,7 @@ def init_db(path=None, conn=None) -> None:
|
||||
_ensure_image_studio_project_draft_columns(database)
|
||||
_ensure_image_studio_job_recovery_columns(database)
|
||||
_ensure_image_studio_generation_round_columns(database)
|
||||
_ensure_image_studio_job_reference_asset_ids_column(database)
|
||||
|
||||
|
||||
def _ensure_batch_delete_columns(database):
|
||||
@@ -612,6 +614,16 @@ def _ensure_image_studio_generation_round_columns(database):
|
||||
"project_id, generation_round_key, generation_slot_index, id)"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_image_studio_job_reference_asset_ids_column(database):
|
||||
columns = {
|
||||
row["name"] for row in database.execute("PRAGMA table_info(image_studio_jobs)").fetchall()
|
||||
}
|
||||
if "reference_asset_ids" not in columns:
|
||||
database.execute(
|
||||
"ALTER TABLE image_studio_jobs ADD COLUMN reference_asset_ids TEXT"
|
||||
)
|
||||
|
||||
def create_batch(file_paths: Iterable[str], note=None, path=None, conn=None) -> str:
|
||||
batch_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:8]
|
||||
files = [os.path.abspath(file_path) for file_path in file_paths]
|
||||
|
||||
@@ -4704,6 +4704,7 @@ class ProductSuiteTab(QWidget):
|
||||
return tuple(
|
||||
(
|
||||
int(spec.get("source_asset_id") or 0),
|
||||
tuple(int(asset_id) for asset_id in (spec.get("reference_asset_ids") or [])),
|
||||
str(spec.get("category") or spec.get("job_type") or ""),
|
||||
int(spec.get("category_index") or 0),
|
||||
int(spec.get("source_index") or 0),
|
||||
@@ -5582,8 +5583,14 @@ class ProductSuiteTab(QWidget):
|
||||
}:
|
||||
self._status("当前图片无需重试", "warning")
|
||||
return
|
||||
try:
|
||||
reference_asset_ids = image_studio.job_reference_asset_ids(job)
|
||||
except Exception as exc:
|
||||
self._message("读取重试图片失败", _user_error(exc))
|
||||
return
|
||||
spec = {
|
||||
"source_asset_id": job.source_asset_id,
|
||||
"reference_asset_ids": reference_asset_ids,
|
||||
"job_type": job.job_type,
|
||||
"prompt": job.prompt,
|
||||
}
|
||||
|
||||
@@ -372,6 +372,7 @@ class ProductSuiteGenerateWorker(BaseWorker):
|
||||
image_studio.create_job(
|
||||
self.project_id,
|
||||
source_asset_id=spec.get("source_asset_id"),
|
||||
reference_asset_ids=spec.get("reference_asset_ids"),
|
||||
job_type=spec.get("job_type") or "套图",
|
||||
prompt=spec.get("prompt") or "",
|
||||
generation_source="cmhub",
|
||||
|
||||
+51
-3
@@ -78,6 +78,7 @@ class ImageStudioJob:
|
||||
id: int
|
||||
project_id: int
|
||||
source_asset_id: Optional[int]
|
||||
reference_asset_ids: Optional[str]
|
||||
output_asset_id: Optional[int]
|
||||
generation_source: str
|
||||
provider: str
|
||||
@@ -344,6 +345,41 @@ def _ensure_assets_belong_to_project(database, project_id, asset_ids):
|
||||
raise db.DbError("AI工场资产不属于当前项目")
|
||||
|
||||
|
||||
def _parse_reference_asset_ids(value, source_asset_id=None):
|
||||
if value is None or str(value).strip() == "":
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise db.DbError("AI工场参考图快照格式无效") from exc
|
||||
if not isinstance(value, (list, tuple)):
|
||||
raise db.DbError("AI工场参考图快照必须是图片ID列表")
|
||||
source_id = int(source_asset_id) if source_asset_id is not None else None
|
||||
normalized = []
|
||||
for asset_id in value:
|
||||
try:
|
||||
parsed = int(asset_id)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise db.DbError("AI工场参考图快照包含无效图片ID") from exc
|
||||
if parsed <= 0:
|
||||
raise db.DbError("AI工场参考图快照包含无效图片ID")
|
||||
if source_id is not None and parsed == source_id:
|
||||
raise db.DbError("AI工场参考图不能包含主图")
|
||||
if parsed in normalized:
|
||||
raise db.DbError("AI工场参考图不能重复")
|
||||
normalized.append(parsed)
|
||||
return normalized
|
||||
|
||||
|
||||
def job_reference_asset_ids(job):
|
||||
"""Return a validated, ordered reference asset snapshot for one job."""
|
||||
return _parse_reference_asset_ids(
|
||||
getattr(job, "reference_asset_ids", None),
|
||||
getattr(job, "source_asset_id", None),
|
||||
)
|
||||
|
||||
|
||||
def get_project(project_id, path=None, conn=None, include_deleted=False):
|
||||
clauses = ["id = ?"]
|
||||
params = [int(project_id)]
|
||||
@@ -1114,6 +1150,7 @@ def create_job(
|
||||
project_id,
|
||||
*,
|
||||
source_asset_id=None,
|
||||
reference_asset_ids=None,
|
||||
job_type="main",
|
||||
prompt="",
|
||||
task_key=None,
|
||||
@@ -1126,6 +1163,12 @@ def create_job(
|
||||
):
|
||||
now = _now()
|
||||
task_key = str(task_key or _task_key(project_id))
|
||||
reference_ids = _parse_reference_asset_ids(reference_asset_ids, source_asset_id)
|
||||
reference_json = None if reference_asset_ids is None else json.dumps(
|
||||
reference_ids,
|
||||
ensure_ascii=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
if generation_round_key is not None:
|
||||
generation_round_key = str(generation_round_key).strip()
|
||||
if not generation_round_key:
|
||||
@@ -1140,18 +1183,23 @@ def create_job(
|
||||
with _connection(conn, path) as database:
|
||||
try:
|
||||
with database:
|
||||
_ensure_assets_belong_to_project(database, project_id, [source_asset_id])
|
||||
_ensure_assets_belong_to_project(
|
||||
database,
|
||||
project_id,
|
||||
[source_asset_id, *reference_ids],
|
||||
)
|
||||
cursor = database.execute(
|
||||
"""
|
||||
INSERT INTO image_studio_jobs
|
||||
(project_id, source_asset_id, generation_source, provider,
|
||||
(project_id, source_asset_id, reference_asset_ids, generation_source, provider,
|
||||
job_type, task_key, status, prompt, generation_round_key,
|
||||
generation_slot_index, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
int(project_id),
|
||||
source_asset_id,
|
||||
reference_json,
|
||||
str(generation_source or "cmhub"),
|
||||
str(provider or "cmhub"),
|
||||
str(job_type),
|
||||
|
||||
@@ -270,6 +270,9 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho
|
||||
return {"job": updated, "status": "failed", "error": "项目或源图不存在"}
|
||||
try:
|
||||
image_studio.update_job_status(job.id, "running", path=db_path)
|
||||
reference_assets = ()
|
||||
if not job.task_id:
|
||||
reference_assets = _reference_assets_for_job(job, db_path)
|
||||
request_result = _submit_or_resume_job(
|
||||
job,
|
||||
source_asset,
|
||||
@@ -279,6 +282,7 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho
|
||||
db_path,
|
||||
should_stop,
|
||||
on_event,
|
||||
reference_assets=reference_assets,
|
||||
)
|
||||
image_studio.update_job_status(job.id, "running", path=db_path)
|
||||
request_result = _poll_job(job.id, request_result["task_id"], runtime, request_result, db_path, should_stop, on_event)
|
||||
@@ -343,6 +347,20 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho
|
||||
return {"job": updated, "status": status, "error": error}
|
||||
|
||||
|
||||
def _reference_assets_for_job(job, db_path):
|
||||
assets = []
|
||||
for asset_id in image_studio.job_reference_asset_ids(job):
|
||||
asset = image_studio.get_asset(asset_id, path=db_path)
|
||||
if asset is None:
|
||||
raise ImageStudioGenerationError("参考图资产不存在,无法继续生成")
|
||||
try:
|
||||
_source_path(asset)
|
||||
except ImageStudioGenerationError as exc:
|
||||
raise ImageStudioGenerationError("参考图尚未下载到本地,无法继续生成") from exc
|
||||
assets.append(asset)
|
||||
return assets
|
||||
|
||||
|
||||
def _submit_or_resume_job(
|
||||
job,
|
||||
source_asset,
|
||||
|
||||
@@ -347,6 +347,13 @@ def build_job_specs(source_assets, base_prompt, settings, item_id, *, template_t
|
||||
if not assets:
|
||||
return []
|
||||
normalized = normalize_suite_settings(settings)
|
||||
primary_asset_id = int(getattr(assets[0], "id", assets[0]))
|
||||
reference_asset_ids = []
|
||||
if not normalized["per_image_primary"]:
|
||||
for asset in assets[1:8]:
|
||||
asset_id = int(getattr(asset, "id", asset))
|
||||
if asset_id != primary_asset_id and asset_id not in reference_asset_ids:
|
||||
reference_asset_ids.append(asset_id)
|
||||
specs = []
|
||||
for category in category_order(normalized):
|
||||
count = _count(normalized["categories"].get(category, 0))
|
||||
@@ -358,6 +365,7 @@ def build_job_specs(source_assets, base_prompt, settings, item_id, *, template_t
|
||||
specs.append(
|
||||
{
|
||||
"source_asset_id": int(getattr(asset, "id", asset)),
|
||||
"reference_asset_ids": list(reference_asset_ids),
|
||||
"job_type": str(category),
|
||||
"category": str(category),
|
||||
"category_index": category_index,
|
||||
|
||||
@@ -438,6 +438,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
|
||||
- 并发数、重试、分辨率、jpg 质量、模型/Key 均来自设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。T-547 后标题并发和图片并发都限制为 1..5,失败重试次数限制为 0..10;旧 `config.json` 或手工配置的超限值会在加载/保存时夹紧。设置仍只展示一个「图片并发」设置;cmhub 模式下②运行日志显示“图片并发 X,cmhub实际生图并发 Y,下载并发 Y”。
|
||||
- 商品套图固定使用设置保存的 cmhub 生图 alias。T-651 后,常规新一轮生成先完成历史确认,再按最终 `build_job_specs()` 在后台读取或复用短期模型目录缓存,最后才显示数量和扣点确认;逐图主图开启时白底图仅使用第一张原图,其他分类按每张原图展开。固定分类顺序为白底图、场景图、模特场景图、细节说明图、卖点图;默认数量为1、2、0、0、2,新增分类不因升级自动产生任务。只有当前生图别名存在唯一无条件 `points_cost` 时显示单张与总预估点数,总数严格按 planned job 数量计算;目录不可用或价格有条件时不显示数字。确认默认、Esc 或关闭均取消,计划在读取期间发生变化时不提交旧 specs;单图重试和恢复不进入该常规确认。预估不落库、不预扣,实际扣点仍以 job 的 cmhub 响应为准。T-637 后套图提示词事实来源分为:安装包只读默认 `app/default_prompts/product_suite/base.txt`、用户全局模板 `data/prompts/product_suite/base.txt`、`app/product_suite.py` 中的分类目标、结构化上下文与只读规则常量。用户模板首次缺失或为空时,必须先按完整占位符契约校验内置模板,再通过原子写入初始化;已有用户模板不被升级静默覆盖。模板无效时在创建 project/job/worker 和调用 cmhub 前阻断新一轮生成,单张历史重试继续使用原 `image_studio_jobs.prompt` 快照。
|
||||
- T-658a 后商品套图异步生图提交统一使用 `images` 数组,单图也使用单元素 `{"image_base64": ...}`,不再提交顶层 `image_base64`。提交层最多传 8 张本地图片,单图原文件上限10MiB、编码后总输入上限32MiB;超限在请求前以中文错误阻断,后续多图参考的资产快照和提示词语义由 T-658b/T-658c 负责。
|
||||
- T-658b 后 `image_studio_jobs.reference_asset_ids` 以有序 JSON 图片 ID 列表冻结每个商品套图 job 的参考图;新一轮未勾选逐图主图时,第1张为主图、后续最多7张写入快照,勾选时写空数组。历史 `NULL` 行继续按单图任务处理。恢复或重试只读 job 快照,不回读当前原图列表;参考图资产或本地文件缺失时提交前失败,不静默减少提交数量。
|
||||
- T-647 后商品套图中的「AI帮写」不是②标题生成的复用入口:它使用 `vision_alias` 调 `POST /api/v1/analyze/images`,把当前项目中1至8张可用本地原图按 `source_order` 在一次请求中发送;超过8张时明确只使用前8张,原图勾选状态不改变该输入集合。视觉提示词将这些图片定义为同一商品项目的多角度、细节、包装或场景证据集,要求产出一份商品级联合分析,不按“图1/图2”逐图罗列;结果按商品概述、可确认卖点、适用人群与场景、套图画面要求、待确认或避免编造的信息组织,并把可见差异明确为待确认项,不强行合并为事实。单图上限10MiB、总计上限32MiB,缺图、未下载、超限或未配置别名时不发请求且保留用户现有卖点。读取等待固定120秒、连接等待沿用设置;读超时或网络中断只提示“结果未确认”,不自动重发。成功只显示图片张数、扣点和余额,不显示路径、base64、接口URL、完整提示词或上游原始响应;取消只在请求前/返回后协作生效,不强杀网络线程。
|
||||
- `product_suite.render_product_suite_prompt()` 是弹窗预览和真实生成的唯一 renderer;`build_job_specs()` 建立本轮 specs 前只读取一次用户模板并冻结,每个 job 保存最终完整 prompt,运行中修改模板只影响下一轮。新模板使用 `{生成目标}`,其值为内置分类固定目标描述或含实际名称的“生成自定义分类图片:分类名称。”;同时保留 `{套图名称}`、`{补充描述}` 及平台/地区/语言/比例、可选商品ID/主参考图序号、参考图规则、商品卖点和四个只读规则。旧模板必须同时包含 `{套图名称}` 与 `{补充描述}` 才能按兼容路径继续使用;必需变量缺失、未知/未闭合花括号、只读规则变量未独占一行都视为无效。`{参考图规则}` 的变量值以 `参考图规则:` 开头:`per_image_primary=true` 时为“参考图规则:当前上传图片是本任务唯一主参考图;保持商品主体、款式、颜色和关键细节准确;不编造用户与参考图均未提供的信息。”,否则为“参考图规则:使用第一張上傳圖作為主商品圖,其餘圖片只作為參考。”。默认模板独占一行,不额外重复标签或序号。四个只读规则覆盖尺寸与长图(含禁止多宫格拼接)、政治标识、价格和尺码;商品主体一致性与禁止编造并入逐图主图规则。比例仍同时传入 `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。商品套图原图列表的批量勾选只保存在当前 `SuiteTaskState` 对应的界面上下文,不写库;批量移除由 `remove_original_assets_if_unused()` 一次校验项目归属、原图类型和 job/终选引用,并在单个 SQLite 事务中删除资产行、连续重排 `source_order`。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。
|
||||
|
||||
@@ -432,6 +432,7 @@ export_generation_round(project_id, generation_round_key, parent_dir, path=None,
|
||||
- `remove_original_assets_if_unused()` 会先校验整批原图的项目归属、资产类型及 job/终选引用,再在单个事务中删除资产行并连续重排 `source_order`;任一图片不可删除时整批不变,本地源文件和蝦皮线上图片始终保留。
|
||||
- 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 或上游接口路径。
|
||||
- T-658a 后商品套图异步提交的图片字段统一为 `images` 数组(每项仅含本地编码的 `image_base64`),单图不再保留顶层 `image_base64` 兼容字段。提交层限制最多8张、单图原文件10MiB、编码后总输入32MiB;参考图快照字段由 T-658b 扩展。
|
||||
- T-658b 后 `image_studio.create_job(..., reference_asset_ids=...)` 接收同项目、去重且不包含主图的有序图片 ID 列表,并以 JSON 快照写入 `image_studio_jobs.reference_asset_ids`;`job_reference_asset_ids(job)` 负责解析和校验。历史 `NULL` 快照返回空列表,恢复/重试不根据当前商品原图补图。
|
||||
- `include_failed_downloads=True` 允许 failed 但已有 `task_id`、无输出 asset 的任务继续查询,用于下载失败或本地保存失败恢复。
|
||||
- 终选顺序由 `replace_selections()` 事务替换,主图/详情图同类别去重、跨类别可复用。
|
||||
- 导出只写 JPEG 图片文件,透明图铺白底;商品目录已存在时只能覆盖受管命名文件或新建带时间目录,不合并、不递归清空。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: T-658b
|
||||
title: 商品套图多图参考任务快照与历史兼容
|
||||
status: TODO
|
||||
status: DONE
|
||||
phase: 7
|
||||
deps: [T-658a]
|
||||
created: 2026-07-17
|
||||
@@ -63,4 +63,8 @@ git diff --check
|
||||
|
||||
## 执行记录
|
||||
|
||||
- 待实现。
|
||||
- 2026-07-17:新增 `image_studio_jobs.reference_asset_ids TEXT` additive 迁移,并扩展 `ImageStudioJob`、`create_job()` 与快照解析校验;历史 `NULL` 保持单图语义。
|
||||
- 2026-07-17:`build_job_specs()` 在未勾选逐图主图时冻结第1张后的最多7张参考图;GUI worker、计划签名和单图重试均传递原 job 快照。
|
||||
- 2026-07-17:运行时仅在尚未提交 cmhub 的 job 读取参考图快照;资产或本地文件缺失时提交前失败,不静默减少图片数量。已有 `task_id` 的任务继续只轮询下载,不要求历史参考文件仍存在。
|
||||
- 2026-07-17:补充 schema/历史 NULL、跨任务快照、参考图提交顺序和缺失参考图不提交的测试;同步架构/API 文档。
|
||||
- 验证通过:`py -3.10 -m unittest tests.test_product_suite tests.test_image_studio tests.test_image_studio_generation`(46项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。
|
||||
|
||||
@@ -75,6 +75,7 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
|
||||
"recovery_action",
|
||||
"generation_round_key",
|
||||
"generation_slot_index",
|
||||
"reference_asset_ids",
|
||||
}.issubset(jobs_columns)
|
||||
)
|
||||
indexes = {
|
||||
@@ -843,6 +844,48 @@ class ImageStudioTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertTrue(
|
||||
all(job.generation_round_key is None for job in legacy_jobs)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(job.reference_asset_ids is None for job in legacy_jobs)
|
||||
)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_create_job_freezes_valid_reference_asset_ids(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
config = self._config(temp_dir)
|
||||
db.init_db(config["db_path"])
|
||||
project = image_studio.create_or_get_project(
|
||||
account_alias="店铺",
|
||||
account_slug="shop",
|
||||
item_id="51100639510",
|
||||
path=config["db_path"],
|
||||
)
|
||||
source = image_studio.add_asset(
|
||||
project.id,
|
||||
image_studio.ASSET_KIND_ORIGINAL,
|
||||
path=config["db_path"],
|
||||
)
|
||||
reference = image_studio.add_asset(
|
||||
project.id,
|
||||
image_studio.ASSET_KIND_ORIGINAL,
|
||||
path=config["db_path"],
|
||||
)
|
||||
job = image_studio.create_job(
|
||||
project.id,
|
||||
source_asset_id=source.id,
|
||||
reference_asset_ids=[reference.id],
|
||||
path=config["db_path"],
|
||||
)
|
||||
|
||||
self.assertEqual("[%d]" % reference.id, job.reference_asset_ids)
|
||||
self.assertEqual([reference.id], image_studio.job_reference_asset_ids(job))
|
||||
with self.assertRaisesRegex(db.DbError, "不能包含主图"):
|
||||
image_studio.create_job(
|
||||
project.id,
|
||||
source_asset_id=source.id,
|
||||
reference_asset_ids=[source.id],
|
||||
path=config["db_path"],
|
||||
)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
|
||||
@@ -246,6 +246,89 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_job_reference_snapshot_submits_ordered_images(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg, project, source = self._project_source(temp_dir)
|
||||
reference_path = os.path.join(temp_dir, "reference.png")
|
||||
with open(reference_path, "wb") as fh:
|
||||
fh.write(self._png_bytes())
|
||||
reference = image_studio.add_asset(
|
||||
project.id,
|
||||
image_studio.ASSET_KIND_ORIGINAL,
|
||||
local_path=reference_path,
|
||||
source_order=2,
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
job = image_studio.create_job(
|
||||
project.id,
|
||||
source_asset_id=source.id,
|
||||
reference_asset_ids=[reference.id],
|
||||
prompt="多图提示词",
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
submitted = []
|
||||
|
||||
def fake_submit(method, url, api_key, **kwargs):
|
||||
submitted.append(kwargs["payload"])
|
||||
return {"task_id": "multi-image-task", "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": "multi-image-task",
|
||||
"status": "succeeded",
|
||||
"result": {"image_url": "https://cdn.example.com/multi.png"},
|
||||
},
|
||||
), \
|
||||
mock.patch(
|
||||
"app.image_studio_generation.ai._download_cmhub_image_with_retry",
|
||||
return_value=(self._png_bytes(), 0.1),
|
||||
):
|
||||
summary = image_studio_generation.run_jobs(
|
||||
[job],
|
||||
config=cfg,
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
|
||||
self.assertEqual(1, summary["success"])
|
||||
self.assertEqual(2, len(submitted[0]["images"]))
|
||||
self.assertNotIn("image_base64", submitted[0])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_missing_reference_snapshot_fails_without_submitting(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg, project, source = self._project_source(temp_dir)
|
||||
reference = image_studio.add_asset(
|
||||
project.id,
|
||||
image_studio.ASSET_KIND_ORIGINAL,
|
||||
local_path=os.path.join(temp_dir, "missing-reference.png"),
|
||||
source_order=2,
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
job = image_studio.create_job(
|
||||
project.id,
|
||||
source_asset_id=source.id,
|
||||
reference_asset_ids=[reference.id],
|
||||
prompt="多图提示词",
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
with mock.patch("app.image_studio_generation._runtime", return_value=self._runtime()), \
|
||||
mock.patch("app.image_studio_generation.ai._cmhub_call_with_retry") as submit:
|
||||
summary = image_studio_generation.run_jobs(
|
||||
[job],
|
||||
config=cfg,
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
|
||||
self.assertEqual(1, summary["failed"])
|
||||
self.assertIn("参考图尚未下载", summary["jobs"][0]["error"])
|
||||
submit.assert_not_called()
|
||||
|
||||
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)
|
||||
|
||||
@@ -99,6 +99,7 @@ class ProductSuiteTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(3, len(specs))
|
||||
self.assertEqual([11, 11, 12], [spec["source_asset_id"] for spec in specs])
|
||||
self.assertEqual([[], [], []], [spec["reference_asset_ids"] for spec in specs])
|
||||
self.assertEqual(["白底图", "场景图", "场景图"], [spec["job_type"] for spec in specs])
|
||||
for spec in specs:
|
||||
self.assertIn("平台:Shopee", spec["prompt"])
|
||||
@@ -135,6 +136,26 @@ class ProductSuiteTests(unittest.TestCase):
|
||||
self.assertLess(white_prompt.index("尺码信息规则"), white_prompt.index(reference_rule))
|
||||
self.assertLess(white_prompt.index(reference_rule), white_prompt.index("商品卖点与要求"))
|
||||
|
||||
def test_job_specs_freeze_first_image_references_when_not_per_image_primary(self):
|
||||
settings = product_suite.default_suite_settings()
|
||||
settings.update(
|
||||
{
|
||||
"per_image_primary": False,
|
||||
"categories": {"白底图": 1, "场景图": 1, "卖点图": 0},
|
||||
}
|
||||
)
|
||||
specs = product_suite.build_job_specs(
|
||||
[SimpleNamespace(id=11), SimpleNamespace(id=12), SimpleNamespace(id=13)],
|
||||
"卖点",
|
||||
settings,
|
||||
"51100639510",
|
||||
template_text=prompts.load_default_product_suite_prompt(),
|
||||
)
|
||||
|
||||
self.assertEqual(2, len(specs))
|
||||
self.assertEqual([11, 11], [spec["source_asset_id"] for spec in specs])
|
||||
self.assertEqual([[12, 13], [12, 13]], [spec["reference_asset_ids"] for spec in specs])
|
||||
|
||||
def test_reference_rule_follows_per_image_primary_setting(self):
|
||||
template = prompts.load_default_product_suite_prompt()
|
||||
settings = product_suite.default_suite_settings()
|
||||
|
||||
Reference in New Issue
Block a user