First truly working custom node. A text file loader!

This commit is contained in:
Ricky Sekulich
2025-11-02 21:38:15 -06:00
parent 3f1f1d530e
commit cddf8bbd45
4 changed files with 135 additions and 134 deletions
+10
View File
@@ -0,0 +1,10 @@
# Flailing Noises
Just me failing at both making and understanding how to do this. I'm slowly learning Python, but this playground should help from a practical perspective.
I finally got the first node working with a lot of back and forth on lack of documentation and help from AI Assistants explaining things to me.
For example, the first node is simply a text loader. Pick and choose the text file you want to use as an input. For me this is so I don't have to copy/paste large descriptions. Instead I can parse them programatically, use them whole, or even concatonate them with other nodes. All without having to mess with the source material and/or ccidentally wiping out previous prompts, pieces, or tweaks.
## Future thoughts(?)
I'm hoping to make a personal repository of quality of life type nodes for ComfyUI that make me scratch my head at why the don't exist in the base program.
+4 -2
View File
@@ -1,8 +1,10 @@
# Import our node classes
# Import your custom node class(es) from their respective files
# This line imports your custom node class (e.g., LoadText) from another Python file within the same custom node directory (e.g., nodes.py).
from .nodes import *
# This tells ComfyUI to look for a 'js' folder in our directory
WEB_DIRECTORY = "js"
# Tell ComfyUI about all the parts of our node
# (Optional) List the names that should be imported when using 'from package import *'
# This list defines the public interface of your module, specifying what names are imported when a user uses from your_module import *. While not strictly necessary for ComfyUI node registration, it's good practice for larger modules.
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
+81 -97
View File
@@ -1,101 +1,85 @@
import { app } from "/scripts/app.js";
import { app } from "../../scripts/app.js";
// This file just needs to register the new widget type
function textUploadWidget(node, inputName, inputData, app) {
const widget = {
type: inputData[0], // "TEXTUPLOAD"
name: inputName,
size: [128, 88], // Widget size
draw(ctx, node, width, y) {}, // No need to draw anything
computeSize(...args) {
return [128, 88]; // Match the size
},
async serializeValue(nodeId, widgetIndex) {
return node.widgets[widgetIndex].value;
},
};
// Create the "Choose File" button
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "text/plain,.txt"; // <--- This is the important change
fileInput.style.display = "none";
// Handle file selection
fileInput.addEventListener("change", (event) => {
if (fileInput.files.length) {
uploadFile(fileInput.files[0]);
}
});
// Create the button that opens the file picker
const uploadButton = document.createElement("button");
uploadButton.type = "button";
uploadButton.innerText = "Choose .txt File to Upload";
uploadButton.className = "comfy-btn";
uploadButton.style.width = "100%"; // Make button fill widget
uploadButton.style.marginTop = "10px";
uploadButton.addEventListener("click", () => {
fileInput.click();
});
// Function to handle the upload
async function uploadFile(file) {
const body = new FormData();
// We use "image" to match the server's /upload/image endpoint
// The server doesn't care about the file type, just saves it.
body.append("image", file);
body.append("overwrite", "true");
widget.value = file.name; // Store the filename for the backend
uploadButton.innerText = "Uploading...";
// This is the "correct" way to add frontend logic
app.registerExtension({
name: "Rathius.TextUploader", // Unique name for our extension
async beforeRegisterNodeDef(nodeType, nodeData, app) {
// This runs for every node *before* it's added
try {
const response = await fetch("/upload/image", {
method: "POST",
body: body,
});
// Check if this is our node by its Python class name
if (nodeData.name === "LoadTextFile_Rathius") {
// This function will be called *after* the node is created on the canvas
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, arguments);
if (response.status === 200) {
uploadButton.innerText = file.name; // Show the new filename
node.setValue(file.name); // Set the node's internal value
} else {
alert(response.status + " - " + response.statusText);
uploadButton.innerText = "Choose .txt File to Upload";
}
} catch (error) {
alert(error);
uploadButton.innerText = "Choose .txt File to Upload";
// Find the default "file_name" widget we created in Python
const widget = this.widgets.find((w) => w.name === "file_name");
// Create the "Choose File" button
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "text/plain,.txt";
fileInput.style.display = "none";
// Create the button widget
const uploadButton = this.addWidget("button", "Choose .txt File", "None", () => {
fileInput.click(); // Open the file picker when clicked
});
uploadButton.serialize = false; // Don't save this button's value in workflows
// Handle what happens when a file is selected
fileInput.addEventListener("change", (event) => {
if (fileInput.files.length) {
uploadFile(fileInput.files[0]);
}
});
// Create the upload function
const uploadFile = async (file) => {
const body = new FormData();
// 'input' is the default folder, but we can specify a subfolder
body.append("subfolder", "text_prompts");
body.append("overwrite", "true"); // Overwrite existing files
// The server's /upload/image endpoint handles *any* file
body.append("image", file, file.name);
uploadButton.name = "Uploading...";
try {
// Send the file to the server
const response = await fetch("/upload/image", {
method: "POST",
body: body,
});
if (response.status === 200) {
const data = await response.json();
// Set the value of the *original* (hidden) widget
// This is what Python will receive
const filePath = data.name; // e.g., "text_prompts/my_prompt.txt"
widget.value = filePath;
uploadButton.name = `Loaded: ${filePath}`;
} else {
alert(response.status + " - " + response.statusText);
uploadButton.name = "Choose .txt File";
}
} catch (error) {
alert(error);
uploadButton.name = "Choose .txt File";
}
};
// Add the hidden file input to the document
document.body.appendChild(fileInput);
// Hide the original string widget
widget.type = "hidden"; // We don't want to see the text box
widget.serialize = true; // We *do* want to save its value
};
}
}
// Add the button to the node's widget area
node.addDOMWidget(inputName, "button", uploadButton, widget);
document.body.appendChild(fileInput); // Add the hidden file input
// Handle drag and drop
node.onDragOver = function (e) {
if (e.dataTransfer.items.length > 0) {
const item = e.dataTransfer.items[0];
// Allow .txt files (which often have no MIME type) or text/plain
return item.kind === 'file' && (item.type === "text/plain" || item.type === "");
}
return false;
};
node.onDragDrop = function (e) {
let handled = false;
for (const file of e.dataTransfer.files) {
if (file.type === "text/plain" || file.name.endsWith(".txt")) {
uploadFile(file);
handled = true;
}
}
return handled;
};
return widget;
}
// Register our new "TEXTUPLOAD" widget with ComfyUI
app.registerWidget("TEXTUPLOAD", textUploadWidget);
},
});
+40 -35
View File
@@ -1,61 +1,66 @@
# Required boilerplate for ComfyUI custom nodes.
# - class LoadTextFile: - "Hello, I am a new node."
# - INPUT_TYPES - "Here is the form for the inputs I need."
# - RETURN_TYPES - "Here is what I will give back."
# - FUNCTION - "This is the name of the function that does the actual work."
# - CATEGORY - "Put me in this menu, please."
# - load_text(self, ...) - "I am the actual function that does the work."
# - NODE_CLASS_MAPPINGS - "This is the official registration form."
# Import any necessary modules
import os
import folder_paths
import hashlib
# --- The Node Class ---
class LoadTextFile:
"""
A node that loads text from a file.
It uses a custom widget to upload a .txt file.
"""
# Here you are naming your node class
class LoadTextFile_Rathius:
@classmethod
def INPUT_TYPES(s):
def INPUT_TYPES(cls):
return {
"required": {
# This string "image" MUST match the name in the
# FormData.append("image", file) in the JS.
# It tells ComfyUI to use our new "TEXTUPLOAD" widget.
"image": ("TEXTUPLOAD", )
# This is just a placeholder text box.
# Our JS will find this widget by its name ("file_name")
# and replace it with an upload button.
"file_name": ("STRING", {"default": "example.txt", "multiline": False}),
}
}
# What the node will output
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
# The function that runs when the node is executed
FUNCTION = "load_text"
CATEGORY = "RathiusNodes/Utils"
# The category in the right-click menu
CATEGORY = "Rathius_ComfyNodes/Utils"
# We don't want this node to run on file load, only when queued
def IS_CHANGED(self, **kwargs):
return float("inf")
def load_text(self, image):
# 'image' is the filename (e.g., "my_prompt.txt")
# The widget has already uploaded it to the 'input' folder.
file_path = os.path.join(folder_paths.get_input_directory(), image)
@classmethod
def IS_CHANGED(cls, file_name):
# This re-runs the node if the file name changes
try:
# Open and read the file
file_path = folder_paths.get_annotated_filepath(file_name)
except:
# File doesnt exist yet
return float("inf")
m = hashlib.sha256()
with open(file_path, 'rb') as f:
m.update(f.read())
return m.digest().hex()
def load_text(self, file_name):
# This function is more robust. It searches in all known input folders.
try:
file_path = folder_paths.get_annotated_filepath(file_name)
with open(file_path, 'r', encoding='utf-8') as f:
text_content = f.read()
# Return the text content as a tuple
return (text_content, )
except Exception as e:
print(f"[LoadTextFile] Error: {e}")
return (f"Error loading file: {e}", )
# Return the error message as a string so it can be debugged
return (f"Error: {e}", )
# --- Node Registration ---
NODE_CLASS_MAPPINGS = {
"LoadTextFile_Rathius": LoadTextFile
"LoadTextFile_Rathius": LoadTextFile_Rathius
}
# I updated the display name to reflect its new function
NODE_DISPLAY_NAME_MAPPINGS = {
"LoadTextFile_Rathius": "Load Text from File (Upload)"
}