Initial commit: ComfyUI WatchPoint node

This commit is contained in:
Alex Bautista
2026-01-02 21:34:23 -06:00
commit 2daaa02c3c
7 changed files with 1605 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
# 👁️ ComfyUI Watch Point
**Dual preview system for ComfyUI workflows**
Watch Point provides two simultaneous preview methods:
- 🖥️ **Monitor Preview**: External Tkinter window on any monitor
- 🖼️ **Floating Preview**: ComfyUI's built-in preview system
## Features
### Monitor Preview (Tkinter)
- 🎯 Display on any connected monitor
- 🔍 Zoom/Pan controls with mouse
- ⌨️ Keyboard shortcuts
- 💾 Save images directly from preview
- 📋 Copy to clipboard
- ⚙️ Customizable settings
### Floating Preview (JavaScript)
- 📌 Uses ComfyUI's native preview system
- 🎨 Works with existing preview extensions
- 🔄 Auto-updates during workflow execution
## Installation
1. Clone into your ComfyUI custom_nodes directory:
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/yourusername/ComfyUI-WatchPoint.git
```
2. Install dependencies (if needed):
```bash
pip install pillow screeninfo
```
3. Restart ComfyUI
## Usage
1. Add **Watch Point** node to your workflow
2. Connect image input from any node
3. Enable/disable previews:
- `floating_preview`: Enable ComfyUI's built-in preview
- `monitor_preview`: Enable external monitor window
4. Select target monitor from dropdown
5. Connect output to next node (pass-through)
### Example Workflow
```
[Load Image] → [Watch Point] → [Upscale] → [Save Image]
↓
(Dual Preview)
```
## Controls (Monitor Preview)
### Mouse
- **Scroll wheel**: Zoom in/out
- **Left click + drag**: Pan image
- **Right click**: Context menu
### Keyboard
- **R**: Reset zoom and pan
- **T**: Toggle toolbar
- **1**: Zoom 1:1 (100% actual size)
- **ESC**: Close window
### Toolbar Buttons
- **↻ Reset**: Reset zoom and pan
- **⊕ Zoom In**: Increase zoom level
- **⊖ Zoom Out**: Decrease zoom level
- **1:1 (100%)**: View image at actual size
- **Window Size**: Quick resize presets
- **⛶ Fullscreen**: Toggle fullscreen mode
- **⚙ Settings**: Open settings dialog
## Settings
Access settings via the **⚙ Settings** button in the toolbar.
### Window Size
- Fixed sizes: 800x600, 1024x768, 1280x720, 1920x1080
- Dynamic sizes:
- **Half Vertical**: Half screen width, full height
- **Half Horizontal**: Full width, half screen height
- **Quarter**: Half width, half height
### Window Position
- Set exact X, Y coordinates for window placement
- Leave empty for automatic positioning
- Example: X=0, Y=0 for top-left corner
### Save Options
- Default format: PNG, JPEG, or WebP
- JPEG quality slider (10-100)
### UI Options
- Show/hide toolbar by default
All settings are saved to `watchpoint_settings.json`
## Configuration File
Edit `watchpoint_settings.json` to customize defaults:
```json
{
"window_width": 800,
"window_height": 600,
"window_x": null,
"window_y": null,
"window_size_mode": "fixed",
"show_toolbar": true,
"save_format": "png",
"jpeg_quality": 90
}
```
### Settings Explained
- `window_x`, `window_y`: Window position (null = auto)
- `window_size_mode`: "fixed", "Half Vertical", "Half Horizontal", or "Quarter"
- `show_toolbar`: Show toolbar on startup
- `save_format`: Default save format ("png", "jpeg", "webp")
- `jpeg_quality`: JPEG compression quality (10-100)
## Use Cases
### Dual Monitor Setup
Perfect for users with multiple monitors:
- Work on ComfyUI on main monitor
- View live preview on secondary monitor
- No need to switch windows or tabs
### Quality Control
Use 1:1 zoom to inspect:
- Image quality at pixel level
- Upscaling artifacts
- Detail preservation
### Live Monitoring
Watch your workflow progress in real-time:
- Both previews update simultaneously
- Choose the view that works best for your setup
- Keep an eye on intermediate results
## Requirements
- Python 3.7+
- tkinter (usually included with Python)
- PIL/Pillow
- screeninfo (optional, for multi-monitor detection)
## Optional Dependencies
For clipboard support (Windows):
```bash
pip install pywin32
```
## Troubleshooting
### Window doesn't appear
- Check if `monitor_preview` is enabled (True)
- Try different monitor selection
- Check console for error messages
### Window appears in wrong location
- Set explicit X, Y coordinates in settings
- Use `window_x: 0, window_y: 0` for top-left
### Image not updating
- Ensure workflow is executing
- Check that image is connected to Watch Point input
- Try disabling/re-enabling monitor preview
### Floating preview not working
- Ensure `floating_preview` is enabled (True)
- Check if other preview extensions are conflicting
- Restart ComfyUI
## Credits
Created for ComfyUI by [Your Name]
## License
MIT License - Feel free to use and modify!
+18
View File
@@ -0,0 +1,18 @@
"""
ComfyUI Watch Point
Dual preview system: External monitor (Tkinter) + Floating preview (JavaScript)
"""
from .watch_point import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
# Indicate that we have JavaScript files
WEB_DIRECTORY = "js"
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
print("✅ Watch Point Extension: Loaded")
print(" 👁️ Dual Preview System")
print(" 📺 Monitor Preview: Tkinter window on external monitor")
print(" 🖼️ Floating Preview: JavaScript floating window")
print(" ⌨️ Shortcuts: Ctrl+Alt+W (toggle floating)")
print(" 📁 JavaScript extension registered")
Binary file not shown.
Binary file not shown.
+633
View File
@@ -0,0 +1,633 @@
// watchpoint_floating.js
// Watch Point Floating Preview Integration
// Only captures images from Watch Point nodes
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
// Default configuration
const DEFAULT_CONFIG = {
window: {
defaultWidth: 320,
defaultHeight: 320,
defaultPosition: "middle-right",
defaultOpacity: 100
},
opacity: {
levels: [20, 40, 60, 80, 100],
showIndicator: true,
indicatorDuration: 1000
},
positioning: {
mode: "snap",
snapPositions: {
"top-left": { x: 20, y: 20 },
"top-center": { x: "center", y: 20 },
"top-right": { x: "right", y: 20 },
"middle-left": { x: 20, y: "center" },
"middle-center": { x: "center", y: "center" },
"middle-right": { x: "right", y: "center" },
"bottom-left": { x: 20, y: "bottom" },
"bottom-center": { x: "center", y: "bottom" },
"bottom-right": { x: "right", y: "bottom" }
},
showPositionIndicator: true,
positionIndicatorDuration: 800
},
ui: {
showHeader: true,
showTitle: true,
showCloseButton: true,
dragFromAnywhere: false
},
shortcuts: {
toggle: "Ctrl+Alt+KeyW",
increaseOpacity: "Ctrl+Alt+Equal",
decreaseOpacity: "Ctrl+Alt+Minus",
snapUp: "Ctrl+Alt+ArrowUp",
snapDown: "Ctrl+Alt+ArrowDown",
snapLeft: "Ctrl+Alt+ArrowLeft",
snapRight: "Ctrl+Alt+ArrowRight",
snapToPosition1: "Ctrl+Alt+Numpad1",
snapToPosition2: "Ctrl+Alt+Numpad2",
snapToPosition3: "Ctrl+Alt+Numpad3",
snapToPosition4: "Ctrl+Alt+Numpad4",
snapToPosition5: "Ctrl+Alt+Numpad5",
snapToPosition6: "Ctrl+Alt+Numpad6",
snapToPosition7: "Ctrl+Alt+Numpad7",
snapToPosition8: "Ctrl+Alt+Numpad8",
snapToPosition9: "Ctrl+Alt+Numpad9"
},
behavior: {
autoShow: true,
rememberPosition: false
}
};
// Configuration loader
async function loadConfig() {
try {
const response = await fetch('/extensions/ComfyUI-WatchPoint/watchpoint_config.json');
if (response.ok) {
const userConfig = await response.json();
console.log("✅ Watch Point Floating: Loaded user config");
return { ...DEFAULT_CONFIG, ...userConfig };
}
} catch (e) {
console.log("ℹ️ Watch Point Floating: No custom config found, using defaults");
}
return DEFAULT_CONFIG;
}
// Main extension
app.registerExtension({
name: "WatchPoint.FloatingPreview",
async setup() {
console.log("👁️ Watch Point Floating: Initializing...");
// Load configuration
const config = await loadConfig();
// Create the floating preview window
const pip = new WatchPointFloatingWindow(config);
pip.create();
// Listen for execution events
api.addEventListener("executed", (event) => {
const { node, output } = event.detail;
// Get the actual node from the workflow
const workflowNode = app.graph.getNodeById(node);
// Only capture if this is a WatchPoint node
if (workflowNode && workflowNode.type === "WatchPoint") {
// Check if floating_preview is enabled
const floatingEnabled = workflowNode.widgets?.find(w => w.name === "floating_preview")?.value;
if (floatingEnabled && output && output.images && output.images.length > 0) {
const imageInfo = output.images[0];
pip.updateImage(imageInfo);
console.log("👁️ Watch Point Floating: Updated from node", node);
}
}
});
// Add keyboard shortcuts
window.addEventListener("keydown", (e) => {
// Check if we're not typing in an input field
const activeElement = document.activeElement;
const isTyping = activeElement && (
activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.isContentEditable
);
if (isTyping) return;
const key = e.code;
const shortcut = `${e.ctrlKey ? 'Ctrl+' : ''}${e.altKey ? 'Alt+' : ''}${e.shiftKey ? 'Shift+' : ''}${key}`;
// Toggle preview
if (shortcut === config.shortcuts.toggle) {
e.preventDefault();
e.stopPropagation();
pip.toggle();
console.log("🎹 Watch Point Floating: Toggled via", config.shortcuts.toggle);
}
// Opacity controls
else if (shortcut === config.shortcuts.increaseOpacity || (e.ctrlKey && e.altKey && e.key === '+')) {
e.preventDefault();
e.stopPropagation();
pip.increaseOpacity();
}
else if (shortcut === config.shortcuts.decreaseOpacity || (e.ctrlKey && e.altKey && e.key === '-')) {
e.preventDefault();
e.stopPropagation();
pip.decreaseOpacity();
}
// Snap positioning - Arrows
else if (shortcut === config.shortcuts.snapUp) {
e.preventDefault();
e.stopPropagation();
pip.moveRelative('up');
}
else if (shortcut === config.shortcuts.snapDown) {
e.preventDefault();
e.stopPropagation();
pip.moveRelative('down');
}
else if (shortcut === config.shortcuts.snapLeft) {
e.preventDefault();
e.stopPropagation();
pip.moveRelative('left');
}
else if (shortcut === config.shortcuts.snapRight) {
e.preventDefault();
e.stopPropagation();
pip.moveRelative('right');
}
// Snap positioning - Numpad (absolute)
else if (shortcut === config.shortcuts.snapToPosition1) {
e.preventDefault();
pip.snapToPosition('bottom-left');
}
else if (shortcut === config.shortcuts.snapToPosition2) {
e.preventDefault();
pip.snapToPosition('bottom-center');
}
else if (shortcut === config.shortcuts.snapToPosition3) {
e.preventDefault();
pip.snapToPosition('bottom-right');
}
else if (shortcut === config.shortcuts.snapToPosition4) {
e.preventDefault();
pip.snapToPosition('middle-left');
}
else if (shortcut === config.shortcuts.snapToPosition5) {
e.preventDefault();
pip.snapToPosition('middle-center');
}
else if (shortcut === config.shortcuts.snapToPosition6) {
e.preventDefault();
pip.snapToPosition('middle-right');
}
else if (shortcut === config.shortcuts.snapToPosition7) {
e.preventDefault();
pip.snapToPosition('top-left');
}
else if (shortcut === config.shortcuts.snapToPosition8) {
e.preventDefault();
pip.snapToPosition('top-center');
}
else if (shortcut === config.shortcuts.snapToPosition9) {
e.preventDefault();
pip.snapToPosition('top-right');
}
}, true);
console.log("⌨️ Watch Point Floating: Keyboard shortcuts registered");
console.log(" - Toggle:", config.shortcuts.toggle);
console.log(" - Opacity: Ctrl+Alt+[+/-]");
console.log(" - Position: Ctrl+Alt+[Arrows] or Ctrl+Alt+[Numpad1-9]");
}
});
// Floating Preview Window Class
class WatchPointFloatingWindow {
constructor(config) {
this.config = config;
this.container = null;
this.img = null;
this.visible = false;
this.isDragging = false;
this.initialX = 0;
this.initialY = 0;
// Size from config
this.size = {
width: config.window.defaultWidth,
height: config.window.defaultHeight
};
// Position from config
this.currentPosition = config.window.defaultPosition;
const pos = this.calculatePosition(this.currentPosition);
this.position = pos;
this.currentX = pos.x;
this.currentY = pos.y;
// Opacity from config
this.opacityLevels = config.opacity.levels.map(l => l / 100);
this.currentOpacityIndex = this.opacityLevels.indexOf(config.window.defaultOpacity / 100);
if (this.currentOpacityIndex === -1) this.currentOpacityIndex = this.opacityLevels.length - 1;
this.opacityIndicator = null;
this.positionIndicator = null;
// Snap positions grid (for relative movement)
this.positionGrid = [
['top-left', 'top-center', 'top-right'],
['middle-left', 'middle-center', 'middle-right'],
['bottom-left', 'bottom-center', 'bottom-right']
];
}
calculatePosition(positionName) {
const snapPos = this.config.positioning.snapPositions[positionName];
if (!snapPos) return { x: 20, y: window.innerHeight / 2 - this.size.height / 2 };
let x = snapPos.x;
let y = snapPos.y;
// Calculate relative positions
if (x === "center") {
x = (window.innerWidth - this.size.width) / 2;
} else if (x === "right") {
x = window.innerWidth - this.size.width - 20;
}
if (y === "center") {
y = (window.innerHeight - this.size.height) / 2;
} else if (y === "bottom") {
y = window.innerHeight - this.size.height - 20;
}
return { x, y };
}
getCurrentGridPosition() {
// Find current position in grid
for (let row = 0; row < this.positionGrid.length; row++) {
for (let col = 0; col < this.positionGrid[row].length; col++) {
if (this.positionGrid[row][col] === this.currentPosition) {
return { row, col };
}
}
}
return { row: 1, col: 2 }; // Default to middle-right
}
moveRelative(direction) {
const current = this.getCurrentGridPosition();
let newRow = current.row;
let newCol = current.col;
switch(direction) {
case 'up':
newRow = Math.max(0, current.row - 1);
break;
case 'down':
newRow = Math.min(this.positionGrid.length - 1, current.row + 1);
break;
case 'left':
newCol = Math.max(0, current.col - 1);
break;
case 'right':
newCol = Math.min(this.positionGrid[0].length - 1, current.col + 1);
break;
}
const newPosition = this.positionGrid[newRow][newCol];
this.snapToPosition(newPosition);
}
snapToPosition(positionName) {
this.currentPosition = positionName;
const pos = this.calculatePosition(positionName);
this.currentX = pos.x;
this.currentY = pos.y;
if (this.container) {
this.container.style.left = pos.x + "px";
this.container.style.top = pos.y + "px";
if (this.config.positioning.showPositionIndicator) {
this.showPositionIndicator(positionName);
}
}
}
showPositionIndicator(positionName) {
// Remove existing indicator if any
if (this.positionIndicator) {
this.positionIndicator.remove();
}
// Format position name for display
const displayName = positionName.split('-').map(w =>
w.charAt(0).toUpperCase() + w.slice(1)
).join(' ');
// Create position indicator
this.positionIndicator = document.createElement("div");
this.positionIndicator.textContent = displayName;
this.positionIndicator.style.cssText = `
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px 20px;
border-radius: 5px;
font-size: 14px;
font-weight: bold;
z-index: 10000;
pointer-events: none;
`;
this.container.appendChild(this.positionIndicator);
// Remove indicator after duration
setTimeout(() => {
if (this.positionIndicator) {
this.positionIndicator.remove();
this.positionIndicator = null;
}
}, this.config.positioning.positionIndicatorDuration);
}
create() {
// Create container
this.container = document.createElement("div");
this.container.id = "watchpoint-floating-preview";
this.container.style.cssText = `
position: fixed;
width: ${this.size.width}px;
height: ${this.size.height}px;
left: ${this.position.x}px;
top: ${this.position.y}px;
background: #1a1a1a;
border: 2px solid #444;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
z-index: 9999;
display: none;
overflow: hidden;
resize: both;
min-width: 160px;
min-height: 120px;
`;
let imageContainer;
// Create header if enabled
if (this.config.ui.showHeader) {
const header = document.createElement("div");
header.style.cssText = `
background: #2a2a2a;
padding: 8px;
cursor: move;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
`;
if (this.config.ui.showTitle) {
const title = document.createElement("span");
title.textContent = "👁️ Watch Point";
title.style.cssText = `
color: #fff;
font-size: 12px;
font-weight: bold;
`;
header.appendChild(title);
}
if (this.config.ui.showCloseButton) {
const closeBtn = document.createElement("button");
closeBtn.textContent = "×";
closeBtn.style.cssText = `
background: none;
border: none;
color: #fff;
font-size: 20px;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
line-height: 16px;
`;
closeBtn.onclick = () => this.hide();
header.appendChild(closeBtn);
}
this.container.appendChild(header);
this.setupDragging(header);
imageContainer = document.createElement("div");
imageContainer.style.cssText = `
width: 100%;
height: calc(100% - 36px);
display: flex;
align-items: center;
justify-content: center;
background: #000;
overflow: hidden;
`;
} else {
// No header - full window is image
imageContainer = document.createElement("div");
imageContainer.style.cssText = `
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #000;
overflow: hidden;
`;
if (this.config.ui.dragFromAnywhere) {
this.setupDragging(this.container);
}
}
// Create image element
this.img = document.createElement("img");
this.img.style.cssText = `
max-width: 100%;
max-height: 100%;
object-fit: contain;
`;
// Placeholder text
const placeholder = document.createElement("div");
placeholder.id = "watchpoint-placeholder";
placeholder.textContent = "Waiting for Watch Point...";
placeholder.style.cssText = `
color: #666;
font-size: 14px;
`;
imageContainer.appendChild(placeholder);
imageContainer.appendChild(this.img);
this.container.appendChild(imageContainer);
document.body.appendChild(this.container);
console.log("✅ Watch Point Floating: Created");
}
setupDragging(dragElement) {
dragElement.addEventListener("mousedown", (e) => {
this.isDragging = true;
const rect = this.container.getBoundingClientRect();
this.currentX = rect.left;
this.currentY = rect.top;
this.initialX = e.clientX - this.currentX;
this.initialY = e.clientY - this.currentY;
document.addEventListener("mousemove", this.drag);
document.addEventListener("mouseup", this.stopDrag);
});
}
drag = (e) => {
if (!this.isDragging) return;
e.preventDefault();
this.currentX = e.clientX - this.initialX;
this.currentY = e.clientY - this.initialY;
this.container.style.left = this.currentX + "px";
this.container.style.top = this.currentY + "px";
}
stopDrag = () => {
this.isDragging = false;
document.removeEventListener("mousemove", this.drag);
document.removeEventListener("mouseup", this.stopDrag);
this.position.x = this.currentX;
this.position.y = this.currentY;
}
updateImage(imageInfo) {
if (!this.container) return;
const url = api.apiURL(
`/view?filename=${encodeURIComponent(imageInfo.filename)}&type=${imageInfo.type}&subfolder=${encodeURIComponent(imageInfo.subfolder || '')}`
);
this.img.src = url;
this.img.style.display = "block";
const placeholder = document.getElementById("watchpoint-placeholder");
if (placeholder) {
placeholder.style.display = "none";
}
if (!this.visible && this.config.behavior.autoShow) {
this.show();
}
console.log("👁️ Watch Point Floating: Updated image");
}
show() {
if (!this.container) return;
this.container.style.display = "block";
this.visible = true;
}
hide() {
if (!this.container) return;
this.container.style.display = "none";
this.visible = false;
}
toggle() {
if (this.visible) {
this.hide();
} else {
this.show();
}
}
increaseOpacity() {
if (this.currentOpacityIndex < this.opacityLevels.length - 1) {
this.currentOpacityIndex++;
this.applyOpacity();
}
}
decreaseOpacity() {
if (this.currentOpacityIndex > 0) {
this.currentOpacityIndex--;
this.applyOpacity();
}
}
applyOpacity() {
if (!this.container) return;
const opacity = this.opacityLevels[this.currentOpacityIndex];
this.container.style.opacity = opacity;
if (this.config.opacity.showIndicator) {
this.showOpacityIndicator(opacity);
}
}
showOpacityIndicator(opacity) {
if (this.opacityIndicator) {
this.opacityIndicator.remove();
}
this.opacityIndicator = document.createElement("div");
this.opacityIndicator.textContent = `Opacity: ${Math.round(opacity * 100)}%`;
this.opacityIndicator.style.cssText = `
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px 20px;
border-radius: 5px;
font-size: 16px;
font-weight: bold;
z-index: 10000;
pointer-events: none;
`;
this.container.appendChild(this.opacityIndicator);
setTimeout(() => {
if (this.opacityIndicator) {
this.opacityIndicator.remove();
this.opacityIndicator = null;
}
}, this.config.opacity.indicatorDuration);
}
}
console.log("✅ Watch Point Floating Extension Loaded");
+756
View File
@@ -0,0 +1,756 @@
import os
import json
import tkinter as tk
from tkinter import filedialog, messagebox
from PIL import Image, ImageTk
import numpy as np
from threading import Thread, Lock
import io
import folder_paths
import torch
# Icon Base64 (opcional, pega tu icono aquí)
ICON_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAACXBIWXMAAAsSAAALEgHS3X78AAAH40lEQVR4nO2dX0xb1x3Hv9exjamHr0GRNzRMAFXrGgztolQFVXRWI5VJCwp9yNI401SpKJOWvoSX9Q"
try:
from screeninfo import get_monitors
SCREENINFO_AVAILABLE = True
except ImportError:
SCREENINFO_AVAILABLE = False
class WatchPoint:
"""
Watch Point - Dual Preview Monitor
Shows image in external monitor (Tkinter) and/or floating preview (ComfyUI built-in)
Controls (Monitor Preview):
- Mouse wheel: Zoom in/out
- Left click + drag: Pan image
- Right click: Context menu
- T key: Toggle toolbar
- R key: Reset zoom and position
- 1 key: Zoom 1:1 (100%)
- ESC: Close window
"""
_windows = {} # monitor_idx -> window data
@classmethod
def INPUT_TYPES(cls):
monitor_list = cls.get_monitors()
return {
"required": {
"images": ("IMAGE",),
"floating_preview": ("BOOLEAN", {"default": True}),
"monitor_preview": ("BOOLEAN", {"default": True}),
"monitor": (monitor_list, {"default": monitor_list[0]}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("images",)
FUNCTION = "watch"
OUTPUT_NODE = True
CATEGORY = "image/preview"
@classmethod
def get_monitors(cls):
monitors = []
if SCREENINFO_AVAILABLE:
try:
actual_monitors = get_monitors()
for i, m in enumerate(actual_monitors):
monitors.append(f"Monitor {i} ({m.width}x{m.height})")
except Exception:
pass
if not monitors:
monitors = ["Monitor 0 (1920x1080)"]
return monitors
def watch(self, images, floating_preview=True, monitor_preview=True, monitor="Monitor 0 (1920x1080)"):
# Determine monitor index
try:
display_idx = int(monitor.split(" ")[1])
except Exception:
display_idx = 0
# Handle Monitor Preview (Tkinter)
if monitor_preview:
self._show_monitor_preview(images, display_idx)
else:
self._hide_monitor_preview(display_idx)
# Handle Floating Preview (ComfyUI built-in)
# For floating preview to work, we need to save the image temporarily
# and return it in the expected format
result = []
if floating_preview:
# Save preview images so ComfyUI's preview system can pick them up
result = self._prepare_preview(images)
return {"ui": {"images": result}, "result": (images,)}
def _show_monitor_preview(self, images, display_idx):
"""Show image in external monitor using Tkinter"""
# Convert tensor to PIL Image
image = images[0]
image = 255.0 * image.cpu().numpy()
pil_img = Image.fromarray(np.clip(image, 0, 255).astype(np.uint8))
# Check if window exists and is still running
if display_idx in self._windows:
# Check if the window is actually alive
if not self._windows[display_idx].get("running", False):
# Window was closed, remove it and create new one
try:
if "thread" in self._windows[display_idx]:
self._windows[display_idx]["thread"].join(timeout=0.1)
except:
pass
del self._windows[display_idx]
# Create or update window
if display_idx not in self._windows:
lock = Lock()
self._windows[display_idx] = {
"image": pil_img,
"lock": lock,
"running": True,
"root": None,
}
t = Thread(target=self._window_loop, args=(display_idx,), daemon=True)
self._windows[display_idx]["thread"] = t
t.start()
else:
# Update existing window
try:
with self._windows[display_idx]["lock"]:
self._windows[display_idx]["image"] = pil_img
except:
# If lock fails, window might be dead, recreate it
del self._windows[display_idx]
self._show_monitor_preview(images, display_idx)
def _hide_monitor_preview(self, display_idx):
"""Hide monitor preview"""
if display_idx in self._windows:
self._windows[display_idx]["running"] = False
def _prepare_preview(self, images):
"""Prepare images for ComfyUI's preview system (enables floating preview)"""
results = []
for i, tensor in enumerate(images):
# Convert tensor to numpy
array = 255.0 * tensor.cpu().numpy()
img = Image.fromarray(np.clip(array, 0, 255).astype(np.uint8))
# Save to temp directory with unique timestamp
output_dir = folder_paths.get_temp_directory()
# Generate unique filename with timestamp to avoid caching issues
import time
timestamp = int(time.time() * 1000) # milliseconds
filename = f"watchpoint_{timestamp:013d}_{i:02d}.png"
file_path = os.path.join(output_dir, filename)
img.save(file_path, compress_level=4)
results.append({
"filename": filename,
"subfolder": "",
"type": "temp"
})
return results
def _window_loop(self, display_idx):
"""Main window loop with Tkinter"""
# Load settings
settings = self._load_settings()
# Create Tkinter window
root = tk.Tk()
root.title(f"Watch Point - Monitor {display_idx}")
# Configure window icon if exists
if ICON_BASE64:
try:
icon_img = tk.PhotoImage(data=ICON_BASE64)
root.iconphoto(True, icon_img)
except Exception as e:
print(f"Could not set window icon: {e}")
# Set window size from settings
window_size_mode = settings.get("window_size_mode", "fixed")
if window_size_mode in ["Half Vertical", "Half Horizontal", "Quarter"]:
# Get screen dimensions
screen_w = root.winfo_screenwidth()
screen_h = root.winfo_screenheight()
# Calculate dynamic size
if window_size_mode == "Half Vertical":
window_width = screen_w // 2
window_height = screen_h - 100
elif window_size_mode == "Half Horizontal":
window_width = screen_w - 100
window_height = screen_h // 2
elif window_size_mode == "Quarter":
window_width = screen_w // 2
window_height = screen_h // 2
else:
# Use fixed size from settings
window_width = settings.get("window_width", 800)
window_height = settings.get("window_height", 600)
root.geometry(f"{window_width}x{window_height}")
# Store root reference
self._windows[display_idx]["root"] = root
# Create preview window instance
preview = WatchPointWindow(root, display_idx, self._windows, settings)
# Position window based on settings or monitor position
window_x = settings.get("window_x", None)
window_y = settings.get("window_y", None)
if window_x is not None and window_y is not None:
# Use saved position from settings
root.geometry(f"+{window_x}+{window_y}")
elif SCREENINFO_AVAILABLE:
# Fallback to monitor position
try:
monitor_info = get_monitors()[display_idx]
x = monitor_info.x + 50
y = monitor_info.y + 50
root.geometry(f"+{x}+{y}")
except Exception:
pass
# Start Tkinter main loop
try:
root.mainloop()
except Exception as e:
print(f"Watch Point window error: {e}")
finally:
self._windows[display_idx]["running"] = False
def _load_settings(self):
"""Load settings from JSON file"""
settings_file = os.path.join(os.path.dirname(__file__), "watchpoint_settings.json")
default_settings = {
"window_width": 800,
"window_height": 600,
"window_x": None,
"window_y": None,
"show_toolbar": True,
"save_format": "png",
"jpeg_quality": 90,
}
try:
if os.path.exists(settings_file):
with open(settings_file, 'r') as f:
user_settings = json.load(f)
return {**default_settings, **user_settings}
except Exception as e:
print(f"Could not load settings: {e}")
return default_settings
class WatchPointWindow:
"""Tkinter-based preview window with zoom/pan"""
def __init__(self, root, display_idx, windows_dict, settings):
self.root = root
self.display_idx = display_idx
self.windows = windows_dict
self.settings = settings
# Zoom/Pan state
self.zoom_level = 1.0
self.pan_x = 0
self.pan_y = 0
self.is_dragging = False
self.drag_start_x = 0
self.drag_start_y = 0
self.zoom_1to1_active = False
# Current image
self.current_pil_image = None
self.photo_image = None
# Setup UI
self._create_ui()
self._bind_events()
# Start update loop
self._update_image()
def _create_ui(self):
"""Create UI elements"""
# Main container
self.main_frame = tk.Frame(self.root, bg='#1a1a1a')
self.main_frame.pack(fill=tk.BOTH, expand=True)
# Toolbar
self.toolbar_visible = self.settings.get("show_toolbar", True)
self._create_toolbar()
# Canvas for image display
self.canvas = tk.Canvas(self.main_frame, bg='#000000', highlightthickness=0)
self.canvas.pack(fill=tk.BOTH, expand=True)
# Create context menu
self._create_context_menu()
def _create_toolbar(self):
"""Create toolbar with buttons"""
self.toolbar = tk.Frame(self.main_frame, bg='#2a2a2a', height=40)
# Buttons
btn_style = {'bg': '#3a3a3a', 'fg': 'white', 'relief': tk.FLAT, 'padx': 8, 'pady': 4}
tk.Button(self.toolbar, text="↻ Reset", command=self._reset_zoom, **btn_style).pack(side=tk.LEFT, padx=2)
tk.Button(self.toolbar, text="⊕ Zoom In", command=self._zoom_in, **btn_style).pack(side=tk.LEFT, padx=2)
tk.Button(self.toolbar, text="⊖ Zoom Out", command=self._zoom_out, **btn_style).pack(side=tk.LEFT, padx=2)
tk.Button(self.toolbar, text="1:1 (100%)", command=self._zoom_1to1, **btn_style).pack(side=tk.LEFT, padx=2)
# Window size dropdown
self.size_var = tk.StringVar(value="Current")
size_options = [
"800x600",
"1024x768",
"1280x720",
"1920x1080",
"---",
"Half Vertical",
"Half Horizontal",
"Quarter"
]
size_menu = tk.OptionMenu(self.toolbar, self.size_var, *size_options,
command=self._change_window_size)
size_menu.config(bg='#3a3a3a', fg='white', relief=tk.FLAT)
size_menu.pack(side=tk.LEFT, padx=2)
tk.Button(self.toolbar, text="⛶ Fullscreen", command=self._toggle_fullscreen, **btn_style).pack(side=tk.LEFT, padx=2)
tk.Button(self.toolbar, text="⚙ Settings", command=self._open_settings, **btn_style).pack(side=tk.LEFT, padx=2)
if self.toolbar_visible:
self.toolbar.pack(side=tk.TOP, fill=tk.X)
def _create_context_menu(self):
"""Create right-click context menu"""
self.context_menu = tk.Menu(self.root, tearoff=0)
self.context_menu.add_command(label="Save Image As...", command=self._save_image)
self.context_menu.add_command(label="Copy to Clipboard", command=self._copy_to_clipboard)
self.context_menu.add_separator()
self.context_menu.add_command(label="Zoom 1:1 (100%)", command=self._zoom_1to1)
self.context_menu.add_command(label="Reset Zoom", command=self._reset_zoom)
def _bind_events(self):
"""Bind keyboard and mouse events"""
# Mouse events
self.canvas.bind("<ButtonPress-1>", self._on_mouse_down)
self.canvas.bind("<B1-Motion>", self._on_mouse_drag)
self.canvas.bind("<ButtonRelease-1>", self._on_mouse_up)
self.canvas.bind("<MouseWheel>", self._on_mouse_wheel)
self.canvas.bind("<Button-3>", self._show_context_menu)
# Keyboard events
self.root.bind("<KeyPress-r>", lambda e: self._reset_zoom())
self.root.bind("<KeyPress-R>", lambda e: self._reset_zoom())
self.root.bind("<KeyPress-t>", lambda e: self._toggle_toolbar())
self.root.bind("<KeyPress-T>", lambda e: self._toggle_toolbar())
self.root.bind("<KeyPress-1>", lambda e: self._zoom_1to1())
self.root.bind("<Escape>", lambda e: self._close_window())
# Window close event
self.root.protocol("WM_DELETE_WINDOW", self._close_window)
def _update_image(self):
"""Update displayed image from shared state"""
try:
if not self.windows[self.display_idx]["running"]:
# Window is closing, stop the loop gracefully
try:
self.root.quit()
except:
pass
return
with self.windows[self.display_idx]["lock"]:
pil_img = self.windows[self.display_idx]["image"]
if pil_img is not None:
if pil_img != self.current_pil_image:
self.current_pil_image = pil_img
self._render_image()
self.root.after(33, self._update_image) # ~30 FPS
except Exception as e:
print(f"Update image error: {e}")
# If error, mark as not running and stop
self.windows[self.display_idx]["running"] = False
try:
self.root.quit()
except:
pass
def _render_image(self):
"""Render image on canvas with zoom/pan"""
if not self.current_pil_image:
return
self.canvas.update_idletasks()
canvas_width = self.canvas.winfo_width()
canvas_height = self.canvas.winfo_height()
if canvas_width <= 1 or canvas_height <= 1:
self.root.after(100, self._render_image)
return
try:
img_w, img_h = self.current_pil_image.size
if self.zoom_1to1_active:
scale = 1.0
else:
scale = min(canvas_width / img_w, canvas_height / img_h) * self.zoom_level
new_w = max(1, int(img_w * scale))
new_h = max(1, int(img_h * scale))
resized = self.current_pil_image.resize((new_w, new_h), Image.LANCZOS)
x_pos = (canvas_width - new_w) // 2 + int(self.pan_x)
y_pos = (canvas_height - new_h) // 2 + int(self.pan_y)
self.photo_image = ImageTk.PhotoImage(resized)
self.canvas.delete("all")
self.canvas.create_image(x_pos, y_pos, anchor=tk.NW, image=self.photo_image)
except Exception as e:
print(f"Render error: {e}")
def _on_mouse_wheel(self, event):
"""Handle mouse wheel for zoom"""
if not self.zoom_1to1_active:
if event.delta > 0:
self._zoom_in()
else:
self._zoom_out()
def _on_mouse_down(self, event):
"""Start dragging"""
self.is_dragging = True
self.drag_start_x = event.x
self.drag_start_y = event.y
def _on_mouse_drag(self, event):
"""Handle dragging for pan"""
if self.is_dragging:
dx = event.x - self.drag_start_x
dy = event.y - self.drag_start_y
self.pan_x += dx
self.pan_y += dy
self.drag_start_x = event.x
self.drag_start_y = event.y
self._render_image()
def _on_mouse_up(self, event):
"""Stop dragging"""
self.is_dragging = False
def _show_context_menu(self, event):
"""Show context menu on right click"""
try:
self.context_menu.tk_popup(event.x_root, event.y_root)
finally:
self.context_menu.grab_release()
def _zoom_in(self):
"""Zoom in"""
self.zoom_1to1_active = False
self.zoom_level = min(10.0, self.zoom_level * 1.2)
self._render_image()
def _zoom_out(self):
"""Zoom out"""
self.zoom_1to1_active = False
self.zoom_level = max(0.1, self.zoom_level / 1.2)
self._render_image()
def _zoom_1to1(self):
"""Zoom to 1:1 (100% actual size)"""
self.zoom_1to1_active = True
self.pan_x = 0
self.pan_y = 0
self._render_image()
def _reset_zoom(self):
"""Reset zoom and pan"""
self.zoom_1to1_active = False
self.zoom_level = 1.0
self.pan_x = 0
self.pan_y = 0
self._render_image()
def _toggle_toolbar(self):
"""Toggle toolbar visibility"""
if self.toolbar_visible:
self.toolbar.pack_forget()
self.toolbar_visible = False
else:
self.toolbar.pack(side=tk.TOP, fill=tk.X, before=self.canvas)
self.toolbar_visible = True
def _change_window_size(self, size_str):
"""Change window size"""
if size_str == "---":
return
try:
screen_w = self.root.winfo_screenwidth()
screen_h = self.root.winfo_screenheight()
if size_str == "Half Vertical":
w = screen_w // 2
h = screen_h - 100
self.root.geometry(f"{w}x{h}")
elif size_str == "Half Horizontal":
w = screen_w - 100
h = screen_h // 2
self.root.geometry(f"{w}x{h}")
elif size_str == "Quarter":
w = screen_w // 2
h = screen_h // 2
self.root.geometry(f"{w}x{h}")
else:
w, h = map(int, size_str.split('x'))
self.root.geometry(f"{w}x{h}")
except Exception as e:
print(f"Error changing window size: {e}")
def _toggle_fullscreen(self):
"""Toggle fullscreen mode"""
current_state = self.root.attributes('-fullscreen')
self.root.attributes('-fullscreen', not current_state)
def _save_image(self):
"""Save current image to file"""
if not self.current_pil_image:
messagebox.showwarning("No Image", "No image to save")
return
save_format = self.settings.get("save_format", "png").upper()
filetypes = [
("PNG Image", "*.png"),
("JPEG Image", "*.jpg"),
("WebP Image", "*.webp"),
("All Files", "*.*")
]
filename = filedialog.asksaveasfilename(
defaultextension=f".{save_format.lower()}",
filetypes=filetypes,
title="Save Image As"
)
if filename:
try:
if filename.lower().endswith('.jpg') or filename.lower().endswith('.jpeg'):
quality = self.settings.get("jpeg_quality", 90)
self.current_pil_image.save(filename, "JPEG", quality=quality)
else:
self.current_pil_image.save(filename)
messagebox.showinfo("Success", f"Image saved to:\n{filename}")
except Exception as e:
messagebox.showerror("Error", f"Failed to save image:\n{str(e)}")
def _copy_to_clipboard(self):
"""Copy image to clipboard"""
if not self.current_pil_image:
messagebox.showwarning("No Image", "No image to copy")
return
try:
output = io.BytesIO()
self.current_pil_image.convert('RGB').save(output, 'BMP')
data = output.getvalue()[14:]
output.close()
import win32clipboard
win32clipboard.OpenClipboard()
win32clipboard.EmptyClipboard()
win32clipboard.SetClipboardData(win32clipboard.CF_DIB, data)
win32clipboard.CloseClipboard()
messagebox.showinfo("Success", "Image copied to clipboard")
except ImportError:
messagebox.showerror("Error", "pywin32 not installed. Install with: pip install pywin32")
except Exception as e:
messagebox.showerror("Error", f"Failed to copy:\n{str(e)}")
def _open_settings(self):
"""Open settings dialog"""
WatchPointSettingsDialog(self.root, self.settings)
def _close_window(self):
"""Close window properly"""
self.windows[self.display_idx]["running"] = False
try:
self.root.destroy()
except:
pass
class WatchPointSettingsDialog:
"""Settings dialog window"""
def __init__(self, parent, settings):
self.settings = settings
self.dialog = tk.Toplevel(parent)
self.dialog.title("Watch Point Settings")
self.dialog.geometry("450x650")
self.dialog.resizable(True, True)
self._create_ui()
def _create_ui(self):
"""Create settings UI"""
# Window Size section
size_frame = tk.LabelFrame(self.dialog, text="Default Window Size", padx=10, pady=10)
size_frame.pack(fill=tk.X, padx=10, pady=10)
current_mode = self.settings.get("window_size_mode", "fixed")
if current_mode in ["Half Vertical", "Half Horizontal", "Quarter"]:
initial_value = current_mode
else:
initial_value = f"{self.settings.get('window_width', 800)}x{self.settings.get('window_height', 600)}"
self.size_var = tk.StringVar(value=initial_value)
sizes = [
"800x600",
"1024x768",
"1280x720",
"1920x1080",
"Half Vertical",
"Half Horizontal",
"Quarter"
]
for size in sizes:
tk.Radiobutton(size_frame, text=size, variable=self.size_var, value=size).pack(anchor=tk.W)
# Window Position section
position_frame = tk.LabelFrame(self.dialog, text="Window Position", padx=10, pady=10)
position_frame.pack(fill=tk.X, padx=10, pady=10)
tk.Label(position_frame, text="X coordinate:").grid(row=0, column=0, sticky=tk.W, pady=5)
self.pos_x_var = tk.StringVar(value=str(self.settings.get("window_x", "")))
tk.Entry(position_frame, textvariable=self.pos_x_var, width=10).grid(row=0, column=1, sticky=tk.W, padx=5)
tk.Label(position_frame, text="(leave empty for auto)").grid(row=0, column=2, sticky=tk.W)
tk.Label(position_frame, text="Y coordinate:").grid(row=1, column=0, sticky=tk.W, pady=5)
self.pos_y_var = tk.StringVar(value=str(self.settings.get("window_y", "")))
tk.Entry(position_frame, textvariable=self.pos_y_var, width=10).grid(row=1, column=1, sticky=tk.W, padx=5)
tk.Label(position_frame, text="(leave empty for auto)").grid(row=1, column=2, sticky=tk.W)
# Save Format section
format_frame = tk.LabelFrame(self.dialog, text="Save Settings", padx=10, pady=10)
format_frame.pack(fill=tk.X, padx=10, pady=10)
tk.Label(format_frame, text="Default Format:").grid(row=0, column=0, sticky=tk.W)
self.format_var = tk.StringVar(value=self.settings.get("save_format", "png").upper())
format_menu = tk.OptionMenu(format_frame, self.format_var, "PNG", "JPEG", "WEBP")
format_menu.grid(row=0, column=1, sticky=tk.W, padx=5)
tk.Label(format_frame, text="JPEG Quality:").grid(row=1, column=0, sticky=tk.W, pady=5)
self.quality_var = tk.IntVar(value=self.settings.get("jpeg_quality", 90))
quality_scale = tk.Scale(format_frame, from_=10, to=100, orient=tk.HORIZONTAL,
variable=self.quality_var, length=200)
quality_scale.grid(row=1, column=1, sticky=tk.W, padx=5)
# UI Options
ui_frame = tk.LabelFrame(self.dialog, text="UI Options", padx=10, pady=10)
ui_frame.pack(fill=tk.X, padx=10, pady=10)
self.toolbar_var = tk.BooleanVar(value=self.settings.get("show_toolbar", True))
tk.Checkbutton(ui_frame, text="Show toolbar by default", variable=self.toolbar_var).pack(anchor=tk.W)
# Buttons at bottom
btn_frame = tk.Frame(self.dialog, pady=10)
btn_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=10)
tk.Button(btn_frame, text="Cancel", command=self.dialog.destroy, width=10,
bg='#666', fg='white').pack(side=tk.RIGHT, padx=5)
tk.Button(btn_frame, text="Save", command=self._save, width=10,
bg='#4a9eff', fg='white').pack(side=tk.RIGHT, padx=5)
def _save(self):
"""Save settings"""
size_value = self.size_var.get()
if size_value in ["Half Vertical", "Half Horizontal", "Quarter"]:
self.settings["window_size_mode"] = size_value
else:
try:
w, h = map(int, size_value.split('x'))
self.settings["window_width"] = w
self.settings["window_height"] = h
self.settings["window_size_mode"] = "fixed"
except:
pass
try:
x_str = self.pos_x_var.get().strip()
y_str = self.pos_y_var.get().strip()
if x_str and y_str:
self.settings["window_x"] = int(x_str)
self.settings["window_y"] = int(y_str)
else:
self.settings["window_x"] = None
self.settings["window_y"] = None
except ValueError:
messagebox.showwarning("Invalid Input", "X and Y coordinates must be valid numbers")
return
self.settings["save_format"] = self.format_var.get().lower()
self.settings["jpeg_quality"] = self.quality_var.get()
self.settings["show_toolbar"] = self.toolbar_var.get()
settings_file = os.path.join(os.path.dirname(__file__), "watchpoint_settings.json")
try:
with open(settings_file, 'w') as f:
json.dump(self.settings, f, indent=2)
messagebox.showinfo("Success", "Settings saved!")
self.dialog.destroy()
except Exception as e:
messagebox.showerror("Error", f"Could not save settings:\n{str(e)}")
# Node registration
NODE_CLASS_MAPPINGS = {
"WatchPoint": WatchPoint
}
NODE_DISPLAY_NAME_MAPPINGS = {
"WatchPoint": "👁️ Watch Point"
}
+10
View File
@@ -0,0 +1,10 @@
{
"window_width": 800,
"window_height": 600,
"window_x": 0,
"window_y": 0,
"show_toolbar": true,
"save_format": "png",
"jpeg_quality": 90,
"window_size_mode": "Half Vertical"
}