From e60e19b175f93f2c8fac037063de9f13259be9d4 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Wed, 14 Aug 2024 06:22:10 +0100 Subject: [PATCH] Add support for simple tooltips (#3842) * Add support for simple tooltips * Fix overflow * Add tooltips for nodes in the default workflow * new line * Prevent potential crash * PR feedback * Hide tooltip when clicking (e.g. combo widget) * Refactor tooltips, add node level support * Fix * move * Fix test (and undo last change) * Fixed indent * Fix dom widgets, dont show tooltip if not over canvas --- nodes.py | 103 ++++++++++++++++------- server.py | 3 + web/extensions/core/tooltips.js | 122 ++++++++++++++++++++++++++++ web/extensions/core/widgetInputs.js | 2 +- web/scripts/app.js | 3 +- web/scripts/domWidget.js | 6 ++ web/scripts/widgets.js | 2 + web/style.css | 17 ++++ 8 files changed, 225 insertions(+), 33 deletions(-) create mode 100644 web/extensions/core/tooltips.js diff --git a/nodes.py b/nodes.py index 525b28d8..16f5c9b0 100644 --- a/nodes.py +++ b/nodes.py @@ -47,11 +47,18 @@ MAX_RESOLUTION=16384 class CLIPTextEncode: @classmethod def INPUT_TYPES(s): - return {"required": {"text": ("STRING", {"multiline": True, "dynamicPrompts": True}), "clip": ("CLIP", )}} + return { + "required": { + "text": ("STRING", {"multiline": True, "dynamicPrompts": True, "tooltip": "The text to be encoded."}), + "clip": ("CLIP", {"tooltip": "The CLIP model used for encoding the text."}) + } + } RETURN_TYPES = ("CONDITIONING",) + OUTPUT_TOOLTIPS = ("A conditioning containing the embedded text used to guide the diffusion model.",) FUNCTION = "encode" CATEGORY = "conditioning" + DESCRIPTION = "Encodes a text prompt using a CLIP model into an embedding that can be used to guide the diffusion model towards generating specific images." def encode(self, clip, text): tokens = clip.tokenize(text) @@ -260,11 +267,18 @@ class ConditioningSetTimestepRange: class VAEDecode: @classmethod def INPUT_TYPES(s): - return {"required": { "samples": ("LATENT", ), "vae": ("VAE", )}} + return { + "required": { + "samples": ("LATENT", {"tooltip": "The latent to be decoded."}), + "vae": ("VAE", {"tooltip": "The VAE model used for decoding the latent."}) + } + } RETURN_TYPES = ("IMAGE",) + OUTPUT_TOOLTIPS = ("The decoded image.",) FUNCTION = "decode" CATEGORY = "latent" + DESCRIPTION = "Decodes latent images back into pixel space images." def decode(self, vae, samples): return (vae.decode(samples["samples"]), ) @@ -506,12 +520,19 @@ class CheckpointLoader: class CheckpointLoaderSimple: @classmethod def INPUT_TYPES(s): - return {"required": { "ckpt_name": (folder_paths.get_filename_list("checkpoints"), ), - }} + return { + "required": { + "ckpt_name": (folder_paths.get_filename_list("checkpoints"), {"tooltip": "The name of the checkpoint (model) to load."}), + } + } RETURN_TYPES = ("MODEL", "CLIP", "VAE") + OUTPUT_TOOLTIPS = ("The model used for denoising latents.", + "The CLIP model used for encoding text prompts.", + "The VAE model used for encoding and decoding images to and from latent space.") FUNCTION = "load_checkpoint" CATEGORY = "loaders" + DESCRIPTION = "Loads a diffusion model checkpoint, diffusion models are used to denoise latents." def load_checkpoint(self, ckpt_name): ckpt_path = folder_paths.get_full_path("checkpoints", ckpt_name) @@ -582,16 +603,22 @@ class LoraLoader: @classmethod def INPUT_TYPES(s): - return {"required": { "model": ("MODEL",), - "clip": ("CLIP", ), - "lora_name": (folder_paths.get_filename_list("loras"), ), - "strength_model": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}), - "strength_clip": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}), - }} + return { + "required": { + "model": ("MODEL", {"tooltip": "The diffusion model the LoRA will be applied to."}), + "clip": ("CLIP", {"tooltip": "The CLIP model the LoRA will be applied to."}), + "lora_name": (folder_paths.get_filename_list("loras"), {"tooltip": "The name of the LoRA."}), + "strength_model": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01, "tooltip": "How strongly to modify the diffusion model. This value can be negative."}), + "strength_clip": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01, "tooltip": "How strongly to modify the CLIP model. This value can be negative."}), + } + } + RETURN_TYPES = ("MODEL", "CLIP") + OUTPUT_TOOLTIPS = ("The modified diffusion model.", "The modified CLIP model.") FUNCTION = "load_lora" CATEGORY = "loaders" + DESCRIPTION = "LoRAs are used to modify diffusion and CLIP models, altering the way in which latents are denoised such as applying styles. Multiple LoRA nodes can be linked together." def load_lora(self, model, clip, lora_name, strength_model, strength_clip): if strength_model == 0 and strength_clip == 0: @@ -1033,13 +1060,19 @@ class EmptyLatentImage: @classmethod def INPUT_TYPES(s): - return {"required": { "width": ("INT", {"default": 512, "min": 16, "max": MAX_RESOLUTION, "step": 8}), - "height": ("INT", {"default": 512, "min": 16, "max": MAX_RESOLUTION, "step": 8}), - "batch_size": ("INT", {"default": 1, "min": 1, "max": 4096})}} + return { + "required": { + "width": ("INT", {"default": 512, "min": 16, "max": MAX_RESOLUTION, "step": 8, "tooltip": "The width of the latent images in pixels."}), + "height": ("INT", {"default": 512, "min": 16, "max": MAX_RESOLUTION, "step": 8, "tooltip": "The height of the latent images in pixels."}), + "batch_size": ("INT", {"default": 1, "min": 1, "max": 4096, "tooltip": "The number of latent images in the batch."}) + } + } RETURN_TYPES = ("LATENT",) + OUTPUT_TOOLTIPS = ("The empty latent image batch.",) FUNCTION = "generate" CATEGORY = "latent" + DESCRIPTION = "Create a new batch of empty latent images to be denoised via sampling." def generate(self, width, height, batch_size=1): latent = torch.zeros([batch_size, 4, height // 8, width // 8], device=self.device) @@ -1359,24 +1392,27 @@ def common_ksampler(model, seed, steps, cfg, sampler_name, scheduler, positive, class KSampler: @classmethod def INPUT_TYPES(s): - return {"required": - {"model": ("MODEL",), - "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), - "steps": ("INT", {"default": 20, "min": 1, "max": 10000}), - "cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0, "step":0.1, "round": 0.01}), - "sampler_name": (comfy.samplers.KSampler.SAMPLERS, ), - "scheduler": (comfy.samplers.KSampler.SCHEDULERS, ), - "positive": ("CONDITIONING", ), - "negative": ("CONDITIONING", ), - "latent_image": ("LATENT", ), - "denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), - } - } + return { + "required": { + "model": ("MODEL", {"tooltip": "The model used for denoising the input latent."}), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "tooltip": "The random seed used for creating the noise."}), + "steps": ("INT", {"default": 20, "min": 1, "max": 10000, "tooltip": "The number of steps used in the denoising process."}), + "cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0, "step":0.1, "round": 0.01, "tooltip": "The Classifier-Free Guidance scale balances creativity and adherence to the prompt. Higher values result in images more closely matching the prompt however too high values will negatively impact quality."}), + "sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"tooltip": "The algorithm used when sampling, this can affect the quality, speed, and style of the generated output."}), + "scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"tooltip": "The scheduler controls how noise is gradually removed to form the image."}), + "positive": ("CONDITIONING", {"tooltip": "The conditioning describing the attributes you want to include in the image."}), + "negative": ("CONDITIONING", {"tooltip": "The conditioning describing the attributes you want to exclude from the image."}), + "latent_image": ("LATENT", {"tooltip": "The latent image to denoise."}), + "denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "The amount of denoising applied, lower values will maintain the structure of the initial image allowing for image to image sampling."}), + } + } RETURN_TYPES = ("LATENT",) + OUTPUT_TOOLTIPS = ("The denoised latent.",) FUNCTION = "sample" CATEGORY = "sampling" + DESCRIPTION = "Uses the provided model, positive and negative conditioning to denoise the latent image." def sample(self, model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image, denoise=1.0): return common_ksampler(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image, denoise=denoise) @@ -1424,11 +1460,15 @@ class SaveImage: @classmethod def INPUT_TYPES(s): - return {"required": - {"images": ("IMAGE", ), - "filename_prefix": ("STRING", {"default": "ComfyUI"})}, - "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, - } + return { + "required": { + "images": ("IMAGE", {"tooltip": "The images to save."}), + "filename_prefix": ("STRING", {"default": "ComfyUI", "tooltip": "The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes."}) + }, + "hidden": { + "prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO" + }, + } RETURN_TYPES = () FUNCTION = "save_images" @@ -1436,6 +1476,7 @@ class SaveImage: OUTPUT_NODE = True CATEGORY = "image" + DESCRIPTION = "Saves the input images to your ComfyUI output directory." def save_images(self, images, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): filename_prefix += self.prefix_append diff --git a/server.py b/server.py index 0c382016..9d8269f2 100644 --- a/server.py +++ b/server.py @@ -438,6 +438,9 @@ class PromptServer(): if hasattr(obj_class, 'CATEGORY'): info['category'] = obj_class.CATEGORY + + if hasattr(obj_class, 'OUTPUT_TOOLTIPS'): + info['output_tooltips'] = obj_class.OUTPUT_TOOLTIPS return info @routes.get("/object_info") diff --git a/web/extensions/core/tooltips.js b/web/extensions/core/tooltips.js new file mode 100644 index 00000000..08ab05ef --- /dev/null +++ b/web/extensions/core/tooltips.js @@ -0,0 +1,122 @@ +import { app } from "../../scripts/app.js"; +import { $el } from "../../scripts/ui.js"; + +// Adds support for tooltips + +function getHoveredWidget() { + if (!app) { + return; + } + + const node = app.canvas.node_over; + if (!node.widgets) return; + + const graphPos = app.canvas.graph_mouse; + + const x = graphPos[0] - node.pos[0]; + const y = graphPos[1] - node.pos[1]; + + for (const w of node.widgets) { + let widgetWidth, widgetHeight; + if (w.computeSize) { + const sz = w.computeSize(); + widgetWidth = sz[0]; + widgetHeight = sz[1]; + } else { + widgetWidth = w.width || node.size[0]; + widgetHeight = LiteGraph.NODE_WIDGET_HEIGHT; + } + + if (w.last_y !== undefined && x >= 6 && x <= widgetWidth - 12 && y >= w.last_y && y <= w.last_y + widgetHeight) { + return w; + } + } +} + +app.registerExtension({ + name: "Comfy.Tooltips", + setup() { + const tooltipEl = $el("div.comfy-graph-tooltip", { + parent: document.body, + }); + let idleTimeout; + + const hideTooltip = () => { + tooltipEl.style.display = "none"; + }; + const showTooltip = (tooltip) => { + if (!tooltip) return; + + tooltipEl.textContent = tooltip; + tooltipEl.style.display = "block"; + tooltipEl.style.left = app.canvas.mouse[0] + "px"; + tooltipEl.style.top = app.canvas.mouse[1] + "px"; + const rect = tooltipEl.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + tooltipEl.style.left = app.canvas.mouse[0] - rect.width + "px"; + } + + if (rect.top < 0) { + tooltipEl.style.top = app.canvas.mouse[1] + rect.height + "px"; + } + }; + const getInputTooltip = (nodeData, name) => { + const inputDef = nodeData.input?.required?.[name] ?? nodeData.input?.optional?.[name]; + return inputDef?.[1]?.tooltip; + }; + const onIdle = () => { + const { canvas } = app; + const node = canvas.node_over; + if (!node) return; + + const nodeData = node.constructor.nodeData ?? {}; + + if (node.constructor.title_mode !== LiteGraph.NO_TITLE && canvas.graph_mouse[1] < node.pos[1]) { + return showTooltip(nodeData.description); + } + + if (node.flags?.collapsed) return; + + const inputSlot = canvas.isOverNodeInput(node, canvas.graph_mouse[0], canvas.graph_mouse[1], [0, 0]); + if (inputSlot !== -1) { + const inputName = node.inputs[inputSlot].name; + return showTooltip(getInputTooltip(nodeData, inputName)); + } + + const outputSlot = canvas.isOverNodeOutput(node, canvas.graph_mouse[0], canvas.graph_mouse[1], [0, 0]); + if (outputSlot !== -1) { + return showTooltip(nodeData.output_tooltips?.[outputSlot]); + } + + const widget = getHoveredWidget(); + // Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these + if (widget && !widget.element) { + return showTooltip(widget.tooltip ?? getInputTooltip(nodeData, widget.name)); + } + }; + + const onMouseMove = (e) => { + hideTooltip(); + clearTimeout(idleTimeout); + + if(e.target.nodeName !== "CANVAS") return + idleTimeout = setTimeout(onIdle, 500); + }; + + app.ui.settings.addSetting({ + id: "Comfy.EnableTooltips", + name: "Enable Tooltips", + type: "boolean", + defaultValue: true, + onChange(value) { + if (value) { + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("click", hideTooltip); + } else { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("click", hideTooltip); + } + }, + }); + }, +}); diff --git a/web/extensions/core/widgetInputs.js b/web/extensions/core/widgetInputs.js index f1a1d22c..08155496 100644 --- a/web/extensions/core/widgetInputs.js +++ b/web/extensions/core/widgetInputs.js @@ -181,7 +181,7 @@ export function mergeIfValid(output, config2, forceUpdate, recreateWidget, confi const isNumber = config1[0] === "INT" || config1[0] === "FLOAT"; for (const k of keys.values()) { - if (k !== "default" && k !== "forceInput" && k !== "defaultInput" && k !== "control_after_generate" && k !== "multiline") { + if (k !== "default" && k !== "forceInput" && k !== "defaultInput" && k !== "control_after_generate" && k !== "multiline" && k !== "tooltip") { let v1 = config1[1][k]; let v2 = config2[1]?.[k]; diff --git a/web/scripts/app.js b/web/scripts/app.js index 8b4478a3..df924509 100644 --- a/web/scripts/app.js +++ b/web/scripts/app.js @@ -1713,9 +1713,10 @@ export class ComfyApp { for (const o in nodeData["output"]) { let output = nodeData["output"][o]; if(output instanceof Array) output = "COMBO"; + const outputTooltip = nodeData["output_tooltips"]?.[o]; const outputName = nodeData["output_name"][o] || output; const outputShape = nodeData["output_is_list"][o] ? LiteGraph.GRID_SHAPE : LiteGraph.CIRCLE_SHAPE ; - this.addOutput(outputName, output, { shape: outputShape }); + this.addOutput(outputName, output, { shape: outputShape, tooltip: outputTooltip }); } const s = this.computeSize(); diff --git a/web/scripts/domWidget.js b/web/scripts/domWidget.js index d97122f9..03ed2ff1 100644 --- a/web/scripts/domWidget.js +++ b/web/scripts/domWidget.js @@ -223,6 +223,12 @@ LGraphNode.prototype.addDOMWidget = function (name, type, element, options) { document.addEventListener("mousedown", mouseDownHandler); } + const { nodeData } = this.constructor; + const tooltip = (nodeData?.input.required?.[name] ?? nodeData?.input.optional?.[name])?.[1]?.tooltip; + if (tooltip && !element.title) { + element.title = tooltip; + } + const widget = { type, name, diff --git a/web/scripts/widgets.js b/web/scripts/widgets.js index 6a689970..59b17fd1 100644 --- a/web/scripts/widgets.js +++ b/web/scripts/widgets.js @@ -75,6 +75,7 @@ export function addValueControlWidgets(node, targetWidget, defaultValue = "rando serialize: false, // Don't include this in prompt. } ); + valueControl.tooltip = "Allows the linked widget to be changed automatically, for example randomizing the noise seed."; valueControl[IS_CONTROL_WIDGET] = true; updateControlWidgetLabel(valueControl); widgets.push(valueControl); @@ -95,6 +96,7 @@ export function addValueControlWidgets(node, targetWidget, defaultValue = "rando } ); updateControlWidgetLabel(comboFilter); + comboFilter.tooltip = "Allows for filtering the list of values when changing the value via the control generate mode. Allows for RegEx matches in the format /abc/ to only filter to values containing 'abc'." widgets.push(comboFilter); } diff --git a/web/style.css b/web/style.css index 8ef1d0dd..7774bfed 100644 --- a/web/style.css +++ b/web/style.css @@ -645,3 +645,20 @@ dialog::backdrop { audio.comfy-audio.empty-audio-widget { display: none; } + +.comfy-graph-tooltip { + background: var(--comfy-input-bg); + border-radius: 5px; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.4); + color: var(--input-text); + display: none; + font-family: sans-serif; + left: 0; + max-width: 30vw; + padding: 4px 8px; + position: absolute; + top: 0; + transform: translate(5px, calc(-100% - 5px)); + white-space: pre-wrap; + z-index: 99999; +}