Files
2025-12-29 15:02:26 +09:00

426 lines
19 KiB
Python

import os
import gc
import sys
import numpy as np
import torch
import torch.distributed as dist
from PIL import Image
from omegaconf import OmegaConf
from transformers import AutoTokenizer
from safetensors.torch import load_file
from huggingface_hub import snapshot_download
# ComfyUI imports
import folder_paths
import comfy.model_management as mm
import comfy.utils
# FlashPortrait / Wan modules
from diffusers import FlowMatchEulerDiscreteScheduler
from .wan.models.face_align import FaceAlignment
from .wan.models.face_model import FaceModel
from .wan.models.pdf import FanEncoder, det_landmarks, get_drive_expression_pd_fgc
from .wan.models.portrait_encoder import PortraitEncoder
from .wan.pipeline.pipeline_wan_long import WanI2VLongPipeline
from .wan.models import AutoencoderKLWan, CLIPModel, WanT5EncoderModel, WanTransformer3DModel
from .wan.utils.utils import filter_kwargs
from .wan.utils.fm_solvers import FlowDPMSolverMultistepScheduler
from .wan.utils.fm_solvers_unipc import FlowUniPCMultistepScheduler
from .wan.utils.fp8_optimization import (
replace_parameters_by_name,
convert_model_weight_to_float8,
convert_weight_dtype_wrapper
)
from comfy.utils import ProgressBar
# Utils
def find_replacement(a):
while a > 0:
if (a - 1) % 4 == 0: return a
a -= 1
return 0
class FlashPortraitLoader:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"precision": (["bf16", "fp16", "fp32"], {"default": "bf16"}),
"GPU_memory_mode": (["default", "sequential_cpu_offload", "model_cpu_offload_and_qfloat8"], {"default": "default"}),
"download_missing": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("FLASH_PORTRAIT_PIPE", "FACE_ALIGN_MODELS")
RETURN_NAMES = ("pipe", "face_align_models")
FUNCTION = "load_models"
CATEGORY = "FlashPortrait"
def load_models(self, precision, GPU_memory_mode, download_missing):
device = mm.get_torch_device()
dtype = {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": torch.float32}[precision]
# Define base path in ComfyUI/models/flash_portrait
base_path = os.path.join(folder_paths.models_dir, "flash_portrait")
# Define sub-paths
wan_path = os.path.join(base_path, "Wan2.1-I2V-14B-720P")
fp_path = os.path.join(base_path, "FlashPortrait")
# Download if requested and missing
if download_missing:
if not os.path.exists(wan_path) or not os.listdir(wan_path):
print(f"Downloading Wan2.1-I2V-14B-720P to {wan_path}...")
snapshot_download(repo_id="Wan-AI/Wan2.1-I2V-14B-720P", local_dir=wan_path)
if not os.path.exists(fp_path) or not os.listdir(fp_path):
print(f"Downloading FlashPortrait to {fp_path}...")
snapshot_download(repo_id="FrancisRing/FlashPortrait", local_dir=fp_path)
# Config paths
config_path = os.path.join(os.path.dirname(__file__), "config/wan2.1/wan_civitai.yaml")
config = OmegaConf.load(config_path)
# Load Heavy Models (Transformer, VAE, Encoders)
print(">>> Loading Transformer...")
transformer = WanTransformer3DModel.from_pretrained(
os.path.join(wan_path, config['transformer_additional_kwargs'].get('transformer_subpath', 'transformer')),
transformer_additional_kwargs=OmegaConf.to_container(config['transformer_additional_kwargs']),
low_cpu_mem_usage=True, torch_dtype=dtype
)
# Load FlashPortrait transformer weights
transformer_fp_path = os.path.join(fp_path, "transformer.pt")
st = load_file(transformer_fp_path) if transformer_fp_path.endswith("safetensors") else torch.load(transformer_fp_path, map_location="cpu", weights_only=True)
transformer.load_state_dict(st.get("state_dict", st), strict=False)
del st
gc.collect()
transformer.to(device) # Keep on device or let Comfy manage? For now, manual management as per infer.py structure.
print(">>> Loading VAE...")
vae = AutoencoderKLWan.from_pretrained(
os.path.join(wan_path, config['vae_kwargs'].get('vae_subpath', 'vae')),
additional_kwargs=OmegaConf.to_container(config['vae_kwargs'])
).to(device=device, dtype=dtype)
print(">>> Loading Text Encoder & Tokenizer...")
tokenizer = AutoTokenizer.from_pretrained(
os.path.join(wan_path, config['text_encoder_kwargs'].get('tokenizer_subpath', 'tokenizer'))
)
text_encoder = WanT5EncoderModel.from_pretrained(
os.path.join(wan_path, config['text_encoder_kwargs'].get('text_encoder_subpath', 'text_encoder')),
additional_kwargs=OmegaConf.to_container(config['text_encoder_kwargs']),
low_cpu_mem_usage=True, torch_dtype=dtype
).to(device).eval()
print(">>> Loading CLIP Image Encoder...")
clip_image_encoder = CLIPModel.from_pretrained(
os.path.join(wan_path, config['image_encoder_kwargs'].get('image_encoder_subpath', 'image_encoder'))
).to(device=device, dtype=dtype).eval()
print(">>> Loading Portrait Encoder...")
pe_path = os.path.join(fp_path, "portrait_encoder.pt")
pe_st = torch.load(pe_path, map_location="cpu", weights_only=True)
portrait_encoder = PortraitEncoder(adapter_in_dim=768, adapter_proj_dim=2048)
for p, sub_m in [("proj_model.", portrait_encoder.proj_model),
("mouth_proj_model.", portrait_encoder.mouth_proj_model),
("emo_proj_model.", portrait_encoder.emo_proj_model)]:
sub_m.load_state_dict({k[len(p):]: v for k, v in pe_st.items() if k.startswith(p)})
del pe_st
gc.collect()
portrait_encoder.to(device).eval()
# Pack pipeline components
# Scheduler setup (default to Flow)
sc_cls = FlowMatchEulerDiscreteScheduler
scheduler = sc_cls(**filter_kwargs(sc_cls, OmegaConf.to_container(config['scheduler_kwargs'])))
pipeline = WanI2VLongPipeline(
transformer=transformer, vae=vae, tokenizer=tokenizer,
text_encoder=text_encoder, scheduler=scheduler,
clip_image_encoder=clip_image_encoder, portrait_encoder=portrait_encoder
)
if GPU_memory_mode == "sequential_cpu_offload":
replace_parameters_by_name(transformer, ["modulation",], device=device)
transformer.freqs = transformer.freqs.to(device=device)
pipeline.enable_sequential_cpu_offload(device=device)
elif GPU_memory_mode == "model_cpu_offload_and_qfloat8":
convert_model_weight_to_float8(transformer, exclude_module_name=["modulation",], device=device)
convert_weight_dtype_wrapper(transformer, dtype)
pipe = {
"pipeline": pipeline,
"transformer": transformer,
"vae": vae,
"tokenizer": tokenizer,
"text_encoder": text_encoder,
"clip_image_encoder": clip_image_encoder,
"portrait_encoder": portrait_encoder,
"config": config,
"device": device,
"dtype": dtype
}
# Load Face Alignment Models (CPU friendly until used)
print(">>> Loading Face Alignment Models...")
det_path = os.path.join(fp_path, "face_det.onnx")
align_path = os.path.join(fp_path, "face_landmark.onnx")
pd_fpg_path = os.path.join(fp_path, "pd_fpg.pth")
# Initialize these later in extractor to save memory?
# But we need paths or loaded models. infer.py loads them in Phase 1 and deletes.
# We'll pass the paths or re-load them in extractor.
# Let's verify paths exist.
if not all(os.path.exists(p) for p in [det_path, align_path, pd_fpg_path]):
raise FileNotFoundError("Some face alignment models are missing.")
face_align_models = {
"det_path": det_path,
"align_path": align_path,
"pd_fpg_path": pd_fpg_path
}
return (pipe, face_align_models)
class FlashPortraitFeatureExtractor:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"face_align_models": ("FACE_ALIGN_MODELS",),
"images": ("IMAGE",), # [B, H, W, 3]
"source_fps": ("FLOAT", {"default": 25.0}),
"context_size": ("INT", {"default": 51}),
"context_overlap": ("INT", {"default": 30}),
}
}
RETURN_TYPES = ("HEAD_EMO_FEAT", "FLOAT", "INT")
RETURN_NAMES = ("head_emo_features", "fps", "total_frames")
FUNCTION = "extract_features"
CATEGORY = "FlashPortrait"
def extract_features(self, face_align_models, images, source_fps, context_size, context_overlap):
device = mm.get_torch_device()
# Prepare Feature Extractor Models
print(">>> Initializing Face Models for extraction...")
fa = FaceModel(FaceAlignment(None, face_align_models["align_path"], face_align_models["det_path"]), reset=False)
fm = FanEncoder().to(device).eval()
fm.load_state_dict(torch.load(face_align_models["pd_fpg_path"], map_location=device), strict=False)
# Convert IMAGE tensor (B,H,W,C) 0-1 to List of numpy (H,W,C) 0-255 BGR (opencv format)
# Comfy IMAGE is RGB. cv2 prefers BGR.
# batch_images = (images.cpu().numpy() * 255).astype(np.uint8)
# frame_list = [cv2.cvtColor(img, cv2.COLOR_RGB2BGR) for img in batch_images]
# Actually infer.py uses: frame_list.append(frame.copy()) where frame is from cap.read() (BGR)
# But let's check det_landmarks implementation. It usually expects BGR if using cv2.
# If I look at infer.py -> det_landmarks, it takes frame_list.
# Let's convert to BGR just to be safe if underlying code uses cv2.
batch = (images.cpu().numpy() * 255).astype(np.uint8)
frame_list = []
for i in range(batch.shape[0]):
# RGB to BGR
frame = batch[i][..., ::-1].copy()
frame_list.append(frame)
num_frames = find_replacement(len(frame_list))
frame_list = frame_list[:num_frames]
print(f">>> Extracting features from {len(frame_list)} frames...")
# Landmark & Emotion Extraction
with torch.no_grad():
landmark_list = det_landmarks(fa, frame_list)[1]
emo_list = get_drive_expression_pd_fgc(fm, frame_list, landmark_list, device)
ef_list, hef_list = [], []
for emo in emo_list:
ef = torch.cat([emo["eye_embed"], emo["emo_embed"], emo["mouth_feat"]], dim=1)
hef = torch.cat([emo["headpose_emb"], ef], dim=1)
ef_list.append(ef)
hef_list.append(hef)
head_emo = torch.cat(hef_list, dim=0) # [NumFrames, FeatureDim]
# Stride compensation from infer.py
raw_num = num_frames
stride = context_size - context_overlap
if raw_num > context_size:
final_num_frames = context_size + ((raw_num - context_size) // stride) * stride
else:
final_num_frames = context_size
# Pad or slice? infer.py does: head_emo = head_emo[:num_frames] (where num_frames matches calculation)
# But wait, raw_num is find_replacement(len).
# infer.py lines 182-184:
# stride = cfg.context_size - cfg.context_overlap
# num_frames = cfg.context_size + ((raw_num - cfg.context_size) // stride) * stride if raw_num > cfg.context_size else cfg.context_size
# head_emo = head_emo[:num_frames].unsqueeze(0).to(device)
# If actual frames < required frames, we might need to be careful.
# But usually raw_num >= num_frames (calculated) unless raw_num < context_size?
# If raw_num < context_size, num_frames=context_size. head_emo length is raw_num.
# So we might need to repeat?
# infer.py doesn't seem to repeat explicitly there, but head_emo[:num_frames] implies it has enough?
# No, if raw_num < calculated, slicing will fail or return shorter.
# Let's implement safe logic.
if head_emo.shape[0] < final_num_frames:
# Repeat last frame to fill
diff = final_num_frames - head_emo.shape[0]
last = head_emo[-1:]
head_emo = torch.cat([head_emo, last.repeat(diff, 1)], dim=0)
head_emo = head_emo[:final_num_frames].unsqueeze(0) # [1, T, D]
# Cleanup
del fa, fm, frame_list, landmark_list, emo_list
torch.cuda.empty_cache()
gc.collect()
return (head_emo, source_fps, final_num_frames)
class FlashPortraitSampler:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"pipe": ("FLASH_PORTRAIT_PIPE",),
"head_emo_features": ("HEAD_EMO_FEAT",),
"image": ("IMAGE",), # Reference image
"prompt": ("STRING", {"multiline": True, "default": "The man is singing"}),
"negative_prompt": ("STRING", {"multiline": True, "default": "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走"}),
"seed": ("INT", {"default": 42, "min": 0, "max": 0xffffffffffffffff}),
"steps": ("INT", {"default": 30, "min": 1}),
"cfg_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 20.0}), # Text CFG ?? infer.py says text_cfg_scale default 1.0, guidance 4.0?
# infer.py: guidance_scale=4.0 (for uncoditional?), text_cfg_scale=1.0, emo_cfg_scale=4.0
"guidance_scale": ("FLOAT", {"default": 4.0}),
"text_cfg_scale": ("FLOAT", {"default": 1.0}),
"emo_cfg_scale": ("FLOAT", {"default": 4.0}),
"max_size": ("INT", {"default": 720}),
"shift": ("FLOAT", {"default": 5.0}),
"context_size": ("INT", {"default": 51}),
"context_overlap": ("INT", {"default": 30}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "sample"
CATEGORY = "FlashPortrait"
def sample(self, pipe, head_emo_features, image, prompt, negative_prompt, seed, steps,
cfg_scale, guidance_scale, text_cfg_scale, emo_cfg_scale, max_size, shift,
context_size, context_overlap):
transformer = pipe["transformer"]
vae = pipe["vae"]
tokenizer = pipe["tokenizer"]
text_encoder = pipe["text_encoder"]
clip_image_encoder = pipe["clip_image_encoder"]
portrait_encoder = pipe["portrait_encoder"]
config = pipe["config"]
device = pipe["device"]
dtype = pipe["dtype"]
if "pipeline" in pipe:
pipeline = pipe["pipeline"]
else:
# Fallback for older workflows or if pipeline wasn't created in loader
# Scheduler setup (default to Flow)
sc_cls = FlowMatchEulerDiscreteScheduler
scheduler = sc_cls(**filter_kwargs(sc_cls, OmegaConf.to_container(config['scheduler_kwargs'])))
pipeline = WanI2VLongPipeline(
transformer=transformer, vae=vae, tokenizer=tokenizer,
text_encoder=text_encoder, scheduler=scheduler,
clip_image_encoder=clip_image_encoder, portrait_encoder=portrait_encoder
)
# Prepare Input Image
# Comfy Image is [B,H,W,C] RGB Tensor 0-1
# Pipeline expects PIL Image for resize logic, then Tensor.
# Let's assume batch size 1 for reference image for now.
ref_img_tensor = image[0]
ref_img_np = (ref_img_tensor.cpu().numpy() * 255).astype(np.uint8)
img = Image.fromarray(ref_img_np).convert("RGB")
# Resize logic from infer.py
num_frames = head_emo_features.shape[1]
# Adjust num_frames to satisfy (N-1)%4 == 0 constraint
if (num_frames - 1) % 4 != 0:
num_frames = (num_frames - 1) // 4 * 4 + 1
# Prepare head_emo
head_emo = head_emo_features[:, :num_frames].to(device)
sub_num_frames = num_frames
# Calculate latent frames similar to pipeline
vae_temporal_compression = 4 # Hardcoded or get from config if possible (pipe['config']['vae_kwargs']['temporal_compression_ratio'])
latents_num_frames = (num_frames - 1) // vae_temporal_compression + 1
infer_length = latents_num_frames
# Resize logic from infer.py
scale = max_size / max(img.size)
w, h = (int(img.size[0] * scale) // 16 * 16, int(img.size[1] * scale) // 16 * 16)
img = img.resize((w, h), Image.LANCZOS)
# Create input_video with correct length
input_video = torch.tile(torch.from_numpy(np.array(img)).permute(2,0,1).unsqueeze(1).unsqueeze(0), [1, 1, sub_num_frames, 1, 1]).to(dtype) / 255.0
input_video_mask = torch.zeros_like(input_video[:, :1])
input_video_mask[:, :, 1:] = 255
# If video is shorter than context window, reduce context size to match
# Ensure effective_context_size is positive and safe
effective_context_size = max(1, min(context_size, infer_length))
# ComfyUI Progress Bar
pbar = ProgressBar(steps)
def callback(pipe, step, timestep, callback_kwargs):
pbar.update(1)
with torch.no_grad():
sample = pipeline(
prompt=prompt, negative_prompt=negative_prompt, num_frames=num_frames,
height=h, width=w, generator=torch.Generator(device=device).manual_seed(seed),
video=input_video, mask_video=input_video_mask, clip_image=img, shift=shift,
context_size=effective_context_size, context_overlap=context_overlap,
latents_num_frames=latents_num_frames, sub_num_frames=sub_num_frames,
head_emo_feat_all=head_emo, guidance_scale=guidance_scale, num_inference_steps=steps,
text_cfg_scale=text_cfg_scale, emo_cfg_scale=emo_cfg_scale,
callback_on_step_end=callback
).videos
# sample result is [B, C, F, H, W]??
# Pipeline output .videos usually is tensor.
# infer.py: sample = sample[:, :, 1:] (removing first frame?)
sample = sample[:, :, 1:]
# Convert to Comfy output [F, H, W, C]
# sample shape: [B, C, F, H, W] -> [B, F, H, W, C]
output = sample.permute(0, 2, 3, 4, 1)
output = output[0] # Take first batch
output = output.cpu()
return (output,)
NODE_CLASS_MAPPINGS = {
"FlashPortraitLoader": FlashPortraitLoader,
"FlashPortraitFeatureExtractor": FlashPortraitFeatureExtractor,
"FlashPortraitSampler": FlashPortraitSampler
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FlashPortraitLoader": "FlashProtrait Model Loader",
"FlashPortraitFeatureExtractor": "FlashPortrait Feature Extractor",
"FlashPortraitSampler": "FlashPortrait Sampler"
}