Files
jandan520-ComfyUI-AdvancedC…/advanced_camera_control_node.py
T

565 lines
22 KiB
Python

"""
Advanced Camera Control Prompt Generator
Reads camera information from Load 3D nodes and generates precise camera control prompts.
Author: Jianghong Zhu
"""
import math
class AdvancedCameraControlNode:
"""Advanced camera control prompt generator for ComfyUI."""
EYE_LEVEL_HEIGHT = 0.425
DEFAULT_POSITION = {"x": 0.0, "y": EYE_LEVEL_HEIGHT, "z": 0.5}
DEFAULT_TARGET = {"x": 0.0, "y": EYE_LEVEL_HEIGHT, "z": 0.0}
GRID_TO_METERS = 4.0
SENSOR_WIDTH_MM = 36.0
SENSOR_HEIGHT_MM = 24.0
FRAMING_THRESHOLDS = {
"extreme close-up": (90, 1000),
"close-up": (60, 90),
"medium close-up": (45, 60),
"medium shot": (30, 60),
"medium long shot": (20, 30),
"full shot": (15, 30),
"wide shot": (5, 15),
"extreme wide shot": (0, 5),
}
CAMERA_SHOTS = [
{"name": "Extreme Close-Up", "distance_m": [0.3, 0.6], "focal_length_mm": [85, 135], "fov_deg": [10, 20]},
{"name": "Close-Up", "distance_m": [0.6, 1.2], "focal_length_mm": [50, 85], "fov_deg": [20, 30]},
{"name": "Medium Close-Up", "distance_m": [1.0, 1.8], "focal_length_mm": [35, 50], "fov_deg": [30, 40]},
{"name": "Medium Shot", "distance_m": [1.5, 3.0], "focal_length_mm": [28, 50], "fov_deg": [35, 45]},
{"name": "Medium Long Shot", "distance_m": [2.5, 4.0], "focal_length_mm": [24, 35], "fov_deg": [45, 55]},
{"name": "Full Shot", "distance_m": [3.0, 5.0], "focal_length_mm": [24, 35], "fov_deg": [50, 60]},
{"name": "Wide Shot", "distance_m": [5.0, 10.0], "focal_length_mm": [18, 24], "fov_deg": [60, 90]},
{"name": "Extreme Wide Shot", "distance_m": [10, 50], "focal_length_mm": [14, 20], "fov_deg": [90, 120]},
]
CAMERA_ANGLES = [
{"name": "Eye Level", "tilt_deg": [-5, 5]},
{"name": "High Angle", "tilt_deg": [-20, -45]},
{"name": "Slight Low Angle", "tilt_deg": [5, 15]},
{"name": "Standard Low Angle", "tilt_deg": [15, 30]},
{"name": "Deep Low Angle", "tilt_deg": [30, 45]},
{"name": "Extreme Low Angle", "tilt_deg": [45, 90]},
{"name": "Bird's Eye", "tilt_deg": [-80, -90]},
{"name": "Dutch Angle", "roll_deg": [5, 30]},
{"name": "Dutch Low Angle", "roll_deg": [10, 45]},
]
EYE_LEVEL_TOLERANCE = 5.0
DUTCH_ANGLE_ROLL_MIN = 5.0
HIGH_ANGLE_MIN = 15.0
BIRD_EYE_MIN = 75.0
LOW_ANGLE_MAX = -15.0
WORM_EYE_MAX = -75.0
EXTREME_CLOSE_UP_MAX = 0.6
CLOSE_UP_MAX = 1.2
MEDIUM_SHOT_MAX = 3.0
FULL_SHOT_MAX = 5.0
WIDE_SHOT_MAX = 10.0
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"camera_info": ("LOAD3D_CAMERA", {"label": "Camera Info (from Load 3D)"}),
},
"optional": {
"focal_length_mm": ("FLOAT", {
"default": 50.0,
"min": 1.0,
"max": 1000.0,
"step": 1.0,
"label": "Focal Length (mm)"
}),
"object_scale_meters": ("FLOAT", {
"default": 1.0,
"min": 0.01,
"max": 100.0,
"step": 0.01,
"label": "Object Scale (meters)"
}),
"custom_description": ("STRING", {
"default": "",
"multiline": True,
"label": "Custom Description (optional)"
}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("prompt", "camera_json")
FUNCTION = "generate_prompt"
CATEGORY = "prompt/camera"
def generate_prompt(self, camera_info, focal_length_mm=50.0, object_scale_meters=None, custom_description=""):
"""Generate camera control prompt from camera_info"""
if not camera_info:
return ("", "{}")
position = camera_info.get("position", {})
target = camera_info.get("target", {})
zoom = camera_info.get("zoom", 1)
distance = self._calculate_distance(position, target)
distance_meters = distance * self.GRID_TO_METERS
pitch, yaw, roll = self._calculate_camera_angles(position, target)
fov = self._calculate_fov_from_focal_length(focal_length_mm, use_horizontal=True)
angle_type = None
if abs(pitch) > self.EYE_LEVEL_TOLERANCE:
angle_type = self._get_angle_type_name(pitch, roll)
shot_prompt = self._get_shot_type_prompt(distance, zoom, fov, focal_length_mm, object_scale_meters)
position_prompt = self._get_camera_position_prompt(position, target, pitch, yaw)
prompt_parts = []
description_parts = []
camera_position_text = position_prompt if position_prompt else None
vertical_angle_text = None
if abs(pitch) > self.EYE_LEVEL_TOLERANCE and angle_type:
pitch_deg = int(abs(pitch))
vertical_angle_text = f"tilt down at {pitch_deg} degree" if pitch > 0 else f"tilt up at {pitch_deg} degree"
shot_type_name = None
camera_info_text = None
if shot_prompt:
if "(" in shot_prompt:
shot_type_name = shot_prompt.split("(")[0].strip()
camera_info_text = "(" + shot_prompt.split("(")[1].strip()
else:
shot_type_name = shot_prompt.strip()
movement_parts = []
if camera_position_text:
movement_parts.append(camera_position_text)
if vertical_angle_text:
movement_parts.append(vertical_angle_text)
angle_shot_parts = []
if angle_type:
angle_shot_parts.append(angle_type)
if shot_type_name:
angle_shot_parts.append(shot_type_name)
if movement_parts:
movement_str = " and ".join(movement_parts)
if angle_shot_parts:
angle_shot_str = " ".join(angle_shot_parts)
description_parts.append(f"{movement_str}, {angle_shot_str}")
else:
description_parts.append(f"{movement_str}.")
elif angle_shot_parts:
angle_shot_str = " ".join(angle_shot_parts)
description_parts.append(f"{angle_shot_str}.")
if description_parts:
prompt_parts.append(" ".join(description_parts))
if camera_info_text:
prompt_parts.append(camera_info_text)
if custom_description and custom_description.strip():
prompt_parts.append(custom_description.strip())
final_prompt = " ".join(prompt_parts)
camera_json = self._generate_camera_json(
position, target, pitch, yaw, roll, distance_meters,
focal_length_mm, fov, shot_type_name
)
return (final_prompt, camera_json)
def _generate_camera_json(self, position, target, pitch, yaw, roll, distance_meters,
focal_length_mm, fov, shot_type_name):
"""Generate JSON format for camera information"""
import json
shot_type_json = None
if shot_type_name:
shot_type_json = shot_type_name.replace(" ", "_").replace("-", "_").lower()
tilt_deg_value = round(pitch, 1)
if pitch > 0:
tilt_deg_str = f"tilt down {abs(tilt_deg_value)}"
elif pitch < 0:
tilt_deg_str = f"tilt up {abs(tilt_deg_value)}"
else:
tilt_deg_str = f"tilt {tilt_deg_value}"
pan_deg_value = round(yaw, 1)
if yaw > 0:
pan_deg_str = f"pan to right {abs(pan_deg_value)}"
elif yaw < 0:
pan_deg_str = f"pan to left {abs(pan_deg_value)}"
else:
pan_deg_str = f"pan {pan_deg_value}"
camera_data = {
"focal_length_mm": int(focal_length_mm) if focal_length_mm else None,
"sensor_width_mm": int(self.SENSOR_WIDTH_MM),
"sensor_height_mm": int(self.SENSOR_HEIGHT_MM),
"distance_m": round(distance_meters, 2),
"tilt_deg": tilt_deg_str,
"pan_deg": pan_deg_str,
"roll_deg": round(roll, 1),
"shot_type": shot_type_json
}
return json.dumps({"camera": camera_data}, indent=4)
def _calculate_distance(self, position, target):
"""Calculate 3D distance from camera position to target"""
if not position or not target:
return 0
dx = position.get("x", 0) - target.get("x", 0)
dy = position.get("y", 0) - target.get("y", 0)
dz = position.get("z", 0) - target.get("z", 0)
return math.sqrt(dx * dx + dy * dy + dz * dz)
def _calculate_camera_angles(self, position, target):
"""Calculate camera angles: pitch (vertical), yaw (horizontal), roll (rotation)"""
if not position or not target:
return (0, 0, 0)
pos_x = position.get("x", 0)
pos_y = position.get("y", 0)
pos_z = position.get("z", 0)
tgt_x = target.get("x", 0)
tgt_y = target.get("y", 0)
tgt_z = target.get("z", 0)
dx = pos_x - tgt_x
dy = pos_y - tgt_y
dz = pos_z - tgt_z
horizontal_dist = math.sqrt(dx * dx + dz * dz)
if horizontal_dist > 0.001:
pitch_rad = math.atan2(dy, horizontal_dist)
pitch = math.degrees(pitch_rad)
else:
pitch = 90.0 if dy > 0 else -90.0
if abs(dz) > 0.001:
yaw_rad = math.atan2(dx, -dz)
yaw = math.degrees(yaw_rad)
else:
yaw = 0.0
roll = 0.0
return (pitch, yaw, roll)
def _get_angle_type_name(self, pitch, roll):
"""Get angle type name"""
if abs(roll) >= 10 and pitch < 0:
for angle in self.CAMERA_ANGLES:
if angle["name"] == "Dutch Low Angle" and "roll_deg" in angle:
roll_range = angle["roll_deg"]
if roll_range[0] <= abs(roll) <= roll_range[1]:
return "dutch low angle"
if abs(roll) >= self.DUTCH_ANGLE_ROLL_MIN:
return "dutch angle"
for angle in self.CAMERA_ANGLES:
if "tilt_deg" in angle:
tilt_range = angle["tilt_deg"]
tilt_value = -pitch
if tilt_range[0] <= tilt_value <= tilt_range[1]:
angle_name = angle["name"]
if angle_name == "High Angle":
return "high angle"
elif angle_name == "Slight Low Angle":
return "slight low angle"
elif angle_name == "Standard Low Angle":
return "standard low angle"
elif angle_name == "Deep Low Angle":
return "deep low angle"
elif angle_name == "Extreme Low Angle":
return "extreme low angle"
elif angle_name == "Bird's Eye":
return "bird's eye view"
if pitch >= self.BIRD_EYE_MIN:
return "bird's eye view"
elif pitch >= self.HIGH_ANGLE_MIN:
return "high angle"
elif pitch <= self.WORM_EYE_MAX:
return "extreme low angle"
elif pitch <= -45:
return "deep low angle"
elif pitch <= -30:
return "standard low angle"
elif pitch <= -15:
return "slight low angle"
elif pitch > 0:
return "high angle"
else:
return "low angle"
def _estimate_fov_from_distance(self, distance):
"""Estimate FOV based on distance"""
distance_meters = distance * self.GRID_TO_METERS
if distance_meters < self.EXTREME_CLOSE_UP_MAX:
return 15.0
elif distance_meters < self.CLOSE_UP_MAX:
return 25.0
elif distance_meters < self.MEDIUM_SHOT_MAX:
return 37.5
elif distance_meters < self.FULL_SHOT_MAX:
return 52.5
elif distance_meters < self.WIDE_SHOT_MAX:
return 70.0
else:
return 100.0
def _calculate_fov_from_focal_length(self, focal_length_mm, use_horizontal=True):
"""Calculate FOV from focal length"""
if focal_length_mm <= 0:
return None
sensor_dim = self.SENSOR_WIDTH_MM if use_horizontal else self.SENSOR_HEIGHT_MM
fov_rad = 2.0 * math.atan(sensor_dim / (2.0 * focal_length_mm))
return math.degrees(fov_rad)
def _estimate_focal_length(self, fov):
"""Estimate focal length from FOV"""
if fov and fov > 0:
fov_rad = math.radians(fov)
focal_length = 18.0 / math.tan(fov_rad / 2.0)
return max(14.0, min(200.0, focal_length))
return None
def _calculate_framing_shot_type(self, object_scale_meters, distance_meters, focal_length_mm, use_horizontal=False):
"""Calculate shot type based on framing"""
if object_scale_meters is None or object_scale_meters <= 0:
return None
if distance_meters <= 0 or focal_length_mm <= 0:
return None
projected_mm = focal_length_mm * (object_scale_meters / distance_meters)
sensor_dim_mm = self.SENSOR_WIDTH_MM if use_horizontal else self.SENSOR_HEIGHT_MM
percent_of_frame = 100.0 * projected_mm / sensor_dim_mm
for shot_name, (min_pct, max_pct) in self.FRAMING_THRESHOLDS.items():
if min_pct <= percent_of_frame < max_pct or (max_pct >= 1000 and percent_of_frame >= min_pct):
return shot_name
if percent_of_frame >= 100:
return "extreme close-up"
elif percent_of_frame < 0.1:
return "extreme wide shot"
return None
def _get_shot_type_prompt(self, distance, zoom, fov=None, focal_length_mm=None, object_scale_meters=None):
"""Generate shot type prompt"""
distance_meters = distance * self.GRID_TO_METERS
distance_str = f"{distance_meters:.1f} m"
estimated_focal = focal_length_mm
if estimated_focal is None and fov is not None:
estimated_focal = self._estimate_focal_length(fov)
shot_type = None
if object_scale_meters is not None and estimated_focal is not None:
shot_type = self._calculate_framing_shot_type(
object_scale_meters=object_scale_meters,
distance_meters=distance_meters,
focal_length_mm=estimated_focal,
use_horizontal=False
)
if shot_type is None:
for shot in self.CAMERA_SHOTS:
dist_range = shot["distance_m"]
if dist_range[0] <= distance_meters <= dist_range[1]:
shot_name = shot["name"]
if shot_name == "Extreme Close-Up":
shot_type = "extreme close-up"
elif shot_name == "Close-Up":
shot_type = "close-up"
elif shot_name == "Medium Close-Up":
shot_type = "medium close-up"
elif shot_name == "Medium Shot":
shot_type = "medium shot"
elif shot_name == "Medium Long Shot":
shot_type = "medium long shot"
elif shot_name == "Full Shot":
shot_type = "full shot"
elif shot_name == "Wide Shot":
shot_type = "wide shot"
elif shot_name == "Extreme Wide Shot":
shot_type = "extreme wide shot"
break
if shot_type is None and fov is not None:
try:
fov_deg = float(fov)
for shot in self.CAMERA_SHOTS:
fov_range = shot["fov_deg"]
if fov_range[0] <= fov_deg <= fov_range[1]:
shot_name = shot["name"]
if shot_name == "Extreme Close-Up":
shot_type = "extreme close-up"
elif shot_name == "Close-Up":
shot_type = "close-up"
elif shot_name == "Medium Close-Up":
shot_type = "medium close-up"
elif shot_name == "Medium Shot":
shot_type = "medium shot"
elif shot_name == "Medium Long Shot":
shot_type = "medium long shot"
elif shot_name == "Full Shot":
shot_type = "full shot"
elif shot_name == "Wide Shot":
shot_type = "wide shot"
elif shot_name == "Extreme Wide Shot":
shot_type = "extreme wide shot"
break
except (ValueError, TypeError):
pass
if shot_type is None and estimated_focal is not None:
for shot in self.CAMERA_SHOTS:
focal_range = shot["focal_length_mm"]
if focal_range[0] <= estimated_focal <= focal_range[1]:
shot_name = shot["name"]
if shot_name == "Extreme Close-Up":
shot_type = "extreme close-up"
elif shot_name == "Close-Up":
shot_type = "close-up"
elif shot_name == "Medium Close-Up":
shot_type = "medium close-up"
elif shot_name == "Medium Shot":
shot_type = "medium shot"
elif shot_name == "Medium Long Shot":
shot_type = "medium long shot"
elif shot_name == "Full Shot":
shot_type = "full shot"
elif shot_name == "Wide Shot":
shot_type = "wide shot"
elif shot_name == "Extreme Wide Shot":
shot_type = "extreme wide shot"
break
if shot_type is None:
if distance_meters < self.EXTREME_CLOSE_UP_MAX:
shot_type = "extreme close-up"
elif distance_meters < self.CLOSE_UP_MAX:
shot_type = "close-up"
elif distance_meters < self.MEDIUM_SHOT_MAX:
shot_type = "medium shot"
elif distance_meters < self.FULL_SHOT_MAX:
shot_type = "full shot"
elif distance_meters < self.WIDE_SHOT_MAX:
shot_type = "wide shot"
else:
shot_type = "extreme wide shot"
camera_info_parts = [f"camera distance {distance_str}"]
focal_to_display = focal_length_mm if focal_length_mm is not None else estimated_focal
if focal_to_display is not None:
camera_info_parts.append(f"{int(focal_to_display)}mm")
if fov is not None:
fov_deg = int(fov) if isinstance(fov, (int, float)) else None
if fov_deg:
camera_info_parts.append(f"FOV {fov_deg}°")
camera_info = " ".join(camera_info_parts)
return f"{shot_type} ({camera_info})"
def _get_camera_position_prompt(self, position, target, pitch, yaw):
"""Get camera position prompt"""
if not position or not target:
return ""
def safe_float(value, default=0.0):
if isinstance(value, (int, float)):
return float(value)
try:
return float(value)
except (ValueError, TypeError):
return default
pos_x = safe_float(position.get("x", 0)) - safe_float(target.get("x", 0))
pos_y = safe_float(position.get("y", 0)) - safe_float(target.get("y", 0))
pos_z = safe_float(position.get("z", 0)) - safe_float(target.get("z", 0))
horizontal_dist = math.sqrt(pos_x * pos_x + pos_z * pos_z)
if horizontal_dist < 0.001:
return "above object" if pos_y > 0 else "below object"
angle_deg = math.degrees(math.atan2(pos_x, pos_z))
if angle_deg < 0:
angle_deg += 360
parts = []
angle_int = int(angle_deg)
is_directly_front = (abs(angle_int) < 2 or abs(angle_int - 360) < 2) and pos_z > 0.1
if is_directly_front:
return ""
if pos_z < -0.1:
if abs(pos_x) < 0.1:
parts.append("looking from behind")
else:
if 90 <= angle_int <= 180:
parts.append(f"Pan the camera {angle_int} degrees to the right-back side")
elif 180 < angle_int <= 270:
parts.append(f"Pan the camera {angle_int} degrees to the left-back side")
else:
parts.append(f"looking from behind at {angle_int} degree")
else:
if abs(pos_x) < 0.1:
if pos_z > 0.1:
return ""
else:
parts.append("looking from behind")
else:
if pos_x > 0.1:
parts.append(f"Pan the camera {angle_int} degrees to the right")
elif pos_x < -0.1:
if angle_int >= 270:
left_angle = 360 - angle_int
parts.append(f"Pan the camera {left_angle} degrees to the left")
elif angle_int > 180:
left_angle = angle_int
parts.append(f"Pan the camera {left_angle} degrees to the left")
else:
parts.append(f"Pan the camera {angle_int} degrees to the left")
return ", ".join(parts) if parts else ""
NODE_CLASS_MAPPINGS = {
"AdvancedCameraControlNode": AdvancedCameraControlNode
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AdvancedCameraControlNode": "Advanced Camera Control Prompt Generator"
}