Files
gmorks-ComfyUI-WatchPoint/watch_point.py
T

756 lines
28 KiB
Python

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