diff --git a/.claude/commands/comprehensive-pr-review.md b/.claude/commands/comprehensive-pr-review.md index a3310127c..84708564e 100644 --- a/.claude/commands/comprehensive-pr-review.md +++ b/.claude/commands/comprehensive-pr-review.md @@ -1,479 +1,275 @@ # Comprehensive PR Review for ComfyUI Frontend - -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. +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. -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 - +## CRITICAL INSTRUCTIONS -Arguments: PR number passed via PR_NUMBER environment variable +**You MUST post individual inline comments on specific lines of code. DO NOT create a single summary comment until the very end.** -## Phase 0: Initialize Variables and Helper Functions +**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.** -```bash -# Validate PR_NUMBER first thing -if [ -z "$PR_NUMBER" ]; then - echo "Error: PR_NUMBER environment variable is not set" - echo "Usage: PR_NUMBER= claude run /comprehensive-pr-review" - exit 1 -fi +To post inline comments, you will use the GitHub API via the `gh` command. Here's how: -# 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 +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 -# 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" -} -``` +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"` ## Phase 1: Environment Setup and PR Context -```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" -} +### Step 1.1: Initialize Review Tracking -# Run pre-flight checks -check_prerequisites +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 -echo "Starting comprehensive review of PR #$PR_NUMBER" +### Step 1.2: Validate Environment -# 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 +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. -# Extract branch names -BASE_BRANCH=$(jq -r '.baseRefName' < pr_info.json) -HEAD_BRANCH=$(jq -r '.headRefName' < pr_info.json) +### Step 1.3: Checkout PR Branch Locally -# 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" +This is critical for better file inspection: -# Get changed files using git locally (much faster) -git diff --name-only "origin/$BASE_BRANCH" > changed_files.txt +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 the diff using git locally -git diff "origin/$BASE_BRANCH" > pr_diff.txt +### Step 1.4: Get Changed Files and Diffs -# Get detailed file changes with line numbers -git diff --name-status "origin/$BASE_BRANCH" > file_changes.txt +Use git locally for much faster analysis: -# 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 +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` -# Setup caching directory -CACHE_DIR=".claude-review-cache" -mkdir -p "$CACHE_DIR" +### Step 1.5: Create Analysis Cache -# 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 -} +Set up caching to avoid re-analyzing unchanged files: -# 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 -``` +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 ## Phase 2: Load Comprehensive Knowledge Base -```bash -# Don't create knowledge directory until we know we need it -KNOWLEDGE_FOUND=false +### Step 2.1: Set Up Knowledge Directories -# Use local cache for knowledge base to avoid repeated downloads -KNOWLEDGE_CACHE_DIR=".claude-knowledge-cache" -mkdir -p "$KNOWLEDGE_CACHE_DIR" +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. -# 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 +### Step 2.2: Load Repository Guide -# 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 -} +This is critical for understanding the architecture: -# 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 +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 -# 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 +### Step 2.3: Load Relevant Knowledge Folders -# 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 +Intelligently load only relevant knowledge: -# 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 -``` +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` ## Phase 3: Deep Analysis Instructions -Perform a comprehensive analysis covering these areas: +Perform comprehensive analysis on each changed file: ### 3.1 Architectural Analysis -Based on the repository guide and project summary, evaluate: -- Does this change align with the established architecture patterns? + +Based on the repository guide and loaded knowledge: +- Does this change align with 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 that aren't caught by simple duplication checks +- DRY violations not caught by simple duplication checks - Proper abstraction levels - Interface design and API clarity -- No leftover debug code (console.log, commented code, TODO comments) +- Leftover debug code (console.log, commented code, TODO comments) ### 3.3 Library Usage Enforcement -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 + +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 ### 3.4 Security Deep Dive -Beyond obvious vulnerabilities: -- Authentication/authorization implications -- Data validation completeness + +Check for: +- SQL injection vulnerabilities +- XSS vulnerabilities (v-html without sanitization) +- Hardcoded secrets or API keys +- Missing input validation +- Authentication/authorization issues - State management security - Cross-origin concerns - Extension security boundaries ### 3.5 Performance Analysis -- Render performance implications -- Layout thrashing prevention -- Memory leak potential + +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 - Network request optimization -- State management efficiency ### 3.6 Integration Concerns + +Consider: - Breaking changes to internal APIs - Extension compatibility - Backward compatibility - Migration requirements -## Phase 4: Create Detailed Review Comments +## Phase 4: Posting Inline Comments -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 +### Step 4.1: Comment Format -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 +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: ```bash -# 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" -} +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" ``` +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, but focus on the changed lines: +Apply ALL validation rules from the loaded knowledge files: -### From Frontend Standards +### Frontend Standards - Vue 3 Composition API patterns - Component communication patterns - Proper use of composables - TypeScript strict mode compliance - Bundle optimization -### From Security Audit +### Security Audit - Input validation - XSS prevention - CSRF protection - Secure state management - API security -### From Performance Check +### Performance Check - Render optimization - Memory management - Network efficiency @@ -481,63 +277,51 @@ Apply ALL validation rules from the loaded knowledge, but focus on the changed l ## Phase 6: Contextual Review Based on PR Type -Analyze the PR description and changes to determine the type: +Analyze the PR to determine its type: -```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 -``` +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 ## Phase 7: Generate Comprehensive Summary -After all inline comments, create a detailed summary: +After ALL inline comments are posted, create a summary: -```bash -# Initialize metrics tracking -REVIEW_START_TIME=$(date +%s) +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 -# Create the comprehensive summary -gh pr review $PR_NUMBER --comment --body "# Comprehensive PR Review +Include in the summary: +``` +# 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**: $ADDITIONS additions, $DELETIONS deletions across $FILE_COUNT files +**PR**: [PR TITLE] (#$PR_NUMBER) +**Impact**: [X] additions, [Y] deletions across [Z] 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 @@ -568,141 +352,27 @@ 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: Think Deeply +## Important Guidelines -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 +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 This is a COMPREHENSIVE review, not a linting pass. Provide the same quality feedback a senior engineer would give after careful consideration. -## Phase 8: Track Review Metrics +## Execution Order -After completing the review, save metrics for analysis: +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 -```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. \ No newline at end of file +Remember: Individual inline comments for each issue, then one final summary. Never batch issues into a single comment. \ No newline at end of file diff --git a/.claude/commands/create-frontend-release.md b/.claude/commands/create-frontend-release.md index 0e0b0036d..91afe7de3 100644 --- a/.claude/commands/create-frontend-release.md +++ b/.claude/commands/create-frontend-release.md @@ -137,8 +137,7 @@ echo "Last stable release: $LAST_STABLE" 1. Run complete test suite: ```bash npm run test:unit - npm run test:component - npm run test:browser + npm run test:component ``` 2. Run type checking: ```bash @@ -170,7 +169,79 @@ echo "Last stable release: $LAST_STABLE" 3. Generate breaking change summary 4. **COMPATIBILITY REVIEW**: Breaking changes documented and justified? -### Step 7: Generate and Save Changelog +### Step 7: Analyze Dependency Updates + +1. **Check for dependency version changes:** + ```bash + # Compare package.json between versions to detect dependency updates + PREV_PACKAGE_JSON=$(git show ${BASE_TAG}:package.json 2>/dev/null || echo '{}') + CURRENT_PACKAGE_JSON=$(cat package.json) + + # Extract litegraph versions + PREV_LITEGRAPH=$(echo "$PREV_PACKAGE_JSON" | grep -o '"@comfyorg/litegraph": "[^"]*"' | grep -o '[0-9][^"]*' || echo "not found") + CURRENT_LITEGRAPH=$(echo "$CURRENT_PACKAGE_JSON" | grep -o '"@comfyorg/litegraph": "[^"]*"' | grep -o '[0-9][^"]*' || echo "not found") + + echo "Litegraph version change: ${PREV_LITEGRAPH} โ†’ ${CURRENT_LITEGRAPH}" + ``` + +2. **Generate litegraph changelog if version changed:** + ```bash + if [ "$PREV_LITEGRAPH" != "$CURRENT_LITEGRAPH" ] && [ "$PREV_LITEGRAPH" != "not found" ]; then + echo "๐Ÿ“ฆ Fetching litegraph changes between v${PREV_LITEGRAPH} and v${CURRENT_LITEGRAPH}..." + + # Clone or update litegraph repo for changelog analysis + if [ ! -d ".temp-litegraph" ]; then + git clone https://github.com/comfyanonymous/litegraph.js.git .temp-litegraph + else + cd .temp-litegraph && git fetch --all && cd .. + fi + + # Get litegraph changelog between versions + LITEGRAPH_CHANGES=$(cd .temp-litegraph && git log v${PREV_LITEGRAPH}..v${CURRENT_LITEGRAPH} --oneline --no-merges 2>/dev/null || \ + git log --oneline --no-merges --since="$(git log -1 --format=%ci ${BASE_TAG})" --until="$(git log -1 --format=%ci HEAD)" 2>/dev/null || \ + echo "Unable to fetch litegraph changes") + + # Categorize litegraph changes + LITEGRAPH_FEATURES=$(echo "$LITEGRAPH_CHANGES" | grep -iE "(feat|feature|add)" || echo "") + LITEGRAPH_FIXES=$(echo "$LITEGRAPH_CHANGES" | grep -iE "(fix|bug)" || echo "") + LITEGRAPH_BREAKING=$(echo "$LITEGRAPH_CHANGES" | grep -iE "(break|breaking)" || echo "") + LITEGRAPH_OTHER=$(echo "$LITEGRAPH_CHANGES" | grep -viE "(feat|feature|add|fix|bug|break|breaking)" || echo "") + + # Clean up temp directory + rm -rf .temp-litegraph + + echo "โœ… Litegraph changelog extracted" + else + echo "โ„น๏ธ No litegraph version change detected" + LITEGRAPH_CHANGES="" + fi + ``` + +3. **Check other significant dependency updates:** + ```bash + # Extract all dependency changes for major version bumps + OTHER_DEP_CHANGES="" + + # Compare major dependency versions (you can extend this list) + MAJOR_DEPS=("vue" "vite" "@vitejs/plugin-vue" "typescript" "pinia") + + for dep in "${MAJOR_DEPS[@]}"; do + PREV_VER=$(echo "$PREV_PACKAGE_JSON" | grep -o "\"$dep\": \"[^\"]*\"" | grep -o '[0-9][^"]*' | head -1 || echo "") + CURR_VER=$(echo "$CURRENT_PACKAGE_JSON" | grep -o "\"$dep\": \"[^\"]*\"" | grep -o '[0-9][^"]*' | head -1 || echo "") + + if [ "$PREV_VER" != "$CURR_VER" ] && [ -n "$PREV_VER" ] && [ -n "$CURR_VER" ]; then + # Check if it's a major version change + PREV_MAJOR=$(echo "$PREV_VER" | cut -d. -f1 | sed 's/[^0-9]//g') + CURR_MAJOR=$(echo "$CURR_VER" | cut -d. -f1 | sed 's/[^0-9]//g') + + if [ "$PREV_MAJOR" != "$CURR_MAJOR" ]; then + OTHER_DEP_CHANGES="${OTHER_DEP_CHANGES}\n- **${dep}**: ${PREV_VER} โ†’ ${CURR_VER} (Major version change)" + fi + fi + done + ``` + +### Step 8: Generate Comprehensive Release Notes 1. Extract commit messages since base release: ```bash @@ -185,42 +256,29 @@ echo "Last stable release: $LAST_STABLE" echo "WARNING: PR #$PR not on main branch!" done ``` -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) +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) + - **Litegraph Changes** (if version updated): + - ๐Ÿš€ Features: ${LITEGRAPH_FEATURES} + - ๐Ÿ› Bug Fixes: ${LITEGRAPH_FIXES} + - ๐Ÿ’ฅ Breaking Changes: ${LITEGRAPH_BREAKING} + - ๐Ÿ”ง Other Changes: ${LITEGRAPH_OTHER} + - **Other Major Dependencies**: ${OTHER_DEP_CHANGES} + - Include PR numbers and links + - Add issue references (Fixes #123) 4. **Save release notes:** ```bash - # Enhanced release notes for GitHub - echo "$RELEASE_NOTES" > github-release-notes-${NEW_VERSION}.md + # Save release notes for PR and GitHub release + echo "$RELEASE_NOTES" > release-notes-${NEW_VERSION}.md ``` -5. **CONTENT REVIEW**: Release notes clear and helpful for users? +5. **CONTENT REVIEW**: Release notes clear and comprehensive with dependency details? ### Step 9: Create Version Bump PR @@ -258,25 +316,20 @@ echo "Workflow triggered. Waiting for PR creation..." # For manual PRs gh pr create --title "${NEW_VERSION}" \ - --body-file enhanced-pr-description.md \ + --body-file release-notes-${NEW_VERSION}.md \ --label "Release" ``` -3. **Create enhanced PR description:** +3. **Add required sections to PR body:** ```bash - cat > enhanced-pr-description.md << EOF - # Release v${NEW_VERSION} - - ## Version Change - \`${CURRENT_VERSION}\` โ†’ \`${NEW_VERSION}\` (${VERSION_TYPE}) - - ## Changelog - ${CHANGELOG} + # Create PR body with release notes plus required sections + cat > pr-body.md << EOF + ${RELEASE_NOTES} ## Breaking Changes - ${BREAKING_CHANGES} + ${BREAKING_CHANGES:-None} ## Testing Performed - - โœ… Full test suite (unit, component, browser) + - โœ… Full test suite (unit, component) - โœ… TypeScript compilation - โœ… Linting checks - โœ… Build verification @@ -295,15 +348,11 @@ echo "Workflow triggered. Waiting for PR creation..." ``` 4. Update PR with enhanced description: ```bash - gh pr edit ${PR_NUMBER} --body-file enhanced-pr-description.md + gh pr edit ${PR_NUMBER} --body-file pr-body.md ``` -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? +5. **PR REVIEW**: Version bump PR created and enhanced correctly? -### Step 11: Critical Release PR Verification +### Step 10: Critical Release PR Verification 1. **CRITICAL**: Verify PR has "Release" label: ```bash @@ -325,7 +374,7 @@ echo "Workflow triggered. Waiting for PR creation..." ``` 7. **FINAL CODE REVIEW**: Release label present and no [skip ci]? -### Step 12: Pre-Merge Validation +### Step 11: Pre-Merge Validation 1. **Review Requirements**: Release PRs require approval 2. Monitor CI checks - watch for update-locales @@ -333,7 +382,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 13: Execute Release +### Step 12: Execute Release 1. **FINAL CONFIRMATION**: Merge PR to trigger release? 2. Merge the Release PR: @@ -358,7 +407,7 @@ echo "Workflow triggered. Waiting for PR creation..." gh run watch ${WORKFLOW_RUN_ID} ``` -### Step 14: Enhance GitHub Release +### Step 13: Enhance GitHub Release 1. Wait for automatic release creation: ```bash @@ -371,10 +420,10 @@ echo "Workflow triggered. Waiting for PR creation..." 2. **Enhance the GitHub release:** ```bash - # Update release with our enhanced notes + # Update release with our release notes gh release edit v${NEW_VERSION} \ --title "๐Ÿš€ ComfyUI Frontend v${NEW_VERSION}" \ - --notes-file github-release-notes-${NEW_VERSION}.md \ + --notes-file release-notes-${NEW_VERSION}.md \ --latest # Add any additional assets if needed @@ -386,7 +435,7 @@ echo "Workflow triggered. Waiting for PR creation..." gh release view v${NEW_VERSION} ``` -### Step 15: Verify Multi-Channel Distribution +### Step 14: Verify Multi-Channel Distribution 1. **GitHub Release:** ```bash @@ -424,7 +473,7 @@ echo "Workflow triggered. Waiting for PR creation..." 4. **DISTRIBUTION VERIFICATION**: All channels published successfully? -### Step 16: Post-Release Monitoring Setup +### Step 15: Post-Release Monitoring Setup 1. **Monitor immediate release health:** ```bash @@ -492,8 +541,7 @@ echo "Workflow triggered. Waiting for PR creation..." - Plan next release cycle ## Files Generated - - \`release-notes-${NEW_VERSION}-$(date +%Y%m%d).md\` - Detailed changelog - - \`github-release-notes-${NEW_VERSION}.md\` - GitHub release notes + - \`release-notes-${NEW_VERSION}.md\` - Comprehensive release notes - \`post-release-checklist.md\` - Follow-up tasks EOF ``` @@ -544,7 +592,7 @@ echo "- GitHub: Update release with warning notes" The command implements multiple quality gates: 1. **๐Ÿ”’ Security Gate**: Vulnerability scanning, secret detection -2. **๐Ÿงช Quality Gate**: Full test suite, linting, type checking +2. **๐Ÿงช Quality Gate**: Unit and component tests, linting, type checking 3. **๐Ÿ“‹ Content Gate**: Changelog accuracy, release notes quality 4. **๐Ÿ”„ Process Gate**: Release timing verification 5. **โœ… Verification Gate**: Multi-channel publishing confirmation @@ -602,6 +650,15 @@ The command implements multiple quality gates: gh pr view ${PR_NUMBER} --json baseRefName ``` +### Issue: Incomplete Dependency Changelog +**Problem**: Litegraph or other dependency updates only show version bump, not actual changes +**Solution**: The command now automatically: +- Detects litegraph version changes between releases +- Clones the litegraph repository temporarily +- Extracts and categorizes changes between versions +- Includes detailed litegraph changelog in release notes +- Cleans up temporary files after analysis + ### Issue: Release Failed Due to [skip ci] **Problem**: Release workflow didn't trigger after merge **Prevention**: Always avoid this scenario @@ -622,4 +679,6 @@ Benefits: Cleaner than creating extra version numbers 2. **Workflow Speed**: Version bump workflow typically completes in ~20-30 seconds 3. **Update-locales Behavior**: Inconsistent - sometimes adds [skip ci], sometimes doesn't 4. **Recovery Options**: Reverting version is cleaner than creating extra versions +5. **Dependency Tracking**: Command now automatically includes litegraph and major dependency changes in changelogs +6. **Litegraph Integration**: Temporary cloning of litegraph repo provides detailed change analysis between versions diff --git a/.gitattributes b/.gitattributes index 37d931349..af4b6adbc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,7 @@ *.ts text eol=lf *.vue text eol=lf *.js text eol=lf + +# Generated files +src/types/comfyRegistryTypes.ts linguist-generated=true +src/types/generatedManagerTypes.ts linguist-generated=true diff --git a/.github/workflows/claude-pr-review.yml b/.github/workflows/claude-pr-review.yml index 1b7f86934..0aae2518d 100644 --- a/.github/workflows/claude-pr-review.yml +++ b/.github/workflows/claude-pr-review.yml @@ -4,6 +4,8 @@ permissions: contents: read pull-requests: write issues: write + id-token: write + statuses: write on: pull_request: @@ -20,7 +22,7 @@ jobs: uses: lewagon/wait-on-check-action@v1.3.1 with: ref: ${{ github.event.pull_request.head.sha }} - check-regexp: '^(ESLint|Prettier Check|Tests CI|Vitest Tests)' + check-regexp: '^(eslint|prettier|test|playwright-tests)' wait-interval: 30 repo-token: ${{ secrets.GITHUB_TOKEN }} @@ -28,7 +30,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 Check|Tests CI|Vitest 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|test|playwright-tests")) | {name, conclusion}') # Check if any required checks failed if echo "$CHECK_RUNS" | grep -q '"conclusion": "failure"'; then @@ -63,10 +65,17 @@ jobs: - name: Run Claude PR Review uses: anthropics/claude-code-action@main with: - prompt_file: .claude/commands/comprehensive-pr-review.md + 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. anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - max_turns: 1 + max_turns: 256 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 }} diff --git a/.github/workflows/eslint.yaml b/.github/workflows/eslint.yaml index 72ed6b8fb..c3735ff5f 100644 --- a/.github/workflows/eslint.yaml +++ b/.github/workflows/eslint.yaml @@ -2,7 +2,7 @@ name: ESLint on: pull_request: - branches: [ main, master, dev*, core/*, desktop/* ] + branches-ignore: [ wip/*, draft/*, temp/* ] jobs: eslint: diff --git a/.github/workflows/format.yaml b/.github/workflows/format.yaml index 23eef7f55..5d71c3452 100644 --- a/.github/workflows/format.yaml +++ b/.github/workflows/format.yaml @@ -2,7 +2,7 @@ name: Prettier Check on: pull_request: - branches: [ main, master, dev*, core/*, desktop/* ] + branches-ignore: [ wip/*, draft/*, temp/* ] jobs: prettier: diff --git a/.github/workflows/test-ui.yaml b/.github/workflows/test-ui.yaml index 580bdee19..35b24d845 100644 --- a/.github/workflows/test-ui.yaml +++ b/.github/workflows/test-ui.yaml @@ -4,7 +4,7 @@ on: push: branches: [main, master, core/*, desktop/*] pull_request: - branches: [main, master, dev*, core/*, desktop/*] + branches-ignore: [wip/*, draft/*, temp/*] jobs: setup: diff --git a/.github/workflows/vitest.yaml b/.github/workflows/vitest.yaml index a2f47c342..788b6aba4 100644 --- a/.github/workflows/vitest.yaml +++ b/.github/workflows/vitest.yaml @@ -4,7 +4,7 @@ on: push: branches: [ main, master, dev*, core/*, desktop/* ] pull_request: - branches: [ main, master, dev*, core/*, desktop/* ] + branches-ignore: [ wip/*, draft/*, temp/* ] jobs: test: diff --git a/README.md b/README.md index d490ae628..89aa97687 100644 --- a/README.md +++ b/README.md @@ -686,6 +686,12 @@ Component test verifies Vue components in `src/components/`. Playwright test verifies the whole app. See for details. +### Custom Icons + +The project supports custom SVG icons through the unplugin-icons system. Custom icons are stored in `src/assets/icons/custom/` and can be used as Vue components with the `i-comfy:` prefix. + +For detailed instructions on adding and using custom icons, see [src/assets/icons/README.md](src/assets/icons/README.md). + ### litegraph.js This repo is using litegraph package hosted on . Any changes to litegraph should be submitted in that repo instead. diff --git a/browser_tests/README.md b/browser_tests/README.md index 1aeaa6e54..9c4d48f6b 100644 --- a/browser_tests/README.md +++ b/browser_tests/README.md @@ -2,76 +2,133 @@ This document outlines the setup, usage, and common patterns for Playwright browser tests in the ComfyUI_frontend project. -## WARNING +## Prerequisites -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. +**CRITICAL**: Start ComfyUI backend with `--multi-user` flag: + +```bash +python main.py --multi-user +``` + +Without this flag, parallel tests will conflict and fail randomly. ## Setup ### ComfyUI devtools + Clone 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 Variables -Ensure the environment variables in `.env` are set correctly according to your setup. +### Environment Configuration -The `.env` file will not exist until you create it yourself. +Create `.env` from the template: -A template with helpful information can be found in `.env_example`. +```bash +cp .env_example .env +``` -### 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. +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') +}) +``` ### 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 -There are multiple ways to run the tests: +**Always use UI mode for development:** -1. **Headless mode with report generation:** - ```bash - npx playwright test - ``` - This runs all tests without a visible browser and generates a comprehensive test report. +```bash +npx playwright test --ui +``` -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. +UI mode features: - ![Playwright UI Mode](https://github.com/user-attachments/assets/6a1ebef0-90eb-4157-8694-f5ee94d03755) +- **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 -3. **Running specific tests:** - ```bash - npx playwright test widget.spec.ts - ``` +![Playwright UI Mode](https://github.com/user-attachments/assets/c158c93f-b39a-44c5-a1a1-e0cc975ee9f2) + +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') + }, + +}) +``` ## 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 @@ -86,18 +143,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 @@ -119,66 +176,102 @@ 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 @@ -192,7 +285,7 @@ test('Can toggle boolean widget', async ({ comfyPage }) => { const node = (await comfyPage.getFirstNodeRef())! const widget = await node.getWidget(0) await widget.click() -}); +}) ``` ### Node References @@ -232,8 +325,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 @@ -275,21 +368,52 @@ 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 Expectations +## Screenshot Testing 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 -To set new test expectations for PR: +### Working with Screenshots Locally -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 +Option 1 - Skip screenshot tests (add to `playwright.config.ts`): -This approach ensures consistent screenshot expectations across all PRs and avoids issues with platform-specific rendering. +```typescript +export default defineConfig({ + grep: process.env.CI ? undefined : /^(?!.*screenshot).*$/ +}) +``` -> **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. \ No newline at end of file +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 diff --git a/browser_tests/assets/basic-subgraph.json b/browser_tests/assets/basic-subgraph.json new file mode 100644 index 000000000..98891ea55 --- /dev/null +++ b/browser_tests/assets/basic-subgraph.json @@ -0,0 +1,244 @@ +{ + "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 +} \ No newline at end of file diff --git a/browser_tests/assets/subgraph-with-multiple-promoted-widgets.json b/browser_tests/assets/subgraph-with-multiple-promoted-widgets.json new file mode 100644 index 000000000..d40b6f783 --- /dev/null +++ b/browser_tests/assets/subgraph-with-multiple-promoted-widgets.json @@ -0,0 +1,412 @@ +{ + "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 +} \ No newline at end of file diff --git a/browser_tests/assets/subgraph-with-promoted-text-widget.json b/browser_tests/assets/subgraph-with-promoted-text-widget.json new file mode 100644 index 000000000..025aec800 --- /dev/null +++ b/browser_tests/assets/subgraph-with-promoted-text-widget.json @@ -0,0 +1,341 @@ +{ + "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 +} \ No newline at end of file diff --git a/browser_tests/assets/subgraph-with-text-widget.json b/browser_tests/assets/subgraph-with-text-widget.json new file mode 100644 index 000000000..c23b5e683 --- /dev/null +++ b/browser_tests/assets/subgraph-with-text-widget.json @@ -0,0 +1,153 @@ +{ + "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 +} \ No newline at end of file diff --git a/browser_tests/assets/workflow.avif b/browser_tests/assets/workflow.avif new file mode 100644 index 000000000..8d8d77ced Binary files /dev/null and b/browser_tests/assets/workflow.avif differ diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index e619e4e75..72445087f 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -21,7 +21,7 @@ import { } from './components/SidebarTab' import { Topbar } from './components/Topbar' import type { Position, Size } from './types' -import { NodeReference } from './utils/litegraphUtils' +import { NodeReference, SubgraphSlotReference } 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.settingDialog = new SettingDialog(page, this) this.confirmDialog = new ConfirmDialog(page) } @@ -563,6 +563,7 @@ 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' } @@ -776,11 +777,524 @@ 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 { + 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 { + 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 { + 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 { + return new SubgraphSlotReference('input', slotName || '', this) + } + + /** + * Get a reference to a subgraph output slot + */ + async getSubgraphOutputSlot( + slotName?: string + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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((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 { + 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 { + 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 { + await this.page.evaluate(() => { + const overlay = document.getElementById('debug-canvas-overlay') + if (overlay) { + overlay.remove() + } + }) + } + async clickEmptyLatentNode() { await this.canvas.click({ position: { diff --git a/browser_tests/fixtures/components/SettingDialog.ts b/browser_tests/fixtures/components/SettingDialog.ts index 82d47e78e..afaf86154 100644 --- a/browser_tests/fixtures/components/SettingDialog.ts +++ b/browser_tests/fixtures/components/SettingDialog.ts @@ -1,15 +1,19 @@ import { Page } from '@playwright/test' +import { ComfyPage } from '../ComfyPage' + export class SettingDialog { - constructor(public readonly page: Page) {} + constructor( + public readonly page: Page, + public readonly comfyPage: ComfyPage + ) {} get root() { return this.page.locator('div.settings-container') } async open() { - const button = this.page.locator('button.comfy-settings-btn:visible') - await button.click() + await this.comfyPage.executeCommand('Comfy.ShowSettingsDialog') await this.page.waitForSelector('div.settings-container') } diff --git a/browser_tests/fixtures/components/Topbar.ts b/browser_tests/fixtures/components/Topbar.ts index c72177d32..81fcf6764 100644 --- a/browser_tests/fixtures/components/Topbar.ts +++ b/browser_tests/fixtures/components/Topbar.ts @@ -15,10 +15,6 @@ 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}")`) } @@ -68,31 +64,41 @@ 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 topLevelMenu = this.page.locator( - `.top-menubar .p-menubar-item-label:text-is("${tabName}")` + const topLevelMenuItem = this.page.locator( + `.p-menubar-item-label:text-is("${tabName}")` ) + const topLevelMenu = menu + .locator('.p-tieredmenu-item') + .filter({ has: topLevelMenuItem }) await topLevelMenu.waitFor({ state: 'visible' }) - await topLevelMenu.click() + await topLevelMenu.hover() + let currentMenu = topLevelMenu for (let i = 1; i < path.length; i++) { const commandName = path[i] - const menuItem = this.page + const menuItem = currentMenu .locator( - `.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")` + `.p-tieredmenu-submenu .p-tieredmenu-item:has-text("${commandName}")` ) .first() await menuItem.waitFor({ state: 'visible' }) await menuItem.hover() - - if (i === path.length - 1) { - await menuItem.click() - } + currentMenu = menuItem } + await currentMenu.click() } } diff --git a/browser_tests/fixtures/utils/litegraphUtils.ts b/browser_tests/fixtures/utils/litegraphUtils.ts index 94bfd059d..8a52d8b66 100644 --- a/browser_tests/fixtures/utils/litegraphUtils.ts +++ b/browser_tests/fixtures/utils/litegraphUtils.ts @@ -12,6 +12,128 @@ export const getMiddlePoint = (pos1: Position, pos2: Position) => { } } +export class SubgraphSlotReference { + constructor( + readonly type: 'input' | 'output', + readonly slotName: string, + readonly comfyPage: ComfyPage + ) {} + + async getPosition(): Promise { + 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 { + 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', @@ -21,11 +143,27 @@ export class NodeSlotReference { async getPosition() { const pos: [number, number] = await this.node.comfyPage.page.evaluate( ([type, id, index]) => { - const node = window['app'].graph.getNodeById(id) + // Use canvas.graph to get the current graph (works in both main graph and subgraphs) + const node = window['app'].canvas.graph.getNodeById(id) if (!node) throw new Error(`Node ${id} not found.`) - return window['app'].canvas.ds.convertOffsetToCanvas( - node.getConnectionPos(type === 'input', index) + + 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 convertedPos }, [this.type, this.node.id, this.index] as const ) @@ -37,7 +175,7 @@ export class NodeSlotReference { async getLinkCount() { return await this.node.comfyPage.page.evaluate( ([type, id, index]) => { - const node = window['app'].graph.getNodeById(id) + const node = window['app'].canvas.graph.getNodeById(id) if (!node) throw new Error(`Node ${id} not found.`) if (type === 'input') { return node.inputs[index].link == null ? 0 : 1 @@ -50,7 +188,7 @@ export class NodeSlotReference { async removeLinks() { await this.node.comfyPage.page.evaluate( ([type, id, index]) => { - const node = window['app'].graph.getNodeById(id) + const node = window['app'].canvas.graph.getNodeById(id) if (!node) throw new Error(`Node ${id} not found.`) if (type === 'input') { node.disconnectInput(index) @@ -75,7 +213,7 @@ export class NodeWidgetReference { async getPosition(): Promise { const pos: [number, number] = await this.node.comfyPage.page.evaluate( ([id, index]) => { - const node = window['app'].graph.getNodeById(id) + const node = window['app'].canvas.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.`) @@ -134,7 +272,7 @@ export class NodeWidgetReference { const pos = await this.getPosition() const canvas = this.node.comfyPage.canvas const canvasPos = (await canvas.boundingBox())! - this.node.comfyPage.dragAndDrop( + await this.node.comfyPage.dragAndDrop( { x: canvasPos.x + pos.x, y: canvasPos.y + pos.y @@ -166,7 +304,7 @@ export class NodeReference { ) {} async exists(): Promise { return await this.comfyPage.page.evaluate((id) => { - const node = window['app'].graph.getNodeById(id) + const node = window['app'].canvas.graph.getNodeById(id) return !!node }, this.id) } @@ -185,7 +323,7 @@ export class NodeReference { async getBounding(): Promise { const [x, y, width, height]: [number, number, number, number] = await this.comfyPage.page.evaluate((id) => { - const node = window['app'].graph.getNodeById(id) + const node = window['app'].canvas.graph.getNodeById(id) if (!node) throw new Error('Node not found') return node.getBounding() }, this.id) @@ -218,7 +356,7 @@ export class NodeReference { async getProperty(prop: string): Promise { return await this.comfyPage.page.evaluate( ([id, prop]) => { - const node = window['app'].graph.getNodeById(id) + const node = window['app'].canvas.graph.getNodeById(id) if (!node) throw new Error('Node not found') return node[prop] }, @@ -259,7 +397,8 @@ export class NodeReference { await this.comfyPage.canvas.click({ ...options, - position: clickPos + position: clickPos, + force: true }) await this.comfyPage.nextFrame() if (moveMouseToEmptyArea) { @@ -319,6 +458,18 @@ 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() @@ -327,4 +478,58 @@ 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' + ) + } + } } diff --git a/browser_tests/tests/domWidget.spec.ts b/browser_tests/tests/domWidget.spec.ts index 4ff305e1a..8119a71ad 100644 --- a/browser_tests/tests/domWidget.spec.ts +++ b/browser_tests/tests/domWidget.spec.ts @@ -47,4 +47,42 @@ 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() + }) }) diff --git a/browser_tests/tests/graphCanvasMenu.spec.ts-snapshots/canvas-with-hidden-links-chromium-linux.png b/browser_tests/tests/graphCanvasMenu.spec.ts-snapshots/canvas-with-hidden-links-chromium-linux.png index c1411d5f9..2b1678631 100644 Binary files a/browser_tests/tests/graphCanvasMenu.spec.ts-snapshots/canvas-with-hidden-links-chromium-linux.png and b/browser_tests/tests/graphCanvasMenu.spec.ts-snapshots/canvas-with-hidden-links-chromium-linux.png differ diff --git a/browser_tests/tests/graphCanvasMenu.spec.ts-snapshots/canvas-with-visible-links-chromium-linux.png b/browser_tests/tests/graphCanvasMenu.spec.ts-snapshots/canvas-with-visible-links-chromium-linux.png index 46b645fc1..fb46d991f 100644 Binary files a/browser_tests/tests/graphCanvasMenu.spec.ts-snapshots/canvas-with-visible-links-chromium-linux.png and b/browser_tests/tests/graphCanvasMenu.spec.ts-snapshots/canvas-with-visible-links-chromium-linux.png differ diff --git a/browser_tests/tests/groupNode.spec.ts b/browser_tests/tests/groupNode.spec.ts index 0e4d7c38a..ae1ee505f 100644 --- a/browser_tests/tests/groupNode.spec.ts +++ b/browser_tests/tests/groupNode.spec.ts @@ -264,10 +264,15 @@ test.describe('Group Node', () => { test('Copies and pastes group node after clearing workflow', async ({ comfyPage }) => { + // Set setting + await comfyPage.setSetting('Comfy.ConfirmClear', false) + + // Clear workflow await comfyPage.menu.topbar.triggerTopbarCommand([ 'Edit', 'Clear Workflow' ]) + await comfyPage.ctrlV() await verifyNodeLoaded(comfyPage, 1) }) diff --git a/browser_tests/tests/interaction.spec.ts b/browser_tests/tests/interaction.spec.ts index fc1b5e8e1..94c2f1632 100644 --- a/browser_tests/tests/interaction.spec.ts +++ b/browser_tests/tests/interaction.spec.ts @@ -768,8 +768,14 @@ 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 expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png') + await comfyPage.nextFrame() + const screenshotA = (await comfyPage.canvas.screenshot()).toString('base64') // Save workflow as a new file, then zoom out before screen shot await comfyPage.menu.topbar.saveWorkflowAs('Workflow B') @@ -777,7 +783,12 @@ test.describe('Viewport settings', () => { for (let i = 0; i < 4; i++) { await comfyMouse.wheel(0, 60) } - await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png') + + 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) const tabA = comfyPage.menu.topbar.getWorkflowTab('Workflow A') const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B') @@ -785,11 +796,269 @@ test.describe('Viewport settings', () => { // Go back to Workflow A await tabA.click() await comfyPage.nextFrame() - await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png') + expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe( + screenshotA + ) // And back to Workflow B await tabB.click() await comfyPage.nextFrame() - await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png') + 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() + }) }) }) diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/legacy-alt-shift-drag-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/legacy-alt-shift-drag-chromium-linux.png new file mode 100644 index 000000000..36cd31910 Binary files /dev/null and b/browser_tests/tests/interaction.spec.ts-snapshots/legacy-alt-shift-drag-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/legacy-click-node-select-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/legacy-click-node-select-chromium-linux.png new file mode 100644 index 000000000..0be4a3565 Binary files /dev/null and b/browser_tests/tests/interaction.spec.ts-snapshots/legacy-click-node-select-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/legacy-left-drag-pan-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/legacy-left-drag-pan-chromium-linux.png new file mode 100644 index 000000000..36cd31910 Binary files /dev/null and b/browser_tests/tests/interaction.spec.ts-snapshots/legacy-left-drag-pan-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/legacy-middle-drag-pan-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/legacy-middle-drag-pan-chromium-linux.png new file mode 100644 index 000000000..36cd31910 Binary files /dev/null and b/browser_tests/tests/interaction.spec.ts-snapshots/legacy-middle-drag-pan-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/legacy-wheel-zoom-in-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/legacy-wheel-zoom-in-chromium-linux.png new file mode 100644 index 000000000..5023002f4 Binary files /dev/null and b/browser_tests/tests/interaction.spec.ts-snapshots/legacy-wheel-zoom-in-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/legacy-wheel-zoom-out-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/legacy-wheel-zoom-out-chromium-linux.png new file mode 100644 index 000000000..8017b8f49 Binary files /dev/null and b/browser_tests/tests/interaction.spec.ts-snapshots/legacy-wheel-zoom-out-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/standard-click-node-select-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/standard-click-node-select-chromium-linux.png new file mode 100644 index 000000000..0be4a3565 Binary files /dev/null and b/browser_tests/tests/interaction.spec.ts-snapshots/standard-click-node-select-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/standard-ctrl-wheel-zoom-in-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/standard-ctrl-wheel-zoom-in-chromium-linux.png new file mode 100644 index 000000000..5023002f4 Binary files /dev/null and b/browser_tests/tests/interaction.spec.ts-snapshots/standard-ctrl-wheel-zoom-in-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/standard-ctrl-wheel-zoom-out-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/standard-ctrl-wheel-zoom-out-chromium-linux.png new file mode 100644 index 000000000..8017b8f49 Binary files /dev/null and b/browser_tests/tests/interaction.spec.ts-snapshots/standard-ctrl-wheel-zoom-out-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/standard-initial-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/standard-initial-chromium-linux.png new file mode 100644 index 000000000..c09736577 Binary files /dev/null and b/browser_tests/tests/interaction.spec.ts-snapshots/standard-initial-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/standard-left-drag-select-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/standard-left-drag-select-chromium-linux.png new file mode 100644 index 000000000..dfccbf641 Binary files /dev/null and b/browser_tests/tests/interaction.spec.ts-snapshots/standard-left-drag-select-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/standard-middle-drag-pan-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/standard-middle-drag-pan-chromium-linux.png new file mode 100644 index 000000000..36cd31910 Binary files /dev/null and b/browser_tests/tests/interaction.spec.ts-snapshots/standard-middle-drag-pan-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-center-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-center-chromium-linux.png new file mode 100644 index 000000000..c9d79512f Binary files /dev/null and b/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-center-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-left-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-left-chromium-linux.png new file mode 100644 index 000000000..c09736577 Binary files /dev/null and b/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-left-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-right-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-right-chromium-linux.png new file mode 100644 index 000000000..c9d79512f Binary files /dev/null and b/browser_tests/tests/interaction.spec.ts-snapshots/standard-shift-wheel-pan-right-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/standard-space-drag-pan-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/standard-space-drag-pan-chromium-linux.png new file mode 100644 index 000000000..d9d922bd3 Binary files /dev/null and b/browser_tests/tests/interaction.spec.ts-snapshots/standard-space-drag-pan-chromium-linux.png differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/viewport-workflow-a-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/viewport-workflow-a-chromium-linux.png deleted file mode 100644 index be9f8d341..000000000 Binary files a/browser_tests/tests/interaction.spec.ts-snapshots/viewport-workflow-a-chromium-linux.png and /dev/null differ diff --git a/browser_tests/tests/interaction.spec.ts-snapshots/viewport-workflow-b-chromium-linux.png b/browser_tests/tests/interaction.spec.ts-snapshots/viewport-workflow-b-chromium-linux.png deleted file mode 100644 index 1d45b9a5d..000000000 Binary files a/browser_tests/tests/interaction.spec.ts-snapshots/viewport-workflow-b-chromium-linux.png and /dev/null differ diff --git a/browser_tests/tests/loadWorkflowInMedia.spec.ts b/browser_tests/tests/loadWorkflowInMedia.spec.ts index 215f977e7..1250d3385 100644 --- a/browser_tests/tests/loadWorkflowInMedia.spec.ts +++ b/browser_tests/tests/loadWorkflowInMedia.spec.ts @@ -15,7 +15,8 @@ test.describe('Load Workflow in Media', () => { 'workflow.mp4', 'workflow.mov', 'workflow.m4v', - 'workflow.svg' + 'workflow.svg', + 'workflow.avif' ] fileNames.forEach(async (fileName) => { test(`Load workflow in ${fileName} (drop from filesystem)`, async ({ diff --git a/browser_tests/tests/loadWorkflowInMedia.spec.ts-snapshots/workflow-avif-chromium-linux.png b/browser_tests/tests/loadWorkflowInMedia.spec.ts-snapshots/workflow-avif-chromium-linux.png new file mode 100644 index 000000000..0dbcabeda Binary files /dev/null and b/browser_tests/tests/loadWorkflowInMedia.spec.ts-snapshots/workflow-avif-chromium-linux.png differ diff --git a/browser_tests/tests/menu.spec.ts b/browser_tests/tests/menu.spec.ts index 7c999f879..a771257f2 100644 --- a/browser_tests/tests/menu.spec.ts +++ b/browser_tests/tests/menu.spec.ts @@ -63,7 +63,7 @@ test.describe('Menu', () => { test('@mobile Items fully visible on mobile screen width', async ({ comfyPage }) => { - await comfyPage.menu.topbar.openSubmenuMobile() + await comfyPage.menu.topbar.openTopbarMenu() const topLevelMenuItem = comfyPage.page .locator('a.p-menubar-item-link') .first() @@ -74,8 +74,9 @@ 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.click() + await workflowMenuItem.hover() const exportTag = comfyPage.page.locator('.keybinding-tag', { hasText: 'Ctrl + s' }) diff --git a/browser_tests/tests/minimap.spec.ts b/browser_tests/tests/minimap.spec.ts new file mode 100644 index 000000000..9275107cb --- /dev/null +++ b/browser_tests/tests/minimap.spec.ts @@ -0,0 +1,77 @@ +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() + }) +}) diff --git a/browser_tests/tests/nodeSearchBox.spec.ts b/browser_tests/tests/nodeSearchBox.spec.ts index ddb67d84e..f455c926a 100644 --- a/browser_tests/tests/nodeSearchBox.spec.ts +++ b/browser_tests/tests/nodeSearchBox.spec.ts @@ -27,6 +27,21 @@ 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) @@ -172,10 +187,10 @@ test.describe('Node search box', () => { await comfyPage.page.mouse.click(panelBounds!.x - 10, panelBounds!.y - 10) // Verify the filter selection panel is hidden - expect(panel.header).not.toBeVisible() + await expect(panel.header).not.toBeVisible() // Verify the node search dialog is still visible - expect(comfyPage.searchBox.input).toBeVisible() + await expect(comfyPage.searchBox.input).toBeVisible() }) test('Can add multiple filters', async ({ comfyPage }) => { @@ -264,4 +279,38 @@ 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() + }) }) diff --git a/browser_tests/tests/subgraph.spec.ts b/browser_tests/tests/subgraph.spec.ts new file mode 100644 index 000000000..16321e229 --- /dev/null +++ b/browser_tests/tests/subgraph.spec.ts @@ -0,0 +1,469 @@ +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 { + 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 { + 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 { + 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) + }) + }) +}) diff --git a/browser_tests/tests/subgraphBreadcrumb.spec.ts b/browser_tests/tests/subgraphBreadcrumb.spec.ts deleted file mode 100644 index d0bcb3360..000000000 --- a/browser_tests/tests/subgraphBreadcrumb.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -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) - }) -}) diff --git a/browser_tests/tests/versionMismatchWarnings.spec.ts b/browser_tests/tests/versionMismatchWarnings.spec.ts new file mode 100644 index 000000000..d85f18723 --- /dev/null +++ b/browser_tests/tests/versionMismatchWarnings.spec.ts @@ -0,0 +1,117 @@ +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() + }) +}) diff --git a/package-lock.json b/package-lock.json index 389b6e742..c62be4f1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "@comfyorg/comfyui-frontend", - "version": "1.24.1", + "version": "1.25.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@comfyorg/comfyui-frontend", - "version": "1.24.1", + "version": "1.25.2", "license": "GPL-3.0-only", "dependencies": { "@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.13", + "@comfyorg/litegraph": "^0.16.20", "@primevue/forms": "^4.2.5", "@primevue/themes": "^4.2.5", "@sentry/vue": "^8.48.0", @@ -31,6 +31,8 @@ "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", @@ -86,8 +88,8 @@ "tsx": "^4.15.6", "typescript": "^5.4.5", "typescript-eslint": "^8.0.0", - "unplugin-icons": "^0.19.3", - "unplugin-vue-components": "^0.27.4", + "unplugin-icons": "^0.22.0", + "unplugin-vue-components": "^0.28.0", "vite": "^5.4.19", "vite-plugin-dts": "^4.3.0", "vite-plugin-html": "^3.2.2", @@ -439,13 +441,13 @@ } }, "node_modules/@antfu/install-pkg": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-0.4.1.tgz", - "integrity": "sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-0.5.0.tgz", + "integrity": "sha512-dKnk2xlAyC7rvTkpkHmu+Qy/2Zc3Vm/l8PtNyIOGDBtXPY3kThfU4ORNEp3V7SXw5XSOb+tOJaUYpfquPzL/Tg==", "dev": true, "dependencies": { - "package-manager-detector": "^0.2.0", - "tinyexec": "^0.3.0" + "package-manager-detector": "^0.2.5", + "tinyexec": "^0.3.1" }, "funding": { "url": "https://github.com/sponsors/antfu" @@ -909,13 +911,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", - "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } @@ -981,9 +980,10 @@ "license": "GPL-3.0-only" }, "node_modules/@comfyorg/litegraph": { - "version": "0.16.13", - "resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.13.tgz", - "integrity": "sha512-Y65YDyX/X/gqxtEa3IS5jb/1UUETCZXnrxM7YwSTIdzq3SHqwnDNstVBvhe+MtQUYRYqURzlg6sSrCO/BoKwkQ==" + "version": "0.16.20", + "resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.20.tgz", + "integrity": "sha512-8iUBhKYkr9qV6vWxC3C9Wea9K7iHwyDHxxN6OrhE9sySYfUA14XuNpVMaC8eVUaIm5KBOSmr/Q1J2XVHsHEISg==", + "license": "MIT" }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -2332,20 +2332,112 @@ "dev": true }, "node_modules/@iconify/utils": { - "version": "2.1.32", - "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-2.1.32.tgz", - "integrity": "sha512-LeifFZPPKu28O3AEDpYJNdEbvS4/ojAPyIW+pF/vUpJTYnbTiXUHkCh0bwgFRzKvdpb8H4Fbfd/742++MF4fPQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-2.3.0.tgz", + "integrity": "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==", "dev": true, "dependencies": { - "@antfu/install-pkg": "^0.4.0", - "@antfu/utils": "^0.7.10", + "@antfu/install-pkg": "^1.0.0", + "@antfu/utils": "^8.1.0", "@iconify/types": "^2.0.0", - "debug": "^4.3.6", + "debug": "^4.4.0", + "globals": "^15.14.0", "kolorist": "^1.8.0", - "local-pkg": "^0.5.0", - "mlly": "^1.7.1" + "local-pkg": "^1.0.0", + "mlly": "^1.7.4" } }, + "node_modules/@iconify/utils/node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "dev": true, + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@iconify/utils/node_modules/@antfu/utils": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-8.1.1.tgz", + "integrity": "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@iconify/utils/node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "dev": true + }, + "node_modules/@iconify/utils/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@iconify/utils/node_modules/local-pkg": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.1.tgz", + "integrity": "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==", + "dev": true, + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.0.1", + "quansync": "^0.2.8" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@iconify/utils/node_modules/package-manager-detector": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz", + "integrity": "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==", + "dev": true + }, + "node_modules/@iconify/utils/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/@iconify/utils/node_modules/pkg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz", + "integrity": "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==", + "dev": true, + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/@iconify/utils/node_modules/tinyexec": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", + "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", + "dev": true + }, "node_modules/@inkjs/ui": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@inkjs/ui/-/ui-1.0.0.tgz", @@ -2453,18 +2545,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/synckit": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.3.tgz", @@ -5749,6 +5829,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/automation-events": { + "version": "7.1.11", + "resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.11.tgz", + "integrity": "sha512-TnclbJ0482ydRenzrR9FIbqalHScBBdQTIXv8tVunhYx8dq7E0Eq5v5CSAo67YmLXNbx5jCstHcLZDJ33iONDw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.19", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", @@ -5999,6 +6092,18 @@ "node": ">=8" } }, + "node_modules/broker-factory": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.7.tgz", + "integrity": "sha512-RxbMXWq/Qvw9aLZMvuooMtVTm2/SV9JEpxpBbMuFhYAnDaZxctbJ+1b9ucHxADk/eQNqDijvWQjLVARqExAeyg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "fast-unique-numbers": "^9.0.22", + "tslib": "^2.8.1", + "worker-factory": "^7.0.43" + } + }, "node_modules/browserslist": { "version": "4.24.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", @@ -6563,9 +6668,9 @@ "license": "MIT" }, "node_modules/confbox": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz", - "integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "dev": true }, "node_modules/config-chain": { @@ -7740,18 +7845,6 @@ "eslint": ">=6.0.0" } }, - "node_modules/eslint-compat-utils/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-config-prettier": { "version": "10.1.2", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.2.tgz", @@ -8296,6 +8389,12 @@ "node": ">= 0.6" } }, + "node_modules/exsolve": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "dev": true + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -8316,6 +8415,56 @@ "node": ">=0.10.0" } }, + "node_modules/extendable-media-recorder": { + "version": "9.2.27", + "resolved": "https://registry.npmjs.org/extendable-media-recorder/-/extendable-media-recorder-9.2.27.tgz", + "integrity": "sha512-2X+Ixi1cxLek0Cj9x9atmhQ+apG+LwJpP2p3ypP8Pxau0poDnicrg7FTfPVQV5PW/3DHFm/eQ16vbgo5Yk3HGQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "media-encoder-host": "^9.0.20", + "multi-buffer-data-view": "^6.0.22", + "recorder-audio-worklet": "^6.0.48", + "standardized-audio-context": "^25.3.77", + "subscribable-things": "^2.1.53", + "tslib": "^2.8.1" + } + }, + "node_modules/extendable-media-recorder-wav-encoder": { + "version": "7.0.129", + "resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder/-/extendable-media-recorder-wav-encoder-7.0.129.tgz", + "integrity": "sha512-/wqM2hnzvLy/iUlg/EU3JIF8MJcidy8I77Z7CCm5+CVEClDfcs6bH9PgghuisndwKTaud0Dh48RTD83gkfEjCw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "extendable-media-recorder-wav-encoder-broker": "^7.0.119", + "extendable-media-recorder-wav-encoder-worker": "^8.0.116", + "tslib": "^2.8.1" + } + }, + "node_modules/extendable-media-recorder-wav-encoder-broker": { + "version": "7.0.119", + "resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder-broker/-/extendable-media-recorder-wav-encoder-broker-7.0.119.tgz", + "integrity": "sha512-BLrFOnqFLpsmmNpSk/TfjNs4j6ImCSGtoryIpRlqNu5S/Avt6gRJI0s4UYvdK7h17PCi+8vaDr75blvmU1sYlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "broker-factory": "^3.1.7", + "extendable-media-recorder-wav-encoder-worker": "^8.0.116", + "tslib": "^2.8.1" + } + }, + "node_modules/extendable-media-recorder-wav-encoder-worker": { + "version": "8.0.116", + "resolved": "https://registry.npmjs.org/extendable-media-recorder-wav-encoder-worker/-/extendable-media-recorder-wav-encoder-worker-8.0.116.tgz", + "integrity": "sha512-bJPR0B7ZHeoqi9YoSie+UXAfEYya3efQ9eLiWuyK4KcOv+SuYQvWCoyzX5kjvb6GqIBCUnev5xulfeHRlyCwvw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "tslib": "^2.8.1", + "worker-factory": "^7.0.43" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8359,6 +8508,19 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-unique-numbers": { + "version": "9.0.22", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.22.tgz", + "integrity": "sha512-dBR+30yHAqBGvOuxxQdnn2lTLHCO6r/9B+M4yF8mNrzr3u1yiF+YVJ6u3GTyPN/VRWqaE1FcscZDdBgVKmrmQQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, "node_modules/fast-uri": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", @@ -8968,11 +9130,10 @@ } }, "node_modules/globals": { - "version": "15.9.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.9.0.tgz", - "integrity": "sha512-SmSKyLLKFbSr6rptvP8izbyxJL4ILwqO9Jg23UA0sDlGlu58V59D1//I3vlc0KJphVdUR7vMjHIplYnzBxorQA==", + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -10270,18 +10431,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/jsonc-eslint-parser/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jsondiffpatch": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz", @@ -10836,13 +10985,13 @@ } }, "node_modules/local-pkg": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", - "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==", + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", "dev": true, "dependencies": { - "mlly": "^1.4.2", - "pkg-types": "^1.0.3" + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" }, "engines": { "node": ">=14" @@ -11381,6 +11530,43 @@ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "license": "MIT" }, + "node_modules/media-encoder-host": { + "version": "9.0.20", + "resolved": "https://registry.npmjs.org/media-encoder-host/-/media-encoder-host-9.0.20.tgz", + "integrity": "sha512-IyEYxw6az97RNuETOAZV4YZqNAPOiF9GKIp5mVZb4HOyWd6mhkWQ34ydOzhqAWogMyc4W05kjN/VCgTtgyFmsw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "media-encoder-host-broker": "^8.0.19", + "media-encoder-host-worker": "^10.0.19", + "tslib": "^2.8.1" + } + }, + "node_modules/media-encoder-host-broker": { + "version": "8.0.19", + "resolved": "https://registry.npmjs.org/media-encoder-host-broker/-/media-encoder-host-broker-8.0.19.tgz", + "integrity": "sha512-lTpsNuaZdTCdtTHsOyww7Ae0Mwv+7mFS+O4YkFYWhXwVs0rm6XbRK5jRRn5JmcX3n1eTE1lQS5RgX8qbNaIjSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "broker-factory": "^3.1.7", + "fast-unique-numbers": "^9.0.22", + "media-encoder-host-worker": "^10.0.19", + "tslib": "^2.8.1" + } + }, + "node_modules/media-encoder-host-worker": { + "version": "10.0.19", + "resolved": "https://registry.npmjs.org/media-encoder-host-worker/-/media-encoder-host-worker-10.0.19.tgz", + "integrity": "sha512-I8fwc6f41peER3RFSiwDxnIHbqU7p3pc2ghQozcw9CQfL0mWEo4IjQJtyswrrlL/HO2pgVSMQbaNzE4q/0mfDQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "extendable-media-recorder-wav-encoder-broker": "^7.0.119", + "tslib": "^2.8.1", + "worker-factory": "^7.0.43" + } + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -12112,17 +12298,23 @@ "dev": true }, "node_modules/mlly": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.1.tgz", - "integrity": "sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", + "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", "dev": true, "dependencies": { - "acorn": "^8.11.3", - "pathe": "^1.1.2", - "pkg-types": "^1.1.1", - "ufo": "^1.5.3" + "acorn": "^8.14.0", + "pathe": "^2.0.1", + "pkg-types": "^1.3.0", + "ufo": "^1.5.4" } }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -12155,6 +12347,19 @@ "dev": true, "license": "MIT" }, + "node_modules/multi-buffer-data-view": { + "version": "6.0.22", + "resolved": "https://registry.npmjs.org/multi-buffer-data-view/-/multi-buffer-data-view-6.0.22.tgz", + "integrity": "sha512-SsI/exkodHsh+ofCV7An2PZWRaJC7eFVl7gtHQlMWFEDmWtb7cELr/GK32Nhe/6dZQhbr81o+Moswx9aXN3RRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, "node_modules/mustache": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", @@ -12662,10 +12867,13 @@ "dev": true }, "node_modules/package-manager-detector": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.0.tgz", - "integrity": "sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog==", - "dev": true + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", + "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", + "dev": true, + "dependencies": { + "quansync": "^0.2.7" + } }, "node_modules/pako": { "version": "1.0.11", @@ -12959,16 +13167,22 @@ } }, "node_modules/pkg-types": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.2.0.tgz", - "integrity": "sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", "dev": true, "dependencies": { - "confbox": "^0.1.7", - "mlly": "^1.7.1", - "pathe": "^1.1.2" + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" } }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, "node_modules/playwright": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", @@ -13557,6 +13771,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quansync": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz", + "integrity": "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ] + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -13714,10 +13944,31 @@ "node": ">=8.10.0" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + "node_modules/recorder-audio-worklet": { + "version": "6.0.48", + "resolved": "https://registry.npmjs.org/recorder-audio-worklet/-/recorder-audio-worklet-6.0.48.tgz", + "integrity": "sha512-PVlq/1hjCrPcUGqARg8rR30A303xDCao0jmlBTaUaKkN3Xme58RI7EQxurv8rw2eDwVrN+nrni0UoJoa5/v+zg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "broker-factory": "^3.1.7", + "fast-unique-numbers": "^9.0.22", + "recorder-audio-worklet-processor": "^5.0.35", + "standardized-audio-context": "^25.3.77", + "subscribable-things": "^2.1.53", + "tslib": "^2.8.1", + "worker-factory": "^7.0.43" + } + }, + "node_modules/recorder-audio-worklet-processor": { + "version": "5.0.35", + "resolved": "https://registry.npmjs.org/recorder-audio-worklet-processor/-/recorder-audio-worklet-processor-5.0.35.tgz", + "integrity": "sha512-5Nzbk/6QzC3QFQ1EG2SE34c1ygLE22lIOvLyjy7N6XxE/jpAZrL4e7xR+yihiTaG3ajiWy6UjqL4XEBMM9ahFQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "tslib": "^2.8.1" + } }, "node_modules/registry-auth-token": { "version": "5.0.3", @@ -14234,6 +14485,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs-interop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/rxjs-interop/-/rxjs-interop-2.0.0.tgz", + "integrity": "sha512-ASEq9atUw7lualXB+knvgtvwkCEvGWV2gDD/8qnASzBkzEARZck9JAyxmY8OS6Nc1pCPEgDTKNcx+YqqYfzArw==", + "license": "MIT" + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -14629,6 +14886,17 @@ "dev": true, "license": "MIT" }, + "node_modules/standardized-audio-context": { + "version": "25.3.77", + "resolved": "https://registry.npmjs.org/standardized-audio-context/-/standardized-audio-context-25.3.77.tgz", + "integrity": "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.6", + "automation-events": "^7.0.9", + "tslib": "^2.7.0" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -14808,6 +15076,17 @@ "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==", "dev": true }, + "node_modules/subscribable-things": { + "version": "2.1.53", + "resolved": "https://registry.npmjs.org/subscribable-things/-/subscribable-things-2.1.53.tgz", + "integrity": "sha512-zWvN9F/eYQWDKszXl4NXkyqPXvMDZDmXfcHiM5C5WQZTTY2OK+2TZeDlA9oio69FEPqPu9T6yeEcAhQ2uRmnaw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "rxjs-interop": "^2.0.0", + "tslib": "^2.8.1" + } + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -15059,8 +15338,7 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/tinypool": { "version": "1.0.1", @@ -15992,39 +16270,32 @@ } }, "node_modules/unplugin": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.13.1.tgz", - "integrity": "sha512-6Kq1iSSwg7KyjcThRUks9LuqDAKvtnioxbL9iEtB9ctTyBA5OmrB8gZd/d225VJu1w3UpUsKV7eGrvf59J7+VA==", + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.5.tgz", + "integrity": "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==", "dev": true, "dependencies": { - "acorn": "^8.12.1", + "acorn": "^8.14.1", + "picomatch": "^4.0.2", "webpack-virtual-modules": "^0.6.2" }, "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "webpack-sources": "^3" - }, - "peerDependenciesMeta": { - "webpack-sources": { - "optional": true - } + "node": ">=18.12.0" } }, "node_modules/unplugin-icons": { - "version": "0.19.3", - "resolved": "https://registry.npmjs.org/unplugin-icons/-/unplugin-icons-0.19.3.tgz", - "integrity": "sha512-EUegRmsAI6+rrYr0vXjFlIP+lg4fSC4zb62zAZKx8FGXlWAGgEGBCa3JDe27aRAXhistObLPbBPhwa/0jYLFkQ==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/unplugin-icons/-/unplugin-icons-0.22.0.tgz", + "integrity": "sha512-CP+iZq5U7doOifer5bcM0jQ9t3Is7EGybIYt3myVxceI8Zuk8EZEpe1NPtJvh7iqMs1VdbK0L41t9+um9VuuLw==", "dev": true, "dependencies": { - "@antfu/install-pkg": "^0.4.1", + "@antfu/install-pkg": "^0.5.0", "@antfu/utils": "^0.7.10", - "@iconify/utils": "^2.1.29", - "debug": "^4.3.6", + "@iconify/utils": "^2.2.0", + "debug": "^4.4.0", "kolorist": "^1.8.0", - "local-pkg": "^0.5.0", - "unplugin": "^1.12.0" + "local-pkg": "^0.5.1", + "unplugin": "^2.1.0" }, "funding": { "url": "https://github.com/sponsors/antfu" @@ -16033,6 +16304,7 @@ "@svgr/core": ">=7.0.0", "@svgx/core": "^1.0.1", "@vue/compiler-sfc": "^3.0.2 || ^2.7.0", + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0", "vue-template-compiler": "^2.6.12", "vue-template-es2015-compiler": "^1.9.0" }, @@ -16046,6 +16318,9 @@ "@vue/compiler-sfc": { "optional": true }, + "svelte": { + "optional": true + }, "vue-template-compiler": { "optional": true }, @@ -16054,22 +16329,39 @@ } } }, + "node_modules/unplugin-icons/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/unplugin-vue-components": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-0.27.4.tgz", - "integrity": "sha512-1XVl5iXG7P1UrOMnaj2ogYa5YTq8aoh5jwDPQhemwO/OrXW+lPQKDXd1hMz15qxQPxgb/XXlbgo3HQ2rLEbmXQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-0.28.0.tgz", + "integrity": "sha512-jiTGtJ3JsRFBjgvyilfrX7yUoGKScFgbdNw+6p6kEXU+Spf/rhxzgvdfuMcvhCcLmflB/dY3pGQshYBVGOUx7Q==", "dev": true, "dependencies": { "@antfu/utils": "^0.7.10", - "@rollup/pluginutils": "^5.1.0", + "@rollup/pluginutils": "^5.1.4", "chokidar": "^3.6.0", - "debug": "^4.3.6", + "debug": "^4.4.0", "fast-glob": "^3.3.2", - "local-pkg": "^0.5.0", - "magic-string": "^0.30.11", + "local-pkg": "^0.5.1", + "magic-string": "^0.30.15", "minimatch": "^9.0.5", - "mlly": "^1.7.1", - "unplugin": "^1.12.1" + "mlly": "^1.7.3", + "unplugin": "^2.1.0" }, "engines": { "node": ">=14" @@ -16100,6 +16392,23 @@ "balanced-match": "^1.0.0" } }, + "node_modules/unplugin-vue-components/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/unplugin-vue-components/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -16115,6 +16424,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/unplugin/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -17298,6 +17619,17 @@ "node": ">=0.10.0" } }, + "node_modules/worker-factory": { + "version": "7.0.43", + "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.43.tgz", + "integrity": "sha512-SACVoj3gWKtMVyT9N+VD11Pd/Xe58+ZFfp8b7y/PagOvj3i8lU3Uyj+Lj7WYTmSBvNLC0JFaQkx44E6DhH5+WA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "fast-unique-numbers": "^9.0.22", + "tslib": "^2.8.1" + } + }, "node_modules/wrap-ansi": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", diff --git a/package.json b/package.json index 31fbfc623..e2652cf23 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@comfyorg/comfyui-frontend", "private": true, - "version": "1.24.1", + "version": "1.25.2", "type": "module", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "homepage": "https://comfy.org", @@ -63,8 +63,8 @@ "tsx": "^4.15.6", "typescript": "^5.4.5", "typescript-eslint": "^8.0.0", - "unplugin-icons": "^0.19.3", - "unplugin-vue-components": "^0.27.4", + "unplugin-icons": "^0.22.0", + "unplugin-vue-components": "^0.28.0", "vite": "^5.4.19", "vite-plugin-dts": "^4.3.0", "vite-plugin-html": "^3.2.2", @@ -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.13", + "@comfyorg/litegraph": "^0.16.20", "@primevue/forms": "^4.2.5", "@primevue/themes": "^4.2.5", "@sentry/vue": "^8.48.0", @@ -97,6 +97,8 @@ "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", diff --git a/src/assets/icons/README.md b/src/assets/icons/README.md new file mode 100644 index 000000000..ce227de35 --- /dev/null +++ b/src/assets/icons/README.md @@ -0,0 +1,184 @@ +# ComfyUI Custom Icons Guide + +This guide explains how to add and use custom SVG icons in the ComfyUI frontend. + +## Overview + +ComfyUI uses a hybrid icon system that supports: +- **PrimeIcons** - Legacy icon library (CSS classes like `pi pi-plus`) +- **Iconify** - Modern icon system with 200,000+ icons +- **Custom Icons** - Your own SVG icons + +Custom icons are powered by [unplugin-icons](https://github.com/unplugin/unplugin-icons) and integrate seamlessly with Vue's component system. + +## Quick Start + +### 1. Add Your SVG Icon + +Place your SVG file in the `custom/` directory: +``` +src/assets/icons/custom/ +โ””โ”€โ”€ your-icon.svg +``` + +### 2. Use in Components + +```vue + +``` + +## SVG Requirements + +### File Naming +- Use kebab-case: `workflow-icon.svg`, `node-tree.svg` +- Avoid special characters and spaces +- The filename becomes the icon name + +### SVG Format +```xml + + + +``` + +**Important:** +- Use `viewBox` for proper scaling (24x24 is standard) +- Don't include `width` or `height` attributes +- Use `currentColor` for theme-aware icons +- Keep SVGs optimized and simple + +### Color Theming + +For icons that adapt to the current theme, use `currentColor`: + +```xml + + + + + + + + + +``` + +## Usage Examples + +### Basic Icon +```vue + +``` + +### With Classes +```vue + +``` + +### In Buttons +```vue + +``` + +### Conditional Icons +```vue + +``` + +## Technical Details + +### How It Works + +1. **unplugin-icons** automatically discovers SVG files in `custom/` +2. During build, SVGs are converted to Vue components +3. Components are tree-shaken - only used icons are bundled +4. The `i-` prefix and `comfy:` namespace identify custom icons + +### Configuration + +The icon system is configured in `vite.config.mts`: + +```typescript +Icons({ + compiler: 'vue3', + customCollections: { + 'comfy': FileSystemIconLoader('src/assets/icons/custom'), + } +}) +``` + +### TypeScript Support + +Icons are automatically typed. If TypeScript doesn't recognize a new icon: +1. Restart your dev server +2. Check that the SVG file is valid +3. Ensure the filename follows kebab-case convention + +## Troubleshooting + +### Icon Not Showing +1. **Check filename**: Must be kebab-case without special characters +2. **Restart dev server**: Required after adding new icons +3. **Verify SVG**: Ensure it's valid SVG syntax +4. **Check console**: Look for Vue component resolution errors + +### Icon Wrong Color +- Replace hardcoded colors with `currentColor` +- Use `stroke="currentColor"` for outlines +- Use `fill="currentColor"` for filled shapes + +### Icon Wrong Size +- Remove `width` and `height` from SVG +- Ensure `viewBox` is present +- Use CSS classes for sizing: `class="w-6 h-6"` + +## Best Practices + +1. **Optimize SVGs**: Use tools like [SVGO](https://jakearchibald.github.io/svgomg/) to minimize file size +2. **Consistent viewBox**: Stick to 24x24 or 16x16 for consistency +3. **Semantic names**: Use descriptive names like `workflow-duplicate` not `icon1` +4. **Theme support**: Always use `currentColor` for adaptable icons +5. **Test both themes**: Verify icons look good in light and dark modes + +## Migration from PrimeIcons + +When replacing a PrimeIcon with a custom icon: + +```vue + + +``` + +## Adding Icon Collections + +To add an entire icon set from npm: + +1. Install the icon package +2. Configure in `vite.config.mts` +3. Use with the appropriate prefix + +See the [unplugin-icons documentation](https://github.com/unplugin/unplugin-icons) for details. \ No newline at end of file diff --git a/src/assets/icons/custom/workflow.svg b/src/assets/icons/custom/workflow.svg new file mode 100644 index 000000000..72f90c1a4 --- /dev/null +++ b/src/assets/icons/custom/workflow.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/components/actionbar/ComfyActionbar.vue b/src/components/actionbar/ComfyActionbar.vue index c9d75d009..be113867a 100644 --- a/src/components/actionbar/ComfyActionbar.vue +++ b/src/components/actionbar/ComfyActionbar.vue @@ -30,10 +30,11 @@ import ComfyQueueButton from './ComfyQueueButton.vue' const settingsStore = useSettingStore() -const visible = computed( - () => settingsStore.get('Comfy.UseNewMenu') !== 'Disabled' -) +const position = computed(() => settingsStore.get('Comfy.UseNewMenu')) +const visible = computed(() => position.value !== 'Disabled') + +const topMenuRef = inject>('topMenuRef') const panelRef = ref(null) const dragHandleRef = ref(null) const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', false) @@ -49,7 +50,16 @@ const { } = useDraggable(panelRef, { initialValue: { x: 0, y: 0 }, handle: dragHandleRef, - containerElement: document.body + 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 + } + } + } }) // Update storedPosition when x or y changes @@ -182,7 +192,6 @@ const adjustMenuPosition = () => { useEventListener(window, 'resize', adjustMenuPosition) -const topMenuRef = inject>('topMenuRef') const topMenuBounds = useElementBounding(topMenuRef) const overlapThreshold = 20 // pixels const isOverlappingWithTopMenu = computed(() => { diff --git a/src/components/breadcrumb/SubgraphBreadcrumb.vue b/src/components/breadcrumb/SubgraphBreadcrumb.vue index 63ee9c269..83bec7ffd 100644 --- a/src/components/breadcrumb/SubgraphBreadcrumb.vue +++ b/src/components/breadcrumb/SubgraphBreadcrumb.vue @@ -1,47 +1,95 @@ - + + diff --git a/src/components/breadcrumb/SubgraphBreadcrumbItem.vue b/src/components/breadcrumb/SubgraphBreadcrumbItem.vue new file mode 100644 index 000000000..ffadd86f8 --- /dev/null +++ b/src/components/breadcrumb/SubgraphBreadcrumbItem.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/src/components/dialog/content/LoadWorkflowWarning.vue b/src/components/dialog/content/LoadWorkflowWarning.vue index 471607483..4a39e2b31 100644 --- a/src/components/dialog/content/LoadWorkflowWarning.vue +++ b/src/components/dialog/content/LoadWorkflowWarning.vue @@ -2,7 +2,7 @@ diff --git a/src/components/dialog/content/manager/ManagerHeader.test.ts b/src/components/dialog/content/manager/ManagerHeader.test.ts new file mode 100644 index 000000000..291020d1f --- /dev/null +++ b/src/components/dialog/content/manager/ManagerHeader.test.ts @@ -0,0 +1,82 @@ +import { mount } from '@vue/test-utils' +import { createPinia } from 'pinia' +import PrimeVue from 'primevue/config' +import Tag from 'primevue/tag' +import Tooltip from 'primevue/tooltip' +import { describe, expect, it } from 'vitest' +import { createI18n } from 'vue-i18n' + +import enMessages from '@/locales/en/main.json' + +import ManagerHeader from './ManagerHeader.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: enMessages + } +}) + +describe('ManagerHeader', () => { + const createWrapper = () => { + return mount(ManagerHeader, { + global: { + plugins: [createPinia(), PrimeVue, i18n], + directives: { + tooltip: Tooltip + }, + components: { + Tag + } + } + }) + } + + it('renders the component title', () => { + const wrapper = createWrapper() + + expect(wrapper.find('h2').text()).toBe( + enMessages.manager.discoverCommunityContent + ) + }) + + it('displays the legacy manager UI tag', () => { + const wrapper = createWrapper() + + const tag = wrapper.find('[data-pc-name="tag"]') + expect(tag.exists()).toBe(true) + expect(tag.text()).toContain(enMessages.manager.legacyManagerUI) + }) + + it('applies info severity to the tag', () => { + const wrapper = createWrapper() + + const tag = wrapper.find('[data-pc-name="tag"]') + expect(tag.classes()).toContain('p-tag-info') + }) + + it('displays info icon in the tag', () => { + const wrapper = createWrapper() + + const icon = wrapper.find('.pi-info-circle') + expect(icon.exists()).toBe(true) + }) + + it('has cursor-help class on the tag', () => { + const wrapper = createWrapper() + + const tag = wrapper.find('[data-pc-name="tag"]') + expect(tag.classes()).toContain('cursor-help') + }) + + it('has proper structure with flex container', () => { + const wrapper = createWrapper() + + const flexContainer = wrapper.find('.flex.justify-end.ml-auto.pr-4') + expect(flexContainer.exists()).toBe(true) + + const tag = flexContainer.find('[data-pc-name="tag"]') + expect(tag.exists()).toBe(true) + }) +}) diff --git a/src/components/dialog/content/manager/ManagerHeader.vue b/src/components/dialog/content/manager/ManagerHeader.vue index bd438b704..a4fbdfaa3 100644 --- a/src/components/dialog/content/manager/ManagerHeader.vue +++ b/src/components/dialog/content/manager/ManagerHeader.vue @@ -4,16 +4,22 @@

{{ $t('manager.discoverCommunityContent') }}

- +
+ +
+ + diff --git a/src/components/graph/DomWidgets.vue b/src/components/graph/DomWidgets.vue index 9f7117e00..7edca3f5e 100644 --- a/src/components/graph/DomWidgets.vue +++ b/src/components/graph/DomWidgets.vue @@ -11,7 +11,6 @@ + + diff --git a/src/components/graph/SelectionToolbox.vue b/src/components/graph/SelectionToolbox.vue index 3b316b06b..2412b703f 100644 --- a/src/components/graph/SelectionToolbox.vue +++ b/src/components/graph/SelectionToolbox.vue @@ -5,6 +5,7 @@ header: 'hidden', content: 'p-0 flex flex-row' }" + @wheel="canvasInteractions.handleWheel" > @@ -39,6 +40,7 @@ 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' @@ -46,6 +48,7 @@ import { useCanvasStore } from '@/stores/graphStore' const commandStore = useCommandStore() const canvasStore = useCanvasStore() const extensionService = useExtensionService() +const canvasInteractions = useCanvasInteractions() const extensionToolboxCommands = computed(() => { const commandIds = new Set( diff --git a/src/components/graph/selectionToolbox/ConvertToSubgraphButton.vue b/src/components/graph/selectionToolbox/ConvertToSubgraphButton.vue index c86582a60..e1f6ee08c 100644 --- a/src/components/graph/selectionToolbox/ConvertToSubgraphButton.vue +++ b/src/components/graph/selectionToolbox/ConvertToSubgraphButton.vue @@ -7,9 +7,12 @@ }" severity="secondary" text - icon="pi pi-box" @click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')" - /> + > + + + + diff --git a/src/components/topbar/TopMenubar.vue b/src/components/topbar/TopMenubar.vue index e126bad67..d898e2f2a 100644 --- a/src/components/topbar/TopMenubar.vue +++ b/src/components/topbar/TopMenubar.vue @@ -1,82 +1,66 @@ diff --git a/src/composables/canvas/useCanvasTransformSync.ts b/src/composables/canvas/useCanvasTransformSync.ts new file mode 100644 index 000000000..cddb4c7f7 --- /dev/null +++ b/src/composables/canvas/useCanvasTransformSync.ts @@ -0,0 +1,136 @@ +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 + } +} diff --git a/src/composables/element/useAbsolutePosition.ts b/src/composables/element/useAbsolutePosition.ts index d334af7c7..bca981669 100644 --- a/src/composables/element/useAbsolutePosition.ts +++ b/src/composables/element/useAbsolutePosition.ts @@ -1,8 +1,9 @@ import type { Size, Vector2 } from '@comfyorg/litegraph' -import { CSSProperties, ref } from 'vue' +import { CSSProperties, ref, watch } 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 */ @@ -18,9 +19,18 @@ export function useAbsolutePosition(options: { useTransform?: boolean } = {}) { const canvasStore = useCanvasStore() const lgCanvas = canvasStore.getCanvas() - const { canvasPosToClientPos } = useCanvasPositionConversion( - lgCanvas.canvas, - lgCanvas + 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' } ) /** diff --git a/src/composables/element/useCanvasPositionConversion.ts b/src/composables/element/useCanvasPositionConversion.ts index 77e86ddec..2c0f7198e 100644 --- a/src/composables/element/useCanvasPositionConversion.ts +++ b/src/composables/element/useCanvasPositionConversion.ts @@ -11,7 +11,7 @@ export const useCanvasPositionConversion = ( canvasElement: Parameters[0], lgCanvas: LGraphCanvas ) => { - const { left, top } = useElementBounding(canvasElement) + const { left, top, update } = useElementBounding(canvasElement) const clientPosToCanvasPos = (pos: Vector2): Vector2 => { const { offset, scale } = lgCanvas.ds @@ -31,6 +31,7 @@ export const useCanvasPositionConversion = ( return { clientPosToCanvasPos, - canvasPosToClientPos + canvasPosToClientPos, + update } } diff --git a/src/composables/element/useOverflowObserver.ts b/src/composables/element/useOverflowObserver.ts new file mode 100644 index 000000000..10b6fecce --- /dev/null +++ b/src/composables/element/useOverflowObserver.ts @@ -0,0 +1,64 @@ +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()) + } + } +} diff --git a/src/composables/graph/useCanvasInteractions.ts b/src/composables/graph/useCanvasInteractions.ts new file mode 100644 index 000000000..dca1bd125 --- /dev/null +++ b/src/composables/graph/useCanvasInteractions.ts @@ -0,0 +1,59 @@ +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 + } +} diff --git a/src/composables/node/useNodePricing.ts b/src/composables/node/useNodePricing.ts index 710fbacaf..36aaff5a7 100644 --- a/src/composables/node/useNodePricing.ts +++ b/src/composables/node/useNodePricing.ts @@ -30,6 +30,25 @@ 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' @@ -110,15 +129,27 @@ const apiNodeCosts: Record = 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 - if (!numImagesWidget) return '$0.06 x num_images/Run' + const turboWidget = node.widgets?.find( + (w) => w.name === 'turbo' + ) as IComboWidget + + if (!numImagesWidget) return '$0.02-0.06 x num_images/Run' const numImages = Number(numImagesWidget.value) || 1 - const cost = (0.06 * numImages).toFixed(2) + const turbo = String(turboWidget?.value).toLowerCase() === 'true' + const basePrice = turbo ? 0.02 : 0.06 + const cost = (basePrice * numImages).toFixed(2) return `$${cost}/Run` } }, @@ -127,10 +158,16 @@ const apiNodeCosts: Record = const numImagesWidget = node.widgets?.find( (w) => w.name === 'num_images' ) as IComboWidget - if (!numImagesWidget) return '$0.08 x num_images/Run' + const turboWidget = node.widgets?.find( + (w) => w.name === 'turbo' + ) as IComboWidget + + if (!numImagesWidget) return '$0.05-0.08 x num_images/Run' const numImages = Number(numImagesWidget.value) || 1 - const cost = (0.08 * numImages).toFixed(2) + const turbo = String(turboWidget?.value).toLowerCase() === 'true' + const basePrice = turbo ? 0.05 : 0.08 + const cost = (basePrice * numImages).toFixed(2) return `$${cost}/Run` } }, @@ -651,10 +688,10 @@ const apiNodeCosts: Record = if (duration.includes('5')) { if (resolution.includes('720p')) return '$0.3/Run' - if (resolution.includes('1080p')) return '~$0.3/Run' + if (resolution.includes('1080p')) return '$0.5/Run' } else if (duration.includes('10')) { - if (resolution.includes('720p')) return '$0.25/Run' - if (resolution.includes('1080p')) return '$1.0/Run' + if (resolution.includes('720p')) return '$0.4/Run' + if (resolution.includes('1080p')) return '$1.5/Run' } return '$0.3/Run' @@ -678,9 +715,9 @@ const apiNodeCosts: Record = if (duration.includes('5')) { if (resolution.includes('720p')) return '$0.2/Run' - if (resolution.includes('1080p')) return '~$0.45/Run' + if (resolution.includes('1080p')) return '$0.3/Run' } else if (duration.includes('10')) { - if (resolution.includes('720p')) return '$0.6/Run' + if (resolution.includes('720p')) return '$0.25/Run' if (resolution.includes('1080p')) return '$1.0/Run' } @@ -896,18 +933,11 @@ const apiNodeCosts: Record = } const model = String(modelWidget.value) - const aspectRatio = String(aspectRatioWidget.value) if (model.includes('photon-flash-1')) { - 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' + return '$0.0019/Run' } else if (model.includes('photon-1')) { - 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.0073/Run' } return '$0.0172/Run' @@ -918,31 +948,17 @@ const apiNodeCosts: Record = 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.0045-0.0182/Run (varies with model & aspect ratio)' + return '$0.0019-0.0073/Run (varies with model)' } const model = String(modelWidget.value) - const aspectRatio = aspectRatioWidget - ? String(aspectRatioWidget.value) - : null if (model.includes('photon-flash-1')) { - 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' + return '$0.0019/Run' } else if (model.includes('photon-1')) { - 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.0073/Run' } return '$0.0172/Run' @@ -1004,6 +1020,279 @@ const apiNodeCosts: Record = 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' + } } } @@ -1045,9 +1334,11 @@ export const useNodePricing = () => { OpenAIDalle3: ['size', 'quality'], OpenAIDalle2: ['size', 'n'], OpenAIGPTImage1: ['quality', 'n'], - IdeogramV1: ['num_images'], - IdeogramV2: ['num_images'], + IdeogramV1: ['num_images', 'turbo'], + IdeogramV2: ['num_images', 'turbo'], IdeogramV3: ['rendering_speed', 'num_images'], + FluxProKontextProNode: [], + FluxProKontextMaxNode: [], VeoVideoGenerationNode: ['duration_seconds'], LumaVideoNode: ['model', 'resolution', 'duration'], LumaImageToVideoNode: ['model', 'resolution', 'duration'], @@ -1075,7 +1366,19 @@ export const useNodePricing = () => { RecraftGenerateVectorImageNode: ['n'], MoonvalleyTxt2VideoNode: ['length'], MoonvalleyImg2VideoNode: ['length'], - MoonvalleyVideo2VideoNode: ['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'] } return widgetMap[nodeType] || [] } diff --git a/src/composables/nodePack/useMissingNodes.ts b/src/composables/nodePack/useMissingNodes.ts index 4327df0c1..a7008ff08 100644 --- a/src/composables/nodePack/useMissingNodes.ts +++ b/src/composables/nodePack/useMissingNodes.ts @@ -8,6 +8,7 @@ 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 @@ -56,7 +57,7 @@ export const useMissingNodes = () => { } const missingCoreNodes = computed>(() => { - const missingNodes = app.graph.nodes.filter(isMissingCoreNode) + const missingNodes = collectAllNodes(app.graph, isMissingCoreNode) return groupBy(missingNodes, (node) => String(node.properties?.ver || '')) }) diff --git a/src/composables/nodePack/useWorkflowPacks.ts b/src/composables/nodePack/useWorkflowPacks.ts index 4fac76e18..296c4191d 100644 --- a/src/composables/nodePack/useWorkflowPacks.ts +++ b/src/composables/nodePack/useWorkflowPacks.ts @@ -9,6 +9,7 @@ import { useNodeDefStore } from '@/stores/nodeDefStore' import { useSystemStatsStore } from '@/stores/systemStatsStore' import { UseNodePacksOptions } from '@/types/comfyManagerTypes' import type { components } from '@/types/comfyRegistryTypes' +import { collectAllNodes } from '@/utils/graphTraversalUtil' type WorkflowPack = { id: @@ -108,11 +109,13 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => { } /** - * Get the node packs for all nodes in the workflow. + * Get the node packs for all nodes in the workflow (including subgraphs). */ const getWorkflowPacks = async () => { - if (!app.graph?.nodes?.length) return [] - const packs = await Promise.all(app.graph.nodes.map(workflowNodeToPack)) + if (!app.graph) return [] + const allNodes = collectAllNodes(app.graph) + if (!allNodes.length) return [] + const packs = await Promise.all(allNodes.map(workflowNodeToPack)) workflowPacks.value = packs.filter((pack) => pack !== undefined) } diff --git a/src/composables/useBrowserTabTitle.ts b/src/composables/useBrowserTabTitle.ts index 95d60752d..82da27149 100644 --- a/src/composables/useBrowserTabTitle.ts +++ b/src/composables/useBrowserTabTitle.ts @@ -1,6 +1,7 @@ import { useTitle } from '@vueuse/core' import { computed } from 'vue' +import { t } from '@/i18n' import { useExecutionStore } from '@/stores/executionStore' import { useSettingStore } from '@/stores/settingStore' import { useWorkflowStore } from '@/stores/workflowStore' @@ -36,11 +37,34 @@ export const useBrowserTabTitle = () => { : DEFAULT_TITLE }) - const nodeExecutionTitle = computed(() => - executionStore.executingNode && executionStore.executingNodeProgress - ? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}` - : '' - ) + const nodeExecutionTitle = computed(() => { + // Check if any nodes are in progress + const nodeProgressEntries = Object.entries( + executionStore.nodeProgressStates + ) + const runningNodes = nodeProgressEntries.filter( + ([_, state]) => state.state === 'running' + ) + + if (runningNodes.length === 0) { + return '' + } + + // If multiple nodes are running + if (runningNodes.length > 1) { + return `${executionText.value}[${runningNodes.length} ${t('g.nodesRunning', 'nodes running')}]` + } + + // If only one node is running + const [nodeId, state] = runningNodes[0] + const progress = Math.round((state.value / state.max) * 100) + const nodeType = + executionStore.activePrompt?.workflow?.changeTracker?.activeState.nodes.find( + (n) => String(n.id) === nodeId + )?.type || 'Node' + + return `${executionText.value}[${progress}%] ${nodeType}` + }) const workflowTitle = computed( () => diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index f0b1cc5e6..7ecbe1fe2 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -31,6 +31,7 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore' import { useWorkspaceStore } from '@/stores/workspaceStore' import { ManagerTab } from '@/types/comfyManagerTypes' +import { getAllNonIoNodesInSubgraph } from '@/utils/graphTraversalUtil' const moveSelectedNodesVersionAdded = '1.22.2' @@ -169,11 +170,20 @@ export function useCoreCommands(): ComfyCommand[] { function: () => { const settingStore = useSettingStore() if ( - !settingStore.get('Comfy.ComfirmClear') || + !settingStore.get('Comfy.ConfirmClear') || confirm('Clear workflow?') ) { app.clean() - app.graph.clear() + 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() + } api.dispatchCustomEvent('graphCleared') } } @@ -315,6 +325,19 @@ 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', diff --git a/src/composables/useFrontendVersionMismatchWarning.ts b/src/composables/useFrontendVersionMismatchWarning.ts new file mode 100644 index 000000000..11897a016 --- /dev/null +++ b/src/composables/useFrontendVersionMismatchWarning.ts @@ -0,0 +1,94 @@ +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 + ) + } +} diff --git a/src/composables/useLitegraphSettings.ts b/src/composables/useLitegraphSettings.ts index 83f19c334..79eaee1c3 100644 --- a/src/composables/useLitegraphSettings.ts +++ b/src/composables/useLitegraphSettings.ts @@ -124,9 +124,12 @@ export const useLitegraphSettings = () => { }) watchEffect(() => { - LiteGraph.macTrackpadGestures = settingStore.get( - 'LiteGraph.Pointer.TrackpadGestures' - ) + const navigationMode = settingStore.get('Comfy.Canvas.NavigationMode') as + | 'standard' + | 'legacy' + + LiteGraph.canvasNavigationMode = navigationMode + LiteGraph.macTrackpadGestures = navigationMode === 'standard' }) watchEffect(() => { diff --git a/src/composables/useMinimap.ts b/src/composables/useMinimap.ts new file mode 100644 index 000000000..1245a0b76 --- /dev/null +++ b/src/composables/useMinimap.ts @@ -0,0 +1,685 @@ +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() + const canvasRef = ref() + const minimapRef = ref(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() + const linksCache = ref('') + + 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 + } +} diff --git a/src/composables/useRefreshableSelection.ts b/src/composables/useRefreshableSelection.ts index bb19918be..20a7c5e75 100644 --- a/src/composables/useRefreshableSelection.ts +++ b/src/composables/useRefreshableSelection.ts @@ -1,5 +1,4 @@ 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' @@ -9,12 +8,11 @@ interface RefreshableItem { refresh: () => Promise | void } -type RefreshableWidget = IBaseWidget & RefreshableItem - -const isRefreshableWidget = ( - widget: IBaseWidget -): widget is RefreshableWidget => - 'refresh' in widget && typeof widget.refresh === 'function' +const isRefreshableWidget = (widget: unknown): widget is RefreshableItem => + widget != null && + typeof widget === 'object' && + 'refresh' in widget && + typeof widget.refresh === 'function' /** * Tracks selected nodes and their refreshable widgets @@ -27,10 +25,17 @@ export const useRefreshableSelection = () => { selectedNodes.value = graphStore.selectedItems.filter(isLGraphNode) }) - const refreshableWidgets = computed(() => - selectedNodes.value.flatMap( - (node) => node.widgets?.filter(isRefreshableWidget) ?? [] - ) + const refreshableWidgets = computed(() => + 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 isRefreshable = computed(() => refreshableWidgets.value.length > 0) diff --git a/src/composables/widgets/useChatHistoryWidget.ts b/src/composables/widgets/useChatHistoryWidget.ts index 9f807a3c6..a3568fb21 100644 --- a/src/composables/widgets/useChatHistoryWidget.ts +++ b/src/composables/widgets/useChatHistoryWidget.ts @@ -3,14 +3,23 @@ import { ref } from 'vue' import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' -import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget' +import { + ComponentWidgetImpl, + type ComponentWidgetStandardProps, + addWidget +} from '@/scripts/domWidget' import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets' +type ChatHistoryCustomProps = Omit< + InstanceType['$props'], + ComponentWidgetStandardProps +> + const PADDING = 16 export const useChatHistoryWidget = ( options: { - props?: Omit['$props'], 'widget'> + props?: ChatHistoryCustomProps } = {} ) => { const widgetConstructor: ComfyWidgetConstructorV2 = ( @@ -20,7 +29,7 @@ export const useChatHistoryWidget = ( const widgetValue = ref('') const widget = new ComponentWidgetImpl< string | object, - InstanceType['$props'] + ChatHistoryCustomProps >({ node, name: inputSpec.name, diff --git a/src/composables/widgets/useProgressTextWidget.ts b/src/composables/widgets/useProgressTextWidget.ts index 625f0f9cf..1e7dc45ce 100644 --- a/src/composables/widgets/useProgressTextWidget.ts +++ b/src/composables/widgets/useProgressTextWidget.ts @@ -3,9 +3,18 @@ import { ref } from 'vue' import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' -import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget' +import { + ComponentWidgetImpl, + type ComponentWidgetStandardProps, + addWidget +} from '@/scripts/domWidget' import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets' +type TextPreviewCustomProps = Omit< + InstanceType['$props'], + ComponentWidgetStandardProps +> + const PADDING = 16 export const useTextPreviewWidget = ( @@ -18,11 +27,17 @@ export const useTextPreviewWidget = ( inputSpec: InputSpec ) => { const widgetValue = ref('') - const widget = new ComponentWidgetImpl({ + const widget = new ComponentWidgetImpl< + string | object, + TextPreviewCustomProps + >({ node, name: inputSpec.name, component: TextPreviewWidget, inputSpec, + props: { + nodeId: node.id + }, options: { getValue: () => widgetValue.value, setValue: (value: string | object) => { diff --git a/src/composables/widgets/useStringWidget.ts b/src/composables/widgets/useStringWidget.ts index d5298338e..b3f438a96 100644 --- a/src/composables/widgets/useStringWidget.ts +++ b/src/composables/widgets/useStringWidget.ts @@ -8,6 +8,8 @@ import { app } from '@/scripts/app' import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets' import { useSettingStore } from '@/stores/settingStore' +const TRACKPAD_DETECTION_THRESHOLD = 50 + function addMultilineWidget( node: LGraphNode, name: string, @@ -54,38 +56,55 @@ function addMultilineWidget( } }) - /** Timer reference. `null` when the timer completes. */ - let ignoreEventsTimer: ReturnType | null = null - /** Total number of events ignored since the timer started. */ - let ignoredEvents = 0 - - // Pass wheel events to the canvas when appropriate inputEl.addEventListener('wheel', (event: WheelEvent) => { - if (!Object.is(event.deltaX, -0)) return + const gesturesEnabled = useSettingStore().get( + 'LiteGraph.Pointer.TrackpadGestures' + ) + const deltaX = event.deltaX + const deltaY = event.deltaY - // If the textarea has focus, require more effort to activate pass-through - const multiplier = document.activeElement === inputEl ? 2 : 1 - const maxScrollHeight = inputEl.scrollHeight - inputEl.clientHeight + const canScrollY = inputEl.scrollHeight > inputEl.clientHeight + const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY) - if ( - (event.deltaY < 0 && inputEl.scrollTop === 0) || - (event.deltaY > 0 && inputEl.scrollTop === maxScrollHeight) - ) { - // Attempting to scroll past the end of the textarea - if (!ignoreEventsTimer || ignoredEvents > 25 * multiplier) { - app.canvas.processMouseWheel(event) - } else { - ignoredEvents++ - } - } else if (event.deltaY !== 0) { - // Start timer whenever a successful scroll occurs - ignoredEvents = 0 - if (ignoreEventsTimer) clearTimeout(ignoreEventsTimer) - - ignoreEventsTimer = setTimeout(() => { - ignoreEventsTimer = null - }, 800 * multiplier) + // Prevent pinch zoom from zooming the page + if (event.ctrlKey) { + event.preventDefault() + event.stopPropagation() + app.canvas.processMouseWheel(event) + return } + + // Detect if this is likely a trackpad gesture vs mouse wheel + // Trackpads usually have deltaX or smaller deltaY values (< TRACKPAD_DETECTION_THRESHOLD) + // Mouse wheels typically have larger discrete deltaY values (>= TRACKPAD_DETECTION_THRESHOLD) + const isLikelyTrackpad = + Math.abs(deltaX) > 0 || Math.abs(deltaY) < TRACKPAD_DETECTION_THRESHOLD + + // Trackpad gestures: when enabled, trackpad panning goes to canvas + if (gesturesEnabled && isLikelyTrackpad) { + event.preventDefault() + event.stopPropagation() + app.canvas.processMouseWheel(event) + return + } + + // When gestures disabled: horizontal always goes to canvas (no horizontal scroll in textarea) + if (isHorizontal) { + event.preventDefault() + event.stopPropagation() + app.canvas.processMouseWheel(event) + return + } + + // Vertical scrolling when gestures disabled: let textarea scroll if scrollable + if (canScrollY) { + event.stopPropagation() + return + } + + // If textarea can't scroll vertically, pass to canvas + event.preventDefault() + app.canvas.processMouseWheel(event) }) return widget diff --git a/src/config/clientFeatureFlags.json b/src/config/clientFeatureFlags.json index ebd6bcf60..84a233ccf 100644 --- a/src/config/clientFeatureFlags.json +++ b/src/config/clientFeatureFlags.json @@ -1,3 +1,3 @@ { - "supports_preview_metadata": false + "supports_preview_metadata": true } diff --git a/src/constants/coreKeybindings.ts b/src/constants/coreKeybindings.ts index 34f2cc39e..c61fd0b35 100644 --- a/src/constants/coreKeybindings.ts +++ b/src/constants/coreKeybindings.ts @@ -68,12 +68,6 @@ export const CORE_KEYBINDINGS: Keybinding[] = [ }, commandId: 'Comfy.OpenWorkflow' }, - { - combo: { - key: 'Backspace' - }, - commandId: 'Comfy.ClearWorkflow' - }, { combo: { key: 'g', @@ -181,5 +175,12 @@ export const CORE_KEYBINDINGS: Keybinding[] = [ shift: true }, commandId: 'Comfy.Graph.ConvertToSubgraph' + }, + { + combo: { + key: 'm', + alt: true + }, + commandId: 'Comfy.Canvas.ToggleMinimap' } ] diff --git a/src/constants/coreSettings.ts b/src/constants/coreSettings.ts index 12803be4c..943d4c8a2 100644 --- a/src/constants/coreSettings.ts +++ b/src/constants/coreSettings.ts @@ -35,7 +35,10 @@ export const CORE_SETTINGS: SettingParams[] = [ name: 'Action on link release (No modifier)', type: 'combo', options: Object.values(LinkReleaseTriggerAction), - defaultValue: LinkReleaseTriggerAction.CONTEXT_MENU + defaultValue: LinkReleaseTriggerAction.CONTEXT_MENU, + defaultsByInstallVersion: { + '1.24.1': LinkReleaseTriggerAction.SEARCH_BOX + } }, { id: 'Comfy.LinkRelease.ActionShift', @@ -43,7 +46,10 @@ export const CORE_SETTINGS: SettingParams[] = [ name: 'Action on link release (Shift)', type: 'combo', options: Object.values(LinkReleaseTriggerAction), - defaultValue: LinkReleaseTriggerAction.SEARCH_BOX + defaultValue: LinkReleaseTriggerAction.SEARCH_BOX, + defaultsByInstallVersion: { + '1.24.1': LinkReleaseTriggerAction.CONTEXT_MENU + } }, { id: 'Comfy.NodeSearchBoxImpl.NodePreview', @@ -505,15 +511,6 @@ export const CORE_SETTINGS: SettingParams[] = [ defaultValue: [] as string[], versionAdded: '1.3.11' }, - { - id: 'Comfy.Validation.NodeDefs', - name: 'Validate node definitions (slow)', - type: 'boolean', - tooltip: - 'Recommended for node developers. This will validate all node definitions on startup.', - defaultValue: false, - versionAdded: '1.3.14' - }, { id: 'Comfy.LinkRenderMode', category: ['LiteGraph', 'Graph', 'LinkRenderMode'], @@ -785,6 +782,21 @@ export const CORE_SETTINGS: SettingParams[] = [ defaultValue: 0.6, versionAdded: '1.9.1' }, + { + id: 'Comfy.Canvas.NavigationMode', + category: ['LiteGraph', 'Canvas', 'CanvasNavigationMode'], + name: 'Canvas Navigation Mode', + defaultValue: 'legacy', + type: 'combo', + options: [ + { value: 'standard', text: 'Standard (New)' }, + { value: 'legacy', text: 'Left-Click Pan (Legacy)' } + ], + versionAdded: '1.25.0', + defaultsByInstallVersion: { + '1.25.0': 'standard' + } + }, { id: 'Comfy.Canvas.SelectionToolbox', category: ['LiteGraph', 'Canvas', 'SelectionToolbox'], @@ -812,6 +824,13 @@ export const CORE_SETTINGS: SettingParams[] = [ defaultValue: false, versionAdded: '1.15.12' }, + { + id: 'Comfy.Minimap.Visible', + name: 'Display minimap on canvas', + type: 'hidden', + defaultValue: true, + versionAdded: '1.25.0' + }, { id: 'Comfy.Workflow.AutoSaveDelay', name: 'Auto Save Delay (ms)', @@ -855,17 +874,6 @@ export const CORE_SETTINGS: SettingParams[] = [ versionAdded: '1.20.4', versionModified: '1.20.5' }, - { - id: 'LiteGraph.Pointer.TrackpadGestures', - category: ['LiteGraph', 'Pointer', 'Trackpad Gestures'], - experimental: true, - name: 'Enable trackpad gestures', - tooltip: - 'This setting enables trackpad mode for the canvas, allowing pinch-to-zoom and panning with two fingers.', - type: 'boolean', - defaultValue: false, - versionAdded: '1.19.1' - }, // Release data stored in settings { id: 'Comfy.Release.Version', diff --git a/src/constants/supportedWorkflowFormats.ts b/src/constants/supportedWorkflowFormats.ts index f00bd3f05..ac0bee186 100644 --- a/src/constants/supportedWorkflowFormats.ts +++ b/src/constants/supportedWorkflowFormats.ts @@ -6,8 +6,8 @@ * All supported image formats that can contain workflow data */ export const IMAGE_WORKFLOW_FORMATS = { - extensions: ['.png', '.webp', '.svg'], - mimeTypes: ['image/png', 'image/webp', 'image/svg+xml'] + extensions: ['.png', '.webp', '.svg', '.avif'], + mimeTypes: ['image/png', 'image/webp', 'image/svg+xml', 'image/avif'] } /** diff --git a/src/extensions/core/groupNode.ts b/src/extensions/core/groupNode.ts index 800255af1..7aa714133 100644 --- a/src/extensions/core/groupNode.ts +++ b/src/extensions/core/groupNode.ts @@ -15,6 +15,7 @@ import { } from '@/schemas/comfyWorkflowSchema' import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' import { useDialogService } from '@/services/dialogService' +import { useExecutionStore } from '@/stores/executionStore' import { useNodeDefStore } from '@/stores/nodeDefStore' import { useToastStore } from '@/stores/toastStore' import { useWidgetStore } from '@/stores/widgetStore' @@ -1224,9 +1225,10 @@ export class GroupNodeHandler { node.onDrawForeground = function (ctx) { // @ts-expect-error fixme ts strict error onDrawForeground?.apply?.(this, arguments) + const progressState = useExecutionStore().nodeProgressStates[this.id] if ( - // @ts-expect-error fixme ts strict error - +app.runningNodeId === this.id && + progressState && + progressState.state === 'running' && this.runningInternalNodeId !== null ) { // @ts-expect-error fixme ts strict error @@ -1340,6 +1342,7 @@ export class GroupNodeHandler { this.node.onRemoved = function () { // @ts-expect-error fixme ts strict error onRemoved?.apply(this, arguments) + // api.removeEventListener('progress_state', progress_state) api.removeEventListener('executing', executing) api.removeEventListener('executed', executed) } diff --git a/src/extensions/core/maskEditorLayerFilenames.ts b/src/extensions/core/maskEditorLayerFilenames.ts new file mode 100644 index 000000000..2e2f1bf98 --- /dev/null +++ b/src/extensions/core/maskEditorLayerFilenames.ts @@ -0,0 +1,29 @@ +export interface ImageLayerFilenames { + maskedImage: string + paint: string + paintedImage: string + paintedMaskedImage: string +} + +const paintedMaskedImagePrefix = 'clipspace-painted-masked-' + +export const imageLayerFilenamesByTimestamp = ( + timestamp: number +): ImageLayerFilenames => ({ + maskedImage: `clipspace-mask-${timestamp}.png`, + paint: `clipspace-paint-${timestamp}.png`, + paintedImage: `clipspace-painted-${timestamp}.png`, + paintedMaskedImage: `${paintedMaskedImagePrefix}${timestamp}.png` +}) + +export const imageLayerFilenamesIfApplicable = ( + inputImageFilename: string +): ImageLayerFilenames | undefined => { + const isPaintedMaskedImageFilename = inputImageFilename.startsWith( + paintedMaskedImagePrefix + ) + if (!isPaintedMaskedImageFilename) return undefined + const suffix = inputImageFilename.slice(paintedMaskedImagePrefix.length) + const timestamp = parseInt(suffix.split('.')[0], 10) + return imageLayerFilenamesByTimestamp(timestamp) +} diff --git a/src/extensions/core/maskeditor.ts b/src/extensions/core/maskeditor.ts index fa7f29f47..c07a3d84d 100644 --- a/src/extensions/core/maskeditor.ts +++ b/src/extensions/core/maskeditor.ts @@ -1,4 +1,5 @@ import { debounce } from 'lodash' +import _ from 'lodash' import { t } from '@/i18n' @@ -7,7 +8,12 @@ import { app } from '../../scripts/app' import { ComfyApp } from '../../scripts/app' import { $el, ComfyDialog } from '../../scripts/ui' import { getStorageValue, setStorageValue } from '../../scripts/utils' +import { hexToRgb } from '../../utils/colorUtil' import { ClipspaceDialog } from './clipspace' +import { + imageLayerFilenamesByTimestamp, + imageLayerFilenamesIfApplicable +} from './maskEditorLayerFilenames' import { MaskEditorDialogOld } from './maskEditorOld' var styles = ` @@ -42,6 +48,7 @@ var styles = ` backdrop-filter: blur(10px); overflow: hidden; user-select: none; + --mask-editor-top-bar-height: 44px; } #maskEditor_sidePanelContainer { height: 100%; @@ -55,13 +62,17 @@ var styles = ` height: 100%; display: flex; align-items: center; - overflow-y: hidden; + overflow-y: auto; width: 220px; + padding: 0 10px; + } + #maskEditor_sidePanelContent { + width: 100%; } #maskEditor_sidePanelShortcuts { display: flex; flex-direction: row; - width: 200px; + width: 100%; margin-top: 10px; gap: 10px; justify-content: center; @@ -82,7 +93,7 @@ var styles = ` display: flex; flex-direction: column; gap: 10px; - width: 200px; + width: 100%; padding: 10px; } .maskEditor_sidePanelTitle { @@ -171,12 +182,12 @@ var styles = ` display: flex; flex-direction: column; gap: 10px; - width: 200px; + width: 100%; align-items: center; } .maskEditor_sidePanelLayer { display: flex; - width: 200px; + width: 100%; height: 50px; } .maskEditor_sidePanelLayerVisibilityContainer { @@ -314,7 +325,7 @@ var styles = ` display: flex; flex-direction: column; gap: 10px; - width: 200px; + width: 100%; padding: 10px; } #canvasBackground { @@ -329,10 +340,10 @@ var styles = ` margin-top: 10px; } .maskEditor_sidePanelSeparator { - width: 200px; + width: 100%; height: 2px; background: var(--border-color); - margin-top: 5px; + margin-top: 1.5em; margin-bottom: 5px; } #maskEditor_pointerZone { @@ -364,14 +375,15 @@ var styles = ` } #maskEditor_uiHorizontalContainer { width: 100%; - height: 100%; + height: calc(100% - var(--mask-editor-top-bar-height)); display: flex; } #maskEditor_topBar { display: flex; - height: 44px; + height: var(--mask-editor-top-bar-height); align-items: center; background: var(--comfy-menu-bg); + flex-shrink: 0; } #maskEditor_topBarTitle { margin: 0; @@ -385,7 +397,7 @@ var styles = ` margin-right: 0.5rem; position: absolute; right: 0; - width: 200px; + width: 100%; } #maskEditor_topBarShortcutsContainer { display: flex; @@ -523,6 +535,7 @@ var styles = ` display: flex; flex-direction: column; gap: 12px; + padding-bottom: 12px; } .maskEditor_sidePanelContainerRow { @@ -669,7 +682,7 @@ var styles = ` .maskEditor_layerRow { height: 50px; - width: 200px; + width: 100%; border-radius: 10px; } @@ -747,10 +760,28 @@ enum BrushShape { } enum Tools { - Pen = 'pen', + MaskPen = 'pen', + PaintPen = 'rgbPaint', Eraser = 'eraser', - PaintBucket = 'paintBucket', - ColorSelect = 'colorSelect' + MaskBucket = 'paintBucket', + MaskColorFill = 'colorSelect' +} + +const allTools = [ + Tools.MaskPen, + Tools.PaintPen, + Tools.Eraser, + Tools.MaskBucket, + Tools.MaskColorFill +] + +const allImageLayers = ['mask', 'rgb'] as const +type ImageLayer = (typeof allImageLayers)[number] + +interface ToolInternalSettings { + container: HTMLElement + cursor?: string + newActiveLayerOnSet?: ImageLayer } enum CompositionOperation { @@ -956,181 +987,138 @@ class MaskEditorDialog extends ComfyDialog { } async save() { - const backupCanvas = document.createElement('canvas') const imageCanvas = this.uiManager.getImgCanvas() const maskCanvas = this.uiManager.getMaskCanvas() + const maskCanvasCtx = getCanvas2dContext(maskCanvas) + const paintCanvas = this.uiManager.getRgbCanvas() const image = this.uiManager.getImage() - const backupCtx = backupCanvas.getContext('2d', { - willReadFrequently: true - }) - - backupCanvas.width = imageCanvas.width - backupCanvas.height = imageCanvas.height - - if (!backupCtx) { - return - } - - // Ensure the mask image is fully loaded - const maskImageLoaded = new Promise((resolve, reject) => { - const maskImage = new Image() - maskImage.src = maskCanvas.toDataURL() - maskImage.onload = () => { - resolve() - } - maskImage.onerror = (error) => { - reject(error) - } - }) try { - await maskImageLoaded + await ensureImageFullyLoaded(maskCanvas.toDataURL()) } catch (error) { console.error('Error loading mask image:', error) return } - backupCtx.clearRect(0, 0, backupCanvas.width, backupCanvas.height) - backupCtx.drawImage( - maskCanvas, + const unrefinedMaskImageData = maskCanvasCtx.getImageData( 0, 0, maskCanvas.width, - maskCanvas.height, - 0, - 0, - backupCanvas.width, - backupCanvas.height + maskCanvas.height ) - let maskHasContent = false - const maskData = backupCtx.getImageData( - 0, - 0, - backupCanvas.width, - backupCanvas.height + const refinedMaskOnlyData = new ImageData( + removeImageRgbValuesAndInvertAlpha(unrefinedMaskImageData.data), + unrefinedMaskImageData.width, + unrefinedMaskImageData.height ) - for (let i = 0; i < maskData.data.length; i += 4) { - if (maskData.data[i + 3] !== 0) { - maskHasContent = true - break - } + // We create an undisplayed copy so as not to alter the original--displayed--canvas + const [refinedMaskCanvas, refinedMaskCanvasCtx] = + createCanvasCopy(maskCanvas) + refinedMaskCanvasCtx.globalCompositeOperation = + CompositionOperation.SourceOver + refinedMaskCanvasCtx.putImageData(refinedMaskOnlyData, 0, 0) + + const timestamp = Math.round(performance.now()) + const filenames = imageLayerFilenamesByTimestamp(timestamp) + const refs = { + maskedImage: toRef(filenames.maskedImage), + paint: toRef(filenames.paint), + paintedImage: toRef(filenames.paintedImage), + paintedMaskedImage: toRef(filenames.paintedMaskedImage) } - // paste mask data into alpha channel - const backupData = backupCtx.getImageData( - 0, - 0, - backupCanvas.width, - backupCanvas.height - ) + const [paintedImageCanvas] = combineOriginalImageAndPaint({ + originalImage: imageCanvas, + paint: paintCanvas + }) - let backupHasContent = false - for (let i = 0; i < backupData.data.length; i += 4) { - if (backupData.data[i + 3] !== 0) { - backupHasContent = true - break - } - } + replaceClipspaceImages(refs.paintedMaskedImage, [refs.paint]) - if (maskHasContent && !backupHasContent) { - console.error('Mask appears to be empty') - alert('Cannot save empty mask') - return - } - - // refine mask image - for (let i = 0; i < backupData.data.length; i += 4) { - const alpha = backupData.data[i + 3] - backupData.data[i] = 0 - backupData.data[i + 1] = 0 - backupData.data[i + 2] = 0 - backupData.data[i + 3] = 255 - alpha - } - - backupCtx.globalCompositeOperation = CompositionOperation.SourceOver - backupCtx.putImageData(backupData, 0, 0) - - const formData = new FormData() - const filename = 'clipspace-mask-' + performance.now() + '.png' - - const item = { - filename: filename, - subfolder: 'clipspace', - type: 'input' - } - - if (ComfyApp?.clipspace?.widgets?.length) { - const index = ComfyApp.clipspace.widgets.findIndex( - (obj) => obj?.name === 'image' - ) - - if (index >= 0 && item !== undefined) { - try { - ComfyApp.clipspace.widgets[index].value = item - } catch (err) { - console.warn('Failed to set widget value:', err) - } - } - } - - const dataURL = backupCanvas.toDataURL() - const blob = this.dataURLToBlob(dataURL) - - let original_url = new URL(image.src) - - type Ref = { filename: string; subfolder?: string; type?: string } + const originalImageUrl = new URL(image.src) this.uiManager.setBrushOpacity(0) - const filenameRef = original_url.searchParams.get('filename') - if (!filenameRef) { - throw new Error('filename parameter is required') - } - const original_ref: Ref = { - filename: filenameRef + const originalImageFilename = originalImageUrl.searchParams.get('filename') + if (!originalImageFilename) + throw new Error( + "Expected original image URL to have a `filename` query parameter, but couldn't find it." + ) + + const originalImageRef: Partial = { + filename: originalImageFilename, + subfolder: originalImageUrl.searchParams.get('subfolder') ?? undefined, + type: originalImageUrl.searchParams.get('type') ?? undefined } - let original_subfolder = original_url.searchParams.get('subfolder') - if (original_subfolder) original_ref.subfolder = original_subfolder + const mkFormData = ( + blob: Blob, + filename: string, + originalImageRefOverride?: Partial + ) => { + const formData = new FormData() + formData.append('image', blob, filename) + formData.append( + 'original_ref', + JSON.stringify(originalImageRefOverride ?? originalImageRef) + ) + formData.append('type', 'input') + formData.append('subfolder', 'clipspace') + return formData + } - let original_type = original_url.searchParams.get('type') - if (original_type) original_ref.type = original_type + const canvasToFormData = ( + canvas: HTMLCanvasElement, + filename: string, + originalImageRefOverride?: Partial + ) => { + const blob = this.dataURLToBlob(canvas.toDataURL()) + return mkFormData(blob, filename, originalImageRefOverride) + } - formData.append('image', blob, filename) - formData.append('original_ref', JSON.stringify(original_ref)) - formData.append('type', 'input') - formData.append('subfolder', 'clipspace') + const formDatas = { + // Note: this canvas only contains mask data (no image), but during the upload process, the backend combines the mask with the original_image. Refer to the backend repo's `server.py`, search for `@routes.post("/upload/mask")` + maskedImage: canvasToFormData(refinedMaskCanvas, filenames.maskedImage), + paint: canvasToFormData(paintCanvas, filenames.paint), + paintedImage: canvasToFormData( + paintedImageCanvas, + filenames.paintedImage + ), + paintedMaskedImage: canvasToFormData( + refinedMaskCanvas, + filenames.paintedMaskedImage, + refs.paintedImage + ) + } this.uiManager.setSaveButtonText(t('g.saving')) this.uiManager.setSaveButtonEnabled(false) this.keyboardManager.removeListeners() - // Retry mechanism - const maxRetries = 3 - let attempt = 0 - let success = false + try { + await this.uploadMask( + refs.maskedImage, + formDatas.maskedImage, + 'selectedIndex' + ) + await this.uploadImage(refs.paint, formDatas.paint) + await this.uploadImage(refs.paintedImage, formDatas.paintedImage, false) - while (attempt < maxRetries && !success) { - try { - await this.uploadMask(item, formData) - success = true - } catch (error) { - console.error(`Upload attempt ${attempt + 1} failed:`, error) - attempt++ - if (attempt < maxRetries) { - console.log('Retrying upload...') - } else { - console.log('Max retries reached. Upload failed.') - } - } - } + // IMPORTANT: We using `uploadMask` here, because the backend combines the mask with the painted image during the upload process. We do NOT want to combine the mask with the original image on the frontend, because the spec for CanvasRenderingContext2D does not allow for setting pixels to transparent while preserving their RGB values. + // See: + // It is possible that WebGL contexts can achieve this, but WebGL is extremely complex, and the backend functionality is here for this purpose! + // Refer to the backend repo's `server.py`, search for `@routes.post("/upload/mask")` + await this.uploadMask( + refs.paintedMaskedImage, + formDatas.paintedMaskedImage, + 'combinedIndex' + ) - if (success) { ComfyApp.onClipspaceEditorSave() this.destroy() - } else { + } catch (error) { + console.error('Error during upload:', error) this.uiManager.setSaveButtonText(t('g.save')) this.uiManager.setSaveButtonEnabled(true) this.keyboardManager.addListeners() @@ -1154,46 +1142,36 @@ class MaskEditorDialog extends ComfyDialog { return new Blob([arrayBuffer], { type: contentType }) } - private async uploadMask( - filepath: { filename: string; subfolder: string; type: string }, + private async uploadImage( + filepath: Ref, formData: FormData, - retries = 3 + isPaintLayer = true ) { - if (retries <= 0) { - throw new Error('Max retries reached') - return - } - await api - .fetchApi('/upload/mask', { + const success = await requestWithRetries(() => + api.fetchApi('/upload/image', { method: 'POST', body: formData }) - .then((response) => { - if (!response.ok) { - console.log('Failed to upload mask:', response) - this.uploadMask(filepath, formData, retries - 1) - } - }) - .catch((error) => { - console.error('Error:', error) - }) + ) + if (!success) { + throw new Error('Upload failed.') + } + if (!isPaintLayer) { + ClipspaceDialog.invalidatePreview() + return success + } try { - const selectedIndex = ComfyApp.clipspace?.selectedIndex - if (ComfyApp.clipspace?.imgs && selectedIndex !== undefined) { + const paintedIndex = ComfyApp.clipspace?.paintedIndex + if (ComfyApp.clipspace?.imgs && paintedIndex !== undefined) { // Create and set new image const newImage = new Image() - newImage.src = api.apiURL( - '/view?' + - new URLSearchParams(filepath).toString() + - app.getPreviewFormatParam() + - app.getRandParam() - ) - ComfyApp.clipspace.imgs[selectedIndex] = newImage + newImage.src = mkFileUrl({ ref: filepath, preview: true }) + ComfyApp.clipspace.imgs[paintedIndex] = newImage // Update images array if it exists if (ComfyApp.clipspace.images) { - ComfyApp.clipspace.images[selectedIndex] = filepath + ComfyApp.clipspace.images[paintedIndex] = filepath } } } catch (err) { @@ -1201,6 +1179,46 @@ class MaskEditorDialog extends ComfyDialog { } ClipspaceDialog.invalidatePreview() } + + private async uploadMask( + filepath: Ref, + formData: FormData, + clipspaceLocation: 'selectedIndex' | 'combinedIndex' + ) { + const success = await requestWithRetries(() => + api.fetchApi('/upload/mask', { + method: 'POST', + body: formData + }) + ) + if (!success) { + throw new Error('Upload failed.') + } + + try { + const nameOfIndexToSaveTo = ( + { + selectedIndex: 'selectedIndex', + combinedIndex: 'combinedIndex' + } as const + )[clipspaceLocation] + if (!nameOfIndexToSaveTo) return + const indexToSaveTo = ComfyApp.clipspace?.[nameOfIndexToSaveTo] + if (!ComfyApp.clipspace?.imgs || indexToSaveTo === undefined) return + // Create and set new image + const newImage = new Image() + newImage.src = mkFileUrl({ ref: filepath, preview: true }) + ComfyApp.clipspace.imgs[indexToSaveTo] = newImage + + // Update images array if it exists + if (ComfyApp.clipspace.images) { + ComfyApp.clipspace.images[indexToSaveTo] = filepath + } + } catch (err) { + console.warn('Failed to update clipspace image:', err) + } + ClipspaceDialog.invalidatePreview() + } } class CanvasHistory { @@ -1210,7 +1228,9 @@ class CanvasHistory { private canvas!: HTMLCanvasElement private ctx!: CanvasRenderingContext2D - private states: ImageData[] = [] + private rgbCanvas!: HTMLCanvasElement + private rgbCtx!: CanvasRenderingContext2D + private states: { mask: ImageData; rgb: ImageData }[] = [] private currentStateIndex: number = -1 private maxStates: number = 20 private initialized: boolean = false @@ -1225,6 +1245,8 @@ class CanvasHistory { private async pullCanvas() { this.canvas = await this.messageBroker.pull('maskCanvas') this.ctx = await this.messageBroker.pull('maskCtx') + this.rgbCanvas = await this.messageBroker.pull('rgbCanvas') + this.rgbCtx = await this.messageBroker.pull('rgbCtx') } private createListeners() { @@ -1241,21 +1263,31 @@ class CanvasHistory { async saveInitialState() { await this.pullCanvas() - if (!this.canvas.width || !this.canvas.height) { + if ( + !this.canvas.width || + !this.canvas.height || + !this.rgbCanvas.width || + !this.rgbCanvas.height + ) { // Canvas not ready yet, defer initialization requestAnimationFrame(() => this.saveInitialState()) return } this.clearStates() - const state = this.ctx.getImageData( + const maskState = this.ctx.getImageData( 0, 0, this.canvas.width, this.canvas.height ) - - this.states.push(state) + const rgbState = this.rgbCtx.getImageData( + 0, + 0, + this.rgbCanvas.width, + this.rgbCanvas.height + ) + this.states.push({ mask: maskState, rgb: rgbState }) this.currentStateIndex = 0 this.initialized = true } @@ -1268,13 +1300,19 @@ class CanvasHistory { } this.states = this.states.slice(0, this.currentStateIndex + 1) - const state = this.ctx.getImageData( + const maskState = this.ctx.getImageData( 0, 0, this.canvas.width, this.canvas.height ) - this.states.push(state) + const rgbState = this.rgbCtx.getImageData( + 0, + 0, + this.rgbCanvas.width, + this.rgbCanvas.height + ) + this.states.push({ mask: maskState, rgb: rgbState }) this.currentStateIndex++ if (this.states.length > this.maxStates) { @@ -1304,9 +1342,10 @@ class CanvasHistory { } } - restoreState(state: ImageData) { + restoreState(state: { mask: ImageData; rgb: ImageData }) { if (state && this.initialized) { - this.ctx.putImageData(state, 0, 0) + this.ctx.putImageData(state.mask, 0, 0) + this.rgbCtx.putImageData(state.rgb, 0, 0) } } } @@ -2007,6 +2046,7 @@ class BrushTool { smoothingCordsArray: Point[] = [] smoothingLastDrawTime!: Date maskCtx: CanvasRenderingContext2D | null = null + rgbCtx: CanvasRenderingContext2D | null = null initialDraw: boolean = true brushStrokeCanvas: HTMLCanvasElement | null = null @@ -2022,6 +2062,9 @@ class BrushTool { maskEditor: MaskEditorDialog messageBroker: MessageBroker + private rgbColor: string = '#FF0000' // Default color + private activeLayer: ImageLayer = 'mask' + constructor(maskEditor: MaskEditorDialog) { this.maskEditor = maskEditor this.messageBroker = maskEditor.getMessageBroker() @@ -2065,10 +2108,17 @@ class BrushTool { this.messageBroker.subscribe('setBrushShape', (type: BrushShape) => this.setBrushType(type) ) + this.messageBroker.subscribe( + 'setActiveLayer', + (layer: ImageLayer) => (this.activeLayer = layer) + ) this.messageBroker.subscribe( 'setBrushSmoothingPrecision', (precision: number) => this.setBrushSmoothingPrecision(precision) ) + this.messageBroker.subscribe('setRGBColor', (color: string) => { + this.rgbColor = color + }) //brush adjustment this.messageBroker.subscribe( 'brushAdjustmentStart', @@ -2149,7 +2199,6 @@ class BrushTool { compositionOp = CompositionOperation.SourceOver //pen } - //check if user wants to draw line or free draw if (event.shiftKey && this.lineStartPoint) { this.isDrawingLine = true this.drawLine(this.lineStartPoint, coords_canvas, compositionOp) @@ -2374,24 +2423,59 @@ class BrushTool { private async draw_shape(point: Point, overrideOpacity?: number) { const brushSettings: Brush = this.brushSettings const maskCtx = this.maskCtx || (await this.messageBroker.pull('maskCtx')) + const rgbCtx = this.rgbCtx || (await this.messageBroker.pull('rgbCtx')) const brushType = await this.messageBroker.pull('brushType') const maskColor = await this.messageBroker.pull('getMaskColor') const size = brushSettings.size - const sliderOpacity = brushSettings.opacity + const brushSettingsSliderOpacity = brushSettings.opacity const opacity = - overrideOpacity == undefined ? sliderOpacity : overrideOpacity + overrideOpacity == undefined + ? brushSettingsSliderOpacity + : overrideOpacity const hardness = brushSettings.hardness - const x = point.x const y = point.y - // Extend the gradient radius beyond the brush size const extendedSize = size * (2 - hardness) - let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, extendedSize) - const isErasing = maskCtx.globalCompositeOperation === 'destination-out' + const currentTool = await this.messageBroker.pull('currentTool') + // handle paint pen + if ( + this.activeLayer === 'rgb' && + (currentTool === Tools.Eraser || currentTool === Tools.PaintPen) + ) { + const rgbaColor = this.formatRgba(this.rgbColor, opacity) + let gradient = rgbCtx.createRadialGradient(x, y, 0, x, y, extendedSize) + if (hardness === 1) { + gradient.addColorStop(0, rgbaColor) + gradient.addColorStop( + 1, + this.formatRgba(this.rgbColor, brushSettingsSliderOpacity) + ) + } else { + gradient.addColorStop(0, rgbaColor) + gradient.addColorStop(hardness, rgbaColor) + gradient.addColorStop(1, this.formatRgba(this.rgbColor, 0)) + } + rgbCtx.fillStyle = gradient + rgbCtx.beginPath() + if (brushType === BrushShape.Rect) { + rgbCtx.rect( + x - extendedSize, + y - extendedSize, + extendedSize * 2, + extendedSize * 2 + ) + } else { + rgbCtx.arc(x, y, extendedSize, 0, Math.PI * 2, false) + } + rgbCtx.fill() + return + } + + let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, extendedSize) if (hardness === 1) { gradient.addColorStop( 0, @@ -2450,15 +2534,28 @@ class BrushTool { maskCtx.fill() } + private formatRgba(hex: string, alpha: number): string { + const { r, g, b } = hexToRgb(hex) + return `rgba(${r}, ${g}, ${b}, ${alpha})` + } + private async init_shape(compositionOperation: CompositionOperation) { const maskBlendMode = await this.messageBroker.pull('maskBlendMode') const maskCtx = this.maskCtx || (await this.messageBroker.pull('maskCtx')) + const rgbCtx = this.rgbCtx || (await this.messageBroker.pull('rgbCtx')) + maskCtx.beginPath() + rgbCtx.beginPath() + + // For both contexts, set the composite operation based on the passed parameter + // This ensures right-click always works for erasing if (compositionOperation == CompositionOperation.SourceOver) { maskCtx.fillStyle = maskBlendMode maskCtx.globalCompositeOperation = CompositionOperation.SourceOver + rgbCtx.globalCompositeOperation = CompositionOperation.SourceOver } else if (compositionOperation == CompositionOperation.DestinationOut) { maskCtx.globalCompositeOperation = CompositionOperation.DestinationOut + rgbCtx.globalCompositeOperation = CompositionOperation.DestinationOut } } @@ -2541,8 +2638,10 @@ class UIManager { private brush!: HTMLDivElement private brushPreviewGradient!: HTMLDivElement private maskCtx!: CanvasRenderingContext2D + private rgbCtx!: CanvasRenderingContext2D private imageCtx!: CanvasRenderingContext2D private maskCanvas!: HTMLCanvasElement + private rgbCanvas!: HTMLCanvasElement private imgCanvas!: HTMLCanvasElement private brushSettingsHTML!: HTMLDivElement private paintBucketSettingsHTML!: HTMLDivElement @@ -2562,13 +2661,28 @@ class UIManager { private canvasBackground!: HTMLDivElement private canvasContainer!: HTMLDivElement private image!: HTMLImageElement + private paint_image!: HTMLImageElement private imageURL!: URL private darkMode: boolean = true + private maskLayerContainer: HTMLElement | null = null + private paintLayerContainer: HTMLElement | null = null + + private createColorPicker(): HTMLInputElement { + const colorPicker = document.createElement('input') + colorPicker.type = 'color' + colorPicker.id = 'maskEditor_colorPicker' + colorPicker.value = '#FF0000' // Default color + colorPicker.addEventListener('input', (event) => { + const color = (event.target as HTMLInputElement).value + this.messageBroker.publish('setRGBColor', color) + }) + return colorPicker + } private maskEditor: MaskEditorDialog private messageBroker: MessageBroker - private mask_opacity: number = 1.0 + private mask_opacity: number = 0.8 private maskBlendMode: MaskBlendMode = MaskBlendMode.Black private zoomTextHTML!: HTMLSpanElement @@ -2620,6 +2734,8 @@ class UIManager { this.messageBroker.createPullTopic('maskCtx', async () => this.maskCtx) this.messageBroker.createPullTopic('imageCtx', async () => this.imageCtx) this.messageBroker.createPullTopic('imgCanvas', async () => this.imgCanvas) + this.messageBroker.createPullTopic('rgbCtx', async () => this.rgbCtx) + this.messageBroker.createPullTopic('rgbCanvas', async () => this.rgbCanvas) this.messageBroker.createPullTopic( 'screenToCanvas', async (coords: Point) => this.screenToCanvas(coords) @@ -2681,22 +2797,32 @@ class UIManager { const maskCanvas = document.createElement('canvas') maskCanvas.id = 'maskCanvas' + const rgbCanvas = document.createElement('canvas') + rgbCanvas.id = 'rgbCanvas' + const canvas_background = document.createElement('div') canvas_background.id = 'canvasBackground' canvasContainer.appendChild(imgCanvas) + canvasContainer.appendChild(rgbCanvas) canvasContainer.appendChild(maskCanvas) canvasContainer.appendChild(canvas_background) // prepare content this.imgCanvas = imgCanvas! + this.rgbCanvas = rgbCanvas! this.maskCanvas = maskCanvas! this.canvasContainer = canvasContainer! this.canvasBackground = canvas_background! + let maskCtx = maskCanvas!.getContext('2d', { willReadFrequently: true }) if (maskCtx) { this.maskCtx = maskCtx } + let rgbCtx = rgbCanvas.getContext('2d', { willReadFrequently: true }) + if (rgbCtx) { + this.rgbCtx = rgbCtx + } let imgCtx = imgCanvas!.getContext('2d', { willReadFrequently: true }) if (imgCtx) { this.imageCtx = imgCtx @@ -2706,11 +2832,15 @@ class UIManager { //remove styling and move to css file this.imgCanvas.style.position = 'absolute' + this.rgbCanvas.style.position = 'absolute' this.maskCanvas.style.position = 'absolute' this.imgCanvas.style.top = '200' this.imgCanvas.style.left = '0' + this.rgbCanvas.style.top = this.imgCanvas.style.top + this.rgbCanvas.style.left = this.imgCanvas.style.left + this.maskCanvas.style.top = this.imgCanvas.style.top this.maskCanvas.style.left = this.imgCanvas.style.left @@ -2747,8 +2877,10 @@ class UIManager { } private async createSidePanel() { - const side_panel = this.createContainer(true) - side_panel.id = 'maskEditor_sidePanel' + const sidePanelWrapper = this.createContainer(true) + const side_panel = document.createElement('div') + sidePanelWrapper.id = 'maskEditor_sidePanel' + side_panel.id = 'maskEditor_sidePanelContent' const brush_settings = await this.createBrushSettings() brush_settings.id = 'maskEditor_brushSettings' @@ -2771,8 +2903,9 @@ class UIManager { side_panel.appendChild(color_select_settings) side_panel.appendChild(separator) side_panel.appendChild(image_layer_settings) + sidePanelWrapper.appendChild(side_panel) - return side_panel + return sidePanelWrapper } private async createBrushSettings() { @@ -2895,18 +3028,18 @@ class UIManager { resetBrushSettingsButton.addEventListener('click', () => { this.messageBroker.publish('setBrushShape', BrushShape.Arc) - this.messageBroker.publish('setBrushSize', 10) - this.messageBroker.publish('setBrushOpacity', 0.7) + this.messageBroker.publish('setBrushSize', 20) + this.messageBroker.publish('setBrushOpacity', 1) this.messageBroker.publish('setBrushHardness', 1) - this.messageBroker.publish('setBrushSmoothingPrecision', 10) + this.messageBroker.publish('setBrushSmoothingPrecision', 60) circle_shape.style.background = 'var(--p-button-text-primary-color)' square_shape.style.background = '' - thicknesSliderObj.slider.value = '10' - opacitySliderObj.slider.value = '0.7' + thicknesSliderObj.slider.value = '20' + opacitySliderObj.slider.value = '1' hardnessSliderObj.slider.value = '1' - brushSmoothingPrecisionSliderObj.slider.value = '10' + brushSmoothingPrecisionSliderObj.slider.value = '60' this.setBrushBorderRadius() this.updateBrushPreview() @@ -2915,6 +3048,23 @@ class UIManager { brush_settings_container.appendChild(brush_settings_title) brush_settings_container.appendChild(resetBrushSettingsButton) brush_settings_container.appendChild(brush_shape_outer_container) + + // Create a new container for the color picker and its title + const color_picker_container = this.createContainer(true) + + // Add the color picker title + const colorPickerTitle = document.createElement('span') + colorPickerTitle.innerText = 'Color Selector' + colorPickerTitle.classList.add('maskEditor_sidePanelSubTitle') // Mimic brush shape title style + color_picker_container.appendChild(colorPickerTitle) + + // Add the color picker + const colorPicker = this.createColorPicker() + color_picker_container.appendChild(colorPicker) + + // Add the color picker container to the main settings container + brush_settings_container.appendChild(color_picker_container) + brush_settings_container.appendChild(thicknesSliderObj.container) brush_settings_container.appendChild(opacitySliderObj.container) brush_settings_container.appendChild(hardnessSliderObj.container) @@ -3058,6 +3208,78 @@ class UIManager { return color_select_settings_container } + activeLayer: 'mask' | 'rgb' = 'mask' + layerButtons: Record = { + mask: (() => { + const btn = document.createElement('button') + btn.style.fontSize = '12px' + return btn + })(), + rgb: (() => { + const btn = document.createElement('button') + btn.style.fontSize = '12px' + return btn + })() + } + updateButtonsVisibility() { + allImageLayers.forEach((layer) => { + const button = this.layerButtons[layer] + if (layer === this.activeLayer) { + button.style.opacity = '0.5' + button.disabled = true + } else { + button.style.opacity = '1' + button.disabled = false + } + }) + } + + async updateLayerButtonsForTool() { + const currentTool = await this.messageBroker.pull('currentTool') + const isEraserTool = currentTool === Tools.Eraser + + // Show/hide buttons based on whether eraser tool is active + Object.values(this.layerButtons).forEach((button) => { + if (isEraserTool) { + button.style.display = 'block' + } else { + button.style.display = 'none' + } + }) + } + + async setActiveLayer(layer: 'mask' | 'rgb') { + this.messageBroker.publish('setActiveLayer', layer) + this.activeLayer = layer + this.updateButtonsVisibility() + const currentTool = await this.messageBroker.pull('currentTool') + const maskOnlyTools = [Tools.MaskPen, Tools.MaskBucket, Tools.MaskColorFill] + if (maskOnlyTools.includes(currentTool) && layer === 'rgb') { + this.setToolTo(Tools.PaintPen) + } + if (currentTool === Tools.PaintPen && layer === 'mask') { + this.setToolTo(Tools.MaskPen) + } + this.updateActiveLayerHighlight() + } + + updateActiveLayerHighlight() { + // Remove blue border from all containers + if (this.maskLayerContainer) { + this.maskLayerContainer.style.border = 'none' + } + if (this.paintLayerContainer) { + this.paintLayerContainer.style.border = 'none' + } + + // Add blue border to active layer container + if (this.activeLayer === 'mask' && this.maskLayerContainer) { + this.maskLayerContainer.style.border = '2px solid #007acc' + } else if (this.activeLayer === 'rgb' && this.paintLayerContainer) { + this.paintLayerContainer.style.border = '2px solid #007acc' + } + } + private async createImageLayerSettings() { const accentColor = this.darkMode ? 'maskEditor_accent_bg_dark' @@ -3069,10 +3291,29 @@ class UIManager { t('maskEditor.Layers') ) - const mask_layer_title = this.createContainerTitle( - t('maskEditor.Mask Layer') - ) + // Add a new container for layer selection + const layer_selection_container = this.createContainer(false) + layer_selection_container.classList.add(accentColor) + layer_selection_container.classList.add('maskEditor_layerRow') + this.layerButtons.mask.innerText = 'Activate Layer' + this.layerButtons.mask.addEventListener('click', async () => { + this.setActiveLayer('mask') + }) + + this.layerButtons.rgb.innerText = 'Activate Layer' + this.layerButtons.rgb.addEventListener('click', async () => { + this.setActiveLayer('rgb') + }) + + // Initially hide the buttons (they'll be shown when eraser tool is selected) + this.layerButtons.mask.style.display = 'none' + this.layerButtons.rgb.style.display = 'none' + + this.setActiveLayer('mask') + + // 1. MASK LAYER CONTAINER + const mask_layer_title = this.createContainerTitle('Mask Layer') const mask_layer_container = this.createContainer(false) mask_layer_container.classList.add(accentColor) mask_layer_container.classList.add('maskEditor_layerRow') @@ -3087,7 +3328,7 @@ class UIManager { if (!(event.target as HTMLInputElement)!.checked) { this.maskCanvas.style.opacity = '0' } else { - this.maskCanvas.style.opacity = String(this.mask_opacity) //change name + this.maskCanvas.style.opacity = String(this.mask_opacity) } }) @@ -3096,17 +3337,32 @@ class UIManager { 'maskEditor_sidePanelLayerPreviewContainer' ) mask_layer_image_container.innerHTML = - ' ' + ' ' + // Add checkbox, image container, and activate button to mask layer container + mask_layer_container.appendChild(mask_layer_visibility_checkbox) + mask_layer_container.appendChild(mask_layer_image_container) + mask_layer_container.appendChild(this.layerButtons.mask) + + // Store reference to container for highlighting + this.maskLayerContainer = mask_layer_container + + // 2. MASK BLENDING OPTIONS CONTAINER + const mask_blending_options_title = this.createContainerTitle( + 'Mask Blending Options' + ) + const mask_blending_options_container = this.createContainer(false) + // mask_blending_options_container.classList.add(accentColor) + mask_blending_options_container.classList.add('maskEditor_layerRow') + mask_blending_options_container.style.marginTop = '-9px' + mask_blending_options_container.style.marginBottom = '-6px' var blending_options = ['black', 'white', 'negative'] - const sidePanelDropdownAccent = this.darkMode ? 'maskEditor_sidePanelDropdown_dark' : 'maskEditor_sidePanelDropdown_light' var mask_layer_dropdown = document.createElement('select') mask_layer_dropdown.classList.add(sidePanelDropdownAccent) - mask_layer_dropdown.classList.add(sidePanelDropdownAccent) blending_options.forEach((option) => { var option_element = document.createElement('option') option_element.value = option @@ -3125,10 +3381,12 @@ class UIManager { this.updateMaskColor() }) - mask_layer_container.appendChild(mask_layer_visibility_checkbox) - mask_layer_container.appendChild(mask_layer_image_container) - mask_layer_container.appendChild(mask_layer_dropdown) + // Center the dropdown in its container + // mask_blending_options_container.style.display = 'flex' + // mask_blending_options_container.style.justifyContent = 'center' + mask_blending_options_container.appendChild(mask_layer_dropdown) + // 3. MASK OPACITY SLIDER const mask_layer_opacity_sliderObj = this.createSlider( t('maskEditor.Mask Opacity'), 0.0, @@ -3148,21 +3406,55 @@ class UIManager { ) this.maskOpacitySlider = mask_layer_opacity_sliderObj.slider - const image_layer_title = this.createContainerTitle( - t('maskEditor.Image Layer') + // 4. PAINT LAYER CONTAINER + const paint_layer_title = this.createContainerTitle('Paint Layer') + const paint_layer_container = this.createContainer(false) + paint_layer_container.classList.add(accentColor) + paint_layer_container.classList.add('maskEditor_layerRow') + + const paint_layer_checkbox = document.createElement('input') + paint_layer_checkbox.setAttribute('type', 'checkbox') + paint_layer_checkbox.classList.add('maskEditor_sidePanelLayerCheckbox') + paint_layer_checkbox.checked = true + paint_layer_checkbox.addEventListener('change', (event) => { + if (!(event.target as HTMLInputElement)!.checked) { + this.rgbCanvas.style.opacity = '0' + } else { + this.rgbCanvas.style.opacity = '1' + } + }) + + const paint_layer_image_container = document.createElement('div') + paint_layer_image_container.classList.add( + 'maskEditor_sidePanelLayerPreviewContainer' ) + paint_layer_image_container.innerHTML = ` + + + + + ` - const image_layer_container = this.createContainer(false) - image_layer_container.classList.add(accentColor) - image_layer_container.classList.add('maskEditor_layerRow') + paint_layer_container.appendChild(paint_layer_checkbox) + paint_layer_container.appendChild(paint_layer_image_container) + paint_layer_container.appendChild(this.layerButtons.rgb) - const image_layer_visibility_checkbox = document.createElement('input') - image_layer_visibility_checkbox.setAttribute('type', 'checkbox') - image_layer_visibility_checkbox.classList.add( + // Store reference to container for highlighting + this.paintLayerContainer = paint_layer_container + + // 5. BASE IMAGE LAYER CONTAINER + const base_image_layer_title = this.createContainerTitle('Base Image Layer') + const base_image_layer_container = this.createContainer(false) + base_image_layer_container.classList.add(accentColor) + base_image_layer_container.classList.add('maskEditor_layerRow') + + const base_image_layer_visibility_checkbox = document.createElement('input') + base_image_layer_visibility_checkbox.setAttribute('type', 'checkbox') + base_image_layer_visibility_checkbox.classList.add( 'maskEditor_sidePanelLayerCheckbox' ) - image_layer_visibility_checkbox.checked = true - image_layer_visibility_checkbox.addEventListener('change', (event) => { + base_image_layer_visibility_checkbox.checked = true + base_image_layer_visibility_checkbox.addEventListener('change', (event) => { if (!(event.target as HTMLInputElement)!.checked) { this.imgCanvas.style.opacity = '0' } else { @@ -3170,35 +3462,51 @@ class UIManager { } }) - const image_layer_image_container = document.createElement('div') - image_layer_image_container.classList.add( + const base_image_layer_image_container = document.createElement('div') + base_image_layer_image_container.classList.add( 'maskEditor_sidePanelLayerPreviewContainer' ) - const image_layer_image = document.createElement('img') - image_layer_image.id = 'maskEditor_sidePanelImageLayerImage' - image_layer_image.src = + const base_image_layer_image = document.createElement('img') + base_image_layer_image.id = 'maskEditor_sidePanelImageLayerImage' + base_image_layer_image.src = ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace?.selectedIndex ?? 0]?.src ?? '' - this.sidebarImage = image_layer_image + this.sidebarImage = base_image_layer_image - image_layer_image_container.appendChild(image_layer_image) + base_image_layer_image_container.appendChild(base_image_layer_image) - image_layer_container.appendChild(image_layer_visibility_checkbox) - image_layer_container.appendChild(image_layer_image_container) + base_image_layer_container.appendChild(base_image_layer_visibility_checkbox) + base_image_layer_container.appendChild(base_image_layer_image_container) + // APPEND ALL CONTAINERS IN ORDER image_layer_settings_container.appendChild(image_layer_settings_title) - image_layer_settings_container.appendChild(mask_layer_title) - image_layer_settings_container.appendChild(mask_layer_container) image_layer_settings_container.appendChild( mask_layer_opacity_sliderObj.container ) - image_layer_settings_container.appendChild(image_layer_title) - image_layer_settings_container.appendChild(image_layer_container) + image_layer_settings_container.appendChild(mask_blending_options_title) + image_layer_settings_container.appendChild(mask_blending_options_container) + image_layer_settings_container.appendChild(mask_layer_title) + image_layer_settings_container.appendChild(mask_layer_container) + image_layer_settings_container.appendChild(paint_layer_title) + image_layer_settings_container.appendChild(paint_layer_container) + image_layer_settings_container.appendChild(base_image_layer_title) + image_layer_settings_container.appendChild(base_image_layer_container) + + // Initialize the active layer highlighting + this.updateActiveLayerHighlight() + + // Initialize button visibility based on current tool + this.updateLayerButtonsForTool() return image_layer_settings_container } + // Method to be called when tool changes + async onToolChange() { + await this.updateLayerButtonsForTool() + } + private createHeadline(title: string) { var headline = document.createElement('h3') headline.classList.add('maskEditor_sidePanelTitle') @@ -3236,7 +3544,6 @@ class UIManager { ) { var slider_container = this.createContainer(true) var slider_title = this.createContainerTitle(title) - var slider = document.createElement('input') slider.classList.add('maskEditor_sidePanelBrushRange') slider.setAttribute('type', 'range') @@ -3392,6 +3699,7 @@ class UIManager { this.maskCanvas.width, this.maskCanvas.height ) + this.rgbCtx.clearRect(0, 0, this.rgbCanvas.width, this.rgbCanvas.height) this.messageBroker.publish('saveState') }) @@ -3427,6 +3735,56 @@ class UIManager { return top_bar } + toolElements: HTMLElement[] = [] + toolSettings: Record = { + [Tools.MaskPen]: { + container: document.createElement('div'), + newActiveLayerOnSet: 'mask' + }, + [Tools.Eraser]: { + container: document.createElement('div') + }, + [Tools.PaintPen]: { + container: document.createElement('div'), + newActiveLayerOnSet: 'rgb' + }, + [Tools.MaskBucket]: { + container: document.createElement('div'), + cursor: "url('/cursor/paintBucket.png') 30 25, auto", + newActiveLayerOnSet: 'mask' + }, + [Tools.MaskColorFill]: { + container: document.createElement('div'), + cursor: "url('/cursor/colorSelect.png') 15 25, auto", + newActiveLayerOnSet: 'mask' + } + } + + setToolTo(tool: Tools) { + this.messageBroker.publish('setTool', tool) + for (let toolElement of this.toolElements) { + if (toolElement != this.toolSettings[tool].container) { + toolElement.classList.remove('maskEditor_toolPanelContainerSelected') + } else { + toolElement.classList.add('maskEditor_toolPanelContainerSelected') + this.brushSettingsHTML.style.display = 'flex' + this.colorSelectSettingsHTML.style.display = 'none' + this.paintBucketSettingsHTML.style.display = 'none' + } + } + this.messageBroker.publish('setTool', tool) + this.onToolChange() + const newActiveLayer = this.toolSettings[tool].newActiveLayerOnSet + if (newActiveLayer) { + this.setActiveLayer(newActiveLayer) + } + const cursor = this.toolSettings[tool].cursor + this.pointerZone.style.cursor = cursor ?? 'none' + if (cursor) { + this.brush.style.opacity = '0' + } + } + private createToolPanel() { var tool_panel = document.createElement('div') tool_panel.id = 'maskEditor_toolPanel' @@ -3435,194 +3793,54 @@ class UIManager { ? 'maskEditor_toolPanelContainerDark' : 'maskEditor_toolPanelContainerLight' - var toolElements: HTMLElement[] = [] + this.toolElements = [] + // mask pen tool + const setupToolContainer = (tool: Tools) => { + this.toolSettings[tool].container = document.createElement('div') + this.toolSettings[tool].container.classList.add( + 'maskEditor_toolPanelContainer' + ) + if (tool == Tools.MaskPen) + this.toolSettings[tool].container.classList.add( + 'maskEditor_toolPanelContainerSelected' + ) + this.toolSettings[tool].container.classList.add(toolPanelHoverAccent) + this.toolSettings[tool].container.innerHTML = iconsHtml[tool] + this.toolElements.push(this.toolSettings[tool].container) + this.toolSettings[tool].container.addEventListener('click', () => { + this.setToolTo(tool) + }) + const activeIndicator = document.createElement('div') + activeIndicator.classList.add('maskEditor_toolPanelIndicator') + this.toolSettings[tool].container.appendChild(activeIndicator) + tool_panel.appendChild(this.toolSettings[tool].container) + } + allTools.forEach(setupToolContainer) - //brush tool + const setupZoomIndicatorContainer = () => { + var toolPanel_zoomIndicator = document.createElement('div') + toolPanel_zoomIndicator.classList.add('maskEditor_toolPanelZoomIndicator') + toolPanel_zoomIndicator.classList.add(toolPanelHoverAccent) - var toolPanel_brushToolContainer = document.createElement('div') - toolPanel_brushToolContainer.classList.add('maskEditor_toolPanelContainer') - toolPanel_brushToolContainer.classList.add( - 'maskEditor_toolPanelContainerSelected' - ) - toolPanel_brushToolContainer.classList.add(toolPanelHoverAccent) - toolPanel_brushToolContainer.innerHTML = ` - - - - - ` - toolElements.push(toolPanel_brushToolContainer) + var toolPanel_zoomText = document.createElement('span') + toolPanel_zoomText.id = 'maskEditor_toolPanelZoomText' + toolPanel_zoomText.innerText = '100%' + this.zoomTextHTML = toolPanel_zoomText - toolPanel_brushToolContainer.addEventListener('click', () => { - //move logic to tool manager - this.messageBroker.publish('setTool', Tools.Pen) - for (let toolElement of toolElements) { - if (toolElement != toolPanel_brushToolContainer) { - toolElement.classList.remove('maskEditor_toolPanelContainerSelected') - } else { - toolElement.classList.add('maskEditor_toolPanelContainerSelected') - this.brushSettingsHTML.style.display = 'flex' - this.colorSelectSettingsHTML.style.display = 'none' - this.paintBucketSettingsHTML.style.display = 'none' - } - } - this.messageBroker.publish('setTool', Tools.Pen) - this.pointerZone.style.cursor = 'none' - }) + var toolPanel_DimensionsText = document.createElement('span') + toolPanel_DimensionsText.id = 'maskEditor_toolPanelDimensionsText' + toolPanel_DimensionsText.innerText = ' ' + this.dimensionsTextHTML = toolPanel_DimensionsText - var toolPanel_brushToolIndicator = document.createElement('div') - toolPanel_brushToolIndicator.classList.add('maskEditor_toolPanelIndicator') + toolPanel_zoomIndicator.appendChild(toolPanel_zoomText) + toolPanel_zoomIndicator.appendChild(toolPanel_DimensionsText) - toolPanel_brushToolContainer.appendChild(toolPanel_brushToolIndicator) - - //eraser tool - - var toolPanel_eraserToolContainer = document.createElement('div') - toolPanel_eraserToolContainer.classList.add('maskEditor_toolPanelContainer') - toolPanel_eraserToolContainer.classList.add(toolPanelHoverAccent) - toolPanel_eraserToolContainer.innerHTML = ` - - - - - - - - ` - toolElements.push(toolPanel_eraserToolContainer) - - toolPanel_eraserToolContainer.addEventListener('click', () => { - //move logic to tool manager - this.messageBroker.publish('setTool', Tools.Eraser) - for (let toolElement of toolElements) { - if (toolElement != toolPanel_eraserToolContainer) { - toolElement.classList.remove('maskEditor_toolPanelContainerSelected') - } else { - toolElement.classList.add('maskEditor_toolPanelContainerSelected') - this.brushSettingsHTML.style.display = 'flex' - this.colorSelectSettingsHTML.style.display = 'none' - this.paintBucketSettingsHTML.style.display = 'none' - } - } - this.messageBroker.publish('setTool', Tools.Eraser) - this.pointerZone.style.cursor = 'none' - }) - - var toolPanel_eraserToolIndicator = document.createElement('div') - toolPanel_eraserToolIndicator.classList.add('maskEditor_toolPanelIndicator') - - toolPanel_eraserToolContainer.appendChild(toolPanel_eraserToolIndicator) - - //paint bucket tool - - var toolPanel_paintBucketToolContainer = document.createElement('div') - toolPanel_paintBucketToolContainer.classList.add( - 'maskEditor_toolPanelContainer' - ) - toolPanel_paintBucketToolContainer.classList.add(toolPanelHoverAccent) - toolPanel_paintBucketToolContainer.innerHTML = ` - - - - - - ` - toolElements.push(toolPanel_paintBucketToolContainer) - - toolPanel_paintBucketToolContainer.addEventListener('click', () => { - //move logic to tool manager - this.messageBroker.publish('setTool', Tools.PaintBucket) - for (let toolElement of toolElements) { - if (toolElement != toolPanel_paintBucketToolContainer) { - toolElement.classList.remove('maskEditor_toolPanelContainerSelected') - } else { - toolElement.classList.add('maskEditor_toolPanelContainerSelected') - this.brushSettingsHTML.style.display = 'none' - this.colorSelectSettingsHTML.style.display = 'none' - this.paintBucketSettingsHTML.style.display = 'flex' - } - } - this.messageBroker.publish('setTool', Tools.PaintBucket) - this.pointerZone.style.cursor = - "url('/cursor/paintBucket.png') 30 25, auto" - this.brush.style.opacity = '0' - }) - - var toolPanel_paintBucketToolIndicator = document.createElement('div') - toolPanel_paintBucketToolIndicator.classList.add( - 'maskEditor_toolPanelIndicator' - ) - - toolPanel_paintBucketToolContainer.appendChild( - toolPanel_paintBucketToolIndicator - ) - - //color select tool - - var toolPanel_colorSelectToolContainer = document.createElement('div') - toolPanel_colorSelectToolContainer.classList.add( - 'maskEditor_toolPanelContainer' - ) - toolPanel_colorSelectToolContainer.classList.add(toolPanelHoverAccent) - toolPanel_colorSelectToolContainer.innerHTML = ` - - - - ` - toolElements.push(toolPanel_colorSelectToolContainer) - toolPanel_colorSelectToolContainer.addEventListener('click', () => { - this.messageBroker.publish('setTool', 'colorSelect') - for (let toolElement of toolElements) { - if (toolElement != toolPanel_colorSelectToolContainer) { - toolElement.classList.remove('maskEditor_toolPanelContainerSelected') - } else { - toolElement.classList.add('maskEditor_toolPanelContainerSelected') - this.brushSettingsHTML.style.display = 'none' - this.paintBucketSettingsHTML.style.display = 'none' - this.colorSelectSettingsHTML.style.display = 'flex' - } - } - this.messageBroker.publish('setTool', Tools.ColorSelect) - this.pointerZone.style.cursor = - "url('/cursor/colorSelect.png') 15 25, auto" - this.brush.style.opacity = '0' - }) - - var toolPanel_colorSelectToolIndicator = document.createElement('div') - toolPanel_colorSelectToolIndicator.classList.add( - 'maskEditor_toolPanelIndicator' - ) - toolPanel_colorSelectToolContainer.appendChild( - toolPanel_colorSelectToolIndicator - ) - - //zoom indicator - var toolPanel_zoomIndicator = document.createElement('div') - toolPanel_zoomIndicator.classList.add('maskEditor_toolPanelZoomIndicator') - toolPanel_zoomIndicator.classList.add(toolPanelHoverAccent) - - var toolPanel_zoomText = document.createElement('span') - toolPanel_zoomText.id = 'maskEditor_toolPanelZoomText' - toolPanel_zoomText.innerText = '100%' - this.zoomTextHTML = toolPanel_zoomText - - var toolPanel_DimensionsText = document.createElement('span') - toolPanel_DimensionsText.id = 'maskEditor_toolPanelDimensionsText' - toolPanel_DimensionsText.innerText = ' ' - this.dimensionsTextHTML = toolPanel_DimensionsText - - toolPanel_zoomIndicator.appendChild(toolPanel_zoomText) - toolPanel_zoomIndicator.appendChild(toolPanel_DimensionsText) - - toolPanel_zoomIndicator.addEventListener('click', () => { - this.messageBroker.publish('resetZoom') - }) - - tool_panel.appendChild(toolPanel_brushToolContainer) - tool_panel.appendChild(toolPanel_eraserToolContainer) - tool_panel.appendChild(toolPanel_paintBucketToolContainer) - tool_panel.appendChild(toolPanel_colorSelectToolContainer) - tool_panel.appendChild(toolPanel_zoomIndicator) + toolPanel_zoomIndicator.addEventListener('click', () => { + this.messageBroker.publish('resetZoom') + }) + tool_panel.appendChild(toolPanel_zoomIndicator) + } + setupZoomIndicatorContainer() return tool_panel } @@ -3674,14 +3892,25 @@ class UIManager { } async screenToCanvas(clientPoint: Point): Promise { - // Get the bounding rectangles for both elements + // Get the zoom ratio const zoomRatio = await this.messageBroker.pull('zoomRatio') - const canvasRect = this.maskCanvas.getBoundingClientRect() + + // Get the bounding rectangles for both canvases + const maskCanvasRect = this.maskCanvas.getBoundingClientRect() + const rgbCanvasRect = this.rgbCanvas.getBoundingClientRect() + + // Check which canvas is currently being used for drawing + const currentTool = await this.messageBroker.pull('currentTool') + const isUsingRGBCanvas = currentTool === Tools.PaintPen + + // Use the appropriate canvas rect based on the current tool + const canvasRect = isUsingRGBCanvas ? rgbCanvasRect : maskCanvasRect // Calculate the offset between pointer zone and canvas const offsetX = clientPoint.x - canvasRect.left + this.toolPanel.clientWidth const offsetY = clientPoint.y - canvasRect.top + 44 // 44 is the height of the top menu + // Adjust for zoom ratio const x = offsetX / zoomRatio const y = offsetY / zoomRatio @@ -3693,6 +3922,10 @@ class UIManager { event.preventDefault() }) + this.rgbCanvas.addEventListener('contextmenu', (event: Event) => { + event.preventDefault() + }) + this.rootElement.addEventListener('contextmenu', (event: Event) => { event.preventDefault() }) @@ -3725,32 +3958,56 @@ class UIManager { const maskCtx = this.maskCtx const maskCanvas = this.maskCanvas + const rgbCanvas = this.rgbCanvas + imgCtx!.clearRect(0, 0, this.imgCanvas.width, this.imgCanvas.height) maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height) - const alpha_url = new URL( - ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace?.selectedIndex ?? 0]?.src ?? - '' - ) - alpha_url.searchParams.delete('channel') - alpha_url.searchParams.delete('preview') - alpha_url.searchParams.set('channel', 'a') - let mask_image: HTMLImageElement = await this.loadImage(alpha_url) + const mainImageUrl = + ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace?.selectedIndex ?? 0]?.src // original image load - if ( - !ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace?.selectedIndex ?? 0]?.src - ) { + if (!mainImageUrl) { throw new Error( 'Unable to access image source - clipspace or image is null' ) } - const rgb_url = new URL( - ComfyApp.clipspace.imgs[ComfyApp.clipspace.selectedIndex].src - ) + const mainImageFilename = + new URL(mainImageUrl).searchParams.get('filename') ?? undefined + + const combinedImageFilename = + ComfyApp.clipspace?.combinedIndex !== undefined && + ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace.combinedIndex]?.src + ? new URL( + ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src + ).searchParams.get('filename') + : undefined + + const imageLayerFilenames = + mainImageFilename !== undefined + ? imageLayerFilenamesIfApplicable( + combinedImageFilename ?? mainImageFilename + ) + : undefined + + const inputUrls = { + baseImagePlusMask: imageLayerFilenames?.maskedImage + ? mkFileUrl({ ref: toRef(imageLayerFilenames.maskedImage) }) + : mainImageUrl, + paintLayer: imageLayerFilenames?.paint + ? mkFileUrl({ ref: toRef(imageLayerFilenames.paint) }) + : undefined + } + + const alpha_url = new URL(inputUrls.baseImagePlusMask) + alpha_url.searchParams.delete('channel') + alpha_url.searchParams.delete('preview') + alpha_url.searchParams.set('channel', 'a') + let mask_image: HTMLImageElement = await this.loadImage(alpha_url) + + const rgb_url = new URL(inputUrls.baseImagePlusMask) this.imageURL = rgb_url - console.log(rgb_url) rgb_url.searchParams.delete('channel') rgb_url.searchParams.set('channel', 'rgb') this.image = new Image() @@ -3762,18 +4019,35 @@ class UIManager { img.src = rgb_url.toString() }) + if (inputUrls.paintLayer) { + const paintURL = new URL(inputUrls.paintLayer) + this.paint_image = new Image() + this.paint_image = await new Promise( + (resolve, reject) => { + const img = new Image() + img.onload = () => resolve(img) + img.onerror = reject + img.src = paintURL.toString() + } + ) + } + maskCanvas.width = this.image.width maskCanvas.height = this.image.height + rgbCanvas.width = this.image.width + rgbCanvas.height = this.image.height + this.dimensionsTextHTML.innerText = `${this.image.width}x${this.image.height}` - await this.invalidateCanvas(this.image, mask_image) + await this.invalidateCanvas(this.image, mask_image, this.paint_image) this.messageBroker.publish('initZoomPan', [this.image, this.rootElement]) } async invalidateCanvas( orig_image: HTMLImageElement, - mask_image: HTMLImageElement + mask_image: HTMLImageElement, + paint_image: HTMLImageElement ) { this.imgCanvas.width = orig_image.width this.imgCanvas.height = orig_image.height @@ -3781,12 +4055,27 @@ class UIManager { this.maskCanvas.width = orig_image.width this.maskCanvas.height = orig_image.height + this.rgbCanvas.width = orig_image.width + this.rgbCanvas.height = orig_image.height + let imgCtx = this.imgCanvas.getContext('2d', { willReadFrequently: true }) let maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }) + let rgbCtx = this.rgbCanvas.getContext('2d', { + willReadFrequently: true + }) imgCtx!.drawImage(orig_image, 0, 0, orig_image.width, orig_image.height) + if (paint_image) { + rgbCtx!.drawImage( + paint_image, + 0, + 0, + paint_image.width, + paint_image.height + ) + } await this.prepare_mask( mask_image, this.maskCanvas, @@ -3962,6 +4251,10 @@ class UIManager { return this.imgCanvas } + getRgbCanvas() { + return this.rgbCanvas + } + getImage() { return this.image } @@ -4005,11 +4298,11 @@ class UIManager { async updateCursor() { const currentTool = await this.messageBroker.pull('currentTool') - if (currentTool === Tools.PaintBucket) { + if (currentTool === Tools.MaskBucket) { this.pointerZone.style.cursor = "url('/cursor/paintBucket.png') 30 25, auto" this.setBrushOpacity(0) - } else if (currentTool === Tools.ColorSelect) { + } else if (currentTool === Tools.MaskColorFill) { this.pointerZone.style.cursor = "url('/cursor/colorSelect.png') 15 25, auto" this.setBrushOpacity(0) @@ -4036,7 +4329,7 @@ class ToolManager { messageBroker: MessageBroker mouseDownPoint: Point | null = null - currentTool: Tools = Tools.Pen + currentTool: Tools = Tools.MaskPen isAdjustingBrush: boolean = false // is user adjusting brush size or hardness with alt + right mouse button constructor(maskEditor: MaskEditorDialog) { @@ -4079,7 +4372,7 @@ class ToolManager { setTool(tool: Tools) { this.currentTool = tool - if (tool != Tools.ColorSelect) { + if (tool != Tools.MaskColorFill) { this.messageBroker.publish('clearLastPoint') } } @@ -4101,8 +4394,21 @@ class ToolManager { return } + // RGB painting + if (this.currentTool === Tools.PaintPen && event.button === 0) { + this.messageBroker.publish('drawStart', event) + this.messageBroker.publish('saveState') + return + } + + // RGB painting + if (this.currentTool === Tools.PaintPen && event.buttons === 1) { + this.messageBroker.publish('draw', event) + return + } + //paint bucket - if (this.currentTool === Tools.PaintBucket && event.button === 0) { + if (this.currentTool === Tools.MaskBucket && event.button === 0) { const offset = { x: event.offsetX, y: event.offsetY } const coords_canvas = await this.messageBroker.pull( 'screenToCanvas', @@ -4113,7 +4419,7 @@ class ToolManager { return } - if (this.currentTool === Tools.ColorSelect && event.button === 0) { + if (this.currentTool === Tools.MaskColorFill && event.button === 0) { const offset = { x: event.offsetX, y: event.offsetY } const coords_canvas = await this.messageBroker.pull( 'screenToCanvas', @@ -4130,7 +4436,9 @@ class ToolManager { return } - var isDrawingTool = [Tools.Pen, Tools.Eraser].includes(this.currentTool) + var isDrawingTool = [Tools.MaskPen, Tools.Eraser, Tools.PaintPen].includes( + this.currentTool + ) //drawing if ([0, 2].includes(event.button) && isDrawingTool) { this.messageBroker.publish('drawStart', event) @@ -4155,13 +4463,16 @@ class ToolManager { //prevent drawing with other tools - var isDrawingTool = [Tools.Pen, Tools.Eraser].includes(this.currentTool) + var isDrawingTool = [Tools.MaskPen, Tools.Eraser, Tools.PaintPen].includes( + this.currentTool + ) if (!isDrawingTool) return // alt + right mouse button hold brush adjustment if ( this.isAdjustingBrush && - (this.currentTool === Tools.Pen || this.currentTool === Tools.Eraser) && + (this.currentTool === Tools.MaskPen || + this.currentTool === Tools.Eraser) && event.altKey && event.buttons === 2 ) { @@ -4213,6 +4524,7 @@ class PanAndZoomManager { canvasContainer: HTMLElement | null = null maskCanvas: HTMLCanvasElement | null = null + rgbCanvas: HTMLCanvasElement | null = null rootElement: HTMLElement | null = null image: HTMLImageElement | null = null @@ -4641,6 +4953,22 @@ class PanAndZoomManager { left: `${this.pan_offset.x}px`, top: `${this.pan_offset.y}px` }) + + this.rgbCanvas = await this.messageBroker.pull('rgbCanvas') + if (this.rgbCanvas) { + // Ensure the canvas has the proper dimensions + if ( + this.rgbCanvas.width !== this.image.width || + this.rgbCanvas.height !== this.image.height + ) { + this.rgbCanvas.width = this.image.width + this.rgbCanvas.height = this.image.height + } + + // Make sure the style dimensions match the container + this.rgbCanvas.style.width = `${raw_width}px` + this.rgbCanvas.style.height = `${raw_height}px` + } } private handlePanStart(event: PointerEvent) { @@ -4711,6 +5039,7 @@ class MessageBroker { this.createPushTopic('setBrushShape') this.createPushTopic('initZoomPan') this.createPushTopic('setTool') + this.createPushTopic('setActiveLayer') this.createPushTopic('pointerDown') this.createPushTopic('pointerMove') this.createPushTopic('pointerUp') @@ -4734,6 +5063,8 @@ class MessageBroker { this.createPushTopic('setZoomText') this.createPushTopic('resetZoom') this.createPushTopic('invert') + this.createPushTopic('setRGBColor') + this.createPushTopic('paintedurl') this.createPushTopic('setSelectionOpacity') this.createPushTopic('setFillOpacity') } @@ -4850,6 +5181,7 @@ class MessageBroker { class KeyboardManager { private keysDown: string[] = [] + // @ts-expect-error unused variable private maskEditor: MaskEditorDialog private messageBroker: MessageBroker @@ -5005,12 +5337,23 @@ app.registerExtension({ selectedNode.previewMediaType !== 'image' ) return - ComfyApp.copyToClipspace(selectedNode) // @ts-expect-error clipspace_return_node is an extension property added at runtime ComfyApp.clipspace_return_node = selectedNode openMaskEditor() } + }, + { + id: 'Comfy.MaskEditor.BrushSize.Increase', + icon: 'pi pi-plus-circle', + label: 'Increase Brush Size in MaskEditor', + function: () => changeBrushSize((old) => _.clamp(old + 4, 1, 100)) + }, + { + id: 'Comfy.MaskEditor.BrushSize.Decrease', + icon: 'pi pi-minus-circle', + label: 'Decrease Brush Size in MaskEditor', + function: () => changeBrushSize((old) => _.clamp(old - 4, 1, 100)) } ], init() { @@ -5024,3 +5367,180 @@ app.registerExtension({ ) } }) + +const changeBrushSize = async (sizeChanger: (oldSize: number) => number) => { + if (!isOpened()) return + const maskEditor = MaskEditorDialog.getInstance() + if (!maskEditor) return + const messageBroker = maskEditor.getMessageBroker() + const oldBrushSize = (await messageBroker.pull('brushSettings')).size + const newBrushSize = sizeChanger(oldBrushSize) + messageBroker.publish('setBrushSize', newBrushSize) + messageBroker.publish('updateBrushPreview') +} + +const requestWithRetries = async ( + mkRequest: () => Promise, + maxRetries: number = 3 +): Promise<{ success: boolean }> => { + let attempt = 0 + let success = false + while (attempt < maxRetries && !success) { + try { + const response = await mkRequest() + if (response.ok) { + success = true + } else { + console.log('Failed to upload mask:', response) + } + } catch (error) { + console.error(`Upload attempt ${attempt + 1} failed:`, error) + attempt++ + if (attempt < maxRetries) { + console.log('Retrying upload...') + } else { + console.log('Max retries reached. Upload failed.') + } + } + } + return { success } +} + +const isAlphaValue = (index: number) => index % 4 === 3 + +const removeImageRgbValuesAndInvertAlpha = (imageData: Uint8ClampedArray) => + imageData.map((val, i) => (isAlphaValue(i) ? 255 - val : 0)) + +type Ref = { filename: string; subfolder?: string; type?: string } + +/** + * Note: the images' positions are important here. What the positions mean is hardcoded in `src/scripts/app.ts` in the `copyToClipspace` method. + * - `newMainOutput` should be the fully composited image: base image + mask (in the alpha channel) + paint. + * - The first array element of `extraImagesShownButNotOutputted` should be JUST the paint layer, with a transparent background. + * - It is possible to add more images in the clipspace array, but is not useful currently. + * With this configuration, the MaskEditor will properly load the paint layer separately from the base image, ensuring it is editable. + * */ +const replaceClipspaceImages = ( + newMainOutput: Ref, + otherImagesInClipspace?: Ref[] +) => { + try { + if (!ComfyApp?.clipspace?.widgets?.length) return + const firstImageWidgetIndex = ComfyApp.clipspace.widgets.findIndex( + (obj) => obj?.name === 'image' + ) + const firstImageWidget = ComfyApp.clipspace.widgets[firstImageWidgetIndex] + if (!firstImageWidget) return + + ComfyApp!.clipspace!.widgets![firstImageWidgetIndex].value = newMainOutput + + otherImagesInClipspace?.forEach((extraImage, extraImageIndex) => { + const extraImageWidgetIndex = firstImageWidgetIndex + extraImageIndex + 1 + ComfyApp!.clipspace!.widgets![extraImageWidgetIndex].value = extraImage + }) + } catch (err) { + console.warn('Failed to set widget value:', err) + } +} + +const ensureImageFullyLoaded = (src: string) => + new Promise((resolve, reject) => { + const maskImage = new Image() + maskImage.src = src + maskImage.onload = () => resolve() + maskImage.onerror = reject + }) + +const createCanvasCopy = ( + canvas: HTMLCanvasElement +): [HTMLCanvasElement, CanvasRenderingContext2D] => { + const newCanvas = document.createElement('canvas') + const newCanvasCtx = getCanvas2dContext(newCanvas) + newCanvas.width = canvas.width + newCanvas.height = canvas.height + newCanvasCtx.clearRect(0, 0, canvas.width, canvas.height) + newCanvasCtx.drawImage( + canvas, + 0, + 0, + canvas.width, + canvas.height, + 0, + 0, + canvas.width, + canvas.height + ) + return [newCanvas, newCanvasCtx] +} + +const getCanvas2dContext = ( + canvas: HTMLCanvasElement +): CanvasRenderingContext2D => { + const ctx = canvas.getContext('2d', { willReadFrequently: true }) + // Safe with the way we use canvases + if (!ctx) throw new Error('Failed to get 2D context from canvas') + return ctx +} + +const combineOriginalImageAndPaint = ( + canvases: Record<'originalImage' | 'paint', HTMLCanvasElement> +): [HTMLCanvasElement, CanvasRenderingContext2D] => { + const { originalImage, paint } = canvases + const [resultCanvas, resultCanvasCtx] = createCanvasCopy(originalImage) + resultCanvasCtx.drawImage(paint, 0, 0) + return [resultCanvas, resultCanvasCtx] +} + +const iconsHtml: Record = { + [Tools.MaskPen]: ` + + + + `, + [Tools.Eraser]: ` + + + + + + + + `, + [Tools.MaskBucket]: ` + + + + + + `, + [Tools.MaskColorFill]: ` + + + + `, + [Tools.PaintPen]: ` + + + + + ` +} + +const toRef = (filename: string): Ref => ({ + filename, + subfolder: 'clipspace', + type: 'input' +}) + +const mkFileUrl = (props: { ref: Ref; preview?: boolean }) => { + const pathPlusQueryParams = api.apiURL( + '/view?' + + new URLSearchParams(props.ref).toString() + + app.getPreviewFormatParam() + + app.getRandParam() + ) + const imageElement = new Image() + imageElement.src = pathPlusQueryParams + const fullyResolvedUrl = imageElement.src + return fullyResolvedUrl +} diff --git a/src/extensions/core/saveImageExtraOutput.ts b/src/extensions/core/saveImageExtraOutput.ts index 3ea312997..77894303a 100644 --- a/src/extensions/core/saveImageExtraOutput.ts +++ b/src/extensions/core/saveImageExtraOutput.ts @@ -35,7 +35,7 @@ app.registerExtension({ // @ts-expect-error fixme ts strict error widget.serializeValue = () => { // @ts-expect-error fixme ts strict error - return applyTextReplacements(app.graph.nodes, widget.value) + return applyTextReplacements(app.graph, widget.value) } return r diff --git a/src/extensions/core/uploadAudio.ts b/src/extensions/core/uploadAudio.ts index 94a16d04d..701fc0b5d 100644 --- a/src/extensions/core/uploadAudio.ts +++ b/src/extensions/core/uploadAudio.ts @@ -1,6 +1,11 @@ import type { LGraphNode } from '@comfyorg/litegraph' -import type { IStringWidget } from '@comfyorg/litegraph/dist/types/widgets' +import type { + IBaseWidget, + IStringWidget +} from '@comfyorg/litegraph/dist/types/widgets' +import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder' +import { useChainCallback } from '@/composables/functional/useChainCallback' import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop' import { useNodeFileInput } from '@/composables/node/useNodeFileInput' import { useNodePaste } from '@/composables/node/useNodePaste' @@ -8,7 +13,10 @@ import { t } from '@/i18n' import type { ResultItemType } from '@/schemas/apiSchema' import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' import type { DOMWidget } from '@/scripts/domWidget' +import { useAudioService } from '@/services/audioService' import { useToastStore } from '@/stores/toastStore' +import { NodeLocatorId } from '@/types' +import { getNodeByLocatorId } from '@/utils/graphTraversalUtil' import { api } from '../../scripts/api' import { app } from '../../scripts/app' @@ -137,14 +145,27 @@ app.registerExtension({ audioUIWidget.element.classList.remove('empty-audio-widget') } } + + audioUIWidget.onRemove = useChainCallback( + audioUIWidget.onRemove, + () => { + if (!audioUIWidget.element) return + audioUIWidget.element.pause() + audioUIWidget.element.src = '' + audioUIWidget.element.remove() + } + ) + return { widget: audioUIWidget } } } }, - onNodeOutputsUpdated(nodeOutputs: Record) { - for (const [nodeId, output] of Object.entries(nodeOutputs)) { - const node = app.graph.getNodeById(nodeId) + onNodeOutputsUpdated(nodeOutputs: Record) { + for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) { if ('audio' in output) { + const node = getNodeByLocatorId(app.graph, nodeLocatorId) + if (!node) continue + // @ts-expect-error fixme ts strict error const audioUIWidget = node.widgets.find( (w) => w.name === 'audioUI' @@ -241,3 +262,167 @@ app.registerExtension({ } } }) + +app.registerExtension({ + name: 'Comfy.RecordAudio', + + getCustomWidgets() { + return { + AUDIO_RECORD(node, inputName: string) { + const audio = document.createElement('audio') + audio.controls = true + audio.classList.add('comfy-audio') + audio.setAttribute('name', 'media') + + const audioUIWidget: DOMWidget = + node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio) + + let mediaRecorder: MediaRecorder | null = null + let isRecording = false + let audioChunks: Blob[] = [] + let currentStream: MediaStream | null = null + let recordWidget: IBaseWidget | null = null + + let stopPromise: Promise | null = null + let stopResolve: (() => void) | null = null + + audioUIWidget.serializeValue = async () => { + if (isRecording && mediaRecorder) { + stopPromise = new Promise((resolve) => { + stopResolve = resolve + }) + + mediaRecorder.stop() + + await stopPromise + } + + const audioSrc = audioUIWidget.element.src + + if (!audioSrc) { + useToastStore().addAlert(t('g.noAudioRecorded')) + return '' + } + + const blob = await fetch(audioSrc).then((r) => r.blob()) + + return await useAudioService().convertBlobToFileAndSubmit(blob) + } + + recordWidget = node.addWidget( + 'button', + inputName, + '', + async () => { + if (!isRecording) { + try { + currentStream = await navigator.mediaDevices.getUserMedia({ + audio: true + }) + + mediaRecorder = new ExtendableMediaRecorder(currentStream, { + mimeType: 'audio/wav' + }) as unknown as MediaRecorder + + audioChunks = [] + + mediaRecorder.ondataavailable = (event) => { + audioChunks.push(event.data) + } + + mediaRecorder.onstop = async () => { + const audioBlob = new Blob(audioChunks, { type: 'audio/wav' }) + + useAudioService().stopAllTracks(currentStream) + + if ( + audioUIWidget.element.src && + audioUIWidget.element.src.startsWith('blob:') + ) { + URL.revokeObjectURL(audioUIWidget.element.src) + } + + audioUIWidget.element.src = URL.createObjectURL(audioBlob) + + isRecording = false + + if (recordWidget) { + recordWidget.label = t('g.startRecording') + } + + if (stopResolve) { + stopResolve() + stopResolve = null + stopPromise = null + } + } + + mediaRecorder.onerror = (event) => { + console.error('MediaRecorder error:', event) + useAudioService().stopAllTracks(currentStream) + isRecording = false + + if (recordWidget) { + recordWidget.label = t('g.startRecording') + } + + if (stopResolve) { + stopResolve() + stopResolve = null + stopPromise = null + } + } + + mediaRecorder.start() + isRecording = true + if (recordWidget) { + recordWidget.label = t('g.stopRecording') + } + } catch (err) { + console.error('Error accessing microphone:', err) + useToastStore().addAlert(t('g.micPermissionDenied')) + + if (mediaRecorder) { + try { + mediaRecorder.stop() + } catch {} + } + useAudioService().stopAllTracks(currentStream) + currentStream = null + isRecording = false + if (recordWidget) { + recordWidget.label = t('g.startRecording') + } + } + } else if (mediaRecorder && isRecording) { + mediaRecorder.stop() + } + }, + { serialize: false } + ) + + recordWidget.label = t('g.startRecording') + + const originalOnRemoved = node.onRemoved + node.onRemoved = function () { + if (isRecording && mediaRecorder) { + mediaRecorder.stop() + } + useAudioService().stopAllTracks(currentStream) + if (audioUIWidget.element.src?.startsWith('blob:')) { + URL.revokeObjectURL(audioUIWidget.element.src) + } + originalOnRemoved?.call(this) + } + + return { widget: recordWidget } + } + } + }, + + async nodeCreated(node) { + if (node.constructor.comfyClass !== 'RecordAudio') return + + await useAudioService().registerWavEncoder() + } +}) diff --git a/src/extensions/core/widgetInputs.ts b/src/extensions/core/widgetInputs.ts index daa28ce0a..fbc23b231 100644 --- a/src/extensions/core/widgetInputs.ts +++ b/src/extensions/core/widgetInputs.ts @@ -46,7 +46,7 @@ export class PrimitiveNode extends LGraphNode { ] let v = this.widgets?.[0].value if (v && this.properties[replacePropertyName]) { - v = applyTextReplacements(app.graph.nodes, v as string) + v = applyTextReplacements(app.graph, v as string) } // For each output link copy our value over the original widget value diff --git a/src/locales/en/commands.json b/src/locales/en/commands.json index e505fa445..8e411e996 100644 --- a/src/locales/en/commands.json +++ b/src/locales/en/commands.json @@ -71,6 +71,9 @@ "Comfy_Canvas_ToggleLock": { "label": "Canvas Toggle Lock" }, + "Comfy_Canvas_ToggleMinimap": { + "label": "Canvas Toggle Minimap" + }, "Comfy_Canvas_ToggleSelected_Pin": { "label": "Pin/Unpin Selected Items" }, @@ -170,6 +173,12 @@ "Comfy_Manager_ToggleManagerProgressDialog": { "label": "Toggle the Custom Nodes Manager Progress Bar" }, + "Comfy_MaskEditor_BrushSize_Decrease": { + "label": "Decrease Brush Size in MaskEditor" + }, + "Comfy_MaskEditor_BrushSize_Increase": { + "label": "Increase Brush Size in MaskEditor" + }, "Comfy_MaskEditor_OpenMaskEditor": { "label": "Open Mask Editor for Selected Node" }, diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 038143d96..242e7adba 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -98,6 +98,12 @@ "nodes": "Nodes", "community": "Community", "all": "All", + "versionMismatchWarning": "Version Compatibility Warning", + "versionMismatchWarningMessage": "{warning}: {detail} Visit https://docs.comfy.org/installation/update_comfyui#common-update-issues for update instructions.", + "frontendOutdated": "Frontend version {frontendVersion} is outdated. Backend requires {requiredVersion} or higher.", + "frontendNewer": "Frontend version {frontendVersion} may not be compatible with backend version {backendVersion}.", + "updateFrontend": "Update Frontend", + "dismiss": "Dismiss", "update": "Update", "updated": "Updated", "resultsCount": "Found {count} Results", @@ -135,7 +141,12 @@ "releaseTitle": "{package} {version} Release", "progressCountOf": "of", "keybindingAlreadyExists": "Keybinding already exists on", - "commandProhibited": "Command {command} is prohibited. Contact an administrator for more information." + "commandProhibited": "Command {command} is prohibited. Contact an administrator for more information.", + "startRecording": "Start Recording", + "stopRecording": "Stop Recording", + "micPermissionDenied": "Microphone permission denied", + "noAudioRecorded": "No audio recorded", + "nodesRunning": "nodes running" }, "manager": { "title": "Custom Nodes Manager", @@ -463,7 +474,6 @@ "restart": "Restart" }, "sideToolbar": { - "themeToggle": "Toggle Theme", "helpCenter": "Help Center", "logout": "Logout", "queue": "Queue", @@ -565,7 +575,13 @@ "clipspace": "Open Clipspace", "resetView": "Reset canvas view", "clear": "Clear workflow", - "toggleBottomPanel": "Toggle Bottom Panel" + "toggleBottomPanel": "Toggle Bottom Panel", + "theme": "Theme", + "dark": "Dark", + "light": "Light", + "manageExtensions": "Manage Extensions", + "settings": "Settings", + "help": "Help" }, "tabMenu": { "duplicateTab": "Duplicate Tab", @@ -907,7 +923,8 @@ "fitView": "Fit View", "selectMode": "Select Mode", "panMode": "Pan Mode", - "toggleLinkVisibility": "Toggle Link Visibility" + "toggleLinkVisibility": "Toggle Link Visibility", + "toggleMinimap": "Toggle Minimap" }, "groupNode": { "create": "Create group node", @@ -984,6 +1001,7 @@ "Resize Selected Nodes": "Resize Selected Nodes", "Canvas Toggle Link Visibility": "Canvas Toggle Link Visibility", "Canvas Toggle Lock": "Canvas Toggle Lock", + "Canvas Toggle Minimap": "Canvas Toggle Minimap", "Pin/Unpin Selected Items": "Pin/Unpin Selected Items", "Bypass/Unbypass Selected Nodes": "Bypass/Unbypass Selected Nodes", "Collapse/Expand Selected Nodes": "Collapse/Expand Selected Nodes", @@ -1017,6 +1035,8 @@ "Install Missing Custom Nodes": "Install Missing Custom Nodes", "Check for Custom Node Updates": "Check for Custom Node Updates", "Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar", + "Decrease Brush Size in MaskEditor": "Decrease Brush Size in MaskEditor", + "Increase Brush Size in MaskEditor": "Increase Brush Size in MaskEditor", "Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node", "Unload Models": "Unload Models", "Unload Models and Execution Cache": "Unload Models and Execution Cache", @@ -1389,6 +1409,13 @@ "outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.", "coreNodesFromVersion": "Requires ComfyUI {version}:" }, + "versionMismatchWarning": { + "title": "Version Compatibility Warning", + "frontendOutdated": "Frontend version {frontendVersion} is outdated. Backend requires version {requiredVersion} or higher.", + "frontendNewer": "Frontend version {frontendVersion} may not be compatible with backend version {backendVersion}.", + "updateFrontend": "Update Frontend", + "dismiss": "Dismiss" + }, "errorDialog": { "defaultTitle": "An error occurred", "loadWorkflowTitle": "Loading aborted due to error reloading workflow data", @@ -1646,5 +1673,11 @@ "whatsNewPopup": { "learnMore": "Learn more", "noReleaseNotes": "No release notes available." + }, + "breadcrumbsMenu": { + "duplicate": "Duplicate", + "clearWorkflow": "Clear Workflow", + "deleteWorkflow": "Delete Workflow", + "enterNewName": "Enter new name" } } \ No newline at end of file diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 766cf071f..f642f69c8 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -29,6 +29,13 @@ "name": "Canvas background image", "tooltip": "Image URL for the canvas background. You can right-click an image in the outputs panel and select \"Set as Background\" to use it, or upload your own image using the upload button." }, + "Comfy_Canvas_NavigationMode": { + "name": "Canvas Navigation Mode", + "options": { + "Standard (New)": "Standard (New)", + "Left-Click Pan (Legacy)": "Left-Click Pan (Legacy)" + } + }, "Comfy_Canvas_SelectionToolbox": { "name": "Show selection toolbox" }, @@ -329,10 +336,6 @@ "Bottom": "Bottom" } }, - "Comfy_Validation_NodeDefs": { - "name": "Validate node definitions (slow)", - "tooltip": "Recommended for node developers. This will validate all node definitions on startup." - }, "Comfy_Validation_Workflows": { "name": "Validate workflows" }, @@ -399,10 +402,6 @@ "LiteGraph_Node_TooltipDelay": { "name": "Tooltip Delay" }, - "LiteGraph_Pointer_TrackpadGestures": { - "name": "Enable trackpad gestures", - "tooltip": "This setting enables trackpad mode for the canvas, allowing pinch-to-zoom and panning with two fingers." - }, "LiteGraph_Reroute_SplineOffset": { "name": "Reroute spline offset", "tooltip": "The bezier control point offset from the reroute centre point" diff --git a/src/locales/es/commands.json b/src/locales/es/commands.json index 58e6ee814..b832f542d 100644 --- a/src/locales/es/commands.json +++ b/src/locales/es/commands.json @@ -71,6 +71,9 @@ "Comfy_Canvas_ToggleLock": { "label": "Alternar bloqueo en lienzo" }, + "Comfy_Canvas_ToggleMinimap": { + "label": "Lienzo Alternar Minimapa" + }, "Comfy_Canvas_ToggleSelectedNodes_Bypass": { "label": "Omitir/No omitir nodos seleccionados" }, @@ -170,6 +173,12 @@ "Comfy_Manager_ToggleManagerProgressDialog": { "label": "Alternar diรกlogo de progreso del administrador" }, + "Comfy_MaskEditor_BrushSize_Decrease": { + "label": "Disminuir tamaรฑo del pincel en MaskEditor" + }, + "Comfy_MaskEditor_BrushSize_Increase": { + "label": "Aumentar tamaรฑo del pincel en MaskEditor" + }, "Comfy_MaskEditor_OpenMaskEditor": { "label": "Abrir editor de mรกscara para el nodo seleccionado" }, diff --git a/src/locales/es/main.json b/src/locales/es/main.json index 7655e51ba..c42543b89 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -82,6 +82,12 @@ "title": "Crea una cuenta" } }, + "breadcrumbsMenu": { + "clearWorkflow": "Limpiar flujo de trabajo", + "deleteWorkflow": "Eliminar flujo de trabajo", + "duplicate": "Duplicar", + "enterNewName": "Ingrese un nuevo nombre" + }, "chatHistory": { "cancelEdit": "Cancelar", "cancelEditTooltip": "Cancelar ediciรณn", @@ -291,6 +297,7 @@ "devices": "Dispositivos", "disableAll": "Deshabilitar todo", "disabling": "Deshabilitando", + "dismiss": "Descartar", "download": "Descargar", "edit": "Editar", "empty": "Vacรญo", @@ -305,6 +312,8 @@ "filter": "Filtrar", "findIssues": "Encontrar problemas", "firstTimeUIMessage": "Esta es la primera vez que usas la nueva interfaz. Elige \"Menรบ > Usar nuevo menรบ > Desactivado\" para restaurar la antigua interfaz.", + "frontendNewer": "La versiรณn del frontend {frontendVersion} puede no ser compatible con la versiรณn del backend {backendVersion}.", + "frontendOutdated": "La versiรณn del frontend {frontendVersion} estรก desactualizada. El backend requiere la versiรณn {requiredVersion} o superior.", "goToNode": "Ir al nodo", "help": "Ayuda", "icon": "Icono", @@ -326,17 +335,20 @@ "loadingPanel": "Cargando panel {panel}...", "login": "Iniciar sesiรณn", "logs": "Registros", + "micPermissionDenied": "Permiso de micrรณfono denegado", "migrate": "Migrar", "missing": "Faltante", "name": "Nombre", "newFolder": "Nueva carpeta", "next": "Siguiente", "no": "No", + "noAudioRecorded": "No se grabรณ audio", "noResultsFound": "No se encontraron resultados", "noTasksFound": "No se encontraron tareas", "noTasksFoundMessage": "No hay tareas en la cola.", "noWorkflowsFound": "No se encontraron flujos de trabajo.", "nodes": "Nodos", + "nodesRunning": "nodos en ejecuciรณn", "ok": "OK", "openNewIssue": "Abrir nuevo problema", "overwrite": "Sobrescribir", @@ -370,7 +382,9 @@ "showReport": "Mostrar informe", "sort": "Ordenar", "source": "Fuente", + "startRecording": "Iniciar grabaciรณn", "status": "Estado", + "stopRecording": "Detener grabaciรณn", "success": "ร‰xito", "systemInfo": "Informaciรณn del sistema", "terminal": "Terminal", @@ -379,11 +393,14 @@ "unknownError": "Error desconocido", "update": "Actualizar", "updateAvailable": "Actualizaciรณn Disponible", + "updateFrontend": "Actualizar frontend", "updated": "Actualizado", "updating": "Actualizando", "upload": "Subir", "usageHint": "Sugerencia de uso", "user": "Usuario", + "versionMismatchWarning": "Advertencia de compatibilidad de versiรณn", + "versionMismatchWarningMessage": "{warning}: {detail} Visita https://docs.comfy.org/installation/update_comfyui#common-update-issues para obtener instrucciones de actualizaciรณn.", "videoFailedToLoad": "Fallรณ la carga del video", "workflow": "Flujo de trabajo" }, @@ -393,6 +410,7 @@ "resetView": "Restablecer vista", "selectMode": "Modo de selecciรณn", "toggleLinkVisibility": "Alternar visibilidad de enlace", + "toggleMinimap": "Alternar minimapa", "zoomIn": "Acercar", "zoomOut": "Alejar" }, @@ -707,13 +725,17 @@ "batchCountTooltip": "El nรบmero de veces que la generaciรณn del flujo de trabajo debe ser encolada", "clear": "Limpiar flujo de trabajo", "clipspace": "Abrir Clipspace", + "dark": "Oscuro", "disabled": "Deshabilitado", "disabledTooltip": "El flujo de trabajo no se encolarรก automรกticamente", "execute": "Ejecutar", + "help": "Ayuda", "hideMenu": "Ocultar menรบ", "instant": "Instantรกneo", "instantTooltip": "El flujo de trabajo se encolarรก instantรกneamente despuรฉs de que finalice una generaciรณn", "interrupt": "Cancelar ejecuciรณn actual", + "light": "Claro", + "manageExtensions": "Gestionar extensiones", "onChange": "Al cambiar", "onChangeTooltip": "El flujo de trabajo se encolarรก una vez que se haga un cambio", "refresh": "Actualizar definiciones de nodos", @@ -721,7 +743,9 @@ "run": "Ejecutar", "runWorkflow": "Ejecutar flujo de trabajo (Shift para encolar al frente)", "runWorkflowFront": "Ejecutar flujo de trabajo (Encolar al frente)", + "settings": "Configuraciรณn", "showMenu": "Mostrar menรบ", + "theme": "Tema", "toggleBottomPanel": "Alternar panel inferior" }, "menuLabels": { @@ -732,6 +756,8 @@ "Canvas Toggle Link Visibility": "Alternar visibilidad de enlace en lienzo", "Canvas Toggle Lock": "Alternar bloqueo en lienzo", "Check for Custom Node Updates": "Buscar Actualizaciones de Nodos Personalizados", + "Canvas Toggle Minimap": "Lienzo: Alternar minimapa", + "Check for Custom Node Updates": "Buscar actualizaciones de nodos personalizados", "Check for Updates": "Buscar actualizaciones", "Clear Pending Tasks": "Borrar tareas pendientes", "Clear Workflow": "Borrar flujo de trabajo", @@ -745,7 +771,9 @@ "Contact Support": "Contactar soporte", "Convert Selection to Subgraph": "Convertir selecciรณn en subgrafo", "Convert selected nodes to group node": "Convertir nodos seleccionados en nodo de grupo", - "Custom Nodes (Legacy)": "Nodos Personalizados (Legado)", + "Custom Nodes (Legacy)": "Nodos personalizados (heredado)", + "Custom Nodes Manager": "Administrador de Nodos Personalizados", + "Decrease Brush Size in MaskEditor": "Disminuir tamaรฑo del pincel en MaskEditor", "Delete Selected Items": "Eliminar elementos seleccionados", "Desktop User Guide": "Guรญa de usuario de escritorio", "Duplicate Current Workflow": "Duplicar flujo de trabajo actual", @@ -758,6 +786,8 @@ "Group Selected Nodes": "Agrupar nodos seleccionados", "Help": "Ayuda", "Install Missing Custom Nodes": "Instalar Nodos Personalizados Faltantes", + "Increase Brush Size in MaskEditor": "Aumentar tamaรฑo del pincel en MaskEditor", + "Install Missing Custom Nodes": "Instalar nodos personalizados faltantes", "Interrupt": "Interrumpir", "Load Default Workflow": "Cargar flujo de trabajo predeterminado", "Manage group nodes": "Gestionar nodos de grupo", @@ -1175,7 +1205,6 @@ }, "showFlatList": "Mostrar lista plana" }, - "themeToggle": "Cambiar tema", "workflowTab": { "confirmDelete": "ยฟEstรกs seguro de que quieres eliminar este flujo de trabajo?", "confirmDeleteTitle": "ยฟEliminar flujo de trabajo?", @@ -1599,6 +1628,13 @@ "prefix": "Debe comenzar con {prefix}", "required": "Requerido" }, + "versionMismatchWarning": { + "dismiss": "Descartar", + "frontendNewer": "La versiรณn del frontend {frontendVersion} puede no ser compatible con la versiรณn del backend {backendVersion}.", + "frontendOutdated": "La versiรณn del frontend {frontendVersion} estรก desactualizada. El backend requiere la versiรณn {requiredVersion} o superior.", + "title": "Advertencia de compatibilidad de versiรณn", + "updateFrontend": "Actualizar frontend" + }, "welcome": { "getStarted": "Empezar", "title": "Bienvenido a ComfyUI" diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 12d7bf88d..b62c2ed57 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -29,6 +29,13 @@ "name": "Imagen de fondo del lienzo", "tooltip": "URL de la imagen para el fondo del lienzo. Puedes hacer clic derecho en una imagen del panel de resultados y seleccionar \"Establecer como fondo\" para usarla." }, + "Comfy_Canvas_NavigationMode": { + "name": "Modo de navegaciรณn del lienzo", + "options": { + "Left-Click Pan (Legacy)": "Desplazamiento con clic izquierdo (Legado)", + "Standard (New)": "Estรกndar (Nuevo)" + } + }, "Comfy_Canvas_SelectionToolbox": { "name": "Mostrar caja de herramientas de selecciรณn" }, @@ -329,10 +336,6 @@ }, "tooltip": "Posiciรณn de la barra de menรบ. En dispositivos mรณviles, el menรบ siempre se muestra en la parte superior." }, - "Comfy_Validation_NodeDefs": { - "name": "Validar definiciones de nodos (lento)", - "tooltip": "Recomendado para desarrolladores de nodos. Esto validarรก todas las definiciones de nodos al iniciar." - }, "Comfy_Validation_Workflows": { "name": "Validar flujos de trabajo" }, @@ -399,10 +402,6 @@ "LiteGraph_Node_TooltipDelay": { "name": "Retraso de la informaciรณn sobre herramientas" }, - "LiteGraph_Pointer_TrackpadGestures": { - "name": "Habilitar gestos del trackpad", - "tooltip": "Esta configuraciรณn activa el modo trackpad para el lienzo, permitiendo hacer zoom con pellizco y desplazar con dos dedos." - }, "LiteGraph_Reroute_SplineOffset": { "name": "Desvรญo de la compensaciรณn de la spline", "tooltip": "El punto de control bezier desplazado desde el punto central de reenrutamiento" diff --git a/src/locales/fr/commands.json b/src/locales/fr/commands.json index 678fcabb9..0444b763d 100644 --- a/src/locales/fr/commands.json +++ b/src/locales/fr/commands.json @@ -71,6 +71,9 @@ "Comfy_Canvas_ToggleLock": { "label": "Basculer le verrouillage du canevas" }, + "Comfy_Canvas_ToggleMinimap": { + "label": "Basculer la mini-carte du canevas" + }, "Comfy_Canvas_ToggleSelectedNodes_Bypass": { "label": "Contourner/Ne pas contourner les nล“uds sรฉlectionnรฉs" }, @@ -170,6 +173,12 @@ "Comfy_Manager_ToggleManagerProgressDialog": { "label": "Basculer la boรฎte de dialogue de progression" }, + "Comfy_MaskEditor_BrushSize_Decrease": { + "label": "Rรฉduire la taille du pinceau dans MaskEditor" + }, + "Comfy_MaskEditor_BrushSize_Increase": { + "label": "Augmenter la taille du pinceau dans MaskEditor" + }, "Comfy_MaskEditor_OpenMaskEditor": { "label": "Ouvrir l'รฉditeur de masque pour le nล“ud sรฉlectionnรฉ" }, diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index 83883640d..fd77f7455 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -82,6 +82,12 @@ "title": "Crรฉer un compte" } }, + "breadcrumbsMenu": { + "clearWorkflow": "Effacer le workflow", + "deleteWorkflow": "Supprimer le workflow", + "duplicate": "Dupliquer", + "enterNewName": "Entrez un nouveau nom" + }, "chatHistory": { "cancelEdit": "Annuler", "cancelEditTooltip": "Annuler la modification", @@ -291,6 +297,7 @@ "devices": "Appareils", "disableAll": "Dรฉsactiver tout", "disabling": "Dรฉsactivation", + "dismiss": "Fermer", "download": "Tรฉlรฉcharger", "edit": "Modifier", "empty": "Vide", @@ -305,6 +312,8 @@ "filter": "Filtrer", "findIssues": "Trouver des problรจmes", "firstTimeUIMessage": "C'est la premiรจre fois que vous utilisez la nouvelle interface utilisateur. Choisissez \"Menu > Utiliser le nouveau menu > Dรฉsactivรฉ\" pour restaurer l'ancienne interface utilisateur.", + "frontendNewer": "La version du frontend {frontendVersion} peut ne pas รชtre compatible avec la version du backend {backendVersion}.", + "frontendOutdated": "La version du frontend {frontendVersion} est obsolรจte. Le backend requiert la version {requiredVersion} ou supรฉrieure.", "goToNode": "Aller au nล“ud", "help": "Aide", "icon": "Icรดne", @@ -326,17 +335,20 @@ "loadingPanel": "Chargement du panneau {panel}...", "login": "Connexion", "logs": "Journaux", + "micPermissionDenied": "Permission du microphone refusรฉe", "migrate": "Migrer", "missing": "Manquant", "name": "Nom", "newFolder": "Nouveau dossier", "next": "Suivant", "no": "Non", + "noAudioRecorded": "Aucun audio enregistrรฉ", "noResultsFound": "Aucun rรฉsultat trouvรฉ", "noTasksFound": "Aucune tรขche trouvรฉe", "noTasksFoundMessage": "Il n'y a pas de tรขches dans la file d'attente.", "noWorkflowsFound": "Aucun flux de travail trouvรฉ.", "nodes": "Nล“uds", + "nodesRunning": "nล“uds en cours dโ€™exรฉcution", "ok": "OK", "openNewIssue": "Ouvrir un nouveau problรจme", "overwrite": "ร‰craser", @@ -370,7 +382,9 @@ "showReport": "Afficher le rapport", "sort": "Trier", "source": "Source", + "startRecording": "Commencer lโ€™enregistrement", "status": "Statut", + "stopRecording": "Arrรชter lโ€™enregistrement", "success": "Succรจs", "systemInfo": "Informations systรจme", "terminal": "Terminal", @@ -379,11 +393,14 @@ "unknownError": "Erreur inconnue", "update": "Mettre ร  jour", "updateAvailable": "Mise ร  jour disponible", + "updateFrontend": "Mettre ร  jour le frontend", "updated": "Mis ร  jour", "updating": "Mise ร  jour", "upload": "Tรฉlรฉverser", "usageHint": "Conseil d'utilisation", "user": "Utilisateur", + "versionMismatchWarning": "Avertissement de compatibilitรฉ de version", + "versionMismatchWarningMessage": "{warning} : {detail} Consultez https://docs.comfy.org/installation/update_comfyui#common-update-issues pour les instructions de mise ร  jour.", "videoFailedToLoad": "ร‰chec du chargement de la vidรฉo", "workflow": "Flux de travail" }, @@ -393,6 +410,7 @@ "resetView": "Rรฉinitialiser la vue", "selectMode": "Mode sรฉlection", "toggleLinkVisibility": "Basculer la visibilitรฉ des liens", + "toggleMinimap": "Afficher/Masquer la mini-carte", "zoomIn": "Zoom avant", "zoomOut": "Zoom arriรจre" }, @@ -707,13 +725,17 @@ "batchCountTooltip": "Le nombre de fois que la gรฉnรฉration du flux de travail doit รชtre mise en file d'attente", "clear": "Effacer le flux de travail", "clipspace": "Ouvrir Clipspace", + "dark": "Sombre", "disabled": "Dรฉsactivรฉ", "disabledTooltip": "Le flux de travail ne sera pas mis en file d'attente automatiquement", "execute": "Exรฉcuter", + "help": "Aide", "hideMenu": "Masquer le menu", "instant": "Instantanรฉ", "instantTooltip": "Le flux de travail sera mis en file d'attente immรฉdiatement aprรจs la fin d'une gรฉnรฉration", "interrupt": "Annuler l'exรฉcution en cours", + "light": "Clair", + "manageExtensions": "Gรฉrer les extensions", "onChange": "Sur modification", "onChangeTooltip": "Le flux de travail sera mis en file d'attente une fois une modification effectuรฉe", "refresh": "Actualiser les dรฉfinitions des nล“uds", @@ -721,7 +743,9 @@ "run": "Exรฉcuter", "runWorkflow": "Exรฉcuter le workflow (Maj pour mettre en file d'attente en premier)", "runWorkflowFront": "Exรฉcuter le workflow (Mettre en file d'attente en premier)", + "settings": "Paramรจtres", "showMenu": "Afficher le menu", + "theme": "Thรจme", "toggleBottomPanel": "Basculer le panneau infรฉrieur" }, "menuLabels": { @@ -731,8 +755,9 @@ "Bypass/Unbypass Selected Nodes": "Contourner/Ne pas contourner les nล“uds sรฉlectionnรฉs", "Canvas Toggle Link Visibility": "Basculer la visibilitรฉ du lien de la toile", "Canvas Toggle Lock": "Basculer le verrouillage de la toile", + "Canvas Toggle Minimap": "Basculer la mini-carte du canevas", "Check for Custom Node Updates": "Vรฉrifier les mises ร  jour des nล“uds personnalisรฉs", - "Check for Updates": "Vรฉrifier les Mises ร  Jour", + "Check for Updates": "Vรฉrifier les mises ร  jour", "Clear Pending Tasks": "Effacer les tรขches en attente", "Clear Workflow": "Effacer le flux de travail", "Clipspace": "Espace de clip", @@ -746,6 +771,8 @@ "Convert Selection to Subgraph": "Convertir la sรฉlection en sous-graphe", "Convert selected nodes to group node": "Convertir les nล“uds sรฉlectionnรฉs en nล“ud de groupe", "Custom Nodes (Legacy)": "Nล“uds personnalisรฉs (hรฉritage)", + "Custom Nodes Manager": "Gestionnaire de Nล“uds Personnalisรฉs", + "Decrease Brush Size in MaskEditor": "Rรฉduire la taille du pinceau dans MaskEditor", "Delete Selected Items": "Supprimer les รฉlรฉments sรฉlectionnรฉs", "Desktop User Guide": "Guide de l'utilisateur de bureau", "Duplicate Current Workflow": "Dupliquer le flux de travail actuel", @@ -757,6 +784,7 @@ "Give Feedback": "Donnez votre avis", "Group Selected Nodes": "Grouper les nล“uds sรฉlectionnรฉs", "Help": "Aide", + "Increase Brush Size in MaskEditor": "Augmenter la taille du pinceau dans MaskEditor", "Install Missing Custom Nodes": "Installer les nล“uds personnalisรฉs manquants", "Interrupt": "Interrompre", "Load Default Workflow": "Charger le flux de travail par dรฉfaut", @@ -1175,7 +1203,6 @@ }, "showFlatList": "Afficher la liste plate" }, - "themeToggle": "Changer de thรจme", "workflowTab": { "confirmDelete": "รŠtes-vous sรปr de vouloir supprimer ce flux de travail ?", "confirmDeleteTitle": "Supprimer le flux de travail ?", @@ -1599,6 +1626,13 @@ "prefix": "Doit commencer par {prefix}", "required": "Requis" }, + "versionMismatchWarning": { + "dismiss": "Ignorer", + "frontendNewer": "La version du frontend {frontendVersion} peut ne pas รชtre compatible avec la version du backend {backendVersion}.", + "frontendOutdated": "La version du frontend {frontendVersion} est obsolรจte. Le backend nรฉcessite la version {requiredVersion} ou supรฉrieure.", + "title": "Avertissement de compatibilitรฉ de version", + "updateFrontend": "Mettre ร  jour le frontend" + }, "welcome": { "getStarted": "Commencer", "title": "Bienvenue sur ComfyUI" diff --git a/src/locales/fr/settings.json b/src/locales/fr/settings.json index f49862726..b27172ec2 100644 --- a/src/locales/fr/settings.json +++ b/src/locales/fr/settings.json @@ -29,6 +29,13 @@ "name": "Image de fond du canevas", "tooltip": "URL de l'image pour le fond du canevas. Vous pouvez faire un clic droit sur une image dans le panneau de sortie et sรฉlectionner ยซ Dรฉfinir comme fond ยป pour l'utiliser." }, + "Comfy_Canvas_NavigationMode": { + "name": "Mode de navigation sur le canvas", + "options": { + "Left-Click Pan (Legacy)": "Panoramique clic gauche (Hรฉritรฉ)", + "Standard (New)": "Standard (Nouveau)" + } + }, "Comfy_Canvas_SelectionToolbox": { "name": "Afficher la boรฎte ร  outils de sรฉlection" }, @@ -329,10 +336,6 @@ }, "tooltip": "Position de la barre de menu. Sur les appareils mobiles, le menu est toujours affichรฉ en haut." }, - "Comfy_Validation_NodeDefs": { - "name": "Valider les dรฉfinitions de nล“uds (lent)", - "tooltip": "Recommandรฉ pour les dรฉveloppeurs de nล“uds. Cela validera toutes les dรฉfinitions de nล“uds au dรฉmarrage." - }, "Comfy_Validation_Workflows": { "name": "Valider les flux de travail" }, @@ -399,10 +402,6 @@ "LiteGraph_Node_TooltipDelay": { "name": "Dรฉlai d'infobulle" }, - "LiteGraph_Pointer_TrackpadGestures": { - "name": "Activer les gestes du trackpad", - "tooltip": "Ce paramรจtre active le mode trackpad pour le canevas, permettant le zoom par pincement et le dรฉplacement ร  deux doigts." - }, "LiteGraph_Reroute_SplineOffset": { "name": "Rรฉacheminement dรฉcalage de spline", "tooltip": "Le point de contrรดle de Bรฉzier est dรฉcalรฉ par rapport au point central de rรฉacheminement" diff --git a/src/locales/ja/commands.json b/src/locales/ja/commands.json index f500b0f74..fd574d68d 100644 --- a/src/locales/ja/commands.json +++ b/src/locales/ja/commands.json @@ -71,6 +71,9 @@ "Comfy_Canvas_ToggleLock": { "label": "ใ‚ญใƒฃใƒณใƒใ‚นใฎใƒญใƒƒใ‚ฏใ‚’ๅˆ‡ใ‚Šๆ›ฟใˆใ‚‹" }, + "Comfy_Canvas_ToggleMinimap": { + "label": "ใ‚ญใƒฃใƒณใƒใ‚น ใƒŸใƒ‹ใƒžใƒƒใƒ—ๅˆ‡ใ‚Šๆ›ฟใˆ" + }, "Comfy_Canvas_ToggleSelectedNodes_Bypass": { "label": "้ธๆŠžใ—ใŸใƒŽใƒผใƒ‰ใฎใƒใ‚คใƒ‘ใ‚น/ใƒใ‚คใƒ‘ใ‚น่งฃ้™ค" }, @@ -170,6 +173,12 @@ "Comfy_Manager_ToggleManagerProgressDialog": { "label": "ใƒ—ใƒญใ‚ฐใƒฌใ‚นใƒ€ใ‚คใ‚ขใƒญใ‚ฐใฎๅˆ‡ใ‚Šๆ›ฟใˆ" }, + "Comfy_MaskEditor_BrushSize_Decrease": { + "label": "ใƒžใ‚นใ‚ฏใ‚จใƒ‡ใ‚ฃใ‚ฟใงใƒ–ใƒฉใ‚ทใ‚ตใ‚คใ‚บใ‚’็ธฎๅฐ" + }, + "Comfy_MaskEditor_BrushSize_Increase": { + "label": "ใƒžใ‚นใ‚ฏใ‚จใƒ‡ใ‚ฃใ‚ฟใงใƒ–ใƒฉใ‚ทใ‚ตใ‚คใ‚บใ‚’ๅคงใใใ™ใ‚‹" + }, "Comfy_MaskEditor_OpenMaskEditor": { "label": "้ธๆŠžใ—ใŸใƒŽใƒผใƒ‰ใฎใƒžใ‚นใ‚ฏใ‚จใƒ‡ใ‚ฃใ‚ฟใ‚’้–‹ใ" }, diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index 5b3062d98..ce8836ce9 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -82,6 +82,12 @@ "title": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆใ‚’ไฝœๆˆใ™ใ‚‹" } }, + "breadcrumbsMenu": { + "clearWorkflow": "ใƒฏใƒผใ‚ฏใƒ•ใƒญใƒผใ‚’ใ‚ฏใƒชใ‚ข", + "deleteWorkflow": "ใƒฏใƒผใ‚ฏใƒ•ใƒญใƒผใ‚’ๅ‰Š้™ค", + "duplicate": "่ค‡่ฃฝ", + "enterNewName": "ๆ–ฐใ—ใ„ๅๅ‰ใ‚’ๅ…ฅๅŠ›" + }, "chatHistory": { "cancelEdit": "ใ‚ญใƒฃใƒณใ‚ปใƒซ", "cancelEditTooltip": "็ทจ้›†ใ‚’ใ‚ญใƒฃใƒณใ‚ปใƒซ", @@ -291,6 +297,7 @@ "devices": "ใƒ‡ใƒใ‚คใ‚น", "disableAll": "ใ™ในใฆ็„กๅŠนใซใ™ใ‚‹", "disabling": "็„กๅŠนๅŒ–", + "dismiss": "้–‰ใ˜ใ‚‹", "download": "ใƒ€ใ‚ฆใƒณใƒญใƒผใƒ‰", "edit": "็ทจ้›†", "empty": "็ฉบ", @@ -305,6 +312,8 @@ "filter": "ใƒ•ใ‚ฃใƒซใ‚ฟ", "findIssues": "ๅ•้กŒใ‚’่ฆ‹ใคใ‘ใ‚‹", "firstTimeUIMessage": "ๆ–ฐใ—ใ„UIใ‚’ๅˆใ‚ใฆไฝฟ็”จใ—ใฆใ„ใพใ™ใ€‚ใ€Œใƒกใƒ‹ใƒฅใƒผ > ๆ–ฐใ—ใ„ใƒกใƒ‹ใƒฅใƒผใ‚’ไฝฟ็”จ > ็„กๅŠนใ€ใ‚’้ธๆŠžใ™ใ‚‹ใ“ใจใงๅคใ„UIใซๆˆปใ™ใ“ใจใŒๅฏ่ƒฝใงใ™ใ€‚", + "frontendNewer": "ใƒ•ใƒญใƒณใƒˆใ‚จใƒณใƒ‰ใฎใƒใƒผใ‚ธใƒงใƒณ {frontendVersion} ใฏใƒใƒƒใ‚ฏใ‚จใƒณใƒ‰ใฎใƒใƒผใ‚ธใƒงใƒณ {backendVersion} ใจไบ’ๆ›ๆ€งใŒใชใ„ๅฏ่ƒฝๆ€งใŒใ‚ใ‚Šใพใ™ใ€‚", + "frontendOutdated": "ใƒ•ใƒญใƒณใƒˆใ‚จใƒณใƒ‰ใฎใƒใƒผใ‚ธใƒงใƒณ {frontendVersion} ใฏๅคใใชใฃใฆใ„ใพใ™ใ€‚ใƒใƒƒใ‚ฏใ‚จใƒณใƒ‰ใฏ {requiredVersion} ไปฅไธŠใŒๅฟ…่ฆใงใ™ใ€‚", "goToNode": "ใƒŽใƒผใƒ‰ใซ็งปๅ‹•", "help": "ใƒ˜ใƒซใƒ—", "icon": "ใ‚ขใ‚คใ‚ณใƒณ", @@ -326,17 +335,20 @@ "loadingPanel": "{panel} ใƒ‘ใƒใƒซใ‚’่ชญใฟ่พผใฟไธญ...", "login": "ใƒญใ‚ฐใ‚คใƒณ", "logs": "ใƒญใ‚ฐ", + "micPermissionDenied": "ใƒžใ‚คใ‚ฏใฎ่จฑๅฏใŒๆ‹’ๅฆใ•ใ‚Œใพใ—ใŸ", "migrate": "็งป่กŒใ™ใ‚‹", "missing": "ไธ่ถณใ—ใฆใ„ใ‚‹", "name": "ๅๅ‰", "newFolder": "ๆ–ฐใ—ใ„ใƒ•ใ‚ฉใƒซใƒ€ใƒผ", "next": "ๆฌกใธ", "no": "ใ„ใ„ใˆ", + "noAudioRecorded": "้ŸณๅฃฐใŒ้Œฒ้Ÿณใ•ใ‚Œใฆใ„ใพใ›ใ‚“", "noResultsFound": "็ตๆžœใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“", "noTasksFound": "ใ‚ฟใ‚นใ‚ฏใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“", "noTasksFoundMessage": "ใ‚ญใƒฅใƒผใซใ‚ฟใ‚นใ‚ฏใŒใ‚ใ‚Šใพใ›ใ‚“ใ€‚", "noWorkflowsFound": "ใƒฏใƒผใ‚ฏใƒ•ใƒญใƒผใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“ใ€‚", "nodes": "ใƒŽใƒผใƒ‰", + "nodesRunning": "ใƒŽใƒผใƒ‰ใŒๅฎŸ่กŒไธญ", "ok": "OK", "openNewIssue": "ๆ–ฐใ—ใ„ๅ•้กŒใ‚’้–‹ใ", "overwrite": "ไธŠๆ›ธใ", @@ -370,7 +382,9 @@ "showReport": "ใƒฌใƒใƒผใƒˆใ‚’่กจ็คบ", "sort": "ไธฆใณๆ›ฟใˆ", "source": "ใ‚ฝใƒผใ‚น", + "startRecording": "้Œฒ้Ÿณ้–‹ๅง‹", "status": "ใ‚นใƒ†ใƒผใ‚ฟใ‚น", + "stopRecording": "้Œฒ้Ÿณๅœๆญข", "success": "ๆˆๅŠŸ", "systemInfo": "ใ‚ทใ‚นใƒ†ใƒ ๆƒ…ๅ ฑ", "terminal": "ใ‚ฟใƒผใƒŸใƒŠใƒซ", @@ -379,11 +393,14 @@ "unknownError": "ไธๆ˜Žใชใ‚จใƒฉใƒผ", "update": "ๆ›ดๆ–ฐ", "updateAvailable": "ๆ›ดๆ–ฐใŒๅˆฉ็”จๅฏ่ƒฝ", + "updateFrontend": "ใƒ•ใƒญใƒณใƒˆใ‚จใƒณใƒ‰ใ‚’ๆ›ดๆ–ฐ", "updated": "ๆ›ดๆ–ฐๆธˆใฟ", "updating": "ๆ›ดๆ–ฐไธญ", "upload": "ใ‚ขใƒƒใƒ—ใƒญใƒผใƒ‰", "usageHint": "ไฝฟ็”จใƒ’ใƒณใƒˆ", "user": "ใƒฆใƒผใ‚ถใƒผ", + "versionMismatchWarning": "ใƒใƒผใ‚ธใƒงใƒณไบ’ๆ›ๆ€งใฎ่ญฆๅ‘Š", + "versionMismatchWarningMessage": "{warning}: {detail} ๆ›ดๆ–ฐๆ‰‹้ †ใซใคใ„ใฆใฏ https://docs.comfy.org/installation/update_comfyui#common-update-issues ใ‚’ใ”่ฆงใใ ใ•ใ„ใ€‚", "videoFailedToLoad": "ใƒ“ใƒ‡ใ‚ชใฎ่ชญใฟ่พผใฟใซๅคฑๆ•—ใ—ใพใ—ใŸ", "workflow": "ใƒฏใƒผใ‚ฏใƒ•ใƒญใƒผ" }, @@ -393,6 +410,7 @@ "resetView": "ใƒ“ใƒฅใƒผใ‚’ใƒชใ‚ปใƒƒใƒˆ", "selectMode": "้ธๆŠžใƒขใƒผใƒ‰", "toggleLinkVisibility": "ใƒชใƒณใ‚ฏใฎ่กจ็คบๅˆ‡ใ‚Šๆ›ฟใˆ", + "toggleMinimap": "ใƒŸใƒ‹ใƒžใƒƒใƒ—ใฎๅˆ‡ใ‚Šๆ›ฟใˆ", "zoomIn": "ๆ‹กๅคง", "zoomOut": "็ธฎๅฐ" }, @@ -707,13 +725,17 @@ "batchCountTooltip": "ใƒฏใƒผใ‚ฏใƒ•ใƒญใƒผ็”Ÿๆˆๅ›žๆ•ฐ", "clear": "ใƒฏใƒผใ‚ฏใƒ•ใƒญใƒผใ‚’ใ‚ฏใƒชใ‚ข", "clipspace": "ใ‚ฏใƒชใƒƒใƒ—ใ‚นใƒšใƒผใ‚นใ‚’้–‹ใ", + "dark": "ใƒ€ใƒผใ‚ฏ", "disabled": "็„กๅŠน", "disabledTooltip": "ใƒฏใƒผใ‚ฏใƒ•ใƒญใƒผใฏ่‡ชๅ‹•็š„ใซใ‚ญใƒฅใƒผใซ่ฟฝๅŠ ใ•ใ‚Œใพใ›ใ‚“", "execute": "ๅฎŸ่กŒ", + "help": "ใƒ˜ใƒซใƒ—", "hideMenu": "ใƒกใƒ‹ใƒฅใƒผใ‚’้š ใ™", "instant": "ๅณๆ™‚", "instantTooltip": "็”ŸๆˆๅฎŒไบ†ๅพŒใ™ใใซใ‚ญใƒฅใƒผใซ่ฟฝๅŠ ", "interrupt": "็พๅœจใฎๅฎŸ่กŒใ‚’ไธญๆญข", + "light": "ใƒฉใ‚คใƒˆ", + "manageExtensions": "ๆ‹กๅผตๆฉŸ่ƒฝใฎ็ฎก็†", "onChange": "ๅค‰ๆ›ดๆ™‚", "onChangeTooltip": "ๅค‰ๆ›ดใŒ่กŒใ‚ใ‚Œใ‚‹ใจใƒฏใƒผใ‚ฏใƒ•ใƒญใƒผใŒใ‚ญใƒฅใƒผใซ่ฟฝๅŠ ใ•ใ‚Œใพใ™", "refresh": "ใƒŽใƒผใƒ‰ใ‚’ๆ›ดๆ–ฐ", @@ -721,7 +743,9 @@ "run": "ๅฎŸ่กŒใ™ใ‚‹", "runWorkflow": "ใƒฏใƒผใ‚ฏใƒ•ใƒญใƒผใ‚’ๅฎŸ่กŒใ™ใ‚‹ (Shiftใงๅ…ˆ้ ญใซใ‚ญใƒฅใƒผ)", "runWorkflowFront": "ใƒฏใƒผใ‚ฏใƒ•ใƒญใƒผใ‚’ๅฎŸ่กŒใ™ใ‚‹ (ๅ…ˆ้ ญใซใ‚ญใƒฅใƒผ)", + "settings": "่จญๅฎš", "showMenu": "ใƒกใƒ‹ใƒฅใƒผใ‚’่กจ็คบ", + "theme": "ใƒ†ใƒผใƒž", "toggleBottomPanel": "ไธ‹้ƒจใƒ‘ใƒใƒซใ‚’ๅˆ‡ใ‚Šๆ›ฟใˆ" }, "menuLabels": { @@ -731,8 +755,9 @@ "Bypass/Unbypass Selected Nodes": "้ธๆŠžใ—ใŸใƒŽใƒผใƒ‰ใฎใƒใ‚คใƒ‘ใ‚น/ใƒใ‚คใƒ‘ใ‚น่งฃ้™ค", "Canvas Toggle Link Visibility": "ใ‚ญใƒฃใƒณใƒใ‚นใฎใƒชใƒณใ‚ฏ่กจ็คบใ‚’ๅˆ‡ใ‚Šๆ›ฟใˆ", "Canvas Toggle Lock": "ใ‚ญใƒฃใƒณใƒใ‚นใฎใƒญใƒƒใ‚ฏใ‚’ๅˆ‡ใ‚Šๆ›ฟใˆ", + "Canvas Toggle Minimap": "ใ‚ญใƒฃใƒณใƒใ‚น ใƒŸใƒ‹ใƒžใƒƒใƒ—ใฎๅˆ‡ใ‚Šๆ›ฟใˆ", "Check for Custom Node Updates": "ใ‚ซใ‚นใ‚ฟใƒ ใƒŽใƒผใƒ‰ใฎใ‚ขใƒƒใƒ—ใƒ‡ใƒผใƒˆใ‚’็ขบ่ช", - "Check for Updates": "ๆ›ดๆ–ฐใ‚’็ขบ่ช", + "Check for Updates": "ๆ›ดๆ–ฐใ‚’็ขบ่ชใ™ใ‚‹", "Clear Pending Tasks": "ไฟ็•™ไธญใฎใ‚ฟใ‚นใ‚ฏใ‚’ใ‚ฏใƒชใ‚ข", "Clear Workflow": "ใƒฏใƒผใ‚ฏใƒ•ใƒญใƒผใ‚’ใ‚ฏใƒชใ‚ข", "Clipspace": "ใ‚ฏใƒชใƒƒใƒ—ใ‚นใƒšใƒผใ‚น", @@ -746,6 +771,8 @@ "Convert Selection to Subgraph": "้ธๆŠž็ฏ„ๅ›ฒใ‚’ใ‚ตใƒ–ใ‚ฐใƒฉใƒ•ใซๅค‰ๆ›", "Convert selected nodes to group node": "้ธๆŠžใ—ใŸใƒŽใƒผใƒ‰ใ‚’ใ‚ฐใƒซใƒผใƒ—ใƒŽใƒผใƒ‰ใซๅค‰ๆ›", "Custom Nodes (Legacy)": "ใ‚ซใ‚นใ‚ฟใƒ ใƒŽใƒผใƒ‰๏ผˆใƒฌใ‚ฌใ‚ทใƒผ๏ผ‰", + "Custom Nodes Manager": "ใ‚ซใ‚นใ‚ฟใƒ ใƒŽใƒผใƒ‰ใƒžใƒใƒผใ‚ธใƒฃ", + "Decrease Brush Size in MaskEditor": "ใƒžใ‚นใ‚ฏใ‚จใƒ‡ใ‚ฃใ‚ฟใงใƒ–ใƒฉใ‚ทใ‚ตใ‚คใ‚บใ‚’ๅฐใ•ใใ™ใ‚‹", "Delete Selected Items": "้ธๆŠžใ—ใŸใ‚ขใ‚คใƒ†ใƒ ใ‚’ๅ‰Š้™ค", "Desktop User Guide": "ใƒ‡ใ‚นใ‚ฏใƒˆใƒƒใƒ—ใƒฆใƒผใ‚ถใƒผใ‚ฌใ‚คใƒ‰", "Duplicate Current Workflow": "็พๅœจใฎใƒฏใƒผใ‚ฏใƒ•ใƒญใƒผใ‚’่ค‡่ฃฝ", @@ -757,6 +784,7 @@ "Give Feedback": "ใƒ•ใ‚ฃใƒผใƒ‰ใƒใƒƒใ‚ฏใ‚’้€ใ‚‹", "Group Selected Nodes": "้ธๆŠžใ—ใŸใƒŽใƒผใƒ‰ใ‚’ใ‚ฐใƒซใƒผใƒ—ๅŒ–", "Help": "ใƒ˜ใƒซใƒ—", + "Increase Brush Size in MaskEditor": "ใƒžใ‚นใ‚ฏใ‚จใƒ‡ใ‚ฃใ‚ฟใงใƒ–ใƒฉใ‚ทใ‚ตใ‚คใ‚บใ‚’ๅคงใใใ™ใ‚‹", "Install Missing Custom Nodes": "ไธ่ถณใ—ใฆใ„ใ‚‹ใ‚ซใ‚นใ‚ฟใƒ ใƒŽใƒผใƒ‰ใ‚’ใ‚คใƒณใ‚นใƒˆใƒผใƒซ", "Interrupt": "ไธญๆ–ญ", "Load Default Workflow": "ใƒ‡ใƒ•ใ‚ฉใƒซใƒˆใƒฏใƒผใ‚ฏใƒ•ใƒญใƒผใ‚’่ชญใฟ่พผใ‚€", @@ -1175,7 +1203,6 @@ }, "showFlatList": "ใƒ•ใƒฉใƒƒใƒˆใƒชใ‚นใƒˆใ‚’่กจ็คบ" }, - "themeToggle": "ใƒ†ใƒผใƒžใฎๅˆ‡ใ‚Šๆ›ฟใˆ", "workflowTab": { "confirmDelete": "ใ“ใฎใƒฏใƒผใ‚ฏใƒ•ใƒญใƒผใ‚’ๅ‰Š้™คใ—ใฆใ‚‚ใ‚ˆใ‚ใ—ใ„ใงใ™ใ‹๏ผŸ", "confirmDeleteTitle": "ใƒฏใƒผใ‚ฏใƒ•ใƒญใƒผใ‚’ๅ‰Š้™คใ—ใพใ™ใ‹๏ผŸ", @@ -1599,6 +1626,13 @@ "prefix": "{prefix}ใงๅง‹ใ‚ใ‚‹ๅฟ…่ฆใŒใ‚ใ‚Šใพใ™", "required": "ๅฟ…้ ˆ" }, + "versionMismatchWarning": { + "dismiss": "้–‰ใ˜ใ‚‹", + "frontendNewer": "ใƒ•ใƒญใƒณใƒˆใ‚จใƒณใƒ‰ใฎใƒใƒผใ‚ธใƒงใƒณ {frontendVersion} ใฏใ€ใƒใƒƒใ‚ฏใ‚จใƒณใƒ‰ใฎใƒใƒผใ‚ธใƒงใƒณ {backendVersion} ใจไบ’ๆ›ๆ€งใŒใชใ„ๅฏ่ƒฝๆ€งใŒใ‚ใ‚Šใพใ™ใ€‚", + "frontendOutdated": "ใƒ•ใƒญใƒณใƒˆใ‚จใƒณใƒ‰ใฎใƒใƒผใ‚ธใƒงใƒณ {frontendVersion} ใฏๅคใใชใฃใฆใ„ใพใ™ใ€‚ใƒใƒƒใ‚ฏใ‚จใƒณใƒ‰ใฏใƒใƒผใ‚ธใƒงใƒณ {requiredVersion} ไปฅไธŠใŒๅฟ…่ฆใงใ™ใ€‚", + "title": "ใƒใƒผใ‚ธใƒงใƒณไบ’ๆ›ๆ€งใฎ่ญฆๅ‘Š", + "updateFrontend": "ใƒ•ใƒญใƒณใƒˆใ‚จใƒณใƒ‰ใ‚’ๆ›ดๆ–ฐ" + }, "welcome": { "getStarted": "ใฏใ˜ใ‚ใ‚‹", "title": "ComfyUIใธใ‚ˆใ†ใ“ใ" diff --git a/src/locales/ja/settings.json b/src/locales/ja/settings.json index 897db2680..46b239f15 100644 --- a/src/locales/ja/settings.json +++ b/src/locales/ja/settings.json @@ -29,6 +29,13 @@ "name": "ใ‚ญใƒฃใƒณใƒใ‚น่ƒŒๆ™ฏ็”ปๅƒ", "tooltip": "ใ‚ญใƒฃใƒณใƒใ‚นใฎ่ƒŒๆ™ฏ็”ปๅƒใฎURLใงใ™ใ€‚ๅ‡บๅŠ›ใƒ‘ใƒใƒซใง็”ปๅƒใ‚’ๅณใ‚ฏใƒชใƒƒใ‚ฏใ—ใ€ใ€Œ่ƒŒๆ™ฏใจใ—ใฆ่จญๅฎšใ€ใ‚’้ธๆŠžใ™ใ‚‹ใจไฝฟ็”จใงใใพใ™ใ€‚" }, + "Comfy_Canvas_NavigationMode": { + "name": "ใ‚ญใƒฃใƒณใƒใ‚นใƒŠใƒ“ใ‚ฒใƒผใ‚ทใƒงใƒณใƒขใƒผใƒ‰", + "options": { + "Left-Click Pan (Legacy)": "ๅทฆใ‚ฏใƒชใƒƒใ‚ฏใƒ‘ใƒณ๏ผˆใƒฌใ‚ฌใ‚ทใƒผ๏ผ‰", + "Standard (New)": "ๆจ™ๆบ–๏ผˆๆ–ฐ๏ผ‰" + } + }, "Comfy_Canvas_SelectionToolbox": { "name": "้ธๆŠžใƒ„ใƒผใƒซใƒœใƒƒใ‚ฏใ‚นใ‚’่กจ็คบ" }, @@ -329,10 +336,6 @@ }, "tooltip": "ใƒกใƒ‹ใƒฅใƒผใƒใƒผใฎไฝ็ฝฎใ€‚ใƒขใƒใ‚คใƒซใƒ‡ใƒใ‚คใ‚นใงใฏใ€ใƒกใƒ‹ใƒฅใƒผใฏๅธธใซไธŠ้ƒจใซ่กจ็คบใ•ใ‚Œใพใ™ใ€‚" }, - "Comfy_Validation_NodeDefs": { - "name": "ใƒŽใƒผใƒ‰ๅฎš็พฉใ‚’ๆคœ่จผ๏ผˆ้…ใ„๏ผ‰", - "tooltip": "ใƒŽใƒผใƒ‰้–‹็™บ่€…ใซๆŽจๅฅจใ•ใ‚Œใพใ™ใ€‚ใ“ใ‚Œใซใ‚ˆใ‚Šใ€่ตทๅ‹•ๆ™‚ใซใ™ในใฆใฎใƒŽใƒผใƒ‰ๅฎš็พฉใŒๆคœ่จผใ•ใ‚Œใพใ™ใ€‚" - }, "Comfy_Validation_Workflows": { "name": "ใƒฏใƒผใ‚ฏใƒ•ใƒญใƒผใ‚’ๆคœ่จผ" }, @@ -399,10 +402,6 @@ "LiteGraph_Node_TooltipDelay": { "name": "ใƒ„ใƒผใƒซใƒใƒƒใƒ—้…ๅปถ" }, - "LiteGraph_Pointer_TrackpadGestures": { - "name": "ใƒˆใƒฉใƒƒใ‚ฏใƒ‘ใƒƒใƒ‰ใ‚ธใ‚งใ‚นใƒใƒฃใƒผใ‚’ๆœ‰ๅŠนใซใ™ใ‚‹", - "tooltip": "ใ“ใฎ่จญๅฎšใ‚’ๆœ‰ๅŠนใซใ™ใ‚‹ใจใ€ใ‚ญใƒฃใƒณใƒใ‚นใงใƒˆใƒฉใƒƒใ‚ฏใƒ‘ใƒƒใƒ‰ใƒขใƒผใƒ‰ใŒๆœ‰ๅŠนใซใชใ‚Šใ€ใƒ”ใƒณใƒใ‚บใƒผใƒ ใ‚„2ๆœฌๆŒ‡ใงใฎใƒ‘ใƒณๆ“ไฝœใŒๅฏ่ƒฝใซใชใ‚Šใพใ™ใ€‚" - }, "LiteGraph_Reroute_SplineOffset": { "name": "ใƒชใƒซใƒผใƒˆใ‚นใƒ—ใƒฉใ‚คใƒณใ‚ชใƒ•ใ‚ปใƒƒใƒˆ", "tooltip": "ใƒชใƒซใƒผใƒˆไธญๅฟƒ็‚นใ‹ใ‚‰ใฎใƒ™ใ‚ธใ‚จๅˆถๅพก็‚นใฎใ‚ชใƒ•ใ‚ปใƒƒใƒˆ" diff --git a/src/locales/ko/commands.json b/src/locales/ko/commands.json index 14b518a2e..30c2fcfbb 100644 --- a/src/locales/ko/commands.json +++ b/src/locales/ko/commands.json @@ -71,6 +71,9 @@ "Comfy_Canvas_ToggleLock": { "label": "์บ”๋ฒ„์Šค ์ž ๊ธˆ ํ† ๊ธ€" }, + "Comfy_Canvas_ToggleMinimap": { + "label": "์บ”๋ฒ„์Šค ๋ฏธ๋‹ˆ๋งต ์ „ํ™˜" + }, "Comfy_Canvas_ToggleSelectedNodes_Bypass": { "label": "์„ ํƒํ•œ ๋…ธ๋“œ ์šฐํšŒ/์šฐํšŒ ํ•ด์ œ" }, @@ -96,13 +99,13 @@ "label": "๋ณด๋ฅ˜ ์ค‘์ธ ์ž‘์—… ์ง€์šฐ๊ธฐ" }, "Comfy_ClearWorkflow": { - "label": "์›Œํฌํ”Œ๋กœ ์ง€์šฐ๊ธฐ" + "label": "์›Œํฌํ”Œ๋กœ ๋‚ด์šฉ ์ง€์šฐ๊ธฐ" }, "Comfy_ContactSupport": { "label": "์ง€์›ํŒ€์— ๋ฌธ์˜ํ•˜๊ธฐ" }, "Comfy_DuplicateWorkflow": { - "label": "ํ˜„์žฌ ์›Œํฌํ”Œ๋กœ์šฐ ๋ณต์ œ" + "label": "ํ˜„์žฌ ์›Œํฌํ”Œ๋กœ ๋ณต์ œ" }, "Comfy_ExportWorkflow": { "label": "์›Œํฌํ”Œ๋กœ ๋‚ด๋ณด๋‚ด๊ธฐ" @@ -170,6 +173,12 @@ "Comfy_Manager_ToggleManagerProgressDialog": { "label": "์ง„ํ–‰ ์ƒํ™ฉ ๋Œ€ํ™” ์ƒ์ž ์ „ํ™˜" }, + "Comfy_MaskEditor_BrushSize_Decrease": { + "label": "๋งˆ์Šคํฌ ํŽธ์ง‘๊ธฐ์—์„œ ๋ธŒ๋Ÿฌ์‹œ ํฌ๊ธฐ ์ค„์ด๊ธฐ" + }, + "Comfy_MaskEditor_BrushSize_Increase": { + "label": "๋งˆ์Šคํฌ ํŽธ์ง‘๊ธฐ์—์„œ ๋ธŒ๋Ÿฌ์‹œ ํฌ๊ธฐ ๋Š˜๋ฆฌ๊ธฐ" + }, "Comfy_MaskEditor_OpenMaskEditor": { "label": "์„ ํƒํ•œ ๋…ธ๋“œ ๋งˆ์Šคํฌ ํŽธ์ง‘๊ธฐ ์—ด๊ธฐ" }, @@ -225,7 +234,7 @@ "label": "๋กœ๊ทธ์•„์›ƒ" }, "Workspace_CloseWorkflow": { - "label": "ํ˜„์žฌ ์›Œํฌํ”Œ๋กœ์šฐ ๋‹ซ๊ธฐ" + "label": "ํ˜„์žฌ ์›Œํฌํ”Œ๋กœ ๋‹ซ๊ธฐ" }, "Workspace_NextOpenedWorkflow": { "label": "๋‹ค์Œ ์—ด๋ฆฐ ์›Œํฌํ”Œ๋กœ" diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index baf8b6000..32b0ef32a 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -5,7 +5,7 @@ "totalCost": "์ด ๋น„์šฉ" }, "apiNodesSignInDialog": { - "message": "์ด ์›Œํฌํ”Œ๋กœ์šฐ์—๋Š” API ๋…ธ๋“œ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์œผ๋ฉฐ, ์‹คํ–‰ํ•˜๋ ค๋ฉด ๊ณ„์ •์— ๋กœ๊ทธ์ธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.", + "message": "์ด ์›Œํฌํ”Œ๋กœ์—๋Š” API ๋…ธ๋“œ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์œผ๋ฉฐ, ์‹คํ–‰ํ•˜๋ ค๋ฉด ๊ณ„์ •์— ๋กœ๊ทธ์ธํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.", "title": "API ๋…ธ๋“œ ์‚ฌ์šฉ์— ํ•„์š”ํ•œ ๋กœ๊ทธ์ธ" }, "auth": { @@ -82,6 +82,12 @@ "title": "๊ณ„์ • ์ƒ์„ฑ" } }, + "breadcrumbsMenu": { + "clearWorkflow": "์›Œํฌํ”Œ๋กœ ๋‚ด์šฉ ์ง€์šฐ๊ธฐ", + "deleteWorkflow": "์›Œํฌํ”Œ๋กœ ์‚ญ์ œ", + "duplicate": "๋ณต์ œ", + "enterNewName": "์ƒˆ ์ด๋ฆ„ ์ž…๋ ฅ" + }, "chatHistory": { "cancelEdit": "์ทจ์†Œ", "cancelEditTooltip": "ํŽธ์ง‘ ์ทจ์†Œ", @@ -155,7 +161,7 @@ "time": "์‹œ๊ฐ„", "topUp": { "buyNow": "์ง€๊ธˆ ๊ตฌ๋งค", - "insufficientMessage": "์ด ์›Œํฌํ”Œ๋กœ์šฐ๋ฅผ ์‹คํ–‰ํ•˜๊ธฐ์— ํฌ๋ ˆ๋”ง์ด ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค.", + "insufficientMessage": "์ด ์›Œํฌํ”Œ๋กœ๋ฅผ ์‹คํ–‰ํ•˜๊ธฐ์— ํฌ๋ ˆ๋”ง์ด ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค.", "insufficientTitle": "ํฌ๋ ˆ๋”ง ๋ถ€์กฑ", "maxAmount": "(์ตœ๋Œ€ $1,000 USD)", "quickPurchase": "๋น ๋ฅธ ๊ตฌ๋งค", @@ -246,7 +252,7 @@ "errorDialog": { "defaultTitle": "์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค", "extensionFileHint": "๋‹ค์Œ ์Šคํฌ๋ฆฝํŠธ ๋•Œ๋ฌธ์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค", - "loadWorkflowTitle": "์›Œํฌํ”Œ๋กœ์šฐ ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ค์‹œ ๋กœ๋“œํ•˜๋Š” ์ค‘ ์˜ค๋ฅ˜๋กœ ์ธํ•ด ๋กœ๋“œ๊ฐ€ ์ค‘๋‹จ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", + "loadWorkflowTitle": "์›Œํฌํ”Œ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ค์‹œ ๋กœ๋“œํ•˜๋Š” ์ค‘ ์˜ค๋ฅ˜๋กœ ์ธํ•ด ๋กœ๋“œ๊ฐ€ ์ค‘๋‹จ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", "noStackTrace": "์Šคํƒ ์ถ”์ ์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค", "promptExecutionError": "ํ”„๋กฌํ”„ํŠธ ์‹คํ–‰ ์‹คํŒจ" }, @@ -291,6 +297,7 @@ "devices": "์žฅ์น˜", "disableAll": "๋ชจ๋‘ ๋น„ํ™œ์„ฑํ™”", "disabling": "๋น„ํ™œ์„ฑํ™” ์ค‘", + "dismiss": "๋‹ซ๊ธฐ", "download": "๋‹ค์šด๋กœ๋“œ", "edit": "ํŽธ์ง‘", "empty": "๋น„์–ด ์žˆ์Œ", @@ -305,6 +312,8 @@ "filter": "ํ•„ํ„ฐ", "findIssues": "๋ฌธ์ œ ์ฐพ๊ธฐ", "firstTimeUIMessage": "์ƒˆ UI๋ฅผ ์ฒ˜์Œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. \"๋ฉ”๋‰ด > ์ƒˆ ๋ฉ”๋‰ด ์‚ฌ์šฉ > ๋น„ํ™œ์„ฑํ™”\"๋ฅผ ์„ ํƒํ•˜์—ฌ ์ด์ „ UI๋กœ ๋ณต์›ํ•˜์„ธ์š”.", + "frontendNewer": "ํ”„๋ก ํŠธ์—”๋“œ ๋ฒ„์ „ {frontendVersion}์ด(๊ฐ€) ๋ฐฑ์—”๋“œ ๋ฒ„์ „ {backendVersion}๊ณผ(์™€) ํ˜ธํ™˜๋˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", + "frontendOutdated": "ํ”„๋ก ํŠธ์—”๋“œ ๋ฒ„์ „ {frontendVersion}์ด(๊ฐ€) ์˜ค๋ž˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋ฐฑ์—”๋“œ๋Š” {requiredVersion} ์ด์ƒ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", "goToNode": "๋…ธ๋“œ๋กœ ์ด๋™", "help": "๋„์›€๋ง", "icon": "์•„์ด์ฝ˜", @@ -326,17 +335,20 @@ "loadingPanel": "{panel} ํŒจ๋„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...", "login": "๋กœ๊ทธ์ธ", "logs": "๋กœ๊ทธ", + "micPermissionDenied": "๋งˆ์ดํฌ ๊ถŒํ•œ์ด ๊ฑฐ๋ถ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค", "migrate": "์ด์ „(migrate)", "missing": "๋ˆ„๋ฝ๋จ", "name": "์ด๋ฆ„", "newFolder": "์ƒˆ ํด๋”", "next": "๋‹ค์Œ", "no": "์•„๋‹ˆ์˜ค", + "noAudioRecorded": "๋…น์Œ๋œ ์˜ค๋””์˜ค๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค", "noResultsFound": "๊ฒฐ๊ณผ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", "noTasksFound": "์ž‘์—…์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", "noTasksFoundMessage": "๋Œ€๊ธฐ์—ด์— ์ž‘์—…์ด ์—†์Šต๋‹ˆ๋‹ค.", "noWorkflowsFound": "์›Œํฌํ”Œ๋กœ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", "nodes": "๋…ธ๋“œ", + "nodesRunning": "๋…ธ๋“œ ์‹คํ–‰ ์ค‘", "ok": "ํ™•์ธ", "openNewIssue": "์ƒˆ ๋ฌธ์ œ ์—ด๊ธฐ", "overwrite": "๋ฎ์–ด์“ฐ๊ธฐ", @@ -370,7 +382,9 @@ "showReport": "๋ณด๊ณ ์„œ ๋ณด๊ธฐ", "sort": "์ •๋ ฌ", "source": "์†Œ์Šค", + "startRecording": "๋…น์Œ ์‹œ์ž‘", "status": "์ƒํƒœ", + "stopRecording": "๋…น์Œ ์ค‘์ง€", "success": "์„ฑ๊ณต", "systemInfo": "์‹œ์Šคํ…œ ์ •๋ณด", "terminal": "ํ„ฐ๋ฏธ๋„", @@ -379,11 +393,14 @@ "unknownError": "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜", "update": "์—…๋ฐ์ดํŠธ", "updateAvailable": "์—…๋ฐ์ดํŠธ ๊ฐ€๋Šฅ", + "updateFrontend": "ํ”„๋ก ํŠธ์—”๋“œ ์—…๋ฐ์ดํŠธ", "updated": "์—…๋ฐ์ดํŠธ ๋จ", "updating": "์—…๋ฐ์ดํŠธ ์ค‘", "upload": "์—…๋กœ๋“œ", "usageHint": "์‚ฌ์šฉ ํžŒํŠธ", "user": "์‚ฌ์šฉ์ž", + "versionMismatchWarning": "๋ฒ„์ „ ํ˜ธํ™˜์„ฑ ๊ฒฝ๊ณ ", + "versionMismatchWarningMessage": "{warning}: {detail} ์—…๋ฐ์ดํŠธ ์ง€์นจ์€ https://docs.comfy.org/installation/update_comfyui#common-update-issues ๋ฅผ ๋ฐฉ๋ฌธํ•˜์„ธ์š”.", "videoFailedToLoad": "๋น„๋””์˜ค๋ฅผ ๋กœ๋“œํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", "workflow": "์›Œํฌํ”Œ๋กœ" }, @@ -393,6 +410,7 @@ "resetView": "๋ณด๊ธฐ ์žฌ์„ค์ •", "selectMode": "์„ ํƒ ๋ชจ๋“œ", "toggleLinkVisibility": "๋งํฌ ๊ฐ€์‹œ์„ฑ ์ „ํ™˜", + "toggleMinimap": "๋ฏธ๋‹ˆ๋งต ์ „ํ™˜", "zoomIn": "ํ™•๋Œ€", "zoomOut": "์ถ•์†Œ" }, @@ -624,7 +642,7 @@ "enabled": "ํ™œ์„ฑํ™”", "nodePack": "๋…ธ๋“œ ํŒฉ" }, - "inWorkflow": "์›Œํฌํ”Œ๋กœ์šฐ ๋‚ด", + "inWorkflow": "์›Œํฌํ”Œ๋กœ ๋‚ด", "infoPanelEmpty": "์ •๋ณด๋ฅผ ๋ณด๋ ค๋ฉด ํ•ญ๋ชฉ์„ ํด๋ฆญํ•˜์„ธ์š”", "installAllMissingNodes": "๋ชจ๋“  ๋ˆ„๋ฝ๋œ ๋…ธ๋“œ ์„ค์น˜", "installSelected": "์„ ํƒํ•œ ํ•ญ๋ชฉ ์„ค์น˜", @@ -707,21 +725,27 @@ "batchCountTooltip": "์›Œํฌํ”Œ๋กœ ์ž‘์—…์„ ์‹คํ–‰ ๋Œ€๊ธฐ์—ด์— ๋ฐ˜๋ณต ์ถ”๊ฐ€ํ•  ํšŸ์ˆ˜", "clear": "์›Œํฌํ”Œ๋กœ ๋น„์šฐ๊ธฐ", "clipspace": "ํด๋ฆฝ์ŠคํŽ˜์ด์Šค ์—ด๊ธฐ", + "dark": "๋‹คํฌ", "disabled": "๋น„ํ™œ์„ฑํ™”๋จ", "disabledTooltip": "์›Œํฌํ”Œ๋กœ ์ž‘์—…์„ ์ž๋™์œผ๋กœ ์‹คํ–‰ ๋Œ€๊ธฐ์—ด์— ์ถ”๊ฐ€ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", "execute": "์‹คํ–‰", + "help": "๋„์›€๋ง", "hideMenu": "๋ฉ”๋‰ด ์ˆจ๊ธฐ๊ธฐ", "instant": "์ฆ‰์‹œ", "instantTooltip": "์›Œํฌํ”Œ๋กœ ์‹คํ–‰์ด ์™„๋ฃŒ๋˜๋ฉด ์ฆ‰์‹œ ์‹คํ–‰ ๋Œ€๊ธฐ์—ด์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.", "interrupt": "ํ˜„์žฌ ์‹คํ–‰ ์ทจ์†Œ", + "light": "๋ผ์ดํŠธ", + "manageExtensions": "ํ™•์žฅ ํ”„๋กœ๊ทธ๋žจ ๊ด€๋ฆฌ", "onChange": "๋ณ€๊ฒฝ ์‹œ", "onChangeTooltip": "๋ณ€๊ฒฝ์ด ์žˆ๋Š” ๊ฒฝ์šฐ์—๋งŒ ์›Œํฌํ”Œ๋กœ๋ฅผ ์‹คํ–‰ ๋Œ€๊ธฐ์—ด์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.", "refresh": "๋…ธ๋“œ ์ •์˜ ์ƒˆ๋กœ ๊ณ ์นจ", "resetView": "์บ”๋ฒ„์Šค ๋ณด๊ธฐ ์žฌ์„ค์ •", "run": "์‹คํ–‰", - "runWorkflow": "์›Œํฌํ”Œ๋กœ์šฐ ์‹คํ–‰ (์‹œํ”„ํŠธ ํ‚ค์™€ ํ•จ๊ป˜ ํด๋ฆญ์‹œ ๊ฐ€์žฅ ๋จผ์ € ์‹คํ–‰)", - "runWorkflowFront": "์›Œํฌํ”Œ๋กœ์šฐ ์‹คํ–‰ (๊ฐ€์žฅ ๋จผ์ € ์‹คํ–‰)", + "runWorkflow": "์›Œํฌํ”Œ๋กœ ์‹คํ–‰ (์‹œํ”„ํŠธ ํ‚ค์™€ ํ•จ๊ป˜ ํด๋ฆญ์‹œ ๊ฐ€์žฅ ๋จผ์ € ์‹คํ–‰)", + "runWorkflowFront": "์›Œํฌํ”Œ๋กœ ์‹คํ–‰ (๊ฐ€์žฅ ๋จผ์ € ์‹คํ–‰)", + "settings": "์„ค์ •", "showMenu": "๋ฉ”๋‰ด ํ‘œ์‹œ", + "theme": "ํ…Œ๋งˆ", "toggleBottomPanel": "ํ•˜๋‹จ ํŒจ๋„ ์ „ํ™˜" }, "menuLabels": { @@ -731,6 +755,7 @@ "Bypass/Unbypass Selected Nodes": "์„ ํƒํ•œ ๋…ธ๋“œ ์šฐํšŒ/์šฐํšŒ ํ•ด์ œ", "Canvas Toggle Link Visibility": "์บ”๋ฒ„์Šค ํ† ๊ธ€ ๋งํฌ ๊ฐ€์‹œ์„ฑ", "Canvas Toggle Lock": "์บ”๋ฒ„์Šค ํ† ๊ธ€ ์ž ๊ธˆ", + "Canvas Toggle Minimap": "์บ”๋ฒ„์Šค ๋ฏธ๋‹ˆ๋งต ์ „ํ™˜", "Check for Custom Node Updates": "์ปค์Šคํ…€ ๋…ธ๋“œ ์—…๋ฐ์ดํŠธ ํ™•์ธ", "Check for Updates": "์—…๋ฐ์ดํŠธ ํ™•์ธ", "Clear Pending Tasks": "๋ณด๋ฅ˜ ์ค‘์ธ ์ž‘์—… ์ œ๊ฑฐํ•˜๊ธฐ", @@ -745,7 +770,9 @@ "Contact Support": "๊ณ ๊ฐ ์ง€์› ๋ฌธ์˜", "Convert Selection to Subgraph": "์„ ํƒ ์˜์—ญ์„ ์„œ๋ธŒ๊ทธ๋ž˜ํ”„๋กœ ๋ณ€ํ™˜", "Convert selected nodes to group node": "์„ ํƒํ•œ ๋…ธ๋“œ๋ฅผ ๊ทธ๋ฃน ๋…ธ๋“œ๋กœ ๋ณ€ํ™˜", - "Custom Nodes (Legacy)": "์ปค์Šคํ…€ ๋…ธ๋“œ (๋ ˆ๊ฑฐ์‹œ)", + "Custom Nodes (Legacy)": "์ปค์Šคํ…€ ๋…ธ๋“œ(๋ ˆ๊ฑฐ์‹œ)", + "Custom Nodes Manager": "์‚ฌ์šฉ์ž ์ •์˜ ๋…ธ๋“œ ๊ด€๋ฆฌ์ž", + "Decrease Brush Size in MaskEditor": "๋งˆ์Šคํฌ ํŽธ์ง‘๊ธฐ์—์„œ ๋ธŒ๋Ÿฌ์‹œ ํฌ๊ธฐ ์ค„์ด๊ธฐ", "Delete Selected Items": "์„ ํƒํ•œ ํ•ญ๋ชฉ ์‚ญ์ œ", "Desktop User Guide": "๋ฐ์Šคํฌํ†ฑ ์‚ฌ์šฉ์ž ๊ฐ€์ด๋“œ", "Duplicate Current Workflow": "ํ˜„์žฌ ์›Œํฌํ”Œ๋กœ ๋ณต์ œ", @@ -757,6 +784,7 @@ "Give Feedback": "ํ”ผ๋“œ๋ฐฑ ์ œ๊ณต", "Group Selected Nodes": "์„ ํƒํ•œ ๋…ธ๋“œ ๊ทธ๋ฃนํ™”", "Help": "๋„์›€๋ง", + "Increase Brush Size in MaskEditor": "๋งˆ์Šคํฌ ํŽธ์ง‘๊ธฐ์—์„œ ๋ธŒ๋Ÿฌ์‹œ ํฌ๊ธฐ ๋Š˜๋ฆฌ๊ธฐ", "Install Missing Custom Nodes": "๋ˆ„๋ฝ๋œ ์ปค์Šคํ…€ ๋…ธ๋“œ ์„ค์น˜", "Interrupt": "์ค‘๋‹จ", "Load Default Workflow": "๊ธฐ๋ณธ ์›Œํฌํ”Œ๋กœ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ", @@ -803,7 +831,7 @@ "Toggle Logs Bottom Panel": "๋กœ๊ทธ ํ•˜๋‹จ ํŒจ๋„ ์ „ํ™˜", "Toggle Model Library Sidebar": "๋ชจ๋ธ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์ด๋“œ๋ฐ” ์ „ํ™˜", "Toggle Node Library Sidebar": "๋…ธ๋“œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์‚ฌ์ด๋“œ๋ฐ” ์ „ํ™˜", - "Toggle Queue Sidebar": "๋Œ€๊ธฐ์—ด ์‚ฌ์ด๋“œ๋ฐ” ์ „ํ™˜", + "Toggle Queue Sidebar": "์‹คํ–‰ ๋Œ€๊ธฐ์—ด ์‚ฌ์ด๋“œ๋ฐ” ์ „ํ™˜", "Toggle Search Box": "๊ฒ€์ƒ‰ ์ƒ์ž ์ „ํ™˜", "Toggle Terminal Bottom Panel": "ํ„ฐ๋ฏธ๋„ ํ•˜๋‹จ ํŒจ๋„ ์ „ํ™˜", "Toggle Theme (Dark/Light)": "ํ…Œ๋งˆ ์ „ํ™˜ (์–ด๋‘์šด/๋ฐ์€)", @@ -902,7 +930,7 @@ "documentationPage": "๋ฌธ์„œ ํŽ˜์ด์ง€", "inputs": "์ž…๋ ฅ", "loadError": "๋„์›€๋ง์„ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค: {error}", - "moreHelp": "๋” ๋งŽ์€ ๋„์›€๋ง์€", + "moreHelp": "๋” ์ž์„ธํ•œ ๋„์›€๋ง์€", "outputs": "์ถœ๋ ฅ", "type": "์œ ํ˜•" }, @@ -1175,7 +1203,6 @@ }, "showFlatList": "ํ‰๋ฉด ๋ชฉ๋ก ํ‘œ์‹œ" }, - "themeToggle": "ํ…Œ๋งˆ ์ „ํ™˜", "workflowTab": { "confirmDelete": "์ •๋ง๋กœ ์ด ์›Œํฌํ”Œ๋กœ๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", "confirmDeleteTitle": "์›Œํฌํ”Œ๋กœ ์‚ญ์ œ", @@ -1230,11 +1257,11 @@ "stable_zero123_example": "Stable Zero123" }, "3D API": { - "api_rodin_image_to_model": "Rodin: ์ด๋ฏธ์ง€ ํˆฌ ๋ชจ๋ธ", - "api_rodin_multiview_to_model": "Rodin: ๋‹ค์ค‘๋ทฐ ํˆฌ ๋ชจ๋ธ", - "api_tripo_image_to_model": "Tripo: ์ด๋ฏธ์ง€ ํˆฌ ๋ชจ๋ธ", - "api_tripo_multiview_to_model": "Tripo: ๋‹ค์ค‘๋ทฐ ํˆฌ ๋ชจ๋ธ", - "api_tripo_text_to_model": "Tripo: ํ…์ŠคํŠธ ํˆฌ ๋ชจ๋ธ" + "api_rodin_image_to_model": "Rodin: ์ด๋ฏธ์ง€ โ†’ ๋ชจ๋ธ", + "api_rodin_multiview_to_model": "Rodin: ๋‹ค์ค‘๋ทฐ โ†’ ๋ชจ๋ธ", + "api_tripo_image_to_model": "Tripo: ์ด๋ฏธ์ง€ โ†’ ๋ชจ๋ธ", + "api_tripo_multiview_to_model": "Tripo: ๋‹ค์ค‘๋ทฐ โ†’ ๋ชจ๋ธ", + "api_tripo_text_to_model": "Tripo: ํ…์ŠคํŠธ โ†’ ๋ชจ๋ธ" }, "Area Composition": { "area_composition": "์˜์—ญ ๊ตฌ์„ฑ", @@ -1242,15 +1269,15 @@ }, "Audio": { "audio_ace_step_1_m2m_editing": "ACE Step v1 M2M ํŽธ์ง‘", - "audio_ace_step_1_t2a_instrumentals": "ACE-Step v1 ํ…์ŠคํŠธ ํˆฌ ์—ฐ์ฃผ๊ณก", - "audio_ace_step_1_t2a_song": "ACE Step v1 ํ…์ŠคํŠธ ํˆฌ ๋…ธ๋ž˜", + "audio_ace_step_1_t2a_instrumentals": "ACE-Step v1 ํ…์ŠคํŠธ โ†’ ์—ฐ์ฃผ๊ณก", + "audio_ace_step_1_t2a_song": "ACE Step v1 ํ…์ŠคํŠธ โ†’ ๋…ธ๋ž˜", "audio_stable_audio_example": "Stable Audio" }, "Basics": { "default": "์ด๋ฏธ์ง€ ์ƒ์„ฑ", "embedding_example": "์ž„๋ฒ ๋”ฉ", "gligen_textbox_example": "๊ธ€๋ฆฌ์   ํ…์ŠคํŠธ๋ฐ•์Šค", - "image2image": "์ด๋ฏธ์ง€ ํˆฌ ์ด๋ฏธ์ง€", + "image2image": "์ด๋ฏธ์ง€ โ†’ ์ด๋ฏธ์ง€", "inpaint_example": "์ธํŽ˜์ธํŠธ", "inpaint_model_outpainting": "์•„์›ƒํŽ˜์ธํŒ…", "lora": "LoRA", @@ -1264,62 +1291,62 @@ "mixing_controlnets": "์ปจํŠธ๋กค๋„ท ํ˜ผํ•ฉ" }, "Flux": { - "flux_canny_model_example": "Flux ์บ๋‹ˆ ๋ชจ๋ธ", - "flux_depth_lora_example": "Flux ๊นŠ์ด ๋กœ๋ผ", - "flux_dev_checkpoint_example": "Flux Dev fp8", - "flux_dev_full_text_to_image": "Flux Dev ์ „์ฒด ํ…์ŠคํŠธ ํˆฌ ์ด๋ฏธ์ง€", - "flux_fill_inpaint_example": "Flux ์ธํŽ˜์ธํŠธ", - "flux_fill_outpaint_example": "Flux ์•„์›ƒํŽ˜์ธํŠธ", - "flux_kontext_dev_basic": "Flux Kontext Dev(๊ธฐ๋ณธ)", - "flux_kontext_dev_grouped": "Flux Kontext Dev(๊ทธ๋ฃนํ™”)", - "flux_redux_model_example": "Flux Redux ๋ชจ๋ธ", - "flux_schnell": "Flux Schnell fp8", - "flux_schnell_full_text_to_image": "Flux Schnell ์ „์ฒด ํ…์ŠคํŠธ ํˆฌ ์ด๋ฏธ์ง€" + "flux_canny_model_example": "FLUX ์บ๋‹ˆ ๋ชจ๋ธ", + "flux_depth_lora_example": "FLUX ๊นŠ์ด ๋กœ๋ผ", + "flux_dev_checkpoint_example": "FLUX Dev fp8", + "flux_dev_full_text_to_image": "FLUX Dev ์ „์ฒด ํ…์ŠคํŠธ ํˆฌ ์ด๋ฏธ์ง€", + "flux_fill_inpaint_example": "FLUX ์ธํŽ˜์ธํŠธ", + "flux_fill_outpaint_example": "FLUX ์•„์›ƒํŽ˜์ธํŠธ", + "flux_kontext_dev_basic": "FLUX Kontext Dev(๊ธฐ๋ณธ)", + "flux_kontext_dev_grouped": "FLUX Kontext Dev(๊ทธ๋ฃนํ™”)", + "flux_redux_model_example": "FLUX Redux ๋ชจ๋ธ", + "flux_schnell": "FLUX Schnell fp8", + "flux_schnell_full_text_to_image": "FLUX Schnell Full ํ…์ŠคํŠธ โ†’ ์ด๋ฏธ์ง€" }, "Image": { "hidream_e1_full": "HiDream E1 Full", "hidream_i1_dev": "HiDream I1 Dev", "hidream_i1_fast": "HiDream I1 Fast", "hidream_i1_full": "HiDream I1 Full", - "image_chroma_text_to_image": "Chroma ํ…์ŠคํŠธ ํˆฌ ์ด๋ฏธ์ง€", + "image_chroma_text_to_image": "Chroma ํ…์ŠคํŠธ โ†’ ์ด๋ฏธ์ง€", "image_cosmos_predict2_2B_t2i": "Cosmos Predict2 2B T2I", "image_lotus_depth_v1_1": "Lotus Depth", "image_omnigen2_image_edit": "OmniGen2 ์ด๋ฏธ์ง€ ํŽธ์ง‘", - "image_omnigen2_t2i": "OmniGen2 ํ…์ŠคํŠธ ํˆฌ ์ด๋ฏธ์ง€", - "sd3_5_large_blur": "SD3.5 ๋Œ€ํ˜• ๋ธ”๋Ÿฌ", - "sd3_5_large_canny_controlnet_example": "SD3.5 ๋Œ€ํ˜• ์บ๋‹ˆ ์ปจํŠธ๋กค๋„ท", - "sd3_5_large_depth": "SD3.5 ๋Œ€ํ˜• ๊นŠ์ด", + "image_omnigen2_t2i": "OmniGen2 ํ…์ŠคํŠธ โ†’ ์ด๋ฏธ์ง€", + "sd3_5_large_blur": "SD3.5 Large ๋ธ”๋Ÿฌ", + "sd3_5_large_canny_controlnet_example": "SD3.5 Large ์บ๋‹ˆ ์ปจํŠธ๋กค๋„ท", + "sd3_5_large_depth": "SD3.5 Large ๊นŠ์ด", "sd3_5_simple_example": "SD3.5 ๊ฐ„๋‹จ ์˜ˆ์ œ", - "sdxl_refiner_prompt_example": "SDXL ๋ฆฌํŒŒ์ด๋„ˆ ํ”„๋กฌํ”„ํŠธ", + "sdxl_refiner_prompt_example": "SDXL Refiner ํ”„๋กฌํ”„ํŠธ", "sdxl_revision_text_prompts": "SDXL Revision ํ…์ŠคํŠธ ํ”„๋กฌํ”„ํŠธ", "sdxl_revision_zero_positive": "SDXL Revision Zero Positive", "sdxl_simple_example": "SDXL ๊ฐ„๋‹จ ์˜ˆ์ œ", "sdxlturbo_example": "SDXL ํ„ฐ๋ณด" }, "Image API": { - "api_bfl_flux_1_kontext_max_image": "BFL Flux.1 Kontext ๋งฅ์Šค", - "api_bfl_flux_1_kontext_multiple_images_input": "BFL Flux.1 Kontext ๋‹ค์ค‘ ์ด๋ฏธ์ง€ ์ž…๋ ฅ", - "api_bfl_flux_1_kontext_pro_image": "BFL Flux.1 Kontext ํ”„๋กœ", - "api_bfl_flux_pro_t2i": "BFL Flux[Pro]: ํ…์ŠคํŠธ ํˆฌ ์ด๋ฏธ์ง€", - "api_ideogram_v3_t2i": "Ideogram V3: ํ…์ŠคํŠธ ํˆฌ ์ด๋ฏธ์ง€", - "api_luma_photon_i2i": "Luma Photon: ์ด๋ฏธ์ง€ ํˆฌ ์ด๋ฏธ์ง€", + "api_bfl_flux_1_kontext_max_image": "BFL FLUX.1 Kontext ๋งฅ์Šค", + "api_bfl_flux_1_kontext_multiple_images_input": "BFL FLUX.1 Kontext ๋‹ค์ค‘ ์ด๋ฏธ์ง€ ์ž…๋ ฅ", + "api_bfl_flux_1_kontext_pro_image": "BFL FLUX.1 Kontext ํ”„๋กœ", + "api_bfl_flux_pro_t2i": "BFL FLUX[Pro]: ํ…์ŠคํŠธ โ†’ ์ด๋ฏธ์ง€", + "api_ideogram_v3_t2i": "Ideogram V3: ํ…์ŠคํŠธ โ†’ ์ด๋ฏธ์ง€", + "api_luma_photon_i2i": "Luma Photon: ์ด๋ฏธ์ง€ โ†’ ์ด๋ฏธ์ง€", "api_luma_photon_style_ref": "Luma Photon: ์Šคํƒ€์ผ ์ฐธ์กฐ", "api_openai_dall_e_2_inpaint": "OpenAI: Dall-E 2 ์ธํŽ˜์ธํŠธ", - "api_openai_dall_e_2_t2i": "OpenAI: Dall-E 2 ํ…์ŠคํŠธ ํˆฌ ์ด๋ฏธ์ง€", - "api_openai_dall_e_3_t2i": "OpenAI: Dall-E 3 ํ…์ŠคํŠธ ํˆฌ ์ด๋ฏธ์ง€", - "api_openai_image_1_i2i": "OpenAI: GPT-Image-1 ์ด๋ฏธ์ง€ ํˆฌ ์ด๋ฏธ์ง€", + "api_openai_dall_e_2_t2i": "OpenAI: Dall-E 2 ํ…์ŠคํŠธ โ†’ ์ด๋ฏธ์ง€", + "api_openai_dall_e_3_t2i": "OpenAI: Dall-E 3 ํ…์ŠคํŠธ โ†’ ์ด๋ฏธ์ง€", + "api_openai_image_1_i2i": "OpenAI: GPT-Image-1 ์ด๋ฏธ์ง€ โ†’ ์ด๋ฏธ์ง€", "api_openai_image_1_inpaint": "OpenAI: GPT-Image-1 ์ธํŽ˜์ธํŠธ", "api_openai_image_1_multi_inputs": "OpenAI: GPT-Image-1 ๋ฉ€ํ‹ฐ ์ž…๋ ฅ", - "api_openai_image_1_t2i": "OpenAI: GPT-Image-1 ํ…์ŠคํŠธ ํˆฌ ์ด๋ฏธ์ง€", + "api_openai_image_1_t2i": "OpenAI: GPT-Image-1 ํ…์ŠคํŠธ โ†’ ์ด๋ฏธ์ง€", "api_recraft_image_gen_with_color_control": "Recraft: ์ƒ‰์ƒ ์ œ์–ด ์ด๋ฏธ์ง€ ์ƒ์„ฑ", "api_recraft_image_gen_with_style_control": "Recraft: ์Šคํƒ€์ผ ์ œ์–ด ์ด๋ฏธ์ง€ ์ƒ์„ฑ", "api_recraft_vector_gen": "Recraft: ๋ฒกํ„ฐ ์ƒ์„ฑ", - "api_runway_reference_to_image": "Runway: ์ฐธ์กฐ ํˆฌ ์ด๋ฏธ์ง€", - "api_runway_text_to_image": "Runway: ํ…์ŠคํŠธ ํˆฌ ์ด๋ฏธ์ง€", - "api_stability_ai_i2i": "Stability AI: ์ด๋ฏธ์ง€ ํˆฌ ์ด๋ฏธ์ง€", - "api_stability_ai_sd3_5_i2i": "Stability AI: SD3.5 ์ด๋ฏธ์ง€ ํˆฌ ์ด๋ฏธ์ง€", - "api_stability_ai_sd3_5_t2i": "Stability AI: SD3.5 ํ…์ŠคํŠธ ํˆฌ ์ด๋ฏธ์ง€", - "api_stability_ai_stable_image_ultra_t2i": "Stability AI: Stable Image Ultra ํ…์ŠคํŠธ ํˆฌ ์ด๋ฏธ์ง€" + "api_runway_reference_to_image": "Runway: ์ฐธ์กฐ โ†’ ์ด๋ฏธ์ง€", + "api_runway_text_to_image": "Runway: ํ…์ŠคํŠธ โ†’ ์ด๋ฏธ์ง€", + "api_stability_ai_i2i": "Stability AI: ์ด๋ฏธ์ง€ โ†’ ์ด๋ฏธ์ง€", + "api_stability_ai_sd3_5_i2i": "Stability AI: SD3.5 ์ด๋ฏธ์ง€ โ†’ ์ด๋ฏธ์ง€", + "api_stability_ai_sd3_5_t2i": "Stability AI: SD3.5 ํ…์ŠคํŠธ โ†’ ์ด๋ฏธ์ง€", + "api_stability_ai_stable_image_ultra_t2i": "Stability AI: Stable Image Ultra ํ…์ŠคํŠธ โ†’ ์ด๋ฏธ์ง€" }, "LLM API": { "api_google_gemini": "Google Gemini: ์ฑ„ํŒ…", @@ -1327,24 +1354,24 @@ }, "Upscaling": { "esrgan_example": "ESRGAN", - "hiresfix_esrgan_workflow": "HiresFix ESRGAN ์›Œํฌํ”Œ๋กœ์šฐ", - "hiresfix_latent_workflow": "์—…์Šค์ผ€์ผ", - "latent_upscale_different_prompt_model": "Latent ์—…์Šค์ผ€์ผ ๋‹ค๋ฅธ ํ”„๋กฌํ”„ํŠธ ๋ชจ๋ธ" + "hiresfix_esrgan_workflow": "HiresFix ESRGAN ์›Œํฌํ”Œ๋กœ", + "hiresfix_latent_workflow": "์ด๋ฏธ์ง€ ํ™•๋Œ€", + "latent_upscale_different_prompt_model": "์ž ์žฌ ์ด๋ฏธ์ง€ ํ™•๋Œ€ ๋‹ค๋ฅธ ํ”„๋กฌํ”„ํŠธ ๋ชจ๋ธ" }, "Video": { - "hunyuan_video_text_to_video": "Hunyuan ๋น„๋””์˜ค ํ…์ŠคํŠธ ํˆฌ ๋น„๋””์˜ค", - "image_to_video": "SVD ์ด๋ฏธ์ง€ ํˆฌ ๋น„๋””์˜ค", - "image_to_video_wan": "Wan 2.1 ์ด๋ฏธ์ง€ ํˆฌ ๋น„๋””์˜ค", - "ltxv_image_to_video": "LTXV ์ด๋ฏธ์ง€ ํˆฌ ๋น„๋””์˜ค", - "ltxv_text_to_video": "LTXV ํ…์ŠคํŠธ ํˆฌ ๋น„๋””์˜ค", - "mochi_text_to_video_example": "Mochi ํ…์ŠคํŠธ ํˆฌ ๋น„๋””์˜ค", - "text_to_video_wan": "Wan 2.1 ํ…์ŠคํŠธ ํˆฌ ๋น„๋””์˜ค", - "txt_to_image_to_video": "SVD ํ…์ŠคํŠธ ํˆฌ ์ด๋ฏธ์ง€ ํˆฌ ๋น„๋””์˜ค", + "hunyuan_video_text_to_video": "Hunyuan ๋น„๋””์˜ค ํ…์ŠคํŠธ โ†’ ๋น„๋””์˜ค", + "image_to_video": "SVD ์ด๋ฏธ์ง€ โ†’ ๋น„๋””์˜ค", + "image_to_video_wan": "Wan 2.1 ์ด๋ฏธ์ง€ โ†’ ๋น„๋””์˜ค", + "ltxv_image_to_video": "LTXV ์ด๋ฏธ์ง€ โ†’ ๋น„๋””์˜ค", + "ltxv_text_to_video": "LTXV ํ…์ŠคํŠธ โ†’ ๋น„๋””์˜ค", + "mochi_text_to_video_example": "Mochi ํ…์ŠคํŠธ โ†’ ๋น„๋””์˜ค", + "text_to_video_wan": "Wan 2.1 ํ…์ŠคํŠธ โ†’ ๋น„๋””์˜ค", + "txt_to_image_to_video": "SVD ํ…์ŠคํŠธ โ†’ ์ด๋ฏธ์ง€ โ†’ ๋น„๋””์˜ค", "video_cosmos_predict2_2B_video2world_480p_16fps": "Cosmos Predict2 2B Video2World 480p 16fps", "video_wan2_1_fun_camera_v1_1_14B": "Wan 2.1 Fun Camera 14B", "video_wan2_1_fun_camera_v1_1_1_3B": "Wan 2.1 Fun Camera 1.3B", - "video_wan_vace_14B_ref2v": "Wan VACE ์ฐธ์กฐ ํˆฌ ๋น„๋””์˜ค", - "video_wan_vace_14B_t2v": "Wan VACE ํ…์ŠคํŠธ ํˆฌ ๋น„๋””์˜ค", + "video_wan_vace_14B_ref2v": "Wan VACE ์ฐธ์กฐ โ†’ ๋น„๋””์˜ค", + "video_wan_vace_14B_t2v": "Wan VACE ํ…์ŠคํŠธ โ†’ ๋น„๋””์˜ค", "video_wan_vace_14B_v2v": "Wan VACE ์ปจํŠธ๋กค ๋น„๋””์˜ค", "video_wan_vace_flf2v": "Wan VACE ์ฒซ-๋งˆ์ง€๋ง‰ ํ”„๋ ˆ์ž„", "video_wan_vace_inpainting": "Wan VACE ์ธํŽ˜์ธํŒ…", @@ -1354,24 +1381,24 @@ "wan2_1_fun_inp": "Wan 2.1 ์ธํŽ˜์ธํŒ…" }, "Video API": { - "api_hailuo_minimax_i2v": "MiniMax: ์ด๋ฏธ์ง€ ํˆฌ ๋น„๋””์˜ค", - "api_hailuo_minimax_t2v": "MiniMax: ํ…์ŠคํŠธ ํˆฌ ๋น„๋””์˜ค", + "api_hailuo_minimax_i2v": "MiniMax: ์ด๋ฏธ์ง€ โ†’ ๋น„๋””์˜ค", + "api_hailuo_minimax_t2v": "MiniMax: ํ…์ŠคํŠธ โ†’ ๋น„๋””์˜ค", "api_kling_effects": "Kling: ๋น„๋””์˜ค ํšจ๊ณผ", "api_kling_flf": "Kling: FLF2V", - "api_kling_i2v": "Kling: ์ด๋ฏธ์ง€ ํˆฌ ๋น„๋””์˜ค", - "api_luma_i2v": "Luma: ์ด๋ฏธ์ง€ ํˆฌ ๋น„๋””์˜ค", - "api_luma_t2v": "Luma: ํ…์ŠคํŠธ ํˆฌ ๋น„๋””์˜ค", - "api_moonvalley_image_to_video": "Moonvalley: ์ด๋ฏธ์ง€ ํˆฌ ๋น„๋””์˜ค", - "api_moonvalley_text_to_video": "Moonvalley: ํ…์ŠคํŠธ ํˆฌ ๋น„๋””์˜ค", - "api_pika_i2v": "Pika: ์ด๋ฏธ์ง€ ํˆฌ ๋น„๋””์˜ค", - "api_pika_scene": "Pika ์žฅ๋ฉด: ์ด๋ฏธ์ง€ ํˆฌ ๋น„๋””์˜ค", - "api_pixverse_i2v": "PixVerse: ์ด๋ฏธ์ง€ ํˆฌ ๋น„๋””์˜ค", - "api_pixverse_t2v": "PixVerse: ํ…์ŠคํŠธ ํˆฌ ๋น„๋””์˜ค", - "api_pixverse_template_i2v": "PixVerse ํ…œํ”Œ๋ฆฟ: ์ด๋ฏธ์ง€ ํˆฌ ๋น„๋””์˜ค", - "api_runway_first_last_frame": "Runway: ์ฒซ-๋งˆ์ง€๋ง‰ ํ”„๋ ˆ์ž„ ํˆฌ ๋น„๋””์˜ค", - "api_runway_gen3a_turbo_image_to_video": "Runway: Gen3a Turbo ์ด๋ฏธ์ง€ ํˆฌ ๋น„๋””์˜ค", - "api_runway_gen4_turo_image_to_video": "Runway: Gen4 Turbo ์ด๋ฏธ์ง€ ํˆฌ ๋น„๋””์˜ค", - "api_veo2_i2v": "Veo2: ์ด๋ฏธ์ง€ ํˆฌ ๋น„๋””์˜ค" + "api_kling_i2v": "Kling: ์ด๋ฏธ์ง€ โ†’ ๋น„๋””์˜ค", + "api_luma_i2v": "Luma: ์ด๋ฏธ์ง€ โ†’ ๋น„๋””์˜ค", + "api_luma_t2v": "Luma: ํ…์ŠคํŠธ โ†’ ๋น„๋””์˜ค", + "api_moonvalley_image_to_video": "Moonvalley: ์ด๋ฏธ์ง€ โ†’ ๋น„๋””์˜ค", + "api_moonvalley_text_to_video": "Moonvalley: ํ…์ŠคํŠธ โ†’ ๋น„๋””์˜ค", + "api_pika_i2v": "Pika: ์ด๋ฏธ์ง€ โ†’ ๋น„๋””์˜ค", + "api_pika_scene": "Pika ์žฅ๋ฉด: ์ด๋ฏธ์ง€ โ†’ ๋น„๋””์˜ค", + "api_pixverse_i2v": "PixVerse: ์ด๋ฏธ์ง€ โ†’ ๋น„๋””์˜ค", + "api_pixverse_t2v": "PixVerse: ํ…์ŠคํŠธ โ†’ ๋น„๋””์˜ค", + "api_pixverse_template_i2v": "PixVerse ํ…œํ”Œ๋ฆฟ: ์ด๋ฏธ์ง€ โ†’ ๋น„๋””์˜ค", + "api_runway_first_last_frame": "Runway: ์ฒซ-๋งˆ์ง€๋ง‰ ํ”„๋ ˆ์ž„ โ†’ ๋น„๋””์˜ค", + "api_runway_gen3a_turbo_image_to_video": "Runway: Gen3a Turbo ์ด๋ฏธ์ง€ โ†’ ๋น„๋””์˜ค", + "api_runway_gen4_turo_image_to_video": "Runway: Gen4 Turbo ์ด๋ฏธ์ง€ โ†’ ๋น„๋””์˜ค", + "api_veo2_i2v": "Veo2: ์ด๋ฏธ์ง€ โ†’ ๋น„๋””์˜ค" } }, "templateDescription": { @@ -1386,7 +1413,7 @@ "api_rodin_multiview_to_model": "Rodin์˜ ๋‹ค๊ฐ๋„ ์žฌ๊ตฌ์„ฑ์œผ๋กœ ์ข…ํ•ฉ์ ์ธ 3D ๋ชจ๋ธ์„ ๋งŒ๋“ญ๋‹ˆ๋‹ค.", "api_tripo_image_to_model": "Tripo ์—”์ง„์œผ๋กœ 2D ์ด๋ฏธ์ง€์—์„œ ์ „๋ฌธ๊ฐ€์šฉ 3D ์—์…‹์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", "api_tripo_multiview_to_model": "Tripo์˜ ๊ณ ๊ธ‰ ์Šค์บ๋„ˆ๋กœ ์—ฌ๋Ÿฌ ๊ฐ๋„์—์„œ 3D ๋ชจ๋ธ์„ ๋งŒ๋“ญ๋‹ˆ๋‹ค.", - "api_tripo_text_to_model": "Tripo์˜ ํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ ๋ชจ๋ธ๋ง์œผ๋กœ ์„ค๋ช…์—์„œ 3D ์˜ค๋ธŒ์ ํŠธ๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค." + "api_tripo_text_to_model": "Tripo์˜ ํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ ๋ชจ๋ธ๋ง์œผ๋กœ ์„ค๋ช…์—์„œ 3D ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค." }, "Area Composition": { "area_composition": "์ •์˜๋œ ์˜์—ญ์œผ๋กœ ๊ตฌ์„ฑ์„ ์ œ์–ดํ•˜์—ฌ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", @@ -1409,49 +1436,49 @@ "lora_multiple": "์—ฌ๋Ÿฌ LoRA ๋ชจ๋ธ์„ ๊ฒฐํ•ฉํ•˜์—ฌ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค." }, "ControlNet": { - "2_pass_pose_worship": "ControlNet์œผ๋กœ ํฌ์ฆˆ ์ฐธ์กฐ๋ฅผ ํ™œ์šฉํ•ด ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", - "controlnet_example": "ControlNet์œผ๋กœ ์Šคํฌ๋ฆฌ๋ธ” ์ฐธ์กฐ ์ด๋ฏธ์ง€๋ฅผ ํ™œ์šฉํ•ด ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", - "depth_controlnet": "ControlNet์œผ๋กœ ๊นŠ์ด ์ •๋ณด๋ฅผ ํ™œ์šฉํ•ด ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", + "2_pass_pose_worship": "์ปจํŠธ๋กค๋„ท์œผ๋กœ ํฌ์ฆˆ ์ฐธ์กฐ๋ฅผ ํ™œ์šฉํ•ด ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", + "controlnet_example": "์ปจํŠธ๋กค๋„ท์œผ๋กœ ์Šคํฌ๋ฆฌ๋ธ” ์ฐธ์กฐ ์ด๋ฏธ์ง€๋ฅผ ํ™œ์šฉํ•ด ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", + "depth_controlnet": "์ปจํŠธ๋กค๋„ท์œผ๋กœ ๊นŠ์ด ์ •๋ณด๋ฅผ ํ™œ์šฉํ•ด ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", "depth_t2i_adapter": "T2I ์–ด๋Œ‘ํ„ฐ๋กœ ๊นŠ์ด ์ •๋ณด๋ฅผ ํ™œ์šฉํ•ด ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", - "mixing_controlnets": "์—ฌ๋Ÿฌ ControlNet ๋ชจ๋ธ์„ ๊ฒฐํ•ฉํ•ด ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค." + "mixing_controlnets": "์—ฌ๋Ÿฌ ์ปจํŠธ๋กค๋„ท ๋ชจ๋ธ์„ ๊ฒฐํ•ฉํ•ด ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค." }, "Flux": { - "flux_canny_model_example": "Flux Canny๋กœ ์—์ง€ ๊ฐ์ง€์— ๋”ฐ๋ผ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", - "flux_depth_lora_example": "Flux LoRA๋กœ ๊นŠ์ด ์ •๋ณด๋ฅผ ํ™œ์šฉํ•ด ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", - "flux_dev_checkpoint_example": "Flux Dev fp8 ์–‘์žํ™” ๋ฒ„์ „์œผ๋กœ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. VRAM์ด ์ œํ•œ๋œ ์žฅ์น˜์— ์ ํ•ฉํ•˜๋ฉฐ, ๋ชจ๋ธ ํŒŒ์ผ ํ•˜๋‚˜๋งŒ ํ•„์š”ํ•˜์ง€๋งŒ ํ™”์งˆ์€ ์ „์ฒด ๋ฒ„์ „๋ณด๋‹ค ์•ฝ๊ฐ„ ๋‚ฎ์Šต๋‹ˆ๋‹ค.", - "flux_dev_full_text_to_image": "Flux Dev ์ „์ฒด ๋ฒ„์ „์œผ๋กœ ๊ณ ํ’ˆ์งˆ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ๋” ๋งŽ์€ VRAM๊ณผ ์—ฌ๋Ÿฌ ๋ชจ๋ธ ํŒŒ์ผ์ด ํ•„์š”ํ•˜์ง€๋งŒ, ์ตœ๊ณ ์˜ ํ”„๋กฌํ”„ํŠธ ๋ฐ˜์˜๋ ฅ๊ณผ ํ™”์งˆ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.", - "flux_fill_inpaint_example": "Flux ์ธํŽ˜์ธํŒ…์œผ๋กœ ์ด๋ฏธ์ง€์˜ ๋ˆ„๋ฝ๋œ ๋ถ€๋ถ„์„ ์ฑ„์›๋‹ˆ๋‹ค.", - "flux_fill_outpaint_example": "Flux ์•„์›ƒํŽ˜์ธํŒ…์œผ๋กœ ์ด๋ฏธ์ง€๋ฅผ ๊ฒฝ๊ณ„ ๋„ˆ๋จธ๋กœ ํ™•์žฅํ•ฉ๋‹ˆ๋‹ค.", - "flux_kontext_dev_basic": "Flux Kontext์˜ ์ „์ฒด ๋…ธ๋“œ ํ‘œ์‹œ๋กœ ์ด๋ฏธ์ง€๋ฅผ ํŽธ์ง‘ํ•ฉ๋‹ˆ๋‹ค. ์›Œํฌํ”Œ๋กœ์šฐ ํ•™์Šต์— ์ ํ•ฉํ•ฉ๋‹ˆ๋‹ค.", - "flux_kontext_dev_grouped": "๋…ธ๋“œ๊ฐ€ ๊ทธ๋ฃนํ™”๋œ Flux Kontext์˜ ๊ฐ„์†Œํ™” ๋ฒ„์ „์œผ๋กœ ์ž‘์—… ๊ณต๊ฐ„์ด ๋” ๊น”๋”ํ•ฉ๋‹ˆ๋‹ค.", - "flux_redux_model_example": "Flux Redux๋กœ ์ฐธ์กฐ ์ด๋ฏธ์ง€์˜ ์Šคํƒ€์ผ์„ ์ „์†กํ•˜์—ฌ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", - "flux_schnell": "Flux Schnell fp8 ์–‘์žํ™” ๋ฒ„์ „์œผ๋กœ ์ด๋ฏธ์ง€๋ฅผ ๋น ๋ฅด๊ฒŒ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ์ €์‚ฌ์–‘ ํ•˜๋“œ์›จ์–ด์— ์ด์ƒ์ ์ด๋ฉฐ, 4๋‹จ๊ณ„๋งŒ์œผ๋กœ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", - "flux_schnell_full_text_to_image": "Flux Schnell ์ „์ฒด ๋ฒ„์ „์œผ๋กœ ์ด๋ฏธ์ง€๋ฅผ ๋น ๋ฅด๊ฒŒ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. Apache2.0 ๋ผ์ด์„ ์Šค๋ฅผ ์‚ฌ์šฉํ•˜๋ฉฐ, 4๋‹จ๊ณ„๋งŒ์œผ๋กœ ์ข‹์€ ํ™”์งˆ์„ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค." + "flux_canny_model_example": "FLUX ์บ๋‹ˆ ์ปจํŠธ๋กค๋„ท์œผ๋กœ ์—์ง€ ๊ฐ์ง€์— ๋”ฐ๋ผ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", + "flux_depth_lora_example": "FLUX LoRA๋กœ ๊นŠ์ด ์ •๋ณด๋ฅผ ํ™œ์šฉํ•ด ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", + "flux_dev_checkpoint_example": "FLUX Dev fp8 ์–‘์žํ™” ๋ฒ„์ „์œผ๋กœ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. VRAM์ด ์ œํ•œ๋œ ์žฅ์น˜์— ์ ํ•ฉํ•˜๋ฉฐ, ๋ชจ๋ธ ํŒŒ์ผ ํ•˜๋‚˜๋งŒ ํ•„์š”ํ•˜์ง€๋งŒ ํ™”์งˆ์€ ์ „์ฒด ๋ฒ„์ „๋ณด๋‹ค ์•ฝ๊ฐ„ ๋‚ฎ์Šต๋‹ˆ๋‹ค.", + "flux_dev_full_text_to_image": "FLUX Dev ์ „์ฒด ๋ฒ„์ „์œผ๋กœ ๊ณ ํ’ˆ์งˆ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ๋” ๋งŽ์€ VRAM๊ณผ ์—ฌ๋Ÿฌ ๋ชจ๋ธ ํŒŒ์ผ์ด ํ•„์š”ํ•˜์ง€๋งŒ, ์ตœ๊ณ ์˜ ํ”„๋กฌํ”„ํŠธ ๋ฐ˜์˜๋ ฅ๊ณผ ํ™”์งˆ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.", + "flux_fill_inpaint_example": "FLUX ์ธํŽ˜์ธํŒ…์œผ๋กœ ์ด๋ฏธ์ง€์˜ ๋ˆ„๋ฝ๋œ ๋ถ€๋ถ„์„ ์ฑ„์›๋‹ˆ๋‹ค.", + "flux_fill_outpaint_example": "FFLUXlux ์•„์›ƒํŽ˜์ธํŒ…์œผ๋กœ ์ด๋ฏธ์ง€๋ฅผ ๊ฒฝ๊ณ„ ๋„ˆ๋จธ๋กœ ํ™•์žฅํ•ฉ๋‹ˆ๋‹ค.", + "flux_kontext_dev_basic": "FLUX Kontext์˜ ์ „์ฒด ๋…ธ๋“œ ํ‘œ์‹œ๋กœ ์ด๋ฏธ์ง€๋ฅผ ํŽธ์ง‘ํ•ฉ๋‹ˆ๋‹ค. ์›Œํฌํ”Œ๋กœ ํ•™์Šต์— ์ ํ•ฉํ•ฉ๋‹ˆ๋‹ค.", + "flux_kontext_dev_grouped": "๋…ธ๋“œ๊ฐ€ ๊ทธ๋ฃนํ™”๋œ FLUX Kontext์˜ ๊ฐ„์†Œํ™” ๋ฒ„์ „์œผ๋กœ ์ž‘์—… ๊ณต๊ฐ„์ด ๋” ๊น”๋”ํ•ฉ๋‹ˆ๋‹ค.", + "flux_redux_model_example": "FLUX Redux๋กœ ์ฐธ์กฐ ์ด๋ฏธ์ง€์˜ ์Šคํƒ€์ผ์„ ์ „์†กํ•˜์—ฌ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", + "flux_schnell": "FLUX Schnell fp8 ์–‘์žํ™” ๋ฒ„์ „์œผ๋กœ ์ด๋ฏธ์ง€๋ฅผ ๋น ๋ฅด๊ฒŒ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ์ €์‚ฌ์–‘ ํ•˜๋“œ์›จ์–ด์— ์ด์ƒ์ ์ด๋ฉฐ, 4๋‹จ๊ณ„๋งŒ์œผ๋กœ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", + "flux_schnell_full_text_to_image": "FLUX Schnell Full ๋ฒ„์ „์„ ์ด์šฉํ•ด ์ด๋ฏธ์ง€๋ฅผ ๋น ๋ฅด๊ฒŒ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. Apache2.0 ๋ผ์ด์„ ์Šค๋ฅผ ์‚ฌ์šฉํ•˜๋ฉฐ, 4๋‹จ๊ณ„๋งŒ์œผ๋กœ ์ข‹์€ ํ™”์งˆ์„ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค." }, "Image": { "hidream_e1_full": "HiDream E1 - ์ „๋ฌธ์ ์ธ ์ž์—ฐ์–ด ์ด๋ฏธ์ง€ ํŽธ์ง‘ ๋ชจ๋ธ๋กœ ์ด๋ฏธ์ง€๋ฅผ ํŽธ์ง‘ํ•ฉ๋‹ˆ๋‹ค.", "hidream_i1_dev": "HiDream I1 Dev - 28 ์Šคํ…์˜ ๊ท ํ˜• ์žกํžŒ ๋ฒ„์ „์œผ๋กœ, ์ค‘๊ฐ„๊ธ‰ ํ•˜๋“œ์›จ์–ด์— ์ ํ•ฉํ•ฉ๋‹ˆ๋‹ค.", "hidream_i1_fast": "HiDream I1 Fast - 16 ์Šคํ…์˜ ๊ฒฝ๋Ÿ‰ ๋ฒ„์ „์œผ๋กœ, ์ €์‚ฌ์–‘ ํ•˜๋“œ์›จ์–ด์—์„œ ๋น ๋ฅธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ์— ์ ํ•ฉํ•ฉ๋‹ˆ๋‹ค.", "hidream_i1_full": "HiDream I1 Full - 50 ์Šคํ…์˜ ์™„์ „ ๋ฒ„์ „์œผ๋กœ, ์ตœ๊ณ ์˜ ํ’ˆ์งˆ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.", - "image_chroma_text_to_image": "Chroma๋Š” flux์—์„œ ์ˆ˜์ •๋œ ๋ชจ๋ธ๋กœ, ์•„ํ‚คํ…์ฒ˜์— ์ผ๋ถ€ ๋ณ€ํ™”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.", + "image_chroma_text_to_image": "Chroma๋Š” FLUX์—์„œ ์ˆ˜์ •๋œ ๋ชจ๋ธ๋กœ, ์•„ํ‚คํ…์ฒ˜์— ์ผ๋ถ€ ๋ณ€ํ™”๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.", "image_cosmos_predict2_2B_t2i": "Cosmos-Predict2 2B T2I๋กœ ๋ฌผ๋ฆฌ์ ์œผ๋กœ ์ •ํ™•ํ•˜๊ณ  ๊ณ ํ•ด์ƒ๋„, ๋””ํ…Œ์ผ์ด ํ’๋ถ€ํ•œ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", "image_lotus_depth_v1_1": "Lotus Depth๋กœ ๊ณ ํšจ์œจ ๋‹จ์•ˆ ๊นŠ์ด ์ถ”์ • ๋ฐ ๋””ํ…Œ์ผ ๋ณด์กด์ด ๋›ฐ์–ด๋‚œ zero-shot ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", "image_omnigen2_image_edit": "OmniGen2์˜ ๊ณ ๊ธ‰ ์ด๋ฏธ์ง€ ํŽธ์ง‘ ๊ธฐ๋Šฅ๊ณผ ํ…์ŠคํŠธ ๋ Œ๋”๋ง ์ง€์›์œผ๋กœ ์ž์—ฐ์–ด ์ง€์‹œ๋กœ ์ด๋ฏธ์ง€๋ฅผ ํŽธ์ง‘ํ•ฉ๋‹ˆ๋‹ค.", "image_omnigen2_t2i": "OmniGen2์˜ ํ†ตํ•ฉ 7B ๋ฉ€ํ‹ฐ๋ชจ๋‹ฌ ๋ชจ๋ธ๊ณผ ๋“€์–ผ ํŒจ์Šค ์•„ํ‚คํ…์ฒ˜๋กœ ํ…์ŠคํŠธ ํ”„๋กฌํ”„ํŠธ์—์„œ ๊ณ ํ’ˆ์งˆ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", "sd3_5_large_blur": "SD 3.5๋กœ ํ๋ฆฟํ•œ ์ฐธ์กฐ ์ด๋ฏธ์ง€๋ฅผ ํ™œ์šฉํ•ด ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", - "sd3_5_large_canny_controlnet_example": "SD 3.5 Canny ControlNet์œผ๋กœ ์—์ง€ ๊ฐ์ง€์— ๋”ฐ๋ผ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", + "sd3_5_large_canny_controlnet_example": "SD 3.5 ์บ๋‹ˆ ์ปจํŠธ๋กค๋„ท์œผ๋กœ ์—์ง€ ๊ฐ์ง€์— ๋”ฐ๋ผ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", "sd3_5_large_depth": "SD 3.5๋กœ ๊นŠ์ด ์ •๋ณด๋ฅผ ํ™œ์šฉํ•ด ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", "sd3_5_simple_example": "SD 3.5๋กœ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", - "sdxl_refiner_prompt_example": "SDXL ๋ฆฌํŒŒ์ด๋„ˆ ๋ชจ๋ธ๋กœ ์ด๋ฏธ์ง€๋ฅผ ํ–ฅ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค.", + "sdxl_refiner_prompt_example": "SDXL Refiner ๋ชจ๋ธ๋กœ ์ด๋ฏธ์ง€๋ฅผ ํ–ฅ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค.", "sdxl_revision_text_prompts": "SDXL Revision์œผ๋กœ ์ฐธ์กฐ ์ด๋ฏธ์ง€์˜ ๊ฐœ๋…์„ ์ „์†กํ•˜์—ฌ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", "sdxl_revision_zero_positive": "SDXL Revision์œผ๋กœ ํ…์ŠคํŠธ ํ”„๋กฌํ”„ํŠธ์™€ ์ฐธ์กฐ ์ด๋ฏธ์ง€๋ฅผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•ด ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", "sdxl_simple_example": "SDXL๋กœ ๊ณ ํ’ˆ์งˆ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", "sdxlturbo_example": "SDXL Turbo๋กœ ํ•œ ๋ฒˆ์— ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค." }, "Image API": { - "api_bfl_flux_1_kontext_max_image": "Flux.1 Kontext ๋งฅ์Šค ์ด๋ฏธ์ง€๋กœ ์ด๋ฏธ์ง€๋ฅผ ํŽธ์ง‘ํ•ฉ๋‹ˆ๋‹ค.", - "api_bfl_flux_1_kontext_multiple_images_input": "์—ฌ๋Ÿฌ ์ด๋ฏธ์ง€๋ฅผ ์ž…๋ ฅํ•˜๊ณ  Flux.1 Kontext๋กœ ํŽธ์ง‘ํ•ฉ๋‹ˆ๋‹ค.", - "api_bfl_flux_1_kontext_pro_image": "Flux.1 Kontext ํ”„๋กœ ์ด๋ฏธ์ง€๋กœ ์ด๋ฏธ์ง€๋ฅผ ํŽธ์ง‘ํ•ฉ๋‹ˆ๋‹ค.", + "api_bfl_flux_1_kontext_max_image": "FLUX.1 Kontext ๋งฅ์Šค ์ด๋ฏธ์ง€๋กœ ์ด๋ฏธ์ง€๋ฅผ ํŽธ์ง‘ํ•ฉ๋‹ˆ๋‹ค.", + "api_bfl_flux_1_kontext_multiple_images_input": "์—ฌ๋Ÿฌ ์ด๋ฏธ์ง€๋ฅผ ์ž…๋ ฅํ•˜๊ณ  FLUX.1 Kontext๋กœ ํŽธ์ง‘ํ•ฉ๋‹ˆ๋‹ค.", + "api_bfl_flux_1_kontext_pro_image": "FLUX.1 Kontext ํ”„๋กœ ์ด๋ฏธ์ง€๋กœ ์ด๋ฏธ์ง€๋ฅผ ํŽธ์ง‘ํ•ฉ๋‹ˆ๋‹ค.", "api_bfl_flux_pro_t2i": "FLUX.1 Pro๋กœ ๋›ฐ์–ด๋‚œ ํ”„๋กฌํ”„ํŠธ ๋ฐ˜์˜๊ณผ ์‹œ๊ฐ์  ํ’ˆ์งˆ๋กœ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", "api_ideogram_v3_t2i": "Ideogram V3๋กœ ๋›ฐ์–ด๋‚œ ํ”„๋กฌํ”„ํŠธ ์ผ์น˜, ํฌํ† ๋ฆฌ์–ผ๋ฆฌ์ฆ˜, ํ…์ŠคํŠธ ๋ Œ๋”๋ง์œผ๋กœ ์ „๋ฌธ๊ฐ€ ์ˆ˜์ค€์˜ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", "api_luma_photon_i2i": "์ด๋ฏธ์ง€์™€ ํ”„๋กฌํ”„ํŠธ๋ฅผ ์กฐํ•ฉํ•˜์—ฌ ์ด๋ฏธ์ง€ ์ƒ์„ฑ์„ ๊ฐ€์ด๋“œํ•ฉ๋‹ˆ๋‹ค.", @@ -1469,9 +1496,9 @@ "api_runway_reference_to_image": "Runway์˜ AI๋กœ ์ฐธ์กฐ ์Šคํƒ€์ผ๊ณผ ๊ตฌ์„ฑ์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์ƒˆ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", "api_runway_text_to_image": "Runway์˜ AI ๋ชจ๋ธ๋กœ ํ…์ŠคํŠธ ํ”„๋กฌํ”„ํŠธ์—์„œ ๊ณ ํ’ˆ์งˆ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", "api_stability_ai_i2i": "Stability AI๋กœ ๊ณ ํ’ˆ์งˆ ์ด๋ฏธ์ง€ ๋ณ€ํ™˜ ๋ฐ ์Šคํƒ€์ผ ์ „ํ™˜์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.", - "api_stability_ai_sd3_5_i2i": "1๋ฉ”๊ฐ€ํ”ฝ์…€ ํ•ด์ƒ๋„์—์„œ ์ „๋ฌธ๊ฐ€์šฉ ๊ณ ํ’ˆ์งˆ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ํ”„๋กฌํ”„ํŠธ ๋ฐ˜์˜์ด ์šฐ์ˆ˜ํ•ฉ๋‹ˆ๋‹ค.", - "api_stability_ai_sd3_5_t2i": "1๋ฉ”๊ฐ€ํ”ฝ์…€ ํ•ด์ƒ๋„์—์„œ ์ „๋ฌธ๊ฐ€์šฉ ๊ณ ํ’ˆ์งˆ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ํ”„๋กฌํ”„ํŠธ ๋ฐ˜์˜์ด ์šฐ์ˆ˜ํ•ฉ๋‹ˆ๋‹ค.", - "api_stability_ai_stable_image_ultra_t2i": "1๋ฉ”๊ฐ€ํ”ฝ์…€ ํ•ด์ƒ๋„์—์„œ ์ „๋ฌธ๊ฐ€์šฉ ๊ณ ํ’ˆ์งˆ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ํ”„๋กฌํ”„ํŠธ ๋ฐ˜์˜์ด ์šฐ์ˆ˜ํ•ฉ๋‹ˆ๋‹ค." + "api_stability_ai_sd3_5_i2i": "1M ํ”ฝ์…€ ํ•ด์ƒ๋„์—์„œ ์ „๋ฌธ๊ฐ€์šฉ ๊ณ ํ’ˆ์งˆ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ํ”„๋กฌํ”„ํŠธ ๋ฐ˜์˜์ด ์šฐ์ˆ˜ํ•ฉ๋‹ˆ๋‹ค.", + "api_stability_ai_sd3_5_t2i": "1M ํ”ฝ์…€ ํ•ด์ƒ๋„์—์„œ ์ „๋ฌธ๊ฐ€์šฉ ๊ณ ํ’ˆ์งˆ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ํ”„๋กฌํ”„ํŠธ ๋ฐ˜์˜์ด ์šฐ์ˆ˜ํ•ฉ๋‹ˆ๋‹ค.", + "api_stability_ai_stable_image_ultra_t2i": "1M ํ”ฝ์…€ ํ•ด์ƒ๋„์—์„œ ์ „๋ฌธ๊ฐ€์šฉ ๊ณ ํ’ˆ์งˆ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ํ”„๋กฌํ”„ํŠธ ๋ฐ˜์˜์ด ์šฐ์ˆ˜ํ•ฉ๋‹ˆ๋‹ค." }, "LLM API": { "api_google_gemini": "Google Gemini์˜ ๋ฉ€ํ‹ฐ๋ชจ๋‹ฌ AI์™€ ์ถ”๋ก  ๋Šฅ๋ ฅ์„ ๊ฒฝํ—˜ํ•˜์„ธ์š”.", @@ -1479,9 +1506,9 @@ }, "Upscaling": { "esrgan_example": "ESRGAN ๋ชจ๋ธ๋กœ ์ด๋ฏธ์ง€ ํ’ˆ์งˆ์„ ํ–ฅ์ƒํ•ฉ๋‹ˆ๋‹ค.", - "hiresfix_esrgan_workflow": "์ค‘๊ฐ„ ์ƒ์„ฑ ๋‹จ๊ณ„์—์„œ ESRGAN ๋ชจ๋ธ๋กœ ์—…์Šค์ผ€์ผํ•ฉ๋‹ˆ๋‹ค.", - "hiresfix_latent_workflow": "Latent ๊ณต๊ฐ„์—์„œ ์ด๋ฏธ์ง€ ํ’ˆ์งˆ์„ ํ–ฅ์ƒํ•ฉ๋‹ˆ๋‹ค.", - "latent_upscale_different_prompt_model": "์—ฌ๋Ÿฌ ๋ฒˆ์˜ ์ƒ์„ฑ ํŒจ์Šค์—์„œ ํ”„๋กฌํ”„ํŠธ๋ฅผ ๋ณ€๊ฒฝํ•˜๋ฉฐ ์—…์Šค์ผ€์ผํ•ฉ๋‹ˆ๋‹ค." + "hiresfix_esrgan_workflow": "์ค‘๊ฐ„ ์ƒ์„ฑ ๋‹จ๊ณ„์—์„œ ESRGAN ๋ชจ๋ธ๋กœ ์ด๋ฏธ์ง€๋ฅผ ํ™•๋Œ€ํ•ฉ๋‹ˆ๋‹ค.", + "hiresfix_latent_workflow": "์ž ์žฌ ์ด๋ฏธ์ง€์˜ ํ™•๋Œ€ ๋ฐฉ์‹์œผ๋กœ ์ด๋ฏธ์ง€ ํ’ˆ์งˆ์„ ํ–ฅ์ƒํ•ฉ๋‹ˆ๋‹ค.", + "latent_upscale_different_prompt_model": "์—ฌ๋Ÿฌ ๋ฒˆ์˜ ์ƒ์„ฑ ํŒจ์Šค์—์„œ ํ”„๋กฌํ”„ํŠธ๋ฅผ ๋ณ€๊ฒฝํ•˜๋ฉฐ ์ด๋ฏธ์ง€๋ฅผ ํ™•๋Œ€ํ•ฉ๋‹ˆ๋‹ค." }, "Video": { "hunyuan_video_text_to_video": "Hunyuan ๋ชจ๋ธ๋กœ ํ…์ŠคํŠธ ํ”„๋กฌํ”„ํŠธ์—์„œ ๋น„๋””์˜ค๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", @@ -1502,7 +1529,7 @@ "video_wan_vace_inpainting": "ํŠน์ • ์˜์—ญ์„ ํŽธ์ง‘ํ•˜๋ฉด์„œ ์ฃผ๋ณ€ ๋‚ด์šฉ์„ ๋ณด์กดํ•˜๋Š” ๋น„๋””์˜ค๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ๊ฐ์ฒด ์ œ๊ฑฐ ๋˜๋Š” ๊ต์ฒด์— ์ ํ•ฉํ•ฉ๋‹ˆ๋‹ค.", "video_wan_vace_outpainting": "Wan VACE ์•„์›ƒํŽ˜์ธํŒ…์œผ๋กœ ๋น„๋””์˜ค ํฌ๊ธฐ๋ฅผ ํ™•์žฅํ•˜์—ฌ ๋น„๋””์˜ค๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", "wan2_1_flf2v_720_f16": "Wan 2.1 FLF2V๋กœ ์ฒซ ํ”„๋ ˆ์ž„๊ณผ ๋งˆ์ง€๋ง‰ ํ”„๋ ˆ์ž„์„ ์ œ์–ดํ•˜์—ฌ ๋น„๋””์˜ค๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", - "wan2_1_fun_control": "Wan 2.1 ControlNet์œผ๋กœ ํฌ์ฆˆ, ๊นŠ์ด, ์—์ง€ ์ œ์–ด๋กœ ๋น„๋””์˜ค๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", + "wan2_1_fun_control": "Wan 2.1 ์ปจํŠธ๋กค๋„ท์œผ๋กœ ํฌ์ฆˆ, ๊นŠ์ด, ์—์ง€ ์ œ์–ด๋กœ ์ ์šฉํ•ด ๋น„๋””์˜ค๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", "wan2_1_fun_inp": "Wan 2.1 ์ธํŽ˜์ธํŒ…์œผ๋กœ ์‹œ์ž‘ ๋ฐ ์ข…๋ฃŒ ํ”„๋ ˆ์ž„์—์„œ ๋น„๋””์˜ค๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค." }, "Video API": { @@ -1546,7 +1573,7 @@ "failedToFetchLogs": "์„œ๋ฒ„ ๋กœ๊ทธ๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", "failedToInitiateCreditPurchase": "ํฌ๋ ˆ๋”ง ๊ตฌ๋งค๋ฅผ ์‹œ์ž‘ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค: {error}", "failedToPurchaseCredits": "ํฌ๋ ˆ๋”ง ๊ตฌ๋งค์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: {error}", - "fileLoadError": "{fileName}์—์„œ ์›Œํฌํ”Œ๋กœ์šฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค", + "fileLoadError": "{fileName}์—์„œ ์›Œํฌํ”Œ๋กœ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค", "fileUploadFailed": "ํŒŒ์ผ ์—…๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค", "interrupted": "์‹คํ–‰์ด ์ค‘๋‹จ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", "migrateToLitegraphReroute": "ํ–ฅํ›„ ๋ฒ„์ „์—์„œ๋Š” Reroute ๋…ธ๋“œ๊ฐ€ ์ œ๊ฑฐ๋ฉ๋‹ˆ๋‹ค. LiteGraph ์—์„œ ์ž์ฒด ์ œ๊ณตํ•˜๋Š” ๊ฒฝ์œ ์ ์œผ๋กœ ๋ณ€ํ™˜ํ•˜๋ ค๋ฉด ํด๋ฆญํ•˜์„ธ์š”.", @@ -1599,6 +1626,13 @@ "prefix": "{prefix}(์œผ)๋กœ ์‹œ์ž‘ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค", "required": "ํ•„์ˆ˜" }, + "versionMismatchWarning": { + "dismiss": "๋‹ซ๊ธฐ", + "frontendNewer": "ํ”„๋ก ํŠธ์—”๋“œ ๋ฒ„์ „ {frontendVersion}์ด(๊ฐ€) ๋ฐฑ์—”๋“œ ๋ฒ„์ „ {backendVersion}๊ณผ(์™€) ํ˜ธํ™˜๋˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", + "frontendOutdated": "ํ”„๋ก ํŠธ์—”๋“œ ๋ฒ„์ „ {frontendVersion}์ด(๊ฐ€) ์˜ค๋ž˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋ฐฑ์—”๋“œ๋Š” {requiredVersion} ์ด์ƒ ๋ฒ„์ „์„ ํ•„์š”๋กœ ํ•ฉ๋‹ˆ๋‹ค.", + "title": "๋ฒ„์ „ ํ˜ธํ™˜์„ฑ ๊ฒฝ๊ณ ", + "updateFrontend": "ํ”„๋ก ํŠธ์—”๋“œ ์—…๋ฐ์ดํŠธ" + }, "welcome": { "getStarted": "์‹œ์ž‘ํ•˜๊ธฐ", "title": "ComfyUI์— ์˜ค์‹  ๊ฒƒ์„ ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค" diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json index c8d30a412..321704a20 100644 --- a/src/locales/ko/settings.json +++ b/src/locales/ko/settings.json @@ -29,6 +29,13 @@ "name": "์บ”๋ฒ„์Šค ๋ฐฐ๊ฒฝ ์ด๋ฏธ์ง€", "tooltip": "์บ”๋ฒ„์Šค ๋ฐฐ๊ฒฝ์— ์‚ฌ์šฉํ•  ์ด๋ฏธ์ง€ URL์ž…๋‹ˆ๋‹ค. ์ถœ๋ ฅ ํŒจ๋„์—์„œ ์ด๋ฏธ์ง€๋ฅผ ๋งˆ์šฐ์Šค ์˜ค๋ฅธ์ชฝ ๋ฒ„ํŠผ์œผ๋กœ ํด๋ฆญํ•œ ํ›„ \"๋ฐฐ๊ฒฝ์œผ๋กœ ์„ค์ •\"์„ ์„ ํƒํ•ด ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค." }, + "Comfy_Canvas_NavigationMode": { + "name": "์บ”๋ฒ„์Šค ๋‚ด๋น„๊ฒŒ์ด์…˜ ๋ชจ๋“œ", + "options": { + "Left-Click Pan (Legacy)": "์™ผ์ชฝ ํด๋ฆญ ์ด๋™(๋ ˆ๊ฑฐ์‹œ)", + "Standard (New)": "ํ‘œ์ค€(์‹ ๊ทœ)" + } + }, "Comfy_Canvas_SelectionToolbox": { "name": "์„ ํƒ ๋„๊ตฌ ์ƒ์ž ํ‘œ์‹œ" }, @@ -329,10 +336,6 @@ }, "tooltip": "๋ฉ”๋‰ด ๋ฐ” ์œ„์น˜์ž…๋‹ˆ๋‹ค. ๋ชจ๋ฐ”์ผ ๊ธฐ๊ธฐ์—์„œ๋Š” ๋ฉ”๋‰ด๊ฐ€ ํ•ญ์ƒ ์ƒ๋‹จ์— ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค." }, - "Comfy_Validation_NodeDefs": { - "name": "๋…ธ๋“œ ์ •์˜ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ (๋А๋ฆผ)", - "tooltip": "๋…ธ๋“œ ๊ฐœ๋ฐœ์ž์—๊ฒŒ ๊ถŒ์žฅ๋ฉ๋‹ˆ๋‹ค. ์‹œ์ž‘ ์‹œ ๋ชจ๋“  ๋…ธ๋“œ ์ •์˜๋ฅผ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌํ•ฉ๋‹ˆ๋‹ค." - }, "Comfy_Validation_Workflows": { "name": "์›Œํฌํ”Œ๋กœ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ" }, @@ -399,10 +402,6 @@ "LiteGraph_Node_TooltipDelay": { "name": "ํˆดํŒ ์ง€์—ฐ" }, - "LiteGraph_Pointer_TrackpadGestures": { - "name": "ํŠธ๋ž™ํŒจ๋“œ ์ œ์Šค์ฒ˜ ํ™œ์„ฑํ™”", - "tooltip": "์ด ์„ค์ •์„ ์ผœ๋ฉด ์บ”๋ฒ„์Šค์—์„œ ํŠธ๋ž™ํŒจ๋“œ ๋ชจ๋“œ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ๋‘ ์†๊ฐ€๋ฝ์œผ๋กœ ํ™•๋Œ€/์ถ•์†Œ ๋ฐ ์ด๋™์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค." - }, "LiteGraph_Reroute_SplineOffset": { "name": "๊ฒฝ์œ ์  ์Šคํ”Œ๋ผ์ธ ์˜คํ”„์…‹", "tooltip": "๊ฒฝ์œ ์  ์ค‘์‹ฌ์—์„œ ๋ฒ ์ง€์–ด ์ œ์–ด์ ๊นŒ์ง€์˜ ์˜คํ”„์…‹" diff --git a/src/locales/ru/commands.json b/src/locales/ru/commands.json index ca88c4b6b..1970fa8d0 100644 --- a/src/locales/ru/commands.json +++ b/src/locales/ru/commands.json @@ -71,6 +71,9 @@ "Comfy_Canvas_ToggleLock": { "label": "ะŸะตั€ะตะบะปัŽั‡ะธั‚ัŒ ะฑะปะพะบะธั€ะพะฒะบัƒ ั…ะพะปัั‚ะฐ" }, + "Comfy_Canvas_ToggleMinimap": { + "label": "ะŸะพะปะพั‚ะฝะพ: ะฟะตั€ะตะบะปัŽั‡ะธั‚ัŒ ะผะธะฝะธะบะฐั€ั‚ัƒ" + }, "Comfy_Canvas_ToggleSelectedNodes_Bypass": { "label": "ะžะฑั…ะพะด/ะะตะพะฑั…ะพะด ะฒั‹ะฑั€ะฐะฝะฝั‹ั… ะฝะพะด" }, @@ -170,6 +173,12 @@ "Comfy_Manager_ToggleManagerProgressDialog": { "label": "ะŸะตั€ะตะบะปัŽั‡ะธั‚ัŒ ะดะธะฐะปะพะณะพะฒะพะต ะพะบะฝะพ ะฟั€ะพะณั€ะตััะฐ" }, + "Comfy_MaskEditor_BrushSize_Decrease": { + "label": "ะฃะผะตะฝัŒัˆะธั‚ัŒ ั€ะฐะทะผะตั€ ะบะธัั‚ะธ ะฒ MaskEditor" + }, + "Comfy_MaskEditor_BrushSize_Increase": { + "label": "ะฃะฒะตะปะธั‡ะธั‚ัŒ ั€ะฐะทะผะตั€ ะบะธัั‚ะธ ะฒ MaskEditor" + }, "Comfy_MaskEditor_OpenMaskEditor": { "label": "ะžั‚ะบั€ั‹ั‚ัŒ ั€ะตะดะฐะบั‚ะพั€ ะผะฐัะพะบ ะดะปั ะฒั‹ะฑั€ะฐะฝะฝะพะน ะฝะพะดั‹" }, diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index 869d25f82..31400756a 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -82,6 +82,12 @@ "title": "ะกะพะทะดะฐั‚ัŒ ะฐะบะบะฐัƒะฝั‚" } }, + "breadcrumbsMenu": { + "clearWorkflow": "ะžั‡ะธัั‚ะธั‚ัŒ ั€ะฐะฑะพั‡ะธะน ะฟั€ะพั†ะตัั", + "deleteWorkflow": "ะฃะดะฐะปะธั‚ัŒ ั€ะฐะฑะพั‡ะธะน ะฟั€ะพั†ะตัั", + "duplicate": "ะ”ัƒะฑะปะธั€ะพะฒะฐั‚ัŒ", + "enterNewName": "ะ’ะฒะตะดะธั‚ะต ะฝะพะฒะพะต ะธะผั" + }, "chatHistory": { "cancelEdit": "ะžั‚ะผะตะฝะฐ", "cancelEditTooltip": "ะžั‚ะผะตะฝะธั‚ัŒ ั€ะตะดะฐะบั‚ะธั€ะพะฒะฐะฝะธะต", @@ -291,6 +297,7 @@ "devices": "ะฃัั‚ั€ะพะนัั‚ะฒะฐ", "disableAll": "ะžั‚ะบะปัŽั‡ะธั‚ัŒ ะฒัะต", "disabling": "ะžั‚ะบะปัŽั‡ะตะฝะธะต", + "dismiss": "ะ—ะฐะบั€ั‹ั‚ัŒ", "download": "ะกะบะฐั‡ะฐั‚ัŒ", "edit": "ะ ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ", "empty": "ะŸัƒัั‚ะพ", @@ -305,6 +312,8 @@ "filter": "ะคะธะปัŒั‚ั€", "findIssues": "ะะฐะนั‚ะธ ะฟั€ะพะฑะปะตะผั‹", "firstTimeUIMessage": "ะ’ั‹ ะฒะฟะตั€ะฒั‹ะต ะธัะฟะพะปัŒะทัƒะตั‚ะต ะฝะพะฒั‹ะน ะธะฝั‚ะตั€ั„ะตะนั. ะ’ั‹ะฑะตั€ะธั‚ะต \"ะœะตะฝัŽ > ะ˜ัะฟะพะปัŒะทะพะฒะฐั‚ัŒ ะฝะพะฒะพะต ะผะตะฝัŽ > ะžั‚ะบะปัŽั‡ะตะฝะพ\", ั‡ั‚ะพะฑั‹ ะฒะพััั‚ะฐะฝะพะฒะธั‚ัŒ ัั‚ะฐั€ั‹ะน ะธะฝั‚ะตั€ั„ะตะนั.", + "frontendNewer": "ะ’ะตั€ัะธั ะธะฝั‚ะตั€ั„ะตะนัะฐ {frontendVersion} ะผะพะถะตั‚ ะฑั‹ั‚ัŒ ะฝะตัะพะฒะผะตัั‚ะธะผะฐ ั ะฒะตั€ัะธะตะน ัะตั€ะฒะตั€ะฐ {backendVersion}.", + "frontendOutdated": "ะ’ะตั€ัะธั ะธะฝั‚ะตั€ั„ะตะนัะฐ {frontendVersion} ัƒัั‚ะฐั€ะตะปะฐ. ะขั€ะตะฑัƒะตั‚ัั ะฒะตั€ัะธั ะฝะต ะฝะธะถะต {requiredVersion} ะดะปั ั€ะฐะฑะพั‚ั‹ ั ัะตั€ะฒะตั€ะพะผ.", "goToNode": "ะŸะตั€ะตะนั‚ะธ ะบ ะฝะพะดะต", "help": "ะŸะพะผะพั‰ัŒ", "icon": "ะ˜ะบะพะฝะบะฐ", @@ -326,17 +335,20 @@ "loadingPanel": "ะ—ะฐะณั€ัƒะทะบะฐ ะฟะฐะฝะตะปะธ {panel}...", "login": "ะ’ั…ะพะด", "logs": "ะ›ะพะณะธ", + "micPermissionDenied": "ะ”ะพัั‚ัƒะฟ ะบ ะผะธะบั€ะพั„ะพะฝัƒ ะทะฐะฟั€ะตั‰ั‘ะฝ", "migrate": "ะœะธะณั€ะธั€ะพะฒะฐั‚ัŒ", "missing": "ะžั‚ััƒั‚ัั‚ะฒัƒะตั‚", "name": "ะ˜ะผั", "newFolder": "ะะพะฒะฐั ะฟะฐะฟะบะฐ", "next": "ะ”ะฐะปะตะต", "no": "ะะตั‚", + "noAudioRecorded": "ะัƒะดะธะพ ะฝะต ะทะฐะฟะธัะฐะฝะพ", "noResultsFound": "ะ ะตะทัƒะปัŒั‚ะฐั‚ะพะฒ ะฝะต ะฝะฐะนะดะตะฝะพ", "noTasksFound": "ะ—ะฐะดะฐั‡ะธ ะฝะต ะฝะฐะนะดะตะฝั‹", "noTasksFoundMessage": "ะ’ ะพั‡ะตั€ะตะดะธ ะฝะตั‚ ะทะฐะดะฐั‡.", "noWorkflowsFound": "ะ ะฐะฑะพั‡ะธะต ะฟั€ะพั†ะตััั‹ ะฝะต ะฝะฐะนะดะตะฝั‹.", "nodes": "ะฃะทะปั‹", + "nodesRunning": "ะทะฐะฟัƒั‰ะตะฝะพ ัƒะทะปะพะฒ", "ok": "ะžะš", "openNewIssue": "ะžั‚ะบั€ั‹ั‚ัŒ ะฝะพะฒัƒัŽ ะฟั€ะพะฑะปะตะผัƒ", "overwrite": "ะŸะตั€ะตะทะฐะฟะธัะฐั‚ัŒ", @@ -370,7 +382,9 @@ "showReport": "ะŸะพะบะฐะทะฐั‚ัŒ ะพั‚ั‡ั‘ั‚", "sort": "ะกะพั€ั‚ะธั€ะพะฒะฐั‚ัŒ", "source": "ะ˜ัั‚ะพั‡ะฝะธะบ", + "startRecording": "ะะฐั‡ะฐั‚ัŒ ะทะฐะฟะธััŒ", "status": "ะกั‚ะฐั‚ัƒั", + "stopRecording": "ะžัั‚ะฐะฝะพะฒะธั‚ัŒ ะทะฐะฟะธััŒ", "success": "ะฃัะฟะตั…", "systemInfo": "ะ˜ะฝั„ะพั€ะผะฐั†ะธั ะพ ัะธัั‚ะตะผะต", "terminal": "ะขะตั€ะผะธะฝะฐะป", @@ -379,11 +393,14 @@ "unknownError": "ะะตะธะทะฒะตัั‚ะฝะฐั ะพัˆะธะฑะบะฐ", "update": "ะžะฑะฝะพะฒะธั‚ัŒ", "updateAvailable": "ะ”ะพัั‚ัƒะฟะฝะพ ะพะฑะฝะพะฒะปะตะฝะธะต", + "updateFrontend": "ะžะฑะฝะพะฒะธั‚ัŒ ะธะฝั‚ะตั€ั„ะตะนั", "updated": "ะžะฑะฝะพะฒะปะตะฝะพ", "updating": "ะžะฑะฝะพะฒะปะตะฝะธะต", "upload": "ะ—ะฐะณั€ัƒะทะธั‚ัŒ", "usageHint": "ะŸะพะดัะบะฐะทะบะฐ ะฟะพ ะธัะฟะพะปัŒะทะพะฒะฐะฝะธัŽ", "user": "ะŸะพะปัŒะทะพะฒะฐั‚ะตะปัŒ", + "versionMismatchWarning": "ะŸั€ะตะดัƒะฟั€ะตะถะดะตะฝะธะต ะพ ะฝะตัะพะฒะผะตัั‚ะธะผะพัั‚ะธ ะฒะตั€ัะธะน", + "versionMismatchWarningMessage": "{warning}: {detail} ะŸะพัะตั‚ะธั‚ะต https://docs.comfy.org/installation/update_comfyui#common-update-issues ะดะปั ะธะฝัั‚ั€ัƒะบั†ะธะน ะฟะพ ะพะฑะฝะพะฒะปะตะฝะธัŽ.", "videoFailedToLoad": "ะะต ัƒะดะฐะปะพััŒ ะทะฐะณั€ัƒะทะธั‚ัŒ ะฒะธะดะตะพ", "workflow": "ะ ะฐะฑะพั‡ะธะน ะฟั€ะพั†ะตัั" }, @@ -393,6 +410,7 @@ "resetView": "ะกะฑั€ะพัะธั‚ัŒ ะฒะธะด", "selectMode": "ะ’ั‹ะฑั€ะฐั‚ัŒ ั€ะตะถะธะผ", "toggleLinkVisibility": "ะŸะตั€ะตะบะปัŽั‡ะธั‚ัŒ ะฒะธะดะธะผะพัั‚ัŒ ััั‹ะปะพะบ", + "toggleMinimap": "ะŸะพะบะฐะทะฐั‚ัŒ/ัะบั€ั‹ั‚ัŒ ะผะธะฝะธะบะฐั€ั‚ัƒ", "zoomIn": "ะฃะฒะตะปะธั‡ะธั‚ัŒ", "zoomOut": "ะฃะผะตะฝัŒัˆะธั‚ัŒ" }, @@ -707,13 +725,17 @@ "batchCountTooltip": "ะšะพะปะธั‡ะตัั‚ะฒะพ ั€ะฐะท, ะบะพะณะดะฐ ะณะตะฝะตั€ะฐั†ะธั ั€ะฐะฑะพั‡ะตะณะพ ะฟั€ะพั†ะตััะฐ ะดะพะปะถะฝะฐ ะฑั‹ั‚ัŒ ะฟะพะผะตั‰ะตะฝะฐ ะฒ ะพั‡ะตั€ะตะดัŒ", "clear": "ะžั‡ะธัั‚ะธั‚ัŒ ั€ะฐะฑะพั‡ะธะน ะฟั€ะพั†ะตัั", "clipspace": "ะžั‚ะบั€ั‹ั‚ัŒ Clipspace", + "dark": "ะขั‘ะผะฝะฐั", "disabled": "ะžั‚ะบะปัŽั‡ะตะฝะพ", "disabledTooltip": "ะ ะฐะฑะพั‡ะธะน ะฟั€ะพั†ะตัั ะฝะต ะฑัƒะดะตั‚ ะฐะฒั‚ะพะผะฐั‚ะธั‡ะตัะบะธ ะฟะพะผะตั‰ั‘ะฝ ะฒ ะพั‡ะตั€ะตะดัŒ", "execute": "ะ’ั‹ะฟะพะปะฝะธั‚ัŒ", + "help": "ะกะฟั€ะฐะฒะบะฐ", "hideMenu": "ะกะบั€ั‹ั‚ัŒ ะผะตะฝัŽ", "instant": "ะœะณะฝะพะฒะตะฝะฝะพ", "instantTooltip": "ะ ะฐะฑะพั‡ะธะน ะฟั€ะพั†ะตัั ะฑัƒะดะตั‚ ะฟะพะผะตั‰ั‘ะฝ ะฒ ะพั‡ะตั€ะตะดัŒ ัั€ะฐะทัƒ ะถะต ะฟะพัะปะต ะทะฐะฒะตั€ัˆะตะฝะธั ะณะตะฝะตั€ะฐั†ะธะธ", "interrupt": "ะžั‚ะผะตะฝะธั‚ัŒ ั‚ะตะบัƒั‰ะตะต ะฒั‹ะฟะพะปะฝะตะฝะธะต", + "light": "ะกะฒะตั‚ะปะฐั", + "manageExtensions": "ะฃะฟั€ะฐะฒะปะตะฝะธะต ั€ะฐััˆะธั€ะตะฝะธัะผะธ", "onChange": "ะŸั€ะธ ะธะทะผะตะฝะตะฝะธะธ", "onChangeTooltip": "ะ ะฐะฑะพั‡ะธะน ะฟั€ะพั†ะตัั ะฑัƒะดะตั‚ ะฟะพัั‚ะฐะฒะปะตะฝ ะฒ ะพั‡ะตั€ะตะดัŒ ะฟะพัะปะต ะฒะฝะตัะตะฝะธั ะธะทะผะตะฝะตะฝะธะน", "refresh": "ะžะฑะฝะพะฒะธั‚ัŒ ะพะฟั€ะตะดะตะปะตะฝะธั ะฝะพะด", @@ -721,7 +743,9 @@ "run": "ะ—ะฐะฟัƒัั‚ะธั‚ัŒ", "runWorkflow": "ะ—ะฐะฟัƒัั‚ะธั‚ัŒ ั€ะฐะฑะพั‡ะธะน ะฟั€ะพั†ะตัั (Shift ะดะปั ะพั‡ะตั€ะตะดะธ ะฒ ะฝะฐั‡ะฐะปะต)", "runWorkflowFront": "ะ—ะฐะฟัƒัั‚ะธั‚ัŒ ั€ะฐะฑะพั‡ะธะน ะฟั€ะพั†ะตัั (ะžั‡ะตั€ะตะดัŒ ะฒ ะฝะฐั‡ะฐะปะต)", + "settings": "ะะฐัั‚ั€ะพะนะบะธ", "showMenu": "ะŸะพะบะฐะทะฐั‚ัŒ ะผะตะฝัŽ", + "theme": "ะขะตะผะฐ", "toggleBottomPanel": "ะŸะตั€ะตะบะปัŽั‡ะธั‚ัŒ ะฝะธะถะฝัŽัŽ ะฟะฐะฝะตะปัŒ" }, "menuLabels": { @@ -731,8 +755,9 @@ "Bypass/Unbypass Selected Nodes": "ะžะฑะพะนั‚ะธ/ะฒะพััั‚ะฐะฝะพะฒะธั‚ัŒ ะฒั‹ะฑั€ะฐะฝะฝั‹ะต ะฝะพะดั‹", "Canvas Toggle Link Visibility": "ะŸะตั€ะตะบะปัŽั‡ะตะฝะธะต ะฒะธะดะธะผะพัั‚ะธ ััั‹ะปะบะธ ะฝะฐ ั…ะพะปัั‚", "Canvas Toggle Lock": "ะŸะตั€ะตะบะปัŽั‡ะตะฝะธะต ะฑะปะพะบะธั€ะพะฒะบะธ ั…ะพะปัั‚ะฐ", + "Canvas Toggle Minimap": "ะŸะพะบะฐะทะฐั‚ัŒ/ัะบั€ั‹ั‚ัŒ ะผะธะฝะธะบะฐั€ั‚ัƒ ะฝะฐ ั…ะพะปัั‚ะต", "Check for Custom Node Updates": "ะŸั€ะพะฒะตั€ะธั‚ัŒ ะพะฑะฝะพะฒะปะตะฝะธั ะฟะพะปัŒะทะพะฒะฐั‚ะตะปัŒัะบะธั… ัƒะทะปะพะฒ", - "Check for Updates": "ะŸั€ะพะฒะตั€ะธั‚ัŒ ะžะฑะฝะพะฒะปะตะฝะธั", + "Check for Updates": "ะŸั€ะพะฒะตั€ะธั‚ัŒ ะฝะฐะปะธั‡ะธะต ะพะฑะฝะพะฒะปะตะฝะธะน", "Clear Pending Tasks": "ะžั‡ะธัั‚ะธั‚ัŒ ะพะถะธะดะฐัŽั‰ะธะต ะทะฐะดะฐั‡ะธ", "Clear Workflow": "ะžั‡ะธัั‚ะธั‚ัŒ ั€ะฐะฑะพั‡ะธะน ะฟั€ะพั†ะตัั", "Clipspace": "ะšะปะธะฟะฟั€ะพัั‚ั€ะฐะฝัั‚ะฒะพ", @@ -746,6 +771,8 @@ "Convert Selection to Subgraph": "ะŸั€ะตะพะฑั€ะฐะทะพะฒะฐั‚ัŒ ะฒั‹ะดะตะปะตะฝะฝะพะต ะฒ ะฟะพะดะณั€ะฐั„", "Convert selected nodes to group node": "ะŸั€ะตะพะฑั€ะฐะทะพะฒะฐั‚ัŒ ะฒั‹ะฑั€ะฐะฝะฝั‹ะต ะฝะพะดั‹ ะฒ ะณั€ัƒะฟะฟะพะฒัƒัŽ ะฝะพะดัƒ", "Custom Nodes (Legacy)": "ะŸะพะปัŒะทะพะฒะฐั‚ะตะปัŒัะบะธะต ัƒะทะปั‹ (ัƒัั‚ะฐั€ะตะฒัˆะธะต)", + "Custom Nodes Manager": "ะœะตะฝะตะดะถะตั€ ะŸะพะปัŒะทะพะฒะฐั‚ะตะปัŒัะบะธั… ะฃะทะปะพะฒ", + "Decrease Brush Size in MaskEditor": "ะฃะผะตะฝัŒัˆะธั‚ัŒ ั€ะฐะทะผะตั€ ะบะธัั‚ะธ ะฒ MaskEditor", "Delete Selected Items": "ะฃะดะฐะปะธั‚ัŒ ะฒั‹ะฑั€ะฐะฝะฝั‹ะต ัะปะตะผะตะฝั‚ั‹", "Desktop User Guide": "ะ ัƒะบะพะฒะพะดัั‚ะฒะพ ะฟะพะปัŒะทะพะฒะฐั‚ะตะปั ะดะปั ะฝะฐัั‚ะพะปัŒะฝั‹ั… ะŸะš", "Duplicate Current Workflow": "ะ”ัƒะฑะปะธั€ะพะฒะฐั‚ัŒ ั‚ะตะบัƒั‰ะธะน ั€ะฐะฑะพั‡ะธะน ะฟั€ะพั†ะตัั", @@ -757,6 +784,7 @@ "Give Feedback": "ะžัั‚ะฐะฒะธั‚ัŒ ะพั‚ะทั‹ะฒ", "Group Selected Nodes": "ะกะณั€ัƒะฟะฟะธั€ะพะฒะฐั‚ัŒ ะฒั‹ะฑั€ะฐะฝะฝั‹ะต ะฝะพะดั‹", "Help": "ะŸะพะผะพั‰ัŒ", + "Increase Brush Size in MaskEditor": "ะฃะฒะตะปะธั‡ะธั‚ัŒ ั€ะฐะทะผะตั€ ะบะธัั‚ะธ ะฒ MaskEditor", "Install Missing Custom Nodes": "ะฃัั‚ะฐะฝะพะฒะธั‚ัŒ ะพั‚ััƒั‚ัั‚ะฒัƒัŽั‰ะธะต ะฟะพะปัŒะทะพะฒะฐั‚ะตะปัŒัะบะธะต ัƒะทะปั‹", "Interrupt": "ะŸั€ะตั€ะฒะฐั‚ัŒ", "Load Default Workflow": "ะ—ะฐะณั€ัƒะทะธั‚ัŒ ัั‚ะฐะฝะดะฐั€ั‚ะฝั‹ะน ั€ะฐะฑะพั‡ะธะน ะฟั€ะพั†ะตัั", @@ -1175,7 +1203,6 @@ }, "showFlatList": "ะŸะพะบะฐะทะฐั‚ัŒ ะฟะปะพัะบะธะน ัะฟะธัะพะบ" }, - "themeToggle": "ะŸะตั€ะตะบะปัŽั‡ะธั‚ัŒ ั‚ะตะผัƒ", "workflowTab": { "confirmDelete": "ะ’ั‹ ัƒะฒะตั€ะตะฝั‹, ั‡ั‚ะพ ั…ะพั‚ะธั‚ะต ัƒะดะฐะปะธั‚ัŒ ัั‚ะพั‚ ั€ะฐะฑะพั‡ะธะน ะฟั€ะพั†ะตัั?", "confirmDeleteTitle": "ะฃะดะฐะปะธั‚ัŒ ั€ะฐะฑะพั‡ะธะน ะฟั€ะพั†ะตัั?", @@ -1599,6 +1626,13 @@ "prefix": "ะ”ะพะปะถะฝะพ ะฝะฐั‡ะธะฝะฐั‚ัŒัั ั {prefix}", "required": "ะžะฑัะทะฐั‚ะตะปัŒะฝะพ" }, + "versionMismatchWarning": { + "dismiss": "ะ—ะฐะบั€ั‹ั‚ัŒ", + "frontendNewer": "ะ’ะตั€ัะธั ะธะฝั‚ะตั€ั„ะตะนัะฐ {frontendVersion} ะผะพะถะตั‚ ะฑั‹ั‚ัŒ ะฝะตัะพะฒะผะตัั‚ะธะผะฐ ั ะฒะตั€ัะธะตะน ัะตั€ะฒะตั€ะฐ {backendVersion}.", + "frontendOutdated": "ะ’ะตั€ัะธั ะธะฝั‚ะตั€ั„ะตะนัะฐ {frontendVersion} ัƒัั‚ะฐั€ะตะปะฐ. ะ”ะปั ั€ะฐะฑะพั‚ั‹ ั ัะตั€ะฒะตั€ะพะผ ั‚ั€ะตะฑัƒะตั‚ัั ะฒะตั€ัะธั {requiredVersion} ะธะปะธ ะฝะพะฒะตะต.", + "title": "ะŸั€ะตะดัƒะฟั€ะตะถะดะตะฝะธะต ะพ ะฝะตัะพะฒะผะตัั‚ะธะผะพัั‚ะธ ะฒะตั€ัะธะน", + "updateFrontend": "ะžะฑะฝะพะฒะธั‚ัŒ ะธะฝั‚ะตั€ั„ะตะนั" + }, "welcome": { "getStarted": "ะะฐั‡ะฐั‚ัŒ", "title": "ะ”ะพะฑั€ะพ ะฟะพะถะฐะปะพะฒะฐั‚ัŒ ะฒ ComfyUI" diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json index f75b429d1..14aad22be 100644 --- a/src/locales/ru/settings.json +++ b/src/locales/ru/settings.json @@ -29,6 +29,13 @@ "name": "ะคะพะฝะพะฒะพะต ะธะทะพะฑั€ะฐะถะตะฝะธะต ั…ะพะปัั‚ะฐ", "tooltip": "URL ะธะทะพะฑั€ะฐะถะตะฝะธั ะดะปั ั„ะพะฝะฐ ั…ะพะปัั‚ะฐ. ะ’ั‹ ะผะพะถะตั‚ะต ะบะปะธะบะฝัƒั‚ัŒ ะฟั€ะฐะฒะพะน ะบะฝะพะฟะบะพะน ะผั‹ัˆะธ ะฝะฐ ะธะทะพะฑั€ะฐะถะตะฝะธะธ ะฒ ะฟะฐะฝะตะปะธ ั€ะตะทัƒะปัŒั‚ะฐั‚ะพะฒ ะธ ะฒั‹ะฑั€ะฐั‚ัŒ ยซะฃัั‚ะฐะฝะพะฒะธั‚ัŒ ะบะฐะบ ั„ะพะฝยป, ั‡ั‚ะพะฑั‹ ะธัะฟะพะปัŒะทะพะฒะฐั‚ัŒ ะตะณะพ." }, + "Comfy_Canvas_NavigationMode": { + "name": "ะ ะตะถะธะผ ะฝะฐะฒะธะณะฐั†ะธะธ ะฟะพ ั…ะพะปัั‚ัƒ", + "options": { + "Left-Click Pan (Legacy)": "ะŸะตั€ะตะผะตั‰ะตะฝะธะต ะปะตะฒะพะน ะบะฝะพะฟะบะพะน (ัƒัั‚ะฐั€ะตะฒัˆะธะน)", + "Standard (New)": "ะกั‚ะฐะฝะดะฐั€ั‚ะฝั‹ะน (ะฝะพะฒั‹ะน)" + } + }, "Comfy_Canvas_SelectionToolbox": { "name": "ะŸะพะบะฐะทะฐั‚ัŒ ะฟะฐะฝะตะปัŒ ะธะฝัั‚ั€ัƒะผะตะฝั‚ะพะฒ ะฒั‹ะฑะพั€ะฐ" }, @@ -329,10 +336,6 @@ }, "tooltip": "ะ ะฐัะฟะพะปะพะถะตะฝะธะต ะฟะฐะฝะตะปะธ ะผะตะฝัŽ. ะะฐ ะผะพะฑะธะปัŒะฝั‹ั… ัƒัั‚ั€ะพะนัั‚ะฒะฐั… ะผะตะฝัŽ ะฒัะตะณะดะฐ ะพั‚ะพะฑั€ะฐะถะฐะตั‚ัั ะฒะฒะตั€ั…ัƒ." }, - "Comfy_Validation_NodeDefs": { - "name": "ะŸั€ะพะฒะตั€ะบะฐ ะพะฟั€ะตะดะตะปะตะฝะธะน ะฝะพะด (ะผะตะดะปะตะฝะฝะพ)", - "tooltip": "ะ ะตะบะพะผะตะฝะดัƒะตั‚ัั ะดะปั ั€ะฐะทั€ะฐะฑะพั‚ั‡ะธะบะพะฒ ะฝะพะด. ะญั‚ะพ ะฟั€ะพะฒะตั€ะธั‚ ะฒัะต ะพะฟั€ะตะดะตะปะตะฝะธั ะฝะพะด ะฟั€ะธ ะทะฐะฟัƒัะบะต." - }, "Comfy_Validation_Workflows": { "name": "ะŸั€ะพะฒะตั€ะบะฐ ั€ะฐะฑะพั‡ะธั… ะฟั€ะพั†ะตััะพะฒ" }, @@ -399,10 +402,6 @@ "LiteGraph_Node_TooltipDelay": { "name": "ะ—ะฐะดะตั€ะถะบะฐ ะฒัะฟะปั‹ะฒะฐัŽั‰ะตะน ะฟะพะดัะบะฐะทะบะธ" }, - "LiteGraph_Pointer_TrackpadGestures": { - "name": "ะ’ะบะปัŽั‡ะธั‚ัŒ ะถะตัั‚ั‹ ั‚ั€ะตะบะฟะฐะดะฐ", - "tooltip": "ะญั‚ะฐ ะฝะฐัั‚ั€ะพะนะบะฐ ะฒะบะปัŽั‡ะฐะตั‚ ั€ะตะถะธะผ ั‚ั€ะตะบะฟะฐะดะฐ ะดะปั ั…ะพะปัั‚ะฐ, ะฟะพะทะฒะพะปัั ะธัะฟะพะปัŒะทะพะฒะฐั‚ัŒ ะผะฐััˆั‚ะฐะฑะธั€ะพะฒะฐะฝะธะต ั‰ะธะฟะบะพะผ ะธ ะฟะฐะฝะพั€ะฐะผะธั€ะพะฒะฐะฝะธะต ะดะฒัƒะผั ะฟะฐะปัŒั†ะฐะผะธ." - }, "LiteGraph_Reroute_SplineOffset": { "name": "ะŸะตั€ะตั€ะฐัะฟั€ะตะดะตะปะตะฝะธะต ัะผะตั‰ะตะฝะธั ัะฟะปะฐะนะฝะฐ", "tooltip": "ะกะผะตั‰ะตะฝะธะต ะบะพะฝั‚ั€ะพะปัŒะฝะพะน ั‚ะพั‡ะบะธ ะ‘ะตะทัŒะต ะพั‚ ั†ะตะฝั‚ั€ะฐะปัŒะฝะพะน ั‚ะพั‡ะบะธ ะฟะตั€ะตั€ะฐัะฟั€ะตะดะตะปะตะฝะธั" diff --git a/src/locales/zh-TW/commands.json b/src/locales/zh-TW/commands.json index 2f81dc6b9..01af3518d 100644 --- a/src/locales/zh-TW/commands.json +++ b/src/locales/zh-TW/commands.json @@ -71,6 +71,9 @@ "Comfy_Canvas_ToggleLock": { "label": "็•ซๅธƒๅˆ‡ๆ›้Ž–ๅฎš" }, + "Comfy_Canvas_ToggleMinimap": { + "label": "็•ซๅธƒๅˆ‡ๆ›ๅฐๅœฐๅœ–" + }, "Comfy_Canvas_ToggleSelectedNodes_Bypass": { "label": "็•ฅ้Ž/ๅ–ๆถˆ็•ฅ้Ž้ธๅ–็š„็ฏ€้ปž" }, @@ -170,6 +173,12 @@ "Comfy_Manager_ToggleManagerProgressDialog": { "label": "ๅˆ‡ๆ›่‡ช่จ‚็ฏ€้ปž็ฎก็†ๅ™จ้€ฒๅบฆๆข" }, + "Comfy_MaskEditor_BrushSize_Decrease": { + "label": "ๆธ›ๅฐ‘ MaskEditor ็•ซ็ญ†ๅคงๅฐ" + }, + "Comfy_MaskEditor_BrushSize_Increase": { + "label": "ๅขžๅŠ  MaskEditor ็•ซ็ญ†ๅคงๅฐ" + }, "Comfy_MaskEditor_OpenMaskEditor": { "label": "็‚บ้ธๅ–็š„็ฏ€้ปž้–‹ๅ•Ÿ Mask ็ทจ่ผฏๅ™จ" }, diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json index 8aaa8d541..8f6219b9e 100644 --- a/src/locales/zh-TW/main.json +++ b/src/locales/zh-TW/main.json @@ -82,6 +82,12 @@ "title": "ๅปบ็ซ‹ๅธณๆˆถ" } }, + "breadcrumbsMenu": { + "clearWorkflow": "ๆธ…้™คๅทฅไฝœๆต็จ‹", + "deleteWorkflow": "ๅˆช้™คๅทฅไฝœๆต็จ‹", + "duplicate": "่ค‡่ฃฝ", + "enterNewName": "่ผธๅ…ฅๆ–ฐๅ็จฑ" + }, "chatHistory": { "cancelEdit": "ๅ–ๆถˆ", "cancelEditTooltip": "ๅ–ๆถˆ็ทจ่ผฏ", @@ -291,6 +297,7 @@ "devices": "่ฃ็ฝฎ", "disableAll": "ๅ…จ้ƒจๅœ็”จ", "disabling": "ๅœ็”จไธญ", + "dismiss": "้—œ้–‰", "download": "ไธ‹่ผ‰", "edit": "็ทจ่ผฏ", "empty": "็ฉบ", @@ -305,6 +312,8 @@ "filter": "็ฏฉ้ธ", "findIssues": "ๅฐ‹ๆ‰พๅ•้กŒ", "firstTimeUIMessage": "้€™ๆ˜ฏๆ‚จ็ฌฌไธ€ๆฌกไฝฟ็”จๆ–ฐไป‹้ขใ€‚่‹ฅ่ฆ่ฟ”ๅ›ž่ˆŠไป‹้ข๏ผŒ่ซ‹ๅ‰ๅพ€ใ€Œ้ธๅ–ฎใ€>ใ€Œไฝฟ็”จๆ–ฐไป‹้ขใ€>ใ€Œ้—œ้–‰ใ€ใ€‚", + "frontendNewer": "ๅ‰็ซฏ็‰ˆๆœฌ {frontendVersion} ๅฏ่ƒฝ่ˆ‡ๅพŒ็ซฏ็‰ˆๆœฌ {backendVersion} ไธ็›ธๅฎนใ€‚", + "frontendOutdated": "ๅ‰็ซฏ็‰ˆๆœฌ {frontendVersion} ๅทฒ้Žๆ™‚ใ€‚ๅพŒ็ซฏ้œ€่ฆ {requiredVersion} ๆˆ–ๆ›ด้ซ˜็‰ˆๆœฌใ€‚", "goToNode": "ๅ‰ๅพ€็ฏ€้ปž", "help": "่ชชๆ˜Ž", "icon": "ๅœ–็คบ", @@ -326,17 +335,20 @@ "loadingPanel": "ๆญฃๅœจ่ผ‰ๅ…ฅ{panel}้ขๆฟ...", "login": "็™ปๅ…ฅ", "logs": "ๆ—ฅ่ชŒ", + "micPermissionDenied": "้บฅๅ…‹้ขจๆฌŠ้™่ขซๆ‹’็ต•", "migrate": "้ท็งป", "missing": "็ผบๅฐ‘", "name": "ๅ็จฑ", "newFolder": "ๆ–ฐ่ณ‡ๆ–™ๅคพ", "next": "ไธ‹ไธ€ๆญฅ", "no": "ๅฆ", + "noAudioRecorded": "ๆฒ’ๆœ‰้Œ„่ฃฝๅˆฐ้Ÿณ่จŠ", "noResultsFound": "ๆ‰พไธๅˆฐ็ตๆžœ", "noTasksFound": "ๆ‰พไธๅˆฐไปปๅ‹™", "noTasksFoundMessage": "ไฝ‡ๅˆ—ไธญๆฒ’ๆœ‰ไปปๅ‹™ใ€‚", "noWorkflowsFound": "ๆ‰พไธๅˆฐๅทฅไฝœๆต็จ‹ใ€‚", "nodes": "็ฏ€้ปž", + "nodesRunning": "็ฏ€้ปžๅŸท่กŒไธญ", "ok": "็ขบๅฎš", "openNewIssue": "้–‹ๅ•Ÿๆ–ฐๅ•้กŒ", "overwrite": "่ฆ†่“‹", @@ -370,7 +382,9 @@ "showReport": "้กฏ็คบๅ ฑๅ‘Š", "sort": "ๆŽ’ๅบ", "source": "ไพ†ๆบ", + "startRecording": "้–‹ๅง‹้Œ„้Ÿณ", "status": "็‹€ๆ…‹", + "stopRecording": "ๅœๆญข้Œ„้Ÿณ", "success": "ๆˆๅŠŸ", "systemInfo": "็ณป็ตฑ่ณ‡่จŠ", "terminal": "็ต‚็ซฏๆฉŸ", @@ -379,11 +393,14 @@ "unknownError": "ๆœช็Ÿฅ้Œฏ่ชค", "update": "ๆ›ดๆ–ฐ", "updateAvailable": "ๆœ‰ๅฏ็”จๆ›ดๆ–ฐ", + "updateFrontend": "ๆ›ดๆ–ฐๅ‰็ซฏ", "updated": "ๅทฒๆ›ดๆ–ฐ", "updating": "ๆ›ดๆ–ฐไธญ", "upload": "ไธŠๅ‚ณ", "usageHint": "ไฝฟ็”จๆ็คบ", "user": "ไฝฟ็”จ่€…", + "versionMismatchWarning": "็‰ˆๆœฌ็›ธๅฎนๆ€ง่ญฆๅ‘Š", + "versionMismatchWarningMessage": "{warning}๏ผš{detail} ่ซ‹ๅƒ้–ฑ https://docs.comfy.org/installation/update_comfyui#common-update-issues ไปฅๅ–ๅพ—ๆ›ดๆ–ฐ่ชชๆ˜Žใ€‚", "videoFailedToLoad": "็„กๆณ•่ผ‰ๅ…ฅๅฝฑ็‰‡", "workflow": "ๅทฅไฝœๆต็จ‹" }, @@ -393,6 +410,7 @@ "resetView": "้‡่จญ่ฆ–ๅœ–", "selectMode": "้ธๅ–ๆจกๅผ", "toggleLinkVisibility": "ๅˆ‡ๆ›้€ฃ็ต้กฏ็คบ", + "toggleMinimap": "ๅˆ‡ๆ›ๅฐๅœฐๅœ–", "zoomIn": "ๆ”พๅคง", "zoomOut": "็ธฎๅฐ" }, @@ -707,13 +725,17 @@ "batchCountTooltip": "ๅทฅไฝœๆต็จ‹็”ข็”Ÿๆ‡‰ๆŽ’ๅ…ฅไฝ‡ๅˆ—็š„ๆฌกๆ•ธ", "clear": "ๆธ…้™คๅทฅไฝœๆต็จ‹", "clipspace": "้–‹ๅ•Ÿ Clipspace", + "dark": "ๆทฑ่‰ฒ", "disabled": "ๅทฒๅœ็”จ", "disabledTooltip": "ๅทฅไฝœๆต็จ‹ๅฐ‡ไธๆœƒ่‡ชๅ‹•ๆŽ’ๅ…ฅไฝ‡ๅˆ—", "execute": "ๅŸท่กŒ", + "help": "่ชชๆ˜Ž", "hideMenu": "้šฑ่—้ธๅ–ฎ", "instant": "็ซ‹ๅณ", "instantTooltip": "ๆฏๆฌก็”ข็”ŸๅฎŒๆˆๅพŒ๏ผŒๅทฅไฝœๆต็จ‹ๆœƒ็ซ‹ๅณๆŽ’ๅ…ฅไฝ‡ๅˆ—", "interrupt": "ๅ–ๆถˆ็›ฎๅ‰ๅŸท่กŒ", + "light": "ๆทบ่‰ฒ", + "manageExtensions": "็ฎก็†ๆ“ดๅ……ๅŠŸ่ƒฝ", "onChange": "่ฎŠๆ›ดๆ™‚", "onChangeTooltip": "ๆฏ็•ถๆœ‰่ฎŠๆ›ดๆ™‚๏ผŒๅทฅไฝœๆต็จ‹ๆœƒๆŽ’ๅ…ฅไฝ‡ๅˆ—", "refresh": "้‡ๆ–ฐๆ•ด็†็ฏ€้ปžๅฎš็พฉ", @@ -721,7 +743,9 @@ "run": "ๅŸท่กŒ", "runWorkflow": "ๅŸท่กŒๅทฅไฝœๆต็จ‹๏ผˆShift ๆ–ผๅ‰ๆ–นๆŽ’้šŠ๏ผ‰", "runWorkflowFront": "ๅŸท่กŒๅทฅไฝœๆต็จ‹๏ผˆๅ‰ๆ–นๆŽ’้šŠ๏ผ‰", + "settings": "่จญๅฎš", "showMenu": "้กฏ็คบ้ธๅ–ฎ", + "theme": "ไธป้กŒ", "toggleBottomPanel": "ๅˆ‡ๆ›ไธ‹ๆ–น้ขๆฟ" }, "menuLabels": { @@ -731,6 +755,7 @@ "Bypass/Unbypass Selected Nodes": "็นž้Ž/ๅ–ๆถˆ็นž้Ž้ธๅ–็ฏ€้ปž", "Canvas Toggle Link Visibility": "ๅˆ‡ๆ›้€ฃ็ตๅฏ่ฆ‹ๆ€ง", "Canvas Toggle Lock": "ๅˆ‡ๆ›็•ซๅธƒ้Ž–ๅฎš", + "Canvas Toggle Minimap": "็•ซๅธƒๅˆ‡ๆ›ๅฐๅœฐๅœ–", "Check for Custom Node Updates": "ๆชขๆŸฅ่‡ช่จ‚็ฏ€้ปžๆ›ดๆ–ฐ", "Check for Updates": "ๆชขๆŸฅๆ›ดๆ–ฐ", "Clear Pending Tasks": "ๆธ…้™คๅพ…่™•็†ไปปๅ‹™", @@ -746,6 +771,8 @@ "Convert Selection to Subgraph": "ๅฐ‡้ธๅ–ๅ…งๅฎน่ฝ‰็‚บๅญๅœ–", "Convert selected nodes to group node": "ๅฐ‡้ธๅ–็ฏ€้ปž่ฝ‰็‚บ็พค็ต„็ฏ€้ปž", "Custom Nodes (Legacy)": "่‡ช่จ‚็ฏ€้ปž๏ผˆ่ˆŠ็‰ˆ๏ผ‰", + "Custom Nodes Manager": "่‡ช่จ‚็ฏ€้ปž็ฎก็†ๅ“ก", + "Decrease Brush Size in MaskEditor": "ๅœจ MaskEditor ไธญๆธ›ๅฐ็ญ†ๅˆทๅคงๅฐ", "Delete Selected Items": "ๅˆช้™ค้ธๅ–้ …็›ฎ", "Desktop User Guide": "ๆกŒ้ขๆ‡‰็”จ็จ‹ๅผไฝฟ็”จๆŒ‡ๅ—", "Duplicate Current Workflow": "่ค‡่ฃฝ็›ฎๅ‰ๅทฅไฝœๆต็จ‹", @@ -757,6 +784,7 @@ "Give Feedback": "ๆไพ›ๆ„่ฆ‹ๅ›ž้ฅ‹", "Group Selected Nodes": "็พค็ต„้ธๅ–็ฏ€้ปž", "Help": "่ชชๆ˜Ž", + "Increase Brush Size in MaskEditor": "ๅœจ MaskEditor ไธญๅขžๅคง็ญ†ๅˆทๅคงๅฐ", "Install Missing Custom Nodes": "ๅฎ‰่ฃ็ผบๅฐ‘็š„่‡ช่จ‚็ฏ€้ปž", "Interrupt": "ไธญๆ–ท", "Load Default Workflow": "่ผ‰ๅ…ฅ้ ่จญๅทฅไฝœๆต็จ‹", @@ -1175,7 +1203,6 @@ }, "showFlatList": "้กฏ็คบๅนณ้ขๆธ…ๅ–ฎ" }, - "themeToggle": "ๅˆ‡ๆ›ไธป้กŒ", "workflowTab": { "confirmDelete": "ๆ‚จ็ขบๅฎš่ฆๅˆช้™ค้€™ๅ€‹ๅทฅไฝœๆต็จ‹ๅ—Ž๏ผŸ", "confirmDeleteTitle": "ๅˆช้™คๅทฅไฝœๆต็จ‹๏ผŸ", @@ -1599,6 +1626,13 @@ "prefix": "ๅฟ…้ ˆไปฅ {prefix} ้–‹้ ญ", "required": "ๅฟ…ๅกซ" }, + "versionMismatchWarning": { + "dismiss": "้—œ้–‰", + "frontendNewer": "ๅ‰็ซฏ็‰ˆๆœฌ {frontendVersion} ๅฏ่ƒฝ่ˆ‡ๅพŒ็ซฏ็‰ˆๆœฌ {backendVersion} ไธ็›ธๅฎนใ€‚", + "frontendOutdated": "ๅ‰็ซฏ็‰ˆๆœฌ {frontendVersion} ๅทฒ้Žๆ™‚ใ€‚ๅพŒ็ซฏ้œ€่ฆ็‰ˆๆœฌ {requiredVersion} ๆˆ–ๆ›ด้ซ˜็‰ˆๆœฌใ€‚", + "title": "็‰ˆๆœฌ็›ธๅฎนๆ€ง่ญฆๅ‘Š", + "updateFrontend": "ๆ›ดๆ–ฐๅ‰็ซฏ" + }, "welcome": { "getStarted": "้–‹ๅง‹ไฝฟ็”จ", "title": "ๆญก่ฟŽไฝฟ็”จ ComfyUI" diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index c0cc900d3..1bd661a1b 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -29,6 +29,13 @@ "name": "็•ซๅธƒ่ƒŒๆ™ฏๅœ–็‰‡", "tooltip": "็•ซๅธƒ่ƒŒๆ™ฏ็š„ๅœ–็‰‡็ถฒๅ€ใ€‚ไฝ ๅฏไปฅๅœจ่ผธๅ‡บ้ขๆฟไธญๅณ้ต้ปžๆ“Šๅœ–็‰‡ไธฆ้ธๆ“‡ใ€Œ่จญ็‚บ่ƒŒๆ™ฏใ€ไพ†ไฝฟ็”จ๏ผŒๆˆ–ๆ˜ฏไฝฟ็”จไธŠๅ‚ณๆŒ‰้ˆ•ไธŠๅ‚ณไฝ ่‡ชๅทฑ็š„ๅœ–็‰‡ใ€‚" }, + "Comfy_Canvas_NavigationMode": { + "name": "็•ซๅธƒๅฐŽ่ˆชๆจกๅผ", + "options": { + "Left-Click Pan (Legacy)": "ๅทฆ้ตๆ‹–ๆ›ณๅนณ็งป๏ผˆ่ˆŠ็‰ˆ๏ผ‰", + "Standard (New)": "ๆจ™ๆบ–๏ผˆๆ–ฐ๏ผ‰" + } + }, "Comfy_Canvas_SelectionToolbox": { "name": "้กฏ็คบ้ธๅ–ๅทฅๅ…ท็ฎฑ" }, @@ -329,10 +336,6 @@ }, "tooltip": "้ธๅ–ฎๅˆ—ไฝ็ฝฎใ€‚ๅœจ่กŒๅ‹•่ฃ็ฝฎไธŠ๏ผŒ้ธๅ–ฎๆฐธ้ ้กฏ็คบๅœจ้ ‚้ƒจใ€‚" }, - "Comfy_Validation_NodeDefs": { - "name": "้ฉ—่ญ‰็ฏ€้ปžๅฎš็พฉ๏ผˆ่ผƒๆ…ข๏ผ‰", - "tooltip": "ๅปบ่ญฐ็ฏ€้ปž้–‹็™ผ่€…ไฝฟ็”จใ€‚้€™ๆœƒๅœจๅ•Ÿๅ‹•ๆ™‚้ฉ—่ญ‰ๆ‰€ๆœ‰็ฏ€้ปžๅฎš็พฉใ€‚" - }, "Comfy_Validation_Workflows": { "name": "้ฉ—่ญ‰ๅทฅไฝœๆต็จ‹" }, @@ -399,10 +402,6 @@ "LiteGraph_Node_TooltipDelay": { "name": "ๆ็คบๅปถ้ฒ" }, - "LiteGraph_Pointer_TrackpadGestures": { - "name": "ๅ•Ÿ็”จ่งธๆŽงๆฟๆ‰‹ๅ‹ข", - "tooltip": "ๆญค่จญๅฎšๅฏ็‚บ็•ซๅธƒๅ•Ÿ็”จ่งธๆŽงๆฟๆจกๅผ๏ผŒๅ…่จฑไฝฟ็”จๅ…ฉๆŒ‡็ธฎๆ”พ่ˆ‡ๅนณ็งปใ€‚" - }, "LiteGraph_Reroute_SplineOffset": { "name": "้‡ๅฐŽๆจฃๆขๅ็งป", "tooltip": "่ฒ่ŒฒๆŽงๅˆถ้ปž็›ธๅฐๆ–ผ้‡ๅฐŽไธญๅฟƒ้ปž็š„ๅ็งป้‡" diff --git a/src/locales/zh/commands.json b/src/locales/zh/commands.json index f7c65dc9c..70cfcd3d9 100644 --- a/src/locales/zh/commands.json +++ b/src/locales/zh/commands.json @@ -71,6 +71,9 @@ "Comfy_Canvas_ToggleLock": { "label": "้”ๅฎš่ง†ๅ›พ" }, + "Comfy_Canvas_ToggleMinimap": { + "label": "็•ซๅธƒๅˆ‡ๆ›ๅฐๅœฐๅœ–" + }, "Comfy_Canvas_ToggleSelectedNodes_Bypass": { "label": "ๅฟฝ็•ฅ/ๅ–ๆถˆๅฟฝ็•ฅ้€‰ไธญ่Š‚็‚น" }, @@ -170,6 +173,12 @@ "Comfy_Manager_ToggleManagerProgressDialog": { "label": "ๅˆ‡ๆข่ฟ›ๅบฆๅฏน่ฏๆก†" }, + "Comfy_MaskEditor_BrushSize_Decrease": { + "label": "ๆธ›ๅฐ MaskEditor ไธญ็š„็ญ†ๅˆทๅคงๅฐ" + }, + "Comfy_MaskEditor_BrushSize_Increase": { + "label": "ๅขžๅŠ  MaskEditor ็•ซ็ญ†ๅคงๅฐ" + }, "Comfy_MaskEditor_OpenMaskEditor": { "label": "ๆ‰“ๅผ€้€‰ไธญ่Š‚็‚น็š„้ฎ็ฝฉ็ผ–่พ‘ๅ™จ" }, diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index b1f154c35..9c1c8c48a 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -82,6 +82,12 @@ "title": "ๅˆ›ๅปบไธ€ไธช่ดฆๆˆท" } }, + "breadcrumbsMenu": { + "clearWorkflow": "ๆธ…้™คๅทฅไฝœๆต็จ‹", + "deleteWorkflow": "ๅˆช้™คๅทฅไฝœๆต็จ‹", + "duplicate": "่ค‡่ฃฝ", + "enterNewName": "่ผธๅ…ฅๆ–ฐๅ็จฑ" + }, "chatHistory": { "cancelEdit": "ๅ–ๆถˆ", "cancelEditTooltip": "ๅ–ๆถˆ็ผ–่พ‘", @@ -291,6 +297,7 @@ "devices": "่ฎพๅค‡", "disableAll": "็ฆ็”จๅ…จ้ƒจ", "disabling": "็ฆ็”จไธญ", + "dismiss": "้—œ้–‰", "download": "ไธ‹่ฝฝ", "edit": "็ผ–่พ‘", "empty": "็ฉบ", @@ -305,6 +312,8 @@ "filter": "่ฟ‡ๆปค", "findIssues": "ๆŸฅๆ‰พ้—ฎ้ข˜", "firstTimeUIMessage": "่ฟ™ๆ˜ฏๆ‚จ็ฌฌไธ€ๆฌกไฝฟ็”จๆ–ฐ็•Œ้ขใ€‚้€‰ๆ‹ฉ \"่œๅ• > ไฝฟ็”จๆ–ฐ่œๅ• > ็ฆ็”จ\" ๆฅๆขๅคๆ—ง็•Œ้ขใ€‚", + "frontendNewer": "ๅ‰็ซฏ็‰ˆๆœฌ {frontendVersion} ๅฏ่ƒฝ่ˆ‡ๅพŒ็ซฏ็‰ˆๆœฌ {backendVersion} ไธ็›ธๅฎนใ€‚", + "frontendOutdated": "ๅ‰็ซฏ็‰ˆๆœฌ {frontendVersion} ๅทฒ้Žๆ™‚ใ€‚ๅพŒ็ซฏ้œ€่ฆ {requiredVersion} ๆˆ–ๆ›ด้ซ˜็‰ˆๆœฌใ€‚", "goToNode": "่ฝฌๅˆฐ่Š‚็‚น", "help": "ๅธฎๅŠฉ", "icon": "ๅ›พๆ ‡", @@ -326,17 +335,20 @@ "loadingPanel": "ๆญฃๅœจๅŠ ่ฝฝ{panel}้ขๆฟ...", "login": "็™ปๅฝ•", "logs": "ๆ—ฅๅฟ—", + "micPermissionDenied": "้บฆๅ…‹้ฃŽๆƒ้™่ขซๆ‹’็ป", "migrate": "่ฟ็งป", "missing": "็ผบๅคฑ", "name": "ๅ็งฐ", "newFolder": "ๆ–ฐๆ–‡ไปถๅคน", "next": "ไธ‹ไธ€ไธช", "no": "ๅฆ", + "noAudioRecorded": "ๆœชๅฝ•ๅˆถ้Ÿณ้ข‘", "noResultsFound": "ๆœชๆ‰พๅˆฐ็ป“ๆžœ", "noTasksFound": "ๆœชๆ‰พๅˆฐไปปๅŠก", "noTasksFoundMessage": "้˜Ÿๅˆ—ไธญๆฒกๆœ‰ไปปๅŠกใ€‚", "noWorkflowsFound": "ๆœชๆ‰พๅˆฐๅทฅไฝœๆตใ€‚", "nodes": "่Š‚็‚น", + "nodesRunning": "่Š‚็‚นๆญฃๅœจ่ฟ่กŒ", "ok": "็กฎๅฎš", "openNewIssue": "ๆ‰“ๅผ€ๆ–ฐ้—ฎ้ข˜", "overwrite": "่ฆ†็›–", @@ -370,7 +382,9 @@ "showReport": "ๆ˜พ็คบๆŠฅๅ‘Š", "sort": "ๆŽ’ๅบ", "source": "ๆฅๆบ", + "startRecording": "ๅผ€ๅง‹ๅฝ•้Ÿณ", "status": "็Šถๆ€", + "stopRecording": "ๅœๆญขๅฝ•้Ÿณ", "success": "ๆˆๅŠŸ", "systemInfo": "็ณป็ปŸไฟกๆฏ", "terminal": "็ปˆ็ซฏ", @@ -379,11 +393,14 @@ "unknownError": "ๆœช็Ÿฅ้”™่ฏฏ", "update": "ๆ›ดๆ–ฐ", "updateAvailable": "ๆœ‰ๆ›ดๆ–ฐๅฏ็”จ", + "updateFrontend": "ๆ›ดๆ–ฐๅ‰็ซฏ", "updated": "ๅทฒๆ›ดๆ–ฐ", "updating": "ๆ›ดๆ–ฐไธญ", "upload": "ไธŠไผ ", "usageHint": "ไฝฟ็”จๆ็คบ", "user": "็”จๆˆท", + "versionMismatchWarning": "็‰ˆๆœฌ็›ธๅฎนๆ€ง่ญฆๅ‘Š", + "versionMismatchWarningMessage": "{warning}๏ผš{detail} ่ซ‹ๅƒ้–ฑ https://docs.comfy.org/installation/update_comfyui#common-update-issues ไปฅๅ–ๅพ—ๆ›ดๆ–ฐ่ชชๆ˜Žใ€‚", "videoFailedToLoad": "่ง†้ข‘ๅŠ ่ฝฝๅคฑ่ดฅ", "workflow": "ๅทฅไฝœๆต" }, @@ -393,6 +410,7 @@ "resetView": "้‡็ฝฎ่ง†ๅ›พ", "selectMode": "้€‰ๆ‹ฉๆจกๅผ", "toggleLinkVisibility": "ๅˆ‡ๆข่ฟž็บฟๅฏ่งๆ€ง", + "toggleMinimap": "ๅˆ‡ๆ›ๅฐๅœฐๅœ–", "zoomIn": "ๆ”พๅคง", "zoomOut": "็ผฉๅฐ" }, @@ -707,13 +725,17 @@ "batchCountTooltip": "ๅทฅไฝœๆต็”Ÿๆˆๆฌกๆ•ฐ", "clear": "ๆธ…็ฉบๅทฅไฝœๆต", "clipspace": "ๆ‰“ๅผ€ๅ‰ช่ดดๆฟ", + "dark": "ๆทฑ่‰ฒ", "disabled": "็ฆ็”จ", "disabledTooltip": "ๅทฅไฝœๆตๅฐ†ไธไผš่‡ชๅŠจๆ‰ง่กŒ", "execute": "ๆ‰ง่กŒ", + "help": "่ชชๆ˜Ž", "hideMenu": "้š่—่œๅ•", "instant": "ๅฎžๆ—ถ", "instantTooltip": "ๅทฅไฝœๆตๅฐ†ไผšๅœจ็”ŸๆˆๅฎŒๆˆๅŽ็ซ‹ๅณๆ‰ง่กŒ", "interrupt": "ๅ–ๆถˆๅฝ“ๅ‰ไปปๅŠก", + "light": "ๆทบ่‰ฒ", + "manageExtensions": "็ฎก็†ๆ“ดๅ……ๅŠŸ่ƒฝ", "onChange": "ๆ›ดๆ”นๆ—ถ", "onChangeTooltip": "ไธ€ๆ—ฆ่ฟ›่กŒๆ›ดๆ”น๏ผŒๅทฅไฝœๆตๅฐ†ๆทปๅŠ ๅˆฐๆ‰ง่กŒ้˜Ÿๅˆ—", "refresh": "ๅˆทๆ–ฐ่Š‚็‚น", @@ -721,7 +743,9 @@ "run": "่ฟ่กŒ", "runWorkflow": "่ฟ่กŒๅทฅไฝœๆต็จ‹๏ผˆShiftๆŽ’ๅœจๅ‰้ข๏ผ‰", "runWorkflowFront": "่ฟ่กŒๅทฅไฝœๆต็จ‹๏ผˆๆŽ’ๅœจๅ‰้ข๏ผ‰", + "settings": "่จญๅฎš", "showMenu": "ๆ˜พ็คบ่œๅ•", + "theme": "ไธป้กŒ", "toggleBottomPanel": "ๅบ•้ƒจ้ขๆฟ" }, "menuLabels": { @@ -732,6 +756,8 @@ "Canvas Toggle Link Visibility": "ๅˆ‡ๆข่ฟž็บฟๅฏ่งๆ€ง", "Canvas Toggle Lock": "ๅˆ‡ๆข่ง†ๅ›พ้”ๅฎš", "Check for Custom Node Updates": "ๆฃ€ๆŸฅ่‡ชๅฎšไน‰่Š‚็‚นๆ›ดๆ–ฐ", + "Canvas Toggle Minimap": "็•ซๅธƒๅˆ‡ๆ›ๅฐๅœฐๅœ–", + "Check for Custom Node Updates": "ๆชขๆŸฅ่‡ช่จ‚็ฏ€้ปžๆ›ดๆ–ฐ", "Check for Updates": "ๆฃ€ๆŸฅๆ›ดๆ–ฐ", "Clear Pending Tasks": "ๆธ…้™คๅพ…ๅค„็†ไปปๅŠก", "Clear Workflow": "ๆธ…้™คๅทฅไฝœๆต", @@ -745,7 +771,9 @@ "Contact Support": "่”็ณปๆ”ฏๆŒ", "Convert Selection to Subgraph": "ๅฐ†้€‰ไธญๅ†…ๅฎน่ฝฌๆขไธบๅญๅ›พ", "Convert selected nodes to group node": "ๅฐ†้€‰ไธญ่Š‚็‚น่ฝฌๆขไธบ็ป„่Š‚็‚น", - "Custom Nodes (Legacy)": "่‡ชๅฎšไน‰่Š‚็‚น๏ผˆๆ—ง็‰ˆ๏ผ‰", + "Custom Nodes (Legacy)": "่‡ช่จ‚็ฏ€้ปž๏ผˆ่ˆŠ็‰ˆ๏ผ‰", + "Custom Nodes Manager": "่‡ชๅฎšไน‰่Š‚็‚น็ฎก็†ๅ™จ", + "Decrease Brush Size in MaskEditor": "ๅœจ MaskEditor ไธญๆธ›ๅฐ็ญ†ๅˆทๅคงๅฐ", "Delete Selected Items": "ๅˆ ้™ค้€‰ๅฎš็š„้กน็›ฎ", "Desktop User Guide": "ๆกŒ้ข็ซฏ็”จๆˆทๆŒ‡ๅ—", "Duplicate Current Workflow": "ๅคๅˆถๅฝ“ๅ‰ๅทฅไฝœๆต", @@ -757,7 +785,8 @@ "Give Feedback": "ๆไพ›ๅ้ฆˆ", "Group Selected Nodes": "ๅฐ†้€‰ไธญ่Š‚็‚น่ฝฌๆขไธบ็ป„่Š‚็‚น", "Help": "ๅธฎๅŠฉ", - "Install Missing Custom Nodes": "ๅฎ‰่ฃ…็ผบๅคฑ็š„่‡ชๅฎšไน‰่Š‚็‚น", + "Increase Brush Size in MaskEditor": "ๅœจ MaskEditor ไธญๅขžๅคง็ญ†ๅˆทๅคงๅฐ", + "Install Missing Custom Nodes": "ๅฎ‰่ฃ็ผบๅฐ‘็š„่‡ช่จ‚็ฏ€้ปž", "Interrupt": "ไธญๆ–ญ", "Load Default Workflow": "ๅŠ ่ฝฝ้ป˜่ฎคๅทฅไฝœๆต", "Manage group nodes": "็ฎก็†็ป„่Š‚็‚น", @@ -801,13 +830,13 @@ "Toggle Bottom Panel": "ๅˆ‡ๆขๅบ•้ƒจ้ขๆฟ", "Toggle Focus Mode": "ๅˆ‡ๆขไธ“ๆณจๆจกๅผ", "Toggle Logs Bottom Panel": "ๅˆ‡ๆขๆ—ฅๅฟ—ๅบ•้ƒจ้ขๆฟ", - "Toggle Model Library Sidebar": "ๅˆ‡ๆขๆจกๅž‹ๅบ“ไพง่พนๆ ", - "Toggle Node Library Sidebar": "ๅˆ‡ๆข่Š‚็‚นๅบ“ไพง่พนๆ ", - "Toggle Queue Sidebar": "ๅˆ‡ๆข้˜Ÿๅˆ—ไพง่พนๆ ", + "Toggle Model Library Sidebar": "ๅˆ‡ๆ›ๆจกๅž‹ๅบซๅด้‚Šๆฌ„", + "Toggle Node Library Sidebar": "ๅˆ‡ๆ›็ฏ€้ปžๅบซๅด้‚Šๆฌ„", + "Toggle Queue Sidebar": "ๅˆ‡ๆ›ไฝ‡ๅˆ—ๅด้‚Šๆฌ„", "Toggle Search Box": "ๅˆ‡ๆขๆœ็ดขๆก†", "Toggle Terminal Bottom Panel": "ๅˆ‡ๆข็ปˆ็ซฏๅบ•้ƒจ้ขๆฟ", "Toggle Theme (Dark/Light)": "ๅˆ‡ๆขไธป้ข˜๏ผˆๆš—/ไบฎ๏ผ‰", - "Toggle Workflows Sidebar": "ๅˆ‡ๆขๅทฅไฝœๆตไพง่พนๆ ", + "Toggle Workflows Sidebar": "ๅˆ‡ๆ›ๅทฅไฝœๆต็จ‹ๅด้‚Šๆฌ„", "Toggle the Custom Nodes Manager Progress Bar": "ๅˆ‡ๆข่‡ชๅฎšไน‰่Š‚็‚น็ฎก็†ๅ™จ่ฟ›ๅบฆๆก", "Undo": "ๆ’ค้”€", "Ungroup selected group nodes": "่งฃๆ•ฃ้€‰ไธญ็ป„่Š‚็‚น", @@ -1175,7 +1204,6 @@ }, "showFlatList": "ๅนณ้“บ็ป“ๆžœ" }, - "themeToggle": "ไธป้ข˜ๅˆ‡ๆข", "workflowTab": { "confirmDelete": "ๆ‚จ็กฎๅฎš่ฆๅˆ ้™คๆญคๅทฅไฝœๆตๅ—๏ผŸ", "confirmDeleteTitle": "ๅˆ ้™คๅทฅไฝœๆต๏ผŸ", @@ -1599,6 +1627,13 @@ "prefix": "ๅฟ…้กปไปฅ {prefix} ๅผ€ๅคด", "required": "ๅฟ…ๅกซ" }, + "versionMismatchWarning": { + "dismiss": "้—œ้–‰", + "frontendNewer": "ๅ‰็ซฏ็‰ˆๆœฌ {frontendVersion} ๅฏ่ƒฝ่ˆ‡ๅพŒ็ซฏ็‰ˆๆœฌ {backendVersion} ไธ็›ธๅฎนใ€‚", + "frontendOutdated": "ๅ‰็ซฏ็‰ˆๆœฌ {frontendVersion} ๅทฒ้Žๆ™‚ใ€‚ๅพŒ็ซฏ้œ€่ฆ {requiredVersion} ็‰ˆๆˆ–ๆ›ด้ซ˜็‰ˆๆœฌใ€‚", + "title": "็‰ˆๆœฌ็›ธๅฎนๆ€ง่ญฆๅ‘Š", + "updateFrontend": "ๆ›ดๆ–ฐๅ‰็ซฏ" + }, "welcome": { "getStarted": "ๅผ€ๅง‹ไฝฟ็”จ", "title": "ๆฌข่ฟŽไฝฟ็”จ ComfyUI" diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index cbb7de4f3..12ecbb5ed 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -29,6 +29,13 @@ "name": "็”ปๅธƒ่ƒŒๆ™ฏๅ›พๅƒ", "tooltip": "็”ปๅธƒ่ƒŒๆ™ฏ็š„ๅ›พๅƒ URLใ€‚ไฝ ๅฏไปฅๅœจ่พ“ๅ‡บ้ขๆฟไธญๅณ้”ฎ็‚นๅ‡ปไธ€ๅผ ๅ›พ็‰‡๏ผŒๅนถ้€‰ๆ‹ฉโ€œ่ฎพไธบ่ƒŒๆ™ฏโ€ๆฅไฝฟ็”จๅฎƒใ€‚" }, + "Comfy_Canvas_NavigationMode": { + "name": "็•ซๅธƒๅฐŽ่ˆชๆจกๅผ", + "options": { + "Left-Click Pan (Legacy)": "ๅทฆ้ตๆ‹–ๆ›ณ๏ผˆ่ˆŠ็‰ˆ๏ผ‰", + "Standard (New)": "ๆจ™ๆบ–๏ผˆๆ–ฐ๏ผ‰" + } + }, "Comfy_Canvas_SelectionToolbox": { "name": "ๆ˜พ็คบ้€‰ๆ‹ฉๅทฅๅ…ท็ฎฑ" }, @@ -329,10 +336,6 @@ }, "tooltip": "้ธๅ–ฎๅˆ—ไฝ็ฝฎใ€‚ๅœจ่กŒๅ‹•่ฃ็ฝฎไธŠ๏ผŒ้ธๅ–ฎๅง‹็ต‚้กฏ็คบๆ–ผ้ ‚็ซฏใ€‚" }, - "Comfy_Validation_NodeDefs": { - "name": "ๆ ก้ชŒ่Š‚็‚นๅฎšไน‰๏ผˆๆ…ข๏ผ‰", - "tooltip": "ๆŽจ่็ป™่Š‚็‚นๅผ€ๅ‘่€…ใ€‚ๅผ€ๅฏๅŽไผšๅœจ ComfyUI ๅฏๅŠจๆ—ถๆ ก้ชŒๅ…จ้ƒจ่Š‚็‚นๅฎšไน‰ใ€‚" - }, "Comfy_Validation_Workflows": { "name": "ๆ ก้ชŒๅทฅไฝœๆต" }, @@ -399,10 +402,6 @@ "LiteGraph_Node_TooltipDelay": { "name": "ๅทฅๅ…ทๆ็คบๅปถ่ฟŸ" }, - "LiteGraph_Pointer_TrackpadGestures": { - "name": "ๅฏ็”จ่งฆๆŽงๆฟๆ‰‹ๅŠฟ", - "tooltip": "ๆญค่ฎพ็ฝฎไธบ็”ปๅธƒๅฏ็”จ่งฆๆŽงๆฟๆจกๅผ๏ผŒๅ…่ฎธไฝฟ็”จๅŒๆŒ‡ๆๅˆ็ผฉๆ”พๅ’Œๆ‹–ๅŠจใ€‚" - }, "LiteGraph_Reroute_SplineOffset": { "name": "้‡ๆ–ฐ่ทฏ็”ฑๆ ทๆกๅ็งป", "tooltip": "่ดๅกžๅฐ”ๆŽงๅˆถ็‚นไปŽ้‡ๆ–ฐ่ทฏ็”ฑไธญๅฟƒ็‚น็š„ๅ็งป" diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts index 0d2709d53..9fe4bc26f 100644 --- a/src/schemas/apiSchema.ts +++ b/src/schemas/apiSchema.ts @@ -48,6 +48,22 @@ const zProgressWsMessage = z.object({ node: zNodeId }) +const zNodeProgressState = z.object({ + value: z.number(), + max: z.number(), + state: z.enum(['pending', 'running', 'finished', 'error']), + node_id: zNodeId, + prompt_id: zPromptId, + display_node_id: zNodeId.optional(), + parent_node_id: zNodeId.optional(), + real_node_id: zNodeId.optional() +}) + +const zProgressStateWsMessage = z.object({ + prompt_id: zPromptId, + nodes: z.record(zNodeId, zNodeProgressState) +}) + const zExecutingWsMessage = z.object({ node: zNodeId, display_node: zNodeId, @@ -134,6 +150,8 @@ export type ProgressTextWsMessage = z.infer export type DisplayComponentWsMessage = z.infer< typeof zDisplayComponentWsMessage > +export type NodeProgressState = z.infer +export type ProgressStateWsMessage = z.infer export type FeatureFlagsWsMessage = z.infer // End of ws messages @@ -320,6 +338,7 @@ export const zSystemStats = z.object({ embedded_python: z.boolean(), comfyui_version: z.string(), pytorch_version: z.string(), + required_frontend_version: z.string().optional(), argv: z.array(z.string()), ram_total: z.number(), ram_free: z.number() @@ -449,7 +468,6 @@ const zSettings = z.object({ 'LiteGraph.Canvas.LowQualityRenderingZoomThreshold': z.number(), 'Comfy.Canvas.SelectionToolbox': z.boolean(), 'LiteGraph.Node.TooltipDelay': z.number(), - 'Comfy.ComfirmClear': z.boolean(), 'LiteGraph.ContextMenu.Scaling': z.boolean(), 'LiteGraph.Reroute.SplineOffset': z.number(), 'Comfy.Toast.DisableReconnectingToast': z.boolean(), @@ -457,6 +475,8 @@ const zSettings = z.object({ 'Comfy.TutorialCompleted': z.boolean(), 'Comfy.InstalledVersion': z.string().nullable(), 'Comfy.Node.AllowImageSizeDraw': z.boolean(), + 'Comfy.Minimap.Visible': z.boolean(), + 'Comfy.Canvas.NavigationMode': z.string(), 'Comfy-Desktop.AutoUpdate': z.boolean(), 'Comfy-Desktop.SendStatistics': z.boolean(), 'Comfy-Desktop.WindowStyle': z.string(), diff --git a/src/scripts/api.ts b/src/scripts/api.ts index fa884a8e5..8ac138c5d 100644 --- a/src/scripts/api.ts +++ b/src/scripts/api.ts @@ -15,6 +15,7 @@ import type { LogsRawResponse, LogsWsMessage, PendingTaskItem, + ProgressStateWsMessage, ProgressTextWsMessage, ProgressWsMessage, PromptResponse, @@ -31,10 +32,7 @@ import type { ComfyWorkflowJSON, NodeId } from '@/schemas/comfyWorkflowSchema' -import { - type ComfyNodeDef, - validateComfyNodeDef -} from '@/schemas/nodeDefSchema' +import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' import { useToastStore } from '@/stores/toastStore' import { WorkflowTemplates } from '@/types/workflowTemplateTypes' @@ -104,7 +102,17 @@ interface BackendApiCalls { logs: LogsWsMessage /** Binary preview/progress data */ b_preview: Blob + /** Binary preview with metadata (node_id, prompt_id) */ + b_preview_with_metadata: { + blob: Blob + nodeId: string + parentNodeId: string + displayNodeId: string + realNodeId: string + promptId: string + } progress_text: ProgressTextWsMessage + progress_state: ProgressStateWsMessage display_component: DisplayComponentWsMessage } @@ -433,6 +441,33 @@ export class ComfyApi extends EventTarget { }) this.dispatchCustomEvent('b_preview', imageBlob) break + case 4: + // PREVIEW_IMAGE_WITH_METADATA + const decoder4 = new TextDecoder() + const metadataLength = view.getUint32(4) + const metadataBytes = event.data.slice(8, 8 + metadataLength) + const metadata = JSON.parse(decoder4.decode(metadataBytes)) + const imageData4 = event.data.slice(8 + metadataLength) + + let imageMime4 = metadata.image_type + + const imageBlob4 = new Blob([imageData4], { + type: imageMime4 + }) + + // Dispatch enhanced preview event with metadata + this.dispatchCustomEvent('b_preview_with_metadata', { + blob: imageBlob4, + nodeId: metadata.node_id, + displayNodeId: metadata.display_node_id, + parentNodeId: metadata.parent_node_id, + realNodeId: metadata.real_node_id, + promptId: metadata.prompt_id + }) + + // Also dispatch legacy b_preview for backward compatibility + this.dispatchCustomEvent('b_preview', imageBlob4) + break default: throw new Error( `Unknown binary websocket message of type ${eventType}` @@ -462,6 +497,7 @@ export class ComfyApi extends EventTarget { case 'execution_cached': case 'execution_success': case 'progress': + case 'progress_state': case 'executed': case 'graphChanged': case 'promptQueued': @@ -534,31 +570,9 @@ export class ComfyApi extends EventTarget { * Loads node object definitions for the graph * @returns The node definitions */ - async getNodeDefs({ validate = false }: { validate?: boolean } = {}): Promise< - Record - > { + async getNodeDefs(): Promise> { const resp = await this.fetchApi('/object_info', { cache: 'no-store' }) - const objectInfoUnsafe = await resp.json() - if (!validate) { - return objectInfoUnsafe - } - // Validate node definitions against zod schema. (slow) - const objectInfo: Record = {} - for (const key in objectInfoUnsafe) { - const validatedDef = validateComfyNodeDef( - objectInfoUnsafe[key], - /* onError=*/ (errorMessage: string) => { - console.warn( - `Skipping invalid node definition: ${key}. See debug log for more information.` - ) - console.debug(errorMessage) - } - ) - if (validatedDef !== null) { - objectInfo[key] = validatedDef - } - } - return objectInfo + return await resp.json() } /** diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 7744ae757..19f51c270 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -47,6 +47,7 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore' import { useExecutionStore } from '@/stores/executionStore' import { useExtensionStore } from '@/stores/extensionStore' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' +import { useNodeOutputStore } from '@/stores/imagePreviewStore' import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore' import { useModelStore } from '@/stores/modelStore' import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore' @@ -60,6 +61,10 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy' import { ExtensionManager } from '@/types/extensionTypes' import { ColorAdjustOptions, adjustColor } from '@/utils/colorUtil' import { graphToPrompt } from '@/utils/executionUtil' +import { + getNodeByExecutionId, + triggerCallbackOnAllNodes +} from '@/utils/graphTraversalUtil' import { executeWidgetsCallback, fixLinkInputSlots, @@ -75,6 +80,7 @@ import { deserialiseAndCreate } from '@/utils/vintageClipboard' import { type ComfyApi, PromptExecutionError, api } from './api' import { defaultGraph } from './defaultGraph' import { + getAvifMetadata, getFlacMetadata, getLatentMetadata, getPngMetadata, @@ -110,6 +116,8 @@ type Clipspace = { images?: any[] | null selectedIndex: number img_paste_mode: string + paintedIndex: number + combinedIndex: number } export class ComfyApp { @@ -194,6 +202,8 @@ export class ComfyApp { /** * @deprecated Use useExecutionStore().executingNodeId instead + * TODO: Update to support multiple executing nodes. This getter returns only the first executing node. + * Consider updating consumers to handle multiple nodes or use executingNodeIds array. */ get runningNodeId(): NodeId | null { return useExecutionStore().executingNodeId @@ -349,13 +359,18 @@ export class ComfyApp { selectedIndex = node.imageIndex } + const paintedIndex = selectedIndex + 1 + const combinedIndex = selectedIndex + 2 + ComfyApp.clipspace = { widgets: widgets, imgs: imgs, original_imgs: orig_imgs, images: node.images, selectedIndex: selectedIndex, - img_paste_mode: 'selected' // reset to default im_paste_mode state on copy action + img_paste_mode: 'selected', // reset to default im_paste_mode state on copy action + paintedIndex: paintedIndex, + combinedIndex: combinedIndex } ComfyApp.clipspace_return_node = null @@ -368,6 +383,8 @@ export class ComfyApp { static pasteFromClipspace(node: LGraphNode) { if (ComfyApp.clipspace) { // image paste + const combinedImgSrc = + ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.combinedIndex].src if (ComfyApp.clipspace.imgs && node.imgs) { if (node.images && ComfyApp.clipspace.images) { if (ComfyApp.clipspace['img_paste_mode'] == 'selected') { @@ -401,6 +418,28 @@ export class ComfyApp { } } + // Paste the RGB canvas if paintedindex exists + if ( + ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.paintedIndex] && + node.imgs + ) { + const paintedImg = new Image() + paintedImg.src = + ComfyApp.clipspace.imgs[ComfyApp.clipspace.paintedIndex].src + node.imgs.push(paintedImg) // Add the RGB canvas to the node's images + } + + // Store only combined image inside the node if it exists + if ( + ComfyApp.clipspace.imgs?.[ComfyApp.clipspace.combinedIndex] && + node.imgs && + combinedImgSrc + ) { + const combinedImg = new Image() + combinedImg.src = combinedImgSrc + node.imgs = [combinedImg] + } + if (node.widgets) { if (ComfyApp.clipspace.images) { const clip_image = @@ -635,36 +674,24 @@ export class ComfyApp { api.addEventListener('executing', () => { this.graph.setDirtyCanvas(true, false) - // @ts-expect-error fixme ts strict error - this.revokePreviews(this.runningNodeId) - // @ts-expect-error fixme ts strict error - delete this.nodePreviewImages[this.runningNodeId] }) api.addEventListener('executed', ({ detail }) => { - const output = this.nodeOutputs[detail.display_node || detail.node] - if (detail.merge && output) { - for (const k in detail.output ?? {}) { - const v = output[k] - if (v instanceof Array) { - output[k] = v.concat(detail.output[k]) - } else { - output[k] = detail.output[k] - } - } - } else { - this.nodeOutputs[detail.display_node || detail.node] = detail.output - } - const node = this.graph.getNodeById(detail.display_node || detail.node) - if (node) { - if (node.onExecuted) node.onExecuted(detail.output) + const nodeOutputStore = useNodeOutputStore() + const executionId = String(detail.display_node || detail.node) + + nodeOutputStore.setNodeOutputsByExecutionId(executionId, detail.output, { + merge: detail.merge + }) + + const node = getNodeByExecutionId(this.graph, executionId) + if (node && node.onExecuted) { + node.onExecuted(detail.output) } }) api.addEventListener('execution_start', () => { - this.graph.nodes.forEach((node) => { - if (node.onExecutionStart) node.onExecutionStart() - }) + triggerCallbackOnAllNodes(this.graph, 'onExecutionStart') }) api.addEventListener('execution_error', ({ detail }) => { @@ -689,15 +716,16 @@ export class ComfyApp { this.canvas.draw(true, true) }) - api.addEventListener('b_preview', ({ detail }) => { - const id = this.runningNodeId - if (id == null) return - - const blob = detail - const blobUrl = URL.createObjectURL(blob) + api.addEventListener('b_preview_with_metadata', ({ detail }) => { + // Enhanced preview with explicit node context + const { blob, displayNodeId } = detail + const { setNodePreviewsByExecutionId, revokePreviewsByExecutionId } = + useNodeOutputStore() // Ensure clean up if `executing` event is missed. - this.revokePreviews(id) - this.nodePreviewImages[id] = [blobUrl] + revokePreviewsByExecutionId(displayNodeId) + const blobUrl = URL.createObjectURL(blob) + // Preview cleanup is handled in progress_state event to support multiple concurrent previews + setNodePreviewsByExecutionId(displayNodeId, [blobUrl]) }) api.init() @@ -724,16 +752,12 @@ export class ComfyApp { fixLinkInputSlots(this) // Fire callbacks before the onConfigure, this is used by widget inputs to setup the config - for (const node of graph.nodes) { - node.onGraphConfigured?.() - } + triggerCallbackOnAllNodes(this, 'onGraphConfigured') const r = onConfigure?.apply(this, args) // Fire after onConfigure, used by primitives to generate widget using input nodes config - for (const node of graph.nodes) { - node.onAfterGraphConfigured?.() - } + triggerCallbackOnAllNodes(this, 'onAfterGraphConfigured') return r } @@ -859,26 +883,33 @@ export class ComfyApp { private updateVueAppNodeDefs(defs: Record) { // Frontend only nodes registered by custom nodes. // Example: https://github.com/rgthree/rgthree-comfy/blob/dd534e5384be8cf0c0fa35865afe2126ba75ac55/src_web/comfyui/fast_groups_bypasser.ts#L10 - const rawDefs: Record = Object.fromEntries( - Object.entries(LiteGraph.registered_node_types).map(([name, node]) => [ + + // Only create frontend_only definitions for nodes that don't have backend definitions + const frontendOnlyDefs: Record = {} + for (const [name, node] of Object.entries( + LiteGraph.registered_node_types + )) { + // Skip if we already have a backend definition or system definition + if (name in defs || name in SYSTEM_NODE_DEFS) { + continue + } + + frontendOnlyDefs[name] = { name, - { - name, - display_name: name, - category: node.category || '__frontend_only__', - input: { required: {}, optional: {} }, - output: [], - output_name: [], - output_is_list: [], - output_node: false, - python_module: 'custom_nodes.frontend_only', - description: `Frontend only node for ${name}` - } as ComfyNodeDefV1 - ]) - ) + display_name: name, + category: node.category || '__frontend_only__', + input: { required: {}, optional: {} }, + output: [], + output_name: [], + output_is_list: [], + output_node: false, + python_module: 'custom_nodes.frontend_only', + description: `Frontend only node for ${name}` + } as ComfyNodeDefV1 + } const allNodeDefs = { - ...rawDefs, + ...frontendOnlyDefs, ...defs, ...SYSTEM_NODE_DEFS } @@ -909,12 +940,7 @@ export class ComfyApp { .join('/') }) - return _.mapValues( - await api.getNodeDefs({ - validate: useSettingStore().get('Comfy.Validation.NodeDefs') - }), - (def) => translateNodeDef(def) - ) + return _.mapValues(await api.getNodeDefs(), (def) => translateNodeDef(def)) } /** @@ -1355,6 +1381,16 @@ export class ComfyApp { } else { this.showErrorOnFileLoad(file) } + } else if (file.type === 'image/avif') { + const { workflow, prompt } = await getAvifMetadata(file) + + if (workflow) { + this.loadGraphData(JSON.parse(workflow), true, true, fileName) + } else if (prompt) { + this.loadApiJson(JSON.parse(prompt), fileName) + } else { + this.showErrorOnFileLoad(file) + } } else if (file.type === 'image/webp') { const pngInfo = await getWebpMetadata(file) // Support loading workflows from that webp custom node. @@ -1676,25 +1712,13 @@ export class ComfyApp { } } - /** - * Frees memory allocated to image preview blobs for a specific node, by revoking the URLs associated with them. - * @param nodeId ID of the node to revoke all preview images of - */ - revokePreviews(nodeId: NodeId) { - if (!this.nodePreviewImages[nodeId]?.[Symbol.iterator]) return - for (const url of this.nodePreviewImages[nodeId]) { - URL.revokeObjectURL(url) - } - } /** * Clean current state */ clean() { this.nodeOutputs = {} - for (const id of Object.keys(this.nodePreviewImages)) { - this.revokePreviews(id) - } - this.nodePreviewImages = {} + const { revokeAllPreviews } = useNodeOutputStore() + revokeAllPreviews() const executionStore = useExecutionStore() executionStore.lastNodeErrors = null executionStore.lastExecutionError = null diff --git a/src/scripts/domWidget.ts b/src/scripts/domWidget.ts index 9846234fc..a24b2619d 100644 --- a/src/scripts/domWidget.ts +++ b/src/scripts/domWidget.ts @@ -44,12 +44,30 @@ export interface DOMWidget inputEl?: T } +/** + * Additional props that can be passed to component widgets. + * These are in addition to the standard props that are always provided: + * - modelValue: The widget's value (handled by v-model) + * - widget: Reference to the widget instance + * - onUpdate:modelValue: The update handler for v-model + */ +export type ComponentWidgetCustomProps = Record + +/** + * Standard props that are handled separately by DomWidget.vue and should be + * omitted when defining custom props for component widgets + */ +export type ComponentWidgetStandardProps = + | 'modelValue' + | 'widget' + | 'onUpdate:modelValue' + /** * A DOM widget that wraps a Vue component as a litegraph widget. */ export interface ComponentWidget< V extends object | string, - P = Record + P extends ComponentWidgetCustomProps = ComponentWidgetCustomProps > extends BaseDOMWidget { readonly component: Component readonly inputSpec: InputSpec @@ -158,6 +176,21 @@ abstract class BaseDOMWidgetImpl override onRemove(): void { useDomWidgetStore().unregisterWidget(this.id) } + + override createCopyForNode(node: LGraphNode): this { + // @ts-expect-error + const cloned: this = new (this.constructor as typeof this)({ + node: node, + name: this.name, + type: this.type, + options: this.options + }) + cloned.value = this.value + // Preserve the Y position from the original widget to maintain proper positioning + // when widgets are promoted through subgraph nesting + cloned.y = this.y + return cloned + } } export class DOMWidgetImpl @@ -177,6 +210,22 @@ export class DOMWidgetImpl this.element = obj.element } + override createCopyForNode(node: LGraphNode): this { + // @ts-expect-error + const cloned: this = new (this.constructor as typeof this)({ + node: node, + name: this.name, + type: this.type, + element: this.element, // Include the element! + options: this.options + }) + cloned.value = this.value + // Preserve the Y position from the original widget to maintain proper positioning + // when widgets are promoted through subgraph nesting + cloned.y = this.y + return cloned + } + /** Extract DOM widget size info */ override computeLayoutSize(node: LGraphNode) { if (this.type === 'hidden') { @@ -222,7 +271,7 @@ export class DOMWidgetImpl export class ComponentWidgetImpl< V extends object | string, - P = Record + P extends ComponentWidgetCustomProps = ComponentWidgetCustomProps > extends BaseDOMWidgetImpl implements ComponentWidget diff --git a/src/scripts/metadata/avif.ts b/src/scripts/metadata/avif.ts new file mode 100644 index 000000000..d23adf4bb --- /dev/null +++ b/src/scripts/metadata/avif.ts @@ -0,0 +1,412 @@ +import { + type AvifIinfBox, + type AvifIlocBox, + type AvifInfeBox, + ComfyMetadata, + ComfyMetadataTags, + type IsobmffBoxContentRange +} from '@/types/metadataTypes' + +const readNullTerminatedString = ( + dataView: DataView, + start: number, + end: number +): { str: string; length: number } => { + let length = 0 + while (start + length < end && dataView.getUint8(start + length) !== 0) { + length++ + } + const str = new TextDecoder('utf-8').decode( + new Uint8Array(dataView.buffer, dataView.byteOffset + start, length) + ) + return { str, length: length + 1 } // Include null terminator +} + +const parseInfeBox = (dataView: DataView, start: number): AvifInfeBox => { + const version = dataView.getUint8(start) + const flags = dataView.getUint32(start) & 0xffffff + let offset = start + 4 + + let item_ID: number, item_protection_index: number, item_type: string + + if (version >= 2) { + if (version === 2) { + item_ID = dataView.getUint16(offset) + offset += 2 + } else { + item_ID = dataView.getUint32(offset) + offset += 4 + } + + item_protection_index = dataView.getUint16(offset) + offset += 2 + item_type = String.fromCharCode( + ...new Uint8Array(dataView.buffer, dataView.byteOffset + offset, 4) + ) + offset += 4 + + const { str: item_name, length: name_len } = readNullTerminatedString( + dataView, + offset, + dataView.byteLength + ) + offset += name_len + + const content_type = readNullTerminatedString( + dataView, + offset, + dataView.byteLength + ).str + + return { + box_header: { size: 0, type: 'infe' }, // Size is dynamic + version, + flags, + item_ID, + item_protection_index, + item_type, + item_name, + content_type + } + } + throw new Error(`Unsupported infe box version: ${version}`) +} + +const parseIinfBox = ( + dataView: DataView, + range: IsobmffBoxContentRange +): AvifIinfBox => { + if (!range) throw new Error('iinf box not found') + + const version = dataView.getUint8(range.start) + const flags = dataView.getUint32(range.start) & 0xffffff + let offset = range.start + 4 + + const entry_count = + version === 0 ? dataView.getUint16(offset) : dataView.getUint32(offset) + offset += version === 0 ? 2 : 4 + + const entries: AvifInfeBox[] = [] + for (let i = 0; i < entry_count; i++) { + const boxSize = dataView.getUint32(offset) + const boxType = String.fromCharCode( + ...new Uint8Array(dataView.buffer, dataView.byteOffset + offset + 4, 4) + ) + + if (boxType === 'infe') { + const infe = parseInfeBox(dataView, offset + 8) + infe.box_header.size = boxSize + entries.push(infe) + } + offset += boxSize + } + + return { + box_header: { size: range.end - range.start + 8, type: 'iinf' }, + version, + flags, + entry_count, + entries + } +} + +const parseIlocBox = ( + dataView: DataView, + range: IsobmffBoxContentRange +): AvifIlocBox => { + if (!range) throw new Error('iloc box not found') + + const version = dataView.getUint8(range.start) + const flags = dataView.getUint32(range.start) & 0xffffff + let offset = range.start + 4 + + const sizes = dataView.getUint8(offset++) + const offset_size = (sizes >> 4) & 0x0f + const length_size = sizes & 0x0f + + const base_offset_size = (dataView.getUint8(offset) >> 4) & 0x0f + const index_size = + version === 1 || version === 2 ? dataView.getUint8(offset) & 0x0f : 0 + offset++ + + const item_count = + version < 2 ? dataView.getUint16(offset) : dataView.getUint32(offset) + offset += version < 2 ? 2 : 4 + + const items = [] + for (let i = 0; i < item_count; i++) { + const item_ID = + version < 2 ? dataView.getUint16(offset) : dataView.getUint32(offset) + offset += version < 2 ? 2 : 4 + + if (version === 1 || version === 2) { + offset += 2 // construction_method + } + + const data_reference_index = dataView.getUint16(offset) + offset += 2 + + const base_offset = base_offset_size > 0 ? dataView.getUint32(offset) : 0 // Simplified + offset += base_offset_size + + const extent_count = dataView.getUint16(offset) + offset += 2 + + const extents = [] + for (let j = 0; j < extent_count; j++) { + if ((version === 1 || version === 2) && index_size > 0) { + offset += index_size + } + const extent_offset = dataView.getUint32(offset) // Simplified + offset += offset_size + const extent_length = dataView.getUint32(offset) // Simplified + offset += length_size + extents.push({ extent_offset, extent_length }) + } + items.push({ + item_ID, + data_reference_index, + base_offset, + extent_count, + extents + }) + } + + return { + box_header: { size: range.end - range.start + 8, type: 'iloc' }, + version, + flags, + offset_size, + length_size, + base_offset_size, + index_size, + item_count, + items + } +} + +function findBox( + dataView: DataView, + start: number, + end: number, + type: string +): IsobmffBoxContentRange { + let offset = start + while (offset < end) { + if (offset + 8 > end) break + const boxLength = dataView.getUint32(offset) + const boxType = String.fromCharCode( + ...new Uint8Array(dataView.buffer, dataView.byteOffset + offset + 4, 4) + ) + + if (boxLength === 0) break + + if (boxType === type) { + return { start: offset + 8, end: offset + boxLength } + } + if (offset + boxLength > end) break + offset += boxLength + } + return null +} + +function parseAvifMetadata(buffer: ArrayBuffer): ComfyMetadata { + const metadata: ComfyMetadata = {} + const dataView = new DataView(buffer) + + if ( + dataView.getUint32(4) !== 0x66747970 || + dataView.getUint32(8) !== 0x61766966 + ) { + console.error('Not a valid AVIF file') + return {} + } + + const metaBox = findBox(dataView, 0, dataView.byteLength, 'meta') + if (!metaBox) return {} + + const metaBoxContentStart = metaBox.start + 4 // Skip version and flags + + const iinfBoxRange = findBox( + dataView, + metaBoxContentStart, + metaBox.end, + 'iinf' + ) + const iinf = parseIinfBox(dataView, iinfBoxRange) + + const exifInfe = iinf.entries.find((e) => e.item_type === 'Exif') + if (!exifInfe) return {} + + const ilocBoxRange = findBox( + dataView, + metaBoxContentStart, + metaBox.end, + 'iloc' + ) + const iloc = parseIlocBox(dataView, ilocBoxRange) + + const exifIloc = iloc.items.find((i) => i.item_ID === exifInfe.item_ID) + if (!exifIloc || exifIloc.extents.length === 0) return {} + + const exifExtent = exifIloc.extents[0] + const itemData = new Uint8Array( + buffer, + exifExtent.extent_offset, + exifExtent.extent_length + ) + + let tiffHeaderOffset = -1 + for (let i = 0; i < itemData.length - 4; i++) { + if ( + (itemData[i] === 0x4d && + itemData[i + 1] === 0x4d && + itemData[i + 2] === 0x00 && + itemData[i + 3] === 0x2a) || // MM* + (itemData[i] === 0x49 && + itemData[i + 1] === 0x49 && + itemData[i + 2] === 0x2a && + itemData[i + 3] === 0x00) // II* + ) { + tiffHeaderOffset = i + break + } + } + + if (tiffHeaderOffset !== -1) { + const exifData = itemData.subarray(tiffHeaderOffset) + const data: Record = parseExifData(exifData) + for (const key in data) { + const value = data[key] + if (typeof value === 'string') { + if (key === 'usercomment') { + try { + const metadataJson = JSON.parse(value) + if (metadataJson.prompt) { + metadata[ComfyMetadataTags.PROMPT] = metadataJson.prompt + } + if (metadataJson.workflow) { + metadata[ComfyMetadataTags.WORKFLOW] = metadataJson.workflow + } + } catch (e) { + console.error('Failed to parse usercomment JSON', e) + } + } else { + const [metadataKey, ...metadataValueParts] = value.split(':') + const metadataValue = metadataValueParts.join(':').trim() + if ( + metadataKey.toLowerCase() === + ComfyMetadataTags.PROMPT.toLowerCase() || + metadataKey.toLowerCase() === + ComfyMetadataTags.WORKFLOW.toLowerCase() + ) { + try { + const jsonValue = JSON.parse(metadataValue) + metadata[metadataKey.toLowerCase() as keyof ComfyMetadata] = + jsonValue + } catch (e) { + console.error(`Failed to parse JSON for ${metadataKey}`, e) + } + } + } + } + } + } else { + console.log('Warning: TIFF header not found in EXIF data.') + } + + return metadata +} + +// @ts-expect-error fixme ts strict error +export function parseExifData(exifData) { + // Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian) + const isLittleEndian = String.fromCharCode(...exifData.slice(0, 2)) === 'II' + + // Function to read 16-bit and 32-bit integers from binary data + // @ts-expect-error fixme ts strict error + function readInt(offset, isLittleEndian, length) { + let arr = exifData.slice(offset, offset + length) + if (length === 2) { + return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16( + 0, + isLittleEndian + ) + } else if (length === 4) { + return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint32( + 0, + isLittleEndian + ) + } + } + + // Read the offset to the first IFD (Image File Directory) + const ifdOffset = readInt(4, isLittleEndian, 4) + + // @ts-expect-error fixme ts strict error + function parseIFD(offset) { + const numEntries = readInt(offset, isLittleEndian, 2) + const result = {} + + // @ts-expect-error fixme ts strict error + for (let i = 0; i < numEntries; i++) { + const entryOffset = offset + 2 + i * 12 + const tag = readInt(entryOffset, isLittleEndian, 2) + const type = readInt(entryOffset + 2, isLittleEndian, 2) + const numValues = readInt(entryOffset + 4, isLittleEndian, 4) + const valueOffset = readInt(entryOffset + 8, isLittleEndian, 4) + + // Read the value(s) based on the data type + let value + if (type === 2) { + // ASCII string + value = new TextDecoder('utf-8').decode( + // @ts-expect-error fixme ts strict error + exifData.subarray(valueOffset, valueOffset + numValues - 1) + ) + } + + // @ts-expect-error fixme ts strict error + result[tag] = value + } + + return result + } + + // Parse the first IFD + const ifdData = parseIFD(ifdOffset) + return ifdData +} + +export function getFromAvifFile(file: File): Promise> { + return new Promise>((resolve) => { + const reader = new FileReader() + reader.onload = (event) => { + const buffer = event.target?.result as ArrayBuffer + if (!buffer) { + resolve({}) + return + } + + try { + const comfyMetadata = parseAvifMetadata(buffer) + const result: Record = {} + if (comfyMetadata.prompt) { + result.prompt = JSON.stringify(comfyMetadata.prompt) + } + if (comfyMetadata.workflow) { + result.workflow = JSON.stringify(comfyMetadata.workflow) + } + resolve(result) + } catch (e) { + console.error('Parser: Error parsing AVIF metadata:', e) + resolve({}) + } + } + reader.onerror = (err) => { + console.error('FileReader: Error reading AVIF file:', err) + resolve({}) + } + reader.readAsArrayBuffer(file) + }) +} diff --git a/src/scripts/pnginfo.ts b/src/scripts/pnginfo.ts index 7c7f6f597..689537f7e 100644 --- a/src/scripts/pnginfo.ts +++ b/src/scripts/pnginfo.ts @@ -1,6 +1,7 @@ import { LiteGraph } from '@comfyorg/litegraph' import { api } from './api' +import { getFromAvifFile } from './metadata/avif' import { getFromFlacFile } from './metadata/flac' import { getFromPngFile } from './metadata/png' @@ -13,6 +14,10 @@ export function getFlacMetadata(file: File): Promise> { return getFromFlacFile(file) } +export function getAvifMetadata(file: File): Promise> { + return getFromAvifFile(file) +} + // @ts-expect-error fixme ts strict error function parseExifData(exifData) { // Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian) diff --git a/src/scripts/utils.ts b/src/scripts/utils.ts index 9cb5a2113..778b712d9 100644 --- a/src/scripts/utils.ts +++ b/src/scripts/utils.ts @@ -21,7 +21,7 @@ export function clone(obj: T): T { * There are external callers to this function, so we need to keep it for now */ export function applyTextReplacements(app: ComfyApp, value: string): string { - return _applyTextReplacements(app.graph.nodes, value) + return _applyTextReplacements(app.graph, value) } export async function addStylesheet( diff --git a/src/services/audioService.ts b/src/services/audioService.ts new file mode 100644 index 000000000..7621e3037 --- /dev/null +++ b/src/services/audioService.ts @@ -0,0 +1,84 @@ +import { register } from 'extendable-media-recorder' +import { connect } from 'extendable-media-recorder-wav-encoder' + +import { api } from '@/scripts/api' +import { useToastStore } from '@/stores/toastStore' + +export interface AudioRecordingError { + type: 'permission' | 'not_supported' | 'encoder' | 'recording' | 'unknown' + message: string + originalError?: unknown +} + +let isEncoderRegistered: boolean = false + +export const useAudioService = () => { + const handleError = ( + type: AudioRecordingError['type'], + message: string, + originalError?: unknown + ) => { + console.error(`Audio Service Error (${type}):`, message, originalError) + } + + const stopAllTracks = (currentStream: MediaStream | null) => { + if (currentStream) { + currentStream.getTracks().forEach((track) => { + track.stop() + }) + currentStream = null + } + } + + const registerWavEncoder = async (): Promise => { + if (isEncoderRegistered) { + return + } + + try { + await register(await connect()) + isEncoderRegistered = true + } catch (err) { + if ( + err instanceof Error && + err.message.includes('already an encoder stored') + ) { + isEncoderRegistered = true + } else { + handleError('encoder', 'Failed to register WAV encoder', err) + } + } + } + + const convertBlobToFileAndSubmit = async (blob: Blob): Promise => { + const name = `recording-${Date.now()}.wav` + const file = new File([blob], name, { type: blob.type || 'audio/wav' }) + + const body = new FormData() + body.append('image', file) + body.append('subfolder', 'audio') + body.append('type', 'temp') + + const resp = await api.fetchApi('/upload/image', { + method: 'POST', + body + }) + + if (resp.status !== 200) { + const err = `Error uploading temp file: ${resp.status} - ${resp.statusText}` + useToastStore().addAlert(err) + throw new Error(err) + } + + const tempAudio = await resp.json() + + return `audio/${tempAudio.name} [temp]` + } + + return { + // Methods + convertBlobToFileAndSubmit, + registerWavEncoder, + stopAllTracks + } +} diff --git a/src/services/dialogService.ts b/src/services/dialogService.ts index 171629c82..6510ae7ec 100644 --- a/src/services/dialogService.ts +++ b/src/services/dialogService.ts @@ -279,7 +279,7 @@ export const useDialogService = () => { onSuccess: () => resolve(true) }, dialogComponentProps: { - closable: false, + closable: true, onClose: () => resolve(false) } }) diff --git a/src/services/litegraphService.ts b/src/services/litegraphService.ts index afd31d83b..4a53ed448 100644 --- a/src/services/litegraphService.ts +++ b/src/services/litegraphService.ts @@ -32,7 +32,10 @@ import type { } from '@/schemas/nodeDef/nodeDefSchemaV2' import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema' import { ComfyApp, app } from '@/scripts/app' +import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget' import { $el } from '@/scripts/ui' +import { useDomWidgetStore } from '@/stores/domWidgetStore' +import { useExecutionStore } from '@/stores/executionStore' import { useCanvasStore } from '@/stores/graphStore' import { useNodeOutputStore } from '@/stores/imagePreviewStore' import { ComfyNodeDefImpl } from '@/stores/nodeDefStore' @@ -87,6 +90,37 @@ export const useLitegraphService = () => { constructor() { super(app.graph, subgraph, instanceData) + // Set up event listener for promoted widget registration + subgraph.events.addEventListener('widget-promoted', (event) => { + const { widget } = event.detail + // Only handle DOM widgets + if (!isDOMWidget(widget) && !isComponentWidget(widget)) return + + const domWidgetStore = useDomWidgetStore() + if (!domWidgetStore.widgetStates.has(widget.id)) { + domWidgetStore.registerWidget(widget) + // Set initial visibility based on whether the widget's node is in the current graph + const widgetState = domWidgetStore.widgetStates.get(widget.id) + if (widgetState) { + const currentGraph = canvasStore.getCanvas().graph + widgetState.visible = + currentGraph?.nodes.includes(widget.node) ?? false + } + } + }) + + // Set up event listener for promoted widget removal + subgraph.events.addEventListener('widget-demoted', (event) => { + const { widget } = event.detail + // Only handle DOM widgets + if (!isDOMWidget(widget) && !isComponentWidget(widget)) return + + const domWidgetStore = useDomWidgetStore() + if (domWidgetStore.widgetStates.has(widget.id)) { + domWidgetStore.unregisterWidget(widget.id) + } + }) + this.#setupStrokeStyles() this.#addInputs(ComfyNode.nodeData.inputs) this.#addOutputs(ComfyNode.nodeData.outputs) @@ -95,9 +129,15 @@ export const useLitegraphService = () => { void extensionService.invokeExtensionsAsync('nodeCreated', this) this.badges.push( new LGraphBadge({ - text: 'โ‡Œ', - fgColor: '#dad0de', - bgColor: '#b3b' + text: '', + iconOptions: { + unicode: '\ue96e', + fontFamily: 'PrimeIcons', + color: '#ffffff', + fontSize: 12 + }, + fgColor: '#ffffff', + bgColor: '#3b82f6' }) ) } @@ -107,7 +147,11 @@ export const useLitegraphService = () => { */ #setupStrokeStyles() { this.strokeStyles['running'] = function (this: LGraphNode) { - if (this.id == app.runningNodeId) { + const nodeId = String(this.id) + const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId) + const state = + useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state + if (state === 'running') { return { color: '#0f0' } } } @@ -362,7 +406,11 @@ export const useLitegraphService = () => { */ #setupStrokeStyles() { this.strokeStyles['running'] = function (this: LGraphNode) { - if (this.id == app.runningNodeId) { + const nodeId = String(this.id) + const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId) + const state = + useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state + if (state === 'running') { return { color: '#0f0' } } } @@ -745,7 +793,7 @@ export const useLitegraphService = () => { if (isImageNode(this)) { options.push({ - content: 'Open in MaskEditor', + content: 'Open in MaskEditor | Image Canvas', callback: () => { ComfyApp.copyToClipspace(this) // @ts-expect-error fixme ts strict error diff --git a/src/stores/README.md b/src/stores/README.md index 3a61d7ae6..de45fdc71 100644 --- a/src/stores/README.md +++ b/src/stores/README.md @@ -135,6 +135,7 @@ The following table lists ALL stores in the system as of 2025-01-30: | toastStore.ts | Manages toast notifications | UI | | userFileStore.ts | Manages user file operations | Files | | userStore.ts | Manages user data and preferences | User | +| versionCompatibilityStore.ts | Manages frontend/backend version compatibility warnings | Core | | widgetStore.ts | Manages widget configurations | Widgets | | workflowStore.ts | Handles workflow data and operations | Workflows | | workflowTemplatesStore.ts | Manages workflow templates | Workflows | diff --git a/src/stores/dialogStore.ts b/src/stores/dialogStore.ts index 67a4805b7..1ad8f3f24 100644 --- a/src/stores/dialogStore.ts +++ b/src/stores/dialogStore.ts @@ -26,6 +26,8 @@ interface CustomDialogComponentProps { modal?: boolean position?: DialogPosition pt?: DialogPassThroughOptions + closeOnEscape?: boolean + dismissableMask?: boolean } export type DialogComponentProps = InstanceType['$props'] & @@ -64,6 +66,12 @@ export interface ShowDialogOptions { export const useDialogStore = defineStore('dialog', () => { const dialogStack = ref([]) + /** + * The key of the currently active (top-most) dialog. + * Only the active dialog can be closed with the ESC key. + */ + const activeKey = ref(null) + const genDialogKey = () => `dialog-${Math.random().toString(36).slice(2, 9)}` /** @@ -89,17 +97,27 @@ export const useDialogStore = defineStore('dialog', () => { if (index !== -1) { const [dialog] = dialogStack.value.splice(index, 1) insertDialogByPriority(dialog) + activeKey.value = dialogKey + updateCloseOnEscapeStates() } } function closeDialog(options?: { key: string }) { const targetDialog = options ? dialogStack.value.find((d) => d.key === options.key) - : dialogStack.value[0] + : dialogStack.value.find((d) => d.key === activeKey.value) if (!targetDialog) return targetDialog.dialogComponentProps?.onClose?.() - dialogStack.value.splice(dialogStack.value.indexOf(targetDialog), 1) + const index = dialogStack.value.indexOf(targetDialog) + dialogStack.value.splice(index, 1) + + activeKey.value = + dialogStack.value.length > 0 + ? dialogStack.value[dialogStack.value.length - 1].key + : null + + updateCloseOnEscapeStates() } function createDialog(options: { @@ -117,7 +135,7 @@ export const useDialogStore = defineStore('dialog', () => { dialogStack.value.shift() } - const dialog: DialogInstance = { + const dialog = { key: options.key, visible: true, title: options.title, @@ -139,7 +157,6 @@ export const useDialogStore = defineStore('dialog', () => { dismissableMask: true, ...options.dialogComponentProps, maximized: false, - // @ts-expect-error TODO: fix this onMaximize: () => { dialog.dialogComponentProps.maximized = true }, @@ -160,10 +177,29 @@ export const useDialogStore = defineStore('dialog', () => { } insertDialogByPriority(dialog) + activeKey.value = options.key + updateCloseOnEscapeStates() return dialog } + /** + * Ensures only the top-most dialog in the stack can be closed with the Escape key. + * This is necessary because PrimeVue Dialogs do not handle `closeOnEscape` prop + * correctly when multiple dialogs are open. + */ + function updateCloseOnEscapeStates() { + const topDialog = dialogStack.value.find((d) => d.key === activeKey.value) + const topClosable = topDialog?.dialogComponentProps.closable + + dialogStack.value.forEach((dialog) => { + dialog.dialogComponentProps = { + ...dialog.dialogComponentProps, + closeOnEscape: dialog === topDialog && !!topClosable + } + }) + } + function showDialog(options: ShowDialogOptions) { const dialogKey = options.key || genDialogKey() @@ -210,6 +246,7 @@ export const useDialogStore = defineStore('dialog', () => { showDialog, closeDialog, showExtensionDialog, - isDialogOpen + isDialogOpen, + activeKey } }) diff --git a/src/stores/executionStore.ts b/src/stores/executionStore.ts index a8667da0d..bc33b75cd 100644 --- a/src/stores/executionStore.ts +++ b/src/stores/executionStore.ts @@ -12,6 +12,8 @@ import type { ExecutionErrorWsMessage, ExecutionStartWsMessage, NodeError, + NodeProgressState, + ProgressStateWsMessage, ProgressTextWsMessage, ProgressWsMessage } from '@/schemas/apiSchema' @@ -21,6 +23,10 @@ import type { NodeId } from '@/schemas/comfyWorkflowSchema' import { api } from '@/scripts/api' +import { app } from '@/scripts/app' +import { useNodeOutputStore } from '@/stores/imagePreviewStore' +import type { NodeLocatorId } from '@/types/nodeIdentification' +import { createNodeLocatorId } from '@/types/nodeIdentification' import { useCanvasStore } from './graphStore' import { ComfyWorkflow, useWorkflowStore } from './workflowStore' @@ -46,7 +52,97 @@ export const useExecutionStore = defineStore('execution', () => { const queuedPrompts = ref>({}) const lastNodeErrors = ref | null>(null) const lastExecutionError = ref(null) - const executingNodeId = ref(null) + // This is the progress of all nodes in the currently executing workflow + const nodeProgressStates = ref>({}) + + /** + * Convert execution context node IDs to NodeLocatorIds + * @param nodeId The node ID from execution context (could be execution ID) + * @returns The NodeLocatorId + */ + const executionIdToNodeLocatorId = ( + nodeId: string | number + ): NodeLocatorId => { + const nodeIdStr = String(nodeId) + + if (!nodeIdStr.includes(':')) { + // It's a top-level node ID + return nodeIdStr + } + + // It's an execution node ID + const parts = nodeIdStr.split(':') + const localNodeId = parts[parts.length - 1] + const subgraphs = getSubgraphsFromInstanceIds(app.graph, parts) + const nodeLocatorId = createNodeLocatorId(subgraphs.at(-1)!.id, localNodeId) + return nodeLocatorId + } + + const mergeExecutionProgressStates = ( + currentState: NodeProgressState | undefined, + newState: NodeProgressState + ): NodeProgressState => { + if (currentState === undefined) { + return newState + } + + const mergedState = { ...currentState } + if (mergedState.state === 'error') { + return mergedState + } else if (newState.state === 'running') { + const newPerc = newState.max > 0 ? newState.value / newState.max : 0.0 + const oldPerc = + mergedState.max > 0 ? mergedState.value / mergedState.max : 0.0 + if ( + mergedState.state !== 'running' || + oldPerc === 0.0 || + newPerc < oldPerc + ) { + mergedState.value = newState.value + mergedState.max = newState.max + } + mergedState.state = 'running' + } + + return mergedState + } + + const nodeLocationProgressStates = computed< + Record + >(() => { + const result: Record = {} + + const states = nodeProgressStates.value // Apparently doing this inside `Object.entries` causes issues + for (const state of Object.values(states)) { + const parts = String(state.display_node_id).split(':') + for (let i = 0; i < parts.length; i++) { + const executionId = parts.slice(0, i + 1).join(':') + const locatorId = executionIdToNodeLocatorId(executionId) + if (!locatorId) continue + + result[locatorId] = mergeExecutionProgressStates( + result[locatorId], + state + ) + } + } + + return result + }) + + // Easily access all currently executing node IDs + const executingNodeIds = computed(() => { + return Object.entries(nodeProgressStates) + .filter(([_, state]) => state.state === 'running') + .map(([nodeId, _]) => nodeId) + }) + + // @deprecated For backward compatibility - stores the primary executing node ID + const executingNodeId = computed(() => { + return executingNodeIds.value.length > 0 ? executingNodeIds.value[0] : null + }) + + // For backward compatibility - returns the primary executing node const executingNode = computed(() => { if (!executingNodeId.value) return null @@ -93,30 +189,7 @@ export const useExecutionStore = defineStore('execution', () => { return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs) } - const executionIdToCurrentId = (id: string) => { - const subgraph = workflowStore.activeSubgraph - - // Short-circuit: ID belongs to the parent workflow / no active subgraph - if (!id.includes(':')) { - return !subgraph ? id : undefined - } else if (!subgraph) { - return - } - - // Parse the hierarchical ID (e.g., "123:456:789") - const subgraphNodeIds = id.split(':') - - // If the last subgraph is the active subgraph, return the node ID - const subgraphs = getSubgraphsFromInstanceIds( - subgraph.rootGraph, - subgraphNodeIds - ) - if (subgraphs.at(-1) === subgraph) { - return subgraphNodeIds.at(-1) - } - } - - // This is the progress of the currently executing node, if any + // This is the progress of the currently executing node (for backward compatibility) const _executingNodeProgress = ref(null) const executingNodeProgress = computed(() => _executingNodeProgress.value @@ -150,24 +223,29 @@ export const useExecutionStore = defineStore('execution', () => { function bindExecutionEvents() { api.addEventListener('execution_start', handleExecutionStart) api.addEventListener('execution_cached', handleExecutionCached) + api.addEventListener('execution_interrupted', handleExecutionInterrupted) api.addEventListener('executed', handleExecuted) api.addEventListener('executing', handleExecuting) api.addEventListener('progress', handleProgress) + api.addEventListener('progress_state', handleProgressState) api.addEventListener('status', handleStatus) api.addEventListener('execution_error', handleExecutionError) + api.addEventListener('progress_text', handleProgressText) + api.addEventListener('display_component', handleDisplayComponent) } - api.addEventListener('progress_text', handleProgressText) - api.addEventListener('display_component', handleDisplayComponent) function unbindExecutionEvents() { api.removeEventListener('execution_start', handleExecutionStart) api.removeEventListener('execution_cached', handleExecutionCached) + api.removeEventListener('execution_interrupted', handleExecutionInterrupted) api.removeEventListener('executed', handleExecuted) api.removeEventListener('executing', handleExecuting) api.removeEventListener('progress', handleProgress) + api.removeEventListener('progress_state', handleProgressState) api.removeEventListener('status', handleStatus) api.removeEventListener('execution_error', handleExecutionError) api.removeEventListener('progress_text', handleProgressText) + api.removeEventListener('display_component', handleDisplayComponent) } function handleExecutionStart(e: CustomEvent) { @@ -183,6 +261,10 @@ export const useExecutionStore = defineStore('execution', () => { } } + function handleExecutionInterrupted() { + nodeProgressStates.value = {} + } + function handleExecuted(e: CustomEvent) { if (!activePrompt.value) return activePrompt.value.nodes[e.detail.node] = true @@ -194,19 +276,42 @@ export const useExecutionStore = defineStore('execution', () => { if (!activePrompt.value) return - if (executingNodeId.value && activePrompt.value) { - // Seems sometimes nodes that are cached fire executing but not executed - activePrompt.value.nodes[executingNodeId.value] = true + // Update the executing nodes list + if (typeof e.detail !== 'string') { + if (activePromptId.value) { + delete queuedPrompts.value[activePromptId.value] + } + activePromptId.value = null } - if (typeof e.detail === 'string') { - executingNodeId.value = executionIdToCurrentId(e.detail) ?? null - } else { - executingNodeId.value = e.detail - if (executingNodeId.value === null) { - if (activePromptId.value) { - delete queuedPrompts.value[activePromptId.value] - } - activePromptId.value = null + } + + function handleProgressState(e: CustomEvent) { + const { nodes } = e.detail + + // Revoke previews for nodes that are starting to execute + for (const nodeId in nodes) { + const nodeState = nodes[nodeId] + if (nodeState.state === 'running' && !nodeProgressStates.value[nodeId]) { + // This node just started executing, revoke its previews + // Note that we're doing the *actual* node id instead of the display node id + // here intentionally. That way, we don't clear the preview every time a new node + // within an expanded graph starts executing. + const { revokePreviewsByExecutionId } = useNodeOutputStore() + revokePreviewsByExecutionId(nodeId) + } + } + + // Update the progress states for all nodes + nodeProgressStates.value = nodes + + // If we have progress for the currently executing node, update it for backwards compatibility + if (executingNodeId.value && nodes[executingNodeId.value]) { + const nodeState = nodes[executingNodeId.value] + _executingNodeProgress.value = { + value: nodeState.value, + max: nodeState.max, + prompt_id: nodeState.prompt_id, + node: nodeState.display_node_id || nodeState.node_id } } } @@ -239,7 +344,7 @@ export const useExecutionStore = defineStore('execution', () => { const { nodeId, text } = e.detail if (!text || !nodeId) return - // Handle hierarchical node IDs for subgraphs + // Handle execution node IDs for subgraphs const currentId = getNodeIdIfExecuting(nodeId) const node = canvasStore.getCanvas().graph?.getNodeById(currentId) if (!node) return @@ -250,7 +355,7 @@ export const useExecutionStore = defineStore('execution', () => { function handleDisplayComponent(e: CustomEvent) { const { node_id: nodeId, component, props = {} } = e.detail - // Handle hierarchical node IDs for subgraphs + // Handle execution node IDs for subgraphs const currentId = getNodeIdIfExecuting(nodeId) const node = canvasStore.getCanvas().graph?.getNodeById(currentId) if (!node) return @@ -290,6 +395,18 @@ export const useExecutionStore = defineStore('execution', () => { ) } + /** + * Convert a NodeLocatorId to an execution context ID + * @param locatorId The NodeLocatorId + * @returns The execution ID or null if conversion fails + */ + const nodeLocatorIdToExecutionId = ( + locatorId: NodeLocatorId | string + ): string | null => { + const executionId = workflowStore.nodeLocatorIdToNodeExecutionId(locatorId) + return executionId + } + return { isIdle, clientId, @@ -310,9 +427,13 @@ export const useExecutionStore = defineStore('execution', () => { */ lastExecutionError, /** - * The id of the node that is currently being executed + * The id of the node that is currently being executed (backward compatibility) */ executingNodeId, + /** + * The list of all nodes that are currently executing + */ + executingNodeIds, /** * The prompt that is currently being executed */ @@ -330,17 +451,25 @@ export const useExecutionStore = defineStore('execution', () => { */ executionProgress, /** - * The node that is currently being executed + * The node that is currently being executed (backward compatibility) */ executingNode, /** - * The progress of the executing node (if the node reports progress) + * The progress of the executing node (backward compatibility) */ executingNodeProgress, + /** + * All node progress states from progress_state events + */ + nodeProgressStates, + nodeLocationProgressStates, bindExecutionEvents, unbindExecutionEvents, storePrompt, // Raw executing progress data for backward compatibility in ComfyApp. - _executingNodeProgress + _executingNodeProgress, + // NodeLocatorId conversion helpers + executionIdToNodeLocatorId, + nodeLocatorIdToExecutionId } }) diff --git a/src/stores/imagePreviewStore.ts b/src/stores/imagePreviewStore.ts index b6d88c9bb..b2c19b8f9 100644 --- a/src/stores/imagePreviewStore.ts +++ b/src/stores/imagePreviewStore.ts @@ -8,6 +8,9 @@ import { } from '@/schemas/apiSchema' import { api } from '@/scripts/api' import { app } from '@/scripts/app' +import { useExecutionStore } from '@/stores/executionStore' +import { useWorkflowStore } from '@/stores/workflowStore' +import type { NodeLocatorId } from '@/types/nodeIdentification' import { parseFilePath } from '@/utils/formatUtil' import { isVideoNode } from '@/utils/litegraphUtil' @@ -22,17 +25,22 @@ const createOutputs = ( } } +interface SetOutputOptions { + merge?: boolean +} + export const useNodeOutputStore = defineStore('nodeOutput', () => { - const getNodeId = (node: LGraphNode): string => node.id.toString() + const { nodeIdToNodeLocatorId } = useWorkflowStore() + const { executionIdToNodeLocatorId } = useExecutionStore() function getNodeOutputs( node: LGraphNode ): ExecutedWsMessage['output'] | undefined { - return app.nodeOutputs[getNodeId(node)] + return app.nodeOutputs[nodeIdToNodeLocatorId(node.id)] } function getNodePreviews(node: LGraphNode): string[] | undefined { - return app.nodePreviewImages[getNodeId(node)] + return app.nodePreviewImages[nodeIdToNodeLocatorId(node.id)] } /** @@ -86,6 +94,35 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { }) } + /** + * Internal function to set outputs by NodeLocatorId. + * Handles the merge logic when needed. + */ + function setOutputsByLocatorId( + nodeLocatorId: NodeLocatorId, + outputs: ExecutedWsMessage['output'] | ResultItem, + options: SetOutputOptions = {} + ) { + if (options.merge) { + const existingOutput = app.nodeOutputs[nodeLocatorId] + if (existingOutput && outputs) { + for (const k in outputs) { + const existingValue = existingOutput[k] + const newValue = (outputs as Record)[k] + + if (Array.isArray(existingValue) && Array.isArray(newValue)) { + existingOutput[k] = existingValue.concat(newValue) + } else { + existingOutput[k] = newValue + } + } + return + } + } + + app.nodeOutputs[nodeLocatorId] = outputs + } + function setNodeOutputs( node: LGraphNode, filenames: string | string[] | ResultItem, @@ -96,24 +133,149 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { ) { if (!filenames || !node) return - const nodeId = getNodeId(node) - if (typeof filenames === 'string') { - app.nodeOutputs[nodeId] = createOutputs([filenames], folder, isAnimated) + setNodeOutputsByNodeId( + node.id, + createOutputs([filenames], folder, isAnimated) + ) } else if (!Array.isArray(filenames)) { - app.nodeOutputs[nodeId] = filenames + setNodeOutputsByNodeId(node.id, filenames) } else { const resultItems = createOutputs(filenames, folder, isAnimated) if (!resultItems?.images?.length) return - app.nodeOutputs[nodeId] = resultItems + setNodeOutputsByNodeId(node.id, resultItems) } } + /** + * Set node outputs by execution ID (hierarchical ID from backend). + * Converts the execution ID to a NodeLocatorId before storing. + * + * @param executionId - The execution ID (e.g., "123:456:789" or "789") + * @param outputs - The outputs to store + * @param options - Options for setting outputs + * @param options.merge - If true, merge with existing outputs (arrays are concatenated) + */ + function setNodeOutputsByExecutionId( + executionId: string, + outputs: ExecutedWsMessage['output'] | ResultItem, + options: SetOutputOptions = {} + ) { + const nodeLocatorId = executionIdToNodeLocatorId(executionId) + if (!nodeLocatorId) return + + setOutputsByLocatorId(nodeLocatorId, outputs, options) + } + + /** + * Set node outputs by node ID. + * Uses the current graph context to create the appropriate NodeLocatorId. + * + * @param nodeId - The node ID + * @param outputs - The outputs to store + * @param options - Options for setting outputs + * @param options.merge - If true, merge with existing outputs (arrays are concatenated) + */ + function setNodeOutputsByNodeId( + nodeId: string | number, + outputs: ExecutedWsMessage['output'] | ResultItem, + options: SetOutputOptions = {} + ) { + const nodeLocatorId = nodeIdToNodeLocatorId(nodeId) + if (!nodeLocatorId) return + + setOutputsByLocatorId(nodeLocatorId, outputs, options) + } + + /** + * Set node preview images by execution ID (hierarchical ID from backend). + * Converts the execution ID to a NodeLocatorId before storing. + * + * @param executionId - The execution ID (e.g., "123:456:789" or "789") + * @param previewImages - Array of preview image URLs to store + */ + function setNodePreviewsByExecutionId( + executionId: string, + previewImages: string[] + ) { + const nodeLocatorId = executionIdToNodeLocatorId(executionId) + if (!nodeLocatorId) return + + app.nodePreviewImages[nodeLocatorId] = previewImages + } + + /** + * Set node preview images by node ID. + * Uses the current graph context to create the appropriate NodeLocatorId. + * + * @param nodeId - The node ID + * @param previewImages - Array of preview image URLs to store + */ + function setNodePreviewsByNodeId( + nodeId: string | number, + previewImages: string[] + ) { + const nodeLocatorId = nodeIdToNodeLocatorId(nodeId) + app.nodePreviewImages[nodeLocatorId] = previewImages + } + + /** + * Revoke preview images by execution ID. + * Frees memory allocated to image preview blobs by revoking the URLs. + * + * @param executionId - The execution ID + */ + function revokePreviewsByExecutionId(executionId: string) { + const nodeLocatorId = executionIdToNodeLocatorId(executionId) + if (!nodeLocatorId) return + + revokePreviewsByLocatorId(nodeLocatorId) + } + + /** + * Revoke preview images by node locator ID. + * Frees memory allocated to image preview blobs by revoking the URLs. + * + * @param nodeLocatorId - The node locator ID + */ + function revokePreviewsByLocatorId(nodeLocatorId: NodeLocatorId) { + const previews = app.nodePreviewImages[nodeLocatorId] + if (!previews?.[Symbol.iterator]) return + + for (const url of previews) { + URL.revokeObjectURL(url) + } + + delete app.nodePreviewImages[nodeLocatorId] + } + + /** + * Revoke all preview images. + * Frees memory allocated to all image preview blobs. + */ + function revokeAllPreviews() { + for (const nodeLocatorId of Object.keys(app.nodePreviewImages)) { + const previews = app.nodePreviewImages[nodeLocatorId] + if (!previews?.[Symbol.iterator]) continue + + for (const url of previews) { + URL.revokeObjectURL(url) + } + } + app.nodePreviewImages = {} + } + return { getNodeOutputs, getNodeImageUrls, getNodePreviews, setNodeOutputs, + setNodeOutputsByExecutionId, + setNodeOutputsByNodeId, + setNodePreviewsByExecutionId, + setNodePreviewsByNodeId, + revokePreviewsByExecutionId, + revokeAllPreviews, getPreviewParam } }) diff --git a/src/stores/menuItemStore.ts b/src/stores/menuItemStore.ts index 432976ec9..a1502e14a 100644 --- a/src/stores/menuItemStore.ts +++ b/src/stores/menuItemStore.ts @@ -23,6 +23,7 @@ export const useMenuItemStore = defineStore('menuItem', () => { // Create a new node if it doesn't exist found = { label: segment, + key: segment, items: [] } currentLevel.push(found) diff --git a/src/stores/nodeDefStore.ts b/src/stores/nodeDefStore.ts index aaa6d000b..8a5a46ade 100644 --- a/src/stores/nodeDefStore.ts +++ b/src/stores/nodeDefStore.ts @@ -251,11 +251,41 @@ export function createDummyFolderNodeDef(folderPath: string): ComfyNodeDefImpl { } as ComfyNodeDefV1) } +/** + * Defines a filter for node definitions in the node library. + * Filters are applied in a single pass to determine node visibility. + */ +export interface NodeDefFilter { + /** + * Unique identifier for the filter. + * Convention: Use dot notation like 'core.deprecated' or 'extension.myfilter' + */ + id: string + + /** + * Display name for the filter (used in UI/debugging). + */ + name: string + + /** + * Optional description explaining what the filter does. + */ + description?: string + + /** + * The filter function that returns true if the node should be visible. + * @param nodeDef - The node definition to evaluate + * @returns true if the node should be visible, false to hide it + */ + predicate: (nodeDef: ComfyNodeDefImpl) => boolean +} + export const useNodeDefStore = defineStore('nodeDef', () => { const nodeDefsByName = ref>({}) const nodeDefsByDisplayName = ref>({}) const showDeprecated = ref(false) const showExperimental = ref(false) + const nodeDefFilters = ref([]) const nodeDefs = computed(() => Object.values(nodeDefsByName.value)) const nodeDataTypes = computed(() => { @@ -270,13 +300,11 @@ export const useNodeDefStore = defineStore('nodeDef', () => { } return types }) - const visibleNodeDefs = computed(() => - nodeDefs.value.filter( - (nodeDef: ComfyNodeDefImpl) => - (showDeprecated.value || !nodeDef.deprecated) && - (showExperimental.value || !nodeDef.experimental) + const visibleNodeDefs = computed(() => { + return nodeDefs.value.filter((nodeDef) => + nodeDefFilters.value.every((filter) => filter.predicate(nodeDef)) ) - ) + }) const nodeSearchService = computed( () => new NodeSearchService(visibleNodeDefs.value) ) @@ -306,15 +334,74 @@ export const useNodeDefStore = defineStore('nodeDef', () => { } function fromLGraphNode(node: LGraphNode): ComfyNodeDefImpl | null { // Frontend-only nodes don't have nodeDef - // @ts-expect-error Optional chaining used in index - return nodeDefsByName.value[node.constructor?.nodeData?.name] ?? null + const nodeTypeName = node.constructor?.nodeData?.name + if (!nodeTypeName) return null + const nodeDef = nodeDefsByName.value[nodeTypeName] ?? null + return nodeDef } + /** + * Registers a node definition filter. + * @param filter - The filter to register + */ + function registerNodeDefFilter(filter: NodeDefFilter) { + nodeDefFilters.value = [...nodeDefFilters.value, filter] + } + + /** + * Unregisters a node definition filter by ID. + * @param id - The ID of the filter to remove + */ + function unregisterNodeDefFilter(id: string) { + nodeDefFilters.value = nodeDefFilters.value.filter((f) => f.id !== id) + } + + /** + * Register the core node definition filters. + */ + function registerCoreNodeDefFilters() { + // Deprecated nodes filter + registerNodeDefFilter({ + id: 'core.deprecated', + name: 'Hide Deprecated Nodes', + description: 'Hides nodes marked as deprecated unless explicitly enabled', + predicate: (nodeDef) => showDeprecated.value || !nodeDef.deprecated + }) + + // Experimental nodes filter + registerNodeDefFilter({ + id: 'core.experimental', + name: 'Hide Experimental Nodes', + description: + 'Hides nodes marked as experimental unless explicitly enabled', + predicate: (nodeDef) => showExperimental.value || !nodeDef.experimental + }) + + // Subgraph nodes filter + // @todo Remove this filter when subgraph v2 is released + registerNodeDefFilter({ + id: 'core.subgraph', + name: 'Hide Subgraph Nodes', + description: + 'Temporarily hides subgraph nodes from node library and search', + predicate: (nodeDef) => { + // Hide subgraph nodes (identified by category='subgraph' and python_module='nodes') + return !( + nodeDef.category === 'subgraph' && nodeDef.python_module === 'nodes' + ) + } + }) + } + + // Register core filters on store initialization + registerCoreNodeDefFilters() + return { nodeDefsByName, nodeDefsByDisplayName, showDeprecated, showExperimental, + nodeDefFilters, nodeDefs, nodeDataTypes, @@ -324,7 +411,9 @@ export const useNodeDefStore = defineStore('nodeDef', () => { updateNodeDefs, addNodeDef, - fromLGraphNode + fromLGraphNode, + registerNodeDefFilter, + unregisterNodeDefFilter } }) diff --git a/src/stores/queueStore.ts b/src/stores/queueStore.ts index 376063958..316c3564a 100644 --- a/src/stores/queueStore.ts +++ b/src/stores/queueStore.ts @@ -14,6 +14,8 @@ import type { import type { ComfyWorkflowJSON, NodeId } from '@/schemas/comfyWorkflowSchema' import { api } from '@/scripts/api' import type { ComfyApp } from '@/scripts/app' +import { useExtensionService } from '@/services/extensionService' +import { useNodeOutputStore } from '@/stores/imagePreviewStore' // Task type used in the API. export type APITaskType = 'queue' | 'history' @@ -377,7 +379,18 @@ export class TaskItemImpl { } await app.loadGraphData(toRaw(this.workflow)) if (this.outputs) { - app.nodeOutputs = toRaw(this.outputs) + const nodeOutputsStore = useNodeOutputStore() + const rawOutputs = toRaw(this.outputs) + for (const nodeExecutionId in rawOutputs) { + nodeOutputsStore.setNodeOutputsByExecutionId( + nodeExecutionId, + rawOutputs[nodeExecutionId] + ) + } + useExtensionService().invokeExtensions( + 'onNodeOutputsUpdated', + app.nodeOutputs + ) } } diff --git a/src/stores/versionCompatibilityStore.ts b/src/stores/versionCompatibilityStore.ts new file mode 100644 index 000000000..da483846e --- /dev/null +++ b/src/stores/versionCompatibilityStore.ts @@ -0,0 +1,138 @@ +import { useStorage } from '@vueuse/core' +import { defineStore } from 'pinia' +import * as semver from 'semver' +import { computed } from 'vue' + +import config from '@/config' +import { useSystemStatsStore } from '@/stores/systemStatsStore' + +const DISMISSAL_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days + +export const useVersionCompatibilityStore = defineStore( + 'versionCompatibility', + () => { + const systemStatsStore = useSystemStatsStore() + + const frontendVersion = computed(() => config.app_version) + const backendVersion = computed( + () => systemStatsStore.systemStats?.system?.comfyui_version ?? '' + ) + const requiredFrontendVersion = computed( + () => + systemStatsStore.systemStats?.system?.required_frontend_version ?? '' + ) + + const isFrontendOutdated = computed(() => { + if ( + !frontendVersion.value || + !requiredFrontendVersion.value || + !semver.valid(frontendVersion.value) || + !semver.valid(requiredFrontendVersion.value) + ) { + return false + } + // Returns true if required version is greater than frontend version + return semver.gt(requiredFrontendVersion.value, frontendVersion.value) + }) + + const isFrontendNewer = computed(() => { + // We don't warn about frontend being newer than backend + // Only warn when frontend is outdated (behind required version) + return false + }) + + const hasVersionMismatch = computed(() => { + return isFrontendOutdated.value + }) + + const versionKey = computed(() => { + if ( + !frontendVersion.value || + !backendVersion.value || + !requiredFrontendVersion.value + ) { + return null + } + return `${frontendVersion.value}-${backendVersion.value}-${requiredFrontendVersion.value}` + }) + + // Use reactive storage for dismissals - creates a reactive ref that syncs with localStorage + // All version mismatch dismissals are stored in a single object for clean localStorage organization + const dismissalStorage = useStorage( + 'comfy.versionMismatch.dismissals', + {} as Record, + localStorage, + { + serializer: { + read: (value: string) => { + try { + return JSON.parse(value) + } catch { + return {} + } + }, + write: (value: Record) => JSON.stringify(value) + } + } + ) + + const isDismissed = computed(() => { + if (!versionKey.value) return false + + const dismissedUntil = dismissalStorage.value[versionKey.value] + if (!dismissedUntil) return false + + // Check if dismissal has expired + return Date.now() < dismissedUntil + }) + + const shouldShowWarning = computed(() => { + return hasVersionMismatch.value && !isDismissed.value + }) + + const warningMessage = computed(() => { + if (isFrontendOutdated.value) { + return { + type: 'outdated' as const, + frontendVersion: frontendVersion.value, + requiredVersion: requiredFrontendVersion.value + } + } + return null + }) + + async function checkVersionCompatibility() { + if (!systemStatsStore.systemStats) { + await systemStatsStore.fetchSystemStats() + } + } + + function dismissWarning() { + if (!versionKey.value) return + + const dismissUntil = Date.now() + DISMISSAL_DURATION_MS + dismissalStorage.value = { + ...dismissalStorage.value, + [versionKey.value]: dismissUntil + } + } + + async function initialize() { + await checkVersionCompatibility() + } + + return { + frontendVersion, + backendVersion, + requiredFrontendVersion, + hasVersionMismatch, + shouldShowWarning, + warningMessage, + isFrontendOutdated, + isFrontendNewer, + checkVersionCompatibility, + dismissWarning, + initialize + } + } +) diff --git a/src/stores/workflowStore.ts b/src/stores/workflowStore.ts index 6b0044e9a..f11068852 100644 --- a/src/stores/workflowStore.ts +++ b/src/stores/workflowStore.ts @@ -4,10 +4,18 @@ import { defineStore } from 'pinia' import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue' import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema' +import type { NodeId } from '@/schemas/comfyWorkflowSchema' import { api } from '@/scripts/api' import { app as comfyApp } from '@/scripts/app' import { ChangeTracker } from '@/scripts/changeTracker' import { defaultGraphJSON } from '@/scripts/defaultGraph' +import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification' +import { + createNodeExecutionId, + createNodeLocatorId, + parseNodeExecutionId, + parseNodeLocatorId +} from '@/types/nodeIdentification' import { getPathDetails } from '@/utils/formatUtil' import { syncEntities } from '@/utils/syncUtil' import { isSubgraph } from '@/utils/typeGuardUtil' @@ -163,6 +171,15 @@ export interface WorkflowStore { /** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */ updateActiveGraph: () => void executionIdToCurrentId: (id: string) => any + nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId + nodeExecutionIdToNodeLocatorId: ( + nodeExecutionId: NodeExecutionId | string + ) => NodeLocatorId | null + nodeLocatorIdToNodeId: (locatorId: NodeLocatorId | string) => NodeId | null + nodeLocatorIdToNodeExecutionId: ( + locatorId: NodeLocatorId | string, + targetSubgraph?: Subgraph + ) => NodeExecutionId | null } export const useWorkflowStore = defineStore('workflow', () => { @@ -473,7 +490,7 @@ export const useWorkflowStore = defineStore('workflow', () => { return } - // Parse the hierarchical ID (e.g., "123:456:789") + // Parse the execution ID (e.g., "123:456:789") const subgraphNodeIds = id.split(':') // Start from the root graph @@ -488,6 +505,136 @@ export const useWorkflowStore = defineStore('workflow', () => { watch(activeWorkflow, updateActiveGraph) + /** + * Convert a node ID to a NodeLocatorId + * @param nodeId The local node ID + * @param subgraph The subgraph containing the node (defaults to active subgraph) + * @returns The NodeLocatorId (for root graph nodes, returns the node ID as-is) + */ + const nodeIdToNodeLocatorId = ( + nodeId: NodeId, + subgraph?: Subgraph + ): NodeLocatorId => { + const targetSubgraph = subgraph ?? activeSubgraph.value + if (!targetSubgraph) { + // Node is in the root graph, return the node ID as-is + return String(nodeId) + } + + return createNodeLocatorId(targetSubgraph.id, nodeId) + } + + /** + * Convert an execution ID to a NodeLocatorId + * @param nodeExecutionId The execution node ID (e.g., "123:456:789") + * @returns The NodeLocatorId or null if conversion fails + */ + const nodeExecutionIdToNodeLocatorId = ( + nodeExecutionId: NodeExecutionId | string + ): NodeLocatorId | null => { + // Handle simple node IDs (root graph - no colons) + if (!nodeExecutionId.includes(':')) { + return nodeExecutionId + } + + const parts = parseNodeExecutionId(nodeExecutionId) + if (!parts || parts.length === 0) return null + + const nodeId = parts[parts.length - 1] + const subgraphNodeIds = parts.slice(0, -1) + + if (subgraphNodeIds.length === 0) { + // Node is in root graph, return the node ID as-is + return String(nodeId) + } + + try { + const subgraphs = getSubgraphsFromInstanceIds( + comfyApp.graph, + subgraphNodeIds.map((id) => String(id)) + ) + const immediateSubgraph = subgraphs[subgraphs.length - 1] + return createNodeLocatorId(immediateSubgraph.id, nodeId) + } catch { + return null + } + } + + /** + * Extract the node ID from a NodeLocatorId + * @param locatorId The NodeLocatorId + * @returns The local node ID or null if invalid + */ + const nodeLocatorIdToNodeId = ( + locatorId: NodeLocatorId | string + ): NodeId | null => { + const parsed = parseNodeLocatorId(locatorId) + return parsed?.localNodeId ?? null + } + + /** + * Convert a NodeLocatorId to an execution ID for a specific context + * @param locatorId The NodeLocatorId + * @param targetSubgraph The subgraph context (defaults to active subgraph) + * @returns The execution ID or null if the node is not accessible from the target context + */ + const nodeLocatorIdToNodeExecutionId = ( + locatorId: NodeLocatorId | string, + targetSubgraph?: Subgraph + ): NodeExecutionId | null => { + const parsed = parseNodeLocatorId(locatorId) + if (!parsed) return null + + const { subgraphUuid, localNodeId } = parsed + + // If no subgraph UUID, this is a root graph node + if (!subgraphUuid) { + return String(localNodeId) + } + + // Find the path from root to the subgraph with this UUID + const findSubgraphPath = ( + graph: LGraph | Subgraph, + targetUuid: string, + path: NodeId[] = [] + ): NodeId[] | null => { + if (isSubgraph(graph) && graph.id === targetUuid) { + return path + } + + for (const node of graph._nodes) { + if (node.isSubgraphNode() && node.subgraph) { + const result = findSubgraphPath(node.subgraph, targetUuid, [ + ...path, + node.id + ]) + if (result) return result + } + } + + return null + } + + const path = findSubgraphPath(comfyApp.graph, subgraphUuid) + if (!path) return null + + // If we have a target subgraph, check if the path goes through it + if ( + targetSubgraph && + !path.some((_, idx) => { + const subgraphs = getSubgraphsFromInstanceIds( + comfyApp.graph, + path.slice(0, idx + 1).map((id) => String(id)) + ) + return subgraphs[subgraphs.length - 1] === targetSubgraph + }) + ) { + return null + } + + return createNodeExecutionId([...path, localNodeId]) + } + return { activeWorkflow, isActive, @@ -514,7 +661,11 @@ export const useWorkflowStore = defineStore('workflow', () => { isSubgraphActive, activeSubgraph, updateActiveGraph, - executionIdToCurrentId + executionIdToCurrentId, + nodeIdToNodeLocatorId, + nodeExecutionIdToNodeLocatorId, + nodeLocatorIdToNodeId, + nodeLocatorIdToNodeExecutionId } }) satisfies () => WorkflowStore diff --git a/src/types/index.ts b/src/types/index.ts index 20d38a345..377581465 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -31,6 +31,16 @@ export type { ComfyApi } from '@/scripts/api' export type { ComfyApp } from '@/scripts/app' export type { ComfyNodeDef } from '@/schemas/nodeDefSchema' export type { InputSpec } from '@/schemas/nodeDefSchema' +export type { + NodeLocatorId, + NodeExecutionId, + isNodeLocatorId, + isNodeExecutionId, + parseNodeLocatorId, + createNodeLocatorId, + parseNodeExecutionId, + createNodeExecutionId +} from './nodeIdentification' export type { EmbeddingsResponse, ExtensionsResponse, diff --git a/src/types/metadataTypes.ts b/src/types/metadataTypes.ts index 9170ef6d4..c63324196 100644 --- a/src/types/metadataTypes.ts +++ b/src/types/metadataTypes.ts @@ -85,3 +85,57 @@ export type GltfJsonData = { * Null if the box was not found. */ export type IsobmffBoxContentRange = { start: number; end: number } | null + +export type AvifInfeBox = { + box_header: { + size: number + type: 'infe' + } + version: number + flags: number + item_ID: number + item_protection_index: number + item_type: string + item_name: string + content_type?: string + content_encoding?: string +} + +export type AvifIinfBox = { + box_header: { + size: number + type: 'iinf' + } + version: number + flags: number + entry_count: number + entries: AvifInfeBox[] +} + +export type AvifIlocItemExtent = { + extent_offset: number + extent_length: number +} + +export type AvifIlocItem = { + item_ID: number + data_reference_index: number + base_offset: number + extent_count: number + extents: AvifIlocItemExtent[] +} + +export type AvifIlocBox = { + box_header: { + size: number + type: 'iloc' + } + version: number + flags: number + offset_size: number + length_size: number + base_offset_size: number + index_size: number + item_count: number + items: AvifIlocItem[] +} diff --git a/src/types/nodeIdentification.ts b/src/types/nodeIdentification.ts new file mode 100644 index 000000000..038f28449 --- /dev/null +++ b/src/types/nodeIdentification.ts @@ -0,0 +1,123 @@ +import type { NodeId } from '@/schemas/comfyWorkflowSchema' + +/** + * A globally unique identifier for nodes that maintains consistency across + * multiple instances of the same subgraph. + * + * Format: + * - For subgraph nodes: `:` + * - For root graph nodes: `` + * + * Examples: + * - "a1b2c3d4-e5f6-7890-abcd-ef1234567890:123" (node in subgraph) + * - "456" (node in root graph) + * + * Unlike execution IDs which change based on the instance path, + * NodeLocatorId remains the same for all instances of a particular node. + */ +export type NodeLocatorId = string + +/** + * An execution identifier representing a node's position in nested subgraphs. + * Also known as ExecutionId in some contexts. + * + * Format: Colon-separated path of node IDs + * Example: "123:456:789" (node 789 in subgraph 456 in subgraph 123) + */ +export type NodeExecutionId = string + +/** + * Type guard to check if a value is a NodeLocatorId + */ +export function isNodeLocatorId(value: unknown): value is NodeLocatorId { + if (typeof value !== 'string') return false + + // Check if it's a simple node ID (root graph node) + const parts = value.split(':') + if (parts.length === 1) { + // Simple node ID - must be non-empty + return value.length > 0 + } + + // Check for UUID:nodeId format + if (parts.length !== 2) return false + + // Check that node ID part is not empty + if (!parts[1]) return false + + // Basic UUID format check (8-4-4-4-12 hex characters) + const uuidPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + return uuidPattern.test(parts[0]) +} + +/** + * Type guard to check if a value is a NodeExecutionId + */ +export function isNodeExecutionId(value: unknown): value is NodeExecutionId { + if (typeof value !== 'string') return false + // Must contain at least one colon to be an execution ID + return value.includes(':') +} + +/** + * Parse a NodeLocatorId into its components + * @param id The NodeLocatorId to parse + * @returns The subgraph UUID and local node ID, or null if invalid + */ +export function parseNodeLocatorId( + id: string +): { subgraphUuid: string | null; localNodeId: NodeId } | null { + if (!isNodeLocatorId(id)) return null + + const parts = id.split(':') + + if (parts.length === 1) { + // Simple node ID (root graph) + return { + subgraphUuid: null, + localNodeId: isNaN(Number(id)) ? id : Number(id) + } + } + + const [subgraphUuid, localNodeId] = parts + return { + subgraphUuid, + localNodeId: isNaN(Number(localNodeId)) ? localNodeId : Number(localNodeId) + } +} + +/** + * Create a NodeLocatorId from components + * @param subgraphUuid The UUID of the immediate containing subgraph + * @param localNodeId The local node ID within that subgraph + * @returns A properly formatted NodeLocatorId + */ +export function createNodeLocatorId( + subgraphUuid: string, + localNodeId: NodeId +): NodeLocatorId { + return `${subgraphUuid}:${localNodeId}` +} + +/** + * Parse a NodeExecutionId into its component node IDs + * @param id The NodeExecutionId to parse + * @returns Array of node IDs from root to target, or null if not an execution ID + */ +export function parseNodeExecutionId(id: string): NodeId[] | null { + if (!isNodeExecutionId(id)) return null + + return id + .split(':') + .map((part) => (isNaN(Number(part)) ? part : Number(part))) +} + +/** + * Create a NodeExecutionId from an array of node IDs + * @param nodeIds Array of node IDs from root to target + * @returns A properly formatted NodeExecutionId + */ +export function createNodeExecutionId(nodeIds: NodeId[]): NodeExecutionId { + return nodeIds.join(':') +} diff --git a/src/utils/colorUtil.ts b/src/utils/colorUtil.ts index 75e8622a4..c57bc14ad 100644 --- a/src/utils/colorUtil.ts +++ b/src/utils/colorUtil.ts @@ -40,7 +40,7 @@ function rgbToHsl({ r, g, b }: RGB): HSL { return { h, s, l } } -function hexToRgb(hex: string): RGB { +export function hexToRgb(hex: string): RGB { let r = 0, g = 0, b = 0 diff --git a/src/utils/formatUtil.ts b/src/utils/formatUtil.ts index 7b7966394..da933d3ec 100644 --- a/src/utils/formatUtil.ts +++ b/src/utils/formatUtil.ts @@ -39,7 +39,11 @@ export function trimJsonExt(path?: string) { export function highlightQuery(text: string, query: string) { if (!query) return text - const regex = new RegExp(`(${query})`, 'gi') + + // Escape special regex characters in the query string + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + + const regex = new RegExp(`(${escapedQuery})`, 'gi') return text.replace(regex, '$1') } diff --git a/src/utils/graphTraversalUtil.ts b/src/utils/graphTraversalUtil.ts new file mode 100644 index 000000000..5fcf0b369 --- /dev/null +++ b/src/utils/graphTraversalUtil.ts @@ -0,0 +1,353 @@ +import type { LGraph, LGraphNode, Subgraph } from '@comfyorg/litegraph' + +import type { NodeLocatorId } from '@/types/nodeIdentification' +import { parseNodeLocatorId } from '@/types/nodeIdentification' + +import { isSubgraphIoNode } from './typeGuardUtil' + +/** + * Parses an execution ID into its component parts. + * + * @param executionId - The execution ID (e.g., "123:456:789" or "789") + * @returns Array of node IDs in the path, or null if invalid + */ +export function parseExecutionId(executionId: string): string[] | null { + if (!executionId || typeof executionId !== 'string') return null + return executionId.split(':').filter((part) => part.length > 0) +} + +/** + * Extracts the local node ID from an execution ID. + * + * @param executionId - The execution ID (e.g., "123:456:789" or "789") + * @returns The local node ID or null if invalid + */ +export function getLocalNodeIdFromExecutionId( + executionId: string +): string | null { + const parts = parseExecutionId(executionId) + return parts ? parts[parts.length - 1] : null +} + +/** + * Extracts the subgraph path from an execution ID. + * + * @param executionId - The execution ID (e.g., "123:456:789" or "789") + * @returns Array of subgraph node IDs (excluding the final node ID), or empty array + */ +export function getSubgraphPathFromExecutionId(executionId: string): string[] { + const parts = parseExecutionId(executionId) + return parts ? parts.slice(0, -1) : [] +} + +/** + * Visits each node in a graph (non-recursive, single level). + * + * @param graph - The graph to visit nodes from + * @param visitor - Function called for each node + */ +export function visitGraphNodes( + graph: LGraph | Subgraph, + visitor: (node: LGraphNode) => void +): void { + for (const node of graph.nodes) { + visitor(node) + } +} + +/** + * Traverses a path of subgraphs to reach a target graph. + * + * @param startGraph - The graph to start from + * @param path - Array of subgraph node IDs to traverse + * @returns The target graph or null if path is invalid + */ +export function traverseSubgraphPath( + startGraph: LGraph | Subgraph, + path: string[] +): LGraph | Subgraph | null { + let currentGraph: LGraph | Subgraph = startGraph + + for (const nodeId of path) { + const node = currentGraph.getNodeById(nodeId) + if (!node?.isSubgraphNode?.() || !node.subgraph) return null + currentGraph = node.subgraph + } + + return currentGraph +} + +/** + * Traverses all nodes in a graph hierarchy (including subgraphs) and invokes + * a callback on each node that has the specified property. + * + * @param graph - The root graph to start traversal from + * @param callbackProperty - The name of the callback property to invoke on each node + */ +export function triggerCallbackOnAllNodes( + graph: LGraph | Subgraph, + callbackProperty: keyof LGraphNode +): void { + forEachNode(graph, (node) => { + const callback = node[callbackProperty] + if (typeof callback === 'function') { + callback.call(node) + } + }) +} + +/** + * Maps a function over all nodes in a graph hierarchy (including subgraphs). + * This is a pure functional traversal that doesn't mutate the graph. + * + * @param graph - The root graph to traverse + * @param mapFn - Function to apply to each node + * @returns Array of mapped results (excluding undefined values) + */ +export function mapAllNodes( + graph: LGraph | Subgraph, + mapFn: (node: LGraphNode) => T | undefined +): T[] { + const results: T[] = [] + + visitGraphNodes(graph, (node) => { + // Recursively map over subgraphs first + if (node.isSubgraphNode?.() && node.subgraph) { + results.push(...mapAllNodes(node.subgraph, mapFn)) + } + + // Apply map function to current node + const result = mapFn(node) + if (result !== undefined) { + results.push(result) + } + }) + + return results +} + +/** + * Executes a side-effect function on all nodes in a graph hierarchy. + * This is for operations that modify nodes or perform side effects. + * + * @param graph - The root graph to traverse + * @param fn - Function to execute on each node + */ +export function forEachNode( + graph: LGraph | Subgraph, + fn: (node: LGraphNode) => void +): void { + visitGraphNodes(graph, (node) => { + // Recursively process subgraphs first + if (node.isSubgraphNode?.() && node.subgraph) { + forEachNode(node.subgraph, fn) + } + + // Execute function on current node + fn(node) + }) +} + +/** + * Collects all nodes in a graph hierarchy (including subgraphs) into a flat array. + * + * @param graph - The root graph to collect nodes from + * @param filter - Optional filter function to include only specific nodes + * @returns Array of all nodes in the graph hierarchy + */ +export function collectAllNodes( + graph: LGraph | Subgraph, + filter?: (node: LGraphNode) => boolean +): LGraphNode[] { + return mapAllNodes(graph, (node) => { + if (!filter || filter(node)) { + return node + } + return undefined + }) +} + +/** + * Finds a node by ID anywhere in the graph hierarchy. + * + * @param graph - The root graph to search + * @param nodeId - The ID of the node to find + * @returns The node if found, null otherwise + */ +export function findNodeInHierarchy( + graph: LGraph | Subgraph, + nodeId: string | number +): LGraphNode | null { + // Check current graph + const node = graph.getNodeById(nodeId) + if (node) return node + + // Search in subgraphs + for (const node of graph.nodes) { + if (node.isSubgraphNode?.() && node.subgraph) { + const found = findNodeInHierarchy(node.subgraph, nodeId) + if (found) return found + } + } + + return null +} + +/** + * Find a subgraph by its UUID anywhere in the graph hierarchy. + * + * @param graph - The root graph to search + * @param targetUuid - The UUID of the subgraph to find + * @returns The subgraph if found, null otherwise + */ +export function findSubgraphByUuid( + graph: LGraph | Subgraph, + targetUuid: string +): Subgraph | null { + // Check all nodes in the current graph + for (const node of graph._nodes) { + if (node.isSubgraphNode?.() && node.subgraph) { + if (node.subgraph.id === targetUuid) { + return node.subgraph + } + // Recursively search in nested subgraphs + const found = findSubgraphByUuid(node.subgraph, targetUuid) + if (found) return found + } + } + return null +} + +/** + * Get a node by its execution ID from anywhere in the graph hierarchy. + * Execution IDs use hierarchical format like "123:456:789" for nested nodes. + * + * @param rootGraph - The root graph to search from + * @param executionId - The execution ID (e.g., "123:456:789" or "789") + * @returns The node if found, null otherwise + */ +export function getNodeByExecutionId( + rootGraph: LGraph, + executionId: string +): LGraphNode | null { + if (!rootGraph) return null + + const localNodeId = getLocalNodeIdFromExecutionId(executionId) + if (!localNodeId) return null + + const subgraphPath = getSubgraphPathFromExecutionId(executionId) + + // If no subgraph path, it's in the root graph + if (subgraphPath.length === 0) { + return rootGraph.getNodeById(localNodeId) || null + } + + // Traverse to the target subgraph + const targetGraph = traverseSubgraphPath(rootGraph, subgraphPath) + if (!targetGraph) return null + + // Get the node from the target graph + return targetGraph.getNodeById(localNodeId) || null +} + +/** + * Get a node by its locator ID from anywhere in the graph hierarchy. + * Locator IDs use UUID format like "uuid:nodeId" for subgraph nodes. + * + * @param rootGraph - The root graph to search from + * @param locatorId - The locator ID (e.g., "uuid:123" or "123") + * @returns The node if found, null otherwise + */ +export function getNodeByLocatorId( + rootGraph: LGraph, + locatorId: NodeLocatorId | string +): LGraphNode | null { + if (!rootGraph) return null + + const parsedIds = parseNodeLocatorId(locatorId) + if (!parsedIds) return null + + const { subgraphUuid, localNodeId } = parsedIds + + // If no subgraph UUID, it's in the root graph + if (!subgraphUuid) { + return rootGraph.getNodeById(localNodeId) || null + } + + // Find the subgraph with the matching UUID + const targetSubgraph = findSubgraphByUuid(rootGraph, subgraphUuid) + if (!targetSubgraph) return null + + return targetSubgraph.getNodeById(localNodeId) || null +} + +/** + * Finds the root graph from any graph in the hierarchy. + * + * @param graph - Any graph or subgraph in the hierarchy + * @returns The root graph + */ +export function getRootGraph(graph: LGraph | Subgraph): LGraph | Subgraph { + let current: LGraph | Subgraph = graph + while ('rootGraph' in current && current.rootGraph) { + current = current.rootGraph + } + return current +} + +/** + * Applies a function to all nodes whose type matches a subgraph ID. + * Operates on the entire graph hierarchy starting from the root. + * + * @param rootGraph - The root graph to search in + * @param subgraphId - The ID/type of the subgraph to match nodes against + * @param fn - Function to apply to each matching node + */ +export function forEachSubgraphNode( + rootGraph: LGraph | Subgraph | null | undefined, + subgraphId: string | null | undefined, + fn: (node: LGraphNode) => void +): void { + if (!rootGraph || !subgraphId) return + + forEachNode(rootGraph, (node) => { + if (node.type === subgraphId) { + fn(node) + } + }) +} + +/** + * Maps a function over all nodes whose type matches a subgraph ID. + * Operates on the entire graph hierarchy starting from the root. + * + * @param rootGraph - The root graph to search in + * @param subgraphId - The ID/type of the subgraph to match nodes against + * @param mapFn - Function to apply to each matching node + * @returns Array of mapped results + */ +export function mapSubgraphNodes( + rootGraph: LGraph | Subgraph | null | undefined, + subgraphId: string | null | undefined, + mapFn: (node: LGraphNode) => T +): T[] { + if (!rootGraph || !subgraphId) return [] + + return mapAllNodes(rootGraph, (node) => { + if (node.type === subgraphId) { + return mapFn(node) + } + return undefined + }) +} + +/** + * Gets all non-IO nodes from a subgraph (excludes SubgraphInputNode and SubgraphOutputNode). + * These are the user-created nodes that can be safely removed when clearing a subgraph. + * + * @param subgraph - The subgraph to get non-IO nodes from + * @returns Array of non-IO nodes (user-created nodes) + */ +export function getAllNonIoNodesInSubgraph(subgraph: Subgraph): LGraphNode[] { + return subgraph.nodes.filter((node) => !isSubgraphIoNode(node)) +} diff --git a/src/utils/litegraphUtil.ts b/src/utils/litegraphUtil.ts index 738fbd7ca..5f147b99b 100644 --- a/src/utils/litegraphUtil.ts +++ b/src/utils/litegraphUtil.ts @@ -153,7 +153,10 @@ export function migrateWidgetsValues( * @param graph - The graph to fix links for. */ export function fixLinkInputSlots(graph: LGraph) { + // Note: We can't use forEachNode here because we need access to the graph's + // links map at each level. Links are stored in their respective graph/subgraph. for (const node of graph.nodes) { + // Fix links for the current node for (const [inputIndex, input] of node.inputs.entries()) { const linkId = input.link if (!linkId) continue @@ -163,6 +166,11 @@ export function fixLinkInputSlots(graph: LGraph) { link.target_slot = inputIndex } + + // Recursively fix links in subgraphs + if (node.isSubgraphNode?.() && node.subgraph) { + fixLinkInputSlots(node.subgraph) + } } } diff --git a/src/utils/mouseDownUtil.ts b/src/utils/mouseDownUtil.ts new file mode 100644 index 000000000..4c6140359 --- /dev/null +++ b/src/utils/mouseDownUtil.ts @@ -0,0 +1,27 @@ +import { useEventListener } from '@vueuse/core' + +export const whileMouseDown = ( + elementOrEvent: HTMLElement | Event, + callback: (iteration: number) => void, + interval: number = 30 +) => { + const element = + elementOrEvent instanceof HTMLElement + ? elementOrEvent + : (elementOrEvent.target as HTMLElement) + + let iteration = 0 + + const intervalId = setInterval(() => { + callback(iteration++) + }, interval) + + const dispose = useEventListener(element, 'mouseup', () => { + clearInterval(intervalId) + dispose() + }) + + return { + dispose + } +} diff --git a/src/utils/nodeDefUtil.ts b/src/utils/nodeDefUtil.ts index 8b130ff2d..cb8a7125b 100644 --- a/src/utils/nodeDefUtil.ts +++ b/src/utils/nodeDefUtil.ts @@ -15,6 +15,7 @@ import { isFloatInputSpec, isIntInputSpec } from '@/schemas/nodeDefSchema' +import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' import { lcm } from './mathUtil' @@ -138,3 +139,11 @@ export const mergeInputSpec = ( return mergeCommonInputSpec(spec1, spec2) } + +/** + * Checks if a node definition represents a subgraph node. + * Subgraph nodes are created with category='subgraph' and python_module='nodes'. + */ +export const isSubgraphNode = (nodeDef: ComfyNodeDefImpl): boolean => { + return nodeDef.category === 'subgraph' && nodeDef.python_module === 'nodes' +} diff --git a/src/utils/searchAndReplace.ts b/src/utils/searchAndReplace.ts index 8023b4882..395d96406 100644 --- a/src/utils/searchAndReplace.ts +++ b/src/utils/searchAndReplace.ts @@ -1,11 +1,14 @@ -import type { LGraphNode } from '@comfyorg/litegraph' +import type { LGraph, Subgraph } from '@comfyorg/litegraph' import { formatDate } from '@/utils/formatUtil' +import { collectAllNodes } from '@/utils/graphTraversalUtil' export function applyTextReplacements( - allNodes: LGraphNode[], + graph: LGraph | Subgraph, value: string ): string { + const allNodes = collectAllNodes(graph) + return value.replace(/%([^%]+)%/g, function (match, text) { const split = text.split('.') if (split.length !== 2) { diff --git a/src/utils/typeGuardUtil.ts b/src/utils/typeGuardUtil.ts index c35a9716b..a3bcb5f3d 100644 --- a/src/utils/typeGuardUtil.ts +++ b/src/utils/typeGuardUtil.ts @@ -27,3 +27,16 @@ export const isSubgraph = ( */ export const isNonNullish = (item: T | undefined | null): item is T => item != null + +/** + * Type guard to check if a node is a subgraph input/output node. + * These nodes are essential to subgraph structure and should not be removed. + */ +export const isSubgraphIoNode = ( + node: LGraphNode +): node is LGraphNode & { + constructor: { comfyClass: 'SubgraphInputNode' | 'SubgraphOutputNode' } +} => { + const nodeClass = node.constructor?.comfyClass + return nodeClass === 'SubgraphInputNode' || nodeClass === 'SubgraphOutputNode' +} diff --git a/src/views/GraphView.vue b/src/views/GraphView.vue index f21cf1b61..f5e299294 100644 --- a/src/views/GraphView.vue +++ b/src/views/GraphView.vue @@ -23,7 +23,14 @@ import { useBreakpoints, useEventListener } from '@vueuse/core' import type { ToastMessageOptions } from 'primevue/toast' import { useToast } from 'primevue/usetoast' -import { computed, onBeforeUnmount, onMounted, watch, watchEffect } from 'vue' +import { + computed, + nextTick, + onBeforeUnmount, + onMounted, + watch, + watchEffect +} from 'vue' import { useI18n } from 'vue-i18n' import MenuHamburger from '@/components/MenuHamburger.vue' @@ -35,6 +42,7 @@ import TopMenubar from '@/components/topbar/TopMenubar.vue' import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle' import { useCoreCommands } from '@/composables/useCoreCommands' import { useErrorHandling } from '@/composables/useErrorHandling' +import { useFrontendVersionMismatchWarning } from '@/composables/useFrontendVersionMismatchWarning' import { useProgressFavicon } from '@/composables/useProgressFavicon' import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig' import { i18n } from '@/i18n' @@ -54,6 +62,7 @@ import { } from '@/stores/queueStore' import { useServerConfigStore } from '@/stores/serverConfigStore' import { useSettingStore } from '@/stores/settingStore' +import { useVersionCompatibilityStore } from '@/stores/versionCompatibilityStore' import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore' import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' @@ -70,6 +79,8 @@ const settingStore = useSettingStore() const executionStore = useExecutionStore() const colorPaletteStore = useColorPaletteStore() const queueStore = useQueueStore() +const versionCompatibilityStore = useVersionCompatibilityStore() + const breakpoints = useBreakpoints({ md: 961 }) const isMobile = breakpoints.smaller('md') const showTopMenu = computed(() => isMobile.value || useNewMenu.value === 'Top') @@ -224,6 +235,17 @@ onBeforeUnmount(() => { useEventListener(window, 'keydown', useKeybindingService().keybindHandler) const { wrapWithErrorHandling, wrapWithErrorHandlingAsync } = useErrorHandling() + +// Initialize version mismatch warning in setup context +// It will be triggered automatically when the store is ready +useFrontendVersionMismatchWarning({ immediate: true }) + +void nextTick(() => { + versionCompatibilityStore.initialize().catch((error) => { + console.warn('Version compatibility check failed:', error) + }) +}) + const onGraphReady = () => { requestIdleCallback( () => { diff --git a/tests-ui/tests/audioService.test.ts b/tests-ui/tests/audioService.test.ts new file mode 100644 index 000000000..68f37680b --- /dev/null +++ b/tests-ui/tests/audioService.test.ts @@ -0,0 +1,332 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + type AudioRecordingError, + useAudioService +} from '@/services/audioService' + +const mockRegister = vi.hoisted(() => vi.fn()) +const mockConnect = vi.hoisted(() => vi.fn()) + +const mockApi = vi.hoisted(() => ({ + fetchApi: vi.fn() +})) + +const mockToastStore = vi.hoisted(() => ({ + addAlert: vi.fn() +})) + +vi.mock('extendable-media-recorder', () => ({ + register: mockRegister +})) + +vi.mock('extendable-media-recorder-wav-encoder', () => ({ + connect: mockConnect +})) + +vi.mock('@/scripts/api', () => ({ + api: mockApi +})) + +vi.mock('@/stores/toastStore', () => ({ + useToastStore: vi.fn(() => mockToastStore) +})) + +describe('useAudioService', () => { + let service: ReturnType + + const mockBlob = new Blob(['test audio data'], { type: 'audio/wav' }) + const mockUploadResponse = { + name: 'test-audio-123.wav' + } + + beforeEach(() => { + vi.clearAllMocks() + + vi.spyOn(console, 'error').mockImplementation(() => {}) + + mockConnect.mockResolvedValue('mock-encoder') + mockRegister.mockResolvedValue(undefined) + mockApi.fetchApi.mockResolvedValue({ + status: 200, + json: () => Promise.resolve(mockUploadResponse) + }) + + service = useAudioService() + }) + + describe('initialization', () => { + it('should initialize service with required methods', () => { + expect(service).toHaveProperty('registerWavEncoder') + expect(service).toHaveProperty('stopAllTracks') + expect(service).toHaveProperty('convertBlobToFileAndSubmit') + expect(typeof service.registerWavEncoder).toBe('function') + expect(typeof service.stopAllTracks).toBe('function') + expect(typeof service.convertBlobToFileAndSubmit).toBe('function') + }) + }) + + describe('registerWavEncoder', () => { + it('should register WAV encoder successfully on first call', async () => { + await service.registerWavEncoder() + + expect(mockConnect).toHaveBeenCalledTimes(1) + expect(mockRegister).toHaveBeenCalledWith('mock-encoder') + }) + + it('should not register again if already registered', async () => { + await service.registerWavEncoder() + + mockConnect.mockClear() + mockRegister.mockClear() + + await service.registerWavEncoder() + + expect(mockConnect).not.toHaveBeenCalled() + expect(mockRegister).not.toHaveBeenCalled() + }) + + it('should handle "already an encoder stored" error gracefully', async () => { + const error = new Error( + 'There is already an encoder stored which handles exactly the same mime types.' + ) + mockRegister.mockRejectedValueOnce(error) + + await service.registerWavEncoder() + + expect(mockConnect).toHaveBeenCalledTimes(0) + expect(mockRegister).toHaveBeenCalledTimes(0) + expect(console.error).not.toHaveBeenCalled() + }) + }) + + describe('stopAllTracks', () => { + it('should stop all tracks in a stream', () => { + const mockTrack1 = { stop: vi.fn() } + const mockTrack2 = { stop: vi.fn() } + const mockStream = { + getTracks: vi.fn().mockReturnValue([mockTrack1, mockTrack2]) + } as unknown as MediaStream + + service.stopAllTracks(mockStream) + + expect(mockStream.getTracks).toHaveBeenCalledTimes(1) + expect(mockTrack1.stop).toHaveBeenCalledTimes(1) + expect(mockTrack2.stop).toHaveBeenCalledTimes(1) + }) + + it('should handle null stream gracefully', () => { + expect(() => service.stopAllTracks(null)).not.toThrow() + }) + + it('should handle stream with no tracks', () => { + const mockStream = { + getTracks: vi.fn().mockReturnValue([]) + } as unknown as MediaStream + + expect(() => service.stopAllTracks(mockStream)).not.toThrow() + expect(mockStream.getTracks).toHaveBeenCalledTimes(1) + }) + + it('should handle tracks that throw on stop', () => { + const mockTrack1 = { stop: vi.fn() } + const mockTrack2 = { + stop: vi.fn().mockImplementation(() => { + throw new Error('Stop failed') + }) + } + const mockStream = { + getTracks: vi.fn().mockReturnValue([mockTrack1, mockTrack2]) + } as unknown as MediaStream + + expect(() => service.stopAllTracks(mockStream)).toThrow() + expect(mockTrack1.stop).toHaveBeenCalledTimes(1) + expect(mockTrack2.stop).toHaveBeenCalledTimes(1) + }) + }) + + describe('convertBlobToFileAndSubmit', () => { + it('should convert blob to file and upload successfully', async () => { + const result = await service.convertBlobToFileAndSubmit(mockBlob) + + expect(mockApi.fetchApi).toHaveBeenCalledWith('/upload/image', { + method: 'POST', + body: expect.any(FormData) + }) + + expect(result).toBe('audio/test-audio-123.wav [temp]') + }) + + it('should create file with correct name and type', async () => { + const mockTimestamp = 1640995200000 + vi.spyOn(Date, 'now').mockReturnValue(mockTimestamp) + + await service.convertBlobToFileAndSubmit(mockBlob) + + const formDataCall = mockApi.fetchApi.mock.calls[0][1].body as FormData + const uploadedFile = formDataCall.get('image') as File + + expect(uploadedFile).toBeInstanceOf(File) + expect(uploadedFile.name).toBe(`recording-${mockTimestamp}.wav`) + expect(uploadedFile.type).toBe('audio/wav') + }) + + it('should set correct form data fields', async () => { + await service.convertBlobToFileAndSubmit(mockBlob) + + const formDataCall = mockApi.fetchApi.mock.calls[0][1].body as FormData + + expect(formDataCall.get('subfolder')).toBe('audio') + expect(formDataCall.get('type')).toBe('temp') + expect(formDataCall.get('image')).toBeInstanceOf(File) + }) + + it('should handle blob with different type', async () => { + const customBlob = new Blob(['test'], { type: 'audio/ogg' }) + + await service.convertBlobToFileAndSubmit(customBlob) + + const formDataCall = mockApi.fetchApi.mock.calls[0][1].body as FormData + const uploadedFile = formDataCall.get('image') as File + + expect(uploadedFile.type).toBe('audio/ogg') + }) + + it('should handle blob with no type', async () => { + const customBlob = new Blob(['test']) + + await service.convertBlobToFileAndSubmit(customBlob) + + const formDataCall = mockApi.fetchApi.mock.calls[0][1].body as FormData + const uploadedFile = formDataCall.get('image') as File + + expect(uploadedFile.type).toBe('audio/wav') // Should default to audio/wav + }) + + it('should handle upload failure with error status', async () => { + mockApi.fetchApi.mockResolvedValueOnce({ + status: 500, + statusText: 'Internal Server Error' + }) + + await expect( + service.convertBlobToFileAndSubmit(mockBlob) + ).rejects.toThrow( + 'Error uploading temp file: 500 - Internal Server Error' + ) + + expect(mockToastStore.addAlert).toHaveBeenCalledWith( + 'Error uploading temp file: 500 - Internal Server Error' + ) + }) + + it('should handle network errors', async () => { + const networkError = new Error('Network Error') + mockApi.fetchApi.mockRejectedValueOnce(networkError) + + await expect( + service.convertBlobToFileAndSubmit(mockBlob) + ).rejects.toThrow('Network Error') + }) + + it('should handle different status codes', async () => { + const testCases = [ + { status: 400, statusText: 'Bad Request' }, + { status: 403, statusText: 'Forbidden' }, + { status: 404, statusText: 'Not Found' }, + { status: 413, statusText: 'Payload Too Large' } + ] + + for (const testCase of testCases) { + mockApi.fetchApi.mockResolvedValueOnce(testCase) + + await expect( + service.convertBlobToFileAndSubmit(mockBlob) + ).rejects.toThrow( + `Error uploading temp file: ${testCase.status} - ${testCase.statusText}` + ) + + expect(mockToastStore.addAlert).toHaveBeenCalledWith( + `Error uploading temp file: ${testCase.status} - ${testCase.statusText}` + ) + + mockToastStore.addAlert.mockClear() + } + }) + + it('should handle malformed response JSON', async () => { + mockApi.fetchApi.mockResolvedValueOnce({ + status: 200, + json: () => Promise.reject(new Error('Invalid JSON')) + }) + + await expect( + service.convertBlobToFileAndSubmit(mockBlob) + ).rejects.toThrow('Invalid JSON') + }) + + it('should handle empty response', async () => { + mockApi.fetchApi.mockResolvedValueOnce({ + status: 200, + json: () => Promise.resolve({}) + }) + + const result = await service.convertBlobToFileAndSubmit(mockBlob) + + expect(result).toBe('audio/undefined [temp]') + }) + }) + + describe('error handling', () => { + it('should handle AudioRecordingError interface correctly', () => { + const error: AudioRecordingError = { + type: 'permission', + message: 'Microphone access denied', + originalError: new Error('Permission denied') + } + + expect(error.type).toBe('permission') + expect(error.message).toBe('Microphone access denied') + expect(error.originalError).toBeInstanceOf(Error) + }) + + it('should support all error types', () => { + const errorTypes = [ + 'permission', + 'not_supported', + 'encoder', + 'recording', + 'unknown' + ] as const + + errorTypes.forEach((type) => { + const error: AudioRecordingError = { + type, + message: `Test error for ${type}` + } + + expect(error.type).toBe(type) + }) + }) + }) + + describe('edge cases', () => { + it('should handle very large blobs', async () => { + const largeData = new Array(1000000).fill('a').join('') + const largeBlob = new Blob([largeData], { type: 'audio/wav' }) + + const result = await service.convertBlobToFileAndSubmit(largeBlob) + + expect(result).toBe('audio/test-audio-123.wav [temp]') + expect(mockApi.fetchApi).toHaveBeenCalledTimes(1) + }) + + it('should handle empty blob', async () => { + const emptyBlob = new Blob([], { type: 'audio/wav' }) + + const result = await service.convertBlobToFileAndSubmit(emptyBlob) + + expect(result).toBe('audio/test-audio-123.wav [temp]') + }) + }) +}) diff --git a/tests-ui/tests/composables/BrowserTabTitle.spec.ts b/tests-ui/tests/composables/BrowserTabTitle.spec.ts index 328f41e80..dd88fb1cd 100644 --- a/tests-ui/tests/composables/BrowserTabTitle.spec.ts +++ b/tests-ui/tests/composables/BrowserTabTitle.spec.ts @@ -3,12 +3,20 @@ import { nextTick, reactive } from 'vue' import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle' +// Mock i18n module +vi.mock('@/i18n', () => ({ + t: (key: string, fallback: string) => + key === 'g.nodesRunning' ? 'nodes running' : fallback +})) + // Mock the execution store const executionStore = reactive({ isIdle: true, executionProgress: 0, executingNode: null as any, - executingNodeProgress: 0 + executingNodeProgress: 0, + nodeProgressStates: {} as any, + activePrompt: null as any }) vi.mock('@/stores/executionStore', () => ({ useExecutionStore: () => executionStore @@ -37,6 +45,8 @@ describe('useBrowserTabTitle', () => { executionStore.executionProgress = 0 executionStore.executingNode = null as any executionStore.executingNodeProgress = 0 + executionStore.nodeProgressStates = {} + executionStore.activePrompt = null // reset setting and workflow stores ;(settingStore.get as any).mockReturnValue('Enabled') @@ -97,13 +107,41 @@ describe('useBrowserTabTitle', () => { expect(document.title).toBe('[30%]ComfyUI') }) - it('shows node execution title when executing a node', async () => { + it('shows node execution title when executing a node using nodeProgressStates', async () => { executionStore.isIdle = false executionStore.executionProgress = 0.4 - executionStore.executingNodeProgress = 0.5 - executionStore.executingNode = { type: 'Foo' } + executionStore.nodeProgressStates = { + '1': { state: 'running', value: 5, max: 10, node: '1', prompt_id: 'test' } + } + executionStore.activePrompt = { + workflow: { + changeTracker: { + activeState: { + nodes: [{ id: 1, type: 'Foo' }] + } + } + } + } useBrowserTabTitle() await nextTick() expect(document.title).toBe('[40%][50%] Foo') }) + + it('shows multiple nodes running when multiple nodes are executing', async () => { + executionStore.isIdle = false + executionStore.executionProgress = 0.4 + executionStore.nodeProgressStates = { + '1': { + state: 'running', + value: 5, + max: 10, + node: '1', + prompt_id: 'test' + }, + '2': { state: 'running', value: 8, max: 10, node: '2', prompt_id: 'test' } + } + useBrowserTabTitle() + await nextTick() + expect(document.title).toBe('[40%][2 nodes running]') + }) }) diff --git a/tests-ui/tests/composables/canvas/useCanvasTransformSync.test.ts b/tests-ui/tests/composables/canvas/useCanvasTransformSync.test.ts new file mode 100644 index 000000000..09eb10bca --- /dev/null +++ b/tests-ui/tests/composables/canvas/useCanvasTransformSync.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' + +import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync' + +// Mock canvas store +let mockGetCanvas = vi.fn() +vi.mock('@/stores/graphStore', () => ({ + useCanvasStore: vi.fn(() => ({ + getCanvas: mockGetCanvas + })) +})) + +describe('useCanvasTransformSync', () => { + let mockCanvas: { ds: { scale: number; offset: [number, number] } } + let syncFn: ReturnType + + beforeEach(() => { + mockCanvas = { + ds: { + scale: 1, + offset: [0, 0] + } + } + syncFn = vi.fn() + mockGetCanvas = vi.fn(() => mockCanvas) + vi.clearAllMocks() + }) + + it('should not call syncFn when transform has not changed', async () => { + const { startSync } = useCanvasTransformSync(syncFn, { autoStart: false }) + + startSync() + await nextTick() + + // Should call once initially + expect(syncFn).toHaveBeenCalledTimes(1) + + // Wait for next RAF cycle + await new Promise((resolve) => requestAnimationFrame(resolve)) + + // Should not call again since transform didn't change + expect(syncFn).toHaveBeenCalledTimes(1) + }) + + it('should call syncFn when scale changes', async () => { + const { startSync } = useCanvasTransformSync(syncFn, { autoStart: false }) + + startSync() + await nextTick() + + expect(syncFn).toHaveBeenCalledTimes(1) + + // Change scale + mockCanvas.ds.scale = 2 + + // Wait for next RAF cycle + await new Promise((resolve) => requestAnimationFrame(resolve)) + + expect(syncFn).toHaveBeenCalledTimes(2) + }) + + it('should call syncFn when offset changes', async () => { + const { startSync } = useCanvasTransformSync(syncFn, { autoStart: false }) + + startSync() + await nextTick() + + expect(syncFn).toHaveBeenCalledTimes(1) + + // Change offset + mockCanvas.ds.offset = [10, 20] + + // Wait for next RAF cycles + await new Promise((resolve) => requestAnimationFrame(resolve)) + + expect(syncFn).toHaveBeenCalledTimes(2) + }) + + it('should stop calling syncFn after stopSync is called', async () => { + const { startSync, stopSync } = useCanvasTransformSync(syncFn, { + autoStart: false + }) + + startSync() + await nextTick() + + expect(syncFn).toHaveBeenCalledTimes(1) + + stopSync() + + // Change transform after stopping + mockCanvas.ds.scale = 2 + + // Wait for RAF cycle + await new Promise((resolve) => requestAnimationFrame(resolve)) + + // Should not call again + expect(syncFn).toHaveBeenCalledTimes(1) + }) + + it('should handle null canvas gracefully', async () => { + mockGetCanvas.mockReturnValue(null) + const { startSync } = useCanvasTransformSync(syncFn, { autoStart: false }) + + startSync() + await nextTick() + + // Should not call syncFn with null canvas + expect(syncFn).not.toHaveBeenCalled() + }) + + it('should call onStart and onStop callbacks', () => { + const onStart = vi.fn() + const onStop = vi.fn() + + const { startSync, stopSync } = useCanvasTransformSync(syncFn, { + autoStart: false, + onStart, + onStop + }) + + startSync() + expect(onStart).toHaveBeenCalledTimes(1) + + stopSync() + expect(onStop).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests-ui/tests/composables/graph/useCanvasInteractions.test.ts b/tests-ui/tests/composables/graph/useCanvasInteractions.test.ts new file mode 100644 index 000000000..589b3d171 --- /dev/null +++ b/tests-ui/tests/composables/graph/useCanvasInteractions.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions' +import { app } from '@/scripts/app' +import * as settingStore from '@/stores/settingStore' + +// Mock the app and canvas +vi.mock('@/scripts/app', () => ({ + app: { + canvas: { + canvas: null as HTMLCanvasElement | null + } + } +})) + +// Mock the setting store +vi.mock('@/stores/settingStore', () => ({ + useSettingStore: vi.fn() +})) + +describe('useCanvasInteractions', () => { + let mockCanvas: HTMLCanvasElement + let mockSettingStore: { get: ReturnType } + let canvasInteractions: ReturnType + + beforeEach(() => { + // Clear mocks + vi.clearAllMocks() + + // Create mock canvas element + mockCanvas = document.createElement('canvas') + mockCanvas.dispatchEvent = vi.fn() + app.canvas!.canvas = mockCanvas + + // Mock setting store + mockSettingStore = { get: vi.fn() } + vi.mocked(settingStore.useSettingStore).mockReturnValue( + mockSettingStore as any + ) + + canvasInteractions = useCanvasInteractions() + }) + + describe('handleWheel', () => { + it('should check navigation mode from settings', () => { + mockSettingStore.get.mockReturnValue('standard') + + const wheelEvent = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: -100 + }) + + canvasInteractions.handleWheel(wheelEvent) + + expect(mockSettingStore.get).toHaveBeenCalledWith( + 'Comfy.Canvas.NavigationMode' + ) + }) + + it('should not forward regular wheel events in standard mode', () => { + mockSettingStore.get.mockReturnValue('standard') + + const wheelEvent = new WheelEvent('wheel', { + deltaY: -100 + }) + + canvasInteractions.handleWheel(wheelEvent) + + expect(mockCanvas.dispatchEvent).not.toHaveBeenCalled() + }) + + it('should forward all wheel events to canvas in legacy mode', () => { + mockSettingStore.get.mockReturnValue('legacy') + + const wheelEvent = new WheelEvent('wheel', { + deltaY: -100, + cancelable: true + }) + + canvasInteractions.handleWheel(wheelEvent) + + expect(mockCanvas.dispatchEvent).toHaveBeenCalled() + }) + + it('should handle missing canvas gracefully', () => { + ;(app.canvas as any).canvas = null + mockSettingStore.get.mockReturnValue('standard') + + const wheelEvent = new WheelEvent('wheel', { + ctrlKey: true, + deltaY: -100 + }) + + expect(() => { + canvasInteractions.handleWheel(wheelEvent) + }).not.toThrow() + }) + }) + + describe('forwardEventToCanvas', () => { + it('should dispatch event to canvas element', () => { + const wheelEvent = new WheelEvent('wheel', { + deltaY: -100, + ctrlKey: true + }) + + canvasInteractions.forwardEventToCanvas(wheelEvent) + + expect(mockCanvas.dispatchEvent).toHaveBeenCalledWith( + expect.any(WheelEvent) + ) + }) + + it('should handle missing canvas gracefully', () => { + ;(app.canvas as any).canvas = null + + const wheelEvent = new WheelEvent('wheel', { + deltaY: -100 + }) + + expect(() => { + canvasInteractions.forwardEventToCanvas(wheelEvent) + }).not.toThrow() + }) + }) +}) diff --git a/tests-ui/tests/composables/node/useNodePricing.test.ts b/tests-ui/tests/composables/node/useNodePricing.test.ts index 189ae3034..67f78d7d3 100644 --- a/tests-ui/tests/composables/node/useNodePricing.test.ts +++ b/tests-ui/tests/composables/node/useNodePricing.test.ts @@ -603,7 +603,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.25/Run') + expect(price).toBe('$0.4/Run') }) it('should return range when widgets are missing', () => { @@ -771,14 +771,14 @@ describe('useNodePricing', () => { const { getRelevantWidgetNames } = useNodePricing() const widgetNames = getRelevantWidgetNames('IdeogramV1') - expect(widgetNames).toEqual(['num_images']) + expect(widgetNames).toEqual(['num_images', 'turbo']) }) it('should return correct widget names for IdeogramV2', () => { const { getRelevantWidgetNames } = useNodePricing() const widgetNames = getRelevantWidgetNames('IdeogramV2') - expect(widgetNames).toEqual(['num_images']) + expect(widgetNames).toEqual(['num_images', 'turbo']) }) it('should return correct widget names for IdeogramV3', () => { @@ -832,7 +832,7 @@ describe('useNodePricing', () => { const node = createMockNode('IdeogramV1', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.06 x num_images/Run') + expect(price).toBe('$0.02-0.06 x num_images/Run') }) it('should fall back to static display when num_images widget is missing for IdeogramV2', () => { @@ -840,7 +840,7 @@ describe('useNodePricing', () => { const node = createMockNode('IdeogramV2', []) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.08 x num_images/Run') + expect(price).toBe('$0.05-0.08 x num_images/Run') }) it('should handle edge case when num_images value is 1 for IdeogramV1', () => { @@ -850,7 +850,7 @@ describe('useNodePricing', () => { ]) const price = getNodeDisplayPrice(node) - expect(price).toBe('$0.06/Run') // 0.06 * 1 + expect(price).toBe('$0.06/Run') // 0.06 * 1 (turbo=false by default) }) }) @@ -1022,5 +1022,517 @@ describe('useNodePricing', () => { getRelevantWidgetNames('RecraftGenerateColorFromImageNode') ).toEqual(['n']) }) + + it('should include relevant widget names for new nodes', () => { + const { getRelevantWidgetNames } = useNodePricing() + + expect(getRelevantWidgetNames('RunwayImageToVideoNodeGen3a')).toEqual([ + 'duration' + ]) + expect(getRelevantWidgetNames('RunwayImageToVideoNodeGen4')).toEqual([ + 'duration' + ]) + expect(getRelevantWidgetNames('RunwayFirstLastFrameNode')).toEqual([ + 'duration' + ]) + expect(getRelevantWidgetNames('TripoTextToModelNode')).toEqual([ + 'quad', + 'style', + 'texture', + 'texture_quality' + ]) + expect(getRelevantWidgetNames('TripoImageToModelNode')).toEqual([ + 'quad', + 'style', + 'texture', + 'texture_quality' + ]) + }) + }) + + describe('New API nodes pricing', () => { + describe('RunwayML nodes', () => { + it('should return static price for RunwayTextToImageNode', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('RunwayTextToImageNode') + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.08/Run') + }) + + it('should calculate dynamic pricing for RunwayImageToVideoNodeGen3a', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('RunwayImageToVideoNodeGen3a', [ + { name: 'duration', value: 10 } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.50/Run') // 0.05 * 10 + }) + + it('should return fallback for RunwayImageToVideoNodeGen3a without duration', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('RunwayImageToVideoNodeGen3a', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.05/second') + }) + + it('should handle zero duration for RunwayImageToVideoNodeGen3a', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('RunwayImageToVideoNodeGen3a', [ + { name: 'duration', value: 0 } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.00/Run') // 0.05 * 0 = 0 + }) + + it('should handle NaN duration for RunwayImageToVideoNodeGen3a', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('RunwayImageToVideoNodeGen3a', [ + { name: 'duration', value: 'invalid' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.25/Run') // Falls back to 5 seconds: 0.05 * 5 + }) + }) + + describe('Rodin nodes', () => { + it('should return base price for Rodin3D_Regular', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('Rodin3D_Regular') + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.4/Run') + }) + + it('should return addon price for Rodin3D_Detail', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('Rodin3D_Detail') + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.4/Run') + }) + + it('should return addon price for Rodin3D_Smooth', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('Rodin3D_Smooth') + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.4/Run') + }) + }) + + describe('Tripo nodes', () => { + it('should return v2.5 standard pricing for TripoTextToModelNode', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('TripoTextToModelNode', [ + { name: 'quad', value: false }, + { name: 'style', value: 'any style' }, + { name: 'texture', value: false }, + { name: 'texture_quality', value: 'standard' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.15/Run') // any style, no quad, no texture + }) + + it('should return v2.5 detailed pricing for TripoTextToModelNode', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('TripoTextToModelNode', [ + { name: 'quad', value: true }, + { name: 'style', value: 'any style' }, + { name: 'texture', value: false }, + { name: 'texture_quality', value: 'detailed' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.35/Run') // any style, quad, no texture, detailed + }) + + it('should return v2.0 detailed pricing for TripoImageToModelNode', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('TripoImageToModelNode', [ + { name: 'quad', value: true }, + { name: 'style', value: 'any style' }, + { name: 'texture', value: false }, + { name: 'texture_quality', value: 'detailed' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.45/Run') // any style, quad, no texture, detailed + }) + + it('should return legacy pricing for TripoTextToModelNode', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('TripoTextToModelNode', [ + { name: 'quad', value: false }, + { name: 'style', value: 'none' }, + { name: 'texture', value: false }, + { name: 'texture_quality', value: 'standard' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.10/Run') // none style, no quad, no texture + }) + + it('should return static price for TripoRefineNode', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('TripoRefineNode') + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.3/Run') + }) + + it('should return fallback for TripoTextToModelNode without model', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('TripoTextToModelNode', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe( + '$0.1-0.4/Run (varies with quad, style, texture & quality)' + ) + }) + + it('should return texture-based pricing for TripoTextureNode', () => { + const { getNodeDisplayPrice } = useNodePricing() + const standardNode = createMockNode('TripoTextureNode', [ + { name: 'texture_quality', value: 'standard' } + ]) + const detailedNode = createMockNode('TripoTextureNode', [ + { name: 'texture_quality', value: 'detailed' } + ]) + + expect(getNodeDisplayPrice(standardNode)).toBe('$0.1/Run') + expect(getNodeDisplayPrice(detailedNode)).toBe('$0.2/Run') + }) + + it('should handle various Tripo parameter combinations', () => { + const { getNodeDisplayPrice } = useNodePricing() + + // Test different parameter combinations + const testCases = [ + { quad: false, style: 'none', texture: false, expected: '$0.10/Run' }, + { + quad: false, + style: 'any style', + texture: false, + expected: '$0.15/Run' + }, + { quad: true, style: 'none', texture: false, expected: '$0.20/Run' }, + { + quad: true, + style: 'any style', + texture: false, + expected: '$0.25/Run' + } + ] + + testCases.forEach(({ quad, style, texture, expected }) => { + const node = createMockNode('TripoTextToModelNode', [ + { name: 'quad', value: quad }, + { name: 'style', value: style }, + { name: 'texture', value: texture }, + { name: 'texture_quality', value: 'standard' } + ]) + expect(getNodeDisplayPrice(node)).toBe(expected) + }) + }) + + it('should return static price for TripoConvertModelNode', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('TripoConvertModelNode') + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.10/Run') + }) + + it('should return static price for TripoRetargetRiggedModelNode', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('TripoRetargetRiggedModelNode') + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.10/Run') + }) + + it('should return dynamic pricing for TripoMultiviewToModelNode', () => { + const { getNodeDisplayPrice } = useNodePricing() + + // Test basic case - no style, no quad, no texture + const basicNode = createMockNode('TripoMultiviewToModelNode', [ + { name: 'quad', value: false }, + { name: 'style', value: 'none' }, + { name: 'texture', value: false }, + { name: 'texture_quality', value: 'standard' } + ]) + expect(getNodeDisplayPrice(basicNode)).toBe('$0.20/Run') + + // Test high-end case - any style, quad, texture, detailed + const highEndNode = createMockNode('TripoMultiviewToModelNode', [ + { name: 'quad', value: true }, + { name: 'style', value: 'stylized' }, + { name: 'texture', value: true }, + { name: 'texture_quality', value: 'detailed' } + ]) + expect(getNodeDisplayPrice(highEndNode)).toBe('$0.50/Run') + }) + + it('should return fallback for TripoMultiviewToModelNode without widgets', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('TripoMultiviewToModelNode', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe( + '$0.2-0.5/Run (varies with quad, style, texture & quality)' + ) + }) + }) + + describe('Gemini and OpenAI Chat nodes', () => { + it('should return specific pricing for supported Gemini models', () => { + const { getNodeDisplayPrice } = useNodePricing() + + const testCases = [ + { + model: 'gemini-2.5-pro-preview-05-06', + expected: '$0.00016/$0.0006 per 1K tokens' + }, + { + model: 'gemini-2.5-flash-preview-04-17', + expected: '$0.00125/$0.01 per 1K tokens' + }, + { model: 'unknown-gemini-model', expected: 'Token-based' } + ] + + testCases.forEach(({ model, expected }) => { + const node = createMockNode('GeminiNode', [ + { name: 'model', value: model } + ]) + expect(getNodeDisplayPrice(node)).toBe(expected) + }) + }) + + it('should return per-second pricing for Gemini Veo models', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('GeminiNode', [ + { name: 'model', value: 'veo-2.0-generate-001' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('$0.5/second') + }) + + it('should return fallback for GeminiNode without model widget', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('GeminiNode', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('Token-based') + }) + + it('should return token-based pricing for OpenAIChatNode', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('OpenAIChatNode', [ + { name: 'model', value: 'unknown-model' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('Token-based') + }) + + it('should return correct pricing for all exposed OpenAI models', () => { + const { getNodeDisplayPrice } = useNodePricing() + + const testCases = [ + { model: 'o4-mini', expected: '$0.0011/$0.0044 per 1K tokens' }, + { model: 'o1-pro', expected: '$0.15/$0.60 per 1K tokens' }, + { model: 'o1', expected: '$0.015/$0.06 per 1K tokens' }, + { model: 'o3-mini', expected: '$0.0011/$0.0044 per 1K tokens' }, + { model: 'o3', expected: '$0.01/$0.04 per 1K tokens' }, + { model: 'gpt-4o', expected: '$0.0025/$0.01 per 1K tokens' }, + { model: 'gpt-4.1-nano', expected: '$0.0001/$0.0004 per 1K tokens' }, + { model: 'gpt-4.1-mini', expected: '$0.0004/$0.0016 per 1K tokens' }, + { model: 'gpt-4.1', expected: '$0.002/$0.008 per 1K tokens' } + ] + + testCases.forEach(({ model, expected }) => { + const node = createMockNode('OpenAIChatNode', [ + { name: 'model', value: model } + ]) + expect(getNodeDisplayPrice(node)).toBe(expected) + }) + }) + + it('should handle model ordering correctly (specific before general)', () => { + const { getNodeDisplayPrice } = useNodePricing() + + // Test that more specific patterns are matched before general ones + const testCases = [ + { + model: 'gpt-4.1-nano-test', + expected: '$0.0001/$0.0004 per 1K tokens' + }, + { + model: 'gpt-4.1-mini-test', + expected: '$0.0004/$0.0016 per 1K tokens' + }, + { model: 'gpt-4.1-test', expected: '$0.002/$0.008 per 1K tokens' }, + { model: 'o1-pro-test', expected: '$0.15/$0.60 per 1K tokens' }, + { model: 'o1-test', expected: '$0.015/$0.06 per 1K tokens' }, + { model: 'o3-mini-test', expected: '$0.0011/$0.0044 per 1K tokens' }, + { model: 'unknown-model', expected: 'Token-based' } + ] + + testCases.forEach(({ model, expected }) => { + const node = createMockNode('OpenAIChatNode', [ + { name: 'model', value: model } + ]) + expect(getNodeDisplayPrice(node)).toBe(expected) + }) + }) + + it('should return fallback for OpenAIChatNode without model widget', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('OpenAIChatNode', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe('Token-based') + }) + }) + + describe('Additional RunwayML edge cases', () => { + it('should handle edge cases for RunwayML duration-based pricing', () => { + const { getNodeDisplayPrice } = useNodePricing() + + // Test edge cases + const testCases = [ + { duration: 0, expected: '$0.00/Run' }, // Now correctly handles 0 duration + { duration: 1, expected: '$0.05/Run' }, + { duration: 30, expected: '$1.50/Run' } + ] + + testCases.forEach(({ duration, expected }) => { + const node = createMockNode('RunwayImageToVideoNodeGen3a', [ + { name: 'duration', value: duration } + ]) + expect(getNodeDisplayPrice(node)).toBe(expected) + }) + }) + + it('should handle invalid duration values gracefully', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('RunwayImageToVideoNodeGen3a', [ + { name: 'duration', value: 'invalid-string' } + ]) + // When Number('invalid-string') returns NaN, it falls back to 5 seconds + expect(getNodeDisplayPrice(node)).toBe('$0.25/Run') + }) + + it('should handle missing duration widget gracefully', () => { + const { getNodeDisplayPrice } = useNodePricing() + const nodes = [ + 'RunwayImageToVideoNodeGen3a', + 'RunwayImageToVideoNodeGen4', + 'RunwayFirstLastFrameNode' + ] + + nodes.forEach((nodeType) => { + const node = createMockNode(nodeType, []) + expect(getNodeDisplayPrice(node)).toBe('$0.05/second') + }) + }) + }) + + describe('Complete Rodin node coverage', () => { + it('should return correct pricing for all Rodin variants', () => { + const { getNodeDisplayPrice } = useNodePricing() + + const testCases = [ + { nodeType: 'Rodin3D_Regular', expected: '$0.4/Run' }, + { nodeType: 'Rodin3D_Sketch', expected: '$0.4/Run' }, + { nodeType: 'Rodin3D_Detail', expected: '$0.4/Run' }, + { nodeType: 'Rodin3D_Smooth', expected: '$0.4/Run' } + ] + + testCases.forEach(({ nodeType, expected }) => { + const node = createMockNode(nodeType) + expect(getNodeDisplayPrice(node)).toBe(expected) + }) + }) + }) + + describe('Comprehensive Tripo edge case testing', () => { + it('should handle TripoImageToModelNode with various parameter combinations', () => { + const { getNodeDisplayPrice } = useNodePricing() + + const testCases = [ + { quad: false, style: 'none', texture: false, expected: '$0.20/Run' }, + { quad: false, style: 'none', texture: true, expected: '$0.25/Run' }, + { + quad: true, + style: 'any style', + texture: true, + textureQuality: 'detailed', + expected: '$0.50/Run' + }, + { + quad: true, + style: 'any style', + texture: false, + textureQuality: 'standard', + expected: '$0.35/Run' + } + ] + + testCases.forEach( + ({ quad, style, texture, textureQuality, expected }) => { + const widgets = [ + { name: 'quad', value: quad }, + { name: 'style', value: style }, + { name: 'texture', value: texture } + ] + if (textureQuality) { + widgets.push({ name: 'texture_quality', value: textureQuality }) + } + const node = createMockNode('TripoImageToModelNode', widgets) + expect(getNodeDisplayPrice(node)).toBe(expected) + } + ) + }) + + it('should return correct fallback for TripoImageToModelNode', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('TripoImageToModelNode', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe( + '$0.2-0.5/Run (varies with quad, style, texture & quality)' + ) + }) + + it('should handle missing texture quality widget', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('TripoTextToModelNode', []) + + const price = getNodeDisplayPrice(node) + expect(price).toBe( + '$0.1-0.4/Run (varies with quad, style, texture & quality)' + ) + }) + + it('should handle missing model version widget', () => { + const { getNodeDisplayPrice } = useNodePricing() + const node = createMockNode('TripoTextToModelNode', [ + { name: 'texture_quality', value: 'detailed' } + ]) + + const price = getNodeDisplayPrice(node) + expect(price).toBe( + '$0.1-0.4/Run (varies with quad, style, texture & quality)' + ) + }) + }) }) }) diff --git a/tests-ui/tests/composables/useCoreCommands.test.ts b/tests-ui/tests/composables/useCoreCommands.test.ts new file mode 100644 index 000000000..a9e801858 --- /dev/null +++ b/tests-ui/tests/composables/useCoreCommands.test.ts @@ -0,0 +1,187 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useCoreCommands } from '@/composables/useCoreCommands' +import { api } from '@/scripts/api' +import { app } from '@/scripts/app' +import { useSettingStore } from '@/stores/settingStore' + +vi.mock('@/scripts/app', () => ({ + app: { + clean: vi.fn(), + canvas: { + subgraph: null + }, + graph: { + clear: vi.fn() + } + } +})) + +vi.mock('@/scripts/api', () => ({ + api: { + dispatchCustomEvent: vi.fn(), + apiURL: vi.fn(() => 'http://localhost:8188') + } +})) + +vi.mock('@/stores/settingStore') + +vi.mock('@/stores/firebaseAuthStore', () => ({ + useFirebaseAuthStore: vi.fn(() => ({})) +})) + +vi.mock('@/composables/auth/useFirebaseAuth', () => ({ + useFirebaseAuth: vi.fn(() => null) +})) + +vi.mock('firebase/auth', () => ({ + setPersistence: vi.fn(), + browserLocalPersistence: {}, + onAuthStateChanged: vi.fn() +})) + +vi.mock('@/services/workflowService', () => ({ + useWorkflowService: vi.fn(() => ({})) +})) + +vi.mock('@/services/dialogService', () => ({ + useDialogService: vi.fn(() => ({})) +})) + +vi.mock('@/services/litegraphService', () => ({ + useLitegraphService: vi.fn(() => ({})) +})) + +vi.mock('@/stores/executionStore', () => ({ + useExecutionStore: vi.fn(() => ({})) +})) + +vi.mock('@/stores/toastStore', () => ({ + useToastStore: vi.fn(() => ({})) +})) + +vi.mock('@/stores/workflowStore', () => ({ + useWorkflowStore: vi.fn(() => ({})) +})) + +vi.mock('@/stores/workspace/colorPaletteStore', () => ({ + useColorPaletteStore: vi.fn(() => ({})) +})) + +vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({ + useFirebaseAuthActions: vi.fn(() => ({})) +})) + +describe('useCoreCommands', () => { + const mockSubgraph = { + nodes: [ + // Mock input node + { + constructor: { comfyClass: 'SubgraphInputNode' }, + id: 'input1' + }, + // Mock output node + { + constructor: { comfyClass: 'SubgraphOutputNode' }, + id: 'output1' + }, + // Mock user node + { + constructor: { comfyClass: 'SomeUserNode' }, + id: 'user1' + }, + // Another mock user node + { + constructor: { comfyClass: 'AnotherUserNode' }, + id: 'user2' + } + ], + remove: vi.fn() + } + + beforeEach(() => { + vi.clearAllMocks() + + // Set up Pinia + setActivePinia(createPinia()) + + // Reset app state + app.canvas.subgraph = undefined + + // Mock settings store + vi.mocked(useSettingStore).mockReturnValue({ + get: vi.fn().mockReturnValue(false) // Skip confirmation dialog + } as any) + + // Mock global confirm + global.confirm = vi.fn().mockReturnValue(true) + }) + + describe('ClearWorkflow command', () => { + it('should clear main graph when not in subgraph', async () => { + const commands = useCoreCommands() + const clearCommand = commands.find( + (cmd) => cmd.id === 'Comfy.ClearWorkflow' + )! + + // Execute the command + await clearCommand.function() + + expect(app.clean).toHaveBeenCalled() + expect(app.graph.clear).toHaveBeenCalled() + expect(api.dispatchCustomEvent).toHaveBeenCalledWith('graphCleared') + }) + + it('should preserve input/output nodes when clearing subgraph', async () => { + // Set up subgraph context + app.canvas.subgraph = mockSubgraph as any + + const commands = useCoreCommands() + const clearCommand = commands.find( + (cmd) => cmd.id === 'Comfy.ClearWorkflow' + )! + + // Execute the command + await clearCommand.function() + + expect(app.clean).toHaveBeenCalled() + expect(app.graph.clear).not.toHaveBeenCalled() + + // Should only remove user nodes, not input/output nodes + expect(mockSubgraph.remove).toHaveBeenCalledTimes(2) + expect(mockSubgraph.remove).toHaveBeenCalledWith(mockSubgraph.nodes[2]) // user1 + expect(mockSubgraph.remove).toHaveBeenCalledWith(mockSubgraph.nodes[3]) // user2 + expect(mockSubgraph.remove).not.toHaveBeenCalledWith( + mockSubgraph.nodes[0] + ) // input1 + expect(mockSubgraph.remove).not.toHaveBeenCalledWith( + mockSubgraph.nodes[1] + ) // output1 + + expect(api.dispatchCustomEvent).toHaveBeenCalledWith('graphCleared') + }) + + it('should respect confirmation setting', async () => { + // Mock confirmation required + vi.mocked(useSettingStore).mockReturnValue({ + get: vi.fn().mockReturnValue(true) // Require confirmation + } as any) + + global.confirm = vi.fn().mockReturnValue(false) // User cancels + + const commands = useCoreCommands() + const clearCommand = commands.find( + (cmd) => cmd.id === 'Comfy.ClearWorkflow' + )! + + // Execute the command + await clearCommand.function() + + // Should not clear anything when user cancels + expect(app.clean).not.toHaveBeenCalled() + expect(app.graph.clear).not.toHaveBeenCalled() + expect(api.dispatchCustomEvent).not.toHaveBeenCalled() + }) + }) +}) diff --git a/tests-ui/tests/composables/useFrontendVersionMismatchWarning.test.ts b/tests-ui/tests/composables/useFrontendVersionMismatchWarning.test.ts new file mode 100644 index 000000000..b8b4fceac --- /dev/null +++ b/tests-ui/tests/composables/useFrontendVersionMismatchWarning.test.ts @@ -0,0 +1,234 @@ +import { createPinia, setActivePinia } from 'pinia' +import { vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { nextTick } from 'vue' + +import { useFrontendVersionMismatchWarning } from '@/composables/useFrontendVersionMismatchWarning' +import { useToastStore } from '@/stores/toastStore' +import { useVersionCompatibilityStore } from '@/stores/versionCompatibilityStore' + +// Mock globals +//@ts-expect-error Define global for the test +global.__COMFYUI_FRONTEND_VERSION__ = '1.0.0' + +// Mock config first - this needs to be before any imports +vi.mock('@/config', () => ({ + default: { + app_title: 'ComfyUI', + app_version: '1.0.0' + } +})) + +// Mock app +vi.mock('@/scripts/app', () => ({ + app: { + ui: { + settings: { + dispatchChange: vi.fn() + } + } + } +})) + +// Mock api +vi.mock('@/scripts/api', () => ({ + api: { + getSettings: vi.fn(() => Promise.resolve({})), + storeSetting: vi.fn(() => Promise.resolve(undefined)) + } +})) + +// Mock vue-i18n +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, params?: any) => { + if (key === 'g.versionMismatchWarning') + return 'Version Compatibility Warning' + if (key === 'g.versionMismatchWarningMessage' && params) { + return `${params.warning}: ${params.detail} Visit https://docs.comfy.org/installation/update_comfyui#common-update-issues for update instructions.` + } + if (key === 'g.frontendOutdated' && params) { + return `Frontend version ${params.frontendVersion} is outdated. Backend requires ${params.requiredVersion} or higher.` + } + if (key === 'g.frontendNewer' && params) { + return `Frontend version ${params.frontendVersion} may not be compatible with backend version ${params.backendVersion}.` + } + return key + } + }), + createI18n: vi.fn(() => ({ + global: { + locale: { value: 'en' }, + t: vi.fn() + } + })) +})) + +// Mock lifecycle hooks to track their calls +const mockOnMounted = vi.fn() +vi.mock('vue', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + onMounted: (fn: () => void) => { + mockOnMounted() + fn() + } + } +}) + +describe('useFrontendVersionMismatchWarning', () => { + beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createPinia()) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should not show warning when there is no version mismatch', () => { + const toastStore = useToastStore() + const versionStore = useVersionCompatibilityStore() + const addAlertSpy = vi.spyOn(toastStore, 'addAlert') + + // Mock no version mismatch + vi.spyOn(versionStore, 'shouldShowWarning', 'get').mockReturnValue(false) + + useFrontendVersionMismatchWarning() + + expect(addAlertSpy).not.toHaveBeenCalled() + }) + + it('should show warning immediately when immediate option is true and there is a mismatch', async () => { + const toastStore = useToastStore() + const versionStore = useVersionCompatibilityStore() + const addAlertSpy = vi.spyOn(toastStore, 'addAlert') + const dismissWarningSpy = vi.spyOn(versionStore, 'dismissWarning') + + // Mock version mismatch + vi.spyOn(versionStore, 'shouldShowWarning', 'get').mockReturnValue(true) + vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue({ + type: 'outdated', + frontendVersion: '1.0.0', + requiredVersion: '2.0.0' + }) + + useFrontendVersionMismatchWarning({ immediate: true }) + + // For immediate: true, the watcher should fire immediately in onMounted + await nextTick() + + expect(addAlertSpy).toHaveBeenCalledWith( + expect.stringContaining('Version Compatibility Warning') + ) + expect(addAlertSpy).toHaveBeenCalledWith( + expect.stringContaining('Frontend version 1.0.0 is outdated') + ) + // Should automatically dismiss the warning + expect(dismissWarningSpy).toHaveBeenCalled() + }) + + it('should not show warning immediately when immediate option is false', async () => { + const toastStore = useToastStore() + const versionStore = useVersionCompatibilityStore() + const addAlertSpy = vi.spyOn(toastStore, 'addAlert') + + // Mock version mismatch + vi.spyOn(versionStore, 'shouldShowWarning', 'get').mockReturnValue(true) + vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue({ + type: 'outdated', + frontendVersion: '1.0.0', + requiredVersion: '2.0.0' + }) + + const result = useFrontendVersionMismatchWarning({ immediate: false }) + await nextTick() + + // Should not show automatically + expect(addAlertSpy).not.toHaveBeenCalled() + + // But should show when called manually + result.showWarning() + expect(addAlertSpy).toHaveBeenCalledOnce() + }) + + it('should call showWarning method manually', () => { + const toastStore = useToastStore() + const versionStore = useVersionCompatibilityStore() + const addAlertSpy = vi.spyOn(toastStore, 'addAlert') + const dismissWarningSpy = vi.spyOn(versionStore, 'dismissWarning') + + vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue({ + type: 'outdated', + frontendVersion: '1.0.0', + requiredVersion: '2.0.0' + }) + + const { showWarning } = useFrontendVersionMismatchWarning() + showWarning() + + expect(addAlertSpy).toHaveBeenCalledOnce() + expect(dismissWarningSpy).toHaveBeenCalled() + }) + + it('should expose store methods and computed values', () => { + const versionStore = useVersionCompatibilityStore() + + const mockDismissWarning = vi.fn() + vi.spyOn(versionStore, 'dismissWarning').mockImplementation( + mockDismissWarning + ) + vi.spyOn(versionStore, 'shouldShowWarning', 'get').mockReturnValue(true) + vi.spyOn(versionStore, 'hasVersionMismatch', 'get').mockReturnValue(true) + + const result = useFrontendVersionMismatchWarning() + + expect(result.shouldShowWarning.value).toBe(true) + expect(result.hasVersionMismatch.value).toBe(true) + + void result.dismissWarning() + expect(mockDismissWarning).toHaveBeenCalled() + }) + + it('should register onMounted hook', () => { + useFrontendVersionMismatchWarning() + + expect(mockOnMounted).toHaveBeenCalledOnce() + }) + + it('should not show warning when warningMessage is null', () => { + const toastStore = useToastStore() + const versionStore = useVersionCompatibilityStore() + const addAlertSpy = vi.spyOn(toastStore, 'addAlert') + + vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue(null) + + const { showWarning } = useFrontendVersionMismatchWarning() + showWarning() + + expect(addAlertSpy).not.toHaveBeenCalled() + }) + + it('should only show warning once even if called multiple times', () => { + const toastStore = useToastStore() + const versionStore = useVersionCompatibilityStore() + const addAlertSpy = vi.spyOn(toastStore, 'addAlert') + + vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue({ + type: 'outdated', + frontendVersion: '1.0.0', + requiredVersion: '2.0.0' + }) + + const { showWarning } = useFrontendVersionMismatchWarning() + + // Call showWarning multiple times + showWarning() + showWarning() + showWarning() + + // Should only have been called once + expect(addAlertSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests-ui/tests/composables/useMinimap.test.ts b/tests-ui/tests/composables/useMinimap.test.ts new file mode 100644 index 000000000..800b87b0e --- /dev/null +++ b/tests-ui/tests/composables/useMinimap.test.ts @@ -0,0 +1,815 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) + +const mockPause = vi.fn() +const mockResume = vi.fn() + +vi.mock('@vueuse/core', () => { + const callbacks: Record void> = {} + let callbackId = 0 + + return { + useRafFn: vi.fn((callback, options) => { + const id = callbackId++ + callbacks[id] = callback + + if (options?.immediate !== false) { + void Promise.resolve().then(() => callback()) + } + + return { + pause: mockPause, + resume: vi.fn(() => { + mockResume() + void Promise.resolve().then(() => callbacks[id]?.()) + }) + } + }), + useThrottleFn: vi.fn((callback) => { + return (...args: any[]) => { + return callback(...args) + } + }) + } +}) + +let mockCanvas: any +let mockGraph: any + +const setupMocks = () => { + const mockNodes = [ + { + id: 'node1', + pos: [0, 0], + size: [100, 50], + color: '#ff0000', + constructor: { color: '#666' }, + outputs: [ + { + links: ['link1'] + } + ] + }, + { + id: 'node2', + pos: [200, 100], + size: [150, 75], + constructor: { color: '#666' }, + outputs: [] + } + ] + + mockGraph = { + _nodes: mockNodes, + links: { + link1: { + id: 'link1', + target_id: 'node2' + } + }, + getNodeById: vi.fn((id) => mockNodes.find((n) => n.id === id)), + setDirtyCanvas: vi.fn(), + onNodeAdded: null, + onNodeRemoved: null, + onConnectionChange: null + } + + mockCanvas = { + graph: mockGraph, + canvas: { + width: 1000, + height: 800, + clientWidth: 1000, + clientHeight: 800 + }, + ds: { + scale: 1, + offset: [0, 0] + }, + setDirty: vi.fn() + } +} + +setupMocks() + +const defaultCanvasStore = { + canvas: mockCanvas, + getCanvas: () => defaultCanvasStore.canvas +} + +const defaultSettingStore = { + get: vi.fn().mockReturnValue(true), + set: vi.fn().mockResolvedValue(undefined) +} + +vi.mock('@/stores/graphStore', () => ({ + useCanvasStore: vi.fn(() => defaultCanvasStore) +})) + +vi.mock('@/stores/settingStore', () => ({ + useSettingStore: vi.fn(() => defaultSettingStore) +})) + +vi.mock('@/scripts/api', () => ({ + api: { + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } +})) + +const { useMinimap } = await import('@/composables/useMinimap') +const { api } = await import('@/scripts/api') + +describe('useMinimap', () => { + let mockCanvas: any + let mockGraph: any + let mockCanvasElement: any + let mockContainerElement: any + let mockContext2D: any + + const createAndInitializeMinimap = async () => { + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + await minimap.init() + await nextTick() + await flushPromises() + return minimap + } + + beforeEach(() => { + vi.clearAllMocks() + + mockPause.mockClear() + mockResume.mockClear() + + mockContext2D = { + clearRect: vi.fn(), + fillRect: vi.fn(), + strokeRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + arc: vi.fn(), + fill: vi.fn(), + fillStyle: '', + strokeStyle: '', + lineWidth: 0 + } + + mockCanvasElement = { + getContext: vi.fn().mockReturnValue(mockContext2D), + width: 250, + height: 200, + clientWidth: 250, + clientHeight: 200 + } + + const mockRect = { + left: 100, + top: 100, + width: 250, + height: 200, + right: 350, + bottom: 300, + x: 100, + y: 100 + } + + mockContainerElement = { + getBoundingClientRect: vi.fn(() => ({ ...mockRect })) + } + + const mockNodes = [ + { + id: 'node1', + pos: [0, 0], + size: [100, 50], + color: '#ff0000', + constructor: { color: '#666' }, + outputs: [ + { + links: ['link1'] + } + ] + }, + { + id: 'node2', + pos: [200, 100], + size: [150, 75], + constructor: { color: '#666' }, + outputs: [] + } + ] + + mockGraph = { + _nodes: mockNodes, + links: { + link1: { + id: 'link1', + target_id: 'node2' + } + }, + getNodeById: vi.fn((id) => mockNodes.find((n) => n.id === id)), + setDirtyCanvas: vi.fn(), + onNodeAdded: null, + onNodeRemoved: null, + onConnectionChange: null + } + + mockCanvas = { + graph: mockGraph, + canvas: { + width: 1000, + height: 800, + clientWidth: 1000, + clientHeight: 800 + }, + ds: { + scale: 1, + offset: [0, 0] + }, + setDirty: vi.fn() + } + + defaultCanvasStore.canvas = mockCanvas + + defaultSettingStore.get = vi.fn().mockReturnValue(true) + defaultSettingStore.set = vi.fn().mockResolvedValue(undefined) + + Object.defineProperty(window, 'devicePixelRatio', { + writable: true, + configurable: true, + value: 1 + }) + + window.addEventListener = vi.fn() + window.removeEventListener = vi.fn() + }) + + describe('initialization', () => { + it('should initialize with default values', () => { + const originalCanvas = defaultCanvasStore.canvas + defaultCanvasStore.canvas = null + + const minimap = useMinimap() + + expect(minimap.width).toBe(250) + expect(minimap.height).toBe(200) + expect(minimap.visible.value).toBe(true) + expect(minimap.initialized.value).toBe(false) + + defaultCanvasStore.canvas = originalCanvas + }) + + it('should initialize minimap when canvas is available', async () => { + const minimap = useMinimap() + + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + + expect(minimap.initialized.value).toBe(true) + expect(defaultSettingStore.get).toHaveBeenCalledWith( + 'Comfy.Minimap.Visible' + ) + expect(api.addEventListener).toHaveBeenCalledWith( + 'graphChanged', + expect.any(Function) + ) + + if (minimap.visible.value) { + expect(mockResume).toHaveBeenCalled() + } + }) + + it('should not initialize without canvas and graph', async () => { + const originalCanvas = defaultCanvasStore.canvas + defaultCanvasStore.canvas = null + + const minimap = useMinimap() + await minimap.init() + + expect(minimap.initialized.value).toBe(false) + expect(api.addEventListener).not.toHaveBeenCalled() + + defaultCanvasStore.canvas = originalCanvas + }) + + it('should setup event listeners on graph', async () => { + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + + expect(mockGraph.onNodeAdded).toBeDefined() + expect(mockGraph.onNodeRemoved).toBeDefined() + expect(mockGraph.onConnectionChange).toBeDefined() + }) + + it('should handle visibility from settings', async () => { + defaultSettingStore.get.mockReturnValue(false) + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + + expect(minimap.visible.value).toBe(false) + expect(mockResume).not.toHaveBeenCalled() + }) + }) + + describe('destroy', () => { + it('should cleanup all resources', async () => { + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + minimap.destroy() + + expect(mockPause).toHaveBeenCalled() + expect(api.removeEventListener).toHaveBeenCalledWith( + 'graphChanged', + expect.any(Function) + ) + expect(window.removeEventListener).toHaveBeenCalled() + expect(minimap.initialized.value).toBe(false) + }) + + it('should restore original graph callbacks', async () => { + const originalCallbacks = { + onNodeAdded: vi.fn(), + onNodeRemoved: vi.fn(), + onConnectionChange: vi.fn() + } + + mockGraph.onNodeAdded = originalCallbacks.onNodeAdded + mockGraph.onNodeRemoved = originalCallbacks.onNodeRemoved + mockGraph.onConnectionChange = originalCallbacks.onConnectionChange + + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + minimap.destroy() + + expect(mockGraph.onNodeAdded).toBe(originalCallbacks.onNodeAdded) + expect(mockGraph.onNodeRemoved).toBe(originalCallbacks.onNodeRemoved) + expect(mockGraph.onConnectionChange).toBe( + originalCallbacks.onConnectionChange + ) + }) + }) + + describe('toggle', () => { + it('should toggle visibility and save to settings', async () => { + const minimap = useMinimap() + const initialVisibility = minimap.visible.value + + await minimap.toggle() + + expect(minimap.visible.value).toBe(!initialVisibility) + expect(defaultSettingStore.set).toHaveBeenCalledWith( + 'Comfy.Minimap.Visible', + !initialVisibility + ) + + await minimap.toggle() + + expect(minimap.visible.value).toBe(initialVisibility) + expect(defaultSettingStore.set).toHaveBeenCalledWith( + 'Comfy.Minimap.Visible', + initialVisibility + ) + }) + }) + + describe('rendering', () => { + it('should verify context is obtained during render', async () => { + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + const getContextSpy = vi.spyOn(mockCanvasElement, 'getContext') + + await minimap.init() + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(getContextSpy).toHaveBeenCalled() + expect(getContextSpy).toHaveBeenCalledWith('2d') + }) + + it('should render at least once after initialization', async () => { + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const renderingOccurred = + mockContext2D.clearRect.mock.calls.length > 0 || + mockContext2D.fillRect.mock.calls.length > 0 + + if (!renderingOccurred) { + console.log('Minimap visible:', minimap.visible.value) + console.log('Minimap initialized:', minimap.initialized.value) + console.log('Canvas exists:', !!defaultCanvasStore.canvas) + console.log('Graph exists:', !!defaultCanvasStore.canvas?.graph) + } + + expect(renderingOccurred).toBe(true) + }) + + it('should not render when context is null', async () => { + mockCanvasElement.getContext = vi.fn().mockReturnValue(null) + + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(mockContext2D.clearRect).not.toHaveBeenCalled() + + mockCanvasElement.getContext = vi.fn().mockReturnValue(mockContext2D) + }) + + it('should handle empty graph', async () => { + const originalNodes = [...mockGraph._nodes] + mockGraph._nodes = [] + + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(minimap.initialized.value).toBe(true) + + expect(mockContext2D.fillRect).not.toHaveBeenCalled() + + mockGraph._nodes = originalNodes + }) + }) + + describe('mouse interactions', () => { + it('should handle mouse down and start dragging', async () => { + const minimap = await createAndInitializeMinimap() + + const mouseEvent = new MouseEvent('mousedown', { + clientX: 150, + clientY: 150 + }) + + minimap.handleMouseDown(mouseEvent) + + expect(mockContainerElement.getBoundingClientRect).toHaveBeenCalled() + expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true) + }) + + it('should handle mouse move while dragging', async () => { + const minimap = await createAndInitializeMinimap() + + const mouseDownEvent = new MouseEvent('mousedown', { + clientX: 150, + clientY: 150 + }) + minimap.handleMouseDown(mouseDownEvent) + + const mouseMoveEvent = new MouseEvent('mousemove', { + clientX: 200, + clientY: 200 + }) + minimap.handleMouseMove(mouseMoveEvent) + + expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true) + expect(mockCanvas.ds.offset).toBeDefined() + }) + + it('should not move when not dragging', async () => { + const minimap = await createAndInitializeMinimap() + + mockCanvas.setDirty.mockClear() + + const mouseMoveEvent = new MouseEvent('mousemove', { + clientX: 200, + clientY: 200 + }) + minimap.handleMouseMove(mouseMoveEvent) + + expect(mockCanvas.setDirty).not.toHaveBeenCalled() + }) + + it('should handle mouse up to stop dragging', async () => { + const minimap = await createAndInitializeMinimap() + + const mouseDownEvent = new MouseEvent('mousedown', { + clientX: 150, + clientY: 150 + }) + minimap.handleMouseDown(mouseDownEvent) + + minimap.handleMouseUp() + + mockCanvas.setDirty.mockClear() + + const mouseMoveEvent = new MouseEvent('mousemove', { + clientX: 200, + clientY: 200 + }) + minimap.handleMouseMove(mouseMoveEvent) + + expect(mockCanvas.setDirty).not.toHaveBeenCalled() + }) + }) + + describe('wheel interactions', () => { + it('should handle wheel zoom in', async () => { + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + + const wheelEvent = new WheelEvent('wheel', { + deltaY: -100, + clientX: 150, + clientY: 150 + }) + + const preventDefault = vi.fn() + Object.defineProperty(wheelEvent, 'preventDefault', { + value: preventDefault + }) + + minimap.handleWheel(wheelEvent) + + expect(preventDefault).toHaveBeenCalled() + expect(mockCanvas.ds.scale).toBeCloseTo(1.1) + expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true) + }) + + it('should handle wheel zoom out', async () => { + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + + const wheelEvent = new WheelEvent('wheel', { + deltaY: 100, + clientX: 150, + clientY: 150 + }) + + const preventDefault = vi.fn() + Object.defineProperty(wheelEvent, 'preventDefault', { + value: preventDefault + }) + + minimap.handleWheel(wheelEvent) + + expect(preventDefault).toHaveBeenCalled() + expect(mockCanvas.ds.scale).toBeCloseTo(0.9) + expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true) + }) + + it('should respect zoom limits', async () => { + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + + mockCanvas.ds.scale = 0.1 + + const wheelEvent = new WheelEvent('wheel', { + deltaY: 100, + clientX: 150, + clientY: 150 + }) + + const preventDefault = vi.fn() + Object.defineProperty(wheelEvent, 'preventDefault', { + value: preventDefault + }) + + minimap.handleWheel(wheelEvent) + + expect(mockCanvas.ds.scale).toBe(0.1) + }) + + it('should update container rect if needed', async () => { + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + + const wheelEvent = new WheelEvent('wheel', { + deltaY: -100, + clientX: 150, + clientY: 150 + }) + + const preventDefault = vi.fn() + Object.defineProperty(wheelEvent, 'preventDefault', { + value: preventDefault + }) + + minimap.handleWheel(wheelEvent) + + expect(mockContainerElement.getBoundingClientRect).toHaveBeenCalled() + }) + }) + + describe('viewport updates', () => { + it('should update viewport transform correctly', async () => { + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + await nextTick() + + const viewportStyles = minimap.viewportStyles.value + + expect(viewportStyles).toBeDefined() + expect(viewportStyles.transform).toMatch( + /translate\(-?\d+(\.\d+)?px, -?\d+(\.\d+)?px\)/ + ) + expect(viewportStyles.width).toMatch(/\d+(\.\d+)?px/) + expect(viewportStyles.height).toMatch(/\d+(\.\d+)?px/) + expect(viewportStyles.border).toBe('2px solid #FFF') + }) + + it('should handle canvas dimension updates', async () => { + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + + mockCanvas.canvas.clientWidth = 1200 + mockCanvas.canvas.clientHeight = 900 + + const resizeHandler = (window.addEventListener as any).mock.calls.find( + (call: any) => call[0] === 'resize' + )?.[1] + + if (resizeHandler) { + resizeHandler() + } + + await nextTick() + + expect(minimap.viewportStyles.value).toBeDefined() + }) + }) + + describe('graph change handling', () => { + it('should handle node addition', async () => { + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + + const newNode = { + id: 'node3', + pos: [300, 200], + size: [100, 100], + constructor: { color: '#666' } + } + + mockGraph._nodes.push(newNode) + if (mockGraph.onNodeAdded) { + mockGraph.onNodeAdded(newNode) + } + + await new Promise((resolve) => setTimeout(resolve, 600)) + }) + + it('should handle node removal', async () => { + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + + const removedNode = mockGraph._nodes[0] + mockGraph._nodes.splice(0, 1) + + if (mockGraph.onNodeRemoved) { + mockGraph.onNodeRemoved(removedNode) + } + + await new Promise((resolve) => setTimeout(resolve, 600)) + }) + + it('should handle connection changes', async () => { + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + + if (mockGraph.onConnectionChange) { + mockGraph.onConnectionChange(mockGraph._nodes[0]) + } + + await new Promise((resolve) => setTimeout(resolve, 600)) + }) + }) + + describe('container styles', () => { + it('should provide correct container styles', () => { + const minimap = useMinimap() + const styles = minimap.containerStyles.value + + expect(styles.width).toBe('250px') + expect(styles.height).toBe('200px') + expect(styles.backgroundColor).toBe('#15161C') + expect(styles.border).toBe('1px solid #333') + expect(styles.borderRadius).toBe('8px') + }) + }) + + describe('edge cases', () => { + it('should handle missing node outputs', async () => { + mockGraph._nodes[0].outputs = null + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await expect(minimap.init()).resolves.not.toThrow() + expect(minimap.initialized.value).toBe(true) + }) + + it('should handle invalid link references', async () => { + mockGraph.links.link1.target_id = 'invalid-node' + mockGraph.getNodeById.mockReturnValue(null) + + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await expect(minimap.init()).resolves.not.toThrow() + expect(minimap.initialized.value).toBe(true) + }) + + it('should handle high DPI displays', async () => { + window.devicePixelRatio = 2 + + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + + expect(minimap.initialized.value).toBe(true) + }) + + it('should handle nodes without color', async () => { + mockGraph._nodes[0].color = undefined + + const minimap = useMinimap() + minimap.containerRef.value = mockContainerElement + minimap.canvasRef.value = mockCanvasElement + + await minimap.init() + + const renderMinimap = (minimap as any).renderMinimap + if (renderMinimap) { + renderMinimap() + } + + expect(mockContext2D.fillRect).toHaveBeenCalled() + expect(mockContext2D.fillStyle).toBeDefined() + }) + }) + + describe('setMinimapRef', () => { + it('should set minimap reference', () => { + const minimap = useMinimap() + const ref = { value: 'test-ref' } + + minimap.setMinimapRef(ref) + + expect(() => minimap.setMinimapRef(ref)).not.toThrow() + }) + }) +}) diff --git a/tests-ui/tests/composables/useMissingNodes.test.ts b/tests-ui/tests/composables/useMissingNodes.test.ts index ec60626ab..9dc0b08e5 100644 --- a/tests-ui/tests/composables/useMissingNodes.test.ts +++ b/tests-ui/tests/composables/useMissingNodes.test.ts @@ -7,6 +7,7 @@ import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks' import { app } from '@/scripts/app' import { useComfyManagerStore } from '@/stores/comfyManagerStore' import { useNodeDefStore } from '@/stores/nodeDefStore' +import { collectAllNodes } from '@/utils/graphTraversalUtil' // Mock Vue's onMounted to execute immediately for testing vi.mock('vue', async () => { @@ -38,9 +39,14 @@ vi.mock('@/scripts/app', () => ({ } })) +vi.mock('@/utils/graphTraversalUtil', () => ({ + collectAllNodes: vi.fn() +})) + const mockUseWorkflowPacks = vi.mocked(useWorkflowPacks) const mockUseComfyManagerStore = vi.mocked(useComfyManagerStore) const mockUseNodeDefStore = vi.mocked(useNodeDefStore) +const mockCollectAllNodes = vi.mocked(collectAllNodes) describe('useMissingNodes', () => { const mockWorkflowPacks = [ @@ -95,6 +101,9 @@ describe('useMissingNodes', () => { // Reset app.graph.nodes // @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing. app.graph.nodes = [] + + // Default mock for collectAllNodes - returns empty array + mockCollectAllNodes.mockReturnValue([]) }) describe('core filtering logic', () => { @@ -286,14 +295,9 @@ describe('useMissingNodes', () => { it('identifies missing core nodes not in nodeDefStore', () => { const coreNode1 = createMockNode('CoreNode1', 'comfy-core', '1.2.0') const coreNode2 = createMockNode('CoreNode2', 'comfy-core', '1.2.0') - const registeredNode = createMockNode( - 'RegisteredNode', - 'comfy-core', - '1.0.0' - ) - // @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing. - app.graph.nodes = [coreNode1, coreNode2, registeredNode] + // Mock collectAllNodes to return only the filtered nodes (missing core nodes) + mockCollectAllNodes.mockReturnValue([coreNode1, coreNode2]) mockUseNodeDefStore.mockReturnValue({ nodeDefsByName: { @@ -316,8 +320,8 @@ describe('useMissingNodes', () => { const node130 = createMockNode('Node130', 'comfy-core', '1.3.0') const nodeNoVer = createMockNode('NodeNoVer', 'comfy-core') - // @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing. - app.graph.nodes = [node120, node130, nodeNoVer] + // Mock collectAllNodes to return these nodes + mockCollectAllNodes.mockReturnValue([node120, node130, nodeNoVer]) // @ts-expect-error - Mocking partial NodeDefStore for testing. mockUseNodeDefStore.mockReturnValue({ @@ -334,11 +338,9 @@ describe('useMissingNodes', () => { it('ignores non-core nodes', () => { const coreNode = createMockNode('CoreNode', 'comfy-core', '1.2.0') - const customNode = createMockNode('CustomNode', 'custom-pack', '1.0.0') - const noPackNode = createMockNode('NoPackNode') - // @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing. - app.graph.nodes = [coreNode, customNode, noPackNode] + // Mock collectAllNodes to return only the filtered nodes (core nodes only) + mockCollectAllNodes.mockReturnValue([coreNode]) // @ts-expect-error - Mocking partial NodeDefStore for testing. mockUseNodeDefStore.mockReturnValue({ @@ -353,19 +355,8 @@ describe('useMissingNodes', () => { }) it('returns empty object when no core nodes are missing', () => { - const registeredNode1 = createMockNode( - 'RegisteredNode1', - 'comfy-core', - '1.0.0' - ) - const registeredNode2 = createMockNode( - 'RegisteredNode2', - 'comfy-core', - '1.1.0' - ) - - // @ts-expect-error - app.graph.nodes is readonly, but we need to modify it for testing. - app.graph.nodes = [registeredNode1, registeredNode2] + // Mock collectAllNodes to return empty array (no missing nodes after filtering) + mockCollectAllNodes.mockReturnValue([]) mockUseNodeDefStore.mockReturnValue({ nodeDefsByName: { @@ -382,4 +373,200 @@ describe('useMissingNodes', () => { expect(Object.keys(missingCoreNodes.value)).toHaveLength(0) }) }) + + describe('subgraph support', () => { + const createMockNode = ( + type: string, + packId?: string, + version?: string + ): LGraphNode => + // @ts-expect-error - Creating a partial mock of LGraphNode for testing. + // We only need specific properties for our tests, not the full LGraphNode interface. + ({ + type, + properties: { cnr_id: packId, ver: version }, + id: 1, + title: type, + pos: [0, 0], + size: [100, 100], + flags: {}, + graph: null, + mode: 0, + inputs: [], + outputs: [] + }) + + it('detects missing core nodes from subgraphs via collectAllNodes', () => { + const mainNode = createMockNode('MainNode', 'comfy-core', '1.0.0') + const subgraphNode1 = createMockNode( + 'SubgraphNode1', + 'comfy-core', + '1.0.0' + ) + const subgraphNode2 = createMockNode( + 'SubgraphNode2', + 'comfy-core', + '1.1.0' + ) + + // Mock collectAllNodes to return all nodes including subgraph nodes + mockCollectAllNodes.mockReturnValue([ + mainNode, + subgraphNode1, + subgraphNode2 + ]) + + // Mock none of the nodes as registered + // @ts-expect-error - Mocking partial NodeDefStore for testing. + mockUseNodeDefStore.mockReturnValue({ + nodeDefsByName: {} + }) + + const { missingCoreNodes } = useMissingNodes() + + // Should detect all 3 nodes as missing + expect(Object.keys(missingCoreNodes.value)).toHaveLength(2) // 2 versions: 1.0.0, 1.1.0 + expect(missingCoreNodes.value['1.0.0']).toHaveLength(2) // MainNode + SubgraphNode1 + expect(missingCoreNodes.value['1.1.0']).toHaveLength(1) // SubgraphNode2 + }) + + it('calls collectAllNodes with the app graph and filter function', () => { + const mockGraph = { nodes: [], subgraphs: new Map() } + // @ts-expect-error - Mocking app.graph for testing + app.graph = mockGraph + + const { missingCoreNodes } = useMissingNodes() + // Access the computed to trigger the function + void missingCoreNodes.value + + expect(mockCollectAllNodes).toHaveBeenCalledWith( + mockGraph, + expect.any(Function) + ) + }) + + it('handles collectAllNodes returning empty array', () => { + mockCollectAllNodes.mockReturnValue([]) + + const { missingCoreNodes } = useMissingNodes() + + expect(Object.keys(missingCoreNodes.value)).toHaveLength(0) + }) + + it('filter function correctly identifies missing core nodes', () => { + const mockGraph = { nodes: [], subgraphs: new Map() } + // @ts-expect-error - Mocking app.graph for testing + app.graph = mockGraph + + mockUseNodeDefStore.mockReturnValue({ + nodeDefsByName: { + // @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing. + RegisteredCore: { name: 'RegisteredCore' } + } + }) + + let capturedFilterFunction: ((node: LGraphNode) => boolean) | undefined + + mockCollectAllNodes.mockImplementation((_graph, filter) => { + capturedFilterFunction = filter + return [] + }) + + const { missingCoreNodes } = useMissingNodes() + void missingCoreNodes.value + + expect(capturedFilterFunction).toBeDefined() + + if (capturedFilterFunction) { + const missingCoreNode = createMockNode( + 'MissingCore', + 'comfy-core', + '1.0.0' + ) + const registeredCoreNode = createMockNode( + 'RegisteredCore', + 'comfy-core', + '1.0.0' + ) + const customNode = createMockNode('CustomNode', 'custom-pack', '1.0.0') + const nodeWithoutPack = createMockNode('NodeWithoutPack') + + expect(capturedFilterFunction(missingCoreNode)).toBe(true) + expect(capturedFilterFunction(registeredCoreNode)).toBe(false) + expect(capturedFilterFunction(customNode)).toBe(false) + expect(capturedFilterFunction(nodeWithoutPack)).toBe(false) + } + }) + + it('integrates with collectAllNodes to find nodes from subgraphs', () => { + mockCollectAllNodes.mockImplementation((graph, filter) => { + const allNodes: LGraphNode[] = [] + + for (const node of graph.nodes) { + if (node.isSubgraphNode?.() && node.subgraph) { + for (const subNode of node.subgraph.nodes) { + if (!filter || filter(subNode)) { + allNodes.push(subNode) + } + } + } + + if (!filter || filter(node)) { + allNodes.push(node) + } + } + + return allNodes + }) + + const mainMissingNode = createMockNode( + 'MainMissing', + 'comfy-core', + '1.0.0' + ) + const subgraphMissingNode = createMockNode( + 'SubgraphMissing', + 'comfy-core', + '1.1.0' + ) + const subgraphRegisteredNode = createMockNode( + 'SubgraphRegistered', + 'comfy-core', + '1.0.0' + ) + + const mockSubgraph = { + nodes: [subgraphMissingNode, subgraphRegisteredNode] + } + + const mockSubgraphNode = { + isSubgraphNode: () => true, + subgraph: mockSubgraph, + type: 'SubgraphContainer', + properties: { cnr_id: 'custom-pack' } + } + + const mockMainGraph = { + nodes: [mainMissingNode, mockSubgraphNode] + } + + // @ts-expect-error - Mocking app.graph for testing + app.graph = mockMainGraph + + mockUseNodeDefStore.mockReturnValue({ + nodeDefsByName: { + // @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing. + SubgraphRegistered: { name: 'SubgraphRegistered' } + } + }) + + const { missingCoreNodes } = useMissingNodes() + + expect(Object.keys(missingCoreNodes.value)).toHaveLength(2) + expect(missingCoreNodes.value['1.0.0']).toHaveLength(1) + expect(missingCoreNodes.value['1.1.0']).toHaveLength(1) + expect(missingCoreNodes.value['1.0.0'][0].type).toBe('MainMissing') + expect(missingCoreNodes.value['1.1.0'][0].type).toBe('SubgraphMissing') + }) + }) }) diff --git a/tests-ui/tests/domWidgetStore.test.ts b/tests-ui/tests/domWidgetStore.test.ts new file mode 100644 index 000000000..21a83f7c6 --- /dev/null +++ b/tests-ui/tests/domWidgetStore.test.ts @@ -0,0 +1,151 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useDomWidgetStore } from '@/stores/domWidgetStore' + +// Mock DOM widget for testing +const createMockDOMWidget = (id: string) => { + const element = document.createElement('input') + return { + id, + element, + node: { + id: 'node-1', + title: 'Test Node', + pos: [0, 0], + size: [200, 100] + } as any, + name: 'test_widget', + type: 'text', + value: 'test', + options: {}, + y: 0, + margin: 10, + isVisible: () => true, + containerNode: undefined as any + } +} + +describe('domWidgetStore', () => { + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + store = useDomWidgetStore() + }) + + describe('widget registration', () => { + it('should register a widget with default state', () => { + const widget = createMockDOMWidget('widget-1') + + store.registerWidget(widget) + + expect(store.widgetStates.has('widget-1')).toBe(true) + const state = store.widgetStates.get('widget-1') + expect(state).toBeDefined() + expect(state!.widget).toBe(widget) + expect(state!.visible).toBe(true) + expect(state!.active).toBe(true) + expect(state!.readonly).toBe(false) + expect(state!.zIndex).toBe(0) + expect(state!.pos).toEqual([0, 0]) + expect(state!.size).toEqual([0, 0]) + }) + + it('should not register the same widget twice', () => { + const widget = createMockDOMWidget('widget-1') + + store.registerWidget(widget) + store.registerWidget(widget) + + // Should still only have one entry + const states = Array.from(store.widgetStates.values()) + expect(states.length).toBe(1) + }) + }) + + describe('widget unregistration', () => { + it('should unregister a widget by id', () => { + const widget = createMockDOMWidget('widget-1') + + store.registerWidget(widget) + expect(store.widgetStates.has('widget-1')).toBe(true) + + store.unregisterWidget('widget-1') + expect(store.widgetStates.has('widget-1')).toBe(false) + }) + + it('should handle unregistering non-existent widget gracefully', () => { + // Should not throw + expect(() => { + store.unregisterWidget('non-existent') + }).not.toThrow() + }) + }) + + describe('widget state management', () => { + it('should activate a widget', () => { + const widget = createMockDOMWidget('widget-1') + store.registerWidget(widget) + + // Set to inactive first + const state = store.widgetStates.get('widget-1')! + state.active = false + + store.activateWidget('widget-1') + expect(state.active).toBe(true) + }) + + it('should deactivate a widget', () => { + const widget = createMockDOMWidget('widget-1') + store.registerWidget(widget) + + store.deactivateWidget('widget-1') + const state = store.widgetStates.get('widget-1') + expect(state!.active).toBe(false) + }) + + it('should handle activating non-existent widget gracefully', () => { + expect(() => { + store.activateWidget('non-existent') + }).not.toThrow() + }) + }) + + describe('computed states', () => { + it('should separate active and inactive widget states', () => { + const widget1 = createMockDOMWidget('widget-1') + const widget2 = createMockDOMWidget('widget-2') + + store.registerWidget(widget1) + store.registerWidget(widget2) + + // Deactivate widget2 + store.deactivateWidget('widget-2') + + expect(store.activeWidgetStates.length).toBe(1) + expect(store.activeWidgetStates[0].widget.id).toBe('widget-1') + + expect(store.inactiveWidgetStates.length).toBe(1) + expect(store.inactiveWidgetStates[0].widget.id).toBe('widget-2') + }) + }) + + describe('clear functionality', () => { + it('should clear all widget states', () => { + const widget1 = createMockDOMWidget('widget-1') + const widget2 = createMockDOMWidget('widget-2') + + store.registerWidget(widget1) + store.registerWidget(widget2) + + expect(store.widgetStates.size).toBe(2) + + store.clear() + + expect(store.widgetStates.size).toBe(0) + expect(store.activeWidgetStates.length).toBe(0) + expect(store.inactiveWidgetStates.length).toBe(0) + }) + }) +}) diff --git a/tests-ui/tests/maskeditor.test.ts b/tests-ui/tests/maskeditor.test.ts new file mode 100644 index 000000000..d3fad6e33 --- /dev/null +++ b/tests-ui/tests/maskeditor.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' + +import { imageLayerFilenamesIfApplicable } from '@/extensions/core/maskEditorLayerFilenames' + +describe('imageLayerFilenamesIfApplicable', () => { + // In case the naming scheme changes, this test will ensure CI fails if developers forget to support the old naming scheme. (Causing MaskEditor to lose layer data for previously-saved images.) + it('should support all past layer naming schemes to preserve backward compatibility', async () => { + const dummyTimestamp = 1234567890 + const inputToSupport = `clipspace-painted-masked-${dummyTimestamp}.png` + const expectedOutput = { + maskedImage: `clipspace-mask-${dummyTimestamp}.png`, + paint: `clipspace-paint-${dummyTimestamp}.png`, + paintedImage: `clipspace-painted-${dummyTimestamp}.png`, + paintedMaskedImage: inputToSupport + } + const actualOutput = imageLayerFilenamesIfApplicable(inputToSupport) + expect(actualOutput).toEqual(expectedOutput) + }) +}) diff --git a/tests-ui/tests/store/dialogStore.test.ts b/tests-ui/tests/store/dialogStore.test.ts index 6d76d9bb1..3d21ff695 100644 --- a/tests-ui/tests/store/dialogStore.test.ts +++ b/tests-ui/tests/store/dialogStore.test.ts @@ -172,4 +172,58 @@ describe('dialogStore', () => { expect(store.dialogStack[0].title).toBe('Original Title') }) }) + + describe('ESC key behavior with multiple dialogs', () => { + it('should only allow the active dialog to close with ESC key', () => { + const store = useDialogStore() + + // Create dialogs with different priorities + store.showDialog({ + key: 'dialog-1', + component: MockComponent, + priority: 1 + }) + + store.showDialog({ + key: 'dialog-2', + component: MockComponent, + priority: 2 + }) + + store.showDialog({ + key: 'dialog-3', + component: MockComponent, + priority: 3 + }) + + // Only the active dialog should be closable with ESC + const activeDialog = store.dialogStack.find( + (d) => d.key === store.activeKey + ) + const inactiveDialogs = store.dialogStack.filter( + (d) => d.key !== store.activeKey + ) + + expect(activeDialog?.dialogComponentProps.closeOnEscape).toBe(true) + inactiveDialogs.forEach((dialog) => { + expect(dialog.dialogComponentProps.closeOnEscape).toBe(false) + }) + + // Close the active dialog + store.closeDialog({ key: store.activeKey! }) + + // The new active dialog should now be closable with ESC + const newActiveDialog = store.dialogStack.find( + (d) => d.key === store.activeKey + ) + const newInactiveDialogs = store.dialogStack.filter( + (d) => d.key !== store.activeKey + ) + + expect(newActiveDialog?.dialogComponentProps.closeOnEscape).toBe(true) + newInactiveDialogs.forEach((dialog) => { + expect(dialog.dialogComponentProps.closeOnEscape).toBe(false) + }) + }) + }) }) diff --git a/tests-ui/tests/store/executionStore.test.ts b/tests-ui/tests/store/executionStore.test.ts index 590ce6955..d34bf4867 100644 --- a/tests-ui/tests/store/executionStore.test.ts +++ b/tests-ui/tests/store/executionStore.test.ts @@ -1,11 +1,22 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { app } from '@/scripts/app' import { useExecutionStore } from '@/stores/executionStore' +import { useWorkflowStore } from '@/stores/workflowStore' + +// Mock the workflowStore +vi.mock('@/stores/workflowStore', () => ({ + useWorkflowStore: vi.fn(() => ({ + nodeExecutionIdToNodeLocatorId: vi.fn(), + nodeIdToNodeLocatorId: vi.fn(), + nodeLocatorIdToNodeExecutionId: vi.fn() + })) +})) // Remove any previous global types declare global { - // Empty interface to override any previous declarations + // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface Window {} } @@ -22,12 +33,16 @@ vi.mock('@/composables/node/useNodeProgressText', () => ({ }) })) -// Create a local mock instead of using global to avoid conflicts -const mockApp = { - graph: { - getNodeById: vi.fn() +// Mock the app import with proper implementation +vi.mock('@/scripts/app', () => ({ + app: { + graph: { + getNodeById: vi.fn() + }, + revokePreviews: vi.fn(), + nodePreviewImages: {} } -} +})) describe('executionStore - display_component handling', () => { function createDisplayComponentEvent( @@ -47,7 +62,7 @@ describe('executionStore - display_component handling', () => { function handleDisplayComponentMessage(event: CustomEvent) { const { node_id, component } = event.detail - const node = mockApp.graph.getNodeById(node_id) + const node = vi.mocked(app.graph.getNodeById)(node_id) if (node && component === 'ChatHistoryWidget') { mockShowChatHistory(node) } @@ -60,23 +75,121 @@ describe('executionStore - display_component handling', () => { }) it('handles ChatHistoryWidget display_component messages', () => { - const mockNode = { id: '123' } - mockApp.graph.getNodeById.mockReturnValue(mockNode) + const mockNode = { id: '123' } as any + vi.mocked(app.graph.getNodeById).mockReturnValue(mockNode) const event = createDisplayComponentEvent('123') handleDisplayComponentMessage(event) - expect(mockApp.graph.getNodeById).toHaveBeenCalledWith('123') + expect(app.graph.getNodeById).toHaveBeenCalledWith('123') expect(mockShowChatHistory).toHaveBeenCalledWith(mockNode) }) it('does nothing if node is not found', () => { - mockApp.graph.getNodeById.mockReturnValue(null) + vi.mocked(app.graph.getNodeById).mockReturnValue(null) const event = createDisplayComponentEvent('non-existent') handleDisplayComponentMessage(event) - expect(mockApp.graph.getNodeById).toHaveBeenCalledWith('non-existent') + expect(app.graph.getNodeById).toHaveBeenCalledWith('non-existent') expect(mockShowChatHistory).not.toHaveBeenCalled() }) }) + +describe('useExecutionStore - NodeLocatorId conversions', () => { + let store: ReturnType + let workflowStore: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + + // Create the mock workflowStore instance + const mockWorkflowStore = { + nodeExecutionIdToNodeLocatorId: vi.fn(), + nodeIdToNodeLocatorId: vi.fn(), + nodeLocatorIdToNodeExecutionId: vi.fn() + } + + // Mock the useWorkflowStore function to return our mock + vi.mocked(useWorkflowStore).mockReturnValue(mockWorkflowStore as any) + + workflowStore = mockWorkflowStore as any + store = useExecutionStore() + vi.clearAllMocks() + }) + + describe('executionIdToNodeLocatorId', () => { + it('should convert execution ID to NodeLocatorId', () => { + // Mock subgraph structure + const mockSubgraph = { + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + _nodes: [] + } + + const mockNode = { + id: 123, + isSubgraphNode: () => true, + subgraph: mockSubgraph + } as any + + // Mock app.graph.getNodeById to return the mock node + vi.mocked(app.graph.getNodeById).mockReturnValue(mockNode) + + const result = store.executionIdToNodeLocatorId('123:456') + + expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456') + }) + + it('should convert simple node ID to NodeLocatorId', () => { + const result = store.executionIdToNodeLocatorId('123') + + // For simple node IDs, it should return the ID as-is + expect(result).toBe('123') + }) + + it('should handle numeric node IDs', () => { + const result = store.executionIdToNodeLocatorId(123) + + // For numeric IDs, it should convert to string and return as-is + expect(result).toBe('123') + }) + + it('should return null when conversion fails', () => { + // Mock app.graph.getNodeById to return null (node not found) + vi.mocked(app.graph.getNodeById).mockReturnValue(null) + + // This should throw an error as the node is not found + expect(() => store.executionIdToNodeLocatorId('999:456')).toThrow( + 'Subgraph not found: 999' + ) + }) + }) + + describe('nodeLocatorIdToExecutionId', () => { + it('should convert NodeLocatorId to execution ID', () => { + const mockExecutionId = '123:456' + vi.spyOn(workflowStore, 'nodeLocatorIdToNodeExecutionId').mockReturnValue( + mockExecutionId as any + ) + + const result = store.nodeLocatorIdToExecutionId( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456' + ) + + expect(workflowStore.nodeLocatorIdToNodeExecutionId).toHaveBeenCalledWith( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456' + ) + expect(result).toBe(mockExecutionId) + }) + + it('should return null when conversion fails', () => { + vi.spyOn(workflowStore, 'nodeLocatorIdToNodeExecutionId').mockReturnValue( + null + ) + + const result = store.nodeLocatorIdToExecutionId('invalid:format') + + expect(result).toBeNull() + }) + }) +}) diff --git a/tests-ui/tests/store/nodeDefStore.test.ts b/tests-ui/tests/store/nodeDefStore.test.ts new file mode 100644 index 000000000..071565a6c --- /dev/null +++ b/tests-ui/tests/store/nodeDefStore.test.ts @@ -0,0 +1,308 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' +import { type NodeDefFilter, useNodeDefStore } from '@/stores/nodeDefStore' + +describe('useNodeDefStore', () => { + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + store = useNodeDefStore() + }) + + const createMockNodeDef = ( + overrides: Partial = {} + ): ComfyNodeDef => ({ + name: 'TestNode', + display_name: 'Test Node', + category: 'test', + python_module: 'test_module', + description: 'Test node', + input: {}, + output: [], + output_is_list: [], + output_name: [], + output_node: false, + deprecated: false, + experimental: false, + ...overrides + }) + + describe('filter registry', () => { + it('should register a new filter', () => { + const filter: NodeDefFilter = { + id: 'test.filter', + name: 'Test Filter', + predicate: () => true + } + + store.registerNodeDefFilter(filter) + expect(store.nodeDefFilters).toContainEqual(filter) + }) + + it('should unregister a filter by id', () => { + const filter: NodeDefFilter = { + id: 'test.filter', + name: 'Test Filter', + predicate: () => true + } + + store.registerNodeDefFilter(filter) + store.unregisterNodeDefFilter('test.filter') + expect(store.nodeDefFilters).not.toContainEqual(filter) + }) + + it('should register core filters on initialization', () => { + const deprecatedFilter = store.nodeDefFilters.find( + (f) => f.id === 'core.deprecated' + ) + const experimentalFilter = store.nodeDefFilters.find( + (f) => f.id === 'core.experimental' + ) + + expect(deprecatedFilter).toBeDefined() + expect(experimentalFilter).toBeDefined() + }) + }) + + describe('filter application', () => { + beforeEach(() => { + // Clear existing filters for isolated tests + store.nodeDefFilters.splice(0) + }) + + it('should apply single filter to visible nodes', () => { + const normalNode = createMockNodeDef({ + name: 'normal', + deprecated: false + }) + const deprecatedNode = createMockNodeDef({ + name: 'deprecated', + deprecated: true + }) + + store.updateNodeDefs([normalNode, deprecatedNode]) + + // Register filter that hides deprecated nodes + store.registerNodeDefFilter({ + id: 'test.no-deprecated', + name: 'Hide Deprecated', + predicate: (node) => !node.deprecated + }) + + expect(store.visibleNodeDefs).toHaveLength(1) + expect(store.visibleNodeDefs[0].name).toBe('normal') + }) + + it('should apply multiple filters with AND logic', () => { + const node1 = createMockNodeDef({ + name: 'node1', + deprecated: false, + experimental: false + }) + const node2 = createMockNodeDef({ + name: 'node2', + deprecated: true, + experimental: false + }) + const node3 = createMockNodeDef({ + name: 'node3', + deprecated: false, + experimental: true + }) + const node4 = createMockNodeDef({ + name: 'node4', + deprecated: true, + experimental: true + }) + + store.updateNodeDefs([node1, node2, node3, node4]) + + // Register filters + store.registerNodeDefFilter({ + id: 'test.no-deprecated', + name: 'Hide Deprecated', + predicate: (node) => !node.deprecated + }) + + store.registerNodeDefFilter({ + id: 'test.no-experimental', + name: 'Hide Experimental', + predicate: (node) => !node.experimental + }) + + // Only node1 should be visible (not deprecated AND not experimental) + expect(store.visibleNodeDefs).toHaveLength(1) + expect(store.visibleNodeDefs[0].name).toBe('node1') + }) + + it('should show all nodes when no filters are registered', () => { + const nodes = [ + createMockNodeDef({ name: 'node1' }), + createMockNodeDef({ name: 'node2' }), + createMockNodeDef({ name: 'node3' }) + ] + + store.updateNodeDefs(nodes) + expect(store.visibleNodeDefs).toHaveLength(3) + }) + + it('should update visibility when filter is removed', () => { + const deprecatedNode = createMockNodeDef({ + name: 'deprecated', + deprecated: true + }) + store.updateNodeDefs([deprecatedNode]) + + const filter: NodeDefFilter = { + id: 'test.no-deprecated', + name: 'Hide Deprecated', + predicate: (node) => !node.deprecated + } + + // Add filter - node should be hidden + store.registerNodeDefFilter(filter) + expect(store.visibleNodeDefs).toHaveLength(0) + + // Remove filter - node should be visible + store.unregisterNodeDefFilter('test.no-deprecated') + expect(store.visibleNodeDefs).toHaveLength(1) + }) + }) + + describe('core filters behavior', () => { + it('should hide deprecated nodes by default', () => { + const normalNode = createMockNodeDef({ + name: 'normal', + deprecated: false + }) + const deprecatedNode = createMockNodeDef({ + name: 'deprecated', + deprecated: true + }) + + store.updateNodeDefs([normalNode, deprecatedNode]) + + expect(store.visibleNodeDefs).toHaveLength(1) + expect(store.visibleNodeDefs[0].name).toBe('normal') + }) + + it('should show deprecated nodes when showDeprecated is true', () => { + const normalNode = createMockNodeDef({ + name: 'normal', + deprecated: false + }) + const deprecatedNode = createMockNodeDef({ + name: 'deprecated', + deprecated: true + }) + + store.updateNodeDefs([normalNode, deprecatedNode]) + store.showDeprecated = true + + expect(store.visibleNodeDefs).toHaveLength(2) + }) + + it('should hide experimental nodes by default', () => { + const normalNode = createMockNodeDef({ + name: 'normal', + experimental: false + }) + const experimentalNode = createMockNodeDef({ + name: 'experimental', + experimental: true + }) + + store.updateNodeDefs([normalNode, experimentalNode]) + + expect(store.visibleNodeDefs).toHaveLength(1) + expect(store.visibleNodeDefs[0].name).toBe('normal') + }) + + it('should show experimental nodes when showExperimental is true', () => { + const normalNode = createMockNodeDef({ + name: 'normal', + experimental: false + }) + const experimentalNode = createMockNodeDef({ + name: 'experimental', + experimental: true + }) + + store.updateNodeDefs([normalNode, experimentalNode]) + store.showExperimental = true + + expect(store.visibleNodeDefs).toHaveLength(2) + }) + + it('should hide subgraph nodes by default', () => { + const normalNode = createMockNodeDef({ + name: 'normal', + category: 'conditioning', + python_module: 'nodes' + }) + const subgraphNode = createMockNodeDef({ + name: 'MySubgraph', + category: 'subgraph', + python_module: 'nodes' + }) + + store.updateNodeDefs([normalNode, subgraphNode]) + + expect(store.visibleNodeDefs).toHaveLength(1) + expect(store.visibleNodeDefs[0].name).toBe('normal') + }) + + it('should show non-subgraph nodes with subgraph category', () => { + const normalNode = createMockNodeDef({ + name: 'normal', + category: 'conditioning', + python_module: 'custom_extension' + }) + const fakeSubgraphNode = createMockNodeDef({ + name: 'FakeSubgraph', + category: 'subgraph', + python_module: 'custom_extension' // Different python_module + }) + + store.updateNodeDefs([normalNode, fakeSubgraphNode]) + + expect(store.visibleNodeDefs).toHaveLength(2) + expect(store.visibleNodeDefs.map((n) => n.name)).toEqual([ + 'normal', + 'FakeSubgraph' + ]) + }) + }) + + describe('performance', () => { + it('should perform single traversal for multiple filters', () => { + let filterCallCount = 0 + + // Register multiple filters that count their calls + for (let i = 0; i < 5; i++) { + store.registerNodeDefFilter({ + id: `test.counter-${i}`, + name: `Counter ${i}`, + predicate: () => { + filterCallCount++ + return true + } + }) + } + + const nodes = Array.from({ length: 10 }, (_, i) => + createMockNodeDef({ name: `node${i}` }) + ) + store.updateNodeDefs(nodes) + + // Force recomputation by accessing visibleNodeDefs + expect(store.visibleNodeDefs).toBeDefined() + + // Each node (10) should be checked by each filter (5 test + 2 core = 7 total) + expect(filterCallCount).toBe(10 * 5) + }) + }) +}) diff --git a/tests-ui/tests/store/subgraphNavigationStore.test.ts b/tests-ui/tests/store/subgraphNavigationStore.test.ts new file mode 100644 index 000000000..4ee63353b --- /dev/null +++ b/tests-ui/tests/store/subgraphNavigationStore.test.ts @@ -0,0 +1,113 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' + +import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore' +import { useWorkflowStore } from '@/stores/workflowStore' +import type { ComfyWorkflow } from '@/stores/workflowStore' + +vi.mock('@/scripts/app', () => ({ + app: { + graph: { + subgraphs: new Map(), + getNodeById: vi.fn() + }, + canvas: { + subgraph: null + } + } +})) + +describe('useSubgraphNavigationStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('should not clear navigation stack when workflow internal state changes', async () => { + const navigationStore = useSubgraphNavigationStore() + const workflowStore = useWorkflowStore() + + // Mock a workflow + const mockWorkflow = { + path: 'test-workflow.json', + filename: 'test-workflow.json', + changeTracker: null + } as ComfyWorkflow + + // Set the active workflow (cast to bypass TypeScript check in test) + workflowStore.activeWorkflow = mockWorkflow as any + + // Simulate being in a subgraph by restoring state + navigationStore.restoreState(['subgraph-1', 'subgraph-2']) + + expect(navigationStore.exportState()).toHaveLength(2) + + // Simulate a change to the workflow's internal state + // (e.g., changeTracker.activeState being reassigned) + mockWorkflow.changeTracker = { activeState: {} } as any + + // The navigation stack should NOT be cleared because the path hasn't changed + expect(navigationStore.exportState()).toHaveLength(2) + expect(navigationStore.exportState()).toEqual(['subgraph-1', 'subgraph-2']) + }) + + it('should clear navigation stack when switching to a different workflow', async () => { + const navigationStore = useSubgraphNavigationStore() + const workflowStore = useWorkflowStore() + + // Mock first workflow + const workflow1 = { + path: 'workflow1.json', + filename: 'workflow1.json' + } as ComfyWorkflow + + // Set the active workflow + workflowStore.activeWorkflow = workflow1 as any + + // Simulate being in a subgraph + navigationStore.restoreState(['subgraph-1', 'subgraph-2']) + + expect(navigationStore.exportState()).toHaveLength(2) + + // Switch to a different workflow + const workflow2 = { + path: 'workflow2.json', + filename: 'workflow2.json' + } as ComfyWorkflow + + workflowStore.activeWorkflow = workflow2 as any + + // Wait for Vue's reactivity to process the change + await nextTick() + + // The navigation stack SHOULD be cleared because we switched workflows + expect(navigationStore.exportState()).toHaveLength(0) + }) + + it('should handle null workflow gracefully', async () => { + const navigationStore = useSubgraphNavigationStore() + const workflowStore = useWorkflowStore() + + // First set an active workflow + const mockWorkflow = { + path: 'test-workflow.json', + filename: 'test-workflow.json' + } as ComfyWorkflow + + workflowStore.activeWorkflow = mockWorkflow as any + await nextTick() + + // Add some items to the navigation stack + navigationStore.restoreState(['subgraph-1']) + expect(navigationStore.exportState()).toHaveLength(1) + + // Set workflow to null + workflowStore.activeWorkflow = null + + // Wait for Vue's reactivity to process the change + await nextTick() + + // Stack should be cleared when workflow becomes null + expect(navigationStore.exportState()).toHaveLength(0) + }) +}) diff --git a/tests-ui/tests/store/systemStatsStore.test.ts b/tests-ui/tests/store/systemStatsStore.test.ts index 3376a19c0..84e84ec44 100644 --- a/tests-ui/tests/store/systemStatsStore.test.ts +++ b/tests-ui/tests/store/systemStatsStore.test.ts @@ -41,6 +41,7 @@ describe('useSystemStatsStore', () => { embedded_python: false, comfyui_version: '1.0.0', pytorch_version: '2.0.0', + required_frontend_version: '1.24.0', argv: [], ram_total: 16000000000, ram_free: 8000000000 @@ -92,6 +93,32 @@ describe('useSystemStatsStore', () => { expect(store.isLoading).toBe(false) }) + + it('should handle system stats updates', async () => { + const updatedStats = { + system: { + os: 'Windows', + python_version: '3.11.0', + embedded_python: false, + comfyui_version: '1.1.0', + pytorch_version: '2.1.0', + required_frontend_version: '1.25.0', + argv: [], + ram_total: 16000000000, + ram_free: 7000000000 + }, + devices: [] + } + + vi.mocked(api.getSystemStats).mockResolvedValue(updatedStats) + + await store.fetchSystemStats() + + expect(store.systemStats).toEqual(updatedStats) + expect(store.isLoading).toBe(false) + expect(store.error).toBeNull() + expect(api.getSystemStats).toHaveBeenCalled() + }) }) describe('getFormFactor', () => { diff --git a/tests-ui/tests/store/versionCompatibilityStore.test.ts b/tests-ui/tests/store/versionCompatibilityStore.test.ts new file mode 100644 index 000000000..e3d3ceca9 --- /dev/null +++ b/tests-ui/tests/store/versionCompatibilityStore.test.ts @@ -0,0 +1,321 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import { useSystemStatsStore } from '@/stores/systemStatsStore' +import { useVersionCompatibilityStore } from '@/stores/versionCompatibilityStore' + +vi.mock('@/config', () => ({ + default: { + app_version: '1.24.0' + } +})) + +vi.mock('@/stores/systemStatsStore') + +// Mock useStorage from VueUse +const mockDismissalStorage = ref({} as Record) +vi.mock('@vueuse/core', () => ({ + useStorage: vi.fn(() => mockDismissalStorage) +})) + +describe('useVersionCompatibilityStore', () => { + let store: ReturnType + let mockSystemStatsStore: any + + beforeEach(() => { + setActivePinia(createPinia()) + + // Clear the mock dismissal storage + mockDismissalStorage.value = {} + + mockSystemStatsStore = { + systemStats: null, + fetchSystemStats: vi.fn() + } + + vi.mocked(useSystemStatsStore).mockReturnValue(mockSystemStatsStore) + + store = useVersionCompatibilityStore() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('version compatibility detection', () => { + it('should detect frontend is outdated when required version is higher', async () => { + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '1.25.0', + required_frontend_version: '1.25.0' + } + } + + await store.checkVersionCompatibility() + + expect(store.isFrontendOutdated).toBe(true) + expect(store.isFrontendNewer).toBe(false) + expect(store.hasVersionMismatch).toBe(true) + }) + + it('should not warn when frontend is newer than backend', async () => { + // Frontend: 1.24.0, Backend: 1.23.0, Required: 1.23.0 + // Frontend meets required version, no warning needed + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '1.23.0', + required_frontend_version: '1.23.0' + } + } + + await store.checkVersionCompatibility() + + expect(store.isFrontendOutdated).toBe(false) + expect(store.isFrontendNewer).toBe(false) + expect(store.hasVersionMismatch).toBe(false) + }) + + it('should not detect mismatch when versions are compatible', async () => { + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '1.24.0', + required_frontend_version: '1.24.0' + } + } + + await store.checkVersionCompatibility() + + expect(store.isFrontendOutdated).toBe(false) + expect(store.isFrontendNewer).toBe(false) + expect(store.hasVersionMismatch).toBe(false) + }) + + it('should handle missing version information gracefully', async () => { + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '', + required_frontend_version: '' + } + } + + await store.checkVersionCompatibility() + + expect(store.isFrontendOutdated).toBe(false) + expect(store.isFrontendNewer).toBe(false) + expect(store.hasVersionMismatch).toBe(false) + }) + + it('should not detect mismatch when versions are not valid semver', async () => { + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '080e6d4af809a46852d1c4b7ed85f06e8a3a72be', // git hash + required_frontend_version: 'not-a-version' // invalid semver format + } + } + + await store.checkVersionCompatibility() + + expect(store.isFrontendOutdated).toBe(false) + expect(store.isFrontendNewer).toBe(false) + expect(store.hasVersionMismatch).toBe(false) + }) + + it('should not warn when frontend exceeds required version', async () => { + // Frontend: 1.24.0 (from mock config) + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '1.22.0', // Backend is older + required_frontend_version: '1.23.0' // Required is 1.23.0, frontend 1.24.0 meets this + } + } + + await store.checkVersionCompatibility() + + expect(store.isFrontendOutdated).toBe(false) // Frontend 1.24.0 >= Required 1.23.0 + expect(store.isFrontendNewer).toBe(false) // Never warns about being newer + expect(store.hasVersionMismatch).toBe(false) + }) + }) + + describe('warning display logic', () => { + it('should show warning when there is a version mismatch and not dismissed', async () => { + // No dismissals in storage + mockDismissalStorage.value = {} + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '1.25.0', + required_frontend_version: '1.25.0' + } + } + + await store.checkVersionCompatibility() + + expect(store.shouldShowWarning).toBe(true) + }) + + it('should not show warning when dismissed', async () => { + const futureTime = Date.now() + 1000000 + // Set dismissal in reactive storage + mockDismissalStorage.value = { + '1.24.0-1.25.0-1.25.0': futureTime + } + + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '1.25.0', + required_frontend_version: '1.25.0' + } + } + + await store.checkVersionCompatibility() + + expect(store.shouldShowWarning).toBe(false) + }) + + it('should not show warning when no version mismatch', async () => { + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '1.24.0', + required_frontend_version: '1.24.0' + } + } + + await store.checkVersionCompatibility() + + expect(store.shouldShowWarning).toBe(false) + }) + }) + + describe('warning messages', () => { + it('should generate outdated message when frontend is outdated', async () => { + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '1.25.0', + required_frontend_version: '1.25.0' + } + } + + await store.checkVersionCompatibility() + + expect(store.warningMessage).toEqual({ + type: 'outdated', + frontendVersion: '1.24.0', + requiredVersion: '1.25.0' + }) + }) + + it('should return null when no mismatch', async () => { + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '1.24.0', + required_frontend_version: '1.24.0' + } + } + + await store.checkVersionCompatibility() + + expect(store.warningMessage).toBeNull() + }) + }) + + describe('dismissal persistence', () => { + it('should save dismissal to reactive storage with expiration', async () => { + const mockNow = 1000000 + vi.spyOn(Date, 'now').mockReturnValue(mockNow) + + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '1.25.0', + required_frontend_version: '1.25.0' + } + } + + await store.checkVersionCompatibility() + store.dismissWarning() + + // Check that the dismissal was added to reactive storage + expect(mockDismissalStorage.value).toEqual({ + '1.24.0-1.25.0-1.25.0': mockNow + 7 * 24 * 60 * 60 * 1000 + }) + }) + + it('should check dismissal state from reactive storage', async () => { + const futureTime = Date.now() + 1000000 // Still valid + mockDismissalStorage.value = { + '1.24.0-1.25.0-1.25.0': futureTime + } + + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '1.25.0', + required_frontend_version: '1.25.0' + } + } + + await store.initialize() + + expect(store.shouldShowWarning).toBe(false) + }) + + it('should show warning if dismissal has expired', async () => { + const pastTime = Date.now() - 1000 // Expired + mockDismissalStorage.value = { + '1.24.0-1.25.0-1.25.0': pastTime + } + + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '1.25.0', + required_frontend_version: '1.25.0' + } + } + + await store.initialize() + + expect(store.shouldShowWarning).toBe(true) + }) + + it('should show warning for different version combinations even if previous was dismissed', async () => { + const futureTime = Date.now() + 1000000 + // Dismissed for different version combination (1.25.0) but current is 1.26.0 + mockDismissalStorage.value = { + '1.24.0-1.25.0-1.25.0': futureTime // Different version was dismissed + } + + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '1.26.0', + required_frontend_version: '1.26.0' + } + } + + await store.initialize() + + expect(store.shouldShowWarning).toBe(true) + }) + }) + + describe('initialization', () => { + it('should fetch system stats if not available', async () => { + mockSystemStatsStore.systemStats = null + + await store.initialize() + + expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled() + }) + + it('should not fetch system stats if already available', async () => { + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '1.24.0', + required_frontend_version: '1.24.0' + } + } + + await store.initialize() + + expect(mockSystemStatsStore.fetchSystemStats).not.toHaveBeenCalled() + }) + }) +}) diff --git a/tests-ui/tests/store/workflowStore.test.ts b/tests-ui/tests/store/workflowStore.test.ts index 4c5668c8f..579f330ff 100644 --- a/tests-ui/tests/store/workflowStore.test.ts +++ b/tests-ui/tests/store/workflowStore.test.ts @@ -1,3 +1,4 @@ +import type { Subgraph } from '@comfyorg/litegraph' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' @@ -11,6 +12,7 @@ import { useWorkflowBookmarkStore, useWorkflowStore } from '@/stores/workflowStore' +import { isSubgraph } from '@/utils/typeGuardUtil' // Add mock for api at the top of the file vi.mock('@/scripts/api', () => ({ @@ -26,10 +28,15 @@ vi.mock('@/scripts/api', () => ({ // Mock comfyApp globally for the store setup vi.mock('@/scripts/app', () => ({ app: { - canvas: null // Start with canvas potentially undefined or null + canvas: {} // Start with empty canvas object } })) +// Mock isSubgraph +vi.mock('@/utils/typeGuardUtil', () => ({ + isSubgraph: vi.fn(() => false) +})) + describe('useWorkflowStore', () => { let store: ReturnType let bookmarkStore: ReturnType @@ -518,8 +525,13 @@ describe('useWorkflowStore', () => { { name: 'Level 1 Subgraph' }, { name: 'Level 2 Subgraph' } ] - } - vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any + } as any + vi.mocked(comfyApp.canvas).subgraph = mockSubgraph + + // Mock isSubgraph to return true for our mockSubgraph + vi.mocked(isSubgraph).mockImplementation( + (obj): obj is Subgraph => obj === mockSubgraph + ) // Act: Trigger the update store.updateActiveGraph() @@ -536,8 +548,13 @@ describe('useWorkflowStore', () => { name: 'Initial Subgraph', pathToRootGraph: [{ name: 'Root' }, { name: 'Initial Subgraph' }], isRootGraph: false - } - vi.mocked(comfyApp.canvas).subgraph = initialSubgraph as any + } as any + vi.mocked(comfyApp.canvas).subgraph = initialSubgraph + + // Mock isSubgraph to return true for our initialSubgraph + vi.mocked(isSubgraph).mockImplementation( + (obj): obj is Subgraph => obj === initialSubgraph + ) // Trigger initial update based on the *first* workflow opened in beforeEach store.updateActiveGraph() @@ -561,6 +578,11 @@ describe('useWorkflowStore', () => { // This ensures the watcher *does* cause a state change we can assert vi.mocked(comfyApp.canvas).subgraph = undefined + // Mock isSubgraph to return false for undefined + vi.mocked(isSubgraph).mockImplementation( + (_obj): _obj is Subgraph => false + ) + await store.openWorkflow(workflow2) // This changes activeWorkflow and triggers the watch await nextTick() // Allow watcher and potential async operations in updateActiveGraph to complete @@ -569,4 +591,131 @@ describe('useWorkflowStore', () => { expect(store.activeSubgraph).toBeUndefined() }) }) + + describe('NodeLocatorId conversions', () => { + beforeEach(() => { + // Setup mock graph structure with subgraphs + const mockSubgraph = { + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + _nodes: [] + } + + const mockNode = { + id: 123, + isSubgraphNode: () => true, + subgraph: mockSubgraph + } + + const mockRootGraph = { + _nodes: [mockNode], + subgraphs: new Map([[mockSubgraph.id, mockSubgraph]]), + getNodeById: (id: string | number) => { + if (String(id) === '123') return mockNode + return null + } + } + + vi.mocked(comfyApp).graph = mockRootGraph as any + vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any + store.activeSubgraph = mockSubgraph as any + }) + + describe('nodeIdToNodeLocatorId', () => { + it('should convert node ID to NodeLocatorId for subgraph nodes', () => { + const result = store.nodeIdToNodeLocatorId(456) + expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456') + }) + + it('should return simple node ID for root graph nodes', () => { + store.activeSubgraph = undefined + const result = store.nodeIdToNodeLocatorId(123) + expect(result).toBe('123') + }) + + it('should use provided subgraph instead of active one', () => { + const customSubgraph = { + id: 'custom-uuid-1234-5678-90ab-cdef12345678' + } as any + const result = store.nodeIdToNodeLocatorId(789, customSubgraph) + expect(result).toBe('custom-uuid-1234-5678-90ab-cdef12345678:789') + }) + }) + + describe('nodeExecutionIdToNodeLocatorId', () => { + it('should convert execution ID to NodeLocatorId', () => { + const result = store.nodeExecutionIdToNodeLocatorId('123:456') + expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456') + }) + + it('should return simple node ID for root level nodes', () => { + const result = store.nodeExecutionIdToNodeLocatorId('123') + expect(result).toBe('123') + }) + + it('should return null for invalid execution IDs', () => { + const result = store.nodeExecutionIdToNodeLocatorId('999:456') + expect(result).toBeNull() + }) + }) + + describe('nodeLocatorIdToNodeId', () => { + it('should extract node ID from NodeLocatorId', () => { + const result = store.nodeLocatorIdToNodeId( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456' + ) + expect(result).toBe(456) + }) + + it('should handle string node IDs', () => { + const result = store.nodeLocatorIdToNodeId( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:node_1' + ) + expect(result).toBe('node_1') + }) + + it('should handle simple node IDs (root graph)', () => { + const result = store.nodeLocatorIdToNodeId('123') + expect(result).toBe(123) + + const stringResult = store.nodeLocatorIdToNodeId('node_1') + expect(stringResult).toBe('node_1') + }) + + it('should return null for invalid NodeLocatorId', () => { + const result = store.nodeLocatorIdToNodeId('invalid:format') + expect(result).toBeNull() + }) + }) + + describe('nodeLocatorIdToNodeExecutionId', () => { + it('should convert NodeLocatorId to execution ID', () => { + // Need to mock isSubgraph to identify our mockSubgraph + vi.mocked(isSubgraph).mockImplementation((obj): obj is Subgraph => { + return obj === store.activeSubgraph + }) + + const result = store.nodeLocatorIdToNodeExecutionId( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456' + ) + expect(result).toBe('123:456') + }) + + it('should handle simple node IDs (root graph)', () => { + const result = store.nodeLocatorIdToNodeExecutionId('123') + expect(result).toBe('123') + }) + + it('should return null for unknown subgraph UUID', () => { + const result = store.nodeLocatorIdToNodeExecutionId( + 'unknown-uuid-1234-5678-90ab-cdef12345678:456' + ) + expect(result).toBeNull() + }) + + it('should return null for invalid NodeLocatorId', () => { + const result = store.nodeLocatorIdToNodeExecutionId('invalid:format') + expect(result).toBeNull() + }) + }) + }) }) diff --git a/tests-ui/tests/types/nodeIdentification.test.ts b/tests-ui/tests/types/nodeIdentification.test.ts new file mode 100644 index 000000000..0d9aa647b --- /dev/null +++ b/tests-ui/tests/types/nodeIdentification.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest' + +import type { NodeId } from '@/schemas/comfyWorkflowSchema' +import { + type NodeLocatorId, + createNodeExecutionId, + createNodeLocatorId, + isNodeExecutionId, + isNodeLocatorId, + parseNodeExecutionId, + parseNodeLocatorId +} from '@/types/nodeIdentification' + +describe('nodeIdentification', () => { + describe('NodeLocatorId', () => { + const validUuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + const validNodeId = '123' + const validNodeLocatorId = `${validUuid}:${validNodeId}` as NodeLocatorId + + describe('isNodeLocatorId', () => { + it('should return true for valid NodeLocatorId', () => { + expect(isNodeLocatorId(validNodeLocatorId)).toBe(true) + expect(isNodeLocatorId(`${validUuid}:456`)).toBe(true) + expect(isNodeLocatorId(`${validUuid}:node_1`)).toBe(true) + // Simple node IDs (root graph) + expect(isNodeLocatorId('123')).toBe(true) + expect(isNodeLocatorId('node_1')).toBe(true) + expect(isNodeLocatorId('5')).toBe(true) + }) + + it('should return false for invalid formats', () => { + expect(isNodeLocatorId('123:456')).toBe(false) // No UUID in first part + expect(isNodeLocatorId('not-a-uuid:123')).toBe(false) + expect(isNodeLocatorId('')).toBe(false) // Empty string + expect(isNodeLocatorId(':123')).toBe(false) // Empty UUID + expect(isNodeLocatorId(`${validUuid}:`)).toBe(false) // Empty node ID + expect(isNodeLocatorId(`${validUuid}:123:456`)).toBe(false) // Too many parts + expect(isNodeLocatorId(123)).toBe(false) // Not a string + expect(isNodeLocatorId(null)).toBe(false) + expect(isNodeLocatorId(undefined)).toBe(false) + }) + + it('should validate UUID format correctly', () => { + // Valid UUID formats + expect( + isNodeLocatorId('00000000-0000-0000-0000-000000000000:123') + ).toBe(true) + expect( + isNodeLocatorId('A1B2C3D4-E5F6-7890-ABCD-EF1234567890:123') + ).toBe(true) + + // Invalid UUID formats + expect(isNodeLocatorId('00000000-0000-0000-0000-00000000000:123')).toBe( + false + ) // Too short + expect( + isNodeLocatorId('00000000-0000-0000-0000-0000000000000:123') + ).toBe(false) // Too long + expect( + isNodeLocatorId('00000000_0000_0000_0000_000000000000:123') + ).toBe(false) // Wrong separator + expect( + isNodeLocatorId('g0000000-0000-0000-0000-000000000000:123') + ).toBe(false) // Invalid hex + }) + }) + + describe('parseNodeLocatorId', () => { + it('should parse valid NodeLocatorId', () => { + const result = parseNodeLocatorId(validNodeLocatorId) + expect(result).toEqual({ + subgraphUuid: validUuid, + localNodeId: 123 + }) + }) + + it('should handle string node IDs', () => { + const stringNodeId = `${validUuid}:node_1` + const result = parseNodeLocatorId(stringNodeId) + expect(result).toEqual({ + subgraphUuid: validUuid, + localNodeId: 'node_1' + }) + }) + + it('should handle simple node IDs (root graph)', () => { + const result = parseNodeLocatorId('123') + expect(result).toEqual({ + subgraphUuid: null, + localNodeId: 123 + }) + + const stringResult = parseNodeLocatorId('node_1') + expect(stringResult).toEqual({ + subgraphUuid: null, + localNodeId: 'node_1' + }) + }) + + it('should return null for invalid formats', () => { + expect(parseNodeLocatorId('123:456')).toBeNull() // No UUID in first part + expect(parseNodeLocatorId('')).toBeNull() + }) + }) + + describe('createNodeLocatorId', () => { + it('should create NodeLocatorId from components', () => { + const result = createNodeLocatorId(validUuid, 123) + expect(result).toBe(validNodeLocatorId) + expect(isNodeLocatorId(result)).toBe(true) + }) + + it('should handle string node IDs', () => { + const result = createNodeLocatorId(validUuid, 'node_1') + expect(result).toBe(`${validUuid}:node_1`) + expect(isNodeLocatorId(result)).toBe(true) + }) + }) + }) + + describe('NodeExecutionId', () => { + describe('isNodeExecutionId', () => { + it('should return true for execution IDs', () => { + expect(isNodeExecutionId('123:456')).toBe(true) + expect(isNodeExecutionId('123:456:789')).toBe(true) + expect(isNodeExecutionId('node_1:node_2')).toBe(true) + }) + + it('should return false for non-execution IDs', () => { + expect(isNodeExecutionId('123')).toBe(false) + expect(isNodeExecutionId('node_1')).toBe(false) + expect(isNodeExecutionId('')).toBe(false) + expect(isNodeExecutionId(123)).toBe(false) + expect(isNodeExecutionId(null)).toBe(false) + expect(isNodeExecutionId(undefined)).toBe(false) + }) + }) + + describe('parseNodeExecutionId', () => { + it('should parse execution IDs correctly', () => { + expect(parseNodeExecutionId('123:456')).toEqual([123, 456]) + expect(parseNodeExecutionId('123:456:789')).toEqual([123, 456, 789]) + expect(parseNodeExecutionId('node_1:node_2')).toEqual([ + 'node_1', + 'node_2' + ]) + expect(parseNodeExecutionId('123:node_2:456')).toEqual([ + 123, + 'node_2', + 456 + ]) + }) + + it('should return null for non-execution IDs', () => { + expect(parseNodeExecutionId('123')).toBeNull() + expect(parseNodeExecutionId('')).toBeNull() + }) + }) + + describe('createNodeExecutionId', () => { + it('should create execution IDs from node arrays', () => { + expect(createNodeExecutionId([123, 456])).toBe('123:456') + expect(createNodeExecutionId([123, 456, 789])).toBe('123:456:789') + expect(createNodeExecutionId(['node_1', 'node_2'])).toBe( + 'node_1:node_2' + ) + expect(createNodeExecutionId([123, 'node_2', 456])).toBe( + '123:node_2:456' + ) + }) + + it('should handle single node ID', () => { + const result = createNodeExecutionId([123]) + expect(result).toBe('123') + // Single node IDs are not execution IDs + expect(isNodeExecutionId(result)).toBe(false) + }) + + it('should handle empty array', () => { + expect(createNodeExecutionId([])).toBe('') + }) + }) + }) + + describe('Integration tests', () => { + it('should round-trip NodeLocatorId correctly', () => { + const uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + const nodeId: NodeId = 123 + + const locatorId = createNodeLocatorId(uuid, nodeId) + const parsed = parseNodeLocatorId(locatorId) + + expect(parsed).toBeTruthy() + expect(parsed!.subgraphUuid).toBe(uuid) + expect(parsed!.localNodeId).toBe(nodeId) + }) + + it('should round-trip NodeExecutionId correctly', () => { + const nodeIds: NodeId[] = [123, 'node_2', 456] + + const executionId = createNodeExecutionId(nodeIds) + const parsed = parseNodeExecutionId(executionId) + + expect(parsed).toEqual(nodeIds) + }) + }) +}) diff --git a/tests-ui/tests/utils/graphTraversalUtil.test.ts b/tests-ui/tests/utils/graphTraversalUtil.test.ts new file mode 100644 index 000000000..b3b3dd23a --- /dev/null +++ b/tests-ui/tests/utils/graphTraversalUtil.test.ts @@ -0,0 +1,811 @@ +import type { LGraph, LGraphNode, Subgraph } from '@comfyorg/litegraph' +import { describe, expect, it, vi } from 'vitest' + +import { + collectAllNodes, + findNodeInHierarchy, + findSubgraphByUuid, + forEachNode, + forEachSubgraphNode, + getAllNonIoNodesInSubgraph, + getLocalNodeIdFromExecutionId, + getNodeByExecutionId, + getNodeByLocatorId, + getRootGraph, + getSubgraphPathFromExecutionId, + mapAllNodes, + mapSubgraphNodes, + parseExecutionId, + traverseSubgraphPath, + triggerCallbackOnAllNodes, + visitGraphNodes +} from '@/utils/graphTraversalUtil' + +// Mock node factory +function createMockNode( + id: string | number, + options: { + isSubgraph?: boolean + subgraph?: Subgraph + callback?: () => void + } = {} +): LGraphNode { + return { + id, + isSubgraphNode: options.isSubgraph ? () => true : undefined, + subgraph: options.subgraph, + onExecutionStart: options.callback + } as unknown as LGraphNode +} + +// Mock graph factory +function createMockGraph(nodes: LGraphNode[]): LGraph { + return { + _nodes: nodes, + nodes: nodes, + getNodeById: (id: string | number) => + nodes.find((n) => String(n.id) === String(id)) || null + } as unknown as LGraph +} + +// Mock subgraph factory +function createMockSubgraph(id: string, nodes: LGraphNode[]): Subgraph { + return { + id, + _nodes: nodes, + nodes: nodes, + getNodeById: (nodeId: string | number) => + nodes.find((n) => String(n.id) === String(nodeId)) || null + } as unknown as Subgraph +} + +describe('graphTraversalUtil', () => { + describe('Pure utility functions', () => { + describe('parseExecutionId', () => { + it('should parse simple execution ID', () => { + expect(parseExecutionId('123')).toEqual(['123']) + }) + + it('should parse complex execution ID', () => { + expect(parseExecutionId('123:456:789')).toEqual(['123', '456', '789']) + }) + + it('should handle empty parts', () => { + expect(parseExecutionId('123::789')).toEqual(['123', '789']) + }) + + it('should return null for invalid input', () => { + expect(parseExecutionId('')).toBeNull() + expect(parseExecutionId(null as any)).toBeNull() + expect(parseExecutionId(undefined as any)).toBeNull() + }) + }) + + describe('getLocalNodeIdFromExecutionId', () => { + it('should extract local node ID from simple ID', () => { + expect(getLocalNodeIdFromExecutionId('123')).toBe('123') + }) + + it('should extract local node ID from complex ID', () => { + expect(getLocalNodeIdFromExecutionId('123:456:789')).toBe('789') + }) + + it('should return null for invalid input', () => { + expect(getLocalNodeIdFromExecutionId('')).toBeNull() + }) + }) + + describe('getSubgraphPathFromExecutionId', () => { + it('should return empty array for root node', () => { + expect(getSubgraphPathFromExecutionId('123')).toEqual([]) + }) + + it('should return subgraph path for nested node', () => { + expect(getSubgraphPathFromExecutionId('123:456:789')).toEqual([ + '123', + '456' + ]) + }) + + it('should return empty array for invalid input', () => { + expect(getSubgraphPathFromExecutionId('')).toEqual([]) + }) + }) + + describe('visitGraphNodes', () => { + it('should visit all nodes in graph', () => { + const visited: number[] = [] + const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)] + const graph = createMockGraph(nodes) + + visitGraphNodes(graph, (node) => { + visited.push(node.id as number) + }) + + expect(visited).toEqual([1, 2, 3]) + }) + + it('should handle empty graph', () => { + const visited: number[] = [] + const graph = createMockGraph([]) + + visitGraphNodes(graph, (node) => { + visited.push(node.id as number) + }) + + expect(visited).toEqual([]) + }) + }) + + describe('traverseSubgraphPath', () => { + it('should return start graph for empty path', () => { + const graph = createMockGraph([]) + const result = traverseSubgraphPath(graph, []) + expect(result).toBe(graph) + }) + + it('should traverse single level', () => { + const subgraph = createMockSubgraph('sub-uuid', []) + const node = createMockNode('1', { isSubgraph: true, subgraph }) + const graph = createMockGraph([node]) + + const result = traverseSubgraphPath(graph, ['1']) + expect(result).toBe(subgraph) + }) + + it('should traverse multiple levels', () => { + const deepSubgraph = createMockSubgraph('deep-uuid', []) + const midNode = createMockNode('2', { + isSubgraph: true, + subgraph: deepSubgraph + }) + const midSubgraph = createMockSubgraph('mid-uuid', [midNode]) + const topNode = createMockNode('1', { + isSubgraph: true, + subgraph: midSubgraph + }) + const graph = createMockGraph([topNode]) + + const result = traverseSubgraphPath(graph, ['1', '2']) + expect(result).toBe(deepSubgraph) + }) + + it('should return null for invalid path', () => { + const graph = createMockGraph([createMockNode('1')]) + const result = traverseSubgraphPath(graph, ['999']) + expect(result).toBeNull() + }) + }) + }) + + describe('Main functions', () => { + describe('triggerCallbackOnAllNodes', () => { + it('should trigger callbacks on all nodes in a flat graph', () => { + const callback1 = vi.fn() + const callback2 = vi.fn() + + const node1 = createMockNode(1, { callback: callback1 }) + const node2 = createMockNode(2, { callback: callback2 }) + const node3 = createMockNode(3) // No callback + + const graph = createMockGraph([node1, node2, node3]) + + triggerCallbackOnAllNodes(graph, 'onExecutionStart') + + expect(callback1).toHaveBeenCalledOnce() + expect(callback2).toHaveBeenCalledOnce() + }) + + it('should trigger callbacks on nodes in subgraphs', () => { + const callback1 = vi.fn() + const callback2 = vi.fn() + const callback3 = vi.fn() + + // Create a subgraph with one node + const subNode = createMockNode(100, { callback: callback3 }) + const subgraph = createMockSubgraph('sub-uuid', [subNode]) + + // Create main graph with two nodes, one being a subgraph + const node1 = createMockNode(1, { callback: callback1 }) + const node2 = createMockNode(2, { + isSubgraph: true, + subgraph, + callback: callback2 + }) + + const graph = createMockGraph([node1, node2]) + + triggerCallbackOnAllNodes(graph, 'onExecutionStart') + + expect(callback1).toHaveBeenCalledOnce() + expect(callback2).toHaveBeenCalledOnce() + expect(callback3).toHaveBeenCalledOnce() + }) + + it('should handle nested subgraphs', () => { + const callbacks = [vi.fn(), vi.fn(), vi.fn(), vi.fn()] + + // Create deeply nested structure + const deepNode = createMockNode(300, { callback: callbacks[3] }) + const deepSubgraph = createMockSubgraph('deep-uuid', [deepNode]) + + const midNode1 = createMockNode(200, { callback: callbacks[2] }) + const midNode2 = createMockNode(201, { + isSubgraph: true, + subgraph: deepSubgraph + }) + const midSubgraph = createMockSubgraph('mid-uuid', [midNode1, midNode2]) + + const node1 = createMockNode(1, { callback: callbacks[0] }) + const node2 = createMockNode(2, { + isSubgraph: true, + subgraph: midSubgraph, + callback: callbacks[1] + }) + + const graph = createMockGraph([node1, node2]) + + triggerCallbackOnAllNodes(graph, 'onExecutionStart') + + callbacks.forEach((cb) => expect(cb).toHaveBeenCalledOnce()) + }) + }) + + describe('collectAllNodes', () => { + it('should collect all nodes from a flat graph', () => { + const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)] + + const graph = createMockGraph(nodes) + const collected = collectAllNodes(graph) + + expect(collected).toHaveLength(3) + expect(collected.map((n) => n.id)).toEqual([1, 2, 3]) + }) + + it('should collect nodes from subgraphs', () => { + const subNode = createMockNode(100) + const subgraph = createMockSubgraph('sub-uuid', [subNode]) + + const nodes = [ + createMockNode(1), + createMockNode(2, { isSubgraph: true, subgraph }) + ] + + const graph = createMockGraph(nodes) + const collected = collectAllNodes(graph) + + expect(collected).toHaveLength(3) + expect(collected.map((n) => n.id)).toContain(100) + }) + + it('should filter nodes when filter function provided', () => { + const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)] + + const graph = createMockGraph(nodes) + const collected = collectAllNodes(graph, (node) => Number(node.id) > 1) + + expect(collected).toHaveLength(2) + expect(collected.map((n) => n.id)).toEqual([2, 3]) + }) + }) + + describe('mapAllNodes', () => { + it('should map over all nodes in a flat graph', () => { + const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)] + const graph = createMockGraph(nodes) + + const results = mapAllNodes(graph, (node) => node.id) + + expect(results).toEqual([1, 2, 3]) + }) + + it('should map over nodes in subgraphs', () => { + const subNode = createMockNode(100) + const subgraph = createMockSubgraph('sub-uuid', [subNode]) + + const nodes = [ + createMockNode(1), + createMockNode(2, { isSubgraph: true, subgraph }) + ] + + const graph = createMockGraph(nodes) + const results = mapAllNodes(graph, (node) => node.id) + + expect(results).toHaveLength(3) + expect(results).toContain(100) + }) + + it('should exclude undefined results', () => { + const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)] + const graph = createMockGraph(nodes) + + const results = mapAllNodes(graph, (node) => { + return Number(node.id) > 1 ? node.id : undefined + }) + + expect(results).toEqual([2, 3]) + }) + + it('should handle deeply nested structures', () => { + const deepNode = createMockNode(300) + const deepSubgraph = createMockSubgraph('deep-uuid', [deepNode]) + + const midNode = createMockNode(200) + const midSubgraphNode = createMockNode(201, { + isSubgraph: true, + subgraph: deepSubgraph + }) + const midSubgraph = createMockSubgraph('mid-uuid', [ + midNode, + midSubgraphNode + ]) + + const nodes = [ + createMockNode(1), + createMockNode(2, { isSubgraph: true, subgraph: midSubgraph }) + ] + + const graph = createMockGraph(nodes) + const results = mapAllNodes(graph, (node) => `node-${node.id}`) + + expect(results).toHaveLength(5) + expect(results).toContain('node-300') + }) + }) + + describe('forEachNode', () => { + it('should execute function on all nodes in a flat graph', () => { + const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)] + const graph = createMockGraph(nodes) + + const visited: number[] = [] + forEachNode(graph, (node) => { + visited.push(node.id as number) + }) + + expect(visited).toHaveLength(3) + expect(visited).toContain(1) + expect(visited).toContain(2) + expect(visited).toContain(3) + }) + + it('should execute function on nodes in subgraphs', () => { + const subNode = createMockNode(100) + const subgraph = createMockSubgraph('sub-uuid', [subNode]) + + const nodes = [ + createMockNode(1), + createMockNode(2, { isSubgraph: true, subgraph }) + ] + + const graph = createMockGraph(nodes) + + const visited: number[] = [] + forEachNode(graph, (node) => { + visited.push(node.id as number) + }) + + expect(visited).toHaveLength(3) + expect(visited).toContain(100) + }) + + it('should allow node mutations', () => { + const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)] + const graph = createMockGraph(nodes) + + // Add a title property to each node + forEachNode(graph, (node) => { + ;(node as any).title = `Node ${node.id}` + }) + + expect(nodes[0]).toHaveProperty('title', 'Node 1') + expect(nodes[1]).toHaveProperty('title', 'Node 2') + expect(nodes[2]).toHaveProperty('title', 'Node 3') + }) + + it('should handle node type matching for subgraph references', () => { + const subgraphId = 'my-subgraph-123' + const nodes = [ + createMockNode(1), + { ...createMockNode(2), type: subgraphId } as LGraphNode, + createMockNode(3), + { ...createMockNode(4), type: subgraphId } as LGraphNode + ] + const graph = createMockGraph(nodes) + + const matchingNodes: number[] = [] + forEachNode(graph, (node) => { + if (node.type === subgraphId) { + matchingNodes.push(node.id as number) + } + }) + + expect(matchingNodes).toEqual([2, 4]) + }) + }) + + describe('findNodeInHierarchy', () => { + it('should find node in root graph', () => { + const nodes = [createMockNode(1), createMockNode(2), createMockNode(3)] + + const graph = createMockGraph(nodes) + const found = findNodeInHierarchy(graph, 2) + + expect(found).toBeTruthy() + expect(found?.id).toBe(2) + }) + + it('should find node in subgraph', () => { + const subNode = createMockNode(100) + const subgraph = createMockSubgraph('sub-uuid', [subNode]) + + const nodes = [ + createMockNode(1), + createMockNode(2, { isSubgraph: true, subgraph }) + ] + + const graph = createMockGraph(nodes) + const found = findNodeInHierarchy(graph, 100) + + expect(found).toBeTruthy() + expect(found?.id).toBe(100) + }) + + it('should return null for non-existent node', () => { + const nodes = [createMockNode(1), createMockNode(2)] + const graph = createMockGraph(nodes) + + const found = findNodeInHierarchy(graph, 999) + expect(found).toBeNull() + }) + }) + + describe('findSubgraphByUuid', () => { + it('should find subgraph by UUID', () => { + const targetUuid = 'target-uuid' + const subgraph = createMockSubgraph(targetUuid, []) + + const nodes = [ + createMockNode(1), + createMockNode(2, { isSubgraph: true, subgraph }) + ] + + const graph = createMockGraph(nodes) + const found = findSubgraphByUuid(graph, targetUuid) + + expect(found).toBe(subgraph) + expect(found?.id).toBe(targetUuid) + }) + + it('should find nested subgraph', () => { + const targetUuid = 'deep-uuid' + const deepSubgraph = createMockSubgraph(targetUuid, []) + + const midSubgraph = createMockSubgraph('mid-uuid', [ + createMockNode(200, { isSubgraph: true, subgraph: deepSubgraph }) + ]) + + const graph = createMockGraph([ + createMockNode(1, { isSubgraph: true, subgraph: midSubgraph }) + ]) + + const found = findSubgraphByUuid(graph, targetUuid) + + expect(found).toBe(deepSubgraph) + expect(found?.id).toBe(targetUuid) + }) + + it('should return null for non-existent UUID', () => { + const subgraph = createMockSubgraph('some-uuid', []) + const graph = createMockGraph([ + createMockNode(1, { isSubgraph: true, subgraph }) + ]) + + const found = findSubgraphByUuid(graph, 'non-existent-uuid') + expect(found).toBeNull() + }) + }) + + describe('getNodeByExecutionId', () => { + it('should find node in root graph', () => { + const nodes = [createMockNode('123'), createMockNode('456')] + + const graph = createMockGraph(nodes) + const found = getNodeByExecutionId(graph, '123') + + expect(found).toBeTruthy() + expect(found?.id).toBe('123') + }) + + it('should find node in subgraph using execution path', () => { + const targetNode = createMockNode('789') + const subgraph = createMockSubgraph('sub-uuid', [targetNode]) + + const subgraphNode = createMockNode('456', { + isSubgraph: true, + subgraph + }) + + const graph = createMockGraph([createMockNode('123'), subgraphNode]) + + const found = getNodeByExecutionId(graph, '456:789') + + expect(found).toBe(targetNode) + expect(found?.id).toBe('789') + }) + + it('should handle deeply nested execution paths', () => { + const targetNode = createMockNode('999') + const deepSubgraph = createMockSubgraph('deep-uuid', [targetNode]) + + const midNode = createMockNode('456', { + isSubgraph: true, + subgraph: deepSubgraph + }) + const midSubgraph = createMockSubgraph('mid-uuid', [midNode]) + + const topNode = createMockNode('123', { + isSubgraph: true, + subgraph: midSubgraph + }) + + const graph = createMockGraph([topNode]) + + const found = getNodeByExecutionId(graph, '123:456:999') + + expect(found).toBe(targetNode) + expect(found?.id).toBe('999') + }) + + it('should return null for invalid path', () => { + const subgraph = createMockSubgraph('sub-uuid', [createMockNode('789')]) + const graph = createMockGraph([ + createMockNode('456', { isSubgraph: true, subgraph }) + ]) + + // Wrong path - node 123 doesn't exist + const found = getNodeByExecutionId(graph, '123:789') + expect(found).toBeNull() + }) + + it('should return null for invalid execution ID', () => { + const graph = createMockGraph([createMockNode('123')]) + const found = getNodeByExecutionId(graph, '') + expect(found).toBeNull() + }) + }) + + describe('getNodeByLocatorId', () => { + it('should find node in root graph', () => { + const nodes = [createMockNode('123'), createMockNode('456')] + + const graph = createMockGraph(nodes) + const found = getNodeByLocatorId(graph, '123') + + expect(found).toBeTruthy() + expect(found?.id).toBe('123') + }) + + it('should find node in subgraph using UUID format', () => { + const targetUuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + const targetNode = createMockNode('789') + const subgraph = createMockSubgraph(targetUuid, [targetNode]) + + const graph = createMockGraph([ + createMockNode('123'), + createMockNode('456', { isSubgraph: true, subgraph }) + ]) + + const locatorId = `${targetUuid}:789` + const found = getNodeByLocatorId(graph, locatorId) + + expect(found).toBe(targetNode) + expect(found?.id).toBe('789') + }) + + it('should return null for invalid locator ID', () => { + const graph = createMockGraph([createMockNode('123')]) + + const found = getNodeByLocatorId(graph, 'invalid:::format') + expect(found).toBeNull() + }) + + it('should return null when subgraph UUID not found', () => { + const subgraph = createMockSubgraph('some-uuid', [ + createMockNode('789') + ]) + const graph = createMockGraph([ + createMockNode('456', { isSubgraph: true, subgraph }) + ]) + + const locatorId = 'non-existent-uuid:789' + const found = getNodeByLocatorId(graph, locatorId) + expect(found).toBeNull() + }) + }) + + describe('getRootGraph', () => { + it('should return the same graph if it is already root', () => { + const graph = createMockGraph([]) + expect(getRootGraph(graph)).toBe(graph) + }) + + it('should return root graph from subgraph', () => { + const rootGraph = createMockGraph([]) + const subgraph = createMockSubgraph('sub-uuid', []) + ;(subgraph as any).rootGraph = rootGraph + + expect(getRootGraph(subgraph)).toBe(rootGraph) + }) + + it('should return root graph from deeply nested subgraph', () => { + const rootGraph = createMockGraph([]) + const midSubgraph = createMockSubgraph('mid-uuid', []) + const deepSubgraph = createMockSubgraph('deep-uuid', []) + + ;(midSubgraph as any).rootGraph = rootGraph + ;(deepSubgraph as any).rootGraph = midSubgraph + + expect(getRootGraph(deepSubgraph)).toBe(rootGraph) + }) + }) + + describe('forEachSubgraphNode', () => { + it('should apply function to all nodes matching subgraph type', () => { + const subgraphId = 'my-subgraph-123' + const nodes = [ + createMockNode(1), + { ...createMockNode(2), type: subgraphId } as LGraphNode, + createMockNode(3), + { ...createMockNode(4), type: subgraphId } as LGraphNode + ] + const graph = createMockGraph(nodes) + + const matchingIds: number[] = [] + forEachSubgraphNode(graph, subgraphId, (node) => { + matchingIds.push(node.id as number) + }) + + expect(matchingIds).toEqual([2, 4]) + }) + + it('should work with root graph directly', () => { + const subgraphId = 'target-subgraph' + const rootNodes = [ + { ...createMockNode(1), type: subgraphId } as LGraphNode, + createMockNode(2), + { ...createMockNode(3), type: subgraphId } as LGraphNode + ] + const rootGraph = createMockGraph(rootNodes) + + const matchingIds: number[] = [] + forEachSubgraphNode(rootGraph, subgraphId, (node) => { + matchingIds.push(node.id as number) + }) + + expect(matchingIds).toEqual([1, 3]) + }) + + it('should handle null inputs gracefully', () => { + const fn = vi.fn() + + forEachSubgraphNode(null, 'id', fn) + forEachSubgraphNode(createMockGraph([]), null, fn) + forEachSubgraphNode(null, null, fn) + + expect(fn).not.toHaveBeenCalled() + }) + + it('should allow node mutations like title updates', () => { + const subgraphId = 'my-subgraph' + const nodes = [ + { ...createMockNode(1), type: subgraphId } as LGraphNode, + { ...createMockNode(2), type: subgraphId } as LGraphNode, + createMockNode(3) + ] + const graph = createMockGraph(nodes) + + forEachSubgraphNode(graph, subgraphId, (node) => { + ;(node as any).title = 'Updated Title' + }) + + expect(nodes[0]).toHaveProperty('title', 'Updated Title') + expect(nodes[1]).toHaveProperty('title', 'Updated Title') + expect(nodes[2]).not.toHaveProperty('title', 'Updated Title') + }) + }) + + describe('mapSubgraphNodes', () => { + it('should map over nodes matching subgraph type', () => { + const subgraphId = 'my-subgraph-123' + const nodes = [ + createMockNode(1), + { ...createMockNode(2), type: subgraphId } as LGraphNode, + createMockNode(3), + { ...createMockNode(4), type: subgraphId } as LGraphNode + ] + const graph = createMockGraph(nodes) + + const results = mapSubgraphNodes(graph, subgraphId, (node) => node.id) + + expect(results).toEqual([2, 4]) + }) + + it('should return empty array for null inputs', () => { + expect(mapSubgraphNodes(null, 'id', (n) => n.id)).toEqual([]) + expect( + mapSubgraphNodes(createMockGraph([]), null, (n) => n.id) + ).toEqual([]) + }) + + it('should work with complex transformations', () => { + const subgraphId = 'target' + const nodes = [ + { ...createMockNode(1), type: subgraphId } as LGraphNode, + { ...createMockNode(2), type: 'other' } as LGraphNode, + { ...createMockNode(3), type: subgraphId } as LGraphNode + ] + const graph = createMockGraph(nodes) + + const results = mapSubgraphNodes(graph, subgraphId, (node) => ({ + id: node.id, + isTarget: true + })) + + expect(results).toEqual([ + { id: 1, isTarget: true }, + { id: 3, isTarget: true } + ]) + }) + }) + + describe('getAllNonIoNodesInSubgraph', () => { + it('should filter out SubgraphInputNode and SubgraphOutputNode', () => { + const nodes = [ + { id: 'input', constructor: { comfyClass: 'SubgraphInputNode' } }, + { id: 'output', constructor: { comfyClass: 'SubgraphOutputNode' } }, + { id: 'user1', constructor: { comfyClass: 'CLIPTextEncode' } }, + { id: 'user2', constructor: { comfyClass: 'KSampler' } } + ] as LGraphNode[] + + const subgraph = createMockSubgraph('sub-uuid', nodes) + const nonIoNodes = getAllNonIoNodesInSubgraph(subgraph) + + expect(nonIoNodes).toHaveLength(2) + expect(nonIoNodes.map((n) => n.id)).toEqual(['user1', 'user2']) + }) + + it('should handle subgraph with only IO nodes', () => { + const nodes = [ + { id: 'input', constructor: { comfyClass: 'SubgraphInputNode' } }, + { id: 'output', constructor: { comfyClass: 'SubgraphOutputNode' } } + ] as LGraphNode[] + + const subgraph = createMockSubgraph('sub-uuid', nodes) + const nonIoNodes = getAllNonIoNodesInSubgraph(subgraph) + + expect(nonIoNodes).toHaveLength(0) + }) + + it('should handle subgraph with only user nodes', () => { + const nodes = [ + { id: 'user1', constructor: { comfyClass: 'CLIPTextEncode' } }, + { id: 'user2', constructor: { comfyClass: 'KSampler' } } + ] as LGraphNode[] + + const subgraph = createMockSubgraph('sub-uuid', nodes) + const nonIoNodes = getAllNonIoNodesInSubgraph(subgraph) + + expect(nonIoNodes).toHaveLength(2) + expect(nonIoNodes).toEqual(nodes) + }) + + it('should handle empty subgraph', () => { + const subgraph = createMockSubgraph('sub-uuid', []) + const nonIoNodes = getAllNonIoNodesInSubgraph(subgraph) + + expect(nonIoNodes).toHaveLength(0) + }) + }) + }) +}) diff --git a/tests-ui/tests/utils/serachAndReplace.test.ts b/tests-ui/tests/utils/serachAndReplace.test.ts index 2c61df1c7..2a8515369 100644 --- a/tests-ui/tests/utils/serachAndReplace.test.ts +++ b/tests-ui/tests/utils/serachAndReplace.test.ts @@ -1,3 +1,4 @@ +import { LGraph } from '@comfyorg/litegraph' import type { LGraphNode } from '@comfyorg/litegraph' import { describe, expect, it } from 'vitest' @@ -21,7 +22,11 @@ describe('applyTextReplacements', () => { } as LGraphNode ] - const result = applyTextReplacements(mockNodes, '%TestNode.testWidget%') + const mockGraph = new LGraph() + for (const node of mockNodes) { + mockGraph.add(node) + } + const result = applyTextReplacements(mockGraph, '%TestNode.testWidget%') // The expected result should have all invalid characters replaced with underscores expect(result).toBe('file_name_with_invalid_chars_____control_chars__') @@ -51,7 +56,11 @@ describe('applyTextReplacements', () => { } as LGraphNode ] - const result = applyTextReplacements(mockNodes, '%TestNode.testWidget%') + const mockGraph = new LGraph() + for (const node of mockNodes) { + mockGraph.add(node) + } + const result = applyTextReplacements(mockGraph, '%TestNode.testWidget%') expect(result).toBe(expected) } }) @@ -66,7 +75,11 @@ describe('applyTextReplacements', () => { } as LGraphNode ] - const result = applyTextReplacements(mockNodes, '%TestNode.testWidget%') + const mockGraph = new LGraph() + for (const node of mockNodes) { + mockGraph.add(node) + } + const result = applyTextReplacements(mockGraph, '%TestNode.testWidget%') expect(result).toBe(validChars) }) }) diff --git a/tests-ui/tests/utils/typeGuardUtil.test.ts b/tests-ui/tests/utils/typeGuardUtil.test.ts new file mode 100644 index 000000000..9c0689271 --- /dev/null +++ b/tests-ui/tests/utils/typeGuardUtil.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' + +import { isSubgraphIoNode } from '@/utils/typeGuardUtil' + +describe('typeGuardUtil', () => { + describe('isSubgraphIoNode', () => { + it('should identify SubgraphInputNode as IO node', () => { + const node = { + constructor: { comfyClass: 'SubgraphInputNode' } + } as any + + expect(isSubgraphIoNode(node)).toBe(true) + }) + + it('should identify SubgraphOutputNode as IO node', () => { + const node = { + constructor: { comfyClass: 'SubgraphOutputNode' } + } as any + + expect(isSubgraphIoNode(node)).toBe(true) + }) + + it('should not identify regular nodes as IO nodes', () => { + const node = { + constructor: { comfyClass: 'CLIPTextEncode' } + } as any + + expect(isSubgraphIoNode(node)).toBe(false) + }) + + it('should handle nodes without constructor', () => { + const node = {} as any + + expect(isSubgraphIoNode(node)).toBe(false) + }) + + it('should handle nodes without comfyClass', () => { + const node = { + constructor: {} + } as any + + expect(isSubgraphIoNode(node)).toBe(false) + }) + }) +}) diff --git a/tests-ui/tests/widgets/domWidget.test.ts b/tests-ui/tests/widgets/domWidget.test.ts new file mode 100644 index 000000000..8c5695f33 --- /dev/null +++ b/tests-ui/tests/widgets/domWidget.test.ts @@ -0,0 +1,82 @@ +import { LGraphNode } from '@comfyorg/litegraph' +import { describe, expect, test, vi } from 'vitest' + +import { ComponentWidgetImpl, DOMWidgetImpl } from '@/scripts/domWidget' + +// Mock dependencies +vi.mock('@/stores/domWidgetStore', () => ({ + useDomWidgetStore: () => ({ + unregisterWidget: vi.fn() + }) +})) + +vi.mock('@/utils/formatUtil', () => ({ + generateUUID: () => 'test-uuid' +})) + +describe('DOMWidget Y Position Preservation', () => { + test('BaseDOMWidgetImpl createCopyForNode preserves Y position', () => { + const mockNode = new LGraphNode('test-node') + const originalWidget = new ComponentWidgetImpl({ + node: mockNode, + name: 'test-widget', + component: { template: '
' }, + inputSpec: { name: 'test', type: 'string' }, + options: {} + }) + + // Set a specific Y position + originalWidget.y = 66 + + const newNode = new LGraphNode('new-node') + const clonedWidget = originalWidget.createCopyForNode(newNode) + + // Verify Y position is preserved + expect(clonedWidget.y).toBe(66) + expect(clonedWidget.node).toBe(newNode) + expect(clonedWidget.name).toBe('test-widget') + }) + + test('DOMWidgetImpl createCopyForNode preserves Y position', () => { + const mockNode = new LGraphNode('test-node') + const mockElement = document.createElement('div') + + const originalWidget = new DOMWidgetImpl({ + node: mockNode, + name: 'test-dom-widget', + type: 'test', + element: mockElement, + options: {} + }) + + // Set a specific Y position + originalWidget.y = 42 + + const newNode = new LGraphNode('new-node') + const clonedWidget = originalWidget.createCopyForNode(newNode) + + // Verify Y position is preserved + expect(clonedWidget.y).toBe(42) + expect(clonedWidget.node).toBe(newNode) + expect(clonedWidget.element).toBe(mockElement) + expect(clonedWidget.name).toBe('test-dom-widget') + }) + + test('Y position defaults to 0 when not set', () => { + const mockNode = new LGraphNode('test-node') + const originalWidget = new ComponentWidgetImpl({ + node: mockNode, + name: 'test-widget', + component: { template: '
' }, + inputSpec: { name: 'test', type: 'string' }, + options: {} + }) + + // Don't explicitly set Y (should be 0 by default) + const newNode = new LGraphNode('new-node') + const clonedWidget = originalWidget.createCopyForNode(newNode) + + // Verify Y position is preserved (should be 0) + expect(clonedWidget.y).toBe(0) + }) +}) diff --git a/vite.config.mts b/vite.config.mts index c866656a0..f7d527981 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -1,5 +1,6 @@ import vue from '@vitejs/plugin-vue' import dotenv from 'dotenv' +import { FileSystemIconLoader } from 'unplugin-icons/loaders' import IconsResolver from 'unplugin-icons/resolver' import Icons from 'unplugin-icons/vite' import Components from 'unplugin-vue-components/vite' @@ -120,12 +121,19 @@ export default defineConfig({ ]), Icons({ - compiler: 'vue3' + compiler: 'vue3', + customCollections: { + comfy: FileSystemIconLoader('src/assets/icons/custom') + } }), Components({ dts: true, - resolvers: [IconsResolver()], + resolvers: [ + IconsResolver({ + customCollections: ['comfy'] + }) + ], dirs: ['src/components', 'src/layout', 'src/views'], deep: true, extensions: ['vue']