SuperRealCo commited on
Commit
5547fae
·
verified ·
1 Parent(s): cde7bcd

Delete comfy_api_nodes

Browse files
Files changed (37) hide show
  1. comfy_api_nodes/README.md +0 -65
  2. comfy_api_nodes/__init__.py +0 -0
  3. comfy_api_nodes/apinode_utils.py +0 -678
  4. comfy_api_nodes/apis/PixverseController.py +0 -17
  5. comfy_api_nodes/apis/PixverseDto.py +0 -57
  6. comfy_api_nodes/apis/__init__.py +0 -0
  7. comfy_api_nodes/apis/bfl_api.py +0 -174
  8. comfy_api_nodes/apis/client.py +0 -1126
  9. comfy_api_nodes/apis/luma_api.py +0 -253
  10. comfy_api_nodes/apis/pixverse_api.py +0 -146
  11. comfy_api_nodes/apis/recraft_api.py +0 -262
  12. comfy_api_nodes/apis/request_logger.py +0 -125
  13. comfy_api_nodes/apis/rodin_api.py +0 -57
  14. comfy_api_nodes/apis/stability_api.py +0 -127
  15. comfy_api_nodes/apis/tripo_api.py +0 -275
  16. comfy_api_nodes/canary.py +0 -10
  17. comfy_api_nodes/mapper_utils.py +0 -116
  18. comfy_api_nodes/nodes_bfl.py +0 -1075
  19. comfy_api_nodes/nodes_gemini.py +0 -446
  20. comfy_api_nodes/nodes_ideogram.py +0 -801
  21. comfy_api_nodes/nodes_kling.py +0 -1760
  22. comfy_api_nodes/nodes_luma.py +0 -737
  23. comfy_api_nodes/nodes_minimax.py +0 -332
  24. comfy_api_nodes/nodes_moonvalley.py +0 -639
  25. comfy_api_nodes/nodes_openai.py +0 -1008
  26. comfy_api_nodes/nodes_pika.py +0 -782
  27. comfy_api_nodes/nodes_pixverse.py +0 -525
  28. comfy_api_nodes/nodes_recraft.py +0 -1138
  29. comfy_api_nodes/nodes_rodin.py +0 -462
  30. comfy_api_nodes/nodes_runway.py +0 -635
  31. comfy_api_nodes/nodes_stability.py +0 -614
  32. comfy_api_nodes/nodes_tripo.py +0 -574
  33. comfy_api_nodes/nodes_veo2.py +0 -308
  34. comfy_api_nodes/redocly-dev.yaml +0 -10
  35. comfy_api_nodes/redocly.yaml +0 -10
  36. comfy_api_nodes/util/__init__.py +0 -0
  37. comfy_api_nodes/util/validation_utils.py +0 -100
comfy_api_nodes/README.md DELETED
@@ -1,65 +0,0 @@
1
- # ComfyUI API Nodes
2
-
3
- ## Introduction
4
-
5
- Below are a collection of nodes that work by calling external APIs. More information available in our [docs](https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes).
6
-
7
- ## Development
8
-
9
- While developing, you should be testing against the Staging environment. To test against staging:
10
-
11
- **Install ComfyUI_frontend**
12
-
13
- Follow the instructions [here](https://github.com/Comfy-Org/ComfyUI_frontend) to start the frontend server. By default, it will connect to Staging authentication.
14
-
15
- > **Hint:** If you use --front-end-version argument for ComfyUI, it will use production authentication.
16
-
17
- ```bash
18
- python run main.py --comfy-api-base https://stagingapi.comfy.org
19
- ```
20
-
21
- To authenticate to staging, please login and then ask one of Comfy Org team to whitelist you for access to staging.
22
-
23
- API stubs are generated through automatic codegen tools from OpenAPI definitions. Since the Comfy Org OpenAPI definition contains many things from the Comfy Registry as well, we use redocly/cli to filter out only the paths relevant for API nodes.
24
-
25
- ### Redocly Instructions
26
-
27
- **Tip**
28
- When developing locally, use the `redocly-dev.yaml` file to generate pydantic models. This lets you use stubs for APIs that are not marked `Released` yet.
29
-
30
- Before your API node PR merges, make sure to add the `Released` tag to the `openapi.yaml` file and test in staging.
31
-
32
- ```bash
33
- # Download the OpenAPI file from staging server.
34
- curl -o openapi.yaml https://stagingapi.comfy.org/openapi
35
-
36
- # Filter out unneeded API definitions.
37
- npm install -g @redocly/cli
38
- redocly bundle openapi.yaml --output filtered-openapi.yaml --config comfy_api_nodes/redocly-dev.yaml --remove-unused-components
39
-
40
- # Generate the pydantic datamodels for validation.
41
- datamodel-codegen --use-subclass-enum --field-constraints --strict-types bytes --input filtered-openapi.yaml --output comfy_api_nodes/apis/__init__.py --output-model-type pydantic_v2.BaseModel
42
-
43
- ```
44
-
45
-
46
- # Merging to Master
47
-
48
- Before merging to comfyanonymous/ComfyUI master, follow these steps:
49
-
50
- 1. Add the "Released" tag to the ComfyUI OpenAPI yaml file for each endpoint you are using in the nodes.
51
- 1. Make sure the ComfyUI API is deployed to prod with your changes.
52
- 1. Run the code generation again with `redocly.yaml` and the production OpenAPI yaml file.
53
-
54
- ```bash
55
- # Download the OpenAPI file from prod server.
56
- curl -o openapi.yaml https://api.comfy.org/openapi
57
-
58
- # Filter out unneeded API definitions.
59
- npm install -g @redocly/cli
60
- redocly bundle openapi.yaml --output filtered-openapi.yaml --config comfy_api_nodes/redocly.yaml --remove-unused-components
61
-
62
- # Generate the pydantic datamodels for validation.
63
- datamodel-codegen --use-subclass-enum --field-constraints --strict-types bytes --input filtered-openapi.yaml --output comfy_api_nodes/apis/__init__.py --output-model-type pydantic_v2.BaseModel
64
-
65
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/__init__.py DELETED
File without changes
comfy_api_nodes/apinode_utils.py DELETED
@@ -1,678 +0,0 @@
1
- from __future__ import annotations
2
- import io
3
- import logging
4
- import mimetypes
5
- from typing import Optional, Union
6
- from comfy.utils import common_upscale
7
- from comfy_api.input_impl import VideoFromFile
8
- from comfy_api.util import VideoContainer, VideoCodec
9
- from comfy_api.input.video_types import VideoInput
10
- from comfy_api.input.basic_types import AudioInput
11
- from comfy_api_nodes.apis.client import (
12
- ApiClient,
13
- ApiEndpoint,
14
- HttpMethod,
15
- SynchronousOperation,
16
- UploadRequest,
17
- UploadResponse,
18
- )
19
- from server import PromptServer
20
-
21
-
22
- import numpy as np
23
- from PIL import Image
24
- import requests
25
- import torch
26
- import math
27
- import base64
28
- import uuid
29
- from io import BytesIO
30
- import av
31
-
32
-
33
- def download_url_to_video_output(video_url: str, timeout: int = None) -> VideoFromFile:
34
- """Downloads a video from a URL and returns a `VIDEO` output.
35
-
36
- Args:
37
- video_url: The URL of the video to download.
38
-
39
- Returns:
40
- A Comfy node `VIDEO` output.
41
- """
42
- video_io = download_url_to_bytesio(video_url, timeout)
43
- if video_io is None:
44
- error_msg = f"Failed to download video from {video_url}"
45
- logging.error(error_msg)
46
- raise ValueError(error_msg)
47
- return VideoFromFile(video_io)
48
-
49
-
50
- def downscale_image_tensor(image, total_pixels=1536 * 1024) -> torch.Tensor:
51
- """Downscale input image tensor to roughly the specified total pixels."""
52
- samples = image.movedim(-1, 1)
53
- total = int(total_pixels)
54
- scale_by = math.sqrt(total / (samples.shape[3] * samples.shape[2]))
55
- if scale_by >= 1:
56
- return image
57
- width = round(samples.shape[3] * scale_by)
58
- height = round(samples.shape[2] * scale_by)
59
-
60
- s = common_upscale(samples, width, height, "lanczos", "disabled")
61
- s = s.movedim(1, -1)
62
- return s
63
-
64
-
65
- def validate_and_cast_response(
66
- response, timeout: int = None, node_id: Union[str, None] = None
67
- ) -> torch.Tensor:
68
- """Validates and casts a response to a torch.Tensor.
69
-
70
- Args:
71
- response: The response to validate and cast.
72
- timeout: Request timeout in seconds. Defaults to None (no timeout).
73
-
74
- Returns:
75
- A torch.Tensor representing the image (1, H, W, C).
76
-
77
- Raises:
78
- ValueError: If the response is not valid.
79
- """
80
- # validate raw JSON response
81
- data = response.data
82
- if not data or len(data) == 0:
83
- raise ValueError("No images returned from API endpoint")
84
-
85
- # Initialize list to store image tensors
86
- image_tensors: list[torch.Tensor] = []
87
-
88
- # Process each image in the data array
89
- for image_data in data:
90
- image_url = image_data.url
91
- b64_data = image_data.b64_json
92
-
93
- if not image_url and not b64_data:
94
- raise ValueError("No image was generated in the response")
95
-
96
- if b64_data:
97
- img_data = base64.b64decode(b64_data)
98
- img = Image.open(io.BytesIO(img_data))
99
-
100
- elif image_url:
101
- if node_id:
102
- PromptServer.instance.send_progress_text(
103
- f"Result URL: {image_url}", node_id
104
- )
105
- img_response = requests.get(image_url, timeout=timeout)
106
- if img_response.status_code != 200:
107
- raise ValueError("Failed to download the image")
108
- img = Image.open(io.BytesIO(img_response.content))
109
-
110
- img = img.convert("RGBA")
111
-
112
- # Convert to numpy array, normalize to float32 between 0 and 1
113
- img_array = np.array(img).astype(np.float32) / 255.0
114
- img_tensor = torch.from_numpy(img_array)
115
-
116
- # Add to list of tensors
117
- image_tensors.append(img_tensor)
118
-
119
- return torch.stack(image_tensors, dim=0)
120
-
121
-
122
- def validate_aspect_ratio(
123
- aspect_ratio: str,
124
- minimum_ratio: float,
125
- maximum_ratio: float,
126
- minimum_ratio_str: str,
127
- maximum_ratio_str: str,
128
- ) -> float:
129
- """Validates and casts an aspect ratio string to a float.
130
-
131
- Args:
132
- aspect_ratio: The aspect ratio string to validate.
133
- minimum_ratio: The minimum aspect ratio.
134
- maximum_ratio: The maximum aspect ratio.
135
- minimum_ratio_str: The minimum aspect ratio string.
136
- maximum_ratio_str: The maximum aspect ratio string.
137
-
138
- Returns:
139
- The validated and cast aspect ratio.
140
-
141
- Raises:
142
- Exception: If the aspect ratio is not valid.
143
- """
144
- # get ratio values
145
- numbers = aspect_ratio.split(":")
146
- if len(numbers) != 2:
147
- raise TypeError(
148
- f"Aspect ratio must be in the format X:Y, such as 16:9, but was {aspect_ratio}."
149
- )
150
- try:
151
- numerator = int(numbers[0])
152
- denominator = int(numbers[1])
153
- except ValueError as exc:
154
- raise TypeError(
155
- f"Aspect ratio must contain numbers separated by ':', such as 16:9, but was {aspect_ratio}."
156
- ) from exc
157
- calculated_ratio = numerator / denominator
158
- # if not close to minimum and maximum, check bounds
159
- if not math.isclose(calculated_ratio, minimum_ratio) or not math.isclose(
160
- calculated_ratio, maximum_ratio
161
- ):
162
- if calculated_ratio < minimum_ratio:
163
- raise TypeError(
164
- f"Aspect ratio cannot reduce to any less than {minimum_ratio_str} ({minimum_ratio}), but was {aspect_ratio} ({calculated_ratio})."
165
- )
166
- elif calculated_ratio > maximum_ratio:
167
- raise TypeError(
168
- f"Aspect ratio cannot reduce to any greater than {maximum_ratio_str} ({maximum_ratio}), but was {aspect_ratio} ({calculated_ratio})."
169
- )
170
- return aspect_ratio
171
-
172
-
173
- def mimetype_to_extension(mime_type: str) -> str:
174
- """Converts a MIME type to a file extension."""
175
- return mime_type.split("/")[-1].lower()
176
-
177
-
178
- def download_url_to_bytesio(url: str, timeout: int = None) -> BytesIO:
179
- """Downloads content from a URL using requests and returns it as BytesIO.
180
-
181
- Args:
182
- url: The URL to download.
183
- timeout: Request timeout in seconds. Defaults to None (no timeout).
184
-
185
- Returns:
186
- BytesIO object containing the downloaded content.
187
- """
188
- response = requests.get(url, stream=True, timeout=timeout)
189
- response.raise_for_status() # Raises HTTPError for bad responses (4XX or 5XX)
190
- return BytesIO(response.content)
191
-
192
-
193
- def bytesio_to_image_tensor(image_bytesio: BytesIO, mode: str = "RGBA") -> torch.Tensor:
194
- """Converts image data from BytesIO to a torch.Tensor.
195
-
196
- Args:
197
- image_bytesio: BytesIO object containing the image data.
198
- mode: The PIL mode to convert the image to (e.g., "RGB", "RGBA").
199
-
200
- Returns:
201
- A torch.Tensor representing the image (1, H, W, C).
202
-
203
- Raises:
204
- PIL.UnidentifiedImageError: If the image data cannot be identified.
205
- ValueError: If the specified mode is invalid.
206
- """
207
- image = Image.open(image_bytesio)
208
- image = image.convert(mode)
209
- image_array = np.array(image).astype(np.float32) / 255.0
210
- return torch.from_numpy(image_array).unsqueeze(0)
211
-
212
-
213
- def download_url_to_image_tensor(url: str, timeout: int = None) -> torch.Tensor:
214
- """Downloads an image from a URL and returns a [B, H, W, C] tensor."""
215
- image_bytesio = download_url_to_bytesio(url, timeout)
216
- return bytesio_to_image_tensor(image_bytesio)
217
-
218
-
219
- def process_image_response(response: requests.Response) -> torch.Tensor:
220
- """Uses content from a Response object and converts it to a torch.Tensor"""
221
- return bytesio_to_image_tensor(BytesIO(response.content))
222
-
223
-
224
- def _tensor_to_pil(image: torch.Tensor, total_pixels: int = 2048 * 2048) -> Image.Image:
225
- """Converts a single torch.Tensor image [H, W, C] to a PIL Image, optionally downscaling."""
226
- if len(image.shape) > 3:
227
- image = image[0]
228
- # TODO: remove alpha if not allowed and present
229
- input_tensor = image.cpu()
230
- input_tensor = downscale_image_tensor(
231
- input_tensor.unsqueeze(0), total_pixels=total_pixels
232
- ).squeeze()
233
- image_np = (input_tensor.numpy() * 255).astype(np.uint8)
234
- img = Image.fromarray(image_np)
235
- return img
236
-
237
-
238
- def _pil_to_bytesio(img: Image.Image, mime_type: str = "image/png") -> BytesIO:
239
- """Converts a PIL Image to a BytesIO object."""
240
- if not mime_type:
241
- mime_type = "image/png"
242
-
243
- img_byte_arr = io.BytesIO()
244
- # Derive PIL format from MIME type (e.g., 'image/png' -> 'PNG')
245
- pil_format = mime_type.split("/")[-1].upper()
246
- if pil_format == "JPG":
247
- pil_format = "JPEG"
248
- img.save(img_byte_arr, format=pil_format)
249
- img_byte_arr.seek(0)
250
- return img_byte_arr
251
-
252
-
253
- def tensor_to_bytesio(
254
- image: torch.Tensor,
255
- name: Optional[str] = None,
256
- total_pixels: int = 2048 * 2048,
257
- mime_type: str = "image/png",
258
- ) -> BytesIO:
259
- """Converts a torch.Tensor image to a named BytesIO object.
260
-
261
- Args:
262
- image: Input torch.Tensor image.
263
- name: Optional filename for the BytesIO object.
264
- total_pixels: Maximum total pixels for potential downscaling.
265
- mime_type: Target image MIME type (e.g., 'image/png', 'image/jpeg', 'image/webp', 'video/mp4').
266
-
267
- Returns:
268
- Named BytesIO object containing the image data.
269
- """
270
- if not mime_type:
271
- mime_type = "image/png"
272
-
273
- pil_image = _tensor_to_pil(image, total_pixels=total_pixels)
274
- img_binary = _pil_to_bytesio(pil_image, mime_type=mime_type)
275
- img_binary.name = (
276
- f"{name if name else uuid.uuid4()}.{mimetype_to_extension(mime_type)}"
277
- )
278
- return img_binary
279
-
280
-
281
- def tensor_to_base64_string(
282
- image_tensor: torch.Tensor,
283
- total_pixels: int = 2048 * 2048,
284
- mime_type: str = "image/png",
285
- ) -> str:
286
- """Convert [B, H, W, C] or [H, W, C] tensor to a base64 string.
287
-
288
- Args:
289
- image_tensor: Input torch.Tensor image.
290
- total_pixels: Maximum total pixels for potential downscaling.
291
- mime_type: Target image MIME type (e.g., 'image/png', 'image/jpeg', 'image/webp', 'video/mp4').
292
-
293
- Returns:
294
- Base64 encoded string of the image.
295
- """
296
- pil_image = _tensor_to_pil(image_tensor, total_pixels=total_pixels)
297
- img_byte_arr = _pil_to_bytesio(pil_image, mime_type=mime_type)
298
- img_bytes = img_byte_arr.getvalue()
299
- # Encode bytes to base64 string
300
- base64_encoded_string = base64.b64encode(img_bytes).decode("utf-8")
301
- return base64_encoded_string
302
-
303
-
304
- def tensor_to_data_uri(
305
- image_tensor: torch.Tensor,
306
- total_pixels: int = 2048 * 2048,
307
- mime_type: str = "image/png",
308
- ) -> str:
309
- """Converts a tensor image to a Data URI string.
310
-
311
- Args:
312
- image_tensor: Input torch.Tensor image.
313
- total_pixels: Maximum total pixels for potential downscaling.
314
- mime_type: Target image MIME type (e.g., 'image/png', 'image/jpeg', 'image/webp').
315
-
316
- Returns:
317
- Data URI string (e.g., 'data:image/png;base64,...').
318
- """
319
- base64_string = tensor_to_base64_string(image_tensor, total_pixels, mime_type)
320
- return f"data:{mime_type};base64,{base64_string}"
321
-
322
-
323
- def text_filepath_to_base64_string(filepath: str) -> str:
324
- """Converts a text file to a base64 string."""
325
- with open(filepath, "rb") as f:
326
- file_content = f.read()
327
- return base64.b64encode(file_content).decode("utf-8")
328
-
329
-
330
- def text_filepath_to_data_uri(filepath: str) -> str:
331
- """Converts a text file to a data URI."""
332
- base64_string = text_filepath_to_base64_string(filepath)
333
- mime_type, _ = mimetypes.guess_type(filepath)
334
- if mime_type is None:
335
- mime_type = "application/octet-stream"
336
- return f"data:{mime_type};base64,{base64_string}"
337
-
338
-
339
- def upload_file_to_comfyapi(
340
- file_bytes_io: BytesIO,
341
- filename: str,
342
- upload_mime_type: str,
343
- auth_kwargs: Optional[dict[str, str]] = None,
344
- ) -> str:
345
- """
346
- Uploads a single file to ComfyUI API and returns its download URL.
347
-
348
- Args:
349
- file_bytes_io: BytesIO object containing the file data.
350
- filename: The filename of the file.
351
- upload_mime_type: MIME type of the file.
352
- auth_kwargs: Optional authentication token(s).
353
-
354
- Returns:
355
- The download URL for the uploaded file.
356
- """
357
- request_object = UploadRequest(file_name=filename, content_type=upload_mime_type)
358
- operation = SynchronousOperation(
359
- endpoint=ApiEndpoint(
360
- path="/customers/storage",
361
- method=HttpMethod.POST,
362
- request_model=UploadRequest,
363
- response_model=UploadResponse,
364
- ),
365
- request=request_object,
366
- auth_kwargs=auth_kwargs,
367
- )
368
-
369
- response: UploadResponse = operation.execute()
370
- upload_response = ApiClient.upload_file(
371
- response.upload_url, file_bytes_io, content_type=upload_mime_type
372
- )
373
- upload_response.raise_for_status()
374
-
375
- return response.download_url
376
-
377
-
378
- def video_to_base64_string(
379
- video: VideoInput,
380
- container_format: VideoContainer = None,
381
- codec: VideoCodec = None
382
- ) -> str:
383
- """
384
- Converts a video input to a base64 string.
385
-
386
- Args:
387
- video: The video input to convert
388
- container_format: Optional container format to use (defaults to video.container if available)
389
- codec: Optional codec to use (defaults to video.codec if available)
390
- """
391
- video_bytes_io = io.BytesIO()
392
-
393
- # Use provided format/codec if specified, otherwise use video's own if available
394
- format_to_use = container_format if container_format is not None else getattr(video, 'container', VideoContainer.MP4)
395
- codec_to_use = codec if codec is not None else getattr(video, 'codec', VideoCodec.H264)
396
-
397
- video.save_to(video_bytes_io, format=format_to_use, codec=codec_to_use)
398
- video_bytes_io.seek(0)
399
- return base64.b64encode(video_bytes_io.getvalue()).decode("utf-8")
400
-
401
-
402
- def upload_video_to_comfyapi(
403
- video: VideoInput,
404
- auth_kwargs: Optional[dict[str, str]] = None,
405
- container: VideoContainer = VideoContainer.MP4,
406
- codec: VideoCodec = VideoCodec.H264,
407
- max_duration: Optional[int] = None,
408
- ) -> str:
409
- """
410
- Uploads a single video to ComfyUI API and returns its download URL.
411
- Uses the specified container and codec for saving the video before upload.
412
-
413
- Args:
414
- video: VideoInput object (Comfy VIDEO type).
415
- auth_kwargs: Optional authentication token(s).
416
- container: The video container format to use (default: MP4).
417
- codec: The video codec to use (default: H264).
418
- max_duration: Optional maximum duration of the video in seconds. If the video is longer than this, an error will be raised.
419
-
420
- Returns:
421
- The download URL for the uploaded video file.
422
- """
423
- if max_duration is not None:
424
- try:
425
- actual_duration = video.duration_seconds
426
- if actual_duration is not None and actual_duration > max_duration:
427
- raise ValueError(
428
- f"Video duration ({actual_duration:.2f}s) exceeds the maximum allowed ({max_duration}s)."
429
- )
430
- except Exception as e:
431
- logging.error(f"Error getting video duration: {e}")
432
- raise ValueError(f"Could not verify video duration from source: {e}") from e
433
-
434
- upload_mime_type = f"video/{container.value.lower()}"
435
- filename = f"uploaded_video.{container.value.lower()}"
436
-
437
- # Convert VideoInput to BytesIO using specified container/codec
438
- video_bytes_io = io.BytesIO()
439
- video.save_to(video_bytes_io, format=container, codec=codec)
440
- video_bytes_io.seek(0)
441
-
442
- return upload_file_to_comfyapi(
443
- video_bytes_io, filename, upload_mime_type, auth_kwargs
444
- )
445
-
446
-
447
- def audio_tensor_to_contiguous_ndarray(waveform: torch.Tensor) -> np.ndarray:
448
- """
449
- Prepares audio waveform for av library by converting to a contiguous numpy array.
450
-
451
- Args:
452
- waveform: a tensor of shape (1, channels, samples) derived from a Comfy `AUDIO` type.
453
-
454
- Returns:
455
- Contiguous numpy array of the audio waveform. If the audio was batched,
456
- the first item is taken.
457
- """
458
- if waveform.ndim != 3 or waveform.shape[0] != 1:
459
- raise ValueError("Expected waveform tensor shape (1, channels, samples)")
460
-
461
- # If batch is > 1, take first item
462
- if waveform.shape[0] > 1:
463
- waveform = waveform[0]
464
-
465
- # Prepare for av: remove batch dim, move to CPU, make contiguous, convert to numpy array
466
- audio_data_np = waveform.squeeze(0).cpu().contiguous().numpy()
467
- if audio_data_np.dtype != np.float32:
468
- audio_data_np = audio_data_np.astype(np.float32)
469
-
470
- return audio_data_np
471
-
472
-
473
- def audio_ndarray_to_bytesio(
474
- audio_data_np: np.ndarray,
475
- sample_rate: int,
476
- container_format: str = "mp4",
477
- codec_name: str = "aac",
478
- ) -> BytesIO:
479
- """
480
- Encodes a numpy array of audio data into a BytesIO object.
481
- """
482
- audio_bytes_io = io.BytesIO()
483
- with av.open(audio_bytes_io, mode="w", format=container_format) as output_container:
484
- audio_stream = output_container.add_stream(codec_name, rate=sample_rate)
485
- frame = av.AudioFrame.from_ndarray(
486
- audio_data_np,
487
- format="fltp",
488
- layout="stereo" if audio_data_np.shape[0] > 1 else "mono",
489
- )
490
- frame.sample_rate = sample_rate
491
- frame.pts = 0
492
-
493
- for packet in audio_stream.encode(frame):
494
- output_container.mux(packet)
495
-
496
- # Flush stream
497
- for packet in audio_stream.encode(None):
498
- output_container.mux(packet)
499
-
500
- audio_bytes_io.seek(0)
501
- return audio_bytes_io
502
-
503
-
504
- def upload_audio_to_comfyapi(
505
- audio: AudioInput,
506
- auth_kwargs: Optional[dict[str, str]] = None,
507
- container_format: str = "mp4",
508
- codec_name: str = "aac",
509
- mime_type: str = "audio/mp4",
510
- filename: str = "uploaded_audio.mp4",
511
- ) -> str:
512
- """
513
- Uploads a single audio input to ComfyUI API and returns its download URL.
514
- Encodes the raw waveform into the specified format before uploading.
515
-
516
- Args:
517
- audio: a Comfy `AUDIO` type (contains waveform tensor and sample_rate)
518
- auth_kwargs: Optional authentication token(s).
519
-
520
- Returns:
521
- The download URL for the uploaded audio file.
522
- """
523
- sample_rate: int = audio["sample_rate"]
524
- waveform: torch.Tensor = audio["waveform"]
525
- audio_data_np = audio_tensor_to_contiguous_ndarray(waveform)
526
- audio_bytes_io = audio_ndarray_to_bytesio(
527
- audio_data_np, sample_rate, container_format, codec_name
528
- )
529
-
530
- return upload_file_to_comfyapi(audio_bytes_io, filename, mime_type, auth_kwargs)
531
-
532
-
533
- def audio_to_base64_string(
534
- audio: AudioInput, container_format: str = "mp4", codec_name: str = "aac"
535
- ) -> str:
536
- """Converts an audio input to a base64 string."""
537
- sample_rate: int = audio["sample_rate"]
538
- waveform: torch.Tensor = audio["waveform"]
539
- audio_data_np = audio_tensor_to_contiguous_ndarray(waveform)
540
- audio_bytes_io = audio_ndarray_to_bytesio(
541
- audio_data_np, sample_rate, container_format, codec_name
542
- )
543
- audio_bytes = audio_bytes_io.getvalue()
544
- return base64.b64encode(audio_bytes).decode("utf-8")
545
-
546
-
547
- def upload_images_to_comfyapi(
548
- image: torch.Tensor,
549
- max_images=8,
550
- auth_kwargs: Optional[dict[str, str]] = None,
551
- mime_type: Optional[str] = None,
552
- ) -> list[str]:
553
- """
554
- Uploads images to ComfyUI API and returns download URLs.
555
- To upload multiple images, stack them in the batch dimension first.
556
-
557
- Args:
558
- image: Input torch.Tensor image.
559
- max_images: Maximum number of images to upload.
560
- auth_kwargs: Optional authentication token(s).
561
- mime_type: Optional MIME type for the image.
562
- """
563
- # if batch, try to upload each file if max_images is greater than 0
564
- idx_image = 0
565
- download_urls: list[str] = []
566
- is_batch = len(image.shape) > 3
567
- batch_length = 1
568
- if is_batch:
569
- batch_length = image.shape[0]
570
- while True:
571
- curr_image = image
572
- if len(image.shape) > 3:
573
- curr_image = image[idx_image]
574
- # get BytesIO version of image
575
- img_binary = tensor_to_bytesio(curr_image, mime_type=mime_type)
576
- # first, request upload/download urls from comfy API
577
- if not mime_type:
578
- request_object = UploadRequest(file_name=img_binary.name)
579
- else:
580
- request_object = UploadRequest(
581
- file_name=img_binary.name, content_type=mime_type
582
- )
583
- operation = SynchronousOperation(
584
- endpoint=ApiEndpoint(
585
- path="/customers/storage",
586
- method=HttpMethod.POST,
587
- request_model=UploadRequest,
588
- response_model=UploadResponse,
589
- ),
590
- request=request_object,
591
- auth_kwargs=auth_kwargs,
592
- )
593
- response = operation.execute()
594
-
595
- upload_response = ApiClient.upload_file(
596
- response.upload_url, img_binary, content_type=mime_type
597
- )
598
- # verify success
599
- try:
600
- upload_response.raise_for_status()
601
- except requests.exceptions.HTTPError as e:
602
- raise ValueError(f"Could not upload one or more images: {e}") from e
603
- # add download_url to list
604
- download_urls.append(response.download_url)
605
-
606
- idx_image += 1
607
- # stop uploading additional files if done
608
- if is_batch and max_images > 0:
609
- if idx_image >= max_images:
610
- break
611
- if idx_image >= batch_length:
612
- break
613
- return download_urls
614
-
615
-
616
- def resize_mask_to_image(
617
- mask: torch.Tensor,
618
- image: torch.Tensor,
619
- upscale_method="nearest-exact",
620
- crop="disabled",
621
- allow_gradient=True,
622
- add_channel_dim=False,
623
- ):
624
- """
625
- Resize mask to be the same dimensions as an image, while maintaining proper format for API calls.
626
- """
627
- _, H, W, _ = image.shape
628
- mask = mask.unsqueeze(-1)
629
- mask = mask.movedim(-1, 1)
630
- mask = common_upscale(
631
- mask, width=W, height=H, upscale_method=upscale_method, crop=crop
632
- )
633
- mask = mask.movedim(1, -1)
634
- if not add_channel_dim:
635
- mask = mask.squeeze(-1)
636
- if not allow_gradient:
637
- mask = (mask > 0.5).float()
638
- return mask
639
-
640
-
641
- def validate_string(
642
- string: str,
643
- strip_whitespace=True,
644
- field_name="prompt",
645
- min_length=None,
646
- max_length=None,
647
- ):
648
- if string is None:
649
- raise Exception(f"Field '{field_name}' cannot be empty.")
650
- if strip_whitespace:
651
- string = string.strip()
652
- if min_length and len(string) < min_length:
653
- raise Exception(
654
- f"Field '{field_name}' cannot be shorter than {min_length} characters; was {len(string)} characters long."
655
- )
656
- if max_length and len(string) > max_length:
657
- raise Exception(
658
- f" Field '{field_name} cannot be longer than {max_length} characters; was {len(string)} characters long."
659
- )
660
-
661
-
662
- def image_tensor_pair_to_batch(
663
- image1: torch.Tensor, image2: torch.Tensor
664
- ) -> torch.Tensor:
665
- """
666
- Converts a pair of image tensors to a batch tensor.
667
- If the images are not the same size, the smaller image is resized to
668
- match the larger image.
669
- """
670
- if image1.shape[1:] != image2.shape[1:]:
671
- image2 = common_upscale(
672
- image2.movedim(-1, 1),
673
- image1.shape[2],
674
- image1.shape[1],
675
- "bilinear",
676
- "center",
677
- ).movedim(1, -1)
678
- return torch.cat((image1, image2), dim=0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/apis/PixverseController.py DELETED
@@ -1,17 +0,0 @@
1
- # generated by datamodel-codegen:
2
- # filename: filtered-openapi.yaml
3
- # timestamp: 2025-04-29T23:44:54+00:00
4
-
5
- from __future__ import annotations
6
-
7
- from typing import Optional
8
-
9
- from pydantic import BaseModel
10
-
11
- from . import PixverseDto
12
-
13
-
14
- class ResponseData(BaseModel):
15
- ErrCode: Optional[int] = None
16
- ErrMsg: Optional[str] = None
17
- Resp: Optional[PixverseDto.V2OpenAPII2VResp] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/apis/PixverseDto.py DELETED
@@ -1,57 +0,0 @@
1
- # generated by datamodel-codegen:
2
- # filename: filtered-openapi.yaml
3
- # timestamp: 2025-04-29T23:44:54+00:00
4
-
5
- from __future__ import annotations
6
-
7
- from typing import Optional
8
-
9
- from pydantic import BaseModel, Field
10
-
11
-
12
- class V2OpenAPII2VResp(BaseModel):
13
- video_id: Optional[int] = Field(None, description='Video_id')
14
-
15
-
16
- class V2OpenAPIT2VReq(BaseModel):
17
- aspect_ratio: str = Field(
18
- ..., description='Aspect ratio (16:9, 4:3, 1:1, 3:4, 9:16)', examples=['16:9']
19
- )
20
- duration: int = Field(
21
- ...,
22
- description='Video duration (5, 8 seconds, --model=v3.5 only allows 5,8; --quality=1080p does not support 8s)',
23
- examples=[5],
24
- )
25
- model: str = Field(
26
- ..., description='Model version (only supports v3.5)', examples=['v3.5']
27
- )
28
- motion_mode: Optional[str] = Field(
29
- 'normal',
30
- description='Motion mode (normal, fast, --fast only available when duration=5; --quality=1080p does not support fast)',
31
- examples=['normal'],
32
- )
33
- negative_prompt: Optional[str] = Field(
34
- None, description='Negative prompt\n', max_length=2048
35
- )
36
- prompt: str = Field(..., description='Prompt', max_length=2048)
37
- quality: str = Field(
38
- ...,
39
- description='Video quality ("360p"(Turbo model), "540p", "720p", "1080p")',
40
- examples=['540p'],
41
- )
42
- seed: Optional[int] = Field(None, description='Random seed, range: 0 - 2147483647')
43
- style: Optional[str] = Field(
44
- None,
45
- description='Style (effective when model=v3.5, "anime", "3d_animation", "clay", "comic", "cyberpunk") Do not include style parameter unless needed',
46
- examples=['anime'],
47
- )
48
- template_id: Optional[int] = Field(
49
- None,
50
- description='Template ID (template_id must be activated before use)',
51
- examples=[302325299692608],
52
- )
53
- water_mark: Optional[bool] = Field(
54
- False,
55
- description='Watermark (true: add watermark, false: no watermark)',
56
- examples=[False],
57
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/apis/__init__.py DELETED
The diff for this file is too large to render. See raw diff
 
comfy_api_nodes/apis/bfl_api.py DELETED
@@ -1,174 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from enum import Enum
4
- from typing import Any, Dict, Optional
5
-
6
- from pydantic import BaseModel, Field, confloat, conint
7
-
8
-
9
- class BFLOutputFormat(str, Enum):
10
- png = 'png'
11
- jpeg = 'jpeg'
12
-
13
-
14
- class BFLFluxExpandImageRequest(BaseModel):
15
- prompt: str = Field(..., description='The description of the changes you want to make. This text guides the expansion process, allowing you to specify features, styles, or modifications for the expanded areas.')
16
- prompt_upsampling: Optional[bool] = Field(
17
- None, description='Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.'
18
- )
19
- seed: Optional[int] = Field(None, description='The seed value for reproducibility.')
20
- top: conint(ge=0, le=2048) = Field(..., description='Number of pixels to expand at the top of the image')
21
- bottom: conint(ge=0, le=2048) = Field(..., description='Number of pixels to expand at the bottom of the image')
22
- left: conint(ge=0, le=2048) = Field(..., description='Number of pixels to expand at the left side of the image')
23
- right: conint(ge=0, le=2048) = Field(..., description='Number of pixels to expand at the right side of the image')
24
- steps: conint(ge=15, le=50) = Field(..., description='Number of steps for the image generation process')
25
- guidance: confloat(ge=1.5, le=100) = Field(..., description='Guidance strength for the image generation process')
26
- safety_tolerance: Optional[conint(ge=0, le=6)] = Field(
27
- 6, description='Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict. Defaults to 2.'
28
- )
29
- output_format: Optional[BFLOutputFormat] = Field(
30
- BFLOutputFormat.png, description="Output format for the generated image. Can be 'jpeg' or 'png'.", examples=['png']
31
- )
32
- image: str = Field(None, description='A Base64-encoded string representing the image you wish to expand')
33
-
34
-
35
- class BFLFluxFillImageRequest(BaseModel):
36
- prompt: str = Field(..., description='The description of the changes you want to make. This text guides the expansion process, allowing you to specify features, styles, or modifications for the expanded areas.')
37
- prompt_upsampling: Optional[bool] = Field(
38
- None, description='Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.'
39
- )
40
- seed: Optional[int] = Field(None, description='The seed value for reproducibility.')
41
- steps: conint(ge=15, le=50) = Field(..., description='Number of steps for the image generation process')
42
- guidance: confloat(ge=1.5, le=100) = Field(..., description='Guidance strength for the image generation process')
43
- safety_tolerance: Optional[conint(ge=0, le=6)] = Field(
44
- 6, description='Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict. Defaults to 2.'
45
- )
46
- output_format: Optional[BFLOutputFormat] = Field(
47
- BFLOutputFormat.png, description="Output format for the generated image. Can be 'jpeg' or 'png'.", examples=['png']
48
- )
49
- image: str = Field(None, description='A Base64-encoded string representing the image you wish to modify. Can contain alpha mask if desired.')
50
- mask: str = Field(None, description='A Base64-encoded string representing the mask of the areas you with to modify.')
51
-
52
-
53
- class BFLFluxCannyImageRequest(BaseModel):
54
- prompt: str = Field(..., description='Text prompt for image generation')
55
- prompt_upsampling: Optional[bool] = Field(
56
- None, description='Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.'
57
- )
58
- canny_low_threshold: Optional[int] = Field(None, description='Low threshold for Canny edge detection')
59
- canny_high_threshold: Optional[int] = Field(None, description='High threshold for Canny edge detection')
60
- seed: Optional[int] = Field(None, description='The seed value for reproducibility.')
61
- steps: conint(ge=15, le=50) = Field(..., description='Number of steps for the image generation process')
62
- guidance: confloat(ge=1, le=100) = Field(..., description='Guidance strength for the image generation process')
63
- safety_tolerance: Optional[conint(ge=0, le=6)] = Field(
64
- 6, description='Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict. Defaults to 2.'
65
- )
66
- output_format: Optional[BFLOutputFormat] = Field(
67
- BFLOutputFormat.png, description="Output format for the generated image. Can be 'jpeg' or 'png'.", examples=['png']
68
- )
69
- control_image: Optional[str] = Field(None, description='Base64 encoded image to use as control input if no preprocessed image is provided')
70
- preprocessed_image: Optional[str] = Field(None, description='Optional pre-processed image that will bypass the control preprocessing step')
71
-
72
-
73
- class BFLFluxDepthImageRequest(BaseModel):
74
- prompt: str = Field(..., description='Text prompt for image generation')
75
- prompt_upsampling: Optional[bool] = Field(
76
- None, description='Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.'
77
- )
78
- seed: Optional[int] = Field(None, description='The seed value for reproducibility.')
79
- steps: conint(ge=15, le=50) = Field(..., description='Number of steps for the image generation process')
80
- guidance: confloat(ge=1, le=100) = Field(..., description='Guidance strength for the image generation process')
81
- safety_tolerance: Optional[conint(ge=0, le=6)] = Field(
82
- 6, description='Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict. Defaults to 2.'
83
- )
84
- output_format: Optional[BFLOutputFormat] = Field(
85
- BFLOutputFormat.png, description="Output format for the generated image. Can be 'jpeg' or 'png'.", examples=['png']
86
- )
87
- control_image: Optional[str] = Field(None, description='Base64 encoded image to use as control input if no preprocessed image is provided')
88
- preprocessed_image: Optional[str] = Field(None, description='Optional pre-processed image that will bypass the control preprocessing step')
89
-
90
-
91
- class BFLFluxProGenerateRequest(BaseModel):
92
- prompt: str = Field(..., description='The text prompt for image generation.')
93
- prompt_upsampling: Optional[bool] = Field(
94
- None, description='Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.'
95
- )
96
- seed: Optional[int] = Field(None, description='The seed value for reproducibility.')
97
- width: conint(ge=256, le=1440) = Field(1024, description='Width of the generated image in pixels. Must be a multiple of 32.')
98
- height: conint(ge=256, le=1440) = Field(768, description='Height of the generated image in pixels. Must be a multiple of 32.')
99
- safety_tolerance: Optional[conint(ge=0, le=6)] = Field(
100
- 6, description='Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict. Defaults to 2.'
101
- )
102
- output_format: Optional[BFLOutputFormat] = Field(
103
- BFLOutputFormat.png, description="Output format for the generated image. Can be 'jpeg' or 'png'.", examples=['png']
104
- )
105
- image_prompt: Optional[str] = Field(None, description='Optional image to remix in base64 format')
106
- # image_prompt_strength: Optional[confloat(ge=0.0, le=1.0)] = Field(
107
- # None, description='Blend between the prompt and the image prompt.'
108
- # )
109
-
110
-
111
- class BFLFluxKontextProGenerateRequest(BaseModel):
112
- prompt: str = Field(..., description='The text prompt for what you wannt to edit.')
113
- input_image: Optional[str] = Field(None, description='Image to edit in base64 format')
114
- seed: Optional[int] = Field(None, description='The seed value for reproducibility.')
115
- guidance: confloat(ge=0.1, le=99.0) = Field(..., description='Guidance strength for the image generation process')
116
- steps: conint(ge=1, le=150) = Field(..., description='Number of steps for the image generation process')
117
- safety_tolerance: Optional[conint(ge=0, le=2)] = Field(
118
- 2, description='Tolerance level for input and output moderation. Between 0 and 2, 0 being most strict, 6 being least strict. Defaults to 2.'
119
- )
120
- output_format: Optional[BFLOutputFormat] = Field(
121
- BFLOutputFormat.png, description="Output format for the generated image. Can be 'jpeg' or 'png'.", examples=['png']
122
- )
123
- aspect_ratio: Optional[str] = Field(None, description='Aspect ratio of the image between 21:9 and 9:21.')
124
- prompt_upsampling: Optional[bool] = Field(
125
- None, description='Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.'
126
- )
127
-
128
-
129
- class BFLFluxProUltraGenerateRequest(BaseModel):
130
- prompt: str = Field(..., description='The text prompt for image generation.')
131
- prompt_upsampling: Optional[bool] = Field(
132
- None, description='Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation.'
133
- )
134
- seed: Optional[int] = Field(None, description='The seed value for reproducibility.')
135
- aspect_ratio: Optional[str] = Field(None, description='Aspect ratio of the image between 21:9 and 9:21.')
136
- safety_tolerance: Optional[conint(ge=0, le=6)] = Field(
137
- 6, description='Tolerance level for input and output moderation. Between 0 and 6, 0 being most strict, 6 being least strict. Defaults to 2.'
138
- )
139
- output_format: Optional[BFLOutputFormat] = Field(
140
- BFLOutputFormat.png, description="Output format for the generated image. Can be 'jpeg' or 'png'.", examples=['png']
141
- )
142
- raw: Optional[bool] = Field(None, description='Generate less processed, more natural-looking images.')
143
- image_prompt: Optional[str] = Field(None, description='Optional image to remix in base64 format')
144
- image_prompt_strength: Optional[confloat(ge=0.0, le=1.0)] = Field(
145
- None, description='Blend between the prompt and the image prompt.'
146
- )
147
-
148
-
149
- class BFLFluxProGenerateResponse(BaseModel):
150
- id: str = Field(..., description='The unique identifier for the generation task.')
151
- polling_url: str = Field(..., description='URL to poll for the generation result.')
152
-
153
-
154
- class BFLStatus(str, Enum):
155
- task_not_found = "Task not found"
156
- pending = "Pending"
157
- request_moderated = "Request Moderated"
158
- content_moderated = "Content Moderated"
159
- ready = "Ready"
160
- error = "Error"
161
-
162
-
163
- class BFLFluxProStatusResponse(BaseModel):
164
- id: str = Field(..., description="The unique identifier for the generation task.")
165
- status: BFLStatus = Field(..., description="The status of the task.")
166
- result: Optional[Dict[str, Any]] = Field(
167
- None, description="The result of the task (null if not completed)."
168
- )
169
- progress: confloat(ge=0.0, le=1.0) = Field(
170
- ..., description="The progress of the task (0.0 to 1.0)."
171
- )
172
- details: Optional[Dict[str, Any]] = Field(
173
- None, description="Additional details about the task (null if not available)."
174
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/apis/client.py DELETED
@@ -1,1126 +0,0 @@
1
- """
2
- API Client Framework for api.comfy.org.
3
-
4
- This module provides a flexible framework for making API requests from ComfyUI nodes.
5
- It supports both synchronous and asynchronous API operations with proper type validation.
6
-
7
- Key Components:
8
- --------------
9
- 1. ApiClient - Handles HTTP requests with authentication and error handling
10
- 2. ApiEndpoint - Defines a single HTTP endpoint with its request/response models
11
- 3. ApiOperation - Executes a single synchronous API operation
12
-
13
- Usage Examples:
14
- --------------
15
-
16
- # Example 1: Synchronous API Operation
17
- # ------------------------------------
18
- # For a simple API call that returns the result immediately:
19
-
20
- # 1. Create the API client
21
- api_client = ApiClient(
22
- base_url="https://api.example.com",
23
- auth_token="your_auth_token_here",
24
- comfy_api_key="your_comfy_api_key_here",
25
- timeout=30.0,
26
- verify_ssl=True
27
- )
28
-
29
- # 2. Define the endpoint
30
- user_info_endpoint = ApiEndpoint(
31
- path="/v1/users/me",
32
- method=HttpMethod.GET,
33
- request_model=EmptyRequest, # No request body needed
34
- response_model=UserProfile, # Pydantic model for the response
35
- query_params=None
36
- )
37
-
38
- # 3. Create the request object
39
- request = EmptyRequest()
40
-
41
- # 4. Create and execute the operation
42
- operation = ApiOperation(
43
- endpoint=user_info_endpoint,
44
- request=request
45
- )
46
- user_profile = operation.execute(client=api_client) # Returns immediately with the result
47
-
48
-
49
- # Example 2: Asynchronous API Operation with Polling
50
- # -------------------------------------------------
51
- # For an API that starts a task and requires polling for completion:
52
-
53
- # 1. Define the endpoints (initial request and polling)
54
- generate_image_endpoint = ApiEndpoint(
55
- path="/v1/images/generate",
56
- method=HttpMethod.POST,
57
- request_model=ImageGenerationRequest,
58
- response_model=TaskCreatedResponse,
59
- query_params=None
60
- )
61
-
62
- check_task_endpoint = ApiEndpoint(
63
- path="/v1/tasks/{task_id}",
64
- method=HttpMethod.GET,
65
- request_model=EmptyRequest,
66
- response_model=ImageGenerationResult,
67
- query_params=None
68
- )
69
-
70
- # 2. Create the request object
71
- request = ImageGenerationRequest(
72
- prompt="a beautiful sunset over mountains",
73
- width=1024,
74
- height=1024,
75
- num_images=1
76
- )
77
-
78
- # 3. Create and execute the polling operation
79
- operation = PollingOperation(
80
- initial_endpoint=generate_image_endpoint,
81
- initial_request=request,
82
- poll_endpoint=check_task_endpoint,
83
- task_id_field="task_id",
84
- status_field="status",
85
- completed_statuses=["completed"],
86
- failed_statuses=["failed", "error"]
87
- )
88
-
89
- # This will make the initial request and then poll until completion
90
- result = operation.execute(client=api_client) # Returns the final ImageGenerationResult when done
91
- """
92
-
93
- from __future__ import annotations
94
- import logging
95
- import time
96
- import io
97
- import socket
98
- from typing import Dict, Type, Optional, Any, TypeVar, Generic, Callable, Tuple
99
- from enum import Enum
100
- import json
101
- import requests
102
- from urllib.parse import urljoin, urlparse
103
- from pydantic import BaseModel, Field
104
- import uuid # For generating unique operation IDs
105
-
106
- from server import PromptServer
107
- from comfy.cli_args import args
108
- from comfy import utils
109
- from . import request_logger
110
-
111
- T = TypeVar("T", bound=BaseModel)
112
- R = TypeVar("R", bound=BaseModel)
113
- P = TypeVar("P", bound=BaseModel) # For poll response
114
-
115
- PROGRESS_BAR_MAX = 100
116
-
117
-
118
- class NetworkError(Exception):
119
- """Base exception for network-related errors with diagnostic information."""
120
- pass
121
-
122
-
123
- class LocalNetworkError(NetworkError):
124
- """Exception raised when local network connectivity issues are detected."""
125
- pass
126
-
127
-
128
- class ApiServerError(NetworkError):
129
- """Exception raised when the API server is unreachable but internet is working."""
130
- pass
131
-
132
-
133
- class EmptyRequest(BaseModel):
134
- """Base class for empty request bodies.
135
- For GET requests, fields will be sent as query parameters."""
136
-
137
- pass
138
-
139
-
140
- class UploadRequest(BaseModel):
141
- file_name: str = Field(..., description="Filename to upload")
142
- content_type: Optional[str] = Field(
143
- None,
144
- description="Mime type of the file. For example: image/png, image/jpeg, video/mp4, etc.",
145
- )
146
-
147
-
148
- class UploadResponse(BaseModel):
149
- download_url: str = Field(..., description="URL to GET uploaded file")
150
- upload_url: str = Field(..., description="URL to PUT file to upload")
151
-
152
-
153
- class HttpMethod(str, Enum):
154
- GET = "GET"
155
- POST = "POST"
156
- PUT = "PUT"
157
- DELETE = "DELETE"
158
- PATCH = "PATCH"
159
-
160
-
161
- class ApiClient:
162
- """
163
- Client for making HTTP requests to an API with authentication, error handling, and retry logic.
164
- """
165
-
166
- def __init__(
167
- self,
168
- base_url: str,
169
- auth_token: Optional[str] = None,
170
- comfy_api_key: Optional[str] = None,
171
- timeout: float = 3600.0,
172
- verify_ssl: bool = True,
173
- max_retries: int = 3,
174
- retry_delay: float = 1.0,
175
- retry_backoff_factor: float = 2.0,
176
- retry_status_codes: Optional[Tuple[int, ...]] = None,
177
- ):
178
- self.base_url = base_url
179
- self.auth_token = auth_token
180
- self.comfy_api_key = comfy_api_key
181
- self.timeout = timeout
182
- self.verify_ssl = verify_ssl
183
- self.max_retries = max_retries
184
- self.retry_delay = retry_delay
185
- self.retry_backoff_factor = retry_backoff_factor
186
- # Default retry status codes: 408 (Request Timeout), 429 (Too Many Requests),
187
- # 500, 502, 503, 504 (Server Errors)
188
- self.retry_status_codes = retry_status_codes or (408, 429, 500, 502, 503, 504)
189
-
190
- def _generate_operation_id(self, path: str) -> str:
191
- """Generates a unique operation ID for logging."""
192
- return f"{path.strip('/').replace('/', '_')}_{uuid.uuid4().hex[:8]}"
193
-
194
- def _create_json_payload_args(
195
- self,
196
- data: Optional[Dict[str, Any]] = None,
197
- headers: Optional[Dict[str, str]] = None,
198
- ) -> Dict[str, Any]:
199
- return {
200
- "json": data,
201
- "headers": headers,
202
- }
203
-
204
- def _create_form_data_args(
205
- self,
206
- data: Dict[str, Any],
207
- files: Dict[str, Any],
208
- headers: Optional[Dict[str, str]] = None,
209
- multipart_parser = None,
210
- ) -> Dict[str, Any]:
211
- if headers and "Content-Type" in headers:
212
- del headers["Content-Type"]
213
-
214
- if multipart_parser:
215
- data = multipart_parser(data)
216
-
217
- return {
218
- "data": data,
219
- "files": files,
220
- "headers": headers,
221
- }
222
-
223
- def _create_urlencoded_form_data_args(
224
- self,
225
- data: Dict[str, Any],
226
- headers: Optional[Dict[str, str]] = None,
227
- ) -> Dict[str, Any]:
228
- headers = headers or {}
229
- headers["Content-Type"] = "application/x-www-form-urlencoded"
230
-
231
- return {
232
- "data": data,
233
- "headers": headers,
234
- }
235
-
236
- def get_headers(self) -> Dict[str, str]:
237
- """Get headers for API requests, including authentication if available"""
238
- headers = {"Content-Type": "application/json", "Accept": "application/json"}
239
-
240
- if self.auth_token:
241
- headers["Authorization"] = f"Bearer {self.auth_token}"
242
- elif self.comfy_api_key:
243
- headers["X-API-KEY"] = self.comfy_api_key
244
-
245
- return headers
246
-
247
- def _check_connectivity(self, target_url: str) -> Dict[str, bool]:
248
- """
249
- Check connectivity to determine if network issues are local or server-related.
250
-
251
- Args:
252
- target_url: URL to check connectivity to
253
-
254
- Returns:
255
- Dictionary with connectivity status details
256
- """
257
- results = {
258
- "internet_accessible": False,
259
- "api_accessible": False,
260
- "is_local_issue": False,
261
- "is_api_issue": False
262
- }
263
-
264
- # First check basic internet connectivity using a reliable external site
265
- try:
266
- # Use a reliable external domain for checking basic connectivity
267
- check_response = requests.get("https://www.google.com",
268
- timeout=5.0,
269
- verify=self.verify_ssl)
270
- if check_response.status_code < 500:
271
- results["internet_accessible"] = True
272
- except (requests.RequestException, socket.error):
273
- results["internet_accessible"] = False
274
- results["is_local_issue"] = True
275
- return results
276
-
277
- # Now check API server connectivity
278
- try:
279
- # Extract domain from the target URL to do a simpler health check
280
- parsed_url = urlparse(target_url)
281
- api_base = f"{parsed_url.scheme}://{parsed_url.netloc}"
282
-
283
- # Try to reach the API domain
284
- api_response = requests.get(f"{api_base}/health", timeout=5.0, verify=self.verify_ssl)
285
- if api_response.status_code < 500:
286
- results["api_accessible"] = True
287
- else:
288
- results["api_accessible"] = False
289
- results["is_api_issue"] = True
290
- except requests.RequestException:
291
- results["api_accessible"] = False
292
- # If we can reach the internet but not the API, it's an API issue
293
- results["is_api_issue"] = True
294
-
295
- return results
296
-
297
- def request(
298
- self,
299
- method: str,
300
- path: str,
301
- params: Optional[Dict[str, Any]] = None,
302
- data: Optional[Dict[str, Any]] = None,
303
- files: Optional[Dict[str, Any]] = None,
304
- headers: Optional[Dict[str, str]] = None,
305
- content_type: str = "application/json",
306
- multipart_parser: Callable = None,
307
- retry_count: int = 0, # Used internally for tracking retries
308
- ) -> Dict[str, Any]:
309
- """
310
- Make an HTTP request to the API with automatic retries for transient errors.
311
-
312
- Args:
313
- method: HTTP method (GET, POST, etc.)
314
- path: API endpoint path (will be joined with base_url)
315
- params: Query parameters
316
- data: body data
317
- files: Files to upload
318
- headers: Additional headers
319
- content_type: Content type of the request. Defaults to application/json.
320
- retry_count: Internal parameter for tracking retries, do not set manually
321
-
322
- Returns:
323
- Parsed JSON response
324
-
325
- Raises:
326
- LocalNetworkError: If local network connectivity issues are detected
327
- ApiServerError: If the API server is unreachable but internet is working
328
- Exception: For other request failures
329
- """
330
- # Use urljoin but ensure path is relative to avoid absolute path behavior
331
- relative_path = path.lstrip('/')
332
- url = urljoin(self.base_url, relative_path)
333
- self.check_auth(self.auth_token, self.comfy_api_key)
334
- # Combine default headers with any provided headers
335
- request_headers = self.get_headers()
336
- if headers:
337
- request_headers.update(headers)
338
-
339
- # Let requests handle the content type when files are present.
340
- if files:
341
- del request_headers["Content-Type"]
342
-
343
- logging.debug(f"[DEBUG] Request Headers: {request_headers}")
344
- logging.debug(f"[DEBUG] Files: {files}")
345
- logging.debug(f"[DEBUG] Params: {params}")
346
- logging.debug(f"[DEBUG] Data: {data}")
347
-
348
- if content_type == "application/x-www-form-urlencoded":
349
- payload_args = self._create_urlencoded_form_data_args(data, request_headers)
350
- elif content_type == "multipart/form-data":
351
- payload_args = self._create_form_data_args(
352
- data, files, request_headers, multipart_parser
353
- )
354
- else:
355
- payload_args = self._create_json_payload_args(data, request_headers)
356
-
357
- operation_id = self._generate_operation_id(path)
358
- request_logger.log_request_response(
359
- operation_id=operation_id,
360
- request_method=method,
361
- request_url=url,
362
- request_headers=request_headers,
363
- request_params=params,
364
- request_data=data if content_type == "application/json" else "[form-data or other]"
365
- )
366
-
367
- try:
368
- response = requests.request(
369
- method=method,
370
- url=url,
371
- params=params,
372
- timeout=self.timeout,
373
- verify=self.verify_ssl,
374
- **payload_args,
375
- )
376
-
377
- # Check if we should retry based on status code
378
- if (response.status_code in self.retry_status_codes and
379
- retry_count < self.max_retries):
380
-
381
- # Calculate delay with exponential backoff
382
- delay = self.retry_delay * (self.retry_backoff_factor ** retry_count)
383
-
384
- logging.warning(
385
- f"Request failed with status {response.status_code}. "
386
- f"Retrying in {delay:.2f}s ({retry_count + 1}/{self.max_retries})"
387
- )
388
-
389
- time.sleep(delay)
390
- return self.request(
391
- method=method,
392
- path=path,
393
- params=params,
394
- data=data,
395
- files=files,
396
- headers=headers,
397
- content_type=content_type,
398
- multipart_parser=multipart_parser,
399
- retry_count=retry_count + 1,
400
- )
401
-
402
- # Raise exception for error status codes
403
- response.raise_for_status()
404
-
405
- # Log successful response
406
- response_content_to_log = response.content
407
- try:
408
- # Attempt to parse JSON for prettier logging, fallback to raw content
409
- response_content_to_log = response.json()
410
- except json.JSONDecodeError:
411
- pass # Keep as bytes/str if not JSON
412
-
413
- request_logger.log_request_response(
414
- operation_id=operation_id,
415
- request_method=method, # Pass request details again for context in log
416
- request_url=url,
417
- response_status_code=response.status_code,
418
- response_headers=dict(response.headers),
419
- response_content=response_content_to_log
420
- )
421
-
422
- except requests.ConnectionError as e:
423
- error_message = f"ConnectionError: {str(e)}"
424
- request_logger.log_request_response(
425
- operation_id=operation_id,
426
- request_method=method,
427
- request_url=url,
428
- error_message=error_message
429
- )
430
- # Only perform connectivity check if we've exhausted all retries
431
- if retry_count >= self.max_retries:
432
- # Check connectivity to determine if it's a local or API issue
433
- connectivity = self._check_connectivity(self.base_url)
434
-
435
- if connectivity["is_local_issue"]:
436
- raise LocalNetworkError(
437
- "Unable to connect to the API server due to local network issues. "
438
- "Please check your internet connection and try again."
439
- ) from e
440
- elif connectivity["is_api_issue"]:
441
- raise ApiServerError(
442
- f"The API server at {self.base_url} is currently unreachable. "
443
- f"The service may be experiencing issues. Please try again later."
444
- ) from e
445
-
446
- # If we haven't exhausted retries yet, retry the request
447
- if retry_count < self.max_retries:
448
- delay = self.retry_delay * (self.retry_backoff_factor ** retry_count)
449
- logging.warning(
450
- f"Connection error: {str(e)}. "
451
- f"Retrying in {delay:.2f}s ({retry_count + 1}/{self.max_retries})"
452
- )
453
- time.sleep(delay)
454
- return self.request(
455
- method=method,
456
- path=path,
457
- params=params,
458
- data=data,
459
- files=files,
460
- headers=headers,
461
- content_type=content_type,
462
- multipart_parser=multipart_parser,
463
- retry_count=retry_count + 1,
464
- )
465
-
466
- # If we've exhausted retries and didn't identify the specific issue,
467
- # raise a generic exception
468
- final_error_message = (
469
- f"Unable to connect to the API server after {self.max_retries} attempts. "
470
- f"Please check your internet connection or try again later."
471
- )
472
- request_logger.log_request_response( # Log final failure
473
- operation_id=operation_id,
474
- request_method=method, request_url=url,
475
- error_message=final_error_message
476
- )
477
- raise Exception(final_error_message) from e
478
-
479
- except requests.Timeout as e:
480
- error_message = f"Timeout: {str(e)}"
481
- request_logger.log_request_response(
482
- operation_id=operation_id,
483
- request_method=method, request_url=url,
484
- error_message=error_message
485
- )
486
- # Retry timeouts if we haven't exhausted retries
487
- if retry_count < self.max_retries:
488
- delay = self.retry_delay * (self.retry_backoff_factor ** retry_count)
489
- logging.warning(
490
- f"Request timed out. "
491
- f"Retrying in {delay:.2f}s ({retry_count + 1}/{self.max_retries})"
492
- )
493
- time.sleep(delay)
494
- return self.request(
495
- method=method,
496
- path=path,
497
- params=params,
498
- data=data,
499
- files=files,
500
- headers=headers,
501
- content_type=content_type,
502
- multipart_parser=multipart_parser,
503
- retry_count=retry_count + 1,
504
- )
505
- final_error_message = (
506
- f"Request timed out after {self.timeout} seconds and {self.max_retries} retry attempts. "
507
- f"The server might be experiencing high load or the operation is taking longer than expected."
508
- )
509
- request_logger.log_request_response( # Log final failure
510
- operation_id=operation_id,
511
- request_method=method, request_url=url,
512
- error_message=final_error_message
513
- )
514
- raise Exception(final_error_message) from e
515
-
516
- except requests.HTTPError as e:
517
- status_code = e.response.status_code if hasattr(e, "response") else None
518
- original_error_message = f"HTTP Error: {str(e)}"
519
- error_content_for_log = None
520
- if hasattr(e, "response") and e.response is not None:
521
- error_content_for_log = e.response.content
522
- try:
523
- error_content_for_log = e.response.json()
524
- except json.JSONDecodeError:
525
- pass
526
-
527
-
528
- # Try to extract detailed error message from JSON response for user display
529
- # but log the full error content.
530
- user_display_error_message = original_error_message
531
-
532
- try:
533
- if hasattr(e, "response") and e.response is not None and e.response.content:
534
- error_json = e.response.json()
535
- if "error" in error_json and "message" in error_json["error"]:
536
- user_display_error_message = f"API Error: {error_json['error']['message']}"
537
- if "type" in error_json["error"]:
538
- user_display_error_message += f" (Type: {error_json['error']['type']})"
539
- elif isinstance(error_json, dict): # Handle cases where error is just a JSON dict
540
- user_display_error_message = f"API Error: {json.dumps(error_json)}"
541
- else: # Non-dict JSON error
542
- user_display_error_message = f"API Error: {str(error_json)}"
543
- except json.JSONDecodeError:
544
- # If not JSON, use the raw content if it's not too long, or a summary
545
- if hasattr(e, "response") and e.response is not None and e.response.content:
546
- raw_content = e.response.content.decode(errors='ignore')
547
- if len(raw_content) < 200: # Arbitrary limit for display
548
- user_display_error_message = f"API Error (raw): {raw_content}"
549
- else:
550
- user_display_error_message = f"API Error (raw, status {status_code})"
551
-
552
- request_logger.log_request_response(
553
- operation_id=operation_id,
554
- request_method=method, request_url=url,
555
- response_status_code=status_code,
556
- response_headers=dict(e.response.headers) if hasattr(e, "response") and e.response is not None else None,
557
- response_content=error_content_for_log,
558
- error_message=original_error_message # Log the original exception string as error
559
- )
560
-
561
- logging.debug(f"[DEBUG] API Error: {user_display_error_message} (Status: {status_code})")
562
- if hasattr(e, "response") and e.response is not None and e.response.content:
563
- logging.debug(f"[DEBUG] Response content: {e.response.content}")
564
-
565
- # Retry if the status code is in our retry list and we haven't exhausted retries
566
- if (status_code in self.retry_status_codes and
567
- retry_count < self.max_retries):
568
-
569
- delay = self.retry_delay * (self.retry_backoff_factor ** retry_count)
570
- logging.warning(
571
- f"HTTP error {status_code}. "
572
- f"Retrying in {delay:.2f}s ({retry_count + 1}/{self.max_retries})"
573
- )
574
- time.sleep(delay)
575
- return self.request(
576
- method=method,
577
- path=path,
578
- params=params,
579
- data=data,
580
- files=files,
581
- headers=headers,
582
- content_type=content_type,
583
- multipart_parser=multipart_parser,
584
- retry_count=retry_count + 1,
585
- )
586
-
587
- # Specific error messages for common status codes for user display
588
- if status_code == 401:
589
- user_display_error_message = "Unauthorized: Please login first to use this node."
590
- elif status_code == 402:
591
- user_display_error_message = "Payment Required: Please add credits to your account to use this node."
592
- elif status_code == 409:
593
- user_display_error_message = "There is a problem with your account. Please contact support@comfy.org."
594
- elif status_code == 429:
595
- user_display_error_message = "Rate Limit Exceeded: Please try again later."
596
- # else, user_display_error_message remains as parsed from response or original HTTPError string
597
-
598
- raise Exception(user_display_error_message) # Raise with the user-friendly message
599
-
600
- # Parse and return JSON response
601
- if response.content:
602
- return response.json()
603
- return {}
604
-
605
- def check_auth(self, auth_token, comfy_api_key):
606
- """Verify that an auth token is present or comfy_api_key is present"""
607
- if auth_token is None and comfy_api_key is None:
608
- raise Exception("Unauthorized: Please login first to use this node.")
609
- return auth_token or comfy_api_key
610
-
611
- @staticmethod
612
- def upload_file(
613
- upload_url: str,
614
- file: io.BytesIO | str,
615
- content_type: str | None = None,
616
- max_retries: int = 3,
617
- retry_delay: float = 1.0,
618
- retry_backoff_factor: float = 2.0,
619
- ):
620
- """Upload a file to the API with retry logic.
621
-
622
- Args:
623
- upload_url: The URL to upload to
624
- file: Either a file path string, BytesIO object, or tuple of (file_path, filename)
625
- content_type: Optional mime type to set for the upload
626
- max_retries: Maximum number of retry attempts
627
- retry_delay: Initial delay between retries in seconds
628
- retry_backoff_factor: Multiplier for the delay after each retry
629
- """
630
- headers = {}
631
- if content_type:
632
- headers["Content-Type"] = content_type
633
-
634
- # Prepare the file data
635
- if isinstance(file, io.BytesIO):
636
- file.seek(0) # Ensure we're at the start of the file
637
- data = file.read()
638
- elif isinstance(file, str):
639
- with open(file, "rb") as f:
640
- data = f.read()
641
- else:
642
- raise ValueError("File must be either a BytesIO object or a file path string")
643
-
644
- # Try the upload with retries
645
- last_exception = None
646
- operation_id = f"upload_{upload_url.split('/')[-1]}_{uuid.uuid4().hex[:8]}" # Simplified ID for uploads
647
-
648
- # Log initial attempt (without full file data for brevity)
649
- request_logger.log_request_response(
650
- operation_id=operation_id,
651
- request_method="PUT",
652
- request_url=upload_url,
653
- request_headers=headers,
654
- request_data=f"[File data of type {content_type or 'unknown'}, size {len(data)} bytes]"
655
- )
656
-
657
- for retry_attempt in range(max_retries + 1):
658
- try:
659
- response = requests.put(upload_url, data=data, headers=headers)
660
- response.raise_for_status()
661
- request_logger.log_request_response(
662
- operation_id=operation_id,
663
- request_method="PUT", request_url=upload_url, # For context
664
- response_status_code=response.status_code,
665
- response_headers=dict(response.headers),
666
- response_content="File uploaded successfully." # Or response.text if available
667
- )
668
- return response
669
-
670
- except (requests.ConnectionError, requests.Timeout, requests.HTTPError) as e:
671
- last_exception = e
672
- error_message_for_log = f"{type(e).__name__}: {str(e)}"
673
- response_content_for_log = None
674
- status_code_for_log = None
675
- headers_for_log = None
676
-
677
- if hasattr(e, 'response') and e.response is not None:
678
- status_code_for_log = e.response.status_code
679
- headers_for_log = dict(e.response.headers)
680
- try:
681
- response_content_for_log = e.response.json()
682
- except json.JSONDecodeError:
683
- response_content_for_log = e.response.content
684
-
685
-
686
- request_logger.log_request_response(
687
- operation_id=operation_id,
688
- request_method="PUT", request_url=upload_url,
689
- response_status_code=status_code_for_log,
690
- response_headers=headers_for_log,
691
- response_content=response_content_for_log,
692
- error_message=error_message_for_log
693
- )
694
-
695
- if retry_attempt < max_retries:
696
- delay = retry_delay * (retry_backoff_factor ** retry_attempt)
697
- logging.warning(
698
- f"File upload failed: {str(e)}. "
699
- f"Retrying in {delay:.2f}s ({retry_attempt + 1}/{max_retries})"
700
- )
701
- time.sleep(delay)
702
- else:
703
- break # Max retries reached
704
-
705
- # If we've exhausted all retries, determine the final error type and raise
706
- final_error_message = f"Failed to upload file after {max_retries + 1} attempts. Error: {str(last_exception)}"
707
- try:
708
- # Check basic internet connectivity
709
- check_response = requests.get("https://www.google.com", timeout=5.0, verify=True) # Assuming verify=True is desired
710
- if check_response.status_code >= 500: # Google itself has an issue (rare)
711
- final_error_message = (f"Failed to upload file. Internet connectivity check to Google failed "
712
- f"(status {check_response.status_code}). Original error: {str(last_exception)}")
713
- # Not raising LocalNetworkError here as Google itself might be down.
714
- # If Google is reachable, the issue is likely with the upload server or a more specific local problem
715
- # not caught by a simple Google ping (e.g., DNS for the specific upload URL, firewall).
716
- # The original last_exception is probably most relevant.
717
-
718
- except (requests.RequestException, socket.error) as conn_check_exc:
719
- # Could not reach Google, likely a local network issue
720
- final_error_message = (f"Failed to upload file due to network connectivity issues "
721
- f"(cannot reach Google: {str(conn_check_exc)}). "
722
- f"Original upload error: {str(last_exception)}")
723
- request_logger.log_request_response( # Log final failure reason
724
- operation_id=operation_id,
725
- request_method="PUT", request_url=upload_url,
726
- error_message=final_error_message
727
- )
728
- raise LocalNetworkError(final_error_message) from last_exception
729
-
730
- request_logger.log_request_response( # Log final failure reason if not LocalNetworkError
731
- operation_id=operation_id,
732
- request_method="PUT", request_url=upload_url,
733
- error_message=final_error_message
734
- )
735
- raise Exception(final_error_message) from last_exception
736
-
737
-
738
- class ApiEndpoint(Generic[T, R]):
739
- """Defines an API endpoint with its request and response types"""
740
-
741
- def __init__(
742
- self,
743
- path: str,
744
- method: HttpMethod,
745
- request_model: Type[T],
746
- response_model: Type[R],
747
- query_params: Optional[Dict[str, Any]] = None,
748
- ):
749
- """Initialize an API endpoint definition.
750
-
751
- Args:
752
- path: The URL path for this endpoint, can include placeholders like {id}
753
- method: The HTTP method to use (GET, POST, etc.)
754
- request_model: Pydantic model class that defines the structure and validation rules for API requests to this endpoint
755
- response_model: Pydantic model class that defines the structure and validation rules for API responses from this endpoint
756
- query_params: Optional dictionary of query parameters to include in the request
757
- """
758
- self.path = path
759
- self.method = method
760
- self.request_model = request_model
761
- self.response_model = response_model
762
- self.query_params = query_params or {}
763
-
764
-
765
- class SynchronousOperation(Generic[T, R]):
766
- """
767
- Represents a single synchronous API operation.
768
- """
769
-
770
- def __init__(
771
- self,
772
- endpoint: ApiEndpoint[T, R],
773
- request: T,
774
- files: Optional[Dict[str, Any]] = None,
775
- api_base: str | None = None,
776
- auth_token: Optional[str] = None,
777
- comfy_api_key: Optional[str] = None,
778
- auth_kwargs: Optional[Dict[str,str]] = None,
779
- timeout: float = 604800.0,
780
- verify_ssl: bool = True,
781
- content_type: str = "application/json",
782
- multipart_parser: Callable = None,
783
- max_retries: int = 3,
784
- retry_delay: float = 1.0,
785
- retry_backoff_factor: float = 2.0,
786
- ):
787
- self.endpoint = endpoint
788
- self.request = request
789
- self.response = None
790
- self.error = None
791
- self.api_base: str = api_base or args.comfy_api_base
792
- self.auth_token = auth_token
793
- self.comfy_api_key = comfy_api_key
794
- if auth_kwargs is not None:
795
- self.auth_token = auth_kwargs.get("auth_token", self.auth_token)
796
- self.comfy_api_key = auth_kwargs.get("comfy_api_key", self.comfy_api_key)
797
- self.timeout = timeout
798
- self.verify_ssl = verify_ssl
799
- self.files = files
800
- self.content_type = content_type
801
- self.multipart_parser = multipart_parser
802
- self.max_retries = max_retries
803
- self.retry_delay = retry_delay
804
- self.retry_backoff_factor = retry_backoff_factor
805
-
806
- def execute(self, client: Optional[ApiClient] = None) -> R:
807
- """Execute the API operation using the provided client or create one with retry support"""
808
- try:
809
- # Create client if not provided
810
- if client is None:
811
- client = ApiClient(
812
- base_url=self.api_base,
813
- auth_token=self.auth_token,
814
- comfy_api_key=self.comfy_api_key,
815
- timeout=self.timeout,
816
- verify_ssl=self.verify_ssl,
817
- max_retries=self.max_retries,
818
- retry_delay=self.retry_delay,
819
- retry_backoff_factor=self.retry_backoff_factor,
820
- )
821
-
822
- # Convert request model to dict, but use None for EmptyRequest
823
- request_dict = (
824
- None
825
- if isinstance(self.request, EmptyRequest)
826
- else self.request.model_dump(exclude_none=True)
827
- )
828
- if request_dict:
829
- for key, value in request_dict.items():
830
- if isinstance(value, Enum):
831
- request_dict[key] = value.value
832
-
833
- # Debug log for request
834
- logging.debug(
835
- f"[DEBUG] API Request: {self.endpoint.method.value} {self.endpoint.path}"
836
- )
837
- logging.debug(f"[DEBUG] Request Data: {json.dumps(request_dict, indent=2)}")
838
- logging.debug(f"[DEBUG] Query Params: {self.endpoint.query_params}")
839
-
840
- # Make the request with built-in retry
841
- resp = client.request(
842
- method=self.endpoint.method.value,
843
- path=self.endpoint.path,
844
- data=request_dict,
845
- params=self.endpoint.query_params,
846
- files=self.files,
847
- content_type=self.content_type,
848
- multipart_parser=self.multipart_parser
849
- )
850
-
851
- # Debug log for response
852
- logging.debug("=" * 50)
853
- logging.debug("[DEBUG] RESPONSE DETAILS:")
854
- logging.debug("[DEBUG] Status Code: 200 (Success)")
855
- logging.debug(f"[DEBUG] Response Body: {json.dumps(resp, indent=2)}")
856
- logging.debug("=" * 50)
857
-
858
- # Parse and return the response
859
- return self._parse_response(resp)
860
-
861
- except LocalNetworkError as e:
862
- # Propagate specific network error types
863
- logging.error(f"[ERROR] Local network error: {str(e)}")
864
- raise
865
-
866
- except ApiServerError as e:
867
- # Propagate API server errors
868
- logging.error(f"[ERROR] API server error: {str(e)}")
869
- raise
870
-
871
- except Exception as e:
872
- logging.error(f"[ERROR] API Exception: {str(e)}")
873
- raise Exception(str(e))
874
-
875
- def _parse_response(self, resp):
876
- """Parse response data - can be overridden by subclasses"""
877
- # The response is already the complete object, don't extract just the "data" field
878
- # as that would lose the outer structure (created timestamp, etc.)
879
-
880
- # Parse response using the provided model
881
- self.response = self.endpoint.response_model.model_validate(resp)
882
- logging.debug(f"[DEBUG] Parsed Response: {self.response}")
883
- return self.response
884
-
885
-
886
- class TaskStatus(str, Enum):
887
- """Enum for task status values"""
888
-
889
- COMPLETED = "completed"
890
- FAILED = "failed"
891
- PENDING = "pending"
892
-
893
-
894
- class PollingOperation(Generic[T, R]):
895
- """
896
- Represents an asynchronous API operation that requires polling for completion.
897
- """
898
-
899
- def __init__(
900
- self,
901
- poll_endpoint: ApiEndpoint[EmptyRequest, R],
902
- completed_statuses: list,
903
- failed_statuses: list,
904
- status_extractor: Callable[[R], str],
905
- progress_extractor: Callable[[R], float] = None,
906
- result_url_extractor: Callable[[R], str] = None,
907
- request: Optional[T] = None,
908
- api_base: str | None = None,
909
- auth_token: Optional[str] = None,
910
- comfy_api_key: Optional[str] = None,
911
- auth_kwargs: Optional[Dict[str,str]] = None,
912
- poll_interval: float = 5.0,
913
- max_poll_attempts: int = 120, # Default max polling attempts (10 minutes with 5s interval)
914
- max_retries: int = 3, # Max retries per individual API call
915
- retry_delay: float = 1.0,
916
- retry_backoff_factor: float = 2.0,
917
- estimated_duration: Optional[float] = None,
918
- node_id: Optional[str] = None,
919
- ):
920
- self.poll_endpoint = poll_endpoint
921
- self.request = request
922
- self.api_base: str = api_base or args.comfy_api_base
923
- self.auth_token = auth_token
924
- self.comfy_api_key = comfy_api_key
925
- if auth_kwargs is not None:
926
- self.auth_token = auth_kwargs.get("auth_token", self.auth_token)
927
- self.comfy_api_key = auth_kwargs.get("comfy_api_key", self.comfy_api_key)
928
- self.poll_interval = poll_interval
929
- self.max_poll_attempts = max_poll_attempts
930
- self.max_retries = max_retries
931
- self.retry_delay = retry_delay
932
- self.retry_backoff_factor = retry_backoff_factor
933
- self.estimated_duration = estimated_duration
934
-
935
- # Polling configuration
936
- self.status_extractor = status_extractor or (
937
- lambda x: getattr(x, "status", None)
938
- )
939
- self.progress_extractor = progress_extractor
940
- self.result_url_extractor = result_url_extractor
941
- self.node_id = node_id
942
- self.completed_statuses = completed_statuses
943
- self.failed_statuses = failed_statuses
944
-
945
- # For storing response data
946
- self.final_response = None
947
- self.error = None
948
-
949
- def execute(self, client: Optional[ApiClient] = None) -> R:
950
- """Execute the polling operation using the provided client. If failed, raise an exception."""
951
- try:
952
- if client is None:
953
- client = ApiClient(
954
- base_url=self.api_base,
955
- auth_token=self.auth_token,
956
- comfy_api_key=self.comfy_api_key,
957
- max_retries=self.max_retries,
958
- retry_delay=self.retry_delay,
959
- retry_backoff_factor=self.retry_backoff_factor,
960
- )
961
- return self._poll_until_complete(client)
962
- except LocalNetworkError as e:
963
- # Provide clear message for local network issues
964
- raise Exception(
965
- f"Polling failed due to local network issues. Please check your internet connection. "
966
- f"Details: {str(e)}"
967
- ) from e
968
- except ApiServerError as e:
969
- # Provide clear message for API server issues
970
- raise Exception(
971
- f"Polling failed due to API server issues. The service may be experiencing problems. "
972
- f"Please try again later. Details: {str(e)}"
973
- ) from e
974
- except Exception as e:
975
- raise Exception(f"Error during polling: {str(e)}")
976
-
977
- def _display_text_on_node(self, text: str):
978
- """Sends text to the client which will be displayed on the node in the UI"""
979
- if not self.node_id:
980
- return
981
-
982
- PromptServer.instance.send_progress_text(text, self.node_id)
983
-
984
- def _display_time_progress_on_node(self, time_completed: int):
985
- if not self.node_id:
986
- return
987
-
988
- if self.estimated_duration is not None:
989
- estimated_time_remaining = max(
990
- 0, int(self.estimated_duration) - int(time_completed)
991
- )
992
- message = f"Task in progress: {time_completed:.0f}s (~{estimated_time_remaining:.0f}s remaining)"
993
- else:
994
- message = f"Task in progress: {time_completed:.0f}s"
995
- self._display_text_on_node(message)
996
-
997
- def _check_task_status(self, response: R) -> TaskStatus:
998
- """Check task status using the status extractor function"""
999
- try:
1000
- status = self.status_extractor(response)
1001
- if status in self.completed_statuses:
1002
- return TaskStatus.COMPLETED
1003
- elif status in self.failed_statuses:
1004
- return TaskStatus.FAILED
1005
- return TaskStatus.PENDING
1006
- except Exception as e:
1007
- logging.error(f"Error extracting status: {e}")
1008
- return TaskStatus.PENDING
1009
-
1010
- def _poll_until_complete(self, client: ApiClient) -> R:
1011
- """Poll until the task is complete"""
1012
- poll_count = 0
1013
- consecutive_errors = 0
1014
- max_consecutive_errors = min(5, self.max_retries * 2) # Limit consecutive errors
1015
-
1016
- if self.progress_extractor:
1017
- progress = utils.ProgressBar(PROGRESS_BAR_MAX)
1018
-
1019
- while poll_count < self.max_poll_attempts:
1020
- try:
1021
- poll_count += 1
1022
- logging.debug(f"[DEBUG] Polling attempt #{poll_count}")
1023
-
1024
- request_dict = (
1025
- self.request.model_dump(exclude_none=True)
1026
- if self.request is not None
1027
- else None
1028
- )
1029
-
1030
- if poll_count == 1:
1031
- logging.debug(
1032
- f"[DEBUG] Poll Request: {self.poll_endpoint.method.value} {self.poll_endpoint.path}"
1033
- )
1034
- logging.debug(
1035
- f"[DEBUG] Poll Request Data: {json.dumps(request_dict, indent=2) if request_dict else 'None'}"
1036
- )
1037
-
1038
- # Query task status
1039
- resp = client.request(
1040
- method=self.poll_endpoint.method.value,
1041
- path=self.poll_endpoint.path,
1042
- params=self.poll_endpoint.query_params,
1043
- data=request_dict,
1044
- )
1045
-
1046
- # Successfully got a response, reset consecutive error count
1047
- consecutive_errors = 0
1048
-
1049
- # Parse response
1050
- response_obj = self.poll_endpoint.response_model.model_validate(resp)
1051
-
1052
- # Check if task is complete
1053
- status = self._check_task_status(response_obj)
1054
- logging.debug(f"[DEBUG] Task Status: {status}")
1055
-
1056
- # If progress extractor is provided, extract progress
1057
- if self.progress_extractor:
1058
- new_progress = self.progress_extractor(response_obj)
1059
- if new_progress is not None:
1060
- progress.update_absolute(new_progress, total=PROGRESS_BAR_MAX)
1061
-
1062
- if status == TaskStatus.COMPLETED:
1063
- message = "Task completed successfully"
1064
- if self.result_url_extractor:
1065
- result_url = self.result_url_extractor(response_obj)
1066
- if result_url:
1067
- message = f"Result URL: {result_url}"
1068
- else:
1069
- message = "Task completed successfully!"
1070
- logging.debug(f"[DEBUG] {message}")
1071
- self._display_text_on_node(message)
1072
- self.final_response = response_obj
1073
- if self.progress_extractor:
1074
- progress.update(100)
1075
- return self.final_response
1076
- elif status == TaskStatus.FAILED:
1077
- message = f"Task failed: {json.dumps(resp)}"
1078
- logging.error(f"[DEBUG] {message}")
1079
- raise Exception(message)
1080
- else:
1081
- logging.debug("[DEBUG] Task still pending, continuing to poll...")
1082
-
1083
- # Wait before polling again
1084
- logging.debug(
1085
- f"[DEBUG] Waiting {self.poll_interval} seconds before next poll"
1086
- )
1087
- for i in range(int(self.poll_interval)):
1088
- time_completed = (poll_count * self.poll_interval) + i
1089
- self._display_time_progress_on_node(time_completed)
1090
- time.sleep(1)
1091
-
1092
- except (LocalNetworkError, ApiServerError) as e:
1093
- # For network-related errors, increment error count and potentially abort
1094
- consecutive_errors += 1
1095
- if consecutive_errors >= max_consecutive_errors:
1096
- raise Exception(
1097
- f"Polling aborted after {consecutive_errors} consecutive network errors: {str(e)}"
1098
- ) from e
1099
-
1100
- # Log the error but continue polling
1101
- logging.warning(
1102
- f"Network error during polling (attempt {poll_count}/{self.max_poll_attempts}): {str(e)}. "
1103
- f"Will retry in {self.poll_interval} seconds."
1104
- )
1105
- time.sleep(self.poll_interval)
1106
-
1107
- except Exception as e:
1108
- # For other errors, increment count and potentially abort
1109
- consecutive_errors += 1
1110
- if consecutive_errors >= max_consecutive_errors or status == TaskStatus.FAILED:
1111
- raise Exception(
1112
- f"Polling aborted after {consecutive_errors} consecutive errors: {str(e)}"
1113
- ) from e
1114
-
1115
- logging.error(f"[DEBUG] Polling error: {str(e)}")
1116
- logging.warning(
1117
- f"Error during polling (attempt {poll_count}/{self.max_poll_attempts}): {str(e)}. "
1118
- f"Will retry in {self.poll_interval} seconds."
1119
- )
1120
- time.sleep(self.poll_interval)
1121
-
1122
- # If we've exhausted all polling attempts
1123
- raise Exception(
1124
- f"Polling timed out after {poll_count} attempts ({poll_count * self.poll_interval} seconds). "
1125
- f"The operation may still be running on the server but is taking longer than expected."
1126
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/apis/luma_api.py DELETED
@@ -1,253 +0,0 @@
1
- from __future__ import annotations
2
-
3
-
4
- import torch
5
-
6
- from enum import Enum
7
- from typing import Optional, Union
8
-
9
- from pydantic import BaseModel, Field, confloat
10
-
11
-
12
-
13
- class LumaIO:
14
- LUMA_REF = "LUMA_REF"
15
- LUMA_CONCEPTS = "LUMA_CONCEPTS"
16
-
17
-
18
- class LumaReference:
19
- def __init__(self, image: torch.Tensor, weight: float):
20
- self.image = image
21
- self.weight = weight
22
-
23
- def create_api_model(self, download_url: str):
24
- return LumaImageRef(url=download_url, weight=self.weight)
25
-
26
- class LumaReferenceChain:
27
- def __init__(self, first_ref: LumaReference=None):
28
- self.refs: list[LumaReference] = []
29
- if first_ref:
30
- self.refs.append(first_ref)
31
-
32
- def add(self, luma_ref: LumaReference=None):
33
- self.refs.append(luma_ref)
34
-
35
- def create_api_model(self, download_urls: list[str], max_refs=4):
36
- if len(self.refs) == 0:
37
- return None
38
- api_refs: list[LumaImageRef] = []
39
- for ref, url in zip(self.refs, download_urls):
40
- api_ref = LumaImageRef(url=url, weight=ref.weight)
41
- api_refs.append(api_ref)
42
- return api_refs
43
-
44
- def clone(self):
45
- c = LumaReferenceChain()
46
- for ref in self.refs:
47
- c.add(ref)
48
- return c
49
-
50
-
51
- class LumaConcept:
52
- def __init__(self, key: str):
53
- self.key = key
54
-
55
-
56
- class LumaConceptChain:
57
- def __init__(self, str_list: list[str] = None):
58
- self.concepts: list[LumaConcept] = []
59
- if str_list is not None:
60
- for c in str_list:
61
- if c != "None":
62
- self.add(LumaConcept(key=c))
63
-
64
- def add(self, concept: LumaConcept):
65
- self.concepts.append(concept)
66
-
67
- def create_api_model(self):
68
- if len(self.concepts) == 0:
69
- return None
70
- api_concepts: list[LumaConceptObject] = []
71
- for concept in self.concepts:
72
- if concept.key == "None":
73
- continue
74
- api_concepts.append(LumaConceptObject(key=concept.key))
75
- if len(api_concepts) == 0:
76
- return None
77
- return api_concepts
78
-
79
- def clone(self):
80
- c = LumaConceptChain()
81
- for concept in self.concepts:
82
- c.add(concept)
83
- return c
84
-
85
- def clone_and_merge(self, other: LumaConceptChain):
86
- c = self.clone()
87
- for concept in other.concepts:
88
- c.add(concept)
89
- return c
90
-
91
-
92
- def get_luma_concepts(include_none=False):
93
- concepts = []
94
- if include_none:
95
- concepts.append("None")
96
- return concepts + [
97
- "truck_left",
98
- "pan_right",
99
- "pedestal_down",
100
- "low_angle",
101
- "pedestal_up",
102
- "selfie",
103
- "pan_left",
104
- "roll_right",
105
- "zoom_in",
106
- "over_the_shoulder",
107
- "orbit_right",
108
- "orbit_left",
109
- "static",
110
- "tiny_planet",
111
- "high_angle",
112
- "bolt_cam",
113
- "dolly_zoom",
114
- "overhead",
115
- "zoom_out",
116
- "handheld",
117
- "roll_left",
118
- "pov",
119
- "aerial_drone",
120
- "push_in",
121
- "crane_down",
122
- "truck_right",
123
- "tilt_down",
124
- "elevator_doors",
125
- "tilt_up",
126
- "ground_level",
127
- "pull_out",
128
- "aerial",
129
- "crane_up",
130
- "eye_level"
131
- ]
132
-
133
-
134
- class LumaImageModel(str, Enum):
135
- photon_1 = "photon-1"
136
- photon_flash_1 = "photon-flash-1"
137
-
138
-
139
- class LumaVideoModel(str, Enum):
140
- ray_2 = "ray-2"
141
- ray_flash_2 = "ray-flash-2"
142
- ray_1_6 = "ray-1-6"
143
-
144
-
145
- class LumaAspectRatio(str, Enum):
146
- ratio_1_1 = "1:1"
147
- ratio_16_9 = "16:9"
148
- ratio_9_16 = "9:16"
149
- ratio_4_3 = "4:3"
150
- ratio_3_4 = "3:4"
151
- ratio_21_9 = "21:9"
152
- ratio_9_21 = "9:21"
153
-
154
-
155
- class LumaVideoOutputResolution(str, Enum):
156
- res_540p = "540p"
157
- res_720p = "720p"
158
- res_1080p = "1080p"
159
- res_4k = "4k"
160
-
161
-
162
- class LumaVideoModelOutputDuration(str, Enum):
163
- dur_5s = "5s"
164
- dur_9s = "9s"
165
-
166
-
167
- class LumaGenerationType(str, Enum):
168
- video = 'video'
169
- image = 'image'
170
-
171
-
172
- class LumaState(str, Enum):
173
- queued = "queued"
174
- dreaming = "dreaming"
175
- completed = "completed"
176
- failed = "failed"
177
-
178
-
179
- class LumaAssets(BaseModel):
180
- video: Optional[str] = Field(None, description='The URL of the video')
181
- image: Optional[str] = Field(None, description='The URL of the image')
182
- progress_video: Optional[str] = Field(None, description='The URL of the progress video')
183
-
184
-
185
- class LumaImageRef(BaseModel):
186
- '''Used for image gen'''
187
- url: str = Field(..., description='The URL of the image reference')
188
- weight: confloat(ge=0.0, le=1.0) = Field(..., description='The weight of the image reference')
189
-
190
-
191
- class LumaImageReference(BaseModel):
192
- '''Used for video gen'''
193
- type: Optional[str] = Field('image', description='Input type, defaults to image')
194
- url: str = Field(..., description='The URL of the image')
195
-
196
-
197
- class LumaModifyImageRef(BaseModel):
198
- url: str = Field(..., description='The URL of the image reference')
199
- weight: confloat(ge=0.0, le=1.0) = Field(..., description='The weight of the image reference')
200
-
201
-
202
- class LumaCharacterRef(BaseModel):
203
- identity0: LumaImageIdentity = Field(..., description='The image identity object')
204
-
205
-
206
- class LumaImageIdentity(BaseModel):
207
- images: list[str] = Field(..., description='The URLs of the image identity')
208
-
209
-
210
- class LumaGenerationReference(BaseModel):
211
- type: str = Field('generation', description='Input type, defaults to generation')
212
- id: str = Field(..., description='The ID of the generation')
213
-
214
-
215
- class LumaKeyframes(BaseModel):
216
- frame0: Optional[Union[LumaImageReference, LumaGenerationReference]] = Field(None, description='')
217
- frame1: Optional[Union[LumaImageReference, LumaGenerationReference]] = Field(None, description='')
218
-
219
-
220
- class LumaConceptObject(BaseModel):
221
- key: str = Field(..., description='Camera Concept name')
222
-
223
-
224
- class LumaImageGenerationRequest(BaseModel):
225
- prompt: str = Field(..., description='The prompt of the generation')
226
- model: LumaImageModel = Field(LumaImageModel.photon_1, description='The image model used for the generation')
227
- aspect_ratio: Optional[LumaAspectRatio] = Field(LumaAspectRatio.ratio_16_9, description='The aspect ratio of the generation')
228
- image_ref: Optional[list[LumaImageRef]] = Field(None, description='List of image reference objects')
229
- style_ref: Optional[list[LumaImageRef]] = Field(None, description='List of style reference objects')
230
- character_ref: Optional[LumaCharacterRef] = Field(None, description='The image identity object')
231
- modify_image_ref: Optional[LumaModifyImageRef] = Field(None, description='The modify image reference object')
232
-
233
-
234
- class LumaGenerationRequest(BaseModel):
235
- prompt: str = Field(..., description='The prompt of the generation')
236
- model: LumaVideoModel = Field(LumaVideoModel.ray_2, description='The video model used for the generation')
237
- duration: Optional[LumaVideoModelOutputDuration] = Field(None, description='The duration of the generation')
238
- aspect_ratio: Optional[LumaAspectRatio] = Field(None, description='The aspect ratio of the generation')
239
- resolution: Optional[LumaVideoOutputResolution] = Field(None, description='The resolution of the generation')
240
- loop: Optional[bool] = Field(None, description='Whether to loop the video')
241
- keyframes: Optional[LumaKeyframes] = Field(None, description='The keyframes of the generation')
242
- concepts: Optional[list[LumaConceptObject]] = Field(None, description='Camera Concepts to apply to generation')
243
-
244
-
245
- class LumaGeneration(BaseModel):
246
- id: str = Field(..., description='The ID of the generation')
247
- generation_type: LumaGenerationType = Field(..., description='Generation type, image or video')
248
- state: LumaState = Field(..., description='The state of the generation')
249
- failure_reason: Optional[str] = Field(None, description='The reason for the state of the generation')
250
- created_at: str = Field(..., description='The date and time when the generation was created')
251
- assets: Optional[LumaAssets] = Field(None, description='The assets of the generation')
252
- model: str = Field(..., description='The model used for the generation')
253
- request: Union[LumaGenerationRequest, LumaImageGenerationRequest] = Field(..., description="The request used for the generation")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/apis/pixverse_api.py DELETED
@@ -1,146 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from enum import Enum
4
- from typing import Optional
5
-
6
- from pydantic import BaseModel, Field
7
-
8
-
9
- pixverse_templates = {
10
- "Microwave": 324641385496960,
11
- "Suit Swagger": 328545151283968,
12
- "Anything, Robot": 313358700761536,
13
- "Subject 3 Fever": 327828816843648,
14
- "kiss kiss": 315446315336768,
15
- }
16
-
17
-
18
- class PixverseIO:
19
- TEMPLATE = "PIXVERSE_TEMPLATE"
20
-
21
-
22
- class PixverseStatus(int, Enum):
23
- successful = 1
24
- generating = 5
25
- deleted = 6
26
- contents_moderation = 7
27
- failed = 8
28
-
29
-
30
- class PixverseAspectRatio(str, Enum):
31
- ratio_16_9 = "16:9"
32
- ratio_4_3 = "4:3"
33
- ratio_1_1 = "1:1"
34
- ratio_3_4 = "3:4"
35
- ratio_9_16 = "9:16"
36
-
37
-
38
- class PixverseQuality(str, Enum):
39
- res_360p = "360p"
40
- res_540p = "540p"
41
- res_720p = "720p"
42
- res_1080p = "1080p"
43
-
44
-
45
- class PixverseDuration(int, Enum):
46
- dur_5 = 5
47
- dur_8 = 8
48
-
49
-
50
- class PixverseMotionMode(str, Enum):
51
- normal = "normal"
52
- fast = "fast"
53
-
54
-
55
- class PixverseStyle(str, Enum):
56
- anime = "anime"
57
- animation_3d = "3d_animation"
58
- clay = "clay"
59
- comic = "comic"
60
- cyberpunk = "cyberpunk"
61
-
62
-
63
- # NOTE: forgoing descriptions for now in return for dev speed
64
- class PixverseTextVideoRequest(BaseModel):
65
- aspect_ratio: PixverseAspectRatio = Field(...)
66
- quality: PixverseQuality = Field(...)
67
- duration: PixverseDuration = Field(...)
68
- model: Optional[str] = Field("v3.5")
69
- motion_mode: Optional[PixverseMotionMode] = Field(PixverseMotionMode.normal)
70
- prompt: str = Field(...)
71
- negative_prompt: Optional[str] = Field(None)
72
- seed: Optional[int] = Field(None)
73
- style: Optional[str] = Field(None)
74
- template_id: Optional[int] = Field(None)
75
- water_mark: Optional[bool] = Field(None)
76
-
77
-
78
- class PixverseImageVideoRequest(BaseModel):
79
- quality: PixverseQuality = Field(...)
80
- duration: PixverseDuration = Field(...)
81
- img_id: int = Field(...)
82
- model: Optional[str] = Field("v3.5")
83
- motion_mode: Optional[PixverseMotionMode] = Field(PixverseMotionMode.normal)
84
- prompt: str = Field(...)
85
- negative_prompt: Optional[str] = Field(None)
86
- seed: Optional[int] = Field(None)
87
- style: Optional[str] = Field(None)
88
- template_id: Optional[int] = Field(None)
89
- water_mark: Optional[bool] = Field(None)
90
-
91
-
92
- class PixverseTransitionVideoRequest(BaseModel):
93
- quality: PixverseQuality = Field(...)
94
- duration: PixverseDuration = Field(...)
95
- first_frame_img: int = Field(...)
96
- last_frame_img: int = Field(...)
97
- model: Optional[str] = Field("v3.5")
98
- motion_mode: Optional[PixverseMotionMode] = Field(PixverseMotionMode.normal)
99
- prompt: str = Field(...)
100
- # negative_prompt: Optional[str] = Field(None)
101
- seed: Optional[int] = Field(None)
102
- # style: Optional[str] = Field(None)
103
- # template_id: Optional[int] = Field(None)
104
- # water_mark: Optional[bool] = Field(None)
105
-
106
-
107
- class PixverseImageUploadResponse(BaseModel):
108
- ErrCode: Optional[int] = None
109
- ErrMsg: Optional[str] = None
110
- Resp: Optional[PixverseImgIdResponseObject] = Field(None, alias='Resp')
111
-
112
-
113
- class PixverseImgIdResponseObject(BaseModel):
114
- img_id: Optional[int] = None
115
-
116
-
117
- class PixverseVideoResponse(BaseModel):
118
- ErrCode: Optional[int] = Field(None)
119
- ErrMsg: Optional[str] = Field(None)
120
- Resp: Optional[PixverseVideoIdResponseObject] = Field(None)
121
-
122
-
123
- class PixverseVideoIdResponseObject(BaseModel):
124
- video_id: int = Field(..., description='Video_id')
125
-
126
-
127
- class PixverseGenerationStatusResponse(BaseModel):
128
- ErrCode: Optional[int] = Field(None)
129
- ErrMsg: Optional[str] = Field(None)
130
- Resp: Optional[PixverseGenerationStatusResponseObject] = Field(None)
131
-
132
-
133
- class PixverseGenerationStatusResponseObject(BaseModel):
134
- create_time: Optional[str] = Field(None)
135
- id: Optional[int] = Field(None)
136
- modify_time: Optional[str] = Field(None)
137
- negative_prompt: Optional[str] = Field(None)
138
- outputHeight: Optional[int] = Field(None)
139
- outputWidth: Optional[int] = Field(None)
140
- prompt: Optional[str] = Field(None)
141
- resolution_ratio: Optional[int] = Field(None)
142
- seed: Optional[int] = Field(None)
143
- size: Optional[int] = Field(None)
144
- status: Optional[int] = Field(None)
145
- style: Optional[str] = Field(None)
146
- url: Optional[str] = Field(None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/apis/recraft_api.py DELETED
@@ -1,262 +0,0 @@
1
- from __future__ import annotations
2
-
3
-
4
-
5
- from enum import Enum
6
- from typing import Optional
7
-
8
- from pydantic import BaseModel, Field, conint, confloat
9
-
10
-
11
- class RecraftColor:
12
- def __init__(self, r: int, g: int, b: int):
13
- self.color = [r, g, b]
14
-
15
- def create_api_model(self):
16
- return RecraftColorObject(rgb=self.color)
17
-
18
-
19
- class RecraftColorChain:
20
- def __init__(self):
21
- self.colors: list[RecraftColor] = []
22
-
23
- def get_first(self):
24
- if len(self.colors) > 0:
25
- return self.colors[0]
26
- return None
27
-
28
- def add(self, color: RecraftColor):
29
- self.colors.append(color)
30
-
31
- def create_api_model(self):
32
- if not self.colors:
33
- return None
34
- colors_api = [x.create_api_model() for x in self.colors]
35
- return colors_api
36
-
37
- def clone(self):
38
- c = RecraftColorChain()
39
- for color in self.colors:
40
- c.add(color)
41
- return c
42
-
43
- def clone_and_merge(self, other: RecraftColorChain):
44
- c = self.clone()
45
- for color in other.colors:
46
- c.add(color)
47
- return c
48
-
49
-
50
- class RecraftControls:
51
- def __init__(self, colors: RecraftColorChain=None, background_color: RecraftColorChain=None,
52
- artistic_level: int=None, no_text: bool=None):
53
- self.colors = colors
54
- self.background_color = background_color
55
- self.artistic_level = artistic_level
56
- self.no_text = no_text
57
-
58
- def create_api_model(self):
59
- if self.colors is None and self.background_color is None and self.artistic_level is None and self.no_text is None:
60
- return None
61
- colors_api = None
62
- background_color_api = None
63
- if self.colors:
64
- colors_api = self.colors.create_api_model()
65
- if self.background_color:
66
- first_background = self.background_color.get_first()
67
- background_color_api = first_background.create_api_model() if first_background else None
68
-
69
- return RecraftControlsObject(colors=colors_api, background_color=background_color_api,
70
- artistic_level=self.artistic_level, no_text=self.no_text)
71
-
72
-
73
- class RecraftStyle:
74
- def __init__(self, style: str=None, substyle: str=None, style_id: str=None):
75
- self.style = style
76
- if substyle == "None":
77
- substyle = None
78
- self.substyle = substyle
79
- self.style_id = style_id
80
-
81
-
82
- class RecraftIO:
83
- STYLEV3 = "RECRAFT_V3_STYLE"
84
- COLOR = "RECRAFT_COLOR"
85
- CONTROLS = "RECRAFT_CONTROLS"
86
-
87
-
88
- class RecraftStyleV3(str, Enum):
89
- #any = 'any' NOTE: this does not work for some reason... why?
90
- realistic_image = 'realistic_image'
91
- digital_illustration = 'digital_illustration'
92
- vector_illustration = 'vector_illustration'
93
- logo_raster = 'logo_raster'
94
-
95
-
96
- def get_v3_substyles(style_v3: str, include_none=True) -> list[str]:
97
- substyles: list[str] = []
98
- if include_none:
99
- substyles.append("None")
100
- return substyles + dict_recraft_substyles_v3.get(style_v3, [])
101
-
102
-
103
- dict_recraft_substyles_v3 = {
104
- RecraftStyleV3.realistic_image: [
105
- "b_and_w",
106
- "enterprise",
107
- "evening_light",
108
- "faded_nostalgia",
109
- "forest_life",
110
- "hard_flash",
111
- "hdr",
112
- "motion_blur",
113
- "mystic_naturalism",
114
- "natural_light",
115
- "natural_tones",
116
- "organic_calm",
117
- "real_life_glow",
118
- "retro_realism",
119
- "retro_snapshot",
120
- "studio_portrait",
121
- "urban_drama",
122
- "village_realism",
123
- "warm_folk"
124
- ],
125
- RecraftStyleV3.digital_illustration: [
126
- "2d_art_poster",
127
- "2d_art_poster_2",
128
- "antiquarian",
129
- "bold_fantasy",
130
- "child_book",
131
- "child_books",
132
- "cover",
133
- "crosshatch",
134
- "digital_engraving",
135
- "engraving_color",
136
- "expressionism",
137
- "freehand_details",
138
- "grain",
139
- "grain_20",
140
- "graphic_intensity",
141
- "hand_drawn",
142
- "hand_drawn_outline",
143
- "handmade_3d",
144
- "hard_comics",
145
- "infantile_sketch",
146
- "long_shadow",
147
- "modern_folk",
148
- "multicolor",
149
- "neon_calm",
150
- "noir",
151
- "nostalgic_pastel",
152
- "outline_details",
153
- "pastel_gradient",
154
- "pastel_sketch",
155
- "pixel_art",
156
- "plastic",
157
- "pop_art",
158
- "pop_renaissance",
159
- "seamless",
160
- "street_art",
161
- "tablet_sketch",
162
- "urban_glow",
163
- "urban_sketching",
164
- "vanilla_dreams",
165
- "young_adult_book",
166
- "young_adult_book_2"
167
- ],
168
- RecraftStyleV3.vector_illustration: [
169
- "bold_stroke",
170
- "chemistry",
171
- "colored_stencil",
172
- "contour_pop_art",
173
- "cosmics",
174
- "cutout",
175
- "depressive",
176
- "editorial",
177
- "emotional_flat",
178
- "engraving",
179
- "infographical",
180
- "line_art",
181
- "line_circuit",
182
- "linocut",
183
- "marker_outline",
184
- "mosaic",
185
- "naivector",
186
- "roundish_flat",
187
- "seamless",
188
- "segmented_colors",
189
- "sharp_contrast",
190
- "thin",
191
- "vector_photo",
192
- "vivid_shapes"
193
- ],
194
- RecraftStyleV3.logo_raster: [
195
- "emblem_graffiti",
196
- "emblem_pop_art",
197
- "emblem_punk",
198
- "emblem_stamp",
199
- "emblem_vintage"
200
- ],
201
- }
202
-
203
-
204
- class RecraftModel(str, Enum):
205
- recraftv3 = 'recraftv3'
206
- recraftv2 = 'recraftv2'
207
-
208
-
209
- class RecraftImageSize(str, Enum):
210
- res_1024x1024 = '1024x1024'
211
- res_1365x1024 = '1365x1024'
212
- res_1024x1365 = '1024x1365'
213
- res_1536x1024 = '1536x1024'
214
- res_1024x1536 = '1024x1536'
215
- res_1820x1024 = '1820x1024'
216
- res_1024x1820 = '1024x1820'
217
- res_1024x2048 = '1024x2048'
218
- res_2048x1024 = '2048x1024'
219
- res_1434x1024 = '1434x1024'
220
- res_1024x1434 = '1024x1434'
221
- res_1024x1280 = '1024x1280'
222
- res_1280x1024 = '1280x1024'
223
- res_1024x1707 = '1024x1707'
224
- res_1707x1024 = '1707x1024'
225
-
226
-
227
- class RecraftColorObject(BaseModel):
228
- rgb: list[int] = Field(..., description='An array of 3 integer values in range of 0...255 defining RGB Color Model')
229
-
230
-
231
- class RecraftControlsObject(BaseModel):
232
- colors: Optional[list[RecraftColorObject]] = Field(None, description='An array of preferable colors')
233
- background_color: Optional[RecraftColorObject] = Field(None, description='Use given color as a desired background color')
234
- no_text: Optional[bool] = Field(None, description='Do not embed text layouts')
235
- artistic_level: Optional[conint(ge=0, le=5)] = Field(None, description='Defines artistic tone of your image. At a simple level, the person looks straight at the camera in a static and clean style. Dynamic and eccentric levels introduce movement and creativity. The value should be in range [0..5].')
236
-
237
-
238
- class RecraftImageGenerationRequest(BaseModel):
239
- prompt: str = Field(..., description='The text prompt describing the image to generate')
240
- size: Optional[RecraftImageSize] = Field(None, description='The size of the generated image (e.g., "1024x1024")')
241
- n: conint(ge=1, le=6) = Field(..., description='The number of images to generate')
242
- negative_prompt: Optional[str] = Field(None, description='A text description of undesired elements on an image')
243
- model: Optional[RecraftModel] = Field(RecraftModel.recraftv3, description='The model to use for generation (e.g., "recraftv3")')
244
- style: Optional[str] = Field(None, description='The style to apply to the generated image (e.g., "digital_illustration")')
245
- substyle: Optional[str] = Field(None, description='The substyle to apply to the generated image, depending on the style input')
246
- controls: Optional[RecraftControlsObject] = Field(None, description='A set of custom parameters to tweak generation process')
247
- style_id: Optional[str] = Field(None, description='Use a previously uploaded style as a reference; UUID')
248
- strength: Optional[confloat(ge=0.0, le=1.0)] = Field(None, description='Defines the difference with the original image, should lie in [0, 1], where 0 means almost identical, and 1 means miserable similarity')
249
- random_seed: Optional[int] = Field(None, description="Seed for video generation")
250
- # text_layout
251
-
252
-
253
- class RecraftReturnedObject(BaseModel):
254
- image_id: str = Field(..., description='Unique identifier for the generated image')
255
- url: str = Field(..., description='URL to access the generated image')
256
-
257
-
258
- class RecraftImageGenerationResponse(BaseModel):
259
- created: int = Field(..., description='Unix timestamp when the generation was created')
260
- credits: int = Field(..., description='Number of credits used for the generation')
261
- data: Optional[list[RecraftReturnedObject]] = Field(None, description='Array of generated image information')
262
- image: Optional[RecraftReturnedObject] = Field(None, description='Single generated image')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/apis/request_logger.py DELETED
@@ -1,125 +0,0 @@
1
- import os
2
- import datetime
3
- import json
4
- import logging
5
- import folder_paths
6
-
7
- # Get the logger instance
8
- logger = logging.getLogger(__name__)
9
-
10
- def get_log_directory():
11
- """
12
- Ensures the API log directory exists within ComfyUI's temp directory
13
- and returns its path.
14
- """
15
- base_temp_dir = folder_paths.get_temp_directory()
16
- log_dir = os.path.join(base_temp_dir, "api_logs")
17
- try:
18
- os.makedirs(log_dir, exist_ok=True)
19
- except Exception as e:
20
- logger.error(f"Error creating API log directory {log_dir}: {e}")
21
- # Fallback to base temp directory if sub-directory creation fails
22
- return base_temp_dir
23
- return log_dir
24
-
25
- def _format_data_for_logging(data):
26
- """Helper to format data (dict, str, bytes) for logging."""
27
- if isinstance(data, bytes):
28
- try:
29
- return data.decode('utf-8') # Try to decode as text
30
- except UnicodeDecodeError:
31
- return f"[Binary data of length {len(data)} bytes]"
32
- elif isinstance(data, (dict, list)):
33
- try:
34
- return json.dumps(data, indent=2, ensure_ascii=False)
35
- except TypeError:
36
- return str(data) # Fallback for non-serializable objects
37
- return str(data)
38
-
39
- def log_request_response(
40
- operation_id: str,
41
- request_method: str,
42
- request_url: str,
43
- request_headers: dict | None = None,
44
- request_params: dict | None = None,
45
- request_data: any = None,
46
- response_status_code: int | None = None,
47
- response_headers: dict | None = None,
48
- response_content: any = None,
49
- error_message: str | None = None
50
- ):
51
- """
52
- Logs API request and response details to a file in the temp/api_logs directory.
53
- """
54
- log_dir = get_log_directory()
55
- timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
56
- filename = f"{timestamp}_{operation_id.replace('/', '_').replace(':', '_')}.log"
57
- filepath = os.path.join(log_dir, filename)
58
-
59
- log_content = []
60
-
61
- log_content.append(f"Timestamp: {datetime.datetime.now().isoformat()}")
62
- log_content.append(f"Operation ID: {operation_id}")
63
- log_content.append("-" * 30 + " REQUEST " + "-" * 30)
64
- log_content.append(f"Method: {request_method}")
65
- log_content.append(f"URL: {request_url}")
66
- if request_headers:
67
- log_content.append(f"Headers:\n{_format_data_for_logging(request_headers)}")
68
- if request_params:
69
- log_content.append(f"Params:\n{_format_data_for_logging(request_params)}")
70
- if request_data:
71
- log_content.append(f"Data/Body:\n{_format_data_for_logging(request_data)}")
72
-
73
- log_content.append("\n" + "-" * 30 + " RESPONSE " + "-" * 30)
74
- if response_status_code is not None:
75
- log_content.append(f"Status Code: {response_status_code}")
76
- if response_headers:
77
- log_content.append(f"Headers:\n{_format_data_for_logging(response_headers)}")
78
- if response_content:
79
- log_content.append(f"Content:\n{_format_data_for_logging(response_content)}")
80
- if error_message:
81
- log_content.append(f"Error:\n{error_message}")
82
-
83
- try:
84
- with open(filepath, "w", encoding="utf-8") as f:
85
- f.write("\n".join(log_content))
86
- logger.debug(f"API log saved to: {filepath}")
87
- except Exception as e:
88
- logger.error(f"Error writing API log to {filepath}: {e}")
89
-
90
- if __name__ == '__main__':
91
- # Example usage (for testing the logger directly)
92
- logger.setLevel(logging.DEBUG)
93
- # Mock folder_paths for direct execution if not running within ComfyUI full context
94
- if not hasattr(folder_paths, 'get_temp_directory'):
95
- class MockFolderPaths:
96
- def get_temp_directory(self):
97
- # Create a local temp dir for testing if needed
98
- p = os.path.join(os.path.dirname(__file__), 'temp_test_logs')
99
- os.makedirs(p, exist_ok=True)
100
- return p
101
- folder_paths = MockFolderPaths()
102
-
103
- log_request_response(
104
- operation_id="test_operation_get",
105
- request_method="GET",
106
- request_url="https://api.example.com/test",
107
- request_headers={"Authorization": "Bearer testtoken"},
108
- request_params={"param1": "value1"},
109
- response_status_code=200,
110
- response_content={"message": "Success!"}
111
- )
112
- log_request_response(
113
- operation_id="test_operation_post_error",
114
- request_method="POST",
115
- request_url="https://api.example.com/submit",
116
- request_data={"key": "value", "nested": {"num": 123}},
117
- error_message="Connection timed out"
118
- )
119
- log_request_response(
120
- operation_id="test_binary_response",
121
- request_method="GET",
122
- request_url="https://api.example.com/image.png",
123
- response_status_code=200,
124
- response_content=b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR...' # Sample binary data
125
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/apis/rodin_api.py DELETED
@@ -1,57 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from enum import Enum
4
- from typing import Optional, List
5
- from pydantic import BaseModel, Field
6
-
7
-
8
- class Rodin3DGenerateRequest(BaseModel):
9
- seed: int = Field(..., description="seed_")
10
- tier: str = Field(..., description="Tier of generation.")
11
- material: str = Field(..., description="The material type.")
12
- quality: str = Field(..., description="The generation quality of the mesh.")
13
- mesh_mode: str = Field(..., description="It controls the type of faces of generated models.")
14
-
15
- class GenerateJobsData(BaseModel):
16
- uuids: List[str] = Field(..., description="str LIST")
17
- subscription_key: str = Field(..., description="subscription key")
18
-
19
- class Rodin3DGenerateResponse(BaseModel):
20
- message: Optional[str] = Field(None, description="Return message.")
21
- prompt: Optional[str] = Field(None, description="Generated Prompt from image.")
22
- submit_time: Optional[str] = Field(None, description="Submit Time")
23
- uuid: Optional[str] = Field(None, description="Task str")
24
- jobs: Optional[GenerateJobsData] = Field(None, description="Details of jobs")
25
-
26
- class JobStatus(str, Enum):
27
- """
28
- Status for jobs
29
- """
30
- Done = "Done"
31
- Failed = "Failed"
32
- Generating = "Generating"
33
- Waiting = "Waiting"
34
-
35
- class Rodin3DCheckStatusRequest(BaseModel):
36
- subscription_key: str = Field(..., description="subscription from generate endpoint")
37
-
38
- class JobItem(BaseModel):
39
- uuid: str = Field(..., description="uuid")
40
- status: JobStatus = Field(...,description="Status Currently")
41
-
42
- class Rodin3DCheckStatusResponse(BaseModel):
43
- jobs: List[JobItem] = Field(..., description="Job status List")
44
-
45
- class Rodin3DDownloadRequest(BaseModel):
46
- task_uuid: str = Field(..., description="Task str")
47
-
48
- class RodinResourceItem(BaseModel):
49
- url: str = Field(..., description="Download Url")
50
- name: str = Field(..., description="File name with ext")
51
-
52
- class Rodin3DDownloadResponse(BaseModel):
53
- list: List[RodinResourceItem] = Field(..., description="Source List")
54
-
55
-
56
-
57
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/apis/stability_api.py DELETED
@@ -1,127 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from enum import Enum
4
- from typing import Optional
5
-
6
- from pydantic import BaseModel, Field, confloat
7
-
8
-
9
- class StabilityFormat(str, Enum):
10
- png = 'png'
11
- jpeg = 'jpeg'
12
- webp = 'webp'
13
-
14
-
15
- class StabilityAspectRatio(str, Enum):
16
- ratio_1_1 = "1:1"
17
- ratio_16_9 = "16:9"
18
- ratio_9_16 = "9:16"
19
- ratio_3_2 = "3:2"
20
- ratio_2_3 = "2:3"
21
- ratio_5_4 = "5:4"
22
- ratio_4_5 = "4:5"
23
- ratio_21_9 = "21:9"
24
- ratio_9_21 = "9:21"
25
-
26
-
27
- def get_stability_style_presets(include_none=True):
28
- presets = []
29
- if include_none:
30
- presets.append("None")
31
- return presets + [x.value for x in StabilityStylePreset]
32
-
33
-
34
- class StabilityStylePreset(str, Enum):
35
- _3d_model = "3d-model"
36
- analog_film = "analog-film"
37
- anime = "anime"
38
- cinematic = "cinematic"
39
- comic_book = "comic-book"
40
- digital_art = "digital-art"
41
- enhance = "enhance"
42
- fantasy_art = "fantasy-art"
43
- isometric = "isometric"
44
- line_art = "line-art"
45
- low_poly = "low-poly"
46
- modeling_compound = "modeling-compound"
47
- neon_punk = "neon-punk"
48
- origami = "origami"
49
- photographic = "photographic"
50
- pixel_art = "pixel-art"
51
- tile_texture = "tile-texture"
52
-
53
-
54
- class Stability_SD3_5_Model(str, Enum):
55
- sd3_5_large = "sd3.5-large"
56
- # sd3_5_large_turbo = "sd3.5-large-turbo"
57
- sd3_5_medium = "sd3.5-medium"
58
-
59
-
60
- class Stability_SD3_5_GenerationMode(str, Enum):
61
- text_to_image = "text-to-image"
62
- image_to_image = "image-to-image"
63
-
64
-
65
- class StabilityStable3_5Request(BaseModel):
66
- model: str = Field(...)
67
- mode: str = Field(...)
68
- prompt: str = Field(...)
69
- negative_prompt: Optional[str] = Field(None)
70
- aspect_ratio: Optional[str] = Field(None)
71
- seed: Optional[int] = Field(None)
72
- output_format: Optional[str] = Field(StabilityFormat.png.value)
73
- image: Optional[str] = Field(None)
74
- style_preset: Optional[str] = Field(None)
75
- cfg_scale: float = Field(...)
76
- strength: Optional[confloat(ge=0.0, le=1.0)] = Field(None)
77
-
78
-
79
- class StabilityUpscaleConservativeRequest(BaseModel):
80
- prompt: str = Field(...)
81
- negative_prompt: Optional[str] = Field(None)
82
- seed: Optional[int] = Field(None)
83
- output_format: Optional[str] = Field(StabilityFormat.png.value)
84
- image: Optional[str] = Field(None)
85
- creativity: Optional[confloat(ge=0.2, le=0.5)] = Field(None)
86
-
87
-
88
- class StabilityUpscaleCreativeRequest(BaseModel):
89
- prompt: str = Field(...)
90
- negative_prompt: Optional[str] = Field(None)
91
- seed: Optional[int] = Field(None)
92
- output_format: Optional[str] = Field(StabilityFormat.png.value)
93
- image: Optional[str] = Field(None)
94
- creativity: Optional[confloat(ge=0.1, le=0.5)] = Field(None)
95
- style_preset: Optional[str] = Field(None)
96
-
97
-
98
- class StabilityStableUltraRequest(BaseModel):
99
- prompt: str = Field(...)
100
- negative_prompt: Optional[str] = Field(None)
101
- aspect_ratio: Optional[str] = Field(None)
102
- seed: Optional[int] = Field(None)
103
- output_format: Optional[str] = Field(StabilityFormat.png.value)
104
- image: Optional[str] = Field(None)
105
- style_preset: Optional[str] = Field(None)
106
- strength: Optional[confloat(ge=0.0, le=1.0)] = Field(None)
107
-
108
-
109
- class StabilityStableUltraResponse(BaseModel):
110
- image: Optional[str] = Field(None)
111
- finish_reason: Optional[str] = Field(None)
112
- seed: Optional[int] = Field(None)
113
-
114
-
115
- class StabilityResultsGetResponse(BaseModel):
116
- image: Optional[str] = Field(None)
117
- finish_reason: Optional[str] = Field(None)
118
- seed: Optional[int] = Field(None)
119
- id: Optional[str] = Field(None)
120
- name: Optional[str] = Field(None)
121
- errors: Optional[list[str]] = Field(None)
122
- status: Optional[str] = Field(None)
123
- result: Optional[str] = Field(None)
124
-
125
-
126
- class StabilityAsyncResponse(BaseModel):
127
- id: Optional[str] = Field(None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/apis/tripo_api.py DELETED
@@ -1,275 +0,0 @@
1
- from __future__ import annotations
2
- from comfy_api_nodes.apis import (
3
- TripoModelVersion,
4
- TripoTextureQuality,
5
- )
6
- from enum import Enum
7
- from typing import Optional, List, Dict, Any, Union
8
-
9
- from pydantic import BaseModel, Field, RootModel
10
-
11
- class TripoStyle(str, Enum):
12
- PERSON_TO_CARTOON = "person:person2cartoon"
13
- ANIMAL_VENOM = "animal:venom"
14
- OBJECT_CLAY = "object:clay"
15
- OBJECT_STEAMPUNK = "object:steampunk"
16
- OBJECT_CHRISTMAS = "object:christmas"
17
- OBJECT_BARBIE = "object:barbie"
18
- GOLD = "gold"
19
- ANCIENT_BRONZE = "ancient_bronze"
20
- NONE = "None"
21
-
22
- class TripoTaskType(str, Enum):
23
- TEXT_TO_MODEL = "text_to_model"
24
- IMAGE_TO_MODEL = "image_to_model"
25
- MULTIVIEW_TO_MODEL = "multiview_to_model"
26
- TEXTURE_MODEL = "texture_model"
27
- REFINE_MODEL = "refine_model"
28
- ANIMATE_PRERIGCHECK = "animate_prerigcheck"
29
- ANIMATE_RIG = "animate_rig"
30
- ANIMATE_RETARGET = "animate_retarget"
31
- STYLIZE_MODEL = "stylize_model"
32
- CONVERT_MODEL = "convert_model"
33
-
34
- class TripoTextureAlignment(str, Enum):
35
- ORIGINAL_IMAGE = "original_image"
36
- GEOMETRY = "geometry"
37
-
38
- class TripoOrientation(str, Enum):
39
- ALIGN_IMAGE = "align_image"
40
- DEFAULT = "default"
41
-
42
- class TripoOutFormat(str, Enum):
43
- GLB = "glb"
44
- FBX = "fbx"
45
-
46
- class TripoTopology(str, Enum):
47
- BIP = "bip"
48
- QUAD = "quad"
49
-
50
- class TripoSpec(str, Enum):
51
- MIXAMO = "mixamo"
52
- TRIPO = "tripo"
53
-
54
- class TripoAnimation(str, Enum):
55
- IDLE = "preset:idle"
56
- WALK = "preset:walk"
57
- CLIMB = "preset:climb"
58
- JUMP = "preset:jump"
59
- RUN = "preset:run"
60
- SLASH = "preset:slash"
61
- SHOOT = "preset:shoot"
62
- HURT = "preset:hurt"
63
- FALL = "preset:fall"
64
- TURN = "preset:turn"
65
-
66
- class TripoStylizeStyle(str, Enum):
67
- LEGO = "lego"
68
- VOXEL = "voxel"
69
- VORONOI = "voronoi"
70
- MINECRAFT = "minecraft"
71
-
72
- class TripoConvertFormat(str, Enum):
73
- GLTF = "GLTF"
74
- USDZ = "USDZ"
75
- FBX = "FBX"
76
- OBJ = "OBJ"
77
- STL = "STL"
78
- _3MF = "3MF"
79
-
80
- class TripoTextureFormat(str, Enum):
81
- BMP = "BMP"
82
- DPX = "DPX"
83
- HDR = "HDR"
84
- JPEG = "JPEG"
85
- OPEN_EXR = "OPEN_EXR"
86
- PNG = "PNG"
87
- TARGA = "TARGA"
88
- TIFF = "TIFF"
89
- WEBP = "WEBP"
90
-
91
- class TripoTaskStatus(str, Enum):
92
- QUEUED = "queued"
93
- RUNNING = "running"
94
- SUCCESS = "success"
95
- FAILED = "failed"
96
- CANCELLED = "cancelled"
97
- UNKNOWN = "unknown"
98
- BANNED = "banned"
99
- EXPIRED = "expired"
100
-
101
- class TripoFileTokenReference(BaseModel):
102
- type: Optional[str] = Field(None, description='The type of the reference')
103
- file_token: str
104
-
105
- class TripoUrlReference(BaseModel):
106
- type: Optional[str] = Field(None, description='The type of the reference')
107
- url: str
108
-
109
- class TripoObjectStorage(BaseModel):
110
- bucket: str
111
- key: str
112
-
113
- class TripoObjectReference(BaseModel):
114
- type: str
115
- object: TripoObjectStorage
116
-
117
- class TripoFileEmptyReference(BaseModel):
118
- pass
119
-
120
- class TripoFileReference(RootModel):
121
- root: Union[TripoFileTokenReference, TripoUrlReference, TripoObjectReference, TripoFileEmptyReference]
122
-
123
- class TripoGetStsTokenRequest(BaseModel):
124
- format: str = Field(..., description='The format of the image')
125
-
126
- class TripoTextToModelRequest(BaseModel):
127
- type: TripoTaskType = Field(TripoTaskType.TEXT_TO_MODEL, description='Type of task')
128
- prompt: str = Field(..., description='The text prompt describing the model to generate', max_length=1024)
129
- negative_prompt: Optional[str] = Field(None, description='The negative text prompt', max_length=1024)
130
- model_version: Optional[TripoModelVersion] = TripoModelVersion.V2_5
131
- face_limit: Optional[int] = Field(None, description='The number of faces to limit the generation to')
132
- texture: Optional[bool] = Field(True, description='Whether to apply texture to the generated model')
133
- pbr: Optional[bool] = Field(True, description='Whether to apply PBR to the generated model')
134
- image_seed: Optional[int] = Field(None, description='The seed for the text')
135
- model_seed: Optional[int] = Field(None, description='The seed for the model')
136
- texture_seed: Optional[int] = Field(None, description='The seed for the texture')
137
- texture_quality: Optional[TripoTextureQuality] = TripoTextureQuality.standard
138
- style: Optional[TripoStyle] = None
139
- auto_size: Optional[bool] = Field(False, description='Whether to auto-size the model')
140
- quad: Optional[bool] = Field(False, description='Whether to apply quad to the generated model')
141
-
142
- class TripoImageToModelRequest(BaseModel):
143
- type: TripoTaskType = Field(TripoTaskType.IMAGE_TO_MODEL, description='Type of task')
144
- file: TripoFileReference = Field(..., description='The file reference to convert to a model')
145
- model_version: Optional[TripoModelVersion] = Field(None, description='The model version to use for generation')
146
- face_limit: Optional[int] = Field(None, description='The number of faces to limit the generation to')
147
- texture: Optional[bool] = Field(True, description='Whether to apply texture to the generated model')
148
- pbr: Optional[bool] = Field(True, description='Whether to apply PBR to the generated model')
149
- model_seed: Optional[int] = Field(None, description='The seed for the model')
150
- texture_seed: Optional[int] = Field(None, description='The seed for the texture')
151
- texture_quality: Optional[TripoTextureQuality] = TripoTextureQuality.standard
152
- texture_alignment: Optional[TripoTextureAlignment] = Field(TripoTextureAlignment.ORIGINAL_IMAGE, description='The texture alignment method')
153
- style: Optional[TripoStyle] = Field(None, description='The style to apply to the generated model')
154
- auto_size: Optional[bool] = Field(False, description='Whether to auto-size the model')
155
- orientation: Optional[TripoOrientation] = TripoOrientation.DEFAULT
156
- quad: Optional[bool] = Field(False, description='Whether to apply quad to the generated model')
157
-
158
- class TripoMultiviewToModelRequest(BaseModel):
159
- type: TripoTaskType = TripoTaskType.MULTIVIEW_TO_MODEL
160
- files: List[TripoFileReference] = Field(..., description='The file references to convert to a model')
161
- model_version: Optional[TripoModelVersion] = Field(None, description='The model version to use for generation')
162
- orthographic_projection: Optional[bool] = Field(False, description='Whether to use orthographic projection')
163
- face_limit: Optional[int] = Field(None, description='The number of faces to limit the generation to')
164
- texture: Optional[bool] = Field(True, description='Whether to apply texture to the generated model')
165
- pbr: Optional[bool] = Field(True, description='Whether to apply PBR to the generated model')
166
- model_seed: Optional[int] = Field(None, description='The seed for the model')
167
- texture_seed: Optional[int] = Field(None, description='The seed for the texture')
168
- texture_quality: Optional[TripoTextureQuality] = TripoTextureQuality.standard
169
- texture_alignment: Optional[TripoTextureAlignment] = TripoTextureAlignment.ORIGINAL_IMAGE
170
- auto_size: Optional[bool] = Field(False, description='Whether to auto-size the model')
171
- orientation: Optional[TripoOrientation] = Field(TripoOrientation.DEFAULT, description='The orientation for the model')
172
- quad: Optional[bool] = Field(False, description='Whether to apply quad to the generated model')
173
-
174
- class TripoTextureModelRequest(BaseModel):
175
- type: TripoTaskType = Field(TripoTaskType.TEXTURE_MODEL, description='Type of task')
176
- original_model_task_id: str = Field(..., description='The task ID of the original model')
177
- texture: Optional[bool] = Field(True, description='Whether to apply texture to the model')
178
- pbr: Optional[bool] = Field(True, description='Whether to apply PBR to the model')
179
- model_seed: Optional[int] = Field(None, description='The seed for the model')
180
- texture_seed: Optional[int] = Field(None, description='The seed for the texture')
181
- texture_quality: Optional[TripoTextureQuality] = Field(None, description='The quality of the texture')
182
- texture_alignment: Optional[TripoTextureAlignment] = Field(TripoTextureAlignment.ORIGINAL_IMAGE, description='The texture alignment method')
183
-
184
- class TripoRefineModelRequest(BaseModel):
185
- type: TripoTaskType = Field(TripoTaskType.REFINE_MODEL, description='Type of task')
186
- draft_model_task_id: str = Field(..., description='The task ID of the draft model')
187
-
188
- class TripoAnimatePrerigcheckRequest(BaseModel):
189
- type: TripoTaskType = Field(TripoTaskType.ANIMATE_PRERIGCHECK, description='Type of task')
190
- original_model_task_id: str = Field(..., description='The task ID of the original model')
191
-
192
- class TripoAnimateRigRequest(BaseModel):
193
- type: TripoTaskType = Field(TripoTaskType.ANIMATE_RIG, description='Type of task')
194
- original_model_task_id: str = Field(..., description='The task ID of the original model')
195
- out_format: Optional[TripoOutFormat] = Field(TripoOutFormat.GLB, description='The output format')
196
- spec: Optional[TripoSpec] = Field(TripoSpec.TRIPO, description='The specification for rigging')
197
-
198
- class TripoAnimateRetargetRequest(BaseModel):
199
- type: TripoTaskType = Field(TripoTaskType.ANIMATE_RETARGET, description='Type of task')
200
- original_model_task_id: str = Field(..., description='The task ID of the original model')
201
- animation: TripoAnimation = Field(..., description='The animation to apply')
202
- out_format: Optional[TripoOutFormat] = Field(TripoOutFormat.GLB, description='The output format')
203
- bake_animation: Optional[bool] = Field(True, description='Whether to bake the animation')
204
-
205
- class TripoStylizeModelRequest(BaseModel):
206
- type: TripoTaskType = Field(TripoTaskType.STYLIZE_MODEL, description='Type of task')
207
- style: TripoStylizeStyle = Field(..., description='The style to apply to the model')
208
- original_model_task_id: str = Field(..., description='The task ID of the original model')
209
- block_size: Optional[int] = Field(80, description='The block size for stylization')
210
-
211
- class TripoConvertModelRequest(BaseModel):
212
- type: TripoTaskType = Field(TripoTaskType.CONVERT_MODEL, description='Type of task')
213
- format: TripoConvertFormat = Field(..., description='The format to convert to')
214
- original_model_task_id: str = Field(..., description='The task ID of the original model')
215
- quad: Optional[bool] = Field(False, description='Whether to apply quad to the model')
216
- force_symmetry: Optional[bool] = Field(False, description='Whether to force symmetry')
217
- face_limit: Optional[int] = Field(10000, description='The number of faces to limit the conversion to')
218
- flatten_bottom: Optional[bool] = Field(False, description='Whether to flatten the bottom of the model')
219
- flatten_bottom_threshold: Optional[float] = Field(0.01, description='The threshold for flattening the bottom')
220
- texture_size: Optional[int] = Field(4096, description='The size of the texture')
221
- texture_format: Optional[TripoTextureFormat] = Field(TripoTextureFormat.JPEG, description='The format of the texture')
222
- pivot_to_center_bottom: Optional[bool] = Field(False, description='Whether to pivot to the center bottom')
223
-
224
- class TripoTaskRequest(RootModel):
225
- root: Union[
226
- TripoTextToModelRequest,
227
- TripoImageToModelRequest,
228
- TripoMultiviewToModelRequest,
229
- TripoTextureModelRequest,
230
- TripoRefineModelRequest,
231
- TripoAnimatePrerigcheckRequest,
232
- TripoAnimateRigRequest,
233
- TripoAnimateRetargetRequest,
234
- TripoStylizeModelRequest,
235
- TripoConvertModelRequest
236
- ]
237
-
238
- class TripoTaskOutput(BaseModel):
239
- model: Optional[str] = Field(None, description='URL to the model')
240
- base_model: Optional[str] = Field(None, description='URL to the base model')
241
- pbr_model: Optional[str] = Field(None, description='URL to the PBR model')
242
- rendered_image: Optional[str] = Field(None, description='URL to the rendered image')
243
- riggable: Optional[bool] = Field(None, description='Whether the model is riggable')
244
-
245
- class TripoTask(BaseModel):
246
- task_id: str = Field(..., description='The task ID')
247
- type: Optional[str] = Field(None, description='The type of task')
248
- status: Optional[TripoTaskStatus] = Field(None, description='The status of the task')
249
- input: Optional[Dict[str, Any]] = Field(None, description='The input parameters for the task')
250
- output: Optional[TripoTaskOutput] = Field(None, description='The output of the task')
251
- progress: Optional[int] = Field(None, description='The progress of the task', ge=0, le=100)
252
- create_time: Optional[int] = Field(None, description='The creation time of the task')
253
- running_left_time: Optional[int] = Field(None, description='The estimated time left for the task')
254
- queue_position: Optional[int] = Field(None, description='The position in the queue')
255
-
256
- class TripoTaskResponse(BaseModel):
257
- code: int = Field(0, description='The response code')
258
- data: TripoTask = Field(..., description='The task data')
259
-
260
- class TripoGeneralResponse(BaseModel):
261
- code: int = Field(0, description='The response code')
262
- data: Dict[str, str] = Field(..., description='The task ID data')
263
-
264
- class TripoBalanceData(BaseModel):
265
- balance: float = Field(..., description='The account balance')
266
- frozen: float = Field(..., description='The frozen balance')
267
-
268
- class TripoBalanceResponse(BaseModel):
269
- code: int = Field(0, description='The response code')
270
- data: TripoBalanceData = Field(..., description='The balance data')
271
-
272
- class TripoErrorResponse(BaseModel):
273
- code: int = Field(..., description='The error code')
274
- message: str = Field(..., description='The error message')
275
- suggestion: str = Field(..., description='The suggestion for fixing the error')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/canary.py DELETED
@@ -1,10 +0,0 @@
1
- import av
2
-
3
- ver = av.__version__.split(".")
4
- if int(ver[0]) < 14:
5
- raise Exception("INSTALL NEW VERSION OF PYAV TO USE API NODES.")
6
-
7
- if int(ver[0]) == 14 and int(ver[1]) < 2:
8
- raise Exception("INSTALL NEW VERSION OF PYAV TO USE API NODES.")
9
-
10
- NODE_CLASS_MAPPINGS = {}
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/mapper_utils.py DELETED
@@ -1,116 +0,0 @@
1
- from enum import Enum
2
-
3
- from pydantic.fields import FieldInfo
4
- from pydantic import BaseModel
5
- from pydantic_core import PydanticUndefined
6
-
7
- from comfy.comfy_types.node_typing import IO, InputTypeOptions
8
-
9
- NodeInput = tuple[IO, InputTypeOptions]
10
-
11
-
12
- def _create_base_config(field_info: FieldInfo) -> InputTypeOptions:
13
- config = {}
14
- if hasattr(field_info, "default") and field_info.default is not PydanticUndefined:
15
- config["default"] = field_info.default
16
- if hasattr(field_info, "description") and field_info.description is not None:
17
- config["tooltip"] = field_info.description
18
- return config
19
-
20
-
21
- def _get_number_constraints_config(field_info: FieldInfo) -> dict:
22
- config = {}
23
- if hasattr(field_info, "metadata"):
24
- metadata = field_info.metadata
25
- for constraint in metadata:
26
- if hasattr(constraint, "ge"):
27
- config["min"] = constraint.ge
28
- if hasattr(constraint, "le"):
29
- config["max"] = constraint.le
30
- if hasattr(constraint, "multiple_of"):
31
- config["step"] = constraint.multiple_of
32
- return config
33
-
34
-
35
- def _model_field_to_image_input(field_info: FieldInfo, **kwargs) -> NodeInput:
36
- return IO.IMAGE, {
37
- **_create_base_config(field_info),
38
- **kwargs,
39
- }
40
-
41
-
42
- def _model_field_to_string_input(field_info: FieldInfo, **kwargs) -> NodeInput:
43
- return IO.STRING, {
44
- **_create_base_config(field_info),
45
- **kwargs,
46
- }
47
-
48
-
49
- def _model_field_to_float_input(field_info: FieldInfo, **kwargs) -> NodeInput:
50
- return IO.FLOAT, {
51
- **_create_base_config(field_info),
52
- **_get_number_constraints_config(field_info),
53
- **kwargs,
54
- }
55
-
56
-
57
- def _model_field_to_int_input(field_info: FieldInfo, **kwargs) -> NodeInput:
58
- return IO.INT, {
59
- **_create_base_config(field_info),
60
- **_get_number_constraints_config(field_info),
61
- **kwargs,
62
- }
63
-
64
-
65
- def _model_field_to_combo_input(
66
- field_info: FieldInfo, enum_type: type[Enum] = None, **kwargs
67
- ) -> NodeInput:
68
- combo_config = {}
69
- if enum_type is not None:
70
- combo_config["options"] = [option.value for option in enum_type]
71
- combo_config = {
72
- **combo_config,
73
- **_create_base_config(field_info),
74
- **kwargs,
75
- }
76
- return IO.COMBO, combo_config
77
-
78
-
79
- def model_field_to_node_input(
80
- input_type: IO, base_model: type[BaseModel], field_name: str, **kwargs
81
- ) -> NodeInput:
82
- """
83
- Maps a field from a Pydantic model to a Comfy node input.
84
-
85
- Args:
86
- input_type: The type of the input.
87
- base_model: The Pydantic model to map the field from.
88
- field_name: The name of the field to map.
89
- **kwargs: Additional key/values to include in the input options.
90
-
91
- Note:
92
- For combo inputs, pass an `Enum` to the `enum_type` keyword argument to populate the options automatically.
93
-
94
- Example:
95
- >>> model_field_to_node_input(IO.STRING, MyModel, "my_field", multiline=True)
96
- >>> model_field_to_node_input(IO.COMBO, MyModel, "my_field", enum_type=MyEnum)
97
- >>> model_field_to_node_input(IO.FLOAT, MyModel, "my_field", slider=True)
98
- """
99
- field_info: FieldInfo = base_model.model_fields[field_name]
100
- result: NodeInput
101
-
102
- if input_type == IO.IMAGE:
103
- result = _model_field_to_image_input(field_info, **kwargs)
104
- elif input_type == IO.STRING:
105
- result = _model_field_to_string_input(field_info, **kwargs)
106
- elif input_type == IO.FLOAT:
107
- result = _model_field_to_float_input(field_info, **kwargs)
108
- elif input_type == IO.INT:
109
- result = _model_field_to_int_input(field_info, **kwargs)
110
- elif input_type == IO.COMBO:
111
- result = _model_field_to_combo_input(field_info, **kwargs)
112
- else:
113
- message = f"Invalid input type: {input_type}"
114
- raise ValueError(message)
115
-
116
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/nodes_bfl.py DELETED
@@ -1,1075 +0,0 @@
1
- import io
2
- from inspect import cleandoc
3
- from typing import Union, Optional
4
- from comfy.comfy_types.node_typing import IO, ComfyNodeABC
5
- from comfy_api_nodes.apis.bfl_api import (
6
- BFLStatus,
7
- BFLFluxExpandImageRequest,
8
- BFLFluxFillImageRequest,
9
- BFLFluxCannyImageRequest,
10
- BFLFluxDepthImageRequest,
11
- BFLFluxProGenerateRequest,
12
- BFLFluxKontextProGenerateRequest,
13
- BFLFluxProUltraGenerateRequest,
14
- BFLFluxProGenerateResponse,
15
- )
16
- from comfy_api_nodes.apis.client import (
17
- ApiEndpoint,
18
- HttpMethod,
19
- SynchronousOperation,
20
- )
21
- from comfy_api_nodes.apinode_utils import (
22
- downscale_image_tensor,
23
- validate_aspect_ratio,
24
- process_image_response,
25
- resize_mask_to_image,
26
- validate_string,
27
- )
28
-
29
- import numpy as np
30
- from PIL import Image
31
- import requests
32
- import torch
33
- import base64
34
- import time
35
- from server import PromptServer
36
-
37
-
38
- def convert_mask_to_image(mask: torch.Tensor):
39
- """
40
- Make mask have the expected amount of dims (4) and channels (3) to be recognized as an image.
41
- """
42
- mask = mask.unsqueeze(-1)
43
- mask = torch.cat([mask]*3, dim=-1)
44
- return mask
45
-
46
-
47
- def handle_bfl_synchronous_operation(
48
- operation: SynchronousOperation,
49
- timeout_bfl_calls=360,
50
- node_id: Union[str, None] = None,
51
- ):
52
- response_api: BFLFluxProGenerateResponse = operation.execute()
53
- return _poll_until_generated(
54
- response_api.polling_url, timeout=timeout_bfl_calls, node_id=node_id
55
- )
56
-
57
-
58
- def _poll_until_generated(
59
- polling_url: str, timeout=360, node_id: Union[str, None] = None
60
- ):
61
- # used bfl-comfy-nodes to verify code implementation:
62
- # https://github.com/black-forest-labs/bfl-comfy-nodes/tree/main
63
- start_time = time.time()
64
- retries_404 = 0
65
- max_retries_404 = 5
66
- retry_404_seconds = 2
67
- retry_202_seconds = 2
68
- retry_pending_seconds = 1
69
- request = requests.Request(method=HttpMethod.GET, url=polling_url)
70
- # NOTE: should True loop be replaced with checking if workflow has been interrupted?
71
- while True:
72
- if node_id:
73
- time_elapsed = time.time() - start_time
74
- PromptServer.instance.send_progress_text(
75
- f"Generating ({time_elapsed:.0f}s)", node_id
76
- )
77
-
78
- response = requests.Session().send(request.prepare())
79
- if response.status_code == 200:
80
- result = response.json()
81
- if result["status"] == BFLStatus.ready:
82
- img_url = result["result"]["sample"]
83
- if node_id:
84
- PromptServer.instance.send_progress_text(
85
- f"Result URL: {img_url}", node_id
86
- )
87
- img_response = requests.get(img_url)
88
- return process_image_response(img_response)
89
- elif result["status"] in [
90
- BFLStatus.request_moderated,
91
- BFLStatus.content_moderated,
92
- ]:
93
- status = result["status"]
94
- raise Exception(
95
- f"BFL API did not return an image due to: {status}."
96
- )
97
- elif result["status"] == BFLStatus.error:
98
- raise Exception(f"BFL API encountered an error: {result}.")
99
- elif result["status"] == BFLStatus.pending:
100
- time.sleep(retry_pending_seconds)
101
- continue
102
- elif response.status_code == 404:
103
- if retries_404 < max_retries_404:
104
- retries_404 += 1
105
- time.sleep(retry_404_seconds)
106
- continue
107
- raise Exception(
108
- f"BFL API could not find task after {max_retries_404} tries."
109
- )
110
- elif response.status_code == 202:
111
- time.sleep(retry_202_seconds)
112
- elif time.time() - start_time > timeout:
113
- raise Exception(
114
- f"BFL API experienced a timeout; could not return request under {timeout} seconds."
115
- )
116
- else:
117
- raise Exception(f"BFL API encountered an error: {response.json()}")
118
-
119
- def convert_image_to_base64(image: torch.Tensor):
120
- scaled_image = downscale_image_tensor(image, total_pixels=2048 * 2048)
121
- # remove batch dimension if present
122
- if len(scaled_image.shape) > 3:
123
- scaled_image = scaled_image[0]
124
- image_np = (scaled_image.numpy() * 255).astype(np.uint8)
125
- img = Image.fromarray(image_np)
126
- img_byte_arr = io.BytesIO()
127
- img.save(img_byte_arr, format="PNG")
128
- return base64.b64encode(img_byte_arr.getvalue()).decode()
129
-
130
-
131
- class FluxProUltraImageNode(ComfyNodeABC):
132
- """
133
- Generates images using Flux Pro 1.1 Ultra via api based on prompt and resolution.
134
- """
135
-
136
- MINIMUM_RATIO = 1 / 4
137
- MAXIMUM_RATIO = 4 / 1
138
- MINIMUM_RATIO_STR = "1:4"
139
- MAXIMUM_RATIO_STR = "4:1"
140
-
141
- @classmethod
142
- def INPUT_TYPES(s):
143
- return {
144
- "required": {
145
- "prompt": (
146
- IO.STRING,
147
- {
148
- "multiline": True,
149
- "default": "",
150
- "tooltip": "Prompt for the image generation",
151
- },
152
- ),
153
- "prompt_upsampling": (
154
- IO.BOOLEAN,
155
- {
156
- "default": False,
157
- "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).",
158
- },
159
- ),
160
- "seed": (
161
- IO.INT,
162
- {
163
- "default": 0,
164
- "min": 0,
165
- "max": 0xFFFFFFFFFFFFFFFF,
166
- "control_after_generate": True,
167
- "tooltip": "The random seed used for creating the noise.",
168
- },
169
- ),
170
- "aspect_ratio": (
171
- IO.STRING,
172
- {
173
- "default": "16:9",
174
- "tooltip": "Aspect ratio of image; must be between 1:4 and 4:1.",
175
- },
176
- ),
177
- "raw": (
178
- IO.BOOLEAN,
179
- {
180
- "default": False,
181
- "tooltip": "When True, generate less processed, more natural-looking images.",
182
- },
183
- ),
184
- },
185
- "optional": {
186
- "image_prompt": (IO.IMAGE,),
187
- "image_prompt_strength": (
188
- IO.FLOAT,
189
- {
190
- "default": 0.1,
191
- "min": 0.0,
192
- "max": 1.0,
193
- "step": 0.01,
194
- "tooltip": "Blend between the prompt and the image prompt.",
195
- },
196
- ),
197
- },
198
- "hidden": {
199
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
200
- "comfy_api_key": "API_KEY_COMFY_ORG",
201
- "unique_id": "UNIQUE_ID",
202
- },
203
- }
204
-
205
- @classmethod
206
- def VALIDATE_INPUTS(cls, aspect_ratio: str):
207
- try:
208
- validate_aspect_ratio(
209
- aspect_ratio,
210
- minimum_ratio=cls.MINIMUM_RATIO,
211
- maximum_ratio=cls.MAXIMUM_RATIO,
212
- minimum_ratio_str=cls.MINIMUM_RATIO_STR,
213
- maximum_ratio_str=cls.MAXIMUM_RATIO_STR,
214
- )
215
- except Exception as e:
216
- return str(e)
217
- return True
218
-
219
- RETURN_TYPES = (IO.IMAGE,)
220
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
221
- FUNCTION = "api_call"
222
- API_NODE = True
223
- CATEGORY = "api node/image/BFL"
224
-
225
- def api_call(
226
- self,
227
- prompt: str,
228
- aspect_ratio: str,
229
- prompt_upsampling=False,
230
- raw=False,
231
- seed=0,
232
- image_prompt=None,
233
- image_prompt_strength=0.1,
234
- unique_id: Union[str, None] = None,
235
- **kwargs,
236
- ):
237
- if image_prompt is None:
238
- validate_string(prompt, strip_whitespace=False)
239
- operation = SynchronousOperation(
240
- endpoint=ApiEndpoint(
241
- path="/proxy/bfl/flux-pro-1.1-ultra/generate",
242
- method=HttpMethod.POST,
243
- request_model=BFLFluxProUltraGenerateRequest,
244
- response_model=BFLFluxProGenerateResponse,
245
- ),
246
- request=BFLFluxProUltraGenerateRequest(
247
- prompt=prompt,
248
- prompt_upsampling=prompt_upsampling,
249
- seed=seed,
250
- aspect_ratio=validate_aspect_ratio(
251
- aspect_ratio,
252
- minimum_ratio=self.MINIMUM_RATIO,
253
- maximum_ratio=self.MAXIMUM_RATIO,
254
- minimum_ratio_str=self.MINIMUM_RATIO_STR,
255
- maximum_ratio_str=self.MAXIMUM_RATIO_STR,
256
- ),
257
- raw=raw,
258
- image_prompt=(
259
- image_prompt
260
- if image_prompt is None
261
- else convert_image_to_base64(image_prompt)
262
- ),
263
- image_prompt_strength=(
264
- None if image_prompt is None else round(image_prompt_strength, 2)
265
- ),
266
- ),
267
- auth_kwargs=kwargs,
268
- )
269
- output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
270
- return (output_image,)
271
-
272
-
273
- class FluxKontextProImageNode(ComfyNodeABC):
274
- """
275
- Edits images using Flux.1 Kontext [pro] via api based on prompt and aspect ratio.
276
- """
277
-
278
- MINIMUM_RATIO = 1 / 4
279
- MAXIMUM_RATIO = 4 / 1
280
- MINIMUM_RATIO_STR = "1:4"
281
- MAXIMUM_RATIO_STR = "4:1"
282
-
283
- @classmethod
284
- def INPUT_TYPES(s):
285
- return {
286
- "required": {
287
- "prompt": (
288
- IO.STRING,
289
- {
290
- "multiline": True,
291
- "default": "",
292
- "tooltip": "Prompt for the image generation - specify what and how to edit.",
293
- },
294
- ),
295
- "aspect_ratio": (
296
- IO.STRING,
297
- {
298
- "default": "16:9",
299
- "tooltip": "Aspect ratio of image; must be between 1:4 and 4:1.",
300
- },
301
- ),
302
- "guidance": (
303
- IO.FLOAT,
304
- {
305
- "default": 3.0,
306
- "min": 0.1,
307
- "max": 99.0,
308
- "step": 0.1,
309
- "tooltip": "Guidance strength for the image generation process"
310
- },
311
- ),
312
- "steps": (
313
- IO.INT,
314
- {
315
- "default": 50,
316
- "min": 1,
317
- "max": 150,
318
- "tooltip": "Number of steps for the image generation process"
319
- },
320
- ),
321
- "seed": (
322
- IO.INT,
323
- {
324
- "default": 1234,
325
- "min": 0,
326
- "max": 0xFFFFFFFFFFFFFFFF,
327
- "control_after_generate": True,
328
- "tooltip": "The random seed used for creating the noise.",
329
- },
330
- ),
331
- "prompt_upsampling": (
332
- IO.BOOLEAN,
333
- {
334
- "default": False,
335
- "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).",
336
- },
337
- ),
338
- },
339
- "optional": {
340
- "input_image": (IO.IMAGE,),
341
- },
342
- "hidden": {
343
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
344
- "comfy_api_key": "API_KEY_COMFY_ORG",
345
- "unique_id": "UNIQUE_ID",
346
- },
347
- }
348
-
349
- RETURN_TYPES = (IO.IMAGE,)
350
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
351
- FUNCTION = "api_call"
352
- API_NODE = True
353
- CATEGORY = "api node/image/BFL"
354
-
355
- BFL_PATH = "/proxy/bfl/flux-kontext-pro/generate"
356
-
357
- def api_call(
358
- self,
359
- prompt: str,
360
- aspect_ratio: str,
361
- guidance: float,
362
- steps: int,
363
- input_image: Optional[torch.Tensor]=None,
364
- seed=0,
365
- prompt_upsampling=False,
366
- unique_id: Union[str, None] = None,
367
- **kwargs,
368
- ):
369
- aspect_ratio = validate_aspect_ratio(
370
- aspect_ratio,
371
- minimum_ratio=self.MINIMUM_RATIO,
372
- maximum_ratio=self.MAXIMUM_RATIO,
373
- minimum_ratio_str=self.MINIMUM_RATIO_STR,
374
- maximum_ratio_str=self.MAXIMUM_RATIO_STR,
375
- )
376
- if input_image is None:
377
- validate_string(prompt, strip_whitespace=False)
378
- operation = SynchronousOperation(
379
- endpoint=ApiEndpoint(
380
- path=self.BFL_PATH,
381
- method=HttpMethod.POST,
382
- request_model=BFLFluxKontextProGenerateRequest,
383
- response_model=BFLFluxProGenerateResponse,
384
- ),
385
- request=BFLFluxKontextProGenerateRequest(
386
- prompt=prompt,
387
- prompt_upsampling=prompt_upsampling,
388
- guidance=round(guidance, 1),
389
- steps=steps,
390
- seed=seed,
391
- aspect_ratio=aspect_ratio,
392
- input_image=(
393
- input_image
394
- if input_image is None
395
- else convert_image_to_base64(input_image)
396
- )
397
- ),
398
- auth_kwargs=kwargs,
399
- )
400
- output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
401
- return (output_image,)
402
-
403
-
404
- class FluxKontextMaxImageNode(FluxKontextProImageNode):
405
- """
406
- Edits images using Flux.1 Kontext [max] via api based on prompt and aspect ratio.
407
- """
408
-
409
- DESCRIPTION = cleandoc(__doc__ or "")
410
- BFL_PATH = "/proxy/bfl/flux-kontext-max/generate"
411
-
412
-
413
- class FluxProImageNode(ComfyNodeABC):
414
- """
415
- Generates images synchronously based on prompt and resolution.
416
- """
417
-
418
- @classmethod
419
- def INPUT_TYPES(s):
420
- return {
421
- "required": {
422
- "prompt": (
423
- IO.STRING,
424
- {
425
- "multiline": True,
426
- "default": "",
427
- "tooltip": "Prompt for the image generation",
428
- },
429
- ),
430
- "prompt_upsampling": (
431
- IO.BOOLEAN,
432
- {
433
- "default": False,
434
- "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).",
435
- },
436
- ),
437
- "width": (
438
- IO.INT,
439
- {
440
- "default": 1024,
441
- "min": 256,
442
- "max": 1440,
443
- "step": 32,
444
- },
445
- ),
446
- "height": (
447
- IO.INT,
448
- {
449
- "default": 768,
450
- "min": 256,
451
- "max": 1440,
452
- "step": 32,
453
- },
454
- ),
455
- "seed": (
456
- IO.INT,
457
- {
458
- "default": 0,
459
- "min": 0,
460
- "max": 0xFFFFFFFFFFFFFFFF,
461
- "control_after_generate": True,
462
- "tooltip": "The random seed used for creating the noise.",
463
- },
464
- ),
465
- },
466
- "optional": {
467
- "image_prompt": (IO.IMAGE,),
468
- # "image_prompt_strength": (
469
- # IO.FLOAT,
470
- # {
471
- # "default": 0.1,
472
- # "min": 0.0,
473
- # "max": 1.0,
474
- # "step": 0.01,
475
- # "tooltip": "Blend between the prompt and the image prompt.",
476
- # },
477
- # ),
478
- },
479
- "hidden": {
480
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
481
- "comfy_api_key": "API_KEY_COMFY_ORG",
482
- "unique_id": "UNIQUE_ID",
483
- },
484
- }
485
-
486
- RETURN_TYPES = (IO.IMAGE,)
487
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
488
- FUNCTION = "api_call"
489
- API_NODE = True
490
- CATEGORY = "api node/image/BFL"
491
-
492
- def api_call(
493
- self,
494
- prompt: str,
495
- prompt_upsampling,
496
- width: int,
497
- height: int,
498
- seed=0,
499
- image_prompt=None,
500
- # image_prompt_strength=0.1,
501
- unique_id: Union[str, None] = None,
502
- **kwargs,
503
- ):
504
- image_prompt = (
505
- image_prompt
506
- if image_prompt is None
507
- else convert_image_to_base64(image_prompt)
508
- )
509
-
510
- operation = SynchronousOperation(
511
- endpoint=ApiEndpoint(
512
- path="/proxy/bfl/flux-pro-1.1/generate",
513
- method=HttpMethod.POST,
514
- request_model=BFLFluxProGenerateRequest,
515
- response_model=BFLFluxProGenerateResponse,
516
- ),
517
- request=BFLFluxProGenerateRequest(
518
- prompt=prompt,
519
- prompt_upsampling=prompt_upsampling,
520
- width=width,
521
- height=height,
522
- seed=seed,
523
- image_prompt=image_prompt,
524
- ),
525
- auth_kwargs=kwargs,
526
- )
527
- output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
528
- return (output_image,)
529
-
530
-
531
- class FluxProExpandNode(ComfyNodeABC):
532
- """
533
- Outpaints image based on prompt.
534
- """
535
-
536
- @classmethod
537
- def INPUT_TYPES(s):
538
- return {
539
- "required": {
540
- "image": (IO.IMAGE,),
541
- "prompt": (
542
- IO.STRING,
543
- {
544
- "multiline": True,
545
- "default": "",
546
- "tooltip": "Prompt for the image generation",
547
- },
548
- ),
549
- "prompt_upsampling": (
550
- IO.BOOLEAN,
551
- {
552
- "default": False,
553
- "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).",
554
- },
555
- ),
556
- "top": (
557
- IO.INT,
558
- {
559
- "default": 0,
560
- "min": 0,
561
- "max": 2048,
562
- "tooltip": "Number of pixels to expand at the top of the image"
563
- },
564
- ),
565
- "bottom": (
566
- IO.INT,
567
- {
568
- "default": 0,
569
- "min": 0,
570
- "max": 2048,
571
- "tooltip": "Number of pixels to expand at the bottom of the image"
572
- },
573
- ),
574
- "left": (
575
- IO.INT,
576
- {
577
- "default": 0,
578
- "min": 0,
579
- "max": 2048,
580
- "tooltip": "Number of pixels to expand at the left side of the image"
581
- },
582
- ),
583
- "right": (
584
- IO.INT,
585
- {
586
- "default": 0,
587
- "min": 0,
588
- "max": 2048,
589
- "tooltip": "Number of pixels to expand at the right side of the image"
590
- },
591
- ),
592
- "guidance": (
593
- IO.FLOAT,
594
- {
595
- "default": 60,
596
- "min": 1.5,
597
- "max": 100,
598
- "tooltip": "Guidance strength for the image generation process"
599
- },
600
- ),
601
- "steps": (
602
- IO.INT,
603
- {
604
- "default": 50,
605
- "min": 15,
606
- "max": 50,
607
- "tooltip": "Number of steps for the image generation process"
608
- },
609
- ),
610
- "seed": (
611
- IO.INT,
612
- {
613
- "default": 0,
614
- "min": 0,
615
- "max": 0xFFFFFFFFFFFFFFFF,
616
- "control_after_generate": True,
617
- "tooltip": "The random seed used for creating the noise.",
618
- },
619
- ),
620
- },
621
- "optional": {},
622
- "hidden": {
623
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
624
- "comfy_api_key": "API_KEY_COMFY_ORG",
625
- "unique_id": "UNIQUE_ID",
626
- },
627
- }
628
-
629
- RETURN_TYPES = (IO.IMAGE,)
630
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
631
- FUNCTION = "api_call"
632
- API_NODE = True
633
- CATEGORY = "api node/image/BFL"
634
-
635
- def api_call(
636
- self,
637
- image: torch.Tensor,
638
- prompt: str,
639
- prompt_upsampling: bool,
640
- top: int,
641
- bottom: int,
642
- left: int,
643
- right: int,
644
- steps: int,
645
- guidance: float,
646
- seed=0,
647
- unique_id: Union[str, None] = None,
648
- **kwargs,
649
- ):
650
- image = convert_image_to_base64(image)
651
-
652
- operation = SynchronousOperation(
653
- endpoint=ApiEndpoint(
654
- path="/proxy/bfl/flux-pro-1.0-expand/generate",
655
- method=HttpMethod.POST,
656
- request_model=BFLFluxExpandImageRequest,
657
- response_model=BFLFluxProGenerateResponse,
658
- ),
659
- request=BFLFluxExpandImageRequest(
660
- prompt=prompt,
661
- prompt_upsampling=prompt_upsampling,
662
- top=top,
663
- bottom=bottom,
664
- left=left,
665
- right=right,
666
- steps=steps,
667
- guidance=guidance,
668
- seed=seed,
669
- image=image,
670
- ),
671
- auth_kwargs=kwargs,
672
- )
673
- output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
674
- return (output_image,)
675
-
676
-
677
-
678
- class FluxProFillNode(ComfyNodeABC):
679
- """
680
- Inpaints image based on mask and prompt.
681
- """
682
-
683
- @classmethod
684
- def INPUT_TYPES(s):
685
- return {
686
- "required": {
687
- "image": (IO.IMAGE,),
688
- "mask": (IO.MASK,),
689
- "prompt": (
690
- IO.STRING,
691
- {
692
- "multiline": True,
693
- "default": "",
694
- "tooltip": "Prompt for the image generation",
695
- },
696
- ),
697
- "prompt_upsampling": (
698
- IO.BOOLEAN,
699
- {
700
- "default": False,
701
- "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).",
702
- },
703
- ),
704
- "guidance": (
705
- IO.FLOAT,
706
- {
707
- "default": 60,
708
- "min": 1.5,
709
- "max": 100,
710
- "tooltip": "Guidance strength for the image generation process"
711
- },
712
- ),
713
- "steps": (
714
- IO.INT,
715
- {
716
- "default": 50,
717
- "min": 15,
718
- "max": 50,
719
- "tooltip": "Number of steps for the image generation process"
720
- },
721
- ),
722
- "seed": (
723
- IO.INT,
724
- {
725
- "default": 0,
726
- "min": 0,
727
- "max": 0xFFFFFFFFFFFFFFFF,
728
- "control_after_generate": True,
729
- "tooltip": "The random seed used for creating the noise.",
730
- },
731
- ),
732
- },
733
- "optional": {},
734
- "hidden": {
735
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
736
- "comfy_api_key": "API_KEY_COMFY_ORG",
737
- "unique_id": "UNIQUE_ID",
738
- },
739
- }
740
-
741
- RETURN_TYPES = (IO.IMAGE,)
742
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
743
- FUNCTION = "api_call"
744
- API_NODE = True
745
- CATEGORY = "api node/image/BFL"
746
-
747
- def api_call(
748
- self,
749
- image: torch.Tensor,
750
- mask: torch.Tensor,
751
- prompt: str,
752
- prompt_upsampling: bool,
753
- steps: int,
754
- guidance: float,
755
- seed=0,
756
- unique_id: Union[str, None] = None,
757
- **kwargs,
758
- ):
759
- # prepare mask
760
- mask = resize_mask_to_image(mask, image)
761
- mask = convert_image_to_base64(convert_mask_to_image(mask))
762
- # make sure image will have alpha channel removed
763
- image = convert_image_to_base64(image[:, :, :, :3])
764
-
765
- operation = SynchronousOperation(
766
- endpoint=ApiEndpoint(
767
- path="/proxy/bfl/flux-pro-1.0-fill/generate",
768
- method=HttpMethod.POST,
769
- request_model=BFLFluxFillImageRequest,
770
- response_model=BFLFluxProGenerateResponse,
771
- ),
772
- request=BFLFluxFillImageRequest(
773
- prompt=prompt,
774
- prompt_upsampling=prompt_upsampling,
775
- steps=steps,
776
- guidance=guidance,
777
- seed=seed,
778
- image=image,
779
- mask=mask,
780
- ),
781
- auth_kwargs=kwargs,
782
- )
783
- output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
784
- return (output_image,)
785
-
786
-
787
- class FluxProCannyNode(ComfyNodeABC):
788
- """
789
- Generate image using a control image (canny).
790
- """
791
-
792
- @classmethod
793
- def INPUT_TYPES(s):
794
- return {
795
- "required": {
796
- "control_image": (IO.IMAGE,),
797
- "prompt": (
798
- IO.STRING,
799
- {
800
- "multiline": True,
801
- "default": "",
802
- "tooltip": "Prompt for the image generation",
803
- },
804
- ),
805
- "prompt_upsampling": (
806
- IO.BOOLEAN,
807
- {
808
- "default": False,
809
- "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).",
810
- },
811
- ),
812
- "canny_low_threshold": (
813
- IO.FLOAT,
814
- {
815
- "default": 0.1,
816
- "min": 0.01,
817
- "max": 0.99,
818
- "step": 0.01,
819
- "tooltip": "Low threshold for Canny edge detection; ignored if skip_processing is True"
820
- },
821
- ),
822
- "canny_high_threshold": (
823
- IO.FLOAT,
824
- {
825
- "default": 0.4,
826
- "min": 0.01,
827
- "max": 0.99,
828
- "step": 0.01,
829
- "tooltip": "High threshold for Canny edge detection; ignored if skip_processing is True"
830
- },
831
- ),
832
- "skip_preprocessing": (
833
- IO.BOOLEAN,
834
- {
835
- "default": False,
836
- "tooltip": "Whether to skip preprocessing; set to True if control_image already is canny-fied, False if it is a raw image.",
837
- },
838
- ),
839
- "guidance": (
840
- IO.FLOAT,
841
- {
842
- "default": 30,
843
- "min": 1,
844
- "max": 100,
845
- "tooltip": "Guidance strength for the image generation process"
846
- },
847
- ),
848
- "steps": (
849
- IO.INT,
850
- {
851
- "default": 50,
852
- "min": 15,
853
- "max": 50,
854
- "tooltip": "Number of steps for the image generation process"
855
- },
856
- ),
857
- "seed": (
858
- IO.INT,
859
- {
860
- "default": 0,
861
- "min": 0,
862
- "max": 0xFFFFFFFFFFFFFFFF,
863
- "control_after_generate": True,
864
- "tooltip": "The random seed used for creating the noise.",
865
- },
866
- ),
867
- },
868
- "optional": {},
869
- "hidden": {
870
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
871
- "comfy_api_key": "API_KEY_COMFY_ORG",
872
- "unique_id": "UNIQUE_ID",
873
- },
874
- }
875
-
876
- RETURN_TYPES = (IO.IMAGE,)
877
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
878
- FUNCTION = "api_call"
879
- API_NODE = True
880
- CATEGORY = "api node/image/BFL"
881
-
882
- def api_call(
883
- self,
884
- control_image: torch.Tensor,
885
- prompt: str,
886
- prompt_upsampling: bool,
887
- canny_low_threshold: float,
888
- canny_high_threshold: float,
889
- skip_preprocessing: bool,
890
- steps: int,
891
- guidance: float,
892
- seed=0,
893
- unique_id: Union[str, None] = None,
894
- **kwargs,
895
- ):
896
- control_image = convert_image_to_base64(control_image[:, :, :, :3])
897
- preprocessed_image = None
898
-
899
- # scale canny threshold between 0-500, to match BFL's API
900
- def scale_value(value: float, min_val=0, max_val=500):
901
- return min_val + value * (max_val - min_val)
902
- canny_low_threshold = int(round(scale_value(canny_low_threshold)))
903
- canny_high_threshold = int(round(scale_value(canny_high_threshold)))
904
-
905
-
906
- if skip_preprocessing:
907
- preprocessed_image = control_image
908
- control_image = None
909
- canny_low_threshold = None
910
- canny_high_threshold = None
911
-
912
- operation = SynchronousOperation(
913
- endpoint=ApiEndpoint(
914
- path="/proxy/bfl/flux-pro-1.0-canny/generate",
915
- method=HttpMethod.POST,
916
- request_model=BFLFluxCannyImageRequest,
917
- response_model=BFLFluxProGenerateResponse,
918
- ),
919
- request=BFLFluxCannyImageRequest(
920
- prompt=prompt,
921
- prompt_upsampling=prompt_upsampling,
922
- steps=steps,
923
- guidance=guidance,
924
- seed=seed,
925
- control_image=control_image,
926
- canny_low_threshold=canny_low_threshold,
927
- canny_high_threshold=canny_high_threshold,
928
- preprocessed_image=preprocessed_image,
929
- ),
930
- auth_kwargs=kwargs,
931
- )
932
- output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
933
- return (output_image,)
934
-
935
-
936
- class FluxProDepthNode(ComfyNodeABC):
937
- """
938
- Generate image using a control image (depth).
939
- """
940
-
941
- @classmethod
942
- def INPUT_TYPES(s):
943
- return {
944
- "required": {
945
- "control_image": (IO.IMAGE,),
946
- "prompt": (
947
- IO.STRING,
948
- {
949
- "multiline": True,
950
- "default": "",
951
- "tooltip": "Prompt for the image generation",
952
- },
953
- ),
954
- "prompt_upsampling": (
955
- IO.BOOLEAN,
956
- {
957
- "default": False,
958
- "tooltip": "Whether to perform upsampling on the prompt. If active, automatically modifies the prompt for more creative generation, but results are nondeterministic (same seed will not produce exactly the same result).",
959
- },
960
- ),
961
- "skip_preprocessing": (
962
- IO.BOOLEAN,
963
- {
964
- "default": False,
965
- "tooltip": "Whether to skip preprocessing; set to True if control_image already is depth-ified, False if it is a raw image.",
966
- },
967
- ),
968
- "guidance": (
969
- IO.FLOAT,
970
- {
971
- "default": 15,
972
- "min": 1,
973
- "max": 100,
974
- "tooltip": "Guidance strength for the image generation process"
975
- },
976
- ),
977
- "steps": (
978
- IO.INT,
979
- {
980
- "default": 50,
981
- "min": 15,
982
- "max": 50,
983
- "tooltip": "Number of steps for the image generation process"
984
- },
985
- ),
986
- "seed": (
987
- IO.INT,
988
- {
989
- "default": 0,
990
- "min": 0,
991
- "max": 0xFFFFFFFFFFFFFFFF,
992
- "control_after_generate": True,
993
- "tooltip": "The random seed used for creating the noise.",
994
- },
995
- ),
996
- },
997
- "optional": {},
998
- "hidden": {
999
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
1000
- "comfy_api_key": "API_KEY_COMFY_ORG",
1001
- "unique_id": "UNIQUE_ID",
1002
- },
1003
- }
1004
-
1005
- RETURN_TYPES = (IO.IMAGE,)
1006
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
1007
- FUNCTION = "api_call"
1008
- API_NODE = True
1009
- CATEGORY = "api node/image/BFL"
1010
-
1011
- def api_call(
1012
- self,
1013
- control_image: torch.Tensor,
1014
- prompt: str,
1015
- prompt_upsampling: bool,
1016
- skip_preprocessing: bool,
1017
- steps: int,
1018
- guidance: float,
1019
- seed=0,
1020
- unique_id: Union[str, None] = None,
1021
- **kwargs,
1022
- ):
1023
- control_image = convert_image_to_base64(control_image[:,:,:,:3])
1024
- preprocessed_image = None
1025
-
1026
- if skip_preprocessing:
1027
- preprocessed_image = control_image
1028
- control_image = None
1029
-
1030
- operation = SynchronousOperation(
1031
- endpoint=ApiEndpoint(
1032
- path="/proxy/bfl/flux-pro-1.0-depth/generate",
1033
- method=HttpMethod.POST,
1034
- request_model=BFLFluxDepthImageRequest,
1035
- response_model=BFLFluxProGenerateResponse,
1036
- ),
1037
- request=BFLFluxDepthImageRequest(
1038
- prompt=prompt,
1039
- prompt_upsampling=prompt_upsampling,
1040
- steps=steps,
1041
- guidance=guidance,
1042
- seed=seed,
1043
- control_image=control_image,
1044
- preprocessed_image=preprocessed_image,
1045
- ),
1046
- auth_kwargs=kwargs,
1047
- )
1048
- output_image = handle_bfl_synchronous_operation(operation, node_id=unique_id)
1049
- return (output_image,)
1050
-
1051
-
1052
- # A dictionary that contains all nodes you want to export with their names
1053
- # NOTE: names should be globally unique
1054
- NODE_CLASS_MAPPINGS = {
1055
- "FluxProUltraImageNode": FluxProUltraImageNode,
1056
- # "FluxProImageNode": FluxProImageNode,
1057
- "FluxKontextProImageNode": FluxKontextProImageNode,
1058
- "FluxKontextMaxImageNode": FluxKontextMaxImageNode,
1059
- "FluxProExpandNode": FluxProExpandNode,
1060
- "FluxProFillNode": FluxProFillNode,
1061
- "FluxProCannyNode": FluxProCannyNode,
1062
- "FluxProDepthNode": FluxProDepthNode,
1063
- }
1064
-
1065
- # A dictionary that contains the friendly/humanly readable titles for the nodes
1066
- NODE_DISPLAY_NAME_MAPPINGS = {
1067
- "FluxProUltraImageNode": "Flux 1.1 [pro] Ultra Image",
1068
- # "FluxProImageNode": "Flux 1.1 [pro] Image",
1069
- "FluxKontextProImageNode": "Flux.1 Kontext [pro] Image",
1070
- "FluxKontextMaxImageNode": "Flux.1 Kontext [max] Image",
1071
- "FluxProExpandNode": "Flux.1 Expand Image",
1072
- "FluxProFillNode": "Flux.1 Fill Image",
1073
- "FluxProCannyNode": "Flux.1 Canny Control Image",
1074
- "FluxProDepthNode": "Flux.1 Depth Control Image",
1075
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/nodes_gemini.py DELETED
@@ -1,446 +0,0 @@
1
- """
2
- API Nodes for Gemini Multimodal LLM Usage via Remote API
3
- See: https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference
4
- """
5
-
6
- import os
7
- from enum import Enum
8
- from typing import Optional, Literal
9
-
10
- import torch
11
-
12
- import folder_paths
13
- from comfy.comfy_types.node_typing import IO, ComfyNodeABC, InputTypeDict
14
- from server import PromptServer
15
- from comfy_api_nodes.apis import (
16
- GeminiContent,
17
- GeminiGenerateContentRequest,
18
- GeminiGenerateContentResponse,
19
- GeminiInlineData,
20
- GeminiPart,
21
- GeminiMimeType,
22
- )
23
- from comfy_api_nodes.apis.client import (
24
- ApiEndpoint,
25
- HttpMethod,
26
- SynchronousOperation,
27
- )
28
- from comfy_api_nodes.apinode_utils import (
29
- validate_string,
30
- audio_to_base64_string,
31
- video_to_base64_string,
32
- tensor_to_base64_string,
33
- )
34
-
35
-
36
- GEMINI_BASE_ENDPOINT = "/proxy/vertexai/gemini"
37
- GEMINI_MAX_INPUT_FILE_SIZE = 20 * 1024 * 1024 # 20 MB
38
-
39
-
40
- class GeminiModel(str, Enum):
41
- """
42
- Gemini Model Names allowed by comfy-api
43
- """
44
-
45
- gemini_2_5_pro_preview_05_06 = "gemini-2.5-pro-preview-05-06"
46
- gemini_2_5_flash_preview_04_17 = "gemini-2.5-flash-preview-04-17"
47
-
48
-
49
- def get_gemini_endpoint(
50
- model: GeminiModel,
51
- ) -> ApiEndpoint[GeminiGenerateContentRequest, GeminiGenerateContentResponse]:
52
- """
53
- Get the API endpoint for a given Gemini model.
54
-
55
- Args:
56
- model: The Gemini model to use, either as enum or string value.
57
-
58
- Returns:
59
- ApiEndpoint configured for the specific Gemini model.
60
- """
61
- if isinstance(model, str):
62
- model = GeminiModel(model)
63
- return ApiEndpoint(
64
- path=f"{GEMINI_BASE_ENDPOINT}/{model.value}",
65
- method=HttpMethod.POST,
66
- request_model=GeminiGenerateContentRequest,
67
- response_model=GeminiGenerateContentResponse,
68
- )
69
-
70
-
71
- class GeminiNode(ComfyNodeABC):
72
- """
73
- Node to generate text responses from a Gemini model.
74
-
75
- This node allows users to interact with Google's Gemini AI models, providing
76
- multimodal inputs (text, images, audio, video, files) to generate coherent
77
- text responses. The node works with the latest Gemini models, handling the
78
- API communication and response parsing.
79
- """
80
-
81
- @classmethod
82
- def INPUT_TYPES(cls) -> InputTypeDict:
83
- return {
84
- "required": {
85
- "prompt": (
86
- IO.STRING,
87
- {
88
- "multiline": True,
89
- "default": "",
90
- "tooltip": "Text inputs to the model, used to generate a response. You can include detailed instructions, questions, or context for the model.",
91
- },
92
- ),
93
- "model": (
94
- IO.COMBO,
95
- {
96
- "tooltip": "The Gemini model to use for generating responses.",
97
- "options": [model.value for model in GeminiModel],
98
- "default": GeminiModel.gemini_2_5_pro_preview_05_06.value,
99
- },
100
- ),
101
- "seed": (
102
- IO.INT,
103
- {
104
- "default": 42,
105
- "min": 0,
106
- "max": 0xFFFFFFFFFFFFFFFF,
107
- "control_after_generate": True,
108
- "tooltip": "When seed is fixed to a specific value, the model makes a best effort to provide the same response for repeated requests. Deterministic output isn't guaranteed. Also, changing the model or parameter settings, such as the temperature, can cause variations in the response even when you use the same seed value. By default, a random seed value is used.",
109
- },
110
- ),
111
- },
112
- "optional": {
113
- "images": (
114
- IO.IMAGE,
115
- {
116
- "default": None,
117
- "tooltip": "Optional image(s) to use as context for the model. To include multiple images, you can use the Batch Images node.",
118
- },
119
- ),
120
- "audio": (
121
- IO.AUDIO,
122
- {
123
- "tooltip": "Optional audio to use as context for the model.",
124
- "default": None,
125
- },
126
- ),
127
- "video": (
128
- IO.VIDEO,
129
- {
130
- "tooltip": "Optional video to use as context for the model.",
131
- "default": None,
132
- },
133
- ),
134
- "files": (
135
- "GEMINI_INPUT_FILES",
136
- {
137
- "default": None,
138
- "tooltip": "Optional file(s) to use as context for the model. Accepts inputs from the Gemini Generate Content Input Files node.",
139
- },
140
- ),
141
- },
142
- "hidden": {
143
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
144
- "comfy_api_key": "API_KEY_COMFY_ORG",
145
- "unique_id": "UNIQUE_ID",
146
- },
147
- }
148
-
149
- DESCRIPTION = "Generate text responses with Google's Gemini AI model. You can provide multiple types of inputs (text, images, audio, video) as context for generating more relevant and meaningful responses."
150
- RETURN_TYPES = ("STRING",)
151
- FUNCTION = "api_call"
152
- CATEGORY = "api node/text/Gemini"
153
- API_NODE = True
154
-
155
- def get_parts_from_response(
156
- self, response: GeminiGenerateContentResponse
157
- ) -> list[GeminiPart]:
158
- """
159
- Extract all parts from the Gemini API response.
160
-
161
- Args:
162
- response: The API response from Gemini.
163
-
164
- Returns:
165
- List of response parts from the first candidate.
166
- """
167
- return response.candidates[0].content.parts
168
-
169
- def get_parts_by_type(
170
- self, response: GeminiGenerateContentResponse, part_type: Literal["text"] | str
171
- ) -> list[GeminiPart]:
172
- """
173
- Filter response parts by their type.
174
-
175
- Args:
176
- response: The API response from Gemini.
177
- part_type: Type of parts to extract ("text" or a MIME type).
178
-
179
- Returns:
180
- List of response parts matching the requested type.
181
- """
182
- parts = []
183
- for part in self.get_parts_from_response(response):
184
- if part_type == "text" and hasattr(part, "text") and part.text:
185
- parts.append(part)
186
- elif (
187
- hasattr(part, "inlineData")
188
- and part.inlineData
189
- and part.inlineData.mimeType == part_type
190
- ):
191
- parts.append(part)
192
- # Skip parts that don't match the requested type
193
- return parts
194
-
195
- def get_text_from_response(self, response: GeminiGenerateContentResponse) -> str:
196
- """
197
- Extract and concatenate all text parts from the response.
198
-
199
- Args:
200
- response: The API response from Gemini.
201
-
202
- Returns:
203
- Combined text from all text parts in the response.
204
- """
205
- parts = self.get_parts_by_type(response, "text")
206
- return "\n".join([part.text for part in parts])
207
-
208
- def create_video_parts(self, video_input: IO.VIDEO, **kwargs) -> list[GeminiPart]:
209
- """
210
- Convert video input to Gemini API compatible parts.
211
-
212
- Args:
213
- video_input: Video tensor from ComfyUI.
214
- **kwargs: Additional arguments to pass to the conversion function.
215
-
216
- Returns:
217
- List of GeminiPart objects containing the encoded video.
218
- """
219
- from comfy_api.util import VideoContainer, VideoCodec
220
- base_64_string = video_to_base64_string(
221
- video_input,
222
- container_format=VideoContainer.MP4,
223
- codec=VideoCodec.H264
224
- )
225
- return [
226
- GeminiPart(
227
- inlineData=GeminiInlineData(
228
- mimeType=GeminiMimeType.video_mp4,
229
- data=base_64_string,
230
- )
231
- )
232
- ]
233
-
234
- def create_audio_parts(self, audio_input: IO.AUDIO) -> list[GeminiPart]:
235
- """
236
- Convert audio input to Gemini API compatible parts.
237
-
238
- Args:
239
- audio_input: Audio input from ComfyUI, containing waveform tensor and sample rate.
240
-
241
- Returns:
242
- List of GeminiPart objects containing the encoded audio.
243
- """
244
- audio_parts: list[GeminiPart] = []
245
- for batch_index in range(audio_input["waveform"].shape[0]):
246
- # Recreate an IO.AUDIO object for the given batch dimension index
247
- audio_at_index = {
248
- "waveform": audio_input["waveform"][batch_index].unsqueeze(0),
249
- "sample_rate": audio_input["sample_rate"],
250
- }
251
- # Convert to MP3 format for compatibility with Gemini API
252
- audio_bytes = audio_to_base64_string(
253
- audio_at_index,
254
- container_format="mp3",
255
- codec_name="libmp3lame",
256
- )
257
- audio_parts.append(
258
- GeminiPart(
259
- inlineData=GeminiInlineData(
260
- mimeType=GeminiMimeType.audio_mp3,
261
- data=audio_bytes,
262
- )
263
- )
264
- )
265
- return audio_parts
266
-
267
- def create_image_parts(self, image_input: torch.Tensor) -> list[GeminiPart]:
268
- """
269
- Convert image tensor input to Gemini API compatible parts.
270
-
271
- Args:
272
- image_input: Batch of image tensors from ComfyUI.
273
-
274
- Returns:
275
- List of GeminiPart objects containing the encoded images.
276
- """
277
- image_parts: list[GeminiPart] = []
278
- for image_index in range(image_input.shape[0]):
279
- image_as_b64 = tensor_to_base64_string(
280
- image_input[image_index].unsqueeze(0)
281
- )
282
- image_parts.append(
283
- GeminiPart(
284
- inlineData=GeminiInlineData(
285
- mimeType=GeminiMimeType.image_png,
286
- data=image_as_b64,
287
- )
288
- )
289
- )
290
- return image_parts
291
-
292
- def create_text_part(self, text: str) -> GeminiPart:
293
- """
294
- Create a text part for the Gemini API request.
295
-
296
- Args:
297
- text: The text content to include in the request.
298
-
299
- Returns:
300
- A GeminiPart object with the text content.
301
- """
302
- return GeminiPart(text=text)
303
-
304
- def api_call(
305
- self,
306
- prompt: str,
307
- model: GeminiModel,
308
- images: Optional[IO.IMAGE] = None,
309
- audio: Optional[IO.AUDIO] = None,
310
- video: Optional[IO.VIDEO] = None,
311
- files: Optional[list[GeminiPart]] = None,
312
- unique_id: Optional[str] = None,
313
- **kwargs,
314
- ) -> tuple[str]:
315
- # Validate inputs
316
- validate_string(prompt, strip_whitespace=False)
317
-
318
- # Create parts list with text prompt as the first part
319
- parts: list[GeminiPart] = [self.create_text_part(prompt)]
320
-
321
- # Add other modal parts
322
- if images is not None:
323
- image_parts = self.create_image_parts(images)
324
- parts.extend(image_parts)
325
- if audio is not None:
326
- parts.extend(self.create_audio_parts(audio))
327
- if video is not None:
328
- parts.extend(self.create_video_parts(video))
329
- if files is not None:
330
- parts.extend(files)
331
-
332
- # Create response
333
- response = SynchronousOperation(
334
- endpoint=get_gemini_endpoint(model),
335
- request=GeminiGenerateContentRequest(
336
- contents=[
337
- GeminiContent(
338
- role="user",
339
- parts=parts,
340
- )
341
- ]
342
- ),
343
- auth_kwargs=kwargs,
344
- ).execute()
345
-
346
- # Get result output
347
- output_text = self.get_text_from_response(response)
348
- if unique_id and output_text:
349
- PromptServer.instance.send_progress_text(output_text, node_id=unique_id)
350
-
351
- return (output_text or "Empty response from Gemini model...",)
352
-
353
-
354
- class GeminiInputFiles(ComfyNodeABC):
355
- """
356
- Loads and formats input files for use with the Gemini API.
357
-
358
- This node allows users to include text (.txt) and PDF (.pdf) files as input
359
- context for the Gemini model. Files are converted to the appropriate format
360
- required by the API and can be chained together to include multiple files
361
- in a single request.
362
- """
363
-
364
- @classmethod
365
- def INPUT_TYPES(cls) -> InputTypeDict:
366
- """
367
- For details about the supported file input types, see:
368
- https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference
369
- """
370
- input_dir = folder_paths.get_input_directory()
371
- input_files = [
372
- f
373
- for f in os.scandir(input_dir)
374
- if f.is_file()
375
- and (f.name.endswith(".txt") or f.name.endswith(".pdf"))
376
- and f.stat().st_size < GEMINI_MAX_INPUT_FILE_SIZE
377
- ]
378
- input_files = sorted(input_files, key=lambda x: x.name)
379
- input_files = [f.name for f in input_files]
380
- return {
381
- "required": {
382
- "file": (
383
- IO.COMBO,
384
- {
385
- "tooltip": "Input files to include as context for the model. Only accepts text (.txt) and PDF (.pdf) files for now.",
386
- "options": input_files,
387
- "default": input_files[0] if input_files else None,
388
- },
389
- ),
390
- },
391
- "optional": {
392
- "GEMINI_INPUT_FILES": (
393
- "GEMINI_INPUT_FILES",
394
- {
395
- "tooltip": "An optional additional file(s) to batch together with the file loaded from this node. Allows chaining of input files so that a single message can include multiple input files.",
396
- "default": None,
397
- },
398
- ),
399
- },
400
- }
401
-
402
- DESCRIPTION = "Loads and prepares input files to include as inputs for Gemini LLM nodes. The files will be read by the Gemini model when generating a response. The contents of the text file count toward the token limit. 🛈 TIP: Can be chained together with other Gemini Input File nodes."
403
- RETURN_TYPES = ("GEMINI_INPUT_FILES",)
404
- FUNCTION = "prepare_files"
405
- CATEGORY = "api node/text/Gemini"
406
-
407
- def create_file_part(self, file_path: str) -> GeminiPart:
408
- mime_type = (
409
- GeminiMimeType.application_pdf
410
- if file_path.endswith(".pdf")
411
- else GeminiMimeType.text_plain
412
- )
413
- # Use base64 string directly, not the data URI
414
- with open(file_path, "rb") as f:
415
- file_content = f.read()
416
- import base64
417
- base64_str = base64.b64encode(file_content).decode("utf-8")
418
-
419
- return GeminiPart(
420
- inlineData=GeminiInlineData(
421
- mimeType=mime_type,
422
- data=base64_str,
423
- )
424
- )
425
-
426
- def prepare_files(
427
- self, file: str, GEMINI_INPUT_FILES: list[GeminiPart] = []
428
- ) -> tuple[list[GeminiPart]]:
429
- """
430
- Loads and formats input files for Gemini API.
431
- """
432
- file_path = folder_paths.get_annotated_filepath(file)
433
- input_file_content = self.create_file_part(file_path)
434
- files = [input_file_content] + GEMINI_INPUT_FILES
435
- return (files,)
436
-
437
-
438
- NODE_CLASS_MAPPINGS = {
439
- "GeminiNode": GeminiNode,
440
- "GeminiInputFiles": GeminiInputFiles,
441
- }
442
-
443
- NODE_DISPLAY_NAME_MAPPINGS = {
444
- "GeminiNode": "Google Gemini",
445
- "GeminiInputFiles": "Gemini Input Files",
446
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/nodes_ideogram.py DELETED
@@ -1,801 +0,0 @@
1
- from comfy.comfy_types.node_typing import IO, ComfyNodeABC, InputTypeDict
2
- from inspect import cleandoc
3
- from PIL import Image
4
- import numpy as np
5
- import io
6
- import torch
7
- from comfy_api_nodes.apis import (
8
- IdeogramGenerateRequest,
9
- IdeogramGenerateResponse,
10
- ImageRequest,
11
- IdeogramV3Request,
12
- IdeogramV3EditRequest,
13
- )
14
-
15
- from comfy_api_nodes.apis.client import (
16
- ApiEndpoint,
17
- HttpMethod,
18
- SynchronousOperation,
19
- )
20
-
21
- from comfy_api_nodes.apinode_utils import (
22
- download_url_to_bytesio,
23
- bytesio_to_image_tensor,
24
- resize_mask_to_image,
25
- )
26
- from server import PromptServer
27
-
28
- V1_V1_RES_MAP = {
29
- "Auto":"AUTO",
30
- "512 x 1536":"RESOLUTION_512_1536",
31
- "576 x 1408":"RESOLUTION_576_1408",
32
- "576 x 1472":"RESOLUTION_576_1472",
33
- "576 x 1536":"RESOLUTION_576_1536",
34
- "640 x 1024":"RESOLUTION_640_1024",
35
- "640 x 1344":"RESOLUTION_640_1344",
36
- "640 x 1408":"RESOLUTION_640_1408",
37
- "640 x 1472":"RESOLUTION_640_1472",
38
- "640 x 1536":"RESOLUTION_640_1536",
39
- "704 x 1152":"RESOLUTION_704_1152",
40
- "704 x 1216":"RESOLUTION_704_1216",
41
- "704 x 1280":"RESOLUTION_704_1280",
42
- "704 x 1344":"RESOLUTION_704_1344",
43
- "704 x 1408":"RESOLUTION_704_1408",
44
- "704 x 1472":"RESOLUTION_704_1472",
45
- "720 x 1280":"RESOLUTION_720_1280",
46
- "736 x 1312":"RESOLUTION_736_1312",
47
- "768 x 1024":"RESOLUTION_768_1024",
48
- "768 x 1088":"RESOLUTION_768_1088",
49
- "768 x 1152":"RESOLUTION_768_1152",
50
- "768 x 1216":"RESOLUTION_768_1216",
51
- "768 x 1232":"RESOLUTION_768_1232",
52
- "768 x 1280":"RESOLUTION_768_1280",
53
- "768 x 1344":"RESOLUTION_768_1344",
54
- "832 x 960":"RESOLUTION_832_960",
55
- "832 x 1024":"RESOLUTION_832_1024",
56
- "832 x 1088":"RESOLUTION_832_1088",
57
- "832 x 1152":"RESOLUTION_832_1152",
58
- "832 x 1216":"RESOLUTION_832_1216",
59
- "832 x 1248":"RESOLUTION_832_1248",
60
- "864 x 1152":"RESOLUTION_864_1152",
61
- "896 x 960":"RESOLUTION_896_960",
62
- "896 x 1024":"RESOLUTION_896_1024",
63
- "896 x 1088":"RESOLUTION_896_1088",
64
- "896 x 1120":"RESOLUTION_896_1120",
65
- "896 x 1152":"RESOLUTION_896_1152",
66
- "960 x 832":"RESOLUTION_960_832",
67
- "960 x 896":"RESOLUTION_960_896",
68
- "960 x 1024":"RESOLUTION_960_1024",
69
- "960 x 1088":"RESOLUTION_960_1088",
70
- "1024 x 640":"RESOLUTION_1024_640",
71
- "1024 x 768":"RESOLUTION_1024_768",
72
- "1024 x 832":"RESOLUTION_1024_832",
73
- "1024 x 896":"RESOLUTION_1024_896",
74
- "1024 x 960":"RESOLUTION_1024_960",
75
- "1024 x 1024":"RESOLUTION_1024_1024",
76
- "1088 x 768":"RESOLUTION_1088_768",
77
- "1088 x 832":"RESOLUTION_1088_832",
78
- "1088 x 896":"RESOLUTION_1088_896",
79
- "1088 x 960":"RESOLUTION_1088_960",
80
- "1120 x 896":"RESOLUTION_1120_896",
81
- "1152 x 704":"RESOLUTION_1152_704",
82
- "1152 x 768":"RESOLUTION_1152_768",
83
- "1152 x 832":"RESOLUTION_1152_832",
84
- "1152 x 864":"RESOLUTION_1152_864",
85
- "1152 x 896":"RESOLUTION_1152_896",
86
- "1216 x 704":"RESOLUTION_1216_704",
87
- "1216 x 768":"RESOLUTION_1216_768",
88
- "1216 x 832":"RESOLUTION_1216_832",
89
- "1232 x 768":"RESOLUTION_1232_768",
90
- "1248 x 832":"RESOLUTION_1248_832",
91
- "1280 x 704":"RESOLUTION_1280_704",
92
- "1280 x 720":"RESOLUTION_1280_720",
93
- "1280 x 768":"RESOLUTION_1280_768",
94
- "1280 x 800":"RESOLUTION_1280_800",
95
- "1312 x 736":"RESOLUTION_1312_736",
96
- "1344 x 640":"RESOLUTION_1344_640",
97
- "1344 x 704":"RESOLUTION_1344_704",
98
- "1344 x 768":"RESOLUTION_1344_768",
99
- "1408 x 576":"RESOLUTION_1408_576",
100
- "1408 x 640":"RESOLUTION_1408_640",
101
- "1408 x 704":"RESOLUTION_1408_704",
102
- "1472 x 576":"RESOLUTION_1472_576",
103
- "1472 x 640":"RESOLUTION_1472_640",
104
- "1472 x 704":"RESOLUTION_1472_704",
105
- "1536 x 512":"RESOLUTION_1536_512",
106
- "1536 x 576":"RESOLUTION_1536_576",
107
- "1536 x 640":"RESOLUTION_1536_640",
108
- }
109
-
110
- V1_V2_RATIO_MAP = {
111
- "1:1":"ASPECT_1_1",
112
- "4:3":"ASPECT_4_3",
113
- "3:4":"ASPECT_3_4",
114
- "16:9":"ASPECT_16_9",
115
- "9:16":"ASPECT_9_16",
116
- "2:1":"ASPECT_2_1",
117
- "1:2":"ASPECT_1_2",
118
- "3:2":"ASPECT_3_2",
119
- "2:3":"ASPECT_2_3",
120
- "4:5":"ASPECT_4_5",
121
- "5:4":"ASPECT_5_4",
122
- }
123
-
124
- V3_RATIO_MAP = {
125
- "1:3":"1x3",
126
- "3:1":"3x1",
127
- "1:2":"1x2",
128
- "2:1":"2x1",
129
- "9:16":"9x16",
130
- "16:9":"16x9",
131
- "10:16":"10x16",
132
- "16:10":"16x10",
133
- "2:3":"2x3",
134
- "3:2":"3x2",
135
- "3:4":"3x4",
136
- "4:3":"4x3",
137
- "4:5":"4x5",
138
- "5:4":"5x4",
139
- "1:1":"1x1",
140
- }
141
-
142
- V3_RESOLUTIONS= [
143
- "Auto",
144
- "512x1536",
145
- "576x1408",
146
- "576x1472",
147
- "576x1536",
148
- "640x1344",
149
- "640x1408",
150
- "640x1472",
151
- "640x1536",
152
- "704x1152",
153
- "704x1216",
154
- "704x1280",
155
- "704x1344",
156
- "704x1408",
157
- "704x1472",
158
- "736x1312",
159
- "768x1088",
160
- "768x1216",
161
- "768x1280",
162
- "768x1344",
163
- "800x1280",
164
- "832x960",
165
- "832x1024",
166
- "832x1088",
167
- "832x1152",
168
- "832x1216",
169
- "832x1248",
170
- "864x1152",
171
- "896x960",
172
- "896x1024",
173
- "896x1088",
174
- "896x1120",
175
- "896x1152",
176
- "960x832",
177
- "960x896",
178
- "960x1024",
179
- "960x1088",
180
- "1024x832",
181
- "1024x896",
182
- "1024x960",
183
- "1024x1024",
184
- "1088x768",
185
- "1088x832",
186
- "1088x896",
187
- "1088x960",
188
- "1120x896",
189
- "1152x704",
190
- "1152x832",
191
- "1152x864",
192
- "1152x896",
193
- "1216x704",
194
- "1216x768",
195
- "1216x832",
196
- "1248x832",
197
- "1280x704",
198
- "1280x768",
199
- "1280x800",
200
- "1312x736",
201
- "1344x640",
202
- "1344x704",
203
- "1344x768",
204
- "1408x576",
205
- "1408x640",
206
- "1408x704",
207
- "1472x576",
208
- "1472x640",
209
- "1472x704",
210
- "1536x512",
211
- "1536x576",
212
- "1536x640"
213
- ]
214
-
215
- def download_and_process_images(image_urls):
216
- """Helper function to download and process multiple images from URLs"""
217
-
218
- # Initialize list to store image tensors
219
- image_tensors = []
220
-
221
- for image_url in image_urls:
222
- # Using functions from apinode_utils.py to handle downloading and processing
223
- image_bytesio = download_url_to_bytesio(image_url) # Download image content to BytesIO
224
- img_tensor = bytesio_to_image_tensor(image_bytesio, mode="RGB") # Convert to torch.Tensor with RGB mode
225
- image_tensors.append(img_tensor)
226
-
227
- # Stack tensors to match (N, width, height, channels)
228
- if image_tensors:
229
- stacked_tensors = torch.cat(image_tensors, dim=0)
230
- else:
231
- raise Exception("No valid images were processed")
232
-
233
- return stacked_tensors
234
-
235
-
236
- def display_image_urls_on_node(image_urls, node_id):
237
- if node_id and image_urls:
238
- if len(image_urls) == 1:
239
- PromptServer.instance.send_progress_text(
240
- f"Generated Image URL:\n{image_urls[0]}", node_id
241
- )
242
- else:
243
- urls_text = "Generated Image URLs:\n" + "\n".join(
244
- f"{i+1}. {url}" for i, url in enumerate(image_urls)
245
- )
246
- PromptServer.instance.send_progress_text(urls_text, node_id)
247
-
248
-
249
- class IdeogramV1(ComfyNodeABC):
250
- """
251
- Generates images using the Ideogram V1 model.
252
- """
253
-
254
- def __init__(self):
255
- pass
256
-
257
- @classmethod
258
- def INPUT_TYPES(cls) -> InputTypeDict:
259
- return {
260
- "required": {
261
- "prompt": (
262
- IO.STRING,
263
- {
264
- "multiline": True,
265
- "default": "",
266
- "tooltip": "Prompt for the image generation",
267
- },
268
- ),
269
- "turbo": (
270
- IO.BOOLEAN,
271
- {
272
- "default": False,
273
- "tooltip": "Whether to use turbo mode (faster generation, potentially lower quality)",
274
- }
275
- ),
276
- },
277
- "optional": {
278
- "aspect_ratio": (
279
- IO.COMBO,
280
- {
281
- "options": list(V1_V2_RATIO_MAP.keys()),
282
- "default": "1:1",
283
- "tooltip": "The aspect ratio for image generation.",
284
- },
285
- ),
286
- "magic_prompt_option": (
287
- IO.COMBO,
288
- {
289
- "options": ["AUTO", "ON", "OFF"],
290
- "default": "AUTO",
291
- "tooltip": "Determine if MagicPrompt should be used in generation",
292
- },
293
- ),
294
- "seed": (
295
- IO.INT,
296
- {
297
- "default": 0,
298
- "min": 0,
299
- "max": 2147483647,
300
- "step": 1,
301
- "control_after_generate": True,
302
- "display": "number",
303
- },
304
- ),
305
- "negative_prompt": (
306
- IO.STRING,
307
- {
308
- "multiline": True,
309
- "default": "",
310
- "tooltip": "Description of what to exclude from the image",
311
- },
312
- ),
313
- "num_images": (
314
- IO.INT,
315
- {"default": 1, "min": 1, "max": 8, "step": 1, "display": "number"},
316
- ),
317
- },
318
- "hidden": {
319
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
320
- "comfy_api_key": "API_KEY_COMFY_ORG",
321
- "unique_id": "UNIQUE_ID",
322
- },
323
- }
324
-
325
- RETURN_TYPES = (IO.IMAGE,)
326
- FUNCTION = "api_call"
327
- CATEGORY = "api node/image/Ideogram"
328
- DESCRIPTION = cleandoc(__doc__ or "")
329
- API_NODE = True
330
-
331
- def api_call(
332
- self,
333
- prompt,
334
- turbo=False,
335
- aspect_ratio="1:1",
336
- magic_prompt_option="AUTO",
337
- seed=0,
338
- negative_prompt="",
339
- num_images=1,
340
- unique_id=None,
341
- **kwargs,
342
- ):
343
- # Determine the model based on turbo setting
344
- aspect_ratio = V1_V2_RATIO_MAP.get(aspect_ratio, None)
345
- model = "V_1_TURBO" if turbo else "V_1"
346
-
347
- operation = SynchronousOperation(
348
- endpoint=ApiEndpoint(
349
- path="/proxy/ideogram/generate",
350
- method=HttpMethod.POST,
351
- request_model=IdeogramGenerateRequest,
352
- response_model=IdeogramGenerateResponse,
353
- ),
354
- request=IdeogramGenerateRequest(
355
- image_request=ImageRequest(
356
- prompt=prompt,
357
- model=model,
358
- num_images=num_images,
359
- seed=seed,
360
- aspect_ratio=aspect_ratio if aspect_ratio != "ASPECT_1_1" else None,
361
- magic_prompt_option=(
362
- magic_prompt_option if magic_prompt_option != "AUTO" else None
363
- ),
364
- negative_prompt=negative_prompt if negative_prompt else None,
365
- )
366
- ),
367
- auth_kwargs=kwargs,
368
- )
369
-
370
- response = operation.execute()
371
-
372
- if not response.data or len(response.data) == 0:
373
- raise Exception("No images were generated in the response")
374
-
375
- image_urls = [image_data.url for image_data in response.data if image_data.url]
376
-
377
- if not image_urls:
378
- raise Exception("No image URLs were generated in the response")
379
-
380
- display_image_urls_on_node(image_urls, unique_id)
381
- return (download_and_process_images(image_urls),)
382
-
383
-
384
- class IdeogramV2(ComfyNodeABC):
385
- """
386
- Generates images using the Ideogram V2 model.
387
- """
388
-
389
- def __init__(self):
390
- pass
391
-
392
- @classmethod
393
- def INPUT_TYPES(cls) -> InputTypeDict:
394
- return {
395
- "required": {
396
- "prompt": (
397
- IO.STRING,
398
- {
399
- "multiline": True,
400
- "default": "",
401
- "tooltip": "Prompt for the image generation",
402
- },
403
- ),
404
- "turbo": (
405
- IO.BOOLEAN,
406
- {
407
- "default": False,
408
- "tooltip": "Whether to use turbo mode (faster generation, potentially lower quality)",
409
- }
410
- ),
411
- },
412
- "optional": {
413
- "aspect_ratio": (
414
- IO.COMBO,
415
- {
416
- "options": list(V1_V2_RATIO_MAP.keys()),
417
- "default": "1:1",
418
- "tooltip": "The aspect ratio for image generation. Ignored if resolution is not set to AUTO.",
419
- },
420
- ),
421
- "resolution": (
422
- IO.COMBO,
423
- {
424
- "options": list(V1_V1_RES_MAP.keys()),
425
- "default": "Auto",
426
- "tooltip": "The resolution for image generation. If not set to AUTO, this overrides the aspect_ratio setting.",
427
- },
428
- ),
429
- "magic_prompt_option": (
430
- IO.COMBO,
431
- {
432
- "options": ["AUTO", "ON", "OFF"],
433
- "default": "AUTO",
434
- "tooltip": "Determine if MagicPrompt should be used in generation",
435
- },
436
- ),
437
- "seed": (
438
- IO.INT,
439
- {
440
- "default": 0,
441
- "min": 0,
442
- "max": 2147483647,
443
- "step": 1,
444
- "control_after_generate": True,
445
- "display": "number",
446
- },
447
- ),
448
- "style_type": (
449
- IO.COMBO,
450
- {
451
- "options": ["AUTO", "GENERAL", "REALISTIC", "DESIGN", "RENDER_3D", "ANIME"],
452
- "default": "NONE",
453
- "tooltip": "Style type for generation (V2 only)",
454
- },
455
- ),
456
- "negative_prompt": (
457
- IO.STRING,
458
- {
459
- "multiline": True,
460
- "default": "",
461
- "tooltip": "Description of what to exclude from the image",
462
- },
463
- ),
464
- "num_images": (
465
- IO.INT,
466
- {"default": 1, "min": 1, "max": 8, "step": 1, "display": "number"},
467
- ),
468
- #"color_palette": (
469
- # IO.STRING,
470
- # {
471
- # "multiline": False,
472
- # "default": "",
473
- # "tooltip": "Color palette preset name or hex colors with weights",
474
- # },
475
- #),
476
- },
477
- "hidden": {
478
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
479
- "comfy_api_key": "API_KEY_COMFY_ORG",
480
- "unique_id": "UNIQUE_ID",
481
- },
482
- }
483
-
484
- RETURN_TYPES = (IO.IMAGE,)
485
- FUNCTION = "api_call"
486
- CATEGORY = "api node/image/Ideogram"
487
- DESCRIPTION = cleandoc(__doc__ or "")
488
- API_NODE = True
489
-
490
- def api_call(
491
- self,
492
- prompt,
493
- turbo=False,
494
- aspect_ratio="1:1",
495
- resolution="Auto",
496
- magic_prompt_option="AUTO",
497
- seed=0,
498
- style_type="NONE",
499
- negative_prompt="",
500
- num_images=1,
501
- color_palette="",
502
- unique_id=None,
503
- **kwargs,
504
- ):
505
- aspect_ratio = V1_V2_RATIO_MAP.get(aspect_ratio, None)
506
- resolution = V1_V1_RES_MAP.get(resolution, None)
507
- # Determine the model based on turbo setting
508
- model = "V_2_TURBO" if turbo else "V_2"
509
-
510
- # Handle resolution vs aspect_ratio logic
511
- # If resolution is not AUTO, it overrides aspect_ratio
512
- final_resolution = None
513
- final_aspect_ratio = None
514
-
515
- if resolution != "AUTO":
516
- final_resolution = resolution
517
- else:
518
- final_aspect_ratio = aspect_ratio if aspect_ratio != "ASPECT_1_1" else None
519
-
520
- operation = SynchronousOperation(
521
- endpoint=ApiEndpoint(
522
- path="/proxy/ideogram/generate",
523
- method=HttpMethod.POST,
524
- request_model=IdeogramGenerateRequest,
525
- response_model=IdeogramGenerateResponse,
526
- ),
527
- request=IdeogramGenerateRequest(
528
- image_request=ImageRequest(
529
- prompt=prompt,
530
- model=model,
531
- num_images=num_images,
532
- seed=seed,
533
- aspect_ratio=final_aspect_ratio,
534
- resolution=final_resolution,
535
- magic_prompt_option=(
536
- magic_prompt_option if magic_prompt_option != "AUTO" else None
537
- ),
538
- style_type=style_type if style_type != "NONE" else None,
539
- negative_prompt=negative_prompt if negative_prompt else None,
540
- color_palette=color_palette if color_palette else None,
541
- )
542
- ),
543
- auth_kwargs=kwargs,
544
- )
545
-
546
- response = operation.execute()
547
-
548
- if not response.data or len(response.data) == 0:
549
- raise Exception("No images were generated in the response")
550
-
551
- image_urls = [image_data.url for image_data in response.data if image_data.url]
552
-
553
- if not image_urls:
554
- raise Exception("No image URLs were generated in the response")
555
-
556
- display_image_urls_on_node(image_urls, unique_id)
557
- return (download_and_process_images(image_urls),)
558
-
559
- class IdeogramV3(ComfyNodeABC):
560
- """
561
- Generates images using the Ideogram V3 model. Supports both regular image generation from text prompts and image editing with mask.
562
- """
563
-
564
- def __init__(self):
565
- pass
566
-
567
- @classmethod
568
- def INPUT_TYPES(cls) -> InputTypeDict:
569
- return {
570
- "required": {
571
- "prompt": (
572
- IO.STRING,
573
- {
574
- "multiline": True,
575
- "default": "",
576
- "tooltip": "Prompt for the image generation or editing",
577
- },
578
- ),
579
- },
580
- "optional": {
581
- "image": (
582
- IO.IMAGE,
583
- {
584
- "default": None,
585
- "tooltip": "Optional reference image for image editing.",
586
- },
587
- ),
588
- "mask": (
589
- IO.MASK,
590
- {
591
- "default": None,
592
- "tooltip": "Optional mask for inpainting (white areas will be replaced)",
593
- },
594
- ),
595
- "aspect_ratio": (
596
- IO.COMBO,
597
- {
598
- "options": list(V3_RATIO_MAP.keys()),
599
- "default": "1:1",
600
- "tooltip": "The aspect ratio for image generation. Ignored if resolution is not set to Auto.",
601
- },
602
- ),
603
- "resolution": (
604
- IO.COMBO,
605
- {
606
- "options": V3_RESOLUTIONS,
607
- "default": "Auto",
608
- "tooltip": "The resolution for image generation. If not set to Auto, this overrides the aspect_ratio setting.",
609
- },
610
- ),
611
- "magic_prompt_option": (
612
- IO.COMBO,
613
- {
614
- "options": ["AUTO", "ON", "OFF"],
615
- "default": "AUTO",
616
- "tooltip": "Determine if MagicPrompt should be used in generation",
617
- },
618
- ),
619
- "seed": (
620
- IO.INT,
621
- {
622
- "default": 0,
623
- "min": 0,
624
- "max": 2147483647,
625
- "step": 1,
626
- "control_after_generate": True,
627
- "display": "number",
628
- },
629
- ),
630
- "num_images": (
631
- IO.INT,
632
- {"default": 1, "min": 1, "max": 8, "step": 1, "display": "number"},
633
- ),
634
- "rendering_speed": (
635
- IO.COMBO,
636
- {
637
- "options": ["BALANCED", "TURBO", "QUALITY"],
638
- "default": "BALANCED",
639
- "tooltip": "Controls the trade-off between generation speed and quality",
640
- },
641
- ),
642
- },
643
- "hidden": {
644
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
645
- "comfy_api_key": "API_KEY_COMFY_ORG",
646
- "unique_id": "UNIQUE_ID",
647
- },
648
- }
649
-
650
- RETURN_TYPES = (IO.IMAGE,)
651
- FUNCTION = "api_call"
652
- CATEGORY = "api node/image/Ideogram"
653
- DESCRIPTION = cleandoc(__doc__ or "")
654
- API_NODE = True
655
-
656
- def api_call(
657
- self,
658
- prompt,
659
- image=None,
660
- mask=None,
661
- resolution="Auto",
662
- aspect_ratio="1:1",
663
- magic_prompt_option="AUTO",
664
- seed=0,
665
- num_images=1,
666
- rendering_speed="BALANCED",
667
- unique_id=None,
668
- **kwargs,
669
- ):
670
- # Check if both image and mask are provided for editing mode
671
- if image is not None and mask is not None:
672
- # Edit mode
673
- path = "/proxy/ideogram/ideogram-v3/edit"
674
-
675
- # Process image and mask
676
- input_tensor = image.squeeze().cpu()
677
- # Resize mask to match image dimension
678
- mask = resize_mask_to_image(mask, image, allow_gradient=False)
679
- # Invert mask, as Ideogram API will edit black areas instead of white areas (opposite of convention).
680
- mask = 1.0 - mask
681
-
682
- # Validate mask dimensions match image
683
- if mask.shape[1:] != image.shape[1:-1]:
684
- raise Exception("Mask and Image must be the same size")
685
-
686
- # Process image
687
- img_np = (input_tensor.numpy() * 255).astype(np.uint8)
688
- img = Image.fromarray(img_np)
689
- img_byte_arr = io.BytesIO()
690
- img.save(img_byte_arr, format="PNG")
691
- img_byte_arr.seek(0)
692
- img_binary = img_byte_arr
693
- img_binary.name = "image.png"
694
-
695
- # Process mask - white areas will be replaced
696
- mask_np = (mask.squeeze().cpu().numpy() * 255).astype(np.uint8)
697
- mask_img = Image.fromarray(mask_np)
698
- mask_byte_arr = io.BytesIO()
699
- mask_img.save(mask_byte_arr, format="PNG")
700
- mask_byte_arr.seek(0)
701
- mask_binary = mask_byte_arr
702
- mask_binary.name = "mask.png"
703
-
704
- # Create edit request
705
- edit_request = IdeogramV3EditRequest(
706
- prompt=prompt,
707
- rendering_speed=rendering_speed,
708
- )
709
-
710
- # Add optional parameters
711
- if magic_prompt_option != "AUTO":
712
- edit_request.magic_prompt = magic_prompt_option
713
- if seed != 0:
714
- edit_request.seed = seed
715
- if num_images > 1:
716
- edit_request.num_images = num_images
717
-
718
- # Execute the operation for edit mode
719
- operation = SynchronousOperation(
720
- endpoint=ApiEndpoint(
721
- path=path,
722
- method=HttpMethod.POST,
723
- request_model=IdeogramV3EditRequest,
724
- response_model=IdeogramGenerateResponse,
725
- ),
726
- request=edit_request,
727
- files={
728
- "image": img_binary,
729
- "mask": mask_binary,
730
- },
731
- content_type="multipart/form-data",
732
- auth_kwargs=kwargs,
733
- )
734
-
735
- elif image is not None or mask is not None:
736
- # If only one of image or mask is provided, raise an error
737
- raise Exception("Ideogram V3 image editing requires both an image AND a mask")
738
- else:
739
- # Generation mode
740
- path = "/proxy/ideogram/ideogram-v3/generate"
741
-
742
- # Create generation request
743
- gen_request = IdeogramV3Request(
744
- prompt=prompt,
745
- rendering_speed=rendering_speed,
746
- )
747
-
748
- # Handle resolution vs aspect ratio
749
- if resolution != "Auto":
750
- gen_request.resolution = resolution
751
- elif aspect_ratio != "1:1":
752
- v3_aspect = V3_RATIO_MAP.get(aspect_ratio)
753
- if v3_aspect:
754
- gen_request.aspect_ratio = v3_aspect
755
-
756
- # Add optional parameters
757
- if magic_prompt_option != "AUTO":
758
- gen_request.magic_prompt = magic_prompt_option
759
- if seed != 0:
760
- gen_request.seed = seed
761
- if num_images > 1:
762
- gen_request.num_images = num_images
763
-
764
- # Execute the operation for generation mode
765
- operation = SynchronousOperation(
766
- endpoint=ApiEndpoint(
767
- path=path,
768
- method=HttpMethod.POST,
769
- request_model=IdeogramV3Request,
770
- response_model=IdeogramGenerateResponse,
771
- ),
772
- request=gen_request,
773
- auth_kwargs=kwargs,
774
- )
775
-
776
- # Execute the operation and process response
777
- response = operation.execute()
778
-
779
- if not response.data or len(response.data) == 0:
780
- raise Exception("No images were generated in the response")
781
-
782
- image_urls = [image_data.url for image_data in response.data if image_data.url]
783
-
784
- if not image_urls:
785
- raise Exception("No image URLs were generated in the response")
786
-
787
- display_image_urls_on_node(image_urls, unique_id)
788
- return (download_and_process_images(image_urls),)
789
-
790
-
791
- NODE_CLASS_MAPPINGS = {
792
- "IdeogramV1": IdeogramV1,
793
- "IdeogramV2": IdeogramV2,
794
- "IdeogramV3": IdeogramV3,
795
- }
796
-
797
- NODE_DISPLAY_NAME_MAPPINGS = {
798
- "IdeogramV1": "Ideogram V1",
799
- "IdeogramV2": "Ideogram V2",
800
- "IdeogramV3": "Ideogram V3",
801
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/nodes_kling.py DELETED
@@ -1,1760 +0,0 @@
1
- """Kling API Nodes
2
-
3
- For source of truth on the allowed permutations of request fields, please reference:
4
- - [Compatibility Table](https://app.klingai.com/global/dev/document-api/apiReference/model/skillsMap)
5
- """
6
-
7
- from __future__ import annotations
8
- from typing import Optional, TypeVar, Any
9
- from collections.abc import Callable
10
- import math
11
- import logging
12
-
13
- import torch
14
-
15
- from comfy_api_nodes.apis import (
16
- KlingTaskStatus,
17
- KlingCameraControl,
18
- KlingCameraConfig,
19
- KlingCameraControlType,
20
- KlingVideoGenDuration,
21
- KlingVideoGenMode,
22
- KlingVideoGenAspectRatio,
23
- KlingVideoGenModelName,
24
- KlingText2VideoRequest,
25
- KlingText2VideoResponse,
26
- KlingImage2VideoRequest,
27
- KlingImage2VideoResponse,
28
- KlingVideoExtendRequest,
29
- KlingVideoExtendResponse,
30
- KlingLipSyncVoiceLanguage,
31
- KlingLipSyncInputObject,
32
- KlingLipSyncRequest,
33
- KlingLipSyncResponse,
34
- KlingVirtualTryOnModelName,
35
- KlingVirtualTryOnRequest,
36
- KlingVirtualTryOnResponse,
37
- KlingVideoResult,
38
- KlingImageResult,
39
- KlingImageGenerationsRequest,
40
- KlingImageGenerationsResponse,
41
- KlingImageGenImageReferenceType,
42
- KlingImageGenModelName,
43
- KlingImageGenAspectRatio,
44
- KlingVideoEffectsRequest,
45
- KlingVideoEffectsResponse,
46
- KlingDualCharacterEffectsScene,
47
- KlingSingleImageEffectsScene,
48
- KlingDualCharacterEffectInput,
49
- KlingSingleImageEffectInput,
50
- KlingCharacterEffectModelName,
51
- KlingSingleImageEffectModelName,
52
- )
53
- from comfy_api_nodes.apis.client import (
54
- ApiEndpoint,
55
- HttpMethod,
56
- SynchronousOperation,
57
- PollingOperation,
58
- EmptyRequest,
59
- )
60
- from comfy_api_nodes.apinode_utils import (
61
- tensor_to_base64_string,
62
- download_url_to_video_output,
63
- upload_video_to_comfyapi,
64
- upload_audio_to_comfyapi,
65
- download_url_to_image_tensor,
66
- )
67
- from comfy_api_nodes.mapper_utils import model_field_to_node_input
68
- from comfy_api_nodes.util.validation_utils import (
69
- validate_image_dimensions,
70
- validate_image_aspect_ratio,
71
- validate_video_dimensions,
72
- validate_video_duration,
73
- )
74
- from comfy_api.input.basic_types import AudioInput
75
- from comfy_api.input.video_types import VideoInput
76
- from comfy_api.input_impl import VideoFromFile
77
- from comfy.comfy_types.node_typing import IO, InputTypeOptions, ComfyNodeABC
78
-
79
- KLING_API_VERSION = "v1"
80
- PATH_TEXT_TO_VIDEO = f"/proxy/kling/{KLING_API_VERSION}/videos/text2video"
81
- PATH_IMAGE_TO_VIDEO = f"/proxy/kling/{KLING_API_VERSION}/videos/image2video"
82
- PATH_VIDEO_EXTEND = f"/proxy/kling/{KLING_API_VERSION}/videos/video-extend"
83
- PATH_LIP_SYNC = f"/proxy/kling/{KLING_API_VERSION}/videos/lip-sync"
84
- PATH_VIDEO_EFFECTS = f"/proxy/kling/{KLING_API_VERSION}/videos/effects"
85
- PATH_CHARACTER_IMAGE = f"/proxy/kling/{KLING_API_VERSION}/images/generations"
86
- PATH_VIRTUAL_TRY_ON = f"/proxy/kling/{KLING_API_VERSION}/images/kolors-virtual-try-on"
87
- PATH_IMAGE_GENERATIONS = f"/proxy/kling/{KLING_API_VERSION}/images/generations"
88
-
89
- MAX_PROMPT_LENGTH_T2V = 2500
90
- MAX_PROMPT_LENGTH_I2V = 500
91
- MAX_PROMPT_LENGTH_IMAGE_GEN = 500
92
- MAX_NEGATIVE_PROMPT_LENGTH_IMAGE_GEN = 200
93
- MAX_PROMPT_LENGTH_LIP_SYNC = 120
94
-
95
- AVERAGE_DURATION_T2V = 319
96
- AVERAGE_DURATION_I2V = 164
97
- AVERAGE_DURATION_LIP_SYNC = 455
98
- AVERAGE_DURATION_VIRTUAL_TRY_ON = 19
99
- AVERAGE_DURATION_IMAGE_GEN = 32
100
- AVERAGE_DURATION_VIDEO_EFFECTS = 320
101
- AVERAGE_DURATION_VIDEO_EXTEND = 320
102
-
103
- R = TypeVar("R")
104
-
105
-
106
- class KlingApiError(Exception):
107
- """Base exception for Kling API errors."""
108
-
109
- pass
110
-
111
-
112
- def poll_until_finished(
113
- auth_kwargs: dict[str, str],
114
- api_endpoint: ApiEndpoint[Any, R],
115
- result_url_extractor: Optional[Callable[[R], str]] = None,
116
- estimated_duration: Optional[int] = None,
117
- node_id: Optional[str] = None,
118
- ) -> R:
119
- """Polls the Kling API endpoint until the task reaches a terminal state, then returns the response."""
120
- return PollingOperation(
121
- poll_endpoint=api_endpoint,
122
- completed_statuses=[
123
- KlingTaskStatus.succeed.value,
124
- ],
125
- failed_statuses=[KlingTaskStatus.failed.value],
126
- status_extractor=lambda response: (
127
- response.data.task_status.value
128
- if response.data and response.data.task_status
129
- else None
130
- ),
131
- auth_kwargs=auth_kwargs,
132
- result_url_extractor=result_url_extractor,
133
- estimated_duration=estimated_duration,
134
- node_id=node_id,
135
- poll_interval=16.0,
136
- max_poll_attempts=256,
137
- ).execute()
138
-
139
-
140
- def is_valid_camera_control_configs(configs: list[float]) -> bool:
141
- """Verifies that at least one camera control configuration is non-zero."""
142
- return any(not math.isclose(value, 0.0) for value in configs)
143
-
144
-
145
- def is_valid_prompt(prompt: str) -> bool:
146
- """Verifies that the prompt is not empty."""
147
- return bool(prompt)
148
-
149
-
150
- def is_valid_task_creation_response(response: KlingText2VideoResponse) -> bool:
151
- """Verifies that the initial response contains a task ID."""
152
- return bool(response.data.task_id)
153
-
154
-
155
- def is_valid_video_response(response: KlingText2VideoResponse) -> bool:
156
- """Verifies that the response contains a task result with at least one video."""
157
- return (
158
- response.data is not None
159
- and response.data.task_result is not None
160
- and response.data.task_result.videos is not None
161
- and len(response.data.task_result.videos) > 0
162
- )
163
-
164
-
165
- def is_valid_image_response(response: KlingVirtualTryOnResponse) -> bool:
166
- """Verifies that the response contains a task result with at least one image."""
167
- return (
168
- response.data is not None
169
- and response.data.task_result is not None
170
- and response.data.task_result.images is not None
171
- and len(response.data.task_result.images) > 0
172
- )
173
-
174
-
175
- def validate_prompts(prompt: str, negative_prompt: str, max_length: int) -> bool:
176
- """Verifies that the positive prompt is not empty and that neither promt is too long."""
177
- if not prompt:
178
- raise ValueError("Positive prompt is empty")
179
- if len(prompt) > max_length:
180
- raise ValueError(f"Positive prompt is too long: {len(prompt)} characters")
181
- if negative_prompt and len(negative_prompt) > max_length:
182
- raise ValueError(
183
- f"Negative prompt is too long: {len(negative_prompt)} characters"
184
- )
185
- return True
186
-
187
-
188
- def validate_task_creation_response(response) -> None:
189
- """Validates that the Kling task creation request was successful."""
190
- if not is_valid_task_creation_response(response):
191
- error_msg = f"Kling initial request failed. Code: {response.code}, Message: {response.message}, Data: {response.data}"
192
- logging.error(error_msg)
193
- raise KlingApiError(error_msg)
194
-
195
-
196
- def validate_video_result_response(response) -> None:
197
- """Validates that the Kling task result contains a video."""
198
- if not is_valid_video_response(response):
199
- error_msg = f"Kling task {response.data.task_id} succeeded but no video data found in response."
200
- logging.error(f"Error: {error_msg}.\nResponse: {response}")
201
- raise KlingApiError(error_msg)
202
-
203
-
204
- def validate_image_result_response(response) -> None:
205
- """Validates that the Kling task result contains an image."""
206
- if not is_valid_image_response(response):
207
- error_msg = f"Kling task {response.data.task_id} succeeded but no image data found in response."
208
- logging.error(f"Error: {error_msg}.\nResponse: {response}")
209
- raise KlingApiError(error_msg)
210
-
211
-
212
- def validate_input_image(image: torch.Tensor) -> None:
213
- """
214
- Validates the input image adheres to the expectations of the Kling API:
215
- - The image resolution should not be less than 300*300px
216
- - The aspect ratio of the image should be between 1:2.5 ~ 2.5:1
217
-
218
- See: https://app.klingai.com/global/dev/document-api/apiReference/model/imageToVideo
219
- """
220
- validate_image_dimensions(image, min_width=300, min_height=300)
221
- validate_image_aspect_ratio(image, min_aspect_ratio=1 / 2.5, max_aspect_ratio=2.5)
222
-
223
-
224
- def get_camera_control_input_config(
225
- tooltip: str, default: float = 0.0
226
- ) -> tuple[IO, InputTypeOptions]:
227
- """Returns common InputTypeOptions for Kling camera control configurations."""
228
- input_config = {
229
- "default": default,
230
- "min": -10.0,
231
- "max": 10.0,
232
- "step": 0.25,
233
- "display": "slider",
234
- "tooltip": tooltip,
235
- }
236
- return IO.FLOAT, input_config
237
-
238
-
239
- def get_video_from_response(response) -> KlingVideoResult:
240
- """Returns the first video object from the Kling video generation task result.
241
- Will raise an error if the response is not valid.
242
- """
243
- video = response.data.task_result.videos[0]
244
- logging.info(
245
- "Kling task %s succeeded. Video URL: %s", response.data.task_id, video.url
246
- )
247
- return video
248
-
249
-
250
- def get_video_url_from_response(response) -> Optional[str]:
251
- """Returns the first video url from the Kling video generation task result.
252
- Will not raise an error if the response is not valid.
253
- """
254
- if response and is_valid_video_response(response):
255
- return str(get_video_from_response(response).url)
256
- else:
257
- return None
258
-
259
-
260
- def get_images_from_response(response) -> list[KlingImageResult]:
261
- """Returns the list of image objects from the Kling image generation task result.
262
- Will raise an error if the response is not valid.
263
- """
264
- images = response.data.task_result.images
265
- logging.info("Kling task %s succeeded. Images: %s", response.data.task_id, images)
266
- return images
267
-
268
-
269
- def get_images_urls_from_response(response) -> Optional[str]:
270
- """Returns the list of image urls from the Kling image generation task result.
271
- Will not raise an error if the response is not valid. If there is only one image, returns the url as a string. If there are multiple images, returns a list of urls.
272
- """
273
- if response and is_valid_image_response(response):
274
- images = get_images_from_response(response)
275
- image_urls = [str(image.url) for image in images]
276
- return "\n".join(image_urls)
277
- else:
278
- return None
279
-
280
-
281
- def video_result_to_node_output(
282
- video: KlingVideoResult,
283
- ) -> tuple[VideoFromFile, str, str]:
284
- """Converts a KlingVideoResult to a tuple of (VideoFromFile, str, str) to be used as a ComfyUI node output."""
285
- return (
286
- download_url_to_video_output(video.url),
287
- str(video.id),
288
- str(video.duration),
289
- )
290
-
291
-
292
- def image_result_to_node_output(
293
- images: list[KlingImageResult],
294
- ) -> torch.Tensor:
295
- """
296
- Converts a KlingImageResult to a tuple containing a [B, H, W, C] tensor.
297
- If multiple images are returned, they will be stacked along the batch dimension.
298
- """
299
- if len(images) == 1:
300
- return download_url_to_image_tensor(images[0].url)
301
- else:
302
- return torch.cat([download_url_to_image_tensor(image.url) for image in images])
303
-
304
-
305
- class KlingNodeBase(ComfyNodeABC):
306
- """Base class for Kling nodes."""
307
-
308
- FUNCTION = "api_call"
309
- CATEGORY = "api node/video/Kling"
310
- API_NODE = True
311
-
312
-
313
- class KlingCameraControls(KlingNodeBase):
314
- """Kling Camera Controls Node"""
315
-
316
- @classmethod
317
- def INPUT_TYPES(cls):
318
- return {
319
- "required": {
320
- "camera_control_type": model_field_to_node_input(
321
- IO.COMBO,
322
- KlingCameraControl,
323
- "type",
324
- enum_type=KlingCameraControlType,
325
- ),
326
- "horizontal_movement": get_camera_control_input_config(
327
- "Controls camera's movement along horizontal axis (x-axis). Negative indicates left, positive indicates right"
328
- ),
329
- "vertical_movement": get_camera_control_input_config(
330
- "Controls camera's movement along vertical axis (y-axis). Negative indicates downward, positive indicates upward."
331
- ),
332
- "pan": get_camera_control_input_config(
333
- "Controls camera's rotation in vertical plane (x-axis). Negative indicates downward rotation, positive indicates upward rotation.",
334
- default=0.5,
335
- ),
336
- "tilt": get_camera_control_input_config(
337
- "Controls camera's rotation in horizontal plane (y-axis). Negative indicates left rotation, positive indicates right rotation.",
338
- ),
339
- "roll": get_camera_control_input_config(
340
- "Controls camera's rolling amount (z-axis). Negative indicates counterclockwise, positive indicates clockwise.",
341
- ),
342
- "zoom": get_camera_control_input_config(
343
- "Controls change in camera's focal length. Negative indicates narrower field of view, positive indicates wider field of view.",
344
- ),
345
- }
346
- }
347
-
348
- DESCRIPTION = "Allows specifying configuration options for Kling Camera Controls and motion control effects."
349
- RETURN_TYPES = ("CAMERA_CONTROL",)
350
- RETURN_NAMES = ("camera_control",)
351
- FUNCTION = "main"
352
- API_NODE = False # This is just a helper node, it doesn't make an API call
353
-
354
- @classmethod
355
- def VALIDATE_INPUTS(
356
- cls,
357
- horizontal_movement: float,
358
- vertical_movement: float,
359
- pan: float,
360
- tilt: float,
361
- roll: float,
362
- zoom: float,
363
- ) -> bool | str:
364
- if not is_valid_camera_control_configs(
365
- [
366
- horizontal_movement,
367
- vertical_movement,
368
- pan,
369
- tilt,
370
- roll,
371
- zoom,
372
- ]
373
- ):
374
- return "Invalid camera control configs: at least one of the values must be non-zero"
375
- return True
376
-
377
- def main(
378
- self,
379
- camera_control_type: str,
380
- horizontal_movement: float,
381
- vertical_movement: float,
382
- pan: float,
383
- tilt: float,
384
- roll: float,
385
- zoom: float,
386
- ) -> tuple[KlingCameraControl]:
387
- return (
388
- KlingCameraControl(
389
- type=KlingCameraControlType(camera_control_type),
390
- config=KlingCameraConfig(
391
- horizontal=horizontal_movement,
392
- vertical=vertical_movement,
393
- pan=pan,
394
- roll=roll,
395
- tilt=tilt,
396
- zoom=zoom,
397
- ),
398
- ),
399
- )
400
-
401
-
402
- class KlingTextToVideoNode(KlingNodeBase):
403
- """Kling Text to Video Node"""
404
-
405
- @staticmethod
406
- def get_mode_string_mapping() -> dict[str, tuple[str, str, str]]:
407
- """
408
- Returns a mapping of mode strings to their corresponding (mode, duration, model_name) tuples.
409
- Only includes config combos that support the `image_tail` request field.
410
-
411
- See: [Kling API Docs Capability Map](https://app.klingai.com/global/dev/document-api/apiReference/model/skillsMap)
412
- """
413
- return {
414
- "standard mode / 5s duration / kling-v1": ("std", "5", "kling-v1"),
415
- "standard mode / 10s duration / kling-v1": ("std", "10", "kling-v1"),
416
- "pro mode / 5s duration / kling-v1": ("pro", "5", "kling-v1"),
417
- "pro mode / 10s duration / kling-v1": ("pro", "10", "kling-v1"),
418
- "standard mode / 5s duration / kling-v1-6": ("std", "5", "kling-v1-6"),
419
- "standard mode / 10s duration / kling-v1-6": ("std", "10", "kling-v1-6"),
420
- "pro mode / 5s duration / kling-v2-master": ("pro", "5", "kling-v2-master"),
421
- "pro mode / 10s duration / kling-v2-master": ("pro", "10", "kling-v2-master"),
422
- "standard mode / 5s duration / kling-v2-master": ("std", "5", "kling-v2-master"),
423
- "standard mode / 10s duration / kling-v2-master": ("std", "10", "kling-v2-master"),
424
- }
425
-
426
- @classmethod
427
- def INPUT_TYPES(s):
428
- modes = list(KlingTextToVideoNode.get_mode_string_mapping().keys())
429
- return {
430
- "required": {
431
- "prompt": model_field_to_node_input(
432
- IO.STRING, KlingText2VideoRequest, "prompt", multiline=True
433
- ),
434
- "negative_prompt": model_field_to_node_input(
435
- IO.STRING, KlingText2VideoRequest, "negative_prompt", multiline=True
436
- ),
437
- "cfg_scale": model_field_to_node_input(
438
- IO.FLOAT,
439
- KlingText2VideoRequest,
440
- "cfg_scale",
441
- default=1.0,
442
- min=0.0,
443
- max=1.0,
444
- ),
445
- "aspect_ratio": model_field_to_node_input(
446
- IO.COMBO,
447
- KlingText2VideoRequest,
448
- "aspect_ratio",
449
- enum_type=KlingVideoGenAspectRatio,
450
- ),
451
- "mode": (
452
- modes,
453
- {
454
- "default": modes[4],
455
- "tooltip": "The configuration to use for the video generation following the format: mode / duration / model_name.",
456
- },
457
- ),
458
- },
459
- "hidden": {
460
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
461
- "comfy_api_key": "API_KEY_COMFY_ORG",
462
- "unique_id": "UNIQUE_ID",
463
- },
464
- }
465
-
466
- RETURN_TYPES = ("VIDEO", "STRING", "STRING")
467
- RETURN_NAMES = ("VIDEO", "video_id", "duration")
468
- DESCRIPTION = "Kling Text to Video Node"
469
-
470
- def get_response(
471
- self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
472
- ) -> KlingText2VideoResponse:
473
- return poll_until_finished(
474
- auth_kwargs,
475
- ApiEndpoint(
476
- path=f"{PATH_TEXT_TO_VIDEO}/{task_id}",
477
- method=HttpMethod.GET,
478
- request_model=EmptyRequest,
479
- response_model=KlingText2VideoResponse,
480
- ),
481
- result_url_extractor=get_video_url_from_response,
482
- estimated_duration=AVERAGE_DURATION_T2V,
483
- node_id=node_id,
484
- )
485
-
486
- def api_call(
487
- self,
488
- prompt: str,
489
- negative_prompt: str,
490
- cfg_scale: float,
491
- mode: str,
492
- aspect_ratio: str,
493
- camera_control: Optional[KlingCameraControl] = None,
494
- model_name: Optional[str] = None,
495
- duration: Optional[str] = None,
496
- unique_id: Optional[str] = None,
497
- **kwargs,
498
- ) -> tuple[VideoFromFile, str, str]:
499
- validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_T2V)
500
- if model_name is None:
501
- mode, duration, model_name = self.get_mode_string_mapping()[mode]
502
- initial_operation = SynchronousOperation(
503
- endpoint=ApiEndpoint(
504
- path=PATH_TEXT_TO_VIDEO,
505
- method=HttpMethod.POST,
506
- request_model=KlingText2VideoRequest,
507
- response_model=KlingText2VideoResponse,
508
- ),
509
- request=KlingText2VideoRequest(
510
- prompt=prompt if prompt else None,
511
- negative_prompt=negative_prompt if negative_prompt else None,
512
- duration=KlingVideoGenDuration(duration),
513
- mode=KlingVideoGenMode(mode),
514
- model_name=KlingVideoGenModelName(model_name),
515
- cfg_scale=cfg_scale,
516
- aspect_ratio=KlingVideoGenAspectRatio(aspect_ratio),
517
- camera_control=camera_control,
518
- ),
519
- auth_kwargs=kwargs,
520
- )
521
-
522
- task_creation_response = initial_operation.execute()
523
- validate_task_creation_response(task_creation_response)
524
-
525
- task_id = task_creation_response.data.task_id
526
- final_response = self.get_response(
527
- task_id, auth_kwargs=kwargs, node_id=unique_id
528
- )
529
- validate_video_result_response(final_response)
530
-
531
- video = get_video_from_response(final_response)
532
- return video_result_to_node_output(video)
533
-
534
-
535
- class KlingCameraControlT2VNode(KlingTextToVideoNode):
536
- """
537
- Kling Text to Video Camera Control Node. This node is a text to video node, but it supports controlling the camera.
538
- Duration, mode, and model_name request fields are hard-coded because camera control is only supported in pro mode with the kling-v1-5 model at 5s duration as of 2025-05-02.
539
- """
540
-
541
- @classmethod
542
- def INPUT_TYPES(s):
543
- return {
544
- "required": {
545
- "prompt": model_field_to_node_input(
546
- IO.STRING, KlingText2VideoRequest, "prompt", multiline=True
547
- ),
548
- "negative_prompt": model_field_to_node_input(
549
- IO.STRING,
550
- KlingText2VideoRequest,
551
- "negative_prompt",
552
- multiline=True,
553
- ),
554
- "cfg_scale": model_field_to_node_input(
555
- IO.FLOAT,
556
- KlingText2VideoRequest,
557
- "cfg_scale",
558
- default=0.75,
559
- min=0.0,
560
- max=1.0,
561
- ),
562
- "aspect_ratio": model_field_to_node_input(
563
- IO.COMBO,
564
- KlingText2VideoRequest,
565
- "aspect_ratio",
566
- enum_type=KlingVideoGenAspectRatio,
567
- ),
568
- "camera_control": (
569
- "CAMERA_CONTROL",
570
- {
571
- "tooltip": "Can be created using the Kling Camera Controls node. Controls the camera movement and motion during the video generation.",
572
- },
573
- ),
574
- },
575
- "hidden": {
576
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
577
- "comfy_api_key": "API_KEY_COMFY_ORG",
578
- "unique_id": "UNIQUE_ID",
579
- },
580
- }
581
-
582
- DESCRIPTION = "Transform text into cinematic videos with professional camera movements that simulate real-world cinematography. Control virtual camera actions including zoom, rotation, pan, tilt, and first-person view, while maintaining focus on your original text."
583
-
584
- def api_call(
585
- self,
586
- prompt: str,
587
- negative_prompt: str,
588
- cfg_scale: float,
589
- aspect_ratio: str,
590
- camera_control: Optional[KlingCameraControl] = None,
591
- unique_id: Optional[str] = None,
592
- **kwargs,
593
- ):
594
- return super().api_call(
595
- model_name=KlingVideoGenModelName.kling_v1,
596
- cfg_scale=cfg_scale,
597
- mode=KlingVideoGenMode.std,
598
- aspect_ratio=KlingVideoGenAspectRatio(aspect_ratio),
599
- duration=KlingVideoGenDuration.field_5,
600
- prompt=prompt,
601
- negative_prompt=negative_prompt,
602
- camera_control=camera_control,
603
- **kwargs,
604
- )
605
-
606
-
607
- class KlingImage2VideoNode(KlingNodeBase):
608
- """Kling Image to Video Node"""
609
-
610
- @classmethod
611
- def INPUT_TYPES(s):
612
- return {
613
- "required": {
614
- "start_frame": model_field_to_node_input(
615
- IO.IMAGE,
616
- KlingImage2VideoRequest,
617
- "image",
618
- tooltip="The reference image used to generate the video.",
619
- ),
620
- "prompt": model_field_to_node_input(
621
- IO.STRING, KlingImage2VideoRequest, "prompt", multiline=True
622
- ),
623
- "negative_prompt": model_field_to_node_input(
624
- IO.STRING,
625
- KlingImage2VideoRequest,
626
- "negative_prompt",
627
- multiline=True,
628
- ),
629
- "model_name": model_field_to_node_input(
630
- IO.COMBO,
631
- KlingImage2VideoRequest,
632
- "model_name",
633
- enum_type=KlingVideoGenModelName,
634
- ),
635
- "cfg_scale": model_field_to_node_input(
636
- IO.FLOAT,
637
- KlingImage2VideoRequest,
638
- "cfg_scale",
639
- default=0.8,
640
- min=0.0,
641
- max=1.0,
642
- ),
643
- "mode": model_field_to_node_input(
644
- IO.COMBO,
645
- KlingImage2VideoRequest,
646
- "mode",
647
- enum_type=KlingVideoGenMode,
648
- ),
649
- "aspect_ratio": model_field_to_node_input(
650
- IO.COMBO,
651
- KlingImage2VideoRequest,
652
- "aspect_ratio",
653
- enum_type=KlingVideoGenAspectRatio,
654
- ),
655
- "duration": model_field_to_node_input(
656
- IO.COMBO,
657
- KlingImage2VideoRequest,
658
- "duration",
659
- enum_type=KlingVideoGenDuration,
660
- ),
661
- },
662
- "hidden": {
663
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
664
- "comfy_api_key": "API_KEY_COMFY_ORG",
665
- "unique_id": "UNIQUE_ID",
666
- },
667
- }
668
-
669
- RETURN_TYPES = ("VIDEO", "STRING", "STRING")
670
- RETURN_NAMES = ("VIDEO", "video_id", "duration")
671
- DESCRIPTION = "Kling Image to Video Node"
672
-
673
- def get_response(
674
- self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
675
- ) -> KlingImage2VideoResponse:
676
- return poll_until_finished(
677
- auth_kwargs,
678
- ApiEndpoint(
679
- path=f"{PATH_IMAGE_TO_VIDEO}/{task_id}",
680
- method=HttpMethod.GET,
681
- request_model=KlingImage2VideoRequest,
682
- response_model=KlingImage2VideoResponse,
683
- ),
684
- result_url_extractor=get_video_url_from_response,
685
- estimated_duration=AVERAGE_DURATION_I2V,
686
- node_id=node_id,
687
- )
688
-
689
- def api_call(
690
- self,
691
- start_frame: torch.Tensor,
692
- prompt: str,
693
- negative_prompt: str,
694
- model_name: str,
695
- cfg_scale: float,
696
- mode: str,
697
- aspect_ratio: str,
698
- duration: str,
699
- camera_control: Optional[KlingCameraControl] = None,
700
- end_frame: Optional[torch.Tensor] = None,
701
- unique_id: Optional[str] = None,
702
- **kwargs,
703
- ) -> tuple[VideoFromFile]:
704
- validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_I2V)
705
- validate_input_image(start_frame)
706
-
707
- if camera_control is not None:
708
- # Camera control type for image 2 video is always `simple`
709
- camera_control.type = KlingCameraControlType.simple
710
-
711
- initial_operation = SynchronousOperation(
712
- endpoint=ApiEndpoint(
713
- path=PATH_IMAGE_TO_VIDEO,
714
- method=HttpMethod.POST,
715
- request_model=KlingImage2VideoRequest,
716
- response_model=KlingImage2VideoResponse,
717
- ),
718
- request=KlingImage2VideoRequest(
719
- model_name=KlingVideoGenModelName(model_name),
720
- image=tensor_to_base64_string(start_frame),
721
- image_tail=(
722
- tensor_to_base64_string(end_frame)
723
- if end_frame is not None
724
- else None
725
- ),
726
- prompt=prompt,
727
- negative_prompt=negative_prompt if negative_prompt else None,
728
- cfg_scale=cfg_scale,
729
- mode=KlingVideoGenMode(mode),
730
- duration=KlingVideoGenDuration(duration),
731
- camera_control=camera_control,
732
- ),
733
- auth_kwargs=kwargs,
734
- )
735
-
736
- task_creation_response = initial_operation.execute()
737
- validate_task_creation_response(task_creation_response)
738
- task_id = task_creation_response.data.task_id
739
-
740
- final_response = self.get_response(
741
- task_id, auth_kwargs=kwargs, node_id=unique_id
742
- )
743
- validate_video_result_response(final_response)
744
-
745
- video = get_video_from_response(final_response)
746
- return video_result_to_node_output(video)
747
-
748
-
749
- class KlingCameraControlI2VNode(KlingImage2VideoNode):
750
- """
751
- Kling Image to Video Camera Control Node. This node is a image to video node, but it supports controlling the camera.
752
- Duration, mode, and model_name request fields are hard-coded because camera control is only supported in pro mode with the kling-v1-5 model at 5s duration as of 2025-05-02.
753
- """
754
-
755
- @classmethod
756
- def INPUT_TYPES(s):
757
- return {
758
- "required": {
759
- "start_frame": model_field_to_node_input(
760
- IO.IMAGE, KlingImage2VideoRequest, "image"
761
- ),
762
- "prompt": model_field_to_node_input(
763
- IO.STRING, KlingImage2VideoRequest, "prompt", multiline=True
764
- ),
765
- "negative_prompt": model_field_to_node_input(
766
- IO.STRING,
767
- KlingImage2VideoRequest,
768
- "negative_prompt",
769
- multiline=True,
770
- ),
771
- "cfg_scale": model_field_to_node_input(
772
- IO.FLOAT,
773
- KlingImage2VideoRequest,
774
- "cfg_scale",
775
- default=0.75,
776
- min=0.0,
777
- max=1.0,
778
- ),
779
- "aspect_ratio": model_field_to_node_input(
780
- IO.COMBO,
781
- KlingImage2VideoRequest,
782
- "aspect_ratio",
783
- enum_type=KlingVideoGenAspectRatio,
784
- ),
785
- "camera_control": (
786
- "CAMERA_CONTROL",
787
- {
788
- "tooltip": "Can be created using the Kling Camera Controls node. Controls the camera movement and motion during the video generation.",
789
- },
790
- ),
791
- },
792
- "hidden": {
793
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
794
- "comfy_api_key": "API_KEY_COMFY_ORG",
795
- "unique_id": "UNIQUE_ID",
796
- },
797
- }
798
-
799
- DESCRIPTION = "Transform still images into cinematic videos with professional camera movements that simulate real-world cinematography. Control virtual camera actions including zoom, rotation, pan, tilt, and first-person view, while maintaining focus on your original image."
800
-
801
- def api_call(
802
- self,
803
- start_frame: torch.Tensor,
804
- prompt: str,
805
- negative_prompt: str,
806
- cfg_scale: float,
807
- aspect_ratio: str,
808
- camera_control: KlingCameraControl,
809
- unique_id: Optional[str] = None,
810
- **kwargs,
811
- ):
812
- return super().api_call(
813
- model_name=KlingVideoGenModelName.kling_v1_5,
814
- start_frame=start_frame,
815
- cfg_scale=cfg_scale,
816
- mode=KlingVideoGenMode.pro,
817
- aspect_ratio=KlingVideoGenAspectRatio(aspect_ratio),
818
- duration=KlingVideoGenDuration.field_5,
819
- prompt=prompt,
820
- negative_prompt=negative_prompt,
821
- camera_control=camera_control,
822
- unique_id=unique_id,
823
- **kwargs,
824
- )
825
-
826
-
827
- class KlingStartEndFrameNode(KlingImage2VideoNode):
828
- """
829
- Kling First Last Frame Node. This node allows creation of a video from a first and last frame. It calls the normal image to video endpoint, but only allows the subset of input options that support the `image_tail` request field.
830
- """
831
-
832
- @staticmethod
833
- def get_mode_string_mapping() -> dict[str, tuple[str, str, str]]:
834
- """
835
- Returns a mapping of mode strings to their corresponding (mode, duration, model_name) tuples.
836
- Only includes config combos that support the `image_tail` request field.
837
-
838
- See: [Kling API Docs Capability Map](https://app.klingai.com/global/dev/document-api/apiReference/model/skillsMap)
839
- """
840
- return {
841
- "standard mode / 5s duration / kling-v1": ("std", "5", "kling-v1"),
842
- "pro mode / 5s duration / kling-v1": ("pro", "5", "kling-v1"),
843
- "pro mode / 5s duration / kling-v1-5": ("pro", "5", "kling-v1-5"),
844
- "pro mode / 10s duration / kling-v1-5": ("pro", "10", "kling-v1-5"),
845
- "pro mode / 5s duration / kling-v1-6": ("pro", "5", "kling-v1-6"),
846
- "pro mode / 10s duration / kling-v1-6": ("pro", "10", "kling-v1-6"),
847
- }
848
-
849
- @classmethod
850
- def INPUT_TYPES(s):
851
- modes = list(KlingStartEndFrameNode.get_mode_string_mapping().keys())
852
- return {
853
- "required": {
854
- "start_frame": model_field_to_node_input(
855
- IO.IMAGE, KlingImage2VideoRequest, "image"
856
- ),
857
- "end_frame": model_field_to_node_input(
858
- IO.IMAGE, KlingImage2VideoRequest, "image_tail"
859
- ),
860
- "prompt": model_field_to_node_input(
861
- IO.STRING, KlingImage2VideoRequest, "prompt", multiline=True
862
- ),
863
- "negative_prompt": model_field_to_node_input(
864
- IO.STRING,
865
- KlingImage2VideoRequest,
866
- "negative_prompt",
867
- multiline=True,
868
- ),
869
- "cfg_scale": model_field_to_node_input(
870
- IO.FLOAT,
871
- KlingImage2VideoRequest,
872
- "cfg_scale",
873
- default=0.5,
874
- min=0.0,
875
- max=1.0,
876
- ),
877
- "aspect_ratio": model_field_to_node_input(
878
- IO.COMBO,
879
- KlingImage2VideoRequest,
880
- "aspect_ratio",
881
- enum_type=KlingVideoGenAspectRatio,
882
- ),
883
- "mode": (
884
- modes,
885
- {
886
- "default": modes[2],
887
- "tooltip": "The configuration to use for the video generation following the format: mode / duration / model_name.",
888
- },
889
- ),
890
- },
891
- "hidden": {
892
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
893
- "comfy_api_key": "API_KEY_COMFY_ORG",
894
- "unique_id": "UNIQUE_ID",
895
- },
896
- }
897
-
898
- DESCRIPTION = "Generate a video sequence that transitions between your provided start and end images. The node creates all frames in between, producing a smooth transformation from the first frame to the last."
899
-
900
- def api_call(
901
- self,
902
- start_frame: torch.Tensor,
903
- end_frame: torch.Tensor,
904
- prompt: str,
905
- negative_prompt: str,
906
- cfg_scale: float,
907
- aspect_ratio: str,
908
- mode: str,
909
- unique_id: Optional[str] = None,
910
- **kwargs,
911
- ):
912
- mode, duration, model_name = KlingStartEndFrameNode.get_mode_string_mapping()[
913
- mode
914
- ]
915
- return super().api_call(
916
- prompt=prompt,
917
- negative_prompt=negative_prompt,
918
- model_name=model_name,
919
- start_frame=start_frame,
920
- cfg_scale=cfg_scale,
921
- mode=mode,
922
- aspect_ratio=aspect_ratio,
923
- duration=duration,
924
- end_frame=end_frame,
925
- unique_id=unique_id,
926
- **kwargs,
927
- )
928
-
929
-
930
- class KlingVideoExtendNode(KlingNodeBase):
931
- @classmethod
932
- def INPUT_TYPES(s):
933
- return {
934
- "required": {
935
- "prompt": model_field_to_node_input(
936
- IO.STRING, KlingVideoExtendRequest, "prompt", multiline=True
937
- ),
938
- "negative_prompt": model_field_to_node_input(
939
- IO.STRING,
940
- KlingVideoExtendRequest,
941
- "negative_prompt",
942
- multiline=True,
943
- ),
944
- "cfg_scale": model_field_to_node_input(
945
- IO.FLOAT,
946
- KlingVideoExtendRequest,
947
- "cfg_scale",
948
- default=0.5,
949
- min=0.0,
950
- max=1.0,
951
- ),
952
- "video_id": model_field_to_node_input(
953
- IO.STRING, KlingVideoExtendRequest, "video_id", forceInput=True
954
- ),
955
- },
956
- "hidden": {
957
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
958
- "comfy_api_key": "API_KEY_COMFY_ORG",
959
- "unique_id": "UNIQUE_ID",
960
- },
961
- }
962
-
963
- RETURN_TYPES = ("VIDEO", "STRING", "STRING")
964
- RETURN_NAMES = ("VIDEO", "video_id", "duration")
965
- DESCRIPTION = "Kling Video Extend Node. Extend videos made by other Kling nodes. The video_id is created by using other Kling Nodes."
966
-
967
- def get_response(
968
- self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
969
- ) -> KlingVideoExtendResponse:
970
- return poll_until_finished(
971
- auth_kwargs,
972
- ApiEndpoint(
973
- path=f"{PATH_VIDEO_EXTEND}/{task_id}",
974
- method=HttpMethod.GET,
975
- request_model=EmptyRequest,
976
- response_model=KlingVideoExtendResponse,
977
- ),
978
- result_url_extractor=get_video_url_from_response,
979
- estimated_duration=AVERAGE_DURATION_VIDEO_EXTEND,
980
- node_id=node_id,
981
- )
982
-
983
- def api_call(
984
- self,
985
- prompt: str,
986
- negative_prompt: str,
987
- cfg_scale: float,
988
- video_id: str,
989
- unique_id: Optional[str] = None,
990
- **kwargs,
991
- ) -> tuple[VideoFromFile, str, str]:
992
- validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_T2V)
993
- initial_operation = SynchronousOperation(
994
- endpoint=ApiEndpoint(
995
- path=PATH_VIDEO_EXTEND,
996
- method=HttpMethod.POST,
997
- request_model=KlingVideoExtendRequest,
998
- response_model=KlingVideoExtendResponse,
999
- ),
1000
- request=KlingVideoExtendRequest(
1001
- prompt=prompt if prompt else None,
1002
- negative_prompt=negative_prompt if negative_prompt else None,
1003
- cfg_scale=cfg_scale,
1004
- video_id=video_id,
1005
- ),
1006
- auth_kwargs=kwargs,
1007
- )
1008
-
1009
- task_creation_response = initial_operation.execute()
1010
- validate_task_creation_response(task_creation_response)
1011
- task_id = task_creation_response.data.task_id
1012
-
1013
- final_response = self.get_response(
1014
- task_id, auth_kwargs=kwargs, node_id=unique_id
1015
- )
1016
- validate_video_result_response(final_response)
1017
-
1018
- video = get_video_from_response(final_response)
1019
- return video_result_to_node_output(video)
1020
-
1021
-
1022
- class KlingVideoEffectsBase(KlingNodeBase):
1023
- """Kling Video Effects Base"""
1024
-
1025
- RETURN_TYPES = ("VIDEO", "STRING", "STRING")
1026
- RETURN_NAMES = ("VIDEO", "video_id", "duration")
1027
-
1028
- def get_response(
1029
- self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
1030
- ) -> KlingVideoEffectsResponse:
1031
- return poll_until_finished(
1032
- auth_kwargs,
1033
- ApiEndpoint(
1034
- path=f"{PATH_VIDEO_EFFECTS}/{task_id}",
1035
- method=HttpMethod.GET,
1036
- request_model=EmptyRequest,
1037
- response_model=KlingVideoEffectsResponse,
1038
- ),
1039
- result_url_extractor=get_video_url_from_response,
1040
- estimated_duration=AVERAGE_DURATION_VIDEO_EFFECTS,
1041
- node_id=node_id,
1042
- )
1043
-
1044
- def api_call(
1045
- self,
1046
- dual_character: bool,
1047
- effect_scene: KlingDualCharacterEffectsScene | KlingSingleImageEffectsScene,
1048
- model_name: str,
1049
- duration: KlingVideoGenDuration,
1050
- image_1: torch.Tensor,
1051
- image_2: Optional[torch.Tensor] = None,
1052
- mode: Optional[KlingVideoGenMode] = None,
1053
- unique_id: Optional[str] = None,
1054
- **kwargs,
1055
- ):
1056
- if dual_character:
1057
- request_input_field = KlingDualCharacterEffectInput(
1058
- model_name=model_name,
1059
- mode=mode,
1060
- images=[
1061
- tensor_to_base64_string(image_1),
1062
- tensor_to_base64_string(image_2),
1063
- ],
1064
- duration=duration,
1065
- )
1066
- else:
1067
- request_input_field = KlingSingleImageEffectInput(
1068
- model_name=model_name,
1069
- image=tensor_to_base64_string(image_1),
1070
- duration=duration,
1071
- )
1072
-
1073
- initial_operation = SynchronousOperation(
1074
- endpoint=ApiEndpoint(
1075
- path=PATH_VIDEO_EFFECTS,
1076
- method=HttpMethod.POST,
1077
- request_model=KlingVideoEffectsRequest,
1078
- response_model=KlingVideoEffectsResponse,
1079
- ),
1080
- request=KlingVideoEffectsRequest(
1081
- effect_scene=effect_scene,
1082
- input=request_input_field,
1083
- ),
1084
- auth_kwargs=kwargs,
1085
- )
1086
-
1087
- task_creation_response = initial_operation.execute()
1088
- validate_task_creation_response(task_creation_response)
1089
- task_id = task_creation_response.data.task_id
1090
-
1091
- final_response = self.get_response(
1092
- task_id, auth_kwargs=kwargs, node_id=unique_id
1093
- )
1094
- validate_video_result_response(final_response)
1095
-
1096
- video = get_video_from_response(final_response)
1097
- return video_result_to_node_output(video)
1098
-
1099
-
1100
- class KlingDualCharacterVideoEffectNode(KlingVideoEffectsBase):
1101
- """Kling Dual Character Video Effect Node"""
1102
-
1103
- @classmethod
1104
- def INPUT_TYPES(s):
1105
- return {
1106
- "required": {
1107
- "image_left": (IO.IMAGE, {"tooltip": "Left side image"}),
1108
- "image_right": (IO.IMAGE, {"tooltip": "Right side image"}),
1109
- "effect_scene": model_field_to_node_input(
1110
- IO.COMBO,
1111
- KlingVideoEffectsRequest,
1112
- "effect_scene",
1113
- enum_type=KlingDualCharacterEffectsScene,
1114
- ),
1115
- "model_name": model_field_to_node_input(
1116
- IO.COMBO,
1117
- KlingDualCharacterEffectInput,
1118
- "model_name",
1119
- enum_type=KlingCharacterEffectModelName,
1120
- ),
1121
- "mode": model_field_to_node_input(
1122
- IO.COMBO,
1123
- KlingDualCharacterEffectInput,
1124
- "mode",
1125
- enum_type=KlingVideoGenMode,
1126
- ),
1127
- "duration": model_field_to_node_input(
1128
- IO.COMBO,
1129
- KlingDualCharacterEffectInput,
1130
- "duration",
1131
- enum_type=KlingVideoGenDuration,
1132
- ),
1133
- },
1134
- "hidden": {
1135
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
1136
- "comfy_api_key": "API_KEY_COMFY_ORG",
1137
- "unique_id": "UNIQUE_ID",
1138
- },
1139
- }
1140
-
1141
- DESCRIPTION = "Achieve different special effects when generating a video based on the effect_scene. First image will be positioned on left side, second on right side of the composite."
1142
- RETURN_TYPES = ("VIDEO", "STRING")
1143
- RETURN_NAMES = ("VIDEO", "duration")
1144
-
1145
- def api_call(
1146
- self,
1147
- image_left: torch.Tensor,
1148
- image_right: torch.Tensor,
1149
- effect_scene: KlingDualCharacterEffectsScene,
1150
- model_name: KlingCharacterEffectModelName,
1151
- mode: KlingVideoGenMode,
1152
- duration: KlingVideoGenDuration,
1153
- unique_id: Optional[str] = None,
1154
- **kwargs,
1155
- ):
1156
- video, _, duration = super().api_call(
1157
- dual_character=True,
1158
- effect_scene=effect_scene,
1159
- model_name=model_name,
1160
- mode=mode,
1161
- duration=duration,
1162
- image_1=image_left,
1163
- image_2=image_right,
1164
- unique_id=unique_id,
1165
- **kwargs,
1166
- )
1167
- return video, duration
1168
-
1169
-
1170
- class KlingSingleImageVideoEffectNode(KlingVideoEffectsBase):
1171
- """Kling Single Image Video Effect Node"""
1172
-
1173
- @classmethod
1174
- def INPUT_TYPES(s):
1175
- return {
1176
- "required": {
1177
- "image": (
1178
- IO.IMAGE,
1179
- {
1180
- "tooltip": " Reference Image. URL or Base64 encoded string (without data:image prefix). File size cannot exceed 10MB, resolution not less than 300*300px, aspect ratio between 1:2.5 ~ 2.5:1"
1181
- },
1182
- ),
1183
- "effect_scene": model_field_to_node_input(
1184
- IO.COMBO,
1185
- KlingVideoEffectsRequest,
1186
- "effect_scene",
1187
- enum_type=KlingSingleImageEffectsScene,
1188
- ),
1189
- "model_name": model_field_to_node_input(
1190
- IO.COMBO,
1191
- KlingSingleImageEffectInput,
1192
- "model_name",
1193
- enum_type=KlingSingleImageEffectModelName,
1194
- ),
1195
- "duration": model_field_to_node_input(
1196
- IO.COMBO,
1197
- KlingSingleImageEffectInput,
1198
- "duration",
1199
- enum_type=KlingVideoGenDuration,
1200
- ),
1201
- },
1202
- "hidden": {
1203
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
1204
- "comfy_api_key": "API_KEY_COMFY_ORG",
1205
- "unique_id": "UNIQUE_ID",
1206
- },
1207
- }
1208
-
1209
- DESCRIPTION = "Achieve different special effects when generating a video based on the effect_scene."
1210
-
1211
- def api_call(
1212
- self,
1213
- image: torch.Tensor,
1214
- effect_scene: KlingSingleImageEffectsScene,
1215
- model_name: KlingSingleImageEffectModelName,
1216
- duration: KlingVideoGenDuration,
1217
- unique_id: Optional[str] = None,
1218
- **kwargs,
1219
- ):
1220
- return super().api_call(
1221
- dual_character=False,
1222
- effect_scene=effect_scene,
1223
- model_name=model_name,
1224
- duration=duration,
1225
- image_1=image,
1226
- unique_id=unique_id,
1227
- **kwargs,
1228
- )
1229
-
1230
-
1231
- class KlingLipSyncBase(KlingNodeBase):
1232
- """Kling Lip Sync Base"""
1233
-
1234
- RETURN_TYPES = ("VIDEO", "STRING", "STRING")
1235
- RETURN_NAMES = ("VIDEO", "video_id", "duration")
1236
-
1237
- def validate_lip_sync_video(self, video: VideoInput):
1238
- """
1239
- Validates the input video adheres to the expectations of the Kling Lip Sync API:
1240
- - Video length does not exceed 10s and is not shorter than 2s
1241
- - Length and width dimensions should both be between 720px and 1920px
1242
-
1243
- See: https://app.klingai.com/global/dev/document-api/apiReference/model/videoTolip
1244
- """
1245
- validate_video_dimensions(video, 720, 1920)
1246
- validate_video_duration(video, 2, 10)
1247
-
1248
- def validate_text(self, text: str):
1249
- if not text:
1250
- raise ValueError("Text is required")
1251
- if len(text) > MAX_PROMPT_LENGTH_LIP_SYNC:
1252
- raise ValueError(
1253
- f"Text is too long. Maximum length is {MAX_PROMPT_LENGTH_LIP_SYNC} characters."
1254
- )
1255
-
1256
- def get_response(
1257
- self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
1258
- ) -> KlingLipSyncResponse:
1259
- """Polls the Kling API endpoint until the task reaches a terminal state."""
1260
- return poll_until_finished(
1261
- auth_kwargs,
1262
- ApiEndpoint(
1263
- path=f"{PATH_LIP_SYNC}/{task_id}",
1264
- method=HttpMethod.GET,
1265
- request_model=EmptyRequest,
1266
- response_model=KlingLipSyncResponse,
1267
- ),
1268
- result_url_extractor=get_video_url_from_response,
1269
- estimated_duration=AVERAGE_DURATION_LIP_SYNC,
1270
- node_id=node_id,
1271
- )
1272
-
1273
- def api_call(
1274
- self,
1275
- video: VideoInput,
1276
- audio: Optional[AudioInput] = None,
1277
- voice_language: Optional[str] = None,
1278
- mode: Optional[str] = None,
1279
- text: Optional[str] = None,
1280
- voice_speed: Optional[float] = None,
1281
- voice_id: Optional[str] = None,
1282
- unique_id: Optional[str] = None,
1283
- **kwargs,
1284
- ) -> tuple[VideoFromFile, str, str]:
1285
- if text:
1286
- self.validate_text(text)
1287
- self.validate_lip_sync_video(video)
1288
-
1289
- # Upload video to Comfy API and get download URL
1290
- video_url = upload_video_to_comfyapi(video, auth_kwargs=kwargs)
1291
- logging.info("Uploaded video to Comfy API. URL: %s", video_url)
1292
-
1293
- # Upload the audio file to Comfy API and get download URL
1294
- if audio:
1295
- audio_url = upload_audio_to_comfyapi(audio, auth_kwargs=kwargs)
1296
- logging.info("Uploaded audio to Comfy API. URL: %s", audio_url)
1297
- else:
1298
- audio_url = None
1299
-
1300
- initial_operation = SynchronousOperation(
1301
- endpoint=ApiEndpoint(
1302
- path=PATH_LIP_SYNC,
1303
- method=HttpMethod.POST,
1304
- request_model=KlingLipSyncRequest,
1305
- response_model=KlingLipSyncResponse,
1306
- ),
1307
- request=KlingLipSyncRequest(
1308
- input=KlingLipSyncInputObject(
1309
- video_url=video_url,
1310
- mode=mode,
1311
- text=text,
1312
- voice_language=voice_language,
1313
- voice_speed=voice_speed,
1314
- audio_type="url",
1315
- audio_url=audio_url,
1316
- voice_id=voice_id,
1317
- ),
1318
- ),
1319
- auth_kwargs=kwargs,
1320
- )
1321
-
1322
- task_creation_response = initial_operation.execute()
1323
- validate_task_creation_response(task_creation_response)
1324
- task_id = task_creation_response.data.task_id
1325
-
1326
- final_response = self.get_response(
1327
- task_id, auth_kwargs=kwargs, node_id=unique_id
1328
- )
1329
- validate_video_result_response(final_response)
1330
-
1331
- video = get_video_from_response(final_response)
1332
- return video_result_to_node_output(video)
1333
-
1334
-
1335
- class KlingLipSyncAudioToVideoNode(KlingLipSyncBase):
1336
- """Kling Lip Sync Audio to Video Node. Syncs mouth movements in a video file to the audio content of an audio file."""
1337
-
1338
- @classmethod
1339
- def INPUT_TYPES(s):
1340
- return {
1341
- "required": {
1342
- "video": (IO.VIDEO, {}),
1343
- "audio": (IO.AUDIO, {}),
1344
- "voice_language": model_field_to_node_input(
1345
- IO.COMBO,
1346
- KlingLipSyncInputObject,
1347
- "voice_language",
1348
- enum_type=KlingLipSyncVoiceLanguage,
1349
- ),
1350
- },
1351
- "hidden": {
1352
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
1353
- "comfy_api_key": "API_KEY_COMFY_ORG",
1354
- "unique_id": "UNIQUE_ID",
1355
- },
1356
- }
1357
-
1358
- DESCRIPTION = "Kling Lip Sync Audio to Video Node. Syncs mouth movements in a video file to the audio content of an audio file. When using, ensure that the audio contains clearly distinguishable vocals and that the video contains a distinct face. The audio file should not be larger than 5MB. The video file should not be larger than 100MB, should have height/width between 720px and 1920px, and should be between 2s and 10s in length."
1359
-
1360
- def api_call(
1361
- self,
1362
- video: VideoInput,
1363
- audio: AudioInput,
1364
- voice_language: str,
1365
- unique_id: Optional[str] = None,
1366
- **kwargs,
1367
- ):
1368
- return super().api_call(
1369
- video=video,
1370
- audio=audio,
1371
- voice_language=voice_language,
1372
- mode="audio2video",
1373
- unique_id=unique_id,
1374
- **kwargs,
1375
- )
1376
-
1377
-
1378
- class KlingLipSyncTextToVideoNode(KlingLipSyncBase):
1379
- """Kling Lip Sync Text to Video Node. Syncs mouth movements in a video file to a text prompt."""
1380
-
1381
- @staticmethod
1382
- def get_voice_config() -> dict[str, tuple[str, str]]:
1383
- return {
1384
- # English voices
1385
- "Melody": ("girlfriend_4_speech02", "en"),
1386
- "Sunny": ("genshin_vindi2", "en"),
1387
- "Sage": ("zhinen_xuesheng", "en"),
1388
- "Ace": ("AOT", "en"),
1389
- "Blossom": ("ai_shatang", "en"),
1390
- "Peppy": ("genshin_klee2", "en"),
1391
- "Dove": ("genshin_kirara", "en"),
1392
- "Shine": ("ai_kaiya", "en"),
1393
- "Anchor": ("oversea_male1", "en"),
1394
- "Lyric": ("ai_chenjiahao_712", "en"),
1395
- "Tender": ("chat1_female_new-3", "en"),
1396
- "Siren": ("chat_0407_5-1", "en"),
1397
- "Zippy": ("cartoon-boy-07", "en"),
1398
- "Bud": ("uk_boy1", "en"),
1399
- "Sprite": ("cartoon-girl-01", "en"),
1400
- "Candy": ("PeppaPig_platform", "en"),
1401
- "Beacon": ("ai_huangzhong_712", "en"),
1402
- "Rock": ("ai_huangyaoshi_712", "en"),
1403
- "Titan": ("ai_laoguowang_712", "en"),
1404
- "Grace": ("chengshu_jiejie", "en"),
1405
- "Helen": ("you_pingjing", "en"),
1406
- "Lore": ("calm_story1", "en"),
1407
- "Crag": ("uk_man2", "en"),
1408
- "Prattle": ("laopopo_speech02", "en"),
1409
- "Hearth": ("heainainai_speech02", "en"),
1410
- "The Reader": ("reader_en_m-v1", "en"),
1411
- "Commercial Lady": ("commercial_lady_en_f-v1", "en"),
1412
- # Chinese voices
1413
- "阳光少年": ("genshin_vindi2", "zh"),
1414
- "懂事小弟": ("zhinen_xuesheng", "zh"),
1415
- "运动少年": ("tiyuxi_xuedi", "zh"),
1416
- "青春少女": ("ai_shatang", "zh"),
1417
- "温柔小妹": ("genshin_klee2", "zh"),
1418
- "元气少女": ("genshin_kirara", "zh"),
1419
- "阳光男生": ("ai_kaiya", "zh"),
1420
- "幽默小哥": ("tiexin_nanyou", "zh"),
1421
- "文艺小哥": ("ai_chenjiahao_712", "zh"),
1422
- "甜美邻家": ("girlfriend_1_speech02", "zh"),
1423
- "温柔姐姐": ("chat1_female_new-3", "zh"),
1424
- "职场女青": ("girlfriend_2_speech02", "zh"),
1425
- "活泼男童": ("cartoon-boy-07", "zh"),
1426
- "俏皮女童": ("cartoon-girl-01", "zh"),
1427
- "稳重老爸": ("ai_huangyaoshi_712", "zh"),
1428
- "温柔妈妈": ("you_pingjing", "zh"),
1429
- "严肃上司": ("ai_laoguowang_712", "zh"),
1430
- "优雅贵妇": ("chengshu_jiejie", "zh"),
1431
- "慈祥爷爷": ("zhuxi_speech02", "zh"),
1432
- "唠叨爷爷": ("uk_oldman3", "zh"),
1433
- "唠叨奶奶": ("laopopo_speech02", "zh"),
1434
- "和蔼奶奶": ("heainainai_speech02", "zh"),
1435
- "东北老铁": ("dongbeilaotie_speech02", "zh"),
1436
- "重庆小伙": ("chongqingxiaohuo_speech02", "zh"),
1437
- "四川妹子": ("chuanmeizi_speech02", "zh"),
1438
- "潮汕大叔": ("chaoshandashu_speech02", "zh"),
1439
- "台湾男生": ("ai_taiwan_man2_speech02", "zh"),
1440
- "西安掌柜": ("xianzhanggui_speech02", "zh"),
1441
- "天津姐姐": ("tianjinjiejie_speech02", "zh"),
1442
- "新闻播报男": ("diyinnansang_DB_CN_M_04-v2", "zh"),
1443
- "译制片男": ("yizhipiannan-v1", "zh"),
1444
- "撒娇女友": ("tianmeixuemei-v1", "zh"),
1445
- "刀片烟嗓": ("daopianyansang-v1", "zh"),
1446
- "乖巧正太": ("mengwa-v1", "zh"),
1447
- }
1448
-
1449
- @classmethod
1450
- def INPUT_TYPES(s):
1451
- voice_options = list(s.get_voice_config().keys())
1452
- return {
1453
- "required": {
1454
- "video": (IO.VIDEO, {}),
1455
- "text": model_field_to_node_input(
1456
- IO.STRING, KlingLipSyncInputObject, "text", multiline=True
1457
- ),
1458
- "voice": (voice_options, {"default": voice_options[0]}),
1459
- "voice_speed": model_field_to_node_input(
1460
- IO.FLOAT, KlingLipSyncInputObject, "voice_speed", slider=True
1461
- ),
1462
- },
1463
- "hidden": {
1464
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
1465
- "comfy_api_key": "API_KEY_COMFY_ORG",
1466
- "unique_id": "UNIQUE_ID",
1467
- },
1468
- }
1469
-
1470
- DESCRIPTION = "Kling Lip Sync Text to Video Node. Syncs mouth movements in a video file to a text prompt. The video file should not be larger than 100MB, should have height/width between 720px and 1920px, and should be between 2s and 10s in length."
1471
-
1472
- def api_call(
1473
- self,
1474
- video: VideoInput,
1475
- text: str,
1476
- voice: str,
1477
- voice_speed: float,
1478
- unique_id: Optional[str] = None,
1479
- **kwargs,
1480
- ):
1481
- voice_id, voice_language = KlingLipSyncTextToVideoNode.get_voice_config()[voice]
1482
- return super().api_call(
1483
- video=video,
1484
- text=text,
1485
- voice_language=voice_language,
1486
- voice_id=voice_id,
1487
- voice_speed=voice_speed,
1488
- mode="text2video",
1489
- unique_id=unique_id,
1490
- **kwargs,
1491
- )
1492
-
1493
-
1494
- class KlingImageGenerationBase(KlingNodeBase):
1495
- """Kling Image Generation Base Node."""
1496
-
1497
- RETURN_TYPES = ("IMAGE",)
1498
- CATEGORY = "api node/image/Kling"
1499
-
1500
- def validate_prompt(self, prompt: str, negative_prompt: Optional[str] = None):
1501
- if not prompt or len(prompt) > MAX_PROMPT_LENGTH_IMAGE_GEN:
1502
- raise ValueError(
1503
- f"Prompt must be less than {MAX_PROMPT_LENGTH_IMAGE_GEN} characters"
1504
- )
1505
- if negative_prompt and len(negative_prompt) > MAX_PROMPT_LENGTH_IMAGE_GEN:
1506
- raise ValueError(
1507
- f"Negative prompt must be less than {MAX_PROMPT_LENGTH_IMAGE_GEN} characters"
1508
- )
1509
-
1510
-
1511
- class KlingVirtualTryOnNode(KlingImageGenerationBase):
1512
- """Kling Virtual Try On Node."""
1513
-
1514
- @classmethod
1515
- def INPUT_TYPES(s):
1516
- return {
1517
- "required": {
1518
- "human_image": (IO.IMAGE, {}),
1519
- "cloth_image": (IO.IMAGE, {}),
1520
- "model_name": model_field_to_node_input(
1521
- IO.COMBO,
1522
- KlingVirtualTryOnRequest,
1523
- "model_name",
1524
- enum_type=KlingVirtualTryOnModelName,
1525
- ),
1526
- },
1527
- "hidden": {
1528
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
1529
- "comfy_api_key": "API_KEY_COMFY_ORG",
1530
- "unique_id": "UNIQUE_ID",
1531
- },
1532
- }
1533
-
1534
- DESCRIPTION = "Kling Virtual Try On Node. Input a human image and a cloth image to try on the cloth on the human. You can merge multiple clothing item pictures into one image with a white background."
1535
-
1536
- def get_response(
1537
- self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
1538
- ) -> KlingVirtualTryOnResponse:
1539
- return poll_until_finished(
1540
- auth_kwargs,
1541
- ApiEndpoint(
1542
- path=f"{PATH_VIRTUAL_TRY_ON}/{task_id}",
1543
- method=HttpMethod.GET,
1544
- request_model=EmptyRequest,
1545
- response_model=KlingVirtualTryOnResponse,
1546
- ),
1547
- result_url_extractor=get_images_urls_from_response,
1548
- estimated_duration=AVERAGE_DURATION_VIRTUAL_TRY_ON,
1549
- node_id=node_id,
1550
- )
1551
-
1552
- def api_call(
1553
- self,
1554
- human_image: torch.Tensor,
1555
- cloth_image: torch.Tensor,
1556
- model_name: KlingVirtualTryOnModelName,
1557
- unique_id: Optional[str] = None,
1558
- **kwargs,
1559
- ):
1560
- initial_operation = SynchronousOperation(
1561
- endpoint=ApiEndpoint(
1562
- path=PATH_VIRTUAL_TRY_ON,
1563
- method=HttpMethod.POST,
1564
- request_model=KlingVirtualTryOnRequest,
1565
- response_model=KlingVirtualTryOnResponse,
1566
- ),
1567
- request=KlingVirtualTryOnRequest(
1568
- human_image=tensor_to_base64_string(human_image),
1569
- cloth_image=tensor_to_base64_string(cloth_image),
1570
- model_name=model_name,
1571
- ),
1572
- auth_kwargs=kwargs,
1573
- )
1574
-
1575
- task_creation_response = initial_operation.execute()
1576
- validate_task_creation_response(task_creation_response)
1577
- task_id = task_creation_response.data.task_id
1578
-
1579
- final_response = self.get_response(
1580
- task_id, auth_kwargs=kwargs, node_id=unique_id
1581
- )
1582
- validate_image_result_response(final_response)
1583
-
1584
- images = get_images_from_response(final_response)
1585
- return (image_result_to_node_output(images),)
1586
-
1587
-
1588
- class KlingImageGenerationNode(KlingImageGenerationBase):
1589
- """Kling Image Generation Node. Generate an image from a text prompt with an optional reference image."""
1590
-
1591
- @classmethod
1592
- def INPUT_TYPES(s):
1593
- return {
1594
- "required": {
1595
- "prompt": model_field_to_node_input(
1596
- IO.STRING,
1597
- KlingImageGenerationsRequest,
1598
- "prompt",
1599
- multiline=True,
1600
- max_length=MAX_PROMPT_LENGTH_IMAGE_GEN,
1601
- ),
1602
- "negative_prompt": model_field_to_node_input(
1603
- IO.STRING,
1604
- KlingImageGenerationsRequest,
1605
- "negative_prompt",
1606
- multiline=True,
1607
- ),
1608
- "image_type": model_field_to_node_input(
1609
- IO.COMBO,
1610
- KlingImageGenerationsRequest,
1611
- "image_reference",
1612
- enum_type=KlingImageGenImageReferenceType,
1613
- ),
1614
- "image_fidelity": model_field_to_node_input(
1615
- IO.FLOAT,
1616
- KlingImageGenerationsRequest,
1617
- "image_fidelity",
1618
- slider=True,
1619
- step=0.01,
1620
- ),
1621
- "human_fidelity": model_field_to_node_input(
1622
- IO.FLOAT,
1623
- KlingImageGenerationsRequest,
1624
- "human_fidelity",
1625
- slider=True,
1626
- step=0.01,
1627
- ),
1628
- "model_name": model_field_to_node_input(
1629
- IO.COMBO,
1630
- KlingImageGenerationsRequest,
1631
- "model_name",
1632
- enum_type=KlingImageGenModelName,
1633
- ),
1634
- "aspect_ratio": model_field_to_node_input(
1635
- IO.COMBO,
1636
- KlingImageGenerationsRequest,
1637
- "aspect_ratio",
1638
- enum_type=KlingImageGenAspectRatio,
1639
- ),
1640
- "n": model_field_to_node_input(
1641
- IO.INT,
1642
- KlingImageGenerationsRequest,
1643
- "n",
1644
- ),
1645
- },
1646
- "optional": {
1647
- "image": (IO.IMAGE, {}),
1648
- },
1649
- "hidden": {
1650
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
1651
- "comfy_api_key": "API_KEY_COMFY_ORG",
1652
- "unique_id": "UNIQUE_ID",
1653
- },
1654
- }
1655
-
1656
- DESCRIPTION = "Kling Image Generation Node. Generate an image from a text prompt with an optional reference image."
1657
-
1658
- def get_response(
1659
- self,
1660
- task_id: str,
1661
- auth_kwargs: Optional[dict[str, str]],
1662
- node_id: Optional[str] = None,
1663
- ) -> KlingImageGenerationsResponse:
1664
- return poll_until_finished(
1665
- auth_kwargs,
1666
- ApiEndpoint(
1667
- path=f"{PATH_IMAGE_GENERATIONS}/{task_id}",
1668
- method=HttpMethod.GET,
1669
- request_model=EmptyRequest,
1670
- response_model=KlingImageGenerationsResponse,
1671
- ),
1672
- result_url_extractor=get_images_urls_from_response,
1673
- estimated_duration=AVERAGE_DURATION_IMAGE_GEN,
1674
- node_id=node_id,
1675
- )
1676
-
1677
- def api_call(
1678
- self,
1679
- model_name: KlingImageGenModelName,
1680
- prompt: str,
1681
- negative_prompt: str,
1682
- image_type: KlingImageGenImageReferenceType,
1683
- image_fidelity: float,
1684
- human_fidelity: float,
1685
- n: int,
1686
- aspect_ratio: KlingImageGenAspectRatio,
1687
- image: Optional[torch.Tensor] = None,
1688
- unique_id: Optional[str] = None,
1689
- **kwargs,
1690
- ):
1691
- self.validate_prompt(prompt, negative_prompt)
1692
-
1693
- if image is not None:
1694
- image = tensor_to_base64_string(image)
1695
-
1696
- initial_operation = SynchronousOperation(
1697
- endpoint=ApiEndpoint(
1698
- path=PATH_IMAGE_GENERATIONS,
1699
- method=HttpMethod.POST,
1700
- request_model=KlingImageGenerationsRequest,
1701
- response_model=KlingImageGenerationsResponse,
1702
- ),
1703
- request=KlingImageGenerationsRequest(
1704
- model_name=model_name,
1705
- prompt=prompt,
1706
- negative_prompt=negative_prompt,
1707
- image=image,
1708
- image_reference=image_type,
1709
- image_fidelity=image_fidelity,
1710
- human_fidelity=human_fidelity,
1711
- n=n,
1712
- aspect_ratio=aspect_ratio,
1713
- ),
1714
- auth_kwargs=kwargs,
1715
- )
1716
-
1717
- task_creation_response = initial_operation.execute()
1718
- validate_task_creation_response(task_creation_response)
1719
- task_id = task_creation_response.data.task_id
1720
-
1721
- final_response = self.get_response(
1722
- task_id, auth_kwargs=kwargs, node_id=unique_id
1723
- )
1724
- validate_image_result_response(final_response)
1725
-
1726
- images = get_images_from_response(final_response)
1727
- return (image_result_to_node_output(images),)
1728
-
1729
-
1730
- NODE_CLASS_MAPPINGS = {
1731
- "KlingCameraControls": KlingCameraControls,
1732
- "KlingTextToVideoNode": KlingTextToVideoNode,
1733
- "KlingImage2VideoNode": KlingImage2VideoNode,
1734
- "KlingCameraControlI2VNode": KlingCameraControlI2VNode,
1735
- "KlingCameraControlT2VNode": KlingCameraControlT2VNode,
1736
- "KlingStartEndFrameNode": KlingStartEndFrameNode,
1737
- "KlingVideoExtendNode": KlingVideoExtendNode,
1738
- "KlingLipSyncAudioToVideoNode": KlingLipSyncAudioToVideoNode,
1739
- "KlingLipSyncTextToVideoNode": KlingLipSyncTextToVideoNode,
1740
- "KlingVirtualTryOnNode": KlingVirtualTryOnNode,
1741
- "KlingImageGenerationNode": KlingImageGenerationNode,
1742
- "KlingSingleImageVideoEffectNode": KlingSingleImageVideoEffectNode,
1743
- "KlingDualCharacterVideoEffectNode": KlingDualCharacterVideoEffectNode,
1744
- }
1745
-
1746
- NODE_DISPLAY_NAME_MAPPINGS = {
1747
- "KlingCameraControls": "Kling Camera Controls",
1748
- "KlingTextToVideoNode": "Kling Text to Video",
1749
- "KlingImage2VideoNode": "Kling Image to Video",
1750
- "KlingCameraControlI2VNode": "Kling Image to Video (Camera Control)",
1751
- "KlingCameraControlT2VNode": "Kling Text to Video (Camera Control)",
1752
- "KlingStartEndFrameNode": "Kling Start-End Frame to Video",
1753
- "KlingVideoExtendNode": "Kling Video Extend",
1754
- "KlingLipSyncAudioToVideoNode": "Kling Lip Sync Video with Audio",
1755
- "KlingLipSyncTextToVideoNode": "Kling Lip Sync Video with Text",
1756
- "KlingVirtualTryOnNode": "Kling Virtual Try On",
1757
- "KlingImageGenerationNode": "Kling Image Generation",
1758
- "KlingSingleImageVideoEffectNode": "Kling Video Effects",
1759
- "KlingDualCharacterVideoEffectNode": "Kling Dual Character Video Effects",
1760
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/nodes_luma.py DELETED
@@ -1,737 +0,0 @@
1
- from __future__ import annotations
2
- from inspect import cleandoc
3
- from typing import Optional
4
- from comfy.comfy_types.node_typing import IO, ComfyNodeABC
5
- from comfy_api.input_impl.video_types import VideoFromFile
6
- from comfy_api_nodes.apis.luma_api import (
7
- LumaImageModel,
8
- LumaVideoModel,
9
- LumaVideoOutputResolution,
10
- LumaVideoModelOutputDuration,
11
- LumaAspectRatio,
12
- LumaState,
13
- LumaImageGenerationRequest,
14
- LumaGenerationRequest,
15
- LumaGeneration,
16
- LumaCharacterRef,
17
- LumaModifyImageRef,
18
- LumaImageIdentity,
19
- LumaReference,
20
- LumaReferenceChain,
21
- LumaImageReference,
22
- LumaKeyframes,
23
- LumaConceptChain,
24
- LumaIO,
25
- get_luma_concepts,
26
- )
27
- from comfy_api_nodes.apis.client import (
28
- ApiEndpoint,
29
- HttpMethod,
30
- SynchronousOperation,
31
- PollingOperation,
32
- EmptyRequest,
33
- )
34
- from comfy_api_nodes.apinode_utils import (
35
- upload_images_to_comfyapi,
36
- process_image_response,
37
- validate_string,
38
- )
39
- from server import PromptServer
40
-
41
- import requests
42
- import torch
43
- from io import BytesIO
44
-
45
- LUMA_T2V_AVERAGE_DURATION = 105
46
- LUMA_I2V_AVERAGE_DURATION = 100
47
-
48
- def image_result_url_extractor(response: LumaGeneration):
49
- return response.assets.image if hasattr(response, "assets") and hasattr(response.assets, "image") else None
50
-
51
- def video_result_url_extractor(response: LumaGeneration):
52
- return response.assets.video if hasattr(response, "assets") and hasattr(response.assets, "video") else None
53
-
54
- class LumaReferenceNode(ComfyNodeABC):
55
- """
56
- Holds an image and weight for use with Luma Generate Image node.
57
- """
58
-
59
- RETURN_TYPES = (LumaIO.LUMA_REF,)
60
- RETURN_NAMES = ("luma_ref",)
61
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
62
- FUNCTION = "create_luma_reference"
63
- CATEGORY = "api node/image/Luma"
64
-
65
- @classmethod
66
- def INPUT_TYPES(s):
67
- return {
68
- "required": {
69
- "image": (
70
- IO.IMAGE,
71
- {
72
- "tooltip": "Image to use as reference.",
73
- },
74
- ),
75
- "weight": (
76
- IO.FLOAT,
77
- {
78
- "default": 1.0,
79
- "min": 0.0,
80
- "max": 1.0,
81
- "step": 0.01,
82
- "tooltip": "Weight of image reference.",
83
- },
84
- ),
85
- },
86
- "optional": {"luma_ref": (LumaIO.LUMA_REF,)},
87
- }
88
-
89
- def create_luma_reference(
90
- self, image: torch.Tensor, weight: float, luma_ref: LumaReferenceChain = None
91
- ):
92
- if luma_ref is not None:
93
- luma_ref = luma_ref.clone()
94
- else:
95
- luma_ref = LumaReferenceChain()
96
- luma_ref.add(LumaReference(image=image, weight=round(weight, 2)))
97
- return (luma_ref,)
98
-
99
-
100
- class LumaConceptsNode(ComfyNodeABC):
101
- """
102
- Holds one or more Camera Concepts for use with Luma Text to Video and Luma Image to Video nodes.
103
- """
104
-
105
- RETURN_TYPES = (LumaIO.LUMA_CONCEPTS,)
106
- RETURN_NAMES = ("luma_concepts",)
107
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
108
- FUNCTION = "create_concepts"
109
- CATEGORY = "api node/video/Luma"
110
-
111
- @classmethod
112
- def INPUT_TYPES(s):
113
- return {
114
- "required": {
115
- "concept1": (get_luma_concepts(include_none=True),),
116
- "concept2": (get_luma_concepts(include_none=True),),
117
- "concept3": (get_luma_concepts(include_none=True),),
118
- "concept4": (get_luma_concepts(include_none=True),),
119
- },
120
- "optional": {
121
- "luma_concepts": (
122
- LumaIO.LUMA_CONCEPTS,
123
- {
124
- "tooltip": "Optional Camera Concepts to add to the ones chosen here."
125
- },
126
- ),
127
- },
128
- }
129
-
130
- def create_concepts(
131
- self,
132
- concept1: str,
133
- concept2: str,
134
- concept3: str,
135
- concept4: str,
136
- luma_concepts: LumaConceptChain = None,
137
- ):
138
- chain = LumaConceptChain(str_list=[concept1, concept2, concept3, concept4])
139
- if luma_concepts is not None:
140
- chain = luma_concepts.clone_and_merge(chain)
141
- return (chain,)
142
-
143
-
144
- class LumaImageGenerationNode(ComfyNodeABC):
145
- """
146
- Generates images synchronously based on prompt and aspect ratio.
147
- """
148
-
149
- RETURN_TYPES = (IO.IMAGE,)
150
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
151
- FUNCTION = "api_call"
152
- API_NODE = True
153
- CATEGORY = "api node/image/Luma"
154
-
155
- @classmethod
156
- def INPUT_TYPES(s):
157
- return {
158
- "required": {
159
- "prompt": (
160
- IO.STRING,
161
- {
162
- "multiline": True,
163
- "default": "",
164
- "tooltip": "Prompt for the image generation",
165
- },
166
- ),
167
- "model": ([model.value for model in LumaImageModel],),
168
- "aspect_ratio": (
169
- [ratio.value for ratio in LumaAspectRatio],
170
- {
171
- "default": LumaAspectRatio.ratio_16_9,
172
- },
173
- ),
174
- "seed": (
175
- IO.INT,
176
- {
177
- "default": 0,
178
- "min": 0,
179
- "max": 0xFFFFFFFFFFFFFFFF,
180
- "control_after_generate": True,
181
- "tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
182
- },
183
- ),
184
- "style_image_weight": (
185
- IO.FLOAT,
186
- {
187
- "default": 1.0,
188
- "min": 0.0,
189
- "max": 1.0,
190
- "step": 0.01,
191
- "tooltip": "Weight of style image. Ignored if no style_image provided.",
192
- },
193
- ),
194
- },
195
- "optional": {
196
- "image_luma_ref": (
197
- LumaIO.LUMA_REF,
198
- {
199
- "tooltip": "Luma Reference node connection to influence generation with input images; up to 4 images can be considered."
200
- },
201
- ),
202
- "style_image": (
203
- IO.IMAGE,
204
- {"tooltip": "Style reference image; only 1 image will be used."},
205
- ),
206
- "character_image": (
207
- IO.IMAGE,
208
- {
209
- "tooltip": "Character reference images; can be a batch of multiple, up to 4 images can be considered."
210
- },
211
- ),
212
- },
213
- "hidden": {
214
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
215
- "comfy_api_key": "API_KEY_COMFY_ORG",
216
- "unique_id": "UNIQUE_ID",
217
- },
218
- }
219
-
220
- def api_call(
221
- self,
222
- prompt: str,
223
- model: str,
224
- aspect_ratio: str,
225
- seed,
226
- style_image_weight: float,
227
- image_luma_ref: LumaReferenceChain = None,
228
- style_image: torch.Tensor = None,
229
- character_image: torch.Tensor = None,
230
- unique_id: str = None,
231
- **kwargs,
232
- ):
233
- validate_string(prompt, strip_whitespace=True, min_length=3)
234
- # handle image_luma_ref
235
- api_image_ref = None
236
- if image_luma_ref is not None:
237
- api_image_ref = self._convert_luma_refs(
238
- image_luma_ref, max_refs=4, auth_kwargs=kwargs,
239
- )
240
- # handle style_luma_ref
241
- api_style_ref = None
242
- if style_image is not None:
243
- api_style_ref = self._convert_style_image(
244
- style_image, weight=style_image_weight, auth_kwargs=kwargs,
245
- )
246
- # handle character_ref images
247
- character_ref = None
248
- if character_image is not None:
249
- download_urls = upload_images_to_comfyapi(
250
- character_image, max_images=4, auth_kwargs=kwargs,
251
- )
252
- character_ref = LumaCharacterRef(
253
- identity0=LumaImageIdentity(images=download_urls)
254
- )
255
-
256
- operation = SynchronousOperation(
257
- endpoint=ApiEndpoint(
258
- path="/proxy/luma/generations/image",
259
- method=HttpMethod.POST,
260
- request_model=LumaImageGenerationRequest,
261
- response_model=LumaGeneration,
262
- ),
263
- request=LumaImageGenerationRequest(
264
- prompt=prompt,
265
- model=model,
266
- aspect_ratio=aspect_ratio,
267
- image_ref=api_image_ref,
268
- style_ref=api_style_ref,
269
- character_ref=character_ref,
270
- ),
271
- auth_kwargs=kwargs,
272
- )
273
- response_api: LumaGeneration = operation.execute()
274
-
275
- operation = PollingOperation(
276
- poll_endpoint=ApiEndpoint(
277
- path=f"/proxy/luma/generations/{response_api.id}",
278
- method=HttpMethod.GET,
279
- request_model=EmptyRequest,
280
- response_model=LumaGeneration,
281
- ),
282
- completed_statuses=[LumaState.completed],
283
- failed_statuses=[LumaState.failed],
284
- status_extractor=lambda x: x.state,
285
- result_url_extractor=image_result_url_extractor,
286
- node_id=unique_id,
287
- auth_kwargs=kwargs,
288
- )
289
- response_poll = operation.execute()
290
-
291
- img_response = requests.get(response_poll.assets.image)
292
- img = process_image_response(img_response)
293
- return (img,)
294
-
295
- def _convert_luma_refs(
296
- self, luma_ref: LumaReferenceChain, max_refs: int, auth_kwargs: Optional[dict[str,str]] = None
297
- ):
298
- luma_urls = []
299
- ref_count = 0
300
- for ref in luma_ref.refs:
301
- download_urls = upload_images_to_comfyapi(
302
- ref.image, max_images=1, auth_kwargs=auth_kwargs
303
- )
304
- luma_urls.append(download_urls[0])
305
- ref_count += 1
306
- if ref_count >= max_refs:
307
- break
308
- return luma_ref.create_api_model(download_urls=luma_urls, max_refs=max_refs)
309
-
310
- def _convert_style_image(
311
- self, style_image: torch.Tensor, weight: float, auth_kwargs: Optional[dict[str,str]] = None
312
- ):
313
- chain = LumaReferenceChain(
314
- first_ref=LumaReference(image=style_image, weight=weight)
315
- )
316
- return self._convert_luma_refs(chain, max_refs=1, auth_kwargs=auth_kwargs)
317
-
318
-
319
- class LumaImageModifyNode(ComfyNodeABC):
320
- """
321
- Modifies images synchronously based on prompt and aspect ratio.
322
- """
323
-
324
- RETURN_TYPES = (IO.IMAGE,)
325
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
326
- FUNCTION = "api_call"
327
- API_NODE = True
328
- CATEGORY = "api node/image/Luma"
329
-
330
- @classmethod
331
- def INPUT_TYPES(s):
332
- return {
333
- "required": {
334
- "image": (IO.IMAGE,),
335
- "prompt": (
336
- IO.STRING,
337
- {
338
- "multiline": True,
339
- "default": "",
340
- "tooltip": "Prompt for the image generation",
341
- },
342
- ),
343
- "image_weight": (
344
- IO.FLOAT,
345
- {
346
- "default": 0.1,
347
- "min": 0.0,
348
- "max": 0.98,
349
- "step": 0.01,
350
- "tooltip": "Weight of the image; the closer to 1.0, the less the image will be modified.",
351
- },
352
- ),
353
- "model": ([model.value for model in LumaImageModel],),
354
- "seed": (
355
- IO.INT,
356
- {
357
- "default": 0,
358
- "min": 0,
359
- "max": 0xFFFFFFFFFFFFFFFF,
360
- "control_after_generate": True,
361
- "tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
362
- },
363
- ),
364
- },
365
- "optional": {},
366
- "hidden": {
367
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
368
- "comfy_api_key": "API_KEY_COMFY_ORG",
369
- "unique_id": "UNIQUE_ID",
370
- },
371
- }
372
-
373
- def api_call(
374
- self,
375
- prompt: str,
376
- model: str,
377
- image: torch.Tensor,
378
- image_weight: float,
379
- seed,
380
- unique_id: str = None,
381
- **kwargs,
382
- ):
383
- # first, upload image
384
- download_urls = upload_images_to_comfyapi(
385
- image, max_images=1, auth_kwargs=kwargs,
386
- )
387
- image_url = download_urls[0]
388
- # next, make Luma call with download url provided
389
- operation = SynchronousOperation(
390
- endpoint=ApiEndpoint(
391
- path="/proxy/luma/generations/image",
392
- method=HttpMethod.POST,
393
- request_model=LumaImageGenerationRequest,
394
- response_model=LumaGeneration,
395
- ),
396
- request=LumaImageGenerationRequest(
397
- prompt=prompt,
398
- model=model,
399
- modify_image_ref=LumaModifyImageRef(
400
- url=image_url, weight=round(max(min(1.0-image_weight, 0.98), 0.0), 2)
401
- ),
402
- ),
403
- auth_kwargs=kwargs,
404
- )
405
- response_api: LumaGeneration = operation.execute()
406
-
407
- operation = PollingOperation(
408
- poll_endpoint=ApiEndpoint(
409
- path=f"/proxy/luma/generations/{response_api.id}",
410
- method=HttpMethod.GET,
411
- request_model=EmptyRequest,
412
- response_model=LumaGeneration,
413
- ),
414
- completed_statuses=[LumaState.completed],
415
- failed_statuses=[LumaState.failed],
416
- status_extractor=lambda x: x.state,
417
- result_url_extractor=image_result_url_extractor,
418
- node_id=unique_id,
419
- auth_kwargs=kwargs,
420
- )
421
- response_poll = operation.execute()
422
-
423
- img_response = requests.get(response_poll.assets.image)
424
- img = process_image_response(img_response)
425
- return (img,)
426
-
427
-
428
- class LumaTextToVideoGenerationNode(ComfyNodeABC):
429
- """
430
- Generates videos synchronously based on prompt and output_size.
431
- """
432
-
433
- RETURN_TYPES = (IO.VIDEO,)
434
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
435
- FUNCTION = "api_call"
436
- API_NODE = True
437
- CATEGORY = "api node/video/Luma"
438
-
439
- @classmethod
440
- def INPUT_TYPES(s):
441
- return {
442
- "required": {
443
- "prompt": (
444
- IO.STRING,
445
- {
446
- "multiline": True,
447
- "default": "",
448
- "tooltip": "Prompt for the video generation",
449
- },
450
- ),
451
- "model": ([model.value for model in LumaVideoModel],),
452
- "aspect_ratio": (
453
- [ratio.value for ratio in LumaAspectRatio],
454
- {
455
- "default": LumaAspectRatio.ratio_16_9,
456
- },
457
- ),
458
- "resolution": (
459
- [resolution.value for resolution in LumaVideoOutputResolution],
460
- {
461
- "default": LumaVideoOutputResolution.res_540p,
462
- },
463
- ),
464
- "duration": ([dur.value for dur in LumaVideoModelOutputDuration],),
465
- "loop": (
466
- IO.BOOLEAN,
467
- {
468
- "default": False,
469
- },
470
- ),
471
- "seed": (
472
- IO.INT,
473
- {
474
- "default": 0,
475
- "min": 0,
476
- "max": 0xFFFFFFFFFFFFFFFF,
477
- "control_after_generate": True,
478
- "tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
479
- },
480
- ),
481
- },
482
- "optional": {
483
- "luma_concepts": (
484
- LumaIO.LUMA_CONCEPTS,
485
- {
486
- "tooltip": "Optional Camera Concepts to dictate camera motion via the Luma Concepts node."
487
- },
488
- ),
489
- },
490
- "hidden": {
491
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
492
- "comfy_api_key": "API_KEY_COMFY_ORG",
493
- "unique_id": "UNIQUE_ID",
494
- },
495
- }
496
-
497
- def api_call(
498
- self,
499
- prompt: str,
500
- model: str,
501
- aspect_ratio: str,
502
- resolution: str,
503
- duration: str,
504
- loop: bool,
505
- seed,
506
- luma_concepts: LumaConceptChain = None,
507
- unique_id: str = None,
508
- **kwargs,
509
- ):
510
- validate_string(prompt, strip_whitespace=False, min_length=3)
511
- duration = duration if model != LumaVideoModel.ray_1_6 else None
512
- resolution = resolution if model != LumaVideoModel.ray_1_6 else None
513
-
514
- operation = SynchronousOperation(
515
- endpoint=ApiEndpoint(
516
- path="/proxy/luma/generations",
517
- method=HttpMethod.POST,
518
- request_model=LumaGenerationRequest,
519
- response_model=LumaGeneration,
520
- ),
521
- request=LumaGenerationRequest(
522
- prompt=prompt,
523
- model=model,
524
- resolution=resolution,
525
- aspect_ratio=aspect_ratio,
526
- duration=duration,
527
- loop=loop,
528
- concepts=luma_concepts.create_api_model() if luma_concepts else None,
529
- ),
530
- auth_kwargs=kwargs,
531
- )
532
- response_api: LumaGeneration = operation.execute()
533
-
534
- if unique_id:
535
- PromptServer.instance.send_progress_text(f"Luma video generation started: {response_api.id}", unique_id)
536
-
537
- operation = PollingOperation(
538
- poll_endpoint=ApiEndpoint(
539
- path=f"/proxy/luma/generations/{response_api.id}",
540
- method=HttpMethod.GET,
541
- request_model=EmptyRequest,
542
- response_model=LumaGeneration,
543
- ),
544
- completed_statuses=[LumaState.completed],
545
- failed_statuses=[LumaState.failed],
546
- status_extractor=lambda x: x.state,
547
- result_url_extractor=video_result_url_extractor,
548
- node_id=unique_id,
549
- estimated_duration=LUMA_T2V_AVERAGE_DURATION,
550
- auth_kwargs=kwargs,
551
- )
552
- response_poll = operation.execute()
553
-
554
- vid_response = requests.get(response_poll.assets.video)
555
- return (VideoFromFile(BytesIO(vid_response.content)),)
556
-
557
-
558
- class LumaImageToVideoGenerationNode(ComfyNodeABC):
559
- """
560
- Generates videos synchronously based on prompt, input images, and output_size.
561
- """
562
-
563
- RETURN_TYPES = (IO.VIDEO,)
564
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
565
- FUNCTION = "api_call"
566
- API_NODE = True
567
- CATEGORY = "api node/video/Luma"
568
-
569
- @classmethod
570
- def INPUT_TYPES(s):
571
- return {
572
- "required": {
573
- "prompt": (
574
- IO.STRING,
575
- {
576
- "multiline": True,
577
- "default": "",
578
- "tooltip": "Prompt for the video generation",
579
- },
580
- ),
581
- "model": ([model.value for model in LumaVideoModel],),
582
- # "aspect_ratio": ([ratio.value for ratio in LumaAspectRatio], {
583
- # "default": LumaAspectRatio.ratio_16_9,
584
- # }),
585
- "resolution": (
586
- [resolution.value for resolution in LumaVideoOutputResolution],
587
- {
588
- "default": LumaVideoOutputResolution.res_540p,
589
- },
590
- ),
591
- "duration": ([dur.value for dur in LumaVideoModelOutputDuration],),
592
- "loop": (
593
- IO.BOOLEAN,
594
- {
595
- "default": False,
596
- },
597
- ),
598
- "seed": (
599
- IO.INT,
600
- {
601
- "default": 0,
602
- "min": 0,
603
- "max": 0xFFFFFFFFFFFFFFFF,
604
- "control_after_generate": True,
605
- "tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
606
- },
607
- ),
608
- },
609
- "optional": {
610
- "first_image": (
611
- IO.IMAGE,
612
- {"tooltip": "First frame of generated video."},
613
- ),
614
- "last_image": (IO.IMAGE, {"tooltip": "Last frame of generated video."}),
615
- "luma_concepts": (
616
- LumaIO.LUMA_CONCEPTS,
617
- {
618
- "tooltip": "Optional Camera Concepts to dictate camera motion via the Luma Concepts node."
619
- },
620
- ),
621
- },
622
- "hidden": {
623
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
624
- "comfy_api_key": "API_KEY_COMFY_ORG",
625
- "unique_id": "UNIQUE_ID",
626
- },
627
- }
628
-
629
- def api_call(
630
- self,
631
- prompt: str,
632
- model: str,
633
- resolution: str,
634
- duration: str,
635
- loop: bool,
636
- seed,
637
- first_image: torch.Tensor = None,
638
- last_image: torch.Tensor = None,
639
- luma_concepts: LumaConceptChain = None,
640
- unique_id: str = None,
641
- **kwargs,
642
- ):
643
- if first_image is None and last_image is None:
644
- raise Exception(
645
- "At least one of first_image and last_image requires an input."
646
- )
647
- keyframes = self._convert_to_keyframes(first_image, last_image, auth_kwargs=kwargs)
648
- duration = duration if model != LumaVideoModel.ray_1_6 else None
649
- resolution = resolution if model != LumaVideoModel.ray_1_6 else None
650
-
651
- operation = SynchronousOperation(
652
- endpoint=ApiEndpoint(
653
- path="/proxy/luma/generations",
654
- method=HttpMethod.POST,
655
- request_model=LumaGenerationRequest,
656
- response_model=LumaGeneration,
657
- ),
658
- request=LumaGenerationRequest(
659
- prompt=prompt,
660
- model=model,
661
- aspect_ratio=LumaAspectRatio.ratio_16_9, # ignored, but still needed by the API for some reason
662
- resolution=resolution,
663
- duration=duration,
664
- loop=loop,
665
- keyframes=keyframes,
666
- concepts=luma_concepts.create_api_model() if luma_concepts else None,
667
- ),
668
- auth_kwargs=kwargs,
669
- )
670
- response_api: LumaGeneration = operation.execute()
671
-
672
- if unique_id:
673
- PromptServer.instance.send_progress_text(f"Luma video generation started: {response_api.id}", unique_id)
674
-
675
- operation = PollingOperation(
676
- poll_endpoint=ApiEndpoint(
677
- path=f"/proxy/luma/generations/{response_api.id}",
678
- method=HttpMethod.GET,
679
- request_model=EmptyRequest,
680
- response_model=LumaGeneration,
681
- ),
682
- completed_statuses=[LumaState.completed],
683
- failed_statuses=[LumaState.failed],
684
- status_extractor=lambda x: x.state,
685
- result_url_extractor=video_result_url_extractor,
686
- node_id=unique_id,
687
- estimated_duration=LUMA_I2V_AVERAGE_DURATION,
688
- auth_kwargs=kwargs,
689
- )
690
- response_poll = operation.execute()
691
-
692
- vid_response = requests.get(response_poll.assets.video)
693
- return (VideoFromFile(BytesIO(vid_response.content)),)
694
-
695
- def _convert_to_keyframes(
696
- self,
697
- first_image: torch.Tensor = None,
698
- last_image: torch.Tensor = None,
699
- auth_kwargs: Optional[dict[str,str]] = None,
700
- ):
701
- if first_image is None and last_image is None:
702
- return None
703
- frame0 = None
704
- frame1 = None
705
- if first_image is not None:
706
- download_urls = upload_images_to_comfyapi(
707
- first_image, max_images=1, auth_kwargs=auth_kwargs,
708
- )
709
- frame0 = LumaImageReference(type="image", url=download_urls[0])
710
- if last_image is not None:
711
- download_urls = upload_images_to_comfyapi(
712
- last_image, max_images=1, auth_kwargs=auth_kwargs,
713
- )
714
- frame1 = LumaImageReference(type="image", url=download_urls[0])
715
- return LumaKeyframes(frame0=frame0, frame1=frame1)
716
-
717
-
718
- # A dictionary that contains all nodes you want to export with their names
719
- # NOTE: names should be globally unique
720
- NODE_CLASS_MAPPINGS = {
721
- "LumaImageNode": LumaImageGenerationNode,
722
- "LumaImageModifyNode": LumaImageModifyNode,
723
- "LumaVideoNode": LumaTextToVideoGenerationNode,
724
- "LumaImageToVideoNode": LumaImageToVideoGenerationNode,
725
- "LumaReferenceNode": LumaReferenceNode,
726
- "LumaConceptsNode": LumaConceptsNode,
727
- }
728
-
729
- # A dictionary that contains the friendly/humanly readable titles for the nodes
730
- NODE_DISPLAY_NAME_MAPPINGS = {
731
- "LumaImageNode": "Luma Text to Image",
732
- "LumaImageModifyNode": "Luma Image to Image",
733
- "LumaVideoNode": "Luma Text to Video",
734
- "LumaImageToVideoNode": "Luma Image to Video",
735
- "LumaReferenceNode": "Luma Reference",
736
- "LumaConceptsNode": "Luma Concepts",
737
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/nodes_minimax.py DELETED
@@ -1,332 +0,0 @@
1
- from typing import Union
2
- import logging
3
- import torch
4
-
5
- from comfy.comfy_types.node_typing import IO
6
- from comfy_api.input_impl.video_types import VideoFromFile
7
- from comfy_api_nodes.apis import (
8
- MinimaxVideoGenerationRequest,
9
- MinimaxVideoGenerationResponse,
10
- MinimaxFileRetrieveResponse,
11
- MinimaxTaskResultResponse,
12
- SubjectReferenceItem,
13
- Model
14
- )
15
- from comfy_api_nodes.apis.client import (
16
- ApiEndpoint,
17
- HttpMethod,
18
- SynchronousOperation,
19
- PollingOperation,
20
- EmptyRequest,
21
- )
22
- from comfy_api_nodes.apinode_utils import (
23
- download_url_to_bytesio,
24
- upload_images_to_comfyapi,
25
- validate_string,
26
- )
27
- from server import PromptServer
28
-
29
-
30
- I2V_AVERAGE_DURATION = 114
31
- T2V_AVERAGE_DURATION = 234
32
-
33
- class MinimaxTextToVideoNode:
34
- """
35
- Generates videos synchronously based on a prompt, and optional parameters using MiniMax's API.
36
- """
37
-
38
- AVERAGE_DURATION = T2V_AVERAGE_DURATION
39
-
40
- @classmethod
41
- def INPUT_TYPES(s):
42
- return {
43
- "required": {
44
- "prompt_text": (
45
- "STRING",
46
- {
47
- "multiline": True,
48
- "default": "",
49
- "tooltip": "Text prompt to guide the video generation",
50
- },
51
- ),
52
- "model": (
53
- [
54
- "T2V-01",
55
- "T2V-01-Director",
56
- ],
57
- {
58
- "default": "T2V-01",
59
- "tooltip": "Model to use for video generation",
60
- },
61
- ),
62
- },
63
- "optional": {
64
- "seed": (
65
- IO.INT,
66
- {
67
- "default": 0,
68
- "min": 0,
69
- "max": 0xFFFFFFFFFFFFFFFF,
70
- "control_after_generate": True,
71
- "tooltip": "The random seed used for creating the noise.",
72
- },
73
- ),
74
- },
75
- "hidden": {
76
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
77
- "comfy_api_key": "API_KEY_COMFY_ORG",
78
- "unique_id": "UNIQUE_ID",
79
- },
80
- }
81
-
82
- RETURN_TYPES = ("VIDEO",)
83
- DESCRIPTION = "Generates videos from prompts using MiniMax's API"
84
- FUNCTION = "generate_video"
85
- CATEGORY = "api node/video/MiniMax"
86
- API_NODE = True
87
- OUTPUT_NODE = True
88
-
89
- def generate_video(
90
- self,
91
- prompt_text,
92
- seed=0,
93
- model="T2V-01",
94
- image: torch.Tensor=None, # used for ImageToVideo
95
- subject: torch.Tensor=None, # used for SubjectToVideo
96
- unique_id: Union[str, None]=None,
97
- **kwargs,
98
- ):
99
- '''
100
- Function used between MiniMax nodes - supports T2V, I2V, and S2V, based on provided arguments.
101
- '''
102
- if image is None:
103
- validate_string(prompt_text, field_name="prompt_text")
104
- # upload image, if passed in
105
- image_url = None
106
- if image is not None:
107
- image_url = upload_images_to_comfyapi(image, max_images=1, auth_kwargs=kwargs)[0]
108
-
109
- # TODO: figure out how to deal with subject properly, API returns invalid params when using S2V-01 model
110
- subject_reference = None
111
- if subject is not None:
112
- subject_url = upload_images_to_comfyapi(subject, max_images=1, auth_kwargs=kwargs)[0]
113
- subject_reference = [SubjectReferenceItem(image=subject_url)]
114
-
115
-
116
- video_generate_operation = SynchronousOperation(
117
- endpoint=ApiEndpoint(
118
- path="/proxy/minimax/video_generation",
119
- method=HttpMethod.POST,
120
- request_model=MinimaxVideoGenerationRequest,
121
- response_model=MinimaxVideoGenerationResponse,
122
- ),
123
- request=MinimaxVideoGenerationRequest(
124
- model=Model(model),
125
- prompt=prompt_text,
126
- callback_url=None,
127
- first_frame_image=image_url,
128
- subject_reference=subject_reference,
129
- prompt_optimizer=None,
130
- ),
131
- auth_kwargs=kwargs,
132
- )
133
- response = video_generate_operation.execute()
134
-
135
- task_id = response.task_id
136
- if not task_id:
137
- raise Exception(f"MiniMax generation failed: {response.base_resp}")
138
-
139
- video_generate_operation = PollingOperation(
140
- poll_endpoint=ApiEndpoint(
141
- path="/proxy/minimax/query/video_generation",
142
- method=HttpMethod.GET,
143
- request_model=EmptyRequest,
144
- response_model=MinimaxTaskResultResponse,
145
- query_params={"task_id": task_id},
146
- ),
147
- completed_statuses=["Success"],
148
- failed_statuses=["Fail"],
149
- status_extractor=lambda x: x.status.value,
150
- estimated_duration=self.AVERAGE_DURATION,
151
- node_id=unique_id,
152
- auth_kwargs=kwargs,
153
- )
154
- task_result = video_generate_operation.execute()
155
-
156
- file_id = task_result.file_id
157
- if file_id is None:
158
- raise Exception("Request was not successful. Missing file ID.")
159
- file_retrieve_operation = SynchronousOperation(
160
- endpoint=ApiEndpoint(
161
- path="/proxy/minimax/files/retrieve",
162
- method=HttpMethod.GET,
163
- request_model=EmptyRequest,
164
- response_model=MinimaxFileRetrieveResponse,
165
- query_params={"file_id": int(file_id)},
166
- ),
167
- request=EmptyRequest(),
168
- auth_kwargs=kwargs,
169
- )
170
- file_result = file_retrieve_operation.execute()
171
-
172
- file_url = file_result.file.download_url
173
- if file_url is None:
174
- raise Exception(
175
- f"No video was found in the response. Full response: {file_result.model_dump()}"
176
- )
177
- logging.info(f"Generated video URL: {file_url}")
178
- if unique_id:
179
- if hasattr(file_result.file, "backup_download_url"):
180
- message = f"Result URL: {file_url}\nBackup URL: {file_result.file.backup_download_url}"
181
- else:
182
- message = f"Result URL: {file_url}"
183
- PromptServer.instance.send_progress_text(message, unique_id)
184
-
185
- video_io = download_url_to_bytesio(file_url)
186
- if video_io is None:
187
- error_msg = f"Failed to download video from {file_url}"
188
- logging.error(error_msg)
189
- raise Exception(error_msg)
190
- return (VideoFromFile(video_io),)
191
-
192
-
193
- class MinimaxImageToVideoNode(MinimaxTextToVideoNode):
194
- """
195
- Generates videos synchronously based on an image and prompt, and optional parameters using MiniMax's API.
196
- """
197
-
198
- AVERAGE_DURATION = I2V_AVERAGE_DURATION
199
-
200
- @classmethod
201
- def INPUT_TYPES(s):
202
- return {
203
- "required": {
204
- "image": (
205
- IO.IMAGE,
206
- {
207
- "tooltip": "Image to use as first frame of video generation"
208
- },
209
- ),
210
- "prompt_text": (
211
- "STRING",
212
- {
213
- "multiline": True,
214
- "default": "",
215
- "tooltip": "Text prompt to guide the video generation",
216
- },
217
- ),
218
- "model": (
219
- [
220
- "I2V-01-Director",
221
- "I2V-01",
222
- "I2V-01-live",
223
- ],
224
- {
225
- "default": "I2V-01",
226
- "tooltip": "Model to use for video generation",
227
- },
228
- ),
229
- },
230
- "optional": {
231
- "seed": (
232
- IO.INT,
233
- {
234
- "default": 0,
235
- "min": 0,
236
- "max": 0xFFFFFFFFFFFFFFFF,
237
- "control_after_generate": True,
238
- "tooltip": "The random seed used for creating the noise.",
239
- },
240
- ),
241
- },
242
- "hidden": {
243
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
244
- "comfy_api_key": "API_KEY_COMFY_ORG",
245
- "unique_id": "UNIQUE_ID",
246
- },
247
- }
248
-
249
- RETURN_TYPES = ("VIDEO",)
250
- DESCRIPTION = "Generates videos from an image and prompts using MiniMax's API"
251
- FUNCTION = "generate_video"
252
- CATEGORY = "api node/video/MiniMax"
253
- API_NODE = True
254
- OUTPUT_NODE = True
255
-
256
-
257
- class MinimaxSubjectToVideoNode(MinimaxTextToVideoNode):
258
- """
259
- Generates videos synchronously based on an image and prompt, and optional parameters using MiniMax's API.
260
- """
261
-
262
- AVERAGE_DURATION = T2V_AVERAGE_DURATION
263
-
264
- @classmethod
265
- def INPUT_TYPES(s):
266
- return {
267
- "required": {
268
- "subject": (
269
- IO.IMAGE,
270
- {
271
- "tooltip": "Image of subject to reference video generation"
272
- },
273
- ),
274
- "prompt_text": (
275
- "STRING",
276
- {
277
- "multiline": True,
278
- "default": "",
279
- "tooltip": "Text prompt to guide the video generation",
280
- },
281
- ),
282
- "model": (
283
- [
284
- "S2V-01",
285
- ],
286
- {
287
- "default": "S2V-01",
288
- "tooltip": "Model to use for video generation",
289
- },
290
- ),
291
- },
292
- "optional": {
293
- "seed": (
294
- IO.INT,
295
- {
296
- "default": 0,
297
- "min": 0,
298
- "max": 0xFFFFFFFFFFFFFFFF,
299
- "control_after_generate": True,
300
- "tooltip": "The random seed used for creating the noise.",
301
- },
302
- ),
303
- },
304
- "hidden": {
305
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
306
- "comfy_api_key": "API_KEY_COMFY_ORG",
307
- "unique_id": "UNIQUE_ID",
308
- },
309
- }
310
-
311
- RETURN_TYPES = ("VIDEO",)
312
- DESCRIPTION = "Generates videos from an image and prompts using MiniMax's API"
313
- FUNCTION = "generate_video"
314
- CATEGORY = "api node/video/MiniMax"
315
- API_NODE = True
316
- OUTPUT_NODE = True
317
-
318
-
319
- # A dictionary that contains all nodes you want to export with their names
320
- # NOTE: names should be globally unique
321
- NODE_CLASS_MAPPINGS = {
322
- "MinimaxTextToVideoNode": MinimaxTextToVideoNode,
323
- "MinimaxImageToVideoNode": MinimaxImageToVideoNode,
324
- # "MinimaxSubjectToVideoNode": MinimaxSubjectToVideoNode,
325
- }
326
-
327
- # A dictionary that contains the friendly/humanly readable titles for the nodes
328
- NODE_DISPLAY_NAME_MAPPINGS = {
329
- "MinimaxTextToVideoNode": "MiniMax Text to Video",
330
- "MinimaxImageToVideoNode": "MiniMax Image to Video",
331
- "MinimaxSubjectToVideoNode": "MiniMax Subject to Video",
332
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/nodes_moonvalley.py DELETED
@@ -1,639 +0,0 @@
1
- import logging
2
- from typing import Any, Callable, Optional, TypeVar
3
- import random
4
- import torch
5
- from comfy_api_nodes.util.validation_utils import get_image_dimensions, validate_image_dimensions, validate_video_dimensions
6
-
7
-
8
- from comfy_api_nodes.apis import (
9
- MoonvalleyTextToVideoRequest,
10
- MoonvalleyTextToVideoInferenceParams,
11
- MoonvalleyVideoToVideoInferenceParams,
12
- MoonvalleyVideoToVideoRequest,
13
- MoonvalleyPromptResponse
14
- )
15
- from comfy_api_nodes.apis.client import (
16
- ApiEndpoint,
17
- HttpMethod,
18
- SynchronousOperation,
19
- PollingOperation,
20
- EmptyRequest,
21
- )
22
- from comfy_api_nodes.apinode_utils import (
23
- download_url_to_video_output,
24
- upload_images_to_comfyapi,
25
- upload_video_to_comfyapi,
26
- )
27
- from comfy_api_nodes.mapper_utils import model_field_to_node_input
28
-
29
- from comfy_api.input.video_types import VideoInput
30
- from comfy.comfy_types.node_typing import IO
31
- from comfy_api.input_impl import VideoFromFile
32
- import av
33
- import io
34
-
35
- API_UPLOADS_ENDPOINT = "/proxy/moonvalley/uploads"
36
- API_PROMPTS_ENDPOINT = "/proxy/moonvalley/prompts"
37
- API_VIDEO2VIDEO_ENDPOINT = "/proxy/moonvalley/prompts/video-to-video"
38
- API_TXT2VIDEO_ENDPOINT = "/proxy/moonvalley/prompts/text-to-video"
39
- API_IMG2VIDEO_ENDPOINT = "/proxy/moonvalley/prompts/image-to-video"
40
-
41
- MIN_WIDTH = 300
42
- MIN_HEIGHT = 300
43
-
44
- MAX_WIDTH = 10000
45
- MAX_HEIGHT = 10000
46
-
47
- MIN_VID_WIDTH = 300
48
- MIN_VID_HEIGHT = 300
49
-
50
- MAX_VID_WIDTH = 10000
51
- MAX_VID_HEIGHT = 10000
52
-
53
- MAX_VIDEO_SIZE = 1024 * 1024 * 1024 # 1 GB max for in-memory video processing
54
-
55
- MOONVALLEY_MAREY_MAX_PROMPT_LENGTH = 5000
56
- R = TypeVar("R")
57
- class MoonvalleyApiError(Exception):
58
- """Base exception for Moonvalley API errors."""
59
- pass
60
-
61
- def is_valid_task_creation_response(response: MoonvalleyPromptResponse) -> bool:
62
- """Verifies that the initial response contains a task ID."""
63
- return bool(response.id)
64
-
65
- def validate_task_creation_response(response) -> None:
66
- if not is_valid_task_creation_response(response):
67
- error_msg = f"Moonvalley Marey API: Initial request failed. Code: {response.code}, Message: {response.message}, Data: {response}"
68
- logging.error(error_msg)
69
- raise MoonvalleyApiError(error_msg)
70
-
71
- def get_video_from_response(response):
72
- video = response.output_url
73
- logging.info(
74
- "Moonvalley Marey API: Task %s succeeded. Video URL: %s", response.id, video
75
- )
76
- return video
77
-
78
-
79
- def get_video_url_from_response(response) -> Optional[str]:
80
- """Returns the first video url from the Moonvalley video generation task result.
81
- Will not raise an error if the response is not valid.
82
- """
83
- if response:
84
- return str(get_video_from_response(response))
85
- else:
86
- return None
87
-
88
-
89
- def poll_until_finished(
90
- auth_kwargs: dict[str, str],
91
- api_endpoint: ApiEndpoint[Any, R],
92
- result_url_extractor: Optional[Callable[[R], str]] = None,
93
- node_id: Optional[str] = None,
94
- ) -> R:
95
- """Polls the Moonvalley API endpoint until the task reaches a terminal state, then returns the response."""
96
- return PollingOperation(
97
- poll_endpoint=api_endpoint,
98
- completed_statuses=[
99
- "completed",
100
- ],
101
- max_poll_attempts=240, # 64 minutes with 16s interval
102
- poll_interval=16.0,
103
- failed_statuses=["error"],
104
- status_extractor=lambda response: (
105
- response.status
106
- if response and response.status
107
- else None
108
- ),
109
- auth_kwargs=auth_kwargs,
110
- result_url_extractor=result_url_extractor,
111
- node_id=node_id,
112
- ).execute()
113
-
114
- def validate_prompts(prompt:str, negative_prompt: str, max_length=MOONVALLEY_MAREY_MAX_PROMPT_LENGTH):
115
- """Verifies that the prompt isn't empty and that neither prompt is too long."""
116
- if not prompt:
117
- raise ValueError("Positive prompt is empty")
118
- if len(prompt) > max_length:
119
- raise ValueError(f"Positive prompt is too long: {len(prompt)} characters")
120
- if negative_prompt and len(negative_prompt) > max_length:
121
- raise ValueError(
122
- f"Negative prompt is too long: {len(negative_prompt)} characters"
123
- )
124
- return True
125
-
126
- def validate_input_media(width, height, with_frame_conditioning, num_frames_in=None):
127
- # inference validation
128
- # T = num_frames
129
- # in all cases, the following must be true: T divisible by 16 and H,W by 8. in addition...
130
- # with image conditioning: H*W must be divisible by 8192
131
- # without image conditioning: T divisible by 32
132
- if num_frames_in and not num_frames_in % 16 == 0 :
133
- return False, (
134
- "The input video total frame count must be divisible by 16!"
135
- )
136
-
137
- if height % 8 != 0 or width % 8 != 0:
138
- return False, (
139
- f"Height ({height}) and width ({width}) must be " "divisible by 8"
140
- )
141
-
142
- if with_frame_conditioning:
143
- if (height * width) % 8192 != 0:
144
- return False, (
145
- f"Height * width ({height * width}) must be "
146
- "divisible by 8192 for frame conditioning"
147
- )
148
- else:
149
- if num_frames_in and not num_frames_in % 32 == 0 :
150
- return False, (
151
- "The input video total frame count must be divisible by 32!"
152
- )
153
-
154
-
155
- def validate_input_image(image: torch.Tensor, with_frame_conditioning: bool=False) -> None:
156
- """
157
- Validates the input image adheres to the expectations of the API:
158
- - The image resolution should not be less than 300*300px
159
- - The aspect ratio of the image should be between 1:2.5 ~ 2.5:1
160
-
161
- """
162
- height, width = get_image_dimensions(image)
163
- validate_input_media(width, height, with_frame_conditioning )
164
- validate_image_dimensions(image, min_width=300, min_height=300, max_height=MAX_HEIGHT, max_width=MAX_WIDTH)
165
-
166
- def validate_input_video(video: VideoInput, num_frames_out: int, with_frame_conditioning: bool=False):
167
- try:
168
- width, height = video.get_dimensions()
169
- except Exception as e:
170
- logging.error("Error getting dimensions of video: %s", e)
171
- raise ValueError(f"Cannot get video dimensions: {e}") from e
172
-
173
- validate_input_media(width, height, with_frame_conditioning)
174
- validate_video_dimensions(video, min_width=MIN_VID_WIDTH, min_height=MIN_VID_HEIGHT, max_width=MAX_VID_WIDTH, max_height=MAX_VID_HEIGHT)
175
-
176
- trimmed_video = validate_input_video_length(video, num_frames_out)
177
- return trimmed_video
178
-
179
-
180
- def validate_input_video_length(video: VideoInput, num_frames: int):
181
-
182
- if video.get_duration() > 60:
183
- raise MoonvalleyApiError("Input Video lenth should be less than 1min. Please trim.")
184
-
185
- if num_frames == 128:
186
- if video.get_duration() < 5:
187
- raise MoonvalleyApiError("Input Video length is less than 5s. Please use a video longer than or equal to 5s.")
188
- if video.get_duration() > 5:
189
- # trim video to 5s
190
- video = trim_video(video, 5)
191
- if num_frames == 256:
192
- if video.get_duration() < 10:
193
- raise MoonvalleyApiError("Input Video length is less than 10s. Please use a video longer than or equal to 10s.")
194
- if video.get_duration() > 10:
195
- # trim video to 10s
196
- video = trim_video(video, 10)
197
- return video
198
-
199
- def trim_video(video: VideoInput, duration_sec: float) -> VideoInput:
200
- """
201
- Returns a new VideoInput object trimmed from the beginning to the specified duration,
202
- using av to avoid loading entire video into memory.
203
-
204
- Args:
205
- video: Input video to trim
206
- duration_sec: Duration in seconds to keep from the beginning
207
-
208
- Returns:
209
- VideoFromFile object that owns the output buffer
210
- """
211
- output_buffer = io.BytesIO()
212
-
213
- input_container = None
214
- output_container = None
215
-
216
- try:
217
- # Get the stream source - this avoids loading entire video into memory
218
- # when the source is already a file path
219
- input_source = video.get_stream_source()
220
-
221
- # Open containers
222
- input_container = av.open(input_source, mode='r')
223
- output_container = av.open(output_buffer, mode='w', format='mp4')
224
-
225
- # Set up output streams for re-encoding
226
- video_stream = None
227
- audio_stream = None
228
-
229
- for stream in input_container.streams:
230
- logging.info(f"Found stream: type={stream.type}, class={type(stream)}")
231
- if isinstance(stream, av.VideoStream):
232
- # Create output video stream with same parameters
233
- video_stream = output_container.add_stream('h264', rate=stream.average_rate)
234
- video_stream.width = stream.width
235
- video_stream.height = stream.height
236
- video_stream.pix_fmt = 'yuv420p'
237
- logging.info(f"Added video stream: {stream.width}x{stream.height} @ {stream.average_rate}fps")
238
- elif isinstance(stream, av.AudioStream):
239
- # Create output audio stream with same parameters
240
- audio_stream = output_container.add_stream('aac', rate=stream.sample_rate)
241
- audio_stream.sample_rate = stream.sample_rate
242
- audio_stream.layout = stream.layout
243
- logging.info(f"Added audio stream: {stream.sample_rate}Hz, {stream.channels} channels")
244
-
245
- # Calculate target frame count that's divisible by 32
246
- fps = input_container.streams.video[0].average_rate
247
- estimated_frames = int(duration_sec * fps)
248
- target_frames = (estimated_frames // 32) * 32 # Round down to nearest multiple of 32
249
-
250
- if target_frames == 0:
251
- raise ValueError("Video too short: need at least 32 frames for Moonvalley")
252
-
253
- frame_count = 0
254
- audio_frame_count = 0
255
-
256
- # Decode and re-encode video frames
257
- if video_stream:
258
- for frame in input_container.decode(video=0):
259
- if frame_count >= target_frames:
260
- break
261
-
262
- # Re-encode frame
263
- for packet in video_stream.encode(frame):
264
- output_container.mux(packet)
265
- frame_count += 1
266
-
267
- # Flush encoder
268
- for packet in video_stream.encode():
269
- output_container.mux(packet)
270
-
271
- logging.info(f"Encoded {frame_count} video frames (target: {target_frames})")
272
-
273
- # Decode and re-encode audio frames
274
- if audio_stream:
275
- input_container.seek(0) # Reset to beginning for audio
276
- for frame in input_container.decode(audio=0):
277
- if frame.time >= duration_sec:
278
- break
279
-
280
- # Re-encode frame
281
- for packet in audio_stream.encode(frame):
282
- output_container.mux(packet)
283
- audio_frame_count += 1
284
-
285
- # Flush encoder
286
- for packet in audio_stream.encode():
287
- output_container.mux(packet)
288
-
289
- logging.info(f"Encoded {audio_frame_count} audio frames")
290
-
291
- # Close containers
292
- output_container.close()
293
- input_container.close()
294
-
295
-
296
- # Return as VideoFromFile using the buffer
297
- output_buffer.seek(0)
298
- return VideoFromFile(output_buffer)
299
-
300
- except Exception as e:
301
- # Clean up on error
302
- if input_container is not None:
303
- input_container.close()
304
- if output_container is not None:
305
- output_container.close()
306
- raise RuntimeError(f"Failed to trim video: {str(e)}") from e
307
-
308
- # --- BaseMoonvalleyVideoNode ---
309
- class BaseMoonvalleyVideoNode:
310
- def parseWidthHeightFromRes(self, resolution: str):
311
- # Accepts a string like "16:9 (1920 x 1080)" and returns width, height as a dict
312
- res_map = {
313
- "16:9 (1920 x 1080)": {"width": 1920, "height": 1080},
314
- "9:16 (1080 x 1920)": {"width": 1080, "height": 1920},
315
- "1:1 (1152 x 1152)": {"width": 1152, "height": 1152},
316
- "4:3 (1440 x 1080)": {"width": 1440, "height": 1080},
317
- "3:4 (1080 x 1440)": {"width": 1080, "height": 1440},
318
- "21:9 (2560 x 1080)": {"width": 2560, "height": 1080},
319
- }
320
- if resolution in res_map:
321
- return res_map[resolution]
322
- else:
323
- # Default to 1920x1080 if unknown
324
- return {"width": 1920, "height": 1080}
325
-
326
- def parseControlParameter(self, value):
327
- control_map = {
328
- "Motion Transfer": "motion_control",
329
- "Canny": "canny_control",
330
- "Pose Transfer": "pose_control",
331
- "Depth": "depth_control"
332
- }
333
- if value in control_map:
334
- return control_map[value]
335
- else:
336
- return control_map["Motion Transfer"]
337
-
338
- def get_response(
339
- self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
340
- ) -> MoonvalleyPromptResponse:
341
- return poll_until_finished(
342
- auth_kwargs,
343
- ApiEndpoint(
344
- path=f"{API_PROMPTS_ENDPOINT}/{task_id}",
345
- method=HttpMethod.GET,
346
- request_model=EmptyRequest,
347
- response_model=MoonvalleyPromptResponse,
348
- ),
349
- result_url_extractor=get_video_url_from_response,
350
- node_id=node_id,
351
- )
352
-
353
- @classmethod
354
- def INPUT_TYPES(cls):
355
- return {
356
- "required": {
357
- "prompt": model_field_to_node_input(
358
- IO.STRING, MoonvalleyTextToVideoRequest, "prompt_text",
359
- multiline=True
360
- ),
361
- "negative_prompt": model_field_to_node_input(
362
- IO.STRING,
363
- MoonvalleyTextToVideoInferenceParams,
364
- "negative_prompt",
365
- multiline=True,
366
- default="gopro, bright, contrast, static, overexposed, bright, vignette, artifacts, still, noise, texture, scanlines, videogame, 360 camera, VR, transition, flare, saturation, distorted, warped, wide angle, contrast, saturated, vibrant, glowing, cross dissolve, texture, videogame, saturation, cheesy, ugly hands, mutated hands, mutant, disfigured, extra fingers, blown out, horrible, blurry, worst quality, bad, transition, dissolve, cross-dissolve, melt, fade in, fade out, wobbly, weird, low quality, plastic, stock footage, video camera, boring, static",
367
- ),
368
-
369
- "resolution": (IO.COMBO, {
370
- "options": ["16:9 (1920 x 1080)",
371
- "9:16 (1080 x 1920)",
372
- "1:1 (1152 x 1152)",
373
- "4:3 (1440 x 1080)",
374
- "3:4 (1080 x 1440)",
375
- "21:9 (2560 x 1080)"],
376
- "default": "16:9 (1920 x 1080)",
377
- "tooltip": "Resolution of the output video",
378
- }),
379
- # "length": (IO.COMBO,{"options":['5s','10s'], "default": '5s'}),
380
- "prompt_adherence": model_field_to_node_input(IO.FLOAT,MoonvalleyTextToVideoInferenceParams,"guidance_scale",default=7.0, step=1, min=1, max=20),
381
- "seed": model_field_to_node_input(IO.INT,MoonvalleyTextToVideoInferenceParams, "seed", default=random.randint(0, 2**32 - 1), min=0, max=4294967295, step=1, display="number", tooltip="Random seed value", control_after_generate=True),
382
- "steps": model_field_to_node_input(IO.INT, MoonvalleyTextToVideoInferenceParams, "steps", default=100, min=1, max=100),
383
- },
384
- "hidden": {
385
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
386
- "comfy_api_key": "API_KEY_COMFY_ORG",
387
- "unique_id": "UNIQUE_ID",
388
- },
389
- "optional": {
390
- "image": model_field_to_node_input(
391
- IO.IMAGE,
392
- MoonvalleyTextToVideoRequest,
393
- "image_url",
394
- tooltip="The reference image used to generate the video",
395
- ),
396
- }
397
- }
398
-
399
- RETURN_TYPES = ("STRING",)
400
- FUNCTION = "generate"
401
- CATEGORY = "api node/video/Moonvalley Marey"
402
- API_NODE = True
403
-
404
- def generate(self, **kwargs):
405
- return None
406
-
407
- # --- MoonvalleyImg2VideoNode ---
408
- class MoonvalleyImg2VideoNode(BaseMoonvalleyVideoNode):
409
-
410
- @classmethod
411
- def INPUT_TYPES(cls):
412
- return super().INPUT_TYPES()
413
-
414
- RETURN_TYPES = ("VIDEO",)
415
- RETURN_NAMES = ("video",)
416
- DESCRIPTION = "Moonvalley Marey Image to Video Node"
417
-
418
- def generate(self, prompt, negative_prompt, unique_id: Optional[str] = None, **kwargs):
419
- image = kwargs.get("image", None)
420
- if (image is None):
421
- raise MoonvalleyApiError("image is required")
422
- total_frames = get_total_frames_from_length()
423
-
424
- validate_input_image(image,True)
425
- validate_prompts(prompt, negative_prompt, MOONVALLEY_MAREY_MAX_PROMPT_LENGTH)
426
- width_height = self.parseWidthHeightFromRes(kwargs.get("resolution"))
427
-
428
- inference_params=MoonvalleyTextToVideoInferenceParams(
429
- negative_prompt=negative_prompt,
430
- steps=kwargs.get("steps"),
431
- seed=kwargs.get("seed"),
432
- guidance_scale=kwargs.get("prompt_adherence"),
433
- num_frames=total_frames,
434
- width=width_height.get("width"),
435
- height=width_height.get("height"),
436
- use_negative_prompts=True
437
- )
438
- """Upload image to comfy backend to have a URL available for further processing"""
439
- # Get MIME type from tensor - assuming PNG format for image tensors
440
- mime_type = "image/png"
441
-
442
- image_url = upload_images_to_comfyapi(image, max_images=1, auth_kwargs=kwargs, mime_type=mime_type)[0]
443
-
444
- request = MoonvalleyTextToVideoRequest(
445
- image_url=image_url,
446
- prompt_text=prompt,
447
- inference_params=inference_params
448
- )
449
- initial_operation = SynchronousOperation(
450
- endpoint=ApiEndpoint(path=API_IMG2VIDEO_ENDPOINT,
451
- method=HttpMethod.POST,
452
- request_model=MoonvalleyTextToVideoRequest,
453
- response_model=MoonvalleyPromptResponse
454
- ),
455
- request=request,
456
- auth_kwargs=kwargs,
457
- )
458
- task_creation_response = initial_operation.execute()
459
- validate_task_creation_response(task_creation_response)
460
- task_id = task_creation_response.id
461
-
462
- final_response = self.get_response(
463
- task_id, auth_kwargs=kwargs, node_id=unique_id
464
- )
465
- video = download_url_to_video_output(final_response.output_url)
466
- return (video, )
467
-
468
- # --- MoonvalleyVid2VidNode ---
469
- class MoonvalleyVideo2VideoNode(BaseMoonvalleyVideoNode):
470
- def __init__(self):
471
- super().__init__()
472
-
473
- @classmethod
474
- def INPUT_TYPES(cls):
475
- input_types = super().INPUT_TYPES()
476
- for param in ["resolution", "image"]:
477
- if param in input_types["required"]:
478
- del input_types["required"][param]
479
- if param in input_types["optional"]:
480
- del input_types["optional"][param]
481
- input_types["optional"] = {
482
- "video": (IO.VIDEO, {"default": "", "multiline": False, "tooltip": "The reference video used to generate the output video. Input a 5s video for 128 frames and a 10s video for 256 frames. Longer videos will be trimmed automatically."}),
483
- "control_type": (
484
- ["Motion Transfer", "Pose Transfer"],
485
- {"default": "Motion Transfer"},
486
- ),
487
- "motion_intensity": (
488
- "INT",
489
- {
490
- "default": 100,
491
- "step": 1,
492
- "min": 0,
493
- "max": 100,
494
- "tooltip": "Only used if control_type is 'Motion Transfer'",
495
- },
496
- )
497
- }
498
-
499
- return input_types
500
-
501
- RETURN_TYPES = ("VIDEO",)
502
- RETURN_NAMES = ("video",)
503
-
504
- def generate(self, prompt, negative_prompt, unique_id: Optional[str] = None, **kwargs):
505
- video = kwargs.get("video")
506
- num_frames = get_total_frames_from_length()
507
-
508
- if not video :
509
- raise MoonvalleyApiError("video is required")
510
-
511
-
512
- """Validate video input"""
513
- video_url=""
514
- if video:
515
- validated_video = validate_input_video(video, num_frames, False)
516
- video_url = upload_video_to_comfyapi(validated_video, auth_kwargs=kwargs)
517
-
518
- control_type = kwargs.get("control_type")
519
- motion_intensity = kwargs.get("motion_intensity")
520
-
521
- """Validate prompts and inference input"""
522
- validate_prompts(prompt, negative_prompt)
523
- inference_params=MoonvalleyVideoToVideoInferenceParams(
524
- negative_prompt=negative_prompt,
525
- steps=kwargs.get("steps"),
526
- seed=kwargs.get("seed"),
527
- guidance_scale=kwargs.get("prompt_adherence"),
528
- control_params={'motion_intensity': motion_intensity}
529
- )
530
-
531
- control = self.parseControlParameter(control_type)
532
-
533
- request = MoonvalleyVideoToVideoRequest(
534
- control_type=control,
535
- video_url=video_url,
536
- prompt_text=prompt,
537
- inference_params=inference_params
538
- )
539
-
540
- initial_operation = SynchronousOperation(
541
- endpoint=ApiEndpoint(path=API_VIDEO2VIDEO_ENDPOINT,
542
- method=HttpMethod.POST,
543
- request_model=MoonvalleyVideoToVideoRequest,
544
- response_model=MoonvalleyPromptResponse
545
- ),
546
- request=request,
547
- auth_kwargs=kwargs,
548
- )
549
- task_creation_response = initial_operation.execute()
550
- validate_task_creation_response(task_creation_response)
551
- task_id = task_creation_response.id
552
-
553
- final_response = self.get_response(
554
- task_id, auth_kwargs=kwargs, node_id=unique_id
555
- )
556
-
557
- video = download_url_to_video_output(final_response.output_url)
558
-
559
- return (video, )
560
-
561
- # --- MoonvalleyTxt2VideoNode ---
562
- class MoonvalleyTxt2VideoNode(BaseMoonvalleyVideoNode):
563
- def __init__(self):
564
- super().__init__()
565
-
566
- RETURN_TYPES = ("VIDEO",)
567
- RETURN_NAMES = ("video",)
568
-
569
- @classmethod
570
- def INPUT_TYPES(cls):
571
- input_types = super().INPUT_TYPES()
572
- # Remove image-specific parameters
573
- for param in ["image"]:
574
- if param in input_types["optional"]:
575
- del input_types["optional"][param]
576
- return input_types
577
-
578
- def generate(self, prompt, negative_prompt, unique_id: Optional[str] = None, **kwargs):
579
- validate_prompts(prompt, negative_prompt, MOONVALLEY_MAREY_MAX_PROMPT_LENGTH)
580
- width_height = self.parseWidthHeightFromRes(kwargs.get("resolution"))
581
- num_frames = get_total_frames_from_length()
582
-
583
- inference_params=MoonvalleyTextToVideoInferenceParams(
584
- negative_prompt=negative_prompt,
585
- steps=kwargs.get("steps"),
586
- seed=kwargs.get("seed"),
587
- guidance_scale=kwargs.get("prompt_adherence"),
588
- num_frames=num_frames,
589
- width=width_height.get("width"),
590
- height=width_height.get("height"),
591
- )
592
- request = MoonvalleyTextToVideoRequest(
593
- prompt_text=prompt,
594
- inference_params=inference_params
595
- )
596
-
597
- initial_operation = SynchronousOperation(
598
- endpoint=ApiEndpoint(path=API_TXT2VIDEO_ENDPOINT,
599
- method=HttpMethod.POST,
600
- request_model=MoonvalleyTextToVideoRequest,
601
- response_model=MoonvalleyPromptResponse
602
- ),
603
- request=request,
604
- auth_kwargs=kwargs,
605
- )
606
- task_creation_response = initial_operation.execute()
607
- validate_task_creation_response(task_creation_response)
608
- task_id = task_creation_response.id
609
-
610
- final_response = self.get_response(
611
- task_id, auth_kwargs=kwargs, node_id=unique_id
612
- )
613
-
614
- video = download_url_to_video_output(final_response.output_url)
615
- return (video, )
616
-
617
-
618
-
619
- NODE_CLASS_MAPPINGS = {
620
- "MoonvalleyImg2VideoNode": MoonvalleyImg2VideoNode,
621
- "MoonvalleyTxt2VideoNode": MoonvalleyTxt2VideoNode,
622
- # "MoonvalleyVideo2VideoNode": MoonvalleyVideo2VideoNode,
623
- }
624
-
625
-
626
- NODE_DISPLAY_NAME_MAPPINGS = {
627
- "MoonvalleyImg2VideoNode": "Moonvalley Marey Image to Video",
628
- "MoonvalleyTxt2VideoNode": "Moonvalley Marey Text to Video",
629
- # "MoonvalleyVideo2VideoNode": "Moonvalley Marey Video to Video",
630
- }
631
-
632
- def get_total_frames_from_length(length="5s"):
633
- # if length == '5s':
634
- # return 128
635
- # elif length == '10s':
636
- # return 256
637
- return 128
638
- # else:
639
- # raise MoonvalleyApiError("length is required")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/nodes_openai.py DELETED
@@ -1,1008 +0,0 @@
1
- import io
2
- from typing import TypedDict, Optional
3
- import json
4
- import os
5
- import time
6
- import re
7
- import uuid
8
- from enum import Enum
9
- from inspect import cleandoc
10
- import numpy as np
11
- import torch
12
- from PIL import Image
13
- from comfy.comfy_types.node_typing import IO, ComfyNodeABC, InputTypeDict
14
- from server import PromptServer
15
- import folder_paths
16
-
17
-
18
- from comfy_api_nodes.apis import (
19
- OpenAIImageGenerationRequest,
20
- OpenAIImageEditRequest,
21
- OpenAIImageGenerationResponse,
22
- OpenAICreateResponse,
23
- OpenAIResponse,
24
- CreateModelResponseProperties,
25
- Item,
26
- Includable,
27
- OutputContent,
28
- InputImageContent,
29
- Detail,
30
- InputTextContent,
31
- InputMessage,
32
- InputMessageContentList,
33
- InputContent,
34
- InputFileContent,
35
- )
36
-
37
- from comfy_api_nodes.apis.client import (
38
- ApiEndpoint,
39
- HttpMethod,
40
- SynchronousOperation,
41
- PollingOperation,
42
- EmptyRequest,
43
- )
44
-
45
- from comfy_api_nodes.apinode_utils import (
46
- downscale_image_tensor,
47
- validate_and_cast_response,
48
- validate_string,
49
- tensor_to_base64_string,
50
- text_filepath_to_data_uri,
51
- )
52
- from comfy_api_nodes.mapper_utils import model_field_to_node_input
53
-
54
-
55
- RESPONSES_ENDPOINT = "/proxy/openai/v1/responses"
56
- STARTING_POINT_ID_PATTERN = r"<starting_point_id:(.*)>"
57
-
58
-
59
- class HistoryEntry(TypedDict):
60
- """Type definition for a single history entry in the chat."""
61
-
62
- prompt: str
63
- response: str
64
- response_id: str
65
- timestamp: float
66
-
67
-
68
- class ChatHistory(TypedDict):
69
- """Type definition for the chat history dictionary."""
70
-
71
- __annotations__: dict[str, list[HistoryEntry]]
72
-
73
-
74
- class SupportedOpenAIModel(str, Enum):
75
- o4_mini = "o4-mini"
76
- o1 = "o1"
77
- o3 = "o3"
78
- o1_pro = "o1-pro"
79
- gpt_4o = "gpt-4o"
80
- gpt_4_1 = "gpt-4.1"
81
- gpt_4_1_mini = "gpt-4.1-mini"
82
- gpt_4_1_nano = "gpt-4.1-nano"
83
-
84
-
85
- class OpenAIDalle2(ComfyNodeABC):
86
- """
87
- Generates images synchronously via OpenAI's DALL·E 2 endpoint.
88
- """
89
-
90
- def __init__(self):
91
- pass
92
-
93
- @classmethod
94
- def INPUT_TYPES(cls) -> InputTypeDict:
95
- return {
96
- "required": {
97
- "prompt": (
98
- IO.STRING,
99
- {
100
- "multiline": True,
101
- "default": "",
102
- "tooltip": "Text prompt for DALL·E",
103
- },
104
- ),
105
- },
106
- "optional": {
107
- "seed": (
108
- IO.INT,
109
- {
110
- "default": 0,
111
- "min": 0,
112
- "max": 2**31 - 1,
113
- "step": 1,
114
- "display": "number",
115
- "control_after_generate": True,
116
- "tooltip": "not implemented yet in backend",
117
- },
118
- ),
119
- "size": (
120
- IO.COMBO,
121
- {
122
- "options": ["256x256", "512x512", "1024x1024"],
123
- "default": "1024x1024",
124
- "tooltip": "Image size",
125
- },
126
- ),
127
- "n": (
128
- IO.INT,
129
- {
130
- "default": 1,
131
- "min": 1,
132
- "max": 8,
133
- "step": 1,
134
- "display": "number",
135
- "tooltip": "How many images to generate",
136
- },
137
- ),
138
- "image": (
139
- IO.IMAGE,
140
- {
141
- "default": None,
142
- "tooltip": "Optional reference image for image editing.",
143
- },
144
- ),
145
- "mask": (
146
- IO.MASK,
147
- {
148
- "default": None,
149
- "tooltip": "Optional mask for inpainting (white areas will be replaced)",
150
- },
151
- ),
152
- },
153
- "hidden": {
154
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
155
- "comfy_api_key": "API_KEY_COMFY_ORG",
156
- "unique_id": "UNIQUE_ID",
157
- },
158
- }
159
-
160
- RETURN_TYPES = (IO.IMAGE,)
161
- FUNCTION = "api_call"
162
- CATEGORY = "api node/image/OpenAI"
163
- DESCRIPTION = cleandoc(__doc__ or "")
164
- API_NODE = True
165
-
166
- def api_call(
167
- self,
168
- prompt,
169
- seed=0,
170
- image=None,
171
- mask=None,
172
- n=1,
173
- size="1024x1024",
174
- unique_id=None,
175
- **kwargs,
176
- ):
177
- validate_string(prompt, strip_whitespace=False)
178
- model = "dall-e-2"
179
- path = "/proxy/openai/images/generations"
180
- content_type = "application/json"
181
- request_class = OpenAIImageGenerationRequest
182
- img_binary = None
183
-
184
- if image is not None and mask is not None:
185
- path = "/proxy/openai/images/edits"
186
- content_type = "multipart/form-data"
187
- request_class = OpenAIImageEditRequest
188
-
189
- input_tensor = image.squeeze().cpu()
190
- height, width, channels = input_tensor.shape
191
- rgba_tensor = torch.ones(height, width, 4, device="cpu")
192
- rgba_tensor[:, :, :channels] = input_tensor
193
-
194
- if mask.shape[1:] != image.shape[1:-1]:
195
- raise Exception("Mask and Image must be the same size")
196
- rgba_tensor[:, :, 3] = 1 - mask.squeeze().cpu()
197
-
198
- rgba_tensor = downscale_image_tensor(rgba_tensor.unsqueeze(0)).squeeze()
199
-
200
- image_np = (rgba_tensor.numpy() * 255).astype(np.uint8)
201
- img = Image.fromarray(image_np)
202
- img_byte_arr = io.BytesIO()
203
- img.save(img_byte_arr, format="PNG")
204
- img_byte_arr.seek(0)
205
- img_binary = img_byte_arr # .getvalue()
206
- img_binary.name = "image.png"
207
- elif image is not None or mask is not None:
208
- raise Exception("Dall-E 2 image editing requires an image AND a mask")
209
-
210
- # Build the operation
211
- operation = SynchronousOperation(
212
- endpoint=ApiEndpoint(
213
- path=path,
214
- method=HttpMethod.POST,
215
- request_model=request_class,
216
- response_model=OpenAIImageGenerationResponse,
217
- ),
218
- request=request_class(
219
- model=model,
220
- prompt=prompt,
221
- n=n,
222
- size=size,
223
- seed=seed,
224
- ),
225
- files=(
226
- {
227
- "image": img_binary,
228
- }
229
- if img_binary
230
- else None
231
- ),
232
- content_type=content_type,
233
- auth_kwargs=kwargs,
234
- )
235
-
236
- response = operation.execute()
237
-
238
- img_tensor = validate_and_cast_response(response, node_id=unique_id)
239
- return (img_tensor,)
240
-
241
-
242
- class OpenAIDalle3(ComfyNodeABC):
243
- """
244
- Generates images synchronously via OpenAI's DALL·E 3 endpoint.
245
- """
246
-
247
- def __init__(self):
248
- pass
249
-
250
- @classmethod
251
- def INPUT_TYPES(cls) -> InputTypeDict:
252
- return {
253
- "required": {
254
- "prompt": (
255
- IO.STRING,
256
- {
257
- "multiline": True,
258
- "default": "",
259
- "tooltip": "Text prompt for DALL·E",
260
- },
261
- ),
262
- },
263
- "optional": {
264
- "seed": (
265
- IO.INT,
266
- {
267
- "default": 0,
268
- "min": 0,
269
- "max": 2**31 - 1,
270
- "step": 1,
271
- "display": "number",
272
- "control_after_generate": True,
273
- "tooltip": "not implemented yet in backend",
274
- },
275
- ),
276
- "quality": (
277
- IO.COMBO,
278
- {
279
- "options": ["standard", "hd"],
280
- "default": "standard",
281
- "tooltip": "Image quality",
282
- },
283
- ),
284
- "style": (
285
- IO.COMBO,
286
- {
287
- "options": ["natural", "vivid"],
288
- "default": "natural",
289
- "tooltip": "Vivid causes the model to lean towards generating hyper-real and dramatic images. Natural causes the model to produce more natural, less hyper-real looking images.",
290
- },
291
- ),
292
- "size": (
293
- IO.COMBO,
294
- {
295
- "options": ["1024x1024", "1024x1792", "1792x1024"],
296
- "default": "1024x1024",
297
- "tooltip": "Image size",
298
- },
299
- ),
300
- },
301
- "hidden": {
302
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
303
- "comfy_api_key": "API_KEY_COMFY_ORG",
304
- "unique_id": "UNIQUE_ID",
305
- },
306
- }
307
-
308
- RETURN_TYPES = (IO.IMAGE,)
309
- FUNCTION = "api_call"
310
- CATEGORY = "api node/image/OpenAI"
311
- DESCRIPTION = cleandoc(__doc__ or "")
312
- API_NODE = True
313
-
314
- def api_call(
315
- self,
316
- prompt,
317
- seed=0,
318
- style="natural",
319
- quality="standard",
320
- size="1024x1024",
321
- unique_id=None,
322
- **kwargs,
323
- ):
324
- validate_string(prompt, strip_whitespace=False)
325
- model = "dall-e-3"
326
-
327
- # build the operation
328
- operation = SynchronousOperation(
329
- endpoint=ApiEndpoint(
330
- path="/proxy/openai/images/generations",
331
- method=HttpMethod.POST,
332
- request_model=OpenAIImageGenerationRequest,
333
- response_model=OpenAIImageGenerationResponse,
334
- ),
335
- request=OpenAIImageGenerationRequest(
336
- model=model,
337
- prompt=prompt,
338
- quality=quality,
339
- size=size,
340
- style=style,
341
- seed=seed,
342
- ),
343
- auth_kwargs=kwargs,
344
- )
345
-
346
- response = operation.execute()
347
-
348
- img_tensor = validate_and_cast_response(response, node_id=unique_id)
349
- return (img_tensor,)
350
-
351
-
352
- class OpenAIGPTImage1(ComfyNodeABC):
353
- """
354
- Generates images synchronously via OpenAI's GPT Image 1 endpoint.
355
- """
356
-
357
- def __init__(self):
358
- pass
359
-
360
- @classmethod
361
- def INPUT_TYPES(cls) -> InputTypeDict:
362
- return {
363
- "required": {
364
- "prompt": (
365
- IO.STRING,
366
- {
367
- "multiline": True,
368
- "default": "",
369
- "tooltip": "Text prompt for GPT Image 1",
370
- },
371
- ),
372
- },
373
- "optional": {
374
- "seed": (
375
- IO.INT,
376
- {
377
- "default": 0,
378
- "min": 0,
379
- "max": 2**31 - 1,
380
- "step": 1,
381
- "display": "number",
382
- "control_after_generate": True,
383
- "tooltip": "not implemented yet in backend",
384
- },
385
- ),
386
- "quality": (
387
- IO.COMBO,
388
- {
389
- "options": ["low", "medium", "high"],
390
- "default": "low",
391
- "tooltip": "Image quality, affects cost and generation time.",
392
- },
393
- ),
394
- "background": (
395
- IO.COMBO,
396
- {
397
- "options": ["opaque", "transparent"],
398
- "default": "opaque",
399
- "tooltip": "Return image with or without background",
400
- },
401
- ),
402
- "size": (
403
- IO.COMBO,
404
- {
405
- "options": ["auto", "1024x1024", "1024x1536", "1536x1024"],
406
- "default": "auto",
407
- "tooltip": "Image size",
408
- },
409
- ),
410
- "n": (
411
- IO.INT,
412
- {
413
- "default": 1,
414
- "min": 1,
415
- "max": 8,
416
- "step": 1,
417
- "display": "number",
418
- "tooltip": "How many images to generate",
419
- },
420
- ),
421
- "image": (
422
- IO.IMAGE,
423
- {
424
- "default": None,
425
- "tooltip": "Optional reference image for image editing.",
426
- },
427
- ),
428
- "mask": (
429
- IO.MASK,
430
- {
431
- "default": None,
432
- "tooltip": "Optional mask for inpainting (white areas will be replaced)",
433
- },
434
- ),
435
- },
436
- "hidden": {
437
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
438
- "comfy_api_key": "API_KEY_COMFY_ORG",
439
- "unique_id": "UNIQUE_ID",
440
- },
441
- }
442
-
443
- RETURN_TYPES = (IO.IMAGE,)
444
- FUNCTION = "api_call"
445
- CATEGORY = "api node/image/OpenAI"
446
- DESCRIPTION = cleandoc(__doc__ or "")
447
- API_NODE = True
448
-
449
- def api_call(
450
- self,
451
- prompt,
452
- seed=0,
453
- quality="low",
454
- background="opaque",
455
- image=None,
456
- mask=None,
457
- n=1,
458
- size="1024x1024",
459
- unique_id=None,
460
- **kwargs,
461
- ):
462
- validate_string(prompt, strip_whitespace=False)
463
- model = "gpt-image-1"
464
- path = "/proxy/openai/images/generations"
465
- content_type = "application/json"
466
- request_class = OpenAIImageGenerationRequest
467
- img_binaries = []
468
- mask_binary = None
469
- files = []
470
-
471
- if image is not None:
472
- path = "/proxy/openai/images/edits"
473
- request_class = OpenAIImageEditRequest
474
- content_type = "multipart/form-data"
475
-
476
- batch_size = image.shape[0]
477
-
478
- for i in range(batch_size):
479
- single_image = image[i : i + 1]
480
- scaled_image = downscale_image_tensor(single_image).squeeze()
481
-
482
- image_np = (scaled_image.numpy() * 255).astype(np.uint8)
483
- img = Image.fromarray(image_np)
484
- img_byte_arr = io.BytesIO()
485
- img.save(img_byte_arr, format="PNG")
486
- img_byte_arr.seek(0)
487
- img_binary = img_byte_arr
488
- img_binary.name = f"image_{i}.png"
489
-
490
- img_binaries.append(img_binary)
491
- if batch_size == 1:
492
- files.append(("image", img_binary))
493
- else:
494
- files.append(("image[]", img_binary))
495
-
496
- if mask is not None:
497
- if image is None:
498
- raise Exception("Cannot use a mask without an input image")
499
- if image.shape[0] != 1:
500
- raise Exception("Cannot use a mask with multiple image")
501
- if mask.shape[1:] != image.shape[1:-1]:
502
- raise Exception("Mask and Image must be the same size")
503
- batch, height, width = mask.shape
504
- rgba_mask = torch.zeros(height, width, 4, device="cpu")
505
- rgba_mask[:, :, 3] = 1 - mask.squeeze().cpu()
506
-
507
- scaled_mask = downscale_image_tensor(rgba_mask.unsqueeze(0)).squeeze()
508
-
509
- mask_np = (scaled_mask.numpy() * 255).astype(np.uint8)
510
- mask_img = Image.fromarray(mask_np)
511
- mask_img_byte_arr = io.BytesIO()
512
- mask_img.save(mask_img_byte_arr, format="PNG")
513
- mask_img_byte_arr.seek(0)
514
- mask_binary = mask_img_byte_arr
515
- mask_binary.name = "mask.png"
516
- files.append(("mask", mask_binary))
517
-
518
- # Build the operation
519
- operation = SynchronousOperation(
520
- endpoint=ApiEndpoint(
521
- path=path,
522
- method=HttpMethod.POST,
523
- request_model=request_class,
524
- response_model=OpenAIImageGenerationResponse,
525
- ),
526
- request=request_class(
527
- model=model,
528
- prompt=prompt,
529
- quality=quality,
530
- background=background,
531
- n=n,
532
- seed=seed,
533
- size=size,
534
- ),
535
- files=files if files else None,
536
- content_type=content_type,
537
- auth_kwargs=kwargs,
538
- )
539
-
540
- response = operation.execute()
541
-
542
- img_tensor = validate_and_cast_response(response, node_id=unique_id)
543
- return (img_tensor,)
544
-
545
-
546
- class OpenAITextNode(ComfyNodeABC):
547
- """
548
- Base class for OpenAI text generation nodes.
549
- """
550
-
551
- RETURN_TYPES = (IO.STRING,)
552
- FUNCTION = "api_call"
553
- CATEGORY = "api node/text/OpenAI"
554
- API_NODE = True
555
-
556
-
557
- class OpenAIChatNode(OpenAITextNode):
558
- """
559
- Node to generate text responses from an OpenAI model.
560
- """
561
-
562
- def __init__(self) -> None:
563
- """Initialize the chat node with a new session ID and empty history."""
564
- self.current_session_id: str = str(uuid.uuid4())
565
- self.history: dict[str, list[HistoryEntry]] = {}
566
- self.previous_response_id: Optional[str] = None
567
-
568
- @classmethod
569
- def INPUT_TYPES(cls) -> InputTypeDict:
570
- return {
571
- "required": {
572
- "prompt": (
573
- IO.STRING,
574
- {
575
- "multiline": True,
576
- "default": "",
577
- "tooltip": "Text inputs to the model, used to generate a response.",
578
- },
579
- ),
580
- "persist_context": (
581
- IO.BOOLEAN,
582
- {
583
- "default": True,
584
- "tooltip": "Persist chat context between calls (multi-turn conversation)",
585
- },
586
- ),
587
- "model": model_field_to_node_input(
588
- IO.COMBO,
589
- OpenAICreateResponse,
590
- "model",
591
- enum_type=SupportedOpenAIModel,
592
- ),
593
- },
594
- "optional": {
595
- "images": (
596
- IO.IMAGE,
597
- {
598
- "default": None,
599
- "tooltip": "Optional image(s) to use as context for the model. To include multiple images, you can use the Batch Images node.",
600
- },
601
- ),
602
- "files": (
603
- "OPENAI_INPUT_FILES",
604
- {
605
- "default": None,
606
- "tooltip": "Optional file(s) to use as context for the model. Accepts inputs from the OpenAI Chat Input Files node.",
607
- },
608
- ),
609
- "advanced_options": (
610
- "OPENAI_CHAT_CONFIG",
611
- {
612
- "default": None,
613
- "tooltip": "Optional configuration for the model. Accepts inputs from the OpenAI Chat Advanced Options node.",
614
- },
615
- ),
616
- },
617
- "hidden": {
618
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
619
- "comfy_api_key": "API_KEY_COMFY_ORG",
620
- "unique_id": "UNIQUE_ID",
621
- },
622
- }
623
-
624
- DESCRIPTION = "Generate text responses from an OpenAI model."
625
-
626
- def get_result_response(
627
- self,
628
- response_id: str,
629
- include: Optional[list[Includable]] = None,
630
- auth_kwargs: Optional[dict[str, str]] = None,
631
- ) -> OpenAIResponse:
632
- """
633
- Retrieve a model response with the given ID from the OpenAI API.
634
-
635
- Args:
636
- response_id (str): The ID of the response to retrieve.
637
- include (Optional[List[Includable]]): Additional fields to include
638
- in the response. See the `include` parameter for Response
639
- creation above for more information.
640
-
641
- """
642
- return PollingOperation(
643
- poll_endpoint=ApiEndpoint(
644
- path=f"{RESPONSES_ENDPOINT}/{response_id}",
645
- method=HttpMethod.GET,
646
- request_model=EmptyRequest,
647
- response_model=OpenAIResponse,
648
- query_params={"include": include},
649
- ),
650
- completed_statuses=["completed"],
651
- failed_statuses=["failed"],
652
- status_extractor=lambda response: response.status,
653
- auth_kwargs=auth_kwargs,
654
- ).execute()
655
-
656
- def get_message_content_from_response(
657
- self, response: OpenAIResponse
658
- ) -> list[OutputContent]:
659
- """Extract message content from the API response."""
660
- for output in response.output:
661
- if output.root.type == "message":
662
- return output.root.content
663
- raise TypeError("No output message found in response")
664
-
665
- def get_text_from_message_content(
666
- self, message_content: list[OutputContent]
667
- ) -> str:
668
- """Extract text content from message content."""
669
- for content_item in message_content:
670
- if content_item.root.type == "output_text":
671
- return str(content_item.root.text)
672
- return "No text output found in response"
673
-
674
- def get_history_text(self, session_id: str) -> str:
675
- """Convert the entire history for a given session to JSON string."""
676
- return json.dumps(self.history[session_id])
677
-
678
- def display_history_on_node(self, session_id: str, node_id: str) -> None:
679
- """Display formatted chat history on the node UI."""
680
- render_spec = {
681
- "node_id": node_id,
682
- "component": "ChatHistoryWidget",
683
- "props": {
684
- "history": self.get_history_text(session_id),
685
- },
686
- }
687
- PromptServer.instance.send_sync(
688
- "display_component",
689
- render_spec,
690
- )
691
-
692
- def add_to_history(
693
- self, session_id: str, prompt: str, output_text: str, response_id: str
694
- ) -> None:
695
- """Add a new entry to the chat history."""
696
- if session_id not in self.history:
697
- self.history[session_id] = []
698
- self.history[session_id].append(
699
- {
700
- "prompt": prompt,
701
- "response": output_text,
702
- "response_id": response_id,
703
- "timestamp": time.time(),
704
- }
705
- )
706
-
707
- def parse_output_text_from_response(self, response: OpenAIResponse) -> str:
708
- """Extract text output from the API response."""
709
- message_contents = self.get_message_content_from_response(response)
710
- return self.get_text_from_message_content(message_contents)
711
-
712
- def generate_new_session_id(self) -> str:
713
- """Generate a new unique session ID."""
714
- return str(uuid.uuid4())
715
-
716
- def get_session_id(self, persist_context: bool) -> str:
717
- """Get the current or generate a new session ID based on context persistence."""
718
- return (
719
- self.current_session_id
720
- if persist_context
721
- else self.generate_new_session_id()
722
- )
723
-
724
- def tensor_to_input_image_content(
725
- self, image: torch.Tensor, detail_level: Detail = "auto"
726
- ) -> InputImageContent:
727
- """Convert a tensor to an input image content object."""
728
- return InputImageContent(
729
- detail=detail_level,
730
- image_url=f"data:image/png;base64,{tensor_to_base64_string(image)}",
731
- type="input_image",
732
- )
733
-
734
- def create_input_message_contents(
735
- self,
736
- prompt: str,
737
- image: Optional[torch.Tensor] = None,
738
- files: Optional[list[InputFileContent]] = None,
739
- ) -> InputMessageContentList:
740
- """Create a list of input message contents from prompt and optional image."""
741
- content_list: list[InputContent] = [
742
- InputTextContent(text=prompt, type="input_text"),
743
- ]
744
- if image is not None:
745
- for i in range(image.shape[0]):
746
- content_list.append(
747
- self.tensor_to_input_image_content(image[i].unsqueeze(0))
748
- )
749
- if files is not None:
750
- content_list.extend(files)
751
-
752
- return InputMessageContentList(
753
- root=content_list,
754
- )
755
-
756
- def parse_response_id_from_prompt(self, prompt: str) -> Optional[str]:
757
- """Extract response ID from prompt if it exists."""
758
- parsed_id = re.search(STARTING_POINT_ID_PATTERN, prompt)
759
- return parsed_id.group(1) if parsed_id else None
760
-
761
- def strip_response_tag_from_prompt(self, prompt: str) -> str:
762
- """Remove the response ID tag from the prompt."""
763
- return re.sub(STARTING_POINT_ID_PATTERN, "", prompt.strip())
764
-
765
- def delete_history_after_response_id(
766
- self, new_start_id: str, session_id: str
767
- ) -> None:
768
- """Delete history entries after a specific response ID."""
769
- if session_id not in self.history:
770
- return
771
-
772
- new_history = []
773
- i = 0
774
- while (
775
- i < len(self.history[session_id])
776
- and self.history[session_id][i]["response_id"] != new_start_id
777
- ):
778
- new_history.append(self.history[session_id][i])
779
- i += 1
780
-
781
- # Since it's the new starting point (not the response being edited), we include it as well
782
- if i < len(self.history[session_id]):
783
- new_history.append(self.history[session_id][i])
784
-
785
- self.history[session_id] = new_history
786
-
787
- def api_call(
788
- self,
789
- prompt: str,
790
- persist_context: bool,
791
- model: SupportedOpenAIModel,
792
- unique_id: Optional[str] = None,
793
- images: Optional[torch.Tensor] = None,
794
- files: Optional[list[InputFileContent]] = None,
795
- advanced_options: Optional[CreateModelResponseProperties] = None,
796
- **kwargs,
797
- ) -> tuple[str]:
798
- # Validate inputs
799
- validate_string(prompt, strip_whitespace=False)
800
-
801
- session_id = self.get_session_id(persist_context)
802
- response_id_override = self.parse_response_id_from_prompt(prompt)
803
- if response_id_override:
804
- is_starting_from_beginning = response_id_override == "start"
805
- if is_starting_from_beginning:
806
- self.history[session_id] = []
807
- previous_response_id = None
808
- else:
809
- previous_response_id = response_id_override
810
- self.delete_history_after_response_id(response_id_override, session_id)
811
- prompt = self.strip_response_tag_from_prompt(prompt)
812
- elif persist_context:
813
- previous_response_id = self.previous_response_id
814
- else:
815
- previous_response_id = None
816
-
817
- # Create response
818
- create_response = SynchronousOperation(
819
- endpoint=ApiEndpoint(
820
- path=RESPONSES_ENDPOINT,
821
- method=HttpMethod.POST,
822
- request_model=OpenAICreateResponse,
823
- response_model=OpenAIResponse,
824
- ),
825
- request=OpenAICreateResponse(
826
- input=[
827
- Item(
828
- root=InputMessage(
829
- content=self.create_input_message_contents(
830
- prompt, images, files
831
- ),
832
- role="user",
833
- )
834
- ),
835
- ],
836
- store=True,
837
- stream=False,
838
- model=model,
839
- previous_response_id=previous_response_id,
840
- **(
841
- advanced_options.model_dump(exclude_none=True)
842
- if advanced_options
843
- else {}
844
- ),
845
- ),
846
- auth_kwargs=kwargs,
847
- ).execute()
848
- response_id = create_response.id
849
-
850
- # Get result output
851
- result_response = self.get_result_response(response_id, auth_kwargs=kwargs)
852
- output_text = self.parse_output_text_from_response(result_response)
853
-
854
- # Update history
855
- self.add_to_history(session_id, prompt, output_text, response_id)
856
- self.display_history_on_node(session_id, unique_id)
857
- self.previous_response_id = response_id
858
-
859
- return (output_text,)
860
-
861
-
862
- class OpenAIInputFiles(ComfyNodeABC):
863
- """
864
- Loads and formats input files for OpenAI API.
865
- """
866
-
867
- @classmethod
868
- def INPUT_TYPES(cls) -> InputTypeDict:
869
- """
870
- For details about the supported file input types, see:
871
- https://platform.openai.com/docs/guides/pdf-files?api-mode=responses
872
- """
873
- input_dir = folder_paths.get_input_directory()
874
- input_files = [
875
- f
876
- for f in os.scandir(input_dir)
877
- if f.is_file()
878
- and (f.name.endswith(".txt") or f.name.endswith(".pdf"))
879
- and f.stat().st_size < 32 * 1024 * 1024
880
- ]
881
- input_files = sorted(input_files, key=lambda x: x.name)
882
- input_files = [f.name for f in input_files]
883
- return {
884
- "required": {
885
- "file": (
886
- IO.COMBO,
887
- {
888
- "tooltip": "Input files to include as context for the model. Only accepts text (.txt) and PDF (.pdf) files for now.",
889
- "options": input_files,
890
- "default": input_files[0] if input_files else None,
891
- },
892
- ),
893
- },
894
- "optional": {
895
- "OPENAI_INPUT_FILES": (
896
- "OPENAI_INPUT_FILES",
897
- {
898
- "tooltip": "An optional additional file(s) to batch together with the file loaded from this node. Allows chaining of input files so that a single message can include multiple input files.",
899
- "default": None,
900
- },
901
- ),
902
- },
903
- }
904
-
905
- DESCRIPTION = "Loads and prepares input files (text, pdf, etc.) to include as inputs for the OpenAI Chat Node. The files will be read by the OpenAI model when generating a response. 🛈 TIP: Can be chained together with other OpenAI Input File nodes."
906
- RETURN_TYPES = ("OPENAI_INPUT_FILES",)
907
- FUNCTION = "prepare_files"
908
- CATEGORY = "api node/text/OpenAI"
909
-
910
- def create_input_file_content(self, file_path: str) -> InputFileContent:
911
- return InputFileContent(
912
- file_data=text_filepath_to_data_uri(file_path),
913
- filename=os.path.basename(file_path),
914
- type="input_file",
915
- )
916
-
917
- def prepare_files(
918
- self, file: str, OPENAI_INPUT_FILES: list[InputFileContent] = []
919
- ) -> tuple[list[InputFileContent]]:
920
- """
921
- Loads and formats input files for OpenAI API.
922
- """
923
- file_path = folder_paths.get_annotated_filepath(file)
924
- input_file_content = self.create_input_file_content(file_path)
925
- files = [input_file_content] + OPENAI_INPUT_FILES
926
- return (files,)
927
-
928
-
929
- class OpenAIChatConfig(ComfyNodeABC):
930
- """Allows setting additional configuration for the OpenAI Chat Node."""
931
-
932
- RETURN_TYPES = ("OPENAI_CHAT_CONFIG",)
933
- FUNCTION = "configure"
934
- DESCRIPTION = (
935
- "Allows specifying advanced configuration options for the OpenAI Chat Nodes."
936
- )
937
- CATEGORY = "api node/text/OpenAI"
938
-
939
- @classmethod
940
- def INPUT_TYPES(cls) -> InputTypeDict:
941
- return {
942
- "required": {
943
- "truncation": (
944
- IO.COMBO,
945
- {
946
- "options": ["auto", "disabled"],
947
- "default": "auto",
948
- "tooltip": "The truncation strategy to use for the model response. auto: If the context of this response and previous ones exceeds the model's context window size, the model will truncate the response to fit the context window by dropping input items in the middle of the conversation.disabled: If a model response will exceed the context window size for a model, the request will fail with a 400 error",
949
- },
950
- ),
951
- },
952
- "optional": {
953
- "max_output_tokens": model_field_to_node_input(
954
- IO.INT,
955
- OpenAICreateResponse,
956
- "max_output_tokens",
957
- min=16,
958
- default=4096,
959
- max=16384,
960
- tooltip="An upper bound for the number of tokens that can be generated for a response, including visible output tokens",
961
- ),
962
- "instructions": model_field_to_node_input(
963
- IO.STRING, OpenAICreateResponse, "instructions", multiline=True
964
- ),
965
- },
966
- }
967
-
968
- def configure(
969
- self,
970
- truncation: bool,
971
- instructions: Optional[str] = None,
972
- max_output_tokens: Optional[int] = None,
973
- ) -> tuple[CreateModelResponseProperties]:
974
- """
975
- Configure advanced options for the OpenAI Chat Node.
976
-
977
- Note:
978
- While `top_p` and `temperature` are listed as properties in the
979
- spec, they are not supported for all models (e.g., o4-mini).
980
- They are not exposed as inputs at all to avoid having to manually
981
- remove depending on model choice.
982
- """
983
- return (
984
- CreateModelResponseProperties(
985
- instructions=instructions,
986
- truncation=truncation,
987
- max_output_tokens=max_output_tokens,
988
- ),
989
- )
990
-
991
-
992
- NODE_CLASS_MAPPINGS = {
993
- "OpenAIDalle2": OpenAIDalle2,
994
- "OpenAIDalle3": OpenAIDalle3,
995
- "OpenAIGPTImage1": OpenAIGPTImage1,
996
- "OpenAIChatNode": OpenAIChatNode,
997
- "OpenAIInputFiles": OpenAIInputFiles,
998
- "OpenAIChatConfig": OpenAIChatConfig,
999
- }
1000
-
1001
- NODE_DISPLAY_NAME_MAPPINGS = {
1002
- "OpenAIDalle2": "OpenAI DALL·E 2",
1003
- "OpenAIDalle3": "OpenAI DALL·E 3",
1004
- "OpenAIGPTImage1": "OpenAI GPT Image 1",
1005
- "OpenAIChatNode": "OpenAI Chat",
1006
- "OpenAIInputFiles": "OpenAI Chat Input Files",
1007
- "OpenAIChatConfig": "OpenAI Chat Advanced Options",
1008
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/nodes_pika.py DELETED
@@ -1,782 +0,0 @@
1
- """
2
- Pika x ComfyUI API Nodes
3
-
4
- Pika API docs: https://pika-827374fb.mintlify.app/api-reference
5
- """
6
- from __future__ import annotations
7
-
8
- import io
9
- import logging
10
- from typing import Optional, TypeVar
11
-
12
- import numpy as np
13
- import torch
14
-
15
- from comfy.comfy_types.node_typing import IO, ComfyNodeABC, InputTypeOptions
16
- from comfy_api.input_impl import VideoFromFile
17
- from comfy_api.input_impl.video_types import VideoCodec, VideoContainer, VideoInput
18
- from comfy_api_nodes.apinode_utils import (
19
- download_url_to_video_output,
20
- tensor_to_bytesio,
21
- )
22
- from comfy_api_nodes.apis import (
23
- IngredientsMode,
24
- PikaBodyGenerate22C2vGenerate22PikascenesPost,
25
- PikaBodyGenerate22I2vGenerate22I2vPost,
26
- PikaBodyGenerate22KeyframeGenerate22PikaframesPost,
27
- PikaBodyGenerate22T2vGenerate22T2vPost,
28
- PikaBodyGeneratePikadditionsGeneratePikadditionsPost,
29
- PikaBodyGeneratePikaffectsGeneratePikaffectsPost,
30
- PikaBodyGeneratePikaswapsGeneratePikaswapsPost,
31
- PikaDurationEnum,
32
- Pikaffect,
33
- PikaGenerateResponse,
34
- PikaResolutionEnum,
35
- PikaVideoResponse,
36
- )
37
- from comfy_api_nodes.apis.client import (
38
- ApiEndpoint,
39
- EmptyRequest,
40
- HttpMethod,
41
- PollingOperation,
42
- SynchronousOperation,
43
- )
44
- from comfy_api_nodes.mapper_utils import model_field_to_node_input
45
-
46
- R = TypeVar("R")
47
-
48
- PATH_PIKADDITIONS = "/proxy/pika/generate/pikadditions"
49
- PATH_PIKASWAPS = "/proxy/pika/generate/pikaswaps"
50
- PATH_PIKAFFECTS = "/proxy/pika/generate/pikaffects"
51
-
52
- PIKA_API_VERSION = "2.2"
53
- PATH_TEXT_TO_VIDEO = f"/proxy/pika/generate/{PIKA_API_VERSION}/t2v"
54
- PATH_IMAGE_TO_VIDEO = f"/proxy/pika/generate/{PIKA_API_VERSION}/i2v"
55
- PATH_PIKAFRAMES = f"/proxy/pika/generate/{PIKA_API_VERSION}/pikaframes"
56
- PATH_PIKASCENES = f"/proxy/pika/generate/{PIKA_API_VERSION}/pikascenes"
57
-
58
- PATH_VIDEO_GET = "/proxy/pika/videos"
59
-
60
-
61
- class PikaApiError(Exception):
62
- """Exception for Pika API errors."""
63
-
64
- pass
65
-
66
-
67
- def is_valid_video_response(response: PikaVideoResponse) -> bool:
68
- """Check if the video response is valid."""
69
- return hasattr(response, "url") and response.url is not None
70
-
71
-
72
- def is_valid_initial_response(response: PikaGenerateResponse) -> bool:
73
- """Check if the initial response is valid."""
74
- return hasattr(response, "video_id") and response.video_id is not None
75
-
76
-
77
- class PikaNodeBase(ComfyNodeABC):
78
- """Base class for Pika nodes."""
79
-
80
- @classmethod
81
- def get_base_inputs_types(
82
- cls, request_model
83
- ) -> dict[str, tuple[IO, InputTypeOptions]]:
84
- """Get the base required inputs types common to all Pika nodes."""
85
- return {
86
- "prompt_text": model_field_to_node_input(
87
- IO.STRING,
88
- request_model,
89
- "promptText",
90
- multiline=True,
91
- ),
92
- "negative_prompt": model_field_to_node_input(
93
- IO.STRING,
94
- request_model,
95
- "negativePrompt",
96
- multiline=True,
97
- ),
98
- "seed": model_field_to_node_input(
99
- IO.INT,
100
- request_model,
101
- "seed",
102
- min=0,
103
- max=0xFFFFFFFF,
104
- control_after_generate=True,
105
- ),
106
- "resolution": model_field_to_node_input(
107
- IO.COMBO,
108
- request_model,
109
- "resolution",
110
- enum_type=PikaResolutionEnum,
111
- ),
112
- "duration": model_field_to_node_input(
113
- IO.COMBO,
114
- request_model,
115
- "duration",
116
- enum_type=PikaDurationEnum,
117
- ),
118
- }
119
-
120
- CATEGORY = "api node/video/Pika"
121
- API_NODE = True
122
- FUNCTION = "api_call"
123
- RETURN_TYPES = ("VIDEO",)
124
-
125
- def poll_for_task_status(
126
- self,
127
- task_id: str,
128
- auth_kwargs: Optional[dict[str, str]] = None,
129
- node_id: Optional[str] = None,
130
- ) -> PikaGenerateResponse:
131
- polling_operation = PollingOperation(
132
- poll_endpoint=ApiEndpoint(
133
- path=f"{PATH_VIDEO_GET}/{task_id}",
134
- method=HttpMethod.GET,
135
- request_model=EmptyRequest,
136
- response_model=PikaVideoResponse,
137
- ),
138
- completed_statuses=[
139
- "finished",
140
- ],
141
- failed_statuses=["failed", "cancelled"],
142
- status_extractor=lambda response: (
143
- response.status.value if response.status else None
144
- ),
145
- progress_extractor=lambda response: (
146
- response.progress if hasattr(response, "progress") else None
147
- ),
148
- auth_kwargs=auth_kwargs,
149
- result_url_extractor=lambda response: (
150
- response.url if hasattr(response, "url") else None
151
- ),
152
- node_id=node_id,
153
- estimated_duration=60
154
- )
155
- return polling_operation.execute()
156
-
157
- def execute_task(
158
- self,
159
- initial_operation: SynchronousOperation[R, PikaGenerateResponse],
160
- auth_kwargs: Optional[dict[str, str]] = None,
161
- node_id: Optional[str] = None,
162
- ) -> tuple[VideoFromFile]:
163
- """Executes the initial operation then polls for the task status until it is completed.
164
-
165
- Args:
166
- initial_operation: The initial operation to execute.
167
- auth_kwargs: The authentication token(s) to use for the API call.
168
-
169
- Returns:
170
- A tuple containing the video file as a VIDEO output.
171
- """
172
- initial_response = initial_operation.execute()
173
- if not is_valid_initial_response(initial_response):
174
- error_msg = f"Pika initial request failed. Code: {initial_response.code}, Message: {initial_response.message}, Data: {initial_response.data}"
175
- logging.error(error_msg)
176
- raise PikaApiError(error_msg)
177
-
178
- task_id = initial_response.video_id
179
- final_response = self.poll_for_task_status(task_id, auth_kwargs)
180
- if not is_valid_video_response(final_response):
181
- error_msg = (
182
- f"Pika task {task_id} succeeded but no video data found in response."
183
- )
184
- logging.error(error_msg)
185
- raise PikaApiError(error_msg)
186
-
187
- video_url = str(final_response.url)
188
- logging.info("Pika task %s succeeded. Video URL: %s", task_id, video_url)
189
-
190
- return (download_url_to_video_output(video_url),)
191
-
192
-
193
- class PikaImageToVideoV2_2(PikaNodeBase):
194
- """Pika 2.2 Image to Video Node."""
195
-
196
- @classmethod
197
- def INPUT_TYPES(cls):
198
- return {
199
- "required": {
200
- "image": (
201
- IO.IMAGE,
202
- {"tooltip": "The image to convert to video"},
203
- ),
204
- **cls.get_base_inputs_types(PikaBodyGenerate22I2vGenerate22I2vPost),
205
- },
206
- "hidden": {
207
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
208
- "comfy_api_key": "API_KEY_COMFY_ORG",
209
- "unique_id": "UNIQUE_ID",
210
- },
211
- }
212
-
213
- DESCRIPTION = "Sends an image and prompt to the Pika API v2.2 to generate a video."
214
-
215
- def api_call(
216
- self,
217
- image: torch.Tensor,
218
- prompt_text: str,
219
- negative_prompt: str,
220
- seed: int,
221
- resolution: str,
222
- duration: int,
223
- unique_id: str,
224
- **kwargs,
225
- ) -> tuple[VideoFromFile]:
226
- # Convert image to BytesIO
227
- image_bytes_io = tensor_to_bytesio(image)
228
- image_bytes_io.seek(0)
229
-
230
- pika_files = {"image": ("image.png", image_bytes_io, "image/png")}
231
-
232
- # Prepare non-file data
233
- pika_request_data = PikaBodyGenerate22I2vGenerate22I2vPost(
234
- promptText=prompt_text,
235
- negativePrompt=negative_prompt,
236
- seed=seed,
237
- resolution=resolution,
238
- duration=duration,
239
- )
240
-
241
- initial_operation = SynchronousOperation(
242
- endpoint=ApiEndpoint(
243
- path=PATH_IMAGE_TO_VIDEO,
244
- method=HttpMethod.POST,
245
- request_model=PikaBodyGenerate22I2vGenerate22I2vPost,
246
- response_model=PikaGenerateResponse,
247
- ),
248
- request=pika_request_data,
249
- files=pika_files,
250
- content_type="multipart/form-data",
251
- auth_kwargs=kwargs,
252
- )
253
-
254
- return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id)
255
-
256
-
257
- class PikaTextToVideoNodeV2_2(PikaNodeBase):
258
- """Pika Text2Video v2.2 Node."""
259
-
260
- @classmethod
261
- def INPUT_TYPES(cls):
262
- return {
263
- "required": {
264
- **cls.get_base_inputs_types(PikaBodyGenerate22T2vGenerate22T2vPost),
265
- "aspect_ratio": model_field_to_node_input(
266
- IO.FLOAT,
267
- PikaBodyGenerate22T2vGenerate22T2vPost,
268
- "aspectRatio",
269
- step=0.001,
270
- min=0.4,
271
- max=2.5,
272
- default=1.7777777777777777,
273
- ),
274
- },
275
- "hidden": {
276
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
277
- "comfy_api_key": "API_KEY_COMFY_ORG",
278
- "unique_id": "UNIQUE_ID",
279
- },
280
- }
281
-
282
- DESCRIPTION = "Sends a text prompt to the Pika API v2.2 to generate a video."
283
-
284
- def api_call(
285
- self,
286
- prompt_text: str,
287
- negative_prompt: str,
288
- seed: int,
289
- resolution: str,
290
- duration: int,
291
- aspect_ratio: float,
292
- unique_id: str,
293
- **kwargs,
294
- ) -> tuple[VideoFromFile]:
295
- initial_operation = SynchronousOperation(
296
- endpoint=ApiEndpoint(
297
- path=PATH_TEXT_TO_VIDEO,
298
- method=HttpMethod.POST,
299
- request_model=PikaBodyGenerate22T2vGenerate22T2vPost,
300
- response_model=PikaGenerateResponse,
301
- ),
302
- request=PikaBodyGenerate22T2vGenerate22T2vPost(
303
- promptText=prompt_text,
304
- negativePrompt=negative_prompt,
305
- seed=seed,
306
- resolution=resolution,
307
- duration=duration,
308
- aspectRatio=aspect_ratio,
309
- ),
310
- auth_kwargs=kwargs,
311
- content_type="application/x-www-form-urlencoded",
312
- )
313
-
314
- return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id)
315
-
316
-
317
- class PikaScenesV2_2(PikaNodeBase):
318
- """PikaScenes v2.2 Node."""
319
-
320
- @classmethod
321
- def INPUT_TYPES(cls):
322
- image_ingredient_input = (
323
- IO.IMAGE,
324
- {"tooltip": "Image that will be used as ingredient to create a video."},
325
- )
326
- return {
327
- "required": {
328
- **cls.get_base_inputs_types(
329
- PikaBodyGenerate22C2vGenerate22PikascenesPost,
330
- ),
331
- "ingredients_mode": model_field_to_node_input(
332
- IO.COMBO,
333
- PikaBodyGenerate22C2vGenerate22PikascenesPost,
334
- "ingredientsMode",
335
- enum_type=IngredientsMode,
336
- default="creative",
337
- ),
338
- "aspect_ratio": model_field_to_node_input(
339
- IO.FLOAT,
340
- PikaBodyGenerate22C2vGenerate22PikascenesPost,
341
- "aspectRatio",
342
- step=0.001,
343
- min=0.4,
344
- max=2.5,
345
- default=1.7777777777777777,
346
- ),
347
- },
348
- "optional": {
349
- "image_ingredient_1": image_ingredient_input,
350
- "image_ingredient_2": image_ingredient_input,
351
- "image_ingredient_3": image_ingredient_input,
352
- "image_ingredient_4": image_ingredient_input,
353
- "image_ingredient_5": image_ingredient_input,
354
- },
355
- "hidden": {
356
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
357
- "comfy_api_key": "API_KEY_COMFY_ORG",
358
- "unique_id": "UNIQUE_ID",
359
- },
360
- }
361
-
362
- DESCRIPTION = "Combine your images to create a video with the objects in them. Upload multiple images as ingredients and generate a high-quality video that incorporates all of them."
363
-
364
- def api_call(
365
- self,
366
- prompt_text: str,
367
- negative_prompt: str,
368
- seed: int,
369
- resolution: str,
370
- duration: int,
371
- ingredients_mode: str,
372
- aspect_ratio: float,
373
- unique_id: str,
374
- image_ingredient_1: Optional[torch.Tensor] = None,
375
- image_ingredient_2: Optional[torch.Tensor] = None,
376
- image_ingredient_3: Optional[torch.Tensor] = None,
377
- image_ingredient_4: Optional[torch.Tensor] = None,
378
- image_ingredient_5: Optional[torch.Tensor] = None,
379
- **kwargs,
380
- ) -> tuple[VideoFromFile]:
381
- # Convert all passed images to BytesIO
382
- all_image_bytes_io = []
383
- for image in [
384
- image_ingredient_1,
385
- image_ingredient_2,
386
- image_ingredient_3,
387
- image_ingredient_4,
388
- image_ingredient_5,
389
- ]:
390
- if image is not None:
391
- image_bytes_io = tensor_to_bytesio(image)
392
- image_bytes_io.seek(0)
393
- all_image_bytes_io.append(image_bytes_io)
394
-
395
- pika_files = [
396
- ("images", (f"image_{i}.png", image_bytes_io, "image/png"))
397
- for i, image_bytes_io in enumerate(all_image_bytes_io)
398
- ]
399
-
400
- pika_request_data = PikaBodyGenerate22C2vGenerate22PikascenesPost(
401
- ingredientsMode=ingredients_mode,
402
- promptText=prompt_text,
403
- negativePrompt=negative_prompt,
404
- seed=seed,
405
- resolution=resolution,
406
- duration=duration,
407
- aspectRatio=aspect_ratio,
408
- )
409
-
410
- initial_operation = SynchronousOperation(
411
- endpoint=ApiEndpoint(
412
- path=PATH_PIKASCENES,
413
- method=HttpMethod.POST,
414
- request_model=PikaBodyGenerate22C2vGenerate22PikascenesPost,
415
- response_model=PikaGenerateResponse,
416
- ),
417
- request=pika_request_data,
418
- files=pika_files,
419
- content_type="multipart/form-data",
420
- auth_kwargs=kwargs,
421
- )
422
-
423
- return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id)
424
-
425
-
426
- class PikAdditionsNode(PikaNodeBase):
427
- """Pika Pikadditions Node. Add an image into a video."""
428
-
429
- @classmethod
430
- def INPUT_TYPES(cls):
431
- return {
432
- "required": {
433
- "video": (IO.VIDEO, {"tooltip": "The video to add an image to."}),
434
- "image": (IO.IMAGE, {"tooltip": "The image to add to the video."}),
435
- "prompt_text": model_field_to_node_input(
436
- IO.STRING,
437
- PikaBodyGeneratePikadditionsGeneratePikadditionsPost,
438
- "promptText",
439
- multiline=True,
440
- ),
441
- "negative_prompt": model_field_to_node_input(
442
- IO.STRING,
443
- PikaBodyGeneratePikadditionsGeneratePikadditionsPost,
444
- "negativePrompt",
445
- multiline=True,
446
- ),
447
- "seed": model_field_to_node_input(
448
- IO.INT,
449
- PikaBodyGeneratePikadditionsGeneratePikadditionsPost,
450
- "seed",
451
- min=0,
452
- max=0xFFFFFFFF,
453
- control_after_generate=True,
454
- ),
455
- },
456
- "hidden": {
457
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
458
- "comfy_api_key": "API_KEY_COMFY_ORG",
459
- "unique_id": "UNIQUE_ID",
460
- },
461
- }
462
-
463
- DESCRIPTION = "Add any object or image into your video. Upload a video and specify what you'd like to add to create a seamlessly integrated result."
464
-
465
- def api_call(
466
- self,
467
- video: VideoInput,
468
- image: torch.Tensor,
469
- prompt_text: str,
470
- negative_prompt: str,
471
- seed: int,
472
- unique_id: str,
473
- **kwargs,
474
- ) -> tuple[VideoFromFile]:
475
- # Convert video to BytesIO
476
- video_bytes_io = io.BytesIO()
477
- video.save_to(video_bytes_io, format=VideoContainer.MP4, codec=VideoCodec.H264)
478
- video_bytes_io.seek(0)
479
-
480
- # Convert image to BytesIO
481
- image_bytes_io = tensor_to_bytesio(image)
482
- image_bytes_io.seek(0)
483
-
484
- pika_files = [
485
- ("video", ("video.mp4", video_bytes_io, "video/mp4")),
486
- ("image", ("image.png", image_bytes_io, "image/png")),
487
- ]
488
-
489
- # Prepare non-file data
490
- pika_request_data = PikaBodyGeneratePikadditionsGeneratePikadditionsPost(
491
- promptText=prompt_text,
492
- negativePrompt=negative_prompt,
493
- seed=seed,
494
- )
495
-
496
- initial_operation = SynchronousOperation(
497
- endpoint=ApiEndpoint(
498
- path=PATH_PIKADDITIONS,
499
- method=HttpMethod.POST,
500
- request_model=PikaBodyGeneratePikadditionsGeneratePikadditionsPost,
501
- response_model=PikaGenerateResponse,
502
- ),
503
- request=pika_request_data,
504
- files=pika_files,
505
- content_type="multipart/form-data",
506
- auth_kwargs=kwargs,
507
- )
508
-
509
- return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id)
510
-
511
-
512
- class PikaSwapsNode(PikaNodeBase):
513
- """Pika Pikaswaps Node."""
514
-
515
- @classmethod
516
- def INPUT_TYPES(cls):
517
- return {
518
- "required": {
519
- "video": (IO.VIDEO, {"tooltip": "The video to swap an object in."}),
520
- "image": (
521
- IO.IMAGE,
522
- {
523
- "tooltip": "The image used to replace the masked object in the video."
524
- },
525
- ),
526
- "mask": (
527
- IO.MASK,
528
- {"tooltip": "Use the mask to define areas in the video to replace"},
529
- ),
530
- "prompt_text": model_field_to_node_input(
531
- IO.STRING,
532
- PikaBodyGeneratePikaswapsGeneratePikaswapsPost,
533
- "promptText",
534
- multiline=True,
535
- ),
536
- "negative_prompt": model_field_to_node_input(
537
- IO.STRING,
538
- PikaBodyGeneratePikaswapsGeneratePikaswapsPost,
539
- "negativePrompt",
540
- multiline=True,
541
- ),
542
- "seed": model_field_to_node_input(
543
- IO.INT,
544
- PikaBodyGeneratePikaswapsGeneratePikaswapsPost,
545
- "seed",
546
- min=0,
547
- max=0xFFFFFFFF,
548
- control_after_generate=True,
549
- ),
550
- },
551
- "hidden": {
552
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
553
- "comfy_api_key": "API_KEY_COMFY_ORG",
554
- "unique_id": "UNIQUE_ID",
555
- },
556
- }
557
-
558
- DESCRIPTION = "Swap out any object or region of your video with a new image or object. Define areas to replace either with a mask or coordinates."
559
- RETURN_TYPES = ("VIDEO",)
560
-
561
- def api_call(
562
- self,
563
- video: VideoInput,
564
- image: torch.Tensor,
565
- mask: torch.Tensor,
566
- prompt_text: str,
567
- negative_prompt: str,
568
- seed: int,
569
- unique_id: str,
570
- **kwargs,
571
- ) -> tuple[VideoFromFile]:
572
- # Convert video to BytesIO
573
- video_bytes_io = io.BytesIO()
574
- video.save_to(video_bytes_io, format=VideoContainer.MP4, codec=VideoCodec.H264)
575
- video_bytes_io.seek(0)
576
-
577
- # Convert mask to binary mask with three channels
578
- mask = torch.round(mask)
579
- mask = mask.repeat(1, 3, 1, 1)
580
-
581
- # Convert 3-channel binary mask to BytesIO
582
- mask_bytes_io = io.BytesIO()
583
- mask_bytes_io.write(mask.numpy().astype(np.uint8))
584
- mask_bytes_io.seek(0)
585
-
586
- # Convert image to BytesIO
587
- image_bytes_io = tensor_to_bytesio(image)
588
- image_bytes_io.seek(0)
589
-
590
- pika_files = [
591
- ("video", ("video.mp4", video_bytes_io, "video/mp4")),
592
- ("image", ("image.png", image_bytes_io, "image/png")),
593
- ("modifyRegionMask", ("mask.png", mask_bytes_io, "image/png")),
594
- ]
595
-
596
- # Prepare non-file data
597
- pika_request_data = PikaBodyGeneratePikaswapsGeneratePikaswapsPost(
598
- promptText=prompt_text,
599
- negativePrompt=negative_prompt,
600
- seed=seed,
601
- )
602
-
603
- initial_operation = SynchronousOperation(
604
- endpoint=ApiEndpoint(
605
- path=PATH_PIKADDITIONS,
606
- method=HttpMethod.POST,
607
- request_model=PikaBodyGeneratePikadditionsGeneratePikadditionsPost,
608
- response_model=PikaGenerateResponse,
609
- ),
610
- request=pika_request_data,
611
- files=pika_files,
612
- content_type="multipart/form-data",
613
- auth_kwargs=kwargs,
614
- )
615
-
616
- return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id)
617
-
618
-
619
- class PikaffectsNode(PikaNodeBase):
620
- """Pika Pikaffects Node."""
621
-
622
- @classmethod
623
- def INPUT_TYPES(cls):
624
- return {
625
- "required": {
626
- "image": (
627
- IO.IMAGE,
628
- {"tooltip": "The reference image to apply the Pikaffect to."},
629
- ),
630
- "pikaffect": model_field_to_node_input(
631
- IO.COMBO,
632
- PikaBodyGeneratePikaffectsGeneratePikaffectsPost,
633
- "pikaffect",
634
- enum_type=Pikaffect,
635
- default="Cake-ify",
636
- ),
637
- "prompt_text": model_field_to_node_input(
638
- IO.STRING,
639
- PikaBodyGeneratePikaffectsGeneratePikaffectsPost,
640
- "promptText",
641
- multiline=True,
642
- ),
643
- "negative_prompt": model_field_to_node_input(
644
- IO.STRING,
645
- PikaBodyGeneratePikaffectsGeneratePikaffectsPost,
646
- "negativePrompt",
647
- multiline=True,
648
- ),
649
- "seed": model_field_to_node_input(
650
- IO.INT,
651
- PikaBodyGeneratePikaffectsGeneratePikaffectsPost,
652
- "seed",
653
- min=0,
654
- max=0xFFFFFFFF,
655
- control_after_generate=True,
656
- ),
657
- },
658
- "hidden": {
659
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
660
- "comfy_api_key": "API_KEY_COMFY_ORG",
661
- "unique_id": "UNIQUE_ID",
662
- },
663
- }
664
-
665
- DESCRIPTION = "Generate a video with a specific Pikaffect. Supported Pikaffects: Cake-ify, Crumble, Crush, Decapitate, Deflate, Dissolve, Explode, Eye-pop, Inflate, Levitate, Melt, Peel, Poke, Squish, Ta-da, Tear"
666
-
667
- def api_call(
668
- self,
669
- image: torch.Tensor,
670
- pikaffect: str,
671
- prompt_text: str,
672
- negative_prompt: str,
673
- seed: int,
674
- unique_id: str,
675
- **kwargs,
676
- ) -> tuple[VideoFromFile]:
677
-
678
- initial_operation = SynchronousOperation(
679
- endpoint=ApiEndpoint(
680
- path=PATH_PIKAFFECTS,
681
- method=HttpMethod.POST,
682
- request_model=PikaBodyGeneratePikaffectsGeneratePikaffectsPost,
683
- response_model=PikaGenerateResponse,
684
- ),
685
- request=PikaBodyGeneratePikaffectsGeneratePikaffectsPost(
686
- pikaffect=pikaffect,
687
- promptText=prompt_text,
688
- negativePrompt=negative_prompt,
689
- seed=seed,
690
- ),
691
- files={"image": ("image.png", tensor_to_bytesio(image), "image/png")},
692
- content_type="multipart/form-data",
693
- auth_kwargs=kwargs,
694
- )
695
-
696
- return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id)
697
-
698
-
699
- class PikaStartEndFrameNode2_2(PikaNodeBase):
700
- """PikaFrames v2.2 Node."""
701
-
702
- @classmethod
703
- def INPUT_TYPES(cls):
704
- return {
705
- "required": {
706
- "image_start": (IO.IMAGE, {"tooltip": "The first image to combine."}),
707
- "image_end": (IO.IMAGE, {"tooltip": "The last image to combine."}),
708
- **cls.get_base_inputs_types(
709
- PikaBodyGenerate22KeyframeGenerate22PikaframesPost
710
- ),
711
- },
712
- "hidden": {
713
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
714
- "comfy_api_key": "API_KEY_COMFY_ORG",
715
- "unique_id": "UNIQUE_ID",
716
- },
717
- }
718
-
719
- DESCRIPTION = "Generate a video by combining your first and last frame. Upload two images to define the start and end points, and let the AI create a smooth transition between them."
720
-
721
- def api_call(
722
- self,
723
- image_start: torch.Tensor,
724
- image_end: torch.Tensor,
725
- prompt_text: str,
726
- negative_prompt: str,
727
- seed: int,
728
- resolution: str,
729
- duration: int,
730
- unique_id: str,
731
- **kwargs,
732
- ) -> tuple[VideoFromFile]:
733
-
734
- pika_files = [
735
- (
736
- "keyFrames",
737
- ("image_start.png", tensor_to_bytesio(image_start), "image/png"),
738
- ),
739
- ("keyFrames", ("image_end.png", tensor_to_bytesio(image_end), "image/png")),
740
- ]
741
-
742
- initial_operation = SynchronousOperation(
743
- endpoint=ApiEndpoint(
744
- path=PATH_PIKAFRAMES,
745
- method=HttpMethod.POST,
746
- request_model=PikaBodyGenerate22KeyframeGenerate22PikaframesPost,
747
- response_model=PikaGenerateResponse,
748
- ),
749
- request=PikaBodyGenerate22KeyframeGenerate22PikaframesPost(
750
- promptText=prompt_text,
751
- negativePrompt=negative_prompt,
752
- seed=seed,
753
- resolution=resolution,
754
- duration=duration,
755
- ),
756
- files=pika_files,
757
- content_type="multipart/form-data",
758
- auth_kwargs=kwargs,
759
- )
760
-
761
- return self.execute_task(initial_operation, auth_kwargs=kwargs, node_id=unique_id)
762
-
763
-
764
- NODE_CLASS_MAPPINGS = {
765
- "PikaImageToVideoNode2_2": PikaImageToVideoV2_2,
766
- "PikaTextToVideoNode2_2": PikaTextToVideoNodeV2_2,
767
- "PikaScenesV2_2": PikaScenesV2_2,
768
- "Pikadditions": PikAdditionsNode,
769
- "Pikaswaps": PikaSwapsNode,
770
- "Pikaffects": PikaffectsNode,
771
- "PikaStartEndFrameNode2_2": PikaStartEndFrameNode2_2,
772
- }
773
-
774
- NODE_DISPLAY_NAME_MAPPINGS = {
775
- "PikaImageToVideoNode2_2": "Pika Image to Video",
776
- "PikaTextToVideoNode2_2": "Pika Text to Video",
777
- "PikaScenesV2_2": "Pika Scenes (Video Image Composition)",
778
- "Pikadditions": "Pikadditions (Video Object Insertion)",
779
- "Pikaswaps": "Pika Swaps (Video Object Replacement)",
780
- "Pikaffects": "Pikaffects (Video Effects)",
781
- "PikaStartEndFrameNode2_2": "Pika Start and End Frame to Video",
782
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/nodes_pixverse.py DELETED
@@ -1,525 +0,0 @@
1
- from inspect import cleandoc
2
- from typing import Optional
3
- from comfy_api_nodes.apis.pixverse_api import (
4
- PixverseTextVideoRequest,
5
- PixverseImageVideoRequest,
6
- PixverseTransitionVideoRequest,
7
- PixverseImageUploadResponse,
8
- PixverseVideoResponse,
9
- PixverseGenerationStatusResponse,
10
- PixverseAspectRatio,
11
- PixverseQuality,
12
- PixverseDuration,
13
- PixverseMotionMode,
14
- PixverseStatus,
15
- PixverseIO,
16
- pixverse_templates,
17
- )
18
- from comfy_api_nodes.apis.client import (
19
- ApiEndpoint,
20
- HttpMethod,
21
- SynchronousOperation,
22
- PollingOperation,
23
- EmptyRequest,
24
- )
25
- from comfy_api_nodes.apinode_utils import (
26
- tensor_to_bytesio,
27
- validate_string,
28
- )
29
- from comfy.comfy_types.node_typing import IO, ComfyNodeABC
30
- from comfy_api.input_impl import VideoFromFile
31
-
32
- import torch
33
- import requests
34
- from io import BytesIO
35
-
36
-
37
- AVERAGE_DURATION_T2V = 32
38
- AVERAGE_DURATION_I2V = 30
39
- AVERAGE_DURATION_T2T = 52
40
-
41
-
42
- def get_video_url_from_response(
43
- response: PixverseGenerationStatusResponse,
44
- ) -> Optional[str]:
45
- if response.Resp is None or response.Resp.url is None:
46
- return None
47
- return str(response.Resp.url)
48
-
49
-
50
- def upload_image_to_pixverse(image: torch.Tensor, auth_kwargs=None):
51
- # first, upload image to Pixverse and get image id to use in actual generation call
52
- files = {"image": tensor_to_bytesio(image)}
53
- operation = SynchronousOperation(
54
- endpoint=ApiEndpoint(
55
- path="/proxy/pixverse/image/upload",
56
- method=HttpMethod.POST,
57
- request_model=EmptyRequest,
58
- response_model=PixverseImageUploadResponse,
59
- ),
60
- request=EmptyRequest(),
61
- files=files,
62
- content_type="multipart/form-data",
63
- auth_kwargs=auth_kwargs,
64
- )
65
- response_upload: PixverseImageUploadResponse = operation.execute()
66
-
67
- if response_upload.Resp is None:
68
- raise Exception(
69
- f"PixVerse image upload request failed: '{response_upload.ErrMsg}'"
70
- )
71
-
72
- return response_upload.Resp.img_id
73
-
74
-
75
- class PixverseTemplateNode:
76
- """
77
- Select template for PixVerse Video generation.
78
- """
79
-
80
- RETURN_TYPES = (PixverseIO.TEMPLATE,)
81
- RETURN_NAMES = ("pixverse_template",)
82
- FUNCTION = "create_template"
83
- CATEGORY = "api node/video/PixVerse"
84
-
85
- @classmethod
86
- def INPUT_TYPES(s):
87
- return {
88
- "required": {
89
- "template": (list(pixverse_templates.keys()),),
90
- }
91
- }
92
-
93
- def create_template(self, template: str):
94
- template_id = pixverse_templates.get(template, None)
95
- if template_id is None:
96
- raise Exception(f"Template '{template}' is not recognized.")
97
- # just return the integer
98
- return (template_id,)
99
-
100
-
101
- class PixverseTextToVideoNode(ComfyNodeABC):
102
- """
103
- Generates videos based on prompt and output_size.
104
- """
105
-
106
- RETURN_TYPES = (IO.VIDEO,)
107
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
108
- FUNCTION = "api_call"
109
- API_NODE = True
110
- CATEGORY = "api node/video/PixVerse"
111
-
112
- @classmethod
113
- def INPUT_TYPES(s):
114
- return {
115
- "required": {
116
- "prompt": (
117
- IO.STRING,
118
- {
119
- "multiline": True,
120
- "default": "",
121
- "tooltip": "Prompt for the video generation",
122
- },
123
- ),
124
- "aspect_ratio": ([ratio.value for ratio in PixverseAspectRatio],),
125
- "quality": (
126
- [resolution.value for resolution in PixverseQuality],
127
- {
128
- "default": PixverseQuality.res_540p,
129
- },
130
- ),
131
- "duration_seconds": ([dur.value for dur in PixverseDuration],),
132
- "motion_mode": ([mode.value for mode in PixverseMotionMode],),
133
- "seed": (
134
- IO.INT,
135
- {
136
- "default": 0,
137
- "min": 0,
138
- "max": 2147483647,
139
- "control_after_generate": True,
140
- "tooltip": "Seed for video generation.",
141
- },
142
- ),
143
- },
144
- "optional": {
145
- "negative_prompt": (
146
- IO.STRING,
147
- {
148
- "default": "",
149
- "forceInput": True,
150
- "tooltip": "An optional text description of undesired elements on an image.",
151
- },
152
- ),
153
- "pixverse_template": (
154
- PixverseIO.TEMPLATE,
155
- {
156
- "tooltip": "An optional template to influence style of generation, created by the PixVerse Template node."
157
- },
158
- ),
159
- },
160
- "hidden": {
161
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
162
- "comfy_api_key": "API_KEY_COMFY_ORG",
163
- "unique_id": "UNIQUE_ID",
164
- },
165
- }
166
-
167
- def api_call(
168
- self,
169
- prompt: str,
170
- aspect_ratio: str,
171
- quality: str,
172
- duration_seconds: int,
173
- motion_mode: str,
174
- seed,
175
- negative_prompt: str = None,
176
- pixverse_template: int = None,
177
- unique_id: Optional[str] = None,
178
- **kwargs,
179
- ):
180
- validate_string(prompt, strip_whitespace=False)
181
- # 1080p is limited to 5 seconds duration
182
- # only normal motion_mode supported for 1080p or for non-5 second duration
183
- if quality == PixverseQuality.res_1080p:
184
- motion_mode = PixverseMotionMode.normal
185
- duration_seconds = PixverseDuration.dur_5
186
- elif duration_seconds != PixverseDuration.dur_5:
187
- motion_mode = PixverseMotionMode.normal
188
-
189
- operation = SynchronousOperation(
190
- endpoint=ApiEndpoint(
191
- path="/proxy/pixverse/video/text/generate",
192
- method=HttpMethod.POST,
193
- request_model=PixverseTextVideoRequest,
194
- response_model=PixverseVideoResponse,
195
- ),
196
- request=PixverseTextVideoRequest(
197
- prompt=prompt,
198
- aspect_ratio=aspect_ratio,
199
- quality=quality,
200
- duration=duration_seconds,
201
- motion_mode=motion_mode,
202
- negative_prompt=negative_prompt if negative_prompt else None,
203
- template_id=pixverse_template,
204
- seed=seed,
205
- ),
206
- auth_kwargs=kwargs,
207
- )
208
- response_api = operation.execute()
209
-
210
- if response_api.Resp is None:
211
- raise Exception(f"PixVerse request failed: '{response_api.ErrMsg}'")
212
-
213
- operation = PollingOperation(
214
- poll_endpoint=ApiEndpoint(
215
- path=f"/proxy/pixverse/video/result/{response_api.Resp.video_id}",
216
- method=HttpMethod.GET,
217
- request_model=EmptyRequest,
218
- response_model=PixverseGenerationStatusResponse,
219
- ),
220
- completed_statuses=[PixverseStatus.successful],
221
- failed_statuses=[
222
- PixverseStatus.contents_moderation,
223
- PixverseStatus.failed,
224
- PixverseStatus.deleted,
225
- ],
226
- status_extractor=lambda x: x.Resp.status,
227
- auth_kwargs=kwargs,
228
- node_id=unique_id,
229
- result_url_extractor=get_video_url_from_response,
230
- estimated_duration=AVERAGE_DURATION_T2V,
231
- )
232
- response_poll = operation.execute()
233
-
234
- vid_response = requests.get(response_poll.Resp.url)
235
-
236
- return (VideoFromFile(BytesIO(vid_response.content)),)
237
-
238
-
239
- class PixverseImageToVideoNode(ComfyNodeABC):
240
- """
241
- Generates videos based on prompt and output_size.
242
- """
243
-
244
- RETURN_TYPES = (IO.VIDEO,)
245
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
246
- FUNCTION = "api_call"
247
- API_NODE = True
248
- CATEGORY = "api node/video/PixVerse"
249
-
250
- @classmethod
251
- def INPUT_TYPES(s):
252
- return {
253
- "required": {
254
- "image": (IO.IMAGE,),
255
- "prompt": (
256
- IO.STRING,
257
- {
258
- "multiline": True,
259
- "default": "",
260
- "tooltip": "Prompt for the video generation",
261
- },
262
- ),
263
- "quality": (
264
- [resolution.value for resolution in PixverseQuality],
265
- {
266
- "default": PixverseQuality.res_540p,
267
- },
268
- ),
269
- "duration_seconds": ([dur.value for dur in PixverseDuration],),
270
- "motion_mode": ([mode.value for mode in PixverseMotionMode],),
271
- "seed": (
272
- IO.INT,
273
- {
274
- "default": 0,
275
- "min": 0,
276
- "max": 2147483647,
277
- "control_after_generate": True,
278
- "tooltip": "Seed for video generation.",
279
- },
280
- ),
281
- },
282
- "optional": {
283
- "negative_prompt": (
284
- IO.STRING,
285
- {
286
- "default": "",
287
- "forceInput": True,
288
- "tooltip": "An optional text description of undesired elements on an image.",
289
- },
290
- ),
291
- "pixverse_template": (
292
- PixverseIO.TEMPLATE,
293
- {
294
- "tooltip": "An optional template to influence style of generation, created by the PixVerse Template node."
295
- },
296
- ),
297
- },
298
- "hidden": {
299
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
300
- "comfy_api_key": "API_KEY_COMFY_ORG",
301
- "unique_id": "UNIQUE_ID",
302
- },
303
- }
304
-
305
- def api_call(
306
- self,
307
- image: torch.Tensor,
308
- prompt: str,
309
- quality: str,
310
- duration_seconds: int,
311
- motion_mode: str,
312
- seed,
313
- negative_prompt: str = None,
314
- pixverse_template: int = None,
315
- unique_id: Optional[str] = None,
316
- **kwargs,
317
- ):
318
- validate_string(prompt, strip_whitespace=False)
319
- img_id = upload_image_to_pixverse(image, auth_kwargs=kwargs)
320
-
321
- # 1080p is limited to 5 seconds duration
322
- # only normal motion_mode supported for 1080p or for non-5 second duration
323
- if quality == PixverseQuality.res_1080p:
324
- motion_mode = PixverseMotionMode.normal
325
- duration_seconds = PixverseDuration.dur_5
326
- elif duration_seconds != PixverseDuration.dur_5:
327
- motion_mode = PixverseMotionMode.normal
328
-
329
- operation = SynchronousOperation(
330
- endpoint=ApiEndpoint(
331
- path="/proxy/pixverse/video/img/generate",
332
- method=HttpMethod.POST,
333
- request_model=PixverseImageVideoRequest,
334
- response_model=PixverseVideoResponse,
335
- ),
336
- request=PixverseImageVideoRequest(
337
- img_id=img_id,
338
- prompt=prompt,
339
- quality=quality,
340
- duration=duration_seconds,
341
- motion_mode=motion_mode,
342
- negative_prompt=negative_prompt if negative_prompt else None,
343
- template_id=pixverse_template,
344
- seed=seed,
345
- ),
346
- auth_kwargs=kwargs,
347
- )
348
- response_api = operation.execute()
349
-
350
- if response_api.Resp is None:
351
- raise Exception(f"PixVerse request failed: '{response_api.ErrMsg}'")
352
-
353
- operation = PollingOperation(
354
- poll_endpoint=ApiEndpoint(
355
- path=f"/proxy/pixverse/video/result/{response_api.Resp.video_id}",
356
- method=HttpMethod.GET,
357
- request_model=EmptyRequest,
358
- response_model=PixverseGenerationStatusResponse,
359
- ),
360
- completed_statuses=[PixverseStatus.successful],
361
- failed_statuses=[
362
- PixverseStatus.contents_moderation,
363
- PixverseStatus.failed,
364
- PixverseStatus.deleted,
365
- ],
366
- status_extractor=lambda x: x.Resp.status,
367
- auth_kwargs=kwargs,
368
- node_id=unique_id,
369
- result_url_extractor=get_video_url_from_response,
370
- estimated_duration=AVERAGE_DURATION_I2V,
371
- )
372
- response_poll = operation.execute()
373
-
374
- vid_response = requests.get(response_poll.Resp.url)
375
- return (VideoFromFile(BytesIO(vid_response.content)),)
376
-
377
-
378
- class PixverseTransitionVideoNode(ComfyNodeABC):
379
- """
380
- Generates videos based on prompt and output_size.
381
- """
382
-
383
- RETURN_TYPES = (IO.VIDEO,)
384
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
385
- FUNCTION = "api_call"
386
- API_NODE = True
387
- CATEGORY = "api node/video/PixVerse"
388
-
389
- @classmethod
390
- def INPUT_TYPES(s):
391
- return {
392
- "required": {
393
- "first_frame": (IO.IMAGE,),
394
- "last_frame": (IO.IMAGE,),
395
- "prompt": (
396
- IO.STRING,
397
- {
398
- "multiline": True,
399
- "default": "",
400
- "tooltip": "Prompt for the video generation",
401
- },
402
- ),
403
- "quality": (
404
- [resolution.value for resolution in PixverseQuality],
405
- {
406
- "default": PixverseQuality.res_540p,
407
- },
408
- ),
409
- "duration_seconds": ([dur.value for dur in PixverseDuration],),
410
- "motion_mode": ([mode.value for mode in PixverseMotionMode],),
411
- "seed": (
412
- IO.INT,
413
- {
414
- "default": 0,
415
- "min": 0,
416
- "max": 2147483647,
417
- "control_after_generate": True,
418
- "tooltip": "Seed for video generation.",
419
- },
420
- ),
421
- },
422
- "optional": {
423
- "negative_prompt": (
424
- IO.STRING,
425
- {
426
- "default": "",
427
- "forceInput": True,
428
- "tooltip": "An optional text description of undesired elements on an image.",
429
- },
430
- ),
431
- },
432
- "hidden": {
433
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
434
- "comfy_api_key": "API_KEY_COMFY_ORG",
435
- "unique_id": "UNIQUE_ID",
436
- },
437
- }
438
-
439
- def api_call(
440
- self,
441
- first_frame: torch.Tensor,
442
- last_frame: torch.Tensor,
443
- prompt: str,
444
- quality: str,
445
- duration_seconds: int,
446
- motion_mode: str,
447
- seed,
448
- negative_prompt: str = None,
449
- unique_id: Optional[str] = None,
450
- **kwargs,
451
- ):
452
- validate_string(prompt, strip_whitespace=False)
453
- first_frame_id = upload_image_to_pixverse(first_frame, auth_kwargs=kwargs)
454
- last_frame_id = upload_image_to_pixverse(last_frame, auth_kwargs=kwargs)
455
-
456
- # 1080p is limited to 5 seconds duration
457
- # only normal motion_mode supported for 1080p or for non-5 second duration
458
- if quality == PixverseQuality.res_1080p:
459
- motion_mode = PixverseMotionMode.normal
460
- duration_seconds = PixverseDuration.dur_5
461
- elif duration_seconds != PixverseDuration.dur_5:
462
- motion_mode = PixverseMotionMode.normal
463
-
464
- operation = SynchronousOperation(
465
- endpoint=ApiEndpoint(
466
- path="/proxy/pixverse/video/transition/generate",
467
- method=HttpMethod.POST,
468
- request_model=PixverseTransitionVideoRequest,
469
- response_model=PixverseVideoResponse,
470
- ),
471
- request=PixverseTransitionVideoRequest(
472
- first_frame_img=first_frame_id,
473
- last_frame_img=last_frame_id,
474
- prompt=prompt,
475
- quality=quality,
476
- duration=duration_seconds,
477
- motion_mode=motion_mode,
478
- negative_prompt=negative_prompt if negative_prompt else None,
479
- seed=seed,
480
- ),
481
- auth_kwargs=kwargs,
482
- )
483
- response_api = operation.execute()
484
-
485
- if response_api.Resp is None:
486
- raise Exception(f"PixVerse request failed: '{response_api.ErrMsg}'")
487
-
488
- operation = PollingOperation(
489
- poll_endpoint=ApiEndpoint(
490
- path=f"/proxy/pixverse/video/result/{response_api.Resp.video_id}",
491
- method=HttpMethod.GET,
492
- request_model=EmptyRequest,
493
- response_model=PixverseGenerationStatusResponse,
494
- ),
495
- completed_statuses=[PixverseStatus.successful],
496
- failed_statuses=[
497
- PixverseStatus.contents_moderation,
498
- PixverseStatus.failed,
499
- PixverseStatus.deleted,
500
- ],
501
- status_extractor=lambda x: x.Resp.status,
502
- auth_kwargs=kwargs,
503
- node_id=unique_id,
504
- result_url_extractor=get_video_url_from_response,
505
- estimated_duration=AVERAGE_DURATION_T2V,
506
- )
507
- response_poll = operation.execute()
508
-
509
- vid_response = requests.get(response_poll.Resp.url)
510
- return (VideoFromFile(BytesIO(vid_response.content)),)
511
-
512
-
513
- NODE_CLASS_MAPPINGS = {
514
- "PixverseTextToVideoNode": PixverseTextToVideoNode,
515
- "PixverseImageToVideoNode": PixverseImageToVideoNode,
516
- "PixverseTransitionVideoNode": PixverseTransitionVideoNode,
517
- "PixverseTemplateNode": PixverseTemplateNode,
518
- }
519
-
520
- NODE_DISPLAY_NAME_MAPPINGS = {
521
- "PixverseTextToVideoNode": "PixVerse Text to Video",
522
- "PixverseImageToVideoNode": "PixVerse Image to Video",
523
- "PixverseTransitionVideoNode": "PixVerse Transition Video",
524
- "PixverseTemplateNode": "PixVerse Template",
525
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/nodes_recraft.py DELETED
@@ -1,1138 +0,0 @@
1
- from __future__ import annotations
2
- from inspect import cleandoc
3
- from typing import Optional
4
- from comfy.utils import ProgressBar
5
- from comfy_extras.nodes_images import SVG # Added
6
- from comfy.comfy_types.node_typing import IO
7
- from comfy_api_nodes.apis.recraft_api import (
8
- RecraftImageGenerationRequest,
9
- RecraftImageGenerationResponse,
10
- RecraftImageSize,
11
- RecraftModel,
12
- RecraftStyle,
13
- RecraftStyleV3,
14
- RecraftColor,
15
- RecraftColorChain,
16
- RecraftControls,
17
- RecraftIO,
18
- get_v3_substyles,
19
- )
20
- from comfy_api_nodes.apis.client import (
21
- ApiEndpoint,
22
- HttpMethod,
23
- SynchronousOperation,
24
- EmptyRequest,
25
- )
26
- from comfy_api_nodes.apinode_utils import (
27
- bytesio_to_image_tensor,
28
- download_url_to_bytesio,
29
- tensor_to_bytesio,
30
- resize_mask_to_image,
31
- validate_string,
32
- )
33
- from server import PromptServer
34
-
35
- import torch
36
- from io import BytesIO
37
- from PIL import UnidentifiedImageError
38
-
39
-
40
- def handle_recraft_file_request(
41
- image: torch.Tensor,
42
- path: str,
43
- mask: torch.Tensor=None,
44
- total_pixels=4096*4096,
45
- timeout=1024,
46
- request=None,
47
- auth_kwargs: dict[str,str] = None,
48
- ) -> list[BytesIO]:
49
- """
50
- Handle sending common Recraft file-only request to get back file bytes.
51
- """
52
- if request is None:
53
- request = EmptyRequest()
54
-
55
- files = {
56
- 'image': tensor_to_bytesio(image, total_pixels=total_pixels).read()
57
- }
58
- if mask is not None:
59
- files['mask'] = tensor_to_bytesio(mask, total_pixels=total_pixels).read()
60
-
61
- operation = SynchronousOperation(
62
- endpoint=ApiEndpoint(
63
- path=path,
64
- method=HttpMethod.POST,
65
- request_model=type(request),
66
- response_model=RecraftImageGenerationResponse,
67
- ),
68
- request=request,
69
- files=files,
70
- content_type="multipart/form-data",
71
- auth_kwargs=auth_kwargs,
72
- multipart_parser=recraft_multipart_parser,
73
- )
74
- response: RecraftImageGenerationResponse = operation.execute()
75
- all_bytesio = []
76
- if response.image is not None:
77
- all_bytesio.append(download_url_to_bytesio(response.image.url, timeout=timeout))
78
- else:
79
- for data in response.data:
80
- all_bytesio.append(download_url_to_bytesio(data.url, timeout=timeout))
81
-
82
- return all_bytesio
83
-
84
-
85
- def recraft_multipart_parser(data, parent_key=None, formatter: callable=None, converted_to_check: list[list]=None, is_list=False) -> dict:
86
- """
87
- Formats data such that multipart/form-data will work with requests library
88
- when both files and data are present.
89
-
90
- The OpenAI client that Recraft uses has a bizarre way of serializing lists:
91
-
92
- It does NOT keep track of indeces of each list, so for background_color, that must be serialized as:
93
- 'background_color[rgb][]' = [0, 0, 255]
94
- where the array is assigned to a key that has '[]' at the end, to signal it's an array.
95
-
96
- This has the consequence of nested lists having the exact same key, forcing arrays to merge; all colors inputs fall under the same key:
97
- if 1 color -> 'controls[colors][][rgb][]' = [0, 0, 255]
98
- if 2 colors -> 'controls[colors][][rgb][]' = [0, 0, 255, 255, 0, 0]
99
- if 3 colors -> 'controls[colors][][rgb][]' = [0, 0, 255, 255, 0, 0, 0, 255, 0]
100
- etc.
101
- Whoever made this serialization up at OpenAI added the constraint that lists must be of uniform length on objects of same 'type'.
102
- """
103
- # Modification of a function that handled a different type of multipart parsing, big ups:
104
- # https://gist.github.com/kazqvaizer/4cebebe5db654a414132809f9f88067b
105
-
106
- def handle_converted_lists(data, parent_key, lists_to_check=tuple[list]):
107
- # if list already exists exists, just extend list with data
108
- for check_list in lists_to_check:
109
- for conv_tuple in check_list:
110
- if conv_tuple[0] == parent_key and type(conv_tuple[1]) is list:
111
- conv_tuple[1].append(formatter(data))
112
- return True
113
- return False
114
-
115
- if converted_to_check is None:
116
- converted_to_check = []
117
-
118
-
119
- if formatter is None:
120
- formatter = lambda v: v # Multipart representation of value
121
-
122
- if type(data) is not dict:
123
- # if list already exists exists, just extend list with data
124
- added = handle_converted_lists(data, parent_key, converted_to_check)
125
- if added:
126
- return {}
127
- # otherwise if is_list, create new list with data
128
- if is_list:
129
- return {parent_key: [formatter(data)]}
130
- # return new key with data
131
- return {parent_key: formatter(data)}
132
-
133
- converted = []
134
- next_check = [converted]
135
- next_check.extend(converted_to_check)
136
-
137
- for key, value in data.items():
138
- current_key = key if parent_key is None else f"{parent_key}[{key}]"
139
- if type(value) is dict:
140
- converted.extend(recraft_multipart_parser(value, current_key, formatter, next_check).items())
141
- elif type(value) is list:
142
- for ind, list_value in enumerate(value):
143
- iter_key = f"{current_key}[]"
144
- converted.extend(recraft_multipart_parser(list_value, iter_key, formatter, next_check, is_list=True).items())
145
- else:
146
- converted.append((current_key, formatter(value)))
147
-
148
- return dict(converted)
149
-
150
-
151
- class handle_recraft_image_output:
152
- """
153
- Catch an exception related to receiving SVG data instead of image, when Infinite Style Library style_id is in use.
154
- """
155
- def __init__(self):
156
- pass
157
-
158
- def __enter__(self):
159
- pass
160
-
161
- def __exit__(self, exc_type, exc_val, exc_tb):
162
- if exc_type is not None and exc_type is UnidentifiedImageError:
163
- raise Exception("Received output data was not an image; likely an SVG. If you used style_id, make sure it is not a Vector art style.")
164
-
165
-
166
- class RecraftColorRGBNode:
167
- """
168
- Create Recraft Color by choosing specific RGB values.
169
- """
170
-
171
- RETURN_TYPES = (RecraftIO.COLOR,)
172
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
173
- RETURN_NAMES = ("recraft_color",)
174
- FUNCTION = "create_color"
175
- CATEGORY = "api node/image/Recraft"
176
-
177
- @classmethod
178
- def INPUT_TYPES(s):
179
- return {
180
- "required": {
181
- "r": (IO.INT, {
182
- "default": 0,
183
- "min": 0,
184
- "max": 255,
185
- "tooltip": "Red value of color."
186
- }),
187
- "g": (IO.INT, {
188
- "default": 0,
189
- "min": 0,
190
- "max": 255,
191
- "tooltip": "Green value of color."
192
- }),
193
- "b": (IO.INT, {
194
- "default": 0,
195
- "min": 0,
196
- "max": 255,
197
- "tooltip": "Blue value of color."
198
- }),
199
- },
200
- "optional": {
201
- "recraft_color": (RecraftIO.COLOR,),
202
- }
203
- }
204
-
205
- def create_color(self, r: int, g: int, b: int, recraft_color: RecraftColorChain=None):
206
- recraft_color = recraft_color.clone() if recraft_color else RecraftColorChain()
207
- recraft_color.add(RecraftColor(r, g, b))
208
- return (recraft_color, )
209
-
210
-
211
- class RecraftControlsNode:
212
- """
213
- Create Recraft Controls for customizing Recraft generation.
214
- """
215
-
216
- RETURN_TYPES = (RecraftIO.CONTROLS,)
217
- RETURN_NAMES = ("recraft_controls",)
218
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
219
- FUNCTION = "create_controls"
220
- CATEGORY = "api node/image/Recraft"
221
-
222
- @classmethod
223
- def INPUT_TYPES(s):
224
- return {
225
- "required": {
226
- },
227
- "optional": {
228
- "colors": (RecraftIO.COLOR,),
229
- "background_color": (RecraftIO.COLOR,),
230
- }
231
- }
232
-
233
- def create_controls(self, colors: RecraftColorChain=None, background_color: RecraftColorChain=None):
234
- return (RecraftControls(colors=colors, background_color=background_color), )
235
-
236
-
237
- class RecraftStyleV3RealisticImageNode:
238
- """
239
- Select realistic_image style and optional substyle.
240
- """
241
-
242
- RETURN_TYPES = (RecraftIO.STYLEV3,)
243
- RETURN_NAMES = ("recraft_style",)
244
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
245
- FUNCTION = "create_style"
246
- CATEGORY = "api node/image/Recraft"
247
-
248
- RECRAFT_STYLE = RecraftStyleV3.realistic_image
249
-
250
- @classmethod
251
- def INPUT_TYPES(s):
252
- return {
253
- "required": {
254
- "substyle": (get_v3_substyles(s.RECRAFT_STYLE),),
255
- }
256
- }
257
-
258
- def create_style(self, substyle: str):
259
- if substyle == "None":
260
- substyle = None
261
- return (RecraftStyle(self.RECRAFT_STYLE, substyle),)
262
-
263
-
264
- class RecraftStyleV3DigitalIllustrationNode(RecraftStyleV3RealisticImageNode):
265
- """
266
- Select digital_illustration style and optional substyle.
267
- """
268
-
269
- RECRAFT_STYLE = RecraftStyleV3.digital_illustration
270
-
271
-
272
- class RecraftStyleV3VectorIllustrationNode(RecraftStyleV3RealisticImageNode):
273
- """
274
- Select vector_illustration style and optional substyle.
275
- """
276
-
277
- RECRAFT_STYLE = RecraftStyleV3.vector_illustration
278
-
279
-
280
- class RecraftStyleV3LogoRasterNode(RecraftStyleV3RealisticImageNode):
281
- """
282
- Select vector_illustration style and optional substyle.
283
- """
284
-
285
- @classmethod
286
- def INPUT_TYPES(s):
287
- return {
288
- "required": {
289
- "substyle": (get_v3_substyles(s.RECRAFT_STYLE, include_none=False),),
290
- }
291
- }
292
-
293
- RECRAFT_STYLE = RecraftStyleV3.logo_raster
294
-
295
-
296
- class RecraftStyleInfiniteStyleLibrary:
297
- """
298
- Select style based on preexisting UUID from Recraft's Infinite Style Library.
299
- """
300
-
301
- RETURN_TYPES = (RecraftIO.STYLEV3,)
302
- RETURN_NAMES = ("recraft_style",)
303
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
304
- FUNCTION = "create_style"
305
- CATEGORY = "api node/image/Recraft"
306
-
307
- @classmethod
308
- def INPUT_TYPES(s):
309
- return {
310
- "required": {
311
- "style_id": (IO.STRING, {
312
- "default": "",
313
- "tooltip": "UUID of style from Infinite Style Library.",
314
- })
315
- }
316
- }
317
-
318
- def create_style(self, style_id: str):
319
- if not style_id:
320
- raise Exception("The style_id input cannot be empty.")
321
- return (RecraftStyle(style_id=style_id),)
322
-
323
-
324
- class RecraftTextToImageNode:
325
- """
326
- Generates images synchronously based on prompt and resolution.
327
- """
328
-
329
- RETURN_TYPES = (IO.IMAGE,)
330
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
331
- FUNCTION = "api_call"
332
- API_NODE = True
333
- CATEGORY = "api node/image/Recraft"
334
-
335
- @classmethod
336
- def INPUT_TYPES(s):
337
- return {
338
- "required": {
339
- "prompt": (
340
- IO.STRING,
341
- {
342
- "multiline": True,
343
- "default": "",
344
- "tooltip": "Prompt for the image generation.",
345
- },
346
- ),
347
- "size": (
348
- [res.value for res in RecraftImageSize],
349
- {
350
- "default": RecraftImageSize.res_1024x1024,
351
- "tooltip": "The size of the generated image.",
352
- },
353
- ),
354
- "n": (
355
- IO.INT,
356
- {
357
- "default": 1,
358
- "min": 1,
359
- "max": 6,
360
- "tooltip": "The number of images to generate.",
361
- },
362
- ),
363
- "seed": (
364
- IO.INT,
365
- {
366
- "default": 0,
367
- "min": 0,
368
- "max": 0xFFFFFFFFFFFFFFFF,
369
- "control_after_generate": True,
370
- "tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
371
- },
372
- ),
373
- },
374
- "optional": {
375
- "recraft_style": (RecraftIO.STYLEV3,),
376
- "negative_prompt": (
377
- IO.STRING,
378
- {
379
- "default": "",
380
- "forceInput": True,
381
- "tooltip": "An optional text description of undesired elements on an image.",
382
- },
383
- ),
384
- "recraft_controls": (
385
- RecraftIO.CONTROLS,
386
- {
387
- "tooltip": "Optional additional controls over the generation via the Recraft Controls node."
388
- },
389
- ),
390
- },
391
- "hidden": {
392
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
393
- "comfy_api_key": "API_KEY_COMFY_ORG",
394
- "unique_id": "UNIQUE_ID",
395
- },
396
- }
397
-
398
- def api_call(
399
- self,
400
- prompt: str,
401
- size: str,
402
- n: int,
403
- seed,
404
- recraft_style: RecraftStyle = None,
405
- negative_prompt: str = None,
406
- recraft_controls: RecraftControls = None,
407
- unique_id: Optional[str] = None,
408
- **kwargs,
409
- ):
410
- validate_string(prompt, strip_whitespace=False, max_length=1000)
411
- default_style = RecraftStyle(RecraftStyleV3.realistic_image)
412
- if recraft_style is None:
413
- recraft_style = default_style
414
-
415
- controls_api = None
416
- if recraft_controls:
417
- controls_api = recraft_controls.create_api_model()
418
-
419
- if not negative_prompt:
420
- negative_prompt = None
421
-
422
- operation = SynchronousOperation(
423
- endpoint=ApiEndpoint(
424
- path="/proxy/recraft/image_generation",
425
- method=HttpMethod.POST,
426
- request_model=RecraftImageGenerationRequest,
427
- response_model=RecraftImageGenerationResponse,
428
- ),
429
- request=RecraftImageGenerationRequest(
430
- prompt=prompt,
431
- negative_prompt=negative_prompt,
432
- model=RecraftModel.recraftv3,
433
- size=size,
434
- n=n,
435
- style=recraft_style.style,
436
- substyle=recraft_style.substyle,
437
- style_id=recraft_style.style_id,
438
- controls=controls_api,
439
- ),
440
- auth_kwargs=kwargs,
441
- )
442
- response: RecraftImageGenerationResponse = operation.execute()
443
- images = []
444
- urls = []
445
- for data in response.data:
446
- with handle_recraft_image_output():
447
- if unique_id and data.url:
448
- urls.append(data.url)
449
- urls_string = '\n'.join(urls)
450
- PromptServer.instance.send_progress_text(
451
- f"Result URL: {urls_string}", unique_id
452
- )
453
- image = bytesio_to_image_tensor(
454
- download_url_to_bytesio(data.url, timeout=1024)
455
- )
456
- if len(image.shape) < 4:
457
- image = image.unsqueeze(0)
458
- images.append(image)
459
- output_image = torch.cat(images, dim=0)
460
-
461
- return (output_image,)
462
-
463
-
464
- class RecraftImageToImageNode:
465
- """
466
- Modify image based on prompt and strength.
467
- """
468
-
469
- RETURN_TYPES = (IO.IMAGE,)
470
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
471
- FUNCTION = "api_call"
472
- API_NODE = True
473
- CATEGORY = "api node/image/Recraft"
474
-
475
- @classmethod
476
- def INPUT_TYPES(s):
477
- return {
478
- "required": {
479
- "image": (IO.IMAGE, ),
480
- "prompt": (
481
- IO.STRING,
482
- {
483
- "multiline": True,
484
- "default": "",
485
- "tooltip": "Prompt for the image generation.",
486
- },
487
- ),
488
- "n": (
489
- IO.INT,
490
- {
491
- "default": 1,
492
- "min": 1,
493
- "max": 6,
494
- "tooltip": "The number of images to generate.",
495
- },
496
- ),
497
- "strength": (
498
- IO.FLOAT,
499
- {
500
- "default": 0.5,
501
- "min": 0.0,
502
- "max": 1.0,
503
- "step": 0.01,
504
- "tooltip": "Defines the difference with the original image, should lie in [0, 1], where 0 means almost identical, and 1 means miserable similarity."
505
- }
506
- ),
507
- "seed": (
508
- IO.INT,
509
- {
510
- "default": 0,
511
- "min": 0,
512
- "max": 0xFFFFFFFFFFFFFFFF,
513
- "control_after_generate": True,
514
- "tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
515
- },
516
- ),
517
- },
518
- "optional": {
519
- "recraft_style": (RecraftIO.STYLEV3,),
520
- "negative_prompt": (
521
- IO.STRING,
522
- {
523
- "default": "",
524
- "forceInput": True,
525
- "tooltip": "An optional text description of undesired elements on an image.",
526
- },
527
- ),
528
- "recraft_controls": (
529
- RecraftIO.CONTROLS,
530
- {
531
- "tooltip": "Optional additional controls over the generation via the Recraft Controls node."
532
- },
533
- ),
534
- },
535
- "hidden": {
536
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
537
- "comfy_api_key": "API_KEY_COMFY_ORG",
538
- },
539
- }
540
-
541
- def api_call(
542
- self,
543
- image: torch.Tensor,
544
- prompt: str,
545
- n: int,
546
- strength: float,
547
- seed,
548
- recraft_style: RecraftStyle = None,
549
- negative_prompt: str = None,
550
- recraft_controls: RecraftControls = None,
551
- **kwargs,
552
- ):
553
- validate_string(prompt, strip_whitespace=False, max_length=1000)
554
- default_style = RecraftStyle(RecraftStyleV3.realistic_image)
555
- if recraft_style is None:
556
- recraft_style = default_style
557
-
558
- controls_api = None
559
- if recraft_controls:
560
- controls_api = recraft_controls.create_api_model()
561
-
562
- if not negative_prompt:
563
- negative_prompt = None
564
-
565
- request = RecraftImageGenerationRequest(
566
- prompt=prompt,
567
- negative_prompt=negative_prompt,
568
- model=RecraftModel.recraftv3,
569
- n=n,
570
- strength=round(strength, 2),
571
- style=recraft_style.style,
572
- substyle=recraft_style.substyle,
573
- style_id=recraft_style.style_id,
574
- controls=controls_api,
575
- )
576
-
577
- images = []
578
- total = image.shape[0]
579
- pbar = ProgressBar(total)
580
- for i in range(total):
581
- sub_bytes = handle_recraft_file_request(
582
- image=image[i],
583
- path="/proxy/recraft/images/imageToImage",
584
- request=request,
585
- auth_kwargs=kwargs,
586
- )
587
- with handle_recraft_image_output():
588
- images.append(torch.cat([bytesio_to_image_tensor(x) for x in sub_bytes], dim=0))
589
- pbar.update(1)
590
-
591
- images_tensor = torch.cat(images, dim=0)
592
- return (images_tensor, )
593
-
594
-
595
- class RecraftImageInpaintingNode:
596
- """
597
- Modify image based on prompt and mask.
598
- """
599
-
600
- RETURN_TYPES = (IO.IMAGE,)
601
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
602
- FUNCTION = "api_call"
603
- API_NODE = True
604
- CATEGORY = "api node/image/Recraft"
605
-
606
- @classmethod
607
- def INPUT_TYPES(s):
608
- return {
609
- "required": {
610
- "image": (IO.IMAGE, ),
611
- "mask": (IO.MASK, ),
612
- "prompt": (
613
- IO.STRING,
614
- {
615
- "multiline": True,
616
- "default": "",
617
- "tooltip": "Prompt for the image generation.",
618
- },
619
- ),
620
- "n": (
621
- IO.INT,
622
- {
623
- "default": 1,
624
- "min": 1,
625
- "max": 6,
626
- "tooltip": "The number of images to generate.",
627
- },
628
- ),
629
- "seed": (
630
- IO.INT,
631
- {
632
- "default": 0,
633
- "min": 0,
634
- "max": 0xFFFFFFFFFFFFFFFF,
635
- "control_after_generate": True,
636
- "tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
637
- },
638
- ),
639
- },
640
- "optional": {
641
- "recraft_style": (RecraftIO.STYLEV3,),
642
- "negative_prompt": (
643
- IO.STRING,
644
- {
645
- "default": "",
646
- "forceInput": True,
647
- "tooltip": "An optional text description of undesired elements on an image.",
648
- },
649
- ),
650
- },
651
- "hidden": {
652
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
653
- "comfy_api_key": "API_KEY_COMFY_ORG",
654
- },
655
- }
656
-
657
- def api_call(
658
- self,
659
- image: torch.Tensor,
660
- mask: torch.Tensor,
661
- prompt: str,
662
- n: int,
663
- seed,
664
- recraft_style: RecraftStyle = None,
665
- negative_prompt: str = None,
666
- **kwargs,
667
- ):
668
- validate_string(prompt, strip_whitespace=False, max_length=1000)
669
- default_style = RecraftStyle(RecraftStyleV3.realistic_image)
670
- if recraft_style is None:
671
- recraft_style = default_style
672
-
673
- if not negative_prompt:
674
- negative_prompt = None
675
-
676
- request = RecraftImageGenerationRequest(
677
- prompt=prompt,
678
- negative_prompt=negative_prompt,
679
- model=RecraftModel.recraftv3,
680
- n=n,
681
- style=recraft_style.style,
682
- substyle=recraft_style.substyle,
683
- style_id=recraft_style.style_id,
684
- )
685
-
686
- # prepare mask tensor
687
- mask = resize_mask_to_image(mask, image, allow_gradient=False, add_channel_dim=True)
688
-
689
- images = []
690
- total = image.shape[0]
691
- pbar = ProgressBar(total)
692
- for i in range(total):
693
- sub_bytes = handle_recraft_file_request(
694
- image=image[i],
695
- mask=mask[i:i+1],
696
- path="/proxy/recraft/images/inpaint",
697
- request=request,
698
- auth_kwargs=kwargs,
699
- )
700
- with handle_recraft_image_output():
701
- images.append(torch.cat([bytesio_to_image_tensor(x) for x in sub_bytes], dim=0))
702
- pbar.update(1)
703
-
704
- images_tensor = torch.cat(images, dim=0)
705
- return (images_tensor, )
706
-
707
-
708
- class RecraftTextToVectorNode:
709
- """
710
- Generates SVG synchronously based on prompt and resolution.
711
- """
712
-
713
- RETURN_TYPES = ("SVG",) # Changed
714
- DESCRIPTION = cleandoc(__doc__ or "") if 'cleandoc' in globals() else __doc__ # Keep cleandoc if other nodes use it
715
- FUNCTION = "api_call"
716
- API_NODE = True
717
- CATEGORY = "api node/image/Recraft"
718
-
719
- @classmethod
720
- def INPUT_TYPES(s):
721
- return {
722
- "required": {
723
- "prompt": (
724
- IO.STRING,
725
- {
726
- "multiline": True,
727
- "default": "",
728
- "tooltip": "Prompt for the image generation.",
729
- },
730
- ),
731
- "substyle": (get_v3_substyles(RecraftStyleV3.vector_illustration),),
732
- "size": (
733
- [res.value for res in RecraftImageSize],
734
- {
735
- "default": RecraftImageSize.res_1024x1024,
736
- "tooltip": "The size of the generated image.",
737
- },
738
- ),
739
- "n": (
740
- IO.INT,
741
- {
742
- "default": 1,
743
- "min": 1,
744
- "max": 6,
745
- "tooltip": "The number of images to generate.",
746
- },
747
- ),
748
- "seed": (
749
- IO.INT,
750
- {
751
- "default": 0,
752
- "min": 0,
753
- "max": 0xFFFFFFFFFFFFFFFF,
754
- "control_after_generate": True,
755
- "tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
756
- },
757
- ),
758
- },
759
- "optional": {
760
- "negative_prompt": (
761
- IO.STRING,
762
- {
763
- "default": "",
764
- "forceInput": True,
765
- "tooltip": "An optional text description of undesired elements on an image.",
766
- },
767
- ),
768
- "recraft_controls": (
769
- RecraftIO.CONTROLS,
770
- {
771
- "tooltip": "Optional additional controls over the generation via the Recraft Controls node."
772
- },
773
- ),
774
- },
775
- "hidden": {
776
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
777
- "comfy_api_key": "API_KEY_COMFY_ORG",
778
- "unique_id": "UNIQUE_ID",
779
- },
780
- }
781
-
782
- def api_call(
783
- self,
784
- prompt: str,
785
- substyle: str,
786
- size: str,
787
- n: int,
788
- seed,
789
- negative_prompt: str = None,
790
- recraft_controls: RecraftControls = None,
791
- unique_id: Optional[str] = None,
792
- **kwargs,
793
- ):
794
- validate_string(prompt, strip_whitespace=False, max_length=1000)
795
- # create RecraftStyle so strings will be formatted properly (i.e. "None" will become None)
796
- recraft_style = RecraftStyle(RecraftStyleV3.vector_illustration, substyle=substyle)
797
-
798
- controls_api = None
799
- if recraft_controls:
800
- controls_api = recraft_controls.create_api_model()
801
-
802
- if not negative_prompt:
803
- negative_prompt = None
804
-
805
- operation = SynchronousOperation(
806
- endpoint=ApiEndpoint(
807
- path="/proxy/recraft/image_generation",
808
- method=HttpMethod.POST,
809
- request_model=RecraftImageGenerationRequest,
810
- response_model=RecraftImageGenerationResponse,
811
- ),
812
- request=RecraftImageGenerationRequest(
813
- prompt=prompt,
814
- negative_prompt=negative_prompt,
815
- model=RecraftModel.recraftv3,
816
- size=size,
817
- n=n,
818
- style=recraft_style.style,
819
- substyle=recraft_style.substyle,
820
- controls=controls_api,
821
- ),
822
- auth_kwargs=kwargs,
823
- )
824
- response: RecraftImageGenerationResponse = operation.execute()
825
- svg_data = []
826
- urls = []
827
- for data in response.data:
828
- if unique_id and data.url:
829
- urls.append(data.url)
830
- # Print result on each iteration in case of error
831
- PromptServer.instance.send_progress_text(
832
- f"Result URL: {' '.join(urls)}", unique_id
833
- )
834
- svg_data.append(download_url_to_bytesio(data.url, timeout=1024))
835
-
836
- return (SVG(svg_data),)
837
-
838
-
839
- class RecraftVectorizeImageNode:
840
- """
841
- Generates SVG synchronously from an input image.
842
- """
843
-
844
- RETURN_TYPES = ("SVG",) # Changed
845
- DESCRIPTION = cleandoc(__doc__ or "") if 'cleandoc' in globals() else __doc__ # Keep cleandoc if other nodes use it
846
- FUNCTION = "api_call"
847
- API_NODE = True
848
- CATEGORY = "api node/image/Recraft"
849
-
850
- @classmethod
851
- def INPUT_TYPES(s):
852
- return {
853
- "required": {
854
- "image": (IO.IMAGE, ),
855
- },
856
- "optional": {
857
- },
858
- "hidden": {
859
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
860
- "comfy_api_key": "API_KEY_COMFY_ORG",
861
- },
862
- }
863
-
864
- def api_call(
865
- self,
866
- image: torch.Tensor,
867
- **kwargs,
868
- ):
869
- svgs = []
870
- total = image.shape[0]
871
- pbar = ProgressBar(total)
872
- for i in range(total):
873
- sub_bytes = handle_recraft_file_request(
874
- image=image[i],
875
- path="/proxy/recraft/images/vectorize",
876
- auth_kwargs=kwargs,
877
- )
878
- svgs.append(SVG(sub_bytes))
879
- pbar.update(1)
880
-
881
- return (SVG.combine_all(svgs), )
882
-
883
-
884
- class RecraftReplaceBackgroundNode:
885
- """
886
- Replace background on image, based on provided prompt.
887
- """
888
-
889
- RETURN_TYPES = (IO.IMAGE,)
890
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
891
- FUNCTION = "api_call"
892
- API_NODE = True
893
- CATEGORY = "api node/image/Recraft"
894
-
895
- @classmethod
896
- def INPUT_TYPES(s):
897
- return {
898
- "required": {
899
- "image": (IO.IMAGE, ),
900
- "prompt": (
901
- IO.STRING,
902
- {
903
- "multiline": True,
904
- "default": "",
905
- "tooltip": "Prompt for the image generation.",
906
- },
907
- ),
908
- "n": (
909
- IO.INT,
910
- {
911
- "default": 1,
912
- "min": 1,
913
- "max": 6,
914
- "tooltip": "The number of images to generate.",
915
- },
916
- ),
917
- "seed": (
918
- IO.INT,
919
- {
920
- "default": 0,
921
- "min": 0,
922
- "max": 0xFFFFFFFFFFFFFFFF,
923
- "control_after_generate": True,
924
- "tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed.",
925
- },
926
- ),
927
- },
928
- "optional": {
929
- "recraft_style": (RecraftIO.STYLEV3,),
930
- "negative_prompt": (
931
- IO.STRING,
932
- {
933
- "default": "",
934
- "forceInput": True,
935
- "tooltip": "An optional text description of undesired elements on an image.",
936
- },
937
- ),
938
- },
939
- "hidden": {
940
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
941
- "comfy_api_key": "API_KEY_COMFY_ORG",
942
- },
943
- }
944
-
945
- def api_call(
946
- self,
947
- image: torch.Tensor,
948
- prompt: str,
949
- n: int,
950
- seed,
951
- recraft_style: RecraftStyle = None,
952
- negative_prompt: str = None,
953
- **kwargs,
954
- ):
955
- default_style = RecraftStyle(RecraftStyleV3.realistic_image)
956
- if recraft_style is None:
957
- recraft_style = default_style
958
-
959
- if not negative_prompt:
960
- negative_prompt = None
961
-
962
- request = RecraftImageGenerationRequest(
963
- prompt=prompt,
964
- negative_prompt=negative_prompt,
965
- model=RecraftModel.recraftv3,
966
- n=n,
967
- style=recraft_style.style,
968
- substyle=recraft_style.substyle,
969
- style_id=recraft_style.style_id,
970
- )
971
-
972
- images = []
973
- total = image.shape[0]
974
- pbar = ProgressBar(total)
975
- for i in range(total):
976
- sub_bytes = handle_recraft_file_request(
977
- image=image[i],
978
- path="/proxy/recraft/images/replaceBackground",
979
- request=request,
980
- auth_kwargs=kwargs,
981
- )
982
- images.append(torch.cat([bytesio_to_image_tensor(x) for x in sub_bytes], dim=0))
983
- pbar.update(1)
984
-
985
- images_tensor = torch.cat(images, dim=0)
986
- return (images_tensor, )
987
-
988
-
989
- class RecraftRemoveBackgroundNode:
990
- """
991
- Remove background from image, and return processed image and mask.
992
- """
993
-
994
- RETURN_TYPES = (IO.IMAGE, IO.MASK)
995
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
996
- FUNCTION = "api_call"
997
- API_NODE = True
998
- CATEGORY = "api node/image/Recraft"
999
-
1000
- @classmethod
1001
- def INPUT_TYPES(s):
1002
- return {
1003
- "required": {
1004
- "image": (IO.IMAGE, ),
1005
- },
1006
- "optional": {
1007
- },
1008
- "hidden": {
1009
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
1010
- "comfy_api_key": "API_KEY_COMFY_ORG",
1011
- },
1012
- }
1013
-
1014
- def api_call(
1015
- self,
1016
- image: torch.Tensor,
1017
- **kwargs,
1018
- ):
1019
- images = []
1020
- total = image.shape[0]
1021
- pbar = ProgressBar(total)
1022
- for i in range(total):
1023
- sub_bytes = handle_recraft_file_request(
1024
- image=image[i],
1025
- path="/proxy/recraft/images/removeBackground",
1026
- auth_kwargs=kwargs,
1027
- )
1028
- images.append(torch.cat([bytesio_to_image_tensor(x) for x in sub_bytes], dim=0))
1029
- pbar.update(1)
1030
-
1031
- images_tensor = torch.cat(images, dim=0)
1032
- # use alpha channel as masks, in B,H,W format
1033
- masks_tensor = images_tensor[:,:,:,-1:].squeeze(-1)
1034
- return (images_tensor, masks_tensor)
1035
-
1036
-
1037
- class RecraftCrispUpscaleNode:
1038
- """
1039
- Upscale image synchronously.
1040
- Enhances a given raster image using ‘crisp upscale’ tool, increasing image resolution, making the image sharper and cleaner.
1041
- """
1042
-
1043
- RETURN_TYPES = (IO.IMAGE,)
1044
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
1045
- FUNCTION = "api_call"
1046
- API_NODE = True
1047
- CATEGORY = "api node/image/Recraft"
1048
-
1049
- RECRAFT_PATH = "/proxy/recraft/images/crispUpscale"
1050
-
1051
- @classmethod
1052
- def INPUT_TYPES(s):
1053
- return {
1054
- "required": {
1055
- "image": (IO.IMAGE, ),
1056
- },
1057
- "optional": {
1058
- },
1059
- "hidden": {
1060
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
1061
- "comfy_api_key": "API_KEY_COMFY_ORG",
1062
- },
1063
- }
1064
-
1065
- def api_call(
1066
- self,
1067
- image: torch.Tensor,
1068
- **kwargs,
1069
- ):
1070
- images = []
1071
- total = image.shape[0]
1072
- pbar = ProgressBar(total)
1073
- for i in range(total):
1074
- sub_bytes = handle_recraft_file_request(
1075
- image=image[i],
1076
- path=self.RECRAFT_PATH,
1077
- auth_kwargs=kwargs,
1078
- )
1079
- images.append(torch.cat([bytesio_to_image_tensor(x) for x in sub_bytes], dim=0))
1080
- pbar.update(1)
1081
-
1082
- images_tensor = torch.cat(images, dim=0)
1083
- return (images_tensor,)
1084
-
1085
-
1086
- class RecraftCreativeUpscaleNode(RecraftCrispUpscaleNode):
1087
- """
1088
- Upscale image synchronously.
1089
- Enhances a given raster image using ‘creative upscale’ tool, boosting resolution with a focus on refining small details and faces.
1090
- """
1091
-
1092
- RETURN_TYPES = (IO.IMAGE,)
1093
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
1094
- FUNCTION = "api_call"
1095
- API_NODE = True
1096
- CATEGORY = "api node/image/Recraft"
1097
-
1098
- RECRAFT_PATH = "/proxy/recraft/images/creativeUpscale"
1099
-
1100
-
1101
- # A dictionary that contains all nodes you want to export with their names
1102
- # NOTE: names should be globally unique
1103
- NODE_CLASS_MAPPINGS = {
1104
- "RecraftTextToImageNode": RecraftTextToImageNode,
1105
- "RecraftImageToImageNode": RecraftImageToImageNode,
1106
- "RecraftImageInpaintingNode": RecraftImageInpaintingNode,
1107
- "RecraftTextToVectorNode": RecraftTextToVectorNode,
1108
- "RecraftVectorizeImageNode": RecraftVectorizeImageNode,
1109
- "RecraftRemoveBackgroundNode": RecraftRemoveBackgroundNode,
1110
- "RecraftReplaceBackgroundNode": RecraftReplaceBackgroundNode,
1111
- "RecraftCrispUpscaleNode": RecraftCrispUpscaleNode,
1112
- "RecraftCreativeUpscaleNode": RecraftCreativeUpscaleNode,
1113
- "RecraftStyleV3RealisticImage": RecraftStyleV3RealisticImageNode,
1114
- "RecraftStyleV3DigitalIllustration": RecraftStyleV3DigitalIllustrationNode,
1115
- "RecraftStyleV3LogoRaster": RecraftStyleV3LogoRasterNode,
1116
- "RecraftStyleV3InfiniteStyleLibrary": RecraftStyleInfiniteStyleLibrary,
1117
- "RecraftColorRGB": RecraftColorRGBNode,
1118
- "RecraftControls": RecraftControlsNode,
1119
- }
1120
-
1121
- # A dictionary that contains the friendly/humanly readable titles for the nodes
1122
- NODE_DISPLAY_NAME_MAPPINGS = {
1123
- "RecraftTextToImageNode": "Recraft Text to Image",
1124
- "RecraftImageToImageNode": "Recraft Image to Image",
1125
- "RecraftImageInpaintingNode": "Recraft Image Inpainting",
1126
- "RecraftTextToVectorNode": "Recraft Text to Vector",
1127
- "RecraftVectorizeImageNode": "Recraft Vectorize Image",
1128
- "RecraftRemoveBackgroundNode": "Recraft Remove Background",
1129
- "RecraftReplaceBackgroundNode": "Recraft Replace Background",
1130
- "RecraftCrispUpscaleNode": "Recraft Crisp Upscale Image",
1131
- "RecraftCreativeUpscaleNode": "Recraft Creative Upscale Image",
1132
- "RecraftStyleV3RealisticImage": "Recraft Style - Realistic Image",
1133
- "RecraftStyleV3DigitalIllustration": "Recraft Style - Digital Illustration",
1134
- "RecraftStyleV3LogoRaster": "Recraft Style - Logo Raster",
1135
- "RecraftStyleV3InfiniteStyleLibrary": "Recraft Style - Infinite Style Library",
1136
- "RecraftColorRGB": "Recraft Color RGB",
1137
- "RecraftControls": "Recraft Controls",
1138
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/nodes_rodin.py DELETED
@@ -1,462 +0,0 @@
1
- """
2
- ComfyUI X Rodin3D(Deemos) API Nodes
3
-
4
- Rodin API docs: https://developer.hyper3d.ai/
5
-
6
- """
7
-
8
- from __future__ import annotations
9
- from inspect import cleandoc
10
- from comfy.comfy_types.node_typing import IO
11
- import folder_paths as comfy_paths
12
- import requests
13
- import os
14
- import datetime
15
- import shutil
16
- import time
17
- import io
18
- import logging
19
- import math
20
- from PIL import Image
21
- from comfy_api_nodes.apis.rodin_api import (
22
- Rodin3DGenerateRequest,
23
- Rodin3DGenerateResponse,
24
- Rodin3DCheckStatusRequest,
25
- Rodin3DCheckStatusResponse,
26
- Rodin3DDownloadRequest,
27
- Rodin3DDownloadResponse,
28
- JobStatus,
29
- )
30
- from comfy_api_nodes.apis.client import (
31
- ApiEndpoint,
32
- HttpMethod,
33
- SynchronousOperation,
34
- PollingOperation,
35
- )
36
-
37
-
38
- COMMON_PARAMETERS = {
39
- "Seed": (
40
- IO.INT,
41
- {
42
- "default":0,
43
- "min":0,
44
- "max":65535,
45
- "display":"number"
46
- }
47
- ),
48
- "Material_Type": (
49
- IO.COMBO,
50
- {
51
- "options": ["PBR", "Shaded"],
52
- "default": "PBR"
53
- }
54
- ),
55
- "Polygon_count": (
56
- IO.COMBO,
57
- {
58
- "options": ["4K-Quad", "8K-Quad", "18K-Quad", "50K-Quad", "200K-Triangle"],
59
- "default": "18K-Quad"
60
- }
61
- )
62
- }
63
-
64
- def create_task_error(response: Rodin3DGenerateResponse):
65
- """Check if the response has error"""
66
- return hasattr(response, "error")
67
-
68
-
69
-
70
- class Rodin3DAPI:
71
- """
72
- Generate 3D Assets using Rodin API
73
- """
74
- RETURN_TYPES = (IO.STRING,)
75
- RETURN_NAMES = ("3D Model Path",)
76
- CATEGORY = "api node/3d/Rodin"
77
- DESCRIPTION = cleandoc(__doc__ or "")
78
- FUNCTION = "api_call"
79
- API_NODE = True
80
-
81
- def tensor_to_filelike(self, tensor, max_pixels: int = 2048*2048):
82
- """
83
- Converts a PyTorch tensor to a file-like object.
84
-
85
- Args:
86
- - tensor (torch.Tensor): A tensor representing an image of shape (H, W, C)
87
- where C is the number of channels (3 for RGB), H is height, and W is width.
88
-
89
- Returns:
90
- - io.BytesIO: A file-like object containing the image data.
91
- """
92
- array = tensor.cpu().numpy()
93
- array = (array * 255).astype('uint8')
94
- image = Image.fromarray(array, 'RGB')
95
-
96
- original_width, original_height = image.size
97
- original_pixels = original_width * original_height
98
- if original_pixels > max_pixels:
99
- scale = math.sqrt(max_pixels / original_pixels)
100
- new_width = int(original_width * scale)
101
- new_height = int(original_height * scale)
102
- else:
103
- new_width, new_height = original_width, original_height
104
-
105
- if new_width != original_width or new_height != original_height:
106
- image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
107
-
108
- img_byte_arr = io.BytesIO()
109
- image.save(img_byte_arr, format='PNG') # PNG is used for lossless compression
110
- img_byte_arr.seek(0)
111
- return img_byte_arr
112
-
113
- def check_rodin_status(self, response: Rodin3DCheckStatusResponse) -> str:
114
- has_failed = any(job.status == JobStatus.Failed for job in response.jobs)
115
- all_done = all(job.status == JobStatus.Done for job in response.jobs)
116
- status_list = [str(job.status) for job in response.jobs]
117
- logging.info(f"[ Rodin3D API - CheckStatus ] Generate Status: {status_list}")
118
- if has_failed:
119
- logging.error(f"[ Rodin3D API - CheckStatus ] Generate Failed: {status_list}, Please try again.")
120
- raise Exception("[ Rodin3D API ] Generate Failed, Please Try again.")
121
- elif all_done:
122
- return "DONE"
123
- else:
124
- return "Generating"
125
-
126
- def CreateGenerateTask(self, images=None, seed=1, material="PBR", quality="medium", tier="Regular", mesh_mode="Quad", **kwargs):
127
- if images == None:
128
- raise Exception("Rodin 3D generate requires at least 1 image.")
129
- if len(images) >= 5:
130
- raise Exception("Rodin 3D generate requires up to 5 image.")
131
-
132
- path = "/proxy/rodin/api/v2/rodin"
133
- operation = SynchronousOperation(
134
- endpoint=ApiEndpoint(
135
- path=path,
136
- method=HttpMethod.POST,
137
- request_model=Rodin3DGenerateRequest,
138
- response_model=Rodin3DGenerateResponse,
139
- ),
140
- request=Rodin3DGenerateRequest(
141
- seed=seed,
142
- tier=tier,
143
- material=material,
144
- quality=quality,
145
- mesh_mode=mesh_mode
146
- ),
147
- files=[
148
- (
149
- "images",
150
- open(image, "rb") if isinstance(image, str) else self.tensor_to_filelike(image)
151
- )
152
- for image in images if image is not None
153
- ],
154
- content_type = "multipart/form-data",
155
- auth_kwargs=kwargs,
156
- )
157
-
158
- response = operation.execute()
159
-
160
- if create_task_error(response):
161
- error_message = f"Rodin3D Create 3D generate Task Failed. Message: {response.message}, error: {response.error}"
162
- logging.error(error_message)
163
- raise Exception(error_message)
164
-
165
- logging.info("[ Rodin3D API - Submit Jobs ] Submit Generate Task Success!")
166
- subscription_key = response.jobs.subscription_key
167
- task_uuid = response.uuid
168
- logging.info(f"[ Rodin3D API - Submit Jobs ] UUID: {task_uuid}")
169
- return task_uuid, subscription_key
170
-
171
- def poll_for_task_status(self, subscription_key, **kwargs) -> Rodin3DCheckStatusResponse:
172
-
173
- path = "/proxy/rodin/api/v2/status"
174
-
175
- poll_operation = PollingOperation(
176
- poll_endpoint=ApiEndpoint(
177
- path = path,
178
- method=HttpMethod.POST,
179
- request_model=Rodin3DCheckStatusRequest,
180
- response_model=Rodin3DCheckStatusResponse,
181
- ),
182
- request=Rodin3DCheckStatusRequest(
183
- subscription_key = subscription_key
184
- ),
185
- completed_statuses=["DONE"],
186
- failed_statuses=["FAILED"],
187
- status_extractor=self.check_rodin_status,
188
- poll_interval=3.0,
189
- auth_kwargs=kwargs,
190
- )
191
-
192
- logging.info("[ Rodin3D API - CheckStatus ] Generate Start!")
193
-
194
- return poll_operation.execute()
195
-
196
-
197
-
198
- def GetRodinDownloadList(self, uuid, **kwargs) -> Rodin3DDownloadResponse:
199
- logging.info("[ Rodin3D API - Downloading ] Generate Successfully!")
200
-
201
- path = "/proxy/rodin/api/v2/download"
202
- operation = SynchronousOperation(
203
- endpoint=ApiEndpoint(
204
- path=path,
205
- method=HttpMethod.POST,
206
- request_model=Rodin3DDownloadRequest,
207
- response_model=Rodin3DDownloadResponse,
208
- ),
209
- request=Rodin3DDownloadRequest(
210
- task_uuid=uuid
211
- ),
212
- auth_kwargs=kwargs
213
- )
214
-
215
- return operation.execute()
216
-
217
- def GetQualityAndMode(self, PolyCount):
218
- if PolyCount == "200K-Triangle":
219
- mesh_mode = "Raw"
220
- quality = "medium"
221
- else:
222
- mesh_mode = "Quad"
223
- if PolyCount == "4K-Quad":
224
- quality = "extra-low"
225
- elif PolyCount == "8K-Quad":
226
- quality = "low"
227
- elif PolyCount == "18K-Quad":
228
- quality = "medium"
229
- elif PolyCount == "50K-Quad":
230
- quality = "high"
231
- else:
232
- quality = "medium"
233
-
234
- return mesh_mode, quality
235
-
236
- def DownLoadFiles(self, Url_List):
237
- Save_path = os.path.join(comfy_paths.get_output_directory(), "Rodin3D", datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S"))
238
- os.makedirs(Save_path, exist_ok=True)
239
- model_file_path = None
240
- for Item in Url_List.list:
241
- url = Item.url
242
- file_name = Item.name
243
- file_path = os.path.join(Save_path, file_name)
244
- if file_path.endswith(".glb"):
245
- model_file_path = file_path
246
- logging.info(f"[ Rodin3D API - download_files ] Downloading file: {file_path}")
247
- max_retries = 5
248
- for attempt in range(max_retries):
249
- try:
250
- with requests.get(url, stream=True) as r:
251
- r.raise_for_status()
252
- with open(file_path, "wb") as f:
253
- shutil.copyfileobj(r.raw, f)
254
- break
255
- except Exception as e:
256
- logging.info(f"[ Rodin3D API - download_files ] Error downloading {file_path}:{e}")
257
- if attempt < max_retries - 1:
258
- logging.info("Retrying...")
259
- time.sleep(2)
260
- else:
261
- logging.info(f"[ Rodin3D API - download_files ] Failed to download {file_path} after {max_retries} attempts.")
262
-
263
- return model_file_path
264
-
265
-
266
- class Rodin3D_Regular(Rodin3DAPI):
267
- @classmethod
268
- def INPUT_TYPES(s):
269
- return {
270
- "required": {
271
- "Images":
272
- (
273
- IO.IMAGE,
274
- {
275
- "forceInput":True,
276
- }
277
- )
278
- },
279
- "optional": {
280
- **COMMON_PARAMETERS
281
- },
282
- "hidden": {
283
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
284
- "comfy_api_key": "API_KEY_COMFY_ORG",
285
- },
286
- }
287
-
288
- def api_call(
289
- self,
290
- Images,
291
- Seed,
292
- Material_Type,
293
- Polygon_count,
294
- **kwargs
295
- ):
296
- tier = "Regular"
297
- num_images = Images.shape[0]
298
- m_images = []
299
- for i in range(num_images):
300
- m_images.append(Images[i])
301
- mesh_mode, quality = self.GetQualityAndMode(Polygon_count)
302
- task_uuid, subscription_key = self.CreateGenerateTask(images=m_images, seed=Seed, material=Material_Type, quality=quality, tier=tier, mesh_mode=mesh_mode, **kwargs)
303
- self.poll_for_task_status(subscription_key, **kwargs)
304
- Download_List = self.GetRodinDownloadList(task_uuid, **kwargs)
305
- model = self.DownLoadFiles(Download_List)
306
-
307
- return (model,)
308
-
309
- class Rodin3D_Detail(Rodin3DAPI):
310
- @classmethod
311
- def INPUT_TYPES(s):
312
- return {
313
- "required": {
314
- "Images":
315
- (
316
- IO.IMAGE,
317
- {
318
- "forceInput":True,
319
- }
320
- )
321
- },
322
- "optional": {
323
- **COMMON_PARAMETERS
324
- },
325
- "hidden": {
326
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
327
- "comfy_api_key": "API_KEY_COMFY_ORG",
328
- },
329
- }
330
-
331
- def api_call(
332
- self,
333
- Images,
334
- Seed,
335
- Material_Type,
336
- Polygon_count,
337
- **kwargs
338
- ):
339
- tier = "Detail"
340
- num_images = Images.shape[0]
341
- m_images = []
342
- for i in range(num_images):
343
- m_images.append(Images[i])
344
- mesh_mode, quality = self.GetQualityAndMode(Polygon_count)
345
- task_uuid, subscription_key = self.CreateGenerateTask(images=m_images, seed=Seed, material=Material_Type, quality=quality, tier=tier, mesh_mode=mesh_mode, **kwargs)
346
- self.poll_for_task_status(subscription_key, **kwargs)
347
- Download_List = self.GetRodinDownloadList(task_uuid, **kwargs)
348
- model = self.DownLoadFiles(Download_List)
349
-
350
- return (model,)
351
-
352
- class Rodin3D_Smooth(Rodin3DAPI):
353
- @classmethod
354
- def INPUT_TYPES(s):
355
- return {
356
- "required": {
357
- "Images":
358
- (
359
- IO.IMAGE,
360
- {
361
- "forceInput":True,
362
- }
363
- )
364
- },
365
- "optional": {
366
- **COMMON_PARAMETERS
367
- },
368
- "hidden": {
369
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
370
- "comfy_api_key": "API_KEY_COMFY_ORG",
371
- },
372
- }
373
-
374
- def api_call(
375
- self,
376
- Images,
377
- Seed,
378
- Material_Type,
379
- Polygon_count,
380
- **kwargs
381
- ):
382
- tier = "Smooth"
383
- num_images = Images.shape[0]
384
- m_images = []
385
- for i in range(num_images):
386
- m_images.append(Images[i])
387
- mesh_mode, quality = self.GetQualityAndMode(Polygon_count)
388
- task_uuid, subscription_key = self.CreateGenerateTask(images=m_images, seed=Seed, material=Material_Type, quality=quality, tier=tier, mesh_mode=mesh_mode, **kwargs)
389
- self.poll_for_task_status(subscription_key, **kwargs)
390
- Download_List = self.GetRodinDownloadList(task_uuid, **kwargs)
391
- model = self.DownLoadFiles(Download_List)
392
-
393
- return (model,)
394
-
395
- class Rodin3D_Sketch(Rodin3DAPI):
396
- @classmethod
397
- def INPUT_TYPES(s):
398
- return {
399
- "required": {
400
- "Images":
401
- (
402
- IO.IMAGE,
403
- {
404
- "forceInput":True,
405
- }
406
- )
407
- },
408
- "optional": {
409
- "Seed":
410
- (
411
- IO.INT,
412
- {
413
- "default":0,
414
- "min":0,
415
- "max":65535,
416
- "display":"number"
417
- }
418
- )
419
- },
420
- "hidden": {
421
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
422
- "comfy_api_key": "API_KEY_COMFY_ORG",
423
- },
424
- }
425
-
426
- def api_call(
427
- self,
428
- Images,
429
- Seed,
430
- **kwargs
431
- ):
432
- tier = "Sketch"
433
- num_images = Images.shape[0]
434
- m_images = []
435
- for i in range(num_images):
436
- m_images.append(Images[i])
437
- material_type = "PBR"
438
- quality = "medium"
439
- mesh_mode = "Quad"
440
- task_uuid, subscription_key = self.CreateGenerateTask(images=m_images, seed=Seed, material=material_type, quality=quality, tier=tier, mesh_mode=mesh_mode, **kwargs)
441
- self.poll_for_task_status(subscription_key, **kwargs)
442
- Download_List = self.GetRodinDownloadList(task_uuid, **kwargs)
443
- model = self.DownLoadFiles(Download_List)
444
-
445
- return (model,)
446
-
447
- # A dictionary that contains all nodes you want to export with their names
448
- # NOTE: names should be globally unique
449
- NODE_CLASS_MAPPINGS = {
450
- "Rodin3D_Regular": Rodin3D_Regular,
451
- "Rodin3D_Detail": Rodin3D_Detail,
452
- "Rodin3D_Smooth": Rodin3D_Smooth,
453
- "Rodin3D_Sketch": Rodin3D_Sketch,
454
- }
455
-
456
- # A dictionary that contains the friendly/humanly readable titles for the nodes
457
- NODE_DISPLAY_NAME_MAPPINGS = {
458
- "Rodin3D_Regular": "Rodin 3D Generate - Regular Generate",
459
- "Rodin3D_Detail": "Rodin 3D Generate - Detail Generate",
460
- "Rodin3D_Smooth": "Rodin 3D Generate - Smooth Generate",
461
- "Rodin3D_Sketch": "Rodin 3D Generate - Sketch Generate",
462
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/nodes_runway.py DELETED
@@ -1,635 +0,0 @@
1
- """Runway API Nodes
2
-
3
- API Docs:
4
- - https://docs.dev.runwayml.com/api/#tag/Task-management/paths/~1v1~1tasks~1%7Bid%7D/delete
5
-
6
- User Guides:
7
- - https://help.runwayml.com/hc/en-us/sections/30265301423635-Gen-3-Alpha
8
- - https://help.runwayml.com/hc/en-us/articles/37327109429011-Creating-with-Gen-4-Video
9
- - https://help.runwayml.com/hc/en-us/articles/33927968552339-Creating-with-Act-One-on-Gen-3-Alpha-and-Turbo
10
- - https://help.runwayml.com/hc/en-us/articles/34170748696595-Creating-with-Keyframes-on-Gen-3
11
-
12
- """
13
-
14
- from typing import Union, Optional, Any
15
- from enum import Enum
16
-
17
- import torch
18
-
19
- from comfy_api_nodes.apis import (
20
- RunwayImageToVideoRequest,
21
- RunwayImageToVideoResponse,
22
- RunwayTaskStatusResponse as TaskStatusResponse,
23
- RunwayTaskStatusEnum as TaskStatus,
24
- RunwayModelEnum as Model,
25
- RunwayDurationEnum as Duration,
26
- RunwayAspectRatioEnum as AspectRatio,
27
- RunwayPromptImageObject,
28
- RunwayPromptImageDetailedObject,
29
- RunwayTextToImageRequest,
30
- RunwayTextToImageResponse,
31
- Model4,
32
- ReferenceImage,
33
- RunwayTextToImageAspectRatioEnum,
34
- )
35
- from comfy_api_nodes.apis.client import (
36
- ApiEndpoint,
37
- HttpMethod,
38
- SynchronousOperation,
39
- PollingOperation,
40
- EmptyRequest,
41
- )
42
- from comfy_api_nodes.apinode_utils import (
43
- upload_images_to_comfyapi,
44
- download_url_to_video_output,
45
- image_tensor_pair_to_batch,
46
- validate_string,
47
- download_url_to_image_tensor,
48
- )
49
- from comfy_api_nodes.mapper_utils import model_field_to_node_input
50
- from comfy_api.input_impl import VideoFromFile
51
- from comfy.comfy_types.node_typing import IO, ComfyNodeABC
52
-
53
- PATH_IMAGE_TO_VIDEO = "/proxy/runway/image_to_video"
54
- PATH_TEXT_TO_IMAGE = "/proxy/runway/text_to_image"
55
- PATH_GET_TASK_STATUS = "/proxy/runway/tasks"
56
-
57
- AVERAGE_DURATION_I2V_SECONDS = 64
58
- AVERAGE_DURATION_FLF_SECONDS = 256
59
- AVERAGE_DURATION_T2I_SECONDS = 41
60
-
61
-
62
- class RunwayApiError(Exception):
63
- """Base exception for Runway API errors."""
64
-
65
- pass
66
-
67
-
68
- class RunwayGen4TurboAspectRatio(str, Enum):
69
- """Aspect ratios supported for Image to Video API when using gen4_turbo model."""
70
-
71
- field_1280_720 = "1280:720"
72
- field_720_1280 = "720:1280"
73
- field_1104_832 = "1104:832"
74
- field_832_1104 = "832:1104"
75
- field_960_960 = "960:960"
76
- field_1584_672 = "1584:672"
77
-
78
-
79
- class RunwayGen3aAspectRatio(str, Enum):
80
- """Aspect ratios supported for Image to Video API when using gen3a_turbo model."""
81
-
82
- field_768_1280 = "768:1280"
83
- field_1280_768 = "1280:768"
84
-
85
-
86
- def get_video_url_from_task_status(response: TaskStatusResponse) -> Union[str, None]:
87
- """Returns the video URL from the task status response if it exists."""
88
- if response.output and len(response.output) > 0:
89
- return response.output[0]
90
- return None
91
-
92
-
93
- # TODO: replace with updated image validation utils (upstream)
94
- def validate_input_image(image: torch.Tensor) -> bool:
95
- """
96
- Validate the input image is within the size limits for the Runway API.
97
- See: https://docs.dev.runwayml.com/assets/inputs/#common-error-reasons
98
- """
99
- return image.shape[2] < 8000 and image.shape[1] < 8000
100
-
101
-
102
- def poll_until_finished(
103
- auth_kwargs: dict[str, str],
104
- api_endpoint: ApiEndpoint[Any, TaskStatusResponse],
105
- estimated_duration: Optional[int] = None,
106
- node_id: Optional[str] = None,
107
- ) -> TaskStatusResponse:
108
- """Polls the Runway API endpoint until the task reaches a terminal state, then returns the response."""
109
- return PollingOperation(
110
- poll_endpoint=api_endpoint,
111
- completed_statuses=[
112
- TaskStatus.SUCCEEDED.value,
113
- ],
114
- failed_statuses=[
115
- TaskStatus.FAILED.value,
116
- TaskStatus.CANCELLED.value,
117
- ],
118
- status_extractor=lambda response: (response.status.value),
119
- auth_kwargs=auth_kwargs,
120
- result_url_extractor=get_video_url_from_task_status,
121
- estimated_duration=estimated_duration,
122
- node_id=node_id,
123
- progress_extractor=extract_progress_from_task_status,
124
- ).execute()
125
-
126
-
127
- def extract_progress_from_task_status(
128
- response: TaskStatusResponse,
129
- ) -> Union[float, None]:
130
- if hasattr(response, "progress") and response.progress is not None:
131
- return response.progress * 100
132
- return None
133
-
134
-
135
- def get_image_url_from_task_status(response: TaskStatusResponse) -> Union[str, None]:
136
- """Returns the image URL from the task status response if it exists."""
137
- if response.output and len(response.output) > 0:
138
- return response.output[0]
139
- return None
140
-
141
-
142
- class RunwayVideoGenNode(ComfyNodeABC):
143
- """Runway Video Node Base."""
144
-
145
- RETURN_TYPES = ("VIDEO",)
146
- FUNCTION = "api_call"
147
- CATEGORY = "api node/video/Runway"
148
- API_NODE = True
149
-
150
- def validate_task_created(self, response: RunwayImageToVideoResponse) -> bool:
151
- """
152
- Validate the task creation response from the Runway API matches
153
- expected format.
154
- """
155
- if not bool(response.id):
156
- raise RunwayApiError("Invalid initial response from Runway API.")
157
- return True
158
-
159
- def validate_response(self, response: RunwayImageToVideoResponse) -> bool:
160
- """
161
- Validate the successful task status response from the Runway API
162
- matches expected format.
163
- """
164
- if not response.output or len(response.output) == 0:
165
- raise RunwayApiError(
166
- "Runway task succeeded but no video data found in response."
167
- )
168
- return True
169
-
170
- def get_response(
171
- self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
172
- ) -> RunwayImageToVideoResponse:
173
- """Poll the task status until it is finished then get the response."""
174
- return poll_until_finished(
175
- auth_kwargs,
176
- ApiEndpoint(
177
- path=f"{PATH_GET_TASK_STATUS}/{task_id}",
178
- method=HttpMethod.GET,
179
- request_model=EmptyRequest,
180
- response_model=TaskStatusResponse,
181
- ),
182
- estimated_duration=AVERAGE_DURATION_FLF_SECONDS,
183
- node_id=node_id,
184
- )
185
-
186
- def generate_video(
187
- self,
188
- request: RunwayImageToVideoRequest,
189
- auth_kwargs: dict[str, str],
190
- node_id: Optional[str] = None,
191
- ) -> tuple[VideoFromFile]:
192
- initial_operation = SynchronousOperation(
193
- endpoint=ApiEndpoint(
194
- path=PATH_IMAGE_TO_VIDEO,
195
- method=HttpMethod.POST,
196
- request_model=RunwayImageToVideoRequest,
197
- response_model=RunwayImageToVideoResponse,
198
- ),
199
- request=request,
200
- auth_kwargs=auth_kwargs,
201
- )
202
-
203
- initial_response = initial_operation.execute()
204
- self.validate_task_created(initial_response)
205
- task_id = initial_response.id
206
-
207
- final_response = self.get_response(task_id, auth_kwargs, node_id)
208
- self.validate_response(final_response)
209
-
210
- video_url = get_video_url_from_task_status(final_response)
211
- return (download_url_to_video_output(video_url),)
212
-
213
-
214
- class RunwayImageToVideoNodeGen3a(RunwayVideoGenNode):
215
- """Runway Image to Video Node using Gen3a Turbo model."""
216
-
217
- DESCRIPTION = "Generate a video from a single starting frame using Gen3a Turbo model. Before diving in, review these best practices to ensure that your input selections will set your generation up for success: https://help.runwayml.com/hc/en-us/articles/33927968552339-Creating-with-Act-One-on-Gen-3-Alpha-and-Turbo."
218
-
219
- @classmethod
220
- def INPUT_TYPES(s):
221
- return {
222
- "required": {
223
- "prompt": model_field_to_node_input(
224
- IO.STRING, RunwayImageToVideoRequest, "promptText", multiline=True
225
- ),
226
- "start_frame": (
227
- IO.IMAGE,
228
- {"tooltip": "Start frame to be used for the video"},
229
- ),
230
- "duration": model_field_to_node_input(
231
- IO.COMBO, RunwayImageToVideoRequest, "duration", enum_type=Duration
232
- ),
233
- "ratio": model_field_to_node_input(
234
- IO.COMBO,
235
- RunwayImageToVideoRequest,
236
- "ratio",
237
- enum_type=RunwayGen3aAspectRatio,
238
- ),
239
- "seed": model_field_to_node_input(
240
- IO.INT,
241
- RunwayImageToVideoRequest,
242
- "seed",
243
- control_after_generate=True,
244
- ),
245
- },
246
- "hidden": {
247
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
248
- "comfy_api_key": "API_KEY_COMFY_ORG",
249
- "unique_id": "UNIQUE_ID",
250
- },
251
- }
252
-
253
- def api_call(
254
- self,
255
- prompt: str,
256
- start_frame: torch.Tensor,
257
- duration: str,
258
- ratio: str,
259
- seed: int,
260
- unique_id: Optional[str] = None,
261
- **kwargs,
262
- ) -> tuple[VideoFromFile]:
263
- # Validate inputs
264
- validate_string(prompt, min_length=1)
265
- validate_input_image(start_frame)
266
-
267
- # Upload image
268
- download_urls = upload_images_to_comfyapi(
269
- start_frame,
270
- max_images=1,
271
- mime_type="image/png",
272
- auth_kwargs=kwargs,
273
- )
274
- if len(download_urls) != 1:
275
- raise RunwayApiError("Failed to upload one or more images to comfy api.")
276
-
277
- return self.generate_video(
278
- RunwayImageToVideoRequest(
279
- promptText=prompt,
280
- seed=seed,
281
- model=Model("gen3a_turbo"),
282
- duration=Duration(duration),
283
- ratio=AspectRatio(ratio),
284
- promptImage=RunwayPromptImageObject(
285
- root=[
286
- RunwayPromptImageDetailedObject(
287
- uri=str(download_urls[0]), position="first"
288
- )
289
- ]
290
- ),
291
- ),
292
- auth_kwargs=kwargs,
293
- node_id=unique_id,
294
- )
295
-
296
-
297
- class RunwayImageToVideoNodeGen4(RunwayVideoGenNode):
298
- """Runway Image to Video Node using Gen4 Turbo model."""
299
-
300
- DESCRIPTION = "Generate a video from a single starting frame using Gen4 Turbo model. Before diving in, review these best practices to ensure that your input selections will set your generation up for success: https://help.runwayml.com/hc/en-us/articles/37327109429011-Creating-with-Gen-4-Video."
301
-
302
- @classmethod
303
- def INPUT_TYPES(s):
304
- return {
305
- "required": {
306
- "prompt": model_field_to_node_input(
307
- IO.STRING, RunwayImageToVideoRequest, "promptText", multiline=True
308
- ),
309
- "start_frame": (
310
- IO.IMAGE,
311
- {"tooltip": "Start frame to be used for the video"},
312
- ),
313
- "duration": model_field_to_node_input(
314
- IO.COMBO, RunwayImageToVideoRequest, "duration", enum_type=Duration
315
- ),
316
- "ratio": model_field_to_node_input(
317
- IO.COMBO,
318
- RunwayImageToVideoRequest,
319
- "ratio",
320
- enum_type=RunwayGen4TurboAspectRatio,
321
- ),
322
- "seed": model_field_to_node_input(
323
- IO.INT,
324
- RunwayImageToVideoRequest,
325
- "seed",
326
- control_after_generate=True,
327
- ),
328
- },
329
- "hidden": {
330
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
331
- "comfy_api_key": "API_KEY_COMFY_ORG",
332
- "unique_id": "UNIQUE_ID",
333
- },
334
- }
335
-
336
- def api_call(
337
- self,
338
- prompt: str,
339
- start_frame: torch.Tensor,
340
- duration: str,
341
- ratio: str,
342
- seed: int,
343
- unique_id: Optional[str] = None,
344
- **kwargs,
345
- ) -> tuple[VideoFromFile]:
346
- # Validate inputs
347
- validate_string(prompt, min_length=1)
348
- validate_input_image(start_frame)
349
-
350
- # Upload image
351
- download_urls = upload_images_to_comfyapi(
352
- start_frame,
353
- max_images=1,
354
- mime_type="image/png",
355
- auth_kwargs=kwargs,
356
- )
357
- if len(download_urls) != 1:
358
- raise RunwayApiError("Failed to upload one or more images to comfy api.")
359
-
360
- return self.generate_video(
361
- RunwayImageToVideoRequest(
362
- promptText=prompt,
363
- seed=seed,
364
- model=Model("gen4_turbo"),
365
- duration=Duration(duration),
366
- ratio=AspectRatio(ratio),
367
- promptImage=RunwayPromptImageObject(
368
- root=[
369
- RunwayPromptImageDetailedObject(
370
- uri=str(download_urls[0]), position="first"
371
- )
372
- ]
373
- ),
374
- ),
375
- auth_kwargs=kwargs,
376
- node_id=unique_id,
377
- )
378
-
379
-
380
- class RunwayFirstLastFrameNode(RunwayVideoGenNode):
381
- """Runway First-Last Frame Node."""
382
-
383
- DESCRIPTION = "Upload first and last keyframes, draft a prompt, and generate a video. More complex transitions, such as cases where the Last frame is completely different from the First frame, may benefit from the longer 10s duration. This would give the generation more time to smoothly transition between the two inputs. Before diving in, review these best practices to ensure that your input selections will set your generation up for success: https://help.runwayml.com/hc/en-us/articles/34170748696595-Creating-with-Keyframes-on-Gen-3."
384
-
385
- def get_response(
386
- self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
387
- ) -> RunwayImageToVideoResponse:
388
- return poll_until_finished(
389
- auth_kwargs,
390
- ApiEndpoint(
391
- path=f"{PATH_GET_TASK_STATUS}/{task_id}",
392
- method=HttpMethod.GET,
393
- request_model=EmptyRequest,
394
- response_model=TaskStatusResponse,
395
- ),
396
- estimated_duration=AVERAGE_DURATION_FLF_SECONDS,
397
- node_id=node_id,
398
- )
399
-
400
- @classmethod
401
- def INPUT_TYPES(s):
402
- return {
403
- "required": {
404
- "prompt": model_field_to_node_input(
405
- IO.STRING, RunwayImageToVideoRequest, "promptText", multiline=True
406
- ),
407
- "start_frame": (
408
- IO.IMAGE,
409
- {"tooltip": "Start frame to be used for the video"},
410
- ),
411
- "end_frame": (
412
- IO.IMAGE,
413
- {
414
- "tooltip": "End frame to be used for the video. Supported for gen3a_turbo only."
415
- },
416
- ),
417
- "duration": model_field_to_node_input(
418
- IO.COMBO, RunwayImageToVideoRequest, "duration", enum_type=Duration
419
- ),
420
- "ratio": model_field_to_node_input(
421
- IO.COMBO,
422
- RunwayImageToVideoRequest,
423
- "ratio",
424
- enum_type=RunwayGen3aAspectRatio,
425
- ),
426
- "seed": model_field_to_node_input(
427
- IO.INT,
428
- RunwayImageToVideoRequest,
429
- "seed",
430
- control_after_generate=True,
431
- ),
432
- },
433
- "hidden": {
434
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
435
- "unique_id": "UNIQUE_ID",
436
- "comfy_api_key": "API_KEY_COMFY_ORG",
437
- },
438
- }
439
-
440
- def api_call(
441
- self,
442
- prompt: str,
443
- start_frame: torch.Tensor,
444
- end_frame: torch.Tensor,
445
- duration: str,
446
- ratio: str,
447
- seed: int,
448
- unique_id: Optional[str] = None,
449
- **kwargs,
450
- ) -> tuple[VideoFromFile]:
451
- # Validate inputs
452
- validate_string(prompt, min_length=1)
453
- validate_input_image(start_frame)
454
- validate_input_image(end_frame)
455
-
456
- # Upload images
457
- stacked_input_images = image_tensor_pair_to_batch(start_frame, end_frame)
458
- download_urls = upload_images_to_comfyapi(
459
- stacked_input_images,
460
- max_images=2,
461
- mime_type="image/png",
462
- auth_kwargs=kwargs,
463
- )
464
- if len(download_urls) != 2:
465
- raise RunwayApiError("Failed to upload one or more images to comfy api.")
466
-
467
- return self.generate_video(
468
- RunwayImageToVideoRequest(
469
- promptText=prompt,
470
- seed=seed,
471
- model=Model("gen3a_turbo"),
472
- duration=Duration(duration),
473
- ratio=AspectRatio(ratio),
474
- promptImage=RunwayPromptImageObject(
475
- root=[
476
- RunwayPromptImageDetailedObject(
477
- uri=str(download_urls[0]), position="first"
478
- ),
479
- RunwayPromptImageDetailedObject(
480
- uri=str(download_urls[1]), position="last"
481
- ),
482
- ]
483
- ),
484
- ),
485
- auth_kwargs=kwargs,
486
- node_id=unique_id,
487
- )
488
-
489
-
490
- class RunwayTextToImageNode(ComfyNodeABC):
491
- """Runway Text to Image Node."""
492
-
493
- RETURN_TYPES = ("IMAGE",)
494
- FUNCTION = "api_call"
495
- CATEGORY = "api node/image/Runway"
496
- API_NODE = True
497
- DESCRIPTION = "Generate an image from a text prompt using Runway's Gen 4 model. You can also include reference images to guide the generation."
498
-
499
- @classmethod
500
- def INPUT_TYPES(s):
501
- return {
502
- "required": {
503
- "prompt": model_field_to_node_input(
504
- IO.STRING, RunwayTextToImageRequest, "promptText", multiline=True
505
- ),
506
- "ratio": model_field_to_node_input(
507
- IO.COMBO,
508
- RunwayTextToImageRequest,
509
- "ratio",
510
- enum_type=RunwayTextToImageAspectRatioEnum,
511
- ),
512
- },
513
- "optional": {
514
- "reference_image": (
515
- IO.IMAGE,
516
- {"tooltip": "Optional reference image to guide the generation"},
517
- )
518
- },
519
- "hidden": {
520
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
521
- "comfy_api_key": "API_KEY_COMFY_ORG",
522
- "unique_id": "UNIQUE_ID",
523
- },
524
- }
525
-
526
- def validate_task_created(self, response: RunwayTextToImageResponse) -> bool:
527
- """
528
- Validate the task creation response from the Runway API matches
529
- expected format.
530
- """
531
- if not bool(response.id):
532
- raise RunwayApiError("Invalid initial response from Runway API.")
533
- return True
534
-
535
- def validate_response(self, response: TaskStatusResponse) -> bool:
536
- """
537
- Validate the successful task status response from the Runway API
538
- matches expected format.
539
- """
540
- if not response.output or len(response.output) == 0:
541
- raise RunwayApiError(
542
- "Runway task succeeded but no image data found in response."
543
- )
544
- return True
545
-
546
- def get_response(
547
- self, task_id: str, auth_kwargs: dict[str, str], node_id: Optional[str] = None
548
- ) -> TaskStatusResponse:
549
- """Poll the task status until it is finished then get the response."""
550
- return poll_until_finished(
551
- auth_kwargs,
552
- ApiEndpoint(
553
- path=f"{PATH_GET_TASK_STATUS}/{task_id}",
554
- method=HttpMethod.GET,
555
- request_model=EmptyRequest,
556
- response_model=TaskStatusResponse,
557
- ),
558
- estimated_duration=AVERAGE_DURATION_T2I_SECONDS,
559
- node_id=node_id,
560
- )
561
-
562
- def api_call(
563
- self,
564
- prompt: str,
565
- ratio: str,
566
- reference_image: Optional[torch.Tensor] = None,
567
- unique_id: Optional[str] = None,
568
- **kwargs,
569
- ) -> tuple[torch.Tensor]:
570
- # Validate inputs
571
- validate_string(prompt, min_length=1)
572
-
573
- # Prepare reference images if provided
574
- reference_images = None
575
- if reference_image is not None:
576
- validate_input_image(reference_image)
577
- download_urls = upload_images_to_comfyapi(
578
- reference_image,
579
- max_images=1,
580
- mime_type="image/png",
581
- auth_kwargs=kwargs,
582
- )
583
- if len(download_urls) != 1:
584
- raise RunwayApiError("Failed to upload reference image to comfy api.")
585
-
586
- reference_images = [ReferenceImage(uri=str(download_urls[0]))]
587
-
588
- # Create request
589
- request = RunwayTextToImageRequest(
590
- promptText=prompt,
591
- model=Model4.gen4_image,
592
- ratio=ratio,
593
- referenceImages=reference_images,
594
- )
595
-
596
- # Execute initial request
597
- initial_operation = SynchronousOperation(
598
- endpoint=ApiEndpoint(
599
- path=PATH_TEXT_TO_IMAGE,
600
- method=HttpMethod.POST,
601
- request_model=RunwayTextToImageRequest,
602
- response_model=RunwayTextToImageResponse,
603
- ),
604
- request=request,
605
- auth_kwargs=kwargs,
606
- )
607
-
608
- initial_response = initial_operation.execute()
609
- self.validate_task_created(initial_response)
610
- task_id = initial_response.id
611
-
612
- # Poll for completion
613
- final_response = self.get_response(
614
- task_id, auth_kwargs=kwargs, node_id=unique_id
615
- )
616
- self.validate_response(final_response)
617
-
618
- # Download and return image
619
- image_url = get_image_url_from_task_status(final_response)
620
- return (download_url_to_image_tensor(image_url),)
621
-
622
-
623
- NODE_CLASS_MAPPINGS = {
624
- "RunwayFirstLastFrameNode": RunwayFirstLastFrameNode,
625
- "RunwayImageToVideoNodeGen3a": RunwayImageToVideoNodeGen3a,
626
- "RunwayImageToVideoNodeGen4": RunwayImageToVideoNodeGen4,
627
- "RunwayTextToImageNode": RunwayTextToImageNode,
628
- }
629
-
630
- NODE_DISPLAY_NAME_MAPPINGS = {
631
- "RunwayFirstLastFrameNode": "Runway First-Last-Frame to Video",
632
- "RunwayImageToVideoNodeGen3a": "Runway Image to Video (Gen3a Turbo)",
633
- "RunwayImageToVideoNodeGen4": "Runway Image to Video (Gen4 Turbo)",
634
- "RunwayTextToImageNode": "Runway Text to Image",
635
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/nodes_stability.py DELETED
@@ -1,614 +0,0 @@
1
- from inspect import cleandoc
2
- from comfy.comfy_types.node_typing import IO
3
- from comfy_api_nodes.apis.stability_api import (
4
- StabilityUpscaleConservativeRequest,
5
- StabilityUpscaleCreativeRequest,
6
- StabilityAsyncResponse,
7
- StabilityResultsGetResponse,
8
- StabilityStable3_5Request,
9
- StabilityStableUltraRequest,
10
- StabilityStableUltraResponse,
11
- StabilityAspectRatio,
12
- Stability_SD3_5_Model,
13
- Stability_SD3_5_GenerationMode,
14
- get_stability_style_presets,
15
- )
16
- from comfy_api_nodes.apis.client import (
17
- ApiEndpoint,
18
- HttpMethod,
19
- SynchronousOperation,
20
- PollingOperation,
21
- EmptyRequest,
22
- )
23
- from comfy_api_nodes.apinode_utils import (
24
- bytesio_to_image_tensor,
25
- tensor_to_bytesio,
26
- validate_string,
27
- )
28
-
29
- import torch
30
- import base64
31
- from io import BytesIO
32
- from enum import Enum
33
-
34
-
35
- class StabilityPollStatus(str, Enum):
36
- finished = "finished"
37
- in_progress = "in_progress"
38
- failed = "failed"
39
-
40
-
41
- def get_async_dummy_status(x: StabilityResultsGetResponse):
42
- if x.name is not None or x.errors is not None:
43
- return StabilityPollStatus.failed
44
- elif x.finish_reason is not None:
45
- return StabilityPollStatus.finished
46
- return StabilityPollStatus.in_progress
47
-
48
-
49
- class StabilityStableImageUltraNode:
50
- """
51
- Generates images synchronously based on prompt and resolution.
52
- """
53
-
54
- RETURN_TYPES = (IO.IMAGE,)
55
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
56
- FUNCTION = "api_call"
57
- API_NODE = True
58
- CATEGORY = "api node/image/Stability AI"
59
-
60
- @classmethod
61
- def INPUT_TYPES(s):
62
- return {
63
- "required": {
64
- "prompt": (
65
- IO.STRING,
66
- {
67
- "multiline": True,
68
- "default": "",
69
- "tooltip": "What you wish to see in the output image. A strong, descriptive prompt that clearly defines" +
70
- "What you wish to see in the output image. A strong, descriptive prompt that clearly defines" +
71
- "elements, colors, and subjects will lead to better results. " +
72
- "To control the weight of a given word use the format `(word:weight)`," +
73
- "where `word` is the word you'd like to control the weight of and `weight`" +
74
- "is a value between 0 and 1. For example: `The sky was a crisp (blue:0.3) and (green:0.8)`" +
75
- "would convey a sky that was blue and green, but more green than blue."
76
- },
77
- ),
78
- "aspect_ratio": ([x.value for x in StabilityAspectRatio],
79
- {
80
- "default": StabilityAspectRatio.ratio_1_1,
81
- "tooltip": "Aspect ratio of generated image.",
82
- },
83
- ),
84
- "style_preset": (get_stability_style_presets(),
85
- {
86
- "tooltip": "Optional desired style of generated image.",
87
- },
88
- ),
89
- "seed": (
90
- IO.INT,
91
- {
92
- "default": 0,
93
- "min": 0,
94
- "max": 4294967294,
95
- "control_after_generate": True,
96
- "tooltip": "The random seed used for creating the noise.",
97
- },
98
- ),
99
- },
100
- "optional": {
101
- "image": (IO.IMAGE,),
102
- "negative_prompt": (
103
- IO.STRING,
104
- {
105
- "default": "",
106
- "forceInput": True,
107
- "tooltip": "A blurb of text describing what you do not wish to see in the output image. This is an advanced feature."
108
- },
109
- ),
110
- "image_denoise": (
111
- IO.FLOAT,
112
- {
113
- "default": 0.5,
114
- "min": 0.0,
115
- "max": 1.0,
116
- "step": 0.01,
117
- "tooltip": "Denoise of input image; 0.0 yields image identical to input, 1.0 is as if no image was provided at all.",
118
- },
119
- ),
120
- },
121
- "hidden": {
122
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
123
- "comfy_api_key": "API_KEY_COMFY_ORG",
124
- },
125
- }
126
-
127
- def api_call(self, prompt: str, aspect_ratio: str, style_preset: str, seed: int,
128
- negative_prompt: str=None, image: torch.Tensor = None, image_denoise: float=None,
129
- **kwargs):
130
- validate_string(prompt, strip_whitespace=False)
131
- # prepare image binary if image present
132
- image_binary = None
133
- if image is not None:
134
- image_binary = tensor_to_bytesio(image, total_pixels=1504*1504).read()
135
- else:
136
- image_denoise = None
137
-
138
- if not negative_prompt:
139
- negative_prompt = None
140
- if style_preset == "None":
141
- style_preset = None
142
-
143
- files = {
144
- "image": image_binary
145
- }
146
-
147
- operation = SynchronousOperation(
148
- endpoint=ApiEndpoint(
149
- path="/proxy/stability/v2beta/stable-image/generate/ultra",
150
- method=HttpMethod.POST,
151
- request_model=StabilityStableUltraRequest,
152
- response_model=StabilityStableUltraResponse,
153
- ),
154
- request=StabilityStableUltraRequest(
155
- prompt=prompt,
156
- negative_prompt=negative_prompt,
157
- aspect_ratio=aspect_ratio,
158
- seed=seed,
159
- strength=image_denoise,
160
- style_preset=style_preset,
161
- ),
162
- files=files,
163
- content_type="multipart/form-data",
164
- auth_kwargs=kwargs,
165
- )
166
- response_api = operation.execute()
167
-
168
- if response_api.finish_reason != "SUCCESS":
169
- raise Exception(f"Stable Image Ultra generation failed: {response_api.finish_reason}.")
170
-
171
- image_data = base64.b64decode(response_api.image)
172
- returned_image = bytesio_to_image_tensor(BytesIO(image_data))
173
-
174
- return (returned_image,)
175
-
176
-
177
- class StabilityStableImageSD_3_5Node:
178
- """
179
- Generates images synchronously based on prompt and resolution.
180
- """
181
-
182
- RETURN_TYPES = (IO.IMAGE,)
183
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
184
- FUNCTION = "api_call"
185
- API_NODE = True
186
- CATEGORY = "api node/image/Stability AI"
187
-
188
- @classmethod
189
- def INPUT_TYPES(s):
190
- return {
191
- "required": {
192
- "prompt": (
193
- IO.STRING,
194
- {
195
- "multiline": True,
196
- "default": "",
197
- "tooltip": "What you wish to see in the output image. A strong, descriptive prompt that clearly defines elements, colors, and subjects will lead to better results."
198
- },
199
- ),
200
- "model": ([x.value for x in Stability_SD3_5_Model],),
201
- "aspect_ratio": ([x.value for x in StabilityAspectRatio],
202
- {
203
- "default": StabilityAspectRatio.ratio_1_1,
204
- "tooltip": "Aspect ratio of generated image.",
205
- },
206
- ),
207
- "style_preset": (get_stability_style_presets(),
208
- {
209
- "tooltip": "Optional desired style of generated image.",
210
- },
211
- ),
212
- "cfg_scale": (
213
- IO.FLOAT,
214
- {
215
- "default": 4.0,
216
- "min": 1.0,
217
- "max": 10.0,
218
- "step": 0.1,
219
- "tooltip": "How strictly the diffusion process adheres to the prompt text (higher values keep your image closer to your prompt)",
220
- },
221
- ),
222
- "seed": (
223
- IO.INT,
224
- {
225
- "default": 0,
226
- "min": 0,
227
- "max": 4294967294,
228
- "control_after_generate": True,
229
- "tooltip": "The random seed used for creating the noise.",
230
- },
231
- ),
232
- },
233
- "optional": {
234
- "image": (IO.IMAGE,),
235
- "negative_prompt": (
236
- IO.STRING,
237
- {
238
- "default": "",
239
- "forceInput": True,
240
- "tooltip": "Keywords of what you do not wish to see in the output image. This is an advanced feature."
241
- },
242
- ),
243
- "image_denoise": (
244
- IO.FLOAT,
245
- {
246
- "default": 0.5,
247
- "min": 0.0,
248
- "max": 1.0,
249
- "step": 0.01,
250
- "tooltip": "Denoise of input image; 0.0 yields image identical to input, 1.0 is as if no image was provided at all.",
251
- },
252
- ),
253
- },
254
- "hidden": {
255
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
256
- "comfy_api_key": "API_KEY_COMFY_ORG",
257
- },
258
- }
259
-
260
- def api_call(self, model: str, prompt: str, aspect_ratio: str, style_preset: str, seed: int, cfg_scale: float,
261
- negative_prompt: str=None, image: torch.Tensor = None, image_denoise: float=None,
262
- **kwargs):
263
- validate_string(prompt, strip_whitespace=False)
264
- # prepare image binary if image present
265
- image_binary = None
266
- mode = Stability_SD3_5_GenerationMode.text_to_image
267
- if image is not None:
268
- image_binary = tensor_to_bytesio(image, total_pixels=1504*1504).read()
269
- mode = Stability_SD3_5_GenerationMode.image_to_image
270
- aspect_ratio = None
271
- else:
272
- image_denoise = None
273
-
274
- if not negative_prompt:
275
- negative_prompt = None
276
- if style_preset == "None":
277
- style_preset = None
278
-
279
- files = {
280
- "image": image_binary
281
- }
282
-
283
- operation = SynchronousOperation(
284
- endpoint=ApiEndpoint(
285
- path="/proxy/stability/v2beta/stable-image/generate/sd3",
286
- method=HttpMethod.POST,
287
- request_model=StabilityStable3_5Request,
288
- response_model=StabilityStableUltraResponse,
289
- ),
290
- request=StabilityStable3_5Request(
291
- prompt=prompt,
292
- negative_prompt=negative_prompt,
293
- aspect_ratio=aspect_ratio,
294
- seed=seed,
295
- strength=image_denoise,
296
- style_preset=style_preset,
297
- cfg_scale=cfg_scale,
298
- model=model,
299
- mode=mode,
300
- ),
301
- files=files,
302
- content_type="multipart/form-data",
303
- auth_kwargs=kwargs,
304
- )
305
- response_api = operation.execute()
306
-
307
- if response_api.finish_reason != "SUCCESS":
308
- raise Exception(f"Stable Diffusion 3.5 Image generation failed: {response_api.finish_reason}.")
309
-
310
- image_data = base64.b64decode(response_api.image)
311
- returned_image = bytesio_to_image_tensor(BytesIO(image_data))
312
-
313
- return (returned_image,)
314
-
315
-
316
- class StabilityUpscaleConservativeNode:
317
- """
318
- Upscale image with minimal alterations to 4K resolution.
319
- """
320
-
321
- RETURN_TYPES = (IO.IMAGE,)
322
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
323
- FUNCTION = "api_call"
324
- API_NODE = True
325
- CATEGORY = "api node/image/Stability AI"
326
-
327
- @classmethod
328
- def INPUT_TYPES(s):
329
- return {
330
- "required": {
331
- "image": (IO.IMAGE,),
332
- "prompt": (
333
- IO.STRING,
334
- {
335
- "multiline": True,
336
- "default": "",
337
- "tooltip": "What you wish to see in the output image. A strong, descriptive prompt that clearly defines elements, colors, and subjects will lead to better results."
338
- },
339
- ),
340
- "creativity": (
341
- IO.FLOAT,
342
- {
343
- "default": 0.35,
344
- "min": 0.2,
345
- "max": 0.5,
346
- "step": 0.01,
347
- "tooltip": "Controls the likelihood of creating additional details not heavily conditioned by the init image.",
348
- },
349
- ),
350
- "seed": (
351
- IO.INT,
352
- {
353
- "default": 0,
354
- "min": 0,
355
- "max": 4294967294,
356
- "control_after_generate": True,
357
- "tooltip": "The random seed used for creating the noise.",
358
- },
359
- ),
360
- },
361
- "optional": {
362
- "negative_prompt": (
363
- IO.STRING,
364
- {
365
- "default": "",
366
- "forceInput": True,
367
- "tooltip": "Keywords of what you do not wish to see in the output image. This is an advanced feature."
368
- },
369
- ),
370
- },
371
- "hidden": {
372
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
373
- "comfy_api_key": "API_KEY_COMFY_ORG",
374
- },
375
- }
376
-
377
- def api_call(self, image: torch.Tensor, prompt: str, creativity: float, seed: int, negative_prompt: str=None,
378
- **kwargs):
379
- validate_string(prompt, strip_whitespace=False)
380
- image_binary = tensor_to_bytesio(image, total_pixels=1024*1024).read()
381
-
382
- if not negative_prompt:
383
- negative_prompt = None
384
-
385
- files = {
386
- "image": image_binary
387
- }
388
-
389
- operation = SynchronousOperation(
390
- endpoint=ApiEndpoint(
391
- path="/proxy/stability/v2beta/stable-image/upscale/conservative",
392
- method=HttpMethod.POST,
393
- request_model=StabilityUpscaleConservativeRequest,
394
- response_model=StabilityStableUltraResponse,
395
- ),
396
- request=StabilityUpscaleConservativeRequest(
397
- prompt=prompt,
398
- negative_prompt=negative_prompt,
399
- creativity=round(creativity,2),
400
- seed=seed,
401
- ),
402
- files=files,
403
- content_type="multipart/form-data",
404
- auth_kwargs=kwargs,
405
- )
406
- response_api = operation.execute()
407
-
408
- if response_api.finish_reason != "SUCCESS":
409
- raise Exception(f"Stability Upscale Conservative generation failed: {response_api.finish_reason}.")
410
-
411
- image_data = base64.b64decode(response_api.image)
412
- returned_image = bytesio_to_image_tensor(BytesIO(image_data))
413
-
414
- return (returned_image,)
415
-
416
-
417
- class StabilityUpscaleCreativeNode:
418
- """
419
- Upscale image with minimal alterations to 4K resolution.
420
- """
421
-
422
- RETURN_TYPES = (IO.IMAGE,)
423
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
424
- FUNCTION = "api_call"
425
- API_NODE = True
426
- CATEGORY = "api node/image/Stability AI"
427
-
428
- @classmethod
429
- def INPUT_TYPES(s):
430
- return {
431
- "required": {
432
- "image": (IO.IMAGE,),
433
- "prompt": (
434
- IO.STRING,
435
- {
436
- "multiline": True,
437
- "default": "",
438
- "tooltip": "What you wish to see in the output image. A strong, descriptive prompt that clearly defines elements, colors, and subjects will lead to better results."
439
- },
440
- ),
441
- "creativity": (
442
- IO.FLOAT,
443
- {
444
- "default": 0.3,
445
- "min": 0.1,
446
- "max": 0.5,
447
- "step": 0.01,
448
- "tooltip": "Controls the likelihood of creating additional details not heavily conditioned by the init image.",
449
- },
450
- ),
451
- "style_preset": (get_stability_style_presets(),
452
- {
453
- "tooltip": "Optional desired style of generated image.",
454
- },
455
- ),
456
- "seed": (
457
- IO.INT,
458
- {
459
- "default": 0,
460
- "min": 0,
461
- "max": 4294967294,
462
- "control_after_generate": True,
463
- "tooltip": "The random seed used for creating the noise.",
464
- },
465
- ),
466
- },
467
- "optional": {
468
- "negative_prompt": (
469
- IO.STRING,
470
- {
471
- "default": "",
472
- "forceInput": True,
473
- "tooltip": "Keywords of what you do not wish to see in the output image. This is an advanced feature."
474
- },
475
- ),
476
- },
477
- "hidden": {
478
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
479
- "comfy_api_key": "API_KEY_COMFY_ORG",
480
- },
481
- }
482
-
483
- def api_call(self, image: torch.Tensor, prompt: str, creativity: float, style_preset: str, seed: int, negative_prompt: str=None,
484
- **kwargs):
485
- validate_string(prompt, strip_whitespace=False)
486
- image_binary = tensor_to_bytesio(image, total_pixels=1024*1024).read()
487
-
488
- if not negative_prompt:
489
- negative_prompt = None
490
- if style_preset == "None":
491
- style_preset = None
492
-
493
- files = {
494
- "image": image_binary
495
- }
496
-
497
- operation = SynchronousOperation(
498
- endpoint=ApiEndpoint(
499
- path="/proxy/stability/v2beta/stable-image/upscale/creative",
500
- method=HttpMethod.POST,
501
- request_model=StabilityUpscaleCreativeRequest,
502
- response_model=StabilityAsyncResponse,
503
- ),
504
- request=StabilityUpscaleCreativeRequest(
505
- prompt=prompt,
506
- negative_prompt=negative_prompt,
507
- creativity=round(creativity,2),
508
- style_preset=style_preset,
509
- seed=seed,
510
- ),
511
- files=files,
512
- content_type="multipart/form-data",
513
- auth_kwargs=kwargs,
514
- )
515
- response_api = operation.execute()
516
-
517
- operation = PollingOperation(
518
- poll_endpoint=ApiEndpoint(
519
- path=f"/proxy/stability/v2beta/results/{response_api.id}",
520
- method=HttpMethod.GET,
521
- request_model=EmptyRequest,
522
- response_model=StabilityResultsGetResponse,
523
- ),
524
- poll_interval=3,
525
- completed_statuses=[StabilityPollStatus.finished],
526
- failed_statuses=[StabilityPollStatus.failed],
527
- status_extractor=lambda x: get_async_dummy_status(x),
528
- auth_kwargs=kwargs,
529
- )
530
- response_poll: StabilityResultsGetResponse = operation.execute()
531
-
532
- if response_poll.finish_reason != "SUCCESS":
533
- raise Exception(f"Stability Upscale Creative generation failed: {response_poll.finish_reason}.")
534
-
535
- image_data = base64.b64decode(response_poll.result)
536
- returned_image = bytesio_to_image_tensor(BytesIO(image_data))
537
-
538
- return (returned_image,)
539
-
540
-
541
- class StabilityUpscaleFastNode:
542
- """
543
- Quickly upscales an image via Stability API call to 4x its original size; intended for upscaling low-quality/compressed images.
544
- """
545
-
546
- RETURN_TYPES = (IO.IMAGE,)
547
- DESCRIPTION = cleandoc(__doc__ or "") # Handle potential None value
548
- FUNCTION = "api_call"
549
- API_NODE = True
550
- CATEGORY = "api node/image/Stability AI"
551
-
552
- @classmethod
553
- def INPUT_TYPES(s):
554
- return {
555
- "required": {
556
- "image": (IO.IMAGE,),
557
- },
558
- "optional": {
559
- },
560
- "hidden": {
561
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
562
- "comfy_api_key": "API_KEY_COMFY_ORG",
563
- },
564
- }
565
-
566
- def api_call(self, image: torch.Tensor,
567
- **kwargs):
568
- image_binary = tensor_to_bytesio(image, total_pixels=4096*4096).read()
569
-
570
- files = {
571
- "image": image_binary
572
- }
573
-
574
- operation = SynchronousOperation(
575
- endpoint=ApiEndpoint(
576
- path="/proxy/stability/v2beta/stable-image/upscale/fast",
577
- method=HttpMethod.POST,
578
- request_model=EmptyRequest,
579
- response_model=StabilityStableUltraResponse,
580
- ),
581
- request=EmptyRequest(),
582
- files=files,
583
- content_type="multipart/form-data",
584
- auth_kwargs=kwargs,
585
- )
586
- response_api = operation.execute()
587
-
588
- if response_api.finish_reason != "SUCCESS":
589
- raise Exception(f"Stability Upscale Fast failed: {response_api.finish_reason}.")
590
-
591
- image_data = base64.b64decode(response_api.image)
592
- returned_image = bytesio_to_image_tensor(BytesIO(image_data))
593
-
594
- return (returned_image,)
595
-
596
-
597
- # A dictionary that contains all nodes you want to export with their names
598
- # NOTE: names should be globally unique
599
- NODE_CLASS_MAPPINGS = {
600
- "StabilityStableImageUltraNode": StabilityStableImageUltraNode,
601
- "StabilityStableImageSD_3_5Node": StabilityStableImageSD_3_5Node,
602
- "StabilityUpscaleConservativeNode": StabilityUpscaleConservativeNode,
603
- "StabilityUpscaleCreativeNode": StabilityUpscaleCreativeNode,
604
- "StabilityUpscaleFastNode": StabilityUpscaleFastNode,
605
- }
606
-
607
- # A dictionary that contains the friendly/humanly readable titles for the nodes
608
- NODE_DISPLAY_NAME_MAPPINGS = {
609
- "StabilityStableImageUltraNode": "Stability AI Stable Image Ultra",
610
- "StabilityStableImageSD_3_5Node": "Stability AI Stable Diffusion 3.5 Image",
611
- "StabilityUpscaleConservativeNode": "Stability AI Upscale Conservative",
612
- "StabilityUpscaleCreativeNode": "Stability AI Upscale Creative",
613
- "StabilityUpscaleFastNode": "Stability AI Upscale Fast",
614
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/nodes_tripo.py DELETED
@@ -1,574 +0,0 @@
1
- import os
2
- from folder_paths import get_output_directory
3
- from comfy_api_nodes.mapper_utils import model_field_to_node_input
4
- from comfy.comfy_types.node_typing import IO
5
- from comfy_api_nodes.apis import (
6
- TripoOrientation,
7
- TripoModelVersion,
8
- )
9
- from comfy_api_nodes.apis.tripo_api import (
10
- TripoTaskType,
11
- TripoStyle,
12
- TripoFileReference,
13
- TripoFileEmptyReference,
14
- TripoUrlReference,
15
- TripoTaskResponse,
16
- TripoTaskStatus,
17
- TripoTextToModelRequest,
18
- TripoImageToModelRequest,
19
- TripoMultiviewToModelRequest,
20
- TripoTextureModelRequest,
21
- TripoRefineModelRequest,
22
- TripoAnimateRigRequest,
23
- TripoAnimateRetargetRequest,
24
- TripoConvertModelRequest,
25
- )
26
-
27
- from comfy_api_nodes.apis.client import (
28
- ApiEndpoint,
29
- HttpMethod,
30
- SynchronousOperation,
31
- PollingOperation,
32
- EmptyRequest,
33
- )
34
- from comfy_api_nodes.apinode_utils import (
35
- upload_images_to_comfyapi,
36
- download_url_to_bytesio,
37
- )
38
-
39
-
40
- def upload_image_to_tripo(image, **kwargs):
41
- urls = upload_images_to_comfyapi(image, max_images=1, auth_kwargs=kwargs)
42
- return TripoFileReference(TripoUrlReference(url=urls[0], type="jpeg"))
43
-
44
- def get_model_url_from_response(response: TripoTaskResponse) -> str:
45
- if response.data is not None:
46
- for key in ["pbr_model", "model", "base_model"]:
47
- if getattr(response.data.output, key, None) is not None:
48
- return getattr(response.data.output, key)
49
- raise RuntimeError(f"Failed to get model url from response: {response}")
50
-
51
-
52
- def poll_until_finished(
53
- kwargs: dict[str, str],
54
- response: TripoTaskResponse,
55
- ) -> tuple[str, str]:
56
- """Polls the Tripo API endpoint until the task reaches a terminal state, then returns the response."""
57
- if response.code != 0:
58
- raise RuntimeError(f"Failed to generate mesh: {response.error}")
59
- task_id = response.data.task_id
60
- response_poll = PollingOperation(
61
- poll_endpoint=ApiEndpoint(
62
- path=f"/proxy/tripo/v2/openapi/task/{task_id}",
63
- method=HttpMethod.GET,
64
- request_model=EmptyRequest,
65
- response_model=TripoTaskResponse,
66
- ),
67
- completed_statuses=[TripoTaskStatus.SUCCESS],
68
- failed_statuses=[
69
- TripoTaskStatus.FAILED,
70
- TripoTaskStatus.CANCELLED,
71
- TripoTaskStatus.UNKNOWN,
72
- TripoTaskStatus.BANNED,
73
- TripoTaskStatus.EXPIRED,
74
- ],
75
- status_extractor=lambda x: x.data.status,
76
- auth_kwargs=kwargs,
77
- node_id=kwargs["unique_id"],
78
- result_url_extractor=get_model_url_from_response,
79
- progress_extractor=lambda x: x.data.progress,
80
- ).execute()
81
- if response_poll.data.status == TripoTaskStatus.SUCCESS:
82
- url = get_model_url_from_response(response_poll)
83
- bytesio = download_url_to_bytesio(url)
84
- # Save the downloaded model file
85
- model_file = f"tripo_model_{task_id}.glb"
86
- with open(os.path.join(get_output_directory(), model_file), "wb") as f:
87
- f.write(bytesio.getvalue())
88
- return model_file, task_id
89
- raise RuntimeError(f"Failed to generate mesh: {response_poll}")
90
-
91
- class TripoTextToModelNode:
92
- """
93
- Generates 3D models synchronously based on a text prompt using Tripo's API.
94
- """
95
- AVERAGE_DURATION = 80
96
- @classmethod
97
- def INPUT_TYPES(s):
98
- return {
99
- "required": {
100
- "prompt": ("STRING", {"multiline": True}),
101
- },
102
- "optional": {
103
- "negative_prompt": ("STRING", {"multiline": True}),
104
- "model_version": model_field_to_node_input(IO.COMBO, TripoTextToModelRequest, "model_version", enum_type=TripoModelVersion),
105
- "style": model_field_to_node_input(IO.COMBO, TripoTextToModelRequest, "style", enum_type=TripoStyle, default="None"),
106
- "texture": ("BOOLEAN", {"default": True}),
107
- "pbr": ("BOOLEAN", {"default": True}),
108
- "image_seed": ("INT", {"default": 42}),
109
- "model_seed": ("INT", {"default": 42}),
110
- "texture_seed": ("INT", {"default": 42}),
111
- "texture_quality": (["standard", "detailed"], {"default": "standard"}),
112
- "face_limit": ("INT", {"min": -1, "max": 500000, "default": -1}),
113
- "quad": ("BOOLEAN", {"default": False})
114
- },
115
- "hidden": {
116
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
117
- "comfy_api_key": "API_KEY_COMFY_ORG",
118
- "unique_id": "UNIQUE_ID",
119
- },
120
- }
121
-
122
- RETURN_TYPES = ("STRING", "MODEL_TASK_ID",)
123
- RETURN_NAMES = ("model_file", "model task_id")
124
- FUNCTION = "generate_mesh"
125
- CATEGORY = "api node/3d/Tripo"
126
- API_NODE = True
127
- OUTPUT_NODE = True
128
-
129
- def generate_mesh(self, prompt, negative_prompt=None, model_version=None, style=None, texture=None, pbr=None, image_seed=None, model_seed=None, texture_seed=None, texture_quality=None, face_limit=None, quad=None, **kwargs):
130
- style_enum = None if style == "None" else style
131
- if not prompt:
132
- raise RuntimeError("Prompt is required")
133
- response = SynchronousOperation(
134
- endpoint=ApiEndpoint(
135
- path="/proxy/tripo/v2/openapi/task",
136
- method=HttpMethod.POST,
137
- request_model=TripoTextToModelRequest,
138
- response_model=TripoTaskResponse,
139
- ),
140
- request=TripoTextToModelRequest(
141
- type=TripoTaskType.TEXT_TO_MODEL,
142
- prompt=prompt,
143
- negative_prompt=negative_prompt if negative_prompt else None,
144
- model_version=model_version,
145
- style=style_enum,
146
- texture=texture,
147
- pbr=pbr,
148
- image_seed=image_seed,
149
- model_seed=model_seed,
150
- texture_seed=texture_seed,
151
- texture_quality=texture_quality,
152
- face_limit=face_limit,
153
- auto_size=True,
154
- quad=quad
155
- ),
156
- auth_kwargs=kwargs,
157
- ).execute()
158
- return poll_until_finished(kwargs, response)
159
-
160
- class TripoImageToModelNode:
161
- """
162
- Generates 3D models synchronously based on a single image using Tripo's API.
163
- """
164
- AVERAGE_DURATION = 80
165
- @classmethod
166
- def INPUT_TYPES(s):
167
- return {
168
- "required": {
169
- "image": ("IMAGE",),
170
- },
171
- "optional": {
172
- "model_version": model_field_to_node_input(IO.COMBO, TripoImageToModelRequest, "model_version", enum_type=TripoModelVersion),
173
- "style": model_field_to_node_input(IO.COMBO, TripoTextToModelRequest, "style", enum_type=TripoStyle, default="None"),
174
- "texture": ("BOOLEAN", {"default": True}),
175
- "pbr": ("BOOLEAN", {"default": True}),
176
- "model_seed": ("INT", {"default": 42}),
177
- "orientation": model_field_to_node_input(IO.COMBO, TripoImageToModelRequest, "orientation", enum_type=TripoOrientation),
178
- "texture_seed": ("INT", {"default": 42}),
179
- "texture_quality": (["standard", "detailed"], {"default": "standard"}),
180
- "texture_alignment": (["original_image", "geometry"], {"default": "original_image"}),
181
- "face_limit": ("INT", {"min": -1, "max": 500000, "default": -1}),
182
- "quad": ("BOOLEAN", {"default": False})
183
- },
184
- "hidden": {
185
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
186
- "comfy_api_key": "API_KEY_COMFY_ORG",
187
- "unique_id": "UNIQUE_ID",
188
- },
189
- }
190
-
191
- RETURN_TYPES = ("STRING", "MODEL_TASK_ID",)
192
- RETURN_NAMES = ("model_file", "model task_id")
193
- FUNCTION = "generate_mesh"
194
- CATEGORY = "api node/3d/Tripo"
195
- API_NODE = True
196
- OUTPUT_NODE = True
197
-
198
- def generate_mesh(self, image, model_version=None, style=None, texture=None, pbr=None, model_seed=None, orientation=None, texture_alignment=None, texture_seed=None, texture_quality=None, face_limit=None, quad=None, **kwargs):
199
- style_enum = None if style == "None" else style
200
- if image is None:
201
- raise RuntimeError("Image is required")
202
- tripo_file = upload_image_to_tripo(image, **kwargs)
203
- response = SynchronousOperation(
204
- endpoint=ApiEndpoint(
205
- path="/proxy/tripo/v2/openapi/task",
206
- method=HttpMethod.POST,
207
- request_model=TripoImageToModelRequest,
208
- response_model=TripoTaskResponse,
209
- ),
210
- request=TripoImageToModelRequest(
211
- type=TripoTaskType.IMAGE_TO_MODEL,
212
- file=tripo_file,
213
- model_version=model_version,
214
- style=style_enum,
215
- texture=texture,
216
- pbr=pbr,
217
- model_seed=model_seed,
218
- orientation=orientation,
219
- texture_alignment=texture_alignment,
220
- texture_seed=texture_seed,
221
- texture_quality=texture_quality,
222
- face_limit=face_limit,
223
- auto_size=True,
224
- quad=quad
225
- ),
226
- auth_kwargs=kwargs,
227
- ).execute()
228
- return poll_until_finished(kwargs, response)
229
-
230
- class TripoMultiviewToModelNode:
231
- """
232
- Generates 3D models synchronously based on up to four images (front, left, back, right) using Tripo's API.
233
- """
234
- AVERAGE_DURATION = 80
235
- @classmethod
236
- def INPUT_TYPES(s):
237
- return {
238
- "required": {
239
- "image": ("IMAGE",),
240
- },
241
- "optional": {
242
- "image_left": ("IMAGE",),
243
- "image_back": ("IMAGE",),
244
- "image_right": ("IMAGE",),
245
- "model_version": model_field_to_node_input(IO.COMBO, TripoMultiviewToModelRequest, "model_version", enum_type=TripoModelVersion),
246
- "orientation": model_field_to_node_input(IO.COMBO, TripoImageToModelRequest, "orientation", enum_type=TripoOrientation),
247
- "texture": ("BOOLEAN", {"default": True}),
248
- "pbr": ("BOOLEAN", {"default": True}),
249
- "model_seed": ("INT", {"default": 42}),
250
- "texture_seed": ("INT", {"default": 42}),
251
- "texture_quality": (["standard", "detailed"], {"default": "standard"}),
252
- "texture_alignment": (["original_image", "geometry"], {"default": "original_image"}),
253
- "face_limit": ("INT", {"min": -1, "max": 500000, "default": -1}),
254
- "quad": ("BOOLEAN", {"default": False})
255
- },
256
- "hidden": {
257
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
258
- "comfy_api_key": "API_KEY_COMFY_ORG",
259
- "unique_id": "UNIQUE_ID",
260
- },
261
- }
262
-
263
- RETURN_TYPES = ("STRING", "MODEL_TASK_ID",)
264
- RETURN_NAMES = ("model_file", "model task_id")
265
- FUNCTION = "generate_mesh"
266
- CATEGORY = "api node/3d/Tripo"
267
- API_NODE = True
268
- OUTPUT_NODE = True
269
-
270
- def generate_mesh(self, image, image_left=None, image_back=None, image_right=None, model_version=None, orientation=None, texture=None, pbr=None, model_seed=None, texture_seed=None, texture_quality=None, texture_alignment=None, face_limit=None, quad=None, **kwargs):
271
- if image is None:
272
- raise RuntimeError("front image for multiview is required")
273
- images = []
274
- image_dict = {
275
- "image": image,
276
- "image_left": image_left,
277
- "image_back": image_back,
278
- "image_right": image_right
279
- }
280
- if image_left is None and image_back is None and image_right is None:
281
- raise RuntimeError("At least one of left, back, or right image must be provided for multiview")
282
- for image_name in ["image", "image_left", "image_back", "image_right"]:
283
- image_ = image_dict[image_name]
284
- if image_ is not None:
285
- tripo_file = upload_image_to_tripo(image_, **kwargs)
286
- images.append(tripo_file)
287
- else:
288
- images.append(TripoFileEmptyReference())
289
- response = SynchronousOperation(
290
- endpoint=ApiEndpoint(
291
- path="/proxy/tripo/v2/openapi/task",
292
- method=HttpMethod.POST,
293
- request_model=TripoMultiviewToModelRequest,
294
- response_model=TripoTaskResponse,
295
- ),
296
- request=TripoMultiviewToModelRequest(
297
- type=TripoTaskType.MULTIVIEW_TO_MODEL,
298
- files=images,
299
- model_version=model_version,
300
- orientation=orientation,
301
- texture=texture,
302
- pbr=pbr,
303
- model_seed=model_seed,
304
- texture_seed=texture_seed,
305
- texture_quality=texture_quality,
306
- texture_alignment=texture_alignment,
307
- face_limit=face_limit,
308
- quad=quad,
309
- ),
310
- auth_kwargs=kwargs,
311
- ).execute()
312
- return poll_until_finished(kwargs, response)
313
-
314
- class TripoTextureNode:
315
- @classmethod
316
- def INPUT_TYPES(s):
317
- return {
318
- "required": {
319
- "model_task_id": ("MODEL_TASK_ID",),
320
- },
321
- "optional": {
322
- "texture": ("BOOLEAN", {"default": True}),
323
- "pbr": ("BOOLEAN", {"default": True}),
324
- "texture_seed": ("INT", {"default": 42}),
325
- "texture_quality": (["standard", "detailed"], {"default": "standard"}),
326
- "texture_alignment": (["original_image", "geometry"], {"default": "original_image"}),
327
- },
328
- "hidden": {
329
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
330
- "comfy_api_key": "API_KEY_COMFY_ORG",
331
- "unique_id": "UNIQUE_ID",
332
- },
333
- }
334
-
335
- RETURN_TYPES = ("STRING", "MODEL_TASK_ID",)
336
- RETURN_NAMES = ("model_file", "model task_id")
337
- FUNCTION = "generate_mesh"
338
- CATEGORY = "api node/3d/Tripo"
339
- API_NODE = True
340
- OUTPUT_NODE = True
341
- AVERAGE_DURATION = 80
342
-
343
- def generate_mesh(self, model_task_id, texture=None, pbr=None, texture_seed=None, texture_quality=None, texture_alignment=None, **kwargs):
344
- response = SynchronousOperation(
345
- endpoint=ApiEndpoint(
346
- path="/proxy/tripo/v2/openapi/task",
347
- method=HttpMethod.POST,
348
- request_model=TripoTextureModelRequest,
349
- response_model=TripoTaskResponse,
350
- ),
351
- request=TripoTextureModelRequest(
352
- original_model_task_id=model_task_id,
353
- texture=texture,
354
- pbr=pbr,
355
- texture_seed=texture_seed,
356
- texture_quality=texture_quality,
357
- texture_alignment=texture_alignment
358
- ),
359
- auth_kwargs=kwargs,
360
- ).execute()
361
- return poll_until_finished(kwargs, response)
362
-
363
-
364
- class TripoRefineNode:
365
- @classmethod
366
- def INPUT_TYPES(s):
367
- return {
368
- "required": {
369
- "model_task_id": ("MODEL_TASK_ID", {
370
- "tooltip": "Must be a v1.4 Tripo model"
371
- }),
372
- },
373
- "hidden": {
374
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
375
- "comfy_api_key": "API_KEY_COMFY_ORG",
376
- "unique_id": "UNIQUE_ID",
377
- },
378
- }
379
-
380
- DESCRIPTION = "Refine a draft model created by v1.4 Tripo models only."
381
-
382
- RETURN_TYPES = ("STRING", "MODEL_TASK_ID",)
383
- RETURN_NAMES = ("model_file", "model task_id")
384
- FUNCTION = "generate_mesh"
385
- CATEGORY = "api node/3d/Tripo"
386
- API_NODE = True
387
- OUTPUT_NODE = True
388
- AVERAGE_DURATION = 240
389
-
390
- def generate_mesh(self, model_task_id, **kwargs):
391
- response = SynchronousOperation(
392
- endpoint=ApiEndpoint(
393
- path="/proxy/tripo/v2/openapi/task",
394
- method=HttpMethod.POST,
395
- request_model=TripoRefineModelRequest,
396
- response_model=TripoTaskResponse,
397
- ),
398
- request=TripoRefineModelRequest(
399
- draft_model_task_id=model_task_id
400
- ),
401
- auth_kwargs=kwargs,
402
- ).execute()
403
- return poll_until_finished(kwargs, response)
404
-
405
-
406
- class TripoRigNode:
407
- @classmethod
408
- def INPUT_TYPES(s):
409
- return {
410
- "required": {
411
- "original_model_task_id": ("MODEL_TASK_ID",),
412
- },
413
- "hidden": {
414
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
415
- "comfy_api_key": "API_KEY_COMFY_ORG",
416
- "unique_id": "UNIQUE_ID",
417
- },
418
- }
419
-
420
- RETURN_TYPES = ("STRING", "RIG_TASK_ID")
421
- RETURN_NAMES = ("model_file", "rig task_id")
422
- FUNCTION = "generate_mesh"
423
- CATEGORY = "api node/3d/Tripo"
424
- API_NODE = True
425
- OUTPUT_NODE = True
426
- AVERAGE_DURATION = 180
427
-
428
- def generate_mesh(self, original_model_task_id, **kwargs):
429
- response = SynchronousOperation(
430
- endpoint=ApiEndpoint(
431
- path="/proxy/tripo/v2/openapi/task",
432
- method=HttpMethod.POST,
433
- request_model=TripoAnimateRigRequest,
434
- response_model=TripoTaskResponse,
435
- ),
436
- request=TripoAnimateRigRequest(
437
- original_model_task_id=original_model_task_id,
438
- out_format="glb",
439
- spec="tripo"
440
- ),
441
- auth_kwargs=kwargs,
442
- ).execute()
443
- return poll_until_finished(kwargs, response)
444
-
445
- class TripoRetargetNode:
446
- @classmethod
447
- def INPUT_TYPES(s):
448
- return {
449
- "required": {
450
- "original_model_task_id": ("RIG_TASK_ID",),
451
- "animation": ([
452
- "preset:idle",
453
- "preset:walk",
454
- "preset:climb",
455
- "preset:jump",
456
- "preset:slash",
457
- "preset:shoot",
458
- "preset:hurt",
459
- "preset:fall",
460
- "preset:turn",
461
- ],),
462
- },
463
- "hidden": {
464
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
465
- "comfy_api_key": "API_KEY_COMFY_ORG",
466
- "unique_id": "UNIQUE_ID",
467
- },
468
- }
469
-
470
- RETURN_TYPES = ("STRING", "RETARGET_TASK_ID")
471
- RETURN_NAMES = ("model_file", "retarget task_id")
472
- FUNCTION = "generate_mesh"
473
- CATEGORY = "api node/3d/Tripo"
474
- API_NODE = True
475
- OUTPUT_NODE = True
476
- AVERAGE_DURATION = 30
477
-
478
- def generate_mesh(self, animation, original_model_task_id, **kwargs):
479
- response = SynchronousOperation(
480
- endpoint=ApiEndpoint(
481
- path="/proxy/tripo/v2/openapi/task",
482
- method=HttpMethod.POST,
483
- request_model=TripoAnimateRetargetRequest,
484
- response_model=TripoTaskResponse,
485
- ),
486
- request=TripoAnimateRetargetRequest(
487
- original_model_task_id=original_model_task_id,
488
- animation=animation,
489
- out_format="glb",
490
- bake_animation=True
491
- ),
492
- auth_kwargs=kwargs,
493
- ).execute()
494
- return poll_until_finished(kwargs, response)
495
-
496
- class TripoConversionNode:
497
- @classmethod
498
- def INPUT_TYPES(s):
499
- return {
500
- "required": {
501
- "original_model_task_id": ("MODEL_TASK_ID,RIG_TASK_ID,RETARGET_TASK_ID",),
502
- "format": (["GLTF", "USDZ", "FBX", "OBJ", "STL", "3MF"],),
503
- },
504
- "optional": {
505
- "quad": ("BOOLEAN", {"default": False}),
506
- "face_limit": ("INT", {"min": -1, "max": 500000, "default": -1}),
507
- "texture_size": ("INT", {"min": 128, "max": 4096, "default": 4096}),
508
- "texture_format": (["BMP", "DPX", "HDR", "JPEG", "OPEN_EXR", "PNG", "TARGA", "TIFF", "WEBP"], {"default": "JPEG"})
509
- },
510
- "hidden": {
511
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
512
- "comfy_api_key": "API_KEY_COMFY_ORG",
513
- "unique_id": "UNIQUE_ID",
514
- },
515
- }
516
-
517
- @classmethod
518
- def VALIDATE_INPUTS(cls, input_types):
519
- # The min and max of input1 and input2 are still validated because
520
- # we didn't take `input1` or `input2` as arguments
521
- if input_types["original_model_task_id"] not in ("MODEL_TASK_ID", "RIG_TASK_ID", "RETARGET_TASK_ID"):
522
- return "original_model_task_id must be MODEL_TASK_ID, RIG_TASK_ID or RETARGET_TASK_ID type"
523
- return True
524
-
525
- RETURN_TYPES = ()
526
- FUNCTION = "generate_mesh"
527
- CATEGORY = "api node/3d/Tripo"
528
- API_NODE = True
529
- OUTPUT_NODE = True
530
- AVERAGE_DURATION = 30
531
-
532
- def generate_mesh(self, original_model_task_id, format, quad, face_limit, texture_size, texture_format, **kwargs):
533
- if not original_model_task_id:
534
- raise RuntimeError("original_model_task_id is required")
535
- response = SynchronousOperation(
536
- endpoint=ApiEndpoint(
537
- path="/proxy/tripo/v2/openapi/task",
538
- method=HttpMethod.POST,
539
- request_model=TripoConvertModelRequest,
540
- response_model=TripoTaskResponse,
541
- ),
542
- request=TripoConvertModelRequest(
543
- original_model_task_id=original_model_task_id,
544
- format=format,
545
- quad=quad if quad else None,
546
- face_limit=face_limit if face_limit != -1 else None,
547
- texture_size=texture_size if texture_size != 4096 else None,
548
- texture_format=texture_format if texture_format != "JPEG" else None
549
- ),
550
- auth_kwargs=kwargs,
551
- ).execute()
552
- return poll_until_finished(kwargs, response)
553
-
554
- NODE_CLASS_MAPPINGS = {
555
- "TripoTextToModelNode": TripoTextToModelNode,
556
- "TripoImageToModelNode": TripoImageToModelNode,
557
- "TripoMultiviewToModelNode": TripoMultiviewToModelNode,
558
- "TripoTextureNode": TripoTextureNode,
559
- "TripoRefineNode": TripoRefineNode,
560
- "TripoRigNode": TripoRigNode,
561
- "TripoRetargetNode": TripoRetargetNode,
562
- "TripoConversionNode": TripoConversionNode,
563
- }
564
-
565
- NODE_DISPLAY_NAME_MAPPINGS = {
566
- "TripoTextToModelNode": "Tripo: Text to Model",
567
- "TripoImageToModelNode": "Tripo: Image to Model",
568
- "TripoMultiviewToModelNode": "Tripo: Multiview to Model",
569
- "TripoTextureNode": "Tripo: Texture model",
570
- "TripoRefineNode": "Tripo: Refine Draft model",
571
- "TripoRigNode": "Tripo: Rig model",
572
- "TripoRetargetNode": "Tripo: Retarget rigged model",
573
- "TripoConversionNode": "Tripo: Convert model",
574
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/nodes_veo2.py DELETED
@@ -1,308 +0,0 @@
1
- import io
2
- import logging
3
- import base64
4
- import requests
5
- import torch
6
- from typing import Optional
7
-
8
- from comfy.comfy_types.node_typing import IO, ComfyNodeABC
9
- from comfy_api.input_impl.video_types import VideoFromFile
10
- from comfy_api_nodes.apis import (
11
- Veo2GenVidRequest,
12
- Veo2GenVidResponse,
13
- Veo2GenVidPollRequest,
14
- Veo2GenVidPollResponse
15
- )
16
- from comfy_api_nodes.apis.client import (
17
- ApiEndpoint,
18
- HttpMethod,
19
- SynchronousOperation,
20
- PollingOperation,
21
- )
22
-
23
- from comfy_api_nodes.apinode_utils import (
24
- downscale_image_tensor,
25
- tensor_to_base64_string
26
- )
27
-
28
- AVERAGE_DURATION_VIDEO_GEN = 32
29
-
30
- def convert_image_to_base64(image: torch.Tensor):
31
- if image is None:
32
- return None
33
-
34
- scaled_image = downscale_image_tensor(image, total_pixels=2048*2048)
35
- return tensor_to_base64_string(scaled_image)
36
-
37
-
38
- def get_video_url_from_response(poll_response: Veo2GenVidPollResponse) -> Optional[str]:
39
- if (
40
- poll_response.response
41
- and hasattr(poll_response.response, "videos")
42
- and poll_response.response.videos
43
- and len(poll_response.response.videos) > 0
44
- ):
45
- video = poll_response.response.videos[0]
46
- else:
47
- return None
48
- if hasattr(video, "gcsUri") and video.gcsUri:
49
- return str(video.gcsUri)
50
- return None
51
-
52
-
53
- class VeoVideoGenerationNode(ComfyNodeABC):
54
- """
55
- Generates videos from text prompts using Google's Veo API.
56
-
57
- This node can create videos from text descriptions and optional image inputs,
58
- with control over parameters like aspect ratio, duration, and more.
59
- """
60
-
61
- @classmethod
62
- def INPUT_TYPES(s):
63
- return {
64
- "required": {
65
- "prompt": (
66
- IO.STRING,
67
- {
68
- "multiline": True,
69
- "default": "",
70
- "tooltip": "Text description of the video",
71
- },
72
- ),
73
- "aspect_ratio": (
74
- IO.COMBO,
75
- {
76
- "options": ["16:9", "9:16"],
77
- "default": "16:9",
78
- "tooltip": "Aspect ratio of the output video",
79
- },
80
- ),
81
- },
82
- "optional": {
83
- "negative_prompt": (
84
- IO.STRING,
85
- {
86
- "multiline": True,
87
- "default": "",
88
- "tooltip": "Negative text prompt to guide what to avoid in the video",
89
- },
90
- ),
91
- "duration_seconds": (
92
- IO.INT,
93
- {
94
- "default": 5,
95
- "min": 5,
96
- "max": 8,
97
- "step": 1,
98
- "display": "number",
99
- "tooltip": "Duration of the output video in seconds",
100
- },
101
- ),
102
- "enhance_prompt": (
103
- IO.BOOLEAN,
104
- {
105
- "default": True,
106
- "tooltip": "Whether to enhance the prompt with AI assistance",
107
- }
108
- ),
109
- "person_generation": (
110
- IO.COMBO,
111
- {
112
- "options": ["ALLOW", "BLOCK"],
113
- "default": "ALLOW",
114
- "tooltip": "Whether to allow generating people in the video",
115
- },
116
- ),
117
- "seed": (
118
- IO.INT,
119
- {
120
- "default": 0,
121
- "min": 0,
122
- "max": 0xFFFFFFFF,
123
- "step": 1,
124
- "display": "number",
125
- "control_after_generate": True,
126
- "tooltip": "Seed for video generation (0 for random)",
127
- },
128
- ),
129
- "image": (IO.IMAGE, {
130
- "default": None,
131
- "tooltip": "Optional reference image to guide video generation",
132
- }),
133
- },
134
- "hidden": {
135
- "auth_token": "AUTH_TOKEN_COMFY_ORG",
136
- "comfy_api_key": "API_KEY_COMFY_ORG",
137
- "unique_id": "UNIQUE_ID",
138
- },
139
- }
140
-
141
- RETURN_TYPES = (IO.VIDEO,)
142
- FUNCTION = "generate_video"
143
- CATEGORY = "api node/video/Veo"
144
- DESCRIPTION = "Generates videos from text prompts using Google's Veo API"
145
- API_NODE = True
146
-
147
- def generate_video(
148
- self,
149
- prompt,
150
- aspect_ratio="16:9",
151
- negative_prompt="",
152
- duration_seconds=5,
153
- enhance_prompt=True,
154
- person_generation="ALLOW",
155
- seed=0,
156
- image=None,
157
- unique_id: Optional[str] = None,
158
- **kwargs,
159
- ):
160
- # Prepare the instances for the request
161
- instances = []
162
-
163
- instance = {
164
- "prompt": prompt
165
- }
166
-
167
- # Add image if provided
168
- if image is not None:
169
- image_base64 = convert_image_to_base64(image)
170
- if image_base64:
171
- instance["image"] = {
172
- "bytesBase64Encoded": image_base64,
173
- "mimeType": "image/png"
174
- }
175
-
176
- instances.append(instance)
177
-
178
- # Create parameters dictionary
179
- parameters = {
180
- "aspectRatio": aspect_ratio,
181
- "personGeneration": person_generation,
182
- "durationSeconds": duration_seconds,
183
- "enhancePrompt": enhance_prompt,
184
- }
185
-
186
- # Add optional parameters if provided
187
- if negative_prompt:
188
- parameters["negativePrompt"] = negative_prompt
189
- if seed > 0:
190
- parameters["seed"] = seed
191
-
192
- # Initial request to start video generation
193
- initial_operation = SynchronousOperation(
194
- endpoint=ApiEndpoint(
195
- path="/proxy/veo/generate",
196
- method=HttpMethod.POST,
197
- request_model=Veo2GenVidRequest,
198
- response_model=Veo2GenVidResponse
199
- ),
200
- request=Veo2GenVidRequest(
201
- instances=instances,
202
- parameters=parameters
203
- ),
204
- auth_kwargs=kwargs,
205
- )
206
-
207
- initial_response = initial_operation.execute()
208
- operation_name = initial_response.name
209
-
210
- logging.info(f"Veo generation started with operation name: {operation_name}")
211
-
212
- # Define status extractor function
213
- def status_extractor(response):
214
- # Only return "completed" if the operation is done, regardless of success or failure
215
- # We'll check for errors after polling completes
216
- return "completed" if response.done else "pending"
217
-
218
- # Define progress extractor function
219
- def progress_extractor(response):
220
- # Could be enhanced if the API provides progress information
221
- return None
222
-
223
- # Define the polling operation
224
- poll_operation = PollingOperation(
225
- poll_endpoint=ApiEndpoint(
226
- path="/proxy/veo/poll",
227
- method=HttpMethod.POST,
228
- request_model=Veo2GenVidPollRequest,
229
- response_model=Veo2GenVidPollResponse
230
- ),
231
- completed_statuses=["completed"],
232
- failed_statuses=[], # No failed statuses, we'll handle errors after polling
233
- status_extractor=status_extractor,
234
- progress_extractor=progress_extractor,
235
- request=Veo2GenVidPollRequest(
236
- operationName=operation_name
237
- ),
238
- auth_kwargs=kwargs,
239
- poll_interval=5.0,
240
- result_url_extractor=get_video_url_from_response,
241
- node_id=unique_id,
242
- estimated_duration=AVERAGE_DURATION_VIDEO_GEN,
243
- )
244
-
245
- # Execute the polling operation
246
- poll_response = poll_operation.execute()
247
-
248
- # Now check for errors in the final response
249
- # Check for error in poll response
250
- if hasattr(poll_response, 'error') and poll_response.error:
251
- error_message = f"Veo API error: {poll_response.error.message} (code: {poll_response.error.code})"
252
- logging.error(error_message)
253
- raise Exception(error_message)
254
-
255
- # Check for RAI filtered content
256
- if (hasattr(poll_response.response, 'raiMediaFilteredCount') and
257
- poll_response.response.raiMediaFilteredCount > 0):
258
-
259
- # Extract reason message if available
260
- if (hasattr(poll_response.response, 'raiMediaFilteredReasons') and
261
- poll_response.response.raiMediaFilteredReasons):
262
- reason = poll_response.response.raiMediaFilteredReasons[0]
263
- error_message = f"Content filtered by Google's Responsible AI practices: {reason} ({poll_response.response.raiMediaFilteredCount} videos filtered.)"
264
- else:
265
- error_message = f"Content filtered by Google's Responsible AI practices ({poll_response.response.raiMediaFilteredCount} videos filtered.)"
266
-
267
- logging.error(error_message)
268
- raise Exception(error_message)
269
-
270
- # Extract video data
271
- video_data = None
272
- if poll_response.response and hasattr(poll_response.response, 'videos') and poll_response.response.videos and len(poll_response.response.videos) > 0:
273
- video = poll_response.response.videos[0]
274
-
275
- # Check if video is provided as base64 or URL
276
- if hasattr(video, 'bytesBase64Encoded') and video.bytesBase64Encoded:
277
- # Decode base64 string to bytes
278
- video_data = base64.b64decode(video.bytesBase64Encoded)
279
- elif hasattr(video, 'gcsUri') and video.gcsUri:
280
- # Download from URL
281
- video_url = video.gcsUri
282
- video_response = requests.get(video_url)
283
- video_data = video_response.content
284
- else:
285
- raise Exception("Video returned but no data or URL was provided")
286
- else:
287
- raise Exception("Video generation completed but no video was returned")
288
-
289
- if not video_data:
290
- raise Exception("No video data was returned")
291
-
292
- logging.info("Video generation completed successfully")
293
-
294
- # Convert video data to BytesIO object
295
- video_io = io.BytesIO(video_data)
296
-
297
- # Return VideoFromFile object
298
- return (VideoFromFile(video_io),)
299
-
300
-
301
- # Register the node
302
- NODE_CLASS_MAPPINGS = {
303
- "VeoVideoGenerationNode": VeoVideoGenerationNode,
304
- }
305
-
306
- NODE_DISPLAY_NAME_MAPPINGS = {
307
- "VeoVideoGenerationNode": "Google Veo2 Video Generation",
308
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/redocly-dev.yaml DELETED
@@ -1,10 +0,0 @@
1
- # This file is used to filter the Comfy Org OpenAPI spec for schemas related to API Nodes.
2
- # This is used for development purposes to generate stubs for unreleased API endpoints.
3
- apis:
4
- filter:
5
- root: openapi.yaml
6
- decorators:
7
- filter-in:
8
- property: tags
9
- value: ['API Nodes']
10
- matchStrategy: all
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/redocly.yaml DELETED
@@ -1,10 +0,0 @@
1
- # This file is used to filter the Comfy Org OpenAPI spec for schemas related to API Nodes.
2
-
3
- apis:
4
- filter:
5
- root: openapi.yaml
6
- decorators:
7
- filter-in:
8
- property: tags
9
- value: ['API Nodes', 'Released']
10
- matchStrategy: all
 
 
 
 
 
 
 
 
 
 
 
comfy_api_nodes/util/__init__.py DELETED
File without changes
comfy_api_nodes/util/validation_utils.py DELETED
@@ -1,100 +0,0 @@
1
- import logging
2
- from typing import Optional
3
-
4
- import torch
5
- from comfy_api.input.video_types import VideoInput
6
-
7
-
8
- def get_image_dimensions(image: torch.Tensor) -> tuple[int, int]:
9
- if len(image.shape) == 4:
10
- return image.shape[1], image.shape[2]
11
- elif len(image.shape) == 3:
12
- return image.shape[0], image.shape[1]
13
- else:
14
- raise ValueError("Invalid image tensor shape.")
15
-
16
-
17
- def validate_image_dimensions(
18
- image: torch.Tensor,
19
- min_width: Optional[int] = None,
20
- max_width: Optional[int] = None,
21
- min_height: Optional[int] = None,
22
- max_height: Optional[int] = None,
23
- ):
24
- height, width = get_image_dimensions(image)
25
-
26
- if min_width is not None and width < min_width:
27
- raise ValueError(f"Image width must be at least {min_width}px, got {width}px")
28
- if max_width is not None and width > max_width:
29
- raise ValueError(f"Image width must be at most {max_width}px, got {width}px")
30
- if min_height is not None and height < min_height:
31
- raise ValueError(
32
- f"Image height must be at least {min_height}px, got {height}px"
33
- )
34
- if max_height is not None and height > max_height:
35
- raise ValueError(f"Image height must be at most {max_height}px, got {height}px")
36
-
37
-
38
- def validate_image_aspect_ratio(
39
- image: torch.Tensor,
40
- min_aspect_ratio: Optional[float] = None,
41
- max_aspect_ratio: Optional[float] = None,
42
- ):
43
- width, height = get_image_dimensions(image)
44
- aspect_ratio = width / height
45
-
46
- if min_aspect_ratio is not None and aspect_ratio < min_aspect_ratio:
47
- raise ValueError(
48
- f"Image aspect ratio must be at least {min_aspect_ratio}, got {aspect_ratio}"
49
- )
50
- if max_aspect_ratio is not None and aspect_ratio > max_aspect_ratio:
51
- raise ValueError(
52
- f"Image aspect ratio must be at most {max_aspect_ratio}, got {aspect_ratio}"
53
- )
54
-
55
-
56
- def validate_video_dimensions(
57
- video: VideoInput,
58
- min_width: Optional[int] = None,
59
- max_width: Optional[int] = None,
60
- min_height: Optional[int] = None,
61
- max_height: Optional[int] = None,
62
- ):
63
- try:
64
- width, height = video.get_dimensions()
65
- except Exception as e:
66
- logging.error("Error getting dimensions of video: %s", e)
67
- return
68
-
69
- if min_width is not None and width < min_width:
70
- raise ValueError(f"Video width must be at least {min_width}px, got {width}px")
71
- if max_width is not None and width > max_width:
72
- raise ValueError(f"Video width must be at most {max_width}px, got {width}px")
73
- if min_height is not None and height < min_height:
74
- raise ValueError(
75
- f"Video height must be at least {min_height}px, got {height}px"
76
- )
77
- if max_height is not None and height > max_height:
78
- raise ValueError(f"Video height must be at most {max_height}px, got {height}px")
79
-
80
-
81
- def validate_video_duration(
82
- video: VideoInput,
83
- min_duration: Optional[float] = None,
84
- max_duration: Optional[float] = None,
85
- ):
86
- try:
87
- duration = video.get_duration()
88
- except Exception as e:
89
- logging.error("Error getting duration of video: %s", e)
90
- return
91
-
92
- epsilon = 0.0001
93
- if min_duration is not None and min_duration - epsilon > duration:
94
- raise ValueError(
95
- f"Video duration must be at least {min_duration}s, got {duration}s"
96
- )
97
- if max_duration is not None and duration > max_duration + epsilon:
98
- raise ValueError(
99
- f"Video duration must be at most {max_duration}s, got {duration}s"
100
- )