Update Stream Response VisionLMs API and many..
This commit is contained in:
+36
-5
@@ -1,16 +1,20 @@
|
||||
from .billbum_modified import *
|
||||
from .nodes4tuzi import (
|
||||
BillBum_Modified_StreamResponse_LLM_API,
|
||||
Url2Image,
|
||||
RegTuziChatResponse,
|
||||
LoadVideoFromUrlVHS,
|
||||
LoadVideoFromUrlComfyIO,
|
||||
)
|
||||
|
||||
# Exporting the node classes for ComfyUI to discover
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"BillBum_Modified_Dalle_API_Node": BillBum_Modified_Dalle_API_Node,
|
||||
"BillBum_Modified_LLM_API_Node": BillBum_Modified_LLM_API_Node,
|
||||
"BillBum_Modified_img2b64_url_Node": BillBum_Modified_img2url_Node,
|
||||
"BillBum_Modified_img2b64_url_Node": BillBum_Modified_img2b64url_Node,
|
||||
"BillBum_Modified_VisionLM_API_Node": BillBum_Modified_VisionLM_API_Node,
|
||||
"BillBum_Modified_SD3_API_Node": BillBum_Modified_SD3_API_Node,
|
||||
"BillBum_Modified_Base64_Url2Img_Node": BillBum_Modified_Base64_Url2Img_Node,
|
||||
"BillBum_Modified_ImageSplit_Node": BillBum_Modified_ImageSplit_Node,
|
||||
"BillBum_Modified_Base64_Url2Data_Node": BillBum_Modified_Base64_Url2Data_Node,
|
||||
"BillBum_Modified_Structured_LLM_Node(Imperfect)": BillBum_Modified_Structured_LLM_Node,
|
||||
"BillBum_Modified_Flux_API_Node": BillBum_Modified_Flux_API_Node,
|
||||
"BillBum_Modified_RegText_Node": BillBum_Modified_RegText_Node,
|
||||
"BillBum_Modified_DropoutToken_Node": BillBum_Modified_DropoutToken_Node,
|
||||
"BillBum_Modified_Image_API_Call_Node": BillBum_Modified_Image_API_Call_Node,
|
||||
@@ -22,7 +26,34 @@ NODE_CLASS_MAPPINGS = {
|
||||
"BillBum_Modified_LLM_ForceStream_Mode": BillBum_Modified_LLM_ForceStream_Mode,
|
||||
"BillBum_Modified_GPTImage1_API_Node": BillBum_Modified_GPTImage1_API_Node,
|
||||
"BillBum_Modified_Flux_API_with_imgInput": BillBum_Modified_Flux_API_Node_imgInput,
|
||||
"billbum_modified_stream_response_llm_api": BillBum_Modified_StreamResponse_LLM_API,
|
||||
"url2image": Url2Image,
|
||||
"reg_tuzi_chat_response": RegTuziChatResponse,
|
||||
"load_video_from_url": LoadVideoFromUrlVHS,
|
||||
"load_video_from_url_comfy_core": LoadVideoFromUrlComfyIO,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"billbum_modified_stream_response_llm_api": "API Node for Stream Response LLMs",
|
||||
"url2image": "Load Image from URL (BillBum)",
|
||||
"reg_tuzi_chat_response": "Tuzi Chat Response Parser",
|
||||
"load_video_from_url": "Load Video From URL (VHS Compatible)",
|
||||
"load_video_from_url_comfy_core": "Load&Save Video From URL (Comfy Core)",
|
||||
"BillBum_Modified_Dalle_API_Node": "Dall-E Custom API Node",
|
||||
"BillBum_Modified_LLM_API_Node": "Custom LLM API Node (Old)",
|
||||
"BillBum_Modified_img2b64_url_Node": "Image to Base64 URL Node",
|
||||
"BillBum_Modified_VisionLM_API_Node": "Vision LLMs API Node (Old)",
|
||||
"BillBum_Modified_SD3_API_Node": "Stable Diffusion 3 API Node",
|
||||
"BillBum_Modified_Base64_Url2Img_Node": "Base64 URL to Image Node",
|
||||
"BillBum_Modified_RegText_Node": "Regular ResponseText to 1linePrompt Node",
|
||||
"BillBum_Modified_DropoutToken_Node": "Dropout by MaxToken Node",
|
||||
"BillBum_Modified_Image_API_Call_Node": "Custom Image Generation API Call Node",
|
||||
"BillBum_Modified_Recraft_API_Node": "Custom Recraft API Node",
|
||||
"Text_Concat": "Concat Text Strings Node",
|
||||
"Input_Text": "Input Text",
|
||||
"BillBum_Modified_Ideogram_API_Node": "Custom Ideogram API Node",
|
||||
"BillBum_NonSysPrompt_VLM_API_Node": "Non-System Prompt VLMs API Node",
|
||||
"BillBum_Modified_LLM_ForceStream_Mode": "LLM StreamResponse Node (Old)",
|
||||
"BillBum_Modified_GPTImage1_API_Node": "Custom GPTImage1 API Node",
|
||||
"BillBum_Modified_Flux_API_with_imgInput": "Custom Flux API Node",
|
||||
}
|
||||
+3
-191
@@ -149,7 +149,7 @@ class Text_Concat:
|
||||
CATEGORY = "string processing"
|
||||
|
||||
def text_concat(self, text_1, text_2):
|
||||
text = f"{text_1} {text_2}"
|
||||
text = f"{text_1}{text_2}"
|
||||
return (text,)
|
||||
|
||||
class Input_Text:
|
||||
@@ -264,7 +264,7 @@ class BillBum_Modified_LLM_API_Node:
|
||||
}),
|
||||
"api_url": ("STRING", {
|
||||
"multiline": False,
|
||||
"default": "https://api.hyprlab.io/v2",
|
||||
"default": "https://api.hyprlab.io/v1",
|
||||
}),
|
||||
"api_key": ("STRING", {
|
||||
"multiline": False,
|
||||
@@ -394,62 +394,6 @@ class BillBum_Modified_LLM_ForceStream_Mode:
|
||||
|
||||
return (full_content,seed,)
|
||||
|
||||
class BillBum_Modified_Structured_LLM_Node:
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"prompt": ("STRING", {"defaultInput": True},),
|
||||
"model": ("STRING", {
|
||||
"default": "gpt-4o-mini",
|
||||
}),
|
||||
"api_url": ("STRING", {
|
||||
"multiline": False,
|
||||
"default": "https://api.hyprlab.io/v1",
|
||||
}),
|
||||
"api_key": ("STRING", {
|
||||
"multiline": False,
|
||||
"default": "YOUR_API_KEY_HERE",
|
||||
}),
|
||||
"system_prompt": ("STRING", {"defaultInput": True},),
|
||||
"output_format": ("STRING", {"defaultInput": True},),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("structured_str",)
|
||||
FUNCTION = "get_llm_structured_response"
|
||||
CATEGORY = "BillBum_API"
|
||||
|
||||
@tenacity.retry(wait=tenacity.wait_exponential(multiplier=1.25, min=5, max=30))
|
||||
def get_llm_structured_response(self, prompt, model, api_url, api_key, system_prompt, output_format, seed):
|
||||
|
||||
META_SCHEMA = json.loads(output_format)
|
||||
|
||||
random.seed(seed)
|
||||
|
||||
client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url=api_url
|
||||
)
|
||||
|
||||
completion = client.chat.completions.create(
|
||||
model=model,
|
||||
response_format={"type": "json_schema", "json_schema": META_SCHEMA},
|
||||
messages=[
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': "Description:\n" + prompt}
|
||||
]
|
||||
)
|
||||
|
||||
response_dict = json.loads(completion.choices[0].message.content)
|
||||
data = response_dict.get("data", [])
|
||||
return (data,)
|
||||
|
||||
class BillBum_Modified_VisionLM_API_Node:
|
||||
|
||||
def __init__(self):
|
||||
@@ -762,7 +706,7 @@ Are you in agreement with these instructions? Please respond with "Ok!"
|
||||
)
|
||||
return (completion.choices[0].message.content, seed, model, api_url, api_key)
|
||||
|
||||
class BillBum_Modified_img2url_Node:
|
||||
class BillBum_Modified_img2b64url_Node:
|
||||
"""
|
||||
A ComfyUI node to convert an image file to a base64 encoded string.
|
||||
"""
|
||||
@@ -890,62 +834,6 @@ class BillBum_Modified_SD3_API_Node:
|
||||
raise Exception(f"Image URL not found in response: {response_json}")
|
||||
return (image_url,)
|
||||
|
||||
class BillBum_Modified_Flux_API_Node:
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"model": ("STRING", {"default": "flux-1.1-pro"}),
|
||||
"prompt": ("STRING", {"defaultInput": True}),
|
||||
"width": ("INT", {"default": 1024, "min": 256, "max": 1440, "step": 32, "display": "number"}),
|
||||
"height": ("INT", {"default": 1024, "min": 256, "max": 1440, "step": 32, "display": "number"}),
|
||||
"steps": ("INT", {"default": 20, "min": 1, "max": 50, "step": 1, "display": "number"}),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 10000}),
|
||||
"api_url": ("STRING", {"multiline": False, "default": "https://api.hyprlab.io/v1/images/generations"}),
|
||||
"api_key": ("STRING", {"default": "YOUR_API_KEY_HERE"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "INT",)
|
||||
RETURN_NAMES = ("base64_url", "seed",)
|
||||
FUNCTION = "get_t2i_image"
|
||||
CATEGORY = "BillBum_API"
|
||||
|
||||
@tenacity.retry(wait=tenacity.wait_exponential(multiplier=1.25, min=5, max=30))
|
||||
def get_t2i_image(self, model, prompt, width, height, steps, api_url, seed, api_key):
|
||||
random.seed(seed)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}"
|
||||
}
|
||||
|
||||
data = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"steps": steps,
|
||||
"height": height,
|
||||
"width": width,
|
||||
"response_format": "b64_json",
|
||||
"output_format": "webp"
|
||||
}
|
||||
|
||||
response = requests.post(api_url, headers=headers, json=data)
|
||||
print(f"HTTP status code: {response.status_code}")
|
||||
response.raise_for_status()
|
||||
|
||||
response_json = response.json()
|
||||
try:
|
||||
b64_string = response_json['data'][0]['b64_json']
|
||||
base64_url = f"data:image/webp;base64,{b64_string}"
|
||||
return (base64_url, seed)
|
||||
except (KeyError, IndexError) as e:
|
||||
raise Exception(f"Unexpected response format: {e}")
|
||||
|
||||
class BillBum_Modified_Flux_API_Node_imgInput:
|
||||
|
||||
def __init__(self):
|
||||
@@ -1224,82 +1112,6 @@ class BillBum_Modified_Base64_Url2Img_Node:
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
return (pil2tensor(image),)
|
||||
|
||||
class BillBum_Modified_ImageSplit_Node:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "split_image"
|
||||
CATEGORY = "BillBum Image Processing"
|
||||
|
||||
def split_image(self, image):
|
||||
# Convert tensor to PIL image if necessary
|
||||
img_pil = Image.fromarray((image.squeeze(0).numpy() * 255).astype('uint8')) if isinstance(image, torch.Tensor) else image
|
||||
|
||||
# Handle different image sizes
|
||||
if img_pil.size == (1024, 1024):
|
||||
# If the image is 1024x1024, return it as is
|
||||
return (image,)
|
||||
elif img_pil.size == (2048, 1024):
|
||||
# If the image is 2048x1024, split it into two 1024x1024 images
|
||||
box_coordinates = [(0, 0, 1024, 1024), (1024, 0, 2048, 1024)]
|
||||
elif img_pil.size == (2048, 2048):
|
||||
# If the image is 2048x2048, split it into four 1024x1024 images
|
||||
box_coordinates = [(0, 0, 1024, 1024), (1024, 0, 2048, 1024), (0, 1024, 1024, 2048), (1024, 1024, 2048, 2048)]
|
||||
else:
|
||||
raise ValueError("Input image must be either 1024x1024, 2048x1024, or 2048x2048.")
|
||||
|
||||
# Define tolerance for detecting near-black images
|
||||
tolerance = 0.08 # 8% tolerance
|
||||
|
||||
# Crop and convert the sub-images to tensors, while filtering out near-black images
|
||||
sub_images = []
|
||||
for box in box_coordinates:
|
||||
cropped_img = img_pil.crop(box)
|
||||
np_img = np.array(cropped_img).astype(np.float32) / 255.0
|
||||
|
||||
# Check if the image is not near-black
|
||||
if not np.all(np_img <= tolerance):
|
||||
sub_images.append(torch.from_numpy(np_img))
|
||||
|
||||
# If all sub-images are near-black, return an empty tensor
|
||||
if not sub_images:
|
||||
raise ValueError("All cropped sub-images are near-black.")
|
||||
|
||||
# Stack valid sub-images to create a batch tensor
|
||||
batch_tensor = torch.stack(sub_images)
|
||||
|
||||
return (batch_tensor,)
|
||||
|
||||
class BillBum_Modified_Base64_Url2Data_Node:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"base64_url": ("STRING", {"defaultInput": True},),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "convert"
|
||||
CATEGORY = "BillBum String Processing"
|
||||
|
||||
def convert(self, base64_url_string):
|
||||
try:
|
||||
base64_data_string = base64_url_string.split(",", 1)[1]
|
||||
except IndexError:
|
||||
raise ValueError("Invalid base64 URL string format.")
|
||||
|
||||
return (base64_data_string,)
|
||||
|
||||
class BillBum_Modified_RegText_Node:
|
||||
|
||||
def __init__(self):
|
||||
|
||||
+535
@@ -0,0 +1,535 @@
|
||||
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,)
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui_billbum_api_nodes"
|
||||
description = "API call node for Third-party platforms both official and local. Support VLMs LLMs Dalle3 Flux-Pro(Support kontext now!!) etc(new Support gpt-image-1!!). And some little tools: img to b64 url, b64 url to img, b64 url to b64 data, reg text to word and ',' only, etc."
|
||||
version = "1.1.5"
|
||||
description = "API call node for Third-party platforms both official and local. Support VLMs LLMs Dalle3 Flux-Pro(Support kontext, banana etc... now!! and new Support gpt-image-1!!). And some little tools: img to b64 url, b64 url to img, b64 url to b64 data, reg text to word and ',' only, etc."
|
||||
version = "1.1.6"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = ["tenacity", "openai", "pillow", "requests", "torch", "numpy", "tiktoken"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user