BlueSkyXN commited on
Commit
ca9a89c
·
verified ·
1 Parent(s): 99adaa0

Unify image references and guided UI/API workflows

Browse files

Sync GitHub commit 07374badda564e1a143363df710bb35476fcf6e3; publish only the image input and API workflow changes.

README.md CHANGED
@@ -200,11 +200,11 @@ Fluxus 与 ImageGen 共享同一 Git 历史,Fluxus 当前版本只调整了品
200
 
201
  ## 支持能力
202
 
203
- - 任务:文生图、图生图、局部重绘、扩图、高清修复。
204
  - 模型:由 `yaml/model_list.yaml` 驱动,当前包含 30 类架构、92 个显示模型。
205
  - 扩展:LoRA、ControlNet、IP-Adapter、Embedding、区域提示、多图编辑、VAE、PiD 等。
206
  - 输出:PNG 内写入生成参数和完整 ComfyUI 工作流元数据。
207
- - 中文 Prompt:默认原样传递,不做隐式翻译;模型说明会区分中文自然语言和英文标签型模型。
208
 
209
  ## 批量、多图与模型 PK
210
 
@@ -219,7 +219,11 @@ Fluxus 与 ImageGen 共享同一 Git 历史,Fluxus 当前版本只调整了品
219
  | 多图融合 | 多张图共同作为一组参考 | 由兼容的编辑模型融合为 1–4 张结果 |
220
 
221
  - 多图独立目前支持图生图、扩图和高清修复;局部重绘需要逐张图片配对蒙版,暂不做批量。
222
- - 多图融合内部固定走“文生图 + 模型专属参考链”,不会与图生图 base latent 混用。Prompt 可按“参考图 1 / 2 / 3”说明各图角色。
 
 
 
 
223
  - 多图融合是生成式参考,不保证无损拼接、角色逐像素保留或确定性元素替换。
224
  - PK 默认采用各模型推荐采样参数;关闭后才严格复用当前步数、CFG、采样器和调度器。同一 Seed 在不同架构之间只是尽量控制变量,不表示初始噪声数学等价。
225
  - PK V1 会关闭 LoRA、ControlNet、IP-Adapter、参考链、自定义 VAE 和 PiD,只比较所有基础 checkpoint 都具备的 Prompt / 源图能力,避免某个模型偷偷多一层条件。
@@ -314,8 +318,69 @@ COMFYUI_PATH=/absolute/path/to/ComfyUI python app.py
314
 
315
  `get_feature_list()` 空参默认返回 Fluxus 兼容的完整 schema;如需节省 MCP token,传 `compact=true` 获取摘要。也可以传 `feature_name`,或使用 `get_chain_schema(chain_type)` 查询单项完整 schema。
316
 
 
 
317
  所有 UI 原子事件均隐藏,不作为公共 API 暴露。
318
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  ## 验证
320
 
321
  ```bash
@@ -323,7 +388,7 @@ python -m compileall -q .
323
  python -m unittest discover -s tests -v
324
  ```
325
 
326
- 界面烟测会以 stub ComfyUI 构建完整 Gradio 配置,因此不需要下载模型或占用 GPU。
327
 
328
  ## 目录
329
 
 
200
 
201
  ## 支持能力
202
 
203
+ - 任务:文生图、参考图编辑 / 融合、图生图(整体重绘)、局部重绘、扩图、高清修复。
204
  - 模型:由 `yaml/model_list.yaml` 驱动,当前包含 30 类架构、92 个显示模型。
205
  - 扩展:LoRA、ControlNet、IP-Adapter、Embedding、区域提示、多图编辑、VAE、PiD 等。
206
  - 输出:PNG 内写入生成参数和完整 ComfyUI 工作流元数据。
207
+ - 中文 Prompt:不做隐式翻译或扩写;只统一显式图片编号,模型说明会区分中文自然语言和英文标签型模型。
208
 
209
  ## 批量、多图与模型 PK
210
 
 
219
  | 多图融合 | 多张图共同作为一组参考 | 由兼容的编辑模型融合为 1–4 张结果 |
220
 
221
  - 多图独立目前支持图生图、扩图和高清修复;局部重绘需要逐张图片配对蒙版,暂不做批量。
222
+ - 按指令改图请选择“参考图编辑 / 融合”:一张图编辑,多张图整组参考。内部走 `txt2img + 模型专属参考链`,不与整体重绘的源图 latent 混用;兼容旧的“文生图 + 多图融合”选择。
223
+ - 图片列表按显示顺序从 1 编号,可拖动排序;有效图片预览中的 `图1 = img1 = image1` 指向同一张图,送入模型时统一为 `image 1`。空槽位不编号;重排/删除后编号随预览更新。编号只是输入对应关系,不保证每种模型都能精确执行空间或身份指令。
224
+ - 提示词中的 `图1`、`图 1`、`参考图1`、`图片1`、`img1`、`IMG1`、`image 1` 均可使用;不存在的编号会在模型下载 / GPU 生成前报错。双引号、中文引号或反引号内的编号视为需要显示的文字,不解析;文件名和标识符不替换。
225
+ - 高层图片列表是当前任务的唯一图片来源,不合并下方旧参考槽位。切换任务/模型会保留隐藏素材供切回,但隐藏或不兼容的原生参考图不参加生成。普通文生图仍兼容下方原生参考槽位;多个原生参考链同时使用时不能用统一编号引用,推荐高层入口。
226
+ - 多图独立 / 图片×模型共用一条 Prompt,请写“当前图片”,不能用 `img2` 跨图引用;需要两图同时参与时改用参考图编辑 / 融合。超出张数限制明确报错,不静默丢图。
227
  - 多图融合是生成式参考,不保证无损拼接、角色逐像素保留或确定性元素替换。
228
  - PK 默认采用各模型推荐采样参数;关闭后才严格复用当前步数、CFG、采样器和调度器。同一 Seed 在不同架构之间只是尽量控制变量,不表示初始噪声数学等价。
229
  - PK V1 会关闭 LoRA、ControlNet、IP-Adapter、参考链、自定义 VAE 和 PiD,只比较所有基础 checkpoint 都具备的 Prompt / 源图能力,避免某个模型偷偷多一层条件。
 
318
 
319
  `get_feature_list()` 空参默认返回 Fluxus 兼容的完整 schema;如需节省 MCP token,传 `compact=true` 获取摘要。也可以传 `feature_name`,或使用 `get_chain_schema(chain_type)` 查询单项完整 schema。
320
 
321
+ `run` 会在排队前校验基础参数、chain 结构和模型扩展能力;请求未知或明显不兼容的 chain 会明确失败。数值字符串会先归一化,不要求调用方严格区分 JSON 数字与表单字符串。
322
+
323
  所有 UI 原子事件均隐藏,不作为公共 API 暴露。
324
 
325
+ ### AI 调用图片编辑与批量任务
326
+
327
+ 推荐流程:`get_task_list()` → `get_model_list()` → `get_model_features(model)` → `run(json_params)` → 轮询 `get_task_status(task_id)`。`get_model_features.image_workflows` 提供具体模型的运行方式、参考用途、图片上限、增量参数 schema 和使用规则;AI 不需要猜 `injector_type`。不传 `steps/cfg/sampler/scheduler` 时服务端沿用模型推荐值,负面提示词是否生效仍取决于模型和 CFG。
328
+
329
+ 编辑两张参考图的 JSON 模板(图片值需替换为实际可访问的 URL 或完整 Base64 Data URI):
330
+
331
+ ```json
332
+ {
333
+ "task_type": "reference",
334
+ "model": "lightx2v/Qwen-Image-Edit-2511-Lightning",
335
+ "prompt": "保留图1的主体,参考 img2 的背景和光线。",
336
+ "images": ["https://example.com/subject.png", "https://example.com/background.png"],
337
+ "reference_role": "auto",
338
+ "width": 1024,
339
+ "height": 1024,
340
+ "async_execution": true
341
+ }
342
+ ```
343
+
344
+ 也可用顶层 `"img1": "...", "图2": "..."` 替代 `images`,按数值排序而非 JSON 字段顺序。编号必须连续且唯一;`img1` 与 `图1` 重复、跳号、空项、`images` 与 `image` / 原生参考 `chain` 混用都会报 `INVALID_PARAMS`。输入图片支持公网 HTTP(S) URL 和 Base64 Data URI,不接受服务端本地路径。
345
+
346
+ | 意图 | 最小图片参数 |
347
+ |---|---|
348
+ | 一图指令编辑 / 多图融合 | `task_type=reference`、`images`、`width`、`height`;默认 `run_mode=multi_reference` |
349
+ | 普通整体重绘 | `task_type=img2img`、`image`(或单元素 `images`);可设 `denoise` |
350
+ | 多图逐张重绘 | `task_type=img2img`、`run_mode=multi_independent`、`images`;Prompt 共用 |
351
+ | 图片×模型 | 同上,改为 `run_mode=multi_model_grid` 并加 `extra_models` |
352
+ | 模型 PK | 原任务参数加 `run_mode=model_pk`、`extra_models`;只比较基础模型 |
353
+
354
+ 旧的 `image`、`txt2img + chain`、同步 / 异步调用、`run_imagegen` 保留。参考用途默认 `auto`;`identity` / `style` 只在模型 discovery 明确返回时使用,不能跨模型猜测。
355
+
356
+ Gradio Client 传参和异步轮询方式(`params` 使用上面的 JSON 内容):
357
+
358
+ ```python
359
+ import json
360
+ import time
361
+ from gradio_client import Client
362
+
363
+ client = Client("BlueSkyXN/ImageGen-Studio")
364
+ task = client.predict(json_params=json.dumps(params, ensure_ascii=False), api_name="/run")
365
+ if "error" in task:
366
+ raise RuntimeError(task["error"])
367
+ while task["status"] in {"queued", "processing"}:
368
+ time.sleep(2)
369
+ task = client.predict(task_id=task["task_id"], api_name="/get_task_status")
370
+ if task["status"] == "failed":
371
+ raise RuntimeError(task["error"])
372
+ print(task["result"])
373
+ ```
374
+
375
+ 轮询超时应重查同一 `task_id`,不要重复提交生成。`result.images` 仍是图片 URL 列表;新增 `image_references`、`effective_prompt`、`captions`、`jobs`、`partial_success` 和 `summary` 用于检查实际编号、送入模型的 Prompt、各子任务结果。批量任务的尺寸/seed 以 `jobs` 为准,顶层字段兼容保留首任务值。匿名 HF 调用仍受 ZeroGPU 额度限制,已有授权客户端可按自身认证方式传入 HF token。
376
+
377
+ ### 配置入口
378
+
379
+ - 模型清单:`yaml/model_list.yaml`;模型推荐采样参数:`yaml/model_defaults.yaml`,UI 重置按钮和 API 默认值沿用此配置。
380
+ - 架构扩展:`yaml/image_gen_features.yaml`;具体 checkpoint 的编辑能力限制:`core/model_capabilities.py`。
381
+ - 原生参考图张数:`core/reference_inputs.py` 的 `REFERENCE_CHAIN_SPECS` 与 injector 容量一致;有效上限还受 `IMAGEGEN_MAX_MULTI_IMAGES`、`IMAGEGEN_MAX_REFERENCE_IMAGES` 限制,修改后重启生效。仅提高环境变量不能突破模型容量。
382
+ - 像素和任务预算沿用上表的 `IMAGEGEN_MAX_*` 环境变量;界面、执行计划与 API discovery 共用,不需维护另一套 API 参数表。
383
+
384
  ## 验证
385
 
386
  ```bash
 
388
  python -m unittest discover -s tests -v
389
  ```
390
 
391
+ 界面烟测在安装 Gradio 后会以 stub ComfyUI 构建完整配置,因此不需要下载模型或占用 GPU;未安装 Gradio 时相关用例会跳过。
392
 
393
  ## 目录
394
 
core/execution_plan.py CHANGED
@@ -17,11 +17,18 @@ from typing import Any
17
 
18
  from PIL import Image
19
 
20
- from core.model_capabilities import supports_chain_for_model
 
 
 
 
 
 
 
 
21
  from core.runtime_config import CONFIG
