From e535e59e133b0921b2e396678d1fd060322e594b Mon Sep 17 00:00:00 2001 From: Alexander Piskun <13381981+bigcat88@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:18:12 +0300 Subject: [PATCH] [Partner Nodes] feat(Bria): add GenFill, Eraser, Expand and Increase Resolution nodes (#15572) Signed-off-by: Alexander Piskun --- comfy_api_nodes/apis/bria.py | 95 ++++++ comfy_api_nodes/nodes_bria.py | 524 ++++++++++++++++++++++++++++++++++ 2 files changed, 619 insertions(+) diff --git a/comfy_api_nodes/apis/bria.py b/comfy_api_nodes/apis/bria.py index 7a98428c3..f55de74bc 100644 --- a/comfy_api_nodes/apis/bria.py +++ b/comfy_api_nodes/apis/bria.py @@ -57,6 +57,81 @@ class BriaRemoveBackgroundRequest(BaseModel): seed: int = Field(...) +class BriaGenFillRequest(BaseModel): + image: str = Field(...) + mask: str = Field( + ..., + description="Binary mask defining the region to fill: white (255) pixels are generated, " + "black (0) pixels are preserved. Must have the same aspect ratio as the image.", + ) + prompt: str = Field(...) + negative_prompt: str | None = Field(None) + refine_prompt: bool = Field(True) + seed: int = Field(...) + prompt_content_moderation: bool = Field(False, description="If true, returns 422 on prompt moderation failure.") + visual_input_content_moderation: bool = Field( + False, description="If true, returns 422 on image or mask moderation failure." + ) + visual_output_content_moderation: bool = Field( + False, description="If true, returns 422 on visual output moderation failure." + ) + + +class BriaEraseRequest(BaseModel): + image: str = Field(...) + mask: str = Field( + ..., + description="Binary mask defining the region to erase: white (255) pixels are removed, " + "black (0) pixels are preserved. Must have the same aspect ratio as the image.", + ) + mask_type: str = Field("manual", description="'manual' for hand-drawn masks, 'automatic' for segmentation masks.") + visual_input_content_moderation: bool = Field( + False, description="If true, returns 422 on image or mask moderation failure." + ) + visual_output_content_moderation: bool = Field( + False, description="If true, returns 422 on visual output moderation failure." + ) + + +class BriaExpandRequest(BaseModel): + image: str = Field(...) + aspect_ratio: str | float | None = Field( + None, + description="Target ratio: a preset string (1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, 9:16, 16:9) " + "or a float between 0.5 and 3.0. When set, the canvas/placement fields are ignored.", + ) + canvas_size: list[int] | None = Field(None, description="Output canvas [width, height]; area up to 5000x5000.") + original_image_size: list[int] | None = Field( + None, description="Size [width, height] of the original image inside the canvas." + ) + original_image_location: list[int] | None = Field( + None, + description="Top-left corner [x, y] of the original image inside the canvas; " + "values may fall outside the canvas, cropping the image.", + ) + prompt: str | None = Field(None, description="If omitted, Bria auto-generates a prompt from the image.") + negative_prompt: str | None = Field(None) + seed: int = Field(...) + prompt_content_moderation: bool = Field(False, description="If true, returns 422 on prompt moderation failure.") + visual_input_content_moderation: bool = Field( + False, description="If true, returns 422 on image moderation failure." + ) + visual_output_content_moderation: bool = Field( + False, description="If true, returns 422 on visual output moderation failure." + ) + + +class BriaIncreaseResolutionRequest(BaseModel): + image: str = Field(...) + desired_increase: int = Field(..., description="Resolution multiplier, 2 or 4.") + visual_input_content_moderation: bool = Field( + False, description="If true, returns 422 on image moderation failure." + ) + visual_output_content_moderation: bool = Field( + False, description="If true, returns 422 on visual output moderation failure." + ) + + class BriaStatusResponse(BaseModel): request_id: str = Field(...) status_url: str = Field(...) @@ -72,6 +147,26 @@ class BriaRemoveBackgroundResponse(BaseModel): result: BriaRemoveBackgroundResult | None = Field(None) +class BriaImageResult(BaseModel): + image_url: str = Field(...) + + +class BriaImageResultResponse(BaseModel): + status: str = Field(...) + result: BriaImageResult | None = Field(None) + + +class BriaExpandResult(BaseModel): + image_url: str = Field(...) + prompt: str | None = Field(None) + seed: int | None = Field(None) + + +class BriaExpandResponse(BaseModel): + status: str = Field(...) + result: BriaExpandResult | None = Field(None) + + class BriaImageEditResult(BaseModel): structured_prompt: str = Field(...) image_url: str = Field(...) diff --git a/comfy_api_nodes/nodes_bria.py b/comfy_api_nodes/nodes_bria.py index 77f780a3b..90cade2d0 100644 --- a/comfy_api_nodes/nodes_bria.py +++ b/comfy_api_nodes/nodes_bria.py @@ -6,7 +6,13 @@ from typing_extensions import override from comfy_api.latest import IO, ComfyExtension, Input from comfy_api_nodes.apis.bria import ( BriaEditImageRequest, + BriaEraseRequest, + BriaExpandRequest, + BriaExpandResponse, + BriaGenFillRequest, BriaImageEditResponse, + BriaImageResultResponse, + BriaIncreaseResolutionRequest, BriaRemoveBackgroundRequest, BriaRemoveBackgroundResponse, BriaRemoveVideoBackgroundRequest, @@ -21,13 +27,30 @@ from comfy_api_nodes.util import ( convert_mask_to_image, download_url_to_image_tensor, download_url_to_video_output, + downscale_image_tensor_by_max_side, + get_image_dimensions, poll_op, sync_op, upload_image_to_comfyapi, upload_video_to_comfyapi, + validate_string, validate_video_duration, ) +BRIA_MAX_OUTPUT_SIDE = 8192 +BRIA_MIN_RATIO = 0.5 +BRIA_MAX_RATIO = 3.0 +BRIA_MIN_SHORT_SIDE = 224 + + +def _upscaled_output_side(height: int, width: int, multiplier: int) -> int: + prescale = max(1.0, BRIA_MIN_SHORT_SIDE / min(height, width)) + return round(max(height, width) * prescale * multiplier) + + +def _smallest_output_side(height: int, width: int, multiplier: int) -> int: + return round(max(height, width) / min(height, width) * BRIA_MIN_SHORT_SIDE * multiplier) + class BriaImageEditNode(IO.ComfyNode): @@ -243,6 +266,503 @@ class BriaRemoveImageBackground(IO.ComfyNode): return IO.NodeOutput(await download_url_to_image_tensor(response.result.image_url)) +def _mask_to_binary_image(mask: Input.Image, action: str) -> torch.Tensor: + binary = (mask > 0.5).float() + if not binary.any(): + raise ValueError( + f"The mask is empty, so there is nothing to {action}. Masks are binarized at 50%: " + f"areas painted at less than half opacity are ignored." + ) + return convert_mask_to_image(binary) + + +def _validate_mask_aspect_ratio(image: Input.Image, mask: Input.Image) -> None: + ih, iw = image.shape[1], image.shape[2] + mh, mw = mask.shape[-2], mask.shape[-1] + if abs(iw * mh - ih * mw) > 0.01 * ih * mw: + raise ValueError(f"Mask must have the same aspect ratio as the image: image is {iw}x{ih}, mask is {mw}x{mh}.") + + +class BriaGenFill(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="BriaGenFill", + display_name="Bria Generative Fill", + category="partner/image/Bria", + description="Generate objects or scenery inside a masked region of an image using Bria.", + inputs=[ + IO.Image.Input("image"), + IO.Mask.Input( + "mask", + tooltip="White areas are filled with generated content, black areas are preserved. " + "The mask is binarized before sending, so partially painted areas count as white. " + "Must have the same aspect ratio as the image.", + ), + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="Description of what to generate inside the masked region.", + ), + IO.String.Input("negative_prompt", multiline=True, default=""), + IO.Boolean.Input( + "refine_prompt", + default=True, + tooltip="Automatically adjust the prompt for better results; " + "disable to use the prompt exactly as written.", + ), + IO.Int.Input( + "seed", + default=42, + min=1, + max=2147483647, + step=1, + display_mode=IO.NumberDisplay.number, + control_after_generate=True, + ), + IO.DynamicCombo.Input( + "moderation", + options=[ + IO.DynamicCombo.Option("false", []), + IO.DynamicCombo.Option( + "true", + [ + IO.Boolean.Input("prompt_content_moderation", default=False), + IO.Boolean.Input("visual_input_moderation", default=False), + IO.Boolean.Input("visual_output_moderation", default=False), + ], + ), + ], + tooltip="Moderation settings", + ), + ], + 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( + expr="""{"type":"usd","usd":0.0429}""", + ), + ) + + @classmethod + async def execute( + cls, + image: Input.Image, + mask: Input.Image, + prompt: str, + negative_prompt: str, + refine_prompt: bool, + seed: int, + moderation: InputModerationSettings, + ) -> IO.NodeOutput: + validate_string(prompt, min_length=1) + _validate_mask_aspect_ratio(image, mask) + mask_image = _mask_to_binary_image(mask, "fill") + response = await sync_op( + cls, + ApiEndpoint(path="/proxy/bria/v2/image/edit/gen_fill", method="POST"), + data=BriaGenFillRequest( + image=await upload_image_to_comfyapi(cls, image, total_pixels=None, wait_label="Uploading image"), + mask=await upload_image_to_comfyapi( + cls, mask_image, total_pixels=None, wait_label="Uploading mask" + ), + prompt=prompt, + negative_prompt=negative_prompt if negative_prompt else None, + refine_prompt=refine_prompt, + seed=seed, + prompt_content_moderation=moderation.get("prompt_content_moderation", False), + visual_input_content_moderation=moderation.get("visual_input_moderation", False), + visual_output_content_moderation=moderation.get("visual_output_moderation", False), + ), + response_model=BriaStatusResponse, + ) + response = await poll_op( + cls, + ApiEndpoint(path=f"/proxy/bria/v2/status/{response.request_id}"), + status_extractor=lambda r: r.status, + response_model=BriaImageResultResponse, + ) + return IO.NodeOutput(await download_url_to_image_tensor(response.result.image_url)) + + +class BriaEraser(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="BriaEraser", + display_name="Bria Eraser", + category="partner/image/Bria", + description="Remove objects or areas outlined by a mask from an image using Bria.", + inputs=[ + IO.Image.Input("image"), + IO.Mask.Input( + "mask", + tooltip="White areas are erased, black areas are preserved. " + "The mask is binarized before sending, so partially painted areas count as white. " + "Must have the same aspect ratio as the image.", + ), + IO.Combo.Input( + "mask_type", + options=["manual", "automatic"], + tooltip="manual for hand-drawn or brush masks, " + "automatic for masks produced by segmentation models such as SAM.", + ), + IO.DynamicCombo.Input( + "moderation", + options=[ + IO.DynamicCombo.Option("false", []), + IO.DynamicCombo.Option( + "true", + [ + IO.Boolean.Input("visual_input_moderation", default=False), + IO.Boolean.Input("visual_output_moderation", default=False), + ], + ), + ], + tooltip="Moderation settings", + ), + ], + 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( + expr="""{"type":"usd","usd":0.0286}""", + ), + ) + + @classmethod + async def execute( + cls, + image: Input.Image, + mask: Input.Image, + mask_type: str, + moderation: dict, + ) -> IO.NodeOutput: + _validate_mask_aspect_ratio(image, mask) + mask_image = _mask_to_binary_image(mask, "erase") + response = await sync_op( + cls, + ApiEndpoint(path="/proxy/bria/v2/image/edit/erase", method="POST"), + data=BriaEraseRequest( + image=await upload_image_to_comfyapi(cls, image, total_pixels=None, wait_label="Uploading image"), + mask=await upload_image_to_comfyapi( + cls, mask_image, total_pixels=None, wait_label="Uploading mask" + ), + mask_type=mask_type, + visual_input_content_moderation=moderation.get("visual_input_moderation", False), + visual_output_content_moderation=moderation.get("visual_output_moderation", False), + ), + response_model=BriaStatusResponse, + ) + response = await poll_op( + cls, + ApiEndpoint(path=f"/proxy/bria/v2/status/{response.request_id}"), + status_extractor=lambda r: r.status, + response_model=BriaImageResultResponse, + ) + return IO.NodeOutput(await download_url_to_image_tensor(response.result.image_url)) + + +class BriaExpandImage(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="BriaExpandImage", + display_name="Bria Expand Image", + category="partner/image/Bria", + description="Expand an image beyond its borders with generated content using Bria.", + inputs=[ + IO.Image.Input("image"), + IO.DynamicCombo.Input( + "expand_mode", + options=[ + *[IO.DynamicCombo.Option(ratio, []) for ratio in + ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9"]], + IO.DynamicCombo.Option( + "custom_ratio", + [ + IO.Int.Input( + "ratio_width", + default=21, + min=1, + max=100, + tooltip="Width side of the target ratio: 21 and 9 give 21:9.", + ), + IO.Int.Input( + "ratio_height", + default=9, + min=1, + max=100, + tooltip="Height side of the target ratio: 21 and 9 give 21:9. " + f"Bria only accepts width/height between {BRIA_MIN_RATIO} and " + f"{BRIA_MAX_RATIO}, so anything taller than 1:2 needs the manual mode.", + ), + ], + ), + IO.DynamicCombo.Option( + "manual", + [ + IO.Int.Input("canvas_width", default=1000, min=64, max=5000), + IO.Int.Input("canvas_height", default=1000, min=64, max=5000), + IO.Int.Input( + "image_width", + default=500, + min=1, + max=5000, + tooltip="Width of the original image inside the canvas.", + ), + IO.Int.Input( + "image_height", + default=500, + min=1, + max=5000, + tooltip="Height of the original image inside the canvas.", + ), + IO.Int.Input( + "image_x", + default=250, + min=-5000, + max=5000, + tooltip="X position of the image's top-left corner inside the canvas; " + "may fall outside the canvas, cropping the image.", + ), + IO.Int.Input( + "image_y", + default=250, + min=-5000, + max=5000, + tooltip="Y position of the image's top-left corner inside the canvas; " + "may fall outside the canvas, cropping the image.", + ), + ], + ), + ], + tooltip="Target shape of the expanded image: a preset aspect ratio, a custom ratio, " + "or manual placement of the original image on a canvas. " + "Manual is the only mode that can reach a canvas taller than 1:2.", + ), + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="Optional description of the expanded scene; " + "when empty, Bria generates one from the image.", + ), + IO.String.Input("negative_prompt", multiline=True, default=""), + IO.Int.Input( + "seed", + default=42, + min=1, + max=2147483647, + step=1, + display_mode=IO.NumberDisplay.number, + control_after_generate=True, + ), + IO.DynamicCombo.Input( + "moderation", + options=[ + IO.DynamicCombo.Option("false", []), + IO.DynamicCombo.Option( + "true", + [ + IO.Boolean.Input("prompt_content_moderation", default=False), + IO.Boolean.Input("visual_input_moderation", default=False), + IO.Boolean.Input("visual_output_moderation", default=False), + ], + ), + ], + tooltip="Moderation settings", + ), + ], + outputs=[ + IO.Image.Output(), + IO.String.Output(display_name="prompt", tooltip="The prompt used for the expansion; " + "auto-generated by Bria when the prompt input is empty."), + ], + 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( + expr="""{"type":"usd","usd":0.0286}""", + ), + ) + + @classmethod + async def execute( + cls, + image: Input.Image, + expand_mode: dict, + prompt: str, + negative_prompt: str, + seed: int, + moderation: InputModerationSettings, + ) -> IO.NodeOutput: + mode = expand_mode["expand_mode"] + aspect_ratio = canvas_size = original_image_size = original_image_location = None + if mode == "manual": + canvas_size = [expand_mode["canvas_width"], expand_mode["canvas_height"]] + original_image_size = [expand_mode["image_width"], expand_mode["image_height"]] + original_image_location = [expand_mode["image_x"], expand_mode["image_y"]] + elif mode == "custom_ratio": + ratio_width, ratio_height = expand_mode["ratio_width"], expand_mode["ratio_height"] + aspect_ratio = ratio_width / ratio_height + if not BRIA_MIN_RATIO <= aspect_ratio <= BRIA_MAX_RATIO: + raise ValueError( + f"Bria accepts a width-to-height ratio between {BRIA_MIN_RATIO} and {BRIA_MAX_RATIO}: " + f"{ratio_width}:{ratio_height} is {aspect_ratio:.4f}. " + f"Use the manual expand mode to reach a canvas of any shape." + ) + else: + aspect_ratio = mode + response = await sync_op( + cls, + ApiEndpoint(path="/proxy/bria/v2/image/edit/expand", method="POST"), + data=BriaExpandRequest( + image=await upload_image_to_comfyapi(cls, image, total_pixels=None, wait_label="Uploading image"), + aspect_ratio=aspect_ratio, + canvas_size=canvas_size, + original_image_size=original_image_size, + original_image_location=original_image_location, + prompt=prompt if prompt else None, + negative_prompt=negative_prompt if negative_prompt else None, + seed=seed, + prompt_content_moderation=moderation.get("prompt_content_moderation", False), + visual_input_content_moderation=moderation.get("visual_input_moderation", False), + visual_output_content_moderation=moderation.get("visual_output_moderation", False), + ), + response_model=BriaStatusResponse, + ) + response = await poll_op( + cls, + ApiEndpoint(path=f"/proxy/bria/v2/status/{response.request_id}"), + status_extractor=lambda r: r.status, + response_model=BriaExpandResponse, + ) + return IO.NodeOutput( + await download_url_to_image_tensor(response.result.image_url), + response.result.prompt or "", + ) + + +class BriaIncreaseResolution(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="BriaIncreaseResolution", + display_name="Bria Increase Resolution", + category="partner/image/Bria", + description="Upscale an image by 2x or 4x using Bria, preserving the original content.", + inputs=[ + IO.Image.Input("image"), + IO.Combo.Input( + "desired_increase", + options=["2", "4"], + tooltip="Resolution multiplier. The output must fit within 8192 pixels on each side.", + ), + IO.Boolean.Input( + "auto_downscale", + default=False, + tooltip="Automatically lower the multiplier, and downscale the input image if that is " + "still not enough, when the output would exceed the limit.", + ), + IO.DynamicCombo.Input( + "moderation", + options=[ + IO.DynamicCombo.Option("false", []), + IO.DynamicCombo.Option( + "true", + [ + IO.Boolean.Input("visual_input_moderation", default=False), + IO.Boolean.Input("visual_output_moderation", default=False), + ], + ), + ], + tooltip="Moderation settings", + ), + ], + 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( + expr="""{"type":"usd","usd":0.0286}""", + ), + ) + + @classmethod + async def execute( + cls, + image: Input.Image, + desired_increase: str, + auto_downscale: bool, + moderation: dict, + ) -> IO.NodeOutput: + multiplier = int(desired_increase) + height, width = get_image_dimensions(image) + if _upscaled_output_side(height, width, multiplier) > BRIA_MAX_OUTPUT_SIDE: + candidates = [c for c in (4, 2) if c <= multiplier] + if not auto_downscale: + predicted = _upscaled_output_side(height, width, multiplier) + raise ValueError( + f"Bria can upscale up to a maximum output dimension of {BRIA_MAX_OUTPUT_SIDE} pixels: " + f"input is {width}x{height}, x{multiplier} would be {predicted} pixels on the long side. " + f"Enable auto_downscale, or use a smaller input image or a lower multiplier." + ) + fitted = next( + (c for c in candidates if _upscaled_output_side(height, width, c) <= BRIA_MAX_OUTPUT_SIDE), None + ) + if fitted is not None: + multiplier = fitted + else: + shrinkable = next((c for c in sorted(candidates) if _smallest_output_side(height, width, c) + <= BRIA_MAX_OUTPUT_SIDE), None) + if shrinkable is None: + raise ValueError( + f"This image cannot be upscaled by Bria at any multiplier: it is {width}x{height}, and " + f"Bria first enlarges the short side to {BRIA_MIN_SHORT_SIDE} pixels, which pushes the " + f"long side past the {BRIA_MAX_OUTPUT_SIDE} pixel limit. Crop it to a squarer shape first." + ) + multiplier = shrinkable + image = downscale_image_tensor_by_max_side(image, max_side=BRIA_MAX_OUTPUT_SIDE // multiplier) + response = await sync_op( + cls, + ApiEndpoint(path="/proxy/bria/v2/image/edit/increase_resolution", method="POST"), + data=BriaIncreaseResolutionRequest( + image=await upload_image_to_comfyapi(cls, image, total_pixels=None, wait_label="Uploading image"), + desired_increase=multiplier, + visual_input_content_moderation=moderation.get("visual_input_moderation", False), + visual_output_content_moderation=moderation.get("visual_output_moderation", False), + ), + response_model=BriaStatusResponse, + ) + response = await poll_op( + cls, + ApiEndpoint(path=f"/proxy/bria/v2/status/{response.request_id}"), + status_extractor=lambda r: r.status, + response_model=BriaImageResultResponse, + ) + return IO.NodeOutput(await download_url_to_image_tensor(response.result.image_url)) + + class BriaRemoveVideoBackground(IO.ComfyNode): @classmethod @@ -572,6 +1092,10 @@ class BriaExtension(ComfyExtension): return [ BriaImageEditNode, BriaRemoveImageBackground, + BriaGenFill, + BriaEraser, + BriaExpandImage, + BriaIncreaseResolution, BriaRemoveVideoBackground, BriaVideoGreenScreen, BriaVideoReplaceBackground,