mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-07-14 02:17:13 +08:00
Compare commits
6 Commits
349c2eca04
...
c32a951e01
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c32a951e01 | ||
|
|
51bf508a0b | ||
|
|
a3020f107e | ||
|
|
b1ff036a22 | ||
|
|
50d77af3af | ||
|
|
a2db31582f |
@ -432,6 +432,98 @@ def is_amd():
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_integrated_gpu(device=None):
|
||||
# AMD APUs / integrated GPUs expose host RAM (GTT/shared) as device memory
|
||||
# via mem_get_info(); torch flags these as integrated. See ComfyUI #14274.
|
||||
if cpu_state != CPUState.GPU:
|
||||
return False
|
||||
if not (is_nvidia() or is_amd()):
|
||||
return False
|
||||
try:
|
||||
if device is None:
|
||||
device = get_torch_device()
|
||||
return bool(getattr(torch.cuda.get_device_properties(device), "is_integrated", 0))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _amd_vram_gtt_totals(device=None):
|
||||
# Best-effort (vram_total, gtt_total) in bytes from the amdgpu sysfs nodes
|
||||
# mem_info_vram_total / mem_info_gtt_total, or None when they cannot be read
|
||||
# (e.g. NVIDIA Tegra integrated parts that have no dedicated VRAM). #14274
|
||||
if not is_amd():
|
||||
return None
|
||||
try:
|
||||
drm_root = "/sys/class/drm"
|
||||
candidates = []
|
||||
for name in os.listdir(drm_root):
|
||||
if not (name.startswith("card") and name[len("card"):].isdigit()):
|
||||
continue
|
||||
dev_dir = os.path.join(drm_root, name, "device")
|
||||
vram_path = os.path.join(dev_dir, "mem_info_vram_total")
|
||||
gtt_path = os.path.join(dev_dir, "mem_info_gtt_total")
|
||||
if not (os.path.exists(vram_path) and os.path.exists(gtt_path)):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(dev_dir, "vendor")) as vf:
|
||||
if vf.read().strip().lower() != "0x1002":
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
candidates.append((os.path.basename(os.path.realpath(dev_dir)), vram_path, gtt_path))
|
||||
if not candidates:
|
||||
return None
|
||||
chosen = None
|
||||
target_bdf = None
|
||||
try:
|
||||
if device is None:
|
||||
device = get_torch_device()
|
||||
props = torch.cuda.get_device_properties(device)
|
||||
# torch reports the PCI location as integers (pci_domain_id / pci_bus_id
|
||||
# / pci_device_id); amdgpu names its sysfs nodes as a hex
|
||||
# "domain:bus:device.function" BDF. Build the canonical hex BDF so the
|
||||
# two are comparable (the old str(pci_bus_id) compared a decimal bus
|
||||
# number against a hex BDF string and could never match). #14274
|
||||
target_bdf = "%04x:%02x:%02x" % (
|
||||
int(getattr(props, "pci_domain_id", 0) or 0),
|
||||
int(getattr(props, "pci_bus_id", 0) or 0),
|
||||
int(getattr(props, "pci_device_id", 0) or 0),
|
||||
)
|
||||
except Exception:
|
||||
target_bdf = None
|
||||
if target_bdf:
|
||||
for pci, vram_path, gtt_path in candidates:
|
||||
# candidates carry the realpath() leaf BDF (domain:bus:device.function),
|
||||
# so matching the domain:bus:device part works whether the GPU is
|
||||
# attached directly or sits behind a PCIe bridge (nested sysfs path). #14274
|
||||
if pci.lower().rsplit(".", 1)[0] == target_bdf:
|
||||
chosen = (vram_path, gtt_path)
|
||||
break
|
||||
if chosen is None and len(candidates) == 1:
|
||||
chosen = (candidates[0][1], candidates[0][2])
|
||||
if chosen is None:
|
||||
return None
|
||||
with open(chosen[0]) as f:
|
||||
vram_total = int(f.read().strip())
|
||||
with open(chosen[1]) as f:
|
||||
gtt_total = int(f.read().strip())
|
||||
return (vram_total, gtt_total)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def integrated_gpu_is_shared_heavy(device=None):
|
||||
# For an integrated GPU, decide whether its memory is dominated by the shared
|
||||
# GTT/host-RAM aperture (treat as UMA -> SHARED) or by a large dedicated VRAM
|
||||
# carveout (keep NORMAL/HIGH_VRAM). Keys on the amdgpu mem_info_vram_total vs
|
||||
# mem_info_gtt_total ratio (ComfyUI #14274). Defaults to True when the totals
|
||||
# are unavailable (e.g. NVIDIA Tegra parts that have no dedicated VRAM).
|
||||
totals = _amd_vram_gtt_totals(device)
|
||||
if totals is None:
|
||||
return True
|
||||
vram_total, gtt_total = totals
|
||||
if not vram_total or vram_total <= 0:
|
||||
return True
|
||||
return gtt_total >= vram_total
|
||||
|
||||
def amd_min_version(device=None, min_rdna_version=0):
|
||||
if not is_amd():
|
||||
return False
|
||||
@ -569,6 +661,15 @@ if cpu_state != CPUState.GPU:
|
||||
if cpu_state == CPUState.MPS:
|
||||
vram_state = VRAMState.SHARED
|
||||
|
||||
if vram_state == VRAMState.NORMAL_VRAM and is_integrated_gpu() and integrated_gpu_is_shared_heavy():
|
||||
# Integrated/UMA GPU whose shared GTT/host-RAM pool dominates the (small)
|
||||
# dedicated VRAM carveout: treat as UMA and use SHARED so the shared pool is
|
||||
# not double-counted as dedicated VRAM (#14274). Dedicated-heavy integrated
|
||||
# parts (large BIOS UMA carveout, e.g. Strix Halo) keep NORMAL_VRAM where
|
||||
# HIGH_VRAM is correct.
|
||||
vram_state = VRAMState.SHARED
|
||||
logging.info("Integrated GPU with shared-memory-dominant pool detected (UMA): using SHARED vram state to avoid double-counting GTT/shared memory as dedicated VRAM.")
|
||||
|
||||
logging.info(f"Set vram state to: {vram_state.name}")
|
||||
|
||||
DISABLE_SMART_MEMORY = args.disable_smart_memory
|
||||
|
||||
@ -281,11 +281,18 @@ class VideoFromFile(VideoInput):
|
||||
video_done = False
|
||||
audio_done = True
|
||||
|
||||
if len(container.streams.audio):
|
||||
audio_stream = container.streams.audio[-1]
|
||||
# Use the last decodable audio stream. Streams FFmpeg has no decoder for have no codec context,
|
||||
# and decoding their packets crashes the process. (e.g. APAC spatial-audio track in iPhone)
|
||||
audio_stream = next(
|
||||
(s for s in reversed(container.streams.audio) if s.codec_context is not None),
|
||||
None,
|
||||
)
|
||||
if audio_stream is not None:
|
||||
streams += [audio_stream]
|
||||
resampler = av.audio.resampler.AudioResampler(format='fltp')
|
||||
audio_done = False
|
||||
elif len(container.streams.audio):
|
||||
logging.warning("No decodable audio stream found in video; ignoring audio.")
|
||||
|
||||
for packet in container.demux(*streams):
|
||||
if video_done and audio_done:
|
||||
@ -457,10 +464,13 @@ class VideoFromFile(VideoInput):
|
||||
else:
|
||||
output_container.metadata[key] = json.dumps(value)
|
||||
|
||||
# Add streams to the new container
|
||||
# Add streams to the new container. Streams with no codec context cannot be used as an output template.
|
||||
stream_map = {}
|
||||
for stream in streams:
|
||||
if isinstance(stream, (av.VideoStream, av.AudioStream, SubtitleStream)):
|
||||
if stream.codec_context is None:
|
||||
logging.warning("Skipping %s stream %d with unsupported codec", stream.type, stream.index)
|
||||
continue
|
||||
out_stream = output_container.add_stream_from_template(template=stream, opaque=True)
|
||||
stream_map[stream] = out_stream
|
||||
|
||||
|
||||
@ -158,7 +158,14 @@ async def upload_video_to_comfyapi(
|
||||
|
||||
# Convert VideoInput to BytesIO using specified container/codec
|
||||
video_bytes_io = BytesIO()
|
||||
video.save_to(video_bytes_io, format=container, codec=codec)
|
||||
try:
|
||||
video.save_to(video_bytes_io, format=container, codec=codec)
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f"Could not convert the input video to {container.value.upper()} for upload; "
|
||||
f"the file may be corrupted or use an unsupported codec. "
|
||||
f"Try re-exporting it as MP4 (H.264). Original error: {e}"
|
||||
) from e
|
||||
video_bytes_io.seek(0)
|
||||
|
||||
return await upload_file_to_comfyapi(cls, video_bytes_io, filename, upload_mime_type, wait_label)
|
||||
|
||||
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()
|
||||
Loading…
Reference in New Issue
Block a user