Files

300 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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": "🖼️图片合成视频并上传到云"
}