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.
@@ -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"