Compare commits

...

4 Commits

Author SHA1 Message Date
Todd
834c28d3a0
Merge bb31f8b707 into b633244635 2026-04-30 16:33:58 -04:00
Alexander Piskun
b633244635
[Partner Nodes] ByteDance: virtual portrait library for regular images (#13638)
Some checks are pending
Python Linting / Run Ruff (push) Waiting to run
Python Linting / Run Pylint (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Waiting to run
Execution Tests / test (macos-latest) (push) Waiting to run
Execution Tests / test (ubuntu-latest) (push) Waiting to run
Execution Tests / test (windows-latest) (push) Waiting to run
Test server launches without errors / test (push) Waiting to run
Unit Tests / test (macos-latest) (push) Waiting to run
Unit Tests / test (ubuntu-latest) (push) Waiting to run
Unit Tests / test (windows-2022) (push) Waiting to run
Build package / Build Test (3.10) (push) Waiting to run
Build package / Build Test (3.11) (push) Waiting to run
Build package / Build Test (3.12) (push) Waiting to run
Build package / Build Test (3.13) (push) Waiting to run
Build package / Build Test (3.14) (push) Waiting to run
* feat(api-nodes-bytedance): use the virtual portrait library for regular images

Signed-off-by: bigcat88 <bigcat88@icloud.com>

* fix: include shape in image dedup hash

Signed-off-by: bigcat88 <bigcat88@icloud.com>

---------

Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-04-30 11:49:08 -07:00
Alexander Piskun
38ecad8f8a
feat(api-nodes): allow custom resolutions for GPTImage2 node (#13631)
Some checks are pending
Python Linting / Run Ruff (push) Waiting to run
Python Linting / Run Pylint (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Waiting to run
Execution Tests / test (macos-latest) (push) Waiting to run
Execution Tests / test (ubuntu-latest) (push) Waiting to run
Execution Tests / test (windows-latest) (push) Waiting to run
Test server launches without errors / test (push) Waiting to run
Unit Tests / test (macos-latest) (push) Waiting to run
Unit Tests / test (ubuntu-latest) (push) Waiting to run
Unit Tests / test (windows-2022) (push) Waiting to run
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-04-30 01:09:33 -07:00
Tsondo
bb31f8b707 fix: per-device fp8/nvfp4 compute detection for multi-GPU setups
supports_fp8_compute() and supports_nvfp4_compute() used the global
is_nvidia() check which ignores the device argument, then defaulted
to cuda:0 when device was None. In heterogeneous multi-GPU setups
(e.g. RTX 5070 + RTX 3090 Ti) this causes the wrong GPU's compute
capability to be checked, incorrectly disabling fp8 on capable
devices.

Replace the global is_nvidia() gate with per-device checks:
- Default device=None to get_torch_device() explicitly
- Early-return False for CPU/MPS devices
- Use is_device_cuda(device) + torch.version.cuda instead of
  the global is_nvidia()

Fixes #4589, relates to #4577, #12405

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 22:56:42 +01:00
5 changed files with 213 additions and 11 deletions

View File

@ -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)

View File

@ -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,

View File

@ -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}",
),
),

View File

@ -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")

View 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