File size: 11,743 Bytes
8a28a8d
 
 
 
 
 
 
 
 
 
 
 
ca9a89c
8a28a8d
 
 
 
 
ca9a89c
 
 
8a28a8d
 
ca9a89c
 
8a28a8d
 
 
 
 
 
ca9a89c
 
 
 
 
 
8a28a8d
 
 
 
 
 
 
 
 
 
 
 
ca9a89c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8a28a8d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ca9a89c
 
 
 
 
8a28a8d
 
ca9a89c
8a28a8d
 
 
 
 
 
 
 
ca9a89c
8a28a8d
 
 
 
 
 
 
 
ca9a89c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8a28a8d
ca9a89c
 
 
 
 
8a28a8d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
"""
MCP Tool: run
Unified image generation task submission and execution interface.
"""

import time
import uuid
from core.task_scheduler import QueueFullError, submit_background
from core.runtime_config import CONFIG
from .common import (
    _load_yaml,
    _MODEL_LIST_PATH,
    _CHAIN_FEATURES_PATH,
    _TASK_DEFINITIONS,
    _TASKS_DB,
    _TASKS_LOCK,
    _get_task_snapshot,
    _execute_imagegen_pipeline,
    _get_model_feature_context,
    _canonicalize_chain_type,
    _resolve_feature_name,
)
from .error_schema import make_error, make_validation_error, make_not_found_error
from .image_requests import normalize_image_request, validate_image_plan
from core.reference_inputs import ImageInputError


