Compare commits
2 Commits
v1.25.0
...
fix/api-no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a8684bcef | ||
|
|
e770b81df9 |
@@ -1,708 +0,0 @@
|
||||
# Comprehensive PR Review for ComfyUI Frontend
|
||||
|
||||
<task>
|
||||
You are performing a comprehensive code review for PR #$1 in the ComfyUI frontend repository. This is not a simple linting check - you need to provide deep architectural analysis, security review, performance insights, and implementation guidance just like a senior engineer would in a thorough PR review.
|
||||
|
||||
Your review should cover:
|
||||
1. Architecture and design patterns
|
||||
2. Security vulnerabilities and risks
|
||||
3. Performance implications
|
||||
4. Code quality and maintainability
|
||||
5. Integration with existing systems
|
||||
6. Best practices and conventions
|
||||
7. Testing considerations
|
||||
8. Documentation needs
|
||||
</task>
|
||||
|
||||
Arguments: PR number passed via PR_NUMBER environment variable
|
||||
|
||||
## Phase 0: Initialize Variables and Helper Functions
|
||||
|
||||
```bash
|
||||
# Validate PR_NUMBER first thing
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "Error: PR_NUMBER environment variable is not set"
|
||||
echo "Usage: PR_NUMBER=<number> claude run /comprehensive-pr-review"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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
|
||||
|
||||
# Helper function for posting review comments
|
||||
post_review_comment() {
|
||||
local file_path=$1
|
||||
local line_number=$2
|
||||
local severity=$3 # critical/high/medium/low
|
||||
local category=$4 # architecture/security/performance/quality
|
||||
local issue=$5
|
||||
local context=$6
|
||||
local suggestion=$7
|
||||
|
||||
# Update counters
|
||||
case $severity in
|
||||
"critical") ((CRITICAL_COUNT++)) ;;
|
||||
"high") ((HIGH_COUNT++)) ;;
|
||||
"medium") ((MEDIUM_COUNT++)) ;;
|
||||
"low") ((LOW_COUNT++)) ;;
|
||||
esac
|
||||
|
||||
case $category in
|
||||
"architecture") ((ARCHITECTURE_ISSUES++)) ;;
|
||||
"security") ((SECURITY_ISSUES++)) ;;
|
||||
"performance") ((PERFORMANCE_ISSUES++)) ;;
|
||||
"quality") ((QUALITY_ISSUES++)) ;;
|
||||
esac
|
||||
|
||||
# Post inline comment via GitHub CLI
|
||||
local comment="${issue}\n${context}\n${suggestion}"
|
||||
gh pr review $PR_NUMBER --comment --body "$comment" -F - <<< "$comment"
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 1: Environment Setup and PR Context
|
||||
|
||||
```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"
|
||||
}
|
||||
|
||||
# Run pre-flight checks
|
||||
check_prerequisites
|
||||
|
||||
echo "Starting comprehensive review of PR #$PR_NUMBER"
|
||||
|
||||
# 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
|
||||
|
||||
# Extract branch names
|
||||
BASE_BRANCH=$(jq -r '.baseRefName' < pr_info.json)
|
||||
HEAD_BRANCH=$(jq -r '.headRefName' < pr_info.json)
|
||||
|
||||
# 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"
|
||||
|
||||
# Get changed files using git locally (much faster)
|
||||
git diff --name-only "origin/$BASE_BRANCH" > changed_files.txt
|
||||
|
||||
# Get the diff using git locally
|
||||
git diff "origin/$BASE_BRANCH" > pr_diff.txt
|
||||
|
||||
# Get detailed file changes with line numbers
|
||||
git diff --name-status "origin/$BASE_BRANCH" > file_changes.txt
|
||||
|
||||
# For API compatibility, create a simplified pr_files.json
|
||||
echo '[]' > pr_files.json
|
||||
while IFS=$'\t' read -r status file; do
|
||||
if [[ "$status" != "D" ]]; then # Skip deleted files
|
||||
# Get the patch for this file
|
||||
patch=$(git diff "origin/$BASE_BRANCH" -- "$file" | jq -Rs .)
|
||||
additions=$(git diff --numstat "origin/$BASE_BRANCH" -- "$file" | awk '{print $1}')
|
||||
deletions=$(git diff --numstat "origin/$BASE_BRANCH" -- "$file" | awk '{print $2}')
|
||||
|
||||
jq --arg file "$file" \
|
||||
--arg patch "$patch" \
|
||||
--arg additions "$additions" \
|
||||
--arg deletions "$deletions" \
|
||||
'. += [{
|
||||
"filename": $file,
|
||||
"patch": $patch,
|
||||
"additions": ($additions | tonumber),
|
||||
"deletions": ($deletions | tonumber)
|
||||
}]' pr_files.json > pr_files.json.tmp
|
||||
mv pr_files.json.tmp pr_files.json
|
||||
fi
|
||||
done < file_changes.txt
|
||||
|
||||
# Setup caching directory
|
||||
CACHE_DIR=".claude-review-cache"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
# Function to get cached analysis
|
||||
get_cached_analysis() {
|
||||
local file_path=$1
|
||||
local file_hash=$(git hash-object "$file_path" 2>/dev/null || echo "no-hash")
|
||||
|
||||
if [ "$file_hash" != "no-hash" ] && [ -f "$CACHE_DIR/${file_hash}.cache" ]; then
|
||||
cat "$CACHE_DIR/${file_hash}.cache"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Clean old cache entries (older than 7 days)
|
||||
find "$CACHE_DIR" -name "*.cache" -mtime +7 -delete 2>/dev/null || true
|
||||
```
|
||||
|
||||
## Phase 2: Load Comprehensive Knowledge Base
|
||||
|
||||
```bash
|
||||
# Don't create knowledge directory until we know we need it
|
||||
KNOWLEDGE_FOUND=false
|
||||
|
||||
# Use local cache for knowledge base to avoid repeated downloads
|
||||
KNOWLEDGE_CACHE_DIR=".claude-knowledge-cache"
|
||||
mkdir -p "$KNOWLEDGE_CACHE_DIR"
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 5. Load local project guidelines
|
||||
if [ -f "CLAUDE.md" ]; then
|
||||
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
|
||||
mkdir -p review_knowledge
|
||||
KNOWLEDGE_FOUND=true
|
||||
fi
|
||||
cp CLAUDE.md review_knowledge/local_claude.md
|
||||
fi
|
||||
if [ -f ".github/CLAUDE.md" ]; then
|
||||
if [ "$KNOWLEDGE_FOUND" = "false" ]; then
|
||||
mkdir -p review_knowledge
|
||||
KNOWLEDGE_FOUND=true
|
||||
fi
|
||||
cp .github/CLAUDE.md review_knowledge/github_claude.md
|
||||
fi
|
||||
```
|
||||
|
||||
## Phase 3: Deep Analysis Instructions
|
||||
|
||||
Perform a comprehensive analysis covering these areas:
|
||||
|
||||
### 3.1 Architectural Analysis
|
||||
Based on the repository guide and project summary, evaluate:
|
||||
- Does this change align with the established architecture patterns?
|
||||
- Are domain boundaries respected?
|
||||
- Is the extension system used appropriately?
|
||||
- Are components properly organized by feature?
|
||||
- Does it follow the established service/composable/store patterns?
|
||||
|
||||
### 3.2 Code Quality Beyond Linting
|
||||
- Cyclomatic complexity and cognitive load
|
||||
- SOLID principles adherence
|
||||
- DRY violations that aren't caught by simple duplication checks
|
||||
- Proper abstraction levels
|
||||
- Interface design and API clarity
|
||||
- No 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
|
||||
|
||||
### 3.4 Security Deep Dive
|
||||
Beyond obvious vulnerabilities:
|
||||
- Authentication/authorization implications
|
||||
- Data validation completeness
|
||||
- State management security
|
||||
- Cross-origin concerns
|
||||
- Extension security boundaries
|
||||
|
||||
### 3.5 Performance Analysis
|
||||
- Render performance implications
|
||||
- Layout thrashing prevention
|
||||
- Memory leak potential
|
||||
- Network request optimization
|
||||
- State management efficiency
|
||||
|
||||
### 3.6 Integration Concerns
|
||||
- Breaking changes to internal APIs
|
||||
- Extension compatibility
|
||||
- Backward compatibility
|
||||
- Migration requirements
|
||||
|
||||
## Phase 4: Create Detailed Review 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
|
||||
|
||||
For each issue found, create a concise inline comment with:
|
||||
1. What's wrong (one line)
|
||||
2. Why it matters (one line)
|
||||
3. How to fix it (one line)
|
||||
4. Code example only if essential
|
||||
|
||||
```bash
|
||||
# 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"
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 5: Validation Rules Application
|
||||
|
||||
Apply ALL validation rules from the loaded knowledge, but focus on the changed lines:
|
||||
|
||||
### From Frontend Standards
|
||||
- Vue 3 Composition API patterns
|
||||
- Component communication patterns
|
||||
- Proper use of composables
|
||||
- TypeScript strict mode compliance
|
||||
- Bundle optimization
|
||||
|
||||
### From Security Audit
|
||||
- Input validation
|
||||
- XSS prevention
|
||||
- CSRF protection
|
||||
- Secure state management
|
||||
- API security
|
||||
|
||||
### From Performance Check
|
||||
- Render optimization
|
||||
- Memory management
|
||||
- Network efficiency
|
||||
- Bundle size impact
|
||||
|
||||
## Phase 6: Contextual Review Based on PR Type
|
||||
|
||||
Analyze the PR description and changes to determine the 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
|
||||
```
|
||||
|
||||
## Phase 7: Generate Comprehensive Summary
|
||||
|
||||
After all inline comments, create a detailed summary:
|
||||
|
||||
```bash
|
||||
# Initialize metrics tracking
|
||||
REVIEW_START_TIME=$(date +%s)
|
||||
|
||||
# Create the comprehensive summary
|
||||
gh pr review $PR_NUMBER --comment --body "# Comprehensive PR Review
|
||||
|
||||
This review is generated by Claude. It may not always be accurate, as with human reviewers. If you believe that any of the comments are invalid or incorrect, please state why for each. For others, please implement the changes in one way or another.
|
||||
|
||||
## Review Summary
|
||||
|
||||
**PR**: $PR_TITLE (#$PR_NUMBER)
|
||||
**Impact**: $ADDITIONS additions, $DELETIONS deletions across $FILE_COUNT files
|
||||
|
||||
### Issue Distribution
|
||||
- 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
|
||||
|
||||
## Key Findings
|
||||
|
||||
### Architecture & Design
|
||||
[Detailed architectural analysis based on repository patterns]
|
||||
|
||||
### Security Considerations
|
||||
[Security implications beyond basic vulnerabilities]
|
||||
|
||||
### Performance Impact
|
||||
[Performance analysis including bundle size, render impact]
|
||||
|
||||
### Integration Points
|
||||
[How this affects other systems, extensions, etc.]
|
||||
|
||||
## Positive Observations
|
||||
[What was done well, good patterns followed]
|
||||
|
||||
## References
|
||||
- [Repository Architecture Guide](https://github.com/Comfy-Org/comfy-claude-prompt-library/blob/master/project-summaries-for-agents/ComfyUI_frontend/REPOSITORY_GUIDE.md)
|
||||
- [Frontend Standards](https://github.com/Comfy-Org/comfy-claude-prompt-library/blob/master/.claude/commands/validation/frontend-code-standards.md)
|
||||
- [Security Guidelines](https://github.com/Comfy-Org/comfy-claude-prompt-library/blob/master/.claude/commands/validation/security-audit.md)
|
||||
|
||||
## Next Steps
|
||||
1. Address critical issues before merge
|
||||
2. Consider architectural feedback for long-term maintainability
|
||||
3. Add tests for uncovered scenarios
|
||||
4. Update documentation if needed
|
||||
|
||||
---
|
||||
*This is a comprehensive automated review. For architectural decisions requiring human judgment, please request additional manual review.*"
|
||||
```
|
||||
|
||||
## Important: Think Deeply
|
||||
|
||||
When reviewing:
|
||||
1. **Think hard** about architectural implications
|
||||
2. Consider how changes affect the entire system
|
||||
3. Look for subtle bugs and edge cases
|
||||
4. Evaluate maintainability over time
|
||||
5. Consider extension developer experience
|
||||
6. Think about migration paths
|
||||
|
||||
This is a COMPREHENSIVE review, not a linting pass. Provide the same quality feedback a senior engineer would give after careful consideration.
|
||||
|
||||
## Phase 8: Track Review Metrics
|
||||
|
||||
After completing the review, save metrics for analysis:
|
||||
|
||||
```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.
|
||||
@@ -1,625 +0,0 @@
|
||||
# Create Frontend Release
|
||||
|
||||
This command guides you through creating a comprehensive frontend release with semantic versioning analysis, automated change detection, security scanning, and multi-stage human verification.
|
||||
|
||||
<task>
|
||||
Create a frontend release with version type: $ARGUMENTS
|
||||
|
||||
Expected format: Version increment type and optional description
|
||||
Examples:
|
||||
- `patch` - Bug fixes only
|
||||
- `minor` - New features, backward compatible
|
||||
- `major` - Breaking changes
|
||||
- `prerelease` - Alpha/beta/rc releases
|
||||
- `patch "Critical security fixes"` - With custom description
|
||||
- `minor --skip-changelog` - Skip automated changelog generation
|
||||
- `minor --dry-run` - Simulate release without executing
|
||||
|
||||
If no arguments provided, the command will always perform prerelease if the current version is prerelease, or patch in other cases. This command will never perform minor or major releases without explicit direction.
|
||||
</task>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before starting, ensure:
|
||||
- You have push access to the repository
|
||||
- GitHub CLI (`gh`) is authenticated
|
||||
- You're on a clean main branch working tree
|
||||
- All intended changes are merged to main
|
||||
- You understand the scope of changes being released
|
||||
|
||||
## Critical Checks Before Starting
|
||||
|
||||
### 1. Check Current Version Status
|
||||
```bash
|
||||
# Get current version and check if it's a pre-release
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
if [[ "$CURRENT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then
|
||||
echo "⚠️ Current version $CURRENT_VERSION is a pre-release"
|
||||
echo "Consider releasing stable (e.g., 1.24.0-1 → 1.24.0) first"
|
||||
fi
|
||||
```
|
||||
|
||||
### 2. Find Last Stable Release
|
||||
```bash
|
||||
# Get last stable release tag (no pre-release suffix)
|
||||
LAST_STABLE=$(git tag -l "v*" | grep -v "\-" | sort -V | tail -1)
|
||||
echo "Last stable release: $LAST_STABLE"
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
**Environment Variables:**
|
||||
- `RELEASE_SKIP_SECURITY_SCAN=true` - Skip security audit
|
||||
- `RELEASE_AUTO_APPROVE=true` - Skip some confirmation prompts
|
||||
- `RELEASE_DRY_RUN=true` - Simulate release without executing
|
||||
|
||||
## Release Process
|
||||
|
||||
### Step 1: Environment Safety Check
|
||||
|
||||
1. Verify clean working directory:
|
||||
```bash
|
||||
git status --porcelain
|
||||
```
|
||||
2. Confirm on main branch:
|
||||
```bash
|
||||
git branch --show-current
|
||||
```
|
||||
3. Pull latest changes:
|
||||
```bash
|
||||
git pull origin main
|
||||
```
|
||||
4. Check GitHub CLI authentication:
|
||||
```bash
|
||||
gh auth status
|
||||
```
|
||||
5. Verify npm/PyPI publishing access (dry run)
|
||||
6. **CONFIRMATION REQUIRED**: Environment ready for release?
|
||||
|
||||
### Step 2: Analyze Recent Changes
|
||||
|
||||
1. Get current version from package.json
|
||||
2. **IMPORTANT**: Determine correct base for comparison:
|
||||
```bash
|
||||
# If current version is pre-release, use last stable release
|
||||
if [[ "$CURRENT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then
|
||||
BASE_TAG=$LAST_STABLE
|
||||
else
|
||||
BASE_TAG=$(git describe --tags --abbrev=0)
|
||||
fi
|
||||
```
|
||||
3. Find commits since base release (CRITICAL: use --first-parent):
|
||||
```bash
|
||||
git log ${BASE_TAG}..HEAD --oneline --no-merges --first-parent
|
||||
```
|
||||
4. Count total commits:
|
||||
```bash
|
||||
COMMIT_COUNT=$(git log ${BASE_TAG}..HEAD --oneline --no-merges --first-parent | wc -l)
|
||||
echo "Found $COMMIT_COUNT commits since $BASE_TAG"
|
||||
```
|
||||
5. Analyze commits for:
|
||||
- Breaking changes (BREAKING CHANGE, !, feat())
|
||||
- New features (feat:, feature:)
|
||||
- Bug fixes (fix:, bugfix:)
|
||||
- Documentation changes (docs:)
|
||||
- Dependency updates
|
||||
6. **VERIFY PR TARGET BRANCHES**:
|
||||
```bash
|
||||
# Get merged PRs and verify they were merged to main
|
||||
gh pr list --state merged --limit 50 --json number,title,baseRefName,mergedAt | \
|
||||
jq -r '.[] | select(.baseRefName == "main") | "\(.number): \(.title)"'
|
||||
```
|
||||
7. **HUMAN ANALYSIS**: Review change summary and verify scope
|
||||
|
||||
### Step 3: Version Preview
|
||||
|
||||
**Version Preview:**
|
||||
- Current: `${CURRENT_VERSION}`
|
||||
- Proposed: Show exact version number
|
||||
- **CONFIRMATION REQUIRED**: Proceed with version `X.Y.Z`?
|
||||
|
||||
### Step 4: Security and Dependency Audit
|
||||
|
||||
1. Run security audit:
|
||||
```bash
|
||||
npm audit --audit-level moderate
|
||||
```
|
||||
2. Check for known vulnerabilities in dependencies
|
||||
3. Scan for hardcoded secrets or credentials:
|
||||
```bash
|
||||
git log -p ${BASE_TAG}..HEAD | grep -iE "(password|key|secret|token)" || echo "No sensitive data found"
|
||||
```
|
||||
4. Verify no sensitive data in recent commits
|
||||
5. **SECURITY REVIEW**: Address any critical findings before proceeding?
|
||||
|
||||
### Step 5: Pre-Release Testing
|
||||
|
||||
1. Run complete test suite:
|
||||
```bash
|
||||
npm run test:unit
|
||||
npm run test:component
|
||||
npm run test:browser
|
||||
```
|
||||
2. Run type checking:
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
3. Run linting (may have issues with missing packages):
|
||||
```bash
|
||||
npm run lint || echo "Lint issues - verify if critical"
|
||||
```
|
||||
4. Test build process:
|
||||
```bash
|
||||
npm run build
|
||||
npm run build:types
|
||||
```
|
||||
5. **QUALITY GATE**: All tests and builds passing?
|
||||
|
||||
### Step 6: Breaking Change Analysis
|
||||
|
||||
1. Analyze API changes in:
|
||||
- Public TypeScript interfaces
|
||||
- Extension APIs
|
||||
- Component props
|
||||
- CLAUDE.md guidelines
|
||||
2. Check for:
|
||||
- Removed public functions/classes
|
||||
- Changed function signatures
|
||||
- Deprecated feature removals
|
||||
- Configuration changes
|
||||
3. Generate breaking change summary
|
||||
4. **COMPATIBILITY REVIEW**: Breaking changes documented and justified?
|
||||
|
||||
### Step 7: Generate and Save Changelog
|
||||
|
||||
1. Extract commit messages since base release:
|
||||
```bash
|
||||
git log ${BASE_TAG}..HEAD --oneline --no-merges --first-parent > commits.txt
|
||||
```
|
||||
2. **CRITICAL**: Verify PR inclusion by checking merge location:
|
||||
```bash
|
||||
# For each significant PR mentioned, verify it's on main
|
||||
for PR in ${SIGNIFICANT_PRS}; do
|
||||
COMMIT=$(gh pr view $PR --json mergeCommit -q .mergeCommit.oid)
|
||||
git branch -r --contains $COMMIT | grep -q "origin/main" || \
|
||||
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)
|
||||
4. **Save release notes:**
|
||||
```bash
|
||||
# Enhanced release notes for GitHub
|
||||
echo "$RELEASE_NOTES" > github-release-notes-${NEW_VERSION}.md
|
||||
```
|
||||
5. **CONTENT REVIEW**: Release notes clear and helpful for users?
|
||||
|
||||
### Step 9: Create Version Bump PR
|
||||
|
||||
**For standard version bumps (patch/minor/major):**
|
||||
```bash
|
||||
# Trigger the workflow
|
||||
gh workflow run version-bump.yaml -f version_type=${VERSION_TYPE}
|
||||
|
||||
# Workflow runs quickly - usually creates PR within 30 seconds
|
||||
echo "Workflow triggered. Waiting for PR creation..."
|
||||
```
|
||||
|
||||
**For releasing a stable version:**
|
||||
1. Must manually create branch and update version:
|
||||
```bash
|
||||
git checkout -b version-bump-${NEW_VERSION}
|
||||
# Edit package.json to remove pre-release suffix
|
||||
git add package.json
|
||||
git commit -m "${NEW_VERSION}"
|
||||
git push origin version-bump-${NEW_VERSION}
|
||||
```
|
||||
|
||||
2. Wait for PR creation (if using workflow) or create manually:
|
||||
```bash
|
||||
# For workflow-created PRs - wait and find it
|
||||
sleep 30
|
||||
# Look for PR from comfy-pr-bot (not github-actions)
|
||||
PR_NUMBER=$(gh pr list --author comfy-pr-bot --limit 1 --json number --jq '.[0].number')
|
||||
|
||||
# Verify we got the PR
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "PR not found yet. Checking recent PRs..."
|
||||
gh pr list --limit 5 --json number,title,author
|
||||
fi
|
||||
|
||||
# For manual PRs
|
||||
gh pr create --title "${NEW_VERSION}" \
|
||||
--body-file enhanced-pr-description.md \
|
||||
--label "Release"
|
||||
```
|
||||
3. **Create enhanced PR description:**
|
||||
```bash
|
||||
cat > enhanced-pr-description.md << EOF
|
||||
# Release v${NEW_VERSION}
|
||||
|
||||
## Version Change
|
||||
\`${CURRENT_VERSION}\` → \`${NEW_VERSION}\` (${VERSION_TYPE})
|
||||
|
||||
## Changelog
|
||||
${CHANGELOG}
|
||||
|
||||
## Breaking Changes
|
||||
${BREAKING_CHANGES}
|
||||
|
||||
## Testing Performed
|
||||
- ✅ Full test suite (unit, component, browser)
|
||||
- ✅ TypeScript compilation
|
||||
- ✅ Linting checks
|
||||
- ✅ Build verification
|
||||
- ✅ Security audit
|
||||
|
||||
## Distribution Channels
|
||||
- GitHub Release (with dist.zip)
|
||||
- PyPI Package (comfyui-frontend-package)
|
||||
- npm Package (@comfyorg/comfyui-frontend-types)
|
||||
|
||||
## Post-Release Tasks
|
||||
- [ ] Verify all distribution channels
|
||||
- [ ] Update external documentation
|
||||
- [ ] Monitor for issues
|
||||
EOF
|
||||
```
|
||||
4. Update PR with enhanced description:
|
||||
```bash
|
||||
gh pr edit ${PR_NUMBER} --body-file enhanced-pr-description.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?
|
||||
|
||||
### Step 11: Critical Release PR Verification
|
||||
|
||||
1. **CRITICAL**: Verify PR has "Release" label:
|
||||
```bash
|
||||
gh pr view ${PR_NUMBER} --json labels | jq -r '.labels[].name' | grep -q "Release" || \
|
||||
echo "ERROR: Release label missing! Add it immediately!"
|
||||
```
|
||||
2. Check for update-locales commits:
|
||||
```bash
|
||||
# WARNING: update-locales may add [skip ci] which blocks release workflow!
|
||||
gh pr view ${PR_NUMBER} --json commits | grep -q "skip ci" && \
|
||||
echo "WARNING: [skip ci] detected - release workflow may not trigger!"
|
||||
```
|
||||
3. Verify version number in package.json
|
||||
4. Review all changed files
|
||||
5. Ensure no unintended changes included
|
||||
6. Wait for required PR checks:
|
||||
```bash
|
||||
gh pr checks ${PR_NUMBER} --watch
|
||||
```
|
||||
7. **FINAL CODE REVIEW**: Release label present and no [skip ci]?
|
||||
|
||||
### Step 12: Pre-Merge Validation
|
||||
|
||||
1. **Review Requirements**: Release PRs require approval
|
||||
2. Monitor CI checks - watch for update-locales
|
||||
3. **CRITICAL WARNING**: If update-locales adds [skip ci], the release workflow won't trigger!
|
||||
4. Check no new commits to main since PR creation
|
||||
5. **DEPLOYMENT READINESS**: Ready to merge?
|
||||
|
||||
### Step 13: Execute Release
|
||||
|
||||
1. **FINAL CONFIRMATION**: Merge PR to trigger release?
|
||||
2. Merge the Release PR:
|
||||
```bash
|
||||
gh pr merge ${PR_NUMBER} --merge
|
||||
```
|
||||
3. **IMMEDIATELY CHECK**: Did release workflow trigger?
|
||||
```bash
|
||||
sleep 10
|
||||
gh run list --workflow=release.yaml --limit=1
|
||||
```
|
||||
4. If workflow didn't trigger due to [skip ci]:
|
||||
```bash
|
||||
echo "ERROR: Release workflow didn't trigger!"
|
||||
echo "Options:"
|
||||
echo "1. Create patch release (e.g., 1.24.1) to trigger workflow"
|
||||
echo "2. Investigate manual release options"
|
||||
```
|
||||
5. If workflow triggered, monitor execution:
|
||||
```bash
|
||||
WORKFLOW_RUN_ID=$(gh run list --workflow=release.yaml --limit=1 --json databaseId --jq '.[0].databaseId')
|
||||
gh run watch ${WORKFLOW_RUN_ID}
|
||||
```
|
||||
|
||||
### Step 14: Enhance GitHub Release
|
||||
|
||||
1. Wait for automatic release creation:
|
||||
```bash
|
||||
# Wait for release to be created
|
||||
while ! gh release view v${NEW_VERSION} >/dev/null 2>&1; do
|
||||
echo "Waiting for release creation..."
|
||||
sleep 10
|
||||
done
|
||||
```
|
||||
|
||||
2. **Enhance the GitHub release:**
|
||||
```bash
|
||||
# Update release with our enhanced notes
|
||||
gh release edit v${NEW_VERSION} \
|
||||
--title "🚀 ComfyUI Frontend v${NEW_VERSION}" \
|
||||
--notes-file github-release-notes-${NEW_VERSION}.md \
|
||||
--latest
|
||||
|
||||
# Add any additional assets if needed
|
||||
# gh release upload v${NEW_VERSION} additional-assets.zip
|
||||
```
|
||||
|
||||
3. **Verify release details:**
|
||||
```bash
|
||||
gh release view v${NEW_VERSION}
|
||||
```
|
||||
|
||||
### Step 15: Verify Multi-Channel Distribution
|
||||
|
||||
1. **GitHub Release:**
|
||||
```bash
|
||||
gh release view v${NEW_VERSION} --json assets,body,createdAt,tagName
|
||||
```
|
||||
- ✅ Check release notes
|
||||
- ✅ Verify dist.zip attachment
|
||||
- ✅ Confirm release marked as latest (for main branch)
|
||||
|
||||
2. **PyPI Package:**
|
||||
```bash
|
||||
# Check PyPI availability (may take a few minutes)
|
||||
for i in {1..10}; do
|
||||
if curl -s https://pypi.org/pypi/comfyui-frontend-package/json | jq -r '.releases | keys[]' | grep -q ${NEW_VERSION}; then
|
||||
echo "✅ PyPI package available"
|
||||
break
|
||||
fi
|
||||
echo "⏳ Waiting for PyPI package... (attempt $i/10)"
|
||||
sleep 30
|
||||
done
|
||||
```
|
||||
|
||||
3. **npm Package:**
|
||||
```bash
|
||||
# Check npm availability
|
||||
for i in {1..10}; do
|
||||
if npm view @comfyorg/comfyui-frontend-types@${NEW_VERSION} version >/dev/null 2>&1; then
|
||||
echo "✅ npm package available"
|
||||
break
|
||||
fi
|
||||
echo "⏳ Waiting for npm package... (attempt $i/10)"
|
||||
sleep 30
|
||||
done
|
||||
```
|
||||
|
||||
4. **DISTRIBUTION VERIFICATION**: All channels published successfully?
|
||||
|
||||
### Step 16: Post-Release Monitoring Setup
|
||||
|
||||
1. **Monitor immediate release health:**
|
||||
```bash
|
||||
# Check for immediate issues
|
||||
gh issue list --label "bug" --state open --limit 5 --json title,number,createdAt
|
||||
|
||||
# Monitor download metrics (if accessible)
|
||||
gh release view v${NEW_VERSION} --json assets --jq '.assets[].downloadCount'
|
||||
```
|
||||
|
||||
2. **Update documentation tracking:**
|
||||
```bash
|
||||
cat > post-release-checklist.md << EOF
|
||||
# Post-Release Checklist for v${NEW_VERSION}
|
||||
|
||||
## Immediate Tasks (Next 24 hours)
|
||||
- [ ] Monitor error rates and user feedback
|
||||
- [ ] Watch for critical issues
|
||||
- [ ] Verify documentation is up to date
|
||||
- [ ] Check community channels for questions
|
||||
|
||||
## Short-term Tasks (Next week)
|
||||
- [ ] Update external integration guides
|
||||
- [ ] Monitor adoption metrics
|
||||
- [ ] Gather user feedback
|
||||
- [ ] Plan next release cycle
|
||||
|
||||
## Long-term Tasks
|
||||
- [ ] Analyze release process improvements
|
||||
- [ ] Update release templates based on learnings
|
||||
- [ ] Document any new patterns discovered
|
||||
|
||||
## Key Metrics to Track
|
||||
- Download counts: GitHub, PyPI, npm
|
||||
- Issue reports related to v${NEW_VERSION}
|
||||
- Community feedback and adoption
|
||||
- Performance impact measurements
|
||||
EOF
|
||||
```
|
||||
|
||||
3. **Create release summary:**
|
||||
```bash
|
||||
cat > release-summary-${NEW_VERSION}.md << EOF
|
||||
# Release Summary: ComfyUI Frontend v${NEW_VERSION}
|
||||
|
||||
**Released:** $(date)
|
||||
**Type:** ${VERSION_TYPE}
|
||||
**Duration:** ~${RELEASE_DURATION} minutes
|
||||
**Release Commit:** ${RELEASE_COMMIT}
|
||||
|
||||
## Metrics
|
||||
- **Commits Included:** ${COMMITS_COUNT}
|
||||
- **Contributors:** ${CONTRIBUTORS_COUNT}
|
||||
- **Files Changed:** ${FILES_CHANGED}
|
||||
- **Lines Added/Removed:** +${LINES_ADDED}/-${LINES_REMOVED}
|
||||
|
||||
## Distribution Status
|
||||
- ✅ GitHub Release: Published
|
||||
- ✅ PyPI Package: Available
|
||||
- ✅ npm Types: Available
|
||||
|
||||
## Next Steps
|
||||
- Monitor for 24-48 hours
|
||||
- Address any critical issues immediately
|
||||
- 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
|
||||
- \`post-release-checklist.md\` - Follow-up tasks
|
||||
EOF
|
||||
```
|
||||
|
||||
4. **RELEASE COMPLETION**: All post-release setup completed?
|
||||
|
||||
## Advanced Safety Features
|
||||
|
||||
### Rollback Procedures
|
||||
|
||||
**Pre-Merge Rollback:**
|
||||
```bash
|
||||
# Close version bump PR and reset
|
||||
gh pr close ${PR_NUMBER}
|
||||
git reset --hard origin/main
|
||||
git clean -fd
|
||||
```
|
||||
|
||||
**Post-Merge Rollback:**
|
||||
```bash
|
||||
# Create immediate patch release with reverts
|
||||
git revert ${RELEASE_COMMIT}
|
||||
# Follow this command again with patch version
|
||||
```
|
||||
|
||||
**Emergency Procedures:**
|
||||
```bash
|
||||
# Document incident
|
||||
cat > release-incident-${NEW_VERSION}.md << EOF
|
||||
# Release Incident Report
|
||||
|
||||
**Version:** ${NEW_VERSION}
|
||||
**Issue:** [Describe the problem]
|
||||
**Impact:** [Severity and scope]
|
||||
**Resolution:** [Steps taken]
|
||||
**Prevention:** [Future improvements]
|
||||
EOF
|
||||
|
||||
# Contact package registries for critical issues
|
||||
echo "For critical security issues, consider:"
|
||||
echo "- PyPI: Contact support for package yanking"
|
||||
echo "- npm: Use 'npm unpublish' within 72 hours"
|
||||
echo "- GitHub: Update release with warning notes"
|
||||
```
|
||||
|
||||
### Quality Gates Summary
|
||||
|
||||
The command implements multiple quality gates:
|
||||
|
||||
1. **🔒 Security Gate**: Vulnerability scanning, secret detection
|
||||
2. **🧪 Quality Gate**: Full test suite, linting, type checking
|
||||
3. **📋 Content Gate**: Changelog accuracy, release notes quality
|
||||
4. **🔄 Process Gate**: Release timing verification
|
||||
5. **✅ Verification Gate**: Multi-channel publishing confirmation
|
||||
6. **📊 Monitoring Gate**: Post-release health tracking
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Scenario 1: Regular Feature Release
|
||||
```bash
|
||||
/project:create-frontend-release minor
|
||||
```
|
||||
- Analyzes features since last release
|
||||
- Generates changelog automatically
|
||||
- Creates comprehensive release notes
|
||||
|
||||
### Scenario 2: Critical Security Patch
|
||||
```bash
|
||||
/project:create-frontend-release patch "Security fixes for CVE-2024-XXXX"
|
||||
```
|
||||
- Expedited security scanning
|
||||
- Enhanced monitoring setup
|
||||
|
||||
### Scenario 3: Major Version with Breaking Changes
|
||||
```bash
|
||||
/project:create-frontend-release major
|
||||
```
|
||||
- Comprehensive breaking change analysis
|
||||
- Migration guide generation
|
||||
|
||||
### Scenario 4: Pre-release Testing
|
||||
```bash
|
||||
/project:create-frontend-release prerelease
|
||||
```
|
||||
- Creates alpha/beta/rc versions
|
||||
- Draft release status
|
||||
- Python package specs require that prereleases use alpha/beta/rc as the preid
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue: Pre-release Version Confusion
|
||||
**Problem**: Not sure whether to promote pre-release or create new version
|
||||
**Solution**:
|
||||
- Follow semver standards: a prerelease version is followed by a normal release. It should have the same major, minor, and patch versions as the prerelease.
|
||||
|
||||
### Issue: Wrong Commit Count
|
||||
**Problem**: Changelog includes commits from other branches
|
||||
**Solution**: Always use `--first-parent` flag with git log
|
||||
|
||||
**Update**: Sometimes update-locales doesn't add [skip ci] - always verify!
|
||||
|
||||
### Issue: Missing PRs in Changelog
|
||||
**Problem**: PR was merged to different branch
|
||||
**Solution**: Verify PR merge target with:
|
||||
```bash
|
||||
gh pr view ${PR_NUMBER} --json baseRefName
|
||||
```
|
||||
|
||||
### Issue: Release Failed Due to [skip ci]
|
||||
**Problem**: Release workflow didn't trigger after merge
|
||||
**Prevention**: Always avoid this scenario
|
||||
- Ensure that `[skip ci]` or similar flags are NOT in the `HEAD` commit message of the PR
|
||||
- Push a new, empty commit to the PR
|
||||
- Always double-check this immediately before merging
|
||||
|
||||
**Recovery Strategy**:
|
||||
1. Revert version in a new PR (e.g., 1.24.0 → 1.24.0-1)
|
||||
2. Merge the revert PR
|
||||
3. Run version bump workflow again
|
||||
4. This creates a fresh PR without [skip ci]
|
||||
Benefits: Cleaner than creating extra version numbers
|
||||
|
||||
## Key Learnings & Notes
|
||||
|
||||
1. **PR Author**: Version bump PRs are created by `comfy-pr-bot`, not `github-actions`
|
||||
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
|
||||
|
||||
36
.github/CLAUDE.md
vendored
@@ -1,36 +0,0 @@
|
||||
# ComfyUI Frontend - Claude Review Context
|
||||
|
||||
This file provides additional context for the automated PR review system.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### PrimeVue Component Migrations
|
||||
|
||||
When reviewing, flag these deprecated components:
|
||||
- `Dropdown` → Use `Select` from 'primevue/select'
|
||||
- `OverlayPanel` → Use `Popover` from 'primevue/popover'
|
||||
- `Calendar` → Use `DatePicker` from 'primevue/datepicker'
|
||||
- `InputSwitch` → Use `ToggleSwitch` from 'primevue/toggleswitch'
|
||||
- `Sidebar` → Use `Drawer` from 'primevue/drawer'
|
||||
- `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled
|
||||
- `TabMenu` → Use `Tabs` without panels
|
||||
- `Steps` → Use `Stepper` without panels
|
||||
- `InlineMessage` → Use `Message` component
|
||||
|
||||
### API Utilities Reference
|
||||
|
||||
- `api.apiURL()` - Backend API calls (/prompt, /queue, /view, etc.)
|
||||
- `api.fileURL()` - Static file access (templates, extensions)
|
||||
- `$t()` / `i18n.global.t()` - Internationalization
|
||||
- `DOMPurify.sanitize()` - HTML sanitization
|
||||
|
||||
## Review Scope
|
||||
|
||||
This automated review performs comprehensive analysis including:
|
||||
- Architecture and design patterns
|
||||
- Security vulnerabilities
|
||||
- Performance implications
|
||||
- Code quality and maintainability
|
||||
- Integration concerns
|
||||
|
||||
For implementation details, see `.claude/commands/comprehensive-pr-review.md`.
|
||||
75
.github/workflows/claude-pr-review.yml
vendored
@@ -1,75 +0,0 @@
|
||||
name: Claude PR Review
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
wait-for-ci:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.label.name == 'claude-review'
|
||||
outputs:
|
||||
should-proceed: ${{ steps.check-status.outputs.proceed }}
|
||||
steps:
|
||||
- name: Wait for other CI checks
|
||||
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)'
|
||||
wait-interval: 30
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check if we should proceed
|
||||
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 if any required checks failed
|
||||
if echo "$CHECK_RUNS" | grep -q '"conclusion": "failure"'; then
|
||||
echo "Some CI checks failed - skipping Claude review"
|
||||
echo "proceed=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "All CI checks passed - proceeding with Claude review"
|
||||
echo "proceed=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
claude-review:
|
||||
needs: wait-for-ci
|
||||
if: needs.wait-for-ci.outputs.should-proceed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies for analysis tools
|
||||
run: |
|
||||
npm install -g typescript @vue/compiler-sfc
|
||||
|
||||
- name: Run Claude PR Review
|
||||
uses: anthropics/claude-code-action@main
|
||||
with:
|
||||
prompt_file: .claude/commands/comprehensive-pr-review.md
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
max_turns: 1
|
||||
timeout_minutes: 30
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
154
.github/workflows/pr-checks.yml
vendored
@@ -1,154 +0,0 @@
|
||||
name: PR Checks
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_run: ${{ steps.check-changes.outputs.should_run }}
|
||||
has_browser_tests: ${{ steps.check-coverage.outputs.has_browser_tests }}
|
||||
has_screen_recording: ${{ steps.check-recording.outputs.has_recording }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Ensure base branch is available
|
||||
run: |
|
||||
# Fetch the specific base commit to ensure it's available for git diff
|
||||
git fetch origin ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: Check if significant changes exist
|
||||
id: check-changes
|
||||
run: |
|
||||
# Get list of changed files
|
||||
CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }})
|
||||
|
||||
# Filter for src/ files
|
||||
SRC_FILES=$(echo "$CHANGED_FILES" | grep '^src/' || true)
|
||||
|
||||
if [ -z "$SRC_FILES" ]; then
|
||||
echo "No src/ files changed"
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Count lines changed in src files
|
||||
TOTAL_LINES=0
|
||||
for file in $SRC_FILES; do
|
||||
if [ -f "$file" ]; then
|
||||
# Count added lines (non-empty)
|
||||
ADDED=$(git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} -- "$file" | grep '^+' | grep -v '^+++' | grep -v '^+$' | wc -l)
|
||||
# Count removed lines (non-empty)
|
||||
REMOVED=$(git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} -- "$file" | grep '^-' | grep -v '^---' | grep -v '^-$' | wc -l)
|
||||
TOTAL_LINES=$((TOTAL_LINES + ADDED + REMOVED))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Total lines changed in src/: $TOTAL_LINES"
|
||||
|
||||
if [ $TOTAL_LINES -gt 3 ]; then
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check browser test coverage
|
||||
id: check-coverage
|
||||
if: steps.check-changes.outputs.should_run == 'true'
|
||||
run: |
|
||||
# Check if browser tests were updated
|
||||
BROWSER_TEST_CHANGES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} | grep '^browser_tests/.*\.ts$' || true)
|
||||
|
||||
if [ -n "$BROWSER_TEST_CHANGES" ]; then
|
||||
echo "has_browser_tests=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_browser_tests=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check for screen recording
|
||||
id: check-recording
|
||||
if: steps.check-changes.outputs.should_run == 'true'
|
||||
env:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
# Check PR body for screen recording
|
||||
# Check for GitHub user attachments or YouTube links
|
||||
if echo "$PR_BODY" | grep -qiE 'github\.com/user-attachments/assets/[a-f0-9-]+|youtube\.com/watch|youtu\.be/'; then
|
||||
echo "has_recording=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_recording=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Final check and create results
|
||||
id: final-check
|
||||
if: always()
|
||||
run: |
|
||||
# Initialize results
|
||||
WARNINGS_JSON=""
|
||||
|
||||
# Only run checks if should_run is true
|
||||
if [ "${{ steps.check-changes.outputs.should_run }}" == "true" ]; then
|
||||
# Check browser test coverage
|
||||
if [ "${{ steps.check-coverage.outputs.has_browser_tests }}" != "true" ]; then
|
||||
if [ -n "$WARNINGS_JSON" ]; then
|
||||
WARNINGS_JSON="${WARNINGS_JSON},"
|
||||
fi
|
||||
WARNINGS_JSON="${WARNINGS_JSON}{\"message\":\"⚠️ **Warning: E2E Test Coverage Missing**\\n\\nIf this PR modifies behavior that can be covered by browser-based E2E tests, those tests are required. PRs lacking applicable test coverage may not be reviewed until added. Please add or update browser tests to ensure code quality and prevent regressions.\"}"
|
||||
fi
|
||||
|
||||
# Check screen recording
|
||||
if [ "${{ steps.check-recording.outputs.has_recording }}" != "true" ]; then
|
||||
if [ -n "$WARNINGS_JSON" ]; then
|
||||
WARNINGS_JSON="${WARNINGS_JSON},"
|
||||
fi
|
||||
WARNINGS_JSON="${WARNINGS_JSON}{\"message\":\"⚠️ **Warning: Visual Documentation Missing**\\n\\nIf this PR changes user-facing behavior, visual proof (screen recording or screenshot) is required. PRs without applicable visual documentation may not be reviewed until provided.\\nYou can add it by:\\n\\n- GitHub: Drag & drop media directly into the PR description\\n\\n- YouTube: Include a link to a short demo\"}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create results JSON
|
||||
if [ -n "$WARNINGS_JSON" ]; then
|
||||
# Create JSON with warnings
|
||||
cat > pr-check-results.json << EOF
|
||||
{
|
||||
"fails": [],
|
||||
"warnings": [$WARNINGS_JSON],
|
||||
"messages": [],
|
||||
"markdowns": []
|
||||
}
|
||||
EOF
|
||||
echo "failed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Create JSON with success
|
||||
cat > pr-check-results.json << 'EOF'
|
||||
{
|
||||
"fails": [],
|
||||
"warnings": [],
|
||||
"messages": [],
|
||||
"markdowns": []
|
||||
}
|
||||
EOF
|
||||
echo "failed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Write PR metadata
|
||||
echo "${{ github.event.pull_request.number }}" > pr-number.txt
|
||||
echo "${{ github.event.pull_request.head.sha }}" > pr-sha.txt
|
||||
|
||||
- name: Upload results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: pr-check-results-${{ github.run_id }}
|
||||
path: |
|
||||
pr-check-results.json
|
||||
pr-number.txt
|
||||
pr-sha.txt
|
||||
retention-days: 1
|
||||
149
.github/workflows/pr-comment.yml
vendored
@@ -1,149 +0,0 @@
|
||||
name: PR Comment
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["PR Checks"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-check-results-${{ github.event.workflow_run.id }}
|
||||
path: /tmp/pr-artifacts
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
- name: Post results
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Helper function to safely read files
|
||||
function safeReadFile(filePath) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return fs.readFileSync(filePath, 'utf8').trim();
|
||||
} catch (e) {
|
||||
console.error(`Error reading ${filePath}:`, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Read artifact files
|
||||
const artifactDir = '/tmp/pr-artifacts';
|
||||
const prNumber = safeReadFile(path.join(artifactDir, 'pr-number.txt'));
|
||||
const prSha = safeReadFile(path.join(artifactDir, 'pr-sha.txt'));
|
||||
const resultsJson = safeReadFile(path.join(artifactDir, 'pr-check-results.json'));
|
||||
|
||||
// Validate PR number
|
||||
if (!prNumber || isNaN(parseInt(prNumber))) {
|
||||
throw new Error('Invalid or missing PR number');
|
||||
}
|
||||
|
||||
// Parse and validate results
|
||||
let results;
|
||||
try {
|
||||
results = JSON.parse(resultsJson || '{}');
|
||||
} catch (e) {
|
||||
console.error('Failed to parse check results:', e);
|
||||
|
||||
// Post error comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: parseInt(prNumber),
|
||||
body: `⚠️ PR checks failed to complete properly. Error parsing results: ${e.message}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Format check messages
|
||||
const messages = [];
|
||||
|
||||
if (results.fails && results.fails.length > 0) {
|
||||
messages.push('### ❌ Failures\n' + results.fails.map(f => f.message).join('\n\n'));
|
||||
}
|
||||
|
||||
if (results.warnings && results.warnings.length > 0) {
|
||||
messages.push('### ⚠️ Warnings\n' + results.warnings.map(w => w.message).join('\n\n'));
|
||||
}
|
||||
|
||||
if (results.messages && results.messages.length > 0) {
|
||||
messages.push('### 💬 Messages\n' + results.messages.map(m => m.message).join('\n\n'));
|
||||
}
|
||||
|
||||
if (results.markdowns && results.markdowns.length > 0) {
|
||||
messages.push(...results.markdowns.map(m => m.message));
|
||||
}
|
||||
|
||||
// Find existing bot comment
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: parseInt(prNumber)
|
||||
});
|
||||
|
||||
const botComment = comments.data.find(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes('<!-- pr-checks-comment -->')
|
||||
);
|
||||
|
||||
// Post comment if there are any messages
|
||||
if (messages.length > 0) {
|
||||
const body = messages.join('\n\n');
|
||||
const commentBody = `<!-- pr-checks-comment -->\n${body}`;
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: commentBody
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: parseInt(prNumber),
|
||||
body: commentBody
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No messages - delete existing comment if present
|
||||
if (botComment) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set commit status based on failures
|
||||
if (prSha) {
|
||||
const hasFailures = results.fails && results.fails.length > 0;
|
||||
const hasWarnings = results.warnings && results.warnings.length > 0;
|
||||
await github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha: prSha,
|
||||
state: hasFailures ? 'failure' : 'success',
|
||||
context: 'pr-checks',
|
||||
description: hasFailures
|
||||
? `${results.fails.length} check(s) failed`
|
||||
: hasWarnings
|
||||
? `${results.warnings.length} warning(s)`
|
||||
: 'All checks passed'
|
||||
});
|
||||
}
|
||||
@@ -9,10 +9,9 @@ module.exports = defineConfig({
|
||||
entry: 'src/locales/en',
|
||||
entryLocale: 'en',
|
||||
output: 'src/locales',
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es'],
|
||||
outputLocales: ['zh', 'ru', 'ja', 'ko', 'fr', 'es'],
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
|
||||
'latent' is the short form of 'latent space'.
|
||||
'mask' is in the context of image processing.
|
||||
Note: For Traditional Chinese (Taiwan), use Taiwan-specific terminology and traditional characters.
|
||||
`
|
||||
});
|
||||
|
||||
@@ -529,7 +529,7 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
|
||||
### Prerequisites & Technology Stack
|
||||
|
||||
- **Required Software**:
|
||||
- Node.js (v16 or later; v20/v22 strongly recommended) and npm
|
||||
- Node.js (v16 or later) and npm
|
||||
- Git for version control
|
||||
- A running ComfyUI backend instance
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Clone <https://github.com/Comfy-Org/ComfyUI_devtools> to your `custom_nodes` dir
|
||||
_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:
|
||||
Ensure you have Node.js v20 or later installed. Then, set up the Chromium test driver:
|
||||
```bash
|
||||
npx playwright install chromium --with-deps
|
||||
```
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
{
|
||||
"id": "fe4562c0-3a0b-4614-bdec-7039a58d75b8",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"pos": [
|
||||
627.5973510742188,
|
||||
423.0972900390625
|
||||
],
|
||||
"size": [
|
||||
144.15234375,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 4,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [
|
||||
347.90441582814213,
|
||||
417.3822440655296,
|
||||
120,
|
||||
60
|
||||
]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [
|
||||
892.5973510742188,
|
||||
416.0972900390625,
|
||||
120,
|
||||
60
|
||||
]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [
|
||||
1
|
||||
],
|
||||
"pos": {
|
||||
"0": 447.9044189453125,
|
||||
"1": 437.3822326660156
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [
|
||||
2
|
||||
],
|
||||
"pos": {
|
||||
"0": 912.5973510742188,
|
||||
"1": 436.0972900390625
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
554.8743286132812,
|
||||
100.95539093017578
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"simple",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "VAEEncode",
|
||||
"pos": [
|
||||
685.1265869140625,
|
||||
439.1734619140625
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "pixels",
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncode"
|
||||
}
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8894351682943402,
|
||||
"offset": [
|
||||
58.7671207025881,
|
||||
137.7124650620126
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,716 +0,0 @@
|
||||
{
|
||||
"id": "976d6e9a-927d-42db-abd4-96bfc0ecf8d9",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 11,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "8beb610f-ddd1-4489-ae0d-2f732a4042ae",
|
||||
"pos": [
|
||||
532,
|
||||
412.5
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [
|
||||
11
|
||||
]
|
||||
}
|
||||
],
|
||||
"title": "subgraph 2",
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
758.2109985351562,
|
||||
398.3681335449219
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 11
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1028.9615478515625,
|
||||
381.83746337890625
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
270
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"ComfyUI"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
9,
|
||||
8,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
10,
|
||||
10,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
11,
|
||||
10,
|
||||
1,
|
||||
8,
|
||||
1,
|
||||
"VAE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "8beb610f-ddd1-4489-ae0d-2f732a4042ae",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 14,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "subgraph 2",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [
|
||||
-154,
|
||||
415.5,
|
||||
120,
|
||||
40
|
||||
]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [
|
||||
1238,
|
||||
395.5,
|
||||
120,
|
||||
80
|
||||
]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "4d6c7e4e-971e-4f78-9218-9a604db53a4b",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [
|
||||
7
|
||||
],
|
||||
"localized_name": "LATENT",
|
||||
"pos": {
|
||||
"0": 1258,
|
||||
"1": 415.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "f8201d4f-7fc6-4a1b-b8c9-9f0716d9c09a",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"linkIds": [
|
||||
14
|
||||
],
|
||||
"localized_name": "VAE",
|
||||
"pos": {
|
||||
"0": 1258,
|
||||
"1": 435.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
415,
|
||||
186
|
||||
],
|
||||
"size": [
|
||||
422.84503173828125,
|
||||
164.31304931640625
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
863,
|
||||
186
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 11
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
32115495257102,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "dbe5763f-440b-47b4-82ac-454f1f98b0e3",
|
||||
"pos": [
|
||||
194.13900756835938,
|
||||
657.3333740234375
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
106
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
11
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "MODEL",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [
|
||||
12
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "CLIP",
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [
|
||||
13
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "VAE",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [
|
||||
14
|
||||
]
|
||||
}
|
||||
],
|
||||
"title": "subgraph 3",
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": 10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": 10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 3,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": 10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": 10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 6,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": 10,
|
||||
"origin_slot": 4,
|
||||
"target_id": -20,
|
||||
"target_slot": 1,
|
||||
"type": "VAE"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "dbe5763f-440b-47b4-82ac-454f1f98b0e3",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 9,
|
||||
"lastLinkId": 9,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "subgraph 3",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [
|
||||
-154,
|
||||
517,
|
||||
120,
|
||||
40
|
||||
]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [
|
||||
898.2780151367188,
|
||||
467,
|
||||
128.6640625,
|
||||
140
|
||||
]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "b4882169-329b-43f6-a373-81abfbdea55b",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [
|
||||
6
|
||||
],
|
||||
"localized_name": "CONDITIONING",
|
||||
"pos": {
|
||||
"0": 918.2780151367188,
|
||||
"1": 487
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "01f51f96-a741-428e-8772-9557ee50b609",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [
|
||||
2
|
||||
],
|
||||
"localized_name": "LATENT",
|
||||
"pos": {
|
||||
"0": 918.2780151367188,
|
||||
"1": 507
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "47fa906e-d80b-45c3-a596-211a0e59d4a1",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"linkIds": [
|
||||
1
|
||||
],
|
||||
"localized_name": "MODEL",
|
||||
"pos": {
|
||||
"0": 918.2780151367188,
|
||||
"1": 527
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "f03dccd7-10e8-4513-9994-15854a92d192",
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"linkIds": [
|
||||
3
|
||||
],
|
||||
"localized_name": "CLIP",
|
||||
"pos": {
|
||||
"0": 918.2780151367188,
|
||||
"1": 547
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a666877f-e34f-49bc-8a78-b26156656b83",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"linkIds": [
|
||||
8
|
||||
],
|
||||
"localized_name": "VAE",
|
||||
"pos": {
|
||||
"0": 918.2780151367188,
|
||||
"1": 567
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
413,
|
||||
389
|
||||
],
|
||||
"size": [
|
||||
425.27801513671875,
|
||||
180.6060791015625
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"text, watermark"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [
|
||||
473,
|
||||
609
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
106
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
512,
|
||||
512,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
26,
|
||||
474
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "MODEL",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "CLIP",
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"slot_index": 1,
|
||||
"links": [
|
||||
3,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "VAE",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 2,
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": [
|
||||
"v1-5-pruned-emaonly-fp16.safetensors"
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 5,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 1,
|
||||
"target_id": 7,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"origin_id": 7,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 5,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 1,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 2,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 1,
|
||||
"target_id": -20,
|
||||
"target_slot": 3,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"origin_id": 4,
|
||||
"origin_slot": 2,
|
||||
"target_id": -20,
|
||||
"target_slot": 4,
|
||||
"type": "VAE"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.24.0-1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
{
|
||||
"id": "c4a254bb-935e-4013-b380-5e36954de4b0",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [
|
||||
791.59912109375,
|
||||
386.13336181640625
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
202
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"",
|
||||
""
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 12,
|
||||
"lastLinkId": 16,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [
|
||||
481.59912109375,
|
||||
379.13336181640625,
|
||||
120,
|
||||
160
|
||||
]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [
|
||||
1121.59912109375,
|
||||
379.13336181640625,
|
||||
128.6640625,
|
||||
60
|
||||
]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"linkIds": [
|
||||
10
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 399.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "736e5a03-0f7f-4e48-93e4-fd66ea6c30f1",
|
||||
"name": "text_1",
|
||||
"type": "STRING",
|
||||
"linkIds": [
|
||||
11
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 419.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b62e7a0b-cc7e-4ca5-a4e1-c81607a13f58",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [
|
||||
13
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 439.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "7a2628da-4879-4f82-a7d3-7b1c00db50a5",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [
|
||||
14
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 459.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "651cf4ad-e8bf-47f6-b181-8f8aeacd6669",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [
|
||||
15
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 479.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c41765ea-61ef-4a77-8cc6-74113903078f",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [
|
||||
16
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 499.13336181640625
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "55dd1505-12bd-4cb4-8e75-031a97bb4387",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [
|
||||
12
|
||||
],
|
||||
"pos": {
|
||||
"0": 1141.59912109375,
|
||||
"1": 399.13336181640625
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
661.59912109375,
|
||||
314.13336181640625
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
668.755859375,
|
||||
571.7766723632812
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": 11
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
12
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
671.7379760742188,
|
||||
1.621593713760376
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 15
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 16
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"simple",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 11,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": 11,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 12,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 12,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 12,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 5,
|
||||
"target_id": 12,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.9581355200690549,
|
||||
"offset": [
|
||||
184.687451089395,
|
||||
80.38288288288285
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
{
|
||||
"id": "c4a254bb-935e-4013-b380-5e36954de4b0",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [
|
||||
400,
|
||||
300
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
168
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [
|
||||
481.59912109375,
|
||||
379.13336181640625,
|
||||
120,
|
||||
160
|
||||
]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [
|
||||
1121.59912109375,
|
||||
379.13336181640625,
|
||||
120,
|
||||
40
|
||||
]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"linkIds": [
|
||||
10
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 399.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [
|
||||
11
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 419.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [
|
||||
12
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 439.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [
|
||||
13
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 459.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [
|
||||
14
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 479.13336181640625
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [
|
||||
15
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 499.13336181640625
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
661.59912109375,
|
||||
314.13336181640625
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
674.1234741210938,
|
||||
570.5839233398438
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"simple",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 11,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 11,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 5,
|
||||
"target_id": 11,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.9581355200690549,
|
||||
"offset": [
|
||||
258.6405769416877,
|
||||
147.17927927927929
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
{
|
||||
"id": "c4a254bb-935e-4013-b380-5e36954de4b0",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [
|
||||
791.59912109375,
|
||||
386.13336181640625
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
26
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 10,
|
||||
"lastLinkId": 10,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [
|
||||
481.59912109375,
|
||||
379.13336181640625,
|
||||
120,
|
||||
60
|
||||
]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [
|
||||
1121.59912109375,
|
||||
379.13336181640625,
|
||||
120,
|
||||
40
|
||||
]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "79e69fca-ad12-499b-8d9b-9f1656b85354",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [
|
||||
10
|
||||
],
|
||||
"pos": {
|
||||
"0": 581.59912109375,
|
||||
"1": 399.13336181640625
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
661.59912109375,
|
||||
314.13336181640625
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.24.1",
|
||||
"VHS_latentpreview": false,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true,
|
||||
"ds": {
|
||||
"scale": 0.9581355200690549,
|
||||
"offset": [
|
||||
258.6405769416877,
|
||||
147.17927927927929
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB |
@@ -21,7 +21,7 @@ import {
|
||||
} from './components/SidebarTab'
|
||||
import { Topbar } from './components/Topbar'
|
||||
import type { Position, Size } from './types'
|
||||
import { NodeReference, SubgraphSlotReference } from './utils/litegraphUtils'
|
||||
import { NodeReference } from './utils/litegraphUtils'
|
||||
import TaskHistory from './utils/taskHistory'
|
||||
|
||||
dotenv.config()
|
||||
@@ -168,7 +168,7 @@ export class ComfyPage {
|
||||
this.menu = new ComfyMenu(page)
|
||||
this.actionbar = new ComfyActionbar(page)
|
||||
this.templates = new ComfyTemplates(page)
|
||||
this.settingDialog = new SettingDialog(page, this)
|
||||
this.settingDialog = new SettingDialog(page)
|
||||
this.confirmDialog = new ConfirmDialog(page)
|
||||
}
|
||||
|
||||
@@ -563,7 +563,6 @@ export class ComfyPage {
|
||||
if (fileName.endsWith('.webm')) return 'video/webm'
|
||||
if (fileName.endsWith('.json')) return 'application/json'
|
||||
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
|
||||
if (fileName.endsWith('.avif')) return 'image/avif'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
@@ -777,524 +776,11 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks on a litegraph context menu item (uses .litemenu-entry selector).
|
||||
* Use this for canvas/node context menus, not PrimeVue menus.
|
||||
*/
|
||||
async clickLitegraphContextMenuItem(name: string): Promise<void> {
|
||||
await this.page.locator(`.litemenu-entry:has-text("${name}")`).click()
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-clicks on a subgraph input slot to open the context menu.
|
||||
* Must be called when inside a subgraph.
|
||||
*
|
||||
* This method uses the actual slot positions from the subgraph.inputs array,
|
||||
* which contain the correct coordinates for each input slot. These positions
|
||||
* are different from the visual node positions and are specifically where
|
||||
* the slots are rendered on the input node.
|
||||
*
|
||||
* @param inputName Optional name of the specific input slot to target (e.g., 'text').
|
||||
* If not provided, tries all available input slots until one works.
|
||||
* @returns Promise that resolves when the context menu appears
|
||||
*/
|
||||
async rightClickSubgraphInputSlot(inputName?: string): Promise<void> {
|
||||
const foundSlot = await this.page.evaluate(async (targetInputName) => {
|
||||
const app = window['app']
|
||||
const currentGraph = app.canvas.graph
|
||||
|
||||
// Check if we're in a subgraph
|
||||
if (currentGraph.constructor.name !== 'Subgraph') {
|
||||
throw new Error(
|
||||
'Not in a subgraph - this method only works inside subgraphs'
|
||||
)
|
||||
}
|
||||
|
||||
// Get the input node
|
||||
const inputNode = currentGraph.inputNode
|
||||
if (!inputNode) {
|
||||
throw new Error('No input node found in subgraph')
|
||||
}
|
||||
|
||||
// Get available inputs
|
||||
const inputs = currentGraph.inputs
|
||||
if (!inputs || inputs.length === 0) {
|
||||
throw new Error('No input slots found in subgraph')
|
||||
}
|
||||
|
||||
// Filter to specific input if requested
|
||||
const inputsToTry = targetInputName
|
||||
? inputs.filter((inp) => inp.name === targetInputName)
|
||||
: inputs
|
||||
|
||||
if (inputsToTry.length === 0) {
|
||||
throw new Error(
|
||||
targetInputName
|
||||
? `Input slot '${targetInputName}' not found`
|
||||
: 'No input slots available to try'
|
||||
)
|
||||
}
|
||||
|
||||
// Try right-clicking on each input slot position until one works
|
||||
for (const input of inputsToTry) {
|
||||
if (!input.pos) continue
|
||||
|
||||
const testX = input.pos[0]
|
||||
const testY = input.pos[1]
|
||||
|
||||
// Create a right-click event at the input slot position
|
||||
const rightClickEvent = {
|
||||
canvasX: testX,
|
||||
canvasY: testY,
|
||||
button: 2, // Right mouse button
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
}
|
||||
|
||||
// Trigger the input node's right-click handler
|
||||
if (inputNode.onPointerDown) {
|
||||
inputNode.onPointerDown(
|
||||
rightClickEvent,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
}
|
||||
|
||||
// Wait briefly for menu to appear
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Check if litegraph context menu appeared
|
||||
const menuExists = document.querySelector('.litemenu-entry')
|
||||
if (menuExists) {
|
||||
return { success: true, inputName: input.name, x: testX, y: testY }
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false }
|
||||
}, inputName)
|
||||
|
||||
if (!foundSlot.success) {
|
||||
throw new Error(
|
||||
inputName
|
||||
? `Could not open context menu for input slot '${inputName}'`
|
||||
: 'Could not find any input slot position to right-click'
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for the litegraph context menu to be visible
|
||||
await this.page.waitForSelector('.litemenu-entry', {
|
||||
state: 'visible',
|
||||
timeout: 5000
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-clicks on a subgraph output slot to open the context menu.
|
||||
* Must be called when inside a subgraph.
|
||||
*
|
||||
* Similar to rightClickSubgraphInputSlot but for output slots.
|
||||
*
|
||||
* @param outputName Optional name of the specific output slot to target.
|
||||
* If not provided, tries all available output slots until one works.
|
||||
* @returns Promise that resolves when the context menu appears
|
||||
*/
|
||||
async rightClickSubgraphOutputSlot(outputName?: string): Promise<void> {
|
||||
const foundSlot = await this.page.evaluate(async (targetOutputName) => {
|
||||
const app = window['app']
|
||||
const currentGraph = app.canvas.graph
|
||||
|
||||
// Check if we're in a subgraph
|
||||
if (currentGraph.constructor.name !== 'Subgraph') {
|
||||
throw new Error(
|
||||
'Not in a subgraph - this method only works inside subgraphs'
|
||||
)
|
||||
}
|
||||
|
||||
// Get the output node
|
||||
const outputNode = currentGraph.outputNode
|
||||
if (!outputNode) {
|
||||
throw new Error('No output node found in subgraph')
|
||||
}
|
||||
|
||||
// Get available outputs
|
||||
const outputs = currentGraph.outputs
|
||||
if (!outputs || outputs.length === 0) {
|
||||
throw new Error('No output slots found in subgraph')
|
||||
}
|
||||
|
||||
// Filter to specific output if requested
|
||||
const outputsToTry = targetOutputName
|
||||
? outputs.filter((out) => out.name === targetOutputName)
|
||||
: outputs
|
||||
|
||||
if (outputsToTry.length === 0) {
|
||||
throw new Error(
|
||||
targetOutputName
|
||||
? `Output slot '${targetOutputName}' not found`
|
||||
: 'No output slots available to try'
|
||||
)
|
||||
}
|
||||
|
||||
// Try right-clicking on each output slot position until one works
|
||||
for (const output of outputsToTry) {
|
||||
if (!output.pos) continue
|
||||
|
||||
const testX = output.pos[0]
|
||||
const testY = output.pos[1]
|
||||
|
||||
// Create a right-click event at the output slot position
|
||||
const rightClickEvent = {
|
||||
canvasX: testX,
|
||||
canvasY: testY,
|
||||
button: 2, // Right mouse button
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
}
|
||||
|
||||
// Trigger the output node's right-click handler
|
||||
if (outputNode.onPointerDown) {
|
||||
outputNode.onPointerDown(
|
||||
rightClickEvent,
|
||||
app.canvas.pointer,
|
||||
app.canvas.linkConnector
|
||||
)
|
||||
}
|
||||
|
||||
// Wait briefly for menu to appear
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Check if litegraph context menu appeared
|
||||
const menuExists = document.querySelector('.litemenu-entry')
|
||||
if (menuExists) {
|
||||
return { success: true, outputName: output.name, x: testX, y: testY }
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false }
|
||||
}, outputName)
|
||||
|
||||
if (!foundSlot.success) {
|
||||
throw new Error(
|
||||
outputName
|
||||
? `Could not open context menu for output slot '${outputName}'`
|
||||
: 'Could not find any output slot position to right-click'
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for the litegraph context menu to be visible
|
||||
await this.page.waitForSelector('.litemenu-entry', {
|
||||
state: 'visible',
|
||||
timeout: 5000
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a reference to a subgraph input slot
|
||||
*/
|
||||
async getSubgraphInputSlot(
|
||||
slotName?: string
|
||||
): Promise<SubgraphSlotReference> {
|
||||
return new SubgraphSlotReference('input', slotName || '', this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a reference to a subgraph output slot
|
||||
*/
|
||||
async getSubgraphOutputSlot(
|
||||
slotName?: string
|
||||
): Promise<SubgraphSlotReference> {
|
||||
return new SubgraphSlotReference('output', slotName || '', this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a regular node output to a subgraph input.
|
||||
* This creates a new input slot on the subgraph if targetInputName is not provided.
|
||||
*/
|
||||
async connectToSubgraphInput(
|
||||
sourceNode: NodeReference,
|
||||
sourceSlotIndex: number,
|
||||
targetInputName?: string
|
||||
): Promise<void> {
|
||||
const sourceSlot = await sourceNode.getOutput(sourceSlotIndex)
|
||||
const targetSlot = await this.getSubgraphInputSlot(targetInputName)
|
||||
|
||||
const targetPosition = targetInputName
|
||||
? await targetSlot.getPosition() // Connect to existing slot
|
||||
: await targetSlot.getOpenSlotPosition() // Create new slot
|
||||
|
||||
await this.dragAndDrop(await sourceSlot.getPosition(), targetPosition)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a subgraph input to a regular node input.
|
||||
* This creates a new input slot on the subgraph if sourceInputName is not provided.
|
||||
*/
|
||||
async connectFromSubgraphInput(
|
||||
targetNode: NodeReference,
|
||||
targetSlotIndex: number,
|
||||
sourceInputName?: string
|
||||
): Promise<void> {
|
||||
const sourceSlot = await this.getSubgraphInputSlot(sourceInputName)
|
||||
const targetSlot = await targetNode.getInput(targetSlotIndex)
|
||||
|
||||
const sourcePosition = sourceInputName
|
||||
? await sourceSlot.getPosition() // Connect from existing slot
|
||||
: await sourceSlot.getOpenSlotPosition() // Create new slot
|
||||
|
||||
const targetPosition = await targetSlot.getPosition()
|
||||
|
||||
// Debug: Log the positions we're trying to use
|
||||
console.log('Drag positions:', {
|
||||
source: sourcePosition,
|
||||
target: targetPosition
|
||||
})
|
||||
|
||||
await this.dragAndDrop(sourcePosition, targetPosition)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a regular node output to a subgraph output.
|
||||
* This creates a new output slot on the subgraph if targetOutputName is not provided.
|
||||
*/
|
||||
async connectToSubgraphOutput(
|
||||
sourceNode: NodeReference,
|
||||
sourceSlotIndex: number,
|
||||
targetOutputName?: string
|
||||
): Promise<void> {
|
||||
const sourceSlot = await sourceNode.getOutput(sourceSlotIndex)
|
||||
const targetSlot = await this.getSubgraphOutputSlot(targetOutputName)
|
||||
|
||||
const targetPosition = targetOutputName
|
||||
? await targetSlot.getPosition() // Connect to existing slot
|
||||
: await targetSlot.getOpenSlotPosition() // Create new slot
|
||||
|
||||
await this.dragAndDrop(await sourceSlot.getPosition(), targetPosition)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a subgraph output to a regular node input.
|
||||
* This creates a new output slot on the subgraph if sourceOutputName is not provided.
|
||||
*/
|
||||
async connectFromSubgraphOutput(
|
||||
targetNode: NodeReference,
|
||||
targetSlotIndex: number,
|
||||
sourceOutputName?: string
|
||||
): Promise<void> {
|
||||
const sourceSlot = await this.getSubgraphOutputSlot(sourceOutputName)
|
||||
const targetSlot = await targetNode.getInput(targetSlotIndex)
|
||||
|
||||
const sourcePosition = sourceOutputName
|
||||
? await sourceSlot.getPosition() // Connect from existing slot
|
||||
: await sourceSlot.getOpenSlotPosition() // Create new slot
|
||||
|
||||
await this.dragAndDrop(sourcePosition, await targetSlot.getPosition())
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a visual marker at a position for debugging
|
||||
*/
|
||||
async debugAddMarker(
|
||||
position: Position,
|
||||
id: string = 'debug-marker'
|
||||
): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
([pos, markerId]) => {
|
||||
// Remove existing marker if present
|
||||
const existing = document.getElementById(markerId)
|
||||
if (existing) existing.remove()
|
||||
|
||||
// Create marker
|
||||
const marker = document.createElement('div')
|
||||
marker.id = markerId
|
||||
marker.style.position = 'fixed'
|
||||
marker.style.left = `${pos.x - 10}px`
|
||||
marker.style.top = `${pos.y - 10}px`
|
||||
marker.style.width = '20px'
|
||||
marker.style.height = '20px'
|
||||
marker.style.border = '2px solid red'
|
||||
marker.style.borderRadius = '50%'
|
||||
marker.style.backgroundColor = 'rgba(255, 0, 0, 0.3)'
|
||||
marker.style.pointerEvents = 'none'
|
||||
marker.style.zIndex = '10000'
|
||||
document.body.appendChild(marker)
|
||||
},
|
||||
[position, id] as const
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove debug markers
|
||||
*/
|
||||
async debugRemoveMarkers(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
document
|
||||
.querySelectorAll('[id^="debug-marker"]')
|
||||
.forEach((el) => el.remove())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot and attach it to the test report for debugging
|
||||
* This is a convenience method that combines screenshot capture and test attachment
|
||||
*
|
||||
* @param testInfo The Playwright TestInfo object (from test parameters)
|
||||
* @param name Name for the attachment
|
||||
* @param options Optional screenshot options (defaults to page screenshot)
|
||||
*/
|
||||
async debugAttachScreenshot(
|
||||
testInfo: any,
|
||||
name: string,
|
||||
options?: {
|
||||
fullPage?: boolean
|
||||
element?: 'canvas' | 'page'
|
||||
markers?: Array<{ position: Position; id?: string }>
|
||||
}
|
||||
): Promise<void> {
|
||||
// Add markers if requested
|
||||
if (options?.markers) {
|
||||
for (const marker of options.markers) {
|
||||
await this.debugAddMarker(marker.position, marker.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Take screenshot - default to page if not specified
|
||||
let screenshot: Buffer
|
||||
const targetElement = options?.element || 'page'
|
||||
|
||||
if (targetElement === 'canvas') {
|
||||
screenshot = await this.canvas.screenshot()
|
||||
} else if (options?.fullPage) {
|
||||
screenshot = await this.page.screenshot({ fullPage: true })
|
||||
} else {
|
||||
screenshot = await this.page.screenshot()
|
||||
}
|
||||
|
||||
// Attach to test report
|
||||
await testInfo.attach(name, {
|
||||
body: screenshot,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
|
||||
// Clean up markers if we added any
|
||||
if (options?.markers) {
|
||||
await this.debugRemoveMarkers()
|
||||
}
|
||||
}
|
||||
|
||||
async doubleClickCanvas() {
|
||||
await this.page.mouse.dblclick(10, 10, { delay: 5 })
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the canvas as a PNG and save it for debugging
|
||||
*/
|
||||
async debugSaveCanvasScreenshot(filename: string): Promise<void> {
|
||||
await this.page.evaluate(async (filename) => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
|
||||
// Convert canvas to blob
|
||||
return new Promise<void>((resolve) => {
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (!blob) {
|
||||
throw new Error('Failed to create blob from canvas')
|
||||
}
|
||||
|
||||
// Create a download link and trigger it
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
resolve()
|
||||
}, 'image/png')
|
||||
})
|
||||
}, filename)
|
||||
|
||||
// Wait a bit for the download to process
|
||||
await this.page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture canvas as base64 data URL for inspection
|
||||
*/
|
||||
async debugGetCanvasDataURL(): Promise<string> {
|
||||
return await this.page.evaluate(() => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
return canvas.toDataURL('image/png')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an overlay div with the canvas image for easier Playwright screenshot
|
||||
*/
|
||||
async debugShowCanvasOverlay(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
const canvas = document.getElementById(
|
||||
'graph-canvas'
|
||||
) as HTMLCanvasElement
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas not found')
|
||||
}
|
||||
|
||||
// Remove existing overlay if present
|
||||
const existingOverlay = document.getElementById('debug-canvas-overlay')
|
||||
if (existingOverlay) {
|
||||
existingOverlay.remove()
|
||||
}
|
||||
|
||||
// Create overlay div
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'debug-canvas-overlay'
|
||||
overlay.style.position = 'fixed'
|
||||
overlay.style.top = '0'
|
||||
overlay.style.left = '0'
|
||||
overlay.style.zIndex = '9999'
|
||||
overlay.style.backgroundColor = 'white'
|
||||
overlay.style.padding = '10px'
|
||||
overlay.style.border = '2px solid red'
|
||||
|
||||
// Create image from canvas
|
||||
const img = document.createElement('img')
|
||||
img.src = canvas.toDataURL('image/png')
|
||||
img.style.maxWidth = '800px'
|
||||
img.style.maxHeight = '600px'
|
||||
overlay.appendChild(img)
|
||||
|
||||
document.body.appendChild(overlay)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the debug canvas overlay
|
||||
*/
|
||||
async debugHideCanvasOverlay(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
const overlay = document.getElementById('debug-canvas-overlay')
|
||||
if (overlay) {
|
||||
overlay.remove()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async clickEmptyLatentNode() {
|
||||
await this.canvas.click({
|
||||
position: {
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
import { ComfyPage } from '../ComfyPage'
|
||||
|
||||
export class SettingDialog {
|
||||
constructor(
|
||||
public readonly page: Page,
|
||||
public readonly comfyPage: ComfyPage
|
||||
) {}
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
get root() {
|
||||
return this.page.locator('div.settings-container')
|
||||
}
|
||||
|
||||
async open() {
|
||||
await this.comfyPage.executeCommand('Comfy.ShowSettingsDialog')
|
||||
const button = this.page.locator('button.comfy-settings-btn:visible')
|
||||
await button.click()
|
||||
await this.page.waitForSelector('div.settings-container')
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ export class Topbar {
|
||||
.innerText()
|
||||
}
|
||||
|
||||
async openSubmenuMobile() {
|
||||
await this.page.locator('.p-menubar-mobile .p-menubar-button').click()
|
||||
}
|
||||
|
||||
getMenuItem(itemLabel: string): Locator {
|
||||
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
|
||||
}
|
||||
@@ -64,41 +68,31 @@ export class Topbar {
|
||||
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 })
|
||||
}
|
||||
|
||||
async openTopbarMenu() {
|
||||
await this.page.locator('.comfyui-logo-wrapper').click()
|
||||
const menu = this.page.locator('.comfy-command-menu')
|
||||
await menu.waitFor({ state: 'visible' })
|
||||
return menu
|
||||
}
|
||||
|
||||
async triggerTopbarCommand(path: string[]) {
|
||||
if (path.length < 2) {
|
||||
throw new Error('Path is too short')
|
||||
}
|
||||
|
||||
const menu = await this.openTopbarMenu()
|
||||
const tabName = path[0]
|
||||
const topLevelMenuItem = this.page.locator(
|
||||
`.p-menubar-item-label:text-is("${tabName}")`
|
||||
const topLevelMenu = this.page.locator(
|
||||
`.top-menubar .p-menubar-item-label:text-is("${tabName}")`
|
||||
)
|
||||
const topLevelMenu = menu
|
||||
.locator('.p-tieredmenu-item')
|
||||
.filter({ has: topLevelMenuItem })
|
||||
await topLevelMenu.waitFor({ state: 'visible' })
|
||||
await topLevelMenu.hover()
|
||||
await topLevelMenu.click()
|
||||
|
||||
let currentMenu = topLevelMenu
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const commandName = path[i]
|
||||
const menuItem = currentMenu
|
||||
const menuItem = this.page
|
||||
.locator(
|
||||
`.p-tieredmenu-submenu .p-tieredmenu-item:has-text("${commandName}")`
|
||||
`.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")`
|
||||
)
|
||||
.first()
|
||||
await menuItem.waitFor({ state: 'visible' })
|
||||
await menuItem.hover()
|
||||
currentMenu = menuItem
|
||||
|
||||
if (i === path.length - 1) {
|
||||
await menuItem.click()
|
||||
}
|
||||
}
|
||||
await currentMenu.click()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,128 +12,6 @@ export const getMiddlePoint = (pos1: Position, pos2: Position) => {
|
||||
}
|
||||
}
|
||||
|
||||
export class SubgraphSlotReference {
|
||||
constructor(
|
||||
readonly type: 'input' | 'output',
|
||||
readonly slotName: string,
|
||||
readonly comfyPage: ComfyPage
|
||||
) {}
|
||||
|
||||
async getPosition(): Promise<Position> {
|
||||
const pos: [number, number] = await this.comfyPage.page.evaluate(
|
||||
([type, slotName]) => {
|
||||
const currentGraph = window['app'].canvas.graph
|
||||
|
||||
// Check if we're in a subgraph
|
||||
if (currentGraph.constructor.name !== 'Subgraph') {
|
||||
throw new Error(
|
||||
'Not in a subgraph - this method only works inside subgraphs'
|
||||
)
|
||||
}
|
||||
|
||||
const slots =
|
||||
type === 'input' ? currentGraph.inputs : currentGraph.outputs
|
||||
if (!slots || slots.length === 0) {
|
||||
throw new Error(`No ${type} slots found in subgraph`)
|
||||
}
|
||||
|
||||
// Find the specific slot or use the first one if no name specified
|
||||
const slot = slotName
|
||||
? slots.find((s) => s.name === slotName)
|
||||
: slots[0]
|
||||
|
||||
if (!slot) {
|
||||
throw new Error(`${type} slot '${slotName}' not found`)
|
||||
}
|
||||
|
||||
if (!slot.pos) {
|
||||
throw new Error(`${type} slot '${slotName}' has no position`)
|
||||
}
|
||||
|
||||
// Convert from offset to canvas coordinates
|
||||
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
|
||||
slot.pos[0],
|
||||
slot.pos[1]
|
||||
])
|
||||
return canvasPos
|
||||
},
|
||||
[this.type, this.slotName] as const
|
||||
)
|
||||
|
||||
return {
|
||||
x: pos[0],
|
||||
y: pos[1]
|
||||
}
|
||||
}
|
||||
|
||||
async getOpenSlotPosition(): Promise<Position> {
|
||||
const pos: [number, number] = await this.comfyPage.page.evaluate(
|
||||
([type]) => {
|
||||
const currentGraph = window['app'].canvas.graph
|
||||
|
||||
if (currentGraph.constructor.name !== 'Subgraph') {
|
||||
throw new Error(
|
||||
'Not in a subgraph - this method only works inside subgraphs'
|
||||
)
|
||||
}
|
||||
|
||||
const node =
|
||||
type === 'input' ? currentGraph.inputNode : currentGraph.outputNode
|
||||
const slots =
|
||||
type === 'input' ? currentGraph.inputs : currentGraph.outputs
|
||||
|
||||
if (!node) {
|
||||
throw new Error(`No ${type} node found in subgraph`)
|
||||
}
|
||||
|
||||
// Calculate position for next available slot
|
||||
// const nextSlotIndex = slots?.length || 0
|
||||
// const slotHeight = 20
|
||||
// const slotY = node.pos[1] + 30 + nextSlotIndex * slotHeight
|
||||
|
||||
// Find last slot position
|
||||
const lastSlot = slots.at(-1)
|
||||
let slotX: number
|
||||
let slotY: number
|
||||
|
||||
if (lastSlot) {
|
||||
// If there are existing slots, position the new one below the last one
|
||||
const gapHeight = 20
|
||||
slotX = lastSlot.pos[0]
|
||||
slotY = lastSlot.pos[1] + gapHeight
|
||||
} else {
|
||||
// No existing slots - use slotAnchorX if available, otherwise calculate from node position
|
||||
if (currentGraph.slotAnchorX !== undefined) {
|
||||
// The actual slot X position seems to be slotAnchorX - 10
|
||||
slotX = currentGraph.slotAnchorX - 10
|
||||
} else {
|
||||
// Fallback: calculate from node edge
|
||||
slotX =
|
||||
type === 'input'
|
||||
? node.pos[0] + node.size[0] - 10 // Right edge for input node
|
||||
: node.pos[0] + 10 // Left edge for output node
|
||||
}
|
||||
// For Y position when no slots exist, use middle of node
|
||||
slotY = node.pos[1] + node.size[1] / 2
|
||||
}
|
||||
|
||||
// Convert from offset to canvas coordinates
|
||||
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
|
||||
slotX,
|
||||
slotY
|
||||
])
|
||||
return canvasPos
|
||||
},
|
||||
[this.type] as const
|
||||
)
|
||||
|
||||
return {
|
||||
x: pos[0],
|
||||
y: pos[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeSlotReference {
|
||||
constructor(
|
||||
readonly type: 'input' | 'output',
|
||||
@@ -143,27 +21,11 @@ export class NodeSlotReference {
|
||||
async getPosition() {
|
||||
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
|
||||
([type, id, index]) => {
|
||||
// Use canvas.graph to get the current graph (works in both main graph and subgraphs)
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
|
||||
const rawPos = node.getConnectionPos(type === 'input', index)
|
||||
const convertedPos =
|
||||
window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
|
||||
|
||||
// Debug logging - convert Float32Arrays to regular arrays for visibility
|
||||
console.log(
|
||||
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
|
||||
{
|
||||
nodePos: [node.pos[0], node.pos[1]],
|
||||
nodeSize: [node.size[0], node.size[1]],
|
||||
rawConnectionPos: [rawPos[0], rawPos[1]],
|
||||
convertedPos: [convertedPos[0], convertedPos[1]],
|
||||
currentGraphType: window['app'].canvas.graph.constructor.name
|
||||
}
|
||||
return window['app'].canvas.ds.convertOffsetToCanvas(
|
||||
node.getConnectionPos(type === 'input', index)
|
||||
)
|
||||
|
||||
return convertedPos
|
||||
},
|
||||
[this.type, this.node.id, this.index] as const
|
||||
)
|
||||
@@ -175,7 +37,7 @@ export class NodeSlotReference {
|
||||
async getLinkCount() {
|
||||
return await this.node.comfyPage.page.evaluate(
|
||||
([type, id, index]) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
if (type === 'input') {
|
||||
return node.inputs[index].link == null ? 0 : 1
|
||||
@@ -188,7 +50,7 @@ export class NodeSlotReference {
|
||||
async removeLinks() {
|
||||
await this.node.comfyPage.page.evaluate(
|
||||
([type, id, index]) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
if (type === 'input') {
|
||||
node.disconnectInput(index)
|
||||
@@ -213,7 +75,7 @@ export class NodeWidgetReference {
|
||||
async getPosition(): Promise<Position> {
|
||||
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
|
||||
([id, index]) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
const widget = node.widgets[index]
|
||||
if (!widget) throw new Error(`Widget ${index} not found.`)
|
||||
@@ -272,7 +134,7 @@ export class NodeWidgetReference {
|
||||
const pos = await this.getPosition()
|
||||
const canvas = this.node.comfyPage.canvas
|
||||
const canvasPos = (await canvas.boundingBox())!
|
||||
await this.node.comfyPage.dragAndDrop(
|
||||
this.node.comfyPage.dragAndDrop(
|
||||
{
|
||||
x: canvasPos.x + pos.x,
|
||||
y: canvasPos.y + pos.y
|
||||
@@ -304,7 +166,7 @@ export class NodeReference {
|
||||
) {}
|
||||
async exists(): Promise<boolean> {
|
||||
return await this.comfyPage.page.evaluate((id) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
return !!node
|
||||
}, this.id)
|
||||
}
|
||||
@@ -323,7 +185,7 @@ export class NodeReference {
|
||||
async getBounding(): Promise<Position & Size> {
|
||||
const [x, y, width, height]: [number, number, number, number] =
|
||||
await this.comfyPage.page.evaluate((id) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error('Node not found')
|
||||
return node.getBounding()
|
||||
}, this.id)
|
||||
@@ -356,7 +218,7 @@ export class NodeReference {
|
||||
async getProperty<T>(prop: string): Promise<T> {
|
||||
return await this.comfyPage.page.evaluate(
|
||||
([id, prop]) => {
|
||||
const node = window['app'].canvas.graph.getNodeById(id)
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error('Node not found')
|
||||
return node[prop]
|
||||
},
|
||||
@@ -397,8 +259,7 @@ export class NodeReference {
|
||||
|
||||
await this.comfyPage.canvas.click({
|
||||
...options,
|
||||
position: clickPos,
|
||||
force: true
|
||||
position: clickPos
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
if (moveMouseToEmptyArea) {
|
||||
@@ -458,18 +319,6 @@ export class NodeReference {
|
||||
}
|
||||
return nodes[0]
|
||||
}
|
||||
async convertToSubgraph() {
|
||||
await this.clickContextMenuOption('Convert to Subgraph')
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.page.waitForTimeout(256)
|
||||
const nodes = await this.comfyPage.getNodeRefsByTitle('New Subgraph')
|
||||
if (nodes.length !== 1) {
|
||||
throw new Error(
|
||||
`Did not find single subgraph node (found=${nodes.length})`
|
||||
)
|
||||
}
|
||||
return nodes[0]
|
||||
}
|
||||
async manageGroupNode() {
|
||||
await this.clickContextMenuOption('Manage Group Node')
|
||||
await this.comfyPage.nextFrame()
|
||||
@@ -478,58 +327,4 @@ export class NodeReference {
|
||||
this.comfyPage.page.locator('.comfy-group-manage')
|
||||
)
|
||||
}
|
||||
async navigateIntoSubgraph() {
|
||||
const titleHeight = await this.comfyPage.page.evaluate(() => {
|
||||
return window['LiteGraph']['NODE_TITLE_HEIGHT']
|
||||
})
|
||||
const nodePos = await this.getPosition()
|
||||
const nodeSize = await this.getSize()
|
||||
|
||||
// Try multiple positions to avoid DOM widget interference
|
||||
const clickPositions = [
|
||||
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + titleHeight + 5 },
|
||||
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + nodeSize.height / 2 },
|
||||
{ x: nodePos.x + 20, y: nodePos.y + titleHeight + 5 }
|
||||
]
|
||||
|
||||
let isInSubgraph = false
|
||||
let attempts = 0
|
||||
const maxAttempts = 3
|
||||
|
||||
while (!isInSubgraph && attempts < maxAttempts) {
|
||||
attempts++
|
||||
|
||||
for (const position of clickPositions) {
|
||||
// Clear any selection first
|
||||
await this.comfyPage.canvas.click({
|
||||
position: { x: 50, y: 50 },
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
|
||||
// Double-click to enter subgraph
|
||||
await this.comfyPage.canvas.dblclick({ position, force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.page.waitForTimeout(500)
|
||||
|
||||
// Check if we successfully entered the subgraph
|
||||
isInSubgraph = await this.comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph?.constructor?.name === 'Subgraph'
|
||||
})
|
||||
|
||||
if (isInSubgraph) break
|
||||
}
|
||||
|
||||
if (!isInSubgraph && attempts < maxAttempts) {
|
||||
await this.comfyPage.page.waitForTimeout(500)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInSubgraph) {
|
||||
throw new Error(
|
||||
'Failed to navigate into subgraph after ' + attempts + ' attempts'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Feature Flags', () => {
|
||||
test('Client and server exchange feature flags on connection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Navigate to a new page to capture the initial WebSocket connection
|
||||
const newPage = await comfyPage.page.context().newPage()
|
||||
|
||||
// Set up monitoring before navigation
|
||||
await newPage.addInitScript(() => {
|
||||
// This runs before any page scripts
|
||||
window.__capturedMessages = {
|
||||
clientFeatureFlags: null,
|
||||
serverFeatureFlags: null
|
||||
}
|
||||
|
||||
// Capture outgoing client messages
|
||||
const originalSend = WebSocket.prototype.send
|
||||
WebSocket.prototype.send = function (data) {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.type === 'feature_flags') {
|
||||
window.__capturedMessages.clientFeatureFlags = parsed
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, ignore
|
||||
}
|
||||
return originalSend.call(this, data)
|
||||
}
|
||||
|
||||
// Monitor for server feature flags
|
||||
const checkInterval = setInterval(() => {
|
||||
if (
|
||||
window['app']?.api?.serverFeatureFlags &&
|
||||
Object.keys(window['app'].api.serverFeatureFlags).length > 0
|
||||
) {
|
||||
window.__capturedMessages.serverFeatureFlags =
|
||||
window['app'].api.serverFeatureFlags
|
||||
clearInterval(checkInterval)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
// Clear after 10 seconds
|
||||
setTimeout(() => clearInterval(checkInterval), 10000)
|
||||
})
|
||||
|
||||
// Navigate to the app
|
||||
await newPage.goto(comfyPage.url)
|
||||
|
||||
// Wait for both client and server feature flags
|
||||
await newPage.waitForFunction(
|
||||
() =>
|
||||
window.__capturedMessages.clientFeatureFlags !== null &&
|
||||
window.__capturedMessages.serverFeatureFlags !== null,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
// Get the captured messages
|
||||
const messages = await newPage.evaluate(() => window.__capturedMessages)
|
||||
|
||||
// Verify client sent feature flags
|
||||
expect(messages.clientFeatureFlags).toBeTruthy()
|
||||
expect(messages.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
|
||||
expect(messages.clientFeatureFlags).toHaveProperty('data')
|
||||
expect(messages.clientFeatureFlags.data).toHaveProperty(
|
||||
'supports_preview_metadata'
|
||||
)
|
||||
expect(
|
||||
typeof messages.clientFeatureFlags.data.supports_preview_metadata
|
||||
).toBe('boolean')
|
||||
|
||||
// Verify server sent feature flags back
|
||||
expect(messages.serverFeatureFlags).toBeTruthy()
|
||||
expect(messages.serverFeatureFlags).toHaveProperty(
|
||||
'supports_preview_metadata'
|
||||
)
|
||||
expect(typeof messages.serverFeatureFlags.supports_preview_metadata).toBe(
|
||||
'boolean'
|
||||
)
|
||||
expect(messages.serverFeatureFlags).toHaveProperty('max_upload_size')
|
||||
expect(typeof messages.serverFeatureFlags.max_upload_size).toBe('number')
|
||||
expect(Object.keys(messages.serverFeatureFlags).length).toBeGreaterThan(0)
|
||||
|
||||
await newPage.close()
|
||||
})
|
||||
|
||||
test('Server feature flags are received and accessible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Wait for connection to establish
|
||||
await comfyPage.page.waitForTimeout(1000)
|
||||
|
||||
// Get the actual server feature flags from the backend
|
||||
const serverFlags = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.serverFeatureFlags
|
||||
})
|
||||
|
||||
// Verify we received real feature flags from the backend
|
||||
expect(serverFlags).toBeTruthy()
|
||||
expect(Object.keys(serverFlags).length).toBeGreaterThan(0)
|
||||
|
||||
// The backend should send feature flags
|
||||
expect(serverFlags).toHaveProperty('supports_preview_metadata')
|
||||
expect(typeof serverFlags.supports_preview_metadata).toBe('boolean')
|
||||
expect(serverFlags).toHaveProperty('max_upload_size')
|
||||
expect(typeof serverFlags.max_upload_size).toBe('number')
|
||||
})
|
||||
|
||||
test('serverSupportsFeature method works with real backend flags', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Wait for connection
|
||||
await comfyPage.page.waitForTimeout(1000)
|
||||
|
||||
// Test serverSupportsFeature with real backend flags
|
||||
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.serverSupportsFeature(
|
||||
'supports_preview_metadata'
|
||||
)
|
||||
})
|
||||
// The method should return a boolean based on the backend's value
|
||||
expect(typeof supportsPreviewMetadata).toBe('boolean')
|
||||
|
||||
// Test non-existent feature - should always return false
|
||||
const supportsNonExistent = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.serverSupportsFeature('non_existent_feature_xyz')
|
||||
})
|
||||
expect(supportsNonExistent).toBe(false)
|
||||
|
||||
// Test that the method only returns true for boolean true values
|
||||
const testResults = await comfyPage.page.evaluate(() => {
|
||||
// Temporarily modify serverFeatureFlags to test behavior
|
||||
const original = window['app'].api.serverFeatureFlags
|
||||
window['app'].api.serverFeatureFlags = {
|
||||
bool_true: true,
|
||||
bool_false: false,
|
||||
string_value: 'yes',
|
||||
number_value: 1,
|
||||
null_value: null
|
||||
}
|
||||
|
||||
const results = {
|
||||
bool_true: window['app'].api.serverSupportsFeature('bool_true'),
|
||||
bool_false: window['app'].api.serverSupportsFeature('bool_false'),
|
||||
string_value: window['app'].api.serverSupportsFeature('string_value'),
|
||||
number_value: window['app'].api.serverSupportsFeature('number_value'),
|
||||
null_value: window['app'].api.serverSupportsFeature('null_value')
|
||||
}
|
||||
|
||||
// Restore original
|
||||
window['app'].api.serverFeatureFlags = original
|
||||
return results
|
||||
})
|
||||
|
||||
// serverSupportsFeature should only return true for boolean true values
|
||||
expect(testResults.bool_true).toBe(true)
|
||||
expect(testResults.bool_false).toBe(false)
|
||||
expect(testResults.string_value).toBe(false)
|
||||
expect(testResults.number_value).toBe(false)
|
||||
expect(testResults.null_value).toBe(false)
|
||||
})
|
||||
|
||||
test('getServerFeature method works with real backend data', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Wait for connection
|
||||
await comfyPage.page.waitForTimeout(1000)
|
||||
|
||||
// Test getServerFeature method
|
||||
const previewMetadataValue = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.getServerFeature('supports_preview_metadata')
|
||||
})
|
||||
expect(typeof previewMetadataValue).toBe('boolean')
|
||||
|
||||
// Test getting max_upload_size
|
||||
const maxUploadSize = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.getServerFeature('max_upload_size')
|
||||
})
|
||||
expect(typeof maxUploadSize).toBe('number')
|
||||
expect(maxUploadSize).toBeGreaterThan(0)
|
||||
|
||||
// Test getServerFeature with default value for non-existent feature
|
||||
const defaultValue = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.getServerFeature(
|
||||
'non_existent_feature_xyz',
|
||||
'default'
|
||||
)
|
||||
})
|
||||
expect(defaultValue).toBe('default')
|
||||
})
|
||||
|
||||
test('getServerFeatures returns all backend feature flags', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Wait for connection
|
||||
await comfyPage.page.waitForTimeout(1000)
|
||||
|
||||
// Test getServerFeatures returns all flags
|
||||
const allFeatures = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].api.getServerFeatures()
|
||||
})
|
||||
|
||||
expect(allFeatures).toBeTruthy()
|
||||
expect(allFeatures).toHaveProperty('supports_preview_metadata')
|
||||
expect(typeof allFeatures.supports_preview_metadata).toBe('boolean')
|
||||
expect(allFeatures).toHaveProperty('max_upload_size')
|
||||
expect(Object.keys(allFeatures).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Client feature flags are immutable', async ({ comfyPage }) => {
|
||||
// Test that getClientFeatureFlags returns a copy
|
||||
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
||||
const flags1 = window['app'].api.getClientFeatureFlags()
|
||||
const flags2 = window['app'].api.getClientFeatureFlags()
|
||||
|
||||
// Modify the first object
|
||||
flags1.test_modification = true
|
||||
|
||||
// Get flags again to check if original was modified
|
||||
const flags3 = window['app'].api.getClientFeatureFlags()
|
||||
|
||||
return {
|
||||
areEqual: flags1 === flags2,
|
||||
hasModification: flags3.test_modification !== undefined,
|
||||
hasSupportsPreview: flags1.supports_preview_metadata !== undefined,
|
||||
supportsPreviewValue: flags1.supports_preview_metadata
|
||||
}
|
||||
})
|
||||
|
||||
// Verify they are different objects (not the same reference)
|
||||
expect(immutabilityTest.areEqual).toBe(false)
|
||||
|
||||
// Verify modification didn't affect the original
|
||||
expect(immutabilityTest.hasModification).toBe(false)
|
||||
|
||||
// Verify the flags contain expected properties
|
||||
expect(immutabilityTest.hasSupportsPreview).toBe(true)
|
||||
expect(typeof immutabilityTest.supportsPreviewValue).toBe('boolean') // From clientFeatureFlags.json
|
||||
})
|
||||
|
||||
test('Server features are immutable when accessed via getServerFeatures', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Wait for connection to establish
|
||||
await comfyPage.page.waitForTimeout(1000)
|
||||
|
||||
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
||||
// Get a copy of server features
|
||||
const features1 = window['app'].api.getServerFeatures()
|
||||
|
||||
// Try to modify it
|
||||
features1.supports_preview_metadata = false
|
||||
features1.new_feature = 'added'
|
||||
|
||||
// Get another copy
|
||||
const features2 = window['app'].api.getServerFeatures()
|
||||
|
||||
return {
|
||||
modifiedValue: features1.supports_preview_metadata,
|
||||
originalValue: features2.supports_preview_metadata,
|
||||
hasNewFeature: features2.new_feature !== undefined,
|
||||
hasSupportsPreview: features2.supports_preview_metadata !== undefined
|
||||
}
|
||||
})
|
||||
|
||||
// The modification should only affect the copy
|
||||
expect(immutabilityTest.modifiedValue).toBe(false)
|
||||
expect(typeof immutabilityTest.originalValue).toBe('boolean') // Backend sends boolean for supports_preview_metadata
|
||||
expect(immutabilityTest.hasNewFeature).toBe(false)
|
||||
expect(immutabilityTest.hasSupportsPreview).toBe(true)
|
||||
})
|
||||
|
||||
test('Feature flags are negotiated early in connection lifecycle', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// This test verifies that feature flags are available early in the app lifecycle
|
||||
// which is important for protocol negotiation
|
||||
|
||||
// Create a new page to ensure clean state
|
||||
const newPage = await comfyPage.page.context().newPage()
|
||||
|
||||
// Set up monitoring before navigation
|
||||
await newPage.addInitScript(() => {
|
||||
// Track when various app components are ready
|
||||
;(window as any).__appReadiness = {
|
||||
featureFlagsReceived: false,
|
||||
apiInitialized: false,
|
||||
appInitialized: false
|
||||
}
|
||||
|
||||
// Monitor when feature flags arrive by checking periodically
|
||||
const checkFeatureFlags = setInterval(() => {
|
||||
if (
|
||||
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
|
||||
undefined
|
||||
) {
|
||||
;(window as any).__appReadiness.featureFlagsReceived = true
|
||||
clearInterval(checkFeatureFlags)
|
||||
}
|
||||
}, 10)
|
||||
|
||||
// Monitor API initialization
|
||||
const checkApi = setInterval(() => {
|
||||
if (window['app']?.api) {
|
||||
;(window as any).__appReadiness.apiInitialized = true
|
||||
clearInterval(checkApi)
|
||||
}
|
||||
}, 10)
|
||||
|
||||
// Monitor app initialization
|
||||
const checkApp = setInterval(() => {
|
||||
if (window['app']?.graph) {
|
||||
;(window as any).__appReadiness.appInitialized = true
|
||||
clearInterval(checkApp)
|
||||
}
|
||||
}, 10)
|
||||
|
||||
// Clean up after 10 seconds
|
||||
setTimeout(() => {
|
||||
clearInterval(checkFeatureFlags)
|
||||
clearInterval(checkApi)
|
||||
clearInterval(checkApp)
|
||||
}, 10000)
|
||||
})
|
||||
|
||||
// Navigate to the app
|
||||
await newPage.goto(comfyPage.url)
|
||||
|
||||
// Wait for feature flags to be received
|
||||
await newPage.waitForFunction(
|
||||
() =>
|
||||
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
|
||||
undefined,
|
||||
{
|
||||
timeout: 10000
|
||||
}
|
||||
)
|
||||
|
||||
// Get readiness state
|
||||
const readiness = await newPage.evaluate(() => {
|
||||
return {
|
||||
...(window as any).__appReadiness,
|
||||
currentFlags: window['app'].api.serverFeatureFlags
|
||||
}
|
||||
})
|
||||
|
||||
// Verify feature flags are available
|
||||
expect(readiness.currentFlags).toHaveProperty('supports_preview_metadata')
|
||||
expect(typeof readiness.currentFlags.supports_preview_metadata).toBe(
|
||||
'boolean'
|
||||
)
|
||||
expect(readiness.currentFlags).toHaveProperty('max_upload_size')
|
||||
|
||||
// Verify feature flags were received (we detected them via polling)
|
||||
expect(readiness.featureFlagsReceived).toBe(true)
|
||||
|
||||
// Verify API was initialized (feature flags require API)
|
||||
expect(readiness.apiInitialized).toBe(true)
|
||||
|
||||
await newPage.close()
|
||||
})
|
||||
|
||||
test('Backend /features endpoint returns feature flags', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Test the HTTP endpoint directly
|
||||
const response = await comfyPage.page.request.get(
|
||||
`${comfyPage.url}/api/features`
|
||||
)
|
||||
expect(response.ok()).toBe(true)
|
||||
|
||||
const features = await response.json()
|
||||
expect(features).toBeTruthy()
|
||||
expect(features).toHaveProperty('supports_preview_metadata')
|
||||
expect(typeof features.supports_preview_metadata).toBe('boolean')
|
||||
expect(features).toHaveProperty('max_upload_size')
|
||||
expect(Object.keys(features).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -769,8 +769,7 @@ test.describe('Viewport settings', () => {
|
||||
}) => {
|
||||
// Screenshot the canvas element
|
||||
await comfyPage.menu.topbar.saveWorkflow('Workflow A')
|
||||
await comfyPage.nextFrame()
|
||||
const screenshotA = (await comfyPage.canvas.screenshot()).toString('base64')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
|
||||
|
||||
// Save workflow as a new file, then zoom out before screen shot
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('Workflow B')
|
||||
@@ -778,12 +777,7 @@ test.describe('Viewport settings', () => {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await comfyMouse.wheel(0, 60)
|
||||
}
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
const screenshotB = (await comfyPage.canvas.screenshot()).toString('base64')
|
||||
|
||||
// Ensure that the screenshots are different due to zoom level
|
||||
expect(screenshotB).not.toBe(screenshotA)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
|
||||
|
||||
const tabA = comfyPage.menu.topbar.getWorkflowTab('Workflow A')
|
||||
const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B')
|
||||
@@ -791,15 +785,11 @@ test.describe('Viewport settings', () => {
|
||||
// Go back to Workflow A
|
||||
await tabA.click()
|
||||
await comfyPage.nextFrame()
|
||||
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
||||
screenshotA
|
||||
)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-a.png')
|
||||
|
||||
// And back to Workflow B
|
||||
await tabB.click()
|
||||
await comfyPage.nextFrame()
|
||||
expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe(
|
||||
screenshotB
|
||||
)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('viewport-workflow-b.png')
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 68 KiB |
@@ -15,8 +15,7 @@ test.describe('Load Workflow in Media', () => {
|
||||
'workflow.mp4',
|
||||
'workflow.mov',
|
||||
'workflow.m4v',
|
||||
'workflow.svg',
|
||||
'workflow.avif'
|
||||
'workflow.svg'
|
||||
]
|
||||
fileNames.forEach(async (fileName) => {
|
||||
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
|
||||
|
||||
|
Before Width: | Height: | Size: 67 KiB |
@@ -63,7 +63,7 @@ test.describe('Menu', () => {
|
||||
test('@mobile Items fully visible on mobile screen width', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.topbar.openTopbarMenu()
|
||||
await comfyPage.menu.topbar.openSubmenuMobile()
|
||||
const topLevelMenuItem = comfyPage.page
|
||||
.locator('a.p-menubar-item-link')
|
||||
.first()
|
||||
@@ -74,9 +74,8 @@ test.describe('Menu', () => {
|
||||
})
|
||||
|
||||
test('Displays keybinding next to item', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.openTopbarMenu()
|
||||
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('Workflow')
|
||||
await workflowMenuItem.hover()
|
||||
await workflowMenuItem.click()
|
||||
const exportTag = comfyPage.page.locator('.keybinding-tag', {
|
||||
hasText: 'Ctrl + s'
|
||||
})
|
||||
|
||||
@@ -27,21 +27,6 @@ test.describe('Node search box', () => {
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('New user (1.24.1+) gets search box by default on link release', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Start fresh to test new user behavior
|
||||
await comfyPage.setup({ clearStorage: true })
|
||||
// Simulate new user with 1.24.1+ installed version
|
||||
await comfyPage.setSetting('Comfy.InstalledVersion', '1.24.1')
|
||||
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
// Don't set LinkRelease settings explicitly to test versioned defaults
|
||||
|
||||
await comfyPage.disconnectEdge()
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
await expect(comfyPage.searchBox.input).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can add node', async ({ comfyPage }) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
@@ -187,10 +172,10 @@ test.describe('Node search box', () => {
|
||||
await comfyPage.page.mouse.click(panelBounds!.x - 10, panelBounds!.y - 10)
|
||||
|
||||
// Verify the filter selection panel is hidden
|
||||
await expect(panel.header).not.toBeVisible()
|
||||
expect(panel.header).not.toBeVisible()
|
||||
|
||||
// Verify the node search dialog is still visible
|
||||
await expect(comfyPage.searchBox.input).toBeVisible()
|
||||
expect(comfyPage.searchBox.input).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can add multiple filters', async ({ comfyPage }) => {
|
||||
@@ -279,38 +264,4 @@ test.describe('Release context menu', () => {
|
||||
'link-context-menu-search.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Existing user (pre-1.24.1) gets context menu by default on link release', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Start fresh to test existing user behavior
|
||||
await comfyPage.setup({ clearStorage: true })
|
||||
// Simulate existing user with pre-1.24.1 version
|
||||
await comfyPage.setSetting('Comfy.InstalledVersion', '1.23.0')
|
||||
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
// Don't set LinkRelease settings explicitly to test versioned defaults
|
||||
|
||||
await comfyPage.disconnectEdge()
|
||||
// Context menu should appear, search box should not
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(0)
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
})
|
||||
|
||||
test('Explicit setting overrides versioned defaults', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Start fresh and simulate new user who should get search box by default
|
||||
await comfyPage.setup({ clearStorage: true })
|
||||
await comfyPage.setSetting('Comfy.InstalledVersion', '1.24.1')
|
||||
// But explicitly set to context menu (overriding versioned default)
|
||||
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'context menu')
|
||||
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
|
||||
await comfyPage.disconnectEdge()
|
||||
// Context menu should appear due to explicit setting, not search box
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(0)
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -130,239 +130,4 @@ test.describe('Release Notifications', () => {
|
||||
whatsNewSection.locator('text=No recent releases')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should hide "What\'s New" section when notifications are disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Disable version update notifications
|
||||
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', false)
|
||||
|
||||
// Mock release API with test data
|
||||
await comfyPage.page.route('**/releases**', async (route) => {
|
||||
const url = route.request().url()
|
||||
if (
|
||||
url.includes('api.comfy.org') ||
|
||||
url.includes('stagingapi.comfy.org')
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'high',
|
||||
content: '## New Features\n\n- Added awesome feature',
|
||||
published_at: new Date().toISOString()
|
||||
}
|
||||
])
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.setup({ mockReleases: false })
|
||||
|
||||
// Open help center
|
||||
const helpCenterButton = comfyPage.page.locator('.comfy-help-center-btn')
|
||||
await helpCenterButton.waitFor({ state: 'visible' })
|
||||
await helpCenterButton.click()
|
||||
|
||||
// Verify help center menu appears
|
||||
const helpMenu = comfyPage.page.locator('.help-center-menu')
|
||||
await expect(helpMenu).toBeVisible()
|
||||
|
||||
// Verify "What's New?" section is hidden
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
await expect(whatsNewSection).not.toBeVisible()
|
||||
|
||||
// Should not show any popups or toasts
|
||||
await expect(comfyPage.page.locator('.whats-new-popup')).not.toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.locator('.release-notification-toast')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should not make API calls when notifications are disabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Disable version update notifications
|
||||
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', false)
|
||||
|
||||
// Track API calls
|
||||
let apiCallCount = 0
|
||||
await comfyPage.page.route('**/releases**', async (route) => {
|
||||
const url = route.request().url()
|
||||
if (
|
||||
url.includes('api.comfy.org') ||
|
||||
url.includes('stagingapi.comfy.org')
|
||||
) {
|
||||
apiCallCount++
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([])
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.setup({ mockReleases: false })
|
||||
|
||||
// Wait a bit to ensure any potential API calls would have been made
|
||||
await comfyPage.page.waitForTimeout(1000)
|
||||
|
||||
// Verify no API calls were made
|
||||
expect(apiCallCount).toBe(0)
|
||||
})
|
||||
|
||||
test('should show "What\'s New" section when notifications are enabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Enable version update notifications (default behavior)
|
||||
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', true)
|
||||
|
||||
// Mock release API with test data
|
||||
await comfyPage.page.route('**/releases**', async (route) => {
|
||||
const url = route.request().url()
|
||||
if (
|
||||
url.includes('api.comfy.org') ||
|
||||
url.includes('stagingapi.comfy.org')
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'medium',
|
||||
content: '## New Features\n\n- Added awesome feature',
|
||||
published_at: new Date().toISOString()
|
||||
}
|
||||
])
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.setup({ mockReleases: false })
|
||||
|
||||
// Open help center
|
||||
const helpCenterButton = comfyPage.page.locator('.comfy-help-center-btn')
|
||||
await helpCenterButton.waitFor({ state: 'visible' })
|
||||
await helpCenterButton.click()
|
||||
|
||||
// Verify help center menu appears
|
||||
const helpMenu = comfyPage.page.locator('.help-center-menu')
|
||||
await expect(helpMenu).toBeVisible()
|
||||
|
||||
// Verify "What's New?" section is visible
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
await expect(whatsNewSection).toBeVisible()
|
||||
|
||||
// Should show the release
|
||||
await expect(
|
||||
whatsNewSection.locator('text=Comfy v0.3.44 Release')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should toggle "What\'s New" section when setting changes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock release API with test data
|
||||
await comfyPage.page.route('**/releases**', async (route) => {
|
||||
const url = route.request().url()
|
||||
if (
|
||||
url.includes('api.comfy.org') ||
|
||||
url.includes('stagingapi.comfy.org')
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 1,
|
||||
project: 'comfyui',
|
||||
version: 'v0.3.44',
|
||||
attention: 'low',
|
||||
content: '## Bug Fixes\n\n- Fixed minor issue',
|
||||
published_at: new Date().toISOString()
|
||||
}
|
||||
])
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
|
||||
// Start with notifications enabled
|
||||
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', true)
|
||||
await comfyPage.setup({ mockReleases: false })
|
||||
|
||||
// Open help center
|
||||
const helpCenterButton = comfyPage.page.locator('.comfy-help-center-btn')
|
||||
await helpCenterButton.waitFor({ state: 'visible' })
|
||||
await helpCenterButton.click()
|
||||
|
||||
// Verify "What's New?" section is visible
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
await expect(whatsNewSection).toBeVisible()
|
||||
|
||||
// Close help center
|
||||
await comfyPage.page.click('.help-center-backdrop')
|
||||
|
||||
// Disable notifications
|
||||
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', false)
|
||||
|
||||
// Reopen help center
|
||||
await helpCenterButton.click()
|
||||
|
||||
// Verify "What's New?" section is now hidden
|
||||
await expect(whatsNewSection).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should handle edge case with empty releases and disabled notifications', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Disable notifications
|
||||
await comfyPage.setSetting('Comfy.Notification.ShowVersionUpdates', false)
|
||||
|
||||
// Mock empty releases
|
||||
await comfyPage.page.route('**/releases**', async (route) => {
|
||||
const url = route.request().url()
|
||||
if (
|
||||
url.includes('api.comfy.org') ||
|
||||
url.includes('stagingapi.comfy.org')
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([])
|
||||
})
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.setup({ mockReleases: false })
|
||||
|
||||
// Open help center
|
||||
const helpCenterButton = comfyPage.page.locator('.comfy-help-center-btn')
|
||||
await helpCenterButton.waitFor({ state: 'visible' })
|
||||
await helpCenterButton.click()
|
||||
|
||||
// Verify help center still works
|
||||
const helpMenu = comfyPage.page.locator('.help-center-menu')
|
||||
await expect(helpMenu).toBeVisible()
|
||||
|
||||
// Section should be hidden regardless of empty releases
|
||||
const whatsNewSection = comfyPage.page.locator('.whats-new-section')
|
||||
await expect(whatsNewSection).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,463 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
// Constants
|
||||
const RENAMED_INPUT_NAME = 'renamed_input'
|
||||
const NEW_SUBGRAPH_TITLE = 'New Subgraph'
|
||||
const UPDATED_SUBGRAPH_TITLE = 'Updated Subgraph Title'
|
||||
const TEST_WIDGET_CONTENT = 'Test content that should persist'
|
||||
|
||||
// Common selectors
|
||||
const SELECTORS = {
|
||||
breadcrumb: '.subgraph-breadcrumb',
|
||||
promptDialog: '.graphdialog input',
|
||||
nodeSearchContainer: '.node-search-container',
|
||||
domWidget: '.comfy-multiline-input'
|
||||
} as const
|
||||
|
||||
test.describe('Subgraph Operations', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
// Helper to get subgraph slot count
|
||||
async function getSubgraphSlotCount(
|
||||
comfyPage: typeof test.prototype.comfyPage,
|
||||
type: 'inputs' | 'outputs'
|
||||
): Promise<number> {
|
||||
return await comfyPage.page.evaluate((slotType) => {
|
||||
return window['app'].canvas.graph[slotType]?.length || 0
|
||||
}, type)
|
||||
}
|
||||
|
||||
// Helper to get current graph node count
|
||||
async function getGraphNodeCount(
|
||||
comfyPage: typeof test.prototype.comfyPage
|
||||
): Promise<number> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
return window['app'].canvas.graph.nodes?.length || 0
|
||||
})
|
||||
}
|
||||
|
||||
// Helper to verify we're in a subgraph
|
||||
async function isInSubgraph(
|
||||
comfyPage: typeof test.prototype.comfyPage
|
||||
): Promise<boolean> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph?.constructor?.name === 'Subgraph'
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('I/O Slot Management', () => {
|
||||
test('Can add input slots to subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||
const vaeEncodeNode = await comfyPage.getNodeRefById('2')
|
||||
|
||||
await comfyPage.connectFromSubgraphInput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||
expect(finalCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('Can add output slots to subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||
const vaeEncodeNode = await comfyPage.getNodeRefById('2')
|
||||
|
||||
await comfyPage.connectToSubgraphOutput(vaeEncodeNode, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||
expect(finalCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('Can remove input slots from subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.rightClickSubgraphInputSlot()
|
||||
await comfyPage.clickLitegraphContextMenuItem('Remove Slot')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await getSubgraphSlotCount(comfyPage, 'inputs')
|
||||
expect(finalCount).toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('Can remove output slots from subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
await comfyPage.rightClickSubgraphOutputSlot()
|
||||
await comfyPage.clickLitegraphContextMenuItem('Remove Slot')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await getSubgraphSlotCount(comfyPage, 'outputs')
|
||||
expect(finalCount).toBe(initialCount - 1)
|
||||
})
|
||||
|
||||
test('Can rename I/O slots', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialInputLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph.inputs?.[0]?.label || null
|
||||
})
|
||||
|
||||
await comfyPage.rightClickSubgraphInputSlot(initialInputLabel)
|
||||
await comfyPage.clickLitegraphContextMenuItem('Rename Slot')
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.promptDialog, {
|
||||
state: 'visible'
|
||||
})
|
||||
await comfyPage.page.fill(SELECTORS.promptDialog, RENAMED_INPUT_NAME)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
// Force re-render
|
||||
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newInputName = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].canvas.graph
|
||||
return graph.inputs?.[0]?.label || null
|
||||
})
|
||||
|
||||
expect(newInputName).toBe(RENAMED_INPUT_NAME)
|
||||
expect(newInputName).not.toBe(initialInputLabel)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Creation and Deletion', () => {
|
||||
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('default')
|
||||
|
||||
const initialNodeCount = await getGraphNodeCount(comfyPage)
|
||||
|
||||
await comfyPage.ctrlA()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const node = await comfyPage.getNodeRefById('5')
|
||||
await node.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodes =
|
||||
await comfyPage.getNodeRefsByTitle(NEW_SUBGRAPH_TITLE)
|
||||
expect(subgraphNodes.length).toBe(1)
|
||||
|
||||
const finalNodeCount = await getGraphNodeCount(comfyPage)
|
||||
expect(finalNodeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('Can delete subgraph node', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
const initialNodeCount = await getGraphNodeCount(comfyPage)
|
||||
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalNodeCount = await getGraphNodeCount(comfyPage)
|
||||
expect(finalNodeCount).toBe(initialNodeCount - 1)
|
||||
|
||||
const deletedNode = await comfyPage.getNodeRefById('2')
|
||||
expect(await deletedNode.exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Operations Inside Subgraphs', () => {
|
||||
test('Can copy and paste nodes in subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialNodeCount = await getGraphNodeCount(comfyPage)
|
||||
|
||||
const nodesInSubgraph = await comfyPage.page.evaluate(() => {
|
||||
const nodes = window['app'].canvas.graph.nodes
|
||||
return nodes?.[0]?.id || null
|
||||
})
|
||||
|
||||
expect(nodesInSubgraph).not.toBeNull()
|
||||
|
||||
const nodeToClone = await comfyPage.getNodeRefById(
|
||||
String(nodesInSubgraph)
|
||||
)
|
||||
await nodeToClone.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+c')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+v')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalNodeCount = await getGraphNodeCount(comfyPage)
|
||||
expect(finalNodeCount).toBe(initialNodeCount + 1)
|
||||
})
|
||||
|
||||
test('Can undo and redo operations in subgraph', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('basic-subgraph')
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Add a node
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Note')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Get initial node count
|
||||
const initialCount = await getGraphNodeCount(comfyPage)
|
||||
|
||||
// Undo
|
||||
await comfyPage.ctrlZ()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterUndoCount = await getGraphNodeCount(comfyPage)
|
||||
expect(afterUndoCount).toBe(initialCount - 1)
|
||||
|
||||
// Redo
|
||||
await comfyPage.ctrlY()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const afterRedoCount = await getGraphNodeCount(comfyPage)
|
||||
expect(afterRedoCount).toBe(initialCount)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Navigation and UI', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Breadcrumb updates when subgraph node title is changed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('nested-subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('10')
|
||||
const nodePos = await subgraphNode.getPosition()
|
||||
const nodeSize = await subgraphNode.getSize()
|
||||
|
||||
// Navigate into subgraph
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb, {
|
||||
state: 'visible',
|
||||
timeout: 20000
|
||||
})
|
||||
|
||||
const breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb)
|
||||
const initialBreadcrumbText = await breadcrumb.textContent()
|
||||
|
||||
// Go back and edit title
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.canvas.dblclick({
|
||||
position: {
|
||||
x: nodePos.x + nodeSize.width / 2,
|
||||
y: nodePos.y - 10
|
||||
},
|
||||
delay: 5
|
||||
})
|
||||
|
||||
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await comfyPage.page.keyboard.type(UPDATED_SUBGRAPH_TITLE)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Navigate back into subgraph
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.page.waitForSelector(SELECTORS.breadcrumb)
|
||||
|
||||
const updatedBreadcrumbText = await breadcrumb.textContent()
|
||||
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
|
||||
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('DOM Widget Promotion', () => {
|
||||
test('DOM widget visibility persists through subgraph navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify promoted widget is visible in parent graph
|
||||
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(parentTextarea).toBeVisible()
|
||||
await expect(parentTextarea).toHaveCount(1)
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('11')
|
||||
expect(await subgraphNode.exists()).toBe(true)
|
||||
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Verify widget is visible in subgraph
|
||||
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(subgraphTextarea).toBeVisible()
|
||||
await expect(subgraphTextarea).toHaveCount(1)
|
||||
|
||||
// Navigate back
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify widget is still visible
|
||||
const backToParentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(backToParentTextarea).toBeVisible()
|
||||
await expect(backToParentTextarea).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('DOM widget content is preserved through navigation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget')
|
||||
|
||||
const textarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await textarea.fill(TEST_WIDGET_CONTENT)
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const subgraphTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const parentTextarea = comfyPage.page.locator(SELECTORS.domWidget)
|
||||
await expect(parentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
|
||||
})
|
||||
|
||||
test('DOM elements are cleaned up when subgraph node is removed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget')
|
||||
|
||||
const initialCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(initialCount).toBe(1)
|
||||
|
||||
const subgraphNode = await comfyPage.getNodeRefById('11')
|
||||
|
||||
await subgraphNode.click('title')
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const finalCount = await comfyPage.page
|
||||
.locator(SELECTORS.domWidget)
|
||||
.count()
|
||||
expect(finalCount).toBe(0)
|
||||
})
|
||||
|
||||
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Enable new menu for breadcrumb navigation
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
await comfyPage.loadWorkflow('subgraph-with-promoted-text-widget')
|
||||
|
||||
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 breadcrumb = comfyPage.page.locator(SELECTORS.breadcrumb).first()
|
||||
await breadcrumb.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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 238 KiB |
@@ -1,289 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Settings Search functionality', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Register test settings to verify hidden/deprecated filtering
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].registerExtension({
|
||||
name: 'TestSettingsExtension',
|
||||
settings: [
|
||||
{
|
||||
id: 'TestHiddenSetting',
|
||||
name: 'Test Hidden Setting',
|
||||
type: 'hidden',
|
||||
defaultValue: 'hidden_value',
|
||||
category: ['Test', 'Hidden']
|
||||
},
|
||||
{
|
||||
id: 'TestDeprecatedSetting',
|
||||
name: 'Test Deprecated Setting',
|
||||
type: 'text',
|
||||
defaultValue: 'deprecated_value',
|
||||
deprecated: true,
|
||||
category: ['Test', 'Deprecated']
|
||||
},
|
||||
{
|
||||
id: 'TestVisibleSetting',
|
||||
name: 'Test Visible Setting',
|
||||
type: 'text',
|
||||
defaultValue: 'visible_value',
|
||||
category: ['Test', 'Visible']
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('can open settings dialog and use search box', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
// Find the search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await expect(searchBox).toBeVisible()
|
||||
|
||||
// Verify search box has the correct placeholder
|
||||
await expect(searchBox).toHaveAttribute(
|
||||
'placeholder',
|
||||
expect.stringContaining('Search')
|
||||
)
|
||||
})
|
||||
|
||||
test('search box is functional and accepts input', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
// Find and interact with the search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Comfy')
|
||||
|
||||
// Verify the input was accepted
|
||||
await expect(searchBox).toHaveValue('Comfy')
|
||||
})
|
||||
|
||||
test('search box clears properly', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
// Find and interact with the search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('test')
|
||||
await expect(searchBox).toHaveValue('test')
|
||||
|
||||
// Clear the search box
|
||||
await searchBox.clear()
|
||||
await expect(searchBox).toHaveValue('')
|
||||
})
|
||||
|
||||
test('settings categories are visible in sidebar', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
// Check that the sidebar has categories
|
||||
const categories = comfyPage.page.locator(
|
||||
'.settings-sidebar .p-listbox-option'
|
||||
)
|
||||
expect(await categories.count()).toBeGreaterThan(0)
|
||||
|
||||
// Check that at least one category is visible
|
||||
await expect(categories.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('can select different categories in sidebar', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
// Get categories and click on different ones
|
||||
const categories = comfyPage.page.locator(
|
||||
'.settings-sidebar .p-listbox-option'
|
||||
)
|
||||
const categoryCount = await categories.count()
|
||||
|
||||
if (categoryCount > 1) {
|
||||
// Click on the second category
|
||||
await categories.nth(1).click()
|
||||
|
||||
// Verify the category is selected
|
||||
await expect(categories.nth(1)).toHaveClass(/p-listbox-option-selected/)
|
||||
}
|
||||
})
|
||||
|
||||
test('settings content area is visible', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
// Check that the content area is visible
|
||||
const contentArea = comfyPage.page.locator('.settings-content')
|
||||
await expect(contentArea).toBeVisible()
|
||||
|
||||
// Check that tab panels are visible
|
||||
const tabPanels = comfyPage.page.locator('.settings-tab-panels')
|
||||
await expect(tabPanels).toBeVisible()
|
||||
})
|
||||
|
||||
test('search functionality affects UI state', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
// Find the search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
|
||||
// Type in search box
|
||||
await searchBox.fill('graph')
|
||||
await comfyPage.page.waitForTimeout(200) // Wait for debounce
|
||||
|
||||
// Verify that the search input is handled
|
||||
await expect(searchBox).toHaveValue('graph')
|
||||
})
|
||||
|
||||
test('settings dialog can be closed', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
// Close with escape key
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
// Verify dialog is closed
|
||||
await expect(settingsDialog).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('search box has proper debouncing behavior', async ({ comfyPage }) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
// Type rapidly in search box
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('a')
|
||||
await searchBox.fill('ab')
|
||||
await searchBox.fill('abc')
|
||||
await searchBox.fill('abcd')
|
||||
|
||||
// Wait for debounce
|
||||
await comfyPage.page.waitForTimeout(200)
|
||||
|
||||
// Verify final value
|
||||
await expect(searchBox).toHaveValue('abcd')
|
||||
})
|
||||
|
||||
test('search excludes hidden settings from results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
// Search for our test settings
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Test')
|
||||
await comfyPage.page.waitForTimeout(300) // Wait for debounce
|
||||
|
||||
// Get all settings content
|
||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
||||
|
||||
// Should show visible setting but not hidden setting
|
||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
||||
await expect(settingsContent).not.toContainText('Test Hidden Setting')
|
||||
})
|
||||
|
||||
test('search excludes deprecated settings from results', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
// Search for our test settings
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Test')
|
||||
await comfyPage.page.waitForTimeout(300) // Wait for debounce
|
||||
|
||||
// Get all settings content
|
||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
||||
|
||||
// Should show visible setting but not deprecated setting
|
||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
||||
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
|
||||
})
|
||||
|
||||
test('search shows visible settings but excludes hidden and deprecated', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
// Search for our test settings
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
await searchBox.fill('Test')
|
||||
await comfyPage.page.waitForTimeout(300) // Wait for debounce
|
||||
|
||||
// Get all settings content
|
||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
||||
|
||||
// Should only show the visible setting
|
||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
||||
|
||||
// Should not show hidden or deprecated settings
|
||||
await expect(settingsContent).not.toContainText('Test Hidden Setting')
|
||||
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
|
||||
})
|
||||
|
||||
test('search by setting name excludes hidden and deprecated', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const settingsDialog = comfyPage.page.locator('.settings-container')
|
||||
await expect(settingsDialog).toBeVisible()
|
||||
|
||||
const searchBox = comfyPage.page.locator('.settings-search-box input')
|
||||
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
|
||||
|
||||
// Search specifically for hidden setting by name
|
||||
await searchBox.clear()
|
||||
await searchBox.fill('Hidden')
|
||||
await comfyPage.page.waitForTimeout(300)
|
||||
|
||||
// Should not show the hidden setting even when searching by name
|
||||
await expect(settingsContent).not.toContainText('Test Hidden Setting')
|
||||
|
||||
// Search specifically for deprecated setting by name
|
||||
await searchBox.clear()
|
||||
await searchBox.fill('Deprecated')
|
||||
await comfyPage.page.waitForTimeout(300)
|
||||
|
||||
// Should not show the deprecated setting even when searching by name
|
||||
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
|
||||
|
||||
// Search for visible setting by name - should work
|
||||
await searchBox.clear()
|
||||
await searchBox.fill('Visible')
|
||||
await comfyPage.page.waitForTimeout(300)
|
||||
|
||||
// Should show the visible setting
|
||||
await expect(settingsContent).toContainText('Test Visible Setting')
|
||||
})
|
||||
})
|
||||
@@ -48,7 +48,7 @@ test.describe('Combo text widget', () => {
|
||||
await comfyPage.page.keyboard.press('r')
|
||||
|
||||
// Wait for nodes' widgets to be updated
|
||||
await comfyPage.page.waitForTimeout(500)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const refreshedComboValues = await getComboValues()
|
||||
expect(refreshedComboValues).not.toEqual(initialComboValues)
|
||||
|
||||
229
package-lock.json
generated
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.25.0",
|
||||
"version": "1.24.0-1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.25.0",
|
||||
"version": "1.24.0-1",
|
||||
"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.16",
|
||||
"@comfyorg/litegraph": "^0.16.6",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
@@ -31,8 +31,6 @@
|
||||
"axios": "^1.8.2",
|
||||
"dompurify": "^3.2.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"extendable-media-recorder": "^9.2.27",
|
||||
"extendable-media-recorder-wav-encoder": "^7.0.129",
|
||||
"firebase": "^11.6.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
@@ -879,10 +877,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.27.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
|
||||
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
|
||||
"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -948,9 +949,9 @@
|
||||
"license": "GPL-3.0-only"
|
||||
},
|
||||
"node_modules/@comfyorg/litegraph": {
|
||||
"version": "0.16.16",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.16.tgz",
|
||||
"integrity": "sha512-6kfTpu6Z7gF0OxSypUsGM7Ggq7JYP/4dhHGhzA3p+C8tak/2dMuQDiOtc5YXRlcYNEk79A/xekpojGC/DtO6iw==",
|
||||
"version": "0.16.6",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.6.tgz",
|
||||
"integrity": "sha512-pRmJYZ39rIpGIaJAaOLicRFe3KyeNTXNAAB0+Thz8cPGpu2dBv8W6PlOu94VYNRc+pBhEwV+jJVlXb5YyAvBXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
@@ -5723,19 +5724,6 @@
|
||||
"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",
|
||||
@@ -5986,18 +5974,6 @@
|
||||
"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",
|
||||
@@ -8354,56 +8330,6 @@
|
||||
"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",
|
||||
@@ -8447,19 +8373,6 @@
|
||||
"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",
|
||||
@@ -11495,43 +11408,6 @@
|
||||
"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",
|
||||
@@ -12306,19 +12182,6 @@
|
||||
"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",
|
||||
@@ -13891,31 +13754,10 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"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/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/registry-auth-token": {
|
||||
"version": "5.0.3",
|
||||
@@ -14432,12 +14274,6 @@
|
||||
"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",
|
||||
@@ -14830,17 +14666,6 @@
|
||||
"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",
|
||||
@@ -15020,17 +14845,6 @@
|
||||
"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",
|
||||
@@ -17560,17 +17374,6 @@
|
||||
"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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.25.0",
|
||||
"version": "1.24.0-1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -77,7 +77,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.16",
|
||||
"@comfyorg/litegraph": "^0.16.6",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
@@ -96,8 +96,6 @@
|
||||
"axios": "^1.8.2",
|
||||
"dompurify": "^3.2.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"extendable-media-recorder": "^9.2.27",
|
||||
"extendable-media-recorder-wav-encoder": "^7.0.129",
|
||||
"firebase": "^11.6.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
|
||||
@@ -17,15 +17,7 @@ const IGNORE_PATTERNS = [
|
||||
/^templateWorkflows\./, // Template workflows are loaded dynamically
|
||||
/^dataTypes\./, // Data types might be referenced dynamically
|
||||
/^contextMenu\./, // Context menu items might be dynamic
|
||||
/^color\./, // Color names might be used dynamically
|
||||
// Auto-generated categories from collect-i18n-general.ts
|
||||
/^menuLabels\./, // Menu labels generated from command labels
|
||||
/^settingsCategories\./, // Settings categories generated from setting definitions
|
||||
/^serverConfigItems\./, // Server config items generated from SERVER_CONFIG_ITEMS
|
||||
/^serverConfigCategories\./, // Server config categories generated from config categories
|
||||
/^nodeCategories\./, // Node categories generated from node definitions
|
||||
// Setting option values that are dynamically generated
|
||||
/\.options\./ // All setting options are rendered dynamically
|
||||
/^color\./ // Color names might be used dynamically
|
||||
]
|
||||
|
||||
// Get list of staged locale files
|
||||
@@ -105,21 +97,17 @@ function shouldIgnoreKey(key: string): boolean {
|
||||
|
||||
// Search for key usage in source files
|
||||
function isKeyUsed(key: string, sourceFiles: string[]): boolean {
|
||||
// Escape special regex characters
|
||||
const escapeRegex = (str: string) =>
|
||||
str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const escapedKey = escapeRegex(key)
|
||||
const lastPart = key.split('.').pop()
|
||||
const escapedLastPart = lastPart ? escapeRegex(lastPart) : ''
|
||||
|
||||
// Common patterns for i18n key usage
|
||||
const patterns = [
|
||||
// Direct usage: $t('key'), t('key'), i18n.t('key')
|
||||
new RegExp(`[t$]\\s*\\(\\s*['"\`]${escapedKey}['"\`]`, 'g'),
|
||||
new RegExp(`[t$]\\s*\\(\\s*['"\`]${key}['"\`]`, 'g'),
|
||||
// With namespace: $t('g.key'), t('namespace.key')
|
||||
new RegExp(`[t$]\\s*\\(\\s*['"\`][^'"]+\\.${escapedLastPart}['"\`]`, 'g'),
|
||||
new RegExp(
|
||||
`[t$]\\s*\\(\\s*['"\`][^'"]+\\.${key.split('.').pop()}['"\`]`,
|
||||
'g'
|
||||
),
|
||||
// Dynamic keys might reference parts of the key
|
||||
new RegExp(`['"\`]${escapedKey}['"\`]`, 'g')
|
||||
new RegExp(`['"\`]${key}['"\`]`, 'g')
|
||||
]
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
@@ -166,7 +154,7 @@ async function checkNewUnusedKeys() {
|
||||
|
||||
// Report results
|
||||
if (unusedNewKeys.length > 0) {
|
||||
console.log('\n⚠️ Warning: Found unused NEW i18n keys:\n')
|
||||
console.log('\n❌ Found unused NEW i18n keys:\n')
|
||||
|
||||
for (const key of unusedNewKeys.sort()) {
|
||||
console.log(` - ${key}`)
|
||||
@@ -176,10 +164,9 @@ async function checkNewUnusedKeys() {
|
||||
console.log(
|
||||
'\nThese keys were added but are not used anywhere in the codebase.'
|
||||
)
|
||||
console.log('Consider using them or removing them in a future update.')
|
||||
console.log('Please either use them or remove them before committing.')
|
||||
|
||||
// Changed from process.exit(1) to process.exit(0) for warning only
|
||||
process.exit(0)
|
||||
process.exit(1)
|
||||
} else {
|
||||
// Silent success - no output needed
|
||||
}
|
||||
|
||||
@@ -30,11 +30,10 @@ import ComfyQueueButton from './ComfyQueueButton.vue'
|
||||
|
||||
const settingsStore = useSettingStore()
|
||||
|
||||
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
||||
const visible = computed(
|
||||
() => settingsStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
|
||||
const visible = computed(() => position.value !== 'Disabled')
|
||||
|
||||
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const dragHandleRef = ref<HTMLElement | null>(null)
|
||||
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', false)
|
||||
@@ -50,16 +49,7 @@ const {
|
||||
} = useDraggable(panelRef, {
|
||||
initialValue: { x: 0, y: 0 },
|
||||
handle: dragHandleRef,
|
||||
containerElement: document.body,
|
||||
onMove: (event) => {
|
||||
// Prevent dragging the menu over the top of the tabs
|
||||
if (position.value === 'Top') {
|
||||
const minY = topMenuRef?.value?.getBoundingClientRect().top ?? 40
|
||||
if (event.y < minY) {
|
||||
event.y = minY
|
||||
}
|
||||
}
|
||||
}
|
||||
containerElement: document.body
|
||||
})
|
||||
|
||||
// Update storedPosition when x or y changes
|
||||
@@ -192,6 +182,7 @@ const adjustMenuPosition = () => {
|
||||
|
||||
useEventListener(window, 'resize', adjustMenuPosition)
|
||||
|
||||
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
|
||||
const topMenuBounds = useElementBounding(topMenuRef)
|
||||
const overlapThreshold = 20 // pixels
|
||||
const isOverlappingWithTopMenu = computed(() => {
|
||||
|
||||
@@ -1,70 +1,34 @@
|
||||
<template>
|
||||
<div
|
||||
class="subgraph-breadcrumb w-auto"
|
||||
:class="{
|
||||
'subgraph-breadcrumb-collapse': collapseTabs,
|
||||
'subgraph-breadcrumb-overflow': overflowingTabs
|
||||
}"
|
||||
:style="{
|
||||
'--p-breadcrumb-gap': `${ITEM_GAP}px`,
|
||||
'--p-breadcrumb-item-min-width': `${MIN_WIDTH}px`,
|
||||
'--p-breadcrumb-item-padding': `${ITEM_PADDING}px`,
|
||||
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
|
||||
}"
|
||||
>
|
||||
<div v-if="workflowStore.isSubgraphActive" class="p-2 subgraph-breadcrumb">
|
||||
<Breadcrumb
|
||||
ref="breadcrumbRef"
|
||||
class="bg-transparent p-0"
|
||||
class="bg-transparent"
|
||||
:home="home"
|
||||
:model="items"
|
||||
aria-label="Graph navigation"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<SubgraphBreadcrumbItem
|
||||
:item="item"
|
||||
:is-active="item === items.at(-1)"
|
||||
/>
|
||||
</template>
|
||||
<template #separator
|
||||
><span style="transform: scale(1.5)"> / </span></template
|
||||
>
|
||||
</Breadcrumb>
|
||||
@item-click="handleItemClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Breadcrumb from 'primevue/breadcrumb'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, onUpdated, ref, watch } from 'vue'
|
||||
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
const MIN_WIDTH = 28
|
||||
const ITEM_GAP = 8
|
||||
const ITEM_PADDING = 8
|
||||
const ICON_WIDTH = 20
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
|
||||
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
const collapseTabs = ref(false)
|
||||
const overflowingTabs = ref(false)
|
||||
|
||||
const breadcrumbElement = computed(() => {
|
||||
if (!breadcrumbRef.value) return null
|
||||
|
||||
const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
|
||||
const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
|
||||
return list
|
||||
})
|
||||
|
||||
const items = computed(() => {
|
||||
const items = navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
|
||||
if (!navigationStore.navigationStack.length) return []
|
||||
|
||||
return navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
|
||||
label: subgraph.name,
|
||||
command: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
@@ -73,14 +37,11 @@ const items = computed(() => {
|
||||
canvas.setGraph(subgraph)
|
||||
}
|
||||
}))
|
||||
|
||||
return [home.value, ...items]
|
||||
})
|
||||
|
||||
const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
key: 'root',
|
||||
command: () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
@@ -89,6 +50,10 @@ const home = computed(() => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const handleItemClick = (event: MenuItemCommandEvent) => {
|
||||
event.item.command?.(event)
|
||||
}
|
||||
|
||||
// Escape exits from the current subgraph.
|
||||
useEventListener(document, 'keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
@@ -100,116 +65,21 @@ useEventListener(document, 'keydown', (event) => {
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
|
||||
let overflowObserver: ReturnType<typeof useOverflowObserver> | undefined
|
||||
watch(breadcrumbElement, (el) => {
|
||||
overflowObserver?.dispose()
|
||||
overflowObserver = undefined
|
||||
|
||||
if (!el) return
|
||||
|
||||
overflowObserver = useOverflowObserver(el, {
|
||||
onCheck: (isOverflowing) => {
|
||||
overflowingTabs.value = isOverflowing
|
||||
|
||||
if (collapseTabs.value) {
|
||||
// Items are currently hidden, check if we can show them
|
||||
if (!isOverflowing) {
|
||||
const items = [
|
||||
...el.querySelectorAll('.p-breadcrumb-item')
|
||||
] as HTMLElement[]
|
||||
|
||||
if (items.length < 3) return
|
||||
|
||||
const itemsWithIcon = items.filter((item) =>
|
||||
item.querySelector('.p-breadcrumb-item-link-icon-visible')
|
||||
).length
|
||||
const separators = el.querySelectorAll(
|
||||
'.p-breadcrumb-separator'
|
||||
) as NodeListOf<HTMLElement>
|
||||
const separator = separators[separators.length - 1] as HTMLElement
|
||||
const separatorWidth = separator.offsetWidth
|
||||
|
||||
// items + separators + gaps + icons
|
||||
const itemsWidth =
|
||||
(MIN_WIDTH + ITEM_PADDING + ITEM_PADDING) * items.length +
|
||||
itemsWithIcon * ICON_WIDTH
|
||||
const separatorsWidth = (items.length - 1) * separatorWidth
|
||||
const gapsWidth = (items.length - 1) * (ITEM_GAP * 2)
|
||||
const totalWidth = itemsWidth + separatorsWidth + gapsWidth
|
||||
const containerWidth = el.clientWidth
|
||||
|
||||
if (totalWidth <= containerWidth) {
|
||||
collapseTabs.value = false
|
||||
}
|
||||
}
|
||||
} else if (isOverflowing) {
|
||||
collapseTabs.value = true
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// If e.g. the workflow name changes, we need to check the overflow again
|
||||
onUpdated(() => {
|
||||
if (!overflowObserver?.disposed.value) {
|
||||
overflowObserver?.checkOverflow()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subgraph-breadcrumb:not(:empty) {
|
||||
flex: auto;
|
||||
flex-shrink: 10000;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.subgraph-breadcrumb,
|
||||
:deep(.p-breadcrumb) {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item) {
|
||||
@apply flex items-center rounded-lg overflow-hidden;
|
||||
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
|
||||
/* Collapse middle items first */
|
||||
flex-shrink: 10000;
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:has(.p-breadcrumb-item-link-icon-visible)) {
|
||||
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem + 20px);
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:first-child) {
|
||||
/* Then collapse the root workflow */
|
||||
flex-shrink: 5000;
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:last-child) {
|
||||
/* Then collapse the active item */
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
:deep(.p-breadcrumb-item:hover),
|
||||
:deep(.p-breadcrumb-item:has(.p-breadcrumb-item-link-menu-visible)) {
|
||||
background-color: color-mix(in srgb, var(--fg-color) 10%, transparent);
|
||||
color: var(--fg-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.subgraph-breadcrumb-collapse .p-breadcrumb-list {
|
||||
.p-breadcrumb-item,
|
||||
.p-breadcrumb-separator {
|
||||
@apply hidden;
|
||||
}
|
||||
.subgraph-breadcrumb {
|
||||
.p-breadcrumb-item-link,
|
||||
.p-breadcrumb-item-icon {
|
||||
@apply select-none;
|
||||
|
||||
.p-breadcrumb-item:nth-last-child(3),
|
||||
.p-breadcrumb-separator:nth-last-child(2),
|
||||
.p-breadcrumb-item:nth-last-child(1) {
|
||||
@apply block;
|
||||
color: #d26565;
|
||||
text-shadow:
|
||||
1px 1px 0 #000,
|
||||
-1px -1px 0 #000,
|
||||
1px -1px 0 #000,
|
||||
-1px 1px 0 #000,
|
||||
0 0 0.375rem #000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
<template>
|
||||
<a
|
||||
ref="wrapperRef"
|
||||
v-tooltip.bottom="item.label"
|
||||
href="#"
|
||||
class="cursor-pointer p-breadcrumb-item-link"
|
||||
:class="{
|
||||
'flex items-center gap-1': isActive,
|
||||
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
|
||||
'p-breadcrumb-item-link-icon-visible': isActive
|
||||
}"
|
||||
@click="handleClick"
|
||||
>
|
||||
<span class="p-breadcrumb-item-label">{{ item.label }}</span>
|
||||
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
|
||||
</a>
|
||||
<Menu
|
||||
v-if="isActive"
|
||||
ref="menu"
|
||||
:model="menuItems"
|
||||
:popup="true"
|
||||
:pt="{
|
||||
root: {
|
||||
style: 'background-color: var(--comfy-menu-secondary-bg)'
|
||||
},
|
||||
itemLink: {
|
||||
class: 'py-2'
|
||||
}
|
||||
}"
|
||||
/>
|
||||
<InputText
|
||||
v-if="isEditing"
|
||||
ref="itemInputRef"
|
||||
v-model="itemLabel"
|
||||
class="fixed z-[10000] text-[.8rem] px-2 py-2"
|
||||
@blur="inputBlur(true)"
|
||||
@click.stop
|
||||
@keydown.enter="inputBlur(true)"
|
||||
@keydown.esc="inputBlur(false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Menu, { MenuState } from 'primevue/menu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
|
||||
interface Props {
|
||||
item: MenuItem
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const menu = ref<InstanceType<typeof Menu> & MenuState>()
|
||||
const dialogService = useDialogService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const isEditing = ref(false)
|
||||
const itemLabel = ref<string>()
|
||||
const itemInputRef = ref<{ $el?: HTMLInputElement }>()
|
||||
const wrapperRef = ref<HTMLAnchorElement>()
|
||||
|
||||
const rename = async (
|
||||
newName: string | null | undefined,
|
||||
initialName: string
|
||||
) => {
|
||||
if (newName && newName !== initialName) {
|
||||
if (workflowStore.activeSubgraph) {
|
||||
workflowStore.activeSubgraph.name = newName
|
||||
} else if (workflowStore.activeWorkflow) {
|
||||
try {
|
||||
await workflowService.renameWorkflow(
|
||||
workflowStore.activeWorkflow,
|
||||
ComfyWorkflow.basePath + appendJsonExt(newName)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
dialogService.showErrorDialog(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Force the navigation stack to recompute the labels
|
||||
// TODO: investigate if there is a better way to do this
|
||||
const navigationStore = useSubgraphNavigationStore()
|
||||
navigationStore.restoreState(navigationStore.exportState())
|
||||
}
|
||||
}
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
label: t('g.rename'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: async () => {
|
||||
let initialName =
|
||||
workflowStore.activeSubgraph?.name ??
|
||||
workflowStore.activeWorkflow?.filename
|
||||
|
||||
if (!initialName) return
|
||||
|
||||
const newName = await dialogService.prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('breadcrumbsMenu.enterNewName'),
|
||||
defaultValue: initialName
|
||||
})
|
||||
|
||||
await rename(newName, initialName)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.duplicate'),
|
||||
icon: 'pi pi-copy',
|
||||
command: async () => {
|
||||
await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: props.item.key === 'root'
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.clearWorkflow'),
|
||||
icon: 'pi pi-trash',
|
||||
command: async () => {
|
||||
await useCommandStore().execute('Comfy.ClearWorkflow')
|
||||
}
|
||||
},
|
||||
{
|
||||
separator: true,
|
||||
visible: props.item.key === 'root'
|
||||
},
|
||||
{
|
||||
label: t('breadcrumbsMenu.deleteWorkflow'),
|
||||
icon: 'pi pi-times',
|
||||
command: async () => {
|
||||
await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
|
||||
},
|
||||
visible: props.item.key === 'root'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (isEditing.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.detail === 1) {
|
||||
if (props.isActive) {
|
||||
menu.value?.toggle(event)
|
||||
} else {
|
||||
props.item.command?.({ item: props.item, originalEvent: event })
|
||||
}
|
||||
} else if (props.isActive && event.detail === 2) {
|
||||
menu.value?.hide()
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
isEditing.value = true
|
||||
itemLabel.value = props.item.label as string
|
||||
void nextTick(() => {
|
||||
if (itemInputRef.value?.$el) {
|
||||
itemInputRef.value.$el.focus()
|
||||
itemInputRef.value.$el.select()
|
||||
if (wrapperRef.value) {
|
||||
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const inputBlur = async (doRename: boolean) => {
|
||||
if (doRename) {
|
||||
await rename(itemLabel.value, props.item.label as string)
|
||||
}
|
||||
|
||||
isEditing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.p-breadcrumb-item-link,
|
||||
.p-breadcrumb-item-icon {
|
||||
@apply select-none;
|
||||
}
|
||||
|
||||
.p-breadcrumb-item-link {
|
||||
@apply overflow-hidden;
|
||||
padding: var(--p-breadcrumb-item-padding);
|
||||
}
|
||||
|
||||
.p-breadcrumb-item-label {
|
||||
@apply whitespace-nowrap text-ellipsis overflow-hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -20,35 +21,24 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
|
||||
const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()])
|
||||
const widgetStates = computed(() => domWidgetStore.activeWidgetStates)
|
||||
|
||||
const updateWidgets = () => {
|
||||
const lgCanvas = canvasStore.canvas
|
||||
if (!lgCanvas) return
|
||||
|
||||
const lowQuality = lgCanvas.low_quality
|
||||
const currentGraph = lgCanvas.graph
|
||||
|
||||
for (const widgetState of widgetStates.value) {
|
||||
const widget = widgetState.widget
|
||||
const node = widget.node as LGraphNode
|
||||
|
||||
// Early exit for non-visible widgets
|
||||
if (!widget.isVisible()) {
|
||||
widgetState.visible = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if the widget's node is in the current graph
|
||||
const node = widget.node
|
||||
const isInCorrectGraph = currentGraph?.nodes.includes(node)
|
||||
|
||||
widgetState.visible =
|
||||
!!isInCorrectGraph &&
|
||||
const visible =
|
||||
lgCanvas.isNodeVisible(node) &&
|
||||
!(widget.options.hideOnZoom && lowQuality)
|
||||
!(widget.options.hideOnZoom && lowQuality) &&
|
||||
widget.isVisible()
|
||||
|
||||
if (widgetState.visible && node) {
|
||||
widgetState.visible = visible
|
||||
if (visible) {
|
||||
const margin = widget.margin
|
||||
widgetState.pos = [node.pos[0] + margin, node.pos[1] + margin + widget.y]
|
||||
widgetState.size = [
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<SecondRowWorkflowTabs
|
||||
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
|
||||
/>
|
||||
<SubgraphBreadcrumb />
|
||||
</div>
|
||||
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
|
||||
</template>
|
||||
@@ -49,6 +50,7 @@ import { computed, onMounted, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
@@ -70,6 +72,7 @@ import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
|
||||
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
|
||||
import { CORE_SETTINGS } from '@/constants/coreSettings'
|
||||
import { i18n, t } from '@/i18n'
|
||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
import { UnauthorizedError, api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
@@ -189,26 +192,22 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// Update the progress of executing nodes
|
||||
// Update the progress of the executing node
|
||||
watch(
|
||||
() =>
|
||||
[executionStore.nodeLocationProgressStates, canvasStore.canvas] as const,
|
||||
([nodeLocationProgressStates, canvas]) => {
|
||||
if (!canvas?.graph) return
|
||||
for (const node of canvas.graph.nodes) {
|
||||
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(node.id)
|
||||
const progressState = nodeLocationProgressStates[nodeLocatorId]
|
||||
if (progressState && progressState.state === 'running') {
|
||||
node.progress = progressState.value / progressState.max
|
||||
[
|
||||
executionStore.executingNodeId,
|
||||
executionStore.executingNodeProgress
|
||||
] satisfies [NodeId | null, number | null],
|
||||
([executingNodeId, executingNodeProgress]) => {
|
||||
for (const node of comfyApp.graph.nodes) {
|
||||
if (node.id == executingNodeId) {
|
||||
node.progress = executingNodeProgress ?? undefined
|
||||
} else {
|
||||
node.progress = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Force canvas redraw to ensure progress updates are visible
|
||||
canvas.graph.setDirtyCanvas(true, false)
|
||||
},
|
||||
{ deep: true }
|
||||
}
|
||||
)
|
||||
|
||||
// Update node slot errors
|
||||
|
||||
@@ -41,15 +41,7 @@ const previousCanvasDraggable = ref(true)
|
||||
|
||||
const onEdit = (newValue: string) => {
|
||||
if (titleEditorStore.titleEditorTarget && newValue.trim() !== '') {
|
||||
const trimmedTitle = newValue.trim()
|
||||
titleEditorStore.titleEditorTarget.title = trimmedTitle
|
||||
|
||||
// If this is a subgraph node, sync the runtime subgraph name for breadcrumb reactivity
|
||||
const target = titleEditorStore.titleEditorTarget
|
||||
if (target instanceof LGraphNode && target.isSubgraphNode?.()) {
|
||||
target.subgraph.name = trimmedTitle
|
||||
}
|
||||
|
||||
titleEditorStore.titleEditorTarget.title = newValue.trim()
|
||||
app.graph.setDirtyCanvas(true, true)
|
||||
}
|
||||
showInput.value = false
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementBounding, useEventListener } from '@vueuse/core'
|
||||
import { CSSProperties, computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { CSSProperties, computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
|
||||
import { useDomClipping } from '@/composables/element/useDomClipping'
|
||||
@@ -61,13 +61,10 @@ const updateDomClipping = () => {
|
||||
if (!lgCanvas || !widgetElement.value) return
|
||||
|
||||
const selectedNode = Object.values(lgCanvas.selected_nodes ?? {})[0]
|
||||
if (!selectedNode) {
|
||||
// Clear clipping when no node is selected
|
||||
updateClipPath(widgetElement.value, lgCanvas.canvas, false, undefined)
|
||||
return
|
||||
}
|
||||
if (!selectedNode) return
|
||||
|
||||
const isSelected = selectedNode === widget.node
|
||||
const node = widget.node
|
||||
const isSelected = selectedNode === node
|
||||
const renderArea = selectedNode?.renderArea
|
||||
const offset = lgCanvas.ds.offset
|
||||
const scale = lgCanvas.ds.scale
|
||||
@@ -125,10 +122,7 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// Set up event listeners only after the widget is mounted and visible
|
||||
const setupDOMEventListeners = () => {
|
||||
if (!isDOMWidget(widget) || !widgetState.visible) return
|
||||
|
||||
if (isDOMWidget(widget)) {
|
||||
if (widget.element.blur) {
|
||||
useEventListener(document, 'mousedown', (event) => {
|
||||
if (!widget.element.contains(event.target as HTMLElement)) {
|
||||
@@ -146,46 +140,14 @@ const setupDOMEventListeners = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners when widget becomes visible
|
||||
watch(
|
||||
() => widgetState.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
setupDOMEventListeners()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const inputSpec = widget.node.constructor.nodeData
|
||||
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
|
||||
|
||||
// Mount DOM element when widget is or becomes visible
|
||||
const mountElementIfVisible = () => {
|
||||
if (widgetState.visible && isDOMWidget(widget) && widgetElement.value) {
|
||||
// Only append if not already a child
|
||||
if (!widgetElement.value.contains(widget.element)) {
|
||||
widgetElement.value.appendChild(widget.element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check on mount - but only after next tick to ensure visibility is calculated
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
mountElementIfVisible()
|
||||
}).catch((error) => {
|
||||
console.error('Error mounting DOM widget element:', error)
|
||||
})
|
||||
})
|
||||
|
||||
// And watch for visibility changes
|
||||
watch(
|
||||
() => widgetState.visible,
|
||||
() => {
|
||||
mountElementIfVisible()
|
||||
if (isDOMWidget(widget) && widgetElement.value) {
|
||||
widgetElement.value.appendChild(widget.element)
|
||||
}
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -20,44 +20,33 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
widget?: object
|
||||
nodeId: NodeId
|
||||
}>()
|
||||
|
||||
const executionStore = useExecutionStore()
|
||||
const isParentNodeExecuting = ref(true)
|
||||
const formattedText = computed(() => nl2br(linkifyHtml(modelValue.value)))
|
||||
|
||||
let parentNodeId: NodeId | null = null
|
||||
let executingNodeId: NodeId | null = null
|
||||
onMounted(() => {
|
||||
// Get the parent node ID from props if provided
|
||||
// For backward compatibility, fall back to the first executing node
|
||||
parentNodeId = props.nodeId
|
||||
executingNodeId = executionStore.executingNodeId
|
||||
})
|
||||
|
||||
// Watch for either a new node has starting execution or overall execution ending
|
||||
const stopWatching = watch(
|
||||
[() => executionStore.executingNodeIds, () => executionStore.isIdle],
|
||||
[() => executionStore.executingNode, () => executionStore.isIdle],
|
||||
() => {
|
||||
if (executionStore.isIdle) {
|
||||
isParentNodeExecuting.value = false
|
||||
stopWatching()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if parent node is no longer in the executing nodes list
|
||||
if (
|
||||
parentNodeId &&
|
||||
!executionStore.executingNodeIds.includes(parentNodeId)
|
||||
executionStore.isIdle ||
|
||||
(executionStore.executingNode &&
|
||||
executionStore.executingNode.id !== executingNodeId)
|
||||
) {
|
||||
isParentNodeExecuting.value = false
|
||||
stopWatching()
|
||||
}
|
||||
|
||||
// Set parent node ID if not set yet
|
||||
if (!parentNodeId && executionStore.executingNodeIds.length > 0) {
|
||||
parentNodeId = executionStore.executingNodeIds[0]
|
||||
if (!executingNodeId) {
|
||||
executingNodeId = executionStore.executingNodeId
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</Teleport>
|
||||
|
||||
<!-- What's New Section -->
|
||||
<section v-if="showVersionUpdates" class="whats-new-section">
|
||||
<section class="whats-new-section">
|
||||
<h3 class="section-description">{{ $t('helpCenter.whatsNew') }}</h3>
|
||||
|
||||
<!-- Release Items -->
|
||||
@@ -126,7 +126,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import { type ReleaseNote } from '@/services/releaseService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useReleaseStore } from '@/stores/releaseStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||
|
||||
@@ -162,14 +161,13 @@ const TIME_UNITS = {
|
||||
const SUBMENU_CONFIG = {
|
||||
DELAY_MS: 100,
|
||||
OFFSET_PX: 8,
|
||||
Z_INDEX: 10001
|
||||
Z_INDEX: 1002
|
||||
} as const
|
||||
|
||||
// Composables
|
||||
const { t, locale } = useI18n()
|
||||
const releaseStore = useReleaseStore()
|
||||
const commandStore = useCommandStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
@@ -184,9 +182,6 @@ let hoverTimeout: number | null = null
|
||||
|
||||
// Computed
|
||||
const hasReleases = computed(() => releaseStore.releases.length > 0)
|
||||
const showVersionUpdates = computed(() =>
|
||||
settingStore.get('Comfy.Notification.ShowVersionUpdates')
|
||||
)
|
||||
|
||||
const moreMenuItem = computed(() =>
|
||||
menuItems.value.find((item) => item.key === 'more')
|
||||
|
||||
@@ -32,32 +32,28 @@
|
||||
|
||||
<div class="whats-new-popup" @click.stop>
|
||||
<!-- Close Button -->
|
||||
<button
|
||||
class="close-button"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="closePopup"
|
||||
>
|
||||
<button class="close-button" aria-label="Close" @click="closePopup">
|
||||
<div class="close-icon"></div>
|
||||
</button>
|
||||
|
||||
<!-- Release Content -->
|
||||
<div class="popup-content">
|
||||
<div class="content-text" v-html="formattedContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Section -->
|
||||
<div class="popup-actions">
|
||||
<a
|
||||
class="learn-more-link"
|
||||
:href="changelogUrl"
|
||||
target="_blank"
|
||||
rel="noopener,noreferrer"
|
||||
@click="closePopup"
|
||||
>
|
||||
{{ $t('whatsNewPopup.learnMore') }}
|
||||
</a>
|
||||
<!-- TODO: CTA button -->
|
||||
<!-- <button class="cta-button" @click="handleCTA">CTA</button> -->
|
||||
</div>
|
||||
<!-- Actions Section -->
|
||||
<div class="popup-actions">
|
||||
<a
|
||||
class="learn-more-link"
|
||||
:href="changelogUrl"
|
||||
target="_blank"
|
||||
rel="noopener,noreferrer"
|
||||
@click="closePopup"
|
||||
>
|
||||
{{ $t('whatsNewPopup.learnMore') }}
|
||||
</a>
|
||||
<!-- TODO: CTA button -->
|
||||
<!-- <button class="cta-button" @click="handleCTA">CTA</button> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,7 +68,7 @@ import type { ReleaseNote } from '@/services/releaseService'
|
||||
import { useReleaseStore } from '@/stores/releaseStore'
|
||||
import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
const { locale } = useI18n()
|
||||
const releaseStore = useReleaseStore()
|
||||
|
||||
// Local state for dismissed status
|
||||
@@ -105,12 +101,13 @@ const changelogUrl = computed(() => {
|
||||
// Format release content for display using marked
|
||||
const formattedContent = computed(() => {
|
||||
if (!latestRelease.value?.content) {
|
||||
return `<p>${t('whatsNewPopup.noReleaseNotes')}</p>`
|
||||
return '<p>No release notes available.</p>'
|
||||
}
|
||||
|
||||
try {
|
||||
// Use marked to parse markdown to HTML
|
||||
return marked(latestRelease.value.content, {
|
||||
breaks: true, // Convert line breaks to <br>
|
||||
gfm: true // Enable GitHub Flavored Markdown
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -202,10 +199,14 @@ defineExpose({
|
||||
}
|
||||
|
||||
.whats-new-popup {
|
||||
padding: 32px 32px 24px;
|
||||
background: #353535;
|
||||
border-radius: 12px;
|
||||
max-width: 400px;
|
||||
width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
outline: 1px solid #4e4e4e;
|
||||
outline-offset: -1px;
|
||||
box-shadow: 0px 8px 32px rgba(0, 0, 0, 0.3);
|
||||
@@ -216,11 +217,6 @@ defineExpose({
|
||||
.popup-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 32px 32px 24px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Close button */
|
||||
@@ -228,17 +224,17 @@ defineExpose({
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
width: 31px;
|
||||
height: 31px;
|
||||
padding: 6px 7px;
|
||||
background: #7c7c7c;
|
||||
border-radius: 16px;
|
||||
border-radius: 15.5px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transform: translate(30%, -30%);
|
||||
transform: translate(50%, -50%);
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transform 0.1s ease;
|
||||
@@ -251,7 +247,7 @@ defineExpose({
|
||||
|
||||
.close-button:active {
|
||||
background: #6a6a6a;
|
||||
transform: translate(30%, -30%) scale(0.95);
|
||||
transform: translate(50%, -50%) scale(0.95);
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
@@ -292,45 +288,73 @@ defineExpose({
|
||||
.content-text {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Style the markdown content */
|
||||
/* Title */
|
||||
.content-text :deep(*) {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.content-text :deep(h1) {
|
||||
font-size: 16px;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Version subtitle - targets the first p tag after h1 */
|
||||
.content-text :deep(h1 + p) {
|
||||
color: #c0c0c0;
|
||||
.content-text :deep(h2) {
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 16px 0 12px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.content-text :deep(h2:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content-text :deep(h3) {
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.8;
|
||||
font-weight: 600;
|
||||
margin: 12px 0 8px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.content-text :deep(h3:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content-text :deep(h4) {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 8px 0 6px 0;
|
||||
}
|
||||
|
||||
.content-text :deep(h4:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Regular paragraphs - short description */
|
||||
.content-text :deep(p) {
|
||||
margin-bottom: 16px;
|
||||
color: #e0e0e0;
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.content-text :deep(p:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content-text :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* List */
|
||||
.content-text :deep(ul),
|
||||
.content-text :deep(ol) {
|
||||
margin-bottom: 16px;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
margin: 0 0 12px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.content-text :deep(ul:first-child),
|
||||
@@ -343,63 +367,12 @@ defineExpose({
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* List items */
|
||||
.content-text :deep(li) {
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.content-text :deep(li:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Custom bullet points */
|
||||
.content-text :deep(li::before) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 10px;
|
||||
display: flex;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: 100px;
|
||||
background: #60a5fa;
|
||||
}
|
||||
|
||||
/* List item strong text */
|
||||
.content-text :deep(li strong) {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.content-text :deep(li p) {
|
||||
font-size: 12px;
|
||||
margin-bottom: 0;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
/* Code styling */
|
||||
.content-text :deep(code) {
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
color: #f8f8f2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Remove top margin for first media element */
|
||||
.content-text :deep(img:first-child),
|
||||
.content-text :deep(video:first-child),
|
||||
.content-text :deep(iframe:first-child) {
|
||||
margin-top: -32px; /* Align with the top edge of the popup content */
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Media elements */
|
||||
@@ -408,7 +381,8 @@ defineExpose({
|
||||
.content-text :deep(iframe) {
|
||||
width: calc(100% + 64px);
|
||||
height: auto;
|
||||
margin: 24px -32px;
|
||||
border-radius: 6px;
|
||||
margin: 12px -32px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -423,6 +397,7 @@ defineExpose({
|
||||
.learn-more-link {
|
||||
color: #60a5fa;
|
||||
font-size: 14px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 18.2px;
|
||||
text-decoration: none;
|
||||
@@ -442,6 +417,7 @@ defineExpose({
|
||||
border: none;
|
||||
color: #121212;
|
||||
font-size: 14px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
@update-up-direction="handleUpdateUpDirection"
|
||||
@update-material-mode="handleUpdateMaterialMode"
|
||||
@update-edge-threshold="handleUpdateEdgeThreshold"
|
||||
@upload-texture="handleUploadTexture"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
<div
|
||||
@@ -214,6 +215,30 @@ const handleBackgroundImageUpdate = async (file: File | null) => {
|
||||
node.properties['Background Image'] = backgroundImage.value
|
||||
}
|
||||
|
||||
const handleUploadTexture = async (file: File) => {
|
||||
if (!load3DSceneRef.value?.load3d) {
|
||||
useToastStore().addAlert(t('toastMessages.no3dScene'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
|
||||
|
||||
const subfolder = resourceFolder.trim()
|
||||
? `3d/${resourceFolder.trim()}`
|
||||
: '3d'
|
||||
|
||||
const texturePath = await Load3dUtils.uploadFile(file, subfolder)
|
||||
|
||||
await load3DSceneRef.value.load3d.applyTexture(texturePath)
|
||||
|
||||
node.properties['Texture'] = texturePath
|
||||
} catch (error) {
|
||||
console.error('Error applying texture:', error)
|
||||
useToastStore().addAlert(t('toastMessages.failedToApplyTexture'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateFOV = (value: number) => {
|
||||
fov.value = value
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
@update-up-direction="handleUpdateUpDirection"
|
||||
@update-material-mode="handleUpdateMaterialMode"
|
||||
@update-edge-threshold="handleUpdateEdgeThreshold"
|
||||
@upload-texture="handleUploadTexture"
|
||||
/>
|
||||
|
||||
<CameraControls
|
||||
@@ -182,6 +183,7 @@ const emit = defineEmits<{
|
||||
(e: 'updateMaterialMode', mode: MaterialMode): void
|
||||
(e: 'updateEdgeThreshold', value: number): void
|
||||
(e: 'exportModel', format: string): void
|
||||
(e: 'uploadTexture', file: File): void
|
||||
}>()
|
||||
|
||||
const backgroundColor = ref(props.backgroundColor)
|
||||
@@ -230,6 +232,10 @@ const handleUpdateEdgeThreshold = (value: number) => {
|
||||
emit('updateEdgeThreshold', value)
|
||||
}
|
||||
|
||||
const handleUploadTexture = (file: File) => {
|
||||
emit('uploadTexture', file)
|
||||
}
|
||||
|
||||
const handleUpdateLightIntensity = (value: number) => {
|
||||
emit('updateLightIntensity', value)
|
||||
}
|
||||
|
||||
@@ -69,6 +69,9 @@ const eventConfig = {
|
||||
exportLoadingEnd: () => {
|
||||
loadingOverlayRef.value?.endLoading()
|
||||
},
|
||||
textureLoadingStart: () =>
|
||||
loadingOverlayRef.value?.startLoading(t('load3d.applyingTexture')),
|
||||
textureLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
|
||||
recordingStatusChange: (value: boolean) =>
|
||||
emit('recordingStatusChange', value)
|
||||
} as const
|
||||
|
||||
@@ -59,6 +59,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
materialMode === 'original' &&
|
||||
!props.inputSpec.isAnimation &&
|
||||
!props.inputSpec.isPreview
|
||||
"
|
||||
class="relative show-texture-upload"
|
||||
>
|
||||
<Button class="p-button-rounded p-button-text" @click="openTextureUpload">
|
||||
<i
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.uploadTexture'),
|
||||
showDelay: 300
|
||||
}"
|
||||
class="pi pi-image text-white text-lg"
|
||||
/>
|
||||
<input
|
||||
ref="texturePickerRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
|
||||
@change="uploadTexture"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="materialMode === 'lineart'" class="relative show-edge-threshold">
|
||||
<Button
|
||||
class="p-button-rounded p-button-text"
|
||||
@@ -116,6 +142,7 @@ const emit = defineEmits<{
|
||||
(e: 'updateUpDirection', direction: UpDirection): void
|
||||
(e: 'updateMaterialMode', mode: MaterialMode): void
|
||||
(e: 'updateEdgeThreshold', value: number): void
|
||||
(e: 'uploadTexture', file: File): void
|
||||
}>()
|
||||
|
||||
const upDirection = ref(props.upDirection || 'original')
|
||||
@@ -124,6 +151,7 @@ const edgeThreshold = ref(props.edgeThreshold || 85)
|
||||
const showUpDirection = ref(false)
|
||||
const showMaterialMode = ref(false)
|
||||
const showEdgeThreshold = ref(false)
|
||||
const texturePickerRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const upDirections: UpDirection[] = [
|
||||
'original',
|
||||
@@ -219,6 +247,18 @@ const updateEdgeThreshold = () => {
|
||||
emit('updateEdgeThreshold', edgeThreshold.value)
|
||||
}
|
||||
|
||||
const openTextureUpload = () => {
|
||||
texturePickerRef.value?.click()
|
||||
}
|
||||
|
||||
const uploadTexture = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
|
||||
if (input.files && input.files[0]) {
|
||||
emit('uploadTexture', input.files[0])
|
||||
}
|
||||
}
|
||||
|
||||
const closeSceneSlider = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
/>
|
||||
<div class="side-tool-bar-end">
|
||||
<SidebarLogoutIcon v-if="userStore.isMultiUserServer" />
|
||||
<SidebarThemeToggleIcon />
|
||||
<SidebarHelpCenterIcon />
|
||||
<SidebarBottomPanelToggleButton />
|
||||
<SidebarSettingsToggleIcon />
|
||||
</div>
|
||||
</nav>
|
||||
</teleport>
|
||||
@@ -31,7 +32,6 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
@@ -41,6 +41,8 @@ import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
|
||||
import SidebarSettingsToggleIcon from './SidebarSettingsToggleIcon.vue'
|
||||
import SidebarThemeToggleIcon from './SidebarThemeToggleIcon.vue'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<SidebarIcon
|
||||
:tooltip="$t('menu.toggleBottomPanel')"
|
||||
:selected="bottomPanelStore.bottomPanelVisible"
|
||||
@click="bottomPanelStore.toggleBottomPanel"
|
||||
>
|
||||
<template #icon>
|
||||
<i-ph:terminal-bold />
|
||||
</template>
|
||||
</SidebarIcon>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
</script>
|
||||
@@ -46,7 +46,7 @@
|
||||
</Teleport>
|
||||
|
||||
<!-- Backdrop to close popup when clicking outside -->
|
||||
<Teleport to="body">
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-backdrop"
|
||||
@@ -101,14 +101,14 @@ onMounted(async () => {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
z-index: 999;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.help-center-popup {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
z-index: 10000;
|
||||
z-index: 1000;
|
||||
animation: slideInUp 0.2s ease-out;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -19,12 +19,10 @@
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<template #icon>
|
||||
<slot name="icon">
|
||||
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
|
||||
<i :class="icon + ' side-bar-button-icon'" />
|
||||
</OverlayBadge>
|
||||
<i v-else :class="icon + ' side-bar-button-icon'" />
|
||||
</slot>
|
||||
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
|
||||
<i :class="icon + ' side-bar-button-icon'" />
|
||||
</OverlayBadge>
|
||||
<i v-else :class="icon + ' side-bar-button-icon'" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
25
src/components/sidebar/SidebarSettingsToggleIcon.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<SidebarIcon
|
||||
icon="pi pi-cog"
|
||||
class="comfy-settings-btn"
|
||||
:tooltip="$t('g.settings')"
|
||||
@click="showSetting"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
|
||||
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const showSetting = () => {
|
||||
dialogStore.showDialog({
|
||||
key: 'global-settings',
|
||||
headerComponent: SettingDialogHeader,
|
||||
component: SettingDialogContent
|
||||
})
|
||||
}
|
||||
</script>
|
||||
29
src/components/sidebar/SidebarThemeToggleIcon.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<SidebarIcon
|
||||
:icon="icon"
|
||||
:tooltip="$t('sideToolbar.themeToggle')"
|
||||
class="comfy-vue-theme-toggle"
|
||||
@click="toggleTheme"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const icon = computed(() =>
|
||||
colorPaletteStore.completedActivePalette.light_theme
|
||||
? 'pi pi-sun'
|
||||
: 'pi pi-moon'
|
||||
)
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const toggleTheme = async () => {
|
||||
await commandStore.execute('Comfy.ToggleTheme')
|
||||
}
|
||||
</script>
|
||||
@@ -159,7 +159,7 @@ const toggleExpanded = () => {
|
||||
|
||||
const removeTask = async (task: TaskItemImpl) => {
|
||||
if (task.isRunning) {
|
||||
await api.interrupt(task.promptId)
|
||||
await api.interrupt()
|
||||
}
|
||||
await queueStore.delete(task)
|
||||
}
|
||||
|
||||
25
src/components/topbar/BottomPanelToggleButton.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="bottomPanelStore.bottomPanelTabs.length > 0"
|
||||
v-tooltip="{ value: $t('menu.toggleBottomPanel'), showDelay: 300 }"
|
||||
severity="secondary"
|
||||
text
|
||||
:aria-label="$t('menu.toggleBottomPanel')"
|
||||
@click="bottomPanelStore.toggleBottomPanel"
|
||||
>
|
||||
<template #icon>
|
||||
<i-material-symbols:dock-to-bottom
|
||||
v-if="bottomPanelStore.bottomPanelVisible"
|
||||
/>
|
||||
<i-material-symbols:dock-to-bottom-outline v-else />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
</script>
|
||||
@@ -1,116 +1,51 @@
|
||||
<template>
|
||||
<div
|
||||
class="comfyui-logo-wrapper p-1 flex justify-center items-center cursor-pointer rounded-md mr-2"
|
||||
:class="{
|
||||
'comfyui-logo-menu-visible': menuRef?.visible
|
||||
}"
|
||||
:style="{
|
||||
minWidth: isLargeSidebar ? '4rem' : 'auto'
|
||||
}"
|
||||
@click="menuRef?.toggle($event)"
|
||||
>
|
||||
<img
|
||||
src="/assets/images/comfy-logo-mono.svg"
|
||||
alt="ComfyUI Logo"
|
||||
class="comfyui-logo h-7"
|
||||
@contextmenu="showNativeSystemMenu"
|
||||
/>
|
||||
<i class="pi pi-angle-down ml-1 text-[10px]" />
|
||||
</div>
|
||||
<TieredMenu
|
||||
ref="menuRef"
|
||||
<Menubar
|
||||
:model="translatedItems"
|
||||
:popup="true"
|
||||
class="comfy-command-menu"
|
||||
:class="{
|
||||
'comfy-command-menu-top': isTopMenu
|
||||
class="top-menubar border-none p-0 bg-transparent"
|
||||
:pt="{
|
||||
rootList: 'gap-0 flex-nowrap w-auto',
|
||||
submenu: `dropdown-direction-${dropdownDirection}`,
|
||||
item: 'relative'
|
||||
}"
|
||||
@show="onMenuShow"
|
||||
>
|
||||
<template #item="{ item, props }">
|
||||
<div
|
||||
v-if="item.key === 'theme'"
|
||||
class="flex items-center gap-4 px-4 py-5"
|
||||
@click.stop.prevent
|
||||
>
|
||||
{{ item.label }}
|
||||
<SelectButton
|
||||
:options="[darkLabel, lightLabel]"
|
||||
:model-value="activeTheme"
|
||||
@click.stop.prevent
|
||||
@update:model-value="onThemeChange"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<i v-if="option === lightLabel" class="pi pi-sun" />
|
||||
<i v-if="option === darkLabel" class="pi pi-moon" />
|
||||
<span>{{ option }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
<template #item="{ item, props, root }">
|
||||
<a
|
||||
v-else
|
||||
class="p-menubar-item-link px-4 py-2"
|
||||
class="p-menubar-item-link"
|
||||
v-bind="props.action"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
>
|
||||
<span v-if="item.icon" class="p-menubar-item-icon" :class="item.icon" />
|
||||
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
|
||||
<span class="p-menubar-item-label">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item?.comfyCommand?.keybinding"
|
||||
class="ml-auto border border-surface rounded text-muted text-xs text-nowrap p-1 keybinding-tag"
|
||||
>
|
||||
{{ item.comfyCommand.keybinding.combo.toString() }}
|
||||
</span>
|
||||
<i v-if="item.items" class="ml-auto pi pi-angle-right" />
|
||||
<i v-if="!root && item.items" class="ml-auto pi pi-angle-right" />
|
||||
</a>
|
||||
</template>
|
||||
</TieredMenu>
|
||||
|
||||
<SubgraphBreadcrumb />
|
||||
</Menubar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Menubar from 'primevue/menubar'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import TieredMenu, {
|
||||
type TieredMenuMethods,
|
||||
type TieredMenuState
|
||||
} from 'primevue/tieredmenu'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
|
||||
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { showNativeSystemMenu } from '@/utils/envUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const menuItemsStore = useMenuItemStore()
|
||||
const commandStore = useCommandStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const aboutPanelStore = useAboutPanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const menuRef = ref<
|
||||
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
|
||||
>(null)
|
||||
const isLargeSidebar = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Size') !== 'small'
|
||||
const dropdownDirection = computed(() =>
|
||||
settingStore.get('Comfy.UseNewMenu') === 'Top' ? 'down' : 'up'
|
||||
)
|
||||
const isTopMenu = computed(() => settingStore.get('Comfy.UseNewMenu') === 'Top')
|
||||
|
||||
const menuItemsStore = useMenuItemStore()
|
||||
const { t } = useI18n()
|
||||
const translateMenuItem = (item: MenuItem): MenuItem => {
|
||||
const label = typeof item.label === 'function' ? item.label() : item.label
|
||||
const translatedLabel = label
|
||||
@@ -124,119 +59,9 @@ const translateMenuItem = (item: MenuItem): MenuItem => {
|
||||
}
|
||||
}
|
||||
|
||||
const showSettings = (defaultPanel?: string) => {
|
||||
dialogStore.showDialog({
|
||||
key: 'global-settings',
|
||||
headerComponent: SettingDialogHeader,
|
||||
component: SettingDialogContent,
|
||||
props: {
|
||||
defaultPanel
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Temporary duplicated from LoadWorkflowWarning.vue
|
||||
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
|
||||
// This allows us to conditionally show the Manager button only when the extension is available
|
||||
// TODO: Remove this check when Manager functionality is fully migrated into core
|
||||
const isManagerInstalled = computed(() => {
|
||||
return aboutPanelStore.badges.some(
|
||||
(badge) =>
|
||||
badge.label.includes('ComfyUI-Manager') ||
|
||||
badge.url.includes('ComfyUI-Manager')
|
||||
)
|
||||
})
|
||||
|
||||
const showManageExtensions = () => {
|
||||
if (isManagerInstalled.value) {
|
||||
useDialogService().showManagerDialog()
|
||||
} else {
|
||||
showSettings('extension')
|
||||
}
|
||||
}
|
||||
|
||||
const extraMenuItems: MenuItem[] = [
|
||||
{ separator: true },
|
||||
{
|
||||
key: 'theme',
|
||||
label: t('menu.theme')
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
key: 'manage-extensions',
|
||||
label: t('menu.manageExtensions'),
|
||||
icon: 'mdi mdi-puzzle-outline',
|
||||
command: showManageExtensions
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: t('g.settings'),
|
||||
icon: 'mdi mdi-cog-outline',
|
||||
command: () => showSettings()
|
||||
}
|
||||
]
|
||||
|
||||
const lightLabel = t('menu.light')
|
||||
const darkLabel = t('menu.dark')
|
||||
|
||||
const activeTheme = computed(() => {
|
||||
return colorPaletteStore.completedActivePalette.light_theme
|
||||
? lightLabel
|
||||
: darkLabel
|
||||
})
|
||||
|
||||
const onThemeChange = async () => {
|
||||
await commandStore.execute('Comfy.ToggleTheme')
|
||||
}
|
||||
|
||||
const translatedItems = computed(() => {
|
||||
const items = menuItemsStore.menuItems.map(translateMenuItem)
|
||||
let helpIndex = items.findIndex((item) => item.key === 'Help')
|
||||
let helpItem: MenuItem | undefined
|
||||
|
||||
if (helpIndex !== -1) {
|
||||
items[helpIndex].icon = 'mdi mdi-help-circle-outline'
|
||||
// If help is not the last item (i.e. we have extension commands), separate them
|
||||
const isLastItem = helpIndex !== items.length - 1
|
||||
helpItem = items.splice(
|
||||
helpIndex,
|
||||
1,
|
||||
...(isLastItem
|
||||
? [
|
||||
{
|
||||
separator: true
|
||||
}
|
||||
]
|
||||
: [])
|
||||
)[0]
|
||||
}
|
||||
helpIndex = items.length
|
||||
|
||||
items.splice(
|
||||
helpIndex,
|
||||
0,
|
||||
...extraMenuItems,
|
||||
...(helpItem
|
||||
? [
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
helpItem
|
||||
]
|
||||
: [])
|
||||
)
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const onMenuShow = () => {
|
||||
void nextTick(() => {
|
||||
// Force the menu to show submenus on hover
|
||||
if (menuRef.value) {
|
||||
menuRef.value.dirty = true
|
||||
}
|
||||
})
|
||||
}
|
||||
const translatedItems = computed(() =>
|
||||
menuItemsStore.menuItems.map(translateMenuItem)
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -249,20 +74,4 @@ const onMenuShow = () => {
|
||||
border-color: var(--p-content-border-color);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.comfyui-logo-menu-visible,
|
||||
.comfyui-logo-wrapper:hover {
|
||||
background-color: color-mix(in srgb, var(--fg-color) 10%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.comfy-command-menu ul {
|
||||
background-color: var(--comfy-menu-secondary-bg) !important;
|
||||
}
|
||||
|
||||
.comfy-command-menu-top .p-tieredmenu-submenu {
|
||||
left: calc(100% + 15px) !important;
|
||||
top: -4px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,66 +1,82 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-show="showTopMenu && workflowTabsPosition === 'Topbar'"
|
||||
class="w-full flex content-end z-[1001] h-[38px]"
|
||||
style="background: var(--border-color)"
|
||||
>
|
||||
<WorkflowTabs />
|
||||
<div
|
||||
v-show="showTopMenu"
|
||||
ref="topMenuRef"
|
||||
class="comfyui-menu flex items-center"
|
||||
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
|
||||
>
|
||||
<img
|
||||
src="/assets/images/comfy-logo-mono.svg"
|
||||
alt="ComfyUI Logo"
|
||||
class="comfyui-logo ml-2 app-drag h-6"
|
||||
/>
|
||||
<CommandMenubar />
|
||||
<div class="flex-grow min-w-0 app-drag h-full">
|
||||
<WorkflowTabs v-if="workflowTabsPosition === 'Topbar'" />
|
||||
</div>
|
||||
<div ref="menuRight" class="comfyui-menu-right flex-shrink-0" />
|
||||
<Actionbar />
|
||||
<CurrentUserButton class="flex-shrink-0" />
|
||||
<BottomPanelToggleButton class="flex-shrink-0" />
|
||||
<Button
|
||||
v-tooltip="{ value: $t('menu.hideMenu'), showDelay: 300 }"
|
||||
class="flex-shrink-0"
|
||||
icon="pi pi-bars"
|
||||
severity="secondary"
|
||||
text
|
||||
:aria-label="$t('menu.hideMenu')"
|
||||
@click="workspaceState.focusMode = true"
|
||||
@contextmenu="showNativeSystemMenu"
|
||||
/>
|
||||
<div
|
||||
v-show="showTopMenu"
|
||||
ref="topMenuRef"
|
||||
class="comfyui-menu flex items-center"
|
||||
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
|
||||
>
|
||||
<CommandMenubar />
|
||||
<div class="flex-grow min-w-0 app-drag h-full"></div>
|
||||
<div
|
||||
ref="menuRight"
|
||||
class="comfyui-menu-right flex-shrink-1 overflow-auto"
|
||||
/>
|
||||
<Actionbar />
|
||||
<CurrentUserButton class="flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
<!-- Virtual top menu for native window (drag handle) -->
|
||||
<div
|
||||
v-show="isNativeWindow() && !showTopMenu"
|
||||
class="fixed top-0 left-0 app-drag w-full h-[var(--comfy-topbar-height)]"
|
||||
v-show="menuSetting !== 'Bottom'"
|
||||
class="window-actions-spacer flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Virtual top menu for native window (drag handle) -->
|
||||
<div
|
||||
v-show="isNativeWindow() && !showTopMenu"
|
||||
class="fixed top-0 left-0 app-drag w-full h-[var(--comfy-topbar-height)]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventBus } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import { computed, onMounted, provide, ref } from 'vue'
|
||||
|
||||
import Actionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import BottomPanelToggleButton from '@/components/topbar/BottomPanelToggleButton.vue'
|
||||
import CommandMenubar from '@/components/topbar/CommandMenubar.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { electronAPI, isElectron, isNativeWindow } from '@/utils/envUtil'
|
||||
import {
|
||||
electronAPI,
|
||||
isElectron,
|
||||
isNativeWindow,
|
||||
showNativeSystemMenu
|
||||
} from '@/utils/envUtil'
|
||||
|
||||
const workspaceState = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const workflowTabsPosition = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
|
||||
)
|
||||
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
|
||||
const betaMenuEnabled = computed(() => menuSetting.value !== 'Disabled')
|
||||
const showTopMenu = computed(
|
||||
() => betaMenuEnabled.value && !workspaceState.focusMode
|
||||
)
|
||||
const workflowTabsPosition = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
|
||||
)
|
||||
|
||||
const menuRight = ref<HTMLDivElement | null>(null)
|
||||
// Menu-right holds legacy topbar elements attached by custom scripts
|
||||
onMounted(() => {
|
||||
if (menuRight.value) {
|
||||
app.menu.element.style.width = 'fit-content'
|
||||
menuRight.value.appendChild(app.menu.element)
|
||||
}
|
||||
})
|
||||
@@ -122,35 +138,4 @@ onMounted(() => {
|
||||
.dark-theme .comfyui-logo {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.comfyui-menu-button-hide {
|
||||
background-color: var(--comfy-menu-secondary-bg);
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.comfyui-menu-right::-webkit-scrollbar {
|
||||
max-height: 5px;
|
||||
}
|
||||
|
||||
.comfyui-menu-right:hover::-webkit-scrollbar {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.comfyui-menu-right::-webkit-scrollbar-track {
|
||||
background: color-mix(in srgb, var(--border-color) 60%, transparent);
|
||||
}
|
||||
|
||||
.comfyui-menu-right:hover::-webkit-scrollbar-track {
|
||||
background: color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
}
|
||||
|
||||
.comfyui-menu-right::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--fg-color) 30%, transparent);
|
||||
}
|
||||
|
||||
.comfyui-menu-right::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--fg-color) 80%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
<template>
|
||||
<div ref="workflowTabRef" class="flex p-2 gap-2 workflow-tab" v-bind="$attrs">
|
||||
<span
|
||||
v-tooltip.bottom="{
|
||||
value: workflowOption.workflow.key,
|
||||
class: 'workflow-tab-tooltip'
|
||||
}"
|
||||
class="workflow-label text-sm max-w-[150px] min-w-[30px] truncate inline-block"
|
||||
v-tooltip.bottom="workflowOption.workflow.key"
|
||||
class="workflow-label text-sm max-w-[150px] truncate inline-block"
|
||||
>
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
@@ -145,9 +142,3 @@ usePragmaticDroppable(tabGetter, {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.p-tooltip.workflow-tab-tooltip {
|
||||
z-index: 1200 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
class="workflow-tabs-container flex flex-row max-w-full h-full flex-auto overflow-hidden"
|
||||
:class="{ 'workflow-tabs-container-desktop': isDesktop }"
|
||||
>
|
||||
<Button
|
||||
v-if="showOverflowArrows"
|
||||
icon="pi pi-chevron-left"
|
||||
text
|
||||
severity="secondary"
|
||||
class="overflow-arrow overflow-arrow-left"
|
||||
:disabled="!leftArrowEnabled"
|
||||
@mousedown="whileMouseDown($event, () => scroll(-1))"
|
||||
/>
|
||||
<div class="workflow-tabs-container flex flex-row max-w-full h-full">
|
||||
<ScrollPanel
|
||||
ref="scrollPanelRef"
|
||||
class="overflow-hidden no-drag"
|
||||
@@ -39,18 +27,9 @@
|
||||
</template>
|
||||
</SelectButton>
|
||||
</ScrollPanel>
|
||||
<Button
|
||||
v-if="showOverflowArrows"
|
||||
icon="pi pi-chevron-right"
|
||||
text
|
||||
severity="secondary"
|
||||
class="overflow-arrow overflow-arrow-right"
|
||||
:disabled="!rightArrowEnabled"
|
||||
@mousedown="whileMouseDown($event, () => scroll(1))"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('sideToolbar.newBlankWorkflow'), showDelay: 300 }"
|
||||
class="new-blank-workflow-button flex-shrink-0 no-drag rounded-none"
|
||||
class="new-blank-workflow-button flex-shrink-0 no-drag"
|
||||
icon="pi pi-plus"
|
||||
text
|
||||
severity="secondary"
|
||||
@@ -58,32 +37,23 @@
|
||||
@click="() => commandStore.execute('Comfy.NewBlankWorkflow')"
|
||||
/>
|
||||
<ContextMenu ref="menu" :model="contextMenuItems" />
|
||||
<div
|
||||
v-if="menuSetting !== 'Bottom' && isDesktop"
|
||||
class="window-actions-spacer flex-shrink-0 app-drag"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useScroll } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { ComfyWorkflow, useWorkflowBookmarkStore } from '@/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
||||
|
||||
interface WorkflowOption {
|
||||
value: string
|
||||
@@ -97,19 +67,11 @@ const props = defineProps<{
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowBookmarkStore = useWorkflowBookmarkStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workflowService = useWorkflowService()
|
||||
|
||||
const workflowBookmarkStore = useWorkflowBookmarkStore()
|
||||
const rightClickedTab = ref<WorkflowOption | undefined>()
|
||||
const menu = ref()
|
||||
const scrollPanelRef = ref()
|
||||
const showOverflowArrows = ref(false)
|
||||
const leftArrowEnabled = ref(false)
|
||||
const rightArrowEnabled = ref(false)
|
||||
|
||||
const isDesktop = isElectron()
|
||||
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
|
||||
|
||||
const workflowToOption = (workflow: ComfyWorkflow): WorkflowOption => ({
|
||||
value: workflow.path,
|
||||
@@ -124,7 +86,6 @@ const selectedWorkflow = computed<WorkflowOption | null>(() =>
|
||||
? workflowToOption(workflowStore.activeWorkflow as ComfyWorkflow)
|
||||
: null
|
||||
)
|
||||
|
||||
const onWorkflowChange = async (option: WorkflowOption) => {
|
||||
// Prevent unselecting the current workflow
|
||||
if (!option) {
|
||||
@@ -217,13 +178,6 @@ const handleWheel = (event: WheelEvent) => {
|
||||
})
|
||||
}
|
||||
|
||||
const scroll = (direction: number) => {
|
||||
const scrollElement = scrollPanelRef.value.$el.querySelector(
|
||||
'.p-scrollpanel-content'
|
||||
) as HTMLElement
|
||||
scrollElement.scrollBy({ left: direction * 20 })
|
||||
}
|
||||
|
||||
// Scroll to active offscreen tab when opened
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow,
|
||||
@@ -254,71 +208,12 @@ watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const scrollContent = computed(
|
||||
() =>
|
||||
scrollPanelRef.value?.$el.querySelector(
|
||||
'.p-scrollpanel-content'
|
||||
) as HTMLElement
|
||||
)
|
||||
let overflowObserver: ReturnType<typeof useOverflowObserver> | null = null
|
||||
let overflowWatch: ReturnType<typeof watch> | null = null
|
||||
watch(scrollContent, (value) => {
|
||||
const scrollState = useScroll(value)
|
||||
|
||||
watch(scrollState.arrivedState, () => {
|
||||
leftArrowEnabled.value = !scrollState.arrivedState.left
|
||||
rightArrowEnabled.value = !scrollState.arrivedState.right
|
||||
})
|
||||
|
||||
overflowObserver?.dispose()
|
||||
overflowWatch?.stop()
|
||||
overflowObserver = useOverflowObserver(value)
|
||||
overflowWatch = watch(
|
||||
overflowObserver.isOverflowing,
|
||||
(value) => {
|
||||
showOverflowArrows.value = value
|
||||
void nextTick(() => {
|
||||
// Force a new check after arrows are updated
|
||||
scrollState.measure()
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
})
|
||||
|
||||
onUpdated(() => {
|
||||
if (!overflowObserver?.disposed.value) {
|
||||
overflowObserver?.checkOverflow()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workflow-tabs-container {
|
||||
background-color: var(--comfy-menu-secondary-bg);
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton) {
|
||||
@apply p-0 bg-transparent rounded-none flex-shrink relative border-0 border-r border-solid;
|
||||
@apply p-0 bg-transparent rounded-none flex-shrink-0 relative border-0 border-r border-solid;
|
||||
border-right-color: var(--border-color);
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.overflow-arrow {
|
||||
@apply px-2 rounded-none;
|
||||
}
|
||||
|
||||
.overflow-arrow[disabled] {
|
||||
@apply opacity-25;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton > .p-togglebutton-content) {
|
||||
@apply max-w-full;
|
||||
}
|
||||
|
||||
:deep(.workflow-tab) {
|
||||
@apply max-w-full;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton::before) {
|
||||
@@ -360,10 +255,6 @@ onUpdated(() => {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
:deep(.workflow-tabs) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Scrollbar half opacity to avoid blocking the active tab bottom border */
|
||||
:deep(.p-scrollpanel:hover .p-scrollpanel-bar),
|
||||
:deep(.p-scrollpanel:active .p-scrollpanel-bar) {
|
||||
@@ -373,15 +264,4 @@ onUpdated(() => {
|
||||
:deep(.p-selectbutton) {
|
||||
@apply rounded-none h-full;
|
||||
}
|
||||
|
||||
.workflow-tabs-container-desktop {
|
||||
max-width: env(titlebar-area-width, 100vw);
|
||||
}
|
||||
|
||||
.window-actions-spacer {
|
||||
@apply flex-auto;
|
||||
/* If we are using custom titlebar, then we need to add a gap for the user to drag the window */
|
||||
--window-actions-spacer-width: min(75px, env(titlebar-area-width, 0) * 9999);
|
||||
min-width: var(--window-actions-spacer-width);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
|
||||
import { debounce } from 'lodash'
|
||||
import { readonly, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Observes an element for overflow changes and optionally debounces the check
|
||||
* @param element - The element to observe
|
||||
* @param options - The options for the observer
|
||||
* @param options.debounceTime - The time to debounce the check in milliseconds
|
||||
* @param options.useMutationObserver - Whether to use a mutation observer to check for overflow
|
||||
* @param options.useResizeObserver - Whether to use a resize observer to check for overflow
|
||||
* @returns An object containing the isOverflowing state and the checkOverflow function to manually trigger
|
||||
*/
|
||||
export const useOverflowObserver = (
|
||||
element: HTMLElement,
|
||||
options?: {
|
||||
debounceTime?: number
|
||||
useMutationObserver?: boolean
|
||||
useResizeObserver?: boolean
|
||||
onCheck?: (isOverflowing: boolean) => void
|
||||
}
|
||||
) => {
|
||||
options = {
|
||||
debounceTime: 25,
|
||||
useMutationObserver: true,
|
||||
useResizeObserver: true,
|
||||
...options
|
||||
}
|
||||
|
||||
const isOverflowing = ref(false)
|
||||
const disposeFns: (() => void)[] = []
|
||||
const disposed = ref(false)
|
||||
|
||||
const checkOverflowFn = () => {
|
||||
isOverflowing.value = element.scrollWidth > element.clientWidth
|
||||
options.onCheck?.(isOverflowing.value)
|
||||
}
|
||||
|
||||
const checkOverflow = options.debounceTime
|
||||
? debounce(checkOverflowFn, options.debounceTime)
|
||||
: checkOverflowFn
|
||||
|
||||
if (options.useMutationObserver) {
|
||||
disposeFns.push(
|
||||
useMutationObserver(element, checkOverflow, {
|
||||
subtree: true,
|
||||
childList: true
|
||||
}).stop
|
||||
)
|
||||
}
|
||||
if (options.useResizeObserver) {
|
||||
disposeFns.push(useResizeObserver(element, checkOverflow).stop)
|
||||
}
|
||||
|
||||
return {
|
||||
isOverflowing: readonly(isOverflowing),
|
||||
disposed: readonly(disposed),
|
||||
checkOverflow,
|
||||
dispose: () => {
|
||||
disposed.value = true
|
||||
disposeFns.forEach((fn) => fn())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
|
||||
const renderingSpeed = String(renderingSpeedWidget.value)
|
||||
if (renderingSpeed.toLowerCase().includes('quality')) {
|
||||
basePrice = 0.09
|
||||
basePrice = 0.08
|
||||
} else if (renderingSpeed.toLowerCase().includes('balanced')) {
|
||||
basePrice = 0.06
|
||||
} else if (renderingSpeed.toLowerCase().includes('turbo')) {
|
||||
@@ -322,15 +322,15 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
const effectScene = String(effectSceneWidget.value)
|
||||
if (
|
||||
effectScene.includes('fuzzyfuzzy') ||
|
||||
effectScene.includes('squish')
|
||||
effectScene.includes('squish') ||
|
||||
effectScene.includes('expansion')
|
||||
) {
|
||||
return '$0.28/Run'
|
||||
} else if (effectScene.includes('dizzydizzy')) {
|
||||
} else if (
|
||||
effectScene.includes('dizzydizzy') ||
|
||||
effectScene.includes('bloombloom')
|
||||
) {
|
||||
return '$0.49/Run'
|
||||
} else if (effectScene.includes('bloombloom')) {
|
||||
return '$0.49/Run'
|
||||
} else if (effectScene.includes('expansion')) {
|
||||
return '$0.28/Run'
|
||||
}
|
||||
|
||||
return '$0.28/Run'
|
||||
@@ -448,12 +448,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
} else if (model.includes('ray-2')) {
|
||||
if (duration.includes('5s')) {
|
||||
if (resolution.includes('4k')) return '$6.37/Run'
|
||||
if (resolution.includes('1080p')) return '$1.59/Run'
|
||||
if (resolution.includes('1080p')) return '$2.30/Run'
|
||||
if (resolution.includes('720p')) return '$0.71/Run'
|
||||
if (resolution.includes('540p')) return '$0.40/Run'
|
||||
} else if (duration.includes('9s')) {
|
||||
if (resolution.includes('4k')) return '$11.47/Run'
|
||||
if (resolution.includes('1080p')) return '$2.87/Run'
|
||||
if (resolution.includes('1080p')) return '$4.14/Run'
|
||||
if (resolution.includes('720p')) return '$1.28/Run'
|
||||
if (resolution.includes('540p')) return '$0.72/Run'
|
||||
}
|
||||
@@ -499,12 +499,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
} else if (model.includes('ray-2')) {
|
||||
if (duration.includes('5s')) {
|
||||
if (resolution.includes('4k')) return '$6.37/Run'
|
||||
if (resolution.includes('1080p')) return '$1.59/Run'
|
||||
if (resolution.includes('1080p')) return '$2.30/Run'
|
||||
if (resolution.includes('720p')) return '$0.71/Run'
|
||||
if (resolution.includes('540p')) return '$0.40/Run'
|
||||
} else if (duration.includes('9s')) {
|
||||
if (resolution.includes('4k')) return '$11.47/Run'
|
||||
if (resolution.includes('1080p')) return '$2.87/Run'
|
||||
if (resolution.includes('1080p')) return '$4.14/Run'
|
||||
if (resolution.includes('720p')) return '$1.28/Run'
|
||||
if (resolution.includes('540p')) return '$0.72/Run'
|
||||
}
|
||||
@@ -1004,194 +1004,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
|
||||
return '$2.25/Run'
|
||||
}
|
||||
},
|
||||
// Runway nodes - using actual node names from ComfyUI
|
||||
RunwayTextToImageNode: {
|
||||
displayPrice: '$0.08/Run'
|
||||
},
|
||||
RunwayImageToVideoNodeGen3a: {
|
||||
displayPrice: (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) || 5
|
||||
const cost = (0.05 * duration).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
},
|
||||
RunwayImageToVideoNodeGen4: {
|
||||
displayPrice: (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) || 5
|
||||
const cost = (0.05 * duration).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
},
|
||||
RunwayFirstLastFrameNode: {
|
||||
displayPrice: (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) || 5
|
||||
const cost = (0.05 * duration).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
},
|
||||
// Rodin nodes - all have the same pricing structure
|
||||
Rodin3D_Regular: {
|
||||
displayPrice: '$0.4/Run'
|
||||
},
|
||||
Rodin3D_Detail: {
|
||||
displayPrice: '$1.2/Run'
|
||||
},
|
||||
Rodin3D_Smooth: {
|
||||
displayPrice: '$1.2/Run'
|
||||
},
|
||||
Rodin3D_Sketch: {
|
||||
displayPrice: '$0.4/Run'
|
||||
},
|
||||
// Tripo nodes - using actual node names from ComfyUI
|
||||
TripoTextToModelNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model' || w.name === 'model_version'
|
||||
) as IComboWidget
|
||||
const textureQualityWidget = node.widgets?.find(
|
||||
(w) => w.name === 'texture_quality'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget)
|
||||
return '$0.2-0.3/Run (varies with model & texture quality)'
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
const textureQuality = String(textureQualityWidget?.value || 'standard')
|
||||
|
||||
// V2.5 pricing
|
||||
if (model.includes('v2.5') || model.includes('2.5')) {
|
||||
return textureQuality.includes('detailed') ? '$0.3/Run' : '$0.2/Run'
|
||||
}
|
||||
// V2.0 pricing
|
||||
else if (model.includes('v2.0') || model.includes('2.0')) {
|
||||
return textureQuality.includes('detailed') ? '$0.3/Run' : '$0.2/Run'
|
||||
}
|
||||
// V1.4 or legacy pricing
|
||||
else {
|
||||
return '$0.2/Run'
|
||||
}
|
||||
}
|
||||
},
|
||||
TripoImageToModelNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model' || w.name === 'model_version'
|
||||
) as IComboWidget
|
||||
const textureQualityWidget = node.widgets?.find(
|
||||
(w) => w.name === 'texture_quality'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget)
|
||||
return '$0.3-0.4/Run (varies with model & texture quality)'
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
const textureQuality = String(textureQualityWidget?.value || 'standard')
|
||||
|
||||
// V2.5 and V2.0 have same pricing structure
|
||||
if (
|
||||
model.includes('v2.5') ||
|
||||
model.includes('2.5') ||
|
||||
model.includes('v2.0') ||
|
||||
model.includes('2.0')
|
||||
) {
|
||||
return textureQuality.includes('detailed') ? '$0.4/Run' : '$0.3/Run'
|
||||
}
|
||||
// V1.4 or legacy pricing (image_to_model is always $0.3)
|
||||
else {
|
||||
return '$0.3/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'
|
||||
}
|
||||
},
|
||||
// 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.0035/$0.0008 per 1K tokens'
|
||||
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
|
||||
return '$0.0015/$0.0004 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1263,19 +1075,7 @@ export const useNodePricing = () => {
|
||||
RecraftGenerateVectorImageNode: ['n'],
|
||||
MoonvalleyTxt2VideoNode: ['length'],
|
||||
MoonvalleyImg2VideoNode: ['length'],
|
||||
MoonvalleyVideo2VideoNode: ['length'],
|
||||
// Runway nodes
|
||||
RunwayImageToVideoNodeGen3a: ['duration'],
|
||||
RunwayImageToVideoNodeGen4: ['duration'],
|
||||
RunwayFirstLastFrameNode: ['duration'],
|
||||
// Tripo nodes
|
||||
TripoTextToModelNode: ['model', 'model_version', 'texture_quality'],
|
||||
TripoImageToModelNode: ['model', 'model_version', 'texture_quality'],
|
||||
TripoTextureNode: ['texture_quality'],
|
||||
// Google/Gemini nodes
|
||||
GeminiNode: ['model'],
|
||||
// OpenAI nodes
|
||||
OpenAIChatNode: ['model']
|
||||
MoonvalleyVideo2VideoNode: ['length']
|
||||
}
|
||||
return widgetMap[nodeType] || []
|
||||
}
|
||||
|
||||
@@ -53,11 +53,6 @@ export function useSettingSearch() {
|
||||
const queryLower = query.toLocaleLowerCase()
|
||||
const allSettings = Object.values(settingStore.settingsById)
|
||||
const filteredSettings = allSettings.filter((setting) => {
|
||||
// Filter out hidden and deprecated settings, just like in normal settings tree
|
||||
if (setting.type === 'hidden' || setting.deprecated) {
|
||||
return false
|
||||
}
|
||||
|
||||
const idLower = setting.id.toLowerCase()
|
||||
const nameLower = setting.name.toLowerCase()
|
||||
const translatedName = st(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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'
|
||||
@@ -37,34 +36,11 @@ export const useBrowserTabTitle = () => {
|
||||
: DEFAULT_TITLE
|
||||
})
|
||||
|
||||
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 nodeExecutionTitle = computed(() =>
|
||||
executionStore.executingNode && executionStore.executingNodeProgress
|
||||
? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}`
|
||||
: ''
|
||||
)
|
||||
|
||||
const workflowTitle = computed(
|
||||
() =>
|
||||
|
||||
@@ -19,7 +19,6 @@ import { useDialogService } from '@/services/dialogService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
|
||||
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
@@ -40,7 +39,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
const toastStore = useToastStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
|
||||
const getSelectedNodes = (): LGraphNode[] => {
|
||||
@@ -171,11 +169,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
confirm('Clear workflow?')
|
||||
) {
|
||||
app.clean()
|
||||
if (app.canvas.subgraph) {
|
||||
app.canvas.subgraph.clear()
|
||||
} else {
|
||||
app.graph.clear()
|
||||
}
|
||||
app.graph.clear()
|
||||
api.dispatchCustomEvent('graphCleared')
|
||||
}
|
||||
}
|
||||
@@ -209,7 +203,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
icon: 'pi pi-stop',
|
||||
label: 'Interrupt',
|
||||
function: async () => {
|
||||
await api.interrupt(executionStore.activePromptId)
|
||||
await api.interrupt()
|
||||
toastStore.add({
|
||||
severity: 'info',
|
||||
summary: t('g.interrupted'),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
@@ -8,11 +9,12 @@ interface RefreshableItem {
|
||||
refresh: () => Promise<void> | void
|
||||
}
|
||||
|
||||
const isRefreshableWidget = (widget: unknown): widget is RefreshableItem =>
|
||||
widget != null &&
|
||||
typeof widget === 'object' &&
|
||||
'refresh' in widget &&
|
||||
typeof widget.refresh === 'function'
|
||||
type RefreshableWidget = IBaseWidget & RefreshableItem
|
||||
|
||||
const isRefreshableWidget = (
|
||||
widget: IBaseWidget
|
||||
): widget is RefreshableWidget =>
|
||||
'refresh' in widget && typeof widget.refresh === 'function'
|
||||
|
||||
/**
|
||||
* Tracks selected nodes and their refreshable widgets
|
||||
@@ -25,17 +27,10 @@ export const useRefreshableSelection = () => {
|
||||
selectedNodes.value = graphStore.selectedItems.filter(isLGraphNode)
|
||||
})
|
||||
|
||||
const refreshableWidgets = computed<RefreshableItem[]>(() =>
|
||||
selectedNodes.value.flatMap((node) => {
|
||||
if (!node.widgets) return []
|
||||
const items: RefreshableItem[] = []
|
||||
for (const widget of node.widgets) {
|
||||
if (isRefreshableWidget(widget)) {
|
||||
items.push(widget)
|
||||
}
|
||||
}
|
||||
return items
|
||||
})
|
||||
const refreshableWidgets = computed(() =>
|
||||
selectedNodes.value.flatMap(
|
||||
(node) => node.widgets?.filter(isRefreshableWidget) ?? []
|
||||
)
|
||||
)
|
||||
|
||||
const isRefreshable = computed(() => refreshableWidgets.value.length > 0)
|
||||
|
||||
@@ -3,23 +3,14 @@ import { ref } from 'vue'
|
||||
|
||||
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
ComponentWidgetImpl,
|
||||
type ComponentWidgetStandardProps,
|
||||
addWidget
|
||||
} from '@/scripts/domWidget'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
type ChatHistoryCustomProps = Omit<
|
||||
InstanceType<typeof ChatHistoryWidget>['$props'],
|
||||
ComponentWidgetStandardProps
|
||||
>
|
||||
|
||||
const PADDING = 16
|
||||
|
||||
export const useChatHistoryWidget = (
|
||||
options: {
|
||||
props?: ChatHistoryCustomProps
|
||||
props?: Omit<InstanceType<typeof ChatHistoryWidget>['$props'], 'widget'>
|
||||
} = {}
|
||||
) => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
@@ -29,7 +20,7 @@ export const useChatHistoryWidget = (
|
||||
const widgetValue = ref<string>('')
|
||||
const widget = new ComponentWidgetImpl<
|
||||
string | object,
|
||||
ChatHistoryCustomProps
|
||||
InstanceType<typeof ChatHistoryWidget>['$props']
|
||||
>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
|
||||
@@ -3,18 +3,9 @@ import { ref } from 'vue'
|
||||
|
||||
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
ComponentWidgetImpl,
|
||||
type ComponentWidgetStandardProps,
|
||||
addWidget
|
||||
} from '@/scripts/domWidget'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
type TextPreviewCustomProps = Omit<
|
||||
InstanceType<typeof TextPreviewWidget>['$props'],
|
||||
ComponentWidgetStandardProps
|
||||
>
|
||||
|
||||
const PADDING = 16
|
||||
|
||||
export const useTextPreviewWidget = (
|
||||
@@ -27,17 +18,11 @@ export const useTextPreviewWidget = (
|
||||
inputSpec: InputSpec
|
||||
) => {
|
||||
const widgetValue = ref<string>('')
|
||||
const widget = new ComponentWidgetImpl<
|
||||
string | object,
|
||||
TextPreviewCustomProps
|
||||
>({
|
||||
const widget = new ComponentWidgetImpl<string | object>({
|
||||
node,
|
||||
name: inputSpec.name,
|
||||
component: TextPreviewWidget,
|
||||
inputSpec,
|
||||
props: {
|
||||
nodeId: node.id
|
||||
},
|
||||
options: {
|
||||
getValue: () => widgetValue.value,
|
||||
setValue: (value: string | object) => {
|
||||
|
||||
@@ -8,8 +8,6 @@ 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,
|
||||
@@ -56,55 +54,38 @@ function addMultilineWidget(
|
||||
}
|
||||
})
|
||||
|
||||
/** Timer reference. `null` when the timer completes. */
|
||||
let ignoreEventsTimer: ReturnType<typeof setTimeout> | 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) => {
|
||||
const gesturesEnabled = useSettingStore().get(
|
||||
'LiteGraph.Pointer.TrackpadGestures'
|
||||
)
|
||||
const deltaX = event.deltaX
|
||||
const deltaY = event.deltaY
|
||||
if (!Object.is(event.deltaX, -0)) return
|
||||
|
||||
const canScrollY = inputEl.scrollHeight > inputEl.clientHeight
|
||||
const isHorizontal = Math.abs(deltaX) > Math.abs(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
|
||||
|
||||
// Prevent pinch zoom from zooming the page
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
app.canvas.processMouseWheel(event)
|
||||
return
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"supports_preview_metadata": true
|
||||
}
|
||||
@@ -35,10 +35,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
name: 'Action on link release (No modifier)',
|
||||
type: 'combo',
|
||||
options: Object.values(LinkReleaseTriggerAction),
|
||||
defaultValue: LinkReleaseTriggerAction.CONTEXT_MENU,
|
||||
defaultsByInstallVersion: {
|
||||
'1.24.1': LinkReleaseTriggerAction.SEARCH_BOX
|
||||
}
|
||||
defaultValue: LinkReleaseTriggerAction.CONTEXT_MENU
|
||||
},
|
||||
{
|
||||
id: 'Comfy.LinkRelease.ActionShift',
|
||||
@@ -46,10 +43,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
name: 'Action on link release (Shift)',
|
||||
type: 'combo',
|
||||
options: Object.values(LinkReleaseTriggerAction),
|
||||
defaultValue: LinkReleaseTriggerAction.SEARCH_BOX,
|
||||
defaultsByInstallVersion: {
|
||||
'1.24.1': LinkReleaseTriggerAction.CONTEXT_MENU
|
||||
}
|
||||
defaultValue: LinkReleaseTriggerAction.SEARCH_BOX
|
||||
},
|
||||
{
|
||||
id: 'Comfy.NodeSearchBoxImpl.NodePreview',
|
||||
@@ -296,7 +290,6 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
options: [
|
||||
{ value: 'en', text: 'English' },
|
||||
{ value: 'zh', text: '中文' },
|
||||
{ value: 'zh-TW', text: '繁體中文' },
|
||||
{ value: 'ru', text: 'Русский' },
|
||||
{ value: 'ja', text: '日本語' },
|
||||
{ value: 'ko', text: '한국어' },
|
||||
@@ -337,14 +330,6 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: true,
|
||||
versionAdded: '1.20.3'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Notification.ShowVersionUpdates',
|
||||
category: ['Comfy', 'Notification Preferences'],
|
||||
name: 'Show version updates',
|
||||
tooltip: 'Show updates for new models, and major new features.',
|
||||
type: 'boolean',
|
||||
defaultValue: true
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ConfirmClear',
|
||||
category: ['Comfy', 'Workflow', 'ConfirmClear'],
|
||||
@@ -446,8 +431,6 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
name: 'Use new menu',
|
||||
type: 'combo',
|
||||
options: ['Disabled', 'Top', 'Bottom'],
|
||||
tooltip:
|
||||
'Menu bar position. On mobile devices, the menu is always shown at the top.',
|
||||
migrateDeprecatedValue: (value: string) => {
|
||||
// Floating is now supported by dragging the docked actionbar off.
|
||||
if (value === 'Floating') {
|
||||
@@ -511,6 +494,15 @@ 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'],
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* All supported image formats that can contain workflow data
|
||||
*/
|
||||
export const IMAGE_WORKFLOW_FORMATS = {
|
||||
extensions: ['.png', '.webp', '.svg', '.avif'],
|
||||
mimeTypes: ['image/png', 'image/webp', 'image/svg+xml', 'image/avif']
|
||||
extensions: ['.png', '.webp', '.svg'],
|
||||
mimeTypes: ['image/png', 'image/webp', 'image/svg+xml']
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import {
|
||||
type ExecutableLGraphNode,
|
||||
type ExecutionId,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
SubgraphNode
|
||||
} from '@comfyorg/litegraph'
|
||||
import { type NodeId } from '@comfyorg/litegraph/dist/LGraphNode'
|
||||
import { LiteGraph } from '@comfyorg/litegraph'
|
||||
import { LGraphNode, type NodeId } from '@comfyorg/litegraph/dist/LGraphNode'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import {
|
||||
@@ -15,13 +9,10 @@ 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'
|
||||
import { ComfyExtension } from '@/types/comfy'
|
||||
import { ExecutableGroupNodeChildDTO } from '@/utils/executableGroupNodeChildDTO'
|
||||
import { GROUP } from '@/utils/executableGroupNodeDto'
|
||||
import { deserialiseAndCreate, serialise } from '@/utils/vintageClipboard'
|
||||
|
||||
import { api } from '../../scripts/api'
|
||||
@@ -35,6 +26,8 @@ type GroupNodeWorkflowData = {
|
||||
nodes: ComfyNode[]
|
||||
}
|
||||
|
||||
const GROUP = Symbol()
|
||||
|
||||
// v1 Prefix + Separator: workflow/
|
||||
// v2 Prefix + Separator: workflow> (ComfyUI_frontend v1.2.63)
|
||||
const PREFIX = 'workflow'
|
||||
@@ -820,7 +813,6 @@ export class GroupNodeHandler {
|
||||
innerNodeIndex++
|
||||
) {
|
||||
const innerNode = this.innerNodes[innerNodeIndex]
|
||||
innerNode.graph ??= this.node.graph
|
||||
|
||||
for (const w of innerNode.widgets ?? []) {
|
||||
if (w.type === 'converted-widget') {
|
||||
@@ -907,20 +899,7 @@ export class GroupNodeHandler {
|
||||
return link
|
||||
}
|
||||
|
||||
/** @internal Used to flatten the subgraph before execution. Recursive; call with no args. */
|
||||
this.node.getInnerNodes = (
|
||||
computedNodeDtos: Map<ExecutionId, ExecutableLGraphNode>,
|
||||
/** The path of subgraph node IDs. */
|
||||
subgraphNodePath: readonly NodeId[] = [],
|
||||
/** The list of nodes to add to. */
|
||||
nodes: ExecutableLGraphNode[] = [],
|
||||
/** The set of visited nodes. */
|
||||
visited = new Set<LGraphNode>()
|
||||
): ExecutableLGraphNode[] => {
|
||||
if (visited.has(this.node))
|
||||
throw new Error('RecursionError: while flattening subgraph')
|
||||
visited.add(this.node)
|
||||
|
||||
this.node.getInnerNodes = () => {
|
||||
if (!this.innerNodes) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.node.setInnerNodes(
|
||||
@@ -931,8 +910,6 @@ export class GroupNodeHandler {
|
||||
innerNode.configure(n)
|
||||
// @ts-expect-error fixme ts strict error
|
||||
innerNode.id = `${this.node.id}:${i}`
|
||||
// @ts-expect-error fixme ts strict error
|
||||
innerNode.graph = this.node.graph
|
||||
return innerNode
|
||||
})
|
||||
)
|
||||
@@ -940,31 +917,7 @@ export class GroupNodeHandler {
|
||||
|
||||
this.updateInnerWidgets()
|
||||
|
||||
const subgraphInstanceIdPath = [...subgraphNodePath, this.node.id]
|
||||
|
||||
// Assertion: Deprecated, does not matter.
|
||||
const subgraphNode = (this.node.graph?.getNodeById(
|
||||
subgraphNodePath.at(-1)
|
||||
) ?? undefined) as SubgraphNode | undefined
|
||||
|
||||
for (const node of this.innerNodes) {
|
||||
node.graph ??= this.node.graph
|
||||
|
||||
// Create minimal DTOs rather than cloning the node
|
||||
const currentId = String(node.id)
|
||||
node.id = currentId.split(':').at(-1)
|
||||
const aVeryRealNode = new ExecutableGroupNodeChildDTO(
|
||||
node,
|
||||
subgraphInstanceIdPath,
|
||||
computedNodeDtos,
|
||||
subgraphNode
|
||||
)
|
||||
node.id = currentId
|
||||
aVeryRealNode.groupNodeHandler = this
|
||||
|
||||
nodes.push(aVeryRealNode)
|
||||
}
|
||||
return nodes
|
||||
return this.innerNodes
|
||||
}
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
@@ -1225,10 +1178,9 @@ 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 (
|
||||
progressState &&
|
||||
progressState.state === 'running' &&
|
||||
// @ts-expect-error fixme ts strict error
|
||||
+app.runningNodeId === this.id &&
|
||||
this.runningInternalNodeId !== null
|
||||
) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
@@ -1342,7 +1294,6 @@ 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)
|
||||
}
|
||||
@@ -1552,9 +1503,6 @@ export class GroupNodeHandler {
|
||||
|
||||
this.linkOutputs(node, i)
|
||||
app.graph.remove(node)
|
||||
|
||||
// Set internal ID to what is expected after workflow is reloaded
|
||||
node.id = `${this.node.id}:${i}`
|
||||
}
|
||||
|
||||
this.linkInputs()
|
||||
@@ -1660,14 +1608,8 @@ async function convertSelectedNodesToGroupNode() {
|
||||
if (nodes.length === 1) {
|
||||
throw new Error('Please select multiple nodes to convert to group node')
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node instanceof SubgraphNode) {
|
||||
throw new Error('Selected nodes contain a subgraph node')
|
||||
}
|
||||
if (GroupNodeHandler.isGroupNode(node)) {
|
||||
throw new Error('Selected nodes contain a group node')
|
||||
}
|
||||
if (nodes.some((n) => GroupNodeHandler.isGroupNode(n))) {
|
||||
throw new Error('Selected nodes contain a group node')
|
||||
}
|
||||
return await GroupNodeHandler.fromNodes(nodes)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ async function handleModelUpload(files: FileList, node: any) {
|
||||
(w: any) => w.name === 'model_file'
|
||||
) as IStringWidget
|
||||
|
||||
node.properties['Texture'] = undefined
|
||||
|
||||
try {
|
||||
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
|
||||
|
||||
@@ -213,6 +215,8 @@ useExtensionService().registerExtension({
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
if (modelWidget) {
|
||||
modelWidget.value = ''
|
||||
|
||||
node.properties['Texture'] = undefined
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -160,6 +160,12 @@ class Load3DConfiguration {
|
||||
|
||||
this.load3d.setEdgeThreshold(edgeThreshold)
|
||||
|
||||
const texturePath = this.load3d.loadNodeProperty('Texture', null)
|
||||
|
||||
if (texturePath) {
|
||||
await this.load3d.applyTexture(texturePath)
|
||||
}
|
||||
|
||||
if (isFirstLoad && cameraState && typeof cameraState === 'object') {
|
||||
try {
|
||||
this.load3d.setCameraState(cameraState)
|
||||
|
||||
@@ -293,6 +293,23 @@ class Load3d {
|
||||
}
|
||||
}
|
||||
|
||||
async applyTexture(texturePath: string): Promise<void> {
|
||||
if (!this.modelManager.currentModel) {
|
||||
throw new Error('No model to apply texture to')
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('textureLoadingStart', null)
|
||||
|
||||
try {
|
||||
await this.modelManager.applyTexture(texturePath)
|
||||
} catch (error) {
|
||||
console.error('Error applying texture:', error)
|
||||
throw error
|
||||
} finally {
|
||||
this.eventManager.emitEvent('textureLoadingEnd', null)
|
||||
}
|
||||
}
|
||||
|
||||
setBackgroundColor(color: string): void {
|
||||
this.sceneManager.setBackgroundColor(color)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { LineSegmentsGeometry } from 'three/examples/jsm/lines/LineSegmentsGeome
|
||||
import { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils'
|
||||
|
||||
import Load3dUtils from './Load3dUtils'
|
||||
import { ColoredShadowMaterial } from './conditional-lines/ColoredShadowMaterial'
|
||||
import { ConditionalEdgesGeometry } from './conditional-lines/ConditionalEdgesGeometry'
|
||||
import { ConditionalEdgesShader } from './conditional-lines/ConditionalEdgesShader.js'
|
||||
@@ -134,6 +135,66 @@ export class ModelManager implements ModelManagerInterface {
|
||||
})
|
||||
}
|
||||
|
||||
async applyTexture(texturePath: string): Promise<void> {
|
||||
if (!this.currentModel) {
|
||||
throw new Error('No model available to apply texture to')
|
||||
}
|
||||
|
||||
if (this.appliedTexture) {
|
||||
this.appliedTexture.dispose()
|
||||
}
|
||||
|
||||
try {
|
||||
let imageUrl = Load3dUtils.getResourceURL(
|
||||
...Load3dUtils.splitFilePath(texturePath)
|
||||
)
|
||||
|
||||
if (!imageUrl.startsWith('/api')) {
|
||||
imageUrl = '/api' + imageUrl
|
||||
}
|
||||
|
||||
this.appliedTexture = await new Promise<THREE.Texture>(
|
||||
(resolve, reject) => {
|
||||
this.textureLoader.load(
|
||||
imageUrl,
|
||||
(texture) => {
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
texture.wrapS = THREE.RepeatWrapping
|
||||
texture.wrapT = THREE.RepeatWrapping
|
||||
resolve(texture)
|
||||
},
|
||||
undefined,
|
||||
(error) => reject(error)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
if (this.materialMode === 'original') {
|
||||
this.currentModel.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
map: this.appliedTexture,
|
||||
metalness: 0.1,
|
||||
roughness: 0.8,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
|
||||
if (!this.originalMaterials.has(child)) {
|
||||
this.originalMaterials.set(child, child.material)
|
||||
}
|
||||
|
||||
child.material = material
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve()
|
||||
} catch (error) {
|
||||
console.error('Error applying texture:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
disposeLineartModel(): void {
|
||||
this.disposeEdgesModel()
|
||||
this.disposeShadowModel()
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IStringWidget
|
||||
} from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder'
|
||||
import type { IStringWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
|
||||
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
|
||||
import { useNodePaste } from '@/composables/node/useNodePaste'
|
||||
@@ -13,10 +8,7 @@ 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'
|
||||
@@ -145,27 +137,14 @@ 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<NodeLocatorId, any>) {
|
||||
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
|
||||
onNodeOutputsUpdated(nodeOutputs: Record<number, any>) {
|
||||
for (const [nodeId, output] of Object.entries(nodeOutputs)) {
|
||||
const node = app.graph.getNodeById(nodeId)
|
||||
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'
|
||||
@@ -262,167 +241,3 @@ 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<HTMLAudioElement, string> =
|
||||
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<void> | 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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -24,10 +24,6 @@ import ruCommands from './locales/ru/commands.json'
|
||||
import ru from './locales/ru/main.json'
|
||||
import ruNodes from './locales/ru/nodeDefs.json'
|
||||
import ruSettings from './locales/ru/settings.json'
|
||||
import zhTWCommands from './locales/zh-TW/commands.json'
|
||||
import zhTW from './locales/zh-TW/main.json'
|
||||
import zhTWNodes from './locales/zh-TW/nodeDefs.json'
|
||||
import zhTWSettings from './locales/zh-TW/settings.json'
|
||||
import zhCommands from './locales/zh/commands.json'
|
||||
import zh from './locales/zh/main.json'
|
||||
import zhNodes from './locales/zh/nodeDefs.json'
|
||||
@@ -45,7 +41,6 @@ function buildLocale<M, N, C, S>(main: M, nodes: N, commands: C, settings: S) {
|
||||
const messages = {
|
||||
en: buildLocale(en, enNodes, enCommands, enSettings),
|
||||
zh: buildLocale(zh, zhNodes, zhCommands, zhSettings),
|
||||
'zh-TW': buildLocale(zhTW, zhTWNodes, zhTWCommands, zhTWSettings),
|
||||
ru: buildLocale(ru, ruNodes, ruCommands, ruSettings),
|
||||
ja: buildLocale(ja, jaNodes, jaCommands, jaSettings),
|
||||
ko: buildLocale(ko, koNodes, koCommands, koSettings),
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
# Contributing Translations to ComfyUI
|
||||
|
||||
## Quick Start for New Languages
|
||||
|
||||
1. **Let us know** - Open an issue or reach out on Discord to request a new language
|
||||
2. **Get technical setup help** - We'll help configure the initial files or you can follow the technical process below
|
||||
3. **Automatic translation** - Our CI system will generate translations using OpenAI when you create a PR
|
||||
4. **Review and refine** - You can improve the auto-generated translations and become a maintainer for that language
|
||||
|
||||
## Technical Process (Confirmed Working)
|
||||
|
||||
### Prerequisites
|
||||
- Node.js installed
|
||||
- Git/GitHub knowledge
|
||||
- OpenAI API key (optional - CI will handle translations)
|
||||
|
||||
### Step 1: Update Configuration Files
|
||||
|
||||
**Time required: ~10 minutes**
|
||||
|
||||
#### 1.1 Update `.i18nrc.cjs`
|
||||
Add your language code to the `outputLocales` array:
|
||||
|
||||
```javascript
|
||||
module.exports = defineConfig({
|
||||
// ... existing config
|
||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es'], // Add your language here
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
|
||||
'latent' is the short form of 'latent space'.
|
||||
'mask' is in the context of image processing.
|
||||
Note: For Traditional Chinese (Taiwan), use Taiwan-specific terminology and traditional characters.
|
||||
`
|
||||
});
|
||||
```
|
||||
|
||||
#### 1.2 Update `src/constants/coreSettings.ts`
|
||||
Add your language to the dropdown options:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'Comfy.Locale',
|
||||
name: 'Language',
|
||||
type: 'combo',
|
||||
options: [
|
||||
{ value: 'en', text: 'English' },
|
||||
{ value: 'zh', text: '中文' },
|
||||
{ value: 'zh-TW', text: '繁體中文 (台灣)' }, // Add your language here
|
||||
{ value: 'ru', text: 'Русский' },
|
||||
{ value: 'ja', text: '日本語' },
|
||||
{ value: 'ko', text: '한국어' },
|
||||
{ value: 'fr', text: 'Français' },
|
||||
{ value: 'es', text: 'Español' }
|
||||
],
|
||||
defaultValue: () => navigator.language.split('-')[0] || 'en'
|
||||
},
|
||||
```
|
||||
|
||||
#### 1.3 Update `src/i18n.ts`
|
||||
Add imports for your new language files:
|
||||
|
||||
```typescript
|
||||
// Add these imports (replace zh-TW with your language code)
|
||||
import zhTWCommands from './locales/zh-TW/commands.json'
|
||||
import zhTW from './locales/zh-TW/main.json'
|
||||
import zhTWNodes from './locales/zh-TW/nodeDefs.json'
|
||||
import zhTWSettings from './locales/zh-TW/settings.json'
|
||||
|
||||
// Add to the messages object
|
||||
const messages = {
|
||||
en: buildLocale(en, enNodes, enCommands, enSettings),
|
||||
zh: buildLocale(zh, zhNodes, zhCommands, zhSettings),
|
||||
'zh-TW': buildLocale(zhTW, zhTWNodes, zhTWCommands, zhTWSettings), // Add this line
|
||||
// ... other languages
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Generate Translation Files
|
||||
|
||||
#### Option A: Local Generation (Optional)
|
||||
```bash
|
||||
# Only if you have OpenAI API key configured
|
||||
npm run locale
|
||||
```
|
||||
|
||||
#### Option B: Let CI Handle It (Recommended)
|
||||
- Create your PR with the configuration changes above
|
||||
- Our GitHub CI will automatically generate translation files
|
||||
- Empty JSON files are fine - they'll be populated by the workflow
|
||||
|
||||
### Step 3: Test Your Changes
|
||||
|
||||
```bash
|
||||
npm run typecheck # Check for TypeScript errors
|
||||
npm run dev # Start development server
|
||||
```
|
||||
|
||||
**Testing checklist:**
|
||||
- [ ] Language appears in ComfyUI Settings > Locale dropdown
|
||||
- [ ] Can select the new language without errors
|
||||
- [ ] Partial translations display correctly
|
||||
- [ ] UI falls back to English for untranslated strings
|
||||
- [ ] No console errors when switching languages
|
||||
|
||||
### Step 4: Submit PR
|
||||
|
||||
1. **Create PR** with your configuration changes
|
||||
2. **CI will run** and automatically populate translation files
|
||||
3. **Request review** from language maintainers: @Yorha4D @KarryCharon @DorotaLuna @shinshin86
|
||||
4. **Get added to CODEOWNERS** as a reviewer for your language
|
||||
|
||||
## What Happens in CI
|
||||
|
||||
Our automated translation workflow:
|
||||
1. **Collects strings**: Scans the UI for translatable text
|
||||
2. **Updates English files**: Ensures all strings are captured
|
||||
3. **Generates translations**: Uses OpenAI API to translate to all configured languages
|
||||
4. **Commits back**: Automatically updates your PR with complete translations
|
||||
|
||||
## File Structure
|
||||
|
||||
Each language has 4 translation files:
|
||||
- `main.json` - Main UI text (~2000+ entries)
|
||||
- `commands.json` - Command descriptions (~200+ entries)
|
||||
- `settings.json` - Settings panel (~400+ entries)
|
||||
- `nodeDefs.json` - Node definitions (~varies based on installed nodes)
|
||||
|
||||
## Translation Quality
|
||||
|
||||
- **Auto-translations are high quality** but may need refinement
|
||||
- **Technical terms** are preserved (flux, photomaker, clip, vae, etc.)
|
||||
- **Context-aware** translations based on UI usage
|
||||
- **Native speaker review** is encouraged for quality improvements
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### Issue: TypeScript errors on imports
|
||||
**Solution**: Ensure your language code matches exactly in all three files
|
||||
|
||||
### Issue: Empty translation files
|
||||
**Solution**: This is normal - CI will populate them when you create a PR
|
||||
|
||||
### Issue: Language not appearing in dropdown
|
||||
**Solution**: Check that the language code in `coreSettings.ts` matches your other files exactly
|
||||
|
||||
### Issue: Rate limits during local translation
|
||||
**Solution**: This is expected - let CI handle the translation generation
|
||||
|
||||
## Regional Variants
|
||||
|
||||
For regional variants (like zh-TW for Taiwan), use:
|
||||
- **Language-region codes**: `zh-TW`, `pt-BR`, `en-US`
|
||||
- **Specific terminology**: Add region-specific context to the reference string
|
||||
- **Native display names**: Use the local language name in the dropdown
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Tag translation maintainers**: @Yorha4D @KarryCharon @DorotaLuna @shinshin86
|
||||
- **Check existing language PRs** for examples
|
||||
- **Open an issue** describing your language addition request
|
||||
- **Reference this tested process** - we've confirmed it works!
|
||||
|
||||
## Becoming a Language Maintainer
|
||||
|
||||
After your language is added:
|
||||
1. **Get added to CODEOWNERS** for your language files
|
||||
2. **Review future PRs** affecting your language
|
||||
3. **Coordinate with other native speakers** for quality improvements
|
||||
4. **Help maintain translations** as the UI evolves
|
||||
|
||||
---
|
||||
|
||||
*This process was tested and confirmed working with Traditional Chinese (Taiwan) addition.*
|
||||
@@ -14,17 +14,68 @@ Our project supports multiple languages using `vue-i18n`. This allows users arou
|
||||
|
||||
## How to Add a New Language
|
||||
|
||||
Want to add a new language to ComfyUI? See our detailed [Contributing Guide](./CONTRIBUTING.md) with step-by-step instructions and confirmed working process.
|
||||
We welcome the addition of new languages. You can add a new language by following these steps:
|
||||
|
||||
### Quick Start
|
||||
1. Open an issue or reach out on Discord to request a new language
|
||||
2. Follow the [technical process](./CONTRIBUTING.md#technical-process-confirmed-working) or ask for help
|
||||
3. Our CI will automatically generate translations using OpenAI
|
||||
4. Become a maintainer for your language
|
||||
### 1\. Generate language files
|
||||
|
||||
### File Structure
|
||||
Each language has 4 translation files in `src/locales/[language-code]/`:
|
||||
- `main.json` - Main UI text
|
||||
- `commands.json` - Command descriptions
|
||||
- `settings.json` - Settings panel
|
||||
- `nodeDefs.json` - Node definitions
|
||||
We use [lobe-i18n](https://github.com/lobehub/lobe-cli-toolbox/blob/master/packages/lobe-i18n/README.md) as our translation tool, which integrates with LLM for efficient localization.
|
||||
|
||||
Update the configuration file to include the new language(s) you wish to add:
|
||||
|
||||
```javascript
|
||||
const { defineConfig } = require('@lobehub/i18n-cli');
|
||||
|
||||
module.exports = defineConfig({
|
||||
entry: 'src/locales/en.json', // Base language file
|
||||
entryLocale: 'en',
|
||||
output: 'src/locales',
|
||||
outputLocales: ['zh', 'ru', 'ja', 'ko', 'fr', 'es'], // Add the new language(s) here
|
||||
});
|
||||
```
|
||||
|
||||
Set your OpenAI API Key by running the following command:
|
||||
|
||||
```sh
|
||||
npx lobe-i18n --option
|
||||
```
|
||||
|
||||
Once configured, generate the translation files with:
|
||||
|
||||
```sh
|
||||
npx lobe-i18n locale
|
||||
```
|
||||
|
||||
This will create the language files for the specified languages in the configuration.
|
||||
|
||||
### 2\. Update i18n Configuration
|
||||
|
||||
Import the newly generated locale file(s) in the `src/i18n.ts` file to include them in the application's i18n setup.
|
||||
|
||||
### 3\. Enable Selection of the New Language
|
||||
|
||||
Add the newly added language to the following item in `src/constants/coreSettings.ts`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'Comfy.Locale',
|
||||
name: 'Locale',
|
||||
type: 'combo',
|
||||
// Add the new language(s) here
|
||||
options: [
|
||||
{ value: 'en', text: 'English' },
|
||||
{ value: 'zh', text: '中文' },
|
||||
{ value: 'ru', text: 'Русский' },
|
||||
{ value: 'ja', text: '日本語' },
|
||||
{ value: 'ko', text: '한국어' },
|
||||
{ value: 'fr', text: 'Français' },
|
||||
{ value: 'es', text: 'Español' }
|
||||
],
|
||||
defaultValue: navigator.language.split('-')[0] || 'en'
|
||||
},
|
||||
```
|
||||
|
||||
This will make the new language selectable in the application's settings.
|
||||
|
||||
### 4\. Test the Translations
|
||||
|
||||
Start the development server, switch to the new language, and verify the translations. You can switch languages by opening the ComfyUI Settings and selecting from the `ComfyUI > Locale` dropdown box.
|
||||
|
||||
@@ -158,12 +158,6 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -133,12 +133,7 @@
|
||||
"copyURL": "Copy URL",
|
||||
"releaseTitle": "{package} {version} Release",
|
||||
"progressCountOf": "of",
|
||||
"keybindingAlreadyExists": "Keybinding already exists on",
|
||||
"startRecording": "Start Recording",
|
||||
"stopRecording": "Stop Recording",
|
||||
"micPermissionDenied": "Microphone permission denied",
|
||||
"noAudioRecorded": "No audio recorded",
|
||||
"nodesRunning": "nodes running"
|
||||
"keybindingAlreadyExists": "Keybinding already exists on"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Custom Nodes Manager",
|
||||
@@ -424,6 +419,7 @@
|
||||
"restart": "Restart"
|
||||
},
|
||||
"sideToolbar": {
|
||||
"themeToggle": "Toggle Theme",
|
||||
"helpCenter": "Help Center",
|
||||
"logout": "Logout",
|
||||
"queue": "Queue",
|
||||
@@ -524,13 +520,7 @@
|
||||
"clipspace": "Open Clipspace",
|
||||
"resetView": "Reset canvas view",
|
||||
"clear": "Clear workflow",
|
||||
"toggleBottomPanel": "Toggle Bottom Panel",
|
||||
"theme": "Theme",
|
||||
"dark": "Dark",
|
||||
"light": "Light",
|
||||
"manageExtensions": "Manage Extensions",
|
||||
"settings": "Settings",
|
||||
"help": "Help"
|
||||
"toggleBottomPanel": "Toggle Bottom Panel"
|
||||
},
|
||||
"tabMenu": {
|
||||
"duplicateTab": "Duplicate Tab",
|
||||
@@ -557,194 +547,182 @@
|
||||
"Audio": "Audio",
|
||||
"Image API": "Image API",
|
||||
"Video API": "Video API",
|
||||
"LLM API": "LLM API",
|
||||
"All": "All Templates"
|
||||
},
|
||||
"templateDescription": {
|
||||
"Basics": {
|
||||
"default": "Generate images from text prompts.",
|
||||
"default": "Generate images from text descriptions.",
|
||||
"image2image": "Transform existing images using text prompts.",
|
||||
"lora": "Generate images with LoRA models for specialized styles or subjects.",
|
||||
"lora_multiple": "Generate images by combining multiple LoRA models.",
|
||||
"lora": "Apply LoRA models for specialized styles or subjects.",
|
||||
"inpaint_example": "Edit specific parts of images seamlessly.",
|
||||
"inpaint_model_outpainting": "Extend images beyond their original boundaries.",
|
||||
"embedding_example": "Generate images using textual inversion for consistent styles.",
|
||||
"gligen_textbox_example": "Generate images with precise object placement using text boxes."
|
||||
"inpain_model_outpainting": "Extend images beyond their original boundaries.",
|
||||
"embedding_example": "Use textual inversion for consistent styles.",
|
||||
"gligen_textbox_example": "Specify the location and size of objects.",
|
||||
"lora_multiple": "Combine multiple LoRA models for unique results."
|
||||
},
|
||||
"Flux": {
|
||||
"flux_kontext_dev_basic": "Edit image using Flux Kontext with full node visibility, perfect for learning the workflow.",
|
||||
"flux_kontext_dev_grouped": "Streamlined version of Flux Kontext with grouped nodes for cleaner workspace.",
|
||||
"flux_dev_checkpoint_example": "Generate images using Flux Dev fp8 quantized version. Suitable for devices with limited VRAM, requires only one model file, but image quality is slightly lower than the full version.",
|
||||
"flux_schnell": "Quickly generate images with Flux Schnell fp8 quantized version. Ideal for low-end hardware, requires only 4 steps to generate images.",
|
||||
"flux_dev_full_text_to_image": "Generate high-quality images with Flux Dev full version. Requires larger VRAM and multiple model files, but provides the best prompt following capability and image quality.",
|
||||
"flux_schnell_full_text_to_image": "Generate images quickly with Flux Schnell full version. Uses Apache2.0 license, requires only 4 steps to generate images while maintaining good image quality.",
|
||||
"flux_fill_inpaint_example": "Fill missing parts of images using Flux inpainting.",
|
||||
"flux_fill_outpaint_example": "Extend images beyond boundaries using Flux outpainting.",
|
||||
"flux_canny_model_example": "Generate images guided by edge detection using Flux Canny.",
|
||||
"flux_depth_lora_example": "Generate images guided by depth information using Flux LoRA.",
|
||||
"flux_redux_model_example": "Generate images by transferring style from reference images using Flux Redux."
|
||||
"flux_dev_checkpoint_example": "Create images using Flux development models.",
|
||||
"flux_schnell": "Generate images quickly with Flux Schnell.",
|
||||
"flux_fill_inpaint_example": "Fill in missing parts of images.",
|
||||
"flux_fill_outpaint_example": "Extend images using Flux outpainting.",
|
||||
"flux_canny_model_example": "Generate images from edge detection.",
|
||||
"flux_depth_lora_example": "Create images with depth-aware LoRA.",
|
||||
"flux_redux_model_example": "Transfer style from a reference image to guide image generation with Flux."
|
||||
},
|
||||
"Image": {
|
||||
"image_omnigen2_t2i": "Generate high-quality images from text prompts using OmniGen2's unified 7B multimodal model with dual-path architecture.",
|
||||
"image_omnigen2_image_edit": "Edit images with natural language instructions using OmniGen2's advanced image editing capabilities and text rendering support.",
|
||||
"image_cosmos_predict2_2B_t2i": "Generate images with Cosmos-Predict2 2B T2I, delivering physically accurate, high-fidelity, and detail-rich image generation.",
|
||||
"image_chroma_text_to_image": "Chroma is modified from flux and has some changes in the architecture.",
|
||||
"hidream_i1_dev": "Generate images with HiDream I1 Dev - Balanced version with 28 inference steps, suitable for medium-range hardware.",
|
||||
"hidream_i1_fast": "Generate images quickly with HiDream I1 Fast - Lightweight version with 16 inference steps, ideal for rapid previews on lower-end hardware.",
|
||||
"hidream_i1_full": "Generate images with HiDream I1 Full - Complete version with 50 inference steps for highest quality output.",
|
||||
"hidream_e1_full": "Edit images with HiDream E1 - Professional natural language image editing model.",
|
||||
"sd3_5_simple_example": "Generate images using SD 3.5.",
|
||||
"sd3_5_large_canny_controlnet_example": "Generate images guided by edge detection using SD 3.5 Canny ControlNet.",
|
||||
"sd3_5_large_depth": "Generate images guided by depth information using SD 3.5.",
|
||||
"sd3_5_large_blur": "Generate images guided by blurred reference images using SD 3.5.",
|
||||
"sdxl_simple_example": "Generate high-quality images using SDXL.",
|
||||
"sdxl_refiner_prompt_example": "Enhance SDXL images using refiner models.",
|
||||
"sdxl_revision_text_prompts": "Generate images by transferring concepts from reference images using SDXL Revision.",
|
||||
"sdxl_revision_zero_positive": "Generate images using both text prompts and reference images with SDXL Revision.",
|
||||
"sdxlturbo_example": "Generate images in a single step using SDXL Turbo.",
|
||||
"image_lotus_depth_v1_1": "Run Lotus Depth in ComfyUI for zero-shot, efficient monocular depth estimation with high detail retention."
|
||||
"hidream_i1_dev": "Generate images with HiDream I1 Dev.",
|
||||
"hidream_i1_fast": "Generate images quickly with HiDream I1.",
|
||||
"hidream_i1_full": "Generate images with HiDream I1.",
|
||||
"hidream_e1_full": "Edit images with HiDream E1.",
|
||||
"sd3_5_simple_example": "Generate images with SD 3.5.",
|
||||
"sd3_5_large_canny_controlnet_example": "Use edge detection to guide image generation with SD 3.5.",
|
||||
"sd3_5_large_depth": "Create depth-aware images with SD 3.5.",
|
||||
"sd3_5_large_blur": "Generate images from blurred reference images with SD 3.5.",
|
||||
"sdxl_simple_example": "Create high-quality images with SDXL.",
|
||||
"sdxl_refiner_prompt_example": "Enhance SDXL outputs with refiners.",
|
||||
"sdxl_revision_text_prompts": "Transfer concepts from reference images to guide image generation with SDXL.",
|
||||
"sdxl_revision_zero_positive": "Add text prompts alongside reference images to guide image generation with SDXL.",
|
||||
"sdxlturbo_example": "Generate images in a single step with SDXL Turbo."
|
||||
},
|
||||
"Video": {
|
||||
"video_cosmos_predict2_2B_video2world_480p_16fps": "Generate videos with Cosmos-Predict2 2B Video2World, generating physically accurate, high-fidelity, and consistent video simulations.",
|
||||
"video_wan_vace_14B_t2v": "Transform text descriptions into high-quality videos. Supports both 480p and 720p with VACE-14B model.",
|
||||
"video_wan_vace_14B_ref2v": "Create videos that match the style and content of a reference image. Perfect for style-consistent video generation.",
|
||||
"video_wan_vace_14B_v2v": "Generate videos by controlling input videos and reference images using Wan VACE.",
|
||||
"video_wan_vace_outpainting": "Generate extended videos by expanding video size using Wan VACE outpainting.",
|
||||
"video_wan_vace_flf2v": "Generate smooth video transitions by defining start and end frames. Supports custom keyframe sequences.",
|
||||
"video_wan_vace_inpainting": "Edit specific regions in videos while preserving surrounding content. Great for object removal or replacement.",
|
||||
"video_wan2_1_fun_camera_v1_1_1_3B": "Generate dynamic videos with cinematic camera movements using Wan 2.1 Fun Camera 1.3B model.",
|
||||
"video_wan2_1_fun_camera_v1_1_14B": "Generate high-quality videos with advanced camera control using the full 14B model",
|
||||
"text_to_video_wan": "Generate videos from text prompts using Wan 2.1.",
|
||||
"image_to_video_wan": "Generate videos from images using Wan 2.1.",
|
||||
"wan2_1_fun_inp": "Generate videos from start and end frames using Wan 2.1 inpainting.",
|
||||
"wan2_1_fun_control": "Generate videos guided by pose, depth, and edge controls using Wan 2.1 ControlNet.",
|
||||
"wan2_1_flf2v_720_f16": "Generate videos by controlling first and last frames using Wan 2.1 FLF2V.",
|
||||
"ltxv_text_to_video": "Generate videos from text prompts.",
|
||||
"ltxv_image_to_video": "Generate videos from still images.",
|
||||
"mochi_text_to_video_example": "Generate videos from text prompts using Mochi model.",
|
||||
"hunyuan_video_text_to_video": "Generate videos from text prompts using Hunyuan model.",
|
||||
"image_to_video": "Generate videos from still images.",
|
||||
"txt_to_image_to_video": "Generate videos by first creating images from text prompts."
|
||||
"text_to_video_wan": "Quickly Generate videos from text descriptions.",
|
||||
"image_to_video_wan": "Quickly Generate videos from images.",
|
||||
"wan2_1_fun_inp": "Create videos from start and end frames.",
|
||||
"wan2_1_fun_control": "Guide video generation with pose, depth, edge controls and more.",
|
||||
"wan2_1_flf2v_720_f16": "Generate video through controlling the first and last frames.",
|
||||
"ltxv_text_to_video": "Generate videos from text descriptions.",
|
||||
"ltxv_image_to_video": "Convert still images into videos.",
|
||||
"mochi_text_to_video_example": "Create videos with Mochi model.",
|
||||
"hunyuan_video_text_to_video": "Generate videos using Hunyuan model.",
|
||||
"image_to_video": "Transform images into animated videos.",
|
||||
"txt_to_image_to_video": "Generate images from text and then convert them into videos."
|
||||
},
|
||||
"Image API": {
|
||||
"api_bfl_flux_1_kontext_multiple_images_input": "Input multiple images and edit them with Flux.1 Kontext.",
|
||||
"api_bfl_flux_1_kontext_pro_image": "Edit images with Flux.1 Kontext pro image.",
|
||||
"api_bfl_flux_1_kontext_max_image": "Edit images with Flux.1 Kontext max image.",
|
||||
"api_bfl_flux_pro_t2i": "Generate images with excellent prompt following and visual quality using FLUX.1 Pro.",
|
||||
"api_openai_image_1_t2i": "Use GPT Image 1 API to generate images from text descriptions.",
|
||||
"api_openai_image_1_i2i": "Use GPT Image 1 API to generate images from images.",
|
||||
"api_openai_image_1_inpaint": "Use GPT Image 1 API to inpaint images.",
|
||||
"api_openai_image_1_multi_inputs": "Use GPT Image 1 API with multiple inputs to generate images.",
|
||||
"api-openai-dall-e-2-t2i": "Use Dall-E 2 API to generate images from text descriptions.",
|
||||
"api-openai-dall-e-2-inpaint": "Use Dall-E 2 API to inpaint images.",
|
||||
"api-openai-dall-e-3-t2i": "Use Dall-E 3 API to generate images from text descriptions.",
|
||||
"api_bfl_flux_pro_t2i": "Create images with FLUX.1 [pro]'s excellent prompt following, visual quality, image detail and output diversity.",
|
||||
"api_stability_sd3_t2i": "Generate high quality images with excellent prompt adherence. Perfect for professional use cases at 1 megapixel resolution.",
|
||||
"api_ideogram_v3_t2i": "Generate images with high-quality image-prompt alignment, photorealism, and text rendering. Create professional-quality logos, promotional posters, landing page concepts, product photography, and more. Effortlessly craft sophisticated spatial compositions with intricate backgrounds, precise and nuanced lighting and colors, and lifelike environmental detail.",
|
||||
"api_luma_photon_i2i": "Guide image generation using a combination of images and prompt.",
|
||||
"api_luma_photon_style_ref": "Generate images by blending style references with precise control using Luma Photon.",
|
||||
"api_recraft_image_gen_with_color_control": "Generate images with custom color palettes and brand-specific visuals using Recraft.",
|
||||
"api_luma_photon_style_ref": "Apply and blend style references with exact control. Luma Photon captures the essence of each reference image, letting you combine distinct visual elements while maintaining professional quality.",
|
||||
"api_recraft_image_gen_with_color_control": "Create a custom palette to reuse for multiple images or hand-pick colors for each photo. Match your brand's color palette and craft visuals that are distinctly yours.",
|
||||
"api_recraft_image_gen_with_style_control": "Control style with visual examples, align positioning, and fine-tune objects. Store and share styles for perfect brand consistency.",
|
||||
"api_recraft_vector_gen": "Generate high-quality vector images from text prompts using Recraft's AI vector generator.",
|
||||
"api_runway_text_to_image": "Generate high-quality images from text prompts using Runway's AI model.",
|
||||
"api_runway_reference_to_image": "Generate new images based on reference styles and compositions with Runway's AI.",
|
||||
"api_stability_ai_stable_image_ultra_t2i": "Generate high quality images with excellent prompt adherence. Perfect for professional use cases at 1 megapixel resolution.",
|
||||
"api_stability_ai_i2i": "Transform images with high-quality generation using Stability AI, perfect for professional editing and style transfer.",
|
||||
"api_stability_ai_sd3_5_t2i": "Generate high quality images with excellent prompt adherence. Perfect for professional use cases at 1 megapixel resolution.",
|
||||
"api_stability_ai_sd3_5_i2i": "Generate high quality images with excellent prompt adherence. Perfect for professional use cases at 1 megapixel resolution.",
|
||||
"api_ideogram_v3_t2i": "Generate professional-quality images with excellent prompt alignment, photorealism, and text rendering using Ideogram V3.",
|
||||
"api_openai_image_1_t2i": "Generate images from text prompts using OpenAI GPT Image 1 API.",
|
||||
"api_openai_image_1_i2i": "Generate images from input images using OpenAI GPT Image 1 API.",
|
||||
"api_openai_image_1_inpaint": "Edit images using inpainting with OpenAI GPT Image 1 API.",
|
||||
"api_openai_image_1_multi_inputs": "Generate images from multiple inputs using OpenAI GPT Image 1 API.",
|
||||
"api_openai_dall_e_2_t2i": "Generate images from text prompts using OpenAI Dall-E 2 API.",
|
||||
"api_openai_dall_e_2_inpaint": "Edit images using inpainting with OpenAI Dall-E 2 API.",
|
||||
"api_openai_dall_e_3_t2i": "Generate images from text prompts using OpenAI Dall-E 3 API."
|
||||
"api_recraft_vector_gen": "Go from a text prompt to vector image with Recraft's AI vector generator. Produce the best-quality vector art for logos, posters, icon sets, ads, banners and mockups. Perfect your designs with sharp, high-quality SVG files. Create branded vector illustrations for your app or website in seconds."
|
||||
},
|
||||
"Video API": {
|
||||
"api_moonvalley_text_to_video": "Generate cinematic, 1080p videos from text prompts through a model trained exclusively on licensed data.",
|
||||
"api_moonvalley_image_to_video": "Generate cinematic, 1080p videos with an image through a model trained exclusively on licensed data.",
|
||||
"api_kling_i2v": "Generate videos with excellent prompt adherence for actions, expressions, and camera movements using Kling.",
|
||||
"api_kling_effects": "Generate dynamic videos by applying visual effects to images using Kling.",
|
||||
"api_kling_flf": "Generate videos through controlling the first and last frames.",
|
||||
"api_luma_i2v": "Take static images and instantly create magical high quality animations.",
|
||||
"api_luma_t2v": "High-quality videos can be generated using simple prompts.",
|
||||
"api_hailuo_minimax_t2v": "Generate high-quality videos directly from text prompts. Explore MiniMax's advanced AI capabilities to create diverse visual narratives with professional CGI effects and stylistic elements to bring your descriptions to life.",
|
||||
"api_hailuo_minimax_i2v": "Generate refined videos from images and text with CGI integration using MiniMax.",
|
||||
"api_pixverse_i2v": "Generate dynamic videos from static images with motion and effects using PixVerse.",
|
||||
"api_pixverse_template_i2v": "Generate dynamic videos from static images with motion and effects using PixVerse.",
|
||||
"api_pixverse_t2v": "Generate videos with accurate prompt interpretation and stunning video dynamics.",
|
||||
"api_runway_gen3a_turbo_image_to_video": "Generate cinematic videos from static images using Runway Gen3a Turbo.",
|
||||
"api_runway_gen4_turo_image_to_video": "Generate dynamic videos from images using Runway Gen4 Turbo.",
|
||||
"api_runway_first_last_frame": "Generate smooth video transitions between two keyframes with Runway's precision.",
|
||||
"api_pika_i2v": "Generate smooth animated videos from single static images using Pika AI.",
|
||||
"api_pika_scene": "Generate videos that incorporate multiple input images using Pika Scenes.",
|
||||
"api_veo2_i2v": "Generate videos from images using Google Veo2 API."
|
||||
},
|
||||
"3D API": {
|
||||
"api_rodin_image_to_model": "Generate detailed 3D models from single photos using Rodin AI.",
|
||||
"api_rodin_multiview_to_model": "Sculpt comprehensive 3D models using Rodin's multi-angle reconstruction.",
|
||||
"api_tripo_text_to_model": "Craft 3D objects from descriptions with Tripo's text-driven modeling.",
|
||||
"api_tripo_image_to_model": "Generate professional 3D assets from 2D images using Tripo engine.",
|
||||
"api_tripo_multiview_to_model": "Build 3D models from multiple angles with Tripo's advanced scanner."
|
||||
},
|
||||
"LLM API": {
|
||||
"api_openai_chat": "Engage with OpenAI's advanced language models for intelligent conversations.",
|
||||
"api_google_gemini": "Experience Google's multimodal AI with Gemini's reasoning capabilities."
|
||||
"api_kling_i2v": "Create videos with great prompt adherence for actions, expressions, and camera movements. Now supporting complex prompts with sequential actions for you to be the director of your scene.",
|
||||
"api_veo2_i2v": "Use Google Veo2 API to generate videos from images.",
|
||||
"api_hailuo_minimax_i2v": "Create refined videos from images and text, including CGI integration and trendy photo effects like viral AI hugging. Choose from a variety of video styles and themes to match your creative vision.",
|
||||
"api_pika_scene": "Use multiple images as ingredients and generate videos that incorporate all of them.",
|
||||
"api_pixverse_template_i2v": "Transforms static images into dynamic videos with motion and effects.",
|
||||
"api_pixverse_t2v": "Generate videos with accurate prompt interpretation and stunning video dynamics."
|
||||
},
|
||||
"Upscaling": {
|
||||
"hiresfix_latent_workflow": "Upscale images by enhancing quality in latent space.",
|
||||
"esrgan_example": "Upscale images using ESRGAN models to enhance quality.",
|
||||
"hiresfix_esrgan_workflow": "Upscale images using ESRGAN models during intermediate generation steps.",
|
||||
"latent_upscale_different_prompt_model": "Upscale images while changing prompts across generation passes."
|
||||
"hiresfix_latent_workflow": "Enhance image quality in latent space.",
|
||||
"esrgan_example": "Use upscale models to enhance image quality.",
|
||||
"hiresfix_esrgan_workflow": "Use upscale models during intermediate steps.",
|
||||
"latent_upscale_different_prompt_model": "Upscale and change prompt across passes."
|
||||
},
|
||||
"ControlNet": {
|
||||
"controlnet_example": "Generate images guided by scribble reference images using ControlNet.",
|
||||
"2_pass_pose_worship": "Generate images guided by pose references using ControlNet.",
|
||||
"depth_controlnet": "Generate images guided by depth information using ControlNet.",
|
||||
"depth_t2i_adapter": "Generate images guided by depth information using T2I adapter.",
|
||||
"mixing_controlnets": "Generate images by combining multiple ControlNet models."
|
||||
"controlnet_example": "Control image generation with reference images.",
|
||||
"2_pass_pose_worship": "Generate images from pose references.",
|
||||
"depth_controlnet": "Create images with depth-aware generation.",
|
||||
"depth_t2i_adapter": "Quickly generate depth-aware images with a T2I adapter.",
|
||||
"mixing_controlnets": "Combine multiple ControlNet models together."
|
||||
},
|
||||
"Area Composition": {
|
||||
"area_composition": "Generate images by controlling composition with defined areas.",
|
||||
"area_composition_square_area_for_subject": "Generate images with consistent subject placement using area composition."
|
||||
"area_composition": "Control image composition with areas.",
|
||||
"area_composition_reversed": "Reverse area composition workflow.",
|
||||
"area_composition_square_area_for_subject": "Create consistent subject placement."
|
||||
},
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Generate 3D models from single images using Hunyuan3D 2.0.",
|
||||
"3d_hunyuan3d_multiview_to_model": "Generate 3D models from multiple views using Hunyuan3D 2.0 MV.",
|
||||
"3d_hunyuan3d_multiview_to_model_turbo": "Generate 3D models from multiple views using Hunyuan3D 2.0 MV Turbo.",
|
||||
"stable_zero123_example": "Generate 3D views from single images using Stable Zero123."
|
||||
"hunyuan3d-non-multiview-train": "Use Hunyuan3D 2.0 to generate models from a single view.",
|
||||
"hunyuan-3d-multiview-elf": " Use Hunyuan3D 2mv to generate models from multiple views.",
|
||||
"hunyuan-3d-turbo": "Use Hunyuan3D 2mv turbo to generate models from multiple views.",
|
||||
"stable_zero123_example": "Generate 3D views from single images."
|
||||
},
|
||||
"Audio": {
|
||||
"audio_stable_audio_example": "Generate audio from text prompts using Stable Audio.",
|
||||
"audio_ace_step_1_t2a_instrumentals": "Generate instrumental music from text prompts using ACE-Step v1.",
|
||||
"audio_ace_step_1_t2a_song": "Generate songs with vocals from text prompts using ACE-Step v1, supporting multilingual and style customization.",
|
||||
"audio_ace_step_1_m2m_editing": "Edit existing songs to change style and lyrics using ACE-Step v1 M2M."
|
||||
"stable_audio_example": "Generate audio from text descriptions."
|
||||
}
|
||||
},
|
||||
"template": {
|
||||
"Basics": {
|
||||
"default": "Image Generation",
|
||||
"image2image": "Image to Image",
|
||||
"lora": "LoRA",
|
||||
"lora_multiple": "LoRA Multiple",
|
||||
"inpaint_example": "Inpaint",
|
||||
"inpaint_model_outpainting": "Outpaint",
|
||||
"embedding_example": "Embedding",
|
||||
"gligen_textbox_example": "Gligen Textbox"
|
||||
},
|
||||
"Flux": {
|
||||
"flux_kontext_dev_basic": "Flux Kontext Dev(Basic)",
|
||||
"flux_kontext_dev_grouped": "Flux Kontext Dev(Grouped)",
|
||||
"flux_dev_checkpoint_example": "Flux Dev fp8",
|
||||
"flux_schnell": "Flux Schnell fp8",
|
||||
"flux_dev_full_text_to_image": "Flux Dev full text to image",
|
||||
"flux_schnell_full_text_to_image": "Flux Schnell full text to image",
|
||||
"flux_dev_checkpoint_example": "Flux Dev",
|
||||
"flux_schnell": "Flux Schnell",
|
||||
"flux_fill_inpaint_example": "Flux Inpaint",
|
||||
"flux_fill_outpaint_example": "Flux Outpaint",
|
||||
"flux_canny_model_example": "Flux Canny Model",
|
||||
"flux_depth_lora_example": "Flux Depth LoRA",
|
||||
"flux_redux_model_example": "Flux Redux Model"
|
||||
"flux_redux_model_example": "Flux Redux Model",
|
||||
"flux_depth_lora_example": "Flux Depth Lora"
|
||||
},
|
||||
"Basics": {
|
||||
"default": "Image Generation",
|
||||
"image2image": "Image to Image",
|
||||
"embedding_example": "Embedding",
|
||||
"gligen_textbox_example": "Gligen Textbox",
|
||||
"lora": "Lora",
|
||||
"lora_multiple": "Lora Multiple",
|
||||
"inpaint_example": "Inpaint",
|
||||
"inpain_model_outpainting": "Outpaint"
|
||||
},
|
||||
"ControlNet": {
|
||||
"controlnet_example": "Scribble ControlNet",
|
||||
"2_pass_pose_worship": "Pose ControlNet 2 Pass",
|
||||
"depth_controlnet": "Depth ControlNet",
|
||||
"depth_t2i_adapter": "Depth T2I Adapter",
|
||||
"mixing_controlnets": "Mixing ControlNets"
|
||||
},
|
||||
"Upscaling": {
|
||||
"esrgan_example": "ESRGAN",
|
||||
"hiresfix_latent_workflow": "Upscale",
|
||||
"hiresfix_esrgan_workflow": "HiresFix ESRGAN Workflow",
|
||||
"latent_upscale_different_prompt_model": "Latent Upscale Different Prompt Model"
|
||||
},
|
||||
"Video": {
|
||||
"text_to_video_wan": "Wan 2.1 Text to Video",
|
||||
"image_to_video_wan": "Wan 2.1 Image to Video",
|
||||
"image_to_video": "SVD Image to Video",
|
||||
"txt_to_image_to_video": "SVD Text to Image to Video",
|
||||
"ltxv_image_to_video": "LTXV Image to Video",
|
||||
"ltxv_text_to_video": "LTXV Text to Video",
|
||||
"mochi_text_to_video_example": "Mochi Text to Video",
|
||||
"hunyuan_video_text_to_video": "Hunyuan Video Text to Video",
|
||||
"wan2_1_fun_inp": "Wan 2.1 Inpainting",
|
||||
"wan2_1_fun_control": "Wan 2.1 ControlNet"
|
||||
},
|
||||
"Image API": {
|
||||
"api_openai_image_1_t2i": "OpenAI Image-1 Text to Image",
|
||||
"api_openai_image_1_i2i": "OpenAI Image-1 Image to Image",
|
||||
"api_openai_image_1_inpaint": "OpenAI Image-1 Inpaint",
|
||||
"api_openai_image_1_multi_inputs": "OpenAI Image-1 Multi Inputs",
|
||||
"api-openai-dall-e-2-t2i": "Dall-E 2 Text to Image",
|
||||
"api-openai-dall-e-2-inpaint": "Dall-E 2 Inpaint",
|
||||
"api-openai-dall-e-3-t2i": "Dall-E 3 Text to Image",
|
||||
"api_bfl_flux_pro_t2i": "BFL Flux 1.1[pro] Ultra Text to Image",
|
||||
"api_stability_sd3_t2i": "Stability AI Stable Image Ultra Text to Image",
|
||||
"api_ideogram_v3_t2i": "Ideogram V3 Text to Image",
|
||||
"api_luma_photon_i2i": "Luma Photon Image to Image",
|
||||
"api_luma_photon_style_ref": "Luma Photon Style Reference",
|
||||
"api_recraft_image_gen_with_color_control": "Recraft Color Control Image Generation",
|
||||
"api_recraft_image_gen_with_style_control": "Recraft Style Control Image Generation",
|
||||
"api_recraft_vector_gen": "Recraft Vector Generation"
|
||||
},
|
||||
"Video API": {
|
||||
"api_luma_i2v": "Luma Image to Video",
|
||||
"api_kling_i2v": "Kling Image to Video",
|
||||
"api_veo2_i2v": "Veo2 Image to Video",
|
||||
"api_hailuo_minimax_i2v": "MiniMax Image to Video",
|
||||
"api_pika_scene": "Pika Scenes: Images to Video",
|
||||
"api_pixverse_template_i2v": "PixVerse Template Effects: Image to Video",
|
||||
"api_pixverse_t2v": "PixVerse Text to Video"
|
||||
},
|
||||
"Image": {
|
||||
"image_omnigen2_t2i": "OmniGen2 Text to Image",
|
||||
"image_omnigen2_image_edit": "OmniGen2 Image Edit",
|
||||
"image_cosmos_predict2_2B_t2i": "Cosmos Predict2 2B T2I",
|
||||
"image_chroma_text_to_image": "Chroma text to image",
|
||||
"hidream_i1_dev": "HiDream I1 Dev",
|
||||
"hidream_i1_fast": "HiDream I1 Fast",
|
||||
"hidream_i1_full": "HiDream I1 Full",
|
||||
"hidream_e1_full": "HiDream E1 Full",
|
||||
"sd3_5_simple_example": "SD3.5 Simple",
|
||||
"sd3_5_large_canny_controlnet_example": "SD3.5 Large Canny ControlNet",
|
||||
"sd3_5_large_depth": "SD3.5 Large Depth",
|
||||
@@ -754,114 +732,23 @@
|
||||
"sdxl_revision_text_prompts": "SDXL Revision Text Prompts",
|
||||
"sdxl_revision_zero_positive": "SDXL Revision Zero Positive",
|
||||
"sdxlturbo_example": "SDXL Turbo",
|
||||
"image_lotus_depth_v1_1": "Lotus Depth"
|
||||
},
|
||||
"Video": {
|
||||
"video_cosmos_predict2_2B_video2world_480p_16fps": "Cosmos Predict2 2B Video2World 480p 16fps",
|
||||
"video_wan_vace_14B_t2v": "Wan VACE Text to Video",
|
||||
"video_wan_vace_14B_ref2v": "Wan VACE Reference to Video",
|
||||
"video_wan_vace_14B_v2v": "Wan VACE Control Video",
|
||||
"video_wan_vace_outpainting": "Wan VACE Outpainting",
|
||||
"video_wan_vace_flf2v": "Wan VACE First-Last Frame",
|
||||
"video_wan_vace_inpainting": "Wan VACE Inpainting",
|
||||
"video_wan2_1_fun_camera_v1_1_1_3B": "Wan 2.1 Fun Camera 1.3B",
|
||||
"video_wan2_1_fun_camera_v1_1_14B": "Wan 2.1 Fun Camera 14B",
|
||||
"text_to_video_wan": "Wan 2.1 Text to Video",
|
||||
"image_to_video_wan": "Wan 2.1 Image to Video",
|
||||
"wan2_1_fun_inp": "Wan 2.1 Inpainting",
|
||||
"wan2_1_fun_control": "Wan 2.1 ControlNet",
|
||||
"wan2_1_flf2v_720_f16": "Wan 2.1 FLF2V 720p F16",
|
||||
"ltxv_text_to_video": "LTXV Text to Video",
|
||||
"ltxv_image_to_video": "LTXV Image to Video",
|
||||
"mochi_text_to_video_example": "Mochi Text to Video",
|
||||
"hunyuan_video_text_to_video": "Hunyuan Video Text to Video",
|
||||
"image_to_video": "SVD Image to Video",
|
||||
"txt_to_image_to_video": "SVD Text to Image to Video"
|
||||
},
|
||||
"Image API": {
|
||||
"api_bfl_flux_1_kontext_multiple_images_input": "BFL Flux.1 Kontext Multiple Image Input",
|
||||
"api_bfl_flux_1_kontext_pro_image": "BFL Flux.1 Kontext Pro",
|
||||
"api_bfl_flux_1_kontext_max_image": "BFL Flux.1 Kontext Max",
|
||||
"api_bfl_flux_pro_t2i": "BFL Flux[Pro]: Text to Image",
|
||||
"api_luma_photon_i2i": "Luma Photon: Image to Image",
|
||||
"api_luma_photon_style_ref": "Luma Photon: Style Reference",
|
||||
"api_recraft_image_gen_with_color_control": "Recraft: Color Control Image Generation",
|
||||
"api_recraft_image_gen_with_style_control": "Recraft: Style Control Image Generation",
|
||||
"api_recraft_vector_gen": "Recraft: Vector Generation",
|
||||
"api_runway_text_to_image": "Runway: Text to Image",
|
||||
"api_runway_reference_to_image": "Runway: Reference to Image",
|
||||
"api_stability_ai_stable_image_ultra_t2i": "Stability AI: Stable Image Ultra Text to Image",
|
||||
"api_stability_ai_i2i": "Stability AI: Image to Image",
|
||||
"api_stability_ai_sd3_5_t2i": "Stability AI: SD3.5 Text to Image",
|
||||
"api_stability_ai_sd3_5_i2i": "Stability AI: SD3.5 Image to Image",
|
||||
"api_ideogram_v3_t2i": "Ideogram V3: Text to Image",
|
||||
"api_openai_image_1_t2i": "OpenAI: GPT-Image-1 Text to Image",
|
||||
"api_openai_image_1_i2i": "OpenAI: GPT-Image-1 Image to Image",
|
||||
"api_openai_image_1_inpaint": "OpenAI: GPT-Image-1 Inpaint",
|
||||
"api_openai_image_1_multi_inputs": "OpenAI: GPT-Image-1 Multi Inputs",
|
||||
"api_openai_dall_e_2_t2i": "OpenAI: Dall-E 2 Text to Image",
|
||||
"api_openai_dall_e_2_inpaint": "OpenAI: Dall-E 2 Inpaint",
|
||||
"api_openai_dall_e_3_t2i": "OpenAI: Dall-E 3 Text to Image"
|
||||
},
|
||||
"Video API": {
|
||||
"api_moonvalley_text_to_video": "Moonvalley: Text to Video",
|
||||
"api_moonvalley_image_to_video": "Moonvalley: Image to Video",
|
||||
"api_kling_i2v": "Kling: Image to Video",
|
||||
"api_kling_effects": "Kling: Video Effects",
|
||||
"api_kling_flf": "Kling: FLF2V",
|
||||
"api_luma_i2v": "Luma: Image to Video",
|
||||
"api_luma_t2v": "Luma: Text to Video",
|
||||
"api_hailuo_minimax_t2v": "MiniMax: Text to Video",
|
||||
"api_hailuo_minimax_i2v": "MiniMax: Image to Video",
|
||||
"api_pixverse_i2v": "PixVerse: Image to Video",
|
||||
"api_pixverse_template_i2v": "PixVerse Templates: Image to Video",
|
||||
"api_pixverse_t2v": "PixVerse: Text to Video",
|
||||
"api_runway_gen3a_turbo_image_to_video": "Runway: Gen3a Turbo Image to Video",
|
||||
"api_runway_gen4_turo_image_to_video": "Runway: Gen4 Turbo Image to Video",
|
||||
"api_runway_first_last_frame": "Runway: First Last Frame to Video",
|
||||
"api_pika_i2v": "Pika: Image to Video",
|
||||
"api_pika_scene": "Pika Scenes: Images to Video",
|
||||
"api_veo2_i2v": "Veo2: Image to Video"
|
||||
},
|
||||
"3D API": {
|
||||
"api_rodin_image_to_model": "Rodin: Image to Model",
|
||||
"api_rodin_multiview_to_model": "Rodin: Multiview to Model",
|
||||
"api_tripo_text_to_model": "Tripo: Text to Model",
|
||||
"api_tripo_image_to_model": "Tripo: Image to Model",
|
||||
"api_tripo_multiview_to_model": "Tripo: Multiview to Model"
|
||||
},
|
||||
"LLM API": {
|
||||
"api_openai_chat": "OpenAI: Chat",
|
||||
"api_google_gemini": "Google Gemini: Chat"
|
||||
},
|
||||
"Upscaling": {
|
||||
"hiresfix_latent_workflow": "Upscale",
|
||||
"esrgan_example": "ESRGAN",
|
||||
"hiresfix_esrgan_workflow": "HiresFix ESRGAN Workflow",
|
||||
"latent_upscale_different_prompt_model": "Latent Upscale Different Prompt Model"
|
||||
},
|
||||
"ControlNet": {
|
||||
"controlnet_example": "Scribble ControlNet",
|
||||
"2_pass_pose_worship": "Pose ControlNet 2 Pass",
|
||||
"depth_controlnet": "Depth ControlNet",
|
||||
"depth_t2i_adapter": "Depth T2I Adapter",
|
||||
"mixing_controlnets": "Mixing ControlNets"
|
||||
"hidream_i1_dev": "HiDream I1 Dev",
|
||||
"hidream_i1_fast": "HiDream I1 Fast",
|
||||
"hidream_i1_full": "HiDream I1 Full"
|
||||
},
|
||||
"Area Composition": {
|
||||
"area_composition": "Area Composition",
|
||||
"area_composition_reversed": "Area Composition Reversed",
|
||||
"area_composition_square_area_for_subject": "Area Composition Square Area for Subject"
|
||||
},
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",
|
||||
"3d_hunyuan3d_multiview_to_model": "Hunyuan3D 2.0 MV",
|
||||
"3d_hunyuan3d_multiview_to_model_turbo": "Hunyuan3D 2.0 MV Turbo",
|
||||
"stable_zero123_example": "Stable Zero123"
|
||||
"stable_zero123_example": "Stable Zero123",
|
||||
"hunyuan3d-non-multiview-train": "Hunyuan3D 2.0",
|
||||
"hunyuan-3d-multiview-elf": "Hunyuan3D 2.0 MV",
|
||||
"hunyuan-3d-turbo": "Hunyuan3D 2.0 MV Turbo"
|
||||
},
|
||||
"Audio": {
|
||||
"audio_stable_audio_example": "Stable Audio",
|
||||
"audio_ace_step_1_t2a_instrumentals": "ACE-Step v1 Text to Instrumentals Music",
|
||||
"audio_ace_step_1_t2a_song": "ACE Step v1 Text to Song",
|
||||
"audio_ace_step_1_m2m_editing": "ACE Step v1 M2M Editing"
|
||||
"stable_audio_example": "Stable Audio"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -977,8 +864,6 @@
|
||||
"Load Default Workflow": "Load Default Workflow",
|
||||
"Toggle the Custom Nodes Manager": "Toggle the Custom Nodes Manager",
|
||||
"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",
|
||||
"New": "New",
|
||||
"Clipspace": "Clipspace",
|
||||
@@ -1064,8 +949,7 @@
|
||||
"Light": "Light",
|
||||
"User": "User",
|
||||
"Credits": "Credits",
|
||||
"API Nodes": "API Nodes",
|
||||
"Notification Preferences": "Notification Preferences"
|
||||
"API Nodes": "API Nodes"
|
||||
},
|
||||
"serverConfigItems": {
|
||||
"listen": {
|
||||
@@ -1604,13 +1488,6 @@
|
||||
"loadError": "Failed to load help: {error}"
|
||||
},
|
||||
"whatsNewPopup": {
|
||||
"learnMore": "Learn more",
|
||||
"noReleaseNotes": "No release notes available."
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"duplicate": "Duplicate",
|
||||
"clearWorkflow": "Clear Workflow",
|
||||
"deleteWorkflow": "Delete Workflow",
|
||||
"enterNewName": "Enter new name"
|
||||
"learnMore": "Learn more"
|
||||
}
|
||||
}
|
||||
@@ -259,10 +259,6 @@
|
||||
"name": "Number of nodes suggestions",
|
||||
"tooltip": "Only for litegraph searchbox/context menu"
|
||||
},
|
||||
"Comfy_Notification_ShowVersionUpdates": {
|
||||
"name": "Show version updates",
|
||||
"tooltip": "Show updates for new models, and major new features."
|
||||
},
|
||||
"Comfy_Pointer_ClickBufferTime": {
|
||||
"name": "Pointer click drift delay",
|
||||
"tooltip": "After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking."
|
||||
@@ -322,13 +318,16 @@
|
||||
},
|
||||
"Comfy_UseNewMenu": {
|
||||
"name": "Use new menu",
|
||||
"tooltip": "Menu bar position. On mobile devices, the menu is always shown at the top.",
|
||||
"options": {
|
||||
"Disabled": "Disabled",
|
||||
"Top": "Top",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -158,12 +158,6 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -82,12 +82,6 @@
|
||||
"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",
|
||||
@@ -331,20 +325,17 @@
|
||||
"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",
|
||||
@@ -378,9 +369,7 @@
|
||||
"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",
|
||||
@@ -708,17 +697,13 @@
|
||||
"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",
|
||||
@@ -726,9 +711,7 @@
|
||||
"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": {
|
||||
@@ -751,7 +734,6 @@
|
||||
"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",
|
||||
"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",
|
||||
@@ -763,7 +745,6 @@
|
||||
"Give Feedback": "Dar retroalimentación",
|
||||
"Group Selected Nodes": "Agrupar nodos seleccionados",
|
||||
"Help": "Ayuda",
|
||||
"Increase Brush Size in MaskEditor": "Aumentar tamaño del pincel en MaskEditor",
|
||||
"Interrupt": "Interrumpir",
|
||||
"Load Default Workflow": "Cargar flujo de trabajo predeterminado",
|
||||
"Manage group nodes": "Gestionar nodos de grupo",
|
||||
@@ -804,13 +785,13 @@
|
||||
"Toggle Bottom Panel": "Alternar panel inferior",
|
||||
"Toggle Focus Mode": "Alternar modo de enfoque",
|
||||
"Toggle Logs Bottom Panel": "Alternar panel inferior de registros",
|
||||
"Toggle Model Library Sidebar": "Alternar barra lateral de la biblioteca de modelos",
|
||||
"Toggle Node Library Sidebar": "Alternar barra lateral de la biblioteca de nodos",
|
||||
"Toggle Queue Sidebar": "Alternar barra lateral de la cola",
|
||||
"Toggle Model Library Sidebar": "Alternar barra lateral de biblioteca de modelos",
|
||||
"Toggle Node Library Sidebar": "Alternar barra lateral de biblioteca de nodos",
|
||||
"Toggle Queue Sidebar": "Alternar barra lateral de cola",
|
||||
"Toggle Search Box": "Alternar caja de búsqueda",
|
||||
"Toggle Terminal Bottom Panel": "Alternar panel inferior de terminal",
|
||||
"Toggle Theme (Dark/Light)": "Alternar tema (Oscuro/Claro)",
|
||||
"Toggle Workflows Sidebar": "Alternar barra lateral de los flujos de trabajo",
|
||||
"Toggle Workflows Sidebar": "Alternar barra lateral de flujos de trabajo",
|
||||
"Toggle the Custom Nodes Manager": "Alternar el Administrador de Nodos Personalizados",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "Alternar la Barra de Progreso del Administrador de Nodos Personalizados",
|
||||
"Undo": "Deshacer",
|
||||
@@ -1118,7 +1099,6 @@
|
||||
"Node Search Box": "Caja de Búsqueda de Nodo",
|
||||
"Node Widget": "Widget de Nodo",
|
||||
"NodeLibrary": "Biblioteca de Nodos",
|
||||
"Notification Preferences": "Preferencias de notificación",
|
||||
"Pointer": "Puntero",
|
||||
"Queue": "Cola",
|
||||
"QueueButton": "Botón de Cola",
|
||||
@@ -1177,6 +1157,7 @@
|
||||
},
|
||||
"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?",
|
||||
@@ -1218,44 +1199,34 @@
|
||||
"Flux": "Flux",
|
||||
"Image": "Imagen",
|
||||
"Image API": "API de Imagen",
|
||||
"LLM API": "API LLM",
|
||||
"Upscaling": "Ampliación",
|
||||
"Video": "Video",
|
||||
"Video API": "API de Video"
|
||||
},
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0",
|
||||
"3d_hunyuan3d_multiview_to_model": "Hunyuan3D 2.0 MV",
|
||||
"3d_hunyuan3d_multiview_to_model_turbo": "Hunyuan3D 2.0 MV Turbo",
|
||||
"hunyuan-3d-multiview-elf": "Hunyuan3D 2.0 MV",
|
||||
"hunyuan-3d-turbo": "Hunyuan3D 2.0 MV Turbo",
|
||||
"hunyuan3d-non-multiview-train": "Hunyuan3D 2.0",
|
||||
"stable_zero123_example": "Estable Zero123"
|
||||
},
|
||||
"3D API": {
|
||||
"api_rodin_image_to_model": "Rodin: Imagen a Modelo",
|
||||
"api_rodin_multiview_to_model": "Rodin: Multivista a Modelo",
|
||||
"api_tripo_image_to_model": "Tripo: Imagen a Modelo",
|
||||
"api_tripo_multiview_to_model": "Tripo: Multivista a Modelo",
|
||||
"api_tripo_text_to_model": "Tripo: Texto a Modelo"
|
||||
},
|
||||
"Area Composition": {
|
||||
"area_composition": "Composición de Área",
|
||||
"area_composition_reversed": "Composición de Área Invertida",
|
||||
"area_composition_square_area_for_subject": "Composición de Área Cuadrada para el Sujeto"
|
||||
},
|
||||
"Audio": {
|
||||
"audio_ace_step_1_m2m_editing": "ACE Step v1 Edición M2M",
|
||||
"audio_ace_step_1_t2a_instrumentals": "ACE-Step v1 Texto a Música Instrumental",
|
||||
"audio_ace_step_1_t2a_song": "ACE Step v1 Texto a Canción",
|
||||
"audio_stable_audio_example": "Stable Audio"
|
||||
"stable_audio_example": "Audio Estable"
|
||||
},
|
||||
"Basics": {
|
||||
"default": "Generación de Imagen",
|
||||
"embedding_example": "Incrustación",
|
||||
"gligen_textbox_example": "Caja de Texto Gligen",
|
||||
"image2image": "Imagen a Imagen",
|
||||
"inpain_model_outpainting": "Outpaint",
|
||||
"inpaint_example": "Inpaint",
|
||||
"inpaint_model_outpainting": "Outpaint",
|
||||
"lora": "LoRA",
|
||||
"lora_multiple": "LoRA Múltiple"
|
||||
"lora": "Lora",
|
||||
"lora_multiple": "Lora Múltiple"
|
||||
},
|
||||
"ControlNet": {
|
||||
"2_pass_pose_worship": "ControlNet de Pose 2 Pasadas",
|
||||
@@ -1266,27 +1237,17 @@
|
||||
},
|
||||
"Flux": {
|
||||
"flux_canny_model_example": "Flux Canny Model",
|
||||
"flux_depth_lora_example": "Flux Depth LoRA",
|
||||
"flux_dev_checkpoint_example": "Flux Dev fp8",
|
||||
"flux_dev_full_text_to_image": "Flux Dev texto a imagen completo",
|
||||
"flux_depth_lora_example": "Flux Depth Lora",
|
||||
"flux_dev_checkpoint_example": "Flux Dev",
|
||||
"flux_fill_inpaint_example": "Flux Inpaint",
|
||||
"flux_fill_outpaint_example": "Flux Outpaint",
|
||||
"flux_kontext_dev_basic": "Flux Kontext Dev (Básico)",
|
||||
"flux_kontext_dev_grouped": "Flux Kontext Dev (Agrupado)",
|
||||
"flux_redux_model_example": "Flux Redux Model",
|
||||
"flux_schnell": "Flux Schnell fp8",
|
||||
"flux_schnell_full_text_to_image": "Flux Schnell texto a imagen completo"
|
||||
"flux_schnell": "Flux Schnell"
|
||||
},
|
||||
"Image": {
|
||||
"hidream_e1_full": "HiDream E1 Completo",
|
||||
"hidream_i1_dev": "HiDream I1 Dev",
|
||||
"hidream_i1_fast": "HiDream I1 Rápido",
|
||||
"hidream_i1_full": "HiDream I1 Completo",
|
||||
"image_chroma_text_to_image": "Chroma texto a imagen",
|
||||
"image_cosmos_predict2_2B_t2i": "Cosmos Predict2 2B T2I",
|
||||
"image_lotus_depth_v1_1": "Lotus Depth",
|
||||
"image_omnigen2_image_edit": "OmniGen2 Edición de Imagen",
|
||||
"image_omnigen2_t2i": "OmniGen2 Texto a Imagen",
|
||||
"sd3_5_large_blur": "SD3.5 Grande Desenfoque",
|
||||
"sd3_5_large_canny_controlnet_example": "SD3.5 Grande Canny ControlNet",
|
||||
"sd3_5_large_depth": "SD3.5 Grande Profundidad",
|
||||
@@ -1298,33 +1259,21 @@
|
||||
"sdxlturbo_example": "SDXL Turbo"
|
||||
},
|
||||
"Image API": {
|
||||
"api_bfl_flux_1_kontext_max_image": "BFL Flux.1 Kontext Max",
|
||||
"api_bfl_flux_1_kontext_multiple_images_input": "BFL Flux.1 Kontext Entrada de Múltiples Imágenes",
|
||||
"api_bfl_flux_1_kontext_pro_image": "BFL Flux.1 Kontext Pro",
|
||||
"api_bfl_flux_pro_t2i": "BFL Flux[Pro]: Texto a Imagen",
|
||||
"api_ideogram_v3_t2i": "Ideogram V3: Texto a Imagen",
|
||||
"api_luma_photon_i2i": "Luma Photon: Imagen a Imagen",
|
||||
"api_luma_photon_style_ref": "Luma Photon: Referencia de Estilo",
|
||||
"api_openai_dall_e_2_inpaint": "OpenAI: Dall-E 2 Rellenar",
|
||||
"api_openai_dall_e_2_t2i": "OpenAI: Dall-E 2 Texto a Imagen",
|
||||
"api_openai_dall_e_3_t2i": "OpenAI: Dall-E 3 Texto a Imagen",
|
||||
"api_openai_image_1_i2i": "OpenAI: GPT-Image-1 Imagen a Imagen",
|
||||
"api_openai_image_1_inpaint": "OpenAI: GPT-Image-1 Rellenar",
|
||||
"api_openai_image_1_multi_inputs": "OpenAI: GPT-Image-1 Múltiples Entradas",
|
||||
"api_openai_image_1_t2i": "OpenAI: GPT-Image-1 Texto a Imagen",
|
||||
"api_recraft_image_gen_with_color_control": "Recraft: Generación de Imagen con Control de Color",
|
||||
"api_recraft_image_gen_with_style_control": "Recraft: Generación de Imagen con Control de Estilo",
|
||||
"api_recraft_vector_gen": "Recraft: Generación de Vectores",
|
||||
"api_runway_reference_to_image": "Runway: Referencia a Imagen",
|
||||
"api_runway_text_to_image": "Runway: Texto a Imagen",
|
||||
"api_stability_ai_i2i": "Stability AI: Imagen a Imagen",
|
||||
"api_stability_ai_sd3_5_i2i": "Stability AI: SD3.5 Imagen a Imagen",
|
||||
"api_stability_ai_sd3_5_t2i": "Stability AI: SD3.5 Texto a Imagen",
|
||||
"api_stability_ai_stable_image_ultra_t2i": "Stability AI: Stable Image Ultra Texto a Imagen"
|
||||
},
|
||||
"LLM API": {
|
||||
"api_google_gemini": "Google Gemini: Chat",
|
||||
"api_openai_chat": "OpenAI: Chat"
|
||||
"api-openai-dall-e-2-inpaint": "Dall-E 2 Rellenar",
|
||||
"api-openai-dall-e-2-t2i": "Dall-E 2 Texto a Imagen",
|
||||
"api-openai-dall-e-3-t2i": "Dall-E 3 Texto a Imagen",
|
||||
"api_bfl_flux_pro_t2i": "BFL Flux 1.1[pro] Ultra Texto a Imagen",
|
||||
"api_ideogram_v3_t2i": "Ideogram V3 Texto a Imagen",
|
||||
"api_luma_photon_i2i": "Luma Photon Imagen a Imagen",
|
||||
"api_luma_photon_style_ref": "Luma Photon Referencia de Estilo",
|
||||
"api_openai_image_1_i2i": "OpenAI Image-1 Imagen a Imagen",
|
||||
"api_openai_image_1_inpaint": "OpenAI Image-1 Rellenar",
|
||||
"api_openai_image_1_multi_inputs": "OpenAI Image-1 Múltiples Entradas",
|
||||
"api_openai_image_1_t2i": "OpenAI Image-1 Texto a Imagen",
|
||||
"api_recraft_image_gen_with_color_control": "Recraft Generación de Imagen con Control de Color",
|
||||
"api_recraft_image_gen_with_style_control": "Recraft Generación de Imagen con Control de Estilo",
|
||||
"api_recraft_vector_gen": "Recraft Generación de Vectores",
|
||||
"api_stability_sd3_t2i": "Stability AI Stable Image Ultra Texto a Imagen"
|
||||
},
|
||||
"Upscaling": {
|
||||
"esrgan_example": "ESRGAN",
|
||||
@@ -1341,190 +1290,119 @@
|
||||
"mochi_text_to_video_example": "Mochi Texto a Video",
|
||||
"text_to_video_wan": "Wan 2.1 Texto a Video",
|
||||
"txt_to_image_to_video": "SVD Texto a Imagen a Video",
|
||||
"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 Referencia a Video",
|
||||
"video_wan_vace_14B_t2v": "Wan VACE Texto a Video",
|
||||
"video_wan_vace_14B_v2v": "Wan VACE Control Video",
|
||||
"video_wan_vace_flf2v": "Wan VACE Primer-Ultimo Fotograma",
|
||||
"video_wan_vace_inpainting": "Wan VACE Inpainting",
|
||||
"video_wan_vace_outpainting": "Wan VACE Outpainting",
|
||||
"wan2_1_flf2v_720_f16": "Wan 2.1 FLF2V 720p F16",
|
||||
"wan2_1_fun_control": "Wan 2.1 ControlNet",
|
||||
"wan2_1_fun_inp": "Wan 2.1 Inpainting"
|
||||
"wan2_1_fun_inp": "Wan 2.1 Relleno"
|
||||
},
|
||||
"Video API": {
|
||||
"api_hailuo_minimax_i2v": "MiniMax: Imagen a Video",
|
||||
"api_hailuo_minimax_t2v": "MiniMax: Texto a Video",
|
||||
"api_kling_effects": "Kling: Efectos de Video",
|
||||
"api_kling_flf": "Kling: FLF2V",
|
||||
"api_kling_i2v": "Kling: Imagen a Video",
|
||||
"api_luma_i2v": "Luma: Imagen a Video",
|
||||
"api_luma_t2v": "Luma: Texto a Video",
|
||||
"api_moonvalley_image_to_video": "Moonvalley: Imagen a Video",
|
||||
"api_moonvalley_text_to_video": "Moonvalley: Texto a Video",
|
||||
"api_pika_i2v": "Pika: Imagen a Video",
|
||||
"api_hailuo_minimax_i2v": "MiniMax Imagen a Video",
|
||||
"api_kling_i2v": "Kling Imagen a Video",
|
||||
"api_luma_i2v": "Luma Imagen a Video",
|
||||
"api_pika_scene": "Pika Escenas: Imágenes a Video",
|
||||
"api_pixverse_i2v": "PixVerse: Imagen a Video",
|
||||
"api_pixverse_t2v": "PixVerse: Texto a Video",
|
||||
"api_pixverse_template_i2v": "PixVerse Plantillas: Imagen a Video",
|
||||
"api_runway_first_last_frame": "Runway: Primer Último Fotograma a Video",
|
||||
"api_runway_gen3a_turbo_image_to_video": "Runway: Gen3a Turbo Imagen a Video",
|
||||
"api_runway_gen4_turo_image_to_video": "Runway: Gen4 Turbo Imagen a Video",
|
||||
"api_veo2_i2v": "Veo2: Imagen a Video"
|
||||
"api_pixverse_t2v": "PixVerse Texto a Video",
|
||||
"api_pixverse_template_i2v": "PixVerse Template Effects: Imagen a Video",
|
||||
"api_veo2_i2v": "Veo2 Imagen a Video"
|
||||
}
|
||||
},
|
||||
"templateDescription": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Genera modelos 3D a partir de imágenes individuales usando Hunyuan3D 2.0.",
|
||||
"3d_hunyuan3d_multiview_to_model": "Genera modelos 3D a partir de múltiples vistas usando Hunyuan3D 2.0 MV.",
|
||||
"3d_hunyuan3d_multiview_to_model_turbo": "Genera modelos 3D a partir de múltiples vistas usando Hunyuan3D 2.0 MV Turbo.",
|
||||
"stable_zero123_example": "Genera vistas 3D a partir de imágenes individuales usando Stable Zero123."
|
||||
},
|
||||
"3D API": {
|
||||
"api_rodin_image_to_model": "Genera modelos 3D detallados a partir de una sola foto usando Rodin AI.",
|
||||
"api_rodin_multiview_to_model": "Esculpe modelos 3D completos usando reconstrucción multivista de Rodin.",
|
||||
"api_tripo_image_to_model": "Genera activos 3D profesionales a partir de imágenes 2D usando el motor Tripo.",
|
||||
"api_tripo_multiview_to_model": "Construye modelos 3D a partir de múltiples ángulos con el escáner avanzado de Tripo.",
|
||||
"api_tripo_text_to_model": "Crea objetos 3D a partir de descripciones con modelado basado en texto de Tripo."
|
||||
"hunyuan-3d-multiview-elf": "Usa Hunyuan3D 2mv para generar modelos desde múltiples vistas.",
|
||||
"hunyuan-3d-turbo": "Usa Hunyuan3D 2mv turbo para generar modelos desde múltiples vistas.",
|
||||
"hunyuan3d-non-multiview-train": "Usa Hunyuan3D 2.0 para generar modelos desde una sola vista.",
|
||||
"stable_zero123_example": "Genera vistas 3D a partir de imágenes individuales."
|
||||
},
|
||||
"Area Composition": {
|
||||
"area_composition": "Genera imágenes controlando la composición con áreas definidas.",
|
||||
"area_composition_square_area_for_subject": "Genera imágenes con colocación consistente del sujeto usando composición de áreas."
|
||||
"area_composition": "Controla la composición de la imagen por áreas.",
|
||||
"area_composition_reversed": "Invierte el flujo de composición por áreas.",
|
||||
"area_composition_square_area_for_subject": "Crea una colocación consistente del sujeto."
|
||||
},
|
||||
"Audio": {
|
||||
"audio_ace_step_1_m2m_editing": "Edita canciones existentes para cambiar el estilo y la letra usando ACE-Step v1 M2M.",
|
||||
"audio_ace_step_1_t2a_instrumentals": "Genera música instrumental a partir de texto usando ACE-Step v1.",
|
||||
"audio_ace_step_1_t2a_song": "Genera canciones con voz a partir de texto usando ACE-Step v1, soportando múltiples idiomas y personalización de estilo.",
|
||||
"audio_stable_audio_example": "Genera audio a partir de descripciones de texto usando Stable Audio."
|
||||
"stable_audio_example": "Genera audio a partir de descripciones de texto."
|
||||
},
|
||||
"Basics": {
|
||||
"default": "Genera imágenes a partir de descripciones de texto.",
|
||||
"embedding_example": "Genera imágenes usando inversión textual para estilos consistentes.",
|
||||
"gligen_textbox_example": "Genera imágenes con colocación precisa de objetos usando cajas de texto.",
|
||||
"embedding_example": "Utiliza inversión textual para estilos consistentes.",
|
||||
"gligen_textbox_example": "Especifica la ubicación y el tamaño de los objetos.",
|
||||
"image2image": "Transforma imágenes existentes usando indicaciones de texto.",
|
||||
"inpain_model_outpainting": "Extiende imágenes más allá de sus límites originales.",
|
||||
"inpaint_example": "Edita partes específicas de imágenes de manera fluida.",
|
||||
"inpaint_model_outpainting": "Extiende imágenes más allá de sus límites originales.",
|
||||
"lora": "Genera imágenes con modelos LoRA para estilos o temas especializados.",
|
||||
"lora_multiple": "Genera imágenes combinando múltiples modelos LoRA."
|
||||
"lora": "Aplica modelos LoRA para estilos o temas especializados.",
|
||||
"lora_multiple": "Combina múltiples modelos LoRA para resultados únicos."
|
||||
},
|
||||
"ControlNet": {
|
||||
"2_pass_pose_worship": "Genera imágenes guiadas por referencias de pose usando ControlNet.",
|
||||
"controlnet_example": "Genera imágenes guiadas por imágenes de garabato usando ControlNet.",
|
||||
"depth_controlnet": "Genera imágenes guiadas por información de profundidad usando ControlNet.",
|
||||
"depth_t2i_adapter": "Genera imágenes guiadas por información de profundidad usando el adaptador T2I.",
|
||||
"mixing_controlnets": "Genera imágenes combinando múltiples modelos ControlNet."
|
||||
"2_pass_pose_worship": "Genera imágenes a partir de referencias de pose.",
|
||||
"controlnet_example": "Controla la generación de imágenes con imágenes de referencia.",
|
||||
"depth_controlnet": "Crea imágenes con generación consciente de profundidad.",
|
||||
"depth_t2i_adapter": "Genera rápidamente imágenes conscientes de profundidad con un adaptador T2I.",
|
||||
"mixing_controlnets": "Combina múltiples modelos ControlNet juntos."
|
||||
},
|
||||
"Flux": {
|
||||
"flux_canny_model_example": "Genera imágenes guiadas por detección de bordes usando Flux Canny.",
|
||||
"flux_depth_lora_example": "Genera imágenes guiadas por información de profundidad usando Flux LoRA.",
|
||||
"flux_dev_checkpoint_example": "Genera imágenes usando la versión cuantizada fp8 de Flux Dev. Ideal para dispositivos con poca VRAM, solo requiere un archivo de modelo, pero la calidad es ligeramente inferior a la versión completa.",
|
||||
"flux_dev_full_text_to_image": "Genera imágenes de alta calidad con la versión completa de Flux Dev. Requiere más VRAM y múltiples archivos de modelo, pero ofrece la mejor adherencia a la indicación y calidad de imagen.",
|
||||
"flux_fill_inpaint_example": "Rellena partes faltantes de imágenes usando inpainting de Flux.",
|
||||
"flux_fill_outpaint_example": "Extiende imágenes más allá de los límites usando outpainting de Flux.",
|
||||
"flux_kontext_dev_basic": "Edita imágenes usando Flux Kontext con visibilidad total de nodos, ideal para aprender el flujo de trabajo.",
|
||||
"flux_kontext_dev_grouped": "Versión simplificada de Flux Kontext con nodos agrupados para un espacio de trabajo más limpio.",
|
||||
"flux_redux_model_example": "Genera imágenes transfiriendo el estilo de imágenes de referencia usando Flux Redux.",
|
||||
"flux_schnell": "Genera imágenes rápidamente con la versión cuantizada fp8 de Flux Schnell. Perfecto para hardware de gama baja, solo requiere 4 pasos.",
|
||||
"flux_schnell_full_text_to_image": "Genera imágenes rápidamente con la versión completa de Flux Schnell. Licencia Apache2.0, solo requiere 4 pasos manteniendo buena calidad."
|
||||
"flux_canny_model_example": "Genera imágenes a partir de detección de bordes.",
|
||||
"flux_depth_lora_example": "Crea imágenes con LoRA consciente de profundidad.",
|
||||
"flux_dev_checkpoint_example": "Crea imágenes usando modelos de desarrollo de Flux.",
|
||||
"flux_fill_inpaint_example": "Rellena partes faltantes de imágenes.",
|
||||
"flux_fill_outpaint_example": "Extiende imágenes usando outpainting de Flux.",
|
||||
"flux_redux_model_example": "Transfiere el estilo de una imagen de referencia para guiar la generación de imágenes con Flux.",
|
||||
"flux_schnell": "Genera imágenes rápidamente con Flux Schnell."
|
||||
},
|
||||
"Image": {
|
||||
"hidream_e1_full": "Edita imágenes con HiDream E1 - Modelo profesional de edición de imágenes por lenguaje natural.",
|
||||
"hidream_i1_dev": "Genera imágenes con HiDream I1 Dev - Versión equilibrada con 28 pasos de inferencia, adecuada para hardware medio.",
|
||||
"hidream_i1_fast": "Genera imágenes rápidamente con HiDream I1 Fast - Versión ligera con 16 pasos, ideal para previsualizaciones rápidas.",
|
||||
"hidream_i1_full": "Genera imágenes con HiDream I1 Full - Versión completa con 50 pasos para la máxima calidad.",
|
||||
"image_chroma_text_to_image": "Chroma está modificado de Flux y tiene algunos cambios en la arquitectura.",
|
||||
"image_cosmos_predict2_2B_t2i": "Genera imágenes con Cosmos-Predict2 2B T2I, logrando generación física precisa, alta fidelidad y gran detalle.",
|
||||
"image_lotus_depth_v1_1": "Ejecuta Lotus Depth en ComfyUI para estimación de profundidad monocular eficiente y detallada.",
|
||||
"image_omnigen2_image_edit": "Edita imágenes con instrucciones en lenguaje natural usando las avanzadas capacidades de edición de imagen y soporte de texto de OmniGen2.",
|
||||
"image_omnigen2_t2i": "Genera imágenes de alta calidad a partir de texto usando el modelo multimodal unificado 7B de OmniGen2 con arquitectura de doble vía.",
|
||||
"sd3_5_large_blur": "Genera imágenes guiadas por imágenes de referencia borrosas usando SD 3.5.",
|
||||
"sd3_5_large_canny_controlnet_example": "Genera imágenes guiadas por detección de bordes usando SD 3.5 Canny ControlNet.",
|
||||
"sd3_5_large_depth": "Genera imágenes guiadas por información de profundidad usando SD 3.5.",
|
||||
"sd3_5_simple_example": "Genera imágenes usando SD 3.5.",
|
||||
"sdxl_refiner_prompt_example": "Mejora imágenes SDXL usando modelos refinadores.",
|
||||
"sdxl_revision_text_prompts": "Genera imágenes transfiriendo conceptos de imágenes de referencia usando SDXL Revision.",
|
||||
"sdxl_revision_zero_positive": "Genera imágenes usando tanto indicaciones de texto como imágenes de referencia con SDXL Revision.",
|
||||
"sdxl_simple_example": "Genera imágenes de alta calidad usando SDXL.",
|
||||
"sdxlturbo_example": "Genera imágenes en un solo paso usando SDXL Turbo."
|
||||
"hidream_e1_full": "Edita imágenes con HiDream E1.",
|
||||
"hidream_i1_dev": "Genera imágenes con HiDream I1 Dev.",
|
||||
"hidream_i1_fast": "Genera imágenes rápidamente con HiDream I1.",
|
||||
"hidream_i1_full": "Genera imágenes con HiDream I1.",
|
||||
"sd3_5_large_blur": "Genera imágenes a partir de imágenes de referencia borrosas con SD 3.5.",
|
||||
"sd3_5_large_canny_controlnet_example": "Usa detección de bordes para guiar la generación de imágenes con SD 3.5.",
|
||||
"sd3_5_large_depth": "Crea imágenes conscientes de profundidad con SD 3.5.",
|
||||
"sd3_5_simple_example": "Genera imágenes con SD 3.5.",
|
||||
"sdxl_refiner_prompt_example": "Mejora los resultados de SDXL con refinadores.",
|
||||
"sdxl_revision_text_prompts": "Transfiere conceptos de imágenes de referencia para guiar la generación de imágenes con SDXL.",
|
||||
"sdxl_revision_zero_positive": "Agrega indicaciones de texto junto a imágenes de referencia para guiar la generación de imágenes con SDXL.",
|
||||
"sdxl_simple_example": "Crea imágenes de alta calidad con SDXL.",
|
||||
"sdxlturbo_example": "Genera imágenes en un solo paso con SDXL Turbo."
|
||||
},
|
||||
"Image API": {
|
||||
"api_bfl_flux_1_kontext_max_image": "Edita imágenes con Flux.1 Kontext max image.",
|
||||
"api_bfl_flux_1_kontext_multiple_images_input": "Introduce múltiples imágenes y edítalas con Flux.1 Kontext.",
|
||||
"api_bfl_flux_1_kontext_pro_image": "Edita imágenes con Flux.1 Kontext pro image.",
|
||||
"api_bfl_flux_pro_t2i": "Genera imágenes con excelente seguimiento de indicaciones y calidad visual usando FLUX.1 Pro.",
|
||||
"api_ideogram_v3_t2i": "Genera imágenes de calidad profesional con excelente alineación de indicaciones, fotorrealismo y renderizado de texto usando Ideogram V3.",
|
||||
"api-openai-dall-e-2-inpaint": "Usa la API Dall-E 2 para hacer inpainting en imágenes.",
|
||||
"api-openai-dall-e-2-t2i": "Usa la API Dall-E 2 para generar imágenes a partir de descripciones de texto.",
|
||||
"api-openai-dall-e-3-t2i": "Usa la API Dall-E 3 para generar imágenes a partir de descripciones de texto.",
|
||||
"api_bfl_flux_pro_t2i": "Crea imágenes con FLUX.1 [pro] y su excelente seguimiento de indicaciones, calidad visual, detalle de imagen y diversidad de resultados.",
|
||||
"api_ideogram_v3_t2i": "Genera imágenes con alineación de indicaciones de alta calidad, fotorrealismo y renderizado de texto. Crea logotipos de calidad profesional, carteles promocionales, conceptos de páginas de destino, fotografía de productos y más. Crea composiciones espaciales sofisticadas con fondos intrincados, iluminación y colores precisos y matizados, y detalles ambientales realistas.",
|
||||
"api_luma_photon_i2i": "Guía la generación de imágenes usando una combinación de imágenes e indicaciones.",
|
||||
"api_luma_photon_style_ref": "Genera imágenes combinando referencias de estilo con control preciso usando Luma Photon.",
|
||||
"api_openai_dall_e_2_inpaint": "Edita imágenes usando inpainting con la API OpenAI Dall-E 2.",
|
||||
"api_openai_dall_e_2_t2i": "Genera imágenes a partir de texto usando la API OpenAI Dall-E 2.",
|
||||
"api_openai_dall_e_3_t2i": "Genera imágenes a partir de texto usando la API OpenAI Dall-E 3.",
|
||||
"api_openai_image_1_i2i": "Genera imágenes a partir de imágenes usando la API OpenAI GPT Image 1.",
|
||||
"api_openai_image_1_inpaint": "Edita imágenes usando inpainting con la API OpenAI GPT Image 1.",
|
||||
"api_openai_image_1_multi_inputs": "Genera imágenes a partir de múltiples entradas usando la API OpenAI GPT Image 1.",
|
||||
"api_openai_image_1_t2i": "Genera imágenes a partir de texto usando la API OpenAI GPT Image 1.",
|
||||
"api_recraft_image_gen_with_color_control": "Genera imágenes con paletas de color personalizadas y visuales de marca usando Recraft.",
|
||||
"api_recraft_image_gen_with_style_control": "Controla el estilo con ejemplos visuales, alinea la posición y ajusta objetos. Guarda y comparte estilos para consistencia de marca.",
|
||||
"api_recraft_vector_gen": "Genera imágenes vectoriales de alta calidad a partir de texto usando el generador de vectores IA de Recraft.",
|
||||
"api_runway_reference_to_image": "Genera nuevas imágenes basadas en estilos y composiciones de referencia con Runway.",
|
||||
"api_runway_text_to_image": "Genera imágenes de alta calidad a partir de texto usando el modelo IA de Runway.",
|
||||
"api_stability_ai_i2i": "Transforma imágenes con generación de alta calidad usando Stability AI, ideal para edición profesional y transferencia de estilo.",
|
||||
"api_stability_ai_sd3_5_i2i": "Genera imágenes de alta calidad con excelente adherencia a la indicación. Perfecto para uso profesional a 1 megapíxel.",
|
||||
"api_stability_ai_sd3_5_t2i": "Genera imágenes de alta calidad con excelente adherencia a la indicación. Perfecto para uso profesional a 1 megapíxel.",
|
||||
"api_stability_ai_stable_image_ultra_t2i": "Genera imágenes de alta calidad con excelente adherencia a la indicación. Perfecto para uso profesional a 1 megapíxel."
|
||||
},
|
||||
"LLM API": {
|
||||
"api_google_gemini": "Experimenta la IA multimodal de Google con las capacidades de razonamiento de Gemini.",
|
||||
"api_openai_chat": "Interactúa con los avanzados modelos de lenguaje de OpenAI para conversaciones inteligentes."
|
||||
"api_luma_photon_style_ref": "Aplica y combina referencias de estilo con control exacto. Luma Photon captura la esencia de cada imagen de referencia, permitiéndote combinar elementos visuales distintos manteniendo calidad profesional.",
|
||||
"api_openai_image_1_i2i": "Usa la API GPT Image 1 para generar imágenes a partir de imágenes.",
|
||||
"api_openai_image_1_inpaint": "Usa la API GPT Image 1 para hacer inpainting en imágenes.",
|
||||
"api_openai_image_1_multi_inputs": "Usa la API GPT Image 1 con múltiples entradas para generar imágenes.",
|
||||
"api_openai_image_1_t2i": "Usa la API GPT Image 1 para generar imágenes a partir de descripciones de texto.",
|
||||
"api_recraft_image_gen_with_color_control": "Crea una paleta personalizada para reutilizar en múltiples imágenes o selecciona colores para cada foto. Haz coincidir la paleta de tu marca y crea imágenes visuales que sean distintivamente tuyas.",
|
||||
"api_recraft_image_gen_with_style_control": "Controla el estilo con ejemplos visuales, alinea la posición y ajusta objetos. Guarda y comparte estilos para una consistencia perfecta de marca.",
|
||||
"api_recraft_vector_gen": "Pasa de una indicación de texto a una imagen vectorial con el generador de vectores IA de Recraft. Produce arte vectorial de la mejor calidad para logotipos, carteles, conjuntos de iconos, anuncios, banners y maquetas. Perfecciona tus diseños con archivos SVG nítidos y de alta calidad. Crea ilustraciones vectoriales de marca para tu app o sitio web en segundos.",
|
||||
"api_stability_sd3_t2i": "Genera imágenes de alta calidad con excelente adherencia a las indicaciones. Perfecto para casos de uso profesional a resolución de 1 megapíxel."
|
||||
},
|
||||
"Upscaling": {
|
||||
"esrgan_example": "Escala imágenes usando modelos ESRGAN para mejorar la calidad.",
|
||||
"hiresfix_esrgan_workflow": "Escala imágenes usando modelos ESRGAN durante pasos intermedios.",
|
||||
"hiresfix_latent_workflow": "Escala imágenes mejorando la calidad en el espacio latente.",
|
||||
"latent_upscale_different_prompt_model": "Escala imágenes cambiando las indicaciones entre pasadas."
|
||||
"esrgan_example": "Usa modelos de escalado para mejorar la calidad de imagen.",
|
||||
"hiresfix_esrgan_workflow": "Usa modelos de escalado durante pasos intermedios.",
|
||||
"hiresfix_latent_workflow": "Mejora la calidad de imagen en el espacio latente.",
|
||||
"latent_upscale_different_prompt_model": "Escala y cambia la indicación entre pasadas."
|
||||
},
|
||||
"Video": {
|
||||
"hunyuan_video_text_to_video": "Genera videos a partir de texto usando el modelo Hunyuan.",
|
||||
"image_to_video": "Genera videos a partir de imágenes fijas.",
|
||||
"image_to_video_wan": "Genera videos a partir de imágenes usando Wan 2.1.",
|
||||
"ltxv_image_to_video": "Genera videos a partir de imágenes fijas.",
|
||||
"ltxv_text_to_video": "Genera videos a partir de texto.",
|
||||
"mochi_text_to_video_example": "Genera videos a partir de texto usando el modelo Mochi.",
|
||||
"text_to_video_wan": "Genera videos a partir de texto usando Wan 2.1.",
|
||||
"txt_to_image_to_video": "Genera videos creando primero imágenes a partir de texto.",
|
||||
"video_cosmos_predict2_2B_video2world_480p_16fps": "Genera videos con Cosmos-Predict2 2B Video2World, logrando simulaciones físicas precisas, alta fidelidad y consistencia.",
|
||||
"video_wan2_1_fun_camera_v1_1_14B": "Genera videos de alta calidad con control avanzado de cámara usando el modelo completo de 14B.",
|
||||
"video_wan2_1_fun_camera_v1_1_1_3B": "Genera videos dinámicos con movimientos de cámara cinematográficos usando Wan 2.1 Fun Camera 1.3B.",
|
||||
"video_wan_vace_14B_ref2v": "Crea videos que coinciden con el estilo y contenido de una imagen de referencia.",
|
||||
"video_wan_vace_14B_t2v": "Transforma descripciones de texto en videos de alta calidad. Soporta 480p y 720p con el modelo VACE-14B.",
|
||||
"video_wan_vace_14B_v2v": "Genera videos controlando videos de entrada e imágenes de referencia usando Wan VACE.",
|
||||
"video_wan_vace_flf2v": "Genera transiciones suaves definiendo fotogramas iniciales y finales. Soporta secuencias de fotogramas personalizadas.",
|
||||
"video_wan_vace_inpainting": "Edita regiones específicas en videos preservando el contenido circundante.",
|
||||
"video_wan_vace_outpainting": "Genera videos extendidos expandiendo el tamaño usando Wan VACE outpainting.",
|
||||
"wan2_1_flf2v_720_f16": "Genera videos controlando primer y último fotograma usando Wan 2.1 FLF2V.",
|
||||
"wan2_1_fun_control": "Genera videos guiados por pose, profundidad y bordes usando Wan 2.1 ControlNet.",
|
||||
"wan2_1_fun_inp": "Genera videos a partir de fotogramas iniciales y finales usando Wan 2.1 inpainting."
|
||||
"hunyuan_video_text_to_video": "Genera videos usando el modelo Hunyuan.",
|
||||
"image_to_video": "Transforma imágenes en videos animados.",
|
||||
"image_to_video_wan": "Genera videos rápidamente a partir de imágenes.",
|
||||
"ltxv_image_to_video": "Convierte imágenes fijas en videos.",
|
||||
"ltxv_text_to_video": "Genera videos a partir de descripciones de texto.",
|
||||
"mochi_text_to_video_example": "Crea videos con el modelo Mochi.",
|
||||
"text_to_video_wan": "Genera videos rápidamente a partir de descripciones de texto.",
|
||||
"txt_to_image_to_video": "Genera imágenes a partir de texto y luego conviértelas en videos.",
|
||||
"wan2_1_flf2v_720_f16": "Genera video controlando el primer y último fotograma.",
|
||||
"wan2_1_fun_control": "Guía la generación de video con pose, profundidad, controles de bordes y más.",
|
||||
"wan2_1_fun_inp": "Crea videos a partir de fotogramas iniciales y finales."
|
||||
},
|
||||
"Video API": {
|
||||
"api_hailuo_minimax_i2v": "Genera videos refinados a partir de imágenes y texto con integración CGI usando MiniMax.",
|
||||
"api_hailuo_minimax_t2v": "Genera videos de alta calidad directamente desde texto. Explora las capacidades avanzadas de IA de MiniMax para crear narrativas visuales diversas con efectos CGI profesionales.",
|
||||
"api_kling_effects": "Genera videos dinámicos aplicando efectos visuales a imágenes usando Kling.",
|
||||
"api_kling_flf": "Genera videos controlando los primeros y últimos fotogramas.",
|
||||
"api_kling_i2v": "Genera videos con excelente adherencia a la indicación para acciones, expresiones y movimientos de cámara usando Kling.",
|
||||
"api_hailuo_minimax_i2v": "Crea videos refinados a partir de imágenes y texto, incluyendo integración CGI y efectos fotográficos de tendencia como abrazos virales de IA. Elige entre una variedad de estilos y temas de video para que coincidan con tu visión creativa.",
|
||||
"api_kling_i2v": "Crea videos con gran adherencia a las indicaciones para acciones, expresiones y movimientos de cámara. Ahora soporta indicaciones complejas con acciones secuenciales para que seas el director de tu escena.",
|
||||
"api_luma_i2v": "Convierte imágenes estáticas en animaciones mágicas de alta calidad al instante.",
|
||||
"api_luma_t2v": "Genera videos de alta calidad usando indicaciones simples.",
|
||||
"api_moonvalley_image_to_video": "Genera videos cinematográficos 1080p a partir de una imagen usando un modelo entrenado solo con datos licenciados.",
|
||||
"api_moonvalley_text_to_video": "Genera videos cinematográficos 1080p a partir de texto usando un modelo entrenado solo con datos licenciados.",
|
||||
"api_pika_i2v": "Genera videos animados suaves a partir de imágenes estáticas usando Pika AI.",
|
||||
"api_pika_scene": "Genera videos que incorporan múltiples imágenes de entrada usando Pika Scenes.",
|
||||
"api_pixverse_i2v": "Genera videos dinámicos a partir de imágenes estáticas con movimiento y efectos usando PixVerse.",
|
||||
"api_pixverse_t2v": "Genera videos con interpretación precisa de indicaciones y dinámica visual impresionante.",
|
||||
"api_pixverse_template_i2v": "Genera videos dinámicos a partir de imágenes estáticas con movimiento y efectos usando PixVerse.",
|
||||
"api_runway_first_last_frame": "Genera transiciones de video suaves entre dos fotogramas clave con precisión de Runway.",
|
||||
"api_runway_gen3a_turbo_image_to_video": "Genera videos cinematográficos a partir de imágenes estáticas usando Runway Gen3a Turbo.",
|
||||
"api_runway_gen4_turo_image_to_video": "Genera videos dinámicos a partir de imágenes usando Runway Gen4 Turbo.",
|
||||
"api_veo2_i2v": "Genera videos a partir de imágenes usando la API Google Veo2."
|
||||
"api_pika_scene": "Usa múltiples imágenes como ingredientes y genera videos que las incorporen todas.",
|
||||
"api_pixverse_t2v": "Genera videos con interpretación precisa de indicaciones y una dinámica visual impresionante.",
|
||||
"api_pixverse_template_i2v": "Transforma imágenes estáticas en videos dinámicos con movimiento y efectos.",
|
||||
"api_veo2_i2v": "Usa la API Google Veo2 para generar videos a partir de imágenes."
|
||||
}
|
||||
},
|
||||
"title": "Comienza con una Plantilla"
|
||||
@@ -1605,8 +1483,7 @@
|
||||
"title": "Bienvenido a ComfyUI"
|
||||
},
|
||||
"whatsNewPopup": {
|
||||
"learnMore": "Aprende más",
|
||||
"noReleaseNotes": "No hay notas de la versión disponibles."
|
||||
"learnMore": "Aprende más"
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "Introduzca el nombre del archivo",
|
||||
|
||||
@@ -259,10 +259,6 @@
|
||||
"name": "Destacar nodo de ajuste",
|
||||
"tooltip": "Al arrastrar un enlace sobre un nodo con ranura de entrada viable, resalta el nodo"
|
||||
},
|
||||
"Comfy_Notification_ShowVersionUpdates": {
|
||||
"name": "Mostrar actualizaciones de versión",
|
||||
"tooltip": "Mostrar actualizaciones para nuevos modelos y funciones principales nuevas."
|
||||
},
|
||||
"Comfy_Pointer_ClickBufferTime": {
|
||||
"name": "Retraso de deriva del clic del puntero",
|
||||
"tooltip": "Después de presionar un botón del puntero, este es el tiempo máximo (en milisegundos) que se puede ignorar el movimiento del puntero.\n\nAyuda a prevenir que los objetos sean movidos involuntariamente si el puntero se mueve al hacer clic."
|
||||
@@ -326,8 +322,11 @@
|
||||
"Bottom": "Abajo",
|
||||
"Disabled": "Deshabilitado",
|
||||
"Top": "Arriba"
|
||||
},
|
||||
"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"
|
||||
|
||||
@@ -158,12 +158,6 @@
|
||||
"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é"
|
||||
},
|
||||
|
||||
@@ -82,12 +82,6 @@
|
||||
"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",
|
||||
@@ -331,20 +325,17 @@
|
||||
"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",
|
||||
@@ -378,9 +369,7 @@
|
||||
"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",
|
||||
@@ -708,17 +697,13 @@
|
||||
"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",
|
||||
@@ -726,9 +711,7 @@
|
||||
"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": {
|
||||
@@ -751,7 +734,6 @@
|
||||
"Contact Support": "Contacter le support",
|
||||
"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",
|
||||
"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",
|
||||
@@ -763,7 +745,6 @@
|
||||
"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",
|
||||
"Interrupt": "Interrompre",
|
||||
"Load Default Workflow": "Charger le flux de travail par défaut",
|
||||
"Manage group nodes": "Gérer les nœuds de groupe",
|
||||
@@ -804,13 +785,13 @@
|
||||
"Toggle Bottom Panel": "Basculer le panneau inférieur",
|
||||
"Toggle Focus Mode": "Basculer le mode focus",
|
||||
"Toggle Logs Bottom Panel": "Basculer le panneau inférieur des journaux",
|
||||
"Toggle Model Library Sidebar": "Afficher/Masquer la barre latérale de la bibliothèque de modèles",
|
||||
"Toggle Node Library Sidebar": "Afficher/Masquer la barre latérale de la bibliothèque de nœuds",
|
||||
"Toggle Queue Sidebar": "Afficher/Masquer la barre latérale de la file d’attente",
|
||||
"Toggle Model Library Sidebar": "Basculer la barre latérale de la bibliothèque de modèles",
|
||||
"Toggle Node Library Sidebar": "Basculer la barre latérale de la bibliothèque de nœuds",
|
||||
"Toggle Queue Sidebar": "Basculer la barre latérale de la file d'attente",
|
||||
"Toggle Search Box": "Basculer la boîte de recherche",
|
||||
"Toggle Terminal Bottom Panel": "Basculer le panneau inférieur du terminal",
|
||||
"Toggle Theme (Dark/Light)": "Basculer le thème (Sombre/Clair)",
|
||||
"Toggle Workflows Sidebar": "Afficher/Masquer la barre latérale des workflows",
|
||||
"Toggle Workflows Sidebar": "Basculer la barre latérale des flux de travail",
|
||||
"Toggle the Custom Nodes Manager": "Basculer le gestionnaire de nœuds personnalisés",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "Basculer la barre de progression du gestionnaire de nœuds personnalisés",
|
||||
"Undo": "Annuler",
|
||||
@@ -1118,7 +1099,6 @@
|
||||
"Node Search Box": "Boîte de Recherche de Nœud",
|
||||
"Node Widget": "Widget de Nœud",
|
||||
"NodeLibrary": "Bibliothèque de Nœuds",
|
||||
"Notification Preferences": "Préférences de notification",
|
||||
"Pointer": "Pointeur",
|
||||
"Queue": "File d'Attente",
|
||||
"QueueButton": "Bouton de File d'Attente",
|
||||
@@ -1177,6 +1157,7 @@
|
||||
},
|
||||
"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 ?",
|
||||
@@ -1218,44 +1199,34 @@
|
||||
"Flux": "Flux",
|
||||
"Image": "Image",
|
||||
"Image API": "API d'image",
|
||||
"LLM API": "API LLM",
|
||||
"Upscaling": "Mise à l'échelle",
|
||||
"Video": "Vidéo",
|
||||
"Video API": "API vidéo"
|
||||
},
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D",
|
||||
"3d_hunyuan3d_multiview_to_model": "Hunyuan3D Multivue",
|
||||
"3d_hunyuan3d_multiview_to_model_turbo": "Hunyuan3D Turbo",
|
||||
"hunyuan-3d-multiview-elf": "Hunyuan3D Multivue",
|
||||
"hunyuan-3d-turbo": "Hunyuan3D Turbo",
|
||||
"hunyuan3d-non-multiview-train": "Hunyuan3D",
|
||||
"stable_zero123_example": "Stable Zero123"
|
||||
},
|
||||
"3D API": {
|
||||
"api_rodin_image_to_model": "Rodin : Image vers Modèle",
|
||||
"api_rodin_multiview_to_model": "Rodin : Multivue vers Modèle",
|
||||
"api_tripo_image_to_model": "Tripo : Image vers Modèle",
|
||||
"api_tripo_multiview_to_model": "Tripo : Multivue vers Modèle",
|
||||
"api_tripo_text_to_model": "Tripo : Texte vers Modèle"
|
||||
},
|
||||
"Area Composition": {
|
||||
"area_composition": "Composition de Zone",
|
||||
"area_composition_reversed": "Composition de Zone Inversée",
|
||||
"area_composition_square_area_for_subject": "Composition de Zone Carrée pour le Sujet"
|
||||
},
|
||||
"Audio": {
|
||||
"audio_ace_step_1_m2m_editing": "ACE Step v1 Édition M2M",
|
||||
"audio_ace_step_1_t2a_instrumentals": "ACE-Step v1 Texte vers Musique Instrumentale",
|
||||
"audio_ace_step_1_t2a_song": "ACE Step v1 Texte vers Chanson",
|
||||
"audio_stable_audio_example": "Stable Audio"
|
||||
"stable_audio_example": "Stable Audio"
|
||||
},
|
||||
"Basics": {
|
||||
"default": "Génération d'Image",
|
||||
"embedding_example": "Intégration",
|
||||
"gligen_textbox_example": "Boîte de Texte Gligen",
|
||||
"image2image": "Image à Image",
|
||||
"inpain_model_outpainting": "Modèle Inpaint Outpainting",
|
||||
"inpaint_example": "Inpaint",
|
||||
"inpaint_model_outpainting": "Outpainting",
|
||||
"lora": "LoRA",
|
||||
"lora_multiple": "LoRA Multiple"
|
||||
"lora": "Lora",
|
||||
"lora_multiple": "Lora Multiple"
|
||||
},
|
||||
"ControlNet": {
|
||||
"2_pass_pose_worship": "2 Passes Pose Worship",
|
||||
@@ -1266,27 +1237,17 @@
|
||||
},
|
||||
"Flux": {
|
||||
"flux_canny_model_example": "Flux Canny Model",
|
||||
"flux_depth_lora_example": "Flux Depth LoRA",
|
||||
"flux_dev_checkpoint_example": "Flux Dev fp8",
|
||||
"flux_dev_full_text_to_image": "Flux Dev texte vers image complet",
|
||||
"flux_depth_lora_example": "Flux Depth Lora",
|
||||
"flux_dev_checkpoint_example": "Flux Dev",
|
||||
"flux_fill_inpaint_example": "Flux Inpaint",
|
||||
"flux_fill_outpaint_example": "Flux Outpaint",
|
||||
"flux_kontext_dev_basic": "Flux Kontext Dev (Basique)",
|
||||
"flux_kontext_dev_grouped": "Flux Kontext Dev (Groupé)",
|
||||
"flux_redux_model_example": "Flux Redux Model",
|
||||
"flux_schnell": "Flux Schnell fp8",
|
||||
"flux_schnell_full_text_to_image": "Flux Schnell texte vers image complet"
|
||||
"flux_schnell": "Flux Schnell"
|
||||
},
|
||||
"Image": {
|
||||
"hidream_e1_full": "HiDream E1 Complet",
|
||||
"hidream_i1_dev": "HiDream I1 Dev",
|
||||
"hidream_i1_fast": "HiDream I1 Rapide",
|
||||
"hidream_i1_full": "HiDream I1 Complet",
|
||||
"image_chroma_text_to_image": "Chroma texte vers image",
|
||||
"image_cosmos_predict2_2B_t2i": "Cosmos Predict2 2B T2I",
|
||||
"image_lotus_depth_v1_1": "Lotus Depth",
|
||||
"image_omnigen2_image_edit": "OmniGen2 Édition d'Image",
|
||||
"image_omnigen2_t2i": "OmniGen2 Texte vers Image",
|
||||
"sd3_5_large_blur": "SD3.5 Grand Flou",
|
||||
"sd3_5_large_canny_controlnet_example": "SD3.5 Grand Canny ControlNet",
|
||||
"sd3_5_large_depth": "SD3.5 Grande Profondeur",
|
||||
@@ -1298,33 +1259,21 @@
|
||||
"sdxlturbo_example": "SDXL Turbo"
|
||||
},
|
||||
"Image API": {
|
||||
"api_bfl_flux_1_kontext_max_image": "BFL Flux.1 Kontext Max",
|
||||
"api_bfl_flux_1_kontext_multiple_images_input": "BFL Flux.1 Kontext Entrée Multi-Images",
|
||||
"api_bfl_flux_1_kontext_pro_image": "BFL Flux.1 Kontext Pro",
|
||||
"api_bfl_flux_pro_t2i": "BFL Flux[Pro] : Texte vers Image",
|
||||
"api_ideogram_v3_t2i": "Ideogram V3 : Texte vers Image",
|
||||
"api_luma_photon_i2i": "Luma Photon : Image vers Image",
|
||||
"api_luma_photon_style_ref": "Luma Photon : Référence de Style",
|
||||
"api_openai_dall_e_2_inpaint": "OpenAI : Dall-E 2 Inpainting",
|
||||
"api_openai_dall_e_2_t2i": "OpenAI : Dall-E 2 Texte vers Image",
|
||||
"api_openai_dall_e_3_t2i": "OpenAI : Dall-E 3 Texte vers Image",
|
||||
"api_openai_image_1_i2i": "OpenAI : GPT-Image-1 Image vers Image",
|
||||
"api_openai_image_1_inpaint": "OpenAI : GPT-Image-1 Inpainting",
|
||||
"api_openai_image_1_multi_inputs": "OpenAI : GPT-Image-1 Entrées Multiples",
|
||||
"api_openai_image_1_t2i": "OpenAI : GPT-Image-1 Texte vers Image",
|
||||
"api_recraft_image_gen_with_color_control": "Recraft : Génération d’Image avec Contrôle des Couleurs",
|
||||
"api_recraft_image_gen_with_style_control": "Recraft : Génération d’Image avec Contrôle du Style",
|
||||
"api_recraft_vector_gen": "Recraft : Génération de Vecteur",
|
||||
"api_runway_reference_to_image": "Runway : Référence vers Image",
|
||||
"api_runway_text_to_image": "Runway : Texte vers Image",
|
||||
"api_stability_ai_i2i": "Stability AI : Image vers Image",
|
||||
"api_stability_ai_sd3_5_i2i": "Stability AI : SD3.5 Image vers Image",
|
||||
"api_stability_ai_sd3_5_t2i": "Stability AI : SD3.5 Texte vers Image",
|
||||
"api_stability_ai_stable_image_ultra_t2i": "Stability AI : Stable Image Ultra Texte vers Image"
|
||||
},
|
||||
"LLM API": {
|
||||
"api_google_gemini": "Google Gemini : Chat",
|
||||
"api_openai_chat": "OpenAI : Chat"
|
||||
"api-openai-dall-e-2-inpaint": "Dall-E 2 Inpainting",
|
||||
"api-openai-dall-e-2-t2i": "Dall-E 2 Texte vers Image",
|
||||
"api-openai-dall-e-3-t2i": "Dall-E 3 Texte vers Image",
|
||||
"api_bfl_flux_pro_t2i": "BFL Flux 1.1[pro] Ultra Texte vers Image",
|
||||
"api_ideogram_v3_t2i": "Ideogram V3 Texte vers Image",
|
||||
"api_luma_photon_i2i": "Luma Photon Image vers Image",
|
||||
"api_luma_photon_style_ref": "Luma Photon Référence de Style",
|
||||
"api_openai_image_1_i2i": "OpenAI Image-1 Image vers Image",
|
||||
"api_openai_image_1_inpaint": "OpenAI Image-1 Inpainting",
|
||||
"api_openai_image_1_multi_inputs": "OpenAI Image-1 Entrées Multiples",
|
||||
"api_openai_image_1_t2i": "OpenAI Image-1 Texte vers Image",
|
||||
"api_recraft_image_gen_with_color_control": "Recraft Génération d’Image avec Contrôle des Couleurs",
|
||||
"api_recraft_image_gen_with_style_control": "Recraft Génération d’Image avec Contrôle du Style",
|
||||
"api_recraft_vector_gen": "Recraft Génération de Vecteur",
|
||||
"api_stability_sd3_t2i": "Stability AI Stable Image Ultra Texte vers Image"
|
||||
},
|
||||
"Upscaling": {
|
||||
"esrgan_example": "ESRGAN",
|
||||
@@ -1333,198 +1282,127 @@
|
||||
"latent_upscale_different_prompt_model": "Modèle d'Agrandissement Latent Différent Prompt"
|
||||
},
|
||||
"Video": {
|
||||
"hunyuan_video_text_to_video": "Hunyuan Texte vers Vidéo",
|
||||
"image_to_video": "SVD Image vers Vidéo",
|
||||
"image_to_video_wan": "Wan 2.1 Image vers Vidéo",
|
||||
"ltxv_image_to_video": "LTXV Image vers Vidéo",
|
||||
"ltxv_text_to_video": "LTXV Texte vers Vidéo",
|
||||
"mochi_text_to_video_example": "Mochi Texte vers Vidéo",
|
||||
"text_to_video_wan": "Wan 2.1 Texte vers Vidéo",
|
||||
"txt_to_image_to_video": "SVD Texte vers Image vers Vidéo",
|
||||
"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 Référence vers Vidéo",
|
||||
"video_wan_vace_14B_t2v": "Wan VACE Texte vers Vidéo",
|
||||
"video_wan_vace_14B_v2v": "Wan VACE Contrôle Vidéo",
|
||||
"video_wan_vace_flf2v": "Wan VACE Premier-Dernier Frame",
|
||||
"video_wan_vace_inpainting": "Wan VACE Inpainting",
|
||||
"video_wan_vace_outpainting": "Wan VACE Outpainting",
|
||||
"wan2_1_flf2v_720_f16": "Wan 2.1 FLF2V 720p F16",
|
||||
"hunyuan_video_text_to_video": "Texte à Vidéo Hunyuan",
|
||||
"image_to_video": "Image à Vidéo",
|
||||
"image_to_video_wan": "Wan 2.1 Image à Vidéo",
|
||||
"ltxv_image_to_video": "LTXV Image à Vidéo",
|
||||
"ltxv_text_to_video": "LTXV Texte à Vidéo",
|
||||
"mochi_text_to_video_example": "Exemple de Texte à Vidéo Mochi",
|
||||
"text_to_video_wan": "Wan 2.1 Texte à Vidéo",
|
||||
"txt_to_image_to_video": "Texte à Image à Vidéo",
|
||||
"wan2_1_fun_control": "Wan 2.1 ControlNet",
|
||||
"wan2_1_fun_inp": "Wan 2.1 Inpainting"
|
||||
},
|
||||
"Video API": {
|
||||
"api_hailuo_minimax_i2v": "MiniMax : Image vers Vidéo",
|
||||
"api_hailuo_minimax_t2v": "MiniMax : Texte vers Vidéo",
|
||||
"api_kling_effects": "Kling : Effets Vidéo",
|
||||
"api_kling_flf": "Kling : FLF2V",
|
||||
"api_kling_i2v": "Kling : Image vers Vidéo",
|
||||
"api_luma_i2v": "Luma : Image vers Vidéo",
|
||||
"api_luma_t2v": "Luma : Texte vers Vidéo",
|
||||
"api_moonvalley_image_to_video": "Moonvalley : Image vers Vidéo",
|
||||
"api_moonvalley_text_to_video": "Moonvalley : Texte vers Vidéo",
|
||||
"api_pika_i2v": "Pika : Image vers Vidéo",
|
||||
"api_hailuo_minimax_i2v": "MiniMax Image vers Vidéo",
|
||||
"api_kling_i2v": "Kling Image vers Vidéo",
|
||||
"api_luma_i2v": "Luma Image vers Vidéo",
|
||||
"api_pika_scene": "Pika Scènes : Images vers Vidéo",
|
||||
"api_pixverse_i2v": "PixVerse : Image vers Vidéo",
|
||||
"api_pixverse_t2v": "PixVerse : Texte vers Vidéo",
|
||||
"api_pixverse_template_i2v": "PixVerse Templates : Image vers Vidéo",
|
||||
"api_runway_first_last_frame": "Runway : Premier Dernier Frame vers Vidéo",
|
||||
"api_runway_gen3a_turbo_image_to_video": "Runway : Gen3a Turbo Image vers Vidéo",
|
||||
"api_runway_gen4_turo_image_to_video": "Runway : Gen4 Turbo Image vers Vidéo",
|
||||
"api_veo2_i2v": "Veo2 : Image vers Vidéo"
|
||||
"api_pixverse_t2v": "PixVerse Texte vers Vidéo",
|
||||
"api_pixverse_template_i2v": "PixVerse Template Effects: Image vers Vidéo",
|
||||
"api_veo2_i2v": "Veo2 Image vers Vidéo"
|
||||
}
|
||||
},
|
||||
"templateDescription": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Générez des modèles 3D à partir d'images uniques avec Hunyuan3D 2.0.",
|
||||
"3d_hunyuan3d_multiview_to_model": "Générez des modèles 3D à partir de plusieurs vues avec Hunyuan3D 2.0 MV.",
|
||||
"3d_hunyuan3d_multiview_to_model_turbo": "Générez des modèles 3D à partir de plusieurs vues avec Hunyuan3D 2.0 MV Turbo.",
|
||||
"stable_zero123_example": "Générez des vues 3D à partir d'images uniques avec Stable Zero123."
|
||||
},
|
||||
"3D API": {
|
||||
"api_rodin_image_to_model": "Générez des modèles 3D détaillés à partir d'une seule photo avec Rodin AI.",
|
||||
"api_rodin_multiview_to_model": "Sculptez des modèles 3D complets à partir de plusieurs vues avec Rodin.",
|
||||
"api_tripo_image_to_model": "Générez des assets 3D professionnels à partir d'images 2D avec Tripo.",
|
||||
"api_tripo_multiview_to_model": "Construisez des modèles 3D à partir de multiples angles avec le scanner avancé de Tripo.",
|
||||
"api_tripo_text_to_model": "Créez des objets 3D à partir de descriptions textuelles avec Tripo."
|
||||
"hunyuan-3d-multiview-elf": "Utilisez Hunyuan3D 2mv pour générer des modèles à partir de plusieurs vues.",
|
||||
"hunyuan-3d-turbo": "Utilisez Hunyuan3D 2mv turbo pour générer des modèles à partir de plusieurs vues.",
|
||||
"hunyuan3d-non-multiview-train": "Utilisez Hunyuan3D 2.0 pour générer des modèles à partir d'une seule vue.",
|
||||
"stable_zero123_example": "Générez des vues 3D à partir d'images uniques."
|
||||
},
|
||||
"Area Composition": {
|
||||
"area_composition": "Générez des images en contrôlant la composition avec des zones définies.",
|
||||
"area_composition_square_area_for_subject": "Générez des images avec un placement cohérent du sujet grâce à la composition de zones."
|
||||
"area_composition": "Contrôlez la composition d'image avec des zones.",
|
||||
"area_composition_reversed": "Inversez le workflow de composition de zones.",
|
||||
"area_composition_square_area_for_subject": "Créez un placement cohérent du sujet."
|
||||
},
|
||||
"Audio": {
|
||||
"audio_ace_step_1_m2m_editing": "Éditez des chansons existantes pour changer le style et les paroles avec ACE-Step v1 M2M.",
|
||||
"audio_ace_step_1_t2a_instrumentals": "Générez de la musique instrumentale à partir de texte avec ACE-Step v1.",
|
||||
"audio_ace_step_1_t2a_song": "Générez des chansons avec voix à partir de texte avec ACE-Step v1, supportant plusieurs langues et la personnalisation du style.",
|
||||
"audio_stable_audio_example": "Générez de l'audio à partir de descriptions textuelles avec Stable Audio."
|
||||
"stable_audio_example": "Générez de l'audio à partir de descriptions textuelles."
|
||||
},
|
||||
"Basics": {
|
||||
"default": "Générez des images à partir de descriptions textuelles.",
|
||||
"embedding_example": "Générez des images avec inversion textuelle pour des styles cohérents.",
|
||||
"gligen_textbox_example": "Générez des images avec un placement précis des objets grâce à des zones de texte.",
|
||||
"embedding_example": "Utilisez l'inversion textuelle pour des styles cohérents.",
|
||||
"gligen_textbox_example": "Spécifiez l'emplacement et la taille des objets.",
|
||||
"image2image": "Transformez des images existantes à l'aide de prompts textuels.",
|
||||
"inpain_model_outpainting": "Étendez les images au-delà de leurs limites d'origine.",
|
||||
"inpaint_example": "Modifiez de façon transparente des parties spécifiques d'une image.",
|
||||
"inpaint_model_outpainting": "Étendez les images au-delà de leurs limites d'origine.",
|
||||
"lora": "Générez des images avec des modèles LoRA pour des styles ou sujets spécialisés.",
|
||||
"lora_multiple": "Générez des images en combinant plusieurs modèles LoRA."
|
||||
"lora": "Appliquez des modèles LoRA pour des styles ou sujets spécialisés.",
|
||||
"lora_multiple": "Combinez plusieurs modèles LoRA pour des résultats uniques."
|
||||
},
|
||||
"ControlNet": {
|
||||
"2_pass_pose_worship": "Générez des images guidées par des références de pose avec ControlNet.",
|
||||
"controlnet_example": "Générez des images guidées par des images de gribouillage avec ControlNet.",
|
||||
"depth_controlnet": "Générez des images guidées par la profondeur avec ControlNet.",
|
||||
"depth_t2i_adapter": "Générez des images guidées par la profondeur avec l'adaptateur T2I.",
|
||||
"mixing_controlnets": "Générez des images en combinant plusieurs modèles ControlNet."
|
||||
"2_pass_pose_worship": "Générez des images à partir de références de pose.",
|
||||
"controlnet_example": "Contrôlez la génération d'image avec des images de référence.",
|
||||
"depth_controlnet": "Créez des images avec une génération sensible à la profondeur.",
|
||||
"depth_t2i_adapter": "Générez rapidement des images sensibles à la profondeur avec un adaptateur T2I.",
|
||||
"mixing_controlnets": "Combinez plusieurs modèles ControlNet ensemble."
|
||||
},
|
||||
"Flux": {
|
||||
"flux_canny_model_example": "Générez des images guidées par détection de contours avec Flux Canny.",
|
||||
"flux_depth_lora_example": "Générez des images guidées par la profondeur avec Flux LoRA.",
|
||||
"flux_dev_checkpoint_example": "Générez des images avec la version quantifiée fp8 de Flux Dev. Idéal pour les appareils à faible VRAM, nécessite un seul fichier modèle, mais la qualité d'image est légèrement inférieure à la version complète.",
|
||||
"flux_dev_full_text_to_image": "Générez des images de haute qualité avec la version complète de Flux Dev. Nécessite plus de VRAM et plusieurs fichiers modèles, mais offre la meilleure fidélité au prompt et la meilleure qualité d'image.",
|
||||
"flux_fill_inpaint_example": "Complétez les parties manquantes d'une image avec Flux inpainting.",
|
||||
"flux_fill_outpaint_example": "Étendez les images au-delà des limites avec Flux outpainting.",
|
||||
"flux_kontext_dev_basic": "Éditez des images avec Flux Kontext (tous les nœuds visibles), parfait pour apprendre le workflow.",
|
||||
"flux_kontext_dev_grouped": "Version simplifiée de Flux Kontext avec des nœuds groupés pour un espace de travail plus propre.",
|
||||
"flux_redux_model_example": "Générez des images en transférant le style d'images de référence avec Flux Redux.",
|
||||
"flux_schnell": "Générez rapidement des images avec la version quantifiée fp8 de Flux Schnell. Parfait pour le matériel bas de gamme, nécessite seulement 4 étapes.",
|
||||
"flux_schnell_full_text_to_image": "Générez rapidement des images avec la version complète de Flux Schnell. Licence Apache2.0, seulement 4 étapes tout en maintenant une bonne qualité."
|
||||
"flux_canny_model_example": "Générez des images à partir de la détection de contours.",
|
||||
"flux_depth_lora_example": "Créez des images avec LoRA sensible à la profondeur.",
|
||||
"flux_dev_checkpoint_example": "Créez des images avec les modèles de développement Flux.",
|
||||
"flux_fill_inpaint_example": "Complétez les parties manquantes d'une image.",
|
||||
"flux_fill_outpaint_example": "Étendez les images avec l'outpainting Flux.",
|
||||
"flux_redux_model_example": "Transférez le style d'une image de référence pour guider la génération d'image avec Flux.",
|
||||
"flux_schnell": "Générez des images rapidement avec Flux Schnell."
|
||||
},
|
||||
"Image": {
|
||||
"hidream_e1_full": "HiDream E1 - Modèle professionnel d'édition d'image par langage naturel.",
|
||||
"hidream_i1_dev": "HiDream I1 Dev - Version équilibrée avec 28 étapes d'inférence, adaptée au matériel intermédiaire.",
|
||||
"hidream_i1_fast": "HiDream I1 Fast - Version légère avec 16 étapes, idéale pour des aperçus rapides sur du matériel bas de gamme.",
|
||||
"hidream_i1_full": "HiDream I1 Full - Version complète avec 50 étapes pour une qualité maximale.",
|
||||
"image_chroma_text_to_image": "Chroma est une version modifiée de Flux avec quelques changements d'architecture.",
|
||||
"image_cosmos_predict2_2B_t2i": "Générez des images avec Cosmos-Predict2 2B T2I, pour une génération physique précise, haute fidélité et riche en détails.",
|
||||
"image_lotus_depth_v1_1": "Exécutez Lotus Depth dans ComfyUI pour une estimation de profondeur monoculaire efficace et détaillée.",
|
||||
"image_omnigen2_image_edit": "Éditez des images avec des instructions en langage naturel grâce aux capacités avancées d'édition d'image et de texte d'OmniGen2.",
|
||||
"image_omnigen2_t2i": "Générez des images de haute qualité à partir de texte avec le modèle multimodal unifié 7B d'OmniGen2 et une architecture à double voie.",
|
||||
"sd3_5_large_blur": "Générez des images guidées par des images de référence floues avec SD 3.5.",
|
||||
"sd3_5_large_canny_controlnet_example": "Générez des images guidées par détection de contours avec SD 3.5 Canny ControlNet.",
|
||||
"sd3_5_large_depth": "Générez des images guidées par la profondeur avec SD 3.5.",
|
||||
"hidream_e1_full": "Modifiez des images avec HiDream E1.",
|
||||
"hidream_i1_dev": "Générez des images avec HiDream I1 Dev.",
|
||||
"hidream_i1_fast": "Générez rapidement des images avec HiDream I1.",
|
||||
"hidream_i1_full": "Générez des images avec HiDream I1.",
|
||||
"sd3_5_large_blur": "Générez des images à partir d'images de référence floues avec SD 3.5.",
|
||||
"sd3_5_large_canny_controlnet_example": "Utilisez la détection de contours pour guider la génération d'images avec SD 3.5.",
|
||||
"sd3_5_large_depth": "Créez des images sensibles à la profondeur avec SD 3.5.",
|
||||
"sd3_5_simple_example": "Générez des images avec SD 3.5.",
|
||||
"sdxl_refiner_prompt_example": "Améliorez les images SDXL avec des modèles de raffinement.",
|
||||
"sdxl_revision_text_prompts": "Générez des images en transférant des concepts d'images de référence avec SDXL Revision.",
|
||||
"sdxl_revision_zero_positive": "Générez des images en utilisant à la fois des prompts textuels et des images de référence avec SDXL Revision.",
|
||||
"sdxl_simple_example": "Générez des images de haute qualité avec SDXL.",
|
||||
"sdxl_refiner_prompt_example": "Améliorez les résultats SDXL avec des refineurs.",
|
||||
"sdxl_revision_text_prompts": "Transférez des concepts à partir d'images de référence pour guider la génération d'images avec SDXL.",
|
||||
"sdxl_revision_zero_positive": "Ajoutez des prompts textuels en plus des images de référence pour guider la génération d'images avec SDXL.",
|
||||
"sdxl_simple_example": "Créez des images de haute qualité avec SDXL.",
|
||||
"sdxlturbo_example": "Générez des images en une seule étape avec SDXL Turbo."
|
||||
},
|
||||
"Image API": {
|
||||
"api_bfl_flux_1_kontext_max_image": "Éditez des images avec Flux.1 Kontext max image.",
|
||||
"api_bfl_flux_1_kontext_multiple_images_input": "Entrez plusieurs images et éditez-les avec Flux.1 Kontext.",
|
||||
"api_bfl_flux_1_kontext_pro_image": "Éditez des images avec Flux.1 Kontext pro image.",
|
||||
"api_bfl_flux_pro_t2i": "Générez des images avec un excellent suivi de prompt et une qualité visuelle avec FLUX.1 Pro.",
|
||||
"api_ideogram_v3_t2i": "Générez des images de qualité professionnelle avec un excellent alignement prompt-image, du photoréalisme et du rendu de texte avec Ideogram V3.",
|
||||
"api-openai-dall-e-2-inpaint": "Utilisez l'API Dall-E 2 pour faire de l'inpainting sur des images.",
|
||||
"api-openai-dall-e-2-t2i": "Utilisez l'API Dall-E 2 pour générer des images à partir de descriptions textuelles.",
|
||||
"api-openai-dall-e-3-t2i": "Utilisez l'API Dall-E 3 pour générer des images à partir de descriptions textuelles.",
|
||||
"api_bfl_flux_pro_t2i": "Créez des images avec FLUX.1 [pro] pour un excellent suivi des prompts, une qualité visuelle, des détails d'image et une grande diversité de sorties.",
|
||||
"api_ideogram_v3_t2i": "Générez des images avec un alignement prompt-image de haute qualité, du photoréalisme et du rendu de texte. Créez des logos professionnels, affiches promotionnelles, concepts de pages d'accueil, photographies de produits et plus. Composez facilement des compositions spatiales sophistiquées avec des arrière-plans complexes, un éclairage et des couleurs précis et nuancés, et des détails environnementaux réalistes.",
|
||||
"api_luma_photon_i2i": "Guidez la génération d'image en combinant images et prompt.",
|
||||
"api_luma_photon_style_ref": "Générez des images en combinant des références de style avec un contrôle précis avec Luma Photon.",
|
||||
"api_openai_dall_e_2_inpaint": "Éditez des images avec inpainting avec l'API OpenAI Dall-E 2.",
|
||||
"api_openai_dall_e_2_t2i": "Générez des images à partir de texte avec l'API OpenAI Dall-E 2.",
|
||||
"api_openai_dall_e_3_t2i": "Générez des images à partir de texte avec l'API OpenAI Dall-E 3.",
|
||||
"api_openai_image_1_i2i": "Générez des images à partir d'images avec l'API OpenAI GPT Image 1.",
|
||||
"api_openai_image_1_inpaint": "Éditez des images avec inpainting avec l'API OpenAI GPT Image 1.",
|
||||
"api_openai_image_1_multi_inputs": "Générez des images à partir de plusieurs entrées avec l'API OpenAI GPT Image 1.",
|
||||
"api_openai_image_1_t2i": "Générez des images à partir de texte avec l'API OpenAI GPT Image 1.",
|
||||
"api_recraft_image_gen_with_color_control": "Générez des images avec des palettes de couleurs personnalisées et des visuels de marque avec Recraft.",
|
||||
"api_recraft_image_gen_with_style_control": "Contrôlez le style avec des exemples visuels, alignez le positionnement et affinez les objets. Stockez et partagez des styles pour une cohérence de marque.",
|
||||
"api_recraft_vector_gen": "Générez des images vectorielles de haute qualité à partir de texte avec le générateur vectoriel IA de Recraft.",
|
||||
"api_runway_reference_to_image": "Générez de nouvelles images basées sur des styles et des compositions de référence avec Runway.",
|
||||
"api_runway_text_to_image": "Générez des images de haute qualité à partir de texte avec le modèle IA de Runway.",
|
||||
"api_stability_ai_i2i": "Transformez des images avec une génération de haute qualité avec Stability AI, idéal pour l'édition professionnelle et le transfert de style.",
|
||||
"api_stability_ai_sd3_5_i2i": "Générez des images de haute qualité avec une excellente fidélité au prompt. Parfait pour un usage professionnel à 1 mégapixel.",
|
||||
"api_stability_ai_sd3_5_t2i": "Générez des images de haute qualité avec une excellente fidélité au prompt. Parfait pour un usage professionnel à 1 mégapixel.",
|
||||
"api_stability_ai_stable_image_ultra_t2i": "Générez des images de haute qualité avec une excellente fidélité au prompt. Parfait pour un usage professionnel à 1 mégapixel."
|
||||
},
|
||||
"LLM API": {
|
||||
"api_google_gemini": "Découvrez l'IA multimodale de Google avec les capacités de raisonnement de Gemini.",
|
||||
"api_openai_chat": "Discutez avec les modèles de langage avancés d'OpenAI pour des conversations intelligentes."
|
||||
"api_luma_photon_style_ref": "Appliquez et mélangez des références de style avec un contrôle précis. Luma Photon capture l'essence de chaque image de référence, vous permettant de combiner des éléments visuels distincts tout en maintenant une qualité professionnelle.",
|
||||
"api_openai_image_1_i2i": "Utilisez l'API GPT Image 1 pour générer des images à partir d'images.",
|
||||
"api_openai_image_1_inpaint": "Utilisez l'API GPT Image 1 pour faire de l'inpainting sur des images.",
|
||||
"api_openai_image_1_multi_inputs": "Utilisez l'API GPT Image 1 avec plusieurs entrées pour générer des images.",
|
||||
"api_openai_image_1_t2i": "Utilisez l'API GPT Image 1 pour générer des images à partir de descriptions textuelles.",
|
||||
"api_recraft_image_gen_with_color_control": "Créez une palette personnalisée à réutiliser pour plusieurs images ou choisissez les couleurs pour chaque photo. Adaptez la palette de couleurs de votre marque et créez des visuels qui vous ressemblent.",
|
||||
"api_recraft_image_gen_with_style_control": "Contrôlez le style avec des exemples visuels, alignez le positionnement et affinez les objets. Stockez et partagez des styles pour une cohérence parfaite de la marque.",
|
||||
"api_recraft_vector_gen": "Passez d'un prompt textuel à une image vectorielle avec le générateur vectoriel IA de Recraft. Produisez des illustrations vectorielles de la meilleure qualité pour des logos, affiches, icônes, publicités, bannières et maquettes. Perfectionnez vos designs avec des fichiers SVG nets et de haute qualité. Créez des illustrations vectorielles de marque pour votre application ou site web en quelques secondes.",
|
||||
"api_stability_sd3_t2i": "Générez des images de haute qualité avec une excellente fidélité au prompt. Parfait pour les cas d'usage professionnels en résolution 1 mégapixel."
|
||||
},
|
||||
"Upscaling": {
|
||||
"esrgan_example": "Agrandissez les images avec des modèles ESRGAN pour améliorer la qualité.",
|
||||
"hiresfix_esrgan_workflow": "Agrandissez les images avec des modèles ESRGAN lors des étapes intermédiaires.",
|
||||
"hiresfix_latent_workflow": "Agrandissez les images en améliorant la qualité dans l'espace latent.",
|
||||
"latent_upscale_different_prompt_model": "Agrandissez les images en changeant les prompts entre les passes."
|
||||
"esrgan_example": "Utilisez des modèles d'upscaling pour améliorer la qualité d'image.",
|
||||
"hiresfix_esrgan_workflow": "Utilisez des modèles d'upscaling lors des étapes intermédiaires.",
|
||||
"hiresfix_latent_workflow": "Améliorez la qualité d'image dans l'espace latent.",
|
||||
"latent_upscale_different_prompt_model": "Upscalez et changez le prompt à chaque passage."
|
||||
},
|
||||
"Video": {
|
||||
"hunyuan_video_text_to_video": "Générez des vidéos à partir de texte avec le modèle Hunyuan.",
|
||||
"image_to_video": "Générez des vidéos à partir d'images fixes.",
|
||||
"image_to_video_wan": "Générez des vidéos à partir d'images avec Wan 2.1.",
|
||||
"ltxv_image_to_video": "Générez des vidéos à partir d'images fixes.",
|
||||
"ltxv_text_to_video": "Générez des vidéos à partir de texte.",
|
||||
"mochi_text_to_video_example": "Générez des vidéos à partir de texte avec le modèle Mochi.",
|
||||
"text_to_video_wan": "Générez des vidéos à partir de texte avec Wan 2.1.",
|
||||
"txt_to_image_to_video": "Générez des vidéos en créant d'abord des images à partir de texte.",
|
||||
"video_cosmos_predict2_2B_video2world_480p_16fps": "Générez des vidéos avec Cosmos-Predict2 2B Video2World, pour des simulations physiques précises, haute fidélité et cohérentes.",
|
||||
"video_wan2_1_fun_camera_v1_1_14B": "Générez des vidéos de haute qualité avec un contrôle avancé de la caméra avec le modèle complet 14B.",
|
||||
"video_wan2_1_fun_camera_v1_1_1_3B": "Générez des vidéos dynamiques avec des mouvements de caméra cinématographiques avec Wan 2.1 Fun Camera 1.3B.",
|
||||
"video_wan_vace_14B_ref2v": "Créez des vidéos qui correspondent au style et au contenu d'une image de référence.",
|
||||
"video_wan_vace_14B_t2v": "Transformez des descriptions textuelles en vidéos de haute qualité. Prend en charge 480p et 720p avec le modèle VACE-14B.",
|
||||
"video_wan_vace_14B_v2v": "Générez des vidéos en contrôlant des vidéos d'entrée et des images de référence avec Wan VACE.",
|
||||
"video_wan_vace_flf2v": "Générez des transitions vidéo fluides en définissant les images de début et de fin. Prend en charge les séquences d'images personnalisées.",
|
||||
"video_wan_vace_inpainting": "Éditez des régions spécifiques dans des vidéos tout en préservant le contenu environnant.",
|
||||
"video_wan_vace_outpainting": "Générez des vidéos étendues en agrandissant la taille avec Wan VACE outpainting.",
|
||||
"wan2_1_flf2v_720_f16": "Générez des vidéos en contrôlant la première et la dernière image avec Wan 2.1 FLF2V.",
|
||||
"wan2_1_fun_control": "Générez des vidéos guidées par la pose, la profondeur et les contours avec Wan 2.1 ControlNet.",
|
||||
"wan2_1_fun_inp": "Générez des vidéos à partir d'images de début et de fin avec Wan 2.1 inpainting."
|
||||
"hunyuan_video_text_to_video": "Générez des vidéos avec le modèle Hunyuan.",
|
||||
"image_to_video": "Transformez des images en vidéos animées.",
|
||||
"image_to_video_wan": "Générez rapidement des vidéos à partir d'images.",
|
||||
"ltxv_image_to_video": "Convertissez des images fixes en vidéos.",
|
||||
"ltxv_text_to_video": "Générez des vidéos à partir de descriptions textuelles.",
|
||||
"mochi_text_to_video_example": "Créez des vidéos avec le modèle Mochi.",
|
||||
"text_to_video_wan": "Générez rapidement des vidéos à partir de descriptions textuelles.",
|
||||
"txt_to_image_to_video": "Générez des images à partir de texte puis convertissez-les en vidéos.",
|
||||
"wan2_1_flf2v_720_f16": "Générez une vidéo en contrôlant la première et la dernière image.",
|
||||
"wan2_1_fun_control": "Guidez la génération vidéo avec le contrôle de pose, profondeur, contours et plus.",
|
||||
"wan2_1_fun_inp": "Créez des vidéos à partir d'images de début et de fin."
|
||||
},
|
||||
"Video API": {
|
||||
"api_hailuo_minimax_i2v": "Générez des vidéos raffinées à partir d'images et de texte avec intégration CGI avec MiniMax.",
|
||||
"api_hailuo_minimax_t2v": "Générez des vidéos de haute qualité directement à partir de texte. Découvrez les capacités avancées de MiniMax pour créer des récits visuels variés avec des effets CGI professionnels.",
|
||||
"api_kling_effects": "Générez des vidéos dynamiques en appliquant des effets visuels à des images avec Kling.",
|
||||
"api_kling_flf": "Générez des vidéos en contrôlant les premières et dernières images.",
|
||||
"api_kling_i2v": "Générez des vidéos avec une excellente fidélité au prompt pour les actions, expressions et mouvements de caméra avec Kling.",
|
||||
"api_hailuo_minimax_i2v": "Créez des vidéos raffinées à partir d'images et de texte, incluant l'intégration CGI et des effets photo tendance comme le hugging IA viral. Choisissez parmi une variété de styles et thèmes vidéo pour correspondre à votre vision créative.",
|
||||
"api_kling_i2v": "Créez des vidéos avec une excellente fidélité au prompt pour les actions, expressions et mouvements de caméra. Prend désormais en charge des prompts complexes avec des actions séquentielles pour vous permettre de diriger votre scène.",
|
||||
"api_luma_i2v": "Transformez des images statiques en animations magiques de haute qualité instantanément.",
|
||||
"api_luma_t2v": "Générez des vidéos de haute qualité à partir de prompts simples.",
|
||||
"api_moonvalley_image_to_video": "Générez des vidéos cinématographiques 1080p à partir d'une image avec un modèle entraîné uniquement sur des données sous licence.",
|
||||
"api_moonvalley_text_to_video": "Générez des vidéos cinématographiques 1080p à partir de texte avec un modèle entraîné uniquement sur des données sous licence.",
|
||||
"api_pika_i2v": "Générez des vidéos animées fluides à partir d'images fixes avec Pika AI.",
|
||||
"api_pika_scene": "Générez des vidéos qui incorporent plusieurs images d'entrée avec Pika Scenes.",
|
||||
"api_pixverse_i2v": "Générez des vidéos dynamiques à partir d'images fixes avec mouvement et effets avec PixVerse.",
|
||||
"api_pixverse_t2v": "Générez des vidéos avec une interprétation précise du prompt et une dynamique visuelle impressionnante.",
|
||||
"api_pixverse_template_i2v": "Générez des vidéos dynamiques à partir d'images fixes avec mouvement et effets avec PixVerse.",
|
||||
"api_runway_first_last_frame": "Générez des transitions vidéo fluides entre deux images clés avec la précision de Runway.",
|
||||
"api_runway_gen3a_turbo_image_to_video": "Générez des vidéos cinématographiques à partir d'images fixes avec Runway Gen3a Turbo.",
|
||||
"api_runway_gen4_turo_image_to_video": "Générez des vidéos dynamiques à partir d'images avec Runway Gen4 Turbo.",
|
||||
"api_veo2_i2v": "Générez des vidéos à partir d'images avec l'API Google Veo2."
|
||||
"api_pika_scene": "Utilisez plusieurs images comme ingrédients et générez des vidéos qui les intègrent toutes.",
|
||||
"api_pixverse_t2v": "Générez des vidéos avec une interprétation précise du prompt et des dynamiques vidéo impressionnantes.",
|
||||
"api_pixverse_template_i2v": "Transformez des images statiques en vidéos dynamiques avec mouvement et effets.",
|
||||
"api_veo2_i2v": "Utilisez l'API Google Veo2 pour générer des vidéos à partir d'images."
|
||||
}
|
||||
},
|
||||
"title": "Commencez avec un modèle"
|
||||
@@ -1605,8 +1483,7 @@
|
||||
"title": "Bienvenue sur ComfyUI"
|
||||
},
|
||||
"whatsNewPopup": {
|
||||
"learnMore": "En savoir plus",
|
||||
"noReleaseNotes": "Aucune note de version disponible."
|
||||
"learnMore": "En savoir plus"
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "Entrez le nom du fichier",
|
||||
|
||||
@@ -259,10 +259,6 @@
|
||||
"name": "Le snap met en évidence le nœud",
|
||||
"tooltip": "Lorsque vous faites glisser un lien sur un nœud avec une fente d'entrée viable, mettez en évidence le nœud"
|
||||
},
|
||||
"Comfy_Notification_ShowVersionUpdates": {
|
||||
"name": "Afficher les mises à jour de version",
|
||||
"tooltip": "Afficher les mises à jour pour les nouveaux modèles et les nouvelles fonctionnalités majeures."
|
||||
},
|
||||
"Comfy_Pointer_ClickBufferTime": {
|
||||
"name": "Délai de dérive du clic du pointeur",
|
||||
"tooltip": "Après avoir appuyé sur un bouton de pointeur, c'est le temps maximum (en millisecondes) que le mouvement du pointeur peut être ignoré.\n\nAide à prévenir que les objets soient déplacés involontairement si le pointeur est déplacé lors du clic."
|
||||
@@ -326,8 +322,11 @@
|
||||
"Bottom": "Bas",
|
||||
"Disabled": "Désactivé",
|
||||
"Top": "Haut"
|
||||
},
|
||||
"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"
|
||||
|
||||
@@ -158,12 +158,6 @@
|
||||
"Comfy_Manager_ToggleManagerProgressDialog": {
|
||||
"label": "プログレスダイアログの切り替え"
|
||||
},
|
||||
"Comfy_MaskEditor_BrushSize_Decrease": {
|
||||
"label": "マスクエディタでブラシサイズを縮小"
|
||||
},
|
||||
"Comfy_MaskEditor_BrushSize_Increase": {
|
||||
"label": "マスクエディタでブラシサイズを大きくする"
|
||||
},
|
||||
"Comfy_MaskEditor_OpenMaskEditor": {
|
||||
"label": "選択したノードのマスクエディタを開く"
|
||||
},
|
||||
|
||||
@@ -82,12 +82,6 @@
|
||||
"title": "アカウントを作成する"
|
||||
}
|
||||
},
|
||||
"breadcrumbsMenu": {
|
||||
"clearWorkflow": "ワークフローをクリア",
|
||||
"deleteWorkflow": "ワークフローを削除",
|
||||
"duplicate": "複製",
|
||||
"enterNewName": "新しい名前を入力"
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "キャンセル",
|
||||
"cancelEditTooltip": "編集をキャンセル",
|
||||
@@ -331,20 +325,17 @@
|
||||
"loadingPanel": "{panel} パネルを読み込み中...",
|
||||
"login": "ログイン",
|
||||
"logs": "ログ",
|
||||
"micPermissionDenied": "マイクの許可が拒否されました",
|
||||
"migrate": "移行する",
|
||||
"missing": "不足している",
|
||||
"name": "名前",
|
||||
"newFolder": "新しいフォルダー",
|
||||
"next": "次へ",
|
||||
"no": "いいえ",
|
||||
"noAudioRecorded": "音声が録音されていません",
|
||||
"noResultsFound": "結果が見つかりません",
|
||||
"noTasksFound": "タスクが見つかりません",
|
||||
"noTasksFoundMessage": "キューにタスクがありません。",
|
||||
"noWorkflowsFound": "ワークフローが見つかりません。",
|
||||
"nodes": "ノード",
|
||||
"nodesRunning": "ノードが実行中",
|
||||
"ok": "OK",
|
||||
"openNewIssue": "新しい問題を開く",
|
||||
"overwrite": "上書き",
|
||||
@@ -378,9 +369,7 @@
|
||||
"showReport": "レポートを表示",
|
||||
"sort": "並び替え",
|
||||
"source": "ソース",
|
||||
"startRecording": "録音開始",
|
||||
"status": "ステータス",
|
||||
"stopRecording": "録音停止",
|
||||
"success": "成功",
|
||||
"systemInfo": "システム情報",
|
||||
"terminal": "ターミナル",
|
||||
@@ -708,17 +697,13 @@
|
||||
"batchCountTooltip": "ワークフロー生成回数",
|
||||
"clear": "ワークフローをクリア",
|
||||
"clipspace": "クリップスペースを開く",
|
||||
"dark": "ダーク",
|
||||
"disabled": "無効",
|
||||
"disabledTooltip": "ワークフローは自動的にキューに追加されません",
|
||||
"execute": "実行",
|
||||
"help": "ヘルプ",
|
||||
"hideMenu": "メニューを隠す",
|
||||
"instant": "即時",
|
||||
"instantTooltip": "生成完了後すぐにキューに追加",
|
||||
"interrupt": "現在の実行を中止",
|
||||
"light": "ライト",
|
||||
"manageExtensions": "拡張機能の管理",
|
||||
"onChange": "変更時",
|
||||
"onChangeTooltip": "変更が行われるとワークフローがキューに追加されます",
|
||||
"refresh": "ノードを更新",
|
||||
@@ -726,9 +711,7 @@
|
||||
"run": "実行する",
|
||||
"runWorkflow": "ワークフローを実行する (Shiftで先頭にキュー)",
|
||||
"runWorkflowFront": "ワークフローを実行する (先頭にキュー)",
|
||||
"settings": "設定",
|
||||
"showMenu": "メニューを表示",
|
||||
"theme": "テーマ",
|
||||
"toggleBottomPanel": "下部パネルを切り替え"
|
||||
},
|
||||
"menuLabels": {
|
||||
@@ -751,7 +734,6 @@
|
||||
"Contact Support": "サポートに連絡",
|
||||
"Convert Selection to Subgraph": "選択範囲をサブグラフに変換",
|
||||
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
|
||||
"Decrease Brush Size in MaskEditor": "マスクエディタでブラシサイズを小さくする",
|
||||
"Delete Selected Items": "選択したアイテムを削除",
|
||||
"Desktop User Guide": "デスクトップユーザーガイド",
|
||||
"Duplicate Current Workflow": "現在のワークフローを複製",
|
||||
@@ -763,7 +745,6 @@
|
||||
"Give Feedback": "フィードバックを送る",
|
||||
"Group Selected Nodes": "選択したノードをグループ化",
|
||||
"Help": "ヘルプ",
|
||||
"Increase Brush Size in MaskEditor": "マスクエディタでブラシサイズを大きくする",
|
||||
"Interrupt": "中断",
|
||||
"Load Default Workflow": "デフォルトワークフローを読み込む",
|
||||
"Manage group nodes": "グループノードを管理",
|
||||
@@ -1118,7 +1099,6 @@
|
||||
"Node Search Box": "ノード検索ボックス",
|
||||
"Node Widget": "ノードウィジェット",
|
||||
"NodeLibrary": "ノードライブラリ",
|
||||
"Notification Preferences": "通知設定",
|
||||
"Pointer": "ポインタ",
|
||||
"Queue": "キュー",
|
||||
"QueueButton": "キューボタン",
|
||||
@@ -1177,6 +1157,7 @@
|
||||
},
|
||||
"showFlatList": "フラットリストを表示"
|
||||
},
|
||||
"themeToggle": "テーマの切り替え",
|
||||
"workflowTab": {
|
||||
"confirmDelete": "このワークフローを削除してもよろしいですか?",
|
||||
"confirmDeleteTitle": "ワークフローを削除しますか?",
|
||||
@@ -1218,113 +1199,81 @@
|
||||
"Flux": "Flux",
|
||||
"Image": "画像",
|
||||
"Image API": "画像API",
|
||||
"LLM API": "LLM API",
|
||||
"Upscaling": "アップスケーリング",
|
||||
"Video": "ビデオ",
|
||||
"Video API": "動画API"
|
||||
},
|
||||
"template": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D",
|
||||
"3d_hunyuan3d_multiview_to_model": "Hunyuan3Dマルチビュー",
|
||||
"3d_hunyuan3d_multiview_to_model_turbo": "Hunyuan3Dターボ",
|
||||
"hunyuan-3d-multiview-elf": "Hunyuan3D マルチビュー",
|
||||
"hunyuan-3d-turbo": "Hunyuan3D ターボ",
|
||||
"hunyuan3d-non-multiview-train": "Hunyuan3D",
|
||||
"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:テキストからモデルへ"
|
||||
},
|
||||
"Area Composition": {
|
||||
"area_composition": "エリア構成",
|
||||
"area_composition_reversed": "エリア構成反転",
|
||||
"area_composition_square_area_for_subject": "主題のためのエリア構成スクエア"
|
||||
},
|
||||
"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_stable_audio_example": "Stable Audio"
|
||||
"stable_audio_example": "Stable Audio"
|
||||
},
|
||||
"Basics": {
|
||||
"default": "画像生成",
|
||||
"embedding_example": "埋め込み",
|
||||
"gligen_textbox_example": "Gligenテキストボックス",
|
||||
"image2image": "画像から画像へ",
|
||||
"inpain_model_outpainting": "InpaintモデルのOutpainting",
|
||||
"inpaint_example": "Inpaint",
|
||||
"inpaint_model_outpainting": "Outpainting",
|
||||
"lora": "LoRA",
|
||||
"lora_multiple": "LoRA複数"
|
||||
"lora": "Lora",
|
||||
"lora_multiple": "Lora複数"
|
||||
},
|
||||
"ControlNet": {
|
||||
"2_pass_pose_worship": "2 Pass Pose Worship",
|
||||
"controlnet_example": "Scribble ControlNet",
|
||||
"controlnet_example": "ControlNet",
|
||||
"depth_controlnet": "Depth ControlNet",
|
||||
"depth_t2i_adapter": "Depth T2Iアダプタ",
|
||||
"mixing_controlnets": "ControlNetsの混合"
|
||||
},
|
||||
"Flux": {
|
||||
"flux_canny_model_example": "Flux Cannyモデル",
|
||||
"flux_depth_lora_example": "Flux Depth LoRA",
|
||||
"flux_dev_checkpoint_example": "Flux Dev fp8",
|
||||
"flux_dev_full_text_to_image": "Flux Devフルテキストから画像へ",
|
||||
"flux_depth_lora_example": "Flux Depth Lora",
|
||||
"flux_dev_checkpoint_example": "Flux Dev",
|
||||
"flux_fill_inpaint_example": "Flux Inpaint",
|
||||
"flux_fill_outpaint_example": "Flux Outpaint",
|
||||
"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_schnell": "Flux Schnell"
|
||||
},
|
||||
"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_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ラージデプス",
|
||||
"sd3_5_simple_example": "SD3.5シンプル",
|
||||
"sdxl_refiner_prompt_example": "SDXLリファイナープロンプト",
|
||||
"sdxl_revision_text_prompts": "SDXLリビジョンテキストプロンプト",
|
||||
"sdxl_revision_zero_positive": "SDXLリビジョンゼロポジティブ",
|
||||
"sd3_5_large_blur": "SD3.5 ラージブラー",
|
||||
"sd3_5_large_canny_controlnet_example": "SD3.5 ラージキャニーコントロールネット",
|
||||
"sd3_5_large_depth": "SD3.5 ラージデプス",
|
||||
"sd3_5_simple_example": "SD3.5 シンプル",
|
||||
"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ターボ"
|
||||
},
|
||||
"Image API": {
|
||||
"api_bfl_flux_1_kontext_max_image": "BFL Flux.1 Kontext Max",
|
||||
"api_bfl_flux_1_kontext_multiple_images_input": "BFL Flux.1 Kontext複数画像入力",
|
||||
"api_bfl_flux_1_kontext_pro_image": "BFL Flux.1 Kontext Pro",
|
||||
"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_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_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テキストから画像へ"
|
||||
},
|
||||
"LLM API": {
|
||||
"api_google_gemini": "Google Gemini:チャット",
|
||||
"api_openai_chat": "OpenAI:チャット"
|
||||
"api-openai-dall-e-2-inpaint": "Dall-E 2 インペイント",
|
||||
"api-openai-dall-e-2-t2i": "Dall-E 2 テキストから画像へ",
|
||||
"api-openai-dall-e-3-t2i": "Dall-E 3 テキストから画像へ",
|
||||
"api_bfl_flux_pro_t2i": "BFL Flux 1.1[pro] Ultra テキストから画像へ",
|
||||
"api_ideogram_v3_t2i": "Ideogram V3 テキストから画像へ",
|
||||
"api_luma_photon_i2i": "Luma Photon 画像から画像へ",
|
||||
"api_luma_photon_style_ref": "Luma Photon スタイル参照",
|
||||
"api_openai_image_1_i2i": "OpenAI Image-1 画像から画像へ",
|
||||
"api_openai_image_1_inpaint": "OpenAI Image-1 インペイント",
|
||||
"api_openai_image_1_multi_inputs": "OpenAI Image-1 複数入力",
|
||||
"api_openai_image_1_t2i": "OpenAI Image-1 テキストから画像へ",
|
||||
"api_recraft_image_gen_with_color_control": "Recraft カラーコントロール画像生成",
|
||||
"api_recraft_image_gen_with_style_control": "Recraft スタイルコントロール画像生成",
|
||||
"api_recraft_vector_gen": "Recraft ベクター生成",
|
||||
"api_stability_sd3_t2i": "Stability AI Stable Image Ultra テキストから画像へ"
|
||||
},
|
||||
"Upscaling": {
|
||||
"esrgan_example": "ESRGAN",
|
||||
@@ -1334,196 +1283,125 @@
|
||||
},
|
||||
"Video": {
|
||||
"hunyuan_video_text_to_video": "Hunyuanビデオテキストからビデオへ",
|
||||
"image_to_video": "SVD画像からビデオへ",
|
||||
"image_to_video_wan": "Wan 2.1画像からビデオへ",
|
||||
"image_to_video": "画像からビデオへ",
|
||||
"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_v2v": "Wan VACEコントロールビデオ",
|
||||
"video_wan_vace_flf2v": "Wan VACEファースト-ラストフレーム",
|
||||
"video_wan_vace_inpainting": "Wan VACEインペインティング",
|
||||
"video_wan_vace_outpainting": "Wan VACEアウトペインティング",
|
||||
"wan2_1_flf2v_720_f16": "Wan 2.1 FLF2V 720p F16",
|
||||
"text_to_video_wan": "Wan 2.1 テキストからビデオへ",
|
||||
"txt_to_image_to_video": "テキストから画像へ、画像からビデオへ",
|
||||
"wan2_1_fun_control": "Wan 2.1 ControlNet",
|
||||
"wan2_1_fun_inp": "Wan 2.1インペインティング"
|
||||
"wan2_1_fun_inp": "Wan 2.1 インペインティング"
|
||||
},
|
||||
"Video API": {
|
||||
"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 Templates:画像から動画へ",
|
||||
"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_hailuo_minimax_i2v": "MiniMax 画像から動画へ",
|
||||
"api_kling_i2v": "Kling 画像から動画へ",
|
||||
"api_luma_i2v": "Luma 画像から動画へ",
|
||||
"api_pika_scene": "Pika シーン: 画像から動画へ",
|
||||
"api_pixverse_t2v": "PixVerse テキストから動画へ",
|
||||
"api_pixverse_template_i2v": "PixVerse Template Effects: 画像から動画へ",
|
||||
"api_veo2_i2v": "Veo2 画像から動画へ"
|
||||
}
|
||||
},
|
||||
"templateDescription": {
|
||||
"3D": {
|
||||
"3d_hunyuan3d_image_to_model": "Hunyuan3D 2.0で単一画像から3Dモデルを生成します。",
|
||||
"3d_hunyuan3d_multiview_to_model": "Hunyuan3D 2.0 MVで複数ビューから3Dモデルを生成します。",
|
||||
"3d_hunyuan3d_multiview_to_model_turbo": "Hunyuan3D 2.0 MV Turboで複数ビューから3Dモデルを生成します。",
|
||||
"stable_zero123_example": "Stable Zero123で単一画像から3Dビューを生成します。"
|
||||
},
|
||||
"3D API": {
|
||||
"api_rodin_image_to_model": "Rodin AIで1枚の写真から高精細3Dモデルを生成します。",
|
||||
"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オブジェクトを作成します。"
|
||||
"hunyuan-3d-multiview-elf": "Hunyuan3D 2mvで複数ビューからモデルを生成します。",
|
||||
"hunyuan-3d-turbo": "Hunyuan3D 2mv turboで複数ビューからモデルを生成します。",
|
||||
"hunyuan3d-non-multiview-train": "Hunyuan3D 2.0で単一ビューからモデルを生成します。",
|
||||
"stable_zero123_example": "単一画像から3Dビューを生成します。"
|
||||
},
|
||||
"Area Composition": {
|
||||
"area_composition": "エリアで画像構成をコントロールします。",
|
||||
"area_composition_square_area_for_subject": "エリア構成で被写体の配置を一貫させます。"
|
||||
"area_composition_reversed": "エリア構成ワークフローを逆転します。",
|
||||
"area_composition_square_area_for_subject": "被写体の配置を一貫させます。"
|
||||
},
|
||||
"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_stable_audio_example": "テキスト説明から音声を生成します。"
|
||||
"stable_audio_example": "テキストの説明から音声を生成します。"
|
||||
},
|
||||
"Basics": {
|
||||
"default": "テキスト説明から画像を生成します。",
|
||||
"embedding_example": "テキスト反転で一貫したスタイルの画像を生成します。",
|
||||
"gligen_textbox_example": "テキストボックスでオブジェクトの位置を正確に指定して画像を生成します。",
|
||||
"default": "テキストの説明から画像を生成します。",
|
||||
"embedding_example": "テキスト反転を使って一貫したスタイルを実現します。",
|
||||
"gligen_textbox_example": "オブジェクトの位置とサイズを指定します。",
|
||||
"image2image": "テキストプロンプトを使って既存の画像を変換します。",
|
||||
"inpain_model_outpainting": "画像を元の境界を超えて拡張します。",
|
||||
"inpaint_example": "画像の特定部分をシームレスに編集します。",
|
||||
"inpaint_model_outpainting": "画像を元の境界を超えて拡張します。",
|
||||
"lora": "LoRAモデルで特定のスタイルやテーマの画像を生成します。",
|
||||
"lora_multiple": "複数のLoRAモデルを組み合わせて画像を生成します。"
|
||||
"lora": "LoRAモデルを適用して特定のスタイルや対象を表現します。",
|
||||
"lora_multiple": "複数のLoRAモデルを組み合わせて独自の結果を得ます。"
|
||||
},
|
||||
"ControlNet": {
|
||||
"2_pass_pose_worship": "ポーズ参照で画像を生成します。",
|
||||
"controlnet_example": "落書き参照画像で画像生成をガイドします。",
|
||||
"depth_controlnet": "深度情報で画像生成をガイドします。",
|
||||
"2_pass_pose_worship": "ポーズ参照から画像を生成します。",
|
||||
"controlnet_example": "参照画像で画像生成をコントロールします。",
|
||||
"depth_controlnet": "深度認識生成で画像を作成します。",
|
||||
"depth_t2i_adapter": "T2Iアダプターで素早く深度認識画像を生成します。",
|
||||
"mixing_controlnets": "複数のControlNetモデルを組み合わせて画像を生成します。"
|
||||
"mixing_controlnets": "複数のControlNetモデルを組み合わせます。"
|
||||
},
|
||||
"Flux": {
|
||||
"flux_canny_model_example": "エッジ検出でFlux画像生成をガイドします。",
|
||||
"flux_depth_lora_example": "深度情報でFlux LoRA画像生成をガイドします。",
|
||||
"flux_dev_checkpoint_example": "Flux Dev fp8量子化版で画像生成。低VRAMデバイス向け、モデルファイル1つでOK、画質はフル版よりやや低め。",
|
||||
"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_depth_lora_example": "深度認識LoRAで画像を生成します。",
|
||||
"flux_dev_checkpoint_example": "Flux開発モデルを使って画像を生成します。",
|
||||
"flux_fill_inpaint_example": "画像の欠損部分を補完します。",
|
||||
"flux_fill_outpaint_example": "Fluxのアウトペイントで画像を拡張します。",
|
||||
"flux_redux_model_example": "参照画像のスタイルを転送し、Fluxで画像生成をガイドします。",
|
||||
"flux_schnell": "Flux Schnellで素早く画像を生成します。"
|
||||
},
|
||||
"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_cosmos_predict2_2B_t2i": "Cosmos-Predict2 2B T2Iで物理的に正確で高精細・高忠実度な画像を生成します。",
|
||||
"image_lotus_depth_v1_1": "ComfyUIでLotus Depthを実行し、高精細な単眼深度推定を実現します。",
|
||||
"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_depth": "SD 3.5で深度情報を使って画像生成をガイドします。",
|
||||
"hidream_e1_full": "HiDream E1で画像を編集します。",
|
||||
"hidream_i1_dev": "HiDream I1 Devで画像を生成します。",
|
||||
"hidream_i1_fast": "HiDream I1で素早く画像を生成します。",
|
||||
"hidream_i1_full": "HiDream I1で画像を生成します。",
|
||||
"sd3_5_large_blur": "SD 3.5でぼかし参照画像から画像を生成します。",
|
||||
"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": "RefinerモデルでSDXL画像を強化します。",
|
||||
"sdxl_revision_text_prompts": "参照画像のコンセプトを転送し、SDXL Revisionで画像生成をガイドします。",
|
||||
"sdxl_revision_zero_positive": "テキストプロンプトと参照画像を組み合わせてSDXL Revisionで画像生成します。",
|
||||
"sdxl_refiner_prompt_example": "SDXLの出力をリファイナーで強化します。",
|
||||
"sdxl_revision_text_prompts": "参照画像からコンセプトを転送し、SDXLで画像生成をガイドします。",
|
||||
"sdxl_revision_zero_positive": "参照画像とテキストプロンプトを組み合わせてSDXLで画像生成をガイドします。",
|
||||
"sdxl_simple_example": "SDXLで高品質な画像を生成します。",
|
||||
"sdxlturbo_example": "SDXL Turboでワンステップ画像生成。"
|
||||
"sdxlturbo_example": "SDXL Turboでワンステップで画像を生成します。"
|
||||
},
|
||||
"Image API": {
|
||||
"api_bfl_flux_1_kontext_max_image": "Flux.1 Kontext max imageで画像を編集します。",
|
||||
"api_bfl_flux_1_kontext_multiple_images_input": "複数画像を入力し、Flux.1 Kontextで編集します。",
|
||||
"api_bfl_flux_1_kontext_pro_image": "Flux.1 Kontext pro imageで画像を編集します。",
|
||||
"api_bfl_flux_pro_t2i": "FLUX.1 Proで優れたプロンプト追従性と画質の画像を生成します。",
|
||||
"api_ideogram_v3_t2i": "Ideogram V3でプロンプト整合性・フォトリアリズム・テキスト描画に優れたプロ品質画像を生成します。",
|
||||
"api-openai-dall-e-2-inpaint": "Dall-E 2 APIで画像のインペイントを行います。",
|
||||
"api-openai-dall-e-2-t2i": "Dall-E 2 APIでテキストの説明から画像を生成します。",
|
||||
"api-openai-dall-e-3-t2i": "Dall-E 3 APIでテキストの説明から画像を生成します。",
|
||||
"api_bfl_flux_pro_t2i": "FLUX.1 [pro]で優れたプロンプト追従性、画質、ディテール、多様な出力の画像を生成します。",
|
||||
"api_ideogram_v3_t2i": "高品質な画像・プロンプト整合性、フォトリアリズム、テキスト描画で画像を生成します。プロ品質のロゴ、ポスター、ランディングページ、商品写真などを作成。複雑な背景や精密なライティング、リアルな環境ディテールで洗練された空間構成を簡単に作成できます。",
|
||||
"api_luma_photon_i2i": "画像とプロンプトを組み合わせて画像生成をガイドします。",
|
||||
"api_luma_photon_style_ref": "Luma Photonでスタイル参照をブレンドし、正確にコントロールします。",
|
||||
"api_openai_dall_e_2_inpaint": "OpenAI Dall-E 2 APIでインペイントを行います。",
|
||||
"api_openai_dall_e_2_t2i": "OpenAI Dall-E 2 APIでテキストから画像を生成します。",
|
||||
"api_openai_dall_e_3_t2i": "OpenAI Dall-E 3 APIでテキストから画像を生成します。",
|
||||
"api_openai_image_1_i2i": "OpenAI GPT Image 1 APIで画像から画像を生成します。",
|
||||
"api_openai_image_1_inpaint": "OpenAI GPT Image 1 APIでインペイントを行います。",
|
||||
"api_openai_image_1_multi_inputs": "OpenAI GPT Image 1 APIで複数入力から画像を生成します。",
|
||||
"api_openai_image_1_t2i": "OpenAI GPT Image 1 APIでテキストから画像を生成します。",
|
||||
"api_recraft_image_gen_with_color_control": "Recraftでカスタムカラーパレットやブランドビジュアルの画像を生成します。",
|
||||
"api_recraft_image_gen_with_style_control": "ビジュアル例でスタイル・位置合わせ・オブジェクト微調整を制御。スタイル保存・共有でブランド一貫性を実現。",
|
||||
"api_recraft_vector_gen": "RecraftのAIベクター生成でテキストから高品質ベクター画像を生成します。",
|
||||
"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": "Stability AI SD3.5で高品質・高プロンプト追従性の画像変換。1メガピクセル対応。",
|
||||
"api_stability_ai_sd3_5_t2i": "Stability AI SD3.5で高品質・高プロンプト追従性の画像を生成。1メガピクセル対応。",
|
||||
"api_stability_ai_stable_image_ultra_t2i": "高品質・高プロンプト追従性の画像を生成。1メガピクセルでプロ用途に最適。"
|
||||
},
|
||||
"LLM API": {
|
||||
"api_google_gemini": "Google GeminiのマルチモーダルAIと推論能力を体験できます。",
|
||||
"api_openai_chat": "OpenAIの高度な言語モデルとインテリジェントな会話を楽しめます。"
|
||||
"api_luma_photon_style_ref": "スタイル参照を正確に適用・ブレンドします。Luma Photonは各参照画像の本質を捉え、異なるビジュアル要素を組み合わせつつプロ品質を維持します。",
|
||||
"api_openai_image_1_i2i": "GPT Image 1 APIで画像から画像を生成します。",
|
||||
"api_openai_image_1_inpaint": "GPT Image 1 APIで画像のインペイントを行います。",
|
||||
"api_openai_image_1_multi_inputs": "GPT Image 1 APIで複数入力を使って画像を生成します。",
|
||||
"api_openai_image_1_t2i": "GPT Image 1 APIでテキストの説明から画像を生成します。",
|
||||
"api_recraft_image_gen_with_color_control": "カスタムパレットを作成して複数画像で再利用したり、各写真の色を手動で選択できます。ブランドのカラーパレットに合わせて独自のビジュアルを作成します。",
|
||||
"api_recraft_image_gen_with_style_control": "ビジュアル例でスタイルを制御し、位置合わせやオブジェクトの微調整が可能です。スタイルを保存・共有してブランドの一貫性を保ちます。",
|
||||
"api_recraft_vector_gen": "テキストプロンプトからRecraftのAIベクター生成でベクター画像を作成します。ロゴ、ポスター、アイコンセット、広告、バナー、モックアップに最適な高品質SVGファイルを生成。アプリやウェブサイト用のブランドベクターイラストを数秒で作成します。",
|
||||
"api_stability_sd3_t2i": "高品質でプロンプト追従性の高い画像を生成します。1メガピクセル解像度でプロ用途に最適です。"
|
||||
},
|
||||
"Upscaling": {
|
||||
"esrgan_example": "ESRGANモデルで画像をアップスケールします。",
|
||||
"hiresfix_esrgan_workflow": "中間ステップでESRGANモデルを使って画像をアップスケールします。",
|
||||
"esrgan_example": "アップスケールモデルで画像品質を向上させます。",
|
||||
"hiresfix_esrgan_workflow": "中間ステップでアップスケールモデルを使用します。",
|
||||
"hiresfix_latent_workflow": "latent空間で画像品質を向上させます。",
|
||||
"latent_upscale_different_prompt_model": "アップスケールしつつ各パスでプロンプトを変更します。"
|
||||
"latent_upscale_different_prompt_model": "アップスケールしつつパスごとにプロンプトを変更します。"
|
||||
},
|
||||
"Video": {
|
||||
"hunyuan_video_text_to_video": "Hunyuanモデルでテキストから動画を生成します。",
|
||||
"image_to_video": "静止画像から動画を生成します。",
|
||||
"image_to_video_wan": "Wan 2.1で画像から動画を生成します。",
|
||||
"ltxv_image_to_video": "静止画像から動画を生成します。",
|
||||
"ltxv_text_to_video": "テキストから動画を生成します。",
|
||||
"mochi_text_to_video_example": "Mochiモデルでテキストから動画を生成します。",
|
||||
"text_to_video_wan": "Wan 2.1でテキストから動画を生成します。",
|
||||
"txt_to_image_to_video": "まず画像を生成し、それを動画に変換します。",
|
||||
"video_cosmos_predict2_2B_video2world_480p_16fps": "Cosmos-Predict2 2B Video2Worldで物理的に正確・高忠実度・一貫性のある動画を生成します。",
|
||||
"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-14Bでテキストから高品質な動画を生成。480p/720p対応。",
|
||||
"video_wan_vace_14B_v2v": "Wan VACEで入力動画や参照画像をコントロールして新しい動画を生成します。",
|
||||
"video_wan_vace_flf2v": "Wan VACEで開始・終了フレームを指定し、滑らかな動画遷移を生成します。",
|
||||
"video_wan_vace_inpainting": "Wan VACEで動画の特定領域を編集し、周囲の内容を保持します。",
|
||||
"video_wan_vace_outpainting": "Wan VACEのアウトペイントで動画サイズを拡張します。",
|
||||
"wan2_1_flf2v_720_f16": "Wan 2.1 FLF2Vで最初と最後のフレームを制御して720p動画を生成します。",
|
||||
"wan2_1_fun_control": "Wan 2.1 ControlNetでポーズ・深度・エッジ制御による動画生成。",
|
||||
"wan2_1_fun_inp": "Wan 2.1で開始・終了フレームから動画を生成します。"
|
||||
"hunyuan_video_text_to_video": "Hunyuanモデルで動画を生成します。",
|
||||
"image_to_video": "画像をアニメーション動画に変換します。",
|
||||
"image_to_video_wan": "画像から素早く動画を生成します。",
|
||||
"ltxv_image_to_video": "静止画像を動画に変換します。",
|
||||
"ltxv_text_to_video": "テキストの説明から動画を生成します。",
|
||||
"mochi_text_to_video_example": "Mochiモデルで動画を作成します。",
|
||||
"text_to_video_wan": "テキストの説明から素早く動画を生成します。",
|
||||
"txt_to_image_to_video": "テキストから画像を生成し、それを動画に変換します。",
|
||||
"wan2_1_flf2v_720_f16": "最初と最後のフレームを制御して動画を生成します。",
|
||||
"wan2_1_fun_control": "ポーズ、深度、エッジ制御などで動画生成をガイドします。",
|
||||
"wan2_1_fun_inp": "開始フレームと終了フレームから動画を作成します。"
|
||||
},
|
||||
"Video API": {
|
||||
"api_hailuo_minimax_i2v": "MiniMaxで画像+テキストからCGI統合の洗練動画を生成します。",
|
||||
"api_hailuo_minimax_t2v": "MiniMaxでテキストから高品質動画を直接生成。CGI効果や多彩なスタイルに対応。",
|
||||
"api_kling_effects": "Klingで画像にビジュアルエフェクトを適用し、ダイナミックな動画を生成します。",
|
||||
"api_kling_flf": "Klingで最初と最後のフレームを制御して動画を生成します。",
|
||||
"api_kling_i2v": "Klingでアクション・表情・カメラワークのプロンプト追従性に優れた動画を生成します。",
|
||||
"api_luma_i2v": "Lumaで静止画像から高品質アニメーションを瞬時に生成します。",
|
||||
"api_luma_t2v": "Lumaでシンプルなプロンプトから高品質動画を生成します。",
|
||||
"api_moonvalley_image_to_video": "Moonvalleyで画像から1080p映画品質動画を生成。学習データは全てライセンス済み。",
|
||||
"api_moonvalley_text_to_video": "Moonvalleyでテキストから1080p映画品質動画を生成。学習データは全てライセンス済み。",
|
||||
"api_pika_i2v": "Pika AIで静止画像から滑らかなアニメ動画を生成します。",
|
||||
"api_pika_scene": "Pika Scenesで複数画像を組み込んだ動画を生成します。",
|
||||
"api_pixverse_i2v": "PixVerseで静止画像から動きやエフェクトのあるダイナミック動画を生成します。",
|
||||
"api_pixverse_t2v": "PixVerseでプロンプト解釈が正確でダイナミックな動画を生成します。",
|
||||
"api_pixverse_template_i2v": "PixVerseで静止画像から動きやエフェクトのあるダイナミック動画を生成します。",
|
||||
"api_runway_first_last_frame": "Runwayで2つのキーフレーム間を滑らかに遷移する動画を生成します。",
|
||||
"api_runway_gen3a_turbo_image_to_video": "Runway Gen3a Turboで静止画像から映画品質動画を生成します。",
|
||||
"api_runway_gen4_turo_image_to_video": "Runway Gen4 Turboで画像からダイナミックな動画を生成します。",
|
||||
"api_hailuo_minimax_i2v": "画像とテキストから洗練された動画を作成。CGI統合や流行のAIハグなどのエフェクトも。多彩なスタイルやテーマから選択可能です。",
|
||||
"api_kling_i2v": "アクション、表情、カメラワークのプロンプト追従性に優れた動画を作成します。複雑なシーケンシャルアクションもサポートし、あなたがシーンの監督になれます。",
|
||||
"api_luma_i2v": "静止画像から瞬時に高品質なアニメーションを作成します。",
|
||||
"api_pika_scene": "複数の画像を素材として使い、それらを組み込んだ動画を生成します。",
|
||||
"api_pixverse_t2v": "プロンプト解釈が正確で、ダイナミックな動画を生成します。",
|
||||
"api_pixverse_template_i2v": "静止画像を動きやエフェクトのあるダイナミックな動画に変換します。",
|
||||
"api_veo2_i2v": "Google Veo2 APIで画像から動画を生成します。"
|
||||
}
|
||||
},
|
||||
@@ -1605,8 +1483,7 @@
|
||||
"title": "ComfyUIへようこそ"
|
||||
},
|
||||
"whatsNewPopup": {
|
||||
"learnMore": "詳細はこちら",
|
||||
"noReleaseNotes": "リリースノートはありません。"
|
||||
"learnMore": "詳細はこちら"
|
||||
},
|
||||
"workflowService": {
|
||||
"enterFilename": "ファイル名を入力",
|
||||
|
||||
@@ -259,10 +259,6 @@
|
||||
"name": "スナップハイライトノード",
|
||||
"tooltip": "有効な入力スロットを持つノードの上にリンクをドラッグすると、ノードがハイライトされます"
|
||||
},
|
||||
"Comfy_Notification_ShowVersionUpdates": {
|
||||
"name": "バージョン更新を表示",
|
||||
"tooltip": "新しいモデルや主要な新機能のアップデートを表示します。"
|
||||
},
|
||||
"Comfy_Pointer_ClickBufferTime": {
|
||||
"name": "ポインタークリックドリフト遅延",
|
||||
"tooltip": "ポインターボタンを押した後、ポインタの動きが無視される最大時間(ミリ秒単位)です。\n\nクリック中にポインタが移動した場合、オブジェクトが意図せず動かされるのを防ぎます。"
|
||||
@@ -326,8 +322,11 @@
|
||||
"Bottom": "下",
|
||||
"Disabled": "無効",
|
||||
"Top": "上"
|
||||
},
|
||||
"tooltip": "メニューバーの位置。モバイルデバイスでは、メニューは常に上部に表示されます。"
|
||||
}
|
||||
},
|
||||
"Comfy_Validation_NodeDefs": {
|
||||
"name": "ノード定義を検証(遅い)",
|
||||
"tooltip": "ノード開発者に推奨されます。これにより、起動時にすべてのノード定義が検証されます。"
|
||||
},
|
||||
"Comfy_Validation_Workflows": {
|
||||
"name": "ワークフローを検証"
|
||||
|
||||