Files
AhBumm-ComfyUI_BillBum_APIs…/nodes4tuzi.py
T

536 lines
17 KiB
Python

import tenacity
import random
from openai import OpenAI
import io
import re
from PIL import Image
import numpy as np
import torch
import requests
import math
import base64
from comfy.utils import common_upscale
import subprocess
import tempfile
import os
from urllib.parse import urlparse
import folder_paths
import shutil
from comfy_api.latest import ui
from comfy_api.latest import io as comfyio
from comfy_api.input_impl import VideoFromFile
## ======== Utils Functions ========
def downscale_input(image):
samples = image.movedim(-1,1)
#downscaling input images to roughly the same size as the outputs
total = int(1536 * 1024)
scale_by = math.sqrt(total / (samples.shape[3] * samples.shape[2]))
if scale_by >= 1:
return image
width = round(samples.shape[3] * scale_by)
height = round(samples.shape[2] * scale_by)
s = common_upscale(samples, width, height, "lanczos", "disabled")
s = s.movedim(1,-1)
return s
def tensor2pil(image):
return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
def pil2tensor(image):
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
## ======== Nodes Classes ========
class BillBum_Modified_StreamResponse_LLM_API:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"forceInput": True}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
"model": ("STRING", {"default": "gpt-4o-mini"}),
"api_url": ("STRING", {"multiline": False, "default": "https://api.tu-zi.com/v1"}),
"api_key": ("STRING", {"multiline": False, "default": "YOUR_API_KEY_HERE"}),
"temperature": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 2.0, "step": 0.05}),
"enable_thinking": ("COMBO", {
"options": ["true", "false", "none"],
"default": "none",
"tooltip": "only true/false would append 'enable_thinking' to request body",
}),
},
"optional": {
"system_prompt": ("STRING", {"forceInput": True, "default": None}),
"images": ("IMAGE", {"default": None, "tooltip": "Use Any Image Batch Nodes to input multiple images"}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("LLM RESPONSE",)
FUNCTION = "get_llm_stream_response"
CATEGORY = "BillBum_API/Stream Response"
@staticmethod
def _encode_images_to_base64(images):
if images is None:
return []
if images.dim() == 3:
images = images.unsqueeze(0)
images = downscale_input(images)
encoded_images = []
for idx in range(images.shape[0]):
tensor_image = images[idx].clamp(0.0, 1.0)
pil_image = tensor2pil(tensor_image)
buffer = io.BytesIO()
pil_image.save(buffer, format="PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
encoded_images.append(encoded)
return encoded_images
@tenacity.retry(wait=tenacity.wait_exponential(multiplier=1.25, min=5, max=30), stop=tenacity.stop_after_attempt(3))
def get_llm_stream_response(
self,
prompt,
seed,
model,
api_url,
api_key,
temperature,
enable_thinking,
images=None,
system_prompt=None,
):
random.seed(seed)
client = OpenAI(api_key=api_key, base_url=api_url)
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
user_content = []
if prompt:
user_content.append({"type": "text", "text": prompt})
for encoded_image in self._encode_images_to_base64(images):
user_content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{encoded_image}"},
})
if not user_content:
raise ValueError("Prompt and images cannot both be empty.")
messages.append({"role": "user", "content": user_content})
request_kwargs = {
"model": model,
"messages": messages,
"stream": True,
}
if temperature != 0.0:
request_kwargs["temperature"] = temperature
extra_body = {}
if enable_thinking == "true":
extra_body["enable_thinking"] = True
elif enable_thinking == "false":
extra_body["enable_thinking"] = False
if extra_body:
request_kwargs["extra_body"] = extra_body
completion = client.chat.completions.create(**request_kwargs)
full_content = ""
for chunk in completion:
if chunk.choices and chunk.choices[0].delta.content is not None:
delta = chunk.choices[0].delta.content
full_content += delta
print(delta, end="")
return (full_content,)
class Url2Image:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"url": ("STRING", {"multiline": False, "default": ""}),
},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "get_url_image"
CATEGORY = "BillBum_API/Utils"
def _load_image_bytes(self, entry: str) -> bytes:
if entry.startswith("data:"):
_, base64_data = entry.split(",", 1)
return base64.b64decode(base64_data)
if entry.startswith(("http://", "https://")):
response = requests.get(entry, timeout=10)
response.raise_for_status()
return response.content
return base64.b64decode(entry)
def _decode_entry(self, entry: str):
image_data = self._load_image_bytes(entry)
image = Image.open(io.BytesIO(image_data))
if image.mode != "RGBA":
image = image.convert("RGBA")
return np.array(image, dtype=np.float32) / 255.0
def get_url_image(self, url):
if not url:
return (None,)
entries = []
for raw_line in url.replace("\r", "").split("\n"):
line = raw_line.strip()
if not line:
continue
if line.startswith("data:"):
entries.append(line)
else:
for part in line.split(","):
part = part.strip()
if part:
entries.append(part)
if not entries:
return (None,)
decoded_images = []
for entry in entries:
try:
decoded_images.append(self._decode_entry(entry))
except Exception as e:
print(f"Url2Image: Can't decode {entry}: {e}")
if not decoded_images:
return (None,)
max_h = max(img.shape[0] for img in decoded_images)
max_w = max(img.shape[1] for img in decoded_images)
batches = []
for img in decoded_images:
h, w, _ = img.shape
padded = np.zeros((max_h, max_w, 4), dtype=np.float32)
padded[:h, :w, :] = img
batches.append(padded)
image_tensor = torch.from_numpy(np.stack(batches, axis=0))
return (image_tensor,)
class LoadVideoFromUrlComfyIO(comfyio.ComfyNode):
def __init__(self):
pass
@classmethod
def define_schema(cls):
return comfyio.Schema(
node_id="load_video_from_url_comfy_core",
display_name="Load&Save Video From URL (Comfy Core)",
category="BillBum_API/Utils",
inputs=[
comfyio.String.Input("url", default="", tooltip="http/https url"),
comfyio.String.Input("filename_prefix", default="video_files/url_download"),
],
outputs=[comfyio.Video.Output("video")],
hidden=[comfyio.Hidden.prompt, comfyio.Hidden.extra_pnginfo],
is_output_node=True,
)
@staticmethod
def _extension_from_url(url: str) -> str:
ext = os.path.splitext(urlparse(url).path)[1].lower()
if ext in {".mp4", ".mov", ".mkv", ".webm", ".gif"}:
return ext
return ".mp4"
@staticmethod
def _download_to_temp(url: str, suffix: str) -> str:
with requests.get(url, stream=True, timeout=30) as resp:
resp.raise_for_status()
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
for chunk in resp.iter_content(chunk_size=8192):
if chunk:
tmp.write(chunk)
return tmp.name
@classmethod
def execute(cls, url, filename_prefix) -> comfyio.NodeOutput:
url = (url or "").strip()
if not url:
raise ValueError("URL cannot be empty.")
suffix = cls._extension_from_url(url)
temp_path = cls._download_to_temp(url, suffix)
try:
video_temp = VideoFromFile(temp_path)
width, height = video_temp.get_dimensions()
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
filename_prefix or "temp",
folder_paths.get_output_directory(),
width,
height,
)
output_name = f"{filename}_{counter:05}{suffix}"
final_path = os.path.join(full_output_folder, output_name)
shutil.move(temp_path, final_path)
video = VideoFromFile(final_path)
preview = ui.PreviewVideo([ui.SavedResult(output_name, subfolder, comfyio.FolderType.output)])
return comfyio.NodeOutput(video, ui=preview)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
class LoadVideoFromUrlVHS:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"url": ("STRING", {"multiline": False, "default": ""}),
}
}
RETURN_TYPES = ("IMAGE", "VHS_VIDEOINFO",)
RETURN_NAMES = ("image", "video_info",)
FUNCTION = "load_video"
CATEGORY = "BillBum_API/Utils"
def _download_video(self, url: str) -> str:
if not url.startswith(("http://", "https://")):
raise ValueError("仅支持 http/https URL。")
try:
with requests.get(url, timeout=15, stream=True) as response:
response.raise_for_status()
suffix = os.path.splitext(url)[1] or ".mp4"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_file:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
tmp_file.write(chunk)
return tmp_file.name
except requests.exceptions.RequestException as e:
raise ConnectionError(f"无法下载视频: {e}")
def _get_video_metadata(self, filepath: str):
ffmpeg_path = "ffmpeg"
width = height = 0
fps = 30.0
duration = 0.0
try:
proc = subprocess.run(
[ffmpeg_path, "-i", filepath, "-f", "null", "-"],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
check=False,
)
stderr_output = proc.stderr.decode("utf-8", errors="ignore")
for line in stderr_output.splitlines():
if "Stream" in line and "Video" in line:
size_match = re.search(r"(\d{2,})x(\d+)", line)
if size_match:
width, height = map(int, size_match.group(0).split("x"))
tbr_match = re.search(r"([\d\.]+) tbr", line)
if tbr_match:
fps = float(tbr_match.group(1))
else:
fps_match = re.search(r"([\d\.]+) fps", line)
if fps_match:
fps = float(fps_match.group(1))
break
duration_match = re.search(r"Duration: (\d{2}):(\d{2}):(\d{2})\.(\d+)", stderr_output)
if duration_match:
h, m, s, ms_part = duration_match.groups()
duration = (
int(h) * 3600
+ int(m) * 60
+ int(s)
+ float(f"0.{ms_part}")
)
except FileNotFoundError as e:
raise RuntimeError("未检测到 ffmpeg,可在系统 PATH 中安装。") from e
return width, height, fps, duration
def _extract_frames(self, filepath: str, width: int, height: int):
if width <= 0 or height <= 0:
raise RuntimeError("无法确定视频分辨率。")
command = [
"ffmpeg",
"-i",
filepath,
"-f",
"rawvideo",
"-pix_fmt",
"rgba",
"pipe:1",
]
frame_size = width * height * 4
frames = []
with subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=10 ** 7,
) as proc:
try:
while True:
frame_bytes = proc.stdout.read(frame_size)
if not frame_bytes or len(frame_bytes) < frame_size:
break
frame_np = np.frombuffer(frame_bytes, dtype=np.uint8).reshape((height, width, 4))
frames.append(frame_np.astype(np.float32) / 255.0)
finally:
proc.stdout.close()
proc.wait()
if not frames:
raise RuntimeError("未能从视频提取任何帧。")
return frames
def load_video(self, url: str):
if not url:
raise ValueError("URL 输入为空。")
temp_path = self._download_video(url)
try:
width, height, fps, source_duration = self._get_video_metadata(temp_path)
if fps <= 0:
fps = 30.0
frames_np = self._extract_frames(temp_path, width, height)
image_tensor = torch.from_numpy(np.stack(frames_np))
loaded_frames = image_tensor.shape[0]
loaded_duration = loaded_frames / fps if fps > 0 else 0.0
source_duration = source_duration or loaded_duration
source_frame_count = int(round(source_duration * fps)) if source_duration and fps > 0 else loaded_frames
video_info = {
"source_fps": fps,
"source_frame_count": source_frame_count,
"source_duration": source_duration,
"source_width": width,
"source_height": height,
"loaded_fps": fps,
"loaded_frame_count": loaded_frames,
"loaded_duration": loaded_duration,
"loaded_width": width,
"loaded_height": height,
}
return (image_tensor, video_info)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
class RegTuziChatResponse:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"response": ("STRING", {"forceInput": True}),
"content_type": ("COMBO", {
"options": ["text", "image", "video"],
"default": "text",
})
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("CONTENT",)
FUNCTION = "reg_chat_response"
CATEGORY = "BillBum_API/Utils"
def reg_chat_response(self, response, content_type):
if content_type == "text":
out_str = response
elif content_type == "image":
image_urls = []
markdown_urls = re.findall(
r'!\[[^\]]*\]\((https?://[^\s\)]+)\)',
response,
flags=re.IGNORECASE,
)
image_urls.extend(markdown_urls)
file_urls = re.findall(
r'(https?://[^\s\)\]]+\.(?:jpg|jpeg|png|webp|gif|bmp|tif|tiff))',
response,
flags=re.IGNORECASE,
)
for url in file_urls:
if url not in image_urls:
image_urls.append(url)
unique_urls = []
seen_names = set()
for url in image_urls:
filename = os.path.basename(urlparse(url).path)
if not filename:
filename = url
if filename.lower() in seen_names:
continue
seen_names.add(filename.lower())
unique_urls.append(url)
out_str = ",".join(unique_urls)
elif content_type == "video":
marker = "[⏬ 下载视频]("
out_str = ""
start = response.find(marker)
if start != -1:
start += len(marker)
end = response.find(")", start)
if end != -1:
out_str = response[start:end]
# === Fallback to find .mp4 URLs in response ===
if not out_str:
mp4_urls = re.findall(r"(https?://[^\s\)\]]+\.mp4)", response, flags=re.IGNORECASE)
if mp4_urls:
out_str = mp4_urls[-1]
return (out_str,)