From 1975e983b67bb09ebe72f0cfe8aa079b9b445eb2 Mon Sep 17 00:00:00 2001 From: wjluoxiao <331030818@qq.com> Date: Sat, 14 Nov 2026 04:05:36 +0800 Subject: [PATCH] =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/en/nodeDefs.json | 14 +- locales/zh/nodeDefs.json | 16 +- nodes/image/nodes_image_preset.py | 2 +- nodes/image/nodes_image_prompt_preset.py | 18 +- nodes/image/nodes_image_prompt_preset_pro.py | 12 +- nodes/image/nodes_qwen_prompt_preset.py | 14 +- web/xb_compat.js | 243 ++++++++++++++++++- web/xb_image_prompt_preset.js | 117 ++++----- web/xb_image_prompt_preset_pro.js | 121 ++++----- web/xb_qwen_prompt_preset.js | 127 ++++------ 10 files changed, 415 insertions(+), 269 deletions(-) diff --git a/locales/en/nodeDefs.json b/locales/en/nodeDefs.json index 84e1a60..8e521f2 100644 --- a/locales/en/nodeDefs.json +++ b/locales/en/nodeDefs.json @@ -489,10 +489,10 @@ }, "XB_ImagePromptPresetPro": { "display_name": "XB-BOX - 🖼️ Image Prompt Preset Pro", - "description": "Image Prompt Preset + Prompt Enhance/Reverse fused: empty latent type + aspect params + 8-category element panel; optional LLM enhance/reverse (local model or online API, enhance preset / task preset / extra settings / sampling params). Outputs: prompt / prompt list / prompt settings / empty latent.", + "description": "Image Prompt Preset + Prompt Enhance/Reverse fused: latent type + aspect params + 8-category element panel; optional LLM enhance/reverse (local model or online API, enhance preset / task preset / extra settings / sampling params). Outputs: prompt / prompt list / prompt settings / empty latent.", "inputs": { "latent_kind": { - "name": "Empty Latent Type" + "name": "Latent Type" }, "output_lang": { "name": "Output Language" @@ -525,7 +525,7 @@ "name": "Batch Size" }, "use_llm": { - "name": "✅ Enable LLM Enhance" + "name": "Enable LLM" }, "backend": { "name": "LLM Backend" @@ -560,10 +560,10 @@ }, "XB_QwenPromptPreset": { "display_name": "XB-BOX - 🖼️ Qwen2.1 Prompt Preset", - "description": "Image prompt preset + official Load CLIP + official Generate Text: empty latent type / aspect params + 8-category element panel, then the text encoder turns the body into a full prompt. Outputs: prompt / empty latent.", + "description": "Image prompt preset + official Load CLIP + official Generate Text: latent type / aspect params + 8-category element panel, then the text encoder turns the body into a full prompt. Outputs: prompt / empty latent.", "inputs": { "latent_kind": { - "name": "Empty Latent Type" + "name": "Latent Type" }, "output_lang": { "name": "Output Language" @@ -669,7 +669,7 @@ "name": "Preset Mode" }, "latent_kind": { - "name": "Empty Latent Type" + "name": "Latent Type" }, "three_view_text": { "name": "Preset Text (manage presets from the buttons above)" @@ -707,7 +707,7 @@ "description": "Everything in Image Prompt Preset (latent type + aspect ratio / size / batch + 8-element prompt panels + preset files), plus two pickers: Pose Reference and Outfit Reference. Bind a catalogued gallery (filter by system / category / angle / tag, grouped by pose with an angle strip) or any plain image folder (grid by file name). Each reference can be disabled: a disabled output produces nothing (like muting it) and skips downstream nodes. Outputs: prompt / empty latent / pose image / outfit image.", "inputs": { "latent_kind": { - "name": "Empty Latent Type" + "name": "Latent Type" }, "output_lang": { "name": "Output Language" diff --git a/locales/zh/nodeDefs.json b/locales/zh/nodeDefs.json index 29e2a5d..3b7e3f4 100644 --- a/locales/zh/nodeDefs.json +++ b/locales/zh/nodeDefs.json @@ -5263,10 +5263,10 @@ }, "XB_ImagePromptPresetPro": { "display_name": "XB-BOX - 🖼️ 生图提示词预设Pro", - "description": "生图提示词预设 + 提示词增强反推 融合:空latent类型/画幅参数 + 8 大类元素面板拼装提示词;可选 LLM 反推(本地模型或在线 API,增强预设 / 反推预设 / 追加设定 / 采样参数 / 生成后控制)。输出:提示词 / 空latent / 提示词列表 / 提示词设定。", + "description": "生图提示词预设 + 提示词增强反推 融合:latent类型/画幅参数 + 8 大类元素面板拼装提示词;可选 LLM 反推(本地模型或在线 API,增强预设 / 反推预设 / 追加设定 / 采样参数 / 生成后控制)。输出:提示词 / 空latent / 提示词列表 / 提示词设定。", "inputs": { "latent_kind": { - "name": "空latent类型" + "name": "latent类型" }, "output_lang": { "name": "输出语言" @@ -5299,7 +5299,7 @@ "name": "生成数量" }, "use_llm": { - "name": "✅ 启用 LLM 反推" + "name": "启用LLM" }, "backend": { "name": "LLM 后端" @@ -5334,10 +5334,10 @@ }, "XB_QwenPromptPreset": { "display_name": "XB-BOX - 🖼️ Qwen2.1提示词预设", - "description": "生图提示词预设 + 官方 Load CLIP + 官方 Generate Text:空latent类型 / 画幅参数 + 8 大类元素面板拼装提示词,再用文本编码器把正文生成成完整提示词。输出:提示词 / 空latent。", + "description": "生图提示词预设 + 官方 Load CLIP + 官方 Generate Text:latent类型 / 画幅参数 + 8 大类元素面板拼装提示词,再用文本编码器把正文生成成完整提示词。输出:提示词 / 空latent。", "inputs": { "latent_kind": { - "name": "空latent类型" + "name": "latent类型" }, "output_lang": { "name": "输出语言" @@ -5443,7 +5443,7 @@ "name": "预设模式" }, "latent_kind": { - "name": "空latent类型" + "name": "latent类型" }, "three_view_text": { "name": "预设句(右上角按钮管理预设)" @@ -5478,10 +5478,10 @@ }, "XB_ImagePreset": { "display_name": "XB-BOX - 🖼️ 生图提示词预设(图)", - "description": "生图提示词预设的全部能力(空latent类型 + 画幅比例/宽高/数量 + 8 大类元素面板拼词 + 预设文件),外加「🎬 姿态参考图选择 / 👚 穿搭参考图选择」两个按钮:可绑定带素材清单的整套图库(体系/分类/视角/标签筛选 + 姿势分组 + 视角条)或任意普通图片文件夹(按文件名多宫格)。参考图可单独勾选「禁用」:禁用后该输出不做任何输出(等同 mute),下游节点会被跳过。输出:提示词 / 空latent / 姿态图 / 穿搭图。", + "description": "生图提示词预设的全部能力(latent类型 + 画幅比例/宽高/数量 + 8 大类元素面板拼词 + 预设文件),外加「🎬 姿态参考图选择 / 👚 穿搭参考图选择」两个按钮:可绑定带素材清单的整套图库(体系/分类/视角/标签筛选 + 姿势分组 + 视角条)或任意普通图片文件夹(按文件名多宫格)。参考图可单独勾选「禁用」:禁用后该输出不做任何输出(等同 mute),下游节点会被跳过。输出:提示词 / 空latent / 姿态图 / 穿搭图。", "inputs": { "latent_kind": { - "name": "空latent类型" + "name": "latent类型" }, "output_lang": { "name": "输出语言" diff --git a/nodes/image/nodes_image_preset.py b/nodes/image/nodes_image_preset.py index 64468a9..20c594c 100644 --- a/nodes/image/nodes_image_preset.py +++ b/nodes/image/nodes_image_preset.py @@ -32,7 +32,7 @@ class XB_ImagePreset(XB_ImagePromptPreset): RETURN_NAMES = ("提示词", "空latent", "姿态图", "穿搭图") FUNCTION = "generate" CATEGORY = "XB_ToolBox/Image_Params" - DESCRIPTION = ("生图预设:在「生图提示词预设」的全部能力(空latent类型 + 画幅比例/宽高/数量 + 8 大类元素面板拼词 + " + DESCRIPTION = ("生图预设:在「生图提示词预设」的全部能力(latent类型 + 画幅比例/宽高/数量 + 8 大类元素面板拼词 + " "预设文件)之上,新增「🎬 姿态参考图选择 / 👚 穿搭参考图选择」两个按钮:可绑定带素材清单的整套图库" "(按体系/分类/视角/标签筛选,按姿势编号分组并带视角条)或任意普通图片文件夹(按文件名多宫格)。" "参考图可单独勾选「禁用」:禁用后该输出不做任何输出(等同 mute),下游节点会被跳过。" diff --git a/nodes/image/nodes_image_prompt_preset.py b/nodes/image/nodes_image_prompt_preset.py index 1807005..2f4b25a 100644 --- a/nodes/image/nodes_image_prompt_preset.py +++ b/nodes/image/nodes_image_prompt_preset.py @@ -3,7 +3,7 @@ 一个节点顶替「提示词输入框 + 画幅比例节点 + 官方空 latent」三件套: · 表面参数(顺序 = 节点上从上到下的显示顺序): - 空latent类型 / 输出语言 / 预设模式 / 画幅比例 / 宽度 / 高度 / 生成数量 + latent类型 / 输出语言 / 预设模式 / 画幅比例 / 宽度 / 高度 / 生成数量 —— 画幅比例 + 步长锁定 + 比例联动 100% 复刻本包「XB-BOX - 🖼️ 图片参数大全」 (去掉强度(小数)/强度(整数)/缩放尺寸三项) —— 预设句不再是表面参数(节点上没有文本显示框):选哪个预设模式,就用该模式预设 txt 的内容 @@ -33,7 +33,7 @@ · `manager_settings`(内部字段,由前端面板隐藏,表面不显示)只存**元素面板配置 JSON** (已选词条 / 自建槽位 / 补充描述 / 自动保存),随工作流保存、节点间互不影响; · `internal_prompt`(内部字段,同上前端隐藏)存提示词正文; -· 空latent类型 / 输出语言 / 预设模式 / 预设句 都是**节点表面参数**(随工作流保存)。 +· latent类型 / 输出语言 / 预设模式 / 预设句 都是**节点表面参数**(随工作流保存)。 预设模式 = 预设 txt 文件(见 xb_presets.py): · 目录 `xb_skill/XB_ImagePromptPreset/ZH` 与 `.../EN`,**一个 txt = 一个选项**; @@ -56,7 +56,7 @@ from ...libs import xb_presets ASPECT_RATIO_OPTIONS = ["Free", "1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "2:3", "3:2"] ASPECT_RATIO_MAP = {"1:1": 1.0, "16:9": 16 / 9, "9:16": 9 / 16, "4:3": 4 / 3, "3:4": 3 / 4, "21:9": 21 / 9, "2:3": 2 / 3, "3:2": 3 / 2} -SIZE_STEP = 16 # 宽高步长的兜底值(实际以「空latent类型」的官方最小步长为准:8/16/32) +SIZE_STEP = 16 # 宽高步长的兜底值(实际以「latent类型」的官方最小步长为准:8/16/32) SIZE_MIN = 16 BATCH_MAX = 4096 # 官方 EmptyLatentImage / EmptySD3LatentImage 的 batch 上限 MAX_RESOLUTION = int(getattr(nodes, "MAX_RESOLUTION", 16384) or 16384) @@ -321,7 +321,7 @@ ELEMENT_CATEGORIES = ("prefix", "angle", "subject", "action", "clothes", "props" def _default_settings(): """节点默认配置(与前端 js/xb_image_prompt_preset.js 的 defaultSettings() 一一对应)。 - ⚠️ 自 0.9.x 起:输出语言 / 预设模式 / 预设句 / 空latent类型 已上提为**节点表面参数**, + ⚠️ 自 0.9.x 起:输出语言 / 预设模式 / 预设句 / latent类型 已上提为**节点表面参数**, 不再存在这份 JSON 里(JSON 只留元素面板配置 + 自动保存开关)。 """ return { @@ -812,7 +812,7 @@ def build_prompt(lang, preset_mode, three_view_text, body, has_image=None, io_mo class XB_ImagePromptPreset: """XB-BOX - 🖼️ 生图提示词预设:画幅参数 + 提示词拼装 + 官方空 latent 一体化。 - · 表面参数顺序 = 空latent类型 / 输出语言 / 预设句(带「添加预设 / 编辑预设 / 复制文本」三个小按钮) + · 表面参数顺序 = latent类型 / 输出语言 / 预设句(带「添加预设 / 编辑预设 / 复制文本」三个小按钮) / 画幅比例 / 宽度 / 高度 / 生成数量(「预设模式」下拉已取消,字段仅作老工作流兼容保留); · 「提示词」输出 = 预设句 + 节点提示词框正文; · 「空latent」输出 = 官方空 latent(见 LATENT_KINDS,共 11 种,逐字段对齐,选项按首字母排列)。 @@ -826,7 +826,7 @@ class XB_ImagePromptPreset: # ── 空latent 类型放最顶(用户要求)── "latent_kind": (list(LATENT_KINDS), { "default": DEFAULT_LATENT_KIND, - "tooltip": "空latent类型:选你正在用的模型即可(自动适配形状/下采样/步长)。\n" + "tooltip": "latent类型:选你正在用的模型即可(自动适配形状/下采样/步长)。\n" "Z-image 16ch·/8·16 Flux2 128ch·/16·16 Flux3 128ch·/16·16(同 Flux2)\n" "Moss-image 128ch·/16·16(同 Flux2) Qwen-image 16ch·/8·32\n" "Krea2 16ch·/8·16 Anima 16ch·/8·8 Boogu 16ch·/8·8\n" @@ -855,12 +855,12 @@ class XB_ImagePromptPreset: }), "width": ("INT", { "default": 1024, "min": SIZE_MIN, "max": MAX_RESOLUTION, "step": SIZE_STEP, - "tooltip": "图片宽度(像素)。固定比例下改宽度会按比例重算高度;步长按「空latent类型」的官方最小步长" + "tooltip": "图片宽度(像素)。固定比例下改宽度会按比例重算高度;步长按「latent类型」的官方最小步长" "(SDXL/Anima/Boogu=8,Flux2/Flux3/Krea2/SD3/Z-image=16,Qwen-image/Hunyuan=32)", }), "height": ("INT", { "default": 1024, "min": SIZE_MIN, "max": MAX_RESOLUTION, "step": SIZE_STEP, - "tooltip": "图片高度(像素)。固定比例下改高度会按比例重算宽度;步长按「空latent类型」的官方最小步长(8/16/32)", + "tooltip": "图片高度(像素)。固定比例下改高度会按比例重算宽度;步长按「latent类型」的官方最小步长(8/16/32)", }), "batch_size": ("INT", { "default": 1, "min": 1, "max": BATCH_MAX, @@ -886,7 +886,7 @@ class XB_ImagePromptPreset: RETURN_NAMES = ("提示词", "空latent") FUNCTION = "generate" CATEGORY = "XB_ToolBox/Image_Params" - DESCRIPTION = ("生图提示词预设:空latent类型(主流模型一键适配 Z-image/Flux2/Flux3/Moss-image/Qwen-image/Krea2/" + DESCRIPTION = ("生图提示词预设:latent类型(主流模型一键适配 Z-image/Flux2/Flux3/Moss-image/Qwen-image/Krea2/" "Anima/Boogu/SDXL/SD3/Hunyuan)+ 输出语言/预设模式 + 画幅比例/宽度/高度/生成数量" "(步长按各模型官方最小步长 8/16/32 + 比例联动)+ 提示词拼装(8 大类:风格/视角/主体/姿态/穿搭/道具/光影/背景)。" "预设模式的选项与设定词来自 xb_skill/XB_ImagePromptPreset 下的 ZH / EN 预设 txt(按系统语言自动选择)。" diff --git a/nodes/image/nodes_image_prompt_preset_pro.py b/nodes/image/nodes_image_prompt_preset_pro.py index 92773f0..7765338 100644 --- a/nodes/image/nodes_image_prompt_preset_pro.py +++ b/nodes/image/nodes_image_prompt_preset_pro.py @@ -4,7 +4,7 @@ XB-BOX - 🖼️ 生图提示词预设Pro 融合「XB-BOX - 🖼️ 生图提示词预设」与「XB-llama - ✨ 提示词增强反推」两个节点: ① 生图预设(原节点全部能力) - · 空latent类型(9 种主流模型,逐字段对齐官方 latent_format) + · latent类型(9 种主流模型,逐字段对齐官方 latent_format) · 输出语言 / 预设模式 / 设定词(三视图·四视图·五视图·背景纯透明) - 设定词既作为 LLM 的**增强参考**,也(启用 LLM 时)被**原封不动**加在最终提示词最顶端; - 用户改过的设定词按「模式 + 语言」存在本节点里,换模式 / 换语言都不会丢。 @@ -69,7 +69,7 @@ from ...libs.xb_skills import SKILL_NONE, skill_list, skill_sig, skill_text ASPECT_RATIO_OPTIONS = ["Free", "1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "2:3", "3:2"] ASPECT_RATIO_MAP = {"1:1": 1.0, "16:9": 16 / 9, "9:16": 9 / 16, "4:3": 4 / 3, "3:4": 3 / 4, "21:9": 21 / 9, "2:3": 2 / 3, "3:2": 3 / 2} -SIZE_STEP = 16 # 宽高步长的兜底值(实际以「空latent类型」的官方最小步长为准:8/16/32) +SIZE_STEP = 16 # 宽高步长的兜底值(实际以「latent类型」的官方最小步长为准:8/16/32) SIZE_MIN = 16 BATCH_MAX = 4096 # 官方 EmptyLatentImage / EmptySD3LatentImage 的 batch 上限 MAX_RESOLUTION = int(getattr(nodes, "MAX_RESOLUTION", 16384) or 16384) @@ -725,7 +725,7 @@ class XB_ImagePromptPresetPro: # ══ ① 生图预设(顺序与「🖼️ 生图提示词预设」保持一致)══ "latent_kind": (list(LATENT_KINDS), { "default": DEFAULT_LATENT_KIND, - "tooltip": "空latent类型:选你正在用的模型即可(自动适配形状/下采样/步长)", + "tooltip": "latent类型:选你正在用的模型即可(自动适配形状/下采样/步长)", }), "output_lang": (list(OUTPUT_LANGS), { "default": DEFAULT_LANG, @@ -758,11 +758,11 @@ class XB_ImagePromptPresetPro: }), "width": ("INT", { "default": 1024, "min": SIZE_MIN, "max": MAX_RESOLUTION, "step": SIZE_STEP, - "tooltip": "图片宽度(步长按「空latent类型」的官方最小步长 8/16/32)", + "tooltip": "图片宽度(步长按「latent类型」的官方最小步长 8/16/32)", }), "height": ("INT", { "default": 1024, "min": SIZE_MIN, "max": MAX_RESOLUTION, "step": SIZE_STEP, - "tooltip": "图片高度(步长按「空latent类型」的官方最小步长 8/16/32)", + "tooltip": "图片高度(步长按「latent类型」的官方最小步长 8/16/32)", }), "batch_size": ("INT", { "default": 1, "min": 1, "max": BATCH_MAX, @@ -823,7 +823,7 @@ class XB_ImagePromptPresetPro: OUTPUT_IS_LIST = (False, False, True, False) FUNCTION = "generate" CATEGORY = "XB_ToolBox/Image_Params" - DESCRIPTION = ("生图提示词预设Pro:生图预设(空latent类型/输出语言/预设模式/画幅参数 + 8 大类元素面板提示词拼装)" + DESCRIPTION = ("生图提示词预设Pro:生图预设(latent类型/输出语言/预设模式/画幅参数 + 8 大类元素面板提示词拼装)" "+ LLM 提示词增强反推(本地模型或在线 API、增强预设、反推预设、追加设定、采样参数、生成后控制)。" "「启用 LLM 反推」关 = 原预设行为;开 = 图/文 → 提示词。输出:提示词 / 空latent / 提示词列表 / 提示词设定。") diff --git a/nodes/image/nodes_qwen_prompt_preset.py b/nodes/image/nodes_qwen_prompt_preset.py index c3d31a8..25d211f 100644 --- a/nodes/image/nodes_qwen_prompt_preset.py +++ b/nodes/image/nodes_qwen_prompt_preset.py @@ -5,7 +5,7 @@ XB-BOX - 🖼️ Qwen2.1提示词预设 把「LLM 反推」换成 **官方 Load CLIP + 官方 Generate Text** 这套纯本地文本生成链路。 ① 生图预设 - · 空latent类型(9 种主流模型,逐字段对齐官方 latent_format) + · latent类型(9 种主流模型,逐字段对齐官方 latent_format) · 输出语言 / 预设模式 / 设定词(三视图·四视图·五视图·背景纯透明) · 画幅比例 / 宽度 / 高度 / 生成数量 · 元素面板提示词拼装(8 大类词表,前端 js/xb_qwen_prompt_preset.js) @@ -48,7 +48,7 @@ import comfy.sd ASPECT_RATIO_OPTIONS = ["Free", "1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "2:3", "3:2"] ASPECT_RATIO_MAP = {"1:1": 1.0, "16:9": 16 / 9, "9:16": 9 / 16, "4:3": 4 / 3, "3:4": 3 / 4, "21:9": 21 / 9, "2:3": 2 / 3, "3:2": 3 / 2} -SIZE_STEP = 16 # 宽高步长的兜底值(实际以「空latent类型」的官方最小步长为准:8/16/32) +SIZE_STEP = 16 # 宽高步长的兜底值(实际以「latent类型」的官方最小步长为准:8/16/32) SIZE_MIN = 16 BATCH_MAX = 4096 # 官方 EmptyLatentImage / EmptySD3LatentImage 的 batch 上限 MAX_RESOLUTION = int(getattr(nodes, "MAX_RESOLUTION", 16384) or 16384) @@ -679,7 +679,7 @@ from ...libs.xb_skills import skill_sig as _skill_signer from ...libs.xb_skills import skill_text as _read_skill_text DEFAULT_LANG = OUTPUT_LANGS[0] -# 用户要求:本节点的空latent类型默认 Qwen-image(与节点定位一致) +# 用户要求:本节点的latent类型默认 Qwen-image(与节点定位一致) DEFAULT_LATENT = "Qwen-image" if "Qwen-image" in LATENT_KINDS else DEFAULT_LATENT_KIND # ══════════════════════════════════════════════════════════════ @@ -894,7 +894,7 @@ class XB_QwenPromptPreset: # ══ ① 生图预设(与「🖼️ 生图提示词预设」一致)══ "latent_kind": (list(LATENT_KINDS), { "default": DEFAULT_LATENT, - "tooltip": "空latent类型:选你正在用的模型即可(自动适配形状/下采样/步长)", + "tooltip": "latent类型:选你正在用的模型即可(自动适配形状/下采样/步长)", }), "output_lang": (list(OUTPUT_LANGS), { "default": DEFAULT_LANG, @@ -929,11 +929,11 @@ class XB_QwenPromptPreset: }), "width": ("INT", { "default": 1024, "min": SIZE_MIN, "max": MAX_RESOLUTION, "step": SIZE_STEP, - "tooltip": "图片宽度(步长按「空latent类型」的官方最小步长 8/16/32)", + "tooltip": "图片宽度(步长按「latent类型」的官方最小步长 8/16/32)", }), "height": ("INT", { "default": 1024, "min": SIZE_MIN, "max": MAX_RESOLUTION, "step": SIZE_STEP, - "tooltip": "图片高度(步长按「空latent类型」的官方最小步长 8/16/32)", + "tooltip": "图片高度(步长按「latent类型」的官方最小步长 8/16/32)", }), "batch_size": ("INT", { "default": 1, "min": 1, "max": BATCH_MAX, @@ -1047,7 +1047,7 @@ class XB_QwenPromptPreset: OUTPUT_IS_LIST = (False, False) FUNCTION = "generate" CATEGORY = "XB_ToolBox/Image_Params" - DESCRIPTION = ("Qwen2.1提示词预设:生图预设(空latent类型/输出语言/预设模式/画幅参数 + 8 大类元素面板拼装)" + DESCRIPTION = ("Qwen2.1提示词预设:生图预设(latent类型/输出语言/预设模式/画幅参数 + 8 大类元素面板拼装)" "+ 官方 Load CLIP 与官方 Generate Text(纯本地文本生成)。" "「启用 / 关闭 LLM 增强」在弹窗「🤖 LLM设置」顶部(默认启用;关闭 = 只输出拼装好的提示词)。" "输出:提示词 / 空latent。") diff --git a/web/xb_compat.js b/web/xb_compat.js index 857d3d6..c52618c 100644 --- a/web/xb_compat.js +++ b/web/xb_compat.js @@ -701,12 +701,251 @@ export function bindNodes2NodeFill(node, box, opts = {}) { return uninstall; } +/** + * ============================================================ + * 稳定的节点高度管理(Nodes 2.0 / Vue 节点) + * ============================================================ + * 实测得到的两条硬事实(这是所有尺寸问题的根源): + * ① **DOM 渲染高度 = max(node.size[1], 内容真实需求) + 标题栏(30)**, + * 与画布缩放无关(offsetHeight 在 0.5x / 1x / 1.5x 下完全一致)。 + * 正常(不溢出)时恒有 `offsetHeight == size[1] + 30`。 + * ② 一旦 `size[1] < 内容需求`,DOM 就会**撑破**节点盒子:视觉底边跑到 size 之外, + * 于是「拖拽弹跳」「底边压不上网格」全部来自这一个错位。 + * + * 因此本实现只做两件事,且只在就绪后 / 松手后各跑一次: + * · **喂饱**:`size[1]` 不允许小于 DOM 实测需求(`offsetHeight - 30`), + * 且必须连续两次读到同样的溢出量才采纳 —— 布局中间帧的瞬时大值不会被永久固化 + * (这是「偶发自动变高」的根因); + * · **压线**:`视觉底边 = pos.y + size[1] + 30`,把它补到网格线(δ 最多 9px)。 + * 因为标题栏 30 本身是网格整数倍,只要 size 与 pos 都是网格倍数就自然对齐。 + * · 不劫持 `onResize`:拖拽完全交给 LiteGraph 原生处理,避免和原生吸附互相打架。 + * + * @param {object} node LiteGraph 节点 + * @param {object} opts + * · minWidth 最小宽度(默认 420) + * · rows 节点表面「可见参数行数」(数字或函数) + * · domFixedH 兜底常量估算用:DOM 区固定高度 + * · textMinH 兜底常量估算用:提示词框最小高度 + * @returns {{ apply:Function, align:Function, markReady:Function, measure:Function, rows:Function, + * destroy:Function, chrome:Function, domHeight:Function, grid:Function, isReady:Function }} + */ +export function installStableNodeHeight(node, opts = {}) { + const MIN_W = Number(opts.minWidth) || 420; + const TITLE_H = 30; // Vue 节点标题栏高度(实测恒定) + const ROW_H = 26; // 原生 widget 单行高度 + const DOM_FIXED_H = Number.isFinite(opts.domFixedH) ? opts.domFixedH : 116; + const TEXT_MIN_H = Number.isFinite(opts.textMinH) ? opts.textMinH : 300; + const HARD_MIN = 420; + const rowsOf = typeof opts.rows === "function" ? opts.rows : () => (Number(opts.rows) || 0); + + /** 画布网格步长(ComfyUI 设置 → LiteGraph.CANVAS_GRID_SIZE → 10) */ + const gridOf = () => { + try { + const v = app?.ui?.settings?.getSettingValue?.("Comfy.SnapToGrid.GridSize"); + if (Number.isFinite(v) && v > 0) return v; + } catch (_) { /* ignore */ } + const lg = Number(window.LiteGraph?.CANVAS_GRID_SIZE); + return Number.isFinite(lg) && lg > 0 ? lg : 10; + }; + /** 取节点**根**元素:同一个 data-node-id 还会命中节点内部的「节点编号」徽标(宽 20~30px), + * 它的高度与节点毫无关系,必须排除,否则量出来的高度是胡说八道。 */ + const nodeEl = () => { + try { + const list = document.querySelectorAll(`[data-node-id="${node.id}"]`); + let best = null; + for (const el of list) { + if (!String(el.className || "").includes("lg-node")) continue; + if (!el.isConnected) continue; + if (!best || el.getBoundingClientRect().width > best.getBoundingClientRect().width) best = el; + } + return best; + } catch (_) { return null; } + }; + /** + * DOM 实测高度,并**归一化到图单位**。 + * ⎠ 必须自校准:DOM 量出来的是渲染像素,受 UI 缩放 / 浏览器缩放 / 画布变换影响; + * 而 node.size 是图单位。两者比例不为 1 时,差值会随 size 线性放大, + * 于是每一轮都判定「还差一点」→ 再长高 → 差值更大 → **指数级一直变高**(用户报障)。 + * 缩放因子用「宽度」自校准(DOM 宽度就是按 node.size[0] 给的),不依赖任何假设。 + * ⎡ 同时只认 .lg-node 根元素,避开「节点编号」徽标。 + */ + const nodeMetrics = () => { + const el = nodeEl(); + if (!el) return null; + let r; + try { r = el.getBoundingClientRect(); } catch (_) { return null; } + const wNode = Number(node.size?.[0]) || 0; + if (!(r.width > 0) || !(r.height > 0) || !(wNode > 0)) return null; + const k = r.width / wNode; // 渲染缩放因子(正常为 1) + if (!(k > 0.05) || !(k < 20)) return null; + return { k, h: r.height / k }; + }; + /** 节点 DOM 实际渲染高度(已归一化到与 node.size 同单位) */ + const domHeight = () => { + const m = nodeMetrics(); + return m ? m.h : 0; + }; + let ready = false; + let pendingNeed = 0; + let retries = 0; + let alignTimer = null; + let verifyUntil = 0; + let confirmedNeed = 0; // 已确认的内容需求(同宽度下不变),用于拖拽到下限时即时弹回、不反复抖 + let confirmedW = 0; + let grownTo = 0; // 上一次「因内容溢出而长高」到的 size + let grownNeed = 0; // 长高时读到的需求 + let frozen = false; // 长高后溢出不减 ⇒ 溢出与 size 无关,永不再长(防指数级跑飞) + + /** 延时再对齐一次(去重)——用于「溢出量需要两次确认」的二次采样 */ + const scheduleAlign = (delay) => { + if (alignTimer) return; + alignTimer = setTimeout(() => { + alignTimer = null; + try { align(); } catch (_) { /* ignore */ } + }, delay || 120); + }; + + /** 兜底估算(仅供下限/调试;真实值以 DOM 实测为准) */ + const measure = () => Math.max(HARD_MIN, Math.round(TITLE_H + rowsOf() * ROW_H + DOM_FIXED_H + TEXT_MIN_H)); + + /** DOM 实测的「内容真实需求」高度;0 表示 DOM 还没挂载 */ + const domNeed = () => { + const h = domHeight(); + return h > 0 ? h - TITLE_H : 0; + }; + + /** chrome = DOM 高度 − size,正常应为 30(标题栏);大于 30 说明内容溢出 */ + const chromeOf = () => { + const h = domHeight(); + const cur = Number(node.size?.[1]) || 0; + return h > 0 && cur > 0 ? Math.round(h - cur) : 0; + }; + + const lockWidth = () => { + try { + const w = Number.isFinite(node.size?.[0]) ? node.size[0] : 0; + if (w >= MIN_W) return; + node.setSize?.([MIN_W, Math.max(Number(node.size?.[1]) || HARD_MIN, HARD_MIN)]); + } catch (_) { /* ignore */ } + }; + + /** 对齐一次(同步)——不能用 await:页面不可见时 requestAnimationFrame 不会回调, + * 一旦 await 它就会永远挂住。改为同步写入 + 多次调用(markReady 四拍 / 松手后延时)收敛。 */ + const align = () => { + if (!ready || !node.size) return 0; + lockWidth(); + const w = Number(node.size?.[0]) || MIN_W; + let h = Number(node.size?.[1]) || HARD_MIN; + let changed = false; + + // ① 喂饱:连续两次读到同样的溢出量才采纳,避免布局中间帧把节点永久顶高 + const need = domNeed(); + if (need > h + 2) { + if (frozen) { + // 已判定「长高治不了溢出」:不再动 size(否则每轮都长一点 → 一直变高) + pendingNeed = 0; + retries = 0; + } else if (grownTo && Math.abs(h - grownTo) < 2 && need > grownNeed + 2) { + // 已按需求长过一次,DOM 反而又变高了 ⇒ 量出来的高度与 size 脱钩,长高无效,冻结 + frozen = true; + pendingNeed = 0; + retries = 0; + try { + console.warn(`[XB-BOX] 节点高度增长无法收敛(size=${Math.round(h)} 需求=${Math.round(need)} 宽度=${Math.round(w)}),已冻结自动长高:${node.title || node.type}`); + } catch (_) { /* ignore */ } + } else if (Math.abs(need - confirmedNeed) <= 2 && Math.abs(w - confirmedW) <= 1) { + h = Math.ceil(need); // 已知需求:立即弹回,不再等二次采样 + pendingNeed = 0; + retries = 0; + } else if (Math.abs(need - pendingNeed) <= 2) { + h = Math.ceil(need); + confirmedNeed = need; + confirmedW = w; + pendingNeed = 0; + retries = 0; + } else { + pendingNeed = need; + if (retries++ < 8) scheduleAlign(150); // 自己排一次二次采样,不依赖外部再调 + } + } else { + pendingNeed = 0; + retries = 0; + } + + // ② 压线:视觉底边 = pos.y + size + 标题栏 + const g = gridOf() > 0 ? gridOf() : 10; + const bottom = (Number(node.pos?.[1]) || 0) + Math.round(h) + TITLE_H; + const delta = (g - (bottom % g)) % g; + if (delta > 0.5) h = Math.round(h) + delta; + + if (Math.abs(h - (Number(node.size?.[1]) || 0)) >= 1) { + const prevSize = Number(node.size?.[1]) || 0; + const growing = h > prevSize + 2; + if (growing) { grownTo = Math.round(h); grownNeed = need; } + else if (frozen && h < prevSize - 2) { frozen = false; grownTo = 0; grownNeed = 0; } // 被拖矮了 → 重新允许长一次 + node.setSize([w, Math.round(h)]); + node.setDirtyCanvas?.(true, true); + changed = true; + verifyUntil = Date.now() + 4000; // 尺寸刚变过,允许后续对开渲染核对 + } else if (!frozen && chromeOf() > TITLE_H + 4 && Date.now() < verifyUntil) { + // 内容仍溢出但短时内没读到稳定需求(DOM 重排布有延迟)→ 再等一拍复查 + scheduleAlign(300); + } + return changed ? 1 : 0; + }; + + /** DOM 挂载 + 内部字段藏好之后调用一次(此后才允许写尺寸) */ + const markReady = () => { + ready = true; + pendingNeed = 0; + retries = 0; + confirmedNeed = 0; + confirmedW = 0; + grownTo = 0; + grownNeed = 0; + frozen = false; + verifyUntil = Date.now() + 5000; + [0, 120, 400, 900].forEach((d) => setTimeout(() => { try { align(); } catch (_) { /* ignore */ } }, d)); + }; + + // ⚠️ 不劫持 onResize:拖拽全交给 LiteGraph 原生(自己劫持是「弹跳好几格」的直接原因) + + // 工作流加载 / 复制节点后重新对齐(configure 会覆盖 size,必须重新喂一次) + try { + const prevCfg = node.onConfigure; + node.onConfigure = function (...args) { + const r = prevCfg?.apply(this, args); + ready = false; + pendingNeed = 0; + retries = 0; + confirmedNeed = 0; + confirmedW = 0; + grownTo = 0; + grownNeed = 0; + frozen = false; + verifyUntil = 0; + setTimeout(() => { try { markReady(); } catch (_) { /* ignore */ } }, 150); + return r; + }; + } catch (_) { /* ignore */ } + + const destroy = () => { try { node.__xbStableHeight = null; } catch (_) { /* ignore */ } }; + + const api = { + apply: align, align, markReady, measure, rows: rowsOf, destroy, + chrome: chromeOf, domHeight, grid: gridOf, + isReady: () => ready, + }; + try { node.__xbStableHeight = api; } catch (_) { /* ignore */ } + return api; + +} + /** * 遍历「根图 + 所有子图(含嵌套)」,返回全部节点。 * 放在子图里的节点不在 app.graph._nodes 里,轮询时必须用这个。 */ -export function allNodes() { - const out = []; +export function allNodes() { const out = []; const seenGraphs = new Set(); const seenNodes = new Set(); const visit = (g) => { diff --git a/web/xb_image_prompt_preset.js b/web/xb_image_prompt_preset.js index c1baabd..0f42fda 100644 --- a/web/xb_image_prompt_preset.js +++ b/web/xb_image_prompt_preset.js @@ -2,7 +2,7 @@ * XB-BOX - 🖼️ 生图提示词预设 — 前端面板 * ============================================================ * 节点:XB_ImagePromptPreset(后端 nodes_image_prompt_preset.py,V1 经典 API) - * 表面:空latent类型 / 输出语言 / 预设模式 / (预设句:选中有预设文件的档位时显示)/ 画幅比例 / 宽度 / 高度 / 生成数量 + * 表面:latent类型 / 输出语言 / 预设模式 / (预设句:选中有预设文件的档位时显示)/ 画幅比例 / 宽度 / 高度 / 生成数量 * + 节点提示词框 + 8 个分类按钮(4 列 × 2 行):🏷️ 风格 | 🎥 视角 | 👤 主体 | 🎬 姿态 | 👚 装扮 | 🎒 道具 | 💡 光影 | 🏞️ 背景 * 每个按钮一个面板,面板顶部「分类签」切子类;选项行整行可点(加入 → 变蓝 ✔ / 再点移除) * 道具类点行会先弹「与主体的关系」多宫格标签窗(30 个关系词) @@ -26,7 +26,7 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; -import { bindNodes2NodeFill, installLatentGuard } from "./xb_compat.js"; +import { installLatentGuard, installStableNodeHeight } from "./xb_compat.js"; import { openRefPicker } from "./xb_gallery_picker.js"; const NODE_TYPE = "XB_ImagePromptPreset"; @@ -109,7 +109,7 @@ function modeHasImage(node) { const ASPECT_MAP = { "1:1": 1, "16:9": 16 / 9, "9:16": 9 / 16, "4:3": 4 / 3, "3:4": 3 / 4, "21:9": 21 / 9, "2:3": 2 / 3, "3:2": 3 / 2 }; -const SIZE_STEP = 16; // width/height 的兜底步长(实际以「空latent类型」的官方最小步长为准) +const SIZE_STEP = 16; // width/height 的兜底步长(实际以「latent类型」的官方最小步长为准) const SIZE_MIN = 16; const SIZE_MAX = 16384; const BATCH_MAX = 4096; @@ -1660,7 +1660,7 @@ function bindWheelZoom(root) { * 配置读写 * ============================================================ */ function defaultSettings() { - // ⚠️ 自 0.9.x 起:输出语言 / 预设模式 / 预设句 / 空latent类型 已上提为**节点表面参数**, + // ⚠️ 自 0.9.x 起:输出语言 / 预设模式 / 预设句 / latent类型 已上提为**节点表面参数**, // 不再存在这份 JSON 里;JSON 只留元素面板配置 + 自动保存开关。 return { auto_save: true, @@ -1819,7 +1819,7 @@ function pickOpt(options, value, def) { const v = (value == null) ? "" : String(value).trim(); return options.includes(v) ? v : def; } -/** 步长锁定(与后端 _round_step 一致:floor(v/step + 0.5);step/min/max 由空latent类型决定) */ +/** 步长锁定(与后端 _round_step 一致:floor(v/step + 0.5);step/min/max 由latent类型决定) */ function roundStep(v, step = SIZE_STEP, minSize = SIZE_MIN, maxSize = SIZE_MAX) { const n = Math.floor((Number(v) || 0) / step + 0.5) * step; return Math.max(minSize, Math.min(maxSize, n)); @@ -2791,7 +2791,7 @@ function setupNode(node) { }; hideInternal(); - // ── 「空latent」输出没连线时:空latent类型 / 画幅比例 / 宽度 / 高度 / 生成数量 全部变灰不可操作 + // ── 「空latent」输出没连线时:latent类型 / 画幅比例 / 宽度 / 高度 / 生成数量 全部变灰不可操作 // (接了「空latent」输出连线后自动恢复;接不接都只是 UI 层面,后端取值不受影响) const refreshLatentGuard = installLatentGuard(node, ["latent_kind", "aspect_ratio", "width", "height", "batch_size"]); // ── 节点表面 DOM:结构照抄「短剧导演台」(js/asset_manager.js)── @@ -2949,7 +2949,7 @@ function setupNode(node) { // 只剩一个提示词文本框:预设内容已经并进正文,面板不再额外前置 presetText: "", }); - /** 当前空latent类型对应的步长/上下限(后端按同一张表算,保证所见即所得) */ + /** 当前latent类型对应的步长/上下限(后端按同一张表算,保证所见即所得) */ const sizeOpt = () => { const sp = latentSpec(surfaceOf().kind); return { step: sp.step, min: sp.min, max: sp.max }; @@ -3416,19 +3416,19 @@ function setupNode(node) { node.setDirtyCanvas?.(true, true); }; - // 空latent类型 → 同步 width/height/batch 的原生 step & 上下限(箭头/拖动按官方步长走) + // latent类型 → 同步 width/height/batch 的原生 step & 上下限(箭头/拖动按官方步长走) const applyKindLimits = () => { const sp = latentSpec(surfaceOf().kind); for (const w of [wW, wH]) { if (w && w.options) { w.options.step = w.options.step2 = sp.step; w.options.min = sp.min; w.options.max = sp.max; } } if (wB && wB.options) wB.options.max = sp.batchMax; - if (wKind && wKind.options) wKind.options.tooltip = `空latent类型(${sp.desc})\n尺寸步长 ${sp.step},batch 上限 ${sp.batchMax}。\n选你正在用的模型即可,形状/下采样/步长会自动适配。`; + if (wKind && wKind.options) wKind.options.tooltip = `latent类型(${sp.desc})\n尺寸步长 ${sp.step},batch 上限 ${sp.batchMax}。\n选你正在用的模型即可,形状/下采样/步长会自动适配。`; const b = parseInt(readWidgetValue(wB), 10) || 1; if (b > sp.batchMax) setWidgetValue(wB, sp.batchMax); }; - // ── 画幅比例 + 步长联动(语义对齐 js/xb_video.js;步长按当前空latent类型) ── + // ── 画幅比例 + 步长联动(语义对齐 js/xb_video.js;步长按当前latent类型) ── let syncing = false; const snapByWidth = () => { if (syncing) return; @@ -3538,79 +3538,45 @@ function setupNode(node) { node._xbDomWidget = widget; node._xbSyncWidth = syncDomWidth; // 供 canvas.onDrawForeground 每帧调用 - // 尺寸刷新 —— 照抄短剧导演台 `refreshSize`: - // ① 用 LiteGraph 自己的 computeSize() 算高度(不读任何 DOM 尺寸 → 无反馈环) - // ② 只做「下限保护」:未设置 / 小于下限时才 setSize,绝不覆盖用户拖拽出来的高度 - // ③ 只在「初始化(3 次) + 工作流加载(configure)」调用,打字 / 面板保存 / onResize 一律不调 - // ⚠️ 这就是「整个界面卡顿」的隔离带:一旦在打字或 onResize 里调它, - // setSize → onResize → setSize 会形成抖动环,每帧整画布重绘 → 全界面卡。 - // 下限 = 标题 + 各可见 widget 实际高度(多行 STRING 会更高)+ DOM 固定行 + 提示词框最小高度。 - // 这样 4 个或 8 个表面参数(三视图预设句框显/隐)都自适应,不用手写魔数。 - const widgetRowH = (w) => { - try { - const s = w && w.computeSize ? w.computeSize(node.size?.[0] || 420) : null; - if (Array.isArray(s) && Number.isFinite(s[1]) && s[1] > 0) return s[1]; - } catch (_) {} - return (w && (w.type === "hidden" || w.hidden)) ? 0 : 26; - }; - const minNodeHeight = () => { - let h = 30; // 节点标题 - for (const w of (node.widgets || [])) h += widgetRowH(w); - return Math.max(420, Math.round(h + DOM_FIXED_H + TEXT_MIN_H)); - }; - refreshSize = () => { - try { - lockMinWidth(); - syncDomWidth(); - const size = node.computeSize?.(); - const computed = (Array.isArray(size) && Number.isFinite(size[1])) ? size[1] : 0; - const need = Math.max(computed, minNodeHeight()); // 取「原生计算」与「DOM 需求」的较大值 - if (!node.size || !Number.isFinite(node.size[1]) || node.size[1] < need) { - node.setSize?.([node.size?.[0] || MIN_W, need]); - } - } catch (_) {} - node.setDirtyCanvas?.(true, true); - }; - // ⚠️ 不能在这里同步跑 refreshSize:工作流加载时 configure 还没应用存档尺寸, - // 先按默认 widget 算出来的高度会让节点「长高一下」,随后被存档高度拉回 → 刷新瞬间高度闪烁。 - // 排到本轮任务末尾(configure 已落定)再做下限保护。 - setTimeout(refreshSize, 0); - setTimeout(refreshSize, 60); - setTimeout(refreshSize, 1200); // 兜底:加载工作流后 widget 才铺完时再校正一次(只增不减) + // 尺寸刷新 —— 用「常量 + 白名单行数」计算,绝不读 widget 运行时状态 + // ⚠️ 历史坑(已由 installStableNodeHeight 彻底修好):以前用 node.computeSize() + + // 遍历 node.widgets 求和,工作流加载/切图时 widget 被重建、hidden 标记短暂丢失, + // 会把「全部 widget 可见」的巨大高度一次固化下来(且只增不减)→ 节点偶发自动变高。 + const SURFACE_ROWS = 6; // 表面可见参数:latent类型/输出语言/画幅比例/宽度/高度/生成数量 + const stableH = installStableNodeHeight(node, { + minWidth: MIN_W, + rows: SURFACE_ROWS, + domFixedH: DOM_FIXED_H, + textMinH: TEXT_MIN_H, + getDomWidget: () => widget, + }); + const minNodeHeight = () => stableH.measure(); + refreshSize = () => { stableH.align(); syncDomWidth(); }; node._xbRefreshSize = refreshSize; node._xbEl = container; // 调试/自动化定位用 - // Nodes 2.0:面板里的提示词框不会自己长高(节点高度由 DOM 内容主导,flex:1 停在 min-height) - // → 默认 300px;用户拖节点尺寸手柄时按指针位移实时跟随(两个方向都跟手); - // 切回经典模式自动还原原样,交回原有 flex 布局。 - // ⚠️ 不能只把「提示词框」钉成固定 px:节点拖高后容器高度仍由内容决定 ⇒ 框下方留一片空白 - // (用户报障)。这里改钉**容器**:高度 = `--node-height`(前端自带的 CSS 变量)− 实测 chrome, - // 容器内的 flex 链(标签 + 提示词框 flex:1)自然把框撑到节点下边缘。 - try { - node._xbUninstallNodeFill = bindNodes2NodeFill(node, container, { min: DOM_MIN_H }); - } catch (e) { console.warn("[XB-生图提示词预设] 2.0 高度跟随安装失败", e); } + // ⚠️ 不再使用 bindNodes2NodeFill:它会按 `calc(--node-height - 实测 top - PAD)` 写死容器高度, + // 实测 top 一旦偏小,容器就会被算得比可用空间大 ⇒ DOM 撑破节点 ⇒ 视觉底边失控 / 拖拽弹跳。 + // 容器自身的 `height:100%` + 内部 flex 链已经能把提示词框撑到节点下边缘,无需额外干预。 - // 节点 resize:立即同步 overlay 宽度 + 重绘;防抖后只校正「最小宽度 / 最小高度」。 - // ⚠️ 高度下限是硬约束(输入框永远 ≥500px):拖小节点时一次性拉回, - // 拉回后尺寸已等于下限 → 不会再次触发 setSize,不会形成抖动环。 + // 节点 resize:只同步 overlay 宽度 + 重绘。 + // 高度下限/用户高度由 installStableNodeHeight 内部处理(它区分「用户拖拽」与「程序写入」), + // 这里不再调 setSize,避免与之打架形成抖动环。 let _xbResizeTimer = null; - let _xbClamping = false; const prevOnResize = node.onResize; node.onResize = function (...args) { const rr = prevOnResize?.apply(this, args); try { syncDomWidth(); - if (!_xbClamping) { - const need = minNodeHeight(); - const cur = Number.isFinite(node.size?.[1]) ? node.size[1] : 0; - if (cur < need) { - _xbClamping = true; - try { node.setSize?.([node.size?.[0] || MIN_W, need]); } finally { _xbClamping = false; } - } - } node.setDirtyCanvas?.(true, true); clearTimeout(_xbResizeTimer); - _xbResizeTimer = setTimeout(() => { lockMinWidth(); syncDomWidth(); node.setDirtyCanvas?.(true, true); }, 200); + _xbResizeTimer = setTimeout(() => { + lockMinWidth(); + syncDomWidth(); + // 松手后补一次网格对齐:原生拖拽按网格步进 size,但视觉底边还要加 chrome 才压线 + try { stableH.align(); } catch (_) {} + node.setDirtyCanvas?.(true, true); + }, 200); } catch (_) {} return rr; }; @@ -3652,7 +3618,7 @@ function setupNode(node) { if (PRESET_TEXT_DEFAULT[out.preset_mode] || MODES.includes(out.preset_mode)) setWidgetValue(wMode, out.preset_mode); if (typeof out.three_view_text === "string" && out.three_view_text.trim()) setWidgetValue(w3v, out.three_view_text); if (String(out.latent_channels || "").startsWith("16")) setWidgetValue(wKind, LATENT_KINDS[1]); - notify("已把旧版「输出设置」里的语言/模式/预设句/空latent类型迁到节点表面参数", "success"); + notify("已把旧版「输出设置」里的语言/模式/预设句/latent类型迁到节点表面参数", "success"); } if (out !== undefined) { delete data.output; @@ -3697,12 +3663,15 @@ function setupNode(node) { const boot = () => { hideInternal(); // 工作流加载后 widget 可能被重建 → 重新藏 + // ⚠️ 关键时序:必须先 hideInternal()(内部字段全部藏好)再 markReady(), + // 否则「隐藏还没打上」的那一拍会把全可见高度固化下来(旧的偶发变高就是这样产生的)。 + try { stableH.markReady(); } catch (_) {} if (isGalleryNode(node)) { refreshRefButton("pose"); refreshRefButton("costume"); } - refreshLatentGuard(); // 「空latent」未连线 → 空latent类型/画幅/宽高/数量变灰 + refreshLatentGuard(); // 「空latent」未连线 → latent类型/画幅/宽高/数量变灰 migrateLegacyOutput(); migrateLegacyPreset(); syncPromptBoxFromWidget(); - applyKindLimits(); // 按当前空latent类型同步 step / 上下限 + applyKindLimits(); // 按当前latent类型同步 step / 上下限 applyPresetTextVisibility(); const ar = String(readWidgetValue(wAR) ?? "Free"); const [nw, nh] = normalizeSize(ar, readWidgetValue(wW), readWidgetValue(wH), sizeOpt()); diff --git a/web/xb_image_prompt_preset_pro.js b/web/xb_image_prompt_preset_pro.js index 84b75c7..1f91ea8 100644 --- a/web/xb_image_prompt_preset_pro.js +++ b/web/xb_image_prompt_preset_pro.js @@ -15,7 +15,7 @@ * XB-BOX - 🖼️ 生图提示词预设 — 前端面板 * ============================================================ * 节点:XB_ImagePromptPreset(后端 nodes_image_prompt_preset.py,V1 经典 API) - * 表面:空latent类型 / 输出语言 / 预设模式 / (三视图/四视图/五视图预设句)/ 画幅比例 / 宽度 / 高度 / 生成数量 + * 表面:latent类型 / 输出语言 / 预设模式 / (三视图/四视图/五视图预设句)/ 画幅比例 / 宽度 / 高度 / 生成数量 * + 节点提示词框 + 8 个分类按钮(4 列 × 2 行):🏷️ 风格 | 🎥 视角 | 👤 主体 | 🎬 姿态 | 👚 装扮 | 🎒 道具 | 💡 光影 | 🏞️ 背景 * 每个按钮一个面板,面板顶部「分类签」切子类;选项行整行可点(加入 → 变蓝 ✔ / 再点移除) * 道具类点行会先弹「与主体的关系」多宫格标签窗(30 个关系词) @@ -35,7 +35,7 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; -import { bindNodes2NodeFill, installLatentGuard } from "./xb_compat.js"; +import { installLatentGuard, installStableNodeHeight } from "./xb_compat.js"; const NODE_TYPE = "XB_ImagePromptPresetPro"; @@ -83,7 +83,7 @@ function modeHasImage(node) { const ASPECT_MAP = { "1:1": 1, "16:9": 16 / 9, "9:16": 9 / 16, "4:3": 4 / 3, "3:4": 3 / 4, "21:9": 21 / 9, "2:3": 2 / 3, "3:2": 3 / 2 }; -const SIZE_STEP = 16; // width/height 的兜底步长(实际以「空latent类型」的官方最小步长为准) +const SIZE_STEP = 16; // width/height 的兜底步长(实际以「latent类型」的官方最小步长为准) const SIZE_MIN = 16; const SIZE_MAX = 16384; const BATCH_MAX = 4096; @@ -1510,7 +1510,7 @@ function bindWheelZoom(root) { * 配置读写 * ============================================================ */ function defaultSettings() { - // ⚠️ 自 0.9.x 起:输出语言 / 预设模式 / 预设句 / 空latent类型 已上提为**节点表面参数**, + // ⚠️ 自 0.9.x 起:输出语言 / 预设模式 / 预设句 / latent类型 已上提为**节点表面参数**, // 不再存在这份 JSON 里;JSON 只留元素面板配置 + 自动保存开关。 return { auto_save: true, @@ -1671,7 +1671,7 @@ function pickOpt(options, value, def) { const v = (value == null) ? "" : String(value).trim(); return options.includes(v) ? v : def; } -/** 步长锁定(与后端 _round_step 一致:floor(v/step + 0.5);step/min/max 由空latent类型决定) */ +/** 步长锁定(与后端 _round_step 一致:floor(v/step + 0.5);step/min/max 由latent类型决定) */ function roundStep(v, step = SIZE_STEP, minSize = SIZE_MIN, maxSize = SIZE_MAX) { const n = Math.floor((Number(v) || 0) / step + 0.5) * step; return Math.max(minSize, Math.min(maxSize, n)); @@ -2653,7 +2653,7 @@ function setupNode(node) { }; hideInternal(); - // ── 「空latent」输出没连线时:空latent类型 / 画幅比例 / 宽度 / 高度 / 生成数量 全部变灰不可操作 + // ── 「空latent」输出没连线时:latent类型 / 画幅比例 / 宽度 / 高度 / 生成数量 全部变灰不可操作 // (接了「空latent」输出连线后自动恢复;后端取值不受影响) const refreshLatentGuard = installLatentGuard(node, ["latent_kind", "aspect_ratio", "width", "height", "batch_size"]); @@ -2725,7 +2725,7 @@ function setupNode(node) { kind: pickOpt(LATENT_KINDS, readWidgetValue(wKind), DEFAULT_LATENT_KIND), presetText: String(readWidgetValue(w3v) ?? ""), }); - /** 当前空latent类型对应的步长/上下限(后端按同一张表算,保证所见即所得) */ + /** 当前latent类型对应的步长/上下限(后端按同一张表算,保证所见即所得) */ const sizeOpt = () => { const sp = latentSpec(surfaceOf().kind); return { step: sp.step, min: sp.min, max: sp.max }; @@ -2839,19 +2839,19 @@ function setupNode(node) { node.setDirtyCanvas?.(true, true); }; - // 空latent类型 → 同步 width/height/batch 的原生 step & 上下限(箭头/拖动按官方步长走) + // latent类型 → 同步 width/height/batch 的原生 step & 上下限(箭头/拖动按官方步长走) const applyKindLimits = () => { const sp = latentSpec(surfaceOf().kind); for (const w of [wW, wH]) { if (w && w.options) { w.options.step = w.options.step2 = sp.step; w.options.min = sp.min; w.options.max = sp.max; } } if (wB && wB.options) wB.options.max = sp.batchMax; - if (wKind && wKind.options) wKind.options.tooltip = `空latent类型(${sp.desc})\n尺寸步长 ${sp.step},batch 上限 ${sp.batchMax}。\n选你正在用的模型即可,形状/下采样/步长会自动适配。`; + if (wKind && wKind.options) wKind.options.tooltip = `latent类型(${sp.desc})\n尺寸步长 ${sp.step},batch 上限 ${sp.batchMax}。\n选你正在用的模型即可,形状/下采样/步长会自动适配。`; const b = parseInt(readWidgetValue(wB), 10) || 1; if (b > sp.batchMax) setWidgetValue(wB, sp.batchMax); }; - // ── 画幅比例 + 步长联动(语义对齐 js/xb_video.js;步长按当前空latent类型) ── + // ── 画幅比例 + 步长联动(语义对齐 js/xb_video.js;步长按当前latent类型) ── let syncing = false; const snapByWidth = () => { if (syncing) return; @@ -2976,79 +2976,45 @@ function setupNode(node) { node._xbDomWidget = widget; node._xbSyncWidth = syncDomWidth; // 供 canvas.onDrawForeground 每帧调用 - // 尺寸刷新 —— 照抄短剧导演台 `refreshSize`: - // ① 用 LiteGraph 自己的 computeSize() 算高度(不读任何 DOM 尺寸 → 无反馈环) - // ② 只做「下限保护」:未设置 / 小于下限时才 setSize,绝不覆盖用户拖拽出来的高度 - // ③ 只在「初始化(3 次) + 工作流加载(configure)」调用,打字 / 面板保存 / onResize 一律不调 - // ⚠️ 这就是「整个界面卡顿」的隔离带:一旦在打字或 onResize 里调它, - // setSize → onResize → setSize 会形成抖动环,每帧整画布重绘 → 全界面卡。 - // 下限 = 标题 + 各可见 widget 实际高度(多行 STRING 会更高)+ DOM 固定行 + 提示词框最小高度。 - // 这样 4 个或 8 个表面参数(三视图预设句框显/隐)都自适应,不用手写魔数。 - const widgetRowH = (w) => { - try { - const s = w && w.computeSize ? w.computeSize(node.size?.[0] || 420) : null; - if (Array.isArray(s) && Number.isFinite(s[1]) && s[1] > 0) return s[1]; - } catch (_) {} - return (w && (w.type === "hidden" || w.hidden)) ? 0 : 26; - }; - const minNodeHeight = () => { - let h = 30; // 节点标题 - for (const w of (node.widgets || [])) h += widgetRowH(w); - return Math.max(420, Math.round(h + DOM_FIXED_H + TEXT_MIN_H)); - }; - refreshSize = () => { - try { - lockMinWidth(); - syncDomWidth(); - const size = node.computeSize?.(); - const computed = (Array.isArray(size) && Number.isFinite(size[1])) ? size[1] : 0; - const need = Math.max(computed, minNodeHeight()); // 取「原生计算」与「DOM 需求」的较大值 - if (!node.size || !Number.isFinite(node.size[1]) || node.size[1] < need) { - node.setSize?.([node.size?.[0] || MIN_W, need]); - } - } catch (_) {} - node.setDirtyCanvas?.(true, true); - }; - // ⚠️ 不能在这里同步跑 refreshSize:工作流加载时 configure 还没应用存档尺寸, - // 先按默认 widget 算出来的高度会让节点「长高一下」,随后被存档高度拉回 → 刷新瞬间高度闪烁。 - // 排到本轮任务末尾(configure 已落定)再做下限保护。 - setTimeout(refreshSize, 0); - setTimeout(refreshSize, 60); - setTimeout(refreshSize, 1200); // 兜底:加载工作流后 widget 才铺完时再校正一次(只增不减) + // 尺寸刷新 —— 用「常量 + 白名单行数」计算,绝不读 widget 运行时状态 + // ⚠️ 历史坑(已由 installStableNodeHeight 彻底修好):以前用 node.computeSize() + + // 遍历 node.widgets 求和,工作流加载/切图时 widget 被重建、hidden 标记短暂丢失, + // 会把「全部 widget 可见」的巨大高度一次固化下来(且只增不减)→ 节点偶发自动变高。 + const SURFACE_ROWS = 7; // 表面可见参数:latent类型/输出语言/画幅比例/宽度/高度/生成数量/启用LLM反推 + const stableH = installStableNodeHeight(node, { + minWidth: MIN_W, + rows: SURFACE_ROWS, + domFixedH: DOM_FIXED_H, + textMinH: TEXT_MIN_H, + getDomWidget: () => widget, + }); + const minNodeHeight = () => stableH.measure(); + refreshSize = () => { stableH.align(); syncDomWidth(); }; node._xbRefreshSize = refreshSize; node._xbEl = container; // 调试/自动化定位用 - // Nodes 2.0:面板里的提示词框不会自己长高(节点高度由 DOM 内容主导,flex:1 停在 min-height) - // → 默认 300px;用户拖节点尺寸手柄时按指针位移实时跟随(两个方向都跟手); - // 切回经典模式自动还原原样,交回原有 flex 布局。 - // ⚠️ 不能只把「提示词框」钉成固定 px:节点拖高后容器高度仍由内容决定 ⇒ 框下方留一片空白 - // (用户报障)。这里改钉**容器**:高度 = `--node-height`(前端自带的 CSS 变量)− 实测 chrome, - // 容器内的 flex 链(标签 + 提示词框 flex:1)自然把框撑到节点下边缘。 - try { - node._xbUninstallNodeFill = bindNodes2NodeFill(node, container, { min: DOM_MIN_H }); - } catch (e) { console.warn("[XB-生图提示词预设] 2.0 高度跟随安装失败", e); } + // ⚠️ 不再使用 bindNodes2NodeFill:它会按 `calc(--node-height - 实测 top - PAD)` 写死容器高度, + // 实测 top 一旦偏小,容器就会被算得比可用空间大 ⇒ DOM 撑破节点 ⇒ 视觉底边失控 / 拖拽弹跳。 + // 容器自身的 `height:100%` + 内部 flex 链已经能把提示词框撑到节点下边缘,无需额外干预。 - // 节点 resize:立即同步 overlay 宽度 + 重绘;防抖后只校正「最小宽度 / 最小高度」。 - // ⚠️ 高度下限是硬约束(输入框永远 ≥500px):拖小节点时一次性拉回, - // 拉回后尺寸已等于下限 → 不会再次触发 setSize,不会形成抖动环。 + // 节点 resize:只同步 overlay 宽度 + 重绘。 + // 高度下限/用户高度由 installStableNodeHeight 内部处理(区分「用户拖拽」与「程序写入」), + // 这里不再调 setSize,避免与之打架形成抖动环。 let _xbResizeTimer = null; - let _xbClamping = false; const prevOnResize = node.onResize; node.onResize = function (...args) { const rr = prevOnResize?.apply(this, args); try { syncDomWidth(); - if (!_xbClamping) { - const need = minNodeHeight(); - const cur = Number.isFinite(node.size?.[1]) ? node.size[1] : 0; - if (cur < need) { - _xbClamping = true; - try { node.setSize?.([node.size?.[0] || MIN_W, need]); } finally { _xbClamping = false; } - } - } node.setDirtyCanvas?.(true, true); clearTimeout(_xbResizeTimer); - _xbResizeTimer = setTimeout(() => { lockMinWidth(); syncDomWidth(); node.setDirtyCanvas?.(true, true); }, 200); + _xbResizeTimer = setTimeout(() => { + lockMinWidth(); + syncDomWidth(); + // 松手后补一次网格对齐:原生拖拽按网格步进 size,但视觉底边还要加 chrome 才压线 + try { stableH.align(); } catch (_) {} + node.setDirtyCanvas?.(true, true); + }, 200); } catch (_) {} return rr; }; @@ -3090,7 +3056,7 @@ function setupNode(node) { if (MODES.includes(out.preset_mode)) setWidgetValue(wMode, out.preset_mode); if (typeof out.three_view_text === "string" && out.three_view_text.trim()) setWidgetValue(w3v, out.three_view_text); if (String(out.latent_channels || "").startsWith("16")) setWidgetValue(wKind, LATENT_KINDS[1]); - notify("已把旧版「输出设置」里的语言/模式/预设句/空latent类型迁到节点表面参数", "success"); + notify("已把旧版「输出设置」里的语言/模式/预设句/latent类型迁到节点表面参数", "success"); } if (out !== undefined) { delete data.output; @@ -3136,11 +3102,14 @@ function setupNode(node) { const boot = () => { hideInternal(); // 工作流加载后 widget 可能被重建 → 重新藏 + // ⚠️ 关键时序:必须先 hideInternal()(内部字段全部藏好)再 markReady(), + // 否则「隐藏还没打上」的那一拍会把全可见高度固化下来(旧的偶发变高就是这样产生的)。 + try { stableH.markReady(); } catch (_) {} repairComboWidgets(); // 老工作流残留的下拉坏值 → 改回默认(后端也不再报错) - refreshLatentGuard(); // 「空latent」未连线 → 空latent类型/画幅/宽高/数量变灰 + refreshLatentGuard(); // 「空latent」未连线 → latent类型/画幅/宽高/数量变灰 migrateLegacyOutput(); syncPromptBoxFromWidget(); - applyKindLimits(); // 按当前空latent类型同步 step / 上下限 + applyKindLimits(); // 按当前latent类型同步 step / 上下限 applyPresetTextVisibility(); // 设定词:以「用户存档 → 当前模版默认」为准 // · 空 → 当前模版默认(无预设 → 默认就是空 → 保持空); @@ -3225,7 +3194,7 @@ const IO_T2I_ONLY = "文生图"; const XBR_SEED_LABEL = { randomize: "随机", fixed: "固定", increment: "增加", decrement: "减少" }; const XBR_PANEL_BUTTONS = [ { id: "llm", label: "🤖 LLM设置", title: "🤖 LLM设置", subtitle: "LLM 反推的后端与推理配置;本弹窗里的「输出语言」是全节点唯一的语言设置(决定词表加载语言 + 最终提示词语言)。" }, - { id: "preset", label: "✨ 预设参数", title: "✨ 预设参数", subtitle: "设定词(含预设词工具)/ 提示词增强(设定词来源:skill 或 预设词)。空latent类型与输出语言在节点表面。" }, + { id: "preset", label: "✨ 预设参数", title: "✨ 预设参数", subtitle: "设定词(含预设词工具)/ 提示词增强(设定词来源:skill 或 预设词)。latent类型与输出语言在节点表面。" }, ]; const XBR_API_ENDPOINT = "/xb_toolbox/llm_api_settings"; @@ -4396,7 +4365,7 @@ function xbrRenderHelp(body) { "【节点表面】", " · 第 1 行按钮:🤖 LLM设置 | ✨ 预设参数", " · 第 2 行按钮:🎨 风格 | 📐 视角 | 👤 主体 | 🕺 姿态 | 👗 装扮 | 🎒 道具 | 💡 光影 | 🏞️ 背景", - " · 画幅比例 / 宽度 / 高度 / 生成数量:数值会按当前空latent类型的官方步长自动对齐", + " · 画幅比例 / 宽度 / 高度 / 生成数量:数值会按当前latent类型的官方步长自动对齐", " · ✅ 启用 LLM 反推:关 = 只输出拼装好的提示词,不加载模型、不调 API", " · 提示词框右上角「复制文本」;下面是参数设定显示,实时告诉你当前是什么配置", "", diff --git a/web/xb_qwen_prompt_preset.js b/web/xb_qwen_prompt_preset.js index d1ee224..ae96a93 100644 --- a/web/xb_qwen_prompt_preset.js +++ b/web/xb_qwen_prompt_preset.js @@ -10,7 +10,7 @@ * XB-BOX - 🖼️ 生图提示词预设 — 前端面板 * ============================================================ * 节点:XB_ImagePromptPreset(后端 nodes_image_prompt_preset.py,V1 经典 API) - * 表面:空latent类型 / 输出语言 / 预设模式 / (三视图/四视图/五视图预设句)/ 画幅比例 / 宽度 / 高度 / 生成数量 + * 表面:latent类型 / 输出语言 / 预设模式 / (三视图/四视图/五视图预设句)/ 画幅比例 / 宽度 / 高度 / 生成数量 * + 节点提示词框 + 8 个分类按钮(4 列 × 2 行):🏷️ 风格 | 🎥 视角 | 👤 主体 | 🎬 姿态 | 👚 装扮 | 🎒 道具 | 💡 光影 | 🏞️ 背景 * 每个按钮一个面板,面板顶部「分类签」切子类;选项行整行可点(加入 → 变蓝 ✔ / 再点移除) * 道具类点行会先弹「与主体的关系」多宫格标签窗(30 个关系词) @@ -29,7 +29,7 @@ */ import { app } from "../../scripts/app.js"; -import { bindNodes2NodeFill } from "./xb_compat.js"; +import { installStableNodeHeight } from "./xb_compat.js"; const NODE_TYPE = "XB_QwenPromptPreset"; @@ -104,7 +104,7 @@ function modeHasImage(node) { const ASPECT_MAP = { "1:1": 1, "16:9": 16 / 9, "9:16": 9 / 16, "4:3": 4 / 3, "3:4": 3 / 4, "21:9": 21 / 9, "2:3": 2 / 3, "3:2": 3 / 2 }; -const SIZE_STEP = 16; // width/height 的兜底步长(实际以「空latent类型」的官方最小步长为准) +const SIZE_STEP = 16; // width/height 的兜底步长(实际以「latent类型」的官方最小步长为准) const SIZE_MIN = 16; const SIZE_MAX = 16384; const BATCH_MAX = 4096; @@ -1607,7 +1607,7 @@ function bindWheelZoom(root) { * 配置读写 * ============================================================ */ function defaultSettings() { - // ⚠️ 自 0.9.x 起:输出语言 / 预设模式 / 预设句 / 空latent类型 已上提为**节点表面参数**, + // ⚠️ 自 0.9.x 起:输出语言 / 预设模式 / 预设句 / latent类型 已上提为**节点表面参数**, // 不再存在这份 JSON 里;JSON 只留元素面板配置 + 自动保存开关。 return { auto_save: true, @@ -1768,7 +1768,7 @@ function pickOpt(options, value, def) { const v = (value == null) ? "" : String(value).trim(); return options.includes(v) ? v : def; } -/** 步长锁定(与后端 _round_step 一致:floor(v/step + 0.5);step/min/max 由空latent类型决定) */ +/** 步长锁定(与后端 _round_step 一致:floor(v/step + 0.5);step/min/max 由latent类型决定) */ function roundStep(v, step = SIZE_STEP, minSize = SIZE_MIN, maxSize = SIZE_MAX) { const n = Math.floor((Number(v) || 0) / step + 0.5) * step; return Math.max(minSize, Math.min(maxSize, n)); @@ -2863,7 +2863,7 @@ function setupNode(node) { kind: pickOpt(LATENT_KINDS, readWidgetValue(wKind), DEFAULT_LATENT_KIND), presetText: String(readWidgetValue(w3v) ?? ""), }); - /** 当前空latent类型对应的步长/上下限(后端按同一张表算,保证所见即所得) */ + /** 当前latent类型对应的步长/上下限(后端按同一张表算,保证所见即所得) */ const sizeOpt = () => { const sp = latentSpec(surfaceOf().kind); return { step: sp.step, min: sp.min, max: sp.max }; @@ -2977,19 +2977,19 @@ function setupNode(node) { node.setDirtyCanvas?.(true, true); }; - // 空latent类型 → 同步 width/height/batch 的原生 step & 上下限(箭头/拖动按官方步长走) + // latent类型 → 同步 width/height/batch 的原生 step & 上下限(箭头/拖动按官方步长走) const applyKindLimits = () => { const sp = latentSpec(surfaceOf().kind); for (const w of [wW, wH]) { if (w && w.options) { w.options.step = w.options.step2 = sp.step; w.options.min = sp.min; w.options.max = sp.max; } } if (wB && wB.options) wB.options.max = sp.batchMax; - if (wKind && wKind.options) wKind.options.tooltip = `空latent类型(${sp.desc})\n尺寸步长 ${sp.step},batch 上限 ${sp.batchMax}。\n选你正在用的模型即可,形状/下采样/步长会自动适配。`; + if (wKind && wKind.options) wKind.options.tooltip = `latent类型(${sp.desc})\n尺寸步长 ${sp.step},batch 上限 ${sp.batchMax}。\n选你正在用的模型即可,形状/下采样/步长会自动适配。`; const b = parseInt(readWidgetValue(wB), 10) || 1; if (b > sp.batchMax) setWidgetValue(wB, sp.batchMax); }; - // ── 画幅比例 + 步长联动(语义对齐 js/xb_video.js;步长按当前空latent类型) ── + // ── 画幅比例 + 步长联动(语义对齐 js/xb_video.js;步长按当前latent类型) ── let syncing = false; const snapByWidth = () => { if (syncing) return; @@ -3113,79 +3113,45 @@ function setupNode(node) { node._xbDomWidget = widget; node._xbSyncWidth = syncDomWidth; // 供 canvas.onDrawForeground 每帧调用 - // 尺寸刷新 —— 照抄短剧导演台 `refreshSize`: - // ① 用 LiteGraph 自己的 computeSize() 算高度(不读任何 DOM 尺寸 → 无反馈环) - // ② 只做「下限保护」:未设置 / 小于下限时才 setSize,绝不覆盖用户拖拽出来的高度 - // ③ 只在「初始化(3 次) + 工作流加载(configure)」调用,打字 / 面板保存 / onResize 一律不调 - // ⚠️ 这就是「整个界面卡顿」的隔离带:一旦在打字或 onResize 里调它, - // setSize → onResize → setSize 会形成抖动环,每帧整画布重绘 → 全界面卡。 - // 下限 = 标题 + 各可见 widget 实际高度(多行 STRING 会更高)+ DOM 固定行 + 提示词框最小高度。 - // 这样 4 个或 8 个表面参数(三视图预设句框显/隐)都自适应,不用手写魔数。 - const widgetRowH = (w) => { - try { - const s = w && w.computeSize ? w.computeSize(node.size?.[0] || 420) : null; - if (Array.isArray(s) && Number.isFinite(s[1]) && s[1] > 0) return s[1]; - } catch (_) {} - return (w && (w.type === "hidden" || w.hidden)) ? 0 : 26; - }; - const minNodeHeight = () => { - let h = 30; // 节点标题 - for (const w of (node.widgets || [])) h += widgetRowH(w); - return Math.max(420, Math.round(h + DOM_FIXED_H + TEXT_MIN_H)); - }; - refreshSize = () => { - try { - lockMinWidth(); - syncDomWidth(); - const size = node.computeSize?.(); - const computed = (Array.isArray(size) && Number.isFinite(size[1])) ? size[1] : 0; - const need = Math.max(computed, minNodeHeight()); // 取「原生计算」与「DOM 需求」的较大值 - if (!node.size || !Number.isFinite(node.size[1]) || node.size[1] < need) { - node.setSize?.([node.size?.[0] || MIN_W, need]); - } - } catch (_) {} - node.setDirtyCanvas?.(true, true); - }; - // ⚠️ 不能在这里同步跑 refreshSize:工作流加载时 configure 还没应用存档尺寸, - // 先按默认 widget 算出来的高度会让节点「长高一下」,随后被存档高度拉回 → 刷新瞬间高度闪烁。 - // 排到本轮任务末尾(configure 已落定)再做下限保护。 - setTimeout(refreshSize, 0); - setTimeout(refreshSize, 60); - setTimeout(refreshSize, 1200); // 兜底:加载工作流后 widget 才铺完时再校正一次(只增不减) + // 尺寸刷新 —— 用「常量 + 白名单行数」计算,绝不读 widget 运行时状态 + // ⚠️ 历史坑(已由 installStableNodeHeight 彻底修好):以前用 node.computeSize() + + // 遍历 node.widgets 求和,工作流加载/切图时 widget 被重建、hidden 标记短暂丢失, + // 会把「全部 widget 可见」的巨大高度一次固化下来(且只增不减)→ 节点偶发自动变高。 + const SURFACE_ROWS = 4; // 表面可见参数:画幅比例/宽度/高度/生成数量(其余全在弹窗里) + const stableH = installStableNodeHeight(node, { + minWidth: MIN_W, + rows: SURFACE_ROWS, + domFixedH: DOM_FIXED_H, + textMinH: TEXT_MIN_H, + getDomWidget: () => widget, + }); + const minNodeHeight = () => stableH.measure(); + refreshSize = () => { stableH.align(); syncDomWidth(); }; node._xbRefreshSize = refreshSize; node._xbEl = container; // 调试/自动化定位用 - // Nodes 2.0:面板里的提示词框不会自己长高(节点高度由 DOM 内容主导,flex:1 停在 min-height) - // → 默认 300px;用户拖节点尺寸手柄时按指针位移实时跟随(两个方向都跟手); - // 切回经典模式自动还原原样,交回原有 flex 布局。 - // ⚠️ 不能只把「提示词框」钉成固定 px:节点拖高后容器高度仍由内容决定 ⇒ 框下方留一片空白 - // (用户报障)。这里改钉**容器**:高度 = `--node-height`(前端自带的 CSS 变量)− 实测 chrome, - // 容器内的 flex 链(标签 + 提示词框 flex:1)自然把框撑到节点下边缘。 - try { - node._xbUninstallNodeFill = bindNodes2NodeFill(node, container, { min: DOM_MIN_H }); - } catch (e) { console.warn("[XB-生图提示词预设] 2.0 高度跟随安装失败", e); } + // ⚠️ 不再使用 bindNodes2NodeFill:它会按 `calc(--node-height - 实测 top - PAD)` 写死容器高度, + // 实测 top 一旦偏小,容器就会被算得比可用空间大 ⇒ DOM 撑破节点 ⇒ 视觉底边失控 / 拖拽弹跳。 + // 容器自身的 `height:100%` + 内部 flex 链已经能把提示词框撑到节点下边缘,无需额外干预。 - // 节点 resize:立即同步 overlay 宽度 + 重绘;防抖后只校正「最小宽度 / 最小高度」。 - // ⚠️ 高度下限是硬约束(输入框永远 ≥500px):拖小节点时一次性拉回, - // 拉回后尺寸已等于下限 → 不会再次触发 setSize,不会形成抖动环。 + // 节点 resize:只同步 overlay 宽度 + 重绘。 + // 高度下限/用户高度由 installStableNodeHeight 内部处理(区分「用户拖拽」与「程序写入」), + // 这里不再调 setSize,避免与之打架形成抖动环。 let _xbResizeTimer = null; - let _xbClamping = false; const prevOnResize = node.onResize; node.onResize = function (...args) { const rr = prevOnResize?.apply(this, args); try { syncDomWidth(); - if (!_xbClamping) { - const need = minNodeHeight(); - const cur = Number.isFinite(node.size?.[1]) ? node.size[1] : 0; - if (cur < need) { - _xbClamping = true; - try { node.setSize?.([node.size?.[0] || MIN_W, need]); } finally { _xbClamping = false; } - } - } node.setDirtyCanvas?.(true, true); clearTimeout(_xbResizeTimer); - _xbResizeTimer = setTimeout(() => { lockMinWidth(); syncDomWidth(); node.setDirtyCanvas?.(true, true); }, 200); + _xbResizeTimer = setTimeout(() => { + lockMinWidth(); + syncDomWidth(); + // 松手后补一次网格对齐:原生拖拽按网格步进 size,但视觉底边还要加 chrome 才压线 + try { stableH.align(); } catch (_) {} + node.setDirtyCanvas?.(true, true); + }, 200); } catch (_) {} return rr; }; @@ -3227,7 +3193,7 @@ function setupNode(node) { if (MODES.includes(out.preset_mode)) setWidgetValue(wMode, out.preset_mode); if (typeof out.three_view_text === "string" && out.three_view_text.trim()) setWidgetValue(w3v, out.three_view_text); if (String(out.latent_channels || "").startsWith("16")) setWidgetValue(wKind, LATENT_KINDS[1]); - notify("已把旧版「输出设置」里的语言/模式/预设句/空latent类型迁到节点表面参数", "success"); + notify("已把旧版「输出设置」里的语言/模式/预设句/latent类型迁到节点表面参数", "success"); } if (out !== undefined) { delete data.output; @@ -3239,9 +3205,12 @@ function setupNode(node) { // ── 载入 / 执行后的对齐 ── const boot = () => { hideInternal(); // 工作流加载后 widget 可能被重建 → 重新藏 + // ⚠️ 关键时序:必须先 hideInternal()(内部字段全部藏好)再 markReady(), + // 否则「隐藏还没打上」的那一拍会把全可见高度固化下来(旧的偶发变高就是这样产生的)。 + try { stableH.markReady(); } catch (_) {} migrateLegacyOutput(); syncPromptBoxFromWidget(); - applyKindLimits(); // 按当前空latent类型同步 step / 上下限 + applyKindLimits(); // 按当前latent类型同步 step / 上下限 applyPresetTextVisibility(); // 设定词:以「用户存档 → 当前模版默认」为准 // · 空 → 当前模版默认(无预设 → 默认就是空 → 保持空); @@ -3319,7 +3288,7 @@ const XBR_MTP_MODES = ["auto", "off", "2", "3", "4", "5"]; const XBR_LLM_MODES = ["启用LLM增强", "关闭LLM增强"]; const XBR_PANEL_BUTTONS = [ { id: "llm", label: "🤖 LLM设置", title: "🤖 LLM设置", subtitle: "顶部 = LLM 增强总开关(默认启用);模型选择 = 官方 Load CLIP;参数设置 = 官方 Generate Text 全量参数。" }, - { id: "preset", label: "✨ 预设参数", title: "✨ 预设参数", subtitle: "输出语言 / 空latent类型 / 预设模式 / 设定词 / SKILL。提示词正文在节点上的提示词框里编辑。" }, + { id: "preset", label: "✨ 预设参数", title: "✨ 预设参数", subtitle: "输出语言 / latent类型 / 预设模式 / 设定词 / SKILL。提示词正文在节点上的提示词框里编辑。" }, ]; /** 全部收进弹窗的节点 widget(节点表面只留画幅参数 / 生成数量) */ const XBR_QWEN_WIDGETS = [ @@ -3615,8 +3584,8 @@ function xbrRenderPreset(body, node, draft, ctx) { ctx.rerender(); }))); body.append(xbrHint("输出语言决定:词表与设定词按哪种语言加载、生成文本是中文还是英文。")); - body.append(field("空latent类型", selectControl(LATENT_KINDS, draft.kind, (v) => { draft.kind = v; }))); - body.append(xbrHint("空latent类型:选你正在用的模型即可(形状 / 下采样 / 尺寸步长自动适配 8/16/32)。")); + body.append(field("latent类型", selectControl(LATENT_KINDS, draft.kind, (v) => { draft.kind = v; }))); + body.append(xbrHint("latent类型:选你正在用的模型即可(形状 / 下采样 / 尺寸步长自动适配 8/16/32)。")); // ── 模版(文生图 / 图生图):决定用哪一套设定词(每个预设模式都有两套)── body.append(field("模版", radioRow(XBR_IO_MODES, draft.ioMode, (v) => { draft.ioMode = v; @@ -3737,11 +3706,11 @@ function xbrRenderHelp(body) { " 2. 送模型 = 【参考设定·本档设定词】+【正文】", " (选中 SKILL 时,SKILL 全文另作为系统提示词送到最前面)", " 3. 输出提示词 = 本档设定词原封不动置顶 + 模型生成的正文", - " 4. 输出空latent = 按空latent类型/画幅参数生成的空 latent", + " 4. 输出空latent = 按latent类型/画幅参数生成的空 latent", "", "【三步上手 · 文生图】", " 1. 🤖 LLM设置 选模型(类型 qwen_image,设备 default)", - " 2. ✨ 预设参数 选输出语言 + 空latent类型 + 预设模式", + " 2. ✨ 预设参数 选输出语言 + latent类型 + 预设模式", " (文生图组)+ SKILL 模式(保持自动即可)", " 3. 📊 空latent 接采样器、📝 提示词 接文本编码器,点运行", "", @@ -3757,7 +3726,7 @@ function xbrRenderHelp(body) { "【节点表面】", " · 第 1 行按钮:🤖 LLM设置 | ✨ 预设参数", " · 第 2 行按钮:🎨 风格 | 📐 视角 | 👤 主体 | 🕺 姿态 | 👗 装扮 | 🎒 道具 | 💡 光影 | 🏞️ 背景", - " · 画幅比例 / 宽度 / 高度 / 生成数量:按空latent类型的官方步长自动对齐", + " · 画幅比例 / 宽度 / 高度 / 生成数量:按latent类型的官方步长自动对齐", " · 提示词框右上角 📋 复制;下面是参数设定显示,实时告诉你当前配置", " · 开关:🤖 LLM设置 顶部「启用LLM增强 / 关闭LLM增强」(默认启用);", " 关闭后执行节点只用拼装好的提示词,不加载文本编码器、不生成文本", @@ -3795,7 +3764,7 @@ function xbrRenderHelp(body) { " · 这两个技能要求输出单行 JSON:正常,见【常见问题】", "", "【✨ 预设参数】", - " · 生图预设:输出语言 / 空latent类型 / 预设模式 / 设定词", + " · 生图预设:输出语言 / latent类型 / 预设模式 / 设定词", " · SKILL 模式(自动 / 手动 / 不用):见上", " · 设定词:见上(无预设档则不前置)", "",