V2.0.0
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
节点基类模块
|
||||
提供所有节点的通用基础能力
|
||||
"""
|
||||
|
||||
from .base_node import BaseNode
|
||||
from .llm_node_base import LLMNodeBase
|
||||
from .vlm_node_base import VLMNodeBase
|
||||
|
||||
__all__ = [
|
||||
'BaseNode', 'LLMNodeBase', 'VLMNodeBase'
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
节点抽象基类
|
||||
提供所有节点的通用基础能力,包括线程管理、中断检测、Provider配置等
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Dict, Optional, Tuple
|
||||
|
||||
from comfy.model_management import InterruptProcessingException
|
||||
|
||||
from ...utils.common import PREFIX as LOG_PREFIX, REQUEST_PREFIX, PROCESS_PREFIX
|
||||
|
||||
|
||||
class BaseNode:
|
||||
"""
|
||||
所有节点的抽象基类
|
||||
|
||||
提供通用功能:
|
||||
- 线程执行与中断管理
|
||||
- Provider配置覆盖
|
||||
- 规则模板获取
|
||||
"""
|
||||
|
||||
# 子类应该覆盖这些常量
|
||||
LOG_PREFIX = LOG_PREFIX
|
||||
REQUEST_PREFIX = REQUEST_PREFIX
|
||||
PROCESS_PREFIX = PROCESS_PREFIX
|
||||
|
||||
|
||||
def _run_thread_with_interrupt(
|
||||
self,
|
||||
target_func: Callable,
|
||||
args: Tuple,
|
||||
task_name: str = "任务"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
在独立线程中运行任务,并支持中断检测
|
||||
|
||||
参数:
|
||||
target_func: 要在线程中执行的函数
|
||||
args: 传递给函数的参数元组
|
||||
task_name: 任务名称,用于日志输出(已废弃,保留参数以兼容旧代码)
|
||||
|
||||
返回:
|
||||
result_container中的'result'字段内容
|
||||
|
||||
异常:
|
||||
InterruptProcessingException: 当检测到用户中断时
|
||||
"""
|
||||
result_container = {}
|
||||
|
||||
# 启动线程
|
||||
thread = threading.Thread(target=target_func, args=args)
|
||||
thread.start()
|
||||
|
||||
# 等待完成,同时检查中断
|
||||
while thread.is_alive():
|
||||
try:
|
||||
import nodes
|
||||
nodes.before_node_execution()
|
||||
except:
|
||||
# 检测到中断,立即抛出异常
|
||||
raise InterruptProcessingException()
|
||||
time.sleep(0.1)
|
||||
|
||||
return result_container.get('result')
|
||||
|
||||
def _run_async_in_thread(
|
||||
self,
|
||||
async_func: Callable,
|
||||
result_container: Dict[str, Any],
|
||||
*args,
|
||||
**kwargs
|
||||
) -> None:
|
||||
"""
|
||||
在独立线程中运行异步任务的辅助方法
|
||||
|
||||
这个方法会:
|
||||
1. 创建新的事件循环
|
||||
2. 运行异步函数
|
||||
3. 将结果存入result_container
|
||||
4. 正确处理中断异常
|
||||
|
||||
参数:
|
||||
async_func: 异步函数
|
||||
result_container: 结果容器字典
|
||||
*args, **kwargs: 传递给async_func的参数
|
||||
|
||||
结果:
|
||||
result_container['result'] = 函数返回值或错误信息
|
||||
"""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
result = loop.run_until_complete(async_func(*args, **kwargs))
|
||||
result_container['result'] = result
|
||||
except (asyncio.CancelledError, KeyboardInterrupt):
|
||||
# 捕获中断异常,不格式化错误
|
||||
print(f"{self.LOG_PREFIX} 检测到异步任务被取消")
|
||||
result_container['result'] = {"success": False, "error": "任务被中断"}
|
||||
except Exception as e:
|
||||
# 捕获其他异常,让子类处理格式化
|
||||
result_container['result'] = {"success": False, "error": str(e)}
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
def _execute_with_interrupt(
|
||||
self,
|
||||
thread_func: Callable,
|
||||
thread_args: Tuple,
|
||||
cancel_event: Optional[Any] = None
|
||||
) -> Any:
|
||||
"""
|
||||
执行带中断检测的线程任务(通用封装)
|
||||
|
||||
参数:
|
||||
thread_func: 线程中执行的函数(通常是包含异步逻辑的函数)
|
||||
thread_args: 传递给thread_func的参数
|
||||
cancel_event: 可选的取消事件,用于通知异步任务中断
|
||||
|
||||
返回:
|
||||
result_container['result']的内容
|
||||
|
||||
异常:
|
||||
InterruptProcessingException: 当检测到用户中断时
|
||||
"""
|
||||
result_container = {}
|
||||
|
||||
# 如果提供了cancel_event,将其传递给thread_func
|
||||
if cancel_event is not None:
|
||||
thread = threading.Thread(target=thread_func, args=(result_container, cancel_event) + thread_args)
|
||||
else:
|
||||
thread = threading.Thread(target=thread_func, args=(result_container,) + thread_args)
|
||||
thread.start()
|
||||
|
||||
# 等待完成,同时检查中断
|
||||
while thread.is_alive():
|
||||
is_interrupted = False
|
||||
try:
|
||||
import nodes
|
||||
nodes.before_node_execution()
|
||||
|
||||
# 双重检查:额外检查 PromptServer 的全局中断状态
|
||||
# 某些情况下 nodes.before_node_execution() 可能不会抛出异常
|
||||
from server import PromptServer
|
||||
if hasattr(PromptServer.instance, 'execution_interrupted') and PromptServer.instance.execution_interrupted:
|
||||
is_interrupted = True
|
||||
except:
|
||||
is_interrupted = True
|
||||
|
||||
if is_interrupted:
|
||||
# 检测到中断,设置取消事件(如果提供)
|
||||
if cancel_event is not None:
|
||||
try:
|
||||
cancel_event.set()
|
||||
except:
|
||||
pass
|
||||
raise InterruptProcessingException()
|
||||
time.sleep(0.1)
|
||||
|
||||
return result_container.get('result')
|
||||
|
||||
def _override_ollama_config(
|
||||
self,
|
||||
provider_config: Dict[str, Any],
|
||||
auto_unload: bool
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
覆盖Ollama配置中的auto_unload参数
|
||||
|
||||
参数:
|
||||
provider_config: 原始Provider配置
|
||||
auto_unload: 节点级别的自动释放设置
|
||||
|
||||
返回:
|
||||
新的配置字典(不修改原始配置)
|
||||
"""
|
||||
config_copy = provider_config.copy()
|
||||
config_copy['auto_unload'] = auto_unload
|
||||
return config_copy
|
||||
|
||||
def _get_prompt_template(
|
||||
self,
|
||||
template_name: str,
|
||||
prompt_type: str,
|
||||
use_temp_rule: bool,
|
||||
temp_rule_content: str,
|
||||
default_content: str
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
获取提示词模板内容
|
||||
|
||||
参数:
|
||||
template_name: 模板名称
|
||||
prompt_type: 模板类型('expand_prompts', 'vision_prompts'等)
|
||||
use_temp_rule: 是否使用临时规则
|
||||
temp_rule_content: 临时规则内容
|
||||
default_content: 默认提示词内容
|
||||
|
||||
返回:
|
||||
(prompt_content, rule_name) 元组
|
||||
"""
|
||||
# 使用临时规则
|
||||
if use_temp_rule and temp_rule_content:
|
||||
return temp_rule_content, "临时规则"
|
||||
|
||||
# 从配置获取模板
|
||||
from ...config_manager import config_manager
|
||||
system_prompts = config_manager.get_system_prompts()
|
||||
|
||||
if not system_prompts:
|
||||
return default_content, "默认规则"
|
||||
|
||||
prompts = system_prompts.get(prompt_type, {})
|
||||
if not prompts:
|
||||
return default_content, "默认规则"
|
||||
|
||||
# 按显示名称匹配
|
||||
for key, value in prompts.items():
|
||||
if value.get('name') == template_name:
|
||||
content = value.get('content', '')
|
||||
if content:
|
||||
return content, template_name
|
||||
|
||||
# 按键名匹配
|
||||
for key, value in prompts.items():
|
||||
if key == template_name:
|
||||
content = value.get('content', '')
|
||||
if content:
|
||||
return content, template_name
|
||||
|
||||
# 未找到,使用默认
|
||||
return default_content, "默认规则"
|
||||
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
LLM节点基类
|
||||
为LLM类节点(扩写、翻译等)提供专用基础能力
|
||||
"""
|
||||
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
import asyncio
|
||||
|
||||
from .base_node import BaseNode
|
||||
from ...utils.common import format_api_error
|
||||
|
||||
|
||||
class LLMNodeBase(BaseNode):
|
||||
"""
|
||||
LLM节点基类
|
||||
|
||||
提供LLM节点专用功能:
|
||||
- LLM Provider配置获取
|
||||
- LLM相关的通用逻辑
|
||||
- 动态服务/模型列表生成
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_llm_service_options():
|
||||
"""
|
||||
获取所有可用的LLM服务/模型选项列表
|
||||
|
||||
返回格式: ["百度翻译", "智谱/glm-4-flash", "Ollama/qwen3:14b", ...]
|
||||
百度翻译服务无模型概念,仅显示服务名
|
||||
其他服务显示为"服务名/模型名"格式
|
||||
|
||||
返回:
|
||||
List[str]: 服务/模型选项列表
|
||||
"""
|
||||
from ...config_manager import config_manager
|
||||
|
||||
options = []
|
||||
services = config_manager.get_all_services()
|
||||
|
||||
for service in services:
|
||||
service_name = service.get('name', '')
|
||||
service_id = service.get('id', '')
|
||||
service_type = service.get('type', '')
|
||||
|
||||
# 百度翻译特殊处理:仅显示服务名
|
||||
if service_type == 'baidu':
|
||||
options.append(service_name)
|
||||
continue
|
||||
|
||||
# 其他服务:遍历llm_models
|
||||
llm_models = service.get('llm_models', [])
|
||||
for model in llm_models:
|
||||
model_name = model.get('name', '')
|
||||
if model_name:
|
||||
# 格式: "服务名/模型名"
|
||||
options.append(f"{service_name}/{model_name}")
|
||||
|
||||
# 如果没有任何选项,返回默认值避免ComfyUI报错
|
||||
if not options:
|
||||
options = ["智谱"]
|
||||
|
||||
return options
|
||||
|
||||
@staticmethod
|
||||
def get_translate_service_options():
|
||||
"""
|
||||
获取翻译服务专用的服务/模型选项列表
|
||||
|
||||
与get_llm_service_options的区别:
|
||||
- 硬编码添加"百度翻译"选项(百度翻译使用独立配置,不在model_services中)
|
||||
- 专门用于翻译节点和翻译按钮
|
||||
|
||||
返回格式: ["百度翻译", "智谱/glm-4-flash", "Ollama/qwen3:14b", ...]
|
||||
|
||||
返回:
|
||||
List[str]: 服务/模型选项列表(包含百度翻译)
|
||||
"""
|
||||
from ...config_manager import config_manager
|
||||
|
||||
options = []
|
||||
|
||||
# ---硬编码添加百度翻译---
|
||||
# 百度翻译使用独立的baidu_translate配置,不在model_services列表中
|
||||
baidu_config = config_manager.load_config().get('baidu_translate', {})
|
||||
# 即使没有配置app_id,也要显示百度选项
|
||||
options.append("百度翻译")
|
||||
|
||||
# ---动态获取其他LLM服务---
|
||||
services = config_manager.get_all_services()
|
||||
|
||||
for service in services:
|
||||
service_name = service.get('name', '')
|
||||
service_type = service.get('type', '')
|
||||
|
||||
# 遍历llm_models
|
||||
llm_models = service.get('llm_models', [])
|
||||
for model in llm_models:
|
||||
model_name = model.get('name', '')
|
||||
if model_name:
|
||||
# 格式: "服务名/模型名"
|
||||
options.append(f"{service_name}/{model_name}")
|
||||
|
||||
return options
|
||||
|
||||
@staticmethod
|
||||
def parse_service_model(service_model_str: str):
|
||||
"""
|
||||
解析"服务名/模型名"格式的字符串
|
||||
|
||||
特殊处理:
|
||||
- "百度翻译": 返回 ('baidu', None) - 百度翻译使用独立配置
|
||||
|
||||
参数:
|
||||
service_model_str: 服务/模型字符串,例如 "智谱/glm-4-flash" 或 "百度翻译"
|
||||
|
||||
返回:
|
||||
Tuple[str, Optional[str]]: (service_id, model_name)
|
||||
- service_id: 服务ID (如 'zhipu', 'baidu')
|
||||
- model_name: 模型名称,如果没有则为None
|
||||
"""
|
||||
from ...config_manager import config_manager
|
||||
|
||||
# 分割字符串
|
||||
if '/' in service_model_str:
|
||||
service_name, model_name = service_model_str.split('/', 1)
|
||||
else:
|
||||
service_name = service_model_str
|
||||
model_name = None
|
||||
|
||||
# ---特殊处理:百度翻译---
|
||||
if service_name in ['百度翻译', '百度', 'baidu']:
|
||||
return 'baidu', None
|
||||
|
||||
# 查找对应的service_id
|
||||
services = config_manager.get_all_services()
|
||||
for service in services:
|
||||
if service.get('name') == service_name:
|
||||
return service.get('id'), model_name
|
||||
|
||||
# 未找到,返回None
|
||||
return None, None
|
||||
|
||||
def _get_provider_config(
|
||||
self,
|
||||
config_manager: Any,
|
||||
provider: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取指定LLM Provider的配置
|
||||
|
||||
参数:
|
||||
config_manager: 配置管理器实例
|
||||
provider: Provider标识符(如 'zhipu', 'ollama'等)
|
||||
|
||||
返回:
|
||||
Provider配置字典,如果未找到则返回 None
|
||||
"""
|
||||
llm_config = config_manager.get_llm_config()
|
||||
|
||||
if 'providers' in llm_config and provider in llm_config['providers']:
|
||||
return llm_config['providers'][provider]
|
||||
|
||||
return None
|
||||
|
||||
def _run_llm_task(
|
||||
self,
|
||||
llm_service_func: Callable,
|
||||
provider: str,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
执行LLM任务(扩写/翻译)的统一方法
|
||||
|
||||
封装了:
|
||||
1. 异步任务的线程执行
|
||||
2. 中断异常的捕获和处理
|
||||
3. API异常的格式化
|
||||
|
||||
参数:
|
||||
llm_service_func: LLMService的异步方法
|
||||
provider: Provider名称(用于错误格式化)
|
||||
*args, **kwargs: 传递给llm_service_func的参数
|
||||
|
||||
返回:
|
||||
{"success": bool, "data": dict, "error": str} 格式的结果
|
||||
"""
|
||||
def thread_task(result_container, cancel_event):
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
# 将cancel_event传递给服务函数
|
||||
kwargs['cancel_event'] = cancel_event
|
||||
result = loop.run_until_complete(llm_service_func(*args, **kwargs))
|
||||
result_container['result'] = result
|
||||
except (asyncio.CancelledError, KeyboardInterrupt):
|
||||
print(f"{self.LOG_PREFIX} 异步任务被取消")
|
||||
result_container['result'] = {"success": False, "error": "任务被中断"}
|
||||
except Exception as e:
|
||||
# 捕获其他异常,格式化错误信息
|
||||
error_message = format_api_error(e, provider)
|
||||
result_container['result'] = {"success": False, "error": error_message}
|
||||
finally:
|
||||
# 清理所有未完成的任务,消除 "Task was destroyed but it is pending" 警告
|
||||
try:
|
||||
pending = asyncio.all_tasks(loop)
|
||||
if pending:
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
# 等待任务取消完成,忽略取消错误
|
||||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
# 创建取消事件
|
||||
import threading
|
||||
cancel_event = threading.Event()
|
||||
|
||||
return self._execute_with_interrupt(thread_task, (), cancel_event)
|
||||
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
VLM节点基类
|
||||
为VLM类节点(图像反推、视频反推等)提供专用基础能力
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
import torch
|
||||
from comfy.model_management import InterruptProcessingException
|
||||
|
||||
from .base_node import BaseNode
|
||||
from ...utils.image import tensor_to_base64, compute_image_hash
|
||||
from ...utils.common import format_api_error
|
||||
|
||||
|
||||
class VLMNodeBase(BaseNode):
|
||||
"""
|
||||
VLM节点基类
|
||||
|
||||
提供VLM节点专用功能:
|
||||
- VLM Provider配置获取
|
||||
- 图像处理工具方法
|
||||
- 图像哈希计算
|
||||
- 统一的异步任务执行与中断处理
|
||||
- 动态服务/模型列表生成
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_vlm_service_options():
|
||||
"""
|
||||
获取所有可用的VLM服务/模型选项列表
|
||||
|
||||
返回格式: ["智谱/glm-4v-flash", "Ollama/llava:Q6_K", ...]
|
||||
所有服务显示为"服务名/模型名"格式
|
||||
|
||||
返回:
|
||||
List[str]: 服务/模型选项列表
|
||||
"""
|
||||
from ...config_manager import config_manager
|
||||
|
||||
options = []
|
||||
services = config_manager.get_all_services()
|
||||
|
||||
for service in services:
|
||||
service_name = service.get('name', '')
|
||||
service_id = service.get('id', '')
|
||||
service_type = service.get('type', '')
|
||||
|
||||
# 百度翻译没有vlm_models,跳过
|
||||
if service_type == 'baidu':
|
||||
continue
|
||||
|
||||
# 遍历vlm_models
|
||||
vlm_models = service.get('vlm_models', [])
|
||||
for model in vlm_models:
|
||||
model_name = model.get('name', '')
|
||||
if model_name:
|
||||
# 格式: "服务名/模型名"
|
||||
options.append(f"{service_name}/{model_name}")
|
||||
|
||||
# 如果没有任何选项,返回默认值避免ComfyUI报错
|
||||
if not options:
|
||||
options = ["智谱"]
|
||||
|
||||
return options
|
||||
|
||||
@staticmethod
|
||||
def parse_service_model(service_model_str: str):
|
||||
"""
|
||||
解析"服务名/模型名"格式的字符串
|
||||
|
||||
参数:
|
||||
service_model_str: 服务/模型字符串,例如 "智谱/glm-4v-flash"
|
||||
|
||||
返回:
|
||||
Tuple[str, Optional[str]]: (service_id, model_name)
|
||||
- service_id: 服务ID (如 'zhipu', 'ollama')
|
||||
- model_name: 模型名称,如果没有则为None
|
||||
"""
|
||||
from ...config_manager import config_manager
|
||||
|
||||
# 分割字符串
|
||||
if '/' in service_model_str:
|
||||
service_name, model_name = service_model_str.split('/', 1)
|
||||
else:
|
||||
service_name = service_model_str
|
||||
model_name = None
|
||||
|
||||
# 查找对应的service_id
|
||||
services = config_manager.get_all_services()
|
||||
for service in services:
|
||||
if service.get('name') == service_name:
|
||||
return service.get('id'), model_name
|
||||
|
||||
# 未找到,返回None
|
||||
return None, None
|
||||
|
||||
def _get_provider_config(
|
||||
self,
|
||||
config_manager: Any,
|
||||
provider: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取指定VLM Provider的配置
|
||||
|
||||
参数:
|
||||
config_manager: 配置管理器实例
|
||||
provider: Provider标识符(如 'zhipu', 'ollama'等)
|
||||
|
||||
返回:
|
||||
Provider配置字典,如果未找到则返回 None
|
||||
"""
|
||||
vision_config = config_manager.get_vision_config()
|
||||
|
||||
if 'providers' in vision_config and provider in vision_config['providers']:
|
||||
return vision_config['providers'][provider]
|
||||
|
||||
return None
|
||||
|
||||
def _image_to_base64(self, image_tensor: torch.Tensor, quality: int = 95) -> str:
|
||||
"""
|
||||
将图像tensor转换为base64编码
|
||||
|
||||
参数:
|
||||
image_tensor: 图像tensor
|
||||
quality: JPEG压缩质量(1-100)
|
||||
|
||||
返回:
|
||||
base64编码的data URL
|
||||
"""
|
||||
return tensor_to_base64(image_tensor, quality)
|
||||
|
||||
def _compute_image_hash(self, image_tensor: Optional[torch.Tensor]) -> str:
|
||||
"""
|
||||
计算图像tensor的哈希值(用于IS_CHANGED)
|
||||
|
||||
参数:
|
||||
image_tensor: 图像tensor或None
|
||||
|
||||
返回:
|
||||
MD5哈希值的十六进制字符串
|
||||
"""
|
||||
return compute_image_hash(image_tensor)
|
||||
|
||||
def _run_async_task(
|
||||
self,
|
||||
async_func: Callable,
|
||||
provider: str,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
在独立线程中运行异步任务,并处理中断
|
||||
|
||||
这个方法封装了:
|
||||
1. 异步事件循环的创建和清理
|
||||
2. 中断异常的捕获和处理
|
||||
3. 普通异常的格式化
|
||||
|
||||
参数:
|
||||
async_func: 异步函数(coroutine function)
|
||||
provider: Provider名称(用于错误格式化)
|
||||
*args, **kwargs: 传递给async_func的参数
|
||||
|
||||
返回:
|
||||
{"success": bool, "data": dict, "error": str} 格式的结果
|
||||
|
||||
示例:
|
||||
result = self._run_async_task(
|
||||
VisionService.analyze_image,
|
||||
"zhipu",
|
||||
image_data=data,
|
||||
request_id=req_id,
|
||||
...
|
||||
)
|
||||
"""
|
||||
result_container = {}
|
||||
|
||||
def thread_target():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
# 执行异步任务
|
||||
result = loop.run_until_complete(async_func(*args, **kwargs))
|
||||
result_container['result'] = result
|
||||
except (asyncio.CancelledError, KeyboardInterrupt):
|
||||
# 捕获中断异常,不格式化错误
|
||||
print(f"{self.LOG_PREFIX} 检测到异步任务被取消")
|
||||
result_container['result'] = {"success": False, "error": "任务被中断"}
|
||||
except Exception as e:
|
||||
# 捕获其他异常,格式化错误信息
|
||||
error_message = format_api_error(e, provider)
|
||||
result_container['result'] = {"success": False, "error": error_message}
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
# 使用基类的线程中断检测方法
|
||||
self._run_thread_with_interrupt(
|
||||
thread_target,
|
||||
(),
|
||||
task_name="异步任务"
|
||||
)
|
||||
|
||||
def _run_vision_task(
|
||||
self,
|
||||
vision_service_func: Callable,
|
||||
provider: str,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
执行视觉任务(图像/视频分析)的统一方法
|
||||
|
||||
封装了:
|
||||
1. 异步任务的线程执行
|
||||
2. 中断异常的捕获和处理
|
||||
3. API异常的格式化
|
||||
|
||||
参数:
|
||||
vision_service_func: VisionService的异步方法(如analyze_image, analyze_images)
|
||||
provider: Provider名称(用于错误格式化,如"zhipu")
|
||||
*args, **kwargs: 传递给vision_service_func的参数
|
||||
|
||||
返回:
|
||||
{"success": bool, "data": dict, "error": str} 格式的结果
|
||||
|
||||
异常:
|
||||
InterruptProcessingException: 当检测到用户中断时
|
||||
|
||||
示例:
|
||||
result = self._run_vision_task(
|
||||
VisionService.analyze_image,
|
||||
"zhipu",
|
||||
image_data=data,
|
||||
request_id=req_id,
|
||||
prompt_content=prompt,
|
||||
custom_provider="zhipu",
|
||||
custom_provider_config=config
|
||||
)
|
||||
"""
|
||||
def thread_task(result_container, cancel_event):
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
# 将cancel_event传递给服务函数
|
||||
kwargs['cancel_event'] = cancel_event
|
||||
result = loop.run_until_complete(vision_service_func(*args, **kwargs))
|
||||
result_container['result'] = result
|
||||
except (asyncio.CancelledError, KeyboardInterrupt):
|
||||
# 捕获中断异常,不格式化错误
|
||||
print(f"{self.LOG_PREFIX} 检测到异步任务被取消")
|
||||
result_container['result'] = {"success": False, "error": "任务被中断"}
|
||||
except Exception as e:
|
||||
# 捕获其他异常,格式化错误信息
|
||||
error_message = format_api_error(e, provider)
|
||||
result_container['result'] = {"success": False, "error": error_message}
|
||||
finally:
|
||||
# 清理所有未完成的任务,消除 "Task was destroyed but it is pending" 警告
|
||||
try:
|
||||
pending = asyncio.all_tasks(loop)
|
||||
if pending:
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
# 等待任务取消完成,忽略取消错误
|
||||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
# 创建取消事件
|
||||
import threading
|
||||
cancel_event = threading.Event()
|
||||
|
||||
# 使用基类的中断检测执行
|
||||
return self._execute_with_interrupt(thread_task, (), cancel_event)
|
||||
|
||||
+157
-241
@@ -8,35 +8,17 @@ import re
|
||||
from comfy.model_management import InterruptProcessingException
|
||||
|
||||
from ..services.llm import LLMService
|
||||
from ..services.error_util import format_api_error
|
||||
from ..utils.common import format_api_error, format_model_with_thinking, generate_request_id, log_prepare, log_error, TASK_EXPAND, SOURCE_NODE
|
||||
from ..services.thinking_control import build_thinking_suppression
|
||||
from .base import LLMNodeBase
|
||||
|
||||
|
||||
# 定义ANSI颜色代码常量
|
||||
GREEN = "\033[32m"
|
||||
BLUE = "\033[34m"
|
||||
YELLOW = "\033[33m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
|
||||
class PromptExpand:
|
||||
class PromptExpand(LLMNodeBase):
|
||||
"""
|
||||
文本扩写节点
|
||||
- 输入"原文",根据所选扩写规则模板或临时规则进行扩写
|
||||
提示词增强节点
|
||||
- 输入"source_text",根据所选规则模板或自定义规则进行增强/扩写
|
||||
- 仅包含一个字符串输入和一个字符串输出
|
||||
"""
|
||||
# 定义日志前缀
|
||||
LOG_PREFIX = f"{GREEN}[✨PromptAssistant]{RESET}" # 绿色:结果阶段
|
||||
REQUEST_PREFIX = f"{BLUE}[✨PromptAssistant]{RESET}" # 蓝色:准备阶段
|
||||
PROCESS_PREFIX = f"{YELLOW}[✨PromptAssistant]{RESET}" # 黄色:API调用阶段
|
||||
|
||||
# 提供商显示名称映射
|
||||
PROVIDER_DISPLAY_MAP = {
|
||||
"zhipu": "智谱",
|
||||
"siliconflow": "硅基流动",
|
||||
"302ai": "302.AI",
|
||||
"ollama": "Ollama",
|
||||
"custom": "自定义"
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -51,97 +33,100 @@ class PromptExpand:
|
||||
expand_prompts = system_prompts.get('expand_prompts', {}) or {}
|
||||
active_expand_id = system_prompts.get('active_prompts', {}).get('expand')
|
||||
|
||||
# 构建提示词模板选项(显示name)
|
||||
# 构建提示词模板选项(支持分类格式:类别/规则名称)
|
||||
prompt_template_options = []
|
||||
id_to_name = {}
|
||||
id_to_display_name = {}
|
||||
for key, value in expand_prompts.items():
|
||||
# 过滤掉不在后端显示的规则
|
||||
show_in = value.get('showIn', ["frontend", "node"])
|
||||
if 'node' not in show_in:
|
||||
continue
|
||||
|
||||
name = value.get('name', key)
|
||||
id_to_name[key] = name
|
||||
prompt_template_options.append(name)
|
||||
category = value.get('category', '')
|
||||
# 如果有分类,显示为 "类别/规则名称",否则直接显示规则名称
|
||||
display_name = f"{category}/{name}" if category else name
|
||||
id_to_display_name[key] = display_name
|
||||
prompt_template_options.append(display_name)
|
||||
|
||||
# 默认选项回退
|
||||
default_template_name = prompt_template_options[0] if prompt_template_options else "扩写-自然语言"
|
||||
if active_expand_id and active_expand_id in id_to_name:
|
||||
default_template_name = id_to_name[active_expand_id]
|
||||
if active_expand_id and active_expand_id in id_to_display_name:
|
||||
default_template_name = id_to_display_name[active_expand_id]
|
||||
|
||||
# ---动态获取LLM服务/模型列表---
|
||||
service_options = cls.get_llm_service_options()
|
||||
default_service = service_options[0] if service_options else "智谱"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
# 规则模板:来自系统配置的所有扩写规则
|
||||
"规则模板": (prompt_template_options or ["扩写-自然语言"], {"default": default_template_name}),
|
||||
# 临时规则开关:BOOLEAN,并添加中文开关标签
|
||||
"临时规则": ("BOOLEAN", {"default": False, "label_on": "启用", "label_off": "禁用"}),
|
||||
# 临时规则内容输入框:仅在启用临时规则时生效
|
||||
"临时规则内容": ("STRING", {"multiline": True, "default": "", "placeholder": "请输入临时规则内容,仅在启用'临时规则'时生效"}),
|
||||
# 用户提示词:与原文合并后提交
|
||||
"用户提示词": ("STRING", {"multiline": True, "default": "", "placeholder": "填写的要扩写的提示词原文,若存在原文端口输入和内容输入,将合并提交"}),
|
||||
# 扩写服务(使用LLM提供商)
|
||||
"扩写服务": (["智谱", "硅基流动", "302.AI", "Ollama", "自定义"], {"default": "智谱"}),
|
||||
# Ollama自动释放:仅对Ollama服务生效
|
||||
"Ollama自动释放": ("BOOLEAN", {"default": True, "label_on": "启用", "label_off": "禁用", "tooltip": "⚠️ 该选项仅在选择了Ollama服务时生效"}),
|
||||
"rule": (prompt_template_options or ["扩写-自然语言"], {"default": default_template_name, "tooltip": "Choose a preset rule for prompt enhancement"}),
|
||||
# 临时规则开关
|
||||
"custom_rule": ("BOOLEAN", {"default": False, "label_on": "Enable", "label_off": "Disable", "tooltip": "Enable to use custom rule content below instead of preset"}),
|
||||
# 临时规则内容输入框
|
||||
"custom_rule_content": ("STRING", {"multiline": True, "default": "", "placeholder": "在此输入临时规则,仅在启用'临时规则'时生效"}),
|
||||
# 用户提示词
|
||||
"user_prompt": ("STRING", {"multiline": True, "default": "", "placeholder": "填写的要优化的提示词原文,若存在原文端口输入和内容输入,将合并提交"}),
|
||||
# 扩写服务
|
||||
"llm_service": (service_options, {"default": default_service, "tooltip": "Select LLM service and model"}),
|
||||
# Ollama自动释放显存
|
||||
"ollama_auto_unload": ("BOOLEAN", {"default": True, "label_on": "Enable", "label_off": "Disable", "tooltip": "Auto unload Ollama model after generation"}),
|
||||
},
|
||||
"optional": {
|
||||
# 原文输入端口:非必选
|
||||
"原文": ("STRING", {"default": "", "multiline": True, "defaultInput": True, "placeholder": "输入需要扩写的文本..."}),
|
||||
# 原文输入端口
|
||||
"source_text": ("STRING", {"default": "", "multiline": True, "defaultInput": True, "placeholder": "Input text to enhance..."}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("扩写结果",)
|
||||
FUNCTION = "expand"
|
||||
CATEGORY = "✨提示词小助手"
|
||||
RETURN_NAMES = ("enhanced_text",)
|
||||
FUNCTION = "enhance"
|
||||
CATEGORY = "✨Prompt Assistant"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, 规则模板=None, 临时规则=None, 临时规则内容=None, 用户提示词=None, 扩写服务=None, Ollama自动释放=None, 原文=None):
|
||||
def IS_CHANGED(cls, rule=None, custom_rule=None, custom_rule_content=None, user_prompt=None, llm_service=None, ollama_auto_unload=None, source_text=None, unique_id=None):
|
||||
"""
|
||||
只在输入内容真正变化时才触发重新执行
|
||||
使用输入参数的哈希值作为判断依据
|
||||
"""
|
||||
text_hash = hashlib.md5(((原文 or "")).encode('utf-8')).hexdigest()
|
||||
temp_rule_hash = hashlib.md5((临时规则内容 or "").encode('utf-8')).hexdigest()
|
||||
user_hint_hash = hashlib.md5((用户提示词 or "").encode('utf-8')).hexdigest()
|
||||
text_hash = hashlib.md5(((source_text or "")).encode('utf-8')).hexdigest()
|
||||
temp_rule_hash = hashlib.md5((custom_rule_content or "").encode('utf-8')).hexdigest()
|
||||
user_hint_hash = hashlib.md5((user_prompt or "").encode('utf-8')).hexdigest()
|
||||
|
||||
input_hash = hash((
|
||||
规则模板,
|
||||
bool(临时规则),
|
||||
rule,
|
||||
bool(custom_rule),
|
||||
temp_rule_hash,
|
||||
user_hint_hash,
|
||||
扩写服务,
|
||||
bool(Ollama自动释放),
|
||||
llm_service,
|
||||
bool(ollama_auto_unload),
|
||||
text_hash,
|
||||
))
|
||||
return input_hash
|
||||
|
||||
def expand(self, 规则模板, 临时规则, 临时规则内容, 用户提示词, 扩写服务, Ollama自动释放, 原文=None):
|
||||
def enhance(self, rule, custom_rule, custom_rule_content, user_prompt, llm_service, ollama_auto_unload, source_text=None, unique_id=None):
|
||||
"""
|
||||
扩写文本函数
|
||||
|
||||
Args:
|
||||
原文: 输入的文本(可为空)
|
||||
规则模板: 选择的扩写规则模板名称(来自系统配置)
|
||||
临时规则: 是否启用临时规则(BOOLEAN)
|
||||
临时规则内容: 临时规则的内容(启用时生效)
|
||||
用户提示词: 附加的用户提示信息,将与原文合并提交
|
||||
扩写服务: LLM提供商(智谱/硅基流动/自定义)
|
||||
Ollama自动释放: 是否在调用完成后自动释放Ollama模型(仅对Ollama生效)
|
||||
|
||||
Returns:
|
||||
tuple: 扩写结果
|
||||
增强/扩写文本函数
|
||||
"""
|
||||
try:
|
||||
# 允许原文为空,但原文与用户提示词至少有一项非空
|
||||
原文 = (原文 or "").strip()
|
||||
用户提示词 = (用户提示词 or "").strip()
|
||||
if not 原文 and not 用户提示词:
|
||||
source_text = (source_text or "").strip()
|
||||
user_prompt = (user_prompt or "").strip()
|
||||
if not source_text and not user_prompt:
|
||||
return ("",)
|
||||
|
||||
# 准备系统提示词(规则)
|
||||
system_message = None
|
||||
rule_name = "临时规则" if (临时规则 and 临时规则内容) else 规则模板
|
||||
rule_name = "Custom Rule" if (custom_rule and custom_rule_content) else rule
|
||||
|
||||
if 临时规则 and 临时规则内容:
|
||||
if custom_rule and custom_rule_content:
|
||||
# 使用临时规则
|
||||
system_message = {"role": "system", "content": 临时规则内容}
|
||||
system_message = {"role": "system", "content": custom_rule_content}
|
||||
else:
|
||||
# 使用模板:从config_manager获取系统提示词配置
|
||||
from ..config_manager import config_manager
|
||||
@@ -149,206 +134,138 @@ class PromptExpand:
|
||||
expand_prompts = system_prompts.get('expand_prompts', {}) if system_prompts else {}
|
||||
|
||||
# 查找选定的提示词模板(按显示名称匹配)
|
||||
# 显示名称格式:有分类时为 "类别/规则名称",无分类时为 "规则名称"
|
||||
template_found = False
|
||||
for key, value in expand_prompts.items():
|
||||
if value.get('name') == 规则模板:
|
||||
name = value.get('name', key)
|
||||
category = value.get('category', '')
|
||||
# 构建与下拉列表一致的显示名称
|
||||
display_name = f"{category}/{name}" if category else name
|
||||
if display_name == rule:
|
||||
system_message = {"role": value.get('role', 'system'), "content": value.get('content', '')}
|
||||
template_found = True
|
||||
break
|
||||
if not template_found:
|
||||
# 允许用键名直接匹配
|
||||
# 允许用规则名称或键名直接匹配(兼容旧格式)
|
||||
for key, value in expand_prompts.items():
|
||||
if key == 规则模板:
|
||||
if value.get('name') == rule or key == rule:
|
||||
system_message = {"role": value.get('role', 'system'), "content": value.get('content', '')}
|
||||
template_found = True
|
||||
break
|
||||
if not template_found or not system_message or not system_message.get('content'):
|
||||
# 回退到默认
|
||||
system_message = {"role": "system", "content": "你是一名提示词扩写专家,请将用户给定文本扩写为更完整、更具可读性和可执行性的提示词。"}
|
||||
rule_name = "默认规则"
|
||||
rule_name = "Default Rule"
|
||||
|
||||
# 选择服务提供商
|
||||
provider_map = {
|
||||
"智谱": "zhipu",
|
||||
"硅基流动": "siliconflow",
|
||||
"302.AI": "302ai",
|
||||
"Ollama": "ollama",
|
||||
"自定义": "custom",
|
||||
}
|
||||
selected_provider = provider_map.get(扩写服务)
|
||||
if not selected_provider:
|
||||
raise ValueError(f"不支持的扩写服务: {扩写服务}")
|
||||
|
||||
# 获取对应provider的配置
|
||||
# ---解析服务/模型字符串---
|
||||
service_id, model_name = self.parse_service_model(llm_service)
|
||||
if not service_id:
|
||||
raise ValueError(f"Invalid service selection: {llm_service}")
|
||||
|
||||
# ---获取服务配置---
|
||||
from ..config_manager import config_manager
|
||||
provider_config = self._get_provider_config(config_manager, selected_provider)
|
||||
if not provider_config:
|
||||
provider_display = {"zhipu": "智谱", "siliconflow": "硅基流动", "302ai": "302.AI", "ollama": "Ollama", "custom": "自定义"}.get(selected_provider, selected_provider)
|
||||
raise ValueError(f"未找到{provider_display}的配置,请先完成API配置")
|
||||
service = config_manager.get_service(service_id)
|
||||
if not service:
|
||||
raise ValueError(f"Service config not found: {llm_service}")
|
||||
|
||||
# ---构建provider_config---
|
||||
# 查找指定的模型或默认模型
|
||||
llm_models = service.get('llm_models', [])
|
||||
target_model = None
|
||||
|
||||
if model_name:
|
||||
# 查找指定的模型
|
||||
target_model = next((m for m in llm_models if m.get('name') == model_name), None)
|
||||
|
||||
if not target_model:
|
||||
# 使用默认模型或第一个模型
|
||||
target_model = next((m for m in llm_models if m.get('is_default')),
|
||||
llm_models[0] if llm_models else None)
|
||||
|
||||
if not target_model:
|
||||
raise ValueError(f"Service {llm_service} has no available models")
|
||||
|
||||
# 构建配置对象
|
||||
provider_config = {
|
||||
'provider': service_id,
|
||||
'model': target_model.get('name', ''),
|
||||
'base_url': service.get('base_url', ''),
|
||||
'api_key': service.get('api_key', ''),
|
||||
'temperature': target_model.get('temperature', 0.7),
|
||||
'max_tokens': target_model.get('max_tokens', 1000),
|
||||
'top_p': target_model.get('top_p', 0.9),
|
||||
}
|
||||
|
||||
# Ollama特殊处理:添加auto_unload配置
|
||||
if service.get('type') == 'ollama':
|
||||
provider_config['auto_unload'] = ollama_auto_unload
|
||||
|
||||
# 执行扩写(异步线程 + 可中断)
|
||||
request_id = f"expand_{int(time.time())}_{random.randint(1000, 9999)}"
|
||||
request_id = generate_request_id("exp", None, unique_id)
|
||||
|
||||
# 合并原文与用户提示词
|
||||
# 合并顺序:输入端口(原文)在前,节点输入框(用户提示词)在后
|
||||
combined_text = 用户提示词 if not 原文 else (f"{原文}\n\n{用户提示词}" if 用户提示词 else 原文)
|
||||
# 合并顺序:输入端口(source_text)在前,节点输入框(user_prompt)在后
|
||||
combined_text = user_prompt if not source_text else (f"{source_text}\n\n{user_prompt}" if user_prompt else source_text)
|
||||
|
||||
# 准备阶段日志(合并为一条)
|
||||
print(f"{self.REQUEST_PREFIX} 扩写准备 | 服务:{扩写服务} | 模型:{provider_config.get('model')} | 规则:{rule_name} | 长度:{len(combined_text)} | 请求ID:{request_id}")
|
||||
# 检查是否关闭思维链
|
||||
model_name = provider_config.get('model')
|
||||
disable_thinking_enabled = service.get('disable_thinking', True)
|
||||
thinking_extra = build_thinking_suppression(service_id, model_name) if disable_thinking_enabled else None
|
||||
model_display = format_model_with_thinking(model_name, bool(thinking_extra))
|
||||
|
||||
# 获取服务显示名称
|
||||
service_display_name = service.get('name', service_id)
|
||||
|
||||
result_container = {}
|
||||
thread = threading.Thread(
|
||||
target=self._run_llm_expand,
|
||||
args=(combined_text, system_message, request_id, result_container, selected_provider, provider_config, 扩写服务, Ollama自动释放)
|
||||
# 准备阶段日志
|
||||
log_prepare(TASK_EXPAND, request_id, SOURCE_NODE, service_display_name, model_display, rule_name, {"长度": len(combined_text)})
|
||||
|
||||
# 检查API密钥和模型
|
||||
api_key = provider_config.get('api_key', '')
|
||||
model = provider_config.get('model', '')
|
||||
|
||||
if not api_key or not model:
|
||||
raise ValueError(f"Please configure API key and model for {llm_service}")
|
||||
|
||||
# 执行扩写(异步线程 + 可中断)
|
||||
result = self._run_llm_task(
|
||||
LLMService.expand_prompt,
|
||||
service_id,
|
||||
prompt=combined_text,
|
||||
request_id=request_id,
|
||||
stream_callback=None,
|
||||
custom_provider=service_id,
|
||||
custom_provider_config=provider_config,
|
||||
system_message_override=system_message,
|
||||
task_type=TASK_EXPAND,
|
||||
source=SOURCE_NODE
|
||||
)
|
||||
thread.start()
|
||||
|
||||
# 等待扩写完成,同时检查中断
|
||||
while thread.is_alive():
|
||||
try:
|
||||
import nodes
|
||||
nodes.before_node_execution()
|
||||
except:
|
||||
print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止扩写任务...")
|
||||
result_container['interrupted'] = True
|
||||
thread.join(timeout=1.0)
|
||||
if thread.is_alive():
|
||||
print(f"{self.LOG_PREFIX} 扩写线程未能及时响应中断")
|
||||
raise InterruptProcessingException()
|
||||
time.sleep(0.1)
|
||||
|
||||
result = result_container.get('result')
|
||||
|
||||
if result and result.get('success'):
|
||||
expanded_text = result.get('data', {}).get('expanded', '').strip()
|
||||
if not expanded_text:
|
||||
error_msg = 'API返回结果为空,请检查API密钥、模型配置或网络连接'
|
||||
print(f"{self.LOG_PREFIX} 扩写失败 | 错误:{error_msg}")
|
||||
raise RuntimeError(f"扩写失败: {error_msg}")
|
||||
error_msg = 'API returned empty result'
|
||||
log_error(TASK_EXPAND, request_id, error_msg, source=SOURCE_NODE)
|
||||
raise RuntimeError(f"Enhancement failed: {error_msg}")
|
||||
# 结果阶段日志由服务层统一输出,节点层不再重复打印
|
||||
return (expanded_text,)
|
||||
else:
|
||||
error_msg = result.get('error', '扩写失败,未知错误') if result else '扩写服务未返回结果'
|
||||
print(f"{self.LOG_PREFIX} 扩写失败: {error_msg}")
|
||||
raise RuntimeError(f"扩写失败: {error_msg}")
|
||||
error_msg = result.get('error', 'Unknown error') if result else 'No result returned'
|
||||
# 如果是中断错误,直接抛出InterruptProcessingException,不打印日志(由基类打印)
|
||||
if error_msg == "任务被中断":
|
||||
raise InterruptProcessingException()
|
||||
log_error(TASK_EXPAND, request_id, error_msg, source=SOURCE_NODE)
|
||||
raise RuntimeError(f"Enhancement failed: {error_msg}")
|
||||
|
||||
except InterruptProcessingException:
|
||||
print(f"{self.LOG_PREFIX} 扩写任务被用户取消")
|
||||
# 不打印日志,由基类统一打印
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = format_api_error(e, 扩写服务)
|
||||
print(f"{self.LOG_PREFIX} 扩写异常: {error_msg}")
|
||||
raise RuntimeError(f"扩写异常: {error_msg}")
|
||||
error_msg = format_api_error(e, llm_service)
|
||||
log_error(TASK_EXPAND, request_id, error_msg, source=SOURCE_NODE)
|
||||
raise RuntimeError(f"Enhancement error: {error_msg}")
|
||||
|
||||
def _get_provider_config(self, config_manager, provider):
|
||||
"""获取指定provider的配置(LLM)"""
|
||||
llm_config = config_manager.get_llm_config()
|
||||
if 'providers' in llm_config and provider in llm_config['providers']:
|
||||
return llm_config['providers'][provider]
|
||||
return None
|
||||
# _get_provider_config 方法已由基类 LLMNodeBase 提供
|
||||
|
||||
async def _unload_ollama_model(self, model: str, provider_config: dict, auto_unload: bool):
|
||||
"""
|
||||
卸载Ollama模型以释放显存和内存
|
||||
|
||||
参数:
|
||||
model: 模型名称
|
||||
provider_config: 提供商配置字典
|
||||
auto_unload: 是否启用自动释放(来自节点参数)
|
||||
"""
|
||||
try:
|
||||
# 检查是否启用自动释放(使用节点参数,不从provider_config读取)
|
||||
if not auto_unload:
|
||||
return
|
||||
|
||||
# 获取base_url
|
||||
base_url = provider_config.get('base_url', 'http://localhost:11434')
|
||||
# 确保URL不以/v1结尾(Ollama原生API不需要/v1)
|
||||
if base_url.endswith('/v1'):
|
||||
base_url = base_url[:-3]
|
||||
|
||||
# 调用Ollama API卸载模型
|
||||
url = f"{base_url}/api/generate"
|
||||
payload = {
|
||||
"model": model,
|
||||
"keep_alive": 0 # 立即卸载模型
|
||||
}
|
||||
|
||||
print(f"{self.PROCESS_PREFIX} Ollama自动释放显存 | 模型:{model}")
|
||||
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
await client.post(url, json=payload)
|
||||
|
||||
except Exception as e:
|
||||
# 释放失败不影响主流程,只记录警告
|
||||
print(f"{self.LOG_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}")
|
||||
|
||||
def _run_llm_expand(self, text, system_message, request_id, result_container, provider, provider_config, service_name, auto_unload):
|
||||
"""在独立线程中运行LLM扩写"""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
if result_container.get('interrupted'):
|
||||
result_container['result'] = {"success": False, "error": "任务被中断"}
|
||||
return
|
||||
|
||||
# 获取API密钥和模型
|
||||
api_key = provider_config.get('api_key', '')
|
||||
model = provider_config.get('model', '')
|
||||
temperature = provider_config.get('temperature', 0.7)
|
||||
top_p = provider_config.get('top_p', 0.9)
|
||||
max_tokens = provider_config.get('max_tokens', 1500)
|
||||
|
||||
if not api_key or not model:
|
||||
result_container['result'] = {
|
||||
"success": False,
|
||||
"error": f"请先配置{provider}的API密钥和模型"
|
||||
}
|
||||
return
|
||||
|
||||
# 检查是否启用直连模式
|
||||
bypass_proxy = False
|
||||
try:
|
||||
from ..config_manager import config_manager as cm
|
||||
settings = cm.get_settings()
|
||||
bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
direct_mode_tag = "(直连)" if bypass_proxy else ""
|
||||
|
||||
# 获取提供商显示名称
|
||||
provider_display_name = PromptExpand.PROVIDER_DISPLAY_MAP.get(provider, provider)
|
||||
|
||||
# 统一走服务层(含Gemini/网关兼容、思维链抑制、降级与重试)
|
||||
service_result = loop.run_until_complete(
|
||||
LLMService.expand_prompt(
|
||||
prompt=text,
|
||||
request_id=request_id,
|
||||
stream_callback=None,
|
||||
custom_provider=provider,
|
||||
custom_provider_config=provider_config,
|
||||
system_message_override=system_message
|
||||
)
|
||||
)
|
||||
|
||||
if result_container.get('interrupted'):
|
||||
result_container['result'] = {"success": False, "error": "任务被中断"}
|
||||
return
|
||||
|
||||
result_container['result'] = service_result
|
||||
|
||||
# 结果日志由服务层统一打印
|
||||
|
||||
except Exception as e:
|
||||
result_container['result'] = {
|
||||
"success": False,
|
||||
"error": format_api_error(e, provider_display_name)
|
||||
}
|
||||
print(f"{self.LOG_PREFIX} 扩写失败 | 服务:{provider_display_name} | 错误:{result_container['result']['error']}")
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
# 节点映射,用于向ComfyUI注册节点
|
||||
@@ -358,6 +275,5 @@ NODE_CLASS_MAPPINGS = {
|
||||
|
||||
# 节点显示名称映射
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PromptExpand": "✨提示词扩写",
|
||||
"PromptExpand": "✨Prompt Enhance",
|
||||
}
|
||||
|
||||
|
||||
+136
-231
@@ -14,33 +14,16 @@ from PIL import Image
|
||||
from comfy.model_management import InterruptProcessingException
|
||||
|
||||
from ..services.vlm import VisionService
|
||||
from ..services.error_util import format_api_error
|
||||
|
||||
# 定义ANSI颜色代码常量
|
||||
GREEN = "\033[32m"
|
||||
BLUE = "\033[34m"
|
||||
YELLOW = "\033[33m"
|
||||
RESET = "\033[0m"
|
||||
from ..utils.common import format_api_error, format_model_with_thinking, generate_request_id, log_prepare, log_error, TASK_IMAGE_CAPTION, SOURCE_NODE
|
||||
from ..services.thinking_control import build_thinking_suppression
|
||||
from .base import VLMNodeBase
|
||||
|
||||
|
||||
class ImageCaptionNode:
|
||||
class ImageCaptionNode(VLMNodeBase):
|
||||
"""
|
||||
图像反推提示词节点
|
||||
分析输入图像并生成描述性提示词
|
||||
"""
|
||||
# 定义日志前缀
|
||||
LOG_PREFIX = f"{GREEN}[✨PromptAssistant]{RESET}" # 绿色:结果阶段
|
||||
REQUEST_PREFIX = f"{BLUE}[✨PromptAssistant]{RESET}" # 蓝色:准备阶段
|
||||
PROCESS_PREFIX = f"{YELLOW}[✨PromptAssistant]{RESET}" # 黄色:API调用阶段
|
||||
|
||||
# 提供商显示名称映射
|
||||
PROVIDER_DISPLAY_MAP = {
|
||||
"zhipu": "智谱",
|
||||
"siliconflow": "硅基流动",
|
||||
"302ai": "302.AI",
|
||||
"ollama": "Ollama",
|
||||
"custom": "自定义"
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -53,74 +36,76 @@ class ImageCaptionNode:
|
||||
if system_prompts and 'vision_prompts' in system_prompts:
|
||||
vision_prompts = system_prompts['vision_prompts']
|
||||
|
||||
# 构建提示词模板选项
|
||||
# 构建提示词模板选项(支持分类格式:类别/规则名称)
|
||||
prompt_template_options = []
|
||||
for key, value in vision_prompts.items():
|
||||
# 过滤掉不在后端显示的规则
|
||||
show_in = value.get('showIn', ["frontend", "node"])
|
||||
if 'node' not in show_in:
|
||||
continue
|
||||
|
||||
name = value.get('name', key)
|
||||
prompt_template_options.append(name)
|
||||
category = value.get('category', '')
|
||||
# 如果有分类,显示为 "类别/规则名称",否则直接显示规则名称
|
||||
display_name = f"{category}/{name}" if category else name
|
||||
prompt_template_options.append(display_name)
|
||||
|
||||
# 如果没有选项,添加一个默认选项
|
||||
if not prompt_template_options:
|
||||
prompt_template_options = ["默认中文反推提示词"]
|
||||
|
||||
# ---动态获取VLM服务/模型列表---
|
||||
service_options = cls.get_vlm_service_options()
|
||||
default_service = service_options[0] if service_options else "智谱"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"图像": ("IMAGE",),
|
||||
"规则模板": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "默认中文反推提示词"}),
|
||||
"临时规则": ("BOOLEAN", {"default": False, "label_on": "启用", "label_off": "禁用"}),
|
||||
"临时规则内容": ("STRING", {"multiline": True, "default": "", "placeholder": "请输入临时规则内容,仅在启用'临时规则'时生效"}),
|
||||
"视觉服务": (["智谱", "硅基流动", "302.AI", "Ollama", "自定义"], {"default": "智谱"}),
|
||||
# Ollama自动释放:仅对Ollama视觉服务生效
|
||||
"Ollama自动释放": ("BOOLEAN", {"default": True, "label_on": "启用", "label_off": "禁用", "tooltip": "⚠️ 该选项仅在选择了Ollama服务时生效"}),
|
||||
"image": ("IMAGE",),
|
||||
"rule": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "默认中文反推提示词", "tooltip": "Choose a preset rule for image captioning"}),
|
||||
"custom_rule": ("BOOLEAN", {"default": False, "label_on": "Enable", "label_off": "Disable", "tooltip": "Enable to use custom rule content below"}),
|
||||
"custom_rule_content": ("STRING", {"multiline": True, "default": "", "placeholder": "在此输入临时规则,仅在启用'临时规则'时生效"}),
|
||||
"user_prompt": ("STRING", {"multiline": True, "default": "", "placeholder": "输入额外的具体要求,将与规则一起发送给模型"}),
|
||||
"vlm_service": (service_options, {"default": default_service, "tooltip": "Select VLM service and model"}),
|
||||
# Ollama Automatic VRAM Unload
|
||||
"ollama_auto_unload": ("BOOLEAN", {"default": True, "label_on": "Enable", "label_off": "Disable", "tooltip": "Auto unload Ollama model after generation"}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("提示词输出",)
|
||||
RETURN_NAMES = ("prompt_output",)
|
||||
FUNCTION = "analyze_image"
|
||||
CATEGORY = "✨提示词小助手"
|
||||
CATEGORY = "✨Prompt Assistant"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, 图像=None, 规则模板=None, 临时规则=None, 临时规则内容=None, 视觉服务=None, Ollama自动释放=None):
|
||||
def IS_CHANGED(cls, image=None, rule=None, custom_rule=None, custom_rule_content=None, user_prompt=None, vlm_service=None, ollama_auto_unload=None, unique_id=None):
|
||||
"""
|
||||
只在输入内容真正变化时才触发重新执行
|
||||
使用输入参数的哈希值作为判断依据
|
||||
"""
|
||||
# 计算图像的哈希值(只使用第一帧的部分数据,避免计算量过大)
|
||||
img_hash = ""
|
||||
if 图像 is not None:
|
||||
try:
|
||||
if len(图像.shape) == 4:
|
||||
# 取第一帧的中心区域作为哈希计算依据
|
||||
h, w = 图像.shape[1:3]
|
||||
center_h, center_w = h // 2, w // 2
|
||||
size = min(100, h // 4, w // 4) # 限制计算区域大小
|
||||
img_data = 图像[0,
|
||||
max(0, center_h - size):min(h, center_h + size),
|
||||
max(0, center_w - size):min(w, center_w + size),
|
||||
0].cpu().numpy().tobytes()
|
||||
img_hash = hashlib.md5(img_data).hexdigest()
|
||||
else:
|
||||
# 如果不是4D张量,使用整个张量的哈希
|
||||
img_data = 图像.cpu().numpy().tobytes()
|
||||
img_hash = hashlib.md5(img_data).hexdigest()
|
||||
except Exception:
|
||||
img_hash = "0"
|
||||
# 导入图像哈希工具函数
|
||||
from ..utils.image import compute_image_hash
|
||||
|
||||
# 计算图像的哈希值
|
||||
img_hash = compute_image_hash(image)
|
||||
|
||||
# 组合所有输入的哈希值
|
||||
input_hash = hash((
|
||||
img_hash,
|
||||
规则模板,
|
||||
bool(临时规则),
|
||||
临时规则内容,
|
||||
视觉服务,
|
||||
bool(Ollama自动释放)
|
||||
rule,
|
||||
bool(custom_rule),
|
||||
custom_rule_content,
|
||||
user_prompt,
|
||||
vlm_service,
|
||||
bool(ollama_auto_unload)
|
||||
))
|
||||
|
||||
return input_hash
|
||||
|
||||
def analyze_image(self, 图像, 规则模板, 临时规则, 临时规则内容, 视觉服务, Ollama自动释放):
|
||||
def analyze_image(self, image, rule, custom_rule, custom_rule_content, user_prompt, vlm_service, ollama_auto_unload, unique_id=None):
|
||||
"""
|
||||
分析图像并生成提示词
|
||||
|
||||
@@ -129,6 +114,7 @@ class ImageCaptionNode:
|
||||
规则模板: 选择的提示词模板
|
||||
临时规则: 是否启用临时规则
|
||||
临时规则内容: 临时规则的内容
|
||||
用户提示词: 用户补充的提示词
|
||||
视觉服务: 选择的视觉服务
|
||||
|
||||
Returns:
|
||||
@@ -136,20 +122,20 @@ class ImageCaptionNode:
|
||||
"""
|
||||
try:
|
||||
# 检查输入
|
||||
if 图像 is None:
|
||||
if image is None:
|
||||
raise ValueError("输入图像不能为空")
|
||||
|
||||
# 将图像转换为base64编码
|
||||
image_data = self._image_to_base64(图像)
|
||||
image_data = self._image_to_base64(image)
|
||||
|
||||
# 获取提示词模板内容
|
||||
prompt_template = None
|
||||
|
||||
# 确定使用的规则
|
||||
rule_name = "临时规则" if (临时规则 and 临时规则内容) else 规则模板
|
||||
rule_name = "Custom Rule" if (custom_rule and custom_rule_content) else rule
|
||||
|
||||
if 临时规则 and 临时规则内容:
|
||||
prompt_template = 临时规则内容
|
||||
if custom_rule and custom_rule_content:
|
||||
prompt_template = custom_rule_content
|
||||
else:
|
||||
# 从config_manager获取系统提示词配置
|
||||
from ..config_manager import config_manager
|
||||
@@ -160,18 +146,23 @@ class ImageCaptionNode:
|
||||
if system_prompts and 'vision_prompts' in system_prompts:
|
||||
vision_prompts = system_prompts['vision_prompts']
|
||||
|
||||
# 查找选定的提示词模板
|
||||
# 查找选定的提示词模板(按显示名称匹配)
|
||||
# 显示名称格式:有分类时为 "类别/规则名称",无分类时为 "规则名称"
|
||||
template_found = False
|
||||
for key, value in vision_prompts.items():
|
||||
if value.get('name') == 规则模板:
|
||||
name = value.get('name', key)
|
||||
category = value.get('category', '')
|
||||
# 构建与下拉列表一致的显示名称
|
||||
display_name = f"{category}/{name}" if category else name
|
||||
if display_name == rule:
|
||||
prompt_template = value.get('content')
|
||||
template_found = True
|
||||
break
|
||||
|
||||
if not template_found:
|
||||
# 尝试直接匹配键名
|
||||
# 允许用规则名称或键名直接匹配(兼容旧格式)
|
||||
for key, value in vision_prompts.items():
|
||||
if key == 规则模板:
|
||||
if value.get('name') == rule or key == rule:
|
||||
prompt_template = value.get('content')
|
||||
template_found = True
|
||||
break
|
||||
@@ -179,195 +170,109 @@ class ImageCaptionNode:
|
||||
# 如果没有找到提示词模板,使用默认值
|
||||
if not template_found or not prompt_template:
|
||||
prompt_template = "请详细描述这张图片的内容,包括主体、场景、风格、色彩等要素。"
|
||||
rule_name = "默认规则"
|
||||
rule_name = "Default Rule"
|
||||
|
||||
# 映射视觉服务选项到provider
|
||||
provider_map = {
|
||||
"智谱": "zhipu",
|
||||
"硅基流动": "siliconflow",
|
||||
"302.AI": "302ai",
|
||||
"Ollama": "ollama",
|
||||
"自定义": "custom"
|
||||
}
|
||||
# 拼接用户提示词
|
||||
if user_prompt and user_prompt.strip():
|
||||
prompt_template = f"{prompt_template}\n\n用户补充要求:\n{user_prompt}"
|
||||
|
||||
# 获取选定的provider
|
||||
selected_provider = provider_map.get(视觉服务)
|
||||
if not selected_provider:
|
||||
raise ValueError(f"不支持的视觉服务: {视觉服务}")
|
||||
|
||||
# 获取对应provider的配置
|
||||
# ---解析服务/模型字符串---
|
||||
service_id, model_name = self.parse_service_model(vlm_service)
|
||||
if not service_id:
|
||||
raise ValueError(f"Invalid service selection: {vlm_service}")
|
||||
|
||||
# ---获取服务配置---
|
||||
from ..config_manager import config_manager
|
||||
provider_config = self._get_provider_config(config_manager, selected_provider)
|
||||
if not provider_config:
|
||||
raise ValueError(f"未找到{视觉服务}的配置,请先完成API配置")
|
||||
service = config_manager.get_service(service_id)
|
||||
if not service:
|
||||
raise ValueError(f"Service config not found: {vlm_service}")
|
||||
|
||||
# ---构建provider_config---
|
||||
# 查找指定的模型或默认模型
|
||||
vlm_models = service.get('vlm_models', [])
|
||||
target_model = None
|
||||
|
||||
if model_name:
|
||||
# 查找指定的模型
|
||||
target_model = next((m for m in vlm_models if m.get('name') == model_name), None)
|
||||
|
||||
if not target_model:
|
||||
# 使用默认模型或第一个模型
|
||||
target_model = next((m for m in vlm_models if m.get('is_default')),
|
||||
vlm_models[0] if vlm_models else None)
|
||||
|
||||
if not target_model:
|
||||
raise ValueError(f"Service {vlm_service} has no available models")
|
||||
|
||||
# 构建配置对象
|
||||
provider_config = {
|
||||
'provider': service_id,
|
||||
'model': target_model.get('name', ''),
|
||||
'base_url': service.get('base_url', ''),
|
||||
'api_key': service.get('api_key', ''),
|
||||
'temperature': target_model.get('temperature', 0.7),
|
||||
'max_tokens': target_model.get('max_tokens', 500),
|
||||
'top_p': target_model.get('top_p', 0.9),
|
||||
}
|
||||
|
||||
# Ollama特殊处理:添加auto_unload配置
|
||||
if service.get('type') == 'ollama':
|
||||
provider_config['auto_unload'] = ollama_auto_unload
|
||||
|
||||
# 创建请求ID
|
||||
request_id = f"image_caption_{int(time.time())}_{random.randint(1000, 9999)}"
|
||||
request_id = generate_request_id("icap", None, unique_id)
|
||||
|
||||
# 准备阶段日志(合并为一条)
|
||||
print(f"{self.REQUEST_PREFIX} 图像反推准备 | 服务:{视觉服务} | 模型:{provider_config.get('model')} | 规则:{rule_name} | 请求ID:{request_id}")
|
||||
# 检查是否关闭思维链
|
||||
model_full_name = provider_config.get('model')
|
||||
disable_thinking_enabled = service.get('disable_thinking', True)
|
||||
thinking_extra = build_thinking_suppression(service_id, model_full_name) if disable_thinking_enabled else None
|
||||
model_display = format_model_with_thinking(model_full_name, bool(thinking_extra))
|
||||
|
||||
# 获取服务显示名称
|
||||
service_display_name = service.get('name', service_id)
|
||||
|
||||
# 准备阶段日志
|
||||
log_prepare(TASK_IMAGE_CAPTION, request_id, SOURCE_NODE, service_display_name, model_display, rule_name)
|
||||
|
||||
# 执行图像分析
|
||||
result = self._analyze_with_vision_service(image_data, prompt_template, selected_provider, provider_config, request_id, Ollama自动释放)
|
||||
result = self._run_vision_task(
|
||||
VisionService.analyze_image,
|
||||
service_id,
|
||||
image_data=image_data,
|
||||
request_id=request_id,
|
||||
stream_callback=None,
|
||||
prompt_content=prompt_template,
|
||||
custom_provider=service_id,
|
||||
custom_provider_config=provider_config,
|
||||
task_type=TASK_IMAGE_CAPTION,
|
||||
source=SOURCE_NODE
|
||||
)
|
||||
|
||||
if result and result.get('success'):
|
||||
description = result.get('data', {}).get('description', '').strip()
|
||||
if not description:
|
||||
error_msg = 'API返回结果为空,请检查API密钥、模型配置或网络连接'
|
||||
print(f"{self.LOG_PREFIX} 图像反推失败 | 错误:{error_msg}")
|
||||
log_error(TASK_IMAGE_CAPTION, request_id, error_msg, source=SOURCE_NODE)
|
||||
raise RuntimeError(f"分析失败: {error_msg}")
|
||||
|
||||
# 服务层已经打印了完成日志,这里不再重复
|
||||
return (description,)
|
||||
else:
|
||||
error_msg = result.get('error', '分析失败,未知错误') if result else '分析服务未返回结果'
|
||||
print(f"{self.LOG_PREFIX} 图像反推分析失败: {error_msg}")
|
||||
# 如果是中断错误,直接抛出InterruptProcessingException,不打印日志(由基类打印)
|
||||
if error_msg == "任务被中断":
|
||||
raise InterruptProcessingException()
|
||||
log_error(TASK_IMAGE_CAPTION, request_id, error_msg, source=SOURCE_NODE)
|
||||
raise RuntimeError(f"分析失败: {error_msg}")
|
||||
|
||||
except InterruptProcessingException:
|
||||
print(f"{self.LOG_PREFIX} 图像反推任务被用户取消")
|
||||
# 不打印日志,由基类统一打印
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = format_api_error(e, 视觉服务)
|
||||
print(f"{self.LOG_PREFIX} 图像反推节点异常: {error_msg}")
|
||||
error_msg = format_api_error(e, vlm_service)
|
||||
log_error(TASK_IMAGE_CAPTION, request_id, error_msg, source=SOURCE_NODE)
|
||||
raise RuntimeError(f"分析异常: {error_msg}")
|
||||
|
||||
def _analyze_with_vision_service(self, image_data, prompt_template, provider, provider_config, request_id, auto_unload):
|
||||
"""使用视觉服务分析图像"""
|
||||
try:
|
||||
result_container = {}
|
||||
|
||||
# 在独立线程中运行图像分析
|
||||
thread = threading.Thread(
|
||||
target=self._run_async_vision_analysis,
|
||||
args=(image_data, prompt_template, request_id, result_container, provider, provider_config, auto_unload)
|
||||
)
|
||||
thread.start()
|
||||
|
||||
# 等待分析完成,同时检查中断
|
||||
while thread.is_alive():
|
||||
# 检查是否被中断 - 这会抛出 InterruptProcessingException
|
||||
try:
|
||||
import nodes
|
||||
nodes.before_node_execution()
|
||||
except:
|
||||
# 如果检查中断时出现异常,说明被中断了
|
||||
print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止分析任务...")
|
||||
# 设置结果容器为中断状态,让线程知道要停止
|
||||
result_container['interrupted'] = True
|
||||
# 等待线程结束或超时
|
||||
thread.join(timeout=1.0)
|
||||
if thread.is_alive():
|
||||
print(f"{self.LOG_PREFIX} 分析线程未能及时响应中断")
|
||||
raise InterruptProcessingException()
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
return result_container.get('result')
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _run_async_vision_analysis(self, image_data, prompt_template, request_id, result_container, provider, provider_config, auto_unload):
|
||||
"""在独立线程中运行异步图像分析任务(改为复用服务层以获得Gemini兼容与降级能力)"""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
# 开始前的中断检测
|
||||
if result_container.get('interrupted'):
|
||||
result_container['result'] = {"success": False, "error": "任务被中断"}
|
||||
return
|
||||
|
||||
# 直接复用服务层:包括图像预处理、流式/非流式降级、max_tokens 兼容与思维链抑制
|
||||
service_call = VisionService.analyze_image(
|
||||
image_data=image_data,
|
||||
request_id=request_id,
|
||||
stream_callback=None,
|
||||
prompt_content=prompt_template,
|
||||
custom_provider=provider,
|
||||
custom_provider_config=provider_config
|
||||
)
|
||||
result = loop.run_until_complete(service_call)
|
||||
|
||||
# 中断检测
|
||||
if result_container.get('interrupted'):
|
||||
result_container['result'] = {"success": False, "error": "任务被中断"}
|
||||
return
|
||||
|
||||
result_container['result'] = result
|
||||
|
||||
except Exception as e:
|
||||
error_message = format_api_error(e, provider)
|
||||
result_container['result'] = {"success": False, "error": error_message}
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
def _get_provider_config(self, config_manager, provider):
|
||||
"""获取指定provider的配置"""
|
||||
vision_config = config_manager.get_vision_config()
|
||||
if 'providers' in vision_config and provider in vision_config['providers']:
|
||||
return vision_config['providers'][provider]
|
||||
return None
|
||||
|
||||
async def _unload_ollama_model(self, model: str, provider_config: dict, auto_unload: bool):
|
||||
"""
|
||||
卸载Ollama模型以释放显存和内存
|
||||
|
||||
参数:
|
||||
model: 模型名称
|
||||
provider_config: 提供商配置字典
|
||||
auto_unload: 是否启用自动释放(来自节点参数)
|
||||
"""
|
||||
try:
|
||||
# 检查是否启用自动释放(使用节点参数,不从provider_config读取)
|
||||
if not auto_unload:
|
||||
return
|
||||
|
||||
# 获取base_url
|
||||
base_url = provider_config.get('base_url', 'http://localhost:11434')
|
||||
# 确保URL不以/v1结尾(Ollama原生API不需要/v1)
|
||||
if base_url.endswith('/v1'):
|
||||
base_url = base_url[:-3]
|
||||
|
||||
# 调用Ollama API卸载模型
|
||||
url = f"{base_url}/api/generate"
|
||||
payload = {
|
||||
"model": model,
|
||||
"keep_alive": 0 # 立即卸载模型
|
||||
}
|
||||
|
||||
print(f"{self.PROCESS_PREFIX} Ollama自动释放显存 | 模型:{model}")
|
||||
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
await client.post(url, json=payload)
|
||||
|
||||
except Exception as e:
|
||||
# 释放失败不影响主流程,只记录警告
|
||||
print(f"{self.LOG_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}")
|
||||
|
||||
def _image_to_base64(self, image_tensor):
|
||||
"""将图像张量转换为base64编码"""
|
||||
# 确保图像是正确的形状 [batch, height, width, channels]
|
||||
if len(image_tensor.shape) == 4:
|
||||
# 取第一张图片
|
||||
image_tensor = image_tensor[0]
|
||||
|
||||
# 将图像转换为numpy数组并缩放到0-255范围
|
||||
image_np = (image_tensor.cpu().numpy() * 255).astype(np.uint8)
|
||||
|
||||
# 创建PIL图像
|
||||
image = Image.fromarray(image_np)
|
||||
|
||||
# 将图像转换为JPEG格式的字节流
|
||||
buffer = BytesIO()
|
||||
image.save(buffer, format="JPEG", quality=95)
|
||||
|
||||
# 将字节流转换为base64编码
|
||||
encoded_image = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
# 返回带有MIME类型的data URL
|
||||
return f"data:image/jpeg;base64,{encoded_image}"
|
||||
|
||||
# 节点映射,用于向ComfyUI注册节点
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
|
||||
+117
-229
@@ -14,33 +14,15 @@ from PIL import Image
|
||||
from comfy.model_management import InterruptProcessingException
|
||||
|
||||
from ..services.vlm import VisionService
|
||||
from ..services.error_util import format_api_error
|
||||
|
||||
# 定义ANSI颜色代码常量
|
||||
GREEN = "\033[32m"
|
||||
BLUE = "\033[34m"
|
||||
YELLOW = "\033[33m"
|
||||
RESET = "\033[0m"
|
||||
from ..utils.common import format_api_error, log_prepare, log_error, SOURCE_NODE, generate_request_id
|
||||
from .base import VLMNodeBase
|
||||
|
||||
|
||||
class KontextPresetNode:
|
||||
class KontextPresetNode(VLMNodeBase):
|
||||
"""
|
||||
Kontext预设助手节点
|
||||
使用Kontext预设分析图像并生成创意转换指令
|
||||
"""
|
||||
# 定义日志前缀
|
||||
LOG_PREFIX = f"{GREEN}[✨PromptAssistant]{RESET}" # 绿色:结果阶段
|
||||
REQUEST_PREFIX = f"{BLUE}[✨PromptAssistant]{RESET}" # 蓝色:准备阶段
|
||||
PROCESS_PREFIX = f"{YELLOW}[✨PromptAssistant]{RESET}" # 黄色:API调用阶段
|
||||
|
||||
# 提供商显示名称映射
|
||||
PROVIDER_DISPLAY_MAP = {
|
||||
"zhipu": "智谱",
|
||||
"siliconflow": "硅基流动",
|
||||
"302ai": "302.AI",
|
||||
"ollama": "Ollama",
|
||||
"custom": "自定义"
|
||||
}
|
||||
|
||||
# 缓存配置数据,避免重复从文件系统读取
|
||||
_kontext_config = None
|
||||
@@ -50,8 +32,10 @@ class KontextPresetNode:
|
||||
"""加载Kontext配置,使用缓存避免重复读取文件"""
|
||||
if cls._kontext_config is None:
|
||||
try:
|
||||
config_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config")
|
||||
kontext_presets_path = os.path.join(config_dir, "kontext_presets.json")
|
||||
from ..config_manager import config_manager
|
||||
# 使用 config_manager 的 kontext_presets_path (指向 rules 目录)
|
||||
kontext_presets_path = config_manager.kontext_presets_path
|
||||
|
||||
if os.path.exists(kontext_presets_path):
|
||||
with open(kontext_presets_path, "r", encoding="utf-8") as f:
|
||||
cls._kontext_config = json.load(f)
|
||||
@@ -61,6 +45,7 @@ class KontextPresetNode:
|
||||
print(f"{cls.LOG_PREFIX} 加载Kontext配置失败: {str(e)}")
|
||||
cls._kontext_config = {}
|
||||
return cls._kontext_config
|
||||
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -79,79 +64,71 @@ class KontextPresetNode:
|
||||
# 如果没有选项,添加一个默认选项
|
||||
if not prompt_template_options:
|
||||
prompt_template_options = ["情境深度融合"]
|
||||
|
||||
# ---动态获取VLM服务/模型列表---
|
||||
service_options = cls.get_vlm_service_options()
|
||||
default_service = service_options[0] if service_options else "智谱"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"图像": ("IMAGE",),
|
||||
"Kontext预设": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "情境深度融合"}),
|
||||
"视觉服务": (["智谱", "硅基流动", "302.AI", "Ollama", "自定义"], {"default": "智谱"}),
|
||||
# Ollama自动释放:仅对Ollama视觉服务生效
|
||||
"Ollama自动释放": ("BOOLEAN", {"default": True, "label_on": "启用", "label_off": "禁用", "tooltip": "⚠️ 该选项仅在选择了Ollama服务时生效"}),
|
||||
"image": ("IMAGE",),
|
||||
"kontext_preset": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "情境深度融合"}),
|
||||
"user_prompt": ("STRING", {"multiline": True, "default": "", "placeholder": "输入额外的具体要求,将与预设一起发送给模型"}),
|
||||
"vlm_service": (service_options, {"default": default_service, "tooltip": "Select VLM service and model"}),
|
||||
# Ollama Automatic VRAM Unload
|
||||
"ollama_auto_unload": ("BOOLEAN", {"default": True, "label_on": "Enable", "label_off": "Disable", "tooltip": "Auto unload Ollama model after generation"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("创意指令",)
|
||||
RETURN_NAMES = ("creative_instruction",)
|
||||
FUNCTION = "analyze_image"
|
||||
CATEGORY = "✨提示词小助手"
|
||||
CATEGORY = "✨Prompt Assistant"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, 图像=None, Kontext预设=None, 视觉服务=None, Ollama自动释放=None):
|
||||
def IS_CHANGED(cls, image=None, kontext_preset=None, user_prompt=None, vlm_service=None, ollama_auto_unload=None):
|
||||
"""
|
||||
只在输入内容真正变化时才触发重新执行
|
||||
使用输入参数的哈希值作为判断依据
|
||||
"""
|
||||
# 计算图像的哈希值(只使用第一帧的部分数据,避免计算量过大)
|
||||
img_hash = ""
|
||||
if 图像 is not None:
|
||||
try:
|
||||
if len(图像.shape) == 4:
|
||||
# 取第一帧的中心区域作为哈希计算依据
|
||||
h, w = 图像.shape[1:3]
|
||||
center_h, center_w = h // 2, w // 2
|
||||
size = min(100, h // 4, w // 4) # 限制计算区域大小
|
||||
img_data = 图像[0,
|
||||
max(0, center_h - size):min(h, center_h + size),
|
||||
max(0, center_w - size):min(w, center_w + size),
|
||||
0].cpu().numpy().tobytes()
|
||||
img_hash = hashlib.md5(img_data).hexdigest()
|
||||
else:
|
||||
# 如果不是4D张量,使用整个张量的哈希
|
||||
img_data = 图像.cpu().numpy().tobytes()
|
||||
img_hash = hashlib.md5(img_data).hexdigest()
|
||||
except Exception:
|
||||
img_hash = "0"
|
||||
# 导入图像哈希工具函数
|
||||
from ..utils.image import compute_image_hash
|
||||
|
||||
# 计算图像的哈希值
|
||||
img_hash = compute_image_hash(image)
|
||||
|
||||
# 组合所有输入的哈希值
|
||||
input_hash = hash((
|
||||
img_hash,
|
||||
Kontext预设,
|
||||
视觉服务,
|
||||
bool(Ollama自动释放)
|
||||
kontext_preset,
|
||||
user_prompt,
|
||||
vlm_service,
|
||||
bool(ollama_auto_unload)
|
||||
))
|
||||
|
||||
return input_hash
|
||||
|
||||
def analyze_image(self, 图像, Kontext预设, 视觉服务, Ollama自动释放):
|
||||
def analyze_image(self, image, kontext_preset, user_prompt, vlm_service, ollama_auto_unload):
|
||||
"""
|
||||
使用Kontext预设分析图像并生成创意转换指令
|
||||
|
||||
Args:
|
||||
图像: 输入的图像数据
|
||||
Kontext预设: 选择的Kontext预设
|
||||
视觉服务: 选择的视觉服务
|
||||
image: 输入的图像数据
|
||||
kontext_preset: 选择的Kontext预设
|
||||
user_prompt: 用户补充的提示词
|
||||
vlm_service: 选择的视觉服务
|
||||
|
||||
Returns:
|
||||
tuple: 分析结果
|
||||
"""
|
||||
try:
|
||||
# 检查输入
|
||||
if 图像 is None:
|
||||
if image is None:
|
||||
raise ValueError("输入图像不能为空")
|
||||
|
||||
# 将图像转换为base64编码
|
||||
image_data = self._image_to_base64(图像)
|
||||
image_data = self._image_to_base64(image)
|
||||
|
||||
# 获取kontext配置
|
||||
config_data = self.__class__._load_kontext_config()
|
||||
@@ -163,10 +140,10 @@ class KontextPresetNode:
|
||||
prompt_template = None
|
||||
|
||||
# 查找选定的提示词模板
|
||||
preset_name = Kontext预设
|
||||
preset_name = kontext_preset
|
||||
template_found = False
|
||||
for key, value in kontext_presets.items():
|
||||
if value.get('name') == Kontext预设:
|
||||
if value.get('name') == kontext_preset:
|
||||
prompt_template = value.get('content')
|
||||
template_found = True
|
||||
break
|
||||
@@ -174,7 +151,7 @@ class KontextPresetNode:
|
||||
if not template_found:
|
||||
# 尝试直接匹配键名
|
||||
for key, value in kontext_presets.items():
|
||||
if key == Kontext预设 or key == f"kontext_{Kontext预设}":
|
||||
if key == kontext_preset or key == f"kontext_{kontext_preset}":
|
||||
prompt_template = value.get('content')
|
||||
template_found = True
|
||||
break
|
||||
@@ -188,192 +165,103 @@ class KontextPresetNode:
|
||||
final_prompt = prompt_template
|
||||
if kontext_prefix and kontext_suffix:
|
||||
final_prompt = f"{kontext_prefix}\n\nThe Brief: {prompt_template}\n\n{kontext_suffix}"
|
||||
|
||||
# 拼接用户提示词
|
||||
if user_prompt and user_prompt.strip():
|
||||
final_prompt = f"{final_prompt}\n\n用户补充要求:\n{user_prompt}"
|
||||
|
||||
# 映射视觉服务选项到provider
|
||||
provider_map = {
|
||||
"智谱": "zhipu",
|
||||
"硅基流动": "siliconflow",
|
||||
"302.AI": "302ai",
|
||||
"Ollama": "ollama",
|
||||
"自定义": "custom"
|
||||
}
|
||||
|
||||
# 获取选定的provider
|
||||
selected_provider = provider_map.get(视觉服务)
|
||||
if not selected_provider:
|
||||
raise ValueError(f"不支持的视觉服务: {视觉服务}")
|
||||
|
||||
# 获取对应provider的配置
|
||||
# ---解析服务/模型字符串---
|
||||
service_id, model_name = self.parse_service_model(vlm_service)
|
||||
if not service_id:
|
||||
raise ValueError(f"Invalid service selection: {vlm_service}")
|
||||
|
||||
# ---获取服务配置---
|
||||
from ..config_manager import config_manager
|
||||
provider_config = self._get_provider_config(config_manager, selected_provider)
|
||||
if not provider_config:
|
||||
raise ValueError(f"未找到{视觉服务}的配置,请先完成API配置")
|
||||
service = config_manager.get_service(service_id)
|
||||
if not service:
|
||||
raise ValueError(f"Service config not found: {vlm_service}")
|
||||
|
||||
# ---构建provider_config---
|
||||
# 查找指定的模型或默认模型
|
||||
vlm_models = service.get('vlm_models', [])
|
||||
target_model = None
|
||||
|
||||
if model_name:
|
||||
# 查找指定的模型
|
||||
target_model = next((m for m in vlm_models if m.get('name') == model_name), None)
|
||||
|
||||
if not target_model:
|
||||
# 使用默认模型或第一个模型
|
||||
target_model = next((m for m in vlm_models if m.get('is_default')),
|
||||
vlm_models[0] if vlm_models else None)
|
||||
|
||||
if not target_model:
|
||||
raise ValueError(f"Service {vlm_service} has no available models")
|
||||
|
||||
# 构建配置对象
|
||||
provider_config = {
|
||||
'provider': service_id,
|
||||
'model': target_model.get('name', ''),
|
||||
'base_url': service.get('base_url', ''),
|
||||
'api_key': service.get('api_key', ''),
|
||||
'temperature': target_model.get('temperature', 0.7),
|
||||
'max_tokens': target_model.get('max_tokens', 500),
|
||||
'top_p': target_model.get('top_p', 0.9),
|
||||
}
|
||||
|
||||
# Ollama特殊处理:添加auto_unload配置
|
||||
if service.get('type') == 'ollama':
|
||||
provider_config['auto_unload'] = ollama_auto_unload
|
||||
|
||||
# 创建请求ID
|
||||
request_id = f"kontext_preset_{int(time.time())}_{random.randint(1000, 9999)}"
|
||||
|
||||
# 准备阶段日志(合并为一条)
|
||||
print(f"{self.REQUEST_PREFIX} Kontext预设准备 | 服务:{视觉服务} | 模型:{provider_config.get('model')} | 预设:{preset_name} | 请求ID:{request_id}")
|
||||
# 获取服务显示名称
|
||||
service_display_name = service.get('name', service_id)
|
||||
|
||||
# 准备阶段日志
|
||||
log_prepare("Kontext预设", request_id, SOURCE_NODE, service_display_name, provider_config.get('model'), preset_name)
|
||||
|
||||
# 执行图像分析
|
||||
result = self._analyze_with_vision_service(image_data, final_prompt, selected_provider, provider_config, request_id, Ollama自动释放)
|
||||
result = self._run_vision_task(
|
||||
VisionService.analyze_image,
|
||||
service_id,
|
||||
image_data=image_data,
|
||||
request_id=request_id,
|
||||
stream_callback=None,
|
||||
prompt_content=final_prompt,
|
||||
custom_provider=service_id,
|
||||
custom_provider_config=provider_config,
|
||||
task_type="Kontext预设",
|
||||
source=SOURCE_NODE
|
||||
)
|
||||
|
||||
if result and result.get('success'):
|
||||
description = result.get('data', {}).get('description', '').strip()
|
||||
if not description:
|
||||
error_msg = 'API返回结果为空,请检查API密钥、模型配置或网络连接'
|
||||
print(f"{self.LOG_PREFIX} Kontext预设失败 | 错误:{error_msg}")
|
||||
raise RuntimeError(f"分析失败: {error_msg}")
|
||||
error_msg = 'API returned empty result'
|
||||
log_error("Kontext预设", request_id, error_msg, source=SOURCE_NODE)
|
||||
raise RuntimeError(f"Analysis failed: {error_msg}")
|
||||
|
||||
# 服务层已经打印了完成日志,这里不再重复
|
||||
return (description,)
|
||||
else:
|
||||
error_msg = result.get('error', '分析失败,未知错误') if result else '分析服务未返回结果'
|
||||
print(f"{self.LOG_PREFIX} Kontext预设分析失败: {error_msg}")
|
||||
raise RuntimeError(f"分析失败: {error_msg}")
|
||||
error_msg = result.get('error', 'Unknown error') if result else 'No result returned'
|
||||
# 如果是中断错误,直接打印日志并抛出InterruptProcessingException
|
||||
if error_msg == "任务被中断":
|
||||
print(f"{self.LOG_PREFIX} ⛔️Task cancelled by user | RequestID:{request_id}")
|
||||
raise InterruptProcessingException()
|
||||
log_error("Kontext预设", request_id, error_msg, source=SOURCE_NODE)
|
||||
raise RuntimeError(f"Analysis failed: {error_msg}")
|
||||
|
||||
except InterruptProcessingException:
|
||||
print(f"{self.LOG_PREFIX} Kontext预设任务被用户取消")
|
||||
print(f"{self.LOG_PREFIX} ⛔️Task cancelled by user | RequestID:{request_id}")
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = format_api_error(e, 视觉服务)
|
||||
print(f"{self.LOG_PREFIX} Kontext预设节点异常: {error_msg}")
|
||||
raise RuntimeError(f"分析异常: {error_msg}")
|
||||
error_msg = format_api_error(e, vlm_service)
|
||||
log_error("Kontext预设", request_id, error_msg, source=SOURCE_NODE)
|
||||
raise RuntimeError(f"Analysis error: {error_msg}")
|
||||
|
||||
def _analyze_with_vision_service(self, image_data, prompt_template, provider, provider_config, request_id, auto_unload):
|
||||
"""使用视觉服务分析图像"""
|
||||
try:
|
||||
result_container = {}
|
||||
|
||||
# 在独立线程中运行图像分析
|
||||
thread = threading.Thread(
|
||||
target=self._run_async_vision_analysis,
|
||||
args=(image_data, prompt_template, request_id, result_container, provider, provider_config, auto_unload)
|
||||
)
|
||||
thread.start()
|
||||
|
||||
# 等待分析完成,同时检查中断
|
||||
while thread.is_alive():
|
||||
# 检查是否被中断 - 这会抛出 InterruptProcessingException
|
||||
try:
|
||||
import nodes
|
||||
nodes.before_node_execution()
|
||||
except:
|
||||
# 如果检查中断时出现异常,说明被中断了
|
||||
print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止分析任务...")
|
||||
# 设置结果容器为中断状态,让线程知道要停止
|
||||
result_container['interrupted'] = True
|
||||
# 等待线程结束或超时
|
||||
thread.join(timeout=1.0)
|
||||
if thread.is_alive():
|
||||
print(f"{self.LOG_PREFIX} 分析线程未能及时响应中断")
|
||||
raise InterruptProcessingException()
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
return result_container.get('result')
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _run_async_vision_analysis(self, image_data, prompt_template, request_id, result_container, provider, provider_config, auto_unload):
|
||||
"""在独立线程中运行异步图像分析任务(改为复用服务层,获得Gemini兼容与降级能力)"""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
if result_container.get('interrupted'):
|
||||
result_container['result'] = {"success": False, "error": "任务被中断"}
|
||||
return
|
||||
|
||||
result = loop.run_until_complete(
|
||||
VisionService.analyze_image(
|
||||
image_data=image_data,
|
||||
request_id=request_id,
|
||||
stream_callback=None,
|
||||
prompt_content=prompt_template,
|
||||
custom_provider=provider,
|
||||
custom_provider_config=provider_config
|
||||
)
|
||||
)
|
||||
|
||||
if result_container.get('interrupted'):
|
||||
result_container['result'] = {"success": False, "error": "任务被中断"}
|
||||
return
|
||||
|
||||
result_container['result'] = result
|
||||
|
||||
except Exception as e:
|
||||
error_message = format_api_error(e, provider)
|
||||
result_container['result'] = {"success": False, "error": error_message}
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
def _get_provider_config(self, config_manager, provider):
|
||||
"""获取指定provider的配置"""
|
||||
vision_config = config_manager.get_vision_config()
|
||||
if 'providers' in vision_config and provider in vision_config['providers']:
|
||||
return vision_config['providers'][provider]
|
||||
return None
|
||||
|
||||
async def _unload_ollama_model(self, model: str, provider_config: dict, auto_unload: bool):
|
||||
"""
|
||||
卸载Ollama模型以释放显存和内存
|
||||
|
||||
参数:
|
||||
model: 模型名称
|
||||
provider_config: 提供商配置字典
|
||||
auto_unload: 是否启用自动释放(来自节点参数)
|
||||
"""
|
||||
try:
|
||||
# 检查是否启用自动释放(使用节点参数,不从provider_config读取)
|
||||
if not auto_unload:
|
||||
return
|
||||
|
||||
# 获取base_url
|
||||
base_url = provider_config.get('base_url', 'http://localhost:11434')
|
||||
# 确保URL不以/v1结尾(Ollama原生API不需要/v1)
|
||||
if base_url.endswith('/v1'):
|
||||
base_url = base_url[:-3]
|
||||
|
||||
# 调用Ollama API卸载模型
|
||||
url = f"{base_url}/api/generate"
|
||||
payload = {
|
||||
"model": model,
|
||||
"keep_alive": 0 # 立即卸载模型
|
||||
}
|
||||
|
||||
print(f"{self.PROCESS_PREFIX} Ollama自动释放显存 | 模型:{model}")
|
||||
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
await client.post(url, json=payload)
|
||||
|
||||
except Exception as e:
|
||||
# 释放失败不影响主流程,只记录警告
|
||||
print(f"{self.LOG_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}")
|
||||
|
||||
def _image_to_base64(self, image_tensor):
|
||||
"""将图像张量转换为base64编码"""
|
||||
# 确保图像是正确的形状 [batch, height, width, channels]
|
||||
if len(image_tensor.shape) == 4:
|
||||
# 取第一张图片
|
||||
image_tensor = image_tensor[0]
|
||||
|
||||
# 将图像转换为numpy数组并缩放到0-255范围
|
||||
image_np = (image_tensor.cpu().numpy() * 255).astype(np.uint8)
|
||||
|
||||
# 创建PIL图像
|
||||
image = Image.fromarray(image_np)
|
||||
|
||||
# 将图像转换为JPEG格式的字节流
|
||||
buffer = BytesIO()
|
||||
image.save(buffer, format="JPEG", quality=95)
|
||||
|
||||
# 将字节流转换为base64编码
|
||||
encoded_image = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
# 返回带有MIME类型的data URL
|
||||
return f"data:image/jpeg;base64,{encoded_image}"
|
||||
|
||||
# 节点映射,用于向ComfyUI注册节点
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -382,5 +270,5 @@ NODE_CLASS_MAPPINGS = {
|
||||
|
||||
# 节点显示名称映射
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"KontextPresetNode": "✨Kontext预设",
|
||||
"KontextPresetNode": "✨Kontext Preset",
|
||||
}
|
||||
+151
-302
@@ -10,71 +10,60 @@ from comfy.model_management import InterruptProcessingException
|
||||
|
||||
from ..services.llm import LLMService
|
||||
from ..services.baidu import BaiduTranslateService
|
||||
from ..services.error_util import format_api_error
|
||||
from ..utils.common import format_api_error, format_model_with_thinking, generate_request_id, log_prepare, log_error, TASK_TRANSLATE, SOURCE_NODE
|
||||
from ..services.thinking_control import build_thinking_suppression
|
||||
from .base import LLMNodeBase
|
||||
|
||||
|
||||
# 定义ANSI颜色代码常量
|
||||
GREEN = "\033[32m"
|
||||
BLUE = "\033[34m"
|
||||
YELLOW = "\033[33m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
|
||||
class PromptTranslate:
|
||||
class PromptTranslate(LLMNodeBase):
|
||||
"""
|
||||
文本翻译节点
|
||||
提示词翻译节点
|
||||
自动识别输入语言并翻译成目标语言,支持多种翻译服务
|
||||
"""
|
||||
# 定义日志前缀
|
||||
LOG_PREFIX = f"{GREEN}[✨PromptAssistant]{RESET}" # 绿色:结果阶段
|
||||
REQUEST_PREFIX = f"{BLUE}[✨PromptAssistant]{RESET}" # 蓝色:准备阶段
|
||||
PROCESS_PREFIX = f"{YELLOW}[✨PromptAssistant]{RESET}" # 黄色:API调用阶段
|
||||
|
||||
# 提供商显示名称映射
|
||||
PROVIDER_DISPLAY_MAP = {
|
||||
"zhipu": "智谱",
|
||||
"siliconflow": "硅基流动",
|
||||
"302ai": "302.AI",
|
||||
"ollama": "Ollama",
|
||||
"custom": "自定义"
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
# ---动态获取翻译服务/模型列表(包含硬编码的百度翻译)---
|
||||
service_options = cls.get_translate_service_options()
|
||||
default_service = service_options[0] if service_options else "百度翻译"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"原文": ("STRING", {"forceInput": True, "default": "", "multiline": True, "placeholder": "输入要翻译的文本..."}),
|
||||
"目标语言": (["英文", "中文"], {"default": "英文"}),
|
||||
"翻译服务": (["百度翻译", "智谱翻译", "硅基流动翻译", "302.AI翻译", "Ollama翻译", "自定义翻译"], {"default": "百度翻译"}),
|
||||
# Ollama自动释放:仅对Ollama翻译服务生效
|
||||
"Ollama自动释放": ("BOOLEAN", {"default": True, "label_on": "启用", "label_off": "禁用", "tooltip": "⚠️ 该选项仅在选择了Ollama服务时生效"}),
|
||||
"source_text": ("STRING", {"forceInput": True, "default": "", "multiline": True, "placeholder": "Input text to translate..."}),
|
||||
"target_language": (["English", "Chinese"], {"default": "English"}),
|
||||
"translate_service": (service_options, {"default": default_service, "tooltip": "Select translation service and model"}),
|
||||
# Ollama Automatic VRAM Unload
|
||||
"ollama_auto_unload": ("BOOLEAN", {"default": True, "label_on": "Enable", "label_off": "Disable", "tooltip": "Auto unload Ollama model after generation"}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("翻译输出",)
|
||||
RETURN_NAMES = ("translated_text",)
|
||||
FUNCTION = "translate"
|
||||
CATEGORY = "✨提示词小助手"
|
||||
CATEGORY = "✨Prompt Assistant"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, 原文=None, 目标语言=None, 翻译服务=None, Ollama自动释放=None):
|
||||
def IS_CHANGED(cls, source_text=None, target_language=None, translate_service=None, ollama_auto_unload=None, unique_id=None):
|
||||
"""
|
||||
只在输入内容真正变化时才触发重新执行
|
||||
使用输入参数的哈希值作为判断依据
|
||||
"""
|
||||
# 计算文本的哈希值
|
||||
text_hash = ""
|
||||
if 原文:
|
||||
if source_text:
|
||||
# 使用hashlib计算文本的哈希值,更安全和一致
|
||||
text_hash = hashlib.md5(原文.encode('utf-8')).hexdigest()
|
||||
text_hash = hashlib.md5(source_text.encode('utf-8')).hexdigest()
|
||||
|
||||
# 组合所有输入的哈希值
|
||||
input_hash = hash((
|
||||
text_hash,
|
||||
目标语言,
|
||||
翻译服务,
|
||||
bool(Ollama自动释放)
|
||||
target_language,
|
||||
translate_service,
|
||||
bool(ollama_auto_unload)
|
||||
))
|
||||
|
||||
return input_hash
|
||||
@@ -102,315 +91,175 @@ class PromptTranslate:
|
||||
else:
|
||||
return "auto"
|
||||
|
||||
def translate(self, 原文, 目标语言, 翻译服务, Ollama自动释放):
|
||||
def translate(self, source_text, target_language, translate_service, ollama_auto_unload, unique_id=None):
|
||||
"""
|
||||
翻译文本函数
|
||||
|
||||
Args:
|
||||
原文: 输入的文本
|
||||
目标语言: 目标语言 ("英文", "中文")
|
||||
翻译服务: 翻译服务 ("百度翻译", "智谱翻译")
|
||||
Ollama自动释放: 是否在调用完成后自动释放Ollama模型(仅对Ollama翻译生效)
|
||||
|
||||
Returns:
|
||||
tuple: 翻译结果
|
||||
"""
|
||||
request_id = None # 提升到方法级别作用域
|
||||
try:
|
||||
# 检查输入
|
||||
if not 原文 or not 原文.strip():
|
||||
if not source_text or not source_text.strip():
|
||||
return ("",)
|
||||
|
||||
# 自动检测源语言
|
||||
detected_lang = self._detect_language(原文)
|
||||
to_lang = "en" if 目标语言 == "英文" else "zh"
|
||||
detected_lang = self._detect_language(source_text)
|
||||
to_lang = "en" if target_language == "English" else "zh"
|
||||
|
||||
# 智能跳过翻译逻辑
|
||||
skip_translation = False
|
||||
if to_lang == 'en' and detected_lang == 'en':
|
||||
print(f"{self.REQUEST_PREFIX} 检测到英文输入,目标为英文,无需翻译")
|
||||
from ..utils.common import _ANSI_CLEAR_EOL
|
||||
print(f"\r{_ANSI_CLEAR_EOL}{self.REQUEST_PREFIX} 检测到英文输入,目标为英文,无需翻译", flush=True)
|
||||
skip_translation = True
|
||||
elif to_lang == 'zh' and detected_lang == 'zh':
|
||||
print(f"{self.REQUEST_PREFIX} 检测到中文输入,目标为中文,无需翻译")
|
||||
from ..utils.common import _ANSI_CLEAR_EOL
|
||||
print(f"\r{_ANSI_CLEAR_EOL}{self.REQUEST_PREFIX} 检测到中文输入,目标为中文,无需翻译", flush=True)
|
||||
skip_translation = True
|
||||
|
||||
if skip_translation:
|
||||
return (原文,)
|
||||
return (source_text,)
|
||||
|
||||
# 映射语言名称
|
||||
lang_map = {'zh': '中文', 'en': '英文', 'auto': '原文'}
|
||||
from_lang_name = lang_map.get(detected_lang, detected_lang)
|
||||
to_lang_name = lang_map.get(to_lang, to_lang)
|
||||
|
||||
if 翻译服务 == "百度翻译":
|
||||
result = self._translate_with_baidu(原文, detected_lang, to_lang, 翻译服务, from_lang_name, to_lang_name)
|
||||
elif 翻译服务 == "智谱翻译":
|
||||
result = self._translate_with_llm(原文, detected_lang, to_lang, "zhipu", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放)
|
||||
elif 翻译服务 == "硅基流动翻译":
|
||||
result = self._translate_with_llm(原文, detected_lang, to_lang, "siliconflow", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放)
|
||||
elif 翻译服务 == "302.AI翻译":
|
||||
result = self._translate_with_llm(原文, detected_lang, to_lang, "302ai", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放)
|
||||
elif 翻译服务 == "Ollama翻译":
|
||||
result = self._translate_with_llm(原文, detected_lang, to_lang, "ollama", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放)
|
||||
elif 翻译服务 == "自定义翻译":
|
||||
result = self._translate_with_llm(原文, detected_lang, to_lang, "custom", 翻译服务, from_lang_name, to_lang_name, Ollama自动释放)
|
||||
# ---解析服务/模型字符串---
|
||||
service_id, model_name = self.parse_service_model(translate_service)
|
||||
if not service_id:
|
||||
raise ValueError(f"Invalid service selection: {translate_service}")
|
||||
|
||||
# ---百度翻译特殊处理---
|
||||
if service_id == 'baidu':
|
||||
request_id, result = self._translate_with_baidu(source_text, detected_lang, to_lang, translate_service, from_lang_name, to_lang_name, unique_id)
|
||||
else:
|
||||
raise ValueError(f"不支持的翻译服务: {翻译服务}")
|
||||
# ---LLM翻译:获取服务配置---
|
||||
from ..config_manager import config_manager
|
||||
service = config_manager.get_service(service_id)
|
||||
if not service:
|
||||
raise ValueError(f"Service config not found: {translate_service}")
|
||||
|
||||
request_id, result = self._translate_with_llm(source_text, detected_lang, to_lang, service_id, model_name, service, translate_service, from_lang_name, to_lang_name, ollama_auto_unload, unique_id)
|
||||
|
||||
if result and result.get('success'):
|
||||
translated_text = result.get('data', {}).get('translated', '').strip()
|
||||
if not translated_text:
|
||||
error_msg = 'API返回结果为空,请检查API密钥、模型配置或网络连接'
|
||||
print(f"{self.LOG_PREFIX} 翻译失败 | 错误:{error_msg}")
|
||||
raise RuntimeError(f"翻译失败: {error_msg}")
|
||||
error_msg = 'API returned empty result'
|
||||
raise RuntimeError(f"❌Translation failed: {error_msg}")
|
||||
|
||||
# 结果阶段日志由服务层统一输出,节点层不再重复打印
|
||||
return (translated_text,)
|
||||
else:
|
||||
error_msg = result.get('error', '翻译失败,未知错误') if result else '翻译服务未返回结果'
|
||||
print(f"{self.LOG_PREFIX} 翻译失败: {error_msg}")
|
||||
raise RuntimeError(f"翻译失败: {error_msg}")
|
||||
error_msg = result.get('error', 'Unknown error') if result else 'No result returned'
|
||||
# 如果是中断错误,直接抛出InterruptProcessingException,不打印日志(由基类打印)
|
||||
if error_msg == "任务被中断":
|
||||
raise InterruptProcessingException()
|
||||
log_error(TASK_TRANSLATE, request_id, error_msg)
|
||||
raise RuntimeError(f"Translation failed: {error_msg}")
|
||||
|
||||
except InterruptProcessingException:
|
||||
print(f"{self.LOG_PREFIX} 翻译任务被用户取消")
|
||||
# 不打印日志,由基类统一打印
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = format_api_error(e, 翻译服务)
|
||||
print(f"{self.LOG_PREFIX} 翻译异常: {error_msg}")
|
||||
raise RuntimeError(f"翻译异常: {error_msg}")
|
||||
error_msg = format_api_error(e, translate_service)
|
||||
log_error(TASK_TRANSLATE, request_id, error_msg)
|
||||
raise RuntimeError(f"Translation error: {error_msg}")
|
||||
|
||||
def _translate_with_baidu(self, text, from_lang, to_lang, service_name, from_lang_name, to_lang_name):
|
||||
def _translate_with_baidu(self, text, from_lang, to_lang, service_name, from_lang_name, to_lang_name, unique_id):
|
||||
"""使用百度翻译服务"""
|
||||
try:
|
||||
# 创建请求ID
|
||||
request_id = f"translate_{int(time.time())}_{random.randint(1000, 9999)}"
|
||||
|
||||
# 准备阶段日志(合并为一条)
|
||||
print(f"{PromptTranslate.REQUEST_PREFIX} 翻译准备 | 服务:{service_name} | 方向:{from_lang_name} → {to_lang_name} | 长度:{len(text)} | 请求ID:{request_id}")
|
||||
|
||||
result_container = {}
|
||||
|
||||
# 在独立线程中运行异步翻译
|
||||
thread = threading.Thread(
|
||||
target=self._run_async_translation,
|
||||
args=(BaiduTranslateService.translate, text, from_lang, to_lang, request_id, result_container)
|
||||
)
|
||||
thread.start()
|
||||
|
||||
# 等待翻译完成,同时检查中断
|
||||
while thread.is_alive():
|
||||
# 检查是否被中断 - 这会抛出 InterruptProcessingException
|
||||
try:
|
||||
import nodes
|
||||
nodes.before_node_execution()
|
||||
except:
|
||||
# 如果检查中断时出现异常,说明被中断了
|
||||
print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止翻译任务...")
|
||||
# 设置结果容器为中断状态,让线程知道要停止
|
||||
result_container['interrupted'] = True
|
||||
# 等待线程结束或超时
|
||||
thread.join(timeout=1.0)
|
||||
if thread.is_alive():
|
||||
print(f"{self.LOG_PREFIX} 翻译线程未能及时响应中断")
|
||||
raise InterruptProcessingException()
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
return result_container.get('result')
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _translate_with_llm(self, text, from_lang, to_lang, provider, service_name, from_lang_name, to_lang_name, auto_unload):
|
||||
"""使用LLM翻译服务"""
|
||||
try:
|
||||
# 获取配置
|
||||
from ..config_manager import config_manager
|
||||
provider_config = self._get_provider_config(config_manager, provider)
|
||||
|
||||
if not provider_config:
|
||||
provider_display_name = self.PROVIDER_DISPLAY_MAP.get(provider, provider)
|
||||
return {"success": False, "error": f"未找到{provider_display_name}的配置,请先完成API配置"}
|
||||
|
||||
# 创建请求ID
|
||||
request_id = f"translate_{int(time.time())}_{random.randint(1000, 9999)}"
|
||||
|
||||
# 准备阶段日志(合并为一条)
|
||||
print(f"{PromptTranslate.REQUEST_PREFIX} 翻译准备 | 服务:{service_name} | 模型:{provider_config.get('model')} | 方向:{from_lang_name} → {to_lang_name} | 长度:{len(text)} | 请求ID:{request_id}")
|
||||
|
||||
result_container = {}
|
||||
|
||||
# 在独立线程中运行LLM翻译
|
||||
thread = threading.Thread(
|
||||
target=self._run_llm_translation,
|
||||
args=(text, from_lang, to_lang, request_id, result_container, provider, provider_config, auto_unload)
|
||||
)
|
||||
thread.start()
|
||||
|
||||
# 等待翻译完成,同时检查中断
|
||||
while thread.is_alive():
|
||||
# 检查是否被中断 - 这会抛出 InterruptProcessingException
|
||||
try:
|
||||
import nodes
|
||||
nodes.before_node_execution()
|
||||
except:
|
||||
# 如果检查中断时出现异常,说明被中断了
|
||||
print(f"{self.LOG_PREFIX} 检测到中断信号,正在终止翻译任务...")
|
||||
# 设置结果容器为中断状态,让线程知道要停止
|
||||
result_container['interrupted'] = True
|
||||
# 等待线程结束或超时
|
||||
thread.join(timeout=1.0)
|
||||
if thread.is_alive():
|
||||
print(f"{self.LOG_PREFIX} 翻译线程未能及时响应中断")
|
||||
raise InterruptProcessingException()
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
return result_container.get('result')
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _run_async_translation(self, service_func, text, from_lang, to_lang, request_id, result_container, **kwargs):
|
||||
"""在独立线程中运行异步翻译任务"""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
# 检查是否在开始前就被中断了
|
||||
if result_container.get('interrupted'):
|
||||
result_container['result'] = {"success": False, "error": "任务被中断"}
|
||||
return
|
||||
|
||||
result = loop.run_until_complete(
|
||||
service_func(text, from_lang, to_lang, request_id, **kwargs)
|
||||
)
|
||||
|
||||
# 检查是否在执行过程中被中断了
|
||||
if result_container.get('interrupted'):
|
||||
result_container['result'] = {"success": False, "error": "任务被中断"}
|
||||
return
|
||||
|
||||
result_container['result'] = result
|
||||
except Exception as e:
|
||||
if result_container.get('interrupted'):
|
||||
result_container['result'] = {"success": False, "error": "任务被中断"}
|
||||
else:
|
||||
result_container['result'] = {"success": False, "error": str(e)}
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
def _get_provider_config(self, config_manager, provider):
|
||||
"""获取指定provider的配置"""
|
||||
llm_config = config_manager.get_llm_config()
|
||||
if 'providers' in llm_config and provider in llm_config['providers']:
|
||||
return llm_config['providers'][provider]
|
||||
return None
|
||||
|
||||
async def _unload_ollama_model(self, model: str, provider_config: dict, auto_unload: bool):
|
||||
"""
|
||||
卸载Ollama模型以释放显存和内存
|
||||
# 创建请求ID
|
||||
request_id = generate_request_id("trans", "baidu", unique_id)
|
||||
|
||||
参数:
|
||||
model: 模型名称
|
||||
provider_config: 提供商配置字典
|
||||
auto_unload: 是否启用自动释放(来自节点参数)
|
||||
"""
|
||||
try:
|
||||
# 检查是否启用自动释放(使用节点参数,不从provider_config读取)
|
||||
if not auto_unload:
|
||||
return
|
||||
|
||||
# 获取base_url
|
||||
base_url = provider_config.get('base_url', 'http://localhost:11434')
|
||||
# 确保URL不以/v1结尾(Ollama原生API不需要/v1)
|
||||
if base_url.endswith('/v1'):
|
||||
base_url = base_url[:-3]
|
||||
|
||||
# 调用Ollama API卸载模型
|
||||
url = f"{base_url}/api/generate"
|
||||
payload = {
|
||||
"model": model,
|
||||
"keep_alive": 0 # 立即卸载模型
|
||||
}
|
||||
|
||||
print(f"{self.PROCESS_PREFIX} Ollama自动释放显存 | 模型:{model}")
|
||||
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
await client.post(url, json=payload)
|
||||
|
||||
except Exception as e:
|
||||
# 释放失败不影响主流程,只记录警告
|
||||
print(f"{self.LOG_PREFIX} Ollama模型释放失败(不影响结果) | 模型:{model} | 错误:{str(e)[:50]}")
|
||||
# 准备阶段日志
|
||||
log_prepare(TASK_TRANSLATE, request_id, SOURCE_NODE, "百度翻译", None, None, {"方向": f"{from_lang_name}→{to_lang_name}", "长度": len(text)})
|
||||
|
||||
# 执行翻译(异步线程 + 可中断)
|
||||
result = self._run_llm_task(
|
||||
BaiduTranslateService.translate,
|
||||
service_name,
|
||||
text=text,
|
||||
from_lang=from_lang,
|
||||
to_lang=to_lang,
|
||||
request_id=request_id,
|
||||
task_type=TASK_TRANSLATE,
|
||||
source=SOURCE_NODE
|
||||
)
|
||||
|
||||
def _run_llm_translation(self, text, from_lang, to_lang, request_id, result_container, provider, provider_config, auto_unload):
|
||||
"""在独立线程中运行LLM翻译"""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
# 检查是否在开始前就被中断了
|
||||
if result_container.get('interrupted'):
|
||||
result_container['result'] = {"success": False, "error": "任务被中断"}
|
||||
return
|
||||
return request_id, result
|
||||
|
||||
# 获取API密钥和模型
|
||||
api_key = provider_config.get('api_key', '')
|
||||
model = provider_config.get('model', '')
|
||||
def _translate_with_llm(self, text, from_lang, to_lang, service_id, model_name, service, service_display_name, from_lang_name, to_lang_name, auto_unload, unique_id):
|
||||
"""使用LLM翻译服务"""
|
||||
# ---构建provider_config---
|
||||
# 查找指定的模型或默认模型
|
||||
llm_models = service.get('llm_models', [])
|
||||
target_model = None
|
||||
|
||||
if model_name:
|
||||
# 查找指定的模型
|
||||
target_model = next((m for m in llm_models if m.get('name') == model_name), None)
|
||||
|
||||
if not target_model:
|
||||
# 使用默认模型或第一个模型
|
||||
target_model = next((m for m in llm_models if m.get('is_default')),
|
||||
llm_models[0] if llm_models else None)
|
||||
|
||||
if not target_model:
|
||||
return {"success": False, "error": f"Service {service_display_name} has no available models"}
|
||||
|
||||
# 构建配置对象
|
||||
provider_config = {
|
||||
'provider': service_id,
|
||||
'model': target_model.get('name', ''),
|
||||
'base_url': service.get('base_url', ''),
|
||||
'api_key': service.get('api_key', ''),
|
||||
'temperature': target_model.get('temperature', 0.7),
|
||||
'max_tokens': target_model.get('max_tokens', 1000),
|
||||
'top_p': target_model.get('top_p', 0.9),
|
||||
}
|
||||
|
||||
# Ollama特殊处理:添加auto_unload配置
|
||||
if service.get('type') == 'ollama':
|
||||
provider_config['auto_unload'] = auto_unload
|
||||
|
||||
if not api_key or not model:
|
||||
result_container['result'] = {
|
||||
"success": False,
|
||||
"error": f"请先配置{provider}的API密钥和模型"
|
||||
}
|
||||
return
|
||||
# 创建请求ID
|
||||
request_id = generate_request_id("trans", "llm", unique_id)
|
||||
|
||||
# 检查是否关闭思维链
|
||||
model_full_name = provider_config.get('model')
|
||||
disable_thinking_enabled = service.get('disable_thinking', True)
|
||||
thinking_extra = build_thinking_suppression(service_id, model_full_name) if disable_thinking_enabled else None
|
||||
model_display = format_model_with_thinking(model_full_name, bool(thinking_extra))
|
||||
|
||||
# 获取服务显示名称
|
||||
service_display_name = service.get('name', service_id)
|
||||
|
||||
# 准备阶段日志
|
||||
log_prepare(TASK_TRANSLATE, request_id, SOURCE_NODE, service_display_name, model_display, None, {"方向": f"{from_lang_name}→{to_lang_name}", "长度": len(text)})
|
||||
|
||||
# 检查API密钥和模型
|
||||
api_key = provider_config.get('api_key', '')
|
||||
model = provider_config.get('model', '')
|
||||
|
||||
if not api_key or not model:
|
||||
return {"success": False, "error": f"Please configure API key and model for {service_display_name}"}
|
||||
|
||||
# 检查是否启用直连模式
|
||||
bypass_proxy = False
|
||||
try:
|
||||
from ..config_manager import config_manager as cm
|
||||
settings = cm.get_settings()
|
||||
bypass_proxy = settings.get('PromptAssistant.Settings.BypassProxy', False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
direct_mode_tag = "(直连)" if bypass_proxy else ""
|
||||
# 执行翻译(异步线程 + 可中断)
|
||||
result = self._run_llm_task(
|
||||
LLMService.translate,
|
||||
service_id,
|
||||
text=text,
|
||||
from_lang=from_lang,
|
||||
to_lang=to_lang,
|
||||
request_id=request_id,
|
||||
stream_callback=None,
|
||||
custom_provider=service_id,
|
||||
custom_provider_config=provider_config,
|
||||
task_type=TASK_TRANSLATE,
|
||||
source=SOURCE_NODE
|
||||
)
|
||||
|
||||
# 获取提供商显示名称
|
||||
provider_display_name = PromptTranslate.PROVIDER_DISPLAY_MAP.get(provider, provider)
|
||||
return request_id, result
|
||||
|
||||
# 统一走服务层(含兼容/降级处理)
|
||||
service_result = loop.run_until_complete(
|
||||
LLMService.translate(
|
||||
text,
|
||||
from_lang,
|
||||
to_lang,
|
||||
request_id,
|
||||
False,
|
||||
None,
|
||||
custom_provider=provider,
|
||||
custom_provider_config=provider_config
|
||||
)
|
||||
)
|
||||
|
||||
# 检查是否在执行过程中被中断了
|
||||
if result_container.get('interrupted'):
|
||||
result_container['result'] = {"success": False, "error": "任务被中断"}
|
||||
return
|
||||
|
||||
result_container['result'] = service_result
|
||||
|
||||
# 结果日志已在translate方法中统一打印,这里不再重复
|
||||
|
||||
except Exception as e:
|
||||
# 格式化错误信息
|
||||
error_message = format_api_error(e, provider_display_name)
|
||||
|
||||
result_container['result'] = {
|
||||
"success": False,
|
||||
"error": error_message
|
||||
}
|
||||
print(f"{PromptTranslate.LOG_PREFIX} 翻译失败 | 服务:{provider_display_name} | 错误:{error_message}")
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
# 节点映射,用于向ComfyUI注册节点
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -419,5 +268,5 @@ NODE_CLASS_MAPPINGS = {
|
||||
|
||||
# 节点显示名称映射
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PromptTranslate": "✨提示词翻译",
|
||||
}
|
||||
"PromptTranslate": "✨Prompt Translate",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
import threading
|
||||
import hashlib
|
||||
import base64
|
||||
import os
|
||||
import tempfile
|
||||
import shutil
|
||||
from io import BytesIO
|
||||
from typing import List, Optional, Union, Tuple
|
||||
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import imageio
|
||||
|
||||
from comfy.model_management import InterruptProcessingException
|
||||
from ..services.vlm import VisionService
|
||||
from ..utils.common import format_api_error, format_model_with_thinking, generate_request_id, log_prepare, log_error, TASK_VIDEO_CAPTION, SOURCE_NODE
|
||||
from ..services.thinking_control import build_thinking_suppression
|
||||
from .base import VLMNodeBase
|
||||
|
||||
|
||||
class VideoCaptionNode(VLMNodeBase):
|
||||
"""
|
||||
视频反推提示词节点
|
||||
分析输入视频或图像序列并生成描述性提示词
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
# 从config_manager获取系统提示词配置
|
||||
from ..config_manager import config_manager
|
||||
system_prompts = config_manager.get_system_prompts()
|
||||
|
||||
# ---获取所有 video_prompts 作为选项---
|
||||
video_prompts = {}
|
||||
if system_prompts and 'video_prompts' in system_prompts:
|
||||
video_prompts = system_prompts['video_prompts']
|
||||
|
||||
# 构建提示词模板选项(支持分类格式:类别/规则名称)
|
||||
prompt_template_options = []
|
||||
for key, value in video_prompts.items():
|
||||
# 过滤掉不在后端显示的规则
|
||||
show_in = value.get('showIn', ["frontend", "node"])
|
||||
if 'node' not in show_in:
|
||||
continue
|
||||
|
||||
name = value.get('name', key)
|
||||
category = value.get('category', '')
|
||||
# 如果有分类,显示为 "类别/规则名称",否则直接显示规则名称
|
||||
display_name = f"{category}/{name}" if category else name
|
||||
prompt_template_options.append(display_name)
|
||||
|
||||
# 如果没有选项,添加一个默认选项
|
||||
if not prompt_template_options:
|
||||
prompt_template_options = ["默认视频反推提示词"]
|
||||
|
||||
# ---动态获取VLM服务/模型列表---
|
||||
service_options = cls.get_vlm_service_options()
|
||||
default_service = service_options[0] if service_options else "智谱"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"rule": (prompt_template_options, {"default": prompt_template_options[0] if prompt_template_options else "默认视频反推提示词", "tooltip": "💡Template Config: Settings -> ✨Prompt Assistant -> Rule Editor"}),
|
||||
"custom_rule": ("BOOLEAN", {"default": False, "label_on": "Enable", "label_off": "Disable", "tooltip": "⚠️ Enable to use custom rule content below instead of preset"}),
|
||||
"custom_rule_content": ("STRING", {"multiline": True, "default": "", "placeholder": "请输入临时规则内容,仅在启用'临时规则'时生效"}),
|
||||
"user_prompt": ("STRING", {"multiline": True, "default": "", "placeholder": "输入额外的具体要求,将与规则一起发送给模型"}),
|
||||
"vlm_service": (service_options, {"default": default_service, "tooltip": "Select VLM service and model"}),
|
||||
"sampling_mode": (["Auto (Uniform)", "Manual (Indices)"], {"default": "Auto (Uniform)"}),
|
||||
"frame_count": ("INT", {"default": 8, "min": 1, "max": 32, "step": 1, "tooltip": "💡Only for 'Auto' mode. Number of frames to sample."}),
|
||||
"manual_indices": ("STRING", {"default": "", "placeholder": "Input indices (e.g. 0,10,20) or range (e.g. 0-10)", "tooltip": "💡Only for 'Manual' mode. Supports comma-separated or range. Negative indices allowed."}),
|
||||
# Ollama Automatic VRAM Unload
|
||||
"ollama_auto_unload": ("BOOLEAN", {"default": True, "label_on": "Enable", "label_off": "Disable", "tooltip": "Auto unload Ollama model after generation"}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
"optional": {
|
||||
"video": ("VIDEO",),
|
||||
"image_sequence": ("IMAGE",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "IMAGE")
|
||||
RETURN_NAMES = ("prompt_output", "preview_frames")
|
||||
FUNCTION = "analyze_video_content"
|
||||
CATEGORY = "✨Prompt Assistant"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, video=None, image_sequence=None, rule=None, custom_rule=None, custom_rule_content=None, user_prompt=None, vlm_service=None, sampling_mode=None, frame_count=None, manual_indices=None, ollama_auto_unload=None, unique_id=None):
|
||||
"""
|
||||
只在输入内容真正变化时才触发重新执行
|
||||
"""
|
||||
# 提取实际的tensor数据
|
||||
target_input = None
|
||||
if video is not None:
|
||||
if isinstance(video, dict):
|
||||
target_input = video.get('frames') or video.get('video')
|
||||
elif hasattr(video, 'video'):
|
||||
target_input = video.video
|
||||
elif hasattr(video, 'frames'):
|
||||
target_input = video.frames
|
||||
elif isinstance(video, torch.Tensor):
|
||||
target_input = video
|
||||
elif image_sequence is not None:
|
||||
target_input = image_sequence
|
||||
|
||||
# 计算哈希值
|
||||
input_data_hash = ""
|
||||
if target_input is not None and isinstance(target_input, torch.Tensor):
|
||||
try:
|
||||
# 取部分帧和中心区域计算哈希,避免全量计算
|
||||
if len(target_input.shape) == 4:
|
||||
frames = target_input.shape[0]
|
||||
# 取首尾和中间帧
|
||||
indices = [0, frames // 2, frames - 1] if frames > 2 else range(frames)
|
||||
|
||||
hash_data = b""
|
||||
for idx in indices:
|
||||
h, w = target_input.shape[1:3]
|
||||
center_h, center_w = h // 2, w // 2
|
||||
size = min(50, h // 4, w // 4)
|
||||
frame_data = target_input[idx,
|
||||
max(0, center_h - size):min(h, center_h + size),
|
||||
max(0, center_w - size):min(w, center_w + size),
|
||||
0].cpu().numpy().tobytes()
|
||||
hash_data += frame_data
|
||||
|
||||
input_data_hash = hashlib.md5(hash_data).hexdigest()
|
||||
else:
|
||||
input_data_hash = "invalid_shape"
|
||||
except Exception:
|
||||
input_data_hash = "hash_error"
|
||||
|
||||
# 组合所有输入的哈希值
|
||||
input_hash = hash((
|
||||
input_data_hash,
|
||||
rule,
|
||||
bool(custom_rule),
|
||||
custom_rule_content,
|
||||
user_prompt,
|
||||
vlm_service,
|
||||
sampling_mode,
|
||||
frame_count,
|
||||
manual_indices,
|
||||
bool(ollama_auto_unload)
|
||||
))
|
||||
|
||||
return input_hash
|
||||
|
||||
def analyze_video_content(self, rule, custom_rule, custom_rule_content, user_prompt, vlm_service, sampling_mode, frame_count, manual_indices, ollama_auto_unload, video=None, image_sequence=None, unique_id=None):
|
||||
"""
|
||||
分析视频或图像序列并生成提示词(使用抽帧模式)
|
||||
"""
|
||||
temp_video_path = None
|
||||
try:
|
||||
# 1. 验证输入
|
||||
if video is None and image_sequence is None:
|
||||
raise ValueError("Video Input or Image Sequence Input is required")
|
||||
|
||||
# 2. 提取tensor数据
|
||||
input_tensor = None
|
||||
is_pre_sampled = False
|
||||
|
||||
if video is not None:
|
||||
# 处理VideoFromFile对象(需要先保存再加载)
|
||||
if hasattr(video, 'save_to') and callable(getattr(video, 'save_to')):
|
||||
try:
|
||||
fd, temp_video_path = tempfile.mkstemp(suffix='.mp4')
|
||||
os.close(fd)
|
||||
video.save_to(temp_video_path)
|
||||
print(f"{self.PROCESS_PREFIX} VideoFromFile saved to temp")
|
||||
#从文件加载为tensor,传入抽帧参数进行预采样优化
|
||||
input_tensor = self._load_video_as_tensor(
|
||||
temp_video_path,
|
||||
target_count=frame_count if sampling_mode == "Auto (Uniform)" else None,
|
||||
target_indices_str=manual_indices if sampling_mode == "Manual (Indices)" else None
|
||||
)
|
||||
is_pre_sampled = True
|
||||
except Exception as e:
|
||||
if temp_video_path and os.path.exists(temp_video_path):
|
||||
os.unlink(temp_video_path)
|
||||
raise RuntimeError(f"VideoFromFile processing failed: {str(e)}")
|
||||
|
||||
# 处理字典格式
|
||||
elif isinstance(video, dict):
|
||||
input_tensor = video.get('frames') or video.get('video')
|
||||
if input_tensor is None:
|
||||
for v in video.values():
|
||||
if isinstance(v, torch.Tensor):
|
||||
input_tensor = v
|
||||
break
|
||||
|
||||
# 处理tensor
|
||||
elif isinstance(video, torch.Tensor):
|
||||
input_tensor = video
|
||||
|
||||
# 尝试索引访问
|
||||
elif hasattr(video, '__getitem__'):
|
||||
try:
|
||||
first_item = video[0]
|
||||
if isinstance(first_item, torch.Tensor):
|
||||
input_tensor = first_item
|
||||
elif isinstance(first_item, dict):
|
||||
input_tensor = first_item.get('frames') or first_item.get('video')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if input_tensor is None:
|
||||
raise ValueError(f"Failed to extract tensor from VIDEO input")
|
||||
else:
|
||||
input_tensor = image_sequence
|
||||
|
||||
# 3. 准备提示词
|
||||
prompt_template = None
|
||||
rule_name = "Custom Rule" if (custom_rule and custom_rule_content) else rule
|
||||
|
||||
if custom_rule and custom_rule_content:
|
||||
prompt_template = custom_rule_content
|
||||
else:
|
||||
from ..config_manager import config_manager
|
||||
system_prompts = config_manager.get_system_prompts()
|
||||
video_prompts = {}
|
||||
if system_prompts and 'video_prompts' in system_prompts:
|
||||
video_prompts = system_prompts['video_prompts']
|
||||
|
||||
# 查找模板(按显示名称匹配)
|
||||
# 显示名称格式:有分类时为 "类别/规则名称",无分类时为 "规则名称"
|
||||
for key, value in video_prompts.items():
|
||||
name = value.get('name', key)
|
||||
category = value.get('category', '')
|
||||
# 构建与下拉列表一致的显示名称
|
||||
display_name = f"{category}/{name}" if category else name
|
||||
if display_name == rule:
|
||||
prompt_template = value.get('content')
|
||||
break
|
||||
|
||||
# 允许用规则名称或键名直接匹配(兼容旧格式)
|
||||
if not prompt_template:
|
||||
for key, value in video_prompts.items():
|
||||
if value.get('name') == rule or key == rule:
|
||||
prompt_template = value.get('content')
|
||||
break
|
||||
|
||||
if not prompt_template:
|
||||
prompt_template = "请详细描述这段视频的内容,包括主要事件、场景变化、人物动作和视觉风格。"
|
||||
rule_name = "Default Rule"
|
||||
|
||||
# 拼接用户提示词
|
||||
if user_prompt and user_prompt.strip():
|
||||
prompt_template = f"{prompt_template}\n\n用户补充要求:\n{user_prompt}"
|
||||
|
||||
# ---解析服务/模型字符串---
|
||||
service_id, model_name = self.parse_service_model(vlm_service)
|
||||
if not service_id:
|
||||
raise ValueError(f"Invalid service selection: {vlm_service}")
|
||||
|
||||
# ---获取服务配置---
|
||||
from ..config_manager import config_manager
|
||||
service = config_manager.get_service(service_id)
|
||||
if not service:
|
||||
raise ValueError(f"Service config not found: {vlm_service}")
|
||||
|
||||
# ---构建provider_config---
|
||||
# 查找指定的模型或默认模型
|
||||
vlm_models = service.get('vlm_models', [])
|
||||
target_model = None
|
||||
|
||||
if model_name:
|
||||
# 查找指定的模型
|
||||
target_model = next((m for m in vlm_models if m.get('name') == model_name), None)
|
||||
|
||||
if not target_model:
|
||||
# 使用默认模型或第一个模型
|
||||
target_model = next((m for m in vlm_models if m.get('is_default')),
|
||||
vlm_models[0] if vlm_models else None)
|
||||
|
||||
if not target_model:
|
||||
raise ValueError(f"Service {vlm_service} has no available models")
|
||||
|
||||
# 构建配置对象
|
||||
provider_config = {
|
||||
'provider': service_id,
|
||||
'model': target_model.get('name', ''),
|
||||
'base_url': service.get('base_url', ''),
|
||||
'api_key': service.get('api_key', ''),
|
||||
'temperature': target_model.get('temperature', 0.7),
|
||||
'max_tokens': target_model.get('max_tokens', 500),
|
||||
'top_p': target_model.get('top_p', 0.9),
|
||||
}
|
||||
|
||||
# Ollama特殊处理:添加auto_unload配置
|
||||
if service.get('type') == 'ollama':
|
||||
provider_config['auto_unload'] = ollama_auto_unload
|
||||
|
||||
model = provider_config.get('model', '')
|
||||
|
||||
# 5. 抽帧处理
|
||||
request_id = generate_request_id("vcap", None, unique_id)
|
||||
# 检查是否关闭思维链
|
||||
disable_thinking_enabled = service.get('disable_thinking', True)
|
||||
thinking_extra = build_thinking_suppression(service_id, model) if disable_thinking_enabled else None
|
||||
model_display = format_model_with_thinking(model, bool(thinking_extra))
|
||||
|
||||
# 获取服务显示名称
|
||||
service_display_name = service.get('name', service_id)
|
||||
|
||||
# 准备阶段日志
|
||||
log_prepare(TASK_VIDEO_CAPTION, request_id, SOURCE_NODE, service_display_name, model_display, rule_name, {"模式": sampling_mode})
|
||||
|
||||
# [Debug] 输出抽帧参数详情
|
||||
print(f"{self.PROCESS_PREFIX} [video-caption-debug] 输入tensor形状:{input_tensor.shape} | is_pre_sampled:{is_pre_sampled}")
|
||||
print(f"{self.PROCESS_PREFIX} [video-caption-debug] sampling_mode:{sampling_mode} | frame_count:{frame_count} | manual_indices:{manual_indices}")
|
||||
|
||||
# 准备抽帧参数
|
||||
sampling_kwargs = {}
|
||||
if not is_pre_sampled:
|
||||
if sampling_mode == "Auto (Uniform)":
|
||||
sampling_kwargs['target_count'] = frame_count
|
||||
elif sampling_mode == "Manual (Indices)":
|
||||
sampling_kwargs['target_indices_str'] = manual_indices
|
||||
# 从tensor中提取帧并转为base64,同时获取预览tensor
|
||||
frames_data, preview_tensor = self._extract_frames_and_tensor(
|
||||
input_tensor,
|
||||
**sampling_kwargs
|
||||
)
|
||||
|
||||
# [Debug] 输出抽帧结果
|
||||
print(f"{self.PROCESS_PREFIX} [video-caption] 抽帧完成 | 帧数量:{len(frames_data)} | 预览tensor:{preview_tensor.shape}")
|
||||
|
||||
# 调用多图像分析 - 使用基类方法
|
||||
result = self._run_vision_task(
|
||||
VisionService.analyze_images,
|
||||
service_id,
|
||||
images_data=frames_data,
|
||||
request_id=request_id,
|
||||
prompt_content=prompt_template,
|
||||
custom_provider=service_id,
|
||||
custom_provider_config=provider_config,
|
||||
task_type=TASK_VIDEO_CAPTION,
|
||||
source=SOURCE_NODE
|
||||
)
|
||||
|
||||
# 6. 处理结果
|
||||
if result and result.get('success'):
|
||||
description = result.get('data', {}).get('description', '').strip()
|
||||
if not description:
|
||||
raise RuntimeError("API returned empty result")
|
||||
return (description, preview_tensor)
|
||||
else:
|
||||
error_msg = result.get('error', 'Unknown error') if result else 'No result returned'
|
||||
# 如果是中断错误,直接抛出InterruptProcessingException,不打印日志(由基类打印)
|
||||
if error_msg == "任务被中断":
|
||||
raise InterruptProcessingException()
|
||||
raise RuntimeError(f"Analysis failed: {error_msg}")
|
||||
|
||||
except InterruptProcessingException:
|
||||
# 不打印日志,由基类统一打印
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = format_api_error(e, vlm_service)
|
||||
log_error(TASK_VIDEO_CAPTION, request_id, error_msg, source=SOURCE_NODE)
|
||||
raise RuntimeError(f"Analysis error: {error_msg}")
|
||||
finally:
|
||||
# 清理临时视频文件
|
||||
if temp_video_path and os.path.exists(temp_video_path):
|
||||
try:
|
||||
os.unlink(temp_video_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _uniform_sample(self, l, n):
|
||||
"""
|
||||
从列表中均匀采样 n 个元素 (参考 video_sampling_guide.md)
|
||||
算法: 将列表分成 n 个等长区间,从每个区间的中心位置取样
|
||||
"""
|
||||
if n >= len(l):
|
||||
return l
|
||||
gap = len(l) / n
|
||||
idxs = [int(i * gap + gap / 2) for i in range(n)]
|
||||
# 确保索引不越界
|
||||
idxs = [min(i, len(l) - 1) for i in idxs]
|
||||
return [l[i] for i in idxs]
|
||||
|
||||
def _parse_frame_indices(self, indices_str, total_frames):
|
||||
"""解析手动输入的帧索引字符串"""
|
||||
indices = set()
|
||||
if not indices_str:
|
||||
return []
|
||||
|
||||
parts = indices_str.split(',')
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
if '-' in part:
|
||||
# Range: 0-10
|
||||
start_str, end_str = part.split('-')
|
||||
start = int(start_str)
|
||||
end = int(end_str)
|
||||
# Handle negative indices
|
||||
if start < 0: start += total_frames
|
||||
if end < 0: end += total_frames
|
||||
|
||||
start = max(0, min(start, total_frames - 1))
|
||||
end = max(0, min(end, total_frames - 1))
|
||||
|
||||
if start <= end:
|
||||
indices.update(range(start, end + 1))
|
||||
else:
|
||||
# Single index
|
||||
idx = int(part)
|
||||
if idx < 0: idx += total_frames
|
||||
idx = max(0, min(idx, total_frames - 1))
|
||||
indices.add(idx)
|
||||
except ValueError:
|
||||
print(f"{self.LOG_PREFIX} 忽略无效的帧索引格式: {part}")
|
||||
|
||||
return sorted(list(indices))
|
||||
|
||||
def _extract_frames_and_tensor(self, tensor, target_count=None, target_indices_str=None):
|
||||
"""从tensor中提取指定数量或指定索引的帧,返回(base64列表, 预览tensor)"""
|
||||
total_frames = tensor.shape[0]
|
||||
|
||||
# 生成所有帧的索引列表
|
||||
all_indices = list(range(total_frames))
|
||||
|
||||
selected_indices = []
|
||||
if target_indices_str is not None:
|
||||
# 手动模式
|
||||
selected_indices = self._parse_frame_indices(target_indices_str, total_frames)
|
||||
if not selected_indices:
|
||||
print(f"{self.PROCESS_PREFIX} ⚠️ 手动帧索引无效或为空,回退到自动采样")
|
||||
selected_indices = self._uniform_sample(all_indices, 8) # Default fallback
|
||||
elif target_count is not None:
|
||||
# 自动模式
|
||||
selected_indices = self._uniform_sample(all_indices, target_count)
|
||||
else:
|
||||
# 默认全量
|
||||
selected_indices = all_indices
|
||||
|
||||
# 提取选中的帧 [N, H, W, C]
|
||||
selected_tensor = tensor[selected_indices]
|
||||
|
||||
frames_base64 = []
|
||||
# 遍历选中的tensor进行转换
|
||||
for i in range(selected_tensor.shape[0]):
|
||||
frame_tensor = selected_tensor[i]
|
||||
# 转为numpy [H, W, C] (0-255)
|
||||
frame_np = (frame_tensor.cpu().numpy() * 255).astype(np.uint8)
|
||||
# 转PIL
|
||||
image = Image.fromarray(frame_np)
|
||||
# 转Base64
|
||||
buffer = BytesIO()
|
||||
image.save(buffer, format="JPEG", quality=85)
|
||||
encoded = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
frames_base64.append(f"data:image/jpeg;base64,{encoded}")
|
||||
|
||||
return frames_base64, selected_tensor
|
||||
|
||||
def _load_video_as_tensor(self, video_path, target_count=None, target_indices_str=None):
|
||||
"""
|
||||
从视频文件加载为tensor
|
||||
优化: 如果指定了target_count或target_indices_str,则只读取需要的帧 (Sample before Load)
|
||||
"""
|
||||
try:
|
||||
import imageio
|
||||
# 读取视频
|
||||
reader = imageio.get_reader(video_path, 'ffmpeg')
|
||||
|
||||
# 获取总帧数
|
||||
# 尝试从元数据获取,如果失败则使用count_frames (较慢)
|
||||
try:
|
||||
total_frames = reader.count_frames()
|
||||
except Exception:
|
||||
# 如果无法获取帧数,回退到读取所有帧
|
||||
print(f"{self.PROCESS_PREFIX} 无法获取视频总帧数,将读取所有帧")
|
||||
frames = []
|
||||
for frame in reader:
|
||||
frame_float = frame.astype(np.float32) / 255.0
|
||||
frames.append(frame_float)
|
||||
reader.close()
|
||||
if not frames:
|
||||
raise RuntimeError("视频文件中没有帧")
|
||||
frames_array = np.stack(frames, axis=0)
|
||||
tensor = torch.from_numpy(frames_array)
|
||||
|
||||
# 如果有采样要求,进行后处理采样
|
||||
if target_count is not None or target_indices_str is not None:
|
||||
print(f"{self.PROCESS_PREFIX} 视频加载回退: 读取全量帧后进行采样")
|
||||
_, tensor = self._extract_frames_and_tensor(tensor, target_count, target_indices_str)
|
||||
|
||||
return tensor
|
||||
|
||||
# 计算需要读取的帧索引
|
||||
indices_to_read = list(range(total_frames))
|
||||
|
||||
if target_indices_str is not None:
|
||||
indices_to_read = self._parse_frame_indices(target_indices_str, total_frames)
|
||||
print(f"{self.PROCESS_PREFIX} 视频加载优化(手动): 从 {total_frames} 帧中提取 {len(indices_to_read)} 帧")
|
||||
elif target_count and total_frames > target_count:
|
||||
indices_to_read = self._uniform_sample(indices_to_read, target_count)
|
||||
print(f"{self.PROCESS_PREFIX} 视频加载优化(自动): 从 {total_frames} 帧中采样 {len(indices_to_read)} 帧")
|
||||
|
||||
# 优化读取: 只读取需要的帧
|
||||
frames = []
|
||||
|
||||
# imageio的get_data(index)支持随机访问
|
||||
for idx in indices_to_read:
|
||||
try:
|
||||
frame = reader.get_data(idx)
|
||||
frame_float = frame.astype(np.float32) / 255.0
|
||||
frames.append(frame_float)
|
||||
except IndexError:
|
||||
break
|
||||
|
||||
reader.close()
|
||||
|
||||
if not frames:
|
||||
raise RuntimeError("未能读取到有效帧")
|
||||
|
||||
# 转换为torch tensor [N, H, W, C]
|
||||
frames_array = np.stack(frames, axis=0)
|
||||
tensor = torch.from_numpy(frames_array)
|
||||
|
||||
return tensor
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"视频文件加载失败: {str(e)}")
|
||||
|
||||
# 节点映射,用于ComfyUI注册节点
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"VideoCaptionNode": VideoCaptionNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"VideoCaptionNode": "✨Video Caption",
|
||||
}
|
||||
Reference in New Issue
Block a user