22
  from core.settings import (
23
  ARCHITECTURES_CONFIG,
24
- FEATURES_CONFIG,
25
  MODEL_DEFAULTS_CONFIG,
26
  MODEL_MAP_CHECKPOINT,
27
  MODEL_TYPE_MAP,
@@ -71,21 +78,15 @@ COMPARISON_CHAIN_INPUT_KEYS = (
71
  "reference_image_data",
72
  )
73
 
74
- # (chain name, pipeline input key, maximum images supported by its injector)
75
- REFERENCE_CHAIN_SPECS = {
76
- "qwen_image_edit": ("qwen_image_edit_data", 3),
77
- "joyai_image": ("joyai_reference_data", 2),
78
- "boogu_image_edit": ("boogu_edit_data", 10),
79
- "reference_image": ("reference_image_data", 10),
80
- "reference_latent": ("reference_latent_data", 10),
81
- "hidream_o1_reference": ("hidream_o1_reference_data", 10),
82
- "krea2_identity_edit": ("krea2_identity_edit_data", 2),
83
- "krea2_style_reference": ("krea2_reference_edit_data", 3),
84
- }
85
-
86
-
87
- class ExecutionPlanError(ValueError):
88
- pass
89
 
90
 
91
  @dataclass(frozen=True)
@@ -219,42 +220,12 @@ def load_uploaded_images(uploaded_files: Sequence[Any] | None) -> list[Image.Ima
219
 
220
 
221
  def _pick_reference_chain(model_name: str, role: str) -> tuple[str, str, int]:
222
- workflow_type = _workflow_type(model_name)
223
- enabled = set(
224
- FEATURES_CONFIG.get(workflow_type, {}).get("enabled_chains", [])
225
- )
226
-
227
- if role == "identity":
228
- order = ["krea2_identity_edit"]
229
- elif role == "style":
230
- # The generic FLUX style injector has a different image/weight schema;
231
- # keep this high-level path limited to the validated Krea reference chain.
232
- order = ["krea2_style_reference"]
233
- else:
234
- order = [
235
- "qwen_image_edit",
236
- "joyai_image",
237
- "boogu_image_edit",
238
- "reference_image",
239
- "reference_latent",
240
- "hidream_o1_reference",
241
- "krea2_identity_edit",
242
- "krea2_style_reference",
243
- ]
244
-
245
- for chain_name in order:
246
- if (
247
- chain_name in enabled
248
- and chain_name in REFERENCE_CHAIN_SPECS
249
- and supports_chain_for_model(model_name, chain_name)
250
- ):
251
- input_key, maximum = REFERENCE_CHAIN_SPECS[chain_name]
252
- return chain_name, input_key, maximum
253
-
254
- if role in {"identity", "style"}:
255
- raise ExecutionPlanError(
256
- f"模型“{model_name}”不支持所选的{('身份' if role == 'identity' else '风格')}参考方式。"
257
- )
258
  raise ExecutionPlanError(
259
  f"模型“{model_name}”没有可自动使用的多图参考链;请换用编辑/多模态模型。"
260
  )
@@ -273,6 +244,14 @@ def build_execution_plan(
273
  if mode not in {choice[1] for choice in RUN_MODE_CHOICES}:
274
  raise ExecutionPlanError(f"未知运行模式:{mode}")
275
 
 
 
 
 
 
 
 
 
276
  base_model = str(base_inputs.get("model_display_name") or "")
277
  if base_model not in MODEL_MAP_CHECKPOINT:
278
  raise ExecutionPlanError("请先选择有效模型。")
@@ -293,6 +272,18 @@ def build_execution_plan(
293
  raise ExecutionPlanError("模型 PK 至少需要再选择 1 个对比模型。")
294
 
295
  source_images = list(images or [])
 
 
 
 
 
 
 
 
 
 
 
 
296
  shared_seed = base_inputs.get("seed", -1)
297
  try:
298
  shared_seed = int(shared_seed)
@@ -337,12 +328,13 @@ def build_execution_plan(
337
  _make_fair_comparison(values)
338
  values["seed"] = shared_seed
339
  values[task_input_key] = image
 
340
  caption = _caption(f"输入 {image_index} · {model_name}", values)
341
  plan.append(PlannedGeneration(values, caption))
342
 
343
  elif mode == MODE_MULTI_REFERENCE:
344
  if str(base_inputs.get("task_type")) != "txt2img":
345
- raise ExecutionPlanError("多图融合请把任务切换为“文生图”;参考图会直接进入编辑模型。")
346
  if not source_images:
347
  raise ExecutionPlanError("多图融合需要上传至少 1 张参考图。")
348
  chain_name, input_key, maximum = _pick_reference_chain(
@@ -353,8 +345,9 @@ def build_execution_plan(
353
  f"当前模型的 {chain_name} 最多支持 {maximum} 张参考图。"
354
  )
355
  values = _for_model(base_inputs, base_model, False)
356
- existing = [value for value in values.get(input_key, []) if value is not None]
357
- values[input_key] = [*existing, *source_images][:maximum]
 
358
  values["seed"] = shared_seed
359
  plan.append(
360
  PlannedGeneration(
@@ -377,6 +370,11 @@ def build_execution_plan(
377
  f"预计输出 {estimated_outputs} 张,超过上限 {CONFIG.max_plan_outputs};"
378
  "请减少模型、输入图片或单次生成数量。"
379
  )
 
 
 
 
 
380
  # Release Comfy's global model state only at an actual model boundary. The
381
  # final model remains warm for a likely follow-up generation.
382
  for current, following in pairwise(plan):
 
17
 
18
  from PIL import Image
19
 
20
+ from core.reference_inputs import (
21
+ ImageInputError as ExecutionPlanError,
22
+ REFERENCE_CHAIN_SPECS,
23
+ SOURCE_IMAGE_KEYS,
24
+ clear_native_references,
25
+ prepare_image_bindings,
26
+ reference_choices,
27
+ validate_image_budget,
28
+ )
29
  from core.runtime_config import CONFIG
30
  from core.settings import (
31
  ARCHITECTURES_CONFIG,
 
32
  MODEL_DEFAULTS_CONFIG,
33
  MODEL_MAP_CHECKPOINT,
34
  MODEL_TYPE_MAP,
 
78
  "reference_image_data",
79
  )
80
 
81
+ def available_run_modes(task_type: str, model_name: str) -> list[tuple[str, str]]:
82
+ if task_type == "reference":
83
+ return [("整组参考图一起编辑", MODE_MULTI_REFERENCE)] if reference_choices(model_name) else []
84
+ modes = {MODE_SINGLE, MODE_MODEL_PK}
85
+ if task_type in INDEPENDENT_IMAGE_TASK_KEYS:
86
+ modes.update({MODE_MULTI_INDEPENDENT, MODE_MULTI_MODEL_GRID})
87
+ if task_type == "txt2img" and reference_choices(model_name):
88
+ modes.add(MODE_MULTI_REFERENCE)
89
+ return [choice for choice in RUN_MODE_CHOICES if choice[1] in modes]
 
 
 
 
 
 
90
 
91
 
92
  @dataclass(frozen=True)
 
220
 
221
 
222
  def _pick_reference_chain(model_name: str, role: str) -> tuple[str, str, int]:
223
+ choices = reference_choices(model_name)
224
+ if role in choices:
225
+ selected = choices[role]
226
+ return selected["chain"], selected["input_key"], selected["max_images"]
227
+ if choices:
228
+ raise ExecutionPlanError(f"模型“{model_name}”不支持参考用途 {role};可选:{', '.join(choices)}。")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  raise ExecutionPlanError(
230
  f"模型“{model_name}”没有可自动使用的多图参考链;请换用编辑/多模态模型。"
231
  )
 
244
  if mode not in {choice[1] for choice in RUN_MODE_CHOICES}:
245
  raise ExecutionPlanError(f"未知运行模式:{mode}")
246
 
247
+ base_inputs = dict(base_inputs)
248
+ if base_inputs.get("task_type") == "reference":
249
+ if mode == MODE_SINGLE:
250
+ mode = MODE_MULTI_REFERENCE
251
+ if mode != MODE_MULTI_REFERENCE:
252
+ raise ExecutionPlanError("参考图编辑 / 融合只支持整组参考一起处理;逐张重绘请选图生图。")
253
+ base_inputs["task_type"] = "txt2img"
254
+
255
  base_model = str(base_inputs.get("model_display_name") or "")
256
  if base_model not in MODEL_MAP_CHECKPOINT:
257
  raise ExecutionPlanError("请先选择有效模型。")
 
272
  raise ExecutionPlanError("模型 PK 至少需要再选择 1 个对比模型。")
273
 
274
  source_images = list(images or [])
275
+ if len(source_images) > CONFIG.max_multi_images:
276
+ raise ExecutionPlanError(f"一次最多输入 {CONFIG.max_multi_images} 张图片;当前为 {len(source_images)} 张。")
277
+ if any(image is None for image in source_images):
278
+ raise ExecutionPlanError("图片列表不能包含空项;请按图1、图2的顺序连续提供图片。")
279
+ validate_image_budget(source_images)
280
+ if source_images and mode in {MODE_SINGLE, MODE_MODEL_PK}:
281
+ key = SOURCE_IMAGE_KEYS.get(base_inputs.get("task_type"))
282
+ if not key or len(source_images) != 1:
283
+ raise ExecutionPlanError("普通生成 / 模型 PK 只接受一张源图;多图请显式选择逐图处理或参考图编辑 / 融合。")
284
+ if base_inputs.get(key) is not None:
285
+ raise ExecutionPlanError("源图与图片列表不能同时提供,请只使用一个图片入口。")
286
+ base_inputs[key] = source_images[0]
287
  shared_seed = base_inputs.get("seed", -1)
288
  try:
289
  shared_seed = int(shared_seed)
 
328
  _make_fair_comparison(values)
329
  values["seed"] = shared_seed
330
  values[task_input_key] = image
331
+ values["_source_image_index"] = image_index
332
  caption = _caption(f"输入 {image_index} · {model_name}", values)
333
  plan.append(PlannedGeneration(values, caption))
334
 
335
  elif mode == MODE_MULTI_REFERENCE:
336
  if str(base_inputs.get("task_type")) != "txt2img":
337
+ raise ExecutionPlanError("多图融合请选“参考图编辑 / 融合”(也兼容文生图);普通图生图是逐张重绘。")
338
  if not source_images:
339
  raise ExecutionPlanError("多图融合需要上传至少 1 张参考图。")
340
  chain_name, input_key, maximum = _pick_reference_chain(
 
345
  f"当前模型的 {chain_name} 最多支持 {maximum} 张参考图。"
346
  )
347
  values = _for_model(base_inputs, base_model, False)
348
+ # 高层入口的文件列表是唯一素材源;隐藏的旧槽位保留在 UI,不并入本次任务。
349
+ clear_native_references(values)
350
+ values[input_key] = source_images
351
  values["seed"] = shared_seed
352
  plan.append(
353
  PlannedGeneration(
 
370
  f"预计输出 {estimated_outputs} 张,超过上限 {CONFIG.max_plan_outputs};"
371
  "请减少模型、输入图片或单次生成数量。"
372
  )
373
+ for item in plan:
374
+ prepare_image_bindings(
375
+ item.inputs, independent=mode in {MODE_MULTI_INDEPENDENT, MODE_MULTI_MODEL_GRID},
376
+ )
377
+
378
  # Release Comfy's global model state only at an actual model boundary. The
379
  # final model remains warm for a likely follow-up generation.
380
  for current, following in pairwise(plan):
core/model_capabilities.py CHANGED
@@ -20,3 +20,17 @@ def supports_chain_for_model(model_name: str, chain_name: str) -> bool:
20
  if chain_name in _EDIT_ONLY_CHAINS:
21
  return "edit" in str(model_name).casefold()
22
  return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  if chain_name in _EDIT_ONLY_CHAINS:
21
  return "edit" in str(model_name).casefold()
22
  return True
23
+
24
+
25
+ def enabled_chains_for_model(model_name: str) -> list[str]:
26
+ """以具体 checkpoint 为准,供界面、计划与 API 共用。"""
27
+ from core.settings import ARCHITECTURES_CONFIG, FEATURES_CONFIG, MODEL_TYPE_MAP
28
+
29
+ architecture = MODEL_TYPE_MAP.get(model_name, "SDXL")
30
+ model_type = ARCHITECTURES_CONFIG.get("architectures", {}).get(
31
+ architecture, {}
32
+ ).get("model_type", architecture.lower().replace(" ", "").replace(".", ""))
33
+ return [
34
+ chain for chain in FEATURES_CONFIG.get(model_type, {}).get("enabled_chains", [])
35
+ if supports_chain_for_model(model_name, chain)
36
+ ]
core/pipelines/pipeline_input_processor.py CHANGED
@@ -7,6 +7,7 @@ from typing import Dict, Any, List
7
 
8
  from core.settings import INPUT_DIR, MULTIPLIERS_MAP, LORA_DIR, EMBEDDING_DIR, VAE_DIR
9
  from core.runtime_config import CONFIG
 
10
  from imagegen_utils.app_utils import (
11
  sanitize_filename,
12
  get_lora_path,
@@ -32,14 +33,7 @@ REFERENCE_IMAGE_LIMITS = {
32
  "flux1_ipadapter_data": 5,
33
  "sd3_ipadapter_chain": 5,
34
  "style_data": 5,
35
- "reference_latent_data": 10,
36
- "hidream_o1_reference_data": 10,
37
- "joyai_reference_data": 2,
38
- "krea2_identity_edit_data": 2,
39
- "krea2_reference_edit_data": 3,
40
- "qwen_image_edit_data": 3,
41
- "boogu_edit_data": 10,
42
- "reference_image_data": 10,
43
  }
44
 
45
 
 
7
 
8
  from core.settings import INPUT_DIR, MULTIPLIERS_MAP, LORA_DIR, EMBEDDING_DIR, VAE_DIR
9
  from core.runtime_config import CONFIG
10
+ from core.reference_inputs import REFERENCE_CHAIN_SPECS
11
  from imagegen_utils.app_utils import (
12
  sanitize_filename,
13
  get_lora_path,
 
33
  "flux1_ipadapter_data": 5,
34
  "sd3_ipadapter_chain": 5,
35
  "style_data": 5,
36
+ **{key: limit for key, limit in REFERENCE_CHAIN_SPECS.values()},
 
 
 
 
 
 
 
37
  }
38
 
39
 
core/pipelines/sd_image_pipeline.py CHANGED
@@ -10,6 +10,7 @@ from typing import List, Dict, Any
10
  from .base_pipeline import BasePipeline
11
  from core.settings import *
12
  from core.model_capabilities import supports_chain_for_model
 
13
  from imagegen_utils.app_utils import sanitize_prompt
14
  from core.workflow_assembler import WorkflowAssembler
15
  from core.runtime_config import CONFIG, estimate_gpu_duration
@@ -114,17 +115,6 @@ class SdImagePipeline(BasePipeline):
114
  enabled_chains = set(
115
  FEATURES_CONFIG.get(workflow_model_type, {}).get("enabled_chains", [])
116
  )
117
- if task_type != "txt2img":
118
- has_native_references = any(
119
- any(ui_inputs.get(input_key) or [])
120
- for chain_name in self.NATIVE_REFERENCE_CHAINS
121
- for input_key in self.CHAIN_INPUT_KEYS[chain_name]
122
- )
123
- if has_native_references:
124
- raise gr.Error(
125
- "原生多图参考编辑目前只支持“文生图 / 多图融合”。"
126
- "普通逐张重绘请使用图生图;不要同时叠加源图 latent 与参考图链。"
127
- )
128
  for chain_name, input_keys in self.CHAIN_INPUT_KEYS.items():
129
  if (
130
  chain_name not in enabled_chains
@@ -132,6 +122,10 @@ class SdImagePipeline(BasePipeline):
132
  ):
133
  for input_key in input_keys:
134
  ui_inputs[input_key] = []
 
 
 
 
135
  if "pid" not in enabled_chains:
136
  ui_inputs["pid_settings"] = "OFF"
137
  if "vae" not in enabled_chains:
 
10
  from .base_pipeline import BasePipeline
11
  from core.settings import *
12
  from core.model_capabilities import supports_chain_for_model
13
+ from core.reference_inputs import ImageInputError, prepare_image_bindings
14
  from imagegen_utils.app_utils import sanitize_prompt
15
  from core.workflow_assembler import WorkflowAssembler
16
  from core.runtime_config import CONFIG, estimate_gpu_duration
 
115
  enabled_chains = set(
116
  FEATURES_CONFIG.get(workflow_model_type, {}).get("enabled_chains", [])
117
  )
 
 
 
 
 
 
 
 
 
 
 
118
  for chain_name, input_keys in self.CHAIN_INPUT_KEYS.items():
119
  if (
120
  chain_name not in enabled_chains
 
122
  ):
123
  for input_key in input_keys:
124
  ui_inputs[input_key] = []
125
+ try:
126
+ prepare_image_bindings(ui_inputs)
127
+ except ImageInputError as exc:
128
+ raise gr.Error(str(exc)) from exc
129
  if "pid" not in enabled_chains:
130
  ui_inputs["pid_settings"] = "OFF"
131
  if "vae" not in enabled_chains:
core/reference_inputs.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """图片顺序、模型参考图能力和提示词编号的共享契约;不下载或改写图片。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from PIL import Image
7
+
8
+ from core.model_capabilities import enabled_chains_for_model
9
+ from core.runtime_config import CONFIG
10
+
11
+
12
+ class ImageInputError(ValueError):
13
+ pass
14
+
15
+
16
+ # (pipeline input key, injector capacity)。应用上限仍受 RuntimeConfig 约束。
17
+ REFERENCE_CHAIN_SPECS = {
18
+ "qwen_image_edit": ("qwen_image_edit_data", 3),
19
+ "joyai_image": ("joyai_reference_data", 2),
20
+ "boogu_image_edit": ("boogu_edit_data", 10),
21
+ "reference_image": ("reference_image_data", 10),
22
+ "reference_latent": ("reference_latent_data", 10),
23
+ "hidream_o1_reference": ("hidream_o1_reference_data", 10),
24
+ "krea2_identity_edit": ("krea2_identity_edit_data", 2),
25
+ "krea2_style_reference": ("krea2_reference_edit_data", 3),
26
+ }
27
+ SOURCE_IMAGE_KEYS = {
28
+ "img2img": "img2img_image",
29
+ "inpaint": "inpaint_image",
30
+ "outpaint": "outpaint_image",
31
+ "hires_fix": "hires_image",
32
+ }
33
+
34
+ # 不匹配文件名/标识符;引号和反引号内的文字作为画面文字保留。
35
+ # 裸“图”不从构图、地图、草图等常用复合词中截取;保留完整的“参考图 / 图片”别名。
36
+ _REFERENCE = re.compile(
37
+ r"(?:(?<![A-Za-z_./\\-])(?:参考图|图片|(?<![构地蓝位视插草绘截贴])图)|(?<![A-Za-z0-9_./\\-])(?:img|image))[ \t]*([0-90-9]+)"
38
+ r"(?![0-90-9A-Za-z_./\\-])", re.IGNORECASE,
39
+ )
40
+ _LITERAL = re.compile(r'("[^"\n]*"|“[^”\n]*”|「[^」\n]*」|`[^`\n]*`)')
41
+
42
+
43
+ def reference_choices(model: str) -> dict[str, dict]:
44
+ enabled = enabled_chains_for_model(model)
45
+ available = [name for name in REFERENCE_CHAIN_SPECS if name in enabled]
46
+ roles = {}
47
+ for role, order in (
48
+ ("auto", available),
49
+ ("identity", ["krea2_identity_edit"]),
50
+ ("style", ["krea2_style_reference"]),
51
+ ):
52
+ chain = next((name for name in order if name in available), None)
53
+ if chain:
54
+ key, limit = REFERENCE_CHAIN_SPECS[chain]
55
+ roles[role] = {
56
+ "chain": chain, "input_key": key,
57
+ "max_images": min(limit, CONFIG.max_multi_images, CONFIG.max_reference_images),
58
+ }
59
+ return roles
60
+
61
+
62
+ def clear_native_references(values: dict) -> None:
63
+ for key, _ in REFERENCE_CHAIN_SPECS.values():
64
+ values[key] = []
65
+
66
+
67
+ def active_reference_groups(values: dict) -> dict[str, list]:
68
+ enabled = enabled_chains_for_model(values.get("model_display_name", ""))
69
+ return {
70
+ chain: [image for image in values.get(key, []) if image is not None]
71
+ for chain, (key, _) in REFERENCE_CHAIN_SPECS.items()
72
+ if chain in enabled and any(image is not None for image in values.get(key, []))
73
+ }
74
+
75
+
76
+ def image_bindings(count: int) -> list[dict]:
77
+ return [
78
+ {"id": f"img{i}", "index": i, "aliases": [f"图{i}", f"img{i}", f"image{i}"],
79
+ "model_reference": f"image {i}"}
80
+ for i in range(1, count + 1)
81
+ ]
82
+
83
+
84
+ def validate_image_budget(images: list) -> None:
85
+ total = 0.0
86
+ for i, image in enumerate(images, 1):
87
+ if not isinstance(image, Image.Image):
88
+ continue # API 排队前可用未下载的 URL 校验结构。
89
+ megapixels = image.width * image.height / 1_000_000
90
+ if megapixels > CONFIG.max_input_megapixels:
91
+ raise ImageInputError(f"图{i} 为 {megapixels:.1f} MP,超过单图 {CONFIG.max_input_megapixels:g} MP 上限。")
92
+ total += megapixels
93
+ if total > CONFIG.max_reference_megapixels:
94
+ raise ImageInputError(f"图片累计 {total:.1f} MP,超过 {CONFIG.max_reference_megapixels:g} MP 上限;请减少或缩小图片。")
95
+
96
+
97
+ def normalize_image_references(prompt: str, count: int, *, independent=False, ambiguous=False) -> str:
98
+ """仅统一显式编号;不是翻译器,也不承诺所有模型都懂位置指令。"""
99
+ def replace(match):
100
+ if independent:
101
+ raise ImageInputError("多图独立/图片×模型共用提示词,不能跨图引用编号;请写“当前图片”,或改用参考图编辑 / 融合。")
102
+ if ambiguous:
103
+ raise ImageInputError("多个原生参考链同时启用时图片编号不明确;请只保留一个参考链,或使用参考图编辑 / 融合入口。")
104
+ index = int(match.group(1))
105
+ if not 1 <= index <= count:
106
+ raise ImageInputError(f"提示词引用了 {match.group(0)},但本次只有 {count} 张有效图片;编号从 1 开始。")
107
+ separator = " " if _REFERENCE.match(match.string, match.end()) else ""
108
+ return f"image {index}{separator}"
109
+
110
+ parts = _LITERAL.split(prompt or "")
111
+ return "".join(part if index % 2 else _REFERENCE.sub(replace, part) for index, part in enumerate(parts))
112
+
113
+
114
+ def prepare_image_bindings(values: dict, *, independent=False) -> None:
115
+ """在下载模型前处理当前有效输入;调用方持有自己的输入字典。"""
116
+ enabled = enabled_chains_for_model(values.get("model_display_name", ""))
117
+ for chain, (key, _) in REFERENCE_CHAIN_SPECS.items():
118
+ if chain not in enabled:
119
+ values[key] = []
120
+ groups = active_reference_groups(values)
121
+ task = values.get("task_type")
122
+ if task != "txt2img" and groups:
123
+ raise ImageInputError("原生参考图只能用于参考图编辑 / 融合(API 也兼容 txt2img + chain);普通重绘不要叠加参考链。")
124
+ for chain, images in groups.items():
125
+ limit = REFERENCE_CHAIN_SPECS[chain][1]
126
+ if len(images) > limit:
127
+ raise ImageInputError(f"{chain} 最多支持 {limit} 张参考图,当前为 {len(images)} 张;不会截断图片。")
128
+ count = sum(map(len, groups.values()))
129
+ if count > CONFIG.max_reference_images:
130
+ raise ImageInputError(f"本次参考图超过 {CONFIG.max_reference_images} 张上限。")
131
+ validate_image_budget([image for images in groups.values() for image in images])
132
+ if task in SOURCE_IMAGE_KEYS:
133
+ source = values.get(SOURCE_IMAGE_KEYS[task])
134
+ if task == "inpaint" and source is None:
135
+ source = (values.get("inpaint_image_dict") or {}).get("background")
136
+ count = int(source is not None)
137
+ for key in ("positive_prompt", "negative_prompt"):
138
+ values[key] = normalize_image_references(
139
+ values.get(key, ""), count, independent=independent, ambiguous=len(groups) > 1,
140
+ )
141
+ values["_image_references"] = image_bindings(count) if len(groups) <= 1 else []
mcp_tools/__init__.py CHANGED
@@ -1,4 +1,18 @@
 
 
 
 
 
 
 
 
1
  def __getattr__(name):
 
 
 
 
 
 
2
  if name in ("types", "server", "client", "shared"):
3
  raise ImportError(f"No module named 'mcp.{name}' in local mcp package")
4
  raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
@@ -12,13 +26,6 @@ from .run import handle_run
12
  from .get_task_status import handle_get_task_status
13
  from .get_chain_schema import handle_get_chain_schema
14
  from .error_schema import make_error, make_validation_error, make_not_found_error
15
- from .mcp_gradio_integration import (
16
- register_high_level_mcp_apis,
17
- cleanup_dependencies_api_names,
18
- patch_gradio_api_suppression,
19
- HIGH_LEVEL_MCP_API_NAMES,
20
- )
21
-
22
  MCP_FUNCTIONS = [
23
  handle_get_task_list,
24
  handle_get_model_architecture_list,
 
1
+ _GRADIO_INTEGRATION_EXPORTS = {
2
+ "register_high_level_mcp_apis",
3
+ "cleanup_dependencies_api_names",
4
+ "patch_gradio_api_suppression",
5
+ "HIGH_LEVEL_MCP_API_NAMES",
6
+ }
7
+
8
+
9
  def __getattr__(name):
10
+ if name in _GRADIO_INTEGRATION_EXPORTS:
11
+ # Keep the transport-independent handlers importable in validation and
12
+ # CLI environments where Gradio is intentionally not installed.
13
+ from . import mcp_gradio_integration
14
+
15
+ return getattr(mcp_gradio_integration, name)
16
  if name in ("types", "server", "client", "shared"):
17
  raise ImportError(f"No module named 'mcp.{name}' in local mcp package")
18
  raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
 
26
  from .get_task_status import handle_get_task_status
27
  from .get_chain_schema import handle_get_chain_schema
28
  from .error_schema import make_error, make_validation_error, make_not_found_error
 
 
 
 
 
 
 
29
  MCP_FUNCTIONS = [
30
  handle_get_task_list,
31
  handle_get_model_architecture_list,
mcp_tools/common.py CHANGED
@@ -202,9 +202,17 @@ def _load_yaml(filepath: str) -> dict:
202
  _COMMON_OPTIONAL_INPUTS = [
203
  "steps", "cfg", "sampler", "scheduler", "seed",
204
  "negative_prompt", "batch_size", "chain", "async_execution",
 
205
  ]
206
 
207
  _TASK_DEFINITIONS = [
 
 
 
 
 
 
 
208
  {
209
  "task_type": "txt2img",
210
  "display_name": "Text-to-Image",
@@ -215,9 +223,9 @@ _TASK_DEFINITIONS = [
215
  {
216
  "task_type": "img2img",
217
  "display_name": "Image-to-Image",
218
- "description": "Perform global repaint and style transfer based on a source image. Denoise strength must be specified.",
219
- "required_inputs": ["prompt", "image", "denoise"],
220
- "optional_inputs": _COMMON_OPTIONAL_INPUTS,
221
  },
222
  {
223
  "task_type": "inpaint",
@@ -242,6 +250,65 @@ _TASK_DEFINITIONS = [
242
  },
243
  ]
244
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  _TASKS_DB: Dict[str, Dict[str, Any]] = {}
246
  _TASKS_LOCK = threading.RLock()
247
 
@@ -351,9 +418,9 @@ def _execute_imagegen_pipeline(task_id: str, params: dict):
351
  "denoise": params.get("denoise", 1.0),
352
  }
353
 
354
- if "image" in params and params["image"]:
355
- pil_img = _parse_image_param(params["image"])
356
- if pil_img:
357
  if task_type == "img2img":
358
  ui_inputs["img2img_image"] = pil_img
359
  ui_inputs["img2img_denoise"] = params.get("denoise", 0.7)
@@ -735,8 +802,40 @@ def _execute_imagegen_pipeline(task_id: str, params: dict):
735
 
736
  _update_task(task_id, progress=50)
737
 
738
- # Execute Pipeline
739
- output = sd_image_pipeline.run(ui_inputs=ui_inputs, progress=DummyProgress())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
740
 
741
  try:
742
  from core.settings import OUTPUT_DIR
@@ -753,8 +852,9 @@ def _execute_imagegen_pipeline(task_id: str, params: dict):
753
 
754
  base_url = _get_public_base_url()
755
  images = []
756
- raw_list = output if isinstance(output, list) else ([output] if output else [])
757
- for idx, item in enumerate(raw_list):
 
758
  target_path = None
759
  if hasattr(item, "save"): # PIL Image
760
  filename = f"mcp_{task_id}_{idx}.png"
@@ -789,6 +889,12 @@ def _execute_imagegen_pipeline(task_id: str, params: dict):
789
  "width": ui_inputs.get("width", params.get("width", 1024)),
790
  "height": ui_inputs.get("height", params.get("height", 1024)),
791
  "execution_time_seconds": execution_time,
 
 
 
 
 
 
792
  },
793
  )
794
 
 
202
  _COMMON_OPTIONAL_INPUTS = [
203
  "steps", "cfg", "sampler", "scheduler", "seed",
204
  "negative_prompt", "batch_size", "chain", "async_execution",
205
+ "images", "run_mode", "extra_models", "reference_role", "use_model_defaults",
206
  ]
207
 
208
  _TASK_DEFINITIONS = [
209
+ {
210
+ "task_type": "reference",
211
+ "display_name": "参考图编辑 / 融合",
212
+ "description": "一张图按指令编辑,多张图整组参考。images 按数组顺序从 1 编号;图1 / img1 / image1 等价。先查询 get_model_features.image_workflows 的用途和张数上限,不需要填写 chain。",
213
+ "required_inputs": ["prompt", "images", "width", "height"],
214
+ "optional_inputs": [field for field in _COMMON_OPTIONAL_INPUTS if field != "images"],
215
+ },
216
  {
217
  "task_type": "txt2img",
218
  "display_name": "Text-to-Image",
 
223
  {
224
  "task_type": "img2img",
225
  "display_name": "Image-to-Image",
226
+ "description": "Perform global repaint and style transfer based on a source image. Denoise defaults to 0.7 when omitted.",
227
+ "required_inputs": ["prompt", "image"],
228
+ "optional_inputs": ["denoise"] + _COMMON_OPTIONAL_INPUTS,
229
  },
230
  {
231
  "task_type": "inpaint",
 
250
  },
251
  ]
252
 
253
+
254
+ def _find_model_record(model: str) -> tuple[str | None, dict | None]:
255
+ """Return the registry architecture and record for a display name."""
256
+
257
+ model_list = _load_yaml(_MODEL_LIST_PATH)
258
+ for architecture, architecture_data in (model_list.get("Checkpoint", {}) or {}).items():
259
+ if not isinstance(architecture_data, dict):
260
+ continue
261
+ for record in architecture_data.get("models", []) or []:
262
+ if isinstance(record, dict) and record.get("display_name") == model:
263
+ return architecture, record
264
+ return None, None
265
+
266
+
267
+ def _resolve_feature_name(chain_type: str) -> str | None:
268
+ """Resolve a canonical chain feature from its name or configured alias."""
269
+
270
+ chain_features = _load_yaml(_CHAIN_FEATURES_PATH)
271
+ if chain_type in chain_features:
272
+ return chain_type
273
+ for feature_name, feature_data in chain_features.items():
274
+ aliases = feature_data.get("chains") if isinstance(feature_data, dict) else None
275
+ if aliases == chain_type or (isinstance(aliases, list) and chain_type in aliases):
276
+ return feature_name
277
+ return None
278
+
279
+
280
+ _FEATURE_ADAPTER_ALIASES = {
281
+ "boogu_edit": "boogu_image_edit",
282
+ }
283
+
284
+
285
+ def _canonicalize_chain_type(chain_type: str) -> str:
286
+ """Normalize explicitly supported legacy request aliases."""
287
+
288
+ return _FEATURE_ADAPTER_ALIASES.get(chain_type, chain_type)
289
+
290
+
291
+ def _get_model_feature_context(model: str) -> dict | None:
292
+ """Return registry-backed architecture and enabled-chain information."""
293
+
294
+ architecture, _ = _find_model_record(model)
295
+ if not architecture:
296
+ return None
297
+ architecture_config = _load_yaml(_MODEL_ARCHITECTURES_PATH).get("architectures", {}) or {}
298
+ architecture_info = architecture_config.get(architecture, {}) or {}
299
+ model_type = architecture_info.get("model_type", architecture.lower())
300
+ features_config = _load_yaml(_IMAGE_GEN_FEATURES_PATH)
301
+ enabled = features_config.get(model_type, {}) or {}
302
+ from core.model_capabilities import supports_chain_for_model
303
+
304
+ return {
305
+ "architecture": architecture,
306
+ "model_type": model_type,
307
+ "enabled_chains": set(enabled.get("enabled_chains", []) or []),
308
+ "supports_chain": lambda chain: supports_chain_for_model(model, chain),
309
+ }
310
+
311
+
312
  _TASKS_DB: Dict[str, Dict[str, Any]] = {}
313
  _TASKS_LOCK = threading.RLock()
314
 
 
418
  "denoise": params.get("denoise", 1.0),
419
  }
420
 
421
+ if task_type in {"img2img", "inpaint", "outpaint", "hires_fix"}:
422
+ pil_img = _parse_image_param(params.get("image"))
423
+ if pil_img is not None or params.get("images"):
424
  if task_type == "img2img":
425
  ui_inputs["img2img_image"] = pil_img
426
  ui_inputs["img2img_denoise"] = params.get("denoise", 0.7)
 
802
 
803
  _update_task(task_id, progress=50)
804
 
805
+ from core.execution_plan import build_execution_plan, execute_generation_plan
806
+
807
+ plan = build_execution_plan(
808
+ ui_inputs, mode=params.get("run_mode", "single"),
809
+ images=[_parse_image_param(value) for value in params.get("images", [])],
810
+ extra_models=params.get("extra_models"),
811
+ reference_role=params.get("reference_role", "auto"),
812
+ use_model_defaults=params.get("use_model_defaults", True),
813
+ )
814
+ jobs = []
815
+
816
+ def generate(values, progress):
817
+ job = {"model": values["model_display_name"], "task_type": values["task_type"], "status": "processing"}
818
+ if "_source_image_index" in values:
819
+ job["source_index"] = values["_source_image_index"]
820
+ jobs.append(job)
821
+ try:
822
+ result = sd_image_pipeline.run(ui_inputs=values, progress=progress)
823
+ if result is None or (isinstance(result, list) and not result):
824
+ raise ValueError("生成器没有返回图片。")
825
+ job.update(status="completed", output_count=len(result) if isinstance(result, list) else int(result is not None))
826
+ return result
827
+ except Exception as exc:
828
+ job.update(status="failed", error=str(exc))
829
+ raise
830
+ finally:
831
+ job.update(
832
+ seed=values.get("seed"), width=values.get("width"), height=values.get("height"),
833
+ effective_prompt=values.get("positive_prompt", ""),
834
+ image_references=values.get("_image_references", []),
835
+ )
836
+
837
+ output, summary = execute_generation_plan(plan, generate, progress=DummyProgress())
838
+ ui_inputs = plan[0].inputs
839
 
840
  try:
841
  from core.settings import OUTPUT_DIR
 
852
 
853
  base_url = _get_public_base_url()
854
  images = []
855
+ captions = []
856
+ for idx, (item, caption) in enumerate(output):
857
+ captions.append(caption)
858
  target_path = None
859
  if hasattr(item, "save"): # PIL Image
860
  filename = f"mcp_{task_id}_{idx}.png"
 
889
  "width": ui_inputs.get("width", params.get("width", 1024)),
890
  "height": ui_inputs.get("height", params.get("height", 1024)),
891
  "execution_time_seconds": execution_time,
892
+ "effective_prompt": ui_inputs.get("positive_prompt", ""),
893
+ "image_references": ui_inputs.get("_image_references", []),
894
+ "captions": captions,
895
+ "jobs": jobs,
896
+ "partial_success": any(job["status"] == "failed" for job in jobs),
897
+ "summary": summary,
898
  },
899
  )
900
 
mcp_tools/get_model_features.py CHANGED
@@ -14,6 +14,7 @@ from .common import (
14
  )
15
  from .error_schema import make_validation_error, make_not_found_error
16
  from core.model_capabilities import supports_chain_for_model
 
17
 
18
 
19
  def handle_get_model_features(model: str) -> dict:
@@ -80,7 +81,11 @@ def handle_get_model_features(model: str) -> dict:
80
  "cfg": merged_defaults.get("cfg", 1.0),
81
  }
82
 
83
- supported_tasks = [t["task_type"] for t in _TASK_DEFINITIONS]
 
 
 
 
84
 
85
  result = {
86
  "name": model,
@@ -88,6 +93,7 @@ def handle_get_model_features(model: str) -> dict:
88
  "supported_tasks": supported_tasks,
89
  "supported_features": supported_features,
90
  "default_parameter": default_parameter,
 
91
  }
92
 
93
  default_pos = model_specific_defaults.get(
 
14
  )
15
  from .error_schema import make_validation_error, make_not_found_error
16
  from core.model_capabilities import supports_chain_for_model
17
+ from .image_requests import image_workflow_contract
18
 
19
 
20
  def handle_get_model_features(model: str) -> dict:
 
81
  "cfg": merged_defaults.get("cfg", 1.0),
82
  }
83
 
84
+ image_workflows = image_workflow_contract(model)
85
+ supported_tasks = [
86
+ t["task_type"] for t in _TASK_DEFINITIONS
87
+ if t["task_type"] != "reference" or image_workflows["reference_supported"]
88
+ ]
89
 
90
  result = {
91
  "name": model,
 
93
  "supported_tasks": supported_tasks,
94
  "supported_features": supported_features,
95
  "default_parameter": default_parameter,
96
+ "image_workflows": image_workflows,
97
  }
98
 
99
  default_pos = model_specific_defaults.get(
mcp_tools/get_task_list.py CHANGED
@@ -4,8 +4,16 @@ Get a list of all supported image generation task types along with their require
4
  """
5
 
6
  from .common import _TASK_DEFINITIONS
 
7
 
8
 
9
  def handle_get_task_list() -> list:
10
  """Get a list of all supported image generation task types along with their required/optional parameter lists."""
11
- return _TASK_DEFINITIONS
 
 
 
 
 
 
 
 
4
  """
5
 
6
  from .common import _TASK_DEFINITIONS
7
+ import copy
8
 
9
 
10
  def handle_get_task_list() -> list:
11
  """Get a list of all supported image generation task types along with their required/optional parameter lists."""
12
+ tasks = copy.deepcopy(_TASK_DEFINITIONS)
13
+ for task in tasks:
14
+ if "image" in task["required_inputs"] or "images" in task["required_inputs"]:
15
+ task["image_input_alternatives"] = {
16
+ "choose_exactly_one": ["image", "images", "numbered fields (img1 / 图1 / image1, ...)"],
17
+ "description": "image is a single source; images is an ordered list. Numbered fields replace images. See get_model_features.image_workflows for task/run_mode constraints. Never send multiple forms together.",
18
+ }
19
+ return tasks
mcp_tools/image_requests.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """高层 API 图片参数兼容层;顺序与能力校验复用 UI 的执行计划。"""
2
+
3
+ import re
4
+ from PIL import Image
5
+
6
+ from core.execution_plan import (
7
+ MODE_MODEL_PK, MODE_MULTI_MODEL_GRID, MODE_MULTI_REFERENCE, MODE_SINGLE,
8
+ available_run_modes, build_execution_plan,
9
+ )
10
+ from core.reference_inputs import ImageInputError, REFERENCE_CHAIN_SPECS, SOURCE_IMAGE_KEYS, reference_choices
11
+ from core.runtime_config import CONFIG
12
+
13
+
14
+ NATIVE_CHAIN_ALIASES = {
15
+ "reference_edit": "reference_latent",
16
+ "mage_flow_reference_edit": "reference_image",
17
+ "joyai_reference_edit": "joyai_image",
18
+ "boogu_edit": "boogu_image_edit",
19
+ }
20
+ _IMAGE_KEY = re.compile(r"(?:图|img|image)([0-90-9]+)", re.IGNORECASE)
21
+
22
+
23
+ def normalize_image_request(params: dict) -> None:
24
+ """只修改 handle_run 的副本。显式混用入口时拒绝,不猜优先级。"""
25
+ numbered = {}
26
+ for key in list(params):
27
+ match = _IMAGE_KEY.fullmatch(key) if isinstance(key, str) else None
28
+ if not match:
29
+ continue
30
+ index = int(match.group(1))
31
+ if index in numbered:
32
+ raise ImageInputError(f"图片编号 {index} 重复;图{index} / img{index} / image{index} 只能提供一个字段。")
33
+ numbered[index] = params.pop(key)
34
+ if numbered:
35
+ if "images" in params or params.get("image") is not None:
36
+ raise ImageInputError("编号字段、images 列表和 image 源图不能混用,请只保留一个入口。")
37
+ if sorted(numbered) != list(range(1, len(numbered) + 1)):
38
+ raise ImageInputError("图片编号必须从 1 开始且连续,不能缺少 img1 或跳号。")
39
+ params["images"] = [numbered[i] for i in range(1, len(numbered) + 1)]
40
+ if "images" in params:
41
+ images = params["images"]
42
+ if not isinstance(images, list) or not images:
43
+ raise ImageInputError("images 必须是非空、有序图片列表。")
44
+ if any(not isinstance(image, (str, Image.Image)) or (isinstance(image, str) and not image.strip()) for image in images):
45
+ raise ImageInputError("images 不允许空图片;删除空项后重新按顺序编号。")
46
+ if params.get("image") is not None:
47
+ raise ImageInputError("image 与 images 不能混用。")
48
+ if params["task_type"] == "reference":
49
+ params.setdefault("run_mode", MODE_MULTI_REFERENCE)
50
+ if params.get("image") is not None:
51
+ if not isinstance(params["image"], (str, Image.Image)):
52
+ raise ImageInputError("image 必须是图片 URL 或 Base64 Data URI。")
53
+ params["images"] = [params.pop("image")]
54
+ elif params["task_type"] == "txt2img" and params.get("image") is not None:
55
+ raise ImageInputError("txt2img 不使用 image 源图;参考图编辑请用 task_type=reference 和 images。")
56
+ if params.get("image") is not None and not isinstance(params["image"], (str, Image.Image)):
57
+ raise ImageInputError("image 必须是图片 URL 或 Base64 Data URI。")
58
+ for field in ("run_mode", "reference_role"):
59
+ if field in params and not isinstance(params[field], str):
60
+ raise ImageInputError(f"{field} 必须是字符串。")
61
+ if "extra_models" in params and (
62
+ not isinstance(params["extra_models"], list)
63
+ or not all(isinstance(model, str) for model in params["extra_models"])
64
+ ):
65
+ raise ImageInputError("extra_models 必须是模型名称列表。")
66
+ if "use_model_defaults" in params and not isinstance(params["use_model_defaults"], bool):
67
+ raise ImageInputError("use_model_defaults 必须是 boolean。")
68
+
69
+
70
+ def validate_image_plan(params: dict) -> None:
71
+ """不下载图片、不分配 GPU,提交队列前校验完整调用语义。"""
72
+ values = {
73
+ "task_type": params["task_type"], "model_display_name": params["model"],
74
+ "positive_prompt": params["prompt"], "negative_prompt": params.get("negative_prompt") or "",
75
+ "seed": params.get("seed", -1), "batch_size": params.get("batch_size", 1),
76
+ }
77
+ task = params["task_type"]
78
+ if task in SOURCE_IMAGE_KEYS and params.get("image") is not None:
79
+ values[SOURCE_IMAGE_KEYS[task]] = params["image"]
80
+ for item in params.get("chain", []):
81
+ chain = NATIVE_CHAIN_ALIASES.get(item["injector_type"], item["injector_type"])
82
+ if chain in REFERENCE_CHAIN_SPECS:
83
+ if params.get("images"):
84
+ raise ImageInputError("images 与原生参考 chain 不能混用;自动路由用 images,旧调用只用 chain。")
85
+ if item.get("image") is None:
86
+ raise ImageInputError(f"{chain} 缺少 image。")
87
+ if not isinstance(item["image"], (str, Image.Image)) or (isinstance(item["image"], str) and not item["image"].strip()):
88
+ raise ImageInputError(f"{chain}.image 必须是图片 URL 或 Base64 Data URI。")
89
+ values.setdefault(REFERENCE_CHAIN_SPECS[chain][0], []).append(item["image"])
90
+ mode = params.get("run_mode", MODE_SINGLE)
91
+ if mode not in {MODE_MODEL_PK, MODE_MULTI_MODEL_GRID} and params.get("extra_models"):
92
+ raise ImageInputError("extra_models 只用于 model_pk / multi_model_grid。")
93
+ if mode != MODE_MULTI_REFERENCE and params.get("reference_role", "auto") != "auto":
94
+ raise ImageInputError("reference_role 只用于 reference / multi_reference。")
95
+ if mode in {MODE_MODEL_PK, MODE_MULTI_MODEL_GRID} and (
96
+ params.get("chain") or params.get("vae_id") or params.get("pid") or params.get("pid_settings")
97
+ ):
98
+ raise ImageInputError("模型对比仅使用基础模型,不支持 chain / VAE / PiD 覆盖。")
99
+ build_execution_plan(
100
+ values, mode=mode, images=params.get("images"),
101
+ extra_models=params.get("extra_models"), reference_role=params.get("reference_role", "auto"),
102
+ use_model_defaults=params.get("use_model_defaults", True),
103
+ )
104
+
105
+
106
+ def image_workflow_contract(model: str) -> dict:
107
+ """在现有 discovery 上增量公开高层图片契约,不要求 AI 猜 injector。"""
108
+ roles = reference_choices(model)
109
+ return {
110
+ "reference_supported": bool(roles),
111
+ "reference_roles": {
112
+ role: {"chain": spec["chain"], "max_images": spec["max_images"]}
113
+ for role, spec in roles.items()
114
+ },
115
+ "run_modes_by_task": {
116
+ task: [mode for _, mode in available_run_modes(task, model)]
117
+ for task in ["txt2img", *SOURCE_IMAGE_KEYS, "reference"]
118
+ },
119
+ "parameters_schema": {
120
+ "type": "object",
121
+ "properties": {
122
+ "images": {"type": "array", "minItems": 1, "maxItems": CONFIG.max_multi_images,
123
+ "items": {"type": "string", "description": "Public HTTP(S) image URL or Base64 Data URI; not a local path."}},
124
+ "run_mode": {"type": "string", "enum": ["single", "model_pk", "multi_independent", "multi_model_grid", "multi_reference"],
125
+ "description": "Default single; task_type=reference defaults to multi_reference. Use run_modes_by_task."},
126
+ "reference_role": {"type": "string", "enum": list(roles) or ["auto"], "default": "auto"},
127
+ "extra_models": {"type": "array", "items": {"type": "string"}, "description": "Only for model_pk / multi_model_grid; current model is included automatically."},
128
+ "use_model_defaults": {"type": "boolean", "default": True},
129
+ },
130
+ "patternProperties": {r"^(图|img|image)[1-9][0-9]*$": {"type": "string", "description": "Alternative to images; indices must start at 1, be contiguous and unique."}},
131
+ },
132
+ "rules": [
133
+ "Use task_type=reference with images, prompt, width, height for instruction editing / fusion; no native chain needed. Model must support reference.",
134
+ "images order is 1-based: 图1 = img1 = image1. Numbered prompt references become image 1; quoted/backticked text is literal. No auto translation or prompt expansion.",
135
+ "Do not mix images, numbered fields, image, or native reference chain. Legacy txt2img + chain and single-image image calls remain supported.",
136
+ "multi_independent / multi_model_grid use one shared prompt per image. Say 当前图片 / the current image; cross-image numbered references are rejected.",
137
+ "Leave steps/cfg/sampler/scheduler omitted to use model defaults. Negative prompts depend on model/CFG and may be unused at CFG=1.",
138
+ "Prefer async_execution=true; poll the same task_id with get_task_status until completed/failed; do not resubmit on a polling timeout.",
139
+ "Read result.image_references, effective_prompt, captions and jobs. Existing result.images remains a flat URL list; partial_success signals mixed results.",
140
+ ],
141
+ "limits": {"max_images": CONFIG.max_multi_images, "max_pk_models": CONFIG.max_pk_models,
142
+ "max_jobs": CONFIG.max_plan_jobs, "max_outputs": CONFIG.max_plan_outputs},
143
+ }
mcp_tools/mcp_gradio_integration.py CHANGED
@@ -100,11 +100,11 @@ def register_high_level_mcp_apis(demo):
100
  )
101
 
102
  def get_model_features(model: str = "") -> dict:
103
- """Query metadata for the specified model, including supported task types, extended features, and official default inference parameters (steps, cfg, sampler, scheduler). This tool MUST be called when explicitly obtaining a model's optimal default hyperparameters (Path 2). Guessing or fabricating hyperparameters without querying is strictly prohibited."""
104
  return sanitize_keys(handle_get_model_features(model.strip()))
105
 
106
  def run(json_params: str = "{}") -> dict:
107
- """[Recommended Discovery Flow Step 4] Unified image generation task execution interface. Supports txt2img, img2img, and other tasks with chainable extended features. [IMPORTANT PARAMETER RULES] Do NOT guess or fabricate inference hyperparameters such as steps, cfg, sampler, scheduler! Path 1 (Recommended): Pass only required parameters (task_type, model, prompt, width, height), leave optional hyperparams empty (server uses optimal defaults). Path 2: If explicit hyperparams are needed, you MUST first call get_model_features to obtain official defaults before passing them."""
108
  try:
109
  if isinstance(json_params, dict):
110
  params = json_params
 
100
  )
101
 
102
  def get_model_features(model: str = "") -> dict:
103
+ """Query the concrete model's task types, features, recommended sampling defaults and image_workflows. For editing/multi-image use image_workflows: supported run modes, reference roles, image count limits, parameter schema, aliases and usage rules. Do not infer editing support from architecture alone."""
104
  return sanitize_keys(handle_get_model_features(model.strip()))
105
 
106
  def run(json_params: str = "{}") -> dict:
107
+ """Submit a JSON object encoded as json_params. First discover get_task_list and get_model_features(model). Text generation: task_type=txt2img, model, prompt, width, height. Instruction editing/fusion: task_type=reference, model, prompt, width, height, images=[URL_or_DataURI,...]; reference_role defaults to auto. images order starts at 1: 图1 / img1 / image1 in prompt refer to the first image. Alternatively use top-level img1,img2 (or 图1,图2) fields; do not mix input forms. Ordinary repaint: task_type=img2img and image; batch repaint: images plus run_mode=multi_independent. See image_workflows for model-specific limits and other modes. Omit steps/cfg/sampler/scheduler to use recommended defaults. Prefer async_execution=true; poll the returned task_id via get_task_status (do not resubmit). Legacy chain and run_imagegen calls remain available."""
108
  try:
109
  if isinstance(json_params, dict):
110
  params = json_params
mcp_tools/run.py CHANGED
@@ -10,19 +10,31 @@ from core.runtime_config import CONFIG
10
  from .common import (
11
  _load_yaml,
12
  _MODEL_LIST_PATH,
 
13
  _TASK_DEFINITIONS,
14
  _TASKS_DB,
15
  _TASKS_LOCK,
16
  _get_task_snapshot,
17
  _execute_imagegen_pipeline,
 
 
 
18
  )
19
  from .error_schema import make_error, make_validation_error, make_not_found_error
 
 
20
 
21
 
22
  def handle_run(params: dict) -> dict:
23
  """Unified image generation task execution interface."""
24
  if not isinstance(params, dict):
25
  return make_validation_error("Request params must be an object.")
 
 
 
 
 
 
26
 
27
  missing = []
28
  for req_field in ["task_type", "model", "prompt"]:
@@ -35,6 +47,25 @@ def handle_run(params: dict) -> dict:
35
  )
36
 
37
  task_type = params["task_type"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  valid_tasks = [t["task_type"] for t in _TASK_DEFINITIONS]
39
  if task_type not in valid_tasks:
40
  return make_validation_error(
@@ -53,8 +84,14 @@ def handle_run(params: dict) -> dict:
53
  if params["model"] not in all_models:
54
  return make_not_found_error("model", params["model"])
55
 
 
 
 
 
 
56
  task_requirements = {
57
  "txt2img": ("width", "height"),
 
58
  "img2img": ("image",),
59
  "inpaint": ("image",),
60
  "outpaint": ("image", "pad_left", "pad_right", "pad_top", "pad_bottom"),
@@ -63,6 +100,7 @@ def handle_run(params: dict) -> dict:
63
  missing_task_fields = [
64
  field
65
  for field in task_requirements.get(task_type, ())
 
66
  if field not in params or params[field] is None or params[field] == ""
67
  ]
68
  if missing_task_fields:
@@ -71,10 +109,123 @@ def handle_run(params: dict) -> dict:
71
  missing_fields=missing_task_fields,
72
  )
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  try:
75
- batch_size = int(params.get("batch_size", 1))
76
- except (TypeError, ValueError):
77
- batch_size = 0
 
 
78
  if not 1 <= batch_size <= CONFIG.max_batch_size:
79
  return make_validation_error(
80
  f"batch_size must be between 1 and {CONFIG.max_batch_size}.",
 
10
  from .common import (
11
  _load_yaml,
12
  _MODEL_LIST_PATH,
13
+ _CHAIN_FEATURES_PATH,
14
  _TASK_DEFINITIONS,
15
  _TASKS_DB,
16
  _TASKS_LOCK,
17
  _get_task_snapshot,
18
  _execute_imagegen_pipeline,
19
+ _get_model_feature_context,
20
+ _canonicalize_chain_type,
21
+ _resolve_feature_name,
22
  )
23
  from .error_schema import make_error, make_validation_error, make_not_found_error
24
+ from .image_requests import normalize_image_request, validate_image_plan
25
+ from core.reference_inputs import ImageInputError
26
 
27
 
28
  def handle_run(params: dict) -> dict:
29
  """Unified image generation task execution interface."""
30
  if not isinstance(params, dict):
31
  return make_validation_error("Request params must be an object.")
32
+ params = params.copy()
33
+ if isinstance(params.get("chain"), list):
34
+ params["chain"] = [
35
+ item.copy() if isinstance(item, dict) else item
36
+ for item in params["chain"]
37
+ ]
38
 
39
  missing = []
40
  for req_field in ["task_type", "model", "prompt"]:
 
47
  )
48
 
49
  task_type = params["task_type"]
50
+ if (
51
+ not isinstance(task_type, str)
52
+ or not isinstance(params["model"], str)
53
+ or not isinstance(params["prompt"], str)
54
+ or not task_type.strip()
55
+ or not params["model"].strip()
56
+ or not params["prompt"].strip()
57
+ ):
58
+ return make_validation_error(
59
+ "task_type, model, and prompt must be strings.",
60
+ invalid_fields={
61
+ key: "Expected a non-empty string"
62
+ for key in ("task_type", "model", "prompt")
63
+ if not isinstance(params.get(key), str) or not params.get(key, "").strip()
64
+ },
65
+ )
66
+ task_type = task_type.strip()
67
+ params["task_type"] = task_type
68
+ params["model"] = params["model"].strip()
69
  valid_tasks = [t["task_type"] for t in _TASK_DEFINITIONS]
70
  if task_type not in valid_tasks:
71
  return make_validation_error(
 
84
  if params["model"] not in all_models:
85
  return make_not_found_error("model", params["model"])
86
 
87
+ try:
88
+ normalize_image_request(params)
89
+ except ImageInputError as exc:
90
+ return make_validation_error(str(exc), invalid_fields={"images": str(exc)})
91
+
92
  task_requirements = {
93
  "txt2img": ("width", "height"),
94
+ "reference": ("width", "height", "images"),
95
  "img2img": ("image",),
96
  "inpaint": ("image",),
97
  "outpaint": ("image", "pad_left", "pad_right", "pad_top", "pad_bottom"),
 
100
  missing_task_fields = [
101
  field
102
  for field in task_requirements.get(task_type, ())
103
+ if not (field == "image" and params.get("images"))
104
  if field not in params or params[field] is None or params[field] == ""
105
  ]
106
  if missing_task_fields:
 
109
  missing_fields=missing_task_fields,
110
  )
111
 
112
+ invalid_fields = {}
113
+
114
+ chain_features = _load_yaml(_CHAIN_FEATURES_PATH)
115
+
116
+ def _number(name, *, integer=False, minimum=None, maximum=None):
117
+ if name not in params:
118
+ return
119
+ raw_value = params[name]
120
+ if raw_value is None or (isinstance(raw_value, str) and not raw_value.strip()):
121
+ if name in task_requirements.get(task_type, ()):
122
+ invalid_fields[name] = "Expected a non-empty number"
123
+ else:
124
+ # 可选数值的空值等同于未提供,统一沿用执行层的任务 / 模型默认值。
125
+ params.pop(name)
126
+ return
127
+ if isinstance(raw_value, bool):
128
+ invalid_fields[name] = "Expected a number" if not integer else "Expected an integer"
129
+ return
130
+ try:
131
+ numeric_value = float(raw_value)
132
+ except (TypeError, ValueError):
133
+ invalid_fields[name] = "Expected a number" if not integer else "Expected an integer"
134
+ return
135
+ if numeric_value != numeric_value or numeric_value in (float("inf"), float("-inf")):
136
+ invalid_fields[name] = "Must be finite"
137
+ return
138
+ if integer and not numeric_value.is_integer():
139
+ invalid_fields[name] = "Expected an integer"
140
+ return
141
+ value = int(numeric_value) if integer else numeric_value
142
+ if minimum is not None and value < minimum:
143
+ invalid_fields[name] = f"Must be >= {minimum}"
144
+ elif maximum is not None and value > maximum:
145
+ invalid_fields[name] = f"Must be <= {maximum}"
146
+ else:
147
+ params[name] = value
148
+
149
+ for field in ("width", "height"):
150
+ if task_type == "txt2img" or field in params:
151
+ _number(field, integer=True, minimum=1, maximum=8192)
152
+ for field in ("pad_left", "pad_right", "pad_top", "pad_bottom"):
153
+ _number(field, integer=True, minimum=0, maximum=4096)
154
+ _number("upscale_by", minimum=0.01, maximum=4.0)
155
+ _number("denoise", minimum=0.0, maximum=1.0)
156
+ _number("steps", integer=True, minimum=1, maximum=100)
157
+ _number("cfg", minimum=0.0, maximum=100.0)
158
+ _number("batch_size", integer=True, minimum=1, maximum=CONFIG.max_batch_size)
159
+ _number("seed", integer=True, minimum=-1)
160
+
161
+ for field in ("sampler", "scheduler", "negative_prompt"):
162
+ if field in params and params[field] is not None and not isinstance(params[field], str):
163
+ invalid_fields[field] = "Expected a string"
164
+
165
+ if "async_execution" in params:
166
+ async_execution = params["async_execution"]
167
+ if isinstance(async_execution, str):
168
+ normalized = async_execution.strip().casefold()
169
+ if normalized in {"true", "1"}:
170
+ params["async_execution"] = True
171
+ elif normalized in {"false", "0"}:
172
+ params["async_execution"] = False
173
+ else:
174
+ invalid_fields["async_execution"] = "Expected true/false or 1/0"
175
+ elif not isinstance(async_execution, bool):
176
+ invalid_fields["async_execution"] = "Expected a boolean"
177
+
178
+ if "chain" in params:
179
+ chain = params["chain"]
180
+ if not isinstance(chain, list):
181
+ invalid_fields["chain"] = "Expected a list of objects"
182
+ else:
183
+ context = _get_model_feature_context(params["model"])
184
+ for index, item in enumerate(chain):
185
+ field_name = f"chain[{index}]"
186
+ if not isinstance(item, dict):
187
+ invalid_fields[field_name] = "Expected an object"
188
+ continue
189
+ injector_type = item.get("injector_type")
190
+ if not isinstance(injector_type, str) or not injector_type.strip():
191
+ invalid_fields[field_name] = "injector_type must be a non-empty string"
192
+ continue
193
+ injector_type = injector_type.strip()
194
+ injector_type = _canonicalize_chain_type(injector_type)
195
+ item["injector_type"] = injector_type
196
+ feature_name = _resolve_feature_name(injector_type)
197
+ if feature_name is None:
198
+ return make_validation_error(
199
+ f"Unknown chain/injector type '{injector_type}'.",
200
+ invalid_fields={field_name: "Unknown injector_type"},
201
+ )
202
+ feature_data = chain_features.get(feature_name, {})
203
+ aliases = feature_data.get("chains", feature_name)
204
+ aliases = aliases if isinstance(aliases, list) else [aliases]
205
+ enabled = context and context["enabled_chains"]
206
+ supports = context and context["supports_chain"]
207
+ if not enabled or not any(alias in enabled for alias in [feature_name, *aliases]):
208
+ return make_error(
209
+ "FEATURE_NOT_SUPPORTED",
210
+ f"Model '{params['model']}' does not support chain '{injector_type}'.",
211
+ {"model": params["model"], "chain": injector_type},
212
+ )
213
+ if supports and not any(supports(alias) for alias in [injector_type, feature_name, *aliases]):
214
+ return make_error(
215
+ "FEATURE_NOT_SUPPORTED",
216
+ f"Model '{params['model']}' does not support chain '{injector_type}'.",
217
+ {"model": params["model"], "chain": injector_type},
218
+ )
219
+
220
+ if invalid_fields:
221
+ return make_validation_error("Request contains invalid parameters.", invalid_fields=invalid_fields)
222
+
223
  try:
224
+ validate_image_plan(params)
225
+ except ImageInputError as exc:
226
+ return make_validation_error(str(exc), invalid_fields={"image_plan": str(exc)})
227
+
228
+ batch_size = params.get("batch_size", 1)
229
  if not 1 <= batch_size <= CONFIG.max_batch_size:
230
  return make_validation_error(
231
  f"batch_size must be between 1 and {CONFIG.max_batch_size}.",
tests/test_gpu_boundary.py CHANGED
@@ -168,6 +168,20 @@ class GpuBoundaryTests(unittest.TestCase):
168
  self.assertNotIn("_cancel_event", self.payloads[0]["ui_inputs"])
169
  self.release_models.assert_not_called()
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  def test_cancellation_after_download_stays_on_cpu(self):
172
  cancellation = threading.Event()
173
  self.download.side_effect = lambda *args, **kwargs: cancellation.set()
 
168
  self.assertNotIn("_cancel_event", self.payloads[0]["ui_inputs"])
169
  self.release_models.assert_not_called()
170
 
171
+ def test_unsupported_stale_reference_does_not_block_img2img(self):
172
+ from PIL import Image
173
+
174
+ self.pipeline.run(self.inputs(task_type="img2img", img2img_image=Image.new("RGB", (64, 64)), qwen_image_edit_data=[Image.new("RGB", (32, 32))], positive_prompt="保留图1"), _LockedProgress())
175
+ self.download.assert_called_once()
176
+ self.assertEqual(self.payloads[0]["ui_inputs"]["qwen_image_edit_data"], [])
177
+ self.assertEqual(self.payloads[0]["ui_inputs"]["positive_prompt"], "保留image 1")
178
+
179
+ def test_invalid_numbered_reference_rejected_before_download_and_gpu(self):
180
+ with self.assertRaisesRegex(RuntimeError, "本次只有 0"):
181
+ self.pipeline.run(self.inputs(positive_prompt="img2"), _LockedProgress())
182
+ self.download.assert_not_called()
183
+ self.assertEqual(self.payloads, [])
184
+
185
  def test_cancellation_after_download_stays_on_cpu(self):
186
  cancellation = threading.Event()
187
  self.download.side_effect = lambda *args, **kwargs: cancellation.set()
tests/test_image_inputs.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+ import itertools
5
+ import types
6
+ from unittest import mock
7
+
8
+ from PIL import Image
9
+
10
+ from core.execution_plan import available_run_modes, build_execution_plan, ExecutionPlanError
11
+ from core.reference_inputs import normalize_image_references, reference_choices
12
+ from mcp_tools.get_model_features import handle_get_model_features
13
+ from mcp_tools.run import handle_run
14
+ from tests.test_execution_plan import base_inputs
15
+
16
+
17
+ EDIT = "lightx2v/Qwen-Image-Edit-2511-Lightning"
18
+ ANIMA = "circlestone-labs/Anima-Turbo-v1.0"
19
+
20
+
21
+ class ImageInputContractTests(unittest.TestCase):
22
+ def test_aliases_are_one_based_and_idempotent(self):
23
+ prompt = "将图1图2合并;img1 在左、IMG2在右;image 1 保留主体,参考图2提供背景。"
24
+ expected = "将image 1 image 2合并;image 1 在左、image 2在右;image 1 保留主体,image 2提供背景。"
25
+ self.assertEqual(normalize_image_references(prompt, 2), expected)
26
+ self.assertEqual(normalize_image_references(expected, 2), expected)
27
+
28
+ def test_filenames_identifiers_and_quoted_literals_are_not_rewritten(self):
29
+ prompt = 'img1.png /images/img2 myimg3 img4_suffix "图9" “img8” `image7` 「图6」'
30
+ self.assertEqual(normalize_image_references(prompt, 0), prompt)
31
+
32
+ def test_chinese_compound_words_are_not_image_aliases(self):
33
+ prompts = (
34
+ "电影感,横向构图16:9,日落风景",
35
+ "横向构图 16:9,地图1:10000,草图2张,插图3幅",
36
+ "蓝图1份,位图2张,视图3个,绘图4幅,截图5张,贴图6张",
37
+ )
38
+ for prompt in prompts:
39
+ for count in (0, 2):
40
+ with self.subTest(prompt=prompt, count=count):
41
+ self.assertEqual(normalize_image_references(prompt, count), prompt)
42
+ plan = build_execution_plan(base_inputs(positive_prompt=prompt))
43
+ self.assertEqual(plan[0].inputs["positive_prompt"], prompt)
44
+
45
+ def test_explicit_aliases_next_to_chinese_text_and_numeric_details_still_work(self):
46
+ prompt = "横向构图16:9;保留图1,图1图2合并;图1:2只猫,参考图2:1只狗,图片1和img2。"
47
+ expected = "横向构图16:9;保留image 1,image 1 image 2合并;image 1:2只猫,image 2:1只狗,image 1和image 2。"
48
+ self.assertEqual(normalize_image_references(prompt, 2), expected)
49
+ self.assertEqual(normalize_image_references(expected, 2), expected)
50
+ with self.assertRaisesRegex(ExecutionPlanError, "本次只有 2"):
51
+ normalize_image_references("横向构图16:9;保留图3", 2)
52
+
53
+ def test_missing_and_cross_image_references_fail_early(self):
54
+ for prompt in ("图0", "img0", "图3", "IMG3", "image 3"):
55
+ with self.subTest(prompt=prompt), self.assertRaisesRegex(ExecutionPlanError, "本次只有 2"):
56
+ normalize_image_references(prompt, 2)
57
+ with self.assertRaisesRegex(ExecutionPlanError, "当前图片"):
58
+ normalize_image_references("保留图1", 1, independent=True)
59
+
60
+ def test_high_level_list_excludes_stale_slots_without_mutating_them(self):
61
+ stale = [Image.new("RGB", (8, 8), "black"), Image.new("RGB", (8, 8), "white")]
62
+ pictures = [Image.new("RGB", (8, 8), "red"), Image.new("RGB", (8, 8), "blue")]
63
+ values = base_inputs(model_display_name=EDIT, task_type="reference", positive_prompt="图1左、img2右", qwen_image_edit_data=stale)
64
+ plan = build_execution_plan(values, images=pictures)
65
+ self.assertEqual(values["qwen_image_edit_data"], stale)
66
+ self.assertEqual(values["positive_prompt"], "图1左、img2右")
67
+ self.assertEqual(plan[0].inputs["qwen_image_edit_data"], pictures)
68
+ self.assertEqual(plan[0].inputs["positive_prompt"], "image 1左、image 2右")
69
+ self.assertEqual(plan[0].inputs["task_type"], "txt2img")
70
+ self.assertIn("2 张参考图", plan[0].caption)
71
+ self.assertEqual([item["id"] for item in plan[0].inputs["_image_references"]], ["img1", "img2"])
72
+ reordered = build_execution_plan(values, images=list(reversed(pictures)))
73
+ self.assertIs(reordered[0].inputs["qwen_image_edit_data"][0], pictures[1])
74
+
75
+ def test_reference_limit_never_truncates(self):
76
+ with self.assertRaisesRegex(ExecutionPlanError, "3 张"):
77
+ build_execution_plan(base_inputs(task_type="reference", model_display_name=EDIT), images=["a", "b", "c", "d"])
78
+ with self.assertRaisesRegex(ExecutionPlanError, "不会截断"):
79
+ build_execution_plan(base_inputs(model_display_name=EDIT, qwen_image_edit_data=["a", "b", "c", "d"]))
80
+
81
+ def test_supported_native_slots_compact_and_unsupported_slots_do_not_count(self):
82
+ values = base_inputs(model_display_name=EDIT, positive_prompt="图1", qwen_image_edit_data=[None, "a", None])
83
+ plan = build_execution_plan(values)
84
+ self.assertEqual(len(plan[0].inputs["_image_references"]), 1)
85
+ plan = build_execution_plan(base_inputs(task_type="img2img", model_display_name=ANIMA, img2img_image="source", positive_prompt="图1", qwen_image_edit_data=["old"]))
86
+ self.assertEqual(plan[0].inputs["qwen_image_edit_data"], [])
87
+ self.assertEqual(plan[0].inputs["positive_prompt"], "image 1")
88
+
89
+ def test_discovery_matches_checkpoint_limits_and_modes(self):
90
+ edit = handle_get_model_features(EDIT)["image_workflows"]
91
+ self.assertEqual(edit["reference_roles"]["auto"]["max_images"], 3)
92
+ self.assertNotIn("style", edit["reference_roles"])
93
+ regular = handle_get_model_features("Qwen-Image")["image_workflows"]
94
+ self.assertFalse(regular["reference_supported"])
95
+ self.assertEqual(regular["run_modes_by_task"]["reference"], [])
96
+ self.assertNotIn("multi_reference", [mode for _, mode in available_run_modes("img2img", EDIT)])
97
+ self.assertNotIn("multi_independent", [mode for _, mode in available_run_modes("inpaint", EDIT)])
98
+ self.assertIn("identity", reference_choices("Krea-2-Turbo"))
99
+
100
+ def test_qwen_injector_wires_ordered_images_to_both_prompt_nodes(self):
101
+ from chain_injectors.qwen_image_edit_injector import inject
102
+
103
+ ids = itertools.count(10)
104
+ assembler = types.SimpleNamespace(
105
+ node_map={"ksampler": "1", "pos_prompt": "2", "neg_prompt": "3", "vae_loader": "4"},
106
+ workflow={
107
+ "1": {"class_type": "KSampler", "inputs": {"model": ["5", 0]}},
108
+ "2": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"prompt": "image 1左,image 2右"}},
109
+ "3": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"prompt": ""}},
110
+ "4": {"class_type": "VAELoader", "inputs": {}},
111
+ },
112
+ _get_unique_id=lambda: str(next(ids)),
113
+ _get_node_template=lambda kind: {"class_type": kind, "inputs": {}, "_meta": {}},
114
+ )
115
+ inject(assembler, {}, ["blue.png", "red.png"])
116
+ for node_id in ("2", "3"):
117
+ for i, filename in enumerate(("blue.png", "red.png"), 1):
118
+ scale_id = assembler.workflow[node_id]["inputs"][f"image{i}"][0]
119
+ load_id = assembler.workflow[scale_id]["inputs"]["image"][0]
120
+ self.assertEqual(assembler.workflow[load_id]["inputs"]["image"], filename)
121
+
122
+ def test_multiple_native_namespaces_cannot_share_numbered_prompt(self):
123
+ with self.assertRaisesRegex(ExecutionPlanError, "多个原生参考链"):
124
+ build_execution_plan(base_inputs(positive_prompt="图1", krea2_identity_edit_data=["a"], krea2_reference_edit_data=["b"]))
125
+
126
+
127
+ class ApiImageRequestTests(unittest.TestCase):
128
+ def request(self, **overrides):
129
+ return dict(task_type="reference", model=EDIT, prompt="图1左,img2右", width=512, height=512, async_execution=True, **overrides)
130
+
131
+ def test_numbered_fields_sort_numerically_and_keep_caller_data(self):
132
+ request = self.request(**{"图2": "blue", "IMG1": "red"})
133
+ with mock.patch("mcp_tools.run.submit_background") as submit:
134
+ result = handle_run(request)
135
+ self.assertEqual(result["status"], "queued")
136
+ self.assertEqual(submit.call_args.args[2]["images"], ["red", "blue"])
137
+ self.assertEqual(submit.call_args.args[2]["run_mode"], "multi_reference")
138
+ self.assertEqual(request["图2"], "blue")
139
+ self.assertNotIn("images", request)
140
+
141
+ def test_invalid_image_contract_never_enters_queue(self):
142
+ cases = [
143
+ {"images": ["a"], "img1": "b"}, {"image": "a", "images": ["b"]},
144
+ {"img1": "a", "图1": "b"}, {"img2": "a"}, {"img0": "a"},
145
+ {"images": []}, {"images": [None]}, {"images": [1]},
146
+ {"images": ["a"]}, {"images": ["a", "b", "c", "d"]},
147
+ {"images": ["a", "b"], "reference_role": "style"},
148
+ {"images": ["a", "b"], "chain": [{"injector_type": "qwen_image_edit", "image": "c"}]},
149
+ {"images": ["a", "b"], "run_mode": "nonsense"},
150
+ ]
151
+ for case in cases:
152
+ with self.subTest(case=case), mock.patch("mcp_tools.run.submit_background") as submit:
153
+ result = handle_run(self.request(**case))
154
+ self.assertEqual(result["error"]["code"], "INVALID_PARAMS")
155
+ submit.assert_not_called()
156
+
157
+ def test_legacy_chain_and_single_image_calls_still_queue(self):
158
+ cases = [
159
+ {"task_type": "txt2img", "model": EDIT, "prompt": "img1左,图2右", "width": 512, "height": 512,
160
+ "chain": [{"injector_type": "qwen_image_edit", "image": "a"}, {"injector_type": "qwen_image_edit", "image": "b"}]},
161
+ {"task_type": "img2img", "model": ANIMA, "prompt": "图1改为水彩", "image": "a"},
162
+ {"task_type": "img2img", "model": ANIMA, "prompt": "当前图片转水彩", "images": ["a", "b"], "run_mode": "multi_independent"},
163
+ ]
164
+ for params in cases:
165
+ with self.subTest(params=params), mock.patch("mcp_tools.run.submit_background") as submit:
166
+ result = handle_run({**params, "async_execution": True})
167
+ self.assertEqual(result["status"], "queued")
168
+ submit.assert_called_once()
169
+
170
+
171
+ if __name__ == "__main__":
172
+ unittest.main()
tests/test_mcp_validation.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import unittest
 
4
 
5
  from PIL import Image
6
 
@@ -12,10 +13,28 @@ from mcp_tools.run import handle_run
12
 
13
 
14
  class McpValidationTests(unittest.TestCase):
 
 
 
 
 
 
 
 
 
 
 
 
15
  def test_requires_common_fields(self):
16
  result = handle_run({})
17
  self.assertEqual(result["error"]["code"], "INVALID_PARAMS")
18
 
 
 
 
 
 
 
19
  def test_requires_task_specific_fields_before_execution(self):
20
  result = handle_run(
21
  {
@@ -28,18 +47,164 @@ class McpValidationTests(unittest.TestCase):
28
  self.assertEqual(set(result["error"]["details"]["missing_fields"]), {"width", "height"})
29
 
30
  def test_batch_limit_is_enforced(self):
31
- result = handle_run(
32
- {
33
- "task_type": "txt2img",
34
- "model": "Krea-2-Turbo",
35
- "prompt": "测试",
36
- "width": 1024,
37
- "height": 1024,
38
- "batch_size": 999,
39
- }
40
- )
41
  self.assertEqual(result["error"]["code"], "INVALID_PARAMS")
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  def test_private_image_urls_are_rejected(self):
44
  with self.assertRaises(ValueError):
45
  _validate_public_image_url("http://127.0.0.1/private.png")
@@ -76,6 +241,30 @@ class McpValidationTests(unittest.TestCase):
76
  self.assertNotIn("qwen_image_edit", regular["supported_features"])
77
  self.assertIn("qwen_image_edit", editing["supported_features"])
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
  if __name__ == "__main__":
81
  unittest.main()
 
1
  from __future__ import annotations
2
 
3
  import unittest
4
+ from unittest import mock
5
 
6
  from PIL import Image
7
 
 
13
 
14
 
15
  class McpValidationTests(unittest.TestCase):
16
+ @staticmethod
17
+ def _request(**overrides):
18
+ params = {
19
+ "task_type": "txt2img",
20
+ "model": "Krea-2-Turbo",
21
+ "prompt": "测试",
22
+ "width": 1024,
23
+ "height": 1024,
24
+ }
25
+ params.update(overrides)
26
+ return params
27
+
28
  def test_requires_common_fields(self):
29
  result = handle_run({})
30
  self.assertEqual(result["error"]["code"], "INVALID_PARAMS")
31
 
32
+ blank_prompt = handle_run(self._request(prompt=" "))
33
+ self.assertEqual(blank_prompt["error"]["code"], "INVALID_PARAMS")
34
+ self.assertIn(
35
+ "prompt", blank_prompt["error"]["details"]["invalid_fields"]
36
+ )
37
+
38
  def test_requires_task_specific_fields_before_execution(self):
39
  result = handle_run(
40
  {
 
47
  self.assertEqual(set(result["error"]["details"]["missing_fields"]), {"width", "height"})
48
 
49
  def test_batch_limit_is_enforced(self):
50
+ result = handle_run(self._request(batch_size=999))
 
 
 
 
 
 
 
 
 
51
  self.assertEqual(result["error"]["code"], "INVALID_PARAMS")
52
 
53
+ def test_async_execution_accepts_common_boolean_strings(self):
54
+ for raw_value in ("true", "1", " TRUE "):
55
+ with self.subTest(raw_value=raw_value), mock.patch(
56
+ "mcp_tools.run.submit_background"
57
+ ) as submit:
58
+ result = handle_run(
59
+ self._request(async_execution=raw_value)
60
+ )
61
+ self.assertEqual(result["status"], "queued")
62
+ self.assertIs(submit.call_args.args[2]["async_execution"], True)
63
+
64
+ for raw_value in ("false", "0", " FALSE "):
65
+ with self.subTest(raw_value=raw_value), mock.patch(
66
+ "mcp_tools.run._execute_imagegen_pipeline"
67
+ ) as execute:
68
+ handle_run(self._request(async_execution=raw_value))
69
+ self.assertIs(execute.call_args.args[1]["async_execution"], False)
70
+
71
+ result = handle_run(self._request(async_execution="yes"))
72
+ self.assertEqual(result["error"]["code"], "INVALID_PARAMS")
73
+ self.assertIn(
74
+ "async_execution", result["error"]["details"]["invalid_fields"]
75
+ )
76
+
77
+ def test_trimmed_task_type_is_forwarded_to_execution(self):
78
+ with mock.patch("mcp_tools.run.submit_background") as submit:
79
+ result = handle_run(
80
+ self._request(task_type=" txt2img ", async_execution=True)
81
+ )
82
+ self.assertEqual(result["status"], "queued")
83
+ submitted_params = submit.call_args.args[2]
84
+ self.assertEqual(submitted_params["task_type"], "txt2img")
85
+
86
+ def test_numeric_fields_are_normalized_and_bounded(self):
87
+ with mock.patch("mcp_tools.run.submit_background") as submit:
88
+ accepted = handle_run(
89
+ self._request(
90
+ width="1024",
91
+ height=1024.0,
92
+ steps=20.0,
93
+ batch_size="2",
94
+ async_execution=True,
95
+ )
96
+ )
97
+ self.assertEqual(accepted["status"], "queued")
98
+ submitted = submit.call_args.args[2]
99
+ self.assertEqual(submitted["width"], 1024)
100
+ self.assertEqual(submitted["height"], 1024)
101
+ self.assertEqual(submitted["steps"], 20)
102
+ self.assertEqual(submitted["batch_size"], 2)
103
+
104
+ for field, value in (
105
+ ("height", 0),
106
+ ("denoise", 1.1),
107
+ ("seed", True),
108
+ ("steps", 1.5),
109
+ ):
110
+ with self.subTest(field=field):
111
+ with mock.patch("mcp_tools.run._execute_imagegen_pipeline"):
112
+ result = handle_run(self._request(**{field: value}))
113
+ self.assertEqual(result["error"]["code"], "INVALID_PARAMS")
114
+ self.assertIn(field, result["error"]["details"]["invalid_fields"])
115
+
116
+ def test_empty_optional_numbers_behave_like_omitted_fields(self):
117
+ fields = ("steps", "cfg", "denoise", "batch_size", "seed")
118
+ for empty in (None, "", " \t "):
119
+ for asynchronous in (True, False):
120
+ with self.subTest(empty=empty, asynchronous=asynchronous), mock.patch(
121
+ "mcp_tools.run.submit_background"
122
+ ) as submit, mock.patch("mcp_tools.run._execute_imagegen_pipeline") as execute:
123
+ request = self._request(**dict.fromkeys(fields, empty), async_execution=asynchronous)
124
+ result = handle_run(request)
125
+ if asynchronous:
126
+ self.assertEqual(result["status"], "queued")
127
+ submitted = submit.call_args.args[2]
128
+ else:
129
+ execute.assert_called_once()
130
+ submitted = execute.call_args.args[1]
131
+ for field in fields:
132
+ self.assertNotIn(field, submitted)
133
+ self.assertEqual(request[field], empty)
134
+
135
+ def test_empty_required_numbers_remain_invalid(self):
136
+ requests = (
137
+ ("width", self._request()),
138
+ ("height", self._request()),
139
+ ("pad_left", self._request(task_type="outpaint", image="opaque-source", pad_left=0, pad_right=0, pad_top=0, pad_bottom=0)),
140
+ ("upscale_by", self._request(task_type="hires_fix", image="opaque-source", upscale_by=1.5)),
141
+ )
142
+ for field, request in requests:
143
+ for empty in (None, "", " \t "):
144
+ with self.subTest(field=field, empty=empty), mock.patch("mcp_tools.run.submit_background") as submit:
145
+ result = handle_run({**request, field: empty, "async_execution": True})
146
+ self.assertEqual(result["error"]["code"], "INVALID_PARAMS")
147
+ details = result["error"]["details"]
148
+ self.assertIn(field, [*details.get("missing_fields", []), *details.get("invalid_fields", {})])
149
+ submit.assert_not_called()
150
+
151
+ def test_zero_optional_numbers_are_not_treated_as_empty(self):
152
+ with mock.patch("mcp_tools.run.submit_background") as submit:
153
+ result = handle_run(self._request(cfg=0, denoise=0, seed=0, async_execution=True))
154
+ self.assertEqual(result["status"], "queued")
155
+ submitted = submit.call_args.args[2]
156
+ self.assertEqual({field: submitted[field] for field in ("cfg", "denoise", "seed")}, {"cfg": 0, "denoise": 0, "seed": 0})
157
+ rejected = handle_run(self._request(batch_size=0))
158
+ self.assertEqual(rejected["error"]["code"], "INVALID_PARAMS")
159
+
160
+ def test_chain_shape_and_model_capability_are_validated(self):
161
+ invalid_shape = handle_run(self._request(chain={"injector_type": "lora"}))
162
+ self.assertEqual(invalid_shape["error"]["code"], "INVALID_PARAMS")
163
+
164
+ invalid_item = handle_run(self._request(chain=["lora"]))
165
+ self.assertEqual(invalid_item["error"]["code"], "INVALID_PARAMS")
166
+
167
+ unknown = handle_run(
168
+ self._request(chain=[{"injector_type": "does_not_exist"}])
169
+ )
170
+ self.assertEqual(unknown["error"]["code"], "INVALID_PARAMS")
171
+
172
+ unsupported = handle_run(
173
+ self._request(chain=[{"injector_type": "qwen_image_edit"}])
174
+ )
175
+ self.assertEqual(unsupported["error"]["code"], "FEATURE_NOT_SUPPORTED")
176
+
177
+ def test_supported_chain_is_forwarded_without_schema_policing(self):
178
+ request = self._request(
179
+ chain=[
180
+ {
181
+ "injector_type": "lora",
182
+ "source": "Civitai",
183
+ "lora_value": "123",
184
+ "custom_field": "kept-for-forward-compatibility",
185
+ }
186
+ ],
187
+ async_execution=True,
188
+ )
189
+ with mock.patch("mcp_tools.run.submit_background") as submit:
190
+ result = handle_run(request)
191
+
192
+ self.assertEqual(result["status"], "queued")
193
+ submitted_item = submit.call_args.args[2]["chain"][0]
194
+ self.assertEqual(
195
+ submitted_item["custom_field"], "kept-for-forward-compatibility"
196
+ )
197
+
198
+ def test_legacy_boogu_alias_is_canonicalized(self):
199
+ request = self._request(
200
+ model="Boogu-Image-Edit-Turbo",
201
+ chain=[{"injector_type": "boogu_edit", "image": "opaque-reference"}],
202
+ )
203
+ with mock.patch("mcp_tools.run.submit_background", return_value=None):
204
+ result = handle_run({**request, "async_execution": True})
205
+ self.assertEqual(result["status"], "queued")
206
+ self.assertEqual(request["chain"][0]["injector_type"], "boogu_edit")
207
+
208
  def test_private_image_urls_are_rejected(self):
209
  with self.assertRaises(ValueError):
210
  _validate_public_image_url("http://127.0.0.1/private.png")
 
241
  self.assertNotIn("qwen_image_edit", regular["supported_features"])
242
  self.assertIn("qwen_image_edit", editing["supported_features"])
243
 
244
+ def test_model_features_keep_legacy_task_list(self):
245
+ result = handle_get_model_features("Krea-2-Turbo")
246
+ self.assertNotIn("task_capabilities_known", result)
247
+ self.assertIn("txt2img", result["supported_tasks"])
248
+
249
+ def test_transport_handlers_import_without_gradio(self):
250
+ import subprocess
251
+ import sys
252
+
253
+ command = (
254
+ "import builtins; real_import = builtins.__import__; "
255
+ "builtins.__import__ = lambda name, *a, **k: "
256
+ "(_ for _ in ()).throw(ImportError('blocked')) "
257
+ "if name == 'gradio' else real_import(name, *a, **k); "
258
+ "import mcp_tools; assert callable(mcp_tools.handle_run)"
259
+ )
260
+ result = subprocess.run(
261
+ [sys.executable, "-c", command],
262
+ check=False,
263
+ capture_output=True,
264
+ text=True,
265
+ )
266
+ self.assertEqual(result.returncode, 0, result.stderr)
267
+
268
 
269
  if __name__ == "__main__":
270
  unittest.main()
tests/test_ui_smoke.py CHANGED
@@ -4,6 +4,7 @@ import os
4
  import sys
5
  import types
6
  import unittest
 
7
  from unittest import mock
8
 
9
  os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
@@ -62,6 +63,82 @@ class UnifiedUiSmokeTests(unittest.TestCase):
62
  "get_chain_schema",
63
  },
64
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
  def test_reference_budget_rejects_oversized_advanced_image(self):
67
  from PIL import Image
@@ -75,6 +152,26 @@ class UnifiedUiSmokeTests(unittest.TestCase):
75
  {"reference_latent_data": [Image.new("1", (3000, 2000))]}
76
  )
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  def test_model_download_preflight_preserves_disk_reserve(self):
79
  from imagegen_utils.app_utils import _assert_download_space
80
 
 
4
  import sys
5
  import types
6
  import unittest
7
+ import tempfile
8
  from unittest import mock
9
 
10
  os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
 
63
  "get_chain_schema",
64
  },
65
  )
66
+ input_update = next(fn for fn in app.fns.values() if getattr(fn.fn, "__qualname__", "") == "attach_input_events.<locals>.update")
67
+ args = [component.value for component in input_update.inputs]
68
+ args[:4] = ["reference", "lightx2v/Qwen-Image-Edit-2511-Lightning", "single", "style"]
69
+ updates = input_update.fn(*args)
70
+ self.assertEqual(updates[9]["value"], "multi_reference")
71
+ self.assertEqual(updates[10]["value"], "auto")
72
+ self.assertNotIn("value", updates[15]) # 切任务/模型只改引导,不覆盖用户 Prompt。
73
+ self.assertFalse(updates[1]["visible"])
74
+ self.assertFalse(updates[14]["visible"])
75
+ run = next(fn for fn in app.fns.values() if getattr(fn.fn, "__qualname__", "") == "create_unified_run_event.<locals>.execute")
76
+ args = [component.value for component in run.inputs]
77
+ args[0] = "reference"
78
+ args[1] = "lightx2v/Qwen-Image-Edit-2511-Lightning"
79
+ failure = run.fn(types.SimpleNamespace(session_hash="test-invalid-images"), lambda *_args, **_kwargs: None, *args)
80
+ self.assertEqual(failure[0], gradio.update())
81
+ self.assertIn("本次未完成", failure[1])
82
+ self.assertIn("不是本次新生成", failure[1])
83
+ app.close()
84
+
85
+ def test_input_context_uses_only_visible_inputs_and_current_model(self):
86
+ from ui.events.input_handlers import input_context
87
+ from ui.guidance import model_hint, prompt_guidance
88
+
89
+ edit = "lightx2v/Qwen-Image-Edit-2511-Lightning"
90
+ ref = input_context("reference", edit, "single", "style", ["new-a", "new-b"], "old-source", {"qwen_image_edit_data": ["old-ref"]})
91
+ self.assertEqual(ref["mode"], "multi_reference")
92
+ self.assertEqual(ref["role"], "auto")
93
+ self.assertEqual([image for image, _ in ref["preview"]], ["new-a", "new-b"])
94
+ self.assertFalse(ref["native"])
95
+ unsupported = input_context("reference", "Qwen-Image", "multi_reference", "auto")
96
+ self.assertFalse(unsupported["can_run"])
97
+ self.assertNotIn("Qwen 图片编辑", model_hint("Qwen-Image"))
98
+ independent = input_context("img2img", edit, "multi_independent", "auto", ["new"], "old")
99
+ self.assertEqual(independent["preview"][0][0], "new")
100
+ self.assertIn("当前图片", independent["prompt"]["help"])
101
+ self.assertNotEqual(prompt_guidance("txt2img", "single", edit)["examples"], prompt_guidance("reference", "multi_reference", edit)["examples"])
102
+
103
+ def test_api_reference_legacy_and_batch_execution_use_shared_plan(self):
104
+ from PIL import Image
105
+ import gradio.processing_utils as pu
106
+ from core import settings
107
+ from mcp_tools import common
108
+ from mcp_tools.run import handle_run
109
+
110
+ red, blue = Image.new("RGB", (32, 32), "red"), Image.new("RGB", (48, 32), "blue")
111
+ cases = [
112
+ {"task_type": "reference", "img1": red, "图2": blue, "prompt": "图1左,img2右"},
113
+ {"task_type": "txt2img", "chain": [{"injector_type": "qwen_image_edit", "image": red}, {"injector_type": "qwen_image_edit", "image": blue}], "prompt": "图1左,img2右"},
114
+ {"task_type": "img2img", "images": [red, blue], "run_mode": "multi_independent", "prompt": "当前图片转水彩"},
115
+ {"task_type": "img2img", "image": red, "prompt": "图1转水彩"},
116
+ ]
117
+ for case in cases:
118
+ called = []
119
+
120
+ def generate(ui_inputs, progress):
121
+ called.append(ui_inputs)
122
+ return [red.copy()]
123
+
124
+ with self.subTest(task=case["task_type"]), tempfile.TemporaryDirectory() as tmp, mock.patch.object(settings, "OUTPUT_DIR", tmp), mock.patch.object(
125
+ sys.modules["core.generation_logic"], "sd_image_pipeline", types.SimpleNamespace(run=generate), create=True
126
+ ), mock.patch.object(pu, "save_file_to_cache", side_effect=lambda path, cache_dir: path), mock.patch.dict(common._TASKS_DB, {}, clear=True):
127
+ response = handle_run({"model": "lightx2v/Qwen-Image-Edit-2511-Lightning", "width": 512, "height": 512, "seed": 123, **case})
128
+ self.assertEqual(response["status"], "completed", response)
129
+ result = response["result"]
130
+ self.assertEqual(len(result["images"]), len(called))
131
+ self.assertFalse(result["partial_success"])
132
+ self.assertEqual(result["seed"], 123)
133
+ if case["task_type"] in {"reference", "txt2img"}:
134
+ self.assertEqual(len(called), 1)
135
+ self.assertEqual(called[0]["positive_prompt"], "image 1左,image 2右")
136
+ self.assertEqual([img.getpixel((0, 0)) for img in called[0]["qwen_image_edit_data"]], [(255, 0, 0), (0, 0, 255)])
137
+ self.assertEqual(len(result["image_references"]), 2)
138
+ elif case.get("images"):
139
+ self.assertEqual(len(called), 2)
140
+ self.assertEqual([values["img2img_image"].size for values in called], [(32, 32), (48, 32)])
141
+ self.assertEqual(called[0]["img2img_denoise"], 0.7)
142
 
143
  def test_reference_budget_rejects_oversized_advanced_image(self):
144
  from PIL import Image
 
152
  {"reference_latent_data": [Image.new("1", (3000, 2000))]}
153
  )
154
 
155
+ def test_api_partial_result_keeps_source_mapping_and_explicit_failure(self):
156
+ from PIL import Image
157
+ import gradio.processing_utils as pu
158
+ from core import settings
159
+ from mcp_tools import common
160
+ from mcp_tools.run import handle_run
161
+
162
+ def generate(ui_inputs, progress):
163
+ return [ui_inputs["img2img_image"]] if ui_inputs["_source_image_index"] == 1 else []
164
+
165
+ with tempfile.TemporaryDirectory() as tmp, mock.patch.object(settings, "OUTPUT_DIR", tmp), mock.patch.object(
166
+ sys.modules["core.generation_logic"], "sd_image_pipeline", types.SimpleNamespace(run=generate), create=True
167
+ ), mock.patch.object(pu, "save_file_to_cache", side_effect=lambda path, cache_dir: path), mock.patch.dict(common._TASKS_DB, {}, clear=True), self.assertLogs("core.execution_plan", level="ERROR"):
168
+ response = handle_run({"task_type": "img2img", "model": "Krea-2-Turbo", "prompt": "当前图片转水彩", "run_mode": "multi_independent", "images": [Image.new("RGB", (32, 32)), Image.new("RGB", (32, 32))]})
169
+ self.assertEqual(response["status"], "completed")
170
+ self.assertEqual(len(response["result"]["images"]), 1)
171
+ self.assertTrue(response["result"]["partial_success"])
172
+ self.assertEqual([job["source_index"] for job in response["result"]["jobs"]], [1, 2])
173
+ self.assertEqual(response["result"]["jobs"][1]["status"], "failed")
174
+
175
  def test_model_download_preflight_preserves_disk_reserve(self):
176
  from imagegen_utils.app_utils import _assert_download_space
177
 
ui/events/input_handlers.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """任务、模型、运行方式和有效图片预览使用同一个输入上下文。"""
2
+
3
+ import gradio as gr
4
+
5
+ from core.execution_plan import available_run_modes
6
+ from core.reference_inputs import active_reference_groups, reference_choices
7
+ from ui.guidance import prompt_guidance, task_help
8
+
9
+
10
+ REFERENCE_COMPONENTS = {
11
+ "reference_latent_data": "all_reference_latent_components_flat",
12
+ "hidream_o1_reference_data": "all_hidream_o1_reference_components_flat",
13
+ "joyai_reference_data": "all_joyai_reference_components_flat",
14
+ "krea2_identity_edit_data": "all_krea2_identity_edit_components_flat",
15
+ "krea2_reference_edit_data": "all_krea2_reference_edit_components_flat",
16
+ "qwen_image_edit_data": "all_qwen_image_edit_components_flat",
17
+ "boogu_edit_data": "all_boogu_edit_components_flat",
18
+ "reference_image_data": "all_reference_image_components_flat",
19
+ }
20
+ ROLE_LABELS = {"auto": "自动匹配模型能力", "identity": "人物 / 身份参考", "style": "风格参考"}
21
+ RUN_LABELS = {"img2img": "开始重绘", "inpaint": "开始局部重绘", "outpaint": "开始扩图", "hires_fix": "开始高清修复"}
22
+
23
+
24
+ def input_context(task, model, mode, role, files=None, source=None, native=None):
25
+ """纯状态计算,供事件和测试共用;不清空用户控件的图片或提示词。"""
26
+ choices = available_run_modes(task, model)
27
+ allowed = [value for _, value in choices]
28
+ selected = mode if mode in allowed else (allowed[0] if allowed else "multi_reference")
29
+ reference = selected == "multi_reference"
30
+ independent = selected in {"multi_independent", "multi_model_grid"}
31
+ pk = selected in {"model_pk", "multi_model_grid"}
32
+ uses_files = reference or independent
33
+ native_visible = task == "txt2img" and selected == "single"
34
+ roles = reference_choices(model)
35
+ selected_role = role if role in roles else "auto"
36
+ info = roles.get(selected_role)
37
+ pictures = []
38
+ if uses_files:
39
+ pictures = [(getattr(value, "name", value), "") for value in (files or [])]
40
+ elif task in {"img2img", "inpaint", "outpaint", "hires_fix"} and source is not None:
41
+ pictures = [(source, "")]
42
+ elif native_visible:
43
+ groups = active_reference_groups({"model_display_name": model, **(native or {})})
44
+ pictures = [(value, chain) for chain, values in groups.items() for value in values]
45
+
46
+ preview = [
47
+ (value, f"输入 {i}(单独处理)" if independent else f"图{i} / img{i}" + (f" · {chain}" if chain else ""))
48
+ for i, (value, chain) in enumerate(pictures, 1)
49
+ ]
50
+ if reference:
51
+ hint = (
52
+ f"**本次有效参考图:{len(pictures)} 张 / 上限 {info['max_images']} 张**。"
53
+ "只使用本列表,按显示顺序编号;拖动可排序。下方旧槽位不参与。"
54
+ if info else "**当前模型不支持参考图编辑 / 融合。** 请选用 Qwen-Image-Edit 等编辑模型;不会自动改用普通重绘。"
55
+ )
56
+ if info and len(pictures) > info["max_images"]:
57
+ hint += " **已超限,请移除图片;不会静默丢图。**"
58
+ elif independent:
59
+ hint = f"**本次输入:{len(pictures)} 张**。逐张处理、共用提示词,不会让某张图参考其他图片。"
60
+ else:
61
+ hint = f"**本次有效图片:{len(pictures)} 张**。" + (
62
+ "图片编号按预览为准,空槽位不编号。" if pictures else "没有参考图;需要编辑素材时选择“参考图编辑 / 融合”。"
63
+ )
64
+ if pk:
65
+ hint += " 模型 PK 仅比较基础模型,忽略高级扩展 / VAE / PiD;Prompt、尺寸、种子共用。"
66
+ return {
67
+ "mode": selected, "choices": choices, "role": selected_role, "roles": roles,
68
+ "reference": reference, "independent": independent, "pk": pk,
69
+ "files": uses_files, "native": native_visible, "preview": preview, "hint": hint,
70
+ "can_run": not reference or bool(info),
71
+ "prompt": prompt_guidance(task, selected, model),
72
+ }
73
+
74
+
75
+ def attach_input_events(prefix, components, demo):
76
+ def component(name):
77
+ return components[f"{name}_{prefix}"]
78
+
79
+ native_lists = [(key, components.get(f"{name}_{prefix}", [])) for key, name in REFERENCE_COMPONENTS.items()]
80
+ inputs = [component(name) for name in ("task_type", "base_model", "run_mode", "reference_role", "batch_images", "source_image", "inpaint_image_dict")]
81
+ inputs.extend(value for _, values in native_lists for value in values)
82
+
83
+ def update(task, model, mode, role, files, source, inpaint, *native_values):
84
+ index = 0
85
+ native = {}
86
+ for key, values in native_lists:
87
+ native[key] = list(native_values[index:index + len(values)])
88
+ index += len(values)
89
+ state = input_context(task, model, mode, role, files, (inpaint or {}).get("background") if task == "inpaint" else source, native)
90
+ prompt = state["prompt"]
91
+ mode_update = {"choices": state["choices"] or [("请选择支持参考图的模型", "multi_reference")]}
92
+ if mode != state["mode"]:
93
+ mode_update["value"] = state["mode"]
94
+ role_update = {
95
+ "choices": [(ROLE_LABELS[key], key) for key in state["roles"]] or [(ROLE_LABELS["auto"], "auto")],
96
+ "visible": state["reference"],
97
+ }
98
+ if role != state["role"]:
99
+ role_update["value"] = state["role"]
100
+ return (
101
+ task_help("reference" if state["reference"] else task),
102
+ gr.update(visible=task in {"img2img", "outpaint", "hires_fix"} and not state["files"]),
103
+ gr.update(label="源图片(图1 / img1)"),
104
+ gr.update(visible=task == "inpaint"),
105
+ gr.update(visible=task == "img2img"),
106
+ gr.update(visible=task == "outpaint"),
107
+ gr.update(visible=task == "hires_fix"),
108
+ gr.update(visible=task == "txt2img" or state["reference"]),
109
+ gr.update(value="开始编辑 / 融合" if state["reference"] else RUN_LABELS.get(task, "开始生成"), interactive=state["can_run"]),
110
+ gr.update(**mode_update), gr.update(**role_update),
111
+ gr.update(visible=state["pk"]), gr.update(visible=state["files"]),
112
+ gr.update(visible=state["pk"]), gr.update(visible=state["native"]),
113
+ gr.update(label=prompt["label"], placeholder=prompt["placeholder"]),
114
+ prompt["help"], gr.update(samples=prompt["examples"]),
115
+ state["hint"], gr.update(value=state["preview"], visible=bool(state["preview"])),
116
+ )
117
+
118
+ outputs = [component(name) for name in (
119
+ "task_help", "source_panel", "source_image", "inpaint_panel", "img2img_panel",
120
+ "outpaint_panel", "hires_panel", "size_panel", "run", "run_mode", "reference_role",
121
+ "pk_models", "batch_images", "pk_model_defaults", "native_reference_panel",
122
+ "prompt", "prompt_help", "prompt_examples", "input_hint", "input_preview",
123
+ )]
124
+ gr.on(
125
+ triggers=[value.change for value in inputs], fn=update, inputs=inputs, outputs=outputs,
126
+ queue=False, show_progress="hidden", api_name=False, show_api=False,
127
+ )
128
+ demo.load(update, inputs, outputs, queue=False, show_progress="hidden", api_name=False, show_api=False)
129
+ component("prompt_examples").click(
130
+ lambda values: values[0], [component("prompt_examples")], [component("prompt")],
131
+ queue=False, show_progress="hidden", api_name=False, show_api=False,
132
+ )
ui/events/main.py CHANGED
@@ -4,7 +4,7 @@ from __future__ import annotations
4
 
5
  import gradio as gr
6
 
7
- from ui.guidance import model_hint, recommended_params, task_help
8
  from .chain_handlers import (
9
  create_anima_controlnet_lllite_event_handlers,
10
  create_boogu_edit_event_handlers,
@@ -33,6 +33,7 @@ from .change_handlers import (
33
  run_on_load,
34
  )
35
  from .run_handlers import create_run_event, create_unified_run_event
 
36
 
37
 
38
  CHAIN_EVENT_FACTORIES = (
@@ -63,33 +64,6 @@ def _append_if(target, component):
63
  target.append(component)
64
 
65
 
66
- def _task_switch_values(task_type):
67
- uses_source = task_type in {"img2img", "outpaint", "hires_fix"}
68
- labels = {
69
- "img2img": "源图片(图生图)",
70
- "outpaint": "源图片(扩图)",
71
- "hires_fix": "源图片(高清修复)",
72
- }
73
- run_labels = {
74
- "txt2img": "开始生成",
75
- "img2img": "开始重绘",
76
- "inpaint": "开始局部重绘",
77
- "outpaint": "开始扩图",
78
- "hires_fix": "开始高清修复",
79
- }
80
- return (
81
- task_help(task_type),
82
- gr.update(visible=uses_source),
83
- gr.update(label=labels.get(task_type, "源图片")),
84
- gr.update(visible=task_type == "inpaint"),
85
- gr.update(visible=task_type == "img2img"),
86
- gr.update(visible=task_type == "outpaint"),
87
- gr.update(visible=task_type == "hires_fix"),
88
- gr.update(visible=task_type == "txt2img"),
89
- gr.update(value=run_labels.get(task_type, "开始生成")),
90
- )
91
-
92
-
93
  def _select_quick_preset(preset):
94
  if preset == "__manual__":
95
  return gr.update()
@@ -283,25 +257,7 @@ def attach_event_handlers(ui_components, demo):
283
  )
284
 
285
  if task_type is None:
286
- task_component = ui_components[f"task_type_{prefix}"]
287
- task_component.change(
288
- _task_switch_values,
289
- [task_component],
290
- [
291
- ui_components[f"task_help_{prefix}"],
292
- ui_components[f"source_panel_{prefix}"],
293
- ui_components[f"source_image_{prefix}"],
294
- ui_components[f"inpaint_panel_{prefix}"],
295
- ui_components[f"img2img_panel_{prefix}"],
296
- ui_components[f"outpaint_panel_{prefix}"],
297
- ui_components[f"hires_panel_{prefix}"],
298
- ui_components[f"size_panel_{prefix}"],
299
- ui_components[f"run_{prefix}"],
300
- ],
301
- show_progress="hidden",
302
- api_name=False,
303
- show_api=False,
304
- )
305
  preset = ui_components[f"quick_preset_{prefix}"]
306
  preset.change(
307
  _select_quick_preset,
 
4
 
5
  import gradio as gr
6
 
7
+ from ui.guidance import model_hint, recommended_params
8
  from .chain_handlers import (
9
  create_anima_controlnet_lllite_event_handlers,
10
  create_boogu_edit_event_handlers,
 
33
  run_on_load,
34
  )
35
  from .run_handlers import create_run_event, create_unified_run_event
36
+ from .input_handlers import attach_input_events
37
 
38
 
39
  CHAIN_EVENT_FACTORIES = (
 
64
  target.append(component)
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  def _select_quick_preset(preset):
68
  if preset == "__manual__":
69
  return gr.update()
 
257
  )
258
 
259
  if task_type is None:
260
+ attach_input_events(prefix, ui_components, demo)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  preset = ui_components[f"quick_preset_{prefix}"]
262
  preset.change(
263
  _select_quick_preset,
ui/events/run_handlers.py CHANGED
@@ -14,6 +14,7 @@ from core.execution_plan import (
14
  from core.generation_logic import generate_image_wrapper
15
  from core.runtime_config import CONFIG
16
  from core.task_scheduler import TaskCancelledError
 
17
 
18
 
19
  _CANCEL_LOCK = threading.Lock()
@@ -222,6 +223,8 @@ def create_unified_run_event(prefix: str, ui_components: dict):
222
 
223
  task_type = values["task_type"]
224
  run_mode = values.get("_run_mode", "single")
 
 
225
  source_image = values.pop("source_image", None)
226
  batch_driven = run_mode in {MODE_MULTI_INDEPENDENT, MODE_MULTI_MODEL_GRID}
227
  if task_type == "img2img" and not batch_driven:
@@ -246,35 +249,6 @@ def create_unified_run_event(prefix: str, ui_components: dict):
246
  result_gallery = ui_components[f"result_{prefix}"]
247
  run_summary = ui_components[f"run_summary_{prefix}"]
248
 
249
- def change_run_mode(mode):
250
- is_pk = mode in {MODE_MODEL_PK, MODE_MULTI_MODEL_GRID}
251
- uses_files = mode in {
252
- MODE_MULTI_INDEPENDENT,
253
- MODE_MULTI_MODEL_GRID,
254
- MODE_MULTI_REFERENCE,
255
- }
256
- return (
257
- gr.update(visible=is_pk),
258
- gr.update(visible=uses_files),
259
- gr.update(visible=mode == MODE_MULTI_REFERENCE),
260
- gr.update(visible=is_pk),
261
- )
262
-
263
- ui_components[f"run_mode_{prefix}"].change(
264
- fn=change_run_mode,
265
- inputs=[ui_components[f"run_mode_{prefix}"]],
266
- outputs=[
267
- ui_components[f"pk_models_{prefix}"],
268
- ui_components[f"batch_images_{prefix}"],
269
- ui_components[f"reference_role_{prefix}"],
270
- ui_components[f"pk_model_defaults_{prefix}"],
271
- ],
272
- queue=False,
273
- show_progress="hidden",
274
- api_name=False,
275
- show_api=False,
276
- )
277
-
278
  def execute(
279
  request: gr.Request,
280
  progress=gr.Progress(track_tqdm=True),
@@ -288,6 +262,8 @@ def create_unified_run_event(prefix: str, ui_components: dict):
288
  values = create_inputs(*args)
289
  values["_cancel_event"] = cancel_event
290
  run_mode = values.pop("_run_mode", "single")
 
 
291
  pk_models = values.pop("_pk_models", []) or []
292
  uploaded_files = values.pop("_batch_images", []) or []
293
  reference_role = values.pop("_reference_role", "auto") or "auto"
@@ -317,7 +293,10 @@ def create_unified_run_event(prefix: str, ui_components: dict):
317
  cancel_event=cancel_event,
318
  )
319
  except (TaskCancelledError, ExecutionPlanError) as exc:
320
- raise gr.Error(str(exc)) from exc
 
 
 
321
  finally:
322
  with _CANCEL_LOCK:
323
  if _CANCEL_EVENTS.get(key) is cancel_event:
 
14
  from core.generation_logic import generate_image_wrapper
15
  from core.runtime_config import CONFIG
16
  from core.task_scheduler import TaskCancelledError
17
+ from core.reference_inputs import clear_native_references
18
 
19
 
20
  _CANCEL_LOCK = threading.Lock()
 
223
 
224
  task_type = values["task_type"]
225
  run_mode = values.get("_run_mode", "single")
226
+ if task_type != "txt2img" or run_mode != "single":
227
+ clear_native_references(values)
228
  source_image = values.pop("source_image", None)
229
  batch_driven = run_mode in {MODE_MULTI_INDEPENDENT, MODE_MULTI_MODEL_GRID}
230
  if task_type == "img2img" and not batch_driven:
 
249
  result_gallery = ui_components[f"result_{prefix}"]
250
  run_summary = ui_components[f"run_summary_{prefix}"]
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  def execute(
253
  request: gr.Request,
254
  progress=gr.Progress(track_tqdm=True),
 
262
  values = create_inputs(*args)
263
  values["_cancel_event"] = cancel_event
264
  run_mode = values.pop("_run_mode", "single")
265
+ if values["task_type"] == "reference":
266
+ run_mode = MODE_MULTI_REFERENCE
267
  pk_models = values.pop("_pk_models", []) or []
268
  uploaded_files = values.pop("_batch_images", []) or []
269
  reference_role = values.pop("_reference_role", "auto") or "auto"
 
293
  cancel_event=cancel_event,
294
  )
295
  except (TaskCancelledError, ExecutionPlanError) as exc:
296
+ return gr.update(), (
297
+ f"### 本次未完成\n\n{exc}\n\n"
298
+ "已保留之前的成功结果(如有);它们不是本次新生成的图片。"
299
+ )
300
  finally:
301
  with _CANCEL_LOCK:
302
  if _CANCEL_EVENTS.get(key) is cancel_event:
ui/guidance.py CHANGED
@@ -4,15 +4,16 @@ from __future__ import annotations
4
 
5
  from core.settings import (
6
  ARCHITECTURES_CONFIG,
7
- FEATURES_CONFIG,
8
  MODEL_DEFAULTS_CONFIG,
9
  MODEL_MAP_CHECKPOINT,
10
  MODEL_TYPE_MAP,
11
  )
12
  from imagegen_utils.app_utils import get_model_generation_defaults
 
13
 
14
 
15
  TASK_HELP = {
 
16
  "txt2img": "**文生图**:选择模型,写清主体、动作、构图、风格和光线,再点击生成。",
17
  "img2img": "**图生图**:上传原图;重绘幅度越低越接近原图,越高改动越大。",
18
  "inpaint": "**局部重绘**:上传图片并涂抹需要修改的区域;提示词只描述希望出现的新内容。",
@@ -73,7 +74,7 @@ def model_hint(model_name: str) -> str:
73
  defaults = get_model_generation_defaults(
74
  model_name, workflow_type, MODEL_DEFAULTS_CONFIG
75
  )
76
- features = FEATURES_CONFIG.get(workflow_type, {}).get("enabled_chains", [])
77
  feature_text = "、".join(FEATURE_NAMES.get(item, item) for item in features[:6]) or "基础生成"
78
  if len(features) > 6:
79
  feature_text += f" 等 {len(features)} 项"
@@ -88,7 +89,7 @@ def model_hint(model_name: str) -> str:
88
 
89
  edit_tip = ""
90
  if "edit" in model_name.lower():
91
- edit_tip = " 这是编辑向模型,配合下方自动出现的参考图能力使用。"
92
 
93
  return (
94
  f"**{architecture} · 推荐参数**:{defaults.get('steps', 20)} 步,"
@@ -120,3 +121,39 @@ def recommended_params(model_name: str) -> tuple:
120
  defaults.get("sampler_name", "euler"),
121
  defaults.get("scheduler", "simple"),
122
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  from core.settings import (
6
  ARCHITECTURES_CONFIG,
 
7
  MODEL_DEFAULTS_CONFIG,
8
  MODEL_MAP_CHECKPOINT,
9
  MODEL_TYPE_MAP,
10
  )
11
  from imagegen_utils.app_utils import get_model_generation_defaults
12
+ from core.model_capabilities import enabled_chains_for_model
13
 
14
 
15
  TASK_HELP = {
16
+ "reference": "**参考图编辑 / 融合**:一张图按指令修改,多张图整组参考;选择编辑模型,按顺序上传图片,再描述各图用途和要保留、修改的内容。",
17
  "txt2img": "**文生图**:选择模型,写清主体、动作、构图、风格和光线,再点击生成。",
18
  "img2img": "**图生图**:上传原图;重绘幅度越低越接近原图,越高改动越大。",
19
  "inpaint": "**局部重绘**:上传图片并涂抹需要修改的区域;提示词只描述希望出现的新内容。",
 
74
  defaults = get_model_generation_defaults(
75
  model_name, workflow_type, MODEL_DEFAULTS_CONFIG
76
  )
77
+ features = enabled_chains_for_model(model_name)
78
  feature_text = "、".join(FEATURE_NAMES.get(item, item) for item in features[:6]) or "基础生成"
79
  if len(features) > 6:
80
  feature_text += f" 等 {len(features)} 项"
 
89
 
90
  edit_tip = ""
91
  if "edit" in model_name.lower():
92
+ edit_tip = " 这是编辑向模型,推荐选择“参考图编辑 / 融合”任务。"
93
 
94
  return (
95
  f"**{architecture} · 推荐参数**:{defaults.get('steps', 20)} 步,"
 
121
  defaults.get("sampler_name", "euler"),
122
  defaults.get("scheduler", "simple"),
123
  )
124
+
125
+
126
+ def prompt_guidance(task_type: str, mode: str, model_name: str) -> dict:
127
+ reference = task_type == "reference" or mode == "multi_reference"
128
+ independent = mode in {"multi_independent", "multi_model_grid"}
129
+ if reference:
130
+ label = "3. 说明每张图的用途和修改要求"
131
+ placeholder = "保留图1的主体,参考图2的背景;写清哪些不变、哪些修改。只有一张图时只写图1。"
132
+ examples = [
133
+ ["保留图1的主体、构图和光线,将背景改成纯白色。"],
134
+ ["保留 img1 的主体,参考 img2 的背景和光线,生成自然融合的新画面。"],
135
+ ]
136
+ help_text = "图1 = img1 = image1,对应有效图片预览顺序;编号统一成 image 1 送入模型。第二条示例需要两张图。"
137
+ elif task_type == "txt2img":
138
+ label, placeholder = "3. 描述你想要的画面", "主体 + 动作 + 场景 + 构图 + 风格 + 光线"
139
+ if MODEL_TYPE_MAP.get(model_name) in {"Anima", "SDXL", "SD1.5", "NewBie-Image"}:
140
+ examples = [["1girl, silver hair, train station, rainy night, upper body, cinematic lighting, detailed illustration"],
141
+ ["cityscape, night, neon lights, wet street, reflections, low angle, depth of field"]]
142
+ else:
143
+ examples = [["现代东方客厅,浅木色与米白配色,午后自然光,广角室内摄影"],
144
+ ["未来城市夜景,湿润街道倒影,霓虹灯,低机位,电影概念设计"]]
145
+ help_text = "描述最终画面;要按图片修改或融合,请选择“参考图编辑 / 融合”。"
146
+ else:
147
+ label, placeholder = "3. 描述修改后的画面", "要保留的主体和构图 + 希望改变的部分;改动大小还受重绘幅度控制"
148
+ example = {
149
+ "img2img": "将当前图片转换为水彩插画,保留主体轮廓和构图,柔和色彩。",
150
+ "inpaint": "将涂抹区域替换为一束白色鲜花,匹配周围光线与透视。",
151
+ "outpaint": "向画面外延续现有背景,保持原有透视、色调与光照,衔接自然。",
152
+ "hires_fix": "保留当前图片的主体、配色和构图,提升细节清晰度,避免新增元素。",
153
+ }.get(task_type, "保留当前图片的主体,背景改为纯白。")
154
+ examples = [[example]]
155
+ help_text = "普通图生图是整体重绘,不等于精确指令编辑;低重绘幅度可能保留你想改变的内容。"
156
+ if independent:
157
+ help_text = "每张源图单独执行,共用同一条提示词;请写“当前图片”,不要用图1/img2跨图引用。需要两图同时参与请选参考图编辑 / 融合。"
158
+ help_text += " 不自动翻译或扩写;引号/反引号内的编号当作画面文字,不解析。"
159
+ return {"label": label, "placeholder": placeholder, "examples": examples, "help": help_text}
ui/layout.py CHANGED
@@ -28,7 +28,7 @@ def build_ui(event_handler_function):
28
  """
29
  <section id="studio-hero">
30
  <h1>ImageGen Studio</h1>
31
- <p>一套界面完成文生图、图生图、局部重绘、扩图和高清修复。模型与任务切换会保留你的提示词和已上传素材。</p>
32
  </section>
33
  <section class="studio-steps" aria-label="快速开始">
34
  <div class="studio-step"><b>① 选任务</b><span>先决定生成、编辑,还是修复图片</span></div>
 
28
  """
29
  <section id="studio-hero">
30
  <h1>ImageGen Studio</h1>
31
+ <p>一套界面完成文生图、参考图编辑 / 融合、整体重绘、扩图和修复。切换任务与模型会保留提示词和素材,仅当前有效输入参与生成。</p>
32
  </section>
33
  <section class="studio-steps" aria-label="快速开始">
34
  <div class="studio-step"><b>① 选任务</b><span>先决定生成、编辑,还是修复图片</span></div>
ui/shared/studio_ui.py CHANGED
@@ -5,7 +5,7 @@ from __future__ import annotations
5
  import gradio as gr
6
 
7
  from comfy_integration.nodes import SAMPLER_CHOICES, SCHEDULER_CHOICES
8
- from core.execution_plan import RUN_MODE_CHOICES
9
  from core.runtime_config import CONFIG
10
  from core.settings import (
11
  ARCHITECTURES_CONFIG,
@@ -14,7 +14,7 @@ from core.settings import (
14
  MODEL_TYPE_MAP,
15
  RESOLUTION_MAP,
16
  )
17
- from ui.guidance import QUICK_PRESETS, model_hint, task_help
18
  from .ui_components import (
19
  create_anima_controlnet_lllite_ui,
20
  create_boogu_edit_ui,
@@ -44,24 +44,19 @@ from .ui_components import (
44
 
45
  TASK_CHOICES = [
46
  ("✨ 文生图", "txt2img"),
47
- ("🖼️ 图生图", "img2img"),
 
48
  ("🖌️ 局部重绘", "inpaint"),
49
  ("↔️ 扩图", "outpaint"),
50
  ("🔎 高清修复", "hires_fix"),
51
  ]
52
 
53
- PROMPT_EXAMPLES = [
54
- ["一位银发少女站在雨夜车站,半身构图,电影感侧光,细腻插画,高质量"],
55
- ["现代东方客厅,浅木色与米白配色,午后自然光,广角室内摄影"],
56
- ["未来城市夜景,湿润街道倒影,霓虹灯,低机位,强烈纵深,电影概念设计"],
57
- ]
58
-
59
-
60
  def create_ui() -> dict:
61
  prefix = "studio"
62
  components: dict = {"_task_prefixes": [(prefix, None)]}
63
  default_values = MODEL_DEFAULTS_CONFIG.get("Default", {})
64
  default_model = next(iter(MODEL_MAP_CHECKPOINT), None)
 
65
  default_architecture = MODEL_TYPE_MAP.get(default_model, "SDXL")
66
  default_architecture_info = ARCHITECTURES_CONFIG.get("architectures", {}).get(
67
  default_architecture, {}
@@ -110,9 +105,9 @@ def create_ui() -> dict:
110
  elem_classes="model-hint",
111
  )
112
 
113
- with gr.Accordion("批量、多图与模型 PK(可选)", open=False):
114
  components[f"run_mode_{prefix}"] = gr.Dropdown(
115
- choices=RUN_MODE_CHOICES,
116
  value="single",
117
  label="运行方式",
118
  info="所有模式都顺序执行,不会同时把多个大模型塞进显存。",
@@ -132,7 +127,7 @@ def create_ui() -> dict:
132
  file_types=["image"],
133
  type="filepath",
134
  allow_reordering=True,
135
- label="批量输入 / 多图参考",
136
  visible=False,
137
  )
138
  with gr.Row():
@@ -152,29 +147,36 @@ def create_ui() -> dict:
152
  info="Prompt、尺寸与种子保持一致;步数和 CFG 按模型推荐值。",
153
  visible=False,
154
  )
155
- gr.Markdown(
156
  "**怎么选:** 多图独立是 A→A′、B→B′;多图×多模型会执行图片和模型的组合;"
157
- "多图融合是把整组图片同时交给 Qwen/Mage-Flow/JoyAI/Boogu/Krea/Flux 等兼容模型。"
 
 
 
 
 
158
  )
159
 
160
  with gr.Group(elem_classes="prompt-card"):
161
  components[f"prompt_{prefix}"] = gr.Textbox(
162
- label="3. 描述你想要的画面",
163
- placeholder="主体 + 动作 + 场景 + 构图 + 风格 + 光线",
164
  lines=4,
165
  max_lines=8,
166
  value=default_values.get("positive_prompt", ""),
167
  )
 
168
  with gr.Accordion("负面提示词(可选)", open=False):
169
  components[f"neg_prompt_{prefix}"] = gr.Textbox(
170
  label="不希望出现的内容",
171
  placeholder="例如:低质量、模糊、多余手指、水印",
172
  lines=2,
173
  value=default_values.get("negative_prompt", ""),
 
174
  )
175
- gr.Examples(
176
- examples=PROMPT_EXAMPLES,
177
- inputs=[components[f"prompt_{prefix}"]],
178
  label="点一下填入示例",
179
  )
180
 
@@ -351,14 +353,17 @@ def create_ui() -> dict:
351
  components.update(create_embedding_ui(prefix))
352
  components.update(create_style_ui(prefix))
353
  components.update(create_conditioning_ui(prefix))
354
- components.update(create_reference_latent_ui(prefix))
355
- components.update(create_hidream_o1_reference_ui(prefix))
356
- components.update(create_joyai_reference_ui(prefix))
357
- components.update(create_krea2_identity_edit_ui(prefix))
358
- components.update(create_krea2_reference_edit_ui(prefix))
359
- components.update(create_qwen_image_edit_ui(prefix))
360
- components.update(create_boogu_edit_ui(prefix))
361
- components.update(create_reference_image_ui(prefix))
 
 
 
362
  components.update(create_vae_override_ui(prefix))
363
  components.update(create_pid_ui(prefix))
364
 
 
5
  import gradio as gr
6
 
7
  from comfy_integration.nodes import SAMPLER_CHOICES, SCHEDULER_CHOICES
8
+ from core.execution_plan import available_run_modes
9
  from core.runtime_config import CONFIG
10
  from core.settings import (
11
  ARCHITECTURES_CONFIG,
 
14
  MODEL_TYPE_MAP,
15
  RESOLUTION_MAP,
16
  )
17
+ from ui.guidance import QUICK_PRESETS, model_hint, prompt_guidance, task_help
18
  from .ui_components import (
19
  create_anima_controlnet_lllite_ui,
20
  create_boogu_edit_ui,
 
44
 
45
  TASK_CHOICES = [
46
  ("✨ 文生图", "txt2img"),
47
+ ("参考图编辑 / 融合", "reference"),
48
+ ("🖼️ 图生图(整体重绘)", "img2img"),
49
  ("🖌️ 局部重绘", "inpaint"),
50
  ("↔️ 扩图", "outpaint"),
51
  ("🔎 高清修复", "hires_fix"),
52
  ]
53
 
 
 
 
 
 
 
 
54
  def create_ui() -> dict:
55
  prefix = "studio"
56
  components: dict = {"_task_prefixes": [(prefix, None)]}
57
  default_values = MODEL_DEFAULTS_CONFIG.get("Default", {})
58
  default_model = next(iter(MODEL_MAP_CHECKPOINT), None)
59
+ prompt_info = prompt_guidance("txt2img", "single", default_model)
60
  default_architecture = MODEL_TYPE_MAP.get(default_model, "SDXL")
61
  default_architecture_info = ARCHITECTURES_CONFIG.get("architectures", {}).get(
62
  default_architecture, {}
 
105
  elem_classes="model-hint",
106
  )
107
 
108
+ with gr.Accordion("图片输入与运行方式", open=True):
109
  components[f"run_mode_{prefix}"] = gr.Dropdown(
110
+ choices=available_run_modes("txt2img", default_model),
111
  value="single",
112
  label="运行方式",
113
  info="所有模式都顺序执行,不会同时把多个大模型塞进显存。",
 
127
  file_types=["image"],
128
  type="filepath",
129
  allow_reordering=True,
130
+ label="图片列表(按顺序编号,可拖动排序)",
131
  visible=False,
132
  )
133
  with gr.Row():
 
147
  info="Prompt、尺寸与种子保持一致;步数和 CFG 按模型推荐值。",
148
  visible=False,
149
  )
150
+ components[f"input_hint_{prefix}"] = gr.Markdown(
151
  "**怎么选:** 多图独立是 A→A′、B→B′;多图×多模型会执行图片和模型的组合;"
152
+ "需要按指令修改或融合素材,请选择上方“参考图编辑 / 融合”。"
153
+ )
154
+ components[f"input_preview_{prefix}"] = gr.Gallery(
155
+ label="本次有效图片(空槽位不编号)", columns=4, rows=1, height=200,
156
+ object_fit="contain", interactive=False, visible=False,
157
+ elem_id="input-image-preview",
158
  )
159
 
160
  with gr.Group(elem_classes="prompt-card"):
161
  components[f"prompt_{prefix}"] = gr.Textbox(
162
+ label=prompt_info["label"],
163
+ placeholder=prompt_info["placeholder"],
164
  lines=4,
165
  max_lines=8,
166
  value=default_values.get("positive_prompt", ""),
167
  )
168
+ components[f"prompt_help_{prefix}"] = gr.Markdown(prompt_info["help"])
169
  with gr.Accordion("负面提示词(可选)", open=False):
170
  components[f"neg_prompt_{prefix}"] = gr.Textbox(
171
  label="不希望出现的内容",
172
  placeholder="例如:低质量、模糊、多余手指、水印",
173
  lines=2,
174
  value=default_values.get("negative_prompt", ""),
175
+ info="是否生效取决于模型和 CFG;CFG=1 的常见采样路径通常不使用负面分支。",
176
  )
177
+ components[f"prompt_examples_{prefix}"] = gr.Dataset(
178
+ samples=prompt_info["examples"],
179
+ components=["textbox"], type="values",
180
  label="点一下填入示例",
181
  )
182
 
 
353
  components.update(create_embedding_ui(prefix))
354
  components.update(create_style_ui(prefix))
355
  components.update(create_conditioning_ui(prefix))
356
+ with gr.Group() as native_reference_panel:
357
+ gr.Markdown("原生参考槽位(兼容旧用法):仅普通文生图使用。参考图编辑 / 融合任务只读取上方图片列表;切换任务保留素材,但不混入本次生成。编号按有效图片预览为准。")
358
+ components.update(create_reference_latent_ui(prefix))
359
+ components.update(create_hidream_o1_reference_ui(prefix))
360
+ components.update(create_joyai_reference_ui(prefix))
361
+ components.update(create_krea2_identity_edit_ui(prefix))
362
+ components.update(create_krea2_reference_edit_ui(prefix))
363
+ components.update(create_qwen_image_edit_ui(prefix))
364
+ components.update(create_boogu_edit_ui(prefix))
365
+ components.update(create_reference_image_ui(prefix))
366
+ components[f"native_reference_panel_{prefix}"] = native_reference_panel
367
  components.update(create_vae_override_ui(prefix))
368
  components.update(create_pid_ui(prefix))
369
 
ui/theme.py CHANGED
@@ -12,6 +12,11 @@ APP_CSS = r"""
12
  margin: 0 auto !important;
13
  }
14
 
 
 
 
 
 
15
  #studio-hero {
16
  padding: 28px 30px;
17
  margin: 14px 0 18px;
 
12
  margin: 0 auto !important;
13
  }
14
 
15
+ #input-image-preview .grid-container {
16
+ grid-template-rows: 160px;
17
+ grid-auto-rows: 160px;
18
+ }
19
+
20
  #studio-hero {
21
  padding: 28px 30px;
22
  margin: 14px 0 18px;