mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-05-25 08:27:25 +08:00
Compare commits
4 Commits
a45e784876
...
834c28d3a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
834c28d3a0 | ||
|
|
b633244635 | ||
|
|
38ecad8f8a | ||
|
|
bb31f8b707 |
@ -1690,7 +1690,21 @@ def supports_fp8_compute(device=None):
|
||||
if SUPPORT_FP8_OPS:
|
||||
return True
|
||||
|
||||
if not is_nvidia():
|
||||
if device is None:
|
||||
device = get_torch_device()
|
||||
|
||||
if is_device_cpu(device) or is_device_mps(device):
|
||||
return False
|
||||
|
||||
# Per-device check instead of the global is_nvidia(). On ROCm builds,
|
||||
# is_device_cuda() returns True (AMD GPUs appear as cuda:N via HIP) but
|
||||
# torch.version.cuda is None, so this correctly returns False for AMD.
|
||||
# If PyTorch ever supports mixed-vendor GPUs in one process, these
|
||||
# per-device checks remain correct unlike the global is_nvidia().
|
||||
if not is_device_cuda(device):
|
||||
return False
|
||||
|
||||
if not torch.version.cuda:
|
||||
return False
|
||||
|
||||
props = torch.cuda.get_device_properties(device)
|
||||
@ -1711,7 +1725,10 @@ def supports_fp8_compute(device=None):
|
||||
return True
|
||||
|
||||
def supports_nvfp4_compute(device=None):
|
||||
if not is_nvidia():
|
||||
if device is None:
|
||||
device = get_torch_device()
|
||||
|
||||
if not is_device_cuda(device) or not torch.version.cuda:
|
||||
return False
|
||||
|
||||
props = torch.cuda.get_device_properties(device)
|
||||
|
||||
@ -157,6 +157,11 @@ class SeedanceCreateAssetResponse(BaseModel):
|
||||
asset_id: str = Field(...)
|
||||
|
||||
|
||||
class SeedanceVirtualLibraryCreateAssetRequest(BaseModel):
|
||||
url: str = Field(..., description="Publicly accessible URL of the image asset to upload.")
|
||||
hash: str = Field(..., description="Dedup key. Re-submitting the same hash returns the existing asset id.")
|
||||
|
||||
|
||||
# Dollars per 1K tokens, keyed by (model_id, has_video_input).
|
||||
SEEDANCE2_PRICE_PER_1K_TOKENS = {
|
||||
("dreamina-seedance-2-0-260128", False): 0.007,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
@ -20,6 +21,7 @@ from comfy_api_nodes.apis.bytedance import (
|
||||
SeedanceCreateAssetResponse,
|
||||
SeedanceCreateVisualValidateSessionResponse,
|
||||
SeedanceGetVisualValidateSessionResponse,
|
||||
SeedanceVirtualLibraryCreateAssetRequest,
|
||||
Seedream4Options,
|
||||
Seedream4TaskCreationRequest,
|
||||
TaskAudioContent,
|
||||
@ -271,6 +273,30 @@ async def _wait_for_asset_active(cls: type[IO.ComfyNode], asset_id: str, group_i
|
||||
)
|
||||
|
||||
|
||||
async def _seedance_virtual_library_upload_image_asset(
|
||||
cls: type[IO.ComfyNode],
|
||||
image: torch.Tensor,
|
||||
*,
|
||||
wait_label: str = "Uploading image",
|
||||
) -> str:
|
||||
"""Upload an image into the caller's per-customer Seedance virtual library."""
|
||||
public_url = await upload_image_to_comfyapi(cls, image, wait_label=wait_label)
|
||||
normalized = image.detach().cpu().contiguous().to(torch.float32)
|
||||
digest = hashlib.sha256()
|
||||
digest.update(str(tuple(normalized.shape)).encode("utf-8"))
|
||||
digest.update(b"\0")
|
||||
digest.update(normalized.numpy().tobytes())
|
||||
image_hash = digest.hexdigest()
|
||||
create_resp = await sync_op(
|
||||
cls,
|
||||
ApiEndpoint(path="/proxy/seedance/virtual-library/assets", method="POST"),
|
||||
response_model=SeedanceCreateAssetResponse,
|
||||
data=SeedanceVirtualLibraryCreateAssetRequest(url=public_url, hash=image_hash),
|
||||
)
|
||||
await _wait_for_asset_active(cls, create_resp.asset_id, group_id="virtual-library")
|
||||
return f"asset://{create_resp.asset_id}"
|
||||
|
||||
|
||||
def _seedance2_price_extractor(model_id: str, has_video_input: bool):
|
||||
"""Returns a price_extractor closure for Seedance 2.0 poll_op."""
|
||||
rate = SEEDANCE2_PRICE_PER_1K_TOKENS.get((model_id, has_video_input))
|
||||
@ -1507,7 +1533,9 @@ class ByteDance2FirstLastFrameNode(IO.ComfyNode):
|
||||
if first_frame_asset_id:
|
||||
first_frame_url = image_assets[first_frame_asset_id]
|
||||
else:
|
||||
first_frame_url = await upload_image_to_comfyapi(cls, first_frame, wait_label="Uploading first frame.")
|
||||
first_frame_url = await _seedance_virtual_library_upload_image_asset(
|
||||
cls, first_frame, wait_label="Uploading first frame."
|
||||
)
|
||||
|
||||
content: list[TaskTextContent | TaskImageContent] = [
|
||||
TaskTextContent(text=model["prompt"]),
|
||||
@ -1527,7 +1555,9 @@ class ByteDance2FirstLastFrameNode(IO.ComfyNode):
|
||||
content.append(
|
||||
TaskImageContent(
|
||||
image_url=TaskImageContentUrl(
|
||||
url=await upload_image_to_comfyapi(cls, last_frame, wait_label="Uploading last frame.")
|
||||
url=await _seedance_virtual_library_upload_image_asset(
|
||||
cls, last_frame, wait_label="Uploading last frame."
|
||||
)
|
||||
),
|
||||
role="last_frame",
|
||||
),
|
||||
@ -1805,9 +1835,9 @@ class ByteDance2ReferenceNode(IO.ComfyNode):
|
||||
content.append(
|
||||
TaskImageContent(
|
||||
image_url=TaskImageContentUrl(
|
||||
url=await upload_image_to_comfyapi(
|
||||
url=await _seedance_virtual_library_upload_image_asset(
|
||||
cls,
|
||||
image=reference_images[key],
|
||||
reference_images[key],
|
||||
wait_label=f"Uploading image {i}",
|
||||
),
|
||||
),
|
||||
|
||||
@ -415,8 +415,9 @@ class OpenAIGPTImage1(IO.ComfyNode):
|
||||
"1152x2048",
|
||||
"3840x2160",
|
||||
"2160x3840",
|
||||
"Custom",
|
||||
],
|
||||
tooltip="Image size",
|
||||
tooltip="Image size. Select 'Custom' to use the custom width and height (GPT Image 2 only).",
|
||||
optional=True,
|
||||
),
|
||||
IO.Int.Input(
|
||||
@ -445,6 +446,26 @@ class OpenAIGPTImage1(IO.ComfyNode):
|
||||
default="gpt-image-2",
|
||||
optional=True,
|
||||
),
|
||||
IO.Int.Input(
|
||||
"custom_width",
|
||||
default=1024,
|
||||
min=1024,
|
||||
max=3840,
|
||||
step=16,
|
||||
tooltip="Used only when `size` is 'Custom'. Must be a multiple of 16 (GPT Image 2 only).",
|
||||
optional=True,
|
||||
advanced=True,
|
||||
),
|
||||
IO.Int.Input(
|
||||
"custom_height",
|
||||
default=1024,
|
||||
min=1024,
|
||||
max=3840,
|
||||
step=16,
|
||||
tooltip="Used only when `size` is 'Custom'. Must be a multiple of 16 (GPT Image 2 only).",
|
||||
optional=True,
|
||||
advanced=True,
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
IO.Image.Output(),
|
||||
@ -471,9 +492,9 @@ class OpenAIGPTImage1(IO.ComfyNode):
|
||||
"high": [0.133, 0.22]
|
||||
},
|
||||
"gpt-image-2": {
|
||||
"low": [0.0048, 0.012],
|
||||
"medium": [0.041, 0.112],
|
||||
"high": [0.165, 0.43]
|
||||
"low": [0.0048, 0.019],
|
||||
"medium": [0.041, 0.168],
|
||||
"high": [0.165, 0.67]
|
||||
}
|
||||
};
|
||||
$range := $lookup($lookup($ranges, widgets.model), widgets.quality);
|
||||
@ -503,6 +524,8 @@ class OpenAIGPTImage1(IO.ComfyNode):
|
||||
mask: Input.Image | None = None,
|
||||
n: int = 1,
|
||||
size: str = "1024x1024",
|
||||
custom_width: int = 1024,
|
||||
custom_height: int = 1024,
|
||||
model: str = "gpt-image-1",
|
||||
) -> IO.NodeOutput:
|
||||
validate_string(prompt, strip_whitespace=False)
|
||||
@ -510,7 +533,25 @@ class OpenAIGPTImage1(IO.ComfyNode):
|
||||
if mask is not None and image is None:
|
||||
raise ValueError("Cannot use a mask without an input image")
|
||||
|
||||
if model in ("gpt-image-1", "gpt-image-1.5"):
|
||||
if size == "Custom":
|
||||
if model != "gpt-image-2":
|
||||
raise ValueError("Custom resolution is only supported by GPT Image 2 model")
|
||||
if custom_width % 16 != 0 or custom_height % 16 != 0:
|
||||
raise ValueError(f"Custom width and height must be multiples of 16, got {custom_width}x{custom_height}")
|
||||
if max(custom_width, custom_height) > 3840:
|
||||
raise ValueError(f"Custom resolution max edge must be <= 3840, got {custom_width}x{custom_height}")
|
||||
ratio = max(custom_width, custom_height) / min(custom_width, custom_height)
|
||||
if ratio > 3:
|
||||
raise ValueError(
|
||||
f"Custom resolution aspect ratio must not exceed 3:1, got {custom_width}x{custom_height}"
|
||||
)
|
||||
total_pixels = custom_width * custom_height
|
||||
if not 655_360 <= total_pixels <= 8_294_400:
|
||||
raise ValueError(
|
||||
f"Custom resolution total pixels must be between 655,360 and 8,294,400, got {total_pixels}"
|
||||
)
|
||||
size = f"{custom_width}x{custom_height}"
|
||||
elif model in ("gpt-image-1", "gpt-image-1.5"):
|
||||
if size not in ("auto", "1024x1024", "1024x1536", "1536x1024"):
|
||||
raise ValueError(f"Resolution {size} is only supported by GPT Image 2 model")
|
||||
|
||||
|
||||
109
tests-unit/comfy_test/model_management_test.py
Normal file
109
tests-unit/comfy_test/model_management_test.py
Normal file
@ -0,0 +1,109 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import torch
|
||||
|
||||
import comfy.model_management as mm
|
||||
|
||||
|
||||
class FakeDeviceProps:
|
||||
"""Minimal stand-in for torch.cuda.get_device_properties return value."""
|
||||
def __init__(self, major, minor, name="FakeGPU"):
|
||||
self.major = major
|
||||
self.minor = minor
|
||||
self.name = name
|
||||
|
||||
|
||||
class TestSupportsFp8Compute:
|
||||
"""Tests for per-device fp8 compute capability detection."""
|
||||
|
||||
def test_cpu_device_returns_false(self):
|
||||
assert mm.supports_fp8_compute(torch.device("cpu")) is False
|
||||
|
||||
@pytest.mark.skipif(not hasattr(torch.backends, "mps"), reason="MPS backend not available")
|
||||
def test_mps_device_returns_false(self):
|
||||
assert mm.supports_fp8_compute(torch.device("mps")) is False
|
||||
|
||||
@patch("comfy.model_management.SUPPORT_FP8_OPS", True)
|
||||
def test_cli_override_returns_true(self):
|
||||
assert mm.supports_fp8_compute(torch.device("cpu")) is True
|
||||
|
||||
@patch("comfy.model_management.get_torch_device", return_value=torch.device("cpu"))
|
||||
def test_none_device_defaults_to_get_torch_device(self, mock_get):
|
||||
result = mm.supports_fp8_compute(None)
|
||||
mock_get.assert_called_once()
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available")
|
||||
def test_each_cuda_device_checked_independently(self):
|
||||
"""On a multi-GPU system, each device should be queried for its own capabilities."""
|
||||
count = torch.cuda.device_count()
|
||||
if count < 2:
|
||||
pytest.skip("Need 2+ CUDA devices for multi-GPU test")
|
||||
results = {}
|
||||
for i in range(count):
|
||||
dev = torch.device(f"cuda:{i}")
|
||||
results[i] = mm.supports_fp8_compute(dev)
|
||||
props = torch.cuda.get_device_properties(dev)
|
||||
# Verify the result is consistent with the device's compute capability
|
||||
if props.major >= 9:
|
||||
assert results[i] is True, f"cuda:{i} ({props.name}) has SM {props.major}.{props.minor}, should support fp8"
|
||||
elif props.major < 8 or props.minor < 9:
|
||||
assert results[i] is False, f"cuda:{i} ({props.name}) has SM {props.major}.{props.minor}, should not support fp8"
|
||||
|
||||
@patch("torch.version.cuda", None)
|
||||
@patch("comfy.model_management.SUPPORT_FP8_OPS", False)
|
||||
def test_rocm_build_returns_false(self):
|
||||
"""On ROCm, devices appear as cuda:N via HIP but torch.version.cuda is None."""
|
||||
dev = MagicMock()
|
||||
dev.type = "cuda"
|
||||
assert mm.supports_fp8_compute(dev) is False
|
||||
|
||||
@patch("torch.version.cuda", "12.4")
|
||||
@patch("comfy.model_management.SUPPORT_FP8_OPS", False)
|
||||
@patch("torch.cuda.get_device_properties")
|
||||
def test_sm89_supports_fp8(self, mock_props):
|
||||
"""Ada Lovelace (SM 8.9, e.g. RTX 4080) should support fp8."""
|
||||
mock_props.return_value = FakeDeviceProps(major=8, minor=9)
|
||||
dev = torch.device("cuda:0")
|
||||
assert mm.supports_fp8_compute(dev) is True
|
||||
|
||||
@patch("torch.version.cuda", "12.4")
|
||||
@patch("comfy.model_management.SUPPORT_FP8_OPS", False)
|
||||
@patch("torch.cuda.get_device_properties")
|
||||
def test_sm86_does_not_support_fp8(self, mock_props):
|
||||
"""Ampere (SM 8.6, e.g. RTX 3090) should not support fp8."""
|
||||
mock_props.return_value = FakeDeviceProps(major=8, minor=6)
|
||||
dev = torch.device("cuda:0")
|
||||
assert mm.supports_fp8_compute(dev) is False
|
||||
|
||||
@patch("torch.version.cuda", "12.4")
|
||||
@patch("comfy.model_management.SUPPORT_FP8_OPS", False)
|
||||
@patch("torch.cuda.get_device_properties")
|
||||
def test_sm90_supports_fp8(self, mock_props):
|
||||
"""Hopper (SM 9.0) and above should support fp8."""
|
||||
mock_props.return_value = FakeDeviceProps(major=9, minor=0)
|
||||
dev = torch.device("cuda:0")
|
||||
assert mm.supports_fp8_compute(dev) is True
|
||||
|
||||
|
||||
class TestSupportsNvfp4Compute:
|
||||
"""Tests for per-device nvfp4 compute capability detection."""
|
||||
|
||||
def test_cpu_device_returns_false(self):
|
||||
assert mm.supports_nvfp4_compute(torch.device("cpu")) is False
|
||||
|
||||
@patch("torch.version.cuda", "12.4")
|
||||
@patch("torch.cuda.get_device_properties")
|
||||
def test_sm100_supports_nvfp4(self, mock_props):
|
||||
"""Blackwell (SM 10.0) should support nvfp4."""
|
||||
mock_props.return_value = FakeDeviceProps(major=10, minor=0)
|
||||
dev = torch.device("cuda:0")
|
||||
assert mm.supports_nvfp4_compute(dev) is True
|
||||
|
||||
@patch("torch.version.cuda", "12.4")
|
||||
@patch("torch.cuda.get_device_properties")
|
||||
def test_sm89_does_not_support_nvfp4(self, mock_props):
|
||||
"""Ada Lovelace (SM 8.9) should not support nvfp4."""
|
||||
mock_props.return_value = FakeDeviceProps(major=8, minor=9)
|
||||
dev = torch.device("cuda:0")
|
||||
assert mm.supports_nvfp4_compute(dev) is False
|
||||
Loading…
Reference in New Issue
Block a user