235 lines
9.8 KiB
Python
235 lines
9.8 KiB
Python
import math
|
|
|
|
|
|
class VideoResolutionNode:
|
|
"""
|
|
Simple video resolution node that outputs width and height.
|
|
Designed for video generation models like LTX, CogVideo, Hunyuan, etc.
|
|
"""
|
|
|
|
RESOLUTIONS = {
|
|
"480p": 480,
|
|
"720p": 720,
|
|
"1080p": 1080,
|
|
"1440p (2K)": 1440,
|
|
"2160p (4K)": 2160,
|
|
"4320p (8K)": 4320,
|
|
"Custom": 0,
|
|
"540p": 540,
|
|
"768p": 768,
|
|
"H3 Native (768p)": 768,
|
|
"0.3 MP": 0,
|
|
"0.5 MP": 0,
|
|
"0.7 MP": 0,
|
|
"1.0 MP": 0,
|
|
"Custom MP": 0,
|
|
}
|
|
|
|
MEGAPIXELS = {"0.3 MP": 0.3, "0.5 MP": 0.5, "0.7 MP": 0.7, "1.0 MP": 1.0}
|
|
|
|
ASPECT_RATIOS = {
|
|
"1:1 (Square)": (1, 1),
|
|
"16:9 (Widescreen)": (16, 9),
|
|
"19:9 (Cinematic Mobile)": (19, 9),
|
|
"2.39:1 (CinemaScope)": (239, 100),
|
|
"9:16 (Vertical)": (9, 16),
|
|
"4:3 (Classic)": (4, 3),
|
|
"5:4 (Classic Photo)": (5, 4),
|
|
"3:4 (Portrait)": (3, 4),
|
|
"4:5 (Social Portrait)": (4, 5),
|
|
"21:9 (Ultrawide)": (21, 9),
|
|
"9:21 (Tall)": (9, 21),
|
|
"3:2 (Photo)": (3, 2),
|
|
"2:3 (Photo Portrait)": (2, 3),
|
|
}
|
|
|
|
SCALE_FACTORS = {
|
|
"0.25x": 0.25,
|
|
"0.5x": 0.5,
|
|
"0.75x": 0.75,
|
|
"1x": 1.0,
|
|
"1.25x": 1.25,
|
|
"1.5x": 1.5,
|
|
"2x": 2.0,
|
|
}
|
|
|
|
CUSTOM_MODES = [
|
|
"manual",
|
|
"from_width",
|
|
"from_height",
|
|
]
|
|
|
|
ROUNDING_MODES = [
|
|
"floor",
|
|
"nearest",
|
|
"ceil",
|
|
]
|
|
|
|
MODEL_PROFILES = {
|
|
"Custom": {"divisible_by": None, "add_one": None},
|
|
# ComfyUI-LTXVideo workflows in this repo expect exact multiples of 64.
|
|
# Using add_one here creates a mismatch because LTX latent creation uses
|
|
# integer division by 32 and silently drops the extra pixel.
|
|
"LTX Video / LTX2": {"divisible_by": 64, "add_one": False},
|
|
"CogVideoX": {"divisible_by": 16, "add_one": False},
|
|
"Hunyuan Video": {"divisible_by": 16, "add_one": False},
|
|
"Mochi": {"divisible_by": 64, "add_one": False},
|
|
"Wan 2.x": {"divisible_by": 16, "add_one": False},
|
|
"MiniMax H3": {"divisible_by": 32, "add_one": False},
|
|
"Wan 2.2 TI2V 5B": {"divisible_by": 32, "add_one": False},
|
|
"Hunyuan Video 1.5": {"divisible_by": 16, "add_one": False},
|
|
"LTX 2.3": {"divisible_by": 64, "add_one": False},
|
|
}
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"resolution": (list(cls.RESOLUTIONS.keys()), {"default": "720p", "tooltip": "p presets use the short edge. MP presets use 1 MP = 1,000,000 pixels. H3 Native uses a 768 short edge and 1344x768 area cap before alignment."}),
|
|
"aspect_ratio": (list(cls.ASPECT_RATIOS.keys()), {"default": "16:9 (Widescreen)"}),
|
|
"scale": (list(cls.SCALE_FACTORS.keys()), {"default": "1x"}),
|
|
"model_profile": (list(cls.MODEL_PROFILES.keys()), {"default": "LTX Video / LTX2", "tooltip": "Select MiniMax H3 for 32-pixel alignment. Wan 2.2 TI2V 5B needs 32; Wan 2.x retains 16 for the other variants. Profiles override divisible_by and add_one."}),
|
|
"custom_mode": (cls.CUSTOM_MODES, {"default": "manual"}),
|
|
"custom_width": ("INT", {"default": 1280, "min": 64, "max": 16384, "step": 8}),
|
|
"custom_height": ("INT", {"default": 720, "min": 64, "max": 16384, "step": 8}),
|
|
"divisible_by": ([8, 16, 32, 64], {"default": 32}),
|
|
"rounding_mode": (cls.ROUNDING_MODES, {"default": "floor"}),
|
|
"add_one": ("BOOLEAN", {"default": True}),
|
|
"swap": ("BOOLEAN", {"default": False}),
|
|
},
|
|
"optional": {
|
|
"custom_megapixels": ("FLOAT", {"default": 0.5, "min": 0.01, "max": 16.0, "step": 0.01,
|
|
"tooltip": "Used only with Custom MP. 1 MP = 1,000,000 pixels before scale and alignment; not a VRAM limit."}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("INT", "INT", "STRING")
|
|
RETURN_NAMES = ("width", "height", "resolution_str")
|
|
OUTPUT_TOOLTIPS = (
|
|
"Width in pixels",
|
|
"Height in pixels",
|
|
"Resolution as string (e.g. 1344x768)",
|
|
)
|
|
FUNCTION = "get_resolution"
|
|
CATEGORY = "video"
|
|
|
|
DESCRIPTION = "Video resolution selector with presets, model profiles, and custom sizing modes."
|
|
SEARCH_ALIASES = ["video size", "ltx resolution", "video dimensions", "cogvideo", "hunyuan", "ltx2", "h3", "minimax", "wan 2.2", "ltx 2.3", "megapixels"]
|
|
|
|
@staticmethod
|
|
def _quantize(value, divisible_by, rounding_mode):
|
|
if rounding_mode == "nearest":
|
|
return int(round(value / divisible_by) * divisible_by)
|
|
if rounding_mode == "ceil":
|
|
return math.ceil(value / divisible_by) * divisible_by
|
|
return int((value // divisible_by) * divisible_by)
|
|
|
|
def _get_preset_resolution(self, resolution, aspect_ratio, custom_megapixels=0.5):
|
|
base_height = self.RESOLUTIONS[resolution]
|
|
w_ratio, h_ratio = self.ASPECT_RATIOS[aspect_ratio]
|
|
|
|
if resolution in self.MEGAPIXELS or resolution == "Custom MP":
|
|
megapixels = self.MEGAPIXELS.get(resolution, custom_megapixels)
|
|
if not isinstance(megapixels, (int, float)) or isinstance(megapixels, bool) or not math.isfinite(megapixels) or not 0.01 <= megapixels <= 16:
|
|
raise ValueError("Custom MP must be a finite number between 0.01 and 16.")
|
|
height = math.sqrt(megapixels * 1_000_000 * h_ratio / w_ratio)
|
|
return height * w_ratio / h_ratio, height
|
|
|
|
if resolution == "H3 Native (768p)":
|
|
width, height = base_height * w_ratio / min(w_ratio, h_ratio), base_height * h_ratio / min(w_ratio, h_ratio)
|
|
factor = min(1.0, math.sqrt((1344 * 768) / (width * height)))
|
|
return max(32, round(width * factor / 32) * 32), max(32, round(height * factor / 32) * 32)
|
|
|
|
if w_ratio == h_ratio:
|
|
return base_height, base_height
|
|
|
|
if w_ratio > h_ratio:
|
|
return round(base_height * w_ratio / h_ratio), base_height
|
|
|
|
return base_height, round(base_height * h_ratio / w_ratio)
|
|
|
|
def _get_custom_resolution(self, aspect_ratio, custom_mode, custom_width, custom_height):
|
|
if custom_mode == "manual":
|
|
return custom_width, custom_height
|
|
|
|
w_ratio, h_ratio = self.ASPECT_RATIOS[aspect_ratio]
|
|
|
|
if custom_mode == "from_width":
|
|
return custom_width, round(custom_width * h_ratio / w_ratio)
|
|
|
|
return round(custom_height * w_ratio / h_ratio), custom_height
|
|
|
|
def _resolve_model_rules(self, model_profile, divisible_by, add_one):
|
|
profile = self.MODEL_PROFILES[model_profile]
|
|
resolved_divisible_by = profile["divisible_by"] if profile["divisible_by"] is not None else divisible_by
|
|
resolved_add_one = profile["add_one"] if profile["add_one"] is not None else add_one
|
|
return resolved_divisible_by, resolved_add_one
|
|
|
|
def get_resolution(self, resolution, aspect_ratio, scale, model_profile, custom_mode, custom_width,
|
|
custom_height, divisible_by, rounding_mode, add_one, swap, custom_megapixels=0.5):
|
|
|
|
scale_factor = self.SCALE_FACTORS[scale]
|
|
divisible_by, add_one = self._resolve_model_rules(model_profile, divisible_by, add_one)
|
|
|
|
if resolution == "Custom":
|
|
width, height = self._get_custom_resolution(
|
|
aspect_ratio, custom_mode, custom_width, custom_height
|
|
)
|
|
else:
|
|
width, height = self._get_preset_resolution(resolution, aspect_ratio, custom_megapixels)
|
|
|
|
# Apply scale
|
|
width *= scale_factor
|
|
height *= scale_factor
|
|
# Preserve legacy preset/custom rounding; MP dimensions stay fractional
|
|
# so floor/nearest/ceil operate on the requested pixel budget.
|
|
if resolution not in self.MEGAPIXELS and resolution != "Custom MP":
|
|
width, height = int(width), int(height)
|
|
|
|
# Quantize dimensions to model requirements.
|
|
width = self._quantize(width, divisible_by, rounding_mode)
|
|
height = self._quantize(height, divisible_by, rounding_mode)
|
|
|
|
# Ensure minimum size
|
|
width = max(width, divisible_by)
|
|
height = max(height, divisible_by)
|
|
|
|
# Legacy custom sizing only; built-in model profiles never add a pixel.
|
|
if add_one:
|
|
width += 1
|
|
height += 1
|
|
|
|
# Swap if requested
|
|
if swap:
|
|
width, height = height, width
|
|
|
|
resolution_str = f"{width}x{height}"
|
|
|
|
return (width, height, resolution_str)
|
|
|
|
|
|
def preview_resolution(payload):
|
|
"""Validate local UI values, then use the node's execution calculation."""
|
|
schema = VideoResolutionNode.INPUT_TYPES()
|
|
inputs = {**schema["required"], **schema["optional"]}
|
|
if not isinstance(payload, dict) or payload.keys() - inputs.keys():
|
|
raise ValueError("Expected resolution inputs only.")
|
|
values = {}
|
|
for name, (kind, options) in inputs.items():
|
|
value = payload.get(name, options["default"])
|
|
if isinstance(kind, list):
|
|
valid = value in kind
|
|
elif kind == "BOOLEAN":
|
|
valid = type(value) is bool
|
|
else:
|
|
valid = type(value) in ((int,) if kind == "INT" else (int, float))
|
|
valid = valid and math.isfinite(value) and options["min"] <= value <= options["max"]
|
|
if not valid:
|
|
raise ValueError(f"Invalid {name}.")
|
|
values[name] = value
|
|
node = VideoResolutionNode()
|
|
width, height, label = node.get_resolution(**values)
|
|
multiple, add_one = node._resolve_model_rules(values["model_profile"], values["divisible_by"], values["add_one"])
|
|
return {"width": width, "height": height, "resolution_str": label,
|
|
"megapixels": width * height / 1_000_000, "multiple": multiple, "add_one": add_one}
|