From 54aa7806080c1ac68ae002a5588f73d1c1f97c8f Mon Sep 17 00:00:00 2001 From: Gin3601 Date: Mon, 25 May 2026 15:29:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=80=E5=8F=91=E5=9B=BE=E5=83=8F=E6=8B=BC?= =?UTF-8?q?=E6=8E=A5=E8=8A=82=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LLS-Nodes-Reference.md | 1047 +++++++++++++++++++ tests/test_concat_by_target_node.py | 230 ++++ tests/test_concat_by_target_registration.py | 51 + utils/concat_by_target.py | 488 +++++++++ utils/nodes.py | 6 + 5 files changed, 1822 insertions(+) create mode 100644 LLS-Nodes-Reference.md create mode 100644 tests/test_concat_by_target_node.py create mode 100644 tests/test_concat_by_target_registration.py create mode 100644 utils/concat_by_target.py diff --git a/LLS-Nodes-Reference.md b/LLS-Nodes-Reference.md new file mode 100644 index 0000000..ada0e72 --- /dev/null +++ b/LLS-Nodes-Reference.md @@ -0,0 +1,1047 @@ +# LLS-Node 节点参考手册 + +> LLS ComfyUI 插件全部节点及参数说明文档。 +> 核心设计:移除 `task_context` 端口,从 MODEL/CLIP/VAE 等标准对象自动推导模型家族与任务模式。 + +--- + +## 目录 + +1. [模型加载 (Model Loader)](#1-模型加载-model-loader) +2. [文本编码 (Conditioning)](#2-文本编码-conditioning) +3. [采样与去噪 (Sampling)](#3-采样与去噪-sampling) +4. [Qwen 一体化节点](#4-qwen-一体化节点) +5. [Latent 空间操作](#5-latent-空间操作) +6. [图像处理 (Image)](#6-图像处理-image) +7. [图像修复 (Repair)](#7-图像修复-repair) +8. [交互式遮罩绘制 (Mask Draw)](#8-交互式遮罩绘制-mask-draw) +9. [专业图像编辑 (Pro Edit)](#9-专业图像编辑-pro-edit) +10. [图像超分 (Upscale)](#10-图像超分-upscale) +11. [遮罩操作 (Mask)](#11-遮罩操作-mask) +12. [工具节点 (Utils)](#12-工具节点-utils) +13. [根级统一节点](#13-根级统一节点) +14. [待实现功能域](#14-待实现功能域) + +--- + +## 1. 模型加载 (Model Loader) + +分类:`LLS/Model Loader` + +### LLS Simple Checkpoint Loader + +统一封装的基础模型加载器,支持 SD1.5 / SDXL / SDXL Turbo / FLUX 全家族。输出同时保留旧工作流兼容的 `clip` 端口和新的 `text_encoder` 别名。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| model | MODEL | 扩散模型 | +| clip | CLIP | 原生 CLIP(旧工作流兼容) | +| vae | VAE | VAE 解码器 | +| text_encoder | CLIP | 同 clip,新命名别名 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| ckpt_name | 下拉列表 | — | 模型文件名,从 ComfyUI 的 checkpoints 和 diffusion_models 目录自动扫描 | +| model_family | 下拉列表 | Auto | 模型家族:`Auto` / `SD1.5` / `SDXL` / `SDXL_TURBO` / `FLUX_DEV` / `FLUX_SDXL`。Auto 时从模型文件名自动推断 | +| load_mode | 下拉列表 | simple | 加载模式:`simple`(简化)/ `advanced`(高级,暴露更多选项) | +| vae_source | 下拉列表 | auto | VAE 来源:`auto`(优先内嵌)/ `embedded`(仅用内嵌)/ `external`(仅用外部)/ `none`(不加载 VAE) | +| text_encoder_source | 下拉列表 | auto | 文本编码器来源:`auto` / `embedded` / `external` / `manual` | +| external_vae_name | 下拉列表 | (auto) | 外部 VAE 文件名,auto 时自动匹配 | +| external_text_encoder_1 | 下拉列表 | (auto) | 外部文本编码器 1(SD1.5: CLIP-L,SDXL: CLIP-L,FLUX: CLIP-L) | +| external_text_encoder_2 | 下拉列表 | (auto) | 外部文本编码器 2(SDXL: CLIP-G,FLUX: T5XXL) | + +--- + +### LLS Universal Model Loader + +统一模型加载器,始终输出单一 `text_encoder`,额外输出 `model_info` JSON 串供下游节点使用。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| model | MODEL | 扩散模型 | +| text_encoder | CLIP | 统一文本编码器(内部处理单/双编码器差异) | +| vae | VAE | VAE 解码器 | +| model_info | STRING | JSON 格式模型元信息(家族、profile、能力标签等) | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| model_name | 下拉列表 | — | 模型文件名 | +| model_family | 下拉列表 | Auto | 模型家族选择 | +| load_mode | 下拉列表 | simple | 加载模式 | +| vae_source | 下拉列表 | auto | VAE 来源:`auto` / `embedded` / `external` | +| text_encoder_source | 下拉列表 | auto | 文本编码器来源:`auto` / `embedded` / `external` | +| text_encoder_1 | 下拉列表 | (auto) | 外部文本编码器 1 | +| text_encoder_2 | 下拉列表 | (auto) | 外部文本编码器 2 | +| vae_name | 下拉列表 | (auto) | 外部 VAE 文件名 | + +--- + +## 2. 文本编码 (Conditioning) + +分类:`LLS/Conditioning` + +### LLS Simple Prompt Encode + +家族感知的提示词编码节点。自动根据模型家族选择编码路径(SD1.5 CLIP / SDXL 双编码器 / FLUX CLIP-L+T5)。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| positive | CONDITIONING | 正向条件 | +| negative | CONDITIONING | 反向条件 | +| prompt_info | STRING | JSON 格式编码元信息 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| positive_prompt | STRING(多行) | "" | 正向提示词 | +| negative_prompt | STRING(多行) | "" | 反向提示词 | +| clip_skip | 下拉列表 | -1 | CLIP 跳层:-1 表示不跳层,-2 跳最后一层,依此类推。FLUX 家族强制 -1 | +| model_family | 下拉列表 | Auto | 模型家族选择 | + +**可选输入** + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| text_encoder | CLIP | 文本编码器(新端口) | +| clip | CLIP | CLIP(旧端口兼容) | + +> text_encoder 和 clip 至少连接一个。 + +--- + +### LLS Universal Prompt Encode + +统一文本编码节点,只公开一个 `text_encoder` 输入,可选接收 `model_info` 来明确家族信息。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| positive | CONDITIONING | 正向条件 | +| negative | CONDITIONING | 反向条件 | +| prompt_info | STRING | JSON 格式编码元信息 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| text_encoder | CLIP | — | 文本编码器(必接) | +| positive_prompt | STRING(多行) | "" | 正向提示词 | +| negative_prompt | STRING(多行) | "" | 反向提示词 | +| clip_skip | 下拉列表 | -1 | CLIP 跳层数 | + +**可选输入** + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| model_info | STRING | 模型元信息 JSON(来自 Universal Model Loader) | + +--- + +## 3. 采样与去噪 (Sampling) + +分类:`LLS/Sampling` + +### LLS Simple KSampler + +简化版 KSampler。内部复用 ComfyUI 原生采样能力,支持 `quality_preset` 一键切换采样参数,并输出 `sample_info` 元数据。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| latent | LATENT | 采样后的潜空间 | +| sample_info | STRING | JSON 格式采样元信息(seed/steps/cfg/denoise/家族等) | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| model | MODEL | — | 扩散模型 | +| positive | CONDITIONING | — | 正向条件 | +| negative | CONDITIONING | — | 反向条件 | +| latent_image | LATENT | — | 输入潜空间 | +| quality_preset | 下拉列表 | Family Default | 质量预设:`Family Default`(家族默认)/ `Manual`(手动)/ `Fast` / `Balanced` / `High Quality`。非 Manual 时覆盖 steps/cfg/sampler/scheduler/denoise | +| seed | INT | -1 | 随机种子。-1 时自动随机生成 | +| steps | INT | 20 | 采样步数(1–10000) | +| cfg | FLOAT | 7.0 | CFG 引导强度(0.0–100.0) | +| sampler_name | 下拉列表 | euler_ancestral | 采样器名称 | +| scheduler | 下拉列表 | karras | 调度器名称 | +| denoise | FLOAT | 1.0 | 去噪强度(0.0–1.0) | +| denoise_mode | 下拉列表 | manual | 去噪模式:`manual`(手动)/ `auto_from_repair`(从修复信息自动推荐) | +| adapter_mode | 下拉列表 | auto | 适配器模式:`auto` / `sd_classic` / `flux` / `sd3` / `qwen` / `zimage` | +| flux_guidance | FLOAT | 3.5 | FLUX 家族 guidance 值(0.0–100.0),仅 FLUX 时生效 | +| model_family | 下拉列表 | Auto | 模型家族选择 | + +**可选输入** + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| repair_info | LLS_REPAIR_INFO | 修复信息(来自 Repair Prepare) | +| guidance_stack | LLS_GUIDANCE_STACK | 引导栈 | +| model_info | STRING | 模型元信息 JSON | + +--- + +## 4. Qwen 一体化节点 + +分类:`LLS/Qwen` + +### LLS Qwen Text To Image + +Qwen 文生图一体化节点。内部自动加载 Qwen 模型资源,支持高级采样控制和可选 Turbo LoRA。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| image | IMAGE | 生成的图像 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| model_name | 下拉列表 | — | Qwen 文生图模型名称 | +| prompt | STRING(多行) | "" | 提示词 | +| width | INT | 1024 | 图像宽度(16–8192,步长16) | +| height | INT | 1024 | 图像高度(16–8192,步长16) | +| steps | INT | 20 | 采样步数 | +| seed | INT | 0 | 随机种子(0–最大值) | +| batch_size | INT | 1 | 批量大小(1–64) | +| negative_prompt | STRING(多行) | "" | 反向提示词(高级参数) | +| cfg | FLOAT | 4.0 | CFG 引导强度(高级参数) | +| sampler_name | 下拉列表 | euler | 采样器(高级参数) | +| scheduler | 下拉列表 | simple | 调度器(高级参数) | +| shift | FLOAT | 3.1 | 采样偏移量(高级参数,0.0–100.0) | +| enable_turbo_mode | BOOLEAN | False | 启用 Turbo 加速模式(高级参数) | +| turbo_lora_name | 下拉列表 | (auto) | Turbo LoRA 模型名称(高级参数) | +| turbo_strength | FLOAT | 1.0 | Turbo LoRA 强度(高级参数,-100.0–100.0) | + +**可选输入** + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| model | MODEL | 外部模型(可选链式连接) | + +--- + +### LLS Qwen Image Edit + +Qwen 图像编辑节点。支持多图编辑条件、可选 Turbo LoRA。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| image | IMAGE | 编辑后的图像 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| model_name | 下拉列表 | — | Qwen 编辑模型名称 | +| image | IMAGE | — | 输入图像 | +| prompt | STRING(多行) | "" | 编辑提示词 | +| steps | INT | 20 | 采样步数 | +| seed | INT | 0 | 随机种子 | +| negative_prompt | STRING(多行) | "" | 反向提示词(高级参数) | +| cfg | FLOAT | 4.0 | CFG 引导强度(高级参数) | +| sampler_name | 下拉列表 | euler | 采样器(高级参数) | +| scheduler | 下拉列表 | simple | 调度器(高级参数) | +| shift | FLOAT | 3.1 | 采样偏移量(高级参数) | +| cfg_norm_strength | FLOAT | 1.0 | CFG 归一化强度(高级参数,0.0–100.0) | +| reference_latents_method | 下拉列表 | index_timestep_zero | 参考潜空间方法(高级参数) | +| enable_turbo_mode | BOOLEAN | False | 启用 Turbo 加速(高级参数) | +| turbo_lora_name | 下拉列表 | (auto) | Turbo LoRA(高级参数) | +| turbo_strength | FLOAT | 1.0 | Turbo LoRA 强度(高级参数) | + +**可选输入** + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| image2 | IMAGE | 第二张参考图(多图编辑) | +| image3 | IMAGE | 第三张参考图(多图编辑) | +| model | MODEL | 外部模型 | + +--- + +## 5. Latent 空间操作 + +分类:`LLS/Latent` + +### LLS Simple Empty Latent + +统一的 Latent 入口节点。未连接 `image` 时生成空白 Latent(txt2img),连接 `image` 时通过 VAE Encode 生成 img2img Latent。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| latent | LATENT | 潜空间张量 | +| width | INT | 实际宽度 | +| height | INT | 实际高度 | +| latent_info | STRING | JSON 格式元信息 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| size_preset | 下拉列表 | Family Default | 尺寸预设:`Family Default`(家族默认)/ `Custom` / `512x512` / `768x768` / `512x768` / `768x512` / `1024x1024` / `832x1216` / `1216x832` / `896x1152` / `1152x896` / `1024x576` / `576x1024` | +| width | INT | 512 | 宽度(Custom 时生效,64–8192,步长8) | +| height | INT | 512 | 高度(Custom 时生效,64–8192,步长8) | +| batch_size | INT | 1 | 批量大小(1–64) | +| model_family | 下拉列表 | Auto | 模型家族选择 | +| resize_mode | 下拉列表 | keep_aspect | 缩放模式:`keep_aspect`(保持比例)/ `crop_center`(居中裁剪)/ `stretch`(拉伸)/ `none`(不缩放) | + +**可选输入** + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| model | MODEL | 扩散模型(用于推断家族) | +| image | IMAGE | 输入图像(连接后切换为 img2img 模式) | +| vae | VAE | VAE(img2img 时必需) | + +--- + +## 6. 图像处理 (Image) + +分类:`LLS/Image` + +### LLS Simple VAE Encode + +将 IMAGE 编码为 LATENT,供 img2img 使用。内部复用 ComfyUI 原生 `vae.encode`,外层补尺寸处理与元信息。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| latent | LATENT | 编码后的潜空间 | +| width | INT | 实际宽度 | +| height | INT | 实际高度 | +| latent_info | STRING | JSON 格式元信息 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| image | IMAGE | — | 输入图像 | +| vae | VAE | — | VAE 解码器 | +| resize_mode | 下拉列表 | keep_aspect | 缩放模式:`keep_aspect` / `crop_center` / `stretch` / `none` | +| size_source | 下拉列表 | input_image | 尺寸来源:`input_image`(原图尺寸)/ `custom`(自定义)/ `model_recommended`(模型推荐) | +| width | INT | 512 | 自定义宽度 | +| height | INT | 512 | 自定义高度 | +| model_family | 下拉列表 | Auto | 模型家族 | + +**可选输入** + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| model | MODEL | 扩散模型 | +| clip | CLIP | CLIP 编码器 | + +--- + +### LLS Simple VAE Decode + +将 LATENT 解码为 IMAGE。内部复用 ComfyUI 原生 `vae.decode`。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| image | IMAGE | 解码后的图像 | +| decode_info | STRING | JSON 格式解码元信息 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| samples | LATENT | — | 潜空间张量 | +| vae | VAE | — | VAE 解码器 | + +--- + +### LLS Save Image + +保存图像,并将 LLS 生成链路信息合并进 PNG metadata。支持同时保存图像和遮罩。 + +**输出端口**:无(终端节点) + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| filename_prefix | STRING | LLS | 文件名前缀 | +| output_mode | 下拉列表 | save | 输出模式:`save`(保存到磁盘)/ `preview_only`(仅预览) | +| save_metadata | BOOLEAN | True | 是否保存 LLS 元数据到 PNG | + +**可选输入** + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| image | IMAGE | 要保存的图像(与 mask 至少接一个) | +| mask | MASK | 要保存的遮罩 | +| prompt_info | STRING | 提示词元信息 | +| latent_info | STRING | 潜空间元信息 | +| sample_info | STRING | 采样元信息 | +| decode_info | STRING | 解码元信息 | +| upscale_info | STRING | 超分元信息 | + +--- + +### LLS Simple Image Composite + +将叠加图像合成到背景图像上,支持平移、缩放、旋转和透明度控制。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| output_image | IMAGE | 合成后的图像 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| background_image | IMAGE | — | 背景图像 | +| overlay_image | IMAGE | — | 叠加图像 | +| x_offset | INT | 0 | X 轴偏移(-8192–8192) | +| y_offset | INT | 0 | Y 轴偏移(-8192–8192) | +| anchor_mode | 下拉列表 | top_left | 锚点模式:`top_left`(左上角为基准)/ `center`(中心点为基准) | +| rotation_origin_mode | 下拉列表 | center | 旋转原点模式:`top_left` / `center` | +| opacity | FLOAT | 1.0 | 不透明度(0.0–1.0) | +| blend_mode | 下拉列表 | normal | 混合模式:`normal` | +| scale | FLOAT | 1.0 | 缩放比例(0.01–32.0) | +| rotation | FLOAT | 0.0 | 旋转角度(-360.0–360.0 度) | +| keep_aspect | BOOLEAN | True | 是否保持宽高比 | + +--- + +## 7. 图像修复 (Repair) + +分类:`LLS/Image Repair` + +### LLS Simple Repair Prepare + +修复流程的"准备"节点。处理输入图像和遮罩,确定修复范围(region/crop/canvas)、修复核心策略,生成工作区 Latent 和元数据。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| latent | LATENT | 工作区潜空间 | +| work_image | IMAGE | 工作区图像 | +| work_mask | MASK | 工作区遮罩 | +| repair_info | LLS_REPAIR_INFO | 修复元数据 | +| recommended_denoise | FLOAT | 推荐去噪强度 | +| positive | CONDITIONING | 正向条件 | +| negative | CONDITIONING | 反向条件 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| image | IMAGE | — | 原始图像 | +| mask | MASK | — | 修复遮罩 | +| vae | VAE | — | VAE 解码器 | +| repair_scope | 下拉列表 | auto | 修复范围:`auto` / `region`(全图区域)/ `crop`(裁剪局部)/ `canvas`(画布扩展) | +| repair_kernel | 下拉列表 | auto | 修复核心:`auto` / `latent_mask` / `vae_inpaint` / `native_fill` | +| task_hint | 下拉列表 | auto | 任务提示:`auto` / `repair` / `remove` / `replace` / `fill` / `appearance` / `content` / `structure` / `dehaze` / `deshadow` / `recolor` | +| mask_grow | INT | 24 | 遮罩扩展像素数(0–2048) | +| mask_blur | FLOAT | 8.0 | 遮罩模糊半径(0.0–256.0) | +| mask_threshold | FLOAT | 0.5 | 遮罩二值化阈值(0.0–1.0) | +| invert_mask | BOOLEAN | False | 是否反转遮罩 | +| crop_context | INT | 64 | 裁剪额外上下文边距(0–512 像素) | +| crop_context_factor | FLOAT | 1.5 | 裁剪上下文缩放因子(1.0–8.0) | +| min_size | INT | 256 | 最小工作区尺寸(64–8192,步长8) | +| max_size | INT | 1024 | 最大工作区尺寸(64–8192,步长8) | +| resize_mode | 下拉列表 | fit | 缩放模式:`fit` / `pad` / `stretch` | +| expand_left | INT | 0 | 画布左扩像素(0–4096) | +| expand_right | INT | 0 | 画布右扩像素 | +| expand_top | INT | 0 | 画布上扩像素 | +| expand_bottom | INT | 0 | 画布下扩像素 | +| canvas_fill | 下拉列表 | edge | 画布填充模式:`edge`(边缘延展)/ `blur` / `black` / `white` / `neutral` | +| auto_recommend | 下拉列表 | enabled | 自动推荐去噪值:`enabled` / `disabled` | + +**可选输入** + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| model | MODEL | 扩散模型 | +| model_info | STRING | 模型元信息 | +| positive | CONDITIONING | 正向条件 | +| negative | CONDITIONING | 反向条件 | + +--- + +### LLS Simple Repair Finish + +修复流程的"合成"节点。将修复结果合成回原始图像,支持羽化、颜色匹配、亮度匹配和边缘修复。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| final_image | IMAGE | 最终合成图像 | +| preview_image | IMAGE | 预览图像 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| original_image | IMAGE | — | 原始图像 | +| generated_image | IMAGE | — | 修复后的图像 | +| repair_info | LLS_REPAIR_INFO | — | 修复元数据(来自 Repair Prepare) | +| feather | FLOAT | 8.0 | 羽化半径(0.0–256.0) | +| color_match | 下拉列表 | disabled | 颜色匹配:`disabled` / `mean_std`(均值标准差匹配)/ `histogram_simple`(直方图匹配) | +| brightness_match | 下拉列表 | enabled | 亮度匹配:`disabled` / `enabled` | +| blend_strength | FLOAT | 1.0 | 混合强度(0.0–1.0) | +| restore_unmasked_area | BOOLEAN | True | 是否恢复未遮罩区域 | +| edge_fix | 下拉列表 | soft | 边缘修复:`none` / `soft` / `strong` | +| preview_mode | 下拉列表 | final | 预览模式:`final` / `compare` / `mask` / `before_after` | + +**可选输入** + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| work_mask | MASK | 工作区遮罩 | +| sample_info | STRING | 采样元信息 | + +--- + +### LLS Native Inpaint Conditioning + +ComfyUI 原生 InpaintModelConditioning 的薄封装。可自动为 FLUX 家族应用 DifferentialDiffusion 模型补丁。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| model | MODEL | 可能经过补丁的模型 | +| positive | CONDITIONING | 正向条件 | +| negative | CONDITIONING | 反向条件 | +| latent | LATENT | 编码后的潜空间 | +| inpaint_info | STRING | JSON 格式修复信息 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| model | MODEL | — | 扩散模型 | +| positive | CONDITIONING | — | 正向条件 | +| negative | CONDITIONING | — | 反向条件 | +| vae | VAE | — | VAE 解码器 | +| image | IMAGE | — | 原始图像 | +| mask | MASK | — | 修复遮罩 | +| patch_mode | 下拉列表 | auto | 模型补丁模式:`auto`(FLUX 自动应用)/ `disabled` / `differential_diffusion` | +| patch_strength | FLOAT | 1.0 | 补丁强度(0.0–1.0) | +| noise_mask | BOOLEAN | True | 是否使用噪声遮罩 | + +**可选输入** + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| model_info | STRING | 模型元信息 | + +--- + +## 8. 交互式遮罩绘制 (Mask Draw) + +分类:`LLS/Image Repair` + +### LLS Simple Mask Draw + +交互式遮罩绘制节点。前端 JS 配合实现在画布上绘制/擦除遮罩,后端解析绘制状态并输出遮罩和预览。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| image | IMAGE | 原始图像(透传) | +| mask | MASK | 最终遮罩 | +| preview_image | IMAGE | 遮罩叠加预览图 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| image | IMAGE | — | 原始图像(同时作为绘制画布背景) | +| draw_mode | 下拉列表 | brush | 绘制模式:`brush`(画笔)/ `erase`(擦除) | +| brush_size | INT | 32 | 画笔大小(1–512) | +| brush_softness | FLOAT | 0.5 | 画笔柔和度(0.0–1.0) | +| overlay_alpha | FLOAT | 0.4 | 预览叠加透明度(0.0–1.0) | +| invert_mask | BOOLEAN | False | 是否反转遮罩 | +| mask_state_json | STRING | {} | 遮罩绘制状态 JSON(前端自动维护,高级参数) | + +**可选输入** + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| input_mask | MASK | 初始遮罩(可在此基础继续编辑) | + +--- + +## 9. 专业图像编辑 (Pro Edit) + +分类:`LLS/Image Edit` + +### LLS Pro Image Edit Prepare + +专业图像编辑的"准备"节点。与 Repair Prepare 类似,但面向更通用的图像编辑场景(如局部替换、外观修改),支持自动后端路由。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| latent | LATENT | 工作区潜空间 | +| work_image | IMAGE | 工作区图像 | +| work_mask | MASK | 工作区遮罩 | +| edit_info | LLS_EDIT_INFO | 编辑元数据 | +| recommended_denoise | FLOAT | 推荐去噪强度 | +| positive | CONDITIONING | 正向条件 | +| negative | CONDITIONING | 反向条件 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| image | IMAGE | — | 原始图像 | +| mask | MASK | — | 编辑遮罩 | +| vae | VAE | — | VAE 解码器 | +| positive | CONDITIONING | — | 正向条件 | +| negative | CONDITIONING | — | 反向条件 | +| backend_mode | 下拉列表 | auto | 后端模式:`auto` / `sdxl` / `flux` | +| edit_scope | 下拉列表 | auto | 编辑范围:`auto` / `region` / `crop` / `canvas` | +| mask_grow | INT | 24 | 遮罩扩展(0–2048) | +| mask_blur | FLOAT | 8.0 | 遮罩模糊(0.0–256.0) | +| mask_threshold | FLOAT | 0.5 | 遮罩阈值 | +| invert_mask | BOOLEAN | False | 反转遮罩 | +| crop_context | INT | 64 | 裁剪上下文边距 | +| crop_context_factor | FLOAT | 1.5 | 裁剪上下文因子 | +| min_size | INT | 256 | 最小尺寸 | +| max_size | INT | 1024 | 最大尺寸 | +| resize_mode | 下拉列表 | fit | 缩放模式:`fit` / `pad` / `stretch` | +| expand_left | INT | 0 | 左扩像素 | +| expand_right | INT | 0 | 右扩像素 | +| expand_top | INT | 0 | 上扩像素 | +| expand_bottom | INT | 0 | 下扩像素 | +| canvas_fill | 下拉列表 | edge | 画布填充:`edge` / `blur` / `black` / `white` / `neutral` | +| auto_recommend | 下拉列表 | enabled | 自动推荐:`enabled` / `disabled` | + +**可选输入** + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| model | MODEL | 扩散模型 | +| model_info | STRING | 模型元信息 | + +--- + +### LLS Pro KSampler Bridge + +专业编辑流程的高级采样器桥接节点,支持分步采样控制(start_at_step / end_at_step)。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| latent | LATENT | 采样结果 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| model | MODEL | — | 扩散模型 | +| add_noise | 下拉列表 | enable | 是否添加噪声:`enable` / `disable`(高级参数) | +| noise_seed | INT | 0 | 噪声种子 | +| steps | INT | 20 | 采样步数 | +| cfg | FLOAT | 8.0 | CFG 引导强度 | +| sampler_name | 下拉列表 | — | 采样器 | +| scheduler | 下拉列表 | — | 调度器 | +| positive | CONDITIONING | — | 正向条件 | +| negative | CONDITIONING | — | 反向条件 | +| latent_image | LATENT | — | 输入潜空间 | +| start_at_step | INT | 0 | 起始步(高级参数,0–10000) | +| end_at_step | INT | 10000 | 结束步(高级参数,0–10000) | +| return_with_leftover_noise | 下拉列表 | disable | 返回残余噪声:`disable` / `enable`(高级参数) | + +--- + +### LLS Pro Image Edit Finish + +专业图像编辑的"合成"节点。与 Repair Finish 类似,处理编辑结果的合成回贴。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| final_image | IMAGE | 最终合成图像 | +| preview_image | IMAGE | 预览图像 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| original_image | IMAGE | — | 原始图像 | +| generated_image | IMAGE | — | 编辑后图像 | +| edit_info | LLS_EDIT_INFO | — | 编辑元数据(来自 Pro Edit Prepare) | +| feather | FLOAT | 8.0 | 羽化半径 | +| color_match | 下拉列表 | disabled | 颜色匹配:`disabled` / `mean_std` / `histogram_simple` | +| brightness_match | 下拉列表 | enabled | 亮度匹配:`disabled` / `enabled` | +| blend_strength | FLOAT | 1.0 | 混合强度 | +| restore_unmasked_area | BOOLEAN | True | 恢复未遮罩区域 | +| edge_fix | 下拉列表 | soft | 边缘修复:`none` / `soft` / `strong` | +| preview_mode | 下拉列表 | final | 预览模式:`final` / `compare` / `mask` / `before_after` | + +**可选输入** + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| work_mask | MASK | 工作区遮罩 | +| sample_info | STRING | 采样元信息 | + +--- + +## 10. 图像超分 (Upscale) + +分类:`LLS/Upscale` + +### LLS Upscale Switcher + +超分辨率切换节点,可在 ESRGAN 等超分模型和 PyTorch 插值之间切换。超分模型模式支持分块推理防止 OOM。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| image | IMAGE | 超分后的图像 | +| upscale_info | STRING | JSON 格式超分元信息 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| image | IMAGE | — | 输入图像 | +| mode | 下拉列表 | upscale_model | 超分模式:`none`(不放大)/ `interpolation`(PyTorch 插值)/ `upscale_model`(超分模型)/ `latent_upscale`(潜空间超分,回退到插值)/ `tile_upscale`(分块超分)/ `pytorch`(同 interpolation) | +| scale | FLOAT | 2.0 | 放大倍率(0.1–8.0) | +| interpolation | 下拉列表 | bilinear | 插值方法:`nearest` / `bilinear` / `bicubic` / `area` | +| model_name | 下拉列表 | — | 超分模型文件名 | +| tile | INT | 512 | 分块大小(128–2048,步长64) | +| overlap | INT | 32 | 分块重叠(0–256,步长8) | + +--- + +## 11. 遮罩操作 (Mask) + +分类:`LLS/Mask` + +### LLS Simple Mask Create + +几何遮罩创建节点。通过指定宽高、形状、位置等参数创建矩形/圆形/椭圆遮罩。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| mask_image | IMAGE | 遮罩可视化图像 | +| mask | MASK | 遮罩张量 | +| area_info | STRING | JSON 格式遮罩区域信息 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| image_width | INT | 1024 | 图像宽度(1–8192) | +| image_height | INT | 1024 | 图像高度(1–8192) | +| shape_type | 下拉列表 | rectangle | 形状类型:`rectangle` / `ellipse` / `circle` 等 | +| coordinate_mode | 下拉列表 | percent | 坐标模式:`percent`(百分比)/ `pixel`(像素) | +| center_x | FLOAT | 0.5 | 中心 X 坐标 | +| center_y | FLOAT | 0.5 | 中心 Y 坐标 | +| width | FLOAT | 0.3 | 形状宽度 | +| height | FLOAT | 0.3 | 形状高度 | +| radius | FLOAT | 0.15 | 圆形半径 | +| feather | FLOAT | 0.0 | 羽化强度(0.0–128.0) | +| blur | FLOAT | 0.0 | 模糊强度(0.0–128.0) | +| invert_mask | BOOLEAN | False | 是否反转遮罩 | +| combine_mode | 下拉列表 | replace | 与输入遮罩的合并模式:`replace` / `add` / `subtract` / `intersect` 等 | + +**可选输入** + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| input_mask | MASK | 输入遮罩(在 replace 以外的模式下与此合并) | + +--- + +### LLS Mask Process + +单遮罩处理节点,支持多种常见操作。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| mask | MASK | 处理后的遮罩 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| mask | MASK | — | 输入遮罩 | +| operation | 下拉列表 | passthrough | 处理操作:`passthrough`(透传)/ `threshold`(二值化)/ `invert`(反转)/ `grow`(扩展)/ `shrink`(收缩)/ `blur`(模糊)/ `feather`(羽化)/ `fill_holes`(填充孔洞)/ `remove_small_regions`(去除小区域)/ `smooth`(平滑)/ `clamp`(裁剪)/ `resize_to_image`(缩放至图像尺寸) | +| value_float | FLOAT | 0.5 | 浮点参数(threshold 时为阈值,0.0–1.0) | +| value_int | INT | 8 | 整数参数(grow/shrink/blur 的像素值,-512–512) | + +**可选输入** + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| image | IMAGE | 目标图像(resize_to_image 操作时使用) | + +--- + +### LLS Mask Combine + +双遮罩合并节点,支持逻辑运算风格的合并。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| mask | MASK | 合并后的遮罩 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| mask_a | MASK | — | 遮罩 A | +| mask_b | MASK | — | 遮罩 B | +| mode | 下拉列表 | add | 合并模式:`add`/`max`(取大)/ `subtract`(A减B)/ `intersect`/`min`(取小)/ `xor`(异或) | + +--- + +## 12. 工具节点 (Utils) + +分类:`LLS/Utils` + +### LLS String Literal + +输出一个字符串常量,可复用于多个下游节点。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| string | STRING | 字符串值 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| value | STRING(多行) | "" | 字符串内容 | + +--- + +### LLS Int Literal + +输出一个整数常量。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| int | INT | 整数值 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| value | INT | 0 | 整数值(-2^31 – 2^31-1) | + +--- + +### LLS Float Literal + +输出一个浮点数常量。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| float | FLOAT | 浮点数值 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| value | FLOAT | 0.0 | 浮点数值 | + +--- + +### LLS Resolution Selector + +常用分辨率选择器,返回宽度和高度。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| width | INT | 宽度 | +| height | INT | 高度 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| preset | 下拉列表 | 1024x1024 | 预设分辨率:`Custom` / `512x512` / `768x768` / `1024x1024` / `512x768` / `768x512` / `768x1024` / `1024x768` / `1024x576` / `576x1024` / `1280x720` / `720x1280` / `1920x1080` / `1080x1920` / `2048x2048` | +| custom_width | INT | 1024 | 自定义宽度(64–8192,步长8) | +| custom_height | INT | 1024 | 自定义高度(64–8192,步长8) | + +--- + +### LLS Generation Config + +按模型家族输出推荐分辨率与采样配置。根据 quality_preset 和 size_preset 自动生成最佳参数。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| width | INT | 推荐宽度 | +| height | INT | 推荐高度 | +| steps | INT | 推荐步数 | +| cfg | FLOAT | 推荐 CFG | +| guidance | FLOAT | 推荐 guidance(FLUX 用) | +| sampler_name | STRING | 推荐采样器 | +| scheduler | STRING | 推荐调度器 | +| denoise | FLOAT | 推荐去噪强度 | +| config_info | STRING | JSON 格式完整配置信息 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| quality_preset | 下拉列表 | Family Default | 质量预设:`Family Default` / `Manual` / `Fast` / `Balanced` / `High Quality` | +| size_preset | 下拉列表 | Family Default | 尺寸预设:`Family Default` / `Custom` / `512x512` / `768x768` / `1024x1024` | +| model_family | 下拉列表 | Auto | 模型家族 | + +**可选输入** + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| model | MODEL | 扩散模型 | +| clip | CLIP | CLIP 编码器 | + +--- + +## 13. 根级统一节点 + +### LLS Universal Image Generator + +根级统一 txt2img 入口节点。收集请求并交给 dispatcher 选择对应家族后端适配器(SD1.5 / SDXL / FLUX)。 + +**输出端口** + +| 端口名 | 类型 | 说明 | +|--------|------|------| +| image | IMAGE | 生成的图像 | + +**输入参数** + +| 参数名 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| model_family | 下拉列表 | SD1.5 | 模型家族:`SD1.5` / `SDXL` / `FLUX` | +| task_mode | 下拉列表 | txt2img | 任务模式 | +| model_name | 下拉列表 | — | 模型文件名 | +| positive_prompt | STRING(多行) | "" | 正向提示词 | +| negative_prompt | STRING(多行) | "" | 反向提示词 | +| width | INT | 1024 | 图像宽度(64–8192,步长8) | +| height | INT | 1024 | 图像高度 | +| steps | INT | 20 | 采样步数 | +| cfg | FLOAT | 7.0 | CFG 引导强度 | +| seed | INT | -1 | 随机种子(-1 自动生成) | +| sampler_name | 下拉列表 | euler | 采样器 | +| scheduler | 下拉列表 | normal | 调度器 | +| denoise | FLOAT | 1.0 | 去噪强度 | + +--- + +## 14. 待实现功能域 + +以下子包已创建框架,但节点尚未实现(`NODE_CLASS_MAPPINGS` 为空): + +### ControlNet (`LLS/ControlNet`) + +计划节点: +- LLSControlNetApply / LLSControlNetApplyAdv +- LLST2IAdapterApply / LLSControlNetStack +- LLSCannyPreprocess / LLSDepthPreprocess / LLSHEDPreprocess +- LLSLotusDepth / LLSLotusNormal + +### LoRA (`LLS/LoRA`) + +计划节点: +- LLSLoRALoader / LLSLoRAApply / LLSLoRAStack / LLSLoRAStackApply +- LLSHypernetworkApply / LLSLoRAExtract +- LLSIPAdapterApply / LLSHookLoRA / LLSWeightAdapter + +### Video (`LLS/Video`) + +计划节点: +- LLSLoadVideoFrames / LLSSaveVideo / LLSFrameInterpolation +- LLSSVDSampler / LLSCogVideoXSampler / LLSHunyuanVideoSampler +- LLSWanVideoSampler / LLSLTXVideoSampler / LLSVideoUpscale / LLSCameraControl + +### Audio (`LLS/Audio`) + +计划节点: +- LLSLoadAudio / LLSSaveAudio / LLSAudioCrop / LLSAudioConcat +- LLSAudioResample / LLSAudioVolumeAdjust / LLSAudioBatch +- LLSAudioEncode / LLSCosyVoiceTTS / LLSHunyuanAudio / LLSSpectrogram + +--- + +## 节点总览表 + +| # | 节点类名 | 显示名 | 分类 | 状态 | +|---|----------|--------|------|------| +| 1 | LLSSimpleCheckpointLoader | LLS Simple Checkpoint Loader | LLS/Model Loader | 已实现 | +| 2 | LLSUniversalModelLoader | LLS Universal Model Loader | LLS/Model Loader | 已实现 | +| 3 | LLSSimplePromptEncode | LLS Simple Prompt Encode | LLS/Conditioning | 已实现 | +| 4 | LLSUniversalPromptEncode | LLS Universal Prompt Encode | LLS/Conditioning | 已实现 | +| 5 | LLSSimpleKSampler | LLS Simple KSampler | LLS/Sampling | 已实现 | +| 6 | LLSQwenTextToImage | LLS Qwen Text To Image | LLS/Qwen | 已实现 | +| 7 | LLSQwenImageEdit | LLS Qwen Image Edit | LLS/Qwen | 已实现 | +| 8 | LLSSimpleEmptyLatent | LLS Simple Empty Latent | LLS/Latent | 已实现 | +| 9 | LLSSimpleVAEEncode | LLS Simple VAE Encode | LLS/Image | 已实现 | +| 10 | LLSSimpleVAEDecode | LLS Simple VAE Decode | LLS/Image | 已实现 | +| 11 | LLSSaveImage | LLS Save Image | LLS/Image | 已实现 | +| 12 | LLSSimpleImageComposite | LLS Simple Image Composite | LLS/Image | 已实现 | +| 13 | LLSSimpleRepairPrepare | LLS Simple Repair Prepare | LLS/Image Repair | 已实现 | +| 14 | LLSSimpleRepairFinish | LLS Simple Repair Finish | LLS/Image Repair | 已实现 | +| 15 | LLSNativeInpaintConditioning | LLS Native Inpaint Conditioning | LLS/Image Repair | 已实现 | +| 16 | LLSSimpleMaskDraw | LLS Simple Mask Draw | LLS/Image Repair | 已实现 | +| 17 | LLSProImageEditPrepare | LLS Pro Image Edit Prepare | LLS/Image Edit | 已实现 | +| 18 | LLSProKSamplerBridge | LLS Pro KSampler Bridge | LLS/Image Edit | 已实现 | +| 19 | LLSProImageEditFinish | LLS Pro Image Edit Finish | LLS/Image Edit | 已实现 | +| 20 | LLSUpscaleSwitcher | LLS Upscale Switcher | LLS/Upscale | 已实现 | +| 21 | LLSSimpleMaskCreate | LLS Simple Mask Create | LLS/Mask | 已实现 | +| 22 | LLSMaskProcess | LLS Mask Process | LLS/Mask | 已实现 | +| 23 | LLSMaskCombine | LLS Mask Combine | LLS/Mask | 已实现 | +| 24 | LLSStringLiteral | LLS String Literal | LLS/Utils | 已实现 | +| 25 | LLSIntLiteral | LLS Int Literal | LLS/Utils | 已实现 | +| 26 | LLSFloatLiteral | LLS Float Literal | LLS/Utils | 已实现 | +| 27 | LLSResolutionSelector | LLS Resolution Selector | LLS/Utils | 已实现 | +| 28 | LLSGenerationConfig | LLS Generation Config | LLS/Utils | 已实现 | +| 29 | LLSUniversalImageGenerator | LLS Universal Image Generator | LLS/Image | 已实现 | diff --git a/tests/test_concat_by_target_node.py b/tests/test_concat_by_target_node.py new file mode 100644 index 0000000..24a4081 --- /dev/null +++ b/tests/test_concat_by_target_node.py @@ -0,0 +1,230 @@ +import unittest + +try: + from .test_mask_draw_helpers import load_plugin_package, torch +except ImportError: + from test_mask_draw_helpers import load_plugin_package, torch + + +HAS_CONCAT_RUNTIME_DEPS = torch is not None +CONCAT_RUNTIME_DEPS_MESSAGE = "concat node tests require torch" + + +@unittest.skipUnless(HAS_CONCAT_RUNTIME_DEPS, CONCAT_RUNTIME_DEPS_MESSAGE) +class TestConcatByTargetNode(unittest.TestCase): + @classmethod + def setUpClass(cls): + plugin = load_plugin_package() + cls.node = plugin.NODE_CLASS_MAPPINGS["LLSConcatByTarget"]() + + def make_image(self, width, height, value, batch=1): + return torch.full((batch, height, width, 3), float(value), dtype=torch.float32) + + def make_mask(self, width, height, value, batch=1): + return torch.full((batch, height, width), float(value), dtype=torch.float32) + + def assert_image_output(self, image, mask, width, height, shape): + self.assertEqual((width, height), (shape[2], shape[1])) + self.assertEqual(tuple(image.shape), shape) + self.assertEqual(tuple(mask.shape), (shape[0], shape[1], shape[2])) + self.assertGreaterEqual(float(image.min().item()), 0.0) + self.assertLessEqual(float(image.max().item()), 1.0) + self.assertTrue(torch.allclose(mask, torch.zeros_like(mask))) + + def test_image_mode_target_b_right_places_a_to_the_right_of_b(self): + image_a = self.make_image(width=2, height=2, value=0.2) + image_b = self.make_image(width=3, height=2, value=0.8) + + image, mask, width, height = self.node.concat( + data_type="IMAGE", + target="B", + position="right", + match_target_size=True, + resize_mode="none", + align="center", + gap=1, + background_color="#000000", + background_value=0.0, + multiple_of=0, + allow_batch_broadcast=True, + image_a=image_a, + image_b=image_b, + mask_a=None, + mask_b=None, + ) + + self.assert_image_output(image, mask, width, height, (1, 2, 6, 3)) + self.assertTrue(torch.allclose(image[:, :, 0:3, :], torch.full((1, 2, 3, 3), 0.8))) + self.assertTrue(torch.allclose(image[:, :, 3:4, :], torch.zeros((1, 2, 1, 3)))) + self.assertTrue(torch.allclose(image[:, :, 4:6, :], torch.full((1, 2, 2, 3), 0.2))) + + def test_image_mode_keep_proportion_gap_and_multiple_of(self): + image_a = self.make_image(width=4, height=4, value=0.25) + image_b = self.make_image(width=2, height=2, value=0.75) + + image, mask, width, height = self.node.concat( + data_type="IMAGE", + target="A", + position="right", + match_target_size=True, + resize_mode="keep_proportion", + align="center", + gap=1, + background_color="#808080", + background_value=0.0, + multiple_of=4, + allow_batch_broadcast=True, + image_a=image_a, + image_b=image_b, + mask_a=None, + mask_b=None, + ) + + self.assert_image_output(image, mask, width, height, (1, 4, 12, 3)) + self.assertTrue(torch.allclose(image[:, :, 0:4, :], torch.full((1, 4, 4, 3), 0.25))) + self.assertTrue(torch.allclose(image[:, :, 4:5, :], torch.full((1, 4, 1, 3), 128.0 / 255.0))) + self.assertTrue(torch.allclose(image[:, :, 5:9, :], torch.full((1, 4, 4, 3), 0.75))) + self.assertTrue(torch.allclose(image[:, :, 9:12, :], torch.full((1, 4, 3, 3), 128.0 / 255.0))) + + def test_mask_mode_outputs_mask_and_preview_image(self): + mask_a = self.make_mask(width=2, height=2, value=1.0) + mask_b = self.make_mask(width=2, height=2, value=0.0) + + image, mask, width, height = self.node.concat( + data_type="MASK", + target="A", + position="bottom", + match_target_size=True, + resize_mode="none", + align="center", + gap=1, + background_color="#000000", + background_value=0.25, + multiple_of=0, + allow_batch_broadcast=True, + image_a=None, + image_b=None, + mask_a=mask_a, + mask_b=mask_b, + ) + + self.assertEqual((width, height), (2, 5)) + self.assertEqual(tuple(mask.shape), (1, 5, 2)) + self.assertEqual(tuple(image.shape), (1, 5, 2, 3)) + self.assertTrue(torch.allclose(mask[:, 0:2, :], torch.full((1, 2, 2), 1.0))) + self.assertTrue(torch.allclose(mask[:, 2:3, :], torch.full((1, 1, 2), 0.25))) + self.assertTrue(torch.allclose(mask[:, 3:5, :], torch.zeros((1, 2, 2)))) + self.assertTrue(torch.allclose(image[..., 0], mask)) + self.assertTrue(torch.allclose(image[..., 1], mask)) + self.assertTrue(torch.allclose(image[..., 2], mask)) + + def test_batch_broadcast_works_when_enabled(self): + image_a = self.make_image(width=2, height=2, value=0.1, batch=1) + image_b = self.make_image(width=2, height=2, value=0.9, batch=2) + + image, mask, width, height = self.node.concat( + data_type="IMAGE", + target="A", + position="left", + match_target_size=True, + resize_mode="none", + align="center", + gap=0, + background_color="#000000", + background_value=0.0, + multiple_of=0, + allow_batch_broadcast=True, + image_a=image_a, + image_b=image_b, + mask_a=None, + mask_b=None, + ) + + self.assert_image_output(image, mask, width, height, (2, 2, 4, 3)) + self.assertTrue(torch.allclose(image[:, :, 0:2, :], torch.full((2, 2, 2, 3), 0.9))) + self.assertTrue(torch.allclose(image[:, :, 2:4, :], torch.full((2, 2, 2, 3), 0.1))) + + def test_missing_required_inputs_for_current_mode_raise_clear_errors(self): + with self.assertRaisesRegex(RuntimeError, "image_a and image_b"): + self.node.concat( + data_type="IMAGE", + target="A", + position="right", + match_target_size=True, + resize_mode="none", + align="center", + gap=0, + background_color="#000000", + background_value=0.0, + multiple_of=0, + allow_batch_broadcast=True, + image_a=None, + image_b=None, + mask_a=self.make_mask(1, 1, 0.0), + mask_b=self.make_mask(1, 1, 0.0), + ) + + with self.assertRaisesRegex(RuntimeError, "mask_a and mask_b"): + self.node.concat( + data_type="MASK", + target="A", + position="right", + match_target_size=True, + resize_mode="none", + align="center", + gap=0, + background_color="#000000", + background_value=0.0, + multiple_of=0, + allow_batch_broadcast=True, + image_a=self.make_image(1, 1, 0.0), + image_b=self.make_image(1, 1, 0.0), + mask_a=None, + mask_b=None, + ) + + def test_invalid_background_color_and_batch_mismatch_raise_clear_errors(self): + image_a = self.make_image(width=2, height=2, value=0.1, batch=2) + image_b = self.make_image(width=2, height=2, value=0.9, batch=3) + + with self.assertRaisesRegex(RuntimeError, "background_color"): + self.node.concat( + data_type="IMAGE", + target="A", + position="right", + match_target_size=True, + resize_mode="none", + align="center", + gap=0, + background_color="bad-color", + background_value=0.0, + multiple_of=0, + allow_batch_broadcast=True, + image_a=self.make_image(2, 2, 0.1), + image_b=self.make_image(2, 2, 0.9), + mask_a=None, + mask_b=None, + ) + + with self.assertRaisesRegex(RuntimeError, "Batch size mismatch"): + self.node.concat( + data_type="IMAGE", + target="A", + position="right", + match_target_size=True, + resize_mode="none", + align="center", + gap=0, + background_color="#000000", + background_value=0.0, + multiple_of=0, + allow_batch_broadcast=False, + image_a=image_a, + image_b=image_b, + mask_a=None, + mask_b=None, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_concat_by_target_registration.py b/tests/test_concat_by_target_registration.py new file mode 100644 index 0000000..c2f7444 --- /dev/null +++ b/tests/test_concat_by_target_registration.py @@ -0,0 +1,51 @@ +import unittest + +try: + from .test_mask_draw_helpers import load_plugin_package +except ImportError: + from test_mask_draw_helpers import load_plugin_package + + +class TestConcatByTargetRegistration(unittest.TestCase): + def test_plugin_registers_concat_by_target_node(self): + plugin = load_plugin_package() + + self.assertIn("LLSConcatByTarget", plugin.NODE_CLASS_MAPPINGS) + self.assertEqual( + plugin.NODE_DISPLAY_NAME_MAPPINGS["LLSConcatByTarget"], + "LLS Concat By Target", + ) + + def test_concat_by_target_schema_matches_contract(self): + plugin = load_plugin_package() + node_cls = plugin.NODE_CLASS_MAPPINGS["LLSConcatByTarget"] + schema = node_cls.INPUT_TYPES() + required = schema["required"] + optional = schema["optional"] + + self.assertEqual(node_cls.CATEGORY, "LLS/Utils") + self.assertEqual(node_cls.FUNCTION, "concat") + self.assertEqual(node_cls.RETURN_TYPES, ("IMAGE", "MASK", "INT", "INT")) + self.assertEqual(node_cls.RETURN_NAMES, ("image", "mask", "width", "height")) + self.assertEqual(required["data_type"][0], ["IMAGE", "MASK"]) + self.assertEqual(required["target"][0], ["A", "B"]) + self.assertEqual(required["position"][0], ["top", "bottom", "left", "right"]) + self.assertEqual(required["resize_mode"][0], ["keep_proportion", "stretch", "none"]) + self.assertEqual(required["align"][0], ["start", "center", "end"]) + self.assertEqual(required["match_target_size"][0], "BOOLEAN") + self.assertEqual(required["gap"][0], "INT") + self.assertEqual(required["gap"][1]["default"], 0) + self.assertEqual(required["background_color"][0], "STRING") + self.assertEqual(required["background_color"][1]["default"], "#000000") + self.assertEqual(required["background_value"][0], "FLOAT") + self.assertEqual(required["background_value"][1]["default"], 0.0) + self.assertEqual(required["multiple_of"][0], "INT") + self.assertEqual(required["allow_batch_broadcast"][0], "BOOLEAN") + self.assertEqual(optional["image_a"], ("IMAGE",)) + self.assertEqual(optional["image_b"], ("IMAGE",)) + self.assertEqual(optional["mask_a"], ("MASK",)) + self.assertEqual(optional["mask_b"], ("MASK",)) + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/concat_by_target.py b/utils/concat_by_target.py new file mode 100644 index 0000000..58d6d99 --- /dev/null +++ b/utils/concat_by_target.py @@ -0,0 +1,488 @@ +from __future__ import annotations + +try: + import torch + import torch.nn.functional as torch_nn_functional +except Exception as exc: # pragma: no cover - optional runtime dependency + torch = None + torch_nn_functional = None + _TORCH_ERR = exc +else: # pragma: no cover - exercised in runtime/tests + _TORCH_ERR = None + + +DATA_TYPE_CHOICES = ["IMAGE", "MASK"] +TARGET_CHOICES = ["A", "B"] +POSITION_CHOICES = ["top", "bottom", "left", "right"] +RESIZE_MODE_CHOICES = ["keep_proportion", "stretch", "none"] +ALIGN_CHOICES = ["start", "center", "end"] + + +def _require_torch(): + if torch is None or torch_nn_functional is None: + raise RuntimeError("[LLS] torch is required for LLS Concat By Target.") from _TORCH_ERR + + +def _validate_choice(name: str, value: str, choices): + if value not in choices: + raise RuntimeError(f"[LLS] Invalid {name} '{value}'. Expected one of: {', '.join(choices)}.") + + +def parse_hex_color(color_str: str): + value = str(color_str or "").strip() + if value.startswith("#"): + value = value[1:] + if len(value) == 3: + value = "".join(part * 2 for part in value) + if len(value) != 6: + raise RuntimeError("[LLS] background_color must be a valid HEX color like '#000000' or '#ffffff'.") + try: + red = int(value[0:2], 16) / 255.0 + green = int(value[2:4], 16) / 255.0 + blue = int(value[4:6], 16) / 255.0 + except ValueError as exc: + raise RuntimeError( + "[LLS] background_color must be a valid HEX color like '#000000' or '#ffffff'." + ) from exc + return red, green, blue + + +def ensure_image_tensor(x): + _require_torch() + if x is None: + raise RuntimeError("[LLS] IMAGE mode requires both image_a and image_b inputs.") + if not isinstance(x, torch.Tensor): + x = torch.as_tensor(x) + if x.ndim == 3: + x = x.unsqueeze(0) + if x.ndim != 4: + raise RuntimeError("[LLS] IMAGE input must have shape [B,H,W,C] or [H,W,C].") + batch, height, width, channels = [int(dim) for dim in x.shape] + if batch <= 0 or height <= 0 or width <= 0 or channels <= 0: + raise RuntimeError("[LLS] IMAGE input must have positive batch, height, width, and channels.") + if not torch.is_floating_point(x): + x = x.to(dtype=torch.float32) + return x + + +def ensure_mask_tensor(x): + _require_torch() + if x is None: + raise RuntimeError("[LLS] MASK mode requires both mask_a and mask_b inputs.") + if not isinstance(x, torch.Tensor): + x = torch.as_tensor(x) + if x.ndim == 2: + x = x.unsqueeze(0) + elif x.ndim == 3: + pass + elif x.ndim == 4 and int(x.shape[-1]) == 1: + x = x.squeeze(-1) + elif x.ndim == 4 and int(x.shape[1]) == 1: + x = x.squeeze(1) + else: + raise RuntimeError("[LLS] MASK input must have shape [B,H,W], [H,W], [B,H,W,1], or [B,1,H,W].") + if not torch.is_floating_point(x): + x = x.to(dtype=torch.float32) + return x.to(dtype=torch.float32).clamp(0.0, 1.0) + + +def _repeat_batch(tensor, target_batch: int): + repeat_shape = [1] * tensor.ndim + repeat_shape[0] = target_batch + return tensor.repeat(*repeat_shape) + + +def broadcast_batch(a, b, allow_batch_broadcast: bool): + batch_a = int(a.shape[0]) + batch_b = int(b.shape[0]) + if batch_a == batch_b: + return a, b + if allow_batch_broadcast and batch_a == 1 and batch_b > 1: + return _repeat_batch(a, batch_b), b + if allow_batch_broadcast and batch_b == 1 and batch_a > 1: + return a, _repeat_batch(b, batch_a) + raise RuntimeError( + f"[LLS] Batch size mismatch: A has batch {batch_a}, B has batch {batch_b}, and broadcasting is disabled or impossible." + ) + + +def resize_tensor_to_match(tensor, *, data_type: str, match_axis: str, target_size: int, resize_mode: str): + _validate_choice("resize_mode", resize_mode, RESIZE_MODE_CHOICES) + if resize_mode == "none": + return tensor + + if data_type == "IMAGE": + current_height = int(tensor.shape[1]) + current_width = int(tensor.shape[2]) + else: + current_height = int(tensor.shape[1]) + current_width = int(tensor.shape[2]) + + if match_axis == "height": + if current_height == target_size: + return tensor + new_height = max(1, int(target_size)) + if resize_mode == "keep_proportion": + scale = float(new_height) / float(current_height) + new_width = max(1, int(round(current_width * scale))) + else: + new_width = current_width + elif match_axis == "width": + if current_width == target_size: + return tensor + new_width = max(1, int(target_size)) + if resize_mode == "keep_proportion": + scale = float(new_width) / float(current_width) + new_height = max(1, int(round(current_height * scale))) + else: + new_height = current_height + else: + raise RuntimeError(f"[LLS] Unsupported match axis '{match_axis}'.") + + if data_type == "IMAGE": + resized = torch_nn_functional.interpolate( + tensor.movedim(-1, 1), + size=(new_height, new_width), + mode="bilinear", + align_corners=False, + ) + return resized.movedim(1, -1) + + resized = torch_nn_functional.interpolate( + tensor.unsqueeze(1), + size=(new_height, new_width), + mode="nearest", + ) + return resized.squeeze(1) + + +def create_canvas( + *, + data_type: str, + batch: int, + height: int, + width: int, + device, + dtype, + channels: int | None = None, + background_color=None, + background_value: float = 0.0, +): + if data_type == "IMAGE": + if channels is None: + raise RuntimeError("[LLS] IMAGE canvas creation requires a channels value.") + canvas = torch.zeros((batch, height, width, channels), device=device, dtype=dtype) + red, green, blue = background_color + if channels == 1: + canvas.fill_((red + green + blue) / 3.0) + return canvas + fill_values = [0.0] * channels + fill_values[0] = red + if channels > 1: + fill_values[1] = green + if channels > 2: + fill_values[2] = blue + if channels == 4: + fill_values[3] = 1.0 + fill_tensor = torch.tensor(fill_values, device=device, dtype=dtype).view(1, 1, 1, channels) + canvas[:] = fill_tensor + return canvas + + return torch.full( + (batch, height, width), + float(background_value), + device=device, + dtype=dtype, + ) + + +def paste_tensor(canvas, tensor, top: int, left: int): + if canvas.ndim == 4: + canvas[:, top : top + tensor.shape[1], left : left + tensor.shape[2], :] = tensor + else: + canvas[:, top : top + tensor.shape[1], left : left + tensor.shape[2]] = tensor + return canvas + + +def pad_to_multiple( + tensor, + *, + data_type: str, + multiple_of: int, + background_color=None, + background_value: float = 0.0, +): + if multiple_of <= 0: + return tensor + + if data_type == "IMAGE": + batch, height, width, channels = [int(dim) for dim in tensor.shape] + dtype = tensor.dtype + device = tensor.device + else: + batch, height, width = [int(dim) for dim in tensor.shape] + channels = None + dtype = tensor.dtype + device = tensor.device + + padded_height = ((height + multiple_of - 1) // multiple_of) * multiple_of + padded_width = ((width + multiple_of - 1) // multiple_of) * multiple_of + if padded_height == height and padded_width == width: + return tensor + + canvas = create_canvas( + data_type=data_type, + batch=batch, + height=padded_height, + width=padded_width, + channels=channels, + device=device, + dtype=dtype, + background_color=background_color, + background_value=background_value, + ) + return paste_tensor(canvas, tensor, 0, 0) + + +def _alignment_offset(container_size: int, item_size: int, align: str) -> int: + _validate_choice("align", align, ALIGN_CHOICES) + if align == "start": + return 0 + if align == "center": + return max(0, (container_size - item_size) // 2) + return max(0, container_size - item_size) + + +def concat_by_target( + *, + data_type: str, + tensor_a, + tensor_b, + target: str, + position: str, + match_target_size: bool, + resize_mode: str, + align: str, + gap: int, + multiple_of: int, + allow_batch_broadcast: bool, + background_color=None, + background_value: float = 0.0, +): + _validate_choice("data_type", data_type, DATA_TYPE_CHOICES) + _validate_choice("target", target, TARGET_CHOICES) + _validate_choice("position", position, POSITION_CHOICES) + _validate_choice("resize_mode", resize_mode, RESIZE_MODE_CHOICES) + _validate_choice("align", align, ALIGN_CHOICES) + if gap < 0: + raise RuntimeError("[LLS] gap must be >= 0.") + if multiple_of < 0: + raise RuntimeError("[LLS] multiple_of must be >= 0.") + + tensor_a, tensor_b = broadcast_batch(tensor_a, tensor_b, allow_batch_broadcast) + target_tensor = tensor_a if target == "A" else tensor_b + other_tensor = tensor_b if target == "A" else tensor_a + + if data_type == "IMAGE": + work_dtype = target_tensor.dtype if torch.is_floating_point(target_tensor) else torch.float32 + target_tensor = target_tensor.to(dtype=work_dtype) + other_tensor = other_tensor.to(device=target_tensor.device, dtype=work_dtype) + else: + target_tensor = target_tensor.to(dtype=torch.float32) + other_tensor = other_tensor.to(device=target_tensor.device, dtype=torch.float32) + + if match_target_size: + match_axis = "height" if position in {"left", "right"} else "width" + target_size = int(target_tensor.shape[1] if match_axis == "height" else target_tensor.shape[2]) + other_tensor = resize_tensor_to_match( + other_tensor, + data_type=data_type, + match_axis=match_axis, + target_size=target_size, + resize_mode=resize_mode, + ) + + if data_type == "IMAGE": + batch = int(target_tensor.shape[0]) + target_height = int(target_tensor.shape[1]) + target_width = int(target_tensor.shape[2]) + other_height = int(other_tensor.shape[1]) + other_width = int(other_tensor.shape[2]) + channels = int(target_tensor.shape[3]) + else: + batch = int(target_tensor.shape[0]) + target_height = int(target_tensor.shape[1]) + target_width = int(target_tensor.shape[2]) + other_height = int(other_tensor.shape[1]) + other_width = int(other_tensor.shape[2]) + channels = None + + if position in {"left", "right"}: + canvas_height = max(target_height, other_height) + canvas_width = target_width + int(gap) + other_width + target_top = _alignment_offset(canvas_height, target_height, align) + other_top = _alignment_offset(canvas_height, other_height, align) + if position == "right": + target_left = 0 + other_left = target_width + int(gap) + else: + other_left = 0 + target_left = other_width + int(gap) + else: + canvas_height = target_height + int(gap) + other_height + canvas_width = max(target_width, other_width) + target_left = _alignment_offset(canvas_width, target_width, align) + other_left = _alignment_offset(canvas_width, other_width, align) + if position == "bottom": + target_top = 0 + other_top = target_height + int(gap) + else: + other_top = 0 + target_top = other_height + int(gap) + + canvas = create_canvas( + data_type=data_type, + batch=batch, + height=canvas_height, + width=canvas_width, + channels=channels, + device=target_tensor.device, + dtype=target_tensor.dtype, + background_color=background_color, + background_value=background_value, + ) + paste_tensor(canvas, target_tensor, target_top, target_left) + paste_tensor(canvas, other_tensor, other_top, other_left) + canvas = pad_to_multiple( + canvas, + data_type=data_type, + multiple_of=int(multiple_of), + background_color=background_color, + background_value=background_value, + ) + + if data_type == "IMAGE": + final_height = int(canvas.shape[1]) + final_width = int(canvas.shape[2]) + else: + final_height = int(canvas.shape[1]) + final_width = int(canvas.shape[2]) + return canvas, final_width, final_height + + +def mask_to_preview_image(mask): + normalized = ensure_mask_tensor(mask) + return normalized.unsqueeze(-1).repeat(1, 1, 1, 3).clamp(0.0, 1.0) + + +class LLSConcatByTarget: + CATEGORY = "LLS/Utils" + FUNCTION = "concat" + RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT") + RETURN_NAMES = ("image", "mask", "width", "height") + DESCRIPTION = "Concatenate IMAGE or MASK inputs around a chosen target canvas." + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "data_type": (DATA_TYPE_CHOICES, {"default": "IMAGE"}), + "target": (TARGET_CHOICES, {"default": "A"}), + "position": (POSITION_CHOICES, {"default": "right"}), + "match_target_size": ("BOOLEAN", {"default": True}), + "resize_mode": (RESIZE_MODE_CHOICES, {"default": "keep_proportion"}), + "align": (ALIGN_CHOICES, {"default": "center"}), + "gap": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}), + "background_color": ("STRING", {"default": "#000000", "multiline": False}), + "background_value": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "multiple_of": ("INT", {"default": 0, "min": 0, "max": 512, "step": 1}), + "allow_batch_broadcast": ("BOOLEAN", {"default": True}), + }, + "optional": { + "image_a": ("IMAGE",), + "image_b": ("IMAGE",), + "mask_a": ("MASK",), + "mask_b": ("MASK",), + }, + } + + def concat( + self, + data_type, + target, + position, + match_target_size, + resize_mode, + align, + gap, + background_color, + background_value, + multiple_of, + allow_batch_broadcast, + image_a=None, + image_b=None, + mask_a=None, + mask_b=None, + ): + _require_torch() + data_type = str(data_type or "IMAGE") + background_value = max(0.0, min(1.0, float(background_value))) + + if data_type == "IMAGE": + if image_a is None or image_b is None: + raise RuntimeError("[LLS] IMAGE mode requires both image_a and image_b inputs.") + color = parse_hex_color(background_color) + tensor_a = ensure_image_tensor(image_a) + tensor_b = ensure_image_tensor(image_b) + output_image, width, height = concat_by_target( + data_type="IMAGE", + tensor_a=tensor_a, + tensor_b=tensor_b, + target=str(target), + position=str(position), + match_target_size=bool(match_target_size), + resize_mode=str(resize_mode), + align=str(align), + gap=int(gap), + multiple_of=int(multiple_of), + allow_batch_broadcast=bool(allow_batch_broadcast), + background_color=color, + background_value=background_value, + ) + output_image = output_image.clamp(0.0, 1.0) + output_mask = torch.zeros( + (int(output_image.shape[0]), int(output_image.shape[1]), int(output_image.shape[2])), + device=output_image.device, + dtype=torch.float32, + ) + return output_image, output_mask, int(width), int(height) + + if data_type == "MASK": + if mask_a is None or mask_b is None: + raise RuntimeError("[LLS] MASK mode requires both mask_a and mask_b inputs.") + tensor_a = ensure_mask_tensor(mask_a) + tensor_b = ensure_mask_tensor(mask_b) + output_mask, width, height = concat_by_target( + data_type="MASK", + tensor_a=tensor_a, + tensor_b=tensor_b, + target=str(target), + position=str(position), + match_target_size=bool(match_target_size), + resize_mode=str(resize_mode), + align=str(align), + gap=int(gap), + multiple_of=int(multiple_of), + allow_batch_broadcast=bool(allow_batch_broadcast), + background_color=None, + background_value=background_value, + ) + output_mask = output_mask.clamp(0.0, 1.0).to(dtype=torch.float32) + output_image = mask_to_preview_image(output_mask) + return output_image, output_mask, int(width), int(height) + + raise RuntimeError(f"[LLS] Invalid data_type '{data_type}'. Expected IMAGE or MASK.") + + +NODE_CLASS_MAPPINGS = {"LLSConcatByTarget": LLSConcatByTarget} +NODE_DISPLAY_NAME_MAPPINGS = {"LLSConcatByTarget": "LLS Concat By Target"} diff --git a/utils/nodes.py b/utils/nodes.py index 3f6885b..82711d3 100644 --- a/utils/nodes.py +++ b/utils/nodes.py @@ -32,6 +32,10 @@ LLS / Utils """ from __future__ import annotations +from .concat_by_target import ( + NODE_CLASS_MAPPINGS as CONCAT_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as CONCAT_NODE_DISPLAY_NAME_MAPPINGS, +) from .model_info import ( FAMILY_DEFAULT_PRESET, MODEL_FAMILY_CHOICES, @@ -238,6 +242,7 @@ NODE_CLASS_MAPPINGS: dict[str, type] = { "LLSResolutionSelector": LLSResolutionSelector, "LLSGenerationConfig": LLSGenerationConfig, } +NODE_CLASS_MAPPINGS.update(CONCAT_NODE_CLASS_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = { "LLSStringLiteral": "LLS String Literal", @@ -246,3 +251,4 @@ NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = { "LLSResolutionSelector": "LLS Resolution Selector", "LLSGenerationConfig": "LLS Generation Config", } +NODE_DISPLAY_NAME_MAPPINGS.update(CONCAT_NODE_DISPLAY_NAME_MAPPINGS)