Initial commit: ComfyUI WatchPoint node
This commit is contained in:
@@ -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
@@ -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.
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user