Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion comfy_api_nodes/apis/quiver.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,27 @@ class QuiverImageObject(BaseModel):
url: str = Field(...)


class QuiverViewBox(BaseModel):
minX: int = Field(default=0)
minY: int = Field(default=0)
width: int = Field(..., gt=0)
height: int = Field(..., gt=0)


class QuiverSVGAttributes(BaseModel):
viewBox: QuiverViewBox | None = Field(default=None)


class QuiverTextToSVGRequest(BaseModel):
model: str = Field(...)
prompt: str = Field(...)
reasoning_effort: str | None = Field(default=None)
instructions: str | None = Field(default=None)
references: list[QuiverImageObject] | None = Field(default=None, max_length=4)
references: list[QuiverImageObject] | None = Field(default=None, max_length=14)
temperature: float | None = Field(default=None, ge=0, le=2)
top_p: float | None = Field(default=None, ge=0, le=1)
presence_penalty: float | None = Field(default=None, ge=-2, le=2)
attributes: QuiverSVGAttributes | None = Field(default=None)


class QuiverImageToSVGRequest(BaseModel):
Expand All @@ -25,6 +37,7 @@ class QuiverImageToSVGRequest(BaseModel):
temperature: float | None = Field(default=None, ge=0, le=2)
top_p: float | None = Field(default=None, ge=0, le=1)
presence_penalty: float | None = Field(default=None, ge=-2, le=2)
attributes: QuiverSVGAttributes | None = Field(default=None)


class QuiverSVGResponseItem(BaseModel):
Expand Down
276 changes: 268 additions & 8 deletions comfy_api_nodes/nodes_quiver.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
from comfy_api_nodes.apis.quiver import (
QuiverImageObject,
QuiverImageToSVGRequest,
QuiverSVGAttributes,
QuiverSVGResponse,
QuiverTextToSVGRequest,
QuiverViewBox,
)
from comfy_api_nodes.util import (
ApiEndpoint,
Expand All @@ -20,6 +22,8 @@
_ARROW_MODELS = ["arrow-2", "arrow-2-telos", "arrow-1.1", "arrow-1.1-max", "arrow-preview"]
_EFFORT_LEVELS = ["low", "medium", "high", "xhigh"]
_TOKEN_MODEL_RATES = {"arrow-2": (4, 20), "arrow-2-telos": (6, 30)}
_NO_SAMPLING_MODELS = ("arrow-2-telos",)
_REFERENCE_CAPS = {"arrow-2": 14, "arrow-2-telos": 14, "arrow-1.1": 4, "arrow-1.1-max": 14, "arrow-preview": 4}
_FIXED_GENERATION_USD = {"arrow-1.1": 0.286, "arrow-1.1-max": 0.3575, "arrow-preview": 0.429}
_FIXED_VECTORIZATION_USD = {"arrow-1.1": 0.2145, "arrow-1.1-max": 0.286, "arrow-preview": 0.429}

Expand Down Expand Up @@ -53,9 +57,8 @@
}


def _effort_bands(model, tokens):
def _effort_bands(model, tokens, effort):
rate_in, rate_out = _TOKEN_MODEL_RATES[model]
effort = "widgets.reasoning_effort"

def band(level):
min_in, min_out, max_in, max_out = tokens[model][level]
Expand All @@ -70,12 +73,12 @@ def band(level):
return " : ".join([*levels, band("high")])


def _arrow_price_badge(tokens, fixed_usd):
def _arrow_price_badge(tokens, fixed_usd, effort="widgets.reasoning_effort", effort_widget="reasoning_effort"):
branches = [f'widgets.model = "{model}" ? {{"type":"usd","usd":{usd}}}' for model, usd in fixed_usd.items()]
branches.append(f'widgets.model = "arrow-2-telos" ? ({_effort_bands("arrow-2-telos", tokens)})')
branches.append(f'({_effort_bands("arrow-2", tokens)})')
branches.append(f'widgets.model = "arrow-2-telos" ? ({_effort_bands("arrow-2-telos", tokens, effort)})')
branches.append(f'({_effort_bands("arrow-2", tokens, effort)})')
return IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(widgets=["model", "reasoning_effort"]),
depends_on=IO.PriceBadgeDepends(widgets=["model", effort_widget]),
expr="(" + " : ".join(branches) + ")",
)

