From d11dc752b506e376ca89672b1f6f45fff0051b9b Mon Sep 17 00:00:00 2001 From: chengma Date: Fri, 17 Jul 2026 16:59:50 +0800 Subject: [PATCH] feat: use images payload for product suite generation --- app/image_studio_generation.py | 43 +++++++++++++++++++- docs/04-architecture.md | 1 + docs/api.md | 1 + docs/tasks/T-658a.md | 7 +++- tests/test_image_studio_generation.py | 56 +++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 4 deletions(-) diff --git a/app/image_studio_generation.py b/app/image_studio_generation.py index 2dae04c..697b008 100644 --- a/app/image_studio_generation.py +++ b/app/image_studio_generation.py @@ -14,6 +14,9 @@ from .version import APP_VERSION MAX_CMHUB_IMAGE_STUDIO_WORKERS = 5 _GLOBAL_IMAGE_STUDIO_SLOTS = threading.BoundedSemaphore(MAX_CMHUB_IMAGE_STUDIO_WORKERS) +CMHUB_IMAGE_STUDIO_MAX_INPUT_IMAGES = 8 +CMHUB_IMAGE_STUDIO_MAX_SINGLE_INPUT_BYTES = 10 * 1024 * 1024 +CMHUB_IMAGE_STUDIO_MAX_TOTAL_INPUT_BYTES = 32 * 1024 * 1024 class ImageStudioGenerationError(RuntimeError): @@ -50,6 +53,31 @@ def _source_path(source_asset): return path +def _build_cmhub_images(source_asset, reference_assets=()): + """Build the ordered cmhub image input array from local image assets.""" + candidates = [source_asset] + list(reference_assets or []) + selected = candidates[:CMHUB_IMAGE_STUDIO_MAX_INPUT_IMAGES] + omitted_count = max(0, len(candidates) - len(selected)) + images = [] + total_bytes = 0 + for index, asset in enumerate(selected, 1): + source_path = _source_path(asset) + file_size = os.path.getsize(source_path) + if file_size > CMHUB_IMAGE_STUDIO_MAX_SINGLE_INPUT_BYTES: + role = "主图" if index == 1 else "参考图" + raise ImageStudioGenerationError("商品套图%s超过10MiB,不能提交" % role) + data_url = ai._image_data_url(source_path) + total_bytes += len(data_url.encode("utf-8")) + if total_bytes > CMHUB_IMAGE_STUDIO_MAX_TOTAL_INPUT_BYTES: + raise ImageStudioGenerationError( + "商品套图提交图片总大小超过32MiB,请减少图片或更换较小原图" + ) + images.append({"image_base64": data_url}) + if not images: + raise ImageStudioGenerationError("商品套图至少需要一张本地原图") + return images, omitted_count + + def create_generation_jobs( project_id, source_asset_id, @@ -324,18 +352,29 @@ def _submit_or_resume_job( db_path, should_stop, on_event, + reference_assets=(), ): if job.task_id: _notify(on_event, {"job_id": job.id, "step": "cover_request", "result": "resume", "task_id": job.task_id}) return _request_result(job.task_id, runtime, config) _raise_if_stopped(should_stop) - source_path = _source_path(source_asset) ai_cfg = appconfig.ai_config(config) resolution = str(ai_cfg.get("resolution", "1k") or "1k") + images, omitted_count = _build_cmhub_images(source_asset, reference_assets) + if omitted_count: + _notify( + on_event, + { + "job_id": job.id, + "step": "cover_submit", + "result": "warning", + "detail": "图片最多提交8张,已忽略%d张参考图" % omitted_count, + }, + ) payload = { "prompt": str(job.prompt or ""), "model": runtime["alias"], - "image_base64": ai._image_data_url(source_path), + "images": images, "resolution": ai._normalize_cmhub_resolution(resolution), "aspect_ratio": str(aspect_ratio or "1:1"), } diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 653ac9e..a217cbe 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -437,6 +437,7 @@ data/images///__new. # AI 生成的新 - 任一组件生成后 `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”。 - 商品套图固定使用设置保存的 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-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`。服务不删除本地文件或蝦皮线上图片,任一资产校验失败时整批回滚。 diff --git a/docs/api.md b/docs/api.md index 8f1568c..83b3149 100644 --- a/docs/api.md +++ b/docs/api.md @@ -431,6 +431,7 @@ export_generation_round(project_id, generation_round_key, parent_dir, path=None, - 原图下载走 `image_studio_images` 的公网 URL、大小、Content-Type、重定向和 PIL 解码校验;只在用户单击时落盘。 - `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 扩展。 - `include_failed_downloads=True` 允许 failed 但已有 `task_id`、无输出 asset 的任务继续查询,用于下载失败或本地保存失败恢复。 - 终选顺序由 `replace_selections()` 事务替换,主图/详情图同类别去重、跨类别可复用。 - 导出只写 JPEG 图片文件,透明图铺白底;商品目录已存在时只能覆盖受管命名文件或新建带时间目录,不合并、不递归清空。 diff --git a/docs/tasks/T-658a.md b/docs/tasks/T-658a.md index d78ebc5..b7576db 100644 --- a/docs/tasks/T-658a.md +++ b/docs/tasks/T-658a.md @@ -1,7 +1,7 @@ --- id: T-658a title: 商品套图多图参考 cmhub 接口与提交 payload -status: TODO +status: DONE phase: 7 deps: [T-564] created: 2026-07-17 @@ -50,4 +50,7 @@ git diff --check ## 执行记录 -- 待实现。 +- 2026-07-17:将商品套图异步生图 payload 统一改为 `images` 数组,单图也不再发送顶层 `image_base64`;为后续参考图传入保留同一构造入口。 +- 2026-07-17:新增最多8张、单图原文件10MiB、编码后总输入32MiB的请求前限制;超限使用中文错误阻断,超出数量以中文事件提示。 +- 2026-07-17:补充单图 payload、图片顺序/8张裁剪及总大小超限的单元测试;同步架构/API 文档中的商品套图提交字段说明。 +- 验证通过:`py -3.10 -m unittest tests.test_image_studio_generation`(11项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。 diff --git a/tests/test_image_studio_generation.py b/tests/test_image_studio_generation.py index 91a7640..f310d60 100644 --- a/tests/test_image_studio_generation.py +++ b/tests/test_image_studio_generation.py @@ -187,6 +187,62 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase): self.assertEqual(1, summary["success"]) self.assertEqual("3:4", submitted_payloads[0]["aspect_ratio"]) + self.assertEqual(1, len(submitted_payloads[0]["images"])) + self.assertIn("image_base64", submitted_payloads[0]["images"][0]) + self.assertNotIn("image_base64", submitted_payloads[0]) + + self.assert_removed(temp_dir) + + def test_build_cmhub_images_keeps_order_and_limits_to_eight_inputs(self): + with self.make_temp_dir() as temp_dir: + cfg, project, source = self._project_source(temp_dir) + references = [] + for index in range(2, 10): + path = os.path.join(temp_dir, "source-%d.png" % index) + with open(path, "wb") as fh: + fh.write(self._png_bytes()) + references.append( + image_studio.add_asset( + project.id, + image_studio.ASSET_KIND_ORIGINAL, + local_path=path, + source_order=index, + path=cfg["db_path"], + ) + ) + + with mock.patch( + "app.image_studio_generation.ai._image_data_url", + side_effect=lambda path: "data:image/png;base64,%s" % os.path.basename(path), + ): + images, omitted_count = image_studio_generation._build_cmhub_images( + source, + references, + ) + + self.assertEqual(8, len(images)) + self.assertEqual(1, omitted_count) + self.assertTrue(images[0]["image_base64"].endswith("source.png")) + self.assertTrue(images[-1]["image_base64"].endswith("source-8.png")) + + self.assert_removed(temp_dir) + + def test_build_cmhub_images_rejects_oversized_total_payload(self): + with self.make_temp_dir() as temp_dir: + _, _, source = self._project_source(temp_dir) + with mock.patch( + "app.image_studio_generation.ai._image_data_url", + return_value="x" * 32, + ), mock.patch.object( + image_studio_generation, + "CMHUB_IMAGE_STUDIO_MAX_TOTAL_INPUT_BYTES", + 16, + ): + with self.assertRaisesRegex( + image_studio_generation.ImageStudioGenerationError, + "总大小超过32MiB", + ): + image_studio_generation._build_cmhub_images(source) self.assert_removed(temp_dir)