Spaces:
Running on Zero
Running on Zero
Unify image references and guided UI/API workflows
Browse filesSync GitHub commit 07374badda564e1a143363df710bb35476fcf6e3; publish only the image input and API workflow changes.
- README.md +69 -4
- core/execution_plan.py +54 -56
- core/model_capabilities.py +14 -0
- core/pipelines/pipeline_input_processor.py +2 -8
- core/pipelines/sd_image_pipeline.py +5 -11
- core/reference_inputs.py +141 -0
- mcp_tools/__init__.py +14 -7
- mcp_tools/common.py +116 -10
- mcp_tools/get_model_features.py +7 -1
- mcp_tools/get_task_list.py +9 -1
- mcp_tools/image_requests.py +143 -0
- mcp_tools/mcp_gradio_integration.py +2 -2
- mcp_tools/run.py +154 -3
- tests/test_gpu_boundary.py +14 -0
- tests/test_image_inputs.py +172 -0
- tests/test_mcp_validation.py +199 -10
- tests/test_ui_smoke.py +97 -0
- ui/events/input_handlers.py +132 -0
- ui/events/main.py +3 -47
- ui/events/run_handlers.py +9 -30
- ui/guidance.py +40 -3
- ui/layout.py +1 -1
- ui/shared/studio_ui.py +33 -28
- ui/theme.py +5 -0
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 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 构建完整
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 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 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 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 |
-
|
| 357 |
-
values
|
|
|
|
| 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 |
-
|
| 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
|
| 219 |
-
"required_inputs": ["prompt", "image"
|
| 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
|
| 355 |
-
pil_img = _parse_image_param(params
|
| 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 |
-
|
| 739 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 757 |
-
for idx, item in enumerate(
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 104 |
return sanitize_keys(handle_get_model_features(model.strip()))
|
| 105 |
|
| 106 |
def run(json_params: str = "{}") -> dict:
|
| 107 |
-
"""
|
| 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 |
-
|
| 76 |
-
except
|
| 77 |
-
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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>一套界面完成文生图、图
|
| 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
|
| 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 |
-
("
|
|
|
|
| 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("
|
| 114 |
components[f"run_mode_{prefix}"] = gr.Dropdown(
|
| 115 |
-
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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
)
|
| 159 |
|
| 160 |
with gr.Group(elem_classes="prompt-card"):
|
| 161 |
components[f"prompt_{prefix}"] = gr.Textbox(
|
| 162 |
-
label="
|
| 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.
|
| 176 |
-
|
| 177 |
-
|
| 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 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
| 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;
|