Compare commits

...
Author SHA1 Message Date
Vito Sansevero 271cd020c1 merge: resolve conflicts with main branch model management improvements 2025-08-01 16:35:39 -07:00
Vito Sansevero e34807855a feat: add Display Text node with smart formatting and enhance Gemini with model refresh
Display Text improvements:
- Add new DisplayText node with intelligent prompt detection and split view
- Implement text wrapping that reflows when node is resized
- Add scrollable content with mouse wheel support and visual indicators
- Include always-visible copy button with visual feedback for easy prompt copying
- Auto-detect SDXL-style prompts and display in side-by-side format
- Strip prompt labels when copying for direct use in workflows

Gemini model refresh functionality:
- Add refresh button to fetch latest available Gemini models dynamically
- Implement model caching system with persistent storage
- Support for Gemini 2.0 and 2.5 models with automatic detection
- Enhanced SDXL prompt template with improved layered structure
- Better error handling and status feedback for model operations

Documentation and version updates:
- Update README with comprehensive Display Text and Gemini feature descriptions
- Add detailed usage examples and workflow patterns
- Bump version to 1.0.9 in pyproject.toml
- Update stats to reflect 8 total nodes and new AI integration features
2025-08-01 14:57:56 -07:00
Vito d32e18f844 Merge pull request #17 from ComfyAssets/feature/gemini-dynamic-models
Feature/gemini dynamic models
2025-08-01 13:36:04 -07:00
10 changed files with 872 additions and 35 deletions
+48 -9
View File
@@ -104,6 +104,24 @@ Enhanced image saving with format selection, quality control, and floating popup
- **Smart UI**: Auto-hide/show, minimize/maximize, roll-up functionality
- **Popup Toggle**: Enable/disable popup viewer per save operation
#### 📋 Display Text
Advanced text display node with intelligent formatting and enhanced user interaction.
- **Smart Prompt Detection**: Automatically detects positive/negative prompt pairs and displays in split view
- **Text Wrapping**: Proper word wrapping that reflows when node is resized
- **Scrollable Content**: Mouse wheel scrolling for long texts with visual scroll indicators
- **Copy Functionality**: Always-visible copy button with visual feedback
- **Split View Mode**: Automatic detection and formatting of SDXL-style prompts
- **Responsive Design**: Content adapts to node resizing with proper text reflow
- **Clean Formatting**: Strips prompt labels when copying for direct use
**Use Cases:**
- Display generated prompts with proper formatting
- Compare positive and negative prompts side-by-side
- Copy prompts without manual label removal
- View long text content with proper wrapping
- Debug prompt generation workflows
#### 🤖 Gemini Prompt Engineer
AI-powered image analysis using Google's Gemini to generate optimized prompts for various models.
@@ -114,6 +132,9 @@ AI-powered image analysis using Google's Gemini to generate optimized prompts fo
- **Flexible API Key Management**: Environment variable, config file, or direct input
- **Visual Status Feedback**: Real-time processing indicators and error states
- **Help Integration**: Built-in setup guide and documentation
- **Dynamic Model Refresh**: Fetch latest Gemini models with refresh button
- **Model Caching**: Persistent model list storage for offline access
- **Enhanced SDXL Prompts**: Improved formatting with layered structure and quality boosters
**Use Cases:**
- Reverse-engineer prompts from reference images
@@ -121,6 +142,7 @@ AI-powered image analysis using Google's Gemini to generate optimized prompts fo
- Generate consistent style descriptions across workflows
- Create detailed scene breakdowns for complex compositions
- Analyze and replicate lighting/mood from existing artwork
- Access latest Gemini models including 2.0 and 2.5 versions
### 💾 Kiko Save Image Features
@@ -245,18 +267,32 @@ Generate Image → Kiko Save Image → Floating Popup Viewer
**Features:** Click images to open in new tabs, download individual files, batch selection
**Advantages:** Immediate preview without file explorer, multi-format comparison, advanced quality controls
### Display Text Example
```
Gemini Prompt → Display Text → Copy to Clipboard
📋 SDXL prompt ↘ auto-split ↘ [📋 Positive] [📋 Negative]
view → formatted display
```
**Input:** Text with "Positive prompt:" and "Negative prompt:" sections
**Output:** Split view with individual copy buttons
**Features:** Text wrapping, scrolling, responsive resizing
**Smart Detection:** Automatically formats SDXL-style prompts
### Gemini Prompt Engineer Example
```
Load Image → Gemini Prompt → Text Generation Model
🖼️ reference ↘ type: FLUX ↘ "majestic landscape..."
[API key] → FLUX model
Load Image → Gemini Prompt → Display Text → Text Generation Model
🖼️ reference ↘ type: SDXL ↘ split view ↘ "detailed portrait..."
[Refresh Models] → SDXL model
```
**Input:** Reference image for style analysis
**Prompt Type:** FLUX (detailed artistic prompts)
**Output:** Optimized prompt with style, lighting, composition details
**Prompt Type:** SDXL (positive/negative pairs with layered structure)
**Model Selection:** Dynamic list with latest Gemini models (2.0, 2.5)
**Output:** Optimized prompts following community best practices
**API:** Requires Gemini API key (free tier available)
**Use Case:** Recreate similar style/mood from reference images
**Refresh:** Click button to fetch latest available models
### Common Workflows
@@ -300,6 +336,8 @@ Load Image → Gemini Prompt → Text Generation Model
| **Sampler Combo** | Unified sampling configuration with smart recommendations | ✅ Complete | [Docs](examples/documentation/sampler_combo.md) |
| **Empty Latent Batch** | Create empty latent batches with preset support | ✅ Complete | [Docs](examples/documentation/empty_latent_batch.md) |
| **Kiko Save Image** | Enhanced image saving with popup viewer and multi-format support | ✅ Complete | [Docs](examples/documentation/kiko_save_image.md) |
| **Display Text** | Advanced text display with smart prompt detection and split view | ✅ Complete | [Docs](examples/documentation/display_text.md) |
| **Gemini Prompt Engineer** | AI-powered image analysis with dynamic model refresh | ✅ Complete | [Docs](examples/documentation/gemini_prompt.md) |
| **Batch Image Processor** | Process multiple images with consistent settings | 🚧 Planned | Coming Soon |
| **Advanced Prompt Utilities** | Enhanced prompt manipulation and generation | 🚧 Planned | Coming Soon |
@@ -593,14 +631,15 @@ MIT License - see [LICENSE](LICENSE) file for details.
## 📈 Stats
- **Nodes**: 6 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo, Empty Latent Batch, Kiko Save Image)
- **Nodes**: 8 (Resolution Calculator, Width Height Selector, Seed History, Sampler Combo, Empty Latent Batch, Kiko Save Image, Display Text, Gemini Prompt Engineer)
- **Format Support**: 3 (PNG, JPEG, WebP with advanced controls)
- **Presets**: 26 curated resolution presets
- **Interactive Features**: 4 (Width/Height Swap Button, Seed History UI, Empty Latent Batch Swap Button, Kiko Save Image Popup Viewer)
- **Interactive Features**: 6 (Width/Height Swap Button, Seed History UI, Empty Latent Batch Swap Button, Kiko Save Image Popup Viewer, Display Text Split View, Gemini Model Refresh)
- **AI Integration**: Gemini API with 40+ model support
- **Test Coverage**: 100% (200+ comprehensive tests)
- **Python Version**: 3.8+
- **ComfyUI Compatibility**: Latest
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow)
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow, google-generativeai for Gemini)
---
+3
View File
@@ -12,6 +12,7 @@ from .tools.kiko_save_image import KikoSaveImageNode
from .tools.image_to_multiple_of import ImageToMultipleOfNode
from .tools.gemini_prompt import GeminiPromptNode
from .tools.display_any import DisplayAnyNode
from .tools.display_text import DisplayTextNode
# ComfyUI node registration mappings
NODE_CLASS_MAPPINGS = {
@@ -25,6 +26,7 @@ NODE_CLASS_MAPPINGS = {
"ImageToMultipleOf": ImageToMultipleOfNode,
"GeminiPrompt": GeminiPromptNode,
"DisplayAny": DisplayAnyNode,
"DisplayText": DisplayTextNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -38,6 +40,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"ImageToMultipleOf": "Image to Multiple of",
"GeminiPrompt": "Gemini Prompt Engineer",
"DisplayAny": "Display Any",
"DisplayText": "Display Text",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+5
View File
@@ -0,0 +1,5 @@
"""Display Text tool for ComfyUI."""
from .node import DisplayTextNode, NODE_DISPLAY_NAME
__all__ = ["DisplayTextNode", "NODE_DISPLAY_NAME"]
+48
View File
@@ -0,0 +1,48 @@
"""Display Text node implementation."""
from ...base import ComfyAssetsBaseNode
class DisplayTextNode(ComfyAssetsBaseNode):
"""Displays text in the ComfyUI interface with copy-to-clipboard functionality."""
@classmethod
def INPUT_TYPES(cls):
"""Define input types for the node."""
return {
"required": {
"text": ("STRING", {"forceInput": True}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text",)
OUTPUT_NODE = True
FUNCTION = "display_text"
CATEGORY = "ComfyAssets"
DESCRIPTION = """
Displays text in the UI with a copy-to-clipboard feature.
Features:
- Shows text content in a readable format
- Copy button appears on hover
- Passes text through for chaining
"""
def display_text(self, text):
"""Display the text and pass it through.
Args:
text: Input text to display
Returns:
Tuple containing the text
"""
# The actual display happens in the frontend
# We just pass the text through
return {"ui": {"text": [text]}, "result": (text,)}
# Node display name
NODE_DISPLAY_NAME = "Display Text"
@@ -0,0 +1,89 @@
{
"models": [
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.5-pro-preview-03-25",
"gemini-2.5-flash-preview-05-20",
"gemini-2.5-pro-preview-05-06",
"gemini-2.5-pro-preview-06-05",
"gemini-2.5-flash-lite-preview-06-17",
"gemini-2.0-flash",
"gemini-2.0-flash-001",
"gemini-2.0-flash-lite-001",
"gemini-2.0-flash-lite",
"gemini-2.5-flash-preview-tts",
"gemini-2.5-pro-preview-tts",
"gemini-2.0-flash-preview-image-generation",
"gemini-2.0-flash-exp",
"gemini-2.0-flash-exp-image-generation",
"gemini-2.0-flash-lite-preview-02-05",
"gemini-2.0-flash-lite-preview",
"gemini-2.0-pro-exp",
"gemini-2.0-pro-exp-02-05",
"learnlm-2.0-flash-experimental",
"gemini-1.5-pro-latest",
"gemini-1.5-pro-002",
"gemini-1.5-pro",
"gemini-1.5-flash-latest",
"gemini-1.5-flash",
"gemini-1.5-flash-002",
"gemini-1.5-flash-8b",
"gemini-1.5-flash-8b-001",
"gemini-1.5-flash-8b-latest",
"gemini-2.0-flash-thinking-exp-01-21",
"gemini-2.0-flash-thinking-exp",
"gemini-2.0-flash-thinking-exp-1219",
"gemma-3-1b-it",
"gemma-3-4b-it",
"gemma-3-12b-it",
"gemma-3-27b-it",
"gemma-3n-e4b-it",
"gemma-3n-e2b-it",
"gemini-exp-1206"
],
"descriptions": {
"gemini-1.5-pro-latest": "Gemini 1.5 Pro Latest",
"gemini-1.5-pro-002": "Gemini 1.5 Pro 002",
"gemini-1.5-pro": "Gemini 1.5 Pro",
"gemini-1.5-flash-latest": "Gemini 1.5 Flash Latest",
"gemini-1.5-flash": "Gemini 1.5 Flash",
"gemini-1.5-flash-002": "Gemini 1.5 Flash 002",
"gemini-1.5-flash-8b": "Gemini 1.5 Flash-8B",
"gemini-1.5-flash-8b-001": "Gemini 1.5 Flash-8B 001",
"gemini-1.5-flash-8b-latest": "Gemini 1.5 Flash-8B Latest",
"gemini-2.5-pro-preview-03-25": "Gemini 2.5 Pro Preview 03-25",
"gemini-2.5-flash-preview-05-20": "Gemini 2.5 Flash Preview 05-20",
"gemini-2.5-flash": "Gemini 2.5 Flash",
"gemini-2.5-flash-lite-preview-06-17": "Gemini 2.5 Flash-Lite Preview 06-17",
"gemini-2.5-pro-preview-05-06": "Gemini 2.5 Pro Preview 05-06",
"gemini-2.5-pro-preview-06-05": "Gemini 2.5 Pro Preview",
"gemini-2.5-pro": "Gemini 2.5 Pro",
"gemini-2.0-flash-exp": "Gemini 2.0 Flash Experimental",
"gemini-2.0-flash": "Gemini 2.0 Flash",
"gemini-2.0-flash-001": "Gemini 2.0 Flash 001",
"gemini-2.0-flash-exp-image-generation": "Gemini 2.0 Flash (Image Generation) Experimental",
"gemini-2.0-flash-lite-001": "Gemini 2.0 Flash-Lite 001",
"gemini-2.0-flash-lite": "Gemini 2.0 Flash-Lite",
"gemini-2.0-flash-preview-image-generation": "Gemini 2.0 Flash Preview Image Generation",
"gemini-2.0-flash-lite-preview-02-05": "Gemini 2.0 Flash-Lite Preview 02-05",
"gemini-2.0-flash-lite-preview": "Gemini 2.0 Flash-Lite Preview",
"gemini-2.0-pro-exp": "Gemini 2.0 Pro Experimental",
"gemini-2.0-pro-exp-02-05": "Gemini 2.0 Pro Experimental 02-05",
"gemini-exp-1206": "Gemini Experimental 1206",
"gemini-2.0-flash-thinking-exp-01-21": "Gemini 2.5 Flash Preview 05-20",
"gemini-2.0-flash-thinking-exp": "Gemini 2.5 Flash Preview 05-20",
"gemini-2.0-flash-thinking-exp-1219": "Gemini 2.5 Flash Preview 05-20",
"gemini-2.5-flash-preview-tts": "Gemini 2.5 Flash Preview TTS",
"gemini-2.5-pro-preview-tts": "Gemini 2.5 Pro Preview TTS",
"learnlm-2.0-flash-experimental": "LearnLM 2.0 Flash Experimental",
"gemma-3-1b-it": "Gemma 3 1B",
"gemma-3-4b-it": "Gemma 3 4B",
"gemma-3-12b-it": "Gemma 3 12B",
"gemma-3-27b-it": "Gemma 3 27B",
"gemma-3n-e4b-it": "Gemma 3n E4B",
"gemma-3n-e2b-it": "Gemma 3n E2B",
"gemini-2.5-flash-lite": "Gemini 2.5 Flash-Lite"
},
"timestamp": 1754078815.7280848
}
+18 -6
View File
@@ -41,6 +41,7 @@ class GeminiPromptNode(ComfyAssetsBaseNode):
"placeholder": "Optional: Enter custom system prompt instead of using templates",
},
),
"refresh_models": ("BOOLEAN", {"default": False, "label_on": "Refresh", "label_off": "Skip"}),
},
}
@@ -62,7 +63,7 @@ Requires Gemini API key (set GEMINI_API_KEY env var or provide in node).
Install: pip install google-generativeai
"""
def generate_prompt(self, image, prompt_type, model, api_key="", custom_prompt=""):
def generate_prompt(self, image, prompt_type, model, api_key="", custom_prompt="", refresh_models=False):
"""Generate prompt from image using Gemini.
Args:
@@ -71,10 +72,21 @@ Install: pip install google-generativeai
model: Gemini model to use
api_key: Optional API key
custom_prompt: Optional custom system prompt
refresh_models: Whether to refresh the model list
Returns:
Tuple of (prompt, negative_prompt)
"""
# Refresh models if requested
if refresh_models and api_key:
try:
from .models import clear_cache
# Clear cache to force refresh on next node creation
clear_cache()
print("Model cache cleared. Please recreate the node to see updated models.")
except Exception as e:
print(f"Failed to clear model cache: {e}")
# Validate prompt type
if not validate_prompt_type(prompt_type):
raise ValueError(f"Invalid prompt type: {prompt_type}")
@@ -119,10 +131,10 @@ Install: pip install google-generativeai
negative_prompt = ""
for line in lines:
if line.startswith("Positive:"):
positive_prompt = line.replace("Positive:", "").strip()
elif line.startswith("Negative:"):
negative_prompt = line.replace("Negative:", "").strip()
if line.lower().startswith("positive:"):
positive_prompt = line.replace("Positive:", "").replace("positive:", "").strip()
elif line.lower().startswith("negative:"):
negative_prompt = line.replace("Negative:", "").replace("negative:", "").strip()
# If format not found, assume entire response is positive prompt
if not positive_prompt:
@@ -136,4 +148,4 @@ Install: pip install google-generativeai
# Node display name
NODE_DISPLAY_NAME = "Gemini Prompt Engineer"
NODE_DISPLAY_NAME = "Gemini Prompt Engineer"
+37 -19
View File
@@ -20,29 +20,47 @@ IMPORTANT: Return ONLY the prompt text. No analysis, headers, or additional comm
Example of correct output:
A close-up portrait of a middle-aged woman with curly red hair and green eyes, wearing a blue silk blouse. She has a warm smile and freckles across her cheeks. The lighting is soft and natural, coming from a window to her left, creating gentle shadows that accentuate her features. The background is softly blurred, showing hints of a cozy bookshelf. The overall mood is warm and inviting, captured in a photorealistic style with shallow depth of field."""
SDXL_PROMPT = """You are an expert SDXL prompt engineer. Analyze the image and generate ONLY the positive and negative prompts for SDXL - no explanations or analysis.
SDXL_PROMPT = """You are an expert prompt engineer specializing in SDXL (Stable Diffusion XL). Your task is to generate high-quality positive and negative prompts that conform to SDXL prompt formatting standards.
SDXL works best with natural language descriptions but also supports comma-separated keywords. Keep prompts concise but descriptive.
Your expertise includes:
- Leveraging community-tested techniques (ComfyUI, A1111, InvokeAI)
- Applying photographic theory for realism, composition, lighting
- Following Civitai trend standards and style best practices
- Mastering Pony Diffusion XL formatting for stylized and anime content
Return your response in EXACTLY this format:
Positive: [your positive prompt here]
Negative: [your negative prompt here]
Structure prompts in this layered, modular format:
[Main Subject], [Pose & Camera], [Lighting & Environment], [Style & Details], [Boost Terms], [Style References]
Guidelines for Positive prompt:
- Start with the main subject and medium (e.g., "photograph of", "digital art of")
- Use natural language or keywords separated by commas
- Include style descriptors (photographic, cinematic, fantasy art, etc.)
- Add quality markers like "8K", "highly detailed", "professional"
- Use (parentheses:1.1) sparingly for slight emphasis (max 1.4)
- Keep it clear and specific but not overly long
For SDXL specifically:
- Use quality boosters: 8k, RAW photo, masterpiece, ultra detailed, cinematic lighting
- Prioritize realism and artistry
- Excellent for portraits, landscapes, or cinematic scenes
Guidelines for Negative prompt:
- Keep it simple and minimal
- Common negatives: ugly, blurry, low quality, distorted, deformed
- Only add specifics you want to avoid (e.g., "cartoon" for photorealistic)
- Don't overload with negative prompts - SDXL needs fewer than SD1.5
Instructions:
IMPORTANT: Return ONLY the two lines starting with "Positive:" and "Negative:". No other text."""
Only reply with two fields:
Positive prompt: (Your positive prompt here)
Negative prompt: (Your negative prompt here)
Do not include any commentary or explanation.
Use concise, highly descriptive language that maximizes visual richness.
Follow SDXL prompt conventions: prioritize subject clarity, camera perspective, lighting, mood, style tags, and composition.
Keep total token length efficient (ideally under 250 tokens).
Avoid redundancy and generic filler words.
Focus on crafting super high-quality prompts for stunning visual output.
Example Input:
A futuristic cyberpunk samurai standing on a neon-lit rooftop in the rain.
Example Output:
Positive prompt: cyberpunk samurai, neon-lit rooftop, dramatic rain, glowing katana, futuristic cityscape, night scene, cinematic lighting, intense expression, sleek cyber armor, atmospheric depth, ultra-detailed, masterpiece, 8k, sharp focus, trending on artstation
Negative prompt: blurry, low quality, poorly drawn, extra limbs, bad anatomy, deformed hands, text, watermark, jpeg artifacts, duplicate, cropped, out of frame
"""
DANBOORU_PROMPT = """You are a Danbooru tagging expert specializing in anime-style image tagging. Analyze the image and generate ONLY Danbooru-style tags - no explanations or analysis.
@@ -107,4 +125,4 @@ DEFAULT_GEMINI_MODELS = [
"gemini-2.0-flash",
"gemini-1.5-flash",
"gemini-1.5-pro",
]
]
+1 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "kikotools"
description = "Simple tools for ComfyUI"
version = "1.0.8"
version = "1.0.9"
license = {text = "MIT"}
dependencies = []
+595
View File
@@ -0,0 +1,595 @@
import { app } from "../../../scripts/app.js";
import { ComfyWidgets } from "../../../scripts/widgets.js";
app.registerExtension({
name: "ComfyAssets.DisplayText",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "DisplayText") {
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function(message) {
onExecuted?.apply(this, arguments);
if (message?.text) {
// Create or update the text widget
this.updateTextDisplay(message.text[0]);
}
};
nodeType.prototype.updateTextDisplay = function(text) {
// Remove existing text widget if any
const existingWidget = this.widgets?.find(w => w.name === "displayed_text");
if (existingWidget) {
const index = this.widgets.indexOf(existingWidget);
this.widgets.splice(index, 1);
}
// Parse the text to detect positive/negative prompt format
function parsePrompts(text) {
const posMatch = text.match(/Positive prompt:\s*([\s\S]*?)(?=Negative prompt:|$)/i);
const negMatch = text.match(/Negative prompt:\s*([\s\S]*?)(?=\*\*|$)/i);
if (posMatch && negMatch) {
// Extract just the prompt content, stopping at the first ** marker
let positiveText = posMatch[1].trim();
let negativeText = negMatch[1].trim();
// Remove any trailing ** markers and everything after them
const posEndIndex = positiveText.indexOf('**');
if (posEndIndex > 0) {
positiveText = positiveText.substring(0, posEndIndex).trim();
}
const negEndIndex = negativeText.indexOf('**');
if (negEndIndex > 0) {
negativeText = negativeText.substring(0, negEndIndex).trim();
}
return {
type: 'prompts',
positive: positiveText,
negative: negativeText
};
}
return {
type: 'text',
content: text
};
}
const parsedContent = parsePrompts(text);
// Create custom widget for text display
const widget = {
type: "custom_text_display",
name: "displayed_text",
size: [this.size[0] - 20, this.size[1] - 60], // Adjust for node chrome
parsedContent: parsedContent,
scrollOffset: 0,
posScrollOffset: 0,
negScrollOffset: 0,
draw: function(ctx, node, widget_width, y, H) {
const margin = 10;
const padding = 10;
const lineHeight = 20;
const buttonHeight = 30;
const buttonWidth = 90;
// Use the actual widget height from node size
const availableHeight = node.size[1] - 60; // Account for node header and margins
this.size[1] = Math.max(100, availableHeight);
// Calculate available width for text
const availableWidth = widget_width - margin * 2 - padding * 2;
// Word wrap function with better performance
function wrapText(text, maxWidth) {
const words = text.split(' ');
const lines = [];
let currentLine = '';
ctx.font = "14px monospace";
for (const word of words) {
const testLine = currentLine + (currentLine ? ' ' : '') + word;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = word;
} else {
currentLine = testLine;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines.length > 0 ? lines : [''];
}
// Draw background
ctx.fillStyle = "#2a2a2a";
ctx.fillRect(margin, y, widget_width - margin * 2, this.size[1]);
// Draw border
ctx.strokeStyle = "#444";
ctx.strokeRect(margin, y, widget_width - margin * 2, this.size[1]);
if (this.parsedContent.type === 'prompts') {
// Simple split view for positive/negative prompts
const headerHeight = 25;
const buttonAreaHeight = buttonHeight + padding;
const totalTextHeight = this.size[1] - buttonAreaHeight;
const halfHeight = totalTextHeight / 2;
// Draw positive prompt header
ctx.fillStyle = "#3a3a3a";
ctx.fillRect(margin + 1, y + 1, widget_width - margin * 2 - 2, headerHeight);
ctx.fillStyle = "#8f8";
ctx.font = "12px sans-serif";
ctx.fillText("✓ Positive Prompt", margin + padding, y + headerHeight - 7);
// Positive prompt text area
const posTextY = y + headerHeight;
const posTextHeight = halfHeight - headerHeight;
ctx.fillStyle = "#1e1e1e";
ctx.fillRect(margin + 1, posTextY, widget_width - margin * 2 - 2, posTextHeight);
// Draw separator
const separatorY = y + halfHeight;
ctx.strokeStyle = "#555";
ctx.beginPath();
ctx.moveTo(margin, separatorY);
ctx.lineTo(widget_width - margin, separatorY);
ctx.stroke();
// Draw negative prompt header
ctx.fillStyle = "#3a3a3a";
ctx.fillRect(margin + 1, separatorY + 1, widget_width - margin * 2 - 2, headerHeight);
ctx.fillStyle = "#f88";
ctx.font = "12px sans-serif";
ctx.fillText("✗ Negative Prompt", margin + padding, separatorY + headerHeight - 7);
// Negative prompt text area
const negTextY = separatorY + headerHeight;
const negTextHeight = halfHeight - headerHeight;
ctx.fillStyle = "#1e1e1e";
ctx.fillRect(margin + 1, negTextY, widget_width - margin * 2 - 2, negTextHeight);
// Draw text for both sections
ctx.font = "14px monospace";
ctx.fillStyle = "#ddd";
// Wrap text for both prompts
const posLines = [];
const posParagraphs = this.parsedContent.positive.split('\n');
for (const para of posParagraphs) {
if (para.trim() === '') {
posLines.push('');
} else {
posLines.push(...wrapText(para, availableWidth - 10));
}
}
const negLines = [];
const negParagraphs = this.parsedContent.negative.split('\n');
for (const para of negParagraphs) {
if (para.trim() === '') {
negLines.push('');
} else {
negLines.push(...wrapText(para, availableWidth - 10));
}
}
// Draw positive prompt text with clipping
ctx.save();
ctx.beginPath();
ctx.rect(margin + padding, posTextY + padding, availableWidth - 10, posTextHeight - padding * 2);
ctx.clip();
let currentY = posTextY + padding + lineHeight - 5;
const posVisibleLines = Math.floor((posTextHeight - padding * 2) / lineHeight);
const posStartLine = Math.floor(this.posScrollOffset);
const posEndLine = Math.min(posStartLine + posVisibleLines, posLines.length);
for (let i = posStartLine; i < posEndLine; i++) {
ctx.fillText(posLines[i], margin + padding, currentY);
currentY += lineHeight;
}
ctx.restore();
// Draw positive scroll indicator if needed
if (posLines.length > posVisibleLines) {
const scrollBarWidth = 6;
const scrollBarX = widget_width - margin - scrollBarWidth - 2;
const scrollBarHeight = posTextHeight - padding * 2;
const maxScroll = posLines.length - posVisibleLines;
const scrollRatio = this.posScrollOffset / maxScroll;
const thumbHeight = Math.max(20, (posVisibleLines / posLines.length) * scrollBarHeight);
const thumbY = posTextY + padding + scrollRatio * (scrollBarHeight - thumbHeight);
ctx.fillStyle = "#333";
ctx.fillRect(scrollBarX, posTextY + padding, scrollBarWidth, scrollBarHeight);
ctx.fillStyle = "#666";
ctx.fillRect(scrollBarX, thumbY, scrollBarWidth, thumbHeight);
}
// Draw negative prompt text with clipping
ctx.save();
ctx.beginPath();
ctx.rect(margin + padding, negTextY + padding, availableWidth - 10, negTextHeight - padding * 2);
ctx.clip();
ctx.fillStyle = "#ddd";
currentY = negTextY + padding + lineHeight - 5;
const negVisibleLines = Math.floor((negTextHeight - padding * 2) / lineHeight);
const negStartLine = Math.floor(this.negScrollOffset);
const negEndLine = Math.min(negStartLine + negVisibleLines, negLines.length);
for (let i = negStartLine; i < negEndLine; i++) {
ctx.fillText(negLines[i], margin + padding, currentY);
currentY += lineHeight;
}
ctx.restore();
// Draw negative scroll indicator if needed
if (negLines.length > negVisibleLines) {
const scrollBarWidth = 6;
const scrollBarX = widget_width - margin - scrollBarWidth - 2;
const scrollBarHeight = negTextHeight - padding * 2;
const maxScroll = negLines.length - negVisibleLines;
const scrollRatio = this.negScrollOffset / maxScroll;
const thumbHeight = Math.max(20, (negVisibleLines / negLines.length) * scrollBarHeight);
const thumbY = negTextY + padding + scrollRatio * (scrollBarHeight - thumbHeight);
ctx.fillStyle = "#333";
ctx.fillRect(scrollBarX, negTextY + padding, scrollBarWidth, scrollBarHeight);
ctx.fillStyle = "#666";
ctx.fillRect(scrollBarX, thumbY, scrollBarWidth, thumbHeight);
}
// Draw copy buttons
const buttonY = y + this.size[1] - buttonHeight - padding / 2;
const halfWidth = (widget_width - margin * 2) / 2;
// Positive copy button
const posButtonX = margin + halfWidth / 2 - buttonWidth / 2;
ctx.fillStyle = this.posCopyHovered ? "#5a5a5a" : "#4a4a4a";
ctx.fillRect(posButtonX, buttonY, buttonWidth, buttonHeight);
ctx.strokeStyle = "#666";
ctx.strokeRect(posButtonX, buttonY, buttonWidth, buttonHeight);
ctx.fillStyle = "#fff";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(this.posCopySuccess ? "✓ Copied!" : "📋 Positive", posButtonX + buttonWidth/2, buttonY + buttonHeight/2);
// Negative copy button
const negButtonX = margin + halfWidth + halfWidth / 2 - buttonWidth / 2;
ctx.fillStyle = this.negCopyHovered ? "#5a5a5a" : "#4a4a4a";
ctx.fillRect(negButtonX, buttonY, buttonWidth, buttonHeight);
ctx.strokeStyle = "#666";
ctx.strokeRect(negButtonX, buttonY, buttonWidth, buttonHeight);
// Ensure text color and alignment are set
ctx.fillStyle = "#fff";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(this.negCopySuccess ? "✓ Copied!" : "📋 Negative", negButtonX + buttonWidth/2, buttonY + buttonHeight/2);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
} else {
// Regular text display
const textAreaHeight = this.size[1] - buttonHeight - padding;
// Draw text area background
ctx.fillStyle = "#1e1e1e";
ctx.fillRect(margin + 1, y + 1, widget_width - margin * 2 - 2, textAreaHeight);
// Process text
ctx.font = "14px monospace";
const paragraphs = this.parsedContent.content.split('\n');
const allLines = [];
for (const paragraph of paragraphs) {
if (paragraph.trim() === '') {
allLines.push('');
} else {
const wrappedLines = wrapText(paragraph, availableWidth);
allLines.push(...wrappedLines);
}
}
// Draw text with clipping
ctx.save();
ctx.beginPath();
ctx.rect(margin + padding, y + padding, availableWidth, textAreaHeight - padding * 2);
ctx.clip();
const visibleLines = Math.floor((textAreaHeight - padding * 2) / lineHeight);
const maxScroll = Math.max(0, allLines.length - visibleLines);
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll));
ctx.fillStyle = "#ddd";
let currentY = y + padding + lineHeight - 5 - (this.scrollOffset * lineHeight);
for (let i = 0; i < allLines.length; i++) {
if (currentY > y && currentY < y + textAreaHeight) {
ctx.fillText(allLines[i], margin + padding, currentY);
}
currentY += lineHeight;
}
ctx.restore();
// Draw scroll indicator if needed
if (allLines.length > visibleLines) {
const scrollBarWidth = 6;
const scrollBarX = widget_width - margin - scrollBarWidth - 2;
const scrollBarHeight = textAreaHeight - 4;
const thumbHeight = Math.max(20, (visibleLines / allLines.length) * scrollBarHeight);
const thumbY = y + 2 + (this.scrollOffset / maxScroll) * (scrollBarHeight - thumbHeight);
ctx.fillStyle = "#333";
ctx.fillRect(scrollBarX, y + 2, scrollBarWidth, scrollBarHeight);
ctx.fillStyle = "#666";
ctx.fillRect(scrollBarX, thumbY, scrollBarWidth, thumbHeight);
}
// Draw copy button
const buttonX = widget_width - margin - buttonWidth - padding;
const buttonY = y + textAreaHeight + padding / 2;
ctx.fillStyle = this.copyButtonHovered ? "#5a5a5a" : "#4a4a4a";
ctx.fillRect(buttonX, buttonY, buttonWidth, buttonHeight);
ctx.strokeStyle = "#666";
ctx.strokeRect(buttonX, buttonY, buttonWidth, buttonHeight);
ctx.fillStyle = "#fff";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(this.copySuccess ? "✓ Copied!" : "📋 Copy", buttonX + buttonWidth/2, buttonY + buttonHeight/2);
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
}
return this.size[1];
},
mouse: function(event, pos, node) {
const margin = 10;
const padding = 10;
const buttonWidth = 90;
const buttonHeight = 30;
const lineHeight = 20;
// Check if mouse is over the widget
const isOver = pos[1] > this.last_y && pos[1] < this.last_y + this.size[1];
if (!isOver) return false;
if (this.parsedContent.type === 'prompts') {
// Handle split view
const headerHeight = 25;
const buttonAreaHeight = buttonHeight + padding;
const totalTextHeight = this.size[1] - buttonAreaHeight;
const halfHeight = totalTextHeight / 2;
const posTextY = this.last_y + headerHeight;
const posTextHeight = halfHeight - headerHeight;
const negTextY = this.last_y + halfHeight + headerHeight;
const negTextHeight = halfHeight - headerHeight;
const buttonY = this.last_y + this.size[1] - buttonHeight - padding / 2;
const halfWidth = (node.size[0] - margin * 2) / 2;
// Check which section for scrolling
const inPosSection = pos[1] > posTextY && pos[1] < posTextY + posTextHeight;
const inNegSection = pos[1] > negTextY && pos[1] < negTextY + negTextHeight;
// Handle scrolling
if (event.type === "wheel") {
if (inPosSection) {
const delta = event.deltaY > 0 ? 1 : -1;
this.posScrollOffset = (this.posScrollOffset || 0) + delta;
// Calculate max scroll
const visibleLines = Math.floor((posTextHeight - padding * 2) / lineHeight);
const totalLines = this.parsedContent.positive.split('\n').length * 2; // Estimate
const maxScroll = Math.max(0, totalLines - visibleLines);
this.posScrollOffset = Math.max(0, Math.min(this.posScrollOffset, maxScroll));
node.setDirtyCanvas(true);
return true;
} else if (inNegSection) {
const delta = event.deltaY > 0 ? 1 : -1;
this.negScrollOffset = (this.negScrollOffset || 0) + delta;
// Calculate max scroll
const visibleLines = Math.floor((negTextHeight - padding * 2) / lineHeight);
const totalLines = this.parsedContent.negative.split('\n').length * 2; // Estimate
const maxScroll = Math.max(0, totalLines - visibleLines);
this.negScrollOffset = Math.max(0, Math.min(this.negScrollOffset, maxScroll));
node.setDirtyCanvas(true);
return true;
}
}
// Check button hovers
const posButtonX = margin + halfWidth / 2 - buttonWidth / 2;
const negButtonX = margin + halfWidth + halfWidth / 2 - buttonWidth / 2;
const oldPosHover = this.posCopyHovered;
const oldNegHover = this.negCopyHovered;
this.posCopyHovered = pos[0] > posButtonX && pos[0] < posButtonX + buttonWidth &&
pos[1] > buttonY && pos[1] < buttonY + buttonHeight;
this.negCopyHovered = pos[0] > negButtonX && pos[0] < negButtonX + buttonWidth &&
pos[1] > buttonY && pos[1] < buttonY + buttonHeight;
if (oldPosHover !== this.posCopyHovered || oldNegHover !== this.negCopyHovered) {
node.setDirtyCanvas(true);
}
// Handle button clicks
if (event.type === "pointerdown") {
if (this.posCopyHovered) {
this.copyToClipboard(this.parsedContent.positive, 'positive');
return true;
} else if (this.negCopyHovered) {
this.copyToClipboard(this.parsedContent.negative, 'negative');
return true;
}
}
} else {
// Regular text handling
const textAreaHeight = this.size[1] - buttonHeight - padding;
const buttonX = node.size[0] - margin - buttonWidth - padding;
const buttonY = this.last_y + textAreaHeight + padding / 2;
// Handle scrolling
if (event.type === "wheel" && pos[1] < this.last_y + textAreaHeight) {
const delta = event.deltaY > 0 ? 1 : -1;
this.scrollOffset = (this.scrollOffset || 0) + delta;
const visibleLines = Math.floor((textAreaHeight - padding * 2) / lineHeight);
const totalLines = this.parsedContent.content.split('\n').length * 2; // Estimate
const maxScroll = Math.max(0, totalLines - visibleLines);
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll));
node.setDirtyCanvas(true);
return true;
}
// Check button hover
const oldHover = this.copyButtonHovered;
this.copyButtonHovered = pos[0] > buttonX && pos[0] < buttonX + buttonWidth &&
pos[1] > buttonY && pos[1] < buttonY + buttonHeight;
if (oldHover !== this.copyButtonHovered) {
node.setDirtyCanvas(true);
}
// Handle button click
if (event.type === "pointerdown" && this.copyButtonHovered) {
this.copyToClipboard(this.parsedContent.content, 'regular');
return true;
}
}
return false;
},
copyToClipboard: function(text, type) {
const node = this._node;
navigator.clipboard.writeText(text).then(() => {
if (type === 'positive') {
this.posCopySuccess = true;
} else if (type === 'negative') {
this.negCopySuccess = true;
} else {
this.copySuccess = true;
}
node.setDirtyCanvas(true);
setTimeout(() => {
this.posCopySuccess = false;
this.negCopySuccess = false;
this.copySuccess = false;
node.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
// Fallback copy method
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
if (type === 'positive') {
this.posCopySuccess = true;
} else if (type === 'negative') {
this.negCopySuccess = true;
} else {
this.copySuccess = true;
}
node.setDirtyCanvas(true);
setTimeout(() => {
this.posCopySuccess = false;
this.negCopySuccess = false;
this.copySuccess = false;
node.setDirtyCanvas(true);
}, 1500);
} catch (err) {
console.error('Fallback copy failed:', err);
}
document.body.removeChild(textArea);
});
},
computeSize: function(width) {
return [width, this.size[1]];
}
};
// Store reference to node for callbacks
widget._node = this;
// Store the last y position for mouse detection
const originalDraw = widget.draw;
widget.draw = function(ctx, node, widget_width, y, H) {
this.last_y = y;
return originalDraw.call(this, ctx, node, widget_width, y, H);
};
// Add the widget
if (!this.widgets) {
this.widgets = [];
}
this.widgets.push(widget);
// Adjust node size to accommodate the widget
this.computeSize();
};
// Handle node resizing
const onResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function(size) {
onResize?.apply(this, arguments);
// Update widget size when node is resized
const textWidget = this.widgets?.find(w => w.name === "displayed_text");
if (textWidget) {
textWidget.size[0] = size[0] - 20;
textWidget.size[1] = size[1] - 60;
this.setDirtyCanvas(true);
}
};
// Initialize on node creation
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
onNodeCreated?.apply(this, arguments);
// Set default size
this.size[0] = Math.max(this.size[0], 350);
this.size[1] = Math.max(this.size[1], 300);
// Add placeholder text
this.updateTextDisplay("Text will appear here after execution...");
};
}
}
});
+28
View File
@@ -26,6 +26,12 @@ app.registerExtension({
// Style the button
helpButton.serialize = false;
// Add refresh models button
const refreshButton = this.addWidget("button", "Refresh Model List", null, () => {
this.refreshModelList();
});
refreshButton.serialize = false;
// Add status indicator
this.status = this.addWidget("text", "status", "Ready", () => {}, {
serialize: false
@@ -94,6 +100,28 @@ app.registerExtension({
// For now, it's always visible but this method provides extensibility
};
// Add method to refresh model list
nodeType.prototype.refreshModelList = function() {
if (this.status) {
this.status.value = "Refreshing models...";
}
// Set the refresh_models flag
const refreshWidget = this.widgets.find(w => w.name === "refresh_models");
if (refreshWidget) {
refreshWidget.value = true;
}
// Show message
alert("Model list will refresh on next execution. Make sure API key is set and run the node.");
if (this.status) {
setTimeout(() => {
this.status.value = "Ready - Run node to refresh";
}, 2000);
}
};
// Override execute to show status
const onExecute = nodeType.prototype.onExecute;
nodeType.prototype.onExecute = function() {