mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-04-19 06:52:31 +08:00
Merge upstream/master, keep local README.md
This commit is contained in:
commit
45ffb85775
2
.github/workflows/release-stable-all.yml
vendored
2
.github/workflows/release-stable-all.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
|||||||
contents: "write"
|
contents: "write"
|
||||||
packages: "write"
|
packages: "write"
|
||||||
pull-requests: "read"
|
pull-requests: "read"
|
||||||
name: "Release NVIDIA Default (cu129)"
|
name: "Release NVIDIA Default (cu130)"
|
||||||
uses: ./.github/workflows/stable-release.yml
|
uses: ./.github/workflows/stable-release.yml
|
||||||
with:
|
with:
|
||||||
git_tag: ${{ inputs.git_tag }}
|
git_tag: ${{ inputs.git_tag }}
|
||||||
|
|||||||
@ -10,7 +10,8 @@ import importlib
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TypedDict, Optional
|
from typing import Dict, TypedDict, Optional
|
||||||
|
from aiohttp import web
|
||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@ -257,7 +258,54 @@ comfyui-frontend-package is not installed.
|
|||||||
sys.exit(-1)
|
sys.exit(-1)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def templates_path(cls) -> str:
|
def template_asset_map(cls) -> Optional[Dict[str, str]]:
|
||||||
|
"""Return a mapping of template asset names to their absolute paths."""
|
||||||
|
try:
|
||||||
|
from comfyui_workflow_templates import (
|
||||||
|
get_asset_path,
|
||||||
|
iter_templates,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
logging.error(
|
||||||
|
f"""
|
||||||
|
********** ERROR ***********
|
||||||
|
|
||||||
|
comfyui-workflow-templates is not installed.
|
||||||
|
|
||||||
|
{frontend_install_warning_message()}
|
||||||
|
|
||||||
|
********** ERROR ***********
|
||||||
|
""".strip()
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
template_entries = list(iter_templates())
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error(f"Failed to enumerate workflow templates: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
asset_map: Dict[str, str] = {}
|
||||||
|
try:
|
||||||
|
for entry in template_entries:
|
||||||
|
for asset in entry.assets:
|
||||||
|
asset_map[asset.filename] = get_asset_path(
|
||||||
|
entry.template_id, asset.filename
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error(f"Failed to resolve template asset paths: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not asset_map:
|
||||||
|
logging.error("No workflow template assets found. Did the packages install correctly?")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return asset_map
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def legacy_templates_path(cls) -> Optional[str]:
|
||||||
|
"""Return the legacy templates directory shipped inside the meta package."""
|
||||||
try:
|
try:
|
||||||
import comfyui_workflow_templates
|
import comfyui_workflow_templates
|
||||||
|
|
||||||
@ -276,6 +324,7 @@ comfyui-workflow-templates is not installed.
|
|||||||
********** ERROR ***********
|
********** ERROR ***********
|
||||||
""".strip()
|
""".strip()
|
||||||
)
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def embedded_docs_path(cls) -> str:
|
def embedded_docs_path(cls) -> str:
|
||||||
@ -392,3 +441,17 @@ comfyui-workflow-templates is not installed.
|
|||||||
logging.info("Falling back to the default frontend.")
|
logging.info("Falling back to the default frontend.")
|
||||||
check_frontend_version()
|
check_frontend_version()
|
||||||
return cls.default_frontend_path()
|
return cls.default_frontend_path()
|
||||||
|
@classmethod
|
||||||
|
def template_asset_handler(cls):
|
||||||
|
assets = cls.template_asset_map()
|
||||||
|
if not assets:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def serve_template(request: web.Request) -> web.StreamResponse:
|
||||||
|
rel_path = request.match_info.get("path", "")
|
||||||
|
target = assets.get(rel_path)
|
||||||
|
if target is None:
|
||||||
|
raise web.HTTPNotFound()
|
||||||
|
return web.FileResponse(target)
|
||||||
|
|
||||||
|
return serve_template
|
||||||
|
|||||||
@ -58,7 +58,8 @@ except (ModuleNotFoundError, TypeError):
|
|||||||
NVIDIA_MEMORY_CONV_BUG_WORKAROUND = False
|
NVIDIA_MEMORY_CONV_BUG_WORKAROUND = False
|
||||||
try:
|
try:
|
||||||
if comfy.model_management.is_nvidia():
|
if comfy.model_management.is_nvidia():
|
||||||
if torch.backends.cudnn.version() >= 91002 and comfy.model_management.torch_version_numeric >= (2, 9) and comfy.model_management.torch_version_numeric <= (2, 10):
|
cudnn_version = torch.backends.cudnn.version()
|
||||||
|
if (cudnn_version >= 91002 and cudnn_version < 91500) and comfy.model_management.torch_version_numeric >= (2, 9) and comfy.model_management.torch_version_numeric <= (2, 10):
|
||||||
#TODO: change upper bound version once it's fixed'
|
#TODO: change upper bound version once it's fixed'
|
||||||
NVIDIA_MEMORY_CONV_BUG_WORKAROUND = True
|
NVIDIA_MEMORY_CONV_BUG_WORKAROUND = True
|
||||||
logging.info("working around nvidia conv3d memory bug.")
|
logging.info("working around nvidia conv3d memory bug.")
|
||||||
|
|||||||
@ -68,7 +68,7 @@ class GeminiTextPart(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class GeminiContent(BaseModel):
|
class GeminiContent(BaseModel):
|
||||||
parts: list[GeminiPart] = Field(...)
|
parts: list[GeminiPart] = Field([])
|
||||||
role: GeminiRole = Field(..., examples=["user"])
|
role: GeminiRole = Field(..., examples=["user"])
|
||||||
|
|
||||||
|
|
||||||
@ -120,7 +120,7 @@ class GeminiGenerationConfig(BaseModel):
|
|||||||
|
|
||||||
class GeminiImageConfig(BaseModel):
|
class GeminiImageConfig(BaseModel):
|
||||||
aspectRatio: str | None = Field(None)
|
aspectRatio: str | None = Field(None)
|
||||||
resolution: str | None = Field(None)
|
imageSize: str | None = Field(None)
|
||||||
|
|
||||||
|
|
||||||
class GeminiImageGenerationConfig(GeminiGenerationConfig):
|
class GeminiImageGenerationConfig(GeminiGenerationConfig):
|
||||||
@ -227,3 +227,4 @@ class GeminiGenerateContentResponse(BaseModel):
|
|||||||
candidates: list[GeminiCandidate] | None = Field(None)
|
candidates: list[GeminiCandidate] | None = Field(None)
|
||||||
promptFeedback: GeminiPromptFeedback | None = Field(None)
|
promptFeedback: GeminiPromptFeedback | None = Field(None)
|
||||||
usageMetadata: GeminiUsageMetadata | None = Field(None)
|
usageMetadata: GeminiUsageMetadata | None = Field(None)
|
||||||
|
modelVersion: str | None = Field(None)
|
||||||
|
|||||||
133
comfy_api_nodes/apis/topaz_api.py
Normal file
133
comfy_api_nodes/apis/topaz_api.py
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ImageEnhanceRequest(BaseModel):
|
||||||
|
model: str = Field("Reimagine")
|
||||||
|
output_format: str = Field("jpeg")
|
||||||
|
subject_detection: str = Field("All")
|
||||||
|
face_enhancement: bool = Field(True)
|
||||||
|
face_enhancement_creativity: float = Field(0, description="Is ignored if face_enhancement is false")
|
||||||
|
face_enhancement_strength: float = Field(0.8, description="Is ignored if face_enhancement is false")
|
||||||
|
source_url: str = Field(...)
|
||||||
|
output_width: Optional[int] = Field(None)
|
||||||
|
output_height: Optional[int] = Field(None)
|
||||||
|
crop_to_fill: bool = Field(False)
|
||||||
|
prompt: Optional[str] = Field(None, description="Text prompt for creative upscaling guidance")
|
||||||
|
creativity: int = Field(3, description="Creativity settings range from 1 to 9")
|
||||||
|
face_preservation: str = Field("true", description="To preserve the identity of characters")
|
||||||
|
color_preservation: str = Field("true", description="To preserve the original color")
|
||||||
|
|
||||||
|
|
||||||
|
class ImageAsyncTaskResponse(BaseModel):
|
||||||
|
process_id: str = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageStatusResponse(BaseModel):
|
||||||
|
process_id: str = Field(...)
|
||||||
|
status: str = Field(...)
|
||||||
|
progress: Optional[int] = Field(None)
|
||||||
|
credits: int = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageDownloadResponse(BaseModel):
|
||||||
|
download_url: str = Field(...)
|
||||||
|
expiry: int = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class Resolution(BaseModel):
|
||||||
|
width: int = Field(...)
|
||||||
|
height: int = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateCreateVideoRequestSource(BaseModel):
|
||||||
|
container: str = Field(...)
|
||||||
|
size: int = Field(..., description="Size of the video file in bytes")
|
||||||
|
duration: int = Field(..., description="Duration of the video file in seconds")
|
||||||
|
frameCount: int = Field(..., description="Total number of frames in the video")
|
||||||
|
frameRate: int = Field(...)
|
||||||
|
resolution: Resolution = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoFrameInterpolationFilter(BaseModel):
|
||||||
|
model: str = Field(...)
|
||||||
|
slowmo: Optional[int] = Field(None)
|
||||||
|
fps: int = Field(...)
|
||||||
|
duplicate: bool = Field(...)
|
||||||
|
duplicate_threshold: float = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoEnhancementFilter(BaseModel):
|
||||||
|
model: str = Field(...)
|
||||||
|
auto: Optional[str] = Field(None, description="Auto, Manual, Relative")
|
||||||
|
focusFixLevel: Optional[str] = Field(None, description="Downscales video input for correction of blurred subjects")
|
||||||
|
compression: Optional[float] = Field(None, description="Strength of compression recovery")
|
||||||
|
details: Optional[float] = Field(None, description="Amount of detail reconstruction")
|
||||||
|
prenoise: Optional[float] = Field(None, description="Amount of noise to add to input to reduce over-smoothing")
|
||||||
|
noise: Optional[float] = Field(None, description="Amount of noise reduction")
|
||||||
|
halo: Optional[float] = Field(None, description="Amount of halo reduction")
|
||||||
|
preblur: Optional[float] = Field(None, description="Anti-aliasing and deblurring strength")
|
||||||
|
blur: Optional[float] = Field(None, description="Amount of sharpness applied")
|
||||||
|
grain: Optional[float] = Field(None, description="Grain after AI model processing")
|
||||||
|
grainSize: Optional[float] = Field(None, description="Size of generated grain")
|
||||||
|
recoverOriginalDetailValue: Optional[float] = Field(None, description="Source details into the output video")
|
||||||
|
creativity: Optional[str] = Field(None, description="Creativity level(high, low) for slc-1 only")
|
||||||
|
isOptimizedMode: Optional[bool] = Field(None, description="Set to true for Starlight Creative (slc-1) only")
|
||||||
|
|
||||||
|
|
||||||
|
class OutputInformationVideo(BaseModel):
|
||||||
|
resolution: Resolution = Field(...)
|
||||||
|
frameRate: int = Field(...)
|
||||||
|
audioCodec: Optional[str] = Field(..., description="Required if audioTransfer is Copy or Convert")
|
||||||
|
audioTransfer: str = Field(..., description="Copy, Convert, None")
|
||||||
|
dynamicCompressionLevel: str = Field(..., description="Low, Mid, High")
|
||||||
|
|
||||||
|
|
||||||
|
class Overrides(BaseModel):
|
||||||
|
isPaidDiffusion: bool = Field(True)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateVideoRequest(BaseModel):
|
||||||
|
source: CreateCreateVideoRequestSource = Field(...)
|
||||||
|
filters: list[Union[VideoFrameInterpolationFilter, VideoEnhancementFilter]] = Field(...)
|
||||||
|
output: OutputInformationVideo = Field(...)
|
||||||
|
overrides: Overrides = Field(Overrides(isPaidDiffusion=True))
|
||||||
|
|
||||||
|
|
||||||
|
class CreateVideoResponse(BaseModel):
|
||||||
|
requestId: str = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoAcceptResponse(BaseModel):
|
||||||
|
uploadId: str = Field(...)
|
||||||
|
urls: list[str] = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoCompleteUploadRequestPart(BaseModel):
|
||||||
|
partNum: int = Field(...)
|
||||||
|
eTag: str = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoCompleteUploadRequest(BaseModel):
|
||||||
|
uploadResults: list[VideoCompleteUploadRequestPart] = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoCompleteUploadResponse(BaseModel):
|
||||||
|
message: str = Field(..., description="Confirmation message")
|
||||||
|
|
||||||
|
|
||||||
|
class VideoStatusResponseEstimates(BaseModel):
|
||||||
|
cost: list[int] = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoStatusResponseDownloadUrl(BaseModel):
|
||||||
|
url: str = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoStatusResponse(BaseModel):
|
||||||
|
status: str = Field(...)
|
||||||
|
estimates: Optional[VideoStatusResponseEstimates] = Field(None)
|
||||||
|
progress: Optional[float] = Field(None)
|
||||||
|
message: Optional[str] = Field("")
|
||||||
|
download: Optional[VideoStatusResponseDownloadUrl] = Field(None)
|
||||||
@ -29,11 +29,13 @@ from comfy_api_nodes.apis.gemini_api import (
|
|||||||
GeminiMimeType,
|
GeminiMimeType,
|
||||||
GeminiPart,
|
GeminiPart,
|
||||||
GeminiRole,
|
GeminiRole,
|
||||||
|
Modality,
|
||||||
)
|
)
|
||||||
from comfy_api_nodes.util import (
|
from comfy_api_nodes.util import (
|
||||||
ApiEndpoint,
|
ApiEndpoint,
|
||||||
audio_to_base64_string,
|
audio_to_base64_string,
|
||||||
bytesio_to_image_tensor,
|
bytesio_to_image_tensor,
|
||||||
|
get_number_of_images,
|
||||||
sync_op,
|
sync_op,
|
||||||
tensor_to_base64_string,
|
tensor_to_base64_string,
|
||||||
validate_string,
|
validate_string,
|
||||||
@ -147,6 +149,49 @@ def get_image_from_response(response: GeminiGenerateContentResponse) -> torch.Te
|
|||||||
return torch.cat(image_tensors, dim=0)
|
return torch.cat(image_tensors, dim=0)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_tokens_price(response: GeminiGenerateContentResponse) -> float | None:
|
||||||
|
if not response.modelVersion:
|
||||||
|
return None
|
||||||
|
# Define prices (Cost per 1,000,000 tokens), see https://cloud.google.com/vertex-ai/generative-ai/pricing
|
||||||
|
if response.modelVersion in ("gemini-2.5-pro-preview-05-06", "gemini-2.5-pro"):
|
||||||
|
input_tokens_price = 1.25
|
||||||
|
output_text_tokens_price = 10.0
|
||||||
|
output_image_tokens_price = 0.0
|
||||||
|
elif response.modelVersion in (
|
||||||
|
"gemini-2.5-flash-preview-04-17",
|
||||||
|
"gemini-2.5-flash",
|
||||||
|
):
|
||||||
|
input_tokens_price = 0.30
|
||||||
|
output_text_tokens_price = 2.50
|
||||||
|
output_image_tokens_price = 0.0
|
||||||
|
elif response.modelVersion in (
|
||||||
|
"gemini-2.5-flash-image-preview",
|
||||||
|
"gemini-2.5-flash-image",
|
||||||
|
):
|
||||||
|
input_tokens_price = 0.30
|
||||||
|
output_text_tokens_price = 2.50
|
||||||
|
output_image_tokens_price = 30.0
|
||||||
|
elif response.modelVersion == "gemini-3-pro-preview":
|
||||||
|
input_tokens_price = 2
|
||||||
|
output_text_tokens_price = 12.0
|
||||||
|
output_image_tokens_price = 0.0
|
||||||
|
elif response.modelVersion == "gemini-3-pro-image-preview":
|
||||||
|
input_tokens_price = 2
|
||||||
|
output_text_tokens_price = 12.0
|
||||||
|
output_image_tokens_price = 120.0
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
final_price = response.usageMetadata.promptTokenCount * input_tokens_price
|
||||||
|
for i in response.usageMetadata.candidatesTokensDetails:
|
||||||
|
if i.modality == Modality.IMAGE:
|
||||||
|
final_price += output_image_tokens_price * i.tokenCount # for Nano Banana models
|
||||||
|
else:
|
||||||
|
final_price += output_text_tokens_price * i.tokenCount
|
||||||
|
if response.usageMetadata.thoughtsTokenCount:
|
||||||
|
final_price += output_text_tokens_price * response.usageMetadata.thoughtsTokenCount
|
||||||
|
return final_price / 1_000_000.0
|
||||||
|
|
||||||
|
|
||||||
class GeminiNode(IO.ComfyNode):
|
class GeminiNode(IO.ComfyNode):
|
||||||
"""
|
"""
|
||||||
Node to generate text responses from a Gemini model.
|
Node to generate text responses from a Gemini model.
|
||||||
@ -314,6 +359,7 @@ class GeminiNode(IO.ComfyNode):
|
|||||||
]
|
]
|
||||||
),
|
),
|
||||||
response_model=GeminiGenerateContentResponse,
|
response_model=GeminiGenerateContentResponse,
|
||||||
|
price_extractor=calculate_tokens_price,
|
||||||
)
|
)
|
||||||
|
|
||||||
output_text = get_text_from_response(response)
|
output_text = get_text_from_response(response)
|
||||||
@ -476,6 +522,13 @@ class GeminiImage(IO.ComfyNode):
|
|||||||
"or otherwise generates 1:1 squares.",
|
"or otherwise generates 1:1 squares.",
|
||||||
optional=True,
|
optional=True,
|
||||||
),
|
),
|
||||||
|
IO.Combo.Input(
|
||||||
|
"response_modalities",
|
||||||
|
options=["IMAGE+TEXT", "IMAGE"],
|
||||||
|
tooltip="Choose 'IMAGE' for image-only output, or "
|
||||||
|
"'IMAGE+TEXT' to return both the generated image and a text response.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
outputs=[
|
outputs=[
|
||||||
IO.Image.Output(),
|
IO.Image.Output(),
|
||||||
@ -498,6 +551,7 @@ class GeminiImage(IO.ComfyNode):
|
|||||||
images: torch.Tensor | None = None,
|
images: torch.Tensor | None = None,
|
||||||
files: list[GeminiPart] | None = None,
|
files: list[GeminiPart] | None = None,
|
||||||
aspect_ratio: str = "auto",
|
aspect_ratio: str = "auto",
|
||||||
|
response_modalities: str = "IMAGE+TEXT",
|
||||||
) -> IO.NodeOutput:
|
) -> IO.NodeOutput:
|
||||||
validate_string(prompt, strip_whitespace=True, min_length=1)
|
validate_string(prompt, strip_whitespace=True, min_length=1)
|
||||||
parts: list[GeminiPart] = [GeminiPart(text=prompt)]
|
parts: list[GeminiPart] = [GeminiPart(text=prompt)]
|
||||||
@ -520,17 +574,16 @@ class GeminiImage(IO.ComfyNode):
|
|||||||
GeminiContent(role=GeminiRole.user, parts=parts),
|
GeminiContent(role=GeminiRole.user, parts=parts),
|
||||||
],
|
],
|
||||||
generationConfig=GeminiImageGenerationConfig(
|
generationConfig=GeminiImageGenerationConfig(
|
||||||
responseModalities=["TEXT", "IMAGE"],
|
responseModalities=(["IMAGE"] if response_modalities == "IMAGE" else ["TEXT", "IMAGE"]),
|
||||||
imageConfig=None if aspect_ratio == "auto" else image_config,
|
imageConfig=None if aspect_ratio == "auto" else image_config,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
response_model=GeminiGenerateContentResponse,
|
response_model=GeminiGenerateContentResponse,
|
||||||
|
price_extractor=calculate_tokens_price,
|
||||||
)
|
)
|
||||||
|
|
||||||
output_image = get_image_from_response(response)
|
|
||||||
output_text = get_text_from_response(response)
|
output_text = get_text_from_response(response)
|
||||||
if output_text:
|
if output_text:
|
||||||
# Not a true chat history like the OpenAI Chat node. It is emulated so the frontend can show a copy button.
|
|
||||||
render_spec = {
|
render_spec = {
|
||||||
"node_id": cls.hidden.unique_id,
|
"node_id": cls.hidden.unique_id,
|
||||||
"component": "ChatHistoryWidget",
|
"component": "ChatHistoryWidget",
|
||||||
@ -551,9 +604,150 @@ class GeminiImage(IO.ComfyNode):
|
|||||||
"display_component",
|
"display_component",
|
||||||
render_spec,
|
render_spec,
|
||||||
)
|
)
|
||||||
|
return IO.NodeOutput(get_image_from_response(response), output_text)
|
||||||
|
|
||||||
output_text = output_text or "Empty response from Gemini model..."
|
|
||||||
return IO.NodeOutput(output_image, output_text)
|
class GeminiImage2(IO.ComfyNode):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls):
|
||||||
|
return IO.Schema(
|
||||||
|
node_id="GeminiImage2Node",
|
||||||
|
display_name="Nano Banana Pro (Google Gemini Image)",
|
||||||
|
category="api node/image/Gemini",
|
||||||
|
description="Generate or edit images synchronously via Google Vertex API.",
|
||||||
|
inputs=[
|
||||||
|
IO.String.Input(
|
||||||
|
"prompt",
|
||||||
|
multiline=True,
|
||||||
|
tooltip="Text prompt describing the image to generate or the edits to apply. "
|
||||||
|
"Include any constraints, styles, or details the model should follow.",
|
||||||
|
default="",
|
||||||
|
),
|
||||||
|
IO.Combo.Input(
|
||||||
|
"model",
|
||||||
|
options=["gemini-3-pro-image-preview"],
|
||||||
|
),
|
||||||
|
IO.Int.Input(
|
||||||
|
"seed",
|
||||||
|
default=42,
|
||||||
|
min=0,
|
||||||
|
max=0xFFFFFFFFFFFFFFFF,
|
||||||
|
control_after_generate=True,
|
||||||
|
tooltip="When the 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.",
|
||||||
|
),
|
||||||
|
IO.Combo.Input(
|
||||||
|
"aspect_ratio",
|
||||||
|
options=["auto", "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"],
|
||||||
|
default="auto",
|
||||||
|
tooltip="If set to 'auto', matches your input image's aspect ratio; "
|
||||||
|
"if no image is provided, generates a 1:1 square.",
|
||||||
|
),
|
||||||
|
IO.Combo.Input(
|
||||||
|
"resolution",
|
||||||
|
options=["1K", "2K", "4K"],
|
||||||
|
tooltip="Target output resolution. For 2K/4K the native Gemini upscaler is used.",
|
||||||
|
),
|
||||||
|
IO.Combo.Input(
|
||||||
|
"response_modalities",
|
||||||
|
options=["IMAGE+TEXT", "IMAGE"],
|
||||||
|
tooltip="Choose 'IMAGE' for image-only output, or "
|
||||||
|
"'IMAGE+TEXT' to return both the generated image and a text response.",
|
||||||
|
),
|
||||||
|
IO.Image.Input(
|
||||||
|
"images",
|
||||||
|
optional=True,
|
||||||
|
tooltip="Optional reference image(s). "
|
||||||
|
"To include multiple images, use the Batch Images node (up to 14).",
|
||||||
|
),
|
||||||
|
IO.Custom("GEMINI_INPUT_FILES").Input(
|
||||||
|
"files",
|
||||||
|
optional=True,
|
||||||
|
tooltip="Optional file(s) to use as context for the model. "
|
||||||
|
"Accepts inputs from the Gemini Generate Content Input Files node.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
outputs=[
|
||||||
|
IO.Image.Output(),
|
||||||
|
IO.String.Output(),
|
||||||
|
],
|
||||||
|
hidden=[
|
||||||
|
IO.Hidden.auth_token_comfy_org,
|
||||||
|
IO.Hidden.api_key_comfy_org,
|
||||||
|
IO.Hidden.unique_id,
|
||||||
|
],
|
||||||
|
is_api_node=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def execute(
|
||||||
|
cls,
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
seed: int,
|
||||||
|
aspect_ratio: str,
|
||||||
|
resolution: str,
|
||||||
|
response_modalities: str,
|
||||||
|
images: torch.Tensor | None = None,
|
||||||
|
files: list[GeminiPart] | None = None,
|
||||||
|
) -> IO.NodeOutput:
|
||||||
|
validate_string(prompt, strip_whitespace=True, min_length=1)
|
||||||
|
|
||||||
|
parts: list[GeminiPart] = [GeminiPart(text=prompt)]
|
||||||
|
if images is not None:
|
||||||
|
if get_number_of_images(images) > 14:
|
||||||
|
raise ValueError("The current maximum number of supported images is 14.")
|
||||||
|
parts.extend(create_image_parts(images))
|
||||||
|
if files is not None:
|
||||||
|
parts.extend(files)
|
||||||
|
|
||||||
|
image_config = GeminiImageConfig(imageSize=resolution)
|
||||||
|
if aspect_ratio != "auto":
|
||||||
|
image_config.aspectRatio = aspect_ratio
|
||||||
|
|
||||||
|
response = await sync_op(
|
||||||
|
cls,
|
||||||
|
ApiEndpoint(path=f"{GEMINI_BASE_ENDPOINT}/{model}", method="POST"),
|
||||||
|
data=GeminiImageGenerateContentRequest(
|
||||||
|
contents=[
|
||||||
|
GeminiContent(role=GeminiRole.user, parts=parts),
|
||||||
|
],
|
||||||
|
generationConfig=GeminiImageGenerationConfig(
|
||||||
|
responseModalities=(["IMAGE"] if response_modalities == "IMAGE" else ["TEXT", "IMAGE"]),
|
||||||
|
imageConfig=image_config,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
response_model=GeminiGenerateContentResponse,
|
||||||
|
price_extractor=calculate_tokens_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
output_text = get_text_from_response(response)
|
||||||
|
if output_text:
|
||||||
|
render_spec = {
|
||||||
|
"node_id": cls.hidden.unique_id,
|
||||||
|
"component": "ChatHistoryWidget",
|
||||||
|
"props": {
|
||||||
|
"history": json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"prompt": prompt,
|
||||||
|
"response": output_text,
|
||||||
|
"response_id": str(uuid.uuid4()),
|
||||||
|
"timestamp": time.time(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
PromptServer.instance.send_sync(
|
||||||
|
"display_component",
|
||||||
|
render_spec,
|
||||||
|
)
|
||||||
|
return IO.NodeOutput(get_image_from_response(response), output_text)
|
||||||
|
|
||||||
|
|
||||||
class GeminiExtension(ComfyExtension):
|
class GeminiExtension(ComfyExtension):
|
||||||
@ -562,6 +756,7 @@ class GeminiExtension(ComfyExtension):
|
|||||||
return [
|
return [
|
||||||
GeminiNode,
|
GeminiNode,
|
||||||
GeminiImage,
|
GeminiImage,
|
||||||
|
GeminiImage2,
|
||||||
GeminiInputFiles,
|
GeminiInputFiles,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -518,7 +518,9 @@ async def execute_lipsync(
|
|||||||
|
|
||||||
# Upload the audio file to Comfy API and get download URL
|
# Upload the audio file to Comfy API and get download URL
|
||||||
if audio:
|
if audio:
|
||||||
audio_url = await upload_audio_to_comfyapi(cls, audio)
|
audio_url = await upload_audio_to_comfyapi(
|
||||||
|
cls, audio, container_format="mp3", codec_name="libmp3lame", mime_type="audio/mpeg", filename="output.mp3"
|
||||||
|
)
|
||||||
logging.info("Uploaded audio to Comfy API. URL: %s", audio_url)
|
logging.info("Uploaded audio to Comfy API. URL: %s", audio_url)
|
||||||
else:
|
else:
|
||||||
audio_url = None
|
audio_url = None
|
||||||
|
|||||||
421
comfy_api_nodes/nodes_topaz.py
Normal file
421
comfy_api_nodes/nodes_topaz.py
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
import builtins
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import torch
|
||||||
|
from typing_extensions import override
|
||||||
|
|
||||||
|
from comfy_api.input.video_types import VideoInput
|
||||||
|
from comfy_api.latest import IO, ComfyExtension
|
||||||
|
from comfy_api_nodes.apis import topaz_api
|
||||||
|
from comfy_api_nodes.util import (
|
||||||
|
ApiEndpoint,
|
||||||
|
download_url_to_image_tensor,
|
||||||
|
download_url_to_video_output,
|
||||||
|
get_fs_object_size,
|
||||||
|
get_number_of_images,
|
||||||
|
poll_op,
|
||||||
|
sync_op,
|
||||||
|
upload_images_to_comfyapi,
|
||||||
|
validate_container_format_is_mp4,
|
||||||
|
)
|
||||||
|
|
||||||
|
UPSCALER_MODELS_MAP = {
|
||||||
|
"Starlight (Astra) Fast": "slf-1",
|
||||||
|
"Starlight (Astra) Creative": "slc-1",
|
||||||
|
}
|
||||||
|
UPSCALER_VALUES_MAP = {
|
||||||
|
"FullHD (1080p)": 1920,
|
||||||
|
"4K (2160p)": 3840,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TopazImageEnhance(IO.ComfyNode):
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls):
|
||||||
|
return IO.Schema(
|
||||||
|
node_id="TopazImageEnhance",
|
||||||
|
display_name="Topaz Image Enhance",
|
||||||
|
category="api node/image/Topaz",
|
||||||
|
description="Industry-standard upscaling and image enhancement.",
|
||||||
|
inputs=[
|
||||||
|
IO.Combo.Input("model", options=["Reimagine"]),
|
||||||
|
IO.Image.Input("image"),
|
||||||
|
IO.String.Input(
|
||||||
|
"prompt",
|
||||||
|
multiline=True,
|
||||||
|
default="",
|
||||||
|
tooltip="Optional text prompt for creative upscaling guidance.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
IO.Combo.Input(
|
||||||
|
"subject_detection",
|
||||||
|
options=["All", "Foreground", "Background"],
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
IO.Boolean.Input(
|
||||||
|
"face_enhancement",
|
||||||
|
default=True,
|
||||||
|
optional=True,
|
||||||
|
tooltip="Enhance faces (if present) during processing.",
|
||||||
|
),
|
||||||
|
IO.Float.Input(
|
||||||
|
"face_enhancement_creativity",
|
||||||
|
default=0.0,
|
||||||
|
min=0.0,
|
||||||
|
max=1.0,
|
||||||
|
step=0.01,
|
||||||
|
display_mode=IO.NumberDisplay.number,
|
||||||
|
optional=True,
|
||||||
|
tooltip="Set the creativity level for face enhancement.",
|
||||||
|
),
|
||||||
|
IO.Float.Input(
|
||||||
|
"face_enhancement_strength",
|
||||||
|
default=1.0,
|
||||||
|
min=0.0,
|
||||||
|
max=1.0,
|
||||||
|
step=0.01,
|
||||||
|
display_mode=IO.NumberDisplay.number,
|
||||||
|
optional=True,
|
||||||
|
tooltip="Controls how sharp enhanced faces are relative to the background.",
|
||||||
|
),
|
||||||
|
IO.Boolean.Input(
|
||||||
|
"crop_to_fill",
|
||||||
|
default=False,
|
||||||
|
optional=True,
|
||||||
|
tooltip="By default, the image is letterboxed when the output aspect ratio differs. "
|
||||||
|
"Enable to crop the image to fill the output dimensions.",
|
||||||
|
),
|
||||||
|
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 output_height if specified).",
|
||||||
|
),
|
||||||
|
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 output width.",
|
||||||
|
),
|
||||||
|
IO.Int.Input(
|
||||||
|
"creativity",
|
||||||
|
default=3,
|
||||||
|
min=1,
|
||||||
|
max=9,
|
||||||
|
step=1,
|
||||||
|
display_mode=IO.NumberDisplay.slider,
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
IO.Boolean.Input(
|
||||||
|
"face_preservation",
|
||||||
|
default=True,
|
||||||
|
optional=True,
|
||||||
|
tooltip="Preserve subjects' facial identity.",
|
||||||
|
),
|
||||||
|
IO.Boolean.Input(
|
||||||
|
"color_preservation",
|
||||||
|
default=True,
|
||||||
|
optional=True,
|
||||||
|
tooltip="Preserve the original colors.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def execute(
|
||||||
|
cls,
|
||||||
|
model: str,
|
||||||
|
image: torch.Tensor,
|
||||||
|
prompt: str = "",
|
||||||
|
subject_detection: str = "All",
|
||||||
|
face_enhancement: bool = True,
|
||||||
|
face_enhancement_creativity: float = 1.0,
|
||||||
|
face_enhancement_strength: float = 0.8,
|
||||||
|
crop_to_fill: bool = False,
|
||||||
|
output_width: int = 0,
|
||||||
|
output_height: int = 0,
|
||||||
|
creativity: int = 3,
|
||||||
|
face_preservation: bool = True,
|
||||||
|
color_preservation: bool = True,
|
||||||
|
) -> IO.NodeOutput:
|
||||||
|
if get_number_of_images(image) != 1:
|
||||||
|
raise ValueError("Only one input image is supported.")
|
||||||
|
download_url = await upload_images_to_comfyapi(cls, image, max_images=1, mime_type="image/png")
|
||||||
|
initial_response = await sync_op(
|
||||||
|
cls,
|
||||||
|
ApiEndpoint(path="/proxy/topaz/image/v1/enhance-gen/async", method="POST"),
|
||||||
|
response_model=topaz_api.ImageAsyncTaskResponse,
|
||||||
|
data=topaz_api.ImageEnhanceRequest(
|
||||||
|
model=model,
|
||||||
|
prompt=prompt,
|
||||||
|
subject_detection=subject_detection,
|
||||||
|
face_enhancement=face_enhancement,
|
||||||
|
face_enhancement_creativity=face_enhancement_creativity,
|
||||||
|
face_enhancement_strength=face_enhancement_strength,
|
||||||
|
crop_to_fill=crop_to_fill,
|
||||||
|
output_width=output_width if output_width else None,
|
||||||
|
output_height=output_height if output_height else None,
|
||||||
|
creativity=creativity,
|
||||||
|
face_preservation=str(face_preservation).lower(),
|
||||||
|
color_preservation=str(color_preservation).lower(),
|
||||||
|
source_url=download_url[0],
|
||||||
|
output_format="png",
|
||||||
|
),
|
||||||
|
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=topaz_api.ImageStatusResponse,
|
||||||
|
status_extractor=lambda x: x.status,
|
||||||
|
progress_extractor=lambda x: getattr(x, "progress", 0),
|
||||||
|
price_extractor=lambda x: x.credits * 0.08,
|
||||||
|
poll_interval=8.0,
|
||||||
|
max_poll_attempts=160,
|
||||||
|
estimated_duration=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await sync_op(
|
||||||
|
cls,
|
||||||
|
ApiEndpoint(path=f"/proxy/topaz/image/v1/download/{initial_response.process_id}"),
|
||||||
|
response_model=topaz_api.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):
|
||||||
|
return IO.Schema(
|
||||||
|
node_id="TopazVideoEnhance",
|
||||||
|
display_name="Topaz Video Enhance",
|
||||||
|
category="api node/video/Topaz",
|
||||||
|
description="Breathe new life into video with powerful upscaling and recovery technology.",
|
||||||
|
inputs=[
|
||||||
|
IO.Video.Input("video"),
|
||||||
|
IO.Boolean.Input("upscaler_enabled", default=True),
|
||||||
|
IO.Combo.Input("upscaler_model", options=list(UPSCALER_MODELS_MAP.keys())),
|
||||||
|
IO.Combo.Input("upscaler_resolution", options=list(UPSCALER_VALUES_MAP.keys())),
|
||||||
|
IO.Combo.Input(
|
||||||
|
"upscaler_creativity",
|
||||||
|
options=["low", "middle", "high"],
|
||||||
|
default="low",
|
||||||
|
tooltip="Creativity level (applies only to Starlight (Astra) Creative).",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
IO.Boolean.Input("interpolation_enabled", default=False, optional=True),
|
||||||
|
IO.Combo.Input("interpolation_model", options=["apo-8"], default="apo-8", optional=True),
|
||||||
|
IO.Int.Input(
|
||||||
|
"interpolation_slowmo",
|
||||||
|
default=1,
|
||||||
|
min=1,
|
||||||
|
max=16,
|
||||||
|
display_mode=IO.NumberDisplay.number,
|
||||||
|
tooltip="Slow-motion factor applied to the input video. "
|
||||||
|
"For example, 2 makes the output twice as slow and doubles the duration.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
IO.Int.Input(
|
||||||
|
"interpolation_frame_rate",
|
||||||
|
default=60,
|
||||||
|
min=15,
|
||||||
|
max=240,
|
||||||
|
display_mode=IO.NumberDisplay.number,
|
||||||
|
tooltip="Output frame rate.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
IO.Boolean.Input(
|
||||||
|
"interpolation_duplicate",
|
||||||
|
default=False,
|
||||||
|
tooltip="Analyze the input for duplicate frames and remove them.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
IO.Float.Input(
|
||||||
|
"interpolation_duplicate_threshold",
|
||||||
|
default=0.01,
|
||||||
|
min=0.001,
|
||||||
|
max=0.1,
|
||||||
|
step=0.001,
|
||||||
|
display_mode=IO.NumberDisplay.number,
|
||||||
|
tooltip="Detection sensitivity for duplicate frames.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
IO.Combo.Input(
|
||||||
|
"dynamic_compression_level",
|
||||||
|
options=["Low", "Mid", "High"],
|
||||||
|
default="Low",
|
||||||
|
tooltip="CQP level.",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def execute(
|
||||||
|
cls,
|
||||||
|
video: VideoInput,
|
||||||
|
upscaler_enabled: bool,
|
||||||
|
upscaler_model: str,
|
||||||
|
upscaler_resolution: str,
|
||||||
|
upscaler_creativity: str = "low",
|
||||||
|
interpolation_enabled: bool = False,
|
||||||
|
interpolation_model: str = "apo-8",
|
||||||
|
interpolation_slowmo: int = 1,
|
||||||
|
interpolation_frame_rate: int = 60,
|
||||||
|
interpolation_duplicate: bool = False,
|
||||||
|
interpolation_duplicate_threshold: float = 0.01,
|
||||||
|
dynamic_compression_level: str = "Low",
|
||||||
|
) -> IO.NodeOutput:
|
||||||
|
if upscaler_enabled is False and interpolation_enabled is False:
|
||||||
|
raise ValueError("There is nothing to do: both upscaling and interpolation are disabled.")
|
||||||
|
src_width, src_height = video.get_dimensions()
|
||||||
|
video_components = video.get_components()
|
||||||
|
src_frame_rate = int(video_components.frame_rate)
|
||||||
|
duration_sec = video.get_duration()
|
||||||
|
estimated_frames = int(duration_sec * src_frame_rate)
|
||||||
|
validate_container_format_is_mp4(video)
|
||||||
|
src_video_stream = video.get_stream_source()
|
||||||
|
target_width = src_width
|
||||||
|
target_height = src_height
|
||||||
|
target_frame_rate = src_frame_rate
|
||||||
|
filters = []
|
||||||
|
if upscaler_enabled:
|
||||||
|
target_width = UPSCALER_VALUES_MAP[upscaler_resolution]
|
||||||
|
target_height = UPSCALER_VALUES_MAP[upscaler_resolution]
|
||||||
|
filters.append(
|
||||||
|
topaz_api.VideoEnhancementFilter(
|
||||||
|
model=UPSCALER_MODELS_MAP[upscaler_model],
|
||||||
|
creativity=(upscaler_creativity if UPSCALER_MODELS_MAP[upscaler_model] == "slc-1" else None),
|
||||||
|
isOptimizedMode=(True if UPSCALER_MODELS_MAP[upscaler_model] == "slc-1" else None),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if interpolation_enabled:
|
||||||
|
target_frame_rate = interpolation_frame_rate
|
||||||
|
filters.append(
|
||||||
|
topaz_api.VideoFrameInterpolationFilter(
|
||||||
|
model=interpolation_model,
|
||||||
|
slowmo=interpolation_slowmo,
|
||||||
|
fps=interpolation_frame_rate,
|
||||||
|
duplicate=interpolation_duplicate,
|
||||||
|
duplicate_threshold=interpolation_duplicate_threshold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
initial_res = await sync_op(
|
||||||
|
cls,
|
||||||
|
ApiEndpoint(path="/proxy/topaz/video/", method="POST"),
|
||||||
|
response_model=topaz_api.CreateVideoResponse,
|
||||||
|
data=topaz_api.CreateVideoRequest(
|
||||||
|
source=topaz_api.CreateCreateVideoRequestSource(
|
||||||
|
container="mp4",
|
||||||
|
size=get_fs_object_size(src_video_stream),
|
||||||
|
duration=int(duration_sec),
|
||||||
|
frameCount=estimated_frames,
|
||||||
|
frameRate=src_frame_rate,
|
||||||
|
resolution=topaz_api.Resolution(width=src_width, height=src_height),
|
||||||
|
),
|
||||||
|
filters=filters,
|
||||||
|
output=topaz_api.OutputInformationVideo(
|
||||||
|
resolution=topaz_api.Resolution(width=target_width, height=target_height),
|
||||||
|
frameRate=target_frame_rate,
|
||||||
|
audioCodec="AAC",
|
||||||
|
audioTransfer="Copy",
|
||||||
|
dynamicCompressionLevel=dynamic_compression_level,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
wait_label="Creating task",
|
||||||
|
final_label_on_success="Task created",
|
||||||
|
)
|
||||||
|
upload_res = await sync_op(
|
||||||
|
cls,
|
||||||
|
ApiEndpoint(
|
||||||
|
path=f"/proxy/topaz/video/{initial_res.requestId}/accept",
|
||||||
|
method="PATCH",
|
||||||
|
),
|
||||||
|
response_model=topaz_api.VideoAcceptResponse,
|
||||||
|
wait_label="Preparing upload",
|
||||||
|
final_label_on_success="Upload started",
|
||||||
|
)
|
||||||
|
if len(upload_res.urls) > 1:
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Large files are not currently supported. Please open an issue in the ComfyUI repository."
|
||||||
|
)
|
||||||
|
async with aiohttp.ClientSession(headers={"Content-Type": "video/mp4"}) as session:
|
||||||
|
if isinstance(src_video_stream, BytesIO):
|
||||||
|
src_video_stream.seek(0)
|
||||||
|
async with session.put(upload_res.urls[0], data=src_video_stream, raise_for_status=True) as res:
|
||||||
|
upload_etag = res.headers["Etag"]
|
||||||
|
else:
|
||||||
|
with builtins.open(src_video_stream, "rb") as video_file:
|
||||||
|
async with session.put(upload_res.urls[0], data=video_file, raise_for_status=True) as res:
|
||||||
|
upload_etag = res.headers["Etag"]
|
||||||
|
await sync_op(
|
||||||
|
cls,
|
||||||
|
ApiEndpoint(
|
||||||
|
path=f"/proxy/topaz/video/{initial_res.requestId}/complete-upload",
|
||||||
|
method="PATCH",
|
||||||
|
),
|
||||||
|
response_model=topaz_api.VideoCompleteUploadResponse,
|
||||||
|
data=topaz_api.VideoCompleteUploadRequest(
|
||||||
|
uploadResults=[
|
||||||
|
topaz_api.VideoCompleteUploadRequestPart(
|
||||||
|
partNum=1,
|
||||||
|
eTag=upload_etag,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
wait_label="Finalizing upload",
|
||||||
|
final_label_on_success="Upload completed",
|
||||||
|
)
|
||||||
|
final_response = await poll_op(
|
||||||
|
cls,
|
||||||
|
ApiEndpoint(path=f"/proxy/topaz/video/{initial_res.requestId}/status"),
|
||||||
|
response_model=topaz_api.VideoStatusResponse,
|
||||||
|
status_extractor=lambda x: x.status,
|
||||||
|
progress_extractor=lambda x: getattr(x, "progress", 0),
|
||||||
|
price_extractor=lambda x: (x.estimates.cost[0] * 0.08 if x.estimates and x.estimates.cost[0] else None),
|
||||||
|
poll_interval=10.0,
|
||||||
|
max_poll_attempts=320,
|
||||||
|
)
|
||||||
|
return IO.NodeOutput(await download_url_to_video_output(final_response.download.url))
|
||||||
|
|
||||||
|
|
||||||
|
class TopazExtension(ComfyExtension):
|
||||||
|
@override
|
||||||
|
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||||
|
return [
|
||||||
|
TopazImageEnhance,
|
||||||
|
TopazVideoEnhance,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def comfy_entrypoint() -> TopazExtension:
|
||||||
|
return TopazExtension()
|
||||||
@ -63,6 +63,7 @@ class _RequestConfig:
|
|||||||
estimated_total: Optional[int] = None
|
estimated_total: Optional[int] = None
|
||||||
final_label_on_success: Optional[str] = "Completed"
|
final_label_on_success: Optional[str] = "Completed"
|
||||||
progress_origin_ts: Optional[float] = None
|
progress_origin_ts: Optional[float] = None
|
||||||
|
price_extractor: Optional[Callable[[dict[str, Any]], Optional[float]]] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -77,9 +78,9 @@ class _PollUIState:
|
|||||||
|
|
||||||
|
|
||||||
_RETRY_STATUS = {408, 429, 500, 502, 503, 504}
|
_RETRY_STATUS = {408, 429, 500, 502, 503, 504}
|
||||||
COMPLETED_STATUSES = ["succeeded", "succeed", "success", "completed", "finished", "done"]
|
COMPLETED_STATUSES = ["succeeded", "succeed", "success", "completed", "finished", "done", "complete"]
|
||||||
FAILED_STATUSES = ["cancelled", "canceled", "fail", "failed", "error"]
|
FAILED_STATUSES = ["cancelled", "canceled", "canceling", "fail", "failed", "error"]
|
||||||
QUEUED_STATUSES = ["created", "queued", "queueing", "submitted"]
|
QUEUED_STATUSES = ["created", "queued", "queueing", "submitted", "initializing"]
|
||||||
|
|
||||||
|
|
||||||
async def sync_op(
|
async def sync_op(
|
||||||
@ -87,6 +88,7 @@ async def sync_op(
|
|||||||
endpoint: ApiEndpoint,
|
endpoint: ApiEndpoint,
|
||||||
*,
|
*,
|
||||||
response_model: Type[M],
|
response_model: Type[M],
|
||||||
|
price_extractor: Optional[Callable[[M], Optional[float]]] = None,
|
||||||
data: Optional[BaseModel] = None,
|
data: Optional[BaseModel] = None,
|
||||||
files: Optional[Union[dict[str, Any], list[tuple[str, Any]]]] = None,
|
files: Optional[Union[dict[str, Any], list[tuple[str, Any]]]] = None,
|
||||||
content_type: str = "application/json",
|
content_type: str = "application/json",
|
||||||
@ -104,6 +106,7 @@ async def sync_op(
|
|||||||
raw = await sync_op_raw(
|
raw = await sync_op_raw(
|
||||||
cls,
|
cls,
|
||||||
endpoint,
|
endpoint,
|
||||||
|
price_extractor=_wrap_model_extractor(response_model, price_extractor),
|
||||||
data=data,
|
data=data,
|
||||||
files=files,
|
files=files,
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
@ -175,6 +178,7 @@ async def sync_op_raw(
|
|||||||
cls: type[IO.ComfyNode],
|
cls: type[IO.ComfyNode],
|
||||||
endpoint: ApiEndpoint,
|
endpoint: ApiEndpoint,
|
||||||
*,
|
*,
|
||||||
|
price_extractor: Optional[Callable[[dict[str, Any]], Optional[float]]] = None,
|
||||||
data: Optional[Union[dict[str, Any], BaseModel]] = None,
|
data: Optional[Union[dict[str, Any], BaseModel]] = None,
|
||||||
files: Optional[Union[dict[str, Any], list[tuple[str, Any]]]] = None,
|
files: Optional[Union[dict[str, Any], list[tuple[str, Any]]]] = None,
|
||||||
content_type: str = "application/json",
|
content_type: str = "application/json",
|
||||||
@ -216,6 +220,7 @@ async def sync_op_raw(
|
|||||||
estimated_total=estimated_duration,
|
estimated_total=estimated_duration,
|
||||||
final_label_on_success=final_label_on_success,
|
final_label_on_success=final_label_on_success,
|
||||||
progress_origin_ts=progress_origin_ts,
|
progress_origin_ts=progress_origin_ts,
|
||||||
|
price_extractor=price_extractor,
|
||||||
)
|
)
|
||||||
return await _request_base(cfg, expect_binary=as_binary)
|
return await _request_base(cfg, expect_binary=as_binary)
|
||||||
|
|
||||||
@ -424,7 +429,9 @@ def _display_text(
|
|||||||
if status:
|
if status:
|
||||||
display_lines.append(f"Status: {status.capitalize() if isinstance(status, str) else status}")
|
display_lines.append(f"Status: {status.capitalize() if isinstance(status, str) else status}")
|
||||||
if price is not None:
|
if price is not None:
|
||||||
display_lines.append(f"Price: ${float(price):,.4f}")
|
p = f"{float(price):,.4f}".rstrip("0").rstrip(".")
|
||||||
|
if p != "0":
|
||||||
|
display_lines.append(f"Price: ${p}")
|
||||||
if text is not None:
|
if text is not None:
|
||||||
display_lines.append(text)
|
display_lines.append(text)
|
||||||
if display_lines:
|
if display_lines:
|
||||||
@ -580,6 +587,7 @@ async def _request_base(cfg: _RequestConfig, expect_binary: bool):
|
|||||||
delay = cfg.retry_delay
|
delay = cfg.retry_delay
|
||||||
operation_succeeded: bool = False
|
operation_succeeded: bool = False
|
||||||
final_elapsed_seconds: Optional[int] = None
|
final_elapsed_seconds: Optional[int] = None
|
||||||
|
extracted_price: Optional[float] = None
|
||||||
while True:
|
while True:
|
||||||
attempt += 1
|
attempt += 1
|
||||||
stop_event = asyncio.Event()
|
stop_event = asyncio.Event()
|
||||||
@ -767,6 +775,8 @@ async def _request_base(cfg: _RequestConfig, expect_binary: bool):
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
payload = {"_raw": text}
|
payload = {"_raw": text}
|
||||||
response_content_to_log = payload if isinstance(payload, dict) else text
|
response_content_to_log = payload if isinstance(payload, dict) else text
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
extracted_price = cfg.price_extractor(payload) if cfg.price_extractor else None
|
||||||
operation_succeeded = True
|
operation_succeeded = True
|
||||||
final_elapsed_seconds = int(time.monotonic() - start_time)
|
final_elapsed_seconds = int(time.monotonic() - start_time)
|
||||||
try:
|
try:
|
||||||
@ -871,7 +881,7 @@ async def _request_base(cfg: _RequestConfig, expect_binary: bool):
|
|||||||
else int(time.monotonic() - start_time)
|
else int(time.monotonic() - start_time)
|
||||||
),
|
),
|
||||||
estimated_total=cfg.estimated_total,
|
estimated_total=cfg.estimated_total,
|
||||||
price=None,
|
price=extracted_price,
|
||||||
is_queued=False,
|
is_queued=False,
|
||||||
processing_elapsed_seconds=final_elapsed_seconds,
|
processing_elapsed_seconds=final_elapsed_seconds,
|
||||||
)
|
)
|
||||||
|
|||||||
6
nodes.py
6
nodes.py
@ -1852,6 +1852,11 @@ class ImageBatch:
|
|||||||
CATEGORY = "image"
|
CATEGORY = "image"
|
||||||
|
|
||||||
def batch(self, image1, image2):
|
def batch(self, image1, image2):
|
||||||
|
if image1.shape[-1] != image2.shape[-1]:
|
||||||
|
if image1.shape[-1] > image2.shape[-1]:
|
||||||
|
image2 = torch.nn.functional.pad(image2, (0,1), mode='constant', value=1.0)
|
||||||
|
else:
|
||||||
|
image1 = torch.nn.functional.pad(image1, (0,1), mode='constant', value=1.0)
|
||||||
if image1.shape[1:] != image2.shape[1:]:
|
if image1.shape[1:] != image2.shape[1:]:
|
||||||
image2 = comfy.utils.common_upscale(image2.movedim(-1,1), image1.shape[2], image1.shape[1], "bilinear", "center").movedim(1,-1)
|
image2 = comfy.utils.common_upscale(image2.movedim(-1,1), image1.shape[2], image1.shape[1], "bilinear", "center").movedim(1,-1)
|
||||||
s = torch.cat((image1, image2), dim=0)
|
s = torch.cat((image1, image2), dim=0)
|
||||||
@ -2359,6 +2364,7 @@ async def init_builtin_api_nodes():
|
|||||||
"nodes_pika.py",
|
"nodes_pika.py",
|
||||||
"nodes_runway.py",
|
"nodes_runway.py",
|
||||||
"nodes_sora.py",
|
"nodes_sora.py",
|
||||||
|
"nodes_topaz.py",
|
||||||
"nodes_tripo.py",
|
"nodes_tripo.py",
|
||||||
"nodes_moonvalley.py",
|
"nodes_moonvalley.py",
|
||||||
"nodes_rodin.py",
|
"nodes_rodin.py",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
comfyui-frontend-package==1.28.8
|
comfyui-frontend-package==1.28.9
|
||||||
comfyui-workflow-templates==0.2.11
|
comfyui-workflow-templates==0.3.1
|
||||||
comfyui-embedded-docs==0.3.1
|
comfyui-embedded-docs==0.3.1
|
||||||
torch
|
torch
|
||||||
torchsde
|
torchsde
|
||||||
|
|||||||
32
server.py
32
server.py
@ -30,7 +30,7 @@ import comfy.model_management
|
|||||||
from comfy_api import feature_flags
|
from comfy_api import feature_flags
|
||||||
import node_helpers
|
import node_helpers
|
||||||
from comfyui_version import __version__
|
from comfyui_version import __version__
|
||||||
from app.frontend_management import FrontendManager
|
from app.frontend_management import FrontendManager, parse_version
|
||||||
from comfy_api.internal import _ComfyNodeInternal
|
from comfy_api.internal import _ComfyNodeInternal
|
||||||
|
|
||||||
from app.user_manager import UserManager
|
from app.user_manager import UserManager
|
||||||
@ -849,11 +849,31 @@ class PromptServer():
|
|||||||
for name, dir in nodes.EXTENSION_WEB_DIRS.items():
|
for name, dir in nodes.EXTENSION_WEB_DIRS.items():
|
||||||
self.app.add_routes([web.static('/extensions/' + name, dir)])
|
self.app.add_routes([web.static('/extensions/' + name, dir)])
|
||||||
|
|
||||||
workflow_templates_path = FrontendManager.templates_path()
|
installed_templates_version = FrontendManager.get_installed_templates_version()
|
||||||
if workflow_templates_path:
|
use_legacy_templates = True
|
||||||
self.app.add_routes([
|
if installed_templates_version:
|
||||||
web.static('/templates', workflow_templates_path)
|
try:
|
||||||
])
|
use_legacy_templates = (
|
||||||
|
parse_version(installed_templates_version)
|
||||||
|
< parse_version("0.3.0")
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.warning(
|
||||||
|
"Unable to parse templates version '%s': %s",
|
||||||
|
installed_templates_version,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
if use_legacy_templates:
|
||||||
|
workflow_templates_path = FrontendManager.legacy_templates_path()
|
||||||
|
if workflow_templates_path:
|
||||||
|
self.app.add_routes([
|
||||||
|
web.static('/templates', workflow_templates_path)
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
handler = FrontendManager.template_asset_handler()
|
||||||
|
if handler:
|
||||||
|
self.app.router.add_get("/templates/{path:.*}", handler)
|
||||||
|
|
||||||
# Serve embedded documentation from the package
|
# Serve embedded documentation from the package
|
||||||
embedded_docs_path = FrontendManager.embedded_docs_path()
|
embedded_docs_path = FrontendManager.embedded_docs_path()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user