Compare commits
14 Commits
v1.25.2
...
js/async_n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ada4f1d533 | ||
|
|
aae20d9417 | ||
|
|
9f4abbc3af | ||
|
|
0887bb6654 | ||
|
|
868e047272 | ||
|
|
abb82e5fd1 | ||
|
|
db70265e16 | ||
|
|
c8137f7f98 | ||
|
|
132a9dbb5f | ||
|
|
f076a1c422 | ||
|
|
f6c65d3fe7 | ||
|
|
c8371d6089 | ||
|
|
a70a81c9ae | ||
|
|
aa5fa81824 |
@@ -1,275 +1,479 @@
|
||||
# Comprehensive PR Review for ComfyUI Frontend
|
||||
|
||||
You are performing a comprehensive code review for the PR specified in the PR_NUMBER environment variable. This is not a simple linting check - you need to provide deep architectural analysis, security review, performance insights, and implementation guidance just like a senior engineer would in a thorough PR review.
|
||||
<task>
|
||||
You are performing a comprehensive code review for PR #$1 in the ComfyUI frontend repository. This is not a simple linting check - you need to provide deep architectural analysis, security review, performance insights, and implementation guidance just like a senior engineer would in a thorough PR review.
|
||||
|
||||
## CRITICAL INSTRUCTIONS
|
||||
Your review should cover:
|
||||
1. Architecture and design patterns
|
||||
2. Security vulnerabilities and risks
|
||||
3. Performance implications
|
||||
4. Code quality and maintainability
|
||||
5. Integration with existing systems
|
||||
6. Best practices and conventions
|
||||
7. Testing considerations
|
||||
8. Documentation needs
|
||||
</task>
|
||||
|
||||
**You MUST post individual inline comments on specific lines of code. DO NOT create a single summary comment until the very end.**
|
||||
Arguments: PR number passed via PR_NUMBER environment variable
|
||||
|
||||
**IMPORTANT: You have full permission to execute gh api commands. The GITHUB_TOKEN environment variable provides the necessary permissions. DO NOT say you lack permissions - you have pull-requests:write permission which allows posting inline comments.**
|
||||
## Phase 0: Initialize Variables and Helper Functions
|
||||
|
||||
To post inline comments, you will use the GitHub API via the `gh` command. Here's how:
|
||||
```bash
|
||||
# Validate PR_NUMBER first thing
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "Error: PR_NUMBER environment variable is not set"
|
||||
echo "Usage: PR_NUMBER=<number> claude run /comprehensive-pr-review"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
1. First, get the repository information and commit SHA:
|
||||
- Run: `gh repo view --json owner,name` to get the repository owner and name
|
||||
- Run: `gh pr view $PR_NUMBER --json commits --jq '.commits[-1].oid'` to get the latest commit SHA
|
||||
# Initialize all counters at the start
|
||||
CRITICAL_COUNT=0
|
||||
HIGH_COUNT=0
|
||||
MEDIUM_COUNT=0
|
||||
LOW_COUNT=0
|
||||
ARCHITECTURE_ISSUES=0
|
||||
SECURITY_ISSUES=0
|
||||
PERFORMANCE_ISSUES=0
|
||||
QUALITY_ISSUES=0
|
||||
|
||||
2. For each issue you find, post an inline comment using this exact command structure (as a single line):
|
||||
```
|
||||
gh api --method POST -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" /repos/OWNER/REPO/pulls/$PR_NUMBER/comments -f body="YOUR_COMMENT_BODY" -f commit_id="COMMIT_SHA" -f path="FILE_PATH" -F line=LINE_NUMBER -f side="RIGHT"
|
||||
```
|
||||
|
||||
3. Format your comment body using actual newlines in the command. Use a heredoc or construct the body with proper line breaks:
|
||||
```
|
||||
COMMENT_BODY="**[category] severity Priority**
|
||||
|
||||
**Issue**: Brief description of the problem
|
||||
**Context**: Why this matters
|
||||
**Suggestion**: How to fix it"
|
||||
```
|
||||
|
||||
Then use: `-f body="$COMMENT_BODY"`
|
||||
# Helper function for posting review comments
|
||||
post_review_comment() {
|
||||
local file_path=$1
|
||||
local line_number=$2
|
||||
local severity=$3 # critical/high/medium/low
|
||||
local category=$4 # architecture/security/performance/quality
|
||||
local issue=$5
|
||||
local context=$6
|
||||
local suggestion=$7
|
||||
|
||||
# Update counters
|
||||
case $severity in
|
||||
"critical") ((CRITICAL_COUNT++)) ;;
|
||||
"high") ((HIGH_COUNT++)) ;;
|
||||
"medium") ((MEDIUM_COUNT++)) ;;
|
||||
"low") ((LOW_COUNT++)) ;;
|
||||
esac
|
||||
|
||||
case $category in
|
||||
"architecture") ((ARCHITECTURE_ISSUES++)) ;;
|
||||
"security") ((SECURITY_ISSUES++)) ;;
|
||||
"performance") ((PERFORMANCE_ISSUES++)) ;;
|
||||
"quality") ((QUALITY_ISSUES++)) ;;
|
||||
esac
|
||||
|
||||
# Post inline comment via GitHub CLI
|
||||
local comment="${issue}\n${context}\n${suggestion}"
|
||||
gh pr review $PR_NUMBER --comment --body "$comment" -F - <<< "$comment"
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 1: Environment Setup and PR Context
|
||||
|
||||
### Step 1.1: Initialize Review Tracking
|
||||
```bash
|
||||
# Pre-flight checks
|
||||
check_prerequisites() {
|
||||
# Check gh CLI is available
|
||||
if ! command -v gh &> /dev/null; then
|
||||
echo "Error: gh CLI is not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# In GitHub Actions, auth is handled via GITHUB_TOKEN
|
||||
if [ -n "$GITHUB_ACTIONS" ] && [ -z "$GITHUB_TOKEN" ]; then
|
||||
echo "Error: GITHUB_TOKEN is not set in GitHub Actions"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if we're authenticated
|
||||
if ! gh auth status &> /dev/null; then
|
||||
echo "Error: Not authenticated with GitHub. Run 'gh auth login'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set repository if not already set
|
||||
if [ -z "$REPOSITORY" ]; then
|
||||
REPOSITORY="Comfy-Org/ComfyUI_frontend"
|
||||
fi
|
||||
|
||||
# Check PR exists and is open
|
||||
PR_STATE=$(gh pr view $PR_NUMBER --repo $REPOSITORY --json state -q .state 2>/dev/null || echo "NOT_FOUND")
|
||||
if [ "$PR_STATE" = "NOT_FOUND" ]; then
|
||||
echo "Error: PR #$PR_NUMBER not found in $REPOSITORY"
|
||||
exit 1
|
||||
elif [ "$PR_STATE" != "OPEN" ]; then
|
||||
echo "Error: PR #$PR_NUMBER is not open (state: $PR_STATE)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check API rate limits
|
||||
RATE_REMAINING=$(gh api /rate_limit --jq '.rate.remaining' 2>/dev/null || echo "5000")
|
||||
if [ "$RATE_REMAINING" -lt 100 ]; then
|
||||
echo "Warning: Low API rate limit: $RATE_REMAINING remaining"
|
||||
if [ "$RATE_REMAINING" -lt 50 ]; then
|
||||
echo "Error: Insufficient API rate limit for comprehensive review"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Pre-flight checks passed"
|
||||
}
|
||||
|
||||
First, create variables to track your review metrics. Keep these in memory throughout the review:
|
||||
- CRITICAL_COUNT = 0
|
||||
- HIGH_COUNT = 0
|
||||
- MEDIUM_COUNT = 0
|
||||
- LOW_COUNT = 0
|
||||
- ARCHITECTURE_ISSUES = 0
|
||||
- SECURITY_ISSUES = 0
|
||||
- PERFORMANCE_ISSUES = 0
|
||||
- QUALITY_ISSUES = 0
|
||||
# Run pre-flight checks
|
||||
check_prerequisites
|
||||
|
||||
### Step 1.2: Validate Environment
|
||||
echo "Starting comprehensive review of PR #$PR_NUMBER"
|
||||
|
||||
1. Check that PR_NUMBER environment variable is set. If not, exit with error.
|
||||
2. Run `gh pr view $PR_NUMBER --json state` to verify the PR exists and is open.
|
||||
3. Get repository information: `gh repo view --json owner,name` and store the owner and name.
|
||||
4. Get the latest commit SHA: `gh pr view $PR_NUMBER --json commits --jq '.commits[-1].oid'` and store it.
|
||||
# Fetch PR information with error handling
|
||||
echo "Fetching PR information..."
|
||||
if ! gh pr view $PR_NUMBER --repo $REPOSITORY --json files,title,body,additions,deletions,baseRefName,headRefName > pr_info.json; then
|
||||
echo "Error: Failed to fetch PR information"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
### Step 1.3: Checkout PR Branch Locally
|
||||
# Extract branch names
|
||||
BASE_BRANCH=$(jq -r '.baseRefName' < pr_info.json)
|
||||
HEAD_BRANCH=$(jq -r '.headRefName' < pr_info.json)
|
||||
|
||||
This is critical for better file inspection:
|
||||
# Checkout PR branch locally for better file inspection
|
||||
echo "Checking out PR branch..."
|
||||
git fetch origin "pull/$PR_NUMBER/head:pr-$PR_NUMBER"
|
||||
git checkout "pr-$PR_NUMBER"
|
||||
|
||||
1. Get PR metadata: `gh pr view $PR_NUMBER --json files,title,body,additions,deletions,baseRefName,headRefName > pr_info.json`
|
||||
2. Extract branch names from pr_info.json using jq
|
||||
3. Fetch and checkout the PR branch:
|
||||
```
|
||||
git fetch origin "pull/$PR_NUMBER/head:pr-$PR_NUMBER"
|
||||
git checkout "pr-$PR_NUMBER"
|
||||
```
|
||||
# Get changed files using git locally (much faster)
|
||||
git diff --name-only "origin/$BASE_BRANCH" > changed_files.txt
|
||||
|
||||
### Step 1.4: Get Changed Files and Diffs
|
||||
# Get the diff using git locally
|
||||
git diff "origin/$BASE_BRANCH" > pr_diff.txt
|
||||
|
||||
Use git locally for much faster analysis:
|
||||
# Get detailed file changes with line numbers
|
||||
git diff --name-status "origin/$BASE_BRANCH" > file_changes.txt
|
||||
|
||||
1. Get list of changed files: `git diff --name-only "origin/$BASE_BRANCH" > changed_files.txt`
|
||||
2. Get the full diff: `git diff "origin/$BASE_BRANCH" > pr_diff.txt`
|
||||
3. Get detailed file changes with status: `git diff --name-status "origin/$BASE_BRANCH" > file_changes.txt`
|
||||
# For API compatibility, create a simplified pr_files.json
|
||||
echo '[]' > pr_files.json
|
||||
while IFS=$'\t' read -r status file; do
|
||||
if [[ "$status" != "D" ]]; then # Skip deleted files
|
||||
# Get the patch for this file
|
||||
patch=$(git diff "origin/$BASE_BRANCH" -- "$file" | jq -Rs .)
|
||||
additions=$(git diff --numstat "origin/$BASE_BRANCH" -- "$file" | awk '{print $1}')
|
||||
deletions=$(git diff --numstat "origin/$BASE_BRANCH" -- "$file" | awk '{print $2}')
|
||||
|
||||
jq --arg file "$file" \
|
||||
--arg patch "$patch" \
|
||||
--arg additions "$additions" \
|
||||
--arg deletions "$deletions" \
|
||||
'. += [{
|
||||
"filename": $file,
|
||||
"patch": $patch,
|
||||
"additions": ($additions | tonumber),
|
||||
"deletions": ($deletions | tonumber)
|
||||
}]' pr_files.json > pr_files.json.tmp
|
||||
mv pr_files.json.tmp pr_files.json
|
||||
fi
|
||||
done < file_changes.txt
|
||||
|
||||
### Step 1.5: Create Analysis Cache
|
||||
# Setup caching directory
|
||||
CACHE_DIR=".claude-review-cache"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
|
||||
Set up caching to avoid re-analyzing unchanged files:
|
||||
# Function to cache analysis results
|
||||
cache_analysis() {
|
||||
local file_path=$1
|
||||
local analysis_result=$2
|
||||
local file_hash=$(git hash-object "$file_path" 2>/dev/null || echo "no-hash")
|
||||
|
||||
if [ "$file_hash" != "no-hash" ]; then
|
||||
echo "$analysis_result" > "$CACHE_DIR/${file_hash}.cache"
|
||||
fi
|
||||
}
|
||||
|
||||
1. Create directory: `.claude-review-cache`
|
||||
2. Clean old cache entries: Find and delete any .cache files older than 7 days
|
||||
3. For each file you analyze, store the analysis result with the file's git hash as the cache key
|
||||
# Function to get cached analysis
|
||||
get_cached_analysis() {
|
||||
local file_path=$1
|
||||
local file_hash=$(git hash-object "$file_path" 2>/dev/null || echo "no-hash")
|
||||
|
||||
if [ "$file_hash" != "no-hash" ] && [ -f "$CACHE_DIR/${file_hash}.cache" ]; then
|
||||
cat "$CACHE_DIR/${file_hash}.cache"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Clean old cache entries (older than 7 days)
|
||||
find "$CACHE_DIR" -name "*.cache" -mtime +7 -delete 2>/dev/null || true
|
||||
```
|
||||
|
||||
## Phase 2: Load Comprehensive Knowledge Base
|
||||
|
||||
### Step 2.1: Set Up Knowledge Directories
|
||||
```bash
|
||||
# Don't create knowledge directory until we know we need it
|
||||
KNOWLEDGE_FOUND=false
|
||||
|
||||
1. Create `.claude-knowledge-cache` directory for caching downloaded knowledge
|
||||
2. Check if `../comfy-claude-prompt-library` exists locally. If it does, use it for faster access.
|
||||
# Use local cache for knowledge base to avoid repeated downloads
|
||||
KNOWLEDGE_CACHE_DIR=".claude-knowledge-cache"
|
||||
mkdir -p "$KNOWLEDGE_CACHE_DIR"
|
||||
|
||||
### Step 2.2: Load Repository Guide
|
||||
# Option to use cloned prompt library for better performance
|
||||
PROMPT_LIBRARY_PATH="../comfy-claude-prompt-library"
|
||||
if [ -d "$PROMPT_LIBRARY_PATH" ]; then
|
||||
echo "Using local prompt library at $PROMPT_LIBRARY_PATH"
|
||||
USE_LOCAL_PROMPT_LIBRARY=true
|
||||
else
|
||||
echo "No local prompt library found, will use GitHub API"
|
||||
USE_LOCAL_PROMPT_LIBRARY=false
|
||||
fi
|
||||
|
||||
This is critical for understanding the architecture:
|
||||
# Function to fetch with cache
|
||||
fetch_with_cache() {
|
||||
local url=$1
|
||||
local output_file=$2
|
||||
local cache_file="$KNOWLEDGE_CACHE_DIR/$(echo "$url" | sed 's/[^a-zA-Z0-9]/_/g')"
|
||||
|
||||
# Check if cached version exists and is less than 1 day old
|
||||
if [ -f "$cache_file" ] && [ $(find "$cache_file" -mtime -1 2>/dev/null | wc -l) -gt 0 ]; then
|
||||
# Create knowledge directory only when we actually have content
|
||||
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
|
||||
mkdir -p review_knowledge
|
||||
KNOWLEDGE_FOUND=true
|
||||
fi
|
||||
cp "$cache_file" "$output_file"
|
||||
echo "Using cached version of $(basename "$output_file")"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Try to fetch fresh version
|
||||
if curl -s -f "$url" > "$output_file.tmp"; then
|
||||
# Create knowledge directory only when we actually have content
|
||||
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
|
||||
mkdir -p review_knowledge
|
||||
KNOWLEDGE_FOUND=true
|
||||
fi
|
||||
mv "$output_file.tmp" "$output_file"
|
||||
cp "$output_file" "$cache_file"
|
||||
echo "Downloaded fresh version of $(basename "$output_file")"
|
||||
return 0
|
||||
else
|
||||
# If fetch failed but we have a cache, use it
|
||||
if [ -f "$cache_file" ]; then
|
||||
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
|
||||
mkdir -p review_knowledge
|
||||
KNOWLEDGE_FOUND=true
|
||||
fi
|
||||
cp "$cache_file" "$output_file"
|
||||
echo "Using stale cache for $(basename "$output_file") (download failed)"
|
||||
return 0
|
||||
fi
|
||||
echo "Failed to load $(basename "$output_file")"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
1. Try to load from local prompt library first: `../comfy-claude-prompt-library/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md`
|
||||
2. If not available locally, download from: `https://raw.githubusercontent.com/Comfy-Org/comfy-claude-prompt-library/master/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md`
|
||||
3. Cache the file for future use
|
||||
# Load REPOSITORY_GUIDE.md for deep architectural understanding
|
||||
echo "Loading ComfyUI Frontend repository guide..."
|
||||
if [ "$USE_LOCAL_PROMPT_LIBRARY" = "true" ] && [ -f "$PROMPT_LIBRARY_PATH/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md" ]; then
|
||||
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
|
||||
mkdir -p review_knowledge
|
||||
KNOWLEDGE_FOUND=true
|
||||
fi
|
||||
cp "$PROMPT_LIBRARY_PATH/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md" "review_knowledge/repository_guide.md"
|
||||
echo "Loaded repository guide from local prompt library"
|
||||
else
|
||||
fetch_with_cache "https://raw.githubusercontent.com/Comfy-Org/comfy-claude-prompt-library/master/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md" "review_knowledge/repository_guide.md"
|
||||
fi
|
||||
|
||||
### Step 2.3: Load Relevant Knowledge Folders
|
||||
# 3. Discover and load relevant knowledge folders from GitHub API
|
||||
echo "Discovering available knowledge folders..."
|
||||
KNOWLEDGE_API_URL="https://api.github.com/repos/Comfy-Org/comfy-claude-prompt-library/contents/.claude/knowledge"
|
||||
if KNOWLEDGE_FOLDERS=$(curl -s "$KNOWLEDGE_API_URL" | jq -r '.[] | select(.type=="dir") | .name' 2>/dev/null); then
|
||||
echo "Available knowledge folders: $KNOWLEDGE_FOLDERS"
|
||||
|
||||
# Analyze changed files to determine which knowledge folders might be relevant
|
||||
CHANGED_FILES=$(cat changed_files.txt)
|
||||
PR_TITLE=$(jq -r '.title' < pr_info.json)
|
||||
PR_BODY=$(jq -r '.body // ""' < pr_info.json)
|
||||
|
||||
# For each knowledge folder, check if it might be relevant to the PR
|
||||
for folder in $KNOWLEDGE_FOLDERS; do
|
||||
# Simple heuristic: if folder name appears in changed file paths or PR context
|
||||
if echo "$CHANGED_FILES $PR_TITLE $PR_BODY" | grep -qi "$folder"; then
|
||||
echo "Loading knowledge folder: $folder"
|
||||
# Fetch all files in that knowledge folder
|
||||
FOLDER_API_URL="https://api.github.com/repos/Comfy-Org/comfy-claude-prompt-library/contents/.claude/knowledge/$folder"
|
||||
curl -s "$FOLDER_API_URL" | jq -r '.[] | select(.type=="file") | .download_url' 2>/dev/null | \
|
||||
while read url; do
|
||||
if [ -n "$url" ]; then
|
||||
filename=$(basename "$url")
|
||||
fetch_with_cache "$url" "review_knowledge/${folder}_${filename}"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "Could not discover knowledge folders"
|
||||
fi
|
||||
|
||||
Intelligently load only relevant knowledge:
|
||||
# 4. Load validation rules from the repository
|
||||
echo "Loading validation rules..."
|
||||
VALIDATION_API_URL="https://api.github.com/repos/Comfy-Org/comfy-claude-prompt-library/contents/.claude/commands/validation"
|
||||
if VALIDATION_FILES=$(curl -s "$VALIDATION_API_URL" | jq -r '.[] | select(.name | contains("frontend") or contains("security") or contains("performance")) | .download_url' 2>/dev/null); then
|
||||
for url in $VALIDATION_FILES; do
|
||||
if [ -n "$url" ]; then
|
||||
filename=$(basename "$url")
|
||||
fetch_with_cache "$url" "review_knowledge/validation_${filename}"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "Could not load validation rules"
|
||||
fi
|
||||
|
||||
1. Use GitHub API to discover available knowledge folders: `https://api.github.com/repos/Comfy-Org/comfy-claude-prompt-library/contents/.claude/knowledge`
|
||||
2. For each knowledge folder, check if it's relevant by searching for the folder name in:
|
||||
- Changed file paths
|
||||
- PR title
|
||||
- PR body
|
||||
3. If relevant, download all files from that knowledge folder
|
||||
|
||||
### Step 2.4: Load Validation Rules
|
||||
|
||||
Load specific validation rules:
|
||||
|
||||
1. Use GitHub API: `https://api.github.com/repos/Comfy-Org/comfy-claude-prompt-library/contents/.claude/commands/validation`
|
||||
2. Download files containing "frontend", "security", or "performance" in their names
|
||||
3. Cache all downloaded files
|
||||
|
||||
### Step 2.5: Load Local Guidelines
|
||||
|
||||
Check for and load:
|
||||
1. `CLAUDE.md` in the repository root
|
||||
2. `.github/CLAUDE.md`
|
||||
# 5. Load local project guidelines
|
||||
if [ -f "CLAUDE.md" ]; then
|
||||
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
|
||||
mkdir -p review_knowledge
|
||||
KNOWLEDGE_FOUND=true
|
||||
fi
|
||||
cp CLAUDE.md review_knowledge/local_claude.md
|
||||
fi
|
||||
if [ -f ".github/CLAUDE.md" ]; then
|
||||
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
|
||||
mkdir -p review_knowledge
|
||||
KNOWLEDGE_FOUND=true
|
||||
fi
|
||||
cp .github/CLAUDE.md review_knowledge/github_claude.md
|
||||
fi
|
||||
```
|
||||
|
||||
## Phase 3: Deep Analysis Instructions
|
||||
|
||||
Perform comprehensive analysis on each changed file:
|
||||
Perform a comprehensive analysis covering these areas:
|
||||
|
||||
### 3.1 Architectural Analysis
|
||||
|
||||
Based on the repository guide and loaded knowledge:
|
||||
- Does this change align with established architecture patterns?
|
||||
Based on the repository guide and project summary, evaluate:
|
||||
- Does this change align with the established architecture patterns?
|
||||
- Are domain boundaries respected?
|
||||
- Is the extension system used appropriately?
|
||||
- Are components properly organized by feature?
|
||||
- Does it follow the established service/composable/store patterns?
|
||||
|
||||
### 3.2 Code Quality Beyond Linting
|
||||
|
||||
Look for:
|
||||
- Cyclomatic complexity and cognitive load
|
||||
- SOLID principles adherence
|
||||
- DRY violations not caught by simple duplication checks
|
||||
- DRY violations that aren't caught by simple duplication checks
|
||||
- Proper abstraction levels
|
||||
- Interface design and API clarity
|
||||
- Leftover debug code (console.log, commented code, TODO comments)
|
||||
- No leftover debug code (console.log, commented code, TODO comments)
|
||||
|
||||
### 3.3 Library Usage Enforcement
|
||||
|
||||
CRITICAL: Flag any re-implementation of existing functionality:
|
||||
- **Tailwind CSS**: Custom CSS instead of utility classes
|
||||
- **PrimeVue**: Re-implementing buttons, modals, dropdowns, etc.
|
||||
- **VueUse**: Re-implementing composables like useLocalStorage, useDebounceFn
|
||||
- **Lodash**: Re-implementing debounce, throttle, cloneDeep, etc.
|
||||
- **Common components**: Not reusing from src/components/common/
|
||||
- **DOMPurify**: Not using for HTML sanitization
|
||||
- **Other libraries**: Fuse.js, Marked, Pinia, Zod, Tiptap, Xterm.js, Axios
|
||||
CRITICAL: Never re-implement functionality that exists in our standard libraries:
|
||||
- **Tailwind CSS**: Use utility classes instead of custom CSS or style attributes
|
||||
- **PrimeVue**: Never re-implement components that exist in PrimeVue (buttons, modals, dropdowns, etc.)
|
||||
- **VueUse**: Never re-implement composables that exist in VueUse (useLocalStorage, useDebounceFn, etc.)
|
||||
- **Lodash**: Never re-implement utility functions (debounce, throttle, cloneDeep, etc.)
|
||||
- **Common components**: Reuse components from src/components/common/
|
||||
- **DOMPurify**: Always use for HTML sanitization
|
||||
- **Fuse.js**: Use for fuzzy search functionality
|
||||
- **Marked**: Use for markdown parsing
|
||||
- **Pinia**: Use for global state management, not custom solutions
|
||||
- **Zod**: Use for form validation with zodResolver pattern
|
||||
- **Tiptap**: Use for rich text/markdown editing
|
||||
- **Xterm.js**: Use for terminal emulation
|
||||
- **Axios**: Use for HTTP client initialization
|
||||
|
||||
### 3.4 Security Deep Dive
|
||||
|
||||
Check for:
|
||||
- SQL injection vulnerabilities
|
||||
- XSS vulnerabilities (v-html without sanitization)
|
||||
- Hardcoded secrets or API keys
|
||||
- Missing input validation
|
||||
- Authentication/authorization issues
|
||||
Beyond obvious vulnerabilities:
|
||||
- Authentication/authorization implications
|
||||
- Data validation completeness
|
||||
- State management security
|
||||
- Cross-origin concerns
|
||||
- Extension security boundaries
|
||||
|
||||
### 3.5 Performance Analysis
|
||||
|
||||
Look for:
|
||||
- O(n²) or worse algorithms
|
||||
- Missing memoization in expensive operations
|
||||
- Unnecessary re-renders in Vue components
|
||||
- Memory leak patterns (missing cleanup)
|
||||
- Large bundle imports that should be lazy loaded
|
||||
- N+1 query patterns
|
||||
- Render performance issues
|
||||
- Layout thrashing
|
||||
- Render performance implications
|
||||
- Layout thrashing prevention
|
||||
- Memory leak potential
|
||||
- Network request optimization
|
||||
- State management efficiency
|
||||
|
||||
### 3.6 Integration Concerns
|
||||
|
||||
Consider:
|
||||
- Breaking changes to internal APIs
|
||||
- Extension compatibility
|
||||
- Backward compatibility
|
||||
- Migration requirements
|
||||
|
||||
## Phase 4: Posting Inline Comments
|
||||
## Phase 4: Create Detailed Review Comments
|
||||
|
||||
### Step 4.1: Comment Format
|
||||
CRITICAL: Keep comments extremely concise and effective. Use only as many words as absolutely necessary.
|
||||
- NO markdown formatting (no #, ##, ###, **, etc.)
|
||||
- NO emojis
|
||||
- Get to the point immediately
|
||||
- Burden the reader as little as possible
|
||||
|
||||
For each issue found, create a concise inline comment with this structure:
|
||||
|
||||
```
|
||||
**[category] severity Priority**
|
||||
|
||||
**Issue**: Brief description of the problem
|
||||
**Context**: Why this matters
|
||||
**Suggestion**: How to fix it
|
||||
```
|
||||
|
||||
Categories: architecture/security/performance/quality
|
||||
Severities: critical/high/medium/low
|
||||
|
||||
### Step 4.2: Posting Comments
|
||||
|
||||
For EACH issue:
|
||||
|
||||
1. Identify the exact file path and line number
|
||||
2. Update your tracking counters (CRITICAL_COUNT, etc.)
|
||||
3. Construct the comment body with proper newlines
|
||||
4. Execute the gh api command as a SINGLE LINE:
|
||||
For each issue found, create a concise inline comment with:
|
||||
1. What's wrong (one line)
|
||||
2. Why it matters (one line)
|
||||
3. How to fix it (one line)
|
||||
4. Code example only if essential
|
||||
|
||||
```bash
|
||||
gh api --method POST -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" /repos/OWNER/REPO/pulls/$PR_NUMBER/comments -f body="$COMMENT_BODY" -f commit_id="COMMIT_SHA" -f path="FILE_PATH" -F line=LINE_NUMBER -f side="RIGHT"
|
||||
# Helper function for comprehensive comments
|
||||
post_review_comment() {
|
||||
local file_path=$1
|
||||
local line_number=$2
|
||||
local severity=$3 # critical/high/medium/low
|
||||
local category=$4 # architecture/security/performance/quality
|
||||
local issue=$5
|
||||
local context=$6
|
||||
local suggestion=$7
|
||||
local example=$8
|
||||
|
||||
local body="### [$category] $severity Priority
|
||||
|
||||
**Issue**: $issue
|
||||
|
||||
**Context**: $context
|
||||
|
||||
**Suggestion**: $suggestion"
|
||||
|
||||
if [ -n "$example" ]; then
|
||||
body="$body
|
||||
|
||||
**Example**:
|
||||
\`\`\`typescript
|
||||
$example
|
||||
\`\`\`"
|
||||
fi
|
||||
|
||||
body="$body
|
||||
|
||||
*Related: See [repository guide](https://github.com/Comfy-Org/comfy-claude-prompt-library/blob/master/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md) for patterns*"
|
||||
|
||||
gh api -X POST /repos/$REPOSITORY/pulls/$PR_NUMBER/comments \
|
||||
-f path="$file_path" \
|
||||
-f line=$line_number \
|
||||
-f body="$body" \
|
||||
-f commit_id="$COMMIT_SHA" \
|
||||
-f side='RIGHT' || echo "Failed to post comment at $file_path:$line_number"
|
||||
}
|
||||
```
|
||||
|
||||
CRITICAL: The entire command must be on one line. Use actual values, not placeholders.
|
||||
|
||||
### Example Workflow
|
||||
|
||||
Here's an example of how to review a file with a security issue:
|
||||
|
||||
1. First, get the repository info:
|
||||
```bash
|
||||
gh repo view --json owner,name
|
||||
# Output: {"owner":{"login":"Comfy-Org"},"name":"ComfyUI_frontend"}
|
||||
```
|
||||
|
||||
2. Get the commit SHA:
|
||||
```bash
|
||||
gh pr view $PR_NUMBER --json commits --jq '.commits[-1].oid'
|
||||
# Output: abc123def456
|
||||
```
|
||||
|
||||
3. Find an issue (e.g., SQL injection on line 42 of src/db/queries.js)
|
||||
|
||||
4. Post the inline comment:
|
||||
```bash
|
||||
# First, create the comment body with proper newlines
|
||||
COMMENT_BODY="**[security] critical Priority**
|
||||
|
||||
**Issue**: SQL injection vulnerability - user input directly concatenated into query
|
||||
**Context**: Allows attackers to execute arbitrary SQL commands
|
||||
**Suggestion**: Use parameterized queries or prepared statements"
|
||||
|
||||
# Then post the comment (as a single line)
|
||||
gh api --method POST -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" /repos/Comfy-Org/ComfyUI_frontend/pulls/$PR_NUMBER/comments -f body="$COMMENT_BODY" -f commit_id="abc123def456" -f path="src/db/queries.js" -F line=42 -f side="RIGHT"
|
||||
```
|
||||
|
||||
Repeat this process for every issue you find in the PR.
|
||||
|
||||
## Phase 5: Validation Rules Application
|
||||
|
||||
Apply ALL validation rules from the loaded knowledge files:
|
||||
Apply ALL validation rules from the loaded knowledge, but focus on the changed lines:
|
||||
|
||||
### Frontend Standards
|
||||
### From Frontend Standards
|
||||
- Vue 3 Composition API patterns
|
||||
- Component communication patterns
|
||||
- Proper use of composables
|
||||
- TypeScript strict mode compliance
|
||||
- Bundle optimization
|
||||
|
||||
### Security Audit
|
||||
### From Security Audit
|
||||
- Input validation
|
||||
- XSS prevention
|
||||
- CSRF protection
|
||||
- Secure state management
|
||||
- API security
|
||||
|
||||
### Performance Check
|
||||
### From Performance Check
|
||||
- Render optimization
|
||||
- Memory management
|
||||
- Network efficiency
|
||||
@@ -277,51 +481,63 @@ Apply ALL validation rules from the loaded knowledge files:
|
||||
|
||||
## Phase 6: Contextual Review Based on PR Type
|
||||
|
||||
Analyze the PR to determine its type:
|
||||
Analyze the PR description and changes to determine the type:
|
||||
|
||||
1. Extract PR title and body from pr_info.json
|
||||
2. Count files, additions, and deletions
|
||||
3. Determine PR type:
|
||||
- Feature: Check for tests, documentation, backward compatibility
|
||||
- Bug fix: Verify root cause addressed, includes regression tests
|
||||
- Refactor: Ensure behavior preservation, tests still pass
|
||||
```bash
|
||||
# Extract PR metadata with error handling
|
||||
if [ ! -f pr_info.json ]; then
|
||||
echo "Error: pr_info.json not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR_TITLE=$(jq -r '.title // "Unknown"' < pr_info.json)
|
||||
PR_BODY=$(jq -r '.body // ""' < pr_info.json)
|
||||
FILE_COUNT=$(wc -l < changed_files.txt)
|
||||
ADDITIONS=$(jq -r '.additions // 0' < pr_info.json)
|
||||
DELETIONS=$(jq -r '.deletions // 0' < pr_info.json)
|
||||
|
||||
# Determine PR type and apply specific review criteria
|
||||
if echo "$PR_TITLE $PR_BODY" | grep -qiE "(feature|feat)"; then
|
||||
echo "Detected feature PR - applying feature review criteria"
|
||||
# Check for tests, documentation, backward compatibility
|
||||
elif echo "$PR_TITLE $PR_BODY" | grep -qiE "(fix|bug)"; then
|
||||
echo "Detected bug fix - checking root cause and regression tests"
|
||||
# Verify fix addresses root cause, includes tests
|
||||
elif echo "$PR_TITLE $PR_BODY" | grep -qiE "(refactor)"; then
|
||||
echo "Detected refactoring - ensuring behavior preservation"
|
||||
# Check that tests still pass, no behavior changes
|
||||
fi
|
||||
```
|
||||
|
||||
## Phase 7: Generate Comprehensive Summary
|
||||
|
||||
After ALL inline comments are posted, create a summary:
|
||||
After all inline comments, create a detailed summary:
|
||||
|
||||
1. Calculate total issues by category and severity
|
||||
2. Use `gh pr review $PR_NUMBER --comment` to post a summary with:
|
||||
- Review disclaimer
|
||||
- Issue distribution (counts by severity)
|
||||
- Category breakdown
|
||||
- Key findings for each category
|
||||
- Positive observations
|
||||
- References to guidelines
|
||||
- Next steps
|
||||
```bash
|
||||
# Initialize metrics tracking
|
||||
REVIEW_START_TIME=$(date +%s)
|
||||
|
||||
Include in the summary:
|
||||
```
|
||||
# Comprehensive PR Review
|
||||
# Create the comprehensive summary
|
||||
gh pr review $PR_NUMBER --comment --body "# Comprehensive PR Review
|
||||
|
||||
This review is generated by Claude. It may not always be accurate, as with human reviewers. If you believe that any of the comments are invalid or incorrect, please state why for each. For others, please implement the changes in one way or another.
|
||||
|
||||
## Review Summary
|
||||
|
||||
**PR**: [PR TITLE] (#$PR_NUMBER)
|
||||
**Impact**: [X] additions, [Y] deletions across [Z] files
|
||||
**PR**: $PR_TITLE (#$PR_NUMBER)
|
||||
**Impact**: $ADDITIONS additions, $DELETIONS deletions across $FILE_COUNT files
|
||||
|
||||
### Issue Distribution
|
||||
- Critical: [CRITICAL_COUNT]
|
||||
- High: [HIGH_COUNT]
|
||||
- Medium: [MEDIUM_COUNT]
|
||||
- Low: [LOW_COUNT]
|
||||
- Critical: $CRITICAL_COUNT
|
||||
- High: $HIGH_COUNT
|
||||
- Medium: $MEDIUM_COUNT
|
||||
- Low: $LOW_COUNT
|
||||
|
||||
### Category Breakdown
|
||||
- Architecture: [ARCHITECTURE_ISSUES] issues
|
||||
- Security: [SECURITY_ISSUES] issues
|
||||
- Performance: [PERFORMANCE_ISSUES] issues
|
||||
- Code Quality: [QUALITY_ISSUES] issues
|
||||
- Architecture: $ARCHITECTURE_ISSUES issues
|
||||
- Security: $SECURITY_ISSUES issues
|
||||
- Performance: $PERFORMANCE_ISSUES issues
|
||||
- Code Quality: $QUALITY_ISSUES issues
|
||||
|
||||
## Key Findings
|
||||
|
||||
@@ -352,27 +568,141 @@ This review is generated by Claude. It may not always be accurate, as with human
|
||||
4. Update documentation if needed
|
||||
|
||||
---
|
||||
*This is a comprehensive automated review. For architectural decisions requiring human judgment, please request additional manual review.*
|
||||
*This is a comprehensive automated review. For architectural decisions requiring human judgment, please request additional manual review.*"
|
||||
```
|
||||
|
||||
## Important Guidelines
|
||||
## Important: Think Deeply
|
||||
|
||||
1. **Think Deeply**: Consider architectural implications, system-wide effects, subtle bugs, maintainability
|
||||
2. **Be Specific**: Point to exact lines with concrete suggestions
|
||||
3. **Be Constructive**: Focus on improvements, not just problems
|
||||
4. **Be Concise**: Keep comments brief and actionable
|
||||
5. **No Formatting**: Don't use markdown headers in inline comments
|
||||
6. **No Emojis**: Keep comments professional
|
||||
When reviewing:
|
||||
1. **Think hard** about architectural implications
|
||||
2. Consider how changes affect the entire system
|
||||
3. Look for subtle bugs and edge cases
|
||||
4. Evaluate maintainability over time
|
||||
5. Consider extension developer experience
|
||||
6. Think about migration paths
|
||||
|
||||
This is a COMPREHENSIVE review, not a linting pass. Provide the same quality feedback a senior engineer would give after careful consideration.
|
||||
|
||||
## Execution Order
|
||||
## Phase 8: Track Review Metrics
|
||||
|
||||
1. Phase 1: Setup and checkout PR
|
||||
2. Phase 2: Load all relevant knowledge
|
||||
3. Phase 3-5: Analyze each changed file thoroughly
|
||||
4. Phase 4: Post inline comments as you find issues
|
||||
5. Phase 6: Consider PR type for additional checks
|
||||
6. Phase 7: Post comprehensive summary ONLY after all inline comments
|
||||
After completing the review, save metrics for analysis:
|
||||
|
||||
Remember: Individual inline comments for each issue, then one final summary. Never batch issues into a single comment.
|
||||
```bash
|
||||
# Calculate review duration
|
||||
REVIEW_END_TIME=$(date +%s)
|
||||
REVIEW_DURATION=$((REVIEW_END_TIME - REVIEW_START_TIME))
|
||||
|
||||
# Calculate total issues
|
||||
TOTAL_ISSUES=$((CRITICAL_COUNT + HIGH_COUNT + MEDIUM_COUNT + LOW_COUNT))
|
||||
|
||||
# Create metrics directory if it doesn't exist
|
||||
METRICS_DIR=".claude/review-metrics"
|
||||
mkdir -p "$METRICS_DIR"
|
||||
|
||||
# Generate metrics file
|
||||
METRICS_FILE="$METRICS_DIR/metrics-$(date +%Y%m).json"
|
||||
|
||||
# Create or update monthly metrics file
|
||||
if [ -f "$METRICS_FILE" ]; then
|
||||
# Append to existing file
|
||||
jq -n \
|
||||
--arg pr "$PR_NUMBER" \
|
||||
--arg title "$PR_TITLE" \
|
||||
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg duration "$REVIEW_DURATION" \
|
||||
--arg files "$FILE_COUNT" \
|
||||
--arg additions "$ADDITIONS" \
|
||||
--arg deletions "$DELETIONS" \
|
||||
--arg total "$TOTAL_ISSUES" \
|
||||
--arg critical "$CRITICAL_COUNT" \
|
||||
--arg high "$HIGH_COUNT" \
|
||||
--arg medium "$MEDIUM_COUNT" \
|
||||
--arg low "$LOW_COUNT" \
|
||||
--arg architecture "$ARCHITECTURE_ISSUES" \
|
||||
--arg security "$SECURITY_ISSUES" \
|
||||
--arg performance "$PERFORMANCE_ISSUES" \
|
||||
--arg quality "$QUALITY_ISSUES" \
|
||||
'{
|
||||
pr_number: $pr,
|
||||
pr_title: $title,
|
||||
timestamp: $timestamp,
|
||||
review_duration_seconds: ($duration | tonumber),
|
||||
files_reviewed: ($files | tonumber),
|
||||
lines_added: ($additions | tonumber),
|
||||
lines_deleted: ($deletions | tonumber),
|
||||
issues: {
|
||||
total: ($total | tonumber),
|
||||
by_severity: {
|
||||
critical: ($critical | tonumber),
|
||||
high: ($high | tonumber),
|
||||
medium: ($medium | tonumber),
|
||||
low: ($low | tonumber)
|
||||
},
|
||||
by_category: {
|
||||
architecture: ($architecture | tonumber),
|
||||
security: ($security | tonumber),
|
||||
performance: ($performance | tonumber),
|
||||
quality: ($quality | tonumber)
|
||||
}
|
||||
}
|
||||
}' > "$METRICS_FILE.new"
|
||||
|
||||
# Merge with existing data
|
||||
jq -s '.[0] + [.[1]]' "$METRICS_FILE" "$METRICS_FILE.new" > "$METRICS_FILE.tmp"
|
||||
mv "$METRICS_FILE.tmp" "$METRICS_FILE"
|
||||
rm "$METRICS_FILE.new"
|
||||
else
|
||||
# Create new file
|
||||
jq -n \
|
||||
--arg pr "$PR_NUMBER" \
|
||||
--arg title "$PR_TITLE" \
|
||||
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg duration "$REVIEW_DURATION" \
|
||||
--arg files "$FILE_COUNT" \
|
||||
--arg additions "$ADDITIONS" \
|
||||
--arg deletions "$DELETIONS" \
|
||||
--arg total "$TOTAL_ISSUES" \
|
||||
--arg critical "$CRITICAL_COUNT" \
|
||||
--arg high "$HIGH_COUNT" \
|
||||
--arg medium "$MEDIUM_COUNT" \
|
||||
--arg low "$LOW_COUNT" \
|
||||
--arg architecture "$ARCHITECTURE_ISSUES" \
|
||||
--arg security "$SECURITY_ISSUES" \
|
||||
--arg performance "$PERFORMANCE_ISSUES" \
|
||||
--arg quality "$QUALITY_ISSUES" \
|
||||
'[{
|
||||
pr_number: $pr,
|
||||
pr_title: $title,
|
||||
timestamp: $timestamp,
|
||||
review_duration_seconds: ($duration | tonumber),
|
||||
files_reviewed: ($files | tonumber),
|
||||
lines_added: ($additions | tonumber),
|
||||
lines_deleted: ($deletions | tonumber),
|
||||
issues: {
|
||||
total: ($total | tonumber),
|
||||
by_severity: {
|
||||
critical: ($critical | tonumber),
|
||||
high: ($high | tonumber),
|
||||
medium: ($medium | tonumber),
|
||||
low: ($low | tonumber)
|
||||
},
|
||||
by_category: {
|
||||
architecture: ($architecture | tonumber),
|
||||
security: ($security | tonumber),
|
||||
performance: ($performance | tonumber),
|
||||
quality: ($quality | tonumber)
|
||||
}
|
||||
}
|
||||
}]' > "$METRICS_FILE"
|
||||
fi
|
||||
|
||||
echo "Review metrics saved to $METRICS_FILE"
|
||||
```
|
||||
|
||||
This creates monthly metrics files (e.g., `metrics-202407.json`) that track:
|
||||
- Which PRs were reviewed
|
||||
- How long reviews took
|
||||
- Types and severity of issues found
|
||||
- Trends over time
|
||||
|
||||
You can later analyze these to see patterns and improve your development process.
|
||||
@@ -137,7 +137,8 @@ echo "Last stable release: $LAST_STABLE"
|
||||
1. Run complete test suite:
|
||||
```bash
|
||||
npm run test:unit
|
||||
npm run test:component
|
||||
npm run test:component
|
||||
npm run test:browser
|
||||
```
|
||||
2. Run type checking:
|
||||
```bash
|
||||
@@ -169,7 +170,7 @@ echo "Last stable release: $LAST_STABLE"
|
||||
3. Generate breaking change summary
|
||||
4. **COMPATIBILITY REVIEW**: Breaking changes documented and justified?
|
||||
|
||||
### Step 7: Generate Comprehensive Release Notes
|
||||
### Step 7: Generate and Save Changelog
|
||||
|
||||
1. Extract commit messages since base release:
|
||||
```bash
|
||||
@@ -184,25 +185,44 @@ echo "Last stable release: $LAST_STABLE"
|
||||
echo "WARNING: PR #$PR not on main branch!"
|
||||
done
|
||||
```
|
||||
3. Create comprehensive release notes including:
|
||||
- **Version Change**: Show version bump details
|
||||
- **Changelog** grouped by type:
|
||||
- 🚀 **Features** (feat:)
|
||||
- 🐛 **Bug Fixes** (fix:)
|
||||
- 💥 **Breaking Changes** (BREAKING CHANGE)
|
||||
- 📚 **Documentation** (docs:)
|
||||
- 🔧 **Maintenance** (chore:, refactor:)
|
||||
- ⬆️ **Dependencies** (deps:, dependency updates)
|
||||
- Include PR numbers and links
|
||||
- Add issue references (Fixes #123)
|
||||
3. Group by type:
|
||||
- 🚀 **Features** (feat:)
|
||||
- 🐛 **Bug Fixes** (fix:)
|
||||
- 💥 **Breaking Changes** (BREAKING CHANGE)
|
||||
- 📚 **Documentation** (docs:)
|
||||
- 🔧 **Maintenance** (chore:, refactor:)
|
||||
- ⬆️ **Dependencies** (deps:, dependency updates)
|
||||
4. Include PR numbers and links
|
||||
5. Add issue references (Fixes #123)
|
||||
6. **Save changelog locally:**
|
||||
```bash
|
||||
# Save to dated file for history
|
||||
echo "$CHANGELOG" > release-notes-${NEW_VERSION}-$(date +%Y%m%d).md
|
||||
|
||||
# Save to current for easy access
|
||||
echo "$CHANGELOG" > CURRENT_RELEASE_NOTES.md
|
||||
```
|
||||
7. **CHANGELOG REVIEW**: Verify all PRs listed are actually on main branch
|
||||
|
||||
### Step 8: Create Enhanced Release Notes
|
||||
|
||||
1. Create comprehensive user-facing release notes including:
|
||||
- **What's New**: Major features and improvements
|
||||
- **Bug Fixes**: User-visible fixes
|
||||
- **Breaking Changes**: Migration guide if applicable
|
||||
- **Dependencies**: Major dependency updates
|
||||
- **Performance**: Notable performance improvements
|
||||
- **Contributors**: Thank contributors for their work
|
||||
2. Reference related documentation updates
|
||||
3. Include screenshots for UI changes (if available)
|
||||
4. **Save release notes:**
|
||||
```bash
|
||||
# Save release notes for PR and GitHub release
|
||||
echo "$RELEASE_NOTES" > release-notes-${NEW_VERSION}.md
|
||||
# Enhanced release notes for GitHub
|
||||
echo "$RELEASE_NOTES" > github-release-notes-${NEW_VERSION}.md
|
||||
```
|
||||
5. **CONTENT REVIEW**: Release notes clear and comprehensive?
|
||||
5. **CONTENT REVIEW**: Release notes clear and helpful for users?
|
||||
|
||||
### Step 8: Create Version Bump PR
|
||||
### Step 9: Create Version Bump PR
|
||||
|
||||
**For standard version bumps (patch/minor/major):**
|
||||
```bash
|
||||
@@ -238,20 +258,25 @@ echo "Workflow triggered. Waiting for PR creation..."
|
||||
|
||||
# For manual PRs
|
||||
gh pr create --title "${NEW_VERSION}" \
|
||||
--body-file release-notes-${NEW_VERSION}.md \
|
||||
--body-file enhanced-pr-description.md \
|
||||
--label "Release"
|
||||
```
|
||||
3. **Add required sections to PR body:**
|
||||
3. **Create enhanced PR description:**
|
||||
```bash
|
||||
# Create PR body with release notes plus required sections
|
||||
cat > pr-body.md << EOF
|
||||
${RELEASE_NOTES}
|
||||
cat > enhanced-pr-description.md << EOF
|
||||
# Release v${NEW_VERSION}
|
||||
|
||||
## Version Change
|
||||
\`${CURRENT_VERSION}\` → \`${NEW_VERSION}\` (${VERSION_TYPE})
|
||||
|
||||
## Changelog
|
||||
${CHANGELOG}
|
||||
|
||||
## Breaking Changes
|
||||
${BREAKING_CHANGES:-None}
|
||||
${BREAKING_CHANGES}
|
||||
|
||||
## Testing Performed
|
||||
- ✅ Full test suite (unit, component)
|
||||
- ✅ Full test suite (unit, component, browser)
|
||||
- ✅ TypeScript compilation
|
||||
- ✅ Linting checks
|
||||
- ✅ Build verification
|
||||
@@ -270,11 +295,15 @@ echo "Workflow triggered. Waiting for PR creation..."
|
||||
```
|
||||
4. Update PR with enhanced description:
|
||||
```bash
|
||||
gh pr edit ${PR_NUMBER} --body-file pr-body.md
|
||||
gh pr edit ${PR_NUMBER} --body-file enhanced-pr-description.md
|
||||
```
|
||||
5. **PR REVIEW**: Version bump PR created and enhanced correctly?
|
||||
5. Add changelog as comment for easy reference:
|
||||
```bash
|
||||
gh pr comment ${PR_NUMBER} --body-file CURRENT_RELEASE_NOTES.md
|
||||
```
|
||||
6. **PR REVIEW**: Version bump PR created and enhanced correctly?
|
||||
|
||||
### Step 9: Critical Release PR Verification
|
||||
### Step 11: Critical Release PR Verification
|
||||
|
||||
1. **CRITICAL**: Verify PR has "Release" label:
|
||||
```bash
|
||||
@@ -296,7 +325,7 @@ echo "Workflow triggered. Waiting for PR creation..."
|
||||
```
|
||||
7. **FINAL CODE REVIEW**: Release label present and no [skip ci]?
|
||||
|
||||
### Step 10: Pre-Merge Validation
|
||||
### Step 12: Pre-Merge Validation
|
||||
|
||||
1. **Review Requirements**: Release PRs require approval
|
||||
2. Monitor CI checks - watch for update-locales
|
||||
@@ -304,7 +333,7 @@ echo "Workflow triggered. Waiting for PR creation..."
|
||||
4. Check no new commits to main since PR creation
|
||||
5. **DEPLOYMENT READINESS**: Ready to merge?
|
||||
|
||||
### Step 11: Execute Release
|
||||
### Step 13: Execute Release
|
||||
|
||||
1. **FINAL CONFIRMATION**: Merge PR to trigger release?
|
||||
2. Merge the Release PR:
|
||||
@@ -329,7 +358,7 @@ echo "Workflow triggered. Waiting for PR creation..."
|
||||
gh run watch ${WORKFLOW_RUN_ID}
|
||||
```
|
||||
|
||||
### Step 12: Enhance GitHub Release
|
||||
### Step 14: Enhance GitHub Release
|
||||
|
||||
1. Wait for automatic release creation:
|
||||
```bash
|
||||
@@ -342,10 +371,10 @@ echo "Workflow triggered. Waiting for PR creation..."
|
||||
|
||||
2. **Enhance the GitHub release:**
|
||||
```bash
|
||||
# Update release with our release notes
|
||||
# Update release with our enhanced notes
|
||||
gh release edit v${NEW_VERSION} \
|
||||
--title "🚀 ComfyUI Frontend v${NEW_VERSION}" \
|
||||
--notes-file release-notes-${NEW_VERSION}.md \
|
||||
--notes-file github-release-notes-${NEW_VERSION}.md \
|
||||
--latest
|
||||
|
||||
# Add any additional assets if needed
|
||||
@@ -357,7 +386,7 @@ echo "Workflow triggered. Waiting for PR creation..."
|
||||
gh release view v${NEW_VERSION}
|
||||
```
|
||||
|
||||
### Step 13: Verify Multi-Channel Distribution
|
||||
### Step 15: Verify Multi-Channel Distribution
|
||||
|
||||
1. **GitHub Release:**
|
||||
```bash
|
||||
@@ -395,7 +424,7 @@ echo "Workflow triggered. Waiting for PR creation..."
|
||||
|
||||
4. **DISTRIBUTION VERIFICATION**: All channels published successfully?
|
||||
|
||||
### Step 14: Post-Release Monitoring Setup
|
||||
### Step 16: Post-Release Monitoring Setup
|
||||
|
||||
1. **Monitor immediate release health:**
|
||||
```bash
|
||||
@@ -463,7 +492,8 @@ echo "Workflow triggered. Waiting for PR creation..."
|
||||
- Plan next release cycle
|
||||
|
||||
## Files Generated
|
||||
- \`release-notes-${NEW_VERSION}.md\` - Comprehensive release notes
|
||||
- \`release-notes-${NEW_VERSION}-$(date +%Y%m%d).md\` - Detailed changelog
|
||||
- \`github-release-notes-${NEW_VERSION}.md\` - GitHub release notes
|
||||
- \`post-release-checklist.md\` - Follow-up tasks
|
||||
EOF
|
||||
```
|
||||
@@ -514,7 +544,7 @@ echo "- GitHub: Update release with warning notes"
|
||||
The command implements multiple quality gates:
|
||||
|
||||
1. **🔒 Security Gate**: Vulnerability scanning, secret detection
|
||||
2. **🧪 Quality Gate**: Unit and component tests, linting, type checking
|
||||
2. **🧪 Quality Gate**: Full test suite, linting, type checking
|
||||
3. **📋 Content Gate**: Changelog accuracy, release notes quality
|
||||
4. **🔄 Process Gate**: Release timing verification
|
||||
5. **✅ Verification Gate**: Multi-channel publishing confirmation
|
||||
|
||||
17
.github/workflows/claude-pr-review.yml
vendored
@@ -4,8 +4,6 @@ permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
statuses: write
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -22,7 +20,7 @@ jobs:
|
||||
uses: lewagon/wait-on-check-action@v1.3.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
check-regexp: '^(eslint|prettier|test|playwright-tests)'
|
||||
check-regexp: '^(ESLint|Prettier Check|Tests CI|Vitest Tests)'
|
||||
wait-interval: 30
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -30,7 +28,7 @@ jobs:
|
||||
id: check-status
|
||||
run: |
|
||||
# Get all check runs for this commit
|
||||
CHECK_RUNS=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs --jq '.check_runs[] | select(.name | test("eslint|prettier|test|playwright-tests")) | {name, conclusion}')
|
||||
CHECK_RUNS=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs --jq '.check_runs[] | select(.name | test("ESLint|Prettier Check|Tests CI|Vitest Tests")) | {name, conclusion}')
|
||||
|
||||
# Check if any required checks failed
|
||||
if echo "$CHECK_RUNS" | grep -q '"conclusion": "failure"'; then
|
||||
@@ -65,17 +63,10 @@ jobs:
|
||||
- name: Run Claude PR Review
|
||||
uses: anthropics/claude-code-action@main
|
||||
with:
|
||||
label_trigger: "claude-review"
|
||||
direct_prompt: |
|
||||
Read the file .claude/commands/comprehensive-pr-review.md and follow ALL the instructions exactly.
|
||||
|
||||
CRITICAL: You must post individual inline comments using the gh api commands shown in the file.
|
||||
DO NOT create a summary comment.
|
||||
Each issue must be posted as a separate inline comment on the specific line of code.
|
||||
prompt_file: .claude/commands/comprehensive-pr-review.md
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
max_turns: 256
|
||||
max_turns: 1
|
||||
timeout_minutes: 30
|
||||
allowed_tools: "Bash(git:*),Bash(gh api:*),Bash(gh pr:*),Bash(gh repo:*),Bash(jq:*),Bash(echo:*),Read,Write,Edit,Glob,Grep,WebFetch"
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
27
.github/workflows/danger.yaml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Danger PR Review
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize]
|
||||
|
||||
jobs:
|
||||
danger:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run Danger
|
||||
run: npx danger ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DANGER_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
154
.github/workflows/pr-checks.yml
vendored
@@ -1,154 +0,0 @@
|
||||
name: PR Checks
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_run: ${{ steps.check-changes.outputs.should_run }}
|
||||
has_browser_tests: ${{ steps.check-coverage.outputs.has_browser_tests }}
|
||||
has_screen_recording: ${{ steps.check-recording.outputs.has_recording }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Ensure base branch is available
|
||||
run: |
|
||||
# Fetch the specific base commit to ensure it's available for git diff
|
||||
git fetch origin ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: Check if significant changes exist
|
||||
id: check-changes
|
||||
run: |
|
||||
# Get list of changed files
|
||||
CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }})
|
||||
|
||||
# Filter for src/ files
|
||||
SRC_FILES=$(echo "$CHANGED_FILES" | grep '^src/' || true)
|
||||
|
||||
if [ -z "$SRC_FILES" ]; then
|
||||
echo "No src/ files changed"
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Count lines changed in src files
|
||||
TOTAL_LINES=0
|
||||
for file in $SRC_FILES; do
|
||||
if [ -f "$file" ]; then
|
||||
# Count added lines (non-empty)
|
||||
ADDED=$(git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} -- "$file" | grep '^+' | grep -v '^+++' | grep -v '^+$' | wc -l)
|
||||
# Count removed lines (non-empty)
|
||||
REMOVED=$(git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} -- "$file" | grep '^-' | grep -v '^---' | grep -v '^-$' | wc -l)
|
||||
TOTAL_LINES=$((TOTAL_LINES + ADDED + REMOVED))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Total lines changed in src/: $TOTAL_LINES"
|
||||
|
||||
if [ $TOTAL_LINES -gt 3 ]; then
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check browser test coverage
|
||||
id: check-coverage
|
||||
if: steps.check-changes.outputs.should_run == 'true'
|
||||
run: |
|
||||
# Check if browser tests were updated
|
||||
BROWSER_TEST_CHANGES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} | grep '^browser_tests/.*\.ts$' || true)
|
||||
|
||||
if [ -n "$BROWSER_TEST_CHANGES" ]; then
|
||||
echo "has_browser_tests=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_browser_tests=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check for screen recording
|
||||
id: check-recording
|
||||
if: steps.check-changes.outputs.should_run == 'true'
|
||||
env:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
# Check PR body for screen recording
|
||||
# Check for GitHub user attachments or YouTube links
|
||||
if echo "$PR_BODY" | grep -qiE 'github\.com/user-attachments/assets/[a-f0-9-]+|youtube\.com/watch|youtu\.be/'; then
|
||||
echo "has_recording=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_recording=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Final check and create results
|
||||
id: final-check
|
||||
if: always()
|
||||
run: |
|
||||
# Initialize results
|
||||
WARNINGS_JSON=""
|
||||
|
||||
# Only run checks if should_run is true
|
||||
if [ "${{ steps.check-changes.outputs.should_run }}" == "true" ]; then
|
||||
# Check browser test coverage
|
||||
if [ "${{ steps.check-coverage.outputs.has_browser_tests }}" != "true" ]; then
|
||||
if [ -n "$WARNINGS_JSON" ]; then
|
||||
WARNINGS_JSON="${WARNINGS_JSON},"
|
||||
fi
|
||||
WARNINGS_JSON="${WARNINGS_JSON}{\"message\":\"⚠️ **Warning: E2E Test Coverage Missing**\\n\\nIf this PR modifies behavior that can be covered by browser-based E2E tests, those tests are required. PRs lacking applicable test coverage may not be reviewed until added. Please add or update browser tests to ensure code quality and prevent regressions.\"}"
|
||||
fi
|
||||
|
||||
# Check screen recording
|
||||
if [ "${{ steps.check-recording.outputs.has_recording }}" != "true" ]; then
|
||||
if [ -n "$WARNINGS_JSON" ]; then
|
||||
WARNINGS_JSON="${WARNINGS_JSON},"
|
||||
fi
|
||||
WARNINGS_JSON="${WARNINGS_JSON}{\"message\":\"⚠️ **Warning: Visual Documentation Missing**\\n\\nIf this PR changes user-facing behavior, visual proof (screen recording or screenshot) is required. PRs without applicable visual documentation may not be reviewed until provided.\\nYou can add it by:\\n\\n- GitHub: Drag & drop media directly into the PR description\\n\\n- YouTube: Include a link to a short demo\"}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create results JSON
|
||||
if [ -n "$WARNINGS_JSON" ]; then
|
||||
# Create JSON with warnings
|
||||
cat > pr-check-results.json << EOF
|
||||
{
|
||||
"fails": [],
|
||||
"warnings": [$WARNINGS_JSON],
|
||||
"messages": [],
|
||||
"markdowns": []
|
||||
}
|
||||
EOF
|
||||
echo "failed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Create JSON with success
|
||||
cat > pr-check-results.json << 'EOF'
|
||||
{
|
||||
"fails": [],
|
||||
"warnings": [],
|
||||
"messages": [],
|
||||
"markdowns": []
|
||||
}
|
||||
EOF
|
||||
echo "failed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Write PR metadata
|
||||
echo "${{ github.event.pull_request.number }}" > pr-number.txt
|
||||
echo "${{ github.event.pull_request.head.sha }}" > pr-sha.txt
|
||||
|
||||
- name: Upload results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: pr-check-results-${{ github.run_id }}
|
||||
path: |
|
||||
pr-check-results.json
|
||||
pr-number.txt
|
||||
pr-sha.txt
|
||||
retention-days: 1
|
||||
149
.github/workflows/pr-comment.yml
vendored
@@ -1,149 +0,0 @@
|
||||
name: PR Comment
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["PR Checks"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-check-results-${{ github.event.workflow_run.id }}
|
||||
path: /tmp/pr-artifacts
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
- name: Post results
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Helper function to safely read files
|
||||
function safeReadFile(filePath) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return fs.readFileSync(filePath, 'utf8').trim();
|
||||
} catch (e) {
|
||||
console.error(`Error reading ${filePath}:`, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Read artifact files
|
||||
const artifactDir = '/tmp/pr-artifacts';
|
||||
const prNumber = safeReadFile(path.join(artifactDir, 'pr-number.txt'));
|
||||
const prSha = safeReadFile(path.join(artifactDir, 'pr-sha.txt'));
|
||||
const resultsJson = safeReadFile(path.join(artifactDir, 'pr-check-results.json'));
|
||||
|
||||
// Validate PR number
|
||||
if (!prNumber || isNaN(parseInt(prNumber))) {
|
||||
throw new Error('Invalid or missing PR number');
|
||||
}
|
||||
|
||||
// Parse and validate results
|
||||
let results;
|
||||
try {
|
||||
results = JSON.parse(resultsJson || '{}');
|
||||
} catch (e) {
|
||||
console.error('Failed to parse check results:', e);
|
||||
|
||||
// Post error comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: parseInt(prNumber),
|
||||
body: `⚠️ PR checks failed to complete properly. Error parsing results: ${e.message}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Format check messages
|
||||
const messages = [];
|
||||
|
||||
if (results.fails && results.fails.length > 0) {
|
||||
messages.push('### ❌ Failures\n' + results.fails.map(f => f.message).join('\n\n'));
|
||||
}
|
||||
|
||||
if (results.warnings && results.warnings.length > 0) {
|
||||
messages.push('### ⚠️ Warnings\n' + results.warnings.map(w => w.message).join('\n\n'));
|
||||
}
|
||||
|
||||
if (results.messages && results.messages.length > 0) {
|
||||
messages.push('### 💬 Messages\n' + results.messages.map(m => m.message).join('\n\n'));
|
||||
}
|
||||
|
||||
if (results.markdowns && results.markdowns.length > 0) {
|
||||
messages.push(...results.markdowns.map(m => m.message));
|
||||
}
|
||||
|
||||
// Find existing bot comment
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: parseInt(prNumber)
|
||||
});
|
||||
|
||||
const botComment = comments.data.find(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes('<!-- pr-checks-comment -->')
|
||||
);
|
||||
|
||||
// Post comment if there are any messages
|
||||
if (messages.length > 0) {
|
||||
const body = messages.join('\n\n');
|
||||
const commentBody = `<!-- pr-checks-comment -->\n${body}`;
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: commentBody
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: parseInt(prNumber),
|
||||
body: commentBody
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No messages - delete existing comment if present
|
||||
if (botComment) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set commit status based on failures
|
||||
if (prSha) {
|
||||
const hasFailures = results.fails && results.fails.length > 0;
|
||||
const hasWarnings = results.warnings && results.warnings.length > 0;
|
||||
await github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha: prSha,
|
||||
state: hasFailures ? 'failure' : 'success',
|
||||
context: 'pr-checks',
|
||||
description: hasFailures
|
||||
? `${results.fails.length} check(s) failed`
|
||||
: hasWarnings
|
||||
? `${results.warnings.length} warning(s)`
|
||||
: 'All checks passed'
|
||||
});
|
||||
}
|
||||
4
.github/workflows/test-ui.yaml
vendored
@@ -88,7 +88,7 @@ jobs:
|
||||
|
||||
- name: Start ComfyUI server
|
||||
run: |
|
||||
python main.py --cpu --multi-user --front-end-root ../ComfyUI_frontend/dist &
|
||||
python main.py --cpu --multi-user --cache-none --front-end-root ../ComfyUI_frontend/dist &
|
||||
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||
working-directory: ComfyUI
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- name: Run Playwright tests (${{ matrix.browser }})
|
||||
run: npx playwright test --project=${{ matrix.browser }}
|
||||
run: npx playwright test --project=${{ matrix.browser }} --workers=1
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -2,133 +2,82 @@
|
||||
|
||||
This document outlines the setup, usage, and common patterns for Playwright browser tests in the ComfyUI_frontend project.
|
||||
|
||||
## Prerequisites
|
||||
## WARNING
|
||||
|
||||
**CRITICAL**: Start ComfyUI backend with `--multi-user` flag:
|
||||
|
||||
```bash
|
||||
python main.py --multi-user
|
||||
```
|
||||
|
||||
Without this flag, parallel tests will conflict and fail randomly.
|
||||
The browser tests will change the ComfyUI backend state, such as user settings and saved workflows.
|
||||
If `TEST_COMFYUI_DIR` in `.env` isn't set to your `(Comfy Path)/ComfyUI` directory, these changes won't be automatically restored.
|
||||
|
||||
## Setup
|
||||
|
||||
### ComfyUI devtools
|
||||
|
||||
Clone <https://github.com/Comfy-Org/ComfyUI_devtools> to your `custom_nodes` directory.
|
||||
_ComfyUI_devtools adds additional API endpoints and nodes to ComfyUI for browser testing._
|
||||
|
||||
### Node.js & Playwright Prerequisites
|
||||
|
||||
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
|
||||
|
||||
```bash
|
||||
npx playwright install chromium --with-deps
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
### Environment Variables
|
||||
Ensure the environment variables in `.env` are set correctly according to your setup.
|
||||
|
||||
Create `.env` from the template:
|
||||
The `.env` file will not exist until you create it yourself.
|
||||
|
||||
A template with helpful information can be found in `.env_example`.
|
||||
|
||||
### Running ComfyUI Backend
|
||||
When running browser tests, ComfyUI must be started with the `--cache-none` argument to disable execution caching:
|
||||
```bash
|
||||
cp .env_example .env
|
||||
python main.py --cache-none
|
||||
```
|
||||
|
||||
Key settings for debugging:
|
||||
|
||||
```bash
|
||||
# Remove Vue dev overlay that blocks UI elements
|
||||
DISABLE_VUE_PLUGINS=true
|
||||
|
||||
# Test against dev server (recommended) or backend directly
|
||||
PLAYWRIGHT_TEST_URL=http://localhost:5173 # Dev server
|
||||
# PLAYWRIGHT_TEST_URL=http://localhost:8188 # Direct backend
|
||||
|
||||
# Path to ComfyUI for backing up user data/settings before tests
|
||||
TEST_COMFYUI_DIR=/path/to/your/ComfyUI
|
||||
```
|
||||
|
||||
### Common Setup Issues
|
||||
|
||||
**Most tests require the new menu system** - Add to your test:
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
```
|
||||
### Multiple Tests
|
||||
If you are running Playwright tests in parallel or running the same test multiple times, the flag `--multi-user` must be added to the main ComfyUI process.
|
||||
|
||||
### Release API Mocking
|
||||
|
||||
By default, all tests mock the release API (`api.comfy.org/releases`) to prevent release notification popups from interfering with test execution. This is necessary because the release notifications can appear over UI elements and block test interactions.
|
||||
|
||||
To test with real release data, you can disable mocking:
|
||||
|
||||
```typescript
|
||||
await comfyPage.setup({ mockReleases: false })
|
||||
await comfyPage.setup({ mockReleases: false });
|
||||
```
|
||||
|
||||
For tests that specifically need to test release functionality, see the example in `tests/releaseNotifications.spec.ts`.
|
||||
|
||||
## Running Tests
|
||||
|
||||
**Always use UI mode for development:**
|
||||
There are multiple ways to run the tests:
|
||||
|
||||
```bash
|
||||
npx playwright test --ui
|
||||
```
|
||||
1. **Headless mode with report generation:**
|
||||
```bash
|
||||
npx playwright test
|
||||
```
|
||||
This runs all tests without a visible browser and generates a comprehensive test report.
|
||||
|
||||
UI mode features:
|
||||
2. **UI mode for interactive testing:**
|
||||
```bash
|
||||
npx playwright test --ui
|
||||
```
|
||||
This opens a user interface where you can select specific tests to run and inspect the test execution timeline.
|
||||
|
||||
- **Locator picker**: Click the target icon, then click any element to get the exact locator code to use in your test. The code appears in the _Locator_ tab.
|
||||
- **Step debugging**: Step through your test line-by-line by clicking _Source_ tab
|
||||
- **Time travel**: In the _Actions_ tab/panel, click any step to see the browser state at that moment
|
||||
- **Console/Network Tabs**: View logs and API calls at each step
|
||||
- **Attachments Tab**: View all snapshots with expected and actual images
|
||||

|
||||
|
||||

|
||||
|
||||
For CI or headless testing:
|
||||
|
||||
```bash
|
||||
npx playwright test # Run all tests
|
||||
npx playwright test widget.spec.ts # Run specific test file
|
||||
```
|
||||
|
||||
### Local Development Config
|
||||
|
||||
For debugging, you can try adjusting these settings in `playwright.config.ts`:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
// VERY HELPFUL: Skip screenshot tests locally
|
||||
grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/
|
||||
|
||||
retries: 0, // No retries while debugging. Increase if writing new tests. that may be flaky.
|
||||
workers: 1, // Single worker for easier debugging. Increase to match CPU cores if you want to run a lot of tests in parallel.
|
||||
timeout: 30000, // Longer timeout for breakpoints
|
||||
|
||||
use: {
|
||||
trace: 'on', // Always capture traces (CI uses 'on-first-retry')
|
||||
video: 'on' // Always record video (CI uses 'retain-on-failure')
|
||||
},
|
||||
|
||||
})
|
||||
```
|
||||
3. **Running specific tests:**
|
||||
```bash
|
||||
npx playwright test widget.spec.ts
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
Browser tests in this project follow a specific organization pattern:
|
||||
|
||||
- **Fixtures**: Located in `fixtures/` - These provide test setup and utilities
|
||||
|
||||
- `ComfyPage.ts` - The main fixture for interacting with ComfyUI
|
||||
- `ComfyMouse.ts` - Utility for mouse interactions with the canvas
|
||||
- Components fixtures in `fixtures/components/` - Page object models for UI components
|
||||
|
||||
- **Tests**: Located in `tests/` - The actual test specifications
|
||||
|
||||
- Organized by functionality (e.g., `widget.spec.ts`, `interaction.spec.ts`)
|
||||
- Snapshot directories (e.g., `widget.spec.ts-snapshots/`) contain reference screenshots
|
||||
|
||||
@@ -143,18 +92,18 @@ When writing new tests, follow these patterns:
|
||||
|
||||
```typescript
|
||||
// Import the test fixture
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage';
|
||||
|
||||
test.describe('Feature Name', () => {
|
||||
// Set up test environment if needed
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Common setup
|
||||
})
|
||||
});
|
||||
|
||||
test('should do something specific', async ({ comfyPage }) => {
|
||||
// Test implementation
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Leverage Existing Fixtures and Helpers
|
||||
@@ -176,102 +125,66 @@ Most common testing needs are already addressed by these helpers, which will mak
|
||||
|
||||
1. **Focus elements explicitly**:
|
||||
Canvas-based elements often need explicit focus before interaction:
|
||||
|
||||
```typescript
|
||||
// Click the canvas first to focus it before pressing keys
|
||||
await comfyPage.canvas.click()
|
||||
await comfyPage.page.keyboard.press('a')
|
||||
await comfyPage.canvas.click();
|
||||
await comfyPage.page.keyboard.press('a');
|
||||
```
|
||||
|
||||
2. **Mark canvas as dirty if needed**:
|
||||
Some interactions need explicit canvas updates:
|
||||
|
||||
```typescript
|
||||
// After programmatically changing node state, mark canvas dirty
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].graph.setDirtyCanvas(true, true)
|
||||
})
|
||||
window['app'].graph.setDirtyCanvas(true, true);
|
||||
});
|
||||
```
|
||||
|
||||
3. **Use node references over coordinates**:
|
||||
3. **Use node references over coordinates**:
|
||||
Node references from `fixtures/utils/litegraphUtils.ts` provide stable ways to interact with nodes:
|
||||
|
||||
```typescript
|
||||
// Prefer this:
|
||||
const node = await comfyPage.getNodeRefsByType('LoadImage')[0]
|
||||
await node.click('title')
|
||||
|
||||
const node = await comfyPage.getNodeRefsByType('LoadImage')[0];
|
||||
await node.click('title');
|
||||
|
||||
// Over this:
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } });
|
||||
```
|
||||
|
||||
4. **Wait for canvas to render after UI interactions**:
|
||||
|
||||
```typescript
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame();
|
||||
```
|
||||
|
||||
5. **Clean up persistent server state**:
|
||||
While most state is reset between tests, anything stored on the server persists:
|
||||
|
||||
```typescript
|
||||
// Reset settings that affect other tests (these are stored on server)
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
|
||||
await comfyPage.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', 'None')
|
||||
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'dark');
|
||||
await comfyPage.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', 'None');
|
||||
|
||||
// Clean up uploaded files if needed
|
||||
await comfyPage.request.delete(`${comfyPage.url}/api/delete/image.png`)
|
||||
await comfyPage.request.delete(`${comfyPage.url}/api/delete/image.png`);
|
||||
```
|
||||
|
||||
6. **Prefer functional assertions over screenshots**:
|
||||
Use screenshots only when visual verification is necessary:
|
||||
|
||||
```typescript
|
||||
// Prefer this:
|
||||
expect(await node.isPinned()).toBe(true)
|
||||
expect(await node.getProperty('title')).toBe('Expected Title')
|
||||
|
||||
expect(await node.isPinned()).toBe(true);
|
||||
expect(await node.getProperty('title')).toBe('Expected Title');
|
||||
|
||||
// Over this - only use when needed:
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('state.png')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('state.png');
|
||||
```
|
||||
|
||||
7. **Use minimal test workflows**:
|
||||
When creating test workflows, keep them as minimal as possible:
|
||||
|
||||
```typescript
|
||||
// Include only the components needed for the test
|
||||
await comfyPage.loadWorkflow('single_ksampler')
|
||||
await comfyPage.loadWorkflow('single_ksampler');
|
||||
```
|
||||
|
||||
8. **Debug helpers for visual debugging** (remove before committing):
|
||||
|
||||
ComfyPage includes temporary debug methods for troubleshooting:
|
||||
|
||||
```typescript
|
||||
test('debug failing interaction', async ({ comfyPage }, testInfo) => {
|
||||
// Add visual markers to see click positions
|
||||
await comfyPage.debugAddMarker({ x: 100, y: 200 })
|
||||
|
||||
// Attach screenshot with markers to test report
|
||||
await comfyPage.debugAttachScreenshot(testInfo, 'node-positions', {
|
||||
element: 'canvas',
|
||||
markers: [{ position: { x: 100, y: 200 } }]
|
||||
})
|
||||
|
||||
// Show canvas overlay for easier debugging
|
||||
await comfyPage.debugShowCanvasOverlay()
|
||||
|
||||
// Remember to remove debug code before committing!
|
||||
})
|
||||
```
|
||||
|
||||
Available debug methods:
|
||||
|
||||
- `debugAddMarker(position)` - Red circle at position
|
||||
- `debugAttachScreenshot(testInfo, name)` - Attach to test report
|
||||
- `debugShowCanvasOverlay()` - Show canvas as overlay
|
||||
- `debugGetCanvasDataURL()` - Get canvas as base64
|
||||
|
||||
## Common Patterns and Utilities
|
||||
|
||||
### Page Object Pattern
|
||||
@@ -285,7 +198,7 @@ test('Can toggle boolean widget', async ({ comfyPage }) => {
|
||||
const node = (await comfyPage.getFirstNodeRef())!
|
||||
const widget = await node.getWidget(0)
|
||||
await widget.click()
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
### Node References
|
||||
@@ -325,8 +238,8 @@ Canvas operations use special helpers to ensure proper timing:
|
||||
```typescript
|
||||
// Using ComfyMouse for drag and drop
|
||||
await comfyMouse.dragAndDrop(
|
||||
{ x: 100, y: 100 }, // From
|
||||
{ x: 200, y: 200 } // To
|
||||
{ x: 100, y: 100 }, // From
|
||||
{ x: 200, y: 200 } // To
|
||||
)
|
||||
|
||||
// Standard ComfyPage helpers
|
||||
@@ -368,52 +281,21 @@ await expect(node).toBeCollapsed()
|
||||
- **Screenshots vary**: Ensure your OS and browser match the reference environment (Linux)
|
||||
- **Async / await**: Race conditions are a very common cause of test flakiness
|
||||
|
||||
## Screenshot Testing
|
||||
## Screenshot Expectations
|
||||
|
||||
Due to variations in system font rendering, screenshot expectations are platform-specific. Please note:
|
||||
|
||||
- **Do not commit local screenshot expectations** to the repository
|
||||
- **DO NOT commit local screenshot expectations** to the repository
|
||||
- We maintain Linux screenshot expectations as our GitHub Action runner operates in a Linux environment
|
||||
- While developing, you can generate local screenshots for your tests, but these will differ from CI-generated ones
|
||||
|
||||
### Working with Screenshots Locally
|
||||
To set new test expectations for PR:
|
||||
|
||||
Option 1 - Skip screenshot tests (add to `playwright.config.ts`):
|
||||
1. Write your test with screenshot assertions using `toHaveScreenshot(filename)`
|
||||
2. Create a pull request from a `Comfy-Org/ComfyUI_frontend` branch
|
||||
3. Add the `New Browser Test Expectation` tag to your pull request
|
||||
4. The GitHub CI will automatically generate and commit the reference screenshots
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/
|
||||
})
|
||||
```
|
||||
This approach ensures consistent screenshot expectations across all PRs and avoids issues with platform-specific rendering.
|
||||
|
||||
Option 2 - Generate local baselines for comparison:
|
||||
|
||||
```bash
|
||||
npx playwright test --update-snapshots
|
||||
```
|
||||
|
||||
### Getting Test Artifacts from GitHub Actions
|
||||
|
||||
When tests fail in CI, you can download screenshots and traces:
|
||||
|
||||
1. Go to the failed workflow run in GitHub Actions
|
||||
2. Scroll to "Artifacts" section at the bottom
|
||||
3. Download `playwright-report` or `test-results`
|
||||
4. Extract and open the HTML report locally
|
||||
5. View actual vs expected screenshots and execution traces
|
||||
|
||||
### Creating New Screenshot Baselines
|
||||
|
||||
For PRs from `Comfy-Org/ComfyUI_frontend` branches:
|
||||
|
||||
1. Write test with `toHaveScreenshot('filename.png')`
|
||||
2. Create PR and add `New Browser Test Expectation` label
|
||||
3. CI will generate and commit the Linux baseline screenshots
|
||||
|
||||
> **Note:** Fork PRs cannot auto-commit screenshots. A maintainer will need to commit the screenshots manually for you (don't worry, they'll do it).
|
||||
|
||||
## Resources
|
||||
|
||||
- [Playwright UI Mode](https://playwright.dev/docs/test-ui-mode) - Interactive test debugging
|
||||
- [Playwright Debugging Guide](https://playwright.dev/docs/debug)
|
||||
- [act](https://github.com/nektos/act) - Run GitHub Actions locally for CI debugging
|
||||
> **Note:** If you're making a pull request from a forked repository, the GitHub action won't be able to commit updated screenshot expectations directly to your PR branch.
|
||||
@@ -1,244 +0,0 @@
|
||||
{
|
||||
"id": "fe4562c0-3a0b-4614-bdec-7039a58d75b8",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"pos": [
|
||||
627.5973510742188,
|
||||
423.0972900390625
|
||||
],
|
||||
"size": [
|
||||
144.15234375,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 4,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [
|
||||
347.90441582814213,
|
||||
417.3822440655296,
|
||||
120,
|
||||
60
|
||||
]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [
|
||||
892.5973510742188,
|
||||
416.0972900390625,
|
||||
120,
|
||||
60
|
||||
]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [
|
||||
1
|
||||
],
|
||||
"pos": {
|
||||
"0": 447.9044189453125,
|
||||
"1": 437.3822326660156
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [
|
||||
2
|
||||
],
|
||||
"pos": {
|
||||
"0": 912.5973510742188,
|
||||
"1": 436.0972900390625
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
554.8743286132812,
|
||||
100.95539093017578
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"simple",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "VAEEncode",
|
||||
"pos": [
|
||||
685.1265869140625,
|
||||
439.1734619140625
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "pixels",
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncode"
|
||||
}
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8894351682943402,
|
||||
"offset": [
|
||||
58.7671207025881,
|
||||
137.7124650620126
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
293
browser_tests/assets/execution/nested-subgraph-test.json
Normal file
@@ -0,0 +1,293 @@
|
||||
{
|
||||
"id": "test-subgraph-workflow",
|
||||
"revision": 0,
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [1], "slot_index": 0 },
|
||||
{ "name": "MASK", "type": "MASK", "links": null, "slot_index": 1 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "LoadImage" },
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "test-subgraph-1",
|
||||
"pos": [400, 200],
|
||||
"size": [200, 80],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "link": 1 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [9] }
|
||||
],
|
||||
"title": "Test Subgraph",
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "SaveImage",
|
||||
"pos": [700, 200],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "images", "type": "IMAGE", "link": 9 }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 10, 0, "IMAGE"],
|
||||
[9, 10, 0, 11, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "test-subgraph-1",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 7,
|
||||
"lastLinkId": 6,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Test Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [-154, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [800, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "input-1",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [1],
|
||||
"localized_name": "IMAGE",
|
||||
"pos": {
|
||||
"0": -134,
|
||||
"1": 220
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "output-1",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [5],
|
||||
"localized_name": "IMAGE",
|
||||
"pos": {
|
||||
"0": 820,
|
||||
"1": 220
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "TestSleep",
|
||||
"pos": [100, 200],
|
||||
"size": [210, 86],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "value", "type": "IMAGE", "link": 1 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [2], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "TestSleep" },
|
||||
"widgets_values": [2.0]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "test-subgraph-2",
|
||||
"pos": [350, 200],
|
||||
"size": [200, 80],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "link": 2 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [5] }
|
||||
],
|
||||
"title": "Nested Test Subgraph",
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": 5,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"origin_id": 5,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "test-subgraph-2",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 7,
|
||||
"lastLinkId": 4,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Nested Test Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [-154, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [600, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "input-1",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [1],
|
||||
"localized_name": "IMAGE",
|
||||
"pos": {
|
||||
"0": -134,
|
||||
"1": 220
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "output-1",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [4],
|
||||
"localized_name": "IMAGE",
|
||||
"pos": {
|
||||
"0": 620,
|
||||
"1": 220
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 6,
|
||||
"type": "TestSleep",
|
||||
"pos": [100, 150],
|
||||
"size": [210, 86],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "value", "type": "IMAGE", "link": 1 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [2], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "TestSleep" },
|
||||
"widgets_values": [2.0]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "TestAsyncProgressNode",
|
||||
"pos": [350, 150],
|
||||
"size": [210, 126],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "value", "type": "IMAGE", "link": 2 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [4], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "TestAsyncProgressNode" },
|
||||
"widgets_values": [3.0, 10]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 6,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": 7,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": 7,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
139
browser_tests/assets/execution/parallel_async_nodes.json
Normal file
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [26, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [1, 2, 3], "slot_index": 0 },
|
||||
{ "name": "MASK", "type": "MASK", "links": null, "slot_index": 1 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "LoadImage" },
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "TestSleep",
|
||||
"pos": [400, 100],
|
||||
"size": [210, 86],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "value", "type": "IMAGE", "link": 1 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [4], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "TestSleep" },
|
||||
"widgets_values": [2.0]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "TestSleep",
|
||||
"pos": [400, 250],
|
||||
"size": [210, 86],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "value", "type": "IMAGE", "link": 2 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [5], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "TestSleep" },
|
||||
"widgets_values": [2.5]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "TestAsyncProgressNode",
|
||||
"pos": [400, 400],
|
||||
"size": [210, 126],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "value", "type": "IMAGE", "link": 3 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [6], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "TestAsyncProgressNode" },
|
||||
"widgets_values": [3.0, 10]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "TestVariadicAverage",
|
||||
"pos": [700, 200],
|
||||
"size": [210, 106],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "input1", "type": "IMAGE", "link": 4 },
|
||||
{ "name": "input2", "type": "IMAGE", "link": 5 },
|
||||
{ "name": "input3", "type": "IMAGE", "link": 6 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [7], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "TestVariadicAverage" }
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "SaveImage",
|
||||
"pos": [1000, 200],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "images", "type": "IMAGE", "link": 7 }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [700, 400],
|
||||
"size": [210, 54],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "CONDITIONING", "type": "CONDITIONING", "links": [], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "CLIPTextEncode" },
|
||||
"widgets_values": ["test"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "IMAGE"],
|
||||
[2, 1, 0, 3, 0, "IMAGE"],
|
||||
[3, 1, 0, 4, 0, "IMAGE"],
|
||||
[4, 2, 0, 5, 0, "IMAGE"],
|
||||
[5, 3, 0, 5, 1, "IMAGE"],
|
||||
[6, 4, 0, 5, 2, "IMAGE"],
|
||||
[7, 5, 0, 6, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
193
browser_tests/assets/execution/parallel_ksamplers.json
Normal file
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 13,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [26, 474],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": [1, 10], "slot_index": 0 },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": [2, 3], "slot_index": 1 },
|
||||
{ "name": "VAE", "type": "VAE", "links": [8, 11], "slot_index": 2 }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [415, 186],
|
||||
"size": [422.84503173828125, 164.31304931640625],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "clip", "type": "CLIP", "link": 2 }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [4, 12],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [413, 389],
|
||||
"size": [425.27801513671875, 180.6060791015625],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "clip", "type": "CLIP", "link": 3 }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [5, 13],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [473, 609],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [6, 14], "slot_index": 0 }],
|
||||
"properties": {},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "KSampler",
|
||||
"pos": [863, 186],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": 1 },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": 4 },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": 5 },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": 6 }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0 }],
|
||||
"properties": {},
|
||||
"widgets_values": [156680208700286, true, 2, 8, "euler", "normal", 1]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1209, 188],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "samples", "type": "LATENT", "link": 7 },
|
||||
{ "name": "vae", "type": "VAE", "link": 8 }
|
||||
],
|
||||
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": [9], "slot_index": 0 }],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "SaveImage",
|
||||
"pos": [1451, 189],
|
||||
"size": [210, 26],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "images", "type": "IMAGE", "link": 9 }],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "KSampler",
|
||||
"pos": [863, 486],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": 10 },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": 12 },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": 13 },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": 14 }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [15], "slot_index": 0 }],
|
||||
"properties": {},
|
||||
"widgets_values": [156680208700287, true, 3, 8, "euler", "normal", 1]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1209, 488],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "samples", "type": "LATENT", "link": 15 },
|
||||
{ "name": "vae", "type": "VAE", "link": 11 }
|
||||
],
|
||||
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": [16], "slot_index": 0 }],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "SaveImage",
|
||||
"pos": [1451, 489],
|
||||
"size": [210, 26],
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "images", "type": "IMAGE", "link": 16 }],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 5, 0, "MODEL"],
|
||||
[2, 1, 1, 2, 0, "CLIP"],
|
||||
[3, 1, 1, 3, 0, "CLIP"],
|
||||
[4, 2, 0, 5, 1, "CONDITIONING"],
|
||||
[5, 3, 0, 5, 2, "CONDITIONING"],
|
||||
[6, 4, 0, 5, 3, "LATENT"],
|
||||
[7, 5, 0, 6, 0, "LATENT"],
|
||||
[8, 1, 2, 6, 1, "VAE"],
|
||||
[9, 6, 0, 7, 0, "IMAGE"],
|
||||
[10, 1, 0, 8, 0, "MODEL"],
|
||||
[11, 1, 2, 9, 1, "VAE"],
|
||||
[12, 2, 0, 8, 1, "CONDITIONING"],
|
||||
[13, 3, 0, 8, 2, "CONDITIONING"],
|
||||
[14, 4, 0, 8, 3, "LATENT"],
|
||||
[15, 8, 0, 9, 0, "LATENT"],
|
||||
[16, 9, 0, 10, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
{
|
||||
"id": "c4a254bb-935e-4013-b380-5e36954de4b0",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [
|
||||
791.59912109375,
|
||||
386.13336181640625
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
202
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"",
|
||||
""
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 12,
|
||||
"lastLinkId": 16,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [
|
||||
481.59912109375,
|
||||
379.13336181640625,
|
||||
120,
|
||||
160
|
||||
]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [
|
||||
1121.59912109375,
|
||||
379.13336181640625,
|
||||
128.6640625,
|
||||
60
|
||||
]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"linkIds": [
|
||||
10
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 399.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "736e5a03-0f7f-4e48-93e4-fd66ea6c30f1",
|
||||
"name": "text_1",
|
||||
"type": "STRING",
|
||||
"linkIds": [
|
||||
11
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 419.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b62e7a0b-cc7e-4ca5-a4e1-c81607a13f58",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [
|
||||
13
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 439.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "7a2628da-4879-4f82-a7d3-7b1c00db50a5",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [
|
||||
14
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 459.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "651cf4ad-e8bf-47f6-b181-8f8aeacd6669",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [
|
||||
15
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 479.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c41765ea-61ef-4a77-8cc6-74113903078f",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [
|
||||
16
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 499.13336181640625
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "55dd1505-12bd-4cb4-8e75-031a97bb4387",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [
|
||||
12
|
||||
],
|
||||
"pos": {
|
||||
"0": 1141.59912109375,
|
||||
"1": 399.13336181640625
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
661.59912109375,
|
||||
314.13336181640625
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
668.755859375,
|
||||
571.7766723632812
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": 11
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
12
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
671.7379760742188,
|
||||
1.621593713760376
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 15
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 16
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"simple",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 11,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": 11,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 12,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 12,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 12,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 5,
|
||||
"target_id": 12,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.9581355200690549,
|
||||
"offset": [
|
||||
184.687451089395,
|
||||
80.38288288288285
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
{
|
||||
"id": "c4a254bb-935e-4013-b380-5e36954de4b0",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [
|
||||
400,
|
||||
300
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
168
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [
|
||||
481.59912109375,
|
||||
379.13336181640625,
|
||||
120,
|
||||
160
|
||||
]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [
|
||||
1121.59912109375,
|
||||
379.13336181640625,
|
||||
120,
|
||||
40
|
||||
]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"linkIds": [
|
||||
10
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 399.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [
|
||||
11
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 419.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [
|
||||
12
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 439.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [
|
||||
13
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 459.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [
|
||||
14
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 479.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [
|
||||
15
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 499.13336181640625
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
661.59912109375,
|
||||
314.13336181640625
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
674.1234741210938,
|
||||
570.5839233398438
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"simple",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 11,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 11,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 5,
|
||||
"target_id": 11,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.9581355200690549,
|
||||
"offset": [
|
||||
258.6405769416877,
|
||||
147.17927927927929
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
{
|
||||
"id": "c4a254bb-935e-4013-b380-5e36954de4b0",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [
|
||||
791.59912109375,
|
||||
386.13336181640625
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
26
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 10,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [
|
||||
481.59912109375,
|
||||
379.13336181640625,
|
||||
120,
|
||||
60
|
||||
]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [
|
||||
1121.59912109375,
|
||||
379.13336181640625,
|
||||
120,
|
||||
40
|
||||
]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "79e69fca-ad12-499b-8d9b-9f1656b85354",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [
|
||||
10
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 399.13336181640625
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
661.59912109375,
|
||||
314.13336181640625
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.24.1",
|
||||
"VHS_latentpreview": false,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true,
|
||||
"ds": {
|
||||
"scale": 0.9581355200690549,
|
||||
"offset": [
|
||||
258.6405769416877,
|
||||
147.17927927927929
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB |
@@ -21,7 +21,7 @@ import {
|
||||
} from './components/SidebarTab'
|
||||
import { Topbar } from './components/Topbar'
|
||||
import type { Position, Size } from './types'
|
||||
import { NodeReference, SubgraphSlotReference } from './utils/litegraphUtils'
|
||||
import { NodeReference } from './utils/litegraphUtils'
|
||||
import TaskHistory from './utils/taskHistory'
|
||||
|
||||
dotenv.config()
|
||||
@@ -168,7 +168,7 @@ export class ComfyPage {
|
||||
this.menu = new ComfyMenu(page)
|
||||
this.actionbar = new ComfyActionbar(page)
|
||||
this.templates = new ComfyTemplates(page)
|
||||
this.settingDialog = new SettingDialog(page, this)
|
||||
this.settingDialog = new SettingDialog(page)
|
||||
this.confirmDialog = new ConfirmDialog(page)
|
||||
}
|
||||
|
||||
@@ -563,7 +563,6 @@ export class ComfyPage {
|
||||
if (fileName.endsWith('.webm')) return 'video/webm'
|
||||
if (fileName.endsWith('.json')) return 'application/json'
|
||||
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
|
||||
if (fileName.endsWith('.avif')) return 'image/avif'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
@@ -777,524 +776,11 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks on a litegraph context menu item (uses .litemenu-entry selector).
|
||||
* Use this for canvas/node context menus, not PrimeVue menus.
|
||||
*/
|
||||
async clickLitegraphContextMenuItem(name: string): Promise<void> {
|
||||
await this.page.locator(`.litemenu-entry:has-text("${name}")`).click()
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-clicks on a subgraph input slot to open the context menu.
|
||||
* Must be called when inside a subgraph.
|
||||
*
|
||||
* This method uses the actual slot positions from the subgraph.inputs array,
|
||||
* which contain the correct coordinates for each input slot. These positions
|
||||
* are different from the visual node positions and are specifically where
|
||||
* the slots are rendered on the input node.
|
||||
*
|
||||
* @param inputName Optional name of the specific input slot to target (e.g., 'text').
|
||||
* If not provided, tries all available input slots until one works.
|
||||
* @returns Promise that resolves when the context menu appears
|
||||
*/
|
||||
async rightClickSubgraphInputSlot(inputName?: string): Promise<void> {
|
||||
const foundSlot = await this.page.evaluate(async (targetInputName) => {
|
||||
const app = window['app']
|
||||
const currentGraph = app.canvas.graph
|
||||
|
||||
// Check if we're in a subgraph
|
||||
if (currentGraph.constructor.name !== 'Subgraph') {
|
||||
throw new Error(
|
||||
'Not in a subgraph - this method only works inside subgraphs'
|
||||
)
|
||||
}
|
||||
|
||||
// Get the input node
|
||||
const inputNode = currentGraph.inputNode
|
||||
if (!inputNode) {
|
||||
throw new Error('No input node found in subgraph')
|
||||
}
|
||||
|
||||
// Get available inputs
|
||||
const inputs = currentGraph.inputs
|
||||
if (!inputs || inputs.length === 0) {
|
||||
throw new Error('No input slots found in subgraph')
|
||||
}
|
||||
|
||||
// Filter to specific input if requested
|
||||
const inputsToTry = targetInputName
|
||||
? inputs.filter((inp) => inp.name === targetInputName)
|
||||
: inputs
|
||||
|
||||
if (inputsToTry.length === 0) {
|
||||
throw new Error(
|
||||
targetInputName
|
||||
? `Input slot '${targetInputName}' not found`
|
||||
: 'No input slots available to try'
|
||||
)
|
||||
}
|
||||
|
||||
// Try right-clicking on each input slot position until one works
|
||||
for (const input of inputsToTry) {
|
||||
if (!input.pos) continue
|
||||
|
||||
const testX = input.pos[0]
|
||||
const testY = input.pos[1]
|
||||
|
||||
// Create a right-click event at the input slot position
|
||||
const rightClickEvent = {
|
||||
canvasX: testX,
|
||||
canvasY: testY,
|
||||
button: 2, // Right mouse button
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
}
|
||||
|
||||
// Trigger the input node's right-click handler
|
||||
if (inputNode.onPointerDown) {
|
||||
inputNode.onPointerDown(
|
||||
rightClickEvent,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
}
|
||||
|
||||
// Wait briefly for menu to appear
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Check if litegraph context menu appeared
|
||||
const menuExists = document.querySelector('.litemenu-entry')
|
||||
if (menuExists) {
|
||||
return { success: true, inputName: input.name, x: testX, y: testY }
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false }
|
||||
}, inputName)
|
||||
|
||||
if (!foundSlot.success) {
|
||||
throw new Error(
|
||||
inputName
|
||||
? `Could not open context menu for input slot '${inputName}'`
|
||||
: 'Could not find any input slot position to right-click'
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for the litegraph context menu to be visible
|
||||
await this.page.waitForSelector('.litemenu-entry', {
|
||||
state: 'visible',
|
||||
timeout: 5000
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-clicks on a subgraph output slot to open the context menu.
|
||||
* Must be called when inside a subgraph.
|
||||
*
|
||||
* Similar to rightClickSubgraphInputSlot but for output slots.
|
||||
*
|
||||
* @param outputName Optional name of the specific output slot to target.
|
||||
* If not provided, tries all available output slots until one works.
|
||||
* @returns Promise that resolves when the context menu appears
|
||||
*/
|
||||
async rightClickSubgraphOutputSlot(outputName?: string): Promise<void> {
|
||||
const foundSlot = await this.page.evaluate(async (targetOutputName) => {
|
||||
const app = window['app']
|
||||
const currentGraph = app.canvas.graph
|
||||
|
||||
// Check if we're in a subgraph
|
||||
if (currentGraph.constructor.name !== 'Subgraph') {
|
||||
throw new Error(
|
||||
'Not in a subgraph - this method only works inside subgraphs'
|
||||
)
|
||||
}
|
||||
|
||||
// Get the output node
|
||||
const outputNode = currentGraph.outputNode
|
||||
if (!outputNode) {
|
||||
throw new Error('No output node found in subgraph')
|
||||
}
|
||||
|
||||
// Get available outputs
|
||||
const outputs = currentGraph.outputs
|
||||
if (!outputs || outputs.length === 0) {
|
||||
throw new Error('No output slots found in subgraph')
|
||||
}
|
||||
|
||||
// Filter to specific output if requested
|
||||
const outputsToTry = targetOutputName
|
||||
? outputs.filter((out) => out.name === targetOutputName)
|
||||
: outputs
|
||||
|
||||
if (outputsToTry.length === 0) {
|
||||
throw new Error(
|
||||
targetOutputName
|
||||
? `Output slot '${targetOutputName}' not found`
|
||||
: 'No output slots available to try'
|
||||
)
|
||||
}
|
||||
|
||||
// Try right-clicking on each output slot position until one works
|
||||
for (const output of outputsToTry) {
|
||||
if (!output.pos) continue
|
||||
|
||||
const testX = output.pos[0]
|
||||
const testY = output.pos[1]
|
||||
|
||||
// Create a right-click event at the output slot position
|
||||
const rightClickEvent = {
|
||||
canvasX: testX,
|
||||
canvasY: testY,
|
||||
button: 2, // Right mouse button
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
}
|
||||
|
||||
// Trigger the output node's right-click handler
|
||||
if (outputNode.onPointerDown) {
|
||||
outputNode.onPointerDown(
|
||||
rightClickEvent,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
}
|
||||
|
||||
// Wait briefly for menu to appear
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Check if litegraph context menu appeared
|
||||
const menuExists = document.querySelector('.litemenu-entry')
|
||||
if (menuExists) {
|
||||
return { success: true, outputName: output.name, x: testX, y: testY }
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false }
|
||||
}, outputName)
|
||||
|
||||
if (!foundSlot.success) {
|
||||
throw new Error(
|
||||
outputName
|
||||
? `Could not open context menu for output slot '${outputName}'`
|
||||
: 'Could not find any output slot position to right-click'
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for the litegraph context menu to be visible
|
||||
await this.page.waitForSelector('.litemenu-entry', {
|
||||
state: 'visible',
|
||||
timeout: 5000
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a reference to a subgraph input slot
|
||||
*/
|
||||
async getSubgraphInputSlot(
|
||||
slotName?: string
|
||||
): Promise<SubgraphSlotReference> {
|
||||
return new SubgraphSlotReference('input', slotName || '', this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a reference to a subgraph output slot
|
||||
*/
|
||||
async getSubgraphOutputSlot(
|
||||
slotName?: string
|
||||
): Promise<SubgraphSlotReference> {
|
||||
return new SubgraphSlotReference('output', slotName || '', this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a regular node output to a subgraph input.
|
||||
* This creates a new input slot on the subgraph if targetInputName is not provided.
|
||||
*/
|
||||
async connectToSubgraphInput(
|
||||
sourceNode: NodeReference,
|
||||
sourceSlotIndex: number,
|
||||
targetInputName?: string
|
||||
): Promise<void> {
|
||||
const sourceSlot = await sourceNode.getOutput(sourceSlotIndex)
|
||||
const targetSlot = await this.getSubgraphInputSlot(targetInputName)
|
||||
|
||||
const targetPosition = targetInputName
|
||||
? await targetSlot.getPosition() // Connect to existing slot
|
||||
: await targetSlot.getOpenSlotPosition() // Create new slot
|
||||
|
||||
await this.dragAndDrop(await sourceSlot.getPosition(), targetPosition)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a subgraph input to a regular node input.
|
||||
* This creates a new input slot on the subgraph if sourceInputName is not provided.
|
||||
*/
|
||||
async connectFromSubgraphInput(
|
||||
targetNode: NodeReference,
|
||||
targetSlotIndex: number,
|
||||
sourceInputName?: string
|
||||
): Promise<void> {
|
||||
const sourceSlot = await this.getSubgraphInputSlot(sourceInputName)
|
||||
const targetSlot = await targetNode.getInput(targetSlotIndex)
|
||||
|
||||
const sourcePosition = sourceInputName
|
||||
? await sourceSlot.getPosition() // Connect from existing slot
|
||||
: await sourceSlot.getOpenSlotPosition() // Create new slot
|
||||
|
||||
const targetPosition = await targetSlot.getPosition()
|
||||
|
||||
// Debug: Log the positions we're trying to use
|
||||
console.log('Drag positions:', {
|
||||
source: sourcePosition,
|
||||
target: targetPosition
|
||||
})
|
||||
|
||||
await this.dragAndDrop(sourcePosition, targetPosition)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a regular node output to a subgraph output.
|
||||
* This creates a new output slot on the subgraph if targetOutputName is not provided.
|
||||
*/
|
||||
async connectToSubgraphOutput(
|
||||
sourceNode: NodeReference,
|
||||
sourceSlotIndex: number,
|
||||
targetOutputName?: string
|
||||
): Promise<void> {
|
||||
const sourceSlot = await sourceNode.getOutput(sourceSlotIndex)
|
||||
const targetSlot = await this.getSubgraphOutputSlot(targetOutputName)
|
||||
|
||||
const targetPosition = targetOutputName
|
||||
? await targetSlot.getPosition() // Connect to existing slot
|
||||
: await targetSlot.getOpenSlotPosition() // Create new slot
|
||||
|
||||
await this.dragAndDrop(await sourceSlot.getPosition(), targetPosition)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a subgraph output to a regular node input.
|
||||
* This creates a new output slot on the subgraph if sourceOutputName is not provided.
|
||||
*/
|
||||
async connectFromSubgraphOutput(
|
||||
targetNode: NodeReference,
|
||||
targetSlotIndex: number,
|
||||
sourceOutputName?: string
|
||||
): Promise<void> {
|
||||
const sourceSlot = await this.getSubgraphOutputSlot(sourceOutputName)
|
||||
const targetSlot = await targetNode.getInput(targetSlotIndex)
|
||||
|
||||
const sourcePosition = sourceOutputName
|
||||
? await sourceSlot.getPosition() // Connect from existing slot
|
||||
: await sourceSlot.getOpenSlotPosition() // Create new slot
|
||||
|
||||
await this.dragAndDrop(sourcePosition, await targetSlot.getPosition())
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a visual marker at a position for debugging
|
||||
*/
|
||||
async debugAddMarker(
|
||||
position: Position,
|
||||
id: string = 'debug-marker'
|
||||
): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
([pos, markerId]) => {
|
||||
// Remove existing marker if present
|
||||
const existing = document.getElementById(markerId)
|
||||
if (existing) existing.remove()
|
||||
|
||||
// Create marker
|
||||
const marker = document.createElement('div')
|
||||
marker.id = markerId
|
||||
marker.style.position = 'fixed'
|
||||
marker.style.left = `${pos.x - 10}px`
|
||||
marker.style.top = `${pos.y - 10}px`
|
||||
marker.style.width = '20px'
|
||||
marker.style.height = '20px'
|
||||
marker.style.border = '2px solid red'
|
||||
marker.style.borderRadius = '50%'
|
||||
marker.style.backgroundColor = 'rgba(255, 0, 0, 0.3)'
|
||||
marker.style.pointerEvents = 'none'
|
||||
marker.style.zIndex = '10000'
|
||||
document.body.appendChild(marker)
|
||||
},
|
||||
[position, id] as const
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove debug markers
|
||||
*/
|
||||
async debugRemoveMarkers(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
document
|
||||
.querySelectorAll('[id^="debug-marker"]')
|
||||
.forEach((el) => el.remove())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot and attach it to the test report for debugging
|
||||
* This is a convenience method that combines screenshot capture and test attachment
|
||||
*
|
||||
* @param testInfo The Playwright TestInfo object (from test parameters)
|
||||
* @param name Name for the attachment
|
||||
* @param options Optional screenshot options (defaults to page screenshot)
|
||||
*/
|
||||
async debugAttachScreenshot(
|
||||
testInfo: any,
|
||||
name: string,
|
||||
options?: {
|
||||
fullPage?: boolean
|
||||
element?: 'canvas' | 'page'
|
||||
markers?: Array<{ position: Position; id?: string }>
|
||||
}
|
||||
): Promise<void> {
|
||||
// Add markers if requested
|
||||
if (options?.markers) {
|
||||
for (const marker of options.markers) {
|
||||
await this.debugAddMarker(marker.position, marker.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Take screenshot - default to page if not specified
|
||||
let screenshot: Buffer
|
||||
const targetElement = options?.element || 'page'
|
||||
|
||||
if (targetElement === 'canvas') {
|
||||
screenshot = await this.canvas.screenshot()
|
||||
} else if (options?.fullPage) {
|
||||
screenshot = await this.page.screenshot({ fullPage: true })
|
||||
} else {
|
||||
screenshot = await this.page.screenshot()
|
||||
}
|
||||
|
||||
// Attach to test report
|
||||
await testInfo.attach(name, {
|
||||
body: screenshot,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
|
||||
// Clean up markers if we added any
|
||||
if (options?.markers) {
|
||||
await this.debugRemoveMarkers()
|
||||
}
|
||||
}
|
||||
|
||||
async doubleClickCanvas() {
|
||||
await this.page.mouse.dblclick(10, 10, { delay: 5 })
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the canvas as a PNG and save it for debugging
|
||||
*/
|
||||
async debugSaveCanvasScreenshot(filename: string): Promise<void> {
|
||||
await this.page.evaluate(async (filename) => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
|
||||
// Convert canvas to blob
|
||||
return new Promise<void>((resolve) => {
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (!blob) {
|
||||
throw new Error('Failed to create blob from canvas')
|
||||
}
|
||||
|
||||
// Create a download link and trigger it
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
resolve()
|
||||
}, 'image/png')
|
||||
})
|
||||
}, filename)
|
||||
|
||||
// Wait a bit for the download to process
|
||||
await this.page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture canvas as base64 data URL for inspection
|
||||
*/
|
||||
async debugGetCanvasDataURL(): Promise<string> {
|
||||
return await this.page.evaluate(() => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
return canvas.toDataURL('image/png')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an overlay div with the canvas image for easier Playwright screenshot
|
||||
*/
|
||||
async debugShowCanvasOverlay(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
|
||||
// Remove existing overlay if present
|
||||
const existingOverlay = document.getElementById('debug-canvas-overlay')
|
||||
if (existingOverlay) {
|
||||
existingOverlay.remove()
|
||||
}
|
||||
|
||||
// Create overlay div
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'debug-canvas-overlay'
|
||||
overlay.style.position = 'fixed'
|
||||
overlay.style.top = '0'
|
||||
overlay.style.left = '0'
|
||||
overlay.style.zIndex = '9999'
|
||||
overlay.style.backgroundColor = 'white'
|
||||
overlay.style.padding = '10px'
|
||||
overlay.style.border = '2px solid red'
|
||||
|
||||
// Create image from canvas
|
||||
const img = document.createElement('img')
|
||||
img.src = canvas.toDataURL('image/png')
|
||||
img.style.maxWidth = '800px'
|
||||
img.style.maxHeight = '600px'
|
||||
overlay.appendChild(img)
|
||||
|
||||
document.body.appendChild(overlay)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the debug canvas overlay
|
||||
*/
|
||||
async debugHideCanvasOverlay(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
const overlay = document.getElementById('debug-canvas-overlay')
|
||||
if (overlay) {
|
||||
overlay.remove()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async clickEmptyLatentNode() {
|
||||
await this.canvas.click({
|
||||
position: {
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
import { ComfyPage } from '../ComfyPage'
|
||||
|
||||
export class SettingDialog {
|
||||
constructor(
|
||||
public readonly page: Page,
|
||||
public readonly comfyPage: ComfyPage
|
||||
) {}
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
get root() {
|
||||
return this.page.locator('div.settings-container')
|
||||
}
|
||||
|
||||
async open() {
|
||||
await this.comfyPage.executeCommand('Comfy.ShowSettingsDialog')
|
||||
const button = this.page.locator('button.comfy-settings-btn:visible')
|
||||
await button.click()
|
||||
await this.page.waitForSelector('div.settings-container')
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ export class Topbar {
|
||||
.innerText()
|
||||
}
|
||||
|
||||
async openSubmenuMobile() {
|
||||
await this.page.locator('.p-menubar-mobile .p-menubar-button').click()
|
||||
}
|
||||
|
||||
getMenuItem(itemLabel: string): Locator {
|
||||
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
|
||||
}
|
||||
@@ -64,41 +68,31 @@ export class Topbar {
|
||||
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 })
|
||||
}
|
||||
|
||||
async openTopbarMenu() {
|
||||
await this.page.locator('.comfyui-logo-wrapper').click()
|
||||
const menu = this.page.locator('.comfy-command-menu')
|
||||
await menu.waitFor({ state: 'visible' })
|
||||
return menu
|
||||
}
|
||||
|
||||
async triggerTopbarCommand(path: string[]) {
|
||||
if (path.length < 2) {
|
||||
throw new Error('Path is too short')
|
||||
}
|
||||
|
||||
const menu = await this.openTopbarMenu()
|
||||
const tabName = path[0]
|
||||
const topLevelMenuItem = this.page.locator(
|
||||
`.p-menubar-item-label:text-is("${tabName}")`
|
||||
const topLevelMenu = this.page.locator(
|
||||
`.top-menubar .p-menubar-item-label:text-is("${tabName}")`
|
||||
)
|
||||
const topLevelMenu = menu
|
||||
.locator('.p-tieredmenu-item')
|
||||
.filter({ has: topLevelMenuItem })
|
||||
await topLevelMenu.waitFor({ state: 'visible' })
|
||||
await topLevelMenu.hover()
|
||||
await topLevelMenu.click()
|
||||
|
||||
let currentMenu = topLevelMenu
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const commandName = path[i]
|
||||
const menuItem = currentMenu
|
||||
const menuItem = this.page
|
||||
.locator(
|
||||
`.p-tieredmenu-submenu .p-tieredmenu-item:has-text("${commandName}")`
|
||||
`.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")`
|
||||
)
|
||||
.first()
|
||||
await menuItem.waitFor({ state: 'visible' })
|
||||
await menuItem.hover()
|
||||
currentMenu = menuItem
|
||||
|
||||
if (i === path.length - 1) {
|
||||
await menuItem.click()
|
||||
}
|
||||
}
|
||||
await currentMenu.click()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,128 +12,6 @@ export const getMiddlePoint = (pos1: Position, pos2: Position) => {
|
||||
}
|
||||
}
|
||||
|
||||
export class SubgraphSlotReference {
|
||||
constructor(
|
||||
readonly type: 'input' | 'output',
|
||||
readonly slotName: string,
|
||||
readonly comfyPage: ComfyPage
|
||||
) {}
|
||||
|
||||
async getPosition(): Promise<Position> {
|
||||
const pos: [number, number] = await this.comfyPage.page.evaluate(
|
||||
([type, slotName]) => {
|
||||
const currentGraph = window['app'].canvas.graph
|
||||
|
||||
// Check if we're in a subgraph
|
||||
if (currentGraph.constructor.name !== 'Subgraph') {
|
||||
throw new Error(
|
||||
'Not in a subgraph - this method only works inside subgraphs'
|
||||
)
|
||||
}
|
||||
|
||||
const slots =
|
||||
type === 'input' ? currentGraph.inputs : currentGraph.outputs
|
||||
if (!slots || slots.length === 0) {
|
||||
throw new Error(`No ${type} slots found in subgraph`)
|
||||
}
|
||||
|
||||
// Find the specific slot or use the first one if no name specified
|
||||
const slot = slotName
|
||||
? slots.find((s) => s.name === slotName)
|
||||
: slots[0]
|
||||
|
||||
if (!slot) {
|
||||
throw new Error(`${type} slot '${slotName}' not found`)
|
||||
}
|
||||
|
||||
if (!slot.pos) {
|
||||
throw new Error(`${type} slot '${slotName}' has no position`)
|
||||
}
|
||||
|
||||
// Convert from offset to canvas coordinates
|
||||
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
|
||||
slot.pos[0],
|
||||
slot.pos[1]
|
||||
])
|
||||
return canvasPos
|
||||
},
|
||||
[this.type, this.slotName] as const
|
||||
)
|
||||
|
||||
return {
|
||||
x: pos[0],
|
||||
y: pos[1]
|
||||
}
|
||||
}
|
||||
|
||||
async getOpenSlotPosition(): Promise<Position> {
|
||||
const pos: [number, number] = await this.comfyPage.page.evaluate(
|
||||
([type]) => {
|
||||
const currentGraph = window['app'].canvas.graph
|
||||
|
||||
if (currentGraph.constructor.name !== 'Subgraph') {
|
||||
throw new Error(
|
||||
'Not in a subgraph - this method only works inside subgraphs'
|
||||
)
|
||||
}
|
||||
|
||||
const node =
|
||||
type === 'input' ? currentGraph.inputNode : currentGraph.outputNode
|
||||
const slots =
|
||||
type === 'input' ? currentGraph.inputs : currentGraph.outputs
|
||||
|
||||
if (!node) {
|
||||
throw new Error(`No ${type} node found in subgraph`)
|
||||
}
|
||||
|
||||
// Calculate position for next available slot
|
||||
// const nextSlotIndex = slots?.length || 0
|
||||
// const slotHeight = 20
|
||||
// const slotY = node.pos[1] + 30 + nextSlotIndex * slotHeight
|
||||
|
||||
// Find last slot position
|
||||
const lastSlot = slots.at(-1)
|
||||
let slotX: number
|
||||
let slotY: number
|
||||
|
||||
if (lastSlot) {
|
||||
// If there are existing slots, position the new one below the last one
|
||||
const gapHeight = 20
|
||||
slotX = lastSlot.pos[0]
|
||||
slotY = lastSlot.pos[1] + gapHeight
|
||||
} else {
|
||||
// No existing slots - use slotAnchorX if available, otherwise calculate from node position
|
||||
if (currentGraph.slotAnchorX !== undefined) {
|
||||
// The actual slot X position seems to be slotAnchorX - 10
|
||||
slotX = currentGraph.slotAnchorX - 10
|
||||
} else {
|
||||
// Fallback: calculate from node edge
|
||||
slotX =
|
||||
type === 'input'
|
||||
? node.pos[0] + node.size[0] - 10 // Right edge for input node
|
||||
: node.pos[0] + 10 // Left edge for output node
|
||||
}
|
||||
// For Y position when no slots exist, use middle of node
|
||||
slotY = node.pos[1] + node.size[1] / 2
|
||||
}
|
||||
|
||||
// Convert from offset to canvas coordinates
|
||||
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
|
||||
slotX,
|
||||
slotY
|
||||
])
|
||||
return canvasPos
|
||||
},
|
||||
[this.type] as const
|
||||
)
|
||||
|
||||
return {
|
||||
x: pos[0],
|
||||
y: pos[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeSlotReference {
|
||||
constructor(
|
||||
readonly type: 'input' | 'output',
|
||||
@@ -143,27 +21,11 @@ export class NodeSlotReference {
|
||||
async getPosition() {
|
||||
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
|
||||
([type, id, index]) => {
|
||||
// Use canvas.graph to get the current graph (works in both main graph and subgraphs)
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
|
||||
const rawPos = node.getConnectionPos(type === 'input', index)
|
||||
const convertedPos =
|
||||
window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
|
||||
|
||||
// Debug logging - convert Float32Arrays to regular arrays for visibility
|
||||
console.log(
|
||||
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
|
||||
{
|
||||
nodePos: [node.pos[0], node.pos[1]],
|
||||
nodeSize: [node.size[0], node.size[1]],
|
||||
rawConnectionPos: [rawPos[0], rawPos[1]],
|
||||
convertedPos: [convertedPos[0], convertedPos[1]],
|
||||
currentGraphType: window['app'].canvas.graph.constructor.name
|
||||
}
|
||||
return window['app'].canvas.ds.convertOffsetToCanvas(
|
||||
node.getConnectionPos(type === 'input', index)
|
||||
)
|
||||
|
||||
return convertedPos
|
||||
},
|
||||
[this.type, this.node.id, this.index] as const
|
||||
)
|
||||
@@ -175,7 +37,7 @@ export class NodeSlotReference {
|
||||
async getLinkCount() {
|
||||
return await this.node.comfyPage.page.evaluate(
|
||||
([type, id, index]) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
if (type === 'input') {
|
||||
return node.inputs[index].link == null ? 0 : 1
|
||||
@@ -188,7 +50,7 @@ export class NodeSlotReference {
|
||||
async removeLinks() {
|
||||
await this.node.comfyPage.page.evaluate(
|
||||
([type, id, index]) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
if (type === 'input') {
|
||||
node.disconnectInput(index)
|
||||
@@ -213,7 +75,7 @@ export class NodeWidgetReference {
|
||||
async getPosition(): Promise<Position> {
|
||||
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
|
||||
([id, index]) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
const widget = node.widgets[index]
|
||||
if (!widget) throw new Error(`Widget ${index} not found.`)
|
||||
@@ -272,7 +134,7 @@ export class NodeWidgetReference {
|
||||
const pos = await this.getPosition()
|
||||
const canvas = this.node.comfyPage.canvas
|
||||
const canvasPos = (await canvas.boundingBox())!
|
||||
await this.node.comfyPage.dragAndDrop(
|
||||
this.node.comfyPage.dragAndDrop(
|
||||
{
|
||||
x: canvasPos.x + pos.x,
|
||||
y: canvasPos.y + pos.y
|
||||
@@ -304,7 +166,7 @@ export class NodeReference {
|
||||
) {}
|
||||
async exists(): Promise<boolean> {
|
||||
return await this.comfyPage.page.evaluate((id) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
return !!node
|
||||
}, this.id)
|
||||
}
|
||||
@@ -323,7 +185,7 @@ export class NodeReference {
|
||||
async getBounding(): Promise<Position & Size> {
|
||||
const [x, y, width, height]: [number, number, number, number] =
|
||||
await this.comfyPage.page.evaluate((id) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error('Node not found')
|
||||
return node.getBounding()
|
||||
}, this.id)
|
||||
@@ -356,7 +218,7 @@ export class NodeReference {
|
||||
async getProperty<T>(prop: string): Promise<T> {
|
||||
return await this.comfyPage.page.evaluate(
|
||||
([id, prop]) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error('Node not found')
|
||||
return node[prop]
|
||||
},
|
||||
@@ -397,8 +259,7 @@ export class NodeReference {
|
||||
|
||||
await this.comfyPage.canvas.click({
|
||||
...options,
|
||||
position: clickPos,
|
||||
force: true
|
||||
position: clickPos
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
if (moveMouseToEmptyArea) {
|
||||
@@ -458,18 +319,6 @@ export class NodeReference {
|
||||
}
|
||||
return nodes[0]
|
||||
}
|
||||
async convertToSubgraph() {
|
||||
await this.clickContextMenuOption('Convert to Subgraph')
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.page.waitForTimeout(256)
|
||||
const nodes = await this.comfyPage.getNodeRefsByTitle('New Subgraph')
|
||||
if (nodes.length !== 1) {
|
||||
throw new Error(
|
||||
`Did not find single subgraph node (found=${nodes.length})`
|
||||
)
|
||||
}
|
||||
return nodes[0]
|
||||
}
|
||||
async manageGroupNode() {
|
||||
await this.clickContextMenuOption('Manage Group Node')
|
||||
await this.comfyPage.nextFrame()
|
||||
@@ -478,58 +327,4 @@ export class NodeReference {
|
||||
this.comfyPage.page.locator('.comfy-group-manage')
|
||||
)
|
||||
}
|
||||
async navigateIntoSubgraph() {
|
||||
const titleHeight = await this.comfyPage.page.evaluate(() => {
|
||||
return window['LiteGraph']['NODE_TITLE_HEIGHT']
|
||||
})
|
||||
const nodePos = await this.getPosition()
|
||||
const nodeSize = await this.getSize()
|
||||
|
||||
// Try multiple positions to avoid DOM widget interference
|
||||
const clickPositions = [
|
||||
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + titleHeight + 5 },
|
||||
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + nodeSize.height / 2 },
|
||||
{ x: nodePos.x + 20, y: nodePos.y + titleHeight + 5 }
|
||||
]
|
||||
|
||||
let isInSubgraph = false
|
||||
let attempts = 0
|
||||
const maxAttempts = 3
|
||||
|
||||
while (!isInSubgraph && attempts < maxAttempts) {
|
||||
attempts++
|
||||
|
||||
for (const position of clickPositions) {
|
||||
// Clear any selection first
|
||||
await this.comfyPage.canvas.click({
|
||||
position: { x: 50, y: 50 },
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
|
||||
// Double-click to enter subgraph
|
||||
await this.comfyPage.canvas.dblclick({ position, force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.page.waitForTimeout(500)
|
||||
|
||||
// Check if we successfully entered the subgraph
|
||||
isInSubgraph = await this.comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph?.constructor?.name === 'Subgraph'
|
||||
})
|
||||
|
||||
if (isInSubgraph) break
|
||||
}
|
||||
|
||||
if (!isInSubgraph && attempts < maxAttempts) {
|
||||
await this.comfyPage.page.waitForTimeout(500)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInSubgraph) {
|
||||
throw new Error(
|
||||
'Failed to navigate into subgraph after ' + attempts + ' attempts'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
507
browser_tests/helpers/ExecutionTestHelper.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
export interface ExecutionEventTracker {
|
||||
progressStates: any[]
|
||||
executionStarted: boolean
|
||||
executionFinished: boolean
|
||||
executionError: any | null
|
||||
executingNodeId: string | null
|
||||
}
|
||||
|
||||
export interface ProgressState {
|
||||
prompt_id: string
|
||||
nodes: Record<
|
||||
string,
|
||||
{
|
||||
state: 'running' | 'finished' | 'waiting'
|
||||
node_id: string
|
||||
display_node_id: string
|
||||
prompt_id: string
|
||||
value?: number
|
||||
max?: number
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export class ExecutionTestHelper {
|
||||
private testId: string
|
||||
|
||||
constructor(private page: Page) {
|
||||
// Generate unique ID for this test instance to avoid conflicts
|
||||
this.testId = `test_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up common event tracking for execution tests
|
||||
*/
|
||||
async setupEventTracking(): Promise<void> {
|
||||
await this.page.evaluate((testId) => {
|
||||
// Use unique property names for this test instance
|
||||
const progressKey = `__progressStates_${testId}`
|
||||
const startedKey = `__executionStarted_${testId}`
|
||||
const finishedKey = `__executionFinished_${testId}`
|
||||
const errorKey = `__executionError_${testId}`
|
||||
const nodeIdKey = `__executingNodeId_${testId}`
|
||||
|
||||
window[progressKey] = []
|
||||
window[startedKey] = false
|
||||
window[finishedKey] = false
|
||||
window[errorKey] = null
|
||||
window[nodeIdKey] = null
|
||||
|
||||
const api = window['app'].api
|
||||
|
||||
// Store listeners so we can clean them up later
|
||||
if (!window['__testListeners']) {
|
||||
window['__testListeners'] = {}
|
||||
}
|
||||
|
||||
// Remove old listeners if they exist
|
||||
if (window['__testListeners'][testId]) {
|
||||
const oldListeners = window['__testListeners'][testId]
|
||||
api.removeEventListener('progress_state', oldListeners.progress)
|
||||
api.removeEventListener('executing', oldListeners.executing)
|
||||
api.removeEventListener('execution_error', oldListeners.error)
|
||||
}
|
||||
|
||||
// Create new listeners
|
||||
const progressListener = (event) => {
|
||||
window[progressKey].push(event.detail)
|
||||
}
|
||||
|
||||
const executingListener = (event) => {
|
||||
window[nodeIdKey] = event.detail
|
||||
if (event.detail !== null) {
|
||||
window[startedKey] = true
|
||||
} else {
|
||||
window[finishedKey] = true
|
||||
}
|
||||
}
|
||||
|
||||
const errorListener = (event) => {
|
||||
window[errorKey] = event.detail
|
||||
}
|
||||
|
||||
// Add listeners
|
||||
api.addEventListener('progress_state', progressListener)
|
||||
api.addEventListener('executing', executingListener)
|
||||
api.addEventListener('execution_error', errorListener)
|
||||
|
||||
// Store listeners for cleanup
|
||||
window['__testListeners'][testId] = {
|
||||
progress: progressListener,
|
||||
executing: executingListener,
|
||||
error: errorListener
|
||||
}
|
||||
}, this.testId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current event tracking state
|
||||
*/
|
||||
async getEventState(): Promise<ExecutionEventTracker> {
|
||||
return await this.page.evaluate(
|
||||
(testId) => ({
|
||||
progressStates: window[`__progressStates_${testId}`] || [],
|
||||
executionStarted: window[`__executionStarted_${testId}`] || false,
|
||||
executionFinished: window[`__executionFinished_${testId}`] || false,
|
||||
executionError: window[`__executionError_${testId}`] || null,
|
||||
executingNodeId: window[`__executingNodeId_${testId}`] || null
|
||||
}),
|
||||
this.testId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for execution to start
|
||||
*/
|
||||
async waitForExecutionStart(timeout: number = 10000): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
(testId) => window[`__executionStarted_${testId}`] === true,
|
||||
this.testId,
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for execution to finish
|
||||
*/
|
||||
async waitForExecutionFinish(timeout: number = 30000): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
(testId) => window[`__executionFinished_${testId}`] === true,
|
||||
this.testId,
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a specific number of nodes to be running
|
||||
*/
|
||||
async waitForRunningNodes(
|
||||
count: number,
|
||||
timeout: number = 10000
|
||||
): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
({ expectedCount, testId }) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes) return false
|
||||
|
||||
const runningNodes = Object.values(latestState.nodes).filter(
|
||||
(node: any) => node.state === 'running'
|
||||
).length
|
||||
|
||||
return runningNodes >= expectedCount
|
||||
},
|
||||
{ expectedCount: count, testId: this.testId },
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for at least one node to finish
|
||||
*/
|
||||
async waitForNodeFinish(timeout: number = 15000): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes) return false
|
||||
|
||||
return Object.values(latestState.nodes).some(
|
||||
(node: any) => node.state === 'finished'
|
||||
)
|
||||
},
|
||||
this.testId,
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest progress state
|
||||
*/
|
||||
async getLatestProgressState(): Promise<ProgressState | null> {
|
||||
return await this.page.evaluate((testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return null
|
||||
return states[states.length - 1]
|
||||
}, this.testId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for node progress to be applied to the graph
|
||||
*/
|
||||
async waitForGraphNodeProgress(
|
||||
nodeIds: number[],
|
||||
timeout: number = 5000
|
||||
): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
(ids) => {
|
||||
return ids.some((id) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
return node?.progress !== undefined && node.progress >= 0
|
||||
})
|
||||
},
|
||||
nodeIds,
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets node progress from the graph
|
||||
*/
|
||||
async getGraphNodeProgress(nodeId: number): Promise<number | undefined> {
|
||||
return await this.page.evaluate((id) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
return node?.progress
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if execution had errors
|
||||
*/
|
||||
async hasExecutionError(): Promise<boolean> {
|
||||
const error = await this.page.evaluate(
|
||||
(testId) => window[`__executionError_${testId}`],
|
||||
this.testId
|
||||
)
|
||||
return error !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets execution error details
|
||||
*/
|
||||
async getExecutionError(): Promise<any> {
|
||||
return await this.page.evaluate(
|
||||
(testId) => window[`__executionError_${testId}`],
|
||||
this.testId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup event listeners when test is done
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
await this.page.evaluate((testId) => {
|
||||
if (window['__testListeners'] && window['__testListeners'][testId]) {
|
||||
const api = window['app'].api
|
||||
const listeners = window['__testListeners'][testId]
|
||||
api.removeEventListener('progress_state', listeners.progress)
|
||||
api.removeEventListener('executing', listeners.executing)
|
||||
api.removeEventListener('execution_error', listeners.error)
|
||||
delete window['__testListeners'][testId]
|
||||
}
|
||||
// Clean up test-specific properties
|
||||
delete window[`__progressStates_${testId}`]
|
||||
delete window[`__executionStarted_${testId}`]
|
||||
delete window[`__executionFinished_${testId}`]
|
||||
delete window[`__executionError_${testId}`]
|
||||
delete window[`__executingNodeId_${testId}`]
|
||||
}, this.testId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the testId for direct window access in evaluate functions
|
||||
*/
|
||||
getTestId(): string {
|
||||
return this.testId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for browser title monitoring
|
||||
*/
|
||||
export class BrowserTitleMonitor {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
/**
|
||||
* Waits for title to not show execution state
|
||||
*/
|
||||
async waitForIdleTitle(timeout: number = 10000): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
() => {
|
||||
const title = document.title
|
||||
return !title.match(/\[\d+%\]/) && !title.match(/\[\d+ nodes running\]/)
|
||||
},
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for title to show execution state
|
||||
*/
|
||||
async waitForExecutionTitle(timeout: number = 5000): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
() => {
|
||||
const title = document.title
|
||||
return title.match(/\[\d+%\]/) || title.match(/\[\d+ nodes running\]/)
|
||||
},
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up title change monitoring
|
||||
*/
|
||||
async setupTitleMonitoring(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
window['__titleUpdateLog'] = []
|
||||
window['__lastTitle'] = document.title
|
||||
|
||||
window['__titleInterval'] = setInterval(() => {
|
||||
const newTitle = document.title
|
||||
if (newTitle !== window['__lastTitle']) {
|
||||
window['__titleUpdateLog'].push({
|
||||
time: Date.now(),
|
||||
title: newTitle,
|
||||
hasProgress: !!newTitle.match(/\[\d+%\]/),
|
||||
hasMultiNode: !!newTitle.match(/\[\d+ nodes running\]/)
|
||||
})
|
||||
window['__lastTitle'] = newTitle
|
||||
}
|
||||
}, 50)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops title monitoring and returns the log
|
||||
*/
|
||||
async stopTitleMonitoring(): Promise<any[]> {
|
||||
const log = await this.page.evaluate(() => {
|
||||
if (window['__titleInterval']) {
|
||||
clearInterval(window['__titleInterval'])
|
||||
}
|
||||
return window['__titleUpdateLog'] || []
|
||||
})
|
||||
return log
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for preview event handling
|
||||
*/
|
||||
export class PreviewTestHelper {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
/**
|
||||
* Sets up preview event tracking
|
||||
*/
|
||||
async setupPreviewTracking(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
window['__previewEvents'] = []
|
||||
window['__revokedNodes'] = []
|
||||
window['__revokedUrls'] = []
|
||||
|
||||
// Track preview events
|
||||
const api = window['app'].api
|
||||
api.addEventListener('b_preview_with_metadata', (event) => {
|
||||
window['__previewEvents'].push({
|
||||
nodeId: event.detail.nodeId,
|
||||
displayNodeId: event.detail.displayNodeId,
|
||||
parentNodeId: event.detail.parentNodeId,
|
||||
realNodeId: event.detail.realNodeId,
|
||||
promptId: event.detail.promptId
|
||||
})
|
||||
})
|
||||
|
||||
// Mock revokePreviews to track calls
|
||||
const originalRevoke = window['app'].revokePreviews
|
||||
window['app'].revokePreviews = function (nodeId) {
|
||||
window['__revokedNodes'].push(nodeId)
|
||||
originalRevoke.call(this, nodeId)
|
||||
}
|
||||
|
||||
// Mock URL.revokeObjectURL to track URL revocations
|
||||
const originalRevokeURL = URL.revokeObjectURL
|
||||
URL.revokeObjectURL = (url: string) => {
|
||||
window['__revokedUrls'].push(url)
|
||||
originalRevokeURL.call(URL, url)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets tracked preview events
|
||||
*/
|
||||
async getPreviewEvents(): Promise<any[]> {
|
||||
return await this.page.evaluate(() => window['__previewEvents'] || [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets revoked node IDs
|
||||
*/
|
||||
async getRevokedNodes(): Promise<string[]> {
|
||||
return await this.page.evaluate(() => window['__revokedNodes'] || [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets revoked URLs
|
||||
*/
|
||||
async getRevokedUrls(): Promise<string[]> {
|
||||
return await this.page.evaluate(() => window['__revokedUrls'] || [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets fake preview for a node
|
||||
*/
|
||||
async setNodePreview(nodeId: string, previewUrl: string): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
({ id, url }) => {
|
||||
window['app'].nodePreviewImages[id] = [url]
|
||||
},
|
||||
{ id: nodeId, url: previewUrl }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets node preview URLs
|
||||
*/
|
||||
async getNodePreviews(nodeId: string): Promise<string[] | undefined> {
|
||||
return await this.page.evaluate(
|
||||
(id) => window['app'].nodePreviewImages[id],
|
||||
nodeId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for checking subgraph execution
|
||||
*/
|
||||
export class SubgraphTestHelper {
|
||||
private testId: string
|
||||
|
||||
constructor(private page: Page) {
|
||||
// Generate unique ID for this test instance
|
||||
this.testId = `test_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the test ID to match ExecutionTestHelper
|
||||
*/
|
||||
setTestId(testId: string): void {
|
||||
this.testId = testId
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for nested node progress (nodes with ':' in their ID)
|
||||
*/
|
||||
async waitForNestedNodeProgress(
|
||||
minNestingLevel: number = 1,
|
||||
timeout: number = 15000
|
||||
): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
({ minLevel, testId }) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
return states.some((state: any) => {
|
||||
if (!state.nodes) return false
|
||||
return Object.keys(state.nodes).some((nodeId) => {
|
||||
const colonCount = (nodeId.match(/:/g) || []).length
|
||||
return colonCount >= minLevel
|
||||
})
|
||||
})
|
||||
},
|
||||
{ minLevel: minNestingLevel, testId: this.testId },
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all nested node IDs from progress states
|
||||
*/
|
||||
async getNestedNodeIds(): Promise<string[]> {
|
||||
return await this.page.evaluate((testId) => {
|
||||
const states = window[`__progressStates_${testId}`] || []
|
||||
const nestedIds = new Set<string>()
|
||||
|
||||
states.forEach((state: any) => {
|
||||
if (state.nodes) {
|
||||
Object.keys(state.nodes).forEach((nodeId) => {
|
||||
if (nodeId.includes(':')) {
|
||||
nestedIds.add(nodeId)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(nestedIds)
|
||||
}, this.testId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a node has running stroke style
|
||||
*/
|
||||
async hasRunningStrokeStyle(nodeId: number): Promise<boolean> {
|
||||
return await this.page.evaluate((id) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node?.strokeStyles?.['running']) return false
|
||||
const style = node.strokeStyles['running'].call(node)
|
||||
return style?.color === '#0f0'
|
||||
}, nodeId)
|
||||
}
|
||||
}
|
||||
316
browser_tests/tests/browserTabTitleMultiNode.spec.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../fixtures/ws'
|
||||
import {
|
||||
BrowserTitleMonitor,
|
||||
ExecutionTestHelper
|
||||
} from '../helpers/ExecutionTestHelper'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
test.describe('Browser Tab Title - Multi-node Execution', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let executionHelper: ExecutionTestHelper
|
||||
let titleMonitor: BrowserTitleMonitor
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
executionHelper = new ExecutionTestHelper(comfyPage.page)
|
||||
titleMonitor = new BrowserTitleMonitor(comfyPage.page)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
// Clean up event listeners to avoid conflicts
|
||||
if (executionHelper) {
|
||||
await executionHelper.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('Shows multiple nodes running in tab title', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Wait for any existing execution to complete
|
||||
await titleMonitor.waitForIdleTitle()
|
||||
|
||||
// Get initial title
|
||||
const initialTitle = await comfyPage.page.title()
|
||||
// Title might show execution state if other tests are running
|
||||
// Just ensure we have a baseline to compare against
|
||||
|
||||
// Wait for the UI to be ready
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Check if workflow is valid and nodes are available
|
||||
const workflowStatus = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].graph
|
||||
const missingNodeTypes: string[] = []
|
||||
const nodeCount = graph.nodes.length
|
||||
|
||||
// Check for missing node types
|
||||
graph.nodes.forEach((node: any) => {
|
||||
if (node.type && !LiteGraph.registered_node_types[node.type]) {
|
||||
missingNodeTypes.push(node.type)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
nodeCount,
|
||||
missingNodeTypes,
|
||||
hasErrors: missingNodeTypes.length > 0
|
||||
}
|
||||
})
|
||||
|
||||
if (workflowStatus.hasErrors) {
|
||||
console.log('Missing node types:', workflowStatus.missingNodeTypes)
|
||||
// Skip test if nodes are missing
|
||||
test.skip()
|
||||
return
|
||||
}
|
||||
|
||||
// Set up tracking for progress events and errors
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Queue the workflow for real execution using the command
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait a moment to see if there's an error
|
||||
await comfyPage.page.waitForTimeout(1000)
|
||||
|
||||
// Check for execution errors
|
||||
if (await executionHelper.hasExecutionError()) {
|
||||
const error = await executionHelper.getExecutionError()
|
||||
console.log('Execution error:', error)
|
||||
}
|
||||
|
||||
// Wait for multiple nodes to be running (TestSleep nodes 2, 3 and TestAsyncProgressNode 4)
|
||||
await executionHelper.waitForRunningNodes(2)
|
||||
|
||||
// Check title while we know multiple nodes are running
|
||||
const testId = executionHelper.getTestId()
|
||||
const titleDuringExecution = await comfyPage.page.evaluate((testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return null
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes) return null
|
||||
|
||||
const runningNodes = Object.values(latestState.nodes).filter(
|
||||
(node: any) => node.state === 'running'
|
||||
).length
|
||||
|
||||
return {
|
||||
title: document.title,
|
||||
runningCount: runningNodes
|
||||
}
|
||||
}, testId)
|
||||
|
||||
// Verify we captured the state with multiple nodes running
|
||||
expect(titleDuringExecution).not.toBeNull()
|
||||
expect(titleDuringExecution.runningCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// The title should show multiple nodes running when we have 2+ nodes executing
|
||||
if (titleDuringExecution.runningCount >= 2) {
|
||||
expect(titleDuringExecution.title).toMatch(/\[\d+ nodes running\]/)
|
||||
}
|
||||
|
||||
// Wait for some nodes to finish, leaving only one running
|
||||
await executionHelper.waitForRunningNodes(1, 15000)
|
||||
|
||||
// Wait for title to show single node progress
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => {
|
||||
const title = document.title
|
||||
return title.match(/\[\d+%\]/) && !title.match(/\[\d+ nodes running\]/)
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Check that title shows single node with progress
|
||||
const titleWithSingleNode = await comfyPage.page.title()
|
||||
expect(titleWithSingleNode).toMatch(/\[\d+%\]/)
|
||||
expect(titleWithSingleNode).not.toMatch(/\[\d+ nodes running\]/)
|
||||
})
|
||||
|
||||
test('Shows progress updates in title during execution', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Wait for the UI to be ready
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Set up tracking for progress events and title changes
|
||||
await executionHelper.setupEventTracking()
|
||||
await titleMonitor.setupTitleMonitoring()
|
||||
|
||||
// Queue the workflow for real execution using the command
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for TestAsyncProgressNode (node 4) to start showing progress
|
||||
// This node reports progress from 0 to 10 with steps of 1
|
||||
const testId2 = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes || !latestState.nodes['4']) return false
|
||||
|
||||
const node4 = latestState.nodes['4']
|
||||
if (node4.state === 'running' && node4.value > 0) {
|
||||
window['__lastProgress'] = Math.round((node4.value / node4.max) * 100)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
testId2,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
// Wait for title to show progress percentage
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => {
|
||||
const title = document.title
|
||||
console.log('Title check 1:', title)
|
||||
return title.match(/\[\d+%\]/)
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Check that title shows a progress percentage
|
||||
const titleWithProgress = await comfyPage.page.title()
|
||||
expect(titleWithProgress).toMatch(/\[\d+%\]/)
|
||||
|
||||
// Wait for progress to update to a different value
|
||||
const firstProgress = await comfyPage.page.evaluate(
|
||||
() => window['__lastProgress']
|
||||
)
|
||||
|
||||
const testId3 = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
({ initialProgress, testId }) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes || !latestState.nodes['4']) return false
|
||||
|
||||
const node4 = latestState.nodes['4']
|
||||
if (node4.state === 'running') {
|
||||
const currentProgress = Math.round((node4.value / node4.max) * 100)
|
||||
window['__lastProgress'] = currentProgress
|
||||
return currentProgress > initialProgress
|
||||
}
|
||||
return false
|
||||
},
|
||||
{ initialProgress: firstProgress, testId: testId3 },
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
// Store the first progress for comparison
|
||||
await comfyPage.page.evaluate((progress) => {
|
||||
window['__firstProgress'] = progress
|
||||
}, firstProgress)
|
||||
|
||||
// Check the title history to verify we captured progress updates
|
||||
const finalCheck = await comfyPage.page.evaluate(() => {
|
||||
const titleLog = window['__titleUpdateLog'] || []
|
||||
const firstProgress = window['__firstProgress'] || 0
|
||||
|
||||
// Find titles with progress information
|
||||
const titlesWithProgress = titleLog.filter((entry) => entry.hasProgress)
|
||||
|
||||
// Check if we saw different progress values or multi-node running state
|
||||
const progressValues = new Set()
|
||||
const hadMultiNodeRunning = titleLog.some((entry) =>
|
||||
entry.title.includes('nodes running')
|
||||
)
|
||||
|
||||
titleLog.forEach((entry) => {
|
||||
const match = entry.title.match(/\[(\d+)%\]/)
|
||||
if (match) {
|
||||
progressValues.add(parseInt(match[1]))
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
sawProgressUpdates: titlesWithProgress.length > 0,
|
||||
uniqueProgressValues: Array.from(progressValues),
|
||||
hadMultiNodeRunning,
|
||||
firstProgress,
|
||||
lastProgress: window['__lastProgress'],
|
||||
totalTitleUpdates: titleLog.length,
|
||||
sampleTitles: titleLog.slice(0, 5)
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Title update check:', JSON.stringify(finalCheck, null, 2))
|
||||
|
||||
// Verify that we captured title updates showing execution progress
|
||||
expect(finalCheck.sawProgressUpdates).toBe(true)
|
||||
expect(finalCheck.totalTitleUpdates).toBeGreaterThan(0)
|
||||
|
||||
// We should have seen either:
|
||||
// 1. Multiple unique progress values, OR
|
||||
// 2. Multi-node running state, OR
|
||||
// 3. Progress different from initial
|
||||
const sawProgressChange =
|
||||
finalCheck.uniqueProgressValues.length > 1 ||
|
||||
finalCheck.hadMultiNodeRunning ||
|
||||
finalCheck.lastProgress !== firstProgress
|
||||
|
||||
expect(sawProgressChange).toBe(true)
|
||||
|
||||
// Clean up interval
|
||||
await titleMonitor.stopTitleMonitoring()
|
||||
})
|
||||
|
||||
test('Clears execution status from title when all nodes finish', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Wait for any existing execution to complete
|
||||
await titleMonitor.waitForIdleTitle()
|
||||
|
||||
// Wait for the UI to be ready
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Set up tracking for events
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Queue the workflow for real execution using the command
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for execution to show progress in title
|
||||
await titleMonitor.waitForExecutionTitle()
|
||||
|
||||
// Verify execution shows in title
|
||||
const executingTitle = await comfyPage.page.title()
|
||||
expect(executingTitle).toMatch(/\[[\d%\s\w]+\]/)
|
||||
|
||||
// Wait for execution to complete (all nodes finished)
|
||||
await executionHelper.waitForExecutionFinish()
|
||||
|
||||
// Give a moment for title to update after execution completes
|
||||
await comfyPage.page.waitForTimeout(500)
|
||||
|
||||
// Wait for title to clear execution status
|
||||
await titleMonitor.waitForIdleTitle()
|
||||
|
||||
// Check that execution status is cleared
|
||||
const finishedTitle = await comfyPage.page.title()
|
||||
expect(finishedTitle).toContain('ComfyUI')
|
||||
expect(finishedTitle).not.toMatch(/\[\d+%\]/) // No percentage
|
||||
expect(finishedTitle).not.toMatch(/\[\d+ nodes running\]/) // No running nodes
|
||||
expect(finishedTitle).not.toContain('Executing')
|
||||
})
|
||||
})
|
||||
85
browser_tests/tests/browserTabTitleSimple.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
BrowserTitleMonitor,
|
||||
ExecutionTestHelper
|
||||
} from '../helpers/ExecutionTestHelper'
|
||||
|
||||
test.describe('Browser Tab Title - Multi-node Simple', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let executionHelper: ExecutionTestHelper
|
||||
let titleMonitor: BrowserTitleMonitor
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
executionHelper = new ExecutionTestHelper(comfyPage.page)
|
||||
titleMonitor = new BrowserTitleMonitor(comfyPage.page)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (executionHelper) {
|
||||
await executionHelper.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('Title updates based on execution state', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Wait for any existing execution to complete
|
||||
await titleMonitor.waitForIdleTitle().catch(() => {
|
||||
// If timeout, cancel any running execution
|
||||
return comfyPage.page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
// Get initial title
|
||||
const initialTitle = await comfyPage.page.title()
|
||||
// Title might show execution state if other tests are running
|
||||
// Just ensure we can detect when it changes
|
||||
const hasExecutionState =
|
||||
initialTitle.match(/\[\d+%\]/) ||
|
||||
initialTitle.match(/\[\d+ nodes running\]/)
|
||||
|
||||
// Set up tracking for execution events
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start real execution using command instead of button
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for execution to start
|
||||
await executionHelper.waitForExecutionStart()
|
||||
|
||||
// Wait for title to update with execution state
|
||||
await titleMonitor.waitForExecutionTitle()
|
||||
|
||||
const executingTitle = await comfyPage.page.title()
|
||||
// If initial title didn't have execution state, it should be different now
|
||||
if (!hasExecutionState) {
|
||||
expect(executingTitle).not.toBe(initialTitle)
|
||||
}
|
||||
expect(executingTitle).toMatch(/\[[\d%\s\w]+\]/)
|
||||
})
|
||||
|
||||
test('Can read workflow name from title', async ({ comfyPage }) => {
|
||||
// Wait for any existing execution to complete
|
||||
await titleMonitor.waitForIdleTitle(5000).catch(async () => {
|
||||
// Cancel any running execution
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.page.waitForTimeout(1000)
|
||||
})
|
||||
|
||||
// Set a workflow name
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].extensionManager.workflow.activeWorkflow.filename =
|
||||
'test-workflow'
|
||||
})
|
||||
|
||||
// Wait for title to update
|
||||
await comfyPage.page.waitForTimeout(100)
|
||||
|
||||
const title = await comfyPage.page.title()
|
||||
expect(title).toContain('test-workflow')
|
||||
// Title should contain workflow name regardless of execution state
|
||||
})
|
||||
})
|
||||
@@ -47,42 +47,4 @@ test.describe('DOM Widget', () => {
|
||||
const finalCount = await comfyPage.getDOMWidgetCount()
|
||||
expect(finalCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('should reposition when layout changes', async ({ comfyPage }) => {
|
||||
// --- setup ---
|
||||
|
||||
const textareaWidget = comfyPage.page
|
||||
.locator('.comfy-multiline-input')
|
||||
.first()
|
||||
await expect(textareaWidget).toBeVisible()
|
||||
|
||||
await comfyPage.setSetting('Comfy.Sidebar.Size', 'small')
|
||||
await comfyPage.setSetting('Comfy.Sidebar.Location', 'left')
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
let oldPos: [number, number]
|
||||
const checkBboxChange = async () => {
|
||||
const boudningBox = (await textareaWidget.boundingBox())!
|
||||
expect(boudningBox).not.toBeNull()
|
||||
const position: [number, number] = [boudningBox.x, boudningBox.y]
|
||||
expect(position).not.toEqual(oldPos)
|
||||
oldPos = position
|
||||
}
|
||||
await checkBboxChange()
|
||||
|
||||
// --- test ---
|
||||
|
||||
await comfyPage.setSetting('Comfy.Sidebar.Size', 'normal')
|
||||
await comfyPage.nextFrame()
|
||||
await checkBboxChange()
|
||||
|
||||
await comfyPage.setSetting('Comfy.Sidebar.Location', 'right')
|
||||
await comfyPage.nextFrame()
|
||||
await checkBboxChange()
|
||||
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Bottom')
|
||||
await comfyPage.nextFrame()
|
||||
await checkBboxChange()
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
@@ -768,14 +768,8 @@ test.describe('Viewport settings', () => {
|
||||
comfyMouse
|
||||
}) => {
|
||||
// Screenshot the canvas element
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
|
||||
|
||||
await toggleButton.click()
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('Workflow A')
|
||||
await comfyPage.nextFrame()
|
||||
const screenshotA = (await comfyPage.canvas.screenshot()).toString('base64')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
|
||||
|
||||
// Save workflow as a new file, then zoom out before screen shot
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('Workflow B')
|
||||
@@ -783,12 +777,7 @@ test.describe('Viewport settings', () => {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await comfyMouse.wheel(0, 60)
|
||||
}
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
const screenshotB = (await comfyPage.canvas.screenshot()).toString('base64')
|
||||
|
||||
// Ensure that the screenshots are different due to zoom level
|
||||
expect(screenshotB).not.toBe(screenshotA)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
|
||||
|
||||
const tabA = comfyPage.menu.topbar.getWorkflowTab('Workflow A')
|
||||
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
|
||||
@@ -796,269 +785,11 @@ test.describe('Viewport settings', () => {
|
||||
// Go back to Workflow A
|
||||
await tabA.click()
|
||||
await comfyPage.nextFrame()
|
||||
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
||||
screenshotA
|
||||
)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
|
||||
|
||||
// And back to Workflow B
|
||||
await tabB.click()
|
||||
await comfyPage.nextFrame()
|
||||
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
||||
screenshotB
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Canvas Navigation', () => {
|
||||
test.describe('Legacy Mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'legacy')
|
||||
})
|
||||
|
||||
test('Left-click drag in empty area should pan canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.dragAndDrop({ x: 50, y: 50 }, { x: 150, y: 150 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'legacy-left-drag-pan.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Middle-click drag should pan canvas', async ({ comfyPage }) => {
|
||||
await comfyPage.page.mouse.move(50, 50)
|
||||
await comfyPage.page.mouse.down({ button: 'middle' })
|
||||
await comfyPage.page.mouse.move(150, 150)
|
||||
await comfyPage.page.mouse.up({ button: 'middle' })
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'legacy-middle-drag-pan.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Mouse wheel should zoom in/out', async ({ comfyPage }) => {
|
||||
await comfyPage.page.mouse.move(400, 300)
|
||||
await comfyPage.page.mouse.wheel(0, -120)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'legacy-wheel-zoom-in.png'
|
||||
)
|
||||
|
||||
await comfyPage.page.mouse.wheel(0, 240)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'legacy-wheel-zoom-out.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Left-click on node should not pan canvas', async ({ comfyPage }) => {
|
||||
await comfyPage.clickTextEncodeNode1()
|
||||
const selectedCount = await comfyPage.getSelectedGraphNodesCount()
|
||||
expect(selectedCount).toBe(1)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'legacy-click-node-select.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Standard Mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'standard')
|
||||
})
|
||||
|
||||
test('Left-click drag in empty area should select nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
|
||||
const clipNode1Pos = await clipNodes[0].getPosition()
|
||||
const clipNode2Pos = await clipNodes[1].getPosition()
|
||||
const offset = 64
|
||||
|
||||
await comfyPage.dragAndDrop(
|
||||
{
|
||||
x: Math.min(clipNode1Pos.x, clipNode2Pos.x) - offset,
|
||||
y: Math.min(clipNode1Pos.y, clipNode2Pos.y) - offset
|
||||
},
|
||||
{
|
||||
x: Math.max(clipNode1Pos.x, clipNode2Pos.x) + offset,
|
||||
y: Math.max(clipNode1Pos.y, clipNode2Pos.y) + offset
|
||||
}
|
||||
)
|
||||
|
||||
const selectedCount = await comfyPage.getSelectedGraphNodesCount()
|
||||
expect(selectedCount).toBe(clipNodes.length)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'standard-left-drag-select.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Middle-click drag should pan canvas', async ({ comfyPage }) => {
|
||||
await comfyPage.page.mouse.move(50, 50)
|
||||
await comfyPage.page.mouse.down({ button: 'middle' })
|
||||
await comfyPage.page.mouse.move(150, 150)
|
||||
await comfyPage.page.mouse.up({ button: 'middle' })
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'standard-middle-drag-pan.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Ctrl + mouse wheel should zoom in/out', async ({ comfyPage }) => {
|
||||
await comfyPage.page.mouse.move(400, 300)
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.mouse.wheel(0, -120)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'standard-ctrl-wheel-zoom-in.png'
|
||||
)
|
||||
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.mouse.wheel(0, 240)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'standard-ctrl-wheel-zoom-out.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Left-click on node should select node (not start selection box)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.clickTextEncodeNode1()
|
||||
const selectedCount = await comfyPage.getSelectedGraphNodesCount()
|
||||
expect(selectedCount).toBe(1)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'standard-click-node-select.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Space + left-click drag should pan canvas', async ({ comfyPage }) => {
|
||||
// Click canvas to focus it
|
||||
await comfyPage.page.click('canvas')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.down('Space')
|
||||
await comfyPage.dragAndDrop({ x: 50, y: 50 }, { x: 150, y: 150 })
|
||||
await comfyPage.page.keyboard.up('Space')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'standard-space-drag-pan.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Space key overrides default left-click behavior', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
|
||||
const clipNode1Pos = await clipNodes[0].getPosition()
|
||||
const offset = 64
|
||||
|
||||
await comfyPage.dragAndDrop(
|
||||
{
|
||||
x: clipNode1Pos.x - offset,
|
||||
y: clipNode1Pos.y - offset
|
||||
},
|
||||
{
|
||||
x: clipNode1Pos.x + offset,
|
||||
y: clipNode1Pos.y + offset
|
||||
}
|
||||
)
|
||||
|
||||
const selectedCountAfterDrag =
|
||||
await comfyPage.getSelectedGraphNodesCount()
|
||||
expect(selectedCountAfterDrag).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.clickEmptySpace()
|
||||
const selectedCountAfterClear =
|
||||
await comfyPage.getSelectedGraphNodesCount()
|
||||
expect(selectedCountAfterClear).toBe(0)
|
||||
|
||||
await comfyPage.page.keyboard.down('Space')
|
||||
await comfyPage.dragAndDrop(
|
||||
{
|
||||
x: clipNode1Pos.x - offset,
|
||||
y: clipNode1Pos.y - offset
|
||||
},
|
||||
{
|
||||
x: clipNode1Pos.x + offset,
|
||||
y: clipNode1Pos.y + offset
|
||||
}
|
||||
)
|
||||
await comfyPage.page.keyboard.up('Space')
|
||||
|
||||
const selectedCountAfterSpaceDrag =
|
||||
await comfyPage.getSelectedGraphNodesCount()
|
||||
expect(selectedCountAfterSpaceDrag).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test('Shift + mouse wheel should pan canvas horizontally', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.click('canvas')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('standard-initial.png')
|
||||
|
||||
await comfyPage.page.mouse.move(400, 300)
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.page.mouse.wheel(0, 120)
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'standard-shift-wheel-pan-right.png'
|
||||
)
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.page.mouse.wheel(0, -240)
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'standard-shift-wheel-pan-left.png'
|
||||
)
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.page.mouse.wheel(0, 120)
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'standard-shift-wheel-pan-center.png'
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Edge Cases', () => {
|
||||
test('Multiple modifier keys work correctly in legacy mode', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'legacy')
|
||||
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.dragAndDrop({ x: 50, y: 50 }, { x: 150, y: 150 })
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'legacy-alt-shift-drag.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Cursor changes appropriately in different modes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const getCursorStyle = async () => {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
return (
|
||||
document.getElementById('graph-canvas')!.style.cursor || 'default'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'legacy')
|
||||
await comfyPage.page.mouse.move(50, 50)
|
||||
await comfyPage.page.mouse.down()
|
||||
expect(await getCursorStyle()).toBe('grabbing')
|
||||
await comfyPage.page.mouse.up()
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 68 KiB |
@@ -15,8 +15,7 @@ test.describe('Load Workflow in Media', () => {
|
||||
'workflow.mp4',
|
||||
'workflow.mov',
|
||||
'workflow.m4v',
|
||||
'workflow.svg',
|
||||
'workflow.avif'
|
||||
'workflow.svg'
|
||||
]
|
||||
fileNames.forEach(async (fileName) => {
|
||||
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
|
||||
|
||||
|
Before Width: | Height: | Size: 67 KiB |
@@ -63,7 +63,7 @@ test.describe('Menu', () => {
|
||||
test('@mobile Items fully visible on mobile screen width', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.topbar.openTopbarMenu()
|
||||
await comfyPage.menu.topbar.openSubmenuMobile()
|
||||
const topLevelMenuItem = comfyPage.page
|
||||
.locator('a.p-menubar-item-link')
|
||||
.first()
|
||||
@@ -74,9 +74,8 @@ test.describe('Menu', () => {
|
||||
})
|
||||
|
||||
test('Displays keybinding next to item', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.openTopbarMenu()
|
||||
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('Workflow')
|
||||
await workflowMenuItem.hover()
|
||||
await workflowMenuItem.click()
|
||||
const exportTag = comfyPage.page.locator('.keybinding-tag', {
|
||||
hasText: 'Ctrl + s'
|
||||
})
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Minimap', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.Minimap.Visible', true)
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.loadWorkflow('default')
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => window['app'] && window['app'].canvas
|
||||
)
|
||||
})
|
||||
|
||||
test('Validate minimap is visible by default', async ({ comfyPage }) => {
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
const minimapCanvas = minimapContainer.locator('.minimap-canvas')
|
||||
await expect(minimapCanvas).toBeVisible()
|
||||
|
||||
const minimapViewport = minimapContainer.locator('.minimap-viewport')
|
||||
await expect(minimapViewport).toBeVisible()
|
||||
|
||||
await expect(minimapContainer).toHaveCSS('position', 'absolute')
|
||||
await expect(minimapContainer).toHaveCSS('z-index', '1000')
|
||||
})
|
||||
|
||||
test('Validate minimap toggle button state', async ({ comfyPage }) => {
|
||||
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
|
||||
|
||||
await expect(toggleButton).toBeVisible()
|
||||
|
||||
await expect(toggleButton).toHaveClass(/minimap-active/)
|
||||
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
const toggleButton = comfyPage.page.getByTestId('toggle-minimap-button')
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
await expect(toggleButton).toHaveClass(/minimap-active/)
|
||||
|
||||
await toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimapContainer).not.toBeVisible()
|
||||
await expect(toggleButton).not.toHaveClass(/minimap-active/)
|
||||
|
||||
await toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
await expect(toggleButton).toHaveClass(/minimap-active/)
|
||||
})
|
||||
|
||||
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimapContainer).not.toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
})
|
||||
385
browser_tests/tests/multiNodeExecution.spec.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../fixtures/ws'
|
||||
import {
|
||||
ExecutionTestHelper,
|
||||
PreviewTestHelper
|
||||
} from '../helpers/ExecutionTestHelper'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
test.describe('Multi-node Execution Progress', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let executionHelper: ExecutionTestHelper
|
||||
let previewHelper: PreviewTestHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
executionHelper = new ExecutionTestHelper(comfyPage.page)
|
||||
previewHelper = new PreviewTestHelper(comfyPage.page)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (executionHelper) {
|
||||
await executionHelper.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('Can track progress of multiple async nodes executing in parallel', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Get references to the async nodes
|
||||
const sleepNode1 = await comfyPage.getNodeRefById(2)
|
||||
const sleepNode2 = await comfyPage.getNodeRefById(3)
|
||||
const progressNode = await comfyPage.getNodeRefById(4)
|
||||
|
||||
// Verify nodes are present
|
||||
expect(sleepNode1).toBeDefined()
|
||||
expect(sleepNode2).toBeDefined()
|
||||
expect(progressNode).toBeDefined()
|
||||
|
||||
// Set up tracking for progress events
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start execution
|
||||
await comfyPage.queueButton.click()
|
||||
|
||||
// Wait for all three nodes (2, 3, 4) to show progress from real execution
|
||||
const testId = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes) return false
|
||||
|
||||
const node2 = latestState.nodes['2']
|
||||
const node3 = latestState.nodes['3']
|
||||
const node4 = latestState.nodes['4']
|
||||
|
||||
// Check that all nodes have started executing
|
||||
return (
|
||||
node2 &&
|
||||
node2.state === 'running' &&
|
||||
node3 &&
|
||||
node3.state === 'running' &&
|
||||
node4 &&
|
||||
node4.state === 'running'
|
||||
)
|
||||
},
|
||||
testId,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
// Wait for progress to be applied to all nodes in the graph
|
||||
await executionHelper.waitForGraphNodeProgress([2, 3, 4])
|
||||
|
||||
// Check that all nodes show progress
|
||||
const nodeProgress1 = await sleepNode1.getProperty('progress')
|
||||
const nodeProgress2 = await sleepNode2.getProperty('progress')
|
||||
const nodeProgress3 = await progressNode.getProperty('progress')
|
||||
|
||||
// Progress values should now be defined (exact values depend on timing)
|
||||
expect(nodeProgress1).toBeDefined()
|
||||
expect(nodeProgress2).toBeDefined()
|
||||
expect(nodeProgress3).toBeDefined()
|
||||
expect(nodeProgress1).toBeGreaterThanOrEqual(0)
|
||||
expect(nodeProgress1).toBeLessThanOrEqual(1)
|
||||
expect(nodeProgress2).toBeGreaterThanOrEqual(0)
|
||||
expect(nodeProgress2).toBeLessThanOrEqual(1)
|
||||
expect(nodeProgress3).toBeGreaterThanOrEqual(0)
|
||||
expect(nodeProgress3).toBeLessThanOrEqual(1)
|
||||
|
||||
// Wait for at least one node to finish
|
||||
await executionHelper.waitForNodeFinish()
|
||||
|
||||
// Wait for the finished node's progress to be cleared
|
||||
const testId2 = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes) return false
|
||||
|
||||
// Find which nodes are finished
|
||||
const finishedNodeIds = Object.entries(latestState.nodes)
|
||||
.filter(([_, node]: [string, any]) => node.state === 'finished')
|
||||
.map(([id, _]) => id)
|
||||
|
||||
// Check that finished nodes have no progress in the graph
|
||||
return finishedNodeIds.some((id) => {
|
||||
const node = window['app'].graph.getNodeById(parseInt(id))
|
||||
return node && node.progress === undefined
|
||||
})
|
||||
},
|
||||
testId2,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Get current state of nodes
|
||||
const testId3 = executionHelper.getTestId()
|
||||
const currentState = await comfyPage.page.evaluate((testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return null
|
||||
const latestState = states[states.length - 1]
|
||||
const graphNodes = {
|
||||
'2': window['app'].graph.getNodeById(2),
|
||||
'3': window['app'].graph.getNodeById(3),
|
||||
'4': window['app'].graph.getNodeById(4)
|
||||
}
|
||||
|
||||
return {
|
||||
stateNodes: latestState.nodes,
|
||||
graphProgress: {
|
||||
'2': graphNodes['2']?.progress,
|
||||
'3': graphNodes['3']?.progress,
|
||||
'4': graphNodes['4']?.progress
|
||||
}
|
||||
}
|
||||
}, testId3)
|
||||
|
||||
// Verify that finished nodes have no progress, running nodes have progress
|
||||
if (currentState && currentState.stateNodes) {
|
||||
Object.entries(currentState.stateNodes).forEach(
|
||||
([nodeId, nodeState]: [string, any]) => {
|
||||
const graphProgress = currentState.graphProgress[nodeId]
|
||||
if (nodeState.state === 'finished') {
|
||||
expect(graphProgress).toBeUndefined()
|
||||
} else if (nodeState.state === 'running') {
|
||||
expect(graphProgress).toBeDefined()
|
||||
expect(graphProgress).toBeGreaterThanOrEqual(0)
|
||||
expect(graphProgress).toBeLessThanOrEqual(1)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Clean up by canceling execution
|
||||
})
|
||||
|
||||
test('Updates visual state for multiple executing nodes', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Wait for the graph to be properly initialized
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => {
|
||||
return window['app']?.graph?.nodes?.length > 0
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Set up tracking for progress events
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start execution
|
||||
await comfyPage.queueButton.click()
|
||||
|
||||
// Wait for multiple nodes to start executing
|
||||
await executionHelper.waitForRunningNodes(2)
|
||||
|
||||
// Wait for the progress to be applied to nodes
|
||||
await executionHelper.waitForGraphNodeProgress([2, 3])
|
||||
|
||||
// Verify that nodes have progress set (indicates they are executing)
|
||||
const nodeStates = await comfyPage.page.evaluate(() => {
|
||||
const node2 = window['app'].graph.getNodeById(2)
|
||||
const node3 = window['app'].graph.getNodeById(3)
|
||||
return {
|
||||
node2Progress: node2?.progress,
|
||||
node3Progress: node3?.progress,
|
||||
// Check if any nodes are marked as running by having progress
|
||||
hasRunningNodes:
|
||||
(node2?.progress !== undefined && node2?.progress >= 0) ||
|
||||
(node3?.progress !== undefined && node3?.progress >= 0)
|
||||
}
|
||||
})
|
||||
|
||||
expect(nodeStates.node2Progress).toBeDefined()
|
||||
expect(nodeStates.node3Progress).toBeDefined()
|
||||
expect(nodeStates.hasRunningNodes).toBe(true)
|
||||
|
||||
// Wait for at least one node to finish
|
||||
await executionHelper.waitForNodeFinish()
|
||||
|
||||
// Wait for progress updates to reflect the finished state
|
||||
const testId4 = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes) return false
|
||||
|
||||
// Find nodes by their state
|
||||
const finishedNodes = Object.entries(latestState.nodes)
|
||||
.filter(([_, node]: [string, any]) => node.state === 'finished')
|
||||
.map(([id, _]) => parseInt(id))
|
||||
|
||||
const runningNodes = Object.entries(latestState.nodes)
|
||||
.filter(([_, node]: [string, any]) => node.state === 'running')
|
||||
.map(([id, _]) => parseInt(id))
|
||||
|
||||
// Check graph nodes match the state
|
||||
const allFinishedCorrect = finishedNodes.every((id) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
return node && node.progress === undefined
|
||||
})
|
||||
|
||||
const allRunningCorrect = runningNodes.every((id) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
return node && node.progress !== undefined && node.progress >= 0
|
||||
})
|
||||
|
||||
return (
|
||||
allFinishedCorrect && allRunningCorrect && finishedNodes.length > 0
|
||||
)
|
||||
},
|
||||
testId4,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Verify the final node states
|
||||
const testId5 = executionHelper.getTestId()
|
||||
const finalNodeStates = await comfyPage.page.evaluate((testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return null
|
||||
const latestState = states[states.length - 1]
|
||||
const node2 = window['app'].graph.getNodeById(2)
|
||||
const node3 = window['app'].graph.getNodeById(3)
|
||||
|
||||
return {
|
||||
node2State: latestState.nodes['2']?.state,
|
||||
node3State: latestState.nodes['3']?.state,
|
||||
node2Progress: node2?.progress,
|
||||
node3Progress: node3?.progress
|
||||
}
|
||||
}, testId5)
|
||||
|
||||
// Verify finished nodes have no progress, running nodes have progress
|
||||
if (finalNodeStates) {
|
||||
if (finalNodeStates.node2State === 'finished') {
|
||||
expect(finalNodeStates.node2Progress).toBeUndefined()
|
||||
} else if (finalNodeStates.node2State === 'running') {
|
||||
expect(finalNodeStates.node2Progress).toBeDefined()
|
||||
}
|
||||
|
||||
if (finalNodeStates.node3State === 'finished') {
|
||||
expect(finalNodeStates.node3Progress).toBeUndefined()
|
||||
} else if (finalNodeStates.node3State === 'running') {
|
||||
expect(finalNodeStates.node3Progress).toBeDefined()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Clears previews when nodes start executing', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Initialize tracking for revoked previews
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['__revokedNodes'] = []
|
||||
})
|
||||
|
||||
// Set up some fake previews
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].nodePreviewImages['2'] = ['fake-preview-url-1']
|
||||
window['app'].nodePreviewImages['3'] = ['fake-preview-url-2']
|
||||
})
|
||||
|
||||
// Verify previews exist
|
||||
const previewsBefore = await comfyPage.page.evaluate(() => {
|
||||
return {
|
||||
node2: window['app'].nodePreviewImages['2'],
|
||||
node3: window['app'].nodePreviewImages['3']
|
||||
}
|
||||
})
|
||||
|
||||
expect(previewsBefore.node2).toEqual(['fake-preview-url-1'])
|
||||
expect(previewsBefore.node3).toEqual(['fake-preview-url-2'])
|
||||
|
||||
// Mock revokePreviews to track calls and set up event listeners
|
||||
await previewHelper.setupPreviewTracking()
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start real execution using command
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for execution to start
|
||||
await executionHelper.waitForExecutionStart()
|
||||
|
||||
// Wait for real execution to trigger progress events that clear previews
|
||||
const testId6 = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
// Check if we have progress for nodes 2 and 3
|
||||
const hasNode2Progress = states.some(
|
||||
(state: any) =>
|
||||
state.nodes &&
|
||||
state.nodes['2'] &&
|
||||
state.nodes['2'].state === 'running'
|
||||
)
|
||||
const hasNode3Progress = states.some(
|
||||
(state: any) =>
|
||||
state.nodes &&
|
||||
state.nodes['3'] &&
|
||||
state.nodes['3'].state === 'running'
|
||||
)
|
||||
|
||||
return hasNode2Progress && hasNode3Progress
|
||||
},
|
||||
testId6,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
// Wait for the event to be processed and previews to be revoked
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => {
|
||||
const revokedNodes = window['__revokedNodes']
|
||||
const node2PreviewCleared =
|
||||
window['app'].nodePreviewImages['2'] === undefined
|
||||
const node3PreviewCleared =
|
||||
window['app'].nodePreviewImages['3'] === undefined
|
||||
|
||||
return (
|
||||
revokedNodes.includes('2') &&
|
||||
revokedNodes.includes('3') &&
|
||||
node2PreviewCleared &&
|
||||
node3PreviewCleared
|
||||
)
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Check that revokePreviews was called for both nodes
|
||||
const revokedNodes = await previewHelper.getRevokedNodes()
|
||||
expect(revokedNodes).toContain('2')
|
||||
expect(revokedNodes).toContain('3')
|
||||
|
||||
// Check that previews were cleared
|
||||
const previewsAfter = await comfyPage.page.evaluate(() => {
|
||||
return {
|
||||
node2: window['app'].nodePreviewImages['2'],
|
||||
node3: window['app'].nodePreviewImages['3']
|
||||
}
|
||||
})
|
||||
|
||||
expect(previewsAfter.node2).toBeUndefined()
|
||||
expect(previewsAfter.node3).toBeUndefined()
|
||||
})
|
||||
})
|
||||
168
browser_tests/tests/multiNodeExecutionSimple.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../fixtures/ws'
|
||||
import {
|
||||
ExecutionTestHelper,
|
||||
PreviewTestHelper
|
||||
} from '../helpers/ExecutionTestHelper'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
test.describe('Multi-node Execution Progress - Simple', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let executionHelper: ExecutionTestHelper
|
||||
let previewHelper: PreviewTestHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
executionHelper = new ExecutionTestHelper(comfyPage.page)
|
||||
previewHelper = new PreviewTestHelper(comfyPage.page)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (executionHelper) {
|
||||
await executionHelper.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('Can dispatch and receive progress_state events', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Set up event tracking
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start real execution
|
||||
await comfyPage.queueButton.click()
|
||||
|
||||
// Wait for real progress_state events from backend
|
||||
const testId = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
return latestState.nodes && Object.keys(latestState.nodes).length > 0
|
||||
},
|
||||
testId,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
// Get the captured states
|
||||
const eventState = await executionHelper.getEventState()
|
||||
const result = eventState.progressStates
|
||||
|
||||
// Should have captured real events
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
const firstState = result[0]
|
||||
expect(firstState).toBeDefined()
|
||||
expect(firstState.prompt_id).toBeDefined()
|
||||
expect(firstState.nodes).toBeDefined()
|
||||
|
||||
// Check that we got real node progress
|
||||
const nodeIds = Object.keys(firstState.nodes)
|
||||
expect(nodeIds.length).toBeGreaterThan(0)
|
||||
|
||||
// Verify node structure
|
||||
for (const nodeId of nodeIds) {
|
||||
const node = firstState.nodes[nodeId]
|
||||
expect(node.state).toBeDefined()
|
||||
expect(node.node_id).toBeDefined()
|
||||
expect(node.display_node_id).toBeDefined()
|
||||
expect(node.prompt_id).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
test('Canvas updates when nodes have progress', async ({ comfyPage, ws }) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Set up progress tracking
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start real execution
|
||||
await comfyPage.queueButton.click()
|
||||
|
||||
// Wait for nodes to have progress from real execution
|
||||
const testId2 = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes) return false
|
||||
|
||||
// Check if any nodes are running with progress
|
||||
const runningNodes = Object.values(latestState.nodes).filter(
|
||||
(node: any) => node.state === 'running' && node.value > 0
|
||||
)
|
||||
|
||||
return runningNodes.length > 0
|
||||
},
|
||||
testId2,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
// Wait for progress to be applied to graph nodes
|
||||
await executionHelper.waitForGraphNodeProgress([2, 3, 4])
|
||||
|
||||
// Check that nodes have progress set from real execution
|
||||
const node2Progress = await executionHelper.getGraphNodeProgress(2)
|
||||
const node3Progress = await executionHelper.getGraphNodeProgress(3)
|
||||
const node4Progress = await executionHelper.getGraphNodeProgress(4)
|
||||
|
||||
// At least one node should have progress
|
||||
const hasProgress =
|
||||
(node2Progress !== undefined && node2Progress > 0) ||
|
||||
(node3Progress !== undefined && node3Progress > 0) ||
|
||||
(node4Progress !== undefined && node4Progress > 0)
|
||||
|
||||
expect(hasProgress).toBe(true)
|
||||
})
|
||||
|
||||
test('Preview events include metadata', async ({ comfyPage, ws }) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Track preview events
|
||||
await previewHelper.setupPreviewTracking()
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start real execution using command
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for execution to start
|
||||
await executionHelper.waitForExecutionStart()
|
||||
|
||||
// For this test, we'll check the event structure by simulating one
|
||||
// since real preview events depend on the workflow actually generating images
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const api = window['app'].api
|
||||
// Simulate a preview event that would come from backend
|
||||
api.dispatchCustomEvent('b_preview_with_metadata', {
|
||||
blob: new Blob(['test'], { type: 'image/png' }),
|
||||
nodeId: '10:5:3',
|
||||
displayNodeId: '10',
|
||||
parentNodeId: '10:5',
|
||||
realNodeId: '3',
|
||||
promptId: 'test-prompt-id'
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Check captured events
|
||||
const captured = await previewHelper.getPreviewEvents()
|
||||
expect(captured).toHaveLength(1)
|
||||
expect(captured[0]).toEqual({
|
||||
nodeId: '10:5:3',
|
||||
displayNodeId: '10',
|
||||
parentNodeId: '10:5',
|
||||
realNodeId: '3',
|
||||
promptId: 'test-prompt-id'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -27,21 +27,6 @@ test.describe('Node search box', () => {
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('New user (1.24.1+) gets search box by default on link release', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Start fresh to test new user behavior
|
||||
await comfyPage.setup({ clearStorage: true })
|
||||
// Simulate new user with 1.24.1+ installed version
|
||||
await comfyPage.setSetting('Comfy.InstalledVersion', '1.24.1')
|
||||
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
// Don't set LinkRelease settings explicitly to test versioned defaults
|
||||
|
||||
await comfyPage.disconnectEdge()
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
await expect(comfyPage.searchBox.input).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can add node', async ({ comfyPage }) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
@@ -187,10 +172,10 @@ test.describe('Node search box', () => {
|
||||
await comfyPage.page.mouse.click(panelBounds!.x - 10, panelBounds!.y - 10)
|
||||
|
||||
// Verify the filter selection panel is hidden
|
||||
await expect(panel.header).not.toBeVisible()
|
||||
expect(panel.header).not.toBeVisible()
|
||||
|
||||
// Verify the node search dialog is still visible
|
||||
await expect(comfyPage.searchBox.input).toBeVisible()
|
||||
expect(comfyPage.searchBox.input).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can add multiple filters', async ({ comfyPage }) => {
|
||||
@@ -279,38 +264,4 @@ test.describe('Release context menu', () => {
|
||||
'link-context-menu-search.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Existing user (pre-1.24.1) gets context menu by default on link release', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Start fresh to test existing user behavior
|
||||
await comfyPage.setup({ clearStorage: true })
|
||||
// Simulate existing user with pre-1.24.1 version
|
||||
await comfyPage.setSetting('Comfy.InstalledVersion', '1.23.0')
|
||||
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
// Don't set LinkRelease settings explicitly to test versioned defaults
|
||||
|
||||
await comfyPage.disconnectEdge()
|
||||
// Context menu should appear, search box should not
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(0)
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
})
|
||||
|
||||
test('Explicit setting overrides versioned defaults', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Start fresh and simulate new user who should get search box by default
|
||||
await comfyPage.setup({ clearStorage: true })
|
||||
await comfyPage.setSetting('Comfy.InstalledVersion', '1.24.1')
|
||||
// But explicitly set to context menu (overriding versioned default)
|
||||
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'context menu')
|
||||
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
|
||||
await comfyPage.disconnectEdge()
|
||||
// Context menu should appear due to explicit setting, not search box
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(0)
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
272
browser_tests/tests/previewWithMetadata.spec.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../fixtures/ws'
|
||||
import {
|
||||
ExecutionTestHelper,
|
||||
PreviewTestHelper
|
||||
} from '../helpers/ExecutionTestHelper'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
test.describe('Preview with Metadata', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let executionHelper: ExecutionTestHelper
|
||||
let previewHelper: PreviewTestHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
executionHelper = new ExecutionTestHelper(comfyPage.page)
|
||||
previewHelper = new PreviewTestHelper(comfyPage.page)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (executionHelper) {
|
||||
await executionHelper.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('Handles b_preview_with_metadata event correctly', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Clear any existing previews
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].nodePreviewImages = {}
|
||||
})
|
||||
|
||||
// Set up handler to track preview events and execution
|
||||
await executionHelper.setupEventTracking()
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['__previewHandled'] = false
|
||||
const api = window['app'].api
|
||||
|
||||
// Add handler to track preview events
|
||||
api.addEventListener('b_preview_with_metadata', (event) => {
|
||||
const { displayNodeId, blob } = event.detail
|
||||
// Create URL from the blob in the event
|
||||
const url = URL.createObjectURL(blob)
|
||||
window['app'].nodePreviewImages[displayNodeId] = [url]
|
||||
window['__previewHandled'] = true
|
||||
window['__lastPreviewUrl'] = url
|
||||
})
|
||||
})
|
||||
|
||||
// Start real execution to test event handling in context
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for execution to start
|
||||
await executionHelper.waitForExecutionStart()
|
||||
|
||||
// Trigger b_preview_with_metadata event (simulating what backend would send)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const api = window['app'].api
|
||||
const event = new CustomEvent('b_preview_with_metadata', {
|
||||
detail: {
|
||||
blob: new Blob(['test'], { type: 'image/png' }),
|
||||
nodeId: '2',
|
||||
displayNodeId: '2',
|
||||
parentNodeId: '2',
|
||||
realNodeId: '2',
|
||||
promptId: 'test-prompt-id'
|
||||
}
|
||||
})
|
||||
api.dispatchEvent(event)
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for preview to be handled
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => window['__previewHandled'] === true,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Check that preview was set for the correct node
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
return {
|
||||
previewImages: window['app'].nodePreviewImages,
|
||||
lastUrl: window['__lastPreviewUrl']
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.previewImages['2']).toBeDefined()
|
||||
expect(result.previewImages['2']).toHaveLength(1)
|
||||
expect(result.previewImages['2'][0]).toBe(result.lastUrl)
|
||||
})
|
||||
|
||||
test('Clears old previews when new preview arrives', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Set up initial preview
|
||||
const initialBlobUrl = await comfyPage.page.evaluate(() => {
|
||||
const blob = new Blob(['initial image'], { type: 'image/png' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
window['app'].nodePreviewImages['3'] = [url]
|
||||
return url
|
||||
})
|
||||
|
||||
// Create spy to track URL revocations
|
||||
await previewHelper.setupPreviewTracking()
|
||||
|
||||
// Mock the handler to revoke old previews
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const api = window['app'].api
|
||||
api.addEventListener('b_preview_with_metadata', (event) => {
|
||||
const { displayNodeId } = event.detail
|
||||
window['app'].revokePreviews(displayNodeId)
|
||||
const newBlob = new Blob(['new image'], { type: 'image/png' })
|
||||
const newUrl = URL.createObjectURL(newBlob)
|
||||
window['app'].nodePreviewImages[displayNodeId] = [newUrl]
|
||||
})
|
||||
})
|
||||
|
||||
// Trigger new preview for same node
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const api = window['app'].api
|
||||
const event = new CustomEvent('b_preview_with_metadata', {
|
||||
detail: {
|
||||
blob: new Blob(['new image'], { type: 'image/png' }),
|
||||
nodeId: '3',
|
||||
displayNodeId: '3',
|
||||
parentNodeId: '3',
|
||||
realNodeId: '3',
|
||||
promptId: 'test-prompt-id'
|
||||
}
|
||||
})
|
||||
api.dispatchEvent(event)
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Check that old URL was revoked
|
||||
const finalRevokedUrls = await previewHelper.getRevokedUrls()
|
||||
expect(finalRevokedUrls).toContain(initialBlobUrl)
|
||||
|
||||
// Check that new preview replaced old one
|
||||
const newPreviewImages = await previewHelper.getNodePreviews('3')
|
||||
|
||||
expect(newPreviewImages).toHaveLength(1)
|
||||
expect(newPreviewImages[0]).not.toBe(initialBlobUrl)
|
||||
})
|
||||
|
||||
test('Associates preview with correct display node in subgraph', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Mock handler that stores metadata
|
||||
await previewHelper.setupPreviewTracking()
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['__previewMetadata'] = {}
|
||||
const api = window['app'].api
|
||||
api.addEventListener('b_preview_with_metadata', (event) => {
|
||||
const { displayNodeId, nodeId, parentNodeId, realNodeId, promptId } =
|
||||
event.detail
|
||||
window['__previewMetadata'][displayNodeId] = {
|
||||
nodeId,
|
||||
displayNodeId,
|
||||
parentNodeId,
|
||||
realNodeId,
|
||||
promptId
|
||||
}
|
||||
// Still create the preview
|
||||
const url = URL.createObjectURL(event.detail.blob)
|
||||
window['app'].nodePreviewImages[displayNodeId] = [url]
|
||||
})
|
||||
})
|
||||
|
||||
// Simulate preview from a subgraph node
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const api = window['app'].api
|
||||
const event = new CustomEvent('b_preview_with_metadata', {
|
||||
detail: {
|
||||
blob: new Blob(['subgraph preview'], { type: 'image/png' }),
|
||||
nodeId: '10:5:3', // Nested execution ID
|
||||
displayNodeId: '10', // Top-level display node
|
||||
parentNodeId: '10:5', // Parent subgraph
|
||||
realNodeId: '3', // Actual node ID within subgraph
|
||||
promptId: 'test-prompt-id'
|
||||
}
|
||||
})
|
||||
api.dispatchEvent(event)
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Check that preview is associated with display node
|
||||
const metadata = await comfyPage.page.evaluate(
|
||||
() => window['__previewMetadata']
|
||||
)
|
||||
expect(metadata['10']).toBeDefined()
|
||||
expect(metadata['10'].nodeId).toBe('10:5:3')
|
||||
expect(metadata['10'].displayNodeId).toBe('10')
|
||||
expect(metadata['10'].parentNodeId).toBe('10:5')
|
||||
expect(metadata['10'].realNodeId).toBe('3')
|
||||
|
||||
// Check that preview exists for display node
|
||||
const previews = await comfyPage.page.evaluate(
|
||||
() => window['app'].nodePreviewImages
|
||||
)
|
||||
expect(previews['10']).toBeDefined()
|
||||
expect(previews['10']).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('Maintains backward compatibility with b_preview event', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Track both events
|
||||
const eventsFired = await comfyPage.page.evaluate(() => {
|
||||
const events: string[] = []
|
||||
const api = window['app'].api
|
||||
|
||||
api.addEventListener('b_preview', () => {
|
||||
events.push('b_preview')
|
||||
})
|
||||
|
||||
api.addEventListener('b_preview_with_metadata', () => {
|
||||
events.push('b_preview_with_metadata')
|
||||
})
|
||||
|
||||
window['__eventsFired'] = events
|
||||
return events
|
||||
})
|
||||
|
||||
// Trigger b_preview_with_metadata
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const api = window['app'].api
|
||||
const blob = new Blob(['test image'], { type: 'image/png' })
|
||||
|
||||
// Simulate the API behavior
|
||||
api.dispatchCustomEvent('b_preview_with_metadata', {
|
||||
blob,
|
||||
nodeId: '2',
|
||||
displayNodeId: '2',
|
||||
parentNodeId: '2',
|
||||
realNodeId: '2',
|
||||
promptId: 'test-prompt-id'
|
||||
})
|
||||
|
||||
// Also dispatch legacy event as the API would
|
||||
api.dispatchCustomEvent('b_preview', blob)
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Check that both events were fired
|
||||
const finalEvents = await comfyPage.page.evaluate(
|
||||
() => window['__eventsFired']
|
||||
)
|
||||
expect(finalEvents).toContain('b_preview_with_metadata')
|
||||
expect(finalEvents).toContain('b_preview')
|
||||
})
|
||||
})
|
||||
@@ -1,469 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
// Constants
|
||||
const RENAMED_INPUT_NAME = 'renamed_input'
|
||||
const NEW_SUBGRAPH_TITLE = 'New Subgraph'
|
||||
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
|
||||
const TEST_WIDGET_CONTENT = 'Test content that should persist'
|
||||
|
||||
// Common selectors
|
||||
const SELECTORS = {
|
||||
breadcrumb: '.subgraph-breadcrumb',
|
||||
promptDialog: '.graphdialog input',
|
||||
nodeSearchContainer: '.node-search-container',
|
||||
domWidget: '.comfy-multiline-input'
|
||||
} as const
|
||||
|
||||
test.describe('Subgraph Operations', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
// Helper to get subgraph slot count
|
||||
async function getSubgraphSlotCount(
|
||||
comfyPage: typeof test.prototype.comfyPage,
|
||||
type: 'inputs' | 'outputs'
|
||||
): Promise<number> {
|
||||
return await comfyPage.page.evaluate((slotType) => {
|
||||
return window['app'].canvas.graph[slotType]?.length || 0
|
||||
}, type)
|
||||
}
|
||||
|
||||
// Helper to get current graph node count
|
||||
async function getGraphNodeCount(
|
||||
comfyPage: typeof test.prototype.comfyPage
|
||||
): Promise<number> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
return window['app'].canvas.graph.nodes?.length || 0
|
||||
})
|
||||
}
|
||||
|
||||
// Helper to verify we're in a subgraph
|
||||
async function isInSubgraph(
|
||||
comfyPage: typeof test.prototype.comfyPage
|
||||
): Promise<boolean> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph?.constructor?.name === 'Subgraph'
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('I/O Slot Management', () => {
|
||||
test('Can add input slots to subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||
const vaeEncodeNode = await comfyPage.getNodeRefById('2')
|
||||
|
||||
await comfyPage.connectFromSubgraphInput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||
expect(finalCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('Can add output slots to subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||
const vaeEncodeNode = await comfyPage.getNodeRefById('2')
|
||||
|
||||
await comfyPage.connectToSubgraphOutput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||
expect(finalCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('Can remove input slots from subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.rightClickSubgraphInputSlot()
|
||||
await comfyPage.clickLitegraphContextMenuItem('Remove Slot')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||
expect(finalCount).toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('Can remove output slots from subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.rightClickSubgraphOutputSlot()
|
||||
await comfyPage.clickLitegraphContextMenuItem('Remove Slot')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||
expect(finalCount).toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('Can rename I/O slots', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph.inputs?.[0]?.label || null
|
||||
})
|
||||
|
||||
await comfyPage.rightClickSubgraphInputSlot(initialInputLabel)
|
||||
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph.inputs?.[0]?.label || null
|
||||
})
|
||||
|
||||
expect(newInputName).toBe(RENAMED_INPUT_NAME)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Creation and Deletion', () => {
|
||||
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('default')
|
||||
|
||||
const initialNodeCount = await getGraphNodeCount(comfyPage)
|
||||
|
||||
await comfyPage.ctrlA()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const node = await comfyPage.getNodeRefById('5')
|
||||
await node.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodes =
|
||||
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
expect(subgraphNodes.length).toBe(1)
|
||||
|
||||
const finalNodeCount = await getGraphNodeCount(comfyPage)
|
||||
expect(finalNodeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('Can delete subgraph node', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
const initialNodeCount = await getGraphNodeCount(comfyPage)
|
||||
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalNodeCount = await getGraphNodeCount(comfyPage)
|
||||
expect(finalNodeCount).toBe(initialNodeCount - 1)
|
||||
|
||||
const deletedNode = await comfyPage.getNodeRefById('2')
|
||||
expect(await deletedNode.exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Operations Inside Subgraphs', () => {
|
||||
test('Can copy and paste nodes in subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialNodeCount = await getGraphNodeCount(comfyPage)
|
||||
|
||||
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window['app'].canvas.graph.nodes
|
||||
return nodes?.[0]?.id || null
|
||||
})
|
||||
|
||||
expect(nodesInSubgraph).not.toBeNull()
|
||||
|
||||
const nodeToClone = await comfyPage.getNodeRefById(
|
||||
String(nodesInSubgraph)
|
||||
)
|
||||
await nodeToClone.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+c')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+v')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalNodeCount = await getGraphNodeCount(comfyPage)
|
||||
expect(finalNodeCount).toBe(initialNodeCount + 1)
|
||||
})
|
||||
|
||||
test('Can undo and redo operations in subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Add a node
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Get initial node count
|
||||
const initialCount = await getGraphNodeCount(comfyPage)
|
||||
|
||||
// Undo
|
||||
await comfyPage.ctrlZ()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterUndoCount = await getGraphNodeCount(comfyPage)
|
||||
expect(afterUndoCount).toBe(initialCount - 1)
|
||||
|
||||
// Redo
|
||||
await comfyPage.ctrlY()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterRedoCount = await getGraphNodeCount(comfyPage)
|
||||
expect(afterRedoCount).toBe(initialCount)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Navigation and UI', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Breadcrumb updates when subgraph node title is changed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('nested-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('10')
|
||||
const nodePos = await subgraphNode.getPosition()
|
||||
const nodeSize = await subgraphNode.getSize()
|
||||
|
||||
// Navigate into subgraph
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
|
||||
state: 'visible',
|
||||
timeout: 20000
|
||||
})
|
||||
|
||||
const breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb)
|
||||
const initialBreadcrumbText = await breadcrumb.textContent()
|
||||
|
||||
// Go back and edit title
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.canvas.dblclick({
|
||||
position: {
|
||||
x: nodePos.x + nodeSize.width / 2,
|
||||
y: nodePos.y - 10
|
||||
},
|
||||
delay: 5
|
||||
})
|
||||
|
||||
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back into subgraph
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
const updatedBreadcrumbText = await breadcrumb.textContent()
|
||||
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
|
||||
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('DOM Widget Promotion', () => {
|
||||
test('DOM widget visibility persists through subgraph navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify promoted widget is visible in parent graph
|
||||
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(parentTextarea).toBeVisible()
|
||||
await expect(parentTextarea).toHaveCount(1)
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('11')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Verify widget is visible in subgraph
|
||||
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(subgraphTextarea).toBeVisible()
|
||||
await expect(subgraphTextarea).toHaveCount(1)
|
||||
|
||||
// Navigate back
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify widget is still visible
|
||||
const backToParentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(backToParentTextarea).toBeVisible()
|
||||
await expect(backToParentTextarea).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('DOM widget content is preserved through navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget')
|
||||
|
||||
const textarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await textarea.fill(TEST_WIDGET_CONTENT)
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(parentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
})
|
||||
|
||||
test('DOM elements are cleaned up when subgraph node is removed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget')
|
||||
|
||||
const initialCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(initialCount).toBe(1)
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('11')
|
||||
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(finalCount).toBe(0)
|
||||
})
|
||||
|
||||
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Enable new menu for breadcrumb navigation
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
const workflowName = 'subgraph-with-promoted-text-widget'
|
||||
await comfyPage.loadWorkflow(workflowName)
|
||||
|
||||
const textareaCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(textareaCount).toBe(1)
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('11')
|
||||
|
||||
// Navigate into subgraph (method now handles retries internally)
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.rightClickSubgraphInputSlot('text')
|
||||
await comfyPage.clickLitegraphContextMenuItem('Remove Slot')
|
||||
await comfyPage.page.waitForTimeout(200)
|
||||
|
||||
// Wait for breadcrumb to be visible
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
|
||||
state: 'visible',
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Click breadcrumb to navigate back to parent graph
|
||||
const homeBreadcrumb = comfyPage.page.getByRole('link', {
|
||||
// In the subgraph navigation breadcrumbs, the home/top level
|
||||
// breadcrumb is just the workflow name
|
||||
name: workflowName
|
||||
})
|
||||
await homeBreadcrumb.waitFor({ state: 'visible' })
|
||||
await homeBreadcrumb.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.waitForTimeout(300)
|
||||
|
||||
// Check that the subgraph node has no widgets after removing the text slot
|
||||
const widgetCount = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].canvas.graph.nodes[0].widgets?.length || 0
|
||||
})
|
||||
|
||||
expect(widgetCount).toBe(0)
|
||||
})
|
||||
|
||||
test('Multiple promoted widgets are handled correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('subgraph-with-multiple-promoted-widgets')
|
||||
|
||||
const parentCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(parentCount).toBeGreaterThan(1)
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const subgraphCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(subgraphCount).toBe(parentCount)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(finalCount).toBe(parentCount)
|
||||
})
|
||||
})
|
||||
})
|
||||
82
browser_tests/tests/subgraphBreadcrumb.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Subgraph Breadcrumb Title Sync', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Breadcrumb updates when subgraph node title is changed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Load a workflow with subgraphs
|
||||
await comfyPage.loadWorkflow('nested-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Get the subgraph node by ID (node 10 is the subgraph)
|
||||
const subgraphNode = await comfyPage.getNodeRefById('10')
|
||||
|
||||
// Get node position and double-click on it to enter the subgraph
|
||||
const nodePos = await subgraphNode.getPosition()
|
||||
const nodeSize = await subgraphNode.getSize()
|
||||
await comfyPage.canvas.dblclick({
|
||||
position: {
|
||||
x: nodePos.x + nodeSize.width / 2,
|
||||
y: nodePos.y + nodeSize.height / 2 + 10
|
||||
}
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for breadcrumb to appear
|
||||
await comfyPage.page.waitForSelector('.subgraph-breadcrumb', {
|
||||
state: 'visible',
|
||||
timeout: 20000
|
||||
})
|
||||
|
||||
// Get initial breadcrumb text
|
||||
const breadcrumb = comfyPage.page.locator('.subgraph-breadcrumb')
|
||||
const initialBreadcrumbText = await breadcrumb.textContent()
|
||||
|
||||
// Go back to main graph
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
// Double-click on the title area of the subgraph node to edit
|
||||
await comfyPage.canvas.dblclick({
|
||||
position: {
|
||||
x: nodePos.x + nodeSize.width / 2,
|
||||
y: nodePos.y - 10 // Title area is above the node body
|
||||
},
|
||||
delay: 5
|
||||
})
|
||||
|
||||
// Wait for title editor to appear
|
||||
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
|
||||
|
||||
// Clear existing text and type new title
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
const newTitle = 'Updated Subgraph Title'
|
||||
await comfyPage.page.keyboard.type(newTitle)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Wait a frame for the update to complete
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Enter the subgraph again
|
||||
await comfyPage.canvas.dblclick({
|
||||
position: {
|
||||
x: nodePos.x + nodeSize.width / 2,
|
||||
y: nodePos.y + nodeSize.height / 2
|
||||
},
|
||||
delay: 5
|
||||
})
|
||||
|
||||
// Wait for breadcrumb
|
||||
await comfyPage.page.waitForSelector('.subgraph-breadcrumb')
|
||||
|
||||
// Check that breadcrumb now shows the new title
|
||||
const updatedBreadcrumbText = await breadcrumb.textContent()
|
||||
expect(updatedBreadcrumbText).toContain(newTitle)
|
||||
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
|
||||
})
|
||||
})
|
||||
237
browser_tests/tests/subgraphExecutionProgress.spec.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../fixtures/ws'
|
||||
import {
|
||||
ExecutionTestHelper,
|
||||
SubgraphTestHelper
|
||||
} from '../helpers/ExecutionTestHelper'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
test.describe('Subgraph Execution Progress', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
test.setTimeout(30000) // Increase timeout for subgraph tests
|
||||
|
||||
let executionHelper: ExecutionTestHelper
|
||||
let subgraphHelper: SubgraphTestHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
executionHelper = new ExecutionTestHelper(comfyPage.page)
|
||||
subgraphHelper = new SubgraphTestHelper(comfyPage.page)
|
||||
// Share the same test ID to access the same window properties
|
||||
subgraphHelper.setTestId(executionHelper.getTestId())
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (executionHelper) {
|
||||
await executionHelper.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('Shows progress for nodes inside subgraphs', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/nested-subgraph-test')
|
||||
|
||||
// Get reference to the subgraph node
|
||||
const subgraphNode = await comfyPage.getNodeRefById(10)
|
||||
expect(subgraphNode).toBeDefined()
|
||||
|
||||
// Set up tracking for progress events
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start real execution using command to avoid click issues
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for execution to start
|
||||
await executionHelper.waitForExecutionStart()
|
||||
|
||||
// Wait for real progress events from subgraph execution
|
||||
await subgraphHelper.waitForNestedNodeProgress(1)
|
||||
|
||||
// Wait for progress to be applied to the subgraph node
|
||||
await executionHelper.waitForGraphNodeProgress([10])
|
||||
|
||||
// Check that the subgraph node shows aggregated progress
|
||||
const subgraphProgress = await subgraphNode.getProperty('progress')
|
||||
|
||||
// The progress should be aggregated from child nodes
|
||||
expect(subgraphProgress).toBeDefined()
|
||||
expect(subgraphProgress).toBeGreaterThan(0)
|
||||
expect(subgraphProgress).toBeLessThanOrEqual(1)
|
||||
|
||||
// Wait for stroke style to be applied
|
||||
await comfyPage.page.waitForFunction(
|
||||
(nodeId) => {
|
||||
const node = window['app'].graph.getNodeById(nodeId)
|
||||
if (!node?.strokeStyles?.['running']) return false
|
||||
const style = node.strokeStyles['running'].call(node)
|
||||
return style?.color === '#0f0'
|
||||
},
|
||||
10,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Check stroke style
|
||||
const strokeStyle = await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window['app'].graph.getNodeById(nodeId)
|
||||
if (!node || !node.strokeStyles || !node.strokeStyles['running']) {
|
||||
return null
|
||||
}
|
||||
return node.strokeStyles['running'].call(node)
|
||||
}, 10)
|
||||
|
||||
expect(strokeStyle).toEqual({ color: '#0f0' })
|
||||
})
|
||||
|
||||
test('Handles deeply nested subgraph execution', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/nested-subgraph-test')
|
||||
|
||||
// Set up tracking for progress events
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start real execution using command to avoid click issues
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for execution to start
|
||||
await executionHelper.waitForExecutionStart()
|
||||
|
||||
// Wait for real progress events from deeply nested subgraph execution
|
||||
await subgraphHelper.waitForNestedNodeProgress(2)
|
||||
|
||||
// Wait for progress to be applied to the top-level subgraph node
|
||||
await executionHelper.waitForGraphNodeProgress([10])
|
||||
|
||||
// Check that top-level subgraph shows progress
|
||||
const subgraphNode = await comfyPage.getNodeRefById(10)
|
||||
const progress = await subgraphNode.getProperty('progress')
|
||||
|
||||
expect(progress).toBeDefined()
|
||||
expect(progress).toBeGreaterThan(0)
|
||||
expect(progress).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Shows running state for parent nodes when child executes', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/nested-subgraph-test')
|
||||
|
||||
// Track which nodes have running stroke style
|
||||
const getRunningNodes = async () => {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const runningNodes: number[] = []
|
||||
const nodes = window['app'].graph.nodes
|
||||
|
||||
for (const node of nodes) {
|
||||
if (
|
||||
node.strokeStyles?.['running'] &&
|
||||
node.strokeStyles['running'].call(node)?.color === '#0f0'
|
||||
) {
|
||||
runningNodes.push(node.id)
|
||||
}
|
||||
}
|
||||
|
||||
return runningNodes
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for any existing execution to complete
|
||||
await comfyPage.page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const nodes = window['app'].graph.nodes
|
||||
for (const node of nodes) {
|
||||
if (
|
||||
node.strokeStyles?.['running'] &&
|
||||
node.strokeStyles['running'].call(node)?.color === '#0f0'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
.catch(() => {
|
||||
// If timeout, continue anyway
|
||||
})
|
||||
|
||||
// Initially no nodes should be running
|
||||
let runningNodes = await getRunningNodes()
|
||||
expect(runningNodes).toHaveLength(0)
|
||||
|
||||
// Set up tracking for progress events
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start real execution using command to avoid click issues
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for execution to start
|
||||
await executionHelper.waitForExecutionStart()
|
||||
|
||||
// Wait for real nested node execution progress
|
||||
await subgraphHelper.waitForNestedNodeProgress(1)
|
||||
|
||||
// Wait for parent subgraph to show as running
|
||||
await comfyPage.page.waitForFunction(
|
||||
(nodeId) => {
|
||||
const node = window['app'].graph.getNodeById(nodeId)
|
||||
if (!node?.strokeStyles?.['running']) return false
|
||||
const style = node.strokeStyles['running'].call(node)
|
||||
return style?.color === '#0f0'
|
||||
},
|
||||
10,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Parent subgraph should show as running
|
||||
runningNodes = await getRunningNodes()
|
||||
expect(runningNodes).toContain(10)
|
||||
|
||||
// Wait for the execution to complete naturally
|
||||
const testId = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
// Check if execution is finished (no more running nodes)
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes) return false
|
||||
|
||||
const runningNodes = Object.values(latestState.nodes).filter(
|
||||
(node: any) => node.state === 'running'
|
||||
).length
|
||||
|
||||
return runningNodes === 0
|
||||
},
|
||||
testId,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
// Add a small delay to ensure UI updates
|
||||
await comfyPage.page.waitForTimeout(500)
|
||||
|
||||
// Wait for parent subgraph to no longer be running
|
||||
await comfyPage.page.waitForFunction(
|
||||
(nodeId) => {
|
||||
const node = window['app'].graph.getNodeById(nodeId)
|
||||
if (!node?.strokeStyles?.['running']) return true
|
||||
const style = node.strokeStyles['running'].call(node)
|
||||
return style?.color !== '#0f0'
|
||||
},
|
||||
10,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Parent should no longer be running
|
||||
runningNodes = await getRunningNodes()
|
||||
expect(runningNodes).not.toContain(10)
|
||||
})
|
||||
})
|
||||
@@ -1,117 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { SystemStats } from '../../src/schemas/apiSchema'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Version Mismatch Warnings', () => {
|
||||
const ALWAYS_AHEAD_OF_INSTALLED_VERSION = '100.100.100'
|
||||
const ALWAYS_BEHIND_INSTALLED_VERSION = '0.0.0'
|
||||
|
||||
const createMockSystemStatsRes = (
|
||||
requiredFrontendVersion: string
|
||||
): SystemStats => {
|
||||
return {
|
||||
system: {
|
||||
os: 'posix',
|
||||
ram_total: 67235385344,
|
||||
ram_free: 13464207360,
|
||||
comfyui_version: '0.3.46',
|
||||
required_frontend_version: requiredFrontendVersion,
|
||||
python_version: '3.12.3 (main, Jun 18 2025, 17:59:45) [GCC 13.3.0]',
|
||||
pytorch_version: '2.6.0+cu124',
|
||||
embedded_python: false,
|
||||
argv: ['main.py']
|
||||
},
|
||||
devices: [
|
||||
{
|
||||
name: 'cuda:0 NVIDIA GeForce RTX 4070 : cudaMallocAsync',
|
||||
type: 'cuda',
|
||||
index: 0,
|
||||
vram_total: 12557156352,
|
||||
vram_free: 2439249920,
|
||||
torch_vram_total: 0,
|
||||
torch_vram_free: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('should show version mismatch warnings when installed version lower than required', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock system_stats route to indicate that the installed version is always ahead of the required version
|
||||
await comfyPage.page.route('**/system_stats**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(
|
||||
createMockSystemStatsRes(ALWAYS_AHEAD_OF_INSTALLED_VERSION)
|
||||
)
|
||||
})
|
||||
})
|
||||
await comfyPage.setup()
|
||||
|
||||
// Expect a warning toast to be shown
|
||||
await expect(
|
||||
comfyPage.page.getByText('Version Compatibility Warning')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show version mismatch warnings when installed version is ahead of required', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock system_stats route to indicate that the installed version is always ahead of the required version
|
||||
await comfyPage.page.route('**/system_stats**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(
|
||||
createMockSystemStatsRes(ALWAYS_BEHIND_INSTALLED_VERSION)
|
||||
)
|
||||
})
|
||||
})
|
||||
await comfyPage.setup()
|
||||
|
||||
// Expect no warning toast to be shown
|
||||
await expect(
|
||||
comfyPage.page.getByText('Version Compatibility Warning')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should persist dismissed state across sessions', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock system_stats route to indicate that the installed version is always ahead of the required version
|
||||
await comfyPage.page.route('**/system_stats**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(
|
||||
createMockSystemStatsRes(ALWAYS_AHEAD_OF_INSTALLED_VERSION)
|
||||
)
|
||||
})
|
||||
})
|
||||
await comfyPage.setup()
|
||||
|
||||
// Locate the warning toast and dismiss it
|
||||
const warningToast = comfyPage.page
|
||||
.locator('div')
|
||||
.filter({ hasText: 'Version Compatibility' })
|
||||
.nth(3)
|
||||
await warningToast.waitFor({ state: 'visible' })
|
||||
const dismissButton = warningToast.getByRole('button', { name: 'Close' })
|
||||
await dismissButton.click()
|
||||
|
||||
// Reload the page, keeping local storage
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
|
||||
// The same warning from same versions should not be shown to the user again
|
||||
await expect(
|
||||
comfyPage.page.getByText('Version Compatibility Warning')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -48,7 +48,7 @@ test.describe('Combo text widget', () => {
|
||||
await comfyPage.page.keyboard.press('r')
|
||||
|
||||
// Wait for nodes' widgets to be updated
|
||||
await comfyPage.page.waitForTimeout(500)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const refreshedComboValues = await getComboValues()
|
||||
expect(refreshedComboValues).not.toEqual(initialComboValues)
|
||||
|
||||
72
dangerfile.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { danger, fail } from 'danger'
|
||||
|
||||
// Check if we should run the checks
|
||||
const shouldRunChecks = async () => {
|
||||
const allChangedFiles = [
|
||||
...danger.git.modified_files,
|
||||
...danger.git.created_files
|
||||
]
|
||||
const srcChanges = allChangedFiles.filter((file) => file.startsWith('src/'))
|
||||
|
||||
if (srcChanges.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check total lines changed in src files
|
||||
let totalLinesChanged = 0
|
||||
for (const file of srcChanges) {
|
||||
const diff = await danger.git.diffForFile(file)
|
||||
if (diff) {
|
||||
// Count only lines with actual content (non-empty after trimming whitespace)
|
||||
// This excludes empty lines and lines containing only spaces/tabs
|
||||
const additions =
|
||||
diff.added?.split('\n').filter((line) => line.trim()).length || 0
|
||||
const deletions =
|
||||
diff.removed?.split('\n').filter((line) => line.trim()).length || 0
|
||||
totalLinesChanged += additions + deletions
|
||||
}
|
||||
}
|
||||
|
||||
return totalLinesChanged > 3
|
||||
}
|
||||
|
||||
// Check if browser tests were updated
|
||||
const checkBrowserTestCoverage = () => {
|
||||
const allChangedFiles = [
|
||||
...danger.git.modified_files,
|
||||
...danger.git.created_files
|
||||
]
|
||||
const hasBrowserTestChanges = allChangedFiles.some(
|
||||
(file) => file.startsWith('browser_tests/') && file.endsWith('.ts')
|
||||
)
|
||||
|
||||
if (!hasBrowserTestChanges) {
|
||||
fail(`🧪 **E2E Test Coverage Missing**
|
||||
|
||||
All changes should be covered under E2E testing. Please add or update browser tests.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for screen recording in PR description
|
||||
const checkScreenRecording = () => {
|
||||
const description = danger.github.pr.body || ''
|
||||
const hasRecording =
|
||||
/github\.com\/user-attachments\/assets\/[a-f0-9-]+/i.test(description) ||
|
||||
/youtube\.com\/watch|youtu\.be\//i.test(description)
|
||||
|
||||
if (!hasRecording) {
|
||||
fail(`📹 **Visual Documentation Missing**
|
||||
|
||||
Please add a screen recording or screenshot:
|
||||
- GitHub: Drag & drop media to PR description
|
||||
- YouTube: Add YouTube link`)
|
||||
}
|
||||
}
|
||||
|
||||
// Run the checks only if conditions are met
|
||||
shouldRunChecks().then((shouldRun) => {
|
||||
if (shouldRun) {
|
||||
checkBrowserTestCoverage()
|
||||
checkScreenRecording()
|
||||
}
|
||||
})
|
||||
1227
package-lock.json
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.25.2",
|
||||
"version": "1.24.1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -40,12 +40,12 @@
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/node": "^20.14.8",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/three": "^0.169.0",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"chalk": "^5.3.0",
|
||||
"danger": "^13.0.4",
|
||||
"eslint": "^9.12.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
@@ -78,7 +78,7 @@
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.43",
|
||||
"@comfyorg/litegraph": "^0.16.19",
|
||||
"@comfyorg/litegraph": "^0.16.9",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
@@ -97,8 +97,6 @@
|
||||
"axios": "^1.8.2",
|
||||
"dompurify": "^3.2.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"extendable-media-recorder": "^9.2.27",
|
||||
"extendable-media-recorder-wav-encoder": "^7.0.129",
|
||||
"firebase": "^11.6.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
@@ -108,7 +106,6 @@
|
||||
"pinia": "^2.1.7",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.2.5",
|
||||
"semver": "^7.7.2",
|
||||
"three": "^0.170.0",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"vue": "^3.5.13",
|
||||
|
||||
@@ -30,11 +30,10 @@ import ComfyQueueButton from './ComfyQueueButton.vue'
|
||||
|
||||
const settingsStore = useSettingStore()
|
||||
|
||||
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
||||
const visible = computed(
|
||||
() => settingsStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
|
||||
const visible = computed(() => position.value !== 'Disabled')
|
||||
|
||||
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const dragHandleRef = ref<HTMLElement | null>(null)
|
||||
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', false)
|
||||
@@ -50,16 +49,7 @@ const {
|
||||
} = useDraggable(panelRef, {
|
||||
initialValue: { x: 0, y: 0 },
|
||||
handle: dragHandleRef,
|
||||
containerElement: document.body,
|
||||
onMove: (event) => {
|
||||
// Prevent dragging the menu over the top of the tabs
|
||||
if (position.value === 'Top') {
|
||||
const minY = topMenuRef?.value?.getBoundingClientRect().top ?? 40
|
||||
if (event.y < minY) {
|
||||
event.y = minY
|
||||
}
|
||||
}
|
||||
}
|
||||
containerElement: document.body
|
||||
})
|
||||
|
||||
// Update storedPosition when x or y changes
|
||||
@@ -192,6 +182,7 @@ const adjustMenuPosition = () => {
|
||||
|
||||
useEventListener(window, 'resize', adjustMenuPosition)
|
||||
|
||||
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
|
||||
const topMenuBounds = useElementBounding(topMenuRef)
|
||||
const overlapThreshold = 20 // pixels
|
||||
const isOverlappingWithTopMenu = computed(() => {
|
||||
|
||||
@@ -1,95 +1,47 @@
|
||||
<template>
|
||||
<div
|
||||
class="subgraph-breadcrumb w-auto"
|
||||
:class="{
|
||||
'subgraph-breadcrumb-collapse': collapseTabs,
|
||||
'subgraph-breadcrumb-overflow': overflowingTabs
|
||||
}"
|
||||
:style="{
|
||||
'--p-breadcrumb-gap': `${ITEM_GAP}px`,
|
||||
'--p-breadcrumb-item-min-width': `${MIN_WIDTH}px`,
|
||||
'--p-breadcrumb-item-padding': `${ITEM_PADDING}px`,
|
||||
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
|
||||
}"
|
||||
>
|
||||
<div v-if="workflowStore.isSubgraphActive" class="p-2 subgraph-breadcrumb">
|
||||
<Breadcrumb
|
||||
ref="breadcrumbRef"
|
||||
class="bg-transparent p-0"
|
||||
class="bg-transparent"
|
||||
:home="home"
|
||||
:model="items"
|
||||
aria-label="Graph navigation"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<SubgraphBreadcrumbItem
|
||||
:item="item"
|
||||
:is-active="item === items.at(-1)"
|
||||
/>
|
||||
</template>
|
||||
<template #separator
|
||||
><span style="transform: scale(1.5)"> / </span></template
|
||||
>
|
||||
</Breadcrumb>
|
||||
@item-click="handleItemClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Breadcrumb from 'primevue/breadcrumb'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onUpdated, ref, watch } from 'vue'
|
||||
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
const MIN_WIDTH = 28
|
||||
const ITEM_GAP = 8
|
||||
const ITEM_PADDING = 8
|
||||
const ICON_WIDTH = 20
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
|
||||
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
const collapseTabs = ref(false)
|
||||
const overflowingTabs = ref(false)
|
||||
|
||||
const breadcrumbElement = computed(() => {
|
||||
if (!breadcrumbRef.value) return null
|
||||
|
||||
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
|
||||
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
|
||||
return list
|
||||
})
|
||||
|
||||
const items = computed(() => {
|
||||
const items = navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
|
||||
if (!navigationStore.navigationStack.length) return []
|
||||
|
||||
return navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
|
||||
label: subgraph.name,
|
||||
command: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(subgraph)
|
||||
},
|
||||
updateTitle: (title: string) => {
|
||||
const rootGraph = useCanvasStore().getCanvas().graph?.rootGraph
|
||||
if (!rootGraph) return
|
||||
|
||||
forEachSubgraphNode(rootGraph, subgraph.id, (node) => {
|
||||
node.title = title
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
return [home.value, ...items]
|
||||
})
|
||||
|
||||
const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
key: 'root',
|
||||
command: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
@@ -98,6 +50,10 @@ const home = computed(() => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const handleItemClick = (event: MenuItemCommandEvent) => {
|
||||
event.item.command?.(event)
|
||||
}
|
||||
|
||||
// Escape exits from the current subgraph.
|
||||
useEventListener(document, 'keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
@@ -109,116 +65,21 @@ useEventListener(document, 'keydown', (event) => {
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
|
||||
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
|
||||
watch(breadcrumbElement, (el) => {
|
||||
overflowObserver?.dispose()
|
||||
overflowObserver = undefined
|
||||
|
||||
if (!el) return
|
||||
|
||||
overflowObserver = useOverflowObserver(el, {
|
||||
onCheck: (isOverflowing) => {
|
||||
overflowingTabs.value = isOverflowing
|
||||
|
||||
if (collapseTabs.value) {
|
||||
// Items are currently hidden, check if we can show them
|
||||
if (!isOverflowing) {
|
||||
const items = [
|
||||
...el.querySelectorAll('.p-breadcrumb-item')
|
||||
] as HTMLElement[]
|
||||
|
||||
if (items.length < 3) return
|
||||
|
||||
const itemsWithIcon = items.filter((item) =>
|
||||
item.querySelector('.p-breadcrumb-item-link-icon-visible')
|
||||
).length
|
||||
const separators = el.querySelectorAll(
|
||||
'.p-breadcrumb-separator'
|
||||
) as NodeListOf<HTMLElement>
|
||||
const separator = separators[separators.length - 1] as HTMLElement
|
||||
const separatorWidth = separator.offsetWidth
|
||||
|
||||
// items + separators + gaps + icons
|
||||
const itemsWidth =
|
||||
(MIN_WIDTH + ITEM_PADDING + ITEM_PADDING) * items.length +
|
||||
itemsWithIcon * ICON_WIDTH
|
||||
const separatorsWidth = (items.length - 1) * separatorWidth
|
||||
const gapsWidth = (items.length - 1) * (ITEM_GAP * 2)
|
||||
const totalWidth = itemsWidth + separatorsWidth + gapsWidth
|
||||
const containerWidth = el.clientWidth
|
||||
|
||||
if (totalWidth <= containerWidth) {
|
||||
collapseTabs.value = false
|
||||
}
|
||||
}
|
||||
} else if (isOverflowing) {
|
||||
collapseTabs.value = true
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// If e.g. the workflow name changes, we need to check the overflow again
|
||||
onUpdated(() => {
|
||||
if (!overflowObserver?.disposed.value) {
|
||||
overflowObserver?.checkOverflow()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subgraph-breadcrumb:not(:empty) {
|
||||
flex: auto;
|
||||
flex-shrink: 10000;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.subgraph-breadcrumb,
|
||||
:deep(.p-breadcrumb) {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item) {
|
||||
@apply flex items-center rounded-lg overflow-hidden;
|
||||
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
|
||||
/* Collapse middle items first */
|
||||
flex-shrink: 10000;
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:has(.p-breadcrumb-item-link-icon-visible)) {
|
||||
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem + 20px);
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:first-child) {
|
||||
/* Then collapse the root workflow */
|
||||
flex-shrink: 5000;
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:last-child) {
|
||||
/* Then collapse the active item */
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:hover),
|
||||
:deep(.p-breadcrumb-item:has(.p-breadcrumb-item-link-menu-visible)) {
|
||||
background-color: color-mix(in srgb, var(--fg-color) 10%, transparent);
|
||||
color: var(--fg-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.subgraph-breadcrumb-collapse .p-breadcrumb-list {
|
||||
.p-breadcrumb-item,
|
||||
.p-breadcrumb-separator {
|
||||
@apply hidden;
|
||||
}
|
||||
.subgraph-breadcrumb {
|
||||
.p-breadcrumb-item-link,
|
||||
.p-breadcrumb-item-icon {
|
||||
@apply select-none;
|
||||
|
||||
.p-breadcrumb-item:nth-last-child(3),
|
||||
.p-breadcrumb-separator:nth-last-child(2),
|
||||
.p-breadcrumb-item:nth-last-child(1) {
|
||||
@apply block;
|
||||
color: #d26565;
|
||||
text-shadow:
|
||||
1px 1px 0 #000,
|
||||
-1px -1px 0 #000,
|
||||
1px -1px 0 #000,
|
||||
-1px 1px 0 #000,
|
||||
0 0 0.375rem #000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
<template>
|
||||
<a
|
||||
ref="wrapperRef"
|
||||
v-tooltip.bottom="{
|
||||
value: item.label,
|
||||
showDelay: 512
|
||||
}"
|
||||
href="#"
|
||||
class="cursor-pointer p-breadcrumb-item-link"
|
||||
:class="{
|
||||
'flex items-center gap-1': isActive,
|
||||
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
|
||||
'p-breadcrumb-item-link-icon-visible': isActive
|
||||
}"
|
||||
@click="handleClick"
|
||||
>
|
||||
<span class="p-breadcrumb-item-label">{{ item.label }}</span>
|
||||
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
|
||||
</a>
|
||||
<Menu
|
||||
v-if="isActive"
|
||||
ref="menu"
|
||||
:model="menuItems"
|
||||
:popup="true"
|
||||
:pt="{
|
||||
root: {
|
||||
style: 'background-color: var(--comfy-menu-secondary-bg)'
|
||||
},
|
||||
itemLink: {
|
||||
class: 'py-2'
|
||||
}
|
||||
}"
|
||||
/>
|
||||
<InputText
|
||||
v-if="isEditing"
|
||||
ref="itemInputRef"
|
||||
v-model="itemLabel"
|
||||
class="fixed z-[10000] text-[.8rem] px-2 py-2"
|
||||
@blur="inputBlur(true)"
|
||||
@click.stop
|
||||
@keydown.enter="inputBlur(true)"
|
||||
@keydown.esc="inputBlur(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Menu, { MenuState } from 'primevue/menu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
|
||||
interface Props {
|
||||
item: MenuItem
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const menu = ref<InstanceType<typeof Menu> & MenuState>()
|
||||
const dialogService = useDialogService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const isEditing = ref(false)
|
||||
const itemLabel = ref<string>()
|
||||
const itemInputRef = ref<{ $el?: HTMLInputElement }>()
|
||||
const wrapperRef = ref<HTMLAnchorElement>()
|
||||
|
||||
const rename = async (
|
||||
newName: string | null | undefined,
|
||||
initialName: string
|
||||
) => {
|
||||
if (newName && newName !== initialName) {
|
||||
// Synchronize the node titles with the new name
|
||||
props.item.updateTitle?.(newName)
|
||||
|
||||
if (workflowStore.activeSubgraph) {
|
||||
workflowStore.activeSubgraph.name = newName
|
||||
} else if (workflowStore.activeWorkflow) {
|
||||
try {
|
||||
await workflowService.renameWorkflow(
|
||||
workflowStore.activeWorkflow,
|
||||
ComfyWorkflow.basePath + appendJsonExt(newName)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
dialogService.showErrorDialog(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Force the navigation stack to recompute the labels
|
||||
// TODO: investigate if there is a better way to do this
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
navigationStore.restoreState(navigationStore.exportState())
|
||||
}
|
||||
}
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
label: t('g.rename'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: async () => {
|
||||
let initialName =
|
||||
workflowStore.activeSubgraph?.name ??
|
||||
workflowStore.activeWorkflow?.filename
|
||||
|
||||
if (!initialName) return
|
||||
|
||||
const newName = await dialogService.prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('breadcrumbsMenu.enterNewName'),
|
||||
defaultValue: initialName
|
||||
})
|
||||
|
||||
await rename(newName, initialName)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.duplicate'),
|
||||
icon: 'pi pi-copy',
|
||||
command: async () => {
|
||||
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: props.item.key === 'root'
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.clearWorkflow'),
|
||||
icon: 'pi pi-trash',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.ClearWorkflow')
|
||||
}
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: props.item.key === 'root'
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.deleteWorkflow'),
|
||||
icon: 'pi pi-times',
|
||||
command: async () => {
|
||||
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: props.item.key === 'root'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (isEditing.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.detail === 1) {
|
||||
if (props.isActive) {
|
||||
menu.value?.toggle(event)
|
||||
} else {
|
||||
props.item.command?.({ item: props.item, originalEvent: event })
|
||||
}
|
||||
} else if (props.isActive && event.detail === 2) {
|
||||
menu.value?.hide()
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
isEditing.value = true
|
||||
itemLabel.value = props.item.label as string
|
||||
void nextTick(() => {
|
||||
if (itemInputRef.value?.$el) {
|
||||
itemInputRef.value.$el.focus()
|
||||
itemInputRef.value.$el.select()
|
||||
if (wrapperRef.value) {
|
||||
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const inputBlur = async (doRename: boolean) => {
|
||||
if (doRename) {
|
||||
await rename(itemLabel.value, props.item.label as string)
|
||||
}
|
||||
|
||||
isEditing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.p-breadcrumb-item-link,
|
||||
.p-breadcrumb-item-icon {
|
||||
@apply select-none;
|
||||
}
|
||||
|
||||
.p-breadcrumb-item-link {
|
||||
@apply overflow-hidden;
|
||||
padding: var(--p-breadcrumb-item-padding);
|
||||
}
|
||||
|
||||
.p-breadcrumb-item-label {
|
||||
@apply whitespace-nowrap text-ellipsis overflow-hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
<NoResultsPlaceholder
|
||||
class="pb-0"
|
||||
icon="pi pi-exclamation-circle"
|
||||
title="Some Nodes Are Missing"
|
||||
title="Missing Node Types"
|
||||
message="When loading the graph, the following node types were not found"
|
||||
/>
|
||||
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -20,35 +21,24 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()])
|
||||
const widgetStates = computed(() => domWidgetStore.activeWidgetStates)
|
||||
|
||||
const updateWidgets = () => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
if (!lgCanvas) return
|
||||
|
||||
const lowQuality = lgCanvas.low_quality
|
||||
const currentGraph = lgCanvas.graph
|
||||
|
||||
for (const widgetState of widgetStates.value) {
|
||||
const widget = widgetState.widget
|
||||
const node = widget.node as LGraphNode
|
||||
|
||||
// Early exit for non-visible widgets
|
||||
if (!widget.isVisible()) {
|
||||
widgetState.visible = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if the widget's node is in the current graph
|
||||
const node = widget.node
|
||||
const isInCorrectGraph = currentGraph?.nodes.includes(node)
|
||||
|
||||
widgetState.visible =
|
||||
!!isInCorrectGraph &&
|
||||
const visible =
|
||||
lgCanvas.isNodeVisible(node) &&
|
||||
!(widget.options.hideOnZoom && lowQuality)
|
||||
!(widget.options.hideOnZoom && lowQuality) &&
|
||||
widget.isVisible()
|
||||
|
||||
if (widgetState.visible && node) {
|
||||
widgetState.visible = visible
|
||||
if (visible) {
|
||||
const margin = widget.margin
|
||||
widgetState.pos = [node.pos[0] + margin, node.pos[1] + margin + widget.y]
|
||||
widgetState.size = [
|
||||
|
||||
@@ -16,14 +16,9 @@
|
||||
<SecondRowWorkflowTabs
|
||||
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
|
||||
/>
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
|
||||
|
||||
<MiniMap
|
||||
v-if="comfyAppReady && minimapEnabled"
|
||||
ref="minimapRef"
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
</template>
|
||||
</LiteGraphCanvasSplitterOverlay>
|
||||
<GraphCanvasMenu v-if="!betaMenuEnabled && canvasMenuEnabled" />
|
||||
@@ -55,9 +50,9 @@ import { computed, onMounted, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import MiniMap from '@/components/graph/MiniMap.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
@@ -72,7 +67,6 @@ import { useContextMenuTranslation } from '@/composables/useContextMenuTranslati
|
||||
import { useCopy } from '@/composables/useCopy'
|
||||
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
|
||||
import { useLitegraphSettings } from '@/composables/useLitegraphSettings'
|
||||
import { useMinimap } from '@/composables/useMinimap'
|
||||
import { usePaste } from '@/composables/usePaste'
|
||||
import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
|
||||
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
|
||||
@@ -119,10 +113,6 @@ const selectionToolboxEnabled = computed(() =>
|
||||
settingStore.get('Comfy.Canvas.SelectionToolbox')
|
||||
)
|
||||
|
||||
const minimapRef = ref<InstanceType<typeof MiniMap>>()
|
||||
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
|
||||
const minimap = useMinimap()
|
||||
|
||||
watchEffect(() => {
|
||||
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
|
||||
})
|
||||
@@ -358,13 +348,6 @@ onMounted(async () => {
|
||||
}
|
||||
)
|
||||
|
||||
whenever(
|
||||
() => minimapRef.value,
|
||||
(ref) => {
|
||||
minimap.setMinimapRef(ref)
|
||||
}
|
||||
)
|
||||
|
||||
whenever(
|
||||
() => useCanvasStore().canvas,
|
||||
(canvas) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<ButtonGroup
|
||||
class="p-buttongroup-vertical absolute bottom-[10px] right-[10px] z-[1000]"
|
||||
@wheel="canvasInteractions.handleWheel"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.left="t('graphCanvasMenu.zoomIn')"
|
||||
@@ -57,15 +56,6 @@
|
||||
data-testid="toggle-link-visibility-button"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.left="t('graphCanvasMenu.toggleMinimap') + ' (Alt + m)'"
|
||||
severity="secondary"
|
||||
:icon="'pi pi-map'"
|
||||
:aria-label="$t('graphCanvasMenu.toggleMinimap')"
|
||||
:class="{ 'minimap-active': minimapVisible }"
|
||||
data-testid="toggle-minimap-button"
|
||||
@click="() => commandStore.execute('Comfy.Canvas.ToggleMinimap')"
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</template>
|
||||
|
||||
@@ -76,7 +66,6 @@ import ButtonGroup from 'primevue/buttongroup'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
@@ -85,9 +74,7 @@ const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const settingStore = useSettingStore()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
|
||||
const minimapVisible = computed(() => settingStore.get('Comfy.Minimap.Visible'))
|
||||
const linkHidden = computed(
|
||||
() => settingStore.get('Comfy.LinkRenderMode') === LiteGraph.HIDDEN_LINK
|
||||
)
|
||||
@@ -120,15 +107,4 @@ const stopRepeat = () => {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.p-button.minimap-active {
|
||||
background-color: var(--p-button-primary-background);
|
||||
border-color: var(--p-button-primary-border-color);
|
||||
color: var(--p-button-primary-color);
|
||||
}
|
||||
|
||||
.p-button.minimap-active:hover {
|
||||
background-color: var(--p-button-primary-hover-background);
|
||||
border-color: var(--p-button-primary-hover-border-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible && initialized"
|
||||
ref="containerRef"
|
||||
class="litegraph-minimap absolute bottom-[20px] right-[90px] z-[1000]"
|
||||
:style="containerStyles"
|
||||
@mousedown="handleMouseDown"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseup="handleMouseUp"
|
||||
@mouseleave="handleMouseUp"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
:width="width"
|
||||
:height="height"
|
||||
class="minimap-canvas"
|
||||
/>
|
||||
<div class="minimap-viewport" :style="viewportStyles" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import { useMinimap } from '@/composables/useMinimap'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const minimap = useMinimap()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const {
|
||||
initialized,
|
||||
visible,
|
||||
containerRef,
|
||||
canvasRef,
|
||||
containerStyles,
|
||||
viewportStyles,
|
||||
width,
|
||||
height,
|
||||
init,
|
||||
destroy,
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp,
|
||||
handleWheel
|
||||
} = minimap
|
||||
|
||||
watch(
|
||||
() => canvasStore.canvas,
|
||||
async (canvas) => {
|
||||
if (canvas && !initialized.value) {
|
||||
await init()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
if (canvasStore.canvas) {
|
||||
await init()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.litegraph-minimap {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.minimap-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.minimap-viewport {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,6 @@
|
||||
header: 'hidden',
|
||||
content: 'p-0 flex flex-row'
|
||||
}"
|
||||
@wheel="canvasInteractions.handleWheel"
|
||||
>
|
||||
<ExecuteButton />
|
||||
<ColorPickerButton />
|
||||
@@ -40,7 +39,6 @@ import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
|
||||
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
|
||||
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
|
||||
import RefreshButton from '@/components/graph/selectionToolbox/RefreshButton.vue'
|
||||
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
@@ -48,7 +46,6 @@ import { useCanvasStore } from '@/stores/graphStore'
|
||||
const commandStore = useCommandStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const extensionService = useExtensionService()
|
||||
const canvasInteractions = useCanvasInteractions()
|
||||
|
||||
const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
|
||||
const commandIds = new Set<string>(
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementBounding, useEventListener } from '@vueuse/core'
|
||||
import { CSSProperties, computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { CSSProperties, computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
|
||||
import { useDomClipping } from '@/composables/element/useDomClipping'
|
||||
@@ -61,13 +61,10 @@ const updateDomClipping = () => {
|
||||
if (!lgCanvas || !widgetElement.value) return
|
||||
|
||||
const selectedNode = Object.values(lgCanvas.selected_nodes ?? {})[0]
|
||||
if (!selectedNode) {
|
||||
// Clear clipping when no node is selected
|
||||
updateClipPath(widgetElement.value, lgCanvas.canvas, false, undefined)
|
||||
return
|
||||
}
|
||||
if (!selectedNode) return
|
||||
|
||||
const isSelected = selectedNode === widget.node
|
||||
const node = widget.node
|
||||
const isSelected = selectedNode === node
|
||||
const renderArea = selectedNode?.renderArea
|
||||
const offset = lgCanvas.ds.offset
|
||||
const scale = lgCanvas.ds.scale
|
||||
@@ -125,10 +122,7 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// Set up event listeners only after the widget is mounted and visible
|
||||
const setupDOMEventListeners = () => {
|
||||
if (!isDOMWidget(widget) || !widgetState.visible) return
|
||||
|
||||
if (isDOMWidget(widget)) {
|
||||
if (widget.element.blur) {
|
||||
useEventListener(document, 'mousedown', (event) => {
|
||||
if (!widget.element.contains(event.target as HTMLElement)) {
|
||||
@@ -146,46 +140,14 @@ const setupDOMEventListeners = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners when widget becomes visible
|
||||
watch(
|
||||
() => widgetState.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
setupDOMEventListeners()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const inputSpec = widget.node.constructor.nodeData
|
||||
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
|
||||
|
||||
// Mount DOM element when widget is or becomes visible
|
||||
const mountElementIfVisible = () => {
|
||||
if (widgetState.visible && isDOMWidget(widget) && widgetElement.value) {
|
||||
// Only append if not already a child
|
||||
if (!widgetElement.value.contains(widget.element)) {
|
||||
widgetElement.value.appendChild(widget.element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check on mount - but only after next tick to ensure visibility is calculated
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
mountElementIfVisible()
|
||||
}).catch((error) => {
|
||||
console.error('Error mounting DOM widget element:', error)
|
||||
})
|
||||
})
|
||||
|
||||
// And watch for visibility changes
|
||||
watch(
|
||||
() => widgetState.visible,
|
||||
() => {
|
||||
mountElementIfVisible()
|
||||
if (isDOMWidget(widget) && widgetElement.value) {
|
||||
widgetElement.value.appendChild(widget.element)
|
||||
}
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -217,8 +217,7 @@ defineExpose({
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
padding: 32px 32px 24px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
@update-up-direction="handleUpdateUpDirection"
|
||||
@update-material-mode="handleUpdateMaterialMode"
|
||||
@update-edge-threshold="handleUpdateEdgeThreshold"
|
||||
@upload-texture="handleUploadTexture"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
<div
|
||||
@@ -214,6 +215,30 @@ const handleBackgroundImageUpdate = async (file: File | null) => {
|
||||
node.properties['Background Image'] = backgroundImage.value
|
||||
}
|
||||
|
||||
const handleUploadTexture = async (file: File) => {
|
||||
if (!load3DSceneRef.value?.load3d) {
|
||||
useToastStore().addAlert(t('toastMessages.no3dScene'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
|
||||
|
||||
const subfolder = resourceFolder.trim()
|
||||
? `3d/${resourceFolder.trim()}`
|
||||
: '3d'
|
||||
|
||||
const texturePath = await Load3dUtils.uploadFile(file, subfolder)
|
||||
|
||||
await load3DSceneRef.value.load3d.applyTexture(texturePath)
|
||||
|
||||
node.properties['Texture'] = texturePath
|
||||
} catch (error) {
|
||||
console.error('Error applying texture:', error)
|
||||
useToastStore().addAlert(t('toastMessages.failedToApplyTexture'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateFOV = (value: number) => {
|
||||
fov.value = value
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
@update-up-direction="handleUpdateUpDirection"
|
||||
@update-material-mode="handleUpdateMaterialMode"
|
||||
@update-edge-threshold="handleUpdateEdgeThreshold"
|
||||
@upload-texture="handleUploadTexture"
|
||||
/>
|
||||
|
||||
<CameraControls
|
||||
@@ -182,6 +183,7 @@ const emit = defineEmits<{
|
||||
(e: 'updateMaterialMode', mode: MaterialMode): void
|
||||
(e: 'updateEdgeThreshold', value: number): void
|
||||
(e: 'exportModel', format: string): void
|
||||
(e: 'uploadTexture', file: File): void
|
||||
}>()
|
||||
|
||||
const backgroundColor = ref(props.backgroundColor)
|
||||
@@ -230,6 +232,10 @@ const handleUpdateEdgeThreshold = (value: number) => {
|
||||
emit('updateEdgeThreshold', value)
|
||||
}
|
||||
|
||||
const handleUploadTexture = (file: File) => {
|
||||
emit('uploadTexture', file)
|
||||
}
|
||||
|
||||
const handleUpdateLightIntensity = (value: number) => {
|
||||
emit('updateLightIntensity', value)
|
||||
}
|
||||
|
||||
@@ -69,6 +69,9 @@ const eventConfig = {
|
||||
exportLoadingEnd: () => {
|
||||
loadingOverlayRef.value?.endLoading()
|
||||
},
|
||||
textureLoadingStart: () =>
|
||||
loadingOverlayRef.value?.startLoading(t('load3d.applyingTexture')),
|
||||
textureLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
|
||||
recordingStatusChange: (value: boolean) =>
|
||||
emit('recordingStatusChange', value)
|
||||
} as const
|
||||
|
||||
@@ -59,6 +59,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
materialMode === 'original' &&
|
||||
!props.inputSpec.isAnimation &&
|
||||
!props.inputSpec.isPreview
|
||||
"
|
||||
class="relative show-texture-upload"
|
||||
>
|
||||
<Button class="p-button-rounded p-button-text" @click="openTextureUpload">
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.uploadTexture'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-image text-white text-lg"
|
||||
/>
|
||||
<input
|
||||
ref="texturePickerRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
|
||||
@change="uploadTexture"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="materialMode === 'lineart'" class="relative show-edge-threshold">
|
||||
<Button
|
||||
class="p-button-rounded p-button-text"
|
||||
@@ -116,6 +142,7 @@ const emit = defineEmits<{
|
||||
(e: 'updateUpDirection', direction: UpDirection): void
|
||||
(e: 'updateMaterialMode', mode: MaterialMode): void
|
||||
(e: 'updateEdgeThreshold', value: number): void
|
||||
(e: 'uploadTexture', file: File): void
|
||||
}>()
|
||||
|
||||
const upDirection = ref(props.upDirection || 'original')
|
||||
@@ -124,6 +151,7 @@ const edgeThreshold = ref(props.edgeThreshold || 85)
|
||||
const showUpDirection = ref(false)
|
||||
const showMaterialMode = ref(false)
|
||||
const showEdgeThreshold = ref(false)
|
||||
const texturePickerRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const upDirections: UpDirection[] = [
|
||||
'original',
|
||||
@@ -219,6 +247,18 @@ const updateEdgeThreshold = () => {
|
||||
emit('updateEdgeThreshold', edgeThreshold.value)
|
||||
}
|
||||
|
||||
const openTextureUpload = () => {
|
||||
texturePickerRef.value?.click()
|
||||
}
|
||||
|
||||
const uploadTexture = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
|
||||
if (input.files && input.files[0]) {
|
||||
emit('uploadTexture', input.files[0])
|
||||
}
|
||||
}
|
||||
|
||||
const closeSceneSlider = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
/>
|
||||
<div class="side-tool-bar-end">
|
||||
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
|
||||
<SidebarThemeToggleIcon />
|
||||
<SidebarHelpCenterIcon />
|
||||
<SidebarBottomPanelToggleButton />
|
||||
<SidebarSettingsToggleIcon />
|
||||
</div>
|
||||
</nav>
|
||||
</teleport>
|
||||
@@ -31,7 +32,6 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
@@ -41,6 +41,8 @@ import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
|
||||
import SidebarSettingsToggleIcon from './SidebarSettingsToggleIcon.vue'
|
||||
import SidebarThemeToggleIcon from './SidebarThemeToggleIcon.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<SidebarIcon
|
||||
:tooltip="$t('menu.toggleBottomPanel')"
|
||||
:selected="bottomPanelStore.bottomPanelVisible"
|
||||
@click="bottomPanelStore.toggleBottomPanel"
|
||||
>
|
||||
<template #icon>
|
||||
<i-ph:terminal-bold />
|
||||
</template>
|
||||
</SidebarIcon>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
</script>
|
||||
@@ -19,12 +19,10 @@
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<template #icon>
|
||||
<slot name="icon">
|
||||
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
|
||||
<i :class="icon + ' side-bar-button-icon'" />
|
||||
</OverlayBadge>
|
||||
<i v-else :class="icon + ' side-bar-button-icon'" />
|
||||
</slot>
|
||||
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
|
||||
<i :class="icon + ' side-bar-button-icon'" />
|
||||
</OverlayBadge>
|
||||
<i v-else :class="icon + ' side-bar-button-icon'" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
25
src/components/sidebar/SidebarSettingsToggleIcon.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<SidebarIcon
|
||||
icon="pi pi-cog"
|
||||
class="comfy-settings-btn"
|
||||
:tooltip="$t('g.settings')"
|
||||
@click="showSetting"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
|
||||
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const showSetting = () => {
|
||||
dialogStore.showDialog({
|
||||
key: 'global-settings',
|
||||
headerComponent: SettingDialogHeader,
|
||||
component: SettingDialogContent
|
||||
})
|
||||
}
|
||||
</script>
|
||||
29
src/components/sidebar/SidebarThemeToggleIcon.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<SidebarIcon
|
||||
:icon="icon"
|
||||
:tooltip="$t('sideToolbar.themeToggle')"
|
||||
class="comfy-vue-theme-toggle"
|
||||
@click="toggleTheme"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const icon = computed(() =>
|
||||
colorPaletteStore.completedActivePalette.light_theme
|
||||
? 'pi pi-sun'
|
||||
: 'pi pi-moon'
|
||||
)
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const toggleTheme = async () => {
|
||||
await commandStore.execute('Comfy.ToggleTheme')
|
||||
}
|
||||
</script>
|
||||
25
src/components/topbar/BottomPanelToggleButton.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="bottomPanelStore.bottomPanelTabs.length > 0"
|
||||
v-tooltip="{ value: $t('menu.toggleBottomPanel'), showDelay: 300 }"
|
||||
severity="secondary"
|
||||
text
|
||||
:aria-label="$t('menu.toggleBottomPanel')"
|
||||
@click="bottomPanelStore.toggleBottomPanel"
|
||||
>
|
||||
<template #icon>
|
||||
<i-material-symbols:dock-to-bottom
|
||||
v-if="bottomPanelStore.bottomPanelVisible"
|
||||
/>
|
||||
<i-material-symbols:dock-to-bottom-outline v-else />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
</script>
|
||||
@@ -1,116 +1,51 @@
|
||||
<template>
|
||||
<div
|
||||
class="comfyui-logo-wrapper p-1 flex justify-center items-center cursor-pointer rounded-md mr-2"
|
||||
:class="{
|
||||
'comfyui-logo-menu-visible': menuRef?.visible
|
||||
}"
|
||||
:style="{
|
||||
minWidth: isLargeSidebar ? '4rem' : 'auto'
|
||||
}"
|
||||
@click="menuRef?.toggle($event)"
|
||||
>
|
||||
<img
|
||||
src="/assets/images/comfy-logo-mono.svg"
|
||||
alt="ComfyUI Logo"
|
||||
class="comfyui-logo h-7"
|
||||
@contextmenu="showNativeSystemMenu"
|
||||
/>
|
||||
<i class="pi pi-angle-down ml-1 text-[10px]" />
|
||||
</div>
|
||||
<TieredMenu
|
||||
ref="menuRef"
|
||||
<Menubar
|
||||
:model="translatedItems"
|
||||
:popup="true"
|
||||
class="comfy-command-menu"
|
||||
:class="{
|
||||
'comfy-command-menu-top': isTopMenu
|
||||
class="top-menubar border-none p-0 bg-transparent"
|
||||
:pt="{
|
||||
rootList: 'gap-0 flex-nowrap w-auto',
|
||||
submenu: `dropdown-direction-${dropdownDirection}`,
|
||||
item: 'relative'
|
||||
}"
|
||||
@show="onMenuShow"
|
||||
>
|
||||
<template #item="{ item, props }">
|
||||
<div
|
||||
v-if="item.key === 'theme'"
|
||||
class="flex items-center gap-4 px-4 py-5"
|
||||
@click.stop.prevent
|
||||
>
|
||||
{{ item.label }}
|
||||
<SelectButton
|
||||
:options="[darkLabel, lightLabel]"
|
||||
:model-value="activeTheme"
|
||||
@click.stop.prevent
|
||||
@update:model-value="onThemeChange"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<i v-if="option === lightLabel" class="pi pi-sun" />
|
||||
<i v-if="option === darkLabel" class="pi pi-moon" />
|
||||
<span>{{ option }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
<template #item="{ item, props, root }">
|
||||
<a
|
||||
v-else
|
||||
class="p-menubar-item-link px-4 py-2"
|
||||
class="p-menubar-item-link"
|
||||
v-bind="props.action"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
>
|
||||
<span v-if="item.icon" class="p-menubar-item-icon" :class="item.icon" />
|
||||
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
|
||||
<span class="p-menubar-item-label">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item?.comfyCommand?.keybinding"
|
||||
class="ml-auto border border-surface rounded text-muted text-xs text-nowrap p-1 keybinding-tag"
|
||||
>
|
||||
{{ item.comfyCommand.keybinding.combo.toString() }}
|
||||
</span>
|
||||
<i v-if="item.items" class="ml-auto pi pi-angle-right" />
|
||||
<i v-if="!root && item.items" class="ml-auto pi pi-angle-right" />
|
||||
</a>
|
||||
</template>
|
||||
</TieredMenu>
|
||||
|
||||
<SubgraphBreadcrumb />
|
||||
</Menubar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Menubar from 'primevue/menubar'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import TieredMenu, {
|
||||
type TieredMenuMethods,
|
||||
type TieredMenuState
|
||||
} from 'primevue/tieredmenu'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
|
||||
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { showNativeSystemMenu } from '@/utils/envUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const menuItemsStore = useMenuItemStore()
|
||||
const commandStore = useCommandStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const aboutPanelStore = useAboutPanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const menuRef = ref<
|
||||
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
|
||||
>(null)
|
||||
const isLargeSidebar = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Size') !== 'small'
|
||||
const dropdownDirection = computed(() =>
|
||||
settingStore.get('Comfy.UseNewMenu') === 'Top' ? 'down' : 'up'
|
||||
)
|
||||
const isTopMenu = computed(() => settingStore.get('Comfy.UseNewMenu') === 'Top')
|
||||
|
||||
const menuItemsStore = useMenuItemStore()
|
||||
const { t } = useI18n()
|
||||
const translateMenuItem = (item: MenuItem): MenuItem => {
|
||||
const label = typeof item.label === 'function' ? item.label() : item.label
|
||||
const translatedLabel = label
|
||||
@@ -124,119 +59,9 @@ const translateMenuItem = (item: MenuItem): MenuItem => {
|
||||
}
|
||||
}
|
||||
|
||||
const showSettings = (defaultPanel?: string) => {
|
||||
dialogStore.showDialog({
|
||||
key: 'global-settings',
|
||||
headerComponent: SettingDialogHeader,
|
||||
component: SettingDialogContent,
|
||||
props: {
|
||||
defaultPanel
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Temporary duplicated from LoadWorkflowWarning.vue
|
||||
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
|
||||
// This allows us to conditionally show the Manager button only when the extension is available
|
||||
// TODO: Remove this check when Manager functionality is fully migrated into core
|
||||
const isManagerInstalled = computed(() => {
|
||||
return aboutPanelStore.badges.some(
|
||||
(badge) =>
|
||||
badge.label.includes('ComfyUI-Manager') ||
|
||||
badge.url.includes('ComfyUI-Manager')
|
||||
)
|
||||
})
|
||||
|
||||
const showManageExtensions = () => {
|
||||
if (isManagerInstalled.value) {
|
||||
useDialogService().showManagerDialog()
|
||||
} else {
|
||||
showSettings('extension')
|
||||
}
|
||||
}
|
||||
|
||||
const extraMenuItems: MenuItem[] = [
|
||||
{ separator: true },
|
||||
{
|
||||
key: 'theme',
|
||||
label: t('menu.theme')
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
key: 'manage-extensions',
|
||||
label: t('menu.manageExtensions'),
|
||||
icon: 'mdi mdi-puzzle-outline',
|
||||
command: showManageExtensions
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: t('g.settings'),
|
||||
icon: 'mdi mdi-cog-outline',
|
||||
command: () => showSettings()
|
||||
}
|
||||
]
|
||||
|
||||
const lightLabel = t('menu.light')
|
||||
const darkLabel = t('menu.dark')
|
||||
|
||||
const activeTheme = computed(() => {
|
||||
return colorPaletteStore.completedActivePalette.light_theme
|
||||
? lightLabel
|
||||
: darkLabel
|
||||
})
|
||||
|
||||
const onThemeChange = async () => {
|
||||
await commandStore.execute('Comfy.ToggleTheme')
|
||||
}
|
||||
|
||||
const translatedItems = computed(() => {
|
||||
const items = menuItemsStore.menuItems.map(translateMenuItem)
|
||||
let helpIndex = items.findIndex((item) => item.key === 'Help')
|
||||
let helpItem: MenuItem | undefined
|
||||
|
||||
if (helpIndex !== -1) {
|
||||
items[helpIndex].icon = 'mdi mdi-help-circle-outline'
|
||||
// If help is not the last item (i.e. we have extension commands), separate them
|
||||
const isLastItem = helpIndex !== items.length - 1
|
||||
helpItem = items.splice(
|
||||
helpIndex,
|
||||
1,
|
||||
...(isLastItem
|
||||
? [
|
||||
{
|
||||
separator: true
|
||||
}
|
||||
]
|
||||
: [])
|
||||
)[0]
|
||||
}
|
||||
helpIndex = items.length
|
||||
|
||||
items.splice(
|
||||
helpIndex,
|
||||
0,
|
||||
...extraMenuItems,
|
||||
...(helpItem
|
||||
? [
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
helpItem
|
||||
]
|
||||
: [])
|
||||
)
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const onMenuShow = () => {
|
||||
void nextTick(() => {
|
||||
// Force the menu to show submenus on hover
|
||||
if (menuRef.value) {
|
||||
menuRef.value.dirty = true
|
||||
}
|
||||
})
|
||||
}
|
||||
const translatedItems = computed(() =>
|
||||
menuItemsStore.menuItems.map(translateMenuItem)
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -249,20 +74,4 @@ const onMenuShow = () => {
|
||||
border-color: var(--p-content-border-color);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.comfyui-logo-menu-visible,
|
||||
.comfyui-logo-wrapper:hover {
|
||||
background-color: color-mix(in srgb, var(--fg-color) 10%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.comfy-command-menu ul {
|
||||
background-color: var(--comfy-menu-secondary-bg) !important;
|
||||
}
|
||||
|
||||
.comfy-command-menu-top .p-tieredmenu-submenu {
|
||||
left: calc(100% + 15px) !important;
|
||||
top: -4px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,66 +1,82 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-show="showTopMenu && workflowTabsPosition === 'Topbar'"
|
||||
class="w-full flex content-end z-[1001] h-[38px]"
|
||||
style="background: var(--border-color)"
|
||||
>
|
||||
<WorkflowTabs />
|
||||
<div
|
||||
v-show="showTopMenu"
|
||||
ref="topMenuRef"
|
||||
class="comfyui-menu flex items-center"
|
||||
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
|
||||
>
|
||||
<img
|
||||
src="/assets/images/comfy-logo-mono.svg"
|
||||
alt="ComfyUI Logo"
|
||||
class="comfyui-logo ml-2 app-drag h-6"
|
||||
/>
|
||||
<CommandMenubar />
|
||||
<div class="flex-grow min-w-0 app-drag h-full">
|
||||
<WorkflowTabs v-if="workflowTabsPosition === 'Topbar'" />
|
||||
</div>
|
||||
<div ref="menuRight" class="comfyui-menu-right flex-shrink-0" />
|
||||
<Actionbar />
|
||||
<CurrentUserButton class="flex-shrink-0" />
|
||||
<BottomPanelToggleButton class="flex-shrink-0" />
|
||||
<Button
|
||||
v-tooltip="{ value: $t('menu.hideMenu'), showDelay: 300 }"
|
||||
class="flex-shrink-0"
|
||||
icon="pi pi-bars"
|
||||
severity="secondary"
|
||||
text
|
||||
:aria-label="$t('menu.hideMenu')"
|
||||
@click="workspaceState.focusMode = true"
|
||||
@contextmenu="showNativeSystemMenu"
|
||||
/>
|
||||
<div
|
||||
v-show="showTopMenu"
|
||||
ref="topMenuRef"
|
||||
class="comfyui-menu flex items-center"
|
||||
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
|
||||
>
|
||||
<CommandMenubar />
|
||||
<div class="flex-grow min-w-0 app-drag h-full"></div>
|
||||
<div
|
||||
ref="menuRight"
|
||||
class="comfyui-menu-right flex-shrink-1 overflow-auto"
|
||||
/>
|
||||
<Actionbar />
|
||||
<CurrentUserButton class="flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
<!-- Virtual top menu for native window (drag handle) -->
|
||||
<div
|
||||
v-show="isNativeWindow() && !showTopMenu"
|
||||
class="fixed top-0 left-0 app-drag w-full h-[var(--comfy-topbar-height)]"
|
||||
v-show="menuSetting !== 'Bottom'"
|
||||
class="window-actions-spacer flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Virtual top menu for native window (drag handle) -->
|
||||
<div
|
||||
v-show="isNativeWindow() && !showTopMenu"
|
||||
class="fixed top-0 left-0 app-drag w-full h-[var(--comfy-topbar-height)]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventBus } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import { computed, onMounted, provide, ref } from 'vue'
|
||||
|
||||
import Actionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import BottomPanelToggleButton from '@/components/topbar/BottomPanelToggleButton.vue'
|
||||
import CommandMenubar from '@/components/topbar/CommandMenubar.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { electronAPI, isElectron, isNativeWindow } from '@/utils/envUtil'
|
||||
import {
|
||||
electronAPI,
|
||||
isElectron,
|
||||
isNativeWindow,
|
||||
showNativeSystemMenu
|
||||
} from '@/utils/envUtil'
|
||||
|
||||
const workspaceState = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const workflowTabsPosition = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
|
||||
)
|
||||
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
|
||||
const betaMenuEnabled = computed(() => menuSetting.value !== 'Disabled')
|
||||
const showTopMenu = computed(
|
||||
() => betaMenuEnabled.value && !workspaceState.focusMode
|
||||
)
|
||||
const workflowTabsPosition = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
|
||||
)
|
||||
|
||||
const menuRight = ref<HTMLDivElement | null>(null)
|
||||
// Menu-right holds legacy topbar elements attached by custom scripts
|
||||
onMounted(() => {
|
||||
if (menuRight.value) {
|
||||
app.menu.element.style.width = 'fit-content'
|
||||
menuRight.value.appendChild(app.menu.element)
|
||||
}
|
||||
})
|
||||
@@ -122,35 +138,4 @@ onMounted(() => {
|
||||
.dark-theme .comfyui-logo {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.comfyui-menu-button-hide {
|
||||
background-color: var(--comfy-menu-secondary-bg);
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.comfyui-menu-right::-webkit-scrollbar {
|
||||
max-height: 5px;
|
||||
}
|
||||
|
||||
.comfyui-menu-right:hover::-webkit-scrollbar {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.comfyui-menu-right::-webkit-scrollbar-track {
|
||||
background: color-mix(in srgb, var(--border-color) 60%, transparent);
|
||||
}
|
||||
|
||||
.comfyui-menu-right:hover::-webkit-scrollbar-track {
|
||||
background: color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
}
|
||||
|
||||
.comfyui-menu-right::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--fg-color) 30%, transparent);
|
||||
}
|
||||
|
||||
.comfyui-menu-right::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--fg-color) 80%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<template>
|
||||
<div ref="workflowTabRef" class="flex p-2 gap-2 workflow-tab" v-bind="$attrs">
|
||||
<span
|
||||
v-tooltip.bottom="{
|
||||
value: workflowOption.workflow.key,
|
||||
class: 'workflow-tab-tooltip',
|
||||
showDelay: 512
|
||||
}"
|
||||
class="workflow-label text-sm max-w-[150px] min-w-[30px] truncate inline-block"
|
||||
v-tooltip.bottom="workflowOption.workflow.key"
|
||||
class="workflow-label text-sm max-w-[150px] truncate inline-block"
|
||||
>
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
@@ -146,9 +142,3 @@ usePragmaticDroppable(tabGetter, {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.p-tooltip.workflow-tab-tooltip {
|
||||
z-index: 1200 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
class="workflow-tabs-container flex flex-row max-w-full h-full flex-auto overflow-hidden"
|
||||
:class="{ 'workflow-tabs-container-desktop': isDesktop }"
|
||||
>
|
||||
<Button
|
||||
v-if="showOverflowArrows"
|
||||
icon="pi pi-chevron-left"
|
||||
text
|
||||
severity="secondary"
|
||||
class="overflow-arrow overflow-arrow-left"
|
||||
:disabled="!leftArrowEnabled"
|
||||
@mousedown="whileMouseDown($event, () => scroll(-1))"
|
||||
/>
|
||||
<div class="workflow-tabs-container flex flex-row max-w-full h-full">
|
||||
<ScrollPanel
|
||||
ref="scrollPanelRef"
|
||||
class="overflow-hidden no-drag"
|
||||
@@ -39,18 +27,9 @@
|
||||
</template>
|
||||
</SelectButton>
|
||||
</ScrollPanel>
|
||||
<Button
|
||||
v-if="showOverflowArrows"
|
||||
icon="pi pi-chevron-right"
|
||||
text
|
||||
severity="secondary"
|
||||
class="overflow-arrow overflow-arrow-right"
|
||||
:disabled="!rightArrowEnabled"
|
||||
@mousedown="whileMouseDown($event, () => scroll(1))"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('sideToolbar.newBlankWorkflow'), showDelay: 300 }"
|
||||
class="new-blank-workflow-button flex-shrink-0 no-drag rounded-none"
|
||||
class="new-blank-workflow-button flex-shrink-0 no-drag"
|
||||
icon="pi pi-plus"
|
||||
text
|
||||
severity="secondary"
|
||||
@@ -58,32 +37,23 @@
|
||||
@click="() => commandStore.execute('Comfy.NewBlankWorkflow')"
|
||||
/>
|
||||
<ContextMenu ref="menu" :model="contextMenuItems" />
|
||||
<div
|
||||
v-if="menuSetting !== 'Bottom' && isDesktop"
|
||||
class="window-actions-spacer flex-shrink-0 app-drag"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useScroll } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { ComfyWorkflow, useWorkflowBookmarkStore } from '@/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
||||
|
||||
interface WorkflowOption {
|
||||
value: string
|
||||
@@ -97,19 +67,11 @@ const props = defineProps<{
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowBookmarkStore = useWorkflowBookmarkStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workflowService = useWorkflowService()
|
||||
|
||||
const workflowBookmarkStore = useWorkflowBookmarkStore()
|
||||
const rightClickedTab = ref<WorkflowOption | undefined>()
|
||||
const menu = ref()
|
||||
const scrollPanelRef = ref()
|
||||
const showOverflowArrows = ref(false)
|
||||
const leftArrowEnabled = ref(false)
|
||||
const rightArrowEnabled = ref(false)
|
||||
|
||||
const isDesktop = isElectron()
|
||||
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
|
||||
|
||||
const workflowToOption = (workflow: ComfyWorkflow): WorkflowOption => ({
|
||||
value: workflow.path,
|
||||
@@ -124,7 +86,6 @@ const selectedWorkflow = computed<WorkflowOption | null>(() =>
|
||||
? workflowToOption(workflowStore.activeWorkflow as ComfyWorkflow)
|
||||
: null
|
||||
)
|
||||
|
||||
const onWorkflowChange = async (option: WorkflowOption) => {
|
||||
// Prevent unselecting the current workflow
|
||||
if (!option) {
|
||||
@@ -217,13 +178,6 @@ const handleWheel = (event: WheelEvent) => {
|
||||
})
|
||||
}
|
||||
|
||||
const scroll = (direction: number) => {
|
||||
const scrollElement = scrollPanelRef.value.$el.querySelector(
|
||||
'.p-scrollpanel-content'
|
||||
) as HTMLElement
|
||||
scrollElement.scrollBy({ left: direction * 20 })
|
||||
}
|
||||
|
||||
// Scroll to active offscreen tab when opened
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow,
|
||||
@@ -254,71 +208,12 @@ watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const scrollContent = computed(
|
||||
() =>
|
||||
scrollPanelRef.value?.$el.querySelector(
|
||||
'.p-scrollpanel-content'
|
||||
) as HTMLElement
|
||||
)
|
||||
let overflowObserver: ReturnType<typeof useOverflowObserver> | null = null
|
||||
let overflowWatch: ReturnType<typeof watch> | null = null
|
||||
watch(scrollContent, (value) => {
|
||||
const scrollState = useScroll(value)
|
||||
|
||||
watch(scrollState.arrivedState, () => {
|
||||
leftArrowEnabled.value = !scrollState.arrivedState.left
|
||||
rightArrowEnabled.value = !scrollState.arrivedState.right
|
||||
})
|
||||
|
||||
overflowObserver?.dispose()
|
||||
overflowWatch?.stop()
|
||||
overflowObserver = useOverflowObserver(value)
|
||||
overflowWatch = watch(
|
||||
overflowObserver.isOverflowing,
|
||||
(value) => {
|
||||
showOverflowArrows.value = value
|
||||
void nextTick(() => {
|
||||
// Force a new check after arrows are updated
|
||||
scrollState.measure()
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
})
|
||||
|
||||
onUpdated(() => {
|
||||
if (!overflowObserver?.disposed.value) {
|
||||
overflowObserver?.checkOverflow()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workflow-tabs-container {
|
||||
background-color: var(--comfy-menu-secondary-bg);
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton) {
|
||||
@apply p-0 bg-transparent rounded-none flex-shrink relative border-0 border-r border-solid;
|
||||
@apply p-0 bg-transparent rounded-none flex-shrink-0 relative border-0 border-r border-solid;
|
||||
border-right-color: var(--border-color);
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.overflow-arrow {
|
||||
@apply px-2 rounded-none;
|
||||
}
|
||||
|
||||
.overflow-arrow[disabled] {
|
||||
@apply opacity-25;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton > .p-togglebutton-content) {
|
||||
@apply max-w-full;
|
||||
}
|
||||
|
||||
:deep(.workflow-tab) {
|
||||
@apply max-w-full;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton::before) {
|
||||
@@ -360,10 +255,6 @@ onUpdated(() => {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
:deep(.workflow-tabs) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Scrollbar half opacity to avoid blocking the active tab bottom border */
|
||||
:deep(.p-scrollpanel:hover .p-scrollpanel-bar),
|
||||
:deep(.p-scrollpanel:active .p-scrollpanel-bar) {
|
||||
@@ -373,15 +264,4 @@ onUpdated(() => {
|
||||
:deep(.p-selectbutton) {
|
||||
@apply rounded-none h-full;
|
||||
}
|
||||
|
||||
.workflow-tabs-container-desktop {
|
||||
max-width: env(titlebar-area-width, 100vw);
|
||||
}
|
||||
|
||||
.window-actions-spacer {
|
||||
@apply flex-auto;
|
||||
/* If we are using custom titlebar, then we need to add a gap for the user to drag the window */
|
||||
--window-actions-spacer-width: min(75px, env(titlebar-area-width, 0) * 9999);
|
||||
min-width: var(--window-actions-spacer-width);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { LGraphCanvas } from '@comfyorg/litegraph'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
interface CanvasTransformSyncOptions {
|
||||
/**
|
||||
* Whether to automatically start syncing when canvas is available
|
||||
* @default true
|
||||
*/
|
||||
autoStart?: boolean
|
||||
/**
|
||||
* Called when sync starts
|
||||
*/
|
||||
onStart?: () => void
|
||||
/**
|
||||
* Called when sync stops
|
||||
*/
|
||||
onStop?: () => void
|
||||
}
|
||||
|
||||
interface CanvasTransform {
|
||||
scale: number
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages requestAnimationFrame-based synchronization with LiteGraph canvas transforms.
|
||||
*
|
||||
* This composable provides a clean way to sync Vue transform state with LiteGraph canvas
|
||||
* on every frame. It handles RAF lifecycle management, and ensures proper cleanup.
|
||||
*
|
||||
* The sync function typically reads canvas.ds properties like offset and scale to keep
|
||||
* Vue components aligned with the canvas coordinate system.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const syncWithCanvas = (canvas: LGraphCanvas) => {
|
||||
* canvas.ds.scale
|
||||
* canvas.ds.offset
|
||||
* }
|
||||
*
|
||||
* const { isActive, startSync, stopSync } = useCanvasTransformSync(
|
||||
* syncWithCanvas,
|
||||
* {
|
||||
* autoStart: false,
|
||||
* onStart: () => emit('rafStatusChange', true),
|
||||
* onStop: () => emit('rafStatusChange', false)
|
||||
* }
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export function useCanvasTransformSync(
|
||||
syncFn: (canvas: LGraphCanvas) => void,
|
||||
options: CanvasTransformSyncOptions = {}
|
||||
) {
|
||||
const { onStart, onStop, autoStart = true } = options
|
||||
const { getCanvas } = useCanvasStore()
|
||||
|
||||
const isActive = ref(false)
|
||||
let rafId: number | null = null
|
||||
let lastTransform: CanvasTransform = {
|
||||
scale: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0
|
||||
}
|
||||
|
||||
const hasTransformChanged = (canvas: LGraphCanvas): boolean => {
|
||||
const ds = canvas.ds
|
||||
return (
|
||||
ds.scale !== lastTransform.scale ||
|
||||
ds.offset[0] !== lastTransform.offsetX ||
|
||||
ds.offset[1] !== lastTransform.offsetY
|
||||
)
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
if (!isActive.value) return
|
||||
|
||||
const canvas = getCanvas()
|
||||
if (!canvas) return
|
||||
|
||||
try {
|
||||
// Only run sync if transform actually changed
|
||||
if (hasTransformChanged(canvas)) {
|
||||
lastTransform = {
|
||||
scale: canvas.ds.scale,
|
||||
offsetX: canvas.ds.offset[0],
|
||||
offsetY: canvas.ds.offset[1]
|
||||
}
|
||||
|
||||
syncFn(canvas)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Canvas transform sync error:', error)
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(sync)
|
||||
}
|
||||
|
||||
const startSync = () => {
|
||||
if (isActive.value) return
|
||||
|
||||
isActive.value = true
|
||||
onStart?.()
|
||||
|
||||
// Reset last transform to force initial sync
|
||||
lastTransform = { scale: 0, offsetX: 0, offsetY: 0 }
|
||||
|
||||
sync()
|
||||
}
|
||||
|
||||
const stopSync = () => {
|
||||
isActive.value = false
|
||||
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
|
||||
onStop?.()
|
||||
}
|
||||
|
||||
onUnmounted(stopSync)
|
||||
|
||||
if (autoStart) {
|
||||
startSync()
|
||||
}
|
||||
|
||||
return {
|
||||
isActive,
|
||||
startSync,
|
||||
stopSync
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Size, Vector2 } from '@comfyorg/litegraph'
|
||||
import { CSSProperties, ref, watch } from 'vue'
|
||||
import { CSSProperties, ref } from 'vue'
|
||||
|
||||
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
export interface PositionConfig {
|
||||
/* The position of the element on litegraph canvas */
|
||||
@@ -19,18 +18,9 @@ export function useAbsolutePosition(options: { useTransform?: boolean } = {}) {
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const lgCanvas = canvasStore.getCanvas()
|
||||
const { canvasPosToClientPos, update: updateCanvasPosition } =
|
||||
useCanvasPositionConversion(lgCanvas.canvas, lgCanvas)
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
watch(
|
||||
[
|
||||
() => settingStore.get('Comfy.Sidebar.Location'),
|
||||
() => settingStore.get('Comfy.Sidebar.Size'),
|
||||
() => settingStore.get('Comfy.UseNewMenu')
|
||||
],
|
||||
() => updateCanvasPosition(),
|
||||
{ flush: 'post' }
|
||||
const { canvasPosToClientPos } = useCanvasPositionConversion(
|
||||
lgCanvas.canvas,
|
||||
lgCanvas
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,7 @@ export const useCanvasPositionConversion = (
|
||||
canvasElement: Parameters<typeof useElementBounding>[0],
|
||||
lgCanvas: LGraphCanvas
|
||||
) => {
|
||||
const { left, top, update } = useElementBounding(canvasElement)
|
||||
const { left, top } = useElementBounding(canvasElement)
|
||||
|
||||
const clientPosToCanvasPos = (pos: Vector2): Vector2 => {
|
||||
const { offset, scale } = lgCanvas.ds
|
||||
@@ -31,7 +31,6 @@ export const useCanvasPositionConversion = (
|
||||
|
||||
return {
|
||||
clientPosToCanvasPos,
|
||||
canvasPosToClientPos,
|
||||
update
|
||||
canvasPosToClientPos
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
|
||||
import { debounce } from 'lodash'
|
||||
import { readonly, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Observes an element for overflow changes and optionally debounces the check
|
||||
* @param element - The element to observe
|
||||
* @param options - The options for the observer
|
||||
* @param options.debounceTime - The time to debounce the check in milliseconds
|
||||
* @param options.useMutationObserver - Whether to use a mutation observer to check for overflow
|
||||
* @param options.useResizeObserver - Whether to use a resize observer to check for overflow
|
||||
* @returns An object containing the isOverflowing state and the checkOverflow function to manually trigger
|
||||
*/
|
||||
export const useOverflowObserver = (
|
||||
element: HTMLElement,
|
||||
options?: {
|
||||
debounceTime?: number
|
||||
useMutationObserver?: boolean
|
||||
useResizeObserver?: boolean
|
||||
onCheck?: (isOverflowing: boolean) => void
|
||||
}
|
||||
) => {
|
||||
options = {
|
||||
debounceTime: 25,
|
||||
useMutationObserver: true,
|
||||
useResizeObserver: true,
|
||||
...options
|
||||
}
|
||||
|
||||
const isOverflowing = ref(false)
|
||||
const disposeFns: (() => void)[] = []
|
||||
const disposed = ref(false)
|
||||
|
||||
const checkOverflowFn = () => {
|
||||
isOverflowing.value = element.scrollWidth > element.clientWidth
|
||||
options.onCheck?.(isOverflowing.value)
|
||||
}
|
||||
|
||||
const checkOverflow = options.debounceTime
|
||||
? debounce(checkOverflowFn, options.debounceTime)
|
||||
: checkOverflowFn
|
||||
|
||||
if (options.useMutationObserver) {
|
||||
disposeFns.push(
|
||||
useMutationObserver(element, checkOverflow, {
|
||||
subtree: true,
|
||||
childList: true
|
||||
}).stop
|
||||
)
|
||||
}
|
||||
if (options.useResizeObserver) {
|
||||
disposeFns.push(useResizeObserver(element, checkOverflow).stop)
|
||||
}
|
||||
|
||||
return {
|
||||
isOverflowing: readonly(isOverflowing),
|
||||
disposed: readonly(disposed),
|
||||
checkOverflow,
|
||||
dispose: () => {
|
||||
disposed.value = true
|
||||
disposeFns.forEach((fn) => fn())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
/**
|
||||
* Composable for handling canvas interactions from Vue components.
|
||||
* This provides a unified way to forward events to the LiteGraph canvas
|
||||
* and will be the foundation for migrating canvas interactions to Vue.
|
||||
*/
|
||||
export function useCanvasInteractions() {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const isStandardNavMode = computed(
|
||||
() => settingStore.get('Comfy.Canvas.NavigationMode') === 'standard'
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles wheel events from UI components that should be forwarded to canvas
|
||||
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
|
||||
*/
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
// In standard mode, Ctrl+wheel should go to canvas for zoom
|
||||
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault() // Prevent browser zoom
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// In legacy mode, all wheel events go to canvas for zoom
|
||||
if (!isStandardNavMode.value) {
|
||||
event.preventDefault()
|
||||
forwardEventToCanvas(event)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, let the component handle it normally
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwards an event to the LiteGraph canvas
|
||||
*/
|
||||
const forwardEventToCanvas = (
|
||||
event: WheelEvent | PointerEvent | MouseEvent
|
||||
) => {
|
||||
const canvasEl = app.canvas?.canvas
|
||||
if (!canvasEl) return
|
||||
|
||||
// Create new event with same properties
|
||||
const EventConstructor = event.constructor as typeof WheelEvent
|
||||
const newEvent = new EventConstructor(event.type, event)
|
||||
canvasEl.dispatchEvent(newEvent)
|
||||
}
|
||||
|
||||
return {
|
||||
handleWheel,
|
||||
forwardEventToCanvas
|
||||
}
|
||||
}
|
||||
@@ -30,25 +30,6 @@ function safePricingExecution(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to calculate Runway duration-based pricing
|
||||
* @param node - The LiteGraph node
|
||||
* @returns Formatted price string
|
||||
*/
|
||||
const calculateRunwayDurationPrice = (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget) return '$0.05/second'
|
||||
|
||||
const duration = Number(durationWidget.value)
|
||||
// If duration is 0 or NaN, don't fall back to 5 seconds - just use 0
|
||||
const validDuration = isNaN(duration) ? 5 : duration
|
||||
const cost = (0.05 * validDuration).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
|
||||
const pixversePricingCalculator = (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration_seconds'
|
||||
@@ -129,27 +110,15 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
FluxProUltraImageNode: {
|
||||
displayPrice: '$0.06/Run'
|
||||
},
|
||||
FluxProKontextProNode: {
|
||||
displayPrice: '$0.04/Run'
|
||||
},
|
||||
FluxProKontextMaxNode: {
|
||||
displayPrice: '$0.08/Run'
|
||||
},
|
||||
IdeogramV1: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const numImagesWidget = node.widgets?.find(
|
||||
(w) => w.name === 'num_images'
|
||||
) as IComboWidget
|
||||
const turboWidget = node.widgets?.find(
|
||||
(w) => w.name === 'turbo'
|
||||
) as IComboWidget
|
||||
|
||||
if (!numImagesWidget) return '$0.02-0.06 x num_images/Run'
|
||||
if (!numImagesWidget) return '$0.06 x num_images/Run'
|
||||
|
||||
const numImages = Number(numImagesWidget.value) || 1
|
||||
const turbo = String(turboWidget?.value).toLowerCase() === 'true'
|
||||
const basePrice = turbo ? 0.02 : 0.06
|
||||
const cost = (basePrice * numImages).toFixed(2)
|
||||
const cost = (0.06 * numImages).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
},
|
||||
@@ -158,16 +127,10 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
const numImagesWidget = node.widgets?.find(
|
||||
(w) => w.name === 'num_images'
|
||||
) as IComboWidget
|
||||
const turboWidget = node.widgets?.find(
|
||||
(w) => w.name === 'turbo'
|
||||
) as IComboWidget
|
||||
|
||||
if (!numImagesWidget) return '$0.05-0.08 x num_images/Run'
|
||||
if (!numImagesWidget) return '$0.08 x num_images/Run'
|
||||
|
||||
const numImages = Number(numImagesWidget.value) || 1
|
||||
const turbo = String(turboWidget?.value).toLowerCase() === 'true'
|
||||
const basePrice = turbo ? 0.05 : 0.08
|
||||
const cost = (basePrice * numImages).toFixed(2)
|
||||
const cost = (0.08 * numImages).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
},
|
||||
@@ -688,10 +651,10 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
|
||||
if (duration.includes('5')) {
|
||||
if (resolution.includes('720p')) return '$0.3/Run'
|
||||
if (resolution.includes('1080p')) return '$0.5/Run'
|
||||
if (resolution.includes('1080p')) return '~$0.3/Run'
|
||||
} else if (duration.includes('10')) {
|
||||
if (resolution.includes('720p')) return '$0.4/Run'
|
||||
if (resolution.includes('1080p')) return '$1.5/Run'
|
||||
if (resolution.includes('720p')) return '$0.25/Run'
|
||||
if (resolution.includes('1080p')) return '$1.0/Run'
|
||||
}
|
||||
|
||||
return '$0.3/Run'
|
||||
@@ -715,9 +678,9 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
|
||||
if (duration.includes('5')) {
|
||||
if (resolution.includes('720p')) return '$0.2/Run'
|
||||
if (resolution.includes('1080p')) return '$0.3/Run'
|
||||
if (resolution.includes('1080p')) return '~$0.45/Run'
|
||||
} else if (duration.includes('10')) {
|
||||
if (resolution.includes('720p')) return '$0.25/Run'
|
||||
if (resolution.includes('720p')) return '$0.6/Run'
|
||||
if (resolution.includes('1080p')) return '$1.0/Run'
|
||||
}
|
||||
|
||||
@@ -933,11 +896,18 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
const aspectRatio = String(aspectRatioWidget.value)
|
||||
|
||||
if (model.includes('photon-flash-1')) {
|
||||
return '$0.0019/Run'
|
||||
if (aspectRatio.includes('1:1')) return '$0.0045/Run'
|
||||
if (aspectRatio.includes('16:9')) return '$0.0045/Run'
|
||||
if (aspectRatio.includes('4:3')) return '$0.0046/Run'
|
||||
if (aspectRatio.includes('21:9')) return '$0.0047/Run'
|
||||
} else if (model.includes('photon-1')) {
|
||||
return '$0.0073/Run'
|
||||
if (aspectRatio.includes('1:1')) return '$0.0172/Run'
|
||||
if (aspectRatio.includes('16:9')) return '$0.0172/Run'
|
||||
if (aspectRatio.includes('4:3')) return '$0.0176/Run'
|
||||
if (aspectRatio.includes('21:9')) return '$0.0182/Run'
|
||||
}
|
||||
|
||||
return '$0.0172/Run'
|
||||
@@ -948,17 +918,31 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
const aspectRatioWidget = node.widgets?.find(
|
||||
(w) => w.name === 'aspect_ratio'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget) {
|
||||
return '$0.0019-0.0073/Run (varies with model)'
|
||||
return '$0.0045-0.0182/Run (varies with model & aspect ratio)'
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
const aspectRatio = aspectRatioWidget
|
||||
? String(aspectRatioWidget.value)
|
||||
: null
|
||||
|
||||
if (model.includes('photon-flash-1')) {
|
||||
return '$0.0019/Run'
|
||||
if (!aspectRatio) return '$0.0045/Run'
|
||||
if (aspectRatio.includes('1:1')) return '~$0.0045/Run'
|
||||
if (aspectRatio.includes('16:9')) return '~$0.0045/Run'
|
||||
if (aspectRatio.includes('4:3')) return '~$0.0046/Run'
|
||||
if (aspectRatio.includes('21:9')) return '~$0.0047/Run'
|
||||
} else if (model.includes('photon-1')) {
|
||||
return '$0.0073/Run'
|
||||
if (!aspectRatio) return '$0.0172/Run'
|
||||
if (aspectRatio.includes('1:1')) return '~$0.0172/Run'
|
||||
if (aspectRatio.includes('16:9')) return '~$0.0172/Run'
|
||||
if (aspectRatio.includes('4:3')) return '~$0.0176/Run'
|
||||
if (aspectRatio.includes('21:9')) return '~$0.0182/Run'
|
||||
}
|
||||
|
||||
return '$0.0172/Run'
|
||||
@@ -1020,279 +1004,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
|
||||
return '$2.25/Run'
|
||||
}
|
||||
},
|
||||
// Runway nodes - using actual node names from ComfyUI
|
||||
RunwayTextToImageNode: {
|
||||
displayPrice: '$0.08/Run'
|
||||
},
|
||||
RunwayImageToVideoNodeGen3a: {
|
||||
displayPrice: calculateRunwayDurationPrice
|
||||
},
|
||||
RunwayImageToVideoNodeGen4: {
|
||||
displayPrice: calculateRunwayDurationPrice
|
||||
},
|
||||
RunwayFirstLastFrameNode: {
|
||||
displayPrice: calculateRunwayDurationPrice
|
||||
},
|
||||
// Rodin nodes - all have the same pricing structure
|
||||
Rodin3D_Regular: {
|
||||
displayPrice: '$0.4/Run'
|
||||
},
|
||||
Rodin3D_Detail: {
|
||||
displayPrice: '$0.4/Run'
|
||||
},
|
||||
Rodin3D_Smooth: {
|
||||
displayPrice: '$0.4/Run'
|
||||
},
|
||||
Rodin3D_Sketch: {
|
||||
displayPrice: '$0.4/Run'
|
||||
},
|
||||
// Tripo nodes - using actual node names from ComfyUI
|
||||
TripoTextToModelNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const quadWidget = node.widgets?.find(
|
||||
(w) => w.name === 'quad'
|
||||
) as IComboWidget
|
||||
const styleWidget = node.widgets?.find(
|
||||
(w) => w.name === 'style'
|
||||
) as IComboWidget
|
||||
const textureWidget = node.widgets?.find(
|
||||
(w) => w.name === 'texture'
|
||||
) as IComboWidget
|
||||
const textureQualityWidget = node.widgets?.find(
|
||||
(w) => w.name === 'texture_quality'
|
||||
) as IComboWidget
|
||||
|
||||
if (!quadWidget || !styleWidget || !textureWidget)
|
||||
return '$0.1-0.4/Run (varies with quad, style, texture & quality)'
|
||||
|
||||
const quad = String(quadWidget.value).toLowerCase() === 'true'
|
||||
const style = String(styleWidget.value).toLowerCase()
|
||||
const texture = String(textureWidget.value).toLowerCase() === 'true'
|
||||
const textureQuality = String(
|
||||
textureQualityWidget?.value || 'standard'
|
||||
).toLowerCase()
|
||||
|
||||
// Pricing logic based on CSV data
|
||||
if (style.includes('none')) {
|
||||
if (!quad) {
|
||||
if (!texture) return '$0.10/Run'
|
||||
else return '$0.15/Run'
|
||||
} else {
|
||||
if (textureQuality.includes('detailed')) {
|
||||
if (!texture) return '$0.30/Run'
|
||||
else return '$0.35/Run'
|
||||
} else {
|
||||
if (!texture) return '$0.20/Run'
|
||||
else return '$0.25/Run'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// any style
|
||||
if (!quad) {
|
||||
if (!texture) return '$0.15/Run'
|
||||
else return '$0.20/Run'
|
||||
} else {
|
||||
if (textureQuality.includes('detailed')) {
|
||||
if (!texture) return '$0.35/Run'
|
||||
else return '$0.40/Run'
|
||||
} else {
|
||||
if (!texture) return '$0.25/Run'
|
||||
else return '$0.30/Run'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
TripoImageToModelNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const quadWidget = node.widgets?.find(
|
||||
(w) => w.name === 'quad'
|
||||
) as IComboWidget
|
||||
const styleWidget = node.widgets?.find(
|
||||
(w) => w.name === 'style'
|
||||
) as IComboWidget
|
||||
const textureWidget = node.widgets?.find(
|
||||
(w) => w.name === 'texture'
|
||||
) as IComboWidget
|
||||
const textureQualityWidget = node.widgets?.find(
|
||||
(w) => w.name === 'texture_quality'
|
||||
) as IComboWidget
|
||||
|
||||
if (!quadWidget || !styleWidget || !textureWidget)
|
||||
return '$0.2-0.5/Run (varies with quad, style, texture & quality)'
|
||||
|
||||
const quad = String(quadWidget.value).toLowerCase() === 'true'
|
||||
const style = String(styleWidget.value).toLowerCase()
|
||||
const texture = String(textureWidget.value).toLowerCase() === 'true'
|
||||
const textureQuality = String(
|
||||
textureQualityWidget?.value || 'standard'
|
||||
).toLowerCase()
|
||||
|
||||
// Pricing logic based on CSV data for Image to Model
|
||||
if (style.includes('none')) {
|
||||
if (!quad) {
|
||||
if (!texture) return '$0.20/Run'
|
||||
else return '$0.25/Run'
|
||||
} else {
|
||||
if (textureQuality.includes('detailed')) {
|
||||
if (!texture) return '$0.40/Run'
|
||||
else return '$0.45/Run'
|
||||
} else {
|
||||
if (!texture) return '$0.30/Run'
|
||||
else return '$0.35/Run'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// any style
|
||||
if (!quad) {
|
||||
if (!texture) return '$0.25/Run'
|
||||
else return '$0.30/Run'
|
||||
} else {
|
||||
if (textureQuality.includes('detailed')) {
|
||||
if (!texture) return '$0.45/Run'
|
||||
else return '$0.50/Run'
|
||||
} else {
|
||||
if (!texture) return '$0.35/Run'
|
||||
else return '$0.40/Run'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
TripoRefineNode: {
|
||||
displayPrice: '$0.3/Run'
|
||||
},
|
||||
TripoTextureNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const textureQualityWidget = node.widgets?.find(
|
||||
(w) => w.name === 'texture_quality'
|
||||
) as IComboWidget
|
||||
|
||||
if (!textureQualityWidget) return '$0.1-0.2/Run (varies with quality)'
|
||||
|
||||
const textureQuality = String(textureQualityWidget.value)
|
||||
return textureQuality.includes('detailed') ? '$0.2/Run' : '$0.1/Run'
|
||||
}
|
||||
},
|
||||
TripoConvertModelNode: {
|
||||
displayPrice: '$0.10/Run'
|
||||
},
|
||||
TripoRetargetRiggedModelNode: {
|
||||
displayPrice: '$0.10/Run'
|
||||
},
|
||||
TripoMultiviewToModelNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const quadWidget = node.widgets?.find(
|
||||
(w) => w.name === 'quad'
|
||||
) as IComboWidget
|
||||
const styleWidget = node.widgets?.find(
|
||||
(w) => w.name === 'style'
|
||||
) as IComboWidget
|
||||
const textureWidget = node.widgets?.find(
|
||||
(w) => w.name === 'texture'
|
||||
) as IComboWidget
|
||||
const textureQualityWidget = node.widgets?.find(
|
||||
(w) => w.name === 'texture_quality'
|
||||
) as IComboWidget
|
||||
|
||||
if (!quadWidget || !styleWidget || !textureWidget)
|
||||
return '$0.2-0.5/Run (varies with quad, style, texture & quality)'
|
||||
|
||||
const quad = String(quadWidget.value).toLowerCase() === 'true'
|
||||
const style = String(styleWidget.value).toLowerCase()
|
||||
const texture = String(textureWidget.value).toLowerCase() === 'true'
|
||||
const textureQuality = String(
|
||||
textureQualityWidget?.value || 'standard'
|
||||
).toLowerCase()
|
||||
|
||||
// Pricing logic based on CSV data for Multiview to Model (same as Image to Model)
|
||||
if (style.includes('none')) {
|
||||
if (!quad) {
|
||||
if (!texture) return '$0.20/Run'
|
||||
else return '$0.25/Run'
|
||||
} else {
|
||||
if (textureQuality.includes('detailed')) {
|
||||
if (!texture) return '$0.40/Run'
|
||||
else return '$0.45/Run'
|
||||
} else {
|
||||
if (!texture) return '$0.30/Run'
|
||||
else return '$0.35/Run'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// any style
|
||||
if (!quad) {
|
||||
if (!texture) return '$0.25/Run'
|
||||
else return '$0.30/Run'
|
||||
} else {
|
||||
if (textureQuality.includes('detailed')) {
|
||||
if (!texture) return '$0.45/Run'
|
||||
else return '$0.50/Run'
|
||||
} else {
|
||||
if (!texture) return '$0.35/Run'
|
||||
else return '$0.40/Run'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Google/Gemini nodes
|
||||
GeminiNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget) return 'Token-based'
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
|
||||
// Google Veo video generation
|
||||
if (model.includes('veo-2.0')) {
|
||||
return '$0.5/second'
|
||||
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
|
||||
return '$0.00016/$0.0006 per 1K tokens'
|
||||
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
|
||||
return '$0.00125/$0.01 per 1K tokens'
|
||||
}
|
||||
// For other Gemini models, show token-based pricing info
|
||||
return 'Token-based'
|
||||
}
|
||||
},
|
||||
// OpenAI nodes
|
||||
OpenAIChatNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget) return 'Token-based'
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
|
||||
// Specific pricing for exposed models based on official pricing data (converted to per 1K tokens)
|
||||
if (model.includes('o4-mini')) {
|
||||
return '$0.0011/$0.0044 per 1K tokens'
|
||||
} else if (model.includes('o1-pro')) {
|
||||
return '$0.15/$0.60 per 1K tokens'
|
||||
} else if (model.includes('o1')) {
|
||||
return '$0.015/$0.06 per 1K tokens'
|
||||
} else if (model.includes('o3-mini')) {
|
||||
return '$0.0011/$0.0044 per 1K tokens'
|
||||
} else if (model.includes('o3')) {
|
||||
return '$0.01/$0.04 per 1K tokens'
|
||||
} else if (model.includes('gpt-4o')) {
|
||||
return '$0.0025/$0.01 per 1K tokens'
|
||||
} else if (model.includes('gpt-4.1-nano')) {
|
||||
return '$0.0001/$0.0004 per 1K tokens'
|
||||
} else if (model.includes('gpt-4.1-mini')) {
|
||||
return '$0.0004/$0.0016 per 1K tokens'
|
||||
} else if (model.includes('gpt-4.1')) {
|
||||
return '$0.002/$0.008 per 1K tokens'
|
||||
}
|
||||
return 'Token-based'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1334,11 +1045,9 @@ export const useNodePricing = () => {
|
||||
OpenAIDalle3: ['size', 'quality'],
|
||||
OpenAIDalle2: ['size', 'n'],
|
||||
OpenAIGPTImage1: ['quality', 'n'],
|
||||
IdeogramV1: ['num_images', 'turbo'],
|
||||
IdeogramV2: ['num_images', 'turbo'],
|
||||
IdeogramV1: ['num_images'],
|
||||
IdeogramV2: ['num_images'],
|
||||
IdeogramV3: ['rendering_speed', 'num_images'],
|
||||
FluxProKontextProNode: [],
|
||||
FluxProKontextMaxNode: [],
|
||||
VeoVideoGenerationNode: ['duration_seconds'],
|
||||
LumaVideoNode: ['model', 'resolution', 'duration'],
|
||||
LumaImageToVideoNode: ['model', 'resolution', 'duration'],
|
||||
@@ -1366,19 +1075,7 @@ export const useNodePricing = () => {
|
||||
RecraftGenerateVectorImageNode: ['n'],
|
||||
MoonvalleyTxt2VideoNode: ['length'],
|
||||
MoonvalleyImg2VideoNode: ['length'],
|
||||
MoonvalleyVideo2VideoNode: ['length'],
|
||||
// Runway nodes
|
||||
RunwayImageToVideoNodeGen3a: ['duration'],
|
||||
RunwayImageToVideoNodeGen4: ['duration'],
|
||||
RunwayFirstLastFrameNode: ['duration'],
|
||||
// Tripo nodes
|
||||
TripoTextToModelNode: ['quad', 'style', 'texture', 'texture_quality'],
|
||||
TripoImageToModelNode: ['quad', 'style', 'texture', 'texture_quality'],
|
||||
TripoTextureNode: ['texture_quality'],
|
||||
// Google/Gemini nodes
|
||||
GeminiNode: ['model'],
|
||||
// OpenAI nodes
|
||||
OpenAIChatNode: ['model']
|
||||
MoonvalleyVideo2VideoNode: ['length']
|
||||
}
|
||||
return widgetMap[nodeType] || []
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { app } from '@/scripts/app'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
/**
|
||||
* Composable to find missing NodePacks from workflow
|
||||
@@ -57,7 +56,7 @@ export const useMissingNodes = () => {
|
||||
}
|
||||
|
||||
const missingCoreNodes = computed<Record<string, LGraphNode[]>>(() => {
|
||||
const missingNodes = collectAllNodes(app.graph, isMissingCoreNode)
|
||||
const missingNodes = app.graph.nodes.filter(isMissingCoreNode)
|
||||
return groupBy(missingNodes, (node) => String(node.properties?.ver || ''))
|
||||
})
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import { SelectedVersion, UseNodePacksOptions } from '@/types/comfyManagerTypes'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
type WorkflowPack = {
|
||||
id:
|
||||
@@ -110,13 +109,11 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the node packs for all nodes in the workflow (including subgraphs).
|
||||
* Get the node packs for all nodes in the workflow.
|
||||
*/
|
||||
const getWorkflowPacks = async () => {
|
||||
if (!app.graph) return []
|
||||
const allNodes = collectAllNodes(app.graph)
|
||||
if (!allNodes.length) return []
|
||||
const packs = await Promise.all(allNodes.map(workflowNodeToPack))
|
||||
if (!app.graph?.nodes?.length) return []
|
||||
const packs = await Promise.all(app.graph.nodes.map(workflowNodeToPack))
|
||||
workflowPacks.value = packs.filter((pack) => pack !== undefined)
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export const useBrowserTabTitle = () => {
|
||||
const [nodeId, state] = runningNodes[0]
|
||||
const progress = Math.round((state.value / state.max) * 100)
|
||||
const nodeType =
|
||||
executionStore.activePrompt?.workflow?.changeTracker?.activeState.nodes.find(
|
||||
executionStore.activePrompt?.workflow?.changeTracker?.activeState?.nodes.find(
|
||||
(n) => String(n.id) === nodeId
|
||||
)?.type || 'Node'
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { getAllNonIoNodesInSubgraph } from '@/utils/graphTraversalUtil'
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
|
||||
@@ -172,16 +171,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
confirm('Clear workflow?')
|
||||
) {
|
||||
app.clean()
|
||||
if (app.canvas.subgraph) {
|
||||
// `clear` is not implemented on subgraphs and the parent class's
|
||||
// (`LGraph`) `clear` breaks the subgraph structure. For subgraphs,
|
||||
// just clear the nodes but preserve input/output nodes and structure
|
||||
const subgraph = app.canvas.subgraph
|
||||
const nonIoNodes = getAllNonIoNodesInSubgraph(subgraph)
|
||||
nonIoNodes.forEach((node) => subgraph.remove(node))
|
||||
} else {
|
||||
app.graph.clear()
|
||||
}
|
||||
app.graph.clear()
|
||||
api.dispatchCustomEvent('graphCleared')
|
||||
}
|
||||
}
|
||||
@@ -323,19 +313,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}
|
||||
})()
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.ToggleMinimap',
|
||||
icon: 'pi pi-map',
|
||||
label: 'Canvas Toggle Minimap',
|
||||
versionAdded: '1.24.1',
|
||||
function: async () => {
|
||||
const settingStore = useSettingStore()
|
||||
await settingStore.set(
|
||||
'Comfy.Minimap.Visible',
|
||||
!settingStore.get('Comfy.Minimap.Visible')
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.QueuePrompt',
|
||||
icon: 'pi pi-play',
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useVersionCompatibilityStore } from '@/stores/versionCompatibilityStore'
|
||||
|
||||
export interface UseFrontendVersionMismatchWarningOptions {
|
||||
immediate?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for handling frontend version mismatch warnings.
|
||||
*
|
||||
* Displays toast notifications when the frontend version is incompatible with the backend,
|
||||
* either because the frontend is outdated or newer than the backend expects.
|
||||
* Automatically dismisses warnings when shown and persists dismissal state for 7 days.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @param options.immediate - If true, automatically shows warning when version mismatch is detected
|
||||
* @returns Object with methods and computed properties for managing version warnings
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Show warning immediately when mismatch detected
|
||||
* const { showWarning, shouldShowWarning } = useFrontendVersionMismatchWarning({ immediate: true })
|
||||
*
|
||||
* // Manual control
|
||||
* const { showWarning } = useFrontendVersionMismatchWarning()
|
||||
* showWarning() // Call when needed
|
||||
* ```
|
||||
*/
|
||||
export function useFrontendVersionMismatchWarning(
|
||||
options: UseFrontendVersionMismatchWarningOptions = {}
|
||||
) {
|
||||
const { immediate = false } = options
|
||||
const { t } = useI18n()
|
||||
const toastStore = useToastStore()
|
||||
const versionCompatibilityStore = useVersionCompatibilityStore()
|
||||
|
||||
// Track if we've already shown the warning
|
||||
let hasShownWarning = false
|
||||
|
||||
const showWarning = () => {
|
||||
// Prevent showing the warning multiple times
|
||||
if (hasShownWarning) return
|
||||
|
||||
const message = versionCompatibilityStore.warningMessage
|
||||
if (!message) return
|
||||
|
||||
const detailMessage = t('g.frontendOutdated', {
|
||||
frontendVersion: message.frontendVersion,
|
||||
requiredVersion: message.requiredVersion
|
||||
})
|
||||
|
||||
const fullMessage = t('g.versionMismatchWarningMessage', {
|
||||
warning: t('g.versionMismatchWarning'),
|
||||
detail: detailMessage
|
||||
})
|
||||
|
||||
toastStore.addAlert(fullMessage)
|
||||
hasShownWarning = true
|
||||
|
||||
// Automatically dismiss the warning so it won't show again for 7 days
|
||||
versionCompatibilityStore.dismissWarning()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Only set up the watcher if immediate is true
|
||||
if (immediate) {
|
||||
whenever(
|
||||
() => versionCompatibilityStore.shouldShowWarning,
|
||||
() => {
|
||||
showWarning()
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
once: true
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
showWarning,
|
||||
shouldShowWarning: computed(
|
||||
() => versionCompatibilityStore.shouldShowWarning
|
||||
),
|
||||
dismissWarning: versionCompatibilityStore.dismissWarning,
|
||||
hasVersionMismatch: computed(
|
||||
() => versionCompatibilityStore.hasVersionMismatch
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -124,12 +124,9 @@ export const useLitegraphSettings = () => {
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const navigationMode = settingStore.get('Comfy.Canvas.NavigationMode') as
|
||||
| 'standard'
|
||||
| 'legacy'
|
||||
|
||||
LiteGraph.canvasNavigationMode = navigationMode
|
||||
LiteGraph.macTrackpadGestures = navigationMode === 'standard'
|
||||
LiteGraph.macTrackpadGestures = settingStore.get(
|
||||
'LiteGraph.Pointer.TrackpadGestures'
|
||||
)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
|
||||
@@ -1,685 +0,0 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useRafFn, useThrottleFn } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
interface GraphCallbacks {
|
||||
onNodeAdded?: (node: LGraphNode) => void
|
||||
onNodeRemoved?: (node: LGraphNode) => void
|
||||
onConnectionChange?: (node: LGraphNode) => void
|
||||
}
|
||||
|
||||
export function useMinimap() {
|
||||
const settingStore = useSettingStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const minimapRef = ref<any>(null)
|
||||
|
||||
const visible = ref(true)
|
||||
|
||||
const initialized = ref(false)
|
||||
const bounds = ref({
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 0,
|
||||
maxY: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
})
|
||||
const scale = ref(1)
|
||||
const isDragging = ref(false)
|
||||
const viewportTransform = ref({ x: 0, y: 0, width: 0, height: 0 })
|
||||
|
||||
const needsFullRedraw = ref(true)
|
||||
const needsBoundsUpdate = ref(true)
|
||||
const lastNodeCount = ref(0)
|
||||
const nodeStatesCache = new Map<NodeId, string>()
|
||||
const linksCache = ref<string>('')
|
||||
|
||||
const updateFlags = ref({
|
||||
bounds: false,
|
||||
nodes: false,
|
||||
connections: false,
|
||||
viewport: false
|
||||
})
|
||||
|
||||
const width = 250
|
||||
const height = 200
|
||||
const nodeColor = '#0B8CE999'
|
||||
const linkColor = '#F99614'
|
||||
const slotColor = '#F99614'
|
||||
const viewportColor = '#FFF'
|
||||
const backgroundColor = '#15161C'
|
||||
const borderColor = '#333'
|
||||
|
||||
const containerRect = ref({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: width,
|
||||
height: height
|
||||
})
|
||||
|
||||
const canvasDimensions = ref({
|
||||
width: 0,
|
||||
height: 0
|
||||
})
|
||||
|
||||
const updateContainerRect = () => {
|
||||
if (!containerRef.value) return
|
||||
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
containerRect.value = {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
}
|
||||
}
|
||||
|
||||
const updateCanvasDimensions = () => {
|
||||
const c = canvas.value
|
||||
if (!c) return
|
||||
|
||||
const canvasEl = c.canvas
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
canvasDimensions.value = {
|
||||
width: canvasEl.clientWidth || canvasEl.width / dpr,
|
||||
height: canvasEl.clientHeight || canvasEl.height / dpr
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = computed(() => canvasStore.canvas)
|
||||
const graph = computed(() => canvas.value?.graph)
|
||||
|
||||
const containerStyles = computed(() => ({
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
backgroundColor: backgroundColor,
|
||||
border: `1px solid ${borderColor}`,
|
||||
borderRadius: '8px'
|
||||
}))
|
||||
|
||||
const viewportStyles = computed(() => ({
|
||||
transform: `translate(${viewportTransform.value.x}px, ${viewportTransform.value.y}px)`,
|
||||
width: `${viewportTransform.value.width}px`,
|
||||
height: `${viewportTransform.value.height}px`,
|
||||
border: `2px solid ${viewportColor}`,
|
||||
backgroundColor: `${viewportColor}33`,
|
||||
willChange: 'transform',
|
||||
backfaceVisibility: 'hidden' as const,
|
||||
perspective: '1000px',
|
||||
pointerEvents: 'none' as const
|
||||
}))
|
||||
|
||||
const calculateGraphBounds = () => {
|
||||
const g = graph.value
|
||||
if (!g?._nodes || g._nodes.length === 0) {
|
||||
return { minX: 0, minY: 0, maxX: 100, maxY: 100, width: 100, height: 100 }
|
||||
}
|
||||
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
for (const node of g._nodes) {
|
||||
minX = Math.min(minX, node.pos[0])
|
||||
minY = Math.min(minY, node.pos[1])
|
||||
maxX = Math.max(maxX, node.pos[0] + node.size[0])
|
||||
maxY = Math.max(maxY, node.pos[1] + node.size[1])
|
||||
}
|
||||
|
||||
let currentWidth = maxX - minX
|
||||
let currentHeight = maxY - minY
|
||||
|
||||
// Enforce minimum viewport dimensions for better visualization
|
||||
const minViewportWidth = 2500
|
||||
const minViewportHeight = 2000
|
||||
|
||||
if (currentWidth < minViewportWidth) {
|
||||
const padding = (minViewportWidth - currentWidth) / 2
|
||||
minX -= padding
|
||||
maxX += padding
|
||||
currentWidth = minViewportWidth
|
||||
}
|
||||
|
||||
if (currentHeight < minViewportHeight) {
|
||||
const padding = (minViewportHeight - currentHeight) / 2
|
||||
minY -= padding
|
||||
maxY += padding
|
||||
currentHeight = minViewportHeight
|
||||
}
|
||||
|
||||
return {
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
width: currentWidth,
|
||||
height: currentHeight
|
||||
}
|
||||
}
|
||||
|
||||
const calculateScale = () => {
|
||||
if (bounds.value.width === 0 || bounds.value.height === 0) {
|
||||
return 1
|
||||
}
|
||||
|
||||
const scaleX = width / bounds.value.width
|
||||
const scaleY = height / bounds.value.height
|
||||
|
||||
// Apply 0.9 factor to provide padding/gap between nodes and minimap borders
|
||||
return Math.min(scaleX, scaleY) * 0.9
|
||||
}
|
||||
|
||||
const renderNodes = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
offsetX: number,
|
||||
offsetY: number
|
||||
) => {
|
||||
const g = graph.value
|
||||
if (!g || !g._nodes || g._nodes.length === 0) return
|
||||
|
||||
for (const node of g._nodes) {
|
||||
const x = (node.pos[0] - bounds.value.minX) * scale.value + offsetX
|
||||
const y = (node.pos[1] - bounds.value.minY) * scale.value + offsetY
|
||||
const w = node.size[0] * scale.value
|
||||
const h = node.size[1] * scale.value
|
||||
|
||||
// Render solid node blocks
|
||||
ctx.fillStyle = nodeColor
|
||||
ctx.fillRect(x, y, w, h)
|
||||
}
|
||||
}
|
||||
|
||||
const renderConnections = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
offsetX: number,
|
||||
offsetY: number
|
||||
) => {
|
||||
const g = graph.value
|
||||
if (!g) return
|
||||
|
||||
ctx.strokeStyle = linkColor
|
||||
ctx.lineWidth = 1.4
|
||||
|
||||
const slotRadius = 3.7 * Math.max(scale.value, 0.5) // Larger slots that scale
|
||||
const connections: Array<{
|
||||
x1: number
|
||||
y1: number
|
||||
x2: number
|
||||
y2: number
|
||||
}> = []
|
||||
|
||||
for (const node of g._nodes) {
|
||||
if (!node.outputs) continue
|
||||
|
||||
const x1 = (node.pos[0] - bounds.value.minX) * scale.value + offsetX
|
||||
const y1 = (node.pos[1] - bounds.value.minY) * scale.value + offsetY
|
||||
|
||||
for (const output of node.outputs) {
|
||||
if (!output.links) continue
|
||||
|
||||
for (const linkId of output.links) {
|
||||
const link = g.links[linkId]
|
||||
if (!link) continue
|
||||
|
||||
const targetNode = g.getNodeById(link.target_id)
|
||||
if (!targetNode) continue
|
||||
|
||||
const x2 =
|
||||
(targetNode.pos[0] - bounds.value.minX) * scale.value + offsetX
|
||||
const y2 =
|
||||
(targetNode.pos[1] - bounds.value.minY) * scale.value + offsetY
|
||||
|
||||
const outputX = x1 + node.size[0] * scale.value
|
||||
const outputY = y1 + node.size[1] * scale.value * 0.2
|
||||
const inputX = x2
|
||||
const inputY = y2 + targetNode.size[1] * scale.value * 0.2
|
||||
|
||||
// Draw connection line
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(outputX, outputY)
|
||||
ctx.lineTo(inputX, inputY)
|
||||
ctx.stroke()
|
||||
|
||||
connections.push({ x1: outputX, y1: outputY, x2: inputX, y2: inputY })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render connection slots on top
|
||||
ctx.fillStyle = slotColor
|
||||
for (const conn of connections) {
|
||||
// Output slot
|
||||
ctx.beginPath()
|
||||
ctx.arc(conn.x1, conn.y1, slotRadius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Input slot
|
||||
ctx.beginPath()
|
||||
ctx.arc(conn.x2, conn.y2, slotRadius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
const renderMinimap = () => {
|
||||
if (!canvasRef.value || !graph.value) return
|
||||
|
||||
const ctx = canvasRef.value.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Fast path for 0 nodes - just show background
|
||||
if (!graph.value._nodes || graph.value._nodes.length === 0) {
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
return
|
||||
}
|
||||
|
||||
const needsRedraw =
|
||||
needsFullRedraw.value ||
|
||||
updateFlags.value.nodes ||
|
||||
updateFlags.value.connections
|
||||
|
||||
if (needsRedraw) {
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
const offsetX = (width - bounds.value.width * scale.value) / 2
|
||||
const offsetY = (height - bounds.value.height * scale.value) / 2
|
||||
|
||||
renderNodes(ctx, offsetX, offsetY)
|
||||
renderConnections(ctx, offsetX, offsetY)
|
||||
|
||||
needsFullRedraw.value = false
|
||||
updateFlags.value.nodes = false
|
||||
updateFlags.value.connections = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateViewport = () => {
|
||||
const c = canvas.value
|
||||
if (!c) return
|
||||
|
||||
if (
|
||||
canvasDimensions.value.width === 0 ||
|
||||
canvasDimensions.value.height === 0
|
||||
) {
|
||||
updateCanvasDimensions()
|
||||
}
|
||||
|
||||
const ds = c.ds
|
||||
|
||||
const viewportWidth = canvasDimensions.value.width / ds.scale
|
||||
const viewportHeight = canvasDimensions.value.height / ds.scale
|
||||
|
||||
const worldX = -ds.offset[0]
|
||||
const worldY = -ds.offset[1]
|
||||
|
||||
const centerOffsetX = (width - bounds.value.width * scale.value) / 2
|
||||
const centerOffsetY = (height - bounds.value.height * scale.value) / 2
|
||||
|
||||
viewportTransform.value = {
|
||||
x: (worldX - bounds.value.minX) * scale.value + centerOffsetX,
|
||||
y: (worldY - bounds.value.minY) * scale.value + centerOffsetY,
|
||||
width: viewportWidth * scale.value,
|
||||
height: viewportHeight * scale.value
|
||||
}
|
||||
|
||||
updateFlags.value.viewport = false
|
||||
}
|
||||
|
||||
const updateMinimap = () => {
|
||||
if (needsBoundsUpdate.value || updateFlags.value.bounds) {
|
||||
bounds.value = calculateGraphBounds()
|
||||
scale.value = calculateScale()
|
||||
needsBoundsUpdate.value = false
|
||||
updateFlags.value.bounds = false
|
||||
needsFullRedraw.value = true
|
||||
}
|
||||
|
||||
if (
|
||||
needsFullRedraw.value ||
|
||||
updateFlags.value.nodes ||
|
||||
updateFlags.value.connections
|
||||
) {
|
||||
renderMinimap()
|
||||
}
|
||||
}
|
||||
|
||||
const checkForChanges = useThrottleFn(() => {
|
||||
const g = graph.value
|
||||
if (!g) return
|
||||
|
||||
let structureChanged = false
|
||||
let positionChanged = false
|
||||
let connectionChanged = false
|
||||
|
||||
if (g._nodes.length !== lastNodeCount.value) {
|
||||
structureChanged = true
|
||||
lastNodeCount.value = g._nodes.length
|
||||
}
|
||||
|
||||
for (const node of g._nodes) {
|
||||
const key = node.id
|
||||
const currentState = `${node.pos[0]},${node.pos[1]},${node.size[0]},${node.size[1]}`
|
||||
|
||||
if (nodeStatesCache.get(key) !== currentState) {
|
||||
positionChanged = true
|
||||
nodeStatesCache.set(key, currentState)
|
||||
}
|
||||
}
|
||||
|
||||
const currentLinks = JSON.stringify(g.links || {})
|
||||
if (currentLinks !== linksCache.value) {
|
||||
connectionChanged = true
|
||||
linksCache.value = currentLinks
|
||||
}
|
||||
|
||||
const currentNodeIds = new Set(g._nodes.map((n) => n.id))
|
||||
for (const [nodeId] of nodeStatesCache) {
|
||||
if (!currentNodeIds.has(nodeId)) {
|
||||
nodeStatesCache.delete(nodeId)
|
||||
structureChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
if (structureChanged || positionChanged) {
|
||||
updateFlags.value.bounds = true
|
||||
updateFlags.value.nodes = true
|
||||
}
|
||||
|
||||
if (connectionChanged) {
|
||||
updateFlags.value.connections = true
|
||||
}
|
||||
|
||||
if (structureChanged || positionChanged || connectionChanged) {
|
||||
updateMinimap()
|
||||
}
|
||||
}, 500)
|
||||
|
||||
const { pause: pauseChangeDetection, resume: resumeChangeDetection } =
|
||||
useRafFn(
|
||||
async () => {
|
||||
if (visible.value) {
|
||||
await checkForChanges()
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const { startSync: startViewportSync, stopSync: stopViewportSync } =
|
||||
useCanvasTransformSync(updateViewport, { autoStart: false })
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
isDragging.value = true
|
||||
updateContainerRect()
|
||||
handleMouseMove(e)
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging.value || !canvasRef.value || !canvas.value) return
|
||||
|
||||
const x = e.clientX - containerRect.value.left
|
||||
const y = e.clientY - containerRect.value.top
|
||||
|
||||
const offsetX = (width - bounds.value.width * scale.value) / 2
|
||||
const offsetY = (height - bounds.value.height * scale.value) / 2
|
||||
|
||||
const worldX = (x - offsetX) / scale.value + bounds.value.minX
|
||||
const worldY = (y - offsetY) / scale.value + bounds.value.minY
|
||||
|
||||
centerViewOn(worldX, worldY)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const c = canvas.value
|
||||
if (!c) return
|
||||
|
||||
if (
|
||||
containerRect.value.left === 0 &&
|
||||
containerRect.value.top === 0 &&
|
||||
containerRef.value
|
||||
) {
|
||||
updateContainerRect()
|
||||
}
|
||||
|
||||
const ds = c.ds
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1
|
||||
|
||||
const newScale = ds.scale * delta
|
||||
|
||||
const MIN_SCALE = 0.1
|
||||
const MAX_SCALE = 10
|
||||
|
||||
if (newScale < MIN_SCALE || newScale > MAX_SCALE) return
|
||||
|
||||
const x = e.clientX - containerRect.value.left
|
||||
const y = e.clientY - containerRect.value.top
|
||||
|
||||
const offsetX = (width - bounds.value.width * scale.value) / 2
|
||||
const offsetY = (height - bounds.value.height * scale.value) / 2
|
||||
|
||||
const worldX = (x - offsetX) / scale.value + bounds.value.minX
|
||||
const worldY = (y - offsetY) / scale.value + bounds.value.minY
|
||||
|
||||
ds.scale = newScale
|
||||
|
||||
centerViewOn(worldX, worldY)
|
||||
}
|
||||
|
||||
const centerViewOn = (worldX: number, worldY: number) => {
|
||||
const c = canvas.value
|
||||
if (!c) return
|
||||
|
||||
if (
|
||||
canvasDimensions.value.width === 0 ||
|
||||
canvasDimensions.value.height === 0
|
||||
) {
|
||||
updateCanvasDimensions()
|
||||
}
|
||||
|
||||
const ds = c.ds
|
||||
|
||||
const viewportWidth = canvasDimensions.value.width / ds.scale
|
||||
const viewportHeight = canvasDimensions.value.height / ds.scale
|
||||
|
||||
ds.offset[0] = -(worldX - viewportWidth / 2)
|
||||
ds.offset[1] = -(worldY - viewportHeight / 2)
|
||||
|
||||
updateFlags.value.viewport = true
|
||||
|
||||
c.setDirty(true, true)
|
||||
}
|
||||
|
||||
let originalCallbacks: GraphCallbacks = {}
|
||||
|
||||
const handleGraphChanged = useThrottleFn(() => {
|
||||
needsFullRedraw.value = true
|
||||
updateFlags.value.bounds = true
|
||||
updateFlags.value.nodes = true
|
||||
updateFlags.value.connections = true
|
||||
updateMinimap()
|
||||
}, 500)
|
||||
|
||||
const setupEventListeners = () => {
|
||||
const g = graph.value
|
||||
if (!g) return
|
||||
|
||||
originalCallbacks = {
|
||||
onNodeAdded: g.onNodeAdded,
|
||||
onNodeRemoved: g.onNodeRemoved,
|
||||
onConnectionChange: g.onConnectionChange
|
||||
}
|
||||
|
||||
g.onNodeAdded = function (node) {
|
||||
originalCallbacks.onNodeAdded?.call(this, node)
|
||||
|
||||
void handleGraphChanged()
|
||||
}
|
||||
|
||||
g.onNodeRemoved = function (node) {
|
||||
originalCallbacks.onNodeRemoved?.call(this, node)
|
||||
nodeStatesCache.delete(node.id)
|
||||
void handleGraphChanged()
|
||||
}
|
||||
|
||||
g.onConnectionChange = function (node) {
|
||||
originalCallbacks.onConnectionChange?.call(this, node)
|
||||
|
||||
void handleGraphChanged()
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupEventListeners = () => {
|
||||
const g = graph.value
|
||||
if (!g) return
|
||||
|
||||
if (originalCallbacks.onNodeAdded !== undefined) {
|
||||
g.onNodeAdded = originalCallbacks.onNodeAdded
|
||||
}
|
||||
if (originalCallbacks.onNodeRemoved !== undefined) {
|
||||
g.onNodeRemoved = originalCallbacks.onNodeRemoved
|
||||
}
|
||||
if (originalCallbacks.onConnectionChange !== undefined) {
|
||||
g.onConnectionChange = originalCallbacks.onConnectionChange
|
||||
}
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
if (initialized.value) return
|
||||
|
||||
visible.value = settingStore.get('Comfy.Minimap.Visible')
|
||||
|
||||
if (canvas.value && graph.value) {
|
||||
setupEventListeners()
|
||||
|
||||
api.addEventListener('graphChanged', handleGraphChanged)
|
||||
|
||||
if (containerRef.value) {
|
||||
updateContainerRect()
|
||||
}
|
||||
updateCanvasDimensions()
|
||||
|
||||
window.addEventListener('resize', updateContainerRect)
|
||||
window.addEventListener('scroll', updateContainerRect)
|
||||
window.addEventListener('resize', updateCanvasDimensions)
|
||||
|
||||
needsFullRedraw.value = true
|
||||
updateFlags.value.bounds = true
|
||||
updateFlags.value.nodes = true
|
||||
updateFlags.value.connections = true
|
||||
updateFlags.value.viewport = true
|
||||
|
||||
updateMinimap()
|
||||
updateViewport()
|
||||
|
||||
if (visible.value) {
|
||||
resumeChangeDetection()
|
||||
startViewportSync()
|
||||
}
|
||||
initialized.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const destroy = () => {
|
||||
pauseChangeDetection()
|
||||
stopViewportSync()
|
||||
cleanupEventListeners()
|
||||
|
||||
api.removeEventListener('graphChanged', handleGraphChanged)
|
||||
|
||||
window.removeEventListener('resize', updateContainerRect)
|
||||
window.removeEventListener('scroll', updateContainerRect)
|
||||
window.removeEventListener('resize', updateCanvasDimensions)
|
||||
|
||||
nodeStatesCache.clear()
|
||||
initialized.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
canvas,
|
||||
async (newCanvas, oldCanvas) => {
|
||||
if (oldCanvas) {
|
||||
cleanupEventListeners()
|
||||
pauseChangeDetection()
|
||||
stopViewportSync()
|
||||
api.removeEventListener('graphChanged', handleGraphChanged)
|
||||
window.removeEventListener('resize', updateContainerRect)
|
||||
window.removeEventListener('scroll', updateContainerRect)
|
||||
window.removeEventListener('resize', updateCanvasDimensions)
|
||||
}
|
||||
if (newCanvas && !initialized.value) {
|
||||
await init()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(visible, async (isVisible) => {
|
||||
if (isVisible) {
|
||||
if (containerRef.value) {
|
||||
updateContainerRect()
|
||||
}
|
||||
updateCanvasDimensions()
|
||||
|
||||
needsFullRedraw.value = true
|
||||
updateFlags.value.bounds = true
|
||||
updateFlags.value.nodes = true
|
||||
updateFlags.value.connections = true
|
||||
updateFlags.value.viewport = true
|
||||
|
||||
await nextTick()
|
||||
|
||||
updateMinimap()
|
||||
updateViewport()
|
||||
resumeChangeDetection()
|
||||
startViewportSync()
|
||||
} else {
|
||||
pauseChangeDetection()
|
||||
stopViewportSync()
|
||||
}
|
||||
})
|
||||
|
||||
const toggle = async () => {
|
||||
visible.value = !visible.value
|
||||
await settingStore.set('Comfy.Minimap.Visible', visible.value)
|
||||
}
|
||||
|
||||
const setMinimapRef = (ref: any) => {
|
||||
minimapRef.value = ref
|
||||
}
|
||||
|
||||
return {
|
||||
visible: computed(() => visible.value),
|
||||
initialized: computed(() => initialized.value),
|
||||
|
||||
containerRef,
|
||||
canvasRef,
|
||||
containerStyles,
|
||||
viewportStyles,
|
||||
width,
|
||||
height,
|
||||
|
||||
init,
|
||||
destroy,
|
||||
toggle,
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp,
|
||||
handleWheel,
|
||||
setMinimapRef
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
@@ -8,11 +9,12 @@ interface RefreshableItem {
|
||||
refresh: () => Promise<void> | void
|
||||
}
|
||||
|
||||
const isRefreshableWidget = (widget: unknown): widget is RefreshableItem =>
|
||||
widget != null &&
|
||||
typeof widget === 'object' &&
|
||||
'refresh' in widget &&
|
||||
typeof widget.refresh === 'function'
|
||||
type RefreshableWidget = IBaseWidget & RefreshableItem
|
||||
|
||||
const isRefreshableWidget = (
|
||||
widget: IBaseWidget
|
||||
): widget is RefreshableWidget =>
|
||||
'refresh' in widget && typeof widget.refresh === 'function'
|
||||
|
||||
/**
|
||||
* Tracks selected nodes and their refreshable widgets
|
||||
@@ -25,17 +27,10 @@ export const useRefreshableSelection = () => {
|
||||
selectedNodes.value = graphStore.selectedItems.filter(isLGraphNode)
|
||||
})
|
||||
|
||||
const refreshableWidgets = computed<RefreshableItem[]>(() =>
|
||||
selectedNodes.value.flatMap((node) => {
|
||||
if (!node.widgets) return []
|
||||
const items: RefreshableItem[] = []
|
||||
for (const widget of node.widgets) {
|
||||
if (isRefreshableWidget(widget)) {
|
||||
items.push(widget)
|
||||
}
|
||||
}
|
||||
return items
|
||||
})
|
||||
const refreshableWidgets = computed(() =>
|
||||
selectedNodes.value.flatMap(
|
||||
(node) => node.widgets?.filter(isRefreshableWidget) ?? []
|
||||
)
|
||||
)
|
||||
|
||||
const isRefreshable = computed(() => refreshableWidgets.value.length > 0)
|
||||
|
||||