Răsfoiți Sursa

Add fmode image set skill package

Yi Jiarui 1 săptămână în urmă
comite
1e31767f44
100 a modificat fișierele cu 10466 adăugiri și 0 ștergeri
  1. 399 0
      Listing优化总结报告.html
  2. 8 0
      fmode-image-set/.claude-plugin/plugin.json
  3. 19 0
      fmode-image-set/.env.example
  4. 11 0
      fmode-image-set/.mcp.json
  5. 40 0
      fmode-image-set/EXPORT-MANIFEST.json
  6. 21 0
      fmode-image-set/LICENSE
  7. 121 0
      fmode-image-set/README.md
  8. 5 0
      fmode-image-set/bin/fmode-image-set-score.js
  9. 132 0
      fmode-image-set/bin/fmode-image-set.js
  10. 34 0
      fmode-image-set/docs/capability-map.md
  11. 58 0
      fmode-image-set/docs/cost-model.md
  12. 41 0
      fmode-image-set/docs/customer-quickstart.md
  13. 26 0
      fmode-image-set/docs/deliverables/image-set-report.md
  14. 69 0
      fmode-image-set/docs/directory-layout.md
  15. 69 0
      fmode-image-set/docs/live-manual-acceptance-checklist.md
  16. 34 0
      fmode-image-set/docs/troubleshooting.md
  17. 66 0
      fmode-image-set/install.js
  18. 170 0
      fmode-image-set/mcp/catalog/domestic-platforms.json
  19. 14 0
      fmode-image-set/mcp/catalog/platforms.json
  20. 80 0
      fmode-image-set/mcp/catalog/templates/en/01-hero-image.json
  21. 85 0
      fmode-image-set/mcp/catalog/templates/en/02-lifestyle-scene.json
  22. 72 0
      fmode-image-set/mcp/catalog/templates/en/03-flat-lay.json
  23. 71 0
      fmode-image-set/mcp/catalog/templates/en/04-detail-macro.json
  24. 85 0
      fmode-image-set/mcp/catalog/templates/en/05-poster-banner.json
  25. 77 0
      fmode-image-set/mcp/catalog/templates/en/06-social-media.json
  26. 84 0
      fmode-image-set/mcp/catalog/templates/en/07-ugc-style.json
  27. 74 0
      fmode-image-set/mcp/catalog/templates/en/08-model-showcase.json
  28. 71 0
      fmode-image-set/mcp/catalog/templates/en/09-before-after.json
  29. 70 0
      fmode-image-set/mcp/catalog/templates/en/10-packaging.json
  30. 71 0
      fmode-image-set/mcp/catalog/templates/en/11-infographic.json
  31. 73 0
      fmode-image-set/mcp/catalog/templates/en/12-creative-concept.json
  32. 73 0
      fmode-image-set/mcp/catalog/templates/en/13-size-spec.json
  33. 72 0
      fmode-image-set/mcp/catalog/templates/en/14-multi-product.json
  34. 73 0
      fmode-image-set/mcp/catalog/templates/en/15-livestream.json
  35. 71 0
      fmode-image-set/mcp/catalog/templates/en/16-try-on-virtual.json
  36. 84 0
      fmode-image-set/mcp/catalog/templates/en/17-exploded-view.json
  37. 79 0
      fmode-image-set/mcp/catalog/templates/en/18-ghost-mannequin.json
  38. 84 0
      fmode-image-set/mcp/catalog/templates/en/19-multi-angle-grid.json
  39. 85 0
      fmode-image-set/mcp/catalog/templates/en/20-magazine-editorial.json
  40. 80 0
      fmode-image-set/mcp/catalog/templates/en/21-seasonal-campaign.json
  41. 83 0
      fmode-image-set/mcp/catalog/templates/en/22-luxury-atmospherics.json
  42. 83 0
      fmode-image-set/mcp/catalog/templates/en/23-device-mockup.json
  43. 82 0
      fmode-image-set/mcp/catalog/templates/en/24-storefront.json
  44. 84 0
      fmode-image-set/mcp/catalog/templates/en/25-sports-campaign.json
  45. 80 0
      fmode-image-set/mcp/catalog/templates/zh/01-hero-image.json
  46. 85 0
      fmode-image-set/mcp/catalog/templates/zh/02-lifestyle-scene.json
  47. 72 0
      fmode-image-set/mcp/catalog/templates/zh/03-flat-lay.json
  48. 71 0
      fmode-image-set/mcp/catalog/templates/zh/04-detail-macro.json
  49. 85 0
      fmode-image-set/mcp/catalog/templates/zh/05-poster-banner.json
  50. 77 0
      fmode-image-set/mcp/catalog/templates/zh/06-social-media.json
  51. 84 0
      fmode-image-set/mcp/catalog/templates/zh/07-ugc-style.json
  52. 74 0
      fmode-image-set/mcp/catalog/templates/zh/08-model-showcase.json
  53. 71 0
      fmode-image-set/mcp/catalog/templates/zh/09-before-after.json
  54. 70 0
      fmode-image-set/mcp/catalog/templates/zh/10-packaging.json
  55. 71 0
      fmode-image-set/mcp/catalog/templates/zh/11-infographic.json
  56. 73 0
      fmode-image-set/mcp/catalog/templates/zh/12-creative-concept.json
  57. 73 0
      fmode-image-set/mcp/catalog/templates/zh/13-size-spec.json
  58. 72 0
      fmode-image-set/mcp/catalog/templates/zh/14-multi-product.json
  59. 73 0
      fmode-image-set/mcp/catalog/templates/zh/15-livestream.json
  60. 71 0
      fmode-image-set/mcp/catalog/templates/zh/16-try-on-virtual.json
  61. 84 0
      fmode-image-set/mcp/catalog/templates/zh/17-exploded-view.json
  62. 79 0
      fmode-image-set/mcp/catalog/templates/zh/18-ghost-mannequin.json
  63. 84 0
      fmode-image-set/mcp/catalog/templates/zh/19-multi-angle-grid.json
  64. 85 0
      fmode-image-set/mcp/catalog/templates/zh/20-magazine-editorial.json
  65. 80 0
      fmode-image-set/mcp/catalog/templates/zh/21-seasonal-campaign.json
  66. 83 0
      fmode-image-set/mcp/catalog/templates/zh/22-luxury-atmospherics.json
  67. 83 0
      fmode-image-set/mcp/catalog/templates/zh/23-device-mockup.json
  68. 82 0
      fmode-image-set/mcp/catalog/templates/zh/24-storefront.json
  69. 84 0
      fmode-image-set/mcp/catalog/templates/zh/25-sports-campaign.json
  70. 132 0
      fmode-image-set/mcp/src/core/attachment-resolver.mjs
  71. 195 0
      fmode-image-set/mcp/src/core/core.test.mjs
  72. 40 0
      fmode-image-set/mcp/src/core/credentials.mjs
  73. 50 0
      fmode-image-set/mcp/src/core/plan-ref-store.mjs
  74. 28 0
      fmode-image-set/mcp/src/core/plan-ref-store.test.mjs
  75. 112 0
      fmode-image-set/mcp/src/core/plan-token.mjs
  76. 83 0
      fmode-image-set/mcp/src/core/plan-token.test.mjs
  77. 48 0
      fmode-image-set/mcp/src/core/public-view.mjs
  78. 33 0
      fmode-image-set/mcp/src/core/redaction.mjs
  79. 19 0
      fmode-image-set/mcp/src/core/request-key.mjs
  80. 40 0
      fmode-image-set/mcp/src/core/result-envelope.mjs
  81. 107 0
      fmode-image-set/mcp/src/core/run-paths.mjs
  82. 50 0
      fmode-image-set/mcp/src/core/state-store.mjs
  83. 1313 0
      fmode-image-set/mcp/src/features/generation/orchestrator.mjs
  84. 1291 0
      fmode-image-set/mcp/src/features/generation/orchestrator.test.mjs
  85. 131 0
      fmode-image-set/mcp/src/features/generation/platform-validation.mjs
  86. 110 0
      fmode-image-set/mcp/src/features/generation/platform-validation.test.mjs
  87. 100 0
      fmode-image-set/mcp/src/features/image-analysis/analyze.mjs
  88. 129 0
      fmode-image-set/mcp/src/features/image-analysis/analyze.test.mjs
  89. 41 0
      fmode-image-set/mcp/src/features/planning/catalog.mjs
  90. 13 0
      fmode-image-set/mcp/src/features/planning/catalog.test.mjs
  91. 119 0
      fmode-image-set/mcp/src/features/planning/domestic-platforms.mjs
  92. 58 0
      fmode-image-set/mcp/src/features/planning/domestic-platforms.test.mjs
  93. 34 0
      fmode-image-set/mcp/src/features/planning/locks.mjs
  94. 262 0
      fmode-image-set/mcp/src/features/planning/planner.mjs
  95. 113 0
      fmode-image-set/mcp/src/features/planning/planner.test.mjs
  96. 264 0
      fmode-image-set/mcp/src/providers/new-api-image.mjs
  97. 16 0
      fmode-image-set/mcp/src/providers/provider-error.mjs
  98. 195 0
      fmode-image-set/mcp/src/providers/providers.test.mjs
  99. 106 0
      fmode-image-set/mcp/src/server.mjs
  100. 5 0
      fmode-image-set/mcp/src/tools/analyze.mjs

Fișier diff suprimat deoarece este prea mare
+ 399 - 0
Listing优化总结报告.html


+ 8 - 0
fmode-image-set/.claude-plugin/plugin.json

@@ -0,0 +1,8 @@
+{
+    "name":  "fmode-image-set",
+    "description":  "Plan and generate consistent e-commerce image sets from product facts, references, brand rules, and platform constraints.",
+    "version":  "0.9.0",
+    "author":  {
+                   "name":  "Fmode"
+               }
+}

+ 19 - 0
fmode-image-set/.env.example

@@ -0,0 +1,19 @@
+# Never put real keys in source control. Configure these in the Claude Code/MCP process environment.
+FMODE_LLM_BASE_URL=http://server.fmode.cn:9999
+FMODE_LLM_API_KEY=
+
+# Optional dedicated secret for restart-safe encrypted plan tokens. Use at least 24 random characters.
+# When omitted, the process derives a separate token key from FMODE_LLM_API_KEY for compatibility.
+FMODE_PLAN_TOKEN_SECRET=
+
+# Required only for a trusted remote HTTP gateway. Prefer HTTPS whenever available.
+FMODE_ALLOW_INSECURE_HTTP=true
+FMODE_LLM_TIMEOUT_MS=180000
+
+# Pricing source currency is USD. Convert every plan and budget gate to CNY.
+# 6.8 is the verified `usd_exchange_rate` returned by this gateway on 2026-08-07.
+FMODE_USD_TO_CNY_RATE=6.8
+FMODE_GEMINI_GENERATION_RESERVE_USD=0.10
+FMODE_GEMINI_VISION_RESERVE_USD=0.01
+FMODE_SEEDREAM_IMAGE_PRICE_USD=0.03
+FMODE_IMAGE_SET_DEFAULT_BUDGET_CNY=5.24

+ 11 - 0
fmode-image-set/.mcp.json

@@ -0,0 +1,11 @@
+{
+  "mcpServers": {
+    "fmode-image-set": {
+      "command": "node",
+      "args": [
+        "./mcp/src/server.mjs"
+      ],
+      "cwd": "."
+    }
+  }
+}

+ 40 - 0
fmode-image-set/EXPORT-MANIFEST.json

@@ -0,0 +1,40 @@
+{
+  "name": "fmode-image-set",
+  "version": "0.9.0",
+  "source": "D:\\Angular\\套图设计-2\\fmode-image-set",
+  "target": "D:\\Angular\\汇总\\fmode-image-set",
+  "exportedAt": "2026-09-09T14:43:23",
+  "includedEntries": [
+    ".claude-plugin",
+    ".env.example",
+    ".mcp.json",
+    "bin",
+    "docs",
+    "install.js",
+    "mcp",
+    "package.json",
+    "package-lock.json",
+    "README.md",
+    "LICENSE",
+    "THIRD_PARTY_NOTICES.md",
+    "scripts",
+    "skill-package-manifest.json",
+    "skills"
+  ],
+  "excluded": [
+    "node_modules",
+    "test",
+    "outputs",
+    "output",
+    "生成结果",
+    "memory",
+    ".tmp",
+    ".env",
+    ".env.local",
+    ".env.*",
+    "*.tgz",
+    "*.log",
+    ".codex",
+    ".claude"
+  ]
+}

+ 21 - 0
fmode-image-set/LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Fmode
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 121 - 0
fmode-image-set/README.md

@@ -0,0 +1,121 @@
+# Fmode Image Set
+
+> 当前版本:`0.9.0`。终局评分升级为逐图矩阵与分层结果:仅有成品图即可给基础质量分,有商品参考和角色合同时再给商品一致性、合同适配与完整保障分;生成流程、Provider、7 个 MCP Tool、模板和生成提示词保持不变。
+
+面向 VSCode Claude Code 的商品套图设计包:用 `gemini-3.1-flash-image-preview` 分析商品图和检查质量,先生成可确认的套图计划,再通过 New API 的 Gemini 或 `doubao-seedream-4-0-250828` 生图。需要 Node.js 20.11 或更高版本。
+
+## 能力
+
+- 商品可见事实、包装、颜色、材质、Logo、文字和风格分析
+- 对话中显式粘贴项目内商品图路径;不依赖 VSCode 图片上传或目录扫描
+- 25 类双语电商模板与平台/行业路由
+- 淘宝/天猫、京东、拼多多独立商品图片资产规则;官方证据不足时明确安全降级,不冒充平台合规
+- 精简七图和 H1-H5/M1-M9 完整十四图预设
+- 主图、副图、详情页、多角度、广告和社媒视觉
+- 商品身份锁、颜色锁、风格锁、本地主图 Data URL 锚点和 Gemini 生成后质量复核
+- 国内平台生成结果的 PNG/JPEG/WebP 真实像素、比例、透明通道和规则状态检查
+- 套图内 `quality_rejected` 单图返修自动归位原运行目录,使用版本化文件名并更新原套图状态
+- 计划/执行分离、短期计划引用稳定衔接、单图临时异常最多三次自动尝试、失败不阻断后续图片、原 run 指定项续跑
+- Gemini 高质量默认生图、Seedream 2K 备选生图、两模型编辑和 Seedream 2K 增强
+- 完成后或任意外部套图的只读终局评分:逐图 × rubric 矩阵、基础质量、参考一致性、角色合同适配、最差四分位惩罚、固定硬失败与源文件哈希保护
+
+## 配置
+
+不要把真实密钥写入仓库。至少为 Claude Code/MCP 进程配置:
+
+```powershell
+$env:FMODE_LLM_BASE_URL="http://server.fmode.cn:9999"
+$env:FMODE_LLM_API_KEY="<your-new-api-key>"
+$env:FMODE_ALLOW_INSECURE_HTTP="true" # 仅在确认信任该内网 HTTP 网关时
+```
+
+也兼容 `LLM_BASE_URL` 与 `LLM_API_KEY`。优先使用 HTTPS;远程 HTTP 默认阻断,必须显式授权。可选成本预留变量见 `.env.example`。
+
+## 安装
+
+```powershell
+npm ci
+node install.js --check
+node bin/fmode-image-set.js workspace --smoke
+```
+
+工作区安装会复制完整包和八个 Skill、合并 `.mcp.json`,并把 MCP 路径改写为安装副本的绝对路径。安装后重启 Claude Code 会话。
+
+## 使用
+
+```text
+D:\项目\商品正面.png、D:\项目\参考图\商品背面.jpg 是 MORI 咖啡的商品图,先规划七张套图,不要生图。
+```
+
+```text
+采用刚才确认的高质量模型计划生成,confirmed=true。
+
+继续原任务,只重试 D4,不要重新规划或生成已完成图片。
+```
+
+国内平台必须明确传入 `platform=淘宝/天猫/京东/拼多多`。计划中的 `platformRule` 会显示资产用途、目标规格、证据状态和风险提醒;京东、拼多多当前缺少可公开核验的通用图片数值时使用保守建议,不能描述为官方审核保证。
+
+### 独立终局评分
+
+完整评分由 Skill 先实际打开全部成品图与可用商品原图,再把可定位证据通过 stdin 交给确定性 CLI;全程不调用 MCP、Provider 或网络,不读取凭据,也不回写图片或运行状态。可以检查本包生成目录,也可以检查任意外部套图。
+
+下面的目录命令只产生文件/规格/集合结构的自动技术基线,`overallScore` 会保持 N/A;它不能替代完整视觉评分:
+
+```powershell
+node skills/fmode-image-set-score/scripts/score-image-set.mjs "D:\待验收\套图A" --profile generic --format markdown
+node skills/fmode-image-set-score/scripts/score-image-set.mjs --manifest - --base "D:\待验收\套图A" --format json
+```
+
+第二条命令要求从 stdin 传入 `score-contract/v3` JSON。完整 quality 矩阵可独立给出 `qualityScore`;有商品参考时给 `fidelityScore`,有显式角色时给 `contractFitScore`,三层均具备资格后才给 `assuredOverallScore`。覆盖按逐图评分单元计算,单次模型审查最高为 medium 可信度。真实 CTR/CVR/ROAS 不由视觉模型预测,也不进入图片质量分。详见 `skills/fmode-image-set-score/SKILL.md`、`references/visual-rubric.md` 和 `references/scoring-contract.md`。内置 profile 是内部验收基线,不代表平台官方审核保证。
+
+## MCP Tools
+
+- `fmode_image_set_analyze`:Gemini 商品识图。
+- `fmode_image_set_plan`:套图规划和模型选择,不向用户展示生成费用;传入 `sessionId + promptId` 时返回跨重启有效的加密 `planToken`,并保留旧 `planRef`。
+- `fmode_image_set_templates`:查询模板和预设。
+- `fmode_image_set_generate`:确认后以 `planToken + confirmedPlanId + 同一 sessionId` 精确执行并接收 Base64 结果;兼容旧 `planRef`、完整 plan 和原 run 指定 outputId 续跑。
+- `fmode_image_set_status`:读取本地运行状态。
+- `fmode_image_set_edit`:Gemini/Seedream 图像编辑;受管理套图中的当前质量拒绝项会原位返修,普通图片仍创建独立编辑运行。
+- `fmode_image_set_upscale`:Seedream 高质量 2K 增强。
+
+独立评分不注册 MCP Tool;它由第 8 个 Skill 自带的 CLI 执行,避免参与生成状态机。
+
+## 输出
+
+新运行结果写入调用方项目的中文目录:
+
+```text
+输出/商品套图/<商品名称>-<8位短请求标识>/
+```
+
+`state.json` 保存脱敏状态,图片以绝对路径返回。完整成功后,项目根直属商品图移动到运行目录的 `商品原图/`,项目子目录商品图复制到该目录,并在结果中逐项报告。部分完成、失败、质量拒绝或 `submission_unknown` 不移动项目根原图。旧 `outputs/fmode-image-set/run_*` 继续支持状态读取,但不再用于新运行。
+
+输出根目录固定,Tool 调用不能改写工作区或输出根路径。
+
+完整目录契约见 `docs/directory-layout.md`。正常流程不会在项目根目录创建 `.tmp-*`;自动测试临时文件统一位于系统 Temp。
+
+## 确认、恢复与安全
+
+- 模板查询和本地规划不调用外部模型;生成、编辑和 2K 增强必须显式 `confirmed: true`。
+- 同一商品的多张路径可一次传入并设置 `allImagesSameProduct=true`;混合参考图才需要逐张角色映射。
+- `planToken` 是 AES-256-GCM 加密的短期自包含计划载体,绑定工作区摘要、规划会话和精确 `planId`,不落盘且不替代用户确认;MCP 重启后仍可执行。它用于防止误串台,但当前 MCP 的 `sessionId` 由调用方提供,不能宣称为宿主级不可伪造身份。旧 `planRef` 仅作同进程兼容。
+- 普通用户计划、状态和总结不展示预计、累计或实际费用。内部仍保留可信安全上限;`budgetLimitCny` 只作为旧调用兼容字段。
+- 单图网络中断、临时网关异常或响应无法解析时,使用稳定幂等键最多自动尝试三次;仍失败则继续后续图片,不自动切换模型。
+- 已取得远程结果 URL 时只重试下载,不重新生成。执行结束后列出失败 outputId,用户确认后以原 `runId + retryOutputIds` 续跑。
+- 已完成图片是稳定资产:失败恢复不得重新识图、规划或生成已完成项。
+- 第一张通过 Gemini 质检后,以本地 Data URL 作为后续图片的身份锚点。
+- 单次最多 14 张;换模型、扩容或改变设计目标必须重新规划并确认。
+
+开发者内部安全上限口径见 `docs/cost-model.md`,不得复制到普通用户输出。
+
+## 测试与排障
+
+```powershell
+npm test
+npm run smoke:package
+npm run mcp:smoke
+npm run acceptance:installer
+npm run acceptance
+```
+
+详见 `docs/customer-quickstart.md`、`docs/live-manual-acceptance-checklist.md` 和 `docs/troubleshooting.md`。

+ 5 - 0
fmode-image-set/bin/fmode-image-set-score.js

@@ -0,0 +1,5 @@
+#!/usr/bin/env node
+import { runCli } from '../skills/fmode-image-set-score/scripts/score-image-set.mjs';
+
+process.exitCode = runCli();
+

+ 132 - 0
fmode-image-set/bin/fmode-image-set.js

@@ -0,0 +1,132 @@
+#!/usr/bin/env node
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { spawnSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { checkPackage } from '../install.js';
+
+const sourceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const exclusions = new Set(['node_modules', '.git', '.env', '.env.local', '.claude', '.codex', 'outputs', 'output', '输出', '生成结果', 'memory', '.tmp']);
+
+function inside(parent, child) {
+  const p = path.resolve(parent).toLowerCase();
+  const c = path.resolve(child).toLowerCase();
+  return c === p || c.startsWith(`${p}${path.sep}`);
+}
+
+function copyPackage(target, force) {
+  if (inside(sourceRoot, target)) throw new Error('安装目标不能位于源码包内部。');
+  if (fs.existsSync(target)) {
+    if (!force) throw new Error(`目标已存在:${target}。如需覆盖请传入 --force。`);
+    fs.rmSync(target, { recursive: true, force: true });
+  }
+  fs.mkdirSync(path.dirname(target), { recursive: true });
+  copyTree(sourceRoot, target, true);
+  // v0.2 removed these providers. Explicit pruning prevents stale runtime files
+  // when upgrading from an older installation on filesystems with unusual copy behavior.
+  for (const legacy of ['qwen-vision.mjs', 'jimeng.mjs']) {
+    const legacyPath = path.join(target, 'mcp', 'src', 'providers', legacy);
+    fs.rmSync(legacyPath, { force: true });
+    if (fs.existsSync(legacyPath)) throw new Error(`无法清理旧 Provider,请先重启/关闭 Claude Code 后重试:${legacy}`);
+  }
+}
+
+function copyTree(source, target, isPackageCopy = false) {
+  const stat = fs.lstatSync(source);
+  if (stat.isSymbolicLink()) return;
+  if (stat.isDirectory()) {
+    fs.mkdirSync(target, { recursive: true });
+    for (const entry of fs.readdirSync(source)) {
+      if (isPackageCopy && (exclusions.has(entry) || entry.endsWith('.tgz') || entry.endsWith('.log'))) continue;
+      copyTree(path.join(source, entry), path.join(target, entry), isPackageCopy);
+    }
+    return;
+  }
+  fs.copyFileSync(source, target);
+}
+
+function readJsonMaybe(file) {
+  try { return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')) : {}; }
+  catch { throw new Error(`JSON 无法解析:${file}`); }
+}
+
+function writeMcp(workspace, installedRoot) {
+  const mcpFile = path.join(workspace, '.mcp.json');
+  const current = readJsonMaybe(mcpFile);
+  current.mcpServers = current.mcpServers || {};
+  current.mcpServers['fmode-image-set'] = {
+    command: 'node', args: [path.join(installedRoot, 'mcp', 'src', 'server.mjs')], cwd: installedRoot,
+    env: { FMODE_WORKSPACE_ROOT: workspace }
+  };
+  fs.writeFileSync(mcpFile, `${JSON.stringify(current, null, 2)}\n`, 'utf8');
+  JSON.parse(fs.readFileSync(mcpFile, 'utf8'));
+  fs.writeFileSync(path.join(installedRoot, '.mcp.json'), `${JSON.stringify({ mcpServers: { 'fmode-image-set': current.mcpServers['fmode-image-set'] } }, null, 2)}\n`, 'utf8');
+}
+
+function exposeWorkspaceSkills(workspace, installedRoot, force) {
+  const manifest = readJsonMaybe(path.join(installedRoot, 'skill-package-manifest.json'));
+  for (const name of manifest.skills) {
+    const source = path.join(installedRoot, 'skills', name);
+    const target = path.join(workspace, '.claude', 'skills', name);
+    if (fs.existsSync(target)) {
+      if (!force) throw new Error(`Skill 已存在:${target}。使用 --force 覆盖。`);
+      fs.rmSync(target, { recursive: true, force: true });
+    }
+    fs.mkdirSync(path.dirname(target), { recursive: true });
+    copyTree(source, target, false);
+  }
+}
+
+function runNode(args, cwd) {
+  const result = spawnSync(process.execPath, args, { cwd, stdio: 'inherit' });
+  if (result.status !== 0) throw new Error(`命令失败:node ${args.join(' ')}`);
+}
+
+function installDependencies(target, skipInstall) {
+  if (skipInstall) return;
+  const result = spawnSync('npm', ['ci', '--omit=dev'], { cwd: target, stdio: 'inherit', shell: process.platform === 'win32' });
+  if (result.status !== 0) throw new Error('依赖安装失败。');
+}
+
+function parseFlags(args) {
+  return { force: args.includes('--force'), smoke: args.includes('--smoke'), skipInstall: args.includes('--skip-install') };
+}
+
+async function workspaceInstall(args) {
+  const flags = parseFlags(args);
+  const workspace = process.cwd();
+  const target = path.join(workspace, '.claude', 'plugins', 'fmode-image-set');
+  copyPackage(target, flags.force);
+  installDependencies(target, flags.skipInstall);
+  exposeWorkspaceSkills(workspace, target, flags.force);
+  writeMcp(workspace, target);
+  if (flags.smoke) {
+    runNode(['scripts/smoke-package.mjs'], target);
+    runNode(['scripts/smoke-mcp.mjs'], target);
+  }
+  console.log(`Workspace installed: ${target}`);
+  console.log('Restart the Claude Code session.');
+}
+
+async function globalInstall(args) {
+  const flags = parseFlags(args);
+  const target = path.join(os.homedir(), '.claude', 'plugins', 'fmode-image-set');
+  copyPackage(target, flags.force);
+  installDependencies(target, flags.skipInstall);
+  writeMcp(target, target);
+  if (flags.smoke) runNode(['scripts/acceptance.mjs'], target);
+  console.log(`Global plugin installed: ${target}`);
+}
+
+async function main() {
+  const [command = 'help', ...args] = process.argv.slice(2);
+  if (command === 'workspace') return workspaceInstall(args);
+  if (command === 'install') return globalInstall(args);
+  if (command === 'check') { const result = checkPackage(sourceRoot); console.log(JSON.stringify(result, null, 2)); process.exitCode = result.ok ? 0 : 1; return; }
+  if (command === 'smoke') { runNode(['scripts/acceptance.mjs'], sourceRoot); return; }
+  if (command === 'path') { console.log(sourceRoot); return; }
+  console.log('Usage: fmode-image-set <workspace|install|check|smoke|path> [--force] [--smoke] [--skip-install]');
+}
+
+main().catch(error => { console.error(error.message); process.exit(1); });

+ 34 - 0
fmode-image-set/docs/capability-map.md

@@ -0,0 +1,34 @@
+# Capability Map
+
+| 用户目标 | Skill | MCP Tool | Feature | Provider | 是否使用外部配额 |
+|---|---|---|---|---|---|
+| 分析商品图 | `fmode-product-vision` | `fmode_image_set_analyze` | image-analysis | Gemini 3.1 Flash Image Preview | 是 |
+| 规划套图 | `fmode-image-set` | `fmode_image_set_plan` | image-set-planning | 本地规则 | 否 |
+| 规划淘宝/天猫、京东、拼多多商品图 | `fmode-product-main-image` / `fmode-detail-page` | `fmode_image_set_plan` | domestic-platform-planning | 本地规则与版本化证据 | 否 |
+| 查询模板 | 所有 Skill | `fmode_image_set_templates` | template-catalog | 本地文件 | 否 |
+| 生成套图 | `fmode-image-set` 及专项 Skill | `fmode_image_set_generate` | generation-orchestrator | Gemini(默认)/Seedream | 是 |
+| 原 run 指定失败项续跑 | `fmode-image-set` 及专项 Skill | `fmode_image_set_generate` | generation-orchestrator | Gemini(默认)/Seedream | 是 |
+| 查询状态 | `fmode-image-set` | `fmode_image_set_status` | generation-orchestrator | 本地状态 | 否 |
+| 编辑图片/套图拒绝项原位返修 | `fmode-image-set` | `fmode_image_set_edit` | generation-orchestrator | Gemini/Seedream | 是 |
+| 2K 增强 | 专项 Skill | `fmode_image_set_upscale` | generation-orchestrator | Seedream | 是 |
+| 终局/外部套图评分 | `fmode-image-set-score` | 无(独立 CLI) | Skill 自包含确定性评分器 | 无 | 否 |
+
+## 业务路由
+
+- 商品主图、副图、白底和电商轮播 → `fmode-product-main-image`
+- A+、详情页、Hero Banner、卖点模块 → `fmode-detail-page`
+- 服装、鞋包、配饰九角度 → `fmode-multi-angle`
+- 付费投放、促销、Campaign → `fmode-ad-creative`
+- 自然社媒、Feed、Story、Carousel → `fmode-social-visual`
+- 信息不完整或跨场景套图 → `fmode-image-set`
+- 已完成套图、任意外部套图、整体验收或客观打分 → `fmode-image-set-score`
+
+## 数据流
+
+`用户粘贴项目内图片路径(精确 ID 附件仅兼容回退)→ Gemini 识图 → Brief/商品名 → 本地计划与模型路由 → 用户确认 → New API 生图(单项临时异常有限重试,失败后继续)→ 中文运行目录安全落盘 → Gemini 质量检查 → 失败 ID 原 run 指定项续跑 / 全部完成后原图移动复制 → 文件与质量摘要`
+
+国内平台商品图在本地计划阶段增加资产类型和证据状态,生成落盘后增加真实像素/比例检查;未命中国内平台时完全沿用旧数据流。
+
+受管理套图的当前质量拒绝文件进入原位返修支路;普通图片编辑、已通过套图图片编辑和增强仍沿用原独立运行数据流。
+
+终局评分是旁路数据流:`用户明确图片/目录/manifest → 本地文件与显式证据合同 → 确定性评分报告`。该支路不进入 MCP Server、Provider、run 状态或返修流程。

+ 58 - 0
fmode-image-set/docs/cost-model.md

@@ -0,0 +1,58 @@
+# 内部成本安全上限(v0.6.0)
+
+> 仅供开发者维护内部执行上限。普通用户计划、状态、错误和总结不得展示本文件中的字段、单价、估算、累计或实际费用。
+
+## 人民币口径
+
+内部状态和旧调用兼容门禁使用人民币:
+
+- 计划字段:`estimatedCostCny`
+- 预算字段:`budgetLimitCny`
+- 单位:`CNY`
+- USD/CNY:6.8(网关 `/api/status` 在 2026-08-07 返回的 `usd_exchange_rate`)
+
+汇率可由受信环境变量 `FMODE_USD_TO_CNY_RATE` 更新,MCP Tool 参数不能修改。
+
+## 截图与网关定价
+
+| 模型/动作 | 美元定价或预留 | 人民币换算 |
+|---|---:|---:|
+| Seedream 生图 | 0.03 USD/请求(固定价) | ¥0.204/张 |
+| Gemini 生图 | 0.10 USD/张(保守预留) | ¥0.680/张 |
+| Gemini 识图/质检 | 0.01 USD/次(保守预留) | ¥0.068/次 |
+
+Gemini 截图中的 token 价格为:
+
+- 输入:1.13 USD / 100 万 tokens
+- 输出:6.75 USD / 100 万 tokens
+- 缓存倍率:0.097345132743
+- 图片倍率:1
+
+网关返回的 Gemini 计费表达式为:
+
+```text
+tier("base", p * 0.75 + c * 4.5 + cr * 0.08 + img * 0.75 + img_o * 90)
+```
+
+因此 Gemini 最终成本取决于实际 token 与图片用量,¥0.68/生成和 ¥0.068/视觉调用是执行前预算预留,不是固定报价。
+
+## 默认计划
+
+- Gemini 七图 + 逐图质检:`7 × (0.680 + 0.068) = ¥5.236`
+- Gemini 十四图 + 逐图质检:`14 × (0.680 + 0.068) = ¥10.472`
+- Seedream 七图 + Gemini 质检:`7 × (0.204 + 0.068) = ¥1.904`
+- Seedream 十四图 + Gemini 质检:`14 × (0.204 + 0.068) = ¥3.808`
+
+计划前的一次 Gemini 识图另预留 ¥0.068,因此完整流程约为:
+
+- Gemini 七图:¥5.304;十四图:¥10.540
+- Seedream 七图:¥1.972;十四图:¥3.876
+
+## 可配置项
+
+- `FMODE_USD_TO_CNY_RATE`
+- `FMODE_GEMINI_GENERATION_RESERVE_USD`
+- `FMODE_GEMINI_VISION_RESERVE_USD`
+- `FMODE_SEEDREAM_IMAGE_PRICE_USD`
+
+调整这些受信值会改变执行前人民币预算门禁,不会改变网关实际账单。

+ 41 - 0
fmode-image-set/docs/customer-quickstart.md

@@ -0,0 +1,41 @@
+# 客户快速开始
+
+## 完成后只读评分
+
+完整评分不需要 API Key,也不使用外部配额。应在 Claude Code 中调用 `$fmode-image-set-score`,让 Skill 逐一打开全部成品图和商品原图、核验显式角色,再运行确定性计分器。
+
+直接运行下面命令只会得到自动技术基线;它不会查看图片内容,因此 `qualityScore` 和完整保障分都不可用:
+
+```powershell
+node skills/fmode-image-set-score/scripts/score-image-set.mjs "D:\待验收\套图" --profile generic --format markdown
+```
+
+完整视觉评分必须用 `score-contract/v3`,并从 stdin 提交逐图 × rubric 的 `assessments` 而不落盘 manifest。`qualityScore` 是套图自身基础质量;没有商品参考时只有 `fidelityScore` 为 N/A,没有显式角色时只有 `contractFitScore` 为 N/A;两者缺失都会使 `assuredOverallScore` 为 N/A,但不再阻止基础质量评分。所有分数必须和矩阵覆盖、可信度、硬失败、最弱图片及整改一起阅读。真实投放指标单独报告,不得根据视觉印象预测。
+
+1. 安装 Node.js 20.11+,在包目录运行 `npm ci`。
+2. 为 Claude Code/MCP 进程配置 `FMODE_LLM_BASE_URL` 和 `FMODE_LLM_API_KEY`;远程 HTTP 网关还需显式设置 `FMODE_ALLOW_INSECURE_HTTP=true`。
+3. 在目标工作区运行 `node <包路径>/bin/fmode-image-set.js workspace --smoke`。
+4. 重启 VSCode Claude Code。
+5. 把商品图放在当前项目内,在对话中粘贴一个或多个路径并说:“这些是 MORI 咖啡的商品图,先规划七张套图,不要生图。”
+   - 国内平台示例:“这些是 MORI 咖啡的商品图,按淘宝主图方案规划,不要生图。”此时必须明确平台为淘宝/天猫、京东或拼多多,不能依靠模糊猜测。
+6. 检查每张图的目标、模型、尺寸和提示词摘要;系统不会展示生成费用。
+   - 国内平台还要检查 `platformRule` 的资产用途、证据状态和警告;`safe_fallback` 只表示保守设计建议,不代表平台官方审核保证。
+7. 确认后说:“按这个计划生成,`confirmed=true`。”之后可以离开界面等待;单图临时异常会自动尝试,仍失败也会继续后续图片。
+8. 检查每项 `quality.status`;`unverified` 或 `quality_rejected` 需要人工复核。
+   - 国内平台生成结果还会读取真实 PNG/JPEG/WebP 元数据并返回 `platformValidation`;required 规格失败会拒绝交付,recommended 规格只警告并保留文件。
+9. 完整成功后检查中文运行目录和 `商品原图/`:项目根图片应已移动,项目子目录图片应保留并复制;最终消息应逐项给出新路径。
+10. 如果最终列出失败 ID,回复“继续原任务,只重试 D4、D6”。系统必须使用原 `runId + retryOutputIds`,不得重新规划或生成已完成图片。
+
+## 模型选择
+
+- 默认/`high_quality`/`gemini`:使用 `gemini-3.1-flash-image-preview`,优先商品身份一致性和整体质量。
+- `seedream`:使用 `doubao-seedream-4-0-250828` 的 2K 图片接口。
+- 无论使用哪个生图模型,质量检查都使用 Gemini。
+- 失败后不会自动切换模型;换模型会改变计划,必须重新规划和确认。
+
+## 凭据缺失
+
+- `needs_token`:配置 New API Key 后重启 MCP。
+- `needs_valid_token`:检查 Key 是否过期或 endpoint 是否匹配。
+- `needs_recharge`:充值后先核对原 `run_id`,不要直接创建重复请求。
+- `blocked`(HTTP):优先改用 HTTPS;仅信任内网网关时才允许远程 HTTP。

+ 26 - 0
fmode-image-set/docs/deliverables/image-set-report.md

@@ -0,0 +1,26 @@
+# 套图交付合同
+
+## 计划阶段
+
+必须返回:
+
+- `plan_id`、`plan_hash`、预设和语言;
+- 商品事实来源与未知项;
+- 每个输出的 ID、类型、目标、尺寸、模板和提示词摘要;
+- 总张数、确认要求和警告;不得返回预计、累计或实际生成费用。
+
+不得返回:完整 API Key、Authorization、原始会话日志或付费提交结果。
+
+## 执行阶段
+
+必须返回:
+
+- `run_id` 和总体状态;
+- 每个输出的 `planned/submitting/retry_wait/receiving/completed/failed/submission_unknown/quality_rejected` 状态;
+- 已完成文件的绝对路径;
+- 中文运行目录,以及每张本地商品原图“已移动/已复制”的原路径和新路径;
+- 已完成/失败 outputId、错误类别和下一步;失败时询问是否以原 run 只重试这些 outputId。
+
+## 阻断阶段
+
+必须清楚说明缺少输入、密钥或确认中的哪一项,以及如何从原计划/运行继续;不得显示费用字段或金额。

+ 69 - 0
fmode-image-set/docs/directory-layout.md

@@ -0,0 +1,69 @@
+# 目录与文件规范
+
+## 正式安装
+
+工作区安装只允许产生以下内容:
+
+```text
+<workspace>/
+├── .mcp.json
+└── .claude/
+    ├── plugins/fmode-image-set/   # 插件运行副本与 node_modules
+    └── skills/
+        ├── fmode-image-set/
+        ├── fmode-product-vision/
+        ├── fmode-product-main-image/
+        ├── fmode-detail-page/
+        ├── fmode-multi-angle/
+        ├── fmode-ad-creative/
+        └── fmode-social-visual/
+```
+
+这些是安装文件,不是生图结果。升级时由安装器原位覆盖,不应在其他目录再复制一份。
+
+## 正式运行结果
+
+所有真实套图运行只允许写入:
+
+```text
+<workspace>/输出/商品套图/<商品名称>-<8位短请求标识>/
+├── state.json
+├── 商品原图/
+│   ├── 01-商品正面.png
+│   └── 02-商品背面.jpg
+├── S1-1.png
+├── S2-1.jpg
+└── ...
+```
+
+- 运行目录名优先使用用户声明商品名,其次使用明确识图名称,并附加 request key 前 8 位;同一请求不会换目录或重复创建付费任务。
+- `state.json` 是脱敏运行状态;图片文件名由计划的 `outputId` 决定。
+- 部分完成、质量拒绝或提交不确定仍保留在同一个运行目录。
+- 全部计划项完成后,项目根直属商品图移动到 `商品原图/`,项目子目录商品图复制到该目录;整理前重新验证真实路径和内容哈希。
+- Skill 不会把结果写入插件源码、`.claude/`、项目根目录或任意自定义位置。
+
+旧版 `outputs/fmode-image-set/run_*/` 不自动迁移或删除,状态查询和恢复继续兼容。
+
+## 测试与临时文件
+
+- 自动测试必须使用操作系统 Temp(Windows 通常是 `%LOCALAPPDATA%/Temp`)。
+- 测试结束会删除自己的临时目录。
+- 项目根目录禁止出现任何 `.tmp-*` 文件或目录。
+- 测试运行器会检查前后直接子项集合;一旦新增项目根 `.tmp-*` 文件或目录,测试直接失败。
+
+## 生命周期
+
+| 类型 | 是否可删除 | 建议 |
+|---|---|---|
+| `.claude/plugins/fmode-image-set` | 不建议手动删除 | 由安装器升级;删除后 Tool 无法启动 |
+| `.claude/skills/fmode-*` | 不建议手动删除 | 由安装器升级;删除后 Skill 无法发现 |
+| `.mcp.json` 中本 Server 配置 | 不建议手动删除 | 删除后 MCP Tool 不可用 |
+| `输出/商品套图/<商品名称>-<短标识>` | 用户确认后可删除 | 一个运行整体归档或删除,不要只删 `state.json` |
+| 系统 Temp 中测试目录 | 可删除 | 正常测试会自动清理 |
+
+## 禁止行为
+
+- 不以日期、模型或调试名称在项目根随意新建输出目录。
+- 不把真实结果混入测试目录。
+- 不把测试 fixture 写到 `输出/商品套图/` 或旧 `outputs/fmode-image-set/`。
+- 不自动清理真实运行结果。

+ 69 - 0
fmode-image-set/docs/live-manual-acceptance-checklist.md

@@ -0,0 +1,69 @@
+# Claude Code 真实人工验收
+
+## 独立终局评分(无外部写动作)
+
+- [ ] 对一个已完成生成目录调用 `fmode-image-set-score`,确认实际打开每张成品图与商品原图,并执行一次跨图复核。
+- [ ] 只运行目录 CLI 时 `automaticScore` 可用但 `overallScore/grade` 为 N/A,不再出现“总体 100/A 但证据不足”。
+- [ ] 完整 v3 逐图矩阵返回基础质量、商品一致性、合同适配和完整保障分,并列出单元覆盖、单图分、最弱图片、硬失败和整改项。
+- [ ] 缺少商品参考或显式角色时,基础质量仍可用;只将对应可选层和完整保障分标为 N/A。
+- [ ] 对任意外部套图目录运行同一 Skill,无需 runId、MCP 或 API Key。
+- [ ] 评分前后所有源图片 SHA-256 一致,目录中没有新增报告、状态或临时文件。
+- [ ] 相同 manifest/evidence 重复运行的确定性报告除展示格式外完全一致。
+- [ ] 无商品参考时商品保真与总体分为 N/A;无投放数据时只把商业表现另列 N/A,不声称平台合规或预测商业表现。
+- [ ] Windows PowerShell 中文路径通过 stdin 时显式使用 UTF-8 `OutputEncoding`,参考图不会退化为 `?` 路径。
+
+## 安装与发现
+
+- [ ] 工作区 `.mcp.json` 保留原有 Server。
+- [ ] 八个 Skill 均可被发现,其中 `fmode-image-set-score` 带自包含评分脚本。
+- [ ] MCP Server 暴露 manifest 中七个 Tool。
+
+## Gemini 识图
+
+- [ ] 显式图片路径可分析。
+- [ ] 用户声明多张路径均为同一商品图时可全部作为 identity 分析。
+- [ ] 不通过 Glob 或历史文件猜测商品图。
+- [ ] 混合多图未标角色时返回 `needs_input`。
+- [ ] 分析结果区分可见事实、推断和未知项。
+- [ ] 用户声明商品名优先;没有声明时可使用明确识图名称。
+
+## 计划门禁
+
+- [ ] “只规划”不调用生图接口。
+- [ ] 默认模型是 `gemini-3.1-flash-image-preview`。
+- [ ] 用户可见计划不包含预计、累计或实际费用字段/金额。
+- [ ] 未确认生成返回 `needs_confirmation`。
+- [ ] 不传 `budgetLimitCny` 也可在明确确认后执行;旧调用传入过低上限仍返回 `needs_confirmation` 且不显示金额。
+- [ ] 同一 request key 不重复提交。
+
+## 真实生成(外部写动作)
+
+- [ ] 用户明确确认张数、模型和计划。
+- [ ] Gemini Markdown Data URL 能写入项目输出目录。
+- [ ] Seedream `b64_json` 能写入项目输出目录。
+- [ ] 本地参考图无需公网 URL。
+- [ ] 临时异常在同一 outputId 使用稳定幂等键最多尝试三次;耗尽后继续后续项目且不自动换模型。
+- [ ] 最终只列出失败 outputId 并询问是否补图;确认后原 `runId + retryOutputIds` 只生成指定项,已完成 requestId/文件保持不变。
+- [ ] 远程结果 URL 下载失败只重试下载,不再次调用生图接口。
+- [ ] 新运行目录为 `输出/商品套图/<商品名称>-<短标识>/`。
+- [ ] 完整成功后项目根参考图移动、项目子目录参考图复制到 `商品原图/`,并逐项报告路径。
+- [ ] 部分完成、失败、质量拒绝和 `submission_unknown` 不移动项目根参考图。
+- [ ] 旧 `outputs/fmode-image-set/run_*` 状态仍可查询且不会重复生成。
+
+## 套图单图返修(外部写动作)
+
+- [ ] 对 `quality_rejected` item 的当前文件调用 edit,结果保存为原目录中的 `<outputId>-v2-1.<扩展名>`,未创建顶层“单图处理”目录。
+- [ ] 原始失败文件仍保留,原 item 的 `revisions` 记录来源、结果和质量;用户输出不展示内部费用。
+- [ ] 返修通过后原 item/run 状态更新;全部通过后才整理商品原图。
+- [ ] 相同返修确认重复调用不再生图;`submission_unknown` 禁止新请求,已持久化远程 URL 可由原 run status refresh 恢复。
+- [ ] 普通独立图片 edit 仍创建独立运行,且不移动源图。
+
+## 质量
+
+- [ ] 质检模型固定为 Gemini。
+- [ ] 商品形状、包装、Logo 和主色跨图一致。
+- [ ] 多角度人物身份、服装和配饰一致。
+- [ ] 平台尺寸、安全区和文字规则正确。
+- [ ] 没有伪造平台 UI、账号数据或工具水印。
+- [ ] 淘宝/天猫、京东、拼多多计划逐项包含资产类型、证据状态和风险提醒;安全降级未被描述为官方合规。
+- [ ] 国内平台结果报告真实文件格式、像素、比例和透明通道;违反官方必需比例时保留文件并拒绝作为身份锚点。

+ 34 - 0
fmode-image-set/docs/troubleshooting.md

@@ -0,0 +1,34 @@
+# Troubleshooting
+
+## Skill 能触发但看不到 MCP Tool
+
+重新运行工作区安装,检查工作区 `.mcp.json` 中 `cwd` 和 Server 路径是否为实际绝对路径,然后重启 Claude Code。
+
+## `needs_token`
+
+为 Claude Code/MCP 进程配置 `FMODE_LLM_API_KEY` 或 `LLM_API_KEY`。不要把 Key 写入仓库。
+
+## 远程 HTTP 被阻断
+
+API Key 会在 HTTP 连接中明文传输。优先要求网关提供 HTTPS;如果目标是明确受信的内网网关,设置 `FMODE_ALLOW_INSECURE_HTTP=true` 后重启 MCP。
+
+## `[Unsupported Image]`
+
+不要让 Skill Glob 或猜测历史图片。首选把商品图保存到当前项目内,并在对话中粘贴图片的显式绝对路径或相对路径。带精确 `sessionId + promptId + cwd` 的旧附件解析仅作为兼容回退。
+
+## `submission_unknown`
+
+请求是否已被网关接收无法确定。执行器会为当前 outputId 使用同一稳定幂等键最多自动尝试三次;耗尽后记录该项并继续后续图片,不自动切换模型。任务结束后保留 `runId`,只询问是否重试失败 outputId;确认后在原 run 续跑,不重新规划整套。
+
+## `quality_rejected` / `unverified`
+
+- `quality_rejected`:Gemini 发现身份漂移或明显质量问题;文件仍会保留,但不会作为主图锚点。
+- `unverified`:Gemini Key、服务或结构化响应不可用;不能当作质量通过,需人工复核。
+
+## 状态查询
+
+当前 New API 链路同步返回图片,不再轮询旧即梦 workId。`fmode_image_set_status` 默认读取脱敏本地状态;若响应已持久化远程结果 URL,可用 `refresh=true` 恢复下载,但不会再次调用生图接口。
+
+## 输出路径被拒绝
+
+新输出根目录固定为调用方项目的 `输出/商品套图/`,不能由 Tool 参数改写,也不能经过符号链接写入 Skill 源码、其他运行目录或项目外路径。旧 `outputs/fmode-image-set/` 仅用于读取既有运行。

+ 66 - 0
fmode-image-set/install.js

@@ -0,0 +1,66 @@
+#!/usr/bin/env node
+import fs from 'node:fs';
+import path from 'node:path';
+import { spawnSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+const packageRoot = path.dirname(fileURLToPath(import.meta.url));
+const required = [
+  '.claude-plugin/plugin.json', '.mcp.json', 'README.md', 'package.json', 'package-lock.json',
+  'skill-package-manifest.json', 'mcp/src/server.mjs', 'scripts/smoke-package.mjs',
+  'scripts/smoke-mcp.mjs', 'docs/capability-map.md', 'docs/customer-quickstart.md',
+  'docs/live-manual-acceptance-checklist.md', 'bin/fmode-image-set-score.js',
+  'skills/fmode-image-set-score/SKILL.md',
+  'skills/fmode-image-set-score/scripts/score-image-set.mjs',
+  'skills/fmode-image-set-score/references/scoring-contract.md',
+  'skills/fmode-image-set-score/references/visual-rubric.md'
+];
+
+function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); }
+
+export function checkPackage(root = packageRoot) {
+  const errors = [];
+  const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number);
+  if (nodeMajor < 20 || (nodeMajor === 20 && nodeMinor < 11)) errors.push('node_version_requires_20.11');
+  for (const item of required) if (!fs.existsSync(path.join(root, item))) errors.push(`missing:${item}`);
+  if (errors.length) return { ok: false, errors };
+  const pkg = readJson(path.join(root, 'package.json'));
+  const plugin = readJson(path.join(root, '.claude-plugin', 'plugin.json'));
+  const manifest = readJson(path.join(root, 'skill-package-manifest.json'));
+  if (pkg.version !== plugin.version || pkg.version !== manifest.version) errors.push('version_mismatch');
+  const skills = fs.readdirSync(path.join(root, 'skills'), { withFileTypes: true }).filter(item => item.isDirectory()).map(item => item.name).sort();
+  if (JSON.stringify(skills) !== JSON.stringify([...manifest.skills].sort())) errors.push('skill_manifest_mismatch');
+  for (const skill of manifest.skills) if (!fs.existsSync(path.join(root, 'skills', skill, 'SKILL.md'))) errors.push(`missing_skill:${skill}`);
+  const serverText = fs.readFileSync(path.join(root, 'mcp', 'src', 'server.mjs'), 'utf8');
+  for (const tool of manifest.mcpTools) if (!serverText.includes(`'${tool}'`)) errors.push(`missing_tool:${tool}`);
+  return { ok: errors.length === 0, errors, version: pkg.version, skills: manifest.skills.length, tools: manifest.mcpTools.length };
+}
+
+function run(command, args, cwd = packageRoot) {
+  const result = spawnSync(command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' });
+  if (result.status !== 0) process.exit(result.status || 1);
+}
+
+function help() {
+  console.log('Usage: node install.js [--check] [--smoke] [--skip-install]');
+}
+
+async function main() {
+  const args = new Set(process.argv.slice(2));
+  if (args.has('--help')) return help();
+  const before = checkPackage();
+  if (!before.ok && !before.errors.every(item => item === 'missing:package-lock.json')) {
+    console.error(JSON.stringify(before, null, 2));
+    process.exit(1);
+  }
+  if (!args.has('--skip-install') && !fs.existsSync(path.join(packageRoot, 'node_modules'))) run('npm', ['ci']);
+  const result = checkPackage();
+  console.log(JSON.stringify(result, null, 2));
+  if (!result.ok) process.exit(1);
+  if (args.has('--smoke')) {
+    run(process.execPath, ['scripts/smoke-package.mjs']);
+    run(process.execPath, ['scripts/smoke-mcp.mjs']);
+  }
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();

+ 170 - 0
fmode-image-set/mcp/catalog/domestic-platforms.json

@@ -0,0 +1,170 @@
+{
+  "schemaVersion": 1,
+  "verifiedAt": "2026-08-11",
+  "evidence": {
+    "TB-2024-01-26-MAIN-RATIO": {
+      "title": "商品接口调整通知",
+      "platform": "taobao_tmall",
+      "sourceType": "official_announcement",
+      "sourceUrl": "https://developer.alibaba.com/support/announcementDetail.htm?id=25721",
+      "publishedAt": "2024-01-26",
+      "verifiedAt": "2026-08-11",
+      "verificationStatus": "official_verified",
+      "scope": "商品发布与编辑主图比例;公告说明淘宝、天猫图片上传接口相同",
+      "facts": [
+        "1:1 主图仅可上传 1:1 图片",
+        "3:4 主图仅支持上传 3:4 图片",
+        "官方建议优先维护 1:1 主图"
+      ]
+    },
+    "JD-RULE-CENTER": {
+      "title": "京东规则中心",
+      "platform": "jingdong",
+      "sourceType": "official_rule_center",
+      "sourceUrl": "https://rule.jd.com/",
+      "verifiedAt": "2026-08-11",
+      "verificationStatus": "official_entry_only",
+      "scope": "官方规则入口;公开页面依赖 JavaScript,未取得可核验的通用商品图片具体数值"
+    },
+    "PDD-HELP-CENTER": {
+      "title": "拼多多商家帮助中心",
+      "platform": "pinduoduo",
+      "sourceType": "official_help_center",
+      "sourceUrl": "https://mms.pinduoduo.com/help-center",
+      "verifiedAt": "2026-08-11",
+      "verificationStatus": "official_entry_only",
+      "scope": "官方帮助入口;未取得可公开核验的通用商品图片具体数值"
+    }
+  },
+  "defaults": {
+    "target": {
+      "width": null,
+      "height": null,
+      "ratio": null,
+      "safeZone": "主体保留 8% 外边距",
+      "textPolicy": "仅使用用户确认或图片可见的简短中文事实,不生成价格、认证、功效、竞品标识或平台 UI",
+      "background": "按资产用途选择;白底资产使用纯白背景"
+    },
+    "enforcement": {
+      "ratio": "recommended",
+      "dimensions": "recommended",
+      "background": "recommended",
+      "textPolicy": "recommended"
+    },
+    "verificationStatus": "safe_fallback",
+    "warnings": [
+      "未取得该资产全部字段的官方可核验证据;当前为保守设计建议,不能声称平台官方合规。"
+    ]
+  },
+  "assetDefaults": {
+    "white_background": {
+      "target": {
+        "background": "纯白 #FFFFFF",
+        "alphaAllowed": false,
+        "width": 2048,
+        "height": 2048,
+        "ratio": "1:1"
+      },
+      "enforcement": {
+        "background": "recommended",
+        "alpha": "recommended"
+      }
+    },
+    "listing_primary": {
+      "target": {
+        "background": "简洁商品背景,禁止竞品标识、虚构价格和平台 UI",
+        "width": 2048,
+        "height": 2048,
+        "ratio": "1:1"
+      }
+    }
+  },
+  "platforms": {
+    "taobao": {
+      "label": "淘宝/天猫",
+      "aliases": [
+        "淘宝/天猫",
+        "淘宝天猫",
+        "淘宝",
+        "天猫",
+        "taobao",
+        "tmall",
+        "taobao.com",
+        "tmall.com"
+      ],
+      "ruleCenterUrl": "https://rule.taobao.com/",
+      "assets": {
+        "listing_primary": {
+          "uploadRole": "商品 1:1 主图",
+          "verificationStatus": "official_verified_partial",
+          "enforcement": {
+            "ratio": "required"
+          },
+          "warnings": [
+            "1:1 比例有官方公告证据;具体像素、文件大小、背景和类目附加要求仍需商家后台复核。"
+          ],
+          "evidenceIds": [
+            "TB-2024-01-26-MAIN-RATIO"
+          ]
+        },
+        "white_background": {
+          "uploadRole": "白底商品图候选",
+          "verificationStatus": "official_verified_partial",
+          "enforcement": {
+            "ratio": "required",
+            "background": "recommended"
+          },
+          "warnings": [
+            "1:1 比例有官方公告证据;白底图位置和具体像素未由当前证据证明。"
+          ],
+          "evidenceIds": [
+            "TB-2024-01-26-MAIN-RATIO"
+          ]
+        }
+      }
+    },
+    "jingdong": {
+      "label": "京东",
+      "aliases": [
+        "京东",
+        "jd",
+        "jd.com",
+        "jingdong",
+        "jing dong"
+      ],
+      "ruleCenterUrl": "https://rule.jd.com/",
+      "evidenceIds": [
+        "JD-RULE-CENTER"
+      ],
+      "assets": {
+        "listing_primary": {
+          "uploadRole": "商品主图候选"
+        },
+        "white_background": {
+          "uploadRole": "白底商品图候选"
+        }
+      }
+    },
+    "pinduoduo": {
+      "label": "拼多多",
+      "aliases": [
+        "拼多多",
+        "pdd",
+        "pinduoduo",
+        "pinduoduo.com"
+      ],
+      "ruleCenterUrl": "https://mms.pinduoduo.com/help-center",
+      "evidenceIds": [
+        "PDD-HELP-CENTER"
+      ],
+      "assets": {
+        "listing_primary": {
+          "uploadRole": "商品轮播主图候选"
+        },
+        "white_background": {
+          "uploadRole": "独立白底商品图候选"
+        }
+      }
+    }
+  }
+}

+ 14 - 0
fmode-image-set/mcp/catalog/platforms.json

@@ -0,0 +1,14 @@
+{
+  "general_ecommerce": { "label": "通用电商", "width": 2048, "height": 2048, "ratio": "1:1", "safe_zone": "主体保留 8% 外边距", "text_policy": "少量必要文字" },
+  "amazon": { "label": "Amazon", "width": 2048, "height": 2048, "ratio": "1:1", "safe_zone": "主图主体居中并占画面约 85%", "text_policy": "主图无文字、无道具、无水印" },
+  "shopify": { "label": "Shopify", "width": 2048, "height": 2048, "ratio": "1:1", "safe_zone": "主体保留 8% 外边距", "text_policy": "按主题需要" },
+  "instagram_feed": { "label": "Instagram Feed", "width": 2048, "height": 2560, "ratio": "4:5", "safe_zone": "关键信息距边缘至少 8%", "text_policy": "短标题;正文放 caption" },
+  "instagram_story": { "label": "Instagram Story", "width": 1440, "height": 2560, "ratio": "9:16", "safe_zone": "顶部和底部各留 14%", "text_policy": "短标题和单一 CTA" },
+  "tiktok": { "label": "TikTok", "width": 1440, "height": 2560, "ratio": "9:16", "safe_zone": "右侧留 15%,顶部/底部留 12%", "text_policy": "短标题,不生成平台 UI" },
+  "facebook_ad": { "label": "Facebook Ad", "width": 2400, "height": 1256, "ratio": "1.91:1", "safe_zone": "核心主体与 CTA 居中 80%", "text_policy": "低文字密度" },
+  "google_display": { "label": "Google Display", "width": 2400, "height": 1256, "ratio": "1.91:1", "safe_zone": "核心主体居中 80%", "text_policy": "不在生成图中渲染文字" },
+  "linkedin_ad": { "label": "LinkedIn Ad", "width": 2400, "height": 1256, "ratio": "1.91:1", "safe_zone": "核心主体与 CTA 居中 80%", "text_policy": "专业、低文字密度" },
+  "pinterest": { "label": "Pinterest", "width": 2000, "height": 3000, "ratio": "2:3", "safe_zone": "标题与主体距边缘至少 8%", "text_policy": "可读短标题" },
+  "rednote": { "label": "小红书", "width": 2048, "height": 2560, "ratio": "4:5", "safe_zone": "关键信息距边缘至少 8%", "text_policy": "自然种草风;不生成平台 UI" },
+  "x": { "label": "X", "width": 2400, "height": 1350, "ratio": "16:9", "safe_zone": "关键信息居中 80%", "text_policy": "短标题" }
+}

+ 80 - 0
fmode-image-set/mcp/catalog/templates/en/01-hero-image.json

@@ -0,0 +1,80 @@
+{
+  "locale": "en",
+  "id": "hero-image",
+  "name": "白底/纯色底产品主图",
+  "keywords": [
+    "白底图",
+    "主图",
+    "hero image",
+    "白背景",
+    "product shot",
+    "packshot",
+    "产品照",
+    "商品主图"
+  ],
+  "trigger_phrases": [
+    "产品主图",
+    "白底图",
+    "白背景产品",
+    "hero image",
+    "电商主图",
+    "纯色背景"
+  ],
+  "prompt_template": {
+    "type": "product photography",
+    "subject": "{product_description}",
+    "background": "clean white background",
+    "lighting": "soft diffused studio lighting, even illumination",
+    "composition": "centered, front view",
+    "quality": "8K, commercial e-commerce photography"
+  },
+  "defaults": {
+    "background": "clean white background",
+    "lighting": "soft diffused studio lighting",
+    "composition": "centered, front view"
+  },
+  "variants": {
+    "luxury": {
+      "description": "高端奢侈品风格",
+      "overrides": {
+        "lighting": "Rembrandt lighting, subtle rim light",
+        "background": "gradient from dark to light, premium feel"
+      }
+    },
+    "fresh": {
+      "description": "清新自然风格",
+      "overrides": {
+        "lighting": "bright natural light",
+        "background": "light pastel tones"
+      }
+    },
+    "tech": {
+      "description": "科技感风格",
+      "overrides": {
+        "lighting": "dramatic side lighting",
+        "background": "dark minimalist"
+      }
+    },
+    "color": {
+      "description": "彩色背景风格",
+      "overrides": {
+        "background": "{color} gradient background"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "emphasize texture and glow, show formula details",
+    "electronics": "highlight metallic finish, screen details, port precision",
+    "food": "vibrant colors, fresh appearance, show texture",
+    "fashion": "show fabric texture, drape quality, stitching details",
+    "home": "show material quality, craftsmanship, lifestyle appeal",
+    "jewelry": "macro detail, sparkle and cut quality, luxurious lighting"
+  },
+  "examples": [
+    "{product}, professional product photography on clean white background, soft diffused studio lighting, centered, 8K, commercial e-commerce photography, no shadows, no props",
+    "{product} on pure white seamless background, bright commercial studio lighting, centered, 3/4 profile, high resolution, marketplace ready, {material_description}",
+    "{product} floating slightly above white surface, softbox overhead, sharp rim light on edges, professional packshot, 8K, ultra-detailed textures"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 85 - 0
fmode-image-set/mcp/catalog/templates/en/02-lifestyle-scene.json

@@ -0,0 +1,85 @@
+{
+  "locale": "en",
+  "id": "lifestyle-scene",
+  "name": "场景化生活图",
+  "keywords": [
+    "场景图",
+    "生活图",
+    "lifestyle",
+    "使用场景",
+    "生活场景",
+    "场景化",
+    "lifestyle photography"
+  ],
+  "trigger_phrases": [
+    "场景图",
+    "生活图",
+    "使用场景图",
+    "lifestyle photo",
+    "产品场景",
+    "生活化展示"
+  ],
+  "prompt_template": {
+    "type": "lifestyle product photography",
+    "subject": "{product_description}",
+    "setting": "{scene_description}",
+    "lighting": "natural {time_of_day} light",
+    "composition": "{composition_style}",
+    "mood": "{mood_description}",
+    "quality": "8K, editorial lifestyle photography"
+  },
+  "defaults": {
+    "setting": "modern living space, clean and organized",
+    "lighting": "natural morning light through window",
+    "composition": "rule of thirds, product as focal point",
+    "mood": "warm and inviting"
+  },
+  "variants": {
+    "morning": {
+      "description": "早晨清新氛围",
+      "overrides": {
+        "setting": "bright room with sunlight streaming through window",
+        "lighting": "soft morning golden light, visible dust particles",
+        "mood": "fresh, clean, new beginning"
+      }
+    },
+    "cozy": {
+      "description": "温馨舒适氛围",
+      "overrides": {
+        "setting": "warm cozy interior, soft textiles, candles",
+        "lighting": "warm ambient lighting, soft golden tones",
+        "mood": "comfortable, intimate"
+      }
+    },
+    "outdoor": {
+      "description": "户外自然场景",
+      "overrides": {
+        "setting": "outdoor natural setting, organic elements",
+        "lighting": "golden hour natural sunlight",
+        "mood": "natural, free, adventurous"
+      }
+    },
+    "luxury": {
+      "description": "奢华高端场景",
+      "overrides": {
+        "setting": "luxury spa/hotel, marble and gold fixtures",
+        "lighting": "cinematic lighting, warm color grading",
+        "mood": "premium, sophisticated"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "bathroom vanity with botanical elements, skincare ritual feel",
+    "electronics": "modern desk setup, minimal aesthetic, tech-forward",
+    "food": "kitchen counter or dining table, fresh ingredients, warm tones",
+    "fashion": "urban street or boutique fitting room, model wearing the item",
+    "home": "styled room interior, product naturally placed"
+  },
+  "examples": [
+    "{product} naturally placed in {scene}, morning sunlight through window, botanical touches, warm atmosphere, professional lifestyle photography, 8K",
+    "Cinematic luxury interior with marble walls and gold fixtures. {product} prominently displayed. Morning sunlight through frosted glass. Fresh flowers. Cinematic depth of field, warm color grading, 8K",
+    "{product} in cozy setting, warm ambient lighting, soft textiles, golden hour tones, lifestyle photography, authentic atmosphere"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 72 - 0
fmode-image-set/mcp/catalog/templates/en/03-flat-lay.json

@@ -0,0 +1,72 @@
+{
+  "locale": "en",
+  "id": "flat-lay",
+  "name": "平铺图",
+  "keywords": [
+    "平铺图",
+    "flat lay",
+    "俯拍",
+    "top-down",
+    "flatlay",
+    "俯视图"
+  ],
+  "trigger_phrases": [
+    "平铺图",
+    "flat lay",
+    "俯拍",
+    "top-down view",
+    "产品平铺"
+  ],
+  "prompt_template": {
+    "type": "flat lay photography, top-down view",
+    "subject": "{product_description} as hero at bottom center",
+    "props": "{prop_list}",
+    "background": "{background_material}",
+    "color_palette": "{color_scheme}",
+    "lighting": "soft natural window light from top-left at 45 degrees",
+    "quality": "8K, editorial photography, no text, no watermark"
+  },
+  "defaults": {
+    "props": "carefully curated complementary objects",
+    "background": "ivory linen texture",
+    "color_palette": "ivory, blush, champagne gold, sage green"
+  },
+  "variants": {
+    "luxury": {
+      "description": "奢华仪式感",
+      "overrides": {
+        "props": "gold foil flakes, pearl beads, dried flowers, silk ribbon, leather journal",
+        "background": "ivory linen with subtle shadow from sheer curtains",
+        "color_palette": "ivory, blush pink, champagne gold, soft cream"
+      }
+    },
+    "minimal": {
+      "description": "极简风格",
+      "overrides": {
+        "props": "only 2-3 essential items, clean lines",
+        "background": "clean white surface",
+        "color_palette": "white, gray, one accent color"
+      }
+    },
+    "seasonal": {
+      "description": "季节主题",
+      "overrides": {
+        "props": "seasonal elements matching theme",
+        "color_palette": "season-appropriate color scheme"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "skincare ritual flat lay, open product showing formula, botanical elements",
+    "food": "ingredients spread, fresh produce, kitchen tools",
+    "fashion": "clothing + accessories + shoes arranged aesthetically, fabric textures",
+    "home": "decor items, textures, materials spread showing lifestyle"
+  },
+  "examples": [
+    "Luxurious {category} ritual flat lay, top-down photography. {product} with lid open showing texture as hero at bottom center. Surrounding: gold-tone palette, crystal bottle, dried lavender with silk ribbon, gold foil flakes, pearl beads. Background: ivory linen. Color: ivory, blush, champagne gold. Soft window light top-left at 45 degrees. 8K, no text, no watermark",
+    "{product} flat lay top-down as hero. Surrounding: {props}. {background} background. Color: {colors}. Soft window light top-left. Clean aesthetic, 8K",
+    "Professional {category} flat lay, top-down editorial. {product} centered with curated objects. {background} background. Strict color control. 8K, magazine quality, no text"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 71 - 0
fmode-image-set/mcp/catalog/templates/en/04-detail-macro.json

@@ -0,0 +1,71 @@
+{
+  "locale": "en",
+  "id": "detail-macro",
+  "name": "细节微距图",
+  "keywords": [
+    "细节图",
+    "微距",
+    "macro",
+    "特写",
+    "close-up",
+    "detail shot",
+    "细节展示"
+  ],
+  "trigger_phrases": [
+    "细节图",
+    "微距图",
+    "产品特写",
+    "材质特写",
+    "detail shot",
+    "macro photography"
+  ],
+  "prompt_template": {
+    "type": "macro product photography",
+    "subject": "{product_description}, extreme close-up on {focus_area}",
+    "detail": "visible {texture_description}",
+    "lighting": "{lighting_style}",
+    "camera": "shot with {camera_setup}",
+    "quality": "ultra macro detail, 8K, professional product photography"
+  },
+  "defaults": {
+    "focus_area": "texture and material quality",
+    "detail": "fine texture, material grain, surface quality",
+    "lighting": "soft directional lighting highlighting texture",
+    "camera": "dedicated macro lens setup"
+  },
+  "variants": {
+    "texture": {
+      "description": "材质纹理展示",
+      "overrides": {
+        "focus_area": "material surface showing grain and quality"
+      }
+    },
+    "formula": {
+      "description": "产品配方展示",
+      "overrides": {
+        "focus_area": "product formula showing cream/liquid texture",
+        "detail": "formula consistency, light reflection"
+      }
+    },
+    "craftsmanship": {
+      "description": "工艺细节展示",
+      "overrides": {
+        "focus_area": "stitching, joints, edges, manufacturing precision"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "show cream formula texture, shimmer particles, light reflection",
+    "electronics": "show port precision, material finish, button detail",
+    "food": "show ingredient texture, cross-section, freshness",
+    "fashion": "show fabric weave, stitching quality, hardware detail",
+    "jewelry": "show gemstone cut, metal finish, clasp detail"
+  },
+  "examples": [
+    "Extreme close-up beauty macro, Canon EOS R5 100mm f/2.8 macro. {product} filling 80% of frame, focusing on {focus_area}. Visible pores, fine lines, natural imperfections, NOT retouched. Formula visible with realistic light reflection. Natural side lighting. 8K",
+    "Cinematic {category} close-up. {product} detail showing {texture_description}. Formula/texture visible between fingertips with subtle light reflection. Soft blur background. Dramatic side lighting, warm tones, 8K",
+    "Macro detail of {product}, Sony A7R V 90mm macro, focus stacking. Extreme detail showing {focus_area}. Professional product photography, 8K"
+  ],
+  "anti_ai_tips": "For hand shots: specify real skin (visible knuckle lines, slight dryness on cuticles, natural warm tone). NOT retouched or smoothed.",
+  "supports_image_reference": true
+}

+ 85 - 0
fmode-image-set/mcp/catalog/templates/en/05-poster-banner.json

@@ -0,0 +1,85 @@
+{
+  "locale": "en",
+  "id": "poster-banner",
+  "name": "促销海报/Banner",
+  "keywords": [
+    "海报",
+    "poster",
+    "banner",
+    "促销",
+    "广告图",
+    "活动图",
+    "promotion",
+    "sale"
+  ],
+  "trigger_phrases": [
+    "促销海报",
+    "banner",
+    "广告图",
+    "活动海报",
+    "促销图",
+    "campaign poster",
+    "sale banner"
+  ],
+  "prompt_template": {
+    "type": "promotional poster design",
+    "subject": "{product_description}",
+    "background": "{background_description}",
+    "headline": "{headline_text}",
+    "subtitle": "{subtitle_text}",
+    "price": "{price_info}",
+    "cta": "{cta_text}",
+    "style": "{visual_style}",
+    "quality": "high-resolution professional marketing design"
+  },
+  "defaults": {
+    "background": "gradient matching brand aesthetic",
+    "headline": "headline text",
+    "subtitle": "subtitle or description",
+    "price": "",
+    "cta": "Shop Now",
+    "style": "clean minimalist with accent colors"
+  },
+  "variants": {
+    "luxury": {
+      "description": "高端奢华风格",
+      "overrides": {
+        "background": "full-bleed rich gradient, gold accents",
+        "style": "luxury magazine editorial, gold foil, pearl embellishments"
+      }
+    },
+    "minimal": {
+      "description": "极简现代风格",
+      "overrides": {
+        "background": "clean white or light gradient",
+        "style": "minimalist modern typography, generous white space"
+      }
+    },
+    "festive": {
+      "description": "节日主题风格",
+      "overrides": {
+        "background": "festive themed gradient with decorative elements",
+        "style": "festive, cultural elements, seasonal motifs"
+      }
+    },
+    "flash-sale": {
+      "description": "限时折扣风格",
+      "overrides": {
+        "style": "bold attention-grabbing, high contrast, urgency elements"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "rose gold accents, elegant serif fonts, luxury gift aesthetic",
+    "electronics": "dark backgrounds, neon accents, futuristic typography",
+    "food": "warm colors, dynamic food elements, freshness cues",
+    "fashion": "editorial style, model inclusion, aspirational lifestyle"
+  },
+  "examples": [
+    "Luxury {category} campaign poster. Full-bleed gradient ivory to rose. Top: {headline} elegant serif. Center: {product} with decorative elements. {subtitle}. Bottom left: price {price} original crossed out. Bottom right: brand logo and {cta}. Gold foil accents, premium aesthetic, 2000x3000px",
+    "{category} promotional poster. {product} centered on {background}. Bold {headline} at top, price {price}, {cta} button. Clean minimalist, accent colors, professional layout, 2000x2000px",
+    "Holiday {category} gift campaign. {background} with decorative elements. Center: {product} with gift wrapping. Headline {headline} modern script. Price tag {price} decorative frame. Festive sophisticated, warm cinematic lighting, 1080x1350px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 77 - 0
fmode-image-set/mcp/catalog/templates/en/06-social-media.json

@@ -0,0 +1,77 @@
+{
+  "locale": "en",
+  "id": "social-media",
+  "name": "社交媒体素材",
+  "keywords": [
+    "社交媒体",
+    "小红书",
+    "instagram",
+    "tiktok",
+    "种草",
+    "社媒",
+    "social media"
+  ],
+  "trigger_phrases": [
+    "社交媒体图",
+    "小红书图",
+    "Instagram",
+    "TikTok",
+    "种草图",
+    "社媒素材",
+    "social media post"
+  ],
+  "prompt_template": {
+    "type": "social media content",
+    "subject": "{product_description}",
+    "platform": "{platform_style}",
+    "composition": "phone camera perspective, slightly off-center",
+    "lighting": "natural indoor lighting",
+    "overlay": "{overlay_elements}",
+    "quality": "authentic smartphone photography aesthetic"
+  },
+  "defaults": {
+    "platform": "Instagram post style",
+    "composition": "casual phone framing, slightly imperfect",
+    "lighting": "natural window light, warm color tone",
+    "overlay": "minimal stickers and text badges"
+  },
+  "variants": {
+    "xiaohongshu": {
+      "description": "小红书种草风格",
+      "overrides": {
+        "platform": "Xiaohongshu RED app lifestyle photo, iPhone shot",
+        "composition": "slightly tilted like quick grab shot",
+        "lighting": "iPhone warm auto-white-balance, Kodak Portra 400 color feel",
+        "overlay": "Xiaohongshu sticker, casual text, star rating"
+      }
+    },
+    "instagram": {
+      "description": "Instagram帖子风格",
+      "overrides": {
+        "platform": "Instagram post premium aesthetic",
+        "composition": "clean framing with visual hierarchy",
+        "overlay": "engagement badges, hashtag elements"
+      }
+    },
+    "tiktok": {
+      "description": "TikTok/Reels封面风格",
+      "overrides": {
+        "platform": "TikTok/Reels thumbnail, vertical format",
+        "overlay": "trending audio icon, duet prompt, engagement metrics"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "show product in use on skin, texture close-up, real results",
+    "food": "overhead dish/drink shot, appetizing colors, 45-degree angle",
+    "fashion": "mirror selfie or outfit grid, casual styling, real-person aesthetic",
+    "home": "corner shot in styled room, cozy atmosphere, warm tones"
+  },
+  "examples": [
+    "Ultra-realistic Xiaohongshu RED product lifestyle photo, iPhone 15 Pro, NOT professional photographer. Slightly tilted angle, {product} on {surface}, lid off showing texture. Environmental details: slight water stain, natural shadows, lived-in feel. iPhone warm auto-white-balance, natural noise, NOT sharpened, Kodak Portra 400 feel, NOT AI-generated look, 8K, 1080x1350px",
+    "Instagram post format. {product} with overlay text, clean aesthetic. Feature cards showing benefits. Engagement icons. Hashtag tags. Square format, lifestyle aesthetic, 1080x1080px",
+    "Premium social media content, TikTok thumbnail. {product} in lifestyle setting. Timestamp badge, engagement metrics, product card. Trending audio icon. Color gradient frame. Viral-worthy composition, 1080x1920px"
+  ],
+  "anti_ai_tips": "Specify phone model. Add imperfection: noise, warm cast, not centered, slight blur. Use 'NOT AI-generated look'. Reference Kodak Portra 400 tone. Show lived-in environment.",
+  "supports_image_reference": true
+}

+ 84 - 0
fmode-image-set/mcp/catalog/templates/en/07-ugc-style.json

@@ -0,0 +1,84 @@
+{
+  "locale": "en",
+  "id": "ugc-style",
+  "name": "UGC风格/买家秀",
+  "keywords": [
+    "UGC",
+    "买家秀",
+    "用户生成",
+    "真实用户",
+    "user generated",
+    "GRWM",
+    "真实感"
+  ],
+  "trigger_phrases": [
+    "UGC图",
+    "买家秀",
+    "用户图",
+    "真实买家",
+    "GRWM",
+    "user-generated",
+    "真实分享"
+  ],
+  "prompt_template": {
+    "type": "authentic UGC snapshot",
+    "subject": "{product_description}",
+    "style": "smartphone front camera selfie, casual snapshot, NOT professional photography",
+    "environment": "real lived-in space, slightly messy, NOT styled",
+    "lighting": "indoor lighting with warm yellow cast, uneven",
+    "imperfection": "visible noise/grain, imperfect framing, off-center, slightly tilted",
+    "quality": "raw phone photo with natural imperfections, NOT AI-generated look, NOT studio quality"
+  },
+  "defaults": {
+    "style": "iPhone front camera, candid snapshot",
+    "environment": "real bathroom or bedroom, lived-in details",
+    "lighting": "warm indoor overhead lighting, slight yellow cast",
+    "imperfection": "noise in shadows, not oversharpened, slight highlight overexposure"
+  },
+  "variants": {
+    "mirror-selfie": {
+      "description": "浴室镜自拍",
+      "overrides": {
+        "style": "iPhone front camera mirror selfie",
+        "environment": "bathroom mirror, slight condensation and fog marks",
+        "imperfection": "phone case at edge, finger smudge on mirror"
+      }
+    },
+    "ccd-retro": {
+      "description": "CCD复古胶片感",
+      "overrides": {
+        "style": "vintage 2005 CCD digicam photo, direct flash, cheap camera aesthetic",
+        "lighting": "harsh direct on-camera flash, strong warm yellow-green color shift, blown-out highlights",
+        "imperfection": "heavy film grain, overexposed skin on forehead and nose, flash hotspot, chromatic aberration at edges, red date stamp bottom-right corner"
+      }
+    },
+    "grwm": {
+      "description": "GRWM跟我一起准备",
+      "overrides": {
+        "style": "Get Ready With Me video thumbnail, morning routine",
+        "environment": "vanity mirror, slightly messy counter"
+      }
+    },
+    "unboxing": {
+      "description": "开箱分享",
+      "overrides": {
+        "style": "casual unboxing photo on bed or desk",
+        "environment": "bed with wrinkled sheets or cluttered desk"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "product in use on real skin, visible pores, bathroom setting",
+    "food": "phone snap on table, casual restaurant or home",
+    "fashion": "mirror selfie wearing item, bedroom or fitting room",
+    "electronics": "desk setup with product in use, realistic cables",
+    "home": "product in actual living space, not perfectly styled"
+  },
+  "examples": [
+    "Bathroom mirror selfie, iPhone front camera. Person with {product}. Mirror has slight condensation. Warm yellowish indoor lighting. {product} label recognizable but not centered. Skin: visible pores, slight redness, NOT flawless. Warm yellow cast, phone noise, natural unposed, NOT AI-generated look, authentic UGC, 1080x1350px",
+    "Vintage 2005 CCD digicam photo, harsh direct flash. Heavy film grain, blown-out highlights on face, strong yellow-green color cast, chromatic aberration at edges. Person holding {product} with casual peace sign. Genuine candid expression. Dim bedroom with string lights, unmade bed. Off-center, slightly tilted composition. Red date stamp '06.12.25' in bottom-right corner. Low resolution, NOT sharp, NOT AI-generated look, raw snapshot, 1080x1350px",
+    "Authentic GRWM photo. Person in casual setting showing {product}. Natural imperfect lighting, slightly messy background. Phone selfie angle. Genuine expression. Warm tones, candid smartphone photography, NOT AI-generated look, 1080x1620px"
+  ],
+  "anti_ai_tips": "CRITICAL: (1) Specify phone model (iPhone 14 Pro/15), (2) Add imperfection (pores, noise, warm cast, off-center), (3) Candid language (NOT professional), (4) Real environment (slightly messy), (5) Avoid AI words (no perfect, flawless, stunning), (6) State 'NOT AI-generated look', (7) Reference Kodak Portra 400 tone",
+  "supports_image_reference": true
+}

+ 74 - 0
fmode-image-set/mcp/catalog/templates/en/08-model-showcase.json

@@ -0,0 +1,74 @@
+{
+  "locale": "en",
+  "id": "model-showcase",
+  "name": "模特展示图",
+  "keywords": [
+    "模特",
+    "model",
+    "人物展示",
+    "模特图",
+    "真人展示",
+    "人物图"
+  ],
+  "trigger_phrases": [
+    "模特图",
+    "真人展示",
+    "人物图",
+    "model photo",
+    "模特展示",
+    "人物展示图"
+  ],
+  "prompt_template": {
+    "type": "editorial beauty/fashion photography",
+    "subject": "{person_description} with {product_description}",
+    "camera": "shot with {camera_setup}",
+    "lighting": "natural window light creating realistic highlights",
+    "skin_detail": "visible pores, natural skin texture, NOT retouched",
+    "expression": "{expression_description}",
+    "quality": "professional editorial photography, 8K"
+  },
+  "defaults": {
+    "camera": "Canon EOS R5, 85mm f/1.4 lens",
+    "lighting": "natural window light from left, realistic highlights",
+    "skin_detail": "visible pores, natural under-eye shadows, slight texture unevenness",
+    "expression": "authentic relaxed, subtle natural asymmetry"
+  },
+  "variants": {
+    "beauty-closeup": {
+      "description": "美妆极致特写",
+      "overrides": {
+        "camera": "Canon EOS R5, 100mm f/2.8 macro",
+        "skin_detail": "incredibly detailed: pores, fine lines, freckles, NOT retouched",
+        "expression": "eyes half-closed, peaceful self-care moment"
+      }
+    },
+    "fashion-full": {
+      "description": "时尚全身展示",
+      "overrides": {
+        "camera": "Canon EOS R5, 50mm f/1.2",
+        "expression": "confident direct gaze, editorial pose"
+      }
+    },
+    "candid": {
+      "description": "自然抓拍",
+      "overrides": {
+        "camera": "iPhone 15 Pro main camera",
+        "expression": "genuine candid, mid-action",
+        "skin_detail": "natural texture, slight blemishes, relatable"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "extreme close-up on application moment, formula texture on skin, real skin mandatory",
+    "fashion": "full outfit showcase, pose highlighting garment, editorial lighting",
+    "accessories": "product being worn/used, hand or body detail, lifestyle context",
+    "sports": "active pose in appropriate setting, show performance"
+  },
+  "examples": [
+    "Extreme close-up beauty macro, Canon EOS R5 100mm f/2.8 macro. Face filling 80% of frame, {product} being applied on {focus_area}. Skin incredibly detailed: pores, fine lines, natural imperfections, NOT retouched. Formula visible with realistic reflection. Natural side lighting. NOT AI-generated look, 8K, 1080x1350px",
+    "Fashion editorial. {person} with {product}, Canon EOS R5 85mm f/1.4. Natural window light, visible pores, natural shadows, authentic expression, soft bokeh, warm golden tones, 1080x1350px",
+    "Candid beauty moment. {person} with {product}, iPhone front camera. Natural lighting, visible pores and texture, relaxed candid, NOT AI-generated look, documentary style, 1080x1350px"
+  ],
+  "anti_ai_tips": "MANDATORY: (1) Real camera (Canon EOS R5 / Sony A7 IV), (2) Visible skin imperfections (pores, uneven tone, blemishes, fine lines), (3) Natural expression asymmetry, (4) 'NOT retouched, NOT AI-generated look', (5) Real lighting (natural window, NOT studio-perfect), (6) Natural hand details (knuckle lines, cuticle texture)",
+  "supports_image_reference": true
+}

+ 71 - 0
fmode-image-set/mcp/catalog/templates/en/09-before-after.json

@@ -0,0 +1,71 @@
+{
+  "locale": "en",
+  "id": "before-after",
+  "name": "使用前后对比图",
+  "keywords": [
+    "对比",
+    "before after",
+    "前后",
+    "效果对比",
+    "transformation",
+    "变化图"
+  ],
+  "trigger_phrases": [
+    "对比图",
+    "前后对比",
+    "before after",
+    "效果展示",
+    "使用效果",
+    "transformation"
+  ],
+  "prompt_template": {
+    "type": "before/after comparison visualization",
+    "subject": "{product_description}",
+    "before_state": "{before_description}",
+    "after_state": "{after_description}",
+    "metrics": "{data_points}",
+    "divider": "elegant center divider",
+    "quality": "professional campaign photography"
+  },
+  "defaults": {
+    "before_state": "dull, dry, uneven appearance",
+    "after_state": "radiant, smooth, healthy appearance",
+    "metrics": "improvement percentage",
+    "divider": "elegant gold line with arrow"
+  },
+  "variants": {
+    "clinical": {
+      "description": "临床数据风格",
+      "overrides": {
+        "metrics": "detailed data grid with 4+ metrics and percentage arrows"
+      }
+    },
+    "cinematic": {
+      "description": "电影感蜕变",
+      "overrides": {
+        "before_state": "moody dramatic, cool tone",
+        "after_state": "bright warm golden hour, warm tone",
+        "divider": "decorative vintage frame with product"
+      }
+    },
+    "simple": {
+      "description": "简洁信息图",
+      "overrides": {
+        "metrics": "simple progress bar with key metric"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "show skin texture change, include moisture/radiance percentage data",
+    "fitness": "body transformation, consistent pose and lighting",
+    "home": "room before/after renovation or cleaning",
+    "automotive": "vehicle before/after detailing"
+  },
+  "examples": [
+    "Cinematic before/after {category} transformation. Left: moody lighting showing {before}, cool tone. Right: bright golden hour showing {after}, warm tone. Center: decorative frame with product. Bottom: comparison data grid. Premium campaign layout, 8K, 1080x1620px",
+    "Premium before/after comparison. Left: {before_state}, caption 'Before'. Right: {after_state}, caption 'After'. Gold divider with arrow. Bottom: progress metrics {data}. Product thumbnail. Clean clinical aesthetic, 1080x1080px",
+    "{category} comparison visualization. Split: {before_state} left, {after_state} right. Key metrics below. White background, professional style, 1080x1080px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 70 - 0
fmode-image-set/mcp/catalog/templates/en/10-packaging.json

@@ -0,0 +1,70 @@
+{
+  "locale": "en",
+  "id": "packaging",
+  "name": "包装设计展示",
+  "keywords": [
+    "包装",
+    "packaging",
+    "礼盒",
+    "gift box",
+    "包装设计",
+    "unboxing",
+    "开箱"
+  ],
+  "trigger_phrases": [
+    "包装设计",
+    "包装展示",
+    "礼盒图",
+    "packaging design",
+    "开箱图",
+    "gift box"
+  ],
+  "prompt_template": {
+    "type": "packaging design visualization",
+    "subject": "{product_description}",
+    "display": "{packaging_components}",
+    "surface": "premium marble surface",
+    "decorative": "scattered luxury elements",
+    "lighting": "soft directional with metallic reflections",
+    "quality": "premium design presentation, 8K"
+  },
+  "defaults": {
+    "display": "full spread: product, outer box, tissue, ribbon, brand card",
+    "decorative": "gold foil, pearls, dried petals, silk fabric"
+  },
+  "variants": {
+    "luxury-gift": {
+      "description": "奢华礼盒",
+      "overrides": {
+        "display": "gift box opened: outer box, inner tray, product, ribbon, brand card",
+        "decorative": "gold foil, pearls, dried roses, silk scarf"
+      }
+    },
+    "minimal-eco": {
+      "description": "极简环保",
+      "overrides": {
+        "display": "sustainable packaging: recycled paper, soy ink, simple label",
+        "decorative": "natural elements, dried leaves, twine"
+      }
+    },
+    "unboxing": {
+      "description": "开箱体验",
+      "overrides": {
+        "display": "unboxing sequence: box opening, tissue reveal, product discovery"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "label design, ingredient preview, formula through transparent elements",
+    "food": "nutritional info area, ingredient imagery, freshness seals",
+    "fashion": "branded tissue, garment tag, care instructions, shopping bag",
+    "electronics": "box design, inner foam, cable organization, quick-start guide"
+  },
+  "examples": [
+    "Luxury {category} packaging concept. Full spread: (1) product with lid, label visible, (2) outer gift box: embossed pattern, gold logo, magnetic closure, (3) tissue with subtle watermark, (4) satin ribbon with monogram, (5) brand card gold foil, (6) sustainable fill. On marble with gold foil, pearls, dried petals. Soft directional lighting, metallic reflections. Premium presentation, 8K, 1080x1620px",
+    "Premium packaging showcase. {product} in gift box open, tissue, ribbon, brand card, luxury unboxing. Professional photography, 1080x1080px",
+    "{category} packaging concept on white. Multiple angles, clean presentation, design mockup, 1080x1080px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 71 - 0
fmode-image-set/mcp/catalog/templates/en/11-infographic.json

@@ -0,0 +1,71 @@
+{
+  "locale": "en",
+  "id": "infographic",
+  "name": "信息图/A+Content",
+  "keywords": [
+    "信息图",
+    "infographic",
+    "A+",
+    "详情页",
+    "卖点图",
+    "产品信息图",
+    "产品详情"
+  ],
+  "trigger_phrases": [
+    "信息图",
+    "产品信息图",
+    "详情页图",
+    "A+Content",
+    "卖点图",
+    "infographic"
+  ],
+  "prompt_template": {
+    "type": "e-commerce product infographic",
+    "subject": "{product_description}",
+    "features": "{feature_list}",
+    "layout": "structured sections with clear hierarchy",
+    "data_vis": "{data_elements}",
+    "color_scheme": "{brand_colors}",
+    "quality": "professional e-commerce design, mobile-friendly"
+  },
+  "defaults": {
+    "features": "4-6 key features with icons and descriptions",
+    "layout": "clean grid, product as focal point",
+    "data_vis": "simple charts or comparison tables",
+    "color_scheme": "brand-consistent palette"
+  },
+  "variants": {
+    "amazon-a-plus": {
+      "description": "亚马逊A+风格",
+      "overrides": {
+        "layout": "4-quadrant module with banner, feature blocks, comparison, badges",
+        "data_vis": "comparison table, ingredient chart, certification badges"
+      }
+    },
+    "feature-grid": {
+      "description": "卖点网格",
+      "overrides": {
+        "layout": "product centered with callout lines to feature blocks"
+      }
+    },
+    "story-flow": {
+      "description": "故事线信息流",
+      "overrides": {
+        "layout": "vertical story: problem → solution → features → results"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "ingredient breakdown with %, before/after data, dermatologist badges",
+    "electronics": "spec comparison, performance metrics, compatibility icons",
+    "food": "nutritional visualization, ingredient sourcing, recipe flow",
+    "fashion": "size guide, material composition, care instructions"
+  },
+  "examples": [
+    "Complex Amazon A+ Content module. Top banner with brand and tagline. Main: 4 quadrants each showing product detail with labeled feature: (1) {f1}, (2) {f2}, (3) {f3}, (4) {f4}. Below: comparison table. Bottom: spec chart and certification badges. {color} palette. Mobile-first, professional e-commerce, 2000x2500px",
+    "Product infographic. Left: {product} on gradient. Right: {n} feature blocks with icons vertically, callout lines connecting to product. Clean modern, {color} accents, 2000x2000px",
+    "{category} specification guide. Product centered with dimension callouts. Feature highlights with icons. Data visualization. Clean typography, mobile-friendly, 2000x2000px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 73 - 0
fmode-image-set/mcp/catalog/templates/en/12-creative-concept.json

@@ -0,0 +1,73 @@
+{
+  "locale": "en",
+  "id": "creative-concept",
+  "name": "创意概念广告图",
+  "keywords": [
+    "创意图",
+    "概念图",
+    "creative",
+    "概念广告",
+    "品牌广告",
+    "创意广告",
+    "concept art"
+  ],
+  "trigger_phrases": [
+    "创意广告",
+    "概念图",
+    "品牌广告",
+    "创意素材",
+    "品牌宣传",
+    "creative concept"
+  ],
+  "prompt_template": {
+    "type": "creative advertising photography",
+    "subject": "{product_description}",
+    "concept": "{creative_concept}",
+    "effects": "{special_effects}",
+    "color_palette": "{bold_palette}",
+    "art_direction": "{art_style}",
+    "quality": "award-winning advertising photography, ultra-detailed, cinematic"
+  },
+  "defaults": {
+    "concept": "dynamic product with unexpected visual elements",
+    "effects": "dramatic visual impact",
+    "color_palette": "bold unusual combinations",
+    "art_direction": "strong visual narrative"
+  },
+  "variants": {
+    "splash-dynamic": {
+      "description": "飞溅动态效果",
+      "overrides": {
+        "effects": "water splash / powder explosion frozen in motion, high-speed photography",
+        "art_direction": "dramatic, vivid, commercial style"
+      }
+    },
+    "surreal": {
+      "description": "超现实概念",
+      "overrides": {
+        "concept": "product in unexpected surreal environment",
+        "effects": "gravity-defying, impossible geometry"
+      }
+    },
+    "minimal-art": {
+      "description": "极简艺术风格",
+      "overrides": {
+        "concept": "product as art object in minimalist composition",
+        "color_palette": "monochromatic or two-tone"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "floating product with splash, ethereal lighting, formula particles",
+    "electronics": "holographic interfaces, data visualization, futuristic",
+    "food": "ingredient explosion, dynamic pour/splash, steam and fire",
+    "fashion": "editorial art direction, dramatic poses, fabric in motion"
+  },
+  "examples": [
+    "{product} floating in {dramatic_environment}, {effects: splash/particles/smoke}, {bold colors}, art direction: {style}, ultra-detailed, cinematic lighting, award-winning advertising, 8K",
+    "{product} with dynamic elements frozen in motion, high-speed photography, dramatic lighting, vivid colors, commercial {category}. Bold concept, 8K",
+    "Creative advertising. {product} in {artistic setting}. {colors} palette. Dramatic lighting with {effects}. Art direction: {style}. Award-winning, cinematic, 8K"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 73 - 0
fmode-image-set/mcp/catalog/templates/en/13-size-spec.json

@@ -0,0 +1,73 @@
+{
+  "locale": "en",
+  "id": "size-spec",
+  "name": "尺寸规格+使用步骤图",
+  "keywords": [
+    "尺寸",
+    "规格",
+    "尺寸图",
+    "使用步骤",
+    "规格图",
+    "dimension",
+    "how to use",
+    "使用指南"
+  ],
+  "trigger_phrases": [
+    "尺寸图",
+    "规格图",
+    "使用步骤图",
+    "尺寸标注",
+    "how to use",
+    "使用指南",
+    "产品规格"
+  ],
+  "prompt_template": {
+    "type": "product specification and usage guide infographic",
+    "subject": "{product_description}",
+    "dimensions": "{size_annotations}",
+    "steps": "{usage_steps}",
+    "extras": "{additional_info}",
+    "style": "clean professional layout",
+    "quality": "professional e-commerce infographic"
+  },
+  "defaults": {
+    "dimensions": "height, width with measurement lines and arrows",
+    "steps": "3-4 step numbered usage guide with icons",
+    "extras": "ingredient highlights or feature badges",
+    "style": "clean white, professional typography"
+  },
+  "variants": {
+    "premium-editorial": {
+      "description": "高端杂志风格",
+      "overrides": {
+        "style": "luxury editorial with decorative elements, gold accents, premium typography"
+      }
+    },
+    "technical": {
+      "description": "技术规格风格",
+      "overrides": {
+        "dimensions": "detailed specs with reference objects for scale",
+        "extras": "comparison charts, certification badges, data grids"
+      }
+    },
+    "ritual-guide": {
+      "description": "使用仪式指南",
+      "overrides": {
+        "steps": "decorative numbered ritual guide with small illustrations"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "dimension callout badge, usage ritual steps, ingredient percentages",
+    "electronics": "precise dimensions with comparison objects, port specs",
+    "food": "serving size, preparation steps, nutritional highlights",
+    "fashion": "size chart with body measurements, care instructions"
+  },
+  "examples": [
+    "Premium {category} specification and ritual guide, luxury editorial. White background, subtle border. Top: {product} centered with dimension badge. Left: 4-step usage guide with decorative icons. Right: highlights with colored pills. Bottom: {data}. Decorative corner accents. Magazine-quality, 2000x2800px",
+    "Product detail card. Left: {product} with dimension annotations {measurements}, arrows and lines. Right: {n}-step usage guide with icons. White background, minimal, professional, 2000x1500px",
+    "{category} specification card. {product} with detailed dimensions and reference object. Usage steps with icons. Key features. Clean layout, mobile-friendly, 2000x2000px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 72 - 0
fmode-image-set/mcp/catalog/templates/en/14-multi-product.json

@@ -0,0 +1,72 @@
+{
+  "locale": "en",
+  "id": "multi-product",
+  "name": "多产品套装/组合展示",
+  "keywords": [
+    "套装",
+    "组合",
+    "多产品",
+    "bundle",
+    "gift set",
+    "礼盒",
+    "系列展示"
+  ],
+  "trigger_phrases": [
+    "套装图",
+    "多产品展示",
+    "产品组合",
+    "bundle image",
+    "礼盒展示",
+    "系列图",
+    "gift set"
+  ],
+  "prompt_template": {
+    "type": "multi-product bundle photography",
+    "subject": "{product_set_description}",
+    "arrangement": "organized composition, all products clearly visible",
+    "background": "{background_description}",
+    "decorative": "{decorative_elements}",
+    "quality": "professional product photography, 8K"
+  },
+  "defaults": {
+    "arrangement": "organized with consistent spacing, all visible, no overlap",
+    "background": "clean suitable for showcase",
+    "decorative": "luxury elements for gift-ready presentation"
+  },
+  "variants": {
+    "gift-set": {
+      "description": "礼盒套装",
+      "overrides": {
+        "arrangement": "products in gift box with cards",
+        "decorative": "satin ribbon, gold foil, dried flowers, pearls"
+      }
+    },
+    "routine-set": {
+      "description": "使用程序套装",
+      "overrides": {
+        "arrangement": "products in order of use, step indicators"
+      }
+    },
+    "lineup": {
+      "description": "产品线排列",
+      "overrides": {
+        "arrangement": "neat row, hero centered and larger",
+        "decorative": "minimal, clean",
+        "background": "white seamless"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "complete routine (cleanser → toner → serum → cream), gift-ready",
+    "food": "product range, variety pack, flavor assortment",
+    "fashion": "outfit coordination pieces, colorway options",
+    "home": "collection of coordinating items"
+  },
+  "examples": [
+    "Luxury {category} gift set on premium surface: {product_list}. Organized composition with product cards. Scattered: gold foil, pearls, dried flowers, velvet ribbon. Bottom left: set description. Bottom right: price with original crossed out. Soft directional lighting, premium photography, 8K",
+    "Premium {category} routine set. Main product centered, lid open showing texture. Surrounding: routine products arranged naturally. Soft background, decorative elements. Warm studio lighting, gift-ready, 2000x2000px",
+    "{category} gift set display. {n} products neat row, consistent spacing. Clean photography, soft shadows, professional bundle image, no text, 2000x2000px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 73 - 0
fmode-image-set/mcp/catalog/templates/en/15-livestream.json

@@ -0,0 +1,73 @@
+{
+  "locale": "en",
+  "id": "livestream",
+  "name": "电商直播间场景",
+  "keywords": [
+    "直播",
+    "livestream",
+    "直播间",
+    "电商直播",
+    "直播截图",
+    "抖音直播",
+    "带货"
+  ],
+  "trigger_phrases": [
+    "直播图",
+    "直播间截图",
+    "电商直播",
+    "livestream",
+    "抖音直播",
+    "带货图",
+    "live commerce"
+  ],
+  "prompt_template": {
+    "type": "e-commerce livestream screenshot",
+    "host": "{host_description}",
+    "product_action": "{product_demonstration}",
+    "background": "real home-style livestream setup",
+    "ui_overlay": "live commerce UI elements",
+    "lighting": "ring light from front, warm indoor overhead mixing in",
+    "quality": "authentic phone screen capture, NOT AI-generated look"
+  },
+  "defaults": {
+    "host": "friendly person, natural expression, visible skin texture, NOT retouched",
+    "product_action": "holding product close to camera showing details",
+    "background": "organized but real product shelf, LED strip, small plants",
+    "ui_overlay": "viewer count, LIVE badge, comments, product card, buy button",
+    "lighting": "ring light circular catchlight in eyes, warm indoor overhead"
+  },
+  "variants": {
+    "douyin": {
+      "description": "抖音直播风格",
+      "overrides": {
+        "ui_overlay": "Douyin UI: LIVE badge, viewer count, scrolling comments, product card with price and buy button, gift icons, shopping cart"
+      }
+    },
+    "taobao": {
+      "description": "淘宝直播风格",
+      "overrides": {
+        "ui_overlay": "Taobao Live UI: product listing, coupon badges, viewer count, chat"
+      }
+    },
+    "setup": {
+      "description": "直播间布景展示",
+      "overrides": {
+        "ui_overlay": "no UI overlay, showing physical setup",
+        "background": "full view of studio with lighting, backdrop, shelves"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "host demonstrating application, showing texture, real-time swatching",
+    "fashion": "host wearing/showing clothing, fit demo, fabric close-up",
+    "food": "host tasting/preparing, showing freshness, unboxing",
+    "electronics": "host demonstrating features, hands-on product demo"
+  },
+  "examples": [
+    "Ultra-realistic Douyin livestream screenshot, phone screen capture. Host in casual {outfit}, minimal makeup, visible pores and skin texture, NOT retouched. Demonstrating {product}, showing details, other hand gesturing. Ring light natural shine, NOT plastic AI look. Background: real home setup - product shelf, whiteboard with prices, string lights, coffee mug. Phone UI: LIVE badge, viewer count, scrolling comments, product card with price, yellow buy button. Warm cast, slight noise, Kodak Portra 400 tone, NOT AI-generated look, 1080x1920px",
+    "Phone screenshot of livestream. Host holding {product}. Ring light catchlight. Product shelf behind. Phone noise, warm indoor, NOT professional, real livestream feel, 1080x1920px",
+    "Livestream scene. Host with {product} in home studio. Ring light, visible skin texture. Product display. Live UI elements. Authentic phone capture, 1080x1920px"
+  ],
+  "anti_ai_tips": "CRITICAL: (1) Phone screen capture style, (2) Ring light: circular catchlight in eyes, (3) Real skin: pores, undereye darkness, NOT smoothed, (4) Real environment: slightly messy, real objects, (5) Warm yellowish cast, (6) Slight noise, (7) 'NOT AI-generated look, NOT plastic smooth', (8) Kodak Portra 400 tone, (9) Realistic UI overlay",
+  "supports_image_reference": true
+}

+ 71 - 0
fmode-image-set/mcp/catalog/templates/en/16-try-on-virtual.json

@@ -0,0 +1,71 @@
+{
+  "locale": "en",
+  "id": "try-on-virtual",
+  "name": "虚拟试穿/产品融入场景",
+  "keywords": [
+    "试穿",
+    "融入",
+    "虚拟试穿",
+    "try on",
+    "场景融合",
+    "产品融入",
+    "product placement"
+  ],
+  "trigger_phrases": [
+    "虚拟试穿",
+    "产品融入场景",
+    "try on",
+    "场景融合",
+    "产品植入",
+    "场景化融入"
+  ],
+  "prompt_template": {
+    "type": "product integration / virtual try-on",
+    "subject": "{product_description} naturally integrated into {scene_type}",
+    "setting": "{detailed_scene}",
+    "atmosphere": "{mood_and_lighting}",
+    "integration": "product prominently displayed, naturally belonging in scene",
+    "quality": "cinematic lifestyle photography, 8K"
+  },
+  "defaults": {
+    "setting": "luxury interior or lifestyle environment matching product",
+    "atmosphere": "warm, inviting, aspirational",
+    "integration": "product as natural focal point"
+  },
+  "variants": {
+    "interior-luxury": {
+      "description": "奢华室内场景",
+      "overrides": {
+        "setting": "high-end spa/hotel/luxury home, marble, gold fixtures",
+        "atmosphere": "cinematic depth, warm grading, premium"
+      }
+    },
+    "outdoor-natural": {
+      "description": "户外自然场景",
+      "overrides": {
+        "setting": "natural outdoor, organic elements",
+        "atmosphere": "golden hour sunlight, natural and free"
+      }
+    },
+    "studio-editorial": {
+      "description": "棚拍编辑风格",
+      "overrides": {
+        "setting": "professional studio with styled backdrop",
+        "atmosphere": "controlled studio lighting, editorial quality"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "spa bathroom, vanity mirror, botanical elements, skincare ritual",
+    "fashion": "model wearing item in appropriate setting, natural styling",
+    "furniture": "product in complete room setting, complementary decor",
+    "electronics": "modern desk setup, in-use context, tech-forward"
+  },
+  "examples": [
+    "Cinematic luxury {category} integration. High-end {interior} with {materials}. {product} prominently displayed. Morning sunlight through frosted glass, ethereal light rays. Fresh flowers, botanical elements. Product label visible, ingredient card beside it. Decorative: tray, pearls, gold foil. Color: {colors}. Cinematic depth, warm grading, premium, 8K, 1080x1620px",
+    "{product} naturally in {scene}, morning sunlight, soft shadows, botanical touches, warm atmosphere, professional lifestyle photography, 8K, 1080x1350px",
+    "{product} integrated into complete {category} lifestyle scene. {person} at {location}, products arranged naturally, natural light, warm atmosphere. Professional lifestyle, 1080x1350px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 84 - 0
fmode-image-set/mcp/catalog/templates/en/17-exploded-view.json

@@ -0,0 +1,84 @@
+{
+  "locale": "en",
+  "id": "exploded-view",
+  "name": "技术拆解/爆炸图",
+  "keywords": [
+    "拆解图",
+    "爆炸图",
+    "exploded view",
+    "技术图",
+    "结构图",
+    "内部结构",
+    "拆机",
+    "teardown",
+    "cross-section"
+  ],
+  "trigger_phrases": [
+    "产品拆解",
+    "爆炸图",
+    "内部结构",
+    "技术拆解",
+    "exploded view",
+    "产品组件",
+    "零件展示"
+  ],
+  "prompt_template": {
+    "type": "technical product exploded view",
+    "subject": "{product_description}",
+    "components": "{component_list}",
+    "arrangement": "vertical stack with increasing spacing",
+    "background": "clean light gray or white",
+    "labels": "thin leader lines to callout labels with name and key spec",
+    "quality": "8K, technical illustration, precise component rendering"
+  },
+  "defaults": {
+    "arrangement": "vertical exploded stack",
+    "background": "clean light gray",
+    "labels": "component name + material + core spec"
+  },
+  "variants": {
+    "blueprint": {
+      "description": "蓝图风格",
+      "overrides": {
+        "background": "dark charcoal with cyan grid lines",
+        "labels": "white and gold typography, blueprint aesthetic"
+      }
+    },
+    "minimal": {
+      "description": "极简白底",
+      "overrides": {
+        "background": "pure white, no grid",
+        "labels": "minimal black text, thin gray leader lines"
+      }
+    },
+    "apple-style": {
+      "description": "Apple产品拆解风格",
+      "overrides": {
+        "arrangement": "isometric floating with precise spacing",
+        "background": "deep gradient from charcoal to black",
+        "labels": "elegant sans-serif, golden accent lines"
+      }
+    },
+    "editorial": {
+      "description": "杂志编辑风格",
+      "overrides": {
+        "background": "warm off-white with subtle texture",
+        "labels": "serif font callouts, hand-drawn arrow style"
+      }
+    }
+  },
+  "category_tips": {
+    "electronics": "highlight circuit boards, chips, battery modules with spec labels",
+    "audio": "show speaker drivers, diaphragms, ANC modules, battery size",
+    "wearables": "include sensors, display panel, waterproof seals, strap mechanism",
+    "home_appliance": "show motor, filter system, internal wiring, control board",
+    "phone_accessories": "highlight charging coils, magnet arrays, protective layers",
+    "camera": "show lens elements, sensor, image processor, stabilization unit"
+  },
+  "examples": [
+    "Product exploded view. {product} disassembled into 5 components floating in mid-air with spacing, arranged vertically. Clean light gray background. Soft shadows beneath each part. Technical illustration style, 8K, no text",
+    "{product} technical exploded view infographic. Components floating in isometric arrangement with thin connecting lines. Each component labeled with name and key spec. Clean white background, blueprint-inspired accent lines, 8K"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 79 - 0
fmode-image-set/mcp/catalog/templates/en/18-ghost-mannequin.json

@@ -0,0 +1,79 @@
+{
+  "locale": "en",
+  "id": "ghost-mannequin",
+  "name": "隐形模特",
+  "keywords": [
+    "隐形模特",
+    "ghost mannequin",
+    "invisible model",
+    "3D服装",
+    "服装展示",
+    "无人模特",
+    "中空模特",
+    "flat mannequin"
+  ],
+  "trigger_phrases": [
+    "隐形模特",
+    "ghost mannequin",
+    "3D服装展示",
+    "无人穿着展示",
+    "服装立体展示",
+    "invisible mannequin"
+  ],
+  "prompt_template": {
+    "type": "ghost mannequin product photography",
+    "subject": "{product_description}",
+    "form": "invisible mannequin creating natural 3D body shape",
+    "details": "natural shoulder slope, bust/chest contour, waist curve visible",
+    "background": "clean white or soft gray gradient",
+    "lighting": "three-point studio setup, gentle dimension",
+    "quality": "8K, fashion e-commerce standard photography"
+  },
+  "defaults": {
+    "form": "natural body contours - shoulders, bust, waist",
+    "background": "soft warm gray gradient",
+    "lighting": "three-point studio setup creating gentle dimension"
+  },
+  "variants": {
+    "white-clean": {
+      "description": "纯白干净",
+      "overrides": {
+        "background": "pure white seamless",
+        "lighting": "bright even commercial lighting"
+      }
+    },
+    "editorial": {
+      "description": "杂志风格",
+      "overrides": {
+        "background": "moody dark gradient from charcoal to deep gray",
+        "lighting": "spotlight from above creating dramatic highlight on shoulders"
+      }
+    },
+    "editorial-detail": {
+      "description": "展示内衬细节",
+      "overrides": {
+        "details": "garment partially open revealing inner lining tag, rolled sleeves showing lining contrast"
+      }
+    },
+    "lifestyle": {
+      "description": "带环境道具",
+      "overrides": {
+        "background": "minimal studio with subtle editorial prop - single branch or flower in corner"
+      }
+    }
+  },
+  "category_tips": {
+    "shirts": "collar standing naturally, top buttons detail, cuff visibility",
+    "dresses": "natural waist cinch, skirt drape showing fabric weight, back zipper detail",
+    "coats": "architectural shoulder structure, fabric texture visible, button and pocket details",
+    "knitwear": "visible knit pattern and texture, natural stretch around body contours",
+    "tshirts": "casual relaxed drape, crew or v-neck sitting naturally, sleeve length proportion",
+    "activewear": "compression fit showing body contour, moisture-wicking fabric texture, logo placement"
+  },
+  "examples": [
+    "Ghost mannequin photography. {product} on invisible mannequin, natural body contours visible. Fabric drapes naturally showing material weight. Pure white background. Soft studio lighting, 8K, fashion e-commerce standard",
+    "Premium ghost mannequin photography. {product} on invisible mannequin, garment partially open revealing inner lining. Background: soft warm gray gradient. Three-point studio lighting, Phase One quality, 8K"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 84 - 0
fmode-image-set/mcp/catalog/templates/en/19-multi-angle-grid.json

@@ -0,0 +1,84 @@
+{
+  "locale": "en",
+  "id": "multi-angle-grid",
+  "name": "产品多角度网格",
+  "keywords": [
+    "多角度",
+    "网格",
+    "grid",
+    "多面",
+    "颜色展示",
+    "colorway",
+    "产品角度",
+    "catalog grid",
+    "产品目录"
+  ],
+  "trigger_phrases": [
+    "多角度展示",
+    "产品网格",
+    "多面展示",
+    "颜色对比",
+    "multi-angle",
+    "grid layout",
+    "产品目录图",
+    "多色展示"
+  ],
+  "prompt_template": {
+    "type": "product photography grid layout",
+    "subject": "{product_description}",
+    "grid": "2x2 or 3x3 equal squares",
+    "variation": "different angles or colorways",
+    "background": "clean white in each cell",
+    "separators": "thin white borders between cells",
+    "quality": "8K, catalog photography, uniform lighting across all cells"
+  },
+  "defaults": {
+    "grid": "2x2 grid, 4 views",
+    "variation": "front, side, back, top-down angles",
+    "background": "clean white per cell",
+    "separators": "thin white borders"
+  },
+  "variants": {
+    "angle-view": {
+      "description": "多角度视角",
+      "overrides": {
+        "variation": "front, side, back, top-down, 3/4 angle views",
+        "grid": "2x2 or 3x3"
+      }
+    },
+    "colorway": {
+      "description": "多配色展示",
+      "overrides": {
+        "variation": "same product in different colors, consistent 3/4 angle",
+        "grid": "3x3 for 9 colors or 2x3 for 6 colors"
+      }
+    },
+    "feature-grid": {
+      "description": "带功能标注网格",
+      "overrides": {
+        "variation": "each cell shows different product feature with label and icon",
+        "grid": "hero image left + 4 smaller shots right column"
+      }
+    },
+    "comparison": {
+      "description": "对比网格",
+      "overrides": {
+        "variation": "before/after or product vs competitor in side-by-side grid"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "show bottle angle, cap detail, texture close-up, and open product",
+    "electronics": "show front face, side ports, back panel, and included accessories",
+    "fashion": "show front view, back view, detail close-up, and fabric texture",
+    "food": "show packaging front, back nutrition, open product, and serving suggestion",
+    "home": "show full product, detail angle, material texture, and in-context shot",
+    "sports": "show product front, side profile, sole/bottom, and action usage shot"
+  },
+  "examples": [
+    "2x2 product grid. {product} shown from 4 angles: front, side, back, top-down. Clean white background per cell. Thin borders. Uniform studio lighting, 8K, 2000x2000px",
+    "3x3 color variation grid. Same {product} in 9 different colors, identical 3/4 angle. Clean white cells, subtle drop shadow. Professional catalog, 8K, 2000x2000px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 85 - 0
fmode-image-set/mcp/catalog/templates/en/20-magazine-editorial.json

@@ -0,0 +1,85 @@
+{
+  "locale": "en",
+  "id": "magazine-editorial",
+  "name": "杂志大片/封面",
+  "keywords": [
+    "杂志",
+    "封面",
+    "杂志大片",
+    "editorial",
+    "magazine cover",
+    "时尚大片",
+    "Vogue",
+    "时尚摄影",
+    "fashion editorial"
+  ],
+  "trigger_phrases": [
+    "杂志封面",
+    "杂志大片",
+    "时尚大片",
+    "editorial",
+    "magazine cover",
+    "品牌大片",
+    "时尚杂志",
+    "封面图"
+  ],
+  "prompt_template": {
+    "type": "fashion/beauty magazine cover editorial",
+    "subject": "{product_description}",
+    "model": "person holding or wearing product in editorial pose",
+    "makeup_hair": "polished editorial beauty look",
+    "background": "studio backdrop in warm/tonal color",
+    "lighting": "beauty dish + rim light, editorial setup",
+    "layout": "magazine masthead area at top, barcode space at bottom",
+    "quality": "8K, Vogue-quality editorial photography"
+  },
+  "defaults": {
+    "lighting": "beauty dish from front-left, soft rim light on hair",
+    "background": "studio backdrop warm peach-nude tone",
+    "layout": "masthead area top, issue text bottom-left, barcode bottom-right"
+  },
+  "variants": {
+    "beauty-cover": {
+      "description": "美妆封面",
+      "overrides": {
+        "model": "close-up portrait with glowing skin, holding skincare near face",
+        "makeup_hair": "dewy natural, glossy lips, subtle smoky eye"
+      }
+    },
+    "fashion-cover": {
+      "description": "时尚封面",
+      "overrides": {
+        "model": "full or 3/4 body shot wearing fashion product, confident pose",
+        "makeup_hair": "bold editorial makeup, high-fashion styling"
+      }
+    },
+    "fragrance-editorial": {
+      "description": "香水大片",
+      "overrides": {
+        "model": "person in contemplative pose with fragrance bottle, atmospheric",
+        "background": "dark moody backdrop with atmospheric haze"
+      }
+    },
+    "minimal-editorial": {
+      "description": "极简杂志内页",
+      "overrides": {
+        "background": "clean white studio, no backdrop color",
+        "layout": "no masthead, clean full-bleed editorial image"
+      }
+    }
+  },
+  "category_tips": {
+    "skincare": "dewy skin, product held at chin level, beauty lighting",
+    "fragrance": "atmospheric smoke, contemplative mood, bottle prominent",
+    "fashion": "confident pose, outfit fully visible, dramatic lighting",
+    "jewelry": "close-up on hands/neck, editorial styling, luxury backdrop",
+    "haircare": "hair as hero element, movement and texture, beauty lighting",
+    "makeup": "bold lip or eye focus, product in hand, editorial beauty"
+  },
+  "examples": [
+    "Beauty magazine cover. Woman with glowing skin holding {product} near face. Beauty dish lighting, soft background. Top area for masthead. Vogue-quality editorial, 8K, 1080x1350px",
+    "Fashion editorial magazine cover. Striking woman wearing {product} in contemplative pose. Bold makeup, sleek hair. High-contrast editorial lighting. Magazine layout overlay with border frame. 8K, 1080x1350px"
+  ],
+  "anti_ai_tips": "For authentic editorial feel: specify actual camera (Phase One IQ4, Canon EOS R5 85mm f/1.2), add visible skin texture, use real beauty lighting terminology",
+  "supports_image_reference": true
+}

+ 80 - 0
fmode-image-set/mcp/catalog/templates/en/21-seasonal-campaign.json

@@ -0,0 +1,80 @@
+{
+  "locale": "en",
+  "id": "seasonal-campaign",
+  "name": "季节主题网格",
+  "keywords": [
+    "季节",
+    "四季",
+    "campaign",
+    "季节主题",
+    "seasonal",
+    "春夏秋冬",
+    "年度campaign",
+    "品牌campaign",
+    "季节变体"
+  ],
+  "trigger_phrases": [
+    "季节主题",
+    "四季展示",
+    "seasonal campaign",
+    "年度campaign",
+    "春夏秋冬",
+    "季节变体",
+    "品牌年度",
+    "四季网格"
+  ],
+  "prompt_template": {
+    "type": "seasonal product campaign grid",
+    "subject": "{product_description}",
+    "layout": "2x2 grid, one product in four seasonal settings",
+    "spring": "pastel tones, blossoms, morning dew, soft light",
+    "summer": "bright warm tones, tropical elements, golden light",
+    "autumn": "warm amber, dried leaves, cinnamon, window light",
+    "winter": "cool blues, pine branches, frost, moonlight",
+    "quality": "8K, brand campaign photography, consistent product placement"
+  },
+  "defaults": {
+    "layout": "2x2 grid with thin white or gold borders",
+    "consistency": "same product angle and size across all four quadrants"
+  },
+  "variants": {
+    "four-seasons": {
+      "description": "经典四季",
+      "overrides": {
+        "layout": "2x2, each quadrant a full seasonal scene"
+      }
+    },
+    "holiday-series": {
+      "description": "节日系列",
+      "overrides": {
+        "layout": "2x3 or 3x2 grid, each cell a different holiday theme (Valentine's, Easter, Summer, Halloween, Thanksgiving, Christmas)"
+      }
+    },
+    "day-to-night": {
+      "description": "日夜变体",
+      "overrides": {
+        "layout": "2x2 grid, same scene at dawn, morning, afternoon, night"
+      }
+    },
+    "travel-series": {
+      "description": "旅行主题",
+      "overrides": {
+        "layout": "2x2 grid, product in 4 travel destinations with local elements"
+      }
+    }
+  },
+  "category_tips": {
+    "skincare": "spring=blossoms+dew, summer=sun+kisses, autumn=cozy+cream, winter=frost+glow",
+    "fragrance": "each season with matching botanical elements and color palette",
+    "fashion": "season-appropriate styling visible in each quadrant",
+    "food": "seasonal ingredients and color palette matching product",
+    "home": "seasonal decor elements and lighting mood",
+    "candles": "seasonal scents visualized through corresponding natural elements"
+  },
+  "examples": [
+    "2x2 seasonal grid. Same {product} in four settings: Spring with cherry blossoms, Summer with citrus and sunshine, Autumn with maple leaves, Winter with pine and snow. Consistent product angle, 8K, 2000x2000px",
+    "Brand seasonal campaign 2x2 grid. {product} in four seasonal worlds: Spring=pastel marble+dew, Summer=tropical leaves+sunset, Autumn=walnut wood+cinnamon, Winter=frosted glass+pine. Season labels in serif font, 8K"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 83 - 0
fmode-image-set/mcp/catalog/templates/en/22-luxury-atmospherics.json

@@ -0,0 +1,83 @@
+{
+  "locale": "en",
+  "id": "luxury-atmospherics",
+  "name": "奢华氛围渲染",
+  "keywords": [
+    "奢华",
+    "氛围",
+    "烟雾",
+    "高级感",
+    "luxury",
+    "atmospheric",
+    "梦幻",
+    "高端",
+    "premium",
+    "烟雾效果",
+    "金色"
+  ],
+  "trigger_phrases": [
+    "奢华氛围",
+    "高端渲染",
+    "梦幻效果",
+    "luxury campaign",
+    "氛围渲染",
+    "高端广告",
+    "品牌大片",
+    "premium visual"
+  ],
+  "prompt_template": {
+    "type": "luxury product photography with atmospheric effects",
+    "subject": "{product_description}",
+    "surface": "polished dark reflective surface",
+    "atmosphere": "wisps of smoke, floating petals, light particles",
+    "lighting": "dramatic rim light + cool ambient fill",
+    "background": "infinite dark void with subtle gradient",
+    "quality": "8K, award-winning luxury advertising photography"
+  },
+  "defaults": {
+    "surface": "polished obsidian or black marble platform",
+    "atmosphere": "purple-blue smoke wisps + floating elements",
+    "lighting": "golden rim light from upper-left, cool blue backlight"
+  },
+  "variants": {
+    "floral-dream": {
+      "description": "花卉梦幻",
+      "overrides": {
+        "atmosphere": "fresh flower petals floating at various heights catching light, scattered gold leaf particles"
+      }
+    },
+    "smoke-mystique": {
+      "description": "烟雾神秘",
+      "overrides": {
+        "atmosphere": "multi-layered violet and midnight blue smoke swirling around product"
+      }
+    },
+    "golden-luxe": {
+      "description": "金色奢华",
+      "overrides": {
+        "atmosphere": "golden bokeh particles, warm amber glow, scattered gold flakes"
+      }
+    },
+    "ice-crystal": {
+      "description": "冰晶冷冽",
+      "overrides": {
+        "atmosphere": "floating ice crystals, frost patterns, diamond-like light refractions",
+        "lighting": "cool platinum spotlight, ice-blue ambient"
+      }
+    }
+  },
+  "category_tips": {
+    "fragrance": "multi-layer smoke + matching botanical elements, amber liquid visible inside",
+    "skincare": "ethereal glow around product, subtle condensation, dreamy quality",
+    "jewelry": "diamond-like light bokeh, dark background, sparkle and reflection focus",
+    "wine": "rich amber or ruby liquid, smoke wisps matching color, candlelight warmth",
+    "chocolate": "warm golden particles, cocoa powder dust, rich dark tones",
+    "watch": "sharp metallic reflections, precise time visible, ice-blue or golden accent"
+  },
+  "examples": [
+    "Luxury product photography with atmospheric effects. {product} on polished dark surface, surrounded by wisps of purple-blue smoke. Dramatic rim light. Deep black background. 8K, cinematic quality",
+    "Premium atmospheric product rendering. {product} on black marble veined with gold. Floating flower petals and gold leaf particles. Multi-point lighting: golden rim light + cool backlight. Dark void background, 8K"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 83 - 0
fmode-image-set/mcp/catalog/templates/en/23-device-mockup.json

@@ -0,0 +1,83 @@
+{
+  "locale": "en",
+  "id": "device-mockup",
+  "name": "设备界面模型",
+  "keywords": [
+    "设备模型",
+    "界面模型",
+    "mockup",
+    "UI展示",
+    "APP截图",
+    "设备展示",
+    "屏幕展示",
+    "device mockup",
+    "SaaS"
+  ],
+  "trigger_phrases": [
+    "设备模型",
+    "界面展示",
+    "mockup",
+    "APP展示",
+    "网站展示",
+    "SaaS展示",
+    "UI mockup",
+    "产品截图",
+    "屏幕展示"
+  ],
+  "prompt_template": {
+    "type": "product device mockup photography",
+    "subject": "{product_description} displayed on device screen",
+    "device": "laptop or smartphone on desk",
+    "screen_content": "modern clean app/dashboard interface",
+    "desk_accessories": "coffee cup, plant, notebook, natural work environment",
+    "lighting": "natural window light with warm fill",
+    "quality": "8K, tech product photography, screen content tack-sharp"
+  },
+  "defaults": {
+    "device": "latest laptop on clean desk",
+    "desk_accessories": "coffee cup, small plant, wireless mouse, notebook",
+    "lighting": "soft window light from left, warm overhead fill",
+    "background": "modern minimal office environment"
+  },
+  "variants": {
+    "single-laptop": {
+      "description": "单笔记本",
+      "overrides": {
+        "device": "MacBook Pro showing product interface, shallow depth of field on screen"
+      }
+    },
+    "multi-device": {
+      "description": "多设备联动",
+      "overrides": {
+        "device": "iPad center + iPhone left + smartwatch right, all showing consistent app UI"
+      }
+    },
+    "phone-only": {
+      "description": "手机展示",
+      "overrides": {
+        "device": "iPhone held at slight angle in hand, lifestyle background, screen clearly visible"
+      }
+    },
+    "office-lifestyle": {
+      "description": "办公场景",
+      "overrides": {
+        "desk_accessories": "Aesop hand cream, Muji pen holder, latte with art, hardcover notebook, trailing plant on shelf",
+        "background": "Scandinavian home office with white bookshelf"
+      }
+    }
+  },
+  "category_tips": {
+    "saas": "show dashboard with charts, KPI cards, data visualizations",
+    "mobile_app": "show app interface on phone, notification visible, clean UI",
+    "ecommerce_platform": "show store admin dashboard with product listings and analytics",
+    "fintech": "show financial dashboard with graphs, portfolio summary",
+    "health_app": "show workout tracker, health metrics, progress charts",
+    "ai_product": "show chat interface, AI responses, modern dark or light theme"
+  },
+  "examples": [
+    "Product mockup on laptop. Silver laptop on white desk, screen showing modern dashboard with charts. Coffee cup and small plant nearby. Natural window light. Clean product photography, 8K",
+    "Multi-device mockup. iPad with keyboard showing {product} app, iPhone showing notification, Apple Watch showing widget. All screens consistent UI in sage green. Scandinavian desk, 8K, 1536x1024px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 82 - 0
fmode-image-set/mcp/catalog/templates/en/24-storefront.json

@@ -0,0 +1,82 @@
+{
+  "locale": "en",
+  "id": "storefront",
+  "name": "店铺门面/空间摄影",
+  "keywords": [
+    "店铺",
+    "门面",
+    "店面",
+    "storefront",
+    "空间摄影",
+    "室内设计",
+    "零售空间",
+    "咖啡店",
+    "实体店"
+  ],
+  "trigger_phrases": [
+    "店铺门面",
+    "店面摄影",
+    "空间摄影",
+    "零售空间",
+    "咖啡店外观",
+    "实体店",
+    "storefront",
+    "室内摄影",
+    "店铺设计"
+  ],
+  "prompt_template": {
+    "type": "retail/storefront architectural photography",
+    "subject": "{business_description}",
+    "exterior": "clean facade with signage and window display",
+    "interior": "curated display shelving, consultation area, ambient lighting",
+    "lighting": "golden hour exterior or warm ambient interior",
+    "quality": "8K, editorial architectural photography, wide-angle sharp"
+  },
+  "defaults": {
+    "exterior": "large glass windows, signage, entrance details, potted plants",
+    "lighting": "late afternoon warm golden light, interior already glowing",
+    "camera": "wide-angle lens, f/8-f/11 for full sharpness"
+  },
+  "variants": {
+    "exterior": {
+      "description": "门面外观",
+      "overrides": {
+        "exterior": "full storefront view with window display, entrance, street context",
+        "interior": "visible through glass windows only"
+      }
+    },
+    "interior": {
+      "description": "室内空间",
+      "overrides": {
+        "interior": "wide-angle interior showing product displays, furniture, lighting design",
+        "exterior": "not visible"
+      }
+    },
+    "corner-detail": {
+      "description": "角落细节",
+      "overrides": {
+        "interior": "close-up vignette of a curated corner: consultation table, product samples, single flower"
+      }
+    },
+    "aerial-plan": {
+      "description": "俯视平面",
+      "overrides": {
+        "camera": "top-down bird's eye view showing full floor plan layout"
+      }
+    }
+  },
+  "category_tips": {
+    "coffee_shop": "espresso machine visible, warm wood tones, latte art, pastry display",
+    "beauty_store": "illuminated niches, marble counter, product wall, consultation area",
+    "fashion_boutique": "minimalist racks, curated display, premium flooring, mirror accents",
+    "restaurant": "table setting, kitchen pass visible, ambient lighting, menu display",
+    "gym_studio": "modern equipment, branded wall, natural light, motivational signage",
+    "pop_up": "temporary creative installation, bold graphics, unique display fixtures"
+  },
+  "examples": [
+    "Storefront photography. Modern {business} with glass windows showing warm interior. Entrance with potted plants. Golden hour light. Architectural photography, 8K",
+    "Premium interior photography of {business}. Wide-angle shot showing product displays, consultation area, herringbone floor. Warm ambient lighting throughout. Architectural Digest quality, 8K, 1536x1024px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 84 - 0
fmode-image-set/mcp/catalog/templates/en/25-sports-campaign.json

@@ -0,0 +1,84 @@
+{
+  "locale": "en",
+  "id": "sports-campaign",
+  "name": "运动/健身广告",
+  "keywords": [
+    "运动",
+    "健身",
+    "广告",
+    "sports",
+    "fitness",
+    "campaign",
+    "运动鞋",
+    "运动服",
+    "篮球",
+    "跑步",
+    "健身器材"
+  ],
+  "trigger_phrases": [
+    "运动广告",
+    "健身广告",
+    "sports campaign",
+    "运动品牌",
+    "运动大片",
+    "健身推广",
+    "运动产品广告",
+    "运动海报"
+  ],
+  "prompt_template": {
+    "type": "sports/fitness advertising campaign photography",
+    "subject": "{product_description}",
+    "athlete": "athletic model in dynamic pose with product",
+    "props": "sports equipment as visual anchor, exaggerated scale",
+    "background": "minimal dark studio with reflective floor",
+    "typography": "bold condensed headline + subtitle",
+    "quality": "8K, Nike/Under Armour campaign quality"
+  },
+  "defaults": {
+    "background": "dark studio, reflective black floor",
+    "typography": "bold condensed font, brand color accent",
+    "lighting": "spotlight from above, dramatic body highlights"
+  },
+  "variants": {
+    "product-hero": {
+      "description": "产品主视觉",
+      "overrides": {
+        "athlete": "no model, product floating at dynamic angle",
+        "props": "motion lines and speed effects around product"
+      }
+    },
+    "athlete-action": {
+      "description": "运动员动态",
+      "overrides": {
+        "athlete": "athlete mid-action wearing product, stadium lights, lens flare"
+      }
+    },
+    "triptych": {
+      "description": "三联画",
+      "overrides": {
+        "layout": "three vertical panels: close-up detail + action shot + product hero, unified bottom strip with branding"
+      }
+    },
+    "gym-power": {
+      "description": "健身力量感",
+      "overrides": {
+        "athlete": "muscular athlete seated on oversized dumbbell or equipment",
+        "props": "exaggerated fitness equipment placed diagonally"
+      }
+    }
+  },
+  "category_tips": {
+    "running_shoes": "dynamic forward motion, speed lines, track or road surface context",
+    "basketball": "mid-dunk or crossover pose, stadium lighting, court texture",
+    "fitness_equipment": "athlete using product, sweat detail, gym environment",
+    "sportswear": "compression fit visible, fabric technology highlighted, movement pose",
+    "protein_supplements": "product with athlete post-workout, muscular definition, energy mood",
+    "sports_drink": "splash effects, condensation, vibrant energy colors, hydration theme"
+  },
+  "examples": [
+    "Sports advertising photo. {product} placed diagonally on reflective dark surface. Dramatic side lighting, speed lines around product. Bold headline text. Dynamic and energetic, 8K, 1080x1350px",
+    "Fitness brand campaign. Athletic model wearing {product}, seated on large dumbbell. Dark studio with reflective floor. Spotlight from above. 'STRENGTH' bold headline. Nike campaign quality, 8K"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 80 - 0
fmode-image-set/mcp/catalog/templates/zh/01-hero-image.json

@@ -0,0 +1,80 @@
+{
+  "locale": "zh",
+  "id": "hero-image",
+  "name": "白底/纯色底产品主图",
+  "keywords": [
+    "白底图",
+    "主图",
+    "hero image",
+    "白背景",
+    "product shot",
+    "packshot",
+    "产品照",
+    "商品主图"
+  ],
+  "trigger_phrases": [
+    "产品主图",
+    "白底图",
+    "白背景产品",
+    "hero image",
+    "电商主图",
+    "纯色背景"
+  ],
+  "prompt_template": {
+    "type": "product photography",
+    "subject": "{product_description}",
+    "background": "clean white background",
+    "lighting": "soft diffused studio lighting, even illumination",
+    "composition": "centered, front view",
+    "quality": "8K, commercial e-commerce photography"
+  },
+  "defaults": {
+    "background": "clean white background",
+    "lighting": "soft diffused studio lighting",
+    "composition": "centered, front view"
+  },
+  "variants": {
+    "luxury": {
+      "description": "高端奢侈品风格",
+      "overrides": {
+        "lighting": "Rembrandt lighting, subtle rim light",
+        "background": "gradient from dark to light, premium feel"
+      }
+    },
+    "fresh": {
+      "description": "清新自然风格",
+      "overrides": {
+        "lighting": "bright natural light",
+        "background": "light pastel tones"
+      }
+    },
+    "tech": {
+      "description": "科技感风格",
+      "overrides": {
+        "lighting": "dramatic side lighting",
+        "background": "dark minimalist"
+      }
+    },
+    "color": {
+      "description": "彩色背景风格",
+      "overrides": {
+        "background": "{color} gradient background"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "emphasize texture and glow, show formula details",
+    "electronics": "highlight metallic finish, screen details, port precision",
+    "food": "vibrant colors, fresh appearance, show texture",
+    "fashion": "show fabric texture, drape quality, stitching details",
+    "home": "show material quality, craftsmanship, lifestyle appeal",
+    "jewelry": "macro detail, sparkle and cut quality, luxurious lighting"
+  },
+  "examples": [
+    "{product}, professional product photography on clean white background, soft diffused studio lighting, centered, 8K, commercial e-commerce photography, no shadows, no props",
+    "{product} on pure white seamless background, bright commercial studio lighting, centered, 3/4 profile, high resolution, marketplace ready, {material_description}",
+    "{product} floating slightly above white surface, softbox overhead, sharp rim light on edges, professional packshot, 8K, ultra-detailed textures"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 85 - 0
fmode-image-set/mcp/catalog/templates/zh/02-lifestyle-scene.json

@@ -0,0 +1,85 @@
+{
+  "locale": "zh",
+  "id": "lifestyle-scene",
+  "name": "场景化生活图",
+  "keywords": [
+    "场景图",
+    "生活图",
+    "lifestyle",
+    "使用场景",
+    "生活场景",
+    "场景化",
+    "lifestyle photography"
+  ],
+  "trigger_phrases": [
+    "场景图",
+    "生活图",
+    "使用场景图",
+    "lifestyle photo",
+    "产品场景",
+    "生活化展示"
+  ],
+  "prompt_template": {
+    "type": "lifestyle product photography",
+    "subject": "{product_description}",
+    "setting": "{scene_description}",
+    "lighting": "natural {time_of_day} light",
+    "composition": "{composition_style}",
+    "mood": "{mood_description}",
+    "quality": "8K, editorial lifestyle photography"
+  },
+  "defaults": {
+    "setting": "modern living space, clean and organized",
+    "lighting": "natural morning light through window",
+    "composition": "rule of thirds, product as focal point",
+    "mood": "warm and inviting"
+  },
+  "variants": {
+    "morning": {
+      "description": "早晨清新氛围",
+      "overrides": {
+        "setting": "bright room with sunlight streaming through window",
+        "lighting": "soft morning golden light, visible dust particles",
+        "mood": "fresh, clean, new beginning"
+      }
+    },
+    "cozy": {
+      "description": "温馨舒适氛围",
+      "overrides": {
+        "setting": "warm cozy interior, soft textiles, candles",
+        "lighting": "warm ambient lighting, soft golden tones",
+        "mood": "comfortable, intimate"
+      }
+    },
+    "outdoor": {
+      "description": "户外自然场景",
+      "overrides": {
+        "setting": "outdoor natural setting, organic elements",
+        "lighting": "golden hour natural sunlight",
+        "mood": "natural, free, adventurous"
+      }
+    },
+    "luxury": {
+      "description": "奢华高端场景",
+      "overrides": {
+        "setting": "luxury spa/hotel, marble and gold fixtures",
+        "lighting": "cinematic lighting, warm color grading",
+        "mood": "premium, sophisticated"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "bathroom vanity with botanical elements, skincare ritual feel",
+    "electronics": "modern desk setup, minimal aesthetic, tech-forward",
+    "food": "kitchen counter or dining table, fresh ingredients, warm tones",
+    "fashion": "urban street or boutique fitting room, model wearing the item",
+    "home": "styled room interior, product naturally placed"
+  },
+  "examples": [
+    "{product} naturally placed in {scene}, morning sunlight through window, botanical touches, warm atmosphere, professional lifestyle photography, 8K",
+    "Cinematic luxury interior with marble walls and gold fixtures. {product} prominently displayed. Morning sunlight through frosted glass. Fresh flowers. Cinematic depth of field, warm color grading, 8K",
+    "{product} in cozy setting, warm ambient lighting, soft textiles, golden hour tones, lifestyle photography, authentic atmosphere"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 72 - 0
fmode-image-set/mcp/catalog/templates/zh/03-flat-lay.json

@@ -0,0 +1,72 @@
+{
+  "locale": "zh",
+  "id": "flat-lay",
+  "name": "平铺图",
+  "keywords": [
+    "平铺图",
+    "flat lay",
+    "俯拍",
+    "top-down",
+    "flatlay",
+    "俯视图"
+  ],
+  "trigger_phrases": [
+    "平铺图",
+    "flat lay",
+    "俯拍",
+    "top-down view",
+    "产品平铺"
+  ],
+  "prompt_template": {
+    "type": "flat lay photography, top-down view",
+    "subject": "{product_description} as hero at bottom center",
+    "props": "{prop_list}",
+    "background": "{background_material}",
+    "color_palette": "{color_scheme}",
+    "lighting": "soft natural window light from top-left at 45 degrees",
+    "quality": "8K, editorial photography, no text, no watermark"
+  },
+  "defaults": {
+    "props": "carefully curated complementary objects",
+    "background": "ivory linen texture",
+    "color_palette": "ivory, blush, champagne gold, sage green"
+  },
+  "variants": {
+    "luxury": {
+      "description": "奢华仪式感",
+      "overrides": {
+        "props": "gold foil flakes, pearl beads, dried flowers, silk ribbon, leather journal",
+        "background": "ivory linen with subtle shadow from sheer curtains",
+        "color_palette": "ivory, blush pink, champagne gold, soft cream"
+      }
+    },
+    "minimal": {
+      "description": "极简风格",
+      "overrides": {
+        "props": "only 2-3 essential items, clean lines",
+        "background": "clean white surface",
+        "color_palette": "white, gray, one accent color"
+      }
+    },
+    "seasonal": {
+      "description": "季节主题",
+      "overrides": {
+        "props": "seasonal elements matching theme",
+        "color_palette": "season-appropriate color scheme"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "skincare ritual flat lay, open product showing formula, botanical elements",
+    "food": "ingredients spread, fresh produce, kitchen tools",
+    "fashion": "clothing + accessories + shoes arranged aesthetically, fabric textures",
+    "home": "decor items, textures, materials spread showing lifestyle"
+  },
+  "examples": [
+    "Luxurious {category} ritual flat lay, top-down photography. {product} with lid open showing texture as hero at bottom center. Surrounding: gold-tone palette, crystal bottle, dried lavender with silk ribbon, gold foil flakes, pearl beads. Background: ivory linen. Color: ivory, blush, champagne gold. Soft window light top-left at 45 degrees. 8K, no text, no watermark",
+    "{product} flat lay top-down as hero. Surrounding: {props}. {background} background. Color: {colors}. Soft window light top-left. Clean aesthetic, 8K",
+    "Professional {category} flat lay, top-down editorial. {product} centered with curated objects. {background} background. Strict color control. 8K, magazine quality, no text"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 71 - 0
fmode-image-set/mcp/catalog/templates/zh/04-detail-macro.json

@@ -0,0 +1,71 @@
+{
+  "locale": "zh",
+  "id": "detail-macro",
+  "name": "细节微距图",
+  "keywords": [
+    "细节图",
+    "微距",
+    "macro",
+    "特写",
+    "close-up",
+    "detail shot",
+    "细节展示"
+  ],
+  "trigger_phrases": [
+    "细节图",
+    "微距图",
+    "产品特写",
+    "材质特写",
+    "detail shot",
+    "macro photography"
+  ],
+  "prompt_template": {
+    "type": "macro product photography",
+    "subject": "{product_description}, extreme close-up on {focus_area}",
+    "detail": "visible {texture_description}",
+    "lighting": "{lighting_style}",
+    "camera": "shot with {camera_setup}",
+    "quality": "ultra macro detail, 8K, professional product photography"
+  },
+  "defaults": {
+    "focus_area": "texture and material quality",
+    "detail": "fine texture, material grain, surface quality",
+    "lighting": "soft directional lighting highlighting texture",
+    "camera": "dedicated macro lens setup"
+  },
+  "variants": {
+    "texture": {
+      "description": "材质纹理展示",
+      "overrides": {
+        "focus_area": "material surface showing grain and quality"
+      }
+    },
+    "formula": {
+      "description": "产品配方展示",
+      "overrides": {
+        "focus_area": "product formula showing cream/liquid texture",
+        "detail": "formula consistency, light reflection"
+      }
+    },
+    "craftsmanship": {
+      "description": "工艺细节展示",
+      "overrides": {
+        "focus_area": "stitching, joints, edges, manufacturing precision"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "show cream formula texture, shimmer particles, light reflection",
+    "electronics": "show port precision, material finish, button detail",
+    "food": "show ingredient texture, cross-section, freshness",
+    "fashion": "show fabric weave, stitching quality, hardware detail",
+    "jewelry": "show gemstone cut, metal finish, clasp detail"
+  },
+  "examples": [
+    "Extreme close-up beauty macro, Canon EOS R5 100mm f/2.8 macro. {product} filling 80% of frame, focusing on {focus_area}. Visible pores, fine lines, natural imperfections, NOT retouched. Formula visible with realistic light reflection. Natural side lighting. 8K",
+    "Cinematic {category} close-up. {product} detail showing {texture_description}. Formula/texture visible between fingertips with subtle light reflection. Soft blur background. Dramatic side lighting, warm tones, 8K",
+    "Macro detail of {product}, Sony A7R V 90mm macro, focus stacking. Extreme detail showing {focus_area}. Professional product photography, 8K"
+  ],
+  "anti_ai_tips": "For hand shots: specify real skin (visible knuckle lines, slight dryness on cuticles, natural warm tone). NOT retouched or smoothed.",
+  "supports_image_reference": true
+}

+ 85 - 0
fmode-image-set/mcp/catalog/templates/zh/05-poster-banner.json

@@ -0,0 +1,85 @@
+{
+  "locale": "zh",
+  "id": "poster-banner",
+  "name": "促销海报/Banner",
+  "keywords": [
+    "海报",
+    "poster",
+    "banner",
+    "促销",
+    "广告图",
+    "活动图",
+    "promotion",
+    "sale"
+  ],
+  "trigger_phrases": [
+    "促销海报",
+    "banner",
+    "广告图",
+    "活动海报",
+    "促销图",
+    "campaign poster",
+    "sale banner"
+  ],
+  "prompt_template": {
+    "type": "promotional poster design",
+    "subject": "{product_description}",
+    "background": "{background_description}",
+    "headline": "{headline_text}",
+    "subtitle": "{subtitle_text}",
+    "price": "{price_info}",
+    "cta": "{cta_text}",
+    "style": "{visual_style}",
+    "quality": "high-resolution professional marketing design"
+  },
+  "defaults": {
+    "background": "gradient matching brand aesthetic",
+    "headline": "headline text",
+    "subtitle": "subtitle or description",
+    "price": "",
+    "cta": "Shop Now",
+    "style": "clean minimalist with accent colors"
+  },
+  "variants": {
+    "luxury": {
+      "description": "高端奢华风格",
+      "overrides": {
+        "background": "full-bleed rich gradient, gold accents",
+        "style": "luxury magazine editorial, gold foil, pearl embellishments"
+      }
+    },
+    "minimal": {
+      "description": "极简现代风格",
+      "overrides": {
+        "background": "clean white or light gradient",
+        "style": "minimalist modern typography, generous white space"
+      }
+    },
+    "festive": {
+      "description": "节日主题风格",
+      "overrides": {
+        "background": "festive themed gradient with decorative elements",
+        "style": "festive, cultural elements, seasonal motifs"
+      }
+    },
+    "flash-sale": {
+      "description": "限时折扣风格",
+      "overrides": {
+        "style": "bold attention-grabbing, high contrast, urgency elements"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "rose gold accents, elegant serif fonts, luxury gift aesthetic",
+    "electronics": "dark backgrounds, neon accents, futuristic typography",
+    "food": "warm colors, dynamic food elements, freshness cues",
+    "fashion": "editorial style, model inclusion, aspirational lifestyle"
+  },
+  "examples": [
+    "Luxury {category} campaign poster. Full-bleed gradient ivory to rose. Top: {headline} elegant serif. Center: {product} with decorative elements. {subtitle}. Bottom left: price {price} original crossed out. Bottom right: brand logo and {cta}. Gold foil accents, premium aesthetic, 2000x3000px",
+    "{category} promotional poster. {product} centered on {background}. Bold {headline} at top, price {price}, {cta} button. Clean minimalist, accent colors, professional layout, 2000x2000px",
+    "Holiday {category} gift campaign. {background} with decorative elements. Center: {product} with gift wrapping. Headline {headline} modern script. Price tag {price} decorative frame. Festive sophisticated, warm cinematic lighting, 1080x1350px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 77 - 0
fmode-image-set/mcp/catalog/templates/zh/06-social-media.json

@@ -0,0 +1,77 @@
+{
+  "locale": "zh",
+  "id": "social-media",
+  "name": "社交媒体素材",
+  "keywords": [
+    "社交媒体",
+    "小红书",
+    "instagram",
+    "tiktok",
+    "种草",
+    "社媒",
+    "social media"
+  ],
+  "trigger_phrases": [
+    "社交媒体图",
+    "小红书图",
+    "Instagram",
+    "TikTok",
+    "种草图",
+    "社媒素材",
+    "social media post"
+  ],
+  "prompt_template": {
+    "type": "social media content",
+    "subject": "{product_description}",
+    "platform": "{platform_style}",
+    "composition": "phone camera perspective, slightly off-center",
+    "lighting": "natural indoor lighting",
+    "overlay": "{overlay_elements}",
+    "quality": "authentic smartphone photography aesthetic"
+  },
+  "defaults": {
+    "platform": "Instagram post style",
+    "composition": "casual phone framing, slightly imperfect",
+    "lighting": "natural window light, warm color tone",
+    "overlay": "minimal stickers and text badges"
+  },
+  "variants": {
+    "xiaohongshu": {
+      "description": "小红书种草风格",
+      "overrides": {
+        "platform": "Xiaohongshu RED app lifestyle photo, iPhone shot",
+        "composition": "slightly tilted like quick grab shot",
+        "lighting": "iPhone warm auto-white-balance, Kodak Portra 400 color feel",
+        "overlay": "Xiaohongshu sticker, casual text, star rating"
+      }
+    },
+    "instagram": {
+      "description": "Instagram帖子风格",
+      "overrides": {
+        "platform": "Instagram post premium aesthetic",
+        "composition": "clean framing with visual hierarchy",
+        "overlay": "engagement badges, hashtag elements"
+      }
+    },
+    "tiktok": {
+      "description": "TikTok/Reels封面风格",
+      "overrides": {
+        "platform": "TikTok/Reels thumbnail, vertical format",
+        "overlay": "trending audio icon, duet prompt, engagement metrics"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "show product in use on skin, texture close-up, real results",
+    "food": "overhead dish/drink shot, appetizing colors, 45-degree angle",
+    "fashion": "mirror selfie or outfit grid, casual styling, real-person aesthetic",
+    "home": "corner shot in styled room, cozy atmosphere, warm tones"
+  },
+  "examples": [
+    "Ultra-realistic Xiaohongshu RED product lifestyle photo, iPhone 15 Pro, NOT professional photographer. Slightly tilted angle, {product} on {surface}, lid off showing texture. Environmental details: slight water stain, natural shadows, lived-in feel. iPhone warm auto-white-balance, natural noise, NOT sharpened, Kodak Portra 400 feel, NOT AI-generated look, 8K, 1080x1350px",
+    "Instagram post format. {product} with overlay text, clean aesthetic. Feature cards showing benefits. Engagement icons. Hashtag tags. Square format, lifestyle aesthetic, 1080x1080px",
+    "Premium social media content, TikTok thumbnail. {product} in lifestyle setting. Timestamp badge, engagement metrics, product card. Trending audio icon. Color gradient frame. Viral-worthy composition, 1080x1920px"
+  ],
+  "anti_ai_tips": "Specify phone model. Add imperfection: noise, warm cast, not centered, slight blur. Use 'NOT AI-generated look'. Reference Kodak Portra 400 tone. Show lived-in environment.",
+  "supports_image_reference": true
+}

+ 84 - 0
fmode-image-set/mcp/catalog/templates/zh/07-ugc-style.json

@@ -0,0 +1,84 @@
+{
+  "locale": "zh",
+  "id": "ugc-style",
+  "name": "UGC风格/买家秀",
+  "keywords": [
+    "UGC",
+    "买家秀",
+    "用户生成",
+    "真实用户",
+    "user generated",
+    "GRWM",
+    "真实感"
+  ],
+  "trigger_phrases": [
+    "UGC图",
+    "买家秀",
+    "用户图",
+    "真实买家",
+    "GRWM",
+    "user-generated",
+    "真实分享"
+  ],
+  "prompt_template": {
+    "type": "authentic UGC snapshot",
+    "subject": "{product_description}",
+    "style": "smartphone front camera selfie, casual snapshot, NOT professional photography",
+    "environment": "real lived-in space, slightly messy, NOT styled",
+    "lighting": "indoor lighting with warm yellow cast, uneven",
+    "imperfection": "visible noise/grain, imperfect framing, off-center, slightly tilted",
+    "quality": "raw phone photo with natural imperfections, NOT AI-generated look, NOT studio quality"
+  },
+  "defaults": {
+    "style": "iPhone front camera, candid snapshot",
+    "environment": "real bathroom or bedroom, lived-in details",
+    "lighting": "warm indoor overhead lighting, slight yellow cast",
+    "imperfection": "noise in shadows, not oversharpened, slight highlight overexposure"
+  },
+  "variants": {
+    "mirror-selfie": {
+      "description": "浴室镜自拍",
+      "overrides": {
+        "style": "iPhone front camera mirror selfie",
+        "environment": "bathroom mirror, slight condensation and fog marks",
+        "imperfection": "phone case at edge, finger smudge on mirror"
+      }
+    },
+    "ccd-retro": {
+      "description": "CCD复古胶片感",
+      "overrides": {
+        "style": "vintage 2005 CCD digicam photo, direct flash, cheap camera aesthetic",
+        "lighting": "harsh direct on-camera flash, strong warm yellow-green color shift, blown-out highlights",
+        "imperfection": "heavy film grain, overexposed skin on forehead and nose, flash hotspot, chromatic aberration at edges, red date stamp bottom-right corner"
+      }
+    },
+    "grwm": {
+      "description": "GRWM跟我一起准备",
+      "overrides": {
+        "style": "Get Ready With Me video thumbnail, morning routine",
+        "environment": "vanity mirror, slightly messy counter"
+      }
+    },
+    "unboxing": {
+      "description": "开箱分享",
+      "overrides": {
+        "style": "casual unboxing photo on bed or desk",
+        "environment": "bed with wrinkled sheets or cluttered desk"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "product in use on real skin, visible pores, bathroom setting",
+    "food": "phone snap on table, casual restaurant or home",
+    "fashion": "mirror selfie wearing item, bedroom or fitting room",
+    "electronics": "desk setup with product in use, realistic cables",
+    "home": "product in actual living space, not perfectly styled"
+  },
+  "examples": [
+    "Bathroom mirror selfie, iPhone front camera. Person with {product}. Mirror has slight condensation. Warm yellowish indoor lighting. {product} label recognizable but not centered. Skin: visible pores, slight redness, NOT flawless. Warm yellow cast, phone noise, natural unposed, NOT AI-generated look, authentic UGC, 1080x1350px",
+    "Vintage 2005 CCD digicam photo, harsh direct flash. Heavy film grain, blown-out highlights on face, strong yellow-green color cast, chromatic aberration at edges. Person holding {product} with casual peace sign. Genuine candid expression. Dim bedroom with string lights, unmade bed. Off-center, slightly tilted composition. Red date stamp '06.12.25' in bottom-right corner. Low resolution, NOT sharp, NOT AI-generated look, raw snapshot, 1080x1350px",
+    "Authentic GRWM photo. Person in casual setting showing {product}. Natural imperfect lighting, slightly messy background. Phone selfie angle. Genuine expression. Warm tones, candid smartphone photography, NOT AI-generated look, 1080x1620px"
+  ],
+  "anti_ai_tips": "CRITICAL: (1) Specify phone model (iPhone 14 Pro/15), (2) Add imperfection (pores, noise, warm cast, off-center), (3) Candid language (NOT professional), (4) Real environment (slightly messy), (5) Avoid AI words (no perfect, flawless, stunning), (6) State 'NOT AI-generated look', (7) Reference Kodak Portra 400 tone",
+  "supports_image_reference": true
+}

+ 74 - 0
fmode-image-set/mcp/catalog/templates/zh/08-model-showcase.json

@@ -0,0 +1,74 @@
+{
+  "locale": "zh",
+  "id": "model-showcase",
+  "name": "模特展示图",
+  "keywords": [
+    "模特",
+    "model",
+    "人物展示",
+    "模特图",
+    "真人展示",
+    "人物图"
+  ],
+  "trigger_phrases": [
+    "模特图",
+    "真人展示",
+    "人物图",
+    "model photo",
+    "模特展示",
+    "人物展示图"
+  ],
+  "prompt_template": {
+    "type": "editorial beauty/fashion photography",
+    "subject": "{person_description} with {product_description}",
+    "camera": "shot with {camera_setup}",
+    "lighting": "natural window light creating realistic highlights",
+    "skin_detail": "visible pores, natural skin texture, NOT retouched",
+    "expression": "{expression_description}",
+    "quality": "professional editorial photography, 8K"
+  },
+  "defaults": {
+    "camera": "Canon EOS R5, 85mm f/1.4 lens",
+    "lighting": "natural window light from left, realistic highlights",
+    "skin_detail": "visible pores, natural under-eye shadows, slight texture unevenness",
+    "expression": "authentic relaxed, subtle natural asymmetry"
+  },
+  "variants": {
+    "beauty-closeup": {
+      "description": "美妆极致特写",
+      "overrides": {
+        "camera": "Canon EOS R5, 100mm f/2.8 macro",
+        "skin_detail": "incredibly detailed: pores, fine lines, freckles, NOT retouched",
+        "expression": "eyes half-closed, peaceful self-care moment"
+      }
+    },
+    "fashion-full": {
+      "description": "时尚全身展示",
+      "overrides": {
+        "camera": "Canon EOS R5, 50mm f/1.2",
+        "expression": "confident direct gaze, editorial pose"
+      }
+    },
+    "candid": {
+      "description": "自然抓拍",
+      "overrides": {
+        "camera": "iPhone 15 Pro main camera",
+        "expression": "genuine candid, mid-action",
+        "skin_detail": "natural texture, slight blemishes, relatable"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "extreme close-up on application moment, formula texture on skin, real skin mandatory",
+    "fashion": "full outfit showcase, pose highlighting garment, editorial lighting",
+    "accessories": "product being worn/used, hand or body detail, lifestyle context",
+    "sports": "active pose in appropriate setting, show performance"
+  },
+  "examples": [
+    "Extreme close-up beauty macro, Canon EOS R5 100mm f/2.8 macro. Face filling 80% of frame, {product} being applied on {focus_area}. Skin incredibly detailed: pores, fine lines, natural imperfections, NOT retouched. Formula visible with realistic reflection. Natural side lighting. NOT AI-generated look, 8K, 1080x1350px",
+    "Fashion editorial. {person} with {product}, Canon EOS R5 85mm f/1.4. Natural window light, visible pores, natural shadows, authentic expression, soft bokeh, warm golden tones, 1080x1350px",
+    "Candid beauty moment. {person} with {product}, iPhone front camera. Natural lighting, visible pores and texture, relaxed candid, NOT AI-generated look, documentary style, 1080x1350px"
+  ],
+  "anti_ai_tips": "MANDATORY: (1) Real camera (Canon EOS R5 / Sony A7 IV), (2) Visible skin imperfections (pores, uneven tone, blemishes, fine lines), (3) Natural expression asymmetry, (4) 'NOT retouched, NOT AI-generated look', (5) Real lighting (natural window, NOT studio-perfect), (6) Natural hand details (knuckle lines, cuticle texture)",
+  "supports_image_reference": true
+}

+ 71 - 0
fmode-image-set/mcp/catalog/templates/zh/09-before-after.json

@@ -0,0 +1,71 @@
+{
+  "locale": "zh",
+  "id": "before-after",
+  "name": "使用前后对比图",
+  "keywords": [
+    "对比",
+    "before after",
+    "前后",
+    "效果对比",
+    "transformation",
+    "变化图"
+  ],
+  "trigger_phrases": [
+    "对比图",
+    "前后对比",
+    "before after",
+    "效果展示",
+    "使用效果",
+    "transformation"
+  ],
+  "prompt_template": {
+    "type": "before/after comparison visualization",
+    "subject": "{product_description}",
+    "before_state": "{before_description}",
+    "after_state": "{after_description}",
+    "metrics": "{data_points}",
+    "divider": "elegant center divider",
+    "quality": "professional campaign photography"
+  },
+  "defaults": {
+    "before_state": "dull, dry, uneven appearance",
+    "after_state": "radiant, smooth, healthy appearance",
+    "metrics": "improvement percentage",
+    "divider": "elegant gold line with arrow"
+  },
+  "variants": {
+    "clinical": {
+      "description": "临床数据风格",
+      "overrides": {
+        "metrics": "detailed data grid with 4+ metrics and percentage arrows"
+      }
+    },
+    "cinematic": {
+      "description": "电影感蜕变",
+      "overrides": {
+        "before_state": "moody dramatic, cool tone",
+        "after_state": "bright warm golden hour, warm tone",
+        "divider": "decorative vintage frame with product"
+      }
+    },
+    "simple": {
+      "description": "简洁信息图",
+      "overrides": {
+        "metrics": "simple progress bar with key metric"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "show skin texture change, include moisture/radiance percentage data",
+    "fitness": "body transformation, consistent pose and lighting",
+    "home": "room before/after renovation or cleaning",
+    "automotive": "vehicle before/after detailing"
+  },
+  "examples": [
+    "Cinematic before/after {category} transformation. Left: moody lighting showing {before}, cool tone. Right: bright golden hour showing {after}, warm tone. Center: decorative frame with product. Bottom: comparison data grid. Premium campaign layout, 8K, 1080x1620px",
+    "Premium before/after comparison. Left: {before_state}, caption 'Before'. Right: {after_state}, caption 'After'. Gold divider with arrow. Bottom: progress metrics {data}. Product thumbnail. Clean clinical aesthetic, 1080x1080px",
+    "{category} comparison visualization. Split: {before_state} left, {after_state} right. Key metrics below. White background, professional style, 1080x1080px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 70 - 0
fmode-image-set/mcp/catalog/templates/zh/10-packaging.json

@@ -0,0 +1,70 @@
+{
+  "locale": "zh",
+  "id": "packaging",
+  "name": "包装设计展示",
+  "keywords": [
+    "包装",
+    "packaging",
+    "礼盒",
+    "gift box",
+    "包装设计",
+    "unboxing",
+    "开箱"
+  ],
+  "trigger_phrases": [
+    "包装设计",
+    "包装展示",
+    "礼盒图",
+    "packaging design",
+    "开箱图",
+    "gift box"
+  ],
+  "prompt_template": {
+    "type": "packaging design visualization",
+    "subject": "{product_description}",
+    "display": "{packaging_components}",
+    "surface": "premium marble surface",
+    "decorative": "scattered luxury elements",
+    "lighting": "soft directional with metallic reflections",
+    "quality": "premium design presentation, 8K"
+  },
+  "defaults": {
+    "display": "full spread: product, outer box, tissue, ribbon, brand card",
+    "decorative": "gold foil, pearls, dried petals, silk fabric"
+  },
+  "variants": {
+    "luxury-gift": {
+      "description": "奢华礼盒",
+      "overrides": {
+        "display": "gift box opened: outer box, inner tray, product, ribbon, brand card",
+        "decorative": "gold foil, pearls, dried roses, silk scarf"
+      }
+    },
+    "minimal-eco": {
+      "description": "极简环保",
+      "overrides": {
+        "display": "sustainable packaging: recycled paper, soy ink, simple label",
+        "decorative": "natural elements, dried leaves, twine"
+      }
+    },
+    "unboxing": {
+      "description": "开箱体验",
+      "overrides": {
+        "display": "unboxing sequence: box opening, tissue reveal, product discovery"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "label design, ingredient preview, formula through transparent elements",
+    "food": "nutritional info area, ingredient imagery, freshness seals",
+    "fashion": "branded tissue, garment tag, care instructions, shopping bag",
+    "electronics": "box design, inner foam, cable organization, quick-start guide"
+  },
+  "examples": [
+    "Luxury {category} packaging concept. Full spread: (1) product with lid, label visible, (2) outer gift box: embossed pattern, gold logo, magnetic closure, (3) tissue with subtle watermark, (4) satin ribbon with monogram, (5) brand card gold foil, (6) sustainable fill. On marble with gold foil, pearls, dried petals. Soft directional lighting, metallic reflections. Premium presentation, 8K, 1080x1620px",
+    "Premium packaging showcase. {product} in gift box open, tissue, ribbon, brand card, luxury unboxing. Professional photography, 1080x1080px",
+    "{category} packaging concept on white. Multiple angles, clean presentation, design mockup, 1080x1080px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 71 - 0
fmode-image-set/mcp/catalog/templates/zh/11-infographic.json

@@ -0,0 +1,71 @@
+{
+  "locale": "zh",
+  "id": "infographic",
+  "name": "信息图/A+Content",
+  "keywords": [
+    "信息图",
+    "infographic",
+    "A+",
+    "详情页",
+    "卖点图",
+    "产品信息图",
+    "产品详情"
+  ],
+  "trigger_phrases": [
+    "信息图",
+    "产品信息图",
+    "详情页图",
+    "A+Content",
+    "卖点图",
+    "infographic"
+  ],
+  "prompt_template": {
+    "type": "e-commerce product infographic",
+    "subject": "{product_description}",
+    "features": "{feature_list}",
+    "layout": "structured sections with clear hierarchy",
+    "data_vis": "{data_elements}",
+    "color_scheme": "{brand_colors}",
+    "quality": "professional e-commerce design, mobile-friendly"
+  },
+  "defaults": {
+    "features": "4-6 key features with icons and descriptions",
+    "layout": "clean grid, product as focal point",
+    "data_vis": "simple charts or comparison tables",
+    "color_scheme": "brand-consistent palette"
+  },
+  "variants": {
+    "amazon-a-plus": {
+      "description": "亚马逊A+风格",
+      "overrides": {
+        "layout": "4-quadrant module with banner, feature blocks, comparison, badges",
+        "data_vis": "comparison table, ingredient chart, certification badges"
+      }
+    },
+    "feature-grid": {
+      "description": "卖点网格",
+      "overrides": {
+        "layout": "product centered with callout lines to feature blocks"
+      }
+    },
+    "story-flow": {
+      "description": "故事线信息流",
+      "overrides": {
+        "layout": "vertical story: problem → solution → features → results"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "ingredient breakdown with %, before/after data, dermatologist badges",
+    "electronics": "spec comparison, performance metrics, compatibility icons",
+    "food": "nutritional visualization, ingredient sourcing, recipe flow",
+    "fashion": "size guide, material composition, care instructions"
+  },
+  "examples": [
+    "Complex Amazon A+ Content module. Top banner with brand and tagline. Main: 4 quadrants each showing product detail with labeled feature: (1) {f1}, (2) {f2}, (3) {f3}, (4) {f4}. Below: comparison table. Bottom: spec chart and certification badges. {color} palette. Mobile-first, professional e-commerce, 2000x2500px",
+    "Product infographic. Left: {product} on gradient. Right: {n} feature blocks with icons vertically, callout lines connecting to product. Clean modern, {color} accents, 2000x2000px",
+    "{category} specification guide. Product centered with dimension callouts. Feature highlights with icons. Data visualization. Clean typography, mobile-friendly, 2000x2000px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 73 - 0
fmode-image-set/mcp/catalog/templates/zh/12-creative-concept.json

@@ -0,0 +1,73 @@
+{
+  "locale": "zh",
+  "id": "creative-concept",
+  "name": "创意概念广告图",
+  "keywords": [
+    "创意图",
+    "概念图",
+    "creative",
+    "概念广告",
+    "品牌广告",
+    "创意广告",
+    "concept art"
+  ],
+  "trigger_phrases": [
+    "创意广告",
+    "概念图",
+    "品牌广告",
+    "创意素材",
+    "品牌宣传",
+    "creative concept"
+  ],
+  "prompt_template": {
+    "type": "creative advertising photography",
+    "subject": "{product_description}",
+    "concept": "{creative_concept}",
+    "effects": "{special_effects}",
+    "color_palette": "{bold_palette}",
+    "art_direction": "{art_style}",
+    "quality": "award-winning advertising photography, ultra-detailed, cinematic"
+  },
+  "defaults": {
+    "concept": "dynamic product with unexpected visual elements",
+    "effects": "dramatic visual impact",
+    "color_palette": "bold unusual combinations",
+    "art_direction": "strong visual narrative"
+  },
+  "variants": {
+    "splash-dynamic": {
+      "description": "飞溅动态效果",
+      "overrides": {
+        "effects": "water splash / powder explosion frozen in motion, high-speed photography",
+        "art_direction": "dramatic, vivid, commercial style"
+      }
+    },
+    "surreal": {
+      "description": "超现实概念",
+      "overrides": {
+        "concept": "product in unexpected surreal environment",
+        "effects": "gravity-defying, impossible geometry"
+      }
+    },
+    "minimal-art": {
+      "description": "极简艺术风格",
+      "overrides": {
+        "concept": "product as art object in minimalist composition",
+        "color_palette": "monochromatic or two-tone"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "floating product with splash, ethereal lighting, formula particles",
+    "electronics": "holographic interfaces, data visualization, futuristic",
+    "food": "ingredient explosion, dynamic pour/splash, steam and fire",
+    "fashion": "editorial art direction, dramatic poses, fabric in motion"
+  },
+  "examples": [
+    "{product} floating in {dramatic_environment}, {effects: splash/particles/smoke}, {bold colors}, art direction: {style}, ultra-detailed, cinematic lighting, award-winning advertising, 8K",
+    "{product} with dynamic elements frozen in motion, high-speed photography, dramatic lighting, vivid colors, commercial {category}. Bold concept, 8K",
+    "Creative advertising. {product} in {artistic setting}. {colors} palette. Dramatic lighting with {effects}. Art direction: {style}. Award-winning, cinematic, 8K"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 73 - 0
fmode-image-set/mcp/catalog/templates/zh/13-size-spec.json

@@ -0,0 +1,73 @@
+{
+  "locale": "zh",
+  "id": "size-spec",
+  "name": "尺寸规格+使用步骤图",
+  "keywords": [
+    "尺寸",
+    "规格",
+    "尺寸图",
+    "使用步骤",
+    "规格图",
+    "dimension",
+    "how to use",
+    "使用指南"
+  ],
+  "trigger_phrases": [
+    "尺寸图",
+    "规格图",
+    "使用步骤图",
+    "尺寸标注",
+    "how to use",
+    "使用指南",
+    "产品规格"
+  ],
+  "prompt_template": {
+    "type": "product specification and usage guide infographic",
+    "subject": "{product_description}",
+    "dimensions": "{size_annotations}",
+    "steps": "{usage_steps}",
+    "extras": "{additional_info}",
+    "style": "clean professional layout",
+    "quality": "professional e-commerce infographic"
+  },
+  "defaults": {
+    "dimensions": "height, width with measurement lines and arrows",
+    "steps": "3-4 step numbered usage guide with icons",
+    "extras": "ingredient highlights or feature badges",
+    "style": "clean white, professional typography"
+  },
+  "variants": {
+    "premium-editorial": {
+      "description": "高端杂志风格",
+      "overrides": {
+        "style": "luxury editorial with decorative elements, gold accents, premium typography"
+      }
+    },
+    "technical": {
+      "description": "技术规格风格",
+      "overrides": {
+        "dimensions": "detailed specs with reference objects for scale",
+        "extras": "comparison charts, certification badges, data grids"
+      }
+    },
+    "ritual-guide": {
+      "description": "使用仪式指南",
+      "overrides": {
+        "steps": "decorative numbered ritual guide with small illustrations"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "dimension callout badge, usage ritual steps, ingredient percentages",
+    "electronics": "precise dimensions with comparison objects, port specs",
+    "food": "serving size, preparation steps, nutritional highlights",
+    "fashion": "size chart with body measurements, care instructions"
+  },
+  "examples": [
+    "Premium {category} specification and ritual guide, luxury editorial. White background, subtle border. Top: {product} centered with dimension badge. Left: 4-step usage guide with decorative icons. Right: highlights with colored pills. Bottom: {data}. Decorative corner accents. Magazine-quality, 2000x2800px",
+    "Product detail card. Left: {product} with dimension annotations {measurements}, arrows and lines. Right: {n}-step usage guide with icons. White background, minimal, professional, 2000x1500px",
+    "{category} specification card. {product} with detailed dimensions and reference object. Usage steps with icons. Key features. Clean layout, mobile-friendly, 2000x2000px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 72 - 0
fmode-image-set/mcp/catalog/templates/zh/14-multi-product.json

@@ -0,0 +1,72 @@
+{
+  "locale": "zh",
+  "id": "multi-product",
+  "name": "多产品套装/组合展示",
+  "keywords": [
+    "套装",
+    "组合",
+    "多产品",
+    "bundle",
+    "gift set",
+    "礼盒",
+    "系列展示"
+  ],
+  "trigger_phrases": [
+    "套装图",
+    "多产品展示",
+    "产品组合",
+    "bundle image",
+    "礼盒展示",
+    "系列图",
+    "gift set"
+  ],
+  "prompt_template": {
+    "type": "multi-product bundle photography",
+    "subject": "{product_set_description}",
+    "arrangement": "organized composition, all products clearly visible",
+    "background": "{background_description}",
+    "decorative": "{decorative_elements}",
+    "quality": "professional product photography, 8K"
+  },
+  "defaults": {
+    "arrangement": "organized with consistent spacing, all visible, no overlap",
+    "background": "clean suitable for showcase",
+    "decorative": "luxury elements for gift-ready presentation"
+  },
+  "variants": {
+    "gift-set": {
+      "description": "礼盒套装",
+      "overrides": {
+        "arrangement": "products in gift box with cards",
+        "decorative": "satin ribbon, gold foil, dried flowers, pearls"
+      }
+    },
+    "routine-set": {
+      "description": "使用程序套装",
+      "overrides": {
+        "arrangement": "products in order of use, step indicators"
+      }
+    },
+    "lineup": {
+      "description": "产品线排列",
+      "overrides": {
+        "arrangement": "neat row, hero centered and larger",
+        "decorative": "minimal, clean",
+        "background": "white seamless"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "complete routine (cleanser → toner → serum → cream), gift-ready",
+    "food": "product range, variety pack, flavor assortment",
+    "fashion": "outfit coordination pieces, colorway options",
+    "home": "collection of coordinating items"
+  },
+  "examples": [
+    "Luxury {category} gift set on premium surface: {product_list}. Organized composition with product cards. Scattered: gold foil, pearls, dried flowers, velvet ribbon. Bottom left: set description. Bottom right: price with original crossed out. Soft directional lighting, premium photography, 8K",
+    "Premium {category} routine set. Main product centered, lid open showing texture. Surrounding: routine products arranged naturally. Soft background, decorative elements. Warm studio lighting, gift-ready, 2000x2000px",
+    "{category} gift set display. {n} products neat row, consistent spacing. Clean photography, soft shadows, professional bundle image, no text, 2000x2000px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 73 - 0
fmode-image-set/mcp/catalog/templates/zh/15-livestream.json

@@ -0,0 +1,73 @@
+{
+  "locale": "zh",
+  "id": "livestream",
+  "name": "电商直播间场景",
+  "keywords": [
+    "直播",
+    "livestream",
+    "直播间",
+    "电商直播",
+    "直播截图",
+    "抖音直播",
+    "带货"
+  ],
+  "trigger_phrases": [
+    "直播图",
+    "直播间截图",
+    "电商直播",
+    "livestream",
+    "抖音直播",
+    "带货图",
+    "live commerce"
+  ],
+  "prompt_template": {
+    "type": "e-commerce livestream screenshot",
+    "host": "{host_description}",
+    "product_action": "{product_demonstration}",
+    "background": "real home-style livestream setup",
+    "ui_overlay": "live commerce UI elements",
+    "lighting": "ring light from front, warm indoor overhead mixing in",
+    "quality": "authentic phone screen capture, NOT AI-generated look"
+  },
+  "defaults": {
+    "host": "friendly person, natural expression, visible skin texture, NOT retouched",
+    "product_action": "holding product close to camera showing details",
+    "background": "organized but real product shelf, LED strip, small plants",
+    "ui_overlay": "viewer count, LIVE badge, comments, product card, buy button",
+    "lighting": "ring light circular catchlight in eyes, warm indoor overhead"
+  },
+  "variants": {
+    "douyin": {
+      "description": "抖音直播风格",
+      "overrides": {
+        "ui_overlay": "Douyin UI: LIVE badge, viewer count, scrolling comments, product card with price and buy button, gift icons, shopping cart"
+      }
+    },
+    "taobao": {
+      "description": "淘宝直播风格",
+      "overrides": {
+        "ui_overlay": "Taobao Live UI: product listing, coupon badges, viewer count, chat"
+      }
+    },
+    "setup": {
+      "description": "直播间布景展示",
+      "overrides": {
+        "ui_overlay": "no UI overlay, showing physical setup",
+        "background": "full view of studio with lighting, backdrop, shelves"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "host demonstrating application, showing texture, real-time swatching",
+    "fashion": "host wearing/showing clothing, fit demo, fabric close-up",
+    "food": "host tasting/preparing, showing freshness, unboxing",
+    "electronics": "host demonstrating features, hands-on product demo"
+  },
+  "examples": [
+    "Ultra-realistic Douyin livestream screenshot, phone screen capture. Host in casual {outfit}, minimal makeup, visible pores and skin texture, NOT retouched. Demonstrating {product}, showing details, other hand gesturing. Ring light natural shine, NOT plastic AI look. Background: real home setup - product shelf, whiteboard with prices, string lights, coffee mug. Phone UI: LIVE badge, viewer count, scrolling comments, product card with price, yellow buy button. Warm cast, slight noise, Kodak Portra 400 tone, NOT AI-generated look, 1080x1920px",
+    "Phone screenshot of livestream. Host holding {product}. Ring light catchlight. Product shelf behind. Phone noise, warm indoor, NOT professional, real livestream feel, 1080x1920px",
+    "Livestream scene. Host with {product} in home studio. Ring light, visible skin texture. Product display. Live UI elements. Authentic phone capture, 1080x1920px"
+  ],
+  "anti_ai_tips": "CRITICAL: (1) Phone screen capture style, (2) Ring light: circular catchlight in eyes, (3) Real skin: pores, undereye darkness, NOT smoothed, (4) Real environment: slightly messy, real objects, (5) Warm yellowish cast, (6) Slight noise, (7) 'NOT AI-generated look, NOT plastic smooth', (8) Kodak Portra 400 tone, (9) Realistic UI overlay",
+  "supports_image_reference": true
+}

+ 71 - 0
fmode-image-set/mcp/catalog/templates/zh/16-try-on-virtual.json

@@ -0,0 +1,71 @@
+{
+  "locale": "zh",
+  "id": "try-on-virtual",
+  "name": "虚拟试穿/产品融入场景",
+  "keywords": [
+    "试穿",
+    "融入",
+    "虚拟试穿",
+    "try on",
+    "场景融合",
+    "产品融入",
+    "product placement"
+  ],
+  "trigger_phrases": [
+    "虚拟试穿",
+    "产品融入场景",
+    "try on",
+    "场景融合",
+    "产品植入",
+    "场景化融入"
+  ],
+  "prompt_template": {
+    "type": "product integration / virtual try-on",
+    "subject": "{product_description} naturally integrated into {scene_type}",
+    "setting": "{detailed_scene}",
+    "atmosphere": "{mood_and_lighting}",
+    "integration": "product prominently displayed, naturally belonging in scene",
+    "quality": "cinematic lifestyle photography, 8K"
+  },
+  "defaults": {
+    "setting": "luxury interior or lifestyle environment matching product",
+    "atmosphere": "warm, inviting, aspirational",
+    "integration": "product as natural focal point"
+  },
+  "variants": {
+    "interior-luxury": {
+      "description": "奢华室内场景",
+      "overrides": {
+        "setting": "high-end spa/hotel/luxury home, marble, gold fixtures",
+        "atmosphere": "cinematic depth, warm grading, premium"
+      }
+    },
+    "outdoor-natural": {
+      "description": "户外自然场景",
+      "overrides": {
+        "setting": "natural outdoor, organic elements",
+        "atmosphere": "golden hour sunlight, natural and free"
+      }
+    },
+    "studio-editorial": {
+      "description": "棚拍编辑风格",
+      "overrides": {
+        "setting": "professional studio with styled backdrop",
+        "atmosphere": "controlled studio lighting, editorial quality"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "spa bathroom, vanity mirror, botanical elements, skincare ritual",
+    "fashion": "model wearing item in appropriate setting, natural styling",
+    "furniture": "product in complete room setting, complementary decor",
+    "electronics": "modern desk setup, in-use context, tech-forward"
+  },
+  "examples": [
+    "Cinematic luxury {category} integration. High-end {interior} with {materials}. {product} prominently displayed. Morning sunlight through frosted glass, ethereal light rays. Fresh flowers, botanical elements. Product label visible, ingredient card beside it. Decorative: tray, pearls, gold foil. Color: {colors}. Cinematic depth, warm grading, premium, 8K, 1080x1620px",
+    "{product} naturally in {scene}, morning sunlight, soft shadows, botanical touches, warm atmosphere, professional lifestyle photography, 8K, 1080x1350px",
+    "{product} integrated into complete {category} lifestyle scene. {person} at {location}, products arranged naturally, natural light, warm atmosphere. Professional lifestyle, 1080x1350px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 84 - 0
fmode-image-set/mcp/catalog/templates/zh/17-exploded-view.json

@@ -0,0 +1,84 @@
+{
+  "locale": "zh",
+  "id": "exploded-view",
+  "name": "技术拆解/爆炸图",
+  "keywords": [
+    "拆解图",
+    "爆炸图",
+    "exploded view",
+    "技术图",
+    "结构图",
+    "内部结构",
+    "拆机",
+    "teardown",
+    "cross-section"
+  ],
+  "trigger_phrases": [
+    "产品拆解",
+    "爆炸图",
+    "内部结构",
+    "技术拆解",
+    "exploded view",
+    "产品组件",
+    "零件展示"
+  ],
+  "prompt_template": {
+    "type": "technical product exploded view",
+    "subject": "{product_description}",
+    "components": "{component_list}",
+    "arrangement": "vertical stack with increasing spacing",
+    "background": "clean light gray or white",
+    "labels": "thin leader lines to callout labels with name and key spec",
+    "quality": "8K, technical illustration, precise component rendering"
+  },
+  "defaults": {
+    "arrangement": "vertical exploded stack",
+    "background": "clean light gray",
+    "labels": "component name + material + core spec"
+  },
+  "variants": {
+    "blueprint": {
+      "description": "蓝图风格",
+      "overrides": {
+        "background": "dark charcoal with cyan grid lines",
+        "labels": "white and gold typography, blueprint aesthetic"
+      }
+    },
+    "minimal": {
+      "description": "极简白底",
+      "overrides": {
+        "background": "pure white, no grid",
+        "labels": "minimal black text, thin gray leader lines"
+      }
+    },
+    "apple-style": {
+      "description": "Apple产品拆解风格",
+      "overrides": {
+        "arrangement": "isometric floating with precise spacing",
+        "background": "deep gradient from charcoal to black",
+        "labels": "elegant sans-serif, golden accent lines"
+      }
+    },
+    "editorial": {
+      "description": "杂志编辑风格",
+      "overrides": {
+        "background": "warm off-white with subtle texture",
+        "labels": "serif font callouts, hand-drawn arrow style"
+      }
+    }
+  },
+  "category_tips": {
+    "electronics": "highlight circuit boards, chips, battery modules with spec labels",
+    "audio": "show speaker drivers, diaphragms, ANC modules, battery size",
+    "wearables": "include sensors, display panel, waterproof seals, strap mechanism",
+    "home_appliance": "show motor, filter system, internal wiring, control board",
+    "phone_accessories": "highlight charging coils, magnet arrays, protective layers",
+    "camera": "show lens elements, sensor, image processor, stabilization unit"
+  },
+  "examples": [
+    "Product exploded view. {product} disassembled into 5 components floating in mid-air with spacing, arranged vertically. Clean light gray background. Soft shadows beneath each part. Technical illustration style, 8K, no text",
+    "{product} technical exploded view infographic. Components floating in isometric arrangement with thin connecting lines. Each component labeled with name and key spec. Clean white background, blueprint-inspired accent lines, 8K"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 79 - 0
fmode-image-set/mcp/catalog/templates/zh/18-ghost-mannequin.json

@@ -0,0 +1,79 @@
+{
+  "locale": "zh",
+  "id": "ghost-mannequin",
+  "name": "隐形模特",
+  "keywords": [
+    "隐形模特",
+    "ghost mannequin",
+    "invisible model",
+    "3D服装",
+    "服装展示",
+    "无人模特",
+    "中空模特",
+    "flat mannequin"
+  ],
+  "trigger_phrases": [
+    "隐形模特",
+    "ghost mannequin",
+    "3D服装展示",
+    "无人穿着展示",
+    "服装立体展示",
+    "invisible mannequin"
+  ],
+  "prompt_template": {
+    "type": "ghost mannequin product photography",
+    "subject": "{product_description}",
+    "form": "invisible mannequin creating natural 3D body shape",
+    "details": "natural shoulder slope, bust/chest contour, waist curve visible",
+    "background": "clean white or soft gray gradient",
+    "lighting": "three-point studio setup, gentle dimension",
+    "quality": "8K, fashion e-commerce standard photography"
+  },
+  "defaults": {
+    "form": "natural body contours - shoulders, bust, waist",
+    "background": "soft warm gray gradient",
+    "lighting": "three-point studio setup creating gentle dimension"
+  },
+  "variants": {
+    "white-clean": {
+      "description": "纯白干净",
+      "overrides": {
+        "background": "pure white seamless",
+        "lighting": "bright even commercial lighting"
+      }
+    },
+    "editorial": {
+      "description": "杂志风格",
+      "overrides": {
+        "background": "moody dark gradient from charcoal to deep gray",
+        "lighting": "spotlight from above creating dramatic highlight on shoulders"
+      }
+    },
+    "editorial-detail": {
+      "description": "展示内衬细节",
+      "overrides": {
+        "details": "garment partially open revealing inner lining tag, rolled sleeves showing lining contrast"
+      }
+    },
+    "lifestyle": {
+      "description": "带环境道具",
+      "overrides": {
+        "background": "minimal studio with subtle editorial prop - single branch or flower in corner"
+      }
+    }
+  },
+  "category_tips": {
+    "shirts": "collar standing naturally, top buttons detail, cuff visibility",
+    "dresses": "natural waist cinch, skirt drape showing fabric weight, back zipper detail",
+    "coats": "architectural shoulder structure, fabric texture visible, button and pocket details",
+    "knitwear": "visible knit pattern and texture, natural stretch around body contours",
+    "tshirts": "casual relaxed drape, crew or v-neck sitting naturally, sleeve length proportion",
+    "activewear": "compression fit showing body contour, moisture-wicking fabric texture, logo placement"
+  },
+  "examples": [
+    "Ghost mannequin photography. {product} on invisible mannequin, natural body contours visible. Fabric drapes naturally showing material weight. Pure white background. Soft studio lighting, 8K, fashion e-commerce standard",
+    "Premium ghost mannequin photography. {product} on invisible mannequin, garment partially open revealing inner lining. Background: soft warm gray gradient. Three-point studio lighting, Phase One quality, 8K"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 84 - 0
fmode-image-set/mcp/catalog/templates/zh/19-multi-angle-grid.json

@@ -0,0 +1,84 @@
+{
+  "locale": "zh",
+  "id": "multi-angle-grid",
+  "name": "产品多角度网格",
+  "keywords": [
+    "多角度",
+    "网格",
+    "grid",
+    "多面",
+    "颜色展示",
+    "colorway",
+    "产品角度",
+    "catalog grid",
+    "产品目录"
+  ],
+  "trigger_phrases": [
+    "多角度展示",
+    "产品网格",
+    "多面展示",
+    "颜色对比",
+    "multi-angle",
+    "grid layout",
+    "产品目录图",
+    "多色展示"
+  ],
+  "prompt_template": {
+    "type": "product photography grid layout",
+    "subject": "{product_description}",
+    "grid": "2x2 or 3x3 equal squares",
+    "variation": "different angles or colorways",
+    "background": "clean white in each cell",
+    "separators": "thin white borders between cells",
+    "quality": "8K, catalog photography, uniform lighting across all cells"
+  },
+  "defaults": {
+    "grid": "2x2 grid, 4 views",
+    "variation": "front, side, back, top-down angles",
+    "background": "clean white per cell",
+    "separators": "thin white borders"
+  },
+  "variants": {
+    "angle-view": {
+      "description": "多角度视角",
+      "overrides": {
+        "variation": "front, side, back, top-down, 3/4 angle views",
+        "grid": "2x2 or 3x3"
+      }
+    },
+    "colorway": {
+      "description": "多配色展示",
+      "overrides": {
+        "variation": "same product in different colors, consistent 3/4 angle",
+        "grid": "3x3 for 9 colors or 2x3 for 6 colors"
+      }
+    },
+    "feature-grid": {
+      "description": "带功能标注网格",
+      "overrides": {
+        "variation": "each cell shows different product feature with label and icon",
+        "grid": "hero image left + 4 smaller shots right column"
+      }
+    },
+    "comparison": {
+      "description": "对比网格",
+      "overrides": {
+        "variation": "before/after or product vs competitor in side-by-side grid"
+      }
+    }
+  },
+  "category_tips": {
+    "beauty": "show bottle angle, cap detail, texture close-up, and open product",
+    "electronics": "show front face, side ports, back panel, and included accessories",
+    "fashion": "show front view, back view, detail close-up, and fabric texture",
+    "food": "show packaging front, back nutrition, open product, and serving suggestion",
+    "home": "show full product, detail angle, material texture, and in-context shot",
+    "sports": "show product front, side profile, sole/bottom, and action usage shot"
+  },
+  "examples": [
+    "2x2 product grid. {product} shown from 4 angles: front, side, back, top-down. Clean white background per cell. Thin borders. Uniform studio lighting, 8K, 2000x2000px",
+    "3x3 color variation grid. Same {product} in 9 different colors, identical 3/4 angle. Clean white cells, subtle drop shadow. Professional catalog, 8K, 2000x2000px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 85 - 0
fmode-image-set/mcp/catalog/templates/zh/20-magazine-editorial.json

@@ -0,0 +1,85 @@
+{
+  "locale": "zh",
+  "id": "magazine-editorial",
+  "name": "杂志大片/封面",
+  "keywords": [
+    "杂志",
+    "封面",
+    "杂志大片",
+    "editorial",
+    "magazine cover",
+    "时尚大片",
+    "Vogue",
+    "时尚摄影",
+    "fashion editorial"
+  ],
+  "trigger_phrases": [
+    "杂志封面",
+    "杂志大片",
+    "时尚大片",
+    "editorial",
+    "magazine cover",
+    "品牌大片",
+    "时尚杂志",
+    "封面图"
+  ],
+  "prompt_template": {
+    "type": "fashion/beauty magazine cover editorial",
+    "subject": "{product_description}",
+    "model": "person holding or wearing product in editorial pose",
+    "makeup_hair": "polished editorial beauty look",
+    "background": "studio backdrop in warm/tonal color",
+    "lighting": "beauty dish + rim light, editorial setup",
+    "layout": "magazine masthead area at top, barcode space at bottom",
+    "quality": "8K, Vogue-quality editorial photography"
+  },
+  "defaults": {
+    "lighting": "beauty dish from front-left, soft rim light on hair",
+    "background": "studio backdrop warm peach-nude tone",
+    "layout": "masthead area top, issue text bottom-left, barcode bottom-right"
+  },
+  "variants": {
+    "beauty-cover": {
+      "description": "美妆封面",
+      "overrides": {
+        "model": "close-up portrait with glowing skin, holding skincare near face",
+        "makeup_hair": "dewy natural, glossy lips, subtle smoky eye"
+      }
+    },
+    "fashion-cover": {
+      "description": "时尚封面",
+      "overrides": {
+        "model": "full or 3/4 body shot wearing fashion product, confident pose",
+        "makeup_hair": "bold editorial makeup, high-fashion styling"
+      }
+    },
+    "fragrance-editorial": {
+      "description": "香水大片",
+      "overrides": {
+        "model": "person in contemplative pose with fragrance bottle, atmospheric",
+        "background": "dark moody backdrop with atmospheric haze"
+      }
+    },
+    "minimal-editorial": {
+      "description": "极简杂志内页",
+      "overrides": {
+        "background": "clean white studio, no backdrop color",
+        "layout": "no masthead, clean full-bleed editorial image"
+      }
+    }
+  },
+  "category_tips": {
+    "skincare": "dewy skin, product held at chin level, beauty lighting",
+    "fragrance": "atmospheric smoke, contemplative mood, bottle prominent",
+    "fashion": "confident pose, outfit fully visible, dramatic lighting",
+    "jewelry": "close-up on hands/neck, editorial styling, luxury backdrop",
+    "haircare": "hair as hero element, movement and texture, beauty lighting",
+    "makeup": "bold lip or eye focus, product in hand, editorial beauty"
+  },
+  "examples": [
+    "Beauty magazine cover. Woman with glowing skin holding {product} near face. Beauty dish lighting, soft background. Top area for masthead. Vogue-quality editorial, 8K, 1080x1350px",
+    "Fashion editorial magazine cover. Striking woman wearing {product} in contemplative pose. Bold makeup, sleek hair. High-contrast editorial lighting. Magazine layout overlay with border frame. 8K, 1080x1350px"
+  ],
+  "anti_ai_tips": "For authentic editorial feel: specify actual camera (Phase One IQ4, Canon EOS R5 85mm f/1.2), add visible skin texture, use real beauty lighting terminology",
+  "supports_image_reference": true
+}

+ 80 - 0
fmode-image-set/mcp/catalog/templates/zh/21-seasonal-campaign.json

@@ -0,0 +1,80 @@
+{
+  "locale": "zh",
+  "id": "seasonal-campaign",
+  "name": "季节主题网格",
+  "keywords": [
+    "季节",
+    "四季",
+    "campaign",
+    "季节主题",
+    "seasonal",
+    "春夏秋冬",
+    "年度campaign",
+    "品牌campaign",
+    "季节变体"
+  ],
+  "trigger_phrases": [
+    "季节主题",
+    "四季展示",
+    "seasonal campaign",
+    "年度campaign",
+    "春夏秋冬",
+    "季节变体",
+    "品牌年度",
+    "四季网格"
+  ],
+  "prompt_template": {
+    "type": "seasonal product campaign grid",
+    "subject": "{product_description}",
+    "layout": "2x2 grid, one product in four seasonal settings",
+    "spring": "pastel tones, blossoms, morning dew, soft light",
+    "summer": "bright warm tones, tropical elements, golden light",
+    "autumn": "warm amber, dried leaves, cinnamon, window light",
+    "winter": "cool blues, pine branches, frost, moonlight",
+    "quality": "8K, brand campaign photography, consistent product placement"
+  },
+  "defaults": {
+    "layout": "2x2 grid with thin white or gold borders",
+    "consistency": "same product angle and size across all four quadrants"
+  },
+  "variants": {
+    "four-seasons": {
+      "description": "经典四季",
+      "overrides": {
+        "layout": "2x2, each quadrant a full seasonal scene"
+      }
+    },
+    "holiday-series": {
+      "description": "节日系列",
+      "overrides": {
+        "layout": "2x3 or 3x2 grid, each cell a different holiday theme (Valentine's, Easter, Summer, Halloween, Thanksgiving, Christmas)"
+      }
+    },
+    "day-to-night": {
+      "description": "日夜变体",
+      "overrides": {
+        "layout": "2x2 grid, same scene at dawn, morning, afternoon, night"
+      }
+    },
+    "travel-series": {
+      "description": "旅行主题",
+      "overrides": {
+        "layout": "2x2 grid, product in 4 travel destinations with local elements"
+      }
+    }
+  },
+  "category_tips": {
+    "skincare": "spring=blossoms+dew, summer=sun+kisses, autumn=cozy+cream, winter=frost+glow",
+    "fragrance": "each season with matching botanical elements and color palette",
+    "fashion": "season-appropriate styling visible in each quadrant",
+    "food": "seasonal ingredients and color palette matching product",
+    "home": "seasonal decor elements and lighting mood",
+    "candles": "seasonal scents visualized through corresponding natural elements"
+  },
+  "examples": [
+    "2x2 seasonal grid. Same {product} in four settings: Spring with cherry blossoms, Summer with citrus and sunshine, Autumn with maple leaves, Winter with pine and snow. Consistent product angle, 8K, 2000x2000px",
+    "Brand seasonal campaign 2x2 grid. {product} in four seasonal worlds: Spring=pastel marble+dew, Summer=tropical leaves+sunset, Autumn=walnut wood+cinnamon, Winter=frosted glass+pine. Season labels in serif font, 8K"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 83 - 0
fmode-image-set/mcp/catalog/templates/zh/22-luxury-atmospherics.json

@@ -0,0 +1,83 @@
+{
+  "locale": "zh",
+  "id": "luxury-atmospherics",
+  "name": "奢华氛围渲染",
+  "keywords": [
+    "奢华",
+    "氛围",
+    "烟雾",
+    "高级感",
+    "luxury",
+    "atmospheric",
+    "梦幻",
+    "高端",
+    "premium",
+    "烟雾效果",
+    "金色"
+  ],
+  "trigger_phrases": [
+    "奢华氛围",
+    "高端渲染",
+    "梦幻效果",
+    "luxury campaign",
+    "氛围渲染",
+    "高端广告",
+    "品牌大片",
+    "premium visual"
+  ],
+  "prompt_template": {
+    "type": "luxury product photography with atmospheric effects",
+    "subject": "{product_description}",
+    "surface": "polished dark reflective surface",
+    "atmosphere": "wisps of smoke, floating petals, light particles",
+    "lighting": "dramatic rim light + cool ambient fill",
+    "background": "infinite dark void with subtle gradient",
+    "quality": "8K, award-winning luxury advertising photography"
+  },
+  "defaults": {
+    "surface": "polished obsidian or black marble platform",
+    "atmosphere": "purple-blue smoke wisps + floating elements",
+    "lighting": "golden rim light from upper-left, cool blue backlight"
+  },
+  "variants": {
+    "floral-dream": {
+      "description": "花卉梦幻",
+      "overrides": {
+        "atmosphere": "fresh flower petals floating at various heights catching light, scattered gold leaf particles"
+      }
+    },
+    "smoke-mystique": {
+      "description": "烟雾神秘",
+      "overrides": {
+        "atmosphere": "multi-layered violet and midnight blue smoke swirling around product"
+      }
+    },
+    "golden-luxe": {
+      "description": "金色奢华",
+      "overrides": {
+        "atmosphere": "golden bokeh particles, warm amber glow, scattered gold flakes"
+      }
+    },
+    "ice-crystal": {
+      "description": "冰晶冷冽",
+      "overrides": {
+        "atmosphere": "floating ice crystals, frost patterns, diamond-like light refractions",
+        "lighting": "cool platinum spotlight, ice-blue ambient"
+      }
+    }
+  },
+  "category_tips": {
+    "fragrance": "multi-layer smoke + matching botanical elements, amber liquid visible inside",
+    "skincare": "ethereal glow around product, subtle condensation, dreamy quality",
+    "jewelry": "diamond-like light bokeh, dark background, sparkle and reflection focus",
+    "wine": "rich amber or ruby liquid, smoke wisps matching color, candlelight warmth",
+    "chocolate": "warm golden particles, cocoa powder dust, rich dark tones",
+    "watch": "sharp metallic reflections, precise time visible, ice-blue or golden accent"
+  },
+  "examples": [
+    "Luxury product photography with atmospheric effects. {product} on polished dark surface, surrounded by wisps of purple-blue smoke. Dramatic rim light. Deep black background. 8K, cinematic quality",
+    "Premium atmospheric product rendering. {product} on black marble veined with gold. Floating flower petals and gold leaf particles. Multi-point lighting: golden rim light + cool backlight. Dark void background, 8K"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 83 - 0
fmode-image-set/mcp/catalog/templates/zh/23-device-mockup.json

@@ -0,0 +1,83 @@
+{
+  "locale": "zh",
+  "id": "device-mockup",
+  "name": "设备界面模型",
+  "keywords": [
+    "设备模型",
+    "界面模型",
+    "mockup",
+    "UI展示",
+    "APP截图",
+    "设备展示",
+    "屏幕展示",
+    "device mockup",
+    "SaaS"
+  ],
+  "trigger_phrases": [
+    "设备模型",
+    "界面展示",
+    "mockup",
+    "APP展示",
+    "网站展示",
+    "SaaS展示",
+    "UI mockup",
+    "产品截图",
+    "屏幕展示"
+  ],
+  "prompt_template": {
+    "type": "product device mockup photography",
+    "subject": "{product_description} displayed on device screen",
+    "device": "laptop or smartphone on desk",
+    "screen_content": "modern clean app/dashboard interface",
+    "desk_accessories": "coffee cup, plant, notebook, natural work environment",
+    "lighting": "natural window light with warm fill",
+    "quality": "8K, tech product photography, screen content tack-sharp"
+  },
+  "defaults": {
+    "device": "latest laptop on clean desk",
+    "desk_accessories": "coffee cup, small plant, wireless mouse, notebook",
+    "lighting": "soft window light from left, warm overhead fill",
+    "background": "modern minimal office environment"
+  },
+  "variants": {
+    "single-laptop": {
+      "description": "单笔记本",
+      "overrides": {
+        "device": "MacBook Pro showing product interface, shallow depth of field on screen"
+      }
+    },
+    "multi-device": {
+      "description": "多设备联动",
+      "overrides": {
+        "device": "iPad center + iPhone left + smartwatch right, all showing consistent app UI"
+      }
+    },
+    "phone-only": {
+      "description": "手机展示",
+      "overrides": {
+        "device": "iPhone held at slight angle in hand, lifestyle background, screen clearly visible"
+      }
+    },
+    "office-lifestyle": {
+      "description": "办公场景",
+      "overrides": {
+        "desk_accessories": "Aesop hand cream, Muji pen holder, latte with art, hardcover notebook, trailing plant on shelf",
+        "background": "Scandinavian home office with white bookshelf"
+      }
+    }
+  },
+  "category_tips": {
+    "saas": "show dashboard with charts, KPI cards, data visualizations",
+    "mobile_app": "show app interface on phone, notification visible, clean UI",
+    "ecommerce_platform": "show store admin dashboard with product listings and analytics",
+    "fintech": "show financial dashboard with graphs, portfolio summary",
+    "health_app": "show workout tracker, health metrics, progress charts",
+    "ai_product": "show chat interface, AI responses, modern dark or light theme"
+  },
+  "examples": [
+    "Product mockup on laptop. Silver laptop on white desk, screen showing modern dashboard with charts. Coffee cup and small plant nearby. Natural window light. Clean product photography, 8K",
+    "Multi-device mockup. iPad with keyboard showing {product} app, iPhone showing notification, Apple Watch showing widget. All screens consistent UI in sage green. Scandinavian desk, 8K, 1536x1024px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 82 - 0
fmode-image-set/mcp/catalog/templates/zh/24-storefront.json

@@ -0,0 +1,82 @@
+{
+  "locale": "zh",
+  "id": "storefront",
+  "name": "店铺门面/空间摄影",
+  "keywords": [
+    "店铺",
+    "门面",
+    "店面",
+    "storefront",
+    "空间摄影",
+    "室内设计",
+    "零售空间",
+    "咖啡店",
+    "实体店"
+  ],
+  "trigger_phrases": [
+    "店铺门面",
+    "店面摄影",
+    "空间摄影",
+    "零售空间",
+    "咖啡店外观",
+    "实体店",
+    "storefront",
+    "室内摄影",
+    "店铺设计"
+  ],
+  "prompt_template": {
+    "type": "retail/storefront architectural photography",
+    "subject": "{business_description}",
+    "exterior": "clean facade with signage and window display",
+    "interior": "curated display shelving, consultation area, ambient lighting",
+    "lighting": "golden hour exterior or warm ambient interior",
+    "quality": "8K, editorial architectural photography, wide-angle sharp"
+  },
+  "defaults": {
+    "exterior": "large glass windows, signage, entrance details, potted plants",
+    "lighting": "late afternoon warm golden light, interior already glowing",
+    "camera": "wide-angle lens, f/8-f/11 for full sharpness"
+  },
+  "variants": {
+    "exterior": {
+      "description": "门面外观",
+      "overrides": {
+        "exterior": "full storefront view with window display, entrance, street context",
+        "interior": "visible through glass windows only"
+      }
+    },
+    "interior": {
+      "description": "室内空间",
+      "overrides": {
+        "interior": "wide-angle interior showing product displays, furniture, lighting design",
+        "exterior": "not visible"
+      }
+    },
+    "corner-detail": {
+      "description": "角落细节",
+      "overrides": {
+        "interior": "close-up vignette of a curated corner: consultation table, product samples, single flower"
+      }
+    },
+    "aerial-plan": {
+      "description": "俯视平面",
+      "overrides": {
+        "camera": "top-down bird's eye view showing full floor plan layout"
+      }
+    }
+  },
+  "category_tips": {
+    "coffee_shop": "espresso machine visible, warm wood tones, latte art, pastry display",
+    "beauty_store": "illuminated niches, marble counter, product wall, consultation area",
+    "fashion_boutique": "minimalist racks, curated display, premium flooring, mirror accents",
+    "restaurant": "table setting, kitchen pass visible, ambient lighting, menu display",
+    "gym_studio": "modern equipment, branded wall, natural light, motivational signage",
+    "pop_up": "temporary creative installation, bold graphics, unique display fixtures"
+  },
+  "examples": [
+    "Storefront photography. Modern {business} with glass windows showing warm interior. Entrance with potted plants. Golden hour light. Architectural photography, 8K",
+    "Premium interior photography of {business}. Wide-angle shot showing product displays, consultation area, herringbone floor. Warm ambient lighting throughout. Architectural Digest quality, 8K, 1536x1024px"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 84 - 0
fmode-image-set/mcp/catalog/templates/zh/25-sports-campaign.json

@@ -0,0 +1,84 @@
+{
+  "locale": "zh",
+  "id": "sports-campaign",
+  "name": "运动/健身广告",
+  "keywords": [
+    "运动",
+    "健身",
+    "广告",
+    "sports",
+    "fitness",
+    "campaign",
+    "运动鞋",
+    "运动服",
+    "篮球",
+    "跑步",
+    "健身器材"
+  ],
+  "trigger_phrases": [
+    "运动广告",
+    "健身广告",
+    "sports campaign",
+    "运动品牌",
+    "运动大片",
+    "健身推广",
+    "运动产品广告",
+    "运动海报"
+  ],
+  "prompt_template": {
+    "type": "sports/fitness advertising campaign photography",
+    "subject": "{product_description}",
+    "athlete": "athletic model in dynamic pose with product",
+    "props": "sports equipment as visual anchor, exaggerated scale",
+    "background": "minimal dark studio with reflective floor",
+    "typography": "bold condensed headline + subtitle",
+    "quality": "8K, Nike/Under Armour campaign quality"
+  },
+  "defaults": {
+    "background": "dark studio, reflective black floor",
+    "typography": "bold condensed font, brand color accent",
+    "lighting": "spotlight from above, dramatic body highlights"
+  },
+  "variants": {
+    "product-hero": {
+      "description": "产品主视觉",
+      "overrides": {
+        "athlete": "no model, product floating at dynamic angle",
+        "props": "motion lines and speed effects around product"
+      }
+    },
+    "athlete-action": {
+      "description": "运动员动态",
+      "overrides": {
+        "athlete": "athlete mid-action wearing product, stadium lights, lens flare"
+      }
+    },
+    "triptych": {
+      "description": "三联画",
+      "overrides": {
+        "layout": "three vertical panels: close-up detail + action shot + product hero, unified bottom strip with branding"
+      }
+    },
+    "gym-power": {
+      "description": "健身力量感",
+      "overrides": {
+        "athlete": "muscular athlete seated on oversized dumbbell or equipment",
+        "props": "exaggerated fitness equipment placed diagonally"
+      }
+    }
+  },
+  "category_tips": {
+    "running_shoes": "dynamic forward motion, speed lines, track or road surface context",
+    "basketball": "mid-dunk or crossover pose, stadium lighting, court texture",
+    "fitness_equipment": "athlete using product, sweat detail, gym environment",
+    "sportswear": "compression fit visible, fabric technology highlighted, movement pose",
+    "protein_supplements": "product with athlete post-workout, muscular definition, energy mood",
+    "sports_drink": "splash effects, condensation, vibrant energy colors, hydration theme"
+  },
+  "examples": [
+    "Sports advertising photo. {product} placed diagonally on reflective dark surface. Dramatic side lighting, speed lines around product. Bold headline text. Dynamic and energetic, 8K, 1080x1350px",
+    "Fitness brand campaign. Athletic model wearing {product}, seated on large dumbbell. Dark studio with reflective floor. Spotlight from above. 'STRENGTH' bold headline. Nike campaign quality, 8K"
+  ],
+  "anti_ai_tips": "",
+  "supports_image_reference": true
+}

+ 132 - 0
fmode-image-set/mcp/src/core/attachment-resolver.mjs

@@ -0,0 +1,132 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { createHash } from 'node:crypto';
+import { assertExistingPathChainInside, isInside } from './run-paths.mjs';
+
+const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
+const MIME = new Map([
+  ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.png', 'image/png'],
+  ['.webp', 'image/webp'], ['.gif', 'image/gif'], ['.bmp', 'image/bmp']
+]);
+
+function fileImage(filePath, cwd, maxBytes = MAX_IMAGE_BYTES, options = {}) {
+  const workspaceRoot = path.resolve(options.workspaceRoot || process.env.FMODE_WORKSPACE_ROOT || cwd);
+  if (!fs.existsSync(workspaceRoot)) throw new Error('当前项目目录不存在。');
+  const realWorkspace = fs.realpathSync.native(workspaceRoot);
+  const absolutePath = path.resolve(cwd, filePath);
+  assertExistingPathChainInside(realWorkspace, absolutePath, { kind: '商品图片路径' });
+  const linkStat = fs.lstatSync(absolutePath);
+  if (linkStat.isSymbolicLink()) throw new Error('商品图片不能通过符号链接读取。');
+  const realPath = fs.realpathSync.native(absolutePath);
+  if (!isInside(realWorkspace, realPath)) throw new Error('商品图片必须位于当前项目内。');
+  const stat = fs.statSync(realPath);
+  if (!stat.isFile()) throw new Error(`不是图片文件:${absolutePath}`);
+  if (stat.size > maxBytes) throw new Error(`图片超过 ${Math.round(maxBytes / 1024 / 1024)}MB:${path.basename(absolutePath)}`);
+  const mimeType = MIME.get(path.extname(absolutePath).toLowerCase());
+  if (!mimeType) throw new Error(`不支持的图片格式:${path.extname(absolutePath)}`);
+  const bytes = fs.readFileSync(realPath);
+  return {
+    url: `data:${mimeType};base64,${bytes.toString('base64')}`,
+    label: path.basename(realPath),
+    source: 'file',
+    localPath: realPath,
+    workspaceRelativePath: path.relative(realWorkspace, realPath),
+    contentHash: createHash('sha256').update(bytes).digest('hex'),
+    size: stat.size
+  };
+}
+
+function normalizeImage(item, input) {
+  if (item && typeof item === 'object') {
+    if (item.type === 'image' && item.source?.type === 'base64') {
+      return { url: `data:${item.source.media_type || 'image/png'};base64,${item.source.data}`, label: 'claude-attachment', source: 'claude' };
+    }
+    return normalizeImage(item.url || item.imageUrl || item.path || item.imagePath || item.dataUrl || item.source, input);
+  }
+  const value = String(item || '').trim();
+  if (!value) return null;
+  if (/^data:image\//i.test(value)) return { url: value, label: 'data-url-image', source: 'data' };
+  if (/^https?:\/\//i.test(value)) return { url: value, label: new URL(value).hostname, source: 'url' };
+  return fileImage(value, input.cwd || process.cwd(), input.maxImageBytes, input);
+}
+
+function walkJsonl(directory, found = []) {
+  if (!fs.existsSync(directory)) return found;
+  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
+    const filePath = path.join(directory, entry.name);
+    if (entry.isDirectory()) walkJsonl(filePath, found);
+    else if (entry.isFile() && entry.name.endsWith('.jsonl')) found.push(filePath);
+  }
+  return found;
+}
+
+function userMessage(record) {
+  const message = record?.message || record;
+  const role = message?.role || record?.role || (record?.type === 'user' ? 'user' : '');
+  if (role !== 'user') return null;
+  const content = message?.content || record?.content || [];
+  const images = Array.isArray(content) ? content.filter(item => item?.type === 'image') : [];
+  return {
+    promptId: String(record?.prompt_id || record?.promptId || message?.id || ''),
+    sessionId: String(record?.session_id || record?.sessionId || ''),
+    cwd: String(record?.cwd || ''),
+    timestamp: String(record?.timestamp || record?.created_at || ''),
+    images
+  };
+}
+
+export function resolveClaudeAttachments(input = {}) {
+  const rawConfig = input.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR;
+  const configDir = rawConfig ? path.resolve(rawConfig) : path.join(os.homedir(), '.claude');
+  const files = walkJsonl(path.join(configDir, 'projects'));
+  const messages = [];
+  for (const file of files) {
+    if (input.sessionId && !path.basename(file).includes(input.sessionId)) {
+      // Keep parsing: some Claude versions do not include session ID in the filename.
+    }
+    for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
+      if (!line.trim()) continue;
+      try {
+        const message = userMessage(JSON.parse(line));
+        if (!message) continue;
+        if (input.sessionId && message.sessionId !== input.sessionId) continue;
+        if (input.promptId && message.promptId !== input.promptId) continue;
+        if (input.cwd && (!message.cwd || path.resolve(message.cwd) !== path.resolve(input.cwd))) continue;
+        messages.push(message);
+      } catch {
+        // Ignore corrupt log lines; never expose raw session data.
+      }
+    }
+  }
+  const latest = messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp)).at(-1);
+  if (!latest) return [];
+  // Critical: return only the latest matching user request, even when it has zero images.
+  return latest.images.map(item => normalizeImage(item, input)).filter(Boolean);
+}
+
+export function resolveImageInputs(input = {}) {
+  const raw = [
+    input.image, input.imagePath, input.imageUrl,
+    ...(Array.isArray(input.images) ? input.images : []),
+    ...(Array.isArray(input.imagePaths) ? input.imagePaths : []),
+    ...(Array.isArray(input.imageUrls) ? input.imageUrls : []),
+    ...(Array.isArray(input.attachments) ? input.attachments : [])
+  ].filter(Boolean);
+  const normalized = raw.map(item => normalizeImage(item, input)).filter(Boolean);
+  if (!normalized.length && input.allowClaudeAttachments !== false && (!input.sessionId || !input.promptId || !input.cwd)) {
+    throw new Error('解析 Claude 当前请求附件必须同时提供 sessionId、promptId 和 cwd。');
+  }
+  const images = normalized.length ? normalized : (input.allowClaudeAttachments === false ? [] : resolveClaudeAttachments(input));
+  const unique = [];
+  const seen = new Set();
+  for (const image of images) {
+    const key = image.localPath ? `file:${process.platform === 'win32' ? image.localPath.toLowerCase() : image.localPath}` : `content:${image.url}`;
+    if (!seen.has(key)) unique.push(image);
+    seen.add(key);
+  }
+  if (unique.length > Number(input.maxImages || 10)) throw new Error('图片数量超过上限,请明确选择最多 10 张参考图。');
+  return unique;
+}
+
+export { MAX_IMAGE_BYTES };

+ 195 - 0
fmode-image-set/mcp/src/core/core.test.mjs

@@ -0,0 +1,195 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+import { resolveClaudeAttachments, resolveImageInputs } from './attachment-resolver.mjs';
+import { toPublicValue } from './public-view.mjs';
+import { createRequestKey } from './request-key.mjs';
+import { createReadableRunId, isInside, resolveExistingRunDirectory, resolveOutputRoot, resolveRunDirectory, sanitizeProductName } from './run-paths.mjs';
+import { readState, writeState } from './state-store.mjs';
+
+const projectRoot = path.resolve(import.meta.dirname, '..', '..', '..', '..');
+
+function tempDirectory(prefix) {
+  return fs.mkdtempSync(path.join(os.tmpdir(), `fmode-image-set-${prefix}-`));
+}
+
+test('request key is deterministic', () => {
+  const a = createRequestKey({ sessionId: 's', promptId: 'p', action: 'generate', planHash: 'h', extra: { b: 2, a: 1 } });
+  const b = createRequestKey({ sessionId: 's', promptId: 'p', action: 'generate', planHash: 'h', extra: { a: 1, b: 2 } });
+  assert.equal(a, b);
+});
+
+test('public projection removes nested cost data and sanitizes user-facing arrays', () => {
+  const projected = toPublicValue({
+    assistantMessage: '按受信美元价格与汇率预计 ¥5.30。',
+    summary: { estimatedCostCny: 5.3, total_cost: 8, nested: { totalCostCny: 6, value: 'keep' } },
+    data: { billing_info: { amount: 4 }, run: { budgetLimitCny: 7, usage: { cost: 1.25, actual_cost_cny: 3 }, items: [{ costUnit: 'CNY', revisionEstimatedCostCny: 1 }] } },
+    nextActions: ['人民币预算上限不足 ¥5.30。', '累计费用 CNY 3.00 / ¥3.00']
+  });
+  assert.deepEqual(projected.summary, { nested: { value: 'keep' } });
+  assert.deepEqual(projected.data, { run: { items: [{}] } });
+  assert.doesNotMatch(JSON.stringify(projected), /estimatedCostCny|total_?cost|actual_?cost|budgetLimitCny|costUnit|revisionEstimatedCostCny|usage|billing|¥|¥|CNY|RMB|费用|5\.30|3\.00/);
+});
+
+test('run paths reject project escape and skill source outputs', () => {
+  const packageRoot = path.join(projectRoot, 'fmode-image-set');
+  assert.throws(() => resolveOutputRoot({ workspaceRoot: projectRoot, outputRoot: path.resolve(projectRoot, '..', 'escape') }), /固定/);
+  assert.throws(() => resolveOutputRoot({ workspaceRoot: projectRoot, outputRoot: path.join(projectRoot, 'custom-output') }), /固定/);
+  assert.throws(() => resolveOutputRoot({ workspaceRoot: packageRoot, packageRoot }), /Skill 源码/);
+  const paths = resolveRunDirectory({ workspaceRoot: projectRoot, packageRoot, runId: '咖啡豆-abcdef12' });
+  assert.equal(isInside(projectRoot, paths.runDirectory), true);
+  assert.equal(paths.runDirectory, path.join(projectRoot, '输出', '商品套图', '咖啡豆-abcdef12'));
+});
+
+test('run paths reject a pre-created run directory symlink or junction', () => {
+  const workspace = tempDirectory('run-link-workspace');
+  const outside = tempDirectory('run-link-outside');
+  const outputRoot = path.join(workspace, '输出', '商品套图');
+  const linkedRun = path.join(outputRoot, '链接商品-abcdef12');
+  fs.mkdirSync(outputRoot, { recursive: true });
+  try {
+    fs.symlinkSync(outside, linkedRun, process.platform === 'win32' ? 'junction' : 'dir');
+    assert.throws(() => resolveRunDirectory({ workspaceRoot: workspace, runId: '链接商品-abcdef12' }), /符号链接|联接点/);
+  } finally {
+    if (fs.existsSync(linkedRun)) fs.unlinkSync(linkedRun);
+    fs.rmSync(workspace, { recursive: true, force: true });
+    fs.rmSync(outside, { recursive: true, force: true });
+  }
+});
+
+test('readable run ids sanitize Windows names and keep a stable request suffix', () => {
+  assert.equal(sanitizeProductName(' MORI 咖啡/经典版:*? '), 'MORI-咖啡-经典版');
+  assert.equal(sanitizeProductName('CON'), 'CON-商品');
+  assert.equal(sanitizeProductName('CON.txt'), 'CON-txt');
+  assert.equal(sanitizeProductName('A&B(经典版)☕'), 'A-B-经典版');
+  assert.equal(createReadableRunId({ productName: 'MORI 咖啡', requestKey: 'ABCDEF1234567890' }), 'MORI-咖啡-abcdef12');
+  assert.throws(() => createReadableRunId({ productName: '咖啡', requestKey: 'bad' }), /稳定/);
+});
+
+test('existing run lookup supports the legacy output directory', () => {
+  const workspace = tempDirectory('legacy-run');
+  const legacy = resolveRunDirectory({ workspaceRoot: workspace, runId: 'run_abcdef', layout: 'legacy' });
+  fs.mkdirSync(legacy.runDirectory, { recursive: true });
+  fs.writeFileSync(path.join(legacy.runDirectory, 'state.json'), '{}');
+  try {
+    const found = resolveExistingRunDirectory({ workspaceRoot: workspace, runId: 'run_abcdef' });
+    assert.equal(found.layout, 'legacy');
+  } finally { fs.rmSync(workspace, { recursive: true, force: true }); }
+});
+
+test('explicit local paths deduplicate the same path but preserve distinct files with identical content', () => {
+  const workspace = tempDirectory('local-dedup');
+  const first = path.join(workspace, 'first.png');
+  const second = path.join(workspace, 'second.png');
+  fs.writeFileSync(first, Buffer.from([137, 80, 78, 71]));
+  fs.copyFileSync(first, second);
+  try {
+    const images = resolveImageInputs({ images: [first, first, second], workspaceRoot: workspace, cwd: workspace, allowClaudeAttachments: false });
+    assert.equal(images.length, 2);
+    assert.deepEqual(images.map(image => image.label), ['first.png', 'second.png']);
+  } finally { fs.rmSync(workspace, { recursive: true, force: true }); }
+});
+
+test('explicit local images reject an internal directory junction or symlink', () => {
+  const workspace = tempDirectory('image-link-workspace');
+  const rootImage = path.join(workspace, 'root.png');
+  const alias = path.join(workspace, 'alias');
+  fs.writeFileSync(rootImage, Buffer.from([137, 80, 78, 71]));
+  try {
+    fs.symlinkSync(workspace, alias, process.platform === 'win32' ? 'junction' : 'dir');
+    assert.throws(() => resolveImageInputs({ imagePath: path.join(alias, 'root.png'), workspaceRoot: workspace, cwd: workspace, allowClaudeAttachments: false }), /符号链接|联接点/);
+  } finally {
+    if (fs.existsSync(alias)) fs.unlinkSync(alias);
+    fs.rmSync(workspace, { recursive: true, force: true });
+  }
+});
+
+test('Claude attachment filters require exact session, prompt, and cwd fields', () => {
+  const config = tempDirectory('claude-strict');
+  const logs = path.join(config, 'projects', 'fixture');
+  fs.mkdirSync(logs, { recursive: true });
+  const records = [
+    { timestamp: '2026-01-01T00:00:00Z', cwd: projectRoot, message: { id: 'p1', role: 'user', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }] } },
+    { timestamp: '2026-01-01T00:01:00Z', sessionId: 's1', message: { id: 'p1', role: 'user', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'BBBB' } }] } }
+  ];
+  fs.writeFileSync(path.join(logs, 's1.jsonl'), records.map(JSON.stringify).join('\n'));
+  try {
+    assert.equal(resolveClaudeAttachments({ claudeConfigDir: config, sessionId: 's1', promptId: 'p1', cwd: projectRoot }).length, 0);
+  } finally { fs.rmSync(config, { recursive: true, force: true }); }
+});
+
+test('state checksum detects tampering', () => {
+  const run = tempDirectory('state');
+  try {
+    writeState(run, { status: 'planned', value: 1 });
+    assert.equal(readState(run).value, 1);
+    const file = path.join(run, 'state.json');
+    const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
+    doc.payload.value = 2;
+    fs.writeFileSync(file, JSON.stringify(doc));
+    assert.throws(() => readState(run), /完整性/);
+  } finally { fs.rmSync(run, { recursive: true, force: true }); }
+});
+
+test('state read and write reject symlinked or hard-linked state files and temp files', () => {
+  const run = tempDirectory('state-link-run');
+  const outside = tempDirectory('state-link-outside');
+  const outsideFile = path.join(outside, 'outside.json');
+  fs.writeFileSync(outsideFile, 'outside');
+  const stateFile = path.join(run, 'state.json');
+  const tempFile = path.join(run, 'state.json.tmp');
+  try {
+    if (process.platform === 'win32') fs.linkSync(outsideFile, stateFile);
+    else fs.symlinkSync(outsideFile, stateFile, 'file');
+    assert.throws(() => readState(run), /普通.*文件/);
+    assert.throws(() => writeState(run, { status: 'blocked' }), /普通.*文件/);
+    fs.unlinkSync(stateFile);
+    if (process.platform === 'win32') fs.linkSync(outsideFile, tempFile);
+    else fs.symlinkSync(outsideFile, tempFile, 'file');
+    assert.throws(() => writeState(run, { status: 'blocked' }), /普通.*文件/);
+    assert.equal(fs.readFileSync(outsideFile, 'utf8'), 'outside');
+  } finally {
+    if (fs.existsSync(stateFile)) fs.unlinkSync(stateFile);
+    if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
+    fs.rmSync(run, { recursive: true, force: true });
+    fs.rmSync(outside, { recursive: true, force: true });
+  }
+});
+
+test('state read and write reject directory junctions at state paths', () => {
+  const run = tempDirectory('state-junction-run');
+  const outside = tempDirectory('state-junction-outside');
+  const stateFile = path.join(run, 'state.json');
+  const tempFile = path.join(run, 'state.json.tmp');
+  try {
+    fs.symlinkSync(outside, tempFile, process.platform === 'win32' ? 'junction' : 'dir');
+    assert.throws(() => writeState(run, { status: 'blocked' }), /普通.*文件/);
+    fs.unlinkSync(tempFile);
+    fs.symlinkSync(outside, stateFile, process.platform === 'win32' ? 'junction' : 'dir');
+    assert.throws(() => readState(run), /普通.*文件/);
+  } finally {
+    if (fs.existsSync(stateFile)) fs.unlinkSync(stateFile);
+    if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
+    fs.rmSync(run, { recursive: true, force: true });
+    fs.rmSync(outside, { recursive: true, force: true });
+  }
+});
+
+test('latest Claude user request with no image never reuses older attachment', () => {
+  const config = tempDirectory('claude');
+  const logs = path.join(config, 'projects', 'fixture');
+  fs.mkdirSync(logs, { recursive: true });
+  const records = [
+    { timestamp: '2026-01-01T00:00:00Z', sessionId: 's1', cwd: projectRoot, message: { id: 'p1', role: 'user', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }] } },
+    { timestamp: '2026-01-01T00:01:00Z', sessionId: 's1', cwd: projectRoot, message: { id: 'p2', role: 'user', content: [{ type: 'text', text: 'no image' }] } }
+  ];
+  fs.writeFileSync(path.join(logs, 's1.jsonl'), records.map(JSON.stringify).join('\n'));
+  try {
+    const current = resolveClaudeAttachments({ claudeConfigDir: config, sessionId: 's1', cwd: projectRoot });
+    assert.equal(current.length, 0);
+    const exact = resolveClaudeAttachments({ claudeConfigDir: config, sessionId: 's1', promptId: 'p1', cwd: projectRoot });
+    assert.equal(exact.length, 1);
+  } finally { fs.rmSync(config, { recursive: true, force: true }); }
+});

+ 40 - 0
fmode-image-set/mcp/src/core/credentials.mjs

@@ -0,0 +1,40 @@
+function firstNonEmpty(values) {
+  return values.find(value => typeof value === 'string' && value.trim())?.trim() || '';
+}
+
+function positiveNumber(values, fallback) {
+  const value = Number(firstNonEmpty(values.map(item => item == null ? '' : String(item))));
+  return Number.isFinite(value) && value > 0 ? value : fallback;
+}
+
+export function readNewApiConfig(options = {}) {
+  return {
+    // API key may be injected by trusted application code/tests, but MCP tool schemas
+    // never expose it. End users should configure one of the environment variables.
+    apiKey: firstNonEmpty([options.apiKey, process.env.FMODE_LLM_API_KEY, process.env.LLM_API_KEY]),
+    // Never accept a caller-controlled endpoint field from an MCP invocation. The
+    // `trustedBaseUrl` option exists only for direct dependency injection in tests.
+    baseUrl: firstNonEmpty([options.trustedBaseUrl, process.env.FMODE_LLM_BASE_URL, process.env.LLM_BASE_URL]),
+    allowInsecureHttp: options.allowInsecureHttp === true || /^true$/i.test(process.env.FMODE_ALLOW_INSECURE_HTTP || ''),
+    timeoutMs: positiveNumber([options.timeoutMs, process.env.FMODE_LLM_TIMEOUT_MS], 180000),
+    usdToCnyRate: positiveNumber([process.env.FMODE_USD_TO_CNY_RATE], 6.8),
+    geminiGenerationReserveUsd: positiveNumber([
+      process.env.FMODE_GEMINI_GENERATION_RESERVE_USD,
+      process.env.FMODE_GEMINI_GENERATION_RESERVE
+    ], 0.10),
+    geminiVisionReserveUsd: positiveNumber([
+      process.env.FMODE_GEMINI_VISION_RESERVE_USD,
+      process.env.FMODE_GEMINI_VISION_RESERVE
+    ], 0.01),
+    seedreamImagePriceUsd: positiveNumber([
+      process.env.FMODE_SEEDREAM_IMAGE_PRICE_USD,
+      process.env.FMODE_SEEDREAM_IMAGE_PRICE
+    ], 0.03)
+  };
+}
+
+export function asBearerToken(token) {
+  const value = String(token || '').trim();
+  if (!value) return '';
+  return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`;
+}

+ 50 - 0
fmode-image-set/mcp/src/core/plan-ref-store.mjs

@@ -0,0 +1,50 @@
+import { randomBytes } from 'node:crypto';
+
+const DEFAULT_TTL_MS = 30 * 60 * 1000;
+const DEFAULT_MAX_ENTRIES = 100;
+
+function clone(value) {
+  return structuredClone(value);
+}
+
+export class PlanRefStore {
+  constructor({ ttlMs = DEFAULT_TTL_MS, maxEntries = DEFAULT_MAX_ENTRIES, now = () => Date.now() } = {}) {
+    this.ttlMs = ttlMs;
+    this.maxEntries = maxEntries;
+    this.now = now;
+    this.entries = new Map();
+  }
+
+  purgeExpired(at = this.now()) {
+    for (const [ref, entry] of this.entries) if (entry.expiresAtMs <= at) this.entries.delete(ref);
+  }
+
+  create(plan) {
+    if (!plan?.planId || !plan?.planHash) throw new Error('只能缓存已完成完整性标识的计划。');
+    const createdAtMs = this.now();
+    this.purgeExpired(createdAtMs);
+    while (this.entries.size >= this.maxEntries) this.entries.delete(this.entries.keys().next().value);
+    const planRef = `pref_${randomBytes(24).toString('base64url')}`;
+    const expiresAtMs = createdAtMs + this.ttlMs;
+    this.entries.set(planRef, { plan: clone(plan), expiresAtMs });
+    return { planRef, expiresAt: new Date(expiresAtMs).toISOString() };
+  }
+
+  resolve(planRef) {
+    const ref = String(planRef || '').trim();
+    const at = this.now();
+    const entry = this.entries.get(ref);
+    if (!entry || entry.expiresAtMs <= at) {
+      if (entry) this.entries.delete(ref);
+      throw new Error('计划引用无效或已过期;请重新执行不调用生图 Provider 的本地规划。');
+    }
+    return clone(entry.plan);
+  }
+
+  clear() {
+    this.entries.clear();
+  }
+}
+
+export const defaultPlanRefStore = new PlanRefStore();
+export { DEFAULT_MAX_ENTRIES, DEFAULT_TTL_MS };

+ 28 - 0
fmode-image-set/mcp/src/core/plan-ref-store.test.mjs

@@ -0,0 +1,28 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { PlanRefStore } from './plan-ref-store.mjs';
+
+function plan(id) {
+  return { planId: `plan_${id}`, planHash: id.repeat(64).slice(0, 64), items: [{ outputId: 'S1' }] };
+}
+
+test('plan references return an isolated in-memory copy and expire', () => {
+  let clock = 1_000;
+  const store = new PlanRefStore({ ttlMs: 100, now: () => clock });
+  const original = plan('a');
+  const issued = store.create(original);
+  assert.match(issued.planRef, /^pref_[A-Za-z0-9_-]+$/);
+  const resolved = store.resolve(issued.planRef);
+  resolved.items[0].outputId = 'changed';
+  assert.equal(store.resolve(issued.planRef).items[0].outputId, 'S1');
+  clock = 1_100;
+  assert.throws(() => store.resolve(issued.planRef), /无效或已过期/);
+});
+
+test('plan reference store evicts the oldest entry at its capacity limit', () => {
+  const store = new PlanRefStore({ maxEntries: 1 });
+  const first = store.create(plan('a')).planRef;
+  const second = store.create(plan('b')).planRef;
+  assert.throws(() => store.resolve(first), /无效或已过期/);
+  assert.equal(store.resolve(second).planId, 'plan_b');
+});

+ 112 - 0
fmode-image-set/mcp/src/core/plan-token.mjs

@@ -0,0 +1,112 @@
+import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from 'node:crypto';
+import fs from 'node:fs';
+import path from 'node:path';
+import { deflateRawSync, inflateRawSync } from 'node:zlib';
+
+const TOKEN_PREFIX = 'ptk1';
+const TOKEN_AAD = Buffer.from('fmode-image-set/plan-token/v1', 'utf8');
+const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
+const MAX_TOKEN_LENGTH = 100_000;
+const MAX_PAYLOAD_BYTES = 512 * 1024;
+
+function text(value) {
+  return String(value || '').trim();
+}
+
+function workspaceDigest(workspaceRoot) {
+  const resolved = path.resolve(workspaceRoot || process.env.FMODE_WORKSPACE_ROOT || process.cwd());
+  let canonical = resolved;
+  try { canonical = fs.realpathSync.native(resolved); } catch {}
+  if (process.platform === 'win32') canonical = canonical.toLowerCase();
+  return createHash('sha256').update(canonical, 'utf8').digest('base64url');
+}
+
+function deriveKey(secret) {
+  const value = text(secret);
+  if (value.length < 24) throw new Error('计划令牌密钥未配置或长度不足。');
+  return createHash('sha256').update('fmode-image-set/plan-token/key/v1\0', 'utf8').update(value, 'utf8').digest();
+}
+
+function equalText(left, right) {
+  const a = Buffer.from(text(left), 'utf8');
+  const b = Buffer.from(text(right), 'utf8');
+  return a.length === b.length && timingSafeEqual(a, b);
+}
+
+export class PlanTokenCodec {
+  constructor({ secret, ttlMs = DEFAULT_TTL_MS, now = () => Date.now() } = {}) {
+    this.key = deriveKey(secret);
+    this.ttlMs = ttlMs;
+    this.now = now;
+  }
+
+  seal(plan, context = {}) {
+    const sessionId = text(context.sessionId);
+    const promptId = text(context.promptId);
+    if (!plan?.planId || !plan?.planHash) throw new Error('只能签发完整性已标识的计划。');
+    if (!sessionId || !promptId) throw new Error('签发计划令牌必须提供 sessionId 和 promptId。');
+    const issuedAtMs = this.now();
+    const payload = {
+      version: 1,
+      issuedAtMs,
+      expiresAtMs: issuedAtMs + this.ttlMs,
+      nonce: randomBytes(16).toString('base64url'),
+      binding: {
+        workspace: workspaceDigest(context.workspaceRoot),
+        sessionId,
+        planningPromptId: promptId,
+        planId: plan.planId,
+        planHash: plan.planHash
+      },
+      plan: structuredClone(plan)
+    };
+    const plain = Buffer.from(JSON.stringify(payload), 'utf8');
+    if (plain.length > MAX_PAYLOAD_BYTES) throw new Error('计划内容过大,无法签发计划令牌。');
+    const compressed = deflateRawSync(plain);
+    if (compressed.length > MAX_PAYLOAD_BYTES) throw new Error('计划内容过大,无法签发计划令牌。');
+    const iv = randomBytes(12);
+    const cipher = createCipheriv('aes-256-gcm', this.key, iv);
+    cipher.setAAD(TOKEN_AAD);
+    const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
+    const tag = cipher.getAuthTag();
+    const planToken = [TOKEN_PREFIX, iv.toString('base64url'), ciphertext.toString('base64url'), tag.toString('base64url')].join('.');
+    if (planToken.length > MAX_TOKEN_LENGTH) throw new Error('计划内容过大,无法签发计划令牌。');
+    return { planToken, expiresAt: new Date(payload.expiresAtMs).toISOString() };
+  }
+
+  open(planToken, context = {}) {
+    const token = text(planToken);
+    if (!token || token.length > MAX_TOKEN_LENGTH) throw new Error('计划令牌无效、已过期或与当前上下文不匹配。');
+    const parts = token.split('.');
+    if (parts.length !== 4 || parts[0] !== TOKEN_PREFIX) throw new Error('计划令牌无效、已过期或与当前上下文不匹配。');
+    try {
+      const iv = Buffer.from(parts[1], 'base64url');
+      const ciphertext = Buffer.from(parts[2], 'base64url');
+      const tag = Buffer.from(parts[3], 'base64url');
+      if (iv.length !== 12 || tag.length !== 16 || !ciphertext.length || ciphertext.length > MAX_PAYLOAD_BYTES) throw new Error('invalid token shape');
+      const decipher = createDecipheriv('aes-256-gcm', this.key, iv);
+      decipher.setAAD(TOKEN_AAD);
+      decipher.setAuthTag(tag);
+      const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
+      const plain = inflateRawSync(compressed, { maxOutputLength: MAX_PAYLOAD_BYTES });
+      const payload = JSON.parse(plain.toString('utf8'));
+      const binding = payload?.binding || {};
+      if (payload?.version !== 1 || !payload?.plan || Number(payload.expiresAtMs) <= this.now()) throw new Error('expired token');
+      if (!equalText(binding.workspace, workspaceDigest(context.workspaceRoot))) throw new Error('workspace mismatch');
+      if (!equalText(binding.sessionId, context.sessionId)) throw new Error('session mismatch');
+      if (!equalText(binding.planId, context.confirmedPlanId)) throw new Error('plan mismatch');
+      if (!equalText(binding.planId, payload.plan.planId) || !equalText(binding.planHash, payload.plan.planHash)) throw new Error('payload mismatch');
+      return structuredClone(payload.plan);
+    } catch {
+      throw new Error('计划令牌无效、已过期或与当前上下文不匹配。');
+    }
+  }
+}
+
+export function configuredPlanTokenCodec(env = process.env, options = {}) {
+  const secret = text(env.FMODE_PLAN_TOKEN_SECRET || env.FMODE_LLM_API_KEY || env.LLM_API_KEY);
+  if (!secret) return null;
+  return new PlanTokenCodec({ ...options, secret });
+}
+
+export { DEFAULT_TTL_MS as DEFAULT_PLAN_TOKEN_TTL_MS, MAX_PAYLOAD_BYTES, MAX_TOKEN_LENGTH, workspaceDigest };

+ 83 - 0
fmode-image-set/mcp/src/core/plan-token.test.mjs

@@ -0,0 +1,83 @@
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import test from 'node:test';
+import { PlanTokenCodec, workspaceDigest } from './plan-token.mjs';
+
+const secret = 'test-only-plan-token-secret-32-bytes';
+const workspaceRoot = process.cwd();
+const plan = { planId: 'plan_1234567890abcdef', planHash: 'a'.repeat(64), items: [{ outputId: 'S1' }] };
+
+test('plan token is stateless across codec instances and bound to workspace, session and confirmed plan', () => {
+  let clock = 1000;
+  const issuer = new PlanTokenCodec({ secret, ttlMs: 100, now: () => clock });
+  const issued = issuer.seal(plan, { workspaceRoot, sessionId: 'session-a', promptId: 'planning-prompt' });
+  assert.match(issued.planToken, /^ptk1\./);
+
+  const restarted = new PlanTokenCodec({ secret, ttlMs: 100, now: () => clock });
+  assert.deepEqual(restarted.open(issued.planToken, {
+    workspaceRoot, sessionId: 'session-a', confirmedPlanId: plan.planId
+  }), plan);
+  assert.throws(() => restarted.open(issued.planToken, {
+    workspaceRoot, sessionId: 'session-b', confirmedPlanId: plan.planId
+  }), /上下文/);
+  assert.throws(() => restarted.open(issued.planToken, {
+    workspaceRoot, sessionId: 'session-a', confirmedPlanId: 'plan_other'
+  }), /上下文/);
+  assert.throws(() => restarted.open(issued.planToken, {
+    workspaceRoot: `${workspaceRoot}-other`, sessionId: 'session-a', confirmedPlanId: plan.planId
+  }), /上下文/);
+});
+
+test('plan token rejects tampering, wrong keys and expiry without revealing which check failed', () => {
+  let clock = 2000;
+  const issuer = new PlanTokenCodec({ secret, ttlMs: 5, now: () => clock });
+  const issued = issuer.seal(plan, { workspaceRoot, sessionId: 'session-a', promptId: 'planning-prompt' });
+  const parts = issued.planToken.split('.');
+  parts[2] = `${parts[2].slice(0, -1)}${parts[2].endsWith('A') ? 'B' : 'A'}`;
+  assert.throws(() => issuer.open(parts.join('.'), {
+    workspaceRoot, sessionId: 'session-a', confirmedPlanId: plan.planId
+  }), /上下文/);
+  const wrongKey = new PlanTokenCodec({ secret: 'another-test-plan-token-secret-32', now: () => clock });
+  assert.throws(() => wrongKey.open(issued.planToken, {
+    workspaceRoot, sessionId: 'session-a', confirmedPlanId: plan.planId
+  }), /上下文/);
+  clock = 2005;
+  assert.throws(() => issuer.open(issued.planToken, {
+    workspaceRoot, sessionId: 'session-a', confirmedPlanId: plan.planId
+  }), /上下文/);
+});
+
+test('workspace digest is stable for the same resolved workspace', () => {
+  assert.equal(workspaceDigest(workspaceRoot), workspaceDigest(`${workspaceRoot}/.`));
+});
+
+test('plan token rejects highly compressible plaintext above the decode limit at issuance', () => {
+  const issuer = new PlanTokenCodec({ secret });
+  const oversized = { ...plan, request: 'x'.repeat(600_000) };
+  assert.throws(() => issuer.seal(oversized, {
+    workspaceRoot, sessionId: 'session-a', promptId: 'planning-prompt'
+  }), /过大/);
+});
+
+test('plan token survives a real Node process restart', () => {
+  const issuer = new PlanTokenCodec({ secret });
+  const issued = issuer.seal(plan, { workspaceRoot, sessionId: 'session-a', promptId: 'planning-prompt' });
+  const moduleUrl = new URL('./plan-token.mjs', import.meta.url).href;
+  const script = `
+    import fs from 'node:fs';
+    import { PlanTokenCodec } from ${JSON.stringify(moduleUrl)};
+    const input = JSON.parse(fs.readFileSync(0, 'utf8'));
+    const codec = new PlanTokenCodec({ secret: process.env.TEST_PLAN_TOKEN_SECRET });
+    process.stdout.write(JSON.stringify(codec.open(input.planToken, input.context)));
+  `;
+  const child = spawnSync(process.execPath, ['--input-type=module', '--eval', script], {
+    input: JSON.stringify({
+      planToken: issued.planToken,
+      context: { workspaceRoot, sessionId: 'session-a', confirmedPlanId: plan.planId }
+    }),
+    encoding: 'utf8',
+    env: { ...process.env, TEST_PLAN_TOKEN_SECRET: secret }
+  });
+  assert.equal(child.status, 0, child.stderr);
+  assert.deepEqual(JSON.parse(child.stdout), plan);
+});

+ 48 - 0
fmode-image-set/mcp/src/core/public-view.mjs

@@ -0,0 +1,48 @@
+const PRIVATE_COST_KEYS = new Set([
+  'estimatedCostCny', 'revisionEstimatedCostCny', 'budgetLimitCny', 'budget_limit_cny',
+  'costUnit', 'costBasis', 'estimatedCostUsd', 'actualCostCny', 'totalCostCny', 'usage', 'billing'
+]);
+
+const USER_MESSAGE_KEYS = new Set([
+  'assistantMessage', 'confirmationMessage', 'error', 'errors', 'warning', 'warnings', 'nextActions'
+]);
+
+export function sanitizeUserText(value = '') {
+  return String(value)
+    .replace(/按受信美元价格与汇率预计\s*¥[\d.]+[,。]?/g, '')
+    .replace(/人民币预算上限不足[^。]*。?/g, '内部执行上限不足,请重新确认。')
+    .replace(/服务端是否已(?:被上游接收或)?计费未知/g, '请求是否已被上游接收未知')
+    .replace(/计费未知/g, '请求状态未知')
+    .replace(/(?:CNY|RMB)\s*[¥¥]?\s*\d+(?:\.\d+)?/gi, '')
+    .replace(/[¥¥]\s*\d+(?:\.\d+)?/g, '')
+    .replace(/(?:预计|累计|总计|合计|实际|本次|生成|返修)?(?:费用|成本|花费|计费金额)\s*[::]?\s*\d+(?:\.\d+)?/g, '')
+    .replace(/(?:预计|累计|总计|合计|实际|本次|生成|返修)(?:费用|成本|花费|计费金额)\s*[::]?/g, '')
+    .replace(/^\s*\/\s*|\s*\/\s*$/g, '')
+    .replace(/\s{2,}/g, ' ')
+    .trim();
+}
+
+function isPrivateCostKey(key = '') {
+  if (PRIVATE_COST_KEYS.has(key)) return true;
+  const normalized = String(key).replace(/[^A-Za-z0-9]/g, '').toLowerCase();
+  return /^(?:usage|billing|billable|cost|fee|charge|budget)$/.test(normalized)
+    || /^(?:billing|billable)(?:info|detail|details|amount|summary|data)/.test(normalized)
+    || /^budget(?:limit|amount|cny|rmb|usd|total|actual|estimated)/.test(normalized)
+    || /^(?:estimated|actual|total|cumulative|accumulated|generation|revision|request)(?:cost|fee|charge|budget)/.test(normalized)
+    || /^(?:cost|fee|charge|budget)(?:cny|rmb|usd|amount|unit|basis|total|actual|estimated)$/.test(normalized);
+}
+
+export function toPublicValue(value, key = '') {
+  if (Array.isArray(value)) return value.map(item => toPublicValue(item, key));
+  if (!value || typeof value !== 'object') {
+    return typeof value === 'string' && USER_MESSAGE_KEYS.has(key) ? sanitizeUserText(value) : value;
+  }
+  const output = {};
+  for (const [entryKey, entryValue] of Object.entries(value)) {
+    if (isPrivateCostKey(entryKey)) continue;
+    output[entryKey] = toPublicValue(entryValue, entryKey);
+  }
+  return output;
+}
+
+export { PRIVATE_COST_KEYS };

+ 33 - 0
fmode-image-set/mcp/src/core/redaction.mjs

@@ -0,0 +1,33 @@
+const SECRET_PATTERNS = [
+  /Bearer\s+[A-Za-z0-9._~+/=:-]+/gi,
+  /\bsk-[A-Za-z0-9._-]{8,}\b/gi,
+  /\br:[A-Za-z0-9._-]{8,}\b/gi,
+  /(authorization|api[_-]?key|token|secret)(["'\s:=]+)([^\s,"'}]+)/gi
+];
+
+export function redactText(value) {
+  let text = String(value ?? '');
+  for (const pattern of SECRET_PATTERNS) {
+    text = text.replace(pattern, (match, label, sep) => {
+      if (label) return `${label}${sep}***`;
+      return match.toLowerCase().startsWith('bearer') ? 'Bearer ***' : '***';
+    });
+  }
+  return text;
+}
+
+export function safeError(error, fallback = '外部服务调用失败。') {
+  const message = redactText(error instanceof Error ? error.message : error);
+  return message && message.length <= 240 ? message : fallback;
+}
+
+export function redactObject(value) {
+  if (Array.isArray(value)) return value.map(redactObject);
+  if (value && typeof value === 'object') {
+    return Object.fromEntries(Object.entries(value).map(([key, item]) => {
+      if (/authorization|api.?key|token|secret/i.test(key)) return [key, '***'];
+      return [key, redactObject(item)];
+    }));
+  }
+  return typeof value === 'string' ? redactText(value) : value;
+}

+ 19 - 0
fmode-image-set/mcp/src/core/request-key.mjs

@@ -0,0 +1,19 @@
+import { createHash } from 'node:crypto';
+
+export function stableStringify(value) {
+  if (value === undefined || typeof value === 'function' || typeof value === 'symbol') return undefined;
+  if (Array.isArray(value)) return `[${value.map(item => stableStringify(item) ?? 'null').join(',')}]`;
+  if (value && typeof value === 'object') {
+    return `{${Object.keys(value).sort().map(key => [key, stableStringify(value[key])]).filter(([, item]) => item !== undefined).map(([key, item]) => `${JSON.stringify(key)}:${item}`).join(',')}}`;
+  }
+  return JSON.stringify(value);
+}
+
+export function sha256(value) {
+  return createHash('sha256').update(String(value)).digest('hex');
+}
+
+export function createRequestKey({ sessionId, promptId, action, planHash = '', extra = {} }) {
+  if (!sessionId || !promptId || !action) throw new Error('sessionId、promptId 和 action 为必填项。');
+  return sha256(stableStringify({ sessionId, promptId, action, planHash, extra }));
+}

+ 40 - 0
fmode-image-set/mcp/src/core/result-envelope.mjs

@@ -0,0 +1,40 @@
+import { toPublicValue } from './public-view.mjs';
+
+const STATUSES = new Set([
+  'ok', 'needs_input', 'needs_token', 'needs_valid_token', 'needs_confirmation',
+  'needs_recharge', 'provider_unavailable', 'partial', 'submission_unknown',
+  'quality_rejected', 'blocked', 'error'
+]);
+
+export function resultEnvelope(payload = {}) {
+  const status = STATUSES.has(payload.status) ? payload.status : 'error';
+  return {
+    status,
+    assistantMessage: String(payload.assistantMessage || ''),
+    summary: payload.summary || {},
+    data: payload.data || {},
+    files: Array.isArray(payload.files) ? payload.files : [],
+    nextActions: Array.isArray(payload.nextActions) ? payload.nextActions : [],
+    warnings: Array.isArray(payload.warnings) ? payload.warnings : [],
+    errors: Array.isArray(payload.errors) ? payload.errors : []
+  };
+}
+
+export function okResult(payload = {}) {
+  return resultEnvelope({ ...payload, status: payload.status || 'ok' });
+}
+
+export function errorResult(status, assistantMessage, payload = {}) {
+  return resultEnvelope({ ...payload, status, assistantMessage });
+}
+
+export function asToolResult(envelope) {
+  const value = toPublicValue(resultEnvelope(envelope));
+  return {
+    content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
+    structuredContent: value,
+    isError: value.status === 'error'
+  };
+}
+
+export { STATUSES };

+ 107 - 0
fmode-image-set/mcp/src/core/run-paths.mjs

@@ -0,0 +1,107 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+export const CURRENT_OUTPUT_SEGMENTS = ['输出', '商品套图'];
+export const LEGACY_OUTPUT_SEGMENTS = ['outputs', 'fmode-image-set'];
+
+function normalized(value) {
+  const resolved = path.resolve(value);
+  return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
+}
+
+export function isInside(parent, child) {
+  const parentPath = normalized(parent);
+  const childPath = normalized(child);
+  return childPath === parentPath || childPath.startsWith(`${parentPath}${path.sep}`);
+}
+
+function samePath(left, right) {
+  return normalized(left) === normalized(right);
+}
+
+function realExisting(value) {
+  return fs.realpathSync.native(path.resolve(value));
+}
+
+export function assertExistingPathChainInside(workspace, target, { kind = '输出目录' } = {}) {
+  const realWorkspace = realExisting(workspace);
+  let cursor = path.resolve(workspace);
+  const relative = path.relative(cursor, path.resolve(target));
+  for (const segment of relative.split(path.sep).filter(Boolean)) {
+    cursor = path.join(cursor, segment);
+    if (!fs.existsSync(cursor)) break;
+    const stat = fs.lstatSync(cursor);
+    if (stat.isSymbolicLink()) throw new Error(`${kind}不能经过符号链接或联接点。`);
+    const realCursor = realExisting(cursor);
+    if (!isInside(realWorkspace, realCursor)) throw new Error(`${kind}必须位于当前项目内,真实路径不能越过边界。`);
+  }
+  return realWorkspace;
+}
+
+export function resolveOutputRoot({ workspaceRoot = process.cwd(), outputRoot, packageRoot } = {}) {
+  const workspace = path.resolve(workspaceRoot);
+  if (!fs.existsSync(workspace) || !fs.statSync(workspace).isDirectory()) throw new Error('当前项目目录不存在。');
+  const expected = path.join(workspace, ...CURRENT_OUTPUT_SEGMENTS);
+  const root = path.resolve(outputRoot || expected);
+  if (!samePath(root, expected)) throw new Error('输出目录固定为当前项目的 输出/商品套图。');
+  assertExistingPathChainInside(workspace, root);
+  if (packageRoot && isInside(path.resolve(packageRoot), root)) throw new Error('拒绝把运行结果写入 Skill 源码目录。');
+  return root;
+}
+
+export function resolveLegacyOutputRoot({ workspaceRoot = process.cwd(), packageRoot } = {}) {
+  const workspace = path.resolve(workspaceRoot);
+  if (!fs.existsSync(workspace) || !fs.statSync(workspace).isDirectory()) throw new Error('当前项目目录不存在。');
+  const root = path.join(workspace, ...LEGACY_OUTPUT_SEGMENTS);
+  assertExistingPathChainInside(workspace, root);
+  if (packageRoot && isInside(path.resolve(packageRoot), root)) throw new Error('拒绝把运行结果写入 Skill 源码目录。');
+  return root;
+}
+
+export function sanitizeProductName(value, fallback = '未命名商品') {
+  const normalizedValue = String(value || '').normalize('NFKC')
+    .replace(/[^\p{L}\p{N}_-]+/gu, '-')
+    .replace(/-+/g, '-')
+    .replace(/^[.\s_-]+|[.\s_-]+$/g, '');
+  const safe = [...normalizedValue].slice(0, 48).join('').replace(/[.\s_-]+$/g, '');
+  const candidate = safe || fallback;
+  return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate) ? `${candidate}-商品` : candidate;
+}
+
+export function createReadableRunId({ productName, requestKey }) {
+  const suffix = String(requestKey || '').toLowerCase().replace(/[^a-f0-9]/g, '').slice(0, 8);
+  if (suffix.length !== 8) throw new Error('无法生成稳定运行标识。');
+  return `${sanitizeProductName(productName)}-${suffix}`;
+}
+
+function assertRunId(runId) {
+  const value = String(runId || '');
+  if (value.length < 3 || value.length > 96 || !/^[\p{L}\p{N}_-]+$/u.test(value)) throw new Error('非法运行标识。');
+  return value;
+}
+
+export function resolveRunDirectory({ workspaceRoot, outputRoot, packageRoot, runId, layout = 'current' }) {
+  const safeRunId = assertRunId(runId);
+  const workspace = path.resolve(workspaceRoot || process.cwd());
+  const root = layout === 'legacy'
+    ? resolveLegacyOutputRoot({ workspaceRoot, packageRoot })
+    : resolveOutputRoot({ workspaceRoot, outputRoot, packageRoot });
+  const runDirectory = path.resolve(root, safeRunId);
+  if (!isInside(root, runDirectory) || runDirectory === root) throw new Error('非法运行目录。');
+  assertExistingPathChainInside(workspace, runDirectory);
+  return { outputRoot: root, runDirectory, layout };
+}
+
+export function resolveExistingRunDirectory({ workspaceRoot, packageRoot, runId }) {
+  const candidates = [
+    resolveRunDirectory({ workspaceRoot, packageRoot, runId, layout: 'current' }),
+    resolveRunDirectory({ workspaceRoot, packageRoot, runId, layout: 'legacy' })
+  ];
+  return candidates.find(candidate => fs.existsSync(path.join(candidate.runDirectory, 'state.json'))) || candidates[0];
+}
+
+export function assertSafeFile(runDirectory, filePath) {
+  const target = path.resolve(filePath);
+  if (!isInside(runDirectory, target) || target === path.resolve(runDirectory)) throw new Error('文件路径越过运行目录边界。');
+  return target;
+}

+ 50 - 0
fmode-image-set/mcp/src/core/state-store.mjs

@@ -0,0 +1,50 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { sha256, stableStringify } from './request-key.mjs';
+import { assertSafeFile, isInside } from './run-paths.mjs';
+
+function checksum(payload) {
+  return sha256(stableStringify(payload));
+}
+
+function assertRegularStatePath(runDirectory, filePath, label) {
+  if (!fs.existsSync(filePath)) return;
+  const stat = fs.lstatSync(filePath);
+  if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) throw new Error(`${label}必须是运行目录内的普通独占文件。`);
+  const realRun = fs.realpathSync.native(runDirectory);
+  const realFile = fs.realpathSync.native(filePath);
+  if (!isInside(realRun, realFile)) throw new Error(`${label}真实路径越过运行目录边界。`);
+}
+
+export function writeState(runDirectory, payload) {
+  fs.mkdirSync(runDirectory, { recursive: true });
+  const runStat = fs.lstatSync(runDirectory);
+  if (runStat.isSymbolicLink() || !runStat.isDirectory()) throw new Error('运行目录必须是普通目录。');
+  const filePath = assertSafeFile(runDirectory, path.join(runDirectory, 'state.json'));
+  const document = { schemaVersion: 1, checksum: checksum(payload), payload };
+  const tempPath = assertSafeFile(runDirectory, `${filePath}.tmp`);
+  assertRegularStatePath(runDirectory, filePath, '状态文件');
+  assertRegularStatePath(runDirectory, tempPath, '状态临时文件');
+  if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
+  try {
+    fs.writeFileSync(tempPath, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
+    assertRegularStatePath(runDirectory, tempPath, '状态临时文件');
+    fs.renameSync(tempPath, filePath);
+  } catch (error) {
+    try {
+      if (fs.existsSync(tempPath) && !fs.lstatSync(tempPath).isSymbolicLink()) fs.unlinkSync(tempPath);
+    } catch { /* Keep the primary state error. */ }
+    throw error;
+  }
+  return filePath;
+}
+
+export function readState(runDirectory) {
+  const filePath = assertSafeFile(runDirectory, path.join(runDirectory, 'state.json'));
+  assertRegularStatePath(runDirectory, filePath, '状态文件');
+  const document = JSON.parse(fs.readFileSync(filePath, 'utf8'));
+  if (document.schemaVersion !== 1 || document.checksum !== checksum(document.payload)) {
+    throw new Error('运行状态完整性校验失败。');
+  }
+  return document.payload;
+}

+ 1313 - 0
fmode-image-set/mcp/src/features/generation/orchestrator.mjs

@@ -0,0 +1,1313 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { createHash, randomUUID } from 'node:crypto';
+import { resolveImageInputs } from '../../core/attachment-resolver.mjs';
+import { createRequestKey, sha256, stableStringify } from '../../core/request-key.mjs';
+import { errorResult, okResult } from '../../core/result-envelope.mjs';
+import { toPublicValue } from '../../core/public-view.mjs';
+import { safeError } from '../../core/redaction.mjs';
+import { assertExistingPathChainInside, assertSafeFile, createReadableRunId, isInside, resolveExistingRunDirectory, resolveRunDirectory } from '../../core/run-paths.mjs';
+import { readState, writeState } from '../../core/state-store.mjs';
+import {
+  COST_UNIT, GEMINI_IMAGE_MODEL, generationReserveCny, NewApiImageProvider,
+  SEEDREAM_IMAGE_MODEL, SUPPORTED_GENERATION_MODELS
+} from '../../providers/new-api-image.mjs';
+import { ProviderError } from '../../providers/provider-error.mjs';
+import { toPublicPlan, verifyPlan } from '../planning/planner.mjs';
+import { validateDomesticFiles } from './platform-validation.mjs';
+
+export const PACKAGE_ROOT = path.resolve(import.meta.dirname, '..', '..', '..', '..');
+const ACTIVE_REVISION_LOCKS = new Set();
+const ACTIVE_RUN_LOCKS = new Set();
+const DEFAULT_MAX_GENERATION_ATTEMPTS = 3;
+const DEFAULT_RETRY_DELAYS_MS = [1500, 4000];
+
+function now() { return new Date().toISOString(); }
+
+function trustedExecutablePlan(plan, qualityReview = true) {
+  if (plan.schemaVersion !== 3) throw new Error('计划版本已过期,请用当前版本重新规划。');
+  const seen = new Set();
+  const items = plan.items.map((item, index) => {
+    const outputId = String(item.outputId || '');
+    const providerModel = String(item.providerModel || '');
+    const width = Number(item.width);
+    const height = Number(item.height);
+    if (!/^[A-Za-z0-9_-]{1,32}$/.test(outputId) || seen.has(outputId)) throw new Error(`计划第 ${index + 1} 项 outputId 无效或重复。`);
+    seen.add(outputId);
+    if (!SUPPORTED_GENERATION_MODELS.includes(providerModel)) throw new Error(`计划第 ${index + 1} 项使用不支持的生图模型。`);
+    if (typeof item.prompt !== 'string' || !item.prompt.trim() || item.prompt.length > 12000) throw new Error(`计划第 ${index + 1} 项提示词无效。`);
+    if (!Number.isInteger(width) || !Number.isInteger(height) || width < 64 || height < 64 || width > 8192 || height > 8192) throw new Error(`计划第 ${index + 1} 项尺寸无效。`);
+    return {
+      ...item, outputId, providerModel, width, height,
+      estimatedCostCny: generationReserveCny(providerModel, { qualityReview }), costUnit: COST_UNIT
+    };
+  });
+  const estimatedCostCny = Number(items.reduce((sum, item) => sum + item.estimatedCostCny, 0).toFixed(4));
+  return {
+    ...plan, items, estimatedCostCny, costUnit: COST_UNIT, costBasis: 'trusted_usd_price_and_exchange_rate',
+    confirmationMessage: `计划共 ${items.length} 张;确认后执行。`
+  };
+}
+
+export function buildOperationPlan({ providerModel = GEMINI_IMAGE_MODEL, prompt, productName = '单图处理', referenceImages = [], width = 2048, height = 2048, scale }) {
+  if (!SUPPORTED_GENERATION_MODELS.includes(providerModel)) throw new Error('不支持的生图模型。');
+  const estimatedCostCny = generationReserveCny(providerModel, { qualityReview: true });
+  const item = {
+    outputId: 'O1', outputKind: 'operation', title: '单图操作', reason: '用户明确请求的单图操作',
+    templateId: 'custom', templateName: 'custom', width, height, ratio: `${width}:${height}`,
+    safeZone: '', textPolicy: '', providerModel, prompt: prompt || '在保持商品身份不变的前提下增强图片。',
+    referenceImages, scale, estimatedCostCny, costUnit: COST_UNIT, sortOrder: 0
+  };
+  const core = {
+    schemaVersion: 3, locale: 'zh', preset: 'operation', request: item.prompt,
+    platform: 'general_ecommerce', productName, productDescription: '', analysis: {}, items: [item],
+    estimatedCostCny, costUnit: COST_UNIT, costBasis: 'trusted_usd_price_and_exchange_rate', requiresConfirmation: true,
+    qualityChecks: ['操作结果与原图语义一致', '无明显生成缺陷']
+  };
+  const planHash = sha256(stableStringify(core));
+  return {
+    ...core, planId: `plan_${planHash.slice(0, 16)}`, planHash, createdAt: now(),
+    confirmationMessage: '单图操作已准备好;确认后执行。'
+  };
+}
+
+function summaryResult(state, message) {
+  const completed = state.items.filter(item => item.status === 'completed');
+  const unknown = state.items.filter(item => item.status === 'submission_unknown');
+  const pending = state.items.filter(item => ['submitting', 'receiving', 'revising', 'retry_wait'].includes(item.status));
+  const failed = state.items.filter(item => ['failed', 'partial'].includes(item.status));
+  const rejected = state.items.filter(item => item.status === 'quality_rejected');
+  const skipped = state.items.filter(item => item.status === 'skipped');
+  const retryableIds = [...failed, ...unknown, ...rejected, ...skipped].map(item => item.outputId);
+  const files = [...completed, ...rejected].flatMap(item => item.files || []);
+  const warnings = state.items.flatMap(item => [
+    item.warning,
+    item.quality?.status === 'unverified' ? `质量复核未验证:${item.outputId}` : '',
+    ...(item.platformValidation?.warnings || []).map(value => `国内平台技术规格提醒 ${item.outputId}:${value}`),
+    ...(item.platformValidation?.ruleWarnings || []).map(value => `国内平台规则提醒 ${item.outputId}:${value}`)
+  ]).filter(Boolean);
+  if (state.sourceOrganization?.status === 'failed') warnings.push(`商品原图整理失败:${state.sourceOrganization.error}`);
+  let status = 'ok';
+  if (unknown.length && !completed.length) status = 'submission_unknown';
+  else if (rejected.length && !completed.length) status = 'quality_rejected';
+  else if (completed.length !== state.items.length || pending.length || failed.length || unknown.length || rejected.length || skipped.length) status = completed.length ? 'partial' : 'error';
+  const summary = {
+    runId: state.runId, completed: completed.length, total: state.items.length,
+    sourceImages: state.sourceOrganization?.files || [],
+    failedOutputIds: retryableIds
+  };
+  const organizationDetails = (state.sourceOrganization?.files || []).map(file => `${file.action === 'move' ? '已移动' : '已复制'} ${file.originalPath} → ${file.path}`).join(';');
+  const organizationMessage = state.sourceOrganization?.status === 'completed'
+    ? ` 商品原图已整理到 ${state.sourceOrganization.directory}:移动 ${state.sourceOrganization.moved} 张,复制 ${state.sourceOrganization.copied} 张。${organizationDetails ? ` ${organizationDetails}` : ''}`
+    : state.sourceOrganization?.status === 'failed'
+      ? ' 套图生成结果已保留,但商品原图整理失败,未重新提交任何请求。'
+      : '';
+  const fullMessage = `${message}${organizationMessage}`;
+  const publicState = toPublicValue(state);
+  return status === 'ok'
+    ? okResult({ assistantMessage: fullMessage, summary, data: { run: publicState }, files, warnings })
+    : errorResult(status, fullMessage, {
+      summary: { ...summary, pending: pending.length, failed: failed.length, rejected: rejected.length, skipped: skipped.length, unknown: unknown.length },
+      data: { run: publicState }, files, warnings,
+      nextActions: retryableIds.length
+        ? [`是否只重试 ${retryableIds.join('、')}?确认后使用原 runId 和这些 outputId 继续。`]
+        : ['查询原 run 状态,等待未完成的结果恢复。']
+    });
+}
+
+function trustedBudgetLimit(input, requiredAmount) {
+  const raw = input.budgetLimitCny ?? input.budget_limit_cny;
+  if (raw === undefined || raw === null || raw === '') return requiredAmount;
+  const value = Number(raw);
+  if (!Number.isFinite(value) || value < requiredAmount) throw new Error('内部执行上限不足,请重新确认。');
+  return value;
+}
+
+function retryOptions(dependencies = {}) {
+  return {
+    maxAttempts: Math.max(1, Math.min(5, Number(dependencies.maxGenerationAttempts || DEFAULT_MAX_GENERATION_ATTEMPTS))),
+    delaysMs: dependencies.retryDelaysMs || DEFAULT_RETRY_DELAYS_MS,
+    sleep: dependencies.sleep || (milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)))
+  };
+}
+
+function samePath(left, right) {
+  const a = path.resolve(left);
+  const b = path.resolve(right);
+  return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
+}
+
+function pathKey(value) {
+  const resolved = path.resolve(value);
+  return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
+}
+
+function runLockPath(runDirectory) {
+  return assertSafeFile(runDirectory, path.join(runDirectory, '.generation.lock'));
+}
+
+function runLockClaimPath(runDirectory) {
+  return assertSafeFile(runDirectory, path.join(runDirectory, '.generation.lock.claim'));
+}
+
+function ownerLockState(lockPath, activeLocks) {
+  if (activeLocks.has(pathKey(lockPath))) return { status: 'active', lockPath };
+  if (!fs.existsSync(lockPath)) return { status: 'missing', lockPath };
+  const stat = fs.lstatSync(lockPath);
+  if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) return { status: 'unsafe', lockPath };
+  let owner;
+  try { owner = JSON.parse(fs.readFileSync(lockPath, 'utf8')); }
+  catch { return { status: Date.now() - stat.mtimeMs < 30_000 ? 'active' : 'stale', lockPath }; }
+  const pid = Number(owner?.pid);
+  if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return { status: 'stale', lockPath };
+  try {
+    process.kill(pid, 0);
+    return { status: 'active', lockPath, pid };
+  } catch (error) {
+    if (error?.code === 'ESRCH') return { status: 'stale', lockPath, pid };
+    return { status: 'active', lockPath, pid };
+  }
+}
+
+function runLockState(runDirectory) {
+  return ownerLockState(runLockPath(runDirectory), ACTIVE_RUN_LOCKS);
+}
+
+function createOwnedRunLock(lockPath, requestKey) {
+  const token = randomUUID();
+  const handle = fs.openSync(lockPath, 'wx');
+  try { fs.writeFileSync(handle, `${JSON.stringify({ pid: process.pid, token, requestKey, createdAt: now() })}\n`, 'utf8'); }
+  catch (error) {
+    try { fs.closeSync(handle); } catch { /* Preserve primary error. */ }
+    try { fs.unlinkSync(lockPath); } catch { /* Preserve primary error. */ }
+    throw error;
+  }
+  const activeKey = pathKey(lockPath);
+  ACTIVE_RUN_LOCKS.add(activeKey);
+  return { handle, activeKey, token };
+}
+
+function acquireRunLock(runDirectory, requestKey) {
+  const lockPath = runLockPath(runDirectory);
+  const claimPath = runLockClaimPath(runDirectory);
+  for (let attempt = 0; attempt < 4; attempt += 1) {
+    if (fs.existsSync(claimPath)) {
+      const claim = ownerLockState(claimPath, new Set());
+      if (claim.status !== 'stale') {
+        const error = new Error('run lock takeover is active or unsafe');
+        error.code = 'EEXIST';
+        throw error;
+      }
+      try {
+        const lock = createOwnedRunLock(lockPath, requestKey);
+        try { fs.unlinkSync(claimPath); } catch { /* The owned main lock remains authoritative. */ }
+        return lock;
+      } catch (error) {
+        if (error?.code === 'EEXIST') throw error;
+        throw error;
+      }
+    }
+
+    try { return createOwnedRunLock(lockPath, requestKey); }
+    catch (error) {
+      if (error?.code !== 'EEXIST') throw error;
+    }
+
+    const observed = ownerLockState(lockPath, ACTIVE_RUN_LOCKS);
+    if (observed.status !== 'stale') {
+      const error = new Error('run lock is active or unsafe');
+      error.code = 'EEXIST';
+      throw error;
+    }
+    try { fs.renameSync(lockPath, claimPath); }
+    catch (error) {
+      if (['ENOENT', 'EEXIST', 'EPERM'].includes(error?.code)) continue;
+      throw error;
+    }
+
+    const claimed = ownerLockState(claimPath, new Set());
+    if (claimed.status !== 'stale') {
+      try { fs.renameSync(claimPath, lockPath); } catch { /* Keep the claim visible rather than deleting another owner. */ }
+      const error = new Error('run lock owner changed during stale takeover');
+      error.code = 'EEXIST';
+      throw error;
+    }
+    try {
+      const lock = createOwnedRunLock(lockPath, requestKey);
+      try { fs.unlinkSync(claimPath); } catch { /* The owned main lock remains authoritative. */ }
+      return lock;
+    } catch (error) {
+      if (error?.code === 'EEXIST') continue;
+      throw error;
+    }
+  }
+  const error = new Error('run lock changed repeatedly during acquisition');
+  error.code = 'EEXIST';
+  throw error;
+}
+
+function releaseRunLock(runDirectory, lock) {
+  if (!lock) return;
+  ACTIVE_RUN_LOCKS.delete(lock.activeKey);
+  try { fs.closeSync(lock.handle); } catch { /* State is already durable. */ }
+  let releasedOwnedLock = false;
+  try {
+    const lockPath = runLockPath(runDirectory);
+    const owner = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
+    if (owner?.token === lock.token) {
+      fs.unlinkSync(lockPath);
+      releasedOwnedLock = true;
+    }
+  } catch { /* Never delete a lock that cannot be proven to belong to this operation. */ }
+  if (releasedOwnedLock) {
+    try {
+      const claimPath = runLockClaimPath(runDirectory);
+      if (ownerLockState(claimPath, new Set()).status === 'stale') fs.unlinkSync(claimPath);
+    } catch { /* A live or unsafe claim must remain visible. */ }
+  }
+}
+
+function hashFile(filePath) {
+  return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
+}
+
+function sourceRecords(references, workspaceRoot) {
+  return references.filter(reference => reference.localPath).map((reference, index) => ({
+    index: index + 1,
+    originalRelativePath: reference.workspaceRelativePath || path.relative(workspaceRoot, reference.localPath),
+    originalName: path.basename(reference.localPath),
+    contentHash: reference.contentHash || hashFile(reference.localPath),
+    size: reference.size ?? fs.statSync(reference.localPath).size,
+    action: samePath(path.dirname(reference.localPath), workspaceRoot) ? 'move' : 'copy',
+    status: 'pending'
+  }));
+}
+
+function verifyLocalSource(record, workspaceRoot) {
+  const workspace = fs.realpathSync.native(path.resolve(workspaceRoot));
+  const candidate = path.resolve(workspaceRoot, record.originalRelativePath);
+  if (!isInside(workspace, candidate) || samePath(workspace, candidate)) throw new Error(`商品原图越过当前项目边界:${record.originalName}`);
+  assertExistingPathChainInside(workspace, candidate, { kind: '商品原图路径' });
+  const linkStat = fs.lstatSync(candidate);
+  if (linkStat.isSymbolicLink()) throw new Error(`商品原图不能通过符号链接整理:${record.originalName}`);
+  const realPath = fs.realpathSync.native(candidate);
+  if (!isInside(workspace, realPath)) throw new Error(`商品原图真实路径越过当前项目边界:${record.originalName}`);
+  const stat = fs.statSync(realPath);
+  if (!stat.isFile() || stat.size !== record.size || hashFile(realPath) !== record.contentHash) throw new Error(`商品原图在生成期间发生变化:${record.originalName}`);
+  const action = samePath(path.dirname(realPath), workspace) ? 'move' : 'copy';
+  if (action !== record.action) throw new Error(`商品原图位置在生成期间发生变化:${record.originalName}`);
+  return realPath;
+}
+
+function organizeSourceImages(state, runDirectory, workspaceRoot) {
+  if (!state.sourceImages?.length) {
+    state.sourceOrganization = { status: 'not_applicable', moved: 0, copied: 0, files: [] };
+    return;
+  }
+  const directory = assertSafeFile(runDirectory, path.join(runDirectory, '商品原图'));
+  const created = [];
+  let preparedPersisted = false;
+  try {
+    if (fs.existsSync(directory) && fs.lstatSync(directory).isSymbolicLink()) throw new Error('商品原图目录不能是符号链接。');
+    fs.mkdirSync(directory, { recursive: true });
+    const prepared = state.sourceImages.map((record, index) => {
+      const expectedSource = path.resolve(workspaceRoot, record.originalRelativePath);
+      const finalName = `${String(index + 1).padStart(2, '0')}-${record.originalName}`;
+      const finalPath = assertSafeFile(runDirectory, path.join(directory, finalName));
+      const tempPath = assertSafeFile(runDirectory, path.join(directory, `.整理中-${String(index + 1).padStart(2, '0')}`));
+      if (fs.existsSync(tempPath)) {
+        const tempStat = fs.lstatSync(tempPath);
+        if (tempStat.isSymbolicLink() || !tempStat.isFile()) throw new Error(`商品原图临时目标类型异常:${record.originalName}`);
+        fs.unlinkSync(tempPath);
+      }
+      if (fs.existsSync(finalPath)) {
+        const finalStat = fs.lstatSync(finalPath);
+        if (finalStat.isSymbolicLink() || !finalStat.isFile() || !isInside(runDirectory, fs.realpathSync.native(finalPath))) throw new Error(`商品原图目标路径异常:${finalName}`);
+        if (hashFile(finalPath) !== record.contentHash) throw new Error(`商品原图目标内容冲突:${finalName}`);
+        return { record, sourcePath: fs.existsSync(expectedSource) ? verifyLocalSource(record, workspaceRoot) : expectedSource, finalPath, tempPath };
+      }
+      const sourcePath = verifyLocalSource(record, workspaceRoot);
+      fs.copyFileSync(sourcePath, tempPath, fs.constants.COPYFILE_EXCL);
+      if (hashFile(tempPath) !== record.contentHash) throw new Error(`商品原图整理校验失败:${record.originalName}`);
+      fs.renameSync(tempPath, finalPath);
+      created.push(finalPath);
+      return { record, sourcePath, finalPath, tempPath };
+    });
+    const files = prepared.map(item => ({
+      originalPath: path.resolve(workspaceRoot, item.record.originalRelativePath),
+      path: item.finalPath,
+      action: item.record.action,
+      status: item.record.action === 'move' ? 'prepared' : 'completed'
+    }));
+    state.sourceImages = state.sourceImages.map((record, index) => ({ ...record, status: files[index].status, destination: files[index].path }));
+    state.sourceOrganization = {
+      status: 'prepared', directory,
+      moved: 0,
+      copied: files.filter(file => file.action === 'copy').length,
+      files
+    };
+    // Persist verified destination copies before deleting any root-level original.
+    save(runDirectory, state);
+    preparedPersisted = true;
+    for (let index = 0; index < prepared.length; index += 1) {
+      const item = prepared[index];
+      if (item.record.action !== 'move') continue;
+      if (fs.existsSync(item.sourcePath)) {
+        verifyLocalSource(item.record, workspaceRoot);
+        fs.unlinkSync(item.sourcePath);
+      }
+      files[index].status = 'completed';
+      state.sourceImages[index].status = 'completed';
+      state.sourceOrganization.moved += 1;
+      save(runDirectory, state);
+    }
+    state.sourceOrganization.status = 'completed';
+  } catch (error) {
+    if (!preparedPersisted) {
+      for (const filePath of created) {
+        try { fs.rmSync(filePath, { force: true }); } catch { /* Keep the primary failure. */ }
+      }
+    }
+    const previous = state.sourceOrganization || {};
+    state.sourceOrganization = {
+      status: 'failed', directory,
+      moved: Number(previous.moved || 0), copied: Number(previous.copied || 0),
+      files: previous.files || [], error: safeError(error)
+    };
+  }
+}
+
+function save(runDirectory, state) {
+  state.updatedAt = now();
+  writeState(runDirectory, state);
+}
+
+function localImage(filePath) {
+  const extension = path.extname(filePath).toLowerCase();
+  const mime = extension === '.jpg' || extension === '.jpeg' ? 'image/jpeg' : extension === '.webp' ? 'image/webp' : 'image/png';
+  return { url: `data:${mime};base64,${fs.readFileSync(filePath).toString('base64')}` };
+}
+
+async function reviewItem(item, state, runDirectory, reviewer, referenceImages, expectedIdentity, qualityReview) {
+  if (qualityReview === false) {
+    item.quality = { status: 'skipped', reason: 'quality_review_disabled' };
+    return;
+  }
+  try {
+    const originals = referenceImages.map(url => ({ url }));
+    const generated = item.files.map(localImage);
+    const result = await reviewer.review(originals, generated, {
+      expectedIdentity, outputId: item.outputId, title: item.title,
+      ...(item.platformRule?.domestic ? { platformRule: item.platformRule } : {})
+    });
+    const review = result?.review || result;
+    if (!review || typeof review.passed !== 'boolean') throw new Error('Gemini 质量复核结果缺少 passed 布尔值。');
+    item.quality = { ...review, model: result?.model || '', status: review.passed ? 'passed' : 'rejected' };
+    if (!review.passed) item.status = 'quality_rejected';
+  } catch (error) {
+    item.quality = { status: 'unverified', error: safeError(error) };
+    item.warning = 'quality_review_unverified';
+  }
+  save(runDirectory, state);
+}
+
+function applyDomesticPlatformValidation(item) {
+  const validation = validateDomesticFiles(item);
+  if (!validation) return;
+  item.platformValidation = validation;
+  if (!validation.passed) {
+    item.status = 'quality_rejected';
+    item.warning = 'domestic_platform_validation_failed';
+  }
+}
+
+function recomputeRunStatus(state) {
+  state.status = state.items.every(item => item.status === 'completed') ? 'completed'
+    : state.items.some(item => item.status === 'completed') ? 'partial'
+      : state.items.some(item => item.status === 'submission_unknown') ? 'submission_unknown'
+        : state.items.some(item => item.status === 'quality_rejected') ? 'quality_rejected' : 'failed';
+  return state.status;
+}
+
+function nextRevisionVersion(item, runDirectory) {
+  let version = Math.max(2, Number(item.revisionCount || 0) + 2);
+  const names = fs.readdirSync(runDirectory);
+  while (names.some(name => name.startsWith(`${item.outputId}-v${version}-`))) version += 1;
+  return version;
+}
+
+function revisionLockPath(item, runDirectory) {
+  return assertSafeFile(runDirectory, path.join(runDirectory, `.revision-${item.outputId}.lock`));
+}
+
+function removeRevisionLock(item, runDirectory) {
+  const lockPath = revisionLockPath(item, runDirectory);
+  if (!fs.existsSync(lockPath)) return;
+  const stat = fs.lstatSync(lockPath);
+  if (!stat.isSymbolicLink() && stat.isFile() && stat.nlink === 1) fs.unlinkSync(lockPath);
+}
+
+function revisionLockState(item, runDirectory) {
+  const lockPath = revisionLockPath(item, runDirectory);
+  return ownerLockState(lockPath, ACTIVE_REVISION_LOCKS);
+}
+
+function revisionPaths(item) {
+  return [
+    ...(item.files || []),
+    ...(item.revisions || []).flatMap(revision => [...(revision.sourceFiles || []), ...(revision.files || [])])
+  ];
+}
+
+function findSuiteRevisionTarget(image, workspaceRoot, packageRoot) {
+  if (typeof image !== 'string' || /^https?:|^data:/i.test(image)) return null;
+  const candidate = path.resolve(workspaceRoot, image);
+  if (!fs.existsSync(candidate) || !fs.lstatSync(candidate).isFile()) return null;
+  assertExistingPathChainInside(workspaceRoot, candidate, { kind: '套图返修源图路径' });
+  const realCandidate = fs.realpathSync.native(candidate);
+  if (!isInside(fs.realpathSync.native(workspaceRoot), realCandidate)) throw new Error('套图返修源图必须位于当前项目内。');
+  const runDirectory = path.dirname(candidate);
+  if (!fs.existsSync(path.join(runDirectory, 'state.json'))) return null;
+  const state = readState(runDirectory);
+  const resolved = resolveExistingRunDirectory({ workspaceRoot, packageRoot, runId: state.runId });
+  if (!samePath(resolved.runDirectory, runDirectory)) throw new Error('套图返修源图所在目录与运行状态不一致。');
+  const item = (state.items || []).find(entry => revisionPaths(entry).some(filePath => samePath(filePath, candidate)));
+  if (!item) return null;
+  const currentFile = (item.files || []).some(filePath => samePath(filePath, candidate));
+  if (item.status !== 'quality_rejected' && !item.revisions?.length) return null;
+  return { state, item, runDirectory, sourceFile: candidate, currentFile };
+}
+
+function assertSafeSuiteRevisionSource(sourceFile, runDirectory) {
+  const sourceStat = fs.lstatSync(sourceFile);
+  if (sourceStat.isSymbolicLink() || !sourceStat.isFile() || sourceStat.nlink !== 1) {
+    throw new Error('套图返修源图必须是原运行目录内的普通独占文件。');
+  }
+  assertSafeFile(runDirectory, sourceFile);
+  if (!isInside(fs.realpathSync.native(runDirectory), fs.realpathSync.native(sourceFile))) {
+    throw new Error('套图返修源图真实路径越过原运行目录。');
+  }
+}
+
+function applyRevisionOutcome(item, revision, { quality, platformValidation }) {
+  revision.quality = quality;
+  if (platformValidation) revision.platformValidation = platformValidation;
+  const rejected = quality?.status === 'rejected' || platformValidation?.status === 'rejected';
+  revision.status = rejected ? 'quality_rejected' : 'completed';
+  item.files = [...revision.files];
+  item.requestId = revision.requestId || item.requestId;
+  item.usage = revision.usage || {};
+  item.quality = quality;
+  if (platformValidation) item.platformValidation = platformValidation;
+  else delete item.platformValidation;
+  item.status = rejected ? 'quality_rejected' : 'completed';
+  item.warning = rejected ? 'revision_quality_rejected' : quality?.status === 'unverified' ? 'quality_review_unverified' : '';
+  delete item.error;
+  delete item.errorKind;
+}
+
+function finalizeRevisionRun(state, runDirectory, workspaceRoot) {
+  recomputeRunStatus(state);
+  if (state.status === 'completed' && !['completed', 'failed', 'not_applicable'].includes(state.sourceOrganization?.status)) {
+    organizeSourceImages(state, runDirectory, workspaceRoot);
+  }
+  save(runDirectory, state);
+}
+
+async function reviewRevision(item, revision, reviewer, referenceImages, qualityReview) {
+  if (qualityReview === false) return { status: 'skipped', reason: 'quality_review_disabled' };
+  try {
+    const result = await reviewer.review(
+      referenceImages.map(url => ({ url })),
+      revision.files.map(localImage),
+      {
+        expectedIdentity: {}, outputId: item.outputId, title: item.title,
+        ...(item.platformRule?.domestic ? { platformRule: item.platformRule } : {})
+      }
+    );
+    const review = result?.review || result;
+    if (!review || typeof review.passed !== 'boolean') throw new Error('Gemini 质量复核结果缺少 passed 布尔值。');
+    return { ...review, model: result?.model || '', status: review.passed ? 'passed' : 'rejected' };
+  } catch (error) {
+    return { status: 'unverified', error: safeError(error) };
+  }
+}
+
+export async function executeSuiteRevision(input = {}, dependencies = {}) {
+  const workspaceRoot = path.resolve(dependencies.workspaceRoot || process.env.FMODE_WORKSPACE_ROOT || process.cwd());
+  const packageRoot = dependencies.packageRoot || PACKAGE_ROOT;
+  let target;
+  try { target = findSuiteRevisionTarget(input.image, workspaceRoot, packageRoot); }
+  catch (error) { return errorResult('blocked', safeError(error)); }
+  if (!target) return null;
+
+  const qualityReview = input.qualityReview !== false;
+  let operationPlan;
+  try { operationPlan = trustedExecutablePlan(verifyPlan(input.plan), qualityReview); }
+  catch (error) { return errorResult('needs_input', safeError(error)); }
+  const operation = {
+    ...operationPlan.items[0],
+    width: Number(input.width || target.item.width || operationPlan.items[0].width),
+    height: Number(input.height || target.item.height || operationPlan.items[0].height)
+  };
+  const estimatedCostCny = operation.estimatedCostCny;
+  if (input.confirmed !== true) {
+    return errorResult('needs_confirmation', `返修 ${target.item.outputId} 已准备好,确认后才会生成。`);
+  }
+  let budgetLimitCny;
+  try { budgetLimitCny = trustedBudgetLimit(input, estimatedCostCny); }
+  catch (error) { return errorResult('needs_confirmation', safeError(error)); }
+  let requestKey;
+  try {
+    requestKey = createRequestKey({
+      sessionId: input.sessionId || input.session_id,
+      promptId: input.promptId || input.prompt_id,
+      action: 'revise_image_set_item', planHash: target.state.planHash,
+      extra: {
+        outputId: target.item.outputId, operationPlanHash: operationPlan.planHash,
+        sourceFile: path.basename(target.sourceFile), width: operation.width, height: operation.height
+      }
+    });
+  } catch (error) { return errorResult('needs_input', safeError(error)); }
+
+  let lockPath;
+  try { lockPath = revisionLockPath(target.item, target.runDirectory); }
+  catch (error) { return errorResult('blocked', safeError(error)); }
+  let runLock;
+  const existingRunLock = runLockState(target.runDirectory);
+  if (['active', 'unsafe'].includes(existingRunLock.status)) {
+    return errorResult('blocked', '原套图仍有活跃生成、返修或续跑任务,当前编辑未介入。');
+  }
+  try { runLock = acquireRunLock(target.runDirectory, requestKey); }
+  catch (error) {
+    return errorResult(error?.code === 'EEXIST' ? 'blocked' : 'error',
+      error?.code === 'EEXIST' ? '原套图仍有活跃生成、返修或续跑任务,当前编辑未介入。' : safeError(error));
+  }
+
+  let lockHandle;
+  let activeLockKey;
+  try {
+    lockHandle = fs.openSync(lockPath, 'wx');
+    fs.writeFileSync(lockHandle, `${JSON.stringify({ pid: process.pid, requestKey, createdAt: now() })}\n`, 'utf8');
+    activeLockKey = pathKey(lockPath);
+    ACTIVE_REVISION_LOCKS.add(activeLockKey);
+  } catch (error) {
+    if (lockHandle !== undefined) try { fs.closeSync(lockHandle); } catch { /* Preserve lock error. */ }
+    releaseRunLock(target.runDirectory, runLock);
+    return errorResult(error?.code === 'EEXIST' ? 'blocked' : 'error',
+      error?.code === 'EEXIST' ? '该套图项正在返修或等待状态恢复,请先查询原 run。' : safeError(error));
+  }
+
+  try {
+    const latestState = readState(target.runDirectory);
+    recoverInterrupted(latestState, target.runDirectory);
+    const latestItem = latestState.items?.find(item => item.outputId === target.item.outputId);
+    if (!latestItem) return errorResult('blocked', '原套图返修项已不存在。');
+    target.state = latestState;
+    target.item = latestItem;
+    target.currentFile = (latestItem.files || []).some(filePath => samePath(filePath, target.sourceFile));
+
+    target.item.revisions ||= [];
+    const existing = target.item.revisions.find(revision => revision.requestKey === requestKey);
+    if (existing) {
+      const message = ['submitting', 'receiving'].includes(existing.status)
+        ? '该返修曾进入付费提交窗口,无法证明请求未提交;未重复生成。'
+        : '该返修请求已有本地状态,未重复生成。';
+      return summaryResult(target.state, message);
+    }
+    const unfinished = target.item.revisions.find(revision => ['submitting', 'receiving'].includes(revision.status));
+    if (target.item.status === 'revising' || unfinished) {
+      return {
+        ...summaryResult(target.state, '该套图项已有返修结果正在提交或等待恢复,请先查询原 run;未创建新的付费任务。'),
+        status: 'blocked'
+      };
+    }
+    if (target.item.status === 'submission_unknown') {
+      return summaryResult(target.state, '该套图项存在提交结果不确定的返修,核验计费前禁止创建新的付费请求。');
+    }
+    if (target.item.status !== 'quality_rejected' || !target.currentFile) return null;
+    try { assertSafeSuiteRevisionSource(target.sourceFile, target.runDirectory); }
+    catch (error) { return errorResult('blocked', safeError(error)); }
+
+    let references;
+    try {
+      references = resolveImageInputs({
+        workspaceRoot, cwd: workspaceRoot, images: input.referenceImages || [input.image],
+        allowClaudeAttachments: false
+      });
+    } catch (error) { return errorResult('needs_input', safeError(error)); }
+    const referenceImages = references.map(reference => reference.url);
+    const version = nextRevisionVersion(target.item, target.runDirectory);
+    const revision = {
+      revisionId: `revision_${requestKey.slice(0, 16)}`, requestKey, version,
+      status: 'submitting', createdAt: now(), updatedAt: now(), sourceFiles: [...(target.item.files || [])],
+      sourceQuality: target.item.quality || null, files: [], prompt: operation.prompt,
+      providerModel: operation.providerModel, width: operation.width, height: operation.height,
+      estimatedCostCny, costUnit: COST_UNIT, budgetLimitCny,
+      materializeOutputId: `${target.item.outputId}-v${version}`
+    };
+    target.item.revisions.push(revision);
+    target.item.revisionCount = Number(target.item.revisionCount || 0) + 1;
+    target.item.status = 'revising';
+    target.state.status = 'running';
+    target.state.revisionEstimatedCostCny = Number((Number(target.state.revisionEstimatedCostCny || 0) + estimatedCostCny).toFixed(4));
+    save(target.runDirectory, target.state);
+
+    const provider = dependencies.provider || new NewApiImageProvider({ fetchImpl: dependencies.fetchImpl });
+    const reviewer = dependencies.visionProvider || new NewApiImageProvider({ fetchImpl: dependencies.visionFetchImpl || dependencies.fetchImpl });
+    try {
+      const candidate = {
+        ...operation, outputId: revision.materializeOutputId,
+        platformRule: target.item.platformRule, referenceImages
+      };
+      const generated = await provider.generate(candidate);
+      revision.requestId = generated.requestId;
+      revision.providerModel = generated.model;
+      revision.usage = generated.usage || {};
+      revision.status = 'receiving';
+      revision.expectedImages = generated.images.length;
+      revision.resultUrls = generated.images.filter(value => /^https?:\/\//i.test(value));
+      revision.updatedAt = now();
+      if (revision.resultUrls.length) save(target.runDirectory, target.state);
+      for (let index = 0; index < generated.images.length; index += 1) {
+        revision.files.push(await provider.materialize(generated.images[index], target.runDirectory, revision.materializeOutputId, index));
+        save(target.runDirectory, target.state);
+      }
+      const quality = await reviewRevision(target.item, revision, reviewer, referenceImages, qualityReview);
+      const platformValidation = target.item.platformRule?.domestic
+        ? validateDomesticFiles({ files: revision.files, platformRule: target.item.platformRule }) : null;
+      applyRevisionOutcome(target.item, revision, { quality, platformValidation });
+      revision.updatedAt = now();
+      finalizeRevisionRun(target.state, target.runDirectory, workspaceRoot);
+      return summaryResult(target.state, revision.status === 'completed'
+        ? `${target.item.outputId} 返修已归位原套图,历史文件已保留。`
+        : `${target.item.outputId} 返修结果已保留在原套图,但仍未通过质量检查。`);
+    } catch (error) {
+      const kind = error instanceof ProviderError ? error.kind : 'error';
+      if (revision.status === 'receiving' && revision.resultUrls?.length) {
+        revision.warning = 'result_download_pending';
+        revision.error = safeError(error);
+        revision.updatedAt = now();
+        target.item.status = 'revising';
+        target.state.status = 'partial';
+        save(target.runDirectory, target.state);
+        return summaryResult(target.state, `${target.item.outputId} 返修结果下载未完成,已保留远程地址且未重复提交。`);
+      }
+      revision.status = kind === 'submission_unknown' ? 'submission_unknown' : 'failed';
+      revision.errorKind = kind;
+      revision.error = safeError(error);
+      revision.updatedAt = now();
+      target.item.status = kind === 'submission_unknown' ? 'submission_unknown' : 'quality_rejected';
+      recomputeRunStatus(target.state);
+      save(target.runDirectory, target.state);
+      return summaryResult(target.state, kind === 'submission_unknown'
+        ? `${target.item.outputId} 返修提交结果不确定,禁止自动重提。`
+        : `${target.item.outputId} 返修失败,原文件和状态已保留。`);
+    }
+  } finally {
+    if (activeLockKey) ACTIVE_REVISION_LOCKS.delete(activeLockKey);
+    try { if (lockHandle !== undefined) fs.closeSync(lockHandle); } catch { /* State already records the outcome. */ }
+    try { removeRevisionLock(target.item, target.runDirectory); } catch { /* A stale lock is safer than deleting an unexpected path. */ }
+    releaseRunLock(target.runDirectory, runLock);
+  }
+}
+
+function recoverDomesticPlatformValidation(state, runDirectory) {
+  let changed = false;
+  for (const item of state.items || []) {
+    const needsValidation = item.platformRule?.domestic
+      && Array.isArray(item.files) && item.files.length
+      && (!item.platformValidation || item.status === 'validating');
+    if (!needsValidation) continue;
+    if (!item.quality) {
+      item.quality = { status: 'unverified', reason: 'recovered_platform_validation_requires_manual_quality_review' };
+      item.warning = 'quality_review_unverified';
+    }
+    applyDomesticPlatformValidation(item);
+    if (item.status === 'validating' && item.platformValidation?.passed) item.status = 'completed';
+    changed = true;
+  }
+  if (changed) {
+    state.status = state.items.every(item => item.status === 'completed') ? 'completed'
+      : state.items.some(item => item.status === 'completed') ? 'partial'
+        : state.items.some(item => item.status === 'submission_unknown') ? 'submission_unknown'
+          : state.items.some(item => item.status === 'quality_rejected') ? 'quality_rejected' : 'failed';
+    save(runDirectory, state);
+  }
+  return changed;
+}
+
+function canAutomaticallyRetry(kind) {
+  return ['submission_unknown', 'provider_unavailable'].includes(kind);
+}
+
+async function materializeWithRetry(provider, value, runDirectory, outputId, index, options) {
+  let lastError;
+  for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
+    try { return await provider.materialize(value, runDirectory, outputId, index); }
+    catch (error) {
+      lastError = error;
+      const kind = error instanceof ProviderError ? error.kind : 'error';
+      if (kind !== 'provider_unavailable' || attempt >= options.maxAttempts) throw error;
+      await options.sleep(Number(options.delaysMs[Math.min(attempt - 1, options.delaysMs.length - 1)] || 0));
+    }
+  }
+  throw lastError;
+}
+
+async function runItem(item, state, runDirectory, provider, reviewer, referenceImages, expectedIdentity, qualityReview, options) {
+  item.attempts ||= [];
+  const cycle = Number(item.generationCycle || 0);
+  const idempotencyKey = item.activeGenerationKey || sha256(stableStringify({
+    requestKey: state.requestKey, outputId: item.outputId, cycle
+  }));
+  item.activeGenerationKey = idempotencyKey;
+  for (let automaticAttempt = 1; automaticAttempt <= options.maxAttempts; automaticAttempt += 1) {
+    const attempt = {
+      attemptNumber: item.attempts.length + 1,
+      automaticAttempt,
+      generationCycle: cycle,
+      idempotencyKey,
+      status: 'submitting',
+      startedAt: now()
+    };
+    item.attempts.push(attempt);
+    item.status = 'submitting';
+    item.warning = '';
+    delete item.error;
+    delete item.errorKind;
+    save(runDirectory, state);
+    try {
+      const generated = await provider.generate({ ...item, referenceImages, idempotencyKey });
+      attempt.requestId = generated.requestId;
+      attempt.status = 'receiving';
+      item.requestId = generated.requestId;
+      item.providerModel = generated.model;
+      item.usage = generated.usage || {};
+      item.status = 'receiving';
+      item.expectedImages = generated.images.length;
+      item.resultUrls = generated.images.filter(value => /^https?:\/\//i.test(value));
+      if (item.resultUrls.length) save(runDirectory, state);
+      item.files = [];
+      const materializeOutputId = item.materializeOutputId || item.outputId;
+      for (let index = 0; index < generated.images.length; index += 1) {
+        item.files.push(await materializeWithRetry(
+          provider, generated.images[index], runDirectory, materializeOutputId, index, options
+        ));
+        save(runDirectory, state);
+      }
+      item.status = item.files.length ? (item.platformRule?.domestic ? 'validating' : 'completed') : 'partial';
+      if (!item.files.length) item.warning = 'finished_without_image';
+      save(runDirectory, state);
+      if (item.status === 'completed' || item.status === 'validating') {
+        await reviewItem(item, state, runDirectory, reviewer, referenceImages, expectedIdentity, qualityReview);
+      }
+      applyDomesticPlatformValidation(item);
+      if (item.status === 'validating' && item.platformValidation?.passed) item.status = 'completed';
+      attempt.status = item.status;
+      attempt.completedAt = now();
+      delete item.activeGenerationKey;
+      save(runDirectory, state);
+      return;
+    } catch (error) {
+      const kind = error instanceof ProviderError ? error.kind : 'error';
+      attempt.status = kind;
+      attempt.error = safeError(error);
+      attempt.completedAt = now();
+      if (item.status === 'receiving' && item.resultUrls?.length) {
+        item.errorKind = kind;
+        item.error = safeError(error);
+        item.warning = 'result_download_pending';
+        save(runDirectory, state);
+        return;
+      }
+      if (canAutomaticallyRetry(kind) && automaticAttempt < options.maxAttempts) {
+        item.status = 'retry_wait';
+        item.warning = `automatic_retry_${automaticAttempt + 1}_of_${options.maxAttempts}`;
+        attempt.retryScheduled = true;
+        save(runDirectory, state);
+        await options.sleep(Number(options.delaysMs[Math.min(automaticAttempt - 1, options.delaysMs.length - 1)] || 0));
+        continue;
+      }
+      item.status = kind === 'submission_unknown' ? 'submission_unknown' : 'failed';
+      item.errorKind = kind;
+      item.error = safeError(error);
+      delete item.activeGenerationKey;
+      save(runDirectory, state);
+      return;
+    }
+  }
+}
+
+function recoverInterrupted(state, runDirectory) {
+  let changed = false;
+  for (const item of state.items) {
+    if (item.status === 'submitting') {
+      item.status = 'submission_unknown';
+      item.errorKind = 'interrupted_during_submit';
+      item.error = '进程在付费提交窗口中断,无法证明请求未提交。';
+      changed = true;
+    }
+    if (item.status === 'retry_wait') {
+      item.status = 'failed';
+      item.errorKind = 'interrupted_during_retry_wait';
+      item.error = '进程在自动重试等待期间中断;可在原 run 中只重试该项。';
+      delete item.activeGenerationKey;
+      changed = true;
+    }
+    if (item.status === 'revising') {
+      const revision = item.revisions?.at(-1);
+      if (revision?.status === 'submitting') {
+        revision.status = 'submission_unknown';
+        revision.errorKind = 'interrupted_during_submit';
+        revision.error = '进程在返修付费提交窗口中断,无法证明请求未提交。';
+        revision.updatedAt = now();
+        item.status = 'submission_unknown';
+        item.errorKind = 'interrupted_during_revision_submit';
+        item.error = '返修提交状态不确定,禁止自动重提。';
+        try { removeRevisionLock(item, runDirectory); } catch { /* Preserve recovery state. */ }
+        changed = true;
+      }
+    }
+  }
+  if (changed) {
+    state.status = state.items.some(item => item.status === 'submission_unknown') ? 'submission_unknown' : 'failed';
+    save(runDirectory, state);
+  }
+}
+
+function recoverSourceOrganization(state, runDirectory, workspaceRoot) {
+  const terminal = ['completed', 'failed', 'not_applicable'].includes(state.sourceOrganization?.status);
+  if (!terminal && Array.isArray(state.sourceImages) && state.items?.length && state.items.every(item => item.status === 'completed')) {
+    organizeSourceImages(state, runDirectory, workspaceRoot);
+    save(runDirectory, state);
+  }
+}
+
+export async function executePlan(input = {}, dependencies = {}) {
+  const qualityReview = input.qualityReview !== false;
+  let plan;
+  try { plan = trustedExecutablePlan(verifyPlan(input.plan), qualityReview); }
+  catch (error) { return errorResult('needs_input', safeError(error)); }
+  if (input.confirmed !== true) return errorResult('needs_confirmation', plan.confirmationMessage, { data: { plan: toPublicPlan(plan) } });
+  let budgetLimitCny;
+  try { budgetLimitCny = trustedBudgetLimit(input, plan.estimatedCostCny); }
+  catch (error) { return errorResult('needs_confirmation', safeError(error), { data: { plan: toPublicPlan(plan) } }); }
+  let requestKey;
+  try {
+    requestKey = createRequestKey({ sessionId: input.sessionId || input.session_id, promptId: input.promptId || input.prompt_id, action: 'generate', planHash: plan.planHash });
+  } catch (error) { return errorResult('needs_input', safeError(error)); }
+  const workspaceRoot = path.resolve(dependencies.workspaceRoot || process.env.FMODE_WORKSPACE_ROOT || process.cwd());
+  const legacyRunId = `run_${requestKey.slice(0, 20)}`;
+  const runId = input.runId || createReadableRunId({ productName: plan.productName, requestKey });
+  let paths;
+  try { paths = resolveRunDirectory({ workspaceRoot, packageRoot: dependencies.packageRoot || PACKAGE_ROOT, runId }); }
+  catch (error) { return errorResult('blocked', safeError(error)); }
+  const legacyPaths = resolveRunDirectory({ workspaceRoot, packageRoot: dependencies.packageRoot || PACKAGE_ROOT, runId: legacyRunId, layout: 'legacy' });
+  if (fs.existsSync(path.join(legacyPaths.runDirectory, 'state.json'))) {
+    try {
+      const observed = readState(legacyPaths.runDirectory);
+      if (observed.requestKey !== requestKey) return errorResult('blocked', '旧运行标识已属于其他请求,拒绝覆盖。');
+      const lock = runLockState(legacyPaths.runDirectory);
+      if (['active', 'unsafe'].includes(lock.status)) return errorResult('blocked', '该请求仍有活跃生成任务,未介入恢复。', { data: { run: toPublicValue(observed) } });
+      let recoveryLock;
+      try { recoveryLock = acquireRunLock(legacyPaths.runDirectory, `recover:${requestKey}`); }
+      catch (error) {
+        return errorResult(error?.code === 'EEXIST' ? 'blocked' : 'error',
+          error?.code === 'EEXIST' ? '该请求刚刚开始新的活跃操作,未介入恢复。' : safeError(error),
+          { data: { run: toPublicValue(observed) } });
+      }
+      try {
+        const existing = readState(legacyPaths.runDirectory);
+        if (existing.requestKey !== requestKey) return errorResult('blocked', '旧运行标识已属于其他请求,拒绝覆盖。');
+        recoverInterrupted(existing, legacyPaths.runDirectory);
+        recoverDomesticPlatformValidation(existing, legacyPaths.runDirectory);
+        recoverSourceOrganization(existing, legacyPaths.runDirectory, workspaceRoot);
+        return summaryResult(existing, existing.status === 'completed' ? '该请求已在旧版目录完成,未重复提交。' : '已找到同一请求的旧版运行状态,未重复创建生成任务。');
+      } finally { releaseRunLock(legacyPaths.runDirectory, recoveryLock); }
+    } catch (error) { return errorResult('blocked', safeError(error)); }
+  }
+  if (fs.existsSync(path.join(paths.runDirectory, 'state.json'))) {
+    try {
+      const observed = readState(paths.runDirectory);
+      if (observed.requestKey !== requestKey) return errorResult('blocked', 'run_id 已属于其他请求,拒绝覆盖。');
+      const lock = runLockState(paths.runDirectory);
+      if (['active', 'unsafe'].includes(lock.status)) return errorResult('blocked', '该请求仍有活跃生成任务,未介入恢复。', { data: { run: toPublicValue(observed) } });
+      let recoveryLock;
+      try { recoveryLock = acquireRunLock(paths.runDirectory, `recover:${requestKey}`); }
+      catch (error) {
+        return errorResult(error?.code === 'EEXIST' ? 'blocked' : 'error',
+          error?.code === 'EEXIST' ? '该请求刚刚开始新的活跃操作,未介入恢复。' : safeError(error),
+          { data: { run: toPublicValue(observed) } });
+      }
+      try {
+        const existing = readState(paths.runDirectory);
+        if (existing.requestKey !== requestKey) return errorResult('blocked', 'run_id 已属于其他请求,拒绝覆盖。');
+        recoverInterrupted(existing, paths.runDirectory);
+        recoverDomesticPlatformValidation(existing, paths.runDirectory);
+        recoverSourceOrganization(existing, paths.runDirectory, workspaceRoot);
+        return summaryResult(existing, existing.status === 'completed' ? '该请求已经完成,未重复提交。' : '已找到同一请求的运行状态,未重复创建生成任务。');
+      } finally { releaseRunLock(paths.runDirectory, recoveryLock); }
+    } catch (error) { return errorResult('blocked', safeError(error)); }
+  }
+  let references = [];
+  try {
+    references = resolveImageInputs({
+      ...input, workspaceRoot, cwd: workspaceRoot, images: input.referenceImages || input.images || [],
+      allowClaudeAttachments: input.allowClaudeAttachments ?? true
+    });
+  } catch (error) { return errorResult('needs_input', safeError(error)); }
+
+  fs.mkdirSync(paths.runDirectory, { recursive: true });
+  const existingLock = runLockState(paths.runDirectory);
+  if (['active', 'unsafe'].includes(existingLock.status)) return errorResult('blocked', '该请求已有活跃生成任务,未重复启动。');
+  let runLock;
+  try { runLock = acquireRunLock(paths.runDirectory, requestKey); }
+  catch (error) {
+    return errorResult(error?.code === 'EEXIST' ? 'blocked' : 'error',
+      error?.code === 'EEXIST' ? '该请求已有活跃生成任务,未重复启动。' : safeError(error));
+  }
+  try {
+    const state = {
+    schemaVersion: 3, runId, requestKey, planId: plan.planId, planHash: plan.planHash,
+    status: 'running', createdAt: now(), updatedAt: now(), workspaceRoot, productName: plan.productName || '未命名商品',
+    estimatedCostCny: plan.estimatedCostCny, costUnit: COST_UNIT, costBasis: 'trusted_usd_price_and_exchange_rate', budgetLimitCny,
+    expectedIdentity: plan.analysis?.product_identity || {},
+    sourceImages: input.organizeSources === true ? sourceRecords(references, workspaceRoot) : [],
+    items: plan.items.map(item => ({ ...item, status: 'planned', requestId: '', files: [] }))
+    };
+    save(paths.runDirectory, state);
+    const provider = dependencies.provider || new NewApiImageProvider({ fetchImpl: dependencies.fetchImpl });
+    const reviewer = dependencies.visionProvider || new NewApiImageProvider({ fetchImpl: dependencies.visionFetchImpl || dependencies.fetchImpl });
+    const originalReferences = references.map(image => image.url);
+    let identityAnchor = [];
+    const options = retryOptions(dependencies);
+    for (let index = 0; index < state.items.length; index += 1) {
+      const item = state.items[index];
+      const itemReferences = [...new Set([...originalReferences, ...identityAnchor])];
+      await runItem(item, state, paths.runDirectory, provider, reviewer, itemReferences, state.expectedIdentity, qualityReview, options);
+      if (!identityAnchor.length && item.status === 'completed' && item.files[0]) identityAnchor = [localImage(item.files[0]).url];
+    }
+    recomputeRunStatus(state);
+    if (state.status === 'completed') organizeSourceImages(state, paths.runDirectory, workspaceRoot);
+    save(paths.runDirectory, state);
+    const failedIds = state.items.filter(item => item.status !== 'completed').map(item => item.outputId);
+    return summaryResult(state, state.status === 'completed'
+      ? `套图已完成,共 ${state.items.length} 个输出。`
+      : `套图已完成可继续部分;${failedIds.join('、')} 未完成,其余图片未被阻断。`);
+  } finally {
+    releaseRunLock(paths.runDirectory, runLock);
+  }
+}
+
+function existingRetryReferencePaths(state, workspaceRoot) {
+  const sources = (state.sourceImages || [])
+    .map(record => path.resolve(workspaceRoot, record.originalRelativePath || ''))
+    .filter(filePath => fs.existsSync(filePath));
+  const anchor = (state.items || [])
+    .filter(item => item.status === 'completed')
+    .flatMap(item => item.files || [])
+    .find(filePath => fs.existsSync(filePath));
+  return [...new Set([...sources, ...(anchor ? [anchor] : [])])];
+}
+
+export async function retryRunItems(input = {}, dependencies = {}) {
+  if (input.confirmed !== true) {
+    return errorResult('needs_confirmation', '指定失败项已准备好;确认后将在原套图中继续。');
+  }
+  const retryOutputIds = [...new Set(input.retryOutputIds || input.retryFailedOutputIds || [])]
+    .map(value => String(value || '').trim()).filter(Boolean);
+  if (!retryOutputIds.length || retryOutputIds.some(value => !/^[A-Za-z0-9_-]{1,32}$/.test(value))) {
+    return errorResult('needs_input', 'retryOutputIds 必须明确列出要重试的 outputId。');
+  }
+  const workspaceRoot = path.resolve(dependencies.workspaceRoot || process.env.FMODE_WORKSPACE_ROOT || process.cwd());
+  let paths;
+  try { paths = resolveExistingRunDirectory({ workspaceRoot, packageRoot: dependencies.packageRoot || PACKAGE_ROOT, runId: input.runId }); }
+  catch (error) { return errorResult('blocked', safeError(error)); }
+  let state;
+  try { state = readState(paths.runDirectory); }
+  catch (error) { return errorResult('needs_input', safeError(error)); }
+  let retryRequestKey;
+  try {
+    retryRequestKey = createRequestKey({
+      sessionId: input.sessionId || input.session_id,
+      promptId: input.promptId || input.prompt_id,
+      action: 'retry_image_set_items',
+      planHash: state.planHash,
+      extra: { runId: state.runId, outputIds: retryOutputIds }
+    });
+  } catch (error) { return errorResult('needs_input', safeError(error)); }
+  const existingRunLock = runLockState(paths.runDirectory);
+  if (['active', 'unsafe'].includes(existingRunLock.status)) {
+    return errorResult('blocked', '原套图仍有活跃生成、返修或续跑任务,当前续跑未介入。', { data: { run: toPublicValue(state) } });
+  }
+  let runLock;
+  try { runLock = acquireRunLock(paths.runDirectory, retryRequestKey); }
+  catch (error) {
+    return errorResult(error?.code === 'EEXIST' ? 'blocked' : 'error',
+      error?.code === 'EEXIST' ? '原套图仍有活跃生成、返修或续跑任务,当前续跑未介入。' : safeError(error));
+  }
+  try {
+    state = readState(paths.runDirectory);
+    for (const item of state.items || []) {
+    const lock = revisionLockState(item, paths.runDirectory);
+    if (['active', 'unsafe'].includes(lock.status)) {
+      return errorResult('blocked', `${item.outputId} 仍有活跃操作,当前请求未介入恢复,也未创建新的生成任务。`, {
+        data: { run: toPublicValue(state) }
+      });
+    }
+    if (lock.status === 'stale') removeRevisionLock(item, paths.runDirectory);
+    }
+    recoverInterrupted(state, paths.runDirectory);
+    recoverDomesticPlatformValidation(state, paths.runDirectory);
+    const items = retryOutputIds.map(outputId => state.items?.find(item => item.outputId === outputId));
+    if (items.some(item => !item)) return errorResult('needs_input', '原 run 中不存在指定的 outputId。');
+    const retryableStatuses = new Set(['failed', 'submission_unknown', 'skipped', 'quality_rejected', 'partial']);
+    if ((state.retryRequests || []).some(request => request.requestKey === retryRequestKey)) {
+      return summaryResult(state, '相同的指定项重试请求已有状态,未重复生成。');
+    }
+  if (items.some(item => !retryableStatuses.has(item.status))) {
+    return errorResult('blocked', '只能重试原 run 中失败、未确定、跳过、部分完成或质量拒绝的项目。');
+  }
+  const requiredAmount = Number(items.reduce((sum, item) => sum + generationReserveCny(
+    item.providerModel, { qualityReview: input.qualityReview !== false }
+  ), 0).toFixed(4));
+  try { trustedBudgetLimit(input, requiredAmount); }
+  catch (error) { return errorResult('needs_confirmation', safeError(error)); }
+
+  let baseReferences = [];
+  try {
+    baseReferences = resolveImageInputs({
+      workspaceRoot, cwd: workspaceRoot,
+      images: existingRetryReferencePaths(state, workspaceRoot),
+      allowClaudeAttachments: false
+    });
+  } catch (error) { return errorResult('needs_input', safeError(error)); }
+  const provider = dependencies.provider || new NewApiImageProvider({ fetchImpl: dependencies.fetchImpl });
+  const reviewer = dependencies.visionProvider || new NewApiImageProvider({ fetchImpl: dependencies.visionFetchImpl || dependencies.fetchImpl });
+  const qualityReview = input.qualityReview !== false;
+  const options = retryOptions(dependencies);
+  state.retryRequests ||= [];
+  state.retryRequests.push({ requestKey: retryRequestKey, outputIds: retryOutputIds, status: 'running', createdAt: now() });
+  save(paths.runDirectory, state);
+
+  for (const item of items) {
+    const lockPath = revisionLockPath(item, paths.runDirectory);
+    let lockHandle;
+    let activeLockKey;
+    try {
+      lockHandle = fs.openSync(lockPath, 'wx');
+      fs.writeFileSync(lockHandle, `${JSON.stringify({ pid: process.pid, requestKey: retryRequestKey, createdAt: now() })}\n`, 'utf8');
+      activeLockKey = pathKey(lockPath);
+      ACTIVE_REVISION_LOCKS.add(activeLockKey);
+    } catch (error) {
+      if (lockHandle !== undefined) try { fs.closeSync(lockHandle); } catch { /* Preserve original lock error. */ }
+      state.retryRequests.at(-1).status = 'blocked';
+      state.retryRequests.at(-1).error = error?.code === 'EEXIST' ? `${item.outputId} 已有活跃操作。` : safeError(error);
+      save(paths.runDirectory, state);
+      return errorResult('blocked', state.retryRequests.at(-1).error, { data: { run: toPublicValue(state) } });
+    }
+    try {
+      const latest = readState(paths.runDirectory);
+      const latestItem = latest.items?.find(entry => entry.outputId === item.outputId);
+      if (!latestItem || !retryableStatuses.has(latestItem.status)) continue;
+      state = latest;
+      const repeated = (state.retryRequests || []).slice(0, -1).some(request => request.requestKey === retryRequestKey);
+      if (repeated) continue;
+      const previousFiles = [...(latestItem.files || [])];
+      latestItem.retryHistory ||= [];
+      latestItem.retryHistory.push({
+        retryRequestKey,
+        previousStatus: latestItem.status,
+        previousFiles,
+        previousRequestId: latestItem.requestId || '',
+        previousErrorKind: latestItem.errorKind || '',
+        previousError: latestItem.error || '',
+        previousQuality: latestItem.quality || null,
+        previousPlatformValidation: latestItem.platformValidation || null,
+        previousAttempts: latestItem.attempts || [],
+        createdAt: now()
+      });
+      latestItem.generationCycle = Number(latestItem.generationCycle || 0) + 1;
+      latestItem.materializeOutputId = previousFiles.some(filePath => fs.existsSync(filePath))
+        ? `${latestItem.outputId}-retry${latestItem.generationCycle}`
+        : latestItem.outputId;
+      latestItem.activeGenerationKey = sha256(stableStringify({ retryRequestKey, outputId: latestItem.outputId }));
+      latestItem.attempts = [];
+      latestItem.files = [];
+      latestItem.status = 'planned';
+      latestItem.requestId = '';
+      delete latestItem.error;
+      delete latestItem.errorKind;
+      delete latestItem.warning;
+      delete latestItem.quality;
+      delete latestItem.platformValidation;
+      save(paths.runDirectory, state);
+      const priorReferences = previousFiles.filter(filePath => fs.existsSync(filePath));
+      let referenceImages = baseReferences.map(reference => reference.url);
+      if (priorReferences.length) {
+        const previous = resolveImageInputs({
+          workspaceRoot, cwd: workspaceRoot, images: priorReferences, allowClaudeAttachments: false
+        });
+        referenceImages = [...new Set([...referenceImages, ...previous.map(reference => reference.url)])];
+      }
+      await runItem(
+        latestItem, state, paths.runDirectory, provider, reviewer, referenceImages,
+        state.expectedIdentity || {}, qualityReview, options
+      );
+    } finally {
+      if (activeLockKey) ACTIVE_REVISION_LOCKS.delete(activeLockKey);
+      try { if (lockHandle !== undefined) fs.closeSync(lockHandle); } catch { /* State is already durable. */ }
+      try { removeRevisionLock(item, paths.runDirectory); } catch { /* Preserve unexpected lock for inspection. */ }
+    }
+  }
+  const request = state.retryRequests.find(entry => entry.requestKey === retryRequestKey);
+  if (request) {
+    request.status = 'completed';
+    request.completedAt = now();
+  }
+  recomputeRunStatus(state);
+  if (state.status === 'completed') recoverSourceOrganization(state, paths.runDirectory, workspaceRoot);
+  save(paths.runDirectory, state);
+  const remaining = state.items.filter(item => item.status !== 'completed').map(item => item.outputId);
+    return summaryResult(state, remaining.length
+      ? `指定项目已在原套图中处理;${remaining.join('、')} 仍未完成,其余已完成图片未重新生成。`
+      : '指定项目已在原套图中完成,既有完成图片未重新生成。');
+  } finally {
+    releaseRunLock(paths.runDirectory, runLock);
+  }
+}
+
+export async function refreshRun(input = {}, dependencies = {}) {
+  const workspaceRoot = path.resolve(dependencies.workspaceRoot || process.env.FMODE_WORKSPACE_ROOT || process.cwd());
+  let paths;
+  try { paths = resolveExistingRunDirectory({ workspaceRoot, packageRoot: dependencies.packageRoot || PACKAGE_ROOT, runId: input.runId }); }
+  catch (error) { return errorResult('blocked', safeError(error)); }
+  let state;
+  try { state = readState(paths.runDirectory); }
+  catch (error) { return errorResult('needs_input', safeError(error)); }
+  const activeRunLock = runLockState(paths.runDirectory);
+  if (['active', 'unsafe'].includes(activeRunLock.status)) {
+    return {
+      ...summaryResult(state, '该原套图仍有活跃生成、返修或续跑任务,状态查询未介入恢复。'),
+      status: 'blocked'
+    };
+  }
+  let runLock;
+  try { runLock = acquireRunLock(paths.runDirectory, `status:${state.requestKey || state.runId}`); }
+  catch (error) {
+    return errorResult(error?.code === 'EEXIST' ? 'blocked' : 'error',
+      error?.code === 'EEXIST' ? '该原套图刚刚开始新的活跃操作,状态查询未介入恢复。' : safeError(error),
+      { data: { run: toPublicValue(state) } });
+  }
+  try {
+    state = readState(paths.runDirectory);
+    let activeRevision;
+    for (const item of state.items || []) {
+    const lock = revisionLockState(item, paths.runDirectory);
+    if (['active', 'unsafe'].includes(lock.status)) {
+      activeRevision = item;
+      break;
+    }
+    if (lock.status === 'stale') removeRevisionLock(item, paths.runDirectory);
+    }
+    if (activeRevision) {
+      return {
+        ...summaryResult(state, `${activeRevision.outputId} 仍有活跃返修任务正在提交或落盘,状态查询未介入恢复,也未创建新的付费任务。`),
+        status: 'blocked'
+      };
+    }
+  recoverInterrupted(state, paths.runDirectory);
+  recoverDomesticPlatformValidation(state, paths.runDirectory);
+  if (state.items?.length && state.items.every(item => item.status === 'completed')) {
+    if (state.status !== 'completed') {
+      state.status = 'completed';
+      save(paths.runDirectory, state);
+    }
+    recoverSourceOrganization(state, paths.runDirectory, workspaceRoot);
+  }
+  const receiving = state.items.filter(item => item.status === 'receiving');
+  const revisionReceiving = state.items.flatMap(item => (item.revisions || [])
+    .filter(revision => item.status === 'revising' && revision.status === 'receiving')
+    .map(revision => ({ item, revision })));
+  if (!input.refresh || (!receiving.length && !revisionReceiving.length)) {
+    return summaryResult(state, '已读取本地运行状态;New API 当前链路无需轮询生成任务。');
+  }
+  const provider = dependencies.provider || new NewApiImageProvider({ fetchImpl: dependencies.fetchImpl });
+  for (const item of receiving) {
+    if (!item.resultUrls?.length) {
+      item.status = 'failed';
+      item.errorKind = 'response_lost';
+      item.error = '内联图片响应在完整落盘前中断;禁止自动重提。';
+      save(paths.runDirectory, state);
+      continue;
+    }
+    item.files ||= [];
+    try {
+      for (let index = item.files.length; index < item.resultUrls.length; index += 1) {
+        item.files.push(await provider.materialize(
+          item.resultUrls[index], paths.runDirectory, item.materializeOutputId || item.outputId, index
+        ));
+        save(paths.runDirectory, state);
+      }
+      item.status = item.files.length === Number(item.expectedImages || item.resultUrls.length)
+        ? (item.platformRule?.domestic ? 'validating' : 'completed') : 'partial';
+      item.quality = { status: 'unverified', reason: 'recovered_download_requires_manual_quality_review' };
+      item.warning = 'quality_review_unverified';
+      applyDomesticPlatformValidation(item);
+      if (item.status === 'validating' && item.platformValidation?.passed) item.status = 'completed';
+    } catch (error) {
+      item.warning = 'result_download_pending';
+      item.error = safeError(error);
+    }
+    save(paths.runDirectory, state);
+  }
+  for (const { item, revision } of revisionReceiving) {
+    revision.files ||= [];
+    try {
+      const resultUrls = revision.resultUrls || [];
+      if (revision.files.length < Number(revision.expectedImages || resultUrls.length) && !resultUrls.length) {
+        revision.status = 'failed';
+        revision.errorKind = 'response_lost';
+        revision.error = '内联返修图片响应在完整落盘前中断;禁止自动重提。';
+        revision.updatedAt = now();
+        item.status = 'quality_rejected';
+        removeRevisionLock(item, paths.runDirectory);
+        save(paths.runDirectory, state);
+        continue;
+      }
+      for (let index = revision.files.length; index < resultUrls.length; index += 1) {
+        revision.files.push(await provider.materialize(
+          resultUrls[index], paths.runDirectory, revision.materializeOutputId, index
+        ));
+        save(paths.runDirectory, state);
+      }
+      if (revision.files.length !== Number(revision.expectedImages || revision.resultUrls.length)) {
+        revision.warning = 'result_download_pending';
+        continue;
+      }
+      const quality = { status: 'unverified', reason: 'recovered_revision_download_requires_manual_quality_review' };
+      const platformValidation = item.platformRule?.domestic
+        ? validateDomesticFiles({ files: revision.files, platformRule: item.platformRule }) : null;
+      applyRevisionOutcome(item, revision, { quality, platformValidation });
+      revision.updatedAt = now();
+      removeRevisionLock(item, paths.runDirectory);
+    } catch (error) {
+      revision.warning = 'result_download_pending';
+      revision.error = safeError(error);
+      revision.updatedAt = now();
+    }
+    save(paths.runDirectory, state);
+  }
+  recomputeRunStatus(state);
+  if (state.status === 'completed' && !['completed', 'failed', 'not_applicable'].includes(state.sourceOrganization?.status)) {
+    organizeSourceImages(state, paths.runDirectory, workspaceRoot);
+  }
+  save(paths.runDirectory, state);
+    return summaryResult(state, state.status === 'completed' ? '远程结果已恢复下载;质量检查需人工补验。' : '已尝试恢复远程结果下载。');
+  } finally {
+    releaseRunLock(paths.runDirectory, runLock);
+  }
+}
+
+export { GEMINI_IMAGE_MODEL, SEEDREAM_IMAGE_MODEL };

+ 1291 - 0
fmode-image-set/mcp/src/features/generation/orchestrator.test.mjs

@@ -0,0 +1,1291 @@
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test, { after } from 'node:test';
+import { pathToFileURL } from 'node:url';
+import { createRequestKey, sha256, stableStringify } from '../../core/request-key.mjs';
+import { createReadableRunId, resolveRunDirectory } from '../../core/run-paths.mjs';
+import { readState, writeState } from '../../core/state-store.mjs';
+import { GEMINI_IMAGE_MODEL } from '../../providers/new-api-image.mjs';
+import { ProviderError } from '../../providers/provider-error.mjs';
+import { buildImageSetPlan, toPublicPlan } from '../planning/planner.mjs';
+import { runEdit } from '../../tools/edit.mjs';
+import { runGenerate } from '../../tools/generate.mjs';
+import { buildOperationPlan, executePlan, refreshRun } from './orchestrator.mjs';
+
+const packageRoot = path.resolve(import.meta.dirname, '..', '..', '..', '..');
+const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fmode-image-set-orchestrator-'));
+
+function removeTree(target) {
+  if (!fs.existsSync(target)) return;
+  const stat = fs.lstatSync(target);
+  if (!stat.isDirectory() || stat.isSymbolicLink()) return fs.unlinkSync(target);
+  for (const entry of fs.readdirSync(target)) removeTree(path.join(target, entry));
+  fs.rmdirSync(target);
+}
+
+after(() => removeTree(workspaceRoot));
+
+class FakeImageProvider {
+  constructor(options = {}) { this.generations = 0; this.unknownAt = options.unknownAt || 0; this.references = []; }
+  async generate(item) {
+    this.generations += 1;
+    this.references.push([...(item.referenceImages || [])]);
+    if (this.generations === this.unknownAt) throw new ProviderError('submission_unknown', 'ambiguous');
+    return { requestId: `request-${this.generations}`, model: item.providerModel, images: ['data:image/png;base64,ZmFrZQ=='], usage: {} };
+  }
+  async materialize(_value, runDirectory, outputId) {
+    const file = path.join(runDirectory, `${outputId}-1.png`);
+    fs.writeFileSync(file, 'fake', { flag: 'wx' });
+    return file;
+  }
+}
+
+function fakePng(width, height, colorType = 2) {
+  const bytes = Buffer.alloc(33);
+  Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes, 0);
+  bytes.writeUInt32BE(13, 8);
+  bytes.write('IHDR', 12, 'ascii');
+  bytes.writeUInt32BE(width, 16);
+  bytes.writeUInt32BE(height, 20);
+  bytes[24] = 8;
+  bytes[25] = colorType;
+  return bytes;
+}
+
+class DomesticImageProvider extends FakeImageProvider {
+  constructor(width, height) { super(); this.width = width; this.height = height; }
+  async materialize(_value, runDirectory, outputId) {
+    const file = path.join(runDirectory, `${outputId}-1.png`);
+    fs.writeFileSync(file, fakePng(this.width, this.height), { flag: 'wx' });
+    return file;
+  }
+}
+
+const passingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: true, score: 1, checks: {}, violations: [], recommendations: [] } }) };
+
+function rehashPlan(plan) {
+  const { planId, planHash, createdAt, confirmationMessage, ...core } = plan;
+  return { ...plan, planHash: sha256(stableStringify(core)) };
+}
+
+test('generation requires explicit confirmation, accepts omitted budget, and keeps the legacy budget gate', async () => {
+  const plan = buildOperationPlan({ prompt: 'test' });
+  const noConfirm = await executePlan({ plan, sessionId: 'session-budget', promptId: 'prompt-budget', workspaceRoot }, { provider: new FakeImageProvider(), workspaceRoot });
+  assert.equal(noConfirm.status, 'needs_confirmation');
+  const lowBudget = await executePlan({ plan, confirmed: true, budgetLimitCny: 0.01, sessionId: 'session-budget', promptId: 'prompt-budget', workspaceRoot }, { provider: new FakeImageProvider(), workspaceRoot });
+  assert.equal(lowBudget.status, 'needs_confirmation');
+  const noBudget = await executePlan({ plan, confirmed: true, sessionId: `session-no-budget-${Date.now()}`, promptId: 'prompt-no-budget', workspaceRoot, allowClaudeAttachments: false }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
+  try { assert.equal(noBudget.status, 'ok'); }
+  finally { if (noBudget.files[0]) removeTree(path.dirname(noBudget.files[0])); }
+});
+
+test('budget gate recomputes cost from trusted model reserve', async () => {
+  const plan = buildOperationPlan({ prompt: 'tampered budget' });
+  plan.items[0].estimatedCostCny = 0;
+  plan.estimatedCostCny = 0;
+  const forged = rehashPlan(plan);
+  const result = await executePlan({ plan: forged, confirmed: true, budgetLimitCny: 0.01, sessionId: 'session-forged-budget', promptId: 'prompt-forged-budget', workspaceRoot }, { provider: new FakeImageProvider(), workspaceRoot });
+  assert.equal(result.status, 'needs_confirmation');
+  assert.doesNotMatch(result.assistantMessage, /0\.75|¥|预算|费用/);
+});
+
+test('same request is idempotent and does not generate twice', async () => {
+  const plan = buildOperationPlan({ prompt: 'idempotent test' });
+  const provider = new FakeImageProvider();
+  const input = { plan, confirmed: true, budgetLimitCny: 1, sessionId: `session-${Date.now()}`, promptId: 'prompt-idem', workspaceRoot, allowClaudeAttachments: false };
+  const first = await executePlan(input, { provider, visionProvider: passingVision, workspaceRoot });
+  const second = await executePlan(input, { provider, visionProvider: passingVision, workspaceRoot });
+  try {
+    assert.equal(first.status, 'ok');
+    assert.equal(second.status, 'ok');
+    assert.equal(provider.generations, 1);
+    assert.match(second.assistantMessage, /未重复提交|已经完成/);
+  } finally { if (first.files[0]) removeTree(path.dirname(first.files[0])); }
+});
+
+test('run-level lock prevents two simultaneous initial executions from generating twice', async () => {
+  const plan = buildOperationPlan({ prompt: 'simultaneous initial execution', productName: '初始并发锁商品' });
+  let releaseGeneration;
+  let signalStarted;
+  const started = new Promise(resolve => { signalStarted = resolve; });
+  const gate = new Promise(resolve => { releaseGeneration = resolve; });
+  const provider = new FakeImageProvider();
+  provider.generate = async item => {
+    provider.generations += 1;
+    signalStarted();
+    await gate;
+    return { requestId: 'initial-lock-1', model: item.providerModel, images: ['data:image/png;base64,ZmFrZQ=='], usage: {} };
+  };
+  const input = {
+    plan, confirmed: true, sessionId: `session-initial-lock-${Date.now()}`,
+    promptId: 'prompt-initial-lock', workspaceRoot, allowClaudeAttachments: false
+  };
+  const firstPromise = executePlan(input, { provider, visionProvider: passingVision, workspaceRoot });
+  await started;
+  const concurrent = await executePlan(input, { provider, visionProvider: passingVision, workspaceRoot });
+  releaseGeneration();
+  const first = await firstPromise;
+  try {
+    assert.equal(concurrent.status, 'blocked');
+    assert.equal(concurrent.data.run.items[0].status, 'submitting');
+    assert.equal(first.status, 'ok');
+    assert.equal(provider.generations, 1);
+  } finally { if (first.files[0]) removeTree(path.dirname(first.files[0])); }
+});
+
+test('two processes cannot both take over the same stale run lock', async () => {
+  const plan = buildOperationPlan({ prompt: 'cross-process stale run lock', productName: '跨进程陈旧锁商品' });
+  const failingProvider = new FakeImageProvider();
+  failingProvider.generate = async () => { throw new ProviderError('submission_unknown', 'ambiguous'); };
+  const initial = await executePlan({
+    plan, confirmed: true, sessionId: `session-stale-lock-source-${Date.now()}`,
+    promptId: 'prompt-stale-lock-source', workspaceRoot, allowClaudeAttachments: false
+  }, { provider: failingProvider, visionProvider: passingVision, workspaceRoot, maxGenerationAttempts: 1, retryDelaysMs: [], sleep: async () => {} });
+  const runDirectory = resolveRunDirectory({ workspaceRoot, packageRoot, runId: initial.data.run.runId }).runDirectory;
+  const lockPath = path.join(runDirectory, '.generation.lock');
+  fs.writeFileSync(lockPath, `${JSON.stringify({ pid: 2147483647, requestKey: 'stale-owner', createdAt: new Date(0).toISOString() })}\n`, { flag: 'wx' });
+  const runnerPath = path.join(workspaceRoot, `stale-lock-runner-${Date.now()}.mjs`);
+  const generateUrl = pathToFileURL(path.join(packageRoot, 'mcp', 'src', 'tools', 'generate.mjs')).href;
+  fs.writeFileSync(runnerPath, `
+import fs from 'node:fs';
+import path from 'node:path';
+import { runGenerate } from ${JSON.stringify(generateUrl)};
+const [workspaceRoot, runId, startAt] = process.argv.slice(2);
+const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
+while (Date.now() < Number(startAt)) await wait(5);
+const provider = {
+  generations: 0,
+  async generate(item) {
+    this.generations += 1;
+    await wait(1500);
+    return { requestId: 'stale-takeover', model: item.providerModel, images: ['data:image/png;base64,ZmFrZQ=='], usage: {} };
+  },
+  async materialize(_value, runDirectory, outputId) {
+    const file = path.join(runDirectory, outputId + '-1.png');
+    fs.writeFileSync(file, 'fake', { flag: 'wx' });
+    return file;
+  }
+};
+const visionProvider = { review: async () => ({ model: 'test', review: { passed: true, score: 1, checks: {}, violations: [], recommendations: [] } }) };
+const result = await runGenerate({
+  runId, retryOutputIds: ['O1'], confirmed: true,
+  sessionId: 'cross-process-stale-session', promptId: 'cross-process-stale-prompt'
+}, { provider, visionProvider, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
+process.stdout.write(JSON.stringify({ status: result.status, generations: provider.generations }));
+`);
+  const startAt = Date.now() + 500;
+  const runChild = () => new Promise((resolve, reject) => {
+    const child = spawn(process.execPath, [runnerPath, workspaceRoot, initial.data.run.runId, String(startAt)], { stdio: ['ignore', 'pipe', 'pipe'] });
+    let stdout = '';
+    let stderr = '';
+    child.stdout.on('data', chunk => { stdout += chunk; });
+    child.stderr.on('data', chunk => { stderr += chunk; });
+    child.once('error', reject);
+    child.once('exit', code => {
+      if (code !== 0) return reject(new Error(`stale lock child failed (${code}): ${stderr}`));
+      try { resolve(JSON.parse(stdout)); } catch (error) { reject(new Error(`invalid stale lock child output: ${stdout}; ${error.message}`)); }
+    });
+  });
+  try {
+    const results = await Promise.all([runChild(), runChild()]);
+    assert.deepEqual(results.map(result => result.status).sort(), ['blocked', 'ok']);
+    assert.equal(results.reduce((sum, result) => sum + result.generations, 0), 1);
+    assert.equal(fs.existsSync(path.join(runDirectory, '.generation.lock.claim')), false);
+    assert.equal(readState(runDirectory).items[0].status, 'completed');
+  } finally {
+    if (fs.existsSync(runnerPath)) fs.unlinkSync(runnerPath);
+    removeTree(runDirectory);
+  }
+});
+
+test('first completed local image becomes a data URL identity anchor', async () => {
+  const plan = buildImageSetPlan({ request: '两步锚点测试', preset: 'compact' });
+  plan.items = plan.items.slice(0, 2);
+  plan.estimatedCostCny = 1.496;
+  const shortened = rehashPlan(plan);
+  const provider = new FakeImageProvider();
+  const result = await executePlan({ plan: shortened, confirmed: true, budgetLimitCny: 2, sessionId: `session-anchor-${Date.now()}`, promptId: 'prompt-anchor', workspaceRoot, allowClaudeAttachments: false }, { provider, visionProvider: passingVision, workspaceRoot });
+  try {
+    assert.equal(result.status, 'ok');
+    assert.equal(provider.generations, 2);
+    assert.equal(provider.references[0].length, 0);
+    assert.ok(provider.references[1].some(value => /^data:image\/png;base64,/.test(value)));
+  } finally { if (result.files[0]) removeTree(path.dirname(result.files[0])); }
+});
+
+test('submission_unknown is retried with one stable idempotency key and later outputs continue', async () => {
+  const plan = buildImageSetPlan({ request: '未知提交停止测试', preset: 'compact' });
+  const provider = new FakeImageProvider({ unknownAt: 2 });
+  const keys = [];
+  const originalGenerate = provider.generate.bind(provider);
+  provider.generate = async item => { keys.push({ outputId: item.outputId, key: item.idempotencyKey }); return originalGenerate(item); };
+  const result = await executePlan({ plan, confirmed: true, sessionId: `session-unknown-${Date.now()}`, promptId: 'prompt-unknown', workspaceRoot, allowClaudeAttachments: false }, { provider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
+  try {
+    assert.equal(result.status, 'ok');
+    assert.equal(provider.generations, plan.items.length + 1);
+    assert.ok(result.data.run.items.every(item => item.status === 'completed'));
+    const retried = keys.filter(entry => entry.outputId === plan.items[1].outputId);
+    assert.equal(retried.length, 2);
+    assert.equal(retried[0].key, retried[1].key);
+  } finally { if (result.files[0]) removeTree(path.dirname(result.files[0])); }
+});
+
+test('retry exhaustion records only the failed output and does not block later outputs', async () => {
+  const plan = buildImageSetPlan({ request: '失败继续测试', preset: 'compact' });
+  plan.items = plan.items.slice(0, 3);
+  const shortened = rehashPlan(plan);
+  const calls = [];
+  const provider = new FakeImageProvider();
+  const originalGenerate = provider.generate.bind(provider);
+  provider.generate = async item => {
+    calls.push(item.outputId);
+    if (item.outputId === shortened.items[1].outputId) throw new ProviderError('submission_unknown', 'ambiguous');
+    return originalGenerate(item);
+  };
+  const result = await executePlan({
+    plan: shortened, confirmed: true, sessionId: `session-exhaust-${Date.now()}`,
+    promptId: 'prompt-exhaust', workspaceRoot, allowClaudeAttachments: false
+  }, { provider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
+  try {
+    assert.equal(result.status, 'partial');
+    assert.deepEqual(result.summary.failedOutputIds, [shortened.items[1].outputId]);
+    assert.equal(result.data.run.items[0].status, 'completed');
+    assert.equal(result.data.run.items[1].status, 'submission_unknown');
+    assert.equal(result.data.run.items[2].status, 'completed');
+    assert.equal(calls.filter(id => id === shortened.items[1].outputId).length, 3);
+    assert.match(result.nextActions[0], new RegExp(shortened.items[1].outputId));
+  } finally { if (result.files[0]) removeTree(path.dirname(result.files[0])); }
+});
+
+test('same-run retry regenerates only selected failed outputIds and is idempotent', async () => {
+  const plan = buildImageSetPlan({ request: '原任务指定项续跑', preset: 'compact', productName: '指定项续跑商品' });
+  plan.items = plan.items.slice(0, 3);
+  const shortened = rehashPlan(plan);
+  const initialProvider = new FakeImageProvider();
+  const initialGenerate = initialProvider.generate.bind(initialProvider);
+  initialProvider.generate = async item => {
+    if (item.outputId === shortened.items[1].outputId) throw new ProviderError('submission_unknown', 'ambiguous');
+    return initialGenerate(item);
+  };
+  const initial = await runGenerate({
+    plan: shortened, confirmed: true, sessionId: `session-target-initial-${Date.now()}`,
+    promptId: 'prompt-target-initial', workspaceRoot, allowClaudeAttachments: false
+  }, { provider: initialProvider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
+  const before = initial.data.run.items.map(item => ({ outputId: item.outputId, requestId: item.requestId, files: [...item.files] }));
+  const retryProvider = new FakeImageProvider();
+  const retryInput = {
+    runId: initial.data.run.runId, retryOutputIds: [shortened.items[1].outputId], confirmed: true,
+    sessionId: `session-target-retry-${Date.now()}`, promptId: 'prompt-target-retry'
+  };
+  const runDirectory = path.dirname(initial.files[0]);
+  const lockPath = path.join(runDirectory, `.revision-${shortened.items[1].outputId}.lock`);
+  const lockOwner = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { stdio: 'ignore' });
+  await new Promise(resolve => lockOwner.once('spawn', resolve));
+  fs.writeFileSync(lockPath, `${JSON.stringify({ pid: lockOwner.pid, requestKey: 'active-test', createdAt: new Date().toISOString() })}\n`, { flag: 'wx' });
+  const blocked = await runGenerate(retryInput, { provider: retryProvider, visionProvider: passingVision, workspaceRoot });
+  lockOwner.kill();
+  if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath);
+  const retried = await runGenerate(retryInput, {
+    provider: retryProvider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {}
+  });
+  const repeated = await runGenerate(retryInput, {
+    provider: retryProvider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {}
+  });
+  try {
+    assert.equal(initial.status, 'partial');
+    assert.equal(blocked.status, 'blocked');
+    assert.equal(blocked.data.run.items[1].status, 'submission_unknown');
+    assert.equal(retried.status, 'ok', JSON.stringify(retried));
+    assert.equal(retried.data.run.runId, initial.data.run.runId);
+    assert.equal(retryProvider.generations, 1);
+    assert.equal(repeated.status, 'ok');
+    assert.equal(retryProvider.generations, 1);
+    for (const index of [0, 2]) {
+      assert.equal(retried.data.run.items[index].requestId, before[index].requestId);
+      assert.deepEqual(retried.data.run.items[index].files, before[index].files);
+    }
+    assert.equal(retried.data.run.items[1].status, 'completed');
+    assert.equal(retried.data.run.items[1].retryHistory.length, 1);
+  } finally {
+    const firstFile = retried.files[0] || initial.files[0];
+    if (firstFile) removeTree(path.dirname(firstFile));
+  }
+});
+
+test('run-level lock serializes simultaneous retries and keeps status from rewriting active state', async () => {
+  const plan = buildOperationPlan({ prompt: 'concurrent retry run lock', productName: '续跑并发锁商品' });
+  const failingProvider = new FakeImageProvider();
+  failingProvider.generate = async () => { throw new ProviderError('submission_unknown', 'ambiguous'); };
+  const initial = await executePlan({
+    plan, confirmed: true, sessionId: `session-concurrent-retry-source-${Date.now()}`,
+    promptId: 'prompt-concurrent-retry-source', workspaceRoot, allowClaudeAttachments: false
+  }, { provider: failingProvider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
+  let releaseGeneration;
+  let signalStarted;
+  const started = new Promise(resolve => { signalStarted = resolve; });
+  const gate = new Promise(resolve => { releaseGeneration = resolve; });
+  const provider = new FakeImageProvider();
+  provider.generate = async item => {
+    provider.generations += 1;
+    signalStarted();
+    await gate;
+    return { requestId: 'concurrent-retry-1', model: item.providerModel, images: ['data:image/png;base64,ZmFrZQ=='], usage: {} };
+  };
+  const retryInput = {
+    runId: initial.data.run.runId, retryOutputIds: ['O1'], confirmed: true,
+    sessionId: `session-concurrent-retry-${Date.now()}`, promptId: 'prompt-concurrent-retry'
+  };
+  const firstPromise = runGenerate(retryInput, { provider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
+  await started;
+  const concurrent = await runGenerate(retryInput, { provider, visionProvider: passingVision, workspaceRoot });
+  const statusDuring = await refreshRun({ runId: initial.data.run.runId, refresh: true }, { provider, workspaceRoot });
+  releaseGeneration();
+  const first = await firstPromise;
+  const repeated = await runGenerate(retryInput, { provider, visionProvider: passingVision, workspaceRoot });
+  try {
+    assert.equal(concurrent.status, 'blocked');
+    assert.equal(statusDuring.status, 'blocked');
+    assert.equal(statusDuring.data.run.items[0].status, 'submitting');
+    assert.equal(first.status, 'ok');
+    assert.equal(repeated.status, 'ok');
+    assert.equal(provider.generations, 1);
+    assert.equal(first.data.run.retryRequests.length, 1);
+    assert.equal(first.data.run.items[0].status, 'completed');
+  } finally {
+    const firstFile = first.files[0] || initial.files[0];
+    if (firstFile) removeTree(path.dirname(firstFile));
+  }
+});
+
+test('a first-output failure still lets later outputs use the original reference', async () => {
+  const source = path.join(workspaceRoot, `首图失败参考-${Date.now()}.png`);
+  fs.writeFileSync(source, Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
+  const plan = buildImageSetPlan({ request: '首图失败继续测试', preset: 'compact' });
+  plan.items = plan.items.slice(0, 2);
+  const shortened = rehashPlan(plan);
+  const references = [];
+  const provider = new FakeImageProvider();
+  const originalGenerate = provider.generate.bind(provider);
+  provider.generate = async item => {
+    references.push({ outputId: item.outputId, values: [...(item.referenceImages || [])] });
+    if (item.outputId === shortened.items[0].outputId) throw new ProviderError('submission_unknown', 'ambiguous');
+    return originalGenerate(item);
+  };
+  const result = await executePlan({
+    plan: shortened, confirmed: true, sessionId: `session-first-fail-${Date.now()}`,
+    promptId: 'prompt-first-fail', workspaceRoot, referenceImages: [source], allowClaudeAttachments: false
+  }, { provider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
+  try {
+    assert.equal(result.status, 'partial');
+    assert.equal(result.data.run.items[1].status, 'completed');
+    const second = references.find(entry => entry.outputId === shortened.items[1].outputId);
+    assert.ok(second.values.some(value => /^data:image\//.test(value)));
+  } finally {
+    if (fs.existsSync(source)) fs.unlinkSync(source);
+    if (result.files[0]) removeTree(path.dirname(result.files[0]));
+  }
+});
+
+test('a public cost-free plan still verifies and its run envelope exposes no cost fields', async () => {
+  const internalPlan = buildOperationPlan({ prompt: '公开计划测试' });
+  const publicPlan = toPublicPlan(internalPlan);
+  const result = await executePlan({
+    plan: publicPlan, confirmed: true, sessionId: `session-public-plan-${Date.now()}`,
+    promptId: 'prompt-public-plan', workspaceRoot, allowClaudeAttachments: false
+  }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
+  try {
+    assert.equal(result.status, 'ok', JSON.stringify(result));
+    const serialized = JSON.stringify(result);
+    assert.doesNotMatch(serialized, /estimatedCostCny|revisionEstimatedCostCny|budgetLimitCny|costUnit|costBasis|actualCostCny|totalCostCny|¥/);
+  } finally { if (result.files[0]) removeTree(path.dirname(result.files[0])); }
+});
+
+test('failed Gemini quality gate preserves file and rejects anchor', async () => {
+  const plan = buildOperationPlan({ prompt: 'quality test' });
+  const provider = new FakeImageProvider();
+  const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.2, checks: { identity: false }, violations: ['identity drift'], recommendations: [] } }) };
+  const result = await executePlan({ plan, confirmed: true, budgetLimitCny: 1, sessionId: `session-quality-${Date.now()}`, promptId: 'prompt-quality', workspaceRoot, allowClaudeAttachments: false }, { provider, visionProvider: rejectingVision, workspaceRoot });
+  try {
+    assert.equal(result.status, 'quality_rejected');
+    assert.equal(result.data.run.items[0].status, 'quality_rejected');
+    assert.equal(result.files.length, 1);
+  } finally { if (result.files[0]) removeTree(path.dirname(result.files[0])); }
+});
+
+test('editing a rejected suite item creates an in-place revision and completes the original run', async () => {
+  const source = path.join(workspaceRoot, `返修商品原图-${Date.now()}.png`);
+  fs.writeFileSync(source, Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
+  const plan = buildImageSetPlan({ request: '套图返修归位测试', preset: 'detail-page', productName: '返修归位商品' });
+  plan.items = plan.items.slice(0, 1);
+  const shortened = rehashPlan(plan);
+  const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.2, checks: { defects: false }, violations: ['text defect'], recommendations: [] } }) };
+  const initial = await runGenerate({
+    plan: shortened, confirmed: true, budgetLimitCny: 2,
+    sessionId: `session-revision-source-${Date.now()}`, promptId: 'prompt-revision-source',
+    workspaceRoot, referenceImages: [source], allowClaudeAttachments: false
+  }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
+  const originalFile = initial.files[0];
+  const revisionProvider = new FakeImageProvider();
+  const editInput = {
+    image: originalFile, prompt: '修复文字和构图缺陷', confirmed: true, budgetLimitCny: 1,
+    sessionId: `session-revision-edit-${Date.now()}`, promptId: 'prompt-revision-edit',
+    workspaceRoot, qualityReview: true
+  };
+  const revised = await runEdit(editInput, { provider: revisionProvider, visionProvider: passingVision, workspaceRoot });
+  const repeated = await runEdit(editInput, { provider: revisionProvider, visionProvider: passingVision, workspaceRoot });
+  try {
+    assert.equal(initial.status, 'quality_rejected');
+    assert.equal(revised.status, 'ok');
+    assert.equal(revised.data.run.runId, initial.data.run.runId);
+    assert.equal(revised.data.run.items[0].status, 'completed');
+    assert.equal(revised.data.run.items[0].revisions.length, 1);
+    assert.equal(revised.data.run.items[0].revisions[0].width, 3024);
+    assert.equal(revised.data.run.items[0].revisions[0].height, 1296);
+    assert.match(path.basename(revised.files[0]), new RegExp(`^${shortened.items[0].outputId}-v2-1\\.png$`));
+    assert.equal(path.dirname(revised.files[0]), path.dirname(originalFile));
+    assert.equal(fs.existsSync(originalFile), true);
+    assert.equal(fs.existsSync(source), false);
+    assert.equal(revised.data.run.sourceOrganization.status, 'completed');
+    assert.equal(revisionProvider.generations, 1);
+    assert.equal(repeated.data.run.runId, initial.data.run.runId);
+    assert.equal(revisionProvider.generations, 1);
+  } finally {
+    if (fs.existsSync(source)) fs.unlinkSync(source);
+    if (originalFile) removeTree(path.dirname(originalFile));
+  }
+});
+
+test('editing an already-passed suite image keeps the legacy standalone edit behavior', async () => {
+  const plan = buildImageSetPlan({ request: '已通过套图独立编辑测试', preset: 'compact', productName: '已通过编辑商品' });
+  plan.items = plan.items.slice(0, 1);
+  const shortened = rehashPlan(plan);
+  const initial = await executePlan({
+    plan: shortened, confirmed: true, budgetLimitCny: 2,
+    sessionId: `session-passed-edit-source-${Date.now()}`, promptId: 'prompt-passed-edit-source',
+    workspaceRoot, allowClaudeAttachments: false
+  }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
+  const originalFile = initial.files[0];
+  const edited = await runEdit({
+    image: originalFile, prompt: '独立调整背景', confirmed: true, budgetLimitCny: 1,
+    sessionId: `session-passed-edit-${Date.now()}`, promptId: 'prompt-passed-edit', workspaceRoot
+  }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
+  try {
+    assert.equal(initial.status, 'ok');
+    assert.equal(edited.status, 'ok');
+    assert.notEqual(edited.data.run.runId, initial.data.run.runId);
+    assert.notEqual(path.dirname(edited.files[0]), path.dirname(originalFile));
+    assert.equal(readState(path.dirname(originalFile)).items[0].revisions, undefined);
+  } finally {
+    if (originalFile) removeTree(path.dirname(originalFile));
+    if (edited.files[0]) removeTree(path.dirname(edited.files[0]));
+  }
+});
+
+test('a hard-linked passed suite image still uses legacy standalone edit behavior', async () => {
+  const plan = buildImageSetPlan({ request: '已通过套图硬链接兼容测试', preset: 'compact', productName: '硬链接兼容商品' });
+  plan.items = plan.items.slice(0, 1);
+  const shortened = rehashPlan(plan);
+  const initial = await executePlan({
+    plan: shortened, confirmed: true, budgetLimitCny: 2,
+    sessionId: `session-passed-hardlink-source-${Date.now()}`, promptId: 'prompt-passed-hardlink-source',
+    workspaceRoot, allowClaudeAttachments: false
+  }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
+  const originalFile = initial.files[0];
+  const alias = path.join(path.dirname(originalFile), 'passed-hardlink-alias.png');
+  fs.linkSync(originalFile, alias);
+  const edited = await runEdit({
+    image: originalFile, prompt: '独立调整已通过图片', confirmed: true, budgetLimitCny: 1,
+    sessionId: `session-passed-hardlink-edit-${Date.now()}`, promptId: 'prompt-passed-hardlink-edit', workspaceRoot
+  }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
+  try {
+    assert.equal(edited.status, 'ok');
+    assert.notEqual(edited.data.run.runId, initial.data.run.runId);
+    assert.equal(readState(path.dirname(originalFile)).items[0].revisions, undefined);
+  } finally {
+    if (edited.files[0]) removeTree(path.dirname(edited.files[0]));
+    if (originalFile) removeTree(path.dirname(originalFile));
+  }
+});
+
+test('a hard-linked rejected suite source is blocked before an in-place paid revision', async () => {
+  const plan = buildImageSetPlan({ request: '套图返修硬链接阻断测试', preset: 'compact', productName: '返修硬链接商品' });
+  plan.items = plan.items.slice(0, 1);
+  const shortened = rehashPlan(plan);
+  const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
+  const initial = await executePlan({
+    plan: shortened, confirmed: true, budgetLimitCny: 2,
+    sessionId: `session-rejected-hardlink-source-${Date.now()}`, promptId: 'prompt-rejected-hardlink-source',
+    workspaceRoot, allowClaudeAttachments: false
+  }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
+  const originalFile = initial.files[0];
+  fs.linkSync(originalFile, path.join(path.dirname(originalFile), 'rejected-hardlink-alias.png'));
+  const provider = new FakeImageProvider();
+  const edited = await runEdit({
+    image: originalFile, prompt: '尝试返修硬链接源图', confirmed: true, budgetLimitCny: 1,
+    sessionId: `session-rejected-hardlink-edit-${Date.now()}`, promptId: 'prompt-rejected-hardlink-edit', workspaceRoot
+  }, { provider, visionProvider: passingVision, workspaceRoot });
+  try {
+    assert.equal(edited.status, 'blocked');
+    assert.equal(provider.generations, 0);
+    assert.equal(readState(path.dirname(originalFile)).items[0].revisions, undefined);
+  } finally { if (originalFile) removeTree(path.dirname(originalFile)); }
+});
+
+test('a hard-linked image that passed an earlier revision can be edited standalone', async () => {
+  const plan = buildImageSetPlan({ request: '返修通过后硬链接兼容测试', preset: 'compact', productName: '返修后兼容商品' });
+  plan.items = plan.items.slice(0, 1);
+  const shortened = rehashPlan(plan);
+  const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
+  const initial = await executePlan({
+    plan: shortened, confirmed: true, budgetLimitCny: 2,
+    sessionId: `session-revised-hardlink-source-${Date.now()}`, promptId: 'prompt-revised-hardlink-source',
+    workspaceRoot, allowClaudeAttachments: false
+  }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
+  const originalFile = initial.files[0];
+  const revised = await runEdit({
+    image: originalFile, prompt: '先完成原位返修', confirmed: true, budgetLimitCny: 1,
+    sessionId: `session-revised-hardlink-revision-${Date.now()}`, promptId: 'prompt-revised-hardlink-revision', workspaceRoot
+  }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
+  const revisedFile = revised.files[0];
+  fs.linkSync(revisedFile, path.join(path.dirname(revisedFile), 'revised-passed-hardlink-alias.png'));
+  const edited = await runEdit({
+    image: revisedFile, prompt: '返修通过后做普通独立编辑', confirmed: true, budgetLimitCny: 1,
+    sessionId: `session-revised-hardlink-edit-${Date.now()}`, promptId: 'prompt-revised-hardlink-edit', workspaceRoot
+  }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
+  try {
+    assert.equal(revised.status, 'ok');
+    assert.equal(edited.status, 'ok');
+    assert.notEqual(edited.data.run.runId, initial.data.run.runId);
+    assert.notEqual(path.dirname(edited.files[0]), path.dirname(revisedFile));
+    assert.equal(readState(path.dirname(revisedFile)).items[0].revisions.length, 1);
+  } finally {
+    if (edited.files[0]) removeTree(path.dirname(edited.files[0]));
+    if (originalFile) removeTree(path.dirname(originalFile));
+  }
+});
+
+test('a rejected in-place revision stays in the suite and preserves revision history', async () => {
+  const plan = buildImageSetPlan({ request: '套图返修再次拒绝测试', preset: 'compact', productName: '返修拒绝商品' });
+  plan.items = plan.items.slice(0, 1);
+  const shortened = rehashPlan(plan);
+  const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: { defects: false }, violations: ['still defective'], recommendations: [] } }) };
+  const initial = await executePlan({
+    plan: shortened, confirmed: true, budgetLimitCny: 2,
+    sessionId: `session-revision-reject-source-${Date.now()}`, promptId: 'prompt-revision-reject-source',
+    workspaceRoot, allowClaudeAttachments: false
+  }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
+  const originalFile = initial.files[0];
+  const revised = await runEdit({
+    image: originalFile, prompt: '再次修复', confirmed: true, budgetLimitCny: 1,
+    sessionId: `session-revision-reject-edit-${Date.now()}`, promptId: 'prompt-revision-reject-edit', workspaceRoot
+  }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
+  try {
+    assert.equal(revised.status, 'quality_rejected');
+    assert.equal(revised.data.run.runId, initial.data.run.runId);
+    assert.equal(revised.data.run.items[0].status, 'quality_rejected');
+    assert.equal(revised.data.run.items[0].revisions[0].status, 'quality_rejected');
+    assert.match(path.basename(revised.files[0]), new RegExp(`^${shortened.items[0].outputId}-v2-1\\.png$`));
+    assert.equal(fs.existsSync(originalFile), true);
+  } finally { if (originalFile) removeTree(path.dirname(originalFile)); }
+});
+
+test('ambiguous in-place revision submission is not generated twice', async () => {
+  const plan = buildImageSetPlan({ request: '套图返修提交不确定测试', preset: 'compact', productName: '返修不确定商品' });
+  plan.items = plan.items.slice(0, 1);
+  const shortened = rehashPlan(plan);
+  const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
+  const initial = await executePlan({
+    plan: shortened, confirmed: true, budgetLimitCny: 2,
+    sessionId: `session-revision-unknown-source-${Date.now()}`, promptId: 'prompt-revision-unknown-source',
+    workspaceRoot, allowClaudeAttachments: false
+  }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
+  const originalFile = initial.files[0];
+  const provider = new FakeImageProvider({ unknownAt: 1 });
+  const editInput = {
+    image: originalFile, prompt: '不确定提交返修', confirmed: true, budgetLimitCny: 1,
+    sessionId: `session-revision-unknown-edit-${Date.now()}`, promptId: 'prompt-revision-unknown-edit', workspaceRoot
+  };
+  const first = await runEdit(editInput, { provider, visionProvider: passingVision, workspaceRoot });
+  const second = await runEdit(editInput, { provider, visionProvider: passingVision, workspaceRoot });
+  try {
+    assert.equal(first.status, 'submission_unknown');
+    assert.equal(first.data.run.items[0].revisions[0].status, 'submission_unknown');
+    assert.equal(second.status, 'submission_unknown');
+    assert.equal(provider.generations, 1);
+  } finally { if (originalFile) removeTree(path.dirname(originalFile)); }
+});
+
+test('concurrent in-place revisions use an exclusive per-item lock', async () => {
+  const plan = buildImageSetPlan({ request: '套图返修并发锁测试', preset: 'compact', productName: '返修并发商品' });
+  plan.items = plan.items.slice(0, 1);
+  const shortened = rehashPlan(plan);
+  const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
+  const initial = await executePlan({
+    plan: shortened, confirmed: true, budgetLimitCny: 2,
+    sessionId: `session-revision-concurrent-source-${Date.now()}`, promptId: 'prompt-revision-concurrent-source',
+    workspaceRoot, allowClaudeAttachments: false
+  }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
+  const originalFile = initial.files[0];
+  let releaseGeneration;
+  let markEntered;
+  const entered = new Promise(resolve => { markEntered = resolve; });
+  const gate = new Promise(resolve => { releaseGeneration = resolve; });
+  const provider = {
+    generations: 0,
+    async generate(item) {
+      this.generations += 1;
+      markEntered();
+      await gate;
+      return { requestId: 'revision-concurrent-1', model: item.providerModel, images: ['data:image/png;base64,ZmFrZQ=='], usage: {} };
+    },
+    async materialize(_value, runDirectory, outputId) {
+      const file = path.join(runDirectory, `${outputId}-1.png`);
+      fs.writeFileSync(file, 'fake', { flag: 'wx' });
+      return file;
+    }
+  };
+  const editInput = {
+    image: originalFile, prompt: '并发返修', confirmed: true, budgetLimitCny: 1,
+    sessionId: `session-revision-concurrent-edit-${Date.now()}`, promptId: 'prompt-revision-concurrent-edit', workspaceRoot
+  };
+  const firstPromise = runEdit(editInput, { provider, visionProvider: passingVision, workspaceRoot });
+  await entered;
+  const activeStatus = await refreshRun({ runId: initial.data.run.runId, refresh: true }, { provider, workspaceRoot });
+  const concurrent = await runEdit(editInput, { provider, visionProvider: passingVision, workspaceRoot });
+  releaseGeneration();
+  const first = await firstPromise;
+  try {
+    assert.equal(activeStatus.status, 'blocked');
+    assert.equal(activeStatus.data.run.items[0].status, 'revising');
+    assert.equal(concurrent.status, 'blocked');
+    assert.equal(first.status, 'ok');
+    assert.equal(provider.generations, 1);
+    assert.equal(fs.existsSync(path.join(path.dirname(originalFile), `.revision-${shortened.items[0].outputId}.lock`)), false);
+  } finally { if (originalFile) removeTree(path.dirname(originalFile)); }
+});
+
+test('run-level lock prevents a retry on another item from overwriting an active edit', async () => {
+  const plan = buildImageSetPlan({ request: '跨项编辑续跑并发', preset: 'compact', productName: '跨项并发商品' });
+  plan.items = plan.items.slice(0, 2);
+  const shortened = rehashPlan(plan);
+  const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.2, checks: { defects: false }, violations: ['defect'], recommendations: [] } }) };
+  const initial = await executePlan({
+    plan: shortened, confirmed: true, sessionId: `session-cross-item-source-${Date.now()}`,
+    promptId: 'prompt-cross-item-source', workspaceRoot, allowClaudeAttachments: false
+  }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
+  let releaseEdit;
+  let signalStarted;
+  const editStarted = new Promise(resolve => { signalStarted = resolve; });
+  const editGate = new Promise(resolve => { releaseEdit = resolve; });
+  const editProvider = new FakeImageProvider();
+  editProvider.generate = async item => {
+    editProvider.generations += 1;
+    signalStarted();
+    await editGate;
+    return { requestId: 'cross-item-edit-1', model: item.providerModel, images: ['data:image/png;base64,ZmFrZQ=='], usage: {} };
+  };
+  const editPromise = runEdit({
+    image: initial.data.run.items[0].files[0], prompt: '修复第一项', confirmed: true,
+    sessionId: `session-cross-item-edit-${Date.now()}`, promptId: 'prompt-cross-item-edit'
+  }, { provider: editProvider, visionProvider: passingVision, workspaceRoot });
+  await editStarted;
+  const retryProvider = new FakeImageProvider();
+  const blockedRetry = await runGenerate({
+    runId: initial.data.run.runId, retryOutputIds: [initial.data.run.items[1].outputId], confirmed: true,
+    sessionId: `session-cross-item-retry-${Date.now()}`, promptId: 'prompt-cross-item-retry'
+  }, { provider: retryProvider, visionProvider: passingVision, workspaceRoot });
+  releaseEdit();
+  const edited = await editPromise;
+  try {
+    assert.equal(blockedRetry.status, 'blocked');
+    assert.equal(retryProvider.generations, 0);
+    assert.equal(editProvider.generations, 1);
+    assert.equal(edited.data.run.items[0].status, 'completed');
+    assert.equal(edited.data.run.items[1].status, 'quality_rejected');
+    assert.equal(edited.data.run.items[1].requestId, initial.data.run.items[1].requestId);
+  } finally {
+    const firstFile = edited.files[0] || initial.files[0];
+    if (firstFile) removeTree(path.dirname(firstFile));
+  }
+});
+
+test('status never races an active in-place revision download', async () => {
+  const plan = buildImageSetPlan({ request: '返修下载并发状态测试', preset: 'compact', productName: '返修下载并发商品' });
+  plan.items = plan.items.slice(0, 1);
+  const shortened = rehashPlan(plan);
+  const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
+  const initial = await executePlan({
+    plan: shortened, confirmed: true, budgetLimitCny: 2,
+    sessionId: `session-revision-download-source-${Date.now()}`, promptId: 'prompt-revision-download-source',
+    workspaceRoot, allowClaudeAttachments: false
+  }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
+  const originalFile = initial.files[0];
+  let releaseDownload;
+  let markDownloading;
+  const downloading = new Promise(resolve => { markDownloading = resolve; });
+  const gate = new Promise(resolve => { releaseDownload = resolve; });
+  const provider = {
+    generations: 0,
+    materializations: 0,
+    async generate(item) {
+      this.generations += 1;
+      return { requestId: 'revision-download-concurrent-1', model: item.providerModel, images: ['https://result.test/revision-concurrent.png'], usage: {} };
+    },
+    async materialize(_value, runDirectory, outputId) {
+      this.materializations += 1;
+      markDownloading();
+      await gate;
+      const file = path.join(runDirectory, `${outputId}-1.png`);
+      fs.writeFileSync(file, 'fake', { flag: 'wx' });
+      return file;
+    }
+  };
+  const revisionPromise = runEdit({
+    image: originalFile, prompt: '并发下载返修', confirmed: true, budgetLimitCny: 1,
+    sessionId: `session-revision-download-edit-${Date.now()}`, promptId: 'prompt-revision-download-edit', workspaceRoot
+  }, { provider, visionProvider: passingVision, workspaceRoot });
+  await downloading;
+  const activeStatus = await refreshRun({ runId: initial.data.run.runId, refresh: true }, { provider, workspaceRoot });
+  releaseDownload();
+  const revised = await revisionPromise;
+  try {
+    assert.equal(activeStatus.status, 'blocked');
+    assert.equal(activeStatus.data.run.items[0].status, 'revising');
+    assert.equal(revised.status, 'ok');
+    assert.equal(provider.generations, 1);
+    assert.equal(provider.materializations, 1);
+    assert.equal(readState(path.dirname(originalFile)).items[0].status, 'completed');
+  } finally { if (originalFile) removeTree(path.dirname(originalFile)); }
+});
+
+test('status blocks a live external revision-lock owner and recovers its stale lock after exit', async () => {
+  const plan = buildImageSetPlan({ request: '跨进程返修锁状态测试', preset: 'compact', productName: '跨进程返修锁商品' });
+  plan.items = plan.items.slice(0, 1);
+  const shortened = rehashPlan(plan);
+  const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
+  const initial = await executePlan({
+    plan: shortened, confirmed: true, budgetLimitCny: 2,
+    sessionId: `session-external-lock-source-${Date.now()}`, promptId: 'prompt-external-lock-source',
+    workspaceRoot, allowClaudeAttachments: false
+  }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
+  const originalFile = initial.files[0];
+  const lockPath = path.join(path.dirname(originalFile), `.revision-${shortened.items[0].outputId}.lock`);
+  const owner = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { stdio: 'ignore' });
+  await new Promise((resolve, reject) => {
+    owner.once('spawn', resolve);
+    owner.once('error', reject);
+  });
+  fs.writeFileSync(lockPath, `${JSON.stringify({ pid: owner.pid, requestKey: 'external-owner', createdAt: new Date().toISOString() })}\n`, { flag: 'wx' });
+  try {
+    const whileAlive = await refreshRun({ runId: initial.data.run.runId, refresh: true }, { workspaceRoot });
+    assert.equal(whileAlive.status, 'blocked');
+    assert.equal(fs.existsSync(lockPath), true);
+    const exited = new Promise(resolve => owner.once('exit', resolve));
+    owner.kill();
+    await exited;
+    const afterExit = await refreshRun({ runId: initial.data.run.runId, refresh: false }, { workspaceRoot });
+    assert.equal(afterExit.status, 'quality_rejected');
+    assert.equal(fs.existsSync(lockPath), false);
+    assert.equal(afterExit.data.run.items[0].status, 'quality_rejected');
+  } finally {
+    if (!owner.killed) owner.kill();
+    if (originalFile) removeTree(path.dirname(originalFile));
+  }
+});
+
+test('in-place revision resumes a persisted remote result without another generation', async () => {
+  const plan = buildImageSetPlan({ request: '套图返修远程恢复测试', preset: 'compact', productName: '返修恢复商品' });
+  plan.items = plan.items.slice(0, 1);
+  const shortened = rehashPlan(plan);
+  const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
+  const initial = await executePlan({
+    plan: shortened, confirmed: true, budgetLimitCny: 2,
+    sessionId: `session-revision-remote-source-${Date.now()}`, promptId: 'prompt-revision-remote-source',
+    workspaceRoot, allowClaudeAttachments: false
+  }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
+  const originalFile = initial.files[0];
+  let downloadAttempts = 0;
+  const provider = {
+    generations: 0,
+    async generate(item) {
+      this.generations += 1;
+      return { requestId: 'revision-remote-1', model: item.providerModel, images: ['https://result.test/revision.png'], usage: {} };
+    },
+    async materialize(_value, runDirectory, outputId) {
+      downloadAttempts += 1;
+      if (downloadAttempts === 1) throw new ProviderError('provider_unavailable', 'revision download interrupted');
+      const file = path.join(runDirectory, `${outputId}-1.png`);
+      fs.writeFileSync(file, 'fake', { flag: 'wx' });
+      return file;
+    }
+  };
+  const first = await runEdit({
+    image: originalFile, prompt: '恢复远程返修', confirmed: true, budgetLimitCny: 1,
+    sessionId: `session-revision-remote-edit-${Date.now()}`, promptId: 'prompt-revision-remote-edit', workspaceRoot
+  }, { provider, visionProvider: passingVision, workspaceRoot });
+  const differentRequest = await runEdit({
+    image: originalFile, prompt: '换一个请求尝试再次返修', confirmed: true, budgetLimitCny: 1,
+    sessionId: `session-revision-remote-edit-other-${Date.now()}`, promptId: 'prompt-revision-remote-edit-other', workspaceRoot
+  }, { provider, visionProvider: passingVision, workspaceRoot });
+  const resumed = await refreshRun({ runId: initial.data.run.runId, refresh: true }, { provider, workspaceRoot });
+  try {
+    assert.equal(first.data.run.items[0].status, 'revising');
+    assert.equal(differentRequest.status, 'blocked');
+    assert.equal(differentRequest.data.run.runId, initial.data.run.runId);
+    assert.equal(resumed.status, 'ok');
+    assert.equal(provider.generations, 1);
+    assert.equal(resumed.data.run.items[0].revisions[0].status, 'completed');
+    assert.equal(resumed.data.run.items[0].quality.status, 'unverified');
+    assert.equal(path.dirname(resumed.files[0]), path.dirname(originalFile));
+  } finally { if (originalFile) removeTree(path.dirname(originalFile)); }
+});
+
+test('status converts an interrupted in-place revision submit into submission_unknown', async () => {
+  const plan = buildImageSetPlan({ request: '套图返修中断测试', preset: 'compact', productName: '返修中断商品' });
+  plan.items = plan.items.slice(0, 1);
+  const shortened = rehashPlan(plan);
+  const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
+  const initial = await executePlan({
+    plan: shortened, confirmed: true, budgetLimitCny: 2,
+    sessionId: `session-revision-interrupt-source-${Date.now()}`, promptId: 'prompt-revision-interrupt-source',
+    workspaceRoot, allowClaudeAttachments: false
+  }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
+  const originalFile = initial.files[0];
+  const runDirectory = path.dirname(originalFile);
+  const state = readState(runDirectory);
+  state.items[0].status = 'revising';
+  state.items[0].revisions = [{
+    revisionId: 'revision_interrupted', requestKey: 'interrupted-key', version: 2,
+    status: 'submitting', sourceFiles: [originalFile], files: [], createdAt: new Date().toISOString()
+  }];
+  state.status = 'running';
+  writeState(runDirectory, state);
+  const recovered = await refreshRun({ runId: state.runId, refresh: false }, { workspaceRoot });
+  try {
+    assert.equal(recovered.status, 'submission_unknown');
+    assert.equal(recovered.data.run.items[0].status, 'submission_unknown');
+    assert.equal(recovered.data.run.items[0].revisions[0].status, 'submission_unknown');
+  } finally { if (originalFile) removeTree(runDirectory); }
+});
+
+test('in-place domestic revision inherits the original required platform validation', async () => {
+  const plan = buildImageSetPlan({ request: '淘宝主图返修规则继承', preset: 'main-image', platform: '淘宝', productName: '返修规则商品' });
+  plan.items = plan.items.slice(0, 1);
+  const shortened = rehashPlan(plan);
+  const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.1, checks: {}, violations: ['reject'], recommendations: [] } }) };
+  const initial = await executePlan({
+    plan: shortened, confirmed: true, budgetLimitCny: 2,
+    sessionId: `session-revision-domestic-source-${Date.now()}`, promptId: 'prompt-revision-domestic-source',
+    workspaceRoot, allowClaudeAttachments: false
+  }, { provider: new DomesticImageProvider(1024, 1024), visionProvider: rejectingVision, workspaceRoot });
+  const originalFile = initial.files[0];
+  const revised = await runEdit({
+    image: originalFile, prompt: '返修淘宝白底主图', confirmed: true, budgetLimitCny: 1,
+    sessionId: `session-revision-domestic-edit-${Date.now()}`, promptId: 'prompt-revision-domestic-edit', workspaceRoot
+  }, { provider: new DomesticImageProvider(1024, 768), visionProvider: passingVision, workspaceRoot });
+  try {
+    assert.equal(revised.status, 'quality_rejected');
+    assert.equal(revised.data.run.items[0].revisions[0].platformValidation.status, 'rejected');
+    assert.equal(revised.data.run.items[0].platformRule.platformKey, 'taobao');
+    assert.equal(path.dirname(revised.files[0]), path.dirname(originalFile));
+  } finally { if (originalFile) removeTree(path.dirname(originalFile)); }
+});
+
+test('domestic generation validates real dimensions without changing legacy execution', async () => {
+  const plan = buildImageSetPlan({ request: '淘宝商品主图', preset: 'main-image', platform: '淘宝' });
+  plan.items = plan.items.slice(0, 1);
+  const shortened = rehashPlan(plan);
+  const provider = new DomesticImageProvider(1024, 1024);
+  let reviewContext;
+  const reviewer = { review: async (_originals, _generated, input) => {
+    reviewContext = input;
+    return { model: GEMINI_IMAGE_MODEL, review: { passed: true, score: 1, checks: {}, violations: [], recommendations: [] } };
+  } };
+  const result = await executePlan({
+    plan: shortened, confirmed: true, budgetLimitCny: 2,
+    sessionId: `session-domestic-pass-${Date.now()}`, promptId: 'prompt-domestic-pass',
+    workspaceRoot, allowClaudeAttachments: false
+  }, { provider, visionProvider: reviewer, workspaceRoot });
+  try {
+    assert.equal(result.status, 'ok');
+    assert.equal(result.data.run.items[0].platformValidation.status, 'warning');
+    assert.equal(result.data.run.items[0].platformValidation.files[0].metadata.width, 1024);
+    assert.equal(reviewContext.platformRule.platformKey, 'taobao');
+  } finally { if (result.files[0]) removeTree(path.dirname(result.files[0])); }
+});
+
+test('required domestic ratio rejects a paid result but preserves its file', async () => {
+  const plan = buildImageSetPlan({ request: '淘宝商品主图比例拒绝', preset: 'main-image', platform: '淘宝' });
+  plan.items = plan.items.slice(0, 1);
+  const shortened = rehashPlan(plan);
+  const provider = new DomesticImageProvider(1024, 768);
+  const result = await executePlan({
+    plan: shortened, confirmed: true, budgetLimitCny: 2,
+    sessionId: `session-domestic-reject-${Date.now()}`, promptId: 'prompt-domestic-reject',
+    workspaceRoot, allowClaudeAttachments: false
+  }, { provider, visionProvider: passingVision, workspaceRoot });
+  try {
+    assert.equal(result.status, 'quality_rejected');
+    assert.equal(result.data.run.items[0].status, 'quality_rejected');
+    assert.equal(result.data.run.items[0].platformValidation.status, 'rejected');
+    assert.equal(result.files.length, 1);
+  } finally { if (result.files[0]) removeTree(path.dirname(result.files[0])); }
+});
+
+test('idempotent recovery cannot bypass missing domestic required validation', async () => {
+  const plan = buildImageSetPlan({ request: '淘宝主图中断恢复', preset: 'main-image', platform: '淘宝' });
+  plan.items = plan.items.slice(0, 1);
+  const shortened = rehashPlan(plan);
+  const sessionId = `session-domestic-recovery-${Date.now()}`;
+  const promptId = 'prompt-domestic-recovery';
+  const requestKey = createRequestKey({ sessionId, promptId, action: 'generate', planHash: shortened.planHash });
+  const runId = createReadableRunId({ productName: shortened.productName, requestKey });
+  const { runDirectory } = resolveRunDirectory({ workspaceRoot, packageRoot, runId });
+  fs.mkdirSync(runDirectory, { recursive: true });
+  const generated = path.join(runDirectory, 'A1-1.png');
+  fs.writeFileSync(generated, fakePng(1024, 768));
+  writeState(runDirectory, {
+    schemaVersion: 3, runId, requestKey, planId: shortened.planId, planHash: shortened.planHash,
+    status: 'completed', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
+    workspaceRoot, productName: shortened.productName, estimatedCostCny: shortened.estimatedCostCny,
+    costUnit: 'CNY', budgetLimitCny: 2, sourceImages: [],
+    items: [{ ...shortened.items[0], status: 'completed', requestId: 'existing-paid-request', files: [generated] }]
+  });
+  const provider = new DomesticImageProvider(2048, 2048);
+  const result = await executePlan({
+    plan: shortened, confirmed: true, budgetLimitCny: 2, sessionId, promptId,
+    workspaceRoot, allowClaudeAttachments: false
+  }, { provider, visionProvider: passingVision, workspaceRoot });
+  try {
+    assert.equal(provider.generations, 0);
+    assert.equal(result.status, 'quality_rejected');
+    assert.equal(result.data.run.items[0].platformValidation.status, 'rejected');
+    assert.equal(fs.existsSync(generated), true);
+  } finally { removeTree(runDirectory); }
+});
+
+test('interrupted submitting state becomes submission_unknown and is never regenerated', async () => {
+  const plan = buildOperationPlan({ prompt: `interrupted-${Date.now()}` });
+  const sessionId = `session-interrupted-${Date.now()}`;
+  const promptId = 'prompt-interrupted';
+  const requestKey = createRequestKey({ sessionId, promptId, action: 'generate', planHash: plan.planHash });
+  const runId = `run_${requestKey.slice(0, 20)}`;
+  const { runDirectory } = resolveRunDirectory({ workspaceRoot, packageRoot, runId, layout: 'legacy' });
+  writeState(runDirectory, {
+    schemaVersion: 3, runId, requestKey, planId: plan.planId, planHash: plan.planHash,
+    status: 'running', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
+    workspaceRoot, estimatedCostCny: plan.estimatedCostCny, costUnit: 'CNY', budgetLimitCny: 1,
+    items: [{ ...plan.items[0], status: 'submitting', requestId: '', files: [] }]
+  });
+  const provider = new FakeImageProvider();
+  const result = await executePlan({ plan, confirmed: true, budgetLimitCny: 1, sessionId, promptId, workspaceRoot, allowClaudeAttachments: false }, { provider, workspaceRoot });
+  try {
+    assert.equal(result.status, 'submission_unknown');
+    assert.equal(provider.generations, 0);
+  } finally { removeTree(runDirectory); }
+});
+
+test('completed run uses a Chinese product directory, moves root images, and copies nested images', async () => {
+  const caseRoot = fs.mkdtempSync(path.join(workspaceRoot, 'source-organize-'));
+  const rootImage = path.join(caseRoot, '..', `根目录商品-${Date.now()}.png`);
+  const nestedDirectory = path.join(caseRoot, '参考图');
+  const nestedImage = path.join(nestedDirectory, '商品背面.jpg');
+  fs.mkdirSync(nestedDirectory, { recursive: true });
+  fs.writeFileSync(rootImage, Buffer.from([137, 80, 78, 71, 1]));
+  fs.writeFileSync(nestedImage, Buffer.from([255, 216, 255, 224, 2]));
+  const plan = buildOperationPlan({ prompt: '整理商品原图', productName: 'MORI 咖啡/经典版' });
+  const input = {
+    plan, confirmed: true, budgetLimitCny: 1,
+    sessionId: `session-organize-${Date.now()}`, promptId: 'prompt-organize',
+    referenceImages: [rootImage, nestedImage], allowClaudeAttachments: false
+  };
+  const result = await runGenerate(input, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
+  try {
+    assert.equal(result.status, 'ok');
+    assert.match(result.data.run.runId, /^MORI-咖啡-经典版-[a-f0-9]{8}$/);
+    assert.ok(result.files[0].includes(`${path.sep}输出${path.sep}商品套图${path.sep}`));
+    assert.equal(fs.existsSync(rootImage), false);
+    assert.equal(fs.existsSync(nestedImage), true);
+    assert.equal(result.data.run.sourceOrganization.moved, 1);
+    assert.equal(result.data.run.sourceOrganization.copied, 1);
+    assert.ok(result.data.run.sourceOrganization.files.every(file => fs.existsSync(file.path)));
+    assert.match(result.assistantMessage, /已移动/);
+    assert.match(result.assistantMessage, /已复制/);
+  } finally {
+    if (result.files[0]) removeTree(path.dirname(result.files[0]));
+    if (fs.existsSync(path.resolve(rootImage))) fs.unlinkSync(path.resolve(rootImage));
+    removeTree(caseRoot);
+  }
+});
+
+test('non-completed run never moves a project-root source image', async () => {
+  const rootImage = path.join(workspaceRoot, `失败保留-${Date.now()}.png`);
+  fs.writeFileSync(rootImage, Buffer.from([137, 80, 78, 71, 3]));
+  const plan = buildOperationPlan({ prompt: '失败时保留原图', productName: '失败测试商品' });
+  const provider = new FakeImageProvider();
+  provider.generate = async () => { provider.generations += 1; throw new ProviderError('submission_unknown', 'ambiguous'); };
+  const result = await executePlan({
+    plan, confirmed: true, budgetLimitCny: 1,
+    sessionId: `session-source-failure-${Date.now()}`, promptId: 'prompt-source-failure',
+    referenceImages: [rootImage], allowClaudeAttachments: false, organizeSources: true
+  }, { provider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
+  try {
+    assert.equal(result.status, 'submission_unknown');
+    assert.equal(fs.existsSync(rootImage), true);
+    assert.equal(result.data.run.sourceImages[0].status, 'pending');
+    assert.equal(result.data.run.sourceOrganization, undefined);
+  } finally {
+    if (fs.existsSync(rootImage)) fs.unlinkSync(rootImage);
+    const currentRun = resolveRunDirectory({ workspaceRoot, packageRoot, runId: result.data.run.runId });
+    removeTree(currentRun.runDirectory);
+  }
+});
+
+test('source mutation blocks organization without changing the successful paid result', async () => {
+  const rootImage = path.join(workspaceRoot, `变化原图-${Date.now()}.png`);
+  fs.writeFileSync(rootImage, Buffer.from([137, 80, 78, 71, 4]));
+  const plan = buildOperationPlan({ prompt: '原图变化保护', productName: '变化保护商品' });
+  const mutatingVision = { review: async () => {
+    fs.appendFileSync(rootImage, Buffer.from([5]));
+    return { model: GEMINI_IMAGE_MODEL, review: { passed: true, score: 1, checks: {}, violations: [], recommendations: [] } };
+  } };
+  const result = await executePlan({
+    plan, confirmed: true, budgetLimitCny: 1,
+    sessionId: `session-source-change-${Date.now()}`, promptId: 'prompt-source-change',
+    referenceImages: [rootImage], allowClaudeAttachments: false, organizeSources: true
+  }, { provider: new FakeImageProvider(), visionProvider: mutatingVision, workspaceRoot });
+  try {
+    assert.equal(result.status, 'ok');
+    assert.equal(fs.existsSync(rootImage), true);
+    assert.equal(result.data.run.sourceOrganization.status, 'failed');
+    assert.match(result.assistantMessage, /整理失败/);
+    assert.ok(result.warnings.some(warning => /生成期间发生变化/.test(warning)));
+  } finally {
+    if (fs.existsSync(rootImage)) fs.unlinkSync(rootImage);
+    if (result.files[0]) removeTree(path.dirname(result.files[0]));
+  }
+});
+
+test('idempotent retry completes a prepared root-image move without regenerating', async () => {
+  const rootImage = path.join(workspaceRoot, `待完成移动-${Date.now()}.png`);
+  const bytes = Buffer.from([137, 80, 78, 71, 6]);
+  fs.writeFileSync(rootImage, bytes);
+  const plan = buildOperationPlan({ prompt: '恢复原图移动', productName: '恢复移动商品' });
+  const sessionId = `session-source-recover-${Date.now()}`;
+  const promptId = 'prompt-source-recover';
+  const requestKey = createRequestKey({ sessionId, promptId, action: 'generate', planHash: plan.planHash });
+  const runId = createReadableRunId({ productName: plan.productName, requestKey });
+  const { runDirectory } = resolveRunDirectory({ workspaceRoot, packageRoot, runId });
+  const sourceDirectory = path.join(runDirectory, '商品原图');
+  const destination = path.join(sourceDirectory, `01-${path.basename(rootImage)}`);
+  const generated = path.join(runDirectory, 'O1-1.png');
+  fs.mkdirSync(sourceDirectory, { recursive: true });
+  fs.copyFileSync(rootImage, destination);
+  fs.writeFileSync(generated, 'fake');
+  const contentHash = createHash('sha256').update(bytes).digest('hex');
+  writeState(runDirectory, {
+    schemaVersion: 3, runId, requestKey, planId: plan.planId, planHash: plan.planHash,
+    status: 'completed', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), workspaceRoot,
+    productName: plan.productName, estimatedCostCny: plan.estimatedCostCny, costUnit: 'CNY', budgetLimitCny: 1,
+    sourceImages: [{ index: 1, originalRelativePath: path.basename(rootImage), originalName: path.basename(rootImage), contentHash, size: bytes.length, action: 'move', status: 'prepared', destination }],
+    sourceOrganization: { status: 'prepared', directory: sourceDirectory, moved: 0, copied: 0, files: [{ originalPath: rootImage, path: destination, action: 'move', status: 'prepared' }] },
+    items: [{ ...plan.items[0], status: 'completed', requestId: 'existing', files: [generated] }]
+  });
+  const provider = new FakeImageProvider();
+  const result = await executePlan({ plan, confirmed: true, budgetLimitCny: 1, sessionId, promptId, allowClaudeAttachments: false }, { provider, workspaceRoot });
+  try {
+    assert.equal(result.status, 'ok');
+    assert.equal(provider.generations, 0);
+    assert.equal(fs.existsSync(rootImage), false);
+    assert.equal(fs.existsSync(destination), true);
+    assert.equal(result.data.run.sourceOrganization.status, 'completed');
+    assert.equal(result.data.run.sourceOrganization.moved, 1);
+  } finally {
+    if (fs.existsSync(rootImage)) fs.unlinkSync(rootImage);
+    removeTree(runDirectory);
+  }
+});
+
+test('status recovers source organization after the last item completed before finalization', async () => {
+  const rootImage = path.join(workspaceRoot, `状态恢复移动-${Date.now()}.png`);
+  const bytes = Buffer.from([137, 80, 78, 71, 8]);
+  fs.writeFileSync(rootImage, bytes);
+  const plan = buildOperationPlan({ prompt: '状态恢复原图整理', productName: '状态恢复商品' });
+  const requestKey = createRequestKey({ sessionId: `session-status-source-${Date.now()}`, promptId: 'prompt-status-source', action: 'generate', planHash: plan.planHash });
+  const runId = createReadableRunId({ productName: plan.productName, requestKey });
+  const { runDirectory } = resolveRunDirectory({ workspaceRoot, packageRoot, runId });
+  const generated = path.join(runDirectory, 'O1-1.png');
+  fs.mkdirSync(runDirectory, { recursive: true });
+  fs.writeFileSync(generated, 'fake');
+  const contentHash = createHash('sha256').update(bytes).digest('hex');
+  writeState(runDirectory, {
+    schemaVersion: 3, runId, requestKey, planId: plan.planId, planHash: plan.planHash,
+    status: 'running', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), workspaceRoot,
+    productName: plan.productName, estimatedCostCny: plan.estimatedCostCny, costUnit: 'CNY', budgetLimitCny: 1,
+    sourceImages: [{ index: 1, originalRelativePath: path.basename(rootImage), originalName: path.basename(rootImage), contentHash, size: bytes.length, action: 'move', status: 'pending' }],
+    items: [{ ...plan.items[0], status: 'completed', requestId: 'existing', files: [generated] }]
+  });
+  const result = await refreshRun({ runId, refresh: false }, { workspaceRoot });
+  try {
+    assert.equal(result.status, 'ok');
+    assert.equal(result.data.run.status, 'completed');
+    assert.equal(result.data.run.sourceOrganization.status, 'completed');
+    assert.equal(fs.existsSync(rootImage), false);
+    assert.equal(fs.existsSync(result.data.run.sourceOrganization.files[0].path), true);
+  } finally {
+    if (fs.existsSync(rootImage)) fs.unlinkSync(rootImage);
+    removeTree(runDirectory);
+  }
+});
+
+test('edit operations never organize or remove a project-root source image', async () => {
+  const rootImage = path.join(workspaceRoot, `编辑源图-${Date.now()}.png`);
+  fs.writeFileSync(rootImage, Buffer.from([137, 80, 78, 71, 7]));
+  const result = await runEdit({
+    image: rootImage, prompt: '保持商品不变,仅调整背景', confirmed: true, budgetLimitCny: 1,
+    sessionId: `session-edit-source-${Date.now()}`, promptId: 'prompt-edit-source', allowClaudeAttachments: false
+  }, { provider: new FakeImageProvider(), visionProvider: passingVision, workspaceRoot });
+  try {
+    assert.equal(result.status, 'ok');
+    assert.equal(fs.existsSync(rootImage), true);
+    assert.equal(result.data.run.sourceImages.length, 0);
+    assert.equal(result.data.run.sourceOrganization.status, 'not_applicable');
+  } finally {
+    if (fs.existsSync(rootImage)) fs.unlinkSync(rootImage);
+    if (result.files[0]) removeTree(path.dirname(result.files[0]));
+  }
+});
+
+test('remote result URL survives download failure and resumes without another generation', async () => {
+  const plan = buildOperationPlan({ prompt: 'remote recovery' });
+  let downloadAttempts = 0;
+  const provider = {
+    generations: 0,
+    async generate(item) {
+      this.generations += 1;
+      return { requestId: 'remote-1', model: item.providerModel, images: ['https://result.test/image.png'], usage: {} };
+    },
+    async materialize(_value, runDirectory, outputId) {
+      downloadAttempts += 1;
+      if (downloadAttempts <= 3) throw new ProviderError('provider_unavailable', 'download interrupted');
+      const file = path.join(runDirectory, `${outputId}-1.png`);
+      fs.writeFileSync(file, 'fake', { flag: 'wx' });
+      return file;
+    }
+  };
+  const input = { plan, confirmed: true, budgetLimitCny: 1, sessionId: `session-remote-${Date.now()}`, promptId: 'prompt-remote', workspaceRoot, allowClaudeAttachments: false };
+  const first = await executePlan(input, { provider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
+  const runId = first.data.run.runId;
+  const resumed = await refreshRun({ runId, refresh: true }, { provider, workspaceRoot });
+  try {
+    assert.equal(first.data.run.items[0].status, 'receiving');
+    assert.equal(resumed.status, 'ok');
+    assert.equal(provider.generations, 1);
+    assert.equal(downloadAttempts, 4);
+    assert.equal(resumed.files.length, 1);
+    assert.equal(resumed.data.run.items[0].quality.status, 'unverified');
+  } finally { if (resumed.files[0]) removeTree(path.dirname(resumed.files[0])); }
+});
+
+test('same-run retry with an old file resumes remote download into its versioned target', async () => {
+  const plan = buildOperationPlan({ prompt: 'versioned remote recovery', productName: '版本化恢复商品' });
+  const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.2, checks: { defects: false }, violations: ['defect'], recommendations: [] } }) };
+  const initial = await executePlan({
+    plan, confirmed: true, sessionId: `session-versioned-source-${Date.now()}`,
+    promptId: 'prompt-versioned-source', workspaceRoot, allowClaudeAttachments: false
+  }, { provider: new FakeImageProvider(), visionProvider: rejectingVision, workspaceRoot });
+  const oldFile = initial.files[0];
+  let downloadAttempts = 0;
+  const retryProvider = {
+    generations: 0,
+    async generate(item) {
+      this.generations += 1;
+      return { requestId: 'remote-versioned-1', model: item.providerModel, images: ['https://result.test/versioned.png'], usage: {} };
+    },
+    async materialize(_value, runDirectory, outputId) {
+      downloadAttempts += 1;
+      if (downloadAttempts <= 3) throw new ProviderError('provider_unavailable', 'download interrupted');
+      const file = path.join(runDirectory, `${outputId}-1.png`);
+      fs.writeFileSync(file, 'retry-file', { flag: 'wx' });
+      return file;
+    }
+  };
+  const retried = await runGenerate({
+    runId: initial.data.run.runId, retryOutputIds: ['O1'], confirmed: true,
+    sessionId: `session-versioned-retry-${Date.now()}`, promptId: 'prompt-versioned-retry'
+  }, { provider: retryProvider, visionProvider: passingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
+  const resumed = await refreshRun({ runId: initial.data.run.runId, refresh: true }, { provider: retryProvider, workspaceRoot });
+  try {
+    assert.equal(retried.data.run.items[0].status, 'receiving');
+    assert.equal(retried.data.run.items[0].materializeOutputId, 'O1-retry1');
+    assert.equal(resumed.status, 'ok');
+    assert.equal(retryProvider.generations, 1);
+    assert.equal(downloadAttempts, 4);
+    assert.equal(path.basename(resumed.files[0]), 'O1-retry1-1.png');
+    assert.equal(fs.existsSync(oldFile), true);
+  } finally { if (oldFile) removeTree(path.dirname(oldFile)); }
+});
+
+test('status holds the run lock while recovering a download and blocks a retry on another item', async () => {
+  const plan = buildImageSetPlan({ request: '状态先持锁再续跑', preset: 'compact', productName: '状态恢复并发商品' });
+  plan.items = plan.items.slice(0, 2);
+  const shortened = rehashPlan(plan);
+  let initialDownloads = 0;
+  const initialProvider = {
+    async generate(item) {
+      return item.outputId === shortened.items[0].outputId
+        ? { requestId: 'status-first-remote', model: item.providerModel, images: ['https://result.test/status-first.png'], usage: {} }
+        : { requestId: 'status-second-inline', model: item.providerModel, images: ['data:image/png;base64,ZmFrZQ=='], usage: {} };
+    },
+    async materialize(value, runDirectory, outputId) {
+      if (/^https?:/.test(value)) {
+        initialDownloads += 1;
+        throw new ProviderError('provider_unavailable', 'download interrupted');
+      }
+      const file = path.join(runDirectory, `${outputId}-1.png`);
+      fs.writeFileSync(file, 'inline', { flag: 'wx' });
+      return file;
+    }
+  };
+  const rejectingVision = { review: async () => ({ model: GEMINI_IMAGE_MODEL, review: { passed: false, score: 0.2, checks: { defects: false }, violations: ['defect'], recommendations: [] } }) };
+  const initial = await executePlan({
+    plan: shortened, confirmed: true, sessionId: `session-status-first-source-${Date.now()}`,
+    promptId: 'prompt-status-first-source', workspaceRoot, allowClaudeAttachments: false
+  }, { provider: initialProvider, visionProvider: rejectingVision, workspaceRoot, retryDelaysMs: [0, 0], sleep: async () => {} });
+  let releaseDownload;
+  let signalDownload;
+  const downloadStarted = new Promise(resolve => { signalDownload = resolve; });
+  const downloadGate = new Promise(resolve => { releaseDownload = resolve; });
+  const recoveryProvider = {
+    async materialize(_value, runDirectory, outputId) {
+      signalDownload();
+      await downloadGate;
+      const file = path.join(runDirectory, `${outputId}-1.png`);
+      fs.writeFileSync(file, 'recovered', { flag: 'wx' });
+      return file;
+    }
+  };
+  const statusPromise = refreshRun({ runId: initial.data.run.runId, refresh: true }, { provider: recoveryProvider, workspaceRoot });
+  await downloadStarted;
+  const retryProvider = new FakeImageProvider();
+  const blockedRetry = await runGenerate({
+    runId: initial.data.run.runId, retryOutputIds: [shortened.items[1].outputId], confirmed: true,
+    sessionId: `session-status-first-retry-${Date.now()}`, promptId: 'prompt-status-first-retry'
+  }, { provider: retryProvider, visionProvider: passingVision, workspaceRoot });
+  releaseDownload();
+  const recovered = await statusPromise;
+  try {
+    assert.equal(initialDownloads, 3);
+    assert.equal(blockedRetry.status, 'blocked');
+    assert.equal(retryProvider.generations, 0);
+    assert.equal(recovered.data.run.items[0].status, 'completed');
+    assert.equal(recovered.data.run.items[1].status, 'quality_rejected');
+  } finally {
+    const firstFile = recovered.files[0] || initial.files[0];
+    if (firstFile) removeTree(path.dirname(firstFile));
+  }
+});

+ 131 - 0
fmode-image-set/mcp/src/features/generation/platform-validation.mjs

@@ -0,0 +1,131 @@
+import fs from 'node:fs';
+
+function pngMetadata(bytes) {
+  const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
+  if (bytes.length < 26 || !bytes.subarray(0, 8).equals(signature) || bytes.toString('ascii', 12, 16) !== 'IHDR') return null;
+  const colorType = bytes[25];
+  let hasTransparencyChunk = false;
+  for (let offset = 8; offset + 12 <= bytes.length;) {
+    const length = bytes.readUInt32BE(offset);
+    if (offset + 12 + length > bytes.length) break;
+    if (bytes.toString('ascii', offset + 4, offset + 8) === 'tRNS') hasTransparencyChunk = true;
+    offset += 12 + length;
+  }
+  return {
+    format: 'png', width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20),
+    hasAlpha: colorType === 4 || colorType === 6 || hasTransparencyChunk
+  };
+}
+
+function jpegMetadata(bytes) {
+  if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
+  const sof = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
+  let offset = 2;
+  while (offset + 8 < bytes.length) {
+    if (bytes[offset] !== 0xff) { offset += 1; continue; }
+    while (offset < bytes.length && bytes[offset] === 0xff) offset += 1;
+    const marker = bytes[offset];
+    offset += 1;
+    if (marker === 0xd8 || marker === 0xd9) continue;
+    if (marker === 0xda) break;
+    if (offset + 2 > bytes.length) break;
+    const length = bytes.readUInt16BE(offset);
+    if (length < 2 || offset + length > bytes.length) break;
+    if (sof.has(marker) && length >= 7) {
+      return { format: 'jpeg', width: bytes.readUInt16BE(offset + 5), height: bytes.readUInt16BE(offset + 3), hasAlpha: false };
+    }
+    offset += length;
+  }
+  return null;
+}
+
+function webpMetadata(bytes) {
+  if (bytes.length < 20 || bytes.toString('ascii', 0, 4) !== 'RIFF' || bytes.toString('ascii', 8, 12) !== 'WEBP') return null;
+  const kind = bytes.toString('ascii', 12, 16);
+  if (kind === 'VP8X' && bytes.length >= 30) {
+    const width = 1 + bytes.readUIntLE(24, 3);
+    const height = 1 + bytes.readUIntLE(27, 3);
+    return { format: 'webp', width, height, hasAlpha: Boolean(bytes[20] & 0x10) };
+  }
+  if (kind === 'VP8 ' && bytes.length >= 30 && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
+    return { format: 'webp', width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff, hasAlpha: false };
+  }
+  if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
+    const width = 1 + (((bytes[22] & 0x3f) << 8) | bytes[21]);
+    const height = 1 + (((bytes[24] & 0x0f) << 10) | (bytes[23] << 2) | ((bytes[22] & 0xc0) >> 6));
+    return { format: 'webp', width, height, hasAlpha: Boolean(bytes[24] & 0x10) };
+  }
+  return null;
+}
+
+export function readImageMetadata(filePath) {
+  const bytes = fs.readFileSync(filePath);
+  const metadata = pngMetadata(bytes) || jpegMetadata(bytes) || webpMetadata(bytes);
+  if (!metadata || !metadata.width || !metadata.height) throw new Error('无法读取生成图片的真实格式或像素尺寸。');
+  return { ...metadata, bytes: bytes.length };
+}
+
+function parseRatio(value) {
+  const match = String(value || '').match(/^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/);
+  if (!match) return null;
+  return Number(match[1]) / Number(match[2]);
+}
+
+function checked(name, enforcement, passed, expected, actual, message) {
+  const level = enforcement || 'recommended';
+  return { name, enforcement: level, passed, expected, actual, message: passed ? '' : message };
+}
+
+export function validateDomesticImage(filePath, platformRule) {
+  if (!platformRule?.domestic) return null;
+  let metadata;
+  try { metadata = readImageMetadata(filePath); }
+  catch (error) {
+    return {
+      status: 'rejected', passed: false, filePath, metadata: null,
+      checks: [], violations: [error.message], warnings: []
+    };
+  }
+  const target = platformRule.target || {};
+  const enforcement = platformRule.enforcement || {};
+  const checks = [];
+  const expectedRatio = parseRatio(target.ratio);
+  if (expectedRatio) {
+    const actualRatio = metadata.width / metadata.height;
+    const passed = Math.abs(actualRatio - expectedRatio) <= 0.005;
+    checks.push(checked('ratio', enforcement.ratio, passed, target.ratio, `${metadata.width}:${metadata.height}`, `实际比例 ${metadata.width}:${metadata.height} 不符合目标 ${target.ratio}。`));
+  }
+  if (target.width && target.height) {
+    const passed = metadata.width === Number(target.width) && metadata.height === Number(target.height);
+    checks.push(checked('dimensions', enforcement.dimensions, passed, `${target.width}x${target.height}`, `${metadata.width}x${metadata.height}`, `实际像素 ${metadata.width}x${metadata.height} 与交付建议 ${target.width}x${target.height} 不一致。`));
+  }
+  if (target.alphaAllowed === false && metadata.hasAlpha !== null) {
+    checks.push(checked('alpha', enforcement.alpha, metadata.hasAlpha === false, false, metadata.hasAlpha, '白底商品图包含透明通道,交付前应扁平化到纯白背景。'));
+  }
+  if (target.maxBytes) {
+    checks.push(checked('maxBytes', enforcement.maxBytes, metadata.bytes <= Number(target.maxBytes), target.maxBytes, metadata.bytes, `文件大小 ${metadata.bytes} 超过目标上限 ${target.maxBytes}。`));
+  }
+  const violations = checks.filter(item => !item.passed && item.enforcement === 'required').map(item => item.message);
+  const warnings = checks.filter(item => !item.passed && item.enforcement !== 'required').map(item => item.message);
+  return {
+    status: violations.length ? 'rejected' : warnings.length ? 'warning' : 'passed',
+    passed: violations.length === 0,
+    filePath, metadata, checks, violations, warnings
+  };
+}
+
+export function validateDomesticFiles(item) {
+  if (!item?.platformRule?.domestic) return null;
+  const files = (item.files || []).map(filePath => validateDomesticImage(filePath, item.platformRule));
+  const violations = files.flatMap(file => file.violations || []);
+  const warnings = files.flatMap(file => file.warnings || []);
+  return {
+    status: violations.length ? 'rejected' : warnings.length ? 'warning' : 'passed',
+    passed: violations.length === 0,
+    platformKey: item.platformRule.platformKey,
+    assetType: item.platformRule.assetType,
+    verificationStatus: item.platformRule.verificationStatus,
+    files, violations, warnings,
+    ruleWarnings: item.platformRule.warnings || []
+  };
+}

+ 110 - 0
fmode-image-set/mcp/src/features/generation/platform-validation.test.mjs

@@ -0,0 +1,110 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+import { readImageMetadata, validateDomesticImage } from './platform-validation.mjs';
+
+function fakePng(width, height, colorType = 2) {
+  const bytes = Buffer.alloc(33);
+  Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes, 0);
+  bytes.writeUInt32BE(13, 8);
+  bytes.write('IHDR', 12, 'ascii');
+  bytes.writeUInt32BE(width, 16);
+  bytes.writeUInt32BE(height, 20);
+  bytes[24] = 8;
+  bytes[25] = colorType;
+  return bytes;
+}
+
+function fakePngWithTransparencyChunk(width, height) {
+  const transparency = Buffer.alloc(18);
+  transparency.writeUInt32BE(6, 0);
+  transparency.write('tRNS', 4, 'ascii');
+  return Buffer.concat([fakePng(width, height, 2), transparency]);
+}
+
+function fakeJpeg(width, height) {
+  const bytes = Buffer.alloc(23);
+  Buffer.from([0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11, 0x08]).copy(bytes, 0);
+  bytes.writeUInt16BE(height, 7);
+  bytes.writeUInt16BE(width, 9);
+  bytes[11] = 3;
+  Buffer.from([1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0, 0xff, 0xd9]).copy(bytes, 12);
+  return bytes;
+}
+
+function fakeWebp(width, height, alpha = false) {
+  const bytes = Buffer.alloc(30);
+  bytes.write('RIFF', 0, 'ascii');
+  bytes.writeUInt32LE(22, 4);
+  bytes.write('WEBPVP8X', 8, 'ascii');
+  bytes.writeUInt32LE(10, 16);
+  bytes[20] = alpha ? 0x10 : 0;
+  bytes.writeUIntLE(width - 1, 24, 3);
+  bytes.writeUIntLE(height - 1, 27, 3);
+  return bytes;
+}
+
+function fakeLosslessWebp(width, height, alpha = false) {
+  const bytes = Buffer.alloc(25);
+  bytes.write('RIFF', 0, 'ascii');
+  bytes.writeUInt32LE(17, 4);
+  bytes.write('WEBPVP8L', 8, 'ascii');
+  bytes.writeUInt32LE(5, 16);
+  bytes[20] = 0x2f;
+  const bits = BigInt(width - 1) | (BigInt(height - 1) << 14n) | (alpha ? 1n << 28n : 0n);
+  bytes.writeUInt32LE(Number(bits), 21);
+  return bytes;
+}
+
+function withFile(bytes, callback) {
+  const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'fmode-platform-validation-'));
+  const file = path.join(directory, 'candidate.png');
+  fs.writeFileSync(file, bytes);
+  try { return callback(file); }
+  finally { fs.rmSync(directory, { recursive: true, force: true }); }
+}
+
+const requiredSquare = {
+  domestic: true, platformKey: 'taobao', assetType: 'listing_primary', verificationStatus: 'official_verified_partial',
+  target: { width: 2048, height: 2048, ratio: '1:1', alphaAllowed: false },
+  enforcement: { ratio: 'required', dimensions: 'recommended', alpha: 'recommended' }, warnings: []
+};
+
+test('reads PNG dimensions and alpha-channel metadata', () => withFile(fakePng(2048, 2048, 6), file => {
+  assert.deepEqual(readImageMetadata(file), { format: 'png', width: 2048, height: 2048, hasAlpha: true, bytes: 33 });
+}));
+
+test('detects indexed or truecolor PNG transparency from tRNS', () => withFile(fakePngWithTransparencyChunk(800, 800), file => {
+  assert.deepEqual(readImageMetadata(file), { format: 'png', width: 800, height: 800, hasAlpha: true, bytes: 51 });
+}));
+
+test('reads JPEG dimensions from a start-of-frame marker', () => withFile(fakeJpeg(1600, 1200), file => {
+  assert.deepEqual(readImageMetadata(file), { format: 'jpeg', width: 1600, height: 1200, hasAlpha: false, bytes: 23 });
+}));
+
+test('reads extended WebP dimensions and alpha flag', () => withFile(fakeWebp(1000, 1000, true), file => {
+  assert.deepEqual(readImageMetadata(file), { format: 'webp', width: 1000, height: 1000, hasAlpha: true, bytes: 30 });
+}));
+
+test('reads lossless WebP dimensions and alpha flag', () => withFile(fakeLosslessWebp(641, 853, true), file => {
+  assert.deepEqual(readImageMetadata(file), { format: 'webp', width: 641, height: 853, hasAlpha: true, bytes: 25 });
+}));
+
+test('required domestic ratio rejects a mismatched image', () => withFile(fakePng(2048, 1536), file => {
+  const result = validateDomesticImage(file, requiredSquare);
+  assert.equal(result.status, 'rejected');
+  assert.match(result.violations[0], /比例/);
+}));
+
+test('recommended dimensions and alpha produce warnings without rejection', () => withFile(fakePng(1024, 1024, 6), file => {
+  const result = validateDomesticImage(file, requiredSquare);
+  assert.equal(result.status, 'warning');
+  assert.equal(result.passed, true);
+  assert.equal(result.warnings.length, 2);
+}));
+
+test('legacy items bypass domestic image validation', () => withFile(Buffer.from('not-an-image'), file => {
+  assert.equal(validateDomesticImage(file, null), null);
+}));

+ 100 - 0
fmode-image-set/mcp/src/features/image-analysis/analyze.mjs

@@ -0,0 +1,100 @@
+import { z } from 'zod';
+import { resolveImageInputs } from '../../core/attachment-resolver.mjs';
+import { errorResult, okResult } from '../../core/result-envelope.mjs';
+import { safeError } from '../../core/redaction.mjs';
+import { ProviderError } from '../../providers/provider-error.mjs';
+import { NewApiImageProvider } from '../../providers/new-api-image.mjs';
+
+const confidence = z.number().min(0).max(1).catch(0.5);
+const fact = z.object({
+  field: z.string().catch(''), value: z.string().catch(''),
+  source_image: z.number().int().positive().catch(1), confidence
+});
+const inference = z.object({
+  field: z.string().catch(''), value: z.string().catch(''), confidence,
+  reason: z.string().catch('')
+});
+
+export const productAnalysisSchema = z.object({
+  visible_facts: z.array(fact).catch([]),
+  visual_inferences: z.array(inference).catch([]),
+  unknowns: z.array(z.string()).catch([]),
+  product_identity: z.object({
+    product_name: z.string().catch(''), category: z.string().catch(''), shape: z.string().catch(''),
+    packaging: z.string().catch(''), logo: z.string().catch(''),
+    visible_text: z.array(z.string()).catch([]), materials: z.array(z.string()).catch([]),
+    colors: z.array(z.string()).catch([]), distinctive_features: z.array(z.string()).catch([])
+  }).catch({ product_name: '', category: '', shape: '', packaging: '', logo: '', visible_text: [], materials: [], colors: [], distinctive_features: [] }),
+  brand_style: z.object({
+    palette: z.array(z.string()).catch([]), lighting: z.string().catch(''),
+    mood: z.string().catch(''), composition: z.string().catch('')
+  }).catch({ palette: [], lighting: '', mood: '', composition: '' }),
+  quality_risks: z.array(z.string()).catch([]),
+  recommended_reference_roles: z.array(z.object({
+    image: z.number().int().positive().catch(1), role: z.enum(['identity', 'style', 'detail', 'model']).catch('identity')
+  })).catch([])
+});
+
+export async function analyzeProductImages(input = {}, dependencies = {}) {
+  const workspaceRoot = dependencies.workspaceRoot || process.env.FMODE_WORKSPACE_ROOT || process.cwd();
+  const resolvedInput = { ...input, workspaceRoot, cwd: workspaceRoot };
+  let images;
+  try {
+    images = resolveImageInputs(resolvedInput);
+  } catch (error) {
+    return errorResult('needs_input', safeError(error), { nextActions: ['提供当前商品图片的绝对路径、URL 或 Claude 当前请求附件。'] });
+  }
+  if (!images.length) {
+    return errorResult('needs_input', '当前请求中没有找到商品图片,且不会复用旧请求附件。', {
+      nextActions: ['在当前请求上传图片,或传入 imagePath/imageUrl。']
+    });
+  }
+  let referenceRoles = [];
+  const allowedRoles = new Set(['identity', 'style', 'detail', 'model']);
+  if (images.length > 1 && resolvedInput.allImagesSameProduct === true && resolvedInput.imageRoles) {
+    return errorResult('needs_input', 'allImagesSameProduct 与 imageRoles 只能传一个;同一商品多视图使用前者,混合参考图使用后者。');
+  }
+  if (images.length > 1 && resolvedInput.allImagesSameProduct === true) {
+    referenceRoles = images.map((image, index) => ({ image: index + 1, label: image.label, role: 'identity' }));
+  } else if (images.length > 1 && !resolvedInput.imageRoles) {
+    return errorResult('needs_input', '检测到多张图片,请先标注每张图是 identity、style、detail 还是 model。', {
+      summary: { imageCount: images.length },
+      nextActions: ['同一商品的多张视图传 allImagesSameProduct=true;混合参考图传覆盖每张图片的 imageRoles,例如 {"1":"identity","2":"style"}。']
+    });
+  }
+  if (images.length > 1 && resolvedInput.allImagesSameProduct !== true && resolvedInput.imageRoles) {
+    for (let index = 0; index < images.length; index += 1) {
+      const image = images[index];
+      const role = resolvedInput.imageRoles[String(index + 1)] || resolvedInput.imageRoles[image.label];
+      if (!allowedRoles.has(role)) {
+        return errorResult('needs_input', `第 ${index + 1} 张图片缺少有效角色;请用编号键覆盖每张图,例如 {"1":"identity","2":"detail"},角色只能是 identity、style、detail 或 model。`);
+      }
+      referenceRoles.push({ image: index + 1, label: image.label, role });
+    }
+  }
+  if (!referenceRoles.length) referenceRoles = images.map((image, index) => ({ image: index + 1, label: image.label, role: 'identity' }));
+  resolvedInput.imageRoles = referenceRoles;
+  const provider = dependencies.provider || new NewApiImageProvider({ fetchImpl: dependencies.fetchImpl });
+  try {
+    const result = await provider.analyze(images, resolvedInput);
+    const analysis = productAnalysisSchema.parse(result.analysis);
+    return okResult({
+      assistantMessage: `已用 Gemini 分析 ${images.length} 张当前商品图;结果已区分可见事实、视觉推断和未知项。`,
+      summary: {
+        imageCount: images.length,
+        visibleFactCount: analysis.visible_facts.length,
+        inferenceCount: analysis.visual_inferences.length,
+        unknownCount: analysis.unknowns.length,
+        model: result.model
+      },
+      data: { analysis, imageSources: images.map(({ label, source }) => ({ label, source })), referenceRoles, usage: result.usage, finishReason: result.finishReason },
+      nextActions: ['审核商品事实;确认后调用 fmode_image_set_plan 生成无费用的套图计划。']
+    });
+  } catch (error) {
+    const status = error instanceof ProviderError ? error.kind : 'error';
+    return errorResult(status, safeError(error), {
+      summary: { imageCount: images.length },
+      nextActions: status === 'needs_token' ? ['通过环境变量配置 FMODE_LLM_API_KEY 或 LLM_API_KEY 后重试。'] : []
+    });
+  }
+}

+ 129 - 0
fmode-image-set/mcp/src/features/image-analysis/analyze.test.mjs

@@ -0,0 +1,129 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+import { analyzeProductImages } from './analyze.mjs';
+
+const projectRoot = path.resolve(import.meta.dirname, '..', '..', '..', '..', '..');
+
+function tempWorkspace(prefix) {
+  return fs.mkdtempSync(path.join(os.tmpdir(), `fmode-image-set-${prefix}-`));
+}
+
+test('analysis rejects missing current-request image without calling provider', async () => {
+  let called = false;
+  const result = await analyzeProductImages({ allowClaudeAttachments: false }, { provider: { analyze: async () => { called = true; } } });
+  assert.equal(result.status, 'needs_input');
+  assert.equal(called, false);
+});
+
+test('analysis validates structured Gemini result', async () => {
+  const workspace = tempWorkspace('analysis');
+  const fixture = path.join(workspace, 'analysis.png');
+  fs.writeFileSync(fixture, Buffer.from([137, 80, 78, 71]));
+  try {
+    const provider = {
+      analyze: async () => ({
+        model: 'fake-gemini', usage: {}, finishReason: 'stop',
+        analysis: {
+          visible_facts: [{ field: 'color', value: 'red', source_image: 1, confidence: 0.9 }],
+          visual_inferences: [], unknowns: ['size'],
+          product_identity: { product_name: '红瓶精华', category: 'beauty', shape: 'bottle', packaging: 'box', logo: '', visible_text: [], materials: [], colors: ['red'], distinctive_features: [] },
+          brand_style: { palette: ['#ff0000'], lighting: 'soft', mood: 'clean', composition: 'centered' },
+          quality_risks: [], recommended_reference_roles: [{ image: 1, role: 'identity' }]
+        }
+      })
+    };
+    const result = await analyzeProductImages({ imagePath: fixture, workspaceRoot: workspace, cwd: workspace, allowClaudeAttachments: false }, { provider, workspaceRoot: workspace });
+    assert.equal(result.status, 'ok');
+    assert.equal(result.data.analysis.visible_facts[0].value, 'red');
+    assert.equal(result.data.analysis.product_identity.product_name, '红瓶精华');
+  } finally {
+    fs.rmSync(workspace, { recursive: true, force: true });
+  }
+});
+
+test('analysis requires a complete role mapping for multiple images and passes it to Gemini', async () => {
+  const workspace = tempWorkspace('roles');
+  const first = path.join(workspace, 'role-1.png');
+  const second = path.join(workspace, 'role-2.png');
+  fs.writeFileSync(first, Buffer.from([137, 80, 78, 71]));
+  fs.writeFileSync(second, Buffer.from([137, 80, 78, 72]));
+  let received;
+  const provider = { analyze: async (_images, input) => {
+    received = input.imageRoles;
+    return { model: 'fake', usage: {}, finishReason: 'stop', analysis: {} };
+  } };
+  try {
+    const incomplete = await analyzeProductImages({ images: [first, second], imageRoles: { 1: 'identity' }, workspaceRoot: workspace, cwd: workspace, allowClaudeAttachments: false }, { provider, workspaceRoot: workspace });
+    assert.equal(incomplete.status, 'needs_input');
+    const complete = await analyzeProductImages({ images: [first, second], imageRoles: { 1: 'identity', 2: 'detail' }, workspaceRoot: workspace, cwd: workspace, allowClaudeAttachments: false }, { provider, workspaceRoot: workspace });
+    assert.equal(complete.status, 'ok');
+    assert.deepEqual(received.map(item => item.role), ['identity', 'detail']);
+  } finally {
+    fs.rmSync(workspace, { recursive: true, force: true });
+  }
+});
+
+test('analysis accepts one explicit same-product flag for multiple identity views', async () => {
+  const workspace = tempWorkspace('same-product');
+  const files = ['main.png', 'front.png', 'back.png'].map((name, index) => {
+    const file = path.join(workspace, name);
+    fs.writeFileSync(file, Buffer.from([137, 80, 78, 71 + index]));
+    return file;
+  });
+  let received;
+  const provider = { analyze: async (_images, input) => {
+    received = input.imageRoles;
+    return { model: 'fake', usage: {}, finishReason: 'stop', analysis: {} };
+  } };
+  try {
+    const result = await analyzeProductImages({
+      images: files, allImagesSameProduct: true, allowClaudeAttachments: false
+    }, { provider, workspaceRoot: workspace });
+    assert.equal(result.status, 'ok');
+    assert.deepEqual(received.map(item => item.role), ['identity', 'identity', 'identity']);
+    assert.deepEqual(result.data.referenceRoles.map(item => item.label), ['main.png', 'front.png', 'back.png']);
+  } finally {
+    fs.rmSync(workspace, { recursive: true, force: true });
+  }
+});
+
+test('analysis rejects ambiguous same-product and per-image role inputs', async () => {
+  const workspace = tempWorkspace('ambiguous-roles');
+  const files = ['first.png', 'second.png'].map((name, index) => {
+    const file = path.join(workspace, name);
+    fs.writeFileSync(file, Buffer.from([137, 80, 78, 71 + index]));
+    return file;
+  });
+  let called = false;
+  try {
+    const result = await analyzeProductImages({
+      images: files,
+      allImagesSameProduct: true,
+      imageRoles: { 1: 'identity', 2: 'style' },
+      allowClaudeAttachments: false
+    }, { workspaceRoot: workspace, provider: { analyze: async () => { called = true; } } });
+    assert.equal(result.status, 'needs_input');
+    assert.match(result.assistantMessage, /只能传一个/);
+    assert.equal(called, false);
+  } finally {
+    fs.rmSync(workspace, { recursive: true, force: true });
+  }
+});
+
+test('analysis rejects a local image outside the declared workspace', async () => {
+  const parent = tempWorkspace('outside-boundary');
+  const workspace = path.join(parent, 'workspace');
+  const outside = path.join(parent, 'outside.png');
+  fs.mkdirSync(workspace, { recursive: true });
+  fs.writeFileSync(outside, Buffer.from([137, 80, 78, 71]));
+  try {
+    const result = await analyzeProductImages({ imagePath: outside, workspaceRoot: workspace, cwd: workspace, allowClaudeAttachments: false }, { workspaceRoot: workspace, provider: { analyze: async () => ({ analysis: {} }) } });
+    assert.equal(result.status, 'needs_input');
+    assert.match(result.assistantMessage, /当前项目内/);
+  } finally {
+    fs.rmSync(parent, { recursive: true, force: true });
+  }
+});

+ 41 - 0
fmode-image-set/mcp/src/features/planning/catalog.mjs

@@ -0,0 +1,41 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+const catalogRoot = path.resolve(import.meta.dirname, '..', '..', '..', 'catalog');
+const templateRoot = path.join(catalogRoot, 'templates');
+
+function readJson(filePath) {
+  return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
+}
+
+export function loadPlatforms() {
+  return readJson(path.join(catalogRoot, 'platforms.json'));
+}
+
+export function listTemplates(locale = 'zh') {
+  const selected = locale === 'en' ? 'en' : 'zh';
+  return fs.readdirSync(path.join(templateRoot, selected))
+    .filter(name => name.endsWith('.json'))
+    .sort()
+    .map(name => readJson(path.join(templateRoot, selected, name)));
+}
+
+export function loadTemplate(id, locale = 'zh') {
+  const template = listTemplates(locale).find(item => item.id === id);
+  if (!template) throw new Error(`未知模板:${id}`);
+  return template;
+}
+
+export function searchTemplates(query = '', locale = 'zh') {
+  const text = String(query || '').trim().toLowerCase();
+  const all = listTemplates(locale);
+  if (!text) return all;
+  const terms = [...new Set(text.split(/\s+/).filter(Boolean))];
+  return all.filter(template => {
+    const searchable = [template.id, template.name, ...(template.keywords || []), ...(template.trigger_phrases || [])]
+      .map(value => String(value).toLowerCase());
+    return terms.some(term => searchable.some(value => value.includes(term)));
+  });
+}
+
+export { catalogRoot, templateRoot };

+ 13 - 0
fmode-image-set/mcp/src/features/planning/catalog.test.mjs

@@ -0,0 +1,13 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { searchTemplates } from './catalog.mjs';
+
+test('template search tokenizes combined natural-language queries', () => {
+  const results = searchTemplates('A+ Amazon 详情页模块', 'zh');
+  assert.ok(results.some(template => template.id === 'infographic'));
+});
+
+test('single-term template search remains compatible', () => {
+  assert.ok(searchTemplates('A+', 'zh').some(template => template.id === 'infographic'));
+  assert.ok(searchTemplates('detail-macro', 'en').some(template => template.id === 'detail-macro'));
+});

+ 119 - 0
fmode-image-set/mcp/src/features/planning/domestic-platforms.mjs

@@ -0,0 +1,119 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { catalogRoot } from './catalog.mjs';
+
+const DOMESTIC_PRESETS = new Set(['compact', 'full', 'main-image', 'detail-page']);
+
+const OUTPUT_ASSET_TYPES = Object.freeze({
+  A1: 'white_background', A2: 'selling_point', A3: 'listing_secondary', A4: 'listing_secondary',
+  A5: 'scene', A6: 'listing_secondary', A7: 'package', A8: 'dimension'
+});
+
+const TEMPLATE_ASSET_TYPES = Object.freeze({
+  'hero-image': 'listing_primary',
+  'infographic': 'selling_point',
+  'multi-angle-grid': 'listing_secondary',
+  'detail-macro': 'listing_secondary',
+  'lifestyle-scene': 'scene',
+  'ugc-style': 'scene',
+  'model-showcase': 'scene',
+  'multi-product': 'listing_secondary',
+  packaging: 'package',
+  'size-spec': 'dimension',
+  'poster-banner': 'detail_module',
+  'before-after': 'detail_module',
+  'exploded-view': 'detail_module',
+  'luxury-atmospherics': 'detail_module',
+  'social-media': 'detail_module'
+});
+
+function readCatalog() {
+  return JSON.parse(fs.readFileSync(path.join(catalogRoot, 'domestic-platforms.json'), 'utf8').replace(/^\uFEFF/, ''));
+}
+
+function normalize(value) {
+  return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ');
+}
+
+const DOMESTIC_ALIAS_HINTS = new Set([
+  '淘宝/天猫', '淘宝天猫', '淘宝', '天猫', 'taobao', 'tmall', 'taobao.com', 'tmall.com',
+  '京东', 'jd', 'jd.com', 'jingdong', 'jing dong',
+  '拼多多', 'pdd', 'pinduoduo', 'pinduoduo.com'
+].map(normalize));
+
+function mergeRule(base, override = {}) {
+  return {
+    ...base,
+    ...override,
+    target: { ...(base.target || {}), ...(override.target || {}) },
+    enforcement: { ...(base.enforcement || {}), ...(override.enforcement || {}) },
+    warnings: [...new Set([...(base.warnings || []), ...(override.warnings || [])])]
+  };
+}
+
+export function loadDomesticPlatforms() {
+  return readCatalog();
+}
+
+export function resolveDomesticPlatform(value) {
+  const catalog = readCatalog();
+  const candidate = normalize(value);
+  if (!candidate) return null;
+  for (const [key, platform] of Object.entries(catalog.platforms || {})) {
+    const aliases = [key, ...(platform.aliases || [])].map(normalize);
+    if (aliases.includes(candidate)) return { key, ...structuredClone(platform) };
+  }
+  return null;
+}
+
+export function assetTypeForPlanItem({ outputId, templateId }) {
+  return OUTPUT_ASSET_TYPES[outputId] || TEMPLATE_ASSET_TYPES[templateId] || 'listing_secondary';
+}
+
+export function resolveDomesticAssetRule({ platform, preset, outputId, templateId }) {
+  if (!DOMESTIC_PRESETS.has(preset)) return null;
+  if (!DOMESTIC_ALIAS_HINTS.has(normalize(platform))) return null;
+  const catalog = readCatalog();
+  const candidate = normalize(platform);
+  const entry = Object.entries(catalog.platforms || {}).find(([key, value]) => (
+    [key, ...(value.aliases || [])].map(normalize).includes(candidate)
+  ));
+  const resolved = entry ? { key: entry[0], ...structuredClone(entry[1]) } : null;
+  if (!resolved) return null;
+  const assetType = assetTypeForPlanItem({ outputId, templateId });
+  const platformRule = catalog.platforms[resolved.key];
+  const withAssetDefaults = mergeRule(catalog.defaults, catalog.assetDefaults?.[assetType]);
+  const merged = mergeRule(withAssetDefaults, platformRule.assets?.[assetType]);
+  const evidence = (merged.evidenceIds || platformRule.evidenceIds || [])
+    .map(id => ({ id, ...catalog.evidence[id] }))
+    .filter(item => item.title);
+  return {
+    domestic: true,
+    schemaVersion: catalog.schemaVersion,
+    platformKey: resolved.key,
+    platformLabel: resolved.label,
+    assetType,
+    uploadRole: merged.uploadRole || `${resolved.label} ${assetType} 候选`,
+    target: merged.target,
+    enforcement: merged.enforcement,
+    verificationStatus: merged.verificationStatus,
+    evidence,
+    warnings: merged.warnings,
+    ruleCenterUrl: resolved.ruleCenterUrl,
+    complianceClaim: merged.verificationStatus === 'official_verified' ? 'official_verified' : 'not_guaranteed'
+  };
+}
+
+export function domesticPromptConstraints(rule) {
+  if (!rule?.domestic) return '';
+  const target = rule.target || {};
+  return [
+    `DOMESTIC MARKETPLACE TARGET — ${rule.platformLabel}; asset role: ${rule.uploadRole}.`,
+    `Compose for ${target.ratio || 'the requested'} ratio with safe zone: ${target.safeZone || 'keep the product fully visible'}.`,
+    `Background policy: ${target.background || 'follow the asset role'}.`,
+    `Text policy: ${target.textPolicy || 'use only caller-confirmed factual copy'}.`,
+    'This is a design target, not a claim that marketplace review is guaranteed.'
+  ].join(' ');
+}
+
+export { DOMESTIC_PRESETS };

+ 58 - 0
fmode-image-set/mcp/src/features/planning/domestic-platforms.test.mjs

@@ -0,0 +1,58 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+  assetTypeForPlanItem, loadDomesticPlatforms, resolveDomesticAssetRule, resolveDomesticPlatform
+} from './domestic-platforms.mjs';
+
+test('domestic aliases resolve without changing unknown platforms', () => {
+  assert.equal(resolveDomesticPlatform('淘宝').key, 'taobao');
+  assert.equal(resolveDomesticPlatform('淘宝/天猫').key, 'taobao');
+  assert.equal(resolveDomesticPlatform('TMALL').key, 'taobao');
+  assert.equal(resolveDomesticPlatform('JD.com').key, 'jingdong');
+  assert.equal(resolveDomesticPlatform('PDD').key, 'pinduoduo');
+  assert.equal(resolveDomesticPlatform('Amazon'), null);
+});
+
+test('domestic catalog keeps evidence status explicit', () => {
+  const catalog = loadDomesticPlatforms();
+  assert.equal(catalog.evidence['TB-2024-01-26-MAIN-RATIO'].verificationStatus, 'official_verified');
+  assert.equal(catalog.evidence['JD-RULE-CENTER'].verificationStatus, 'official_entry_only');
+  assert.equal(catalog.evidence['PDD-HELP-CENTER'].verificationStatus, 'official_entry_only');
+  assert.equal(catalog.defaults.enforcement.textPolicy, 'recommended');
+  for (const [key, platform] of Object.entries(catalog.platforms)) {
+    for (const alias of [key, ...platform.aliases]) {
+      assert.equal(resolveDomesticAssetRule({ platform: alias, preset: 'compact', outputId: 'H1', templateId: 'hero-image' })?.platformKey, key);
+    }
+  }
+});
+
+test('main-image slots map to stable domestic asset types', () => {
+  assert.equal(assetTypeForPlanItem({ outputId: 'A1', templateId: 'hero-image' }), 'white_background');
+  assert.equal(assetTypeForPlanItem({ outputId: 'A2', templateId: 'infographic' }), 'selling_point');
+  assert.equal(assetTypeForPlanItem({ outputId: 'A8', templateId: 'size-spec' }), 'dimension');
+});
+
+test('only commerce presets receive domestic rules', () => {
+  const rule = resolveDomesticAssetRule({ platform: '淘宝', preset: 'main-image', outputId: 'A1', templateId: 'hero-image' });
+  assert.equal(rule.platformKey, 'taobao');
+  assert.equal(rule.enforcement.ratio, 'required');
+  assert.equal(rule.complianceClaim, 'not_guaranteed');
+  assert.equal(resolveDomesticAssetRule({ platform: '淘宝', preset: 'social', outputId: 'C1', templateId: 'social-media' }), null);
+});
+
+test('Taobao main-image evidence does not leak into unrelated asset types', () => {
+  const primary = resolveDomesticAssetRule({ platform: '淘宝', preset: 'compact', outputId: 'H1', templateId: 'hero-image' });
+  const sellingPoint = resolveDomesticAssetRule({ platform: '淘宝', preset: 'main-image', outputId: 'A2', templateId: 'infographic' });
+  assert.deepEqual(primary.evidence.map(item => item.id), ['TB-2024-01-26-MAIN-RATIO']);
+  assert.equal(primary.verificationStatus, 'official_verified_partial');
+  assert.deepEqual(sellingPoint.evidence, []);
+  assert.equal(sellingPoint.verificationStatus, 'safe_fallback');
+});
+
+test('non-primary assets keep their preset dimensions instead of inheriting a square target', () => {
+  const rule = resolveDomesticAssetRule({ platform: '京东', preset: 'detail-page', outputId: 'D1', templateId: 'poster-banner' });
+  assert.equal(rule.assetType, 'detail_module');
+  assert.equal(rule.target.width, null);
+  assert.equal(rule.target.height, null);
+  assert.equal(rule.target.ratio, null);
+});

+ 34 - 0
fmode-image-set/mcp/src/features/planning/locks.mjs

@@ -0,0 +1,34 @@
+function clean(values) {
+  return (Array.isArray(values) ? values : [values]).map(value => String(value || '').trim()).filter(Boolean);
+}
+
+export function buildIdentityLock(analysis = {}, explicit = {}) {
+  const identity = analysis.product_identity || analysis.productIdentity || {};
+  const parts = [
+    explicit.productDescription,
+    identity.category && `category: ${identity.category}`,
+    identity.shape && `shape: ${identity.shape}`,
+    identity.packaging && `packaging: ${identity.packaging}`,
+    identity.logo && `logo exactly as reference: ${identity.logo}`,
+    clean(identity.visible_text).length && `visible packaging text unchanged: ${clean(identity.visible_text).join(' / ')}`,
+    clean(identity.materials).length && `materials: ${clean(identity.materials).join(', ')}`,
+    clean(identity.colors).length && `product colors unchanged: ${clean(identity.colors).join(', ')}`,
+    clean(identity.distinctive_features).length && `distinctive features: ${clean(identity.distinctive_features).join(', ')}`
+  ].filter(Boolean);
+  return parts.length ? `IDENTITY LOCK — preserve exactly across every image: ${parts.join('; ')}.` : 'IDENTITY LOCK — preserve the supplied product geometry, packaging, logo, color, material, and distinctive details exactly.';
+}
+
+export function buildStyleLock(analysis = {}, input = {}) {
+  const style = analysis.brand_style || analysis.brandStyle || {};
+  const parts = [
+    input.style,
+    clean(input.brandColors).length && `brand colors ${clean(input.brandColors).join(', ')}`,
+    clean(style.palette).length && `palette ${clean(style.palette).join(', ')}`,
+    style.lighting && `lighting ${style.lighting}`,
+    style.mood && `mood ${style.mood}`,
+    style.composition && `composition language ${style.composition}`
+  ].filter(Boolean);
+  return `STYLE LOCK — ${parts.length ? parts.join('; ') : 'consistent commercial lighting, color grading, lens language, shadow softness, and layout system'}.`;
+}
+
+export const NEGATIVE_CONSTRAINTS = 'Do not alter product geometry, packaging, logo, color, material, proportions, or factual labels. No invented claims, fake certification, fake platform UI, account information, engagement metrics, watermark, tool logo, garbled text, duplicate product, deformed object, extra parts, blur, or low resolution.';

+ 262 - 0
fmode-image-set/mcp/src/features/planning/planner.mjs

@@ -0,0 +1,262 @@
+import { randomUUID } from 'node:crypto';
+import { COST_UNIT, GEMINI_IMAGE_MODEL, generationReserveCny, SEEDREAM_IMAGE_MODEL } from '../../providers/new-api-image.mjs';
+import { sha256, stableStringify } from '../../core/request-key.mjs';
+import { loadPlatforms, loadTemplate } from './catalog.mjs';
+import { domesticPromptConstraints, resolveDomesticAssetRule } from './domestic-platforms.mjs';
+import { buildIdentityLock, buildStyleLock, NEGATIVE_CONSTRAINTS } from './locks.mjs';
+
+const BANNED_PROMPT_TERMS = /\b(instagram|tiktok|facebook|rednote|xiaohongshu|pinterest|linkedin|twitter|google display|amazon|shopify|sponsored|like button|follow button|comment box|engagement metrics|timestamp badge|audio icon|app ui|platform ui)\b/gi;
+
+const FULL_SLOT_TEMPLATES = {
+  H1: 'hero-image', H2: 'lifestyle-scene', H3: 'before-after', H4: 'ugc-style', H5: 'poster-banner',
+  M1: 'lifestyle-scene', M2: 'detail-macro', M3: 'exploded-view', M4: 'ugc-style', M5: 'packaging',
+  M6: 'size-spec', M7: 'infographic', M8: 'poster-banner', M9: 'social-media'
+};
+
+const COMPACT = [
+  ['S1', 'hero-image', '电商主图'], ['S2', 'lifestyle-scene', '生活方式场景'],
+  ['S3', 'detail-macro', '材质与细节'], ['S4', 'infographic', '核心卖点'],
+  ['S5', 'size-spec', '尺寸与规格'], ['S6', 'packaging', '包装与清单'],
+  ['S7', 'poster-banner', '营销收束图']
+];
+
+const MAIN_IMAGE = [
+  ['A1', 'hero-image', '合规白底主图'], ['A2', 'infographic', '核心卖点副图'],
+  ['A3', 'multi-angle-grid', '多角度副图'], ['A4', 'detail-macro', '细节副图'],
+  ['A5', 'lifestyle-scene', '生活方式副图'], ['A6', 'multi-product', '款式/组合副图'],
+  ['A7', 'packaging', '包装清单副图'], ['A8', 'size-spec', '尺寸参考副图']
+];
+
+const DETAIL_PAGE = [
+  ['D1', 'poster-banner', '品牌 Hero Banner'], ['D2', 'before-after', '痛点与结果'],
+  ['D3', 'infographic', '核心卖点'], ['D4', 'detail-macro', '材质工艺'],
+  ['D5', 'exploded-view', '技术结构'], ['D6', 'size-spec', '规格/用法'],
+  ['D7', 'packaging', '包装与系列'], ['D8', 'luxury-atmospherics', '品牌背书收束']
+];
+
+const ANGLES = ['正面', '左前 45 度', '右前 45 度', '左侧', '右侧', '背面', '上视', '细节近景', '动态/穿着展示'];
+
+function detectPreset(request = '', explicit = '') {
+  if (explicit) return explicit;
+  const text = String(request).toLowerCase();
+  if (/14|十四|完整套图|h1|m9/.test(text)) return 'full';
+  if (/九角|9角|多角度|multi-angle|lookbook/.test(text)) return 'multi-angle';
+  if (/a\+|详情页|detail page|hero banner/.test(text)) return 'detail-page';
+  if (/广告|投放|ad creative|campaign|促销/.test(text)) return 'ad-creative';
+  if (/社媒|小红书|instagram|tiktok|feed|story|carousel|轮播/.test(text)) return 'social';
+  if (/亚马逊|amazon|主图|副图|listing image/.test(text)) return 'main-image';
+  return 'compact';
+}
+
+function platformKey(value = '') {
+  const text = String(value).toLowerCase();
+  if (/amazon|亚马逊/.test(text)) return 'amazon';
+  if (/shopify/.test(text)) return 'shopify';
+  if (/google/.test(text)) return 'google_display';
+  if (/facebook/.test(text)) return 'facebook_ad';
+  if (/linkedin/.test(text)) return 'linkedin_ad';
+  if (/story/.test(text)) return 'instagram_story';
+  if (/instagram/.test(text)) return 'instagram_feed';
+  if (/tiktok|抖音/.test(text)) return 'tiktok';
+  if (/pinterest/.test(text)) return 'pinterest';
+  if (/小红书|rednote|xiaohongshu/.test(text)) return 'rednote';
+  if (/\bx\b|twitter/.test(text)) return 'x';
+  return 'general_ecommerce';
+}
+
+function interpolate(value, vars) {
+  if (typeof value === 'string') return value.replace(/\{([A-Za-z0-9_]+)\}/g, (_, key) => vars[key] || '');
+  if (Array.isArray(value)) return value.map(item => interpolate(item, vars));
+  if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, interpolate(item, vars)]));
+  return value;
+}
+
+function flattenPrompt(value) {
+  return Object.entries(value || {}).map(([key, item]) => `${key}: ${Array.isArray(item) ? item.join(', ') : item}`).filter(part => !part.endsWith(': ')).join('; ');
+}
+
+export function sanitizeGeneratorPrompt(value) {
+  return String(value || '').replace(BANNED_PROMPT_TERMS, '').replace(/\s{2,}/g, ' ').replace(/\s+([,;:.])/g, '$1').trim();
+}
+
+export function deriveProductName(input = {}, analysis = {}) {
+  const explicit = String(input.productName || '').trim();
+  if (explicit) return explicit;
+  const identity = analysis.product_identity || {};
+  const identityName = String(identity.product_name || identity.productName || identity.name || '').trim();
+  if (identityName) return identityName;
+  const namedFact = (analysis.visible_facts || []).find(fact => /^(商品名|产品名|品名|product[ _-]?name|name)$/i.test(String(fact?.field || '').trim()));
+  if (String(namedFact?.value || '').trim()) return String(namedFact.value).trim();
+  const brand = String(identity.logo || '').trim();
+  const category = String(identity.category || '').trim();
+  if (brand && category && !category.toLowerCase().includes(brand.toLowerCase())) return `${brand}-${category}`;
+  return brand || category || '未命名商品';
+}
+
+function applyDimensions(item, width, height, ratio) {
+  item.width = width;
+  item.height = height;
+  item.ratio = ratio;
+  item.prompt = item.prompt.replace(/Canvas\s+\d+x\d+/i, `Canvas ${width}x${height}`);
+}
+
+function templatePrompt(template, input, title, analysis, platform) {
+  const identity = analysis.product_identity || {};
+  const vars = {
+    product_description: input.productDescription || [identity.category, identity.shape, identity.packaging].filter(Boolean).join(' ') || 'the exact referenced product',
+    material_description: (identity.materials || []).join(', '),
+    platform_style: platform.label,
+    overlay_elements: input.requestedCopy || 'none',
+    color: (input.brandColors || [])[0] || (identity.colors || [])[0] || '',
+    product: input.productDescription || identity.category || 'product'
+  };
+  const base = interpolate(template.prompt_template || {}, vars);
+  const variant = input.style && template.variants?.[input.style]?.overrides ? interpolate(template.variants[input.style].overrides, vars) : {};
+  const categoryTip = template.category_tips?.[input.category || identity.category] || '';
+  const textPolicy = platform.text_policy;
+  const requestedCopy = input.requestedCopy && !/不在生成图中渲染文字|无文字/.test(textPolicy) ? `Render only this exact short copy: “${input.requestedCopy}”.` : 'Do not render text unless explicitly supplied.';
+  return sanitizeGeneratorPrompt([
+    `OUTPUT PURPOSE — ${title}.`,
+    buildIdentityLock(analysis, input), buildStyleLock(analysis, input),
+    flattenPrompt({ ...base, ...variant }), categoryTip,
+    `Canvas ${platform.width}x${platform.height}; safe zone: ${platform.safe_zone}; text policy: ${textPolicy}.`,
+    requestedCopy, NEGATIVE_CONSTRAINTS
+  ].filter(Boolean).join(' '));
+}
+
+function itemFromTuple(tuple, index, input, analysis, locale, platformOverride) {
+  const [outputId, templateId, title, note] = tuple;
+  const template = loadTemplate(templateId, locale);
+  const platforms = loadPlatforms();
+  const platform = platforms[platformOverride || platformKey(input.platform)] || platforms.general_ecommerce;
+  const providerModel = input.modelPreference === 'seedream' || input.modelPreference === SEEDREAM_IMAGE_MODEL
+    ? SEEDREAM_IMAGE_MODEL
+    : GEMINI_IMAGE_MODEL;
+  const domesticRule = resolveDomesticAssetRule({
+    platform: input.platform, preset: input.preset, outputId, templateId
+  });
+  if (domesticRule) {
+    const target = domesticRule.target || {};
+    const domesticPlatform = {
+      ...platform,
+      label: domesticRule.platformLabel,
+      width: Number(target.width || platform.width),
+      height: Number(target.height || platform.height),
+      ratio: target.ratio || platform.ratio,
+      safe_zone: target.safeZone || platform.safe_zone,
+      text_policy: target.textPolicy || platform.text_policy
+    };
+    const basePrompt = templatePrompt(template, input, note ? `${title};${note}` : title, analysis, domesticPlatform);
+    return {
+      outputId, outputKind: templateId, title, reason: note || `套图预设中的${title}`,
+      templateId, templateName: template.name, width: domesticPlatform.width, height: domesticPlatform.height,
+      ratio: domesticPlatform.ratio, safeZone: domesticPlatform.safe_zone, textPolicy: domesticPlatform.text_policy,
+      providerModel, prompt: `${basePrompt} ${domesticPromptConstraints(domesticRule)}`,
+      estimatedCostCny: generationReserveCny(providerModel, { qualityReview: input.qualityReview !== false }), costUnit: COST_UNIT, sortOrder: index,
+      platformRule: domesticRule
+    };
+  }
+  return {
+    outputId, outputKind: templateId, title, reason: note || `套图预设中的${title}`,
+    templateId, templateName: template.name, width: platform.width, height: platform.height,
+    ratio: platform.ratio, safeZone: platform.safe_zone, textPolicy: platform.text_policy,
+    providerModel, prompt: templatePrompt(template, input, note ? `${title};${note}` : title, analysis, platform),
+    estimatedCostCny: generationReserveCny(providerModel, { qualityReview: input.qualityReview !== false }), costUnit: COST_UNIT, sortOrder: index
+  };
+}
+
+function tuplesForPreset(preset, input) {
+  if (preset === 'full') return Object.entries(FULL_SLOT_TEMPLATES).map(([id, template]) => [id, template, `${id} 套图模块`]);
+  if (preset === 'main-image') return MAIN_IMAGE;
+  if (preset === 'detail-page') return DETAIL_PAGE;
+  if (preset === 'multi-angle') return ANGLES.map((angle, index) => [`V${index + 1}`, 'multi-angle-grid', `一致性多角度 ${index + 1}`, angle]);
+  if (preset === 'ad-creative') {
+    const platforms = Array.isArray(input.platforms) && input.platforms.length ? input.platforms : [input.platform || 'general_ecommerce'];
+    return platforms.slice(0, 8).map((platform, index) => [`AD${index + 1}`, 'poster-banner', `${platform} 广告创意`, `为 ${platform} 独立设计,不是简单缩放`, platformKey(platform)]);
+  }
+  if (preset === 'social') {
+    const count = /轮播|carousel/i.test(input.request || '') ? Math.min(Number(input.count || 7), 10) : Math.min(Number(input.count || 1), 10);
+    return Array.from({ length: count }, (_, index) => [`C${index + 1}`, 'social-media', count > 1 ? `社媒轮播第 ${index + 1} 页` : '社媒视觉', index === 0 ? '三秒钩子封面' : index === count - 1 ? '行动引导收束' : '单页一个信息点']);
+  }
+  return COMPACT;
+}
+
+export function buildImageSetPlan(input = {}) {
+  const request = String(input.request || input.prompt || '').trim();
+  if (!request) throw new Error('request 为必填项。');
+  const locale = input.locale === 'en' ? 'en' : 'zh';
+  const preset = detectPreset(request, input.preset);
+  const analysis = input.analysis?.analysis || input.analysis || {};
+  const productName = deriveProductName(input, analysis);
+  const tuples = tuplesForPreset(preset, { ...input, request });
+  if (!tuples.length || tuples.length > 14) throw new Error('输出计划必须包含 1–14 个项目。');
+  const items = tuples.map((tuple, index) => itemFromTuple(tuple, index, { ...input, request, preset }, analysis, locale, tuple[4]));
+  if (preset === 'multi-angle') {
+    for (const item of items) applyDimensions(item, 2048, 2560, '4:5');
+  }
+  if (preset === 'detail-page') {
+    applyDimensions(items[0], 3024, 1296, '21:9');
+    applyDimensions(items.at(-1), 3024, 1296, '21:9');
+  }
+  const estimatedCostCny = Number(items.reduce((sum, item) => sum + (item.estimatedCostCny || 0), 0).toFixed(4));
+  const planCore = {
+    schemaVersion: 3, locale, preset, request, platform: input.platform || 'general_ecommerce',
+    productName, productDescription: input.productDescription || '', analysis,
+    items, estimatedCostCny, costUnit: COST_UNIT, costBasis: 'trusted_usd_price_and_exchange_rate', qualityReview: input.qualityReview !== false, requiresConfirmation: true,
+    qualityChecks: ['商品身份一致', '品牌色与商品色一致', 'Logo/文字不变形', '平台尺寸和安全区', '无虚构声明', '无平台 UI/账号/水印', '无明显生成缺陷']
+  };
+  const planHash = sha256(stableStringify(planCore));
+  return {
+    ...planCore,
+    planId: input.planId || `plan_${planHash.slice(0, 16)}`,
+    planHash,
+    createdAt: input.createdAt || new Date().toISOString(),
+    confirmationMessage: `计划共 ${items.length} 张,按受信美元价格与汇率预计 ¥${estimatedCostCny.toFixed(2)}。确认张数、模型、提示词和人民币预算后才能生成。`
+  };
+}
+
+export function toPublicPlan(plan = {}) {
+  const items = (plan.items || []).map(({ estimatedCostCny, costUnit, ...item }) => item);
+  const {
+    estimatedCostCny, costUnit, costBasis, confirmationMessage,
+    ...publicPlan
+  } = plan;
+  return {
+    ...publicPlan,
+    items,
+    confirmationMessage: `计划共 ${items.length} 张;确认张数、模型、尺寸、用途和提示词后即可生成。`
+  };
+}
+
+function restoreTrustedPlanCosts(plan = {}) {
+  const qualityReview = plan.qualityReview !== false;
+  const items = (plan.items || []).map(item => ({
+    ...item,
+    estimatedCostCny: generationReserveCny(item.providerModel, { qualityReview }),
+    costUnit: COST_UNIT
+  }));
+  return {
+    ...plan,
+    items,
+    estimatedCostCny: Number(items.reduce((sum, item) => sum + item.estimatedCostCny, 0).toFixed(4)),
+    costUnit: COST_UNIT,
+    costBasis: 'trusted_usd_price_and_exchange_rate'
+  };
+}
+
+export function verifyPlan(plan) {
+  const { planId, planHash, createdAt, confirmationMessage, ...planCore } = plan || {};
+  let verified = plan;
+  let actual = sha256(stableStringify(planCore));
+  if (planHash && actual !== planHash) {
+    const restored = restoreTrustedPlanCosts(plan);
+    const { planId: ignoredPlanId, planHash: ignoredPlanHash, createdAt: ignoredCreatedAt, confirmationMessage: ignoredMessage, ...restoredCore } = restored;
+    actual = sha256(stableStringify(restoredCore));
+    if (actual === planHash) verified = restored;
+  }
+  if (!planHash || actual !== planHash) throw new Error('计划完整性校验失败,请重新规划。');
+  if (!planId || !Array.isArray(verified.items) || !verified.items.length || verified.items.length > 14) throw new Error('计划结构无效。');
+  return verified;
+}
+
+export { detectPreset, platformKey };

+ 113 - 0
fmode-image-set/mcp/src/features/planning/planner.test.mjs

@@ -0,0 +1,113 @@
+import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import test from 'node:test';
+import { GEMINI_IMAGE_MODEL, SEEDREAM_IMAGE_MODEL } from '../../providers/new-api-image.mjs';
+import { buildImageSetPlan, deriveProductName, sanitizeGeneratorPrompt, toPublicPlan, verifyPlan } from './planner.mjs';
+
+test('compact high-quality plan uses Gemini and CNY reserve', () => {
+  const plan = buildImageSetPlan({ request: '为这款护肤品规划一套图', productDescription: '磨砂玻璃精华瓶' });
+  assert.equal(plan.preset, 'compact');
+  assert.equal(plan.items.length, 7);
+  assert.equal(plan.estimatedCostCny, 5.236);
+  assert.equal(plan.costUnit, 'CNY');
+  assert.ok(plan.items.every(item => item.providerModel === GEMINI_IMAGE_MODEL));
+  assert.equal(plan.requiresConfirmation, true);
+  assert.equal(verifyPlan(plan), plan);
+});
+
+test('public plan omits user-visible cost data but preserves trusted plan verification', () => {
+  const plan = buildImageSetPlan({ request: '公开计划不显示费用' });
+  const publicPlan = toPublicPlan(plan);
+  assert.equal(verifyPlan(publicPlan).planHash, plan.planHash);
+  assert.doesNotMatch(JSON.stringify(publicPlan), /estimatedCostCny|costUnit|costBasis|¥|预计费用|预算/);
+});
+
+test('full preset preserves H1-H5 M1-M9 order and recalculated reserve', () => {
+  const plan = buildImageSetPlan({ request: '做完整十四图', preset: 'full' });
+  assert.deepEqual(plan.items.map(item => item.outputId), ['H1','H2','H3','H4','H5','M1','M2','M3','M4','M5','M6','M7','M8','M9']);
+  assert.equal(plan.estimatedCostCny, 10.472);
+});
+
+test('Seedream option uses verified fixed image price plus Gemini review reserve', () => {
+  const plan = buildImageSetPlan({ request: '做七张套图', modelPreference: 'seedream' });
+  assert.ok(plan.items.every(item => item.providerModel === SEEDREAM_IMAGE_MODEL));
+  assert.equal(plan.estimatedCostCny, 1.904);
+});
+
+test('generator prompt removes platform UI terms', () => {
+  assert.equal(/instagram|engagement metrics/i.test(sanitizeGeneratorPrompt('Instagram engagement metrics clean product')), false);
+});
+
+test('tampered plan is rejected', () => {
+  const plan = buildImageSetPlan({ request: '做七张套图' });
+  plan.items[0].prompt = 'tampered';
+  assert.throws(() => verifyPlan(plan), /完整性/);
+});
+
+test('product name prefers user claim then verified analysis fields', () => {
+  const analysis = {
+    visible_facts: [{ field: '商品名', value: '识图咖啡' }],
+    product_identity: { logo: 'MORI', category: '咖啡豆' }
+  };
+  assert.equal(deriveProductName({ productName: '用户命名咖啡' }, analysis), '用户命名咖啡');
+  assert.equal(deriveProductName({}, analysis), '识图咖啡');
+  assert.equal(deriveProductName({}, { product_identity: { logo: 'MORI', category: '咖啡豆' } }), 'MORI-咖啡豆');
+  assert.equal(buildImageSetPlan({ request: '做七图', productName: '用户商品' }).productName, '用户商品');
+});
+
+test('detail-page wide modules keep prompt and dimensions in sync', () => {
+  const plan = buildImageSetPlan({ request: '做 A+ 详情页', preset: 'detail-page' });
+  assert.equal(plan.items[0].width, 3024);
+  assert.match(plan.items[0].prompt, /Canvas 3024x1296/);
+  assert.match(plan.items.at(-1).prompt, /Canvas 3024x1296/);
+});
+
+test('all presets produce bounded executable plans', () => {
+  for (const preset of ['compact', 'full', 'main-image', 'detail-page', 'multi-angle', 'ad-creative', 'social']) {
+    const plan = buildImageSetPlan({ request: preset === 'social' ? '社媒轮播图' : `test ${preset}`, preset, platforms: ['Google Display', 'TikTok'] });
+    assert.ok(plan.items.length >= 1 && plan.items.length <= 14, preset);
+    assert.ok(plan.items.every(item => item.width > 0 && item.height > 0 && item.prompt.length > 80), preset);
+    verifyPlan(plan);
+  }
+});
+
+test('domestic commerce plans carry isolated platform rules', () => {
+  const taobao = buildImageSetPlan({ request: '淘宝商品主图', preset: 'main-image', platform: '淘宝' });
+  assert.equal(taobao.items[0].platformRule.platformKey, 'taobao');
+  assert.equal(taobao.items[0].platformRule.assetType, 'white_background');
+  assert.equal(taobao.items[0].platformRule.enforcement.ratio, 'required');
+  assert.match(taobao.items[0].prompt, /DOMESTIC MARKETPLACE TARGET/);
+  assert.equal(verifyPlan(taobao), taobao);
+
+  const jd = buildImageSetPlan({ request: '京东商品主图', preset: 'main-image', platform: 'JD' });
+  assert.equal(jd.items[0].platformRule.platformKey, 'jingdong');
+  assert.equal(jd.items[0].platformRule.verificationStatus, 'safe_fallback');
+
+  const pdd = buildImageSetPlan({ request: '拼多多商品主图', preset: 'main-image', platform: 'PDD' });
+  assert.equal(pdd.items[0].platformRule.platformKey, 'pinduoduo');
+  assert.equal(pdd.items[0].platformRule.complianceClaim, 'not_guaranteed');
+
+  const detail = buildImageSetPlan({ request: '京东商品详情页', preset: 'detail-page', platform: '京东' });
+  assert.deepEqual([detail.items[0].width, detail.items[0].height, detail.items[0].ratio], [3024, 1296, '21:9']);
+  assert.equal(detail.items[0].platformRule.target.ratio, null);
+  assert.match(detail.items[0].prompt, /Canvas 3024x1296/);
+});
+
+test('legacy 12-platform x 7-preset plans remain byte-for-byte compatible', () => {
+  const platforms = ['general_ecommerce', 'amazon', 'shopify', 'instagram_feed', 'instagram_story', 'tiktok', 'facebook_ad', 'google_display', 'linkedin_ad', 'pinterest', 'rednote', 'x'];
+  const presets = ['compact', 'full', 'main-image', 'detail-page', 'multi-angle', 'ad-creative', 'social'];
+  const snapshots = [];
+  for (const platform of platforms) for (const preset of presets) {
+    const plan = buildImageSetPlan({
+      request: `baseline ${preset}`,
+      preset,
+      platform,
+      platforms: [platform],
+      productName: 'baseline-product',
+      createdAt: '2026-08-11T00:00:00.000Z'
+    });
+    snapshots.push({ platform, preset, plan });
+  }
+  const hash = createHash('sha256').update(`${JSON.stringify(snapshots, null, 2)}\n`).digest('hex');
+  assert.equal(hash, 'c6c2c7e1a526703a392c2356ff86f0cd6e69fd0cb4e4c467451a2a7d16cdb368');
+});

+ 264 - 0
fmode-image-set/mcp/src/providers/new-api-image.mjs

@@ -0,0 +1,264 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { asBearerToken, readNewApiConfig } from '../core/credentials.mjs';
+import { assertSafeFile } from '../core/run-paths.mjs';
+import { ProviderError, mapHttpStatus } from './provider-error.mjs';
+
+export const GEMINI_IMAGE_MODEL = 'gemini-3.1-flash-image-preview';
+export const SEEDREAM_IMAGE_MODEL = 'doubao-seedream-4-0-250828';
+export const COST_UNIT = 'CNY';
+export const SUPPORTED_GENERATION_MODELS = Object.freeze([GEMINI_IMAGE_MODEL, SEEDREAM_IMAGE_MODEL]);
+
+const DEFAULT_SYSTEM = `You are a product-image evidence analyst. Return one JSON object only.
+Separate directly visible facts from visual inferences and unknowns.
+Never invent ingredients, certification, dimensions, performance, efficacy, price, or legal claims.
+Preserve exact visible text when legible and attach confidence values between 0 and 1.`;
+
+function cny(usd, rate) {
+  return Number((Number(usd) * Number(rate)).toFixed(4));
+}
+
+export function generationReserveCny(model, { qualityReview = true } = {}) {
+  const config = readNewApiConfig();
+  const generationUsd = model === SEEDREAM_IMAGE_MODEL ? config.seedreamImagePriceUsd : config.geminiGenerationReserveUsd;
+  const totalUsd = generationUsd + (qualityReview ? config.geminiVisionReserveUsd : 0);
+  return cny(totalUsd, config.usdToCnyRate);
+}
+
+export function analysisReserveCny() {
+  const config = readNewApiConfig();
+  return cny(config.geminiVisionReserveUsd, config.usdToCnyRate);
+}
+
+export function pricingSnapshot() {
+  const config = readNewApiConfig();
+  return Object.freeze({
+    usdToCnyRate: config.usdToCnyRate,
+    geminiGenerationReserveUsd: config.geminiGenerationReserveUsd,
+    geminiVisionReserveUsd: config.geminiVisionReserveUsd,
+    seedreamImagePriceUsd: config.seedreamImagePriceUsd,
+    geminiGenerationReserveCny: cny(config.geminiGenerationReserveUsd, config.usdToCnyRate),
+    geminiVisionReserveCny: cny(config.geminiVisionReserveUsd, config.usdToCnyRate),
+    seedreamImagePriceCny: cny(config.seedreamImagePriceUsd, config.usdToCnyRate)
+  });
+}
+
+export function geminiAnalysisPrompt(input = {}) {
+  const locale = input.locale === 'en' ? 'English' : 'Chinese';
+  const roles = Array.isArray(input.imageRoles) && input.imageRoles.length
+    ? `\nCaller-declared reference roles (authoritative): ${JSON.stringify(input.imageRoles)}`
+    : '';
+  return `${input.prompt || 'Analyze the supplied product images for image-set planning.'}${roles}
+
+Respond in ${locale} with this schema:
+{
+  "visible_facts": [{"field":"","value":"","source_image":1,"confidence":0.0}],
+  "visual_inferences": [{"field":"","value":"","confidence":0.0,"reason":""}],
+  "unknowns": [""],
+  "product_identity": {"product_name":"","category":"","shape":"","packaging":"","logo":"","visible_text":[],"materials":[],"colors":[],"distinctive_features":[]},
+  "brand_style": {"palette":[],"lighting":"","mood":"","composition":""},
+  "quality_risks": [""],
+  "recommended_reference_roles": [{"image":1,"role":"identity|style|detail|model"}]
+}`;
+}
+
+export function geminiQualityPrompt(input = {}) {
+  const platformSection = input.platformRule?.domestic
+    ? `\nDomestic marketplace design target: ${JSON.stringify({
+      platform: input.platformRule.platformLabel,
+      assetType: input.platformRule.assetType,
+      uploadRole: input.platformRule.uploadRole,
+      target: input.platformRule.target,
+      verificationStatus: input.platformRule.verificationStatus
+    })}\nAlso report a platform_constraints boolean in checks. Treat an explicit required constraint as a rejection criterion; treat recommended or unverified constraints as recommendations, not guaranteed platform compliance.`
+    : '';
+  return `Review one generated product image against the preceding original reference images.
+Original reference image count: ${Number(input.originalCount || 0)}. The final image is the generated candidate.
+Expected product identity: ${JSON.stringify(input.expectedIdentity || {})}${platformSection}
+Return one JSON object only:
+{"passed":true,"score":0.0,"checks":{"identity":true,"color":true,"logo_text":true,"composition":true,"defects":true},"violations":[],"recommendations":[]}
+Set passed=false for product identity drift, material/color drift, malformed logo/text, severe composition failure, or obvious generation defects.`;
+}
+
+function extractText(json) {
+  const content = json?.choices?.[0]?.message?.content ?? json?.choices?.[0]?.text ?? json?.output_text ?? '';
+  if (typeof content === 'string') return content;
+  if (Array.isArray(content)) return content.map(part => typeof part === 'string' ? part : part?.text || '').join('');
+  return '';
+}
+
+export function parseJsonText(text) {
+  const cleaned = String(text || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
+  const first = cleaned.indexOf('{');
+  const last = cleaned.lastIndexOf('}');
+  if (first < 0 || last < first) throw new ProviderError('error', 'Gemini 未返回可解析的 JSON。');
+  try { return JSON.parse(cleaned.slice(first, last + 1)); }
+  catch { throw new ProviderError('error', 'Gemini 返回的结构化结果格式无效。'); }
+}
+
+function ensureEndpoint(config) {
+  if (!config.baseUrl) throw new ProviderError('needs_input', '未配置 FMODE_LLM_BASE_URL 或 LLM_BASE_URL。');
+  let url;
+  try { url = new URL(config.baseUrl); }
+  catch { throw new ProviderError('needs_input', 'New API Base URL 格式无效。'); }
+  if (!['http:', 'https:'].includes(url.protocol)) throw new ProviderError('blocked', 'New API endpoint 仅允许 HTTP(S)。');
+  const local = ['localhost', '127.0.0.1', '::1'].includes(url.hostname);
+  if (url.protocol === 'http:' && !local && !config.allowInsecureHttp) {
+    throw new ProviderError('blocked', '远程 HTTP 会明文传输 API Key;确认信任该内网网关后设置 FMODE_ALLOW_INSECURE_HTTP=true。');
+  }
+  return url.href.replace(/\/+$/, '');
+}
+
+function imageContent(images) {
+  return (images || []).map(image => ({ type: 'image_url', image_url: { url: image?.url || image } }));
+}
+
+export function extractGeminiImages(json) {
+  const text = extractText(json);
+  const found = [];
+  for (const match of text.matchAll(/data:image\/[A-Za-z0-9.+-]+;base64,[A-Za-z0-9+/=\r\n]+/g)) found.push(match[0].replace(/\s/g, ''));
+  for (const match of text.matchAll(/!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/g)) found.push(match[1]);
+  const content = json?.choices?.[0]?.message?.content;
+  if (Array.isArray(content)) {
+    for (const part of content) {
+      const value = part?.image_url?.url || part?.image_url || part?.url || part?.data;
+      if (typeof value === 'string' && (/^data:image\//i.test(value) || /^https?:\/\//i.test(value))) found.push(value);
+    }
+  }
+  return [...new Set(found)];
+}
+
+function seedreamImages(json) {
+  return (json?.data || []).map(item => item?.b64_json
+    ? `data:${item.mime_type || 'image/jpeg'};base64,${item.b64_json}`
+    : item?.url).filter(Boolean);
+}
+
+function dataImage(value) {
+  const match = String(value || '').match(/^data:(image\/[A-Za-z0-9.+-]+);base64,([A-Za-z0-9+/=\r\n]+)$/s);
+  if (!match) return null;
+  return { mime: match[1].toLowerCase(), bytes: Buffer.from(match[2].replace(/\s/g, ''), 'base64') };
+}
+
+function extensionFor(mime = '') {
+  if (/jpe?g/i.test(mime)) return '.jpg';
+  if (/webp/i.test(mime)) return '.webp';
+  return '.png';
+}
+
+export class NewApiImageProvider {
+  constructor(options = {}) {
+    this.config = readNewApiConfig(options);
+    this.fetch = options.fetchImpl || globalThis.fetch;
+  }
+
+  async request(endpoint, body, { submission = false, idempotencyKey = '' } = {}) {
+    if (!this.config.apiKey) throw new ProviderError('needs_token', '未配置 FMODE_LLM_API_KEY 或 LLM_API_KEY。');
+    const baseUrl = ensureEndpoint(this.config);
+    const controller = new AbortController();
+    const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
+    let response;
+    try {
+      response = await this.fetch(`${baseUrl}${endpoint}`, {
+        method: 'POST',
+        headers: {
+          Accept: 'application/json', 'Content-Type': 'application/json', Authorization: asBearerToken(this.config.apiKey),
+          ...(idempotencyKey ? { 'Idempotency-Key': String(idempotencyKey) } : {})
+        },
+        signal: controller.signal,
+        body: JSON.stringify(body)
+      });
+    } catch (error) {
+      const timeout = error?.name === 'AbortError';
+      throw new ProviderError(submission ? 'submission_unknown' : 'provider_unavailable', submission
+        ? `生图${timeout ? '超时' : '连接中断'},请求是否已被上游接收未知。`
+        : `Gemini 请求${timeout ? '超时' : '暂时无法连接'}。`);
+    } finally { clearTimeout(timer); }
+    if (!response.ok) {
+      if (submission && (response.status === 408 || response.status === 429 || response.status >= 500)) {
+        throw new ProviderError('submission_unknown', '生图网关返回暂时性异常,请求是否已被上游接收未知。', { httpStatus: response.status });
+      }
+      throw new ProviderError(mapHttpStatus(response.status), 'New API 返回异常;鉴权和上游详情已隐藏。', { httpStatus: response.status });
+    }
+    try { return await response.json(); }
+    catch {
+      throw new ProviderError(submission ? 'submission_unknown' : 'error', submission
+        ? '生图响应无法解析,请求状态未知。'
+        : 'Gemini 响应无法解析。');
+    }
+  }
+
+  async analyze(images, input = {}) {
+    if (!images?.length) throw new ProviderError('needs_input', '没有可分析的商品图片。');
+    const json = await this.request('/v1/chat/completions', {
+      model: GEMINI_IMAGE_MODEL, temperature: 0.1, max_tokens: Number(input.maxTokens || 2600), modalities: ['text'],
+      messages: [
+        { role: 'system', content: DEFAULT_SYSTEM },
+        { role: 'user', content: [...imageContent(images), { type: 'text', text: geminiAnalysisPrompt(input) }] }
+      ]
+    });
+    return { analysis: parseJsonText(extractText(json)), model: GEMINI_IMAGE_MODEL, usage: json.usage || {}, finishReason: json?.choices?.[0]?.finish_reason || '' };
+  }
+
+  async review(originalImages, generatedImages, input = {}) {
+    if (!generatedImages?.length) throw new ProviderError('needs_input', '没有待复核的生成图片。');
+    const images = [...(originalImages || []), ...generatedImages];
+    const json = await this.request('/v1/chat/completions', {
+      model: GEMINI_IMAGE_MODEL, temperature: 0, max_tokens: Number(input.maxTokens || 1200), modalities: ['text'],
+      messages: [
+        { role: 'system', content: DEFAULT_SYSTEM },
+        { role: 'user', content: [...imageContent(images), { type: 'text', text: geminiQualityPrompt({ ...input, originalCount: originalImages?.length || 0 }) }] }
+      ]
+    });
+    return { review: parseJsonText(extractText(json)), model: GEMINI_IMAGE_MODEL, usage: json.usage || {} };
+  }
+
+  async generate(item) {
+    const model = SUPPORTED_GENERATION_MODELS.includes(item.providerModel) ? item.providerModel : GEMINI_IMAGE_MODEL;
+    const references = (item.referenceImages || []).map(value => value?.url || value).filter(Boolean);
+    let json;
+    let images;
+    if (model === SEEDREAM_IMAGE_MODEL) {
+      json = await this.request('/v1/images/generations', {
+        model, prompt: item.prompt, ...(references.length ? { image: references } : {}), size: '2K',
+        sequential_image_generation: 'disabled', stream: false, response_format: 'b64_json', watermark: false
+      }, { submission: true, idempotencyKey: item.idempotencyKey });
+      images = seedreamImages(json);
+    } else {
+      json = await this.request('/v1/chat/completions', {
+        model, modalities: ['text', 'image'],
+        messages: [{ role: 'user', content: [...imageContent(references), { type: 'text', text: item.prompt }] }]
+      }, { submission: true, idempotencyKey: item.idempotencyKey });
+      images = extractGeminiImages(json);
+    }
+    if (!images.length) throw new ProviderError('submission_unknown', '生图响应中没有可接收的图片,请求状态未知。');
+    return {
+      requestId: String(json?.id || json?.request_id || ''), model, images, usage: json?.usage || {},
+      estimatedCostCny: generationReserveCny(model, { qualityReview: false }), costUnit: COST_UNIT
+    };
+  }
+
+  async materialize(value, runDirectory, outputId, index = 0) {
+    let mime = 'image/png';
+    let bytes;
+    const inline = dataImage(value);
+    if (inline) ({ mime, bytes } = inline);
+    else if (/^https?:\/\//i.test(String(value || ''))) {
+      let response;
+      try {
+        response = await this.fetch(value);
+        if (!response.ok) throw new ProviderError('provider_unavailable', '生成图片下载失败。', { httpStatus: response.status });
+        mime = response.headers?.get?.('content-type') || mime;
+        bytes = Buffer.from(await response.arrayBuffer());
+        if (!bytes.length) throw new ProviderError('provider_unavailable', '生成图片下载结果为空。');
+      } catch (error) {
+        if (error instanceof ProviderError) throw error;
+        throw new ProviderError('provider_unavailable', '生成图片下载失败。');
+      }
+    } else throw new ProviderError('error', '生图返回了不支持的图片格式。');
+    if (!bytes?.length || bytes.length > 40 * 1024 * 1024) throw new ProviderError('error', '生图文件为空或超过 40MB 上限。');
+    const filePath = assertSafeFile(runDirectory, path.join(runDirectory, `${outputId}-${index + 1}${extensionFor(mime)}`));
+    fs.writeFileSync(filePath, bytes, { flag: 'wx' });
+    return filePath;
+  }
+}

+ 16 - 0
fmode-image-set/mcp/src/providers/provider-error.mjs

@@ -0,0 +1,16 @@
+export class ProviderError extends Error {
+  constructor(kind, message, options = {}) {
+    super(message, options);
+    this.name = 'ProviderError';
+    this.kind = kind;
+    this.recoverable = options.recoverable ?? true;
+    this.httpStatus = options.httpStatus || 0;
+  }
+}
+
+export function mapHttpStatus(status) {
+  if (status === 401) return 'needs_valid_token';
+  if (status === 402 || status === 403) return 'needs_recharge';
+  if (status === 408 || status === 429 || status >= 500) return 'provider_unavailable';
+  return 'error';
+}

+ 195 - 0
fmode-image-set/mcp/src/providers/providers.test.mjs

@@ -0,0 +1,195 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+import {
+  analysisReserveCny, GEMINI_IMAGE_MODEL, geminiQualityPrompt, generationReserveCny, NewApiImageProvider,
+  pricingSnapshot, SEEDREAM_IMAGE_MODEL
+} from './new-api-image.mjs';
+import { ProviderError } from './provider-error.mjs';
+
+const structuredAnalysis = {
+  visible_facts: [], visual_inferences: [], unknowns: [], product_identity: {}, brand_style: {}, quality_risks: [], recommended_reference_roles: []
+};
+
+test('domestic quality prompt is additive and legacy prompt remains unchanged', () => {
+  const legacy = geminiQualityPrompt({ expectedIdentity: { category: 'bottle' }, originalCount: 1 });
+  assert.doesNotMatch(legacy, /Domestic marketplace/);
+  const domestic = geminiQualityPrompt({
+    expectedIdentity: { category: 'bottle' }, originalCount: 1,
+    platformRule: {
+      domestic: true, platformLabel: '淘宝/天猫', assetType: 'white_background', uploadRole: '白底商品图候选',
+      target: { ratio: '1:1' }, verificationStatus: 'official_verified_partial'
+    }
+  });
+  assert.match(domestic, /Domestic marketplace design target/);
+  assert.match(domestic, /platform_constraints/);
+});
+
+test('pricing converts trusted USD prices to CNY at gateway rate 6.8', () => {
+  const pricing = pricingSnapshot();
+  assert.equal(pricing.usdToCnyRate, 6.8);
+  assert.equal(pricing.seedreamImagePriceCny, 0.204);
+  assert.equal(pricing.geminiGenerationReserveCny, 0.68);
+  assert.equal(analysisReserveCny(), 0.068);
+  assert.equal(generationReserveCny(GEMINI_IMAGE_MODEL), 0.748);
+  assert.equal(generationReserveCny(SEEDREAM_IMAGE_MODEL), 0.272);
+});
+
+test('Gemini provider parses structured analysis and sends image content in text-only modality', async () => {
+  let request; let requestUrl;
+  const fetchImpl = async (url, init) => {
+    requestUrl = String(url);
+    request = JSON.parse(init.body);
+    return new Response(JSON.stringify({
+      choices: [{ message: { content: JSON.stringify(structuredAnalysis) }, finish_reason: 'stop' }], usage: { total_tokens: 1 }
+    }), { status: 200, headers: { 'content-type': 'application/json' } });
+  };
+  const provider = new NewApiImageProvider({ apiKey: 'fake-test-key', trustedBaseUrl: 'https://gateway.test', fetchImpl });
+  const result = await provider.analyze([{ url: 'data:image/png;base64,AAAA' }]);
+  assert.equal(result.analysis.visible_facts.length, 0);
+  assert.equal(result.model, GEMINI_IMAGE_MODEL);
+  assert.equal(request.messages[1].content[0].type, 'image_url');
+  assert.deepEqual(request.modalities, ['text']);
+  assert.equal(requestUrl, 'https://gateway.test/v1/chat/completions');
+});
+
+test('Gemini provider returns structured generation quality review', async () => {
+  const fetchImpl = async () => new Response(JSON.stringify({
+    choices: [{ message: { content: JSON.stringify({ passed: false, score: 0.4, checks: { identity: false }, violations: ['identity drift'], recommendations: [] }) } }]
+  }), { status: 200, headers: { 'content-type': 'application/json' } });
+  const provider = new NewApiImageProvider({ apiKey: 'fake-test-key', trustedBaseUrl: 'https://gateway.test', fetchImpl });
+  const result = await provider.review([{ url: 'data:image/png;base64,AAAA' }], [{ url: 'data:image/png;base64,BBBB' }]);
+  assert.equal(result.review.passed, false);
+  assert.equal(result.model, GEMINI_IMAGE_MODEL);
+});
+
+test('Gemini generation extracts markdown data URL', async () => {
+  let request; let requestHeaders;
+  const fetchImpl = async (_url, init) => {
+    request = JSON.parse(init.body);
+    requestHeaders = init.headers;
+    return new Response(JSON.stringify({ id: 'gem-1', choices: [{ message: { content: '![image](data:image/png;base64,ZmFrZQ==)' } }] }), { status: 200 });
+  };
+  const provider = new NewApiImageProvider({ apiKey: 'fake-test-key', trustedBaseUrl: 'https://gateway.test', fetchImpl });
+  const result = await provider.generate({ providerModel: GEMINI_IMAGE_MODEL, prompt: 'generate', referenceImages: ['data:image/png;base64,AAAA'], idempotencyKey: 'stable-test-key' });
+  assert.equal(result.images[0], 'data:image/png;base64,ZmFrZQ==');
+  assert.deepEqual(request.modalities, ['text', 'image']);
+  assert.equal(requestHeaders['Idempotency-Key'], 'stable-test-key');
+});
+
+test('Seedream generation uses images endpoint and b64_json', async () => {
+  let requestUrl; let request;
+  const fetchImpl = async (url, init) => {
+    requestUrl = String(url); request = JSON.parse(init.body);
+    return new Response(JSON.stringify({ data: [{ b64_json: 'ZmFrZQ==' }] }), { status: 200 });
+  };
+  const provider = new NewApiImageProvider({ apiKey: 'fake-test-key', trustedBaseUrl: 'https://gateway.test', fetchImpl });
+  const result = await provider.generate({ providerModel: SEEDREAM_IMAGE_MODEL, prompt: 'generate', referenceImages: ['data:image/png;base64,AAAA'] });
+  assert.equal(requestUrl, 'https://gateway.test/v1/images/generations');
+  assert.equal(request.image[0], 'data:image/png;base64,AAAA');
+  assert.equal(request.size, '2K');
+  assert.equal(result.images[0], 'data:image/jpeg;base64,ZmFrZQ==');
+});
+
+test('generation network ambiguity maps to submission_unknown', async () => {
+  const provider = new NewApiImageProvider({ apiKey: 'fake-test-key', trustedBaseUrl: 'https://gateway.test', fetchImpl: async () => { throw new Error('network'); } });
+  await assert.rejects(() => provider.generate({ providerModel: GEMINI_IMAGE_MODEL, prompt: 'test' }), error => error instanceof ProviderError && error.kind === 'submission_unknown');
+});
+
+test('paid generation HTTP timeout and server errors map to submission_unknown', async () => {
+  for (const status of [408, 429, 500, 503]) {
+    const provider = new NewApiImageProvider({
+      apiKey: 'fake-test-key', trustedBaseUrl: 'https://gateway.test',
+      fetchImpl: async () => new Response('{}', { status })
+    });
+    await assert.rejects(
+      () => provider.generate({ providerModel: GEMINI_IMAGE_MODEL, prompt: 'test' }),
+      error => error instanceof ProviderError && error.kind === 'submission_unknown' && error.httpStatus === status
+    );
+  }
+});
+
+test('definitive paid generation HTTP rejection is not treated as ambiguous', async () => {
+  const provider = new NewApiImageProvider({
+    apiKey: 'fake-test-key', trustedBaseUrl: 'https://gateway.test',
+    fetchImpl: async () => new Response('{}', { status: 400 })
+  });
+  await assert.rejects(
+    () => provider.generate({ providerModel: GEMINI_IMAGE_MODEL, prompt: 'test' }),
+    error => error instanceof ProviderError && error.kind === 'error' && error.httpStatus === 400
+  );
+});
+
+test('paid generation malformed JSON or successful response without image is ambiguous', async () => {
+  const malformed = new NewApiImageProvider({
+    apiKey: 'fake-test-key', trustedBaseUrl: 'https://gateway.test',
+    fetchImpl: async () => new Response('not-json', { status: 200 })
+  });
+  await assert.rejects(() => malformed.generate({ providerModel: GEMINI_IMAGE_MODEL, prompt: 'test' }), error => error instanceof ProviderError && error.kind === 'submission_unknown');
+  const empty = new NewApiImageProvider({
+    apiKey: 'fake-test-key', trustedBaseUrl: 'https://gateway.test',
+    fetchImpl: async () => new Response(JSON.stringify({ choices: [{ message: { content: 'done' } }] }), { status: 200 })
+  });
+  await assert.rejects(() => empty.generate({ providerModel: GEMINI_IMAGE_MODEL, prompt: 'test' }), error => error instanceof ProviderError && error.kind === 'submission_unknown');
+});
+
+test('provider ignores caller endpoint field and rejects unapproved remote HTTP', async () => {
+  const previous = process.env.FMODE_ALLOW_INSECURE_HTTP;
+  process.env.FMODE_ALLOW_INSECURE_HTTP = 'false';
+  let requestUrl = '';
+  const fetchImpl = async url => {
+    requestUrl = String(url);
+    return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(structuredAnalysis) } }] }), { status: 200 });
+  };
+  try {
+    const safe = new NewApiImageProvider({ apiKey: 'fake-test-key', trustedBaseUrl: 'https://safe.test', baseUrl: 'https://attacker.invalid', fetchImpl });
+    await safe.analyze([{ url: 'data:image/png;base64,AAAA' }]);
+    assert.equal(requestUrl.startsWith('https://safe.test/'), true);
+    const insecure = new NewApiImageProvider({ apiKey: 'fake-test-key', trustedBaseUrl: 'http://gateway.test', fetchImpl });
+    await assert.rejects(() => insecure.analyze([{ url: 'data:image/png;base64,AAAA' }]), error => error instanceof ProviderError && error.kind === 'blocked');
+  } finally {
+    if (previous === undefined) delete process.env.FMODE_ALLOW_INSECURE_HTTP;
+    else process.env.FMODE_ALLOW_INSECURE_HTTP = previous;
+  }
+});
+
+test('trusted remote HTTP works only with explicit opt-in', async () => {
+  let requestUrl = '';
+  const provider = new NewApiImageProvider({
+    apiKey: 'fake-test-key', trustedBaseUrl: 'http://gateway.test', allowInsecureHttp: true,
+    fetchImpl: async url => {
+      requestUrl = String(url);
+      return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(structuredAnalysis) } }] }), { status: 200 });
+    }
+  });
+  await provider.analyze([{ url: 'data:image/png;base64,AAAA' }]);
+  assert.equal(requestUrl, 'http://gateway.test/v1/chat/completions');
+});
+
+test('materialize decodes inline image without remote URL', async () => {
+  const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'fmode-materialize-'));
+  try {
+    const provider = new NewApiImageProvider({ apiKey: 'fake', trustedBaseUrl: 'https://gateway.test' });
+    const file = await provider.materialize('data:image/png;base64,ZmFrZQ==', directory, 'S1');
+    assert.equal(fs.readFileSync(file, 'utf8'), 'fake');
+  } finally { fs.rmSync(directory, { recursive: true, force: true }); }
+});
+
+test('remote response body interruption maps to retryable provider_unavailable', async () => {
+  const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'fmode-materialize-body-'));
+  try {
+    const provider = new NewApiImageProvider({
+      apiKey: 'fake', trustedBaseUrl: 'https://gateway.test',
+      fetchImpl: async () => ({
+        ok: true, status: 200, headers: { get: () => 'image/png' },
+        arrayBuffer: async () => { throw new TypeError('body terminated'); }
+      })
+    });
+    await assert.rejects(
+      () => provider.materialize('https://result.test/image.png', directory, 'S1'),
+      error => error instanceof ProviderError && error.kind === 'provider_unavailable'
+    );
+  } finally { fs.rmSync(directory, { recursive: true, force: true }); }
+});

+ 106 - 0
fmode-image-set/mcp/src/server.mjs

@@ -0,0 +1,106 @@
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
+import { pathToFileURL } from 'node:url';
+import { z } from 'zod';
+import { asToolResult } from './core/result-envelope.mjs';
+import { runAnalyze } from './tools/analyze.mjs';
+import { runEdit } from './tools/edit.mjs';
+import { runGenerate } from './tools/generate.mjs';
+import { runPlan } from './tools/plan.mjs';
+import { runStatus } from './tools/status.mjs';
+import { runTemplates } from './tools/templates.mjs';
+import { runUpscale } from './tools/upscale.mjs';
+
+export const VERSION = '0.6.0';
+export const TOOL_NAMES = [
+  'fmode_image_set_analyze', 'fmode_image_set_plan', 'fmode_image_set_templates',
+  'fmode_image_set_generate', 'fmode_image_set_status', 'fmode_image_set_edit', 'fmode_image_set_upscale'
+];
+
+const outputSchema = {
+  status: z.string(), assistantMessage: z.string(), summary: z.record(z.string(), z.unknown()),
+  data: z.record(z.string(), z.unknown()), files: z.array(z.string()), nextActions: z.array(z.string()),
+  warnings: z.array(z.unknown()), errors: z.array(z.unknown())
+};
+const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
+const externalRead = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
+const paidWrite = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true };
+const paidGenerate = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true };
+
+export function createServer() {
+  const server = new McpServer({ name: 'fmode-image-set', version: VERSION });
+
+  server.registerTool('fmode_image_set_analyze', {
+    description: 'Analyze explicitly supplied current-project product image paths or current Claude attachments with Gemini. Separates visible facts, visual inferences, and unknowns.',
+    inputSchema: {
+      imagePath: z.string().optional(), imageUrl: z.string().optional(), images: z.array(z.unknown()).optional(),
+      imageRoles: z.record(z.string(), z.string()).optional(),
+      allImagesSameProduct: z.boolean().optional().describe('Set true only when every supplied image is a view of the same product; all images become identity references.'),
+      allowClaudeAttachments: z.boolean().optional(), sessionId: z.string().optional(), promptId: z.string().optional(),
+      locale: z.enum(['zh', 'en']).optional(),
+      prompt: z.string().optional()
+    }, outputSchema, annotations: externalRead
+  }, async input => asToolResult(await runAnalyze(input)));
+
+  server.registerTool('fmode_image_set_plan', {
+    description: 'Create a product image-set brief with a user/analysis-derived productName. This tool does not generate images.',
+    inputSchema: {
+      request: z.string(), preset: z.enum(['compact', 'full', 'main-image', 'detail-page', 'multi-angle', 'ad-creative', 'social']).optional(),
+      locale: z.enum(['zh', 'en']).optional(), analysis: z.unknown().optional(), productDescription: z.string().optional(),
+      productName: z.string().max(120).optional(),
+      category: z.string().optional(), platform: z.string().optional(), platforms: z.array(z.string()).optional(),
+      style: z.string().optional(), brandColors: z.array(z.string()).optional(), requestedCopy: z.string().optional(), count: z.number().int().min(1).max(14).optional(),
+      modelPreference: z.enum(['high_quality', 'gemini', 'seedream']).optional(), qualityReview: z.boolean().optional(),
+      sessionId: z.string().optional().describe('Conversation identifier used to bind a restart-safe planToken.'),
+      promptId: z.string().optional().describe('Planning prompt identifier recorded inside the planToken for audit correlation.')
+    }, outputSchema, annotations: readOnly
+  }, async input => asToolResult(await runPlan(input)));
+
+  server.registerTool('fmode_image_set_templates', {
+    description: 'Search the local 25-scene template catalog and platform constraints without generating images.',
+    inputSchema: { query: z.string().optional(), locale: z.enum(['zh', 'en']).optional(), includePlatforms: z.boolean().optional() },
+    outputSchema, annotations: readOnly
+  }, async input => asToolResult(await runTemplates(input)));
+
+  server.registerTool('fmode_image_set_generate', {
+    description: 'Execute a confirmed image-set plan, or retry only selected failed outputIds in an existing run. Transient single-image failures are retried automatically and do not block later outputs. On full success, root-level local references are moved and nested references are copied into the Chinese run directory.',
+    inputSchema: {
+      plan: z.record(z.string(), z.unknown()).optional(), planRef: z.string().min(16).optional(),
+      planToken: z.string().min(32).max(100000).optional(), confirmedPlanId: z.string().min(8).max(120).optional(), runId: z.string().optional(),
+      retryOutputIds: z.array(z.string()).optional(), confirmed: z.boolean(), budgetLimitCny: z.number().nonnegative().optional(),
+      sessionId: z.string(), promptId: z.string(), referenceImages: z.array(z.unknown()).optional(),
+      qualityReview: z.boolean().optional(), allowClaudeAttachments: z.boolean().optional()
+    }, outputSchema, annotations: paidGenerate
+  }, async input => asToolResult(await runGenerate(input)));
+
+  server.registerTool('fmode_image_set_status', {
+    description: 'Read an existing run and optionally resume downloading a persisted remote result URL, without creating or polling a paid generation task.',
+    inputSchema: { runId: z.string(), refresh: z.boolean().optional() },
+    outputSchema, annotations: externalRead
+  }, async input => asToolResult(await runStatus(input)));
+
+  server.registerTool('fmode_image_set_edit', {
+    description: 'Edit a confirmed product image while preserving identity. A quality-rejected image from a managed image-set is revised in place with versioned history; standalone images keep an independent run.',
+    inputSchema: {
+      image: z.string(), maskUrl: z.string().optional(), prompt: z.string(), modelPreference: z.enum(['high_quality', 'gemini', 'seedream']).optional(),
+      width: z.number().int().optional(), height: z.number().int().optional(), scale: z.number().optional(),
+      confirmed: z.boolean(), budgetLimitCny: z.number().nonnegative().optional(), sessionId: z.string(), promptId: z.string(),
+      qualityReview: z.boolean().optional()
+    }, outputSchema, annotations: paidGenerate
+  }, async input => asToolResult(await runEdit(input)));
+
+  server.registerTool('fmode_image_set_upscale', {
+    description: 'Regenerate one confirmed local or remote image as a high-quality 2K Seedream output.',
+    inputSchema: {
+      image: z.string(), scale: z.number().min(0).max(100).optional(),
+      confirmed: z.boolean(), budgetLimitCny: z.number().nonnegative().optional(), sessionId: z.string(), promptId: z.string(),
+      qualityReview: z.boolean().optional()
+    }, outputSchema, annotations: paidWrite
+  }, async input => asToolResult(await runUpscale(input)));
+  return server;
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+  const server = createServer();
+  await server.connect(new StdioServerTransport());
+}

+ 5 - 0
fmode-image-set/mcp/src/tools/analyze.mjs

@@ -0,0 +1,5 @@
+import { analyzeProductImages } from '../features/image-analysis/analyze.mjs';
+
+export async function runAnalyze(input = {}, dependencies = {}) {
+  return analyzeProductImages(input, dependencies);
+}

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff