commit 2ebb082d7de92dddf875e1032ce8d01b4292f20d Author: jueming Date: Tue Jun 24 10:32:25 2025 +0800 增加火山引擎通用3.0文生图模型节点 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a57fd51 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# ComfyUI Volcengine SeeDream V3 Node + +这是一个用于ComfyUI的自定义节点,可以调用火山引擎通用3.0文生图模型API来生成图像。 + +## 功能特性 + +- 调用火山引擎SeeDream V3文生图API +- 支持所有官方API参数 +- 自动处理API签名验证 +- 支持URL和Base64图像响应格式 +- 完整的错误处理机制 + +## 安装 + +1. 将此项目克隆到ComfyUI的custom_nodes目录下: +```bash +cd ComfyUI/custom_nodes/ +git clone [your-repo-url] ComfyUI-JM-Volcengine-API +``` + +2. 安装依赖: +```bash +cd ComfyUI-JM-Volcengine-API +pip install -r requirements.txt +``` + +3. 重启ComfyUI + +## 使用方法 + +### 节点参数 + +#### 必需参数 +- **access_key**: 火山引擎API访问密钥 +- **secret_key**: 火山引擎API秘钥 +- **prompt**: 图像生成提示词(支持中英文) + +#### 可选参数 +- **use_pre_llm**: 是否开启文本扩写(默认:False) +- **seed**: 随机种子,用于生成可重复的结果(默认:-1为随机) +- **guidance_scale**: 文本描述的影响程度(默认:2.5,范围:1.0-10.0) +- **aspect_ratio**: 图像宽高比,支持1.5K分辨率预设(默认:1:1) +- **return_url**: 是否返回图片URL链接(默认:True) + +### 支持的图像分辨率 (1.5K) + +节点支持以下预设的1.5K分辨率比例选项: +- **1:1** → 1536×1536 (正方形) +- **4:3** → 1472×1104 (传统照片比例) +- **3:2** → 1584×1056 (经典摄影比例) +- **16:9** → 1664×936 (宽屏比例) +- **9:16** → 936×1664 (竖屏比例,适合手机wallpaper) +- **21:9** → 2016×864 (超宽屏比例) + +### 提示词优化技巧 + +1. **画面描述**: 使用连贯的自然语言描述画面内容(主体+行为+环境等) +2. **美学描述**: 用短词语描述画面美学(风格、色彩、光影、构图等) +3. **专业词汇**: 推荐使用英文描述专业词汇,获得更精准效果 +4. **图像用途**: 说明图像用途和类型,如"PPT封面背景图"、"广告海报设计"等 +5. **文字排版**: 将文字内容置于双引号""内,并描述文字的大小、字体、颜色、风格和位置 + +### 示例提示词 + +``` +【风格:漫画风格】【镜头视角:特写】一个可爱的机器人在花园里浇花,阳光明媚,色彩鲜艳,卡通风格 +``` + +### 参数说明对照表 + +| 节点参数名 | API参数名 | 说明 | +|-----------|-----------|------| +| access_key | - | 火山引擎API访问密钥 | +| secret_key | - | 火山引擎API密钥 | +| prompt | prompt | 图像生成提示词 | +| use_pre_llm | use_pre_llm | 文本扩写增强 | +| seed | seed | 随机种子 | +| guidance_scale | scale | 引导强度 | +| aspect_ratio | width/height | 自动设置宽高比 | +| return_url | return_url | 返回图片URL | + +## 节点位置 + +在ComfyUI中,您可以在以下位置找到该节点: +**Add Node → JM-MiniMax-API/Video → volcengine-seedream-v3** + +## API配置 + +1. 登录火山引擎控制台 +2. 获取您的access_key和secret_key +3. 确保您的账户有足够的API调用额度 + +## 错误处理 + +节点包含完整的错误处理机制: +- API密钥验证 +- 网络请求超时处理 +- 图像下载失败处理 +- API响应错误处理 + +如果生成失败,节点会返回一个空白图像并在控制台输出错误信息。 + +## 注意事项 + +- 图像链接有效期为24小时 +- 建议使用1.3K-1.5K分辨率获得最佳画质 +- 请合理使用API避免超出配额限制 +- 网络环境需要能够访问火山引擎API服务 + +## 技术支持 + +如果您遇到任何问题,请检查: +1. API密钥是否正确 +2. 网络连接是否正常 +3. ComfyUI控制台的错误信息 +4. 火山引擎API配额是否充足 + +## 许可证 + +本项目基于MIT许可证开源。 \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..133d81e --- /dev/null +++ b/__init__.py @@ -0,0 +1,11 @@ +from .nodes.volcengine_seedream_v3 import VolcengineSeeDreamV3Node + +NODE_CLASS_MAPPINGS = { + "volcengine-seedream-v3": VolcengineSeeDreamV3Node +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "volcengine-seedream-v3": "Volcengine SeeDream V3" +} + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] \ No newline at end of file diff --git a/nodes/__init__.py b/nodes/__init__.py new file mode 100644 index 0000000..7378b6b --- /dev/null +++ b/nodes/__init__.py @@ -0,0 +1 @@ +# nodes/__init__.py \ No newline at end of file diff --git a/nodes/volcengine_seedream_v3.py b/nodes/volcengine_seedream_v3.py new file mode 100644 index 0000000..2c9e21e --- /dev/null +++ b/nodes/volcengine_seedream_v3.py @@ -0,0 +1,271 @@ +import json +import base64 +import datetime +import hashlib +import hmac +import requests +import torch +import numpy as np +from PIL import Image +import io +import os + + +class VolcengineSeeDreamV3Node: + """ + ComfyUI custom node for Volcengine SeeDream V3 text-to-image API + """ + + def __init__(self): + self.method = 'POST' + self.host = 'visual.volcengineapi.com' + self.region = 'cn-north-1' + self.endpoint = 'https://visual.volcengineapi.com' + self.service = 'cv' + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "access_key": ("STRING", {"default": "", "multiline": False}), + "secret_key": ("STRING", {"default": "", "multiline": False}), + "prompt": ("STRING", {"default": "", "multiline": True}), + }, + "optional": { + "use_pre_llm": ("BOOLEAN", {"default": False}), + "seed": ("INT", {"default": -1, "min": -1, "max": 2147483647}), + "guidance_scale": ("FLOAT", {"default": 2.5, "min": 1.0, "max": 10.0, "step": 0.1}), + "aspect_ratio": (["1:1", "4:3", "3:2", "16:9", "9:16", "21:9"], {"default": "1:1"}), + "return_url": ("BOOLEAN", {"default": True}), + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + FUNCTION = "generate_image" + CATEGORY = "JM-MiniMax-API/Video" + + def sign(self, key, msg): + """Generate HMAC signature""" + return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest() + + def get_signature_key(self, key, date_stamp, region_name, service_name): + """Generate signature key""" + k_date = self.sign(key.encode('utf-8'), date_stamp) + k_region = self.sign(k_date, region_name) + k_service = self.sign(k_region, service_name) + k_signing = self.sign(k_service, 'request') + return k_signing + + def format_query(self, parameters): + """Format query parameters""" + request_parameters_init = '' + for key in sorted(parameters): + request_parameters_init += key + '=' + parameters[key] + '&' + request_parameters = request_parameters_init[:-1] + return request_parameters + + def sign_v4_request(self, access_key, secret_key, service, req_query, req_body): + """Sign request using AWS V4 signature""" + if access_key is None or secret_key is None: + raise ValueError('Access key and secret key are required.') + + t = datetime.datetime.utcnow() + current_date = t.strftime('%Y%m%dT%H%M%SZ') + datestamp = t.strftime('%Y%m%d') + + canonical_uri = '/' + canonical_querystring = req_query + signed_headers = 'content-type;host;x-content-sha256;x-date' + payload_hash = hashlib.sha256(req_body.encode('utf-8')).hexdigest() + content_type = 'application/json' + + canonical_headers = ( + f'content-type:{content_type}\n' + f'host:{self.host}\n' + f'x-content-sha256:{payload_hash}\n' + f'x-date:{current_date}\n' + ) + + canonical_request = ( + f'{self.method}\n{canonical_uri}\n{canonical_querystring}\n' + f'{canonical_headers}\n{signed_headers}\n{payload_hash}' + ) + + algorithm = 'HMAC-SHA256' + credential_scope = f'{datestamp}/{self.region}/{service}/request' + string_to_sign = ( + f'{algorithm}\n{current_date}\n{credential_scope}\n' + f'{hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()}' + ) + + signing_key = self.get_signature_key(secret_key, datestamp, self.region, service) + signature = hmac.new(signing_key, string_to_sign.encode('utf-8'), hashlib.sha256).hexdigest() + + authorization_header = ( + f'{algorithm} Credential={access_key}/{credential_scope}, ' + f'SignedHeaders={signed_headers}, Signature={signature}' + ) + + headers = { + 'X-Date': current_date, + 'Authorization': authorization_header, + 'X-Content-Sha256': payload_hash, + 'Content-Type': content_type + } + + return headers + + def download_image_from_url(self, url): + """Download image from URL and convert to tensor""" + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + + # Convert to PIL Image + image = Image.open(io.BytesIO(response.content)) + + # Convert to RGB if necessary + if image.mode != 'RGB': + image = image.convert('RGB') + + # Convert to numpy array + image_array = np.array(image).astype(np.float32) / 255.0 + + # Convert to tensor with batch dimension + image_tensor = torch.from_numpy(image_array)[None,] + + return image_tensor + + except Exception as e: + raise Exception(f"Failed to download image from URL: {str(e)}") + + def decode_base64_image(self, base64_string): + """Decode base64 image string to tensor""" + try: + # Remove data URL prefix if present + if base64_string.startswith('data:image'): + base64_string = base64_string.split(',')[1] + + # Decode base64 + image_data = base64.b64decode(base64_string) + + # Convert to PIL Image + image = Image.open(io.BytesIO(image_data)) + + # Convert to RGB if necessary + if image.mode != 'RGB': + image = image.convert('RGB') + + # Convert to numpy array + image_array = np.array(image).astype(np.float32) / 255.0 + + # Convert to tensor with batch dimension + image_tensor = torch.from_numpy(image_array)[None,] + + return image_tensor + + except Exception as e: + raise Exception(f"Failed to decode base64 image: {str(e)}") + + def get_resolution_from_aspect_ratio(self, aspect_ratio): + """Get width and height from aspect ratio (1.5K resolution)""" + resolution_map = { + "1:1": (1536, 1536), + "4:3": (1472, 1104), + "3:2": (1584, 1056), + "16:9": (1664, 936), + "9:16": (936, 1664), + "21:9": (2016, 864) + } + return resolution_map.get(aspect_ratio, (1536, 1536)) + + def generate_image(self, access_key, secret_key, prompt, use_pre_llm=False, + seed=-1, guidance_scale=2.5, aspect_ratio="1:1", return_url=True): + """ + Generate image using Volcengine SeeDream V3 API + """ + try: + # Validate inputs + if not access_key or not secret_key: + raise ValueError("Access key and secret key are required") + + if not prompt.strip(): + raise ValueError("Prompt cannot be empty") + + # Get resolution from aspect ratio + width, height = self.get_resolution_from_aspect_ratio(aspect_ratio) + + # Prepare query parameters + query_params = { + 'Action': 'CVProcess', + 'Version': '2022-08-31', + } + formatted_query = self.format_query(query_params) + + # Prepare body parameters + body_params = { + "req_key": "high_aes_general_v30l_zt2i", + "prompt": prompt, + "use_pre_llm": use_pre_llm, + "seed": seed, + "scale": guidance_scale, + "width": width, + "height": height, + "return_url": return_url + } + formatted_body = json.dumps(body_params) + + # Sign the request + headers = self.sign_v4_request(access_key, secret_key, self.service, + formatted_query, formatted_body) + + # Make the request + request_url = f"{self.endpoint}?{formatted_query}" + + print(f"Making request to Volcengine SeeDream V3 API...") + print(f"Resolution: {aspect_ratio} ({width}x{height})") + print(f"Prompt: {prompt[:100]}...") + + response = requests.post(request_url, headers=headers, data=formatted_body, timeout=60) + + if response.status_code != 200: + raise Exception(f"API request failed with status {response.status_code}: {response.text}") + + # Parse response + result = response.json() + + if 'ResponseMetadata' in result and result['ResponseMetadata'].get('Error'): + error_info = result['ResponseMetadata']['Error'] + raise Exception(f"API Error: {error_info.get('Message', 'Unknown error')}") + + # Extract image data + if 'Result' not in result or 'data' not in result['Result']: + raise Exception("No image data in API response") + + data = result['Result']['data'] + + # Handle URL or base64 response + if return_url and 'image_url' in data: + # Download image from URL + image_url = data['image_url'] + print(f"Downloading image from URL: {image_url}") + image_tensor = self.download_image_from_url(image_url) + + elif 'binary_data_base64' in data: + # Decode base64 image + print("Decoding base64 image data...") + base64_data = data['binary_data_base64'][0] if isinstance(data['binary_data_base64'], list) else data['binary_data_base64'] + image_tensor = self.decode_base64_image(base64_data) + + else: + raise Exception("No valid image data found in API response") + + print("Image generated successfully!") + return (image_tensor,) + + except Exception as e: + print(f"Error generating image: {str(e)}") + # Return a blank image in case of error + blank_image = torch.zeros((1, height, width, 3), dtype=torch.float32) + return (blank_image,) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eb20200 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.28.0 +Pillow>=9.0.0 \ No newline at end of file