Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for simple tooltips #3842

Merged
merged 15 commits into from
Aug 14, 2024
Merged
103 changes: 72 additions & 31 deletions nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,18 @@ def interrupt_processing(value=True):
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)
Expand Down Expand Up @@ -260,11 +267,18 @@ def set_range(self, conditioning, start, end):
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"]), )
Expand Down Expand Up @@ -506,12 +520,19 @@ def load_checkpoint(self, config_name, ckpt_name):
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)
Expand Down Expand Up @@ -582,16 +603,22 @@ def __init__(self):

@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:
Expand Down Expand Up @@ -1033,13 +1060,19 @@ def __init__(self):

@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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1424,18 +1460,23 @@ def __init__(self):

@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"

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
Expand Down
3 changes: 3 additions & 0 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,9 @@ def node_info(node_class):

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")
Expand Down
122 changes: 122 additions & 0 deletions web/extensions/core/tooltips.js
Original file line number Diff line number Diff line change
@@ -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);
}
},
});
},
});
2 changes: 1 addition & 1 deletion web/extensions/core/widgetInputs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down
3 changes: 2 additions & 1 deletion web/scripts/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions web/scripts/domWidget.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions web/scripts/widgets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down
17 changes: 17 additions & 0 deletions web/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -645,3 +645,20 @@ dialog::backdrop {
audio.comfy-audio.empty-audio-widget {
display: none;
}

.comfy-graph-tooltip {
pythongosssss marked this conversation as resolved.
Show resolved Hide resolved
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;
}
Loading