- Updated .env.template to include separate API keys for international and mainland China endpoints. - Modified base.py to load environment variables from multiple .env file locations and support region-specific API endpoints. - Refactored API endpoint definitions to use a centralized dictionary for international and mainland China endpoints. - Updated generator classes (i2v, ii2v, t2i, t2v, VACE) to accept region as a parameter and utilize the appropriate API endpoints and keys. - Implemented region selection in the video processing classes, ensuring correct API requests based on the selected region.
378 lines
16 KiB
Python
378 lines
16 KiB
Python
"""
|
|
Wan VACE Local Video Editing Node for ComfyUI
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import requests
|
|
from PIL import Image
|
|
import numpy as np
|
|
import torch
|
|
import io
|
|
import base64
|
|
from dotenv import load_dotenv
|
|
import sys
|
|
import pathlib
|
|
from datetime import datetime
|
|
|
|
# Import the base class and COMFYUI_AVAILABLE flag
|
|
from ..core.base import WanAPIBase, COMFYUI_AVAILABLE
|
|
|
|
# Try to import folder_paths if available
|
|
try:
|
|
import folder_paths
|
|
except ImportError:
|
|
pass
|
|
|
|
class WanVACEVideoEdit(WanAPIBase):
|
|
"""Node for local video editing using Wan VACE model"""
|
|
|
|
# Define available Wan VACE models
|
|
MODEL_OPTIONS = [
|
|
"wan2.1-vace-plus" # Professional Edition
|
|
]
|
|
|
|
# Define control conditions for local editing
|
|
CONTROL_CONDITION_OPTIONS = [
|
|
"", # No control condition
|
|
"posebodyface", # Extract facial expressions and body movements
|
|
"posebody", # Extract body movements only
|
|
"depth", # Extract composition and motion contours
|
|
"scribble" # Extract line art structure
|
|
]
|
|
|
|
# Define mask types for local editing
|
|
MASK_TYPE_OPTIONS = [
|
|
"tracking", # Dynamic tracking of the target object
|
|
"fixed" # Fixed mask area
|
|
]
|
|
|
|
# Define expand modes for local editing
|
|
EXPAND_MODE_OPTIONS = [
|
|
"hull", # Polygon mode
|
|
"bbox", # Bounding box mode
|
|
"original" # Raw mode
|
|
]
|
|
|
|
# Define video resolutions
|
|
RESOLUTION_OPTIONS = [
|
|
"1280*720", # 16:9 aspect ratio (default)
|
|
"720*1280", # 9:16 aspect ratio
|
|
"960*960", # 1:1 aspect ratio
|
|
"832*1088", # 3:4 aspect ratio
|
|
"1088*832" # 4:3 aspect ratio
|
|
]
|
|
|
|
# Define region options
|
|
REGION_OPTIONS = [
|
|
"international",
|
|
"mainland_china"
|
|
]
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
# Define output directory options
|
|
if COMFYUI_AVAILABLE:
|
|
# Use ComfyUI's output directory with browseable option
|
|
output_dir_options = {
|
|
"default": "./videos",
|
|
"tooltip": "Directory where the generated video will be saved. Browse to select a custom directory."
|
|
}
|
|
else:
|
|
# Fallback to string input
|
|
output_dir_options = {
|
|
"default": "./videos",
|
|
"multiline": False
|
|
}
|
|
|
|
return {
|
|
"required": {
|
|
"model": (cls.MODEL_OPTIONS, {
|
|
"default": "wan2.1-vace-plus"
|
|
}),
|
|
"prompt": ("STRING", {
|
|
"multiline": True,
|
|
"default": "Edit the video with the following description"
|
|
}),
|
|
"video_url": ("STRING", {
|
|
"default": "",
|
|
"tooltip": "URL of the input video"
|
|
}),
|
|
"region": (cls.REGION_OPTIONS, {
|
|
"default": "international"
|
|
})
|
|
},
|
|
"optional": {
|
|
"ref_images_url": ("STRING", {
|
|
"multiline": True,
|
|
"default": "",
|
|
"tooltip": "Newline-separated URLs for reference images (only 1 image supported)"
|
|
}),
|
|
"mask_image_url": ("STRING", {
|
|
"default": "",
|
|
"tooltip": "URL of the mask image"
|
|
}),
|
|
"mask_frame_id": ("INT", {
|
|
"default": 1,
|
|
"min": 1,
|
|
"max": 1000
|
|
}),
|
|
"mask_video_url": ("STRING", {
|
|
"default": "",
|
|
"tooltip": "URL of the mask video"
|
|
}),
|
|
"control_condition": (cls.CONTROL_CONDITION_OPTIONS, {
|
|
"default": ""
|
|
}),
|
|
"mask_type": (cls.MASK_TYPE_OPTIONS, {
|
|
"default": "tracking"
|
|
}),
|
|
"expand_ratio": ("FLOAT", {
|
|
"default": 0.05,
|
|
"min": 0.0,
|
|
"max": 1.0,
|
|
"step": 0.01
|
|
}),
|
|
"expand_mode": (cls.EXPAND_MODE_OPTIONS, {
|
|
"default": "hull"
|
|
}),
|
|
"size": (cls.RESOLUTION_OPTIONS, {
|
|
"default": "1280*720"
|
|
}),
|
|
"seed": ("INT", {
|
|
"default": 0,
|
|
"min": 0,
|
|
"max": 2147483647
|
|
}),
|
|
"prompt_extend": ("BOOLEAN", {
|
|
"default": False
|
|
}),
|
|
"watermark": ("BOOLEAN", {
|
|
"default": False
|
|
}),
|
|
"output_dir": ("STRING", output_dir_options)
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING") # Returns path to downloaded video file and video URL
|
|
RETURN_NAMES = ("video_file_path", "video_url")
|
|
FUNCTION = "generate"
|
|
CATEGORY = "Ru4ls/Wan/VACE"
|
|
|
|
def generate(self, model, prompt, video_url, region, ref_images_url="", mask_image_url="",
|
|
mask_frame_id=1, mask_video_url="", control_condition="", mask_type="tracking",
|
|
expand_ratio=0.05, expand_mode="hull", size="1280*720", seed=0,
|
|
prompt_extend=False, watermark=False, output_dir="./videos"):
|
|
|
|
# Check API key based on region
|
|
api_key = self.check_api_key(region)
|
|
|
|
# Get the appropriate API endpoints based on region
|
|
endpoints = self.get_api_endpoints(region)
|
|
api_url = endpoints["video_post"]
|
|
|
|
# Prepare API payload
|
|
payload = {
|
|
"model": model,
|
|
"input": {
|
|
"function": "video_edit",
|
|
"prompt": prompt,
|
|
"video_url": video_url
|
|
},
|
|
"parameters": {
|
|
"mask_type": mask_type,
|
|
"expand_mode": expand_mode,
|
|
"size": size,
|
|
"prompt_extend": prompt_extend,
|
|
"watermark": watermark
|
|
}
|
|
}
|
|
|
|
# Add seed if provided
|
|
if seed > 0:
|
|
payload["parameters"]["seed"] = seed
|
|
|
|
# Add control condition if provided
|
|
if control_condition:
|
|
payload["parameters"]["control_condition"] = control_condition
|
|
|
|
# Add expand_ratio if not default
|
|
if expand_ratio != 0.05:
|
|
payload["parameters"]["expand_ratio"] = expand_ratio
|
|
|
|
# Add mask_image_url if provided
|
|
if mask_image_url:
|
|
payload["input"]["mask_image_url"] = mask_image_url
|
|
|
|
# Add mask_frame_id if not default
|
|
if mask_frame_id != 1:
|
|
payload["input"]["mask_frame_id"] = mask_frame_id
|
|
|
|
# Add mask_video_url if provided
|
|
if mask_video_url:
|
|
payload["input"]["mask_video_url"] = mask_video_url
|
|
|
|
# Handle ref_images_url as a list (only 1 image supported)
|
|
if ref_images_url:
|
|
ref_images_list = [url.strip() for url in ref_images_url.split('\n') if url.strip()]
|
|
if ref_images_list:
|
|
payload["input"]["ref_images_url"] = ref_images_list[:1] # Only take the first image
|
|
|
|
# Set headers according to DashScope documentation
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
"X-DashScope-Async": "enable" # Wan requires async processing
|
|
}
|
|
|
|
try:
|
|
# Make API request
|
|
print(f"Making API request to {api_url}")
|
|
print(f"Payload: {json.dumps(payload, indent=2)}")
|
|
response = requests.post(api_url, headers=headers, json=payload)
|
|
print(f"Response status code: {response.status_code}")
|
|
if hasattr(response, 'text'):
|
|
print(f"Response text: {response.text[:500]}...") # Print first 500 chars
|
|
response.raise_for_status()
|
|
|
|
# Parse response to get task_id
|
|
result = response.json()
|
|
print(f"API response received: {json.dumps(result, indent=2)[:200]}...") # Print first 200 chars
|
|
|
|
# Check if this is a task creation response
|
|
if "output" in result and "task_id" in result["output"]:
|
|
task_id = result["output"]["task_id"]
|
|
task_status = result["output"]["task_status"]
|
|
print(f"Task created with ID: {task_id}, status: {task_status}")
|
|
|
|
# Now we need to poll for the result
|
|
task_result = self.poll_task_result(task_id, output_dir, region)
|
|
return task_result # Return both path to downloaded video file and video URL
|
|
else:
|
|
raise ValueError(f"Unexpected API response format: {result}")
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
# More detailed error handling
|
|
if hasattr(e, 'response') and e.response is not None:
|
|
status_code = e.response.status_code
|
|
response_text = e.response.text
|
|
print(f"API request failed with status {status_code}: {response_text}")
|
|
if status_code == 401:
|
|
raise RuntimeError(f"API request failed: 401 Unauthorized. "
|
|
f"This usually means your API key is invalid or not properly configured. "
|
|
f"Error details: {response_text}")
|
|
elif status_code == 403:
|
|
raise RuntimeError(f"API request failed: 403 Forbidden. "
|
|
f"This usually means your API key is valid but you don't have access to this model. "
|
|
f"Error details: {response_text}")
|
|
elif status_code == 400:
|
|
raise RuntimeError(f"API request failed: 400 Bad Request. "
|
|
f"This usually means there's an issue with the request format. "
|
|
f"Error details: {response_text}")
|
|
else:
|
|
raise RuntimeError(f"API request failed: {status_code} {e.response.reason}. Response: {response_text}")
|
|
else:
|
|
raise RuntimeError(f"API request failed: {str(e)}")
|
|
except Exception as e:
|
|
raise RuntimeError(f"Failed to process API response: {str(e)}")
|
|
|
|
def poll_task_result(self, task_id, output_dir="./videos", region="international"):
|
|
"""Poll for task result until completion and download video"""
|
|
import time
|
|
|
|
# Get the appropriate API endpoints based on region
|
|
endpoints = self.get_api_endpoints(region)
|
|
query_url = endpoints["get"].format(task_id=task_id)
|
|
|
|
# Check API key based on region
|
|
api_key = self.check_api_key(region)
|
|
|
|
headers = {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json"
|
|
}
|
|
|
|
max_attempts = 60 # Maximum polling attempts (may take longer for video)
|
|
attempt = 0
|
|
|
|
while attempt < max_attempts:
|
|
try:
|
|
print(f"Polling task {task_id}, attempt {attempt + 1}/{max_attempts}")
|
|
response = requests.get(query_url, headers=headers)
|
|
response.raise_for_status()
|
|
|
|
result = response.json()
|
|
task_status = result["output"]["task_status"]
|
|
print(f"Task status: {task_status}")
|
|
|
|
if task_status == "SUCCEEDED":
|
|
# Task completed successfully
|
|
if "video_url" in result["output"]:
|
|
video_url = result["output"]["video_url"]
|
|
|
|
# Download the video
|
|
video_response = requests.get(video_url)
|
|
video_response.raise_for_status()
|
|
|
|
# Create a unique filename for the video
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
video_filename = f"wan_vace_video_edit_{timestamp}.mp4"
|
|
|
|
# Handle output directory based on ComfyUI availability
|
|
if COMFYUI_AVAILABLE and not output_dir.startswith(("./", "/")):
|
|
# Use ComfyUI's output directory structure
|
|
if output_dir.endswith("/"):
|
|
output_dir = output_dir[:-1]
|
|
full_output_folder = folder_paths.get_output_directory()
|
|
output_path = os.path.join(full_output_folder, output_dir)
|
|
else:
|
|
# Resolve output directory path (existing logic)
|
|
if output_dir.startswith("./"):
|
|
# Relative to the node directory
|
|
output_path = os.path.join(os.path.dirname(__file__), output_dir[2:])
|
|
else:
|
|
output_path = output_dir
|
|
|
|
# Create output directory if it doesn't exist
|
|
os.makedirs(output_path, exist_ok=True)
|
|
|
|
# Save video to file
|
|
video_path = os.path.join(output_path, video_filename)
|
|
with open(video_path, "wb") as f:
|
|
f.write(video_response.content)
|
|
|
|
print(f"Video downloaded and saved to: {video_path}")
|
|
# Return path relative to ComfyUI output directory if using ComfyUI
|
|
if COMFYUI_AVAILABLE and not output_dir.startswith(("./", "/")):
|
|
return_path = os.path.join(output_dir, video_filename) if output_dir != "./videos" else video_filename
|
|
else:
|
|
return_path = video_path # Return full path
|
|
# Return both the file path and the video URL
|
|
return (return_path, video_url)
|
|
else:
|
|
raise ValueError(f"Unexpected API response format: {result}")
|
|
|
|
elif task_status == "FAILED":
|
|
# Task failed
|
|
error_code = result["output"].get("code", "Unknown")
|
|
error_message = result["output"].get("message", "Unknown error")
|
|
raise RuntimeError(f"Task failed with code: {error_code}, message: {error_message}")
|
|
|
|
elif task_status in ["PENDING", "RUNNING"]:
|
|
# Task still in progress, wait and retry
|
|
time.sleep(10) # Wait 10 seconds before retrying (video generation may take longer)
|
|
attempt += 1
|
|
continue
|
|
|
|
else:
|
|
raise ValueError(f"Unexpected task status: {task_status}")
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
raise RuntimeError(f"Failed to query task status: {str(e)}")
|
|
|
|
# If we've reached here, we've exceeded max attempts
|
|
raise RuntimeError(f"Task did not complete within the expected time ({max_attempts} attempts)")
|