comfygit-studio 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- comfygit_studio/__init__.py +10 -0
- comfygit_studio/api_schema.py +564 -0
- comfygit_studio/embedded.py +295 -0
- comfygit_studio/executor.py +1002 -0
- comfygit_studio/openapi/studio-contract-api.v1.json +1379 -0
- comfygit_studio/runtime.py +3155 -0
- comfygit_studio/state.py +1019 -0
- comfygit_studio/static/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- comfygit_studio/static/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- comfygit_studio/static/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- comfygit_studio/static/assets/index-BDmIh9tA.css +1 -0
- comfygit_studio/static/assets/index-BlnDFmNd.js +17 -0
- comfygit_studio/static/index.html +14 -0
- comfygit_studio-0.5.0.dist-info/METADATA +25 -0
- comfygit_studio-0.5.0.dist-info/RECORD +16 -0
- comfygit_studio-0.5.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
"""Execution strategies for the ComfyGit serve runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import mimetypes
|
|
8
|
+
import struct
|
|
9
|
+
import uuid
|
|
10
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Protocol, cast
|
|
14
|
+
from urllib.parse import quote, urlencode
|
|
15
|
+
|
|
16
|
+
import aiohttp
|
|
17
|
+
from comfygit_core.models import WorkflowContractOutput
|
|
18
|
+
from comfygit_core.workflow import extract_contract_outputs
|
|
19
|
+
|
|
20
|
+
OUTPUT_RESPONSE_HEADERS = (
|
|
21
|
+
"accept-ranges",
|
|
22
|
+
"content-range",
|
|
23
|
+
"etag",
|
|
24
|
+
"last-modified",
|
|
25
|
+
)
|
|
26
|
+
COMFYUI_CLIENT_ID_PREFIX = "comfygit-serve"
|
|
27
|
+
PROXY_AUTH_HEADER = "Authorization"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ComfyGitServeTimeoutError(Exception):
|
|
31
|
+
"""Raised when a submitted ComfyUI prompt does not finish in time."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ComfyUIRequestError(Exception):
|
|
35
|
+
"""Raised when ComfyUI returns a structured non-2xx API response."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, status: int, url: str, payload: Any) -> None:
|
|
38
|
+
self.status = status
|
|
39
|
+
self.url = url
|
|
40
|
+
self.payload = payload
|
|
41
|
+
super().__init__(_comfyui_error_message(payload) or f"ComfyUI returned HTTP {status}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ComfyUIExecutionError(Exception):
|
|
45
|
+
"""Raised when ComfyUI accepts a prompt but history reports execution failure."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, prompt_id: str, history: Mapping[str, Any], message: str) -> None:
|
|
48
|
+
self.prompt_id = prompt_id
|
|
49
|
+
self.history = history
|
|
50
|
+
self.payload = _comfyui_history_error_payload(prompt_id, history, message)
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class StagedUpload:
|
|
56
|
+
"""A front-door uploaded file that must be staged into a runtime proxy."""
|
|
57
|
+
|
|
58
|
+
input_name: str
|
|
59
|
+
path: Path
|
|
60
|
+
filename: str
|
|
61
|
+
content_type: str
|
|
62
|
+
size: int | None
|
|
63
|
+
comfyui_filename: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class RunExecutionRequest:
|
|
68
|
+
"""A contract prompt ready to execute through a serve executor."""
|
|
69
|
+
|
|
70
|
+
prompt: dict[str, dict[str, Any]]
|
|
71
|
+
outputs: tuple[Any, ...]
|
|
72
|
+
wait: bool
|
|
73
|
+
timeout_seconds: float
|
|
74
|
+
poll_interval_seconds: float
|
|
75
|
+
cache_token: str
|
|
76
|
+
on_submitted: Callable[[str], Awaitable[None]] | None = None
|
|
77
|
+
staged_uploads: tuple[StagedUpload, ...] = ()
|
|
78
|
+
callback_run_id: str | None = None
|
|
79
|
+
callback_url: str | None = None
|
|
80
|
+
callback_token: str | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class RunExecutionResult:
|
|
85
|
+
"""Executor result normalized back into the serve contract shape."""
|
|
86
|
+
|
|
87
|
+
status: str
|
|
88
|
+
prompt_id: str
|
|
89
|
+
outputs: list[dict[str, Any]] = field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class ServeOutputResponse:
|
|
94
|
+
"""Raw ComfyUI output response data proxied through `cg serve`."""
|
|
95
|
+
|
|
96
|
+
body: bytes
|
|
97
|
+
content_type: str
|
|
98
|
+
disposition: str | None = None
|
|
99
|
+
status: int = 200
|
|
100
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class RunExecutor(Protocol):
|
|
104
|
+
"""Serve-owned execution strategy boundary."""
|
|
105
|
+
|
|
106
|
+
async def execute(self, request: RunExecutionRequest) -> RunExecutionResult:
|
|
107
|
+
"""Execute a contract prompt and return normalized output payloads."""
|
|
108
|
+
raise NotImplementedError
|
|
109
|
+
|
|
110
|
+
async def complete_submitted(
|
|
111
|
+
self,
|
|
112
|
+
prompt_id: str,
|
|
113
|
+
outputs: tuple[Any, ...],
|
|
114
|
+
*,
|
|
115
|
+
timeout_seconds: float,
|
|
116
|
+
poll_interval_seconds: float,
|
|
117
|
+
) -> RunExecutionResult:
|
|
118
|
+
"""Wait for a previously submitted prompt and normalize its outputs."""
|
|
119
|
+
raise NotImplementedError
|
|
120
|
+
|
|
121
|
+
async def cancel(self, prompt_id: str) -> None:
|
|
122
|
+
"""Request cancellation for a previously submitted prompt."""
|
|
123
|
+
raise NotImplementedError
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ComfyUIClient:
|
|
127
|
+
"""Small async HTTP client for the ComfyUI API used by `cg serve`."""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
base_url: str,
|
|
132
|
+
timeout: float = 10.0,
|
|
133
|
+
session: aiohttp.ClientSession | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
self.base_url = base_url.rstrip("/")
|
|
136
|
+
self.timeout = timeout
|
|
137
|
+
self._session = session
|
|
138
|
+
|
|
139
|
+
async def check_health(self) -> dict[str, Any]:
|
|
140
|
+
return await self._request_json("GET", "/system_stats", timeout=2)
|
|
141
|
+
|
|
142
|
+
async def submit_prompt(self, prompt: dict[str, dict[str, Any]]) -> str:
|
|
143
|
+
payload = await self._request_json(
|
|
144
|
+
"POST",
|
|
145
|
+
"/prompt",
|
|
146
|
+
json_data={"prompt": prompt, "client_id": _new_comfyui_client_id()},
|
|
147
|
+
)
|
|
148
|
+
prompt_id = payload.get("prompt_id")
|
|
149
|
+
if not prompt_id:
|
|
150
|
+
raise RuntimeError(f"ComfyUI did not return a prompt_id: {payload}")
|
|
151
|
+
return str(prompt_id)
|
|
152
|
+
|
|
153
|
+
async def get_history(self, prompt_id: str) -> dict[str, Any] | None:
|
|
154
|
+
payload = await self._request_json("GET", f"/history/{prompt_id}")
|
|
155
|
+
if isinstance(payload, dict) and prompt_id in payload:
|
|
156
|
+
history = payload[prompt_id]
|
|
157
|
+
return history if isinstance(history, dict) else None
|
|
158
|
+
return payload if isinstance(payload, dict) and payload else None
|
|
159
|
+
|
|
160
|
+
async def delete_queued_prompt(self, prompt_id: str) -> None:
|
|
161
|
+
await self._request_json("POST", "/queue", json_data={"delete": [prompt_id]})
|
|
162
|
+
|
|
163
|
+
async def interrupt_prompt(self, prompt_id: str) -> None:
|
|
164
|
+
await self._request_json("POST", "/interrupt", json_data={"prompt_id": prompt_id})
|
|
165
|
+
|
|
166
|
+
async def fetch_output(
|
|
167
|
+
self,
|
|
168
|
+
params: Mapping[str, str],
|
|
169
|
+
request_headers: Mapping[str, str] | None = None,
|
|
170
|
+
) -> ServeOutputResponse:
|
|
171
|
+
url = f"{self.base_url}/view"
|
|
172
|
+
request_timeout = aiohttp.ClientTimeout(total=self.timeout)
|
|
173
|
+
if self._session is not None:
|
|
174
|
+
return await self._fetch_output_with_session(
|
|
175
|
+
self._session,
|
|
176
|
+
url,
|
|
177
|
+
params=params,
|
|
178
|
+
request_headers=request_headers,
|
|
179
|
+
timeout=request_timeout,
|
|
180
|
+
)
|
|
181
|
+
async with aiohttp.ClientSession() as session:
|
|
182
|
+
return await self._fetch_output_with_session(
|
|
183
|
+
session,
|
|
184
|
+
url,
|
|
185
|
+
params=params,
|
|
186
|
+
request_headers=request_headers,
|
|
187
|
+
timeout=request_timeout,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def output_view_url(self, params: Mapping[str, str]) -> str:
|
|
191
|
+
return f"{self.base_url}/view?{urlencode(params)}"
|
|
192
|
+
|
|
193
|
+
async def wait_for_history(
|
|
194
|
+
self,
|
|
195
|
+
prompt_id: str,
|
|
196
|
+
*,
|
|
197
|
+
timeout_seconds: float,
|
|
198
|
+
poll_interval_seconds: float,
|
|
199
|
+
) -> dict[str, Any]:
|
|
200
|
+
deadline = asyncio.get_running_loop().time() + timeout_seconds
|
|
201
|
+
while asyncio.get_running_loop().time() < deadline:
|
|
202
|
+
history = await self.get_history(prompt_id)
|
|
203
|
+
if history:
|
|
204
|
+
return history
|
|
205
|
+
await asyncio.sleep(poll_interval_seconds)
|
|
206
|
+
raise ComfyGitServeTimeoutError(f"Timed out waiting for ComfyUI prompt {prompt_id}")
|
|
207
|
+
|
|
208
|
+
async def _request_json(
|
|
209
|
+
self,
|
|
210
|
+
method: str,
|
|
211
|
+
path: str,
|
|
212
|
+
*,
|
|
213
|
+
json_data: dict[str, Any] | None = None,
|
|
214
|
+
data: aiohttp.FormData | None = None,
|
|
215
|
+
timeout: float | None = None,
|
|
216
|
+
) -> dict[str, Any]:
|
|
217
|
+
url = f"{self.base_url}{path}"
|
|
218
|
+
request_timeout = aiohttp.ClientTimeout(total=timeout or self.timeout)
|
|
219
|
+
if self._session is not None:
|
|
220
|
+
return await self._request_json_with_session(
|
|
221
|
+
self._session,
|
|
222
|
+
method,
|
|
223
|
+
url,
|
|
224
|
+
json_data=json_data,
|
|
225
|
+
data=data,
|
|
226
|
+
timeout=request_timeout,
|
|
227
|
+
)
|
|
228
|
+
async with aiohttp.ClientSession() as session:
|
|
229
|
+
return await self._request_json_with_session(
|
|
230
|
+
session,
|
|
231
|
+
method,
|
|
232
|
+
url,
|
|
233
|
+
json_data=json_data,
|
|
234
|
+
data=data,
|
|
235
|
+
timeout=request_timeout,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
async def _request_json_with_session(
|
|
239
|
+
self,
|
|
240
|
+
session: aiohttp.ClientSession,
|
|
241
|
+
method: str,
|
|
242
|
+
url: str,
|
|
243
|
+
*,
|
|
244
|
+
json_data: dict[str, Any] | None,
|
|
245
|
+
data: aiohttp.FormData | None,
|
|
246
|
+
timeout: aiohttp.ClientTimeout,
|
|
247
|
+
) -> dict[str, Any]:
|
|
248
|
+
async with session.request(
|
|
249
|
+
method,
|
|
250
|
+
url,
|
|
251
|
+
json=json_data,
|
|
252
|
+
data=data,
|
|
253
|
+
timeout=timeout,
|
|
254
|
+
) as response:
|
|
255
|
+
payload = await _response_payload(response)
|
|
256
|
+
if response.status >= 400:
|
|
257
|
+
raise ComfyUIRequestError(response.status, url, payload)
|
|
258
|
+
return payload if isinstance(payload, dict) else {}
|
|
259
|
+
|
|
260
|
+
async def _fetch_output_with_session(
|
|
261
|
+
self,
|
|
262
|
+
session: aiohttp.ClientSession,
|
|
263
|
+
url: str,
|
|
264
|
+
*,
|
|
265
|
+
params: Mapping[str, str],
|
|
266
|
+
request_headers: Mapping[str, str] | None,
|
|
267
|
+
timeout: aiohttp.ClientTimeout,
|
|
268
|
+
) -> ServeOutputResponse:
|
|
269
|
+
async with session.get(url, params=params, headers=request_headers, timeout=timeout) as response:
|
|
270
|
+
response.raise_for_status()
|
|
271
|
+
body = await response.read()
|
|
272
|
+
content_type = response.headers.get("content-type") or "application/octet-stream"
|
|
273
|
+
disposition = response.headers.get("content-disposition")
|
|
274
|
+
headers = {
|
|
275
|
+
name: response.headers[name]
|
|
276
|
+
for name in OUTPUT_RESPONSE_HEADERS
|
|
277
|
+
if name in response.headers
|
|
278
|
+
}
|
|
279
|
+
status = response.status
|
|
280
|
+
return ServeOutputResponse(
|
|
281
|
+
body=body,
|
|
282
|
+
content_type=content_type,
|
|
283
|
+
disposition=disposition,
|
|
284
|
+
status=status,
|
|
285
|
+
headers=headers,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class LocalComfyExecutor:
|
|
290
|
+
"""Run contract prompts against the configured local ComfyUI HTTP API."""
|
|
291
|
+
|
|
292
|
+
def __init__(self, client: ComfyUIClient, *, artifact_dir: Path | None = None) -> None:
|
|
293
|
+
self._client = client
|
|
294
|
+
self._artifact_dir = artifact_dir
|
|
295
|
+
|
|
296
|
+
async def execute(self, request: RunExecutionRequest) -> RunExecutionResult:
|
|
297
|
+
_stamp_output_cache_busters(request.prompt, request.outputs, request.cache_token)
|
|
298
|
+
prompt_id = await self._client.submit_prompt(request.prompt)
|
|
299
|
+
if request.on_submitted is not None:
|
|
300
|
+
await request.on_submitted(prompt_id)
|
|
301
|
+
if not request.wait:
|
|
302
|
+
return RunExecutionResult(status="submitted", prompt_id=prompt_id)
|
|
303
|
+
|
|
304
|
+
return await self.complete_submitted(
|
|
305
|
+
prompt_id,
|
|
306
|
+
request.outputs,
|
|
307
|
+
timeout_seconds=request.timeout_seconds,
|
|
308
|
+
poll_interval_seconds=request.poll_interval_seconds,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
async def complete_submitted(
|
|
312
|
+
self,
|
|
313
|
+
prompt_id: str,
|
|
314
|
+
outputs: tuple[Any, ...],
|
|
315
|
+
*,
|
|
316
|
+
timeout_seconds: float,
|
|
317
|
+
poll_interval_seconds: float,
|
|
318
|
+
) -> RunExecutionResult:
|
|
319
|
+
history = await self._client.wait_for_history(
|
|
320
|
+
prompt_id,
|
|
321
|
+
timeout_seconds=timeout_seconds,
|
|
322
|
+
poll_interval_seconds=poll_interval_seconds,
|
|
323
|
+
)
|
|
324
|
+
error_message = _comfyui_history_error_message(history)
|
|
325
|
+
if error_message:
|
|
326
|
+
raise ComfyUIExecutionError(prompt_id, history, error_message)
|
|
327
|
+
extracted_outputs = extract_contract_outputs(outputs, history)
|
|
328
|
+
output_payloads = [_contract_output_payload(output) for output in extracted_outputs]
|
|
329
|
+
await _attach_artifact_dimensions(self._client, output_payloads)
|
|
330
|
+
if self._artifact_dir is not None:
|
|
331
|
+
await _localize_client_outputs(
|
|
332
|
+
self._client,
|
|
333
|
+
prompt_id,
|
|
334
|
+
output_payloads,
|
|
335
|
+
self._artifact_dir,
|
|
336
|
+
temp_only=True,
|
|
337
|
+
)
|
|
338
|
+
return RunExecutionResult(status="completed", prompt_id=prompt_id, outputs=output_payloads)
|
|
339
|
+
|
|
340
|
+
async def cancel(self, prompt_id: str) -> None:
|
|
341
|
+
await self._client.delete_queued_prompt(prompt_id)
|
|
342
|
+
await self._client.interrupt_prompt(prompt_id)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class ProxyComfyExecutor:
|
|
346
|
+
"""Run contract prompts through a remote `cg serve --role proxy` runtime."""
|
|
347
|
+
|
|
348
|
+
def __init__(
|
|
349
|
+
self,
|
|
350
|
+
base_url: str,
|
|
351
|
+
*,
|
|
352
|
+
session: aiohttp.ClientSession | None = None,
|
|
353
|
+
token: str | None = None,
|
|
354
|
+
artifact_dir: Path,
|
|
355
|
+
) -> None:
|
|
356
|
+
self.base_url = base_url.rstrip("/")
|
|
357
|
+
self._session = session
|
|
358
|
+
self._token = token
|
|
359
|
+
self._artifact_dir = artifact_dir
|
|
360
|
+
|
|
361
|
+
async def check_health(self) -> dict[str, Any]:
|
|
362
|
+
return await self._request_json("GET", "/proxy/health", timeout=5)
|
|
363
|
+
|
|
364
|
+
async def execute(self, request: RunExecutionRequest) -> RunExecutionResult:
|
|
365
|
+
_stamp_output_cache_busters(request.prompt, request.outputs, request.cache_token)
|
|
366
|
+
payload = {
|
|
367
|
+
"prompt": request.prompt,
|
|
368
|
+
"outputs": [_workflow_contract_output_payload(output) for output in request.outputs],
|
|
369
|
+
"wait": request.wait,
|
|
370
|
+
"timeout_seconds": request.timeout_seconds,
|
|
371
|
+
"poll_interval_seconds": request.poll_interval_seconds,
|
|
372
|
+
"cache_token": request.cache_token,
|
|
373
|
+
"uploads": [_staged_upload_payload(upload, index) for index, upload in enumerate(request.staged_uploads)],
|
|
374
|
+
}
|
|
375
|
+
if request.callback_run_id and request.callback_url:
|
|
376
|
+
callback_payload: dict[str, Any] = {
|
|
377
|
+
"run_id": request.callback_run_id,
|
|
378
|
+
"url": request.callback_url,
|
|
379
|
+
}
|
|
380
|
+
if request.callback_token:
|
|
381
|
+
callback_payload["token"] = request.callback_token
|
|
382
|
+
payload["callback"] = callback_payload
|
|
383
|
+
response = await self._post_run(payload, request.staged_uploads, timeout_seconds=request.timeout_seconds)
|
|
384
|
+
prompt_id = str(response.get("prompt_id") or "")
|
|
385
|
+
if not prompt_id:
|
|
386
|
+
raise RuntimeError(f"Proxy runtime did not return a prompt_id: {response}")
|
|
387
|
+
if request.on_submitted is not None:
|
|
388
|
+
await request.on_submitted(prompt_id)
|
|
389
|
+
if not request.wait:
|
|
390
|
+
return RunExecutionResult(status=str(response.get("status") or "submitted"), prompt_id=prompt_id)
|
|
391
|
+
return await self.complete_submitted(
|
|
392
|
+
prompt_id,
|
|
393
|
+
request.outputs,
|
|
394
|
+
timeout_seconds=request.timeout_seconds,
|
|
395
|
+
poll_interval_seconds=request.poll_interval_seconds,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
async def complete_submitted(
|
|
399
|
+
self,
|
|
400
|
+
prompt_id: str,
|
|
401
|
+
outputs: tuple[Any, ...],
|
|
402
|
+
*,
|
|
403
|
+
timeout_seconds: float,
|
|
404
|
+
poll_interval_seconds: float,
|
|
405
|
+
) -> RunExecutionResult:
|
|
406
|
+
del outputs
|
|
407
|
+
deadline = asyncio.get_running_loop().time() + timeout_seconds
|
|
408
|
+
while asyncio.get_running_loop().time() < deadline:
|
|
409
|
+
payload = await self._request_json("GET", f"/proxy/runs/{prompt_id}", timeout=10)
|
|
410
|
+
status = str(payload.get("status") or "").lower()
|
|
411
|
+
if status == "completed":
|
|
412
|
+
output_payloads = [
|
|
413
|
+
dict(output)
|
|
414
|
+
for output in payload.get("outputs", [])
|
|
415
|
+
if isinstance(output, Mapping)
|
|
416
|
+
]
|
|
417
|
+
await self._localize_proxy_outputs(prompt_id, output_payloads)
|
|
418
|
+
return RunExecutionResult(status="completed", prompt_id=prompt_id, outputs=output_payloads)
|
|
419
|
+
if status in {"error", "failed"}:
|
|
420
|
+
message = str(payload.get("message") or payload.get("error") or "Proxy execution failed")
|
|
421
|
+
raise ComfyUIExecutionError(prompt_id, payload, message)
|
|
422
|
+
if status == "cancelled":
|
|
423
|
+
return RunExecutionResult(status="cancelled", prompt_id=prompt_id)
|
|
424
|
+
await asyncio.sleep(poll_interval_seconds)
|
|
425
|
+
raise ComfyGitServeTimeoutError(f"Timed out waiting for proxy prompt {prompt_id}")
|
|
426
|
+
|
|
427
|
+
async def cancel(self, prompt_id: str) -> None:
|
|
428
|
+
await self._request_json("POST", f"/proxy/runs/{prompt_id}/cancel", timeout=10)
|
|
429
|
+
|
|
430
|
+
async def _post_run(
|
|
431
|
+
self,
|
|
432
|
+
payload: Mapping[str, Any],
|
|
433
|
+
staged_uploads: tuple[StagedUpload, ...],
|
|
434
|
+
*,
|
|
435
|
+
timeout_seconds: float,
|
|
436
|
+
) -> dict[str, Any]:
|
|
437
|
+
if not staged_uploads:
|
|
438
|
+
return await self._request_json("POST", "/proxy/runs", json_data=dict(payload), timeout=timeout_seconds)
|
|
439
|
+
|
|
440
|
+
form = aiohttp.FormData()
|
|
441
|
+
form.add_field("payload", json.dumps(payload), content_type="application/json")
|
|
442
|
+
handles = []
|
|
443
|
+
try:
|
|
444
|
+
for index, upload in enumerate(staged_uploads):
|
|
445
|
+
handle = upload.path.open("rb")
|
|
446
|
+
handles.append(handle)
|
|
447
|
+
form.add_field(
|
|
448
|
+
f"file_{index}",
|
|
449
|
+
handle,
|
|
450
|
+
filename=upload.comfyui_filename,
|
|
451
|
+
content_type=upload.content_type,
|
|
452
|
+
)
|
|
453
|
+
return await self._request_json("POST", "/proxy/runs", data=form, timeout=timeout_seconds)
|
|
454
|
+
finally:
|
|
455
|
+
for handle in handles:
|
|
456
|
+
handle.close()
|
|
457
|
+
|
|
458
|
+
async def _request_json(
|
|
459
|
+
self,
|
|
460
|
+
method: str,
|
|
461
|
+
path: str,
|
|
462
|
+
*,
|
|
463
|
+
json_data: dict[str, Any] | None = None,
|
|
464
|
+
data: aiohttp.FormData | None = None,
|
|
465
|
+
timeout: float | None = None,
|
|
466
|
+
) -> dict[str, Any]:
|
|
467
|
+
url = f"{self.base_url}{path}"
|
|
468
|
+
request_timeout = aiohttp.ClientTimeout(total=timeout or 10)
|
|
469
|
+
headers = self._headers()
|
|
470
|
+
if self._session is not None:
|
|
471
|
+
return await self._request_json_with_session(
|
|
472
|
+
self._session,
|
|
473
|
+
method,
|
|
474
|
+
url,
|
|
475
|
+
json_data=json_data,
|
|
476
|
+
data=data,
|
|
477
|
+
headers=headers,
|
|
478
|
+
timeout=request_timeout,
|
|
479
|
+
)
|
|
480
|
+
async with aiohttp.ClientSession() as session:
|
|
481
|
+
return await self._request_json_with_session(
|
|
482
|
+
session,
|
|
483
|
+
method,
|
|
484
|
+
url,
|
|
485
|
+
json_data=json_data,
|
|
486
|
+
data=data,
|
|
487
|
+
headers=headers,
|
|
488
|
+
timeout=request_timeout,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
async def _request_json_with_session(
|
|
492
|
+
self,
|
|
493
|
+
session: aiohttp.ClientSession,
|
|
494
|
+
method: str,
|
|
495
|
+
url: str,
|
|
496
|
+
*,
|
|
497
|
+
json_data: dict[str, Any] | None,
|
|
498
|
+
data: aiohttp.FormData | None,
|
|
499
|
+
headers: Mapping[str, str],
|
|
500
|
+
timeout: aiohttp.ClientTimeout,
|
|
501
|
+
) -> dict[str, Any]:
|
|
502
|
+
async with session.request(
|
|
503
|
+
method,
|
|
504
|
+
url,
|
|
505
|
+
json=json_data,
|
|
506
|
+
data=data,
|
|
507
|
+
headers=headers,
|
|
508
|
+
timeout=timeout,
|
|
509
|
+
) as response:
|
|
510
|
+
payload = await _response_payload(response)
|
|
511
|
+
if response.status >= 400:
|
|
512
|
+
raise ComfyUIRequestError(response.status, url, payload)
|
|
513
|
+
return payload if isinstance(payload, dict) else {}
|
|
514
|
+
|
|
515
|
+
async def _fetch_artifact(self, artifact_id: str) -> ServeOutputResponse:
|
|
516
|
+
url = f"{self.base_url}/proxy/artifacts/{quote(artifact_id, safe='')}"
|
|
517
|
+
request_timeout = aiohttp.ClientTimeout(total=60)
|
|
518
|
+
headers = self._headers()
|
|
519
|
+
if self._session is not None:
|
|
520
|
+
return await self._fetch_artifact_with_session(self._session, url, headers, request_timeout)
|
|
521
|
+
async with aiohttp.ClientSession() as session:
|
|
522
|
+
return await self._fetch_artifact_with_session(session, url, headers, request_timeout)
|
|
523
|
+
|
|
524
|
+
async def _fetch_artifact_with_session(
|
|
525
|
+
self,
|
|
526
|
+
session: aiohttp.ClientSession,
|
|
527
|
+
url: str,
|
|
528
|
+
headers: Mapping[str, str],
|
|
529
|
+
timeout: aiohttp.ClientTimeout,
|
|
530
|
+
) -> ServeOutputResponse:
|
|
531
|
+
async with session.get(url, headers=headers, timeout=timeout) as response:
|
|
532
|
+
response.raise_for_status()
|
|
533
|
+
return ServeOutputResponse(
|
|
534
|
+
body=await response.read(),
|
|
535
|
+
content_type=response.headers.get("content-type") or "application/octet-stream",
|
|
536
|
+
disposition=response.headers.get("content-disposition"),
|
|
537
|
+
status=response.status,
|
|
538
|
+
headers={
|
|
539
|
+
name: response.headers[name]
|
|
540
|
+
for name in OUTPUT_RESPONSE_HEADERS
|
|
541
|
+
if name in response.headers
|
|
542
|
+
},
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
async def _localize_proxy_outputs(self, prompt_id: str, outputs: list[dict[str, Any]]) -> None:
|
|
546
|
+
for output_index, output in enumerate(outputs):
|
|
547
|
+
artifacts = output.get("artifacts")
|
|
548
|
+
if not isinstance(artifacts, list):
|
|
549
|
+
continue
|
|
550
|
+
for artifact_index, artifact in enumerate(artifacts):
|
|
551
|
+
if not isinstance(artifact, dict):
|
|
552
|
+
continue
|
|
553
|
+
artifact = cast(dict[str, Any], artifact)
|
|
554
|
+
artifact_id = str(artifact.get("proxy_artifact_id") or "")
|
|
555
|
+
if not artifact_id:
|
|
556
|
+
continue
|
|
557
|
+
response = await self._fetch_artifact(artifact_id)
|
|
558
|
+
filename = _safe_artifact_filename(
|
|
559
|
+
artifact.get("filename"),
|
|
560
|
+
fallback=f"artifact_{output_index}_{artifact_index}{_extension_for_content_type(response.content_type)}",
|
|
561
|
+
)
|
|
562
|
+
ref = _write_local_artifact(self._artifact_dir, prompt_id, filename, response.body)
|
|
563
|
+
artifact["serve_artifact"] = ref
|
|
564
|
+
artifact["url"] = f"/outputs/view?serve_artifact={quote(ref, safe='/')}"
|
|
565
|
+
artifact["content_type"] = response.content_type
|
|
566
|
+
|
|
567
|
+
def _headers(self) -> dict[str, str]:
|
|
568
|
+
if not self._token:
|
|
569
|
+
return {}
|
|
570
|
+
return {PROXY_AUTH_HEADER: f"Bearer {self._token}"}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _new_comfyui_client_id() -> str:
|
|
574
|
+
return f"{COMFYUI_CLIENT_ID_PREFIX}-{uuid.uuid4().hex}"
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
async def _response_payload(response: aiohttp.ClientResponse) -> Any:
|
|
578
|
+
content_type = response.headers.get("content-type", "")
|
|
579
|
+
if "json" in content_type.lower():
|
|
580
|
+
return await response.json(content_type=None)
|
|
581
|
+
text = await response.text()
|
|
582
|
+
return {"message": text} if text else {}
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _comfyui_error_message(payload: Any) -> str:
|
|
586
|
+
if not isinstance(payload, Mapping):
|
|
587
|
+
return str(payload) if payload else ""
|
|
588
|
+
error = payload.get("error")
|
|
589
|
+
if isinstance(error, Mapping):
|
|
590
|
+
message = str(error.get("message") or error.get("type") or "")
|
|
591
|
+
else:
|
|
592
|
+
message = str(payload.get("message") or error or "")
|
|
593
|
+
detail = _first_comfyui_node_error_detail(payload)
|
|
594
|
+
if message and detail:
|
|
595
|
+
return f"{message}: {detail}"
|
|
596
|
+
return detail or message
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _first_comfyui_node_error_detail(payload: Mapping[str, Any]) -> str:
|
|
600
|
+
node_errors = payload.get("node_errors")
|
|
601
|
+
if not isinstance(node_errors, Mapping):
|
|
602
|
+
return ""
|
|
603
|
+
for node_error in node_errors.values():
|
|
604
|
+
if not isinstance(node_error, Mapping):
|
|
605
|
+
continue
|
|
606
|
+
errors = node_error.get("errors")
|
|
607
|
+
if not isinstance(errors, list):
|
|
608
|
+
continue
|
|
609
|
+
for error in errors:
|
|
610
|
+
if not isinstance(error, Mapping):
|
|
611
|
+
continue
|
|
612
|
+
detail = error.get("details") or error.get("message")
|
|
613
|
+
if detail:
|
|
614
|
+
return str(detail)
|
|
615
|
+
return ""
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _comfyui_history_error_message(history: Mapping[str, Any]) -> str:
|
|
619
|
+
status = history.get("status")
|
|
620
|
+
if not isinstance(status, Mapping):
|
|
621
|
+
return ""
|
|
622
|
+
status_str = str(status.get("status_str") or "").lower()
|
|
623
|
+
execution_error = _first_execution_error(status)
|
|
624
|
+
if status_str in {"error", "failed"} or execution_error:
|
|
625
|
+
return execution_error or str(status.get("status_str") or "ComfyUI execution failed")
|
|
626
|
+
return ""
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _first_execution_error(status: Mapping[str, Any]) -> str:
|
|
630
|
+
messages = status.get("messages")
|
|
631
|
+
if not isinstance(messages, list):
|
|
632
|
+
return ""
|
|
633
|
+
for message in messages:
|
|
634
|
+
if not isinstance(message, list | tuple) or len(message) < 2:
|
|
635
|
+
continue
|
|
636
|
+
event_type, event_payload = message[0], message[1]
|
|
637
|
+
if event_type != "execution_error" or not isinstance(event_payload, Mapping):
|
|
638
|
+
continue
|
|
639
|
+
error_text = str(event_payload.get("exception_message") or event_payload.get("message") or "").strip()
|
|
640
|
+
if not error_text:
|
|
641
|
+
error_text = str(event_payload.get("exception_type") or "ComfyUI execution failed")
|
|
642
|
+
node_type = event_payload.get("node_type")
|
|
643
|
+
node_id = event_payload.get("node_id")
|
|
644
|
+
if node_type and node_id:
|
|
645
|
+
return f"{node_type} node {node_id} failed: {error_text}"
|
|
646
|
+
if node_type:
|
|
647
|
+
return f"{node_type} failed: {error_text}"
|
|
648
|
+
return error_text
|
|
649
|
+
return ""
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _comfyui_history_error_payload(
|
|
653
|
+
prompt_id: str,
|
|
654
|
+
history: Mapping[str, Any],
|
|
655
|
+
message: str,
|
|
656
|
+
) -> dict[str, Any]:
|
|
657
|
+
payload: dict[str, Any] = {
|
|
658
|
+
"prompt_id": prompt_id,
|
|
659
|
+
"message": message,
|
|
660
|
+
}
|
|
661
|
+
status = history.get("status")
|
|
662
|
+
if isinstance(status, Mapping):
|
|
663
|
+
payload["status"] = status
|
|
664
|
+
outputs = history.get("outputs")
|
|
665
|
+
if isinstance(outputs, Mapping):
|
|
666
|
+
payload["outputs"] = outputs
|
|
667
|
+
return payload
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def output_kind(output_type: str, filename: str) -> str:
|
|
671
|
+
lowered = filename.lower()
|
|
672
|
+
normalized_type = output_type.lower()
|
|
673
|
+
if normalized_type == "video" or lowered.endswith((".mp4", ".webm", ".mov", ".mkv")):
|
|
674
|
+
return "video"
|
|
675
|
+
if normalized_type == "audio" or lowered.endswith((".wav", ".mp3", ".m4a", ".flac", ".ogg")):
|
|
676
|
+
return "audio"
|
|
677
|
+
if normalized_type == "image" or lowered.endswith((".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp")):
|
|
678
|
+
return "image"
|
|
679
|
+
return "json"
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _stamp_output_cache_busters(
|
|
683
|
+
prompt: dict[str, dict[str, Any]],
|
|
684
|
+
outputs: tuple[Any, ...],
|
|
685
|
+
token: str,
|
|
686
|
+
) -> None:
|
|
687
|
+
"""Force artifact-producing output nodes to re-run for each contract request.
|
|
688
|
+
|
|
689
|
+
ComfyUI can mark a whole prompt as cached when input values repeat. When
|
|
690
|
+
`SaveImage` is cached, history may contain no `outputs` entry even though
|
|
691
|
+
execution reports success. Stamping the filename prefix preserves upstream
|
|
692
|
+
cache reuse while making the output node produce fresh artifact metadata.
|
|
693
|
+
"""
|
|
694
|
+
|
|
695
|
+
safe_token = "".join(char for char in token if char.isalnum() or char in {"-", "_"}) or "run"
|
|
696
|
+
for output in outputs:
|
|
697
|
+
if str(getattr(output, "type", "")).lower() != "image":
|
|
698
|
+
continue
|
|
699
|
+
node_id = str(getattr(output, "node_id", ""))
|
|
700
|
+
node = prompt.get(node_id)
|
|
701
|
+
if not isinstance(node, dict):
|
|
702
|
+
continue
|
|
703
|
+
if str(node.get("class_type", "")) != "SaveImage":
|
|
704
|
+
continue
|
|
705
|
+
inputs = node.setdefault("inputs", {})
|
|
706
|
+
if not isinstance(inputs, dict) or "filename_prefix" not in inputs:
|
|
707
|
+
continue
|
|
708
|
+
prefix = str(inputs.get("filename_prefix") or "ComfyUI")
|
|
709
|
+
inputs["filename_prefix"] = f"{prefix}_{safe_token}"
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _contract_output_payload(output: Any) -> dict[str, Any]:
|
|
713
|
+
payload = asdict(output)
|
|
714
|
+
artifacts = []
|
|
715
|
+
for artifact in output.artifacts:
|
|
716
|
+
item = asdict(artifact)
|
|
717
|
+
filename = item.get("filename")
|
|
718
|
+
if filename:
|
|
719
|
+
item["url"] = _artifact_view_url(item)
|
|
720
|
+
artifacts.append(item)
|
|
721
|
+
payload["artifacts"] = artifacts
|
|
722
|
+
return payload
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _workflow_contract_output_payload(output: Any) -> dict[str, Any]:
|
|
726
|
+
to_dict = getattr(output, "to_dict", None)
|
|
727
|
+
if callable(to_dict):
|
|
728
|
+
payload = to_dict()
|
|
729
|
+
return dict(payload) if isinstance(payload, Mapping) else {}
|
|
730
|
+
return {
|
|
731
|
+
key: value
|
|
732
|
+
for key in ("name", "type", "node_id", "display_name", "selector", "description")
|
|
733
|
+
if (value := getattr(output, key, None)) is not None
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def workflow_contract_output_from_payload(payload: Mapping[str, Any]) -> WorkflowContractOutput:
|
|
738
|
+
return WorkflowContractOutput.from_toml_dict(dict(payload))
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
async def _attach_artifact_dimensions(client: ComfyUIClient, outputs: list[dict[str, Any]]) -> None:
|
|
742
|
+
for output in outputs:
|
|
743
|
+
output_type = str(output.get("type") or "").lower()
|
|
744
|
+
artifacts = output.get("artifacts")
|
|
745
|
+
if not isinstance(artifacts, list):
|
|
746
|
+
continue
|
|
747
|
+
for artifact in artifacts:
|
|
748
|
+
if not isinstance(artifact, dict):
|
|
749
|
+
continue
|
|
750
|
+
filename = str(artifact.get("filename") or "")
|
|
751
|
+
kind = output_kind(output_type, filename)
|
|
752
|
+
if artifact_dimensions(artifact) != (1, 1):
|
|
753
|
+
continue
|
|
754
|
+
params = _artifact_view_params(artifact)
|
|
755
|
+
if kind == "image":
|
|
756
|
+
dimensions = await _image_dimensions_from_artifact(client, params)
|
|
757
|
+
elif kind == "video":
|
|
758
|
+
dimensions = await _video_dimensions_from_artifact(client, params)
|
|
759
|
+
else:
|
|
760
|
+
continue
|
|
761
|
+
if dimensions is None:
|
|
762
|
+
continue
|
|
763
|
+
artifact["width"], artifact["height"] = dimensions
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
async def _localize_client_outputs(
|
|
767
|
+
client: ComfyUIClient,
|
|
768
|
+
prompt_id: str,
|
|
769
|
+
outputs: list[dict[str, Any]],
|
|
770
|
+
artifact_dir: Path,
|
|
771
|
+
*,
|
|
772
|
+
temp_only: bool,
|
|
773
|
+
) -> None:
|
|
774
|
+
for output_index, output in enumerate(outputs):
|
|
775
|
+
artifacts = output.get("artifacts")
|
|
776
|
+
if not isinstance(artifacts, list):
|
|
777
|
+
continue
|
|
778
|
+
for artifact_index, artifact in enumerate(artifacts):
|
|
779
|
+
if not isinstance(artifact, dict):
|
|
780
|
+
continue
|
|
781
|
+
artifact = cast(dict[str, Any], artifact)
|
|
782
|
+
if artifact.get("serve_artifact"):
|
|
783
|
+
continue
|
|
784
|
+
if temp_only and str(artifact.get("type") or "").lower() != "temp":
|
|
785
|
+
continue
|
|
786
|
+
try:
|
|
787
|
+
response = await client.fetch_output(_artifact_view_params(artifact))
|
|
788
|
+
except (aiohttp.ClientError, asyncio.TimeoutError, OSError):
|
|
789
|
+
continue
|
|
790
|
+
filename = _safe_artifact_filename(
|
|
791
|
+
artifact.get("filename"),
|
|
792
|
+
fallback=f"artifact_{output_index}_{artifact_index}{_extension_for_content_type(response.content_type)}",
|
|
793
|
+
)
|
|
794
|
+
ref = _write_local_artifact(artifact_dir, prompt_id, filename, response.body)
|
|
795
|
+
artifact["serve_artifact"] = ref
|
|
796
|
+
artifact["url"] = f"/outputs/view?serve_artifact={quote(ref, safe='/')}"
|
|
797
|
+
artifact["content_type"] = response.content_type
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
async def _image_dimensions_from_artifact(
|
|
801
|
+
client: ComfyUIClient,
|
|
802
|
+
params: Mapping[str, str],
|
|
803
|
+
) -> tuple[int, int] | None:
|
|
804
|
+
try:
|
|
805
|
+
response = await client.fetch_output(params)
|
|
806
|
+
except (aiohttp.ClientError, asyncio.TimeoutError, OSError):
|
|
807
|
+
return None
|
|
808
|
+
return _image_dimensions_from_bytes(response.body)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
async def _video_dimensions_from_artifact(
|
|
812
|
+
client: ComfyUIClient,
|
|
813
|
+
params: Mapping[str, str],
|
|
814
|
+
) -> tuple[int, int] | None:
|
|
815
|
+
try:
|
|
816
|
+
process = await asyncio.create_subprocess_exec(
|
|
817
|
+
"ffprobe",
|
|
818
|
+
"-v",
|
|
819
|
+
"error",
|
|
820
|
+
"-select_streams",
|
|
821
|
+
"v:0",
|
|
822
|
+
"-show_entries",
|
|
823
|
+
"stream=width,height",
|
|
824
|
+
"-of",
|
|
825
|
+
"json",
|
|
826
|
+
client.output_view_url(params),
|
|
827
|
+
stdout=asyncio.subprocess.PIPE,
|
|
828
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
829
|
+
)
|
|
830
|
+
except OSError:
|
|
831
|
+
return None
|
|
832
|
+
|
|
833
|
+
try:
|
|
834
|
+
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=10)
|
|
835
|
+
except asyncio.TimeoutError:
|
|
836
|
+
process.kill()
|
|
837
|
+
await process.communicate()
|
|
838
|
+
return None
|
|
839
|
+
if process.returncode != 0:
|
|
840
|
+
return None
|
|
841
|
+
return _video_dimensions_from_ffprobe_json(stdout)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _video_dimensions_from_ffprobe_json(data: bytes) -> tuple[int, int] | None:
|
|
845
|
+
try:
|
|
846
|
+
payload = json.loads(data.decode("utf-8"))
|
|
847
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
848
|
+
return None
|
|
849
|
+
streams = payload.get("streams") if isinstance(payload, Mapping) else None
|
|
850
|
+
if not isinstance(streams, list) or not streams:
|
|
851
|
+
return None
|
|
852
|
+
stream = streams[0]
|
|
853
|
+
if not isinstance(stream, Mapping):
|
|
854
|
+
return None
|
|
855
|
+
width = _positive_int(stream.get("width"))
|
|
856
|
+
height = _positive_int(stream.get("height"))
|
|
857
|
+
if width is None or height is None:
|
|
858
|
+
return None
|
|
859
|
+
return (width, height)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def artifact_dimensions(artifact: Mapping[str, Any]) -> tuple[int, int]:
|
|
863
|
+
width = _positive_int(artifact.get("width"))
|
|
864
|
+
height = _positive_int(artifact.get("height"))
|
|
865
|
+
if width is None or height is None:
|
|
866
|
+
return (1, 1)
|
|
867
|
+
return (width, height)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _positive_int(value: Any) -> int | None:
|
|
871
|
+
try:
|
|
872
|
+
number = int(value)
|
|
873
|
+
except (TypeError, ValueError):
|
|
874
|
+
return None
|
|
875
|
+
return number if number > 0 else None
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _image_dimensions_from_bytes(body: bytes) -> tuple[int, int] | None:
|
|
879
|
+
if body.startswith(b"\x89PNG\r\n\x1a\n") and len(body) >= 24:
|
|
880
|
+
width, height = struct.unpack(">II", body[16:24])
|
|
881
|
+
return (width, height) if width > 0 and height > 0 else None
|
|
882
|
+
|
|
883
|
+
if body.startswith((b"GIF87a", b"GIF89a")) and len(body) >= 10:
|
|
884
|
+
width, height = struct.unpack("<HH", body[6:10])
|
|
885
|
+
return (width, height) if width > 0 and height > 0 else None
|
|
886
|
+
|
|
887
|
+
if body.startswith(b"BM") and len(body) >= 26:
|
|
888
|
+
width = abs(struct.unpack("<i", body[18:22])[0])
|
|
889
|
+
height = abs(struct.unpack("<i", body[22:26])[0])
|
|
890
|
+
return (width, height) if width > 0 and height > 0 else None
|
|
891
|
+
|
|
892
|
+
if body.startswith(b"\xff\xd8"):
|
|
893
|
+
return _jpeg_dimensions_from_bytes(body)
|
|
894
|
+
|
|
895
|
+
if len(body) >= 30 and body.startswith(b"RIFF") and body[8:12] == b"WEBP":
|
|
896
|
+
return _webp_dimensions_from_bytes(body)
|
|
897
|
+
|
|
898
|
+
return None
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _jpeg_dimensions_from_bytes(body: bytes) -> tuple[int, int] | None:
|
|
902
|
+
index = 2
|
|
903
|
+
while index + 9 < len(body):
|
|
904
|
+
if body[index] != 0xFF:
|
|
905
|
+
index += 1
|
|
906
|
+
continue
|
|
907
|
+
while index < len(body) and body[index] == 0xFF:
|
|
908
|
+
index += 1
|
|
909
|
+
if index >= len(body):
|
|
910
|
+
return None
|
|
911
|
+
marker = body[index]
|
|
912
|
+
index += 1
|
|
913
|
+
if marker in {0xD8, 0xD9} or 0xD0 <= marker <= 0xD7:
|
|
914
|
+
continue
|
|
915
|
+
if index + 2 > len(body):
|
|
916
|
+
return None
|
|
917
|
+
segment_length = struct.unpack(">H", body[index : index + 2])[0]
|
|
918
|
+
if segment_length < 2 or index + segment_length > len(body):
|
|
919
|
+
return None
|
|
920
|
+
if marker in {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}:
|
|
921
|
+
if segment_length < 7:
|
|
922
|
+
return None
|
|
923
|
+
height, width = struct.unpack(">HH", body[index + 3 : index + 7])
|
|
924
|
+
return (width, height) if width > 0 and height > 0 else None
|
|
925
|
+
index += segment_length
|
|
926
|
+
return None
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _webp_dimensions_from_bytes(body: bytes) -> tuple[int, int] | None:
|
|
930
|
+
chunk_type = body[12:16]
|
|
931
|
+
if chunk_type == b"VP8X" and len(body) >= 30:
|
|
932
|
+
width = 1 + int.from_bytes(body[24:27], "little")
|
|
933
|
+
height = 1 + int.from_bytes(body[27:30], "little")
|
|
934
|
+
return (width, height) if width > 0 and height > 0 else None
|
|
935
|
+
if chunk_type == b"VP8 " and len(body) >= 30:
|
|
936
|
+
width = struct.unpack("<H", body[26:28])[0] & 0x3FFF
|
|
937
|
+
height = struct.unpack("<H", body[28:30])[0] & 0x3FFF
|
|
938
|
+
return (width, height) if width > 0 and height > 0 else None
|
|
939
|
+
if chunk_type == b"VP8L" and len(body) >= 25:
|
|
940
|
+
b0, b1, b2, b3 = body[21:25]
|
|
941
|
+
width = 1 + (((b1 & 0x3F) << 8) | b0)
|
|
942
|
+
height = 1 + (((b3 & 0x0F) << 10) | (b2 << 2) | ((b1 & 0xC0) >> 6))
|
|
943
|
+
return (width, height) if width > 0 and height > 0 else None
|
|
944
|
+
return None
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _artifact_view_url(artifact: Mapping[str, Any]) -> str:
|
|
948
|
+
query = urlencode(_artifact_view_params(artifact))
|
|
949
|
+
return f"/outputs/view?{query}"
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _artifact_view_params(artifact: Mapping[str, Any]) -> dict[str, str]:
|
|
953
|
+
return {
|
|
954
|
+
"filename": str(artifact.get("filename") or ""),
|
|
955
|
+
"subfolder": str(artifact.get("subfolder") or ""),
|
|
956
|
+
"type": str(artifact.get("type") or "output"),
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _staged_upload_payload(upload: StagedUpload, index: int) -> dict[str, Any]:
|
|
961
|
+
payload: dict[str, Any] = {
|
|
962
|
+
"field_name": f"file_{index}",
|
|
963
|
+
"input_name": upload.input_name,
|
|
964
|
+
"filename": upload.filename,
|
|
965
|
+
"comfyui_filename": upload.comfyui_filename,
|
|
966
|
+
"content_type": upload.content_type,
|
|
967
|
+
}
|
|
968
|
+
if upload.size is not None:
|
|
969
|
+
payload["size"] = upload.size
|
|
970
|
+
return payload
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _safe_artifact_filename(value: Any, *, fallback: str = "artifact.bin") -> str:
|
|
974
|
+
raw = str(value or fallback).strip().replace("\\", "/").split("/")[-1]
|
|
975
|
+
safe = "".join(char for char in raw if char.isalnum() or char in {"-", "_", "."}).strip(" .")
|
|
976
|
+
return safe if safe and any(char.isalnum() for char in safe) else fallback
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _safe_artifact_path_segment(value: Any) -> str:
|
|
980
|
+
return "".join(char for char in str(value or "") if char.isalnum() or char in {"-", "_"})
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _write_local_artifact(artifact_dir: Path, prompt_id: str, filename: str, body: bytes) -> str:
|
|
984
|
+
safe_prompt_id = _safe_artifact_path_segment(prompt_id) or f"prompt_{uuid.uuid4().hex}"
|
|
985
|
+
safe_filename = _safe_artifact_filename(filename)
|
|
986
|
+
target_dir = artifact_dir / safe_prompt_id
|
|
987
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
988
|
+
target_path = target_dir / safe_filename
|
|
989
|
+
if target_path.exists():
|
|
990
|
+
stem = target_path.stem
|
|
991
|
+
suffix = target_path.suffix
|
|
992
|
+
target_path = target_dir / f"{stem}_{uuid.uuid4().hex[:8]}{suffix}"
|
|
993
|
+
safe_filename = target_path.name
|
|
994
|
+
temp_path = target_path.with_name(f".{target_path.name}.tmp")
|
|
995
|
+
temp_path.write_bytes(body)
|
|
996
|
+
temp_path.replace(target_path)
|
|
997
|
+
return f"{safe_prompt_id}/{safe_filename}"
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _extension_for_content_type(content_type: str) -> str:
|
|
1001
|
+
extension = mimetypes.guess_extension(content_type.split(";", 1)[0].strip().lower())
|
|
1002
|
+
return extension or ".bin"
|