diff --git a/comfy_api_nodes/apis/bfl.py b/comfy_api_nodes/apis/bfl.py index 4c950da84..389706cf4 100644 --- a/comfy_api_nodes/apis/bfl.py +++ b/comfy_api_nodes/apis/bfl.py @@ -1,7 +1,7 @@ from enum import Enum from typing import Any -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class BFLFluxExpandImageRequest(BaseModel): @@ -121,6 +121,8 @@ class BFLFluxProGenerateResponse(BaseModel): class BFLStatus(str, Enum): task_not_found = "Task not found" pending = "Pending" + reasoning = "Reasoning" + generating = "Generating" request_moderated = "Request Moderated" content_moderated = "Content Moderated" ready = "Ready" @@ -132,3 +134,35 @@ class BFLFluxStatusResponse(BaseModel): status: BFLStatus = Field(...) result: dict[str, Any] | None = Field(None) progress: float | None = Field(None, ge=0.0, le=1.0) + + +class Flux3VideoRequest(BaseModel): + """Fields shared by every generation mode of /v1/flux-3-video.""" + + model_config = ConfigDict(extra="forbid") + + prompt: str = Field(...) + aspect_ratio: str = Field("auto") + duration: int | str = Field("auto", description="Whole seconds, or 'auto'.") + resolution: str = Field("hd", description="'hd' is the 720p class, 'fhd' the 1080p class.") + generate_audio: bool = Field(True) + safety_tolerance: int = Field(2, description="0 is the strictest; conditioned modes cap at 2.") + + +class Flux3TextToVideoRequest(Flux3VideoRequest): + mode: str = Field("t2v") + + +class Flux3ImageToVideoRequest(Flux3VideoRequest): + mode: str = Field("i2v") + keyframes: list[str] | list[tuple[float, str]] = Field( + ..., + description="Images (URL or base64), or [seconds, image] pairs pinning each to a time.", + ) + + +class Flux3VideoContinuationRequest(Flux3VideoRequest): + mode: str = Field("v2v") + start_video: str = Field( + ..., description="MP4 (URL or base64); the new clip carries on from its final frames." + ) diff --git a/comfy_api_nodes/apis/topaz.py b/comfy_api_nodes/apis/topaz.py index f91980e3d..b4a137680 100644 --- a/comfy_api_nodes/apis/topaz.py +++ b/comfy_api_nodes/apis/topaz.py @@ -20,6 +20,41 @@ class ImageEnhanceRequest(BaseModel): color_preservation: str = Field("true", description="To preserve the original color") +class ImageEnhanceRequestV2(BaseModel): + model: str = Field(...) + output_format: str = Field("png") + source_url: str = Field(...) + output_width: Optional[int] = Field(None) + output_height: Optional[int] = Field(None) + crop_to_fill: Optional[bool] = Field(None, description="Available for Reimagine only") + prompt: Optional[str] = Field(None, description="Available for Reimagine and Bloom 2") + creativity: Optional[int] = Field(None, description="From 1 to 9; available for Reimagine and Bloom 2") + subject_detection: Optional[str] = Field(None, description="Available for Reimagine only") + face_enhancement: Optional[bool] = Field(None, description="Available for Reimagine only") + face_enhancement_creativity: Optional[float] = Field(None, description="Is ignored if face_enhancement is false") + face_enhancement_strength: Optional[float] = Field(None, description="Is ignored if face_enhancement is false") + face_preservation: Optional[str] = Field( + None, description='String "true" or "false"; available for Reimagine only' + ) + color_preservation: Optional[str] = Field( + None, description='String "true" or "false"; available for Reimagine and Bloom 2' + ) + autoprompt: Optional[str] = Field( + None, description='String "true" or "false"; auto-generate a prompt, available for Bloom 2 only' + ) + seed: Optional[int] = Field(None, description="Available for Bloom 2 only") + enhancement_strength: Optional[str] = Field( + None, description="low, medium or high; available for Wonder 3.5 only" + ) + grain: Optional[str] = Field( + None, description='String "true" or "false"; available for Bloom 2 and Wonder 3.5' + ) + grain_model: Optional[str] = Field(None, description="silver, gaussian or grey") + grain_strength: Optional[float] = Field(None, description="From 0 to 1") + grain_size: Optional[float] = Field(None, description="From 1 to 5") + grain_density: Optional[float] = Field(None, description="From 0 to 1") + + class ImageAsyncTaskResponse(BaseModel): process_id: str = Field(...) diff --git a/comfy_api_nodes/nodes_bfl.py b/comfy_api_nodes/nodes_bfl.py index 259c54ef9..6c961dc0c 100644 --- a/comfy_api_nodes/nodes_bfl.py +++ b/comfy_api_nodes/nodes_bfl.py @@ -1,3 +1,5 @@ +import math + import torch from pydantic import BaseModel from typing_extensions import override @@ -14,16 +16,23 @@ from comfy_api_nodes.apis.bfl import ( BFLFluxVTORequest, BFLStatus, Flux2ProGenerateRequest, + Flux3ImageToVideoRequest, + Flux3TextToVideoRequest, + Flux3VideoContinuationRequest, + Flux3VideoRequest, ) from comfy_api_nodes.util import ( ApiEndpoint, convert_mask_to_image, download_url_to_image_tensor, + download_url_to_video_output, get_number_of_images, poll_op, resize_mask_to_image, sync_op, tensor_to_base64_string, + upload_images_to_comfyapi, + upload_video_to_comfyapi, validate_aspect_ratio_string, validate_image_dimensions, validate_string, @@ -1007,6 +1016,385 @@ class Flux2ImageNode(IO.ComfyNode): return IO.NodeOutput(await download_url_to_image_tensor(response.result["sample"])) +_FLUX3_ASPECT_RATIOS = ["auto", "21:9", "2:1", "16:9", "4:3", "1:1", "3:4", "9:16"] +_FLUX3_MIN_DURATION = 5 +_FLUX3_MAX_DURATION = 20 +_FLUX3_DURATIONS = ["auto"] + [str(i) for i in range(_FLUX3_MIN_DURATION, _FLUX3_MAX_DURATION + 1)] +_FLUX3_RESOLUTIONS = {"720p": "hd", "1080p": "fhd"} +_FLUX3_MAX_IMAGES = 10 +_FLUX3_MIN_IMAGE_SIDE = 256 +_FLUX3_MAX_IMAGE_ASPECT = 64 + + +def _flux3_validate_image(image: torch.Tensor) -> None: + validate_image_dimensions(image, min_width=_FLUX3_MIN_IMAGE_SIDE, min_height=_FLUX3_MIN_IMAGE_SIDE) + height, width = image.shape[-3], image.shape[-2] + if max(width, height) > _FLUX3_MAX_IMAGE_ASPECT * min(width, height): + raise ValueError( + f"Image aspect ratio is too extreme ({width}x{height}); " + f"FLUX 3 accepts at most {_FLUX3_MAX_IMAGE_ASPECT}:1." + ) + + +def _flux3_collect_images(images: dict | None, field_name: str) -> list[torch.Tensor]: + """Flatten Autogrow slots (each possibly batched) into single images and validate them.""" + flat: list[torch.Tensor] = [] + for tensor in (images or {}).values(): + if tensor is None: + continue + if tensor.ndim == 4: + flat.extend(tensor[i] for i in range(tensor.shape[0])) + else: + flat.append(tensor) + if len(flat) > _FLUX3_MAX_IMAGES: + raise ValueError(f"FLUX 3 supports at most {_FLUX3_MAX_IMAGES} {field_name}, got {len(flat)}.") + for tensor in flat: + _flux3_validate_image(tensor) + return flat + + +def _flux3_parse_times(value: str, image_count: int, duration: int | str) -> list[float]: + """Parse one keyframe time in seconds per image: increasing, inside the clip.""" + parts = [part.strip() for part in value.split(",") if part.strip()] + if len(parts) != image_count: + raise ValueError( + f"Give one time per keyframe image: got {len(parts)} time(s) for {image_count} image(s)." + ) + try: + times = [float(part) for part in parts] + except ValueError as exc: + raise ValueError(f"Keyframe times must be numbers in seconds, comma-separated; got '{value}'.") from exc + if not all(math.isfinite(time) for time in times): + raise ValueError(f"Keyframe times must be finite numbers in seconds; got '{value}'.") + if any(later <= earlier for earlier, later in zip(times, times[1:])): + raise ValueError(f"Keyframe times must increase; got {times}.") + if times[0] < 0: + raise ValueError(f"Keyframe times cannot be negative; got {times[0]}.") + cap = _FLUX3_MAX_DURATION if duration == "auto" else int(duration) + if times[-1] > cap: + raise ValueError(f"Keyframe time {times[-1]}s is past the end of a {cap}s clip.") + return times + + +class Flux3VideoNodeBase(IO.ComfyNode): + """Shared widgets, request plumbing and polling for the FLUX 3 generation modes.""" + + RATE_HD: float + RATE_FHD: float + + @classmethod + def common_inputs(cls) -> list: + return [ + IO.Combo.Input( + "aspect_ratio", + options=_FLUX3_ASPECT_RATIOS, + default="auto", + tooltip="Output aspect ratio. 'auto' picks one from the prompt and inputs.", + ), + IO.Combo.Input( + "duration", + options=_FLUX3_DURATIONS, + default="auto", + tooltip="Clip length in seconds. 'auto' fits the length to the content.", + ), + IO.Combo.Input( + "resolution", + options=list(_FLUX3_RESOLUTIONS), + default="720p", + tooltip="Output resolution.", + ), + IO.Boolean.Input( + "generate_audio", + default=True, + tooltip="Generate synchronized audio (ambient, speech, effects). " + "Off produces a video with no audio track.", + ), + IO.Int.Input( + "safety_tolerance", + default=2, + min=0, + max=4, + advanced=True, + tooltip="Moderation tolerance, 0 is the strictest. Requests that send images or " + "video are capped at 2 whatever you set here.", + ), + IO.Int.Input( + "seed", + default=42, + min=0, + max=0xFFFFFFFF, + control_after_generate=True, + tooltip="Seed to determine if node should re-run; FLUX 3 picks its own seed, so " + "actual results are nondeterministic regardless of this value.", + ), + ] + + @classmethod + def common_fields( + cls, + prompt: str, + aspect_ratio: str, + duration: str, + resolution: str, + generate_audio: bool, + safety_tolerance: int, + ) -> dict: + validate_string(prompt, field_name="prompt", min_length=1) + return { + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "duration": duration if duration == "auto" else int(duration), + "resolution": _FLUX3_RESOLUTIONS[resolution], + "generate_audio": generate_audio, + "safety_tolerance": safety_tolerance, + } + + @classmethod + def price_badge(cls) -> IO.PriceBadge: + return IO.PriceBadge( + depends_on=IO.PriceBadgeDepends(widgets=["resolution", "duration"]), + expr=f""" + ( + $rate := widgets.resolution = "1080p" ? {cls.RATE_FHD} : {cls.RATE_HD}; + $type(widgets.duration) = "string" and widgets.duration != "auto" + ? {{"type":"usd","usd": $rate * $number(widgets.duration)}} + : {{"type":"usd","usd": $rate, "format": {{"suffix": "/second"}}}} + ) + """, + ) + + +async def _flux3_execute(cls: type[IO.ComfyNode], request: Flux3VideoRequest) -> IO.NodeOutput: + initial_response = await sync_op( + cls, + ApiEndpoint(path="/proxy/bfl/v1/flux-3-video", method="POST"), + response_model=BFLFluxProGenerateResponse, + data=request, + ) + + def price_extractor(_r: BaseModel) -> float | None: + return None if initial_response.cost is None else initial_response.cost / 100 + + response = await poll_op( + cls, + ApiEndpoint(initial_response.polling_url), + response_model=BFLFluxStatusResponse, + status_extractor=lambda r: r.status, + progress_extractor=lambda r: r.progress, + price_extractor=price_extractor, + completed_statuses=[BFLStatus.ready], + failed_statuses=[ + BFLStatus.request_moderated, + BFLStatus.content_moderated, + BFLStatus.error, + BFLStatus.task_not_found, + ], + queued_statuses=[BFLStatus.pending], + poll_interval=8.0, + # a failed task answers the poll with a retryable-class HTTP 5xx (500 and 503 observed); + # a small retry budget surfaces real failures quickly + max_retries_per_poll=3, + ) + return IO.NodeOutput(await download_url_to_video_output(response.result["sample"])) + + +class Flux3TextToVideoNode(Flux3VideoNodeBase): + RATE_HD = 0.2431 + RATE_FHD = 0.4147 + + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="Flux3TextToVideoNode", + display_name="Flux 3 Text to Video", + category="partner/video/BFL", + description="Generates a video with synchronized audio from a text prompt via FLUX 3.", + inputs=[ + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="What you want, in plain language; the prompt is interpreted and expanded " + "before generation. Describe ambient sound, music and speech separately for layered audio.", + ), + *cls.common_inputs(), + ], + outputs=[IO.Video.Output()], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=cls.price_badge(), + ) + + @classmethod + async def execute( + cls, + prompt: str, + aspect_ratio: str, + duration: str, + resolution: str, + generate_audio: bool, + safety_tolerance: int, + seed: int, + ) -> IO.NodeOutput: + request = Flux3TextToVideoRequest( + **cls.common_fields(prompt, aspect_ratio, duration, resolution, generate_audio, safety_tolerance) + ) + return await _flux3_execute(cls, request) + + +class Flux3ImageToVideoNode(Flux3VideoNodeBase): + RATE_HD = 0.2431 + RATE_FHD = 0.4147 + + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="Flux3ImageToVideoNode", + display_name="Flux 3 Image to Video", + category="partner/video/BFL", + description="Animates 1 to 10 images with FLUX 3. Each image becomes a frame of the clip: " + "one image opens it, two morph from the first to the second, and more are spread across it " + "or pinned to times you choose.", + inputs=[ + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="How the scene should move and sound; the prompt is interpreted and " + "expanded before generation.", + ), + IO.Autogrow.Input( + "keyframes", + template=IO.Autogrow.TemplatePrefix( + IO.Image.Input("image", tooltip="Keyframe image."), + prefix="image_", + min=1, + max=_FLUX3_MAX_IMAGES, + ), + tooltip="1 to 10 images, in playback order. Minimum 256x256 pixels each.", + ), + IO.DynamicCombo.Input( + "placement", + options=[ + IO.DynamicCombo.Option("spread across the clip", []), + IO.DynamicCombo.Option( + "at times", + [ + IO.String.Input( + "times", + default="0", + tooltip="One time in seconds per image, comma-separated and " + "increasing, e.g. '0, 2.5, 5'.", + ), + ], + ), + ], + tooltip="'spread across the clip' lets FLUX 3 place the images (one opens the clip, " + "two become its start and end); 'at times' pins every image to a second you choose.", + ), + *cls.common_inputs(), + ], + outputs=[IO.Video.Output()], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=cls.price_badge(), + ) + + @classmethod + async def execute( + cls, + prompt: str, + keyframes: IO.Autogrow.Type, + placement: dict, + aspect_ratio: str, + duration: str, + resolution: str, + generate_audio: bool, + safety_tolerance: int, + seed: int, + ) -> IO.NodeOutput: + fields = cls.common_fields(prompt, aspect_ratio, duration, resolution, generate_audio, safety_tolerance) + images = _flux3_collect_images(keyframes, "keyframes") + if not images: + raise ValueError("Connect at least one keyframe image.") + times = None + if placement["placement"] == "at times": + times = _flux3_parse_times(placement["times"], len(images), fields["duration"]) + elif len(images) >= 3 and fields["duration"] == "auto": + # spread images land evenly between the first and last, which needs a known length + raise ValueError( + f"Spreading {len(images)} images across the clip needs an explicit duration: " + "set duration, or place the images yourself with 'at times'." + ) + urls = await upload_images_to_comfyapi( + cls, images, max_images=_FLUX3_MAX_IMAGES, wait_label="Uploading keyframes" + ) + request = Flux3ImageToVideoRequest( + keyframes=list(zip(times, urls)) if times is not None else urls, + **fields, + ) + return await _flux3_execute(cls, request) + + +class Flux3VideoContinuationNode(Flux3VideoNodeBase): + RATE_HD = 0.5863 + RATE_FHD = 0.7579 + + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="Flux3VideoContinuationNode", + display_name="Flux 3 Video Continuation", + category="partner/video/BFL", + description="Continues a video with FLUX 3: the new clip carries on from the final frames " + "of the one you provide.", + inputs=[ + IO.Video.Input("video", tooltip="The clip to continue."), + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="What the continuation should show; the prompt is interpreted and expanded " + "before generation.", + ), + *cls.common_inputs(), + ], + outputs=[IO.Video.Output()], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=cls.price_badge(), + ) + + @classmethod + async def execute( + cls, + video: Input.Video, + prompt: str, + aspect_ratio: str, + duration: str, + resolution: str, + generate_audio: bool, + safety_tolerance: int, + seed: int, + ) -> IO.NodeOutput: + fields = cls.common_fields(prompt, aspect_ratio, duration, resolution, generate_audio, safety_tolerance) + url = await upload_video_to_comfyapi(cls, video, wait_label="Uploading source video") + request = Flux3VideoContinuationRequest(start_video=url, **fields) + return await _flux3_execute(cls, request) + + class BFLExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: @@ -1021,6 +1409,9 @@ class BFLExtension(ComfyExtension): Flux2ProImageNode, Flux2MaxImageNode, Flux2ImageNode, + Flux3TextToVideoNode, + Flux3ImageToVideoNode, + Flux3VideoContinuationNode, ] diff --git a/comfy_api_nodes/nodes_topaz.py b/comfy_api_nodes/nodes_topaz.py index f7ef4cbf6..9a0c70b4d 100644 --- a/comfy_api_nodes/nodes_topaz.py +++ b/comfy_api_nodes/nodes_topaz.py @@ -12,6 +12,7 @@ from comfy_api_nodes.apis.topaz import ( ImageAsyncTaskResponse, ImageDownloadResponse, ImageEnhanceRequest, + ImageEnhanceRequestV2, ImageStatusResponse, OutputInformationVideo, Resolution, @@ -51,7 +52,7 @@ class TopazImageEnhance(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="TopazImageEnhance", - display_name="Topaz Image Enhance", + display_name="Topaz Image Enhance (Legacy)", category="partner/image/Topaz", description="Industry-standard upscaling and image enhancement.", inputs=[ @@ -162,6 +163,7 @@ class TopazImageEnhance(IO.ComfyNode): IO.Hidden.unique_id, ], is_api_node=True, + is_deprecated=True, ) @classmethod @@ -229,6 +231,355 @@ class TopazImageEnhance(IO.ComfyNode): return IO.NodeOutput(await download_url_to_image_tensor(results.download_url)) +class TopazImageEnhanceV2(IO.ComfyNode): + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="TopazImageEnhanceV2", + display_name="Topaz Image Enhance", + category="partner/image/Topaz", + description="Industry-standard upscaling and image enhancement.", + inputs=[ + IO.Image.Input("image"), + IO.DynamicCombo.Input( + "model", + options=[ + IO.DynamicCombo.Option( + "Reimagine", + [ + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="Optional text prompt for creative upscaling guidance.", + ), + IO.Int.Input( + "creativity", + default=3, + min=1, + max=9, + step=1, + display_mode=IO.NumberDisplay.slider, + ), + IO.Combo.Input( + "subject_detection", + options=["All", "Foreground", "Background"], + advanced=True, + ), + IO.Boolean.Input( + "face_enhancement", + default=True, + tooltip="Enhance faces (if present) during processing.", + advanced=True, + ), + IO.Float.Input( + "face_enhancement_creativity", + default=0.0, + min=0.0, + max=1.0, + step=0.01, + display_mode=IO.NumberDisplay.number, + tooltip="Set the creativity level for face enhancement.", + advanced=True, + ), + IO.Float.Input( + "face_enhancement_strength", + default=1.0, + min=0.0, + max=1.0, + step=0.01, + display_mode=IO.NumberDisplay.number, + tooltip="Controls how sharp enhanced faces are relative to the background.", + advanced=True, + ), + IO.Boolean.Input( + "face_preservation", + default=True, + tooltip="Preserve subjects' facial identity.", + advanced=True, + ), + IO.Boolean.Input( + "color_preservation", + default=True, + tooltip="Preserve the original colors.", + advanced=True, + ), + IO.Boolean.Input( + "crop_to_fill", + default=False, + tooltip="By default, the image is letterboxed when the output aspect " + "ratio differs. Enable to crop the image to fill the output dimensions.", + advanced=True, + ), + ], + ), + IO.DynamicCombo.Option( + "Bloom 2", + [ + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="Optional text prompt for generation. " + "Leave empty to auto-generate a prompt from the input image.", + ), + IO.Int.Input( + "creativity", + default=3, + min=1, + max=9, + step=1, + display_mode=IO.NumberDisplay.slider, + tooltip="1 is restrained enhancement, 9 is pronounced reinterpretation " + "with newly generated detail.", + ), + IO.Int.Input( + "seed", + default=2, + min=1, + max=2000, + control_after_generate=True, + tooltip="Seed for reproducible generation.", + ), + IO.Boolean.Input( + "color_preservation", + default=True, + tooltip="Preserve the original colors.", + advanced=True, + ), + IO.Boolean.Input( + "grain", + default=False, + tooltip="Add grain to the output image.", + advanced=True, + ), + IO.Combo.Input( + "grain_model", + options=["silver", "gaussian", "grey"], + tooltip="Is ignored if grain is disabled.", + advanced=True, + ), + IO.Float.Input( + "grain_strength", + default=0.5, + min=0.0, + max=1.0, + step=0.01, + display_mode=IO.NumberDisplay.number, + tooltip="Strength of the grain effect. Is ignored if grain is disabled.", + advanced=True, + ), + IO.Float.Input( + "grain_size", + default=1.0, + min=1.0, + max=5.0, + step=0.1, + display_mode=IO.NumberDisplay.number, + tooltip="Size of the grain particles. Is ignored if grain is disabled.", + advanced=True, + ), + IO.Float.Input( + "grain_density", + default=0.5, + min=0.0, + max=1.0, + step=0.01, + display_mode=IO.NumberDisplay.number, + tooltip="Intensity of the grain effect. Is ignored if grain is disabled.", + advanced=True, + ), + ], + ), + IO.DynamicCombo.Option( + "Wonder 3.5", + [ + IO.Combo.Input( + "enhancement_strength", + options=["low", "medium", "high"], + default="high", + tooltip="Enhancement level for varying input conditions.", + ), + IO.Boolean.Input( + "grain", + default=False, + tooltip="Add grain to the output image.", + advanced=True, + ), + IO.Combo.Input( + "grain_model", + options=["silver", "gaussian", "grey"], + tooltip="Is ignored if grain is disabled.", + advanced=True, + ), + IO.Float.Input( + "grain_strength", + default=0.5, + min=0.0, + max=1.0, + step=0.01, + display_mode=IO.NumberDisplay.number, + tooltip="Strength of the grain effect. Is ignored if grain is disabled.", + advanced=True, + ), + IO.Float.Input( + "grain_size", + default=1.0, + min=1.0, + max=5.0, + step=0.1, + display_mode=IO.NumberDisplay.number, + tooltip="Size of the grain particles. Is ignored if grain is disabled.", + advanced=True, + ), + IO.Float.Input( + "grain_density", + default=0.5, + min=0.0, + max=1.0, + step=0.01, + display_mode=IO.NumberDisplay.number, + tooltip="Intensity of the grain effect. Is ignored if grain is disabled.", + advanced=True, + ), + ], + ), + ], + ), + IO.Int.Input( + "output_width", + default=0, + min=0, + max=32000, + step=1, + display_mode=IO.NumberDisplay.number, + optional=True, + tooltip="Zero value means to calculate automatically (usually it will be original size " + "or scaled proportionally to output_height if specified). " + "Wonder 3.5 supports upscale factors from 1x to 6x only. " + "Bloom 2 and Wonder 3.5 preserve the input aspect ratio and treat the " + "requested size as a target.", + advanced=True, + ), + IO.Int.Input( + "output_height", + default=0, + min=0, + max=32000, + step=1, + display_mode=IO.NumberDisplay.number, + optional=True, + tooltip="Zero value means to output in the same height as original or scaled " + "proportionally to output_width if specified. " + "Wonder 3.5 supports upscale factors from 1x to 6x only. " + "Bloom 2 and Wonder 3.5 preserve the input aspect ratio and treat the " + "requested size as a target.", + advanced=True, + ), + ], + outputs=[ + IO.Image.Output(), + ], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + depends_on=IO.PriceBadgeDepends(widgets=["model"]), + expr=""" + ( + $usdPer8Mp := $lookup( + {"reimagine": 0.32, "bloom 2": 0.4576, "wonder 3.5": 0.1144}, + $lookup(widgets, "model") + ); + {"type":"usd","usd": $usdPer8Mp, "format": {"suffix": "/8MP", "approximate": true}} + ) + """, + ), + ) + + @classmethod + async def execute( + cls, + image: Input.Image, + model: dict, + output_width: int = 0, + output_height: int = 0, + ) -> IO.NodeOutput: + if get_number_of_images(image) != 1: + raise ValueError("Only one input image is supported.") + model_choice = model["model"] + download_url = await upload_images_to_comfyapi( + cls, image, max_images=1, mime_type="image/png", total_pixels=4096 * 4096 + ) + request = ImageEnhanceRequestV2( + model=model_choice, + source_url=download_url[0], + output_width=output_width if output_width else None, + output_height=output_height if output_height else None, + ) + if model_choice == "Reimagine": + request.prompt = model["prompt"] + request.creativity = model["creativity"] + request.subject_detection = model["subject_detection"] + request.face_enhancement = model["face_enhancement"] + request.face_enhancement_creativity = model["face_enhancement_creativity"] + request.face_enhancement_strength = model["face_enhancement_strength"] + request.face_preservation = str(model["face_preservation"]).lower() + request.color_preservation = str(model["color_preservation"]).lower() + request.crop_to_fill = model["crop_to_fill"] + elif model_choice == "Bloom 2": + prompt = model["prompt"].strip() + if prompt: + request.prompt = prompt + request.autoprompt = "false" + else: + request.autoprompt = "true" + request.creativity = model["creativity"] + request.seed = model["seed"] + request.color_preservation = str(model["color_preservation"]).lower() + if model["grain"]: + request.grain = "true" + request.grain_model = model["grain_model"] + request.grain_strength = model["grain_strength"] + request.grain_size = model["grain_size"] + request.grain_density = model["grain_density"] + else: + request.enhancement_strength = model["enhancement_strength"] + if model["grain"]: + request.grain = "true" + request.grain_model = model["grain_model"] + request.grain_strength = model["grain_strength"] + request.grain_size = model["grain_size"] + request.grain_density = model["grain_density"] + initial_response = await sync_op( + cls, + ApiEndpoint(path="/proxy/topaz/image/v1/enhance-gen/async", method="POST"), + response_model=ImageAsyncTaskResponse, + data=request, + content_type="multipart/form-data", + ) + await poll_op( + cls, + poll_endpoint=ApiEndpoint(path=f"/proxy/topaz/image/v1/status/{initial_response.process_id}"), + response_model=ImageStatusResponse, + status_extractor=lambda x: x.status, + progress_extractor=lambda x: getattr(x, "progress", 0), + price_extractor=lambda x: x.credits * (0.08 if model_choice == "Reimagine" else 0.1144), + poll_interval=8.0, + estimated_duration=60, + ) + results = await sync_op( + cls, + ApiEndpoint(path=f"/proxy/topaz/image/v1/download/{initial_response.process_id}"), + response_model=ImageDownloadResponse, + monitor_progress=False, + ) + return IO.NodeOutput(await download_url_to_image_tensor(results.download_url)) + + class TopazVideoEnhance(IO.ComfyNode): @classmethod def define_schema(cls): @@ -818,6 +1169,7 @@ class TopazExtension(ComfyExtension): async def get_node_list(self) -> list[type[IO.ComfyNode]]: return [ TopazImageEnhance, + TopazImageEnhanceV2, TopazVideoEnhance, TopazVideoEnhanceV2, ]