Skip to content

Commit

Permalink
More tweaks for log streaming and update readme
Browse files Browse the repository at this point in the history
  • Loading branch information
andrewharp committed Aug 1, 2024
1 parent 13f812e commit 74aae6f
Show file tree
Hide file tree
Showing 8 changed files with 140 additions and 75 deletions.
Binary file added assets/exceptions.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/image copy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/log_streaming.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/threshold_example.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
63 changes: 33 additions & 30 deletions easy_nodes/log_streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,37 +192,40 @@ def send_footer(response):
async def show_log(request):
offset = int(request.rel_url.query.get("offset", "-1"))
if "node" in request.rel_url.query:
node_id = str(request.rel_url.query["node"])
if node_id not in _buffers:
logging.error(f"Node {node_id} not found in buffers: {_buffers}")
return web.json_response({"node not found": node_id,
"valid nodes": [str(key) for key in _buffers.keys()]}, status=404)

response = await send_header(request)
node_class, prompt_id, buffer_list = _buffers[node_id]
await response.write(convert_text(f"Logs for node {Fore.GREEN}{node_id}{Fore.RESET} ({Fore.GREEN}{node_class}{Fore.RESET}) in prompt {Fore.GREEN}{prompt_id}{Fore.RESET}\n\n"))

invocation = 1
last_buffer_index = 0
while True:
for i in range(last_buffer_index, len(buffer_list)):
buffer = buffer_list[i]
invocation_header = f"======== Node invocation {Fore.GREEN}{invocation:3d}{Fore.RESET} ========\n"
await response.write(convert_text(invocation_header))
invocation += 1
buffer_content = buffer.value()
generator = tail_string(buffer_content, offset) if isinstance(buffer_content, str) else tail_buffer(buffer_content, offset)
await stream_content(response, generator)
last_buffer_index = i + 1
response.write("\n\n")
try:
node_id = str(request.rel_url.query["node"])
if node_id not in _buffers:
logging.error(f"Node {node_id} not found in buffers: {_buffers}")
return web.json_response({"node not found": node_id,
"valid nodes": [str(key) for key in _buffers.keys()]}, status=404)

# Wait for a second to check for new logs in case there's more coming.
await asyncio.sleep(1)
if last_buffer_index >= len(buffer_list):
break

await response.write(b"=====================================\n\nEnd of node logs.</pre></body></html>")

response = await send_header(request)
node_class, prompt_id, buffer_list = _buffers[node_id]
await response.write(convert_text(f"Logs for node {Fore.GREEN}{node_id}{Fore.RESET} ({Fore.GREEN}{node_class}{Fore.RESET}) in prompt {Fore.GREEN}{prompt_id}{Fore.RESET}\n\n"))

invocation = 1
last_buffer_index = 0
while True:
for i in range(last_buffer_index, len(buffer_list)):
buffer = buffer_list[i]
invocation_header = f"======== Node invocation {Fore.GREEN}{invocation:3d}{Fore.RESET} ========\n"
await response.write(convert_text(invocation_header))
invocation += 1
buffer_content = buffer.value()
generator = tail_string(buffer_content, offset) if isinstance(buffer_content, str) else tail_buffer(buffer_content, offset)
await stream_content(response, generator)
last_buffer_index = i + 1
await response.write("\n\n")

# Wait for a second to check for new logs in case there's more coming.
await asyncio.sleep(1)
if last_buffer_index >= len(buffer_list):
break

await response.write(b"=====================================\n\nEnd of node logs.</pre></body></html>")
except Exception as _:
pass

return response

response = await send_header(request)
Expand Down
83 changes: 58 additions & 25 deletions easy_nodes/web/easy_nodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ class FloatingLogWindow {
this.streamPromise = null;
this.debounceTimeout = null;
this.isFirstChunk = true;
this.isClicked = false;
this.isPinned = false;
this.pinButton = null;
}

