Files
yawiii-ComfyUI-Prompt-Assis…/node/video_caption_node.py
T

343 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
视频反推(视频理解)节点 - V3 版本
"""
import os
import shutil
import tempfile
import hashlib
from typing import List, Tuple
from io import BytesIO
from PIL import Image as PILImage
import torch
import numpy as np
from comfy.model_management import InterruptProcessingException
from comfy_api.latest import io
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,
get_model_max_images, WARN_PREFIX
)
from ..services.thinking_control import build_thinking_suppression
from .base import VLMNodeBase
class VideoCaptionNode(VLMNodeBase, io.ComfyNode):
"""视频反推节点(V3)"""
@classmethod
def define_schema(cls):
service_options = cls.get_vlm_service_options()
default_service = service_options[0] if service_options else "智谱"
from ..config_manager import config_manager
system_prompts = config_manager.get_system_prompts()
video_prompts = {}
active_video_id = None
if system_prompts:
video_prompts = system_prompts.get('video_prompts', {}) or {}
active_video_id = system_prompts.get('active_prompts', {}).get('video')
prompt_template_options = []
id_to_display_name = {}
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
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_video_id and active_video_id in id_to_display_name:
default_template_name = id_to_display_name[active_video_id]
if not prompt_template_options:
prompt_template_options = ["视频-自然语言"]
return io.Schema(
node_id="VideoCaptionNode",
display_name="✨Video Caption (VLM)",
category="✨Prompt Assistant",
description="Extract text prompt from video frames using Vision-Language Models",
inputs=[
# ComfyUI 的 VIDEO 数据类型比较特殊,可能是包含 frames 的字典 (如 VHS 节点)
# 而很多其他节点出来的视频类型是直接的 IMAGE batch(一堆图片张量)
# 我们同时提供两个端口以实现最大程度的兼容
io.Image.Input("video_frames", tooltip="The video frames to analyze (IMAGE batch)", optional=True),
io.Custom.Input("video", "VIDEO", tooltip="Compatible with VIDEO type nodes (like VHS)", optional=True),
io.Combo.Input(
"rule",
options=prompt_template_options,
default=default_template_name,
),
io.Boolean.Input(
"custom_rule",
default=False,
label_on="Enable",
label_off="Disable"
),
io.String.Input(
"custom_rule_content",
multiline=True,
default="",
),
io.String.Input(
"user_prompt",
multiline=True,
default="",
),
io.Int.Input(
"frame_count",
default=4,
min=1,
max=16,
step=1
),
io.Combo.Input(
"vlm_service",
options=service_options,
default=default_service,
),
io.Boolean.Input(
"ollama_auto_unload",
default=True,
label_on="Enable",
label_off="Disable"
),
io.Int.Input(
"seed",
default=0,
min=0,
max=0xffffffffffffffff,
control_after_generate=True,
),
],
outputs=[
io.String.Output("caption_text"),
],
hidden=[io.Hidden.unique_id],
)
@classmethod
def fingerprint_inputs(
cls,
video_frames=None, video=None, rule=None, custom_rule=None, custom_rule_content=None,
user_prompt=None, frame_count=None, vlm_service=None, ollama_auto_unload=None, seed=None
):
import hashlib
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()
# 兼容性:如果 video_frames 为空,尝试从 video 字典提取
effective_frames = video_frames
if effective_frames is None and isinstance(video, dict) and "frames" in video:
effective_frames = video["frames"]
# 计算有效视频帧哈希
video_hash = ""
if effective_frames is not None and hasattr(effective_frames, "shape"):
shape_str = str(effective_frames.shape)
hasher = hashlib.md5(shape_str.encode('utf-8'))
if effective_frames.numel() > 0:
step = max(1, effective_frames.shape[0] // min(4, effective_frames.shape[0]))
sample = effective_frames[::step]
hasher.update(str(int(sample.sum().item())).encode('utf-8'))
video_hash = hasher.hexdigest()
return hash((
video_hash,
rule,
bool(custom_rule),
temp_rule_hash,
user_hint_hash,
frame_count,
vlm_service,
bool(ollama_auto_unload),
seed
))
@classmethod
def _extract_frames(cls, video_tensor: torch.Tensor, target_count: int) -> List[str]:
"""按时间均匀抽取视频帧并转为 base64"""
total_frames = video_tensor.shape[0]
if total_frames == 0:
return []
if target_count >= total_frames:
indices = list(range(total_frames))
else:
indices = np.linspace(0, total_frames - 1, target_count, dtype=int).tolist()
base64_frames = []
for idx in indices:
frame_tensor = video_tensor[idx:idx+1]
b64 = cls._image_to_base64(frame_tensor, quality=85)
base64_frames.append(b64)
return base64_frames
@classmethod
def execute(
cls,
video_frames=None, video=None, rule=None, custom_rule=None, custom_rule_content=None,
user_prompt=None, frame_count=None, vlm_service=None, ollama_auto_unload=None, seed=None
):
unique_id = cls.hidden.unique_id
request_id = None
try:
# 兼容性处理:如果 video_frames 为空,尝试从 video (VIDEO 类型) 中提取帧
if (video_frames is None or video_frames.numel() == 0) and video is not None:
if isinstance(video, dict) and "frames" in video:
video_frames = video["frames"]
elif isinstance(video, torch.Tensor):
video_frames = video
if video_frames is None or video_frames.numel() == 0:
raise ValueError("No video frames provided. Please connect IMAGE batch or VIDEO input.")
system_message = None
rule_name = "Custom Rule" if (custom_rule and custom_rule_content) else rule
if custom_rule and custom_rule_content:
system_message = {"role": "system", "content": custom_rule_content}
else:
from ..config_manager import config_manager
system_prompts = config_manager.get_system_prompts()
video_prompts = system_prompts.get('video_prompts', {}) if system_prompts else {}
template_found = False
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 or 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 = "Default Rule"
service_id, model_name = cls.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}")
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 vision 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),
}
if service.get('type') == 'ollama':
provider_config['auto_unload'] = ollama_auto_unload
request_id = generate_request_id("video", None, unique_id)
# 抽帧
base64_images = cls._extract_frames(video_frames, frame_count)
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)
# 获取模型推断上限
max_images = get_model_max_images(model_full_name)
# 截断检查
original_count = len(base64_images)
truncated_count = 0
if original_count > max_images:
base64_images = base64_images[:max_images]
truncated_count = original_count - max_images
print(f"{WARN_PREFIX} ⚠️ 帧数截断 | 原始:{original_count} → 实际:{max_images} | 已忽略:{truncated_count}帧 | 模型:{model_full_name}")
log_prepare(TASK_VIDEO_CAPTION, request_id, SOURCE_NODE, service_display_name, model_display, rule_name, {"帧数": len(base64_images)})
if not provider_config.get('api_key', '') or not provider_config.get('model', ''):
raise ValueError(f"Please configure API key and model for {vlm_service}")
# 从 system_message 字典中提取纯文本内容
system_text = system_message.get('content', '') if isinstance(system_message, dict) else str(system_message)
prompt_to_send = f"{system_text}\n\n{user_prompt}".strip() if user_prompt else system_text
result = cls._run_vision_task(
VisionService.analyze_images,
service_id,
images_data=base64_images,
prompt_content=prompt_to_send,
request_id=request_id,
custom_provider=service_id,
custom_provider_config=provider_config,
source=SOURCE_NODE
)
if result and result.get('success'):
# 成功后推送截断警告到前端
if truncated_count > 0:
try:
from server import PromptServer
PromptServer.instance.send_sync("prompt_assistant.warning", {
"type": "frame_truncated",
"model": model_full_name,
"max_images": max_images,
"original": original_count,
"truncated": truncated_count
})
except Exception:
pass
# V3 重构后返回的键名是 description 而不是 caption
data = result.get('data', {})
caption_text = data.get('description', data.get('caption', '')).strip()
if not caption_text:
error_msg = 'API returned empty result'
log_error(TASK_VIDEO_CAPTION, request_id, error_msg, source=SOURCE_NODE)
raise RuntimeError(f"Analysis failed: {error_msg}")
return io.NodeOutput(caption_text)
else:
error_msg = result.get('error', 'Unknown error') if result else 'No result returned'
if error_msg == "任务被中断":
raise InterruptProcessingException()
log_error(TASK_VIDEO_CAPTION, request_id, error_msg, source=SOURCE_NODE)
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}")