Add LTX nodes, VACE 2.2 support & UI tweaks

Add a new ltxnodes module implementing LTX video latent nodes, samplers, and a taeltx-based video previewer (auto-download/load). Update package init to expose ltxnodes and adjust loraloader model list sorting for loras. Extend maxedoutnodes with a BBOX detector batch node and an Image+Mask preview node plus color parsing/helpers. Add VACE 2.2 latent save/load support and related helpers in wan22nodes (trim_latent sidecar, loaders), and enhance WAN 2.2 scaler logic to support 1024×1024 square buckets, aspect_mode options, and auto-square behavior. Add/modify several web UI assets and minor project housekeeping (.gitignore local_notes).
This commit is contained in:
Maxed-Out-99
2026-06-24 14:04:07 -07:00
parent b0842cfdd7
commit ac82ed7bea
18 changed files with 1852 additions and 390 deletions
+1
View File
@@ -32,6 +32,7 @@ logs/
# Local testing workspace
testing/
userdata/
local_notes/
# ComfyUI local cache & configs
ComfyUI/output/
+1 -1
View File
@@ -24,8 +24,8 @@ for _name in (
"mediacomparers",
"wan22nodes",
"loraloader_mxd",
"wan_svi_first_last_mxd",
"CharacterPrompts",
"ltxnodes",
):
_mod = _safe_import(_name)
_class_map, _display_map = _get_mappings(_mod)
@@ -20,6 +20,14 @@ def _check_valid_model_type(request):
return None
def _file_details_sort_key(file_info):
modified = file_info.get('modified')
if not isinstance(modified, (int, float)):
modified = 0
file = str(file_info.get('file') or '').replace('\\', '/').lower()
return (-modified, file)
@routes.get('/loraloader-mxd/api/{type}')
async def api_get_models_list(request):
"""Returns a list of model types from user configuration.
@@ -59,6 +67,8 @@ async def api_get_models_list(request):
id=f'no_file_details_{model_type}',
at_most_secs=30
)
if model_type == 'loras':
response.sort(key=_file_details_sort_key)
return web.json_response(response)
return web.json_response(list(files))
+610
View File
@@ -0,0 +1,610 @@
from __future__ import annotations
import os
import re
import struct
import time
import urllib.error
import urllib.request
from io import BytesIO
from PIL import Image
from threading import Lock, Thread
import torch
import torch.nn.functional as F
import comfy
import comfy.model_management
import comfy.patcher_extension
import comfy.samplers
import comfy.sample
import comfy.utils
import latent_preview
import server
_serv = server.PromptServer.instance
########################################################################################################################
# LTX Video Empty Latent Image
class LTXVideoEmptyLatentMXD:
DESCRIPTION = "Create an LTX Video empty latent batch from connected width/height and frame count."
TITLE = "LTX Empty Latent Video MXD"
CATEGORY = "MXD/Latent"
# All dimensions must be multiples of 32 (LTX 32× spatial compression).
# Lengths must be 8n+1 for LTX's 8× temporal compression.
RESOLUTIONS = {
"16:9 Landscape": None,
"16:9 512×288": (512, 288),
"16:9 768×448": (768, 448),
"16:9 832×480": (832, 480),
"16:9 1024×576": (1024, 576),
"16:9 1280×736": (1280, 736),
"9:16 Portrait": None,
"9:16 288×512": (288, 512),
"9:16 448×768": (448, 768),
"9:16 480×832": (480, 832),
"9:16 576×1024": (576, 1024),
"4:3 Standard": None,
"4:3 512×384": (512, 384),
"4:3 768×576": (768, 576),
"1:1 Square": None,
"1:1 512×512": (512, 512),
"1:1 768×768": (768, 768),
}
def __init__(self):
self.device = comfy.model_management.intermediate_device()
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"length": (
"INT",
{
"default": 97,
"min": 9,
"max": 1025,
"step": 8,
"tooltip": "Number of frames. Must be 8n+1 (e.g. 25, 49, 73, 97, 121, 201).",
},
),
"batch_size": (
"INT",
{
"default": 1,
"min": 1,
"max": 4096,
"tooltip": "Number of latent videos in the batch.",
},
),
},
"optional": {
"width": ("INT", {
"default": 640, "min": 32, "max": 8192, "step": 32,
"tooltip": "Stage 1 width. Connect the LTX Image Scaler stage1_width output for I2V workflows.",
}),
"height": ("INT", {
"default": 384, "min": 32, "max": 8192, "step": 32,
"tooltip": "Stage 1 height. Connect the LTX Image Scaler stage1_height output for I2V workflows.",
}),
},
}
RETURN_TYPES = ("LATENT", "INT")
RETURN_NAMES = ("latent", "length")
FUNCTION = "generate"
def generate(self, length, batch_size=1, width=640, height=384):
# LTX latent: 128 channels, 32× spatial compression, 8× temporal compression
width = max(32, int(width) // 32 * 32)
height = max(32, int(height) // 32 * 32)
length = max(9, 1 + 8 * round((int(length) - 1) / 8))
t = ((length - 1) // 8) + 1
h = height // 32
w = width // 32
latent = torch.zeros([batch_size, 128, t, h, w], device=self.device)
return ({"samples": latent}, length)
########################################################################################################################
# Shared noise helper — equivalent to ComfyUI RandomNoise
class _LTXNoise:
def __init__(self, seed: int):
self.seed = seed
def generate_noise(self, latent: dict) -> torch.Tensor:
samples = latent["samples"]
batch_inds = latent.get("batch_index", None)
return comfy.sample.prepare_noise(samples, self.seed, batch_inds)
########################################################################################################################
# LTX video preview (taeltx TAE decode)
#
# Core ComfyUI has no preview for the LTXAV format used by LTX 2.3, and the
# latent2rgb approximation looks awful for video. This installs a previewer that
# decodes latent frames with the tiny "taeltx" autoencoder for accurate previews.
# The taeltx model is auto-discovered in the vae / vae_approx model folders. If
# it isn't found, it is downloaded to the configured vae model folder.
#
# TAE decode path borrowed from kjnodes / VideoHelperSuite.
_TAELTX_FILENAME = "taeltx2_3.safetensors"
_TAELTX_URL = "https://huggingface.co/Kijai/LTX2.3_comfy/resolve/main/vae/taeltx2_3.safetensors?download=true"
_TAELTX_DOWNLOAD_LOCK = Lock()
def _find_taeltx_path(folder_paths):
for folder in ("vae", "vae_approx"):
try:
names = folder_paths.get_filename_list(folder)
except Exception:
continue
name = next((fn for fn in names if "taeltx" in fn.lower()), None)
if name is not None:
path = folder_paths.get_full_path(folder, name)
if path:
return path
return None
def _download_taeltx(folder_paths):
try:
vae_dirs = folder_paths.get_folder_paths("vae")
except Exception as exc:
print(f"[MXD LTX preview] cannot find ComfyUI vae model folder: {exc}")
return None
if not vae_dirs:
print("[MXD LTX preview] cannot find ComfyUI vae model folder.")
return None
target_dir = vae_dirs[0]
target_path = os.path.join(target_dir, _TAELTX_FILENAME)
partial_path = f"{target_path}.part"
with _TAELTX_DOWNLOAD_LOCK:
if os.path.isfile(target_path):
return target_path
try:
os.makedirs(target_dir, exist_ok=True)
print(f"[MXD LTX preview] downloading {_TAELTX_FILENAME} to {target_path}")
request = urllib.request.Request(_TAELTX_URL, headers={"User-Agent": "ComfyUI-MaxedOut"})
with urllib.request.urlopen(request, timeout=120) as response, open(partial_path, "wb") as out:
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
out.write(chunk)
if not os.path.isfile(partial_path) or os.path.getsize(partial_path) == 0:
raise RuntimeError("downloaded file is empty")
os.replace(partial_path, target_path)
try:
folder_paths.get_filename_list("vae")
except Exception:
pass
print(f"[MXD LTX preview] downloaded {_TAELTX_FILENAME}")
return target_path
except (OSError, RuntimeError, urllib.error.URLError) as exc:
try:
if os.path.exists(partial_path):
os.remove(partial_path)
except OSError:
pass
print(f"[MXD LTX preview] failed to download {_TAELTX_FILENAME}: {exc}")
return None
def _load_taeltx():
"""Load the taeltx TAE from the vae / vae_approx model folders. Returns a VAE or None."""
try:
import folder_paths
from comfy.sd import VAE
except Exception:
return None
path = _find_taeltx_path(folder_paths)
if not path:
path = _download_taeltx(folder_paths)
if not path:
return None
try:
taeltx = VAE(comfy.utils.load_torch_file(path))
taeltx.first_stage_model.show_progress_bar = False
except Exception as exc:
print(f"[MXD LTX preview] failed to load taeltx ({path}): {exc}")
return None
return taeltx
class _LTXTAEPreviewer:
"""Cycles through LTX video latent frames during sampling, decoding with taeltx."""
def __init__(self, taeltx, rate=8):
self.first_preview = True
self.last_time = 0.0
self.c_index = 0
self.rate = rate
self.taeltx = taeltx
def decode_latent_to_preview_image(self, preview_format, x0):
if x0.ndim == 5:
x0 = x0.movedim(2, 1)
x0 = x0.reshape((-1,) + x0.shape[-3:])
num_images = x0.size(0)
new_time = time.time()
num_previews = int((new_time - self.last_time) * self.rate)
self.last_time += num_previews / self.rate
if num_previews > num_images:
num_previews = num_images
elif num_previews <= 0:
return None
if self.first_preview:
self.first_preview = False
_serv.send_sync(
'VHS_latentpreview',
{'length': num_images, 'rate': self.rate, 'id': _serv.last_node_id},
)
self.last_time = new_time + 1.0 / self.rate
if self.c_index + num_previews > num_images:
frames = x0.roll(-self.c_index, 0)[:num_previews]
else:
frames = x0[self.c_index:self.c_index + num_previews]
Thread(target=self._send_frames, args=(frames, self.c_index, num_images)).run()
self.c_index = (self.c_index + num_previews) % num_images
return None
def _send_frames(self, image_tensor, ind, leng):
max_size, min_size = 512, 256
image_tensor = self._decode(image_tensor)
if image_tensor.size(1) < min_size or image_tensor.size(2) < min_size:
image_tensor = F.interpolate(
image_tensor.movedim(-1, 0), scale_factor=4, mode='nearest'
).movedim(0, -1)
if image_tensor.size(1) > max_size or image_tensor.size(2) > max_size:
t = image_tensor.movedim(-1, 0)
if t.size(2) < t.size(3):
h = (max_size * t.size(2)) // t.size(3)
t = F.interpolate(t, (h, max_size), mode='nearest')
else:
w = (max_size * t.size(3)) // t.size(2)
t = F.interpolate(t, (max_size, w), mode='nearest')
image_tensor = t.movedim(0, -1)
previews = image_tensor.clamp(0, 1).mul(0xFF).to(device="cpu", dtype=torch.uint8)
for preview in previews:
img = Image.fromarray(preview.numpy())
buf = BytesIO()
buf.write((1).to_bytes(length=4, byteorder='big') * 2)
buf.write(ind.to_bytes(length=4, byteorder='big'))
buf.write(struct.pack('16p', _serv.last_node_id.encode('ascii')))
img.save(buf, format="JPEG", quality=95, compress_level=1)
_serv.send_sync(server.BinaryEventTypes.PREVIEW_IMAGE, buf.getvalue(), _serv.client_id)
# taeltx expands the 8× temporal compression on decode
ind = (ind + 1) % ((leng - 1) * 8 + 1)
def _decode(self, x0):
dev = comfy.model_management.get_torch_device()
dtype = self.taeltx.first_stage_model.decoder[1].weight.dtype
x0 = x0.unsqueeze(0).to(dtype=dtype, device=dev)
return self.taeltx.first_stage_model.decode(x0)[0].permute(1, 2, 3, 0)
class _LTXPreviewWrapper:
"""OUTER_SAMPLE wrapper that installs the taeltx video previewer during sampling."""
def __init__(self, taeltx):
self.taeltx = taeltx
def __call__(self, executor, noise, latent_image, sampler, sigmas,
denoise_mask, callback, disable_pbar, seed, latent_shapes):
guider = executor.class_obj
device = comfy.model_management.get_torch_device()
self.taeltx.first_stage_model.to(device)
previewer = _LTXTAEPreviewer(self.taeltx, rate=8)
pbar = comfy.utils.ProgressBar(len(sigmas) - 1)
# Strip I2V guide frames appended at the end of the latent before previewing.
num_keyframes = 0
if 'positive' in guider.conds and guider.conds['positive']:
kf = guider.conds['positive'][0].get('keyframe_idxs')
if kf is not None:
num_keyframes = len(torch.unique(kf[0, 0, :, 0]))
def ltx_callback(step, x0, x, total_steps):
x0_v = x0
if x0_v is not None and len(latent_shapes) > 1:
# Audio+video latents are packed into [B, 1, total]; unpack and
# take the video tensor (the 5D one). Audio is a lower-rank entry.
x0_v = next(
(p for p in comfy.utils.unpack_latents(x0, latent_shapes) if p.ndim == 5),
None,
)
if x0_v is not None and x0_v.ndim == 5 and num_keyframes > 0:
x0_v = x0_v[:, :, :-num_keyframes]
preview = (
previewer.decode_latent_to_preview_image("JPEG", x0_v)
if x0_v is not None and x0_v.ndim == 5 else None
)
pbar.update_absolute(step + 1, total_steps, preview)
if callback is not None:
callback(step, x0, x, total_steps)
try:
return executor(
noise, latent_image, sampler, sigmas, denoise_mask,
ltx_callback, disable_pbar, seed, latent_shapes=latent_shapes,
)
finally:
self.taeltx.first_stage_model.to(comfy.model_management.unet_offload_device())
########################################################################################################################
# LTX KSampler — Stage 1 (T2V / I2V generation at base resolution)
class LTXKSamplerMXD:
DESCRIPTION = (
"LTX-Video Stage 1 sampler for the distilled workflow. Use Distilled 8 Step "
"for the trained schedule, or Custom Sigmas when intentionally testing a "
"manual schedule."
)
TITLE = "LTX Stage 1 Sampler MXD"
CATEGORY = "MXD/Sampling"
MODES = ["Distilled 8 Step", "Custom Sigmas"]
_DISTILLED_SIGMAS = [1.0, 0.99375, 0.9875, 0.98125, 0.975,
0.909375, 0.725, 0.421875, 0.0]
_CUSTOM_SIGMAS_DEFAULT = "1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"latent_image": ("LATENT",),
"mode": (cls.MODES, {"default": "Distilled 8 Step"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF, "control_after_generate": True}),
"cfg": ("FLOAT", {"default": 2.0, "min": 0.0, "max": 100.0, "step": 0.1}),
"sampler_name": (
["euler_ancestral_cfg_pp", "euler_cfg_pp", "euler"],
{"default": "euler_ancestral_cfg_pp"},
),
"custom_sigmas": (
"STRING",
{
"default": cls._CUSTOM_SIGMAS_DEFAULT,
"multiline": True,
"tooltip": "Only used when mode is Custom Sigmas. Enter comma, space, or newline separated sigma values.",
},
),
"ltx_preview": ("BOOLEAN", {"default": True, "tooltip": "Show LTX video previews during sampling. Downloads the taeltx VAE to your vae model folder if it is missing."}),
},
}
RETURN_TYPES = ("LATENT",)
RETURN_NAMES = ("latent",)
FUNCTION = "sample"
OUTPUT_NODE = False
def sample(
self,
model,
positive,
negative,
latent_image,
mode="Distilled 8 Step",
seed=0,
cfg=2.0,
sampler_name="euler_ancestral_cfg_pp",
custom_sigmas=_CUSTOM_SIGMAS_DEFAULT,
ltx_preview=True,
):
sigmas = _select_sigmas(
mode,
{
"Distilled 8 Step": self._DISTILLED_SIGMAS,
},
custom_sigmas,
"LTX Stage 1 Sampler MXD",
)
return _run_sampling(model, positive, negative, latent_image, seed, cfg, sampler_name, sigmas, ltx_preview)
########################################################################################################################
# LTX KSampler 2 — Stage 2 (refinement at 2× resolution with distilled LoRA)
class LTXKSampler2MXD:
DESCRIPTION = (
"LTX-Video Stage 2 refiner for the distilled workflow. Official Refine "
"matches the Lightricks 2.3 two-stage example (start sigma 0.85). "
"Custom Sigmas is for manual testing."
)
TITLE = "LTX Stage 2 Refiner MXD"
CATEGORY = "MXD/Sampling"
# Exact stage-2 refine schedule from the official Lightricks 2.3 two-stage
# workflow (LTX-2.3_T2V_I2V_Two_Stage_Distilled.json, euler_cfg_pp, cfg 1).
# Only the starting sigma (denoise strength) is meant to vary; use Custom
# Sigmas for that.
MODES = ["Official Refine", "Custom Sigmas"]
_OFFICIAL_REFINE_SIGMAS = [0.85, 0.725, 0.4219, 0.0]
_CUSTOM_SIGMAS_DEFAULT = "0.85, 0.725, 0.4219, 0.0"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"latent_image": ("LATENT",),
"mode": (cls.MODES, {"default": "Official Refine"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFFFFFFFFFF, "control_after_generate": True}),
"cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 100.0, "step": 0.1}),
"sampler_name": (
["euler_cfg_pp", "euler_ancestral_cfg_pp", "euler"],
{"default": "euler_cfg_pp"},
),
"custom_sigmas": (
"STRING",
{
"default": cls._CUSTOM_SIGMAS_DEFAULT,
"multiline": True,
"tooltip": "Only used when mode is Custom Sigmas. Enter comma, space, or newline separated sigma values.",
},
),
"ltx_preview": ("BOOLEAN", {"default": True, "tooltip": "Show LTX video previews during sampling. Downloads the taeltx VAE to your vae model folder if it is missing."}),
},
}
RETURN_TYPES = ("LATENT",)
RETURN_NAMES = ("latent",)
FUNCTION = "sample"
OUTPUT_NODE = False
def sample(
self,
model,
positive,
negative,
latent_image,
mode="Official Refine",
seed=0,
cfg=1.0,
sampler_name="euler_cfg_pp",
custom_sigmas=_CUSTOM_SIGMAS_DEFAULT,
ltx_preview=True,
):
sigmas = _select_sigmas(
mode,
{
"Official Refine": self._OFFICIAL_REFINE_SIGMAS,
},
custom_sigmas,
"LTX Stage 2 Refiner MXD",
)
return _run_sampling(model, positive, negative, latent_image, seed, cfg, sampler_name, sigmas, ltx_preview)
########################################################################################################################
# Sigma schedule helpers
_SIGMA_RE = re.compile(r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?")
def _select_sigmas(mode, presets, custom_sigmas, node_name):
if mode == "Custom Sigmas":
values = _parse_custom_sigmas(custom_sigmas, node_name)
else:
try:
values = presets[mode]
except KeyError as exc:
allowed = ", ".join([*presets.keys(), "Custom Sigmas"])
raise ValueError(f"{node_name}: unknown mode '{mode}'. Expected one of: {allowed}.") from exc
return torch.tensor(values, dtype=torch.float32)
def _parse_custom_sigmas(custom_sigmas, node_name):
text = str(custom_sigmas or "")
values = [float(match.group(0)) for match in _SIGMA_RE.finditer(text)]
if len(values) < 2:
raise ValueError(f"{node_name}: Custom Sigmas needs at least two sigma values, ending with 0.0.")
for index, (left, right) in enumerate(zip(values, values[1:]), start=1):
if right > left:
raise ValueError(
f"{node_name}: Custom Sigmas must be in descending order. "
f"Value {index + 1} ({right}) is greater than value {index} ({left})."
)
if abs(values[-1]) > 1e-8:
raise ValueError(f"{node_name}: Custom Sigmas must end with 0.0.")
return values
########################################################################################################################
# Shared sampling logic
def _run_sampling(model, positive, negative, latent_image, seed, cfg, sampler_name, sigmas, ltx_preview=False):
taeltx = _load_taeltx() if ltx_preview else None
if ltx_preview and taeltx is None:
print("[MXD LTX preview] taeltx model not found in vae / vae_approx — skipping preview.")
if taeltx is not None:
model = model.clone()
model.add_wrapper_with_key(
comfy.patcher_extension.WrappersMP.OUTER_SAMPLE,
"ltx_mxd_preview",
_LTXPreviewWrapper(taeltx),
)
guider = comfy.samplers.CFGGuider(model)
guider.set_conds(positive, negative)
guider.set_cfg(cfg)
sampler = comfy.samplers.sampler_object(sampler_name)
latent = latent_image.copy()
latent_samples = latent["samples"]
try:
latent_samples = comfy.sample.fix_empty_latent_channels(
guider.model_patcher, latent_samples,
latent.get("downscale_ratio_spacial", None),
)
except AttributeError:
pass
latent["samples"] = latent_samples
noise_mask = latent.get("noise_mask", None)
noise = _LTXNoise(seed)
if taeltx is not None:
# The preview wrapper owns the progress bar / callback.
callback = None
else:
x0_output = {}
callback = latent_preview.prepare_callback(guider.model_patcher, sigmas.shape[-1] - 1, x0_output)
disable_pbar = not comfy.utils.PROGRESS_BAR_ENABLED
samples = guider.sample(
noise.generate_noise(latent),
latent_samples,
sampler,
sigmas,
denoise_mask=noise_mask,
callback=callback,
disable_pbar=disable_pbar,
seed=seed,
)
samples = samples.to(comfy.model_management.intermediate_device())
out = latent.copy()
out.pop("downscale_ratio_spacial", None)
out["samples"] = samples
return (out,)
########################################################################################################################
NODE_CLASS_MAPPINGS = {
"LTXVideoEmptyLatent_MXD": LTXVideoEmptyLatentMXD,
"LTXKSampler_MXD": LTXKSamplerMXD,
"LTXKSampler2_MXD": LTXKSampler2MXD,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"LTXVideoEmptyLatent_MXD": "LTX Empty Latent Video MXD",
"LTXKSampler_MXD": "LTX Stage 1 Sampler MXD",
"LTXKSampler2_MXD": "LTX Stage 2 Refiner MXD",
}
+190 -2
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
import torch, math, comfy, os, folder_paths, node_helpers, comfy.model_management, comfy.utils, json, hashlib, re
import torch, math, comfy, os, folder_paths, node_helpers, comfy.model_management, comfy.utils, json, hashlib, re, random
import torch.nn.functional as F
from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict
import numpy as np
from PIL import Image, ImageOps, ImageSequence, ImageFilter
from PIL import Image, ImageOps, ImageSequence, ImageFilter, ImageColor
from nodes import SaveImage
try:
from comfy_api.latest import io
HAVE_COMFY_API = True
@@ -1514,6 +1515,189 @@ class SmartCropByMaskMXD:
########################################################################################################################
class BboxDetectorCombinedBatchMXD:
DESCRIPTION = "Run an Impact Pack BBOX_DETECTOR combined mask over each image in a batch."
CATEGORY = "MXD/Detector"
RETURN_TYPES = ("MASK",)
RETURN_NAMES = ("mask",)
FUNCTION = "detect"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"bbox_detector": ("BBOX_DETECTOR",),
"images": ("IMAGE",),
"threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"dilation": ("INT", {"default": 4, "min": -512, "max": 512, "step": 1}),
}
}
def detect(self, bbox_detector, images, threshold=0.5, dilation=4):
if images.ndim == 3:
images = images.unsqueeze(0)
if images.ndim != 4:
raise ValueError(f"[BboxDetectorCombinedBatchMXD] Expected IMAGE tensor [B,H,W,C], got shape {tuple(images.shape)}")
masks = []
frame_count, height, width, _ = images.shape
pbar = comfy.utils.ProgressBar(frame_count)
for i in range(frame_count):
frame = images[i:i + 1]
mask = bbox_detector.detect_combined(frame, threshold, dilation)
if mask is None:
mask = torch.zeros((height, width), dtype=torch.float32, device="cpu")
elif torch.is_tensor(mask):
mask = mask.detach().to(dtype=torch.float32, device="cpu")
else:
mask = torch.as_tensor(mask, dtype=torch.float32, device="cpu")
if mask.ndim == 3 and mask.shape[0] == 1:
mask = mask.squeeze(0)
if mask.ndim != 2:
raise ValueError(f"[BboxDetectorCombinedBatchMXD] Detector returned unexpected mask shape {tuple(mask.shape)} for frame {i}.")
masks.append(mask.unsqueeze(0))
pbar.update(1)
return (torch.cat(masks, dim=0),)
########################################################################################################################
def _parse_mxd_mask_color(color_string):
if color_string is None:
return [255, 255, 255]
text = str(color_string).strip()
color = [255, 255, 255]
if "," in text:
try:
values = [float(channel.strip()) for channel in text.split(",")]
if all(0.0 <= value <= 1.0 for value in values):
color = [int(value * 255) for value in values]
else:
color = [int(value) for value in values]
except Exception:
color = [255, 255, 255]
else:
try:
color = list(ImageColor.getrgb(text))
except Exception:
try:
value = float(text)
value = int(value * 255) if 0.0 <= value <= 1.0 else int(value)
color = [value, value, value]
except Exception:
color = [255, 255, 255]
color = np.clip(color, 0, 255).astype(np.int32).tolist()
if len(color) < 3:
color = (color + [color[-1] if color else 255] * 3)[:3]
return color[:4]
def _mxd_image_batch(image):
if image is None:
return None
if image.ndim == 3:
image = image.unsqueeze(0)
if image.ndim != 4:
raise ValueError(f"[ImageAndMaskPreviewMXD] Expected IMAGE tensor [B,H,W,C], got shape {tuple(image.shape)}")
return image.to(dtype=torch.float32)
def _mxd_mask_batch(mask, height=None, width=None, batch_size=None, device=None):
if mask is None:
return None
if mask.ndim == 2:
mask = mask.unsqueeze(0)
elif mask.ndim == 4 and mask.shape[-1] == 1:
mask = mask[..., 0]
elif mask.ndim == 4 and mask.shape[1] == 1:
mask = mask[:, 0]
if mask.ndim != 3:
raise ValueError(f"[ImageAndMaskPreviewMXD] Expected MASK tensor [B,H,W], got shape {tuple(mask.shape)}")
mask = mask.to(dtype=torch.float32, device=device if device is not None else mask.device).clamp(0.0, 1.0)
if height is not None and width is not None and (mask.shape[-2] != height or mask.shape[-1] != width):
mask = F.interpolate(mask.unsqueeze(1), size=(height, width), mode="bilinear", align_corners=False).squeeze(1)
if batch_size is not None:
mask = comfy.utils.repeat_to_batch_size(mask, batch_size)
return mask
class ImageAndMaskPreviewMXD(SaveImage):
DESCRIPTION = """Return an image with a mask composited over it without creating a node preview."""
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("composite",)
FUNCTION = "execute"
CATEGORY = "MXD/Image"
OUTPUT_NODE = False
def __init__(self):
self.output_dir = folder_paths.get_temp_directory()
self.type = "temp"
self.prefix_append = "_temp_" + "".join(random.choice("abcdefghijklmnopqrstupvxyz") for _ in range(5))
self.compress_level = 4
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mask_opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"mask_color": ("STRING", {"default": "255, 255, 255", "tooltip": "RGB/RGBA CSV, hex, or color name."}),
"pass_through": ("BOOLEAN", {"default": True, "tooltip": "Legacy option. This node now always returns the composite without creating a preview."}),
},
"optional": {
"image": ("IMAGE",),
"mask": ("MASK",),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
def _build_composite(self, image=None, mask=None, mask_opacity=1.0, mask_color="255, 255, 255"):
image = _mxd_image_batch(image)
if image is None and mask is None:
raise ValueError("[ImageAndMaskPreviewMXD] Connect an image, a mask, or both.")
if image is None:
mask = _mxd_mask_batch(mask)
return mask.unsqueeze(-1).expand(-1, -1, -1, 3).contiguous()
if image.shape[-1] == 1:
image = image.expand(-1, -1, -1, 3).clone()
elif image.shape[-1] >= 3:
image = image[..., :3].clone()
else:
raise ValueError(f"[ImageAndMaskPreviewMXD] Expected IMAGE tensor with 1 or more channels, got shape {tuple(image.shape)}")
if mask is None:
return image
batch_size, height, width, channels = image.shape
mask = _mxd_mask_batch(mask, height, width, batch_size, image.device)
color = _parse_mxd_mask_color(mask_color)
alpha = mask.mul(float(mask_opacity)).clamp(0.0, 1.0)
if len(color) == 4:
alpha = alpha * (color[3] / 255.0)
rgb = torch.tensor(color[:3], dtype=image.dtype, device=image.device).view(1, 1, 1, channels) / 255.0
alpha = alpha.unsqueeze(-1)
return (image * (1.0 - alpha) + rgb * alpha).clamp(0.0, 1.0)
def execute(self, mask_opacity, mask_color, pass_through, filename_prefix="ComfyUI", image=None, mask=None, prompt=None, extra_pnginfo=None):
composite = self._build_composite(image=image, mask=mask, mask_opacity=mask_opacity, mask_color=mask_color)
return (composite,)
########################################################################################################################
# NODE MAPPING
NODE_CLASS_MAPPINGS = {
"Flux Empty Latent Image": FluxEmptyLatentImage,
@@ -1535,6 +1719,8 @@ NODE_CLASS_MAPPINGS = {
"Save Image MXD": SaveImage_MXD,
"Extract Workflow From Image MXD": ExtractWorkflowFromImageMXD,
"SmartCropByMaskMXD": SmartCropByMaskMXD,
"BboxDetectorCombinedBatchMXD": BboxDetectorCombinedBatchMXD,
"ImageAndMaskPreviewMXD": ImageAndMaskPreviewMXD,
}
if HAVE_COMFY_API:
@@ -1563,6 +1749,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"Save Image MXD": "Save Image MXD",
"Extract Workflow From Image MXD": "Extract Workflow From Image MXD",
"SmartCropByMaskMXD": "Smart Crop by Mask MXD",
"BboxDetectorCombinedBatchMXD": "BBOX Detector Combined Batch MXD",
"ImageAndMaskPreviewMXD": "Image and Mask Preview MXD",
}
if HAVE_COMFY_API:
+502 -230
View File
@@ -196,41 +196,51 @@ class SaveLatent_I2V_MXD:
def save_only(self, samples, positive, negative, filename_prefix="I2V",
prompt=None, extra_pnginfo=None, unique_id=None):
# ---- save latent (.latent) ----
latents_dir = os.path.join(folder_paths.get_input_directory(), "latents")
os.makedirs(latents_dir, exist_ok=True)
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix, latents_dir
_save_i2v_latent_bundle(
samples=samples,
positive=positive,
negative=negative,
filename_prefix=filename_prefix,
prompt=prompt,
extra_pnginfo=extra_pnginfo,
unique_id=unique_id,
)
return {}
# Metadata
meta = None
if not args.disable_metadata:
meta = {}
if prompt is not None:
try: meta["prompt"] = json.dumps(prompt)
except: pass
if extra_pnginfo is not None:
for k, v in extra_pnginfo.items():
try: meta[k] = json.dumps(v)
except: pass
_attach_source_ksampler_metadata(meta, prompt, unique_id)
class SaveLatent_VACE22_MXD(SaveLatent_I2V_MXD):
"""
VACE 2.2 saver: I2V latent + conditioning sidecar + trim_latent value.
Kept as a separate node so existing I2V workflows stay unchanged.
"""
TITLE = "Save Latent Vace 2.2"
CATEGORY = "MXD/Latents (VACE 2.2)"
latent_path = os.path.join(full_output_folder, f"{filename}_{counter:05}_.latent")
payload = {
"latent_tensor": samples["samples"].contiguous(),
"latent_format_version_0": torch.tensor([]),
@classmethod
def INPUT_TYPES(cls):
inputs = SaveLatent_I2V_MXD.INPUT_TYPES()
inputs["optional"] = {
"trim_latent": ("INT", {
"default": 0,
"min": 0,
"max": 10000,
"step": 1,
"tooltip": "VACE 2.2 trim_latent value to preserve with this latent. Usually 0 or 1."
}),
}
comfy.utils.save_torch_file(payload, latent_path, metadata=meta)
return inputs
# ---- save conditioning sidecar (.cond.pt) ----
cond_path = latent_path.replace(".latent", ".cond.pt")
torch.save({"positive": positive, "negative": negative}, cond_path)
# No preview logic at all
def save_only(self, samples, positive, negative, filename_prefix="I2V",
trim_latent=0, prompt=None, extra_pnginfo=None, unique_id=None):
_save_i2v_latent_bundle(
samples=samples,
positive=positive,
negative=negative,
filename_prefix=filename_prefix,
prompt=prompt,
extra_pnginfo=extra_pnginfo,
unique_id=unique_id,
sidecar_extra={"trim_latent": _coerce_trim_latent(trim_latent)},
)
return {}
# ---------- Helpers ----------
@@ -453,6 +463,97 @@ def _attach_source_ksampler_metadata(meta: Dict[str, Any], prompt: Any, unique_i
pass
def _build_latent_metadata(prompt=None, extra_pnginfo=None, unique_id=None, extra_meta=None):
if args.disable_metadata:
return None
meta = {}
if prompt is not None:
try:
meta["prompt"] = json.dumps(prompt)
except Exception:
pass
if extra_pnginfo is not None:
for k, v in extra_pnginfo.items():
try:
meta[k] = json.dumps(v)
except Exception:
pass
if isinstance(extra_meta, dict):
for k, v in extra_meta.items():
try:
meta[str(k)] = json.dumps(v)
except Exception:
pass
_attach_source_ksampler_metadata(meta, prompt, unique_id)
return meta
def _save_i2v_latent_bundle(
samples,
positive,
negative,
filename_prefix="I2V",
prompt=None,
extra_pnginfo=None,
unique_id=None,
sidecar_extra=None,
):
latents_dir = os.path.join(folder_paths.get_input_directory(), "latents")
os.makedirs(latents_dir, exist_ok=True)
full_output_folder, filename, counter, _subfolder, _filename_prefix = folder_paths.get_save_image_path(
filename_prefix, latents_dir
)
extra_meta = sidecar_extra if isinstance(sidecar_extra, dict) else None
meta = _build_latent_metadata(
prompt=prompt,
extra_pnginfo=extra_pnginfo,
unique_id=unique_id,
extra_meta=extra_meta,
)
latent_path = os.path.join(full_output_folder, f"{filename}_{counter:05}_.latent")
payload = {
"latent_tensor": samples["samples"].contiguous(),
"latent_format_version_0": torch.tensor([]),
}
comfy.utils.save_torch_file(payload, latent_path, metadata=meta)
sidecar = {"positive": positive, "negative": negative}
if isinstance(sidecar_extra, dict):
sidecar.update(sidecar_extra)
torch.save(sidecar, latent_path.replace(".latent", ".cond.pt"))
return latent_path
def _load_i2v_conditioning_sidecar(latent_path):
cond_path = latent_path.replace(".latent", ".cond.pt")
if not os.path.exists(cond_path):
return [], [], {}
try:
data = torch.load(cond_path, map_location="cpu")
except Exception:
return [], [], {}
if not isinstance(data, dict):
return [], [], {}
return data.get("positive", []), data.get("negative", []), data
def _coerce_trim_latent(value, default=0):
try:
if isinstance(value, str):
parsed = _safe_json_loads(value)
value = parsed if parsed is not None else value
return int(value)
except Exception:
return int(default)
def _extract_prompt_text_from_ksampler(graph: Dict[str, Any], ks_node: Dict[str, Any]) -> Tuple[str, str]:
pos = ""
neg = ""
@@ -1267,6 +1368,129 @@ class LoadLatents_FromFolder_I2V_MXD(LoadLatents_FromFolder_WithParams):
filename_prefixes,
)
class LoadLatent_VACE22_MXD(LoadLatent_I2V_MXD):
"""
I2V loader plus the VACE 2.2 trim_latent value saved by Save Latent Vace 2.2.
"""
TITLE = "Load Latent Vace 2.2"
CATEGORY = "MXD/Latents (VACE 2.2)"
RETURN_TYPES = (
"FLOAT",
"CONDITIONING",
"CONDITIONING",
"LATENT",
"INT",
"FLOAT",
"STRING",
"STRING",
"INT",
"STRING",
"INT",
)
RETURN_NAMES = (
"shift",
"positive",
"negative",
"samples",
"steps",
"cfg",
"sampler_name",
"scheduler",
"end_at_step",
"filename_prefix",
"trim_latent",
)
@classmethod
def INPUT_TYPES(s):
inputs = LoadLatent_I2V_MXD.INPUT_TYPES.__func__(s)
sampler_type = s.RETURN_TYPES[6]
scheduler_type = s.RETURN_TYPES[7]
s.RETURN_TYPES = (
"FLOAT", "CONDITIONING", "CONDITIONING", "LATENT",
"INT", "FLOAT", sampler_type, scheduler_type,
"INT", "STRING", "INT",
)
return inputs
def load(self, latent):
base_tuple = super().load(latent)
latent_ref = latent if str(latent).startswith("latents/") else f"latents/{latent}"
latent_path = folder_paths.get_annotated_filepath(latent_ref)
_pos, _neg, sidecar = _load_i2v_conditioning_sidecar(latent_path)
_sample_dict, meta, _keys = _load_latent_file(latent_path)
trim_latent = _coerce_trim_latent(sidecar.get("trim_latent", meta.get("trim_latent", 0)))
return (*base_tuple, trim_latent)
class LoadLatents_FromFolder_VACE22_MXD(LoadLatents_FromFolder_I2V_MXD):
"""
Batch I2V loader plus a trim_latent list aligned with each returned latent slice.
"""
TITLE = "Load Latents (Folder, Vace 2.2)"
CATEGORY = "MXD/Latents (VACE 2.2)"
FUNCTION = "load_batch_vace22"
RETURN_TYPES = (
"FLOAT",
"CONDITIONING",
"CONDITIONING",
"LATENT",
"INT",
"FLOAT",
"STRING",
"STRING",
"INT",
"STRING",
"INT",
)
RETURN_NAMES = (
"shift",
"positive",
"negative",
"samples",
"steps",
"cfg",
"sampler_name",
"scheduler",
"end_at_step",
"filename_prefix",
"trim_latent",
)
OUTPUT_IS_LIST = (True,) * 11
@classmethod
def INPUT_TYPES(s):
inputs = LoadLatents_FromFolder_I2V_MXD.INPUT_TYPES.__func__(s)
sampler_type = s.RETURN_TYPES[6]
scheduler_type = s.RETURN_TYPES[7]
s.RETURN_TYPES = (
"FLOAT", "CONDITIONING", "CONDITIONING", "LATENT",
"INT", "FLOAT", sampler_type, scheduler_type,
"INT", "STRING", "INT",
)
return inputs
def load_batch_vace22(self, subfolder):
base_tuple = super().load_batch_i2v(subfolder)
latents_root = os.path.join(folder_paths.get_input_directory(), "latents")
base = os.path.join(latents_root, subfolder) if subfolder else latents_root
files = glob.glob(os.path.join(base, "**", "*.latent"), recursive=True)
files = _sort_paths_newest_first(files)
trims = []
for path in files:
sample_dict, meta, _keys = _load_latent_file(path)
_pos, _neg, sidecar = _load_i2v_conditioning_sidecar(path)
trim_latent = _coerce_trim_latent(sidecar.get("trim_latent", meta.get("trim_latent", 0)))
t = sample_dict["samples"]
slice_count = int(t.size(0)) if isinstance(t, torch.Tensor) and t.dim() >= 4 and t.size(0) > 1 else 1
trims.extend([trim_latent] * slice_count)
return (*base_tuple, trims)
# ---------- Empty latent image generator (for video nodes) ----------
class Wan2_2EmptyLatentImageMXD:
"""
@@ -1348,6 +1572,7 @@ class wan22EmptyHunyuanLatentVideoMXD:
RESOLUTIONS = {
"— 720p —": None,
"Widescreen (16:9) 1280×720": (1280, 720),
"Square (1:1) 1024×1024": (1024, 1024),
"— 480p —": None,
"Widescreen (16:9) 832×480": (832, 480),
@@ -1462,9 +1687,10 @@ if HAVE_COMFY_API:
return io.NodeOutput(positive, negative, out_latent)
# ---- Canonical WAN 2.2 buckets ----
BUCKETS_480 = [(832,480), (480,832), (624,624)] # 16:9, 9:16, 1:1
BUCKETS_720 = [(1280,720), (720,1280)] # 16:9, 9:16
SQUARE_TOL = 0.03 # ±3% aspect-ratio tolerance counts as "square-ish"
BUCKETS_480 = [(832,480), (480,832), (624,624)] # 16:9, 9:16, 1:1
BUCKETS_720 = [(1280,720), (720,1280), (1024,1024)] # 16:9, 9:16, 1:1
SQUARE_TOL = 0.03 # exact-ish square passthrough tolerance
AUTO_SQUARE_MAX_AR = 1.25 # Auto may crop to square when the source is within 25% of 1:1.
def _ar(w, h):
return w / max(1, h)
@@ -1486,6 +1712,32 @@ def _is_squareish(w, h, tol=SQUARE_TOL):
r = _ar(w, h)
return abs(r - 1.0) <= tol
def _is_auto_square_candidate(w, h):
r = _ar(w, h)
return max(r, 1.0 / max(r, 1e-9)) <= AUTO_SQUARE_MAX_AR
def _wan22_tier_from_area(iw, ih):
area = iw * ih
area_480 = 832 * 480
area_720 = 1280 * 720
return "480p" if abs(area - area_480) / area_480 <= abs(area - area_720) / area_720 else "720p"
def _wan22_square_bucket(tier, iw=None, ih=None):
if tier == "720p":
return (1024, 1024)
if tier == "480p":
return (624, 624)
return (1024, 1024) if _wan22_tier_from_area(iw, ih) == "720p" else (624, 624)
def _wan22_oriented_bucket(tier, orientation, iw=None, ih=None):
if tier == "Auto":
tier = _wan22_tier_from_area(iw, ih)
if orientation == "Tall":
return (480, 832) if tier == "480p" else (720, 1280)
if orientation == "Wide":
return (832, 480) if tier == "480p" else (1280, 720)
return _wan22_square_bucket(tier, iw, ih)
def _closest_bucket(img_w, img_h, bucket_list, cover=False):
"""
Pick the best (bw,bh) from bucket_list for this image.
@@ -1578,22 +1830,26 @@ def _resize_to_explicit_resolution(img, out_w, out_h, match_mode="crop_to_match"
_WAN22_VALID_RES = {
(832, 480), (480, 832),
(1280, 720), (720, 1280),
(624, 624), (720, 720),
(624, 624), (1024, 1024),
}
def _wan22_is_valid_dim(w, h):
return (w, h) in _WAN22_VALID_RES
def _wan22_pick_bucket(iw, ih, tier, crop_to_fit):
def _wan22_pick_bucket(iw, ih, tier, crop_to_fit, aspect_mode="Auto"):
if tier == "Safe Auto":
tier = "Auto"
if aspect_mode in ("Tall", "Wide", "Square"):
return _wan22_oriented_bucket(tier, aspect_mode, iw, ih)
is_squareish = _is_squareish(iw, ih)
is_landscape = iw >= ih
# --- Square handling ---
if is_squareish:
if tier == "720p":
return (720, 720)
return (624, 624)
if is_squareish or (crop_to_fit and _is_auto_square_candidate(iw, ih)):
return _wan22_square_bucket(tier, iw, ih)
# --- Explicit tiers ---
if tier == "480p":
@@ -1615,7 +1871,7 @@ def _wan22_pick_bucket(iw, ih, tier, crop_to_fit):
return _closest_bucket(iw, ih, buckets_480 if scale_to_480 <= scale_to_720 else buckets_720, cover=crop_to_fit)
def _wan22_scale_image_core(image, tier="Auto", crop_to_fit=False):
def _wan22_scale_image_core(image, tier="Auto", crop_to_fit=False, aspect_mode="Auto"):
"""
Shared WAN 2.2 scaler core.
Returns (scaled_image, out_w, out_h, did_passthrough).
@@ -1639,7 +1895,7 @@ def _wan22_scale_image_core(image, tier="Auto", crop_to_fit=False):
"WAN 2.2 works best around:\n"
" - 480p tier ~= 832x480 (or 480x832)\n"
" - 720p tier ~= 1280x720 (or 720x1280)\n"
" - Squares: 624x624 or 720x720\n\n"
" - Squares: 624x624 or 1024x1024\n\n"
"Please use a source closer to 480p/720p, or first process it "
"through your WAN 2.2 workflow. This ensures extend runs without mismatch."
)
@@ -1647,12 +1903,7 @@ def _wan22_scale_image_core(image, tier="Auto", crop_to_fit=False):
tier = "Auto"
# --- Normal path (Auto / 480p / 720p) ---
bw, bh = _wan22_pick_bucket(iw, ih, tier, crop_to_fit)
is_squareish = _is_squareish(iw, ih)
if is_squareish:
crop_to_fit = False
bw, bh = _wan22_pick_bucket(iw, ih, tier, crop_to_fit, aspect_mode=aspect_mode)
if crop_to_fit:
bw, bh = _safe_hw(_ceil16(bw), _ceil16(bh))
out = _resize_then_center_crop(image, bw, bh)
@@ -1733,7 +1984,7 @@ class WAN22_I2V_Image_Scaler_MXD:
- Crop (no pad): resize-to-cover then center-crop to exact target.
- Square handling:
* Auto & 480p: ~square → 624×624
* 720p: ~square → 720×720
* 720p: ~square -> 1024x1024
- “Safe Auto”:
* If input is already a valid WAN 2.2 bucket, passthrough.
* If input is far outside 480p–720p range, error early.
@@ -1757,6 +2008,10 @@ class WAN22_I2V_Image_Scaler_MXD:
"label_on": "Perfect Fit (Crops Edges)",
"label_off": "Closest Fit (No Crop)"
}),
"aspect_mode": (["Auto", "Tall", "Wide", "Square"], {
"default": "Auto",
"tooltip": "Auto picks wide/tall/square from the source. Use Square/Tall/Wide to force the target bucket shape."
}),
}
}
@@ -1770,7 +2025,7 @@ class WAN22_I2V_Image_Scaler_MXD:
# --- Square handling ---
if is_squareish:
if tier == "720p":
return (720, 720)
return (1024, 1024)
else:
return (624, 624)
@@ -1796,10 +2051,15 @@ class WAN22_I2V_Image_Scaler_MXD:
# -----------------------------
# Main function
# -----------------------------
def scale(self, image, tier="Auto", crop_to_fit=False):
def scale(self, image, tier="Auto", crop_to_fit=False, aspect_mode="Auto"):
# Keep legacy "Safe Auto" values from old workflows working, but expose only one Auto in UI.
internal_tier = "Safe Auto" if tier == "Auto" else tier
out, _, _, _ = _wan22_scale_image_core(image, tier=internal_tier, crop_to_fit=crop_to_fit)
out, _, _, _ = _wan22_scale_image_core(
image,
tier=internal_tier,
crop_to_fit=crop_to_fit,
aspect_mode=aspect_mode,
)
return (out,)
_, ih, iw, _ = image.shape
@@ -1821,7 +2081,7 @@ class WAN22_I2V_Image_Scaler_MXD:
"WAN 2.2 works best around:\n"
" • 480p tier ≈ 832×480 (or 480×832)\n"
" • 720p tier ≈ 1280×720 (or 720×1280)\n"
" • Squares: 624×624 or 720×720\n\n"
" • Squares: 624×624 or 1024×1024\n\n"
"Please use a source closer to 480p/720p, or first process it "
"through your WAN 2.2 workflow. This ensures extend runs without mismatch."
)
@@ -1891,7 +2151,7 @@ class WAN22_I2V_Match_Resolution_MXD:
"Valid WAN 2.2 buckets are:\n"
" - 832x480 / 480x832\n"
" - 1280x720 / 720x1280\n"
" - 624x624 / 720x720\n\n"
" - 624x624 / 1024x1024\n\n"
"Recommended workflow:\n"
" 1. Scale the first image with 'Image Scaler Wan 2.2 I2V MXD'\n"
" 2. Use this node to match the second image to the scaled first image"
@@ -2127,13 +2387,13 @@ if HAVE_COMFY_API:
"""
Prepare a source video for iterative WAN 2.2 extension:
- scale entire video using WAN bucket logic
- output start/end frames from the full scaled video
- output the scaled frame batch directly
- keep default workflow simple for common use
"""
CATEGORY = "MXD/video"
FUNCTION = "prepare"
RETURN_TYPES = ("VIDEO", "IMAGE", "IMAGE", "INT", "INT", "FLOAT")
RETURN_NAMES = ("scaled_video", "start_image", "end_image", "width", "height", "fps")
RETURN_TYPES = ("VIDEO", "IMAGE", "FLOAT")
RETURN_NAMES = ("scaled_video", "images", "fps")
@classmethod
def INPUT_TYPES(cls):
@@ -2146,21 +2406,27 @@ if HAVE_COMFY_API:
"label_on": "Perfect Fit (Crops Edges)",
"label_off": "Closest Fit (No Crop)"
}),
"fps_mode": (["none", "force"], {
"default": "none",
"tooltip": "none = keep source fps. force = resample frames (drop/duplicate) and set exact target fps."
"force_fps": ("BOOLEAN", {
"default": False,
"label_on": "Force FPS",
"label_off": "Keep Source FPS",
"tooltip": "When enabled, resample frames (drop/duplicate) and set exact target fps."
}),
"target_fps": ("FLOAT", {
"default": 16.0,
"min": 0.001,
"max": 1000.0,
"step": 0.01,
"tooltip": "Used when fps_mode=force. Output video fps will be set exactly to this value."
"target_fps": ("INT", {
"default": 16,
"min": 1,
"max": 1000,
"step": 1,
"tooltip": "Used when Force FPS is enabled. Output video fps will be set exactly to this value."
}),
"aspect_mode": (["Auto", "Tall", "Wide", "Square"], {
"default": "Auto",
"tooltip": "Auto picks wide/tall/square from the source. Use Square/Tall/Wide to force the target bucket shape."
}),
},
}
def prepare(self, video, tier="Auto", crop_to_fit=True, fps_mode="none", target_fps=16.0):
def prepare(self, video, tier="Auto", crop_to_fit=True, force_fps=False, target_fps=16, aspect_mode="Auto"):
comp = video.get_components()
if isinstance(comp.images, list):
if len(comp.images) == 0:
@@ -2179,7 +2445,7 @@ if HAVE_COMFY_API:
raise ValueError("[WAN22_I2V_Video_Prep_MXD] Input video has zero frames.")
out_frame_rate = float(comp.frame_rate) if comp.frame_rate is not None else None
if fps_mode == "force":
if force_fps:
frames, out_frame_rate, _ = _resample_video_frames_to_fps(
frames, comp.frame_rate, target_fps
)
@@ -2187,13 +2453,13 @@ if HAVE_COMFY_API:
# "Auto" in video prep uses the safer extend-friendly behavior.
# Keep accepting legacy "Safe Auto" values from older saved workflows.
internal_tier = "Safe Auto" if tier == "Auto" else tier
scaled_frames, out_w, out_h, _ = _wan22_scale_image_core(
frames, tier=internal_tier, crop_to_fit=crop_to_fit
scaled_frames, _, _, _ = _wan22_scale_image_core(
frames,
tier=internal_tier,
crop_to_fit=crop_to_fit,
aspect_mode=aspect_mode,
)
start_image = scaled_frames[0:1].clone()
end_image = scaled_frames[-1:].clone()
scaled_video = VideoFromComponents(
VideoComponents(
images=scaled_frames,
@@ -2203,92 +2469,7 @@ if HAVE_COMFY_API:
)
fps = float(out_frame_rate) if out_frame_rate is not None else 0.0
return (scaled_video, start_image, end_image, out_w, out_h, fps)
class WAN22_I2V_Video_Prep_Advanced_MXD:
"""
Advanced variant of WAN22_I2V_Video_Prep_MXD with frame-selection controls.
"""
CATEGORY = "MXD/video"
FUNCTION = "prepare"
RETURN_TYPES = ("VIDEO", "IMAGE", "IMAGE", "IMAGE", "INT", "INT", "FLOAT")
RETURN_NAMES = ("scaled_video", "selected_frames", "start_image", "end_image", "width", "height", "fps")
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"video": ("VIDEO",),
"tier": (["Auto", "480p", "720p"], {"default": "Auto"}),
"crop_to_fit": ("BOOLEAN", {
"default": True,
"label_on": "Perfect Fit (Crops Edges)",
"label_off": "Closest Fit (No Crop)"
}),
"fps_mode": (["none", "force"], {
"default": "none",
"tooltip": "none = keep source fps. force = resample frames (drop/duplicate) and set exact target fps."
}),
"target_fps": ("FLOAT", {
"default": 16.0,
"min": 0.001,
"max": 1000.0,
"step": 0.01,
"tooltip": "Used when fps_mode=force. Output video fps will be set exactly to this value."
}),
"mode": (["start", "end"], {"default": "end"}),
"count": ("INT", {"default": 1, "min": 1, "max": 10000}),
"offset": ("INT", {"default": 1, "min": 1, "max": 10000}),
},
}
def prepare(self, video, tier="Auto", crop_to_fit=True, fps_mode="none", target_fps=16.0, mode="end", count=1, offset=1):
comp = video.get_components()
if isinstance(comp.images, list):
if len(comp.images) == 0:
raise ValueError("[WAN22_I2V_Video_Prep_Advanced_MXD] Input video has zero frames.")
frames = torch.stack(comp.images)
else:
frames = comp.images
if frames is None:
raise ValueError("[WAN22_I2V_Video_Prep_Advanced_MXD] Input video has no frames.")
if frames.ndim == 3:
frames = frames.unsqueeze(0)
if frames.ndim != 4:
raise ValueError(f"[WAN22_I2V_Video_Prep_Advanced_MXD] Unexpected frame tensor shape: {tuple(frames.shape)}")
if frames.shape[0] <= 0:
raise ValueError("[WAN22_I2V_Video_Prep_Advanced_MXD] Input video has zero frames.")
out_frame_rate = float(comp.frame_rate) if comp.frame_rate is not None else None
if fps_mode == "force":
frames, out_frame_rate, _ = _resample_video_frames_to_fps(
frames, comp.frame_rate, target_fps
)
# "Auto" in video prep uses the safer extend-friendly behavior.
# Keep accepting legacy "Safe Auto" values from older saved workflows.
internal_tier = "Safe Auto" if tier == "Auto" else tier
scaled_frames, out_w, out_h, _ = _wan22_scale_image_core(
frames, tier=internal_tier, crop_to_fit=crop_to_fit
)
selected_frames = _select_frames_start_end(
scaled_frames, count=count, offset=offset, mode=mode
)
start_image = selected_frames[0:1].clone()
end_image = selected_frames[-1:].clone()
scaled_video = VideoFromComponents(
VideoComponents(
images=scaled_frames,
audio=comp.audio,
frame_rate=out_frame_rate,
)
)
fps = float(out_frame_rate) if out_frame_rate is not None else 0.0
return (scaled_video, selected_frames, start_image, end_image, out_w, out_h, fps)
return (scaled_video, scaled_frames, fps)
# ---------- Load Video MXD (video-only picker with refresh) ----------
class LoadVideoMXD:
@@ -2607,31 +2788,33 @@ if HAVE_COMFY_API:
# ============================================================
# LTX Video Image Scaler MXD
# ============================================================
# LTX Video requires all dimensions to be multiples of 32.
# Tiers: 480p / 768 / 1024 (or Auto to pick nearest by area)
# Fit (no pad): proportional resize <= target, /32 aligned.
# Official LTX-2.3 rules (Lightricks model card + example workflows):
# - Width & height must be divisible by 32; frame count must be 8n+1.
# - The distilled two-stage workflow generates Stage 1 low-res, then the
# ltx-2.3-spatial-upscaler-x2 doubles it (exactly 2x) for Stage 2.
# - The one published two-stage resolution is Stage 1 960x544 -> 1920x1088.
#
# Tiers below are FINAL (Stage 2) sizes; Stage 1 is exactly half. Finals are
# kept /64 so Stage 1 stays /32 (the latent constraint). Only the 1080p 16:9
# row is officially published by Lightricks; the portrait/square rows and the
# 720p/576p tiers are /32-aligned siblings at the same pixel budget.
#
# Buckets (FINAL size, all /64) -> Stage 1 (half, all /32):
# 1080p: 1920x1088 / 1088x1920 / 1408x1408 (Stage 1: 960x544 / 544x960 / 704x704)
# 720p: 1280x704 / 704x1280 / 960x960 (Stage 1: 640x352 / 352x640 / 480x480)
# 576p: 1024x576 / 576x1024 / 768x768 (Stage 1: 512x288 / 288x512 / 384x384)
#
# Fit (no pad): proportional resize <= target, /64 aligned.
# Crop (no pad): resize-to-cover then center-crop to exact bucket.
# Square images map to each tier's square bucket.
# Buckets (all /32):
# 480p: 832x480 / 480x832 / 512x512
# 768: 1280x768 / 768x1280 / 768x768
# 1024: 1792x1024 / 1024x1792 / 1024x1024
# ============================================================
_LTX_BUCKETS = {
"480p": {"landscape": (832, 480), "portrait": (480, 832), "square": (512, 512)},
"768": {"landscape": (1280, 768), "portrait": (768, 1280), "square": (768, 768)},
"1024": {"landscape": (1792, 1024), "portrait": (1024, 1792), "square": (1024, 1024)},
"1080p": {"landscape": (1920, 1088), "portrait": (1088, 1920), "square": (1408, 1408)},
"720p": {"landscape": (1280, 704), "portrait": (704, 1280), "square": (960, 960)},
"576p": {"landscape": (1024, 576), "portrait": (576, 1024), "square": (768, 768)},
}
_LTX_TIER_AREAS = {
"480p": 832 * 480, # 399,360
"768": 1280 * 768, # 983,040
"1024": 1792 * 1024, # 1,835,008
}
_LTX_VALID_RES = {b for t in _LTX_BUCKETS.values() for b in t.values()}
def _ceil32(x):
x = (int(x) + 31) // 32 * 32
@@ -2643,16 +2826,22 @@ def _floor32(x):
return max(32, x)
def _ltx_is_valid_res(w, h):
return (w, h) in _LTX_VALID_RES
def _floor64(x):
x = int(x) // 64 * 64
return max(64, x)
def _ltx_stage1_dims(final_w, final_h):
"""Return Stage 1 dimensions that upscale exactly to the final size."""
return max(32, int(final_w) // 2), max(32, int(final_h) // 2)
def _ltx_resize_fit_inside(img, out_w, out_h):
"""Resize to fit inside (out_w, out_h), output /32 aligned on both sides."""
"""Resize to fit inside (out_w, out_h), output /64 aligned on both sides."""
_, ih, iw, _ = img.shape
s = min(out_w / iw, out_h / ih)
tw = _floor32(iw * s)
th = _floor32(ih * s)
tw = _floor64(iw * s)
th = _floor64(ih * s)
tw = max(32, min(tw, nodes.MAX_RESOLUTION))
th = max(32, min(th, nodes.MAX_RESOLUTION))
resized = comfy.utils.common_upscale(img.movedim(-1, 1), tw, th, "bilinear", "center").movedim(1, -1)
@@ -2671,12 +2860,6 @@ def _ltx_resize_then_center_crop(img, out_w, out_h):
return tmp[:, y0:y0+out_h, x0:x0+out_w, :]
def _ltx_pick_tier_auto(iw, ih):
"""Pick the LTX tier whose reference area is closest to the input area."""
area = iw * ih
return min(_LTX_TIER_AREAS, key=lambda t: abs(area - _LTX_TIER_AREAS[t]))
def _ltx_pick_bucket(iw, ih, tier):
"""Pick the landscape / portrait / square bucket for the given tier."""
tier_map = _LTX_BUCKETS[tier]
@@ -2685,35 +2868,13 @@ def _ltx_pick_bucket(iw, ih, tier):
return tier_map["landscape"] if iw >= ih else tier_map["portrait"]
def _ltx_scale_image_core(image, tier="Auto", crop_to_fit=True):
def _ltx_scale_image_core(image, tier="1080p", crop_to_fit=True):
"""
Core LTX scaler. Returns (scaled_image, out_w, out_h, passthrough).
passthrough=True only when Safe Auto detects an already-valid resolution.
Core LTX scaler. Returns (scaled_image, final_w, final_h, stage1_w, stage1_h).
'tier' is the FINAL (Stage 2) size budget; Stage 1 is exactly half.
"""
_, ih, iw, _ = image.shape
if tier == "Safe Auto":
if _ltx_is_valid_res(iw, ih):
return image, iw, ih, True
area = iw * ih
min_area = int(_LTX_TIER_AREAS["480p"] * 0.5)
max_area = int(_LTX_TIER_AREAS["1024"] * 1.8)
if area < min_area or area > max_area:
size_label = "small" if area < min_area else "large"
raise ValueError(
f"[LTX_Image_Scaler_MXD] Input {iw}x{ih} is too {size_label} for LTX Video buckets.\n"
"LTX Video works best around:\n"
" - 480p tier: 832x480 / 480x832 / 512x512\n"
" - 768 tier: 1280x768 / 768x1280 / 768x768\n"
" - 1024 tier: 1792x1024 / 1024x1792 / 1024x1024\n\n"
"Use a source image closer to one of these tiers, or process it "
"through your LTX workflow first."
)
tier = "Auto"
if tier == "Auto":
tier = _ltx_pick_tier_auto(iw, ih)
bw, bh = _ltx_pick_bucket(iw, ih, tier)
if _is_squareish(iw, ih):
@@ -2724,25 +2885,32 @@ def _ltx_scale_image_core(image, tier="Auto", crop_to_fit=True):
else:
out, bw, bh = _ltx_resize_fit_inside(image, bw, bh)
return out, int(out.shape[2]), int(out.shape[1]), False
final_w = int(out.shape[2])
final_h = int(out.shape[1])
stage1_w, stage1_h = _ltx_stage1_dims(final_w, final_h)
return out, final_w, final_h, stage1_w, stage1_h
class LTX_Image_Scaler_MXD:
"""
MXD Image Scaler for LTX Video — all outputs are multiples of 32.
MXD Image Scaler for LTX Video (distilled two-stage workflow).
Tiers:
Auto — picks the tier whose area is closest to the input.
480p — targets 832x480 / 480x832 / 512x512.
768 — targets 1280x768 / 768x1280 / 768x768.
1024 — targets 1792x1024 / 1024x1792 / 1024x1024.
'tier' is the FINAL (Stage 2) size; Stage 1 is exactly half. Finals are /64
so Stage 1 stays /32 (the LTX latent constraint). Wire stage1_width /
stage1_height into the empty latent for the low-res pass; the spatial
upscaler-x2 then doubles it back to the final size.
Tiers (final / Stage 1):
1080p 1920x1088 (official 16:9) / 1088x1920 / 1408x1408 -> half
720p 1280x704 / 704x1280 / 960x960 -> half
576p 1024x576 / 576x1024 / 768x768 -> half
Modes:
Perfect Fit (Crops Edges) resize-to-cover + center-crop to exact bucket size.
Closest Fit (No Crop) proportional resize, /32-aligned; may be smaller than bucket.
Perfect Fit (Crops Edges) resize-to-cover + center-crop to exact bucket.
Closest Fit (No Crop) proportional resize, /64-aligned; may be smaller.
Square images (aspect ratio within +-3% of 1:1) map to the tier's square bucket.
Returns image + width + height so downstream nodes can read the final dims directly.
Square images (within +-3% of 1:1) map to the tier's square bucket.
Outputs the scaled image at final size plus the Stage 1 dimensions.
"""
TITLE = "LTX Video Image Scaler MXD"
@@ -2756,19 +2924,117 @@ class LTX_Image_Scaler_MXD:
return {
"required": {
"image": ("IMAGE",),
"tier": (["Auto", "480p", "768", "1024"], {"default": "Auto"}),
"tier": (["1080p", "720p", "576p"], {"default": "1080p"}),
"crop_to_fit": ("BOOLEAN", {
"default": True,
"label_on": "Perfect Fit (Crops Edges)",
"label_on": "Crop Edges",
"label_off": "Closest Fit (No Crop)",
}),
}
}
def scale(self, image, tier="Auto", crop_to_fit=True):
def scale(self, image, tier="1080p", crop_to_fit=True):
image = _validate_image_batch_4d(image, "LTX_Image_Scaler_MXD", "image")
out, ow, oh, _ = _ltx_scale_image_core(image, tier=tier, crop_to_fit=crop_to_fit)
return (out, ow, oh)
out, _final_w, _final_h, stage1_w, stage1_h = _ltx_scale_image_core(
image, tier=tier, crop_to_fit=crop_to_fit
)
return (out, stage1_w, stage1_h)
class PadImageForOutpaintingMXD:
SEARCH_ALIASES = ["extend canvas", "expand image", "outpaint pad"]
RETURN_TYPES = ("IMAGE", "MASK")
FUNCTION = "expand_image"
CATEGORY = "image/transform"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"left": ("INT", {"default": 0, "min": 0, "max": nodes.MAX_RESOLUTION, "step": 2}),
"top": ("INT", {"default": 0, "min": 0, "max": nodes.MAX_RESOLUTION, "step": 2}),
"right": ("INT", {"default": 0, "min": 0, "max": nodes.MAX_RESOLUTION, "step": 2}),
"bottom": ("INT", {"default": 0, "min": 0, "max": nodes.MAX_RESOLUTION, "step": 2}),
"round_to": (["None", "2", "8", "16", "32", "64"], {"default": "16"}),
}
}
@staticmethod
def _nearest_multiple(value: int, multiple: int, padded: bool) -> int:
if multiple <= 1 or value % multiple == 0:
return value
lower = (value // multiple) * multiple
upper = lower + multiple
if lower <= 0:
return upper
if not padded:
return lower
return lower if value - lower <= upper - value else upper
@staticmethod
def _axis_plan(size: int, before: int, after: int, multiple: int) -> Tuple[int, int, int, int, int]:
target = size + before + after
if multiple > 1:
target = PadImageForOutpaintingMXD._nearest_multiple(target, multiple, before + after > 0)
delta = target - (size + before + after)
if delta < 0:
remove = -delta
from_after = min(after, remove)
after -= from_after
remove -= from_after
from_before = min(before, remove)
before -= from_before
remove -= from_before
crop_before = remove // 2
crop_after = remove - crop_before
else:
crop_before = 0
crop_after = 0
if before > 0 and after > 0:
add_before = delta // 2
before += add_before
after += delta - add_before
elif before > 0:
before += delta
else:
after += delta
final_size = size - crop_before - crop_after + before + after
if final_size <= 0:
raise ValueError("[PadImageForOutpaintingMXD] Rounding removed the full image on one axis.")
return before, after, crop_before, crop_after, final_size
def expand_image(self, image, left, top, right, bottom, round_to="16"):
image = _validate_image_batch_4d(image, "PadImageForOutpaintingMXD", "image")
batch, height, width, channels = image.size()
multiple = 1 if round_to == "None" else int(round_to)
left, right, crop_left, crop_right, final_width = self._axis_plan(width, left, right, multiple)
top, bottom, crop_top, crop_bottom, final_height = self._axis_plan(height, top, bottom, multiple)
cropped = image[:, crop_top:height - crop_bottom, crop_left:width - crop_right, :]
crop_height = cropped.shape[1]
crop_width = cropped.shape[2]
new_image = torch.full(
(batch, final_height, final_width, channels),
0.5,
dtype=image.dtype,
device=image.device,
)
new_image[:, top:top + crop_height, left:left + crop_width, :] = cropped
mask = torch.ones(
(final_height, final_width),
dtype=torch.float32,
device=image.device,
)
mask[top:top + crop_height, left:left + crop_width] = 0.0
return (new_image, mask.unsqueeze(0))
# ---------- Node registration ----------
@@ -2781,19 +3047,22 @@ NODE_CLASS_MAPPINGS = {
"SaveLatent_I2V_MXD": SaveLatent_I2V_MXD,
"LoadLatent_I2V_MXD": LoadLatent_I2V_MXD,
"LoadLatents_FromFolder_I2V_MXD": LoadLatents_FromFolder_I2V_MXD,
"SaveLatent_VACE22_MXD": SaveLatent_VACE22_MXD,
"LoadLatent_VACE22_MXD": LoadLatent_VACE22_MXD,
"LoadLatents_FromFolder_VACE22_MXD": LoadLatents_FromFolder_VACE22_MXD,
"WAN22_I2V_Image_Scaler_MXD": WAN22_I2V_Image_Scaler_MXD,
"LTX_Image_Scaler_MXD": LTX_Image_Scaler_MXD,
"WAN22_I2V_Match_Resolution_MXD": WAN22_I2V_Match_Resolution_MXD,
"Frames_Remove_From_Start_MXD": Frames_Remove_From_Start_MXD,
"GroupVideoFramesMXD": GroupVideoFramesMXD,
"Frames_Select_StartEnd_MXD": Frames_Select_StartEnd_MXD,
"PadImageForOutpaintingMXD": PadImageForOutpaintingMXD,
}
if HAVE_COMFY_API:
NODE_CLASS_MAPPINGS.update({
"Wan22ImageToVideoMXD": Wan22ImageToVideoMXD,
"WAN22_I2V_Video_Prep_MXD": WAN22_I2V_Video_Prep_MXD,
"WAN22_I2V_Video_Prep_Advanced_MXD": WAN22_I2V_Video_Prep_Advanced_MXD,
"CombineVideos_MXD": CombineVideos_MXD,
"LoadVideoMXD": LoadVideoMXD,
"SaveVideoMXD": SaveVideoMXD,
@@ -2810,19 +3079,22 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"SaveLatent_I2V_MXD": "Save Latent I2V MXD",
"LoadLatent_I2V_MXD": "Load Latent I2V MXD",
"LoadLatents_FromFolder_I2V_MXD": "Load Latent Batch I2V MXD",
"SaveLatent_VACE22_MXD": "Save Latent Vace 2.2 MXD",
"LoadLatent_VACE22_MXD": "Load Latent Vace 2.2 MXD",
"LoadLatents_FromFolder_VACE22_MXD": "Load Latent Batch Vace 2.2 MXD",
"WAN22_I2V_Image_Scaler_MXD": "Image Scaler Wan 2.2 I2V MXD",
"LTX_Image_Scaler_MXD": "LTX Video Image Scaler MXD",
"WAN22_I2V_Match_Resolution_MXD": "Match Resolution Wan 2.2 I2V MXD",
"Frames_Remove_From_Start_MXD": "Remove Frames From Start MXD",
"GroupVideoFramesMXD": "Group Video Frames MXD",
"Frames_Select_StartEnd_MXD": "Select Frames MXD",
"PadImageForOutpaintingMXD": "Pad Image for Outpainting MXD",
}
if HAVE_COMFY_API:
NODE_DISPLAY_NAME_MAPPINGS.update({
"Wan22ImageToVideoMXD": "Wan 2.2 Image to Video MXD",
"WAN22_I2V_Video_Prep_MXD": "WAN 2.2 Video Prep I2V MXD",
"WAN22_I2V_Video_Prep_Advanced_MXD": "WAN 2.2 Video Prep I2V MXD Advanced",
"CombineVideos_MXD": "Combine Videos MXD",
"LoadVideoMXD": "Load Video MXD",
"SaveVideoMXD": "Save Video MXD",
+2
View File
@@ -3,4 +3,6 @@ import './js/image_comparer.js';
import './addons/zip_loader/js/zip_loader.js';
import './loraloader_mxd_entry.js';
import './mxd_character_prompts.js';
import './ltx_sampler_mxd.js';
import './wan22_video_prep_mxd.js';
+266
View File
@@ -0,0 +1,266 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const LTX_SAMPLER_NODE_TYPES = new Set(["LTXKSampler_MXD", "LTXKSampler2_MXD"]);
const CUSTOM_SIGMAS_MODE = "Custom Sigmas";
const ltxPreviewImages = {};
const ltxPreviewTimers = {};
const ltxPreviewPaused = {};
const ltxPreviewAutoPaused = {};
const textDecoder = new TextDecoder();
function getWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function hideWidget(widget) {
if (!widget._mxdOriginalComputeSize) {
widget._mxdOriginalComputeSize = widget.computeSize;
}
widget.hidden = true;
widget.disabled = true;
widget.computeSize = () => [0, -4];
}
function showWidget(widget) {
widget.hidden = false;
widget.disabled = false;
if (widget._mxdOriginalComputeSize) {
widget.computeSize = widget._mxdOriginalComputeSize;
}
}
function resizeNodeToWidgets(node) {
if (!node.computeSize || !node.setSize) {
return;
}
const computed = node.computeSize();
const currentWidth = node.size?.[0] ?? computed[0];
node.setSize([Math.max(currentWidth, computed[0]), computed[1]]);
}
function updateCustomSigmasVisibility(node) {
const modeWidget = getWidget(node, "mode");
const sigmasWidget = getWidget(node, "custom_sigmas");
if (!modeWidget || !sigmasWidget) {
return;
}
if (modeWidget.value === CUSTOM_SIGMAS_MODE) {
showWidget(sigmasWidget);
} else {
hideWidget(sigmasWidget);
}
resizeNodeToWidgets(node);
app.canvas?.setDirty(true, true);
}
function getNodeById(id) {
return app.graph?._nodes_by_id?.[id] ?? app.graph?.getNodeById?.(id);
}
function updatePauseButton(id, buttonEl) {
if (!buttonEl) {
return;
}
if (ltxPreviewPaused[id]) {
buttonEl.textContent = "Play";
buttonEl.title = "Resume latent preview playback";
} else {
buttonEl.textContent = "Pause";
buttonEl.title = "Pause latent preview playback";
}
}
function setLatentPreviewPaused(id, paused) {
ltxPreviewPaused[id] = paused;
const node = getNodeById(id);
const widget = node ? getWidget(node, "ltxlatentpreview") : null;
updatePauseButton(id, widget?.pauseEl);
}
function getPreviewContext(id, width, height) {
const node = getNodeById(id);
if (!node) {
return null;
}
let widget = getWidget(node, "ltxlatentpreview");
if (!widget) {
const previewEl = document.createElement("div");
previewEl.style.width = "100%";
previewEl.style.position = "relative";
const canvasEl = document.createElement("canvas");
canvasEl.style.width = "100%";
canvasEl.style.display = "block";
previewEl.appendChild(canvasEl);
const pauseEl = document.createElement("button");
pauseEl.textContent = "Pause";
pauseEl.style.position = "absolute";
pauseEl.style.right = "6px";
pauseEl.style.bottom = "6px";
pauseEl.style.padding = "1px 6px";
pauseEl.style.fontSize = "11px";
pauseEl.style.lineHeight = "1.2";
pauseEl.style.opacity = "0.85";
pauseEl.style.cursor = "pointer";
previewEl.appendChild(pauseEl);
widget = node.addDOMWidget("ltxlatentpreview", "ltxcanvas", previewEl, {
serialize: false,
hideOnZoom: false,
});
widget.serialize = false;
widget.canvasEl = canvasEl;
widget.pauseEl = pauseEl;
widget.computeSize = function (availableWidth) {
if (!this.aspectRatio) {
return [availableWidth, -4];
}
return [availableWidth, (node.size[0] - 20) / this.aspectRatio + 10];
};
pauseEl.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
}, true);
pauseEl.addEventListener("click", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
setLatentPreviewPaused(id, !ltxPreviewPaused[id]);
}, true);
}
updatePauseButton(id, widget.pauseEl);
const canvasEl = widget.canvasEl || widget.element;
if (canvasEl.width !== width || canvasEl.height !== height) {
widget.aspectRatio = width / height;
canvasEl.width = width;
canvasEl.height = height;
resizeNodeToWidgets(node);
}
return canvasEl.getContext("2d");
}
function beginLatentPreview(id, rate) {
clearInterval(ltxPreviewTimers[id]);
let displayIndex = 0;
ltxPreviewAutoPaused[id] = false;
setLatentPreviewPaused(id, false);
const startNode = getNodeById(id);
if (startNode) {
startNode.progress = 0;
}
ltxPreviewTimers[id] = setInterval(() => {
const node = getNodeById(id);
if (!node) {
clearInterval(ltxPreviewTimers[id]);
delete ltxPreviewTimers[id];
delete ltxPreviewAutoPaused[id];
return;
}
if (node.progress == null) {
if (!ltxPreviewAutoPaused[id]) {
ltxPreviewAutoPaused[id] = true;
setLatentPreviewPaused(id, true);
}
} else {
ltxPreviewAutoPaused[id] = false;
}
if (ltxPreviewPaused[id]) {
return;
}
const images = ltxPreviewImages[id];
const image = images?.[displayIndex];
if (!image) {
return;
}
getPreviewContext(id, image.width, image.height)?.drawImage(image, 0, 0);
displayIndex = (displayIndex + 1) % images.length;
app.canvas?.setDirty(true, true);
}, 1000 / Math.max(1, rate || 8));
}
api.addEventListener("VHS_latentpreview", ({ detail }) => {
if (detail.id == null) {
return;
}
ltxPreviewImages[detail.id] = [];
ltxPreviewImages[detail.id].length = detail.length;
const idParts = String(detail.id).split(":");
for (let i = 1; i <= idParts.length; i++) {
const id = idParts.slice(0, i).join(":");
ltxPreviewImages[id] = ltxPreviewImages[detail.id];
beginLatentPreview(id, detail.rate);
}
});
api.addEventListener("b_preview", async (event) => {
if (Object.keys(ltxPreviewTimers).length === 0) {
return;
}
const header = new DataView(await event.detail.slice(0, 24).arrayBuffer());
const index = header.getUint32(4);
const idLength = header.getUint8(8);
const id = textDecoder.decode(header.buffer.slice(9, 9 + idLength));
const images = ltxPreviewImages[id];
if (!images) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
images[index] = await window.createImageBitmap(event.detail.slice(24));
}, true);
app.registerExtension({
name: "ComfyUI-MaxedOut.LTXSamplerMXD",
beforeRegisterNodeDef(nodeType, nodeData) {
if (!LTX_SAMPLER_NODE_TYPES.has(nodeData.name)) {
return;
}
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
const node = this;
const modeWidget = getWidget(node, "mode");
if (modeWidget && !modeWidget._mxdLtxCallbackWrapped) {
const originalCallback = modeWidget.callback;
modeWidget.callback = function () {
const callbackResult = originalCallback?.apply(this, arguments);
updateCustomSigmasVisibility(node);
return callbackResult;
};
modeWidget._mxdLtxCallbackWrapped = true;
}
updateCustomSigmasVisibility(node);
return result;
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const result = onConfigure?.apply(this, arguments);
requestAnimationFrame(() => updateCustomSigmasVisibility(this));
return result;
};
},
});
+26 -26
View File
@@ -1,4 +1,4 @@
:not(#fakeid) .rgthree-button-reset {
:not(#fakeid) .mxd-button-reset {
position: relative;
appearance: none;
cursor: pointer;
@@ -9,7 +9,7 @@
margin: 0;
}
:not(#fakeid) .rgthree-button {
:not(#fakeid) .mxd-button {
--padding-top: 7px;
--padding-bottom: 9px;
--padding-x: 16px;
@@ -34,7 +34,7 @@
align-items: center;
justify-content: center;
}
:not(#fakeid) .rgthree-button::before, :not(#fakeid) .rgthree-button::after {
:not(#fakeid) .mxd-button::before, :not(#fakeid) .mxd-button::after {
content: "";
display: block;
position: absolute;
@@ -47,55 +47,55 @@
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.06), rgba(0, 0, 0, 0.15));
mix-blend-mode: screen;
}
:not(#fakeid) .rgthree-button::after {
:not(#fakeid) .mxd-button::after {
mix-blend-mode: multiply;
}
:not(#fakeid) .rgthree-button:hover {
:not(#fakeid) .mxd-button:hover {
background: #303030;
}
:not(#fakeid) .rgthree-button:active {
:not(#fakeid) .mxd-button:active {
box-shadow: 0px 0px 0px rgba(0, 0, 0, 0);
background: #121212;
padding: calc(var(--padding-top) + 1px) calc(var(--padding-x) - 1px) calc(var(--padding-bottom) - 1px) calc(var(--padding-x) + 1px);
}
:not(#fakeid) .rgthree-button:active::before, :not(#fakeid) .rgthree-button:active::after {
:not(#fakeid) .mxd-button:active::before, :not(#fakeid) .mxd-button:active::after {
box-shadow: 1px 1px 0px rgba(255, 255, 255, 0.15), inset 1px 1px 0px rgba(0, 0, 0, 0.5), inset 1px 3px 5px rgba(0, 0, 0, 0.33);
}
:not(#fakeid) .rgthree-button.-blue {
:not(#fakeid) .mxd-button.-blue {
background: #346599 !important;
}
:not(#fakeid) .rgthree-button.-blue:hover {
:not(#fakeid) .mxd-button.-blue:hover {
background: #3b77b8 !important;
}
:not(#fakeid) .rgthree-button.-blue:active {
:not(#fakeid) .mxd-button.-blue:active {
background: #1d5086 !important;
}
:not(#fakeid) .rgthree-button.-green {
:not(#fakeid) .mxd-button.-green {
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.06), rgba(0, 0, 0, 0.15)), #14580b;
}
:not(#fakeid) .rgthree-button.-green:hover {
:not(#fakeid) .mxd-button.-green:hover {
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.06), rgba(0, 0, 0, 0.15)), #1a6d0f;
}
:not(#fakeid) .rgthree-button.-green:active {
:not(#fakeid) .mxd-button.-green:active {
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.06)), #0f3f09;
}
:not(#fakeid) .rgthree-button[disabled] {
:not(#fakeid) .mxd-button[disabled] {
box-shadow: none;
background: #666 !important;
color: #aaa;
pointer-events: none;
}
:not(#fakeid) .rgthree-button[disabled]::before, :not(#fakeid) .rgthree-button[disabled]::after {
:not(#fakeid) .mxd-button[disabled]::before, :not(#fakeid) .mxd-button[disabled]::after {
display: none;
}
:not(#fakeid) .rgthree-comfybar-top-button-group {
:not(#fakeid) .mxd-comfybar-top-button-group {
font-size: 0;
flex: 1 1 auto;
display: flex;
align-items: stretch;
}
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button {
:not(#fakeid) .mxd-comfybar-top-button-group .mxd-comfybar-top-button {
margin: 0;
flex: 1 1;
height: 36px;
@@ -104,27 +104,27 @@
background: var(--p-button-secondary-background);
color: var(--p-button-secondary-color);
}
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button.-primary {
:not(#fakeid) .mxd-comfybar-top-button-group .mxd-comfybar-top-button.-primary {
background: var(--p-button-primary-background);
color: var(--p-button-primary-color);
}
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button::before, :not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button::after {
:not(#fakeid) .mxd-comfybar-top-button-group .mxd-comfybar-top-button::before, :not(#fakeid) .mxd-comfybar-top-button-group .mxd-comfybar-top-button::after {
border-radius: 0;
}
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button svg {
:not(#fakeid) .mxd-comfybar-top-button-group .mxd-comfybar-top-button svg {
fill: currentColor;
width: 28px;
height: 28px;
}
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button:first-of-type,
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button:first-of-type::before,
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button:first-of-type::after {
:not(#fakeid) .mxd-comfybar-top-button-group .mxd-comfybar-top-button:first-of-type,
:not(#fakeid) .mxd-comfybar-top-button-group .mxd-comfybar-top-button:first-of-type::before,
:not(#fakeid) .mxd-comfybar-top-button-group .mxd-comfybar-top-button:first-of-type::after {
border-top-left-radius: 0.33rem;
border-bottom-left-radius: 0.33rem;
}
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button:last-of-type,
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button:last-of-type::before,
:not(#fakeid) .rgthree-comfybar-top-button-group .rgthree-comfybar-top-button:last-of-type::after {
:not(#fakeid) .mxd-comfybar-top-button-group .mxd-comfybar-top-button:last-of-type,
:not(#fakeid) .mxd-comfybar-top-button-group .mxd-comfybar-top-button:last-of-type::before,
:not(#fakeid) .mxd-comfybar-top-button-group .mxd-comfybar-top-button:last-of-type::after {
border-top-right-radius: 0.33rem;
border-bottom-right-radius: 0.33rem;
}
+21 -21
View File
@@ -1,5 +1,5 @@
@charset "UTF-8";
.rgthree-dialog {
.mxd-dialog {
outline: 0;
border: 0;
border-radius: 6px;
@@ -13,21 +13,21 @@
padding: 0;
max-height: calc(100% - 32px);
}
.rgthree-dialog *, .rgthree-dialog *::before, .rgthree-dialog *::after {
.mxd-dialog *, .mxd-dialog *::before, .mxd-dialog *::after {
box-sizing: inherit;
}
.rgthree-dialog-container > * {
.mxd-dialog-container > * {
padding: 8px 16px;
}
.rgthree-dialog-container > *:first-child {
.mxd-dialog-container > *:first-child {
padding-top: 16px;
}
.rgthree-dialog-container > *:last-child {
.mxd-dialog-container > *:last-child {
padding-bottom: 16px;
}
.rgthree-dialog.-iconed::after {
.mxd-dialog.-iconed::after {
content: "";
font-size: 276px;
position: absolute;
@@ -43,68 +43,68 @@
z-index: -1;
}
.rgthree-dialog.-iconed.-help::after {
.mxd-dialog.-iconed.-help::after {
content: "🛟";
}
.rgthree-dialog.-iconed.-settings::after {
.mxd-dialog.-iconed.-settings::after {
content: "⚙️";
}
@media (max-width: 832px) {
.rgthree-dialog {
.mxd-dialog {
max-width: calc(100% - 32px);
}
}
.rgthree-dialog-container-title {
.mxd-dialog-container-title {
display: flex;
flex-direction: row;
align-items: center;
justify-content: start;
}
.rgthree-dialog-container-title > svg:first-child {
.mxd-dialog-container-title > svg:first-child {
width: 36px;
height: 36px;
margin-right: 16px;
}
.rgthree-dialog-container-title h2 {
.mxd-dialog-container-title h2 {
font-size: 1.375rem;
margin: 0;
font-weight: bold;
}
.rgthree-dialog-container-title h2 small {
.mxd-dialog-container-title h2 small {
font-size: 0.8125rem;
font-weight: normal;
opacity: 0.75;
}
.rgthree-dialog-container-content {
.mxd-dialog-container-content {
overflow: auto;
max-height: calc(100vh - 200px); /* Arbitrary height to copensate for margin, title, and footer.*/
}
.rgthree-dialog-container-content p {
.mxd-dialog-container-content p {
font-size: 0.8125rem;
margin-top: 0;
}
.rgthree-dialog-container-content ul li p {
.mxd-dialog-container-content ul li p {
margin-bottom: 4px;
}
.rgthree-dialog-container-content ul li p + p {
.mxd-dialog-container-content ul li p + p {
margin-top: 0.5em;
}
.rgthree-dialog-container-content ul li ul {
.mxd-dialog-container-content ul li ul {
margin-top: 0.5em;
margin-bottom: 1em;
}
.rgthree-dialog-container-content p code {
.mxd-dialog-container-content p code {
display: inline-block;
padding: 2px 4px;
margin: 0px 2px;
@@ -113,12 +113,12 @@
background: rgba(255, 255, 255, 0.1);
}
.rgthree-dialog-container-footer {
.mxd-dialog-container-footer {
display: flex;
align-items: center;
justify-content: center;
}
body.rgthree-dialog-open > *:not(.rgthree-dialog):not(.rgthree-top-messages-container) {
body.mxd-dialog-open > *:not(.mxd-dialog):not(.mxd-top-messages-container) {
filter: blur(5px);
}
+9 -9
View File
@@ -3,16 +3,16 @@ export class MxdDialog extends EventTarget {
constructor(options) {
super();
this.options = options;
let container = $el("div.rgthree-dialog-container");
let container = $el("div.mxd-dialog-container");
this.element = $el("dialog", {
classes: ["rgthree-dialog", options.class || ""],
classes: ["mxd-dialog", options.class || ""],
child: container,
parent: document.body,
events: {
click: (event) => {
if (!this.element.open ||
event.target === container ||
getClosestOrSelf(event.target, `.rgthree-dialog-container`) === container) {
getClosestOrSelf(event.target, `.mxd-dialog-container`) === container) {
return;
}
return this.close();
@@ -22,7 +22,7 @@ export class MxdDialog extends EventTarget {
this.element.addEventListener("close", (event) => {
this.onDialogElementClose();
});
this.titleElement = $el("div.rgthree-dialog-container-title", {
this.titleElement = $el("div.mxd-dialog-container-title", {
parent: container,
children: !options.title
? null
@@ -34,11 +34,11 @@ export class MxdDialog extends EventTarget {
: options.title
: options.title,
});
this.contentElement = $el("div.rgthree-dialog-container-content", {
this.contentElement = $el("div.mxd-dialog-container-content", {
parent: container,
child: options.content,
});
const footerEl = $el("footer.rgthree-dialog-container-footer", { parent: container });
const footerEl = $el("footer.mxd-dialog-container-footer", { parent: container });
for (const button of options.buttons || []) {
$el("button", {
text: button.label,
@@ -56,7 +56,7 @@ export class MxdDialog extends EventTarget {
if (options.closeButtonLabel !== false) {
$el("button", {
text: options.closeButtonLabel || "Close",
className: "rgthree-button",
className: "mxd-button",
parent: footerEl,
events: {
click: (e) => {
@@ -76,7 +76,7 @@ export class MxdDialog extends EventTarget {
setAttributes(this.contentElement, { children: content });
}
show() {
document.body.classList.add("rgthree-dialog-open");
document.body.classList.add("mxd-dialog-open");
this.element.showModal();
this.dispatchEvent(new CustomEvent("show"));
return this;
@@ -88,7 +88,7 @@ export class MxdDialog extends EventTarget {
this.element.close();
}
onDialogElementClose() {
document.body.classList.remove("rgthree-dialog-open");
document.body.classList.remove("mxd-dialog-open");
this.element.remove();
this.dispatchEvent(new CustomEvent("close", this.getCloseEventDetail()));
}
+16 -16
View File
@@ -18,7 +18,7 @@ const EXTENSION_BASE = new URL(".", import.meta.url).pathname.replace(/\/$/, "")
class MxdInfoDialog extends MxdDialog {
constructor(file) {
const dialogOptions = {
class: "rgthree-info-dialog",
class: "mxd-info-dialog",
title: `<h2>Loading...</h2>`,
content: "<center>Loading..</center>",
onBeforeClose: () => true,
@@ -60,7 +60,7 @@ class MxdInfoDialog extends MxdDialog {
this.setContent(this.getInfoContent());
this.setTitle(this.modelInfo?.name || this.modelInfo?.file || "Unknown");
} else if (action === "copy-trained-words") {
const selected = queryAll(".-rgthree-is-selected", target.closest("tr"));
const selected = queryAll(".-mxd-is-selected", target.closest("tr"));
const text = selected.map((el) => el.getAttribute("data-word")).join(", ");
await navigator.clipboard.writeText(text);
mxdRuntime.showMessage({
@@ -70,7 +70,7 @@ class MxdInfoDialog extends MxdDialog {
timeout: 3000,
});
} else if (action === "toggle-trained-word") {
target?.classList.toggle("-rgthree-is-selected");
target?.classList.toggle("-mxd-is-selected");
const tr = target.closest("tr");
if (tr) {
const span = query("td:first-child > *", tr);
@@ -78,7 +78,7 @@ class MxdInfoDialog extends MxdDialog {
if (!small) {
small = $el("small", { parent: span });
}
const num = queryAll(".-rgthree-is-selected", tr).length;
const num = queryAll(".-mxd-is-selected", tr).length;
small.innerHTML = num ? `${num} selected | <span role="button" data-action="copy-trained-words">Copy</span>` : "";
}
} else if (action === "edit-row") {
@@ -87,7 +87,7 @@ class MxdInfoDialog extends MxdDialog {
const input = td.querySelector("input,textarea");
if (!input) {
const fieldName = tr.dataset["fieldName"];
tr.classList.add("-rgthree-editing");
tr.classList.add("-mxd-editing");
const isTextarea = fieldName === "userNote";
const rowInput = $el(`${isTextarea ? "textarea" : 'input[type="text"]'}`, { value: td.textContent });
rowInput.addEventListener("keydown", (evt) => {
@@ -118,13 +118,13 @@ class MxdInfoDialog extends MxdDialog {
const info = this.modelInfo || {};
const civitaiLink = info.links?.find((i) => i.includes("civitai.com/models"));
const html = `
<ul class="rgthree-info-area">
<li title="Type" class="rgthree-info-tag -type -type-${(info.type || "").toLowerCase()}"><span>${info.type || ""}</span></li>
<li title="Base Model" class="rgthree-info-tag -basemodel -basemodel-${(info.baseModel || "").toLowerCase()}"><span>${info.baseModel || ""}</span></li>
<li class="rgthree-info-menu" stub="menu"></li>
<ul class="mxd-info-area">
<li title="Type" class="mxd-info-tag -type -type-${(info.type || "").toLowerCase()}"><span>${info.type || ""}</span></li>
<li title="Base Model" class="mxd-info-tag -basemodel -basemodel-${(info.baseModel || "").toLowerCase()}"><span>${info.baseModel || ""}</span></li>
<li class="mxd-info-menu" stub="menu"></li>
</ul>
<table class="rgthree-info-table">
<table class="mxd-info-table">
${infoTableRow("File", info.file || "")}
${infoTableRow("Hash (sha256)", info.sha256 || "")}
${
@@ -135,7 +135,7 @@ class MxdInfoDialog extends MxdDialog {
: info.raw?.civitai?.error
? infoTableRow("Civitai", info.raw?.civitai?.error)
: !info.raw?.civitai
? infoTableRow("Civitai", `<button class="rgthree-button" data-action="fetch-civitai">Fetch info from civitai</button>`)
? infoTableRow("Civitai", `<button class="mxd-button" data-action="fetch-civitai">Fetch info from civitai</button>`)
: ""
}
${infoTableRow("Name", info.name || info.raw?.metadata?.ss_output_name || "", "Display name.", "name")}
@@ -155,7 +155,7 @@ class MxdInfoDialog extends MxdDialog {
${infoTableRow("Additional Notes", info.userNote ?? "", "Local note.", "userNote")}
</table>
<ul class="rgthree-info-images">${
<ul class="mxd-info-images">${
info.images?.map(
(img) => `
<li>
@@ -229,14 +229,14 @@ function infoTableRow(name, value, help = "", editableFieldName = "") {
<tr class="${editableFieldName ? "editable" : ""}" ${editableFieldName ? `data-field-name="${editableFieldName}"` : ""}>
<td><span>${name} ${help ? `<span class="-help" title="${help}"></span>` : ""}<span></td>
<td ${editableFieldName ? "" : 'colspan="2"'}>${String(value).startsWith("<") ? value : `<span>${value}<span>`}</td>
${editableFieldName ? `<td style="width: 24px;"><button class="rgthree-button-reset rgthree-button-edit" data-action="edit-row">${pencilColored}${diskColored}</button></td>` : ""}
${editableFieldName ? `<td style="width: 24px;"><button class="mxd-button-reset mxd-button-edit" data-action="edit-row">${pencilColored}${diskColored}</button></td>` : ""}
</tr>`;
}
function getTrainedWordsMarkup(words) {
let markup = `<ul class="rgthree-info-trained-words-list">`;
let markup = `<ul class="mxd-info-trained-words-list">`;
for (const wordData of words || []) {
markup += `<li title="${wordData.word}" data-word="${wordData.word}" class="rgthree-info-trained-words-list-item" data-action="toggle-trained-word">
markup += `<li title="${wordData.word}" data-word="${wordData.word}" class="mxd-info-trained-words-list-item" data-action="toggle-trained-word">
<span>${wordData.word}</span>
${wordData.civitai ? logoCivitai : ""}
${wordData.count != null ? `<small>${wordData.count}</small>` : ""}
@@ -263,7 +263,7 @@ function saveEditableRow(info, tr, saving = true) {
LORA_INFO_SERVICE.savePartialInfo(info.file, { [fieldName]: newValue });
modified = true;
}
tr.classList.remove("-rgthree-editing");
tr.classList.remove("-mxd-editing");
const td = query("td:nth-child(2)", tr);
appendChildren(empty(td), [$el("span", { text: newValue })]);
return modified;
+62 -62
View File
@@ -1,25 +1,25 @@
.rgthree-info-dialog {
.mxd-info-dialog {
width: 90vw;
max-width: 960px;
}
.rgthree-info-dialog .rgthree-info-area {
.mxd-info-dialog .mxd-info-area {
list-style: none;
padding: 0;
margin: 0;
display: flex;
}
.rgthree-info-dialog .rgthree-info-area > li {
.mxd-info-dialog .mxd-info-area > li {
display: inline-flex;
margin: 0;
vertical-align: top;
}
.rgthree-info-dialog .rgthree-info-area > li + li {
.mxd-info-dialog .mxd-info-area > li + li {
margin-left: 6px;
}
.rgthree-info-dialog .rgthree-info-area > li:not(.-link) + li.-link {
.mxd-info-dialog .mxd-info-area > li:not(.-link) + li.-link {
margin-left: auto;
}
.rgthree-info-dialog .rgthree-info-area > li.rgthree-info-tag > * {
.mxd-info-dialog .mxd-info-area > li.mxd-info-tag > * {
min-height: 24px;
border-radius: 4px;
line-height: 1;
@@ -38,99 +38,99 @@
align-items: center;
box-shadow: inset 0px 0px 0 1px rgba(0, 0, 0, 0.5);
}
.rgthree-info-dialog .rgthree-info-area > li.rgthree-info-tag > * > svg {
.mxd-info-dialog .mxd-info-area > li.mxd-info-tag > * > svg {
width: 16px;
height: 16px;
}
.rgthree-info-dialog .rgthree-info-area > li.rgthree-info-tag > * > svg:last-child {
.mxd-info-dialog .mxd-info-area > li.mxd-info-tag > * > svg:last-child {
margin-left: 0.5em;
}
.rgthree-info-dialog .rgthree-info-area > li.rgthree-info-tag > *[href] {
.mxd-info-dialog .mxd-info-area > li.mxd-info-tag > *[href] {
box-shadow: inset 0px 1px 0px rgba(255, 255, 255, 0.25), inset 0px -1px 0px rgba(0, 0, 0, 0.66);
}
.rgthree-info-dialog .rgthree-info-area > li.rgthree-info-tag > *:empty {
.mxd-info-dialog .mxd-info-area > li.mxd-info-tag > *:empty {
display: none;
}
.rgthree-info-dialog .rgthree-info-area > li.-type > * {
.mxd-info-dialog .mxd-info-area > li.-type > * {
background: rgb(73, 54, 94);
color: rgb(228, 209, 248);
}
.rgthree-info-dialog .rgthree-info-area > li.rgthree-info-menu {
.mxd-info-dialog .mxd-info-area > li.mxd-info-menu {
margin-left: auto;
}
:not(#fakeid) .rgthree-info-dialog .rgthree-info-area > li.rgthree-info-menu .rgthree-button {
:not(#fakeid) .mxd-info-dialog .mxd-info-area > li.mxd-info-menu .mxd-button {
margin: 0;
min-height: 24px;
padding: 0 12px;
}
.rgthree-info-dialog .rgthree-info-area > li.rgthree-info-menu svg {
.mxd-info-dialog .mxd-info-area > li.mxd-info-menu svg {
width: 16px;
height: 16px;
}
.rgthree-info-dialog .rgthree-info-table {
.mxd-info-dialog .mxd-info-table {
border-collapse: collapse;
margin: 16px 0px;
width: 100%;
font-size: 12px;
}
.rgthree-info-dialog .rgthree-info-table tr.editable button {
.mxd-info-dialog .mxd-info-table tr.editable button {
display: flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
}
.rgthree-info-dialog .rgthree-info-table tr.editable button svg + svg {
.mxd-info-dialog .mxd-info-table tr.editable button svg + svg {
display: none;
}
.rgthree-info-dialog .rgthree-info-table tr.editable.-rgthree-editing button svg {
.mxd-info-dialog .mxd-info-table tr.editable.-mxd-editing button svg {
display: none;
}
.rgthree-info-dialog .rgthree-info-table tr.editable.-rgthree-editing button svg + svg {
.mxd-info-dialog .mxd-info-table tr.editable.-mxd-editing button svg + svg {
display: inline-block;
}
.rgthree-info-dialog .rgthree-info-table td {
.mxd-info-dialog .mxd-info-table td {
position: relative;
border: 1px solid rgba(255, 255, 255, 0.25);
padding: 0;
vertical-align: top;
}
.rgthree-info-dialog .rgthree-info-table td:first-child {
.mxd-info-dialog .mxd-info-table td:first-child {
background: rgba(255, 255, 255, 0.075);
width: 10px;
}
.rgthree-info-dialog .rgthree-info-table td:first-child > *:first-child {
.mxd-info-dialog .mxd-info-table td:first-child > *:first-child {
white-space: nowrap;
padding-right: 32px;
}
.rgthree-info-dialog .rgthree-info-table td:first-child small {
.mxd-info-dialog .mxd-info-table td:first-child small {
display: block;
margin-top: 2px;
opacity: 0.75;
}
.rgthree-info-dialog .rgthree-info-table td:first-child small > [data-action] {
.mxd-info-dialog .mxd-info-table td:first-child small > [data-action] {
text-decoration: underline;
cursor: pointer;
}
.rgthree-info-dialog .rgthree-info-table td:first-child small > [data-action]:hover {
.mxd-info-dialog .mxd-info-table td:first-child small > [data-action]:hover {
text-decoration: none;
}
.rgthree-info-dialog .rgthree-info-table td a, .rgthree-info-dialog .rgthree-info-table td a:hover, .rgthree-info-dialog .rgthree-info-table td a:visited {
.mxd-info-dialog .mxd-info-table td a, .mxd-info-dialog .mxd-info-table td a:hover, .mxd-info-dialog .mxd-info-table td a:visited {
color: inherit;
}
.rgthree-info-dialog .rgthree-info-table td svg {
.mxd-info-dialog .mxd-info-table td svg {
width: 1.3333em;
height: 1.3333em;
vertical-align: -0.285em;
}
.rgthree-info-dialog .rgthree-info-table td svg.logo-civitai {
.mxd-info-dialog .mxd-info-table td svg.logo-civitai {
margin-right: 0.3333em;
}
.rgthree-info-dialog .rgthree-info-table td > *:first-child {
.mxd-info-dialog .mxd-info-table td > *:first-child {
display: block;
padding: 6px 10px;
}
.rgthree-info-dialog .rgthree-info-table td > input, .rgthree-info-dialog .rgthree-info-table td > textarea {
.mxd-info-dialog .mxd-info-table td > input, .mxd-info-dialog .mxd-info-table td > textarea {
padding: 5px 10px;
border: 0;
box-shadow: inset 1px 1px 5px 0px rgba(0, 0, 0, 0.5);
@@ -140,31 +140,31 @@
color: #121212;
resize: vertical;
}
.rgthree-info-dialog .rgthree-info-table td > input:only-child, .rgthree-info-dialog .rgthree-info-table td > textarea:only-child {
.mxd-info-dialog .mxd-info-table td > input:only-child, .mxd-info-dialog .mxd-info-table td > textarea:only-child {
width: 100%;
}
:not(#fakeid) .rgthree-info-dialog .rgthree-info-table td .rgthree-button[data-action=fetch-civitai] {
:not(#fakeid) .mxd-info-dialog .mxd-info-table td .mxd-button[data-action=fetch-civitai] {
font-size: inherit;
padding: 6px 16px;
margin: 2px;
}
.rgthree-info-dialog .rgthree-info-table tr[data-field-name=userNote] td > span:first-child {
.mxd-info-dialog .mxd-info-table tr[data-field-name=userNote] td > span:first-child {
white-space: pre;
}
.rgthree-info-dialog .rgthree-info-table tr.rgthree-info-table-break-row td {
.mxd-info-dialog .mxd-info-table tr.mxd-info-table-break-row td {
border: 0;
background: transparent;
padding: 12px 4px 4px;
font-size: 1.2em;
}
.rgthree-info-dialog .rgthree-info-table tr.rgthree-info-table-break-row td > small {
.mxd-info-dialog .mxd-info-table tr.mxd-info-table-break-row td > small {
font-style: italic;
opacity: 0.66;
}
.rgthree-info-dialog .rgthree-info-table tr.rgthree-info-table-break-row td:empty {
.mxd-info-dialog .mxd-info-table tr.mxd-info-table-break-row td:empty {
padding: 4px;
}
.rgthree-info-dialog .rgthree-info-table td .-help {
.mxd-info-dialog .mxd-info-table td .-help {
border: 1px solid currentColor;
position: absolute;
right: 5px;
@@ -179,10 +179,10 @@
justify-content: center;
cursor: help;
}
.rgthree-info-dialog .rgthree-info-table td .-help::before {
.mxd-info-dialog .mxd-info-table td .-help::before {
content: "?";
}
.rgthree-info-dialog .rgthree-info-table td > ul.rgthree-info-trained-words-list {
.mxd-info-dialog .mxd-info-table td > ul.mxd-info-trained-words-list {
list-style: none;
padding: 2px 8px;
margin: 0;
@@ -192,7 +192,7 @@
max-height: 15vh;
overflow: auto;
}
.rgthree-info-dialog .rgthree-info-table td > ul.rgthree-info-trained-words-list > li {
.mxd-info-dialog .mxd-info-table td > ul.mxd-info-trained-words-list > li {
display: inline-flex;
margin: 2px;
vertical-align: top;
@@ -213,21 +213,21 @@
white-space: nowrap;
max-width: 183px;
}
.rgthree-info-dialog .rgthree-info-table td > ul.rgthree-info-trained-words-list > li:hover {
.mxd-info-dialog .mxd-info-table td > ul.mxd-info-trained-words-list > li:hover {
background: rgb(68, 109, 142);
}
.rgthree-info-dialog .rgthree-info-table td > ul.rgthree-info-trained-words-list > li > svg {
.mxd-info-dialog .mxd-info-table td > ul.mxd-info-trained-words-list > li > svg {
width: auto;
height: 1.2em;
}
.rgthree-info-dialog .rgthree-info-table td > ul.rgthree-info-trained-words-list > li > span {
.mxd-info-dialog .mxd-info-table td > ul.mxd-info-trained-words-list > li > span {
padding-left: 0.5em;
padding-right: 0.5em;
padding-bottom: 0.1em;
text-overflow: ellipsis;
overflow: hidden;
}
.rgthree-info-dialog .rgthree-info-table td > ul.rgthree-info-trained-words-list > li > small {
.mxd-info-dialog .mxd-info-table td > ul.mxd-info-trained-words-list > li > small {
align-self: stretch;
display: flex;
align-items: center;
@@ -235,10 +235,10 @@
padding: 0 0.5em;
background: rgba(0, 0, 0, 0.2);
}
.rgthree-info-dialog .rgthree-info-table td > ul.rgthree-info-trained-words-list > li.-rgthree-is-selected {
.mxd-info-dialog .mxd-info-table td > ul.mxd-info-trained-words-list > li.-mxd-is-selected {
background: rgb(42, 126, 193);
}
.rgthree-info-dialog .rgthree-info-images {
.mxd-info-dialog .mxd-info-images {
list-style: none;
padding: 0;
margin: 0;
@@ -247,7 +247,7 @@
flex-direction: row;
overflow: auto;
}
.rgthree-info-dialog .rgthree-info-images > li {
.mxd-info-dialog .mxd-info-images > li {
scroll-snap-align: start;
max-width: 90%;
flex: 0 0 auto;
@@ -261,14 +261,14 @@
font-size: 0;
position: relative;
}
.rgthree-info-dialog .rgthree-info-images > li figure {
.mxd-info-dialog .mxd-info-images > li figure {
margin: 0;
position: static;
}
.rgthree-info-dialog .rgthree-info-images > li figure video, .rgthree-info-dialog .rgthree-info-images > li figure img {
.mxd-info-dialog .mxd-info-images > li figure video, .mxd-info-dialog .mxd-info-images > li figure img {
max-height: 45vh;
}
.rgthree-info-dialog .rgthree-info-images > li figure figcaption {
.mxd-info-dialog .mxd-info-images > li figure figcaption {
position: absolute;
left: 0;
width: 100%;
@@ -280,7 +280,7 @@
transform: translateY(50px);
transition: all 0.25s ease-in-out;
}
.rgthree-info-dialog .rgthree-info-images > li figure figcaption > span {
.mxd-info-dialog .mxd-info-images > li figure figcaption > span {
display: inline-block;
padding: 2px 4px;
margin: 2px;
@@ -288,7 +288,7 @@
border: 1px solid rgba(255, 255, 255, 0.2);
word-break: break-word;
}
.rgthree-info-dialog .rgthree-info-images > li figure figcaption > span label {
.mxd-info-dialog .mxd-info-images > li figure figcaption > span label {
display: inline;
padding: 0;
margin: 0;
@@ -296,40 +296,40 @@
pointer-events: none;
user-select: none;
}
.rgthree-info-dialog .rgthree-info-images > li figure figcaption > span a {
.mxd-info-dialog .mxd-info-images > li figure figcaption > span a {
color: inherit;
text-decoration: underline;
}
.rgthree-info-dialog .rgthree-info-images > li figure figcaption > span a:hover {
.mxd-info-dialog .mxd-info-images > li figure figcaption > span a:hover {
text-decoration: none;
}
.rgthree-info-dialog .rgthree-info-images > li figure figcaption > span a svg {
.mxd-info-dialog .mxd-info-images > li figure figcaption > span a svg {
height: 10px;
margin-left: 4px;
fill: currentColor;
}
.rgthree-info-dialog .rgthree-info-images > li figure figcaption:empty {
.mxd-info-dialog .mxd-info-images > li figure figcaption:empty {
text-align: center;
}
.rgthree-info-dialog .rgthree-info-images > li figure figcaption:empty::before {
.mxd-info-dialog .mxd-info-images > li figure figcaption:empty::before {
content: "No data.";
}
.rgthree-info-dialog .rgthree-info-images > li:hover figure figcaption {
.mxd-info-dialog .mxd-info-images > li:hover figure figcaption {
opacity: 1;
transform: translateY(0px);
}
.rgthree-info-dialog .rgthree-info-images > li .rgthree-info-table {
.mxd-info-dialog .mxd-info-images > li .mxd-info-table {
width: calc(100% - 16px);
}
.rgthree-info-dialog .rgthree-info-civitai-link {
.mxd-info-dialog .mxd-info-civitai-link {
margin: 8px;
color: #eee;
}
.rgthree-info-dialog .rgthree-info-civitai-link a, .rgthree-info-dialog .rgthree-info-civitai-link a:hover, .rgthree-info-dialog .rgthree-info-civitai-link a:visited {
.mxd-info-dialog .mxd-info-civitai-link a, .mxd-info-dialog .mxd-info-civitai-link a:hover, .mxd-info-dialog .mxd-info-civitai-link a:visited {
color: inherit;
text-decoration: none;
}
.rgthree-info-dialog .rgthree-info-civitai-link > svg {
.mxd-info-dialog .mxd-info-civitai-link > svg {
width: 16px;
height: 16px;
margin-right: 8px;
+17 -17
View File
@@ -1,4 +1,4 @@
.rgthree-menu {
.mxd-menu {
list-style: none;
padding: 0;
margin: 0;
@@ -12,31 +12,31 @@
font-size: 12px;
box-shadow: 0 0 10px black !important;
}
.rgthree-menu > li {
.mxd-menu > li {
position: relative;
padding: 4px 6px;
z-index: 9999;
white-space: nowrap;
}
.rgthree-menu > li[role=button] {
.mxd-menu > li[role=button] {
background-color: var(--comfy-menu-bg) !important;
color: var(--input-text);
cursor: pointer;
}
.rgthree-menu > li[role=button]:hover {
.mxd-menu > li[role=button]:hover {
filter: brightness(155%);
}
.rgthree-menu[state^=measuring] {
.mxd-menu[state^=measuring] {
display: block;
opacity: 0;
}
.rgthree-menu[state=open] {
.mxd-menu[state=open] {
display: block;
opacity: 1;
pointer-events: all;
}
.rgthree-top-menu {
.mxd-top-menu {
box-sizing: border-box;
white-space: nowrap;
background: var(--content-bg);
@@ -47,17 +47,17 @@
padding: 0;
margin: 0;
}
.rgthree-top-menu * {
.mxd-top-menu * {
box-sizing: inherit;
}
.rgthree-top-menu > li:not(#fakeid) {
.mxd-top-menu > li:not(#fakeid) {
list-style: none;
padding: 0;
margin: 0;
position: relative;
z-index: 2;
}
.rgthree-top-menu > li:not(#fakeid) > button {
.mxd-top-menu > li:not(#fakeid) > button {
cursor: pointer;
padding: 8px 12px 8px 8px;
width: 100%;
@@ -67,21 +67,21 @@
align-items: center;
justify-content: start;
}
.rgthree-top-menu > li:not(#fakeid) > button:hover {
.mxd-top-menu > li:not(#fakeid) > button:hover {
background-color: var(--comfy-input-bg);
}
.rgthree-top-menu > li:not(#fakeid) > button svg {
.mxd-top-menu > li:not(#fakeid) > button svg {
height: 16px;
width: auto;
margin-inline-end: 0.6em;
}
.rgthree-top-menu > li:not(#fakeid) > button svg.github-star {
.mxd-top-menu > li:not(#fakeid) > button svg.github-star {
fill: rgb(227, 179, 65);
}
.rgthree-top-menu > li:not(#fakeid).rgthree-message {
.mxd-top-menu > li:not(#fakeid).mxd-message {
min-height: 32px;
}
.rgthree-top-menu > li:not(#fakeid).rgthree-message > span {
.mxd-top-menu > li:not(#fakeid).mxd-message > span {
padding: 8px 12px;
display: block;
width: 100%;
@@ -89,7 +89,7 @@
font-style: italic;
font-size: 12px;
}
.rgthree-top-menu.-modal::after {
.mxd-top-menu.-modal::after {
content: "";
display: block;
position: fixed;
@@ -98,6 +98,6 @@
background: rgba(0, 0, 0, 0.0666666667);
}
body.rgthree-modal-menu-open > *:not(.rgthree-menu):not(.rgthree-top-messages-container) {
body.mxd-modal-menu-open > *:not(.mxd-menu):not(.mxd-top-messages-container) {
filter: blur(2px);
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { generateId, wait } from "./mxd_shared_utils.js";
import { createElement as $el, getClosestOrSelf, setAttributes } from "./mxd_utils_dom.js";
class Menu {
constructor(options) {
this.element = $el('menu.rgthree-menu');
this.element = $el('menu.mxd-menu');
this.callbacks = new Map();
this.handleWindowPointerDownBound = this.handleWindowPointerDown.bind(this);
this.setOptions(options);
@@ -82,7 +82,7 @@ class Menu {
}
export class MenuButton {
constructor(options) {
this.element = $el('button.rgthree-button[data-action="open-menu"]');
this.element = $el('button.mxd-button[data-action="open-menu"]');
this.options = options;
this.element.innerHTML = options.icon;
this.menu = new Menu(options.options);
+3 -3
View File
@@ -133,7 +133,7 @@ export function defineProperty(instance, property, desc) {
var _a, _b, _c, _d, _e, _f;
const existingDesc = Object.getOwnPropertyDescriptor(instance, property);
if ((existingDesc === null || existingDesc === void 0 ? void 0 : existingDesc.configurable) === false) {
throw new Error(`Error: rgthree-comfy cannot define un-configurable property "${property}"`);
throw new Error(`Error: MaxedOut cannot define un-configurable property "${property}"`);
}
if ((existingDesc === null || existingDesc === void 0 ? void 0 : existingDesc.get) && desc.get) {
const descGet = desc.get;
@@ -386,13 +386,13 @@ export class Broadcaster extends EventTarget {
}, 250);
}
else {
this.dispatchEvent(new CustomEvent("rgthree-broadcast-message", {
this.dispatchEvent(new CustomEvent("mxd-broadcast-message", {
detail: Object.assign({ replyTo: (_b = e.data) === null || _b === void 0 ? void 0 : _b.id }, e.data),
}));
}
}
addMessageListener(callback, options) {
return super.addEventListener("rgthree-broadcast-message", callback, options);
return super.addEventListener("mxd-broadcast-message", callback, options);
}
}
const broadcastChannelMap = new Map();
+1 -1
View File
@@ -162,7 +162,7 @@ function normalizeMenuCallbackValue(value) {
export async function showLoraChooser(event, callback, parentMenu, loras) {
const canvas = app.canvas;
if (!loras) {
loras = ["None", ...(await mxdApi.getLoras().then((items) => items.map((l) => l.file)))];
loras = await mxdApi.getLoras().then((items) => items.map((l) => l.file));
}
ensureLoraChooserTreeStyles();
+113
View File
@@ -0,0 +1,113 @@
import { app } from "../../scripts/app.js";
const NODE_TYPES = new Set(["WAN22_I2V_Video_Prep_MXD"]);
function getWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function hideWidget(widget) {
if (!widget._mxdOriginalComputeSize) {
widget._mxdOriginalComputeSize = widget.computeSize;
}
widget.hidden = true;
widget.disabled = true;
widget.computeSize = () => [0, 0];
}
function showWidget(widget) {
widget.hidden = false;
widget.disabled = false;
if (widget._mxdOriginalComputeSize) {
widget.computeSize = widget._mxdOriginalComputeSize;
}
}
function resizeNodeToWidgets(node) {
if (!node.computeSize || !node.setSize) {
return;
}
const computed = node.computeSize();
const currentWidth = node.size?.[0] ?? computed[0];
let requiredHeight = computed[1];
for (const widget of node.widgets ?? []) {
if (widget.hidden) {
continue;
}
const widgetY = Number.isFinite(widget.last_y) ? widget.last_y : 0;
let widgetHeight = 20;
try {
const size = widget.computeSize?.(currentWidth);
if (Array.isArray(size) && Number.isFinite(size[1])) {
widgetHeight = Math.max(widgetHeight, size[1]);
}
} catch (error) {
// Keep the fallback height.
}
requiredHeight = Math.max(requiredHeight, widgetY + widgetHeight + 8);
}
node.setSize([Math.max(currentWidth, computed[0]), Math.ceil(requiredHeight)]);
}
function scheduleResizeNodeToWidgets(node) {
resizeNodeToWidgets(node);
requestAnimationFrame(() => {
resizeNodeToWidgets(node);
requestAnimationFrame(() => resizeNodeToWidgets(node));
});
}
function updateTargetFpsVisibility(node) {
const forceWidget = getWidget(node, "force_fps");
const targetWidget = getWidget(node, "target_fps");
if (!forceWidget || !targetWidget) {
return;
}
if (forceWidget.value) {
showWidget(targetWidget);
} else {
hideWidget(targetWidget);
}
scheduleResizeNodeToWidgets(node);
app.canvas?.setDirty(true, true);
}
app.registerExtension({
name: "ComfyUI-MaxedOut.Wan22VideoPrepMXD",
beforeRegisterNodeDef(nodeType, nodeData) {
if (!NODE_TYPES.has(nodeData.name)) {
return;
}
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
const node = this;
const forceWidget = getWidget(node, "force_fps");
if (forceWidget && !forceWidget._mxdWan22CallbackWrapped) {
const originalCallback = forceWidget.callback;
forceWidget.callback = function () {
const callbackResult = originalCallback?.apply(this, arguments);
updateTargetFpsVisibility(node);
return callbackResult;
};
forceWidget._mxdWan22CallbackWrapped = true;
}
updateTargetFpsVisibility(node);
return result;
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const result = onConfigure?.apply(this, arguments);
requestAnimationFrame(() => updateTargetFpsVisibility(this));
return result;
};
},
});