Expand Down Expand Up @@ -127,12 +130,151 @@ def _arrow_sampling_inputs():
]


def _v2_view_box_inputs():
return [
IO.Int.Input(
"width",
default=0,
min=0,
max=8192,
tooltip="Width of the output SVG canvas (viewBox), in user units. Set both width and "
"height to control the output size and aspect ratio; leave either at 0 to let the model "
"choose, which usually gives a square canvas.",
advanced=True,
),
IO.Int.Input(
"height",
default=0,
min=0,
max=8192,
tooltip="Height of the output SVG canvas (viewBox), in user units. Set both width and "
"height to control the output size and aspect ratio; leave either at 0 to let the model "
"choose, which usually gives a square canvas.",
advanced=True,
),
]


def _v2_seed_input():
return IO.Int.Input(
"seed",
default=42,
min=0,
max=2147483647,
control_after_generate=True,
tooltip="Seed to determine if node should re-run; "
"actual results are nondeterministic regardless of seed.",
)


def _v2_model_tail(model):
inputs = []
if model in _TOKEN_MODEL_RATES:
inputs.append(
IO.Combo.Input(
"reasoning_effort",
options=_EFFORT_LEVELS,
default="high",
tooltip="How much reasoning the model spends before drawing. Higher levels improve "
"detail and cost more tokens.",
)
)
if model not in _NO_SAMPLING_MODELS:
inputs.extend(_arrow_sampling_inputs())
inputs.extend(_v2_view_box_inputs())
inputs.append(_v2_seed_input())
return inputs


def _v2_text_option(model):
cap = _REFERENCE_CAPS[model]
return IO.DynamicCombo.Option(
model,
[
IO.String.Input(
"prompt",
multiline=True,
default="",
tooltip="Text description of the desired SVG output.",
),
IO.String.Input(
"instructions",
multiline=True,
default="",
tooltip="Additional style or formatting guidance.",
optional=True,
advanced=True,
),
IO.Autogrow.Input(
"reference_images",
template=IO.Autogrow.TemplatePrefix(
IO.Image.Input("image"),
prefix="ref_",
min=0,
max=cap,
),
tooltip=f"Up to {cap} reference images to guide the generation.",
optional=True,
),
*_v2_model_tail(model),
],
)


def _v2_image_option(model):
return IO.DynamicCombo.Option(
model,
[
IO.Image.Input("image", tooltip="Input image to vectorize."),
IO.Boolean.Input(
"auto_crop",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
default=False,
tooltip="Automatically crop to the dominant subject.",
advanced=True,
),
IO.Int.Input(
"target_size",
default=0,
min=0,
max=4096,
tooltip="Square resize applied to the input image before vectorizing, in pixels, "
"128 to 4096. 0 keeps the source size, which vectorizes more cleanly than forcing a "
"resize. This does not set the output canvas; use width and height for that.",
advanced=True,
),
*_v2_model_tail(model),
],
)


def _target_size(model):
target_size = model.get("target_size") or 0
return max(target_size, 128) if target_size else None


def _view_box_attributes(model):
width = model.get("width") or 0
height = model.get("height") or 0
if width and height:
return QuiverSVGAttributes(viewBox=QuiverViewBox(width=width, height=height))
return None


def _v2_price_badge(tokens, fixed_usd):
return _arrow_price_badge(
tokens,
fixed_usd,
effort='$lookup(widgets, "model.reasoning_effort")',
effort_widget="model.reasoning_effort",
)


class QuiverTextToSVGNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="QuiverTextToSVGNode",
display_name="Quiver Text to SVG",
display_name="Quiver Text to SVG (Legacy)",
category="partner/image/Quiver",
description="Generate an SVG from a text prompt using Quiver AI.",
inputs=[
Expand Down Expand Up @@ -186,6 +328,7 @@ def define_schema(cls):
IO.Hidden.unique_id,
],
is_api_node=True,
is_deprecated=True,
price_badge=_arrow_price_badge(_GENERATION_TOKENS, _FIXED_GENERATION_USD),
)