def handle_run(params: dict) -> dict:
    """Unified image generation task execution interface."""
    if not isinstance(params, dict):
        return make_validation_error("Request params must be an object.")
    params = params.copy()
    if isinstance(params.get("chain"), list):
        params["chain"] = [
            item.copy() if isinstance(item, dict) else item
            for item in params["chain"]
        ]

    missing = []
    for req_field in ["task_type", "model", "prompt"]:
        if req_field not in params or not params[req_field]:
            missing.append(req_field)
    if missing:
        return make_validation_error(
            f"Missing required parameter(s): {', '.join(missing)}",
            missing_fields=missing,
        )

    task_type = params["task_type"]
    if (
        not isinstance(task_type, str)
        or not isinstance(params["model"], str)
        or not isinstance(params["prompt"], str)
        or not task_type.strip()
        or not params["model"].strip()
        or not params["prompt"].strip()
    ):
        return make_validation_error(
            "task_type, model, and prompt must be strings.",
            invalid_fields={
                key: "Expected a non-empty string"
                for key in ("task_type", "model", "prompt")
                if not isinstance(params.get(key), str) or not params.get(key, "").strip()
            },
        )
    task_type = task_type.strip()
    params["task_type"] = task_type
    params["model"] = params["model"].strip()
    valid_tasks = [t["task_type"] for t in _TASK_DEFINITIONS]
    if task_type not in valid_tasks:
        return make_validation_error(
            f"Invalid task_type '{task_type}'. Must be one of {valid_tasks}.",
            invalid_fields={"task_type": f"Must be in {valid_tasks}"},
        )

    model_list = _load_yaml(_MODEL_LIST_PATH)
    checkpoints = model_list.get("Checkpoint", {})
    all_models = set()
    for arch_name, arch_data in checkpoints.items():
        if isinstance(arch_data, dict):
            for m in arch_data.get("models", []):
                all_models.add(m.get("display_name"))

    if params["model"] not in all_models:
        return make_not_found_error("model", params["model"])

    try:
        normalize_image_request(params)
    except ImageInputError as exc:
        return make_validation_error(str(exc), invalid_fields={"images": str(exc)})

    task_requirements = {
        "txt2img": ("width", "height"),
        "reference": ("width", "height", "images"),
        "img2img": ("image",),
        "inpaint": ("image",),
        "outpaint": ("image", "pad_left", "pad_right", "pad_top", "pad_bottom"),
        "hires_fix": ("image", "upscale_by"),
    }
    missing_task_fields = [
        field
        for field in task_requirements.get(task_type, ())
        if not (field == "image" and params.get("images"))
        if field not in params or params[field] is None or params[field] == ""
    ]
    if missing_task_fields:
        return make_validation_error(
            f"Missing required parameter(s) for {task_type}: {', '.join(missing_task_fields)}",
            missing_fields=missing_task_fields,
        )

    invalid_fields = {}

    chain_features = _load_yaml(_CHAIN_FEATURES_PATH)

    def _number(name, *, integer=False, minimum=None, maximum=None):
        if name not in params:
            return
        raw_value = params[name]
        if raw_value is None or (isinstance(raw_value, str) and not raw_value.strip()):
            if name in task_requirements.get(task_type, ()):
                invalid_fields[name] = "Expected a non-empty number"
            else:
                # 可选数值的空值等同于未提供,统一沿用执行层的任务 / 模型默认值。
                params.pop(name)
            return
        if isinstance(raw_value, bool):
            invalid_fields[name] = "Expected a number" if not integer else "Expected an integer"
            return
        try:
            numeric_value = float(raw_value)
        except (TypeError, ValueError):
            invalid_fields[name] = "Expected a number" if not integer else "Expected an integer"
            return
        if numeric_value != numeric_value or numeric_value in (float("inf"), float("-inf")):
            invalid_fields[name] = "Must be finite"
            return
        if integer and not numeric_value.is_integer():
            invalid_fields[name] = "Expected an integer"
            return
        value = int(numeric_value) if integer else numeric_value
        if minimum is not None and value < minimum:
            invalid_fields[name] = f"Must be >= {minimum}"
        elif maximum is not None and value > maximum:
            invalid_fields[name] = f"Must be <= {maximum}"
        else:
            params[name] = value

    for field in ("width", "height"):
        if task_type == "txt2img" or field in params:
            _number(field, integer=True, minimum=1, maximum=8192)
    for field in ("pad_left", "pad_right", "pad_top", "pad_bottom"):
        _number(field, integer=True, minimum=0, maximum=4096)
    _number("upscale_by", minimum=0.01, maximum=4.0)
    _number("denoise", minimum=0.0, maximum=1.0)
    _number("steps", integer=True, minimum=1, maximum=100)
    _number("cfg", minimum=0.0, maximum=100.0)
    _number("batch_size", integer=True, minimum=1, maximum=CONFIG.max_batch_size)
    _number("seed", integer=True, minimum=-1)

    for field in ("sampler", "scheduler", "negative_prompt"):
        if field in params and params[field] is not None and not isinstance(params[field], str):
            invalid_fields[field] = "Expected a string"

    if "async_execution" in params:
        async_execution = params["async_execution"]
        if isinstance(async_execution, str):
            normalized = async_execution.strip().casefold()
            if normalized in {"true", "1"}:
                params["async_execution"] = True
            elif normalized in {"false", "0"}:
                params["async_execution"] = False
            else:
                invalid_fields["async_execution"] = "Expected true/false or 1/0"
        elif not isinstance(async_execution, bool):
            invalid_fields["async_execution"] = "Expected a boolean"

    if "chain" in params:
        chain = params["chain"]
        if not isinstance(chain, list):
            invalid_fields["chain"] = "Expected a list of objects"
        else:
            context = _get_model_feature_context(params["model"])
            for index, item in enumerate(chain):
                field_name = f"chain[{index}]"
                if not isinstance(item, dict):
                    invalid_fields[field_name] = "Expected an object"
                    continue
                injector_type = item.get("injector_type")
                if not isinstance(injector_type, str) or not injector_type.strip():
                    invalid_fields[field_name] = "injector_type must be a non-empty string"
                    continue
                injector_type = injector_type.strip()
                injector_type = _canonicalize_chain_type(injector_type)
                item["injector_type"] = injector_type
                feature_name = _resolve_feature_name(injector_type)
                if feature_name is None:
                    return make_validation_error(
                        f"Unknown chain/injector type '{injector_type}'.",
                        invalid_fields={field_name: "Unknown injector_type"},
                    )
                feature_data = chain_features.get(feature_name, {})
                aliases = feature_data.get("chains", feature_name)
                aliases = aliases if isinstance(aliases, list) else [aliases]
                enabled = context and context["enabled_chains"]
                supports = context and context["supports_chain"]
                if not enabled or not any(alias in enabled for alias in [feature_name, *aliases]):
                    return make_error(
                        "FEATURE_NOT_SUPPORTED",
                        f"Model '{params['model']}' does not support chain '{injector_type}'.",
                        {"model": params["model"], "chain": injector_type},
                    )
                if supports and not any(supports(alias) for alias in [injector_type, feature_name, *aliases]):
                    return make_error(
                        "FEATURE_NOT_SUPPORTED",
                        f"Model '{params['model']}' does not support chain '{injector_type}'.",
                        {"model": params["model"], "chain": injector_type},
                    )

    if invalid_fields:
        return make_validation_error("Request contains invalid parameters.", invalid_fields=invalid_fields)

    try:
        validate_image_plan(params)
    except ImageInputError as exc:
        return make_validation_error(str(exc), invalid_fields={"image_plan": str(exc)})

    batch_size = params.get("batch_size", 1)
    if not 1 <= batch_size <= CONFIG.max_batch_size:
        return make_validation_error(
            f"batch_size must be between 1 and {CONFIG.max_batch_size}.",
            invalid_fields={"batch_size": f"Expected 1..{CONFIG.max_batch_size}"},
        )

    with _TASKS_LOCK:
        if len(_TASKS_DB) >= CONFIG.mcp_task_retention:
            finished = sorted(
                (
                    (old_task_id, task)
                    for old_task_id, task in _TASKS_DB.items()
                    if task.get("status") in {"completed", "failed"}
                ),
                key=lambda pair: pair[1].get("completed_at", pair[1].get("failed_at", 0)),
            )
            remove_count = max(1, len(_TASKS_DB) - CONFIG.mcp_task_retention + 1)
            for old_task_id, _ in finished[:remove_count]:
                _TASKS_DB.pop(old_task_id, None)
            if len(_TASKS_DB) >= CONFIG.mcp_task_retention:
                return make_error(
                    "QUEUE_FULL",
                    "任务记录已满且当前任务均未结束,请稍后再试。",
                )

        task_id = f"img_task_{uuid.uuid4().hex[:10]}"
        created_at = int(time.time())
        _TASKS_DB[task_id] = {
            "task_id": task_id,
            "status": "queued",
            "progress": 0,
            "created_at": created_at,
        }

    async_exec = params.get("async_execution", False)

    if async_exec:
        try:
            submit_background(_execute_imagegen_pipeline, task_id, params)
        except QueueFullError as exc:
            with _TASKS_LOCK:
                _TASKS_DB.pop(task_id, None)
            return make_error("QUEUE_FULL", str(exc))
        return {
            "status": "queued",
            "task_id": task_id,
            "poll_interval_ms": 2000,
            "message": "Task queued successfully. Poll get_task_status for results.",
        }
    else:
        _execute_imagegen_pipeline(task_id, params)
        return _get_task_snapshot(task_id)