Files
if-ai-ComfyUI-IF_VideoPrompts/IF_VideoPromptsNode.py
T
2025-03-06 16:37:16 +00:00

980 lines
50 KiB
Python

# IF_VideoPromptsNode.py
import os
import sys
import json
import torch
import logging
import hashlib
import time
import math
from pathlib import Path
from typing import List, Dict, Any, Optional, Union, Tuple
from PIL import Image, ImageOps
import numpy as np
import folder_paths
import gc
import qwen_vl_utils
try:
from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration, AutoConfig
QWEN_AVAILABLE = True
except ImportError:
logging.warning("Transformers package not found. Please install with: pip install transformers torch")
QWEN_AVAILABLE = False
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
class VideoPromptNode:
"""
A ComfyUI node that analyzes video sequences or video files using Qwen2.5-VL multimodal models.
This node provides two main modes of operation:
1. Frame-based mode: Takes pre-loaded frames from LoadVideo nodes and analyzes them
2. Direct video mode: Takes a video file directly and processes it
The node can generate:
- Descriptive prompts for video content
- Complete scene analysis
- Key scene breakdowns
- Negative prompts based on configured templates
"""
def __init__(self):
# Initialize paths for storing presets and profiles
self.current_dir = os.path.dirname(os.path.abspath(__file__))
self.presets_dir = os.path.join(self.current_dir, "presets")
self.profiles_path = os.path.join(self.presets_dir, "profiles.json")
# Create directory if it doesn't exist
os.makedirs(self.presets_dir, exist_ok=True)
# Load profiles
self.profiles = self.load_presets(self.profiles_path)
# Check if LLM folder exists in models, create if not
llm_path = os.path.join(folder_paths.models_dir, "LLM")
os.makedirs(llm_path, exist_ok=True)
# Add LLM path to folder_paths if not already there
if hasattr(folder_paths, "folder_names_and_paths"):
if "LLM" not in folder_paths.folder_names_and_paths:
# Register LLM path to folder_paths
supported_extensions = {'.pt', '.pth', '.safetensors', '.bin', '.ckpt'}
folder_paths.folder_names_and_paths["LLM"] = ([llm_path], supported_extensions)
# Default values
self.model = None
self.processor = None
self.device = self.get_optimal_device()
self.qwen_models = {
"Qwen2.5-VL-3B-Instruct": "Qwen/Qwen2.5-VL-3B-Instruct",
"Qwen2.5-VL-7B-Instruct": "Qwen/Qwen2.5-VL-7B-Instruct",
"Qwen2.5-VL-3B-Instruct-AWQ": "Qwen/Qwen2.5-VL-3B-Instruct-AWQ",
"Qwen2.5-VL-7B-Instruct-AWQ": "Qwen/Qwen2.5-VL-7B-Instruct-AWQ"
}
# System prompts for different languages
self.system_prompts = {
"en": "You are a professional video sequence analyzer. Describe the visual content of the frames with attention to detail, capturing the storytelling, composition, lighting, movement, and emotional tone. Be specific, clear, and concise.",
"zh": "你是一位专业的视频序列分析师。请详细描述帧中的视觉内容,注意讲故事、构图、光线、运动和情感基调。请具体、清晰、简洁。"
}
# Load negative prompts
self.neg_prompts_path = os.path.join(self.presets_dir, "neg_prompts.json")
self.neg_prompts = self.load_neg_prompts()
def get_optimal_device(self):
"""Determine the best device for model loading based on system capabilities."""
if torch.cuda.is_available():
return "cuda"
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
# For Mac M1/M2 chips
return "mps"
else:
return "cpu"
@classmethod
def INPUT_TYPES(cls):
# Get available local models
llm_path = os.path.join(folder_paths.models_dir, "LLM")
local_models = []
# Look for locally downloaded models in the LLM directory
if os.path.exists(llm_path):
for model_dir in os.listdir(llm_path):
if os.path.isdir(os.path.join(llm_path, model_dir)):
# Check if it contains a config.json file (transformer model)
if os.path.exists(os.path.join(llm_path, model_dir, "config.json")):
local_models.append(f"local:{model_dir}")
# Get available negative prompts
instance = cls()
neg_prompt_keys = ["None"] + list(instance.neg_prompts.keys())
# Combine with remote models
model_choices = [
"Qwen2.5-VL-3B-Instruct",
"Qwen2.5-VL-7B-Instruct",
"Qwen2.5-VL-3B-Instruct-AWQ",
"Qwen2.5-VL-7B-Instruct-AWQ"
] + local_models
# Get input directories for video files
input_dir = folder_paths.get_input_directory()
video_files = []
for f in os.listdir(input_dir):
if os.path.isfile(os.path.join(input_dir, f)):
file_parts = f.split('.')
if len(file_parts) > 1 and (file_parts[-1].lower() in ['mp4', 'avi', 'mov', 'webm', 'mkv']):
video_files.append(f)
# Sort video files for easier selection
video_files = sorted(video_files)
return {
"required": {
"input_mode": (["Frames", "Video File"], {"default": "Frames", "tooltip": "Select input mode: use pre-loaded frames or direct video file"}),
"model_name": (model_choices, {"default": "Qwen2.5-VL-3B-Instruct-AWQ", "tooltip": "Select the Qwen2.5-VL model to use"}),
"profile": (["None"] + list(instance.profiles.keys()), {"default": "HyVideoAnalyzer - Simple one line prompt", "tooltip": "Select a profile with predefined system prompt and rules"}),
"max_new_tokens": ("INT", {"default": 512, "min": 1, "max": 2048, "tooltip": "Maximum number of new tokens to generate"}),
"frame_sample_count": ("INT", {"default": 16, "min": 1, "max": 32, "step": 1, "tooltip": "Number of frames to sample from entire sequence"}),
"temperature": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 2.0, "step": 0.01, "tooltip": "Higher values increase creativity but reduce coherence"}),
"analysis_type": (["Full sequence", "Key scenes", "Single summary"], {"default": "Full sequence", "tooltip": "Type of analysis to perform on the video"}),
"language": (["English", "Chinese"], {"default": "English", "tooltip": "Language for the output"}),
},
"optional": {
"images": ("IMAGE", {"tooltip": "Input frames from a LoadVideo node"}),
"video_file": (sorted(video_files), {"tooltip": "Select a video file from the input directory"}),
"fps": ("FLOAT", {"default": 8.0, "min": 0.1, "max": 60.0, "step": 0.1, "tooltip": "Frames per second for video processing. Higher values sample more frames."}),
"max_pixels": ("INT", {"default": 512*512, "min": 0, "max": 1280*720, "step": 1000, "tooltip": "Max pixels for video processing (0 = default)"}),
"fallback_frame_count": ("INT", {"default": 4, "min": 1, "max": 16, "step": 1, "tooltip": "Number of frames to use in fallback mode if initial processing fails. Lower values use less VRAM."}),
"custom_system_prompt": ("STRING", {"multiline": True, "default": "", "tooltip": "Custom system prompt to override the profile"}),
"prefix": ("STRING", {"default": "", "tooltip": "Text to add before the generated prompt"}),
"suffix": ("STRING", {"default": "", "tooltip": "Text to add after the generated prompt"}),
"seed": ("INT", {"default": -1, "tooltip": "Random seed for generation (use -1 for random)"}),
"negative_prompt": (neg_prompt_keys, {"default": "None", "tooltip": "Predefined negative prompt to use"}),
"model_offload": (["Yes", "No"], {"default": "Yes", "tooltip": "Offload model from GPU when not in use to save VRAM"}),
"precision": (["float16", "bfloat16", "float32"], {"default": "float16", "tooltip": "Model precision - lower precision uses less VRAM but may reduce quality"})
}
}
RETURN_TYPES = ("STRING", "STRING", "IMAGE", "STRING")
RETURN_NAMES = ("sequence_description", "scene_breakdown", "preview_image", "negative_prompt")
FUNCTION = "analyze_sequence"
CATEGORY = "ImpactFrames💥🎞️/LLM"
def load_model(self, model_name, precision="float16", model_offload="Yes"):
"""Load and prepare the model."""
if self.model is not None:
# Check if we need to reinitialize
if getattr(self, "current_model_name", None) == model_name and \
getattr(self, "current_precision", None) == precision:
logger.info(f"Model {model_name} already loaded, skipping")
# If we're using CUDA, make sure the model is on CUDA
if self.device == "cuda" and hasattr(self.model, "device") and self.model.device.type != "cuda":
logger.info(f"Moving model from {self.model.device} to cuda:0")
try:
self.model = self.model.to("cuda:0")
except Exception as e:
logger.warning(f"Failed to move model to CUDA: {e}")
# We'll try again later in analyze_sequence
return
else:
# Clean up old model before loading a new one
logger.info(f"Unloading previous model {getattr(self, 'current_model_name', 'unknown')}")
del self.model
del self.processor
torch.cuda.empty_cache()
gc.collect()
self.model = None
self.processor = None
try:
logger.info(f"Loading model: {model_name} with precision {precision}")
# Set appropriate tensor type based on precision setting
if precision == "bfloat16" and torch.cuda.is_available() and torch.cuda.is_bf16_supported():
dtype = torch.bfloat16
elif precision == "float32":
dtype = torch.float32
else:
dtype = torch.float16
# Determine if we can use flash attention
can_use_flash_attn = False
if self.device == "cuda":
try:
from flash_attn import flash_attn_func
can_use_flash_attn = True
logger.info("Flash attention available, will use for better performance")
except ImportError:
pass
# Handle local models
if model_name.startswith("local:"):
local_model_dir = os.path.join(folder_paths.models_dir, "LLM", model_name[6:])
logger.info(f"Loading local model from: {local_model_dir}")
# Check for a specific model_type.txt file that could indicate special loading requirements
model_type_path = os.path.join(local_model_dir, "model_type.txt")
if os.path.exists(model_type_path):
with open(model_type_path, "r") as f:
model_type = f.read().strip()
else:
model_type = "default"
# Setup model loading parameters
model_kwargs = {
"torch_dtype": dtype,
"trust_remote_code": True,
}
# Configure device mapping based on hardware
if self.device == "cuda":
# Always use a single GPU setup to avoid device conflicts
logger.info("Using single GPU configuration to avoid device conflicts")
# Don't use device_map at all - load to a specific device instead
model_kwargs.pop("device_map", None)
else:
# For CPU or MPS, use the device directly
logger.info(f"Using {self.device} device")
model_kwargs["device_map"] = {"": self.device}
# Add flash attention if available
if can_use_flash_attn:
model_kwargs["attn_implementation"] = "flash_attention_2"
# Load the model
self.model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
local_model_dir,
**model_kwargs
)
self.processor = AutoProcessor.from_pretrained(
local_model_dir,
trust_remote_code=True
)
# Handle remote Qwen models
elif model_name in self.qwen_models:
# Get the HF model name
hf_model_name = self.qwen_models[model_name]
# Setup model loading parameters
model_kwargs = {
"torch_dtype": dtype,
"trust_remote_code": True,
}
# Configure device mapping based on hardware
if self.device == "cuda":
# Always use a single GPU setup to avoid device conflicts
logger.info("Using single GPU configuration to avoid device conflicts")
# Don't use device_map at all - load to a specific device instead
model_kwargs.pop("device_map", None)
else:
# For CPU or MPS, use the device directly
logger.info(f"Using {self.device} device")
model_kwargs["device_map"] = {"": self.device}
# Add flash attention if available and appropriate
if can_use_flash_attn:
model_kwargs["attn_implementation"] = "flash_attention_2"
# Special handling for AWQ models
if "AWQ" in model_name:
logger.info("Loading AWQ quantized model")
model_kwargs["torch_dtype"] = dtype # AWQ models still need dtype
self.model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
hf_model_name,
**model_kwargs
)
# Load processor with appropriate settings
# Use default pixel limits for processor
min_pixels = 256 * 28 * 28 # Minimum pixel dimensions (default from Qwen docs)
max_pixels = 1280 * 28 * 28 # Maximum pixel dimensions (default from Qwen docs)
self.processor = AutoProcessor.from_pretrained(
hf_model_name,
min_pixels=min_pixels,
max_pixels=max_pixels,
trust_remote_code=True
)
else:
raise ValueError(f"Unknown model: {model_name}")
# Store the model name for future reference
self.current_model_name = model_name
self.current_precision = precision
# For CUDA, explicitly move model to cuda:0 after loading
if self.device == "cuda" and not hasattr(self.model, "device_map"):
logger.info("Moving model to cuda:0")
self.model = self.model.to("cuda:0")
logger.info(f"Model loaded successfully with device_map: {self.model.device_map if hasattr(self.model, 'device_map') else self.device}")
except Exception as e:
import traceback
logger.error(f"Error loading model: {e}")
logger.error(traceback.format_exc())
raise RuntimeError(f"Failed to load model: {e}")
def ensure_model_downloaded(self, model_name):
"""
Ensures the model is downloaded to the models/LLM directory.
Returns the local path to the model.
"""
if not model_name.startswith("local:"):
model_id = self.qwen_models.get(model_name, model_name)
local_dir = os.path.join(folder_paths.models_dir, "LLM", model_id.split('/')[-1])
if not os.path.exists(local_dir):
try:
from huggingface_hub import snapshot_download
# Create directory
os.makedirs(local_dir, exist_ok=True)
logger.info(f"Downloading {model_id} to {local_dir}...")
snapshot_download(
repo_id=model_id,
local_dir=local_dir,
local_dir_use_symlinks=False
)
logger.info(f"Model downloaded successfully to {local_dir}")
except Exception as e:
logger.error(f"Error downloading model: {e}")
raise
return local_dir
else:
# For local models, just return the path
local_model_dir = model_name[6:] # Remove "local:" prefix
return os.path.join(folder_paths.models_dir, "LLM", local_model_dir)
def load_presets(self, file_path: str) -> Dict[str, Any]:
"""
Load JSON presets with support for multiple encodings and better error handling.
"""
# Create default profiles if file doesn't exist
if not os.path.exists(file_path):
default_profiles = {
"HyVideoAnalyzer - Simple one line prompt": {
"instruction": "You are an AI that combines the eye of a cinematographer with the heart of a storyteller. Your role is to analyze video scenes and create concise prompts that capture both the technical beauty and emotional essence of a scene, adhering to the HyVideo schema. Transform visual inputs into clear, evocative descriptions that balance artistic vision with practical filmmaking elements, while limiting the prompt to 77 tokens and 20-30 keywords.",
"rules": [
"Begin with the story or emotion the scene conveys",
"Describe the visual composition in natural language",
"Include key technical elements without overwhelming detail",
"Blend narrative focus with cinematic techniques",
"Consider the mood and atmosphere",
"Maintain balance between artistic and technical descriptions",
"Incorporate user modifications naturally into the scene vision",
"Limit the prompt to 77 tokens and 20-30 keywords",
"Do not enumerate or use formatting",
"Reply with the content only, no additional commentary or reasoning steps"
]
},
"HyVideoAnalyzer3 - Multi-Frame RF-Edit": {
"instruction": "You are a cinematic sequence analyzer. Describe a series of images as frames from a movie scene, using rich visual storytelling language. Detail for each frame: 1. Main theme evolution. 2. Object properties changes. 3. Actions, behaviors, temporal progression. 4. Environment, atmosphere continuity. 5. Camera techniques & transitions. Infer camera movement and transitions between frames to ensure cinematic coherence. Emphasize action/movement progression across frames unless stillness is implied. Maintain narrative continuity across the sequence.",
"rules": [
"Cinematic, natural language descriptions for the entire sequence",
"Focus on visual storytelling across the frame sequence",
"Infer camera movement and transitions between frames",
"Emphasize action/movement progression through frames",
"Maintain narrative and visual continuity",
"Analyze the video as a complete sequence, not disconnected frames",
"Identify recurring elements and motion patterns",
"Note significant changes in subject position or appearance",
"Describe the overall narrative arc of the sequence"
]
},
"Narrative VideoFlow Analyzer": {
"instruction": "You are a specialized AI video sequence analyzer. Your task is to analyze a sequence of frames from a video and provide a comprehensive narrative description of the scene as it develops over time. Focus on character movements, actions, expressions, and the overall flow of the scene.",
"rules": [
"Analyze the sequence as a continuous narrative, not separate frames",
"Identify main characters/subjects and track their movements through the sequence",
"Note how the scene evolves from beginning to end",
"Describe key actions, gestures, and movements",
"Identify any significant changes in expression or emotion",
"Describe the setting and any changes to it",
"Note visual style, lighting, and color palette changes",
"Provide one cohesive description of the entire sequence",
"Focus on the narrative flow and emotional content",
"Be cinematic in your language and description"
]
}
}
try:
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(default_profiles, f, indent=2)
return default_profiles
except Exception as e:
logger.error(f"Failed to create default profiles: {e}")
return {}
# Try to load existing file with different encodings
encodings = ['utf-8', 'utf-8-sig', 'latin1', 'cp1252', 'gbk']
for encoding in encodings:
try:
with open(file_path, 'r', encoding=encoding) as f:
content = f.read()
data = json.loads(content)
return data
except Exception:
continue
logger.error(f"Error: Failed to load {file_path} with any supported encoding")
return {}
def get_system_prompt(self, profile: str, custom_prompt: str = None, language: str = "English") -> str:
"""Get the system prompt from a profile, custom prompt, or language-specific default."""
# If custom prompt is provided, use it
if custom_prompt and custom_prompt.strip():
return custom_prompt.strip()
# If profile is specified and exists, use it
if profile != "None" and profile in self.profiles:
profile_content = self.profiles.get(profile, {})
if isinstance(profile_content, str):
return profile_content
elif isinstance(profile_content, dict):
# Try to construct from instruction and rules
instruction = profile_content.get("instruction", "")
rules = profile_content.get("rules", [])
if instruction and rules:
return instruction + "\n\n" + "\n".join([f"- {rule}" for rule in rules])
elif instruction:
return instruction
# Fallback to language-specific system prompt
lang_key = "zh" if language == "Chinese" else "en"
return self.system_prompts.get(lang_key, self.system_prompts["en"])
def load_neg_prompts(self) -> Dict[str, str]:
"""Load negative prompts from JSON file or create defaults if not exists."""
if not os.path.exists(self.neg_prompts_path):
# Create default negative prompts
default_neg_prompts = {
"WAN_neg": "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走",
"VidNeg": "frames showing ugly scenes, static with no motion, motion blur, over-saturation, shaky footage, poor color balance, washed out colors, choppy sequences, jerky movements, unnatural transitions, unconvincing visuals, jump cuts, visual noise, and flickering. Overall, the video is of poor quality."
}
try:
with open(self.neg_prompts_path, 'w', encoding='utf-8') as f:
json.dump(default_neg_prompts, f, indent=2)
return default_neg_prompts
except Exception as e:
logger.error(f"Failed to create default negative prompts: {e}")
return {}
# Try to load existing file with different encodings
encodings = ['utf-8', 'utf-8-sig', 'latin1', 'cp1252', 'gbk']
for encoding in encodings:
try:
with open(self.neg_prompts_path, 'r', encoding=encoding) as f:
content = f.read()
data = json.loads(content)
return data
except Exception:
continue
logger.error(f"Error: Failed to load {self.neg_prompts_path} with any supported encoding")
return {}
def process_frames(self, images_tensor, frame_sample_count, max_pixels=512*512):
"""
Process image frames for the model by sampling and resizing.
Args:
images_tensor: Input tensor in shape [B,H,W,C]
frame_sample_count: Number of frames to sample
max_pixels: Max pixels for each frame
Returns:
List of processed PIL images
"""
# Get the batch size (number of frames)
batch_size = images_tensor.shape[0]
# Sample the frames evenly from the batch
if batch_size <= frame_sample_count:
# Use all frames if we have fewer than requested
sampled_indices = list(range(batch_size))
else:
# Sample evenly across the frames
sampled_indices = [int(i * (batch_size - 1) / (frame_sample_count - 1)) for i in range(frame_sample_count)]
# Extract the sampled frames from the tensor
sampled_frames = [images_tensor[i] for i in sampled_indices]
# Convert to PIL images
pil_images = []
for frame in sampled_frames:
# Convert tensor to numpy
frame_np = frame.cpu().numpy()
# Scale from [0,1] to [0,255]
frame_np = (frame_np * 255).astype(np.uint8)
# Convert to PIL
pil_image = Image.fromarray(frame_np)
# Calculate resize dimensions if needed
if max_pixels > 0:
width, height = pil_image.size
if width * height > max_pixels:
scale = math.sqrt(max_pixels / (width * height))
new_width = int(width * scale)
new_height = int(height * scale)
pil_image = pil_image.resize((new_width, new_height), Image.LANCZOS)
pil_images.append(pil_image)
return pil_images
def analyze_sequence(self, input_mode="Frames", model_name="Qwen2.5-VL-3B-Instruct-AWQ",
profile="HyVideoAnalyzer - Simple one line prompt", max_new_tokens=512,
frame_sample_count=16, temperature=0.7, analysis_type="Full sequence",
language="English", images=None, video_file=None, fps=8.0, max_pixels=512*512,
fallback_frame_count=4, custom_system_prompt="", prefix="", suffix="",
seed=-1, negative_prompt="None", model_offload="Yes", precision="float16"):
"""
Main function to analyze video sequences and generate text prompts.
Args:
input_mode: "Frames" or "Video File"
model_name: The name of the Qwen2.5-VL model to use
profile: The system prompt profile to use
max_new_tokens: Maximum number of tokens to generate
frame_sample_count: Number of frames to sample from the input
temperature: Temperature for text generation
analysis_type: Type of analysis to perform
language: Output language
images: Input frames tensor (from LoadVideo node)
video_file: Video file path
fps: Frames per second for video processing
max_pixels: Maximum pixels per frame
fallback_frame_count: Fallback frame count if primary fails
custom_system_prompt: Custom system prompt
prefix: Text to add before the generated text
suffix: Text to add after the generated text
seed: Random seed for generation
negative_prompt: Negative prompt configuration
model_offload: Whether to offload model from GPU when not in use
precision: Model precision setting
Returns:
Tuple of (sequence_description, scene_breakdown, preview_image, negative_prompt)
"""
if not QWEN_AVAILABLE:
raise ImportError("Transformers package not found. Please install with: pip install transformers torch")
# Initialize sampled_indices to empty list as a fallback
sampled_indices = []
# Ensure model is loaded and on the correct device
self.load_model(model_name, precision=precision, model_offload=model_offload)
# Make sure model is on GPU for inference - this is critical since the model might have been offloaded
if self.device == "cuda":
logger.info("Enforcing model on CUDA for inference")
try:
# Force the model to CUDA, regardless of current state
if hasattr(self.model, 'hf_device_map'):
logger.info("Model uses HF device map, skipping direct device movement")
else:
self.model = self.model.to("cuda:0")
logger.info(f"Model moved to {self.model.device}")
except Exception as e:
logger.warning(f"Error moving model to CUDA: {e}")
# Process the input frames
if input_mode == "Frames" and images is not None:
# Get frames from the input tensor
pil_images = self.process_frames(images, frame_sample_count, max_pixels)
# For Frames mode, initialize sampled_indices with sequential numbers
# This matches what we do for Video File mode
sampled_indices = list(range(len(pil_images)))
elif input_mode == "Video File" and video_file is not None:
# Load frames from video file
try:
# For ComfyUI compatibility - get the full path of the video file
video_path = folder_paths.get_annotated_filepath(video_file)
# We'll use qwen_vl_utils if available, otherwise fallback to a simpler method
try:
from qwen_vl_utils import fetch_video
# Convert the video to frames
video_info = {
"video": video_path,
"fps": fps,
"max_pixels": max_pixels
}
video_frames = fetch_video(video_info)
# If we got a tensor, convert to PIL images
if isinstance(video_frames, torch.Tensor):
pil_images = []
sampled_indices = [] # Store frame indices for scene breakdown
for i in range(video_frames.shape[0]):
# Convert from TCHW to HWC format
frame = video_frames[i].permute(1, 2, 0)
# Ensure values are in the [0,1] range for proper image conversion
# This fixes the "negative" appearance issue
if frame.max() > 1.0:
frame = frame / 255.0
# Convert to PIL
frame_np = (frame.cpu().numpy() * 255).astype(np.uint8)
pil_images.append(Image.fromarray(frame_np))
sampled_indices.append(i) # Store original frame index
else:
# Already PIL images
pil_images = video_frames
sampled_indices = list(range(len(pil_images))) # Store frame indices
# Sample frames if needed
if len(pil_images) > frame_sample_count:
indices = [int(i * (len(pil_images) - 1) / (frame_sample_count - 1)) for i in range(frame_sample_count)]
# Store the actual frame indices for the breakdown
frame_indices = [sampled_indices[i] for i in indices]
pil_images = [pil_images[i] for i in indices]
sampled_indices = frame_indices
except ImportError:
# Fallback to using PIL directly
import av
container = av.open(video_path)
video_stream = next(s for s in container.streams if s.type == 'video')
# Calculate frame extraction rate
total_frames = video_stream.frames
if total_frames <= frame_sample_count:
# If there are fewer frames than requested, use all frames
indices = list(range(total_frames))
else:
# Sample frames evenly
indices = [int(i * (total_frames - 1) / (frame_sample_count - 1)) for i in range(frame_sample_count)]
pil_images = []
sampled_indices = [] # Store frame indices for scene breakdown
for i, frame in enumerate(container.decode(video_stream)):
if i in indices:
# Convert frame to PIL
img = frame.to_image()
# Resize if needed
if max_pixels > 0:
width, height = img.size
if width * height > max_pixels:
scale = math.sqrt(max_pixels / (width * height))
new_width = int(width * scale)
new_height = int(height * scale)
img = img.resize((new_width, new_height), Image.LANCZOS)
pil_images.append(img)
sampled_indices.append(i) # Store the original frame index
# Break if we have enough frames
if len(pil_images) >= frame_sample_count:
break
except Exception as e:
logger.error(f"Error loading video: {e}")
raise RuntimeError(f"Failed to load video: {e}")
else:
raise ValueError("No valid input provided. Please provide either image frames or a video file.")
# Create a montage of frames for preview
preview_image = self.create_montage(pil_images)
# Prepare the system prompt
system_prompt = self.get_system_prompt(profile, custom_system_prompt, language)
# Add specific instructions based on the analysis type
if analysis_type == "Key scenes":
system_prompt += "\n\nProvide a breakdown of key scenes in the video, highlighting important visual elements and transitions."
elif analysis_type == "Single summary":
system_prompt += "\n\nProvide a single, concise summary of the entire video sequence."
# Prepare the messages for the model
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": []}
]
# Add all images to the user message
for img in pil_images:
messages[1]["content"].append({"type": "image", "image": img})
# Add the text prompt
prompt_text = "Analyze this video sequence and provide a descriptive prompt that captures its essence."
if language == "Chinese":
prompt_text = "分析这个视频序列,并提供一个能够捕捉其本质的描述性提示。"
messages[1]["content"].append({"type": "text", "text": prompt_text})
# Process with the model
try:
# Format the input for the model
text = self.processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
# Extract images and prepare inputs
image_inputs = [item["image"] for item in messages[1]["content"] if "image" in item]
# First prepare inputs without device specification
inputs = self.processor(text=[text], images=image_inputs, return_tensors="pt")
# Then forcefully move both model and inputs to same device
if self.device == "cuda":
logger.info("Ensuring model and inputs on same CUDA device")
device = "cuda:0"
try:
if not hasattr(self.model, 'hf_device_map'):
self.model = self.model.to(device)
inputs = inputs.to(device)
except Exception as e:
logger.warning(f"Error aligning devices: {e}")
else:
# For CPU inference
device = self.device
self.model = self.model.to(device)
inputs = inputs.to(device)
# Log device information for debugging
if hasattr(self.model, 'device'):
logger.info(f"Model device: {self.model.device}")
logger.info(f"Input IDs device: {inputs.input_ids.device}")
# Set the seed for reproducibility
if seed >= 0:
torch.manual_seed(seed)
# Generate the response
with torch.no_grad():
outputs = self.model.generate(
**inputs,
do_sample=temperature > 0,
temperature=max(0.01, temperature), # Avoid division by zero
max_new_tokens=max_new_tokens,
)
# Decode the generated text
generated_text = self.processor.batch_decode(outputs[:, inputs.input_ids.shape[1]:], skip_special_tokens=True)[0]
except Exception as e:
logger.error(f"Error during model inference: {e}")
# Try with fewer frames as a fallback
if frame_sample_count > fallback_frame_count and len(pil_images) > fallback_frame_count:
logger.info(f"Retrying with {fallback_frame_count} frames instead of {frame_sample_count}")
try:
# Sample fewer frames
reduced_indices = [int(i * (len(pil_images) - 1) / (fallback_frame_count - 1)) for i in range(fallback_frame_count)]
reduced_frames = [pil_images[i] for i in reduced_indices]
# Update sampled_indices for the reduced set
if sampled_indices and len(sampled_indices) >= len(reduced_indices):
reduced_frame_indices = [sampled_indices[i] for i in reduced_indices]
else:
# Fallback if sampled_indices is not properly set
reduced_frame_indices = reduced_indices
# Update the messages
messages[1]["content"] = []
for img in reduced_frames:
messages[1]["content"].append({"type": "image", "image": img})
messages[1]["content"].append({"type": "text", "text": prompt_text})
# Try again with fewer frames
text = self.processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
image_inputs = [item["image"] for item in messages[1]["content"] if "image" in item]
inputs = self.processor(text=[text], images=image_inputs, return_tensors="pt")
# Again, ensure everything is on the same device
if self.device == "cuda":
device = "cuda:0"
if not hasattr(self.model, 'hf_device_map'):
self.model = self.model.to(device)
inputs = inputs.to(device)
else:
device = self.device
self.model = self.model.to(device)
inputs = inputs.to(device)
logger.info(f"Fallback - Inputs device: {inputs.input_ids.device}")
# Generate with reduced frame count
with torch.no_grad():
outputs = self.model.generate(
**inputs,
do_sample=temperature > 0,
temperature=max(0.01, temperature),
max_new_tokens=max_new_tokens,
)
# Decode the generated text
generated_text = self.processor.batch_decode(outputs[:, inputs.input_ids.shape[1]:], skip_special_tokens=True)[0]
# Update sampled indices to the reduced set
sampled_indices = reduced_frame_indices
except Exception as e:
logger.error(f"Fallback also failed: {e}")
generated_text = f"Failed to analyze video: {str(e)}"
else:
generated_text = f"Failed to analyze video: {str(e)}"
# Apply prefix and suffix if provided
final_text = prefix + generated_text + suffix
# Generate scene breakdown with frame indices
scene_breakdown = self.create_scene_breakdown(final_text, sampled_indices)
# Generate a negative prompt if configured
neg_prompt_text = "None"
if negative_prompt != "None" and negative_prompt in self.neg_prompts:
neg_prompt_text = self.neg_prompts[negative_prompt]
# Offload the model if requested
if model_offload == "Yes" and self.device == "cuda":
logger.info("Offloading model to CPU")
# Only offload if we're not using a device_map
if not hasattr(self.model, "device_map") or not self.model.device_map:
try:
# Instead of moving, create a new reference to avoid device map issues
if hasattr(self.model, 'to'):
logger.info("Moving model to CPU via to() method")
self.model = self.model.cpu()
except Exception as e:
logger.warning(f"Could not move model to CPU: {e}")
# Continue even if offloading fails
# Force garbage collection
torch.cuda.empty_cache()
gc.collect()
# Convert preview image to tensor for ComfyUI
preview_tensor = self.pil_to_tensor(preview_image)
return final_text, scene_breakdown, preview_tensor, neg_prompt_text
def create_montage(self, images, max_images=9):
"""Create a montage of the sampled frames for preview."""
if not images:
# Create a blank image if no frames
return Image.new("RGB", (512, 512), (0, 0, 0))
# Limit the number of images to show in the montage
images = images[:max_images]
# Calculate the grid size
grid_size = math.ceil(math.sqrt(len(images)))
# Determine the size of each thumbnail
thumb_size = 512 // grid_size
# Create a new image for the montage
montage = Image.new("RGB", (thumb_size * grid_size, thumb_size * grid_size), (0, 0, 0))
# Place each image in the grid
for i, img in enumerate(images):
# Calculate position
x = (i % grid_size) * thumb_size
y = (i // grid_size) * thumb_size
# Resize the image to thumbnail size
thumb = img.copy()
thumb.thumbnail((thumb_size, thumb_size), Image.LANCZOS)
# Calculate centered position for the thumbnail
pos_x = x + (thumb_size - thumb.width) // 2
pos_y = y + (thumb_size - thumb.height) // 2
# Paste into montage
montage.paste(thumb, (pos_x, pos_y))
return montage
def pil_to_tensor(self, pil_image):
"""Convert a PIL image to a torch tensor in ComfyUI format [B,H,W,C]."""
# Convert PIL to numpy
img_np = np.array(pil_image).astype(np.float32) / 255.0
# Convert to torch tensor and add batch dimension
img_tensor = torch.from_numpy(img_np)[None,]
return img_tensor
def create_scene_breakdown(self, generated_text, sampled_indices=None):
"""
Create a structured scene breakdown from the generated text.
Args:
generated_text: The text generated by the model
sampled_indices: Optional list of frame indices used for the analysis
"""
# In a real implementation, this could call the model again with a different prompt
# For now, we'll just format the text into a simple scene breakdown
lines = generated_text.split(". ")
breakdown = "## Scene Breakdown\n\n"
# Add information about sampled frames if available
if sampled_indices:
breakdown += "### Frames Used for Analysis\n"
breakdown += "Frame indices: " + ", ".join(map(str, sampled_indices)) + "\n\n"
# Create 3-5 scenes depending on the length of the text
num_scenes = min(max(3, len(lines) // 3), 5)
for i in range(num_scenes):
start_idx = i * len(lines) // num_scenes
end_idx = (i + 1) * len(lines) // num_scenes
scene_text = ". ".join(lines[start_idx:end_idx])
if not scene_text.endswith("."):
scene_text += "."
breakdown += f"### Scene {i+1}\n{scene_text}\n\n"
return breakdown
@classmethod
def IS_CHANGED(cls, **kwargs):
# Return a unique hash for the inputs to determine if recomputation is needed
# This is especially important for video file inputs
m = hashlib.sha256()
if kwargs.get('video_file'):
video_path = folder_paths.get_annotated_filepath(kwargs.get('video_file'))
if os.path.exists(video_path):
file_hash = hashlib.sha256()
with open(video_path, 'rb') as f:
for byte_block in iter(lambda: f.read(4096), b""):
file_hash.update(byte_block)
m.update(file_hash.digest())
for k, v in kwargs.items():
if k != 'video_file' and k != 'images':
m.update(str(v).encode())
# For image inputs, use the shape as a proxy for change detection
if kwargs.get('images') is not None:
images = kwargs.get('images')
m.update(str(images.shape).encode())
# Also add a sample of pixel values
if images.numel() > 0:
sample = images.flatten()[:10].tolist()
m.update(str(sample).encode())
return m.digest().hex()