create() {
Expand Down Expand Up @@ -124,8 +127,27 @@ class FloatingLogWindow {
color: #e0e0e0;
font-weight: bold;
cursor: move;
display: flex;
justify-content: space-between;
align-items: center;
`;

const title = document.createElement('span');
title.textContent = 'Node Log';
this.header.appendChild(title);

this.pinButton = document.createElement('button');
this.pinButton.style.cssText = `
background: none;
border: none;
color: #e0e0e0;
font-size: 18px;
cursor: pointer;
padding: 0 5px;
`;
this.header.textContent = 'Node Log';
this.pinButton.innerHTML = '📌';
this.pinButton.title = 'Pin window';
this.header.appendChild(this.pinButton);

this.content = document.createElement('div');
this.content.style.cssText = `
Expand Down Expand Up @@ -179,7 +201,6 @@ class FloatingLogWindow {
this.window.style.width = `${Math.max(this.minWidth, width)}px`;
this.window.style.height = `${Math.max(this.minHeight, height)}px`;
}

};

const onMouseUp = () => {
Expand All @@ -190,11 +211,13 @@ class FloatingLogWindow {
};

this.header.addEventListener('mousedown', (e) => {
isDragging = true;
startX = e.clientX;
startY = e.clientY;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
if (e.target !== this.pinButton) {
isDragging = true;
startX = e.clientX;
startY = e.clientY;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
});

this.resizeHandle.addEventListener('mousedown', (e) => {
Expand All @@ -208,41 +231,52 @@ class FloatingLogWindow {
});

this.window.addEventListener('mouseenter', () => {
this.mouseOverWindow = true;
this.cancelHideTimeout();
});

this.window.addEventListener('mouseleave', () => {
this.mouseOverWindow = false;
this.scheduleHide();
if (!this.isClicked && !this.isPinned) {
this.scheduleHide();
}
});

// Add click event listener to the window
this.window.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent the click from propagating to the document
this.isClicked = true;
this.cancelHideTimeout();
});

// Add global click event listener
document.addEventListener('click', (e) => {
if (this.window && this.window.style.display !== 'none') {
if (this.window && this.window.style.display !== 'none' && !this.isPinned) {
this.isClicked = false;
this.hide();
}
});


// Add pin button functionality
this.pinButton.addEventListener('click', () => {
this.isPinned = !this.isPinned;
this.pinButton.innerHTML = this.isPinned ? '📍' : '📌';
this.pinButton.title = this.isPinned ? 'Unpin window' : 'Pin window';
});
}

show(x, y, nodeId) {
if (!this.window) this.create();

// Convert canvas coordinates to screen coordinates
const rect = app.canvas.canvas.getBoundingClientRect();
const screenX = (x + rect.left + app.canvas.ds.offset[0]) * app.canvas.ds.scale;
const screenY = (y + rect.top + app.canvas.ds.offset[1]) * app.canvas.ds.scale;
if (!this.isPinned) {
// Convert canvas coordinates to screen coordinates
const rect = app.canvas.canvas.getBoundingClientRect();
const screenX = (x + rect.left + app.canvas.ds.offset[0]) * app.canvas.ds.scale;
const screenY = (y + rect.top + app.canvas.ds.offset[1]) * app.canvas.ds.scale;

this.window.style.left = `${screenX}px`;
this.window.style.top = `${screenY}px`;
}

this.window.style.display = 'flex';
this.window.style.left = `${screenX}px`;
this.window.style.top = `${screenY}px`;

if (this.currentNodeId !== nodeId) {
this.currentNodeId = nodeId;
Expand All @@ -251,15 +285,14 @@ class FloatingLogWindow {
this.debouncedStreamLog();
}

if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
this.hideTimeout = null;
}
this.cancelHideTimeout();
}

scheduleHide() {
this.cancelHideTimeout();
this.hideTimeout = setTimeout(() => this.hide(), 300);
if (!this.isPinned) {
this.cancelHideTimeout();
this.hideTimeout = setTimeout(() => this.hide(), 300);
}
}

cancelHideTimeout() {
Expand All @@ -270,7 +303,7 @@ class FloatingLogWindow {
}

hide() {
if (this.window) {
if (this.window && !this.isClicked && !this.isPinned) {
this.window.style.display = 'none';
this.currentNodeId = null;
this.cancelStream();
Expand Down
69 changes: 49 additions & 20 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,33 +18,75 @@ def threshold_image(image: ImageTensor,

That (plus [a tiny bit of initialization](#installation) in `__init__.py`) and your node is ready for ComfyUI! More examples can be found [here](example/example_nodes.py).

Sample node with tooltip and deep source link:
## ComfyNode Features

<img src="assets/threshold_example.png" alt="The new node with tooltip" width="50%">
- **@ComfyNode Decorator**: Simplifies the declaration of custom nodes with automagic node declaration based on Python type annotations. Existing Python functions can be converted to ComfyUI nodes with a simple "@ComfyNode()"
- **Built-in text and image previews**: Just call `easy_nodes.add_preview_text()` and `easy_nodes.add_preview_image()` in the body of your function and EasyNodes will automatically display it, no JavaScript hacking required.
- **Set node color easily**: No messing with JavaScript, just tell the decorator what color you want the node to be.
- **Type Support**: Includes several custom types (`ImageTensor`, `MaskTensor`, `NumberInput`, `Choice`, etc.) to support ComfyUI's connection semantics and UI functionality. Register additional types with `register_type`.
- **Automatic list and tuple handling**: Simply annotate the type as e.g. ```list[torch.Tensor]``` and your function will automatically make sure you get passed a list. It will also auto-tuple your return value for you internally (or leave it alone if you just want to copy your existing code).
- **Init-time checking**: Less scratching your head when your node doesn't fire off properly later. For example, if you copy-paste a node definition and forget to rename it, @ComfyNode will alert you immediately about duplicate nodes rather than simply overwriting the earlier definition.
- **Supports most ComfyUI node definition features**: validate_input, is_output_node, etc can be specified as parameters to the ComfyNode decorator.
- **Convert existing data classes to ComfyUI nodes**: pass `create_field_setter_node` a type, and it will automatically create a new node type with widgets to set all the fields.
- **LLM-based debugging**: Optional debugging and auto-fixing of exceptions during node execution. Will automatically create a prompt with the relevent context and send it to ChatGPT, create a patch and fix your code.

New settings:
## Additional Quality of Life Features

ComfyUI-EasyNodes also provides several new QoL features. Some only affect ComfyNodes (e.g. Log Streaming), others are more general (e.g. better stack traces). Look for the 🪄 symbol to find the settings:

<img src="assets/menu_options.png" alt="New menu options" width="50%">

Note that ImageTensor/MaskTensor are just syntactic sugar for semantically differentiating the annotations (allowing ComfyUI to know what plugs into what); your function will still get passed genunine torch.Tensor objects.
### New badges on node titles

Hover over the Log streaming, info and source links for new functionality.

<img src="assets/threshold_example.png" alt="The new node with tooltip" width="50%">

### Log Streaming

When hovering over the log icon, you'll see a floating log window that shows you the live output from your node. Clicking the pin will make the window stay even if it loses focus, and clicking the log icon will open the log in a new tab.

<img src="assets/log_streaming.png" alt="Streaming node logs into the UI" width="50%">

### Deep source links

Set the stack trace link prefix to make a "src" link appear on the node title and in the right-click menu. This will take you directly to the file in e.g. VSCode or Github.

### Info

## New in 1.2:
Automatically show the node's description when hovering over the info icon.

### Better stack traces

Setting the stack trace link prefix will give you prettier exception windows, with deep source links.

Old on left, new on right:

<img src="assets/exceptions.png" alt="New menu options" width="50%">

### Preview Image Persistence

Selecting the "Save preview images across browser refreshes" option means you won't have to re-run the node anymore to see the image again, so long as it persists on the server.

## Changelog

### New in 1.2:

- Stream node logs right to your browser; when an EasyNode is run it will show a log icon on the title bar. Clicking this will open up a new tab where you can see the logs accumulated during that node's execution. Icon rendering can be disabled via settings option if you want to keep things cleaner; in this case access via right-click menu option.
- Added save_node_list function to export nodes to a json file. This can be helpful e.g. for ingestion by ComfyUI-Manager.
- Set default node width and height.
- Retain preview images across browser refreshes if option is enabled (applies to all ComfyUI nodes)
- Bug fixes and cleanup.

## New in 1.1:
### New in 1.1:

- Custom verifiers for types on input and output for your nodes. For example, it will automatically verify that images always have 1, 3 or 4 channels (B&W, RGB and RGBA). Set `verify_level` when calling initialize_easy_nodes to either CheckSeverityMode OFF, WARN, or FATAL (default is WARN). You can write your own verifiers. See [comfy_types.py](easy_nodes/comfy_types.py) for examples of types with verifiers.
- Expanded ComfyUI type support. See [comfy_types.py](easy_nodes/comfy_types.py) for the full list of registered types.
- Added warnings if relying on node auto-registration without explicitly asking for it (while also supporting get_node_mappings() at the same time). This is because the default for auto_register will change to False in a future release, in order to make ComfyUI-EasyNodes more easily findable by indexers like ComfyUI-Manager (which expects your nodes to be found in your `__init__.py`). Options:
- If you wish to retain the previous behavior, you can enable auto-registration explicitly with `easy_nodes.initialize_easy_nodes(auto_register=True)`.
- Otherwise, export your nodes the normal way as shown in the [installation](#installation) section.

## New in 1.0:
### New in 1.0:

- Renamed to ComfyUI-EasyNodes from ComfyUI-Annotations to better reflect the package's goal (rather than the means)
- Package is now `easy_nodes` rather than `comfy_annotations`
Expand All @@ -62,19 +104,6 @@ Note that ImageTensor/MaskTensor are just syntactic sugar for semantically diffe
- Deep links to source code if you set a base source path (e.g. to github or your IDE)
- Bug fixes

## Features

- **@ComfyNode Decorator**: Simplifies the declaration of custom nodes with automagic node declaration based on Python type annotations. Existing Python functions can be converted to ComfyUI nodes with a simple "@ComfyNode()"
- **Built-in text and image previews**: Just call `easy_nodes.add_preview_text()` and `easy_nodes.add_preview_image()` in the body of your function and EasyNodes will automatically display it, no JavaScript hacking required.
- **Set node color easily**: No messing with JavaScript, just tell the decorator what color you want the node to be.
- **Type Support**: Includes several custom types (`ImageTensor`, `MaskTensor`, `NumberInput`, `Choice`, etc.) to support ComfyUI's connection semantics and UI functionality. Register additional types with `register_type`.
- **Automatic list and tuple handling**: Simply annotate the type as e.g. ```list[torch.Tensor]``` and your function will automatically make sure you get passed a list. It will also auto-tuple your return value for you internally (or leave it alone if you just want to copy your existing code).
- **Init-time checking**: Less scratching your head when your node doesn't fire off properly later. For example, if you copy-paste a node definition and forget to rename it, @ComfyNode will alert you immediately about duplicate nodes rather than simply overwriting the earlier definition.
- **Supports most ComfyUI node definition features**: validate_input, is_output_node, etc can be specified as parameters to the ComfyNode decorator.
- **Convert existing data classes to ComfyUI nodes**: pass `create_field_setter_node` a type, and it will automatically create a new node type with widgets to set all the fields.
- **LLM-based debugging**: Optional debugging and auto-fixing of exceptions during node execution. Will automatically create a prompt with the relevent context and send it to ChatGPT, create a patch and fix your code.


## Installation

To use this module in your ComfyUI project, follow these steps:
Expand Down

0 comments on commit 74aae6f

Please sign in to comment.