Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a7ea89870 | ||
|
|
1da964609d | ||
|
|
415254023c | ||
|
|
e309b8800b | ||
|
|
cf5ee1afd9 | ||
|
|
aa8eeaaccd | ||
|
|
c57a4577fd | ||
|
|
86ee20fc77 | ||
|
|
c9cba61305 | ||
|
|
3350c25aa0 | ||
|
|
6ca863f304 | ||
|
|
680773535e | ||
|
|
379b4c8b52 | ||
|
|
1003d8636d | ||
|
|
828a2ce25a | ||
|
|
8a568fac1f | ||
|
|
bc136f9295 | ||
|
|
62011d840a | ||
|
|
02364afa5f | ||
|
|
b67b389b2c | ||
|
|
0e4e218ccd | ||
|
|
c82f9806ef | ||
|
|
32e9fa10dc | ||
|
|
afc2435e1d | ||
|
|
27e49d06a9 | ||
|
|
f23de5ec01 | ||
|
|
ea66351318 | ||
|
|
ad927e382a | ||
|
|
9e224d1813 |
+62
@@ -0,0 +1,62 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
*.pyd
|
||||
*.pyo
|
||||
*.pdb
|
||||
*.egg-info/
|
||||
*.egg
|
||||
*.log
|
||||
|
||||
# Jupyter
|
||||
.ipynb_checkpoints/
|
||||
|
||||
# VSCode
|
||||
.vscode/
|
||||
|
||||
# 环境/依赖
|
||||
.env
|
||||
.venv
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.temp
|
||||
*.bak
|
||||
*.swp
|
||||
*.swo
|
||||
*.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 数据/输出
|
||||
output/
|
||||
*.ckpt
|
||||
*.pth
|
||||
*.h5
|
||||
*.pt
|
||||
*.onnx
|
||||
*.npz
|
||||
*.npy
|
||||
*.wav
|
||||
*.mp4
|
||||
*.avi
|
||||
*.mov
|
||||
*.mkv
|
||||
*.webm
|
||||
*.gif
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.bmp
|
||||
*.tiff
|
||||
*.tif
|
||||
|
||||
# 云配置
|
||||
cloud_config.json
|
||||
|
||||
# 其他
|
||||
*.sqlite3
|
||||
*.db
|
||||
*.lock
|
||||
@@ -1,6 +1,10 @@
|
||||
# comfyUI_LLM
|
||||
comfyUI_LLM 是在comfyUI中集成大语言模型
|
||||
|
||||
## 继承deepseek(本地模型)
|
||||
## 集成deepseek(本地模型)
|
||||
更新:
|
||||

|
||||
|
||||
|
||||
示例:
|
||||

