269 lines
14 KiB
Python
269 lines
14 KiB
Python
import os
|
|
import io
|
|
import base64
|
|
import locale
|
|
import json
|
|
import asyncio
|
|
import aiohttp
|
|
import numpy
|
|
import PIL.Image
|
|
import torch
|
|
from openai import AsyncOpenAI
|
|
from volcenginesdkarkruntime import Ark
|
|
|
|
GLOBAL_CATEGORY = "JimengAI"
|
|
|
|
DEFAULT_DOWNLOAD_TIMEOUT = 60 # 下载超时时间(秒)
|
|
DEFAULT_DOWNLOAD_RETRIES = 3 # 下载重试次数
|
|
|
|
jimeng_api_dir = os.path.dirname(os.path.abspath(__file__))
|
|
API_KEYS_FILE = os.path.join(jimeng_api_dir, "api_keys.json")
|
|
|
|
API_KEYS_CONFIG = []
|
|
CURRENT_LANG = "en"
|
|
|
|
LOG_TRANSLATIONS = {
|
|
"zh": {
|
|
"api_file_not_found": "[JimengAI] 提示: 未找到 API 密钥文件。请将 'api_keys.json.example' 重命名为 'api_keys.json' 并填入您的密钥。",
|
|
"api_file_empty": "[JimengAI] 警告: 'api_keys.json' 为空或格式不正确。",
|
|
"api_load_error": "[JimengAI] 错误: 加载 'api_keys.json' 失败: {e}",
|
|
"api_key_not_found": "[JimengAI] 错误: 未找到 '{key_name}' 的 API Key,请检查配置文件。",
|
|
"est_fallback": "默认兜底",
|
|
"est_history": "历史均值",
|
|
"est_regression": "线性回归",
|
|
"est_recent": "近期负载调整",
|
|
"task_submitted_est": "[JimengAI] 任务已提交。预估生成时间: {time}s (预估方式: {method})",
|
|
"task_info_simple": "[JimengAI] 任务 ID: {task_id} | 模型: {model}",
|
|
"batch_submit_start": "[JimengAI] 正在提交 {count} 个任务 (模型: {model})...",
|
|
"batch_submit_result": "[JimengAI] 提交完成。成功: {created},失败: {failed}。",
|
|
"polling_single": "[JimengAI] 任务 {task_id}: 已耗时 {elapsed}s / 预估 {max}s ...",
|
|
"polling_batch": "[JimengAI] 批量进度: 完成 {done}/{total}。等待中: {pending}... (耗时 {elapsed}s / {max}s)",
|
|
"interrupted": "\n[JimengAI] 用户中断了处理。正在尝试取消挂起任务...",
|
|
"task_finished_single": "[JimengAI] 任务执行成功。",
|
|
"batch_finished_stats": "[JimengAI] 批量处理完成。成功: {success},失败: {failed}。",
|
|
"batch_handling": "[JimengAI] 正在处理 {count} 个成功任务,按 Seed 排序并下载...",
|
|
"batch_copying": "[JimengAI] 正在保存文件到输出目录: {path}",
|
|
"err_download_url": "[JimengAI] 异步下载或转换失败,URL: {url},错误: {e}",
|
|
"err_task_create": "[JimengAI] 任务创建失败: {e}",
|
|
"err_task_check": "[JimengAI] 检查任务状态失败 ({tid}): {e}",
|
|
"err_task_fail_msg": "[JimengAI] 任务 {tid} 失败: {msg}",
|
|
"err_batch_fail_all": "[JimengAI] 批量任务失败: 无任务成功。",
|
|
"err_copy_fail": "[JimengAI] 复制文件失败: {path},错误: {e}",
|
|
"err_convert_tensor": "[JimengAI] 转换图片 Tensor 失败: {e}",
|
|
"err_check_status_batch": "[JimengAI] 检查非阻塞任务状态失败: {e}",
|
|
"download_retry": "[JimengAI] 警告: 下载失败 (尝试 {attempt}/{total})。{delay}秒后重试... 错误: {e}",
|
|
|
|
# 前端弹窗提示
|
|
"popup_req_failed": "[JimengAI] 请求失败: {msg}",
|
|
"popup_task_failed": "[JimengAI] 任务 {task_id} 失败: {msg}",
|
|
"popup_batch_pending": "[JimengAI] 批量任务 ({count} 个) 处理中。请再次运行以检查结果。",
|
|
"popup_task_pending": "[JimengAI] 任务 {task_id} 状态为 {status}。请再次运行以检查结果。",
|
|
"popup_param_not_allowed": "[JimengAI] 参数错误: 提示词中不允许包含参数 '--{param}'。请使用节点的组件进行设置。",
|
|
"popup_first_frame_missing": "[JimengAI] 参数错误: 使用尾帧图片时必须提供首帧图片。",
|
|
"popup_ref_missing": "[JimengAI] 参数错误: 必须提供至少一张参考图。",
|
|
"popup_prepare_failed": "[JimengAI] 任务准备失败: {e}",
|
|
|
|
# 图片节点提示
|
|
"err_pixels_range": "[JimengAI] 参数错误: 总像素数必须在 {min} ({min_desc}) 和 {max} ({max_desc}) 之间。当前值: {current}",
|
|
"err_aspect_ratio": "[JimengAI] 参数错误: 宽高比必须在 {min} 和 {max} 之间。当前值: {current}",
|
|
"err_download_img": "[JimengAI] 错误: 下载生成的图像失败。",
|
|
"err_gen_model": "[JimengAI] 模型 {model} 生成失败: {e}",
|
|
"err_img_limit_10": "[JimengAI] 参数错误: 输入图像数量不能超过 10 张。",
|
|
"err_img_limit_15": "[JimengAI] 参数错误: 输入图像数 ({n}) 与最大生成数 ({max}) 之和不能超过 15。",
|
|
|
|
# API Key 相关
|
|
"combo_no_keys": "未找到密钥 (请配置 api_keys.json)",
|
|
"popup_key_valid_err": "[JimengAI] 配置错误: 所选密钥 '{key}' 无效或未找到。请检查 api_keys.json。",
|
|
},
|
|
"en": {
|
|
"api_file_not_found": "[JimengAI] Info: API keys file not found. Please rename 'api_keys.json.example' to 'api_keys.json' and fill in your keys.",
|
|
"api_file_empty": "[JimengAI] Warning: 'api_keys.json' is empty or not formatted correctly.",
|
|
"api_load_error": "[JimengAI] Error: Failed to load 'api_keys.json': {e}",
|
|
"api_key_not_found": "[JimengAI] Error: API Key for '{key_name}' not found.",
|
|
"est_fallback": "Fallback Default",
|
|
"est_history": "History Average",
|
|
"est_regression": "Linear Regression",
|
|
"est_recent": "Recent Load Adjustment",
|
|
"task_submitted_est": "[JimengAI] Task submitted. Est. time: {time}s (Method: {method})",
|
|
"task_info_simple": "[JimengAI] Task ID: {task_id} | Model: {model}",
|
|
"batch_submit_start": "[JimengAI] Submitting batch of {count} tasks (Model: {model})...",
|
|
"batch_submit_result": "[JimengAI] Submission complete. Created: {created}, Failed: {failed}.",
|
|
"polling_single": "[JimengAI] Task {task_id}: {elapsed}s / {max}s elapsed...",
|
|
"polling_batch": "[JimengAI] Batch Progress: {done}/{total} done. {pending} pending... ({elapsed}s / {max}s Est.)",
|
|
"interrupted": "\n[JimengAI] Processing interrupted by user. Cancelling pending tasks...",
|
|
"task_finished_single": "[JimengAI] Task completed successfully.",
|
|
"batch_finished_stats": "[JimengAI] Batch finished. Success: {success}, Failed: {failed}.",
|
|
"batch_handling": "[JimengAI] Handling {count} successful tasks. Sorting by seed and downloading...",
|
|
"batch_copying": "[JimengAI] Copying files to output directory: {path}",
|
|
"err_download_url": "[JimengAI] Async download failed, URL: {url}, Error: {e}",
|
|
"err_task_create": "[JimengAI] Task creation failed: {e}",
|
|
"err_task_check": "[JimengAI] Failed to check status for {tid}: {e}",
|
|
"err_task_fail_msg": "[JimengAI] Task {tid} failed: {msg}",
|
|
"err_batch_fail_all": "[JimengAI] Batch failed: No tasks succeeded.",
|
|
"err_copy_fail": "[JimengAI] Failed to copy file: {path}. Error: {e}",
|
|
"err_convert_tensor": "[JimengAI] Failed to convert frame to tensor: {e}",
|
|
"err_check_status_batch": "[JimengAI] API Error checking batch status: {e}",
|
|
"download_retry": "[JimengAI] Warning: Download failed (Attempt {attempt}/{total}). Retrying in {delay}s... Error: {e}",
|
|
|
|
# Popup Messages
|
|
"popup_req_failed": "[JimengAI] Request failed: {msg}",
|
|
"popup_task_failed": "[JimengAI] Task {task_id} failed: {msg}",
|
|
"popup_batch_pending": "[JimengAI] Batch ({count} tasks) is pending. Run again to check results.",
|
|
"popup_task_pending": "[JimengAI] Task {task_id} is {status}. Run again to check results.",
|
|
"popup_param_not_allowed": "[JimengAI] Parameter Error: Parameter '--{param}' is not allowed in the prompt. Please use the node's widget for this value.",
|
|
"popup_first_frame_missing": "[JimengAI] Parameter Error: A first frame image must be provided when using a last frame image.",
|
|
"popup_ref_missing": "[JimengAI] Parameter Error: At least one reference image must be provided.",
|
|
"popup_prepare_failed": "[JimengAI] Failed to prepare task: {e}",
|
|
|
|
# Image Nodes
|
|
"err_pixels_range": "[JimengAI] Parameter Error: Total pixels must be between {min} ({min_desc}) and {max} ({max_desc}). Your current: {current}",
|
|
"err_aspect_ratio": "[JimengAI] Parameter Error: Aspect ratio must be between {min} and {max}. Your current: {current}",
|
|
"err_download_img": "[JimengAI] Error: Failed to download the generated image.",
|
|
"err_gen_model": "[JimengAI] Failed to generate image with model {model}: {e}",
|
|
"err_img_limit_10": "[JimengAI] Parameter Error: The number of input images cannot exceed 10.",
|
|
"err_img_limit_15": "[JimengAI] Parameter Error: The sum of input images ({n}) and max generated images ({max}) cannot exceed 15.",
|
|
|
|
# API Key
|
|
"combo_no_keys": "No Keys Found (Check api_keys.json)",
|
|
"popup_key_valid_err": "[JimengAI] Config Error: Selected key '{key}' is invalid or not found. Please check api_keys.json.",
|
|
}
|
|
}
|
|
|
|
def detect_system_language():
|
|
try:
|
|
lang_code, _ = locale.getdefaultlocale()
|
|
if lang_code and lang_code.startswith('zh'):
|
|
return 'zh'
|
|
except:
|
|
pass
|
|
return 'en'
|
|
|
|
def get_text(key):
|
|
global CURRENT_LANG
|
|
mapping = LOG_TRANSLATIONS.get(CURRENT_LANG, LOG_TRANSLATIONS["en"])
|
|
return mapping.get(key, LOG_TRANSLATIONS["en"].get(key, key))
|
|
|
|
def log_msg(key, default_msg="", **kwargs):
|
|
global CURRENT_LANG
|
|
mapping = LOG_TRANSLATIONS.get(CURRENT_LANG, LOG_TRANSLATIONS["en"])
|
|
msg = mapping.get(key, None)
|
|
if msg is None:
|
|
msg = LOG_TRANSLATIONS["en"].get(key, default_msg)
|
|
|
|
if msg:
|
|
try:
|
|
print(msg.format(**kwargs))
|
|
except Exception:
|
|
print(msg)
|
|
|
|
def load_api_keys():
|
|
global API_KEYS_CONFIG, CURRENT_LANG
|
|
API_KEYS_CONFIG = []
|
|
|
|
CURRENT_LANG = detect_system_language()
|
|
|
|
if not os.path.exists(API_KEYS_FILE):
|
|
log_msg("api_file_not_found")
|
|
return
|
|
|
|
try:
|
|
with open(API_KEYS_FILE, 'r', encoding='utf-8') as f:
|
|
keys_data = json.load(f)
|
|
if isinstance(keys_data, list):
|
|
for item in keys_data:
|
|
if "customName" in item and "apiKey" in item:
|
|
API_KEYS_CONFIG.append(item)
|
|
if not API_KEYS_CONFIG:
|
|
log_msg("api_file_empty")
|
|
except Exception as e:
|
|
log_msg("api_load_error", e=e)
|
|
|
|
async def _fetch_data_from_url_async(session: aiohttp.ClientSession, url: str, timeout: int = DEFAULT_DOWNLOAD_TIMEOUT, retries: int = DEFAULT_DOWNLOAD_RETRIES) -> bytes:
|
|
for attempt in range(1, retries + 2):
|
|
try:
|
|
client_timeout = aiohttp.ClientTimeout(total=timeout)
|
|
async with session.get(url, timeout=client_timeout) as response:
|
|
response.raise_for_status()
|
|
return await response.read()
|
|
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
if attempt > retries:
|
|
raise e
|
|
retry_delay = 2
|
|
log_msg("download_retry", attempt=attempt, total=retries+1, delay=retry_delay, e=e)
|
|
await asyncio.sleep(retry_delay)
|
|
return b""
|
|
|
|
def _tensor2images(tensor: torch.Tensor) -> list:
|
|
np_imgs = numpy.clip(tensor.cpu().numpy() * 255.0, 0, 255.0).astype(numpy.uint8)
|
|
return [PIL.Image.fromarray(np_img) for np_img in np_imgs]
|
|
|
|
def _image_to_base64(image: torch.Tensor) -> str:
|
|
if image is None: return None
|
|
with io.BytesIO() as bytes_io:
|
|
_tensor2images(image)[0].save(bytes_io, format='JPEG')
|
|
data_bytes = bytes_io.getvalue()
|
|
return base64.b64encode(data_bytes).decode("utf-8")
|
|
|
|
async def _download_url_to_image_tensor_async(session: aiohttp.ClientSession, url: str) -> torch.Tensor | None:
|
|
if not url: return None
|
|
try:
|
|
image_data = await _fetch_data_from_url_async(session, url)
|
|
i = PIL.Image.open(io.BytesIO(image_data))
|
|
image = i.convert("RGB")
|
|
image = numpy.array(image).astype(numpy.float32) / 255.0
|
|
return torch.from_numpy(image)[None,]
|
|
except Exception as e:
|
|
log_msg("err_download_url", url=url, e=e)
|
|
return None
|
|
|
|
class JimengClients:
|
|
def __init__(self, openai_client, ark_client):
|
|
self.openai = openai_client
|
|
self.ark = ark_client
|
|
|
|
class JimengAPIClient:
|
|
# 即梦 API 客户端节点
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
load_api_keys()
|
|
key_names = [key["customName"] for key in API_KEYS_CONFIG]
|
|
if not key_names:
|
|
# 汉化:列表为空时的占位符
|
|
key_names = [get_text("combo_no_keys")]
|
|
return { "required": { "key_name": (key_names,), } }
|
|
|
|
RETURN_TYPES = ("JIMENG_CLIENT",)
|
|
RETURN_NAMES = ("client",)
|
|
FUNCTION = "create_clients"
|
|
CATEGORY = GLOBAL_CATEGORY
|
|
|
|
def create_clients(self, key_name):
|
|
api_key = None
|
|
# 检查是否选择了占位符
|
|
if key_name == get_text("combo_no_keys"):
|
|
# 抛出中文弹窗,提示去配置 Keys
|
|
raise RuntimeError(get_text("api_file_not_found"))
|
|
|
|
for key_info in API_KEYS_CONFIG:
|
|
if key_info["customName"] == key_name:
|
|
api_key = key_info["apiKey"]
|
|
break
|
|
|
|
if not api_key:
|
|
log_msg("api_key_not_found", key_name=key_name)
|
|
# 抛出中文弹窗,提示 Key 无效
|
|
raise RuntimeError(get_text("popup_key_valid_err").format(key=key_name))
|
|
|
|
openai_client = AsyncOpenAI(api_key=api_key, base_url="https://ark.cn-beijing.volces.com/api/v3")
|
|
ark_client = Ark(api_key=api_key)
|
|
|
|
clients = JimengClients(openai_client, ark_client)
|
|
return (clients,)
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"JimengAPIClient": JimengAPIClient,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"JimengAPIClient": "Jimeng API Client",
|
|
} |