diff --git a/__init__.py b/__init__.py index 84b4d6c..a3dbb38 100644 --- a/__init__.py +++ b/__init__.py @@ -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", } \ No newline at end of file diff --git a/billbum_modified.py b/billbum_modified.py index 385e3d4..faf8f7c 100644 --- a/billbum_modified.py +++ b/billbum_modified.py @@ -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): diff --git a/nodes4tuzi.py b/nodes4tuzi.py new file mode 100644 index 0000000..a855c45 --- /dev/null +++ b/nodes4tuzi.py @@ -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,) + diff --git a/pyproject.toml b/pyproject.toml index 391410f..d6eee93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"]