|
||||
|
||||
+19
-2
@@ -1,3 +1,20 @@
|
||||
from .ollama_node import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
import importlib
|
||||
import os
|
||||
import pkgutil
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
# 动态加载 node 目录下所有节点模块
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
|
||||
package = __name__
|
||||
node_pkg = f"{package}.node"
|
||||
|
||||
for _, modname, ispkg in pkgutil.iter_modules([os.path.join(os.path.dirname(__file__), 'node')]):
|
||||
if not ispkg:
|
||||
module = importlib.import_module(f"{node_pkg}.{modname}")
|
||||
if hasattr(module, "NODE_CLASS_MAPPINGS"):
|
||||
NODE_CLASS_MAPPINGS.update(getattr(module, "NODE_CLASS_MAPPINGS"))
|
||||
if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS"):
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(getattr(module, "NODE_DISPLAY_NAME_MAPPINGS"))
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
"bucket_name": "dyaigc",
|
||||
"input_dir": "input",
|
||||
"output_dir": "output",
|
||||
"domain": "swqqsa5wv.hb-bkt.clouddn.com"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import os
|
||||
import json
|
||||
|
||||
|
||||
def load_cloud_config(config_path=None):
|
||||
"""
|
||||
通用多云配置读取工具方法,返回 (cloud_type, config) 元组。
|
||||
config_path 可选,默认自动定位到 cloud_config.json。
|
||||
"""
|
||||
if config_path is None:
|
||||
# 优先本目录下 cloud_config.json
|
||||
local_path = os.path.join(os.path.dirname(__file__), "cloud_config.json")
|
||||
parent_path = os.path.join(os.path.dirname(__file__), "..", "cloud_config.json")
|
||||
if os.path.exists(local_path):
|
||||
config_path = local_path
|
||||
elif os.path.exists(parent_path):
|
||||
config_path = parent_path
|
||||
else:
|
||||
# 返回空配置,避免节点加载时报错
|
||||
return "qiniu", {"access_key": "", "secret_key": "", "bucket_name": "", "domain": ""}
|
||||
if not os.path.exists(config_path):
|
||||
return "qiniu", {"access_key": "", "secret_key": "", "bucket_name": "", "domain": ""}
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
cloud_type = config.get("cloud_type", "qiniu")
|
||||
cloud_config = config.get(cloud_type, {})
|
||||
return cloud_type, cloud_config
|
||||
|
||||
|
||||
class CloudUploader:
|
||||
"""
|
||||
云存储上传抽象基类,所有云厂商需实现 upload_binary 方法
|
||||
"""
|
||||
|
||||
def upload_binary(self, data: bytes, key: str = None) -> str:
|
||||
raise NotImplementedError("upload_binary 必须由子类实现")
|
||||
@@ -0,0 +1,41 @@
|
||||
app.registerExtension({
|
||||
name: "comfy.ollama.widgets",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name === "ComfyUI_LLM_Ollama") {
|
||||
// 重写节点模板
|
||||
const originalOnExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
originalOnExecuted?.apply(this, arguments);
|
||||
|
||||
// 添加可调整大小的文本域
|
||||
setTimeout(() => {
|
||||
// 为prompt和system_message添加特性
|
||||
this.querySelectorAll(
|
||||
'textarea[data-input-name="prompt"],' +
|
||||
'textarea[data-input-name="system_message"]'
|
||||
).forEach(textarea => {
|
||||
// 添加可调整样式
|
||||
textarea.style.resize = 'vertical';
|
||||
textarea.style.minHeight = '100px';
|
||||
textarea.style.overflowY = 'auto';
|
||||
|
||||
// 添加拖动记忆功能
|
||||
const storeKey = `ollamaTextareaSize_${nodeData.name}_${textarea.dataset.inputName}`;
|
||||
|
||||
// 从本地存储读取高度
|
||||
const savedHeight = localStorage.getItem(storeKey);
|
||||
if (savedHeight) {
|
||||
textarea.style.height = savedHeight + 'px';
|
||||
}
|
||||
|
||||
// 监听高度变化
|
||||
textarea.addEventListener('mouseup', () => {
|
||||
localStorage.setItem(storeKey, textarea.offsetHeight);
|
||||
});
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/* 自定义文本域样式 */
|
||||
.comfy-ollama-textarea {
|
||||
background: #f8f8f8;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
font-family: monospace;
|
||||
border: 1px solid #ccc;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.comfy-ollama-textarea:focus {
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import os
|
||||
from qiniu import Auth, put_data
|
||||
import io
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
import uuid
|
||||
import os
|
||||
import uuid
|
||||
import tempfile
|
||||
import subprocess
|
||||
import imageio_ffmpeg
|
||||
from ..cloud_utils import load_cloud_config, CloudUploader
|
||||
|
||||
|
||||
class QiniuUploader:
|
||||
def __init__(self, access_key: str, secret_key: str, bucket_name: str, domain: str,):
|
||||
self.access_key = access_key
|
||||
self.secret_key = secret_key
|
||||
self.bucket_name = bucket_name
|
||||
self.domain = domain
|
||||
self.q = Auth(self.access_key, self.secret_key)
|
||||
|
||||
def upload_binary(self, data: bytes, key: str = None) -> str:
|
||||
"""
|
||||
上传二进制数据到七牛云
|
||||
:param data: 二进制内容
|
||||
:param key: 文件名(可选),不传则由七牛自动生成
|
||||
:return: 文件外链URL
|
||||
"""
|
||||
token = self.q.upload_token(self.bucket_name, key, 3600)
|
||||
ret, info = put_data(token, key, data)
|
||||
print(f"七牛 put_data 返回 ret: {ret}, info: {info}") # 打印上传结果
|
||||
# 七牛 put_data 返回 ret 可能为 None,info.key 才是真实 key
|
||||
real_key = None
|
||||
if ret is not None and 'key' in ret:
|
||||
real_key = ret['key']
|
||||
elif hasattr(info, 'key') and info.key:
|
||||
real_key = info.key
|
||||
if real_key:
|
||||
url = f"{self.domain}/{real_key}"
|
||||
if not url.startswith("http://") and not url.startswith("https://"):
|
||||
url = "http://" + url.lstrip("/")
|
||||
return url
|
||||
else:
|
||||
raise Exception(f"上传失败: {info}")
|
||||
|
||||
# class JDCloudUploader(CloudUploader):
|
||||
# """
|
||||
# 京东云对象存储上传实现(需安装 jdcloud-sdk-python)
|
||||
# """
|
||||
# def __init__(self, access_key: str, secret_key: str, bucket_name: str, domain: str, region: str = "cn-north-1"):
|
||||
# from jdcloud_sdk.services.oss.client.OssClient import OssClient
|
||||
# from jdcloud_sdk.core.credential import Credential
|
||||
# self.access_key = access_key
|
||||
# self.secret_key = secret_key
|
||||
# self.bucket_name = bucket_name
|
||||
# self.domain = domain
|
||||
# self.region = region
|
||||
# self.credential = Credential(self.access_key, self.secret_key)
|
||||
# self.client = OssClient(self.credential)
|
||||
|
||||
# def upload_binary(self, data: bytes, key: str = None) -> str:
|
||||
# if not key:
|
||||
# import uuid
|
||||
# key = uuid.uuid4().hex
|
||||
# resp = self.client.put_object(self.bucket_name, key, body=data)
|
||||
# if hasattr(resp, "error") and resp.error:
|
||||
# raise Exception(f"京东云上传失败: {resp.error}")
|
||||
# url = f"{self.domain}/{key}"
|
||||
# if not url.startswith("http://") and not url.startswith("https://"):
|
||||
# url = "http://" + url.lstrip("/")
|
||||
# return url
|
||||
|
||||
class CloudImageUploadNode:
|
||||
"""
|
||||
ComfyUI 图片张量直接上传云节点
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
cloud_type, config = load_cloud_config()
|
||||
return {
|
||||
"required": {
|
||||
"access_key": ("STRING", {"default": config.get("access_key", "")}),
|
||||
"secret_key": ("STRING", {"default": config.get("secret_key", "")}),
|
||||
"bucket_name": ("STRING", {"default": config.get("bucket_name", "")}),
|
||||
"domain": ("STRING", {"default": config.get("domain", "")}),
|
||||
"images": ("IMAGE", ),
|
||||
"folder": ("STRING", {"default": "output"}),
|
||||
"key_prefix": ("STRING", {"default": "comfyui_"}),
|
||||
"format": (["PNG", "JPEG", "GIF"], {"default": "PNG"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("urls",)
|
||||
FUNCTION = "upload_images"
|
||||
CATEGORY = "云服务"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def upload_images(self, access_key, secret_key, bucket_name, domain, images, folder, key_prefix, format):
|
||||
cloud_type, _ = load_cloud_config()
|
||||
if cloud_type == "jdcloud":
|
||||
# uploader = JDCloudUploader(access_key, secret_key, bucket_name, domain)
|
||||
raise NotImplementedError(f"暂不支持的云类型: {cloud_type}")
|
||||
else:
|
||||
uploader = QiniuUploader(access_key, secret_key, bucket_name, domain)
|
||||
urls = []
|
||||
arr = images.cpu().numpy() if hasattr(images, 'cpu') else images
|
||||
for idx, image in enumerate(arr):
|
||||
img = Image.fromarray(np.clip(255. * image, 0, 255).astype(np.uint8))
|
||||
buf = io.BytesIO()
|
||||
if format == "GIF":
|
||||
img = img.convert("P", palette=Image.ADAPTIVE)
|
||||
img.save(buf, format="GIF")
|
||||
else:
|
||||
img.save(buf, format=format)
|
||||
buf.seek(0)
|
||||
random_name = uuid.uuid4().hex
|
||||
# 拼接文件夹路径
|
||||
folder_path = folder.strip().strip('/')
|
||||
key = f"{folder_path}/{key_prefix}{random_name}.{format.lower()}"
|
||||
url = uploader.upload_binary(buf.getvalue(), key)
|
||||
print(f"上传图片返回 url: {url}, 类型: {type(url)}")
|
||||
urls.append(str(url))
|
||||
return (urls,)
|
||||
|
||||
class CloudVideoUploadNode:
|
||||
"""
|
||||
ComfyUI 视频文件上传云节点
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
cloud_type, config = load_cloud_config()
|
||||
return {
|
||||
"required": {
|
||||
"access_key": ("STRING", {"default": config.get("access_key", "")}),
|
||||
"secret_key": ("STRING", {"default": config.get("secret_key", "")}),
|
||||
"bucket_name": ("STRING", {"default": config.get("bucket_name", "")}),
|
||||
"domain": ("STRING", {"default": config.get("domain", "")}),
|
||||
"video_path": ("STRING", {"default": ""}),
|
||||
"folder": ("STRING", {"default": "video"}),
|
||||
"key_prefix": ("STRING", {"default": "comfyui_"}),
|
||||
"ext": (["mp4", "mov", "avi", "mkv"], {"default": "mp4"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("url",)
|
||||
FUNCTION = "upload_video"
|
||||
CATEGORY = "云服务"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def upload_video(self, access_key, secret_key, bucket_name, domain, video_path, folder, key_prefix, ext):
|
||||
cloud_type, _ = load_cloud_config()
|
||||
if cloud_type == "jdcloud":
|
||||
# uploader = JDCloudUploader(access_key, secret_key, bucket_name, domain)
|
||||
raise NotImplementedError(f"暂不支持的云类型: {cloud_type}")
|
||||
else:
|
||||
uploader = QiniuUploader(access_key, secret_key, bucket_name, domain)
|
||||
if not os.path.isfile(video_path):
|
||||
raise FileNotFoundError(f"视频文件不存在: {video_path}")
|
||||
with open(video_path, "rb") as f:
|
||||
data = f.read()
|
||||
random_name = uuid.uuid4().hex
|
||||
folder_path = folder.strip().strip('/')
|
||||
key = f"{folder_path}/{key_prefix}{random_name}.{ext}"
|
||||
url = uploader.upload_binary(data, key)
|
||||
print(f"上传视频返回 url: {url}, 类型: {type(url)}")
|
||||
return (url,)
|
||||
|
||||
class CloudImagesToVideoAndUpload:
|
||||
"""
|
||||
将图片张量序列合成为视频并上传到云存储(支持多云扩展)
|
||||
输入: 图片张量 (N, H, W, C)
|
||||
输出: 云存储视频URL
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
cloud_type, config = load_cloud_config()
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"fps": ("INT", {"default": 12, "min": 1, "max": 60}),
|
||||
"cloud_type": ("STRING", {"default": cloud_type}),
|
||||
"access_key": ("STRING", {"default": config["access_key"]}),
|
||||
"secret_key": ("STRING", {"default": config["secret_key"]}),
|
||||
"bucket_name": ("STRING", {"default": config["bucket_name"]}),
|
||||
"domain": ("STRING", {"default": config["domain"]}),
|
||||
"folder": ("STRING", {"default": "video"}),
|
||||
"key_prefix": ("STRING", {"default": "comfyui_"}),
|
||||
"ext": (["mp4", "mov", "avi", "mkv"], {"default": "mp4"}),
|
||||
"audio": ("AUDIO", {"default": None}), # 改为AUDIO类型
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("url",)
|
||||
FUNCTION = "images_to_video_and_upload"
|
||||
CATEGORY = "云服务"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def images_to_video_and_upload(self, images, fps, cloud_type, access_key, secret_key, bucket_name, domain, folder, key_prefix, ext, audio=None):
|
||||
config = load_cloud_config()[1]
|
||||
access_key = access_key or config.get("access_key", "")
|
||||
secret_key = secret_key or config.get("secret_key", "")
|
||||
bucket_name = bucket_name or config.get("bucket_name", "")
|
||||
domain = domain or config.get("domain", "")
|
||||
# 1. 张量转图片序列
|
||||
arr = images.cpu().numpy() if hasattr(images, 'cpu') else images
|
||||
img_list = [Image.fromarray(np.clip(255. * img, 0, 255).astype(np.uint8)) for img in arr]
|
||||
# 2. 合成视频
|
||||
height, width = img_list[0].height, img_list[0].width
|
||||
pix_fmt = 'rgb24'
|
||||
with tempfile.NamedTemporaryFile(suffix=f'.{ext}', delete=False) as tmpfile:
|
||||
tmp_path = tmpfile.name
|
||||
# 先生成无音频视频,临时文件名用 _noaudio 结尾但扩展名标准
|
||||
tmp_video_path = tmp_path.replace(f'.{ext}', f'_noaudio.{ext}')
|
||||
cmd = [
|
||||
imageio_ffmpeg.get_ffmpeg_exe(),
|
||||
'-y',
|
||||
'-f', 'rawvideo',
|
||||
'-vcodec', 'rawvideo',
|
||||
'-s', f'{width}x{height}',
|
||||
'-pix_fmt', pix_fmt,
|
||||
'-r', str(fps),
|
||||
'-i', '-',
|
||||
'-an',
|
||||
'-vcodec', 'libx264',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
tmp_video_path
|
||||
]
|
||||
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
|
||||
for im in img_list:
|
||||
proc.stdin.write(im.convert('RGB').tobytes())
|
||||
proc.stdin.close()
|
||||
proc.wait()
|
||||
# 如果有AUDIO,保存为wav临时文件再合成
|
||||
def is_valid_audio(audio):
|
||||
if not (audio and isinstance(audio, dict) and "waveform" in audio and "sample_rate" in audio):
|
||||
return False
|
||||
waveform = audio["waveform"]
|
||||
if not hasattr(waveform, 'numel') or waveform.numel() == 0:
|
||||
return False
|
||||
if waveform.abs().sum().item() == 0:
|
||||
return False
|
||||
return True
|
||||
if is_valid_audio(audio):
|
||||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp_audio:
|
||||
audio_path = tmp_audio.name
|
||||
waveform = audio["waveform"]
|
||||
sample_rate = audio["sample_rate"]
|
||||
# waveform shape: (1, channels, samples) or (channels, samples)
|
||||
if waveform.dim() == 3:
|
||||
waveform = waveform.squeeze(0)
|
||||
import torchaudio
|
||||
torchaudio.save(audio_path, waveform, sample_rate)
|
||||
# 合成音视频到tmp_path
|
||||
merge_cmd = [
|
||||
imageio_ffmpeg.get_ffmpeg_exe(),
|
||||
'-y',
|
||||
'-i', tmp_video_path,
|
||||
'-i', audio_path,
|
||||
'-c:v', 'copy',
|
||||
'-c:a', 'aac',
|
||||
'-shortest',
|
||||
tmp_path
|
||||
]
|
||||
subprocess.run(merge_cmd, check=True)
|
||||
os.remove(tmp_video_path)
|
||||
os.remove(audio_path)
|
||||
else:
|
||||
# 无音频直接重命名
|
||||
os.rename(tmp_video_path, tmp_path)
|
||||
# 3. 上传到云存储
|
||||
if cloud_type == "qiniu":
|
||||
uploader = QiniuUploader(access_key, secret_key, bucket_name, domain)
|
||||
elif cloud_type == "jdcloud":
|
||||
# uploader = JDCloudUploader(access_key, secret_key, bucket_name, domain)
|
||||
raise NotImplementedError(f"暂不支持的云类型: {cloud_type}")
|
||||
random_name = uuid.uuid4().hex
|
||||
folder_path = folder.strip().strip('/')
|
||||
key = f"{folder_path}/{key_prefix}{random_name}.{ext}"
|
||||
with open(tmp_path, "rb") as f:
|
||||
data = f.read()
|
||||
url = uploader.upload_binary(data, key)
|
||||
os.remove(tmp_path)
|
||||
return (url,)
|
||||
|
||||
# 注册到节点映射
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"CloudImageUploadNode": CloudImageUploadNode,
|
||||
"CloudVideoUploadNode": CloudVideoUploadNode,
|
||||
"CloudImagesToVideoAndUpload": CloudImagesToVideoAndUpload
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"CloudImageUploadNode": "☁️ 图片上传到云 (IMAGE)",
|
||||
"CloudVideoUploadNode": "☁️ 视频上传到云 (VIDEO)",
|
||||
"CloudImagesToVideoAndUpload": "🖼️图片合成视频并上传到云"
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
from PIL import Image, ImageOps
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
import requests
|
||||
import torch
|
||||
import numpy as np
|
||||
|
||||
class LoadImgFromUrl:
|
||||
"""Load an image from the given URL"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"url": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "http://swqqsa5wv.hb-bkt.clouddn.com/admin/comfyui_b46dcb7133234c549218bf957a6334ed.png"
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "load"
|
||||
CATEGORY = "云服务"
|
||||
|
||||
def load(self, url):
|
||||
response = requests.get(url, stream=True, timeout=10)
|
||||
response.raise_for_status()
|
||||
image = Image.open(response.raw)
|
||||
image = ImageOps.exif_transpose(image)
|
||||
np_image = np.array(image).astype(np.float32) / 255.0
|
||||
if np_image.ndim == 2: # 灰度图
|
||||
np_image = np.expand_dims(np_image, axis=-1)
|
||||
# 保证输出为 (1, H, W, C)
|
||||
tensor = torch.from_numpy(np_image).unsqueeze(0)
|
||||
return (tensor,)
|
||||
|
||||
|
||||
class LoadGifFromLocal:
|
||||
"""从本地路径加载GIF图片(返回所有帧的张量)"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"path": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "your_local_gif_path.gif"
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("images",)
|
||||
FUNCTION = "load"
|
||||
CATEGORY = "本地文件"
|
||||
|
||||
def load(self, path):
|
||||
images = []
|
||||
with Image.open(path) as im:
|
||||
for frame in range(im.n_frames):
|
||||
im.seek(frame)
|
||||
frame_img = ImageOps.exif_transpose(im.convert("RGBA"))
|
||||
np_image = np.array(frame_img).astype(np.float32) / 255.0
|
||||
if np_image.ndim == 2: # 灰度图
|
||||
np_image = np.expand_dims(np_image, axis=-1)
|
||||
images.append(torch.from_numpy(np_image))
|
||||
# 输出为 (帧数, H, W, C)
|
||||
tensor = torch.stack(images, dim=0)
|
||||
return (tensor,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"LoadImgFromUrl": LoadImgFromUrl,
|
||||
"LoadGifFromLocal": LoadGifFromLocal
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"LoadImgFromUrl": "☁️ 加载图片",
|
||||
"LoadGifFromLocal": "📂 加载本地GIF"
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
多路径文件批量加载节点。
|
||||
|
||||
用途:一个节点解析用户传入的字符串(多种分隔符),按类型(图片/视频/音频)
|
||||
分别输出到固定数量的端口。下游若某个端口无输入需要,直接不连线即可 ——
|
||||
本节点对"没有对应文件"的端口返回 None,ComfyUI 不会执行未连线的下游,
|
||||
从而保证一个工作流适配 0~N 个素材。
|
||||
|
||||
默认端口数量与 MiniMaxH3ReferenceToVideo 对齐:9 图 / 3 视频 / 3 音频。
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
# 重量级依赖延迟到函数内 import,方便离线做纯字符串逻辑的单测
|
||||
try:
|
||||
import folder_paths # ComfyUI 运行环境注入
|
||||
except Exception: # 允许脱离 ComfyUI 时被静态解析
|
||||
folder_paths = None
|
||||
|
||||
|
||||
# 与 MiniMax H3 对齐的默认端口数量
|
||||
MAX_IMAGES = 9
|
||||
MAX_VIDEOS = 3
|
||||
MAX_AUDIOS = 3
|
||||
|
||||
# 支持的扩展名(判断类型用;用户显式把文件填到哪个字段就走哪个字段)
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tif", ".tiff"}
|
||||
VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".avi", ".webm", ".flv", ".m4v"}
|
||||
AUDIO_EXTS = {".wav", ".mp3", ".flac", ".m4a", ".aac", ".ogg", ".opus"}
|
||||
|
||||
# 拆分正则:逗号/分号/竖线/花括号/中英文引号/换行/制表符/多空格
|
||||
# 反斜杠 \ 也作为分隔符(用户描述里提到),但注意 Windows 路径本身含 \,
|
||||
# 因此优先尝试解析为原始路径,只有当整体不是合法文件时才用 \ 拆一次。
|
||||
_SPLIT_PATTERN = re.compile(r"[,;|\n\r\t{}\[\]\"\u201c\u201d\u2018\u2019']+")
|
||||
# 看起来像 Windows 绝对路径(C:\..., D:/...)
|
||||
_WIN_ABS = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
|
||||
|
||||
def _looks_absolute(p):
|
||||
"""跨平台的"看起来是绝对路径"判断:兼顾 POSIX 和 Windows。"""
|
||||
return os.path.isabs(p) or bool(_WIN_ABS.match(p))
|
||||
|
||||
|
||||
def _split_paths(text):
|
||||
"""把一段用户输入的字符串切成候选路径列表。
|
||||
|
||||
规则:
|
||||
1. 先按正则拆一遍
|
||||
2. 每段 strip 掉空白和成对包裹的引号
|
||||
3. 单个段落如果不是已存在的文件,再尝试按空格/反斜杠二次拆分
|
||||
(这样 "C:\\a.png" 这种 Windows 绝对路径整体不会被误拆)
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
raw_parts = _SPLIT_PATTERN.split(text)
|
||||
out = []
|
||||
for p in raw_parts:
|
||||
p = p.strip().strip("\"'\u201c\u201d\u2018\u2019")
|
||||
if not p:
|
||||
continue
|
||||
if os.path.isfile(p) or _looks_absolute(p):
|
||||
# 整段就是合法文件(或至少是绝对路径),直接收
|
||||
out.append(p)
|
||||
continue
|
||||
# 二次拆:空格和反斜杠。到这里说明段里可能塞了多个相对路径
|
||||
for q in re.split(r"[\s\\]+", p):
|
||||
q = q.strip()
|
||||
if q:
|
||||
out.append(q)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve(path):
|
||||
"""把路径解析成一个真实存在的文件路径。找不到返回 None。
|
||||
|
||||
顺序:绝对路径 → ComfyUI input 目录相对路径。
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
if folder_paths is not None:
|
||||
candidate = os.path.join(folder_paths.get_input_directory(), path)
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _guess_kind(path):
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext in IMAGE_EXTS:
|
||||
return "image"
|
||||
if ext in VIDEO_EXTS:
|
||||
return "video"
|
||||
if ext in AUDIO_EXTS:
|
||||
return "audio"
|
||||
return None
|
||||
|
||||
|
||||
# ---------- 加载器 ----------
|
||||
|
||||
def _load_image(path):
|
||||
"""PIL 加载 → (1, H, W, C) float32 in [0,1]。"""
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageOps
|
||||
with Image.open(path) as im:
|
||||
im = ImageOps.exif_transpose(im)
|
||||
if im.mode == "RGBA":
|
||||
im = im.convert("RGBA")
|
||||
elif im.mode != "RGB":
|
||||
im = im.convert("RGB")
|
||||
arr = np.array(im).astype(np.float32) / 255.0
|
||||
if arr.ndim == 2:
|
||||
arr = np.expand_dims(arr, -1)
|
||||
tensor = torch.from_numpy(arr).unsqueeze(0)
|
||||
return tensor
|
||||
|
||||
|
||||
def _extract_audio(video_path):
|
||||
"""用 ffmpeg 抽视频音轨为 wav 再读入。没有音轨返回静音 dict。"""
|
||||
import torch
|
||||
sample_rate = 44100
|
||||
silent = {
|
||||
"waveform": torch.zeros((1, 2, 1), dtype=torch.float32),
|
||||
"sample_rate": sample_rate,
|
||||
}
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
tmp_path = tmp.name
|
||||
tmp.close()
|
||||
try:
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-i", video_path,
|
||||
"-vn", "-acodec", "pcm_s16le", "-ar", str(sample_rate), "-ac", "2",
|
||||
"-f", "wav", tmp_path,
|
||||
]
|
||||
subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if not os.path.exists(tmp_path) or os.path.getsize(tmp_path) == 0:
|
||||
return silent
|
||||
import torchaudio
|
||||
waveform, sr = torchaudio.load(tmp_path)
|
||||
if waveform.dim() == 2:
|
||||
waveform = waveform.unsqueeze(0)
|
||||
return {"waveform": waveform, "sample_rate": sr}
|
||||
except Exception as e:
|
||||
print(f"[MultiFileLoader] 抽取音轨失败,返回静音:{e}")
|
||||
return silent
|
||||
finally:
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _load_video(path):
|
||||
"""加载视频为 (T, H, W, C) 帧序列 + 音轨 AUDIO dict。"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
import torch
|
||||
cap = cv2.VideoCapture(path)
|
||||
if not cap.isOpened():
|
||||
raise FileNotFoundError(f"无法打开视频文件: {path}")
|
||||
frames = []
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
frames.append(torch.from_numpy(frame.astype(np.float32) / 255.0))
|
||||
cap.release()
|
||||
if not frames:
|
||||
raise ValueError(f"视频读取到 0 帧: {path}")
|
||||
return torch.stack(frames, dim=0), _extract_audio(path)
|
||||
|
||||
|
||||
def _load_audio(path):
|
||||
"""加载独立音频文件为 ComfyUI AUDIO dict。"""
|
||||
import torchaudio
|
||||
waveform, sr = torchaudio.load(path)
|
||||
if waveform.dim() == 2:
|
||||
waveform = waveform.unsqueeze(0)
|
||||
return {"waveform": waveform, "sample_rate": sr}
|
||||
|
||||
|
||||
# ---------- 节点主体 ----------
|
||||
|
||||
class MultiFileLoader:
|
||||
"""一次解析多组路径字符串,按类型分发到固定数量的输出端口。
|
||||
|
||||
- 未使用的端口返回 None,下游不连线即安全(ComfyUI 不会执行未连线的下游)。
|
||||
- 视频端口成对:video_k 输出帧序列 IMAGE,video_audio_k 输出该视频的音轨 AUDIO。
|
||||
若视频无音轨则输出静音 dict,避免下游 AudioLoader 报错。
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"optional": {
|
||||
"image_paths": ("STRING", {
|
||||
"multiline": True, "default": "",
|
||||
"tooltip": "图片路径,支持 , ; | 换行 空格 引号 花括号 等分隔",
|
||||
}),
|
||||
"video_paths": ("STRING", {
|
||||
"multiline": True, "default": "",
|
||||
"tooltip": "视频路径,同上分隔规则",
|
||||
}),
|
||||
"audio_paths": ("STRING", {
|
||||
"multiline": True, "default": "",
|
||||
"tooltip": "音频路径,同上分隔规则",
|
||||
}),
|
||||
"auto_classify": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "开启时会按扩展名自动纠正类型(比如把 mp4 从 image_paths 归到视频输出)",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
# 动态生成 RETURN_TYPES/RETURN_NAMES
|
||||
RETURN_TYPES = (
|
||||
*(("IMAGE",) * MAX_IMAGES),
|
||||
*(("IMAGE", "AUDIO") * MAX_VIDEOS),
|
||||
*(("AUDIO",) * MAX_AUDIOS),
|
||||
"INT", "INT", "INT",
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
*(f"image_{i + 1}" for i in range(MAX_IMAGES)),
|
||||
*(name for i in range(MAX_VIDEOS) for name in (f"video_{i + 1}", f"video_audio_{i + 1}")),
|
||||
*(f"audio_{i + 1}" for i in range(MAX_AUDIOS)),
|
||||
"image_count", "video_count", "audio_count",
|
||||
)
|
||||
FUNCTION = "load"
|
||||
CATEGORY = "云服务/多路加载"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
def load(self, image_paths="", video_paths="", audio_paths="", auto_classify=True):
|
||||
# 1. 拆分三组字符串
|
||||
raw = {
|
||||
"image": _split_paths(image_paths),
|
||||
"video": _split_paths(video_paths),
|
||||
"audio": _split_paths(audio_paths),
|
||||
}
|
||||
|
||||
# 2. 解析真实路径 + 类型分桶
|
||||
bucket = {"image": [], "video": [], "audio": []}
|
||||
for declared_kind, paths in raw.items():
|
||||
for p in paths:
|
||||
resolved = _resolve(p)
|
||||
if resolved is None:
|
||||
print(f"[MultiFileLoader] 找不到文件,跳过:{p}")
|
||||
continue
|
||||
kind = declared_kind
|
||||
if auto_classify:
|
||||
guessed = _guess_kind(resolved)
|
||||
if guessed is not None and guessed != declared_kind:
|
||||
print(f"[MultiFileLoader] 自动纠正类型 {declared_kind}→{guessed}: {resolved}")
|
||||
kind = guessed
|
||||
bucket[kind].append(resolved)
|
||||
|
||||
# 3. 数量校验(超出直接截断,给个 warning)
|
||||
if len(bucket["image"]) > MAX_IMAGES:
|
||||
print(f"[MultiFileLoader] 图片超过上限 {MAX_IMAGES},多余的已忽略")
|
||||
bucket["image"] = bucket["image"][:MAX_IMAGES]
|
||||
if len(bucket["video"]) > MAX_VIDEOS:
|
||||
print(f"[MultiFileLoader] 视频超过上限 {MAX_VIDEOS},多余的已忽略")
|
||||
bucket["video"] = bucket["video"][:MAX_VIDEOS]
|
||||
if len(bucket["audio"]) > MAX_AUDIOS:
|
||||
print(f"[MultiFileLoader] 音频超过上限 {MAX_AUDIOS},多余的已忽略")
|
||||
bucket["audio"] = bucket["audio"][:MAX_AUDIOS]
|
||||
|
||||
# 4. 逐个加载,未填满的端口填 None
|
||||
image_outs = [None] * MAX_IMAGES
|
||||
for i, p in enumerate(bucket["image"]):
|
||||
try:
|
||||
image_outs[i] = _load_image(p)
|
||||
except Exception as e:
|
||||
print(f"[MultiFileLoader] 加载图片失败 {p}: {e}")
|
||||
|
||||
video_frame_outs = [None] * MAX_VIDEOS
|
||||
video_audio_outs = [None] * MAX_VIDEOS
|
||||
for i, p in enumerate(bucket["video"]):
|
||||
try:
|
||||
frames, audio = _load_video(p)
|
||||
video_frame_outs[i] = frames
|
||||
video_audio_outs[i] = audio
|
||||
except Exception as e:
|
||||
print(f"[MultiFileLoader] 加载视频失败 {p}: {e}")
|
||||
|
||||
audio_outs = [None] * MAX_AUDIOS
|
||||
for i, p in enumerate(bucket["audio"]):
|
||||
try:
|
||||
audio_outs[i] = _load_audio(p)
|
||||
except Exception as e:
|
||||
print(f"[MultiFileLoader] 加载音频失败 {p}: {e}")
|
||||
|
||||
# 5. 按 RETURN_NAMES 顺序拼输出
|
||||
result: list = list(image_outs)
|
||||
for f, a in zip(video_frame_outs, video_audio_outs):
|
||||
result.append(f)
|
||||
result.append(a)
|
||||
result.extend(audio_outs)
|
||||
result.extend([
|
||||
sum(1 for x in image_outs if x is not None),
|
||||
sum(1 for x in video_frame_outs if x is not None),
|
||||
sum(1 for x in audio_outs if x is not None),
|
||||
])
|
||||
return tuple(result)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"MultiFileLoader": MultiFileLoader,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"MultiFileLoader": "📦 多路径文件批量加载",
|
||||
}
|
||||
@@ -1,227 +1,231 @@
|
||||
import requests
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import os
|
||||
import threading
|
||||
from threading import Lock
|
||||
from typing import Optional, List, Union, Dict, Any
|
||||
|
||||
# ----------------------------
|
||||
# 日志系统配置
|
||||
# ----------------------------
|
||||
desktop_path = os.path.join(os.path.expanduser("~"), "Desktop")
|
||||
log_path = os.path.join(desktop_path, "ollama_comfyui.log")
|
||||
|
||||
# 强制删除旧日志
|
||||
try:
|
||||
if os.path.exists(log_path):
|
||||
os.remove(log_path)
|
||||
except Exception as e:
|
||||
print(f"旧日志删除失败: {str(e)}")
|
||||
|
||||
# 日志初始化
|
||||
logging.basicConfig(
|
||||
filename=log_path,
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
filemode='w'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.addHandler(logging.StreamHandler()) # 控制台输出
|
||||
|
||||
class ComfyUI_LLM_Ollama:
|
||||
"""
|
||||
Ollama LLM集成节点
|
||||
|
||||
特性:
|
||||
- 支持流式响应生成
|
||||
- 上下文记忆管理
|
||||
- 多线程安全访问
|
||||
- 动态模型列表加载
|
||||
- 详细的日志记录
|
||||
"""
|
||||
|
||||
# 类级共享状态
|
||||
_conn_lock = Lock()
|
||||
_api_lock = Lock()
|
||||
_connection_checked = False
|
||||
_connection_status = False
|
||||
_available_models = ["llama3", "deepseek-r1:7b"] # 默认值
|
||||
|
||||
# 类级日志器 ✅ 修正点1
|
||||
logger = logging.getLogger("ComfyUI-Ollama")
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""动态生成输入配置"""
|
||||
# 在类加载时尝试获取可用模型
|
||||
if not cls._connection_checked:
|
||||
cls._check_connection()
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"prompt": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": "请用简洁的语言回答...",
|
||||
"dynamicPrompts": False
|
||||
}),
|
||||
"model": (cls._available_models, {"default": "deepseek-r1:7b"}),
|
||||
"temperature": ("FLOAT", {
|
||||
"default": 0.7,
|
||||
"min": 0.0,
|
||||
"max": 2.0,
|
||||
"step": 0.1,
|
||||
"display": "slider"
|
||||
}),
|
||||
"max_tokens": ("INT", {
|
||||
"default": 1024,
|
||||
"min": 1,
|
||||
"max": 4096,
|
||||
"step": 64,
|
||||
"display": "number"
|
||||
}),
|
||||
"hide_thoughts": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"context": ("STRING", {"forceInput": True}),
|
||||
"system_message": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": "你是有帮助的AI助手",
|
||||
"lazy": True
|
||||
}),
|
||||
"stop_sequences": ("STRING", {
|
||||
"default": "",
|
||||
"lazy": True
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("response", "context")
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "LLM"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
def __init__(self):
|
||||
self.ollama_url = "http://localhost:11434"
|
||||
self.headers = {"Content-Type": "application/json"}
|
||||
self.timeout = 120
|
||||
self._init_logging() # ✅ 修正点2:实例初始化日志配置
|
||||
|
||||
def _init_logging(self):
|
||||
"""初始化日志系统"""
|
||||
self.logger = logging.getLogger("ComfyUI-Ollama")
|
||||
self.logger.propagate = False
|
||||
if not self.logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter('[%(name)s] %(levelname)s - %(message)s')
|
||||
handler.setFormatter(formatter)
|
||||
self.logger.addHandler(handler)
|
||||
self.logger.setLevel(logging.INFO)
|
||||
|
||||
@classmethod
|
||||
def _check_connection(cls):
|
||||
"""类方法使用类级日志器 ✅ 修正点3"""
|
||||
with cls._conn_lock:
|
||||
if not cls._connection_checked:
|
||||
try:
|
||||
# 使用类级日志器
|
||||
cls.logger.info(f"🛠 正在检查Ollama连接...")
|
||||
|
||||
response = requests.get(
|
||||
"http://localhost:11434/api/tags",
|
||||
timeout=10
|
||||
)
|
||||
if response.status_code == 200:
|
||||
models = response.json().get("models", [])
|
||||
cls._available_models = [m["name"] for m in models]
|
||||
cls._connection_status = True
|
||||
cls.logger.info(f"✅ 可用模型: {cls._available_models}")
|
||||
else:
|
||||
cls.logger.warning(f"连接失败,状态码:{response.status_code}")
|
||||
except Exception as e:
|
||||
cls.logger.error(f"连接异常:{str(e)}")
|
||||
finally:
|
||||
cls._connection_checked = True
|
||||
|
||||
def _build_payload(self, **kwargs):
|
||||
"""构造API请求负载"""
|
||||
stop_sequences = [s.strip() for s in kwargs['stop_sequences'].split(',')] if kwargs['stop_sequences'] else []
|
||||
|
||||
return {
|
||||
"model": kwargs['model'],
|
||||
"prompt": kwargs['prompt'],
|
||||
"system": kwargs.get('system_message', '你是有帮助的AI助手'),
|
||||
"context": self._parse_context(kwargs.get('context')),
|
||||
"options": {
|
||||
"temperature": kwargs['temperature'],
|
||||
"num_predict": kwargs['max_tokens'],
|
||||
"stop": stop_sequences,
|
||||
}
|
||||
}
|
||||
|
||||
def _parse_context(self, context: Optional[str]) -> List:
|
||||
"""解析上下文数据"""
|
||||
try:
|
||||
return json.loads(context) if context else []
|
||||
except json.JSONDecodeError:
|
||||
self.logger.warning("上下文解析失败,使用空上下文")
|
||||
return []
|
||||
|
||||
def _clean_response(self, text: str, hide_thoughts: bool) -> str:
|
||||
"""响应后处理"""
|
||||
if hide_thoughts:
|
||||
return re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
|
||||
return text.strip()
|
||||
|
||||
def generate(self, **kwargs):
|
||||
"""主执行方法"""
|
||||
if not self._connection_status:
|
||||
return ("Ollama服务不可用,请检查", "")
|
||||
|
||||
with self._api_lock:
|
||||
try:
|
||||
payload = self._build_payload(**kwargs)
|
||||
self.logger.debug(f"请求参数:{json.dumps(payload, indent=2)}")
|
||||
|
||||
response_text = ""
|
||||
context = []
|
||||
|
||||
with requests.post(
|
||||
f"{self.ollama_url}/api/generate",
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
stream=True,
|
||||
timeout=self.timeout
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
data = json.loads(line.decode('utf-8'))
|
||||
response_text += data.get("response", "")
|
||||
if data.get("done"):
|
||||
context = data.get("context", [])
|
||||
|
||||
cleaned_response = self._clean_response(response_text, kwargs['hide_thoughts'])
|
||||
self.logger.info(f"📥 响应长度: {len(cleaned_response)}字符")
|
||||
return (cleaned_response, json.dumps(context))
|
||||
|
||||
except requests.RequestException as e:
|
||||
error_msg = f"API请求失败: {str(e)}"
|
||||
self.logger.error(error_msg)
|
||||
return (error_msg, "")
|
||||
except Exception as e:
|
||||
error_msg = f"处理错误: {str(e)}"
|
||||
self.logger.exception(error_msg)
|
||||
return (error_msg, "")
|
||||
|
||||
# 节点注册
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ComfyUI_LLM_Ollama": ComfyUI_LLM_Ollama
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ComfyUI_LLM_Ollama": "🤖 Ollama LLM"
|
||||
import requests
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import os
|
||||
import threading
|
||||
from threading import Lock
|
||||
from typing import Optional, List, Union, Dict, Any
|
||||
|
||||
# ----------------------------
|
||||
# 日志系统配置
|
||||
# ----------------------------
|
||||
desktop_path = os.path.join(os.path.expanduser("~"), "Desktop")
|
||||
log_path = os.path.join(desktop_path, "ollama_comfyui.log")
|
||||
|
||||
# 强制删除旧日志
|
||||
try:
|
||||
if os.path.exists(log_path):
|
||||
os.remove(log_path)
|
||||
except Exception as e:
|
||||
print(f"旧日志删除失败: {str(e)}")
|
||||
|
||||
# 日志初始化
|
||||
logging.basicConfig(
|
||||
filename=log_path,
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
filemode='w'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.addHandler(logging.StreamHandler()) # 控制台输出
|
||||
|
||||
class ComfyUI_LLM_Ollama:
|
||||
"""
|
||||
Ollama LLM集成节点
|
||||
|
||||
特性:
|
||||
- 支持流式响应生成
|
||||
- 上下文记忆管理
|
||||
- 多线程安全访问
|
||||
- 动态模型列表加载
|
||||
- 详细的日志记录
|
||||
"""
|
||||
# 在类定义顶部添加
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
# 类级共享状态
|
||||
_conn_lock = Lock()
|
||||
_api_lock = Lock()
|
||||
_connection_checked = False
|
||||
_connection_status = False
|
||||
_available_models = ["llama3", "deepseek-r1:7b"] # 默认值
|
||||
|
||||
# 类级日志器 ✅ 修正点1
|
||||
logger = logging.getLogger("ComfyUI-Ollama")
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""动态生成输入配置"""
|
||||
# 在类加载时尝试获取可用模型
|
||||
if not cls._connection_checked:
|
||||
cls._check_connection()
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"prompt": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": "请用简洁的语言回答...",
|
||||
"dynamicPrompts": False,
|
||||
"lineCount": 4 # ✅ 初始显示4行高度
|
||||
}),
|
||||
"model": (cls._available_models, {"default": "deepseek-r1:7b"}),
|
||||
"temperature": ("FLOAT", {
|
||||
"default": 0.7,
|
||||
"min": 0.0,
|
||||
"max": 2.0,
|
||||
"step": 0.1,
|
||||
"display": "slider"
|
||||
}),
|
||||
"max_tokens": ("INT", {
|
||||
"default": 1024,
|
||||
"min": 1,
|
||||
"max": 4096,
|
||||
"step": 64,
|
||||
"display": "number"
|
||||
}),
|
||||
"hide_thoughts": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"context": ("STRING", {"forceInput": True}),
|
||||
"system_message": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": "你是有帮助的AI助手",
|
||||
"lazy": True,
|
||||
"lineCount": 3 # ✅ 初始显示3行高度
|
||||
}),
|
||||
"stop_sequences": ("STRING", {
|
||||
"default": "",
|
||||
"lazy": True
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("response", "context")
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "LLM"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
def __init__(self):
|
||||
self.ollama_url = "http://localhost:11434"
|
||||
self.headers = {"Content-Type": "application/json"}
|
||||
self.timeout = 120
|
||||
self._init_logging() # ✅ 修正点2:实例初始化日志配置
|
||||
|
||||
def _init_logging(self):
|
||||
"""初始化日志系统"""
|
||||
self.logger = logging.getLogger("ComfyUI-Ollama")
|
||||
self.logger.propagate = False
|
||||
if not self.logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter('[%(name)s] %(levelname)s - %(message)s')
|
||||
handler.setFormatter(formatter)
|
||||
self.logger.addHandler(handler)
|
||||
self.logger.setLevel(logging.INFO)
|
||||
|
||||
@classmethod
|
||||
def _check_connection(cls):
|
||||
"""类方法使用类级日志器 ✅ 修正点3"""
|
||||
with cls._conn_lock:
|
||||
if not cls._connection_checked:
|
||||
try:
|
||||
# 使用类级日志器
|
||||
cls.logger.info(f"🛠 正在检查Ollama连接...")
|
||||
|
||||
response = requests.get(
|
||||
"http://localhost:11434/api/tags",
|
||||
timeout=10
|
||||
)
|
||||
if response.status_code == 200:
|
||||
models = response.json().get("models", [])
|
||||
cls._available_models = [m["name"] for m in models]
|
||||
cls._connection_status = True
|
||||
cls.logger.info(f"✅ 可用模型: {cls._available_models}")
|
||||
else:
|
||||
cls.logger.warning(f"连接失败,状态码:{response.status_code}")
|
||||
except Exception as e:
|
||||
cls.logger.error(f"连接异常:{str(e)}")
|
||||
finally:
|
||||
cls._connection_checked = True
|
||||
|
||||
def _build_payload(self, **kwargs):
|
||||
"""构造API请求负载"""
|
||||
stop_sequences = [s.strip() for s in kwargs['stop_sequences'].split(',')] if kwargs['stop_sequences'] else []
|
||||
|
||||
return {
|
||||
"model": kwargs['model'],
|
||||
"prompt": kwargs['prompt'],
|
||||
"system": kwargs.get('system_message', '你是有帮助的AI助手'),
|
||||
"context": self._parse_context(kwargs.get('context')),
|
||||
"options": {
|
||||
"temperature": kwargs['temperature'],
|
||||
"num_predict": kwargs['max_tokens'],
|
||||
"stop": stop_sequences,
|
||||
}
|
||||
}
|
||||
|
||||
def _parse_context(self, context: Optional[str]) -> List:
|
||||
"""解析上下文数据"""
|
||||
try:
|
||||
return json.loads(context) if context else []
|
||||
except json.JSONDecodeError:
|
||||
self.logger.warning("上下文解析失败,使用空上下文")
|
||||
return []
|
||||
|
||||
def _clean_response(self, text: str, hide_thoughts: bool) -> str:
|
||||
"""响应后处理"""
|
||||
if hide_thoughts:
|
||||
return re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
|
||||
return text.strip()
|
||||
|
||||
def generate(self, **kwargs):
|
||||
"""主执行方法"""
|
||||
if not self._connection_status:
|
||||
return ("Ollama服务不可用,请检查", "")
|
||||
|
||||
with self._api_lock:
|
||||
try:
|
||||
payload = self._build_payload(**kwargs)
|
||||
self.logger.debug(f"请求参数:{json.dumps(payload, indent=2)}")
|
||||
|
||||
response_text = ""
|
||||
context = []
|
||||
|
||||
with requests.post(
|
||||
f"{self.ollama_url}/api/generate",
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
stream=True,
|
||||
timeout=self.timeout
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
data = json.loads(line.decode('utf-8'))
|
||||
response_text += data.get("response", "")
|
||||
if data.get("done"):
|
||||
context = data.get("context", [])
|
||||
|
||||
cleaned_response = self._clean_response(response_text, kwargs['hide_thoughts'])
|
||||
self.logger.info(f"📥 响应长度: {len(cleaned_response)}字符")
|
||||
return (cleaned_response, json.dumps(context))
|
||||
|
||||
except requests.RequestException as e:
|
||||
error_msg = f"API请求失败: {str(e)}"
|
||||
self.logger.error(error_msg)
|
||||
return (error_msg, "")
|
||||
except Exception as e:
|
||||
error_msg = f"处理错误: {str(e)}"
|
||||
self.logger.exception(error_msg)
|
||||
return (error_msg, "")
|
||||
|
||||
# 节点注册
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ComfyUI_LLM_Ollama": ComfyUI_LLM_Ollama
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ComfyUI_LLM_Ollama": "🤖 Ollama LLM"
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from openai import OpenAI, Stream
|
||||
from openai.types.chat import ChatCompletionChunk
|
||||
|
||||
# ----------------------------
|
||||
# 日志系统配置
|
||||
# ----------------------------
|
||||
desktop_path = os.path.join(os.path.expanduser("~"), "Desktop")
|
||||
log_path = os.path.join(desktop_path, "deepseek_comfyui.log")
|
||||
|
||||
logging.basicConfig(
|
||||
filename=log_path,
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
filemode='w'
|
||||
)
|
||||
logger = logging.getLogger("ComfyUI-DeepSeek")
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
|
||||
class ComfyUI_LLM_Online:
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"api_key": ("STRING", {"default": "", "password": True}),
|
||||
"input_str": ("STRING", {"multiline": True, "dynamicPrompts": False}),
|
||||
"model": (["deepseek-chat", "deepseek-coder","deepseek-reasoner"], {"default": "deepseek-chat"}),
|
||||
"temperature": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0, "step": 0.1}),
|
||||
"max_tokens": ("INT", {"default": 512, "min": 1, "max": 4096}),
|
||||
"stop_sequences": ("STRING", {"default": ""}),
|
||||
"stream_mode": (["enable", "disable"], {"default": "disable"}),
|
||||
},
|
||||
"optional": {
|
||||
"system_prompt": ("STRING", {"default": "你是有帮助的AI助手", "multiline": True}),
|
||||
"context": ("STRING", {"default": ""}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("response",)
|
||||
FUNCTION = "query_llm"
|
||||
CATEGORY = "LLM"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def _validate_inputs(self, **kwargs):
|
||||
"""参数验证"""
|
||||
if not kwargs.get("api_key"):
|
||||
raise ValueError("API密钥不能为空")
|
||||
|
||||
if len(kwargs.get("input_str", "").strip()) < 2:
|
||||
raise ValueError("输入内容过短(至少需要2个字符)")
|
||||
|
||||
if not 0 <= kwargs.get("temperature", 0.7) <= 1:
|
||||
raise ValueError("温度参数需在0-1之间")
|
||||
|
||||
def _build_messages(self, **kwargs):
|
||||
"""构造消息历史"""
|
||||
messages = []
|
||||
|
||||
# 系统提示
|
||||
if system_prompt := kwargs.get("system_prompt"):
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
|
||||
# 上下文历史
|
||||
if context := kwargs.get("context"):
|
||||
try:
|
||||
history = json.loads(context)
|
||||
if isinstance(history, list):
|
||||
messages.extend(history)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("上下文解析失败,使用原始字符串")
|
||||
messages.append({"role": "user", "content": context})
|
||||
|
||||
# 当前输入
|
||||
messages.append({"role": "user", "content": kwargs["input_str"]})
|
||||
return messages
|
||||
|
||||
def _handle_stream_response(self, stream: Stream[ChatCompletionChunk]) -> str:
|
||||
"""处理流式响应"""
|
||||
full_response = []
|
||||
for chunk in stream:
|
||||
if content := chunk.choices[0].delta.content:
|
||||
full_response.append(content)
|
||||
yield content # 实时输出
|
||||
|
||||
logger.debug(f"完整响应:{''.join(full_response)}")
|
||||
return ''.join(full_response)
|
||||
|
||||
def query_llm(self, **kwargs):
|
||||
try:
|
||||
# 输入验证
|
||||
self._validate_inputs(**kwargs)
|
||||
|
||||
# 初始化客户端
|
||||
client = OpenAI(
|
||||
api_key=kwargs["api_key"],
|
||||
base_url="https://api.deepseek.com/v1",
|
||||
)
|
||||
|
||||
# 构造请求参数
|
||||
stop_sequences = [s.strip() for s in kwargs["stop_sequences"].split(",") if s.strip()]
|
||||
|
||||
# 创建API请求
|
||||
response = client.chat.completions.create(
|
||||
model=kwargs["model"],
|
||||
messages=self._build_messages(**kwargs),
|
||||
temperature=kwargs["temperature"],
|
||||
max_tokens=kwargs["max_tokens"],
|
||||
stop=stop_sequences if stop_sequences else None,
|
||||
stream=kwargs["stream_mode"] == "enable",
|
||||
)
|
||||
|
||||
# 处理响应
|
||||
if isinstance(response, Stream):
|
||||
# 流式处理
|
||||
return (self._handle_stream_response(response),)
|
||||
|
||||
# 普通响应
|
||||
content = response.choices[0].message.content
|
||||
logger.debug(f"API响应:{content}")
|
||||
return (content,)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"API请求失败:{str(e)}", exc_info=True)
|
||||
return (f"错误:{str(e)}",)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""通过哈希值检测输入变化"""
|
||||
return hash(json.dumps(kwargs, sort_keys=True))
|
||||
|
||||
# 节点注册
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"DeepSeek_Online": ComfyUI_LLM_Online
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"DeepSeek_Online": "🧠 DeepSeek 智能助手"
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from typing import Optional
|
||||
from typing import Union
|
||||
|
||||
# ----------------------------
|
||||
# 日志系统配置
|
||||
# ----------------------------
|
||||
desktop_path = os.path.join(os.path.expanduser("~"), "Desktop")
|
||||
log_path = os.path.join(desktop_path, "ollama_comfyui.log")
|
||||
|
||||
# 日志初始化
|
||||
logging.basicConfig(
|
||||
filename=log_path,
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
filemode='w'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.addHandler(logging.StreamHandler()) # 控制台输出
|
||||
|
||||
class StringArrayFormatter:
|
||||
"""
|
||||
字符串数组处理节点
|
||||
|
||||
功能:
|
||||
- 解析JSON格式字符串数组
|
||||
- 生成格式化统计报告
|
||||
- 自动验证输入格式
|
||||
- 支持自定义分隔符
|
||||
- 错误友好提示
|
||||
"""
|
||||
|
||||
# 类级日志器 ✅ 修正点1
|
||||
logger = logging.getLogger("ComfyUI-Ollama")
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"input_str": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": '["示例文本1", "示例文本2"]',
|
||||
"dynamicPrompts": False
|
||||
}),
|
||||
"show_index": ("BOOLEAN", {"default": True}),
|
||||
"max_length": ("INT", {
|
||||
"default": 200,
|
||||
"min": 0,
|
||||
"max": 65535,
|
||||
"step": 50,
|
||||
"display": "slider"
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"delimiter": ("STRING", {"default": "|", "lazy": True}),
|
||||
}
|
||||
}
|
||||
|
||||
# 更新 RETURN_TYPES 和 RETURN_NAMES
|
||||
RETURN_TYPES = ( "INT", "LIST_STR")
|
||||
RETURN_NAMES = ("长度", "字符串数组")
|
||||
FUNCTION = "format_array"
|
||||
CATEGORY = "LLM/文本处理"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def _validate_input(self, input_str: str) -> Optional[list]:
|
||||
"""输入验证与解析"""
|
||||
try:
|
||||
data = json.loads(input_str)
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("输入不是数组格式")
|
||||
if not all(isinstance(item, str) for item in data):
|
||||
raise ValueError("数组包含非字符串元素")
|
||||
return data
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 输入验证失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def _format_item(self, index: int, text: str, delimiter: str, max_len: int) -> str:
|
||||
"""单个元素的格式化处理"""
|
||||
# 截断处理
|
||||
display_text = text[:max_len] + "..." if len(text) > max_len else text
|
||||
|
||||
# 转义分隔符
|
||||
safe_text = display_text.replace(delimiter, f"\\{delimiter}")
|
||||
|
||||
# 添加索引
|
||||
if index >= 0:
|
||||
return f"[{index:02d}] {safe_text}"
|
||||
return safe_text
|
||||
|
||||
def format_array(self, input_str: str, show_index: bool, max_length: int, delimiter: str = "|"):
|
||||
# 输入验证(调整后允许自动转换元素类型)
|
||||
str_array = []
|
||||
try:
|
||||
data = json.loads(input_str)
|
||||
if not isinstance(data, list):
|
||||
logger.error(f"⚠️ 输入必须是数组: {input_str}")
|
||||
return (0, [])
|
||||
# 强制所有元素转为字符串
|
||||
str_array = [str(item) for item in data]
|
||||
except Exception as e:
|
||||
logger.error(f"⚠️ 解析失败: {str(e)}")
|
||||
return (0, [])
|
||||
|
||||
# 返回原始数组和长度(新增第三个返回值)
|
||||
return (
|
||||
len(str_array),
|
||||
str_array # 新增数组输出
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, input_str: str, **kwargs):
|
||||
"""通过哈希值检测输入变化"""
|
||||
return hash(input_str)
|
||||
|
||||
|
||||
class StringArrayIndexer:
|
||||
"""
|
||||
字符串数组索引器(增强输入兼容性)
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"input_array": ("LIST_STR", {"forceInput": True}),
|
||||
"index": ("INT", {"default": 0}),
|
||||
},
|
||||
"optional": {
|
||||
"default_value": ("STRING", {"default": "🚫 无效索引"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "get_element"
|
||||
CATEGORY = "LLM/文本处理"
|
||||
|
||||
def get_element(self, input_array, index, default_value="🚫 无效索引"):
|
||||
# 多模式解析
|
||||
parsed_list = self._parse_input(input_array)
|
||||
|
||||
if not parsed_list:
|
||||
return (default_value,)
|
||||
|
||||
# 处理负索引
|
||||
if index < 0:
|
||||
index += len(parsed_list)
|
||||
|
||||
# 边界检查
|
||||
if 0 <= index < len(parsed_list):
|
||||
return (parsed_list[index],)
|
||||
|
||||
return (default_value,)
|
||||
|
||||
def _parse_input(self, raw_input):
|
||||
"""支持解析多种格式的列表输入"""
|
||||
# 类型 1:直接列表输入
|
||||
if isinstance(raw_input, list):
|
||||
return [str(item) for item in raw_input]
|
||||
|
||||
# 类型 2:JSON 字符串
|
||||
try:
|
||||
data = json.loads(raw_input)
|
||||
if isinstance(data, list):
|
||||
return [str(item) for item in data]
|
||||
except:
|
||||
pass
|
||||
|
||||
# 类型 3:Python 风格列表字符串
|
||||
if raw_input.startswith("[") and raw_input.endswith("]"):
|
||||
try:
|
||||
return [
|
||||
item.strip().strip("\"' ")
|
||||
for item in raw_input[1:-1].split(",")
|
||||
]
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
# 节点注册
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"StringArrayIndexer": StringArrayIndexer,
|
||||
"StringArrayFormatter": StringArrayFormatter
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"StringArrayIndexer": "📜 数组元素索引器",
|
||||
"StringArrayFormatter": "📜 文本数组格式化器"
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import cv2
|
||||
import torch
|
||||
import numpy as np
|
||||
|
||||
import tempfile
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
class SplitVideoByFrames:
|
||||
"""
|
||||
用OpenCV拆分视频为多个片段,每段帧数不超过max_frames_per_clip,音频输出为ComfyUI官方格式
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"video_path": ("STRING", {"default": "your_video.mp4"}),
|
||||
"max_frames_per_clip": ("INT", {"default": 30, "min": 1, "max": 1000}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "LIST", "AUDIO")
|
||||
RETURN_NAMES = ("num_clips", "clips", "audio")
|
||||
FUNCTION = "split_video"
|
||||
CATEGORY = "云服务"
|
||||
|
||||
def split_video(self, video_path, max_frames_per_clip):
|
||||
# 1. 提取音频为wav临时文件
|
||||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp_audio:
|
||||
audio_path = tmp_audio.name
|
||||
ffmpeg_bin = "ffmpeg" # 假设已在环境变量
|
||||
cmd = [ffmpeg_bin, '-y', '-i', video_path, '-vn', '-acodec', 'pcm_s16le', '-ar', '44100', '-ac', '2', '-f', 'wav', audio_path]
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
# 检查音频文件是否有效
|
||||
if not os.path.exists(audio_path) or os.path.getsize(audio_path) == 0:
|
||||
print(f"警告:视频 {video_path} 无音轨或音频提取失败,返回空音频。")
|
||||
waveform = torch.zeros((1, 2, 1), dtype=torch.float32) # 1帧2通道空音频
|
||||
sample_rate = 44100
|
||||
audio_dict = {"waveform": waveform, "sample_rate": sample_rate}
|
||||
else:
|
||||
try:
|
||||
import torchaudio
|
||||
waveform, sample_rate = torchaudio.load(audio_path)
|
||||
except Exception as e:
|
||||
print(f"警告:音频文件读取失败,返回空音频。错误信息: {e}")
|
||||
waveform = torch.zeros((1, 2, 1), dtype=torch.float32)
|
||||
sample_rate = 44100
|
||||
audio_dict = {"waveform": waveform, "sample_rate": sample_rate}
|
||||
os.remove(audio_path)
|
||||
# 保证shape为(1,channels,samples)
|
||||
if waveform.dim() == 2:
|
||||
waveform = waveform.unsqueeze(0)
|
||||
audio_dict = {"waveform": waveform, "sample_rate": sample_rate}
|
||||
|
||||
# 3. 视频分帧(不做resize,假设视频分辨率一致)
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
if not cap.isOpened():
|
||||
raise FileNotFoundError(f"无法打开视频文件: {video_path}")
|
||||
frames = []
|
||||
clips = []
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
np_image = frame.astype(np.float32) / 255.0
|
||||
frames.append(torch.from_numpy(np_image))
|
||||
if len(frames) == max_frames_per_clip:
|
||||
clips.append({"frames": torch.stack(frames, dim=0), "audio": audio_dict})
|
||||
frames = []
|
||||
if frames:
|
||||
clips.append({"frames": torch.stack(frames, dim=0), "audio": audio_dict})
|
||||
cap.release()
|
||||
return (len(clips), clips, audio_dict)
|
||||
|
||||
class GetVideoClipByIndex:
|
||||
"""
|
||||
输入clips和索引,输出该片段的图片数组(IMAGE)、音频(官方格式)和帧数(num_frames)
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"index": ("INT", {"default": 0, "min": 0}),
|
||||
"clips": ("LIST", {}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "AUDIO", "INT")
|
||||
RETURN_NAMES = ("images", "audio", "num_frames")
|
||||
FUNCTION = "get_clip"
|
||||
CATEGORY = "云服务"
|
||||
|
||||
def get_clip(self, index, clips):
|
||||
if not clips or index < 0 or index >= len(clips):
|
||||
raise IndexError("索引超出clips范围")
|
||||
clip = clips[index]
|
||||
frames = clip["frames"]
|
||||
audio = clip["audio"]
|
||||
print(f"获取片段 {index},帧数: {frames.shape[0]}, 音频采样率: {audio['sample_rate']}")
|
||||
num_frames = frames.shape[0]
|
||||
return (frames, audio, num_frames)
|
||||
|
||||
class CreateEmptyImageBatch:
|
||||
"""
|
||||
创建一个空的图像batch,若传入images则追加到空batch后返回。
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"optional": {
|
||||
"images": ("IMAGE", {}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image_batch",)
|
||||
FUNCTION = "create"
|
||||
CATEGORY = "云服务/集合工具"
|
||||
|
||||
def create(self, images=None):
|
||||
import torch
|
||||
def to_3ch(img):
|
||||
if img is None:
|
||||
return img
|
||||
if img.dim() == 3:
|
||||
if img.shape[-1] == 1:
|
||||
img = img.repeat(1, 1, 3)
|
||||
elif img.shape[-1] == 4:
|
||||
img = img[..., :3]
|
||||
elif img.dim() == 4:
|
||||
if img.shape[-1] == 1:
|
||||
img = img.repeat(1, 1, 1, 3)
|
||||
elif img.shape[-1] == 4:
|
||||
img = img[..., :3]
|
||||
return img
|
||||
batch = torch.empty((0, 0, 0, 0), dtype=torch.float32)
|
||||
if images is not None:
|
||||
images = to_3ch(images)
|
||||
if images.dim() == 3:
|
||||
images = images.unsqueeze(0)
|
||||
# 直接用AppendImagesToBatch的逻辑拼接
|
||||
if batch.numel() == 0 or batch.shape[0] == 0:
|
||||
return (images,)
|
||||
else:
|
||||
new_batch = torch.cat([batch, images], dim=0)
|
||||
return (new_batch,)
|
||||
return (batch,)
|
||||
|
||||
class AppendImagesToBatch:
|
||||
"""
|
||||
向已有图像batch追加图片,支持单张或多张图片,输出合并后的IMAGE batch。
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image_batch": ("IMAGE", {}),
|
||||
"images_to_add": ("IMAGE", {}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image_batch",)
|
||||
FUNCTION = "append"
|
||||
CATEGORY = "云服务/集合工具"
|
||||
|
||||
def append(self, image_batch, images_to_add):
|
||||
import torch
|
||||
# 如果 image_batch 是空的(shape为(0, 0, 0, 0)),直接返回 images_to_add
|
||||
if image_batch.numel() == 0 or image_batch.shape[0] == 0:
|
||||
if images_to_add.dim() == 3:
|
||||
images_to_add = images_to_add.unsqueeze(0)
|
||||
return (images_to_add,)
|
||||
if image_batch.dim() == 3:
|
||||
image_batch = image_batch.unsqueeze(0)
|
||||
if images_to_add.dim() == 3:
|
||||
images_to_add = images_to_add.unsqueeze(0)
|
||||
new_batch = torch.cat([image_batch, images_to_add], dim=0)
|
||||
return (new_batch,)
|
||||
|
||||
class GetFirstImageFromBatch:
|
||||
"""
|
||||
获取图片batch的首帧或末帧,输出单帧IMAGE batch(shape为(1, H, W, C))及宽高。
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image_batch": ("IMAGE", {}),
|
||||
"mode": (["first", "last"], {"default": "first"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "INT", "INT")
|
||||
RETURN_NAMES = ("image", "width", "height")
|
||||
FUNCTION = "get"
|
||||
CATEGORY = "云服务/集合工具"
|
||||
|
||||
def get(self, image_batch, mode="first"):
|
||||
import torch
|
||||
if image_batch.dim() == 3:
|
||||
img = image_batch.unsqueeze(0)
|
||||
else:
|
||||
if image_batch.shape[0] == 0:
|
||||
raise ValueError("image_batch is empty!")
|
||||
if mode == "first":
|
||||
img = image_batch[0:1]
|
||||
else:
|
||||
img = image_batch[-1:]
|
||||
# img shape: (1, H, W, C)
|
||||
_, h, w, _ = img.shape
|
||||
return (img, w, h)
|
||||
|
||||
class RemoveFirstOrLastImageFromBatch:
|
||||
"""
|
||||
删除图片batch的首帧或末帧,输出剩余IMAGE batch。
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image_batch": ("IMAGE", {}),
|
||||
"mode": (["first", "last"], {"default": "first"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image_batch",)
|
||||
FUNCTION = "remove"
|
||||
CATEGORY = "云服务/集合工具"
|
||||
|
||||
def remove(self, image_batch, mode="first"):
|
||||
import torch
|
||||
if image_batch.dim() == 3:
|
||||
# 只有一张,删除后为空batch
|
||||
return (torch.empty((0, *image_batch.shape), dtype=image_batch.dtype, device=image_batch.device),)
|
||||
if image_batch.shape[0] == 0:
|
||||
return (image_batch,)
|
||||
if mode == "first":
|
||||
return (image_batch[1:],)
|
||||
else:
|
||||
return (image_batch[:-1],)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SplitVideoByFrames": SplitVideoByFrames,
|
||||
"GetVideoClipByIndex": GetVideoClipByIndex,
|
||||
"CreateEmptyImageBatch": CreateEmptyImageBatch,
|
||||
"AppendImagesToBatch": AppendImagesToBatch,
|
||||
"GetFirstImageFromBatch": GetFirstImageFromBatch,
|
||||
"RemoveFirstOrLastImageFromBatch": RemoveFirstOrLastImageFromBatch
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SplitVideoByFrames": "🎬 视频分段拆帧",
|
||||
"GetVideoClipByIndex": "🖼️ 获取片段图片和音频",
|
||||
"CreateEmptyImageBatch": "📂 创建空图像Batch",
|
||||
"AppendImagesToBatch": "➕ 追加图片到Batch",
|
||||
"GetFirstImageFromBatch": "🔍 获取首/末帧图片",
|
||||
"RemoveFirstOrLastImageFromBatch": "❌ 删除首/末帧图片"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pillow
|
||||
numpy
|
||||
requests
|
||||
qiniu
|
||||
openai
|
||||
imageio-ffmpeg
|
||||
torch
|
||||
torchaudio
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,73 @@
|
||||
## 可灵提示词公式
|
||||
提示词 = 主体(主体描述) + 运动+场景(场景描述)+(镜头语言+光影+氛围)
|
||||
括号里的内容可选填主体:主体是视频中的主要表现对象,是画面主题的重要体现者。如人、动物、植物,以及物体等;主体描述:对主体外貌细节和肢体姿态等的描述,可通过多个短句进行列举。如运动表现、发型发色、服饰穿搭、五官形态、肢体姿态等,主体运动:对主体运动状态的描述,包括静止和运动等,运动状态不宜过于复杂,符合5s视频内可以展现的画面即可,
|
||||
场景:场景是主体所处的环境,包括前景、背景等:场景描述:对主体所处环境的细节描述,可通过多个短句进行列举,但不宜过多,符合5s视频内可以展现的画面即可。如室内场景、室外场景、自然场景等。镜头语言:是指通过镜头的各种应用以及镜头之间的衔接和切换来传达故事或信息,并创造出特定的视觉效果和情感氛围。如超大远景拍摄,背景虚化、特写长焦镜头拍摄、地面拍摄、顶部拍摄、航拍、景深等;(注意:这里与运镜控制作区分)
|
||||
光影:光影是赋予摄影作品灵魂的关键元素,光影的运用可以使照片更具深度,更具情感,我们可以通过光影创造出富有层次感和情感表达力的作品。如氛围光照、晨光、夕阳、光影、丁达尔效应、灯光等;
|
||||
氛围:对预期视频画面的氛围描述。如热闹的场景、电影级调色、温美好等
|
||||
|
||||
|
||||
以上公式最核心的构成就是主体、 运动和场景,这也是描述一个视频画面最简单、最基本的单元。当我们希望更细节地描述主体与场景时,只需要通过列举多个描述词短句,保持Prompt中希望出现要素的完整性即可,会根据我们的表达进行提示词扩写,生成符合预期的视频。
|
||||
如“一只大能猫在咖啡厅里看书”,我们可以增加主体和场景的细节描述"一只大能猫戴着黑框眼镜在咖啡厅看书,书本放在桌子上,桌子上还有-杯咖啡,冒着热气,旁边是咖啡厅的窗户”,这样可灵生成的画面会更具体可控,如果想要增加一些镜头语言和光影氛围,我们也可以尝试“镜头中景拍摄,背景虚化,氛围光照,一只大能猫戴着黑框眼镜在咖啡厅看书,书本放在桌子上,桌子上还有一杯咖啡,冒着热气,旁边是咖啡厅的窗户,电影级调色”,这样生成的视频质感会进一步提升,有可能会得到超出预期的结果。
|
||||
|
||||
|
||||
## 通义万象提示词公式
|
||||
|
||||
进阶公式 适用于有一定AI视频使用经验的用户,在基础公式之上添加更丰富细致的描述可有效提升视频质感、生动性与故事性
|
||||
提示词=主体(主体描述)+场景(场景描述)+运动(运动描述)+镜头语言+氛围词+风格化
|
||||
主体描述:主体描述是对主体外观特征细节的描述,可通过形容词或短句列举,例如“一位身着少数民族服饰的黑发苗族少女”、“一位来自异世界的飞天仙子,身着破旧却华丽的服饰,青后展开一对由废墟碎片构成的奇异翅膀"。
|
||||
场景描述:场暴描述是对主体所处环境特征细节的描述,可通过形容词或短句列举。
|
||||
运动描述:运动描述是对运动特征细节的描述,包含运动的幅度、速率和运动作用的效果,例如·猛烈地摇摆”、“缓慢地移动”、“打碎了玻璃”。
|
||||
镜头语言:镜头语言包含景别、视角、镜头、运镜等,常见镜头语言详见下方提示词词典。
|
||||
围氛词:氛围词是对预期画面氛围的描述,例如“梦幻”、“孤独”、“宏伟”,常见氛围词详见下方提示词词典。风格化:风格化是对画面风格语言的描述,例如“赛博朋克"、“勾线插画”、"废士风格”,常见风格化详见下方提示词词典
|
||||
|
||||
### 景别:
|
||||
特写(示例:镜头特写一位古风女子的脸庞,柔和的光线洒在她的皮肤上,勾勒出细腻的轮廓。)
|
||||
近景(示例:镜头近景显示一位古风女子捧着一把青色折扇,指尖轻轻划过扇面上的精美花纹,仿佛在细细品味其中的韵味。)
|
||||
中景(示例:镜头中景展示一位古风女子袅袅地走在花丛中,长裙随风飘荡,仿佛在与自然交融。)
|
||||
远景(示例:镜头采用鸟瞰视角,俯瞰整个城市的全貌,展现出互相交织的街道与建筑。)
|
||||
|
||||
### 视角:
|
||||
低角度:(示例:镜头采用低角度拍摄,聚焦于脚步的移动。画面中,鞋子踩踏在粗糙的废弃地面上,周围是破碎的混凝土和季散的杂草,展现出废土风格的荒凉与颓废。)
|
||||
俯拍:(示例:视频展示了一个在末世中行走的人的场景,镜头从高处俯拍一个人在废土风格的场景中随着阳光缓缓前行。)
|
||||
无人机:(示例:镜头采用FPV(第一人视角)无人机拍摄,带来了一种身临其境的感受。镜头迅速穿越城市的高楼大厦之间,展现出宏伟的都市景观。建筑物在视野中迅速闪过光影交错,映衬出城市的现代与繁华。)
|
||||
|
||||
### 镜头:
|
||||
鱼眼:(示例:镜头采用鱼眼视角,带来了一种独特的弯曲效果,将繁忙的城市街道呈现得更加引人注目。画面的密阔视野让观众感受到建筑物仿佛在向天空倾斜,街道两旁的高楼在鱼眼镜头的渲染下,显得分外壮观而立体,扭曲的线条形成强烈的视觉冲击,充分展现了城市的活力与喧嚣)
|
||||
广角:(示例:视频以广角镜头捕捉一个繁忙的城市街道监控场景,画面宽广,视野开阔。街道两旁高楼林立,车流不息,行人川流不息,展现出城市生活的喧嚣与活力。)
|
||||
|
||||
### 运镜:
|
||||
推进:(示例:视频中展示了一块巨大的立方体石块,伫立在广场中央,周围宁静。镜头缓缓推进,逐渐靠近石块,粗犷的纹理和岁月的痕迹逐渐清晰。)
|
||||
拉远:(示例:视频中展示了一块巨大的立方体石块,稳稳伫立在广场中央。镜头缓缓拉远,石块的宏伟轮廓逐渐显现,广场的石砖和周国的草坪逐渐展露。最终,整个广场的景色展现在眼前。)
|
||||
平移:(示例:视频中展示了一块立方体石块,镜头聚焦在立方体石块上,阳光透过,散发出温暖的光泽。随着镜头轻轻移动,石块的细节在视野中渐渐变化,周围环境模糊成背景。随即,镜头自然地切换到旁边的立方体铁块,光滑的金属表面在阳光下闪烁。焦点流畅地转移,铁块的冷冽与石块的厚重形成鲜明对比。镜头在两者之间游走,展现出各自的独特魅力)
|
||||
跟随:(示例:视频中展示了一个立方体石块在广场中央滚动,镜头跟随运动。随着砾石的移动,周围的绿草如茵。镜头灵活地紧贴着砾石,捕捉到地面上细微的纹路和春风掠过的动感。)
|
||||
环绕:(示例:视频中展示了一块巨大的立方体石块,伫立在广场中央。镜头旋转延展,围绕这块石块延伸,捕捉到表面粗犷的纹理和阳光下闪烁的细微光泽。)
|
||||
|
||||
### 速度:
|
||||
慢速:(示例:赛车缓慢移动,背景在静谧中逐渐清晰,一切都仿佛被时间拉长。车速温和,带来一种悠然自得的体验与心灵的宁静,车手在这片安详中如同与周围的环境融为一体,流露出的专注让人心生敬畏。)
|
||||
快速:(示例:赛车快速行驶,背景在瞬息之间模糊,一切都仿佛变成了色彩的交错。车速迅猛,让人感受到强烈的刺激与肾上腺素的飙升,车手在这片轰鸣中如同掌控了整个世界,释放出的激情让人热血沸腾。)
|
||||
慢动作:(示例:人流缓缓移动,慢动作放大了每个行人的步伐,行人与环境融为一体,映衬出生活的每一个细节。步伐缓慢却充满韵律,仿佛在诉说着城市的故事,带着思考与感悟,分享着生活的诗意。)
|
||||
延时摄影:(示例:视频展示了植物以延时效果迅速生长开花的惊人过程。画面中,嫩芽在短短几秒钟内经历了生长与绽放,从青涩的芽苞到鲜艳的花朵,瞬间令观众目眩神迷。每个环节都被清晰地捕捉,展现出生命力的蓬勃与美丽,让人不禁感叹自然界的奇妙与神奇。)
|
||||
|
||||
### 氛围:
|
||||
活力/欢快/愉悦/美好:(示例:视频展示了一片充满活力的森林,阳光透过树冠酒下金色的光斑,鸟儿在林间欢快地飞舞,清脆的鸣叫声在空中回荡,仿佛在为这片生机勃勃的天地演唱颂歌。树叶在微风中轻轻摇摆,像是随着乐曲的节拍在欢快地起舞,欣欣向荣的景象令人心生愉悦小动物们在草地上嬉戏,花朵在阳光下绽放,整个森林充满了生机与活力,仿佛在诉说着生命的美好与无限可能,让人感受到大自然的蓬勃与希望。)
|
||||
|
||||
幽深/柔和/安静/梦幻:(示例:视频展示了一片幽深的森林,夜晚的宁静如同一层柔和的轻纱温柔地笼罩着每一寸土地。四周静谧无声,仿佛时间在此刻凝固,唯有微风轻轻拂过树梢,带来细微的沙沙声,仿佛在与大自然低语。夜空如深蓝的绒布,星光点点闪烁,宛如遥远的宝石铺嵌其中,散发出清冷而柔和的光芒。整个场景显得安静而和谐,令人心旷神怡,仿佛走入了一个梦幻般的境地,恍若与世隔绝尽情融入这片自然的怀抱。)
|
||||
|
||||
孤独/冷清/惆怅/宁静:(示例:视频展示了一片孤独的森林,四周寂静无声,仿佛时间在这一刻停滞。树叶轻轻飘落,缓缓地铺酒在地面上,发出微弱的窸窣声,仿佛在低声诉说着秋天的离别。空旷的空间中只有风声孤独地回荡,似乎在唤醒这片沉寂的土地。阴影在树木间交错,营造出冷清而忧郁的氛围,整个场景透着一丝孤寂,让人感受到一种淡淡的惆怅,仿佛在向失去的时光致敬,深处于这一片宁静中,让人不禁沉思。)
|
||||
|
||||
紧张/不安/压抑/阴郁:(示例:视频展示了一片紧张的森林,狂风肆虐,猛烈地吹动着树梢和树叶,发出一阵阵低沉的沙沙声,如同大自然在低语着不安的预兆。树干摇曳,似乎在抗争着这突如其来的威严。天空中乌云密布,厚重的云层像是压抑的心情,笼罩着整个画面,透出一种阴郁而凝重的气氛。周围的一切似乎也在为即将到来的变化而屏息,整个场景充满了紧迫感,令人心头微微一紧,仿佛随时都会爆发出无法预知的风暴,使人感受到大自然的无情与力量。)
|
||||
|
||||
庄严/力量/宏伟/敬畏:(示例:视频展示了一片庄严的森林,树木高大挺拔,如同守护者般屹立在大地上,散发出一种无言的力量。阳光透过浓密的树梢,酒下斑驳的光芒,形成了层层光影在地面交织,仿佛在绘制一幅自然的画卷。空气中弥漫着清新的气息,偶尔传来的鸟鸣声低沉而庄重,增添了几分肃穆的氛围。整个场景透着一种宏伟与宁静,引人肃然起敬。这个森林仿佛是时光的见证,承载着无数故事与秘密,让人感受到大自然的庄重与神秘,令人心生敬畏,仿佛置身于一片灵性之地。)
|
||||
|
||||
### 风格:
|
||||
赛博朋克:(示例:复古赛博朋克风格-闪烁的霓虹灯下一名身着皮夹克的赛博战士在废弃的电子工厂中穿行镜头从他的背影拉远展示出一个充满未来科技感的城市夜景。)
|
||||
|
||||
废士风格:(示例:视频展示了废土风格背景下,一位仿佛来自异世界的飞天仙女的惊艳现身。她身着破旧却华丽的服饰,背后展开一对由废墟碎片构成的奇异翅膀,翱翔在荒凉的地景之上。镜头跟随她的飞行轨迹,从低空拉高至广阔天空,展现出她轻盈的身姿与废土世界的强烈对比,每一次挥翅都似乎在诉说着生存与希望的故事。)
|
||||
|
||||
勾线插画:(示例:法庭上,一只机智敏捷、口才了得的狐狸律师,身着笔挺的律师袍,正滔滔不绝地为当事人辩护,她的每一个论点都精准有力,让旁听者无不为之动容,勾线动画。)
|
||||
|
||||
国风动漫:(示例:国漫风格穿越少女,在古代言廷中烛光照耀下学习礼仪一举一动皆显古风优雅)
|
||||
|
||||
毛毡风格:(示例:视频展示了一个人物在厨房环境中的生动场景,特别的是,这个人物是由毛毡制成的,增添了几分童趣。毛毡小人正站在一个迷你厨房里,手里拿着小锅铲,仿佛在精心烹饪美食。背景是一面挂满各种厨具的墙,整个空间充满了温馨的气氛。视频通过固定视角捕捉了毛毡小人的每一个动作,包括搅拌、翻炒等,展现了其精湛的“厨艺”。)
|
||||
|
||||
经典名作:(示例:梵高的《星夜》中,一位穿着现代服饰的滑板少年在扭曲的树木间穿梭,是空下的光影效果与滑板的轨迹交相辉映。)
|
||||
Reference in New Issue
Block a user