mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-07-12 01:17:46 +08:00
Compare commits
5 Commits
08b540844a
...
ad697bf3cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad697bf3cc | ||
|
|
6880614319 | ||
|
|
51bf508a0b | ||
|
|
058692a79b | ||
|
|
58df1a3564 |
@ -127,6 +127,8 @@
|
||||
- Do not add unnecessary `try`/`except` blocks. Use them for optional dependency,
|
||||
platform, or backend capability detection only when the program has a useful
|
||||
fallback. Prefer specific exception types when changing new code.
|
||||
- If a library version is pinned in `requirements.txt`, do not add code to
|
||||
ComfyUI to handle older versions of that library.
|
||||
- Remove any workarounds for PyTorch versions that ComfyUI no longer officially
|
||||
supports. Deprecated workarounds include catching an exception and rerunning
|
||||
the same op with the input cast to float. If a workaround does not have a
|
||||
|
||||
@ -165,6 +165,8 @@ def _build_asset_response(result: schemas.AssetDetailResult | schemas.UploadResu
|
||||
return schemas_out.Asset(
|
||||
id=result.ref.id,
|
||||
name=result.ref.name,
|
||||
# Mirror name for backwards compatibility; ref.name is always set.
|
||||
display_name=result.ref.name,
|
||||
hash=asset_content_hash,
|
||||
asset_hash=asset_content_hash,
|
||||
size=int(result.asset.size_bytes) if result.asset else None,
|
||||
@ -219,6 +221,7 @@ async def list_assets_route(request: web.Request) -> web.Response:
|
||||
exclude_tags=q.exclude_tags,
|
||||
name_contains=q.name_contains,
|
||||
metadata_filter=q.metadata_filter,
|
||||
asset_hash=q.hash,
|
||||
limit=q.limit,
|
||||
offset=q.offset,
|
||||
sort=sort,
|
||||
|
||||
@ -54,6 +54,18 @@ class ListAssetsQuery(BaseModel):
|
||||
exclude_tags: list[str] = Field(default_factory=list)
|
||||
name_contains: str | None = None
|
||||
|
||||
# Filter to assets whose content hash matches exactly. Param name is `hash`
|
||||
# per the projected openapi.yaml listAssets contract (the response-body field
|
||||
# is `asset_hash`; the query param is `hash`).
|
||||
hash: str | None = None
|
||||
|
||||
# Declared for cloud/core contract parity. In core, reads are owner-scoped
|
||||
# (owner_id == "") and there is no separate shared/public pool for this flag
|
||||
# to include or exclude, so it is inert here and intentionally not threaded
|
||||
# into the query. Accepted (not rejected) so the FE needs no isCloud branch;
|
||||
# cloud enforces the flag in its own service layer.
|
||||
include_public: bool = True
|
||||
|
||||
# Accept either a JSON string (query param) or a dict
|
||||
metadata_filter: dict[str, Any] | None = None
|
||||
|
||||
@ -86,6 +98,19 @@ class ListAssetsQuery(BaseModel):
|
||||
return out
|
||||
return v
|
||||
|
||||
@field_validator("hash", mode="before")
|
||||
@classmethod
|
||||
def _normalize_hash(cls, v):
|
||||
# Normalize for an exact match against stored hashes (which are
|
||||
# lowercase `blake3:<hex>`). Liberal in what we accept — no pattern
|
||||
# enforcement; a non-matching value simply yields an empty page.
|
||||
# An explicitly-supplied-but-empty value (`?hash=`) stays `""` so it
|
||||
# is treated as an exact-match miss (empty page), not silently dropped
|
||||
# to "no filter" — omit the param entirely to disable the filter.
|
||||
if isinstance(v, str):
|
||||
return v.strip().lower()
|
||||
return v
|
||||
|
||||
@field_validator("metadata_filter", mode="before")
|
||||
@classmethod
|
||||
def _parse_metadata_json(cls, v):
|
||||
|
||||
@ -10,6 +10,8 @@ class Asset(BaseModel):
|
||||
|
||||
id: str
|
||||
name: str
|
||||
# Mirrors `name` for backwards compatibility.
|
||||
display_name: str | None = None
|
||||
hash: str | None = None
|
||||
asset_hash: str | None = None
|
||||
size: int | None = None
|
||||
|
||||
@ -261,6 +261,7 @@ def list_references_page(
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
name_contains: str | None = None,
|
||||
asset_hash: str | None = None,
|
||||
include_tags: Sequence[str] | None = None,
|
||||
exclude_tags: Sequence[str] | None = None,
|
||||
metadata_filter: dict | None = None,
|
||||
@ -293,6 +294,11 @@ def list_references_page(
|
||||
escaped, esc = escape_sql_like_string(name_contains)
|
||||
base = base.where(AssetReference.name.ilike(f"%{escaped}%", escape=esc))
|
||||
|
||||
# `is not None` (not truthiness): an explicit empty hash is an exact-match
|
||||
# miss (empty page), while an omitted hash (None) disables the filter.
|
||||
if asset_hash is not None:
|
||||
base = base.where(Asset.hash == asset_hash)
|
||||
|
||||
base = apply_tag_filters(base, include_tags, exclude_tags)
|
||||
base = apply_metadata_filter(base, metadata_filter)
|
||||
|
||||
@ -345,6 +351,8 @@ def list_references_page(
|
||||
count_stmt = count_stmt.where(
|
||||
AssetReference.name.ilike(f"%{escaped}%", escape=esc)
|
||||
)
|
||||
if asset_hash is not None:
|
||||
count_stmt = count_stmt.where(Asset.hash == asset_hash)
|
||||
count_stmt = apply_tag_filters(count_stmt, include_tags, exclude_tags)
|
||||
count_stmt = apply_metadata_filter(count_stmt, metadata_filter)
|
||||
|
||||
|
||||
@ -274,6 +274,7 @@ def list_assets_page(
|
||||
exclude_tags: Sequence[str] | None = None,
|
||||
name_contains: str | None = None,
|
||||
metadata_filter: dict | None = None,
|
||||
asset_hash: str | None = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
sort: str = "created_at",
|
||||
@ -319,6 +320,7 @@ def list_assets_page(
|
||||
exclude_tags=exclude_tags,
|
||||
name_contains=name_contains,
|
||||
metadata_filter=metadata_filter,
|
||||
asset_hash=asset_hash,
|
||||
limit=fetch_limit,
|
||||
offset=offset,
|
||||
sort=sort,
|
||||
|
||||
150
comfy_extras/nodes_text_overlay.py
Normal file
150
comfy_extras/nodes_text_overlay.py
Normal file
@ -0,0 +1,150 @@
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image as PILImage, ImageColor, ImageDraw, ImageFont
|
||||
from typing_extensions import override
|
||||
|
||||
from comfy_api.latest import ComfyExtension, IO
|
||||
|
||||
|
||||
class TextOverlay(IO.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="TextOverlay",
|
||||
display_name="Draw Text Overlay",
|
||||
category="text",
|
||||
description="Draw text overlay on an image or batch of images.",
|
||||
search_aliases=["text", "label", "caption", "subtitle", "watermark", "title", "addlabel", "overlay"],
|
||||
inputs=[
|
||||
IO.Image.Input("images"),
|
||||
IO.String.Input("text", multiline=True, default=""),
|
||||
IO.Float.Input("font_size", default=5.0, min=0.5, max=50.0, step=0.5, tooltip="Font size as a percentage of the image height."),
|
||||
IO.Color.Input("color", default="#ffffff", tooltip="Color of the text."),
|
||||
IO.Combo.Input("position", options=["top", "bottom"], default="top"),
|
||||
IO.Combo.Input("align", options=["left", "center", "right"], default="left"),
|
||||
IO.Boolean.Input("outline", default=True, tooltip="Draw a black outline around the text."),
|
||||
],
|
||||
outputs=[IO.Image.Output(display_name="images")],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, images, text, font_size, color, position, align, outline) -> IO.NodeOutput:
|
||||
if text.strip() == "":
|
||||
return IO.NodeOutput(images)
|
||||
|
||||
text = text.replace("\\n", "\n").replace("\\t", "\t")
|
||||
|
||||
text_rgba = cls.parse_color_to_rgba(color)
|
||||
outline_rgba = (0, 0, 0, 255) if outline else (0, 0, 0, 0)
|
||||
|
||||
# Render the overlay once and composite it across all frames in the batch
|
||||
height = images.shape[1]
|
||||
width = images.shape[2]
|
||||
overlay_rgb, overlay_alpha = cls.render_overlay_text(width, height, text, position, align, font_size, text_rgba, outline_rgba)
|
||||
overlay_rgb = overlay_rgb.to(device=images.device, dtype=images.dtype)
|
||||
overlay_alpha = overlay_alpha.to(device=images.device, dtype=images.dtype)
|
||||
|
||||
result = images * (1.0 - overlay_alpha) + overlay_rgb * overlay_alpha
|
||||
return IO.NodeOutput(result)
|
||||
|
||||
@staticmethod
|
||||
def parse_color_to_rgba(color_string):
|
||||
parsed = ImageColor.getrgb(color_string)
|
||||
|
||||
if len(parsed) == 3:
|
||||
return (*parsed, 255)
|
||||
|
||||
return parsed
|
||||
|
||||
@classmethod
|
||||
def render_overlay_text(cls, width, height, text, position, align, font_size, text_rgba, outline_rgba):
|
||||
line_spacing = 1.2
|
||||
margin_percent = 1.0
|
||||
min_font_percent = 2.0
|
||||
min_font_pixels = 10
|
||||
outline_thickness_factor = 0.04
|
||||
|
||||
# Draw onto a transparent layer so the result can be alpha-composited over any frame.
|
||||
layer = PILImage.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
|
||||
margin = int(round(margin_percent / 100.0 * min(width, height)))
|
||||
max_width = max(1, width - 2 * margin)
|
||||
max_height = max(1, height - 2 * margin)
|
||||
|
||||
# Font scales with resolution, then shrinks to fit the height.
|
||||
size = max(1, int(round(font_size / 100.0 * height)))
|
||||
floor = min(size, max(min_font_pixels, int(round(min_font_percent / 100.0 * height))))
|
||||
|
||||
while True:
|
||||
font = ImageFont.load_default(size=size)
|
||||
stroke = max(1, int(round(size * outline_thickness_factor))) if outline_rgba[3] > 0 else 0
|
||||
block = "\n".join(cls.wrap_text(text, font, max_width))
|
||||
# convert line spacing to pixel spacing
|
||||
single = draw.textbbox((0, 0), "Ay", font=font, stroke_width=stroke)
|
||||
double = draw.multiline_textbbox((0, 0), "Ay\nAy", font=font, spacing=0, stroke_width=stroke)
|
||||
natural_advance = (double[3] - double[1]) - (single[3] - single[1])
|
||||
pixel_spacing = int(round(size * line_spacing - natural_advance))
|
||||
box = draw.multiline_textbbox((0, 0), block, font=font, spacing=pixel_spacing, stroke_width=stroke)
|
||||
block_height = box[3] - box[1]
|
||||
|
||||
if block_height <= max_height or size <= floor:
|
||||
break
|
||||
|
||||
size = max(floor, int(size * 0.9))
|
||||
|
||||
anchor_h, x = {"left": ("l", margin), "center": ("m", width / 2), "right": ("r", width - margin)}[align]
|
||||
|
||||
# Offset y so the rendered text sits flush against the margin
|
||||
if position == "bottom":
|
||||
y = height - margin - box[3]
|
||||
else:
|
||||
y = margin - box[1]
|
||||
|
||||
draw.multiline_text((x, y), block, font=font, fill=text_rgba, anchor=anchor_h + "a",
|
||||
align=align, spacing=pixel_spacing, stroke_width=stroke, stroke_fill=outline_rgba)
|
||||
|
||||
overlay = np.array(layer).astype(np.float32) / 255.0
|
||||
overlay_rgb = torch.from_numpy(overlay[:, :, :3])
|
||||
overlay_alpha = torch.from_numpy(overlay[:, :, 3:4])
|
||||
return overlay_rgb, overlay_alpha
|
||||
|
||||
@staticmethod
|
||||
def wrap_text(text, font, max_width):
|
||||
lines = []
|
||||
for raw_line in text.split("\n"):
|
||||
words = raw_line.split()
|
||||
if not words:
|
||||
lines.append("")
|
||||
continue
|
||||
current = ""
|
||||
# Break the line into words and split words that are too long
|
||||
for word in words:
|
||||
while font.getlength(word) > max_width and len(word) > 1:
|
||||
cut = 1
|
||||
while cut < len(word) and font.getlength(word[:cut + 1]) <= max_width:
|
||||
cut += 1
|
||||
if current:
|
||||
lines.append(current)
|
||||
current = ""
|
||||
lines.append(word[:cut])
|
||||
word = word[cut:]
|
||||
candidate = word if not current else current + " " + word
|
||||
if not current or font.getlength(candidate) <= max_width:
|
||||
current = candidate
|
||||
else:
|
||||
lines.append(current)
|
||||
current = word
|
||||
if current:
|
||||
lines.append(current)
|
||||
return lines
|
||||
|
||||
|
||||
class TextOverlayExtension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||
return [TextOverlay]
|
||||
|
||||
|
||||
async def comfy_entrypoint() -> TextOverlayExtension:
|
||||
return TextOverlayExtension()
|
||||
1
nodes.py
1
nodes.py
@ -2478,6 +2478,7 @@ async def init_builtin_extra_nodes():
|
||||
"nodes_glsl.py",
|
||||
"nodes_lora_debug.py",
|
||||
"nodes_textgen.py",
|
||||
"nodes_text_overlay.py",
|
||||
"nodes_color.py",
|
||||
"nodes_toolkit.py",
|
||||
"nodes_replacements.py",
|
||||
|
||||
@ -306,6 +306,130 @@ def test_list_assets_invalid_query_rejected(http: requests.Session, api_base: st
|
||||
assert body["error"]["code"] == error_code
|
||||
|
||||
|
||||
def test_list_assets_display_name_mirrors_name(http, api_base, asset_factory, make_asset_bytes):
|
||||
"""`display_name` is emitted and mirrors `name` for every populated asset."""
|
||||
scope = f"lf-dispname-{uuid.uuid4().hex[:6]}"
|
||||
tags = ["models", "checkpoints", "unit-tests", scope]
|
||||
asset_factory("dn_a.safetensors", tags, {}, make_asset_bytes("dn_a", 700))
|
||||
asset_factory("dn_b.safetensors", tags, {}, make_asset_bytes("dn_b", 700))
|
||||
|
||||
r = http.get(
|
||||
api_base + "/api/assets",
|
||||
params={"include_tags": f"unit-tests,{scope}", "limit": "50"},
|
||||
timeout=120,
|
||||
)
|
||||
body = r.json()
|
||||
assert r.status_code == 200, body
|
||||
assert body["assets"], "expected at least one asset"
|
||||
for asset in body["assets"]:
|
||||
assert "display_name" in asset, "populated asset must emit display_name"
|
||||
assert asset["display_name"] == asset["name"]
|
||||
|
||||
|
||||
def test_list_assets_hash_filter_exact_match(http, api_base, asset_factory, make_asset_bytes):
|
||||
"""`hash` filters to assets whose content hash matches exactly."""
|
||||
scope = f"lf-hash-{uuid.uuid4().hex[:6]}"
|
||||
tags = ["models", "checkpoints", "unit-tests", scope]
|
||||
a = asset_factory("hf_a.safetensors", tags, {}, make_asset_bytes("hf_a", 1024))
|
||||
b = asset_factory("hf_b.safetensors", tags, {}, make_asset_bytes("hf_b", 2048))
|
||||
|
||||
target = a["hash"]
|
||||
assert target and a["hash"] != b["hash"], "fixtures must have distinct content hashes"
|
||||
|
||||
r = http.get(
|
||||
api_base + "/api/assets",
|
||||
params={"hash": target, "limit": "50"},
|
||||
timeout=120,
|
||||
)
|
||||
body = r.json()
|
||||
assert r.status_code == 200, body
|
||||
names = [x["name"] for x in body["assets"]]
|
||||
assert names == [a["name"]]
|
||||
assert body["total"] == 1
|
||||
|
||||
|
||||
def test_list_assets_hash_filter_no_match(http, api_base, asset_factory, make_asset_bytes):
|
||||
"""A well-formed but unknown hash returns an empty page (200)."""
|
||||
scope = f"lf-hash-none-{uuid.uuid4().hex[:6]}"
|
||||
tags = ["models", "checkpoints", "unit-tests", scope]
|
||||
asset_factory("hn_a.safetensors", tags, {}, make_asset_bytes("hn_a", 800))
|
||||
|
||||
unknown = "blake3:" + ("0" * 64)
|
||||
r = http.get(
|
||||
api_base + "/api/assets",
|
||||
params={"hash": unknown, "limit": "50"},
|
||||
timeout=120,
|
||||
)
|
||||
body = r.json()
|
||||
assert r.status_code == 200, body
|
||||
assert body["assets"] == []
|
||||
assert body["total"] == 0
|
||||
|
||||
|
||||
def test_list_assets_hash_filter_normalizes_case_and_whitespace(
|
||||
http, api_base, asset_factory, make_asset_bytes
|
||||
):
|
||||
"""`hash` is trimmed and lowercased before matching, so an upper-cased,
|
||||
space-padded value still matches the stored lowercase hash."""
|
||||
scope = f"lf-hashnorm-{uuid.uuid4().hex[:6]}"
|
||||
tags = ["models", "checkpoints", "unit-tests", scope]
|
||||
a = asset_factory("hnorm_a.safetensors", tags, {}, make_asset_bytes("hnorm_a", 1024))
|
||||
|
||||
target = a["hash"]
|
||||
assert target == target.lower(), "stored hash is expected to be lowercase"
|
||||
messy = f" {target.upper()} "
|
||||
|
||||
r = http.get(
|
||||
api_base + "/api/assets",
|
||||
params={"hash": messy, "limit": "50"},
|
||||
timeout=120,
|
||||
)
|
||||
body = r.json()
|
||||
assert r.status_code == 200, body
|
||||
names = [x["name"] for x in body["assets"]]
|
||||
assert names == [a["name"]]
|
||||
assert body["total"] == 1
|
||||
|
||||
|
||||
def test_list_assets_hash_filter_empty_returns_empty_page(
|
||||
http, api_base, asset_factory, make_asset_bytes
|
||||
):
|
||||
"""An explicitly-supplied but empty `hash` (`?hash=`) is an exact-match miss
|
||||
and returns an empty page, rather than silently disabling the filter."""
|
||||
scope = f"lf-hashempty-{uuid.uuid4().hex[:6]}"
|
||||
tags = ["models", "checkpoints", "unit-tests", scope]
|
||||
asset_factory("he_a.safetensors", tags, {}, make_asset_bytes("he_a", 800))
|
||||
|
||||
r = http.get(
|
||||
api_base + "/api/assets",
|
||||
params={"hash": "", "limit": "50"},
|
||||
timeout=120,
|
||||
)
|
||||
body = r.json()
|
||||
assert r.status_code == 200, body
|
||||
assert body["assets"] == []
|
||||
assert body["total"] == 0
|
||||
|
||||
|
||||
def test_list_assets_include_public_accepted(http, api_base, asset_factory, make_asset_bytes):
|
||||
"""`include_public` is accepted for contract parity; core results are always
|
||||
the caller's own assets regardless of its value (the param is inert)."""
|
||||
scope = f"lf-incpub-{uuid.uuid4().hex[:6]}"
|
||||
tags = ["models", "checkpoints", "unit-tests", scope]
|
||||
a = asset_factory("ip_a.safetensors", tags, {}, make_asset_bytes("ip_a", 900))
|
||||
|
||||
for value in ("false", "true"):
|
||||
r = http.get(
|
||||
api_base + "/api/assets",
|
||||
params={"include_tags": f"unit-tests,{scope}", "include_public": value, "limit": "50"},
|
||||
timeout=120,
|
||||
)
|
||||
body = r.json()
|
||||
assert r.status_code == 200, body
|
||||
names = [x["name"] for x in body["assets"]]
|
||||
assert a["name"] in names, f"caller's own asset must be returned (include_public={value})"
|
||||
|
||||
|
||||
def test_list_assets_name_contains_literal_underscore(
|
||||
http,
|
||||
api_base,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user