Merge branch 'main' into pyproject

This commit is contained in:
aimfordeb
2026-01-01 19:52:34 +02:00
committed by GitHub
4 changed files with 11769 additions and 171 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Publish to Comfy registry
on:
workflow_dispatch:
push:
branches:
- main
- master
paths:
- "pyproject.toml"
permissions:
issues: write
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'aimfordeb' }}
steps:
- name: Check out code
uses: actions/checkout@v4
with:
submodules: true
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@v1
with:
## Add your own personal access token to your Github Repository secrets and reference it here.
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
File diff suppressed because it is too large Load Diff
+765 -141
View File
@@ -10,7 +10,274 @@ import folder_paths
import nodes
import tempfile
import gc # Import garbage collector
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
from PIL import Image
import folder_paths
from comfy.utils import ProgressBar
import comfy.clip_vision
import math
import json
from comfy.utils import ProgressBar
# Constants from StoryMem
IMAGE_FACTOR = 28
VIDEO_MIN_PIXELS = 48 * IMAGE_FACTOR * IMAGE_FACTOR # 37,632
MIN_FRAME_SIMILARITY = 0.9
MAX_KEYFRAME_NUM = 3
ADAPTIVE_ALPHA = 0.01
HPSV3_QUALITY_THRESHOLD = 3.0
class FunPackStoryMemJSONConverter:
"""
FunPack StoryMem LoRA JSON Converter - supports both manual input and Qwen-generated output.
Features:
- Lenient parsing: auto-adjusts mismatched lengths for cut/first_frame_prompt
- Handles 'cut' as single bool (replicates) or list
- NO forced first cut=True (user can control via prompt or manual input)
- Safe defaults on invalid data → no crashes
- Splits full Qwen story into up to 3 separate scene JSONs
When use_qwen_output=False → uses manual per-scene inputs with the same leniency.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"use_qwen_output": ("BOOLEAN", {
"default": False,
"label_on": "Use Qwen Output (ignores manual scenes below)",
"label_off": "Use Manual Scene Inputs",
"tooltip": "Toggle between Qwen-generated full story JSON or classic manual per-scene setup"
}),
"qwen_output": ("STRING", {
"multiline": True,
"default": "",
"tooltip": "Connect STRING output from Kijai's WanVideoPromptExtender / Qwen enhancer here.\nDrag wire onto the left side of this box to convert to input socket."
}),
"story_name": ("STRING", {
"default": "",
"tooltip": "Story title (used in all output JSONs)"
}),
"story_overview": ("STRING", {
"multiline": True,
"default": "",
"tooltip": "Overall story summary (shared across all scenes)"
}),
# Scene 1
"scene1_video_prompts": ("STRING", {
"multiline": True,
"default": "",
"tooltip": "One prompt per line (each = 5-sec clip)"
}),
"scene1_first_frame_prompts": ("STRING", {
"multiline": True,
"default": "",
"tooltip": "First frame description per prompt (will auto-adjust to match count)"
}),
"scene1_cut_values": ("STRING", {
"default": "",
"tooltip": "Comma-separated booleans: true,false,true,... (will auto-adjust to match prompt count)"
}),
# Scene 2
"scene2_video_prompts": ("STRING", {
"multiline": True,
"default": "",
"tooltip": "One prompt per line (each = 5-sec clip)"
}),
"scene2_first_frame_prompts": ("STRING", {
"multiline": True,
"default": "",
"tooltip": "First frame description per prompt (will auto-adjust to match count)"
}),
"scene2_cut_values": ("STRING", {
"default": "",
"tooltip": "Comma-separated booleans: true,false,true,... (will auto-adjust to match prompt count)"
}),
# Scene 3
"scene3_video_prompts": ("STRING", {
"multiline": True,
"default": "",
"tooltip": "One prompt per line (each = 5-sec clip)"
}),
"scene3_first_frame_prompts": ("STRING", {
"multiline": True,
"default": "",
"tooltip": "First frame description per prompt (will auto-adjust to match count)"
}),
"scene3_cut_values": ("STRING", {
"default": "",
"tooltip": "Comma-separated booleans: true,false,true,... (will auto-adjust to match prompt count)"
}),
},
"optional": {
"qwen_mode_notice": ("STRING", {
"multiline": True,
"default": "!!! QWEN MODE ACTIVE !!!\nAll manual inputs below are completely ignored\nConnect the Qwen JSON string above.",
"tooltip": "Reminder - only visible/meaningful when use_qwen_output = True"
})
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("json_scene_1", "json_scene_2", "json_scene_3")
OUTPUT_NODE = True
FUNCTION = "convert"
CATEGORY = "FunPack"
def convert(self,
use_qwen_output,
qwen_output,
story_name, story_overview,
scene1_video_prompts, scene1_first_frame_prompts, scene1_cut_values,
scene2_video_prompts, scene2_first_frame_prompts, scene2_cut_values,
scene3_video_prompts, scene3_first_frame_prompts, scene3_cut_values,
qwen_mode_notice=""):
if use_qwen_output:
if not qwen_output.strip():
raise ValueError("Qwen mode enabled but 'qwen_output' is empty! Connect Kijai's prompt enhancer output.")
pbar = ProgressBar(2)
try:
full_data = json.loads(qwen_output)
except json.JSONDecodeError as e:
raise ValueError(f"Failed to parse Qwen output as JSON:\n{str(e)}\n\nMake sure system prompt instructs Qwen to return **only** valid JSON.")
story_name = full_data.get("story_name", "Generated Story")
story_overview = full_data.get("story_overview", "Generated overview")
scenes = full_data.get("scenes", [])
if not isinstance(scenes, list) or not scenes:
raise ValueError("Qwen JSON must contain non-empty 'scenes' list.")
outputs = ["", "", ""]
for i, scene in enumerate(scenes[:3]):
if not isinstance(scene, dict):
continue
scene_num = scene.get("scene_num", i+1)
video_prompts = scene.get("video_prompts", [])
first_frame_prompt = scene.get("first_frame_prompt", [])
cut = scene.get("cut", [])
if not video_prompts:
continue
num_prompts = len(video_prompts)
# Lenient: adjust first_frame_prompt
if len(first_frame_prompt) < num_prompts:
if first_frame_prompt:
first_frame_prompt += [first_frame_prompt[-1]] * (num_prompts - len(first_frame_prompt))
else:
first_frame_prompt = [""] * num_prompts
elif len(first_frame_prompt) > num_prompts:
first_frame_prompt = first_frame_prompt[:num_prompts]
# Lenient: handle cut (single bool or mismatched list)
if isinstance(cut, bool):
cut = [cut] * num_prompts
elif not isinstance(cut, list) or not all(isinstance(c, bool) for c in cut):
cut = [False] * num_prompts # Default to no cuts
else:
if len(cut) < num_prompts:
cut += [cut[-1] if cut else False] * (num_prompts - len(cut))
elif len(cut) > num_prompts:
cut = cut[:num_prompts]
# NO forced first cut=True anymore
single_scene_data = {
"scene_num": scene_num,
"video_prompts": video_prompts,
"first_frame_prompt": first_frame_prompt,
"cut": cut
}
json_data = {
"story_name": story_name,
"story_overview": story_overview,
"scenes": [single_scene_data]
}
outputs[i] = json.dumps(json_data, indent=2, ensure_ascii=False)
pbar.update(2)
return tuple(outputs)
else:
# Manual mode - same leniency
scenes_input = [
(scene1_video_prompts, scene1_first_frame_prompts, scene1_cut_values, 1),
(scene2_video_prompts, scene2_first_frame_prompts, scene2_cut_values, 2),
(scene3_video_prompts, scene3_first_frame_prompts, scene3_cut_values, 3),
]
outputs = ["", "", ""]
for i, (video_text, first_text, cuts_text, scene_num) in enumerate(scenes_input):
if not video_text.strip():
continue
video_prompts = [p.strip() for p in video_text.split("\n") if p.strip()]
if not video_prompts:
continue
num_prompts = len(video_prompts)
first_prompts = [f.strip() for f in first_text.split("\n") if f.strip()]
# Lenient adjust first_prompts
if len(first_prompts) < num_prompts:
if first_prompts:
first_prompts += [first_prompts[-1]] * (num_prompts - len(first_prompts))
else:
first_prompts = [""] * num_prompts
elif len(first_prompts) > num_prompts:
first_prompts = first_prompts[:num_prompts]
# Parse cuts leniently
if cuts_text.strip():
cuts_str = [c.strip().lower() for c in cuts_text.split(",") if c.strip()]
cuts = []
for c in cuts_str:
if c in ("true", "t", "1", "yes"):
cuts.append(True)
else:
cuts.append(False) # default for invalid or false
else:
cuts = [False] * num_prompts
# Lenient adjust cuts
if len(cuts) < num_prompts:
cuts += [cuts[-1] if cuts else False] * (num_prompts - len(cuts))
elif len(cuts) > num_prompts:
cuts = cuts[:num_prompts]
# NO forced first cut=True
scene = {
"scene_num": scene_num,
"video_prompts": video_prompts,
"first_frame_prompt": first_prompts,
"cut": cuts
}
full_json = {
"story_name": story_name.strip() or "Manual Story",
"story_overview": story_overview.strip() or "Manual overview",
"scenes": [scene]
}
outputs[i] = json.dumps(full_json, indent=2, ensure_ascii=False)
return tuple(outputs)
class FunPackImg2LatentInterpolation:
@classmethod
def INPUT_TYPES(cls):
@@ -57,143 +324,6 @@ class FunPackImg2LatentInterpolation:
return (output, preview)
# Removed the problematic list_clip_files() helper function
class FunPackCLIPLoader:
class _CLIPAdapter:
def __init__(self, clip_l_model, llama_te_model, optional_secondary_clip=None, optional_merge_mode="None"):
self.clip_l_model = clip_l_model
self.llama_te_model = llama_te_model
self.optional_secondary_clip = optional_secondary_clip
self.optional_merge_mode = optional_merge_mode
print(f"[_CLIPAdapter] Initialized with CLIP-L and LLaMA text encoder.")
if self.optional_secondary_clip:
print(f"[_CLIPAdapter] Optional secondary CLIP also loaded. Merge mode: {self.optional_merge_mode}")
else:
print("[_CLIPAdapter] No optional secondary CLIP provided.")
def tokenize(self, prompt):
# Use the CLIP-L model's tokenizer
return self.clip_l_model.tokenize(prompt)
def encode_from_tokens(self, tokens, return_pooled=True):
# 1. Get embeddings from the primary CLIP-L model
clip_l_cond, clip_l_pooled = self.clip_l_model.encode_from_tokens(tokens, return_pooled=True)
# 2. Get embeddings from the LLaMA text encoder
llama_cond, llama_pooled = self.llama_te_model.encode_from_tokens(tokens, return_pooled=True)
# 3. Handle optional secondary CLIP merge specifically with CLIP-L's output
final_clip_l_cond = clip_l_cond
final_clip_l_pooled = clip_l_pooled
if self.optional_secondary_clip and self.optional_merge_mode != "None":
print(f"[_CLIPAdapter] Merging optional secondary CLIP embeddings (Mode: {self.optional_merge_mode}).")
secondary_cond, secondary_pooled = self.optional_secondary_clip.encode_from_tokens(tokens, return_pooled=True)
if self.optional_merge_mode == "Concatenate Pooled with CLIP-L":
final_clip_l_pooled = torch.cat([clip_l_pooled, secondary_pooled], dim=-1)
print(f"[_CLIPAdapter] Optional CLIP pooled embeddings concatenated with CLIP-L pooled. New CLIP-L pooled dim: {final_clip_l_pooled.shape[-1]}")
elif self.optional_merge_mode == "Concatenate Unpooled with CLIP-L":
if clip_l_cond.shape[1] != secondary_cond.shape[1]:
print("[_CLIPAdapter] WARNING: Unpooled embeddings have different sequence lengths for CLIP-L and optional secondary CLIP. Skipping unpooled concatenation.")
else:
final_clip_l_cond = torch.cat([clip_l_cond, secondary_cond], dim=-1)
print(f"[_CLIPAdapter] Optional CLIP unpooled embeddings concatenated with CLIP-L unpooled. New CLIP-L unpooled dim: {final_clip_l_cond.shape[-1]}")
# 4. Final combination: Concatenate the (potentially merged) CLIP-L output with LLaMA output
final_cond = torch.cat([final_clip_l_cond, llama_cond], dim=-1)
final_pooled = torch.cat([final_clip_l_pooled, llama_pooled], dim=-1)
if return_pooled:
return final_cond, final_pooled
else:
return final_cond
def encode_token_weights(self, tokens):
return self.clip_l_model.encode_token_weights(tokens)
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"clip_l_model_name": (s.get_filename_list(), {"default": "clip_l.safetensors"}),
"llama_text_encoder_name": (s.get_filename_list(), {"default": "clip_g.safetensors"}),
"clip_type": (["HUNYUAN_VIDEO"], {"default": "HUNYUAN_VIDEO"}),
},
"optional": {
"optional_secondary_clip_model_name": (s.get_filename_list(), {"default": "None"}),
"optional_secondary_clip_type": (["CLIP_G", "CLIP_L", "T5_XXL"], {"default": "CLIP_G"}),
"optional_merge_mode": (["None", "Concatenate Pooled with CLIP-L", "Concatenate Unpooled with CLIP-L"], {"default": "None"})
}
}
RETURN_TYPES = ("CLIP",)
RETURN_NAMES = ("clip",)
FUNCTION = "load"
CATEGORY = "FunPack"
@classmethod
def get_filename_list(s):
return folder_paths.get_filename_list("clip")
def load(self, clip_l_model_name, llama_text_encoder_name, clip_type,
optional_secondary_clip_model_name="None", optional_secondary_clip_type="CLIP_G", optional_merge_mode="None"):
# Load CLIP-L model with HUNYUAN_VIDEO type
clip_l_path = folder_paths.get_full_path('clip', clip_l_model_name)
loaded_clip_l = sd.load_clip(
ckpt_paths=[clip_l_path],
clip_type=sd.CLIPType.HUNYUAN_VIDEO,
embedding_directory=None,
model_options={"ignore_mismatched_sizes": True}
)
print(f"[FunPackCLIPLoader] Loaded CLIP-L model: {clip_l_model_name}")
# Load LLaMA text encoder (using CLIP_G as it's part of the Hunyuan architecture)
llama_te_path = folder_paths.get_full_path('clip', llama_text_encoder_name)
loaded_llama_te = sd.load_clip(
ckpt_paths=[llama_te_path],
clip_type=sd.CLIPType.HUNYUAN_VIDEO,
embedding_directory=None,
model_options={"ignore_mismatched_sizes": True}
)
print(f"[FunPackCLIPLoader] Loaded LLaMA text encoder: {llama_text_encoder_name}")
# Load optional secondary CLIP
loaded_optional_secondary_clip = None
if optional_secondary_clip_model_name != "None":
optional_secondary_clip_path = folder_paths.get_full_path('clip', optional_secondary_clip_model_name)
# Map the input type to CLIPType enum
clip_type_mapping = {
"CLIP_G": sd.CLIPType.STABLE_CASCADE,
"CLIP_L": sd.CLIPType.STABLE_DIFFUSION,
"T5_XXL": sd.CLIPType.SD3
}
secondary_clip_type = clip_type_mapping.get(optional_secondary_clip_type, sd.CLIPType.STABLE_DIFFUSION)
loaded_optional_secondary_clip = sd.load_clip(
ckpt_paths=[optional_secondary_clip_path],
clip_type=secondary_clip_type,
embedding_directory=None,
model_options={"ignore_mismatched_sizes": True}
)
print(f"[FunPackCLIPLoader] Loaded optional secondary CLIP model: {optional_secondary_clip_model_name}")
else:
print("[FunPackCLIPLoader] No optional secondary CLIP model specified.")
# Create the adapter
clip_adapter = FunPackCLIPLoader._CLIPAdapter(
clip_l_model=loaded_clip_l,
llama_te_model=loaded_llama_te,
optional_secondary_clip=loaded_optional_secondary_clip,
optional_merge_mode=optional_merge_mode
)
return (clip_adapter,)
# The standalone FunPackPromptEnhancer remains completely unchanged as requested
class FunPackPromptEnhancer:
@classmethod
@@ -376,19 +506,513 @@ class FunPackContinueVideo:
continued = images[-frame_count:]
return (continued,)
class FunPackStoryMemKeyframeExtractor:
"""
Extracts keyframes from video frames using:
1. HPSv3 for quality assessment (optional)
2. CLIP Vision for frame similarity
3. Adaptive threshold to limit keyframe count
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"frames": ("IMAGE",), # ComfyUI IMAGE format [B, H, W, C]
"clip_vision": (folder_paths.get_filename_list("clip_vision"),),
"max_keyframes": ("INT", {
"default": 3,
"min": 1,
"max": 20,
"step": 1,
"tooltip": "Maximum number of keyframes to extract"
}),
"similarity_threshold": ("FLOAT", {
"default": 0.9,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "CLIP similarity threshold (lower = more keyframes)"
}),
"use_quality_filter": ("BOOLEAN", {
"default": False,
"tooltip": "Use HPSv3 to filter low-quality frames (requires hpsv3 package)"
}),
"quality_threshold": ("FLOAT", {
"default": 3.0,
"min": 0.0,
"max": 10.0,
"step": 0.1,
"tooltip": "HPSv3 quality threshold (higher = stricter)"
}),
},
"optional": {
"memory_frames": ("IMAGE", {
"tooltip": "Previous keyframes to compare against (avoid duplicates)"
}),
}
}
RETURN_TYPES = ("IMAGE", "INT",)
RETURN_NAMES = ("keyframes", "keyframe_count",)
FUNCTION = "extract_keyframes"
CATEGORY = "FunPack"
DESCRIPTION = "Extract keyframes using CLIP similarity + HPSv3 quality (StoryMem algorithm)"
def __init__(self):
self.quality_model = None
def load_clip_model(self, clip_vision_name):
"""Load CLIP Vision model from ComfyUI models/clip_vision folder"""
clip_path = folder_paths.get_full_path("clip_vision", clip_vision_name)
clip_vision = comfy.clip_vision.load(clip_path)
return clip_vision
def load_quality_model(self):
"""Load HPSv3 quality assessment model"""
if self.quality_model is not None:
return
try:
from hpsv3 import HPSv3RewardInferencer
device = "cuda" if torch.cuda.is_available() else "cpu"
self.quality_model = HPSv3RewardInferencer(device=device)
except ImportError:
print("WARNING: HPSv3 not installed. Install with: pip install hpsv3")
print("Quality filtering will be disabled.")
self.quality_model = None
def smart_resize(self, height: int, width: int) -> tuple:
"""Resize frame to efficient size for processing"""
factor = IMAGE_FACTOR
min_pixels = VIDEO_MIN_PIXELS
max_pixels = 256 * IMAGE_FACTOR * IMAGE_FACTOR
h_bar = max(factor, round(height / factor) * factor)
w_bar = max(factor, round(width / factor) * factor)
if h_bar * w_bar > max_pixels:
beta = math.sqrt((height * width) / max_pixels)
h_bar = math.floor(height / beta / factor) * factor
w_bar = math.floor(width / beta / factor) * factor
elif h_bar * w_bar < min_pixels:
beta = math.sqrt(min_pixels / (height * width))
h_bar = math.ceil(height * beta / factor) * factor
w_bar = math.ceil(width * beta / factor) * factor
return max(h_bar, factor), max(w_bar, factor)
def clip_preprocess(self, frame_chw: torch.Tensor, clip_vision) -> torch.Tensor:
"""Preprocess frame for CLIP Vision model"""
# ComfyUI CLIP Vision expects [B, H, W, C] format in range [0, 1]
# Convert from [C, H, W] to [1, H, W, C]
frame = frame_chw.permute(1, 2, 0).unsqueeze(0)
# Ensure [0, 1] range
if not torch.is_floating_point(frame):
frame = frame.float()
if frame.max() > 1.5:
frame = frame / 255.0
frame = frame.clamp(0.0, 1.0)
return frame
@torch.no_grad()
def get_clip_similarity(self, frame1: torch.Tensor, frame2: torch.Tensor, clip_vision) -> float:
# Preprocess frames to [1, H, W, C] format
x1 = self.clip_preprocess(frame1, clip_vision)
x2 = self.clip_preprocess(frame2, clip_vision)
# Get CLIP Vision embeddings
z1_raw = clip_vision.encode_image(x1)
z2_raw = clip_vision.encode_image(x2)
# Extract the actual embedding tensor from various possible return formats
def extract_embedding(output):
# Case 1: Direct tensor (older/basic models)
if isinstance(output, torch.Tensor):
return output
# Case 2: ComfyUI's custom Output wrapper (common with projection models)
if isinstance(output, comfy.clip_vision.Output): # Import at top if needed: import comfy.clip_vision
if hasattr(output, 'image_embeds'):
return output.image_embeds
elif hasattr(output, 'pooled_output'):
return output.pooled_output
# Fallback: treat like dict
try:
return output['image_embeds']
except:
pass
# Case 3: Dictionary (some models)
if isinstance(output, dict):
if 'image_embeds' in output:
return output['image_embeds']
if 'pooled_output' in output:
return output['pooled_output']
if 'last_hidden_state' in output:
return output['last_hidden_state'][:, 0] # CLS token if sequence
# Fallback: first tensor value
for v in output.values():
if isinstance(v, torch.Tensor) and v.ndim >= 2:
return v
# Case 4: Tuple (rare here, but safe)
if isinstance(output, (tuple, list)) and len(output) == 1:
return extract_embedding(output[0])
raise TypeError(f"Unexpected output from encode_image: {type(output)}. "
"Supported: tensor, dict, or comfy.clip_vision.Output with 'image_embeds'.")
z1 = extract_embedding(z1_raw)
z2 = extract_embedding(z2_raw)
# Final check
if not (isinstance(z1, torch.Tensor) and isinstance(z2, torch.Tensor)):
raise RuntimeError(f"Failed to extract tensor embeddings: {type(z1)}, {type(z2)}")
# Normalize and compute cosine similarity
z1 = F.normalize(z1, dim=-1)
z2 = F.normalize(z2, dim=-1)
similarity = (z1 * z2).sum(dim=-1).item()
return similarity
def is_low_quality(self, frame: torch.Tensor, threshold: float) -> bool:
"""Check if frame quality is below threshold using HPSv3"""
if self.quality_model is None:
return False # Skip quality check if model not available
# Convert to PIL Image
frame_np = frame.permute(1, 2, 0).cpu().numpy()
frame_np = (frame_np * 255).astype(np.uint8).clip(0, 255)
pil_image = Image.fromarray(frame_np)
# Get quality score
try:
rewards = self.quality_model.reward(image_paths=[pil_image], prompts=[""])
score = rewards[0][0].item()
return score < threshold
except Exception as e:
print(f"Quality check failed: {e}")
return False
def extract_keyframe_indices(self, frames: torch.Tensor, threshold: float,
quality_threshold: float, use_quality: bool, clip_vision) -> list:
"""
Extract keyframe indices using CLIP similarity and quality filtering
Args:
frames: [N, C, H, W] tensor
threshold: CLIP similarity threshold
quality_threshold: HPSv3 quality threshold
use_quality: Whether to use quality filtering
clip_vision: ComfyUI CLIP Vision model
Returns:
List of keyframe indices
"""
num_frames, _, height, width = frames.shape
# Resize frames for efficient processing
resized_h, resized_w = self.smart_resize(height, width)
resized_frames = F.interpolate(
frames,
size=(resized_h, resized_w),
mode="bilinear",
align_corners=False
).float()
# Load quality model if needed
if use_quality:
self.load_quality_model()
# Find first high-quality frame
first_idx = 0
if use_quality and self.quality_model is not None:
while first_idx < num_frames:
if not self.is_low_quality(resized_frames[first_idx], quality_threshold):
break
first_idx += 1
if first_idx >= num_frames:
return [] # No high-quality frames found
# Initialize keyframes
keyframe_indices = [first_idx]
last_keyframe = resized_frames[first_idx]
# Iterate through remaining frames
pbar = ProgressBar(num_frames - first_idx - 1)
for i in range(first_idx + 1, num_frames):
current_frame = resized_frames[i]
# Calculate similarity with last keyframe
similarity = self.get_clip_similarity(last_keyframe, current_frame, clip_vision)
# Check if frame is different enough and high quality
is_different = similarity < threshold
is_quality = True
if use_quality and self.quality_model is not None:
is_quality = not self.is_low_quality(current_frame, quality_threshold)
if is_different and is_quality:
keyframe_indices.append(i)
last_keyframe = current_frame
pbar.update(1)
return keyframe_indices
def check_memory_duplicates(self, keyframes: torch.Tensor,
memory_frames: torch.Tensor,
clip_vision,
threshold: float = 0.9) -> list:
"""
Filter out keyframes that are too similar to memory frames
Returns:
List of boolean flags (True = keep, False = duplicate)
"""
keep_flags = []
for keyframe in keyframes:
is_duplicate = False
for memory_frame in memory_frames:
similarity = self.get_clip_similarity(keyframe, memory_frame, clip_vision)
if similarity > threshold:
is_duplicate = True
break
keep_flags.append(not is_duplicate)
return keep_flags
def extract_keyframes(self, frames, clip_vision, max_keyframes, similarity_threshold,
use_quality_filter, quality_threshold, memory_frames=None):
"""
Main extraction function
Args:
frames: ComfyUI IMAGE format [B, H, W, C] in range [0, 1]
clip_vision: CLIP Vision model name from dropdown
max_keyframes: Maximum number of keyframes
similarity_threshold: Initial CLIP similarity threshold
use_quality_filter: Whether to use HPSv3 filtering
quality_threshold: HPSv3 threshold
memory_frames: Optional previous keyframes to avoid duplicates
Returns:
(keyframes, keyframe_count)
"""
# Load CLIP Vision model from ComfyUI models folder
clip_vision_model = self.load_clip_model(clip_vision)
# Convert ComfyUI format [B, H, W, C] to PyTorch [B, C, H, W]
frames_tensor = frames.permute(0, 3, 1, 2).contiguous()
# Adaptive threshold loop
threshold = similarity_threshold
while True:
keyframe_indices = self.extract_keyframe_indices(
frames_tensor,
threshold,
quality_threshold,
use_quality_filter,
clip_vision_model
)
# Check if we have too many keyframes
if len(keyframe_indices) <= max_keyframes:
break
# Increase threshold to get fewer keyframes
threshold -= ADAPTIVE_ALPHA
# Safety check
if threshold < 0.5:
# Take first N keyframes
keyframe_indices = keyframe_indices[:max_keyframes]
break
print(f"Extracted {len(keyframe_indices)} keyframes at threshold {threshold:.3f}")
# Extract keyframes
if len(keyframe_indices) == 0:
# Return first frame as fallback
keyframes_out = frames[:1]
return (keyframes_out, 1)
keyframes_tensor = frames_tensor[keyframe_indices]
# Check against memory frames to avoid duplicates
if memory_frames is not None:
memory_tensor = memory_frames.permute(0, 3, 1, 2).contiguous()
keep_flags = self.check_memory_duplicates(
keyframes_tensor,
memory_tensor,
clip_vision_model,
threshold=MIN_FRAME_SIMILARITY
)
# Filter keyframes
kept_indices = [i for i, keep in enumerate(keep_flags) if keep]
if len(kept_indices) > 0:
keyframes_tensor = keyframes_tensor[kept_indices]
else:
# Keep at least one keyframe
keyframes_tensor = keyframes_tensor[:1]
# Convert back to ComfyUI format [B, H, W, C]
keyframes_out = keyframes_tensor.permute(0, 2, 3, 1).contiguous()
return (keyframes_out, keyframes_out.shape[0])
class FunPackStoryMemLastFrameExtractor:
"""Extract last frame and last N frames for MI2V/MM2V continuity"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"frames": ("IMAGE",),
"n_frames": ("INT", {
"default": 5,
"min": 1,
"max": 20,
"step": 1,
"tooltip": "Number of frames to extract from end (for MM2V)"
}),
}
}
RETURN_TYPES = ("IMAGE", "IMAGE",)
RETURN_NAMES = ("last_frame", "motion_frames",)
FUNCTION = "extract"
CATEGORY = "FunPack"
DESCRIPTION = "Extract last frame and last N frames for shot continuity (MI2V/MM2V)"
def extract(self, frames, n_frames):
"""
Extract last frame and last N frames
Returns:
(last_frame [1, H, W, C], motion_frames [N, H, W, C])
"""
last_frame = frames[-1:]
motion_frames = frames[-n_frames:]
return (last_frame, motion_frames)
import os
import json
class FunPackCreativeTemplate:
@classmethod
def INPUT_TYPES(s):
# Path to templates.json in the same directory as this script
template_file = os.path.join(os.path.dirname(__file__), "templates.json")
templates = {}
if os.path.exists(template_file):
with open(template_file, 'r') as f:
try:
templates = json.load(f)
except json.JSONDecodeError:
templates = {}
template_names = sorted(list(templates.keys())) + ["Custom"]
default_template = template_names[0] if template_names else "Custom"
return {
"required": {
"template_name": (template_names, {"default": default_template}),
"template_text": ("STRING", {"multiline": True, "default": ""}),
"replacements": ("STRING", {"multiline": True, "default": ""}),
},
"optional": {
"save_name": ("STRING", {"default": ""}),
}
}
RETURN_TYPES = ("STRING",)
FUNCTION = "replace"
CATEGORY = "FunPack/Text"
DESCRIPTION = "Select or create text templates, replace placeholders, and output the result."
def replace(self, template_name, template_text, replacements, save_name=""):
# Load templates
template_file = os.path.join(os.path.dirname(__file__), "templates.json")
templates = {}
if os.path.exists(template_file):
with open(template_file, 'r') as f:
try:
templates = json.load(f)
except json.JSONDecodeError:
templates = {}
# Get the template string
if template_name == "Custom":
if not template_text.strip():
return ("",) # Empty if no custom text provided
template_str = template_text
else:
template_str = templates.get(template_name, "")
# Parse replacements (format: KEY: value per line)
replace_dict = {}
for line in replacements.splitlines():
if ':' in line:
key, val = line.split(':', 1)
replace_dict[key.strip()] = val.strip()
# Perform replacements
for key, val in replace_dict.items():
template_str = template_str.replace(f"[{key}]", val)
# Save new template if save_name provided and using Custom
if save_name and template_name == "Custom" and template_text.strip():
templates[save_name] = template_text
# Create directories if needed (though unlikely)
os.makedirs(os.path.dirname(template_file), exist_ok=True)
with open(template_file, 'w') as f:
json.dump(templates, f, indent=4)
return (template_str,)
# Update NODE_CLASS_MAPPINGS and NODE_DISPLAY_NAME_MAPPINGS
NODE_CLASS_MAPPINGS = {
"FunPackStoryMemJSONConverter": FunPackStoryMemJSONConverter,
"FunPackStoryMemKeyframeExtractor": FunPackStoryMemKeyframeExtractor,
"FunPackStoryMemLastFrameExtractor": FunPackStoryMemLastFrameExtractor,
"FunPackImg2LatentInterpolation": FunPackImg2LatentInterpolation,
"FunPackCLIPLoader": FunPackCLIPLoader,
"FunPackPromptEnhancer": FunPackPromptEnhancer,
"FunPackVideoStitch": FunPackVideoStitch,
"FunPackContinueVideo": FunPackContinueVideo,
"FunPackCreativeTemplate": FunPackCreativeTemplate,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FunPackStoryMemJSONConverter": "FunPack StoryMem JSON Converter",
"FunPackStoryMemKeyframeExtractor": "FunPack StoryMem Keyframe Extractor",
"FunPackStoryMemLastFrameExtractor": "FunPack StoryMem Last Frame Extractor",
"FunPackImg2LatentInterpolation": "FunPack Img2Latent Interpolation",
"FunPackCLIPLoader": "FunPack CLIP Loader",
"FunPackPromptEnhancer": "FunPack Prompt Enhancer (Standalone)",
"FunPackVideoStitch": "FunPack Video Stitch",
"FunPackContinueVideo": "FunPack Continue Video"
}
"FunPackContinueVideo": "FunPack Continue Video",
"FunPackCreativeTemplate": "FunPack Creative Template"
}
+9 -30
View File
@@ -1,36 +1,15 @@
# pyproject.toml
[project]
name = "funpack"
description = ""
version = "1.0.0"
license = {file = "LICENSE"}
# classifiers = [
# # For OS-independent nodes (works on all operating systems)
# "Operating System :: OS Independent",
#
# # OR for OS-specific nodes, specify the supported systems:
# "Operating System :: Microsoft :: Windows", # Windows specific
# "Operating System :: POSIX :: Linux", # Linux specific
# "Operating System :: MacOS", # macOS specific
#
# # GPU Accelerator support. Pick the ones that are supported by your extension.
# "Environment :: GPU :: NVIDIA CUDA", # NVIDIA CUDA support
# "Environment :: GPU :: AMD ROCm", # AMD ROCm support
# "Environment :: GPU :: Intel Arc", # Intel Arc support
# "Environment :: NPU :: Huawei Ascend", # Huawei Ascend support
# "Environment :: GPU :: Apple Metal", # Apple Metal support
# ]
name = "funpack" # Unique identifier for your node. Immutable after creation.
description = "A set of nodes for experiments with video generation models, including Prompt Enhancer, Video Stitch, Creative Template and many other nodes."
version = "1.0.0" # Custom Node version. Must be semantically versioned.
license = { text = "GNU GPL v3" }
dependencies = [] # Filled in from requirements.txt
[project.urls]
Repository = "https://github.com/aimfordeb/ComfyUI-FunPack"
# Used by Comfy Registry https://registry.comfy.org
Documentation = "https://github.com/aimfordeb/ComfyUI-FunPack/wiki"
"Bug Tracker" = "https://github.com/aimfordeb/ComfyUI-FunPack/issues"
[tool.comfy]
PublisherId = ""
DisplayName = "ComfyUI-FunPack"
Icon = ""
includes = []
# "requires-comfyui" = ">=1.0.0" # ComfyUI version compatibility
PublisherId = "aimfordeb" # TODO (fill in Publisher ID from Comfy Registry Website).
DisplayName = "FunPack" # Display name for the Custom Node. Can be changed later.
Icon = "https://example.com/icon.png" # SVG, PNG, JPG or GIF (MAX. 800x400px)