mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-08-12 12:53:50 +08:00
Merge branch 'master' into update-comfyui-workflow-templates-0.11.31-20260805-033240
This commit is contained in:
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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(...)
|
||||
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user