commit 2daaa02c3cd543b2561e64ed66d03e4f9c22a771 Author: Alex Bautista Date: Fri Jan 2 21:34:23 2026 -0600 Initial commit: ComfyUI WatchPoint node diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae74bf5 --- /dev/null +++ b/README.md @@ -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! \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e9d0a08 --- /dev/null +++ b/__init__.py @@ -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") diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..a42fefc Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/__pycache__/watch_point.cpython-312.pyc b/__pycache__/watch_point.cpython-312.pyc new file mode 100644 index 0000000..6c0aff0 Binary files /dev/null and b/__pycache__/watch_point.cpython-312.pyc differ diff --git a/js/watchpoint-floating.js b/js/watchpoint-floating.js new file mode 100644 index 0000000..9c5c3e6 --- /dev/null +++ b/js/watchpoint-floating.js @@ -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"); \ No newline at end of file diff --git a/watch_point.py b/watch_point.py new file mode 100644 index 0000000..2b3f032 --- /dev/null +++ b/watch_point.py @@ -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("", self._on_mouse_down) + self.canvas.bind("", self._on_mouse_drag) + self.canvas.bind("", self._on_mouse_up) + self.canvas.bind("", self._on_mouse_wheel) + self.canvas.bind("", self._show_context_menu) + + # Keyboard events + self.root.bind("", lambda e: self._reset_zoom()) + self.root.bind("", lambda e: self._reset_zoom()) + self.root.bind("", lambda e: self._toggle_toolbar()) + self.root.bind("", lambda e: self._toggle_toolbar()) + self.root.bind("", lambda e: self._zoom_1to1()) + self.root.bind("", 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" +} \ No newline at end of file diff --git a/watchpoint_settings.json b/watchpoint_settings.json new file mode 100644 index 0000000..5f03380 --- /dev/null +++ b/watchpoint_settings.json @@ -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" +} \ No newline at end of file