Expand Down Expand Up @@ -237,7 +380,7 @@ class QuiverImageToSVGNode(IO.ComfyNode):
def define_schema(cls):
return IO.Schema(
node_id="QuiverImageToSVGNode",
display_name="Quiver Image to SVG",
display_name="Quiver Image to SVG (Legacy)",
category="partner/image/Quiver",
description="Vectorize a raster image into SVG using Quiver AI.",
inputs=[
Expand Down Expand Up @@ -293,6 +436,7 @@ def define_schema(cls):
IO.Hidden.unique_id,
],
is_api_node=True,
is_deprecated=True,
price_badge=_arrow_price_badge(_VECTORIZATION_TOKENS, _FIXED_VECTORIZATION_USD),
)

Expand Down Expand Up @@ -327,12 +471,128 @@ async def execute(
return IO.NodeOutput(SVG(svg_data))


class QuiverTextToSVGNodeV2(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="QuiverTextToSVGNodeV2",
display_name="Quiver Text to SVG",
category="partner/image/Quiver",
description="Generate an SVG from a text prompt using Quiver AI.",
inputs=[
IO.DynamicCombo.Input(
"model",
options=[_v2_text_option(model) for model in _ARROW_MODELS],
tooltip="Model to use for SVG generation.",
),
],
outputs=[
IO.SVG.Output(),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
is_api_node=True,
price_badge=_v2_price_badge(_GENERATION_TOKENS, _FIXED_GENERATION_USD),
)

@classmethod
async def execute(cls, model: dict) -> IO.NodeOutput:
validate_string(model["prompt"], strip_whitespace=False, min_length=1)

references = None
reference_images = model.get("reference_images")
if reference_images:
references = []
for key in reference_images:
url = await upload_image_to_comfyapi(cls, reference_images[key], mime_type="image/png")
references.append(QuiverImageObject(url=url))

instructions = (model.get("instructions") or "").strip() or None

response = await sync_op(
cls,
ApiEndpoint(path="/proxy/quiver/v1/svgs/generations", method="POST"),
response_model=QuiverSVGResponse,
data=QuiverTextToSVGRequest(
model=model["model"],
prompt=model["prompt"],
instructions=instructions,
references=references,
reasoning_effort=model.get("reasoning_effort"),
temperature=model.get("temperature"),
top_p=model.get("top_p"),
presence_penalty=model.get("presence_penalty"),
attributes=_view_box_attributes(model),
),
)

svg_data = [BytesIO(item.svg.encode("utf-8")) for item in response.data]
return IO.NodeOutput(SVG(svg_data))


class QuiverImageToSVGNodeV2(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="QuiverImageToSVGNodeV2",
display_name="Quiver Image to SVG",
category="partner/image/Quiver",
description="Vectorize a raster image into SVG using Quiver AI.",
inputs=[
IO.DynamicCombo.Input(
"model",
options=[_v2_image_option(model) for model in _ARROW_MODELS],
tooltip="Model to use for SVG vectorization.",
),
],
outputs=[
IO.SVG.Output(),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
is_api_node=True,
price_badge=_v2_price_badge(_VECTORIZATION_TOKENS, _FIXED_VECTORIZATION_USD),
)

@classmethod
async def execute(cls, model: dict) -> IO.NodeOutput:
image_url = await upload_image_to_comfyapi(cls, model["image"], mime_type="image/png")

response = await sync_op(
cls,
ApiEndpoint(path="/proxy/quiver/v1/svgs/vectorizations", method="POST"),
response_model=QuiverSVGResponse,
data=QuiverImageToSVGRequest(
model=model["model"],
image=QuiverImageObject(url=image_url),
auto_crop=model.get("auto_crop") or None,
target_size=_target_size(model),
reasoning_effort=model.get("reasoning_effort"),
temperature=model.get("temperature"),
top_p=model.get("top_p"),
presence_penalty=model.get("presence_penalty"),
attributes=_view_box_attributes(model),
),
)

svg_data = [BytesIO(item.svg.encode("utf-8")) for item in response.data]
return IO.NodeOutput(SVG(svg_data))


class QuiverExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
return [
QuiverTextToSVGNode,
QuiverImageToSVGNode,
QuiverTextToSVGNodeV2,
QuiverImageToSVGNodeV2,
]


Expand Down
Loading