Files

374 lines
12 KiB
Python

"""
Camera Angle Selector Node for ComfyUI
A custom node that provides a 3D visual interface for selecting camera angles.
The 3D visualization renders directly inside the node using Three.js.
"""
import json
import os
import shutil
import urllib.error
import urllib.request
import comfy.sd
import comfy.utils
import folder_paths
# View directions (8 angles)
VIEW_DIRECTIONS = [
"front view",
"front-right quarter view",
"right side view",
"back-right quarter view",
"back view",
"back-left quarter view",
"left side view",
"front-left quarter view",
]
# Height angles (4 types)
HEIGHT_ANGLES = [
"low-angle shot",
"eye-level shot",
"elevated shot",
"high-angle shot",
]
# Shot sizes (3 types)
SHOT_SIZES = [
"close-up",
"medium shot",
"wide shot",
]
AZIMUTH_MAP = {
0: "front view",
45: "front-right quarter view",
90: "right side view",
135: "back-right quarter view",
180: "back view",
225: "back-left quarter view",
270: "left side view",
315: "front-left quarter view",
}
ELEVATION_MAP = {
-30: "low-angle shot",
0: "eye-level shot",
30: "elevated shot",
60: "high-angle shot",
}
DISTANCE_MAP = {
0.6: "close-up",
1.0: "medium shot",
1.8: "wide shot",
}
ANGLE_LORA_NAME = "qwen-image-edit-2511-multiple-angles-lora.safetensors"
ANGLE_LORA_REPO = "fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA"
ANGLE_LORA_URLS = [
f"https://huggingface.co/{ANGLE_LORA_REPO}/resolve/main/{ANGLE_LORA_NAME}?download=true",
]
ANGLE_LORA_MODEL_PAGE = f"https://huggingface.co/{ANGLE_LORA_REPO}"
# Generate all 96 combinations
CAMERA_ANGLES = []
for direction in VIEW_DIRECTIONS:
for height in HEIGHT_ANGLES:
for size in SHOT_SIZES:
CAMERA_ANGLES.append({
"direction": direction,
"height": height,
"size": size,
"prompt": f"<sks> {direction} {height} {size}"
})
class CameraAngleSelector:
"""
ComfyUI custom node for selecting camera angles using a 3D visual interface.
The 3D visualization is rendered by the companion JavaScript extension.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"selected_indices": ("STRING", {
"default": "[]",
"multiline": False,
}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("selected_angles",)
OUTPUT_IS_LIST = (True,)
FUNCTION = "execute"
CATEGORY = "camera"
DESCRIPTION = "Select camera angles using a 3D visual interface rendered directly in the node"
def execute(self, selected_indices="[]"):
"""
Execute the node and return the list of selected prompts.
Args:
selected_indices: JSON string containing list of selected indices
Returns:
Tuple containing list of selected angle prompts
"""
# Parse selected indices
try:
indices = json.loads(selected_indices)
except (json.JSONDecodeError, TypeError):
indices = []
if not isinstance(indices, list):
indices = []
# Clamp indices to valid range
clamped_indices = []
for idx in indices:
if isinstance(idx, int):
clamped_indices.append(max(0, min(idx, len(CAMERA_ANGLES) - 1)))
# Build <sks> ... prompts from clamped indices
selected_prompts = []
for idx in clamped_indices:
selected_prompts.append(CAMERA_ANGLES[idx]["prompt"])
return (selected_prompts,)
class CameraAnglePromptCombine:
"""
Combine a camera-angle LoRA trigger prompt with the normal edit instruction.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"camera_prompt": ("STRING", {
"default": "",
"multiline": False,
}),
"edit_prompt": ("STRING", {
"default": "Edit the image while preserving identity, pose, and composition.",
"multiline": True,
}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("prompt",)
FUNCTION = "execute"
CATEGORY = "camera"
DESCRIPTION = "Prepends a Qwen multi-angle LoRA camera prompt to an edit prompt"
def execute(self, camera_prompt, edit_prompt):
camera_prompt = (camera_prompt or "").strip()
edit_prompt = (edit_prompt or "").strip()
if camera_prompt and edit_prompt:
prompt = f"{camera_prompt}\n{edit_prompt}"
else:
prompt = camera_prompt or edit_prompt
return (prompt,)
def _lora_missing_message():
return (
f"Missing required LoRA: {ANGLE_LORA_NAME}\n"
f"Expected location: ComfyUI/models/loras/{ANGLE_LORA_NAME}\n"
f"Download page: {ANGLE_LORA_MODEL_PAGE}"
)
def _configured_lora_paths():
if hasattr(folder_paths, "get_folder_paths"):
try:
paths = folder_paths.get_folder_paths("loras")
if paths:
return paths
except Exception:
pass
folder_config = getattr(folder_paths, "folder_names_and_paths", {}).get("loras")
if folder_config:
paths = folder_config[0]
if paths:
return paths
models_dir = getattr(folder_paths, "models_dir", None)
if models_dir:
return [os.path.join(models_dir, "loras")]
return []
def _find_angle_lora():
if hasattr(folder_paths, "get_full_path"):
lora_path = folder_paths.get_full_path("loras", ANGLE_LORA_NAME)
if lora_path:
return lora_path
try:
return folder_paths.get_full_path_or_raise("loras", ANGLE_LORA_NAME)
except Exception:
return None
def _download_angle_lora():
lora_paths = _configured_lora_paths()
if not lora_paths:
raise RuntimeError(f"{_lora_missing_message()}\nCould not find a configured ComfyUI loras folder.")
destination = os.path.join(lora_paths[0], ANGLE_LORA_NAME)
if os.path.exists(destination):
return destination
os.makedirs(os.path.dirname(destination), exist_ok=True)
temporary_path = destination + ".download"
if os.path.exists(temporary_path):
os.remove(temporary_path)
last_error = None
for url in ANGLE_LORA_URLS:
try:
print(f"FrameKit: downloading {ANGLE_LORA_NAME} from {ANGLE_LORA_MODEL_PAGE}")
request = urllib.request.Request(url, headers={"User-Agent": "ComfyUI-Qwen-FrameKit"})
with urllib.request.urlopen(request) as response, open(temporary_path, "wb") as output:
shutil.copyfileobj(response, output)
os.replace(temporary_path, destination)
print(f"FrameKit: downloaded {ANGLE_LORA_NAME} to {destination}")
return destination
except (OSError, urllib.error.URLError, urllib.error.HTTPError) as error:
last_error = error
if os.path.exists(temporary_path):
os.remove(temporary_path)
raise RuntimeError(f"{_lora_missing_message()}\nAutomatic download failed: {last_error}")
def _get_or_download_angle_lora(auto_download_lora):
lora_path = _find_angle_lora()
if lora_path:
return lora_path
if auto_download_lora:
return _download_angle_lora()
raise FileNotFoundError(_lora_missing_message())
class QwenImageEdit2511AngleCamera:
"""
Single-camera control for Qwen-Image-Edit-2511 Multiple Angles LoRA.
Applies the fixed LoRA and outputs only the formatted <sks> camera prompt.
"""
def __init__(self):
self.loaded_lora = None
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"image": ("IMAGE",),
"azimuth": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 315.0, "step": 45.0}),
"elevation": ("FLOAT", {"default": 0.0, "min": -30.0, "max": 60.0, "step": 30.0}),
"distance": ("FLOAT", {"default": 1.0, "min": 0.6, "max": 1.8, "step": 0.4}),
"strength_model": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05}),
"strength_clip": ("FLOAT", {"default": 1.0, "min": -5.0, "max": 5.0, "step": 0.05}),
"auto_download_lora": ("BOOLEAN", {"default": True}),
},
}
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
RETURN_NAMES = ("model", "clip", "prompt")
FUNCTION = "execute"
CATEGORY = "camera"
DESCRIPTION = "Single-camera control that applies the fixed Qwen-Image-Edit-2511 Multiple Angles LoRA and emits its <sks> prompt"
@staticmethod
def snap_to_nearest(value, options):
best = options[0]
best_distance = abs(best - value)
for option in options[1:]:
distance = abs(option - value)
if distance < best_distance or (abs(distance - best_distance) <= 1e-9 and option > best):
best = option
best_distance = distance
return best
@classmethod
def build_camera_prompt(cls, azimuth, elevation, distance):
azimuth = cls.snap_to_nearest(float(azimuth) % 360, list(AZIMUTH_MAP.keys()))
elevation = cls.snap_to_nearest(float(elevation), list(ELEVATION_MAP.keys()))
distance = cls.snap_to_nearest(float(distance), list(DISTANCE_MAP.keys()))
return f"<sks> {AZIMUTH_MAP[azimuth]} {ELEVATION_MAP[elevation]} {DISTANCE_MAP[distance]}"
def load_fixed_lora(self, model, clip, strength_model, strength_clip, auto_download_lora):
if strength_model == 0 and strength_clip == 0:
return model, clip
lora_path = _get_or_download_angle_lora(auto_download_lora)
lora = None
lora_metadata = None
if self.loaded_lora is not None:
if self.loaded_lora[0] == lora_path:
lora = self.loaded_lora[1]
lora_metadata = self.loaded_lora[2] if len(self.loaded_lora) > 2 else None
else:
self.loaded_lora = None
if lora is None:
lora, lora_metadata = comfy.utils.load_torch_file(lora_path, safe_load=True, return_metadata=True)
self.loaded_lora = (lora_path, lora, lora_metadata)
return comfy.sd.load_lora_for_models(
model,
clip,
lora,
strength_model,
strength_clip,
lora_metadata=lora_metadata,
)
def execute(
self,
model,
clip,
image,
azimuth,
elevation,
distance,
strength_model,
strength_clip,
auto_download_lora=True,
):
model_lora, clip_lora = self.load_fixed_lora(model, clip, strength_model, strength_clip, auto_download_lora)
camera_prompt = ""
if strength_model != 0 or strength_clip != 0:
camera_prompt = self.build_camera_prompt(azimuth, elevation, distance)
return model_lora, clip_lora, camera_prompt
NODE_CLASS_MAPPINGS = {
"CameraAngleSelector": CameraAngleSelector,
"CameraAnglePromptCombine": CameraAnglePromptCombine,
"QwenImageEdit2511AngleCamera": QwenImageEdit2511AngleCamera,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"CameraAngleSelector": "Camera Angle Selector",
"CameraAnglePromptCombine": "Camera Angle Prompt Combine",
"QwenImageEdit2511AngleCamera": "Qwen Image Edit 2511 Angle Camera",
}